Lazy TUI, the rest: watched state, incremental layout, terminal scroll, row reuse, event-driven loop

Closes every gap listed under "Lazy rendering gaps" in tui.md.

State a panel keeps in a list, dict or set (nested, depth cap 4) or a TuiStyle is watched, so an in-place mutation repaints the panel with no touch(). Layout arranges only the subtrees that asked, from the nearest ancestor with a fixed size, and a touched auto-sized panel is measured again so text that grows relays out; app.verify checks every partial pass against a full arrange. A log that scrolls moves the terminal's rows (DECSTBM, IND, RI) when an exact cost count says it writes less, and keeps each line's converted row (cap 4096), so a 3-line scroll at 200x50 writes 1.8 KB instead of 7.9 KB and a line appended while pinned costs 0.6 ms. The main loop sleeps until a timer, an animation, a half-read key or a wake() (touch from any thread, pty, SIGWINCH, quit, end of input): 5 s idle at 200x50 is 7 loop iterations and 4 ms CPU instead of 147 and 20 ms. The chat composer resizes from a TuiChange event and its spinner timer only exists during a turn, instead of 0.2 s and 0.08 s polls.

Found by the new random-tree storm and fixed: a padding, border or alignment change that left the rect the same left stale cells (it was in v1.34.0); a bordered, padded log built a pad row too wide. Known and documented, not changed: animate() on x, y, w, h cannot work because positions and sizes read ints only.

