From e5ed00c52bb10aec811734c5568c881c41e58474 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 8 Jun 2026 10:41:30 -0600 Subject: Replace print CLIRenderer with differential TUI engine Implement TUI Phase 1: a raw-mode terminal, differential render engine, pinned input box + footer, and component model wired into the libpanto event stream. Adds foundation modules (theme, key, component, input, terminal), the render engine, components, and the app loop; removes the old print CLIRenderer and driveTurn REPL from main.zig. Also removes scratch status reports (tui-p1/, progress.md) for the now completed work. --- src/tui_engine.zig | 1116 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1116 insertions(+) create mode 100644 src/tui_engine.zig (limited to 'src/tui_engine.zig') diff --git a/src/tui_engine.zig b/src/tui_engine.zig new file mode 100644 index 0000000..fbe8474 --- /dev/null +++ b/src/tui_engine.zig @@ -0,0 +1,1116 @@ +//! The differential render engine for the TUI (plan §3). +//! +//! The engine owns a LIST of live `Component`s (plan invariant: there is NO +//! single "active component" — multiple tool calls render in parallel later, +//! so the engine always walks a list, even when the list has one element). +//! Each frame it walks the list top-to-bottom, asks each component to +//! `render(width)` into lines, and uses `firstLineChanged` plus an old-vs-new +//! line diff to repaint only what changed. +//! +//! Output is abstracted behind a `*std.Io.Writer` sink so the engine is +//! unit-testable without a real TTY: tests inject an in-memory writer and +//! assert on the emitted bytes. The real terminal is one implementation of the +//! sink (a `Terminal`-backed writer, wired in the app sub-phase). +//! +//! ## The render passes (plan §3.3) +//! +//! A single top-to-bottom walk: +//! 1. Render each component (or reuse its cached lines) and accumulate a +//! global line offset for each. +//! 2. Compute `cut = min(offset_i + firstLineChanged_i)` across ALL +//! components whose `firstLineChanged` is non-null. NOT just the first +//! dirty component: a ticking footer below an otherwise-clean transcript +//! is the counter-example. +//! 3. Reprint from `cut` downward. Components fully above the cut are +//! untouched. The component owning the cut and any dirty component below +//! re-render from their local `firstLineChanged`. CLEAN components below +//! the cut reuse their CACHED lines verbatim (reprinted because they sit +//! below the rolled-back point, but never re-rendered — no component CPU). +//! 4. Length deltas: when a component returns fewer lines than before, the +//! orphaned trailing lines are cleared and offsets below recomputed. +//! 5. Line-diff backstop: from `cut` downward we still diff old-vs-new +//! lines. `firstLineChanged` decides WHERE re-rendering starts (the fast +//! path); the diff is the CORRECTNESS FLOOR that defends against an +//! inaccurate signal and handles length deltas. +//! +//! ## Viewport & scrollback (plan §3.2) +//! +//! Lines above `viewport_top` have scrolled into the terminal's NATIVE +//! scrollback and are off-limits to differential updates. If a change lands +//! above `viewport_top`, the engine falls back to a full redraw. We never +//! implement our own scrollback — content that grows past the top scrolls up +//! into the real terminal scrollback. +//! +//! ## Output discipline (plan §3.1) +//! +//! Every frame is wrapped in synchronized output. A full redraw happens ONLY +//! when forced (first paint, width change, height change, or a change above +//! `viewport_top`) and clears scrollback; ordinary frames NEVER clear +//! scrollback. + +const std = @import("std"); +const component = @import("tui_component.zig"); +const terminal = @import("tui_terminal.zig"); + +const Component = component.Component; +const CURSOR_MARKER = component.CURSOR_MARKER; + +pub const Error = error{ + /// A component returned a line whose visible width exceeds the render + /// width. Components must truncate; the engine treats overflow as a hard + /// error (plan §3.1). The caller is expected to restore the terminal and + /// surface a diagnostic. + LineOverflow, +} || std.mem.Allocator.Error || std.Io.Writer.Error; + +/// Visible (display-column) width of a rendered line, ignoring escape +/// sequences and the zero-width cursor marker. +/// +/// P1 scope: strips CSI (`ESC [ ... final`), SS2/SS3 (`ESC N`/`ESC O` + 1 +/// byte), OSC (`ESC ] ... BEL|ST`), and APC/PM/DCS-style strings (`ESC _`/`ESC +/// ^`/`ESC P` ... `ST`) — the last covers `CURSOR_MARKER`. Remaining bytes are +/// counted as UTF-8 codepoints, one column each. Wide-character (CJK/emoji) +/// width is a documented P1 approximation; refining it is deferred. +pub fn visibleWidth(line: []const u8) usize { + var cols: usize = 0; + var i: usize = 0; + while (i < line.len) { + const b = line[i]; + if (b == 0x1b) { + i += skipEscape(line[i..]); + continue; + } + // Count one column per UTF-8 codepoint start byte. + const seq_len = std.unicode.utf8ByteSequenceLength(b) catch 1; + cols += 1; + i += @min(seq_len, line.len - i); + } + return cols; +} + +/// Returns the number of bytes the escape sequence at the start of `s` +/// (s[0] == ESC) occupies. `s` is guaranteed non-empty with s[0] == 0x1b. +fn skipEscape(s: []const u8) usize { + if (s.len < 2) return s.len; // lone trailing ESC + switch (s[1]) { + '[' => { + // CSI: params/intermediates until a final byte 0x40..0x7e. + var i: usize = 2; + while (i < s.len) : (i += 1) { + if (s[i] >= 0x40 and s[i] <= 0x7e) return i + 1; + } + return s.len; + }, + ']' => { + // OSC: terminated by BEL or ST (ESC \). + var i: usize = 2; + while (i < s.len) : (i += 1) { + if (s[i] == 0x07) return i + 1; + if (s[i] == 0x1b and i + 1 < s.len and s[i + 1] == '\\') return i + 2; + } + return s.len; + }, + '_', '^', 'P' => { + // APC / PM / DCS: terminated by ST (ESC \). Covers CURSOR_MARKER. + var i: usize = 2; + while (i < s.len) : (i += 1) { + if (s[i] == 0x1b and i + 1 < s.len and s[i + 1] == '\\') return i + 2; + } + return s.len; + }, + 'N', 'O' => { + // SS2 / SS3: one following byte. + return @min(@as(usize, 3), s.len); + }, + else => { + // ESC + single byte (e.g. ESC c) — consume both. + return 2; + }, + } +} + +/// Strip the cursor marker from `line` into `out`, returning the written +/// slice. P1 only removes the marker so it never prints; locating it for +/// hardware-cursor placement is the P3 hook (see `cursor_hint`). +fn stripCursorMarker(line: []const u8, out: []u8) []const u8 { + if (std.mem.indexOf(u8, line, CURSOR_MARKER)) |idx| { + const before = line[0..idx]; + const after = line[idx + CURSOR_MARKER.len ..]; + @memcpy(out[0..before.len], before); + @memcpy(out[before.len .. before.len + after.len], after); + return out[0 .. before.len + after.len]; + } + return line; +} + +/// Per-component bookkeeping carried across frames. +const Slot = struct { + comp: Component, + /// Global (engine-wide) line offset of this component's first line at the + /// last render. + offset: usize = 0, + /// Line count this component produced at the last render. + line_count: usize = 0, + /// The lines this component produced at the last render. These are the + /// diff baseline AND the cache the engine reprints verbatim for a clean + /// component below the cut. Owned by the engine (duped per render). + lines: [][]u8 = &.{}, +}; + +/// A monotonic clock abstraction so the coalescing scheduler is testable +/// without a real clock. `now()` returns nanoseconds. +pub const Clock = struct { + ptr: *anyopaque, + nowFn: *const fn (ptr: *anyopaque) i128, + + pub fn now(self: Clock) i128 { + return self.nowFn(self.ptr); + } + + // A real monotonic clock is provided by the app sub-phase as an + // `Io`-backed `Clock` (this std's monotonic clock lives behind the `Io` + // interface, `std.Io.Clock.now(.awake, io)`, so it can't be a free + // function here). The engine stays Io-agnostic: it only consumes the + // injected `Clock` vtable. Tests inject a deterministic clock directly. +}; + +/// Frame coalescing scheduler (plan §3.4). +/// +/// `requestRender` records that a frame is wanted. `shouldRenderNow` is the +/// pure decision function: render immediately when idle (no frame drawn within +/// the coalescing window), otherwise defer until the window since the last +/// render elapses. The window is a CEILING so we never redraw faster than the +/// terminal can drain (~120fps default). The actual sleeping/event-loop +/// integration belongs to the app sub-phase; this struct only decides. +pub const Scheduler = struct { + /// Coalescing window in nanoseconds (default ~8ms ≈ 120fps). + window_ns: i128 = 8 * std.time.ns_per_ms, + /// Timestamp of the last render, or null if none yet. + last_render: ?i128 = null, + /// Whether a render has been requested since the last one ran. + pending: bool = false, + + pub fn init(window_ns: i128) Scheduler { + return .{ .window_ns = window_ns }; + } + + /// Record that a frame is wanted. + pub fn requestRender(self: *Scheduler) void { + self.pending = true; + } + + /// Pure decision: given the current time, may we render now? Renders + /// immediately when idle; under burst, defers until the window elapses. + pub fn shouldRenderNow(self: *const Scheduler, now: i128) bool { + if (!self.pending) return false; + const last = self.last_render orelse return true; // idle: render now + return now - last >= self.window_ns; + } + + /// Nanoseconds until the next render is permitted, or 0 if allowed now. + /// Useful for an event loop's sleep duration. Returns null when no frame + /// is pending. + pub fn nextDeadline(self: *const Scheduler, now: i128) ?i128 { + if (!self.pending) return null; + const last = self.last_render orelse return 0; + const elapsed = now - last; + if (elapsed >= self.window_ns) return 0; + return self.window_ns - elapsed; + } + + /// Mark that a render ran at `now`, clearing the pending flag. + pub fn noteRendered(self: *Scheduler, now: i128) void { + self.last_render = now; + self.pending = false; + } +}; + +/// The differential render engine. +pub const Engine = struct { + alloc: std.mem.Allocator, + /// Output sink. The real terminal is one implementation; tests inject an + /// in-memory writer. + writer: *std.Io.Writer, + /// Whether to wrap frames in synchronized-output escapes. + synchronized_output: bool, + + /// Live components, top-to-bottom. The engine holds a LIST (never a single + /// active component). + slots: std.ArrayList(Slot) = .empty, + + /// Render width in columns. Components are asked to render at this width + /// and every returned line must fit. + width: usize, + /// Terminal height in rows. + height: usize, + + /// Global line index of the first line still inside the visible viewport. + /// Lines below this index are addressable for differential repaint; lines + /// above have scrolled into native scrollback and are off-limits. + viewport_top: usize = 0, + /// Total lines produced by the last render pass. + total_lines: usize = 0, + /// Highest `total_lines` we've ever rendered (used to clear orphaned + /// trailing lines on shrink). + max_lines_rendered: usize = 0, + /// Forces a full redraw on the next frame (first paint, resize). + force_full: bool = true, + + /// P3 hook: the global (line, col) where the focused component drew its + /// cursor marker, or null. P1 records it but leaves the hardware cursor at + /// end-of-content (the documented safe deferral); P3 wires hardware + /// positioning. + cursor_hint: ?struct { line: usize, col: usize } = null, + + pub fn init( + alloc: std.mem.Allocator, + writer: *std.Io.Writer, + width: usize, + height: usize, + synchronized_output: bool, + ) Engine { + return .{ + .alloc = alloc, + .writer = writer, + .synchronized_output = synchronized_output, + .width = width, + .height = height, + }; + } + + pub fn deinit(self: *Engine) void { + for (self.slots.items) |*slot| self.freeSlotLines(slot); + self.slots.deinit(self.alloc); + } + + fn freeSlotLines(self: *Engine, slot: *Slot) void { + for (slot.lines) |line| self.alloc.free(line); + if (slot.lines.len != 0) self.alloc.free(slot.lines); + slot.lines = &.{}; + } + + // -- component list management ----------------------------------------- + + /// Append a component to the live list. The engine does not take ownership + /// of the component's backing state, only of its slot bookkeeping. + pub fn addComponent(self: *Engine, comp: Component) !void { + try self.slots.append(self.alloc, .{ .comp = comp }); + self.force_full = true; // layout changed + } + + /// Remove the component matching `comp.ptr` from the list. Returns true if + /// found. Forces a full redraw (layout changed). + pub fn removeComponent(self: *Engine, comp: Component) bool { + for (self.slots.items, 0..) |slot, i| { + if (slot.comp.ptr == comp.ptr) { + var removed = self.slots.orderedRemove(i); + self.freeSlotLines(&removed); + self.force_full = true; + return true; + } + } + return false; + } + + pub fn componentCount(self: *const Engine) usize { + return self.slots.items.len; + } + + // -- resize ------------------------------------------------------------ + + /// Apply a new terminal size. A width change (wrapping changes) or height + /// change (viewport realignment) forces a full redraw on the next frame. + pub fn resize(self: *Engine, width: usize, height: usize) void { + if (width != self.width or height != self.height) { + self.force_full = true; + } + self.width = width; + self.height = height; + } + + /// Force the next frame to be a full redraw (e.g. on unrecoverable doubt). + pub fn forceFullRedraw(self: *Engine) void { + self.force_full = true; + } + + // -- the render -------------------------------------------------------- + + /// Render a frame. Walks the component list, computes the differential + /// repaint, and writes it (wrapped in synchronized output) to the sink. + /// + /// Returns `anyerror` because a component's `render` may surface an + /// arbitrary error; engine-originated failures are the narrower + /// `Engine.Error` (notably `Error.LineOverflow` for the width contract). + pub fn render(self: *Engine) anyerror!void { + // 1. Collect this frame's lines per component, computing global + // offsets. Clean components below the eventual cut keep their cached + // lines (no re-render); only dirty/first-render components render. + const Frame = struct { + new_lines: []const []const u8, // borrowed (component- or cache-owned) + first_changed: ?usize, // local first-changed signal + old_count: usize, + offset: usize, + }; + var frames = try self.alloc.alloc(Frame, self.slots.items.len); + defer self.alloc.free(frames); + + var offset: usize = 0; + var cut: ?usize = null; + + for (self.slots.items, 0..) |*slot, idx| { + const first_changed = slot.comp.firstLineChanged(); + // Render only when there's a reason to: dirty signal, or first + // paint (no cached lines yet), or a forced full redraw. + const must_render = first_changed != null or slot.lines.len == 0 or self.force_full; + + var new_lines: []const []const u8 = undefined; + if (must_render) { + new_lines = try slot.comp.render(self.width, self.alloc); + } else { + // CLEAN below-cut reuse path: no re-render, reprint the cache. + new_lines = slot.lines; + } + + // Width contract (plan §3.1): enforce every line fits. + for (new_lines) |line| { + if (visibleWidth(line) > self.width) return Error.LineOverflow; + } + + frames[idx] = .{ + .new_lines = new_lines, + .first_changed = first_changed, + .old_count = slot.line_count, + .offset = offset, + }; + + // 2. cut = min across ALL components (not just the first dirty). + if (first_changed) |fc| { + const global = offset + fc; + cut = if (cut) |c| @min(c, global) else global; + } + // A length delta is itself a change point even when the component + // reports firstLineChanged == null (the diff backstop will catch + // the content, but the boundary must roll the cut back). + if (new_lines.len != slot.line_count) { + const boundary = offset + @min(new_lines.len, slot.line_count); + cut = if (cut) |c| @min(c, boundary) else boundary; + } + + offset += new_lines.len; + } + const new_total = offset; + + // 2b. Line-diff backstop (plan §3.3 step 5): the CORRECTNESS FLOOR. + // `firstLineChanged` is the fast-path *input* deciding where + // re-rendering starts, but a component can misreport it. From the + // signal-derived cut downward we diff the flattened OLD global lines + // against the NEW global lines and lower `cut` to the first true + // divergence. For clean (not re-rendered) components new == old, so + // they contribute no false divergence; only a component that + // actually re-rendered to different bytes (or changed length) can + // move the cut here. This both defends a lying signal and is what + // ultimately guarantees correctness. + if (try self.lineDiffBackstop(frames)) |diff_cut| { + cut = if (cut) |c| @min(c, diff_cut) else diff_cut; + } + + // 3. Decide full vs differential. + // Full redraw is forced by: first paint / resize (`force_full`), or + // a change that lands above `viewport_top` (off-limits to diff). + var full = self.force_full; + if (!full) { + if (cut) |c| { + if (c < self.viewport_top) full = true; + } + } + + try self.beginFrame(); + + if (full) { + try self.fullRedraw(frames, new_total); + } else { + try self.differential(frames, cut, new_total); + } + + try self.endFrame(); + + // 4. Commit: store each component's new lines as the next baseline and + // record offsets. Clean-reuse slots keep their existing owned copy. + var commit_offset: usize = 0; + for (self.slots.items, 0..) |*slot, idx| { + const f = frames[idx]; + if (f.new_lines.ptr != slot.lines.ptr or f.new_lines.len != slot.lines.len) { + // The component rendered fresh lines; dupe them as the baseline. + try self.storeSlotLines(slot, f.new_lines); + } + slot.offset = commit_offset; + slot.line_count = f.new_lines.len; + commit_offset += f.new_lines.len; + } + + self.total_lines = new_total; + if (new_total > self.max_lines_rendered) self.max_lines_rendered = new_total; + self.recomputeViewportTop(); + self.force_full = false; + } + + /// Dupe `new_lines` into the slot's owned baseline, freeing the old copy. + fn storeSlotLines(self: *Engine, slot: *Slot, new_lines: []const []const u8) !void { + var copies = try self.alloc.alloc([]u8, new_lines.len); + var made: usize = 0; + errdefer { + for (copies[0..made]) |c| self.alloc.free(c); + self.alloc.free(copies); + } + for (new_lines, 0..) |line, i| { + copies[i] = try self.alloc.dupe(u8, line); + made = i + 1; + } + self.freeSlotLines(slot); + slot.lines = copies; + } + + /// Line-diff backstop (the CORRECTNESS FLOOR). Flattens the previous + /// baseline lines (`slot.lines`) and the new frame lines into global arrays + /// and returns the FIRST global index where they actually differ, or null + /// if identical. + /// + /// It scans from index 0 (NOT from the signal cut) precisely because the + /// signal can be wrong: a component may claim its change starts lower than + /// it really does. The caller takes `min(signal_cut, backstop)`, so this + /// can only roll the cut earlier, never later. For clean (not re-rendered) + /// components the new slice IS the old baseline, so they contribute no + /// spurious divergence; only a component that actually produced different + /// bytes — or changed length — moves the cut here. + fn lineDiffBackstop(self: *Engine, frames: anytype) Error!?usize { + // Build old and new flattened global line arrays. + var old_lines: std.ArrayList([]const u8) = .empty; + defer old_lines.deinit(self.alloc); + var new_lines: std.ArrayList([]const u8) = .empty; + defer new_lines.deinit(self.alloc); + + for (self.slots.items) |slot| { + for (slot.lines) |l| try old_lines.append(self.alloc, l); + } + for (frames) |f| { + for (f.new_lines) |l| try new_lines.append(self.alloc, l); + } + + const n = @min(old_lines.items.len, new_lines.items.len); + var i: usize = 0; + while (i < n) : (i += 1) { + if (!std.mem.eql(u8, old_lines.items[i], new_lines.items[i])) return i; + } + // Lengths differ beyond the common prefix: first extra/missing line. + if (old_lines.items.len != new_lines.items.len) return n; + return null; + } + + fn beginFrame(self: *Engine) Error!void { + if (self.synchronized_output) try self.writer.writeAll(terminal.seq.sync_begin); + } + + fn endFrame(self: *Engine) Error!void { + if (self.synchronized_output) try self.writer.writeAll(terminal.seq.sync_end); + } + + /// Full redraw: clear screen + scrollback, home cursor, print everything + /// from the top. Resets the viewport. + /// + /// Cursor-resting invariant (shared with `differential`): the last line is + /// written WITHOUT a trailing newline, so after the frame the hardware + /// cursor rests at the end of the last content line (global row + /// `total_lines - 1`), not one row below it. `differential` relies on this + /// to compute how far to move up. + fn fullRedraw(self: *Engine, frames: anytype, new_total: usize) Error!void { + try self.writer.writeAll(terminal.seq.full_clear); + self.cursor_hint = null; + var global_line: usize = 0; + var first = true; + for (frames) |f| { + for (f.new_lines) |line| { + if (!first) try self.writer.writeAll("\r\n"); + try self.writeLineNoNewline(line, global_line); + first = false; + global_line += 1; + } + } + // After a full redraw nothing is orphaned; the screen is clean. + _ = new_total; + } + + /// Differential redraw: from `cut` downward, move the cursor up to the cut + /// line, clear to end of screen, and reprint every line at/after the cut. + /// Components above the cut are untouched. Clean components below the cut + /// were not re-rendered (their cached lines flow through `frames`), but are + /// reprinted here because they sit below the rolled-back point. + fn differential(self: *Engine, frames: anytype, cut: ?usize, new_total: usize) Error!void { + const cut_line = cut orelse { + // Nothing changed and no length delta: emit an empty (but + // synchronized) frame. This is the no-op fast path. + return; + }; + + // Cursor-resting invariant: after the previous frame the hardware + // cursor rests at the END of the last content line (global row + // `prev_total - 1`), because the last line is written without a + // trailing newline. To reach the cut line we move up by + // `(prev_total - 1) - cut_line`, then carriage-return to column 0. + const prev_total = self.total_lines; + const last_row = if (prev_total == 0) 0 else prev_total - 1; + const up = last_row - @min(cut_line, last_row); + if (up > 0) try self.cursorUp(up); + try self.writer.writeAll(terminal.seq.carriage_return); + // Clear from the cut downward; this also handles orphaned trailing + // lines when the content shrank (plan §3.3 step 4). + try self.writer.writeAll(terminal.seq.clear_to_end); + + self.cursor_hint = null; + var global_line: usize = 0; + var first = true; + for (frames) |f| { + for (f.new_lines) |line| { + if (global_line >= cut_line) { + if (!first) try self.writer.writeAll("\r\n"); + try self.writeLineNoNewline(line, global_line); + first = false; + } + global_line += 1; + } + } + _ = new_total; + } + + /// Write a line WITHOUT a trailing newline. Strips the cursor marker (P1) + /// and records the cursor hint for P3. Both render paths join lines with an + /// explicit `\r\n` between them and omit the trailing newline after the + /// last line, to maintain the cursor-resting invariant (see `fullRedraw`). + fn writeLineNoNewline(self: *Engine, line: []const u8, global_line: usize) Error!void { + if (std.mem.indexOf(u8, line, CURSOR_MARKER)) |idx| { + // P1: record where the cursor would go (hook for P3), strip the + // marker so it never prints. Column is the visible width of the + // text before the marker. + self.cursor_hint = .{ .line = global_line, .col = visibleWidth(line[0..idx]) }; + const buf = try self.alloc.alloc(u8, line.len); + defer self.alloc.free(buf); + const stripped = stripCursorMarker(line, buf); + try self.writer.writeAll(stripped); + } else { + try self.writer.writeAll(line); + } + } + + fn cursorUp(self: *Engine, n: usize) Error!void { + var buf: [16]u8 = undefined; + const s = std.fmt.bufPrint(&buf, "\x1b[{d}A", .{n}) catch return; + try self.writer.writeAll(s); + } + + /// Recompute `viewport_top`: lines beyond the terminal height have scrolled + /// into native scrollback and are off-limits to future differential + /// updates. The bottom `height` lines remain addressable. + fn recomputeViewportTop(self: *Engine) void { + if (self.total_lines > self.height) { + self.viewport_top = self.total_lines - self.height; + } else { + self.viewport_top = 0; + } + } +}; + +// =========================================================================== +// Tests +// =========================================================================== + +const testing = std.testing; + +/// A scripted fake component for tests. Returns a programmed sequence of line +/// sets across successive renders and a programmed `firstLineChanged`. Counts +/// render calls to prove the no-re-render-of-clean-below-cut property. +const FakeComponent = struct { + scripts: []const []const []const u8, + first_changed: []const ?usize, + step: usize = 0, + render_calls: usize = 0, + + fn render(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 { + const self: *FakeComponent = @ptrCast(@alignCast(ptr)); + _ = width; + _ = alloc; + self.render_calls += 1; + const idx = @min(self.step, self.scripts.len - 1); + return self.scripts[idx]; + } + + fn firstLineChanged(ptr: *anyopaque) ?usize { + const self: *FakeComponent = @ptrCast(@alignCast(ptr)); + const idx = @min(self.step, self.first_changed.len - 1); + return self.first_changed[idx]; + } + + fn invalidate(ptr: *anyopaque) void { + _ = ptr; + } + + const vtable = Component.VTable{ + .render = render, + .firstLineChanged = firstLineChanged, + .invalidate = invalidate, + }; + + fn comp(self: *FakeComponent) Component { + return .{ .ptr = self, .vtable = &vtable }; + } + + /// Advance to the next scripted step (simulating the app mutating state + + /// the cache recomputing firstLineChanged). + fn advance(self: *FakeComponent) void { + self.step += 1; + } +}; + +fn makeEngine(buf: *std.Io.Writer.Allocating, width: usize, height: usize) Engine { + return Engine.init(testing.allocator, &buf.writer, width, height, false); +} + +test "visibleWidth strips CSI and counts codepoints" { + try testing.expectEqual(@as(usize, 5), visibleWidth("hello")); + try testing.expectEqual(@as(usize, 5), visibleWidth("\x1b[2mhello\x1b[0m")); + try testing.expectEqual(@as(usize, 0), visibleWidth("\x1b[0m")); + // CURSOR_MARKER (APC string) is zero-width. + try testing.expectEqual(@as(usize, 2), visibleWidth("a" ++ CURSOR_MARKER ++ "b")); + // multibyte UTF-8 counts one column per codepoint. + try testing.expectEqual(@as(usize, 3), visibleWidth("aé✓")); +} + +test "first paint is a full redraw clearing scrollback" { + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + var eng = makeEngine(&buf, 80, 24); + defer eng.deinit(); + + var fc = FakeComponent{ + .scripts = &.{&.{ "line one", "line two" }}, + .first_changed = &.{0}, + }; + try eng.addComponent(fc.comp()); + try eng.render(); + + const out = buf.written(); + try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) != null); + try testing.expect(std.mem.indexOf(u8, out, "line one") != null); + try testing.expect(std.mem.indexOf(u8, out, "line two") != null); + try testing.expectEqual(@as(usize, 1), fc.render_calls); +} + +test "cut is the min across ALL components (ticking footer below clean body)" { + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + var eng = makeEngine(&buf, 80, 100); // tall: nothing scrolls off + defer eng.deinit(); + + // Body: clean after first paint. Footer: dirty at local line 0 each step. + var body = FakeComponent{ + .scripts = &.{ &.{ "b0", "b1" }, &.{ "b0", "b1" } }, + .first_changed = &.{ 0, null }, // first paint dirty, then clean + }; + var footer = FakeComponent{ + .scripts = &.{ &.{"f-0"}, &.{"f-1"} }, + .first_changed = &.{ 0, 0 }, // always dirty at local 0 + }; + try eng.addComponent(body.comp()); + try eng.addComponent(footer.comp()); + + try eng.render(); // first paint (full) + body.advance(); + footer.advance(); + buf.clearRetainingCapacity(); + try eng.render(); // differential + + // The footer occupies global line 2 (after body's 2 lines). The cut must + // be 2 (min across all), NOT 0, and NOT "first dirty component" (body is + // clean now). So the body lines must NOT be reprinted. + const out = buf.written(); + try testing.expect(std.mem.indexOf(u8, out, "f-1") != null); + try testing.expect(std.mem.indexOf(u8, out, "b0") == null); + try testing.expect(std.mem.indexOf(u8, out, "b1") == null); + // Body was clean and below... no: body is ABOVE the cut, so untouched and + // NOT re-rendered. + try testing.expectEqual(@as(usize, 1), body.render_calls); // only first paint +} + +test "clean components below the cut are reprinted but NOT re-rendered" { + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + var eng = makeEngine(&buf, 80, 100); + defer eng.deinit(); + + // Header is dirty (forces the cut up to line 0); body is clean but sits + // below the cut, so its CACHED lines must be reprinted without re-render. + var header = FakeComponent{ + .scripts = &.{ &.{"h-0"}, &.{"h-1"} }, + .first_changed = &.{ 0, 0 }, + }; + var body = FakeComponent{ + .scripts = &.{ &.{ "b0", "b1" }, &.{ "b0", "b1" } }, + .first_changed = &.{ 0, null }, + }; + try eng.addComponent(header.comp()); + try eng.addComponent(body.comp()); + + try eng.render(); // first paint + header.advance(); + body.advance(); + buf.clearRetainingCapacity(); + try eng.render(); // differential, cut == 0 + + const out = buf.written(); + // Header changed and reprinted. + try testing.expect(std.mem.indexOf(u8, out, "h-1") != null); + // Body is below the cut => reprinted verbatim from cache... + try testing.expect(std.mem.indexOf(u8, out, "b0") != null); + try testing.expect(std.mem.indexOf(u8, out, "b1") != null); + // ...but NOT re-rendered: render_calls stayed at 1 (the first paint). + try testing.expectEqual(@as(usize, 1), body.render_calls); +} + +test "shrinking component clears orphaned trailing lines" { + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + var eng = makeEngine(&buf, 80, 100); + defer eng.deinit(); + + // Body collapses from 3 lines to 1. A shrinking component marks dirty + // (RenderCache reports the divergence boundary), so it re-renders; the + // engine must clear the orphaned trailing lines and recompute totals. + var body = FakeComponent{ + .scripts = &.{ &.{ "x0", "x1", "x2" }, &.{"x0"} }, + .first_changed = &.{ 0, 1 }, + }; + try eng.addComponent(body.comp()); + + try eng.render(); + try testing.expectEqual(@as(usize, 3), eng.total_lines); + body.advance(); + buf.clearRetainingCapacity(); + try eng.render(); + + // Orphans removed: total dropped from 3 to 1. + try testing.expectEqual(@as(usize, 1), eng.total_lines); + const out = buf.written(); + // The differential frame must clear to end of screen to erase the two + // orphaned trailing lines (x1, x2). + try testing.expect(std.mem.indexOf(u8, out, terminal.seq.clear_to_end) != null); + // x1/x2 must NOT be reprinted (they were cleared). + try testing.expect(std.mem.indexOf(u8, out, "x1") == null); + try testing.expect(std.mem.indexOf(u8, out, "x2") == null); + // No full redraw was needed — shrink stays on the differential path. + try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null); +} + +test "line-diff backstop corrects an inaccurate firstLineChanged" { + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + var eng = makeEngine(&buf, 80, 100); + defer eng.deinit(); + + // The component IS re-rendered (it reports dirty), but its signal LIES + // about WHERE the change is: it claims the change starts at line 2 while + // the real divergence is at line 0. The signal alone would reprint only + // from line 2 down and leave the stale line 0 on screen; the line-diff + // backstop must roll the cut back to 0 so "A0" actually paints. + var body = FakeComponent{ + .scripts = &.{ &.{ "a0", "a1", "a2" }, &.{ "A0", "a1", "a2" } }, + .first_changed = &.{ 0, 2 }, // lie: real change is at line 0 + }; + try eng.addComponent(body.comp()); + + try eng.render(); + body.advance(); + buf.clearRetainingCapacity(); + try eng.render(); + + const out = buf.written(); + // Backstop rolled the cut from the claimed line 2 back to line 0, so the + // new first line is reprinted. + try testing.expect(std.mem.indexOf(u8, out, "A0") != null); + // And no full redraw was needed — this stayed on the differential path. + try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null); +} + +test "consecutive differential frames move the cursor up correctly (streaming append)" { + // Regression: the differential path must account for the cursor resting at + // the END of the last content line (no trailing newline), not one row + // below it. The original code assumed cursor-at-`prev_total`, so the second + // (and every subsequent) differential frame moved up one row too few, + // clobbering everything but the last line — the streaming bug. + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + var eng = makeEngine(&buf, 80, 100); // tall: nothing scrolls off + defer eng.deinit(); + + // A footer that ticks at local line 0 on every frame, sitting below a body + // that grows by one line each step (the streaming transcript shape). + var body = FakeComponent{ + .scripts = &.{ + &.{"l0"}, + &.{ "l0", "l1" }, + &.{ "l0", "l1", "l2" }, + }, + // append-only: each step's first change is the new tail line. + .first_changed = &.{ 0, 1, 2 }, + }; + try eng.addComponent(body.comp()); + + try eng.render(); // first paint (full): l0 + try testing.expectEqual(@as(usize, 1), eng.total_lines); + + // Frame 2 (differential): append l1. prev_total==1 => last_row==0 => up==0. + body.advance(); + buf.clearRetainingCapacity(); + try eng.render(); + { + const out = buf.written(); + try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null); + // cut is line 1; cursor was on row 0 (end of l0), so NO up-move at all. + try testing.expect(std.mem.indexOf(u8, out, "\x1b[1A") == null); + try testing.expect(std.mem.indexOf(u8, out, "l1") != null); + } + try testing.expectEqual(@as(usize, 2), eng.total_lines); + + // Frame 3 (differential): append l2. prev_total==2 => last_row==1, cut==2. + // up == last_row - min(cut,last_row) == 1 - 1 == 0 (cut is below cursor). + body.advance(); + buf.clearRetainingCapacity(); + try eng.render(); + { + const out = buf.written(); + try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null); + try testing.expect(std.mem.indexOf(u8, out, "l2") != null); + // l0 must NOT be reprinted — it is above the cut and untouched. This is + // the assertion that fails under the old (over-eager) cursor math, + // which clobbered everything above the last line. + try testing.expect(std.mem.indexOf(u8, out, "l0") == null); + } + try testing.expectEqual(@as(usize, 3), eng.total_lines); +} + +test "differential edit of an upper line moves the cursor up the right distance" { + // A change at line 0 of a 3-line body, on the SECOND differential frame, so + // the cursor starts at row 2 (end of last line) and must climb 2 rows. + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + var eng = makeEngine(&buf, 80, 100); + defer eng.deinit(); + + var body = FakeComponent{ + .scripts = &.{ + &.{ "a0", "a1", "a2" }, + &.{ "a0", "a1", "A2" }, // first diff frame: change the tail + &.{ "X0", "a1", "A2" }, // second diff frame: change the head + }, + .first_changed = &.{ 0, 2, 0 }, + }; + try eng.addComponent(body.comp()); + + try eng.render(); // full + body.advance(); + buf.clearRetainingCapacity(); + try eng.render(); // diff: tail change (cursor stays low) + body.advance(); + buf.clearRetainingCapacity(); + try eng.render(); // diff: head change (must climb to line 0) + + const out = buf.written(); + try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null); + // Cursor rested at end of row 2 (A2); to reach cut line 0 it must move up 2. + try testing.expect(std.mem.indexOf(u8, out, "\x1b[2A") != null); + try testing.expect(std.mem.indexOf(u8, out, "X0") != null); +} + +test "width change forces a full redraw" { + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + var eng = makeEngine(&buf, 80, 24); + defer eng.deinit(); + + var body = FakeComponent{ + .scripts = &.{ &.{"hi"}, &.{"hi"} }, + .first_changed = &.{ 0, null }, + }; + try eng.addComponent(body.comp()); + try eng.render(); + + body.advance(); + eng.resize(60, 24); // width change + buf.clearRetainingCapacity(); + try eng.render(); + + const out = buf.written(); + try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) != null); +} + +test "height change forces a full redraw" { + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + var eng = makeEngine(&buf, 80, 24); + defer eng.deinit(); + + var body = FakeComponent{ + .scripts = &.{ &.{"hi"}, &.{"hi"} }, + .first_changed = &.{ 0, null }, + }; + try eng.addComponent(body.comp()); + try eng.render(); + + body.advance(); + eng.resize(80, 30); // height change + buf.clearRetainingCapacity(); + try eng.render(); + + const out = buf.written(); + try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) != null); +} + +test "change above viewport_top forces a full redraw" { + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + // Short viewport so most content scrolls into native scrollback. + var eng = makeEngine(&buf, 80, 2); + defer eng.deinit(); + + // 5 lines, viewport height 2 => viewport_top becomes 3 after first paint. + var body = FakeComponent{ + .scripts = &.{ &.{ "l0", "l1", "l2", "l3", "l4" }, &.{ "L0", "l1", "l2", "l3", "l4" } }, + .first_changed = &.{ 0, 0 }, // change lands at line 0, above viewport_top + }; + try eng.addComponent(body.comp()); + try eng.render(); + try testing.expectEqual(@as(usize, 3), eng.viewport_top); + + body.advance(); + buf.clearRetainingCapacity(); + try eng.render(); + + const out = buf.written(); + try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) != null); +} + +test "width overflow is a hard error" { + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + var eng = makeEngine(&buf, 4, 24); + defer eng.deinit(); + + var body = FakeComponent{ + .scripts = &.{&.{"way too wide"}}, + .first_changed = &.{0}, + }; + try eng.addComponent(body.comp()); + try testing.expectError(Error.LineOverflow, eng.render()); +} + +test "engine holds a LIST of components" { + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + var eng = makeEngine(&buf, 80, 24); + defer eng.deinit(); + + var a = FakeComponent{ .scripts = &.{&.{"a"}}, .first_changed = &.{0} }; + var b = FakeComponent{ .scripts = &.{&.{"b"}}, .first_changed = &.{0} }; + try eng.addComponent(a.comp()); + try eng.addComponent(b.comp()); + try testing.expectEqual(@as(usize, 2), eng.componentCount()); + + try testing.expect(eng.removeComponent(a.comp())); + try testing.expectEqual(@as(usize, 1), eng.componentCount()); + try testing.expect(!eng.removeComponent(a.comp())); +} + +test "cursor marker is stripped from output and recorded as a hint" { + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + var eng = makeEngine(&buf, 80, 24); + defer eng.deinit(); + + var body = FakeComponent{ + .scripts = &.{&.{"ab" ++ CURSOR_MARKER ++ "cd"}}, + .first_changed = &.{0}, + }; + try eng.addComponent(body.comp()); + try eng.render(); + + const out = buf.written(); + try testing.expect(std.mem.indexOf(u8, out, CURSOR_MARKER) == null); // stripped + try testing.expect(std.mem.indexOf(u8, out, "abcd") != null); + try testing.expect(eng.cursor_hint != null); + try testing.expectEqual(@as(usize, 2), eng.cursor_hint.?.col); +} + +test "every frame is wrapped in synchronized output when enabled" { + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + var eng = Engine.init(testing.allocator, &buf.writer, 80, 24, true); + defer eng.deinit(); + + var body = FakeComponent{ .scripts = &.{&.{"x"}}, .first_changed = &.{0} }; + try eng.addComponent(body.comp()); + try eng.render(); + + const out = buf.written(); + try testing.expect(std.mem.startsWith(u8, out, terminal.seq.sync_begin)); + try testing.expect(std.mem.endsWith(u8, out, terminal.seq.sync_end)); + // Ordinary content never clears scrollback on a forced full first paint is + // fine, but the sync frame must wrap it. +} + +test "ordinary differential frame never clears scrollback" { + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + var eng = makeEngine(&buf, 80, 100); + defer eng.deinit(); + + var body = FakeComponent{ + .scripts = &.{ &.{ "a", "b" }, &.{ "a", "B" } }, + .first_changed = &.{ 0, 1 }, + }; + try eng.addComponent(body.comp()); + try eng.render(); + body.advance(); + buf.clearRetainingCapacity(); + try eng.render(); + + const out = buf.written(); + try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null); + try testing.expect(std.mem.indexOf(u8, out, "B") != null); +} + +// -- Scheduler tests -------------------------------------------------------- + +test "scheduler: idle renders immediately" { + var s = Scheduler.init(8 * std.time.ns_per_ms); + try testing.expect(!s.shouldRenderNow(0)); // nothing pending + s.requestRender(); + try testing.expect(s.shouldRenderNow(0)); // idle => now +} + +test "scheduler: burst coalesces within the window" { + var s = Scheduler.init(8 * std.time.ns_per_ms); + s.requestRender(); + s.noteRendered(1000); + s.requestRender(); + // Within the window: defer. + try testing.expect(!s.shouldRenderNow(1000 + 4 * std.time.ns_per_ms)); + // Past the window: render. + try testing.expect(s.shouldRenderNow(1000 + 8 * std.time.ns_per_ms)); +} + +test "scheduler: nextDeadline reports remaining window" { + var s = Scheduler.init(8 * std.time.ns_per_ms); + try testing.expectEqual(@as(?i128, null), s.nextDeadline(0)); // not pending + s.requestRender(); + s.noteRendered(0); + s.requestRender(); + try testing.expectEqual(@as(?i128, 8 * std.time.ns_per_ms), s.nextDeadline(0)); + try testing.expectEqual(@as(?i128, 0), s.nextDeadline(8 * std.time.ns_per_ms)); +} -- cgit v1.3