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:
1 parent
a23b12f6a4
commit
768487e456
11 files changed
+367
-177
No files matched your search
+18
-1
@@ -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]]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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
@@ -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__"))
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user