diff --git a/meshterm/tools/chat.py b/meshterm/tools/chat.py index f1fe5a9..fe2da91 100644 --- a/meshterm/tools/chat.py +++ b/meshterm/tools/chat.py @@ -119,7 +119,7 @@ class ChatTool(Tool): lasts = ctx.repo.last_chat_messages() live = _LiveLasts(ctx, seed=lasts) - items: list = [Separator("── πŸ“‘ CHANNELS ──")] + items: list = [Separator(_picker_header()), Separator("── πŸ“‘ CHANNELS ──")] for conversation in channels: items.append(Choice(title=_row_title(ctx, conversation, live), value=conversation)) @@ -503,6 +503,23 @@ _AGE_WIDTH = 3 _PREVIEW_WIDTH = 40 +def _picker_header() -> str: + """Column headers over the picker's fixed lanes (see :func:`_title` for the layout). + + Leading spaces cover the select screen's pointer column (2 cells, drawn on choice rows + but not separators) plus the marker lane (3 cells), so each header lands exactly over + its column. UNREAD borrows its lane's trailing gap β€” the badge lane itself is one cell + too narrow for the word β€” which still leaves a space before the age column. + """ + return ( + " " + + "CONVERSATION".ljust(_LABEL_WIDTH + 2) + + "UNREAD".ljust(_BADGE_WIDTH + 2) + + f"{'AGE':>{_AGE_WIDTH}}" + + " LAST MESSAGE" + ) + + def _row_title( ctx: AppContext, conversation: Conversation, lasts: "_LiveLasts" ) -> Callable[[], Union[str, Text]]: diff --git a/meshterm/tools/config.py b/meshterm/tools/config.py index efc7878..d6d67df 100644 --- a/meshterm/tools/config.py +++ b/meshterm/tools/config.py @@ -2,10 +2,11 @@ Interactively it launches a full editor (see :mod:`meshterm.ui.config_editor`); on the CLI it exposes generic key/value subcommands plus backup/restore, channels, custom vars, -and a ``--yes``-gated set of destructive operations. Both paths funnel through +and a ``--yes``-gated set of destructive operations. Everything funnels through :func:`apply_ops`: the editor stages value changes for :meth:`ConfigTool.run` to execute -and log, and runs its device actions (adverts, reboot, key management, factory reset) -through the same executor immediately. +and log, and the Device actions screen (the sibling ``device-actions`` tool) runs its +immediate operations (adverts, reboot, key management, factory reset) through the same +executor. """ from __future__ import annotations diff --git a/meshterm/tools/device_actions.py b/meshterm/tools/device_actions.py new file mode 100644 index 0000000..81215bf --- /dev/null +++ b/meshterm/tools/device_actions.py @@ -0,0 +1,69 @@ +"""The ``device-actions`` tool: immediate operations on the companion device itself. + +Opens the Device actions screen (see :func:`meshterm.ui.config_editor.device_actions`) β€” +adverts, backup/restore, the identity key, reboot, and factory reset. Where the sibling +``config`` tool *stages* value changes for review, everything here acts on the box the +moment it is confirmed, so the two concerns get their own menu entries and screens. + +Menu-only: every action already has a first-class scripted equivalent under the ``config`` +CLI group (``config backup``, ``config restore``, ``config reboot``, ...), so no duplicate +subcommand is registered here. +""" + +from __future__ import annotations + +from typing import Any, Optional + +import typer + +from ..context import AppContext +from .base import Tool, ToolResult, register + + +@register +class DeviceActionsTool(Tool): + """Run immediate device operations: advert, backup, identity key, reboot, reset.""" + + name = "device-actions" + title = "Device actions" + help = "Act on the device itself β€” advert, backup, identity key, reboot, reset" + category = "Device" + order = 9 + + async def prompt_params(self, ctx: AppContext) -> Optional[dict[str, Any]]: + """Run the interactive actions screen; there are never parameters to collect. + + Each action executes and presents its result inside the screen itself, so by the + time the user backs out there is nothing left for :meth:`run` to do β€” returning + ``None`` tells the menu the invocation is complete (the same pattern as backing + out of the config editor with nothing staged). + + Args: + ctx: Shared application context. + + Returns: + Always ``None``. + """ + from ..ui.config_editor import device_actions + + await device_actions(ctx) + return None + + async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult: + """Nothing runs here: the actions execute inside :meth:`prompt_params`. + + Args: + ctx: Shared application context. + params: Unused. + + Returns: + An empty :class:`ToolResult` (only reachable from a scripted call). + """ + return ToolResult(summary={}) + + def register_cli(self, app: typer.Typer) -> None: + """Register no CLI command β€” the scripted equivalents live under ``config``. + + Args: + app: The Typer application (untouched). + """ diff --git a/meshterm/ui/config_editor.py b/meshterm/ui/config_editor.py index 7ae03ef..3fb5f32 100644 --- a/meshterm/ui/config_editor.py +++ b/meshterm/ui/config_editor.py @@ -1,20 +1,20 @@ -"""Interactive device-configuration editor, rendered in the full-screen session. +"""Interactive device-configuration screens, rendered in the full-screen session. -Drives the menu flow for the ``config`` tool. The screen is one grouped list: every -setting under its category heading (each row showing its current value, any staged change, -and a one-line explanation), followed by a *Device actions* section for the operations that -act on the box itself rather than a value β€” adverts, reboot, backup/restore, the identity -key, and factory reset. +Two sibling screens live here, deliberately kept distinct: -Two kinds of interaction live here, deliberately kept distinct: - -* **Settings are staged.** Editing a row stages the new value (shown as ``current β†’ new`` - in the row) and nothing touches the radio until *Apply*; backing out with staged changes - asks before discarding them. The editor returns the staged operations for - :class:`~meshterm.tools.config.ConfigTool` to execute and log. -* **Device actions run immediately** (after their own confirmation dialog β€” destructive - ones gate behind typing a confirmation word). They have no meaningful "preview", so their - result is shown at once. +* **Device configuration** (:func:`edit_config`, behind the ``config`` tool) β€” one grouped, + column-aligned list of every setting under its category heading, each row showing its + current value, any staged change, and a one-line explanation. Editing a row *stages* the + new value (shown as ``current β†’ new``) and nothing touches the radio until *Apply*; + backing out with staged changes asks before discarding them. The editor returns the + staged operations for :class:`~meshterm.tools.config.ConfigTool` to execute and log. +* **Device actions** (:func:`device_actions`, behind the ``device-actions`` tool) β€” the + operations that act on the box itself rather than a value: adverts, backup/restore, the + identity key, reboot, and factory reset. These *run immediately* (after their own + confirmation dialog β€” destructive ones gate behind typing a confirmation word); they + have no meaningful "preview", so their result is shown at once. They share the settings + snapshot and :func:`~meshterm.tools.config.apply_ops` executor with the editor, which is + why both screens live in this module. Multiple-choice values are picked in dialogs (booleans as an On/Off button pair, enums as a floating select), the node's location can be set by pointing at the full-screen map (see @@ -31,6 +31,7 @@ from typing import TYPE_CHECKING, Any, Optional from urllib.parse import quote from rich import box +from rich.cells import cell_len from rich.console import Group from rich.table import Table from rich.text import Text @@ -87,8 +88,7 @@ async def edit_config(ctx: "AppContext") -> Optional[list[tuple]]: Returns: A list of operation tuples for the tool to execute, or ``None`` if the user - cancelled (or a device action β€” e.g. a reboot β€” ended the session) without - anything staged to apply. + cancelled without anything staged to apply. """ from .tui import CANCEL, SelectScreen @@ -145,27 +145,6 @@ async def edit_config(ctx: "AppContext") -> Optional[list[tuple]]: await _stage_preset(ctx, pending) elif choice == _CUSTOM: await _stage_custom_var(ctx, custom, extra_ops) - elif choice == _ADVERT: - await _advert_menu(ctx, device, snapshot) - elif choice == _REBOOT: - if await _reboot(ctx, device, snapshot, staged): - return None # the link is dropping; the reconnect dialog takes over - elif choice == _BACKUP: - await _backup_now(ctx, device, snapshot) - elif choice == _RESTORE: - if await _restore_now(ctx, device, snapshot): - snapshot = await build_snapshot(device) - custom = await device.get_custom_vars() - elif choice == _IDENTITY_KEY: - if await _identity_key_menu(ctx, device, snapshot): - snapshot = await build_snapshot(device) - elif choice == _RESET: - if await _factory_reset(ctx, device, snapshot): - # Everything the editor knew about the device is gone; start clean. - pending.clear() - extra_ops.clear() - snapshot = await build_snapshot(device) - custom = await device.get_custom_vars() else: # a setting key await _stage_setting(ctx, choice, snapshot, pending) finally: @@ -239,19 +218,16 @@ def config_table( return table -def _setting_row(spec: SettingSpec, snapshot: dict, pending: dict) -> Text: - """Build one setting's menu row: ``label: current [β†’ staged] β€” help``. +def _setting_value(spec: SettingSpec, snapshot: dict, pending: dict) -> Text: + """One setting's VALUE lane: ``current [β†’ staged]``. - The staged arrow is drawn in the warn style so a dirty row stands out at a glance, - and the help trails muted; both spans survive the select highlight (which only tints - the row's base style). + The staged arrow is drawn in the warn style so a dirty row stands out at a glance; + the span survives the select highlight (which only tints the row's base style). """ - row = Text(f"{spec.label}: ") - row.append(format_value(spec, spec.getter(snapshot))) + value = Text(format_value(spec, spec.getter(snapshot))) if spec.key in pending: - row.append(f" β†’ {format_value(spec, pending[spec.key])}", style="warn") - row.append(f" β€” {spec.help}", style="muted") - return row + value.append(f" β†’ {format_value(spec, pending[spec.key])}", style="warn") + return value def _format_coords(lat: Any, lon: Any) -> str: @@ -265,15 +241,28 @@ def _format_coords(lat: Any, lon: Any) -> str: return f"{lat_f:.5f}, {lon_f:.5f}" -def _location_row(snapshot: dict, pending: dict) -> Text: - """The single Location row standing in for the ``adv_lat``/``adv_lon`` pair.""" - row = Text("Location: ") - row.append(_format_coords(snapshot.get("adv_lat"), snapshot.get("adv_lon"))) +def _location_value(snapshot: dict, pending: dict) -> Text: + """The Location row's VALUE lane, standing in for the ``adv_lat``/``adv_lon`` pair.""" + value = Text(_format_coords(snapshot.get("adv_lat"), snapshot.get("adv_lon"))) if any(k in pending for k in _COORD_KEYS): lat = pending.get("adv_lat", snapshot.get("adv_lat")) lon = pending.get("adv_lon", snapshot.get("adv_lon")) - row.append(f" β†’ {_format_coords(lat, lon)}", style="warn") - row.append(" β€” Advertised position; pick it on the map", style="muted") + value.append(f" β†’ {_format_coords(lat, lon)}", style="warn") + return value + + +def _lane_row(label: str, value: Text, help_text: str, label_w: int, value_w: int) -> Text: + """Lay one row out in the SETTING / VALUE / DESCRIPTION lanes. + + Padding is computed in display cells so a wide glyph in a value can't skew the lanes, + and the description stays muted under the select highlight (which only tints the + row's base style). + """ + row = Text(label) + row.append(" " * (label_w - cell_len(label) + 2)) + row.append_text(value) + row.append(" " * (value_w - cell_len(value.plain) + 2)) + row.append(help_text, style="muted") return row @@ -283,61 +272,71 @@ def _menu_items( """Build the editor menu's title and rows for the current snapshot + staged state. Returns the ``(title, items)`` the caller pushes as a persistent backdrop screen (so - sub-prompts float over it). Each setting row shows its current value (and any staged - new value) plus a one-line explanation, so the user can see and understand what - they're changing in place. The device actions that run immediately live in their own - section below the settings. + sub-prompts float over it). The rows sit in three aligned columns β€” setting, current + value (and any staged new value), description β€” under one header line, so the list + reads like the full-configuration table it stages changes for. """ - items: list = [] + # First pass: collect every row's lanes per category, so the columns can be sized to + # their content (including any staged ``β†’ new`` arrows) before a single row is built. + sections: list[tuple[str, list[tuple[str, Text, str, Any]]]] = [] for category, specs in settings_by_category(): - items.append(Separator(f"── {category.upper()} ──")) + rows: list[tuple[str, Text, str, Any]] = [] for spec in specs: if spec.key in _COORD_KEYS: # Latitude/longitude collapse into one Location row (inserted in # adv_lat's slot so it sits where the coordinates used to). if spec.key == "adv_lat": - items.append(Choice(title=_location_row(snapshot, pending), value=_LOCATION)) + rows.append(( + "Location", + _location_value(snapshot, pending), + "Advertised position; pick it on the map", + _LOCATION, + )) continue - items.append(Choice(title=_setting_row(spec, snapshot, pending), value=spec.key)) + rows.append((spec.label, _setting_value(spec, snapshot, pending), spec.help, spec.key)) if category == "Radio": - items.append( - Choice( - title=Text.assemble( - "Radio presets…", - (" β€” Apply a standard regional or trade-off config", "muted"), - ), - value=_PRESETS, - ) - ) + rows.append(( + "Radio presets…", Text(), + "Apply a standard regional or trade-off config", _PRESETS, + )) elif category == "Experimental": + rows.append(( + "Custom variables…", Text(), + "Set a raw firmware variable by name", _CUSTOM, + )) + sections.append((category, rows)) + + label_w = max(cell_len(label) for _, rows in sections for label, _, _, _ in rows) + value_w = max(cell_len(value.plain) for _, rows in sections for _, value, _, _ in rows) + + # The header leads with two spaces to clear the select screen's pointer column, so + # each heading lands exactly over its lane. + items: list = [ + Separator( + " " + "SETTING".ljust(label_w + 2) + "VALUE".ljust(value_w + 2) + "DESCRIPTION" + ) + ] + for category, rows in sections: + items.append(Separator(f"── {category.upper()} ──")) + for label, value, help_text, key in rows: items.append( - Choice( - title=Text.assemble( - "Custom variables…", - (" β€” Set a raw firmware variable by name", "muted"), - ), - value=_CUSTOM, - ) + Choice(title=_lane_row(label, value, help_text, label_w, value_w), value=key) ) - items.append(Separator("── DEVICE ACTIONS (RUN IMMEDIATELY) ──")) - items.append(_action("πŸ“‘ Send advert…", "Zero-hop, flood, or share this node as a QR code", _ADVERT)) - items.append(_action("πŸ’Ύ Back up config to a file…", "Write every setting to TOML", _BACKUP)) - items.append(_action("πŸ“‚ Restore config from a backup…", "Preview or apply a saved TOML", _RESTORE)) - items.append(_action("πŸ” Identity key…", "Export or import the node's private key", _IDENTITY_KEY)) - items.append(_action("πŸ”„ Reboot device…", "Restart the companion and reconnect", _REBOOT)) + items.append(Separator("── REVIEW ──")) items.append( Choice( title=Text.assemble( - ("⚠ Factory reset…", "err"), - (" β€” Erase everything (typed confirmation)", "muted"), + "🧾 View full configuration", (" β€” Every value in one table", "muted") ), - value=_RESET, + value=_VIEW, ) ) - items.append(Separator("── REVIEW ──")) - items.append(_action("🧾 View full configuration", "Every value in one table", _VIEW)) + # The backtracking rows sit together below one blank line, like every other screen's + # Back (the main menu's Quit included); with changes staged, Apply joins the group and + # Back spells out the consequence of leaving. + items.append(Separator(" ")) if staged: items.append( Choice( @@ -345,11 +344,6 @@ def _menu_items( value=_APPLY, ) ) - - # The backtracking row sits alone below a blank line, like every other screen's Back - # (the main menu's Quit included); with changes staged it spells out the consequence. - items.append(Separator(" ")) - if staged: items.append( Choice( title=Text.assemble(("βœ— ", "err"), "Back β€” discard staged changes"), @@ -363,11 +357,6 @@ def _menu_items( return title, items -def _action(label: str, help_text: str, value: str) -> Choice: - """Build a device-action menu row: a label with a muted explanation.""" - return Choice(title=Text.assemble(label, (f" β€” {help_text}", "muted")), value=value) - - def _changes(count: int) -> str: """``"1 staged change"`` / ``"3 staged changes"`` for dialogs and menu rows.""" return f"{count} staged change{'' if count == 1 else 's'}" @@ -589,6 +578,89 @@ async def _stage_custom_var( # --- device actions (run immediately) ----------------------------------------- +async def device_actions(ctx: "AppContext") -> None: + """Run the Device actions screen: immediate operations on the companion itself. + + The action counterpart of :func:`edit_config`, behind the ``device-actions`` tool. + Nothing here is staged β€” each action runs as soon as its own confirmation is given + (destructive ones gate behind typing a confirmation word) and presents its result at + once. The menu stays open between actions so several can be run in a row; Esc (or + Back) returns to the main menu, and a reboot closes the screen and hands off to the + session's reconnect dialog. + + Args: + ctx: Shared application context (provides the connected device and UI surface). + """ + from .tui import CANCEL, SelectScreen + + device = await ctx.device() + snapshot = await build_snapshot(device) + + # Same persistent-backdrop pattern as the editor: the menu stays pushed while each + # action's prompts float over it as modal popups (see edit_config). + session = getattr(ctx.ui, "session", None) + if session is None: # pragma: no cover - guarded by the menu-only caller + raise RuntimeError("device actions are only available in the menu") + loop = asyncio.get_running_loop() + cursor: Any = None # the row to re-highlight, so the menu reopens where you left it + + while True: + menu = SelectScreen("Device actions", _action_items(), default=cursor, wrap=False) + menu.future = loop.create_future() + session.push(menu) + try: + choice = await menu.future + if choice is CANCEL or choice in (None, _CANCEL): + return + cursor = choice + if choice == _ADVERT: + await _advert_menu(ctx, device, snapshot) + elif choice == _BACKUP: + await _backup_now(ctx, device, snapshot) + elif choice == _RESTORE: + if await _restore_now(ctx, device, snapshot): + snapshot = await build_snapshot(device) + elif choice == _IDENTITY_KEY: + if await _identity_key_menu(ctx, device, snapshot): + snapshot = await build_snapshot(device) + elif choice == _REBOOT: + if await _reboot(ctx, device, snapshot): + return # the link is dropping; the reconnect dialog takes over + elif choice == _RESET: + if await _factory_reset(ctx, device, snapshot): + snapshot = await build_snapshot(device) + finally: + session.pop(menu) + + +def _action_items() -> list: + """Build the Device actions rows: label and description in two aligned columns. + + Menu-style lanes (no header line β€” these are commands, not tabular data), padded in + display cells so the double-width emoji can't skew the description column. Factory + reset keeps its err-tinted label so the one irreversible row reads as such. + """ + actions: list[tuple[str, str, str, str]] = [ + ("πŸ“‘ Send advert…", "Zero-hop, flood, or share this node as a QR code", _ADVERT, ""), + ("πŸ’Ύ Back up config to a file…", "Write every setting to TOML", _BACKUP, ""), + ("πŸ“‚ Restore config from a backup…", "Preview or apply a saved TOML", _RESTORE, ""), + ("πŸ” Identity key…", "Export or import the node's private key", _IDENTITY_KEY, ""), + ("πŸ”„ Reboot device…", "Restart the companion and reconnect", _REBOOT, ""), + ("⚠ Factory reset…", "Erase everything (typed confirmation)", _RESET, "err"), + ] + width = max(cell_len(label) for label, _, _, _ in actions) + items: list = [] + for label, help_text, value, style in actions: + row = Text() + row.append(label, style=style or None) + row.append(" " * (width - cell_len(label) + 2)) + row.append(help_text, style="muted") + items.append(Choice(title=row, value=value)) + items.append(Separator(" ")) + items.append(Choice(title="Back", value=_CANCEL)) + return items + + async def _run_now( ctx: "AppContext", device: "Device", snapshot: dict, ops: list[tuple], title: str ) -> int: @@ -673,29 +745,20 @@ async def _show_contact_card(ctx: "AppContext", snapshot: dict) -> None: await ctx.ui.view(body, title=f"Share {name}", footer_hint="Esc back") -async def _reboot( - ctx: "AppContext", device: "Device", snapshot: dict, staged: int -) -> bool: +async def _reboot(ctx: "AppContext", device: "Device", snapshot: dict) -> bool: """Confirm and reboot the device, handing off to the session's reconnect dialog. - The confirmation dialog warns when staged changes would be lost (a reboot ends the - editor, discarding them). After the command is sent, we wait to actually observe the - link dropping β€” flagging :attr:`~meshterm.context.AppContext.reboot_in_progress` so - the session-wide disconnect watcher labels the ensuing dialog as a reboot, waits for - the companion to come back, and reconnects β€” exactly the unplugged-device flow. + After the command is sent, we wait to actually observe the link dropping β€” flagging + :attr:`~meshterm.context.AppContext.reboot_in_progress` so the session-wide + disconnect watcher labels the ensuing dialog as a reboot, waits for the companion to + come back, and reconnects β€” exactly the unplugged-device flow. Returns: - ``True`` if the reboot was sent and the editor should close; ``False`` if the - user backed out (or the simulator, which has no link to drop, absorbed it). + ``True`` if the reboot was sent and the actions screen should close; ``False`` + if the user backed out (or the simulator, which has no link to drop, absorbed it). """ - warning = "Reboot the device now?" - if staged: - warning = ( - f"Reboot the device now? Your {_changes(staged)} have not been " - "applied and will be discarded." - ) choice = await ctx.ui.dialog( - warning, + "Reboot the device now?", [("Cancel", None), ("Reboot", "reboot")], title="πŸ”„ Reboot device", default=1, @@ -720,8 +783,8 @@ async def _reboot( ctx.reboot_in_progress = False raise # Hold here until the link is actually observed down (or a generous timeout), so the - # editor doesn't flash back to the menu for the second or two before the watcher - # notices. The watcher may cancel us mid-wait when it fires β€” that's the handoff. + # actions screen doesn't flash back to the menu for the second or two before the + # watcher notices. The watcher may cancel us mid-wait when it fires β€” that's the handoff. deadline = asyncio.get_running_loop().time() + _REBOOT_DROP_TIMEOUT_S while asyncio.get_running_loop().time() < deadline: if not await ctx.link_alive(): diff --git a/meshterm/ui/map_screen.py b/meshterm/ui/map_screen.py index 7b7e462..e68fa09 100644 --- a/meshterm/ui/map_screen.py +++ b/meshterm/ui/map_screen.py @@ -85,7 +85,7 @@ class MapScreen(Screen): the densest that many, so outliers don't dominate. See :meth:`geo.Viewport.fit`. """ super().__init__() - self.title = "mesh map" + self.title = "Map" self._session = session self._markers = markers self._source = source @@ -194,7 +194,7 @@ class MapScreen(Screen): ) scale = f"{m_per_dot * vp.dot_w:.0f} m across" if m_per_dot * vp.dot_w < 1000 else \ f"{m_per_dot * vp.dot_w / 1000:.1f} km across" - return f"mesh map Β· z{vp.zoom} Β· {len(self._markers)} nodes Β· {scale}" + return f"Map Β· z{vp.zoom} Β· {len(self._markers)} nodes Β· {scale}" # --- tiles --------------------------------------------------------------- diff --git a/meshterm/ui/menu.py b/meshterm/ui/menu.py index 205fbac..90d3a6d 100644 --- a/meshterm/ui/menu.py +++ b/meshterm/ui/menu.py @@ -18,6 +18,7 @@ import time from contextlib import asynccontextmanager, contextmanager from typing import AsyncIterator, Iterator, Optional +from rich.cells import cell_len from rich.logging import RichHandler from rich.text import Text @@ -250,16 +251,21 @@ async def _menu_loop(ctx: AppContext, session: TuiSession) -> None: last_selection: str | None = None loop = asyncio.get_running_loop() while True: + # Two aligned columns β€” tool name, then its muted description β€” with no header + # line: these are commands, not tabular data, so the alignment alone carries it. + tools = [tool for tool in all_tools() if tool.menu_visible] + name_w = max((cell_len(tool.title or tool.name) for tool in tools), default=0) items: list = [] current_category: str | None = None - for tool in all_tools(): - if not tool.menu_visible: - continue + for tool in tools: if tool.category != current_category: current_category = tool.category items.append(Separator(f"── {current_category.upper()} ──")) label = tool.title or tool.name - items.append(Choice(title=f"{label} β€” {tool.help}", value=tool.name)) + row = Text(label) + row.append(" " * (name_w - cell_len(label) + 2)) + row.append(tool.help, style="muted") + items.append(Choice(title=row, value=tool.name)) items.append(Separator(" ")) items.append(Choice(title="Quit", value="__quit__")) diff --git a/meshterm/ui/theme.py b/meshterm/ui/theme.py index 738be98..bba5073 100644 --- a/meshterm/ui/theme.py +++ b/meshterm/ui/theme.py @@ -43,6 +43,15 @@ MESH_THEME = Theme( "title.err": "bold #fca5a5", "title.ok": "bold #86efac", "title.brand": "bold #99f6e4", + # Panel/dialog footer hints: the border's hue one shade *darker* (and not bold), the + # mirror image of the ``title.*`` brightening β€” the hint reads as part of the frame + # while receding behind it. One entry per border style (see theme.hint_style). + "hint.accent": "#6366f1", + "hint.muted": "#64748b", + "hint.warn": "#f59e0b", + "hint.err": "#ef4444", + "hint.ok": "#22c55e", + "hint.brand": "#2dd4bf", # A step darker than ``muted`` for placeholder dashes (a node's missing packet count / # age) that should recede below the real, muted values around them. "faint": "#64748b", @@ -90,6 +99,25 @@ def title_style(border_style: str) -> str: return name if name in MESH_THEME.styles else border_style +def hint_style(border_style: str) -> str: + """Return the footer-hint style matching a panel's border: the same hue, muted. + + The bottom-border counterpart of :func:`title_style` β€” where the title brightens the + border's hue, the hint darkens it, so both read as part of the frame with the right + emphasis. + + Args: + border_style: The theme name the panel's border is drawn in (``"accent"``, + ``"warn"``, ...). + + Returns: + The matching ``hint.*`` theme name, or ``"muted"`` when no variant is defined (so + an unknown border keeps the old neutral hint rather than a loud one). + """ + name = f"hint.{border_style}" + return name if name in MESH_THEME.styles else "muted" + + def snr_style(snr: float | None) -> str: """Return a theme style name describing an SNR value's quality. diff --git a/meshterm/ui/tui/frame.py b/meshterm/ui/tui/frame.py index 7477e1b..6f10d2e 100644 --- a/meshterm/ui/tui/frame.py +++ b/meshterm/ui/tui/frame.py @@ -14,7 +14,7 @@ from rich.console import Group, RenderableType from rich.panel import Panel from rich.text import Text -from ..theme import title_style +from ..theme import hint_style, title_style from .render import render_lines from .screen import Screen @@ -92,11 +92,11 @@ def _panel(screen: Screen, inner_w: int, viewport: int, active: bool) -> Panel: body_lines = screen.render_body(inner_w) visible, more_above, more_below = _visible_slice(screen, body_lines, viewport) body = Text.from_ansi("\n".join(visible)) + border = "accent" if active else "muted" subtitle = None if more_above or more_below: arrow = ("↑" if more_above else " ") + ("↓" if more_below else " ") - subtitle = f"[muted]{arrow} more[/muted]" - border = "accent" if active else "muted" + subtitle = f"[{hint_style(border)}]{arrow} more[/]" return Panel( body, title=f"[{title_style(border)}]{screen.title}[/]" if screen.title else None, @@ -208,10 +208,11 @@ def compose_startup(screen: Screen, cols: int, rows: int) -> str: visible, more_above, more_below = _visible_slice(screen, body_lines, viewport) body = Text.from_ansi("\n".join(visible)) - subtitle = f"[muted]{screen.footer_hint}[/muted]" + hint = hint_style("accent") + subtitle = f"[{hint}]{screen.footer_hint}[/]" if more_above or more_below: arrow = ("↑" if more_above else " ") + ("↓" if more_below else " ") - subtitle = f"[muted]{arrow} Β· {screen.footer_hint}[/muted]" + subtitle = f"[{hint}]{arrow} Β· {screen.footer_hint}[/]" panel = Panel( body, title=f"[{title_style('accent')}]{screen.title}[/]" if screen.title else None, @@ -253,22 +254,27 @@ def compose_dialog(screen: Screen, cols: int, rows: int) -> str: cap = min(cols - 6, 100) natural = getattr(screen, "dialog_width", None) max_w = cap if natural is None else max(24, min(cap, natural)) - max_h = max(3, rows - 6) + # Rows the box may spend between its borders β€” on body lines and breathing room alike. + budget = max(3, rows - 6) body_lines = screen.render_body(max_w - 4) - viewport = min(max_h, max(1, len(body_lines))) + # Breathing room: a blank row above and below the body whenever it doesn't cost visible + # content; on a terminal too short for both, the content wins and the dialog sits flush. + vpad = 1 if len(body_lines) + 2 <= budget else 0 + viewport = min(budget - 2 * vpad, max(1, len(body_lines))) visible, more_above, more_below = _visible_slice(screen, body_lines, viewport) body = Text.from_ansi("\n".join(visible)) - subtitle = f"[muted]{screen.footer_hint}[/muted]" + border = getattr(screen, "border_style", "accent") + hint = hint_style(border) + subtitle = f"[{hint}]{screen.footer_hint}[/]" if more_above or more_below: arrow = ("↑" if more_above else " ") + ("↓" if more_below else " ") - subtitle = f"[muted]{arrow} Β· {screen.footer_hint}[/muted]" - border = getattr(screen, "border_style", "accent") + subtitle = f"[{hint}]{arrow} Β· {screen.footer_hint}[/]" panel = Panel( body, title=f"[{title_style(border)}]{screen.title}[/]" if screen.title else None, subtitle=subtitle, border_style=border, - padding=(0, 1), + padding=(vpad, 1), width=max_w, ) return render_to_ansi_dialog(panel, max_w) diff --git a/meshterm/ui/tui/select.py b/meshterm/ui/tui/select.py index 223d271..8f2bd93 100644 --- a/meshterm/ui/tui/select.py +++ b/meshterm/ui/tui/select.py @@ -113,8 +113,9 @@ class SelectScreen(Screen): def _rows(self) -> list: """Return the items to display given the active filter. - Without a filter, groups and headings show as authored. With a filter, headings - are dropped and only matching choices are shown, flattened. + Without a filter, groups and headings show as authored. With a filter, only the + matching choices are shown β€” but every separator stays, so the list keeps its + section landmarks (and column headers) while it narrows. """ if not self._filter: return self._items @@ -122,7 +123,7 @@ class SelectScreen(Screen): return [ it for it in self._items - if isinstance(it, Choice) and needle in _plain(it.label).lower() + if isinstance(it, Separator) or needle in _plain(it.label).lower() ] def _choices(self, rows: Optional[list] = None) -> list: @@ -133,15 +134,14 @@ class SelectScreen(Screen): def _section_starts(self) -> list[int]: """Choice indices that begin a section β€” the first choice after each run of separators. - Drives Ctrl+PageUp/PageDown section jumps. Empty while filtering, since the filtered - list is flattened to bare matches with no group headings to jump between. + Drives Ctrl+PageUp/PageDown section jumps. Computed over the *displayed* rows, so + the jumps keep working while a filter narrows the list (the headings stay β€” see + :meth:`_rows` β€” and empty sections simply yield no stop). """ - if self._filter: - return [] starts: list[int] = [] idx = 0 fresh = True # the next choice opens a section (top of the list, or just past a heading) - for item in self._items: + for item in self._rows(): if isinstance(item, Separator): fresh = True elif isinstance(item, Choice): @@ -208,8 +208,8 @@ class SelectScreen(Screen): lines.append("") prefix = len(plines) + 1 # Record each section heading as a sticky-header candidate, so one that scrolls off is - # re-pinned to the top row by the base Screen.sticky_header. Filtering flattens the list - # to bare choices (see _rows), so this stays empty then and nothing is pinned. + # re-pinned to the top row by the base Screen.sticky_header. Separators survive + # filtering (see _rows), so the pinning keeps working while the list narrows. self._sticky_headers = [] if self._filter: lines.append(render_to_ansi(Text(f"/{self._filter}", style="warn"), width)) diff --git a/tests/test_config_editor.py b/tests/test_config_editor.py index a46d1b4..4460a9e 100644 --- a/tests/test_config_editor.py +++ b/tests/test_config_editor.py @@ -24,6 +24,7 @@ from meshterm.ui.config_editor import ( _parse_coords, _valid_coords, contact_share_url, + device_actions, edit_config, ) from meshterm.ui.tui.prompt import TypedConfirmDialog @@ -434,7 +435,7 @@ async def test_editor_location_clear_stages_the_no_fix_pair(ctx: AppContext) -> assert ("set", "adv_lon", 0.0) in ops -async def test_editor_advert_zero_hop_and_flood_run_immediately(ctx: AppContext) -> None: +async def test_actions_advert_zero_hop_and_flood_run_immediately(ctx: AppContext) -> None: """The advert dialog sends immediately: zero-hop by default, flood when chosen.""" device = await ctx.device() sent: list[bool] = [] @@ -450,25 +451,25 @@ async def test_editor_advert_zero_hop_and_flood_run_immediately(ctx: AppContext) ("select", "flood"), ("select", "__cancel__"), ]) - assert await edit_config(ctx) is None + await device_actions(ctx) assert sent == [False, True] assert any("zero-hop" in n for n in ui.notes) assert any("flood" in n for n in ui.notes) -async def test_editor_advert_share_shows_the_contact_card(ctx: AppContext) -> None: +async def test_actions_advert_share_shows_the_contact_card(ctx: AppContext) -> None: """The share option opens the QR/URI view built from the node's own identity.""" ui = _install(ctx, [ ("select", "__advert__"), ("select", "share"), ("select", "__cancel__"), ]) - assert await edit_config(ctx) is None + await device_actions(ctx) assert any(v.startswith("Share ") for v in ui.views) -async def test_editor_factory_reset_gates_on_typed_confirmation(ctx: AppContext) -> None: - """Factory reset only runs behind the typed dialog, and drops staged changes.""" +async def test_actions_factory_reset_gates_on_typed_confirmation(ctx: AppContext) -> None: + """Factory reset only runs behind the typed dialog.""" device = await ctx.device() await device.set_custom_var("mode", "test") @@ -478,23 +479,20 @@ async def test_editor_factory_reset_gates_on_typed_confirmation(ctx: AppContext) ("typed_confirm", False), ("select", "__cancel__"), ]) - assert await edit_config(ctx) is None + await device_actions(ctx) assert await device.get_custom_vars() == {"mode": "test"} - # Confirmed: the reset runs and previously staged changes are cleared with it. + # Confirmed: the reset runs and the device comes back empty. _install(ctx, [ - ("select", "name"), - ("text", "Doomed"), + ("select", "__reset__"), ("typed_confirm", True), - ("select", "__cancel__"), # clean close: the staged change died with the reset + ("select", "__cancel__"), ]) - ui = ctx.ui - ui.script.insert(2, ("select", "__reset__")) # reset after staging the name - assert await edit_config(ctx) is None + await device_actions(ctx) assert await device.get_custom_vars() == {} -async def test_editor_import_key_gates_on_typed_confirmation(ctx: AppContext) -> None: +async def test_actions_import_key_gates_on_typed_confirmation(ctx: AppContext) -> None: """Importing an identity key requires the typed IMPORT confirmation.""" device = await ctx.device() new_key = "ab" * 32 @@ -505,31 +503,31 @@ async def test_editor_import_key_gates_on_typed_confirmation(ctx: AppContext) -> ("typed_confirm", True), ("select", "__cancel__"), ]) - assert await edit_config(ctx) is None + await device_actions(ctx) assert await device.export_private_key() == new_key -async def test_editor_reboot_on_simulator_stays_in_the_editor(ctx: AppContext) -> None: +async def test_actions_reboot_on_simulator_stays_on_the_screen(ctx: AppContext) -> None: """The simulator has no link to drop, so a confirmed reboot just notes and returns.""" ui = _install(ctx, [ ("select", "__reboot__"), ("dialog", "reboot"), ("select", "__cancel__"), ]) - assert await edit_config(ctx) is None + await device_actions(ctx) assert ctx.reboot_in_progress is False assert any("rebooting" in n for n in ui.notes) -async def test_editor_backup_writes_immediately(ctx: AppContext, tmp_path: Path) -> None: - """Backup is a device action: the TOML lands on disk before the editor closes.""" +async def test_actions_backup_writes_immediately(ctx: AppContext, tmp_path: Path) -> None: + """Backup runs at once: the TOML lands on disk before the screen closes.""" target = tmp_path / "out" / "backup.toml" ui = _install(ctx, [ ("select", "__backup__"), ("path", str(target)), ("select", "__cancel__"), ]) - assert await edit_config(ctx) is None + await device_actions(ctx) assert target.exists() assert any("wrote" in n for n in ui.notes) diff --git a/tests/test_tui.py b/tests/test_tui.py index 606d0d9..9f1967d 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -97,15 +97,17 @@ def test_select_default_and_arrow_wrap() -> None: assert _run(screen, "enter") == 1 -def test_select_filter_narrows_and_hides_separators() -> None: - """Typing filters to matching choices only; separators drop out while filtering.""" +def test_select_filter_narrows_but_keeps_separators() -> None: + """Typing filters to matching choices while the section headings stay in place.""" screen = _menu() - screen.handle("text", "a") # matches alpha, beta(?), gamma -> those containing 'a' + screen.handle("text", "a") # matches alpha, beta, gamma β€” all contain 'a' rows = screen._rows() - assert all(isinstance(r, Choice) for r in rows) - assert {r.title for r in rows} == {"alpha", "beta", "gamma"} - screen.handle("text", "l") # now 'al' -> only alpha - assert [r.title for r in screen._rows()] == ["alpha"] + assert [r.title for r in rows if isinstance(r, Separator)] == ["── group ──"] + assert {r.title for r in rows if isinstance(r, Choice)} == {"alpha", "beta", "gamma"} + screen.handle("text", "l") # now 'al' -> only alpha (the heading still shows) + rows = screen._rows() + assert [r.title for r in rows if isinstance(r, Choice)] == ["alpha"] + assert [r.title for r in rows if isinstance(r, Separator)] == ["── group ──"] assert _run(screen, "enter") == 1