diff options
Diffstat (limited to 'src/tui_components.zig')
| -rw-r--r-- | src/tui_components.zig | 1101 |
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); +} |
