feat: Implement keyboard system improvements (4 phases)

Phase 1: Frame timing in Context
- Added current_time_ms and frame_delta_ms to Context
- Added setFrameTime() method for applications to provide timing

Phase 2: Centralized shortcuts system
- Added StandardShortcut enum with common shortcuts (copy, paste, etc.)
- Added isStandardActive() function for checking shortcuts
- Updated TextInput to use centralized shortcuts

Phase 3: Incremental search in table
- Added search_buffer, search_len, search_last_time to TableState
- Added addSearchChar(), getSearchTerm(), clearSearch() methods
- Typing in focused table searches first column (case-insensitive)
- 1 second timeout resets search buffer

Phase 4: Blinking cursor in TextInput
- Cursor blinks every 500ms when field is focused
- Uses current_time_ms from Context for timing

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
reugenio 2025-12-11 22:58:22 +01:00
parent 7073ccef9f
commit 05e4f2c926
4 changed files with 290 additions and 18 deletions

View file

@ -88,6 +88,13 @@ pub const Context = struct {
/// Unified focus management system
focus: FocusSystem,
/// Current time in milliseconds (set by application each frame)
/// Used for animations, cursor blinking, etc.
current_time_ms: u64 = 0,
/// Time delta since last frame in milliseconds
frame_delta_ms: u32 = 0,
const Self = @This();
/// Frame statistics for performance monitoring
@ -248,6 +255,30 @@ pub const Context = struct {
self.focus.focusNextGroup();
}
// =========================================================================
// Timing
// =========================================================================
/// Set the current frame time (call once per frame, before beginFrame or after)
/// This enables animations, cursor blinking, and other time-based effects.
pub fn setFrameTime(self: *Self, time_ms: u64) void {
if (self.current_time_ms > 0) {
const delta = time_ms -| self.current_time_ms;
self.frame_delta_ms = @intCast(@min(delta, std.math.maxInt(u32)));
}
self.current_time_ms = time_ms;
}
/// Get current time in milliseconds
pub fn getTime(self: Self) u64 {
return self.current_time_ms;
}
/// Get time delta since last frame in milliseconds
pub fn getDeltaTime(self: Self) u32 {
return self.frame_delta_ms;
}
// =========================================================================
// ID Management
// =========================================================================
@ -589,3 +620,27 @@ test "Context focus groups" {
ctx.endFrame();
}
test "Context timing" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
// Initially zero
try std.testing.expectEqual(@as(u64, 0), ctx.getTime());
try std.testing.expectEqual(@as(u32, 0), ctx.getDeltaTime());
// Set first frame time
ctx.setFrameTime(1000);
try std.testing.expectEqual(@as(u64, 1000), ctx.getTime());
try std.testing.expectEqual(@as(u32, 0), ctx.getDeltaTime()); // No delta on first frame
// Set second frame time
ctx.setFrameTime(1016); // ~60 FPS = 16ms per frame
try std.testing.expectEqual(@as(u64, 1016), ctx.getTime());
try std.testing.expectEqual(@as(u32, 16), ctx.getDeltaTime());
// Set third frame time with larger gap
ctx.setFrameTime(1116); // 100ms later
try std.testing.expectEqual(@as(u64, 1116), ctx.getTime());
try std.testing.expectEqual(@as(u32, 100), ctx.getDeltaTime());
}

View file

