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:
1 parent
c2640d9660
commit
07481c4fd1
4 files changed
+92
-14
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user