//! The core component contract for the TUI. //! //! Dispatch is a vtable of function pointers over `*anyopaque` (decided in //! plan §4.5; a tagged union was rejected so out-of-tree extensions can define //! their own components later without editing a central enum). //! //! The render engine (later sub-phase) holds a list of `Component`s, asks each //! to `render(width)` into lines, and uses `firstLineChanged` to do a //! differential repaint. This file defines only the interface plus a small //! reusable cache/dirty mixin; it implements no concrete components and no //! engine. const std = @import("std"); /// Invisible cursor marker. /// /// A focused `Focusable` component embeds this marker in its rendered output /// at the virtual cursor location. It is an APC string (`ESC _ ... ESC \`), /// which terminals ignore (zero visible width), so it survives in the line /// buffer until the engine scans for it. In P3 the engine will locate this /// marker, strip it from the emitted bytes, and position the hardware cursor /// there; for P1 the marker simply ships and is treated as zero-width. /// /// The payload `panto-cursor` disambiguates it from any other APC a child /// component might legitimately emit. pub const CURSOR_MARKER = "\x1b_panto-cursor\x1b\\"; /// The component vtable. Concrete components store their own state behind /// `ptr` and provide function pointers that recover the concrete type via /// `@ptrCast`/`@alignCast`. /// /// Line / width contract (plan §3.1): every line returned by `render` MUST /// have visible width <= the `width` argument. The engine validates this and /// treats overflow as a hard error, so components are responsible for /// truncating. The returned `[]const []const u8` and its lines are owned by /// the component (typically backed by its render cache); they must remain /// valid until the next `render`/`invalidate` call on that component. pub const Component = struct { ptr: *anyopaque, vtable: *const VTable, pub const VTable = struct { /// Render the component at `width` columns, returning one slice per /// visible line. Each line's visible width must be <= `width`. /// `alloc` is the engine's per-frame/render allocator; whether the /// component caches into its own storage or allocates fresh each call /// is up to it, but the returned slices must outlive the call until /// the next render/invalidate. render: *const fn (ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8, /// Lowest component-local line index whose rendered output differs /// since the last successful render, or null if nothing changed. /// /// This MUST be derived from the component's render cache (see /// `RenderCache`), not a separately hand-managed integer that can /// drift from reality. firstLineChanged: *const fn (ptr: *anyopaque) ?usize, /// Drop cached render state, forcing a full re-render and re-dirtying /// the component. invalidate: *const fn (ptr: *anyopaque) void, /// Optional: feed raw input bytes (already routed to this component by /// the engine) for the component to interpret. Null when the component /// does not accept input. handleInput: ?*const fn (ptr: *anyopaque, data: []const u8) void = null, /// Capability: when true the engine should deliver key *release* /// events to this component (most components only want press/repeat). wantsKeyRelease: bool = false, }; pub fn render(self: Component, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 { return self.vtable.render(self.ptr, width, alloc); } pub fn firstLineChanged(self: Component) ?usize { return self.vtable.firstLineChanged(self.ptr); } pub fn invalidate(self: Component) void { self.vtable.invalidate(self.ptr); } pub fn handleInput(self: Component, data: []const u8) void { if (self.vtable.handleInput) |f| f(self.ptr, data); } pub fn wantsKeyRelease(self: Component) bool { return self.vtable.wantsKeyRelease; } }; /// The focus contract. A component that can hold focus exposes `focused`, /// which the engine flips on focus changes. When focused, the component emits /// `CURSOR_MARKER` at its virtual cursor position inside its `render` output. /// /// This is a thin embeddable struct: a focusable component contains one and /// the engine sets `.focused` via the component's own setter (the vtable does /// not yet carry focus methods — focus routing wiring is part of the engine /// sub-phase; what ships in P1 is the flag + the marker constant + the /// documented contract). pub const Focusable = struct { focused: bool = false, pub fn setFocused(self: *Focusable, value: bool) void { self.focused = value; } }; /// Reusable cache + dirty bookkeeping for components. /// /// firstLineChanged lifecycle (mirrors pi's `invalidate()` model): /// - State mutation calls `markDirty()` (or `invalidate()`), which clears /// the cache. While dirty, `firstLineChanged()` reports the recorded /// dirty line (0 by default — "everything from the top changed"). /// - A *successful* render calls `store(lines)`, which: /// 1. diffs the new lines against the previously cached lines, /// 2. records the lowest differing index as the "changed-from" value, /// 3. replaces the cache with a copy of the new lines, /// 4. marks the cache clean. /// - Once the cache is CLEAN, `firstLineChanged()` returns null regardless /// of the diff `store` recorded. `firstLineChanged()` is the LIVE /// pre-render dirty signal (the engine reads it once per slot BEFORE /// `render` to decide whether to re-render and where to cut); a clean /// component is already up to date on screen and has nothing pending, so /// it must report null. The recorded diff index is retained separately in /// `changed_from` as render bookkeeping and is NOT a live signal — exposing /// it while clean would peg the differential cut to a stale line on every /// later frame (a first-paint store records `changed_from == 0`, which /// would otherwise force the cut to line 0 forever). /// /// Because `firstLineChanged` is computed purely from cache state, it cannot /// drift from a hand-managed field. Components embed this struct and route /// their vtable through `cacheRender`/`markDirty`. /// /// The cache owns its copies of the line bytes (allocated with the allocator /// passed to `init`); call `deinit` to free them. pub const RenderCache = struct { alloc: std.mem.Allocator, /// Owned copies of the last rendered lines, or null when never rendered or /// invalidated. lines: ?[][]u8 = null, /// Lowest line index that changed at the last `store` (or the pending /// dirty line while dirty; 0 when dirty with no prior cache). This is /// render bookkeeping, NOT the live signal: when the cache is clean, /// `firstLineChanged()` returns null even though this still holds the last /// diff index. Null only when a clean store found no change. changed_from: ?usize = null, dirty: bool = true, pub fn init(alloc: std.mem.Allocator) RenderCache { return .{ .alloc = alloc }; } pub fn deinit(self: *RenderCache) void { self.freeLines(); } fn freeLines(self: *RenderCache) void { if (self.lines) |lines| { for (lines) |line| self.alloc.free(line); self.alloc.free(lines); self.lines = null; } } /// Mark the component dirty (e.g. on any state mutation). Drops the cache /// so the next render is a full render starting at `from` (default 0). pub fn markDirty(self: *RenderCache) void { self.markDirtyFrom(0); } /// Mark dirty starting at a specific line. The cache is dropped (we no /// longer trust any cached line), and `firstLineChanged` will report /// `from` until the next successful render. pub fn markDirtyFrom(self: *RenderCache, from: usize) void { self.dirty = true; self.changed_from = from; self.freeLines(); } /// Mark dirty for an APPEND-style mutation that only affects the tail of /// the rendered output (e.g. a streaming content delta appended to the /// end of a buffer). /// /// Unlike `markDirty`/`markDirtyFrom`, this RETAINS the cached baseline /// lines so the next `store` can diff against them and recover the TRUE /// lowest-changed index (which, for an append, is near the tail). While /// dirty, `firstLineChanged` reports a conservative TAIL HINT — the index /// of the last cached line (or 0 when there is no baseline yet) — so the /// engine re-renders and the cut stays near the end rather than rolling to /// line 0. The post-render diff in `store` then replaces the hint with the /// exact change point. `firstLineChanged` therefore remains cache-derived: /// the dirty hint comes from the retained cache's length, and the precise /// value comes from the diff. pub fn markDirtyAppend(self: *RenderCache) void { self.dirty = true; if (self.lines) |lines| { // Tail hint: the last existing line is the lowest line an append // can change. Keep the baseline for the diff in `store`. self.changed_from = if (lines.len == 0) 0 else lines.len - 1; } else { self.changed_from = 0; } } /// Equivalent to `markDirty` — full drop + re-dirty. Provided so a /// component's `invalidate` vtable entry can forward here verbatim. pub fn invalidate(self: *RenderCache) void { self.markDirty(); } /// Derived dirty signal. Returns the lowest changed line index, or null /// when the cache is clean and nothing changed at the last store. pub fn firstLineChanged(self: *const RenderCache) ?usize { // The engine reads this as the PRE-render dirty signal (once per slot, // before `render`). A clean cache has no pending change to repaint, so // it must report null — even though the last `store` recorded a // `changed_from` diff index. Returning that retained index while clean // makes an unchanged component spuriously cut the differential repaint // at its stale diff line on EVERY later frame (notably a first-paint // store leaves `changed_from == 0`, which would peg the cut to line 0 // forever). The retained `changed_from` is internal render bookkeeping, // not a live signal; only the dirty state drives a repaint. if (self.dirty) return self.changed_from orelse 0; return null; } /// Record a successful render. Diffs `new_lines` against the cache, /// updates `changed_from` to the lowest differing index, copies the new /// lines into the cache, and marks it clean. pub fn store(self: *RenderCache, new_lines: []const []const u8) !void { const diff = self.computeFirstDiff(new_lines); // Build the new owned copy first; only swap in on success. 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.freeLines(); self.lines = copies; self.changed_from = diff; self.dirty = false; } /// Lowest index at which `new_lines` differs from the cached lines. /// Returns null when they are identical. When there is no prior cache (or /// the line counts differ at index 0), returns 0. fn computeFirstDiff(self: *const RenderCache, new_lines: []const []const u8) ?usize { const old = self.lines orelse return 0; const n = @min(old.len, new_lines.len); var i: usize = 0; while (i < n) : (i += 1) { if (!std.mem.eql(u8, old[i], new_lines[i])) return i; } // Common prefix matched; if lengths differ, the first extra/missing // line index is the change point. if (old.len != new_lines.len) return n; return null; } }; test "CURSOR_MARKER is an APC string" { try std.testing.expect(std.mem.startsWith(u8, CURSOR_MARKER, "\x1b_")); try std.testing.expect(std.mem.endsWith(u8, CURSOR_MARKER, "\x1b\\")); } test "RenderCache: starts dirty from 0" { var c = RenderCache.init(std.testing.allocator); defer c.deinit(); try std.testing.expectEqual(@as(?usize, 0), c.firstLineChanged()); } test "RenderCache: store cleans, no-change render returns null" { var c = RenderCache.init(std.testing.allocator); defer c.deinit(); const a = [_][]const u8{ "one", "two" }; try c.store(&a); // store cleans the cache, so the live signal is null (clean => null). // The recorded diff (first store after empty => 0) lives in changed_from. try std.testing.expectEqual(@as(?usize, null), c.firstLineChanged()); try std.testing.expectEqual(@as(?usize, 0), c.changed_from); // Re-store identical => no change, still clean. try c.store(&a); try std.testing.expectEqual(@as(?usize, null), c.firstLineChanged()); try std.testing.expectEqual(@as(?usize, null), c.changed_from); } test "RenderCache: store reports lowest differing line" { var c = RenderCache.init(std.testing.allocator); defer c.deinit(); const a = [_][]const u8{ "one", "two", "three" }; try c.store(&a); const b = [_][]const u8{ "one", "TWO", "three" }; try c.store(&b); // The diff store computed is recorded in changed_from; the live signal is // null because the cache is now clean (already rendered on screen). try std.testing.expectEqual(@as(?usize, 1), c.changed_from); try std.testing.expectEqual(@as(?usize, null), c.firstLineChanged()); } test "RenderCache: line-count change reports the boundary" { var c = RenderCache.init(std.testing.allocator); defer c.deinit(); const a = [_][]const u8{ "one", "two" }; try c.store(&a); const b = [_][]const u8{ "one", "two", "three" }; try c.store(&b); try std.testing.expectEqual(@as(?usize, 2), c.changed_from); try std.testing.expectEqual(@as(?usize, null), c.firstLineChanged()); } test "RenderCache: markDirtyAppend keeps baseline and reports a tail hint, diff recovers exact change" { var c = RenderCache.init(std.testing.allocator); defer c.deinit(); const a = [_][]const u8{ "l0", "l1", "l2", "l3" }; try c.store(&a); // No baseline change yet; append-dirty reports the tail hint (last line). c.markDirtyAppend(); try std.testing.expectEqual(@as(?usize, 3), c.firstLineChanged()); // The baseline is retained for the diff. try std.testing.expect(c.lines != null); // A render that only adds a tail line: diff recovers the exact boundary (4), // NOT 0 — the streaming-tail property. The cache is clean after store, so // the recorded diff lives in changed_from and the live signal is null. const b = [_][]const u8{ "l0", "l1", "l2", "l3", "l4" }; try c.store(&b); try std.testing.expectEqual(@as(?usize, 4), c.changed_from); try std.testing.expectEqual(@as(?usize, null), c.firstLineChanged()); } test "RenderCache: markDirtyAppend with no baseline reports 0" { var c = RenderCache.init(std.testing.allocator); defer c.deinit(); c.markDirtyAppend(); try std.testing.expectEqual(@as(?usize, 0), c.firstLineChanged()); } test "RenderCache: markDirty re-dirties from 0" { var c = RenderCache.init(std.testing.allocator); defer c.deinit(); const a = [_][]const u8{"x"}; try c.store(&a); c.markDirty(); try std.testing.expectEqual(@as(?usize, 0), c.firstLineChanged()); } test "Focusable flips" { var f: Focusable = .{}; try std.testing.expect(!f.focused); f.setFocused(true); try std.testing.expect(f.focused); }