Implement user-friendly cursor behavior: - Cursor blinks (500ms on/off) while there's user activity - After 5 seconds of inactivity, cursor becomes solid (always visible) - Any input (keyboard, mouse move, click, scroll) resets the timer Changes: - context.zig: Add last_input_time_ms tracking - input.zig: Add hasActivity() and hasActivityWithMouse() methods - input.zig: Track mouse_x_prev/mouse_y_prev for movement detection - text_input.zig: Implement hybrid blink logic This saves battery on laptops while maintaining natural cursor feedback. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
489 lines
15 KiB
Zig
489 lines
15 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,
|
|
};
|
|
|
|
/// 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;
|
|
const border_color = if (has_focus) theme.primary else theme.input_border;
|
|
const text_color = theme.input_fg;
|
|
const placeholder_color = theme.secondary;
|
|
|
|
// Draw background
|
|
ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, bg_color));
|
|
|
|
// Draw border
|
|
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)
|
|
const char_height: u32 = 8;
|
|
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) {
|
|
const char_width: u32 = 8;
|
|
const cursor_x = inner.x + @as(i32, @intCast(state.cursor * char_width));
|
|
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_timeout_ms: u64 = 5000; // 5 seconds
|
|
const idle_time = ctx.current_time_ms -| ctx.last_input_time_ms;
|
|
|
|
if (idle_time >= idle_timeout_ms) {
|
|
// Idle: cursor always visible (solid, no blink)
|
|
break :blk true;
|
|
} else {
|
|
// Active: cursor blinks (visible for 500ms, hidden for 500ms)
|
|
const blink_period_ms: u64 = 500;
|
|
break :blk (ctx.current_time_ms / 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 char_width: u32 = 8;
|
|
const start = @min(sel_start, state.cursor);
|
|
const end = @max(sel_start, state.cursor);
|
|
const sel_x = inner.x + @as(i32, @intCast(start * char_width));
|
|
const sel_w: u32 = @intCast((end - start) * char_width);
|
|
|
|
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();
|
|
}
|