diff --git a/CLAUDE.md b/CLAUDE.md index 95083c4..33a0879 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -189,19 +189,27 @@ branch on the platform per frame, and never `from meshterm.platforms import PLAT rather than derived go through a theme name so the slot is chosen deliberately (the node types' `type.*`; a raster resolves the same entry via `theme.mark_rgb`) — a naive downsample greys the repeater's violet. Footer hints are replaced by the **F-key lane** - (`ui/tui/fkeys.py`): five `FPair` slots per screen, F1–F5 primary and F6–F10 their - paired opposites (physical Shift+F1..F5), labels ≤7 cells. Screens override - `fkey_lane` for their own verbs; slots 3/4 are the customary free pair. **A chip names - an action, never a key** — `Page ↑`, not `PgUp`; `Latest` on a transcript; `Reset` on - the map, whose Home refits and whose paging zooms. **A directional pair rises to the - right**: where two adjacent chips are opposite ends of one axis (F1/F2's jumps, F4/F5's - paging, the map's zoom, a chat's latest/oldest), the *up · in · more* end takes the - right slot — `Zoom -` then `Zoom +`, a rocker. The lane *is* the footer here, so it - obeys the hint line's rule — never advertise a key that would do nothing. Empty and dim - are different claims: leave the slot **empty** when the action isn't a thing on this - screen (Retry in a channel), and clear `enabled`/`opp_enabled` to draw it **dim** (label - kept, fill dropped) when it's a thing that just isn't available this paint. Dimming is - presentational — `handle` stays the authority and no-ops. + (`ui/tui/fkeys.py`): five `FPair` slots per screen, F1–F5 primary and F6–F10 each + slot's *opposite number* (physical Shift+F1..F5), labels ≤6 cells. `DEFAULT_LANE` claims + only **F4/F5 — the pager**, which has no physical key at all; **the jump to either end + rides the Shift half of the very pager heading for it** (F10 Top behind F5 Page ↑, F9 + Bottom behind F4 Page ↓), because Home and End *are* on this keyboard and only ever + wanted a chip for consistency. That leaves **F1–F3 free on every screen** for its own + verbs, and `EMPTY_LANE` for a screen with none at all (every dialog). **A chip names an + action, never a key** — `Page ↑`, not `PgUp`; `Latest` on a transcript; `Region` on the + map, whose Home reframes and whose paging zooms. **A directional pair rises to the + right**: where two adjacent chips are opposite ends of one axis (F4/F5's paging, the + map's zoom, a select list's section jumps), the *up · in · more* end takes the right + slot — `Zoom -` then `Zoom +`, a rocker — and a slot's Shift companion follows the same + rule along its own slot. The lane *is* the footer here, so it obeys the hint line's rule + — never advertise a key that would do nothing. Empty and dim are different claims: leave + the slot **empty** when the action isn't a thing on this screen (Retry in a channel), and + clear `enabled`/`opp_enabled` to draw it **dim** (label kept, fill dropped) when it's a + thing that just isn't available this paint. Dimming is presentational — `handle` stays + the authority and no-ops. The lane is also the *only* affordance advertisement here, so + anything the desktop reaches by a chord or a bare letter (a list's `^PgUp/^PgDn` section + jumps, the Time Machine's `w`, the map's `^L`, a row's `Del`) earns a slot — otherwise + it is undiscoverable on the device. - `meshterm specimen` prints the whole visual language through the real funnels — the acceptance card on-device, a preview under `--platform picocalc` on the desktop. - Dev loop: `meshterm --mock --platform picocalc` in a 53×40 window; the gallery and diff --git a/meshterm/ui/chat.py b/meshterm/ui/chat.py index 0d54e8a..ca33e2b 100644 --- a/meshterm/ui/chat.py +++ b/meshterm/ui/chat.py @@ -82,29 +82,29 @@ class ChatScreen(Screen): def fkey_lane(self): """The PicoCalc lane in the transcript's own words: Latest/Oldest, Paths, Retry. - The default lane's F1/F2 dispatch the plain ``end``/``home`` actions, but here - those are already claimed by the compose line's cursor (see :meth:`handle`) — so - without an override, both keys land on the same "clear the pick, stick to the - tail" fallthrough and read as if either one scrolls to the bottom. F1/F2 dispatch - ``ctrl_end``/``ctrl_home`` instead, and say what those do to a conversation: - return to the *latest* message and the compose line, or reach back to the - *oldest*. That is the shared pair relabelled, so it keeps the shared handedness — - the far end of the scroll-up axis on the right (see - :data:`~meshterm.ui.tui.fkeys.DEFAULT_LANE`). F4/F5 keep the shared paging pair, - which is what a screenful of transcript looks like from the outside even though it - is the pick that moves. + The shared lane's Shift bank dispatches the plain ``end``/``home`` actions, but + here those are already claimed by the compose line's cursor (see :meth:`handle`) — + so left alone, both keys land on the same "clear the pick, stick to the tail" + fallthrough and read as if either one scrolls to the bottom. The two companions + dispatch ``ctrl_end``/``ctrl_home`` instead, and say what those do to a + conversation: return to the *latest* message and the compose line, or reach back + to the *oldest*. That is the shared pair relabelled in place, so it keeps the + shared handedness — each jump still sitting behind the page heading the same way + (see :data:`~meshterm.ui.tui.fkeys.DEFAULT_LANE`). F4/F5 keep the shared paging + pair, which is what a screenful of transcript looks like from the outside even + though it is the pick that moves. The F3 pair follows the lane's two claims. *Retry* is absent in a channel — a channel message is never acknowledged, so there is no such thing to retry there — and merely dim in a direct chat with nothing outstanding. *Paths* needs a picked - message to have paths of, and all four nav slots need a transcript to walk. + message to have paths of, and both nav slots need a transcript to walk. """ from .tui.fkeys import FPair, default_lane lane = list(default_lane(nav=bool(self._messages))) live = bool(self._messages) - lane[0] = FPair("Latest", "ctrl_end", enabled=live) - lane[1] = FPair("Oldest", "ctrl_home", enabled=live) + lane[3] = FPair("Page ↓", "pagedown", "Latest", "ctrl_end", enabled=live, opp_enabled=live) + lane[4] = FPair("Page ↑", "pageup", "Oldest", "ctrl_home", enabled=live, opp_enabled=live) retry = ("Retry", "retry") if not self._is_channel else ("", "") lane[2] = FPair( "Paths", "paths", *retry, diff --git a/meshterm/ui/contactlist.py b/meshterm/ui/contactlist.py index 9b35f3a..25b1017 100644 --- a/meshterm/ui/contactlist.py +++ b/meshterm/ui/contactlist.py @@ -344,7 +344,7 @@ class ContactListScreen(SelectScreen): @property def fkey_lane(self): - """The PicoCalc lane: the whole sort on F3/F8 beside the shared defaults. + """The select list's lane, with the whole sort added on F3/F8. Four columns is a short ring — walking it backwards saves at most two presses, so it never earns the Shift companion. That slot takes the sort's *other* half @@ -355,10 +355,14 @@ class ContactListScreen(SelectScreen): force — ``Sort ▼`` while the list reads ascending — in the same triangle the header lights over the active column, so the chip and the column cue can never read as the same claim. - """ - from .tui.fkeys import DEFAULT_LANE, FPair - lane = list(DEFAULT_LANE) + Built on ``super()``'s lane rather than the bare default, so a contact list that + carries section headings (the Time Machine's whole-mesh lead, the Contacts screen's + purge tail) keeps the section jumps the select list promotes onto F1/F2. + """ + from .tui.fkeys import FPair + + lane = list(super().fkey_lane) descend = self._sort.ascending # ascending now, so the flip lands descending lane[2] = FPair( "Sort →", diff --git a/meshterm/ui/livefeed_screen.py b/meshterm/ui/livefeed_screen.py index 11fe43f..6715b19 100644 --- a/meshterm/ui/livefeed_screen.py +++ b/meshterm/ui/livefeed_screen.py @@ -145,6 +145,23 @@ class LiveFeedScreen(Screen): floating = False + @property + def fkey_lane(self): + """The shared lane, gated on there being packets to walk. + + Every nav key here moves the *cursor* over the feed rather than a scroll offset, + but the vocabulary is the same either way — the window follows the highlight — so + the shared labels stand. ``Top`` and ``Bottom`` stay literal on purpose: the top of + this list is the newest packet, and its bottom is the ``Back`` row one past the + oldest, which is where End actually lands. + + A feed with nothing in it still has that ``Back`` row, and nowhere to move to, so + all four chips go dim until the first packet arrives. + """ + from .tui.fkeys import default_lane + + return default_lane(nav=bool(self._feed)) + def __init__( self, *, diff --git a/meshterm/ui/map_screen.py b/meshterm/ui/map_screen.py index 6cc6b89..ad64724 100644 --- a/meshterm/ui/map_screen.py +++ b/meshterm/ui/map_screen.py @@ -8,12 +8,13 @@ render_map`. Keys: * the **arrow keys** pan; holding **Shift** pans by a single character cell for fine positioning, * ``PgUp`` / ``PgDn`` zoom in / out, -* ``Home`` recenters and refits to the dense core of the nodes (the same default view the - map opens on), +* ``Home`` recenters and refits to the dense core of the nodes — the *region* the mesh + covers, and the same default view the map opens on, +* ``Ctrl+L`` recenters on **your own node**, keeping the zoom you chose, * **typing finds nodes**: every letter key feeds a live name filter — matching nodes keep bright labels while the rest dim to context, ``Enter`` frames the matches, ``Backspace`` edits, and ``Esc`` clears the filter (a second ``Esc`` leaves the map). This is why no - letters are bound to actions here, + plain letters are bound to actions here, * ``Esc`` leaves the map. With no network (and no cached tiles) the basemap is simply absent and nodes are plotted on a @@ -81,25 +82,34 @@ class MapScreen(Screen): @property def fkey_lane(self): - """The PicoCalc lane: reset on F1, zoom on F4/F5 — nothing that doesn't act here. + """The PicoCalc lane: three ways to frame the view, then the zoom rocker. - The map is the one screen that repurposes the nav actions: PgUp/PgDn zoom and - Home refits the view, while ``end`` is bound to nothing at all. The shared lane - would label those keys as paging and offer a fifth that does nothing, so the map - names its own verbs and leaves F2 unassigned. All three are always live — a map - can always zoom or refit — so no slot dims. + The map is the one screen that repurposes the nav actions wholesale: PgUp/PgDn + zoom, Home reframes, and ``end`` is bound to nothing at all — so the shared lane's + Shift-bank jumps have nothing to jump to here and F4/F5 are lone slots. That is + also the one exception to the rule that a Home/End verb rides the Shift half of + its pager (JP, 2026-08-08): ``Region`` is not a vertical move through a body, it + is a *destination*, and it keeps the prime F1 slot next to the other two. - The zoom pair keeps the lane's handedness (see :data:`~meshterm.ui.tui.fkeys.DEFAULT_LANE`): - out on the left, in on the right, so F4/F5 read as the ``−``/``+`` rocker they are. + All three destinations answer "where should I be looking?", in widening order of + specificity: the whole **Region** the mesh covers, **You** at the centre of it, or + just the nodes a find query **Frame**\\ s. Region always acts; You needs our own + node to be on the map at all; Frame needs a query with matches to frame, so on an + unfiltered map it dims (and no-ops) rather than standing in for Region. + + The zoom pair keeps the lane's handedness (see + :data:`~meshterm.ui.tui.fkeys.DEFAULT_LANE`): out on the left, in on the right, so + F4/F5 read as the ``−``/``+`` rocker they are. """ - from .tui.fkeys import DEFAULT_LANE, FPair + from .tui.fkeys import FPair - lane = list(DEFAULT_LANE) - lane[0] = FPair("Reset", "home") - lane[1] = None # End does nothing on a map - lane[3] = FPair("Zoom -", "pagedown") - lane[4] = FPair("Zoom +", "pageup") - return lane + return [ + FPair("Region", "home"), + FPair("You", "locate", enabled=self._self_marker() is not None), + FPair("Frame", "frame", enabled=bool(self._filter) and bool(self._matches())), + FPair("Zoom -", "pagedown"), + FPair("Zoom +", "pageup"), + ] def __init__( self, @@ -162,20 +172,22 @@ class MapScreen(Screen): @property def footer_hint(self) -> str: # type: ignore[override] - """Key hints — or the live find query — plus a tile-loading indicator. + """Key hints — or the live find query. While a find filter is active the hints give way to the query itself with its editing keys, so the typed text is always visible somewhere fixed. + + The line spends its whole 72-cell budget, so the two view-jump keys share one atom + (``Home/^L region/you``) and ``⇧ fine`` — a refinement of a key the line already + names, and the only atom here that documents a *modifier* rather than a binding — + is the one that gives way to make room for them. The basemap's state used to hang + off the end of this line as a suffix; it is a status atom, not a key, so it moved + to the title where the standards chain those (see :meth:`_title`), which is what + finally brought the line inside the budget. """ if self._filter: - base = f"find: {self._filter}▏ · Enter frame · ⌫ erase · Esc clear" - else: - base = "↑↓←→ pan · ⇧ fine · PgUp/PgDn zoom · Home reset · type to find · Esc back" - if self._pending: - return f"{base} · [muted]loading {len(self._pending)} tiles…[/muted]" - if not self._source.available: - return f"{base} · [warn]offline — no basemap[/warn]" - return base + return f"find: {self._filter}▏ · Enter frame · ⌫ erase · Esc clear" + return "↑↓←→ pan · PgUp/PgDn zoom · Home/^L region/you · type to find · Esc back" def consume_edge_scrub(self) -> int: """Right-edge columns the session should force-repaint on the next paint (0 = none). @@ -249,7 +261,14 @@ class MapScreen(Screen): return [m for m in self._markers if needle in m.label.casefold()] def _title(self, vp: Viewport) -> str: - """A compact status title: zoom, node count (find matches), and ground scale.""" + """A compact status title: zoom, node count (find matches), and ground scale. + + The basemap's own state — tiles still in flight, or no source at all — is the last + atom when there is one to report, standing in for the scale rather than joining it. + Both are answers to "how much ground am I looking at", the status is the more urgent + of the two while it lasts, and swapping (rather than appending) keeps the title from + outgrowing a 53-column title bar, which clips rather than wraps. + """ # Ground metres per braille dot at the view centre, for a rough sense of scale. m_per_dot = ( 2 * math.pi * EARTH_RADIUS_KM * 1000 @@ -258,6 +277,10 @@ class MapScreen(Screen): ) scale = f"{m_per_dot * vp.dot_w:.0f} m across" if m_per_dot * vp.dot_w < 1000 else \ f"{m_per_dot * vp.dot_w / 1000:.1f} km across" + if self._pending: + scale = f"{len(self._pending)} tiles…" + elif not self._source.available: + scale = "offline" if self._filter: nodes = f"{len(self._matches())} of {len(self._markers)} match" else: @@ -320,11 +343,16 @@ class MapScreen(Screen): # --- input --------------------------------------------------------------- def handle(self, action: str, data: str = "") -> None: - """Pan, zoom, reset, edit the find filter, or exit. + """Pan, zoom, reframe, edit the find filter, or exit. Every printable key feeds the find filter — nothing pans or zooms by letter, so typing a node name can never fling the view around. Esc peels one layer: an active filter first, the map itself only once the filter is clear. + + Three actions reframe the view, and each has both a key and an F-key chip: + ``home`` the whole region, ``locate`` (Ctrl+L) our own node, ``frame`` the find + matches — which is what Enter already does while a query is being typed, kept as + a separate action so the lane can name it on a platform that draws no hint line. """ vp = self._viewport if action == "escape": @@ -345,6 +373,10 @@ class MapScreen(Screen): self._viewport = vp.zoomed(-1) elif action in ("home", "ctrl_home"): self._reset_view(vp) + elif action == "locate": + self._locate(vp) + elif action == "frame" and self._filter: + self._frame_matches(vp) elif action == "text" and self.find_enabled: if not data.isspace() or self._filter: # never begin the filter with a space self._filter += data @@ -358,6 +390,27 @@ class MapScreen(Screen): self._needs_scrub = True self._persist() + def _self_marker(self) -> Optional[MapMarker]: + """Our own node among the markers, or ``None`` when the map can't place us. + + A device with no location fix of its own is simply absent from the marker list, so + ``You`` has nowhere to go and its chip dims (see :attr:`fkey_lane`). + """ + return next((m for m in self._markers if m.is_self), None) + + def _locate(self, vp: Viewport) -> None: + """Recentre on our own node, keeping the current zoom (``Ctrl+L`` / the ``You`` chip). + + Deliberately *only* a recentre: the zoom is the one the user chose, so pressing + this twice does the same thing twice and pairing it with one Zoom + is a single + extra press. Refitting instead would make "where am I" silently also mean "and + forget how close I was looking". + """ + me = self._self_marker() + if me is None: + return + self._viewport = Viewport(clamp_lat(me.lat), me.lon, vp.zoom, vp.dot_w, vp.dot_h) + def _reset_view(self, vp: Viewport) -> None: """Refit the view to the nodes' dense core (the map's opening frame).""" self._viewport = Viewport.fit( @@ -418,6 +471,22 @@ class LocationPickScreen(MapScreen): find_enabled = False + @property + def fkey_lane(self): # type: ignore[override] + """The map's lane minus the two verbs a picker has no use for. + + ``Frame`` goes because find is off here — nothing can ever be typed to frame, so + the slot is *empty*, not dim. ``You`` goes because the whole screen is about + choosing where "you" will be: the crosshair at the centre already is that answer, + and a chip that jumped to wherever the node currently claims to be would compete + with the pick rather than help it. ``Home`` keeps F1, but reframed: here it returns + to the view the picker **opened** on, so a pan that went wrong is one key to undo. + """ + from .tui.fkeys import FPair + + return [FPair("Start", "home"), None, None, FPair("Zoom -", "pagedown"), + FPair("Zoom +", "pageup")] + def __init__( self, session, # noqa: ANN001 - TuiSession, imported lazily to avoid a cycle diff --git a/meshterm/ui/node_detail_screen.py b/meshterm/ui/node_detail_screen.py index 3516bf8..7688d58 100644 --- a/meshterm/ui/node_detail_screen.py +++ b/meshterm/ui/node_detail_screen.py @@ -323,6 +323,30 @@ class NodeDetailScreen(Screen): floating = False + @property + def fkey_lane(self): + """The shared pager over the route list, plus the tab switch on F3. + + The strip shows *that* there are two views; nothing on screen says the key that + moves between them, and on a platform with no hint line the chip is the only place + to learn it. It names the tab it would take you *to*, never the one you are on — + the strip already marks that, and a chip repeating it would say nothing about what + pressing it does. Unlike the Time Machine's window chip, this one carries no ``▸`` + lead-in: ``Routes`` is exactly six cells on its own, and the strip beside it makes + the direction plain anyway. A page with one tab has nothing to switch, so the slot + stays empty. + + The pager gates on the route list actually being windowed: on the Info tab, and on + a node whose routes all fit, the keys move nothing. + """ + from .tui.fkeys import FPair, default_lane + + lane = list(default_lane(nav=self._list_hidden)) + if len(self._tabs) >= 2: + nxt = self._tabs[(self._tab_index + 1) % len(self._tabs)] + lane[2] = FPair(nxt.name, "tab") + return lane + def __init__( self, *, diff --git a/meshterm/ui/packet_viewer.py b/meshterm/ui/packet_viewer.py index 30c848e..522a3f1 100644 --- a/meshterm/ui/packet_viewer.py +++ b/meshterm/ui/packet_viewer.py @@ -315,6 +315,29 @@ class PacketViewer(Screen): grow_only = True + @property + def fkey_lane(self): + """The shared pager over the body, with the jumps renamed for what they land on. + + Home and End here don't reach the ends of a *body* — they reach the ends of the + *list*, the newest and oldest packet — so the Shift companions say ``Newest`` and + ``Oldest``. The handedness is unchanged: newest is the top of a newest-first list, + so it keeps the right-hand slot behind Page ↑. + + The two banks gate on different things, because they move different things. The + pager needs a body taller than the box; the jumps need a second packet to jump to. + Opened over a single packet, all four chips go dim at once. + """ + from .tui.fkeys import FPair, default_lane + + lane = list(default_lane(nav=self.content_overflows)) + many = len(self._entries) > 1 + lane[3] = FPair("Page ↓", "pagedown", "Oldest", "end", + enabled=self.content_overflows, opp_enabled=many) + lane[4] = FPair("Page ↑", "pageup", "Newest", "home", + enabled=self.content_overflows, opp_enabled=many) + return lane + def __init__( self, entries: list[PacketEntry], diff --git a/meshterm/ui/remote_cli.py b/meshterm/ui/remote_cli.py index 7ad5c89..758bc63 100644 --- a/meshterm/ui/remote_cli.py +++ b/meshterm/ui/remote_cli.py @@ -44,6 +44,24 @@ class RemoteCliScreen(Screen): floating = False + @property + def fkey_lane(self): + """The pager over the transcript, with no jumps behind it. + + Home and End never reach the transcript here: they fall through to the compose + line's editor, where they move the text cursor to either end of what is being + typed (see :meth:`handle`). That is the ordinary behaviour of a command line and + both keys are on the keyboard, so the Shift companions stay blank rather than + promising a jump the transcript would not make. + """ + from .tui.fkeys import FPair, default_lane + + overflows = self.content_overflows + lane = list(default_lane(nav=overflows)) + lane[3] = FPair("Page ↓", "pagedown", enabled=overflows) + lane[4] = FPair("Page ↑", "pageup", enabled=overflows) + return lane + def __init__( self, *, diff --git a/meshterm/ui/specimen.py b/meshterm/ui/specimen.py index 6ba3f1a..be95762 100644 --- a/meshterm/ui/specimen.py +++ b/meshterm/ui/specimen.py @@ -17,6 +17,7 @@ from rich.text import Text from ..platforms import get_platform from .packet_viewer import KIND_ICONS, PAYLOAD_ICONS from .theme import _VT_SLOTS, fold_text, glyph, name_style, snr_style +from .tui.fkeys import FPair, lane_text from .widgets import _recency_style, channel_glyph #: Demo identities for the name-colour row: ``(name, key)``. The keys are synthetic, and @@ -135,4 +136,19 @@ def specimen_lines() -> list[RenderableType]: chart.append("⡀⠄⠂", style="snr.bad") chart.append(" braille", style="muted") lines.append(chart) + + # The F-key lane, on the one platform that draws it: both fills, plus a dim slot and + # an unassigned one, so the card shows every state a chip can be in. Built through + # lane_text itself — this is the acceptance screen, so it must be the real renderer. + if platform.footer_fkeys: + lane = ( + FPair("Region", "home"), + FPair("You", "locate", enabled=False), + None, + FPair("Page ↓", "pagedown", "Bottom", "end"), + FPair("Page ↑", "pageup", "Top", "home"), + ) + lines.append(Text()) + lines.append(lane_text(lane)) + lines.append(lane_text(lane, shifted=True)) return lines diff --git a/meshterm/ui/timemachine_screen.py b/meshterm/ui/timemachine_screen.py index 727099e..b768e63 100644 --- a/meshterm/ui/timemachine_screen.py +++ b/meshterm/ui/timemachine_screen.py @@ -142,6 +142,27 @@ class TimeMachineScreen(Screen): floating = False footer_hint = "↑↓ PgUp/PgDn scroll · w window · Esc back" + @property + def fkey_lane(self): + """The shared pager, plus the window cycle on F3. + + ``w`` is the whole point of this screen — the same history at five spans — and it + is exactly the kind of affordance that vanishes on a platform with no hint line to + read it off. The chip is the only place the PicoCalc can learn the key exists, so + it earns the free F3 slot even though the letter itself is easy to press. + + The chip names the span it would *take you to*, not the one on screen: the title + already says where you are, so a chip repeating it would be the same claim twice + and would never tell you what pressing it does. ``all time`` shortens to ``all`` + to stay inside the 6-cell chip — the only span whose name doesn't already fit. + """ + from .tui.fkeys import FPair, default_lane + + lane = list(default_lane(nav=self.content_overflows)) + nxt, _delta = _WINDOWS[(self._window_index + 1) % len(_WINDOWS)] + lane[2] = FPair(f"▸ {'all' if nxt == 'all time' else nxt}", "window") + return lane + def __init__( self, *, @@ -171,7 +192,12 @@ class TimeMachineScreen(Screen): self.title = f"{self._label} · {name}" def handle(self, action: str, data: str = "") -> None: - """Scroll, cycle the window, or dismiss.""" + """Scroll, cycle the window, or dismiss. + + The window cycles on either the ``w`` key or the ``window`` action the F-key lane + dispatches — one behaviour, two ways in, so the chip is not a second implementation + of the letter. + """ if action == "up": self.scroll_lines(-1) elif action == "down": @@ -184,7 +210,7 @@ class TimeMachineScreen(Screen): self.scroll_to_top() elif action in ("end", "ctrl_end"): self.scroll_to_bottom() - elif action == "text" and data.lower() == "w": + elif action == "window" or (action == "text" and data.lower() == "w"): self._window_index = (self._window_index + 1) % len(_WINDOWS) self._set_title() self.scroll_to_top() diff --git a/meshterm/ui/trace_screen.py b/meshterm/ui/trace_screen.py index 3237217..9e0350c 100644 --- a/meshterm/ui/trace_screen.py +++ b/meshterm/ui/trace_screen.py @@ -232,6 +232,13 @@ class TracingDialog(Screen): footer_hint = "Enter/Esc abort" + @property + def fkey_lane(self): + """No lane: the dialog's only verb is abort, and it already answers to Enter and Esc.""" + from .tui.fkeys import EMPTY_LANE + + return EMPTY_LANE + def __init__(self, title: str, *, spinner: Spinner, on_abort: Callable[[], None]) -> None: """Build the dialog. diff --git a/meshterm/ui/tui/fkeys.py b/meshterm/ui/tui/fkeys.py index 19e1bcc..5240db3 100644 --- a/meshterm/ui/tui/fkeys.py +++ b/meshterm/ui/tui/fkeys.py @@ -9,11 +9,19 @@ By JP's spec (2026-08-01): a lane slot is for functionality that would otherwise sit right on the keyboard and are already the easiest keys to hit, so they never occupy a slot. Paging has no physical key at all, so a screen that scrolls earns it the prime F4/F5 pair. Ctrl+letter chords (Ctrl+P for message paths, Ctrl+arrows for a sort column, -…) are fiddly to hold on this keyboard, so a screen promotes its own onto the lone F3 -slot (a second, if it has one, riding F3's Shift companion). The five *plain* keys carry -a screen's most-used, otherwise-awkward actions — reachable with no Shift at all — and -the Shift bank (F6-F10) is overflow for a screen with more than five such actions. A -lane can, and does, differ from screen to screen. +…) are fiddly to hold on this keyboard, so a screen promotes its own onto the free +F1-F3 bank. The five *plain* keys carry a screen's most-used, otherwise-awkward actions +— reachable with no Shift at all — and the Shift bank (F6-F10) holds each slot's +*opposite number*, not unrelated overflow. A lane can, and does, differ from screen to +screen. + +**Second-grade keys ride their own pair's Shift half** (JP, 2026-08-08). Home and End +*are* on this keyboard, so jumping to either end of a body was never the unreachable +thing paging is — it only ever wanted a chip for consistency with the pager it belongs +to. So it rides that pager: Shift+F5 (``F10``) is Top because F5 is Page ↑, Shift+F4 +(``F9``) is Bottom because F4 is Page ↓. One axis, one pair of slots, the jump sitting +behind the page on the very same key. That leaves **F1-F3 free on every screen** for the +verbs a screen actually has to offer, which is where the interesting work goes. **A chip names an action, never a key** (JP, 2026-08-07). ``PgUp``, ``End`` and their kin are the names of keys this keyboard doesn't even have; what the slot is for is the @@ -90,36 +98,42 @@ class FPair: Lane = Sequence[Optional[FPair]] #: The default lane, in the vocabulary of a screen that is one scrolling body: move -#: through it a page at a time, or jump to either end. No Enter or Esc slot — both keys -#: sit right on the keyboard already. F4/F5 (the most reachable pair) carry paging, which -#: has no physical key at all; F1/F2 carry the jumps, Fn-layered on the physical keyboard -#: and so worth a slot too, just a notch behind paging; F3 is the lone slot a screen's own -#: :attr:`~meshterm.ui.tui.screen.Screen.fkey_lane` override reaches for first — typically -#: a Ctrl+letter chord promoted up because chording is a pain here. A screen whose body is -#: not one scrolling column says so in its own words (see :class:`ChatScreen`, ``MapScreen``). +#: through it a page at a time (F4/F5), or — behind Shift, on those same two keys — jump +#: to the end each pager heads for (F9/F10). No Enter or Esc slot: both keys sit right on +#: the keyboard already. F1-F3 stay free for the screen's own verbs; a screen that has +#: none leaves them blank rather than filling the lane out. A screen whose body is not one +#: scrolling column says so in its own words (see :class:`ChatScreen`, ``MapScreen``). #: #: **A directional pair rises to the right** (JP, 2026-08-08): wherever two adjacent chips -#: are opposite ends of one axis — the F1/F2 jumps and the F4/F5 pager here, the map's zoom, -#: a chat's oldest/latest — the *up · in · more* end takes the right slot and its opposite -#: the left, so every such pair reads like a rocker (``−`` then ``+``) and one handedness -#: carries across screens. A screen relabelling a pair keeps that order. +#: are opposite ends of one axis — the F4/F5 pager here, the map's zoom, a select list's +#: section jumps — the *up · in · more* end takes the right slot and its opposite the left, +#: so every such pair reads like a rocker (``−`` then ``+``) and one handedness carries +#: across screens. A slot's own Shift companion follows the same rule *along its slot*: the +#: jump behind ``Page ↑`` is the one Page ↑ is heading for, so F10 is Top and F9 Bottom. A +#: screen relabelling a pair keeps that order (a chat's ``Latest``/``Oldest``). DEFAULT_LANE: tuple[Optional[FPair], ...] = ( - FPair("Bottom", "end"), - FPair("Top", "home"), None, - FPair("Page ↓", "pagedown"), - FPair("Page ↑", "pageup"), + None, + None, + FPair("Page ↓", "pagedown", "Bottom", "end"), + FPair("Page ↑", "pageup", "Top", "home"), ) +#: The lane of a screen with no lane at all: a confirm dialog, a busy spinner, a text +#: prompt. Nothing there pages or jumps, so the shared pager is not dim-but-real, it is +#: simply *not a thing here* — the distinction the module docstring draws — and every slot +#: renders as a bare, unfilled key number. +EMPTY_LANE: tuple[Optional[FPair], ...] = (None, None, None, None, None) + def default_lane(*, nav: bool = True) -> tuple[Optional[FPair], ...]: - """:data:`DEFAULT_LANE`, with its four nav slots dimmed unless ``nav``. + """:data:`DEFAULT_LANE`, with its pager slots dimmed unless ``nav``. - The shared slots all *move* something — the jump-to-ends pair and the paging pair — - so on a screen with nothing to move (a body that fits its viewport, a transcript with - no messages) all four are inert and say so. Screens that repurpose the nav actions - for something always live (the map's zoom) build from :data:`DEFAULT_LANE` instead, - relabelling as they go. + Both shared slots *move* something — the page, and the jump behind it — so on a screen + with nothing to move (a body that fits its viewport, a transcript with no messages) + both banks are inert and say so. Screens that repurpose the nav actions for something + always live (the map's zoom) build from :data:`DEFAULT_LANE` instead, relabelling as + they go. Args: nav: Whether the nav keys would do anything on this paint — typically diff --git a/meshterm/ui/tui/progress.py b/meshterm/ui/tui/progress.py index 0fb85cc..859969b 100644 --- a/meshterm/ui/tui/progress.py +++ b/meshterm/ui/tui/progress.py @@ -40,6 +40,13 @@ class ProgressScreen(Screen): footer_hint = "working…" floating = True + @property + def fkey_lane(self): + """No lane: the dialog is non-interactive, so no key does anything to it.""" + from .fkeys import EMPTY_LANE + + return EMPTY_LANE + def __init__(self, title: str = "Working") -> None: """Start an empty progress dialog with the given heading.""" super().__init__() diff --git a/meshterm/ui/tui/prompt.py b/meshterm/ui/tui/prompt.py index b36595a..4bbf341 100644 --- a/meshterm/ui/tui/prompt.py +++ b/meshterm/ui/tui/prompt.py @@ -223,7 +223,27 @@ class _LineEditor: return text -class TextScreen(Screen): +class _KeylessDialog(Screen): + """A dialog with no F-key lane, because it has nothing a lane could carry. + + Every prompt in this module is answered with Enter, Esc, the arrows, and typing — + keys that sit right under the reader's hands on both platforms. None of them scroll, + so the shared pager (which the base :class:`~meshterm.ui.tui.screen.Screen` hands out) + would name two keys that do nothing here. The lane's standing rule is to leave a slot + **empty** rather than dim when the action is not a thing on this screen *at all* — + dim is for a thing that just isn't available this paint — so these dialogs draw the + bare key numbers (see :mod:`~meshterm.ui.tui.fkeys`). + """ + + @property + def fkey_lane(self): + """No slots: nothing on a prompt pages, jumps, or needs promoting off a chord.""" + from .fkeys import EMPTY_LANE + + return EMPTY_LANE + + +class TextScreen(_KeylessDialog): """A validated single-line text prompt. Resolves with the string, or CANCEL on Esc.""" def __init__( @@ -334,7 +354,7 @@ class TextScreen(Screen): self._error = "" -class PinDialog(Screen): +class PinDialog(_KeylessDialog): """A startup popup that collects a Bluetooth pairing PIN, re-asking on a rejected code. Shown by the device picker when a chosen companion answers the scan but refuses the GATT @@ -398,7 +418,7 @@ class PinDialog(Screen): self._error = "" -class ConfirmScreen(Screen): +class ConfirmScreen(_KeylessDialog): """A yes/no prompt. Resolves with a bool, or CANCEL on Esc.""" def __init__( @@ -450,7 +470,7 @@ class ConfirmScreen(Screen): super().handle("escape") -class ButtonDialog(Screen): +class ButtonDialog(_KeylessDialog): """A centered dialog: a prompt above a row of side-by-side buttons. A reusable choose-one prompt. The buttons sit in a row; ←/→ (or Tab) move the highlight, @@ -565,7 +585,7 @@ class ButtonDialog(Screen): super().handle("escape") -class TypedConfirmDialog(Screen): +class TypedConfirmDialog(_KeylessDialog): """A destructive-action gate: the user must type a confirmation word to proceed. A centered, error-themed dialog for the actions a stray Enter must never be able to @@ -638,7 +658,7 @@ class TypedConfirmDialog(Screen): self._error = "" -class ReconnectDialog(Screen): +class ReconnectDialog(_KeylessDialog): """A centered dialog shown when the companion link drops mid-session. An animated spinner and message sit above a single ``Abort`` button. Unlike the other @@ -712,7 +732,7 @@ class ReconnectDialog(Screen): self.resolve("quit") -class AutocompleteScreen(Screen): +class AutocompleteScreen(_KeylessDialog): """A free-text field with a live suggestion list (``questionary.autocomplete``). The user may type any value; matching suggestions appear below and can be highlighted diff --git a/meshterm/ui/tui/screen.py b/meshterm/ui/tui/screen.py index 1e6227e..dc8ccc6 100644 --- a/meshterm/ui/tui/screen.py +++ b/meshterm/ui/tui/screen.py @@ -66,9 +66,10 @@ class Screen: Five slots, F1–F5, each with an optional F6–F10 Shift-bank companion. A slot is for functionality that is otherwise hard to reach — Enter/Esc never appear, since both keys are already close at hand. The default puts paging (no physical key at - all) on the prime F4/F5 pair, the nav cluster's jump-to-top/end (Fn-layered) on - F1/F2, and leaves F3 as the one lone slot — the first a screen reaches for to - promote its own otherwise-fiddly shortcut (a Ctrl+letter chord, say). A screen + all) on the prime F4/F5 pair and the jump-to-top/end pair behind Shift on those + same two keys, since Home and End *are* on this keyboard and only ever wanted a + chip for consistency with the pager they belong to. F1–F3 are left free: they are + where a screen's own verbs go, and a screen with none leaves them blank. A screen overrides this property for its own verbs; the lane may differ screen to screen. Read fresh every paint, so an override dims the slots whose action would do @@ -504,6 +505,13 @@ class BusyScreen(Screen): footer_hint = "working…" floating = False + @property + def fkey_lane(self): + """No lane at all: this screen swallows every key, so none of them do anything.""" + from .fkeys import EMPTY_LANE + + return EMPTY_LANE + def __init__(self, message: str, *, title: str = "") -> None: """Start a busy splash showing ``message`` under an optional ``title``.""" super().__init__() diff --git a/meshterm/ui/tui/select.py b/meshterm/ui/tui/select.py index 7ea3e6e..908f3c0 100644 --- a/meshterm/ui/tui/select.py +++ b/meshterm/ui/tui/select.py @@ -206,6 +206,46 @@ class SelectScreen(Screen): #: Cells one ←/→ press shifts an h-scrolling list by (see the ``hscroll`` flag). _HSCROLL_STEP = 8 + @property + def fkey_lane(self): + """The shared pager, the section jumps on F1/F2, and row deletion on F3. + + Ctrl+PageUp/PageDown step the highlight heading by heading — and on the PicoCalc + they are the one binding in the app that is *physically unreachable*: paging has + no key there to hold Ctrl over. So a grouped list promotes them onto the lane, + keeping the shared handedness (down left, up right — see + :data:`~meshterm.ui.tui.fkeys.DEFAULT_LANE`). + + Their two gates say different things, per the lane's empty-versus-dim rule. A list + that has no headings *at all* leaves both slots blank: sections aren't a thing + here. A grouped list whose live filter has narrowed the rows down to a single + surviving section keeps its labels and dims them — the sections are still a thing, + they just have nowhere to jump right now. + + ``Delete`` is the same claim as the footer's per-row delete atom, on a platform + that draws no footer: only a list that hands out :attr:`Choice.deletable` rows at + all shows the slot, and it lights exactly on the rows the key would act on. The + chip says ``Delete`` on every such list rather than echoing each one's own hint + phrasing, because six cells hold the key's verb and not the object it removes — + which the highlighted row is already naming. + """ + from .fkeys import FPair, default_lane + + # One pass over the displayed rows for the whole lane: this runs every paint on + # the platform that draws it, and `_rows()` re-filters the item list each call. + rows = self._rows() + lane = list(default_lane(nav=len(self._choices(rows)) > 1)) + if len(self._all_section_starts()) >= 2: + live = len(self._section_starts(rows)) > 1 + lane[0] = FPair("Sect ↓", "ctrl_pagedown", enabled=live) + lane[1] = FPair("Sect ↑", "ctrl_pageup", enabled=live) + if self._delete_hint: + choices = self._choices(rows) + current = choices[max(0, min(self._index, len(choices) - 1))] if choices else None + lane[2] = FPair("Delete", "delete", + enabled=current is not None and current.deletable) + return lane + def __init__( self, title: str, @@ -302,17 +342,22 @@ class SelectScreen(Screen): rows = self._rows() if rows is None else rows return [it for it in rows if isinstance(it, Choice)] - def _section_starts(self) -> list[int]: + def _section_starts(self, rows: Optional[list] = None) -> list[int]: """Choice indices that begin a section — the first choice after each run of separators. - Drives Ctrl+PageUp/PageDown section jumps. Computed over the *displayed* rows, so - the jumps keep working while a filter narrows the list (the headings stay — see - :meth:`_rows` — and empty sections simply yield no stop). + Drives Ctrl+PageUp/PageDown section jumps. Computed over the *displayed* rows by + default, so the jumps keep working while a filter narrows the list (the headings + stay — see :meth:`_rows` — and empty sections simply yield no stop). + + Args: + rows: The rows to read the sections off, or ``None`` for the displayed ones. + :meth:`_all_section_starts` passes the unfiltered items instead, to ask + the structural question rather than the live one. """ starts: list[int] = [] idx = 0 fresh = True # the next choice opens a section (top of the list, or just past a heading) - for item in self._rows(): + for item in self._rows() if rows is None else rows: if isinstance(item, Separator): fresh = True elif isinstance(item, Choice): @@ -322,6 +367,15 @@ class SelectScreen(Screen): idx += 1 return starts + def _all_section_starts(self) -> list[int]: + """The section starts of the *unfiltered* list — whether this list has sections at all. + + The F-key lane needs both questions answered separately (see :attr:`fkey_lane`): + a flat list never offers a section jump, while a grouped one whose filter has + collapsed it to one section offers it again the moment the filter is edited. + """ + return self._section_starts(self._items) + def _jump_section(self, direction: int) -> None: """Move the highlight to the next section (``+1``) or the current/previous one (``-1``). @@ -630,6 +684,13 @@ class ReorderScreen(Screen): _HINT_IDLE = "↑↓ move · Enter grab / select · Esc cancel" _HINT_GRABBED = "↑↓ move row · Enter drop · Esc cancel" + @property + def fkey_lane(self): + """No lane: the whole screen is ↑↓ and Enter, three keys already on the keyboard.""" + from .fkeys import EMPTY_LANE + + return EMPTY_LANE + def __init__(self, title: str, labels: list[str]) -> None: """Build a reorder screen. diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index ea4253c..44b70ba 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -60,6 +60,7 @@ from .select import Choice, ReorderScreen, SelectScreen, Separator #: ``Keys.ControlM``/``ControlI``/``ControlH``, so claiming them here would rebind those keys. _CTRL_LETTER_CHORDS: dict[str, str] = { "c": "quit", + "l": "locate", "p": "paths", "r": "retry", "v": "paste_clipboard", diff --git a/meshterm/ui/walk_screen.py b/meshterm/ui/walk_screen.py index 7dfaa94..fe27541 100644 --- a/meshterm/ui/walk_screen.py +++ b/meshterm/ui/walk_screen.py @@ -210,6 +210,24 @@ class WalkScreen(Screen): floating = False + @property + def fkey_lane(self): + """The shared pager over the link list, with ``You`` behind Page ↑ and nothing behind ↓. + + The walk's Home does not scroll to a top — it drops the whole trail and puts the + focus back on our own node — so the Shift companion that would say *Top* says + ``You`` instead. ``End`` is bound to nothing here, so its companion stays blank: + a trail has a beginning (us) but no far end to jump to. Both nav slots need rows + to move through, which a leaf node in a sparse graph may not have. + """ + from .tui.fkeys import FPair, default_lane + + paging = len(self._rows()) > 1 + lane = list(default_lane(nav=paging)) + lane[3] = FPair("Page ↓", "pagedown", enabled=paging) + lane[4] = FPair("Page ↑", "pageup", "You", "home", enabled=paging) + return lane + def __init__( self, *, diff --git a/tests/test_chat.py b/tests/test_chat.py index 7c412da..784ce83 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -893,7 +893,7 @@ async def test_chat_fkey_lane_dims_retry_and_the_nav_slots() -> None: failed = ChatMessage(text="oops", outbound=True, peer="d4e5f6a7", acked=False) screen = _screen(_StubSession(), send=None, messages=[failed], resend=resend) - assert all(pair.enabled for pair in screen.fkey_lane[:2]) # Top/End walk the pick + assert all(pair.enabled for pair in screen.fkey_lane[3:]) # the pager walks the pick assert screen.fkey_lane[2].opp_enabled is True # F8 Retry: the message never acked screen.handle("retry") @@ -908,11 +908,12 @@ def test_chat_fkey_lane_speaks_the_transcript_and_drops_channel_retry() -> None: messages = [ChatMessage(text="hi", peer="d4e5f6a7")] direct = _screen(_StubSession(), send=None, messages=messages) - # Not "Bottom"/"Top": what these do to a conversation is come back to the latest - # message and the compose line, or reach back to its oldest one. Same handedness as - # the shared pair they relabel — the far end of the scroll-up axis on the right. - assert [pair.label for pair in direct.fkey_lane[:2]] == ["Latest", "Oldest"] - assert [pair.action for pair in direct.fkey_lane[:2]] == ["ctrl_end", "ctrl_home"] + # Not "Bottom"/"Top": what the Shift bank does to a conversation is come back to the + # latest message and the compose line, or reach back to its oldest one. Relabelled in + # place, so each jump still rides the pager heading for it. + assert [pair.opp_label for pair in direct.fkey_lane[3:]] == ["Latest", "Oldest"] + assert [pair.opp_action for pair in direct.fkey_lane[3:]] == ["ctrl_end", "ctrl_home"] + assert [pair.label for pair in direct.fkey_lane[3:]] == ["Page ↓", "Page ↑"] assert direct.fkey_lane[2].opp_label == "Retry" channel = ChatScreen( diff --git a/tests/test_gallery.py b/tests/test_gallery.py index 1dd3966..9b761f9 100644 --- a/tests/test_gallery.py +++ b/tests/test_gallery.py @@ -436,9 +436,10 @@ _KNOWN_WIDE: set[str] = set() #: Entries whose footer_hint already overflowed the *existing* 72-column standard before #: this platform seam existed. Not this phase's to fix: CLAUDE.md's 72-col rule predates #: the seam, and per standing guidance old chrome that already broke it is left for a -#: dedicated pass rather than retrofitted as a drive-by here (map's footer grows an -#: "offline — no basemap" suffix that was already too long without it). -_PREEXISTING_REGULAR_OVERFLOW: set[str] = {"map"} +#: dedicated pass rather than retrofitted as a drive-by here. Empty since the F-lane pass +#: of 2026-08-08 rebuilt the map's hint around its new view-jump keys and brought it back +#: inside the budget on the way through. +_PREEXISTING_REGULAR_OVERFLOW: set[str] = set() def _cases(): diff --git a/tests/test_map.py b/tests/test_map.py index fd03f6a..b70fef1 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -14,6 +14,7 @@ import re from pathlib import Path import pytest +from rich.cells import cell_len from rich.console import Console from meshterm.core.geo import BBox, Viewport, haversine_km, lonlat_to_world, world_to_lonlat @@ -1094,7 +1095,11 @@ def test_map_screen_renders_pans_zooms_and_resets() -> None: # Offline source: nodes still render and both markers are present. assert "▲" in _plain(screen.render_body(80)) and "●" in _plain(screen.render_body(80)) - assert "offline" in screen.footer_hint + # The basemap's state is a status atom, so it rides the title's `·` chain (standing in + # for the scale) and leaves the footer a pure key line inside its 72-cell budget. + assert screen.title.endswith("· offline") + assert "offline" not in screen.footer_hint + assert cell_len(screen.footer_hint) <= 72 def test_map_screen_shift_pans_by_a_single_cell() -> None: @@ -1123,7 +1128,7 @@ def test_map_screen_shift_pans_by_a_single_cell() -> None: assert screen._viewport.center_lon == pytest.approx(expected_lon) -def test_map_fkey_lane_names_zoom_and_drops_the_dead_slot() -> None: +def test_map_fkey_lane_names_its_three_destinations_and_the_zoom() -> None: """The map repurposes the nav keys, so its lane labels them — and offers no dead key.""" from meshterm.ui.map_render import MapMarker from meshterm.ui.map_screen import MapScreen @@ -1132,15 +1137,73 @@ def test_map_fkey_lane_names_zoom_and_drops_the_dead_slot() -> None: screen = MapScreen(_StubSession(80, 24), [MapMarker("A", 45.5, -73.6)], _StubSource(), 14) lane = screen.fkey_lane - # PgUp/PgDn zoom here and Home refits — the shared "Top/End/PgUp/PgDn" labels would - # all be lies, and "end" is bound to nothing at all. + # PgUp/PgDn zoom here and Home reframes — the shared paging labels would all be lies, + # and "end" is bound to nothing at all, so no jump rides the Shift bank. # The zoom pair rises to the right, like every directional pair on the lane. assert [pair.label if pair else None for pair in lane] == [ - "Reset", None, None, "Zoom -", "Zoom +", + "Region", "You", "Frame", "Zoom -", "Zoom +", ] - assert action_for(lane, 2) is None # F2 offers nothing rather than a dead End + assert all(action_for(lane, n) is None for n in range(6, 11)) assert action_for(lane, 1) == "home" and action_for(lane, 5) == "pageup" - assert all(pair.enabled for pair in lane if pair) # a map can always zoom or refit + assert action_for(lane, 2) == "locate" and action_for(lane, 3) == "frame" + # Region and the zoom always act; the other two only where they'd land somewhere. + assert [pair.enabled for pair in lane] == [True, False, False, True, True] + + +def test_map_locate_recenters_on_our_own_node_at_the_current_zoom() -> None: + """``You`` moves the view to us and changes nothing else — not even the zoom.""" + from meshterm.ui.map_render import MapMarker + from meshterm.ui.map_screen import MapScreen + + markers = [ + MapMarker("YUL-Cartierville", 45.53, -73.71, is_repeater=True), + MapMarker("Homestead", 45.40, -73.50, is_self=True), + ] + screen = MapScreen(_StubSession(80, 24), markers, _StubSource(), 14) + screen.render_body(80) + screen.handle("pageup") # zoom in one step, so the recentre has a zoom to preserve + zoom = screen._viewport.zoom + screen.handle("left") # and pan away from wherever we are + + assert screen.fkey_lane[1].enabled is True # we're on the map, so You can act + screen.handle("locate") + assert screen._viewport.center_lat == pytest.approx(45.40) + assert screen._viewport.center_lon == pytest.approx(-73.50) + assert screen._viewport.zoom == zoom + + # A mesh we aren't located in has nowhere to go: the chip dims and the key no-ops. + away = MapScreen(_StubSession(80, 24), markers[:1], _StubSource(), 14) + away.render_body(80) + before = away._viewport + away.handle("locate") + assert away.fkey_lane[1].enabled is False and away._viewport is before + + +def test_map_frame_chip_lights_only_with_matches_to_frame() -> None: + """``Frame`` is the find's Enter under another name — dim, and inert, without a query.""" + from meshterm.ui.map_render import MapMarker + from meshterm.ui.map_screen import MapScreen + + markers = [ + MapMarker("YUL-Cartierville", 45.53, -73.71, is_repeater=True), + MapMarker("Alice", 45.40, -73.50), + ] + screen = MapScreen(_StubSession(80, 24), markers, _StubSource(), 14) + screen.render_body(80) + before = screen._viewport + screen.handle("frame") # no query typed: nothing to frame, so nothing moves + assert screen.fkey_lane[2].enabled is False and screen._viewport is before + + for ch in "ali": + screen.handle("text", ch) + assert screen.fkey_lane[2].enabled is True + screen.handle("frame") + assert screen._viewport.center_lat == pytest.approx(45.40) + + # A query nothing matches has no extent either — the chip goes back to dim. + for ch in "zzz": + screen.handle("text", ch) + assert screen._matches() == [] and screen.fkey_lane[2].enabled is False def test_map_screen_find_filters_frames_and_clears() -> None: diff --git a/tests/test_node_detail.py b/tests/test_node_detail.py index 063b2d0..d12ae3b 100644 --- a/tests/test_node_detail.py +++ b/tests/test_node_detail.py @@ -820,10 +820,13 @@ def test_node_detail_screen_tabs_switch_the_stage() -> None: assert "│ Info │" in body and "centred here" in body assert screen.consume_edge_scrub() == 2 + # The F-key chip names where the switch would take you, so it flips with the stage. + assert screen.fkey_lane[2].label == "Routes" screen.handle("tab") # switch to the Routes tab body = _plain(screen.render_body(72)) assert "│ Routes │" in body and "no route observed yet" in body assert screen.consume_edge_scrub() == 0 # the braille preview isn't showing now + assert screen.fkey_lane[2].label == "Info" def test_node_detail_single_tab_hides_the_switch_hint() -> None: @@ -831,3 +834,5 @@ def test_node_detail_single_tab_hides_the_switch_hint() -> None: screen = _screen(tabs=[_Tab("Info", "info")]) # our own node: no Routes tab assert "←→ tab" not in screen.footer_hint assert "↑↓ move" in screen.footer_hint and screen.footer_hint.endswith("Esc back") + # Nothing to switch to, so the lane leaves the slot empty rather than dimming it. + assert screen.fkey_lane[2] is None diff --git a/tests/test_packet_viewer.py b/tests/test_packet_viewer.py index c405aa0..8a3678c 100644 --- a/tests/test_packet_viewer.py +++ b/tests/test_packet_viewer.py @@ -121,6 +121,25 @@ def test_packet_viewer_heard_row_says_now_not_now_ago() -> None: assert "now ago" not in body +def test_packet_viewer_lane_names_the_ends_of_the_list_not_the_body() -> None: + """Home/End reach the newest and oldest packet here, so the Shift bank says so.""" + old = PacketEntry(when=utcnow(), kind="advert", node="aa") + newer = PacketEntry(when=utcnow(), kind="message", node="bb", text="new!") + + lone = PacketViewer([old], 0, resolve=lambda h: "") + lone.note_metrics(4, 20) # a short body in a roomy box: nothing to page either + assert [pair.opp_label for pair in lone.fkey_lane[3:]] == ["Oldest", "Newest"] + assert not any(pair.enabled or pair.opp_enabled for pair in lone.fkey_lane[3:]) + + # A second packet lights the jumps on their own — the pager still needs a tall body. + pair_view = PacketViewer([newer, old], 0, resolve=lambda h: "") + pair_view.note_metrics(4, 20) + assert [pair.opp_enabled for pair in pair_view.fkey_lane[3:]] == [True, True] + assert [pair.enabled for pair in pair_view.fkey_lane[3:]] == [False, False] + pair_view.note_metrics(80, 20) + assert [pair.enabled for pair in pair_view.fkey_lane[3:]] == [True, True] + + def test_packet_viewer_without_a_source_stays_a_snapshot() -> None: """With no live source the viewer is frozen on its opening list (unchanged behaviour).""" entry = PacketEntry(when=utcnow(), kind="advert", node="aa") diff --git a/tests/test_picocalc_chrome.py b/tests/test_picocalc_chrome.py index e4afdc8..b42e0bf 100644 --- a/tests/test_picocalc_chrome.py +++ b/tests/test_picocalc_chrome.py @@ -82,14 +82,13 @@ def test_picocalc_header_brands_the_app_not_the_device() -> None: def test_fkey_lane_resolution_and_banks() -> None: lane = DEFAULT_LANE - # F4/F5 (paging — no physical key at all) are plain-key only, no Shift companion. - assert action_for(lane, 4) == "pagedown" and action_for(lane, 9) is None - assert action_for(lane, 5) == "pageup" and action_for(lane, 10) is None - # F1/F2 carry the Fn-layered jump-to-end/top pair, also with no Shift companion. - assert action_for(lane, 1) == "end" and action_for(lane, 6) is None - assert action_for(lane, 2) == "home" and action_for(lane, 7) is None - # F3 is the lone, unassigned slot a screen's own lane fills in. - assert action_for(lane, 3) is None and action_for(lane, 8) is None + # F4/F5 (paging — no physical key at all) carry the pager, and each jump rides the + # Shift half of the very pager heading for it: Home behind Page ↑, End behind Page ↓. + assert action_for(lane, 4) == "pagedown" and action_for(lane, 9) == "end" + assert action_for(lane, 5) == "pageup" and action_for(lane, 10) == "home" + # F1-F3 are free on every screen — the shared lane claims none of them. + for number in (1, 2, 3, 6, 7, 8): + assert action_for(lane, number) is None # Enter/Esc never occupy a slot — both keys are already close at hand. assert "enter" not in {action_for(lane, n) for n in range(1, 11)} assert "escape" not in {action_for(lane, n) for n in range(1, 11)} @@ -99,13 +98,14 @@ def test_fkey_lane_text_fits_and_flips() -> None: primary = lane_text(DEFAULT_LANE) shifted = lane_text(DEFAULT_LANE, shifted=True) assert cell_len(primary.plain) == 53 and cell_len(shifted.plain) == 53 - assert "F1 Bottom" in primary.plain # the chips name actions, not the keys they sit on - # A directional pair rises to the right: down/out left, up/in right. + # A directional pair rises to the right: down/out left, up/in right — and each + # companion keeps its slot's end of that axis. assert "F4 Page ↓" in primary.plain and "F5 Page ↑" in primary.plain - # No slot has a Shift companion by default: the whole shifted bank renders unfilled. - for number in (6, 7, 8, 9): - assert f"F{number}" in shifted.plain - assert "10" in shifted.plain + assert "F9 Bottom" in shifted.plain and "10 Top" in shifted.plain + # The free bank renders as bare, unfilled key numbers in both banks. + for number in (1, 2, 3): + assert f"F{number} " in primary.plain + assert f"F{number + 5} " in shifted.plain wide = [FPair("Muchtoolonglabel", "a", "Muchtoolonglabel", "b")] * 5 assert cell_len(lane_text(wide).plain) == 53 # clipped to the slot budget assert cell_len(lane_text(wide, shifted=True).plain) == 53 @@ -132,11 +132,13 @@ def test_default_lane_dims_every_nav_slot_when_nothing_moves() -> None: assert default_lane() is DEFAULT_LANE # the live lane is the constant itself dim = default_lane(nav=False) - assert [pair.label for pair in dim if pair] == ["Bottom", "Top", "Page ↓", "Page ↑"] - assert not any(pair.enabled for pair in dim if pair) + assert [pair.label for pair in dim if pair] == ["Page ↓", "Page ↑"] + assert not any(pair.enabled or pair.opp_enabled for pair in dim if pair) row = lane_text(dim) - assert cell_len(row.plain) == 53 and "F1 Bottom" in row.plain and "F5 Page ↑" in row.plain + assert cell_len(row.plain) == 53 and "F4 Page ↓" in row.plain and "F5 Page ↑" in row.plain assert {str(span.style) for span in row.spans} == {"muted"} + # Both banks dim together: the jump behind a dead pager is just as dead. + assert {str(span.style) for span in lane_text(dim, shifted=True).spans} == {"muted"} def test_lane_is_built_after_the_body_renders() -> None: @@ -164,6 +166,61 @@ def test_scroll_screen_lane_tracks_whether_its_body_overflows() -> None: assert all(pair.enabled for pair in screen.fkey_lane if pair) +def test_select_lane_promotes_the_section_jumps_only_where_there_are_sections() -> None: + """Ctrl+PgUp/PgDn is unreachable on a keyboard with no PgUp, so a grouped list lanes it.""" + from meshterm.ui.tui.select import Choice, Separator, SelectScreen + + flat = SelectScreen("Flat", [Choice("one", 1), Choice("two", 2)]) + # No headings anywhere: sections are not a thing on this list, so the slots are empty + # rather than dim — and F1/F2 resolve to nothing at all. + assert flat.fkey_lane[0] is None and flat.fkey_lane[1] is None + assert action_for(flat.fkey_lane, 1) is None + + grouped = SelectScreen("Grouped", [ + Separator("── Near ──"), Choice("alpha", 1), Choice("beta", 2), + Separator("── Far ──"), Choice("gamma", 3), + ]) + lane = grouped.fkey_lane + assert [pair.label for pair in lane[:2]] == ["Sect ↓", "Sect ↑"] + # Down left, up right — the same handedness the pager below them reads with. + assert action_for(lane, 1) == "ctrl_pagedown" and action_for(lane, 2) == "ctrl_pageup" + assert all(pair.enabled for pair in lane[:2]) + + # A filter that collapses the list onto one section keeps the labels and dims them: + # the sections are still a thing here, they just have nowhere to jump right now. + for ch in "gam": + grouped.handle("text", ch) + dim = grouped.fkey_lane + assert [pair.label for pair in dim[:2]] == ["Sect ↓", "Sect ↑"] + assert not any(pair.enabled for pair in dim[:2]) + + +def test_dialogs_draw_no_lane_at_all() -> None: + """A prompt has nothing to page, so it shows bare key numbers, not a dim pager.""" + from meshterm.ui.tui.fkeys import EMPTY_LANE + from meshterm.ui.tui.prompt import ButtonDialog, ConfirmScreen, TextScreen + + for screen in ( + TextScreen("Name", prompt="Call it what?"), + ConfirmScreen("Sure?"), + ButtonDialog("Reboot the node?", ["Cancel", "Reboot"]), + ): + assert list(screen.fkey_lane) == list(EMPTY_LANE) + row = lane_text(screen.fkey_lane) + assert cell_len(row.plain) == 53 + assert {str(span.style) for span in row.spans} == {"muted"} + + +def test_map_locate_is_reachable_from_both_control_keys() -> None: + """``locate`` is bound as a chord app-wide, so both Ctrl keys and F2 reach the same action.""" + from prompt_toolkit.keys import Keys + + from meshterm.ui.tui.session import _CTRL_LETTER_CHORDS, _KEY_ACTIONS + + assert _CTRL_LETTER_CHORDS["l"] == "locate" # the right-Ctrl rescue's half + assert _KEY_ACTIONS[Keys.ControlL] == "locate" # the ordinary binding, generated from it + + def test_host_battery_reads_the_sysfs_supply(tmp_path, monkeypatch) -> None: """The PicoCalc battery path: a true percent and charging flag straight from sysfs.""" from meshterm.services import battery_service diff --git a/tests/test_timemachine.py b/tests/test_timemachine.py index f4d74bc..f376581 100644 --- a/tests/test_timemachine.py +++ b/tests/test_timemachine.py @@ -907,6 +907,14 @@ def test_screen_cycles_windows_and_caches(tmp_path: Path) -> None: screen.render_body(80) assert len(calls) == 2 + # The F-key chip is the same behaviour under another name — the only way a platform + # that draws no hint line can learn that `w` exists at all. It names the span it takes + # you *to*, since the title already says where you are. + assert screen.fkey_lane[2].label == "▸ all" + screen.handle("window") + assert "all time" in screen.title + assert screen.fkey_lane[2].label == "▸ 24 h" # the ring wraps back around + # --- the own-node page ------------------------------------------------------------------ diff --git a/tests/test_walk.py b/tests/test_walk.py index 31ad253..e108f04 100644 --- a/tests/test_walk.py +++ b/tests/test_walk.py @@ -315,6 +315,22 @@ def test_walk_home_refocuses_us() -> None: assert screen._trail == [topo.self_id] +def test_walk_fkey_lane_says_you_where_the_shared_lane_would_say_top() -> None: + """Home drops the trail rather than scrolling, and End is bound to nothing at all.""" + from meshterm.ui.tui.fkeys import action_for + + screen = _screen(_topo()) + screen.render_body(80) + lane = screen.fkey_lane + + assert [pair.label if pair else None for pair in lane] == [ + None, None, None, "Page ↓", "Page ↑", + ] + # The jump behind Page ↑ goes back to us; the one behind Page ↓ doesn't exist here. + assert lane[4].opp_label == "You" and action_for(lane, 10) == "home" + assert lane[3].opp_label == "" and action_for(lane, 9) is None + + def test_walk_came_from_anchors_west_and_the_fan_stays_east() -> None: """The trail-back node sits at the far west; every fan node east of the focus.""" topo = _topo()