//! 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(); }