diff --git a/CLAUDE.md b/CLAUDE.md index 87ea3d2..bc44d25 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,6 +76,13 @@ deliberately, one at a time, and say why in the code. refreshes in place with `SelectScreen.replace_items`, which follows the highlighted row by value and keeps the filter. Rebuilding the screen is for when the rows it was holding a place in are genuinely gone (a purge, a delete) — and then say so. +- **Esc peels before it leaves.** A screen carrying a find-as-you-type filter treats the + typed query as the most recent thing the reader entered: the first Esc clears it and the + screen stays, the second leaves. One rule on all four (`SelectScreen` and everything + built on it, the path composer, the map, the mesh walk), and the footer says `Esc clear` + for as long as it is true. Chat is the same shape with a different peelable thing — its + first Esc unpicks a selected message. It used to be a 2–2 split, which put opposite + outcomes behind the same keystroke on the same affordance. - **^W** unwinds every frame back to the main menu, **^Q** quits from anywhere. Both are answered in `TuiSession._dispatch` and neither is ever advertised — a global verb has no screen to belong to, and the F-key lane has only three free slots per screen. They are @@ -151,7 +158,8 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow - ≤72 cells. Sentence shape: navigation keys, then action keys, **Esc last**. - Esc verb by surface: `Esc back` leaves a screen · `Esc close` dismisses a read-only floating view · `Esc cancel` abandons a prompt/dialog · `Esc keep` leaves a value - picker unchanged · `Esc quit` only at the main menu. + picker unchanged · `Esc quit` only at the main menu. While a find-as-you-type filter + is standing the verb becomes `Esc clear`, because that is what the press does then. - Enter verb by action: `Enter open` when the row pushes a screen or dialog · `Enter select` when it picks a value or action row · `Enter set` in a value picker. A more specific committing verb (`Enter adopt path`, `Enter add`) is fine; a synonym diff --git a/meshterm/ui/path_composer.py b/meshterm/ui/path_composer.py index 196b0ff..2ee44cb 100644 --- a/meshterm/ui/path_composer.py +++ b/meshterm/ui/path_composer.py @@ -139,7 +139,22 @@ class PathComposerScreen(Screen): """ grow_only = True - footer_hint = "↑↓ move · ←→ cursor · Enter add · type to filter · ⌫ remove · Esc cancel" + + #: The composing keys, in the hint grammar's navigation-then-actions-then-Esc order. + #: Esc's verb is the only part that moves — see :attr:`footer_hint`. + _HINT = "↑↓ move · ←→ cursor · Enter add · type to filter · ⌫ remove · Esc cancel" + + @property + def footer_hint(self) -> str: # type: ignore[override] + """The composing keys, with ``Esc clear`` standing in while an entry is typed. + + Esc peels the typed entry before it abandons the composer, exactly as ⌫ peels it + before it removes a hop, so the line says which of the two the press will do (the + app-wide filter rule — the map, the mesh walk and every select list read the same). + """ + if not self._entry: + return self._HINT + return self._HINT.replace("Esc cancel", "Esc clear") def __init__( self, @@ -715,4 +730,11 @@ class PathComposerScreen(Screen): self._entry += data self._index = 0 elif action == "escape": - super().handle("escape") + # The typed entry is the most recent thing the reader entered, so Esc peels it + # first — the same layering ⌫ already has here (entry, then a hop) and the + # app-wide rule for a find-as-you-type screen. + if self._entry: + self._entry = "" + self._index = 0 + else: + super().handle("escape") diff --git a/meshterm/ui/tui/select.py b/meshterm/ui/tui/select.py index dca6c47..829d342 100644 --- a/meshterm/ui/tui/select.py +++ b/meshterm/ui/tui/select.py @@ -138,6 +138,25 @@ def _splice_hint(base: str, segment: str) -> str: return f"{base[:idx]} · {segment}{base[idx:]}" +def _esc_verb(base: str, verb: str) -> str: + """Rewrite a footer hint's trailing ``Esc`` clause to ``Esc ``. + + Esc's verb is fixed per surface (``back``, ``cancel``, ``keep`` …) — except while a + find-as-you-type filter is standing, where the press peels the filter instead of + leaving, and the line has to say so. The map and the mesh walk have always spelled that + ``Esc clear``; this is the same swap for a list, which keeps the rest of its atoms + rather than giving the whole line over to the query. + + A hint with no ``Esc`` clause is returned unchanged: there is nothing being claimed to + correct. + """ + marker = " · Esc " + idx = base.rfind(marker) + if idx == -1: + return base + return f"{base[:idx]}{marker}{verb}" + + def _insert_atom(base: str, atom: str, index: int = 1) -> str: """Insert ``atom`` as the ``index``-th ` · `-separated atom of a footer hint. @@ -485,6 +504,9 @@ class SelectScreen(Screen): * the :attr:`_hscroll_hint` atom (``hscroll`` lists only), inserted right after the move atom (see :func:`_insert_atom`) while the highlighted row overflows the width — a short row scrolls nowhere, so ←→ stays hidden on it. + + And the trailing ``Esc`` clause turns into ``Esc clear`` while a filter is typed, + because that is what the press does then (see :meth:`handle`). """ base = self._footer_base current = self._current_choice() @@ -492,6 +514,8 @@ class SelectScreen(Screen): base = _splice_hint(base, self._delete_hint) if self._hscroll and self._hscroll_hint and self._selected_overflows(): base = _insert_atom(base, self._hscroll_hint) + if self._filter: + base = _esc_verb(base, "clear") return base def _selected_overflows(self) -> bool: @@ -814,7 +838,15 @@ class SelectScreen(Screen): elif action == "right" and self._hscroll: self._hshift += self._HSCROLL_STEP # clamped to the highlighted row's tail at render elif action == "escape": - super().handle("escape") + # A typed filter is the most recent thing the reader entered, so Esc peels that + # before it leaves — the map and the mesh walk's rule, now every filtering + # screen's. Leaving a narrowed list is the second Esc. + if self._filterable and self._filter: + self._filter = "" + self._index = 0 + self._hshift = 0 + else: + super().handle("escape") elif action == "backspace" and self._filterable: self._filter = self._filter[:-1] self._index = 0 diff --git a/tests/test_navigation.py b/tests/test_navigation.py index c1dd711..12f5eea 100644 --- a/tests/test_navigation.py +++ b/tests/test_navigation.py @@ -357,6 +357,72 @@ def test_update_rows_resorts_the_lanes_and_keeps_the_highlight_on_its_contact() assert [c.value for c in screen._choices()] == ["beta", "alpha"] +# --- Esc peels the filter before it leaves ----------------------------------- + + +async def test_a_typed_filter_is_peeled_before_the_list_is_left() -> None: + """Esc clears a standing filter and stays; the next Esc leaves. + + The map and the mesh walk always did this. The select list and the path composer did + not — the same keys on the same affordance, opposite outcomes — so typing three letters + on a contact list and pressing Esc dropped you at the main menu. + """ + screen = SelectScreen("Contacts", [Choice(n, n) for n in ("alpha", "beta", "gamma")]) + screen.future = asyncio.get_running_loop().create_future() + screen.handle("text", "b") + assert [c.value for c in screen._choices()] == ["beta"] + + screen.handle("escape") + assert screen._filter == "", "the filter is what the first Esc peels" + assert not screen.future.done(), "…and the screen is still here" + assert [c.value for c in screen._choices()] == ["alpha", "beta", "gamma"] + + screen.handle("escape") + assert screen.future.result() is CANCEL, "the second Esc leaves" + + +def test_the_footer_says_clear_while_a_filter_is_standing() -> None: + """Esc's verb follows what the press will actually do — the map and walk's wording.""" + screen = SelectScreen( + "Contacts", + [Choice("alpha", "a")], + footer_hint="↑↓ move · Enter open · type to filter · Esc back", + ) + assert screen.footer_hint.endswith("Esc back") + screen.handle("text", "a") + assert screen.footer_hint.endswith("Esc clear") + screen.handle("backspace") + assert screen.footer_hint.endswith("Esc back") + + +async def test_the_path_composer_peels_its_typed_entry_first() -> None: + """Esc unwinds the composer the way ⌫ already did: the entry, then the screen.""" + from meshterm.services.topology import build_topology + from meshterm.ui.path_composer import PathComposerScreen + + self_id = "aa" * 6 + "0" * 52 + screen = PathComposerScreen( + device_label="Homestead", + device_hash=self_id, + topology=build_topology( + self_id=self_id, contacts=[], trace_paths=[], packet_paths=[], + neighbour_links=[], + ), + width_bytes=1, + ) + screen.future = asyncio.get_running_loop().create_future() + screen.handle("text", "a") + screen.handle("text", "1") + assert screen.footer_hint.endswith("Esc clear") + + screen.handle("escape") + assert not screen.future.done(), "the entry is peeled, the composer stays" + assert screen.footer_hint.endswith("Esc cancel") + + screen.handle("escape") + assert screen.future.result() is CANCEL + + # --- ^W, the pop-all ---------------------------------------------------------