The graph holds still while the arrivals scroll past it

Message paths laid its whole body out as one strip — quote, fan, captions,
then every arrival — and let the frame scroll it under the selection. Walk
far enough down a busy message and the picture the routes are being compared
against slides off the top of the dialog, which is the one thing the reader
was looking at (JP, 2026-09-04).

The head is pinned now and only the list moves, the app-wide windowed-list
pattern the node detail's Routes tab already runs: the quote and the fan take
their lines, the arrivals window into what is left with faint `↑ n more` /
`↓ n more` edge markers, and the fan compresses its own lane pitch before the
list would lose its window — floored at two arrivals and a marker, because
comparing one arrival against another is what this dialog is for. PgUp/PgDn
now page the selection by a windowful of arrivals rather than by body lines,
and both they and the F-lane's chips say so only while there is more than one
arrival to move between.

The variable-height fit both screens need moves to `ListWindow.fit_blocks`,
beside the single-line `fit` it is the sibling of; node detail drops its own
copy and its hand-kept top/page state for the shared window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-09-04 02:03:25 -04:00
1 parent 6fc4e7afc9
commit 0eb9845cd9
5 files changed
+237 -88

No files matched your search

+113 -26
View File
@@ -24,7 +24,12 @@ detail read together:
cross-reference by hue instead of by spelling the hex twice. Under it, hanging muted,
the frame's own facts: time heard, reception SNR, and which resend it was.
The route is the line you pick — it is what one arrival differs from another by, and
what the graph highlights. ↑↓ move the selection (the graph's highlight follows); a
what the graph highlights. The list scrolls *inside* the dialog, in whatever rows the
quote and the graph above it leave, with faint ``↑ n more`` / ``↓ n more`` edge markers
(the app-wide windowed-list pattern, :class:`~meshterm.ui.tui.screen.ListWindow`) — so
walking the arrivals can never push the picture they belong to out of the box, and the
fan compresses its own lanes before the list would lose its window. ↑↓ move the
selection (the graph's highlight follows), PgUp/PgDn page it by a windowful; a
route longer than the dialog **scrolls horizontally with ←→**, the whole line shifting
under a cracked chip at whichever edge continues, and snaps back the moment the
selection moves on. A row you are *not* on is cut the same way — it just cannot slide.
@@ -55,7 +60,7 @@ from .pathline import (
)
from .theme import snr_style
from .tui.render import crop_cells, render_to_ansi
from .tui.screen import Screen
from .tui.screen import ListWindow, Screen
from .widgets import (
NameKeyResolver,
NodeResolver,
@@ -72,6 +77,16 @@ _HSTEP = 4
#: ``❯ `` pointer lane, so the pair reads as one arrival with its detail tucked under it.
_DETAIL_INDENT = 4
#: The fewest rows the fan may compress to, and the most it may spend — the graph gives
#: its lane pitch up to the arrival list before the list would lose its window.
_GRAPH_MIN_ROWS = 5
_GRAPH_MAX_ROWS = 15
#: Lines the arrival list is guaranteed out of the dialog's budget, so a tall fan can
#: never squeeze it below *two* arrivals and the marker saying more are hidden — one row
#: alone is a fact, and what this dialog is for is comparing one arrival against another.
_LIST_MIN_LINES = 5
#: Edge colours: the selected path draws white over the unused paths' gray.
_EDGE_SELECTED = (255, 255, 255)
_EDGE_UNUSED = (110, 110, 110)
@@ -141,15 +156,40 @@ class MessagePathsScreen(Screen):
self._hshift = 0
self._hmax = 0
self._cursor: Optional[int] = None
#: The arrival list's window over the rows the pinned head leaves (its ``page`` is
#: the PgUp/PgDn stride), and whether it currently hides any rows — which is what
#: makes the paging keys worth advertising.
self._list = ListWindow()
self._list_hidden = False
# --- input ---------------------------------------------------------------
@property
def footer_hint(self) -> str: # type: ignore[override]
"""Row keys while there are rows; a bare close hint otherwise."""
if self._arrivals:
return "↑↓ move · ←→ scroll line · Esc close"
return "Esc close"
"""Row keys while there are rows; a bare close hint otherwise.
The paging atom appears only while the window actually hides arrivals — the
app-wide rule that a hint never advertises a key that would do nothing.
"""
if not self._arrivals:
return "Esc close"
parts = ["↑↓ move"]
if self._list_hidden:
parts.append("PgUp/PgDn scroll")
parts.extend(("←→ scroll line", "Esc close"))
return " · ".join(parts)
@property
def fkey_lane(self):
"""The shared pager, dimmed while every arrival is already on screen.
Both banks move the *selection* here — the pager by a windowful, the jumps to the
first and last arrival — so they are live exactly when there is more than one
arrival to move between, whether or not the window hides any.
"""
from .tui.fkeys import default_lane
return default_lane(nav=len(self._arrivals) > 1)
def handle(self, action: str, data: str = "") -> None:
"""Move the selection, shift the selected line sideways, or dismiss."""
@@ -161,10 +201,11 @@ class MessagePathsScreen(Screen):
self._index = (self._index + 1) % rows
self._hshift = 0
elif action == "pageup" and rows:
self._index = max(0, self._index - self._page_step)
# A windowful of *arrivals*, not of body lines: the list is what moves.
self._index = max(0, self._index - self._list.page)
self._hshift = 0
elif action in ("pagedown", "space") and rows:
self._index = min(rows - 1, self._index + self._page_step)
self._index = min(rows - 1, self._index + self._list.page)
self._hshift = 0
elif action in ("home", "ctrl_home") and rows:
self._index = 0
@@ -180,13 +221,24 @@ class MessagePathsScreen(Screen):
self.resolve(None)
def cursor_line(self) -> Optional[int]:
"""The selected row, so a tall arrival list scrolls with the selection."""
"""The selected row's body line. The window already keeps it inside the dialog, so
this only bites on a terminal too short for even the compressed head."""
return self._cursor
# --- rendering -------------------------------------------------------------
def render_body(self, width: int) -> list[str]:
"""The quoted message and summary, the path graph, then the arrival rows."""
"""The pinned quote and path graph, then the arrival list windowed under them.
The head — the quoted message, the evidence summary, the fan and its captions —
holds still, and only the arrival rows scroll (see
:class:`~meshterm.ui.tui.screen.ListWindow`), so walking the arrivals can never
carry the picture they are being compared against out of the dialog. The three
share one budget: the head takes its lines, the list is guaranteed
:data:`_LIST_MIN_LINES`, and the fan compresses its lane pitch into whatever is
left rather than growing past it.
"""
viewport = self._scroll_viewport
quoted = self._message.text.replace("\n", " ")
if len(quoted) > 64:
quoted = quoted[:63] + "…"
@@ -200,6 +252,7 @@ class MessagePathsScreen(Screen):
render_to_ansi(stamp, width, no_wrap=True),
]
self._cursor = None
self._list_hidden = False
if not self._arrivals:
lines.append("")
note = (
@@ -212,9 +265,22 @@ class MessagePathsScreen(Screen):
self._scroll_total = len(lines)
return lines
# The rows are drawn before the fan is sized: what they need is one of the terms
# the fan's row ceiling is struck from.
blocks = [
self._row_block(arrival, i == self._index, width)
for i, arrival in enumerate(self._arrivals)
]
heights = [len(block) for block in blocks]
# No blank line above the graph: its canvas already opens with air over the
# topmost lane, so a spacer here would read as two rows of margin.
graph = self._graph_lines(width)
revisit = self._revisit_line(width)
# The fan's own trailing chrome (caption, node-type key, the revisit note) plus the
# blank that sets the list apart — all struck from the budget before the ceiling is.
chrome = 2 + len(revisit) + 1
room = viewport - len(lines) - chrome - min(sum(heights), _LIST_MIN_LINES)
graph = self._graph_lines(width, max(_GRAPH_MIN_ROWS, min(_GRAPH_MAX_ROWS, room)))
if graph:
lines.extend(graph)
far = "you" if not self._destination else self._destination
@@ -224,25 +290,40 @@ class MessagePathsScreen(Screen):
)
lines.append(render_to_ansi(caption, width, no_wrap=True))
lines.append(render_to_ansi(node_type_legend(), width, no_wrap=True))
lines.extend(self._revisit_line(width))
lines.extend(revisit)
lines.append("")
for i, arrival in enumerate(self._arrivals):
# -- the arrival list, windowed into whatever the head left.
window = max(min(heights), viewport - len(lines))
top, count = self._list.fit_blocks(heights, window, self._index)
if top > 0:
lines.append(render_to_ansi(ListWindow.marker(top, "above"), width))
for i in range(top, top + count):
if i == self._index:
self._cursor = len(lines)
lines.append(self._selected_line(arrival, width))
else:
row = Text(" ")
row.append_text(self._path_text(arrival))
# Cut here rather than letting the render boundary ellipsize it: an
# unselected route runs off the lane exactly as the selected one does,
# and it owes the reader the same cracked chip rather than three dots.
lines.append(render_to_ansi(cut_to(row, width), width, no_wrap=True))
detail = Text(" " * _DETAIL_INDENT)
detail.append_text(self._detail_text(arrival))
lines.append(render_to_ansi(detail, width, no_wrap=True))
lines.extend(blocks[i])
below = len(blocks) - top - count
if below > 0:
lines.append(render_to_ansi(ListWindow.marker(below, "below"), width))
self._list_hidden = top > 0 or below > 0
self._scroll_total = len(lines)
return lines
def _row_block(self, arrival: Arrival, selected: bool, width: int) -> list[str]:
"""One arrival as the window moves it: its route, then its facts hanging under it."""
if selected:
route = self._selected_line(arrival, width)
else:
row = Text(" ")
row.append_text(self._path_text(arrival))
# Cut here rather than letting the render boundary ellipsize it: an
# unselected route runs off the lane exactly as the selected one does,
# and it owes the reader the same cracked chip rather than three dots.
route = render_to_ansi(cut_to(row, width), width, no_wrap=True)
detail = Text(" " * _DETAIL_INDENT)
detail.append_text(self._detail_text(arrival))
return [route, render_to_ansi(detail, width, no_wrap=True)]
# -- the rows --
def _path_text(self, arrival: Arrival) -> Text:
@@ -392,7 +473,7 @@ class MessagePathsScreen(Screen):
seen.append(arrival.hops)
return seen
def _graph_lines(self, width: int) -> list[str]:
def _graph_lines(self, width: int, max_rows: int) -> list[str]:
"""Draw every distinct path origin → us, the selected one white over gray.
The shared route-graph widget does the layout (a left-to-right flow of paths that
@@ -408,6 +489,12 @@ class MessagePathsScreen(Screen):
exactly as the node detail's Routes tab fixes it by evidence order, so the fan's
geometry holds still as ↑↓ walk the arrivals — only the emphasis (which lane draws
white and on top) follows the pick.
Args:
width: Canvas width in cells.
max_rows: The most rows the fan may spend — what the dialog's budget leaves once
the quote, the fan's own captions and the list's guaranteed lines are paid
for. A fan with more lanes than that compresses its lane pitch to fit.
"""
selected = self._arrivals[self._index].hops
paths = self._paths()
@@ -427,7 +514,7 @@ class MessagePathsScreen(Screen):
destination=self._destination, type_of=self._type_of, key_of=self._key_of,
)
return render_path_graph(
layers, width,
layers, width, max_rows=max_rows,
glyph_of=glyph_of, label_of=label_of, label_rgb_of=label_rgb_of,
allow_duplicate_nodes=True,
)
+10 -56
View File
@@ -59,9 +59,9 @@ lanes before it would overflow, the location preview grows into what the Info ta
and a faint rule closes it, so the drawn view and the rows below read as separate bands.
The route list scrolls *inside* the leftover rows with faint ``↑ n more`` / ``↓ n more``
edge markers — the app-wide windowed-list pattern (see
:class:`~meshterm.ui.tui.screen.ListWindow`; the variable-height rows get their own fit
here) — so the graph, the selection driving it, and the action rows share one screen
however many routes a busy node has. ``PgUp/PgDn`` page the cursor through the window.
:class:`~meshterm.ui.tui.screen.ListWindow`, fitting whole blocks) — so the graph, the
selection driving it, and the action rows share one screen however many routes a busy
node has. ``PgUp/PgDn`` page the cursor through the window.
The screen is a pure read-and-route view: it renders already-resolved display data and
resolves an action token (the Trace token carrying the selected route's spec via
@@ -463,11 +463,9 @@ class NodeDetailScreen(Screen):
#: The composed route fan for one (width, rows, selection) — see _routes_stage.
self._stage_memo: Optional[tuple[tuple, list[str]]] = None
self._cursor: Optional[int] = None
#: The route list's window state: the first visible row, the rows the last fit
#: carried (the PgUp/PgDn stride), and whether any rows are hidden (gates the
#: footer's scroll atom).
self._list_top = 0
self._list_page = 1
#: The route list's window over the leftover rows (its ``page`` is the PgUp/PgDn
#: stride), and whether any rows are hidden by it (gates the footer's scroll atom).
self._list = ListWindow()
self._list_hidden = False
#: The highlighted route's pathline horizontal scroll (cells shifted in, ``←/→``);
#: resets whenever the cursor leaves that row (see :data:`_HSHIFT_RESET_ACTIONS`).
@@ -613,11 +611,11 @@ class NodeDetailScreen(Screen):
self._sync_route_sel(focus)
elif action == "pageup":
if n:
self._row_index = max(0, self._row_index - max(1, self._list_page))
self._row_index = max(0, self._row_index - self._list.page)
self._sync_route_sel(focus)
elif action in ("pagedown", "space"):
if n:
self._row_index = min(n - 1, self._row_index + max(1, self._list_page))
self._row_index = min(n - 1, self._row_index + self._list.page)
self._sync_route_sel(focus)
elif action in ("home", "ctrl_home"):
if n:
@@ -659,7 +657,7 @@ class NodeDetailScreen(Screen):
self._tab_index = (self._tab_index + delta) % len(self._tabs)
self._row_index = 0
self._route_sel = 0
self._list_top = 0
self._list.top = 0
self._hshift = 0
self._key_shift = 0
self.scroll_to_top()
@@ -758,8 +756,7 @@ class NodeDetailScreen(Screen):
window = max(1, viewport - len(lines) - action_lines)
heights = [len(block) for block in route_blocks]
on_route = self._row_index if self._row_index < len(route_blocks) else None
top, count = self._fit_blocks(heights, window, on_route)
self._list_page = max(1, count)
top, count = self._list.fit_blocks(heights, window, on_route)
if top > 0:
lines.append(render_to_ansi(ListWindow.marker(top, "above"), width))
for i in range(top, top + count):
@@ -785,37 +782,6 @@ class NodeDetailScreen(Screen):
self._scroll_total = max(1, len(lines))
return lines
def _fit_blocks(self, heights: list[int], win: int, index: Optional[int]) -> tuple[int, int]:
"""Settle the route-list window over variable-height rows: ``(top, count)`` to draw.
The hanging-wrap sibling of :meth:`~meshterm.ui.tui.screen.ListWindow.fit`: each row
is a whole block of rendered lines (a wrapped route never splits mid-hang), the faint
edge markers eat a window line exactly when rows hide beyond them, and the cursor's
row (``index``, or ``None`` while it rests on the action rows) is walked into view.
Args:
heights: Rendered line count per route row.
win: Lines the window may spend — on content and markers alike.
index: The cursor's route row to keep visible, or ``None`` to just clamp.
"""
n = len(heights)
if sum(heights) <= win:
self._list_top = 0
return 0, n
top = max(0, min(self._list_top, n - 1))
if index is not None:
top = min(top, index)
while True:
above = 1 if top > 0 else 0
count = _fill(heights, top, win - above)
if top + count < n: # rows hide below: the marker takes one of the lines
count = max(1, _fill(heights, top, win - above - 1))
if index is None or index < top + count:
break
top += 1 # walk the window down until the cursor's row is inside
self._list_top = top
return top, count
def _info_stage(self, width: int, budget: int) -> list[str]:
"""The vitals block and, with a fix, the location preview grown to fit.
@@ -1064,18 +1030,6 @@ class NodeDetailScreen(Screen):
return render_to_ansi(text, width)
def _fill(heights: list[int], top: int, budget: int) -> int:
"""How many whole blocks from ``top`` fit into ``budget`` lines (greedy, in order)."""
used = 0
count = 0
for h in heights[top:]:
if used + h > budget:
break
used += h
count += 1
return count
# -- geographic helpers --------------------------------------------------------
_COMPASS = ("N", "NE", "E", "SE", "S", "SW", "W", "NW")
+52
View File
@@ -491,6 +491,46 @@ class ListWindow:
self.page = count
return top, count
def fit_blocks(
self, heights: list[int], win: int, index: Optional[int] = None
) -> tuple[int, int]:
"""Settle the window over variable-height rows: ``(top, count)`` blocks to draw.
The hanging-wrap sibling of :meth:`fit`, for a list whose rows are *blocks* of
rendered lines — a route row and the muted facts under it, a wrapped path line —
rather than one line each. A block is never split across the window's edge, the
faint edge markers eat a window line exactly when rows hide beyond them, and the
highlighted block is walked into view.
Args:
heights: Rendered line count per list row.
win: Lines the window may spend — on content and markers alike.
index: A highlighted row that must stay visible, or ``None`` when the window
scrolls free (``top`` is then just clamped).
Returns:
The first visible row and how many rows to draw, as :meth:`fit` returns them.
"""
n = len(heights)
if sum(heights) <= win:
self.top = 0
self.page = max(1, n)
return 0, n
top = max(0, min(self.top, n - 1))
if index is not None:
top = min(top, index)
while True:
above = 1 if top > 0 else 0
count = _fill(heights, top, win - above)
if top + count < n: # rows hide below: the marker takes one of the lines
count = max(1, _fill(heights, top, win - above - 1))
if index is None or index < top + count:
break
top += 1 # walk the window down until the cursor's row is inside
self.top = top
self.page = max(1, count)
return top, count
def to_end(self) -> None:
"""Slide the window to the list's tail on the next :meth:`fit`.
@@ -506,6 +546,18 @@ class ListWindow:
return Text(f" {arrow} {hidden} more", style="faint")
def _fill(heights: list[int], top: int, budget: int) -> int:
"""How many whole blocks from ``top`` fit into ``budget`` lines (greedy, in order)."""
used = 0
count = 0
for h in heights[top:]:
if used + h > budget:
break
used += h
count += 1
return count
def _clamp_scroll(scroll: int, total: int, viewport: int) -> int:
"""Clamp a scroll offset to the valid range for the content and viewport.
+57 -2
View File
@@ -30,7 +30,9 @@ def _screen(arrivals: list[Arrival], **kwargs) -> MessagePathsScreen:
summary="heard twice", source="Alice",
)
defaults.update(kwargs)
return MessagePathsScreen(message, arrivals, **defaults)
screen = MessagePathsScreen(message, arrivals, **defaults)
screen.note_viewport(40) # the frame records this before every real paint
return screen
def _arrivals() -> list[Arrival]:
@@ -144,7 +146,7 @@ def test_paths_screen_graph_geometry_holds_still_across_the_selection() -> None:
screen = _screen(arrivals)
shapes = []
for _ in range(len(arrivals)):
shapes.append(_plain(screen._graph_lines(76)))
shapes.append(_plain(screen._graph_lines(76, 15)))
screen.handle("down")
assert len(set(shapes)) == 1, "the graph's shape moved when the selection did"
assert shapes[0].count("\n") > 1 # a genuine multi-lane fan, not one flat line
@@ -329,3 +331,56 @@ def test_a_routed_frame_with_no_path_draws_no_graph_and_says_why() -> None:
body = _plain(screen.render_body(72))
assert "route not carried" in body
assert "origin →" not in body, "no graph, so nothing for the caption to label"
def _many(n: int) -> list[Arrival]:
"""``n`` arrivals, each on its own two-hop route, oldest first."""
now = utcnow()
return [
Arrival(when=now + timedelta(seconds=i), hops=(f"{i:02x}{i:02x}", "77aa"), snr=1.0)
for i in range(n)
]
def test_the_arrival_list_windows_and_the_graph_stays_put() -> None:
"""Only the arrivals scroll: the quote, the fan and its captions are pinned chrome.
Walking to the last arrival slides the window under them — the body still fits the
dialog's budget, and the picture the routes are being compared against never leaves it.
"""
screen = _screen(_many(12))
screen.note_viewport(24)
lines = screen.render_body(72)
assert len(lines) <= 24 # nothing scrolls off: the list took what the head left
body = _plain(lines)
assert "“on my way”" in body and "white = selected path" in body
assert "↓" in body and "more" in body # the edge marker counts the hidden arrivals
assert "PgUp/PgDn scroll" in screen.footer_hint # paging advertised only when needed
for _ in range(11):
screen.handle("down")
lines = screen.render_body(72)
body = _plain(lines)
assert len(lines) <= 24
assert "white = selected path" in body, "the graph is pinned, not scrolled away"
assert "↑" in body and "more" in body # rows now hidden above instead
assert screen.cursor_line() is not None
def test_paging_moves_the_selection_by_a_windowful() -> None:
"""PgDn steps the pick by the rows the window actually carried, not by body lines."""
screen = _screen(_many(12))
screen.note_viewport(24)
screen.render_body(72)
page = screen._list.page
assert 1 <= page < 12
screen.handle("pagedown")
assert screen._index == page
def test_a_short_list_advertises_no_paging() -> None:
"""Every arrival on screen means the pager moves nothing, and the hint says nothing."""
screen = _screen(_arrivals())
screen.render_body(72)
assert "PgUp/PgDn" not in screen.footer_hint
assert "more" not in _plain(screen.render_body(72))
+5 -4
View File
@@ -37,7 +37,7 @@ from meshterm.ui.node_detail_screen import (
)
from meshterm.ui.pathgraph import DST_NODE, SRC_NODE
from meshterm.ui.pathline import CRACK_HEAD, CRACK_TAIL, SELF_GLYPH, PathHop, PathLine
from meshterm.ui.tui.screen import CANCEL
from meshterm.ui.tui.screen import CANCEL, ListWindow
from meshterm.ui.widgets import highlighted_hash, route_graph_style, tab_strip
US = "aaaaaaaaaaaa"
@@ -1009,10 +1009,11 @@ def test_node_detail_route_list_windows_inside_the_page() -> None:
def test_fit_blocks_walks_wrapped_rows_into_view() -> None:
"""The variable-height fit keeps whole blocks, spends marker lines only when rows hide,
and walks the window down to the cursor's row."""
screen = _screen()
top, count = screen._fit_blocks([2, 2, 2, 2], 5, 3) # cursor on the last 2-line row
window = ListWindow()
top, count = window.fit_blocks([2, 2, 2, 2], 5, 3) # cursor on the last 2-line row
assert top + count == 4 and top == 2 # slid to the tail; the last two rows fit
top, count = screen._fit_blocks([1, 1], 5, 0)
assert window.page == count # the settled capacity is the paging stride
top, count = window.fit_blocks([1, 1], 5, 0)
assert (top, count) == (0, 2) # everything fits: no window, no markers