diff --git a/meshterm/ui/livefeed_screen.py b/meshterm/ui/livefeed_screen.py index 332f208..d8fc47c 100644 --- a/meshterm/ui/livefeed_screen.py +++ b/meshterm/ui/livefeed_screen.py @@ -35,7 +35,11 @@ packet re-pins, and Home jumps straight back to the pin from anywhere. Enter opens the highlighted packet in the shared :class:`~meshterm.ui.packet_viewer.PacketViewer` — which then pages through the feed itself with the same ``↑``/``↓``, and, for an overheard channel-text packet naming a -channel we hold the key for, decrypts it. +channel we hold the key for, decrypts it. **Both stops go up with it**: the viewer holds +the same pin above the newest packet, so ``↑`` off the top follows the stream there too +(each arrival becoming the card being read), and it walks the feed's cursor in step, pin +included — closing the dialog lands on whatever the reader was doing inside it rather +than on a state they left behind at the door. The screen holds no subscriptions of its own — the opener (:func:`open_livefeed`) wires the hub subscription and the once-a-second repaint, and tears them down when @@ -438,6 +442,10 @@ class LiveFeedScreen(Screen): list(self._feed), self._selected, resolve=self._resolve, prefix_bytes=self._prefix_bytes, self_name=self._self_name, on_navigate=follow, + # The viewer carries this screen's own second stop, so a reader who walks up + # to the stream inside the dialog re-pins the feed under it and is still + # following when the dialog closes. + on_pin=lambda: self._select_stop(0), channels=self._channels, type_of=self._type_of, key_of=self._key_of, # The live feed itself (newest first), so the viewer keeps up with packets # that arrive while it is open instead of freezing at this snapshot. diff --git a/meshterm/ui/packet_viewer.py b/meshterm/ui/packet_viewer.py index 144711e..4a8d79b 100644 --- a/meshterm/ui/packet_viewer.py +++ b/meshterm/ui/packet_viewer.py @@ -26,6 +26,15 @@ the newer/older packet, Home/End jump to the newest/oldest, mirroring the keys t opening list itself walks rows with — so a burst can be read packet by packet without bouncing back out to the list. PgUp/PgDn scroll a tall packet inside the dialog, matching what they do on every other screen; Esc closes it. + +Over a *live* list — one that hands the viewer a ``source`` — the top is **two stops +rather than one**, exactly as the live feed's own top is. ``↑`` off the newest packet, +and Home from anywhere, lands on the **pin**: the view holds the top *position* instead +of a packet, so every arrival becomes the card being read, and the title says +``following`` where it would say ``n/total``. ``↓`` steps back off the pin onto whichever +packet is showing at that moment, which then rides down the list as newer ones arrive — +the reader who opened a packet to read it keeps it, and the reader who walked up to the +top gets the stream. Same two stops, same keys, as the feed underneath. """ from __future__ import annotations @@ -345,6 +354,11 @@ class PacketViewer(Screen): ends, PgUp/PgDn scroll a tall body, Esc closes. Opened over a single packet (a one-entry list) the paging keys simply do nothing. + Over a live list (``source``) the top gains the feed's second stop — the pin, where + the view follows the stream instead of a packet (see the module docstring and + :meth:`_pin`). Opened over a snapshot there is nothing to follow, and ``↑`` on the + newest packet clamps as it always did. + The dialog only ever grows (see :attr:`~meshterm.ui.tui.screen.Screen.grow_only`): paging from a short packet to a tall one enlarges the box, but paging back keeps it at that size — blank-padded @@ -364,13 +378,15 @@ class PacketViewer(Screen): so it keeps the right-hand slot behind Page ↑. The two banks gate on different things, because they move different things. The - pager needs a body taller than the box; the jumps need a second packet to jump to. - Opened over a single packet, all four chips go dim at once. + pager needs a body taller than the box; the jumps need somewhere to jump to — a + second packet, or, on a live list, the pin that ``Newest`` reaches even when the + list holds one packet (the feed's ``Top`` chip, one screen up). Opened over a lone + packet of a snapshot, all four chips go dim at once. """ from .tui.fkeys import FPair, default_lane lane = list(default_lane(nav=self.content_overflows)) - many = len(self._entries) > 1 + many = len(self._entries) > 1 or self._can_pin lane[3] = FPair("Page ↓", "pagedown", "Oldest", "end", enabled=self.content_overflows, opp_enabled=many) lane[4] = FPair("Page ↑", "pageup", "Newest", "home", @@ -386,6 +402,7 @@ class PacketViewer(Screen): prefix_bytes: int = 0, self_name: Optional[str] = None, on_navigate: Optional[Callable[[PacketEntry], None]] = None, + on_pin: Optional[Callable[[], None]] = None, channels: Sequence[tuple[str, bytes]] = (), source: Optional[Callable[[], Sequence[PacketEntry]]] = None, type_of: Optional[TypeOf] = None, @@ -401,6 +418,10 @@ class PacketViewer(Screen): self_name: Our own node's name, drawn white wherever it appears. on_navigate: Called with the newly shown entry whenever paging moves the view, so the opening list can walk its own highlight in step. + on_pin: Called when the view moves onto the pin, so a list that has the same + stop (the live feed) can follow the stream too and still be following it + when the dialog closes. Never called without a ``source``: the pin is + only a stop where the list is live. channels: The device's configured channels, as ``(name, secret)`` pairs — tried against an overheard ``packet`` entry's channel-text frame (see :func:`~meshterm.core.channels.decrypt_channel_text`), so a raw frame @@ -426,6 +447,7 @@ class PacketViewer(Screen): self._prefix_bytes = prefix_bytes self._self_name = self_name self._on_navigate = on_navigate + self._on_pin = on_pin self._channels = channels self._source = source self._type_of = type_of @@ -435,15 +457,49 @@ class PacketViewer(Screen): self._current: Optional[PacketEntry] = ( self._entries[self._index] if self._entries else None ) + #: Whether the view is on the *pin* — the stop above the newest packet, which + #: holds the top position rather than a packet. Implies ``_index == 0``: the pin + #: is a mode on the newest entry, so everything that reads the view (the card, + #: the scroll, the cache) needs no special case; only the title and the moves + #: differ. Opening never pins — Enter named *this* packet (see + #: :meth:`~meshterm.ui.livefeed_screen.LiveFeedScreen._open_packet`) — so the + #: reader walks up to the stream deliberately or not at all. + self._pinned = False self._update_footer() self._set_title() def _update_footer(self) -> None: - """Set the footer hint to match whether the list currently has more than one entry.""" - if len(self._entries) > 1: - self.footer_hint = "↑↓ newer/older · PgUp/PgDn scroll · Home/End ends · Esc close" + """Set the footer hint to the moves that are live right now. + + Three shapes, because the top of a live list is two stops. On the pin only ``↓`` + moves, and the atom says what stepping off it buys — the packet on screen, held + against the next arrival. One stop below, ``↑`` and ``↓`` do different things (one + follows the stream, the other walks older), so they take an atom each rather than + sharing ``newer/older``, which would name neither. Everywhere else the pair walks + the list and reads as one atom, and a lone packet with nothing to follow keeps the + bare ``Esc close`` it always had. + """ + if self._pinned: + atoms = ["↓ hold packet"] + elif self._index == 0 and self._can_pin: + atoms = ["↑ follow"] + (["↓ older"] if len(self._entries) > 1 else []) + elif len(self._entries) > 1: + atoms = ["↑↓ newer/older"] else: - self.footer_hint = "Esc close" + atoms = [] + if len(self._entries) > 1 or self._can_pin: + atoms += ["PgUp/PgDn scroll", "Home/End ends"] + atoms.append("Esc close") + self.footer_hint = " · ".join(atoms) + + @property + def _can_pin(self) -> bool: + """Whether this viewer has a stream to follow — which is what a ``source`` is. + + Over a snapshot there is no second stop above the newest packet: nothing will ever + arrive there, so the pin would be an empty mode and ``↑`` clamps instead. + """ + return self._source is not None def _sync(self) -> None: """Refresh the entries from the live source, re-finding the viewed packet by identity. @@ -454,6 +510,11 @@ class PacketViewer(Screen): so paging keys measure against the current list and newly arrived packets sit reachable above the one being read. A packet that has since aged out of the list drops the view to the nearest surviving index. + + The pin is where that parts company: it is attached to the *position*, so a + re-read lands it on whatever is newest now and the card under it changes. This + runs on every paint as well as before every move, so following costs nothing + more than the repaint the feed was already buying. """ if self._source is None: return @@ -461,7 +522,9 @@ class PacketViewer(Screen): if not entries: return self._entries = entries - if self._current is not None: + if self._pinned: + self._show(0) + elif self._current is not None: for i, candidate in enumerate(entries): if candidate is self._current: self._index = i @@ -477,26 +540,45 @@ class PacketViewer(Screen): def _set_title(self) -> None: # No emoji in a dialog title (the standards' rule); the class row carries the icon. + # The position atom says which of the two things the view is doing: a count while + # it holds a packet, and ``following`` while it holds the top of the stream — the + # word rather than a perpetual ``1/n``, because what changes under the pin is the + # card, not the number. entry = self._entries[self._index] title = entry.kind - if len(self._entries) > 1: + if self._pinned: + title += " · following" + elif len(self._entries) > 1: title += f" · {self._index + 1}/{len(self._entries)}" self.title = title # --- input --------------------------------------------------------------- def handle(self, action: str, data: str = "") -> None: - """Page through the list, scroll a tall entry, or dismiss.""" + """Page through the list, follow the stream, scroll a tall entry, or dismiss.""" if action in ("up", "down", "home", "ctrl_home", "end", "ctrl_end"): # Re-read the live list first so newly arrived packets are in range before we # step — otherwise the first ``↑`` off the newest could never reach them. self._sync() if action == "up": - self._jump(self._index - 1) + # Off the newest packet ``↑`` has one more place to go on a live list: the + # pin. On a snapshot the same press clamps, there being no stream to follow. + if self._index == 0: + self._pin() + else: + self._jump(self._index - 1) elif action == "down": - self._jump(self._index + 1) + # Off the pin ``↓`` does not move down a row — it lands on the same packet + # showing under it, now held. The feed's own ``↓`` off its pin, exactly. + self._jump(self._index if self._pinned else self._index + 1) elif action in ("home", "ctrl_home"): - self._jump(0) + # "Take me back to the top" resumes the stream where there is one, rather than + # parking on whichever packet happens to be newest this instant — the feed's + # Home again, and what the ``Newest`` chip reaches on the console. + if self._can_pin: + self._pin() + else: + self._jump(0) elif action in ("end", "ctrl_end"): self._jump(len(self._entries) - 1) elif action == "pageup": @@ -507,15 +589,53 @@ class PacketViewer(Screen): self.resolve(None) def _jump(self, index: int) -> None: - """Move to another entry (clamped), resetting the scroll for the new body.""" + """Hold the entry at ``index`` (clamped), leaving the pin behind. + + Landing on an entry is by definition holding a packet rather than following the + stream, so this is also the way *off* the pin — and the one move that does + something while the index stays put, which is exactly what ``↓`` off the pin is. + The scroll resets only where the packet itself changes (see :meth:`_show`), so + that press keeps the reader where they were in the card they were already reading. + """ index = max(0, min(index, len(self._entries) - 1)) - if index != self._index: - self._index = index - self._current = self._entries[index] + if index == self._index and not self._pinned: + return + self._pinned = False + self._show(index) + self._set_title() + self._update_footer() + if self._on_navigate is not None and self._current is not None: + self._on_navigate(self._current) + + def _pin(self) -> None: + """Follow the stream: hold the top of the list, whichever packet is newest. + + A no-op over a snapshot (nothing to follow), and on the pin already. Otherwise the + list is re-read so the pin lands on what is newest *now*, and the opener is told — + so a list carrying the same stop follows along, and is still following when the + dialog closes back onto it. + """ + if self._pinned or not self._can_pin: + return + self._pinned = True + self._sync() + self._set_title() + self._update_footer() + if self._on_pin is not None: + self._on_pin() + + def _show(self, index: int) -> None: + """Put the view on entry ``index``, starting a *different* packet from its top. + + The scroll belongs to the packet, not to the position: paging onto another card + starts it at its beginning, while re-landing on the card already showing (``↓`` + off the pin) leaves the reader exactly where they had scrolled to. + """ + self._index = index + entry = self._entries[index] + if entry is not self._current: + self._current = entry self.scroll = 0 - self._set_title() - if self._on_navigate is not None: - self._on_navigate(self._current) # --- rendering ------------------------------------------------------------- diff --git a/tests/test_livefeed.py b/tests/test_livefeed.py index 1a31b20..f76aa7b 100644 --- a/tests/test_livefeed.py +++ b/tests/test_livefeed.py @@ -19,10 +19,19 @@ from meshterm.ui.livefeed_screen import _ICON_LANE, LiveFeedScreen class _FakeSession: def __init__(self) -> None: self.repaints = 0 + #: The screen the feed last floated over itself (the packet viewer). + self.opened = None def invalidate(self) -> None: self.repaints += 1 + def run_screen(self, screen): + """Record the floated screen instead of running it — there is no loop here.""" + self.opened = screen + + def run_detached(self, coro) -> None: + """The session's fire-and-forget launch; ``run_screen`` already did the recording.""" + class _Fut: """A minimal future stand-in so the screen can resolve without an event loop.""" @@ -352,6 +361,28 @@ def test_livefeed_opening_a_packet_drops_the_pin() -> None: assert screen._selected == 1, "the cursor left the packet being viewed" +def test_livefeed_viewer_that_walks_up_to_the_stream_re_pins_the_feed() -> None: + """The viewer carries this screen's second stop, and hands the pin back through it. + + Enter still names *this* packet, so the dialog opens holding one. Walking up off the + top inside it is the same move as walking up off the top out here, so the feed + follows the stream again — and is still following when the dialog closes over it. + """ + screen = _screen(seed=[_obs(node="n0", age_s=2), _obs(node="n1", age_s=1)]) + screen.handle("enter") + viewer = screen._session.opened + assert viewer is not None and not screen._pinned + + viewer.handle("up") + assert viewer._pinned and "following" in viewer.title + assert screen._pinned, "the feed stayed parked while the viewer followed the stream" + + # One arrival, both stops: the feed's cursor holds the top and so does the card. + screen.on_event(MeshEvent.observation_event(_obs(node="a1b2"))) + assert screen._selected == 0 + assert "Alice" in _plain(viewer.render_body(72)) + + def test_livefeed_page_keys_move_the_feed_selection() -> None: """PgUp/PgDn walk the cursor a windowful at a time, clamped to the list's two ends.""" seed = [_obs(node=f"n{i}", age_s=i) for i in range(20)] diff --git a/tests/test_packet_viewer.py b/tests/test_packet_viewer.py index 2e82bf2..b3552c5 100644 --- a/tests/test_packet_viewer.py +++ b/tests/test_packet_viewer.py @@ -141,6 +141,69 @@ def test_packet_viewer_lane_names_the_ends_of_the_list_not_the_body() -> None: assert [pair.enabled for pair in pair_view.fkey_lane[3:]] == [True, True] +def test_packet_viewer_follows_the_stream_off_the_top_of_a_live_list() -> None: + """``↑`` off the newest packet is the feed's second stop, not a clamp. + + On the pin the view holds the top *position*: each arrival becomes the card being + read, the title says so, and ``↓`` steps back off onto whichever packet is showing + at that moment. + """ + old = PacketEntry(when=utcnow(), kind="advert", node="aa") + feed = [old] + pinned_by_viewer = [] + viewer = PacketViewer( + list(feed), 0, resolve=lambda h: "", source=lambda: list(feed), + on_pin=lambda: pinned_by_viewer.append(True), + ) + assert not viewer._pinned, "opening a packet named that packet, not the stream" + assert "↑ follow" in viewer.footer_hint + + viewer.handle("up") + assert viewer._pinned and pinned_by_viewer == [True] + assert viewer.title.endswith("· following") # the word, not a perpetual 1/n + assert "↓ hold packet" in viewer.footer_hint + + newer = PacketEntry(when=utcnow(), kind="message", node="bb", text="new!") + feed.insert(0, newer) + assert "new!" in _plain(viewer.render_body(80)) # the arrival is now the card + assert viewer.title.endswith("· following") + + viewer.handle("down") + assert not viewer._pinned and viewer._entries[viewer._index] is newer + assert "1/2" in viewer.title # holding that packet, which now rides down the list + + +def test_packet_viewer_over_a_snapshot_has_nothing_to_follow() -> None: + """No live source, no pin: ``↑`` on the newest packet clamps as it always did.""" + newer = PacketEntry(when=utcnow(), kind="message", node="bb", text="new!") + old = PacketEntry(when=utcnow(), kind="advert", node="aa") + viewer = PacketViewer([newer, old], 0, resolve=lambda h: "") + + viewer.handle("up") + assert not viewer._pinned and "following" not in viewer.title + assert viewer.footer_hint.startswith("↑↓ newer/older") + viewer.handle("home") + assert not viewer._pinned and viewer._index == 0 + + +def test_packet_viewer_home_resumes_the_stream() -> None: + """Home reaches the pin on a live list — the feed's own Home, and its ``Newest`` chip. + + Which is also why the jump chips light over a live list holding a single packet: + there is somewhere to go even where there is no second packet to go to. + """ + feed = [PacketEntry(when=utcnow(), kind="advert", node=f"n{i}") for i in range(3)] + viewer = PacketViewer(list(feed), 0, resolve=lambda h: "", source=lambda: list(feed)) + viewer.handle("end") + assert viewer._index == 2 and not viewer._pinned # the oldest packet + viewer.handle("home") + assert viewer._pinned + + lone = PacketViewer(feed[:1], 0, resolve=lambda h: "", source=lambda: feed[:1]) + lone.note_metrics(4, 20) # a short body in a roomy box: nothing to page + assert [pair.opp_enabled for pair in lone.fkey_lane[3:]] == [True, True] + + def test_packet_viewer_without_a_source_stays_a_snapshot() -> None: """With no live source the viewer is frozen on its opening list (unchanged behaviour).""" entry = PacketEntry(when=utcnow(), kind="advert", node="aa")