Polish menus and dialogs: lanes, headers, and a Device actions screen

- Dialog footer hints take the border's hue one shade darker (hint.*
  theme styles), mirroring the brighter title tinting.
- Dialogs get a blank breathing row above and below the body whenever
  it doesn't cost visible content.
- The chat conversation picker gains column headers over its lanes.
- The map screen titles itself "Map", matching its menu item.
- The config editor lays settings out in aligned SETTING / VALUE /
  DESCRIPTION columns under a header line, and with changes staged the
  Apply and Back rows sit together below the blank separator.
- Device actions move out of the config editor into their own screen
  behind a new menu-only device-actions tool (CLI equivalents stay
  under the config group).
- Type-to-filter keeps section headers (and column headers) in place,
  and section jumps now work over the filtered rows too.
- The main menu aligns tool names and muted descriptions into two
  columns, headerless.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Fable 5 committed 2026-07-09 23:10:11 -04:00
1 parent a23b12f6a4
commit 768487e456
11 files changed
+367 -177

No files matched your search

+18 -1
View File
@@ -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]]:
+4 -3
View File
@@ -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
+69
View File
@@ -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).
"""
+181 -118
View File
@@ -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():
+2 -2
View File
@@ -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 ---------------------------------------------------------------
+10 -4
View File
@@ -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__"))
+28
View File
@@ -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.
+17 -11
View File
@@ -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)
+10 -10
View File
@@ -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))
+19 -21
View File
@@ -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)
+9 -7
View File
@@ -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