//! AdvancedTable Widget - Schema-driven data table //! //! A full-featured table widget with: //! - Schema-driven configuration (TableSchema + ColumnDef) //! - Excel-style cell editing with overlay //! - Auto-CRUD (automatic CREATE/UPDATE/DELETE detection) //! - Keyboard navigation (arrows, Tab, Enter, Escape) //! - Column sorting (click header) //! - Row operations (Ctrl+N/A/B, Ctrl+arrows) //! - Visual state indicators (normal, modified, new, deleted, error) //! - Lookup & auto-fill for related data //! //! This module re-exports types from the advanced_table/ subdirectory. 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 table_core = @import("../table_core.zig"); // Re-export types pub const types = @import("types.zig"); pub const CellValue = types.CellValue; pub const ColumnType = types.ColumnType; pub const RowState = types.RowState; pub const RowLockState = types.RowLockState; pub const SortDirection = types.SortDirection; pub const CRUDAction = types.CRUDAction; pub const ValidationResult = types.ValidationResult; pub const TableColors = types.TableColors; pub const BasicColors = types.BasicColors; pub const TableConfig = types.TableConfig; pub const Row = types.Row; // Re-export schema pub const schema = @import("schema.zig"); pub const ColumnDef = schema.ColumnDef; pub const ColumnAlign = schema.ColumnAlign; pub const AutoFillMapping = schema.AutoFillMapping; pub const SelectOption = schema.SelectOption; pub const TableSchema = schema.TableSchema; pub const DataStore = schema.DataStore; // Re-export state pub const state = @import("state.zig"); pub const AdvancedTableState = state.AdvancedTableState; pub const AdvancedTableResult = state.AdvancedTableResult; // Re-export datasource pub const datasource = @import("datasource.zig"); pub const MemoryDataSource = datasource.MemoryDataSource; // Re-export table_core types pub const NavigateDirection = table_core.NavigateDirection; // ============================================================================= // Public API // ============================================================================= /// Draw an AdvancedTable with default layout pub fn advancedTable( ctx: *Context, table_state: *AdvancedTableState, table_schema: *const TableSchema, ) AdvancedTableResult { return advancedTableEx(ctx, table_state, table_schema, null); } /// Draw an AdvancedTable with custom colors pub fn advancedTableEx( ctx: *Context, table_state: *AdvancedTableState, table_schema: *const TableSchema, colors: ?*const TableColors, ) AdvancedTableResult { const bounds = ctx.layout.nextRect(); return advancedTableRect(ctx, bounds, table_state, table_schema, colors); } /// Draw an AdvancedTable in a specific rectangle pub fn advancedTableRect( ctx: *Context, bounds: Layout.Rect, table_state: *AdvancedTableState, table_schema: *const TableSchema, custom_colors: ?*const TableColors, ) AdvancedTableResult { var result = AdvancedTableResult{}; if (bounds.isEmpty() or table_schema.columns.len == 0) return result; // Get colors const default_colors = TableColors{}; const colors = custom_colors orelse table_schema.colors orelse &default_colors; const config = table_schema.config; // Ensure valid selection if table has data (like Table widget does) if (table_state.getRowCount() > 0 and table_schema.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 focus system const widget_id: u64 = @intFromPtr(table_state); // Register as focusable ctx.registerFocusable(widget_id); // Check mouse interaction const mouse = ctx.input.mousePos(); const hovered = bounds.contains(mouse.x, mouse.y); const clicked = hovered and ctx.input.mousePressed(.left); if (clicked) { ctx.requestFocus(widget_id); result.clicked = true; } // Check if we have focus const has_focus = ctx.hasFocus(widget_id); table_state.nav.has_focus = has_focus; // Calculate dimensions const state_col_w: u32 = if (config.show_row_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); // Begin clipping ctx.pushCommand(Command.clip(bounds.x, bounds.y, bounds.w, bounds.h)); // Draw header if (config.show_headers) { drawHeader(ctx, bounds, table_state, table_schema, state_col_w, colors, &result); } // Calculate visible row range const first_visible = table_state.nav.scroll_row; const last_visible = @min(first_visible + visible_rows, table_state.getRowCount()); // Manejar clicks en filas (separado del renderizado) handleRowClicks(ctx, bounds, table_state, table_schema, header_h, state_col_w, first_visible, last_visible, &result); // Construir ColumnRenderDefs para la función unificada var col_defs: [64]table_core.ColumnRenderDef = undefined; var col_count: usize = 0; for (table_schema.columns) |col| { if (col_count >= 64) break; col_defs[col_count] = .{ .width = col.width, .visible = col.visible, .text_align = 0, // Por ahora left-align }; col_count += 1; } // Crear MemoryDataSource y dibujar filas con función unificada var memory_ds = MemoryDataSource.init(table_state, table_schema.columns); const data_src = memory_ds.toDataSource(); // Construir RowRenderColors manualmente (los dos TableColors son tipos diferentes) const render_colors = table_core.RowRenderColors{ .row_normal = colors.row_normal, .row_alternate = colors.row_alternate, .selected_row = colors.selected_row, .selected_row_unfocus = colors.selected_row_unfocus, .selected_cell = colors.selected_cell, .selected_cell_unfocus = Style.Color.rgb(80, 80, 90), // Default similar a table_core .text_normal = colors.text_normal, .text_selected = colors.text_selected, .border = colors.border, .state_modified = colors.state_modified, .state_new = colors.state_new, .state_deleted = colors.state_deleted, .state_error = colors.state_error, }; var cell_buffer: [256]u8 = undefined; _ = table_core.drawRowsWithDataSource(ctx, data_src, .{ .bounds_x = bounds.x, .bounds_y = bounds.y + @as(i32, @intCast(header_h)), .bounds_w = bounds.w, .row_height = config.row_height, .first_row = first_visible, .last_row = last_visible, .has_focus = has_focus, .selected_row = table_state.selected_row, .active_col = @intCast(@max(0, table_state.selected_col)), .colors = render_colors, .columns = col_defs[0..col_count], .state_indicator_width = state_col_w, .apply_state_colors = true, .draw_row_borders = true, .alternating_rows = config.alternating_rows, .edit_buffer = &table_state.row_edit_buffer, }, &cell_buffer); // End clipping ctx.pushCommand(Command.clipEnd()); // Draw focus ring (outside clip) if (has_focus) { if (Style.isFancy()) { ctx.pushCommand(Command.focusRing(bounds.x, bounds.y, bounds.w, bounds.h, 4)); } else { ctx.pushCommand(Command.rectOutline( bounds.x - 1, bounds.y - 1, bounds.w + 2, bounds.h + 2, colors.focus_ring, )); } } // Draw scrollbar if needed if (table_state.getRowCount() > visible_rows) { drawScrollbar(ctx, bounds, table_state, visible_rows, config, colors); } // Handle keyboard if (has_focus) { if (table_state.isEditing()) { // Handle editing keyboard handleEditingKeyboard(ctx, table_state, table_schema, &result); // Draw editing overlay drawEditingOverlay(ctx, bounds, table_state, table_schema, header_h, state_col_w, colors); } else if (config.keyboard_nav) { // Handle navigation keyboard handleKeyboard(ctx, table_state, table_schema, visible_rows, &result); } } // Ensure selection is visible ensureSelectionVisible(table_state, visible_rows); // Auto-CRUD detection (when row changes) if (config.auto_crud_enabled and result.selection_changed and table_state.rowChanged()) { result.crud_action = detectCRUDAction(table_state, table_schema); // Capture snapshot of new row if (table_state.selected_row >= 0) { table_state.captureSnapshot(@intCast(table_state.selected_row)) catch {}; } } // Phase 8: Invoke callbacks invokeCallbacks(ctx, table_state, table_schema, &result); return result; } // ============================================================================= // Callback System (Phase 8) // ============================================================================= fn invokeCallbacks( ctx: *Context, table_state: *AdvancedTableState, table_schema: *const TableSchema, result: *AdvancedTableResult, ) void { const config = table_schema.config; const current_time = ctx.current_time_ms; // Check debounce const time_since_last = current_time -| table_state.last_callback_time_ms; const debounce_ok = time_since_last >= config.callback_debounce_ms; // on_row_selected: called when selection changes (with debounce) if (result.selection_changed and debounce_ok) { if (table_schema.on_row_selected) |callback| { if (table_state.selected_row >= 0) { const row_idx: usize = @intCast(table_state.selected_row); if (table_state.getRowConst(row_idx)) |row| { callback(row_idx, row); table_state.last_callback_time_ms = current_time; } } } } // on_active_row_changed: called when moving to a different row (for loading detail panels) // Only fires once per row change, not on every frame if (table_state.selected_row != table_state.last_notified_row) { if (table_schema.on_active_row_changed) |callback| { if (table_state.selected_row >= 0) { const new_row_idx: usize = @intCast(table_state.selected_row); if (table_state.getRowConst(new_row_idx)) |row| { const old_row: ?usize = if (table_state.last_notified_row >= 0) @intCast(table_state.last_notified_row) else null; callback(old_row, new_row_idx, row); } } } table_state.last_notified_row = table_state.selected_row; } } // ============================================================================= // Row Click Handling (separado del rendering) // ============================================================================= /// Maneja clicks en las filas de la tabla (single-click y double-click) /// Retorna si hubo algún cambio de selección o edición iniciada fn handleRowClicks( ctx: *Context, bounds: Layout.Rect, table_state: *AdvancedTableState, table_schema: *const TableSchema, header_h: u32, state_col_w: u32, first_visible: usize, last_visible: usize, result: *AdvancedTableResult, ) void { const config = table_schema.config; const mouse = ctx.input.mousePos(); // Solo procesar si hubo click if (!ctx.input.mousePressed(.left)) return; // Verificar si el click está en el área de filas const rows_area_y = bounds.y + @as(i32, @intCast(header_h)); if (mouse.y < rows_area_y) return; if (mouse.x < bounds.x or mouse.x >= bounds.x + @as(i32, @intCast(bounds.w))) return; // Calcular fila clickeada const relative_y = mouse.y - rows_area_y; if (relative_y < 0) return; const row_offset: usize = @intCast(@divFloor(relative_y, @as(i32, @intCast(config.row_height)))); const row_idx = first_visible + row_offset; if (row_idx >= last_visible or row_idx >= table_state.getRowCount()) return; // Calcular columna clickeada var col_x = bounds.x + @as(i32, @intCast(state_col_w)); var clicked_col: ?usize = null; for (table_schema.columns, 0..) |col, col_idx| { if (!col.visible) continue; const col_end = col_x + @as(i32, @intCast(col.width)); if (mouse.x >= col_x and mouse.x < col_end) { clicked_col = col_idx; break; } col_x = col_end; } if (clicked_col == null) return; const col_idx = clicked_col.?; const is_selected_cell = table_state.selected_row == @as(i32, @intCast(row_idx)) and table_state.selected_col == @as(i32, @intCast(col_idx)); // Detectar doble-click const current_time = ctx.current_time_ms; const same_cell = table_state.nav.double_click.last_click_row == @as(i32, @intCast(row_idx)) and table_state.nav.double_click.last_click_col == @as(i32, @intCast(col_idx)); const time_diff = current_time -| table_state.nav.double_click.last_click_time; const is_double_click = same_cell and time_diff < table_state.nav.double_click.threshold_ms; if (is_double_click and config.allow_edit and col_idx < table_schema.columns.len and table_schema.columns[col_idx].editable and !table_state.isEditing()) { // Double-click: iniciar edición if (table_state.getRow(row_idx)) |row| { const value = row.get(table_schema.columns[col_idx].name); var format_buf: [128]u8 = undefined; const edit_text = value.format(&format_buf); table_state.startEditing(edit_text); table_state.original_value = value; result.edit_started = true; } // Reset click tracking table_state.nav.double_click.last_click_time = 0; table_state.nav.double_click.last_click_row = -1; table_state.nav.double_click.last_click_col = -1; } else { // Single click: seleccionar celda if (!is_selected_cell) { table_state.selectCell(row_idx, col_idx); result.selection_changed = true; result.selected_row = row_idx; result.selected_col = col_idx; } // Actualizar tracking para posible doble-click table_state.nav.double_click.last_click_time = current_time; table_state.nav.double_click.last_click_row = @intCast(row_idx); table_state.nav.double_click.last_click_col = @intCast(col_idx); } } // ============================================================================= // Internal Rendering // ============================================================================= fn drawHeader( ctx: *Context, bounds: Layout.Rect, table_state: *AdvancedTableState, table_schema: *const TableSchema, state_col_w: u32, colors: *const TableColors, result: *AdvancedTableResult, ) void { const config = table_schema.config; const header_y = bounds.y; var col_x = bounds.x; // State indicator column header if (state_col_w > 0) { ctx.pushCommand(Command.rect(col_x, header_y, state_col_w, config.header_height, colors.header_bg)); col_x += @as(i32, @intCast(state_col_w)); } // Column headers const mouse = ctx.input.mousePos(); for (table_schema.columns, 0..) |col, idx| { if (!col.visible) continue; const col_rect = Layout.Rect.init(col_x, header_y, col.width, config.header_height); const col_hovered = col_rect.contains(mouse.x, mouse.y); const col_clicked = col_hovered and ctx.input.mousePressed(.left); // Determine background color var bg_color = colors.header_bg; if (table_state.sort_column == @as(i32, @intCast(idx))) { bg_color = colors.header_sorted; } else if (col_hovered) { bg_color = colors.header_hover; } // Draw header cell if (Style.isFancy()) { ctx.pushCommand(Command.roundedRect(col_rect.x, col_rect.y, col_rect.w, col_rect.h, bg_color, 0)); } else { ctx.pushCommand(Command.rect(col_rect.x, col_rect.y, col_rect.w, col_rect.h, bg_color)); } // Draw header text const text_y = header_y + @as(i32, @intCast((config.header_height - 8) / 2)); ctx.pushCommand(Command.text(col_x + 4, text_y, col.title, colors.header_fg)); // Draw lookup indicator (Phase 7c) - small "?" icon if (col.hasLookup()) { const lookup_x = col_x + @as(i32, @intCast(col.width)) - 24; ctx.pushCommand(Command.text(lookup_x, text_y, "?", Style.Color.primary)); } // Draw sort indicator (▴/▾ Unicode glyphs) if (table_state.sort_column == @as(i32, @intCast(idx))) { const indicator_x = col_x + @as(i32, @intCast(col.width)) - 16; const indicator = switch (table_state.sort_direction) { .ascending => "▴", .descending => "▾", .none => "", }; if (indicator.len > 0) { ctx.pushCommand(Command.text(indicator_x, text_y, indicator, colors.sort_indicator)); } } // Handle click for sorting if (col_clicked and col.sortable and config.allow_sorting) { _ = table_state.toggleSort(idx); result.sort_changed = true; result.sort_column = idx; result.sort_direction = table_state.sort_direction; // Actually sort the rows sortRows(table_state, table_schema.columns[idx].name, table_state.sort_direction); } // Draw separator ctx.pushCommand(Command.rect( col_x + @as(i32, @intCast(col.width)) - 1, header_y, 1, config.header_height, colors.border, )); col_x += @as(i32, @intCast(col.width)); } } fn drawScrollbar( ctx: *Context, bounds: Layout.Rect, table_state: *AdvancedTableState, visible_rows: usize, config: TableConfig, colors: *const TableColors, ) void { const total_rows = table_state.getRowCount(); if (total_rows == 0) return; const scrollbar_w: u32 = 12; const header_h: u32 = if (config.show_headers) config.header_height else 0; const scrollbar_h = bounds.h -| header_h; // Usar función unificada de table_core table_core.drawVerticalScrollbar(ctx, .{ .track_x = bounds.x + @as(i32, @intCast(bounds.w -| scrollbar_w)), .track_y = bounds.y + @as(i32, @intCast(header_h)), .width = scrollbar_w, .height = scrollbar_h, .visible_count = visible_rows, .total_count = total_rows, .scroll_pos = table_state.nav.scroll_row, .track_color = colors.border, .thumb_color = colors.header_bg, }); } fn drawEditingOverlay( ctx: *Context, bounds: Layout.Rect, table_state: *AdvancedTableState, table_schema: *const TableSchema, header_h: u32, state_col_w: u32, colors: *const TableColors, ) void { if (table_state.selected_row < 0 or table_state.selected_col < 0) return; const row_idx: usize = @intCast(table_state.selected_row); const col_idx: usize = @intCast(table_state.selected_col); const config = table_schema.config; // Check if row is visible if (row_idx < table_state.nav.scroll_row) return; const visible_row = row_idx - table_state.nav.scroll_row; const visible_rows = (bounds.h -| header_h) / config.row_height; if (visible_row >= visible_rows) return; // Calculate cell position var col_x = bounds.x + @as(i32, @intCast(state_col_w)); for (table_schema.columns[0..col_idx]) |col| { if (col.visible) { col_x += @as(i32, @intCast(col.width)); } } const col_def = table_schema.columns[col_idx]; const cell_y = bounds.y + @as(i32, @intCast(header_h + visible_row * config.row_height)); const cell_h = config.row_height; // Draw editing overlay background ctx.pushCommand(Command.rect(col_x, cell_y, col_def.width, cell_h, colors.cell_editing_bg)); // Draw border ctx.pushCommand(Command.rectOutline(col_x, cell_y, col_def.width, cell_h, colors.cell_editing_border)); // Draw edit text const edit_text = table_state.getEditText(); const text_y = cell_y + @as(i32, @intCast((cell_h - 8) / 2)); // Draw selection highlight if exists (Excel-style) const sel_start = table_state.cell_edit.selection_start; const sel_end = table_state.cell_edit.selection_end; if (sel_start != sel_end and edit_text.len > 0) { const sel_min = @min(sel_start, sel_end); const sel_max = @min(@max(sel_start, sel_end), edit_text.len); if (sel_max > sel_min) { const sel_x = col_x + 4 + @as(i32, @intCast(sel_min * 8)); const sel_width = @as(u32, @intCast((sel_max - sel_min) * 8)); ctx.pushCommand(Command.rect(sel_x, text_y, sel_width, 8, colors.cell_selection_bg)); } } // Draw text (on top of selection) ctx.pushCommand(Command.text(col_x + 4, text_y, edit_text, colors.text_selected)); // Draw cursor only if no selection if (sel_start == sel_end) { const cursor_x = col_x + 4 + @as(i32, @intCast(table_state.cell_edit.edit_cursor * 8)); ctx.pushCommand(Command.rect(cursor_x, text_y, 1, 8, colors.text_selected)); } } // ============================================================================= // Keyboard Handling (Brain-in-Core pattern) // ============================================================================= // // Arquitectura: TODA la lógica de decisión está en table_core.processTableEvents() // Este handler solo aplica flags y maneja lógica específica de AdvancedTable. fn handleKeyboard( ctx: *Context, table_state: *AdvancedTableState, table_schema: *const TableSchema, visible_rows: usize, result: *AdvancedTableResult, ) void { const row_count = table_state.getRowCount(); if (row_count == 0) return; const config = table_schema.config; const col_count = table_schema.columns.len; // ========================================================================= // BRAIN-IN-CORE: Delegar toda la lógica de decisión al Core // ========================================================================= const events = table_core.processTableEvents(ctx, table_state.isEditing()); // ========================================================================= // Aplicar navegación desde el Core // ========================================================================= if (events.move_up and table_state.selected_row > 0) { const new_row: usize = @intCast(table_state.selected_row - 1); const new_col: usize = @intCast(@max(0, table_state.selected_col)); table_state.selectCell(new_row, new_col); result.selection_changed = true; result.selected_row = new_row; result.selected_col = new_col; } if (events.move_down and table_state.selected_row < @as(i32, @intCast(row_count)) - 1) { const new_row: usize = @intCast(table_state.selected_row + 1); const new_col: usize = @intCast(@max(0, table_state.selected_col)); table_state.selectCell(new_row, new_col); result.selection_changed = true; result.selected_row = new_row; result.selected_col = new_col; } if (events.move_left and table_state.selected_col > 0) { const new_row: usize = @intCast(@max(0, table_state.selected_row)); const new_col: usize = @intCast(table_state.selected_col - 1); table_state.selectCell(new_row, new_col); result.selection_changed = true; result.selected_row = new_row; result.selected_col = new_col; } if (events.move_right and table_state.selected_col < @as(i32, @intCast(col_count)) - 1) { const new_row: usize = @intCast(@max(0, table_state.selected_row)); const new_col: usize = @intCast(table_state.selected_col + 1); table_state.selectCell(new_row, new_col); result.selection_changed = true; result.selected_row = new_row; result.selected_col = new_col; } if (events.page_up) { const new_row: usize = @intCast(@max(0, table_state.selected_row - @as(i32, @intCast(visible_rows)))); const new_col: usize = @intCast(@max(0, table_state.selected_col)); table_state.selectCell(new_row, new_col); result.selection_changed = true; result.selected_row = new_row; result.selected_col = new_col; } if (events.page_down) { const new_row: usize = @intCast(@min( @as(i32, @intCast(row_count)) - 1, table_state.selected_row + @as(i32, @intCast(visible_rows)), )); const new_col: usize = @intCast(@max(0, table_state.selected_col)); table_state.selectCell(new_row, new_col); result.selection_changed = true; result.selected_row = new_row; result.selected_col = new_col; } if (events.go_to_first_col) { const new_row: usize = if (events.go_to_first_row) 0 else @intCast(@max(0, table_state.selected_row)); table_state.selectCell(new_row, 0); result.selection_changed = true; result.selected_row = new_row; result.selected_col = 0; } else if (events.go_to_first_row) { const new_col: usize = @intCast(@max(0, table_state.selected_col)); table_state.selectCell(0, new_col); result.selection_changed = true; result.selected_row = 0; result.selected_col = new_col; } if (events.go_to_last_col) { const new_row: usize = if (events.go_to_last_row) (if (row_count > 0) row_count - 1 else 0) else @intCast(@max(0, table_state.selected_row)); const new_col: usize = col_count - 1; table_state.selectCell(new_row, new_col); result.selection_changed = true; result.selected_row = new_row; result.selected_col = new_col; } else if (events.go_to_last_row) { const new_row: usize = if (row_count > 0) row_count - 1 else 0; const new_col: usize = @intCast(@max(0, table_state.selected_col)); table_state.selectCell(new_row, new_col); result.selection_changed = true; result.selected_row = new_row; result.selected_col = new_col; } // ========================================================================= // Tab navigation con commit Excel-style (DRY: lógica en table_core) // ========================================================================= if (events.tab_out and config.handle_tab) { // Wrapper para obtener row_id por índice (en AdvancedTable, usamos índice como ID) const RowIdGetter = struct { total: usize, pub fn getRowId(self: @This(), row: usize) i64 { // Ghost row está al final if (row >= self.total) return table_core.NEW_ROW_ID; return @intCast(row); } }; const getter = RowIdGetter{ .total = row_count }; const current_row: usize = @intCast(@max(0, table_state.selected_row)); const current_col: usize = @intCast(@max(0, table_state.selected_col)); const forward = !events.tab_shift; // AdvancedTable: usar count de filas existentes (no tiene ghost row como VirtualAdvancedTable) const num_rows = row_count; const plan = table_core.planTabNavigation( &table_state.row_edit_buffer, current_row, current_col, col_count, num_rows, forward, config.wrap_navigation, getter, &result.row_changes, ); // Ejecutar el plan switch (plan.action) { .move, .move_with_commit => { table_state.selectCell(plan.new_row, plan.new_col); result.selection_changed = true; if (plan.action == .move_with_commit) { if (plan.commit_info) |info| { result.row_committed = true; result.row_commit_id = info.row_id; result.row_commit_is_insert = info.is_insert; result.row_changes_count = info.change_count; } } }, .exit, .exit_with_commit => { result.tab_out = true; result.tab_shift = events.tab_shift; if (plan.action == .exit_with_commit) { if (plan.commit_info) |info| { result.row_committed = true; result.row_commit_id = info.row_id; result.row_commit_is_insert = info.is_insert; result.row_changes_count = info.change_count; } } }, } } // ========================================================================= // Inicio de edición (F2, Space, o tecla alfanumérica desde el Core) // ========================================================================= if (events.start_editing and config.allow_edit) { if (table_state.selected_row >= 0 and table_state.selected_col >= 0) { const col_idx: usize = @intCast(table_state.selected_col); if (col_idx < table_schema.columns.len and table_schema.columns[col_idx].editable) { if (table_state.getRow(@intCast(table_state.selected_row))) |row| { const value = row.get(table_schema.columns[col_idx].name); if (events.initial_char) |ch| { // Tecla alfanumérica: empezar con ese caracter var char_buf: [1]u8 = .{ch}; table_state.startEditing(&char_buf); } else { // F2/Space: empezar con valor actual var format_buf: [128]u8 = undefined; const text = value.format(&format_buf); table_state.startEditing(text); } table_state.original_value = value; result.edit_started = true; } } } } // ========================================================================= // Operaciones CRUD (Ctrl+N, Ctrl+Delete, Ctrl+B desde el Core) // ========================================================================= if (config.allow_row_operations) { // Ctrl+N: Insert row BELOW current row (inyección local) if (events.insert_row) { const insert_idx: usize = if (table_state.selected_row >= 0) @as(usize, @intCast(table_state.selected_row)) + 1 // +1 = debajo else 0; if (table_state.insertRow(insert_idx)) |new_idx| { table_state.selectCell(new_idx, 0); // Inicializar buffer para fila inyectada (Excel-style) table_state.row_edit_buffer.startInjectedEdit(new_idx); result.row_inserted = true; result.selection_changed = true; } else |_| {} } // Ctrl+Delete o Ctrl+B: Delete row if (events.delete_row) { if (table_state.selected_row >= 0) { const delete_idx: usize = @intCast(table_state.selected_row); table_state.deleteRow(delete_idx); result.row_deleted = true; // Adjust selection const remaining_rows = table_state.getRowCount(); if (remaining_rows == 0) { table_state.selected_row = -1; } else if (delete_idx >= remaining_rows) { table_state.selected_row = @intCast(remaining_rows - 1); } result.selection_changed = true; } } } // ========================================================================= // Ctrl+A: Select all (lógica específica de AdvancedTable) // ========================================================================= if (config.allow_multi_select and ctx.input.keyPressed(.a) and ctx.input.modifiers.ctrl) { table_state.selectAllRows(); result.selection_changed = true; } // ========================================================================= // Búsqueda incremental (solo si NO se inició edición) // Solo para celdas no editables - lógica específica de AdvancedTable // ========================================================================= if (!events.start_editing and !ctx.input.modifiers.ctrl and !ctx.input.modifiers.alt) { if (ctx.input.text_input_len > 0) { const text = ctx.input.text_input[0..ctx.input.text_input_len]; // Solo búsqueda si la celda actual NO es editable const current_cell_editable = blk: { if (!config.allow_edit) break :blk false; if (table_state.selected_row < 0 or table_state.selected_col < 0) break :blk false; const col_idx: usize = @intCast(table_state.selected_col); if (col_idx >= table_schema.columns.len) break :blk false; break :blk table_schema.columns[col_idx].editable; }; if (!current_cell_editable) { // Incremental search (type-to-search) in first column for (text) |char| { if (char >= 32 and char < 127) { const search_term = table_state.addSearchChar(char, ctx.current_time_ms); if (search_term.len > 0 and table_schema.columns.len > 0) { const first_col_name = table_schema.columns[0].name; const start_row: usize = if (table_state.selected_row >= 0) @intCast(table_state.selected_row) else 0; var found_row: ?usize = null; // Search from current position to end for (start_row..row_count) |row| { if (table_state.getRowConst(row)) |row_data| { const cell_value = row_data.get(first_col_name); var format_buf: [128]u8 = undefined; const cell_text = cell_value.format(&format_buf); if (startsWithIgnoreCase(cell_text, search_term)) { found_row = row; break; } } } // Wrap to beginning if not found if (found_row == null and start_row > 0) { for (0..start_row) |row| { if (table_state.getRowConst(row)) |row_data| { const cell_value = row_data.get(first_col_name); var format_buf: [128]u8 = undefined; const cell_text = cell_value.format(&format_buf); if (startsWithIgnoreCase(cell_text, search_term)) { found_row = row; break; } } } } // Move selection if found if (found_row) |row_idx| { table_state.selectCell(row_idx, @intCast(@max(0, table_state.selected_col))); result.selection_changed = true; } } } } } } } } fn handleEditingKeyboard( ctx: *Context, table_state: *AdvancedTableState, table_schema: *const TableSchema, result: *AdvancedTableResult, ) void { const config = table_schema.config; // Obtener texto original para revert var orig_format_buf: [128]u8 = undefined; const original_text: ?[]const u8 = if (table_state.original_value) |orig| orig.format(&orig_format_buf) else null; // Usar table_core para procesamiento de teclado (DRY) con soporte selección const kb_result = table_core.handleEditingKeyboard( ctx, &table_state.cell_edit.edit_buffer, &table_state.cell_edit.edit_len, &table_state.cell_edit.edit_cursor, &table_state.cell_edit.escape_count, original_text, &table_state.cell_edit.selection_start, &table_state.cell_edit.selection_end, ); // Si no se procesó ningún evento, salir if (!kb_result.handled) return; // Escape canceló la edición if (kb_result.cancelled) { table_state.stopEditing(); result.edit_ended = true; return; } // Commit (Enter, Tab, flechas) y navegación if (kb_result.committed) { commitEdit(table_state, table_schema, result); table_state.stopEditing(); result.edit_ended = true; // Procesar navegación después de commit switch (kb_result.navigate) { .next_cell, .prev_cell => { if (!config.handle_tab) return; const col_count = table_schema.columns.len; const row_count = table_state.getRowCount(); if (kb_result.navigate == .prev_cell) { // Shift+Tab: move left if (table_state.selected_col > 0) { table_state.selectCell( @intCast(@max(0, table_state.selected_row)), @intCast(table_state.selected_col - 1), ); } else if (table_state.selected_row > 0 and config.wrap_navigation) { table_state.selectCell( @intCast(table_state.selected_row - 1), col_count - 1, ); } } else { // Tab: move right if (table_state.selected_col < @as(i32, @intCast(col_count)) - 1) { table_state.selectCell( @intCast(@max(0, table_state.selected_row)), @intCast(table_state.selected_col + 1), ); } else if (table_state.selected_row < @as(i32, @intCast(row_count)) - 1 and config.wrap_navigation) { table_state.selectCell( @intCast(table_state.selected_row + 1), 0, ); } } // Auto-start editing in new cell if editable const new_col: usize = @intCast(@max(0, table_state.selected_col)); if (new_col < table_schema.columns.len and table_schema.columns[new_col].editable) { if (table_state.getRow(@intCast(@max(0, table_state.selected_row)))) |row| { const value = row.get(table_schema.columns[new_col].name); var format_buf: [128]u8 = undefined; const text = value.format(&format_buf); table_state.startEditing(text); result.edit_started = true; } } result.selection_changed = true; }, .next_row, .prev_row => { // Enter o flechas arriba/abajo: solo commit, sin navegación adicional aquí // (La navegación entre filas se maneja en otro lugar si es necesario) }, .none => {}, } } } fn commitEdit( table_state: *AdvancedTableState, table_schema: *const TableSchema, result: *AdvancedTableResult, ) void { if (table_state.selected_row < 0 or table_state.selected_col < 0) return; const row_idx: usize = @intCast(table_state.selected_row); const col_idx: usize = @intCast(table_state.selected_col); if (col_idx >= table_schema.columns.len) return; const col_def = &table_schema.columns[col_idx]; const edit_text = table_state.getEditText(); // Parse text to CellValue based on column type const new_value = parseValue(edit_text, col_def.column_type); // Check if value changed if (table_state.original_value) |orig| { if (new_value.eql(orig)) { return; // No change } } // Update the row if (table_state.getRow(row_idx)) |row| { row.set(col_def.name, new_value) catch {}; table_state.markDirty(row_idx); result.cell_edited = true; // Lookup & Auto-fill (Phase 7) if (col_def.hasLookup()) { performLookupAndAutoFill(table_state, table_schema, row, col_def, new_value, result); } // Call on_cell_changed callback (Phase 8) if (table_schema.on_cell_changed) |callback| { const old_value = table_state.original_value orelse CellValue{ .null_val = {} }; callback(row_idx, col_idx, old_value, new_value); } } } /// Perform lookup in related table and auto-fill columns fn performLookupAndAutoFill( table_state: *AdvancedTableState, table_schema: *const TableSchema, row: *Row, col_def: *const ColumnDef, lookup_value: CellValue, result: *AdvancedTableResult, ) void { // Need DataStore to perform lookup const data_store = table_schema.data_store orelse return; // Get lookup configuration const lookup_table = col_def.lookup_table orelse return; const lookup_key = col_def.lookup_key_column orelse return; // Perform lookup const lookup_result = data_store.lookup( lookup_table, lookup_key, lookup_value, table_state.allocator, ) catch return; // If lookup found a match, auto-fill related columns if (lookup_result) |lookup_row| { defer { // Clean up the lookup row after use var mutable_lookup = lookup_row; mutable_lookup.deinit(); } // Auto-fill columns based on mapping if (col_def.auto_fill_columns) |mappings| { for (mappings) |mapping| { const source_value = lookup_row.get(mapping.source_field); if (!source_value.isEmpty()) { row.set(mapping.target_column, source_value) catch {}; } } } result.lookup_success = true; } else { result.lookup_success = false; } } fn parseValue(text: []const u8, column_type: ColumnType) CellValue { return switch (column_type) { .string => CellValue{ .string = text }, .integer => blk: { const val = std.fmt.parseInt(i64, text, 10) catch 0; break :blk CellValue{ .integer = val }; }, .float, .money => blk: { const val = std.fmt.parseFloat(f64, text) catch 0.0; break :blk CellValue{ .float = val }; }, .boolean => blk: { const lower = text; const is_true = std.mem.eql(u8, lower, "true") or std.mem.eql(u8, lower, "yes") or std.mem.eql(u8, lower, "1") or std.mem.eql(u8, lower, "Y"); break :blk CellValue{ .boolean = is_true }; }, .date, .select, .lookup => CellValue{ .string = text }, }; } fn detectCRUDAction( table_state: *AdvancedTableState, table_schema: *const TableSchema, ) ?CRUDAction { // Check if previous row was valid if (table_state.prev_selected_row < 0) return null; const prev_row_idx: usize = @intCast(table_state.prev_selected_row); // Check if row was marked for deletion if (table_state.isDeleted(prev_row_idx)) { return .delete; } // Get the row (might have been deleted) const row = table_state.getRow(prev_row_idx) orelse return null; // Get snapshot for comparison const snapshot = table_state.getSnapshot(prev_row_idx); // Check if row is new (was in new_rows map) const is_new = table_state.isNew(prev_row_idx); if (is_new) { // Check if new row has any data if (rowHasData(row, table_schema)) { return .create; } return null; // Empty new row, no action } // Check if row was modified if (snapshot) |snap| { if (rowsAreDifferent(row, snap, table_schema)) { return .update; } } else if (table_state.isDirty(prev_row_idx)) { // No snapshot but marked dirty - assume update return .update; } return null; } fn rowHasData(row: *const Row, table_schema: *const TableSchema) bool { for (table_schema.columns) |col| { if (!col.visible) continue; const value = row.get(col.name); if (!value.isEmpty()) return true; } return false; } fn rowsAreDifferent(row: *const Row, snapshot: *const Row, table_schema: *const TableSchema) bool { for (table_schema.columns) |col| { if (!col.editable) continue; const current = row.get(col.name); const original = snapshot.get(col.name); if (!current.eql(original)) return true; } return false; } fn ensureSelectionVisible(table_state: *AdvancedTableState, visible_rows: usize) void { if (table_state.selected_row < 0) return; const row: usize = @intCast(table_state.selected_row); // Scroll to show selected row if (row < table_state.nav.scroll_row) { table_state.nav.scroll_row = row; } else if (row >= table_state.nav.scroll_row + visible_rows) { table_state.nav.scroll_row = row - visible_rows + 1; } } // ============================================================================= // Color Helpers // ============================================================================= fn blendColor(base: Style.Color, overlay: Style.Color, alpha: f32) Style.Color { const inv_alpha = 1.0 - alpha; return Style.Color.rgba( @intFromFloat(@as(f32, @floatFromInt(base.r)) * inv_alpha + @as(f32, @floatFromInt(overlay.r)) * alpha), @intFromFloat(@as(f32, @floatFromInt(base.g)) * inv_alpha + @as(f32, @floatFromInt(overlay.g)) * alpha), @intFromFloat(@as(f32, @floatFromInt(base.b)) * inv_alpha + @as(f32, @floatFromInt(overlay.b)) * alpha), base.a, ); } // ============================================================================= // Sorting // ============================================================================= /// Sort rows by column value fn sortRows( table_state: *AdvancedTableState, column_name: []const u8, direction: SortDirection, ) void { if (direction == .none) return; if (table_state.rows.items.len < 2) return; // Simple bubble sort (stable, works for small-medium datasets) // For large datasets, consider using std.mem.sort with a context const len = table_state.rows.items.len; var swapped = true; while (swapped) { swapped = false; for (0..len - 1) |i| { const val_a = table_state.rows.items[i].get(column_name); const val_b = table_state.rows.items[i + 1].get(column_name); const cmp = val_a.compare(val_b); const should_swap = switch (direction) { .ascending => cmp > 0, .descending => cmp < 0, .none => false, }; if (should_swap) { // Swap rows usando std.mem.swap (seguro para structs con punteros internos) // NOTA: Row contiene StringHashMap que tiene punteros a buckets. // El swap mueve el struct completo, no clona los datos. // Los punteros obtenidos via getRow() se invalidan tras sort. std.mem.swap(Row, &table_state.rows.items[i], &table_state.rows.items[i + 1]); // Swap state map entries swapRowStates(table_state, i, i + 1); swapped = true; } } } } /// Swap state map entries between two row indices fn swapRowStates(table_state: *AdvancedTableState, idx_a: usize, idx_b: usize) void { // Helper to swap a single map's entries const swapInMap = struct { fn swap(map: anytype, a: usize, b: usize) void { const val_a = map.get(a); const val_b = map.get(b); if (val_a != null and val_b != null) { // Both exist - no change needed (both stay) } else if (val_a) |v| { // Only a exists - move to b _ = map.remove(a); map.put(b, v) catch {}; } else if (val_b) |v| { // Only b exists - move to a _ = map.remove(b); map.put(a, v) catch {}; } // Neither exists - nothing to do } }.swap; swapInMap(&table_state.dirty_rows, idx_a, idx_b); swapInMap(&table_state.new_rows, idx_a, idx_b); swapInMap(&table_state.deleted_rows, idx_a, idx_b); swapInMap(&table_state.validation_errors, idx_a, idx_b); } // ============================================================================= // Search Helpers // ============================================================================= /// 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 // ============================================================================= test "AdvancedTable basic rendering" { var ctx = try Context.init(std.testing.allocator, 800, 600); defer ctx.deinit(); var table_state = AdvancedTableState.init(std.testing.allocator); defer table_state.deinit(); const columns = [_]ColumnDef{ .{ .name = "id", .title = "ID", .width = 50, .editable = false }, .{ .name = "name", .title = "Name", .width = 150 }, .{ .name = "value", .title = "Value", .width = 100 }, }; const table_schema = TableSchema{ .table_name = "test", .columns = &columns, }; ctx.beginFrame(); ctx.layout.row_height = 200; _ = advancedTable(&ctx, &table_state, &table_schema); // Should generate commands try std.testing.expect(ctx.commands.items.len > 0); ctx.endFrame(); } test "blendColor" { const white = Style.Color.rgb(255, 255, 255); const black = Style.Color.rgb(0, 0, 0); const gray = blendColor(white, black, 0.5); try std.testing.expectEqual(@as(u8, 127), gray.r); try std.testing.expectEqual(@as(u8, 127), gray.g); try std.testing.expectEqual(@as(u8, 127), gray.b); } test "AdvancedTableResult lookup_success field" { var result = AdvancedTableResult{}; // Default is null (no lookup attempted) try std.testing.expect(result.lookup_success == null); // Can be set to true (lookup found) result.lookup_success = true; try std.testing.expect(result.lookup_success.? == true); // Can be set to false (lookup not found) result.lookup_success = false; try std.testing.expect(result.lookup_success.? == false); } test "AdvancedTableState callback fields" { var table_state = AdvancedTableState.init(std.testing.allocator); defer table_state.deinit(); // Initial state try std.testing.expectEqual(@as(u64, 0), table_state.last_callback_time_ms); try std.testing.expectEqual(@as(i32, -1), table_state.last_notified_row); // Can be updated table_state.last_callback_time_ms = 1000; table_state.last_notified_row = 5; try std.testing.expectEqual(@as(u64, 1000), table_state.last_callback_time_ms); try std.testing.expectEqual(@as(i32, 5), table_state.last_notified_row); } test "ColumnDef hasLookup" { // Column without lookup const col_no_lookup = ColumnDef{ .name = "test", .title = "Test", }; try std.testing.expect(!col_no_lookup.hasLookup()); // Column with lookup enabled but no config const col_partial = ColumnDef{ .name = "test", .title = "Test", .enable_lookup = true, }; try std.testing.expect(!col_partial.hasLookup()); // Missing table/key // Column with full lookup config const col_full = ColumnDef{ .name = "test", .title = "Test", .enable_lookup = true, .lookup_table = "other_table", .lookup_key_column = "id", }; try std.testing.expect(col_full.hasLookup()); } test "startsWithIgnoreCase" { // Basic match try std.testing.expect(startsWithIgnoreCase("Hello World", "Hello")); try std.testing.expect(startsWithIgnoreCase("Hello World", "hello")); try std.testing.expect(startsWithIgnoreCase("hello world", "HELLO")); // Empty needle matches everything try std.testing.expect(startsWithIgnoreCase("anything", "")); // Non-match try std.testing.expect(!startsWithIgnoreCase("Hello", "World")); try std.testing.expect(!startsWithIgnoreCase("Hi", "Hello")); // Needle longer than haystack try std.testing.expect(!startsWithIgnoreCase("Hi", "Hello World")); }