From ab63a83bbc4e746a0258d261c3c49e4535e01949 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Mon, 10 Aug 2026 17:01:42 -0400 Subject: [PATCH] Trace actions: the icon column is measured, not assumed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every action row led with its mark and a hard space, which only aligns while every mark is one cell wide. ⚡ is an emoji — two — so Explore's label sat a column right of Compose's, Trace's, and the rest. The marks now go through a measured icon column: each pads out to the widest of them, so the labels share one column whatever they lead with. Measured rather than fixed at two because the marks change width with the platform — on PicoCalc every icon funnels through theme.glyph to a single console-font character, so the column tightens to one cell and the labels move left with it. Co-Authored-By: Claude Opus 5 (1M context) --- meshterm/ui/trace_screen.py | 43 +++++++++++++++++++++++++++++++------ tests/test_live_screens.py | 17 +++++++++++++++ 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/meshterm/ui/trace_screen.py b/meshterm/ui/trace_screen.py index 292eaf0..dedb7d8 100644 --- a/meshterm/ui/trace_screen.py +++ b/meshterm/ui/trace_screen.py @@ -97,7 +97,7 @@ from ..services.records import first_repeated_edge from ..services.topology import render_forced_spec from .braillechart import meter from .menus import back_rows, section_heading -from .theme import snr_style +from .theme import glyph, snr_style from .tui.render import render_lines, render_to_ansi from .tui.screen import ListWindow, Screen from .tui.spinner import Spinner, spinner_interval @@ -149,6 +149,35 @@ _ROUTE_LANE = "route " #: spec starts, and hangs, in the same column as the numbers above it. _PATH_LANE = "path " +#: Every mark the action rows lead with, in row order. The list exists to *measure* +#: the icon column: ``⚡`` is an emoji, two cells where the rest are one, so a hard +#: ``"icon "`` prefix would start Explore's label a column right of every other +#: action's. See :func:`_icon_lane`. +_ACTION_ICONS = ("✎", "⇄", "⚡", "⚙", "#", "▶") + + +def _icon_lane() -> int: + """The action rows' icon column, in cells: the widest mark on this platform. + + Measured rather than hard-coded because the marks themselves change width with + the platform: on PicoCalc every icon funnels through :func:`~meshterm.ui.theme.glyph` + to a single console-font character (``⚡`` → ``!``), so the column tightens to one + cell and the labels move left with it — the same alignment rule at the console's + own resolution. + """ + return max(cell_len(glyph(icon)) for icon in _ACTION_ICONS) + + +def _icon(text: Text, icon: str, style: str) -> None: + """Append an action row's mark in the icon column, its trailing space included. + + A one-cell mark pads out to :func:`_icon_lane` so every label — narrow mark or + wide emoji — starts in the same column. + """ + mark = glyph(icon) + text.append(mark, style=style) + text.append(" " * (_icon_lane() - cell_len(mark) + 1)) + def _lane_lines(label: str, value: list[Text], width: int) -> list[str]: """Render a labelled lane whose value hangs, already broken, under its own column. @@ -842,27 +871,27 @@ class TraceScreen(Screen): """One action row: pointer, glyph, and label (current value inlined).""" text = Text("❯ " if selected else " ", style="brand" if selected else "") if key == "compose": - text.append("✎ ", style="brand") + _icon(text, "✎", "brand") text.append("Compose path") elif key == "reverse": - text.append("⇄ ", style="accent") + _icon(text, "⇄", "accent") if self._effective_spec()[0]: text.append("Reverse path — trace it the other way") else: text.append("Reverse path — compose a path first", style="muted") elif key == "explore": - text.append("⚡ ", style="warn") + _icon(text, "⚡", "warn") text.append("Explore paths") elif key == "width": w = self._width_bytes() - text.append("⚙ ", style="accent") + _icon(text, "⚙", "accent") text.append(f"Path width — {w} byte{'s' if w != 1 else ''} per hop") elif key == "samples": n = self._sample_count() - text.append("# ", style="accent") + _icon(text, "#", "accent") text.append(f"Sample count — {n} trace{'s' if n != 1 else ''}") elif key == "trace": - text.append("▶ ", style="ok") + _icon(text, "▶", "ok") if self._mode == "path" and not self._effective_spec()[0]: text.append("Trace — compose a path first", style="muted") else: diff --git a/tests/test_live_screens.py b/tests/test_live_screens.py index 3fc3a93..87882da 100644 --- a/tests/test_live_screens.py +++ b/tests/test_live_screens.py @@ -11,6 +11,7 @@ import asyncio from datetime import timedelta import pytest +from rich.cells import cell_len from meshterm.core.models import Contact, Hop, TraceResult, TraceStats, TxLevelResult, \ TxOptResult, utcnow @@ -419,6 +420,22 @@ async def test_trace_screen_explore_adopts_a_scenario_path() -> None: assert screen._path_spec == "3d63,f2c2" # None leaves the spec untouched +async def test_trace_screen_action_labels_share_one_column() -> None: + """Every action's label starts in the same column, wide mark or narrow. + + ``⚡`` is an emoji — two cells where ``✎``/``⚙``/``#``/``▶`` are one — so a fixed + ``"icon "`` prefix would start Explore's label a column right of the rest. + """ + screen, _ = _trace_screen() + labels = ["Compose path", "Explore paths", "Path width", "Sample count", "Trace —"] + columns = set() + for row in _plain(screen.render_body(100)).splitlines(): + for label in labels: + if label in row: + columns.add(cell_len(row[: row.index(label)])) + assert len(columns) == 1, columns + + async def test_trace_screen_action_cursor_commits_the_selected_row() -> None: """↑↓ move over the action rows; Enter commits the one under the cursor.""" opened: list[str] = []