Tests: TuiObservedTests (every mutator), TuiLayoutTests (random trees, partial equals full), TuiScrollTests (VT replay, overlays, resizes), TuiLoopTests (the real _main on pipes: wakeups, latency, timers, signals); twelve mutants of the new code were each caught. VERSION is left to the pending 1.35.0 work in the same file.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 17:54:30 +02:00
co-authored by Claude Sonnet 5.5
parent 46682d42c3
commit f22d15a2f7
4 changed files with 957 additions and 138 deletions
+1 -1
View File
@@ -340,7 +340,7 @@ File changes render as a unified diff right inside the call box: line numbers, g
## TUI framework
The interactive UI toolkit lives in `tai.py` itself (stdlib-only, no dependencies): a retained, DOM-like panel tree with rows/cols flow, percent/`fr`/`auto` sizing, docking, named z-layers, a lazy compositor (per-panel cell cache, front-to-back occlusion, damage rects; an unchanged frame writes nothing), SGR mouse, bubbling events, Tab/Shift+Tab focus with modal trap, tick animations, and live pty sub-terminals behind a reimplemented VT screen. `/demo` (or `./tai.py --demo`) tours every widget; `./tai.py --demo-shot` prints one composed demo frame as plain text. `./tai.py --tui` runs the fullscreen Telegram-style chat UI (bots sidebar, transcript, input, status bar) with REPL parity; `./tai.py --theme NAME` picks a theme (`dark`, `light`, `nord`, `gruvbox`, `ansi`, or `TAI_THEME`). Full reference: [tui.md](tui.md); design background: [research/tui-framework-design/conclusions/retained-dom-compositor-tui.md](research/tui-framework-design/conclusions/retained-dom-compositor-tui.md), [chat layout](research/tui-framework-design/conclusions/messaging-layout-chat-scene.md), [theming](research/tui-framework-design/conclusions/palette-role-theming.md).
The interactive UI toolkit lives in `tai.py` itself (stdlib-only, no dependencies): a retained, DOM-like panel tree with rows/cols flow, percent/`fr`/`auto` sizing, docking, named z-layers, a lazy pipeline (watched state, per-panel cell cache, incremental layout, front-to-back occlusion, damage rects, terminal scroll, an event-driven loop that sleeps while nothing changes; an unchanged frame writes nothing), SGR mouse, bubbling events, Tab/Shift+Tab focus with modal trap, tick animations, and live pty sub-terminals behind a reimplemented VT screen. `/demo` (or `./tai.py --demo`) tours every widget; `./tai.py --demo-shot` prints one composed demo frame as plain text. `./tai.py --tui` runs the fullscreen Telegram-style chat UI (bots sidebar, transcript, input, status bar) with REPL parity; `./tai.py --theme NAME` picks a theme (`dark`, `light`, `nord`, `gruvbox`, `ansi`, or `TAI_THEME`). Full reference: [tui.md](tui.md); design background: [research/tui-framework-design/conclusions/retained-dom-compositor-tui.md](research/tui-framework-design/conclusions/retained-dom-compositor-tui.md), [chat layout](research/tui-framework-design/conclusions/messaging-layout-chat-scene.md), [theming](research/tui-framework-design/conclusions/palette-role-theming.md).
## Configuration
+787 -68
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -5616,12 +5616,12 @@ class TuiScrollTests(unittest.TestCase):
self.assertEqual(app.compositor.stats["scrolled"], before)
compositor_scroll = tai.TuiCompositor.scroll
def broken(self, shifts, fresh, cols, rows, out):
def broken(self, stack, shifts, fresh, cols, rows, out):
believed = [list(row) for row in self.last]
strips = compositor_scroll(self, shifts, fresh, cols, rows, out)
if strips:
before = self.stats["scrolled"]
compositor_scroll(self, stack, shifts, fresh, cols, rows, out)
if self.stats["scrolled"] != before:
self.last[:] = believed
return strips
tai.TuiCompositor.scroll = broken
try:
+165 -65
View File
@@ -46,7 +46,8 @@ buttons, Esc closes dialogs, `q` quits demo apps, Ctrl+C always quits.
- **Layout.** Two phases: `measure()` (intrinsic content size, chrome
excluded) then `arrange()` (absolute rects plus clip rects). Docked
children carve edges first, flow children fill the remainder, and
positioned children (x/y given) overlay out of flow. See sizes below.
positioned children (x/y given) overlay out of flow. A pass arranges
only the subtrees that asked, see "Lazy rendering". See sizes below.
- **Compositor.** Lazy, see "Lazy rendering" below: painted cells are
cached per panel, a frame is built front to back so every cell is
written once, and only damaged rects are recomposed and diffed against
@@ -58,64 +59,126 @@ buttons, Esc closes dialogs, `q` quits demo apps, Ctrl+C always quits.
only hands over its stack; the grid is built by the first click or
wheel event after it, so frames without mouse input never pay for it.
Clicks resolve topmost-first inside clip rects.
- **Loop.** One `asyncio` loop. stdin and sub-terminal ptys are loop readers (and writers while input is queued) that dispatch as bytes arrive; a 30 fps frame clock (`asyncio.sleep`) advances timers, animations and painting. `offload(work, done)` runs blocking work on its own daemon thread and calls `done(result, error)` back on the loop via `call_soon_threadsafe`; without a running loop (headless tests) it runs inline. An exception in a loop callback is kept, the terminal restores, then it is re-raised.
- **Loop.** One `asyncio` loop. stdin and sub-terminal ptys are loop readers (and writers while input is queued) that dispatch as bytes arrive. The loop is event-driven: it sleeps until the next timer, the next animation or `on_tick` frame (30 fps, only while something animates or ticks), a half-read key sequence, or a `wake()`, never longer than `TUI_IDLE_MAX` (1 s heartbeat). A touch from any thread, a pty read, SIGWINCH, a new timer or animation, `quit()` and end of input all call `wake()`. Paints are at least one frame apart; input paints immediately. `offload(work, done)` runs blocking work on its own daemon thread and calls `done(result, error)` back on the loop via `call_soon_threadsafe`; without a running loop (headless tests) it runs inline. An exception in a loop callback is kept, the terminal restores, then it is re-raised.
Dirty flags skip idle frames entirely. Timers (`after`/`every`),
animations, widget `on_tick`, and terminal polling all run on the
same clock. Terminal state (raw mode, alt screen, mouse, cursor,
animations, widget `on_tick`, and terminal polling run in the same
loop; the ticker and terminal lists are cached per tree change, not
walked per frame. Terminal state (raw mode, alt screen, mouse, cursor,
SIGWINCH handler, child ptys) restores in a `finally`.
## Lazy rendering
Rule: nothing is computed that is not visible, and nothing is recomputed
that did not change. Four layers, each with a hard cap:
that did not change. Eight layers, each with a hard cap:
1. **Per-panel cell cache.** `paint()` output is converted once to interned
`(char, style key)` cells and kept on the panel (`_cells`, at most the
panel's own area). A panel repaints only after `touch()`. Writing any
attribute of a mounted panel is the change signal: `TuiPanel.__setattr__`
calls `touch()` (repaint this panel) or, for geometry names (`x y w h flow
dock layer z offset align valign padding gap border visible`),
`dirty_layout(paint=False)`. A scalar or an equal `TuiStyle` written back
unchanged is not a change. In-place mutation (`list.append`, `set.add`)
must call `self.touch()`; every built-in widget does. The anonymous
`app.dirty_paint()` / `app.dirty_layout()` still mean "everything may have
changed" and drop every cache, so an unaudited caller is slow, never wrong.
`set_focus` touches the old and new focus panel (border and cursor style
depend on focus). Cache invalidation also covers resize (`aw`/`ah` change),
re-attach (`_bind_app`), and a broken widget (its `paint()` raising covers
nothing that frame and is retried).
2. **Front-to-back coverage.** `TuiCompositor.fill` walks the stack from the
calls `touch()` (repaint this panel), plus `relayout()` for geometry names
(`x y w h flow dock offset align valign padding gap border`), `relayout()`
with restack for `visible`, and `restack()` for `layer` and `z`. A scalar or
an equal `TuiStyle` written back unchanged is not a change. Names starting
`on_` are handlers, not state. The anonymous `app.dirty_paint()` /
`app.dirty_layout()` still mean "everything may have changed" and drop
every cache, so an unaudited caller is slow, never wrong. `set_focus`
touches the old and new focus panel (border and cursor style depend on
focus). Invalidation also covers resize (`aw`/`ah` change), re-attach
(`_bind_app`), and a broken widget (its `paint()` raising covers nothing
that frame and is retried).
2. **Watched state.** A list, dict or set assigned to a public attribute of a
panel is stored as an observing copy (`TuiList`, `TuiDict`, `TuiSet`,
nested plain containers included, down to `TUI_OBSERVE_DEPTH` 4): every
in-place mutation (`append`, item assignment, `update`, `|=`, ...) touches
the owner, so no widget needs `touch()` after mutating its own containers.
The panel keeps a copy: a list you keep a reference to is not the panel's
list, and writes to your original do not reach it. Copies and pickles are
plain containers. A `TuiStyle` assigned to a panel registers that panel;
writing a field of the style later repaints every panel using it. What is
not watched: underscore attributes, `children` (use `add`/`remove`), and
state in other objects (a `TuiSpan`, your own class): assign a new value
or call `touch()`. `TAI_TUI_VERIFY=1` finds the misses.
3. **Front-to-back coverage.** `TuiCompositor.fill` walks the stack from the
topmost panel down, keeping per-row covered intervals. Each panel copies
only its uncovered intervals (list slice copies, no per-cell Python), and a
panel with no uncovered cell is never painted at all: the root background
under a full-screen layout costs nothing. No overdraw, no sort per frame
(the stack is built once per layout).
3. **Damage rects.** `touch()` records the panel; a layout pass diffs each
panel's `(ax, ay, aw, ah, clip)` against the previous pass and records old
and new rects of what moved or resized; a different stack (panel added,
removed, reordered, shown, hidden) means the whole screen. Only the merged
rects are recomposed and compared with the persistent screen
(`TuiCompositor.last`). More than `TUI_DAMAGE_RECTS` (64) rects means the
whole screen. Damage is recorded under a lock because turn threads touch
panels while the loop composes, and the dirty flags are cleared at the
start of a pass, so a touch during a frame schedules the next frame.
4. **Quiet output.** Bytes are built from the cell keys alone
(the stack is rebuilt only when it can have changed).
4. **Damage rects.** `touch()` records the panel; a layout pass diffs the
`(ax, ay, aw, ah, clip)` of every panel it arranged against the previous
pass and records old and new rects of what moved or resized. A stack that
gained or lost panels damages those panels' rects; a stack whose remaining
panels changed order is the whole screen. Only the merged rects are
recomposed and compared with the persistent screen (`TuiCompositor.last`).
More than `TUI_DAMAGE_RECTS` (64) rects means the whole screen. Damage is
recorded under a lock because turn threads touch panels while the loop
composes, and the dirty flags are cleared at the start of a pass, so a
touch during a frame schedules the next frame.
5. **Incremental layout.** `relayout(panel)` (own size, position or chrome
changed) arranges the panel's parent again; `relayout(panel, inner=True)`
(children or content changed) arranges the panel again. Either climbs only
while the panel is `content_sized()` (a `w` or `h` of `"auto"`, because the
parent's `measure()` reads both axes of every child) and stops at the first
panel with a fixed, percent or fr size on both axes: a layout boundary.
Requests from many panels collapse to the outermost; more than
`TuiApp.LAYOUT_STARTS` (64) requests, an anonymous `dirty_layout()`, a
resize and the first pass arrange everything. A touched `auto` panel is
measured again at the next pass and relays out when its size changed (text
that grows, a log that gains lines); if only a non-auto axis changed, just
that panel is arranged again (its scroll clamp). `z`/`layer` changes
arrange nothing. `TAI_TUI_VERIFY=1` arranges the whole tree after every
partial pass and raises `TuiError` naming the first panel that differs.
6. **Terminal scroll.** A panel with `shifts = True` (`TuiLog` and
subclasses) records the net change of `scroll_y` as a hint. The compositor
checks the hint against the real cells: when the new rows of the content
rect equal the old rows moved by the hint, it emits a scroll of those rows
(`CSI top;bottom r`, `ESC D` or `ESC M` per row, `CSI r`, no capability
needed beyond VT100), moves the persistent screen the same way, and
recomposes the strips left and right of the rect (the scroll moved them
too) so the ordinary diff repairs them. It is only taken when the estimate
says it writes less than rewriting the cells (at least 2 rows must match;
cells and cursor moves of the rect and of the repaired strips are counted
exactly, with a 25% margin for style switches),
at most `TUI_SCROLLS` (4) per frame, and never on a whole-screen frame. A
wrong hint costs nothing but the check; correctness never depends on it,
because the diff that follows compares every cell that may differ.
7. **Row reuse.** `TuiPanel.paint_cells()` is what the compositor calls;
the default converts `paint()`. `TuiLog` (and `TuiChatLog`, which adds the
scrollbar cell) overrides it: each line's converted row is kept, looked up
by the identity of its span list and checked against scroll_x, width and
style (cap `TUI_ROW_MEMO` 4096 rows, cleared on overflow). A scroll or a
repaint converts only lines it has not shown at this width. This is safe
because log lines are append-only span lists; a subclass that overrides
`paint()` or `content_rows()` gets the plain path. Other widgets repaint
whole on a touch: the largest of them is a few rows and costs under 0.1 ms.
8. **Quiet output.** Bytes are built from the cell keys alone
(`TuiStyle.sgr_from_key`, memoized, `TUI_SGR_CAP` 4096, dropped on theme
change). A frame in which no cell differs and the cursor stayed returns
`""` and writes nothing. `measure()` is memoized per layout pass
(`TuiPanel.measured()`; `TuiTerminal` opts out because its measure follows
its screen size), and `TuiStyle.key()` is cached and dropped on any field
write.
write. The hit map is built on the first click or wheel event after a layout.
Event-driven composer: `TuiTextBox` emits `TuiChange(rows)` (`on_change`,
on the loop thread) when its number of lines changes; the chat scene sizes the
composer from it instead of a 0.2 s timer, and its spinner timer exists only
while a turn is running. An idle chat has one repeating timer (the 1 Hz
status line).
Correctness guard: the original whole-tree algorithm is kept as
`TuiCompositor.compose_reference`. `TuiApp.verify` (env `TAI_TUI_VERIFY=1`)
recomposes from scratch after every frame and raises `TuiError` naming the
first diverging cell and the panel on top of it. `test_tai.TuiLazyTests` runs
a seeded 400-step storm (keys, wheel, clicks, resizes, themes, dialogs, bot
switches, timers) with it on, replays the emitted bytes through `TuiVtScreen`
and compares with a fresh render, and mutation-checks that dropping damage or
geometry rects is caught. Run the whole suite with `TAI_TUI_VERIFY=1` after
touching paint, layout, or the compositor.
first diverging cell and the panel on top of it, and checks every partial
layout against a full arrange. `test_tai.TuiLazyTests` runs a seeded 400-step
storm (keys, wheel, clicks, resizes, themes, dialogs, bot switches, timers)
with it on, replays the emitted bytes through `TuiVtScreen` and compares with
a fresh render, and mutation-checks that dropping damage or geometry rects is
caught. `TuiLayoutTests` runs random trees (every size spec, docks, floats,
borders, padding, visibility, adds, removes, text growth) through partial
layouts; `TuiScrollTests` replays scroll storms with overlays and resizes
through the VT emulator; `TuiObservedTests` covers every mutator;
`TuiLoopTests` runs the real `_main` on pipes. Run the whole suite with
`TAI_TUI_VERIFY=1` after touching paint, layout, or the compositor.
## Geometry
@@ -144,7 +207,8 @@ and padding add outside the given w/h (CSS `content-box`).
## Events
`TuiEvent` subclasses: Key, Click, Scroll, Focus, Blur, Resize, Tick,
Mount, Unmount, Select, Press, Close, Paste. Dispatch calls `on_<name>` on
Mount, Unmount, Select, Press, Close, Paste, Change (a text box's line
count changed, `rows`; does not bubble). Dispatch calls `on_<name>` on
the target, then walks base classes implicitly (plain attribute
lookup), then bubbles to the parent while `bubble` is true.
`event.stop()` halts everything. Custom events: subclass `TuiEvent`,
@@ -395,31 +459,60 @@ s) means deny / no answer, never a hang. Without a `prompter` (CLI,
Telegram) behavior is unchanged.
Measured on one machine only (the development box, Python 3.14, chat scene
with a 40-exchange transcript, 19 panels, cost of `dirty -> frame_bytes()`;
the 400-exchange transcript costs the same, history length does not matter).
Before the lazy pipeline, every dirty frame cost the same 4.0-4.6 ms at
200x50 (1.5 ms at 80x24) whatever had changed:
with a 40-exchange transcript, 19 panels, cost of `dirty -> frame_bytes()`,
mean of 50, three runs; the 400-exchange transcript costs the same, history
length does not matter). "Before" is v1.33.1, whose every dirty frame cost
4.0-4.6 ms at 200x50 (1.5 ms at 80x24) whatever had changed:
| frame at 200x50 | before | lazy |
| frame at 200x50 | before | v1.34.0 | now |
|---|---|---|---|
| one typed character | 4.6 ms | 0.08-0.13 ms | 0.09 ms |
| 1 Hz status refresh | 4.9 ms | 0.05-0.08 ms | 0.05 ms |
| Tab moves focus | 4.3 ms | 0.45 ms | 0.42 ms |
| transcript scroll, 2 frames | 9.5 ms | 2.6-2.8 ms | 1.3 ms |
| one line appended while pinned | n/a | 0.78 ms | 0.6-0.7 ms |
| anonymous `dirty_paint()` (everything) | 4.0 ms | 1.2 ms | 0.8 ms |
| anonymous `dirty_layout()` | 5.4 ms | 1.6-1.8 ms | 1.2 ms |
| sidebar hidden and shown, 2 frames | n/a | 4.0-4.5 ms | 4.3-4.7 ms |
The sidebar toggle is a genuine whole-screen change (every panel moves) and
did not get faster. Bytes written per frame with varied transcript text
(random words, so rows differ; a transcript of repeated rows gives the
scroll less to win and the estimate then declines it):
| frame | size | v1.34.0 | now |
|---|---|---|---|
| 3-line scroll | 80x24 | 1388 B | 1354 B |
| 3-line scroll | 120x40 | 3585 B | 1576 B |
| 3-line scroll | 200x50 | 7905 B | 1822 B |
| 1-line scroll | 80x24 | 1327 B | 822 B |
| 1-line scroll | 120x40 | 3223 B | 883 B |
| 1-line scroll | 200x50 | 6496 B | 966 B |
At 80x24 the sidebar is 30 of 80 columns and the scroll moves it too, which
the strip repair pays for, so a 3-line scroll is a wash there; it wins as the
transcript gets wider.
The loop, 5 s of the real `_main` on pipes at 200x50 (CPU is process time
including the first full frame):
| 5 s | v1.34.0 | now |
|---|---|---|
| one typed character | 4.6 ms | 0.08-0.15 ms |
| 1 Hz status refresh | 4.9 ms | 0.05-0.07 ms |
| Tab moves focus | 4.3 ms | 0.5-0.7 ms |
| transcript scroll, 2 frames | 9.5 ms | 2.5-2.8 ms |
| anonymous `dirty_paint()` (everything) | 4.0 ms | 1.5 ms |
| relayout | 5.4 ms | 1.5-2.0 ms |
| idle: loop iterations / CPU | 147 / 20.4 ms | 7 / 4.0 ms |
| a running turn: loop iterations / CPU | 147 / 32.6 ms | 14 / 14.8 ms |
Typing at 80x24 went from 1.5 ms to 0.06 ms. Simulated 5 s of the main loop
at 200x50: idle went from 4 frames and 18 ms CPU to 0 frames and 1 ms; a
running turn from 29 ms to 7 ms. These are CPU numbers: the app was never
slow enough to feel, the gain is battery, SSH bandwidth and headroom. Not
measured: other machines, slow terminals, 300x70. Guarded by
Latency on pipes (no real terminal): key to frame 0.2-0.5 ms, a touch from
another thread to frame 0.2 ms. These are CPU numbers: the app was never slow
enough to feel, the gain is battery, SSH bandwidth and headroom. Not
measured: other machines, real terminals' scroll speed, 300x70. Guarded by
`test_frame_stays_inside_budget_on_large_screen` (full repaint under one
frame) and `test_typing_frame_budget_at_200x50` (5 ms ceiling, 30x slack).
Live in a pty: a resize reflows to a correct frame in 18-60 ms, a burst of 30
resizes ends on the right frame, key to screen averages 23 ms (input paints
immediately, no longer waiting for the next tick; this live figure predates
the lazy pipeline and was not re-measured).
frame), `test_typing_frame_budget_at_200x50` (5 ms ceiling, 30x slack),
`test_an_idle_screen_wakes_almost_never` and the latency tests in
`TuiLoopTests`. Live in a pty: a resize reflows to a correct frame in
18-60 ms, a burst of 30 resizes ends on the right frame, and the demo ran
150 random keys, mouse events and resizes with the oracle on without a
divergence; key to screen on a real terminal was last measured at 23 ms,
before the lazy pipeline, and was not re-measured.
## Terminal widget notes
@@ -454,14 +547,21 @@ the widget shows a plain error line instead of crashing.
pills or per-chat times (no honest data source), no attach/voice
or reply-preview (no function behind them), tool-call chips are not
collapsible and show no results, no per-token streaming.
- Lazy rendering gaps: a transcript scroll still rewrites every changed cell
(no terminal scroll regions, so a 3-line scroll costs ~1.3 ms at 200x50);
the main loop still wakes at 30 fps and walks the tree for `on_tick`,
focus and terminal sync (0.01 ms at 19 panels, linear in panels); a damage
rect covering a whole panel repaints that whole panel (no per-row paint
cache); a layout pass is still a full `arrange` of the tree; a widget that
mutates itself in place without `touch()` shows stale cells until
something else repaints it (`TAI_TUI_VERIFY=1` finds these).
- Lazy rendering gaps: a terminal scroll moves whole rows, so the strips
beside a scrolled panel are repaired by the diff (no left/right margins,
`DECLRMM` is not probed); only `TuiLog` and its subclasses reuse converted
rows or hint scrolls, other widgets repaint whole on a touch (all are a
few rows); the cost model that accepts a scroll counts cells and cursor
moves, not style switches, hence a 25% margin; layout climbs to the first
panel with a fixed size on both axes, so a tree of all-`auto` panels
arranges from the root; state held in objects other than list, dict, set
and `TuiStyle` (a `TuiSpan`, a class of yours) is not watched and needs a
new assignment or `touch()` (`TAI_TUI_VERIFY=1` finds the misses); the loop
still wakes once a second as a heartbeat.
- `animate()` tweens through floats, and positions and sizes only read ints
(`_place_pos`, `_fixed_len`): animating `x`, `y`, `w` or `h` places the
panel at 0 or sizes it 0 instead of moving it. Found while testing the
loop; animating other numeric attributes (`value`, `scroll_y`) works.
- No per-widget async tasks; blocking work belongs in threads that
post back via timers.
- The VT parser drops provably-malformed tails past 64KB and caps