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>
This commit is contained in:
parent
e73836493e
commit
cfe4ee7935
7 changed files with 1804 additions and 1770 deletions
File diff suppressed because it is too large
Load diff
294
src/widgets/table/keyboard.zig
Normal file
294
src/widgets/table/keyboard.zig
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
//! 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;
|
||||||
|
}
|
||||||
405
src/widgets/table/render.zig
Normal file
405
src/widgets/table/render.zig
Normal file
|
|
@ -0,0 +1,405 @@
|
||||||
|
//! Table Rendering - Drawing functions for table components
|
||||||
|
//!
|
||||||
|
//! Handles drawing headers, rows, cells, state indicators, and scrollbar.
|
||||||
|
//! Part of the table widget module.
|
||||||
|
|
||||||
|
const Context = @import("../../core/context.zig").Context;
|
||||||
|
const Command = @import("../../core/command.zig");
|
||||||
|
const Layout = @import("../../core/layout.zig");
|
||||||
|
const Style = @import("../../core/style.zig");
|
||||||
|
|
||||||
|
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 TableColors = types.TableColors;
|
||||||
|
const Column = types.Column;
|
||||||
|
const RowState = types.RowState;
|
||||||
|
const CellDataFn = types.CellDataFn;
|
||||||
|
const CellEditFn = types.CellEditFn;
|
||||||
|
const CellValidateFn = types.CellValidateFn;
|
||||||
|
|
||||||
|
/// Draw column headers
|
||||||
|
pub fn drawHeader(
|
||||||
|
ctx: *Context,
|
||||||
|
bounds: Layout.Rect,
|
||||||
|
state: *TableState,
|
||||||
|
columns: []const Column,
|
||||||
|
state_col_w: u32,
|
||||||
|
config: TableConfig,
|
||||||
|
colors: TableColors,
|
||||||
|
) TableResult {
|
||||||
|
var result = TableResult{};
|
||||||
|
|
||||||
|
const header_bounds = Layout.Rect.init(
|
||||||
|
bounds.x,
|
||||||
|
bounds.y,
|
||||||
|
bounds.w,
|
||||||
|
config.header_height,
|
||||||
|
);
|
||||||
|
|
||||||
|
const mouse = ctx.input.mousePos();
|
||||||
|
const mouse_pressed = ctx.input.mousePressed(.left);
|
||||||
|
|
||||||
|
// Header background
|
||||||
|
ctx.pushCommand(Command.rect(
|
||||||
|
header_bounds.x,
|
||||||
|
header_bounds.y,
|
||||||
|
header_bounds.w,
|
||||||
|
header_bounds.h,
|
||||||
|
colors.header_bg,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Header border
|
||||||
|
ctx.pushCommand(Command.line(
|
||||||
|
header_bounds.x,
|
||||||
|
header_bounds.bottom() - 1,
|
||||||
|
header_bounds.right(),
|
||||||
|
header_bounds.bottom() - 1,
|
||||||
|
colors.border,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Reset hovered header
|
||||||
|
state.hovered_header = -1;
|
||||||
|
|
||||||
|
// State indicator column header (empty)
|
||||||
|
var col_x = bounds.x + @as(i32, @intCast(state_col_w));
|
||||||
|
|
||||||
|
// Draw column headers
|
||||||
|
const char_height: u32 = 8;
|
||||||
|
const text_y = header_bounds.y + @as(i32, @intCast((config.header_height -| char_height) / 2));
|
||||||
|
|
||||||
|
for (columns, 0..) |col, col_idx| {
|
||||||
|
const col_header_bounds = Layout.Rect.init(
|
||||||
|
col_x,
|
||||||
|
header_bounds.y,
|
||||||
|
col.width,
|
||||||
|
config.header_height,
|
||||||
|
);
|
||||||
|
|
||||||
|
const is_hovered = col_header_bounds.contains(mouse.x, mouse.y);
|
||||||
|
const is_sorted = state.sort_column == @as(i32, @intCast(col_idx));
|
||||||
|
|
||||||
|
if (is_hovered and col.sortable) {
|
||||||
|
state.hovered_header = @intCast(col_idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column background (for hover/sorted state)
|
||||||
|
const col_bg = if (is_sorted)
|
||||||
|
colors.header_sorted
|
||||||
|
else if (is_hovered and col.sortable and config.allow_sorting)
|
||||||
|
colors.header_hover
|
||||||
|
else
|
||||||
|
colors.header_bg;
|
||||||
|
|
||||||
|
if (col_bg.r != colors.header_bg.r or col_bg.g != colors.header_bg.g or col_bg.b != colors.header_bg.b) {
|
||||||
|
ctx.pushCommand(Command.rect(
|
||||||
|
col_header_bounds.x,
|
||||||
|
col_header_bounds.y,
|
||||||
|
col_header_bounds.w,
|
||||||
|
col_header_bounds.h,
|
||||||
|
col_bg,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column text
|
||||||
|
const text_x = col_x + 4; // Padding
|
||||||
|
ctx.pushCommand(Command.text(text_x, text_y, col.name, colors.header_fg));
|
||||||
|
|
||||||
|
// Sort indicator
|
||||||
|
if (is_sorted and state.sort_direction != .none) {
|
||||||
|
const indicator_x = col_x + @as(i32, @intCast(col.width)) - 16;
|
||||||
|
const indicator_y = header_bounds.y + @as(i32, @intCast((config.header_height - 8) / 2));
|
||||||
|
|
||||||
|
// Draw arrow (triangle approximation with text)
|
||||||
|
const arrow: []const u8 = switch (state.sort_direction) {
|
||||||
|
.ascending => "^",
|
||||||
|
.descending => "v",
|
||||||
|
.none => "",
|
||||||
|
};
|
||||||
|
if (arrow.len > 0) {
|
||||||
|
ctx.pushCommand(Command.text(indicator_x, indicator_y, arrow, colors.sort_indicator));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle click for sorting
|
||||||
|
if (mouse_pressed and is_hovered and col.sortable and config.allow_sorting) {
|
||||||
|
const new_direction = state.toggleSort(col_idx);
|
||||||
|
result.sort_changed = true;
|
||||||
|
result.sort_column = @intCast(col_idx);
|
||||||
|
result.sort_direction = new_direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column separator
|
||||||
|
col_x += @as(i32, @intCast(col.width));
|
||||||
|
ctx.pushCommand(Command.line(
|
||||||
|
col_x,
|
||||||
|
header_bounds.y,
|
||||||
|
col_x,
|
||||||
|
header_bounds.bottom(),
|
||||||
|
colors.border,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a single data row
|
||||||
|
pub fn drawRow(
|
||||||
|
ctx: *Context,
|
||||||
|
row_bounds: Layout.Rect,
|
||||||
|
state: *TableState,
|
||||||
|
row: usize,
|
||||||
|
columns: []const Column,
|
||||||
|
get_cell: CellDataFn,
|
||||||
|
on_edit: ?CellEditFn,
|
||||||
|
validate: ?CellValidateFn,
|
||||||
|
state_col_w: u32,
|
||||||
|
config: TableConfig,
|
||||||
|
colors: TableColors,
|
||||||
|
) TableResult {
|
||||||
|
var result = TableResult{};
|
||||||
|
|
||||||
|
const mouse = ctx.input.mousePos();
|
||||||
|
const is_selected = state.selected_row == @as(i32, @intCast(row));
|
||||||
|
const row_hovered = row_bounds.contains(mouse.x, mouse.y);
|
||||||
|
|
||||||
|
// Row background
|
||||||
|
const row_bg = if (is_selected)
|
||||||
|
colors.row_selected
|
||||||
|
else if (row_hovered)
|
||||||
|
colors.row_hover
|
||||||
|
else if (config.alternating_rows and row % 2 == 1)
|
||||||
|
colors.row_odd
|
||||||
|
else
|
||||||
|
colors.row_even;
|
||||||
|
|
||||||
|
ctx.pushCommand(Command.rect(row_bounds.x, row_bounds.y, row_bounds.w, row_bounds.h, row_bg));
|
||||||
|
|
||||||
|
// State indicator
|
||||||
|
if (config.show_state_indicators) {
|
||||||
|
const indicator_bounds = Layout.Rect.init(
|
||||||
|
row_bounds.x,
|
||||||
|
row_bounds.y,
|
||||||
|
state_col_w,
|
||||||
|
config.row_height,
|
||||||
|
);
|
||||||
|
drawStateIndicator(ctx, indicator_bounds, state.getRowState(row), colors);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw cells
|
||||||
|
var col_x = row_bounds.x + @as(i32, @intCast(state_col_w));
|
||||||
|
const char_height: u32 = 8;
|
||||||
|
const text_y = row_bounds.y + @as(i32, @intCast((config.row_height -| char_height) / 2));
|
||||||
|
|
||||||
|
for (columns, 0..) |col, col_idx| {
|
||||||
|
const cell_bounds = Layout.Rect.init(
|
||||||
|
col_x,
|
||||||
|
row_bounds.y,
|
||||||
|
col.width,
|
||||||
|
config.row_height,
|
||||||
|
);
|
||||||
|
|
||||||
|
const is_cell_selected = is_selected and state.selected_col == @as(i32, @intCast(col_idx));
|
||||||
|
const cell_hovered = cell_bounds.contains(mouse.x, mouse.y);
|
||||||
|
const has_error = state.hasCellError(row, col_idx);
|
||||||
|
|
||||||
|
// Cell validation error background
|
||||||
|
if (has_error) {
|
||||||
|
ctx.pushCommand(Command.rect(
|
||||||
|
cell_bounds.x + 1,
|
||||||
|
cell_bounds.y + 1,
|
||||||
|
cell_bounds.w - 2,
|
||||||
|
cell_bounds.h - 2,
|
||||||
|
colors.validation_error_bg,
|
||||||
|
));
|
||||||
|
ctx.pushCommand(Command.rectOutline(
|
||||||
|
cell_bounds.x,
|
||||||
|
cell_bounds.y,
|
||||||
|
cell_bounds.w,
|
||||||
|
cell_bounds.h,
|
||||||
|
colors.validation_error_border,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cell selection highlight (drawn over error background if both)
|
||||||
|
if (is_cell_selected and !state.editing) {
|
||||||
|
ctx.pushCommand(Command.rectOutline(
|
||||||
|
cell_bounds.x + 1,
|
||||||
|
cell_bounds.y + 1,
|
||||||
|
cell_bounds.w - 2,
|
||||||
|
cell_bounds.h - 2,
|
||||||
|
Style.Color.primary,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle cell click
|
||||||
|
if (cell_hovered and ctx.input.mousePressed(.left)) {
|
||||||
|
const was_selected = is_cell_selected;
|
||||||
|
state.selectCell(row, col_idx);
|
||||||
|
result.selection_changed = true;
|
||||||
|
|
||||||
|
// Double-click to edit (or click on already selected)
|
||||||
|
if (was_selected and config.allow_edit and col.editable) {
|
||||||
|
const cell_text = get_cell(row, col_idx);
|
||||||
|
state.startEditing(cell_text);
|
||||||
|
result.edit_started = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw cell content
|
||||||
|
if (state.editing and is_cell_selected) {
|
||||||
|
// Draw edit field
|
||||||
|
ctx.pushCommand(Command.rect(
|
||||||
|
cell_bounds.x + 1,
|
||||||
|
cell_bounds.y + 1,
|
||||||
|
cell_bounds.w - 2,
|
||||||
|
cell_bounds.h - 2,
|
||||||
|
colors.cell_editing,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Real-time validation during editing
|
||||||
|
if (validate) |validate_fn| {
|
||||||
|
const edit_text = state.getEditText();
|
||||||
|
const validation = validate_fn(row, col_idx, edit_text);
|
||||||
|
if (!validation.valid) {
|
||||||
|
// Draw error indicator while editing
|
||||||
|
ctx.pushCommand(Command.rectOutline(
|
||||||
|
cell_bounds.x,
|
||||||
|
cell_bounds.y,
|
||||||
|
cell_bounds.w,
|
||||||
|
cell_bounds.h,
|
||||||
|
colors.validation_error_border,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle text input
|
||||||
|
const text_in = ctx.input.getTextInput();
|
||||||
|
if (text_in.len > 0) {
|
||||||
|
state.edit_state.insert(text_in);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw edit text
|
||||||
|
const edit_text = state.getEditText();
|
||||||
|
ctx.pushCommand(Command.text(col_x + 4, text_y, edit_text, colors.cell_text));
|
||||||
|
|
||||||
|
// Draw cursor
|
||||||
|
const cursor_x = col_x + 4 + @as(i32, @intCast(state.edit_state.cursor * 8));
|
||||||
|
ctx.pushCommand(Command.rect(
|
||||||
|
cursor_x,
|
||||||
|
cell_bounds.y + 2,
|
||||||
|
2,
|
||||||
|
cell_bounds.h - 4,
|
||||||
|
colors.cell_text,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
// Normal cell display
|
||||||
|
const cell_text = get_cell(row, col_idx);
|
||||||
|
const text_color = if (is_selected) colors.cell_text_selected else colors.cell_text;
|
||||||
|
ctx.pushCommand(Command.text(col_x + 4, text_y, cell_text, text_color));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column separator
|
||||||
|
col_x += @as(i32, @intCast(col.width));
|
||||||
|
ctx.pushCommand(Command.line(
|
||||||
|
col_x,
|
||||||
|
row_bounds.y,
|
||||||
|
col_x,
|
||||||
|
row_bounds.bottom(),
|
||||||
|
colors.border,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row bottom border
|
||||||
|
ctx.pushCommand(Command.line(
|
||||||
|
row_bounds.x,
|
||||||
|
row_bounds.bottom() - 1,
|
||||||
|
row_bounds.right(),
|
||||||
|
row_bounds.bottom() - 1,
|
||||||
|
colors.border,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Handle edit commit on Enter or when moving away
|
||||||
|
if (state.editing and is_selected) {
|
||||||
|
// This will be handled by keyboard handler
|
||||||
|
_ = on_edit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw row state indicator (new, modified, deleted)
|
||||||
|
pub fn drawStateIndicator(
|
||||||
|
ctx: *Context,
|
||||||
|
bounds: Layout.Rect,
|
||||||
|
row_state: RowState,
|
||||||
|
colors: TableColors,
|
||||||
|
) void {
|
||||||
|
const indicator_size: u32 = 8;
|
||||||
|
const x = bounds.x + @as(i32, @intCast((bounds.w -| indicator_size) / 2));
|
||||||
|
const y = bounds.y + @as(i32, @intCast((bounds.h -| indicator_size) / 2));
|
||||||
|
|
||||||
|
const color: ?Style.Color = switch (row_state) {
|
||||||
|
.clean => null,
|
||||||
|
.new => colors.state_new,
|
||||||
|
.modified => colors.state_modified,
|
||||||
|
.deleted => colors.state_deleted,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (color) |c| {
|
||||||
|
ctx.pushCommand(Command.rect(x, y, indicator_size, indicator_size, c));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw vertical scrollbar
|
||||||
|
pub fn drawScrollbar(
|
||||||
|
ctx: *Context,
|
||||||
|
bounds: Layout.Rect,
|
||||||
|
state: *TableState,
|
||||||
|
visible_rows: usize,
|
||||||
|
config: TableConfig,
|
||||||
|
colors: TableColors,
|
||||||
|
) void {
|
||||||
|
_ = config;
|
||||||
|
|
||||||
|
const scrollbar_w: u32 = 12;
|
||||||
|
const header_h: u32 = 28; // Assume header
|
||||||
|
|
||||||
|
const track_x = bounds.right() - @as(i32, @intCast(scrollbar_w));
|
||||||
|
const track_y = bounds.y + @as(i32, @intCast(header_h));
|
||||||
|
const track_h = bounds.h -| header_h;
|
||||||
|
|
||||||
|
// Track
|
||||||
|
ctx.pushCommand(Command.rect(
|
||||||
|
track_x,
|
||||||
|
track_y,
|
||||||
|
scrollbar_w,
|
||||||
|
track_h,
|
||||||
|
colors.row_odd,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Thumb
|
||||||
|
if (state.row_count > 0) {
|
||||||
|
const visible_rows_u32: u32 = @intCast(visible_rows);
|
||||||
|
const row_count_u32: u32 = @intCast(state.row_count);
|
||||||
|
const thumb_h: u32 = @max((visible_rows_u32 * track_h) / row_count_u32, 20);
|
||||||
|
const scroll_range = state.row_count - visible_rows;
|
||||||
|
const scroll_row_u32: u32 = @intCast(state.scroll_row);
|
||||||
|
const scroll_range_u32: u32 = @intCast(scroll_range);
|
||||||
|
const thumb_offset: u32 = if (scroll_range > 0)
|
||||||
|
(scroll_row_u32 * (track_h - thumb_h)) / scroll_range_u32
|
||||||
|
else
|
||||||
|
0;
|
||||||
|
|
||||||
|
ctx.pushCommand(Command.rect(
|
||||||
|
track_x + 2,
|
||||||
|
track_y + @as(i32, @intCast(thumb_offset)),
|
||||||
|
scrollbar_w - 4,
|
||||||
|
thumb_h,
|
||||||
|
colors.header_bg,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
504
src/widgets/table/state.zig
Normal file
504
src/widgets/table/state.zig
Normal file
|
|
@ -0,0 +1,504 @@
|
||||||
|
//! Table State - State management and result types
|
||||||
|
//!
|
||||||
|
//! Part of the table widget module.
|
||||||
|
|
||||||
|
const types = @import("types.zig");
|
||||||
|
const text_input = @import("../text_input.zig");
|
||||||
|
|
||||||
|
const RowState = types.RowState;
|
||||||
|
const SortDirection = types.SortDirection;
|
||||||
|
const MAX_COLUMNS = types.MAX_COLUMNS;
|
||||||
|
const MAX_EDIT_BUFFER = types.MAX_EDIT_BUFFER;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Table Result
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Result of table interaction
|
||||||
|
pub const TableResult = struct {
|
||||||
|
/// Cell was selected
|
||||||
|
selection_changed: bool = false,
|
||||||
|
/// Cell value was edited
|
||||||
|
cell_edited: bool = false,
|
||||||
|
/// Row was added (Ctrl+N pressed)
|
||||||
|
row_added: bool = false,
|
||||||
|
/// Insert row at this index (-1 = append)
|
||||||
|
insert_at: i32 = -1,
|
||||||
|
/// Row was deleted (Delete pressed)
|
||||||
|
row_deleted: bool = false,
|
||||||
|
/// Rows to delete (indices)
|
||||||
|
delete_rows: [64]usize = undefined,
|
||||||
|
/// Number of rows to delete
|
||||||
|
delete_count: usize = 0,
|
||||||
|
/// Editing started
|
||||||
|
edit_started: bool = false,
|
||||||
|
/// Editing ended
|
||||||
|
edit_ended: bool = false,
|
||||||
|
/// Sort changed
|
||||||
|
sort_changed: bool = false,
|
||||||
|
/// Column that was sorted (-1 if none)
|
||||||
|
sort_column: i32 = -1,
|
||||||
|
/// New sort direction
|
||||||
|
sort_direction: SortDirection = .none,
|
||||||
|
/// Select all was triggered (Ctrl+A)
|
||||||
|
select_all: bool = false,
|
||||||
|
/// Validation failed
|
||||||
|
validation_failed: bool = false,
|
||||||
|
/// Validation error message
|
||||||
|
validation_message: []const u8 = "",
|
||||||
|
/// Incremental search matched a row
|
||||||
|
search_matched: bool = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Table State
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Table state (caller-managed)
|
||||||
|
pub const TableState = struct {
|
||||||
|
/// Number of rows
|
||||||
|
row_count: usize = 0,
|
||||||
|
|
||||||
|
/// Selected row (-1 for none)
|
||||||
|
selected_row: i32 = -1,
|
||||||
|
/// Selected column (-1 for none)
|
||||||
|
selected_col: i32 = -1,
|
||||||
|
|
||||||
|
/// Whether a cell is being edited
|
||||||
|
editing: bool = false,
|
||||||
|
/// Edit buffer
|
||||||
|
edit_buffer: [MAX_EDIT_BUFFER]u8 = undefined,
|
||||||
|
/// Edit state (for TextInput)
|
||||||
|
edit_state: text_input.TextInputState = undefined,
|
||||||
|
|
||||||
|
/// Scroll offset (first visible row)
|
||||||
|
scroll_row: usize = 0,
|
||||||
|
/// Horizontal scroll offset
|
||||||
|
scroll_x: i32 = 0,
|
||||||
|
|
||||||
|
/// Whether table has focus
|
||||||
|
focused: bool = false,
|
||||||
|
|
||||||
|
/// Row states for dirty tracking
|
||||||
|
row_states: [1024]RowState = [_]RowState{.clean} ** 1024,
|
||||||
|
|
||||||
|
/// Currently sorted column (-1 for none)
|
||||||
|
sort_column: i32 = -1,
|
||||||
|
/// Sort direction
|
||||||
|
sort_direction: SortDirection = .none,
|
||||||
|
/// Hovered header column (-1 for none)
|
||||||
|
hovered_header: i32 = -1,
|
||||||
|
|
||||||
|
/// Multi-row selection (bit array for first 1024 rows)
|
||||||
|
selected_rows: [128]u8 = [_]u8{0} ** 128, // 1024 bits
|
||||||
|
/// Selection anchor for shift-click
|
||||||
|
selection_anchor: i32 = -1,
|
||||||
|
|
||||||
|
/// Cells with validation errors (row * MAX_COLUMNS + col)
|
||||||
|
validation_errors: [256]u32 = [_]u32{0xFFFFFFFF} ** 256,
|
||||||
|
/// Number of cells with validation errors
|
||||||
|
validation_error_count: usize = 0,
|
||||||
|
/// Last validation error message
|
||||||
|
last_validation_message: [128]u8 = [_]u8{0} ** 128,
|
||||||
|
/// 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
|
||||||
|
pub fn init() Self {
|
||||||
|
var state = Self{};
|
||||||
|
state.edit_state = text_input.TextInputState.init(&state.edit_buffer);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set row count
|
||||||
|
pub fn setRowCount(self: *Self, count: usize) void {
|
||||||
|
self.row_count = count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get selected cell
|
||||||
|
pub fn selectedCell(self: Self) ?struct { row: usize, col: usize } {
|
||||||
|
if (self.selected_row < 0 or self.selected_col < 0) return null;
|
||||||
|
return .{
|
||||||
|
.row = @intCast(self.selected_row),
|
||||||
|
.col = @intCast(self.selected_col),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select a cell
|
||||||
|
pub fn selectCell(self: *Self, row: usize, col: usize) void {
|
||||||
|
self.selected_row = @intCast(row);
|
||||||
|
self.selected_col = @intCast(col);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear selection
|
||||||
|
pub fn clearSelection(self: *Self) void {
|
||||||
|
self.selected_row = -1;
|
||||||
|
self.selected_col = -1;
|
||||||
|
self.editing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start editing current cell
|
||||||
|
pub fn startEditing(self: *Self, initial_text: []const u8) void {
|
||||||
|
self.editing = true;
|
||||||
|
self.edit_state.setText(initial_text);
|
||||||
|
self.edit_state.focused = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop editing
|
||||||
|
pub fn stopEditing(self: *Self) void {
|
||||||
|
self.editing = false;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark row as modified
|
||||||
|
pub fn markModified(self: *Self, row: usize) void {
|
||||||
|
if (row < self.row_states.len) {
|
||||||
|
if (self.row_states[row] == .clean) {
|
||||||
|
self.row_states[row] = .modified;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark row as new
|
||||||
|
pub fn markNew(self: *Self, row: usize) void {
|
||||||
|
if (row < self.row_states.len) {
|
||||||
|
self.row_states[row] = .new;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark row as deleted
|
||||||
|
pub fn markDeleted(self: *Self, row: usize) void {
|
||||||
|
if (row < self.row_states.len) {
|
||||||
|
self.row_states[row] = .deleted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get row state
|
||||||
|
pub fn getRowState(self: Self, row: usize) RowState {
|
||||||
|
if (row < self.row_states.len) {
|
||||||
|
return self.row_states[row];
|
||||||
|
}
|
||||||
|
return .clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure selected row is visible
|
||||||
|
pub fn ensureVisible(self: *Self, visible_rows: usize) void {
|
||||||
|
if (self.selected_row < 0) return;
|
||||||
|
const row: usize = @intCast(self.selected_row);
|
||||||
|
|
||||||
|
if (row < self.scroll_row) {
|
||||||
|
self.scroll_row = row;
|
||||||
|
} else if (row >= self.scroll_row + visible_rows) {
|
||||||
|
self.scroll_row = row - visible_rows + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Navigation
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Move selection up
|
||||||
|
pub fn moveUp(self: *Self) void {
|
||||||
|
if (self.selected_row > 0) {
|
||||||
|
self.selected_row -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move selection down
|
||||||
|
pub fn moveDown(self: *Self) void {
|
||||||
|
if (self.selected_row < @as(i32, @intCast(self.row_count)) - 1) {
|
||||||
|
self.selected_row += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move selection left
|
||||||
|
pub fn moveLeft(self: *Self) void {
|
||||||
|
if (self.selected_col > 0) {
|
||||||
|
self.selected_col -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move selection right
|
||||||
|
pub fn moveRight(self: *Self, col_count: usize) void {
|
||||||
|
if (self.selected_col < @as(i32, @intCast(col_count)) - 1) {
|
||||||
|
self.selected_col += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move to first row
|
||||||
|
pub fn moveToFirst(self: *Self) void {
|
||||||
|
if (self.row_count > 0) {
|
||||||
|
self.selected_row = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move to last row
|
||||||
|
pub fn moveToLast(self: *Self) void {
|
||||||
|
if (self.row_count > 0) {
|
||||||
|
self.selected_row = @intCast(self.row_count - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Page up
|
||||||
|
pub fn pageUp(self: *Self, visible_rows: usize) void {
|
||||||
|
if (self.selected_row > 0) {
|
||||||
|
const jump = @as(i32, @intCast(visible_rows));
|
||||||
|
self.selected_row = @max(0, self.selected_row - jump);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Page down
|
||||||
|
pub fn pageDown(self: *Self, visible_rows: usize) void {
|
||||||
|
const max_row = @as(i32, @intCast(self.row_count)) - 1;
|
||||||
|
if (self.selected_row < max_row) {
|
||||||
|
const jump = @as(i32, @intCast(visible_rows));
|
||||||
|
self.selected_row = @min(max_row, self.selected_row + jump);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Sorting
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Set sort column and direction
|
||||||
|
pub fn setSort(self: *Self, column: i32, direction: SortDirection) void {
|
||||||
|
self.sort_column = column;
|
||||||
|
self.sort_direction = direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear sort
|
||||||
|
pub fn clearSort(self: *Self) void {
|
||||||
|
self.sort_column = -1;
|
||||||
|
self.sort_direction = .none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle sort on a column
|
||||||
|
pub fn toggleSort(self: *Self, column: usize) SortDirection {
|
||||||
|
const col_i32 = @as(i32, @intCast(column));
|
||||||
|
if (self.sort_column == col_i32) {
|
||||||
|
// Same column - toggle direction
|
||||||
|
self.sort_direction = self.sort_direction.toggle();
|
||||||
|
if (self.sort_direction == .none) {
|
||||||
|
self.sort_column = -1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Different column - start ascending
|
||||||
|
self.sort_column = col_i32;
|
||||||
|
self.sort_direction = .ascending;
|
||||||
|
}
|
||||||
|
return self.sort_direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current sort info
|
||||||
|
pub fn getSortInfo(self: Self) ?struct { column: usize, direction: SortDirection } {
|
||||||
|
if (self.sort_column < 0 or self.sort_direction == .none) return null;
|
||||||
|
return .{
|
||||||
|
.column = @intCast(self.sort_column),
|
||||||
|
.direction = self.sort_direction,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Multi-Row Selection
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Check if a row is selected
|
||||||
|
pub fn isRowSelected(self: Self, row: usize) bool {
|
||||||
|
if (row >= 1024) return false;
|
||||||
|
const byte_idx = row / 8;
|
||||||
|
const bit_idx: u3 = @intCast(row % 8);
|
||||||
|
return (self.selected_rows[byte_idx] & (@as(u8, 1) << bit_idx)) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select a single row (clears other selections)
|
||||||
|
pub fn selectSingleRow(self: *Self, row: usize) void {
|
||||||
|
self.clearRowSelection();
|
||||||
|
self.addRowToSelection(row);
|
||||||
|
self.selected_row = @intCast(row);
|
||||||
|
self.selection_anchor = @intCast(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a row to selection
|
||||||
|
pub fn addRowToSelection(self: *Self, row: usize) void {
|
||||||
|
if (row >= 1024) return;
|
||||||
|
const byte_idx = row / 8;
|
||||||
|
const bit_idx: u3 = @intCast(row % 8);
|
||||||
|
self.selected_rows[byte_idx] |= (@as(u8, 1) << bit_idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a row from selection
|
||||||
|
pub fn removeRowFromSelection(self: *Self, row: usize) void {
|
||||||
|
if (row >= 1024) return;
|
||||||
|
const byte_idx = row / 8;
|
||||||
|
const bit_idx: u3 = @intCast(row % 8);
|
||||||
|
self.selected_rows[byte_idx] &= ~(@as(u8, 1) << bit_idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle row selection
|
||||||
|
pub fn toggleRowSelection(self: *Self, row: usize) void {
|
||||||
|
if (self.isRowSelected(row)) {
|
||||||
|
self.removeRowFromSelection(row);
|
||||||
|
} else {
|
||||||
|
self.addRowToSelection(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all row selections
|
||||||
|
pub fn clearRowSelection(self: *Self) void {
|
||||||
|
@memset(&self.selected_rows, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select all rows
|
||||||
|
pub fn selectAllRows(self: *Self) void {
|
||||||
|
if (self.row_count == 0) return;
|
||||||
|
// Set bits for all rows
|
||||||
|
const full_bytes = self.row_count / 8;
|
||||||
|
const remaining_bits: u3 = @intCast(self.row_count % 8);
|
||||||
|
|
||||||
|
for (0..full_bytes) |i| {
|
||||||
|
self.selected_rows[i] = 0xFF;
|
||||||
|
}
|
||||||
|
if (remaining_bits > 0 and full_bytes < self.selected_rows.len) {
|
||||||
|
self.selected_rows[full_bytes] = (@as(u8, 1) << remaining_bits) - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select range of rows (for Shift+click)
|
||||||
|
pub fn selectRowRange(self: *Self, from: usize, to: usize) void {
|
||||||
|
const start = @min(from, to);
|
||||||
|
const end = @max(from, to);
|
||||||
|
for (start..end + 1) |row| {
|
||||||
|
self.addRowToSelection(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get count of selected rows
|
||||||
|
pub fn getSelectedRowCount(self: Self) usize {
|
||||||
|
var count: usize = 0;
|
||||||
|
for (0..@min(self.row_count, 1024)) |row| {
|
||||||
|
if (self.isRowSelected(row)) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get list of selected row indices
|
||||||
|
pub fn getSelectedRows(self: Self, buffer: []usize) usize {
|
||||||
|
var count: usize = 0;
|
||||||
|
for (0..@min(self.row_count, 1024)) |row| {
|
||||||
|
if (self.isRowSelected(row) and count < buffer.len) {
|
||||||
|
buffer[count] = row;
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Validation
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Check if a cell has a validation error
|
||||||
|
pub fn hasCellError(self: Self, row: usize, col: usize) bool {
|
||||||
|
const cell_id = @as(u32, @intCast(row)) * MAX_COLUMNS + @as(u32, @intCast(col));
|
||||||
|
for (0..self.validation_error_count) |i| {
|
||||||
|
if (self.validation_errors[i] == cell_id) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a validation error for a cell
|
||||||
|
pub fn addCellError(self: *Self, row: usize, col: usize, message: []const u8) void {
|
||||||
|
// Store message first (even if cell already has error)
|
||||||
|
const copy_len = @min(message.len, self.last_validation_message.len);
|
||||||
|
for (0..copy_len) |i| {
|
||||||
|
self.last_validation_message[i] = message[i];
|
||||||
|
}
|
||||||
|
self.last_validation_message_len = copy_len;
|
||||||
|
|
||||||
|
if (self.hasCellError(row, col)) return;
|
||||||
|
if (self.validation_error_count >= self.validation_errors.len) return;
|
||||||
|
|
||||||
|
const cell_id = @as(u32, @intCast(row)) * MAX_COLUMNS + @as(u32, @intCast(col));
|
||||||
|
self.validation_errors[self.validation_error_count] = cell_id;
|
||||||
|
self.validation_error_count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear validation error for a cell
|
||||||
|
pub fn clearCellError(self: *Self, row: usize, col: usize) void {
|
||||||
|
const cell_id = @as(u32, @intCast(row)) * MAX_COLUMNS + @as(u32, @intCast(col));
|
||||||
|
for (0..self.validation_error_count) |i| {
|
||||||
|
if (self.validation_errors[i] == cell_id) {
|
||||||
|
// Move last error to this slot
|
||||||
|
if (self.validation_error_count > 1) {
|
||||||
|
self.validation_errors[i] = self.validation_errors[self.validation_error_count - 1];
|
||||||
|
}
|
||||||
|
self.validation_error_count -= 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all validation errors
|
||||||
|
pub fn clearAllErrors(self: *Self) void {
|
||||||
|
self.validation_error_count = 0;
|
||||||
|
self.last_validation_message_len = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if any cell has validation errors
|
||||||
|
pub fn hasAnyErrors(self: Self) bool {
|
||||||
|
return self.validation_error_count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get last validation message
|
||||||
|
pub fn getLastValidationMessage(self: Self) []const u8 {
|
||||||
|
return self.last_validation_message[0..self.last_validation_message_len];
|
||||||
|
}
|
||||||
|
};
|
||||||
442
src/widgets/table/table.zig
Normal file
442
src/widgets/table/table.zig
Normal file
|
|
@ -0,0 +1,442 @@
|
||||||
|
//! Table Widget - Editable data table
|
||||||
|
//!
|
||||||
|
//! A full-featured table widget with:
|
||||||
|
//! - Keyboard navigation (arrows, Tab, Enter, Escape)
|
||||||
|
//! - In-place cell editing
|
||||||
|
//! - Row state indicators (new, modified, deleted)
|
||||||
|
//! - Column headers with optional sorting
|
||||||
|
//! - Virtualized rendering (only visible rows)
|
||||||
|
//! - Scrolling support
|
||||||
|
//! - Incremental search (type to search)
|
||||||
|
//!
|
||||||
|
//! This module re-exports types from the table/ subdirectory.
|
||||||
|
|
||||||
|
const std = @import("std");
|
||||||
|
const Context = @import("../../core/context.zig").Context;
|
||||||
|
const Layout = @import("../../core/layout.zig");
|
||||||
|
|
||||||
|
// Re-export types
|
||||||
|
pub const types = @import("types.zig");
|
||||||
|
pub const RowState = types.RowState;
|
||||||
|
pub const ColumnType = types.ColumnType;
|
||||||
|
pub const SortDirection = types.SortDirection;
|
||||||
|
pub const Column = types.Column;
|
||||||
|
pub const TableConfig = types.TableConfig;
|
||||||
|
pub const TableColors = types.TableColors;
|
||||||
|
pub const CellDataFn = types.CellDataFn;
|
||||||
|
pub const CellEditFn = types.CellEditFn;
|
||||||
|
pub const ValidationResult = types.ValidationResult;
|
||||||
|
pub const CellValidateFn = types.CellValidateFn;
|
||||||
|
pub const MAX_COLUMNS = types.MAX_COLUMNS;
|
||||||
|
pub const MAX_EDIT_BUFFER = types.MAX_EDIT_BUFFER;
|
||||||
|
|
||||||
|
// Re-export state
|
||||||
|
pub const state = @import("state.zig");
|
||||||
|
pub const TableState = state.TableState;
|
||||||
|
pub const TableResult = state.TableResult;
|
||||||
|
|
||||||
|
// Re-export keyboard (for startsWithIgnoreCase if needed)
|
||||||
|
pub const keyboard = @import("keyboard.zig");
|
||||||
|
|
||||||
|
// Import render for internal use
|
||||||
|
const render = @import("render.zig");
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Public API
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Draw a table with default options
|
||||||
|
pub fn table(
|
||||||
|
ctx: *Context,
|
||||||
|
table_state: *TableState,
|
||||||
|
columns: []const Column,
|
||||||
|
get_cell: CellDataFn,
|
||||||
|
) TableResult {
|
||||||
|
return tableEx(ctx, table_state, columns, get_cell, null, .{}, .{});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a table with full options
|
||||||
|
pub fn tableEx(
|
||||||
|
ctx: *Context,
|
||||||
|
table_state: *TableState,
|
||||||
|
columns: []const Column,
|
||||||
|
get_cell: CellDataFn,
|
||||||
|
on_edit: ?CellEditFn,
|
||||||
|
config: TableConfig,
|
||||||
|
colors: TableColors,
|
||||||
|
) TableResult {
|
||||||
|
const bounds = ctx.layout.nextRect();
|
||||||
|
return tableRectFull(ctx, bounds, table_state, columns, get_cell, on_edit, null, config, colors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a table with validation
|
||||||
|
pub fn tableWithValidation(
|
||||||
|
ctx: *Context,
|
||||||
|
table_state: *TableState,
|
||||||
|
columns: []const Column,
|
||||||
|
get_cell: CellDataFn,
|
||||||
|
on_edit: ?CellEditFn,
|
||||||
|
validate: ?CellValidateFn,
|
||||||
|
config: TableConfig,
|
||||||
|
colors: TableColors,
|
||||||
|
) TableResult {
|
||||||
|
const bounds = ctx.layout.nextRect();
|
||||||
|
return tableRectFull(ctx, bounds, table_state, columns, get_cell, on_edit, validate, config, colors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a table in a specific rectangle
|
||||||
|
pub fn tableRect(
|
||||||
|
ctx: *Context,
|
||||||
|
bounds: Layout.Rect,
|
||||||
|
table_state: *TableState,
|
||||||
|
columns: []const Column,
|
||||||
|
get_cell: CellDataFn,
|
||||||
|
on_edit: ?CellEditFn,
|
||||||
|
config: TableConfig,
|
||||||
|
colors: TableColors,
|
||||||
|
) TableResult {
|
||||||
|
return tableRectFull(ctx, bounds, table_state, columns, get_cell, on_edit, null, config, colors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a table in a specific rectangle with full options
|
||||||
|
pub fn tableRectFull(
|
||||||
|
ctx: *Context,
|
||||||
|
bounds: Layout.Rect,
|
||||||
|
table_state: *TableState,
|
||||||
|
columns: []const Column,
|
||||||
|
get_cell: CellDataFn,
|
||||||
|
on_edit: ?CellEditFn,
|
||||||
|
validate: ?CellValidateFn,
|
||||||
|
config: TableConfig,
|
||||||
|
colors: TableColors,
|
||||||
|
) TableResult {
|
||||||
|
var result = TableResult{};
|
||||||
|
|
||||||
|
if (bounds.isEmpty() or columns.len == 0) return result;
|
||||||
|
|
||||||
|
// Ensure valid selection if table has data
|
||||||
|
if (table_state.row_count > 0 and columns.len > 0) {
|
||||||
|
if (table_state.selected_row < 0) table_state.selected_row = 0;
|
||||||
|
if (table_state.selected_col < 0) table_state.selected_col = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique ID for this table based on state address
|
||||||
|
const widget_id: u64 = @intFromPtr(table_state);
|
||||||
|
|
||||||
|
// Register as focusable
|
||||||
|
ctx.registerFocusable(widget_id);
|
||||||
|
|
||||||
|
// Check mouse click for focus
|
||||||
|
const mouse = ctx.input.mousePos();
|
||||||
|
if (bounds.contains(mouse.x, mouse.y) and ctx.input.mousePressed(.left)) {
|
||||||
|
ctx.requestFocus(widget_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have focus
|
||||||
|
const has_focus = ctx.hasFocus(widget_id);
|
||||||
|
table_state.focused = has_focus;
|
||||||
|
|
||||||
|
// Calculate dimensions
|
||||||
|
const state_col_w: u32 = if (config.show_state_indicators) config.state_indicator_width else 0;
|
||||||
|
const header_h: u32 = if (config.show_headers) config.header_height else 0;
|
||||||
|
const content_h = bounds.h -| header_h;
|
||||||
|
const visible_rows: usize = @intCast(content_h / config.row_height);
|
||||||
|
|
||||||
|
// Draw header if enabled
|
||||||
|
if (config.show_headers) {
|
||||||
|
const header_result = render.drawHeader(ctx, bounds, table_state, columns, state_col_w, config, colors);
|
||||||
|
if (header_result.sort_changed) {
|
||||||
|
result.sort_changed = true;
|
||||||
|
result.sort_column = header_result.sort_column;
|
||||||
|
result.sort_direction = header_result.sort_direction;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate visible row range
|
||||||
|
const first_visible = table_state.scroll_row;
|
||||||
|
const last_visible = @min(first_visible + visible_rows, table_state.row_count);
|
||||||
|
|
||||||
|
// Draw visible rows
|
||||||
|
for (first_visible..last_visible) |row| {
|
||||||
|
const row_y = bounds.y + @as(i32, @intCast(header_h)) +
|
||||||
|
@as(i32, @intCast((row - first_visible) * config.row_height));
|
||||||
|
|
||||||
|
const row_bounds = Layout.Rect.init(
|
||||||
|
bounds.x,
|
||||||
|
row_y,
|
||||||
|
bounds.w,
|
||||||
|
config.row_height,
|
||||||
|
);
|
||||||
|
|
||||||
|
const row_result = render.drawRow(
|
||||||
|
ctx,
|
||||||
|
row_bounds,
|
||||||
|
table_state,
|
||||||
|
row,
|
||||||
|
columns,
|
||||||
|
get_cell,
|
||||||
|
on_edit,
|
||||||
|
validate,
|
||||||
|
state_col_w,
|
||||||
|
config,
|
||||||
|
colors,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Merge results
|
||||||
|
if (row_result.selection_changed) result.selection_changed = true;
|
||||||
|
if (row_result.edit_started) result.edit_started = true;
|
||||||
|
if (row_result.edit_ended) result.edit_ended = true;
|
||||||
|
if (row_result.cell_edited) result.cell_edited = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw scrollbar if needed
|
||||||
|
if (table_state.row_count > visible_rows) {
|
||||||
|
render.drawScrollbar(ctx, bounds, table_state, visible_rows, config, colors);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle keyboard if focused and not editing
|
||||||
|
if (table_state.focused and config.keyboard_nav and !table_state.editing) {
|
||||||
|
keyboard.handleKeyboard(ctx, table_state, columns.len, visible_rows, get_cell, on_edit, validate, config, &result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure selection is visible after navigation
|
||||||
|
table_state.ensureVisible(visible_rows);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
fn testGetCell(row: usize, col: usize) []const u8 {
|
||||||
|
_ = row;
|
||||||
|
_ = col;
|
||||||
|
return "test";
|
||||||
|
}
|
||||||
|
|
||||||
|
test "TableState init" {
|
||||||
|
var table_state = TableState.init();
|
||||||
|
try std.testing.expect(table_state.selectedCell() == null);
|
||||||
|
|
||||||
|
table_state.selectCell(2, 3);
|
||||||
|
const cell = table_state.selectedCell().?;
|
||||||
|
try std.testing.expectEqual(@as(usize, 2), cell.row);
|
||||||
|
try std.testing.expectEqual(@as(usize, 3), cell.col);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "TableState selection" {
|
||||||
|
var table_state = TableState.init();
|
||||||
|
table_state.setRowCount(10);
|
||||||
|
|
||||||
|
table_state.selectCell(5, 2);
|
||||||
|
try std.testing.expectEqual(@as(i32, 5), table_state.selected_row);
|
||||||
|
try std.testing.expectEqual(@as(i32, 2), table_state.selected_col);
|
||||||
|
|
||||||
|
table_state.clearSelection();
|
||||||
|
try std.testing.expect(table_state.selectedCell() == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "TableState row states" {
|
||||||
|
var table_state = TableState.init();
|
||||||
|
table_state.setRowCount(5);
|
||||||
|
|
||||||
|
try std.testing.expectEqual(RowState.clean, table_state.getRowState(0));
|
||||||
|
|
||||||
|
table_state.markNew(0);
|
||||||
|
try std.testing.expectEqual(RowState.new, table_state.getRowState(0));
|
||||||
|
|
||||||
|
table_state.markModified(1);
|
||||||
|
try std.testing.expectEqual(RowState.modified, table_state.getRowState(1));
|
||||||
|
|
||||||
|
table_state.markDeleted(2);
|
||||||
|
try std.testing.expectEqual(RowState.deleted, table_state.getRowState(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "TableState editing" {
|
||||||
|
var table_state = TableState.init();
|
||||||
|
|
||||||
|
try std.testing.expect(!table_state.editing);
|
||||||
|
|
||||||
|
table_state.startEditing("initial");
|
||||||
|
try std.testing.expect(table_state.editing);
|
||||||
|
try std.testing.expectEqualStrings("initial", table_state.getEditText());
|
||||||
|
|
||||||
|
table_state.stopEditing();
|
||||||
|
try std.testing.expect(!table_state.editing);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "table generates commands" {
|
||||||
|
var ctx = try Context.init(std.testing.allocator, 800, 600);
|
||||||
|
defer ctx.deinit();
|
||||||
|
|
||||||
|
var table_state = TableState.init();
|
||||||
|
table_state.setRowCount(5);
|
||||||
|
|
||||||
|
const columns = [_]Column{
|
||||||
|
.{ .name = "Name", .width = 150 },
|
||||||
|
.{ .name = "Value", .width = 100 },
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.beginFrame();
|
||||||
|
ctx.layout.row_height = 200;
|
||||||
|
|
||||||
|
_ = table(&ctx, &table_state, &columns, testGetCell);
|
||||||
|
|
||||||
|
// Should generate many commands (background, headers, rows, etc.)
|
||||||
|
try std.testing.expect(ctx.commands.items.len > 10);
|
||||||
|
|
||||||
|
ctx.endFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
test "TableState sorting" {
|
||||||
|
var table_state = TableState.init();
|
||||||
|
|
||||||
|
// Initially no sort
|
||||||
|
try std.testing.expect(table_state.getSortInfo() == null);
|
||||||
|
|
||||||
|
// Toggle sort on column 0 -> ascending
|
||||||
|
const dir1 = table_state.toggleSort(0);
|
||||||
|
try std.testing.expectEqual(SortDirection.ascending, dir1);
|
||||||
|
try std.testing.expectEqual(@as(i32, 0), table_state.sort_column);
|
||||||
|
try std.testing.expectEqual(SortDirection.ascending, table_state.sort_direction);
|
||||||
|
|
||||||
|
// Toggle again -> descending
|
||||||
|
const dir2 = table_state.toggleSort(0);
|
||||||
|
try std.testing.expectEqual(SortDirection.descending, dir2);
|
||||||
|
|
||||||
|
// Toggle again -> none (clear)
|
||||||
|
const dir3 = table_state.toggleSort(0);
|
||||||
|
try std.testing.expectEqual(SortDirection.none, dir3);
|
||||||
|
try std.testing.expectEqual(@as(i32, -1), table_state.sort_column);
|
||||||
|
|
||||||
|
// Sort different column
|
||||||
|
_ = table_state.toggleSort(2);
|
||||||
|
try std.testing.expectEqual(@as(i32, 2), table_state.sort_column);
|
||||||
|
try std.testing.expectEqual(SortDirection.ascending, table_state.sort_direction);
|
||||||
|
|
||||||
|
// Get sort info
|
||||||
|
const info = table_state.getSortInfo().?;
|
||||||
|
try std.testing.expectEqual(@as(usize, 2), info.column);
|
||||||
|
try std.testing.expectEqual(SortDirection.ascending, info.direction);
|
||||||
|
|
||||||
|
// Clear sort
|
||||||
|
table_state.clearSort();
|
||||||
|
try std.testing.expect(table_state.getSortInfo() == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "SortDirection toggle" {
|
||||||
|
try std.testing.expectEqual(SortDirection.ascending, SortDirection.none.toggle());
|
||||||
|
try std.testing.expectEqual(SortDirection.descending, SortDirection.ascending.toggle());
|
||||||
|
try std.testing.expectEqual(SortDirection.none, SortDirection.descending.toggle());
|
||||||
|
}
|
||||||
|
|
||||||
|
test "TableState multi-row selection" {
|
||||||
|
var table_state = TableState.init();
|
||||||
|
table_state.setRowCount(10);
|
||||||
|
|
||||||
|
// Initially no selection
|
||||||
|
try std.testing.expect(!table_state.isRowSelected(0));
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), table_state.getSelectedRowCount());
|
||||||
|
|
||||||
|
// Add rows to selection
|
||||||
|
table_state.addRowToSelection(3);
|
||||||
|
table_state.addRowToSelection(5);
|
||||||
|
table_state.addRowToSelection(7);
|
||||||
|
|
||||||
|
try std.testing.expect(table_state.isRowSelected(3));
|
||||||
|
try std.testing.expect(table_state.isRowSelected(5));
|
||||||
|
try std.testing.expect(table_state.isRowSelected(7));
|
||||||
|
try std.testing.expect(!table_state.isRowSelected(4));
|
||||||
|
try std.testing.expectEqual(@as(usize, 3), table_state.getSelectedRowCount());
|
||||||
|
|
||||||
|
// Remove from selection
|
||||||
|
table_state.removeRowFromSelection(7);
|
||||||
|
try std.testing.expect(!table_state.isRowSelected(7));
|
||||||
|
|
||||||
|
// Get selected rows
|
||||||
|
var buffer: [10]usize = undefined;
|
||||||
|
const count = table_state.getSelectedRows(&buffer);
|
||||||
|
try std.testing.expectEqual(@as(usize, 2), count);
|
||||||
|
|
||||||
|
// Clear selection
|
||||||
|
table_state.clearRowSelection();
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), table_state.getSelectedRowCount());
|
||||||
|
|
||||||
|
// Select all
|
||||||
|
table_state.selectAllRows();
|
||||||
|
try std.testing.expectEqual(@as(usize, 10), table_state.getSelectedRowCount());
|
||||||
|
|
||||||
|
// Select range
|
||||||
|
table_state.clearRowSelection();
|
||||||
|
table_state.selectRowRange(2, 5);
|
||||||
|
try std.testing.expect(!table_state.isRowSelected(1));
|
||||||
|
try std.testing.expect(table_state.isRowSelected(2));
|
||||||
|
try std.testing.expect(table_state.isRowSelected(3));
|
||||||
|
try std.testing.expect(table_state.isRowSelected(4));
|
||||||
|
try std.testing.expect(table_state.isRowSelected(5));
|
||||||
|
try std.testing.expect(!table_state.isRowSelected(6));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "TableState validation" {
|
||||||
|
var table_state = TableState.init();
|
||||||
|
table_state.setRowCount(5);
|
||||||
|
|
||||||
|
// Initially no errors
|
||||||
|
try std.testing.expect(!table_state.hasAnyErrors());
|
||||||
|
try std.testing.expect(!table_state.hasCellError(0, 0));
|
||||||
|
|
||||||
|
// Add error
|
||||||
|
table_state.addCellError(0, 0, "Required field");
|
||||||
|
try std.testing.expect(table_state.hasAnyErrors());
|
||||||
|
try std.testing.expect(table_state.hasCellError(0, 0));
|
||||||
|
try std.testing.expectEqual(@as(usize, 14), table_state.last_validation_message_len);
|
||||||
|
|
||||||
|
// Add another error
|
||||||
|
table_state.addCellError(1, 2, "Invalid number");
|
||||||
|
try std.testing.expect(table_state.hasCellError(1, 2));
|
||||||
|
try std.testing.expectEqual(@as(usize, 2), table_state.validation_error_count);
|
||||||
|
|
||||||
|
// Clear specific error
|
||||||
|
table_state.clearCellError(0, 0);
|
||||||
|
try std.testing.expect(!table_state.hasCellError(0, 0));
|
||||||
|
try std.testing.expect(table_state.hasCellError(1, 2));
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), table_state.validation_error_count);
|
||||||
|
|
||||||
|
// Clear all errors
|
||||||
|
table_state.clearAllErrors();
|
||||||
|
try std.testing.expect(!table_state.hasAnyErrors());
|
||||||
|
}
|
||||||
|
|
||||||
|
test "startsWithIgnoreCase" {
|
||||||
|
try std.testing.expect(keyboard.startsWithIgnoreCase("Hello World", "hello"));
|
||||||
|
try std.testing.expect(keyboard.startsWithIgnoreCase("Hello World", "HELLO"));
|
||||||
|
try std.testing.expect(keyboard.startsWithIgnoreCase("Hello World", "Hello"));
|
||||||
|
try std.testing.expect(keyboard.startsWithIgnoreCase("ABC", "abc"));
|
||||||
|
try std.testing.expect(keyboard.startsWithIgnoreCase("abc", "ABC"));
|
||||||
|
try std.testing.expect(!keyboard.startsWithIgnoreCase("Hello", "World"));
|
||||||
|
try std.testing.expect(!keyboard.startsWithIgnoreCase("Hi", "Hello"));
|
||||||
|
try std.testing.expect(keyboard.startsWithIgnoreCase("Test", ""));
|
||||||
|
try std.testing.expect(keyboard.startsWithIgnoreCase("", ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "TableState incremental search" {
|
||||||
|
var table_state = TableState.init();
|
||||||
|
|
||||||
|
// Add first char
|
||||||
|
const search1 = table_state.addSearchChar('a', 1000);
|
||||||
|
try std.testing.expectEqualStrings("a", search1);
|
||||||
|
try std.testing.expectEqualStrings("a", table_state.getSearchTerm());
|
||||||
|
|
||||||
|
// Add second char within timeout
|
||||||
|
const search2 = table_state.addSearchChar('b', 1500);
|
||||||
|
try std.testing.expectEqualStrings("ab", search2);
|
||||||
|
|
||||||
|
// Timeout resets search
|
||||||
|
const search3 = table_state.addSearchChar('c', 3000);
|
||||||
|
try std.testing.expectEqualStrings("c", search3);
|
||||||
|
|
||||||
|
// Clear search
|
||||||
|
table_state.clearSearch();
|
||||||
|
try std.testing.expectEqualStrings("", table_state.getSearchTerm());
|
||||||
|
}
|
||||||
158
src/widgets/table/types.zig
Normal file
158
src/widgets/table/types.zig
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
//! Table Types - Enums, configs, and column definitions
|
||||||
|
//!
|
||||||
|
//! Part of the table widget module.
|
||||||
|
|
||||||
|
const Style = @import("../../core/style.zig");
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Enums
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Row state for dirty tracking
|
||||||
|
pub const RowState = enum {
|
||||||
|
/// Unchanged from original
|
||||||
|
clean,
|
||||||
|
/// Newly added row
|
||||||
|
new,
|
||||||
|
/// Modified row
|
||||||
|
modified,
|
||||||
|
/// Marked for deletion
|
||||||
|
deleted,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Column type for formatting/validation
|
||||||
|
pub const ColumnType = enum {
|
||||||
|
text,
|
||||||
|
number,
|
||||||
|
money,
|
||||||
|
date,
|
||||||
|
select,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Sort direction
|
||||||
|
pub const SortDirection = enum {
|
||||||
|
none,
|
||||||
|
ascending,
|
||||||
|
descending,
|
||||||
|
|
||||||
|
/// Toggle to next direction
|
||||||
|
pub fn toggle(self: SortDirection) SortDirection {
|
||||||
|
return switch (self) {
|
||||||
|
.none => .ascending,
|
||||||
|
.ascending => .descending,
|
||||||
|
.descending => .none,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Column Definition
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Column definition
|
||||||
|
pub const Column = struct {
|
||||||
|
/// Column header text
|
||||||
|
name: []const u8,
|
||||||
|
/// Column width in pixels
|
||||||
|
width: u32,
|
||||||
|
/// Column type for formatting
|
||||||
|
column_type: ColumnType = .text,
|
||||||
|
/// Whether cells in this column are editable
|
||||||
|
editable: bool = true,
|
||||||
|
/// Minimum width when resizing
|
||||||
|
min_width: u32 = 40,
|
||||||
|
/// Whether this column is sortable
|
||||||
|
sortable: bool = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Configuration
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Table configuration
|
||||||
|
pub const TableConfig = struct {
|
||||||
|
/// Height of header row
|
||||||
|
header_height: u32 = 28,
|
||||||
|
/// Height of each data row
|
||||||
|
row_height: u32 = 24,
|
||||||
|
/// Show row state indicators
|
||||||
|
show_state_indicators: bool = true,
|
||||||
|
/// Width of state indicator column
|
||||||
|
state_indicator_width: u32 = 24,
|
||||||
|
/// Allow keyboard navigation (arrows, Page Up/Down, etc.)
|
||||||
|
keyboard_nav: bool = true,
|
||||||
|
/// Handle Tab key internally (navigate between cells)
|
||||||
|
/// Set to false if you want Tab to be handled by external focus system
|
||||||
|
handle_tab: bool = true,
|
||||||
|
/// Allow cell editing
|
||||||
|
allow_edit: bool = true,
|
||||||
|
/// Show column headers
|
||||||
|
show_headers: bool = true,
|
||||||
|
/// Alternating row colors
|
||||||
|
alternating_rows: bool = true,
|
||||||
|
/// Allow column sorting
|
||||||
|
allow_sorting: bool = true,
|
||||||
|
/// Allow row operations (Ctrl+N, Delete, etc.)
|
||||||
|
allow_row_operations: bool = true,
|
||||||
|
/// Allow multi-row selection
|
||||||
|
allow_multi_select: bool = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Table colors
|
||||||
|
pub const TableColors = struct {
|
||||||
|
header_bg: Style.Color = Style.Color.rgb(50, 50, 50),
|
||||||
|
header_fg: Style.Color = Style.Color.rgb(220, 220, 220),
|
||||||
|
header_hover: Style.Color = Style.Color.rgb(60, 60, 65),
|
||||||
|
header_sorted: Style.Color = Style.Color.rgb(55, 55, 60),
|
||||||
|
sort_indicator: Style.Color = Style.Color.primary,
|
||||||
|
row_even: Style.Color = Style.Color.rgb(35, 35, 35),
|
||||||
|
row_odd: Style.Color = Style.Color.rgb(40, 40, 40),
|
||||||
|
row_hover: Style.Color = Style.Color.rgb(50, 50, 60),
|
||||||
|
row_selected: Style.Color = Style.Color.rgb(66, 135, 245),
|
||||||
|
cell_editing: Style.Color = Style.Color.rgb(60, 60, 80),
|
||||||
|
cell_text: Style.Color = Style.Color.rgb(220, 220, 220),
|
||||||
|
cell_text_selected: Style.Color = Style.Color.rgb(255, 255, 255),
|
||||||
|
border: Style.Color = Style.Color.rgb(60, 60, 60),
|
||||||
|
state_new: Style.Color = Style.Color.rgb(76, 175, 80),
|
||||||
|
state_modified: Style.Color = Style.Color.rgb(255, 152, 0),
|
||||||
|
state_deleted: Style.Color = Style.Color.rgb(244, 67, 54),
|
||||||
|
/// Validation error cell background
|
||||||
|
validation_error_bg: Style.Color = Style.Color.rgb(80, 40, 40),
|
||||||
|
/// Validation error border
|
||||||
|
validation_error_border: Style.Color = Style.Color.rgb(200, 60, 60),
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Callback Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Callback to get cell data
|
||||||
|
pub const CellDataFn = *const fn (row: usize, col: usize) []const u8;
|
||||||
|
|
||||||
|
/// Callback when cell is edited
|
||||||
|
pub const CellEditFn = *const fn (row: usize, col: usize, value: []const u8) void;
|
||||||
|
|
||||||
|
/// Validation result
|
||||||
|
pub const ValidationResult = struct {
|
||||||
|
valid: bool,
|
||||||
|
message: []const u8 = "",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Callback to validate cell value
|
||||||
|
pub const CellValidateFn = *const fn (row: usize, col: usize, value: []const u8) ValidationResult;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Constants
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Maximum columns supported
|
||||||
|
pub const MAX_COLUMNS = 32;
|
||||||
|
|
||||||
|
/// Maximum edit buffer size
|
||||||
|
pub const MAX_EDIT_BUFFER = 256;
|
||||||
|
|
||||||
|
/// Maximum validation errors tracked
|
||||||
|
pub const MAX_VALIDATION_ERRORS = 64;
|
||||||
|
|
||||||
|
/// Maximum multi-select rows
|
||||||
|
pub const MAX_SELECTED_ROWS = 256;
|
||||||
|
|
@ -14,7 +14,7 @@ pub const text_input = @import("text_input.zig");
|
||||||
pub const checkbox = @import("checkbox.zig");
|
pub const checkbox = @import("checkbox.zig");
|
||||||
pub const select = @import("select.zig");
|
pub const select = @import("select.zig");
|
||||||
pub const list = @import("list.zig");
|
pub const list = @import("list.zig");
|
||||||
pub const table = @import("table.zig");
|
pub const table = @import("table/table.zig");
|
||||||
pub const split = @import("split.zig");
|
pub const split = @import("split.zig");
|
||||||
pub const panel = @import("panel.zig");
|
pub const panel = @import("panel.zig");
|
||||||
pub const modal = @import("modal.zig");
|
pub const modal = @import("modal.zig");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue