Files
MeshTerm/meshterm/ui/menus.py
T
Jean-Pierre MartineauandClaude Opus 5 92d31a3191 Every file is spelled the way the formatter spells it
`ruff format .` across the repo: 153 files, no behaviour change. This is the
second gate CONTRIBUTING.md promises, and until now it reported 154 files needing
reformat — so a first contributor ran the command the guide told them to run and
could not tell their own diff from the background.

Landed on its own, with nothing else in it, because a whole-repo reformat is only
safe to skip past in blame if it is provably nothing but layout. It was checked
that way rather than assumed: every changed file was formatted in memory and
compared by full `ast.dump()`, and 152 of 153 come out structurally identical
(the one exception was a docstring opening on a quote character, fixed in the
commit before this one). The string and comment token streams were diffed
separately, so a quote flip or an edited comment could not hide inside a
re-layout — five quote-style normalisations and sixteen implicit-concatenation
joins, all value-identical, and one commented-out line gaining the space after
its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite
is 1888 green, gallery gate and `test_theme16` included.

What actually changed, in order of how much of the diff it is: wrapped calls and
signatures rejoin onto one line where they fit inside the hundred columns; the
magic trailing comma expands what it is there to expand; complex slices gain
their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are
laid out to be read were fenced in the previous commit and are untouched here.

The aligned trailing-comment column is gone from about ninety lanes that were not
fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced
where it sits. Every comment stays on the entry it describes, so nothing there
reads wrong; it just no longer lines up.

Add this commit to `.git-blame-ignore-revs`, which follows immediately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 01:07:19 -04:00

492 lines
22 KiB
Python

