Live feed: column headers over the lanes, the app's own cursor
Two bits of chrome the screen had been going without. A column header, pinned above the rows. It needs none of the base screen's sticky-header machinery: the feed windows inside the space the chrome leaves, so the body never scrolls and the header cannot travel off the top of it. Nothing here sorts — a feed is a stream and its one order is the status line's to state — so no column carries the contact list's sort triangle or its lit active-column style. These are signposts, not controls. Getting a label to sit over its values meant the row and the header having to agree about the lanes, so both now measure them from one set of constants. That turned up a real defect: the RSSI lane rendered 11 cells when a packet carried a reading and 10 when it didn't, so rows were ragged by one and the class-label threshold was a cell short — at exactly its own width the row lost its last character. Both readings now right-align their number in a shared field, their labels right-align over the same field, and an empty lane pads to what it would have filled. And the highlight is the app's own at last: ❯ over a brand row, as every select list draws it, in place of the ▸/accent this one screen had invented. The lanes keep their colours over the tint — those are the content; the pointer is what says "this row". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
db68a17e23
commit
64ac7dc2ed
2 files changed
+142
-21
No files matched your search
@@ -67,11 +67,31 @@ _FEED_NAME_WIDTH = 18
|
||||
#: packet under (``direct message``), so every label reads whole and the lanes hold.
|
||||
_FEED_CLASS_WIDTH = 14
|
||||
|
||||
# The remaining lanes, as the row and its column header both measure them — one set of
|
||||
# numbers so a header label can never drift off the values it names, and so a lane a
|
||||
# packet left empty pads to exactly the width it would have filled.
|
||||
#: Cells between adjacent lanes.
|
||||
_LANE_GAP = 2
|
||||
#: ``HH:MM:SS`` plus the gap that follows it.
|
||||
_TIME_LANE = 8 + _LANE_GAP
|
||||
#: The class icon and its trailing space (all the class lane keeps on a narrow terminal).
|
||||
_ICON_LANE = 3
|
||||
#: Cells a reception reading right-aligns its number in — the ``%+5.1f``/``%5.0f`` field
|
||||
#: both lanes are built on, and the slot their column headers sit over.
|
||||
_READING_W = 5
|
||||
#: ``+13.2 dB`` — the number field, then its unit.
|
||||
_SNR_LANE = _READING_W + len(" dB")
|
||||
#: `` -61 dBm`` — the lane gap, the number field, then its unit.
|
||||
_RSSI_LANE = _LANE_GAP + _READING_W + len(" dBm")
|
||||
|
||||
#: Terminal width below which the feed drops the textual class label and keeps only the
|
||||
#: two-cell icon. It is exactly the width the full row occupies — with no route to make
|
||||
#: room for, every lane fits a 72-column screen, so the label (the row's most informative
|
||||
#: field now that it names the real class) is only ever dropped when it truly can't fit.
|
||||
_FEED_LABEL_MIN_WIDTH = 69
|
||||
_FEED_LABEL_MIN_WIDTH = (
|
||||
2 + _TIME_LANE + _ICON_LANE + _FEED_CLASS_WIDTH + _LANE_GAP
|
||||
+ _FEED_NAME_WIDTH + _LANE_GAP + _SNR_LANE + _RSSI_LANE
|
||||
)
|
||||
|
||||
#: Cells one ←/→ press shifts the highlighted row by — the app-wide select list's own
|
||||
#: step (:attr:`~meshterm.ui.tui.select.SelectScreen._HSCROLL_STEP`), so a row here
|
||||
@@ -300,14 +320,23 @@ class LiveFeedScreen(Screen):
|
||||
# --- rendering ---------------------------------------------------------------------
|
||||
|
||||
def render_body(self, width: int) -> list[str]:
|
||||
"""The pinned heading, then the feed's window filling the viewport."""
|
||||
"""The status line and column header, then the feed's window under them.
|
||||
|
||||
Both chrome lines are pinned by construction rather than by the base screen's
|
||||
sticky-header machinery: the feed windows *inside* the space they leave (see
|
||||
:meth:`_feed_lines`), so the body never scrolls and the header can never travel
|
||||
off the top of it.
|
||||
"""
|
||||
if self._selected is not None and self._feed:
|
||||
self._selected = min(self._selected, len(self._feed) - 1)
|
||||
else:
|
||||
self._hmax = 0 # nothing highlighted scrolls, so nothing advertises ←→
|
||||
show_label = width >= _FEED_LABEL_MIN_WIDTH
|
||||
lines = [render_to_ansi(self._heading(), width, no_wrap=True)]
|
||||
if self._feed: # a header over nothing is noise; the empty note speaks for itself
|
||||
lines.append(render_to_ansi(self._column_header(show_label), width, no_wrap=True))
|
||||
win = max(1, self._scroll_viewport - len(lines))
|
||||
lines.extend(self._feed_lines(width, win))
|
||||
lines.extend(self._feed_lines(width, win, show_label))
|
||||
self._scroll_total = max(1, len(lines))
|
||||
return lines
|
||||
|
||||
@@ -320,11 +349,39 @@ class LiveFeedScreen(Screen):
|
||||
heading.append("○ waiting for a device", style="muted")
|
||||
return heading
|
||||
|
||||
def _feed_lines(self, width: int, win: int) -> list[str]:
|
||||
@staticmethod
|
||||
def _column_header(show_label: bool) -> Text:
|
||||
"""The lane names, in the app's uppercase muted column-header voice.
|
||||
|
||||
Laid out lane for lane against :meth:`_feed_row`, the pointer column included, so
|
||||
every label sits over the values it names. Nothing here sorts — the feed is a
|
||||
stream, and its one order (newest first) is the status line's to state — so no
|
||||
column carries the contact list's sort triangle or its lit active-column style;
|
||||
these are signposts, not controls. On a terminal too narrow for the class label
|
||||
the header drops its ``CLASS`` too, leaving the icon lane unlabelled rather than
|
||||
clipping a word into three cells.
|
||||
"""
|
||||
header = Text(" ", style="muted") # the pointer lane
|
||||
header.append(fit_cells("TIME", _TIME_LANE))
|
||||
header.append(
|
||||
fit_cells("CLASS", _ICON_LANE + _FEED_CLASS_WIDTH + _LANE_GAP)
|
||||
if show_label
|
||||
else " " * _ICON_LANE
|
||||
)
|
||||
header.append(fit_cells("NODE", _FEED_NAME_WIDTH + _LANE_GAP))
|
||||
# The two readings right-align their number, so their labels do too — each sits
|
||||
# over the digits it names rather than over the sign column ahead of them.
|
||||
header.append(fit_cells("SNR", _READING_W, align="right"))
|
||||
header.append(" " * (_SNR_LANE - _READING_W))
|
||||
header.append(" " * _LANE_GAP)
|
||||
header.append(fit_cells("RSSI", _READING_W, align="right"))
|
||||
header.append(" " * (_RSSI_LANE - _LANE_GAP - _READING_W))
|
||||
return header
|
||||
|
||||
def _feed_lines(self, width: int, win: int, show_label: bool) -> list[str]:
|
||||
"""The feed's windowed rows: newest first, ``↑/↓ n more`` at the edges."""
|
||||
if not self._feed:
|
||||
return [render_to_ansi(Text("nothing heard yet", style="muted"), width)]
|
||||
show_label = width >= _FEED_LABEL_MIN_WIDTH
|
||||
entries = list(self._feed)
|
||||
top, count = self._feed_window.fit(len(entries), win, self._selected)
|
||||
out: list[str] = []
|
||||
@@ -355,43 +412,53 @@ class LiveFeedScreen(Screen):
|
||||
it doesn't try to, and the route is drawn properly one keypress away, in the
|
||||
packet viewer.
|
||||
|
||||
The highlight is the app's own: the ``❯`` pointer and a brand base style under the
|
||||
whole row, exactly as every select list draws its cursor. The lanes keep their own
|
||||
colours over it — the SNR its quality hue, a name its palette hue — because those
|
||||
colours are the content; the pointer and the base tint are what say "this row".
|
||||
|
||||
A row that runs past the right edge — a terminal narrower than the lanes need —
|
||||
is read by scrolling it, not by growing it: the *highlighted* row slides under
|
||||
``←→`` (:attr:`_hshift`), the app-wide h-scroll convention, with the ``▸``
|
||||
``←→`` (:attr:`_hshift`), the app-wide h-scroll convention, with the ``❯``
|
||||
pointer lane pinned so the cursor never scrolls away from the row it marks. The
|
||||
shift bound is measured here, against the current width, so a resize can only
|
||||
clamp it back into range.
|
||||
"""
|
||||
row = Text(no_wrap=True, overflow="ellipsis")
|
||||
row.append("▸ " if selected else " ", style="accent")
|
||||
row.append("❯ " if selected else " ", style="brand" if selected else "")
|
||||
avail = max(1, width - 2)
|
||||
|
||||
body = Text(no_wrap=True, overflow="ellipsis")
|
||||
body.append(entry.when.astimezone().strftime("%H:%M:%S") + " ", style="muted")
|
||||
body.append(
|
||||
fit_cells(entry.when.astimezone().strftime("%H:%M:%S"), _TIME_LANE),
|
||||
style="muted",
|
||||
)
|
||||
icon, class_label = class_marks(entry)
|
||||
body.append(icon + " ")
|
||||
body.append(fit_cells(icon, _ICON_LANE))
|
||||
if show_label:
|
||||
body.append(
|
||||
fit_cells(class_label, _FEED_CLASS_WIDTH),
|
||||
style=KIND_STYLES.get(entry.kind, "brand"),
|
||||
)
|
||||
body.append(" ") # the lane's own gutter — the longest class fills it exactly
|
||||
body.append(" " * _LANE_GAP) # the lane's gutter — the longest class fills it
|
||||
label, style = node_label(entry, self._resolve, self._self_name)
|
||||
if label == "?":
|
||||
label, style = self._feed_subject(entry) # no node identity: name what we can
|
||||
body.append(fit_cells(label, _FEED_NAME_WIDTH), style=style)
|
||||
body.append(" ")
|
||||
body.append(" " * _LANE_GAP)
|
||||
body.append(
|
||||
f"{entry.snr:+5.1f} dB" if entry.snr is not None else " " * 8,
|
||||
f"{entry.snr:+{_READING_W}.1f} dB" if entry.snr is not None
|
||||
else " " * _SNR_LANE,
|
||||
style=snr_style(entry.snr) if entry.snr is not None else "muted",
|
||||
)
|
||||
body.append(
|
||||
f" {entry.rssi:5.0f} dBm" if entry.rssi is not None else " " * 10,
|
||||
f"{'':{_LANE_GAP}}{entry.rssi:{_READING_W}.0f} dBm"
|
||||
if entry.rssi is not None else " " * _RSSI_LANE,
|
||||
style="muted",
|
||||
)
|
||||
note = self._feed_note(entry)
|
||||
if note is not None:
|
||||
body.append(" ")
|
||||
body.append(" " * _LANE_GAP)
|
||||
body.append_text(note)
|
||||
|
||||
if selected:
|
||||
@@ -400,6 +467,10 @@ class LiveFeedScreen(Screen):
|
||||
if self._hshift:
|
||||
body = crop_cells(body, self._hshift, avail)
|
||||
row.append_text(body)
|
||||
if selected:
|
||||
# The brand tint rides *under* the row's spans (the select list's own
|
||||
# convention), so the lanes keep their colours and only the gaps take it.
|
||||
row.style = "brand"
|
||||
return row
|
||||
|
||||
def _feed_subject(self, entry: PacketEntry) -> tuple[str, str]:
|
||||
|
||||
+57
-7
@@ -9,9 +9,11 @@ from __future__ import annotations
|
||||
import re
|
||||
from datetime import timedelta
|
||||
|
||||
from rich.cells import cell_len
|
||||
|
||||
from meshterm.core.events import MeshEvent
|
||||
from meshterm.core.models import Ack, Message, Observation, utcnow
|
||||
from meshterm.ui.livefeed_screen import LiveFeedScreen
|
||||
from meshterm.ui.livefeed_screen import _ICON_LANE, LiveFeedScreen
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
@@ -49,6 +51,16 @@ def _stripped(lines: list[str]) -> list[str]:
|
||||
return [re.sub(r"\x1b\[[0-9;]*m", "", line) for line in lines]
|
||||
|
||||
|
||||
def _rows(screen: LiveFeedScreen, width: int) -> list[str]:
|
||||
"""Just the packet rows — the body past its status line and its column header."""
|
||||
return _stripped(screen.render_body(width))[2:]
|
||||
|
||||
|
||||
def _col(line: str, needle: str) -> int:
|
||||
"""The display column (cells, not characters) ``needle`` starts at within ``line``."""
|
||||
return cell_len(line[: line.index(needle)])
|
||||
|
||||
|
||||
def test_livefeed_tool_registers_under_the_dashboard() -> None:
|
||||
"""The livefeed tool lands in the Mesh section, right below the dashboard."""
|
||||
from meshterm.tools import load_all_tools
|
||||
@@ -85,7 +97,7 @@ def test_livefeed_rows_leave_the_relay_path_to_the_viewer() -> None:
|
||||
screen.on_event(
|
||||
MeshEvent.observation_event(_obs(kind="packet", path="3d63,a1b2", snr=1.0))
|
||||
)
|
||||
row = _stripped(screen.render_body(72))[1]
|
||||
row = _rows(screen, 72)[0]
|
||||
assert "via" not in row and "YUL" not in row # no route, not even a stub of one
|
||||
assert "📦 packet" in row # the class still reads in words at 72
|
||||
assert "+1.0 dB" in row and "-90 dBm" in row # …as does the reception it was heard at
|
||||
@@ -107,7 +119,7 @@ def test_livefeed_class_lane_names_the_payload_class_once() -> None:
|
||||
_obs(node="", kind="packet", snr=1.0, path="3d63,a1b2", raw=raw)
|
||||
)
|
||||
)
|
||||
row = _stripped(screen.render_body(100))[1]
|
||||
row = _rows(screen, 100)[0]
|
||||
assert "🎯 trace" in row # the class lane, under the payload class's own icon
|
||||
assert row.count("trace") == 1 # said once, not once per lane
|
||||
assert "packet" not in row # …and never as the generic event family
|
||||
@@ -126,6 +138,44 @@ def test_livefeed_names_a_channel_message_by_its_sender() -> None:
|
||||
assert "ch 3" in body # …with the channel kept as the trailing context
|
||||
|
||||
|
||||
def test_livefeed_column_header_sits_over_the_lanes_it_names() -> None:
|
||||
"""The header names each lane, and every label lands on the values beneath it."""
|
||||
screen = _screen(seed=[_obs(node="3d63", kind="telemetry", snr=12.8, rssi=-105.0)])
|
||||
header, row = _stripped(screen.render_body(72))[1:3]
|
||||
assert header.split() == ["TIME", "CLASS", "NODE", "SNR", "RSSI"]
|
||||
# Measured in display cells, not characters — the class icon is one character wide
|
||||
# but two cells, so a character index would report every later lane one column early.
|
||||
assert _col(header, "TIME") == _col(row, "03:")
|
||||
assert _col(header, "CLASS") == _col(row, "telemetry") - _ICON_LANE
|
||||
assert _col(header, "NODE") == _col(row, "YUL")
|
||||
# The two readings right-align their number, so their labels end where the digits do.
|
||||
assert _col(header, "SNR") + 3 == _col(row, "+12.8") + 5
|
||||
assert _col(header, "RSSI") + 4 == _col(row, "-105") + 4
|
||||
|
||||
|
||||
def test_livefeed_column_header_is_pinned_and_carries_no_sort_cue() -> None:
|
||||
"""The header never scrolls away, and it advertises no sort — the feed has one order."""
|
||||
screen = _screen(seed=[_obs(node=f"n{i}", age_s=i) for i in range(40)])
|
||||
screen.note_viewport(12)
|
||||
lines = _stripped(screen.render_body(100))
|
||||
assert "TIME" in lines[1]
|
||||
assert not any(mark in lines[1] for mark in ("▲", "▼")) # nothing here sorts
|
||||
screen.handle("end") # walk to the oldest packet — the window scrolls under the header
|
||||
after = _stripped(screen.render_body(100))
|
||||
assert after[1] == lines[1] # …and the header is exactly where it was
|
||||
assert "↑" in after[2] and "more" in after[2] # the rows really did travel
|
||||
|
||||
|
||||
def test_livefeed_highlight_uses_the_app_wide_cursor() -> None:
|
||||
"""The feed marks its row like every other list: a ``❯`` pointer over a brand row."""
|
||||
screen = _screen(seed=[_obs(node="n0", age_s=1), _obs(node="n1", age_s=0)])
|
||||
rows = _rows(screen, 100)
|
||||
assert rows[0].startswith("❯ ") and rows[1].startswith(" ")
|
||||
assert "▸" not in "\n".join(rows) # the old odd-one-out mark is gone
|
||||
raw = screen.render_body(100)[2]
|
||||
assert raw.startswith("\x1b[") # the pointer carries the brand style, not bare text
|
||||
|
||||
|
||||
def test_livefeed_page_keys_move_the_feed_selection() -> None:
|
||||
"""With a feed row highlighted, PgUp/PgDn walk the selection a windowful at a time."""
|
||||
seed = [_obs(node=f"n{i}", age_s=i) for i in range(20)]
|
||||
@@ -172,22 +222,22 @@ def test_livefeed_the_whole_row_fits_a_72_column_screen() -> None:
|
||||
def test_livefeed_highlighted_row_scrolls_sideways_to_its_tail() -> None:
|
||||
"""On a terminal too narrow for the lanes, ←→ slide the highlighted row to its end."""
|
||||
screen = _screen(seed=[_obs()])
|
||||
opening = _stripped(screen.render_body(_CRAMPED))[1]
|
||||
opening = _rows(screen, _CRAMPED)[0]
|
||||
assert screen._hmax > 0 # the row does run past the right edge
|
||||
assert not opening.rstrip().endswith("dBm") # …so its reception tail is off-screen
|
||||
|
||||
for _ in range(40): # → saturates at the row's own end, never past it
|
||||
screen.handle("right")
|
||||
scrolled = _stripped(screen.render_body(_CRAMPED))[1]
|
||||
scrolled = _rows(screen, _CRAMPED)[0]
|
||||
assert screen._hshift == screen._hmax
|
||||
assert scrolled.startswith("▸ ") # the pointer lane stays pinned while the row slides
|
||||
assert scrolled.startswith("❯ ") # the pointer lane stays pinned while the row slides
|
||||
assert scrolled.rstrip().endswith("-90 dBm") # the tail is now readable
|
||||
assert opening[2:10] not in scrolled # …at the cost of the time lane, slid off left
|
||||
|
||||
for _ in range(40):
|
||||
screen.handle("left")
|
||||
assert screen._hshift == 0
|
||||
assert _stripped(screen.render_body(_CRAMPED))[1] == opening # back where it started
|
||||
assert _rows(screen, _CRAMPED)[0] == opening # back where it started
|
||||
|
||||
|
||||
def test_livefeed_moving_the_selection_abandons_the_rows_scroll() -> None:
|
||||
|
||||
Reference in new issue
Block a user