Live feed: the top of the list is two stops, watching and reading
The newest row now carries a pin — the stop above row 0, which holds the topmost position rather than a packet. Every arrival takes the highlight with it, so the cursor stays on the traffic instead of being pushed down into history by it, and it draws ^ rather than ❯: the mark points past the top of the list, at the packets still to come, because that is what it is attached to. One ↓ off the pin does not move a row. It lands on that same newest packet, now normally selected under a plain ❯, and from there the highlight belongs to that packet and rides down as newer ones arrive. So the pin follows the stream and a selection follows a packet, and the cursor lane says which you are doing. ↑ from the newest packet re-pins, Home jumps back to the pin from anywhere, and the F-lane's Top chip is how the PicoCalc reaches it with no hint line to name it. The pin is a real cursor stop, not a flag beside one, so every move stays a clamp on the single index _cursor already was — which is what makes ↓ off it land on the newest packet rather than the second. Opening a packet drops the pin: Enter names *this* packet, and arrivals during a long read would otherwise walk the cursor off it and land the closing viewer somewhere the reader never chose. ^ is plain ASCII, so it needs no console-font glyph. It is deliberately not ↑, which this screen already spends on the window's "↑ n more" and would read here as "there is more above" — the one thing the top of a feed can never mean; nor ▲, which is the repeater in the node-type set. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
3c714e4c84
commit
ec4836f63d
2 files changed
+190
-28
No files matched your search
+115
-24
@@ -18,8 +18,21 @@ tag. Only a class that genuinely says nothing about itself leaves the lane empty
|
||||
|
||||
Seeded from stored history so the screen opens full, then streamed
|
||||
live off the event hub. ``↑``/``↓`` walk the rows, PgUp/PgDn/Home/End page and jump
|
||||
them, ``←``/``→`` scroll the highlighted row sideways on a terminal too narrow to hold
|
||||
it whole, and Enter opens the highlighted packet in the shared
|
||||
them, and ``←``/``→`` scroll the highlighted row sideways on a terminal too narrow to
|
||||
hold it whole.
|
||||
|
||||
The top of the list is **two stops, not one** — the difference between watching the feed
|
||||
and reading it. The first is the *pin*: it holds the very topmost position, so every
|
||||
packet that arrives takes the highlight with it, and the cursor draws as ``^`` (pointing
|
||||
past the list's top, at the traffic still to come) rather than the app's ``❯``. One
|
||||
``↓`` off it does not move down a row — it lands on that same newest packet, now
|
||||
*normally* selected under a plain ``❯``, and from there the highlight belongs to that
|
||||
packet and rides down as newer ones push it along. So the pin follows the stream and a
|
||||
selection follows a packet, and the mark in the cursor lane says which you are doing.
|
||||
The screen opens pinned (a live feed's resting state is *watching*), ``↑`` from the newest
|
||||
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.
|
||||
@@ -128,6 +141,20 @@ _HSHIFT_RESET = frozenset({
|
||||
"up", "down", "pageup", "pagedown", "space", "home", "ctrl_home", "end", "ctrl_end",
|
||||
})
|
||||
|
||||
#: The cursor over a normally selected row — the app-wide select-list pointer, pointing
|
||||
#: *into* the list at the one row it marks.
|
||||
_CURSOR = "❯ "
|
||||
|
||||
#: The cursor over the pinned newest row (see the module docstring). The same lane, turned
|
||||
#: to point past the top of the list — at the traffic still to arrive — because that is
|
||||
#: what the pin is attached to: not this packet, but whichever one is newest. It is
|
||||
#: deliberately not ``↑``, which this screen already spends on the window's ``↑ n more``
|
||||
#: marker and would read here as "there is more above" — the one thing the top of the feed
|
||||
#: can never mean; nor ``▲``, which is the repeater in the app's node-type set. A caret is
|
||||
#: unclaimed, reads as "topmost" on both platforms, and is plain ASCII, so it needs no
|
||||
#: entry in the console font's glyph map (see :mod:`meshterm.ui.fontset`).
|
||||
_PIN_CURSOR = "^ "
|
||||
|
||||
|
||||
def _channel_sender(text: Optional[str]) -> Optional[str]:
|
||||
"""The sender named by a channel message's ``Name: `` prefix, or ``None`` if absent.
|
||||
@@ -152,8 +179,10 @@ class LiveFeedScreen(Screen):
|
||||
Every nav key here moves the *cursor* over the feed rather than a scroll offset,
|
||||
but the vocabulary is the same either way — the window follows the highlight — so
|
||||
the shared labels stand. ``Top`` and ``Bottom`` stay literal on purpose: the top of
|
||||
this list is the newest packet, and its bottom is the ``Back`` row one past the
|
||||
oldest, which is where End actually lands.
|
||||
this list is the pin riding the newest packet, and its bottom is the ``Back`` row
|
||||
one past the oldest, which is where End actually lands. ``Top`` is how the pin is
|
||||
reached where there is no hint line to name it — a feed read down into history
|
||||
resumes following in one chip.
|
||||
|
||||
A feed with nothing in it still has that ``Back`` row, and nowhere to move to, so
|
||||
all four chips go dim until the first packet arrives.
|
||||
@@ -220,6 +249,13 @@ class LiveFeedScreen(Screen):
|
||||
#: row past the oldest packet — the screen's other cursor stop, and the only one
|
||||
#: an empty feed offers. Starts on the newest packet.
|
||||
self._selected: Optional[int] = 0 if self._feed else None
|
||||
#: Whether the cursor is on the *pin* — the stop above row 0, which holds the
|
||||
#: topmost position rather than a packet (see the module docstring). Implies
|
||||
#: ``_selected == 0``: the pin is a mode on the newest row, so everything that
|
||||
#: reads the highlight (the window fit, the viewer, the ``←→`` scroll) needs no
|
||||
#: special case; only the cursor mark drawn in the pointer lane differs. A feed
|
||||
#: with nothing in it has no row to pin to, so it opens on ``Back`` instead.
|
||||
self._pinned: bool = bool(self._feed)
|
||||
#: The feed's window within the fixed screen (its rows scroll under the heading).
|
||||
self._feed_window = ListWindow()
|
||||
#: The highlighted row's horizontal scroll (cells shifted in, ``←/→``) and how
|
||||
@@ -231,7 +267,16 @@ class LiveFeedScreen(Screen):
|
||||
# --- live feed -----------------------------------------------------------------
|
||||
|
||||
def on_event(self, event: MeshEvent) -> None:
|
||||
"""Fold one hub event into the feed and repaint."""
|
||||
"""Fold one hub event into the feed and repaint.
|
||||
|
||||
The arrival is where the screen's two top stops part company. A *selection* is
|
||||
attached to a packet, so it rides down with it as the newer row pushes in above.
|
||||
The *pin* is attached to the position, so it stays at row 0 and the packet under
|
||||
it changes — which is the whole point of it, and also why the highlighted row's
|
||||
horizontal scroll is dropped here: the cursor has landed on a different packet
|
||||
just as surely as if the reader had pressed ``↓``, and every other move onto a new
|
||||
row starts that row at its own beginning (see :data:`_HSHIFT_RESET`).
|
||||
"""
|
||||
entry: Optional[PacketEntry] = None
|
||||
obs = event.observation
|
||||
if obs is not None:
|
||||
@@ -252,8 +297,11 @@ class LiveFeedScreen(Screen):
|
||||
if entry is None:
|
||||
return # nothing new on screen — don't buy a full repaint for it
|
||||
self._feed.appendleft(entry)
|
||||
# Keep the highlight on the same packet as new rows push it down.
|
||||
if self._selected is not None:
|
||||
if self._pinned:
|
||||
self._selected = 0 # the pin holds the top: the newest packet is now this one
|
||||
self._hshift = 0
|
||||
elif self._selected is not None:
|
||||
# Keep the highlight on the same packet as new rows push it down.
|
||||
self._selected = min(self._selected + 1, len(self._feed) - 1)
|
||||
self._session.invalidate()
|
||||
|
||||
@@ -289,11 +337,13 @@ class LiveFeedScreen(Screen):
|
||||
elif action in ("pagedown", "space"):
|
||||
self._select_stop(self._cursor + self._feed_window.page)
|
||||
elif action in ("home", "ctrl_home"):
|
||||
# Home jumps to the newest packet; End to the list's last row, which — as on
|
||||
# every list in the app — is the ``Back`` row (the oldest packet is one ↑ up).
|
||||
# Home jumps to the pin — the top of a live feed is the *following* state, so
|
||||
# "take me back to the top" resumes the stream rather than parking on whichever
|
||||
# packet happens to be newest this instant. End goes to the list's last row,
|
||||
# which — as on every list in the app — is ``Back`` (the oldest packet is one ↑).
|
||||
self._select_stop(0)
|
||||
elif action in ("end", "ctrl_end"):
|
||||
self._select_stop(len(self._feed))
|
||||
self._select_stop(len(self._feed) + 1)
|
||||
elif action == "left":
|
||||
self._scroll_line(-_HSCROLL_STEP)
|
||||
elif action == "right":
|
||||
@@ -310,24 +360,54 @@ class LiveFeedScreen(Screen):
|
||||
|
||||
@property
|
||||
def _cursor(self) -> int:
|
||||
"""The cursor as one index over the screen's stops: a feed row, else the ``Back`` row.
|
||||
"""The cursor as one index over the screen's stops, from the pin down to ``Back``.
|
||||
|
||||
The feed's rows number ``0 .. len(feed) - 1`` and the exit row sits one past them,
|
||||
so every move is a clamp on this one number — and an empty feed leaves the ``Back``
|
||||
row as the only stop there is.
|
||||
The stops are the pin (0), then the feed's rows (``1 .. len(feed)``), then the exit
|
||||
row one past them — so every move stays a clamp on this one number, the pin
|
||||
included. That the pin and row 0 draw on the *same line* is a rendering detail;
|
||||
as cursor positions they are two, which is exactly what makes ``↓`` off the pin
|
||||
land on the newest packet rather than the second one.
|
||||
|
||||
An empty feed has no row to pin to, so it leaves ``Back`` as the only stop there is.
|
||||
"""
|
||||
return len(self._feed) if self._selected is None else self._selected
|
||||
if self._pinned:
|
||||
return 0
|
||||
return len(self._feed) + 1 if self._selected is None else self._selected + 1
|
||||
|
||||
def _move_selection(self, delta: int) -> None:
|
||||
"""Step the cursor by ``delta`` rows (off the oldest packet lands it on ``Back``)."""
|
||||
"""Step the cursor by ``delta`` stops (off the oldest packet lands it on ``Back``)."""
|
||||
self._select_stop(self._cursor + delta)
|
||||
|
||||
def _select_stop(self, index: int) -> None:
|
||||
"""Move the cursor to one stop — a feed row, or the ``Back`` row past them — and repaint."""
|
||||
index = max(0, min(index, len(self._feed)))
|
||||
self._selected = index if index < len(self._feed) else None
|
||||
"""Move the cursor to one stop — the pin, a feed row, or ``Back`` — and repaint.
|
||||
|
||||
Args:
|
||||
index: The stop to land on, clamped into range (see :attr:`_cursor`). Both ends
|
||||
clamp rather than wrap, so a key held down settles at an end.
|
||||
"""
|
||||
if not self._feed: # nothing to pin or select: Back is the whole cursor space
|
||||
self._pinned = False
|
||||
self._selected = None
|
||||
self._session.invalidate()
|
||||
return
|
||||
index = max(0, min(index, len(self._feed) + 1))
|
||||
self._pinned = index == 0
|
||||
if index <= len(self._feed):
|
||||
self._selected = max(0, index - 1)
|
||||
else:
|
||||
self._selected = None
|
||||
self._session.invalidate()
|
||||
|
||||
def _select_row(self, row: int) -> None:
|
||||
"""Put the cursor on feed row ``row``, normally selected — never on the pin.
|
||||
|
||||
The way *something else* moves the highlight (the packet viewer paging through the
|
||||
feed under it). Landing on a specific packet is by definition a selection and not a
|
||||
pin, even when that packet happens to be the newest one: the reader is following
|
||||
that packet, so the next arrival must push it down rather than steal the cursor.
|
||||
"""
|
||||
self._select_stop(row + 1)
|
||||
|
||||
def _commit(self) -> None:
|
||||
"""Enter: open the highlighted packet, or leave when the cursor is on ``Back``."""
|
||||
if self._selected is None:
|
||||
@@ -338,17 +418,21 @@ class LiveFeedScreen(Screen):
|
||||
def _open_packet(self) -> None:
|
||||
"""Float the packet viewer over the highlighted feed row.
|
||||
|
||||
Paging inside the viewer walks the feed highlight in step (via
|
||||
``on_navigate``), so closing it lands back on the packet last viewed.
|
||||
Paging inside the viewer walks the feed highlight in step (via ``on_navigate``), so
|
||||
closing it lands back on the packet last viewed — and opening one drops the pin for
|
||||
the same reason. Enter names *this* packet; if the pin outlived it, the arrivals
|
||||
during a long read would walk the cursor off it, and closing the viewer would land
|
||||
somewhere the reader never chose.
|
||||
"""
|
||||
if self._selected is None or not self._feed:
|
||||
return
|
||||
self._select_row(self._selected)
|
||||
|
||||
def follow(entry: PacketEntry) -> None:
|
||||
# The feed may have grown since the snapshot; find the entry itself.
|
||||
for i, candidate in enumerate(self._feed):
|
||||
if candidate is entry:
|
||||
self._select_stop(i)
|
||||
self._select_row(i)
|
||||
return
|
||||
|
||||
viewer = PacketViewer(
|
||||
@@ -377,6 +461,7 @@ class LiveFeedScreen(Screen):
|
||||
self._selected = min(self._selected, len(self._feed) - 1) if self._feed else None
|
||||
if self._selected is None:
|
||||
self._hmax = 0 # nothing highlighted scrolls, so nothing advertises ←→
|
||||
self._pinned = False # a feed with no rows has nothing to pin to
|
||||
show_label = width >= _FEED_LABEL_MIN_WIDTH
|
||||
lines = [render_to_ansi(self._heading(), width, no_wrap=True)]
|
||||
if self._feed: # a header over nothing is noise; the empty note speaks for itself
|
||||
@@ -396,7 +481,7 @@ class LiveFeedScreen(Screen):
|
||||
brand row when it is the stop the cursor rests on.
|
||||
"""
|
||||
selected = self._selected is None
|
||||
row = Text("❯ " if selected else " ", style="brand" if selected else "")
|
||||
row = Text(_CURSOR if selected else " ", style="brand" if selected else "")
|
||||
row.append("Back")
|
||||
if selected:
|
||||
row.style = "brand"
|
||||
@@ -482,6 +567,11 @@ class LiveFeedScreen(Screen):
|
||||
whole row, exactly as every select list draws its cursor. The lanes keep their own
|
||||
colours over it — the SNR its quality hue, a name its palette hue — because those
|
||||
colours are the content; the pointer and the base tint are what say "this row".
|
||||
The one row that can wear a *different* pointer is the newest, which takes ``^``
|
||||
while the cursor is pinned to the top of the stream rather than to the packet
|
||||
currently sitting there (:data:`_PIN_CURSOR`, and the module docstring). Same lane,
|
||||
same brand tint, same one cell — only the mark differs, because only the meaning
|
||||
does.
|
||||
|
||||
A row that runs past the right edge — a terminal narrower than the lanes need —
|
||||
is read by scrolling it, not by growing it: the *highlighted* row slides under
|
||||
@@ -491,7 +581,8 @@ class LiveFeedScreen(Screen):
|
||||
clamp it back into range.
|
||||
"""
|
||||
row = Text(no_wrap=True, overflow="ellipsis")
|
||||
row.append("❯ " if selected else " ", style="brand" if selected else "")
|
||||
cursor = _PIN_CURSOR if self._pinned else _CURSOR
|
||||
row.append(cursor if selected else " ", style="brand" if selected else "")
|
||||
avail = max(1, width - 2)
|
||||
|
||||
body = Text(no_wrap=True, overflow="ellipsis")
|
||||
|
||||
+75
-4
@@ -280,6 +280,7 @@ def test_livefeed_column_header_is_pinned_and_carries_no_sort_cue() -> None:
|
||||
def test_livefeed_highlight_uses_the_app_wide_cursor() -> None:
|
||||
"""The feed marks its row like every other list: a ``❯`` pointer over a brand row."""
|
||||
screen = _screen(seed=[_obs(node="n0", age_s=1), _obs(node="n1", age_s=0)])
|
||||
screen.handle("down") # off the pin, onto the newest packet normally selected
|
||||
rows = _rows(screen, 100)
|
||||
assert rows[0].startswith("❯ ") and rows[1].startswith(" ")
|
||||
assert "▸" not in "\n".join(rows) # the old odd-one-out mark is gone
|
||||
@@ -287,6 +288,71 @@ def test_livefeed_highlight_uses_the_app_wide_cursor() -> None:
|
||||
assert raw.startswith("\x1b[") # the pointer carries the brand style, not bare text
|
||||
|
||||
|
||||
def test_livefeed_opens_pinned_and_the_pin_rides_the_newest_packet() -> None:
|
||||
"""The top stop follows the stream: arrivals take the highlight with them.
|
||||
|
||||
A live feed's resting state is *watching*, so the screen opens on the pin — drawn
|
||||
``^`` rather than ``❯`` — and every packet that lands keeps the cursor on the very
|
||||
topmost row instead of being pushed down with the packet it was on.
|
||||
"""
|
||||
screen = _screen(seed=[_obs(node="n0", age_s=1)])
|
||||
assert screen._pinned and screen._selected == 0
|
||||
assert _rows(screen, 100)[0].startswith("^ ") # the pin's own mark
|
||||
|
||||
screen.on_event(MeshEvent.observation_event(_obs(node="a1b2")))
|
||||
assert screen._selected == 0, "the pin let go of the top"
|
||||
rows = _rows(screen, 100)
|
||||
assert rows[0].startswith("^ ") and rows[1].startswith(" ")
|
||||
assert "Alice" in rows[0] # …and it is the *newly arrived* packet under the cursor
|
||||
|
||||
|
||||
def test_livefeed_down_off_the_pin_selects_the_newest_packet_itself() -> None:
|
||||
"""``↓`` off the pin does not move a row — it changes what the cursor is attached to.
|
||||
|
||||
The two stops share the top line: the pin holds the position, a selection holds the
|
||||
packet. So the first ``↓`` lands on that same newest packet under a plain ``❯``, and
|
||||
only the *second* reaches the packet below it. ``↑`` re-pins.
|
||||
"""
|
||||
screen = _screen(seed=[_obs(node="n0", age_s=2), _obs(node="n1", age_s=1)])
|
||||
screen.handle("down")
|
||||
assert screen._selected == 0 and not screen._pinned, "↓ skipped past the newest packet"
|
||||
assert _rows(screen, 100)[0].startswith("❯ ")
|
||||
|
||||
# Now the highlight belongs to that packet: an arrival pushes it down, as ever.
|
||||
screen.on_event(MeshEvent.observation_event(_obs(node="a1b2")))
|
||||
assert screen._selected == 1
|
||||
|
||||
screen.handle("down")
|
||||
assert screen._selected == 2 and not screen._pinned # the next ↓ really does move
|
||||
screen.handle("up")
|
||||
screen.handle("up")
|
||||
assert screen._selected == 0 and not screen._pinned # back on the newest packet…
|
||||
screen.handle("up")
|
||||
assert screen._pinned # …and one more ↑ re-pins
|
||||
|
||||
|
||||
def test_livefeed_home_resumes_following_from_deep_in_the_history() -> None:
|
||||
"""Home lands on the pin, not merely on whichever packet is newest right now."""
|
||||
screen = _screen(seed=[_obs(node=f"n{i}", age_s=i) for i in range(10)])
|
||||
screen.handle("end")
|
||||
assert screen._selected is None and not screen._pinned
|
||||
screen.handle("home")
|
||||
assert screen._pinned and screen._selected == 0
|
||||
screen.on_event(MeshEvent.observation_event(_obs(node="a1b2")))
|
||||
assert screen._selected == 0 # following again, not parked on the old newest
|
||||
|
||||
|
||||
def test_livefeed_opening_a_packet_drops_the_pin() -> None:
|
||||
"""Enter names *this* packet, so arrivals during a long read can't walk off it."""
|
||||
screen = _screen(seed=[_obs(node="n0", age_s=1)])
|
||||
screen.future = _Fut()
|
||||
assert screen._pinned
|
||||
screen._select_row(0) # what _open_packet does before floating the viewer
|
||||
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"
|
||||
|
||||
|
||||
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)]
|
||||
@@ -294,11 +360,13 @@ def test_livefeed_page_keys_move_the_feed_selection() -> None:
|
||||
screen._feed_window.page = 5 # as if the last paint settled a five-row window
|
||||
assert screen._selected == 0 # the newest packet is highlighted from the start
|
||||
screen.handle("pagedown")
|
||||
assert screen._selected == 5 # the selection travelled down a page, not just the view
|
||||
# A page is five *stops*, and the pin is the first of them (see LiveFeedScreen._cursor),
|
||||
# so paging off it lands on row 4 — the same five positions every other page travels.
|
||||
assert screen._selected == 4 # the selection travelled down a page, not just the view
|
||||
screen.handle("pageup")
|
||||
assert screen._selected == 0
|
||||
assert screen._selected == 0 and screen._pinned
|
||||
screen.handle("pageup")
|
||||
assert screen._selected == 0 # …and stops at the newest rather than wrapping
|
||||
assert screen._selected == 0 # …and stops at the pin rather than wrapping
|
||||
|
||||
for _ in range(5): # paged past the oldest packet, the cursor lands on Back and stays
|
||||
screen.handle("pagedown")
|
||||
@@ -329,6 +397,7 @@ def test_livefeed_back_row_is_the_cursors_last_stop() -> None:
|
||||
screen = _screen(seed=[_obs(node="n0", age_s=1), _obs(node="n1", age_s=0)])
|
||||
screen.future = _Fut()
|
||||
|
||||
screen.handle("down") # off the pin, onto the newest
|
||||
screen.handle("down")
|
||||
screen.handle("down")
|
||||
assert screen._selected is None # past the oldest packet: Back
|
||||
@@ -391,7 +460,9 @@ def test_livefeed_highlighted_row_scrolls_sideways_to_its_tail() -> None:
|
||||
screen.handle("right")
|
||||
scrolled = _rows(screen, _CRAMPED)[0]
|
||||
assert screen._hshift == screen._hmax
|
||||
assert scrolled.startswith("❯ ") # the pointer lane stays pinned while the row slides
|
||||
# The cursor lane holds its two cells while the row slides under it — here carrying
|
||||
# the pin's ``^``, since a feed just opened is following the stream.
|
||||
assert scrolled.startswith("^ ")
|
||||
assert scrolled.rstrip().endswith("-90 dBm") # the tail is now readable
|
||||
assert opening[2:10] not in scrolled # …at the cost of the time lane, slid off left
|
||||
|
||||
|
||||
Reference in new issue
Block a user