"""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 share the same furniture: label-and-description rows padded into
aligned lanes. 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 it, so the app's row language and alignment stay uniform by
construction:
* :func:`exit_rows` — the staged-changes exit group: ``✓ Apply n staged changes`` above
an err-tinted ``✗ Back — discard staged changes``, and *nothing at all* 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:`column_header` / :class:`Lane` — the header line over a lane-aligned list, which
abbreviates its labels to fit a narrow terminal instead of wrapping or losing one, and
:func:`lane_header`, the ready-made header for :func:`lane_row`'s lanes.
* :func:`changes_phrase` — ``"1 staged change"`` / ``"3 staged changes"``.
* :func:`confirm_discard` — the shared are-you-sure dialog for leaving staged changes.
* :func:`run_steps` — an entry flow of two or more prompts, run as a stack: Esc on one
step goes back to the step before it, with what was answered there still in hand.
House rules the helpers encode (see the UX standards in ``CLAUDE.md``): a list carries
no exit row — Esc leaves, and a row repeating it earned nothing for its two lines; the
one survivor is the staged-changes pair, which is a choice rather than an exit and keeps
its blank separator line above it. ``✓``/``✗`` (U+2713/U+2717) are THE status marks,
styled ``ok``/``err``.
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable, Iterable, Sequence
from dataclasses import dataclass
from typing import (
TYPE_CHECKING,
Any,
)
from rich.cells import cell_len
from rich.text import Text
from ..platforms import get_platform
from .theme import glyph
from .tui import Choice, Separator
if TYPE_CHECKING:
from ..context import AppContext
#: What a command row's label may be: plain text, or styled text whose spans survive the
#: icon cut (an err-tinted destructive row, a live unread badge).
LabelT = str | Text
#: The app's status-atom separator, in its two widths. ``·`` chains atoms (the standards'
#: rule — ``Map · z12 · 34 nodes``); the roomy form is the default, and a surface running
#: out of cells falls back to the compact one rather than folding an atom onto a line of
#: its own. The header lays its segments out this way (see :func:`~meshterm.ui.menu._header`)
#: and so does the packet card's reception row — the same trade, wherever the cells are the
#: scarce thing.
SEP_ROOMY = " · "
SEP_COMPACT = " · "
def command_icon(icon: str) -> str:
"""The glyph a command row leads with — empty where the platform drops the icon lane.
The compose-time half of :data:`~meshterm.platforms.Platform.menu_icons`, for a row
that builds its own icon lane (a screen's body actions) rather than carrying the icon
inside a label string — :func:`command_label` is that case. Returning ``""`` rather
than a stand-in is the point: the caller measures what comes back, so an emptied lane
costs no cells at all, and the padding it would have taken goes to the label.
Args:
icon: The row's icon, as written on the regular platform.
Returns:
:func:`~meshterm.ui.theme.glyph`'s rendering of it, or ``""``.
"""
return glyph(icon) if get_platform().menu_icons else ""
def icon_lane(icons: Iterable[str]) -> int:
"""THE icon column's width in cells: the widest mark among ``icons`` on this platform.
A list's icons are **not all the same width**, and the terminal is the authority on
which are which: of the app's lexicon, ten (``🗑 ✎ ⚙ ▶ ★ ↻ ↕ ⇄ ⌨ #``) draw one cell
and the rest two. A row that simply wrote ``icon + " "`` therefore started its label a
column left of its two-cell siblings — which is what put ``🗑 Delete contact…`` out of
line under ``💾 Archive contact`` on the node page (JP, 2026-09-01). So a list declares
the icons it uses, measures the column once, and pads every mark out to it.
Measured rather than hard-coded because the marks themselves change with the platform:
the PicoCalc draws no icon lane on a command row at all (see :func:`command_icon`), so
the column measures zero and the labels take back the cells — the same alignment rule
wherever the icons land.
Args:
icons: Every mark the list's rows can lead with, as written on the regular platform.
Returns:
The column width in cells, or ``0`` where this platform draws no icons (and for an
empty set, which is the same thing to a caller).
"""
return max((cell_len(command_icon(icon)) for icon in icons if icon), default=0)
def icon_mark(icon: str, style: str, lane: int) -> Text:
"""One row's mark, tinted, padded to ``lane`` cells, its trailing space included.
The single place a command row's icon column is written, so a mark one cell narrower
than its neighbours cannot shift the label after it. An emptied lane (``lane`` of
``0``) appends nothing at all, separator included — the cells belong to the label.
Args:
icon: The row's icon, as written on the regular platform.
style: The theme style the mark is drawn in.
lane: The column width from :func:`icon_lane`.
Returns:
The padded mark, or empty :class:`~rich.text.Text` where there is no lane.
"""
mark = command_icon(icon) if icon else ""
if not lane or not mark:
return Text()
text = Text(mark, style=style)
text.append(" " * (lane - cell_len(mark) + 1))
return text
def marked_label(icon: str, label: str, style: str, *, lane: int | None = None) -> Text:
"""A command row whose icon carries a tint — the tint moving to the label if it goes.
The app marks a destructive command by tinting its icon, not its words (``🗑`` in
``err`` before a plain "Delete all records…"). Drop the icon on a platform without an
icon lane and the tint would go with it, leaving a delete row looking like any other,
so it lands on the label instead — the presentation the config editor's *Factory
reset* has always used.
Args:
icon: The row's icon, as written on the regular platform.
label: The row's words, with no icon and no leading space.
style: The theme style the mark (or, iconless, the label) is drawn in.
lane: The icon column's width in cells, from :func:`icon_lane` over every icon the
surrounding list uses. **Pass it whenever the list mixes icon widths**, or the
narrow ones start their labels a column early. ``None`` (the default) measures
this icon alone, which is right for a list whose rows all lead with the same
mark, and for the lone action row.
Returns:
The composed row label.
"""
width = icon_lane((icon,)) if lane is None else lane
mark = icon_mark(icon, style, width)
return Text.assemble(mark, label) if mark.plain else Text(label, style=style)
def command_label(label: LabelT) -> LabelT:
"""``label`` with its leading icon dropped where the platform drops the icon lane.
A command row's icon is *decoration*: it names the action's family while the label
names the action, so it is the first thing to go when cells are scarce (see
:data:`~meshterm.platforms.Platform.menu_icons`). The icon is taken to be everything
before the label's first space, and only when that head starts with a non-alphanumeric
character — so ``"🗑 Clear this slot…"`` and ``"↻ Read settings"`` both lose their
head while ``"Reorder"`` and ``"Trace target"`` pass through untouched.
What must *not* come through here: a glyph carrying data (a channel's openness, a
packet's class, a node's type) or a status mark on an outcome or commit row
(``✓ Apply…``, ``✗ Back — discard…``, built by :func:`exit_rows`). Those say something
the label doesn't, on every platform.
Args:
label: The row's full label, icon included — a plain string or a styled
:class:`~rich.text.Text` (whose spans survive the cut).
Returns:
The label, iconless or unchanged. The type is the one that went in.
"""
if get_platform().menu_icons:
return label
plain = label.plain if isinstance(label, Text) else label
head, sep, rest = plain.partition(" ")
if not sep or not head or head[0].isalnum() or not rest.strip():
return label
# Take the gap with the icon: a row that padded a one-cell mark out to the width of
# its two-cell siblings ("↕ Reorder channels") must not leave the padding behind.
cut = len(plain) - len(rest.lstrip(" "))
return label[cut:]
def exit_rows(staged: int, *, apply_value: Any, back_value: Any) -> list:
"""The editor exit group — nothing at all until there is something staged.
A list does not advertise its own exit: Esc leaves, on both platforms, and a row
that only repeated it cost two lines of every screen to say what Esc already said
(the app-wide ``Back`` row, retired 2026-08-29). What survives here is the case
where leaving is not merely leaving: with changes staged, an ok-marked
``✓ Apply …`` row sits above an err-marked ``✗ Back — discard staged changes``.
That pair is a *choice*, not an exit — ``Apply`` has no key of its own, so it needs
a visible counterpart naming what the other way out costs — and a choice keeps its
rows for the same reason a confirm dialog keeps its Cancel button.
Args:
staged: How many changes are staged (0 = no rows at all).
apply_value: The value the Apply row resolves with.
back_value: The value the Back row resolves with — identical to what Esc
resolves, since both run the caller's discard confirm.
Returns:
The rows to append: nothing when clean, else a blank separator and the pair.
"""
if not staged:
return []
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[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.
Each label passes through :func:`command_label` first, so a platform that draws no
icon lane loses it *before* the lane is measured — the description column moves left
with the labels rather than going ragged behind them. Each row also pins that column
as its :attr:`~meshterm.ui.tui.select.Choice.hscroll_from`, so on an ``hscroll`` list
←→ slide the description under a label that stays put.
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 = [
(Text(label) if isinstance(label, str) else label.copy(), help_text, value)
for label, help_text, value in (
(command_label(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")
# The label lane is the row's identity; only the description overflows, so the
# h-scroll rides it alone and the name stays pinned (see Choice.hscroll_from).
items.append(Choice(title=row, value=value, hscroll_from=width + 2))
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
@dataclass(frozen=True)
class Lane:
"""One lane of a column header: its label, any shorter forms, and its width.
Attributes:
label: The lane's label — a plain string, or its forms longest-first
(``("DESCRIPTION", "DESC")``) for a lane that can give cells back on a narrow
terminal. Only a lane the line actually overruns is ever shortened.
width: Cells the label is padded to — the lane's own width *plus* the gap before
the next lane, so a label wider than its column absorbs that gap instead of
shifting every lane after it right (``UNREAD`` over a narrower badge lane).
Zero (the default) for a trailing lane, which just runs to the edge.
"""
label: str | Sequence[str]
width: int = 0
@property
def forms(self) -> tuple[str, ...]:
"""The lane's labels, longest first (a bare string is its own only form)."""
return (self.label,) if isinstance(self.label, str) else tuple(self.label)
def column_header(lanes: Sequence[Lane], width: int, *, indent: int = 2) -> str:
"""The column-header line over a lane-aligned list, fitted to ``width``.
THE header builder for every list that pads its rows into columns (the two setting
editors, the chat picker). Lanes are laid out left to right at their own widths, after
``indent`` cells clearing the pointer column, so each label lands exactly over the lane
it names.
A header is one row and stays one row. Where the line overruns ``width`` the lanes fall
back to their shorter labels — from the right, since the fixed lanes are padded to their
rows' content and only a trailing lane can actually give a cell back — and a line that
still won't fit is ellipsized. It never wraps: a pinned header (see
:attr:`~meshterm.ui.tui.select.Separator.pinned`) is drawn outside the body slice, where
a second row would cost the content one.
Args:
lanes: The lanes in display order.
width: Cells the line has to fit into — the screen's render width.
indent: Leading pad in cells: the select screen's 2-cell pointer column, plus any
glyph lane the rows draw before their first value.
Returns:
The header line, at most ``width`` cells wide.
"""
picked = [0] * len(lanes)
def line() -> str:
out = " " * indent
for lane, form in zip(lanes, picked, strict=True):
label = lane.forms[form]
out += label + " " * max(0, lane.width - cell_len(label))
return out
at = len(lanes) - 1
while at >= 0 and cell_len(line()) > width:
if picked[at] + 1 < len(lanes[at].forms):
picked[at] += 1 # this lane has something shorter to offer — take it
else:
at -= 1 # spent; ask the lane to its left
text = line()
return fit_cells(text, width) if cell_len(text) > width else text
def lane_header(label_w: int, value_w: int, width: int) -> str:
"""The SETTING / VALUE / DESCRIPTION header over :func:`lane_row`'s lanes.
The row builder's header twin, shared by the two editor lists (device configuration
and repeater admin) so they head identical lanes identically. ``DESCRIPTION`` shortens
to ``DESC`` where the two value lanes leave it no room — long setting labels and a
staged ``current → new`` value can push the full word past a 72-column terminal.
"""
return column_header(
[
Lane("SETTING", label_w + 2),
Lane("VALUE", value_w + 2),
Lane(("DESCRIPTION", "DESC")),
],
width,
)
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"
async def run_steps(steps: Sequence[Callable[[list], Awaitable[Any]]]) -> list | None:
"""Run a chain of prompts as a stack — Esc on a step goes back to the one before it.
An entry flow that asks two or more things in a row (pick a slot, then name the
channel; pick a recipient, write the message, choose when it goes) is a stack like any
other: Esc undoes the last thing you did, not the whole flow. Gathering the answers as
one straight run of awaits instead made every Esc abandon everything, so a mistyped
32-hex channel key also cost the name typed before it.
Each step is awaited with the answers gathered so far — the same list each time, so a
step can label itself with an earlier answer *and* offer its own previous answer as its
default when it is come back to — and returns its value, or ``None`` to step back.
``None`` from the first step ends the flow: there is nothing behind it. A step whose
real answer can be ``None`` (Courier's "when it's next heard") returns a sentinel
instead and the caller reads it back.
Args:
steps: The prompts, in order. Index ``i`` reads and writes ``values[i]``.
Returns:
The answers, positionally, or ``None`` if the flow was abandoned.
"""
values: list = [None] * len(steps)
index = 0
while index < len(steps):
answer = await steps[index](values)
if answer is None:
index -= 1
if index < 0:
return None
continue
values[index] = answer
index += 1
return values
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.
It is also what makes a heading a *landmark*: the row is marked
:attr:`~meshterm.ui.tui.select.Separator.heading`, so it re-pins to the top row once its
section scrolls under it and the Ctrl+PageUp/PageDown jumps step by it. Building the row
by hand is how a section loses that — go through here.
A heading that leads with an icon (``📡 Channels``, a trophy discipline's mark) loses it
on a platform that draws no icon lane, exactly as a command row does — a heading is the
same kind of label, and a stand-in glyph beside the rule already drawing ``──`` reads as
noise (see :func:`command_label`).
"""
return Separator(f"── {command_label(label)} ──", style="accent", heading=True)
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__ = [
"Lane",
"changes_phrase",
"column_header",
"confirm_discard",
"exit_rows",
"fit_cells",
"lane_header",
"lane_row",
"menu_rows",
"section_heading",
]