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>
294 lines
12 KiB
Zig
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;
|
|
}
|