Frame: draw only what shows, and stop handing dialogs away

Two halves of the same paint.

A screen renders its body whole and the frame then slices a viewport out
of it, so a 141-contact list rasterized 150 rows to show the twenty that
fit — on every keystroke and on every idle tick. A select screen now
plans its body and hands over one callable per row; the count, the
cursor, and the sticky landmarks are all still exact (they are layout,
not drawing), but only the sliced rows are ever built. A row's live
callable title is resolved only while it is on screen. 3.2x on the body.

And a floating dialog dropped the whole frame onto prompt_toolkit's
renderer, paying its full grid rebuild and diff — 70-125 ms per keystroke
in every confirm, picker and viewer in the app. There is nothing in that
placement we cannot do: an unanchored, unsized float is centred. So the
frame source composites the boxes itself, cell-accurately, memoized per
row; the rows a box does not reach come back as the same strings, which
is what lets the row diff skip them. The busy overlay, a content-sized
window the float container measures, is still prompt_toolkit's to place.

The row writer also stops writing entirely when the frame is unchanged —
an empty write leaves the panel's damage region empty, so the display
never flushes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-08-09 05:42:34 -04:00
1 parent c9aa9a410d
commit 2decbcf754
7 files changed
+472 -37

No files matched your search

+12
View File
@@ -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()
+73 -2
View File
@@ -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()
+67 -1
View File
@@ -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.
+58 -23
View File
@@ -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."""
+24 -9
View File
@@ -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]]:
+94 -2
View File
@@ -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)
+144
View File
@@ -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"]