From 0eb9845cd9d1d4e4e45d22d545a41cb997ae09f7 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Fri, 4 Sep 2026 02:03:25 -0400 Subject: [PATCH] The graph holds still while the arrivals scroll past it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- meshterm/ui/message_paths_screen.py | 139 ++++++++++++++++++++++------ meshterm/ui/node_detail_screen.py | 66 ++----------- meshterm/ui/tui/screen.py | 52 +++++++++++ tests/test_message_paths_screen.py | 59 +++++++++++- tests/test_node_detail.py | 9 +- 5 files changed, 237 insertions(+), 88 deletions(-) diff --git a/meshterm/ui/message_paths_screen.py b/meshterm/ui/message_paths_screen.py index 74ee3d1..e76549e 100644 --- a/meshterm/ui/message_paths_screen.py +++ b/meshterm/ui/message_paths_screen.py @@ -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, ) diff --git a/meshterm/ui/node_detail_screen.py b/meshterm/ui/node_detail_screen.py index 352cf61..b9a9296 100644 --- a/meshterm/ui/node_detail_screen.py +++ b/meshterm/ui/node_detail_screen.py @@ -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") diff --git a/meshterm/ui/tui/screen.py b/meshterm/ui/tui/screen.py index 395f295..96735d2 100644 --- a/meshterm/ui/tui/screen.py +++ b/meshterm/ui/tui/screen.py @@ -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. diff --git a/tests/test_message_paths_screen.py b/tests/test_message_paths_screen.py index a7825f5..0d8cc50 100644 --- a/tests/test_message_paths_screen.py +++ b/tests/test_message_paths_screen.py @@ -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)) diff --git a/tests/test_node_detail.py b/tests/test_node_detail.py index b54a74c..d401826 100644 --- a/tests/test_node_detail.py +++ b/tests/test_node_detail.py @@ -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