diff --git a/docs/features.md b/docs/features.md index 067bbfa..2d2319b 100644 --- a/docs/features.md +++ b/docs/features.md @@ -24,7 +24,7 @@ Every interactive screen is listed here with its scripted equivalent, where one | Feature | What it does | Scripted | | --- | --- | --- | | **πŸ“Š Dashboard** | The live mesh overview: a two-hour all-packet activity chart with a pulse line, session traffic tallies by packet class, and window RF health beside the radio's own live numbers. Repaints every second. | β€” | -| **πŸ“° Live feed** | Every packet as it arrives, newest first: time, what the frame *is* (its parsed payload class), and what it is *about* β€” the node for an advert, the channel for a channel text, sender β†’ recipient for a direct message or a request, the tag for a trace β€” with reception quality beside it, and the region a scoped flood was sent into where the terminal is wide enough. Enter opens any row in the packet viewer, route graph and all. | β€” | +| **πŸ“° Live feed** | Every packet as it arrives, newest first: time, what the frame *is* (its parsed payload class), and what it is *about* β€” the node for an advert, the channel for a channel text, sender β†’ recipient for a direct message or a request, the tag for a trace β€” with reception quality beside it, and the region a scoped flood was sent into. Enter opens any row in the packet viewer, route graph and all. | β€” | | **🚨 Watchtower** | A passive sentinel over the nodes you star: silence alarms when a watched node goes quiet, SNR-sag warnings when reception degrades, recovery notes when it returns, and a heads-up when a never-before-seen node appears. Runs in the background all session; unacked alerts show as a header badge. | β€” | | **⏳ Time machine** | Everything the recorder ever heard, as braille charts: reception volume, the median-SNR band, hour-of-day rhythm, packets and nodes per day, and first-ever arrivals β€” over a switchable 24h / 7d / 30d / all-time window (on the PicoCalc the windows stop at 30d), per node or mesh-wide. | β€” | | **🎧 Monitor** | Passive recording is always on in the app. On the CLI, capture a bounded foreground window, tailing each overheard packet and summarising when it ends. It transmits nothing β€” it only listens. | `meshterm monitor --seconds 60` | diff --git a/meshterm/ui/livefeed_screen.py b/meshterm/ui/livefeed_screen.py index b82fb20..0564444 100644 --- a/meshterm/ui/livefeed_screen.py +++ b/meshterm/ui/livefeed_screen.py @@ -11,11 +11,11 @@ to the packet viewer, which has the room to draw it as a wrapped path line over graph. The feed's job is to say what arrived and how well it was heard; Enter says how it got here. -Where the width allows, a **scope** lane sits between the subject and the readings: the -region a flood was sent into (``harbour``), ``? 3fa1`` for a region nobody here has named, a -muted ``-`` for a plain flood, and nothing for a direct frame, which no repeater -region-filters (see :func:`_lanes_for` for which widths hold it, and why the PicoCalc's -never does). +A **scope** lane sits between the subject and the readings, at every width: the region a +flood was sent into (``harbour``), ``? 3fa1`` for a region nobody here has named, a muted +``-`` for a plain flood, and nothing for a direct frame, which no repeater region-filters. +It never collapses; the class label beside the icon is what gives way on a narrow terminal +(see :func:`_lanes_for`). The subject lane is contextual: it holds whatever the *class* of packet is about (see :meth:`LiveFeedScreen._feed_subject`). An advert or a telemetry frame is about the node @@ -145,11 +145,10 @@ _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 = ( +#: The full row's width without its scope lane: every lane the row always carries plus the +#: class label β€” see :data:`_FEED_LABEL_MIN_WIDTH` for the width the label needs once the +#: scope is added. +_FEED_ROW_WIDTH = ( 2 + _TIME_LANE + _ICON_LANE @@ -162,20 +161,19 @@ _FEED_LABEL_MIN_WIDTH = ( ) #: The scope lane's width. Room for an unnamed scope's ``? 3fa1`` and a short region name -#: whole; a longer name ellipsizes β€” its whole name is on the viewer's ``route`` row, one +#: whole; a longer name ellipsizes β€” its whole name is on the viewer's ``scope`` row, one #: keypress away, like everything else a lane cuts. _FEED_SCOPE_WIDTH = 10 #: The scope lane with the gap that sets it off from the readings after it. _SCOPE_LANE = _FEED_SCOPE_WIDTH + _LANE_GAP -#: The most cells the subject lane lends the scope lane when the row is that close to -#: holding it. On a 72-column terminal the framed body is 68 cells and the icon-only row 54, -#: so the scope lane fits there with room to spare today; the lend is what keeps it at the -#: width the app is designed to if either lane ever grows by a cell. Never more than that: -#: the subject is what the row is about, and a lane that shrank to make room for another -#: would stop naming it. -_SUBJECT_LEND = 1 +#: Terminal width below which the feed drops the textual class label and keeps only the +#: two-cell icon: the whole row, scope lane included. The scope never collapses (JP, +#: 2026-09-25) β€” a lane that came and went with the terminal's width hid the one reading +#: that says where a flood was allowed to go β€” so on a narrower screen it is the label that +#: gives way, the icon beside it still saying what the packet is. +_FEED_LABEL_MIN_WIDTH = _FEED_ROW_WIDTH + _SCOPE_LANE def _feed_scope(scope: Scope | None) -> Text: @@ -194,37 +192,24 @@ class _Lanes(NamedTuple): Attributes: label: The class lane's text label beside its icon. - scope: The scope lane between the subject and the readings. - subject: The subject lane's width (:data:`_FEED_SUBJECT_WIDTH`, less whatever it - lent the scope lane). + subject: The subject lane's width (:data:`_FEED_SUBJECT_WIDTH`). """ label: bool - scope: bool subject: int def _lanes_for(width: int) -> _Lanes: - """The lanes a row can carry at ``width``, most informative first. + """The lanes a row can carry at ``width``. - The class label comes first β€” it says what a packet *is*, on every row β€” and keeps - the threshold it always had (:data:`_FEED_LABEL_MIN_WIDTH`). The scope lane then takes - whatever the row has left, borrowing up to :data:`_SUBJECT_LEND` cells off the subject - to fit. That makes the scope appear at two widths with a gap between them: at 72 - columns (the label already dropped, the icon row leaving room), and again once a wide - terminal has room for both. In between the label wins, because a scope is a dash on - most rows and a class never is. - - The PicoCalc's 53 columns never carry it: the icon-only row is 54 cells before any - scope, so it already scrolls sideways there, and a lane only reachable by ``←→`` on the - highlighted row would be a lane nobody reads. The viewer's ``route`` row states it. + Every lane but one is always drawn, the scope lane included. The class label is the + one that gives way: it needs the whole row (:data:`_FEED_LABEL_MIN_WIDTH`), and below + that the icon alone says what the packet is. On a 72-column terminal (a 68-cell body) + that is the icon row with its scope, 66 cells; on the PicoCalc's 53 the icon row is + wider than the screen before any scope, so there the row reads by scrolling the + highlighted one sideways (``←→``), as it already did. """ - label = width >= _FEED_LABEL_MIN_WIDTH - row = _FEED_LABEL_MIN_WIDTH - (0 if label else _FEED_CLASS_WIDTH + _LANE_GAP) - short = row + _SCOPE_LANE - width - if short > _SUBJECT_LEND: - return _Lanes(label, False, _FEED_SUBJECT_WIDTH) - return _Lanes(label, True, _FEED_SUBJECT_WIDTH - max(0, short)) + return _Lanes(width >= _FEED_LABEL_MIN_WIDTH, _FEED_SUBJECT_WIDTH) #: Cells one ←/β†’ press shifts the highlighted row by β€” the app-wide select list's own @@ -617,8 +602,7 @@ class LiveFeedScreen(Screen): else " " * _ICON_LANE ) header.append(fit_cells("SUBJECT", lanes.subject + _LANE_GAP)) - if lanes.scope: - header.append(fit_cells("SCOPE", _SCOPE_LANE)) + header.append(fit_cells("SCOPE", _SCOPE_LANE)) # 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")) @@ -700,15 +684,14 @@ class LiveFeedScreen(Screen): subject.truncate(lanes.subject, overflow="ellipsis", pad=True) body.append_text(subject) body.append(" " * _LANE_GAP) - if lanes.scope: - # Beside the subject, ahead of the readings: where a flood was allowed to go is - # a fact about the packet, as its subject is, and the readings are about how it - # reached us. A plain flood β€” most rows β€” is a muted dash rather than the word, - # so the lane is quiet until a row is actually scoped. - scope = _feed_scope(self._entry_scope(entry)) - scope.truncate(_FEED_SCOPE_WIDTH, overflow="ellipsis", pad=True) - body.append_text(scope) - body.append(" " * _LANE_GAP) + # Beside the subject, ahead of the readings: where a flood was allowed to go is a + # fact about the packet, as its subject is, and the readings are about how it + # reached us. A plain flood β€” most rows β€” is a muted dash rather than the word, so + # the lane is quiet until a row is actually scoped. + scope = _feed_scope(self._entry_scope(entry)) + scope.truncate(_FEED_SCOPE_WIDTH, overflow="ellipsis", pad=True) + body.append_text(scope) + body.append(" " * _LANE_GAP) body.append( 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", diff --git a/tests/test_livefeed.py b/tests/test_livefeed.py index da6d7f8..1010409 100644 --- a/tests/test_livefeed.py +++ b/tests/test_livefeed.py @@ -123,16 +123,16 @@ def test_livefeed_rows_leave_the_relay_path_to_the_viewer() -> None: """A row says what arrived and how well it was heard; the route is Enter's job. Whatever the fixed lanes left a route was never enough to draw one in β€” it arrived - elided to a stub β€” so the feed carries none, and the whole row fits a 72-column - screen with its class label intact. + elided to a stub β€” so the feed carries none: the whole row, scope lane and all, fits a + 72-column screen, and a wide one keeps its class label in words too. """ screen = _screen() screen.on_event(MeshEvent.observation_event(_obs(kind="packet", path="3d63,a1b2", snr=1.0))) - row = _rows(screen, 72)[0] + row = _rows(screen, 68)[0] # a 72-column terminal's framed body assert "via" not in row and "Hub" 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 - assert len(row.rstrip()) <= 72 + assert "+1.0 dB" in row and "-90 dBm" in row # …the reception it was heard at reads + assert len(row.rstrip()) <= 68 + assert "πŸ“¦ packet" in _rows(screen, 90)[0] # the class label, where there is room def test_livefeed_class_lane_names_the_payload_class_once() -> None: @@ -263,8 +263,8 @@ def test_livefeed_tokened_classes_are_about_their_token() -> None: 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))[:2] - assert header.split() == ["TIME", "CLASS", "SUBJECT", "SNR", "RSSI"] + header, row = _stripped(screen.render_body(90))[:2] + assert header.split() == ["TIME", "CLASS", "SUBJECT", "SCOPE", "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. # The clock is read off the row itself: a literal needle would only match during the diff --git a/tests/test_scope_display.py b/tests/test_scope_display.py index b9b3889..dcf12e1 100644 --- a/tests/test_scope_display.py +++ b/tests/test_scope_display.py @@ -200,21 +200,18 @@ def test_the_feed_carries_a_scope_lane_on_a_72_column_terminal() -> None: assert all(row.rstrip().endswith("dBm") for row in rows) -def test_the_feed_keeps_the_class_label_ahead_of_the_scope() -> None: - """Between the two widths that hold it, the label wins: a class is never ``unscoped``.""" - screen = _feed(_scoped("harbour")) - header = _stripped(screen.render_body(72))[0] - assert "CLASS" in header and "SCOPE" not in header - header, row = _stripped(screen.render_body(100))[:2] - assert "CLASS" in header and "SCOPE" in header - assert _col(header, "SCOPE") == _col(row, "harbour") +def test_the_feed_never_collapses_the_scope_lane() -> None: + """The scope is drawn at every width; the class label is what gives way when narrow. - -def test_the_feed_leaves_the_scope_to_the_viewer_on_the_picocalc() -> None: - """At 53 the row already scrolls sideways; a lane only ←→ reaches is not drawn.""" + Beside the subject, the lane is on screen even at the PicoCalc's 53, where it is the + readings after it that run off the edge. + """ screen = _feed(_scoped("harbour")) - body = _plain(screen.render_body(53)) - assert "SCOPE" not in body and "harbour" not in body + for width in (53, 68, 72, 80, 100): + header, row = _stripped(screen.render_body(width))[:2] + assert _col(header, "SCOPE") == _col(row, "harbour"), width + assert "CLASS" not in _stripped(screen.render_body(80))[0] + assert "CLASS" in _stripped(screen.render_body(100))[0] def test_the_feed_hands_its_scope_reader_to_the_viewer() -> None: