diff --git a/README.md b/README.md index 9ee2f61..3c98d14 100644 --- a/README.md +++ b/README.md @@ -322,7 +322,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 cuts/chops occlusion compositor, 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 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). ## Configuration diff --git a/tai.py b/tai.py index bee31f3..5b5a2b1 100755 --- a/tai.py +++ b/tai.py @@ -97,7 +97,7 @@ def _fromisoformat(text): return parsed.replace(tzinfo=tzinfo) raise ValueError("invalid datetime, use ISO like 2026-10-08T09:00") -VERSION = "1.33.1" +VERSION = "1.34.0" WORKER_STEPS = 12 CREATE_SKILL_STEPS = 40 FORK_MAX_DEPTH = 2 @@ -10135,16 +10135,29 @@ class TuiStyle: "bright_cyan"), 256-color ints, "#rrggbb" strings, or (r, g, b) tuples.""" def __init__(self, fg=None, bg=None, bold=False, dim=False, underline=False, reverse=False, strike=False): - self.fg = fg - self.bg = bg - self.bold = bold - self.dim = dim - self.underline = underline - self.reverse = reverse - self.strike = strike + fields = self.__dict__ + fields["fg"] = fg + fields["bg"] = bg + fields["bold"] = bold + fields["dim"] = dim + fields["underline"] = underline + fields["reverse"] = reverse + fields["strike"] = strike + + def __setattr__(self, name, value): + fields = self.__dict__ + fields[name] = value + fields.pop("_key", None) def key(self): - return (TuiStyle.color_key(self.fg), TuiStyle.color_key(self.bg), self.bold, self.dim, self.underline, self.reverse, self.strike) + """Hashable identity of the look; cached, and any later write to a + field drops the cache.""" + fields = self.__dict__ + key = fields.get("_key") + if key is None: + key = fields["_key"] = (TuiStyle.color_key(self.fg), TuiStyle.color_key(self.bg), + self.bold, self.dim, self.underline, self.reverse, self.strike) + return key def sgr(self): parts = ["0"] @@ -10181,6 +10194,30 @@ class TuiStyle: merged.strike = merged.strike or other.strike return merged + @staticmethod + def sgr_from_key(key): + """The sgr() of the style that produced key(): lets the compositor + emit bytes from cell keys alone, with no style object kept alive.""" + fg, bg, bold, dim, underline, reverse, strike = key + parts = ["0"] + if bold: + parts.append("1") + if dim: + parts.append("2") + if underline: + parts.append("4") + if reverse: + parts.append("7") + if strike: + parts.append("9") + fg_sgr = TuiStyle.color_sgr(fg, False) + if fg_sgr: + parts.append(fg_sgr) + bg_sgr = TuiStyle.color_sgr(bg, True) + if bg_sgr: + parts.append(bg_sgr) + return "\x1b[" + ";".join(parts) + "m" + @staticmethod def color_key(color): if color is None or isinstance(color, int) or isinstance(color, str): @@ -10356,6 +10393,13 @@ def tui_pad_spans(spans, width, fill_style): return tui_slice_spans(spans, 0, width, fill_style) +def tui_span_width(spans): + width = 0 + for span in spans: + width += len(span.text) + return width + + def tui_slice_spans(spans, start, length, fill_style): """Columns [start, start+length) of a span row, padded with fill_style to exactly length. Works on whole spans, never per character; neighbors @@ -10365,33 +10409,41 @@ def tui_slice_spans(spans, start, length, fill_style): end = start + length out = [] last = last_key = None - - def put(text, style): - nonlocal last, last_key - if style is last: - out[-1].text += text - return - key = style.key() - if out and key == last_key: - out[-1].text += text - else: - out.append(TuiSpan(text, style)) - last_key = key - last = style - pos = 0 for span in spans: if pos >= end: break - nxt = pos + len(span.text) + text = span.text + size = len(text) + nxt = pos + size if nxt > start: - piece = span.text[max(start, pos) - pos:min(end, nxt) - pos] + cut_lo = start - pos if start > pos else 0 + cut_hi = end - pos if end < nxt else size + piece = text if cut_lo == 0 and cut_hi == size else text[cut_lo:cut_hi] if piece: - put(piece, span.style) + style = span.style + if style is last: + out[-1].text += piece + else: + key = style.key() + if out and key == last_key: + out[-1].text += piece + else: + out.append(TuiSpan(piece, style)) + last_key = key + last = style pos = nxt covered = min(pos, end) - start if pos > start else 0 if covered < length: - put(" " * (length - covered), fill_style) + fill = " " * (length - covered) + if fill_style is last: + out[-1].text += fill + else: + key = fill_style.key() + if out and key == last_key: + out[-1].text += fill + else: + out.append(TuiSpan(fill, fill_style)) return out @@ -10407,6 +10459,15 @@ class TuiPanel: _seq_counter = [0] + # Attribute writes on a mounted panel are the change signal. Geometry and + # structure names relayout (and the layout pass diffs rects); the layout + # outputs and links are bookkeeping; everything else repaints this panel + # only. A scalar written back unchanged is not a change. + _RELAYOUT = frozenset(("x", "y", "w", "h", "flow", "dock", "layer", "z", "offset", "align", "valign", + "padding", "gap", "border", "visible")) + _QUIET = frozenset(("ax", "ay", "aw", "ah", "clip", "parent", "app", "children", "seq")) + _SCALARS = (int, float, str, bool, type(None), tuple) + def __init__(self, id=None, title="", x=None, y=None, w="auto", h="auto", flow="rows", dock=None, layer=None, z=0, offset=None, align="left", valign="top", padding=0, gap=0, border=False, @@ -10415,6 +10476,8 @@ class TuiPanel: visible=True, focusable=False, enabled=True, modal=False, scrollable=False): TuiPanel._seq_counter[0] += 1 + self._cells = None + self._cells_size = None self.seq = TuiPanel._seq_counter[0] self.id = id self.title = title or "" @@ -10454,6 +10517,32 @@ class TuiPanel: self.clip = (0, 0, 0, 0) self._pending_anims = [] + def __setattr__(self, name, value): + fields = self.__dict__ + old = fields.get(name, self) + object.__setattr__(self, name, value) + if name[0] == "_" or name in TuiPanel._QUIET: + return + app = fields.get("app") + if app is None: + return + if type(old) is type(value): + if type(value) in TuiPanel._SCALARS: + if old == value: + return + elif type(value) is TuiStyle and old.key() == value.key(): + return + if name in TuiPanel._RELAYOUT: + app.dirty_layout(paint=False) + else: + self.touch() + + def touch(self): + """Mark this panel's painted cells stale; only its rect repaints.""" + self._cells = None + if self.app is not None: + self.app.damage(self) + @staticmethod def norm_padding(padding): if isinstance(padding, (tuple, list)): @@ -10471,7 +10560,7 @@ class TuiPanel: if self.app is not None: child._bind_app(self.app) if self.app is not None: - self.app.dirty_layout() + self.app.dirty_layout(paint=False) return children[0] if len(children) == 1 else children def remove(self, child): @@ -10480,7 +10569,7 @@ class TuiPanel: child.parent = None child._unbind_app() if self.app is not None: - self.app.dirty_layout() + self.app.dirty_layout(paint=False) return child def clear(self): @@ -10534,6 +10623,7 @@ class TuiPanel: def _bind_app(self, app): self.app = app + self._cells = None for anim in self._pending_anims: app.animations.append(anim) self._pending_anims = [] @@ -10598,6 +10688,21 @@ class TuiPanel: return max(0, content_len) return 0 + _measure_memo = None + memoize_measure = True + + def measured(self): + """measure() once per layout pass: intrinsic sizes depend on content + only, so the repeated asks of one pass (parent, flow, cross axis, + scroll clamp) share one answer. Outside a pass, a plain measure().""" + memo = TuiPanel._measure_memo + if memo is None or not self.memoize_measure: + return self.measure() + size = memo.get(self) + if size is None: + size = memo[self] = self.measure() + return size + def measure(self): """Intrinsic content size (w, h), chrome excluded.""" widths = [] @@ -10606,7 +10711,7 @@ class TuiPanel: flow_h = 0 shown = [child for child in self.children if child.visible] for child in shown: - cw, ch = child.measure() + cw, ch = child.measured() outer_w = child._fixed_len(child.w, 0, cw) + child.chrome_w() outer_h = child._fixed_len(child.h, 0, ch) + child.chrome_h() if child.dock or child.x is not None or child.y is not None: @@ -10643,7 +10748,7 @@ class TuiPanel: self._place_dock(child, box) if self.flow is None: for child in flowing: - cw, ch = child.measure() + cw, ch = child.measured() child_w = child._fixed_len(child.w, box[2], cw) + child.chrome_w() child_h = child._fixed_len(child.h, box[3], ch) + child.chrome_h() child.arrange(box[0], box[1], child_w, child_h, self._content_clip()) @@ -10667,7 +10772,7 @@ class TuiPanel: return TuiPanel.intersect(self.clip, inner) def _place_dock(self, child, box): - cw, ch = child.measure() + cw, ch = child.measured() side = (child.dock or "").lower() if side in ("top", "bottom"): child_h = child._fixed_len(child.h, box[3], ch) + child.chrome_h() @@ -10688,7 +10793,7 @@ class TuiPanel: def _place_flow(self, flowing, box, horizontal): total = box[2] if horizontal else box[3] - measures = [child.measure() for child in flowing] + measures = [child.measured() for child in flowing] sizes = [] fr_total = 0.0 fixed = 0 @@ -10738,7 +10843,7 @@ class TuiPanel: child.arrange(at_x, at_y, child_w, child_h, self._content_clip()) def _cross_size(self, child, box, horizontal): - cw, ch = child.measure() + cw, ch = child.measured() if horizontal: spec = child.h total, content = box[3], ch @@ -10767,7 +10872,7 @@ class TuiPanel: return start def _place_float(self, child, box): - cw, ch = child.measure() + cw, ch = child.measured() child_w = child._fixed_len(child.w, box[2], cw) + child.chrome_w() child_h = child._fixed_len(child.h, box[3], ch) + child.chrome_h() at_x = box[0] + self._place_pos(child.x, box[2], child_w) @@ -10789,7 +10894,7 @@ class TuiPanel: return 0 def _clamp_scroll(self): - cw, ch = self.measure() + cw, ch = self.measured() self.scroll_x = max(0, min(self.scroll_x, max(0, cw - self._content_w()))) self.scroll_y = max(0, min(self.scroll_y, max(0, ch - self._content_h()))) @@ -10804,7 +10909,7 @@ class TuiPanel: self.scroll_y += dy self._clamp_scroll() if self.app is not None: - self.app.dirty_paint() + self.touch() def on_scroll(self, event): if self.scrollable and self.enabled: @@ -10831,26 +10936,31 @@ class TuiPanel: height = self._content_h() rows = [] content = self.content_rows() + scroll_x = self.scroll_x + scroll_y = self.scroll_y for row_pos in range(height): - source = content[self.scroll_y + row_pos] if self.scroll_y + row_pos < len(content) else [] - rows.append(tui_slice_spans(source, self.scroll_x, width, self.style)) + source = content[scroll_y + row_pos] if scroll_y + row_pos < len(content) else [] + if scroll_x == 0 and source and tui_span_width(source) == width: + rows.append(source) + else: + rows.append(tui_slice_spans(source, scroll_x, width, self.style)) pad_top, pad_right, pad_bottom, pad_left = self.padding edge = 1 if self.border else 0 if pad_left or pad_right: - widened = [] - for spans in rows: - cells = tui_flatten(spans) - row_cells = [(" ", self.style)] * pad_left + cells + [(" ", self.style)] * pad_right - widened.append(tui_runs(row_cells)) - rows = widened + left = [TuiSpan(" " * pad_left, self.style)] if pad_left else [] + right = [TuiSpan(" " * pad_right, self.style)] if pad_right else [] + rows = [left + spans + right for spans in rows] inner_w = width + pad_left + pad_right - blank = tui_runs([(" ", self.style)] * inner_w) + blank = [TuiSpan(" " * inner_w, self.style)] if inner_w else [] rows = [list(blank) for _row in range(pad_top)] + rows + [list(blank) for _row in range(pad_bottom)] if edge: rows = self._add_border(rows, inner_w) while len(rows) < self.ah: - rows.append(tui_runs([(" ", self.style)] * self.aw)) - return [tui_pad_spans(spans, self.aw, self.style) for spans in rows[:self.ah]] + rows.append([TuiSpan(" " * self.aw, self.style)] if self.aw else []) + rows = rows[:self.ah] + if self.aw < self.chrome_w() + 2: + rows = [tui_pad_spans(spans, self.aw, self.style) for spans in rows] + return rows def _add_border(self, rows, inner_w): tl, tr, bl, br = "╭╮╰╯" if self.rounded else "┌┐└┘" @@ -10948,18 +11058,54 @@ TUI_PASTE_CAP = 1 << 20 TUI_COMPLETE_MAXH = 8 +TUI_BLANK_KEY = (None, None, False, False, False, False, False) + +TUI_BLANK = (" ", TUI_BLANK_KEY) + +TUI_POOL_KEYS = 512 + +TUI_POOL_CHARS = 4096 + +TUI_SGR_CAP = 4096 + +TUI_DAMAGE_RECTS = 64 + + +class TuiCellPool(dict): + """Interned cells of one style key: char -> (char, key). Equal cells are + the same object, so frame diffs short-circuit on identity.""" + + def __init__(self, key): + super().__init__() + self.key = key + + def __missing__(self, char): + if len(self) >= TUI_POOL_CHARS: + self.clear() + cell = self[char] = (char, self.key) + return cell + + class TuiCompositor: - """Paints panels to runs, resolves occlusion per row through cuts and - chops, and emits minimal bytes by diffing frames. One single-write - frame wrapped in synchronized-output markers: atomic where the - terminal honors it, ignored everywhere else.""" + """Lazy compositor. Panels cache their painted cells (touch() drops one), + a frame is built front to back with per-row coverage so every cell is + written once and a fully covered panel is never painted, and the screen + it believes is shown persists, so an update recomposes and diffs only the + damaged rects. compose_reference is the original full algorithm, kept as + the oracle the lazy path is verified against. Output is one single-write + frame in synchronized-output markers: atomic where the terminal honors + it, ignored everywhere else.""" def __init__(self): self.last = None - self.styles = {} + self.pool = {} + self.sgr_memo = {} + self.sgr_theme = None + self.stats = {"painted": 0, "composed": 0, "emitted": 0, "frames": 0} def invalidate(self): self.last = None + self.sgr_memo = {} def flatten(self, root): panels = [node for node in root.walk() if node.is_shown() and node.aw > 0 and node.ah > 0] @@ -10974,7 +11120,141 @@ class TuiCompositor: node = node.parent return tuple(reversed(parts)) + # -- cells ------------------------------------------------------- # + + def sgr(self, key): + theme = tui_theme() + if theme is not self.sgr_theme or len(self.sgr_memo) >= TUI_SGR_CAP: + self.sgr_memo = {} + self.sgr_theme = theme + code = self.sgr_memo.get(key) + if code is None: + code = self.sgr_memo[key] = TuiStyle.sgr_from_key(key) + return code + + def cells_from(self, painted, width): + """Span rows -> cell rows of exactly width interned cells.""" + pool = self.pool + if len(pool) >= TUI_POOL_KEYS: + pool.clear() + keys = {} + out = [] + blank = [TUI_BLANK] * width + for spans in painted: + row = [] + for span in spans: + style = span.style + key = keys.get(id(style)) + if key is None: + key = keys[id(style)] = style.key() + chars = pool.get(key) + if chars is None: + chars = pool[key] = TuiCellPool(key) + row.extend(map(chars.__getitem__, span.text)) + if len(row) < width: + row.extend(blank[:width - len(row)]) + out.append(row) + return out + + def cells_of(self, panel, cached): + """The panel's cell rows, painting only when stale. None when the + widget's paint raised: it then covers nothing this frame and never + garbles the rest.""" + size = (panel.aw, panel.ah) + if cached and panel._cells is not None and panel._cells_size == size: + return panel._cells + try: + painted = panel.paint() + except Exception: + return None + self.stats["painted"] += 1 + cells = self.cells_from(painted, panel.aw) + if cached: + panel._cells = cells + panel._cells_size = size + return cells + + # -- compose ----------------------------------------------------- # + + @staticmethod + def gaps(covered, lo, hi): + """Sub-intervals of [lo, hi) not yet claimed in this row.""" + found = [] + pos = lo + for start, end in covered: + if end <= pos: + continue + if start >= hi: + break + if start > pos: + found.append((pos, start)) + pos = end + if pos >= hi: + return found + if pos < hi: + found.append((pos, hi)) + return found + + @staticmethod + def claim(covered, lo, hi): + covered.append((lo, hi)) + covered.sort() + merged = [covered[0]] + for start, end in covered[1:]: + if start <= merged[-1][1]: + if end > merged[-1][1]: + merged[-1] = (merged[-1][0], end) + else: + merged.append((start, end)) + covered[:] = merged + + def fill(self, stack, x0, y0, x1, y1, cached): + """Cell rows for the rect [x0,x1) x [y0,y1), topmost panel first. A + panel only paints when some of its rows are still uncovered.""" + width = x1 - x0 + rows = [[TUI_BLANK] * width for _y in range(y0, y1)] + covers = [[] for _y in range(y0, y1)] + left = width * (y1 - y0) + for panel in reversed(stack): + if left <= 0: + break + cx0, cy0, cx1, cy1 = panel.clip + cx0, cy0, cx1, cy1 = max(cx0, x0), max(cy0, y0), min(cx1, x1), min(cy1, y1) + if cx0 >= cx1 or cy0 >= cy1: + continue + todo = [] + for y in range(cy0, cy1): + free = TuiCompositor.gaps(covers[y - y0], cx0, cx1) + if free: + todo.append((y, free)) + if not todo: + continue + cells = self.cells_of(panel, cached) + if cells is None: + continue + origin = panel.ax + for y, free in todo: + local = y - panel.ay + if local < 0 or local >= len(cells): + continue + source = cells[local] + target = rows[y - y0] + for start, end in free: + target[start - x0:end - x0] = source[start - origin:end - origin] + left -= end - start + TuiCompositor.claim(covers[y - y0], cx0, cx1) + self.stats["composed"] += width * (y1 - y0) + return rows + def compose(self, root, rows, cols): + """One whole frame from a fresh paint of the tree (no caches).""" + return self.fill(self.flatten(root), 0, 0, cols, rows, False) + + def compose_reference(self, root, rows, cols): + """The original whole-tree algorithm: paint everything bottom to top, + cut every row at run edges, keep the topmost run per interval. Slow + on purpose and never used for output: it is the oracle the lazy + pipeline is verified against (tests, TAI_TUI_VERIFY).""" panels = self.flatten(root) runs_by_row = {} for panel in panels: @@ -11031,30 +11311,77 @@ class TuiCompositor: col = nxt if col >= end: break - self.styles = styles return frame - def render(self, frame): - out = [TUI_SYNC_START, "\x1b[?25l"] + # -- output ------------------------------------------------------ # + + def diff_row(self, out, y, x0, new, old, current): + """Append the moves and glyphs that turn old into new for one row + segment starting at column x0. Returns the style now selected.""" + count = len(new) + col = 0 + while col < count: + if new[col] == old[col]: + col += 1 + continue + start = col + while col < count and new[col] != old[col]: + col += 1 + out.append("\x1b[%d;%dH" % (y + 1, x0 + start + 1)) + for char, key in new[start:col]: + if key != current: + out.append(self.sgr(key)) + current = key + out.append(char) + self.stats["emitted"] += col - start + return current + + def refresh(self, stack, rows, cols, rects): + """Bring the believed-shown screen up to date. rects None means the + whole screen (first frame, resize, invalidate), else the damaged + rects (x0, y0, x1, y1). Returns only the bytes that changed.""" + self.stats["frames"] += 1 + out = [] current = None + if self.last is None or len(self.last) != rows or len(self.last[0]) != cols: + self.last = [[(None, None)] * cols for _y in range(rows)] + rects = None + if rects is None: + rects = [(0, 0, cols, rows)] + for x0, y0, x1, y1 in TuiCompositor.merge_rects(rects, cols, rows): + fresh = self.fill(stack, x0, y0, x1, y1, True) + for offset, row in enumerate(fresh): + shown = self.last[y0 + offset] + if row == shown[x0:x1]: + continue + current = self.diff_row(out, y0 + offset, x0, row, shown[x0:x1], current) + shown[x0:x1] = row + return "".join(out) + + @staticmethod + def merge_rects(rects, cols, rows): + """Clamp to the screen and drop rects contained in another.""" + clamped = [] + for x0, y0, x1, y1 in rects: + x0, y0, x1, y1 = max(0, x0), max(0, y0), min(cols, x1), min(rows, y1) + if x0 < x1 and y0 < y1: + clamped.append((x0, y0, x1, y1)) + clamped.sort(key=lambda r: (r[2] - r[0]) * (r[3] - r[1]), reverse=True) + kept = [] + for rect in clamped: + if not any(big[0] <= rect[0] and big[1] <= rect[1] and big[2] >= rect[2] and big[3] >= rect[3] for big in kept): + kept.append(rect) + return kept + + def render(self, frame): + """Diff a whole frame against what is shown and wrap it for the + terminal.""" + out = [TUI_SYNC_START, "\x1b[?25l"] if self.last is None: self.last = [[(None, None) for _cell in row] for row in frame] + current = None for row_pos, (row, old_row) in enumerate(zip(frame, self.last)): - col = 0 - while col < len(row): - if row[col] == old_row[col]: - col += 1 - continue - start = col - while col < len(row) and row[col] != old_row[col]: - col += 1 - out.append("\x1b[%d;%dH" % (row_pos + 1, start + 1)) - for char, key in row[start:col]: - if key != current: - style = self.styles.get(key) - out.append(style.sgr() if style is not None else "\x1b[0m") - current = key - out.append(char) + current = self.diff_row(out, row_pos, 0, row, old_row, current) out.append("\x1b[0m") self.last = [[cell for cell in row] for row in frame] return "".join(out) @@ -11072,17 +11399,23 @@ class TuiCompositor: class TuiHitMap: """Cell-grid index over painted panels so hit-testing stays cheap no - matter how many layers stack up. Rebuilt on every layout pass.""" + matter how many layers stack up. Lazy: a layout only hands over its + stack; the grid is built by the first click or wheel event after it, + so frames with no mouse input never pay for it.""" CELL_W = 8 CELL_H = 4 def __init__(self): self.grid = {} + self.pending = None def rebuild(self, panels): + self.pending = list(panels) + + def build(self): grid = {} - for panel in panels: + for panel in self.pending: x0, y0, x1, y1 = panel.clip if x0 >= x1 or y0 >= y1: continue @@ -11090,8 +11423,11 @@ class TuiHitMap: for gx in range(x0 // self.CELL_W, (x1 - 1) // self.CELL_W + 1): grid.setdefault((gx, gy), []).append(panel) self.grid = grid + self.pending = None def at(self, x, y): + if self.pending is not None: + self.build() for panel in reversed(self.grid.get((x // self.CELL_W, y // self.CELL_H), [])): if not panel.is_shown(): continue @@ -12006,10 +12342,15 @@ class TuiRich(TuiPanel): rows = list(rows) if rows and isinstance(rows[0], TuiSpan): rows = [rows] - self.rows = [list(row) for row in rows] + rows = [list(row) for row in rows] + look = [tuple((span.text, span.style.key()) for span in row) for row in rows] + if look == self.__dict__.get("_look"): + return + self._look = look + self.rows = rows self.text = "\n".join("".join(span.text for span in row) for row in self.rows) if self.app is not None: - self.app.dirty_paint() + self.touch() def measure(self): width = 0 @@ -12023,7 +12364,7 @@ class TuiRich(TuiPanel): out = [] for pos in range(height): row = self.rows[pos] if pos < len(self.rows) else [] - out.append(tui_pad_spans(tui_slice_spans(row, 0, width, self.style), width, self.style)) + out.append(tui_slice_spans(row, 0, width, self.style)) return out @@ -12076,7 +12417,7 @@ class TuiButton(TuiPanel): def press(self): self.emit(TuiPress(panel=self)) if self.app is not None: - self.app.dirty_paint() + self.touch() class TuiOptions(TuiPanel): @@ -12146,7 +12487,7 @@ class TuiOptions(TuiPanel): return self.selected = max(0, min(len(self.options) - 1, self.selected + delta)) if self.app is not None: - self.app.dirty_paint() + self.touch() def choose(self, index=None): if not self.options or not self.enabled: @@ -12161,7 +12502,7 @@ class TuiOptions(TuiPanel): label, value = self.options[self.selected] self.emit(TuiSelect(panel=self, index=self.selected, option=label, value=value)) if self.app is not None: - self.app.dirty_paint() + self.touch() def on_key(self, event): if not self.enabled: @@ -12310,7 +12651,7 @@ class TuiAutocomplete(TuiOptions): self.selected = 0 self.scroll_y = 0 if self.app is not None: - self.app.dirty_paint() + self.touch() TUI_COMMAND_HELP = {} @@ -12493,7 +12834,7 @@ class TuiTextBox(TuiPanel): def _touch(self): if self.app is not None: - self.app.dirty_paint() + self.touch() def _origin(self): edge = 1 if self.border else 0 @@ -12578,7 +12919,7 @@ class TuiTextBox(TuiPanel): self.root().add(popup) popup.set_items(items) self._complete_place(popup) - self.app.dirty_layout() + self.app.dirty_layout(paint=False) def _complete_place(self, popup): root = self.root() @@ -12894,7 +13235,7 @@ class TuiProgress(TuiPanel): def set(self, value): self.value = max(0.0, min(1.0, float(value))) if self.app is not None: - self.app.dirty_paint() + self.touch() def content_rows(self): width = self._content_w() @@ -12926,7 +13267,7 @@ class TuiLog(TuiPanel): if pinned: self.scroll_y = max(0, len(self.lines) - self._content_h()) if self.app is not None: - self.app.dirty_paint() + self.touch() def append(self, text, style=None): text = str(text) @@ -12959,12 +13300,12 @@ class TuiLog(TuiPanel): event.stop() self.scroll_y = 0 if self.app is not None: - self.app.dirty_paint() + self.touch() elif event.key == "end": event.stop() self.scroll_y = max(0, len(self.lines) - self._content_h()) if self.app is not None: - self.app.dirty_paint() + self.touch() TUI_TERM_KEYS = {"enter": b"\r", "tab": b"\t", "backtab": b"\x1b[Z", "backspace": b"\x7f", "escape": b"\x1b", @@ -12979,6 +13320,8 @@ TUI_TERM_KEYS = {"enter": b"\r", "tab": b"\t", "backtab": b"\x1b[Z", "backspace" class TuiTerminal(TuiPanel): """A live sub-terminal: real pty child streamed through a VT screen.""" + memoize_measure = False + def __init__(self, cmd=None, interactive=True, **kwargs): kwargs.setdefault("focusable", interactive) kwargs.setdefault("border", True) @@ -13047,7 +13390,7 @@ class TuiTerminal(TuiPanel): return False self.screen.feed(data) if self.app is not None: - self.app.dirty_paint() + self.touch() return True def drain_pending(self): @@ -13075,7 +13418,7 @@ class TuiTerminal(TuiPanel): pass self.fd = None if self.app is not None: - self.app.dirty_paint() + self.touch() return self.exit_status def terminate(self): @@ -13221,6 +13564,16 @@ class TuiApp: self._timer_seq = 0 self._need_layout = True self._need_paint = True + self._lock = threading.Lock() + self._drop_all = True + self._damage = set() + self._stack = [] + self._geom = [] + self._geom_rects = [] + self._geom_full = True + self._last_cursor = None + self.verify = bool(os.environ.get("TAI_TUI_VERIFY")) + self.verified = 0 self._running = False self._resize_pending = False self._saved_termios = None @@ -13247,12 +13600,29 @@ class TuiApp: self.dirty_paint() return self.root - def dirty_layout(self): - self._need_layout = True - self._need_paint = True + def dirty_layout(self, paint=True): + """Geometry or structure changed. paint=True (the default, for + callers that also edited state without saying so) drops every cached + panel; paint=False trusts the panels' own touch() and lets the layout + pass diff rects.""" + with self._lock: + self._need_layout = True + self._need_paint = True + if paint: + self._drop_all = True def dirty_paint(self): - self._need_paint = True + """Anonymous "something changed": repaint every panel. Precise + callers use panel.touch() and only that panel's rect repaints.""" + with self._lock: + self._need_paint = True + self._drop_all = True + + def damage(self, panel): + """Thread-safe: turn threads touch panels while the loop composes.""" + with self._lock: + self._damage.add(panel) + self._need_paint = True def set_theme(self, name): """Switch the global theme and repaint every cell. Cells are diffed @@ -13331,9 +13701,11 @@ class TuiApp: self.focused = panel if old is not None: old.emit(TuiBlur(panel=old)) + old.touch() if panel is not None: panel.emit(TuiFocus(panel=panel)) - self.dirty_paint() + panel.touch() + self._need_paint = True def focus_first(self, scope=None): found = self.focusables(scope) @@ -13400,19 +13772,83 @@ class TuiApp: # -- layout + paint ---------------------------------------------- # def layout(self): - self.root.arrange(0, 0, self.cols, self.rows, (0, 0, self.cols, self.rows)) - self.hitmap.rebuild(self.compositor.flatten(self.root)) - self._need_layout = False + """Arrange, restack, and work out what the pass changed: the same + stack with moved or resized panels damages only their old and new + rects; a different stack (panel added, removed, reordered) is the + whole screen.""" + with self._lock: + self._need_layout = False + TuiPanel._measure_memo = {} + try: + self.root.arrange(0, 0, self.cols, self.rows, (0, 0, self.cols, self.rows)) + finally: + TuiPanel._measure_memo = None + stack = self.compositor.flatten(self.root) + geom = [(panel.ax, panel.ay, panel.aw, panel.ah, panel.clip) for panel in stack] + if len(stack) != len(self._stack) or any(a is not b for a, b in zip(stack, self._stack)): + self._geom_full = True + else: + for panel, was, now in zip(stack, self._geom, geom): + if was == now: + continue + self._geom_rects.append(was[4]) + self._geom_rects.append(now[4]) + if was[2:4] != now[2:4]: + panel._cells = None + self._stack = stack + self._geom = geom + self.hitmap.rebuild(stack) + + def _damage_rects(self): + """Rects to recompose for this frame, or None for the whole screen. + Consumes the pending damage.""" + with self._lock: + self._need_paint = False + drop = self._drop_all + touched, self._damage = self._damage, set() + self._drop_all = False + rects, self._geom_rects = self._geom_rects, [] + full = drop or self._geom_full + self._geom_full = False + if drop: + for node in self.root.walk(): + node._cells = None + if full: + return None + shown = set(self._stack) + for panel in touched: + if panel in shown: + rects.append(panel.clip) + return None if len(rects) > TUI_DAMAGE_RECTS else rects def frame_bytes(self): + """The bytes that bring the terminal up to date; "" when nothing on + screen changed (no cell differs and the cursor stayed).""" if self._need_layout: self.layout() - frame = self.compositor.compose(self.root, self.rows, self.cols) - out = self.compositor.render(frame) - out += self._cursor_bytes() - out += TUI_SYNC_END - self._need_paint = False - return out + compositor = self.compositor + fresh = compositor.last is None + body = compositor.refresh(self._stack, self.rows, self.cols, self._damage_rects()) + cursor = self._cursor_bytes() + if self.verify: + self._verify_frame() + if not body and not fresh and cursor == self._last_cursor: + return "" + self._last_cursor = cursor + return TUI_SYNC_START + "\x1b[?25l" + body + "\x1b[0m" + cursor + TUI_SYNC_END + + def _verify_frame(self): + """Oracle check: the lazy screen must equal a from-scratch recompose + by the reference algorithm. Raises TuiError naming the first cell.""" + want = self.compositor.compose_reference(self.root, self.rows, self.cols) + have = self.compositor.last + self.verified += 1 + for y, (row_want, row_have) in enumerate(zip(want, have)): + if row_want != row_have: + x = next(pos for pos, (a, b) in enumerate(zip(row_want, row_have)) if a != b) + owner = [node for node in self._stack if node.ax <= x < node.ax + node.aw and node.ay <= y < node.ay + node.ah] + raise TuiError("lazy frame diverged from reference at x=%d y=%d: want %r have %r (top panel %s)" % ( + x, y, row_want[x], row_have[x], type(owner[-1]).__name__ if owner else "none")) def _cursor_bytes(self): node = self.focused @@ -13670,7 +14106,9 @@ class TuiApp: if not (self._need_paint or self._need_layout): return True try: - TuiApp.write_all(self._stdout_fd, self.frame_bytes().encode("utf-8")) + data = self.frame_bytes().encode("utf-8") + if data: + TuiApp.write_all(self._stdout_fd, data) except OSError: self._running = False return False @@ -14582,7 +15020,7 @@ def tui_build_chat(app, agent, spawn=None): else: transcript._clamp_scroll() refs["render_w"] = transcript._content_w() - app.dirty_paint() + transcript.touch() def maybe_replay(): if transcript.aw and transcript._content_w() != refs["render_w"]: @@ -14746,13 +15184,12 @@ def tui_build_chat(app, agent, spawn=None): label = "%s" % (pins or "auto") head_meta.set([[], [TuiSpan(("◆ " + label + " ")[-22:].rjust(24), meta_style)], []]) head_meta.visible = app.cols >= 70 - app.dirty_paint() def clear_box(): box.lines = [""] box.cx = box.cy = box.top = 0 box._complete_hide() - app.dirty_paint() + box.touch() def bot_snippet(name): try: @@ -14778,7 +15215,7 @@ def tui_build_chat(app, agent, spawn=None): def refresh_meta(): for name, meta in sidebar.row_meta.items(): meta.update(bot_meta(name)) - app.dirty_paint() + sidebar.touch() def refresh_bots(): names = [item["name"] for item in agent.store.list_bots(agent.profile)] @@ -14795,7 +15232,7 @@ def tui_build_chat(app, agent, spawn=None): meta = {"snippet": bot_snippet(name), "draft": refs["drafts"].get(name, "").split("\n")[0][:40]} meta.update(bot_meta(name)) sidebar.row_meta[name] = meta - app.dirty_paint() + sidebar.touch() def activate(session): nonlocal agent @@ -15042,7 +15479,7 @@ def tui_build_chat(app, agent, spawn=None): dialog.on_close = finish shown.append(dialog) app.root.add(dialog) - app.dirty_layout() + app.dirty_layout(paint=False) def chosen(value): if value is TUI_CHAT_TYPE_ANSWER: @@ -15071,7 +15508,7 @@ def tui_build_chat(app, agent, spawn=None): dialog.on_close = closed shown.append(style_dialog(dialog)) app.root.add(dialog) - app.dirty_layout() + app.dirty_layout(paint=False) before = session.phase, session.detail session.phase, session.detail = "ask", "" @@ -15105,7 +15542,7 @@ def tui_build_chat(app, agent, spawn=None): refs["sidebar_auto"] = False if sidebar_wrap.visible: sidebar_wrap.w = tui_chat_sidebar_w(app.cols) - app.dirty_layout() + app.dirty_layout(paint=False) app.ensure_focus() def on_resize(event): @@ -15119,7 +15556,7 @@ def tui_build_chat(app, agent, spawn=None): elif app.cols >= TUI_CHAT_NARROW_COLS and refs["sidebar_auto"] and not refs["sidebar_hide"]: sidebar_wrap.visible = True refs["sidebar_auto"] = False - app.dirty_layout() + app.dirty_layout(paint=False) app.ensure_focus() app.after(0.05, maybe_replay) @@ -15147,7 +15584,7 @@ def tui_build_chat(app, agent, spawn=None): box.h = rows send_btn.h = rows composer.h = rows + 2 - app.dirty_layout() + app.dirty_layout(paint=False) sidebar.on_select = lambda event: select_bot(event.value) root.on_key = on_root_key diff --git a/test_tai.py b/test_tai.py index dbcb128..e2b6fd7 100644 --- a/test_tai.py +++ b/test_tai.py @@ -4905,6 +4905,248 @@ class TuiCompositorTests(unittest.TestCase): self.assertIs(app.hitmap.at(19, 5), bottom) +class TuiLazyTests(unittest.TestCase): + """The lazy pipeline (cached cells, front-to-back coverage, damage rects, + persistent screen) must always show exactly what the original full + compositor would, while doing almost no work.""" + + def tearDown(self): + tai.tui_set_theme("dark") + + @staticmethod + def spawn(base, name): + child = FakeChatAgent() + child.bot = name + child.messages = [{"role": "system", "content": "sys"}] + return child + + def make_chat(self, cols=100, rows=30, exchanges=12): + app = tai.TuiApp() + app.rows, app.cols = rows, cols + app.verify = True + agent = FakeChatAgent() + for n in range(exchanges): + agent.messages += [{"role": "user", "content": "q %d" % n}, + {"role": "assistant", "content": "answer " + "word " * 40}] + root, refs = tai.tui_build_chat(app, agent, spawn=self.spawn) + app.mount(root) + refs["replay"]() + return app, refs + + @staticmethod + def chars(grid): + return [("".join(cell[0] if cell else " " for cell in row)).rstrip() for row in grid] + + def test_storm_matches_reference_and_the_terminal(self): + """Seeded random storm of keys, wheel, clicks, resizes, themes, + dialogs and bot switches. Every frame is checked against the + reference compositor (app.verify), and the bytes actually emitted are + replayed through the VT emulator and compared with a fresh render.""" + import random + rng = random.Random(20260412) + app, refs = self.make_chat() + vt = tai.TuiVtScreen(app.rows, app.cols) + vt.feed(app.frame_bytes().encode("utf-8")) + keys = ["a", "b", " ", "backspace", "enter", "left", "right", "up", "down", "tab", "backtab", + "pageup", "pagedown", "home", "end", "f2", "f4", "escape", "/", "@"] + dialogs = [] + for step in range(400): + op = rng.choice(("key", "key", "key", "key", "wheel", "click", "resize", "note", "status", "dialog", "close", "bot", "timers", "idle")) + if op == "key": + key = rng.choice(keys) + app.dispatch(tai.TuiKey(key=key, char=key if len(key) == 1 else "")) + elif op == "wheel": + app.dispatch(tai.TuiScroll(x=rng.randrange(app.cols), y=rng.randrange(app.rows), direction=rng.choice(("up", "down")))) + elif op == "click": + app.dispatch(tai.TuiClick(x=rng.randrange(app.cols), y=rng.randrange(app.rows), button=0, pressed=True)) + elif op == "resize": + app.rows, app.cols = rng.randrange(10, 40), rng.randrange(30, 140) + app.compositor.invalidate() + app.dirty_layout() + app.root.emit(tai.TuiResize(panel=app.root, rows=app.rows, cols=app.cols)) + vt = tai.TuiVtScreen(app.rows, app.cols) + elif op == "note": + refs["show"]("bot" if rng.random() < 0.5 else "you", "note %d " % step + "x" * rng.randrange(1, 120), None) + elif op == "status": + refs["paint_status"]() + elif op == "dialog" and len(dialogs) < 3: + dialog = tai.TuiQuestion("Pick %d" % step, ["one", "two", "three"], on_done=lambda value: None, title="Q", w=rng.randrange(20, 50)) + app.root.add(dialog) + dialogs.append(dialog) + elif op == "close" and dialogs: + dialogs.pop().close() + elif op == "bot": + refs["select_bot"](rng.choice(refs["bots"])) + elif op == "timers": + app._fire_timers(time.time() + 5) + vt.feed(app.frame_bytes().encode("utf-8")) + if step % 25 == 0: + want = app.compositor.compose_reference(app.root, app.rows, app.cols) + self.assertEqual(self.chars(vt.grid), tai.TuiCompositor.render_text(want).split("\n"), "step %d op %s" % (step, op)) + self.assertGreater(app.verified, 300) + + def test_demo_scene_matches_reference(self): + app = tai.TuiApp() + app.rows, app.cols = 24, 80 + app.verify = True + root = tai.TuiPanel(flow=None, style=tai.TuiStyle(bg="black")) + base = tai.TuiRows(id="base", x=0, y=0, w="100%", h="100%", border=True, title="base") + base.add(tai.TuiText("hello", id="t"), tai.TuiButton("go"), tai.TuiProgress(0.3)) + root.add(base) + root.add(tai.TuiPanel(x=10, y=5, w=30, h=8, z=3, border=True, rounded=True, title="over", style=tai.TuiStyle(bg="blue"))) + app.mount(root) + app.frame_bytes() + text = base.find("t") + for n in range(30): + text.text = "hello %d" % n + app.frame_bytes() + root.children[1].x = 5 + n + app.frame_bytes() + root.children[1].z = n % 5 + app.frame_bytes() + + def test_typing_repaints_one_panel(self): + app, refs = self.make_chat() + app.verify = False + refs["box"].focus() + app.frame_bytes() + painted = app.compositor.stats["painted"] + app.dispatch(tai.TuiKey(key="x", char="x")) + out = app.frame_bytes() + self.assertIn("x", out) + self.assertEqual(app.compositor.stats["painted"] - painted, 1) + + def test_idle_frame_writes_nothing_and_paints_nothing(self): + app, refs = self.make_chat() + app.verify = False + app.frame_bytes() + stats = dict(app.compositor.stats) + refs["paint_status"]() + self.assertEqual(app.frame_bytes(), "") + self.assertEqual(app.compositor.stats["painted"], stats["painted"]) + self.assertEqual(app.compositor.stats["emitted"], stats["emitted"]) + + def test_covered_panels_are_never_painted(self): + app = tai.TuiApp() + app.rows, app.cols = 10, 40 + app.verify = False + painted = [] + + class Probe(tai.TuiPanel): + def paint(self): + painted.append(self) + return super().paint() + + root = Probe(flow=None) + under = Probe(x=0, y=0, w=40, h=10) + over = Probe(x=0, y=0, w=40, h=10, z=1) + root.add(under, over) + app.mount(root) + app.frame_bytes() + self.assertEqual(painted, [over]) + + def test_theme_switch_keeps_cached_cells_correct(self): + app, refs = self.make_chat() + app.frame_bytes() + app.set_theme("gruvbox") + vt_bytes = app.frame_bytes() + self.assertIn("48;2;40;40;40", vt_bytes) + + def test_style_key_cache_drops_on_write(self): + style = tai.TuiStyle(fg="red") + before = style.key() + style.fg = "blue" + self.assertNotEqual(style.key(), before) + self.assertEqual(style.key()[0], "blue") + + def test_sgr_from_key_matches_sgr(self): + for style in (tai.TuiStyle(), tai.TuiStyle(fg="role:accent", bg="#102030", bold=True), + tai.TuiStyle(fg=(1, 2, 3), bg=200, dim=True, underline=True, reverse=True, strike=True), + tai.TuiStyle(fg="bright_cyan", bg="black")): + self.assertEqual(tai.TuiStyle.sgr_from_key(style.key()), style.sgr()) + + def test_measure_memo_never_outlives_a_pass(self): + app, refs = self.make_chat() + self.assertIsNone(tai.TuiPanel._measure_memo) + refs["box"].lines = ["a", "b", "c"] + app.dirty_layout() + app.frame_bytes() + self.assertIsNone(tai.TuiPanel._measure_memo) + + def test_hit_map_builds_on_first_click_only(self): + app, refs = self.make_chat() + app.frame_bytes() + self.assertIsNotNone(app.hitmap.pending) + hit = app.hitmap.at(1, 1) + self.assertIsNone(app.hitmap.pending) + self.assertIsNotNone(hit) + + def test_touches_from_threads_are_never_lost(self): + """Turn threads touch panels while the loop composes. Verify is off + mid-run (the reference would race the writers); after the writers + join, one more frame must equal a from-scratch recompose.""" + import threading + app = tai.TuiApp() + app.rows, app.cols = 12, 40 + app.verify = False + root = tai.TuiRows() + labels = [tai.TuiText("-", id="l%d" % n) for n in range(8)] + root.add(*labels) + app.mount(root) + app.frame_bytes() + stop = threading.Event() + + def writer(seed): + for n in range(400): + labels[(seed + n) % 8].text = "w%d-%d" % (seed, n) + + threads = [threading.Thread(target=writer, args=(seed,)) for seed in range(4)] + for thread in threads: + thread.start() + while any(thread.is_alive() for thread in threads): + app.frame_bytes() + for thread in threads: + thread.join() + app.verify = True + app.frame_bytes() + self.assertEqual(app.verified, 1) + + def test_typing_frame_budget_at_200x50(self): + app = tai.TuiApp() + app.rows, app.cols = 50, 200 + app.verify = False + agent = FakeChatAgent() + for n in range(40): + agent.messages += [{"role": "user", "content": "q %d" % n}, + {"role": "assistant", "content": "answer " + "word " * 60}] + root, refs = tai.tui_build_chat(app, agent) + app.mount(root) + refs["replay"]() + refs["box"].focus() + app.frame_bytes() + started = time.perf_counter() + for _ in range(50): + app.dispatch(tai.TuiKey(key="a", char="a")) + app.frame_bytes() + per_frame = (time.perf_counter() - started) / 50 + self.assertLess(per_frame, 0.005, "typing frame %.2fms (measured 0.08ms on the dev box)" % (per_frame * 1000)) + + def test_broken_widget_garbles_only_itself(self): + app = tai.TuiApp() + app.rows, app.cols = 6, 20 + app.verify = False + + class Broken(tai.TuiPanel): + def paint(self): + raise RuntimeError("boom") + + root = tai.TuiPanel(flow=None) + root.add(tai.TuiText("under", x=0, y=0, w=20, h=6), Broken(x=0, y=0, w=5, h=2, z=1)) + app.mount(root) + app.frame_bytes() + self.assertIn("under", app.render_text()) + + class TuiEventTests(unittest.TestCase): def test_bubble_order_and_stop(self): order = [] diff --git a/tui.md b/tui.md index 0656829..2429a78 100644 --- a/tui.md +++ b/tui.md @@ -47,21 +47,76 @@ buttons, Esc closes dialogs, `q` quits demo apps, Ctrl+C always quits. 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. -- **Compositor.** Each panel paints styled text runs for its own rect. - Per screen row the compositor collects cuts (every run edge), - slices runs into aligned chops, drops occluded chops, and merges - the topmost survivor per interval. Frames diff against the previous - frame; one single-write update wrapped in synchronized-output - markers (`CSI ? 2026 h/l`, ignored where unsupported) keeps output - atomic and flicker-free. -- **Hit-testing.** Panels index into an 8x4-cell spatial grid rebuilt - on every layout; clicks resolve topmost-first inside clip rects. +- **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 + the screen the compositor believes is shown. One single-write update + wrapped in synchronized-output markers (`CSI ? 2026 h/l`, ignored where + unsupported) keeps output atomic and flicker-free; a frame that changes + nothing writes nothing. +- **Hit-testing.** Panels index into an 8x4-cell spatial grid. A layout + 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. 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, 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: + +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 + 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 + (`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. + +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. + ## Geometry Sizes accept `int` cells, `"50%"` (of the parent content box), `"1fr"` @@ -339,12 +394,32 @@ waits. Esc, Cancel, or no answer within `TUI_CHAT_PROMPT_TIMEOUT` (600 s) means deny / no answer, never a hang. Without a `prompter` (CLI, Telegram) behavior is unchanged. -Measured (this machine, 40-exchange transcript, repaint of a dirty -frame): 1.9 ms at 80x24, 5.0 ms at 200x50, 7.8 ms at 300x70 against a -33 ms budget (guarded by a unit test at 200x50). 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). +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: + +| frame at 200x50 | before | lazy | +|---|---|---| +| 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 | + +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 +`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). ## Terminal widget notes @@ -370,6 +445,14 @@ 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). - 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