One exit language: shared menu chrome in ui/menus.py

Every select list now builds its closing furniture through one module,
so the conventions hold by construction instead of by copy-paste:

* Back is THE exit word — bare, no arrow, no icon — with exactly one
  blank separator above it (back_rows). Courier and Watchtower lose
  their "← Back"; the rules popover's "← Close" becomes Back too.
* The staged-changes exit pair (✓ Apply … / ✗ Back — discard) moves to
  exit_rows, shared by the config editor and repeater admin, along with
  changes_phrase and one confirm_discard dialog (apply/send verb
  parametrised).
* Label + description rows share menu_rows (cell-padded lanes): Device
  actions, channel detail, repeater actions, and — converted from the
  inline "label  —  description" style — Send advert, Identity key, and
  the channel manager's Add-a-channel section, whose rows now read as
  two aligned lanes with the trailing … that marks "opens a prompt".
* Grouped-list headings share section_heading (── Label ── in accent):
  Watchtower, Courier, the Time Machine picker, and the admin picker
  (which also drops the stray 🔑 inside its heading).
* Status marks normalize to ✓/✗ (U+2713/17): Watchtower's ✔ and the
  ✖ on Cancel-this-message / Stop-watching become err-tinted ✗ rows.
* fit_cells replaces the four private _fit copies (channels, dashboard,
  time machine, chat picker) and measures display cells, so a wide
  glyph in a name can't skew a lane; empty-state notes drop their
  parentheses ("no channels configured yet", "no matches").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 4.8 committed 2026-07-12 03:48:48 -04:00
1 parent 1c267378ef
commit 241be536d8
13 files changed
+435 -242

No files matched your search

