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:
1 parent
c9aa9a410d
commit
2decbcf754
7 files changed
+472
-37
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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."""
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
Reference in new issue
Block a user