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:
parent
7073ccef9f
commit
05e4f2c926
4 changed files with 290 additions and 18 deletions
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,19 +351,28 @@ 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) {
|
||||
const char_width: u32 = 8;
|
||||
const cursor_x = inner.x + @as(i32, @intCast(state.cursor * char_width));
|
||||
const cursor_color = theme.foreground;
|
||||
// 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
|
||||
|
||||
ctx.pushCommand(Command.rect(
|
||||
cursor_x,
|
||||
inner.y,
|
||||
2,
|
||||
inner.h,
|
||||
cursor_color,
|
||||
));
|
||||
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;
|
||||
|
||||
ctx.pushCommand(Command.rect(
|
||||
cursor_x,
|
||||
inner.y,
|
||||
2,
|
||||
inner.h,
|
||||
cursor_color,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Draw selection if any
|
||||
|
|
|
|||
Loading…
Reference in a new issue