diff --git a/CLAUDE.md b/CLAUDE.md index e528a72..b0a0de7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -219,7 +219,7 @@ branch on the platform per frame, and never `from meshterm.platforms import PLAT 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 + jumps, the Time Machine's `w`, the map's `^U`, 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. diff --git a/meshterm/ui/map_screen.py b/meshterm/ui/map_screen.py index cec3164..d2e5577 100644 --- a/meshterm/ui/map_screen.py +++ b/meshterm/ui/map_screen.py @@ -14,8 +14,8 @@ render_map`. Keys: * ``PgUp`` / ``PgDn`` zoom in / out, * ``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 and clearing any - find (the lane's ``You`` chip; its Shift half also zooms in close), +* ``^U`` (for *you*) recenters on **your own node**, keeping the zoom you chose and + clearing any find (the lane's ``You`` chip; its Shift half also zooms in close), * **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 @@ -224,7 +224,7 @@ class MapScreen(Screen): 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 + (``Home/^U 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 @@ -233,7 +233,7 @@ class MapScreen(Screen): """ if self._filter: return f"find: {self._filter}▏ · Enter frame · ⌫ erase · Esc clear" - return "↑↓←→ pan · PgUp/PgDn zoom · Home/^L region/you · type to find · Esc back" + return "↑↓←→ pan · PgUp/PgDn zoom · Home/^U 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). @@ -520,7 +520,7 @@ class MapScreen(Screen): 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 — ``locate_zoom`` + ``home`` the whole region, ``locate`` (^U, for *you*) our own node — ``locate_zoom`` the Shift-bank variant that also homes in — and ``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. @@ -589,7 +589,7 @@ class MapScreen(Screen): return next((m for m in self._markers if m.is_self), None) def _locate(self, vp: Viewport, *, zoom_in: bool = False) -> None: - """Recentre on our own node (``Ctrl+L`` / the ``You`` chip), clearing any find. + """Recentre on our own node (``^U`` / the ``You`` chip), clearing any find. Plain ``You`` keeps the zoom the user chose — pressing it twice does the same thing twice, and pairing it with one Zoom + is a single extra press. Its Shift diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index 84535f8..a2b2991 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -58,11 +58,14 @@ from .select import Choice, ReorderScreen, SelectScreen, Separator #: genuine third-level character arrives as some *other* glyph and never matches this table. #: ``m``/``i``/``h`` are not free: prompt_toolkit spells Enter, Tab and Backspace as #: ``Keys.ControlM``/``ControlI``/``ControlH``, so claiming them here would rebind those keys. +#: A chord's letter is the mnemonic of the *action*, not of one screen's word for it — +#: ``locate`` is ^U for **you** (JP, 2026-08-09), the same key on the map and in the mesh +#: walk, because what it names on both is our own node. _CTRL_LETTER_CHORDS: dict[str, str] = { "c": "quit", - "l": "locate", "p": "paths", "r": "retry", + "u": "locate", "v": "paste_clipboard", } diff --git a/meshterm/ui/walk_screen.py b/meshterm/ui/walk_screen.py index 195636a..ea94b23 100644 --- a/meshterm/ui/walk_screen.py +++ b/meshterm/ui/walk_screen.py @@ -213,20 +213,24 @@ class WalkScreen(Screen): @property def fkey_lane(self): - """The shared pager over the link list, with ``You`` behind Page ↑ and nothing behind ↓. + """``You`` on the screen's own F3, the shared pager and its two ends on F4/F5. - 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. + ``You`` is not an end of the link list — it drops the whole trail and puts the + focus back on our own node — so it never belonged behind the pager, where it had + to stand in for *Top* and leave *Bottom* blank to keep the pretence. It takes a + slot of the screen's own instead (JP, 2026-08-09), which is what F1-F3 are for, + and the pager's Shift bank goes back to meaning here what it means everywhere + else: the two ends of the list this screen scrolls. ^U reaches the same action on + a keyboard, the same chord the map spends on our node. + + Both nav slots need rows to move through, which a leaf node in a sparse graph may + not have; ``You`` needs somewhere to come back *from* — a walked trail, or a find + narrowing the list — and dims once the focus is already us. """ 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) + lane = list(default_lane(nav=len(self._rows()) > 1)) + lane[2] = FPair("You", "locate", enabled=len(self._trail) > 1 or bool(self._filter)) return lane def __init__( @@ -364,10 +368,15 @@ class WalkScreen(Screen): """Walking keys — or the live find query while one is being typed.""" if self._filter: return f"find: {self._filter}▏ · ↑↓ move · Enter focus · ⌫ erase · Esc clear" - return "↑↓ move · Enter focus · ⌫ back · Home you · type to find · Esc back" + return "↑↓ move · Enter focus · ⌫ back · ^U you · type to find · Esc back" def handle(self, action: str, data: str = "") -> None: - """Move the highlight, walk, back up, find, or dismiss.""" + """Move the highlight, walk, back up, find, or dismiss. + + Home/End are the list's own ends here, as on every other scrolling screen; the jump + back to our own node is ``locate`` (^U, and the lane's F3), which is not a place in + this list at all — see :attr:`fkey_lane`. + """ rows = self._rows() if action == "escape": if self._filter: @@ -395,7 +404,13 @@ class WalkScreen(Screen): elif len(self._trail) > 1: self._trail.pop() self._index = 0 - elif action in ("home", "ctrl_home"): + elif action in ("home", "ctrl_home") and rows: + self._index = 0 + elif action in ("end", "ctrl_end") and rows: + self._index = len(rows) - 1 + elif action == "locate": + # ^U (and F3): abandon the walk rather than move within it — the trail goes + # back to just us and any find narrowing the list is dropped with it. self._trail = [self._topo.self_id] self._filter = "" self._index = 0 diff --git a/tests/test_picocalc_chrome.py b/tests/test_picocalc_chrome.py index d73969e..e260281 100644 --- a/tests/test_picocalc_chrome.py +++ b/tests/test_picocalc_chrome.py @@ -212,13 +212,17 @@ def test_dialogs_draw_no_lane_at_all() -> None: 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.""" + """``locate`` is bound as a chord app-wide, so both Ctrl keys and F2 reach the same action. + + The letter is the mnemonic of the action — ^U for *you* — so it is the same chord on + every screen that can point at our own node, not one screen's initial. + """ 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 + assert _CTRL_LETTER_CHORDS["u"] == "locate" # the right-Ctrl rescue's half + assert _KEY_ACTIONS[Keys.ControlU] == "locate" # the ordinary binding, generated from it def test_host_battery_reads_the_sysfs_supply(tmp_path, monkeypatch) -> None: diff --git a/tests/test_walk.py b/tests/test_walk.py index 4f6bd3e..254eae4 100644 --- a/tests/test_walk.py +++ b/tests/test_walk.py @@ -305,13 +305,22 @@ def test_walk_trail_names_carry_their_node_hues() -> None: ) -def test_walk_home_refocuses_us() -> None: - """Home resets the walk to our own node from anywhere.""" +def test_walk_locate_refocuses_us_and_home_end_walk_the_list() -> None: + """^U resets the walk to our own node from anywhere; Home/End are the list's ends.""" topo = _topo() screen = _screen(topo) screen.render_body(80) screen.handle("enter") # us → YUL + rows = screen._rows() + assert len(rows) > 1, "the fixture's YUL needs a list to move within" + + # Home/End move the highlight — they no longer abandon the walk. + screen.handle("end") + assert screen._index == len(rows) - 1 and screen._trail[-1] != topo.self_id screen.handle("home") + assert screen._index == 0 and screen._trail[-1] != topo.self_id + + screen.handle("locate") assert screen._trail == [topo.self_id] @@ -348,8 +357,8 @@ def test_walk_echoes_the_find_query_above_the_matches_it_narrows() -> None: set_platform(REGULAR) -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.""" +def test_walk_fkey_lane_gives_you_its_own_slot_over_the_pagers_ends() -> None: + """``You`` is a verb of this screen (F3), not an end of the list the pager scrolls.""" from meshterm.ui.tui.fkeys import action_for screen = _screen(_topo()) @@ -357,11 +366,28 @@ def test_walk_fkey_lane_says_you_where_the_shared_lane_would_say_top() -> None: lane = screen.fkey_lane assert [pair.label if pair else None for pair in lane] == [ - None, None, None, "Page ↓", "Page ↑", + None, None, "You", "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 + assert action_for(lane, 3) == "locate" + assert lane[2].opp_label == "" # nothing is the opposite of going home + # And the pager's Shift bank means what it means everywhere else: the list's two ends. + assert lane[4].opp_label == "Top" and action_for(lane, 10) == "home" + assert lane[3].opp_label == "Bottom" and action_for(lane, 9) == "end" + + +def test_walk_you_dims_once_the_focus_is_already_us() -> None: + """Dim says *a thing here, just not right now* — there is nowhere to come back from.""" + screen = _screen(_topo()) + screen.render_body(80) + assert screen.fkey_lane[2].enabled is False # opens on us, trail of one + + screen.handle("enter") # walked away: now there is + assert screen.fkey_lane[2].enabled is True + + screen.handle("locate") + assert screen.fkey_lane[2].enabled is False + screen.handle("text", "a") # a find narrows the list off our own neighbours too + assert screen.fkey_lane[2].enabled is True def test_walk_came_from_anchors_west_and_the_fan_stays_east() -> None: