//! 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; } if (b == '\t') { const rem = cols % 8; cols += if (rem == 0) 8 else 8 - rem; i += 1; continue; } if (b == '\r') { cols = 0; i += 1; 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, layout change, /// resize). A full redraw reprints every line from the top of the working /// area; it does NOT by itself clear the screen/scrollback. force_full: bool = true, /// When a forced full redraw should ALSO clear the screen + scrollback /// (`\x1b[2J\x1b[H\x1b[3J`). True only for cases where the prior on-screen /// layout is no longer valid and reprinting in place would corrupt it: /// width/height resize (wrapping/viewport changes) and explicit /// `forceFullRedraw` (e.g. after an external program scribbled the screen). /// /// Crucially this is FALSE on first paint and on ordinary layout changes /// (add/remove component), so panto NEVER wipes the user's pre-launch /// shell scrollback: the first frame is printed where the cursor already /// sits and earlier output stays scrollable above (plan §1/§3.1; mirrors /// pi-tui's first render, which calls its `fullRender(false)` — no clear). force_clear: bool = false, /// The global (line, col) where the focused component drew its cursor /// marker this frame, or null when no focused component emitted a marker. /// Set by `writeLineNoNewline` as content is written; consumed by /// `positionHardwareCursor` after content for IME anchoring (§3.5). cursor_hint: ?struct { line: usize, col: usize } = null, /// The global row the HARDWARE cursor was actually left on after the last /// frame finished. This is the differential render path's source of truth /// for its next up-move, REPLACING the old assumption that the cursor /// always rests at `total_lines - 1` (end of content). /// /// Why this exists (the rest-row reconciliation, §3.5): writing a frame's /// content leaves the cursor at the end of the last content line /// (`total_lines - 1`). But P3 then MOVES the cursor for IME anchoring to /// the virtual-cursor position (`cursor_hint`), which is usually NOT the /// last content row. The next differential frame must compute its up-move /// from wherever the cursor truly is, not from a stale /// "rest-at-end-of-content" assumption — otherwise every frame following a /// cursor reposition would be off by `(last_content_row - hint_row)` rows. /// So each frame records here the row it actually left the cursor on, and /// `differential` reads it. /// /// Initialized to 0; meaningful only after the first render. hw_cursor_row: usize = 0, 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; } /// Reconcile the live slot list against `comps` IN PLACE, preserving the /// render baseline of every slot whose component pointer is unchanged. /// /// This is the structure-aware replacement for "drain every slot + re-add /// all" (which reset EVERY baseline and forced a full redraw on every /// transcript mutation). The app rebuilds its component list on each new /// entry, but the overwhelmingly common shape is an APPEND at the end /// (a new transcript entry) with all earlier slots unchanged. Resetting /// the unchanged slots' baselines turned that append into a full redraw, /// and when earlier content had scrolled into native scrollback /// (`viewport_top > 0`) the viewport-escape rule escalated it to a /// SCROLLBACK-CLEARING redraw — the visible "flash" on every streamed /// block while tool output was expanded. /// /// Reconciliation anchors on the longest common PREFIX and the longest /// common SUFFIX (both matched by `comp.ptr`); everything between them is /// the changed region. /// - Prefix and suffix slots keep their baseline verbatim (no re-render, /// no forced full): unchanged components above/below the change point /// stay clean and reprint from cache only if they sit below the cut. /// - PURE INSERTION (prefix + suffix account for every OLD slot; the new /// list only ADDS items in the middle): the inserted slots get EMPTY /// baselines, so they first-render at their own offset and the cut /// lands at the insertion point. This covers the dominant case — a new /// transcript entry appended just before the pinned input box + footer /// suffix. The frame stays on the differential path with NO clear, and /// `force_full` is deliberately NOT set; that is the whole point. /// - STRUCTURAL CHANGE (a slot in the changed region is REPLACED or /// REMOVED — an override swap or a transcript removal): `force_full` is /// set. Correctness for scrolled content is still guaranteed by the /// viewport-escape rule in `render` (a cut above `viewport_top` /// escalates to a clearing redraw); only the rare mid-list mutation /// pays that cost, not every append. pub fn syncComponents(self: *Engine, comps: []const Component) !void { const old = self.slots.items; // Longest common prefix by component pointer. var pre: usize = 0; const pre_max = @min(old.len, comps.len); while (pre < pre_max and old[pre].comp.ptr == comps[pre].ptr) pre += 1; // Longest common suffix, not overlapping the prefix on either side. var suf: usize = 0; const suf_max = @min(old.len - pre, comps.len - pre); while (suf < suf_max and old[old.len - 1 - suf].comp.ptr == comps[comps.len - 1 - suf].ptr) suf += 1; // OLD slots in [pre, old.len - suf) are replaced/removed; NEW comps in // [pre, comps.len - suf) are inserted. const old_changed = old.len - suf - pre; // old slots dropped const new_inserted = comps.len - suf - pre; // new slots added // Only a NET REMOVAL forces a full redraw. A pure insertion or a 1:1 // in-place replacement (override swap) stays differential: the // replaced/inserted slot first-renders from an empty baseline, so the // cut lands at THAT slot's offset (clear_to_end clears any orphaned tail). const structural_change = new_inserted < old_changed; // Detach the preserved suffix slots (with their baselines) before we // mutate the middle, then re-attach them after the inserted slots. var suffix_slots: std.ArrayList(Slot) = .empty; defer suffix_slots.deinit(self.alloc); try suffix_slots.ensureTotalCapacity(self.alloc, suf); { var k: usize = 0; while (k < suf) : (k += 1) suffix_slots.appendAssumeCapacity(old[old.len - suf + k]); } // Pop everything from the divergence point down to the prefix. The // top `suf` entries are the preserved suffix (copied out above — pop // WITHOUT freeing their lines, they will be re-attached). The next // `old_changed` entries are genuinely replaced/removed — free them. var popped: usize = 0; while (self.slots.items.len > pre) : (popped += 1) { var removed = self.slots.pop().?; if (popped >= suf) self.freeSlotLines(&removed); } // Insert the new middle components with empty baselines, then restore // the preserved suffix slots. for (comps[pre .. comps.len - suf]) |comp| { try self.slots.append(self.alloc, .{ .comp = comp }); } for (suffix_slots.items) |slot| try self.slots.append(self.alloc, slot); if (structural_change) self.force_full = true; } // -- 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; // A resize invalidates the on-screen layout (wrapping / viewport // alignment), so this is one of the few redraws that legitimately // clears the screen before reprinting. self.force_clear = true; } self.width = width; self.height = height; } /// Force the next frame to be a full redraw that also CLEARS the screen + /// scrollback. Use only when the on-screen content is known to be corrupt /// (e.g. an external program like `$EDITOR` drew over the terminal); an /// ordinary forced reprint that should preserve scrollback does not belong /// here. For a plain layout-change reprint, the engine sets `force_full` /// without `force_clear` itself. pub fn forceFullRedraw(self: *Engine) void { self.force_full = true; self.force_clear = 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 dirty_signal = 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. The signal // is ONLY a dirty/render hint; the repaint cut below is computed // from the actual old-vs-new line diff after rendering. const must_render = dirty_signal != 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; } const first_changed = firstDiffLines(slot.lines, new_lines); frames[idx] = .{ .new_lines = new_lines, .first_changed = first_changed, .old_count = slot.line_count, .offset = offset, }; // 2. cut = min across ALL components with an ACTUAL line diff. // A dirty component whose re-rendered bytes are identical leaves // first_changed null and does not move the repaint point. if (first_changed) |fc| { const global = offset + fc; cut = if (cut) |c| @min(c, global) else global; } 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 / layout change / resize // (`force_full`), or a change that lands above `viewport_top` // (off-limits to differential repaint). var full = self.force_full; // Whether that full redraw also clears the screen + scrollback. var clear = self.force_clear; // Viewport-escape: a change at/above `viewport_top` touches lines that // have already scrolled into native scrollback and cannot be patched // in place. Reprinting from the top WITHOUT a clear would re-emit // those scrolled-away lines below their original copy, DUPLICATING // them in scrollback. So such a change must force a CLEARING full // redraw (matches pi-tui's viewport-escape path = fullRender(true)). // // This is checked independently of `force_full`: a forced full redraw // (e.g. an in-session layout change via addComponent/rebuild) is by // default a NON-clearing reprint, but if the cut lands above the // viewport it must escalate to a clearing one — otherwise the layout // change duplicates scrollback. Only first paint (viewport_top == 0) // and changes at/below the viewport stay clear-free. if (cut) |c| { if (c < self.viewport_top) { full = true; clear = true; } } try self.beginFrame(); if (full) { // Clear the screen/scrollback only when required (resize, // `forceFullRedraw`, or a change above the viewport). First paint // and ordinary layout changes are full reprints WITHOUT a clear, // so pre-launch scrollback survives (plan §1/§3.1). try self.fullRedraw(frames, new_total, clear); } else { try self.differential(frames, cut, new_total); } // Position the hardware cursor for IME anchoring (§3.5), still INSIDE // the synchronized-output block so it composites atomically with the // frame. This also records `hw_cursor_row`, the rest row the next // differential frame measures its move from. // // The no-op differential fast path (`cut == null`) returns early // WITHOUT writing or moving the cursor, so the cursor stays where the // previous frame left it and `hw_cursor_row` already reflects that — // we must not recompute it from `new_total` in that case, or we would // clobber a valid IME position with end-of-content. Detect the no-op // frame (differential path with no cut) and skip repositioning. const was_noop = !full and cut == null; if (!was_noop) try self.positionHardwareCursor(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; self.force_clear = 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; } /// Lowest local line index where `new_lines` differs from `old_lines`, or /// null when they are byte-identical (including line count). This is the /// authoritative repaint signal for a rendered component in the current /// frame; pre-render `firstLineChanged()` is treated only as a cheap dirty /// hint for whether the component needs to render. fn firstDiffLines(old_lines: []const []const u8, new_lines: []const []const u8) ?usize { const n = @min(old_lines.len, new_lines.len); var i: usize = 0; while (i < n) : (i += 1) { if (!std.mem.eql(u8, old_lines[i], new_lines[i])) return i; } if (old_lines.len != new_lines.len) return n; return null; } /// 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: optionally clear screen + scrollback, home cursor, then /// print everything from the top. Resets the viewport. /// /// `clear` controls whether the screen + scrollback are wiped first /// (`full_clear`). It is TRUE for resize / explicit `forceFullRedraw`, and /// FALSE for first paint and layout-change reprints — so the very first /// frame prints where the cursor already is and the user's prior shell /// scrollback is preserved (plan §1/§3.1; mirrors pi-tui's first render). /// /// 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, clear: bool) Error!void { if (clear) { try self.writer.writeAll(terminal.seq.full_clear); } else { // Non-clearing full reprint (layout change / first paint after // scrolled content). The cursor may be anywhere in the previously // rendered region; move it to `viewport_top` so the reprint // overwrites the correct rows rather than appending below them. // First paint has viewport_top == 0 and hw_cursor_row == 0, so // this is a no-op for the initial frame. const start_row = self.hw_cursor_row; const target = self.viewport_top; if (target < start_row) { try self.cursorUp(start_row - target); } else if (target > start_row) { try self.cursorDown(target - start_row); } try self.writer.writeAll(terminal.seq.carriage_return); 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 (!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; }; // Reach the cut line from wherever the cursor was ACTUALLY left after // the previous frame. Historically that was assumed to be the end of // the last content line (`total_lines - 1`); since P3 may reposition // the cursor for IME anchoring after writing content, the true row is // tracked in `hw_cursor_row` (see its doc-comment for the rest-row // reconciliation). We move up by `hw_cursor_row - cut_line`, then // carriage-return to column 0. // // If the cut is BELOW the cursor's current row (possible only when the // prior frame parked the cursor above the change, e.g. the virtual // cursor sat on an earlier line than a now-dirty footer), we must move // DOWN instead. `cursorDown` covers that case; the common path is a // pure up-move. const start_row = self.hw_cursor_row; if (cut_line < start_row) { try self.cursorUp(start_row - cut_line); } else if (cut_line > start_row) { try self.cursorDown(cut_line - start_row); } 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); // Cursor-hint persistence across a PARTIAL repaint. `differential` only // re-scans lines from `cut_line` down, so a focused component whose // marker line sits ABOVE the cut is not re-emitted this frame — its // marker is still on screen, unchanged, from a prior frame. If we blindly // reset the hint to null, the cursor would wrongly jump to end-of-content // whenever an unrelated region below the cursor changes (e.g. a footer // ticking under a pinned input box). So we remember the prior hint, // reset, let the scan overwrite it if the marker is in the repainted // region, and otherwise RESTORE it when the marker line is above the cut // (hence still displayed). A full redraw re-scans every line, so it // resets the hint unconditionally (see `fullRedraw`). const prev_hint = self.cursor_hint; 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; } } // The scan did not find a marker (cursor_hint still null), but the // previous frame's marker line is above the repainted region and thus // still on screen: keep using it so the hardware cursor stays anchored. if (self.cursor_hint == null) { if (prev_hint) |h| { if (h.line < cut_line) self.cursor_hint = h; } } _ = 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 { if (n == 0) return; var buf: [16]u8 = undefined; const s = std.fmt.bufPrint(&buf, "\x1b[{d}A", .{n}) catch return; try self.writer.writeAll(s); } fn cursorDown(self: *Engine, n: usize) Error!void { if (n == 0) return; var buf: [16]u8 = undefined; const s = std.fmt.bufPrint(&buf, "\x1b[{d}B", .{n}) catch return; try self.writer.writeAll(s); } fn cursorForward(self: *Engine, n: usize) Error!void { if (n == 0) return; var buf: [16]u8 = undefined; const s = std.fmt.bufPrint(&buf, "\x1b[{d}C", .{n}) catch return; try self.writer.writeAll(s); } /// Position the HARDWARE cursor for IME anchoring (plan §3.5), and record /// the row it was left on for the next differential frame. /// /// Called once per frame AFTER content is written and BEFORE `endFrame` /// (so the move composites atomically inside the synchronized-output /// block). On entry the cursor rests at the end of the last content line /// (`last_content_row`, at that line's visible width), per the /// cursor-resting invariant. /// /// When a focused component emitted `CURSOR_MARKER` this frame, /// `cursor_hint` holds the marker's global (line, col). We move the cursor /// there with RELATIVE moves only — never absolute CUP rows, because the /// no-alt-screen scrolling model makes absolute row numbers unstable: /// 1. vertical: up/down from `last_content_row` to `cursor_hint.line`. /// A vertical move preserves the column, so afterward we are on the /// target row but at the old column. /// 2. carriage-return to column 0, then cursor-forward by /// `cursor_hint.col`. Cursor-forward (`\x1b[C`) moves without /// writing glyphs, so it never overwrites the already-painted line /// (the virtual reverse-video cursor block the InputBox drew stays /// intact — the two coexist: the styled block is what the user sees, /// the hardware cursor is moved to the same cell for the IME). /// /// The cursor is left HIDDEN (the session hides it globally; see /// `runLoop`). Most terminals anchor an IME candidate popup to the cursor /// POSITION regardless of visibility, and the InputBox already draws the /// visible block, so showing a second hardware caret over it would be /// redundant and distracting. We therefore position-but-hide. /// /// IME CAVEAT [verify]: anchoring an IME popup to a HIDDEN, repositioned /// hardware cursor is implemented here but UNVERIFIED against a real IME /// (CJK / accent / emoji composition). If a terminal turns out to ignore a /// hidden cursor for IME placement, the fix is to show the cursor here; /// that is a one-line policy change and does not affect the positioning /// math. The mechanism (marker -> strip -> relative move) is what §3.5 /// specifies; the popup-follows-hidden-cursor assumption is the unverified /// part. /// /// `last_content_row` is `total_lines - 1` (0 when empty). After this /// runs, `hw_cursor_row` records the row the cursor truly rests on, which /// `differential` reads next frame for its up/down move. fn positionHardwareCursor(self: *Engine, new_total: usize) Error!void { const last_content_row: usize = if (new_total == 0) 0 else new_total - 1; if (self.cursor_hint) |hint| { // Clamp defensively: the marker is within content, so the hint row // should never exceed the last content row, but guard anyway. const target_row = @min(hint.line, last_content_row); if (target_row < last_content_row) { try self.cursorUp(last_content_row - target_row); } else if (target_row > last_content_row) { try self.cursorDown(target_row - last_content_row); } try self.writer.writeAll(terminal.seq.carriage_return); try self.cursorForward(hint.col); self.hw_cursor_row = target_row; } else { // No focused marker: leave the cursor at end-of-content (where the // content write already parked it). Record that row so the next // differential frame computes its move correctly. self.hw_cursor_row = last_content_row; } } /// 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; } } /// On teardown, move the hardware cursor to the first row AFTER the /// rendered content so the shell prompt appears below the footer, not at /// the focused input cursor position we used for IME anchoring. pub fn finalizeCursor(self: *Engine) Error!void { if (self.total_lines == 0) { try self.writer.writeAll(terminal.seq.carriage_return); self.hw_cursor_row = 0; return; } const past_content_row = self.total_lines; const current_row = self.hw_cursor_row; if (past_content_row > current_row) { try self.cursorDown(past_content_row - current_row); } try self.writer.writeAll(terminal.seq.carriage_return); self.hw_cursor_row = past_content_row; } }; // =========================================================================== // 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")); try testing.expectEqual(@as(usize, 8), visibleWidth("\t")); try testing.expectEqual(@as(usize, 9), visibleWidth("a\tX")); try testing.expectEqual(@as(usize, 9), visibleWidth("prepainted\r\tX")); // 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 that does NOT clear scrollback" { // Plan §1/§3.1: the first frame must print where the cursor already sits // and preserve the user's pre-launch shell scrollback. It is a full // reprint, but it must NOT emit the screen+scrollback clear (mirrors // pi-tui's first render = fullRender(false)). 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(); // No scrollback wipe on first paint. try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null); // But the content is printed. 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 "resize forces a full redraw that DOES clear scrollback" { // A width/height change invalidates the on-screen layout, so this is one // of the few redraws that legitimately clears before reprinting. 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" }, &.{ "line one", "line two" } }, .first_changed = &.{ 0, null }, }; try eng.addComponent(fc.comp()); try eng.render(); // first paint: no clear try testing.expect(std.mem.indexOf(u8, buf.written(), terminal.seq.full_clear) == null); fc.advance(); buf.clearRetainingCapacity(); eng.resize(100, 24); // width change -> clear on next frame try eng.render(); try testing.expect(std.mem.indexOf(u8, buf.written(), terminal.seq.full_clear) != null); } test "in-session layout change (addComponent) does NOT clear scrollback" { // The app calls rebuildEngineList (drain + re-add all slots) on EVERY new // transcript entry, which sets force_full each time. That forced full // redraw must be a plain reprint WITHOUT a screen/scrollback clear, or the // user's history would flash-wipe on every streamed message. Plan §1/§3.1. 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(); var body = FakeComponent{ .scripts = &.{&.{ "b0", "b1" }}, .first_changed = &.{0} }; try eng.addComponent(body.comp()); try eng.render(); // first paint (no clear) try testing.expect(std.mem.indexOf(u8, buf.written(), terminal.seq.full_clear) == null); // Add a second component (a layout change => force_full, NOT force_clear). var footer = FakeComponent{ .scripts = &.{&.{"f0"}}, .first_changed = &.{0} }; buf.clearRetainingCapacity(); try eng.addComponent(footer.comp()); try eng.render(); const out = buf.written(); // Layout-change reprint: full content, but NO scrollback wipe. try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null); try testing.expect(std.mem.indexOf(u8, out, "b0") != null); try testing.expect(std.mem.indexOf(u8, out, "f0") != null); } test "layout change with scrolled content: change above viewport_top clears (no scrollback duplication)" { // The scrollback-duplication hazard: when earlier lines have already // scrolled into native scrollback (viewport_top > 0), a forced full // reprint from the top WITHOUT a clear would re-emit those scrolled-away // lines below their original copy, duplicating them in scrollback. // // The engine must NOT do that. When a change (here a fresh first-render of // re-added slots after a drain+rebuild) lands at/above viewport_top, the // render path must clear before reprinting. We simulate the drain+rebuild // by removing and re-adding the component, which resets its baseline so it // first-renders at line 0 (i.e. the "change" is at line 0, above // viewport_top). var buf = std.Io.Writer.Allocating.init(testing.allocator); defer buf.deinit(); // Short viewport so content scrolls: 5 lines, height 2 => viewport_top 3. var eng = makeEngine(&buf, 80, 2); defer eng.deinit(); var body = FakeComponent{ .scripts = &.{ &.{ "l0", "l1", "l2", "l3", "l4" }, &.{ "l0", "l1", "l2", "l3", "l4" } }, .first_changed = &.{ 0, 0 }, }; try eng.addComponent(body.comp()); try eng.render(); // first paint try testing.expectEqual(@as(usize, 3), eng.viewport_top); // Drain + re-add (the rebuildEngineList shape). This resets the slot // baseline so the re-added component first-renders from line 0. _ = eng.removeComponent(body.comp()); body.advance(); try eng.addComponent(body.comp()); buf.clearRetainingCapacity(); try eng.render(); const out = buf.written(); // The change is at line 0 (above viewport_top == 3), so the engine MUST // clear before reprinting to avoid duplicating the scrolled-away lines. try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) != null); // And exactly one copy of each line is emitted (no duplication). Count l0. try testing.expectEqual(@as(usize, 1), std.mem.count(u8, out, "l0")); try testing.expectEqual(@as(usize, 1), std.mem.count(u8, out, "l4")); } test "forceFullRedraw clears scrollback (e.g. $EDITOR return path)" { // After an external program ($EDITOR) scribbles the screen, the app calls // engine.forceFullRedraw() to repaint from a known-clean slate. That path // MUST clear (the on-screen content is corrupt), unlike ordinary layout // changes. 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, null }, }; try eng.addComponent(body.comp()); try eng.render(); // first paint: no clear try testing.expect(std.mem.indexOf(u8, buf.written(), terminal.seq.full_clear) == null); body.advance(); buf.clearRetainingCapacity(); eng.forceFullRedraw(); try eng.render(); try testing.expect(std.mem.indexOf(u8, buf.written(), terminal.seq.full_clear) != null); } test "first paint preserves the cursor-resting invariant (no trailing newline)" { // The no-clear first paint must still leave the cursor at the END of the // last content line (last line written WITHOUT a trailing newline), since // the differential cursor math on the next frame depends on it. 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 = &.{&.{ "l0", "l1", "l2" }}, .first_changed = &.{0} }; try eng.addComponent(body.comp()); try eng.render(); const out = buf.written(); // No clear, and the frame must NOT end with a trailing newline after the // last line (it ends with the last line's content, or the sync-end marker // when synchronized; this engine has sync off). try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null); try testing.expect(!std.mem.endsWith(u8, out, "\r\n")); try testing.expect(std.mem.endsWith(u8, out, "l2")); // Lines are joined by \r\n, so exactly (count-1) separators for 3 lines. try testing.expectEqual(@as(usize, 2), std.mem.count(u8, out, "\r\n")); } test "finalizeCursor moves below the footer before exit" { 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 = &.{&.{ "welcome", "prompt", "footer" }}, .first_changed = &.{0} }; try eng.addComponent(body.comp()); try eng.render(); // Simulate the session parking the hardware cursor on the input row while // footer content still exists below it. eng.hw_cursor_row = 1; buf.clearRetainingCapacity(); try eng.finalizeCursor(); try testing.expectEqualStrings("\x1b[2B\r", buf.written()); try testing.expectEqual(@as(usize, 3), eng.hw_cursor_row); } 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 "dirty hint at 0 still repaints from actual first changed line" { var buf = std.Io.Writer.Allocating.init(testing.allocator); defer buf.deinit(); var eng = makeEngine(&buf, 80, 100); defer eng.deinit(); // Simulates a component that conservatively reports dirty from its top // (e.g. markDirty dropped its internal cache), while the engine still has // the previous slot baseline and can cheaply find the true line diff. var body = FakeComponent{ .scripts = &.{ &.{ "l0", "l1", "l2", "l3" }, &.{ "l0", "l1", "l2", "L3" } }, .first_changed = &.{ 0, 0 }, }; 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, "L3") != null); try testing.expect(std.mem.indexOf(u8, out, "l0") == null); try testing.expect(std.mem.indexOf(u8, out, "l1") == null); try testing.expect(std.mem.indexOf(u8, out, "l2") == null); 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 (keeps scrollback honest)" { // A change to a line that has scrolled into native scrollback (above // viewport_top) cannot be patched in place — the cursor can't reach it. // The ONLY way to make scrollback reflect the change is a clearing full // redraw (\x1b[3J wipes history) + reprint from the top. We accept that // cost to keep scrollback honest; the differential path can't address // those lines. (Tool INPUT streaming does not hit this: the tool is small // and in-view while args stream — it has no output yet — so the changing // header stays at/below viewport_top.) 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 "syncComponents: middle insertion before a pinned suffix does NOT clear scrollback (the expanded-tools flash fix)" { // The regression: the app rebuilds its component list on every new // transcript entry. The desired list is [..transcript.., input, footer], // so a new entry is INSERTED just before the pinned input+footer suffix. // The old drain+rebuild reset every baseline and forced a full redraw; // with scrolled content (viewport_top > 0) that escalated to a // scrollback-CLEARING redraw — the visible flash. syncComponents must keep // the unchanged prefix/suffix baselines and stay on the differential path. var buf = std.Io.Writer.Allocating.init(testing.allocator); defer buf.deinit(); // Viewport tall enough that the insertion point (transcript tail, just // above the 2-line pinned suffix) stays visible, but short enough that the // tall tool body has scrolled some lines into native scrollback // (viewport_top > 0). This mirrors the real app: the new entry lands at // the bottom, below viewport_top, so it MUST stay on the differential path. var eng = makeEngine(&buf, 80, 6); defer eng.deinit(); // A tall "tool" body, plus pinned input + footer (the suffix). Each // unchanged component reports firstLineChanged 0 on first paint, then null // (clean) on later frames — the real RenderCache contract. Preserved slots // must therefore contribute NO cut, so the insert stays at the tail. var tool = FakeComponent{ .scripts = &.{ &.{ "t0", "t1", "t2", "t3", "t4", "t5", "t6", "t7" }, &.{ "t0", "t1", "t2", "t3", "t4", "t5", "t6", "t7" } }, .first_changed = &.{ 0, null } }; var input = FakeComponent{ .scripts = &.{ &.{"input"}, &.{"input"} }, .first_changed = &.{ 0, null } }; var footer = FakeComponent{ .scripts = &.{ &.{"f0"}, &.{"f0"} }, .first_changed = &.{ 0, null } }; try eng.syncComponents(&.{ tool.comp(), input.comp(), footer.comp() }); try eng.render(); // first paint try testing.expect(eng.viewport_top > 0); // Advance the preserved components to their clean (null) signal. tool.advance(); input.advance(); footer.advance(); // Insert a new entry between the tool body and the pinned input/footer. var entry = FakeComponent{ .scripts = &.{&.{ "new0", "new1" }}, .first_changed = &.{0} }; buf.clearRetainingCapacity(); try eng.syncComponents(&.{ tool.comp(), entry.comp(), input.comp(), footer.comp() }); try eng.render(); const out = buf.written(); // The inserted content is drawn, but WITHOUT a scrollback wipe. try testing.expect(std.mem.indexOf(u8, out, "new0") != null); try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null); // The unchanged tool body was NOT re-rendered (its baseline survived): // only the first paint rendered it. try testing.expectEqual(@as(usize, 1), tool.render_calls); } test "syncComponents: removal in the middle forces a full redraw (structural change)" { var buf = std.Io.Writer.Allocating.init(testing.allocator); defer buf.deinit(); var eng = makeEngine(&buf, 80, 100); // tall: nothing scrolls defer eng.deinit(); var a = FakeComponent{ .scripts = &.{&.{"a"}}, .first_changed = &.{0} }; var b = FakeComponent{ .scripts = &.{&.{"b"}}, .first_changed = &.{0} }; var c = FakeComponent{ .scripts = &.{&.{"c"}}, .first_changed = &.{0} }; try eng.syncComponents(&.{ a.comp(), b.comp(), c.comp() }); try eng.render(); try testing.expectEqual(@as(usize, 3), eng.componentCount()); // Remove the middle component => structural change => force_full. buf.clearRetainingCapacity(); try eng.syncComponents(&.{ a.comp(), c.comp() }); try testing.expect(eng.force_full); try eng.render(); try testing.expectEqual(@as(usize, 2), eng.componentCount()); const out = buf.written(); try testing.expect(std.mem.indexOf(u8, out, "b") == null); } test "syncComponents: replacing a slot (override swap) stays differential, no reprint-from-top" { // A 1:1 in-place swap (e.g. a tool-renderer override installed on a // `tool_delta`) must NOT force a full reprint. The replaced slot // first-renders from an empty baseline, so the differential cut lands at // THAT slot's offset and only the slot (and anything below it) repaints. // Forcing a full redraw here reprinted the whole transcript from line 0 on // every delta — the streaming-tool flicker. var buf = std.Io.Writer.Allocating.init(testing.allocator); defer buf.deinit(); var eng = makeEngine(&buf, 80, 100); defer eng.deinit(); var a = FakeComponent{ .scripts = &.{&.{"a"}}, .first_changed = &.{0} }; var b = FakeComponent{ .scripts = &.{&.{"b"}}, .first_changed = &.{0} }; var b2 = FakeComponent{ .scripts = &.{&.{"b2"}}, .first_changed = &.{0} }; try eng.syncComponents(&.{ a.comp(), b.comp() }); try eng.render(); // Swap b -> b2 at the same position (pointer differs, count unchanged). buf.clearRetainingCapacity(); try eng.syncComponents(&.{ a.comp(), b2.comp() }); try testing.expect(!eng.force_full); // NOT a full redraw try eng.render(); const out = buf.written(); try testing.expect(std.mem.indexOf(u8, out, "b2") != null); } test "syncComponents: override swap with scrolled content does not reprint from the top (tool-stream flicker)" { // The real streaming-tool flicker. A tool-renderer override is re-installed // on every `tool_delta`; the Lua bridge mints a fresh component per // `set_component`, so the App swaps the slot every delta. With earlier // content scrolled into native scrollback (viewport_top > 0), forcing a // full redraw reprinted the ENTIRE conversation from line 0 each delta — // the visible flash. The swap must stay differential and never re-emit the // scrolled-away prefix. var buf = std.Io.Writer.Allocating.init(testing.allocator); defer buf.deinit(); // Short viewport so the header scrolls off: total 9 lines, height 8 => // viewport_top 1. The swapped tool slot stays in view (offset 6 >= 1). var eng = makeEngine(&buf, 80, 8); defer eng.deinit(); // [tall header h0..h5][tool][input][footer]; header h0 scrolls off the top. var header = FakeComponent{ .scripts = &.{&.{ "h0", "h1", "h2", "h3", "h4", "h5" }}, .first_changed = &.{0} }; var tool = FakeComponent{ .scripts = &.{&.{"tool_v0"}}, .first_changed = &.{0} }; var tool2 = FakeComponent{ .scripts = &.{&.{"tool_v1"}}, .first_changed = &.{0} }; var input = FakeComponent{ .scripts = &.{&.{"input"}}, .first_changed = &.{0} }; var footer = FakeComponent{ .scripts = &.{&.{"f0"}}, .first_changed = &.{0} }; try eng.syncComponents(&.{ header.comp(), tool.comp(), input.comp(), footer.comp() }); try eng.render(); try testing.expect(eng.viewport_top > 0); // Swap the tool component (the per-delta override re-install). buf.clearRetainingCapacity(); try eng.syncComponents(&.{ header.comp(), tool2.comp(), input.comp(), footer.comp() }); try eng.render(); const out = buf.written(); // No screen clear and — the flicker signature — no reprint from line 0: // the scrolled-away header top is never re-emitted. try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null); try testing.expect(std.mem.indexOf(u8, out, "h0") == null); try testing.expect(std.mem.indexOf(u8, out, "tool_v1") != null); // the swap shows } 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 "cursor_hint repositions the hardware cursor to (line,col) with relative moves" { // A single-line frame with the marker at column 2. After writing content // the cursor rests at end-of-content (row 0, col 4). The hint is on the // SAME row (0), so there is no vertical move: just CR to column 0 then // cursor-forward by 2. The emitted tail must be `\r\x1b[2C`. 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(); // Same row => no vertical move escape. try testing.expect(std.mem.indexOf(u8, out, "\x1b[1A") == null); try testing.expect(std.mem.indexOf(u8, out, "\x1b[1B") == null); // CR + cursor-forward(2) places the cursor at the marker column. try testing.expect(std.mem.indexOf(u8, out, "\r\x1b[2C") != null); // The engine recorded the row it left the cursor on. try testing.expectEqual(@as(usize, 0), eng.hw_cursor_row); } test "cursor_hint on a non-last line moves up then forward" { // Three lines, marker on the MIDDLE line (global row 1) at column 1. After // content the cursor rests at row 2 (end of "l2"). To reach the marker: // up 1 row (`\x1b[1A`), CR, forward 1 (`\x1b[1C`). 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 = &.{&.{ "l0", "a" ++ CURSOR_MARKER ++ "b", "l2" }}, .first_changed = &.{0}, }; try eng.addComponent(body.comp()); try eng.render(); const out = buf.written(); try testing.expect(eng.cursor_hint != null); try testing.expectEqual(@as(usize, 1), eng.cursor_hint.?.line); try testing.expectEqual(@as(usize, 1), eng.cursor_hint.?.col); // Up 1, then CR + forward 1. try testing.expect(std.mem.indexOf(u8, out, "\x1b[1A") != null); try testing.expect(std.mem.indexOf(u8, out, "\r\x1b[1C") != null); try testing.expectEqual(@as(usize, 1), eng.hw_cursor_row); } test "no-marker frame parks cursor at end-of-content and does not misposition" { // Without a marker, hw_cursor_row must equal the last content row and no // IME cursor-forward escape is emitted on a fresh, column-0 baseline. 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 = &.{&.{ "l0", "l1", "l2" }}, .first_changed = &.{0}, }; try eng.addComponent(body.comp()); try eng.render(); try testing.expect(eng.cursor_hint == null); // Cursor parked at the last content row (row 2 of a 3-line frame). try testing.expectEqual(@as(usize, 2), eng.hw_cursor_row); } test "rest-row reconciliation: a marker frame followed by a differential frame patches the right lines" { // THE acceptance-critical invariant. Frame 1 emits a marker on the MIDDLE // line, so the engine parks the hardware cursor on row 1 (not the // end-of-content row 2). Frame 2 is a differential frame that changes the // TAIL (row 2). The differential up/down math must start from the ACTUAL // cursor row (1, via hw_cursor_row), not the stale // "rest-at-end-of-content" assumption (2) — otherwise it would mis-target // the patched region. // // With reconciliation: cut == 2, start_row == 1 => move DOWN 1 (`\x1b[1B`), // CR, reprint the tail. The unchanged upper lines (l0, the marker line) // must NOT be reprinted. 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(); var body = FakeComponent{ .scripts = &.{ &.{ "l0", "a" ++ CURSOR_MARKER ++ "b", "l2" }, &.{ "l0", "a" ++ CURSOR_MARKER ++ "b", "L2" }, // only the tail changes }, .first_changed = &.{ 0, 2 }, }; try eng.addComponent(body.comp()); try eng.render(); // frame 1: marker parks cursor on row 1 try testing.expectEqual(@as(usize, 1), eng.hw_cursor_row); body.advance(); buf.clearRetainingCapacity(); try eng.render(); // frame 2: differential tail change const out = buf.written(); // Stayed differential (no full clear). try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null); // The cursor started on row 1 and the cut is row 2 => a DOWN move, proving // the reconciliation used hw_cursor_row (1), not total_lines-1 (2). try testing.expect(std.mem.indexOf(u8, out, "\x1b[1B") != null); // The changed tail is reprinted. try testing.expect(std.mem.indexOf(u8, out, "L2") != null); // The untouched first line is NOT reprinted (it is above the cut). If the // reconciliation were wrong, the engine would mis-position and the diff // region would be corrupted. try testing.expect(std.mem.indexOf(u8, out, "l0") == null); // And the marker frame re-parks the cursor on row 1 again. try testing.expectEqual(@as(usize, 1), eng.hw_cursor_row); } test "cursor reconciliation: MULTIPLE differential frames after a marker frame" { // Stress the rest-row reconciliation across a SEQUENCE of differential // frames (not just one). The cursor marker sits on the middle line and // never moves; only the tail line changes each frame. Every frame the // engine must (1) start its diff move from hw_cursor_row, (2) re-park the // cursor on the marker row, so hw_cursor_row stays pinned to row 1 across // all frames and the upper lines are never reprinted. 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 = &.{ &.{ "l0", "a" ++ CURSOR_MARKER ++ "b", "t0" }, &.{ "l0", "a" ++ CURSOR_MARKER ++ "b", "t1" }, &.{ "l0", "a" ++ CURSOR_MARKER ++ "b", "t2" }, &.{ "l0", "a" ++ CURSOR_MARKER ++ "b", "t3" }, }, .first_changed = &.{ 0, 2, 2, 2 }, }; try eng.addComponent(body.comp()); try eng.render(); // first paint, marker parks cursor on row 1 try testing.expectEqual(@as(usize, 1), eng.hw_cursor_row); // Three successive differential tail changes. inline for (.{ "t1", "t2", "t3" }) |tail| { body.advance(); buf.clearRetainingCapacity(); try eng.render(); const out = buf.written(); // Each frame: cursor was on row 1, cut is row 2 => DOWN 1 to reach it. try testing.expect(std.mem.indexOf(u8, out, "\x1b[1B") != null); // The new tail is painted; the marker line and l0 are not. try testing.expect(std.mem.indexOf(u8, out, tail) != null); try testing.expect(std.mem.indexOf(u8, out, "l0") == null); // Re-parked on the marker row. try testing.expectEqual(@as(usize, 1), eng.hw_cursor_row); // Hint preserved (marker line above the cut => restored). try testing.expect(eng.cursor_hint != null); try testing.expectEqual(@as(usize, 1), eng.cursor_hint.?.line); } } test "cursor reconciliation: marker MOVES between frames (user editing)" { // The user types/moves the caret: the marker shifts column and row across // frames. Because the marker is part of the diffed line bytes, a move // changes that line, rolls the cut to it, and the scan re-derives the // fresh hint. hw_cursor_row and the emitted forward-distance must track the // new position each frame. 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 = &.{ // Frame 1: marker after "ab" on row 1 (col 2). &.{ "hdr", "ab" ++ CURSOR_MARKER, "ftr" }, // Frame 2: caret moved left to col 1 (marker between a and b). &.{ "hdr", "a" ++ CURSOR_MARKER ++ "b", "ftr" }, // Frame 3: caret moved up to the header row (row 0, col 3). &.{ "hdr" ++ CURSOR_MARKER, "ab", "ftr" }, }, .first_changed = &.{ 0, 1, 0 }, }; try eng.addComponent(body.comp()); try eng.render(); // marker col 2 on row 1 try testing.expectEqual(@as(usize, 1), eng.cursor_hint.?.line); try testing.expectEqual(@as(usize, 2), eng.cursor_hint.?.col); try testing.expectEqual(@as(usize, 1), eng.hw_cursor_row); body.advance(); buf.clearRetainingCapacity(); try eng.render(); // marker now col 1, still row 1 try testing.expectEqual(@as(usize, 1), eng.cursor_hint.?.line); try testing.expectEqual(@as(usize, 1), eng.cursor_hint.?.col); try testing.expectEqual(@as(usize, 1), eng.hw_cursor_row); // CR + forward 1 places the caret. try testing.expect(std.mem.indexOf(u8, buf.written(), "\r\x1b[1C") != null); body.advance(); buf.clearRetainingCapacity(); try eng.render(); // marker jumps UP to row 0, col 3 try testing.expectEqual(@as(usize, 0), eng.cursor_hint.?.line); try testing.expectEqual(@as(usize, 3), eng.cursor_hint.?.col); try testing.expectEqual(@as(usize, 0), eng.hw_cursor_row); } test "cursor reconciliation: marker DISAPPEARS (focus lost) clears the hint" { // When a focused component loses focus it stops emitting the marker. That // is a byte change on the marker line, so the diff rolls the cut to that // line, the scan finds no marker, and the hint must drop to null (the // cursor falls back to end-of-content). This guards the stale-hint bug: // the above-the-cut restoration must NOT resurrect a marker that no longer // exists, because a vanished marker can only appear via a line change that // forces re-scan. 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 = &.{ &.{ "l0", "a" ++ CURSOR_MARKER ++ "b", "l2" }, &.{ "l0", "ab", "l2" }, // marker gone on row 1 }, .first_changed = &.{ 0, 1 }, }; try eng.addComponent(body.comp()); try eng.render(); try testing.expect(eng.cursor_hint != null); try testing.expectEqual(@as(usize, 1), eng.hw_cursor_row); body.advance(); buf.clearRetainingCapacity(); try eng.render(); // The hint is cleared; the cursor parks at end-of-content (row 2). try testing.expect(eng.cursor_hint == null); try testing.expectEqual(@as(usize, 2), eng.hw_cursor_row); } test "cursor reconciliation: marker BELOW the cut is re-scanned and re-derived" { // The complement of the above-cut restoration: an UPPER line changes (cut // rolls high) while the marker sits on a LOWER line. That lower line is // within the repainted region (>= cut), so the scan re-derives the hint // from fresh bytes rather than the above-cut restoration branch. The caret // must still land on its (unchanged) row/col. 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 = &.{ &.{ "h0", "mid", "x" ++ CURSOR_MARKER ++ "y" }, // marker on row 2 &.{ "H0", "mid", "x" ++ CURSOR_MARKER ++ "y" }, // only row 0 changes }, .first_changed = &.{ 0, 0 }, }; try eng.addComponent(body.comp()); try eng.render(); try testing.expectEqual(@as(usize, 2), eng.cursor_hint.?.line); try testing.expectEqual(@as(usize, 2), eng.hw_cursor_row); // parked on the marker row body.advance(); buf.clearRetainingCapacity(); try eng.render(); // cut == 0 (row 0 changed); marker row 2 is BELOW the cut const out = buf.written(); try testing.expect(std.mem.indexOf(u8, out, "H0") != null); // changed upper line repainted // Marker re-derived from the fresh scan of row 2. try testing.expect(eng.cursor_hint != null); try testing.expectEqual(@as(usize, 2), eng.cursor_hint.?.line); try testing.expectEqual(@as(usize, 1), eng.cursor_hint.?.col); // after "x" try testing.expectEqual(@as(usize, 2), eng.hw_cursor_row); } test "cursor reconciliation: marker exactly AT the cut is re-derived, not stale" { // The marker line is precisely the cut line. It IS re-scanned (cut is // inclusive), so the hint comes from the fresh scan, never the above-cut // restoration branch (which only fires for h.line < cut_line). 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 = &.{ &.{ "l0", "x" ++ CURSOR_MARKER ++ "y", "l2" }, // Row 1 changes (marker shifts to col 2) and is the cut. &.{ "l0", "xy" ++ CURSOR_MARKER, "l2" }, }, .first_changed = &.{ 0, 1 }, }; try eng.addComponent(body.comp()); try eng.render(); body.advance(); buf.clearRetainingCapacity(); try eng.render(); try testing.expect(eng.cursor_hint != null); try testing.expectEqual(@as(usize, 1), eng.cursor_hint.?.line); try testing.expectEqual(@as(usize, 2), eng.cursor_hint.?.col); // after "xy" try testing.expectEqual(@as(usize, 1), eng.hw_cursor_row); } test "cursor reconciliation: full redraw (clear) between marker frames resets cleanly" { // A resize forces a full clear+redraw between marker frames. fullRedraw // re-scans every line, so the hint is re-derived from scratch and the // hardware cursor is re-anchored without any stale carry-over. 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 = &.{ &.{ "l0", "a" ++ CURSOR_MARKER ++ "b", "l2" }, &.{ "l0", "a" ++ CURSOR_MARKER ++ "b", "l2" }, }, .first_changed = &.{ 0, null }, }; try eng.addComponent(body.comp()); try eng.render(); try testing.expectEqual(@as(usize, 1), eng.hw_cursor_row); body.advance(); buf.clearRetainingCapacity(); eng.resize(70, 100); // width change => full clear + redraw try eng.render(); const out = buf.written(); try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) != null); // Hint re-derived from the full re-scan, cursor re-anchored on row 1. try testing.expect(eng.cursor_hint != null); try testing.expectEqual(@as(usize, 1), eng.cursor_hint.?.line); try testing.expectEqual(@as(usize, 1), eng.hw_cursor_row); } test "cursor reconciliation: no-op frame preserves the prior IME position" { // A differential frame with no change (cut == null) returns early WITHOUT // moving the cursor. hw_cursor_row and cursor_hint must survive untouched // so the IME stays anchored where the previous marker frame left it. 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 = &.{ &.{ "l0", "a" ++ CURSOR_MARKER ++ "b", "l2" }, &.{ "l0", "a" ++ CURSOR_MARKER ++ "b", "l2" }, // identical }, .first_changed = &.{ 0, null }, }; try eng.addComponent(body.comp()); try eng.render(); try testing.expectEqual(@as(usize, 1), eng.hw_cursor_row); const hint_before = eng.cursor_hint; body.advance(); buf.clearRetainingCapacity(); try eng.render(); // no-op frame // No cursor-move escapes emitted (the frame is a sync-wrapped no-op). const out = buf.written(); try testing.expect(std.mem.indexOf(u8, out, "\x1b[1A") == null); try testing.expect(std.mem.indexOf(u8, out, "\x1b[1B") == null); // State preserved. try testing.expectEqual(@as(usize, 1), eng.hw_cursor_row); try testing.expect(eng.cursor_hint != null); try testing.expectEqual(hint_before.?.line, eng.cursor_hint.?.line); try testing.expectEqual(hint_before.?.col, eng.cursor_hint.?.col); } test "cursor reconciliation: marker with content scrolled into scrollback (viewport_top)" { // With a short viewport, upper content scrolls into native scrollback // (viewport_top > 0). A marker on a still-visible line must still anchor // correctly: the hint row is a GLOBAL row, and positionHardwareCursor moves // relative to last_content_row, so the math is independent of viewport_top. var buf = std.Io.Writer.Allocating.init(testing.allocator); defer buf.deinit(); var eng = makeEngine(&buf, 80, 3); // height 3 defer eng.deinit(); // 5 lines, marker on row 3 (visible: rows 2..4 are the bottom 3). var body = FakeComponent{ .scripts = &.{&.{ "l0", "l1", "l2", "m" ++ CURSOR_MARKER, "l4" }}, .first_changed = &.{0}, }; try eng.addComponent(body.comp()); try eng.render(); try testing.expect(eng.viewport_top > 0); try testing.expectEqual(@as(usize, 3), eng.cursor_hint.?.line); try testing.expectEqual(@as(usize, 1), eng.cursor_hint.?.col); // after "m" // last_content_row is 4; marker on row 3 => up 1 to reach it. try testing.expect(std.mem.indexOf(u8, buf.written(), "\x1b[1A") != null); try testing.expectEqual(@as(usize, 3), eng.hw_cursor_row); } 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)); } test "layout change reprint homes cursor to viewport_top (no duplication when cursor was repositioned)" { // Regression: when the hardware cursor was parked at an input row (above // end-of-content) and a layout change triggered a full reprint, fullRedraw // wrote all lines starting from the CURRENT cursor row rather than // viewport_top, duplicating previously rendered content in the terminal. // The fix: non-clearing fullRedraw moves the cursor to viewport_top first. var buf = std.Io.Writer.Allocating.init(testing.allocator); defer buf.deinit(); var eng = makeEngine(&buf, 80, 100); // tall: viewport_top stays 0 defer eng.deinit(); // First frame: 3-line component (welcome + user + input). var body = FakeComponent{ .scripts = &.{ &.{ "welcome", "user", "input" }, &.{ "welcome", "user", "input" } }, .first_changed = &.{ 0, 0 }, }; try eng.addComponent(body.comp()); try eng.render(); // Simulate the app parking the cursor at the input row (row 1, mid-content) // as it would after positioning the hardware cursor for IME anchoring. eng.hw_cursor_row = 1; // Layout change: add a new component (thinking block spawn). body.advance(); var thinking = FakeComponent{ .scripts = &.{&.{"thinking..."}}, .first_changed = &.{0} }; buf.clearRetainingCapacity(); _ = eng.removeComponent(body.comp()); // drain try eng.addComponent(body.comp()); // re-add (rebuildEngineList shape) try eng.addComponent(thinking.comp()); try eng.render(); const out = buf.written(); // Must NOT wipe scrollback. try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null); // Must reposition the cursor to viewport_top (row 0) from hw_cursor_row (1) // before reprinting: that's one cursorUp(1) = "\x1b[1A" followed by \r. try testing.expect(std.mem.indexOf(u8, out, "\x1b[1A") != null); // Content must be present exactly once. try testing.expectEqual(@as(usize, 1), std.mem.count(u8, out, "welcome")); try testing.expectEqual(@as(usize, 1), std.mem.count(u8, out, "thinking...")); }