F-key lane: a jump rides the Shift half of the page it follows

Home and End are on the PicoCalc's keyboard. Paging is not. So the
jump-to-either-end pair was never the unreachable thing that earned it
F1/F2 -- it only ever wanted a chip for consistency with the pager it
belongs to. Now it rides that pager: Shift+F5 is Top because F5 is
Page up, Shift+F4 is Bottom because F4 is Page down. One axis, one pair
of keys, the second-grade verb sitting behind the first.

That frees F1-F3 on every screen, and the lane is the only place the
device can advertise anything -- there is no hint line to read a chord
off. So the freed slots went to the affordances that were invisible
there:

  select lists  Sect down/up, the section jumps. Ctrl+PgUp on a
                keyboard with no PgUp is the one binding in the app
                that was physically unreachable. Plus Delete, lit on
                exactly the rows the key acts on.
  map           Region (Home, renamed -- it frames the ground the mesh
                covers, it does not "reset" anything), You (new: Ctrl+L,
                recentre on our own node at the zoom you chose), and
                Frame for the find matches.
  node page     the tab it would switch you to.
  time machine  the window span it would take you to -- w was
                undiscoverable on the device.

The map is the one exception to the new rule: Region answers "where
should I be looking", not "how far down the body am I", so it keeps F1
beside the other two destinations rather than hiding behind a pager it
has nothing to do with.

Screens that never scroll (every dialog, the busy splash, the reorder
list) now say so with an empty lane instead of offering a pager that
does nothing. Screens whose Home/End mean something else say what:
Latest/Oldest in a chat, Newest/Oldest in the packet viewer, You on the
walk (where End is bound to nothing, so its slot stays blank).

The map's hint line finally fits 72: its basemap status was never a key
hint, so it moved to the title's status chain where the standards put
those -- which is what took the map off the gallery's pre-existing
overflow list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-08-08 14:56:42 -04:00
1 parent e00a0529fa
commit 8a80d47815
26 files changed
+647 -136

No files matched your search

+21 -13
View File
@@ -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
+14 -14
View File
@@ -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,
+8 -4
View File
@@ -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 →",
+17
View File
@@ -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,
*,
+98 -29
View File
@@ -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
+24
View File
@@ -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,
*,
+23
View File
@@ -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],
+18
View File
@@ -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,
*,
+16
View File
@@ -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
+28 -2
View File
@@ -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()
+7
View File
@@ -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.
+40 -26
View File
@@ -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
+7
View File
@@ -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__()
+27 -7
View File
@@ -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
+11 -3
View File
@@ -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__()
+66 -5
View File
@@ -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.
+1
View File
@@ -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",
+18
View File
@@ -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,
*,
+7 -6
View File
@@ -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(
+4 -3
View File
@@ -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():
+70 -7
View File
@@ -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:
+5
View File
@@ -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
+19
View File
@@ -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")
+74 -17
View File
@@ -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
+8
View File
@@ -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 ------------------------------------------------------------------
+16
View File
@@ -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()