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());
|
||||
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
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 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
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