zcatgui/src/widgets/table/keyboard.zig
reugenio cfe4ee7935 refactor: Split table.zig into modular structure
Split the 1770-line table.zig into a cleaner module structure:

  src/widgets/table/
  ├── table.zig     (~400 lines) - Public API + re-exports + tests
  ├── types.zig     (~150 lines) - Enums, configs, column definitions
  ├── state.zig     (~500 lines) - TableState, TableResult
  ├── keyboard.zig  (~270 lines) - Keyboard handling, search
  └── render.zig    (~350 lines) - Drawing functions

Benefits:
- Each file is now manageable (<500 lines)
- Clearer separation of concerns
- Easier to navigate and modify
- Same public API (no breaking changes)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 23:11:35 +01:00

294 lines
12 KiB
Zig

//! 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;
}