Watchtower: pin the alert, scroll the message

←→ 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) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-08-10 01:30:18 -04:00
1 parent c2640d9660
commit 07481c4fd1
4 files changed
+92 -14

No files matched your search

+9 -2
View File
@@ -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:
+27 -5
View File
@@ -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
+25
View File
@@ -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
+31 -7
View File
@@ -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