diff --git a/CLAUDE.md b/CLAUDE.md index 6e6143a..9a1e7f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -170,6 +170,17 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow A more specific committing verb (`Enter adopt path`, `Enter add`) is fine; a synonym of the generic three (`pick`, `choose`, `commit`) is not. The filter atom is always `type to filter`. +- A hint is drawn **once per frame**, and which surface draws it is the footer's call. + Where the footer is the hint line (regular) it carries the *top* screen's hint, so a + floating dialog's border stays silent — the same sentence in the border and at the + bottom of the terminal was one of them wasted — and its clip arrows fall back to the + base frame's `↑↓ more`. Where the footer is the F-key lane (picocalc) there is no hint + line, so the border is the only place Enter/Esc/the arrows can be named and it keeps + the hint, less every atom whose keys are all chips on the lane one row below + (`fkeys.strip_lane_atoms` via `frame._dialog_hint`, resolved **per paint** — a screen + rewrites its hint as its content changes and reads its lane fresh every frame). The + chromeless splash has no footer row of any kind and keeps its hint in its own border on + both platforms. - Key notation: `↑↓ move` (include the verb), `^R`/`^End` for Ctrl chords, `⌫` for backspace, `⇧` for shift. diff --git a/meshterm/ui/tui/fkeys.py b/meshterm/ui/tui/fkeys.py index 924e6a6..3c3718b 100644 --- a/meshterm/ui/tui/fkeys.py +++ b/meshterm/ui/tui/fkeys.py @@ -208,3 +208,97 @@ def lane_text(lane: Lane, *, shifted: bool = False) -> Text: # key is for once it becomes available. row.append(_chip(number, label), style=fill if live else "muted") return row + + +#: The screen action each key notation in a footer hint dispatches — the vocabulary the +#: hint line and the lane have in common, and the only place the two can say the same +#: thing twice. Arrows, Enter, Esc and the letter chords are absent deliberately: no lane +#: slot ever claims them (Enter and Esc are barred outright), so an atom naming one can +#: never be a duplicate of a chip. +_HINT_KEY_ACTIONS: dict[str, str] = { + "PgUp": "pageup", + "PgDn": "pagedown", + "Home": "home", + "End": "end", + "^PgUp": "ctrl_pageup", + "^PgDn": "ctrl_pagedown", + "^Home": "ctrl_home", + "^End": "ctrl_end", + "Tab": "tab", +} + +#: Marks a word as *some* key notation even where it isn't one this module can map: a +#: chord (``^U``), a shifted key (``⇧Tab``), or a bare glyph key (``⌫``). An atom whose +#: verb half carries one of these names more than the keys it opened with, so it is never +#: dropped on the strength of the ones it did name (the map's ``Home/^U region/you``). +_KEYISH = ("^", "⇧", "⌫", "↑", "↓", "←", "→") + + +def _hint_keys(atom: str) -> tuple[str, ...]: + """The key notations ``atom`` opens with, or ``()`` where it names something else too. + + A hint atom is *keys then verb* — ``PgUp/PgDn scroll``, ``Home/End ends`` — so the + keys are the leading run of words whose every ``/``-separated part this module knows. + The run stops at the verb, and the answer is empty (meaning "keep this atom") both + when the atom opens with a key we can't map (``↑↓ PgUp/PgDn scroll`` documents the + arrows too) and when anything after the run still looks like a key notation + (:data:`_KEYISH`) — dropping such an atom would take an unrepresented key with it. + """ + keys: list[str] = [] + verb = False # past the leading key run: everything from here is the atom's verb half + for word in atom.split(): + parts = word.split("/") + mappable = all(part in _HINT_KEY_ACTIONS for part in parts) + if not verb and mappable: + keys.extend(parts) + continue + verb = True + if mappable or any(mark in word for mark in _KEYISH): + return () # a second key rides in the verb half — the atom says more than these + return tuple(keys) + + +def strip_lane_atoms(hint: str, lane: Lane) -> str: + """``hint`` with every atom the lane already advertises removed. + + Where both a hint line and the lane are drawn at once — a floating dialog on the + PicoCalc, whose box carries the hint in its border while the frame's footer row below + carries *its* lane — an atom whose keys are all chips is the same statement twice, in + two notations, and the cells are the dialog's to spend on what the lane doesn't say. + An atom survives unless **every** key it names is a slot's action: a lane covering + only half of ``Home/End ends`` leaves the whole atom standing rather than telling half + a truth. + + Dimmed slots count as covering. A dim chip keeps its label and means *a thing here, + just not right now* (see the module docstring) — the reader has been told where the + action lives either way, which is all the hint atom was for. + + Runs per paint, never once at construction: a screen rewrites its hint as its content + changes (the packet viewer drops to a bare ``Esc close`` on a single packet) and reads + its lane fresh on every frame, so the overlap between the two is a property of *this* + paint. + + Args: + hint: The screen's footer hint, in the standard ``a · b · Esc x`` grammar. + lane: The lane drawn on the same frame. + + Returns: + The kept atoms, rejoined in order; ``""`` when the lane says all of it. + """ + covered = { + action + for pair in lane + if pair is not None + for action in (pair.action, pair.opp_action) + if action + } + kept = [] + for raw in hint.split("·"): + atom = raw.strip() + if not atom: + continue + keys = _hint_keys(atom) + if keys and all(_HINT_KEY_ACTIONS[key] in covered for key in keys): + continue + kept.append(atom) + return " · ".join(kept) diff --git a/meshterm/ui/tui/frame.py b/meshterm/ui/tui/frame.py index 0a6990e..ff9727e 100644 --- a/meshterm/ui/tui/frame.py +++ b/meshterm/ui/tui/frame.py @@ -19,6 +19,7 @@ from rich.text import Text from ...platforms import Platform, get_platform, on_platform from ..logo import load_logo, logo_width from ..theme import fold_text, hint_style, title_style +from . import fkeys from .render import crop_cells, render_lines, render_to_ansi from .screen import Screen @@ -518,6 +519,56 @@ _DIALOG_CACHE: "OrderedDict[tuple, str]" = OrderedDict() _DIALOG_CACHE_MAX = 12 +def _dialog_hint(screen: Screen) -> str: + """The hint a floating dialog's own border carries — often none at all. + + A dialog is drawn over a frame that already has a footer row, and *what that row is* + decides whether the box needs to speak at all: + + - Where the footer is the **hint line** (the desktop), it draws the top screen's hint + — this dialog's — in full, on the row every screen's keys have always been read + from. A subtitle repeating it was the same sentence twice on one frame, once in the + border and once at the bottom of the terminal (JP, 2026-08-31), so the border says + nothing and the box leaves the reader's eye on the one place hints live. + - Where the footer is the **F-key lane** (the PicoCalc), there is no hint line at all + and the lane speaks only for its five chips, so the border is the only thing that + can name Enter, Esc or the arrows — it keeps the hint. What the lane *does* say it + says already, one row below: :func:`~meshterm.ui.tui.fkeys.strip_lane_atoms` lifts + out the atoms whose every key is a chip on that row. + + Resolved on each paint rather than folded into a screen's hint once, because both + halves move: a screen rewrites its hint as its content changes (the packet viewer + drops to a bare ``Esc close`` on a single packet) and its lane is read fresh every + frame. + + The chromeless startup splash is not this case and never passes through here — it has + no footer row of any kind, so :func:`compose_startup` keeps drawing the hint in its + own panel's subtitle on both platforms. + """ + if not get_platform().footer_fkeys: + return "" + return fkeys.strip_lane_atoms(screen.footer_hint, screen.fkey_lane) + + +def _dialog_subtitle( + hint: str, more_above: bool, more_below: bool, border: str +) -> Optional[str]: + """A floating box's bottom-border legend: the clip arrows, the hint, or both. + + With no hint to carry (the desktop — see :func:`_dialog_hint`) the arrows keep the + base frame's own wording, ``↑↓ more``, exactly as :func:`_panel_box` labels them: a + border that has fallen silent still says the one thing every border here says, in the + vocabulary the frame around it uses. + """ + arrow = "" + if more_above or more_below: + arrow = ("↑" if more_above else " ") + ("↓" if more_below else " ") + if not hint: + return f"[{hint_style(border)}]{arrow} more[/]" if arrow else None + legend = f"{arrow} · {hint}" if arrow else hint + return f"[{hint_style(border)}]{legend}[/]" + + def compose_dialog(screen: Screen, cols: int, rows: int) -> str: """Compose a centered dialog panel for a floating screen, bounded to the terminal. @@ -532,8 +583,11 @@ def compose_dialog(screen: Screen, cols: int, rows: int) -> str: max_w, vpad, viewport, body_lines = _dialog_layout(screen, cols, rows) visible, more_above, more_below = _visible_slice(screen, body_lines, viewport) border = getattr(screen, "border_style", "accent") + # The resolved hint, not the screen's own: it is what the border actually draws, and + # it moves with the lane below as well as with the screen's hint (see _dialog_hint). + hint = _dialog_hint(screen) key = ( - max_w, vpad, screen.title, screen.footer_hint, border, + max_w, vpad, screen.title, hint, border, more_above, more_below, *visible, ) cached = _DIALOG_CACHE.get(key) @@ -541,11 +595,7 @@ def compose_dialog(screen: Screen, cols: int, rows: int) -> str: _DIALOG_CACHE.move_to_end(key) return cached body = Text.from_ansi("\n".join(visible)) - hint = hint_style(border) - subtitle = f"[{hint}]{screen.footer_hint}[/]" - if more_above or more_below: - arrow = ("↑" if more_above else " ") + ("↓" if more_below else " ") - subtitle = f"[{hint}]{arrow} · {screen.footer_hint}[/]" + subtitle = _dialog_subtitle(hint, more_above, more_below, border) panel = Panel( body, title=f"[{title_style(border)}]{screen.title}[/]" if screen.title else None, diff --git a/tests/test_picocalc_chrome.py b/tests/test_picocalc_chrome.py index 9551588..350de6e 100644 --- a/tests/test_picocalc_chrome.py +++ b/tests/test_picocalc_chrome.py @@ -13,6 +13,7 @@ from meshterm.ui.tui.fkeys import ( action_for, default_lane, lane_text, + strip_lane_atoms, ) from meshterm.ui.tui.screen import ScrollScreen @@ -301,3 +302,66 @@ def test_dialog_gate_shrinks_on_picocalc() -> None: assert _dialog_max_cells() == 43 set_platform(REGULAR) assert _dialog_max_cells() == 76 + + +# --- the dialog border's hint, against the lane one row below it ------------------------ + + +def _dialog(hint: str) -> ScrollScreen: + """A floating screen carrying ``hint``, on the shared pager lane.""" + screen = ScrollScreen(Text("body"), title="Probe") + screen._footer_hint = hint + return screen + + +def test_a_dialog_keeps_its_hint_where_the_lane_is_the_footer() -> None: + """There is no hint line here, so a floating box's border is the only place keys read. + + The desktop drops the border hint entirely (the footer row below repeats it word for + word); this platform's footer row is the lane, which speaks only for its five chips, + so Enter, Esc and the arrows have nowhere else to be said. + """ + screen = _dialog("↑↓ move · Enter select · Esc back") + set_platform(REGULAR) + assert frame._dialog_hint(screen) == "" + set_platform(PICOCALC) + assert "Esc back" in _plain(frame.compose_dialog(screen, 53, 26)) + + +def test_the_dialog_hint_drops_what_the_lane_already_says() -> None: + """An atom whose every key is a chip on the very next row is the same claim twice.""" + set_platform(PICOCALC) + screen = _dialog("↑↓ move · PgUp/PgDn scroll · Home/End ends · Enter select · Esc back") + # The shared lane pages on F4/F5 and jumps to either end behind their Shift halves. + assert frame._dialog_hint(screen) == "↑↓ move · Enter select · Esc back" + assert "PgUp" not in _plain(frame.compose_dialog(screen, 53, 26)) + + +def test_the_dialog_hint_is_resolved_on_every_paint() -> None: + """Never folded into a screen's hint once: both halves move while the screen is up. + + The packet viewer rewrites its own hint as the list it pages through grows past one + entry (JP, 2026-08-31), and every screen reads its lane fresh each frame. + """ + set_platform(PICOCALC) + screen = _dialog("Esc close") + assert frame._dialog_hint(screen) == "Esc close" + screen._footer_hint = "↑↓ newer/older · PgUp/PgDn scroll · Home/End ends · Esc close" + assert frame._dialog_hint(screen) == "↑↓ newer/older · Esc close" + + +def test_an_atom_the_lane_only_half_covers_stands() -> None: + """Half a truth is worse than the whole atom: it survives unless every key is a chip.""" + pager_only = (None, None, None, FPair("Page ↓", "pagedown"), FPair("Page ↑", "pageup")) + assert strip_lane_atoms("PgUp/PgDn scroll", pager_only) == "" + # No Shift bank here, so Home/End are nowhere on the lane. + assert strip_lane_atoms("Home/End ends", pager_only) == "Home/End ends" + # The atom documents the arrows too, and no slot ever claims those. + assert strip_lane_atoms("↑↓ PgUp/PgDn scroll", DEFAULT_LANE) == "↑↓ PgUp/PgDn scroll" + # A second key riding in the verb half keeps the atom whole (the map's region/you). + assert strip_lane_atoms("Home/^U region/you", DEFAULT_LANE) == "Home/^U region/you" + + +def test_a_dimmed_chip_still_covers_its_atom() -> None: + """Dim says *a thing here, just not right now* — the reader has been told where it is.""" + assert strip_lane_atoms("PgUp/PgDn scroll", default_lane(nav=False)) == "" diff --git a/tests/test_tui.py b/tests/test_tui.py index a1e2ba9..b533ff3 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -922,6 +922,31 @@ def _border_colours(lines: list[str]) -> set[tuple[int, int, int]]: } +def test_a_dialog_leaves_its_hint_to_the_footer_line() -> None: + """On the desktop the box says nothing about keys — the footer row below already does. + + The frame draws the *top* screen's hint on its footer line, so a dialog that also + carried the hint in its own border put the same sentence on one frame twice (JP, + 2026-08-31). The border falls silent and the footer is the single place hints live. + """ + hint = "←→ choose · Enter select · Esc cancel" + screen = ScrollScreen(Text("Delete this contact?"), title="Delete contact") + screen._footer_hint = hint + box = _plain(frame.compose_dialog(screen, 72, 20)) + assert "Esc cancel" not in box and "Enter select" not in box + + base = ScrollScreen(Text("row"), title="Contacts", floating=False) + footer = _plain(frame.compose_base(Text("h"), base, hint, 72, 20).split("\n")[-1]) + assert footer.strip() == hint + + +def test_a_silent_dialog_border_still_says_there_is_more() -> None: + """With no hint to carry, the clip arrows keep the base frame's own ``more`` wording.""" + tall = ScrollScreen(Text("\n".join(f"line {i}" for i in range(40))), title="Packet") + tall._footer_hint = "↑↓ newer/older · Esc close" + assert "↓ more" in _plain(frame.compose_dialog(tall, 72, 16)) + + def test_a_frame_draws_in_one_colour() -> None: """A border is its own colour the whole way round — no corner lit, no edge fading.