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:
@@ -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
|
||||
|
||||
|
||||
+4
-4
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user