diff --git a/meshterm/ui/livefeed_screen.py b/meshterm/ui/livefeed_screen.py index 2c4171e..09af52c 100644 --- a/meshterm/ui/livefeed_screen.py +++ b/meshterm/ui/livefeed_screen.py @@ -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]: diff --git a/tests/test_livefeed.py b/tests/test_livefeed.py index 4d4cec3..14406a2 100644 --- a/tests/test_livefeed.py +++ b/tests/test_livefeed.py @@ -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: