summaryrefslogtreecommitdiff
path: root/src/tui_engine.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/tui_engine.zig')
-rw-r--r--src/tui_engine.zig1116
1 files changed, 1116 insertions, 0 deletions
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));
+}