diff --git a/CHANGELOG.md b/CHANGELOG.md index 10d3ebe..ba80057 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,12 +35,15 @@ can change how things behave. It does not only add new things. a 32-byte seed in the node's `identity.key` as you give it. This is the same form that the node uses when it makes its own key. A firmware companion still gets the full 64-byte key, because it accepts only that form. -- **In a chat, a blank line comes after the messages of each author.** Thus the - conversation is easier to follow when many people write. By default, the line shows on - a terminal of 24 rows or more. A smaller terminal, and the 14 rows of the Cardputer Zero, - keep each row for a message. Two new preferences in the Display group control the line: - **Author spacing** (`auto`, `on`, or `off`) and **Spacing from** (the smallest terminal, - in rows, that gets the line with `auto`). +- **A blank line comes before each section.** In each list that has sections (the main + menu, the Messages list, the Preferences page, Device config, and the others), the line + comes before each section heading. In a chat, it comes after the messages of each author + and before each new day. Thus the parts of a screen are easier to find, and a + conversation is easier to follow when many people write. By default, the line shows on a + terminal of 24 rows or more. A smaller terminal, and the 14 rows of the Cardputer Zero, + keep each row for content. The new **Section spacing** preference in the Display group + sets the smallest terminal that gets the line: any height, 20, 24, 30, or 40 rows, or + never. ## [0.11.1] - 2026-10-13 diff --git a/CLAUDE.md b/CLAUDE.md index ff75a02..f90daff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -276,6 +276,14 @@ deliberately, one at a time, and say why in the code. `Separator` is no landmark. Prose written *directly under* a heading (a description of what the section holds) is its preamble and pins with it, in order, as each row scrolls off; prose after the section's first row labels nothing and never pins. + A heading is also *spaced*: a blank line goes before it wherever the terminal is at + least `section_spacing_min_rows` tall (one preference, read through + `Screen.section_spacing`, which the chat's author and day breaks read too) β€” never at + the top, after a blank line, or under another heading or a column header. So don't + hand-place a `Separator(" ")` before a heading for space: it would show where the + preference says no. A blank separator there is only ever the seam between two + *different* lists, each with its own icon column (the region editor's table and its + actions); the heading then adds no second line. - Label + description command rows go through `menu_rows` (two cell-aligned lanes, description muted). Editor rows (setting/value/description) go through `lane_row`. - A row that opens further prompts ends with `…`; a row that acts immediately doesn't. diff --git a/docs/cli/README.md b/docs/cli/README.md index 9f97b9a..2a799cc 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -1727,34 +1727,33 @@ If you give no subcommand, it runs `show`. ```console $ meshterm preferences show -PREFERENCE VALUE DEFAULT DESCRIPTION -set_clock_on_connect off off Set the device clock from this computer when it connects -trace_cooldown_s 5 5 Wait this long before transmitting again -flood_advert_cooldown_s 60 60 Extra wait before another mesh-wide advert -weekly_flood_advert off off Flood an advert after a week without one -advert_quiet_s 30 30 Silence to wait for before the weekly advert -direct_message_soft_retries 0 0 Times to resend a message that gets no reply -room_relogin_minutes 30 30 Opening a room this quiet logs in again -tx_opt_min 18 18 Weakest power it will try -tx_opt_max 28 28 Strongest power it will try -tx_snr_tolerance_db 1 1 Signals this close are a tie, so less power wins -watch_silence_hours 12 12 Warn when a watched node goes quiet this long -watch_alerts_kept 200 200 How many past alerts to keep -map_view_fraction 0.5 0.5 Share of nodes to fit on screen; 1 shows them all -basemap_tilejson_url https://tiles.openfreemap.org/planet https://tiles.openfreemap.org/planet Where map images come from (next launch) -history_days 365 365 Older ones are deleted; 0 keeps everything -chat_history_limit 200 200 Old messages to load when you open a chat -courier_history_kept 100 100 Finished outbox messages to keep -cli_time_format relative relative Whether the command line prints ages or timestamps -fast_render on on Faster screen updates; turn off if it looks wrong (next launch) -full_width auto auto Use the extra column some terminals hide -color_depth auto auto How many colours to send this terminal (next launch) -console_setup auto auto Move to a better terminal when this console can't draw MeshTerm -console_font 6x12 6x12 Bigger text, or more rows on the handheld's panel -name_overlap_days 7 7 Grey a name two nodes used within this many days -chat_author_spacing auto auto A blank line after each author in a chat -chat_author_spacing_min_rows 24 24 With auto: the smallest terminal that gets it -log_level WARNING WARNING How much MeshTerm writes to its log file (next launch) +PREFERENCE VALUE DEFAULT DESCRIPTION +set_clock_on_connect off off Set the device clock from this computer when it connects +trace_cooldown_s 5 5 Wait this long before transmitting again +flood_advert_cooldown_s 60 60 Extra wait before another mesh-wide advert +weekly_flood_advert off off Flood an advert after a week without one +advert_quiet_s 30 30 Silence to wait for before the weekly advert +direct_message_soft_retries 0 0 Times to resend a message that gets no reply +room_relogin_minutes 30 30 Opening a room this quiet logs in again +tx_opt_min 18 18 Weakest power it will try +tx_opt_max 28 28 Strongest power it will try +tx_snr_tolerance_db 1 1 Signals this close are a tie, so less power wins +watch_silence_hours 12 12 Warn when a watched node goes quiet this long +watch_alerts_kept 200 200 How many past alerts to keep +map_view_fraction 0.5 0.5 Share of nodes to fit on screen; 1 shows them all +basemap_tilejson_url https://tiles.openfreemap.org/planet https://tiles.openfreemap.org/planet Where map images come from (next launch) +history_days 365 365 Older ones are deleted; 0 keeps everything +chat_history_limit 200 200 Old messages to load when you open a chat +courier_history_kept 100 100 Finished outbox messages to keep +cli_time_format relative relative Whether the command line prints ages or timestamps +fast_render on on Faster screen updates; turn off if it looks wrong (next launch) +full_width auto auto Use the extra column some terminals hide +color_depth auto auto How many colours to send this terminal (next launch) +console_setup auto auto Move to a better terminal when this console can't draw MeshTerm +console_font 6x12 6x12 Bigger text, or more rows on the handheld's panel +name_overlap_days 7 7 Grey a name two nodes used within this many days +section_spacing_min_rows 24 24 A blank line between sections and between chat authors +log_level WARNING WARNING How much MeshTerm writes to its log file (next launch) ``` **`DESCRIPTION`** says what each preference does, because the name of a key does not give diff --git a/meshterm/core/preferences.py b/meshterm/core/preferences.py index 4556283..f561253 100644 --- a/meshterm/core/preferences.py +++ b/meshterm/core/preferences.py @@ -173,9 +173,21 @@ _QUIET_CHOICES: dict[int, str] = {s: f"{s} s" for s in QUIET_CHOICES_S} #: column, and for the cost when MeshTerm reclaims a column that is not hidden. _WIDTH_CHOICES: dict[str, str] = {"auto": "auto", "yes": "yes", "no": "no"} -#: When a chat puts a blank line after the messages of each author. ``auto`` puts it on a -#: terminal that has at least ``chat_author_spacing_min_rows`` rows. -_SPACING_CHOICES: dict[str, str] = {"auto": "auto", "on": "on", "off": "off"} +#: The smallest terminal, in rows, that gets a blank line before each section. The two +#: ends are numbers too, so that one comparison (``rows >= value``) gives each choice: 0 is +#: lower than each terminal, and :data:`_SPACING_NEVER` is higher than each terminal. The +#: steps between them fall between the heights that MeshTerm knows: the 14 rows of the +#: Cardputer Zero, a standard terminal of 24, the 26 and 40 rows of the two console fonts of +#: the PicoCalc, and a tall window on a desktop. +_SPACING_NEVER = 10_000 +_SPACING_CHOICES: dict[int, str] = { + 0: "any height", + 20: "20+ rows", + 24: "24+ rows", + 30: "30+ rows", + 40: "40+ rows", + _SPACING_NEVER: "never", +} #: How many colours to send to the terminal. ``auto`` is the honest default, and it is the #: only value that is not a depth. It means "take prompt_toolkit's verdict unless we can @@ -509,30 +521,19 @@ PREFERENCES: tuple[PrefSpec, ...] = ( unit="days", ), PrefSpec( - key="chat_author_spacing", - label="Author spacing", - help="A blank line after each author in a chat", + key="section_spacing_min_rows", + label="Section spacing", + help="A blank line between sections and between chat authors", group="Display", value_type="enum", - # Auto, because the line costs one row for each group of messages. A tall terminal - # has the rows, and the 14 rows of the Cardputer Zero do not: there a blank line is - # a message less on the screen. The test is the real height of the terminal, so a - # small window on a desktop drops the line too (JP, 2026-10-13). - default="auto", - choices=_SPACING_CHOICES, - ), - PrefSpec( - key="chat_author_spacing_min_rows", - label="Spacing from", - help="With auto: the smallest terminal that gets it", - group="Display", - value_type="int", - # A standard terminal (24 rows) and the PicoCalc (26) get the line. The Cardputer - # Zero (14) and a smaller window do not (JP, 2026-10-13). + # The line costs one row for each section. A standard terminal (24 rows) and the + # PicoCalc (26) have the rows. The 14 rows of the Cardputer Zero do not: there a + # blank line is a row less of the list. The test is the real height of the + # terminal, so a small window on a desktop drops the line too (JP, 2026-10-13). It + # applies to each section heading of a list, to the day dividers of a chat, and to + # the messages of each author in a chat (JP, 2026-10-15). default=24, - minimum=10, - maximum=100, - unit="rows", + choices=_SPACING_CHOICES, ), # --- Diagnostics --------------------------------------------------------------- PrefSpec( diff --git a/meshterm/ui/chat.py b/meshterm/ui/chat.py index fb337b8..d31133a 100644 --- a/meshterm/ui/chat.py +++ b/meshterm/ui/chat.py @@ -25,7 +25,7 @@ from rich.cells import cell_len from rich.console import Group, RenderableType from rich.text import Text -from ..core import links, preferences +from ..core import links from ..core.channels import MENTION, split_channel_sender from ..core.connection import ContactNotOnDeviceError from ..core.events import EventKind, MeshEvent @@ -84,21 +84,14 @@ _URL_GUTTER = 2 _URL_CODES = False -#: The rows of the terminal when the session cannot say (a stand-in session with no -#: ``terminal_rows``): the design minimum of the platform (``Platform.readable_rows``). -_FALLBACK_ROWS = 24 - - @on_platform def _bind(platform: Platform) -> None: """Bind the transcript to the platform (now and at each switch). - The platform says whether codes hang under URLs, and how many rows to assume when the - session cannot tell the height of the terminal. + The platform says whether codes hang under URLs. """ - global _URL_CODES, _FALLBACK_ROWS + global _URL_CODES _URL_CODES = platform.url_codes - _FALLBACK_ROWS = platform.readable_rows #: The maximum width of a footer hint, in cells (refer to the UX standards). @@ -594,7 +587,7 @@ class ChatScreen(Screen): messages = self._messages total = len(messages) cache = self._render_cache - spacing = self._author_spacing() + spacing = self.section_spacing() if cache is not None and cache["width"] == width and cache["spacing"] == spacing: cached_ids = cache["ids"] cached_acked = cache["acked"] @@ -640,9 +633,11 @@ class ChatScreen(Screen): and cached_ids[idx] == id(message) and cached_acked[idx] == message.acked ) - # A blank line closes the messages of an author, where the preferences ask for - # it (:meth:`_author_spacing`). It is the first line of the slice of the message - # that starts the next group, so a cached slice brings it back with it. + # A blank line closes the messages of an author and the messages of a day, where + # the preferences ask for it (:meth:`~meshterm.ui.tui.screen.Screen.section_spacing`, + # the same test as the section headings of a list). It is the first line of the + # slice of the message that starts the next group, so a cached slice brings it + # back with it. gap = spacing and bool(lines) and (new_day or group != prev_group) if reusable: start = cached_boundaries[idx - 1] if idx > 0 else 0 @@ -696,24 +691,6 @@ class ChatScreen(Screen): } return lines - def _author_spacing(self) -> bool: - """Whether a blank line closes the messages of each author, at this paint. - - The ``chat_author_spacing`` preference decides: ``on`` and ``off`` are fixed, and - ``auto`` puts the line on a terminal of ``chat_author_spacing_min_rows`` rows or - more. The line costs one row for each group of messages, so a short terminal (the - Cardputer Zero, or a small window) keeps the transcript dense. The test reads the - rows of the terminal before the paint, and reads the preferences at each paint, - so a change on the Preferences page applies when the user comes back. - """ - prefs = preferences.current() - mode = prefs.chat_author_spacing - if mode != "auto": - return mode == "on" - rows = getattr(self._session, "terminal_rows", None) - height = rows() if callable(rows) else _FALLBACK_ROWS - return height >= prefs.chat_author_spacing_min_rows - def _sender_and_body(self, message: ChatMessage) -> tuple[str, str]: """Return the display sender and the clean body of a message. diff --git a/meshterm/ui/courier_screen.py b/meshterm/ui/courier_screen.py index 3f3d669..c0ef7a3 100644 --- a/meshterm/ui/courier_screen.py +++ b/meshterm/ui/courier_screen.py @@ -281,7 +281,6 @@ def _menu_items(ctx: AppContext, entries: list[QueuedMessage]) -> list: items.append(Choice(marked_label("πŸ“¨", "Queue a message…", "", lane=lane), _QUEUE)) if done: - items.append(Separator(" ")) items.append(section_heading("Finished")) for message in done[:15]: items.append(Choice(lambda m=message: _done_row(m, lane), ("msg", message.ident))) diff --git a/meshterm/ui/menus.py b/meshterm/ui/menus.py index 5474461..29b04b7 100644 --- a/meshterm/ui/menus.py +++ b/meshterm/ui/menus.py @@ -657,8 +657,13 @@ def section_heading(label: str) -> Separator: Grey, never the accent of the frame: a rule in the colour of the border looks like a part of the frame, not like a heading in it. + + The heading is also *spaced*: a blank line comes before it where the + ``section_spacing_min_rows`` preference asks for one (refer to + :attr:`~meshterm.ui.tui.select.Separator.spaced`). Thus a list does not put a blank + separator before a heading for space. """ - return Separator(f"── {command_label(label)} ──", style="heading", heading=True) + return Separator(f"── {command_label(label)} ──", style="heading", heading=True, spaced=True) def fit_cells(text: str, width: int, *, align: str = "left") -> str: diff --git a/meshterm/ui/region_editor.py b/meshterm/ui/region_editor.py index 5521369..98bea09 100644 --- a/meshterm/ui/region_editor.py +++ b/meshterm/ui/region_editor.py @@ -237,7 +237,9 @@ def region_items(node: Contact, table: RegionTable, unsaved: int) -> tuple[str, # A blank line between the table and the commands of the page, because they are two lists # and not one. The first row of the tree is the wildcard. Code that measures icon # columns reads its name ``*`` as a leading mark. But the name is data, and it does not - # belong to a column that the actions keep. + # belong to a column that the actions keep. This blank line is the seam between two + # lists, not the space of a section. It stays when the section spacing is off, and the + # list adds no second line after it (Separator.spaced). items.append(Separator(" ")) items.append(section_heading("Actions")) items.extend( diff --git a/meshterm/ui/timemachine_screen.py b/meshterm/ui/timemachine_screen.py index 1a32156..e8decee 100644 --- a/meshterm/ui/timemachine_screen.py +++ b/meshterm/ui/timemachine_screen.py @@ -63,7 +63,7 @@ from .scopering import ScopeKey, ring, scope_atom, scope_chip, scope_key, step from .theme import name_style, snr_style from .tui.render import render_lines from .tui.screen import CANCEL, Screen -from .tui.select import Choice, Separator +from .tui.select import Choice from .widgets import ( ContactsSort, _recency_style, @@ -1504,7 +1504,6 @@ class TimeMachinePickerScreen(ContactListScreen): prompt=prompt, lead=[ Choice(command_label("🌐 The whole mesh β€” days, arrivals, the ledger"), MESH), - Separator(" "), section_heading("Nodes"), ], ) diff --git a/meshterm/ui/tui/frame.py b/meshterm/ui/tui/frame.py index f2f5d8b..0986e03 100644 --- a/meshterm/ui/tui/frame.py +++ b/meshterm/ui/tui/frame.py @@ -456,6 +456,7 @@ def compose_base( # (refer to :class:`~meshterm.ui.tui.screen.ListWindow`) can size its chrome to the # frame into which it will be sliced. base.note_viewport(viewport) + base.note_terminal_rows(rows) body_lines = base.render_body(cols - panel_inset(base.flush)) visible, more_above, more_below = _visible_slice(base, body_lines, viewport) # The panel wrap is a pure function of what is between its borders. Thus cache it, @@ -484,6 +485,7 @@ def compose_base( # the Panel. The body gets the full terminal width and one more row. viewport = max(1, rows - header_h - 1 - 1) # minus footer(1) and title bar(1) base.note_viewport(viewport) + base.note_terminal_rows(rows) body_lines = base.render_body(cols) visible, more_above, more_below = _visible_slice(base, body_lines, viewport) status = None if platform.header_row else header @@ -645,6 +647,7 @@ def compose_startup(screen: Screen, cols: int, rows: int) -> str: # measure). It is never wider than the terminal, and never narrower than the title or the # hint that it must show. probe = max(10, min(cols - 6, 100)) + screen.note_terminal_rows(rows) probe_lines = screen.render_body(probe) measured = max((_ansi_width(line) for line in probe_lines), default=10) # Size to the widest footer possible, not to the footer of this frame. A screen whose @@ -755,6 +758,7 @@ def compose_bare(screen: Screen, cols: int, rows: int) -> str: # share screen fits its code to the rows). Thus the height is recorded before the body # is requested, not after, as the composers with a frame also do. screen.note_viewport(rows) + screen.note_terminal_rows(rows) body_lines = screen.render_body(cols) while body_lines and not _ansi_width(body_lines[-1]): body_lines.pop() # a trailing blank row pushes the block off its anchor @@ -783,6 +787,7 @@ def _dialog_layout(screen: Screen, cols: int, rows: int) -> tuple[int, int, int, # with a list window (the path composer) can size to what it can use. After that, the # slice records the real viewport, sized to the body. screen.note_viewport(budget) + screen.note_terminal_rows(rows) body_lines = screen.render_body(max_w - 4) # A grow-only screen (the packet viewer, when it pages between packets) sizes to the # tallest body that it has shown, not to this body. Thus a shorter body keeps the larger diff --git a/meshterm/ui/tui/screen.py b/meshterm/ui/tui/screen.py index bf23ca1..56669b4 100644 --- a/meshterm/ui/tui/screen.py +++ b/meshterm/ui/tui/screen.py @@ -155,6 +155,9 @@ class Screen: #: first and the last row, and the page follows to its ends. It is off where these keys #: move a text caret instead: the compose line of the chat, the field of a prompt. home_end_jumps: bool = True + #: The rows of the terminal at the last paint, or ``None`` before the first paint. The + #: frame records it before it asks for the body (refer to :meth:`note_terminal_rows`). + _terminal_rows: int | None = None @property def fkey_lane(self): @@ -537,6 +540,38 @@ class Screen: """Record only the viewport height (for callers that do not know the body total).""" self._scroll_viewport = max(1, viewport) + def note_terminal_rows(self, rows: int) -> None: + """Record the rows of the terminal, before the body of this paint renders. + + The frame calls this method, together with :meth:`note_viewport`, for each type of + frame. :meth:`section_spacing` reads it. The rows must come before the body: rows + recorded after a paint come one paint late, and the first paint of a screen then + comes out without its blank lines and then jumps. + """ + self._terminal_rows = rows + + def section_spacing(self) -> bool: + """Whether a blank line comes before each section of the page, at this paint. + + The ``section_spacing_min_rows`` preference decides. A terminal of that many rows + or more gets the line. The line costs one row for each section, so a short + terminal (the Cardputer Zero, or a small window) keeps its rows for content. A + screen that the frame has not painted yet uses the design minimum of the platform + (:attr:`~meshterm.platforms.Platform.readable_rows`). The preference is read at + each paint, so a change on the Preferences page applies when the user comes back. + + One rule for each place that has sections: the section headings of a select list + (refer to :attr:`~meshterm.ui.tui.select.Separator.spaced`), and the day dividers + and the authors of a chat. + """ + from ...core.preferences import current as current_preferences + from ...platforms import get_platform + + rows = self._terminal_rows + if rows is None: + rows = get_platform().readable_rows + return rows >= current_preferences().section_spacing_min_rows + @property def _page_step(self) -> int: """The lines that PageUp/PageDown move: the viewport, less one line for continuity.""" diff --git a/meshterm/ui/tui/select.py b/meshterm/ui/tui/select.py index 3fe9f08..33d86c7 100644 --- a/meshterm/ui/tui/select.py +++ b/meshterm/ui/tui/select.py @@ -255,6 +255,16 @@ class Separator: nothing, it takes a content line, and it hides the heading above it. It is set through :func:`~meshterm.ui.menus.section_heading`, or by hand on a column header that is the only landmark of its block. + spaced: Whether a blank line comes before this row, when the + ``section_spacing_min_rows`` preference asks for one at this paint (refer to + :meth:`~meshterm.ui.tui.screen.Screen.section_spacing`). It is set through + :func:`~meshterm.ui.menus.section_heading`, so that each section of each list + gets the same space. A list does not put a blank separator before a heading + for space, because then the space shows also where the preference says no. A + blank separator there is only the seam between two different lists, which + keep their own icon columns (the table and the actions of the region editor). + The line never comes at the top of the list, after a blank line, or under + another heading or a column header, because there it separates nothing. pans: The column header of a table that ←→ pan as one unit (refer to :attr:`Choice.pans`). It is drawn in its fullest form, and it slides with the rows under it. Thus each label stays above its lane. Its layout is the same as a @@ -267,6 +277,7 @@ class Separator: style: str = "muted" pinned: bool = False heading: bool = False + spaced: bool = False pans: bool = False def text(self, width: int) -> str | Text: @@ -288,6 +299,23 @@ _UNBOUNDED = 10_000 _POINTER = 2 +def _spaces(previous: Choice | Separator | None) -> bool: + """Whether a blank line before a section separates it from ``previous``. + + The line separates a section from the content above it: a row, or a note. At the top + of the list there is nothing to separate. After a blank line, the space is already + there. Under a heading or a column header, the heading belongs with the rows below it. + """ + if previous is None: + return False + if isinstance(previous, Choice): + return True + if previous.heading or previous.pinned: + return False + title = previous.title + return callable(title) or bool(str(title).strip()) + + def _pans(items: list) -> bool: """Whether one of ``items`` is a table row or a header that pans (:attr:`Choice.pans`).""" return any(getattr(item, "pans", False) for item in items) @@ -830,7 +858,15 @@ class SelectScreen(Screen): # Thus the code follows the line of the highlight while it draws the rows, and does # not calculate it from the row index. cursor_at: int | None = None + spacing = self.section_spacing() + previous: Choice | Separator | None = None # the row drawn before this one, if any for item in rows: + if isinstance(item, Separator) and item.spaced and spacing and _spaces(previous): + # The blank line before a section. It is not an item, so it never joins + # the block of the heading above it, and the heading still pins at its + # own line. + lines.append("") + previous = item if isinstance(item, Separator): # A Text title has its own spans (a column header with two colours). A plain # string is drawn all in the style of the separator. A separator scrolls diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index 70d4a8f..14fb906 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -2056,15 +2056,6 @@ class TuiSession: size = self._app.output.get_size() # type: ignore[union-attr] return max(20, size.columns), max(6, size.rows) - def terminal_rows(self) -> int: - """The rows of the terminal now. A screen reads it before its paint, never after. - - The chat uses it for the space between authors (``chat_author_spacing``). The rows - that the frame records after a paint come one paint late, so the first paint of a - chat would come out without the space and then jump. - """ - return self._size()[1] - def base_body_size(self) -> tuple[int, int]: """Return the ``(width, height)`` in cells that the body of the base screen can use. diff --git a/meshterm/ui/watchtower_screen.py b/meshterm/ui/watchtower_screen.py index 43a4453..78479fd 100644 --- a/meshterm/ui/watchtower_screen.py +++ b/meshterm/ui/watchtower_screen.py @@ -220,7 +220,6 @@ def _menu_items( if acked: items.append(Choice(marked_label("πŸ—‘", "Clear acknowledged", "err", lane=lane), _CLEAR)) - items.append(Separator(" ")) items.append(section_heading("Watched nodes")) if not watched: items.append(Separator(" none starred yet β€” silence and SNR rules need one")) diff --git a/tests/test_chat.py b/tests/test_chat.py index 91c0554..b77c620 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -41,17 +41,12 @@ from tests.conftest import plain as _strip_ansi # the only strip-and-join scree class _StubSession: """A session stand-in that counts the requests for a paint.""" - def __init__(self, rows: int = 24) -> None: + def __init__(self) -> None: self.invalidations = 0 - self.rows = rows def invalidate(self) -> None: self.invalidations += 1 - def terminal_rows(self) -> int: - """The rows of the terminal, for the space between authors.""" - return self.rows - def run_detached(self, work): # noqa: ANN001, ANN201 """Start the flow of a key handler as a task, as ``TuiSession.run_detached`` does.""" return asyncio.ensure_future(work) @@ -1557,13 +1552,14 @@ def test_channel_own_messages_do_not_merge_with_remote_namesake() -> None: def test_a_blank_line_closes_each_author_where_the_rows_allow( monkeypatch: pytest.MonkeyPatch, ) -> None: - """With ``auto``, a terminal of 24 rows or more puts a blank line after each author. + """By default, a terminal of 24 rows or more puts a blank line after each author. The blank line also comes before the divider of a new day, and never before the first line. A terminal of fewer rows (the 14 of the Cardputer Zero) has no row to spare for it. A second paint reads the cached lines, and the divider of the second day still pins - at its own line, not at the blank line before it. ``on`` and ``off`` ignore the rows, - and a change applies at the next paint. + at its own line, not at the blank line before it. The two ends of the + ``section_spacing_min_rows`` preference ignore the rows, and a change applies at the + next paint. """ from datetime import datetime, timedelta, timezone @@ -1584,7 +1580,8 @@ def test_a_blank_line_closes_each_author_where_the_rows_allow( prefs = preferences.Preferences() monkeypatch.setattr(preferences, "current", lambda: prefs) - screen = _channel_screen(transcript(), session=_StubSession(rows=24)) + screen = _channel_screen(transcript()) + screen.note_terminal_rows(24) lines = [_strip_ansi(line) for line in screen._render_grouped(53)] assert lines[0].startswith("──") and lines[1] == "Alice" assert lines[4] == "" and lines[5] == "Bob" # after the two messages of Alice @@ -1594,16 +1591,14 @@ def test_a_blank_line_closes_each_author_where_the_rows_allow( screen._render_grouped(53) # the cached lines assert screen._sticky_headers == first and [at for at, _ in first] == [0, 8] - short = _channel_screen(transcript(), session=_StubSession(rows=14)) + short = _channel_screen(transcript()) + short.note_terminal_rows(14) assert "" not in [_strip_ansi(line) for line in short._render_grouped(53)] - prefs.set("chat_author_spacing", "on") # always, also on 14 rows + prefs.set("section_spacing_min_rows", 0) # any height, also 14 rows assert "" in [_strip_ansi(line) for line in short._render_grouped(53)] - prefs.set("chat_author_spacing", "off") # never, and the cached lines change too + prefs.set("section_spacing_min_rows", 10_000) # never, and the cached lines change too assert "" not in [_strip_ansi(line) for line in screen._render_grouped(53)] - prefs.set("chat_author_spacing", "auto") - prefs.set("chat_author_spacing_min_rows", 14) - assert "" in [_strip_ansi(line) for line in short._render_grouped(53)] def test_a_mention_of_our_name_takes_our_chip_with_no_star(powerline) -> None: diff --git a/tests/test_gallery.py b/tests/test_gallery.py index e921c28..04a6ed7 100644 --- a/tests/test_gallery.py +++ b/tests/test_gallery.py @@ -147,9 +147,6 @@ class _GallerySession: def base_body_size(self) -> tuple[int, int]: return self._cols, self._rows - def terminal_rows(self) -> int: - return self._rows - async def button_dialog(self, prompt, buttons, **kwargs): # noqa: ANN001 return None diff --git a/tests/test_tui.py b/tests/test_tui.py index 8ea3623..a6bf0e2 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -3476,6 +3476,62 @@ def test_a_lazy_body_slices_frames_and_scrolls_exactly_as_a_list_did() -> None: assert "── Group ──" in plain, "the section heading pins once it scrolls off" +def test_a_blank_line_comes_before_each_section_where_the_rows_allow(monkeypatch) -> None: # noqa: ANN001 + """A terminal of 24 rows or more puts a blank line before each section heading. + + The line separates a section from a row or a note above it. It never comes at the top, + after a blank separator, or under another heading, because there it separates nothing. + The heading pins at its own line, not at the blank line. The frame records the rows + before the body renders, so the first paint is already correct, and a terminal of 14 + rows (the Cardputer Zero) gets no line. The ``section_spacing_min_rows`` preference + moves the limit, and a change applies at the next paint. + """ + from meshterm.core import preferences + + prefs = preferences.Preferences() + monkeypatch.setattr(preferences, "current", lambda: prefs) + items = [ + section_heading("A"), + Choice("alpha", 1), + Separator(" a note"), + section_heading("B"), + Separator(" "), + section_heading("C"), + Choice("gamma", 3), + section_heading("D"), + section_heading("E"), + Choice("epsilon", 5), + ] + screen = SelectScreen("Sections", items) + screen.floating = False + + def body(rows: int) -> list[str]: + frame.compose_base(Text("hdr"), screen, "Esc back", 53, rows) + return [_plain(line).strip() for line in screen.render_body(53)] + + assert body(26) == [ + "── A ──", + "❯ alpha", + "a note", + "", + "── B ──", + "", + "── C ──", + "gamma", + "", + "── D ──", + "── E ──", + "epsilon", + ] + assert [at for at, _ in screen._sticky_headers] == [0, 4, 6, 9, 10] + assert body(14).count("") == 1 # only the blank separator of the list itself + + prefs.set("section_spacing_min_rows", 0) # any height + assert body(14).count("") == 3 + prefs.set("section_spacing_min_rows", 10_000) # never + assert body(26).count("") == 1 + + def test_an_off_screen_rows_live_title_is_left_alone() -> None: """MeshTerm resolves a callable title of a row only while the row is on the screen.""" calls: list[str] = []