diff --git a/build.zig b/build.zig index f3bfd03..6543acb 100644 --- a/build.zig +++ b/build.zig @@ -118,4 +118,23 @@ pub fn build(b: *std.Build) void { run_dashboard.step.dependOn(b.getInstallStep()); const dashboard_step = b.step("dashboard", "Run dashboard demo"); dashboard_step.dependOn(&run_dashboard.step); + + // Ejemplo: input_demo + const input_demo_exe = b.addExecutable(.{ + .name = "input-demo", + .root_module = b.createModule(.{ + .root_source_file = b.path("examples/input_demo.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "zcatui", .module = zcatui_mod }, + }, + }), + }); + b.installArtifact(input_demo_exe); + + const run_input_demo = b.addRunArtifact(input_demo_exe); + run_input_demo.step.dependOn(b.getInstallStep()); + const input_demo_step = b.step("input-demo", "Run input demo"); + input_demo_step.dependOn(&run_input_demo.step); } diff --git a/examples/input_demo.zig b/examples/input_demo.zig new file mode 100644 index 0000000..d2ce556 --- /dev/null +++ b/examples/input_demo.zig @@ -0,0 +1,231 @@ +//! Interactive input demo for zcatui. +//! +//! Demonstrates the readline-style Input widget with: +//! - Basic text editing (insert, delete, backspace) +//! - Cursor movement (home, end, left, right) +//! - Word navigation (Ctrl+Left/Right) +//! - Kill/yank (Ctrl+K, Ctrl+Y) +//! - Clear line (Ctrl+U) +//! - History navigation (Up/Down arrows) +//! +//! Run with: zig build input-demo + +const std = @import("std"); +const zcatui = @import("zcatui"); + +const Terminal = zcatui.Terminal; +const Buffer = zcatui.Buffer; +const Rect = zcatui.Rect; +const Style = zcatui.Style; +const Color = zcatui.Color; +const Event = zcatui.Event; +const KeyCode = zcatui.KeyCode; +const Layout = zcatui.Layout; +const Constraint = zcatui.Constraint; +const Block = zcatui.widgets.Block; +const Borders = zcatui.widgets.Borders; +const Paragraph = zcatui.widgets.Paragraph; +const Input = zcatui.widgets.Input; +const InputState = zcatui.widgets.InputState; + +const AppState = struct { + input_state: InputState, + password_state: InputState, + focus: Focus = .normal_input, + messages: std.ArrayListUnmanaged([]u8) = .{}, + running: bool = true, + allocator: std.mem.Allocator, + + const Focus = enum { normal_input, password_input }; + + fn init(allocator: std.mem.Allocator) AppState { + return .{ + .input_state = InputState.init(allocator), + .password_state = InputState.init(allocator), + .allocator = allocator, + }; + } + + fn deinit(self: *AppState) void { + self.input_state.deinit(); + self.password_state.deinit(); + for (self.messages.items) |msg| { + self.allocator.free(msg); + } + self.messages.deinit(self.allocator); + } + + fn addMessage(self: *AppState, msg: []const u8) !void { + const copy = try self.allocator.dupe(u8, msg); + try self.messages.append(self.allocator, copy); + // Keep only last 10 messages + while (self.messages.items.len > 10) { + self.allocator.free(self.messages.orderedRemove(0)); + } + } + + fn toggleFocus(self: *AppState) void { + self.focus = if (self.focus == .normal_input) .password_input else .normal_input; + } +}; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + var term = try Terminal.init(allocator); + defer term.deinit(); + + var state = AppState.init(allocator); + defer state.deinit(); + + // Add initial help message + try state.addMessage("Type text and press Enter to submit."); + try state.addMessage("Use Tab to switch between inputs."); + try state.addMessage("Press Ctrl+C or Esc to quit."); + + while (state.running) { + try term.drawWithContext(&state, render); + + if (try term.pollEvent(100)) |event| { + try handleEvent(&state, event); + } + } +} + +fn handleEvent(state: *AppState, event: Event) !void { + switch (event) { + .key => |key| { + switch (key.code) { + .esc => state.running = false, + .char => |c| { + if (key.modifiers.ctrl and (c == 'c' or c == 'C')) { + state.running = false; + return; + } + // Let input handle other chars + const input_state = switch (state.focus) { + .normal_input => &state.input_state, + .password_input => &state.password_state, + }; + _ = try input_state.handleKey(key); + }, + .tab => state.toggleFocus(), + .enter => { + const input_state = switch (state.focus) { + .normal_input => &state.input_state, + .password_input => &state.password_state, + }; + const submitted = try input_state.submit(); + if (submitted.len > 0) { + var msg_buf: [128]u8 = undefined; + const prefix = if (state.focus == .normal_input) "Input" else "Password"; + const msg = std.fmt.bufPrint(&msg_buf, "{s}: {s}", .{ prefix, submitted }) catch "???"; + try state.addMessage(msg); + state.allocator.free(submitted); + } + }, + else => { + const input_state = switch (state.focus) { + .normal_input => &state.input_state, + .password_input => &state.password_state, + }; + _ = try input_state.handleKey(key); + }, + } + }, + else => {}, + } +} + +fn render(state: *AppState, area: Rect, buf: *Buffer) void { + // Layout: help at top, inputs in middle, messages at bottom + const chunks = Layout.vertical(&.{ + Constraint.length(6), // Help + Constraint.length(3), // Normal input + Constraint.length(3), // Password input + Constraint.min(0), // Messages + }).split(area); + + // Help section + renderHelp(chunks.get(0), buf); + + // Normal input + const normal_style = if (state.focus == .normal_input) + Style.default.fg(Color.cyan) + else + Style.default.fg(Color.white); + + const normal_input = Input.init() + .setBlock(Block.init() + .title(" Input (Tab to switch) ") + .setBorders(Borders.all) + .style(normal_style)) + .setPlaceholder("Type here...") + .showCursor(state.focus == .normal_input); + normal_input.render(chunks.get(1), buf, &state.input_state); + + // Password input + const password_style = if (state.focus == .password_input) + Style.default.fg(Color.cyan) + else + Style.default.fg(Color.white); + + const password_input = Input.init() + .setBlock(Block.init() + .title(" Password ") + .setBorders(Borders.all) + .style(password_style)) + .setPlaceholder("Enter password...") + .setMask('*') + .showCursor(state.focus == .password_input); + password_input.render(chunks.get(2), buf, &state.password_state); + + // Messages + renderMessages(state, chunks.get(3), buf); +} + +fn renderHelp(area: Rect, buf: *Buffer) void { + const help_block = Block.init() + .title(" Keyboard Shortcuts ") + .setBorders(Borders.all) + .style(Style.default.fg(Color.yellow)); + help_block.render(area, buf); + + const inner = help_block.inner(area); + var y = inner.top(); + + const lines = [_][]const u8{ + "Ctrl+A/E: Home/End | Ctrl+B/F: Left/Right | Ctrl+W: Delete word", + "Ctrl+K: Kill to end | Ctrl+U: Kill to start | Ctrl+Y: Yank", + "Up/Down: History | Tab: Switch input | Enter: Submit | Esc: Quit", + }; + + for (lines) |line| { + if (y < inner.bottom()) { + _ = buf.setString(inner.left(), y, line, Style.default); + y += 1; + } + } +} + +fn renderMessages(state: *AppState, area: Rect, buf: *Buffer) void { + const msg_block = Block.init() + .title(" Messages ") + .setBorders(Borders.all) + .style(Style.default.fg(Color.green)); + msg_block.render(area, buf); + + const inner = msg_block.inner(area); + var y = inner.top(); + + // Show messages in reverse order (newest at top) + var i: usize = state.messages.items.len; + while (i > 0 and y < inner.bottom()) { + i -= 1; + const msg = state.messages.items[i]; + _ = buf.setString(inner.left(), y, msg, Style.default); + y += 1; + } +} diff --git a/src/root.zig b/src/root.zig index ed1c6b6..b7e43a2 100644 --- a/src/root.zig +++ b/src/root.zig @@ -129,6 +129,10 @@ pub const widgets = struct { pub const Monthly = calendar_mod.Monthly; pub const Date = calendar_mod.Date; pub const CalendarEventStore = calendar_mod.CalendarEventStore; + + pub const input_mod = @import("widgets/input.zig"); + pub const Input = input_mod.Input; + pub const InputState = input_mod.InputState; }; // Backend diff --git a/src/widgets/input.zig b/src/widgets/input.zig new file mode 100644 index 0000000..9a129a2 --- /dev/null +++ b/src/widgets/input.zig @@ -0,0 +1,755 @@ +//! Text input widget with readline-style editing and history. +//! +//! The Input widget provides a single-line text input field with: +//! - Basic editing (insert, delete, backspace) +//! - Cursor movement (home, end, left, right, word navigation) +//! - Kill/yank (Ctrl+K, Ctrl+Y) +//! - Clear line (Ctrl+U) +//! - History navigation (up/down arrows) +//! +//! This widget is stateful - use InputState to track the input state +//! across frames and event handling. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const style_mod = @import("../style.zig"); +const Style = style_mod.Style; +const Color = style_mod.Color; +const buffer_mod = @import("../buffer.zig"); +const Buffer = buffer_mod.Buffer; +const Rect = buffer_mod.Rect; +const text_mod = @import("../text.zig"); +const Line = text_mod.Line; +const event_mod = @import("../event.zig"); +const Event = event_mod.Event; +const KeyEvent = event_mod.KeyEvent; +const KeyCode = event_mod.KeyCode; +const KeyModifiers = event_mod.KeyModifiers; +const block_mod = @import("block.zig"); +const Block = block_mod.Block; + +// ============================================================================ +// InputState +// ============================================================================ + +/// State for the Input widget. +/// +/// This struct manages the input buffer, cursor position, history, +/// and the kill ring (for Ctrl+K/Ctrl+Y operations). +pub const InputState = struct { + /// The current input buffer. + buffer: std.ArrayListUnmanaged(u8) = .{}, + /// Current cursor position (byte offset). + cursor: usize = 0, + /// View offset for scrolling (byte offset of first visible char). + view_offset: usize = 0, + /// Command history. + history: std.ArrayListUnmanaged([]u8) = .{}, + /// Current position in history (-1 = current input). + history_index: isize = -1, + /// Saved current input when navigating history. + saved_input: ?[]u8 = null, + /// Kill ring (for Ctrl+K/Ctrl+Y). + kill_ring: ?[]u8 = null, + /// Allocator for dynamic memory. + allocator: Allocator, + + /// Creates a new InputState. + pub fn init(allocator: Allocator) InputState { + return .{ + .allocator = allocator, + }; + } + + /// Deinitializes and frees all memory. + pub fn deinit(self: *InputState) void { + self.buffer.deinit(self.allocator); + for (self.history.items) |item| { + self.allocator.free(item); + } + self.history.deinit(self.allocator); + if (self.saved_input) |saved| { + self.allocator.free(saved); + } + if (self.kill_ring) |ring| { + self.allocator.free(ring); + } + } + + /// Returns the current input as a string slice. + pub fn value(self: *const InputState) []const u8 { + return self.buffer.items; + } + + /// Sets the input value and moves cursor to end. + pub fn setValue(self: *InputState, text: []const u8) !void { + self.buffer.clearRetainingCapacity(); + try self.buffer.appendSlice(self.allocator, text); + self.cursor = self.buffer.items.len; + } + + /// Clears the input. + pub fn clear(self: *InputState) void { + self.buffer.clearRetainingCapacity(); + self.cursor = 0; + self.view_offset = 0; + } + + /// Adds the current input to history and clears input. + pub fn submit(self: *InputState) ![]const u8 { + if (self.buffer.items.len == 0) return ""; + + // Copy current input for return + const result = try self.allocator.dupe(u8, self.buffer.items); + + // Add to history (if not duplicate of last entry) + const should_add = if (self.history.items.len > 0) + !std.mem.eql(u8, self.history.items[self.history.items.len - 1], self.buffer.items) + else + true; + + if (should_add) { + const hist_copy = try self.allocator.dupe(u8, self.buffer.items); + try self.history.append(self.allocator, hist_copy); + } + + // Reset state + self.clear(); + self.history_index = -1; + if (self.saved_input) |saved| { + self.allocator.free(saved); + self.saved_input = null; + } + + return result; + } + + // ======================================================================== + // Cursor movement + // ======================================================================== + + /// Moves cursor left by one character. + pub fn cursorLeft(self: *InputState) void { + if (self.cursor > 0) { + // Move back one UTF-8 character + self.cursor = prevCharBoundary(self.buffer.items, self.cursor); + } + } + + /// Moves cursor right by one character. + pub fn cursorRight(self: *InputState) void { + if (self.cursor < self.buffer.items.len) { + // Move forward one UTF-8 character + self.cursor = nextCharBoundary(self.buffer.items, self.cursor); + } + } + + /// Moves cursor to start of line. + pub fn cursorHome(self: *InputState) void { + self.cursor = 0; + } + + /// Moves cursor to end of line. + pub fn cursorEnd(self: *InputState) void { + self.cursor = self.buffer.items.len; + } + + /// Moves cursor to start of previous word. + pub fn cursorWordLeft(self: *InputState) void { + if (self.cursor == 0) return; + + var pos = self.cursor; + + // Skip any spaces before cursor + while (pos > 0 and isSpace(self.buffer.items[pos - 1])) { + pos -= 1; + } + + // Skip to start of word + while (pos > 0 and !isSpace(self.buffer.items[pos - 1])) { + pos -= 1; + } + + self.cursor = pos; + } + + /// Moves cursor to start of next word. + pub fn cursorWordRight(self: *InputState) void { + if (self.cursor >= self.buffer.items.len) return; + + var pos = self.cursor; + + // Skip current word + while (pos < self.buffer.items.len and !isSpace(self.buffer.items[pos])) { + pos += 1; + } + + // Skip spaces + while (pos < self.buffer.items.len and isSpace(self.buffer.items[pos])) { + pos += 1; + } + + self.cursor = pos; + } + + // ======================================================================== + // Editing + // ======================================================================== + + /// Inserts a single byte at cursor position. + pub fn insertByte(self: *InputState, byte: u8) !void { + try self.buffer.insert(self.allocator, self.cursor, byte); + self.cursor += 1; + } + + /// Inserts a Unicode codepoint at cursor position (UTF-8 encoded). + pub fn insert(self: *InputState, codepoint: u21) !void { + var buf: [4]u8 = undefined; + const len = std.unicode.utf8Encode(codepoint, &buf) catch return; + try self.buffer.insertSlice(self.allocator, self.cursor, buf[0..len]); + self.cursor += len; + } + + /// Inserts a string at cursor position. + pub fn insertSlice(self: *InputState, text: []const u8) !void { + try self.buffer.insertSlice(self.allocator, self.cursor, text); + self.cursor += text.len; + } + + /// Deletes character before cursor (backspace). + pub fn backspace(self: *InputState) void { + if (self.cursor > 0) { + const prev = prevCharBoundary(self.buffer.items, self.cursor); + const char_len = self.cursor - prev; + _ = self.buffer.orderedRemove(prev); + for (1..char_len) |_| { + if (prev < self.buffer.items.len) { + _ = self.buffer.orderedRemove(prev); + } + } + self.cursor = prev; + } + } + + /// Deletes character at cursor (delete key). + pub fn delete(self: *InputState) void { + if (self.cursor < self.buffer.items.len) { + const next = nextCharBoundary(self.buffer.items, self.cursor); + const char_len = next - self.cursor; + for (0..char_len) |_| { + if (self.cursor < self.buffer.items.len) { + _ = self.buffer.orderedRemove(self.cursor); + } + } + } + } + + /// Deletes word before cursor (Ctrl+W). + pub fn deleteWordBack(self: *InputState) void { + if (self.cursor == 0) return; + + const start = self.cursor; + + // Find start of word + self.cursorWordLeft(); + const word_start = self.cursor; + + // Delete from word_start to start + if (word_start < start) { + const len = start - word_start; + for (0..len) |_| { + if (word_start < self.buffer.items.len) { + _ = self.buffer.orderedRemove(word_start); + } + } + } + } + + /// Kills from cursor to end of line (Ctrl+K). + pub fn killToEnd(self: *InputState) !void { + if (self.cursor >= self.buffer.items.len) return; + + // Save to kill ring + const killed = self.buffer.items[self.cursor..]; + if (self.kill_ring) |ring| { + self.allocator.free(ring); + } + self.kill_ring = try self.allocator.dupe(u8, killed); + + // Remove from buffer + self.buffer.shrinkRetainingCapacity(self.cursor); + } + + /// Kills from start to cursor (Ctrl+U). + pub fn killToStart(self: *InputState) !void { + if (self.cursor == 0) return; + + // Save to kill ring + const killed = self.buffer.items[0..self.cursor]; + if (self.kill_ring) |ring| { + self.allocator.free(ring); + } + self.kill_ring = try self.allocator.dupe(u8, killed); + + // Remove from buffer + for (0..self.cursor) |_| { + _ = self.buffer.orderedRemove(0); + } + self.cursor = 0; + } + + /// Yanks (pastes) from kill ring (Ctrl+Y). + pub fn yank(self: *InputState) !void { + if (self.kill_ring) |ring| { + try self.insertSlice(ring); + } + } + + // ======================================================================== + // History + // ======================================================================== + + /// Goes to previous history entry (up arrow). + pub fn historyPrev(self: *InputState) !void { + if (self.history.items.len == 0) return; + + // Save current input if at bottom of history + if (self.history_index == -1) { + if (self.saved_input) |saved| { + self.allocator.free(saved); + } + self.saved_input = try self.allocator.dupe(u8, self.buffer.items); + } + + // Move up in history + if (self.history_index < @as(isize, @intCast(self.history.items.len)) - 1) { + self.history_index += 1; + const hist_idx: usize = @intCast(@as(isize, @intCast(self.history.items.len)) - 1 - self.history_index); + try self.setValue(self.history.items[hist_idx]); + } + } + + /// Goes to next history entry (down arrow). + pub fn historyNext(self: *InputState) !void { + if (self.history_index < 0) return; + + self.history_index -= 1; + + if (self.history_index < 0) { + // Restore saved input + if (self.saved_input) |saved| { + try self.setValue(saved); + self.allocator.free(saved); + self.saved_input = null; + } else { + self.clear(); + } + } else { + const hist_idx: usize = @intCast(@as(isize, @intCast(self.history.items.len)) - 1 - self.history_index); + try self.setValue(self.history.items[hist_idx]); + } + } + + // ======================================================================== + // Event handling + // ======================================================================== + + /// Handles a key event. Returns true if the event was handled. + pub fn handleEvent(self: *InputState, event: Event) !bool { + switch (event) { + .key => |key| return try self.handleKey(key), + else => return false, + } + } + + /// Handles a key event. Returns true if the event was handled. + pub fn handleKey(self: *InputState, key: KeyEvent) !bool { + const ctrl = key.modifiers.ctrl; + + switch (key.code) { + .char => |c| { + if (ctrl) { + switch (c) { + 'a', 'A' => self.cursorHome(), + 'e', 'E' => self.cursorEnd(), + 'b', 'B' => self.cursorLeft(), + 'f', 'F' => self.cursorRight(), + 'k', 'K' => try self.killToEnd(), + 'u', 'U' => try self.killToStart(), + 'y', 'Y' => try self.yank(), + 'w', 'W' => self.deleteWordBack(), + 'h', 'H' => self.backspace(), + 'd', 'D' => self.delete(), + else => return false, + } + } else { + try self.insert(c); + } + }, + .left => { + if (ctrl) { + self.cursorWordLeft(); + } else { + self.cursorLeft(); + } + }, + .right => { + if (ctrl) { + self.cursorWordRight(); + } else { + self.cursorRight(); + } + }, + .home => self.cursorHome(), + .end => self.cursorEnd(), + .backspace => { + if (ctrl) { + self.deleteWordBack(); + } else { + self.backspace(); + } + }, + .delete => self.delete(), + .up => try self.historyPrev(), + .down => try self.historyNext(), + else => return false, + } + + return true; + } +}; + +// ============================================================================ +// Input Widget +// ============================================================================ + +/// A single-line text input widget. +/// +/// Use this with InputState for a complete readline-style input experience. +pub const Input = struct { + /// Optional block to wrap the input. + block: ?Block = null, + /// Base style for the widget. + style: Style = Style.default, + /// Style for the cursor. + cursor_style: Style = Style.default.reversed(), + /// Placeholder text when empty. + placeholder: ?[]const u8 = null, + /// Placeholder style. + placeholder_style: Style = Style.default.fg(Color.white), + /// Whether to show the cursor. + show_cursor: bool = true, + /// Mask character for passwords (null = no mask). + mask: ?u8 = null, + + /// Creates a new Input widget. + pub fn init() Input { + return .{}; + } + + /// Sets the block wrapper. + pub fn setBlock(self: Input, b: Block) Input { + var input = self; + input.block = b; + return input; + } + + /// Sets the base style. + pub fn setStyle(self: Input, s: Style) Input { + var input = self; + input.style = s; + return input; + } + + /// Sets the cursor style. + pub fn cursorStyle(self: Input, s: Style) Input { + var input = self; + input.cursor_style = s; + return input; + } + + /// Sets the placeholder text. + pub fn setPlaceholder(self: Input, text: []const u8) Input { + var input = self; + input.placeholder = text; + return input; + } + + /// Sets the placeholder style. + pub fn placeholderStyle(self: Input, s: Style) Input { + var input = self; + input.placeholder_style = s; + return input; + } + + /// Sets whether to show the cursor. + pub fn showCursor(self: Input, show: bool) Input { + var input = self; + input.show_cursor = show; + return input; + } + + /// Sets the mask character for password input. + pub fn setMask(self: Input, char: u8) Input { + var input = self; + input.mask = char; + return input; + } + + /// Renders the input to a buffer with state. + pub fn render(self: Input, area: Rect, buf: *Buffer, state: *InputState) void { + if (area.isEmpty()) return; + + buf.setStyle(area, self.style); + + // Render block if present + const input_area = if (self.block) |b| blk: { + b.render(area, buf); + break :blk b.inner(area); + } else area; + + if (input_area.isEmpty()) return; + + const content = state.buffer.items; + const available_width = input_area.width; + + // Update view offset to ensure cursor is visible + self.updateViewOffset(state, available_width); + + const y = input_area.top(); + const x = input_area.left(); + + // If empty and has placeholder, show placeholder + if (content.len == 0 and self.placeholder != null) { + const placeholder = self.placeholder.?; + const len = @min(placeholder.len, available_width); + _ = buf.setString(x, y, placeholder[0..len], self.placeholder_style); + + // Show cursor at start + if (self.show_cursor) { + buf.setStyle(Rect.init(x, y, 1, 1), self.cursor_style); + } + return; + } + + // Render content starting from view_offset + var char_idx: usize = 0; + var byte_idx: usize = 0; + var col: u16 = 0; + + while (byte_idx < content.len and col < available_width) { + const is_cursor_pos = byte_idx == state.cursor; + + // Skip bytes before view_offset (but still track cursor) + if (char_idx < state.view_offset) { + byte_idx = nextCharBoundary(content, byte_idx); + char_idx += 1; + continue; + } + + // Get the character to display + const display_char: u8 = if (self.mask) |m| m else content[byte_idx]; + + // Render character + const char_style = if (is_cursor_pos and self.show_cursor) + self.cursor_style + else + self.style; + + _ = buf.setString(x + col, y, &[_]u8{display_char}, char_style); + col += 1; + + byte_idx = nextCharBoundary(content, byte_idx); + char_idx += 1; + } + + // If cursor is at end, render cursor in empty space + if (state.cursor >= content.len and self.show_cursor and col < available_width) { + buf.setStyle(Rect.init(x + col, y, 1, 1), self.cursor_style); + } + } + + fn updateViewOffset(self: Input, state: *InputState, width: u16) void { + _ = self; + + // Count characters to cursor + var char_count: usize = 0; + var byte_idx: usize = 0; + while (byte_idx < state.cursor) { + byte_idx = nextCharBoundary(state.buffer.items, byte_idx); + char_count += 1; + } + + // Ensure cursor is visible + if (char_count < state.view_offset) { + state.view_offset = char_count; + } else if (char_count >= state.view_offset + width) { + state.view_offset = char_count - width + 1; + } + } +}; + +// ============================================================================ +// Helper functions +// ============================================================================ + +fn isSpace(c: u8) bool { + return c == ' ' or c == '\t'; +} + +fn prevCharBoundary(text: []const u8, pos: usize) usize { + if (pos == 0) return 0; + var p = pos - 1; + // Skip UTF-8 continuation bytes + while (p > 0 and (text[p] & 0xC0) == 0x80) { + p -= 1; + } + return p; +} + +fn nextCharBoundary(text: []const u8, pos: usize) usize { + if (pos >= text.len) return text.len; + var p = pos + 1; + // Skip UTF-8 continuation bytes + while (p < text.len and (text[p] & 0xC0) == 0x80) { + p += 1; + } + return p; +} + +// ============================================================================ +// Tests +// ============================================================================ + +test "InputState init and deinit" { + var state = InputState.init(std.testing.allocator); + defer state.deinit(); + + try std.testing.expectEqual(@as(usize, 0), state.value().len); + try std.testing.expectEqual(@as(usize, 0), state.cursor); +} + +test "InputState insert and cursor" { + var state = InputState.init(std.testing.allocator); + defer state.deinit(); + + try state.insert('h'); + try state.insert('e'); + try state.insert('l'); + try state.insert('l'); + try state.insert('o'); + + try std.testing.expectEqualStrings("hello", state.value()); + try std.testing.expectEqual(@as(usize, 5), state.cursor); +} + +test "InputState cursor movement" { + var state = InputState.init(std.testing.allocator); + defer state.deinit(); + + try state.setValue("hello world"); + + state.cursorHome(); + try std.testing.expectEqual(@as(usize, 0), state.cursor); + + state.cursorEnd(); + try std.testing.expectEqual(@as(usize, 11), state.cursor); + + state.cursorLeft(); + try std.testing.expectEqual(@as(usize, 10), state.cursor); + + state.cursorRight(); + try std.testing.expectEqual(@as(usize, 11), state.cursor); +} + +test "InputState word navigation" { + var state = InputState.init(std.testing.allocator); + defer state.deinit(); + + try state.setValue("hello world test"); + state.cursorEnd(); // Start at end + + state.cursorWordLeft(); // To start of "test" + try std.testing.expectEqual(@as(usize, 12), state.cursor); + + state.cursorWordLeft(); // To start of "world" + try std.testing.expectEqual(@as(usize, 6), state.cursor); + + state.cursorWordRight(); // To start of "test" + try std.testing.expectEqual(@as(usize, 12), state.cursor); +} + +test "InputState backspace and delete" { + var state = InputState.init(std.testing.allocator); + defer state.deinit(); + + try state.setValue("hello"); + + state.backspace(); // Remove 'o' + try std.testing.expectEqualStrings("hell", state.value()); + + state.cursorHome(); + state.delete(); // Remove 'h' + try std.testing.expectEqualStrings("ell", state.value()); +} + +test "InputState kill and yank" { + var state = InputState.init(std.testing.allocator); + defer state.deinit(); + + try state.setValue("hello world"); + state.cursor = 6; // After "hello " + + try state.killToEnd(); // Kill "world" + try std.testing.expectEqualStrings("hello ", state.value()); + try std.testing.expectEqualStrings("world", state.kill_ring.?); + + try state.yank(); // Paste "world" + try std.testing.expectEqualStrings("hello world", state.value()); +} + +test "InputState history" { + var state = InputState.init(std.testing.allocator); + defer state.deinit(); + + // Add some history + try state.setValue("first"); + _ = try state.submit(); + + try state.setValue("second"); + _ = try state.submit(); + + try state.setValue("third"); + _ = try state.submit(); + + // Navigate history + try state.historyPrev(); + try std.testing.expectEqualStrings("third", state.value()); + + try state.historyPrev(); + try std.testing.expectEqualStrings("second", state.value()); + + try state.historyNext(); + try std.testing.expectEqualStrings("third", state.value()); + + try state.historyNext(); + try std.testing.expectEqualStrings("", state.value()); // Back to empty +} + +test "Input default" { + const input = Input.init(); + try std.testing.expect(input.block == null); + try std.testing.expect(input.show_cursor); + try std.testing.expect(input.mask == null); +} + +test "Input setters" { + const input = Input.init() + .setPlaceholder("Enter text...") + .showCursor(false) + .setMask('*'); + + try std.testing.expectEqualStrings("Enter text...", input.placeholder.?); + try std.testing.expect(!input.show_cursor); + try std.testing.expectEqual(@as(u8, '*'), input.mask.?); +}