The viewer's top is two stops as well

Enter on the live feed names *this* packet, so the dialog rightly opens holding
one — but there was then no way back to the stream from inside it. The reader
who walked up to the newest packet hit a clamp, and following the feed again
meant Esc, ↑ on the list, and re-opening whatever had landed since.

The pin is now the viewer's stop too, on the same key it is on the feed: ↑ off
the newest packet, and Home from anywhere, holds the top *position* rather than
a packet, so every arrival becomes the card being read. The title says
`following` where it would say `n/total` — what changes under the pin is the
card, not the number — and ↓ steps back off onto whichever packet is showing at
that moment, keeping the scroll the reader had in it, because that card is the
one they were already reading.

Both ends move together: the viewer tells the feed when it pins (`on_pin`), the
way it already tells it when it lands on a packet, so a dialog closed while
following leaves the list following, and one closed on a packet still lands on
that packet. Only a live list has the stop at all — over a snapshot there is
nothing to follow, so ↑ on the newest packet clamps as it always did.

The footer follows the moves rather than the count: `↓ hold packet` on the pin,
`↑ follow · ↓ older` one stop below it (two keys doing different things, so an
atom each), and the plain pair everywhere else. On the console the `Newest`
chip reaches the pin, exactly as the feed's `Top` chip does, which is also why
the jump chips light over a live list holding a single packet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-09-05 12:36:46 -04:00
1 parent 24e4da9176
commit 5ca5ea7d56
4 files changed
+243 -21

No files matched your search

+9 -1
View File
@@ -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.
+140 -20
View File
@@ -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 -------------------------------------------------------------
+31
View File
@@ -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)]
+63
View File
@@ -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")