//! 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, }; /// 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, }; /// 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, }; /// 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), 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), }; /// 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 row_added: bool = false, /// Row was deleted row_deleted: bool = false, /// Editing started edit_started: bool = false, /// Editing ended edit_ended: 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, 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); } } }; // ============================================================================= // 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; /// 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 tableRect(ctx, bounds, state, columns, get_cell, on_edit, 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 { 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) { drawHeader(ctx, bounds, columns, state_col_w, config, colors); } // 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, 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; 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, config, &result); } // Ensure selection is visible after navigation state.ensureVisible(visible_rows); return result; } // ============================================================================= // Drawing Helpers // ============================================================================= fn drawHeader( ctx: *Context, bounds: Layout.Rect, columns: []const Column, state_col_w: u32, config: TableConfig, colors: TableColors, ) void { const header_bounds = Layout.Rect.init( bounds.x, bounds.y, bounds.w, config.header_height, ); // 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, )); // 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) |col| { // Column text const text_x = col_x + 4; // Padding ctx.pushCommand(Command.text(text_x, text_y, col.name, colors.header_fg)); // 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, )); } } fn drawRow( ctx: *Context, row_bounds: Layout.Rect, state: *TableState, row: usize, columns: []const Column, get_cell: CellDataFn, on_edit: ?CellEditFn, 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); // Cell selection highlight 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, )); // 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, 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 (on_edit) |edit_fn| { if (state.selectedCell()) |cell| { edit_fn(cell.row, cell.col, state.getEditText()); } } state.stopEditing(); result.cell_edited = true; result.edit_ended = 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 = 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(); }