summaryrefslogtreecommitdiff
path: root/src/tui_component.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-08 10:41:30 -0600
committert <t@tjp.lol>2026-06-08 12:25:55 -0600
commite5ed00c52bb10aec811734c5568c881c41e58474 (patch)
treef51342f759efac79ec175dcc3ede7ebebc0bc470 /src/tui_component.zig
parenta5881243f9bb4642f90fa94179f7a5bff23e4657 (diff)
Replace print CLIRenderer with differential TUI engine
Implement TUI Phase 1: a raw-mode terminal, differential render engine, pinned input box + footer, and component model wired into the libpanto event stream. Adds foundation modules (theme, key, component, input, terminal), the render engine, components, and the app loop; removes the old print CLIRenderer and driveTurn REPL from main.zig. Also removes scratch status reports (tui-p1/, progress.md) for the now completed work.
Diffstat (limited to 'src/tui_component.zig')
-rw-r--r--src/tui_component.zig337
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);
+}