diff --git a/src/widgets/table.zig b/src/widgets/table.zig deleted file mode 100644 index e6e2d58..0000000 --- a/src/widgets/table.zig +++ /dev/null @@ -1,1592 +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 - keyboard_nav: 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 = "", -}; - -// ============================================================================= -// 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, - - 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; - } - - /// 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; - - const mouse = ctx.input.mousePos(); - const table_hovered = bounds.contains(mouse.x, mouse.y); - - // Click for focus - if (table_hovered and ctx.input.mousePressed(.left)) { - state.focused = true; - } - - // 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 - 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; - }, - .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; - } - } -} - -// ============================================================================= -// 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()); -}