//! Table Keyboard Handling //! //! Handles keyboard navigation, editing, and incremental search. //! Part of the table widget module. const Context = @import("../../core/context.zig").Context; const types = @import("types.zig"); const state_mod = @import("state.zig"); const TableState = state_mod.TableState; const TableResult = state_mod.TableResult; const TableConfig = types.TableConfig; const CellDataFn = types.CellDataFn; const CellEditFn = types.CellEditFn; const CellValidateFn = types.CellValidateFn; /// Handle keyboard input for table navigation and editing pub fn handleKeyboard( ctx: *Context, state: *TableState, col_count: usize, visible_rows: usize, get_cell: CellDataFn, on_edit: ?CellEditFn, validate: ?CellValidateFn, config: TableConfig, result: *TableResult, ) void { // Check for navigation keys if (ctx.input.navKeyPressed()) |key| { switch (key) { .up => { if (state.selected_row > 0) { state.selected_row -= 1; result.selection_changed = true; state.ensureVisible(visible_rows); } }, .down => { if (state.selected_row < @as(i32, @intCast(state.row_count)) - 1) { state.selected_row += 1; result.selection_changed = true; state.ensureVisible(visible_rows); } }, .left => { if (state.selected_col > 0) { state.selected_col -= 1; result.selection_changed = true; } }, .right => { if (state.selected_col < @as(i32, @intCast(col_count)) - 1) { state.selected_col += 1; result.selection_changed = true; } }, .home => { if (ctx.input.modifiers.ctrl) { // Ctrl+Home: go to first row state.selected_row = 0; state.scroll_row = 0; } else { // Home: go to first column state.selected_col = 0; } result.selection_changed = true; }, .end => { if (ctx.input.modifiers.ctrl) { // Ctrl+End: go to last row state.selected_row = @as(i32, @intCast(state.row_count)) - 1; state.ensureVisible(visible_rows); } else { // End: go to last column state.selected_col = @as(i32, @intCast(col_count)) - 1; } result.selection_changed = true; }, .page_up => { const jump = @as(i32, @intCast(visible_rows)); state.selected_row = @max(0, state.selected_row - jump); state.ensureVisible(visible_rows); result.selection_changed = true; }, .page_down => { const jump = @as(i32, @intCast(visible_rows)); const max_row = @as(i32, @intCast(state.row_count)) - 1; state.selected_row = @min(max_row, state.selected_row + jump); state.ensureVisible(visible_rows); result.selection_changed = true; }, .tab => { // Tab: next cell, Shift+Tab: previous cell // Only handle if config.handle_tab is true if (config.handle_tab) { if (ctx.input.modifiers.shift) { if (state.selected_col > 0) { state.selected_col -= 1; } else if (state.selected_row > 0) { state.selected_row -= 1; state.selected_col = @as(i32, @intCast(col_count)) - 1; } } else { if (state.selected_col < @as(i32, @intCast(col_count)) - 1) { state.selected_col += 1; } else if (state.selected_row < @as(i32, @intCast(state.row_count)) - 1) { state.selected_row += 1; state.selected_col = 0; } } state.ensureVisible(visible_rows); result.selection_changed = true; } // If handle_tab is false, Tab is handled by external focus system }, .enter => { // Enter: start editing if not editing if (!state.editing and config.allow_edit) { if (state.selectedCell()) |cell| { const current_text = get_cell(cell.row, cell.col); state.startEditing(current_text); result.edit_started = true; } } }, .escape => { // Escape: cancel editing if (state.editing) { state.stopEditing(); result.edit_ended = true; } }, else => {}, } } // F2 also starts editing if (ctx.input.keyPressed(.f2) and !state.editing and config.allow_edit) { if (state.selectedCell()) |cell| { const current_text = get_cell(cell.row, cell.col); state.startEditing(current_text); result.edit_started = true; } } // Handle edit commit for Enter during editing if (state.editing and ctx.input.keyPressed(.enter)) { if (state.selectedCell()) |cell| { const edit_text = state.getEditText(); // Validate before commit if validator provided var should_commit = true; if (validate) |validate_fn| { const validation = validate_fn(cell.row, cell.col, edit_text); if (!validation.valid) { // Don't commit, mark error state.addCellError(cell.row, cell.col, validation.message); result.validation_failed = true; result.validation_message = validation.message; should_commit = false; } else { // Clear any previous error on this cell state.clearCellError(cell.row, cell.col); } } if (should_commit) { if (on_edit) |edit_fn| { edit_fn(cell.row, cell.col, edit_text); } state.stopEditing(); result.cell_edited = true; result.edit_ended = true; } } } // Row operations (only when not editing) if (!state.editing and config.allow_row_operations) { // Ctrl+N: Insert new row if (ctx.input.keyPressed(.n) and ctx.input.modifiers.ctrl) { result.row_added = true; // Insert after current row, or append if no selection if (state.selected_row >= 0) { result.insert_at = state.selected_row + 1; } else { result.insert_at = -1; // Append } } // Delete: Delete selected row(s) if (ctx.input.keyPressed(.delete)) { const count = state.getSelectedRows(&result.delete_rows); if (count > 0) { result.row_deleted = true; result.delete_count = count; } else if (state.selected_row >= 0) { // Single row delete (from selected_row) result.row_deleted = true; result.delete_rows[0] = @intCast(state.selected_row); result.delete_count = 1; } } // Ctrl+A: Select all rows if (ctx.input.keyPressed(.a) and ctx.input.modifiers.ctrl and config.allow_multi_select) { state.selectAllRows(); result.select_all = true; 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 pub 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; }