diff --git a/src/core/context.zig b/src/core/context.zig index 041ff6d..51cebac 100644 --- a/src/core/context.zig +++ b/src/core/context.zig @@ -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()); +} diff --git a/src/core/shortcuts.zig b/src/core/shortcuts.zig index 02bbd70..f0e2b94 100644 --- a/src/core/shortcuts.zig +++ b/src/core/shortcuts.zig @@ -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 +} diff --git a/src/widgets/table.zig b/src/widgets/table.zig index 6d3f9a2..48161e4 100644 --- a/src/widgets/table.zig +++ b/src/widgets/table.zig @@ -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()); +} diff --git a/src/widgets/text_input.zig b/src/widgets/text_input.zig index b9072fe..cbe6044 100644 --- a/src/widgets/text_input.zig +++ b/src/widgets/text_input.zig @@ -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