summaryrefslogtreecommitdiff
path: root/src/tui_components.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/tui_components.zig')
-rw-r--r--src/tui_components.zig1101
1 files changed, 1101 insertions, 0 deletions
diff --git a/src/tui_components.zig b/src/tui_components.zig
new file mode 100644
index 0000000..1832fc2
--- /dev/null
+++ b/src/tui_components.zig
@@ -0,0 +1,1101 @@
+//! Built-in P1 components for the TUI (plan §6).
+//!
+//! Each component satisfies the `Component` vtable (`tui_component.zig`) and
+//! implements the cache-derived dirty model exactly as `RenderCache` defines
+//! it: any state mutation calls `markDirty`/`markDirtyFrom` (drops the cache),
+//! and a successful `render` calls `cache.store(lines)` (diffs the new lines
+//! against the prior cache, records the lowest differing index, re-populates
+//! the cache, and marks it clean). `firstLineChanged` is therefore derived
+//! purely from cache state and never a hand-managed integer that can drift.
+//!
+//! Data-in / lines-out: each component takes STRUCTURED DATA IN via setters or
+//! delta-appenders and produces LINES OUT from `render(width, alloc)`. Every
+//! returned line's visible width is <= `width` (we TRUNCATE; the engine treats
+//! overflow as a hard error per plan §3.1).
+//!
+//! Render storage convention: a component renders into a transient list, calls
+//! `cache.store(lines)` (which dupes the bytes into cache-owned storage), then
+//! returns `cache.lines` re-typed as `[]const []const u8`. The returned slices
+//! are owned by the cache and stay valid until the next `render`/`invalidate`
+//! — satisfying the vtable's lifetime contract.
+
+const std = @import("std");
+const component = @import("tui_component.zig");
+const theme = @import("tui_theme.zig");
+const input = @import("tui_input.zig");
+const key = @import("tui_key.zig");
+
+const Component = component.Component;
+const Focusable = component.Focusable;
+const RenderCache = component.RenderCache;
+const CURSOR_MARKER = component.CURSOR_MARKER;
+const Style = theme.Style;
+const Key = key.Key;
+const KeyCode = key.KeyCode;
+
+// ===========================================================================
+// Shared helpers
+// ===========================================================================
+
+/// Number of display columns occupied by `text`, counted as one column per
+/// UTF-8 codepoint. `text` here is assumed to be PLAIN (no escape sequences);
+/// components wrap on plain text and only add styling escapes afterward, so
+/// this is a faithful visible width. Mirrors the engine's P1 approximation
+/// (1 col per codepoint; wide CJK/emoji width is a deferred refinement).
+pub fn displayWidth(text: []const u8) usize {
+ var cols: usize = 0;
+ var i: usize = 0;
+ while (i < text.len) {
+ const seq_len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1;
+ cols += 1;
+ i += @min(seq_len, text.len - i);
+ }
+ return cols;
+}
+
+/// Truncate `text` to at most `max_cols` display columns, returning a byte
+/// slice of `text` that ends on a codepoint boundary. Never splits a multibyte
+/// codepoint.
+pub fn truncateToCols(text: []const u8, max_cols: usize) []const u8 {
+ var cols: usize = 0;
+ var i: usize = 0;
+ while (i < text.len and cols < max_cols) {
+ const seq_len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1;
+ const adv = @min(seq_len, text.len - i);
+ i += adv;
+ cols += 1;
+ }
+ return text[0..i];
+}
+
+/// Wrap `text` (a single logical paragraph, no embedded newlines) into lines of
+/// at most `width` display columns, appending each produced line to `out`.
+/// Greedy word-wrap on ASCII spaces; a word longer than `width` is hard-split.
+/// An empty paragraph yields one empty line. Lines pushed to `out` are slices
+/// borrowed from `text` (no allocation of line bytes here; `out` only stores
+/// the slice headers).
+fn wrapParagraph(text: []const u8, width: usize, out: *std.ArrayList([]const u8), alloc: std.mem.Allocator) !void {
+ if (width == 0) {
+ try out.append(alloc, "");
+ return;
+ }
+ if (text.len == 0) {
+ try out.append(alloc, "");
+ return;
+ }
+
+ // Greedy word-wrap. We accumulate a line by byte range [line_start, i); on
+ // overflow we break at the last space that fits, or hard-split a word that
+ // is wider than `width`.
+ var line_start: usize = 0;
+ var line_cols: usize = 0;
+ var last_break: ?usize = null; // byte index of the last space on this line
+ var i: usize = 0;
+
+ while (i < text.len) {
+ const seq_len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1;
+ const adv = @min(seq_len, text.len - i);
+ const is_space = adv == 1 and text[i] == ' ';
+
+ if (is_space and line_cols == width) {
+ // The overflowing glyph is the inter-word space itself: break here
+ // and consume the space (standard word-wrap discards it) so the
+ // current word group fills the line exactly.
+ try out.append(alloc, text[line_start..i]);
+ line_start = i + adv;
+ last_break = null;
+ line_cols = 0;
+ i += adv;
+ continue;
+ }
+ if (line_cols + 1 > width) {
+ // Adding this glyph would overflow; break the line first.
+ if (last_break) |brk| {
+ // Break at the last space: emit up to (not including) it, and
+ // start the next line just after it.
+ try out.append(alloc, text[line_start..brk]);
+ line_start = brk + 1;
+ last_break = null;
+ line_cols = displayWidth(text[line_start..i]);
+ } else {
+ // No space on this line: hard-split before the current glyph.
+ try out.append(alloc, text[line_start..i]);
+ line_start = i;
+ line_cols = 0;
+ }
+ }
+
+ if (is_space) last_break = i;
+ line_cols += 1;
+ i += adv;
+ }
+ // Flush the final line (always emit, even if empty/trailing fragment).
+ try out.append(alloc, text[line_start..]);
+}
+
+/// Split `buffer` on newlines into paragraphs and wrap each to `width`,
+/// appending all produced lines to `out`. A trailing newline produces a final
+/// empty line (so a freshly-typed "\n" shows a blank row). An empty buffer
+/// produces no lines.
+fn wrapBuffer(buffer: []const u8, width: usize, out: *std.ArrayList([]const u8), alloc: std.mem.Allocator) !void {
+ if (buffer.len == 0) return;
+ var it = std.mem.splitScalar(u8, buffer, '\n');
+ while (it.next()) |para| {
+ try wrapParagraph(para, width, out, alloc);
+ }
+}
+
+/// Build the cache-owned line set for a styled text block: each wrapped plain
+/// line is wrapped in `style.open()`/`style.close()` and stored via the cache.
+/// Returns the cache's owned lines re-typed for the vtable.
+///
+/// `width` bounds the *visible* width: the plain text is truncated to `width`
+/// columns BEFORE styling escapes are added (escapes are zero visible width).
+fn renderStyledLines(
+ cache: *RenderCache,
+ buffer: []const u8,
+ style: Style,
+ width: usize,
+ alloc: std.mem.Allocator,
+) ![]const []const u8 {
+ // 1. Wrap plain text into borrowed slices.
+ var plain: std.ArrayList([]const u8) = .empty;
+ defer plain.deinit(alloc);
+ try wrapBuffer(buffer, width, &plain, alloc);
+
+ // 2. Style each line into a transient owned buffer.
+ var styled: std.ArrayList([]const u8) = .empty;
+ defer {
+ for (styled.items) |s| alloc.free(s);
+ styled.deinit(alloc);
+ }
+ for (plain.items) |line| {
+ // Defensive truncate (wrap already bounds it, but a hard contract).
+ const vis = truncateToCols(line, width);
+ const composed = try std.fmt.allocPrint(alloc, "{s}{s}{s}", .{ style.open(), vis, style.close() });
+ try styled.append(alloc, composed);
+ }
+
+ // 3. Commit to the cache (dupes), then return the cache's owned copy.
+ try cache.store(styled.items);
+ return cacheLines(cache);
+}
+
+/// Re-type the cache's owned `[][]u8` lines as `[]const []const u8` for the
+/// vtable return. The cache guarantees these outlive the call until the next
+/// render/invalidate.
+fn cacheLines(cache: *RenderCache) []const []const u8 {
+ const owned = cache.lines orelse return &.{};
+ return @ptrCast(owned);
+}
+
+// ===========================================================================
+// AssistantText — streaming assistant message (plan §6, §8)
+// ===========================================================================
+
+/// Accumulates assistant content deltas into an internal buffer and renders
+/// the wrapped text with the theme's assistant style.
+///
+/// Streaming-tail dirty model (plan §3.3): a delta is appended to the buffer
+/// and the cache is marked dirty; the engine then requests a render. Because
+/// appended text only changes the LAST wrapped line(s) and leaves earlier
+/// wrapped lines byte-identical, `RenderCache.store`'s diff naturally reports
+/// `firstLineChanged` near the TAIL, not line 0 — the cut stays near the end
+/// during streaming. (There is no per-delta render method on the interface;
+/// the delta just mutates state + dirties, per plan §8.)
+///
+/// Markdown hosting (plan §8, DEFERRED): the buffer/render are structured so a
+/// later pass can cache finished blocks and only re-render the last open block.
+/// For P1 this is plain text + word wrap; the tail-near firstLineChanged
+/// property already holds via the cache diff, so the markdown upgrade slots in
+/// without changing the dirty model.
+pub const AssistantText = struct {
+ alloc: std.mem.Allocator,
+ buffer: std.ArrayList(u8) = .empty,
+ cache: RenderCache,
+
+ pub fn init(alloc: std.mem.Allocator) AssistantText {
+ return .{ .alloc = alloc, .cache = RenderCache.init(alloc) };
+ }
+
+ pub fn deinit(self: *AssistantText) void {
+ self.buffer.deinit(self.alloc);
+ self.cache.deinit();
+ }
+
+ /// Append a streaming content delta. Mutates the buffer and marks the cache
+ /// dirty (the engine will requestRender). The cache diff keeps
+ /// firstLineChanged near the tail.
+ pub fn appendDelta(self: *AssistantText, delta: []const u8) !void {
+ try self.buffer.appendSlice(self.alloc, delta);
+ // markDirtyAppend RETAINS the baseline so the post-render diff recovers
+ // the true tail change point; while dirty it reports a tail hint, so
+ // the engine's cut stays near the end during streaming (plan §3.3/§8).
+ self.cache.markDirtyAppend();
+ }
+
+ /// Replace the whole buffer (e.g. a non-streaming set). Marks dirty.
+ pub fn setText(self: *AssistantText, text: []const u8) !void {
+ self.buffer.clearRetainingCapacity();
+ try self.buffer.appendSlice(self.alloc, text);
+ self.cache.markDirty();
+ }
+
+ fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 {
+ _ = alloc;
+ const self: *AssistantText = @ptrCast(@alignCast(ptr));
+ return renderStyledLines(&self.cache, self.buffer.items, theme.default.fg(.assistant), width, self.alloc);
+ }
+
+ fn firstLineChangedImpl(ptr: *anyopaque) ?usize {
+ const self: *AssistantText = @ptrCast(@alignCast(ptr));
+ return self.cache.firstLineChanged();
+ }
+
+ fn invalidateImpl(ptr: *anyopaque) void {
+ const self: *AssistantText = @ptrCast(@alignCast(ptr));
+ self.cache.invalidate();
+ }
+
+ const vtable = Component.VTable{
+ .render = renderImpl,
+ .firstLineChanged = firstLineChangedImpl,
+ .invalidate = invalidateImpl,
+ };
+
+ pub fn comp(self: *AssistantText) Component {
+ return .{ .ptr = self, .vtable = &vtable };
+ }
+};
+
+// ===========================================================================
+// UserText — submitted user message (plan §6)
+// ===========================================================================
+
+/// A submitted user message, rendered with the theme's user style. Static once
+/// set; `setText` replaces it and marks dirty.
+pub const UserText = struct {
+ alloc: std.mem.Allocator,
+ buffer: std.ArrayList(u8) = .empty,
+ cache: RenderCache,
+
+ pub fn init(alloc: std.mem.Allocator) UserText {
+ return .{ .alloc = alloc, .cache = RenderCache.init(alloc) };
+ }
+
+ pub fn deinit(self: *UserText) void {
+ self.buffer.deinit(self.alloc);
+ self.cache.deinit();
+ }
+
+ /// Set the (static) message text. Marks dirty.
+ pub fn setText(self: *UserText, text: []const u8) !void {
+ self.buffer.clearRetainingCapacity();
+ try self.buffer.appendSlice(self.alloc, text);
+ self.cache.markDirty();
+ }
+
+ fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 {
+ _ = alloc;
+ const self: *UserText = @ptrCast(@alignCast(ptr));
+ return renderStyledLines(&self.cache, self.buffer.items, theme.default.fg(.user), width, self.alloc);
+ }
+
+ fn firstLineChangedImpl(ptr: *anyopaque) ?usize {
+ const self: *UserText = @ptrCast(@alignCast(ptr));
+ return self.cache.firstLineChanged();
+ }
+
+ fn invalidateImpl(ptr: *anyopaque) void {
+ const self: *UserText = @ptrCast(@alignCast(ptr));
+ self.cache.invalidate();
+ }
+
+ const vtable = Component.VTable{
+ .render = renderImpl,
+ .firstLineChanged = firstLineChangedImpl,
+ .invalidate = invalidateImpl,
+ };
+
+ pub fn comp(self: *UserText) Component {
+ return .{ .ptr = self, .vtable = &vtable };
+ }
+};
+
+// ===========================================================================
+// InputBox — editable single-row+ input (plan §6, §3.5)
+// ===========================================================================
+
+/// A `Focusable` editor. Single row by default; ENTER submits, SHIFT+ENTER
+/// inserts a newline (grows one row per line). Growth is UNBOUNDED in P1 (the
+/// cap + scroll-window is deferred to P2).
+///
+/// Editing (raw keys via `handleInput`, decoded by `tui_input`):
+/// - printable chars (UTF-8) insert at the cursor
+/// - backspace deletes the codepoint before the cursor
+/// - delete removes the codepoint at the cursor
+/// - left/right move by one codepoint; home/end jump to start/end of the
+/// current visual buffer (P1: whole buffer, not per-line)
+/// - ENTER submits the whole buffer (see "Submit mechanism")
+/// - SHIFT+ENTER inserts a '\n'
+///
+/// Cursor (plan §3.5): the box draws its OWN cursor as a reverse-video block
+/// (theme `.cursor` style) over the glyph at the cursor position. When focused
+/// it also emits `CURSOR_MARKER` (zero visible width) at the cursor location in
+/// its render output, so the engine can later position the hardware cursor.
+///
+/// SHIFT+ENTER limitation: on terminals without the Kitty protocol, Enter and
+/// Shift+Enter send identical bytes (`\r`); the decoder cannot distinguish them
+/// and both arrive as `.enter` with no shift modifier, so only plain submit is
+/// possible there. When Kitty IS active, Shift+Enter arrives as CSI-u
+/// (`\x1b[13;2u`) with `mods.shift` set and this box inserts a newline. The
+/// logic works wherever the distinction is available.
+///
+/// Submit mechanism: a POLLABLE buffer. On ENTER the current editor contents
+/// are moved into `submitted` and the editor is cleared. The app calls
+/// `takeSubmitted()` once per frame; it returns the submitted bytes (owned by
+/// the box, valid until the next `takeSubmitted`/edit) and clears the pending
+/// flag, or null if nothing was submitted. This avoids callback re-entrancy
+/// into the render loop.
+pub const InputBox = struct {
+ alloc: std.mem.Allocator,
+ focusable: Focusable = .{},
+ /// Editor contents (may contain '\n' for multi-line input). UTF-8.
+ text: std.ArrayList(u8) = .empty,
+ /// Cursor position as a BYTE offset into `text` (always on a codepoint
+ /// boundary).
+ cursor: usize = 0,
+ /// Pending submitted line, owned by the box. Valid until the next submit
+ /// or `takeSubmitted`.
+ submitted: std.ArrayList(u8) = .empty,
+ has_submitted: bool = false,
+ cache: RenderCache,
+
+ pub fn init(alloc: std.mem.Allocator) InputBox {
+ return .{ .alloc = alloc, .cache = RenderCache.init(alloc) };
+ }
+
+ pub fn deinit(self: *InputBox) void {
+ self.text.deinit(self.alloc);
+ self.submitted.deinit(self.alloc);
+ self.cache.deinit();
+ }
+
+ // -- focus -------------------------------------------------------------
+
+ /// Set focus. Re-dirties because the cursor block + marker only render when
+ /// focused, so focus changes alter the output.
+ pub fn setFocused(self: *InputBox, value: bool) void {
+ if (self.focusable.focused != value) {
+ self.focusable.setFocused(value);
+ self.cache.markDirty();
+ }
+ }
+
+ pub fn isFocused(self: *const InputBox) bool {
+ return self.focusable.focused;
+ }
+
+ // -- submit polling ----------------------------------------------------
+
+ /// Poll the submitted line. Returns the bytes (box-owned) and clears the
+ /// pending flag, or null if nothing was submitted since the last poll.
+ pub fn takeSubmitted(self: *InputBox) ?[]const u8 {
+ if (!self.has_submitted) return null;
+ self.has_submitted = false;
+ return self.submitted.items;
+ }
+
+ // -- editing primitives (also directly unit-testable) ------------------
+
+ fn insertText(self: *InputBox, bytes: []const u8) !void {
+ try self.text.insertSlice(self.alloc, self.cursor, bytes);
+ self.cursor += bytes.len;
+ self.cache.markDirty();
+ }
+
+ fn backspace(self: *InputBox) void {
+ if (self.cursor == 0) return;
+ const start = self.prevBoundary(self.cursor);
+ const removed = self.cursor - start;
+ std.mem.copyForwards(u8, self.text.items[start..], self.text.items[self.cursor..]);
+ self.text.items.len -= removed;
+ self.cursor = start;
+ self.cache.markDirty();
+ }
+
+ fn deleteForward(self: *InputBox) void {
+ if (self.cursor >= self.text.items.len) return;
+ const next = self.nextBoundary(self.cursor);
+ const removed = next - self.cursor;
+ std.mem.copyForwards(u8, self.text.items[self.cursor..], self.text.items[next..]);
+ self.text.items.len -= removed;
+ self.cache.markDirty();
+ }
+
+ fn moveLeft(self: *InputBox) void {
+ if (self.cursor == 0) return;
+ self.cursor = self.prevBoundary(self.cursor);
+ self.cache.markDirty();
+ }
+
+ fn moveRight(self: *InputBox) void {
+ if (self.cursor >= self.text.items.len) return;
+ self.cursor = self.nextBoundary(self.cursor);
+ self.cache.markDirty();
+ }
+
+ fn moveHome(self: *InputBox) void {
+ if (self.cursor == 0) return;
+ self.cursor = 0;
+ self.cache.markDirty();
+ }
+
+ fn moveEnd(self: *InputBox) void {
+ if (self.cursor == self.text.items.len) return;
+ self.cursor = self.text.items.len;
+ self.cache.markDirty();
+ }
+
+ fn submit(self: *InputBox) !void {
+ self.submitted.clearRetainingCapacity();
+ try self.submitted.appendSlice(self.alloc, self.text.items);
+ self.has_submitted = true;
+ self.text.clearRetainingCapacity();
+ self.cursor = 0;
+ self.cache.markDirty();
+ }
+
+ /// Byte index of the codepoint boundary before `i` (i > 0).
+ fn prevBoundary(self: *const InputBox, i: usize) usize {
+ var j = i - 1;
+ while (j > 0 and isContinuation(self.text.items[j])) : (j -= 1) {}
+ return j;
+ }
+
+ /// Byte index of the next codepoint boundary after `i` (i < len).
+ fn nextBoundary(self: *const InputBox, i: usize) usize {
+ const seq_len = std.unicode.utf8ByteSequenceLength(self.text.items[i]) catch 1;
+ return @min(i + seq_len, self.text.items.len);
+ }
+
+ fn isContinuation(b: u8) bool {
+ return (b & 0xc0) == 0x80;
+ }
+
+ // -- input handling ----------------------------------------------------
+
+ /// Apply one decoded key. Split out so tests can drive editing without raw
+ /// byte sequences.
+ pub fn applyKey(self: *InputBox, k: Key) !void {
+ if (k.event == .release) return;
+ switch (k.code) {
+ .char => {
+ if (k.mods.ctrl or k.mods.alt or k.mods.super) return; // not a printable insert
+ if (k.text) |t| {
+ try self.insertText(t);
+ } else {
+ // Encode the codepoint ourselves when no text was carried.
+ var buf: [4]u8 = undefined;
+ const n = std.unicode.utf8Encode(k.code.char, &buf) catch return;
+ try self.insertText(buf[0..n]);
+ }
+ },
+ .enter => {
+ if (k.mods.shift) {
+ try self.insertText("\n"); // shift+enter newline
+ } else {
+ try self.submit();
+ }
+ },
+ .backspace => self.backspace(),
+ .delete => self.deleteForward(),
+ .left => self.moveLeft(),
+ .right => self.moveRight(),
+ .home => self.moveHome(),
+ .end => self.moveEnd(),
+ else => {}, // tab, arrows up/down, fkeys: ignored in P1
+ }
+ }
+
+ fn handleInputImpl(ptr: *anyopaque, data: []const u8) void {
+ const self: *InputBox = @ptrCast(@alignCast(ptr));
+ var off: usize = 0;
+ while (off < data.len) {
+ const step = input.decodeOne(data[off..]) orelse break; // partial tail: drop in P1
+ off += step.consumed;
+ switch (step.decoded) {
+ .key => |k| self.applyKey(k) catch return,
+ .paste => |p| self.insertText(p) catch return,
+ }
+ }
+ }
+
+ // -- render ------------------------------------------------------------
+
+ fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 {
+ _ = alloc;
+ const self: *InputBox = @ptrCast(@alignCast(ptr));
+ return self.renderLines(width);
+ }
+
+ /// Render the editor: split on '\n' into visual rows, place the styled
+ /// cursor block + CURSOR_MARKER at the cursor row/column when focused.
+ /// Truncates each row to `width` columns.
+ fn renderLines(self: *InputBox, width: usize) ![]const []const u8 {
+ const a = self.alloc;
+ var rows: std.ArrayList([]const u8) = .empty;
+ defer {
+ for (rows.items) |r| a.free(r);
+ rows.deinit(a);
+ }
+
+ const cursor_style = theme.default.fg(.cursor);
+ // Locate the cursor's (row, byte-col-in-row).
+ const focused = self.focusable.focused;
+
+ // Walk lines, tracking byte offset so we know which row holds cursor.
+ var line_byte_start: usize = 0;
+ var produced_any = false;
+ var it = std.mem.splitScalar(u8, self.text.items, '\n');
+ while (it.next()) |line| {
+ const line_start = line_byte_start;
+ const line_end = line_start + line.len;
+ const cursor_in_line = focused and self.cursor >= line_start and self.cursor <= line_end and
+ // The cursor belongs to the FIRST line whose range contains it
+ // (at a '\n' boundary it stays on the line before the break,
+ // i.e. == line_end). Disambiguate the boundary: if cursor ==
+ // line_end and there are more lines, it belongs to the NEXT
+ // line's start unless this is the last line.
+ (self.cursor < line_end or it.peek() == null);
+
+ const row = try self.renderRow(line, if (cursor_in_line) self.cursor - line_start else null, cursor_style, width, focused);
+ try rows.append(a, row);
+ produced_any = true;
+ line_byte_start = line_end + 1; // skip the '\n'
+ }
+ if (!produced_any) {
+ // Empty buffer: a single (possibly cursor-bearing) row.
+ const row = try self.renderRow("", if (focused) @as(?usize, 0) else null, cursor_style, width, focused);
+ try rows.append(a, row);
+ }
+
+ try self.cache.store(rows.items);
+ return cacheLines(&self.cache);
+ }
+
+ /// Render one visual row. `cursor_col` is the byte offset within `line`
+ /// where the cursor sits (null if the cursor isn't on this row). When
+ /// present and focused, draws a reverse-video block over the glyph at the
+ /// cursor (or a space at end-of-line) and emits CURSOR_MARKER there.
+ fn renderRow(self: *InputBox, line: []const u8, cursor_col: ?usize, cursor_style: Style, width: usize, focused: bool) ![]u8 {
+ const a = self.alloc;
+ // The cursor block consumes one visible column, so usable text width
+ // is width-1 when the cursor sits at/after the truncated end and we
+ // must show the block. To keep it simple and always-safe: truncate the
+ // plain line to `width` columns; if a cursor block would push us to
+ // width+1, the block replaces the last column instead.
+ const vis = truncateToCols(line, width);
+
+ var buf: std.ArrayList(u8) = .empty;
+ errdefer buf.deinit(a);
+
+ if (cursor_col == null or !focused) {
+ try buf.appendSlice(a, vis);
+ return buf.toOwnedSlice(a);
+ }
+
+ // Cursor is on this row. Find the byte position within `vis`.
+ const cc = cursor_col.?;
+ const before_cols = displayWidth(line[0..@min(cc, line.len)]);
+
+ if (cc >= line.len) {
+ // Cursor at end-of-line: block over a trailing space. Ensure room:
+ // if the visible text already fills `width`, drop its last column.
+ var head = vis;
+ if (before_cols >= width) {
+ head = truncateToCols(line, width - 1);
+ }
+ try buf.appendSlice(a, head);
+ try buf.appendSlice(a, CURSOR_MARKER);
+ try buf.appendSlice(a, cursor_style.open());
+ try buf.appendSlice(a, " ");
+ try buf.appendSlice(a, cursor_style.close());
+ return buf.toOwnedSlice(a);
+ }
+
+ // Cursor over an interior glyph. Split: head | glyph | tail.
+ const glyph_len = blk: {
+ const sl = std.unicode.utf8ByteSequenceLength(line[cc]) catch 1;
+ break :blk @min(sl, line.len - cc);
+ };
+ const head = line[0..cc];
+ const glyph = line[cc .. cc + glyph_len];
+ const tail = line[cc + glyph_len ..];
+
+ // Compose head + marker + [reverse]glyph[/] + tail, then truncate the
+ // whole visible width to `width` columns (escapes + marker are
+ // zero-width, so truncation acts on glyphs).
+ try buf.appendSlice(a, head);
+ try buf.appendSlice(a, CURSOR_MARKER);
+ try buf.appendSlice(a, cursor_style.open());
+ try buf.appendSlice(a, glyph);
+ try buf.appendSlice(a, cursor_style.close());
+ try buf.appendSlice(a, tail);
+
+ // The composed row's visible width == displayWidth(line). If that
+ // exceeds `width`, rebuild with a width-bounded tail. (Rare: cursor
+ // near a long line's start.) Simpler safe path: if over, truncate the
+ // tail.
+ if (displayWidth(line) > width) {
+ buf.clearRetainingCapacity();
+ // Keep head+glyph; truncate tail to remaining columns.
+ const used = before_cols + 1; // head cols + the glyph
+ try buf.appendSlice(a, head);
+ try buf.appendSlice(a, CURSOR_MARKER);
+ try buf.appendSlice(a, cursor_style.open());
+ try buf.appendSlice(a, glyph);
+ try buf.appendSlice(a, cursor_style.close());
+ if (used < width) {
+ const remaining = width - used;
+ try buf.appendSlice(a, truncateToCols(tail, remaining));
+ }
+ }
+ return buf.toOwnedSlice(a);
+ }
+
+ fn firstLineChangedImpl(ptr: *anyopaque) ?usize {
+ const self: *InputBox = @ptrCast(@alignCast(ptr));
+ return self.cache.firstLineChanged();
+ }
+
+ fn invalidateImpl(ptr: *anyopaque) void {
+ const self: *InputBox = @ptrCast(@alignCast(ptr));
+ self.cache.invalidate();
+ }
+
+ const vtable = Component.VTable{
+ .render = renderImpl,
+ .firstLineChanged = firstLineChangedImpl,
+ .invalidate = invalidateImpl,
+ .handleInput = handleInputImpl,
+ };
+
+ pub fn comp(self: *InputBox) Component {
+ return .{ .ptr = self, .vtable = &vtable };
+ }
+};
+
+// ===========================================================================
+// Footer — persistent bottom line with frame-timing element (plan §6)
+// ===========================================================================
+
+/// The persistent bottom line. For P1 it renders a FRAME-TIMING element: the
+/// last frame's render time as a theoretical-max fps (1000/ms), shown inverted
+/// (reverse-video). It optionally shows model info passed in by the app. The
+/// fps element is TEMPORARY (removed after perf validation) but REQUIRED for
+/// P1.
+///
+/// Frame-time input: the app calls `setFrameTime(ms)` after each rendered frame
+/// with the measured render duration in milliseconds; this updates the fps and
+/// marks dirty so the footer repaints. `setModel(name)` sets the model info.
+pub const Footer = struct {
+ alloc: std.mem.Allocator,
+ cache: RenderCache,
+ /// Last frame's render time in milliseconds (null = not measured yet).
+ frame_ms: ?f64 = null,
+ /// Model info string (borrowed; copied into a small owned buffer on set).
+ model: std.ArrayList(u8) = .empty,
+
+ pub fn init(alloc: std.mem.Allocator) Footer {
+ return .{ .alloc = alloc, .cache = RenderCache.init(alloc) };
+ }
+
+ pub fn deinit(self: *Footer) void {
+ self.model.deinit(self.alloc);
+ self.cache.deinit();
+ }
+
+ /// Feed the last frame's render time (milliseconds). Marks dirty so the
+ /// footer's fps element repaints next frame.
+ pub fn setFrameTime(self: *Footer, ms: f64) void {
+ self.frame_ms = ms;
+ self.cache.markDirty();
+ }
+
+ /// Set the model info shown in the footer.
+ pub fn setModel(self: *Footer, name: []const u8) !void {
+ self.model.clearRetainingCapacity();
+ try self.model.appendSlice(self.alloc, name);
+ self.cache.markDirty();
+ }
+
+ /// Format the theoretical-max fps element from the last frame time.
+ /// `fps = 1000 / ms`; a zero/sub-millisecond frame is reported as a capped
+ /// ">9999" sentinel rather than infinity. "--" when unmeasured.
+ fn fpsText(self: *const Footer, buf: []u8) []const u8 {
+ const ms = self.frame_ms orelse return std.fmt.bufPrint(buf, "fps: --", .{}) catch "fps: --";
+ if (ms <= 0.0) return std.fmt.bufPrint(buf, "fps: >9999", .{}) catch "fps: >9999";
+ const fps = 1000.0 / ms;
+ if (fps > 9999.0) return std.fmt.bufPrint(buf, "fps: >9999", .{}) catch "fps: >9999";
+ return std.fmt.bufPrint(buf, "fps: {d:.0} ({d:.2}ms)", .{ fps, ms }) catch "fps: ?";
+ }
+
+ fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 {
+ _ = alloc;
+ const self: *Footer = @ptrCast(@alignCast(ptr));
+ const a = self.alloc;
+
+ var fps_buf: [48]u8 = undefined;
+ const fps = self.fpsText(&fps_buf);
+
+ // Build the PLAIN content: "<fps> <model>" (model only if set).
+ var plain: std.ArrayList(u8) = .empty;
+ defer plain.deinit(a);
+ try plain.appendSlice(a, fps);
+ if (self.model.items.len != 0) {
+ try plain.appendSlice(a, " ");
+ try plain.appendSlice(a, self.model.items);
+ }
+
+ const vis = truncateToCols(plain.items, width);
+
+ // The fps element is shown INVERTED (reverse video). The whole footer
+ // line uses reverse video so the timing element stands out; the model
+ // rides along in the same inverted run. (Temporary perf chrome.)
+ const cursor_style = theme.default.fg(.cursor); // reverse video
+ const composed = try std.fmt.allocPrint(a, "{s}{s}{s}", .{ cursor_style.open(), vis, cursor_style.close() });
+ defer a.free(composed);
+
+ const lines = [_][]const u8{composed};
+ try self.cache.store(&lines);
+ return cacheLines(&self.cache);
+ }
+
+ fn firstLineChangedImpl(ptr: *anyopaque) ?usize {
+ const self: *Footer = @ptrCast(@alignCast(ptr));
+ return self.cache.firstLineChanged();
+ }
+
+ fn invalidateImpl(ptr: *anyopaque) void {
+ const self: *Footer = @ptrCast(@alignCast(ptr));
+ self.cache.invalidate();
+ }
+
+ const vtable = Component.VTable{
+ .render = renderImpl,
+ .firstLineChanged = firstLineChangedImpl,
+ .invalidate = invalidateImpl,
+ };
+
+ pub fn comp(self: *Footer) Component {
+ return .{ .ptr = self, .vtable = &vtable };
+ }
+};
+
+// ===========================================================================
+// Tests
+// ===========================================================================
+
+const testing = std.testing;
+const engine = @import("tui_engine.zig");
+
+/// Visible width of a rendered (possibly styled, possibly marker-bearing) line,
+/// reusing the engine's authoritative measure.
+fn vw(line: []const u8) usize {
+ return engine.visibleWidth(line);
+}
+
+// -- helpers ---------------------------------------------------------------
+
+test "displayWidth counts codepoints; truncateToCols respects boundaries" {
+ try testing.expectEqual(@as(usize, 3), displayWidth("abc"));
+ try testing.expectEqual(@as(usize, 3), displayWidth("aé✓"));
+ try testing.expectEqualStrings("aé", truncateToCols("aé✓", 2));
+ try testing.expectEqualStrings("abc", truncateToCols("abcdef", 3));
+ // Never splits a multibyte codepoint.
+ const t = truncateToCols("é", 1);
+ try testing.expectEqualStrings("é", t);
+}
+
+test "wrapParagraph word-wraps and hard-splits long words" {
+ var out: std.ArrayList([]const u8) = .empty;
+ defer out.deinit(testing.allocator);
+ try wrapParagraph("hello world foo", 7, &out, testing.allocator);
+ try testing.expectEqual(@as(usize, 3), out.items.len);
+ try testing.expectEqualStrings("hello", out.items[0]);
+ try testing.expectEqualStrings("world", out.items[1]);
+ try testing.expectEqualStrings("foo", out.items[2]);
+
+ out.clearRetainingCapacity();
+ try wrapParagraph("abcdefghij", 4, &out, testing.allocator);
+ try testing.expectEqual(@as(usize, 3), out.items.len);
+ try testing.expectEqualStrings("abcd", out.items[0]);
+ try testing.expectEqualStrings("efgh", out.items[1]);
+ try testing.expectEqualStrings("ij", out.items[2]);
+}
+
+// -- AssistantText ---------------------------------------------------------
+
+test "AssistantText: renders wrapped text within width" {
+ var at = AssistantText.init(testing.allocator);
+ defer at.deinit();
+ try at.setText("hello world foo");
+ const lines = try at.comp().render(7, testing.allocator);
+ try testing.expectEqual(@as(usize, 3), lines.len);
+ for (lines) |l| try testing.expect(vw(l) <= 7);
+ // First render after empty cache => changed from 0.
+ try testing.expectEqual(@as(?usize, 0), at.comp().firstLineChanged());
+}
+
+test "AssistantText: streaming keeps firstLineChanged near the tail, not 0" {
+ var at = AssistantText.init(testing.allocator);
+ defer at.deinit();
+ // Seed several wrapped lines.
+ try at.setText("alpha beta gamma delta epsilon");
+ _ = try at.comp().render(11, testing.allocator);
+ const lines1 = at.cache.lines.?.len;
+ try testing.expect(lines1 >= 3);
+
+ // Append a delta to the tail; earlier wrapped lines stay byte-identical.
+ try at.appendDelta(" zeta");
+ // While dirty, firstLineChanged reports 0 (full markDirty). The KEY
+ // property is what the cache reports AFTER the render: the lowest line that
+ // actually changed must be near the tail, not 0.
+ _ = try at.comp().render(11, testing.allocator);
+ const fc = at.comp().firstLineChanged();
+ // The change landed on the last line(s); the cut must be > 0.
+ try testing.expect(fc == null or fc.? > 0);
+ // Stronger: the first changed line should be at the tail region.
+ if (fc) |v| try testing.expect(v >= lines1 - 1);
+}
+
+test "AssistantText: width truncation on a long unbroken word" {
+ var at = AssistantText.init(testing.allocator);
+ defer at.deinit();
+ try at.setText("supercalifragilistic");
+ const lines = try at.comp().render(5, testing.allocator);
+ for (lines) |l| try testing.expect(vw(l) <= 5);
+}
+
+// -- UserText ---------------------------------------------------------------
+
+test "UserText: renders user-styled lines within width" {
+ var ut = UserText.init(testing.allocator);
+ defer ut.deinit();
+ try ut.setText("a user message that wraps");
+ const lines = try ut.comp().render(10, testing.allocator);
+ for (lines) |l| try testing.expect(vw(l) <= 10);
+ // Static: re-render with no change => clean.
+ _ = try ut.comp().render(10, testing.allocator);
+ try testing.expectEqual(@as(?usize, null), ut.comp().firstLineChanged());
+}
+
+// -- InputBox ---------------------------------------------------------------
+
+fn charKey(c: u21, text: []const u8) Key {
+ return .{ .code = .{ .char = c }, .text = text };
+}
+
+test "InputBox: insert printable chars and render with cursor block when focused" {
+ var ib = InputBox.init(testing.allocator);
+ defer ib.deinit();
+ ib.setFocused(true);
+ try ib.applyKey(charKey('h', "h"));
+ try ib.applyKey(charKey('i', "i"));
+ const lines = try ib.comp().render(20, testing.allocator);
+ try testing.expectEqual(@as(usize, 1), lines.len);
+ try testing.expect(vw(lines[0]) <= 20);
+ // Focused => emits CURSOR_MARKER and reverse-video style.
+ try testing.expect(std.mem.indexOf(u8, lines[0], CURSOR_MARKER) != null);
+ try testing.expect(std.mem.indexOf(u8, lines[0], "\x1b[7m") != null);
+ try testing.expect(std.mem.indexOf(u8, lines[0], "hi") != null);
+}
+
+test "InputBox: not focused emits no cursor marker" {
+ var ib = InputBox.init(testing.allocator);
+ defer ib.deinit();
+ try ib.applyKey(charKey('x', "x"));
+ const lines = try ib.comp().render(20, testing.allocator);
+ try testing.expect(std.mem.indexOf(u8, lines[0], CURSOR_MARKER) == null);
+}
+
+test "InputBox: backspace, delete, and cursor movement" {
+ var ib = InputBox.init(testing.allocator);
+ defer ib.deinit();
+ for ("abc") |c| try ib.applyKey(charKey(c, &[_]u8{c}));
+ try testing.expectEqual(@as(usize, 3), ib.cursor);
+ ib.backspace(); // "ab"
+ try testing.expectEqualStrings("ab", ib.text.items);
+ try testing.expectEqual(@as(usize, 2), ib.cursor);
+ ib.moveLeft(); // cursor at 1
+ try testing.expectEqual(@as(usize, 1), ib.cursor);
+ ib.deleteForward(); // delete 'b' -> "a"
+ try testing.expectEqualStrings("a", ib.text.items);
+ ib.moveHome();
+ try testing.expectEqual(@as(usize, 0), ib.cursor);
+ ib.moveEnd();
+ try testing.expectEqual(@as(usize, 1), ib.cursor);
+}
+
+test "InputBox: multibyte backspace removes a whole codepoint" {
+ var ib = InputBox.init(testing.allocator);
+ defer ib.deinit();
+ try ib.applyKey(charKey('é', "é")); // 2 bytes
+ try testing.expectEqual(@as(usize, 2), ib.cursor);
+ ib.backspace();
+ try testing.expectEqual(@as(usize, 0), ib.text.items.len);
+}
+
+test "InputBox: shift+enter inserts newline, enter submits" {
+ var ib = InputBox.init(testing.allocator);
+ defer ib.deinit();
+ for ("ab") |c| try ib.applyKey(charKey(c, &[_]u8{c}));
+ // Shift+Enter => newline (grows a row).
+ try ib.applyKey(.{ .code = .enter, .mods = .{ .shift = true } });
+ for ("cd") |c| try ib.applyKey(charKey(c, &[_]u8{c}));
+ try testing.expectEqualStrings("ab\ncd", ib.text.items);
+ const lines = try ib.comp().render(20, testing.allocator);
+ try testing.expectEqual(@as(usize, 2), lines.len);
+
+ // Plain Enter => submit, editor cleared, pollable buffer set.
+ try ib.applyKey(.{ .code = .enter });
+ const got = ib.takeSubmitted();
+ try testing.expect(got != null);
+ try testing.expectEqualStrings("ab\ncd", got.?);
+ try testing.expectEqual(@as(usize, 0), ib.text.items.len);
+ // Second poll returns null.
+ try testing.expect(ib.takeSubmitted() == null);
+}
+
+test "InputBox: handleInput decodes raw bytes (typing + enter)" {
+ var ib = InputBox.init(testing.allocator);
+ defer ib.deinit();
+ ib.comp().handleInput("hi\r"); // 'h' 'i' Enter
+ const got = ib.takeSubmitted();
+ try testing.expect(got != null);
+ try testing.expectEqualStrings("hi", got.?);
+}
+
+test "InputBox: handleInput kitty shift+enter inserts newline" {
+ var ib = InputBox.init(testing.allocator);
+ defer ib.deinit();
+ ib.comp().handleInput("a\x1b[13;2ub"); // 'a', shift+enter, 'b'
+ try testing.expectEqualStrings("a\nb", ib.text.items);
+ try testing.expect(ib.takeSubmitted() == null); // no plain enter yet
+}
+
+test "InputBox: firstLineChanged is cache-derived (clean after stable render)" {
+ var ib = InputBox.init(testing.allocator);
+ defer ib.deinit();
+ ib.setFocused(true);
+ try ib.applyKey(charKey('x', "x"));
+ _ = try ib.comp().render(20, testing.allocator);
+ // Re-render with no state change => clean.
+ _ = try ib.comp().render(20, testing.allocator);
+ try testing.expectEqual(@as(?usize, null), ib.comp().firstLineChanged());
+ // Edit => dirty again.
+ try ib.applyKey(charKey('y', "y"));
+ try testing.expectEqual(@as(?usize, 0), ib.comp().firstLineChanged());
+}
+
+test "InputBox: cursor block fits within width at end of a full line" {
+ var ib = InputBox.init(testing.allocator);
+ defer ib.deinit();
+ ib.setFocused(true);
+ for ("abcde") |c| try ib.applyKey(charKey(c, &[_]u8{c}));
+ // width 5, cursor at end: the block must not overflow.
+ const lines = try ib.comp().render(5, testing.allocator);
+ try testing.expect(vw(lines[0]) <= 5);
+}
+
+// -- Footer -----------------------------------------------------------------
+
+test "Footer: renders fps from frame time, inverted, within width" {
+ var ft = Footer.init(testing.allocator);
+ defer ft.deinit();
+ ft.setFrameTime(8.0); // 1000/8 = 125 fps
+ const lines = try ft.comp().render(80, testing.allocator);
+ try testing.expectEqual(@as(usize, 1), lines.len);
+ try testing.expect(vw(lines[0]) <= 80);
+ // Inverted (reverse video) styling present.
+ try testing.expect(std.mem.indexOf(u8, lines[0], "\x1b[7m") != null);
+ // fps value 125 present.
+ try testing.expect(std.mem.indexOf(u8, lines[0], "125") != null);
+}
+
+test "Footer: unmeasured frame shows placeholder; submillisecond capped" {
+ var ft = Footer.init(testing.allocator);
+ defer ft.deinit();
+ var buf: [48]u8 = undefined;
+ try testing.expectEqualStrings("fps: --", ft.fpsText(&buf));
+ ft.setFrameTime(0.0);
+ try testing.expectEqualStrings("fps: >9999", ft.fpsText(&buf));
+ ft.setFrameTime(0.05); // 20000 fps -> capped
+ try testing.expectEqualStrings("fps: >9999", ft.fpsText(&buf));
+}
+
+test "Footer: shows model info and truncates to width" {
+ var ft = Footer.init(testing.allocator);
+ defer ft.deinit();
+ ft.setFrameTime(10.0);
+ try ft.setModel("gpt-test-model");
+ const lines = try ft.comp().render(12, testing.allocator);
+ try testing.expect(vw(lines[0]) <= 12);
+}
+
+test "Footer: setFrameTime dirties; stable re-render is clean" {
+ var ft = Footer.init(testing.allocator);
+ defer ft.deinit();
+ ft.setFrameTime(8.0);
+ _ = try ft.comp().render(80, testing.allocator);
+ _ = try ft.comp().render(80, testing.allocator);
+ try testing.expectEqual(@as(?usize, null), ft.comp().firstLineChanged());
+ ft.setFrameTime(16.0);
+ try testing.expectEqual(@as(?usize, 0), ft.comp().firstLineChanged());
+}
+
+// -- Integration with the real Engine (no TTY) ------------------------------
+
+test "components drive the real engine without a TTY" {
+ var buf = std.Io.Writer.Allocating.init(testing.allocator);
+ defer buf.deinit();
+ var eng = engine.Engine.init(testing.allocator, &buf.writer, 40, 24, false);
+ defer eng.deinit();
+
+ var user = UserText.init(testing.allocator);
+ defer user.deinit();
+ var assistant = AssistantText.init(testing.allocator);
+ defer assistant.deinit();
+ var ib = InputBox.init(testing.allocator);
+ defer ib.deinit();
+ var footer = Footer.init(testing.allocator);
+ defer footer.deinit();
+
+ try user.setText("hi there");
+ try assistant.appendDelta("hello");
+ ib.setFocused(true);
+ try ib.applyKey(charKey('q', "q"));
+ footer.setFrameTime(8.0);
+
+ try eng.addComponent(user.comp());
+ try eng.addComponent(assistant.comp());
+ try eng.addComponent(ib.comp());
+ try eng.addComponent(footer.comp());
+
+ try eng.render(); // first paint: must not error (width contract holds)
+ const out = buf.written();
+ try testing.expect(std.mem.indexOf(u8, out, "hi there") != null);
+ try testing.expect(std.mem.indexOf(u8, out, "hello") != null);
+ // Cursor marker is consumed by the engine and recorded as a hint.
+ try testing.expect(eng.cursor_hint != null);
+
+ // Stream another delta -> only the assistant should re-render; the engine
+ // stays on the differential path (no full clear after first paint).
+ try assistant.appendDelta(" world");
+ footer.setFrameTime(9.0);
+ buf.clearRetainingCapacity();
+ try eng.render();
+ const out2 = buf.written();
+ try testing.expect(std.mem.indexOf(u8, out2, "world") != null);
+}