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:
parent
b9223dec85
commit
4d27a7b13d
4 changed files with 1009 additions and 0 deletions
19
build.zig
19
build.zig
|
|
@ -118,4 +118,23 @@ pub fn build(b: *std.Build) void {
|
||||||
run_dashboard.step.dependOn(b.getInstallStep());
|
run_dashboard.step.dependOn(b.getInstallStep());
|
||||||
const dashboard_step = b.step("dashboard", "Run dashboard demo");
|
const dashboard_step = b.step("dashboard", "Run dashboard demo");
|
||||||
dashboard_step.dependOn(&run_dashboard.step);
|
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
231
examples/input_demo.zig
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -129,6 +129,10 @@ pub const widgets = struct {
|
||||||
pub const Monthly = calendar_mod.Monthly;
|
pub const Monthly = calendar_mod.Monthly;
|
||||||
pub const Date = calendar_mod.Date;
|
pub const Date = calendar_mod.Date;
|
||||||
pub const CalendarEventStore = calendar_mod.CalendarEventStore;
|
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
|
// Backend
|
||||||
|
|
|
||||||
755
src/widgets/input.zig
Normal file
755
src/widgets/input.zig
Normal 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.?);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue