Add readline-style Input widget with history support

New widget: src/widgets/input.zig
- Basic editing (insert, delete, backspace)
- Cursor movement (home, end, left, right)
- Word navigation (Ctrl+Left/Right, Ctrl+B/F)
- Kill/yank operations (Ctrl+K, Ctrl+U, Ctrl+Y)
- Delete word (Ctrl+W)
- History navigation (Up/Down arrows)
- Unicode support (UTF-8 encoding)
- Password masking support

New example: examples/input_demo.zig
- Interactive demo with normal and password inputs
- Tab to switch focus between inputs
- Shows submitted values in message log

Run with: zig build input-demo

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
reugenio 2025-12-08 13:26:56 +01:00
parent b9223dec85
commit 4d27a7b13d
4 changed files with 1009 additions and 0 deletions

View file

@ -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);
}

231
examples/input_demo.zig Normal file
View file

@ -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;
}
}

View file

@ -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

755
src/widgets/input.zig Normal file
View file

@ -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.?);
}