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:
1 parent
24e4da9176
commit
5ca5ea7d56
4 files changed
+243
-21
No files matched your search
@@ -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
@@ -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 -------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user