Enter on the live feed's pin opens the packet viewer on the pin

The viewer now opens on the stop the feed has. On the pin (^), the card
follows the stream and the feed stays pinned; on a selected packet, the
viewer holds that packet as before. Enter no longer drops the pin.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Haiku 5.5 committed 2026-10-10 01:36:53 -04:00
1 parent c8f7e7ec48
commit db62ddf701
4 files changed
+87 -27

No files matched your search

+14 -8
View File
@@ -57,9 +57,11 @@ itself with the same ``↑``/``↓``. If an overheard channel-text packet names
which we have the key, the viewer decrypts it. **The viewer has the two stops too.** It
holds the same pin above the newest packet, so ``↑`` from the top follows the stream
there too (each packet that arrives becomes the card that the user reads). The viewer
also moves the cursor of the feed with it, the pin included. Thus, when the dialog
closes, the feed is on the stop that the user had in the dialog, not on the stop that
the user left when the dialog opened.
opens on the stop that the feed has. Enter on the pin opens the viewer on the pin, so
the card follows the stream immediately, and Enter on a selected packet opens that
packet and holds it. The viewer also moves the cursor of the feed with it, the pin
included. Thus, when the dialog closes, the feed is on the stop that the user had in the
dialog, not on the stop that the user left when the dialog opened.
The screen has no subscriptions of its own. The opener (:func:`open_livefeed`) connects
the hub subscription and the paint that occurs one time each second, and removes them
@@ -556,14 +558,17 @@ class LiveFeedScreen(Screen):
When the user pages in the viewer, the feed highlight moves with it (through
``on_navigate``). Thus, when the viewer closes, the feed is on the packet that the
user saw last. For the same reason, this method removes the pin when it opens the
viewer. Enter names this packet. If the pin stays, the packets that arrive during a
long read move the cursor off this packet. Then, when the viewer closes, the feed
is on a row that the user did not select.
user saw last.
The viewer opens on the stop that the feed has, and Enter does not change it. On
the pin, the viewer opens on the pin too, and the card follows the stream. On a
selected packet, the viewer holds that packet. JP decided this on 2026-10-10.
Before, Enter removed the pin, because Enter named the packet under it. But a user
who watches the feed and opens the card continues to watch, and ``↓`` in the viewer
holds the visible packet with one key press.
"""
if self._selected is None or not self._feed:
return
self._select_row(self._selected)
def follow(entry: PacketEntry) -> None:
# The feed may have more rows after the snapshot. Thus find the entry itself.
@@ -575,6 +580,7 @@ class LiveFeedScreen(Screen):
viewer = PacketViewer(
list(self._feed),
self._selected,
pinned=self._pinned,
resolve=self._resolve,
prefix_bytes=self._prefix_bytes,
self_name=self._self_name,
+17 -11
View File
@@ -42,9 +42,9 @@ packet, and Home from anywhere, go to the **pin**. On the pin, the viewer holds
position instead of a packet. Thus each new packet becomes the card that the user reads,
and the title says ``following`` instead of ``n/total``. ``↓`` steps off the pin onto
the packet that shows at that moment. That packet then moves down the list as newer
packets arrive. Thus a user who opened a packet to read it keeps it, and a user who
walked up to the top gets the stream. These are the same two stops, with the same keys,
as the feed under the dialog.
packets arrive. The viewer opens on the stop that the list had (``pinned``). Thus a user
who opened a packet to read it keeps it, and a user who watched the stream continues to
watch it. These are the same two stops, with the same keys, as the feed under the dialog.
"""
from __future__ import annotations
@@ -465,6 +465,7 @@ class PacketViewer(Screen):
self_name: str | None = None,
on_navigate: Callable[[PacketEntry], None] | None = None,
on_pin: Callable[[], None] | None = None,
pinned: bool = False,
channels: Sequence[tuple[str, bytes]] = (),
source: Callable[[], Sequence[PacketEntry]] | None = None,
type_of: TypeOf | None = None,
@@ -486,6 +487,10 @@ class PacketViewer(Screen):
same stop (the live feed) can also follow the stream, and still follows it
when the dialog closes. It is never called without a ``source``, because
the pin is a stop only where the list is live.
pinned: Whether to open on the pin instead of on the packet at ``index``.
The live feed gives its own stop here, so the viewer follows the stream
when the feed did. The open does not call ``on_pin``, because the list is
already on the pin. Without a ``source``, the viewer ignores this value.
channels: The configured channels of the device, as ``(name, secret)``
pairs. The viewer tries them on the channel-text packet of an overheard
``packet`` entry (refer to
@@ -532,18 +537,19 @@ class PacketViewer(Screen):
self._key_of = key_of
self._scope_of: ScopeOf = scope_of or unnamed_scope
self._message_scope = message_scope
#: The packet that shows now, followed by identity. Thus a live prepend to the
#: source (which moves each index) never moves the viewer onto another packet.
self._current: PacketEntry | None = self._entries[self._index] if self._entries else None
#: Whether the viewer is on the pin: the stop above the newest packet, which
#: holds the top position instead of a packet. It means that ``_index == 0``. The
#: pin is a mode on the newest entry, so all the code that reads the viewer (the
#: card, the scroll, the cache) needs no special case. Only the title and the
#: moves are different. An open never goes to the pin, because Enter selected
#: this packet (refer to
#: :meth:`~meshterm.ui.livefeed_screen.LiveFeedScreen._open_packet`). Thus the
#: user walks up to the stream on purpose, or not at all.
self._pinned = False
#: moves are different. An open goes to the pin only when the list was on its own
#: pin (refer to
#: :meth:`~meshterm.ui.livefeed_screen.LiveFeedScreen._open_packet`).
self._pinned = pinned and self._can_pin and bool(self._entries)
if self._pinned:
self._index = 0
#: The packet that shows now, followed by identity. Thus a live prepend to the
#: source (which moves each index) never moves the viewer onto another packet.
self._current: PacketEntry | None = self._entries[self._index] if self._entries else None
self._update_footer()
self._set_title()
+26 -7
View File
@@ -366,12 +366,30 @@ def test_livefeed_home_resumes_following_from_deep_in_the_history() -> None:
assert screen._selected == 0 # the feed follows again, and does not stay on the old newest
def test_livefeed_opening_a_packet_drops_the_pin() -> None:
"""Enter names this packet, so an arrival during a long read cannot move the highlight."""
def test_livefeed_opening_a_packet_keeps_the_pin() -> None:
"""Enter on the pin opens the viewer on the pin, so the feed and the card both follow.
JP decided this on 2026-10-10. Before, Enter removed the pin.
"""
screen = _screen(seed=[_obs(node="n0", age_s=1)])
screen.future = _Fut()
assert screen._pinned
screen._select_row(0) # this is what _open_packet does before it floats the viewer
screen.handle("enter")
viewer = screen._session.opened
assert viewer is not None and viewer._pinned and "following" in viewer.title
assert screen._pinned, "Enter knocked the feed out of the stream"
screen.on_event(MeshEvent.observation_event(_obs(node="a1b2")))
assert screen._selected == 0, "the pin let go of the top"
assert "Alice" in _plain(viewer.render_body(72)), "the card did not follow the stream"
def test_livefeed_opening_a_selected_packet_holds_it() -> None:
"""Enter on a selected packet holds it, so an arrival during a long read cannot move it."""
screen = _screen(seed=[_obs(node="n0", age_s=1)])
screen.handle("down") # off the pin, onto the newest packet
screen.handle("enter")
viewer = screen._session.opened
assert viewer is not None and not viewer._pinned
assert not screen._pinned and screen._selected == 0
screen.on_event(MeshEvent.observation_event(_obs(node="a1b2")))
assert screen._selected == 1, "the cursor left the packet being viewed"
@@ -380,11 +398,12 @@ def test_livefeed_opening_a_packet_drops_the_pin() -> None:
def test_livefeed_viewer_that_walks_up_to_the_stream_re_pins_the_feed() -> None:
"""The viewer has the second stop of this screen, and it returns the pin through it.
Enter still names this packet, so the dialog opens with one packet. The user can press
up at the top of the viewer. This is the same move as up at the top of the feed. Thus
the feed follows the stream again, and it still follows when the dialog closes.
Enter on a selected packet opens the dialog on that packet. The user can press up at
the top of the viewer. This is the same move as up at the top of the feed. Thus the
feed follows the stream again, and it still follows when the dialog closes.
"""
screen = _screen(seed=[_obs(node="n0", age_s=2), _obs(node="n1", age_s=1)])
screen.handle("down") # off the pin, onto the newest packet
screen.handle("enter")
viewer = screen._session.opened
assert viewer is not None and not screen._pinned
+30 -1
View File
@@ -158,7 +158,7 @@ def test_packet_viewer_follows_the_stream_off_the_top_of_a_live_list() -> None:
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 not viewer._pinned, "an open without pinned holds the packet, not the stream"
assert "↑ follow" in viewer.footer_hint
viewer.handle("up")
@@ -176,6 +176,35 @@ def test_packet_viewer_follows_the_stream_off_the_top_of_a_live_list() -> None:
assert "1/2" in viewer.title # the viewer holds that packet, which now moves down the list
def test_packet_viewer_opens_on_the_pin_of_a_live_list() -> None:
"""``pinned`` opens on the pin: the card follows the stream from the first paint.
The open does not call ``on_pin``, because the list is already on its own pin. Over a
snapshot, ``pinned`` does nothing, because there is no stream to follow.
"""
old = PacketEntry(when=utcnow(), kind="advert", node="aa")
feed = [old]
pinned_by_viewer = []
viewer = PacketViewer(
list(feed),
0,
pinned=True,
resolve=lambda h: "",
source=lambda: list(feed),
on_pin=lambda: pinned_by_viewer.append(True),
)
assert viewer._pinned and viewer.title.endswith("· following")
assert "↓ hold packet" in viewer.footer_hint
assert pinned_by_viewer == []
newer = PacketEntry(when=utcnow(), kind="message", node="bb", text="new!")
feed.insert(0, newer)
assert "new!" in _plain(viewer.render_body(80))
snapshot = PacketViewer([newer, old], 0, pinned=True, resolve=lambda h: "")
assert not snapshot._pinned and "following" not in snapshot.title
def test_packet_viewer_over_a_snapshot_has_nothing_to_follow() -> None:
"""With no live source there is no pin. ``↑`` at the newest packet clamps, as before."""
newer = PacketEntry(when=utcnow(), kind="message", node="bb", text="new!")