diff options
Diffstat (limited to 'src/tui_component.zig')
| -rw-r--r-- | src/tui_component.zig | 337 |
1 files changed, 337 insertions, 0 deletions
diff --git a/src/tui_component.zig b/src/tui_component.zig new file mode 100644 index 0000000..a2b0ea4 --- /dev/null +++ b/src/tui_component.zig @@ -0,0 +1,337 @@ +//! 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. +/// - After a clean render with no diff, `firstLineChanged()` returns null. +/// +/// 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 0 when dirty + /// with no prior cache. Null only when clean with 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 { + if (self.dirty) return self.changed_from orelse 0; + return self.changed_from; + } + + /// 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); + // First store after empty cache => changed from 0. + try std.testing.expectEqual(@as(?usize, 0), c.firstLineChanged()); + + // Re-store identical => no change. + try c.store(&a); + try std.testing.expectEqual(@as(?usize, null), c.firstLineChanged()); +} + +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); + try std.testing.expectEqual(@as(?usize, 1), 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.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. + const b = [_][]const u8{ "l0", "l1", "l2", "l3", "l4" }; + try c.store(&b); + try std.testing.expectEqual(@as(?usize, 4), 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); +} |
