//! 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)); }