diff --git a/src/widgets/table.zig b/src/widgets/table.zig deleted file mode 100644 index 48161e4..0000000 --- a/src/widgets/table.zig +++ /dev/null @@ -1,1769 +0,0 @@ -//! 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 - -const std = @import("std"); -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 Input = @import("../core/input.zig"); -const text_input = @import("text_input.zig"); - -// ============================================================================= -// Types -// ============================================================================= - -/// 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 -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, -}; - -/// 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), -}; - -/// 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 -// ============================================================================= - -/// Maximum columns supported -pub const MAX_COLUMNS = 32; -/// Maximum edit buffer size -pub const MAX_EDIT_BUFFER = 256; - -/// 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; - // Reset states for new rows - for (0..@min(count, self.row_states.len)) |i| { - if (self.row_states[i] == .clean) { - // Keep existing state - } - } - } - - /// 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]; - } -}; - -// ============================================================================= -// Table Widget -// ============================================================================= - -/// Cell data provider callback -pub const CellDataFn = *const fn (row: usize, col: usize) []const u8; - -/// Cell edit callback (called when edit is committed) -pub const CellEditFn = *const fn (row: usize, col: usize, new_value: []const u8) void; - -/// Validation result -pub const ValidationResult = struct { - /// Whether the value is valid - valid: bool = true, - /// Error message (if invalid) - message: []const u8 = "", -}; - -/// Cell validation callback -pub const CellValidateFn = *const fn (row: usize, col: usize, value: []const u8) ValidationResult; - -/// Draw a table -pub fn table( - ctx: *Context, - state: *TableState, - columns: []const Column, - get_cell: CellDataFn, -) TableResult { - return tableEx(ctx, state, columns, get_cell, null, .{}, .{}); -} - -/// Draw a table with full options -pub fn tableEx( - ctx: *Context, - 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, state, columns, get_cell, on_edit, null, config, colors); -} - -/// Draw a table with validation -pub fn tableWithValidation( - ctx: *Context, - 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, state, columns, get_cell, on_edit, validate, config, colors); -} - -/// Draw a table in a specific rectangle -pub fn tableRect( - ctx: *Context, - bounds: Layout.Rect, - state: *TableState, - columns: []const Column, - get_cell: CellDataFn, - on_edit: ?CellEditFn, - config: TableConfig, - colors: TableColors, -) TableResult { - return tableRectFull(ctx, bounds, 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, - 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 - // Without this, selected_row/col stay at -1 until user clicks, - // which breaks keyboard navigation and selectedCell() returns null - if (state.row_count > 0 and columns.len > 0) { - if (state.selected_row < 0) state.selected_row = 0; - if (state.selected_col < 0) state.selected_col = 0; - } - - // Generate unique ID for this table based on state address - const widget_id: u64 = @intFromPtr(state); - - // Register as focusable in the active focus group - ctx.registerFocusable(widget_id); - - const mouse = ctx.input.mousePos(); - const table_hovered = bounds.contains(mouse.x, mouse.y); - - // Click for focus - use the new focus system - if (table_hovered and ctx.input.mousePressed(.left)) { - ctx.requestFocus(widget_id); - } - - // Check if this table has focus (via focus group system) - const has_focus = ctx.hasFocus(widget_id); - state.focused = has_focus; - - // Calculate dimensions - const header_h = if (config.show_headers) config.header_height else 0; - const state_col_w = if (config.show_state_indicators) config.state_indicator_width else 0; - - // Calculate total column width - var total_col_width: u32 = state_col_w; - for (columns) |col| { - total_col_width += col.width; - } - - // Data area - const data_area = Layout.Rect.init( - bounds.x, - bounds.y + @as(i32, @intCast(header_h)), - bounds.w, - bounds.h -| header_h, - ); - - // Visible rows - const visible_rows = data_area.h / config.row_height; - - // Clamp scroll - if (state.row_count <= visible_rows) { - state.scroll_row = 0; - } else if (state.scroll_row > state.row_count - visible_rows) { - state.scroll_row = state.row_count - visible_rows; - } - - // Handle scroll wheel - if (table_hovered) { - if (ctx.input.scroll_y < 0 and state.scroll_row > 0) { - state.scroll_row -= 1; - } else if (ctx.input.scroll_y > 0 and state.scroll_row < state.row_count -| visible_rows) { - state.scroll_row += 1; - } - } - - // Draw background - ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, colors.row_even)); - - // Draw border - const border_color = if (state.focused) Style.Color.primary else colors.border; - ctx.pushCommand(Command.rectOutline(bounds.x, bounds.y, bounds.w, bounds.h, border_color)); - - // Clip to table bounds - ctx.pushCommand(Command.clip(bounds.x, bounds.y, bounds.w, bounds.h)); - - // Draw header - if (config.show_headers) { - const header_result = drawHeader(ctx, bounds, 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; - } - } - - // Draw rows - const end_row = @min(state.scroll_row + visible_rows + 1, state.row_count); - var row_y = data_area.y; - - for (state.scroll_row..end_row) |row| { - if (row_y >= data_area.bottom()) break; - - const row_bounds = Layout.Rect.init( - data_area.x, - row_y, - data_area.w, - config.row_height, - ); - - const row_result = drawRow( - ctx, - row_bounds, - state, - row, - columns, - get_cell, - on_edit, - validate, - state_col_w, - config, - colors, - ); - - if (row_result.selection_changed) result.selection_changed = true; - if (row_result.cell_edited) result.cell_edited = true; - if (row_result.edit_started) result.edit_started = true; - if (row_result.edit_ended) result.edit_ended = true; - if (row_result.validation_failed) { - result.validation_failed = true; - result.validation_message = row_result.validation_message; - } - - row_y += @as(i32, @intCast(config.row_height)); - } - - // Draw scrollbar if needed - if (state.row_count > visible_rows) { - drawScrollbar(ctx, bounds, state, visible_rows, config, colors); - } - - // End clip - ctx.pushCommand(Command.clipEnd()); - - // Handle keyboard if focused and not editing - if (state.focused and config.keyboard_nav and !state.editing) { - handleKeyboard(ctx, state, columns.len, visible_rows, get_cell, on_edit, validate, config, &result); - } - - // Ensure selection is visible after navigation - state.ensureVisible(visible_rows); - - return result; -} - -// ============================================================================= -// Drawing Helpers -// ============================================================================= - -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; -} - -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; -} - -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)); - } -} - -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, - )); - } -} - -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 -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; -} - -// ============================================================================= -// Tests -// ============================================================================= - -fn testGetCell(row: usize, col: usize) []const u8 { - _ = row; - _ = col; - return "test"; -} - -test "TableState init" { - var state = TableState.init(); - try std.testing.expect(state.selectedCell() == null); - - state.selectCell(2, 3); - const sel = state.selectedCell().?; - try std.testing.expectEqual(@as(usize, 2), sel.row); - try std.testing.expectEqual(@as(usize, 3), sel.col); -} - -test "TableState navigation" { - var state = TableState.init(); - state.setRowCount(10); - state.selectCell(5, 2); - - state.moveUp(); - try std.testing.expectEqual(@as(i32, 4), state.selected_row); - - state.moveDown(); - try std.testing.expectEqual(@as(i32, 5), state.selected_row); - - state.moveToFirst(); - try std.testing.expectEqual(@as(i32, 0), state.selected_row); - - state.moveToLast(); - try std.testing.expectEqual(@as(i32, 9), state.selected_row); -} - -test "TableState row states" { - var state = TableState.init(); - state.setRowCount(5); - - try std.testing.expectEqual(RowState.clean, state.getRowState(0)); - - state.markNew(0); - try std.testing.expectEqual(RowState.new, state.getRowState(0)); - - state.markModified(1); - try std.testing.expectEqual(RowState.modified, state.getRowState(1)); - - state.markDeleted(2); - try std.testing.expectEqual(RowState.deleted, state.getRowState(2)); -} - -test "TableState editing" { - var state = TableState.init(); - - try std.testing.expect(!state.editing); - - state.startEditing("initial"); - try std.testing.expect(state.editing); - try std.testing.expectEqualStrings("initial", state.getEditText()); - - state.stopEditing(); - try std.testing.expect(!state.editing); -} - -test "table generates commands" { - var ctx = try Context.init(std.testing.allocator, 800, 600); - defer ctx.deinit(); - - var state = TableState.init(); - state.setRowCount(5); - - const columns = [_]Column{ - .{ .name = "Name", .width = 150 }, - .{ .name = "Value", .width = 100 }, - }; - - ctx.beginFrame(); - ctx.layout.row_height = 200; - - _ = table(&ctx, &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 state = TableState.init(); - - // Initially no sort - try std.testing.expect(state.getSortInfo() == null); - - // Toggle sort on column 0 -> ascending - const dir1 = state.toggleSort(0); - try std.testing.expectEqual(SortDirection.ascending, dir1); - try std.testing.expectEqual(@as(i32, 0), state.sort_column); - try std.testing.expectEqual(SortDirection.ascending, state.sort_direction); - - // Toggle again -> descending - const dir2 = state.toggleSort(0); - try std.testing.expectEqual(SortDirection.descending, dir2); - - // Toggle again -> none (clear) - const dir3 = state.toggleSort(0); - try std.testing.expectEqual(SortDirection.none, dir3); - try std.testing.expectEqual(@as(i32, -1), state.sort_column); - - // Sort different column - _ = state.toggleSort(2); - try std.testing.expectEqual(@as(i32, 2), state.sort_column); - try std.testing.expectEqual(SortDirection.ascending, state.sort_direction); - - // Get sort info - const info = state.getSortInfo().?; - try std.testing.expectEqual(@as(usize, 2), info.column); - try std.testing.expectEqual(SortDirection.ascending, info.direction); - - // Clear sort - state.clearSort(); - try std.testing.expect(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 state = TableState.init(); - state.setRowCount(10); - - // Initially no selection - try std.testing.expect(!state.isRowSelected(0)); - try std.testing.expectEqual(@as(usize, 0), state.getSelectedRowCount()); - - // Select single row - state.selectSingleRow(3); - try std.testing.expect(state.isRowSelected(3)); - try std.testing.expectEqual(@as(usize, 1), state.getSelectedRowCount()); - - // Add more rows to selection - state.addRowToSelection(5); - state.addRowToSelection(7); - try std.testing.expect(state.isRowSelected(3)); - try std.testing.expect(state.isRowSelected(5)); - try std.testing.expect(state.isRowSelected(7)); - try std.testing.expectEqual(@as(usize, 3), state.getSelectedRowCount()); - - // Toggle selection - state.toggleRowSelection(5); // Remove - try std.testing.expect(!state.isRowSelected(5)); - state.toggleRowSelection(5); // Add back - try std.testing.expect(state.isRowSelected(5)); - - // Remove from selection - state.removeRowFromSelection(7); - try std.testing.expect(!state.isRowSelected(7)); - - // Get selected rows - var buffer: [10]usize = undefined; - const count = state.getSelectedRows(&buffer); - try std.testing.expectEqual(@as(usize, 2), count); - - // Clear selection - state.clearRowSelection(); - try std.testing.expectEqual(@as(usize, 0), state.getSelectedRowCount()); - - // Select all - state.selectAllRows(); - try std.testing.expectEqual(@as(usize, 10), state.getSelectedRowCount()); - - // Select range - state.clearRowSelection(); - state.selectRowRange(2, 5); - try std.testing.expect(!state.isRowSelected(1)); - try std.testing.expect(state.isRowSelected(2)); - try std.testing.expect(state.isRowSelected(3)); - try std.testing.expect(state.isRowSelected(4)); - try std.testing.expect(state.isRowSelected(5)); - try std.testing.expect(!state.isRowSelected(6)); -} - -test "TableState validation" { - var state = TableState.init(); - state.setRowCount(5); - - // Initially no errors - try std.testing.expect(!state.hasAnyErrors()); - try std.testing.expect(!state.hasCellError(0, 0)); - - // Add error - state.addCellError(0, 0, "Required field"); - try std.testing.expect(state.hasAnyErrors()); - try std.testing.expect(state.hasCellError(0, 0)); - try std.testing.expectEqual(@as(usize, 14), state.last_validation_message_len); - - // Add another error - state.addCellError(1, 2, "Invalid number"); - try std.testing.expect(state.hasCellError(1, 2)); - try std.testing.expectEqual(@as(usize, 2), state.validation_error_count); - - // Clear specific error - state.clearCellError(0, 0); - try std.testing.expect(!state.hasCellError(0, 0)); - try std.testing.expect(state.hasCellError(1, 2)); - try std.testing.expectEqual(@as(usize, 1), state.validation_error_count); - - // Clear all errors - state.clearAllErrors(); - try std.testing.expect(!state.hasAnyErrors()); -} - -test "startsWithIgnoreCase" { - try std.testing.expect(startsWithIgnoreCase("Hello World", "hello")); - try std.testing.expect(startsWithIgnoreCase("Hello World", "HELLO")); - try std.testing.expect(startsWithIgnoreCase("Hello World", "Hello")); - try std.testing.expect(startsWithIgnoreCase("ABC", "abc")); - try std.testing.expect(startsWithIgnoreCase("abc", "ABC")); - try std.testing.expect(!startsWithIgnoreCase("Hello", "World")); - try std.testing.expect(!startsWithIgnoreCase("Hi", "Hello")); - try std.testing.expect(startsWithIgnoreCase("Test", "")); - try std.testing.expect(startsWithIgnoreCase("", "")); -} - -test "TableState incremental search" { - var state = TableState.init(); - - // Add first char - const search1 = state.addSearchChar('a', 1000); - try std.testing.expectEqualStrings("a", search1); - try std.testing.expectEqualStrings("a", state.getSearchTerm()); - - // Add second char within timeout - const search2 = state.addSearchChar('b', 1500); - try std.testing.expectEqualStrings("ab", search2); - - // Timeout resets search - const search3 = state.addSearchChar('c', 3000); - try std.testing.expectEqualStrings("c", search3); - - // Clear search - state.clearSearch(); - try std.testing.expectEqualStrings("", state.getSearchTerm()); -} diff --git a/src/widgets/table/keyboard.zig b/src/widgets/table/keyboard.zig new file mode 100644 index 0000000..b2b7ea2 --- /dev/null +++ b/src/widgets/table/keyboard.zig @@ -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; +} diff --git a/src/widgets/table/render.zig b/src/widgets/table/render.zig new file mode 100644 index 0000000..4ed01da --- /dev/null +++ b/src/widgets/table/render.zig @@ -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, + )); + } +} diff --git a/src/widgets/table/state.zig b/src/widgets/table/state.zig new file mode 100644 index 0000000..80fd92b --- /dev/null +++ b/src/widgets/table/state.zig @@ -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]; + } +}; diff --git a/src/widgets/table/table.zig b/src/widgets/table/table.zig new file mode 100644 index 0000000..21e109b --- /dev/null +++ b/src/widgets/table/table.zig @@ -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()); +} diff --git a/src/widgets/table/types.zig b/src/widgets/table/types.zig new file mode 100644 index 0000000..4953e7b --- /dev/null +++ b/src/widgets/table/types.zig @@ -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; diff --git a/src/widgets/widgets.zig b/src/widgets/widgets.zig index 6ab6aca..8bd5c6d 100644 --- a/src/widgets/widgets.zig +++ b/src/widgets/widgets.zig @@ -14,7 +14,7 @@ pub const text_input = @import("text_input.zig"); pub const checkbox = @import("checkbox.zig"); pub const select = @import("select.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 panel = @import("panel.zig"); pub const modal = @import("modal.zig");