Hide the devices on the splash that are never the one you want

A machine with a debug probe, a programmer and a USB adapter soldered
into it shows all three on every start, in front of the one radio the
user actually came for. h drops the highlighted device from the list
and remembers it in the device registry; ⇧H brings every hidden one
back. Both keys are named in the footer only where they would act -
h on a device row, ⇧H only while something is hidden - which is the
rule Del remove already followed.

Hiding is a listing choice and nothing else: a hidden device is still
remembered, still resolvable by --port, and shows itself again the
moment it is connected to. A row that is simply gone would let the
list misrepresent the machine, so a muted line says how many are
hidden and which key returns them - and that line is also where ⇧H
survives on a console whose splash border is 47 cells.

The mechanics are shared rather than local: a non-filterable list can
declare bare-key shortcuts (a filtering one cannot - every letter
there belongs to the query) and name them per highlighted row, and a
chromeless splash whose hint outgrows its box now drops atoms in front
of Esc rather than being cut off at the end, where Esc is.

Two things the narrow console showed while this was being fitted: the
splash header was a hand-built string that wrapped at 53 columns, and
the DEVICE lane sized itself to the longest name - which for a USB
adapter is its forty-character product string, pushing HARDWARE off
the edge entirely. The header goes through column_header, and the name
lane now yields to keep HARDWARE on screen. What is left of a long
model string scrolls: ←→ read the highlighted row's tail to its end,
with the lanes in front of it pinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-09-09 01:26:06 -04:00
1 parent 3991c723ab
commit b2bf305a31
8 files changed
+658 -59

No files matched your search

+74 -16
View File
@@ -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))
+170 -20
View File
@@ -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
+25 -3
View File
@@ -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(
+2 -1
View File
@@ -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",
+48 -2
View File
@@ -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,
+54 -1
View File
@@ -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
+15 -1
View File
@@ -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
+270 -15
View File
@@ -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