diff --git a/meshterm/ui/tui/fastrender.py b/meshterm/ui/tui/fastrender.py index c1eeecf..76c7b9e 100644 --- a/meshterm/ui/tui/fastrender.py +++ b/meshterm/ui/tui/fastrender.py @@ -92,15 +92,19 @@ class FastRenderer(Renderer): return output = self.output + pending = False # whether the terminal setup below has queued anything to flush if self.full_screen and not self._in_alternate_screen: self._in_alternate_screen = True output.enter_alternate_screen() + pending = True if not self._bracketed_paste_enabled: output.enable_bracketed_paste() self._bracketed_paste_enabled = True + pending = True if not self._cursor_key_mode_reset: output.reset_cursor_key_mode() self._cursor_key_mode_reset = True + pending = True size = output.get_size() dims = (size.rows, size.columns) @@ -108,6 +112,14 @@ class FastRenderer(Renderer): # A resize invalidates both records of the screen: ours and prompt_toolkit's. full = self._prev_rows is None or self._prev_size != dims + if not full and not pending and rows == self._prev_rows: + # The app repaints on a timer to keep the header's pulse moving, and most of + # those frames come back identical. Writing nothing at all is not merely cheaper + # than writing the rows again — it leaves the panel's damage region empty, so + # the display never flushes and the SPI bus stays quiet. + self._last_size = size + self.fast_paints += 1 + return if full: self._last_screen = None output.hide_cursor() diff --git a/meshterm/ui/tui/frame.py b/meshterm/ui/tui/frame.py index 390b1e1..634ea7c 100644 --- a/meshterm/ui/tui/frame.py +++ b/meshterm/ui/tui/frame.py @@ -19,7 +19,7 @@ from ...platforms import Platform, get_platform, on_platform from ..logo import load_logo, logo_width from ..theme import fold_text, hint_style, title_style from .glow import apply_corner_glow -from .render import render_lines +from .render import crop_cells, render_lines, render_to_ansi from .screen import Screen @@ -520,13 +520,84 @@ def compose_dialog(screen: Screen, cols: int, rows: int) -> str: return out +#: Memoized composited rows, keyed by everything the merge is a function of: the base row +#: under the box, the box's own line, and where it sits. A dialog repaints with almost every +#: row identical to the last paint (only the highlight moved, or nothing did but the header's +#: pulse), and each unchanged row then costs a dict hit instead of two ANSI parses, two cell +#: crops and a render. +_OVERLAY_CACHE: "OrderedDict[tuple, str]" = OrderedDict() + +#: Composited rows the memo holds — a few dialogs' worth of rows. +_OVERLAY_CACHE_MAX = 256 + + +def _overlay_row(base_row: str, box_line: str, left: int, cols: int) -> str: + """Lay one box line over one base row at cell ``left``, keeping both sides' styling.""" + key = (base_row, box_line, left, cols) + cached = _OVERLAY_CACHE.get(key) + if cached is not None: + _OVERLAY_CACHE.move_to_end(key) + return cached + under = Text.from_ansi(base_row) + over = Text.from_ansi(box_line) + merged = crop_cells(under, 0, left) + # A base row shorter than the box's left edge (a blank line under a wide dialog) still + # has to hold the box out at its true column, so pad the gap rather than closing it. + merged.append(" " * max(0, left - merged.cell_len)) + merged.append_text(over) + tail_at = left + over.cell_len + merged.append_text(crop_cells(under, tail_at, max(0, cols - tail_at))) + merged.no_wrap = True + merged.overflow = "crop" + merged.truncate(cols) + out = render_to_ansi(merged, cols, no_wrap=True) + _OVERLAY_CACHE[key] = out + if len(_OVERLAY_CACHE) > _OVERLAY_CACHE_MAX: + _OVERLAY_CACHE.popitem(last=False) + return out + + +def composite_float(base_rows: list[str], box: str, cols: int, rows: int) -> list[str]: + """Lay a dialog box over the full-screen rows beneath it, centred, and return the result. + + The floating layers are normally placed by prompt_toolkit's ``FloatContainer``, which is + why a frame carrying one used to fall through to prompt_toolkit's renderer whole — and + pay its full grid rebuild and diff (70-125 ms on the PicoCalc) on every keystroke in + every dialog. There is nothing to that placement we cannot do here: an unanchored float + with no size of its own is centred on the frame, horizontally at its widest line and + vertically at its line count, clipped to the terminal. Doing it ourselves keeps dialogs + on the row-diff path with the rest of the app. + + Args: + base_rows: The frame beneath, one ANSI string per terminal row. + box: The composed dialog, its rows joined by newlines (see :func:`compose_dialog`). + cols: Terminal width. + rows: Terminal height. + + Returns: + A new list of rows with the box merged in. Rows the box doesn't reach are passed + through untouched — the same strings, so the row diff skips them for free. + """ + lines = box.split("\n") + width = max((cell_len(Text.from_ansi(line).plain) for line in lines), default=0) + top = max(0, (rows - len(lines)) // 2) + left = max(0, (cols - width) // 2) + out = list(base_rows) + for i, line in enumerate(lines): + y = top + i + if 0 <= y < len(out): + out[y] = _overlay_row(out[y], line, left, cols) + return out + + @on_platform def _bind(platform: Platform) -> None: """Drop the composition memos on a platform switch — their output bakes the theme in. Registered at module bottom so the immediate first run (see - :func:`~meshterm.platforms.on_platform`) finds both caches already defined. + :func:`~meshterm.platforms.on_platform`) finds every cache already defined. """ global _BASE_BOX_CACHE _BASE_BOX_CACHE = None _DIALOG_CACHE.clear() + _OVERLAY_CACHE.clear() diff --git a/meshterm/ui/tui/screen.py b/meshterm/ui/tui/screen.py index dc8ccc6..461b098 100644 --- a/meshterm/ui/tui/screen.py +++ b/meshterm/ui/tui/screen.py @@ -13,7 +13,8 @@ push/await/pop model behind ``await session.select(...)`` and friends. from __future__ import annotations import asyncio -from typing import Any, Optional, Sequence +from collections.abc import Sequence as _SequenceABC +from typing import Any, Callable, Optional, Sequence, Union from rich.console import RenderableType from rich.text import Text @@ -331,6 +332,71 @@ class Screen: self.scroll = _clamp_scroll(target, self._scroll_total, self._scroll_viewport) +class LazyLines(_SequenceABC): + """A body's ANSI lines, each rendered the first time something actually reads it. + + A screen renders its body *whole* and the frame then slices a viewport out of it — so a + 141-contact list rasterizes 150 rows to show the twenty that fit, on every keystroke and + on every idle tick. The lines outside the slice are never looked at; they are built and + thrown away. + + This is the same list, deferred. A screen hands over one entry per body line — either the + finished string (for the few lines it had to render anyway: separators, headings, the + query echo) or a callable that will render it — and only the entries the frame indexes + are ever called. It is a plain :class:`~collections.abc.Sequence`, so ``len()``, slicing, + iteration and ``==`` against a list all behave exactly as the list it replaces; a slice + materializes to a real list of strings, which is what the frame goes on to pad and frame. + + Two properties make it safe to substitute: + + * **The line count is exact and immediate.** Deferring the *drawing* of a row never + defers *how many rows there are* — the screen counts them while it plans — so the + scroll clamp, the ``↑↓ more`` markers and the sticky-header offsets are unchanged. + * **A line is rendered at most once.** Results are memoized per index, so the frame + re-reading a row (a pinned header, a re-settled slice) costs nothing extra. + + A row's content is resolved inside its callable, which means a :class:`Choice` with a + live callable title is resolved only while it is on screen — the same rows a person can + actually see changing. + """ + + __slots__ = ("_entries", "_drawn") + + def __init__(self, entries: list[Union[str, Callable[[], str]]]) -> None: + """Wrap one entry per body line: a rendered string, or a callable rendering it.""" + self._entries = entries + self._drawn: dict[int, str] = {} + + def __len__(self) -> int: + """The body's height in lines — known without rendering any of them.""" + return len(self._entries) + + def __getitem__(self, index): # type: ignore[no-untyped-def] + """Return line ``index`` (or a real list of lines for a slice), rendering on demand.""" + if isinstance(index, slice): + return [self[i] for i in range(*index.indices(len(self._entries)))] + if index < 0: + index += len(self._entries) + if not 0 <= index < len(self._entries): + raise IndexError(index) + line = self._drawn.get(index) + if line is None: + entry = self._entries[index] + line = entry if isinstance(entry, str) else entry() + self._drawn[index] = line + return line + + def __eq__(self, other: object) -> bool: + """Compare equal to the plain list of lines this stands in for.""" + if isinstance(other, (LazyLines, list)): + return list(self) == list(other) + return NotImplemented + + def __repr__(self) -> str: + """Describe the height and how much of it has actually been drawn.""" + return f"LazyLines({len(self._entries)} lines, {len(self._drawn)} drawn)" + + class ListWindow: """Scroll state for a list that scrolls *inside* a fixed screen. diff --git a/meshterm/ui/tui/select.py b/meshterm/ui/tui/select.py index fc8534a..3513060 100644 --- a/meshterm/ui/tui/select.py +++ b/meshterm/ui/tui/select.py @@ -16,7 +16,7 @@ from rich.cells import cell_len from rich.text import Text from .render import crop_cells, query_line, render_lines, render_to_ansi -from .screen import Screen +from .screen import LazyLines, Screen def _wants_width(fn: Callable) -> bool: @@ -481,7 +481,18 @@ class SelectScreen(Screen): return max(widths, default=20) + 8 def render_body(self, width: int) -> list[str]: - """Render the optional prompt then each row as one ANSI line, the choice marked.""" + """Plan the body's lines, drawing the rows the viewport actually shows. + + Walks every row to lay the body out — how many lines it takes, where the highlight + landed, which separators are sticky landmarks — because the frame's scroll clamp and + pinning need all of that exactly. But a *choice* row's own rasterizing is handed over + as a callable rather than done here (see + :class:`~meshterm.ui.tui.screen.LazyLines`): a list of 141 contacts laid out 150 rows + tall only ever shows the twenty that fit, so the other hundred and thirty were built + and discarded on every keystroke and every idle tick. Separators are rendered on the + spot — they are few, and their lines are the ones :meth:`sticky_rows` pins *outside* + the slice. + """ rows = self._rows() choices = self._choices(rows) self._index = max(0, min(self._index, len(choices) - 1)) if choices else 0 @@ -497,7 +508,9 @@ class SelectScreen(Screen): sel_len = cell_len(_plain(selected.label)) if selected is not None else 0 self._hshift = max(0, min(self._hshift, sel_len - avail)) - lines: list[str] = [] + # One entry per body line: a finished string, or a callable that draws it when the + # frame asks. Positions are exact either way, which is all the layout below reads. + lines: list[Union[str, Callable[[], str]]] = [] # A prompt (when set) sits above the list, offsetting every row below it; the cursor # line and sticky-header indices below are shifted by exactly this many lines. prefix = 0 @@ -558,8 +571,24 @@ class SelectScreen(Screen): is_sel = item is selected if is_sel: cursor_at = len(lines) - pointer = "❯ " if is_sel else " " - style = "brand" if is_sel else "" + lines.append(self._row_drawer(item, is_sel, width)) + # Resolved here, not in the drawer: whether the row is one line tall or two is + # part of the layout, so a callable detail is read while planning — and its one + # resolved value is what the drawer below renders, never a second call. + detail = item.detail_label if isinstance(item, Choice) else None + if detail is not None and _plain(detail): + lines.append(self._detail_drawer(detail, width)) + if not choices: + lines.append(render_to_ansi(Text("no matches", style="muted"), width)) + # Remember where the highlighted row landed so the session can keep it in view, + # shifted past any prompt lines drawn above the list. + self._cursor = None if cursor_at is None else cursor_at + prefix + return LazyLines(lines) + + def _row_drawer(self, item: "Choice", is_sel: bool, width: int) -> Callable[[], str]: + """A callable that rasterizes one choice row — run only if the row is on screen.""" + + def draw() -> str: # A width-aware title fits itself to the row's content area (the width less # the 2-cell pointer). The exception is the highlighted row of an ``hscroll`` # list, which keeps its natural form: ←→ slide the full line, and a row that @@ -568,6 +597,8 @@ class SelectScreen(Screen): label = item.label else: label = item.text(max(1, width - 2)) + pointer = "❯ " if is_sel else " " + style = "brand" if is_sel else "" # A Text label carries its own spans (e.g. a red badge); keep them and lay the # row's base style underneath, so the highlight tints the row while the badge # keeps its colour. A plain string is styled uniformly as before. @@ -582,24 +613,28 @@ class SelectScreen(Screen): text.no_wrap = True text.overflow = "ellipsis" text.truncate(width) - lines.append(render_to_ansi(text, width)) - detail = item.detail_label if isinstance(item, Choice) else None - if detail is not None and _plain(detail): - # Hangs under the row at the pointer's own indent — never scrolls or - # wraps, just ellipsizes on its own if it's too wide to fit. - detail_text = detail if isinstance(detail, Text) else Text(detail) - line = Text(" ") - line.append_text(detail_text) - line.no_wrap = True - line.overflow = "ellipsis" - line.truncate(width) - lines.append(render_to_ansi(line, width)) - if not choices: - lines.append(render_to_ansi(Text("no matches", style="muted"), width)) - # Remember where the highlighted row landed so the session can keep it in view, - # shifted past any prompt lines drawn above the list. - self._cursor = None if cursor_at is None else cursor_at + prefix - return lines + return render_to_ansi(text, width) + + return draw + + @staticmethod + def _detail_drawer(detail: Union[str, Text], width: int) -> Callable[[], str]: + """A callable that rasterizes a row's already-resolved detail line, on demand. + + Hangs under the row at the pointer's own indent — never scrolls or wraps, just + ellipsizes on its own if it's too wide to fit. + """ + + def draw() -> str: + detail_text = detail if isinstance(detail, Text) else Text(detail) + line = Text(" ") + line.append_text(detail_text) + line.no_wrap = True + line.overflow = "ellipsis" + line.truncate(width) + return render_to_ansi(line, width) + + return draw def cursor_line(self) -> Optional[int]: """Return the body line index of the highlighted row.""" diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index 325ee3f..84535f8 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -1331,18 +1331,33 @@ class TuiSession: ) def _plain_frame(self) -> Optional[str]: - """The composed full-screen frame, when this paint is one the fast path may take. + """The whole frame as rows, when this paint is one the fast path may take. - Answers ``None`` — meaning "let prompt_toolkit lay this one out" — whenever the - frame is more than a single background screen: a floating dialog or the busy - overlay is placed by pt's float containers, not by us (see - :mod:`~meshterm.ui.tui.fastrender`). Composing the base is not wasted in that - case: the float layers draw *over* it, so pt asks for it a moment later and gets - the memoized composition. + The background screen, with every floating dialog composited over it exactly where + prompt_toolkit's ``FloatContainer`` would centre it (see + :func:`~meshterm.ui.tui.frame.composite_float`) — so a confirm, a picker or the + packet viewer stays on the row-diff path rather than paying prompt_toolkit's whole + grid rebuild on every keystroke. + + Answers ``None`` — meaning "let prompt_toolkit lay this one out" — for the two + frames we don't place ourselves: the busy overlay, which is a content-sized window + the float container measures, and an empty stack, which has no background at all. + + Every layer still goes through its usual renderer (:meth:`_render_base`, + :meth:`_render_float_layer`), so the layer bookkeeping those keep is identical + whichever path the paint takes. """ - if self._has_float() or self._overlay_visible() or not self._stack: + if self._overlay_visible() or not self._stack: return None - return self._render_base().value + rows_out = self._render_base().value.split("\n") + layers = self._float_layers() + if layers: + cols, rows = self._size() + for index in range(len(layers)): + rows_out = frame.composite_float( + rows_out, self._render_float_layer(index).value, cols, rows + ) + return "\n".join(rows_out) @staticmethod def _fkey_lane(active: Screen) -> Optional[Callable[[], Text]]: diff --git a/tests/test_performance.py b/tests/test_performance.py index 60bd56b..7e4e06b 100644 --- a/tests/test_performance.py +++ b/tests/test_performance.py @@ -330,8 +330,15 @@ def test_fastrender_erases_before_it_draws_so_a_full_row_keeps_its_last_cell() - assert not chunk.rstrip().endswith("\x1b[K"), chunk[-40:] -def test_fastrender_hands_dialogs_back_to_prompt_toolkit(monkeypatch) -> None: # noqa: ANN001 - """A ``None`` frame means a float is up, which pt lays out — it must own that paint.""" +def test_fastrender_hands_a_frame_it_cannot_place_back_to_prompt_toolkit( + monkeypatch, # noqa: ANN001 +) -> None: + """A ``None`` frame means the session declined to place it — pt must own that paint. + + The session answers ``None`` for the frames it does not lay out itself: the busy + overlay, which the float container measures and centres as a content-sized window, and + an empty stack, which has no background at all. + """ from prompt_toolkit.renderer import Renderer from prompt_toolkit.styles import Style @@ -346,3 +353,88 @@ def test_fastrender_hands_dialogs_back_to_prompt_toolkit(monkeypatch) -> None: assert calls == [True] assert renderer.slow_paints == 1 assert renderer.fast_paints == 0 + + +def test_fastrender_writes_nothing_at_all_when_the_frame_is_unchanged() -> None: + """The idle tick's frame is usually identical, and identical must cost no bytes. + + Not merely fewer bytes: an empty write leaves the panel's damage region empty, so the + display never flushes and the SPI bus stays quiet between real changes. + """ + same = "\n".join(f"row {i:02d}" for i in range(26)) + renderer, out = _fast_renderer([same, same]) + + renderer.render(None, None) # first paint: everything + out.written.clear() + renderer.render(None, None) # the tick: nothing moved + + assert out.written == [] + assert renderer.fast_paints == 2 + + +# --- dialogs stay on the fast path --------------------------------------------------- + + +def test_a_dialog_is_composited_onto_the_frame_rather_than_handed_to_prompt_toolkit() -> None: + """A float used to drop the whole frame onto pt's renderer — and pay its grid rebuild. + + The placement is reproducible: an unanchored float with no size of its own is centred + on the frame. So the box is merged in here, the row diff still applies, and the rows + the box does not reach come back as the *same strings* — which is what lets the diff + skip them. + """ + from meshterm.ui.tui.frame import composite_float + + base = [f"{i:02d}" + "." * 51 for i in range(26)] + box = "\n".join(["+" + "-" * 18 + "+"] + ["|" + " " * 18 + "|"] * 4 + + ["+" + "-" * 18 + "+"]) + out = composite_float(base, box, 53, 26) + + assert len(out) == 26 + # Six box rows, centred vertically: (26 - 6) // 2 = 10. + for i in list(range(10)) + list(range(16, 26)): + assert out[i] is base[i], f"row {i} was rewritten for nothing" + # Centred horizontally too: (53 - 20) // 2 = 16 cells of the base still on the left. + plain = _plain_row(out[10]) + assert plain.startswith("10" + "." * 14) + assert plain[16:36] == "+" + "-" * 18 + "+" + assert plain[36:].startswith(".") + assert len(plain) == 53 + + +def test_a_composited_row_keeps_the_styling_of_both_sides() -> None: + """The merge is cell-accurate over *styled* rows — the base's colour must survive it.""" + from rich.text import Text + + from meshterm.ui.tui.frame import composite_float + from meshterm.ui.tui.render import render_to_ansi + + base = [render_to_ansi(Text("x" * 53, style="bold red"), 53)] * 10 + box = render_to_ansi(Text("[ ok ]", style="bold green"), 6) + out = composite_float(base, box, 53, 10) + + row = out[(10 - 1) // 2] + assert "[ ok ]" in _plain_row(row) + assert row.count("\x1b[") > 2, "styling was flattened by the merge" + + +def test_stacked_dialogs_composite_in_z_order() -> None: + """A confirm over a picker over a menu: each box lands on the frame below it.""" + from meshterm.ui.tui.frame import composite_float + + rows = ["." * 53 for _ in range(26)] + rows = composite_float(rows, "\n".join(["under" + "." * 15] * 8), 53, 26) + rows = composite_float(rows, "\n".join(["OVER"] * 2), 53, 26) + + middle = _plain_row(rows[12]) + # The lower box is 20 cells wide, so it starts at (53 - 20) // 2 = 16; the 4-cell box + # above it starts at 24 and covers the lower one's middle without disturbing its edges. + assert middle.startswith("." * 16 + "under") + assert middle[24:28] == "OVER" + + +def _plain_row(row: str) -> str: + """The row's text with its ANSI escapes stripped.""" + import re + + return re.sub(r"\x1b\[[0-9;]*m", "", row) diff --git a/tests/test_tui.py b/tests/test_tui.py index c3b8299..2910c88 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -19,6 +19,7 @@ from rich.table import Table from rich.text import Text from meshterm import copyright_notice +from tests.conftest import plain as _plain from meshterm.ui.menus import section_heading from meshterm.ui.tui import frame, glow from meshterm.ui.tui.glow import apply_corner_glow @@ -2496,3 +2497,146 @@ def test_select_hscroll_leaves_the_detail_line_unshifted() -> None: screen.handle("right") lines = _row_plains(screen, 40) assert "weakest -6.0 dB" in lines[1] + + +# --- the fast path's frame source ---------------------------------------------------- + + +def _framed_session(): + """A session with just enough of an app behind it to compose a frame at 53x26.""" + import types + + from prompt_toolkit.data_structures import Size + + session = TuiSession() + session._app = types.SimpleNamespace( + renderer=types.SimpleNamespace(_last_screen=None), + output=types.SimpleNamespace(get_size=lambda: Size(rows=26, columns=53)), + invalidate=lambda: None, + ) + return session + + +def test_a_dialog_frame_is_composed_here_not_handed_to_prompt_toolkit() -> None: + """A float used to send the whole frame down prompt_toolkit's renderer. + + That cost its full grid rebuild and diff on every keystroke — in every confirm, picker + and viewer in the app. The float's placement is reproducible (an unanchored, unsized + float is centred), so the frame source returns the finished picture with the box merged + in, and the row diff applies to dialogs like everything else. + """ + session = _framed_session() + session.push(ScrollScreen(Text("the list beneath"), title="Nodes", floating=False)) + session.push(ButtonDialog("Remove this contact?", [("Cancel", 0), ("Remove", 1)])) + + text = session._plain_frame() + assert text is not None, "a dialog must not fall back to prompt_toolkit" + rows = text.split("\n") + assert len(rows) == 26 + body = "\n".join(rows) + assert "Remove this contact?" in _plain(body) + assert "the list beneath" in _plain(body), "the backdrop must show around the box" + + +def test_the_busy_overlay_is_still_prompt_toolkits_to_place() -> None: + """The one float we don't place: a content-sized window the float container measures.""" + from meshterm.ui.tui.overlay import BusyOverlay + + session = _framed_session() + assert session._plain_frame() is None # an empty stack has no background either + overlay = BusyOverlay(title="Starting up") + overlay.started_at -= overlay.hold + overlay.fade # past the hold: it is on screen + session._overlay = overlay + assert session._overlay_visible() + assert session._plain_frame() is None + + +def test_the_rows_a_dialog_does_not_reach_come_back_unchanged() -> None: + """What makes compositing worth doing: the diff still skips the untouched rows.""" + session = _framed_session() + session.push(ScrollScreen(Text("\n".join(f"line {i}" for i in range(40))), + title="Nodes", floating=False)) + first = session._plain_frame().split("\n") + session.push(ButtonDialog("Sure?", [("Cancel", 0), ("Yes", 1)])) + second = session._plain_frame().split("\n") + + unchanged = sum(1 for a, b in zip(first, second) if a == b) + assert unchanged >= 10, "the box should only rewrite the rows it covers" + + +# --- the body is drawn one viewport at a time ---------------------------------------- + + +def test_a_long_list_only_rasterizes_the_rows_the_viewport_shows() -> None: + """141 contacts laid out 150 rows tall still only shows twenty of them. + + Rendering the rest was pure waste on every keystroke *and* on the once-a-second tick. + The line count has to stay exact, though — the scroll clamp, the ``↑↓ more`` markers and + the sticky-header offsets are all measured against it. + """ + from meshterm.ui.tui.screen import LazyLines + + drawn: list[int] = [] + + def title(i: int): + def build(): + drawn.append(i) + return f"contact {i:03d}" + + return build + + screen = SelectScreen("Contacts", [Choice(title(i), i) for i in range(150)]) + lines = screen.render_body(53) + + assert isinstance(lines, LazyLines) + assert len(lines) == 150 # the count is exact and cost nothing + assert drawn == [], "no row is rasterized until something reads it" + + window = lines[0:20] + assert len(window) == 20 + assert sorted(drawn) == list(range(20)) + assert "contact 000" in _plain(window[0]) + + lines[5] # a re-read is memoized, never a second render + assert sorted(drawn) == list(range(20)) + + +def test_a_lazy_body_slices_frames_and_scrolls_exactly_as_a_list_did() -> None: + """The substitution has to be invisible to the frame: same height, same clip flags. + + Deferring a row's *drawing* must never defer how many rows there are — the scroll + clamp, the ``↑↓ more`` subtitle and the pinned heading are all measured against that + count, and every one of them would go wrong if the tail were merely unbuilt. + """ + items = [section_heading("Group")] + items += [Choice(f"row {i}", i) for i in range(60)] + screen = SelectScreen("Long", items, footer_hint="Esc back") + screen.floating = False + + out = frame.compose_base(Text("hdr"), screen, "Esc back", 53, 26) + assert len(out.split("\n")) == 26 + plain = _plain(out) + assert "row 0" in plain and "↓ more" in plain + assert screen._scroll_total == 61 # the whole body is still measured + + for _ in range(45): # walk the highlight past the fold + screen.handle("down") + plain = _plain(frame.compose_base(Text("hdr"), screen, "Esc back", 53, 26)) + assert "row 45" in plain, "the highlight must still be kept in view" + assert "row 0 " not in plain, "the top of the list should have scrolled away" + assert "── Group ──" in plain, "the section heading pins once it scrolls off" + + +def test_an_off_screen_rows_live_title_is_left_alone() -> None: + """A row's callable title is resolved only while the row is actually on screen.""" + calls: list[str] = [] + items = [ + Choice(lambda: (calls.append("top"), "top row")[1], 0), + *[Choice(f"filler {i}", i) for i in range(1, 40)], + Choice(lambda: (calls.append("bottom"), "bottom row")[1], 40), + ] + screen = SelectScreen("Live", items) + screen.floating = False + lines = screen.render_body(53) + lines[0:10] + assert calls == ["top"]