zcatgui/src/widgets/text_input.zig
reugenio f077c87dfc feat(v0.22.2): AutoComplete focus + Text Metrics + cursor 300ms
AutoComplete:
- Integración sistema focus (registerFocusable, requestFocus, hasFocus)
- Fix input: event.char → ctx.input.getTextInput()
- Fix dropdown al iniciar: is_first_frame guard

Text Metrics:
- Nuevo ctx.measureText() y ctx.measureTextToCursor()
- text_measure_fn callback para fuentes TTF
- char_width fallback para bitmap (8px)

Cursor:
- text_input.zig y autocomplete.zig usan métricas reales
- Blink rate: 500ms → 300ms (más responsive)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 20:08:11 +01:00

507 lines
16 KiB
Zig

//! TextInput Widget - Editable text field
//!
//! A single-line text input with cursor, selection, and editing support.
//! Manages its own buffer that the caller provides.
const std = @import("std");
const Context = @import("../core/context.zig").Context;
const Command = @import("../core/command.zig");
const Layout = @import("../core/layout.zig");
const Style = @import("../core/style.zig");
const Input = @import("../core/input.zig");
const shortcuts = @import("../core/shortcuts.zig");
/// Text input state (caller-managed)
pub const TextInputState = struct {
/// Text buffer
buffer: []u8,
/// Current text length
len: usize = 0,
/// Cursor position (byte index)
cursor: usize = 0,
/// Selection start (byte index), null if no selection
selection_start: ?usize = null,
/// Whether this input has focus
focused: bool = false,
/// Initialize with empty buffer
pub fn init(buffer: []u8) TextInputState {
return .{ .buffer = buffer };
}
/// Get the current text
pub fn text(self: TextInputState) []const u8 {
return self.buffer[0..self.len];
}
/// Set text programmatically
pub fn setText(self: *TextInputState, new_text: []const u8) void {
const copy_len = @min(new_text.len, self.buffer.len);
@memcpy(self.buffer[0..copy_len], new_text[0..copy_len]);
self.len = copy_len;
self.cursor = copy_len;
self.selection_start = null;
}
/// Clear the text
pub fn clear(self: *TextInputState) void {
self.len = 0;
self.cursor = 0;
self.selection_start = null;
}
/// Insert text at cursor
pub fn insert(self: *TextInputState, new_text: []const u8) void {
// Delete selection first if any
self.deleteSelection();
const available = self.buffer.len - self.len;
const to_insert = @min(new_text.len, available);
if (to_insert == 0) return;
// Move text after cursor
const after_cursor = self.len - self.cursor;
if (after_cursor > 0) {
std.mem.copyBackwards(
u8,
self.buffer[self.cursor + to_insert .. self.len + to_insert],
self.buffer[self.cursor..self.len],
);
}
// Insert new text
@memcpy(self.buffer[self.cursor..][0..to_insert], new_text[0..to_insert]);
self.len += to_insert;
self.cursor += to_insert;
}
/// Delete character before cursor (backspace)
pub fn deleteBack(self: *TextInputState) void {
if (self.selection_start != null) {
self.deleteSelection();
return;
}
if (self.cursor == 0) return;
// Move text after cursor back
const after_cursor = self.len - self.cursor;
if (after_cursor > 0) {
std.mem.copyForwards(
u8,
self.buffer[self.cursor - 1 .. self.len - 1],
self.buffer[self.cursor..self.len],
);
}
self.cursor -= 1;
self.len -= 1;
}
/// Delete character at cursor (delete key)
pub fn deleteForward(self: *TextInputState) void {
if (self.selection_start != null) {
self.deleteSelection();
return;
}
if (self.cursor >= self.len) return;
// Move text after cursor back
const after_cursor = self.len - self.cursor - 1;
if (after_cursor > 0) {
std.mem.copyForwards(
u8,
self.buffer[self.cursor .. self.len - 1],
self.buffer[self.cursor + 1 .. self.len],
);
}
self.len -= 1;
}
/// Delete selected text
fn deleteSelection(self: *TextInputState) void {
const start = self.selection_start orelse return;
const sel_start = @min(start, self.cursor);
const sel_end = @max(start, self.cursor);
const sel_len = sel_end - sel_start;
if (sel_len == 0) {
self.selection_start = null;
return;
}
// Move text after selection
const after_sel = self.len - sel_end;
if (after_sel > 0) {
std.mem.copyForwards(
u8,
self.buffer[sel_start .. sel_start + after_sel],
self.buffer[sel_end..self.len],
);
}
self.len -= sel_len;
self.cursor = sel_start;
self.selection_start = null;
}
/// Move cursor left
pub fn cursorLeft(self: *TextInputState, shift: bool) void {
if (shift and self.selection_start == null) {
self.selection_start = self.cursor;
} else if (!shift) {
self.selection_start = null;
}
if (self.cursor > 0) {
self.cursor -= 1;
}
}
/// Move cursor right
pub fn cursorRight(self: *TextInputState, shift: bool) void {
if (shift and self.selection_start == null) {
self.selection_start = self.cursor;
} else if (!shift) {
self.selection_start = null;
}
if (self.cursor < self.len) {
self.cursor += 1;
}
}
/// Move cursor to start
pub fn cursorHome(self: *TextInputState, shift: bool) void {
if (shift and self.selection_start == null) {
self.selection_start = self.cursor;
} else if (!shift) {
self.selection_start = null;
}
self.cursor = 0;
}
/// Move cursor to end
pub fn cursorEnd(self: *TextInputState, shift: bool) void {
if (shift and self.selection_start == null) {
self.selection_start = self.cursor;
} else if (!shift) {
self.selection_start = null;
}
self.cursor = self.len;
}
/// Select all text
pub fn selectAll(self: *TextInputState) void {
self.selection_start = 0;
self.cursor = self.len;
}
};
/// Text input configuration
pub const TextInputConfig = struct {
/// Placeholder text when empty
placeholder: []const u8 = "",
/// Read-only mode
readonly: bool = false,
/// Password mode (show dots instead of text)
password: bool = false,
/// Padding inside the input
padding: u32 = 4,
/// Override text color (for validation feedback). If null, uses theme default.
text_color: ?Style.Color = null,
/// Override border color (for validation feedback). If null, uses theme default.
border_color: ?Style.Color = null,
/// Corner radius (default 3 for fancy mode)
corner_radius: u8 = 3,
};
/// Result of text input widget
pub const TextInputResult = struct {
/// Text was changed this frame
changed: bool,
/// Enter was pressed
submitted: bool,
/// Widget was clicked (for focus management)
clicked: bool,
};
/// Draw a text input and return interaction result
pub fn textInput(ctx: *Context, state: *TextInputState) TextInputResult {
return textInputEx(ctx, state, .{});
}
/// Draw a text input with custom configuration
pub fn textInputEx(ctx: *Context, state: *TextInputState, config: TextInputConfig) TextInputResult {
const bounds = ctx.layout.nextRect();
return textInputRect(ctx, bounds, state, config);
}
/// Draw a text input in a specific rectangle
pub fn textInputRect(
ctx: *Context,
bounds: Layout.Rect,
state: *TextInputState,
config: TextInputConfig,
) TextInputResult {
var result = TextInputResult{
.changed = false,
.submitted = false,
.clicked = false,
};
if (bounds.isEmpty()) return result;
// Generate unique ID for this widget based on buffer memory address
const widget_id: u64 = @intFromPtr(state.buffer.ptr);
// Register as focusable in the active focus group
ctx.registerFocusable(widget_id);
// Check mouse interaction
const mouse = ctx.input.mousePos();
const hovered = bounds.contains(mouse.x, mouse.y);
const clicked = hovered and ctx.input.mousePressed(.left);
if (clicked) {
// Request focus - this also activates the group containing this widget
ctx.requestFocus(widget_id);
result.clicked = true;
}
// Check if this widget has focus (is focused widget in active group)
const has_focus = ctx.hasFocus(widget_id);
// Sync state.focused for backwards compatibility
state.focused = has_focus;
// Theme colors
const theme = Style.Theme.dark;
const bg_color = if (has_focus) theme.input_bg.lighten(5) else theme.input_bg;
// Use override colors if provided, otherwise use theme defaults
const border_color = config.border_color orelse (if (has_focus) theme.primary else theme.input_border);
const text_color = config.text_color orelse theme.input_fg;
const placeholder_color = theme.secondary;
// Draw background and border based on render mode
if (Style.isFancy() and config.corner_radius > 0) {
// Fancy mode: rounded corners
ctx.pushCommand(Command.roundedRect(bounds.x, bounds.y, bounds.w, bounds.h, bg_color, config.corner_radius));
ctx.pushCommand(Command.roundedRectOutline(bounds.x, bounds.y, bounds.w, bounds.h, border_color, config.corner_radius));
// Draw focus ring when focused
if (has_focus) {
ctx.pushCommand(Command.focusRing(bounds.x, bounds.y, bounds.w, bounds.h, config.corner_radius));
}
} else {
// Simple mode: square corners
ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, bg_color));
ctx.pushCommand(Command.rectOutline(bounds.x, bounds.y, bounds.w, bounds.h, border_color));
}
// Inner area
const inner = bounds.shrink(config.padding);
if (inner.isEmpty()) return result;
// Handle keyboard input if focused
if (has_focus and !config.readonly) {
// Handle special keys (navigation, deletion)
for (ctx.input.key_events[0..ctx.input.key_event_count]) |key_event| {
if (key_event.pressed) {
const shift = key_event.modifiers.shift;
switch (key_event.key) {
.backspace => {
state.deleteBack();
result.changed = true;
},
.delete => {
state.deleteForward();
result.changed = true;
},
.left => state.cursorLeft(shift),
.right => state.cursorRight(shift),
.home => state.cursorHome(shift),
.end => state.cursorEnd(shift),
.enter => {
result.submitted = true;
},
else => {},
}
}
}
// Check standard shortcuts using centralized system
if (shortcuts.isStandardActive(&ctx.input, .select_all)) {
state.selectAll();
}
// Handle typed text
const text_in = ctx.input.getTextInput();
if (text_in.len > 0) {
state.insert(text_in);
result.changed = true;
}
}
// Draw text or placeholder
const display_text = if (state.len == 0)
config.placeholder
else
state.text();
const display_color = if (state.len == 0) placeholder_color else text_color;
// Calculate text position (left-aligned, vertically centered)
// Use 16 for 8x16 fonts (VGA standard, better readability)
const char_height: u32 = 16;
const text_y = inner.y + @as(i32, @intCast((inner.h -| char_height) / 2));
if (config.password and state.len > 0) {
// Draw dots for password
var dots: [256]u8 = undefined;
const dot_count = @min(state.len, dots.len);
@memset(dots[0..dot_count], '*');
ctx.pushCommand(Command.text(inner.x, text_y, dots[0..dot_count], display_color));
} else {
ctx.pushCommand(Command.text(inner.x, text_y, display_text, display_color));
}
// Draw cursor if focused
// Hybrid behavior: blinks while active, solid after idle timeout
if (has_focus and !config.readonly) {
// Use ctx.measureTextToCursor for accurate cursor positioning with variable-width fonts
const cursor_offset = ctx.measureTextToCursor(display_text, state.cursor);
const cursor_x = inner.x + @as(i32, @intCast(cursor_offset));
const cursor_color = theme.foreground;
// Determine if cursor should be visible
const cursor_visible = blk: {
// If no timing available, always show cursor
if (ctx.current_time_ms == 0) break :blk true;
// Check idle time (time since last user input)
const idle_time = ctx.current_time_ms -| ctx.last_input_time_ms;
if (idle_time >= Context.CURSOR_IDLE_TIMEOUT_MS) {
// Idle: cursor always visible (solid, no blink)
break :blk true;
} else {
// Active: cursor blinks
break :blk (ctx.current_time_ms / Context.CURSOR_BLINK_PERIOD_MS) % 2 == 0;
}
};
if (cursor_visible) {
ctx.pushCommand(Command.rect(
cursor_x,
inner.y,
2,
inner.h,
cursor_color,
));
}
}
// Draw selection if any
if (state.selection_start) |sel_start| {
const start = @min(sel_start, state.cursor);
const end = @max(sel_start, state.cursor);
// Use ctx.measureTextToCursor for accurate selection with variable-width fonts
const start_offset = ctx.measureTextToCursor(display_text, start);
const end_offset = ctx.measureTextToCursor(display_text, end);
const sel_x = inner.x + @as(i32, @intCast(start_offset));
const sel_w: u32 = end_offset - start_offset;
if (sel_w > 0) {
ctx.pushCommand(Command.rect(
sel_x,
inner.y,
sel_w,
inner.h,
theme.selection_bg.blend(Style.Color.rgba(0, 0, 0, 128)),
));
}
}
return result;
}
// =============================================================================
// Tests
// =============================================================================
test "TextInputState insert" {
var buf: [64]u8 = undefined;
var state = TextInputState.init(&buf);
state.insert("Hello");
try std.testing.expectEqualStrings("Hello", state.text());
try std.testing.expectEqual(@as(usize, 5), state.cursor);
state.insert(" World");
try std.testing.expectEqualStrings("Hello World", state.text());
}
test "TextInputState backspace" {
var buf: [64]u8 = undefined;
var state = TextInputState.init(&buf);
state.insert("Hello");
state.deleteBack();
try std.testing.expectEqualStrings("Hell", state.text());
state.deleteBack();
state.deleteBack();
try std.testing.expectEqualStrings("He", state.text());
}
test "TextInputState cursor movement" {
var buf: [64]u8 = undefined;
var state = TextInputState.init(&buf);
state.insert("Hello");
try std.testing.expectEqual(@as(usize, 5), state.cursor);
state.cursorLeft(false);
try std.testing.expectEqual(@as(usize, 4), state.cursor);
state.cursorHome(false);
try std.testing.expectEqual(@as(usize, 0), state.cursor);
state.cursorEnd(false);
try std.testing.expectEqual(@as(usize, 5), state.cursor);
}
test "TextInputState selection" {
var buf: [64]u8 = undefined;
var state = TextInputState.init(&buf);
state.insert("Hello");
state.selectAll();
try std.testing.expectEqual(@as(?usize, 0), state.selection_start);
try std.testing.expectEqual(@as(usize, 5), state.cursor);
state.insert("X");
try std.testing.expectEqualStrings("X", state.text());
}
test "textInput generates commands" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
var buf: [64]u8 = undefined;
var state = TextInputState.init(&buf);
ctx.beginFrame();
ctx.layout.row_height = 24;
_ = textInput(&ctx, &state);
// Should generate: rect (bg) + rect_outline (border) + text (placeholder)
try std.testing.expect(ctx.commands.items.len >= 2);
ctx.endFrame();
}