Esc peels a typed filter before it leaves, everywhere

Four screens carry a find-as-you-type filter and Esc meant two things across
them: the map and the mesh walk cleared the query and stayed, while the select
list and the path composer left the screen, filter and all. Typing "yul" on the
map then pressing Esc showed you the map; typing "yul" on a contact list then
pressing Esc dropped you at the main menu. Same affordance, same glyphs,
opposite outcome — and the select list is by far the most-used of the four.

They peel now, all of them: the typed query is the most recent thing the reader
entered, so the first Esc takes it back and the second leaves. The footer says
which, swapping its trailing Esc verb for "Esc clear" while a filter stands —
the word the map and the walk have always used, via a _esc_verb helper beside
the two atom-splicers that already bend that line. The composer's Esc now
layers exactly as its own backspace does: the entry first, then the screen.

This was finding D in docs/survey-navigation.md, open since the survey was
written and the last thing left telling a reader what Esc does. The rule is now
stated in CLAUDE.md under Navigation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-08-30 18:02:21 -04:00
1 parent 72e11e17e6
commit deea9ebdd2
4 files changed
+132 -4

No files matched your search

+9 -1
View File
@@ -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
+24 -2
View File
@@ -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")
+33 -1
View File
@@ -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 <verb>``.
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
+66
View File
@@ -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 ---------------------------------------------------------