+2 -8
View File
@@ -30,6 +30,7 @@ from ..core.connection import Device
from ..core.events import EventKind, MeshEvent
from ..core.models import ChatMessage, Contact, Conversation, utcnow
from ..ui.chat import _MENTION, _sender_hue, _split_channel_sender
from ..ui.menus import fit_cells
from ..ui.tui import Choice, Separator
from ..ui.widgets import channel_glyph
from .base import Tool, ToolResult, register
@@ -569,7 +570,7 @@ def _title(
last = lasts.get(conversation.key)
text = Text(no_wrap=True, overflow="ellipsis")
_append_marker(text, conversation, last)
text.append(_fit(conversation.label, _LABEL_WIDTH))
text.append(fit_cells(conversation.label, _LABEL_WIDTH))
text.append(" ")
# Unread badge lane (_BADGE_WIDTH cells): a red ● with the count in warn, or blank filler so
# the following lanes still line up on rows with nothing unread.
@@ -666,13 +667,6 @@ def _ago(when: Any) -> str:
return f"{int(secs // 604800)}w"
def _fit(text: str, width: int) -> str:
"""Left-justify ``text`` to ``width`` columns, ellipsizing anything that would overflow."""
if len(text) > width:
return text[: width - 1] + "…"
return text.ljust(width)
def _history_table(label: str, messages: list[ChatMessage]) -> Table:
"""Render a conversation's stored messages as a table.
+6 -6
View File
@@ -38,7 +38,8 @@ async def pick_admin_node(
Returns:
The chosen contact, or ``None`` if cancelled (or there is nothing to pick).
"""
from .tui import Choice, Separator
from .menus import back_rows, section_heading
from .tui import Choice
from .widgets import _DEFAULT_GLYPH, _NODE_GLYPHS
candidates = [c for c in contacts if (c.public_key or c.key_prefix).strip()]
@@ -65,16 +66,15 @@ async def pick_admin_node(
items: list = []
if remembered:
items.append(Separator("── 🔑 Remembered admins ──", style="accent"))
items.append(section_heading("Remembered admins"))
items.extend(row(c) for c in sorted(remembered, key=recency))
if infrastructure:
items.append(Separator("── Repeaters & rooms ──", style="accent"))
items.append(section_heading("Repeaters & rooms"))
items.extend(row(c) for c in sorted(infrastructure, key=recency))
if others:
items.append(Separator("── Other contacts ──", style="accent"))
items.append(section_heading("Other contacts"))
items.extend(row(c) for c in sorted(others, key=recency))
items.append(Separator(" "))
items.append(Choice(title="Back", value=None))
items.extend(back_rows())
choice = await ctx.ui.select(title, items, prompt=prompt, wrap=False)
if choice is None:
+30 -35
View File
@@ -32,7 +32,6 @@ import time
from dataclasses import dataclass
from typing import TYPE_CHECKING, Awaitable, Callable, Optional
from rich.cells import cell_len
from rich.console import Group
from rich.text import Text
@@ -54,6 +53,7 @@ from ..core.connection import Device
from ..core.models import Conversation
from ..persistence.repository import ACTIVITY_BUCKETS
from .braillechart import activity_sparkline
from .menus import back_rows, fit_cells, menu_rows, section_heading
from .qr import qr_text
from .tui import CANCEL, Choice, SelectScreen, Separator
from .widgets import _age_seconds, _format_age, channel_glyph, format_ago
@@ -351,13 +351,6 @@ def _activity_sparkline(histogram: "tuple[int, ...]") -> Text:
return activity_sparkline(histogram, _ACTIVITY_LEVELS, ACTIVITY_BUCKETS)
def _fit(text: str, width: int) -> str:
"""Left-justify ``text`` to ``width`` columns, ellipsizing anything that would overflow."""
if len(text) > width:
return text[: width - 1] + "…"
return text.ljust(width)
# --- menus -------------------------------------------------------------------
@@ -412,7 +405,7 @@ def _slot_text(
unread = ctx.chat.unread(slot.conversation.key)
text = Text(no_wrap=True, overflow="ellipsis")
text.append(f"{channel_glyph(slot.name, slot.secret)} ") # # / 🌐 / 🔒 (2 cells) + gap
text.append(_fit(slot.name, name_w))
text.append(fit_cells(slot.name, name_w))
text.append(" ")
if unread:
text.append("●", style="err")
@@ -451,22 +444,27 @@ def _menu_items(
for slot in slots:
items.append(Choice(title=_slot_row(ctx, slot, stats, name_w), value=slot.idx))
else:
items.append(Separator(" (no channels configured yet)"))
items.append(Separator(" no channels configured yet"))
if len(slots) > 1:
items.append(Separator("── Organize ──", style="accent"))
items.append(section_heading("Organize"))
# Two spaces after the arrow: ↕ (East-Asian-ambiguous width) renders one cell where
# the sibling rows' glyphs (+ # 🔑 🔗) render two, so the extra space keeps this
# label's text column-aligned with theirs.
items.append(Choice(title="↕ Reorder channels", value=_REORDER))
items.append(Separator("── Add a channel ──", style="accent"))
items.append(Choice(title="+ New private channel (random key)", value=_CREATE))
items.append(Choice(title="# Public channel (key from its name)", value=_PUBLIC))
items.append(Choice(title="🔑 Join a channel with its key", value=_JOIN))
items.append(Choice(title="🔗 Import a meshcore:// link", value=_IMPORT))
items.append(Separator(" "))
items.append(Choice(title="Back", value=_BACK))
items.append(section_heading("Add a channel"))
items.extend(
menu_rows(
[
("+ New private channel…", "A fresh random key", _CREATE),
("# Public channel…", "Key derived from its name", _PUBLIC),
("🔑 Join with a key…", "Paste a channel's 32-hex key", _JOIN),
("🔗 Import a link…", "Paste a meshcore:// share link", _IMPORT),
]
)
)
items.extend(back_rows(_BACK))
return f"Channels — {len(slots)}/{capacity} slots", items
@@ -498,23 +496,20 @@ def _detail_items(ctx: "AppContext", slot: ChannelSlot) -> list:
if unread:
chat_label.append(" ●", style="err")
chat_label.append(f" {unread}", style="warn")
rows: list[tuple[Text, str, object]] = [
(Text("📱 Show QR code"), "Share this channel as a scannable code", _QR),
(Text("🔑 Show key"), "The name, key, hash, and share link", _KEY),
(chat_label, "Read and send messages on this channel", _CHAT),
(Text("✎ Rename / change key"), "Edit the name or paste a different key", _EDIT),
(Text("🗑 Clear this slot", style="err"), "Remove the channel from this device", _CLEAR),
]
width = max(cell_len(label.plain) for label, _, _ in rows)
items: list = []
for label, help_text, value in rows:
row = Text()
row.append_text(label)
row.append(" " * (width - cell_len(label.plain) + 2))
row.append(help_text, style="muted")
items.append(Choice(title=row, value=value))
items.append(Separator(" "))
items.append(Choice(title="Back", value=_BACK))
items = menu_rows(
[
("📱 Show QR code", "Share this channel as a scannable code", _QR),
("🔑 Show key", "The name, key, hash, and share link", _KEY),
(chat_label, "Read and send messages on this channel", _CHAT),
("✎ Rename / change key…", "Edit the name or paste a different key", _EDIT),
(
Text("🗑 Clear this slot…", style="err"),
"Remove the channel from this device",
_CLEAR,
),
]
)
items.extend(back_rows(_BACK))
return items
+47 -88
View File
@@ -55,6 +55,14 @@ from ..core.device_config import (
parse_value,
settings_by_category,
)
from .menus import (
back_rows,
confirm_discard,
exit_rows,
lane_row,
menu_rows,
section_heading,
)
from .tui import Choice, Separator
if TYPE_CHECKING:
@@ -143,7 +151,7 @@ async def edit_config(ctx: "AppContext") -> Optional[list[tuple]]:
cursor = choice
if choice in (None, _CANCEL):
if staged and not await _confirm_discard(ctx, staged):
if staged and not await confirm_discard(ctx, staged, verb="applying"):
continue # keep editing — the same menu is rebuilt next loop
return None
if choice == _APPLY:
@@ -269,21 +277,6 @@ def _location_value(snapshot: dict, pending: dict) -> Text:
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
def _menu_items(
snapshot: dict, pending: dict, staged: int, policy: AdvertPolicy
) -> tuple[str, list]:
@@ -356,41 +349,21 @@ def _menu_items(
)
]
for category, rows in sections:
items.append(Separator(f"── {category} ──", style="accent"))
items.append(section_heading(category))
for label, value, help_text, key in rows:
items.append(
Choice(title=_lane_row(label, value, help_text, label_w, value_w), value=key)
Choice(title=lane_row(label, value, help_text, label_w, value_w), value=key)
)
# 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(
title=Text.assemble(("✓ ", "ok"), f"Apply {_changes(staged)}"),
value=_APPLY,
)
)
items.append(
Choice(
title=Text.assemble(("✗ ", "err"), "Back — discard staged changes"),
value=_CANCEL,
)
)
else:
items.append(Choice(title="Back", value=_CANCEL))
# Back spells out the consequence of leaving (see menus.exit_rows).
items.extend(exit_rows(staged, apply_value=_APPLY, back_value=_CANCEL))
title = "Device configuration" + (f" — {staged} staged" if staged else "")
return title, items
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'}"
def _cadence_value(policy: AdvertPolicy, flood: bool, pending: dict) -> Text:
"""One background-advert row's VALUE lane: ``current [→ staged]``."""
key = _ADVERT_FLOOD if flood else _ADVERT_DIRECT
@@ -618,8 +591,7 @@ async def _stage_preset(ctx: "AppContext", pending: dict[str, Any]) -> None:
)
for i, p in enumerate(RADIO_PRESETS)
]
items.append(Separator(" "))
items.append(Choice(title="Back", value=None))
items.extend(back_rows())
idx = await ctx.ui.select(
"Radio presets",
items,
@@ -721,28 +693,24 @@ async def device_actions(ctx: "AppContext") -> None:
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.
The shared menu-row presentation (see :func:`~meshterm.ui.menus.menu_rows`).
Factory reset keeps its err-tinted label so the one irreversible row reads as such.
"""
actions: list[tuple[str, str, str, str]] = [
("🕒 Sync clock…", "Set the device clock from this computer", _SYNC_CLOCK, ""),
("💾 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))
items = menu_rows(
[
("🕒 Sync clock…", "Set the device clock from this computer", _SYNC_CLOCK),
("💾 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),
(
Text("⚠ Factory reset…", style="err"),
"Erase everything (typed confirmation)",
_RESET,
),
]
)
items.extend(back_rows(_CANCEL))
return items
@@ -789,11 +757,14 @@ async def send_advert(ctx: "AppContext") -> None:
choice = await ctx.ui.select(
"Send advert",
[
Choice(title="Zero-hop — Announce to direct neighbours", value="zero"),
Choice(title="Flood — Repeaters rebroadcast it across the mesh", value="flood"),
Choice(title="Share QR / URI — Show this node's contact card", value="share"),
Separator(" "),
Choice(title="Back", value=None),
*menu_rows(
[
("Zero-hop", "Announce to direct neighbours", "zero"),
("Flood", "Repeaters rebroadcast it across the mesh", "flood"),
("Share QR / URI", "Show this node's contact card", "share"),
]
),
*back_rows(),
],
prompt="Announce this node to the mesh:",
filterable=False,
@@ -1004,11 +975,14 @@ async def _identity_key_menu(ctx: "AppContext", device: "Device", snapshot: dict
choice = await ctx.ui.select(
"🔐 Identity key",
[
Choice(title="Show private key — Display it on screen (sensitive)", value="show"),
Choice(title="Export to a file… — Write it to disk (keep it secret)", value="file"),
Choice(title="Import a key… — Replace this device's identity", value="import"),
Separator(" "),
Choice(title="Back", value=None),
*menu_rows(
[
("Show private key", "Display it on screen (sensitive)", "show"),
("Export to a file…", "Write it to disk (keep it secret)", "file"),
("Import a key…", "Replace this device's identity", "import"),
]
),
*back_rows(),
],
prompt="Manage this node's private identity key:",
)
@@ -1077,21 +1051,6 @@ async def _factory_reset(ctx: "AppContext", device: "Device", snapshot: dict) ->
return True
# --- confirmations -------------------------------------------------------------
async def _confirm_discard(ctx: "AppContext", staged: int) -> bool:
"""Ask before dropping staged changes on the way out; ``True`` means discard."""
choice = await ctx.ui.dialog(
f"Discard {_changes(staged)} without applying them?",
[("Keep editing", "keep"), ("Discard", "discard")],
title="Unsaved changes",
default=1,
danger=True,
)
return choice == "discard"
# --- validators --------------------------------------------------------------
+9 -9
View File
@@ -21,8 +21,9 @@ from typing import TYPE_CHECKING, Any, Optional
from rich.text import Text
from ..core.courier_store import DELIVERED, GAVE_UP, QUEUED, QueuedMessage
from ..core.courier_store import DELIVERED, QUEUED, QueuedMessage
from ..core.models import Contact, utcnow
from .menus import back_rows, section_heading
from .tui import Choice, Separator
from .watchtower_screen import contact_watch_key
from .widgets import (
@@ -148,23 +149,22 @@ def _menu_items(ctx: "AppContext", entries: list[QueuedMessage]) -> list:
waiting = [m for m in entries if m.status == QUEUED]
done = [m for m in entries if m.status != QUEUED]
items: list = [Separator("Outbox", style="accent")]
items: list = [section_heading("Outbox")]
if not waiting:
items.append(Separator(" empty — queued messages wait here for their moment"))
for message in waiting:
items.append(Choice(_waiting_row(ctx, message), ("msg", message.ident)))
items.append(Separator("")) # space the action off the outbox rows above it
items.append(Choice("✉ Queue a message…", _QUEUE))
items.append(Separator(" ")) # space the action off the outbox rows above it
items.append(Choice("📨 Queue a message…", _QUEUE))
if done:
items.append(Separator(""))
items.append(Separator("Finished", style="accent"))
items.append(Separator(" "))
items.append(section_heading("Finished"))
for message in done[:15]:
items.append(Choice(_done_row(message), ("msg", message.ident)))
items.append(Choice("🗑 Clear finished", _CLEAR))
items.append(Separator(""))
items.append(Choice("← Back", None)) # a visible exit beside Esc
items.extend(back_rows()) # a visible exit beside Esc
return items
@@ -329,7 +329,7 @@ async def _entry_actions(ctx: "AppContext", ident: int) -> None:
return
items = [
Choice("📤 Send now — one forced attempt", "send"),
Choice("✖ Cancel this message", "cancel"),
Choice(Text.assemble(("✗ ", "err"), "Cancel this message"), "cancel"),
]
picked = await session.select(
f"📨 {message.node_name} — “{_shorten(message.text, 28)}”",
+2 -8
View File
@@ -47,6 +47,7 @@ from ..core.events import EventKind, MeshEvent
from ..core.models import NODE_TYPE_REPEATER, Observation, utcnow
from ..persistence.repository import ACTIVITY_WINDOW
from .braillechart import axis_chart, meter, timeline_rows
from .menus import fit_cells
from .packet_viewer import (
KIND_STYLES,
PacketEntry,
@@ -108,13 +109,6 @@ def _channel_sender(text: Optional[str]) -> Optional[str]:
return name if name and not name.isdigit() else None
def _fit(text: str, width: int) -> str:
"""Left-justify ``text`` to ``width`` columns, ellipsizing anything longer."""
if len(text) > width:
return text[: width - 1] + "…"
return text.ljust(width)
def _span_label(minutes: int) -> str:
"""A compact duration — ``45 min`` under two hours, else ``2.5 h`` / ``3 h``."""
if minutes < 120:
@@ -572,7 +566,7 @@ class DashboardScreen(Screen):
label, style = node_label(entry, self._resolve, self._self_name)
if label == "?":
label, style = self._feed_subject(entry) # no node identity: name what we can
row.append(_fit(label, _FEED_NAME_WIDTH), style=style)
row.append(fit_cells(label, _FEED_NAME_WIDTH), style=style)
row.append(" ")
row.append(
f"{entry.snr:+5.1f} dB" if entry.snr is not None else " " * 8,
+206
View File
@@ -0,0 +1,206 @@
"""Shared select-menu chrome: the pieces every list screen builds the same way.
MeshTerm's list screens (the config editors, the channel manager, Watchtower, Courier,
the pickers) all end in the same furniture: label-and-description rows padded into
aligned lanes, a blank line, then the exit group — a plain ``Back`` row, or the
``✓ Apply / ✗ Back — discard`` pair once changes are staged. Before this module each
screen hand-rolled its own copy, and they drifted (``← Back``, ``← Close``, missing
blank lines, ``✖`` for ``✗``). These helpers are now the one way to build that
furniture, so the app's exit language and row alignment stay uniform by construction:
* :func:`back_rows` — the standard exit group: one blank separator, then ``Back``.
* :func:`exit_rows` — the staged-changes variant: ``✓ Apply n staged changes`` above an
err-tinted ``✗ Back — discard staged changes`` (or the plain group when clean).
* :func:`menu_rows` — label + muted description :class:`Choice` rows in two aligned
lanes, padded in display cells so double-width emoji can't skew the description
column (the Device actions presentation).
* :func:`lane_row` — one SETTING / VALUE / DESCRIPTION row for the editor-style lists.
* :func:`changes_phrase` — ``"1 staged change"`` / ``"3 staged changes"``.
* :func:`confirm_discard` — the shared are-you-sure dialog for leaving staged changes.
House rules the helpers encode (see the UX standards in ``CLAUDE.md``): the exit row is
always the bare word ``Back`` — no arrow, no icon — with exactly one blank separator
line above it; ``✓``/``✗`` (U+2713/U+2717) are THE status marks, styled ``ok``/``err``.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Iterable, Union
from rich.cells import cell_len
from rich.text import Text
from .tui import Choice, Separator
if TYPE_CHECKING:
from ..context import AppContext
def back_rows(value: Any = None) -> list:
"""The standard exit group closing a select list: a blank line, then ``Back``.
Args:
value: The value the Back row resolves with (``None`` reads as cancel for
most callers; the config editors pass their own sentinel).
Returns:
``[Separator(" "), Choice("Back", value)]`` — append to the end of the items.
"""
return [Separator(" "), Choice(title="Back", value=value)]
def exit_rows(staged: int, *, apply_value: Any, back_value: Any) -> list:
"""The editor exit group: Apply joins Back once there is something staged.
With nothing staged this is exactly :func:`back_rows`. With changes staged, an
ok-marked ``✓ Apply …`` row sits above an err-marked ``✗ Back — discard staged
changes`` so the consequence of leaving is spelled out (the config editor's
long-standing presentation, now shared).
Args:
staged: How many changes are staged (0 = the plain Back group).
apply_value: The value the Apply row resolves with.
back_value: The value the Back row resolves with.
Returns:
The rows to append: a blank separator, then the one- or two-row exit group.
"""
if not staged:
return back_rows(back_value)
return [
Separator(" "),
Choice(
title=Text.assemble(("✓ ", "ok"), f"Apply {changes_phrase(staged)}"),
value=apply_value,
),
Choice(
title=Text.assemble(("✗ ", "err"), "Back — discard staged changes"),
value=back_value,
),
]
def menu_rows(rows: Iterable[tuple[Union[str, Text], str, Any]]) -> list:
"""Label + description :class:`Choice` rows in two aligned lanes.
The menu-style presentation shared by Device actions, the channel detail, and the
repeater actions: no header line (these are commands, not tabular data), the
description column starting two cells past the widest label, padding computed in
display cells so a double-width emoji can't skew it.
Args:
rows: ``(label, description, value)`` triples. A :class:`Text` label keeps its
own styling (an err-tinted destructive row, a live unread badge).
Returns:
One :class:`Choice` per row, lanes aligned across them all.
"""
prepared = [
(label if isinstance(label, Text) else Text(label), help_text, value)
for label, help_text, value in rows
]
width = max((cell_len(label.plain) for label, _, _ in prepared), default=0)
items: list = []
for label, help_text, value in prepared:
row = Text()
row.append_text(label)
row.append(" " * (width - cell_len(label.plain) + 2))
row.append(help_text, style="muted")
items.append(Choice(title=row, value=value))
return items
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 (cell-padded).
The editor-list presentation (device configuration, repeater admin): 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
def changes_phrase(count: int) -> str:
"""``"1 staged change"`` / ``"3 staged changes"`` for dialogs and menu rows."""
return f"{count} staged change{'' if count == 1 else 's'}"
async def confirm_discard(
ctx: "AppContext", staged: int, *, verb: str = "applying"
) -> bool:
"""Ask before dropping staged changes on the way out; ``True`` means discard.
The shared unsaved-changes dialog: the safe way out (keep editing) sits left, the
committing Discard right and default, per the app's dialog convention.
Args:
ctx: Shared application context (for the UI surface).
staged: How many changes would be dropped.
verb: What applying them would have done — ``"applying"`` for the local
editor, ``"sending"`` for a remote one — woven into the question.
Returns:
``True`` if the user chose to discard; ``False`` to keep editing.
"""
choice = await ctx.ui.dialog(
f"Discard {changes_phrase(staged)} without {verb} them?",
[("Keep editing", "keep"), ("Discard", "discard")],
title="Unsaved changes",
default=1,
danger=True,
)
return choice == "discard"
def section_heading(label: str) -> Separator:
"""A ``── Label ──`` accent section heading for a grouped select list.
The one form every grouped list's headings take (the main menu's categories, the
config editor's setting groups, Watchtower's Alerts/Watched nodes, Courier's
Outbox/Finished), so sections read the same on every screen.
"""
return Separator(f"── {label} ──", style="accent")
def fit_cells(text: str, width: int, *, align: str = "left") -> str:
"""Fit ``text`` into exactly ``width`` display cells, ellipsizing overflow.
The lane-fitting helper every fixed-width name/label column uses: padding and
truncation are measured in display cells (wide glyphs count 2), so a name carrying
an emoji or CJK character can't skew the lanes the way ``str.ljust`` would.
Args:
text: The text to fit.
width: The exact cell width to return.
align: ``"left"`` (pad on the right) or ``"right"`` (pad on the left).
Returns:
A string measuring exactly ``width`` cells.
"""
if cell_len(text) > width:
kept = ""
for ch in text:
if cell_len(kept + ch) > width - 1:
break
kept += ch
text = kept + "…"
pad = " " * max(0, width - cell_len(text))
return pad + text if align == "right" else text + pad
__all__ = [
"back_rows",
"changes_phrase",
"confirm_discard",
"exit_rows",
"fit_cells",
"lane_row",
"menu_rows",
"section_heading",
]
+18 -61
View File
@@ -36,6 +36,7 @@ from ..core.remote_config import (
reply_is_error,
settings_by_category,
)
from .menus import confirm_discard, exit_rows, lane_row, menu_rows, section_heading
from .trace_screen import TracingDialog
from .tui import Choice, Separator
from .tui.spinner import Spinner
@@ -162,7 +163,7 @@ async def _admin_session(
cursor = choice
if choice in (None, _CANCEL):
if pending and not await _confirm_discard(ctx, len(pending)):
if pending and not await confirm_discard(ctx, len(pending), verb="sending"):
continue
return {"node": node.name, "applied": applied}
if choice == _APPLY:
@@ -229,45 +230,28 @@ def _menu_items(
)
]
for category, rows in sections:
items.append(Separator(f"── {category} ──", style="accent"))
items.append(section_heading(category))
for label, value, help_text, key in rows:
items.append(
Choice(title=_lane_row(label, value, help_text, label_w, value_w), value=key)
Choice(title=lane_row(label, value, help_text, label_w, value_w), value=key)
)
items.append(Separator("── Actions ──", style="accent"))
actions: list[tuple[str, str, str]] = [
("↻ Read settings", "Fetch every value from the node, one paced get", _READ),
("⌨ Command line…", "Talk to the node's CLI directly", _CLI),
("📡 Send advert…", "Have the node announce itself now", _ADVERT),
("🕒 Sync clock…", "Set the node's clock over the mesh", _CLOCK),
("🔐 Admin password…", "Change the node's admin password", _PASSWORD),
("🔄 Reboot node…", "Restart it remotely", _REBOOT),
]
action_w = max(cell_len(label) for label, _, _ in actions)
for label, help_text, value in actions:
row = Text(label)
row.append(" " * (action_w - cell_len(label) + 2))
row.append(help_text, style="muted")
items.append(Choice(title=row, value=value))
items.append(section_heading("Actions"))
items.extend(
menu_rows(
[
("↻ Read settings", "Fetch every value from the node, one paced get", _READ),
("⌨ Command line…", "Talk to the node's CLI directly", _CLI),
("📡 Send advert…", "Have the node announce itself now", _ADVERT),
("🕒 Sync clock…", "Set the node's clock over the mesh", _CLOCK),
("🔐 Admin password…", "Change the node's admin password", _PASSWORD),
("🔄 Reboot node…", "Restart it remotely", _REBOOT),
]
)
)
items.append(Separator(" "))
staged = len(pending)
if staged:
items.append(
Choice(
title=Text.assemble(("✓ ", "ok"), f"Apply {_changes(staged)}"),
value=_APPLY,
)
)
items.append(
Choice(
title=Text.assemble(("✗ ", "err"), "Back — discard staged changes"),
value=_CANCEL,
)
)
else:
items.append(Choice(title="Back", value=_CANCEL))
items.extend(exit_rows(staged, apply_value=_APPLY, back_value=_CANCEL))
title = f"Repeater admin — {node.name}" + (f" · {staged} staged" if staged else "")
return title, items
@@ -290,33 +274,6 @@ def _value_text(spec: RemoteSetting, cache: dict, pending: dict[str, str]) -> Te
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 (cell-padded)."""
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
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'}"
async def _confirm_discard(ctx: "AppContext", staged: int) -> bool:
"""Ask before dropping staged changes on the way out; ``True`` means discard."""
choice = await ctx.ui.dialog(
f"Discard {_changes(staged)} without sending them?",
[("Keep editing", "keep"), ("Discard", "discard")],
title="Unsaved changes",
default=1,
danger=True,
)
return choice == "discard"
# --- staging and applying ---------------------------------------------------------
+5 -11
View File
@@ -31,6 +31,7 @@ from rich.text import Text
from ..core.models import utcnow
from .braillechart import GAP, axis_chart, chart_span, timeline_rows
from .menus import fit_cells, section_heading
from .theme import snr_style
from .tui.render import render_lines
from .tui.screen import Screen
@@ -581,7 +582,7 @@ def _mesh_sections(
for node, name, first in listed:
secs = _age_seconds(first)
line = Text(" ", no_wrap=True, overflow="ellipsis")
line.append(_fit(name or "unknown", name_w), style=_recency_style(secs))
line.append(fit_cells(name or "unknown", name_w), style=_recency_style(secs))
line.append(" ")
line.append_text(highlighted_hash(node, prefix_bytes, width=_PICK_HASH_W))
line.append(" ")
@@ -619,13 +620,6 @@ _PICK_NAME_MAX = 18
_PICK_HASH_W = 12
def _fit(text: str, width: int) -> str:
"""Left-justify ``text`` to ``width`` columns, ellipsizing anything that overflows."""
if len(text) > width:
return text[: width - 1] + "…"
return text.ljust(width)
def _known_name(resolve: "NodeResolver", node: Optional[str], name: Optional[str]) -> Optional[str]:
"""The best display name for a heard node, or ``None`` when truly unknown.
@@ -674,7 +668,7 @@ def _picker_row(node: "HeardNode", name: Optional[str], name_w: int, prefix_byte
row = Text(no_wrap=True, overflow="ellipsis")
row.append(glyph, style=glyph_style)
row.append(" ")
row.append(_fit(name or "unknown", name_w), style=_recency_style(secs))
row.append(fit_cells(name or "unknown", name_w), style=_recency_style(secs))
row.append(" ")
row.append_text(highlighted_hash(node.node or "", prefix_bytes, width=_PICK_HASH_W))
row.append(" ")
@@ -764,8 +758,8 @@ async def open_timemachine(ctx: "AppContext") -> None:
)
items: list = [
Choice("🌐 The whole mesh — days, arrivals, the ledger", MESH),
Separator(""),
Separator("Nodes, most recently heard first", style="accent"),
Separator(" "),
section_heading("Nodes · most recently heard first"),
Separator(_picker_header(name_w)),
]
for node, name in listed:
+6 -3
View File
@@ -72,6 +72,7 @@ from ..core.models import PATH_TRACE_TARGET, TraceResult, TraceStats
from ..services import trace_runner
from ..services.topology import render_forced_spec
from .braillechart import meter
from .menus import back_rows
from .theme import snr_style
from .tui.render import render_lines, render_to_ansi
from .tui.screen import Screen
@@ -1463,7 +1464,9 @@ async def _open_session(ctx: "AppContext", target: Optional[str]) -> int:
)
return None
items: list = [Separator("── Candidate paths · from received evidence ──", style="accent")]
items: list = [
Separator("── Candidate paths · from received evidence ──", style="accent")
]
for scenario in scenarios:
items.append(
Choice(title=scenario_title(scenario, topo), value=("use", scenario))
@@ -1475,7 +1478,7 @@ async def _open_session(ctx: "AppContext", target: Optional[str]) -> int:
value=("probe", None),
)
)
items.append(Choice(title="Back", value=("back", None)))
items.extend(back_rows(("back", None)))
picked = await session.run_screen(
SelectScreen(
f"reach {target_label} · scenarios",
@@ -1506,7 +1509,7 @@ async def _open_session(ctx: "AppContext", target: Optional[str]) -> int:
for rank, outcome in enumerate(outcomes, start=1):
result_items.append(Choice(title=outcome_title(rank, outcome), value=outcome))
result_items.append(Separator(" "))
result_items.append(Choice(title="Keep current path", value=None))
result_items.append(Choice(title="Keep current path", value=None)) # the exit: no adoption
adopted = await session.run_screen(
SelectScreen(
f"probe results · {target_label}",
+1 -1
View File
@@ -245,7 +245,7 @@ class SelectScreen(Screen):
text.truncate(width)
lines.append(render_to_ansi(text, width))
if not choices:
lines.append(render_to_ansi(Text("(no matches)", style="muted"), width))
lines.append(render_to_ansi(Text("no matches", style="muted"), width))
# Remember where the highlighted row landed so the session can keep it in view,
# shifted past any prompt lines drawn above the list.
base_cursor = _cursor_line(rows, selected, bool(self._filter))
+11 -12
View File
@@ -22,6 +22,7 @@ from rich.text import Text
from ..core.models import NODE_TYPE_REPEATER, Contact
from ..core.watch_store import OFF, SILENCE_CHOICES_H, Alert, WatchedNode
from .menus import back_rows, section_heading
from .tui import Choice, Separator
from .widgets import _age_seconds, _format_age
@@ -124,7 +125,7 @@ def _menu_items(
alerts: list[Alert], watched: dict[str, WatchedNode], new_node_alerts: bool
) -> list:
"""Build the screen's rows: alerts, then the watchlist, then the actions."""
items: list = [Separator("Alerts", style="accent")]
items: list = [section_heading("Alerts")]
if not alerts:
items.append(Separator(" nothing yet — tripped rules land here"))
for alert in alerts[:_SHOWN_ALERTS]:
@@ -132,19 +133,19 @@ def _menu_items(
unacked = sum(1 for a in alerts if not a.acked)
acked = len(alerts) - unacked
if unacked:
items.append(Choice(f"✔ Acknowledge all ({unacked})", _ACK_ALL))
items.append(Choice(f"✓ Acknowledge all ({unacked})", _ACK_ALL))
if acked:
items.append(Choice("🗑 Clear acknowledged", _CLEAR))
items.append(Separator(""))
items.append(Separator("Watched nodes", style="accent"))
items.append(Separator(" "))
items.append(section_heading("Watched nodes"))
if not watched:
items.append(Separator(" none starred yet — silence and SNR rules need one"))
for key in sorted(watched, key=lambda k: watched[k].name.casefold()):
items.append(Choice(_watched_row(watched[key]), ("node", key)))
items.append(Choice("⭐ Watch a node…", _WATCH))
items.append(Separator(""))
items.append(Separator(" "))
state = "[ok]on[/ok]" if new_node_alerts else "[muted]off[/muted]"
items.append(
Choice(
@@ -153,8 +154,7 @@ def _menu_items(
)
)
items.append(Separator(""))
items.append(Choice("← Back", None)) # a visible exit beside Esc
items.extend(back_rows()) # a visible exit beside Esc
return items
@@ -241,12 +241,11 @@ async def _node_rules(ctx: "AppContext", key: str) -> None:
return
silence = "off" if entry.silence_hours == OFF else f"after {entry.silence_hours} h"
items = [
Choice(f"🕐 Silence alarm {silence}", "silence"),
Choice(f"🕒 Silence alarm {silence}", "silence"),
Choice("📶 SNR watch " + ("on" if entry.snr_watch else "off"), "snr"),
Separator(""),
Choice("✖ Stop watching this node", "unwatch"),
Separator(""),
Choice("← Close", None),
Separator(" "),
Choice(Text.assemble(("✗ ", "err"), "Stop watching this node"), "unwatch"),
*back_rows(),
]
picked = await session.select(
f"Rules — {entry.name}", items, filterable=False,
+92
View File
@@ -0,0 +1,92 @@
"""Shared menu-chrome tests: the furniture every select list builds the same way.
These pin the app's exit-affordance and lane conventions at their source, so a screen
that builds its rows through :mod:`meshterm.ui.menus` inherits the standards and a
regression here fails once rather than on every screen.
"""
from __future__ import annotations
from rich.text import Text
from meshterm.ui.menus import (
back_rows,
changes_phrase,
exit_rows,
fit_cells,
menu_rows,
section_heading,
)
from meshterm.ui.tui import Choice, Separator
def test_back_rows_are_one_blank_line_then_bare_back() -> None:
"""The exit group is exactly: a blank separator, then the unadorned word Back."""
rows = back_rows("sentinel")
assert len(rows) == 2
assert isinstance(rows[0], Separator) and rows[0].title == " "
assert isinstance(rows[1], Choice)
assert rows[1].title == "Back" # no arrow, no icon — the app-wide exit word
assert rows[1].value == "sentinel"
def test_exit_rows_clean_state_is_plain_back() -> None:
"""With nothing staged the editor exit group collapses to the plain Back group."""
rows = exit_rows(0, apply_value="A", back_value="B")
assert [type(r) for r in rows] == [Separator, Choice]
assert rows[1].title == "Back" and rows[1].value == "B"
def test_exit_rows_staged_state_spells_out_the_consequence() -> None:
"""Staged changes produce ✓ Apply above ✗ Back — discard, after one blank line."""
rows = exit_rows(3, apply_value="A", back_value="B")
assert isinstance(rows[0], Separator) and rows[0].title == " "
apply_row, back_row = rows[1], rows[2]
assert apply_row.value == "A" and back_row.value == "B"
assert apply_row.title.plain == "✓ Apply 3 staged changes"
assert back_row.title.plain == "✗ Back — discard staged changes"
def test_menu_rows_align_descriptions_in_display_cells() -> None:
"""Every description starts at the same cell column, wide emoji labels included."""
rows = menu_rows(
[
("🔄 Reboot device…", "Restart it", 1), # emoji = 2 cells
("Plain", "Second", 2),
]
)
from rich.cells import cell_len
starts = {cell_len(r.title.plain[: r.title.plain.index(d)]) for r, d in
zip(rows, ["Restart it", "Second"])}
assert len(starts) == 1 # one shared description column
def test_menu_rows_keep_a_styled_label_styled() -> None:
"""A Text label (an err-tinted destructive row) keeps its spans in the built row."""
rows = menu_rows([(Text("⚠ Danger", style="err"), "Careful", "x")])
title = rows[0].title
assert title.plain.startswith("⚠ Danger")
assert any(span.style == "err" for span in title.spans)
def test_section_heading_wears_the_dashes_and_accent() -> None:
"""Grouped-list headings read ── Label ── in the accent style, everywhere."""
sep = section_heading("Outbox")
assert sep.title == "── Outbox ──"
assert sep.style == "accent"
def test_changes_phrase_pluralizes() -> None:
assert changes_phrase(1) == "1 staged change"
assert changes_phrase(2) == "2 staged changes"
def test_fit_cells_measures_display_cells_not_characters() -> None:
"""Padding and truncation count display cells, so wide glyphs can't skew lanes."""
from rich.cells import cell_len
assert fit_cells("abc", 5) == "abc "
assert cell_len(fit_cells("日本語の名前", 5)) == 5 # wide chars: truncated by cells
assert fit_cells("abcdef", 5).endswith("…")
assert fit_cells("ab", 5, align="right") == " ab"