diff --git a/meshterm/tools/chat.py b/meshterm/tools/chat.py index eab2625..dbe7e3e 100644 --- a/meshterm/tools/chat.py +++ b/meshterm/tools/chat.py @@ -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. diff --git a/meshterm/ui/admin_picker.py b/meshterm/ui/admin_picker.py index 064e19e..45ffee2 100644 --- a/meshterm/ui/admin_picker.py +++ b/meshterm/ui/admin_picker.py @@ -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: diff --git a/meshterm/ui/channels.py b/meshterm/ui/channels.py index 9b9e106..38b3799 100644 --- a/meshterm/ui/channels.py +++ b/meshterm/ui/channels.py @@ -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 diff --git a/meshterm/ui/config_editor.py b/meshterm/ui/config_editor.py index 222d954..0daadbf 100644 --- a/meshterm/ui/config_editor.py +++ b/meshterm/ui/config_editor.py @@ -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 -------------------------------------------------------------- diff --git a/meshterm/ui/courier_screen.py b/meshterm/ui/courier_screen.py index de749bd..ff625a9 100644 --- a/meshterm/ui/courier_screen.py +++ b/meshterm/ui/courier_screen.py @@ -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)}โ€", diff --git a/meshterm/ui/dashboard_screen.py b/meshterm/ui/dashboard_screen.py index dce95c5..751f6ca 100644 --- a/meshterm/ui/dashboard_screen.py +++ b/meshterm/ui/dashboard_screen.py @@ -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, diff --git a/meshterm/ui/menus.py b/meshterm/ui/menus.py new file mode 100644 index 0000000..23387ae --- /dev/null +++ b/meshterm/ui/menus.py @@ -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", +] diff --git a/meshterm/ui/repeater_admin.py b/meshterm/ui/repeater_admin.py index e231361..43d847d 100644 --- a/meshterm/ui/repeater_admin.py +++ b/meshterm/ui/repeater_admin.py @@ -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 --------------------------------------------------------- diff --git a/meshterm/ui/timemachine_screen.py b/meshterm/ui/timemachine_screen.py index c5a2911..90d3baf 100644 --- a/meshterm/ui/timemachine_screen.py +++ b/meshterm/ui/timemachine_screen.py @@ -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: diff --git a/meshterm/ui/trace_screen.py b/meshterm/ui/trace_screen.py index 7f38137..5ab1e26 100644 --- a/meshterm/ui/trace_screen.py +++ b/meshterm/ui/trace_screen.py @@ -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}", diff --git a/meshterm/ui/tui/select.py b/meshterm/ui/tui/select.py index 3ec69e1..db2c4fb 100644 --- a/meshterm/ui/tui/select.py +++ b/meshterm/ui/tui/select.py @@ -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)) diff --git a/meshterm/ui/watchtower_screen.py b/meshterm/ui/watchtower_screen.py index f939b48..2ee77ba 100644 --- a/meshterm/ui/watchtower_screen.py +++ b/meshterm/ui/watchtower_screen.py @@ -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, diff --git a/tests/test_menus.py b/tests/test_menus.py new file mode 100644 index 0000000..c94e7c6 --- /dev/null +++ b/tests/test_menus.py @@ -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"