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:
1 parent
1c267378ef
commit
241be536d8
13 files changed
+435
-242
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 --------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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)}”",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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 ---------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user