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:
1 parent
3991c723ab
commit
b2bf305a31
8 files changed
+658
-59
No files matched your search
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user