diff --git a/meshterm/core/device_store.py b/meshterm/core/device_store.py index 59a7266..f345710 100644 --- a/meshterm/core/device_store.py +++ b/meshterm/core/device_store.py @@ -11,6 +11,15 @@ The registry also tracks which device was most recently connected (``last``), us preselect and star a default on the startup splash. Membership in the registry is what marks a device as a confirmed MeshCore companion — the splash reserves its "MeshCore device" tag for these, rather than guessing from the USB vendor ID. + +Alongside those it keeps ``hidden``: the stable ids the splash has been told to stop +listing. It lives here rather than in the preferences because it is a fact about *this +machine's* hardware — the USB adapters and dev boards permanently plugged into it that are +not companions — and because the ids it holds are the registry's own. Hiding is a **splash** +concern only: nothing else consults it, so ``--port`` and every scripted path still reach a +hidden device by name. A hidden id need not be a remembered device (usually it is the +opposite: a serial adapter nobody wants to see), so the set stands on its own rather than +being a flag on a record. """ from __future__ import annotations @@ -101,8 +110,8 @@ class DeviceStore: """ self._path = path - def _read(self) -> tuple[dict[str, RememberedDevice], str | None]: - """Return the parsed ``(registry, last_id)``; empty on a missing/corrupt file. + def _read(self) -> tuple[dict[str, RememberedDevice], str | None, set[str]]: + """Return the parsed ``(registry, last_id, hidden_ids)``; empty on a missing file. A missing or corrupt file is treated as "nothing remembered" rather than an error, so a stray edit never blocks startup. An old flat-format file (a single record at @@ -112,16 +121,16 @@ class DeviceStore: try: data = json.loads(self._path.read_text(encoding="utf-8")) except (OSError, ValueError): - return {}, None + return {}, None, set() if not isinstance(data, dict): - return {}, None + return {}, None, set() # Old flat shape: a single record with ``stable_id`` at the top level. if "devices" not in data and "stable_id" in data: record = self._record_from(data) if record is None: - return {}, None - return {record.stable_id: record}, record.stable_id + return {}, None, set() + return {record.stable_id: record}, record.stable_id, set() registry: dict[str, RememberedDevice] = {} for entry in (data.get("devices") or {}).values(): @@ -131,7 +140,9 @@ class DeviceStore: last = data.get("last") if last not in registry: last = None - return registry, last + raw_hidden = data.get("hidden") + hidden = {str(x) for x in raw_hidden} if isinstance(raw_hidden, list) else set() + return registry, last, hidden @staticmethod def _record_from(entry: object) -> RememberedDevice | None: @@ -163,17 +174,17 @@ class DeviceStore: Returns: The last-connected :class:`RememberedDevice`, or ``None``. """ - registry, last = self._read() + registry, last, _hidden = self._read() return registry.get(last) if last is not None else None def load_all(self) -> dict[str, RememberedDevice]: """Return the full registry of confirmed devices, keyed by ``stable_id``.""" - registry, _ = self._read() + registry, _last, _hidden = self._read() return registry def is_known(self, device: DiscoveredDevice) -> bool: """Return whether ``device`` has ever been confirmed as a MeshCore companion.""" - registry, _ = self._read() + registry, _last, _hidden = self._read() return device.stable_id in registry def remember( @@ -193,7 +204,7 @@ class DeviceStore: a reconnect on firmware that couldn't answer the query doesn't erase a model learned earlier. """ - registry, _ = self._read() + registry, _last, hidden = self._read() existing = registry.get(device.stable_id) if not node_name and existing is not None: node_name = existing.node_name @@ -211,7 +222,11 @@ class DeviceStore: host=device.host or "", tcp_port=device.tcp_port or 0, ) - self._write(registry, device.stable_id) + # Connecting to a device is the plainest statement that it should be listed, so a + # confirmed one stops being hidden — otherwise it would vanish from the splash the + # moment it proved itself, which is the opposite of what hiding is for. + hidden.discard(device.stable_id) + self._write(registry, device.stable_id, hidden) def forget(self, stable_id: str) -> bool: """Drop a remembered device from the registry. @@ -230,17 +245,58 @@ class DeviceStore: Returns: ``True`` if a record was removed, ``False`` if none matched. """ - registry, last = self._read() + registry, last, hidden = self._read() if stable_id not in registry: return False del registry[stable_id] if last == stable_id: last = max(registry, key=lambda k: registry[k].last_connected, default=None) - self._write(registry, last) + hidden.discard(stable_id) # nothing left to hide it from + self._write(registry, last, hidden) return True - def _write(self, registry: dict[str, RememberedDevice], last: str | None) -> None: - """Persist the registry, marking ``last`` as the most recently connected device.""" + def hidden_ids(self) -> set[str]: + """Return the stable ids the startup splash has been told not to list.""" + _registry, _last, hidden = self._read() + return hidden + + def hide(self, stable_id: str) -> None: + """Stop listing ``stable_id`` on the startup splash, from now until it is shown again. + + Kept forever, like the registry itself — the splash is a list of what is plugged into + this machine, and the adapters that are always plugged in and never companions are + the same ones every time. Hiding an id that is not remembered is normal and fine: + those are exactly the rows worth hiding. + + Args: + stable_id: The device's :attr:`DiscoveredDevice.stable_id`. + """ + registry, last, hidden = self._read() + if stable_id in hidden: + return + hidden.add(stable_id) + self._write(registry, last, hidden) + + def show_all(self) -> int: + """Un-hide every hidden device; return how many were brought back. + + The single way back, deliberately: hiding is per-row and un-hiding is not, because a + hidden row is not on screen to press a key on. Returning the count lets the caller + say what it just did. + """ + registry, last, hidden = self._read() + if not hidden: + return 0 + self._write(registry, last, set()) + return len(hidden) + + def _write( + self, + registry: dict[str, RememberedDevice], + last: str | None, + hidden: set[str], + ) -> None: + """Persist the registry, its ``last`` pointer, and the splash's hidden ids.""" self._path.parent.mkdir(parents=True, exist_ok=True) payload = { "devices": { @@ -259,5 +315,7 @@ class DeviceStore: for stable_id, record in registry.items() }, "last": last, + # Sorted so the file does not churn between writes over set ordering alone. + "hidden": sorted(hidden), } write_atomically(self._path, json.dumps(payload, indent=2)) diff --git a/meshterm/ui/device_picker.py b/meshterm/ui/device_picker.py index fdf78c4..17ce8e8 100644 --- a/meshterm/ui/device_picker.py +++ b/meshterm/ui/device_picker.py @@ -15,6 +15,14 @@ merely detected). Everything else follows in discovery order. Selecting a device runs an immediate smoke test (via the ``verify`` callback): a genuine MeshCore companion is remembered — forever — as confirmed and becomes the session's active device; anything else sends the user back to the list to choose another. + +A machine with things permanently plugged into it that are not companions — a debug probe, a +programmer, a serial adapter — shows them here every single time, in front of the one device +the user actually wants. So a row can be **hidden**: ``h`` drops the highlighted device from +this list and remembers that (see :meth:`~meshterm.core.device_store.DeviceStore.hide`), and +``⇧H`` brings every hidden one back. It is a *listing* choice and nothing more: a hidden +device is still remembered, still reachable by ``--port``, and un-hides itself the moment it +is connected to again. """ from __future__ import annotations @@ -35,8 +43,10 @@ from ..core.discovery import ( serial_device, tcp_device, ) +from ..platforms import get_platform from .logo import load_logo -from .tui import Choice, DeleteRequest, Separator +from .menus import Lane, column_header, fit_cells +from .tui import Choice, DeleteRequest, KeyRequest, Separator if TYPE_CHECKING: from .surface import Ui @@ -57,6 +67,13 @@ _QUIT = object() #: companions aren't discoverable, so they're named by hand) and smoke-tests the result. _ADD_TCP = object() +#: The two shortcut tokens the splash declares, and the keys that raise them. ``h`` acts on +#: the highlighted row; ``⇧H`` is screen-wide, because a hidden row is not there to press a +#: key on — the way back has to be reachable from anywhere in the list. +_HIDE = object() +_SHOW_ALL = object() +_SHORTCUTS = {"h": _HIDE, "H": _SHOW_ALL} + #: TYPE-column glyphs marking how a device connects. Kept as module constants so the splash's #: look can be retuned without touching the row-building logic. ``ᛒ`` is the *Bjarkan* rune the #: Bluetooth logo is drawn from — rendered white on the Bluetooth blue (see the ``bluetooth`` @@ -297,23 +314,34 @@ async def prompt_device( # produced by the scan, so fold those in too — after the scanned set, so a port # pyserial *does* enumerate keeps its richer scanned row rather than the bare profile. listed += _profile_serial_devices(listed, profiles) + # Devices the user has told this splash to stop showing (h). Read each pass, so a + # 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 listed if remembered and remembered.matches(d)), None) + default = next((d for d in visible if remembered and remembered.matches(d)), 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(listed, remembered, registry) + items = _build_items(visible, remembered, registry, hidden=len(hidden)) chosen = await ui.select_startup( "Select a companion device", items, default=default, banner=load_logo(), + keys=_SHORTCUTS, + key_hint=_shortcut_hint(len(hidden)), ) # Esc (``None``) and the Quit row both mean "leave the picker" — surface that to the # caller as ``None`` so it can exit the program instead of continuing device-less. if chosen is None or chosen is _QUIT: return None + if isinstance(chosen, KeyRequest): + _run_shortcut(store, chosen) + continue + if chosen is _ADD_TCP: # Name a network companion by hand and smoke-test it. On success it's returned like # any picked device; on cancel/failure we loop back to the list. @@ -344,6 +372,63 @@ 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. + + 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. + """ + if request.action is _SHOW_ALL: + store.show_all() + elif 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) + + +def _shortcut_hint(hidden: int) -> Callable[[object], str]: + """Name the hide shortcuts that would actually do something right here, right now. + + Asked of the highlighted row on every paint (see ``key_hint`` on + :class:`~meshterm.ui.tui.select.SelectScreen`), so the footer follows the same rule + ``Del remove`` does: a key is named where it acts and nowhere else. + + * ``h hide`` only on a device row — on *Add a network device* or *Quit* there is nothing + to hide, and the key is inert there. + * ``⇧H show all`` only while something is actually hidden. It is screen-wide rather than + per-row (a hidden row is not there to press a key on), so it rides whichever row the + reader is standing on — but it stays off the line entirely while it would do nothing, + which is also what keeps the splash from ever naming a key nobody could act on. + """ + + def atoms_for(value: object) -> str: + atoms = [] + if isinstance(value, DiscoveredDevice): + atoms.append("h hide") + if hidden: + atoms.append("⇧H show all") + return " · ".join(atoms) + + return atoms_for + + +def _hidden_note(hidden: int) -> Separator: + """The muted row stating what the list is *not* showing, and the key that brings it back. + + 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. + """ + it = "it" if hidden == 1 else "them" + device = "device" if hidden == 1 else "devices" + return Separator(f" {hidden} {device} hidden — press ⇧H to show {it}") + + def _where_phrase(device: DiscoveredDevice) -> str: """A human phrase for a device's transport, woven into the smoke-test spinner line.""" if device.is_tcp: @@ -541,6 +626,40 @@ async def _remove_network_device( store.forget(device.stable_id) +#: The narrowest the HARDWARE column is worth keeping before the name lane has to give way. +#: Below this a model string says nothing at all, and the lane is better spent on the name. +_HARDWARE_MIN = 10 + + +def _name_width( + devices: list[DiscoveredDevice], registry: dict[str, RememberedDevice], other_lanes: int +) -> int: + """The DEVICE lane's width: the longest name, capped so HARDWARE keeps a readable column. + + A USB adapter's own product string runs to forty-odd characters ("CP2102 USB to UART + Bridge Controller"), and the lane used to size itself to whichever of those was longest. + On a 53-column console that pushed every other lane off the right-hand edge — the row + ended mid-port, and the HARDWARE column that says what the thing *is* was not on screen + at all, nor reachable by scrolling, because the pinned head was already wider than the + box. So the name is the lane that gives: it ellipsizes, and the columns after it survive. + + Args: + devices: The devices being listed. + registry: Confirmed companions, for their remembered names. + other_lanes: Cells the PORT and TYPE lanes take between them. + + Returns: + The name lane's width in cells. + """ + platform = get_platform() + # What the splash's box gives a row: the terminal, less the gutter it floats over, less + # its own border and padding, less the pointer and star columns the row leads with. + content = platform.readable_cols - platform.dialog_margin - 4 - 4 + fixed = other_lanes + 6 # the three two-cell gaps between the four lanes + longest = max(cell_len(_display_name(d, registry)) for d in devices) + return max(len("DEVICE"), min(longest, content - fixed - _HARDWARE_MIN)) + + def _order( devices: list[DiscoveredDevice], registry: dict[str, RememberedDevice] ) -> list[DiscoveredDevice]: @@ -560,6 +679,8 @@ def _build_items( devices: list[DiscoveredDevice], remembered: RememberedDevice | None, registry: dict[str, RememberedDevice], + *, + hidden: int = 0, ) -> list: """Build the aligned splash rows (a muted header + one :class:`Choice` per device). @@ -569,17 +690,33 @@ def _build_items( vendor); columns are padded to a shared width so they align. Confirmed companions sort to the top (most-recent first), wear their name in white and a bright tag; the remembered default is starred. Trailing rows let the user name a network device by hand and quit here. + + A device row's fixed lanes are pinned and its HARDWARE tail scrolls: a firmware model + string is as long as its vendor felt like making it, and on a narrow terminal the column + that says *what the thing actually is* was the one being cut off. ←→ read it to its end, + on the highlighted row alone, and only while that row overflows. + + Args: + devices: The devices to list — already less anything hidden. + remembered: The last-connected device, starred and preselected when present. + registry: Every confirmed companion, keyed by stable id. + hidden: How many devices were left out for being hidden. Only the empty state uses + it, to tell "nothing is plugged in" apart from "you hid all of it". """ if not devices: # Nothing attached or in range — but a TCP companion can still be reached by hand, so - # show a muted note over the same action rows rather than a dead-end. - note = Separator(" no companion devices detected — add a network device, or quit") + # show a muted note over the same action rows rather than a dead-end. When the list is + # empty *because everything in it is hidden*, say that instead: "nothing detected" + # would be a lie, and the way back is a key the reader has to be told about. + note = ( + _hidden_note(hidden) + if hidden + else Separator(" no companion devices detected — add a network device, or quit") + ) return [note, *_action_rows()] devices = _order(devices, registry) known: set[str] = set(registry) - name_w = max(cell_len(_display_name(d, registry)) for d in devices) - name_w = max(name_w, len("DEVICE")) # The middle column holds a serial port, a BLE address, or a TCP host:port; label it for # whichever kinds are present so a non-serial endpoint never sits under a bare "PORT" # heading (a BLE address and a network host:port both read as an "address"). @@ -593,21 +730,26 @@ def _build_items( # The TYPE column holds a small transport badge (at most 3 cells); its heading is wider, # so the four-cell "TYPE" label sets the column width and every badge pads out to it. type_w = len("TYPE") + name_w = _name_width(devices, registry, port_w + type_w) hardware_w = max(cell_len(_hardware_label(d, registry)) for d in devices) hardware_w = max(hardware_w, len("HARDWARE")) - # A muted, aligned header. The leading spaces mirror the row pointer (2) and the star - # column (2) so the labels sit above their columns. It leads the device rows as their - # landmark, so a long detection list keeps the lane names overhead as it scrolls. + # A muted, aligned header, resolved against the render width because it is *pinned*: it + # leads the device rows as their landmark, so a long detection list keeps the lane names + # overhead as it scrolls — and a pinned row that wraps is drawn outside the body slice, + # where the second line costs the list a device. The indent mirrors the row pointer (2) + # and the star column (2) so each label sits over its own lane. header = Separator( - " " - + _pad("DEVICE", name_w) - + " " - + _pad(port_label, port_w) - + " " - + _pad("TYPE", type_w) - + " " - + "HARDWARE", + lambda width: column_header( + [ + Lane("DEVICE", name_w + 2), + Lane((port_label, "PORT" if has_serial else "ADDRESS"), port_w + 2), + Lane("TYPE", type_w + 2), + Lane(("HARDWARE", "HW")), + ], + width, + indent=4, + ), heading=True, ) @@ -620,7 +762,8 @@ def _build_items( row.append(" ") # A confirmed companion wears its name in white so it stands out from mere detections. row.append( - _pad(_display_name(device, registry), name_w), style="device.known" if is_known else "" + _pad(fit_cells(_display_name(device, registry), name_w), name_w), + style="device.known" if is_known else "", ) row.append(" ") row.append(_pad(_where(device), port_w), style="muted") @@ -633,6 +776,11 @@ def _build_items( row.append_text(cell) row.append(" " * max(0, type_w - cell.cell_len)) row.append(" ") + # Everything up to here is the row's fixed lanes — the reader's place in the list — + # so it is pinned and only what follows slides under ←→ (see Choice.hscroll_from). + # What follows is the hardware model and its tag, which is the part that runs long: + # a firmware model string is as long as its vendor felt like making it. + head = row.cell_len row.append(_pad(_hardware_label(device, registry), hardware_w), style="muted") # Only devices we've actually confirmed are billed as MeshCore companions; a USB # vendor ID (or a BLE advert) is a sort hint, not a claim. A bare serial bridge earns @@ -652,7 +800,9 @@ def _build_items( # Only a network device opts into Delete-to-remove: it's listed solely from its # 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)) + 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/surface.py b/meshterm/ui/surface.py index c6a8a73..08a4201 100644 --- a/meshterm/ui/surface.py +++ b/meshterm/ui/surface.py @@ -17,7 +17,7 @@ only), so :class:`PlainUi` leaves them unsupported. from __future__ import annotations -from collections.abc import AsyncIterator, Callable +from collections.abc import AsyncIterator, Callable, Mapping from contextlib import asynccontextmanager from typing import Any @@ -181,8 +181,17 @@ class Ui: default: Any = None, banner: Any = None, footnote: str | None = None, + footer_hint: str | None = None, + keys: Mapping[str, Any] | None = None, + key_hint: Callable[[Any], str] | None = None, ) -> Any: - """Choose one item on a chromeless startup splash; ``None`` if skipped.""" + """Choose one item on a chromeless startup splash; ``None`` if skipped. + + ``keys`` declares bare-key shortcuts the splash answers, resolving with a + :class:`~meshterm.ui.tui.select.KeyRequest` (the picker's hide/show-all pair), and + ``key_hint`` names them per highlighted row so the footer only advertises a key + where it would act. ``footer_hint`` replaces the base sentence. + """ raise NotImplementedError async def confirm_startup( @@ -461,6 +470,9 @@ class PlainUi(Ui): default: Any = None, banner: Any = None, footnote: str | None = None, + footer_hint: str | None = None, + keys: Mapping[str, Any] | None = None, + key_hint: Callable[[Any], str] | None = None, ) -> Any: """Unsupported in scripted CLI mode.""" raise self._no_prompt() @@ -719,10 +731,20 @@ class TuiUi(Ui): default: Any = None, banner: Any = None, footnote: str | None = None, + footer_hint: str | None = None, + keys: Mapping[str, Any] | None = None, + key_hint: Callable[[Any], str] | None = None, ) -> Any: """Delegate to the session's chromeless startup select splash.""" return await self.session.select_startup( - title, items, default=default, banner=banner, footnote=footnote + title, + items, + default=default, + banner=banner, + footnote=footnote, + keys=keys, + key_hint=key_hint, + **({} if footer_hint is None else {"footer_hint": footer_hint}), ) async def confirm_startup( diff --git a/meshterm/ui/tui/__init__.py b/meshterm/ui/tui/__init__.py index 09e7055..2a1436d 100644 --- a/meshterm/ui/tui/__init__.py +++ b/meshterm/ui/tui/__init__.py @@ -33,7 +33,7 @@ from .prompt import ( byte_counter, ) from .screen import CANCEL, BusyDialog, BusyScreen, PopToMenu, Screen, ScrollScreen -from .select import Choice, DeleteRequest, SelectScreen, Separator +from .select import Choice, DeleteRequest, KeyRequest, SelectScreen, Separator from .session import TuiSession, Visit from .spinner import Spinner @@ -46,6 +46,7 @@ __all__ = [ "SelectScreen", "Choice", "DeleteRequest", + "KeyRequest", "Separator", "TextScreen", "ConfirmScreen", diff --git a/meshterm/ui/tui/frame.py b/meshterm/ui/tui/frame.py index 403ba8c..41c6f32 100644 --- a/meshterm/ui/tui/frame.py +++ b/meshterm/ui/tui/frame.py @@ -376,6 +376,48 @@ def _banner_lines(banner: Sequence[str], cols: int) -> list[str]: return _center(padded, cols) +def fit_hint(hint: str, width: int, *, shed_first: Sequence[str] = ()) -> str: + """Drop ``·`` atoms from a footer hint, right to left, until it fits ``width``. + + The chromeless splash has one row of border for its hint and a box no wider than the + terminal less its gutter — 47 cells on the PicoCalc — while the hint itself grows as the + highlight moves onto a row with more keys to offer. Cutting the line at the edge takes + the *end* of it, and the end is ``Esc``: the one atom that must survive, being the only + way out. So atoms are dropped instead, from the right and never the last one, which + spends the cells on the keys nearest the front of the sentence — the ones that move and + commit — and gives up the optional ones a screen advertises elsewhere. + + ``shed_first`` overrides that order for atoms whose key the reader would find anyway. A + scroll atom is the example: ←→ are the keys already named by the leading move atom, so + trying them costs nothing and teaches the rest, while a bare-letter shortcut is + unguessable and has nowhere else to be advertised on a chromeless splash. Given the + choice between those two, the sentence keeps the letter. + + Args: + hint: The composed hint sentence. + width: Cells available. + shed_first: Atoms to drop before any others, in the order given. + + Returns: + The hint, or as much of its head plus its final atom as fits (ellipsized only if + even those cannot). + """ + if cell_len(hint) <= width: + return hint + atoms = hint.split(" · ") + for spare in shed_first: + if cell_len(" · ".join(atoms)) <= width: + break + if spare in atoms: + atoms.remove(spare) + while len(atoms) > 2 and cell_len(" · ".join(atoms)) > width: + del atoms[-2] # the last atom is Esc's; drop what sits in front of it + fitted = " · ".join(atoms) + if cell_len(fitted) <= width: + return fitted + return crop_cells(Text(fitted), 0, max(1, width - 1)).plain + "…" + + def compose_startup(screen: Screen, cols: int, rows: int) -> str: """Compose a chromeless splash: a centered wordmark above a content-sized panel. @@ -416,6 +458,10 @@ def compose_startup(screen: Screen, cols: int, rows: int) -> str: sizing_footer = getattr(screen, "sizing_footer_hint", screen.footer_hint) inner_w = max(measured, cell_len(screen.title), cell_len(sizing_footer)) inner_w = max(10, min(inner_w, cols - 6)) + # The hint is fitted to the box rather than cut off by it (see :func:`fit_hint`). + hint_text = fit_hint( + screen.footer_hint, inner_w, shed_first=getattr(screen, "spare_hint_atoms", ()) + ) # The probe render is the answer whenever it was already made at the width we settled # on — the common case, since a body narrower than the probe *is* what set inner_w. @@ -455,10 +501,10 @@ def compose_startup(screen: Screen, cols: int, rows: int) -> str: body = Text.from_ansi("\n".join(visible)) hint = hint_style("accent") - subtitle = f"[{hint}]{screen.footer_hint}[/]" + subtitle = f"[{hint}]{hint_text}[/]" if more_above or more_below: arrow = ("↑" if more_above else " ") + ("↓" if more_below else " ") - subtitle = f"[{hint}]{arrow} · {screen.footer_hint}[/]" + subtitle = f"[{hint}]{arrow} · {fit_hint(hint_text, inner_w - 5)}[/]" panel = Panel( body, title=f"[{title_style('accent')}]{screen.title}[/]" if screen.title else None, diff --git a/meshterm/ui/tui/select.py b/meshterm/ui/tui/select.py index b8283d9..0f476eb 100644 --- a/meshterm/ui/tui/select.py +++ b/meshterm/ui/tui/select.py @@ -9,7 +9,7 @@ arbitrary objects, so the same screen drives the main menu (tool names), the dev from __future__ import annotations import inspect -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass from typing import Any @@ -119,6 +119,25 @@ class DeleteRequest: value: Any +@dataclass +class KeyRequest: + """A shortcut key pressed on a select list, with the row it was pressed on. + + The sibling of :class:`DeleteRequest`, for a list that declares its own bare-key + shortcuts (see :class:`SelectScreen`'s ``keys``): the list resolves with this rather + than with a row's value, so the caller can tell "the user pressed *this key*" apart + from "the user chose this row" and act accordingly before re-opening the list. + + Attributes: + action: The token the pressed key was declared for. + value: The :attr:`Choice.value` of the highlighted row, or ``None`` where the list + had no rows to highlight. A screen-wide shortcut simply ignores it. + """ + + action: Any + value: Any = None + + def _plain(label: str | Text) -> str: """The plain-text form of a row label, for filtering (a :class:`Text` keeps its ``plain``).""" return label.plain if isinstance(label, Text) else label @@ -300,6 +319,8 @@ class SelectScreen(Screen): filterable: bool = True, hscroll: bool = False, hscroll_hint: str = "←→ scroll", + keys: Mapping[str, Any] | None = None, + key_hint: Callable[[Any], str] | None = None, ) -> None: """Build a select screen. @@ -331,6 +352,22 @@ class SelectScreen(Screen): the move atom) while ``hscroll`` is on and the highlighted row overflows — so ←→ advertises itself exactly when it would do something. Ignored when ``hscroll`` is off. + keys: Bare-key shortcuts this list answers, mapping the character pressed to a + token naming what it means — the idiom + :meth:`~meshterm.ui.tui.session.TuiSession.button_dialog` already uses for a + dialog's y/n accelerators. The list resolves with a :class:`KeyRequest` + carrying the token and the highlighted row's value, so the caller can act + and re-open. **Only honoured on a non-filterable list**, where a bare letter + has nothing else to do: on a filtering list every letter belongs to the + query, and a shortcut stealing one would be a key that silently stops the + reader typing a name. + key_hint: What those shortcuts are called in the footer, asked of the *highlighted + row's value* on every paint and spliced in just before the trailing ``Esc`` + clause. Return one ` · `-joined run of atoms, or ``""`` for a row none of the + shortcuts would touch — the same rule ``delete_hint`` follows, and for the + same reason: the footer must never name a key that would do nothing where the + reader is standing. The policy lives with the caller because only it knows + which rows its keys mean anything on. """ super().__init__() self.title = title @@ -342,6 +379,10 @@ class SelectScreen(Screen): # wherever it is opened, ``ctx.ui.select`` included. self._hscroll = hscroll or any(getattr(item, "hscroll_from", 0) > 0 for item in items) self._hscroll_hint = hscroll_hint + # Shortcuts are the non-filterable list's compensation for having no filter: the + # letters are free, so a list may spend them (see the ``keys`` argument). + self._keys: dict[str, Any] = {} if filterable else dict(keys or {}) + self._key_hint = key_hint self._hshift = 0 self._last_width = 0 # the last render width, for the footer's overflow probe if footer_hint is None: @@ -502,6 +543,8 @@ class SelectScreen(Screen): * the :attr:`_delete_hint` atom, spliced just before the trailing ``Esc`` clause (see :func:`_splice_hint`) while the highlighted row is :attr:`Choice.deletable`; + * whatever ``key_hint`` names for the highlighted row's value — the shortcut keys + (``keys``) that would act on *it*, spliced the same way; * 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. @@ -513,6 +556,10 @@ class SelectScreen(Screen): current = self._current_choice() if self._delete_hint and current is not None and current.deletable: base = _splice_hint(base, self._delete_hint) + if self._key_hint is not None: + atoms = self._key_hint(current.value if current is not None else None) + if atoms: + base = _splice_hint(base, atoms) if self._hscroll and self._hscroll_hint and self._selected_overflows(): base = _insert_atom(base, self._hscroll_hint) if self._filter: @@ -858,6 +905,12 @@ class SelectScreen(Screen): self._filter = self._filter[:-1] self._index = 0 self._hshift = 0 + elif action == "text" and data in self._keys: + # A declared shortcut resolves the list with what was pressed and where the + # highlight was standing; only a non-filterable list can declare any. + self.resolve( + KeyRequest(self._keys[data], choices[self._index].value if choices else None) + ) elif action == "text" and self._filterable and data.isprintable(): # A leading space is ignored (the filter never begins with whitespace); a # trailing one is dropped when matching (see _rows), so spaces count only diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index 85e0dac..b397faa 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -14,7 +14,7 @@ from __future__ import annotations import asyncio import os import sys -from collections.abc import AsyncIterator, Callable, Sequence +from collections.abc import AsyncIterator, Callable, Mapping, Sequence from contextlib import asynccontextmanager from typing import Any @@ -896,6 +896,8 @@ class TuiSession: banner: Any | None = None, footnote: str | None = None, footer_hint: str = "↑↓ move · Enter select · Esc quit", + 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). @@ -907,6 +909,11 @@ class TuiSession: atom joins the footer — but only while the highlight is actually on such a row, so the removal key advertises itself exactly where it acts (see :attr:`~meshterm.ui.tui.select.SelectScreen.footer_hint`). + + ``keys`` hands the list bare-key shortcuts — which a splash can afford precisely + because it does not filter, so every letter is free (see :class:`SelectScreen`) — + and ``key_hint`` says what they are called on the row the highlight is standing on, + so they advertise themselves exactly where they act, as ``Del remove`` does. """ delete_hint = ( "Del remove" if any(isinstance(it, Choice) and it.deletable for it in items) else "" @@ -918,10 +925,17 @@ class TuiSession: footer_hint=footer_hint, delete_hint=delete_hint, filterable=False, + keys=keys, + key_hint=key_hint, ) screen.chrome = False 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",) result = await self.run_screen(screen) return None if result is CANCEL else result diff --git a/tests/test_tui.py b/tests/test_tui.py index b0f5a6d..2b847e5 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -1226,7 +1226,9 @@ def test_device_picker_builds_aligned_columns(tmp_path) -> None: captured: dict = {} class _Ui: - async def select_startup(self, title, items, *, default=None, banner=None, footnote=None): + async def select_startup( # noqa: ANN001, ANN201, ANN003 + self, title, items, *, default=None, banner=None, footnote=None, **_kw + ): captured["items"] = items captured["banner"] = banner captured["footnote"] = footnote @@ -1278,7 +1280,9 @@ def test_device_picker_names_and_sorts_known_devices(tmp_path) -> None: captured: dict = {} class _Ui: - async def select_startup(self, title, items, *, default=None, banner=None, footnote=None): + async def select_startup( # noqa: ANN001, ANN201, ANN003 + self, title, items, *, default=None, banner=None, footnote=None, **_kw + ): captured["items"] = items return None # skip past the smoke test @@ -1315,7 +1319,9 @@ def test_device_picker_reinjects_remembered_tcp_device(tmp_path) -> None: captured: dict = {} class _Ui: - async def select_startup(self, title, items, *, default=None, banner=None, footnote=None): + async def select_startup( # noqa: ANN001, ANN201, ANN003 + self, title, items, *, default=None, banner=None, footnote=None, **_kw + ): captured["items"] = items return None # skip past the smoke test @@ -1346,7 +1352,9 @@ def test_device_picker_lists_configured_tcp_profile(tmp_path) -> None: captured: dict = {} class _Ui: - async def select_startup(self, title, items, *, default=None, banner=None, footnote=None): + async def select_startup( # noqa: ANN001, ANN201, ANN003 + self, title, items, *, default=None, banner=None, footnote=None, **_kw + ): captured["items"] = items return None # skip past the smoke test @@ -1379,7 +1387,9 @@ def test_device_picker_lists_configured_serial_profile(tmp_path) -> None: captured: dict = {} class _Ui: - async def select_startup(self, title, items, *, default=None, banner=None, footnote=None): + async def select_startup( # noqa: ANN001, ANN201, ANN003 + self, title, items, *, default=None, banner=None, footnote=None, **_kw + ): captured["items"] = items return None # skip past the smoke test @@ -1412,7 +1422,9 @@ def test_device_picker_serial_profile_yields_to_scanned_port(tmp_path) -> None: captured: dict = {} class _Ui: - async def select_startup(self, title, items, *, default=None, banner=None, footnote=None): + async def select_startup( # noqa: ANN001, ANN201, ANN003 + self, title, items, *, default=None, banner=None, footnote=None, **_kw + ): captured["items"] = items return None @@ -1442,7 +1454,9 @@ def test_device_picker_profile_yields_to_remembered_endpoint(tmp_path) -> None: captured: dict = {} class _Ui: - async def select_startup(self, title, items, *, default=None, banner=None, footnote=None): + async def select_startup( # noqa: ANN001, ANN201, ANN003 + self, title, items, *, default=None, banner=None, footnote=None, **_kw + ): captured["items"] = items return None @@ -1467,7 +1481,9 @@ def test_device_picker_adds_network_device(tmp_path) -> None: probed: dict = {} class _Ui: - async def select_startup(self, title, items, *, default=None, banner=None, footnote=None): + async def select_startup( # noqa: ANN001, ANN201, ANN003 + self, title, items, *, default=None, banner=None, footnote=None, **_kw + ): # Choose the "add a network device" action row. return next(it.value for it in items if isinstance(it, Choice) and it.value is _ADD_TCP) @@ -1517,7 +1533,9 @@ def test_device_picker_removes_network_device_on_delete(tmp_path) -> None: def __init__(self) -> None: self._passes = 0 - async def select_startup(self, title, items, *, default=None, banner=None, footnote=None): + async def select_startup( # noqa: ANN001, ANN201, ANN003 + self, title, items, *, default=None, banner=None, footnote=None, **_kw + ): names = [ it.label.plain for it in items @@ -1583,7 +1601,9 @@ def test_device_picker_keeps_network_device_when_removal_cancelled(tmp_path) -> def __init__(self) -> None: self._passes = 0 - async def select_startup(self, title, items, *, default=None, banner=None, footnote=None): + async def select_startup( # noqa: ANN001, ANN201, ANN003 + self, title, items, *, default=None, banner=None, footnote=None, **_kw + ): self._passes += 1 if self._passes == 1: row = next( @@ -1662,7 +1682,9 @@ class _PickerUi: def __init__(self) -> None: self.notes: list = [] - async def select_startup(self, title, items, *, default=None, banner=None, footnote=None): + async def select_startup( # noqa: ANN001, ANN201, ANN003 + self, title, items, *, default=None, banner=None, footnote=None, **_kw + ): return next(it.value for it in items if isinstance(it, Choice)) async def notify_startup(self, renderable, *, title="", banner=None, footnote=None): @@ -1707,7 +1729,9 @@ def test_device_picker_leaves_the_copyright_to_the_wordmark(tmp_path) -> None: footnotes: list = [] class _Ui: - async def select_startup(self, title, items, *, default=None, banner=None, footnote=None): + async def select_startup( # noqa: ANN001, ANN201, ANN003 + self, title, items, *, default=None, banner=None, footnote=None, **_kw + ): footnotes.append(("select", footnote)) return next(it.value for it in items if isinstance(it, Choice)) @@ -1748,7 +1772,9 @@ def test_device_picker_prompts_and_retries_ble_pin(tmp_path) -> None: errors: list = [] class _Ui: - async def select_startup(self, title, items, *, default=None, banner=None, footnote=None): + async def select_startup( # noqa: ANN001, ANN201, ANN003 + self, title, items, *, default=None, banner=None, footnote=None, **_kw + ): return next(it.value for it in items if isinstance(it, Choice)) async def notify_startup(self, renderable, *, title="", banner=None, footnote=None): @@ -1793,7 +1819,9 @@ def test_device_picker_pin_cancel_returns_to_list(tmp_path) -> None: picks = iter([0, "quit"]) # pick the device once, then quit the re-opened list class _Ui: - async def select_startup(self, title, items, *, default=None, banner=None, footnote=None): + async def select_startup( # noqa: ANN001, ANN201, ANN003 + self, title, items, *, default=None, banner=None, footnote=None, **_kw + ): choices = [it for it in items if isinstance(it, Choice)] step = next(picks) if step == "quit": @@ -1825,7 +1853,9 @@ def test_device_picker_quit_row_returns_none(tmp_path) -> None: devices = [DiscoveredDevice(port="COM5", product="Wio SX1262")] class _QuitUi: - async def select_startup(self, title, items, *, default=None, banner=None, footnote=None): + async def select_startup( # noqa: ANN001, ANN201, ANN003 + self, title, items, *, default=None, banner=None, footnote=None, **_kw + ): # The last choice is the Quit row; picking it signals "exit". quit_choice = [it for it in items if isinstance(it, Choice) and it.value is _QUIT] assert quit_choice, "the picker offers a Quit row" @@ -2958,3 +2988,228 @@ def test_a_button_dialogs_hint_never_names_the_verb_of_one_button() -> None: assert "←→" in hint, ( f"{where}:{line} hides ←→, the key that moves the highlight Enter commits" ) + + +# --- hiding devices on the splash -------------------------------------------- + + +def _hide_store(tmp_path): # noqa: ANN001, ANN202 + """A device registry on a scratch file.""" + from meshterm.core.device_store import DeviceStore + + return DeviceStore(tmp_path / "devices.json") + + +def test_hidden_devices_survive_the_session_that_hid_them(tmp_path) -> None: # noqa: ANN001 + """Hiding is written to the registry file, so a fresh store still knows about it.""" + store = _hide_store(tmp_path) + assert store.hidden_ids() == set() + + store.hide("usb-0001") + store.hide("usb-0002") + store.hide("usb-0001") # idempotent + assert _hide_store(tmp_path).hidden_ids() == {"usb-0001", "usb-0002"} + + assert _hide_store(tmp_path).show_all() == 2 + assert _hide_store(tmp_path).hidden_ids() == set() + assert _hide_store(tmp_path).show_all() == 0 # nothing left to bring back + + +def test_hiding_leaves_the_registry_and_its_default_alone(tmp_path) -> None: # noqa: ANN001 + """A hidden device is still remembered, still the default, still reachable by name. + + Hiding is a *listing* choice on one screen. Everything that resolves a device without + the splash — ``--port``, a profile, the reconnect — must not be able to tell. + """ + from meshterm.core.discovery import serial_device + + store = _hide_store(tmp_path) + device = serial_device("COM7", name="A Radio") + store.remember(device, node_name="Wardriver") + store.hide(device.stable_id) + + fresh = _hide_store(tmp_path) + assert fresh.hidden_ids() == {device.stable_id} + assert fresh.is_known(device) + remembered = fresh.load() + assert remembered is not None and remembered.node_name == "Wardriver" + + +def test_connecting_to_a_hidden_device_shows_it_again(tmp_path) -> None: # noqa: ANN001 + """Confirming a device is the plainest statement that it belongs on the list.""" + from meshterm.core.discovery import serial_device + + store = _hide_store(tmp_path) + device = serial_device("COM7", name="A Radio") + store.hide(device.stable_id) + store.remember(device, node_name="Wardriver") + assert store.hidden_ids() == set() + + +async def test_the_splash_hides_the_highlighted_device_and_redraws(tmp_path) -> None: # noqa: ANN001 + """Pressing h drops the row and remembers it; the redrawn list is the feedback.""" + from meshterm.core.discovery import serial_device + from meshterm.ui.device_picker import prompt_device + from meshterm.ui.tui import Choice, KeyRequest + + probe = serial_device("COM3", name="A Debug Probe") + radio = serial_device("COM7", name="A Radio") + store = _hide_store(tmp_path) + drawn: list[list] = [] + + class _Ui: + """Presses h on the probe, then picks whatever is left.""" + + def __init__(self) -> None: + self.round = 0 + + async def select_startup(self, title, items, **kw): # noqa: ANN001, ANN003, ANN201 + drawn.append([it for it in items if isinstance(it, Choice)]) + self.round += 1 + if self.round == 1: + return KeyRequest(kw["keys"]["h"], probe) + return radio + + async def busy_startup(self, message, coro, **kw): # noqa: ANN001, ANN003, ANN201 + return await coro + + async def verify(device, pin): # noqa: ANN001, ANN202 + return {"name": "Wardriver"} + + chosen = await prompt_device(_Ui(), [probe, radio], store, verify) + assert chosen is radio + assert store.hidden_ids() == {probe.stable_id} + # The first pass offered both devices; the second offered only the one left. + assert probe in [row.value for row in drawn[0]] + assert probe not in [row.value for row in drawn[1]] + + +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.""" + from meshterm.ui.device_picker import _build_items + + lines = [str(it.title) for it in _build_items([], None, {}, hidden=2)] + assert any("2 devices hidden" in line and "⇧H" in line for line in lines) + + detected = [str(it.title) for it in _build_items([], None, {}, hidden=0)] + assert any("no companion devices detected" in line for line in detected) + + +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.""" + from meshterm.core.discovery import serial_device + from meshterm.ui.device_picker import _ADD_TCP, _QUIT, _shortcut_hint + + radio = serial_device("COM7", name="A Radio") + + nothing_hidden = _shortcut_hint(0) + assert nothing_hidden(radio) == "h hide" # no way back is offered; there is nothing back + assert nothing_hidden(_ADD_TCP) == "" # nothing to hide on the action rows + assert nothing_hidden(_QUIT) == "" + assert nothing_hidden(None) == "" # an empty list highlights nothing at all + + with_hidden = _shortcut_hint(2) + assert with_hidden(radio) == "h hide · ⇧H show all" + # ⇧H is screen-wide, so it rides whatever row the reader happens to be standing on. + assert with_hidden(_QUIT) == "⇧H show all" + + +def test_a_hint_too_long_for_its_box_drops_atoms_rather_than_its_tail() -> None: + """Cutting the line at the edge would take Esc, which is the one atom that must survive.""" + from meshterm.ui.tui.frame import fit_hint + + full = "↑↓ move · ←→ scroll · Enter select · h hide · ⇧H show all · Esc quit" + assert fit_hint(full, 100) == full # room for everything: untouched + + # Dropped from the right, in front of Esc — never Esc itself. + assert fit_hint(full, 56) == "↑↓ move · ←→ scroll · Enter select · h hide · Esc quit" + 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",)) + assert spared == "↑↓ move · Enter select · h hide · ⇧H show all · Esc quit" + + +def test_the_splash_scrolls_the_hardware_column_of_the_row_it_is_on() -> None: + """←→ read a long firmware model to its end; the lanes in front of it stay put.""" + from meshterm.core.device_store import RememberedDevice + from meshterm.core.discovery import serial_device + from meshterm.platforms import PICOCALC, REGULAR, set_platform + from meshterm.ui.device_picker import _build_items + from meshterm.ui.tui import Choice, SelectScreen + + radio = serial_device("COM7", name="Wardriver") + registry = { + radio.stable_id: RememberedDevice( + stable_id=radio.stable_id, + port="COM7", + label="COM7", + last_connected="2026-09-01T00:00:00+00:00", + node_name="Wardriver", + hardware_model="Seeed Wio Tracker 1110 Development Kit", + ) + } + try: + for platform, width in ((REGULAR, 66), (PICOCALC, 47)): + set_platform(platform) + items = _build_items([radio], registry[radio.stable_id], registry) + screen = SelectScreen("Select a companion device", items, filterable=False) + row = next(it for it in items if isinstance(it, Choice)) + assert row.hscroll_from > 0 # the fixed lanes are pinned... + assert row.hscroll_from < width # ...and leave something to scroll into + + before = _plain(screen.render_body(width)[1]) + for _ in range(3): + screen.handle("right") + after = _plain(screen.render_body(width)[1]) + assert before != after, platform.name + assert after.startswith(before[: row.hscroll_from - 4]), "the lanes moved" + finally: + set_platform(REGULAR) + + +def test_the_splash_hint_stays_inside_the_box_it_is_drawn_in() -> None: + """The chromeless splash keeps its hint in its own border, on both platforms. + + The border is the terminal less the gutter the box floats over — not the full readable + width — and on the PicoCalc that is 47 cells, which is what makes every atom here + conditional rather than merely tidy. + """ + from rich.cells import cell_len + + from meshterm.core.discovery import serial_device + from meshterm.platforms import PICOCALC, REGULAR + from meshterm.ui.device_picker import _shortcut_hint + from meshterm.ui.tui.select import _splice_hint + + base = "↑↓ move · Enter select · Esc quit" + radio = serial_device("COM7", name="A Radio") + common = _splice_hint(base, _shortcut_hint(0)(radio)) + for platform in (REGULAR, PICOCALC): + budget = platform.readable_cols - platform.dialog_margin - 2 + assert cell_len(common) <= budget, (platform.name, common) + + +def test_a_shortcut_is_only_honoured_where_a_letter_is_free() -> None: + """A filtering list spends its letters on the query, so it declares no shortcuts. + + Otherwise a device named "Homestead" could not be typed on a list that had claimed h. + """ + from meshterm.ui.tui import Choice, KeyRequest + from meshterm.ui.tui.select import SelectScreen + + token = object() + fixed = SelectScreen("fixed", [Choice("a row", 1)], filterable=False, keys={"h": token}) + fixed.future = None + resolved: list = [] + fixed.resolve = lambda value: resolved.append(value) # type: ignore[method-assign] + fixed.handle("text", "h") + assert resolved == [KeyRequest(token, 1)] + + filtering = SelectScreen("filtering", [Choice("Homestead", 1)], keys={"h": token}) + filtering.resolve = lambda value: resolved.append(value) # type: ignore[method-assign] + filtering.handle("text", "h") + assert len(resolved) == 1 # nothing new resolved + assert filtering._filter == "h" # the letter went to the query, where it belongs