@ -44,7 +44,7 @@ pub const Modifiers = packed struct {
.ctrl = input.keyDown(.left_ctrl) or input.keyDown(.right_ctrl),
.shift = input.keyDown(.left_shift) or input.keyDown(.right_shift),
.alt = input.keyDown(.left_alt) or input.keyDown(.right_alt),
.super = input.keyDown(.left_super) or input.keyDown(.right_super),
.super = input.modifiers.super, // Use tracked modifier state
};
}
};
@ -104,6 +104,48 @@ pub const Shortcut = struct {
}
};
/// Standard shortcut IDs for quick checking without registration
pub const StandardShortcut = enum {
select_all,
copy,
paste,
cut,
undo,
redo,
save,
new,
open,
close,
find,
quit,
help,
cancel,
};
/// Check if a standard shortcut is active (convenience function)
/// Works without ShortcutManager - checks input directly
pub fn isStandardActive(input: *const Input.InputState, shortcut: StandardShortcut) bool {
const mods = Modifiers.fromInput(input);
return switch (shortcut) {
.select_all => input.keyPressed(.a) and mods.eql(.{ .ctrl = true }),
.copy => input.keyPressed(.c) and mods.eql(.{ .ctrl = true }),
.paste => input.keyPressed(.v) and mods.eql(.{ .ctrl = true }),
.cut => input.keyPressed(.x) and mods.eql(.{ .ctrl = true }),
.undo => input.keyPressed(.z) and mods.eql(.{ .ctrl = true }),
.redo => (input.keyPressed(.z) and mods.eql(.{ .ctrl = true, .shift = true })) or
(input.keyPressed(.y) and mods.eql(.{ .ctrl = true })),
.save => input.keyPressed(.s) and mods.eql(.{ .ctrl = true }),
.new => input.keyPressed(.n) and mods.eql(.{ .ctrl = true }),
.open => input.keyPressed(.o) and mods.eql(.{ .ctrl = true }),
.close => input.keyPressed(.w) and mods.eql(.{ .ctrl = true }),
.find => input.keyPressed(.f) and mods.eql(.{ .ctrl = true }),
.quit => input.keyPressed(.q) and mods.eql(.{ .ctrl = true }),
.help => input.keyPressed(.f1) and mods.eql(.{}),
.cancel => input.keyPressed(.escape) and mods.eql(.{}),
};
}
/// Shortcut manager for registering and checking shortcuts
pub const ShortcutManager = struct {
shortcuts: [MAX_SHORTCUTS]Shortcut = undefined,
@ -381,3 +423,17 @@ test "registerCommon" {
try std.testing.expect(manager.getByAction("paste") != null);
try std.testing.expect(manager.getByAction("undo") != null);
}
test "isStandardActive" {
var input = Input.InputState.init();
// Simulate Ctrl being held
input.keys_down[@intFromEnum(Input.Key.left_ctrl)] = true;
// Simulate 'a' key press this frame
input.keys_down[@intFromEnum(Input.Key.a)] = true;
input.keys_down_prev[@intFromEnum(Input.Key.a)] = false;
try std.testing.expect(isStandardActive(&input, .select_all));
try std.testing.expect(!isStandardActive(&input, .copy)); // 'c' not pressed
}

View file

@ -158,6 +158,8 @@ pub const TableResult = struct {
validation_failed: bool = false,
/// Validation error message
validation_message: []const u8 = "",
/// Incremental search matched a row
search_matched: bool = false,
};
// =============================================================================
@ -218,6 +220,15 @@ pub const TableState = struct {
/// Length of last validation message
last_validation_message_len: usize = 0,
/// Incremental search buffer
search_buffer: [64]u8 = [_]u8{0} ** 64,
/// Search buffer length
search_len: usize = 0,
/// Last search keypress time (for timeout reset)
search_last_time: u64 = 0,
/// Search timeout in ms (reset search after this)
search_timeout_ms: u64 = 1000,
const Self = @This();
/// Initialize table state
@ -273,6 +284,34 @@ pub const TableState = struct {
self.edit_state.focused = false;
}
/// Add character to search buffer (for incremental search)
/// Returns the current search term
pub fn addSearchChar(self: *Self, char: u8, current_time: u64) []const u8 {
// Reset search if timeout expired
if (current_time > self.search_last_time + self.search_timeout_ms) {
self.search_len = 0;
}
// Add character if room
if (self.search_len < self.search_buffer.len) {
self.search_buffer[self.search_len] = char;
self.search_len += 1;
}
self.search_last_time = current_time;
return self.search_buffer[0..self.search_len];
}
/// Get current search term
pub fn getSearchTerm(self: Self) []const u8 {
return self.search_buffer[0..self.search_len];
}
/// Clear search buffer
pub fn clearSearch(self: *Self) void {
self.search_len = 0;
}
/// Get edit text
pub fn getEditText(self: *Self) []const u8 {
return self.edit_state.text();
@ -1397,6 +1436,86 @@ fn handleKeyboard(
result.selection_changed = true;
}
}
// Incremental search (only when not editing and no modifiers pressed)
if (!state.editing and !ctx.input.modifiers.ctrl and !ctx.input.modifiers.alt) {
const text_in = ctx.input.getTextInput();
if (text_in.len > 0) {
// Add characters to search buffer
for (text_in) |char| {
if (char >= 32 and char < 127) { // Printable ASCII
const search_term = state.addSearchChar(char, ctx.current_time_ms);
// Search for matching row in first column (column 0)
if (search_term.len > 0) {
var found_row: ?usize = null;
// Search from current position first, then wrap
const start_row: usize = if (state.selected_row >= 0)
@intCast(state.selected_row)
else
0;
// Search from start_row to end
var row: usize = start_row;
while (row < state.row_count) : (row += 1) {
const cell_text = get_cell(row, 0);
if (startsWithIgnoreCase(cell_text, search_term)) {
found_row = row;
break;
}
}
// If not found, wrap to beginning
if (found_row == null and start_row > 0) {
row = 0;
while (row < start_row) : (row += 1) {
const cell_text = get_cell(row, 0);
if (startsWithIgnoreCase(cell_text, search_term)) {
found_row = row;
break;
}
}
}
// Move selection if found
if (found_row) |row_idx| {
state.selected_row = @intCast(row_idx);
state.ensureVisible(visible_rows);
result.selection_changed = true;
result.search_matched = true;
}
}
}
}
}
}
}
// =============================================================================
// Helper Functions
// =============================================================================
/// Case-insensitive prefix match for incremental search
fn startsWithIgnoreCase(haystack: []const u8, needle: []const u8) bool {
if (needle.len > haystack.len) return false;
if (needle.len == 0) return true;
for (needle, 0..) |needle_char, i| {
const haystack_char = haystack[i];
// Simple ASCII case-insensitive comparison
const needle_lower = if (needle_char >= 'A' and needle_char <= 'Z')
needle_char + 32
else
needle_char;
const haystack_lower = if (haystack_char >= 'A' and haystack_char <= 'Z')
haystack_char + 32
else
haystack_char;
if (needle_lower != haystack_lower) return false;
}
return true;
}
// =============================================================================
@ -1615,3 +1734,36 @@ test "TableState validation" {
state.clearAllErrors();
try std.testing.expect(!state.hasAnyErrors());
}
test "startsWithIgnoreCase" {
try std.testing.expect(startsWithIgnoreCase("Hello World", "hello"));
try std.testing.expect(startsWithIgnoreCase("Hello World", "HELLO"));
try std.testing.expect(startsWithIgnoreCase("Hello World", "Hello"));
try std.testing.expect(startsWithIgnoreCase("ABC", "abc"));
try std.testing.expect(startsWithIgnoreCase("abc", "ABC"));
try std.testing.expect(!startsWithIgnoreCase("Hello", "World"));
try std.testing.expect(!startsWithIgnoreCase("Hi", "Hello"));
try std.testing.expect(startsWithIgnoreCase("Test", ""));
try std.testing.expect(startsWithIgnoreCase("", ""));
}
test "TableState incremental search" {
var state = TableState.init();
// Add first char
const search1 = state.addSearchChar('a', 1000);
try std.testing.expectEqualStrings("a", search1);
try std.testing.expectEqualStrings("a", state.getSearchTerm());
// Add second char within timeout
const search2 = state.addSearchChar('b', 1500);
try std.testing.expectEqualStrings("ab", search2);
// Timeout resets search
const search3 = state.addSearchChar('c', 3000);
try std.testing.expectEqualStrings("c", search3);
// Clear search
state.clearSearch();
try std.testing.expectEqualStrings("", state.getSearchTerm());
}

View file

@ -9,6 +9,7 @@ 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 {
@ -310,17 +311,16 @@ pub fn textInputRect(
.enter => {
result.submitted = true;
},
.a => {
// Ctrl+A = Select All
if (key_event.modifiers.ctrl) {
state.selectAll();
}
},
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) {
@ -351,8 +351,16 @@ pub fn textInputRect(
ctx.pushCommand(Command.text(inner.x, text_y, display_text, display_color));
}
// Draw cursor if focused
// Draw cursor if focused (blinking every 500ms)
if (has_focus and !config.readonly) {
// Cursor blinks: visible for 500ms, hidden for 500ms
const blink_period_ms: u64 = 500;
const cursor_visible = if (ctx.current_time_ms > 0)
(ctx.current_time_ms / blink_period_ms) % 2 == 0
else
true; // Always visible if no timing available
if (cursor_visible) {
const char_width: u32 = 8;
const cursor_x = inner.x + @as(i32, @intCast(state.cursor * char_width));
const cursor_color = theme.foreground;
@ -365,6 +373,7 @@ pub fn textInputRect(
cursor_color,
));
}
}
// Draw selection if any
if (state.selection_start) |sel_start| {