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:
1 parent
6fc4e7afc9
commit
0eb9845cd9
5 files changed
+237
-88
No files matched your search
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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))
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user