diff --git a/CLAUDE.md b/CLAUDE.md index 180acd9..5decfcd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -187,8 +187,15 @@ 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. While a find-as-you-type filter - is standing the verb becomes `Esc clear`, because that is what the press does then. + picker unchanged · `Esc quit` only at the main menu · `Esc bye` only on the device + splash, which is the door rather than a screen — nothing has been started there to + quit out of. While a find-as-you-type filter is standing the verb becomes `Esc clear`, + because that is what the press does then. +- A hint that outgrows the surface drawing it **sheds atoms rather than being cut off**, + because the cut takes the end and the end is Esc (`frame.fit_hint`, the chromeless + splash). They go from the right, in front of Esc; a screen may name the ones it can + spare first (`spare_hint_atoms`) — a key the reader would find anyway, like the ←→ its + move atom already named, before one that is unguessable and advertised nowhere else. - 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/device_picker.py b/meshterm/ui/device_picker.py index 17ce8e8..029306c 100644 --- a/meshterm/ui/device_picker.py +++ b/meshterm/ui/device_picker.py @@ -297,6 +297,9 @@ async def prompt_device( which the caller treats as a request to exit. """ scanned = list(devices) + #: The row the *next* redraw should open on, when the pass just finished moved the list + #: under the reader (see :func:`_after_hiding`). Cleared as soon as it is spent. + focus: DiscoveredDevice | None = None while True: remembered = store.load() # The full registry (not just the single last device) so *every* confirmed companion @@ -318,13 +321,19 @@ async def prompt_device( # row hidden a moment ago is gone the next time the list is drawn — which is the # only feedback hiding needs. hidden = store.hidden_ids() - visible = [d for d in listed if d.stable_id not in hidden] - # Preselect the remembered "last known good" device when it is currently attached/in range. - default = next((d for d in visible if remembered and remembered.matches(d)), None) + # In display order, so a shortcut acting on a row can say what the row *after* it is. + ordered = _order([d for d in listed if d.stable_id not in hidden], registry) + # Preselect the remembered "last known good" device when it is currently attached/in + # range — unless the last pass asked for a particular row, which a hide does so the + # highlight lands where the vanished row was rather than jumping back to the default. + default = next((d for d in ordered if remembered and remembered.matches(d)), None) + if focus is not None: + default = focus if focus in ordered else default + focus = None # Build the rows once so the same list can be redrawn as the backdrop behind a # removal confirm (so it floats over the picker rather than replacing it). - items = _build_items(visible, remembered, registry, hidden=len(hidden)) + items = _build_items(ordered, remembered, registry, hidden=len(hidden)) chosen = await ui.select_startup( "Select a companion device", items, @@ -339,7 +348,7 @@ async def prompt_device( return None if isinstance(chosen, KeyRequest): - _run_shortcut(store, chosen) + focus = _run_shortcut(store, chosen, ordered) continue if chosen is _ADD_TCP: @@ -372,20 +381,52 @@ async def prompt_device( return chosen -def _run_shortcut(store: DeviceStore, request: KeyRequest) -> None: - """Act on ``h`` / ``⇧H``; the redrawn list is the feedback either way. +def _run_shortcut( + store: DeviceStore, request: KeyRequest, listed: list[DiscoveredDevice] +) -> DiscoveredDevice | None: + """Act on ``h`` / ``⇧H``, and say which row the redrawn list should open on. - Nothing is said about it, because there is nothing to say that the list does not: after - a hide the row is gone, after a show-all the hidden rows are back. A dialog acknowledging - a change the reader is looking straight at is the acknowledgement this app has been - taking out of screens, not adding to them. + Nothing is *said* about either: after a hide the row is gone, after a show-all the hidden + rows are back, and a dialog acknowledging a change the reader is looking straight at is + the acknowledgement this app takes out of screens rather than adding to them. What the + redraw does owe them is their place — see :func:`_after_hiding`. + + Args: + store: The registry the hidden set lives in. + request: The shortcut the splash resolved with. + listed: The devices as displayed, in display order. + + Returns: + The device to highlight when the list is redrawn, or ``None`` to let the remembered + default decide (which is what a show-all wants: the list it restores is a different + list, and the reader's place in the old one means nothing in it). """ if request.action is _SHOW_ALL: store.show_all() - elif request.action is _HIDE and isinstance(request.value, DiscoveredDevice): + return None + if request.action is _HIDE and isinstance(request.value, DiscoveredDevice): # Only a device row can be hidden — ``h`` on Add a network device or Quit is inert, # the way Delete is on a row that never opted into removal. store.hide(request.value.stable_id) + return _after_hiding(listed, request.value) + return None + + +def _after_hiding( + listed: list[DiscoveredDevice], hidden: DiscoveredDevice +) -> DiscoveredDevice | None: + """The row the highlight should land on once ``hidden`` leaves the list. + + The next device down, so a run of adapters can be cleared with a run of presses without + the hand moving; the one above where there is no next, because the highlight has reached + the end and there is nowhere further down to go. Neither ever rolls round to the top — + nothing in the app does — and an emptied list has nothing to land on at all. + """ + remaining = [d for d in listed if d.stable_id != hidden.stable_id] + if not remaining: + return None + at = next((i for i, d in enumerate(listed) if d.stable_id == hidden.stable_id), 0) + return remaining[min(at, len(remaining) - 1)] def _shortcut_hint(hidden: int) -> Callable[[object], str]: @@ -415,14 +456,12 @@ def _shortcut_hint(hidden: int) -> Callable[[object], str]: def _hidden_note(hidden: int) -> Separator: - """The muted row stating what the list is *not* showing, and the key that brings it back. + """The muted row that stands in for a list hiding has emptied. - Hiding is the one action here that leaves no trace: a row that is gone looks exactly like - a device that was never plugged in, so without this the list would quietly misrepresent - the machine. It also carries ``⇧H`` in the body, which matters on a console whose splash - border is 47 cells wide — the footer atom naming that key is among the first to be shed - when the sentence outgrows the box (see :func:`~meshterm.ui.tui.frame.fit_hint`), and a - way back that can be squeezed off the screen is not a way back. + Only then. With devices still listed, the footer already names ``⇧H`` on every row and a + standing line saying the same thing is a row of the box spent on chrome. With *nothing* + listed there is no footer atom to read it off — and "no companion devices detected" would + be a lie about a machine with a radio plugged into it. """ it = "it" if hidden == 1 else "them" device = "device" if hidden == 1 else "devices" @@ -801,8 +840,6 @@ def _build_items( # remembered endpoint, so forgetting it is the only way it leaves the picker. A scanned # serial/BLE device would just reappear, so Delete stays inert on those rows. items.append(Choice(title=row, value=device, deletable=device.is_tcp, hscroll_from=head)) - if hidden: - items.append(_hidden_note(hidden)) items.extend(_action_rows()) return items diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index b397faa..c18d50e 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -895,14 +895,16 @@ class TuiSession: default: Any = None, banner: Any | None = None, footnote: str | None = None, - footer_hint: str = "↑↓ move · Enter select · Esc quit", + footer_hint: str = "↑↓ move · Enter select · Esc bye", keys: Mapping[str, Any] | None = None, key_hint: Callable[[Any], str] | None = None, ) -> Any: """Show a chromeless select splash (banner above a content-sized box). Like :meth:`select`, but drawn without the header/footer status bars and centered - under ``banner`` — the startup device picker's presentation. Type-to-filter is off: + under ``banner`` — the startup device picker's presentation. Esc's verb here is + ``bye``, the app's one send-off: this splash is the door, and the reader leaving it + has not started anything to quit out of. Type-to-filter is off: the device list is short and fixed, so stray keys never narrow it. An optional ``footnote`` (e.g. a copyright notice) sits muted below the box. When any row opts into removal (a :attr:`~meshterm.ui.tui.select.Choice.deletable` row), a "Del remove" @@ -932,10 +934,12 @@ class TuiSession: screen.banner = banner screen.footnote = footnote # The splash's border is its only hint line and it is narrow (47 cells on the - # PicoCalc), so when the sentence outgrows the box the scroll atom is the one it - # can spare: ←→ are already named by the move atom in front of it, while a - # bare-letter shortcut is unguessable and has nowhere else to be advertised here. - screen.spare_hint_atoms = ("←→ scroll",) + # PicoCalc), so when the sentence outgrows the box it gives up the scroll atom + # first — ←→ are already named by the move atom in front of it — and then the + # *hide* key. Between the two shortcuts the one that survives is the way back: + # ⇧H only appears at all once something is hidden, which is exactly the state where + # a reader needs to be told how to undo it, and by then they have already found h. + screen.spare_hint_atoms = ("←→ scroll", "h hide") result = await self.run_screen(screen) return None if result is CANCEL else result @@ -1005,7 +1009,7 @@ class TuiSession: backdrop_title, backdrop_items, default=backdrop_default, - footer_hint="↑↓ move · Enter select · Esc quit", + footer_hint="↑↓ move · Enter select · Esc bye", delete_hint="Del remove", filterable=False, ) diff --git a/tests/test_tui.py b/tests/test_tui.py index 2b847e5..f867ecc 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -3085,7 +3085,8 @@ async def test_the_splash_hides_the_highlighted_device_and_redraws(tmp_path) -> def test_the_splash_says_so_when_it_is_empty_only_because_of_hiding() -> None: - """ "Nothing detected" would be a lie, and the way back is a key nobody could guess.""" + """Nothing-detected would be a lie, and with no rows there is no footer atom either.""" + from meshterm.core.discovery import serial_device from meshterm.ui.device_picker import _build_items lines = [str(it.title) for it in _build_items([], None, {}, hidden=2)] @@ -3094,6 +3095,28 @@ def test_the_splash_says_so_when_it_is_empty_only_because_of_hiding() -> None: detected = [str(it.title) for it in _build_items([], None, {}, hidden=0)] assert any("no companion devices detected" in line for line in detected) + # With something still listed the footer names ⇧H on every row, so the line would be a + # row of the box spent saying it twice. + radio = serial_device("COM7", name="A Radio") + rows = [str(it.title) for it in _build_items([radio], None, {}, hidden=2)] + assert not any("hidden" in line for line in rows) + + +def test_hiding_a_device_leaves_the_highlight_where_the_row_was() -> None: + """The next device down, or the one above at the end — so a run of adapters clears in a run.""" + from meshterm.core.discovery import serial_device + from meshterm.ui.device_picker import _after_hiding + + first, middle, last = (serial_device(f"COM{n}", name=f"D{n}") for n in (1, 2, 3)) + listed = [first, middle, last] + + assert _after_hiding(listed, first) is middle + assert _after_hiding(listed, middle) is last + # Off the end there is nowhere further down, so the highlight steps up rather than + # rolling round to the top — nothing in the app rolls over. + assert _after_hiding(listed, last) is middle + assert _after_hiding([first], first) is None # an emptied list has nothing to land on + def test_the_splash_names_a_shortcut_only_where_it_would_act() -> None: """Named on a device row only, and ⇧H only while hidden — the Del remove rule.""" @@ -3126,10 +3149,15 @@ def test_a_hint_too_long_for_its_box_drops_atoms_rather_than_its_tail() -> None: assert fit_hint(full, 45) == "↑↓ move · ←→ scroll · Enter select · Esc quit" assert fit_hint(full, 20).endswith("Esc quit") - # ...unless the caller names an atom it can spare first: the scroll keys are already - # named by the move atom, while a bare letter is unguessable. - spared = fit_hint(full, 56, shed_first=("←→ scroll",)) + # ...unless the caller names atoms it can spare first, in the order it can spare them: + # the scroll keys are already named by the move atom, and between the two hide keys the + # one to keep is the way back. + spared = fit_hint(full, 56, shed_first=("←→ scroll", "h hide")) assert spared == "↑↓ move · Enter select · h hide · ⇧H show all · Esc quit" + assert ( + fit_hint(full, 50, shed_first=("←→ scroll", "h hide")) + == "↑↓ move · Enter select · ⇧H show all · Esc quit" + ) def test_the_splash_scrolls_the_hardware_column_of_the_row_it_is_on() -> None: @@ -3184,7 +3212,7 @@ def test_the_splash_hint_stays_inside_the_box_it_is_drawn_in() -> None: from meshterm.ui.device_picker import _shortcut_hint from meshterm.ui.tui.select import _splice_hint - base = "↑↓ move · Enter select · Esc quit" + base = "↑↓ move · Enter select · Esc bye" # the splash's own send-off radio = serial_device("COM7", name="A Radio") common = _splice_hint(base, _shortcut_hint(0)(radio)) for platform in (REGULAR, PICOCALC):