diff --git a/meshterm/ui/livefeed_screen.py b/meshterm/ui/livefeed_screen.py index e731b27..a9b2f6d 100644 --- a/meshterm/ui/livefeed_screen.py +++ b/meshterm/ui/livefeed_screen.py @@ -3,9 +3,8 @@ The interactive face of the ``livefeed`` tool — the dashboard's old feed panel, promoted to a first-class screen. One always-repainting list streams the latest packets, newest first: time, class (icon + label, icon alone on a narrow terminal), -SNR/RSSI, and then whatever the row is *about* — a node that named itself followed by -its detail, or, for the relayed flood traffic that names nobody at all, its ``via`` -chain given the whole lane. Seeded from stored history so the screen opens full, then streamed +node, SNR/RSSI, and the trailing detail (a relayed frame's ``via`` chain, a message's +conversation). Seeded from stored history so the screen opens full, then streamed live off the event hub. ``↑``/``↓`` walk the rows, PgUp/PgDn/Home/End page and jump them, ``←``/``→`` scroll the highlighted row sideways when it runs past the right edge (a long relay path read to its end without opening anything), and Enter opens the @@ -26,7 +25,6 @@ import asyncio from collections import deque from typing import TYPE_CHECKING, Any, Optional, Sequence -from rich.cells import cell_len from rich.text import Text from ..core.channels import split_channel_sender @@ -58,25 +56,16 @@ _REFRESH_S = 1.0 #: history depth, not layout). _FEED_CAP = 100 -#: The widest a name may grow in the trailing subject lane before it ellipsizes. Not a -#: column — the lane is shared with the row's detail, so a name only takes the cells it -#: needs, and this is the ceiling that stops one long name from swallowing the route -#: behind it. -_FEED_NAME_MAX = 18 +#: The feed's fixed node-name lane width; longer names ellipsize so the columns hold. +_FEED_NAME_WIDTH = 18 #: The class lane's fixed width — wide enough for the longest class the app files a #: packet under (``direct message``), so every label reads whole and the lanes hold. _FEED_CLASS_WIDTH = 14 -#: Cells between the fixed lanes and the trailing subject, and between a name and the -#: detail behind it — the feed's one lane gutter. -_SUBJECT_GAP = 2 - -#: Terminal width below which the feed drops the textual class label and keeps only the -#: two-cell icon. The label is the row's most informative fixed field now that it names -#: the real class, so it is held onto down to the width where the subject lane would stop -#: being able to say anything (the fixed lanes plus a readable route). -_FEED_LABEL_MIN_WIDTH = 68 +#: Terminal width below which the feed drops the textual class label and keeps only +#: the two-cell icon, buying the name and reception lanes room (≤72-col care). +_FEED_LABEL_MIN_WIDTH = 80 #: 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 @@ -346,25 +335,17 @@ class LiveFeedScreen(Screen): def _feed_row( self, entry: PacketEntry, selected: bool, show_label: bool, width: int ) -> Text: - """Lay one feed row out: time, class, reception — then whatever it is *about*. + """Lay one feed row out in fixed lanes: time, class, node, reception, detail. - Four fixed lanes carry the facts every packet has. The class lane says what the - packet *is*, straight from :func:`~meshterm.ui.packet_viewer.class_marks` — so a - raw frame reads ``📻 channel text``, the class the viewer's card headlines, - rather than the ``📦 packet`` event family it merely arrived in. Its icon always - shows; the textual label beside it is dropped wholesale on a narrow terminal - (``show_label``), keeping the lanes aligned either way. - - Everything after the reception lanes is *one* lane, not two, because a packet - does not always have a node. Most of what a real mesh carries is relayed flood - traffic that names no origin at all, and a fixed node column spent those cells on - a dash for row after row while the route — the one thing such a frame does say — - was crushed against the right edge. So the trailing lane holds whatever the row - is about: a node that named itself (in the app-wide palette hue, our own node - white) followed by its detail, or, when nobody did, the ``via`` chain alone. It - still opens at a fixed column, so names stay scannable down the screen; what - changes is that a nameless row gives those cells to its route instead of to a - placeholder. + The class lane says what the packet *is*, straight from + :func:`~meshterm.ui.packet_viewer.class_marks` — so a raw frame reads + ``📻 channel text``, the class the viewer's card headlines, rather than the + ``📦 packet`` event family it merely arrived in. Its icon always shows; the + textual label beside it is dropped wholesale on a narrow terminal + (``show_label``), keeping the lanes aligned either way. The node lane is then + free to be about the node: its name in the app-wide palette hue (our own node + white, a bare hash muted), and a dash where nobody identified themselves. Never + wraps — the detail lane gets whatever width the fixed lanes leave (``width``). A row that runs past the right edge is read by scrolling it, not by growing it. An unhighlighted row simply elides its relay path's middle hops, keeping both @@ -388,6 +369,11 @@ class LiveFeedScreen(Screen): style=KIND_STYLES.get(entry.kind, "brand"), ) body.append(" ") # the lane's own gutter — the longest class fills it exactly + 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( f"{entry.snr:+5.1f} dB" if entry.snr is not None else " " * 8, style=snr_style(entry.snr) if entry.snr is not None else "muted", @@ -396,29 +382,12 @@ class LiveFeedScreen(Screen): f" {entry.rssi:5.0f} dBm" if entry.rssi is not None else " " * 10, style="muted", ) - - subject = Text(no_wrap=True, overflow="ellipsis") - named = self._feed_name(entry) - if named is not None: - # Capped, not padded: a long name ellipsizes rather than starving the route - # behind it, but a short one hands its spare cells straight to that route. - name, style = named - if cell_len(name) > _FEED_NAME_MAX: - name = fit_cells(name, _FEED_NAME_MAX) - subject.append(name, style=style) - used = body.cell_len + _SUBJECT_GAP + subject.cell_len note = self._feed_note( - entry, - None if selected - else max(1, avail - used - (_SUBJECT_GAP if subject.cell_len else 0)), + entry, None if selected else max(1, avail - body.cell_len - 2) ) if note is not None: - if subject.cell_len: - subject.append(" " * _SUBJECT_GAP) - subject.append_text(note) - if subject.cell_len: - body.append(" " * _SUBJECT_GAP) - body.append_text(subject) + body.append(" ") + body.append_text(note) if selected: self._hmax = max(0, body.cell_len - avail) @@ -428,32 +397,29 @@ class LiveFeedScreen(Screen): row.append_text(body) return row - def _feed_name(self, entry: PacketEntry) -> Optional[tuple[str, str]]: - """Who the row is about, as ``(label, style)`` — or ``None`` when nobody is. + def _feed_subject(self, entry: PacketEntry) -> tuple[str, str]: + """Name the node lane when an entry carries no resolvable node identity. - The node the packet identified itself as, if any; failing that, the most - identifying thing it still carries: the sender a channel message named itself - with, then an ack's code. Two things it deliberately does *not* do. It does not - stand in the packet's class — the class lane already says that, straight from + Rather than a useless ``?``, the lane reads the most identifying thing the entry + still carries: the sender a channel message named itself with, then an ack's + code. What it deliberately does *not* do is stand in the packet's class — the + class lane two columns left already says that, straight from :func:`~meshterm.ui.packet_viewer.class_marks`, and a row that spelled it twice - read ``packet`` beside ``channel text`` as though they were two facts. And it - does not reach for a placeholder: a relayed flood names no origin, so it returns - ``None`` and the trailing lane goes to its route, which does name nodes. + was reading ``packet`` beside ``channel text`` as though they were two facts. + A relayed flood names no origin, so its lane says so with a dash and lets the + eye travel on to the ``via`` chain, which does name nodes. """ - label, style = node_label(entry, self._resolve, self._self_name) - if label != "?": - return label, style if entry.kind == "message": sender = _channel_sender(entry.text) - if not sender: - return None # naming the conversation is the note's job, not a name's - ours = self._self_name and sender == self._self_name - # A resolvable sender takes its key-derived hue; a stranger stays muted — - # colour is reserved for keyed identities. - return sender, ("you" if ours else name_style(sender, self._key_of(sender))) + if sender: + ours = self._self_name and sender == self._self_name + # A resolvable sender takes its key-derived hue; a stranger stays + # muted — colour is reserved for keyed identities. + return sender, ("you" if ours else name_style(sender, self._key_of(sender))) + return "channel", "muted" if entry.where: return entry.where, "muted" # an ack's code, or any other stray context - return None + return "—", "muted" def _feed_note(self, entry: PacketEntry, budget: Optional[int]) -> Optional[Text]: """The row's trailing detail: a packet's relay path, a message's conversation. diff --git a/tests/test_livefeed.py b/tests/test_livefeed.py index 48c04bc..37d310b 100644 --- a/tests/test_livefeed.py +++ b/tests/test_livefeed.py @@ -92,8 +92,8 @@ def test_livefeed_class_lane_names_the_payload_class_once() -> None: ``packet`` names only the event family the frame arrived in, so the class lane reads its parsed payload class — the very label the viewer's card headlines. The - trailing lane then goes straight to the route, because an origin-less flood has no - node to name and a placeholder would only spend the cells the route needs. + node lane is then free to be about the node, and an origin-less flood says so with + a dash rather than standing the class in a second time. """ screen = _screen() raw = {"payload_typename": "TRACE", "route_typename": "FLOOD"} @@ -106,8 +106,8 @@ def test_livefeed_class_lane_names_the_payload_class_once() -> None: 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 - assert row.rstrip().endswith("via YUL → Alice") # the lane is all route - assert "—" not in row and "?" not in row # no placeholder for a node that isn't there + assert "—" in row # the node lane: nobody identified themselves + assert "?" not in row # …but never the useless placeholder def test_livefeed_names_a_channel_message_by_its_sender() -> None: @@ -121,33 +121,6 @@ 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_subject_lane_opens_at_one_column_for_every_row() -> None: - """Named or not, the trailing lane starts in the same place — names stay scannable. - - A named row spends its opening cells on the name; a nameless flood hands the whole - lane to its route. What must not move is where that lane begins, or a screenful of - rows stops reading as a column. - """ - screen = _screen(seed=[ - _obs(kind="advert", age_s=1), - _obs(node="", kind="packet", path="3d63,a1b2", age_s=0, - raw={"payload_typename": "GRP_TXT"}), - ]) - flood, named = _stripped(screen.render_body(90))[1:3] # newest (the flood) first - assert named.index("Alice") == flood.index("via") # one lane, one opening column - assert "channel text" in flood and "advert" in named - - -def test_livefeed_keeps_the_class_label_at_72_columns() -> None: - """The class label is the row's most informative field, so it survives a 72-col screen.""" - screen = _screen(seed=[ - _obs(node="", kind="packet", path="3d63", raw={"payload_typename": "GRP_TXT"}), - ]) - row = _stripped(screen.render_body(72))[1] - assert "📻 channel text" in row # the words, not just the icon - assert "YUL" in row # …and the route still has room to name a node - - 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)] @@ -195,7 +168,7 @@ def test_livefeed_highlighted_row_scrolls_sideways_to_its_tail() -> None: assert screen._hshift == screen._hmax assert scrolled.startswith("▸ ") # the pointer lane stays pinned while the row slides assert scrolled.rstrip().endswith("11aa") # the last hop is now readable - assert opening[2:10] not in scrolled # …at the cost of the time lane, slid off left + assert "Alice" not in scrolled # …at the cost of the lanes that slid off the left for _ in range(40): screen.handle("left")