diff --git a/meshterm/ui/livefeed_screen.py b/meshterm/ui/livefeed_screen.py index e5bf430..e87d1cb 100644 --- a/meshterm/ui/livefeed_screen.py +++ b/meshterm/ui/livefeed_screen.py @@ -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, diff --git a/meshterm/ui/packet_viewer.py b/meshterm/ui/packet_viewer.py index 123cbc5..dff916d 100644 --- a/meshterm/ui/packet_viewer.py +++ b/meshterm/ui/packet_viewer.py @@ -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() diff --git a/tests/test_livefeed.py b/tests/test_livefeed.py index 16c6ca2..3ddd16f 100644 --- a/tests/test_livefeed.py +++ b/tests/test_livefeed.py @@ -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 diff --git a/tests/test_packet_viewer.py b/tests/test_packet_viewer.py index 0bfe0e9..b28eca4 100644 --- a/tests/test_packet_viewer.py +++ b/tests/test_packet_viewer.py @@ -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!")