diff --git a/README.md b/README.md index 2950697..0f3f65f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/tai.py b/tai.py index 39cf355..63dcc2c 100755 --- a/tai.py +++ b/tai.py @@ -15,6 +15,7 @@ import hmac import html import io import json +import operator import os import queue import random @@ -36,6 +37,7 @@ import urllib.error import urllib.parse import urllib.request import uuid +import weakref import zipfile from datetime import datetime, timedelta, timezone @@ -16402,6 +16404,8 @@ TUI_FPS = 30 TUI_INPUT_PAINT_GAP = 0.008 +TUI_IDLE_MAX = 1.0 + TUI_MIN_ROWS = 8 TUI_MIN_COLS = 20 @@ -16534,8 +16538,22 @@ class TuiStyle: def __setattr__(self, name, value): fields = self.__dict__ + if name in fields and fields[name] == value: + return fields[name] = value fields.pop("_key", None) + owners = fields.get("_owners") + if owners: + for panel in list(owners): + panel.touch() + + def adopt(self, panel): + """Register a panel that paints with this style, so a field written + later repaints it.""" + owners = self.__dict__.get("_owners") + if owners is None: + owners = self.__dict__["_owners"] = weakref.WeakSet() + owners.add(panel) def key(self): """Hashable identity of the look; cached, and any later write to a @@ -16743,6 +16761,11 @@ class TuiPaste(TuiEvent): name = "paste" +class TuiChange(TuiEvent): + name = "change" + bubble = False + + def tui_int(value, default=0): try: return int(value) @@ -16835,6 +16858,157 @@ def tui_slice_spans(spans, start, length, fill_style): return out +TUI_OBSERVE_DEPTH = 4 + + +class TuiObserved: + """Marker and shared behavior of the containers a panel keeps its state + in. An in-place mutation (append, item assignment, update, ...) touches + the owning panel, so a widget never shows stale cells because someone + forgot touch(). Copies and pickles are plain containers: the owner link + never travels.""" + + __slots__ = () + + def _adopt(self, value): + """A container inserted into an observed one is observed too, down + to TUI_OBSERVE_DEPTH levels.""" + if self._depth < TUI_OBSERVE_DEPTH and (type(value) in TUI_PLAIN or isinstance(value, TUI_OBSERVING)): + return tui_observe(self._owner, value, self._depth + 1) + return value + + def _changed(self): + owner = self._owner + if owner is not None: + owner.touch() + + +def tui_watch(base, names): + """Subclass body that runs the base method, then touches the owner.""" + body = {} + for name in names: + def method(self, *args, _call=getattr(base, name), **kwargs): + result = _call(self, *args, **kwargs) + self._changed() + return result + method.__name__ = name + body[name] = method + return body + + +class TuiList(TuiObserved, list): + __slots__ = ("_owner", "_depth") + + def __reduce__(self): + return (list, (list(self),)) + + def append(self, value): + list.append(self, self._adopt(value)) + self._changed() + + def insert(self, index, value): + list.insert(self, index, self._adopt(value)) + self._changed() + + def extend(self, values): + list.extend(self, [self._adopt(value) for value in values]) + self._changed() + + def __iadd__(self, values): + self.extend(values) + return self + + def __setitem__(self, index, value): + if isinstance(index, slice): + value = [self._adopt(item) for item in value] + else: + value = self._adopt(value) + list.__setitem__(self, index, value) + self._changed() + + +for _name, _method in tui_watch(list, ("remove", "pop", "clear", "sort", "reverse", "__delitem__", "__imul__")).items(): + setattr(TuiList, _name, _method) + + +class TuiDict(TuiObserved, dict): + __slots__ = ("_owner", "_depth") + + def __reduce__(self): + return (dict, (dict(self),)) + + def __setitem__(self, key, value): + dict.__setitem__(self, key, self._adopt(value)) + self._changed() + + def setdefault(self, key, default=None): + if key not in self: + dict.__setitem__(self, key, self._adopt(default)) + self._changed() + return dict.__getitem__(self, key) + + def update(self, *args, **kwargs): + dict.update(self, {key: self._adopt(value) for key, value in dict(*args, **kwargs).items()}) + self._changed() + + def __ior__(self, other): + self.update(other) + return self + + +for _name, _method in tui_watch(dict, ("pop", "popitem", "clear", "__delitem__")).items(): + setattr(TuiDict, _name, _method) + + +class TuiSet(TuiObserved, set): + __slots__ = ("_owner", "_depth") + + def __reduce__(self): + return (set, (set(self),)) + + +for _name, _method in tui_watch(set, ("add", "discard", "remove", "pop", "clear", "update", "difference_update", + "intersection_update", "symmetric_difference_update", "__ior__", "__iand__", + "__ixor__", "__isub__")).items(): + setattr(TuiSet, _name, _method) + + +TUI_PLAIN = {list: TuiList, dict: TuiDict, set: TuiSet} + +TUI_OBSERVING = (TuiList, TuiDict, TuiSet) + + +def tui_observe(owner, value, depth=0): + """An observing copy of a list, dict or set (nested containers included) + whose in-place mutations touch owner. The caller's original is left + alone: the panel keeps the copy.""" + if isinstance(value, TuiList): + kind = TuiList + elif isinstance(value, TuiDict): + kind = TuiDict + elif isinstance(value, TuiSet): + kind = TuiSet + else: + kind = TUI_PLAIN[type(value)] + nested = depth < TUI_OBSERVE_DEPTH + if kind is TuiSet: + copy = TuiSet(value) + else: + copy = kind() + if kind is TuiDict: + for key, item in dict.items(value): + dict.__setitem__(copy, key, tui_observe(owner, item, depth + 1) if nested and tui_watchable(item) else item) + else: + list.extend(copy, [tui_observe(owner, item, depth + 1) if nested and tui_watchable(item) else item for item in value]) + copy._depth = depth + copy._owner = owner + return copy + + +def tui_watchable(value): + return type(value) in TUI_PLAIN or isinstance(value, TUI_OBSERVING) + + class TuiPanel: """Base of every component: box, children, layout, paint, events. @@ -16851,10 +17025,16 @@ class TuiPanel: # 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")) + _RELAYOUT = frozenset(("x", "y", "w", "h", "flow", "dock", "offset", "align", "valign", "padding", "gap", "border")) + _RESTACK = frozenset(("layer", "z")) _QUIET = frozenset(("ax", "ay", "aw", "ah", "clip", "parent", "app", "children", "seq")) _SCALARS = (int, float, str, bool, type(None), tuple) + # A panel that sets shifts = True promises that changing scroll_y by n + # moves its rows n up or down and rewrites little else; the compositor + # then tries a terminal scroll before rewriting cells. It is only a hint: + # the compositor checks it against the real cells and falls back. + shifts = False + _shift = 0 def __init__(self, id=None, title="", x=None, y=None, w="auto", h="auto", flow="rows", dock=None, layer=None, z=0, offset=None, @@ -16903,25 +17083,46 @@ class TuiPanel: self.aw = 0 self.ah = 0 self.clip = (0, 0, 0, 0) + self._arrange = None + self._geom = None + self._measure_seen = None self._pending_anims = [] def __setattr__(self, name, value): fields = self.__dict__ old = fields.get(name, self) + if name[0] != "_" and name not in TuiPanel._QUIET: + kind = type(value) + if kind in TUI_PLAIN: + value = tui_observe(self, value) + elif kind is TuiStyle: + value.adopt(self) + elif kind in TUI_OBSERVING and value._owner is not self: + value = tui_observe(self, value) object.__setattr__(self, name, value) if name[0] == "_" or name in TuiPanel._QUIET: return app = fields.get("app") if app is None: return + if name == "scroll_y" and self.shifts and type(old) is int and type(value) is int: + self._shift += value - old 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) + if name[:3] == "on_": + if name == "on_tick": + app.tree_changed() + elif name in TuiPanel._RELAYOUT: + app.relayout(self) + self.touch() + elif name == "visible": + app.relayout(self, restack=True) + elif name in TuiPanel._RESTACK: + app.restack() else: self.touch() @@ -16948,7 +17149,7 @@ class TuiPanel: if self.app is not None: child._bind_app(self.app) if self.app is not None: - self.app.dirty_layout(paint=False) + self.app.relayout(self, inner=True, restack=True) return children[0] if len(children) == 1 else children def remove(self, child): @@ -16957,7 +17158,7 @@ class TuiPanel: child.parent = None child._unbind_app() if self.app is not None: - self.app.dirty_layout(paint=False) + self.app.relayout(self, inner=True, restack=True) return child def clear(self): @@ -17116,7 +17317,16 @@ class TuiPanel: return (max(widths + [flow_w + gaps]), max(heights + [0])) return (max(widths + [0]), max(heights + [flow_h + gaps])) + def content_sized(self): + """True when this panel's outer size follows its content, so a change + inside it can change what its parent lays out or measures. A panel + with a fixed, percent or fr size on both axes is a layout boundary: + relayout stops there. (The parent's measure() reads both axes of + every child, which is why one auto axis is enough.)""" + return self.parent is not None and (self.w == "auto" or self.h == "auto") + def arrange(self, x, y, w, h, clip): + self._arrange = (x, y, w, h, clip) ox, oy = self.offset self.ax = x + tui_int(ox) self.ay = y + tui_int(oy) @@ -17283,9 +17493,18 @@ class TuiPanel: def _clamp_scroll(self): cw, ch = self.measured() + self._measure_seen = (cw, ch) 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()))) + def content_rect(self): + """Screen rect (x0, y0, x1, y1) of the content area, clipped.""" + edge = 1 if self.border else 0 + pad_top, pad_right, pad_bottom, pad_left = self.padding + x0, y0, x1, y1 = self.clip + return (max(x0, self.ax + edge + pad_left), max(y0, self.ay + edge + pad_top), + min(x1, self.ax + self.aw - edge - pad_right), min(y1, self.ay + self.ah - edge - pad_bottom)) + def _content_w(self): return max(0, self.aw - self.chrome_w()) @@ -17350,6 +17569,12 @@ class TuiPanel: rows = [tui_pad_spans(spans, self.aw, self.style) for spans in rows] return rows + def paint_cells(self, compositor): + """The panel's cell rows, exactly aw wide and ah tall. The default + converts paint(); a widget whose rows are stable between paints + overrides it to reuse them (TuiLog).""" + return compositor.cells_from(self.paint(), self.aw) + def _add_border(self, rows, inner_w): tl, tr, bl, br = "╭╮╰╯" if self.rounded else "┌┐└┘" line = self.border_style @@ -17380,6 +17605,7 @@ class TuiPanel: "started": None, "from": None} if self.app is not None: self.app.animations.append(anim) + self.app.wake() else: self._pending_anims.append(anim) return anim @@ -17458,6 +17684,10 @@ TUI_SGR_CAP = 4096 TUI_DAMAGE_RECTS = 64 +TUI_ROW_MEMO = 4096 + +TUI_SCROLLS = 4 + class TuiCellPool(dict): """Interned cells of one style key: char -> (char, key). Equal cells are @@ -17489,7 +17719,7 @@ class TuiCompositor: self.pool = {} self.sgr_memo = {} self.sgr_theme = None - self.stats = {"painted": 0, "composed": 0, "emitted": 0, "frames": 0} + self.stats = {"painted": 0, "composed": 0, "emitted": 0, "frames": 0, "scrolled": 0} def invalidate(self): self.last = None @@ -17544,6 +17774,16 @@ class TuiCompositor: out.append(row) return out + def cell(self, char, key): + """The interned cell of one char in one style key.""" + pool = self.pool + chars = pool.get(key) + if chars is None: + if len(pool) >= TUI_POOL_KEYS: + pool.clear() + chars = pool[key] = TuiCellPool(key) + return chars[char] + 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 @@ -17552,11 +17792,10 @@ class TuiCompositor: if cached and panel._cells is not None and panel._cells_size == size: return panel._cells try: - painted = panel.paint() + cells = panel.paint_cells(self) except Exception: return None self.stats["painted"] += 1 - cells = self.cells_from(painted, panel.aw) if cached: panel._cells = cells panel._cells_size = size @@ -17724,10 +17963,12 @@ class TuiCompositor: self.stats["emitted"] += col - start return current - def refresh(self, stack, rows, cols, rects): + def refresh(self, stack, rows, cols, rects, shifts=()): """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.""" + rects (x0, y0, x1, y1); shifts are scroll hints (x0, y0, x1, y1, dy) + for panels whose scroll_y moved. Returns only the bytes that + changed.""" self.stats["frames"] += 1 out = [] current = None @@ -17736,9 +17977,14 @@ class TuiCompositor: 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): + shifts = () + fresh = [(rect, self.fill(stack, rect[0], rect[1], rect[2], rect[3], True)) + for rect in TuiCompositor.merge_rects(rects, cols, rows)] + if shifts: + self.scroll(stack, shifts, fresh, cols, rows, out) + current = None + for (x0, y0, x1, y1), block in fresh: + for offset, row in enumerate(block): shown = self.last[y0 + offset] if row == shown[x0:x1]: continue @@ -17746,6 +17992,77 @@ class TuiCompositor: shown[x0:x1] = row return "".join(out) + @staticmethod + def cost(pairs): + """Bytes-ish cost of turning each old row segment into its new one: + changed cells plus a cursor move per changed row.""" + cells = moves = 0 + for new, old in pairs: + if new != old: + moves += 1 + cells += sum(map(operator.ne, new, old)) + return cells + 6 * moves + + def scroll(self, stack, shifts, fresh, cols, rows, out): + """Turn scroll hints into terminal scrolls where that writes less than + rewriting cells. A hint (x0, y0, x1, y1, dy) says the rows of that rect + moved up by dy (down when negative). It is checked against the cells + that are really there: new rows equal to the old rows moved by dy, and + the cost of every cell the scroll would leave wrong (the strips left + and right of the rect move with it) is counted exactly. Taken, it emits + a scroll of the whole rows y0..y1 (margins, then index or reverse index + per row), moves the believed screen the same way and queues the strips + for the normal diff, which repairs them. That diff is what keeps this + correct: it compares the new content with the believed screen wherever + anything may differ.""" + for x0, y0, x1, y1, dy in shifts[:TUI_SCROLLS]: + x0, y0, x1, y1 = max(0, x0), max(0, y0), min(cols, x1), min(rows, y1) + height = y1 - y0 + size = abs(dy) + if x0 >= x1 or height < 3 or size >= height: + continue + owner = None + for (rx0, ry0, rx1, ry1), block in fresh: + if rx0 <= x0 and ry0 <= y0 and rx1 >= x1 and ry1 >= y1: + owner = (rx0, ry0, block) + break + if owner is None: + continue + rx0, ry0, block = owner + last = self.last + new_rows = [row[x0 - rx0:x1 - rx0] for row in block[y0 - ry0:y1 - ry0]] + if dy > 0: + moved = sum(1 for pos in range(height - size) if new_rows[pos] == last[y0 + pos + size][x0:x1]) + else: + moved = sum(1 for pos in range(size, height) if new_rows[pos] == last[y0 + pos - size][x0:x1]) + if moved < 2: + continue + strips = [(0, y0, x0, y1)] if x0 > 0 else [] + if x1 < cols: + strips.append((x1, y0, cols, y1)) + strip_blocks = [(rect, self.fill(stack, rect[0], rect[1], rect[2], rect[3], True)) for rect in strips] + blank = [TUI_BLANK] * cols + + def moved_row(y): + """What the terminal would show in row y after the scroll.""" + src = y + size if dy > 0 else y - size + return last[src] if y0 <= src < y1 else blank + + now = TuiCompositor.cost((new, last[y0 + pos][x0:x1]) for pos, new in enumerate(new_rows)) + then = TuiCompositor.cost((new, moved_row(y0 + pos)[x0:x1]) for pos, new in enumerate(new_rows)) + for (sx0, _sy0, sx1, _sy1), sblock in strip_blocks: + then += TuiCompositor.cost((new, moved_row(y0 + pos)[sx0:sx1]) for pos, new in enumerate(sblock)) + if then * 5 // 4 + 24 + 2 * size >= now: + continue + if dy > 0: + out.append("\x1b[0m\x1b[%d;%dr\x1b[%d;1H%s\x1b[r" % (y0 + 1, y1, y1, "\x1bD" * size)) + last[y0:y1] = last[y0 + size:y1] + [[TUI_BLANK] * cols for _row in range(size)] + else: + out.append("\x1b[0m\x1b[%d;%dr\x1b[%d;1H%s\x1b[r" % (y0 + 1, y1, y0 + 1, "\x1bM" * size)) + last[y0:y1] = [[TUI_BLANK] * cols for _row in range(size)] + last[y0:y1 - size] + self.stats["scrolled"] += height + fresh.extend(strip_blocks) + @staticmethod def merge_rects(rects, cols, rows): """Clamp to the screen and drop rects contained in another.""" @@ -19278,6 +19595,17 @@ class TuiTextBox(TuiPanel): self.max_chars = max(0, int(max_chars or 0)) self.placeholder = placeholder or "" self.history = history + self._rows_seen = len(self.lines) + + def touch(self): + """Besides repainting, tell the owner when the number of lines + changed (on_change, on the loop), so a composer can resize without a + polling timer.""" + super().touch() + rows = len(self.lines) + if rows != self._rows_seen and self.app is not None: + self._rows_seen = rows + self.app.run_on_loop(lambda: self.emit(TuiChange(panel=self, rows=rows))) @property def value(self): @@ -19380,7 +19708,6 @@ class TuiTextBox(TuiPanel): self.root().add(popup) popup.set_items(items) self._complete_place(popup) - self.app.dirty_layout(paint=False) def _complete_place(self, popup): root = self.root() @@ -19711,6 +20038,8 @@ class TuiLog(TuiPanel): append() takes plain text plus one style, append_spans() takes pre-built multi-style runs (chat bubbles).""" + shifts = True + def __init__(self, **kwargs): kwargs.setdefault("scrollable", True) super().__init__(**kwargs) @@ -19743,6 +20072,80 @@ class TuiLog(TuiPanel): itself, so scrolling here too would shift the view twice.""" return [spans for _text, spans in self.lines] + def paint_cells(self, compositor): + """paint() in cell space. A line's converted row is kept between + paints (looked up by the identity of its span list), so scrolling or + repainting after a neighbor changed converts only lines it has not + seen. Lines are append-only span lists, which is what makes that + safe. A subclass that draws its own rows or content gets the plain + path.""" + kind = type(self) + if kind.paint is not TuiPanel.paint or kind.content_rows is not TuiLog.content_rows or self.aw < self.chrome_w() + 2: + return TuiPanel.paint_cells(self, compositor) + return self._log_cells(compositor) + + def _log_cells(self, compositor): + width = self._content_w() + height = self._content_h() + pad_top, pad_right, pad_bottom, pad_left = self.padding + style = self.style + key = style.key() + blank = compositor.cell(" ", key) + inner_w = width + pad_left + pad_right + edge_left = [] + edge_right = [] + top = bottom = None + if self.border: + framed = self._add_border([[]], inner_w) + top, bottom = compositor.cells_from([framed[0], framed[-1]], self.aw) + bar_left, bar_right = compositor.cells_from([framed[1]], 2)[0] + edge_left = [bar_left] + edge_right = [bar_right] + head = edge_left + [blank] * pad_left + tail = [blank] * pad_right + edge_right + pad_row = edge_left + [blank] * inner_w + edge_right + empty = [blank] * width + rows = [top] if top is not None else [] + rows.extend([pad_row] * pad_top) + memo = self.__dict__.get("_row_memo") + if memo is None: + memo = self._row_memo = {} + lines = self.lines + total = len(lines) + scroll_x = self.scroll_x + sig = (scroll_x, width, key) + first = self.scroll_y + found = [] + missing = [] + for index in range(first, min(first + height, total)): + spans = lines[index][1] + entry = memo.get(id(spans)) + if entry is not None and entry[0] is spans and entry[1] == sig: + found.append(entry[2]) + else: + found.append(None) + missing.append(spans) + if missing: + if len(memo) + len(missing) > TUI_ROW_MEMO: + memo.clear() + converted = iter(compositor.cells_from([tui_slice_spans(spans, scroll_x, width, style) for spans in missing], width)) + for pos, cells in enumerate(found): + if cells is None: + spans = lines[first + pos][1] + cells = found[pos] = next(converted) + memo[id(spans)] = (spans, sig, cells) + for cells in found: + rows.append(head + cells + tail) + for _index in range(height - len(found)): + rows.append(head + empty + tail) + rows.extend([pad_row] * pad_bottom) + if bottom is not None: + rows.append(bottom) + full = [blank] * self.aw + while len(rows) < self.ah: + rows.append(full) + return rows[:self.ah] + def on_scroll(self, event): event.stop() if event.direction == "up": @@ -20029,9 +20432,15 @@ class TuiApp: self._drop_all = True self._damage = set() self._stack = [] - self._geom = [] + self._layout_full = True + self._layout_starts = [] + self._watch = set() + self._shifts = [] + self._restack = False self._geom_rects = [] self._geom_full = True + self.layouts = {"full": 0, "partial": 0, "arranged": 0} + self._tree_version = 0 self._last_cursor = None self.verify = bool(os.environ.get("TAI_TUI_VERIFY")) self.verified = 0 @@ -20044,6 +20453,14 @@ class TuiApp: self._input_seen = False self._last_frame = 0.0 self._stdout_fd = None + self._stdin_fd = None + self._wake = None + self._loop_thread = None + self._tickers = [] + self._tickers_version = -1 + self._terms = [] + self._terms_version = -1 + self.iterations = 0 self._readers = {} self._writers = {} self._eof = set() @@ -20056,21 +20473,74 @@ class TuiApp: self.root = root self.root._bind_app(self) self.focused = None + self._layout_full = True + self._stack = [] self.layout() self.auto_focus() self.dirty_paint() return self.root 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 + """Anonymous "geometry or structure changed somewhere": the next + layout arranges the whole tree. paint=True (the default, for callers + that also edited state without saying so) also drops every cached panel; paint=False trusts the panels' own touch() and lets the layout - pass diff rects.""" + pass diff rects. Precise callers use relayout().""" with self._lock: self._need_layout = True self._need_paint = True + self._layout_full = True if paint: self._drop_all = True + self.wake() + + LAYOUT_STARTS = 64 + + def relayout(self, panel, inner=False, restack=False): + """Thread-safe and precise: only this panel's subtree is arranged + again. inner=False means the panel's own size, position or chrome + changed, so its parent lays it out again; inner=True means its + children (or content) changed, so it lays them out again. Either way + the work climbs only while the panel's size follows its content + (content_sized), and stops at the first fixed-size panel. restack + marks a panel added, removed, shown or hidden.""" + with self._lock: + self._need_layout = True + self._need_paint = True + if len(self._layout_starts) >= TuiApp.LAYOUT_STARTS: + self._layout_full = True + else: + self._layout_starts.append((panel, "inner" if inner else "own")) + if restack: + self._restack = True + self._tree_version += 1 + self.wake() + + def run_on_loop(self, call): + """Run call() on the loop thread: now when already there (or when no + loop runs, as in headless tests), else queued thread-safely.""" + loop = self._loop + if loop is None or threading.get_ident() == self._loop_thread: + call() + return + try: + loop.call_soon_threadsafe(call) + except RuntimeError: + pass + + def tree_changed(self): + """A panel gained or lost an on_tick handler: the cached ticker and + terminal lists are stale.""" + with self._lock: + self._tree_version += 1 + + def restack(self): + """Paint order changed (z or layer) without any geometry change.""" + with self._lock: + self._need_layout = True + self._need_paint = True + self._restack = True + self.wake() def dirty_paint(self): """Anonymous "something changed": repaint every panel. Precise @@ -20078,12 +20548,32 @@ class TuiApp: with self._lock: self._need_paint = True self._drop_all = True + self.wake() 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 + if panel.w == "auto" or panel.h == "auto": + self._watch.add(panel) + self._need_layout = True + self.wake() + + def wake(self): + """Thread-safe: end the main loop's sleep early. The loop sleeps + until its next timer, animation frame or event, so anything that makes + a frame due (a touch, a timer, a thread's result) calls this.""" + loop = self._loop + if loop is None or self._wake is None: + return + if threading.get_ident() == self._loop_thread: + self._wake.set() + else: + try: + loop.call_soon_threadsafe(self._wake.set) + except RuntimeError: + pass def set_theme(self, name): """Switch the global theme and repaint every cell. Cells are diffed @@ -20108,6 +20598,7 @@ class TuiApp: self._timer_seq += 1 handle = [time.time() + max(0.0, delay), interval, callback, self._timer_seq] self.timers.append(handle) + self.wake() return handle def _fire_timers(self, now): @@ -20228,37 +20719,167 @@ class TuiApp: if node is not None and (node is panel or node.root() is not self.root or not node.is_shown()): self.focused = None self._restore_focus() - self.dirty_layout() # -- layout + paint ---------------------------------------------- # def layout(self): - """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.""" + """Arrange, restack, and work out what the pass changed. Only the + subtrees that asked (relayout) are arranged again, each from the + nearest ancestor whose size does not follow its content; an anonymous + dirty_layout, a resize or the first pass arranges everything. Moved or + resized panels damage only their old and new rects, and a stack that + gained or lost panels damages those panels' rects; a stack whose + remaining panels changed order is the whole screen.""" with self._lock: self._need_layout = False + full, self._layout_full = self._layout_full, False + restack, self._restack = self._restack, False + starts, self._layout_starts = self._layout_starts, [] + watch, self._watch = self._watch, set() + for panel in watch: + seen = panel._measure_seen + size = panel.measure() + if size != seen and panel.root() is self.root: + outer = seen is None or (panel.w == "auto" and size[0] != seen[0]) or (panel.h == "auto" and size[1] != seen[1]) + starts.append((panel, "inner" if outer else "self")) + everything = full or not self._stack + roots = [self.root] if everything else self._layout_roots(starts) + if roots is None: + everything, roots = True, [self.root] + TuiPanel._measure_memo = {} + try: + for node in roots: + if everything: + node.arrange(0, 0, self.cols, self.rows, (0, 0, self.cols, self.rows)) + else: + node.arrange(*node._arrange) + finally: + TuiPanel._measure_memo = None + self.layouts["full" if everything else "partial"] += 1 + if self.verify and not everything: + self._verify_layout() + if everything or restack or self._geometry_changed_membership(roots): + self._restack_diff() + else: + self._geometry_diff(roots) + self.hitmap.rebuild(self._stack) + + def _layout_roots(self, starts): + """The panels to arrange again for these requests, outermost only, + or None when the request cannot be narrowed (arrange everything).""" + found = [] + for panel, mode in starts: + if panel.root() is not self.root: + continue + node = panel.parent if mode == "own" else panel + if node is None: + return None + while mode != "self" and node.content_sized(): + node = node.parent + while node._arrange is None and node.parent is not None: + node = node.parent + if node._arrange is None: + return None + if node.is_shown(): + found.append(node) + keep = set(found) + roots = [] + for node in found: + above = node.parent + while above is not None and above not in keep: + above = above.parent + if above is None and node not in roots: + roots.append(node) + return roots + + @staticmethod + def _shown_subtree(node): + """node and every descendant that arrange() reaches.""" + out = [] + pending = [node] + while pending: + top = pending.pop() + out.append(top) + pending.extend(child for child in top.children if child.visible) + return out + + def _geometry_changed_membership(self, roots): + """True when a re-arranged panel entered or left the stack (it became + zero-sized, or was zero-sized and grew).""" + for root in roots: + for node in TuiApp._shown_subtree(root): + listed = node._geom is not None + if listed != (node.aw > 0 and node.ah > 0): + return True + return False + + def _geometry_diff(self, roots): + """Same stack: damage the old and new rects of the moved or resized + panels among the re-arranged subtrees.""" + rects = self._geom_rects + for root in roots: + for node in TuiApp._shown_subtree(root): + geom = (node.ax, node.ay, node.aw, node.ah, node.clip) + was = node._geom + self.layouts["arranged"] += 1 + if was is None or was == geom: + continue + rects.append(was[4]) + rects.append(geom[4]) + if was[2:4] != geom[2:4]: + node._cells = None + node._geom = geom + + def _restack_diff(self): + """The stack may have changed: rebuild it and damage what differs.""" + old = self._stack + stack = self.compositor.flatten(self.root) + self._tree_version += 1 + rects = self._geom_rects + if not old: + self._geom_full = True + elif stack != old: + now = set(stack) + before = set(old) + if [node for node in old if node in now] != [node for node in stack if node in before]: + self._geom_full = True + else: + for node in old: + if node not in now: + rects.append(node._geom[4]) + node._geom = None + for node in stack: + geom = (node.ax, node.ay, node.aw, node.ah, node.clip) + was = node._geom + if was is None: + rects.append(geom[4]) + elif was != geom: + rects.append(was[4]) + rects.append(geom[4]) + if was[2:4] != geom[2:4]: + node._cells = None + node._geom = geom + self.layouts["arranged"] += len(stack) + self._stack = stack + + def _verify_layout(self): + """Oracle check: arranging the whole tree again must not move any + panel the partial pass left alone. Raises TuiError naming the first.""" + def snapshot(): + return [(node, node.ax, node.ay, node.aw, node.ah, node.clip, node.scroll_x, node.scroll_y) + for node in self.root.walk() if node._arrange is not None] + before = snapshot() 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) + after = {id(row[0]): row for row in snapshot()} + for row in before: + want = after.get(id(row[0])) + if want is not None and want[1:] != row[1:]: + raise TuiError("partial layout diverged from full layout for %s id=%r: partial %r full %r" % ( + type(row[0]).__name__, row[0].id, row[1:], want[1:])) def _damage_rects(self): """Rects to recompose for this frame, or None for the whole screen. @@ -20271,16 +20892,26 @@ class TuiApp: rects, self._geom_rects = self._geom_rects, [] full = drop or self._geom_full self._geom_full = False + self._shifts = [] if drop: for node in self.root.walk(): node._cells = None + node._shift = 0 if full: + for panel in touched: + panel._shift = 0 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 + if panel._shift: + self._shifts.append(panel.content_rect() + (panel._shift,)) + panel._shift = 0 + if len(rects) > TUI_DAMAGE_RECTS: + self._shifts = [] + return None + return rects def frame_bytes(self): """The bytes that bring the terminal up to date; "" when nothing on @@ -20289,7 +20920,8 @@ class TuiApp: self.layout() compositor = self.compositor fresh = compositor.last is None - body = compositor.refresh(self._stack, self.rows, self.cols, self._damage_rects()) + rects = self._damage_rects() + body = compositor.refresh(self._stack, self.rows, self.cols, rects, self._shifts if rects is not None else ()) cursor = self._cursor_bytes() if self.verify: self._verify_frame() @@ -20397,7 +21029,6 @@ class TuiApp: def tick_animations(self, now): if not self.animations: return - layout_props = ("x", "y", "w", "h") for anim in list(self.animations): panel = anim["panel"] if anim["started"] is None: @@ -20412,10 +21043,6 @@ class TuiApp: setattr(panel, anim["prop"], value) except (AttributeError, TypeError, ValueError): pass - if anim["prop"] in layout_props: - self.dirty_layout() - else: - self.dirty_paint() if progress >= 1.0: self.animations.remove(anim) if callable(anim["on_done"]): @@ -20424,8 +21051,15 @@ class TuiApp: except Exception: pass + def tickers(self): + """Panels with an on_tick handler, found once per tree change.""" + if self._tickers_version != self._tree_version: + self._tickers = [node for node in self.root.walk() if callable(getattr(node, "on_tick", None))] + self._tickers_version = self._tree_version + return self._tickers + def tick_widgets(self, dt, now): - for node in self.root.walk(): + for node in self.tickers(): handler = getattr(node, "on_tick", None) if callable(handler): try: @@ -20446,10 +21080,20 @@ class TuiApp: return (max(TUI_MIN_ROWS, rows or 0), max(TUI_MIN_COLS, cols or 0)) def _terminals(self): - return [node for node in self.root.walk() if isinstance(node, TuiTerminal)] + """The live terminal panels, found once per tree change.""" + if self._terms_version != self._tree_version: + self._terms = [node for node in self.root.walk() if isinstance(node, TuiTerminal)] + self._terms_version = self._tree_version + return self._terms def _on_winch(self, _signum, _frame): self._resize_pending = True + loop, wake = self._loop, self._wake + if loop is not None and wake is not None: + try: + loop.call_soon_threadsafe(wake.set) + except RuntimeError: + pass def _setup(self): if select is None or termios is None or tty is None: @@ -20493,6 +21137,7 @@ class TuiApp: def quit(self): self._running = False + self.wake() @staticmethod def write_all(fd, data): @@ -20555,12 +21200,15 @@ class TuiApp: if not data: self._loop.remove_reader(fd) self._running = False + self.wake() return self._input_seen = True for event in self.input.feed(data): self.dispatch(event) if time.time() - self._last_frame >= TUI_INPUT_PAINT_GAP: self._paint_frame() + if self.input.buf or self._need_paint or self._need_layout: + self.wake() def _paint_frame(self): """Write one frame if anything is dirty. False when stdout is gone.""" @@ -20581,6 +21229,7 @@ class TuiApp: self._eof.add(fd) if self._readers.pop(fd, None) is not None: self._loop.remove_reader(fd) + self.wake() def _on_term_writable(self, term): term.drain_pending() @@ -20619,12 +21268,20 @@ class TuiApp: keep it, stop the loop, and re-raise it after the terminal restores.""" self._failure = context.get("exception") or RuntimeError(context.get("message", "tui loop error")) self._running = False + self.wake() async def _main(self): + """The event loop. It sleeps until something is due: the next timer, + the next animation or ticker frame, a pending partial key sequence, + or a wake() (a touch from any thread, a pty, a signal). A quiet screen + costs no wakeups beyond the TUI_IDLE_MAX heartbeat, and paints are + spaced at least one frame apart.""" loop = asyncio.get_running_loop() self._loop = loop + self._wake = asyncio.Event() + self._loop_thread = threading.get_ident() loop.set_exception_handler(self._loop_failed) - stdin_fd = sys.stdin.fileno() + stdin_fd = self._stdin_fd if self._stdin_fd is not None else sys.stdin.fileno() self.dirty_layout() self._running = True loop.add_reader(stdin_fd, self._on_stdin, stdin_fd) @@ -20632,6 +21289,8 @@ class TuiApp: last = time.time() try: while self._running: + self._wake.clear() + self.iterations += 1 started = time.time() self._fire_timers(started) self._sync_terminals() @@ -20641,18 +21300,22 @@ class TuiApp: self.compositor.invalidate() self.dirty_layout() self.root.emit(TuiResize(panel=self.root, rows=self.rows, cols=self.cols)) - if not self._input_seen: + if not self._input_seen and self.input.buf: for event in self.input.flush(): self.dispatch(event) self._input_seen = False tick_now = time.time() self.tick_animations(tick_now) - self.tick_widgets(tick_now - last, tick_now) + if self.tickers(): + self.tick_widgets(tick_now - last, tick_now) last = tick_now self.ensure_focus() + wait = self._last_frame + frame - time.time() + if (self._need_paint or self._need_layout) and wait > 0: + await asyncio.sleep(wait) if not self._paint_frame(): break - await asyncio.sleep(max(0.0, frame - (time.time() - started))) + await self._sleep(self._idle_delay(frame, started)) finally: for fd in list(self._readers): loop.remove_reader(fd) @@ -20665,6 +21328,30 @@ class TuiApp: except Exception: pass self._loop = None + self._wake = None + + def _idle_delay(self, frame, started): + """Seconds the loop may sleep: zero when a frame is already due, one + frame while something animates or ticks or a key sequence is half + read, else until the next timer, never past TUI_IDLE_MAX.""" + now = time.time() + if self._need_paint or self._need_layout or self._resize_pending: + return max(0.0, self._last_frame + frame - now) + if self.animations or self.tickers() or self.input.buf: + return max(0.0, frame - (now - started)) + delay = TUI_IDLE_MAX + for deadline, _interval, _callback, _seq in self.timers: + delay = min(delay, deadline - now) + return max(0.0, delay) + + async def _sleep(self, delay): + if delay <= 0: + await asyncio.sleep(0) + return + try: + await asyncio.wait_for(self._wake.wait(), delay) + except asyncio.TimeoutError: + pass def tui_build_demo(app, live=True): @@ -20783,7 +21470,6 @@ def tui_build_demo(app, live=True): elif event.index == 2: refs["banner_on"] = not refs["banner_on"] banner.visible = refs["banner_on"] - app.dirty_layout() elif event.index == 3: close_top() elif event.index == 4: @@ -20813,7 +21499,6 @@ def tui_build_demo(app, live=True): if live: def tick_clock(): header.text = " tai TUI demo -- %s" % now_iso() - app.dirty_paint() def tick_progress(): prog_a.set((prog_a.value + 0.02) % 1.0) @@ -20824,7 +21509,6 @@ def tui_build_demo(app, live=True): return room = max(1, stage.aw - 68) banner.x = 1 + int(time.time() * 14) % room - app.dirty_layout() def heartbeat(): refs["log_count"] += 1 @@ -20845,7 +21529,6 @@ def tui_demo(): app.mount(root) for term in app._terminals(): term.spawn() - app.dirty_layout() return app.run() except TuiError as exc: print("demo unavailable: %s" % exc, file=sys.stderr) @@ -21222,17 +21905,51 @@ class TuiChatLog(TuiLog): """Transcript view with a slim scrollbar in the last column, drawn only when there is something to scroll.""" - def paint(self): - rows = super().paint() + def bar_geometry(self): + """(thumb top row, thumb height) of the scrollbar, or None when there + is nothing to scroll.""" width = self._content_w() height = self._content_h() total = len(self.lines) if total <= height or height < 3 or width < 2: - return rows - edge = 1 if self.border else 0 + return None thumb = max(1, height * height // total) span = max(1, total - height) - top = int((height - thumb) * min(1.0, self.scroll_y / float(span))) + return int((height - thumb) * min(1.0, self.scroll_y / float(span))), thumb + + def paint_cells(self, compositor): + """The cell-space paint(): the log's reused rows with the scrollbar + cell written over each content row's last column.""" + kind = type(self) + edge = 1 if self.border else 0 + if (edge or kind.paint is not TuiChatLog.paint or kind.content_rows is not TuiLog.content_rows + or self.aw < self.chrome_w() + 2): + return TuiPanel.paint_cells(self, compositor) + rows = self._log_cells(compositor) + geometry = self.bar_geometry() + if geometry is None: + return rows + top, thumb = geometry + height = self._content_h() + pad_top, pad_right = self.padding[0], self.padding[1] + blank = compositor.cell(" ", self.style.key()) + track = compositor.cell("│", TuiStyle(bg=self.style.bg, fg="role:border").key()) + grip = compositor.cell("┃", TuiStyle(bg=self.style.bg, fg="role:accent", bold=True).key()) + at = self.aw - pad_right - 1 + tail = [blank] * pad_right + for local in range(height): + row = rows[pad_top + local] + rows[pad_top + local] = row[:at] + [grip if top <= local < top + thumb else track] + tail + return rows + + def paint(self): + rows = super().paint() + geometry = self.bar_geometry() + if geometry is None: + return rows + top, thumb = geometry + height = self._content_h() + edge = 1 if self.border else 0 track = TuiStyle(bg=self.style.bg, fg="role:border") grip = TuiStyle(bg=self.style.bg, fg="role:accent", bold=True) out = [] @@ -21795,6 +22512,7 @@ def tui_build_chat(app, agent, spawn=None): return session.busy = True session.since = time.time() + arm_spin() worker = session.agent worker.on_phase = lambda name, detail="": set_phase(session, name, detail) worker.prompter = lambda kind, ask, options: prompter(kind, ask, options, session) @@ -21940,7 +22658,6 @@ def tui_build_chat(app, agent, spawn=None): dialog.on_close = finish shown.append(dialog) app.root.add(dialog) - app.dirty_layout(paint=False) def chosen(value): if value is TUI_CHAT_TYPE_ANSWER: @@ -21969,7 +22686,6 @@ def tui_build_chat(app, agent, spawn=None): dialog.on_close = closed shown.append(style_dialog(dialog)) app.root.add(dialog) - app.dirty_layout(paint=False) before = session.phase, session.detail session.phase, session.detail = "ask", "" @@ -21986,9 +22702,15 @@ def tui_build_chat(app, agent, spawn=None): session.phase, session.detail = before return answer["value"] + def arm_spin(): + if refs.get("spin_timer") is None: + refs["spin_timer"] = app.every(0.08, spin) + def spin(): live = [item for item in sessions.values() if item.busy] if not live: + app.cancel_timer(refs.get("spin_timer")) + refs["spin_timer"] = None return refs["spin"] += 1 for item in live: @@ -22003,7 +22725,6 @@ 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(paint=False) app.ensure_focus() def on_resize(event): @@ -22017,7 +22738,6 @@ 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(paint=False) app.ensure_focus() app.after(0.05, maybe_replay) @@ -22045,14 +22765,13 @@ def tui_build_chat(app, agent, spawn=None): box.h = rows send_btn.h = rows composer.h = rows + 2 - app.dirty_layout(paint=False) sidebar.on_select = lambda event: select_bot(event.value) root.on_key = on_root_key root.on_resize = on_resize - app.every(0.2, tick_input) + box.on_change = lambda event: tick_input() + tick_input() app.every(1.0, paint_status) - app.every(0.08, spin) on_resize(None) refresh_bots() render_history() diff --git a/test_tai.py b/test_tai.py index 1f8f273..e9a0948 100644 --- a/test_tai.py +++ b/test_tai.py @@ -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: diff --git a/tui.md b/tui.md index dde485f..bb5c263 100644 --- a/tui.md +++ b/tui.md @@ -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_` on +Mount, Unmount, Select, Press, Close, Paste, Change (a text box's line +count changed, `rows`; does not bubble). Dispatch calls `on_` 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