From 07481c4fd15d1dd8f2b3e48fb8429bc3e37c8f1d Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Mon, 10 Aug 2026 01:30:18 -0400 Subject: [PATCH] Watchtower: pin the alert, scroll the message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ←→ was sliding the whole alert row, so reading a long message to its end cost the marker, age, kind, glyph and node name off the left edge — the lanes that say *which* alert this is, and the part that already fitted. Each row now declares them (`Choice.hscroll_from`, the seam the Trophy case cut), keeping the `—` with the head so the message still arrives introduced. The ←→ footer atom is gated on the run rather than the whole label, so a row whose pinned lanes make it wide but whose message fits no longer advertises a key that would do nothing. The section jumps were already there: two `section_heading` rows earn the list its ^PgUp/^PgDn and their F1/F2 chips automatically. Co-Authored-By: Claude Opus 5 (1M context) --- meshterm/ui/tui/select.py | 11 +++++++-- meshterm/ui/watchtower_screen.py | 32 ++++++++++++++++++++++----- tests/test_tui.py | 25 +++++++++++++++++++++ tests/test_watchtower.py | 38 ++++++++++++++++++++++++++------ 4 files changed, 92 insertions(+), 14 deletions(-) diff --git a/meshterm/ui/tui/select.py b/meshterm/ui/tui/select.py index 96d9f64..965cd17 100644 --- a/meshterm/ui/tui/select.py +++ b/meshterm/ui/tui/select.py @@ -446,14 +446,21 @@ class SelectScreen(Screen): The gate for both the ←→ scroll and its footer atom: a row that fits has nothing to scroll. Measured against the row's content area (the width less the 2-cell pointer), using the width the last :meth:`render_body` saw (``0`` before the first paint, so - nothing reads as overflowing until a real width is known). + nothing reads as overflowing until a real width is known). Asked of + :meth:`_max_hshift` rather than of the raw label width, so a row that pins a head + block (:attr:`Choice.hscroll_from`) is judged on the run that would actually move — + the marks a scrolled row spends cells on included. """ if not self._hscroll or self._last_width <= 0: return False current = self._current_choice() if current is None: return False - return cell_len(_plain(current.label)) > max(1, self._last_width - 2) + return self._max_hshift( + cell_len(_plain(current.label)), + current.hscroll_from, + max(1, self._last_width - 2), + ) > 0 @property def sizing_footer_hint(self) -> str: diff --git a/meshterm/ui/watchtower_screen.py b/meshterm/ui/watchtower_screen.py index fb478fb..1e8ddf7 100644 --- a/meshterm/ui/watchtower_screen.py +++ b/meshterm/ui/watchtower_screen.py @@ -121,7 +121,11 @@ async def open_watchtower(ctx: "AppContext") -> Optional[dict[str, Any]]: wrap=False, hscroll=True, # ←→ scroll is surfaced by the list itself, but only while the highlighted - # alert actually overflows the width (see SelectScreen.hscroll_hint). + # alert actually overflows the width (see SelectScreen.hscroll_hint) — and it + # slides the *message* alone: each row pins its own lanes (see _alert_lanes). + # The two section headings below earn the list its ^PgUp/^PgDn jumps and their + # F1/F2 chips for free (SelectScreen.fkey_lane); the hint has no room to name + # them beside the ←→ atom, and no other grouped list spells them out either. footer_hint="↑↓ move · Enter select/acknowledge · Esc back", ) menu.future = loop.create_future() @@ -183,8 +187,13 @@ def _menu_items( if not alerts: items.append(Separator(" nothing yet — tripped rules land here")) for alert in alerts[:_SHOWN_ALERTS]: + lanes = _alert_lanes(alert, label_key, alert_type_of) items.append( - Choice(_alert_row(alert, label_key, alert_type_of), ("ack", alert.ident)) + Choice( + _alert_row(lanes, alert), + ("ack", alert.ident), + hscroll_from=lanes.cell_len, + ) ) unacked = sum(1 for a in alerts if not a.acked) acked = len(alerts) - unacked @@ -214,12 +223,12 @@ def _menu_items( return items -def _alert_row( +def _alert_lanes( alert: Alert, key_of: Callable[[str], Optional[str]], type_of: Callable[[str], Optional[int]] = lambda label: None, ) -> Text: - """One alert as a row: marker, age, kind, type glyph, node, and the message. + """An alert's fixed head: marker, age, kind, type glyph, node — and the ``—`` lead-in. The leading ``●``/``○`` is the *acknowledgement* state, so the node's own type marker (``▲`` repeater, ``●`` node, …) leads the node name instead — the map's shared marker @@ -228,6 +237,12 @@ def _alert_row( alert's node label takes its key-derived hue (resolved through ``key_of``, muted when no key is known); an acked row recedes to muted throughout — the type glyph included — with the rest of its history. + + Split from the message so the row can measure what it pins out of the ←→ scroll (see + :attr:`~meshterm.ui.tui.select.Choice.hscroll_from`): these lanes are *which alert this + is*, and reading a long message to its end is no reason to lose the row's identity. + The ``—`` stays with the head so the message still arrives introduced, whatever it is + slid to. """ row = Text() if alert.acked: @@ -243,7 +258,14 @@ def _alert_row( row.append(alert.label, style="muted") else: row.append(alert.label, style=name_style(alert.label, key_of(alert.label))) - row.append(f" — {alert.message}", style="muted") + row.append(" — ", style="muted") + return row + + +def _alert_row(lanes: Text, alert: Alert) -> Text: + """One alert as a row: its fixed lanes, then the message ``←→`` scrolls.""" + row = lanes.copy() + row.append(alert.message, style="muted") return row diff --git a/tests/test_tui.py b/tests/test_tui.py index 72d5bdd..a62ead9 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -2480,6 +2480,31 @@ def test_select_hscroll_from_pins_the_rows_head_and_slides_only_its_run() -> Non assert end.startswith("❯ " + lanes) and "-end" in end # the tail is reachable +def test_select_hscroll_hint_is_gated_on_the_run_not_the_whole_row() -> None: + """A row whose *run* fits earns no ←→ atom, however wide its pinned head makes it. + + The hint may only advertise a key that would do something (the footer's own rule), and + on a row that pins a head block the key moves the run alone — so a long lane block in + front of a short tail is not overflow, it is just a wide row. + """ + from meshterm.ui.tui.select import Choice, SelectScreen + + lanes = "#1 ✓ replied min -6.0 dB 248 ms via " + fits = SelectScreen( + "probe", [Choice(lanes + "3d,f2", 1, hscroll_from=len(lanes))], hscroll=True + ) + fits.render_body(60) + assert "←→ scroll" not in fits.footer_hint + + overflows = SelectScreen( + "probe", + [Choice(lanes + ",".join(["3d"] * 20), 1, hscroll_from=len(lanes))], + hscroll=True, + ) + overflows.render_body(60) + assert "←→ scroll" in overflows.footer_hint + + def test_select_hscroll_marks_both_edges_the_run_continues_past() -> None: """A scrolled row cracks/ellipsizes at whichever side its run runs on.""" from meshterm.ui.pathline import _ELLIPSIS diff --git a/tests/test_watchtower.py b/tests/test_watchtower.py index afa980e..6c54d36 100644 --- a/tests/test_watchtower.py +++ b/tests/test_watchtower.py @@ -235,11 +235,11 @@ def test_alert_row_hues_the_label_by_resolved_key(tmp_path: Path) -> None: """An unacked alert's label takes its key-derived hue; acked recedes to muted.""" from meshterm.core.watch_store import Alert from meshterm.ui.theme import name_style - from meshterm.ui.watchtower_screen import _alert_row + from meshterm.ui.watchtower_screen import _alert_lanes key_of = lambda label: "d4" * 6 if label == "Roof" else None alert = Alert(ident=1, when=utcnow(), kind="silence", label="Roof", message="quiet") - row = _alert_row(alert, key_of) + row = _alert_lanes(alert, key_of) at = row.plain.index("Roof") assert any( s.style == name_style("Roof", "d4" * 6) and s.start <= at < s.end @@ -249,7 +249,7 @@ def test_alert_row_hues_the_label_by_resolved_key(tmp_path: Path) -> None: acked = Alert( ident=2, when=utcnow(), kind="silence", label="Roof", message="quiet", acked=True ) - acked_row = _alert_row(acked, key_of) + acked_row = _alert_lanes(acked, key_of) at = acked_row.plain.index("Roof") assert any(s.style == "muted" and s.start <= at < s.end for s in acked_row.spans) @@ -257,7 +257,7 @@ def test_alert_row_hues_the_label_by_resolved_key(tmp_path: Path) -> None: def test_alert_row_leads_node_name_with_type_glyph(tmp_path: Path) -> None: """The node name is preceded by its shared type glyph (own colour, muted when acked).""" from meshterm.core.watch_store import Alert - from meshterm.ui.watchtower_screen import _alert_row + from meshterm.ui.watchtower_screen import _alert_lanes from meshterm.ui.widgets import _DEFAULT_GLYPH, _NODE_GLYPHS key_of = lambda label: None @@ -265,18 +265,42 @@ def test_alert_row_leads_node_name_with_type_glyph(tmp_path: Path) -> None: glyph, glyph_style = _NODE_GLYPHS[2] alert = Alert(ident=1, when=utcnow(), kind="silence", label="Roof", message="quiet") - row = _alert_row(alert, key_of, type_of) + row = _alert_lanes(alert, key_of, type_of) assert f"{glyph} Roof" in row.plain # glyph sits immediately left of the name gi = row.plain.index(glyph) assert any(s.style == glyph_style and s.start <= gi < s.end for s in row.spans) # Unknown type (and a keyless kind like courier) falls back to the plain-node glyph. ghost = Alert(ident=2, when=utcnow(), kind="courier", label="Ghost", message="gave up") - assert f"{_DEFAULT_GLYPH[0]} Ghost" in _alert_row(ghost, key_of).plain + assert f"{_DEFAULT_GLYPH[0]} Ghost" in _alert_lanes(ghost, key_of).plain # An acked alert mutes the glyph with the rest of its history. acked = Alert(ident=3, when=utcnow(), kind="silence", label="Roof", message="quiet", acked=True) - acked_row = _alert_row(acked, key_of, type_of) + acked_row = _alert_lanes(acked, key_of, type_of) gi = acked_row.plain.index(glyph) assert any(s.style == "muted" and s.start <= gi < s.end for s in acked_row.spans) + + +def test_alert_rows_pin_their_lanes_and_scroll_only_the_message() -> None: + """←→ slide an alert's *message*; the marker, age, kind, glyph and node hold still. + + The lanes in front of the ``—`` are which alert this is (JP, 2026-08-10). Reading a + long message to its end is no reason to lose them off the left edge — they are the part + that already fits, so scrolling them buys nothing and costs the row its identity. + """ + from meshterm.core.watch_store import Alert + from meshterm.ui.tui import Choice + from meshterm.ui.watchtower_screen import _alert_lanes, _menu_items + + alert = Alert(ident=1, when=utcnow(), kind="silence", label="Roof", + message="nothing heard for 6 h " + "and counting " * 6) + items = _menu_items([alert], {}, False) + row = next(it for it in items + if isinstance(it, Choice) and it.value == ("ack", alert.ident)) + + lanes = _alert_lanes(alert, lambda label: None) + assert row.hscroll_from == lanes.cell_len + assert row.label.plain.startswith(lanes.plain) + assert lanes.plain.endswith(" — ") # the lead-in stays with the head it introduces + assert row.label.plain[row.hscroll_from:] == alert.message # …and the run is the message