Live feed: the way out, where every list keeps it

The feed was the one screen in the app with no Back row. Esc worked, but
the exit group is not a shortcut — it is the app's standard closing
furniture, and a screen that lists rows and offers none reads as a screen
you have to already know how to leave.

So the cursor gains a stop past the oldest packet: a blank separator and
the bare word Back, exactly the shape back_rows() gives a select list,
drawn by hand because the feed is a rendered screen rather than a list of
choices. It is pinned chrome, struck from the viewport before the feed
windows into what is left — so it sits at the foot of the screen however
far the packets have scrolled, and the body still never scrolls as a
whole. Enter on it resolves what Esc resolves.

That makes the cursor one index over len(feed) + 1 stops, which collapses
three special cases into a clamp: the "nothing highlighted" state the
page keys used to slide the bare window in is now simply "the cursor is
on Back", and an empty feed opens with the only stop it has already
highlighted. End lands on the list's last row, as it does on every other
list — the oldest packet is one ↑ up from there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-07-28 19:27:33 -04:00
1 parent 01ca242c98
commit 7b3fdff84e
2 files changed
+149 -58

No files matched your search

+75 -50
View File
@@ -24,6 +24,11 @@ it whole, and Enter opens the highlighted packet in the shared
itself with the same ``↑``/``↓``, and, for an overheard channel-text packet naming a
channel we hold the key for, decrypts it.
The cursor's last stop, past the oldest packet, is the app's own exit group — a blank
line and the bare word ``Back``, pinned to the foot of the screen so it is there to be
seen and pressed however far the feed has scrolled. Enter on it leaves exactly as Esc
does.
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
the screen resolves. The newest packet is highlighted from the moment the screen
@@ -105,6 +110,12 @@ _FEED_LABEL_MIN_WIDTH = (
+ _FEED_SUBJECT_WIDTH + _LANE_GAP + _SNR_LANE + _RSSI_LANE
)
#: Body lines the pinned exit group costs: the blank separator, then the ``Back`` row —
#: the exact shape :func:`~meshterm.ui.menus.back_rows` gives a select list, struck from
#: the feed's window so the group stays at the foot of the screen instead of scrolling
#: with the packets.
_EXIT_LINES = 2
#: Cells one ←/→ press shifts the highlighted row by — the app-wide select list's own
#: step (:attr:`~meshterm.ui.tui.select.SelectScreen._HSCROLL_STEP`), so a row here
#: scrolls at the rate a row anywhere else does.
@@ -188,8 +199,9 @@ class LiveFeedScreen(Screen):
self._feed: deque[PacketEntry] = deque(maxlen=_FEED_CAP)
for obs in list(seed)[-_FEED_CAP:][::-1]:
self._feed.append(PacketEntry.from_observation(obs))
#: The highlighted feed row. Starts on the newest packet (``None`` only when
#: the feed is empty) so a lone Esc always backs straight out of the screen.
#: The highlighted feed row, or ``None`` when the cursor rests on the ``Back``
#: 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
#: The feed's window within the fixed screen (its rows scroll under the heading).
self._feed_window = ListWindow()
@@ -243,7 +255,7 @@ class LiveFeedScreen(Screen):
return base
def handle(self, action: str, data: str = "") -> None:
"""Walk the feed, open the highlighted packet, scroll, or dismiss."""
"""Walk the cursor, commit the row it rests on, scroll a row, or dismiss."""
if action in _HSHIFT_RESET:
self._hshift = 0 # moving off a row abandons its scroll
if action == "up":
@@ -251,36 +263,19 @@ class LiveFeedScreen(Screen):
elif action == "down":
self._move_selection(1)
elif action == "enter":
self._open_packet()
self._commit()
elif action == "pageup":
# With a feed row highlighted the page keys walk the selection (a
# windowful at a time), so the highlight travels with the window; with
# none, they slide the feed window itself under the pinned heading.
if self._selected is not None:
self._select_index(self._selected - self._feed_window.page)
else:
self._feed_window.top -= self._feed_window.page
self._session.invalidate()
# The page keys walk the cursor a windowful at a time, so the highlight
# travels with the window rather than being left behind by it.
self._select_stop(self._cursor - self._feed_window.page)
elif action in ("pagedown", "space"):
if self._selected is not None:
self._select_index(self._selected + self._feed_window.page)
else:
self._feed_window.top += self._feed_window.page
self._session.invalidate()
self._select_stop(self._cursor + self._feed_window.page)
elif action in ("home", "ctrl_home"):
# With the feed highlight active, Home jumps to the newest packet;
# otherwise it slides the window to the feed's newest end (End mirrors).
if self._selected is not None:
self._select_index(0)
else:
self._feed_window.top = 0
self._session.invalidate()
# 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).
self._select_stop(0)
elif action in ("end", "ctrl_end"):
if self._selected is not None:
self._select_index(len(self._feed) - 1)
else:
self._feed_window.to_end()
self._session.invalidate()
self._select_stop(len(self._feed))
elif action == "left":
self._scroll_line(-_HSCROLL_STEP)
elif action == "right":
@@ -295,20 +290,33 @@ class LiveFeedScreen(Screen):
self._hshift = shift
self._session.invalidate()
def _move_selection(self, delta: int) -> None:
"""Move the feed highlight (the first press lands on the newest packet)."""
if not self._feed:
return
if self._selected is None:
self._select_index(0)
else:
self._select_index(self._selected + delta)
@property
def _cursor(self) -> int:
"""The cursor as one index over the screen's stops: a feed row, else the ``Back`` row.
def _select_index(self, index: int) -> None:
"""Highlight one feed row (clamped) and repaint."""
self._selected = max(0, min(index, len(self._feed) - 1))
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.
"""
return len(self._feed) if self._selected is None else self._selected
def _move_selection(self, delta: int) -> None:
"""Step the cursor by ``delta`` rows (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
self._session.invalidate()
def _commit(self) -> None:
"""Enter: open the highlighted packet, or leave when the cursor is on ``Back``."""
if self._selected is None:
self.resolve(None) # the exit row leaves exactly as Esc does
else:
self._open_packet()
def _open_packet(self) -> None:
"""Float the packet viewer over the highlighted feed row.
@@ -322,7 +330,7 @@ class LiveFeedScreen(Screen):
# 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_index(i)
self._select_stop(i)
return
viewer = PacketViewer(
@@ -339,26 +347,43 @@ class LiveFeedScreen(Screen):
# --- rendering ---------------------------------------------------------------------
def render_body(self, width: int) -> list[str]:
"""The status line and column header, then the feed's window under them.
"""The status line and column header, the feed's window, then the exit group.
Both chrome lines are pinned by construction rather than by the base screen's
sticky-header machinery: the feed windows *inside* the space they leave (see
:meth:`_feed_lines`), so the body never scrolls and the header can never travel
off the top of it.
Every line but the feed's own is pinned by construction rather than by the base
screen's sticky-header machinery: the chrome above and below is struck from the
viewport first and the feed windows *inside* what is left (see
:meth:`_feed_lines`), so the body never scrolls — the header can't travel off the
top of it, and ``Back`` can't sink past the bottom.
"""
if self._selected is not None and self._feed:
self._selected = min(self._selected, len(self._feed) - 1)
else:
if self._selected is not None:
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 ←→
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
lines.append(render_to_ansi(self._column_header(show_label), width, no_wrap=True))
win = max(1, self._scroll_viewport - len(lines))
win = max(1, self._scroll_viewport - len(lines) - _EXIT_LINES)
lines.extend(self._feed_lines(width, win, show_label))
lines.extend(self._exit_lines(width))
self._scroll_total = max(1, len(lines))
return lines
def _exit_lines(self, width: int) -> list[str]:
"""The pinned exit group: one blank separator, then the bare word ``Back``.
The very rows :func:`~meshterm.ui.menus.back_rows` gives a select list, drawn here
by hand because the feed is a rendered screen rather than a list of choices — same
blank line, same bare word, no arrow and no icon, and the same ``❯`` cursor over a
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.append("Back")
if selected:
row.style = "brand"
return ["", render_to_ansi(row, width, no_wrap=True)]
def _heading(self) -> Text:
"""The feed's pinned status line, with a live-light for the hub."""
heading = Text("newest first · Enter opens a packet ", style="muted")
+74 -8
View File
@@ -24,6 +24,21 @@ class _FakeSession:
self.repaints += 1
class _Fut:
"""A minimal future stand-in so the screen can resolve without an event loop."""
def __init__(self) -> None:
self.value = None
self._done = False
def done(self) -> bool:
return self._done
def set_result(self, value) -> None:
self.value = value
self._done = True
#: Nodes the feed's resolver can name, by the hash a frame addresses them with (one byte)
#: and by the wider prefixes an advert carries.
_KNOWN = {"a1b2": "Alice", "3d63": "YUL", "a1": "Alice", "3d": "YUL", "c0": "Us"}
@@ -58,8 +73,8 @@ def _stripped(lines: list[str]) -> list[str]:
def _rows(screen: LiveFeedScreen, width: int) -> list[str]:
"""Just the packet rows — the body past its status line and its column header."""
return _stripped(screen.render_body(width))[2:]
"""Just the packet rows — the body past its chrome, above the pinned exit group."""
return _stripped(screen.render_body(width))[2:-2]
def _col(line: str, needle: str) -> int:
@@ -253,7 +268,8 @@ def test_livefeed_column_header_is_pinned_and_carries_no_sort_cue() -> None:
lines = _stripped(screen.render_body(100))
assert "TIME" in lines[1]
assert not any(mark in lines[1] for mark in ("▲", "▼")) # nothing here sorts
screen.handle("end") # walk to the oldest packet — the window scrolls under the header
screen.handle("end") # the list's last row — Back
screen.handle("up") # …and one up from it, the oldest packet: the window scrolls
after = _stripped(screen.render_body(100))
assert after[1] == lines[1] # …and the header is exactly where it was
assert "↑" in after[2] and "more" in after[2] # the rows really did travel
@@ -270,7 +286,7 @@ def test_livefeed_highlight_uses_the_app_wide_cursor() -> None:
def test_livefeed_page_keys_move_the_feed_selection() -> None:
"""With a feed row highlighted, PgUp/PgDn walk the selection a windowful at a time."""
"""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)]
screen = _screen(seed=seed)
screen._feed_window.page = 5 # as if the last paint settled a five-row window
@@ -279,11 +295,61 @@ def test_livefeed_page_keys_move_the_feed_selection() -> None:
assert screen._selected == 5 # the selection travelled down a page, not just the view
screen.handle("pageup")
assert screen._selected == 0
screen.handle("pageup")
assert screen._selected == 0 # …and stops at the newest rather than wrapping
# With nothing highlighted, the page keys slide the feed window instead.
screen._selected = None
screen.handle("pagedown")
assert screen._feed_window.top > 0 and screen._selected is None
for _ in range(5): # paged past the oldest packet, the cursor lands on Back and stays
screen.handle("pagedown")
assert screen._selected is None
def test_livefeed_ends_in_the_apps_exit_group() -> None:
"""The screen closes with the standard exit group, pinned to the foot of the body."""
seed = [_obs(node=f"n{i}", age_s=i) for i in range(40)]
screen = _screen(seed=seed)
screen.note_viewport(24)
lines = _stripped(screen.render_body(100))
assert len(lines) <= 24 # the group is chrome: it fits, it doesn't grow
assert lines[-2].strip() == "" # exactly one blank separator above it…
assert lines[-1].strip() == "Back" # …then the bare word, no arrow and no icon
assert "↓" in lines[-3] and "more" in lines[-3] # the feed windows above it
# It stays put as the feed scrolls: the packets travel under it, it does not.
screen.handle("end")
screen.handle("up") # onto the oldest packet, so the window really has scrolled
after = _stripped(screen.render_body(100))
assert "↑" in after[2] and "more" in after[2]
assert after[-1].strip() == "Back" and after[-2].strip() == ""
def test_livefeed_back_row_is_the_cursors_last_stop() -> None:
"""↓ off the oldest packet lands on Back, End jumps to it, and Enter there leaves."""
screen = _screen(seed=[_obs(node="n0", age_s=1), _obs(node="n1", age_s=0)])
screen.future = _Fut()
screen.handle("down")
screen.handle("down")
assert screen._selected is None # past the oldest packet: Back
assert _stripped(screen.render_body(100))[-1].startswith("❯ Back")
screen.handle("down")
assert screen._selected is None # …and the cursor stops there
screen.handle("up")
assert screen._selected == 1 # back onto the oldest packet
screen.handle("end")
assert screen._selected is None # End is the list's last row
screen.handle("enter") # Enter on Back leaves…
assert screen.future.done() and screen.future.value is None # …exactly as Esc does
def test_livefeed_empty_feed_still_offers_a_way_out() -> None:
"""With nothing heard, Back is the only stop there is — and it is already the cursor's."""
screen = _screen()
lines = _stripped(screen.render_body(100))
assert "nothing heard yet" in lines[1]
assert lines[-1].startswith("❯ Back")
screen.handle("up") # nowhere else to go
assert screen._selected is None and _stripped(screen.render_body(100))[-1].startswith("❯ ")
def test_livefeed_windows_inside_the_fixed_screen() -> None: