diff --git a/docs/ADVANCED_TABLE_DESIGN.md b/docs/ADVANCED_TABLE_DESIGN.md new file mode 100644 index 0000000..2d4ec09 --- /dev/null +++ b/docs/ADVANCED_TABLE_DESIGN.md @@ -0,0 +1,582 @@ +# AdvancedTable - Documento de Diseño + +> **Fecha**: 2025-12-17 +> **Basado en**: Análisis de `simifactu-fyne/internal/ui/components/advanced_table/` (~2,800 LOC Go) +> **Target**: zcatgui (Zig) + +--- + +## 1. Resumen Ejecutivo + +AdvancedTable es una tabla parametrizada de alto nivel que proporciona: +- **Configuración schema-driven** (sin callbacks hardcoded) +- **Edición Excel-style** (Entry overlay sobre celda) +- **Auto-CRUD** (detecta y ejecuta CREATE/UPDATE/DELETE automáticamente) +- **Navegación teclado completa** (arrows, Tab, Enter, Escape) +- **Sorting por columna** (click header, cycle asc/desc/restore) +- **Visual states** (normal, modified, new, deleted, error, selected) +- **Lookup & Auto-fill** (para campos de lookup de BD) + +La idea es que cualquier tabla en la aplicación (WHO, líneas de documento, configuración, etc.) sea simplemente una **instancia parametrizada** de AdvancedTable. + +--- + +## 2. Arquitectura Propuesta para zcatgui + +### 2.1 Estructura de Archivos + +``` +src/widgets/advanced_table/ +├── advanced_table.zig # Entry point, re-exports +├── types.zig # Tipos, enums, configs +├── schema.zig # TableSchema, ColumnDef +├── state.zig # AdvancedTableState +├── render.zig # Rendering visual +├── editing.zig # Edición in-situ +├── navigation.zig # Navegación teclado +├── sorting.zig # Sorting por columnas +├── row_ops.zig # Operaciones de filas (CRUD local) +├── autocrud.zig # Detección y ejecución Auto-CRUD +├── lookup.zig # Lookup y Auto-fill +└── callbacks.zig # Sistema de callbacks +``` + +### 2.2 Tipos Fundamentales + +```zig +// types.zig + +/// Tipos de datos para columnas +pub const ColumnType = enum { + string, + integer, + float, + money, + boolean, + date, + select, // Dropdown con opciones + lookup, // Busca en tabla relacionada +}; + +/// Estado de fila (dirty tracking) +pub const RowState = enum { + normal, // Sin cambios + modified, // Editado, pendiente de guardar + new, // Fila nueva, no existe en BD + deleted, // Marcado para borrar + error, // Error de validación +}; + +/// Estado de lock de fila (para VeriFacTu certified docs) +pub const RowLockState = enum { + unlocked, // Editable + locked, // Requiere password + read_only, // Solo lectura (certificado) +}; + +/// Resultado de validación +pub const ValidationResult = struct { + valid: bool, + message: []const u8 = "", + severity: enum { info, warning, error } = .error, +}; +``` + +### 2.3 Schema de Tabla + +```zig +// schema.zig + +/// Definición de columna +pub const ColumnDef = struct { + /// Nombre interno (key en row map) + name: []const u8, + /// Título visible en header + title: []const u8, + /// Ancho en pixels + width: u32 = 100, + /// Tipo de datos + column_type: ColumnType = .string, + /// Es editable + editable: bool = true, + /// Es ordenable + sortable: bool = true, + /// Ancho mínimo + min_width: u32 = 40, + /// Alineación + align: enum { left, center, right } = .left, + + // Lookup + enable_lookup: bool = false, + lookup_table: ?[]const u8 = null, + lookup_key_column: ?[]const u8 = null, + auto_fill_columns: ?[]const AutoFillMapping = null, + + // Callbacks por columna (opcionales) + validator: ?*const fn(value: CellValue) ValidationResult = null, + formatter: ?*const fn(value: CellValue) []const u8 = null, + parser: ?*const fn(text: []const u8) ?CellValue = null, +}; + +/// Mapping para auto-fill después de lookup +pub const AutoFillMapping = struct { + source_field: []const u8, // Campo en tabla lookup + target_column: []const u8, // Columna destino +}; + +/// Schema completo de tabla +pub const TableSchema = struct { + /// Nombre de la tabla (para DataStore) + table_name: []const u8, + /// Columnas + columns: []const ColumnDef, + + // Configuración visual + show_row_state_indicators: bool = true, + show_headers: bool = true, + header_height: u32 = 28, + row_height: u32 = 24, + alternating_rows: bool = true, + + // Configuración de edición + allow_edit: bool = true, + allow_sorting: bool = true, + allow_row_operations: bool = true, // Ctrl+N, Delete, etc. + + // Auto-CRUD + auto_crud_enabled: bool = true, // Detecta CREATE/UPDATE/DELETE al cambiar fila + always_show_empty_row: bool = false, // Fila vacía al final para entrada continua + + // Row locking (para certificados) + support_row_locking: bool = false, + + // DataStore (opcional, para Auto-CRUD) + data_store: ?*DataStore = null, + + // Colors (opcional, override del theme) + colors: ?*const TableColors = null, +}; +``` + +### 2.4 State de la Tabla + +```zig +// state.zig + +pub const AdvancedTableState = struct { + // Datos + rows: std.ArrayList(Row), + allocator: std.mem.Allocator, + + // Selección + selected_row: i32 = -1, + selected_col: i32 = -1, + prev_selected_row: i32 = -1, + prev_selected_col: i32 = -1, + + // Edición + editing: bool = false, + edit_buffer: [256]u8 = undefined, + edit_len: usize = 0, + edit_cursor: usize = 0, + original_value: ?CellValue = null, + escape_count: u8 = 0, // 1=revert, 2=cancel + + // Sorting + sort_column: i32 = -1, + sort_ascending: bool = true, + original_order: ?std.ArrayList(Row) = null, // Para restore + + // Scroll + scroll_row: usize = 0, + scroll_x: usize = 0, + + // State maps (sparse - solo filas modificadas) + dirty_rows: std.AutoHashMap(usize, bool), + new_rows: std.AutoHashMap(usize, bool), + deleted_rows: std.AutoHashMap(usize, bool), + validation_errors: std.AutoHashMap(usize, bool), + + // Snapshots para Auto-CRUD + row_snapshots: std.AutoHashMap(usize, Row), + + // Focus + focused: bool = false, + + // Debounce (para callbacks externos) + last_callback_time: i64 = 0, + pending_row_callback: ?usize = null, + + pub fn init(allocator: std.mem.Allocator) AdvancedTableState { ... } + pub fn deinit(self: *AdvancedTableState) void { ... } + + // Row operations + pub fn setRows(self: *AdvancedTableState, rows: []const Row) void { ... } + pub fn getRowCount(self: *AdvancedTableState) usize { ... } + pub fn getRow(self: *AdvancedTableState, index: usize) ?*Row { ... } + pub fn insertRow(self: *AdvancedTableState, index: usize) void { ... } + pub fn deleteRow(self: *AdvancedTableState, index: usize) void { ... } + pub fn moveRowUp(self: *AdvancedTableState, index: usize) void { ... } + pub fn moveRowDown(self: *AdvancedTableState, index: usize) void { ... } + + // State queries + pub fn getRowState(self: *AdvancedTableState, index: usize) RowState { ... } + pub fn markDirty(self: *AdvancedTableState, index: usize) void { ... } + pub fn markNew(self: *AdvancedTableState, index: usize) void { ... } + pub fn markDeleted(self: *AdvancedTableState, index: usize) void { ... } + pub fn clearRowState(self: *AdvancedTableState, index: usize) void { ... } + + // Snapshots (para Auto-CRUD) + pub fn captureSnapshot(self: *AdvancedTableState, index: usize) void { ... } + pub fn getSnapshot(self: *AdvancedTableState, index: usize) ?Row { ... } + pub fn clearSnapshot(self: *AdvancedTableState, index: usize) void { ... } + + // Selection + pub fn selectCell(self: *AdvancedTableState, row: usize, col: usize) void { ... } + pub fn clearSelection(self: *AdvancedTableState) void { ... } + + // Editing + pub fn startEditing(self: *AdvancedTableState, initial_value: []const u8) void { ... } + pub fn stopEditing(self: *AdvancedTableState) void { ... } + pub fn getEditText(self: *AdvancedTableState) []const u8 { ... } + + // Sorting + pub fn toggleSort(self: *AdvancedTableState, col: usize) void { ... } + pub fn restoreOriginalOrder(self: *AdvancedTableState) void { ... } +}; +``` + +### 2.5 DataStore Interface + +```zig +// types.zig + +/// Interface para persistencia (BD, archivo, API, etc.) +pub const DataStore = struct { + ptr: *anyopaque, + vtable: *const VTable, + + pub const VTable = struct { + load: *const fn(ptr: *anyopaque) []Row, + save: *const fn(ptr: *anyopaque, row: *Row) anyerror!void, + delete: *const fn(ptr: *anyopaque, row: *Row) anyerror!void, + lookup: *const fn(ptr: *anyopaque, table: []const u8, key_col: []const u8, key_val: CellValue) ?Row, + }; + + pub fn load(self: DataStore) []Row { + return self.vtable.load(self.ptr); + } + + pub fn save(self: DataStore, row: *Row) !void { + return self.vtable.save(self.ptr, row); + } + + pub fn delete(self: DataStore, row: *Row) !void { + return self.vtable.delete(self.ptr, row); + } + + pub fn lookup(self: DataStore, table: []const u8, key_col: []const u8, key_val: CellValue) ?Row { + return self.vtable.lookup(self.ptr, table, key_col, key_val); + } +}; +``` + +--- + +## 3. Funcionalidades Detalladas + +### 3.1 Edición Excel-Style + +**Comportamiento**: +1. **Activar edición**: F2, Enter, Spacebar, o tecla alfanumérica +2. **Tecla alfanumérica**: Reemplaza contenido con esa letra, cursor al final +3. **F2/Enter/Space**: Muestra valor actual, selecciona todo +4. **Durante edición**: + - Arrow Up/Down: Commit + navega + auto-edit + - Tab: Commit + navega siguiente editable + auto-edit + - Shift+Tab: Commit + navega anterior + auto-edit + - Enter: Commit + navega abajo + - Escape (1): Revert al valor original (sigue editando) + - Escape (2): Cancel, sale de edición +5. **Commit**: Valida, parsea, guarda en row, marca dirty + +### 3.2 Navegación Teclado + +| Tecla | Acción | +|-------|--------| +| ↑/↓/←/→ | Navegar entre celdas | +| Tab | Siguiente celda editable | +| Shift+Tab | Anterior celda editable | +| Enter | Iniciar edición o navegar abajo | +| F2 | Iniciar edición | +| Escape | Cancelar edición / Revert cambios | +| Ctrl+N | Insertar fila en posición actual | +| Ctrl+A | Agregar fila al final | +| Ctrl+B / Delete | Borrar fila actual | +| Ctrl+↑ | Mover fila arriba | +| Ctrl+↓ | Mover fila abajo | +| Home | Ir a primera columna | +| End | Ir a última columna | +| Ctrl+Home | Ir a primera fila | +| Ctrl+End | Ir a última fila | +| Page Up/Down | Scroll por página | + +### 3.3 Auto-CRUD + +**Flujo**: +1. Usuario entra en fila → `captureSnapshot(rowIndex)` +2. Usuario edita celdas → row modificado, `markDirty(rowIndex)` +3. Usuario sale de fila (navega a otra) → `detectCRUDAction(rowIndex)` +4. Según resultado: + - **ActionNone**: No hubo cambios reales → limpiar dirty flag + - **ActionCreate**: Snapshot vacío, current tiene datos → `DataStore.save()` (INSERT) + - **ActionUpdate**: Snapshot y current tienen datos diferentes → `DataStore.save()` (UPDATE) + - **ActionDelete**: Row marcado deleted o current vacío → `DataStore.delete()` + +**Detección**: +```zig +fn detectCRUDAction(state: *AdvancedTableState, row_index: usize) CRUDAction { + // Check deleted flag first (Ctrl+B) + if (state.deleted_rows.get(row_index)) |_| { + return .delete; + } + + const snapshot = state.getSnapshot(row_index) orelse return .none; + const current = state.getRow(row_index) orelse return .none; + + const was_empty = isRowEmpty(snapshot); + const is_empty = isRowEmpty(current); + const has_changes = rowHasChanges(snapshot, current); + + if (was_empty and !is_empty) return .create; + if (!was_empty and !is_empty and has_changes) return .update; + if (!was_empty and is_empty) return .delete; + + return .none; +} +``` + +### 3.4 Sorting + +**Ciclo**: Click en header → Ascending → Click → Descending → Click → Restore original + +**Implementación**: +1. Primer sort en columna: Guardar orden original +2. Sort usa índices indirectos (no modifica rows directamente) +3. Después de sort: Sincronizar TODOS los state maps (dirty, new, deleted, snapshots, selection) +4. Restore: Volver al orden guardado + +### 3.5 Visual States + +| Estado | Color Background | Indicador | +|--------|------------------|-----------| +| Normal | Theme default | - | +| Modified | Amarillo suave | ● amarillo | +| New | Verde suave | ● verde | +| Deleted | Rojo suave | ● rojo | +| Error | Rojo intenso | ⚠ | +| Selected Cell | Azul intenso | - | +| Selected Row | Azul suave | - | +| Read-Only | Gris claro | 🔒 | +| Locked | Gris oscuro | 🔐 | + +### 3.6 Lookup & Auto-fill + +**Caso de uso**: Líneas de factura +- Usuario escribe código de producto +- Tabla busca en tabla `productos` por `codigo` +- Si encuentra: Auto-rellena `descripcion`, `precio`, `iva_porc`, `re_porc` +- Si no encuentra: Callback `OnLookupNotFound` (puede abrir diálogo crear producto) + +**Configuración en ColumnDef**: +```zig +ColumnDef{ + .name = "producto_codigo", + .title = "Código", + .enable_lookup = true, + .lookup_table = "productos", + .lookup_key_column = "codigo", + .auto_fill_columns = &[_]AutoFillMapping{ + .{ .source_field = "descripcion", .target_column = "descripcion" }, + .{ .source_field = "precio_venta", .target_column = "precio" }, + .{ .source_field = "iva_porcentaje", .target_column = "iva_porc" }, + .{ .source_field = "re_porcentaje", .target_column = "re_porc" }, + }, +} +``` + +--- + +## 4. API Pública + +```zig +// advanced_table.zig + +/// Dibujar AdvancedTable con schema y state +pub fn advancedTable( + ctx: *Context, + state: *AdvancedTableState, + schema: *const TableSchema, +) AdvancedTableResult; + +/// Dibujar en rectángulo específico +pub fn advancedTableRect( + ctx: *Context, + bounds: Layout.Rect, + state: *AdvancedTableState, + schema: *const TableSchema, + colors: ?*const TableColors, +) AdvancedTableResult; + +/// Resultado de interacción +pub const AdvancedTableResult = struct { + // Selección + selection_changed: bool = false, + selected_row: ?usize = null, + selected_col: ?usize = null, + + // Edición + edit_started: bool = false, + edit_ended: bool = false, + cell_edited: bool = false, + + // Sorting + sort_changed: bool = false, + sort_column: ?usize = null, + sort_ascending: bool = true, + + // Row operations + row_inserted: bool = false, + row_deleted: bool = false, + row_moved: bool = false, + + // Auto-CRUD (si habilitado) + crud_action: ?CRUDAction = null, + crud_error: ?[]const u8 = null, + + // Lookup + lookup_triggered: bool = false, + lookup_found: bool = false, +}; +``` + +--- + +## 5. Comparativa con Table actual + +| Feature | Table actual | AdvancedTable | +|---------|-------------|---------------| +| Config | Callbacks `getCellData` | Schema-driven | +| Edición | Overlay básico | Excel-style completo | +| Auto-CRUD | Manual | Automático | +| Lookup | No | Sí | +| Calculated | No | Sí (futuro) | +| Row types | No | Sí (futuro) | +| Row locking | No | Sí | +| Sorting sync | Básico | Completo | +| State maps | Básico | Sparse optimizado | +| Callbacks | Legacy | Jerarquía Per-Column → Global → Default | +| Debounce | No | Sí (150ms default) | + +--- + +## 6. Fases de Implementación + +### Fase 1: Core (~600 LOC) +- [ ] types.zig - Tipos básicos +- [ ] schema.zig - TableSchema, ColumnDef +- [ ] state.zig - AdvancedTableState +- [ ] advanced_table.zig - Rendering básico + +### Fase 2: Navegación (~300 LOC) +- [ ] navigation.zig - Keyboard navigation +- [ ] Integración con FocusSystem + +### Fase 3: Edición (~400 LOC) +- [ ] editing.zig - Entry overlay, commit/cancel +- [ ] Parsers y formatters por tipo + +### Fase 4: Sorting (~200 LOC) +- [ ] sorting.zig - Sort por columna +- [ ] Sync de state maps + +### Fase 5: Row Operations (~250 LOC) +- [ ] row_ops.zig - Insert/Delete/Move +- [ ] State map management + +### Fase 6: Auto-CRUD (~300 LOC) +- [ ] autocrud.zig - Detection & execution +- [ ] DataStore interface +- [ ] Snapshots + +### Fase 7: Lookup (~200 LOC) +- [ ] lookup.zig - Lookup & auto-fill + +### Fase 8: Callbacks (~150 LOC) +- [ ] callbacks.zig - Sistema jerárquico +- [ ] Debounce + +**Total estimado**: ~2,400 LOC + +--- + +## 7. Ejemplo de Uso + +```zig +// Panel WHO List usando AdvancedTable + +const who_schema = TableSchema{ + .table_name = "who", + .columns = &[_]ColumnDef{ + .{ .name = "id", .title = "ID", .width = 50, .editable = false }, + .{ .name = "serie", .title = "Serie", .width = 80 }, + .{ .name = "numero", .title = "Número", .width = 80, .column_type = .integer }, + .{ .name = "fecha", .title = "Fecha", .width = 100, .column_type = .date }, + .{ .name = "cliente_nif", .title = "NIF", .width = 100, + .enable_lookup = true, + .lookup_table = "clientes", + .lookup_key_column = "nif", + .auto_fill_columns = &[_]AutoFillMapping{ + .{ .source_field = "nombre", .target_column = "cliente_nombre" }, + .{ .source_field = "direccion", .target_column = "cliente_direccion" }, + }, + }, + .{ .name = "cliente_nombre", .title = "Cliente", .width = 200 }, + .{ .name = "base", .title = "Base", .width = 100, .column_type = .money, .align = .right }, + .{ .name = "total", .title = "Total", .width = 100, .column_type = .money, .align = .right }, + }, + .auto_crud_enabled = true, + .show_row_state_indicators = true, + .data_store = &who_data_store, +}; + +var who_state = AdvancedTableState.init(allocator); +defer who_state.deinit(); + +// En el loop de UI: +const result = advancedTable(&ctx, &who_state, &who_schema); + +if (result.selection_changed) { + // Actualizar panel de detalle + if (result.selected_row) |row_idx| { + loadDocumentDetail(who_state.getRow(row_idx)); + } +} + +if (result.crud_action) |action| { + switch (action) { + .create => showStatus("Documento creado", .success), + .update => showStatus("Documento guardado", .success), + .delete => showStatus("Documento borrado", .success), + .none => {}, + } +} +``` + +--- + +## 8. Referencias + +- **Go Implementation**: `/mnt/cello2/arno/re/recode/go/simifactu-fyne/internal/ui/components/advanced_table/` +- **zcatgui Table actual**: `src/widgets/table/` +- **DVUI Audit**: `docs/research/DVUI_AUDIT_2025-12-17.md` diff --git a/src/widgets/advanced_table/advanced_table.zig b/src/widgets/advanced_table/advanced_table.zig new file mode 100644 index 0000000..a4ef48a --- /dev/null +++ b/src/widgets/advanced_table/advanced_table.zig @@ -0,0 +1,1022 @@ +//! 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"); + +// 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 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; + +// ============================================================================= +// 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; + + // 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.focused = 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.scroll_row; + const last_visible = @min(first_visible + visible_rows, table_state.getRowCount()); + + // Draw visible rows + for (first_visible..last_visible) |row_idx| { + const row_y = bounds.y + @as(i32, @intCast(header_h)) + + @as(i32, @intCast((row_idx - first_visible) * config.row_height)); + + const row_bounds = Layout.Rect.init( + bounds.x, + row_y, + bounds.w, + config.row_height, + ); + + drawRow(ctx, row_bounds, table_state, table_schema, row_idx, state_col_w, colors, &result); + } + + // 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.editing) { + // 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 {}; + } + } + + return result; +} + +// ============================================================================= +// 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 sort indicator + if (table_state.sort_column == @as(i32, @intCast(idx))) { + const indicator_x = col_x + @as(i32, @intCast(col.width)) - 12; + const indicator = switch (table_state.sort_direction) { + .ascending => "^", + .descending => "v", + .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; + } + + // 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 drawRow( + ctx: *Context, + bounds: Layout.Rect, + table_state: *AdvancedTableState, + table_schema: *const TableSchema, + row_idx: usize, + state_col_w: u32, + colors: *const TableColors, + result: *AdvancedTableResult, +) void { + const config = table_schema.config; + const is_selected_row = table_state.selected_row == @as(i32, @intCast(row_idx)); + const row_state = table_state.getRowState(row_idx); + + // Determine row background color + var row_bg = if (config.alternating_rows and row_idx % 2 == 1) + colors.row_alternate + else + colors.row_normal; + + // Apply state color overlay + row_bg = switch (row_state) { + .modified => blendColor(row_bg, colors.state_modified, 0.2), + .new => blendColor(row_bg, colors.state_new, 0.2), + .deleted => blendColor(row_bg, colors.state_deleted, 0.3), + .@"error" => blendColor(row_bg, colors.state_error, 0.3), + .normal => row_bg, + }; + + // Selection overlay + if (is_selected_row) { + row_bg = blendColor(row_bg, colors.selected_row, 0.4); + } + + // Draw row background + ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, config.row_height, row_bg)); + + var col_x = bounds.x; + const mouse = ctx.input.mousePos(); + + // State indicator column + if (state_col_w > 0) { + drawStateIndicator(ctx, col_x, bounds.y, state_col_w, config.row_height, row_state, colors); + col_x += @as(i32, @intCast(state_col_w)); + } + + // Data cells + for (table_schema.columns, 0..) |col, col_idx| { + if (!col.visible) continue; + + const cell_rect = Layout.Rect.init(col_x, bounds.y, col.width, config.row_height); + const is_selected_cell = is_selected_row and table_state.selected_col == @as(i32, @intCast(col_idx)); + const cell_clicked = cell_rect.contains(mouse.x, mouse.y) and ctx.input.mousePressed(.left); + + // Cell background for selected cell + if (is_selected_cell) { + ctx.pushCommand(Command.rect(col_x, bounds.y, col.width, config.row_height, colors.selected_cell)); + } + + // Get cell value + if (table_state.getRow(row_idx)) |row| { + const value = row.get(col.name); + var format_buf: [128]u8 = undefined; + const text = value.format(&format_buf); + + // Draw cell text + const text_y = bounds.y + @as(i32, @intCast((config.row_height - 8) / 2)); + const text_color = if (is_selected_cell) colors.text_selected else colors.text_normal; + ctx.pushCommand(Command.text(col_x + 4, text_y, text, text_color)); + } + + // Handle cell click + if (cell_clicked) { + 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; + } + } + + col_x += @as(i32, @intCast(col.width)); + } + + // Bottom border + ctx.pushCommand(Command.rect( + bounds.x, + bounds.y + @as(i32, @intCast(config.row_height)) - 1, + bounds.w, + 1, + colors.border, + )); +} + +fn drawStateIndicator( + ctx: *Context, + x: i32, + y: i32, + w: u32, + h: u32, + row_state: RowState, + colors: *const TableColors, +) void { + const indicator_size: u32 = 8; + const indicator_x = x + @as(i32, @intCast((w - indicator_size) / 2)); + const indicator_y = y + @as(i32, @intCast((h - indicator_size) / 2)); + + const color = switch (row_state) { + .modified => colors.indicator_modified, + .new => colors.indicator_new, + .deleted => colors.indicator_deleted, + .@"error" => colors.state_error, + .normal => return, // No indicator + }; + + // Draw circle indicator + ctx.pushCommand(Command.rect(indicator_x, indicator_y, indicator_size, indicator_size, color)); +} + +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; + + const scrollbar_x = bounds.x + @as(i32, @intCast(bounds.w -| scrollbar_w)); + const scrollbar_y = bounds.y + @as(i32, @intCast(header_h)); + + // Background + ctx.pushCommand(Command.rect(scrollbar_x, scrollbar_y, scrollbar_w, scrollbar_h, colors.border)); + + // Thumb + const thumb_ratio = @as(f32, @floatFromInt(visible_rows)) / @as(f32, @floatFromInt(total_rows)); + const thumb_h = @max(20, @as(u32, @intFromFloat(@as(f32, @floatFromInt(scrollbar_h)) * thumb_ratio))); + + const scroll_ratio = @as(f32, @floatFromInt(table_state.scroll_row)) / + @as(f32, @floatFromInt(@max(1, total_rows - visible_rows))); + const thumb_y_offset = @as(u32, @intFromFloat(scroll_ratio * @as(f32, @floatFromInt(scrollbar_h - thumb_h)))); + + ctx.pushCommand(Command.rect( + scrollbar_x + 2, + scrollbar_y + @as(i32, @intCast(thumb_y_offset)), + scrollbar_w - 4, + thumb_h, + 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.scroll_row) return; + const visible_row = row_idx - table_state.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)); + ctx.pushCommand(Command.text(col_x + 4, text_y, edit_text, colors.text_selected)); + + // Draw cursor + const cursor_x = col_x + 4 + @as(i32, @intCast(table_state.edit_cursor * 8)); + ctx.pushCommand(Command.rect(cursor_x, text_y, 1, 8, colors.text_selected)); +} + +// ============================================================================= +// Keyboard Handling +// ============================================================================= + +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; + + // Navigation + if (ctx.input.keyPressed(.up)) { + if (table_state.selected_row > 0) { + table_state.selectCell( + @intCast(table_state.selected_row - 1), + @intCast(@max(0, table_state.selected_col)), + ); + result.selection_changed = true; + } + } + if (ctx.input.keyPressed(.down)) { + if (table_state.selected_row < @as(i32, @intCast(row_count)) - 1) { + table_state.selectCell( + @intCast(table_state.selected_row + 1), + @intCast(@max(0, table_state.selected_col)), + ); + result.selection_changed = true; + } + } + if (ctx.input.keyPressed(.left)) { + if (table_state.selected_col > 0) { + table_state.selectCell( + @intCast(@max(0, table_state.selected_row)), + @intCast(table_state.selected_col - 1), + ); + result.selection_changed = true; + } + } + if (ctx.input.keyPressed(.right)) { + const col_count = table_schema.columns.len; + 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), + ); + result.selection_changed = true; + } + } + + // Page navigation + if (ctx.input.keyPressed(.page_up)) { + const new_row = @max(0, table_state.selected_row - @as(i32, @intCast(visible_rows))); + table_state.selectCell(@intCast(new_row), @intCast(@max(0, table_state.selected_col))); + result.selection_changed = true; + } + if (ctx.input.keyPressed(.page_down)) { + const new_row = @min( + @as(i32, @intCast(row_count)) - 1, + table_state.selected_row + @as(i32, @intCast(visible_rows)), + ); + table_state.selectCell(@intCast(new_row), @intCast(@max(0, table_state.selected_col))); + result.selection_changed = true; + } + + // Home/End (within row) + if (ctx.input.keyPressed(.home) and !ctx.input.modifiers.ctrl) { + table_state.selectCell(@intCast(@max(0, table_state.selected_row)), 0); + result.selection_changed = true; + } + if (ctx.input.keyPressed(.end) and !ctx.input.modifiers.ctrl) { + const last_col = table_schema.columns.len - 1; + table_state.selectCell(@intCast(@max(0, table_state.selected_row)), last_col); + result.selection_changed = true; + } + + // Ctrl+Home/End (beginning/end of table) + if (ctx.input.keyPressed(.home) and ctx.input.modifiers.ctrl) { + table_state.selectCell(0, 0); + result.selection_changed = true; + } + if (ctx.input.keyPressed(.end) and ctx.input.modifiers.ctrl) { + const last_row = if (row_count > 0) row_count - 1 else 0; + const last_col = table_schema.columns.len - 1; + table_state.selectCell(last_row, last_col); + result.selection_changed = true; + } + + // Tab navigation (if handle_tab is enabled) + if (config.handle_tab and ctx.input.keyPressed(.tab)) { + const shift = ctx.input.modifiers.shift; + const col_count = table_schema.columns.len; + + if (shift) { + // Shift+Tab: move left, wrap to previous row + if (table_state.selected_col > 0) { + table_state.selectCell( + @intCast(@max(0, table_state.selected_row)), + @intCast(table_state.selected_col - 1), + ); + result.selection_changed = true; + } else if (table_state.selected_row > 0 and config.wrap_navigation) { + // Wrap to end of previous row + table_state.selectCell( + @intCast(table_state.selected_row - 1), + col_count - 1, + ); + result.selection_changed = true; + } + } else { + // Tab: move right, wrap to next row + 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), + ); + result.selection_changed = true; + } else if (table_state.selected_row < @as(i32, @intCast(row_count)) - 1 and config.wrap_navigation) { + // Wrap to beginning of next row + table_state.selectCell( + @intCast(table_state.selected_row + 1), + 0, + ); + result.selection_changed = true; + } + } + } + + // Start editing with F2 or Enter + if (config.allow_edit and (ctx.input.keyPressed(.f2) or ctx.input.keyPressed(.enter))) { + 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) { + // Get current value + if (table_state.getRow(@intCast(table_state.selected_row))) |row| { + const value = row.get(table_schema.columns[col_idx].name); + 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; + } + } + } + } + + // Row operations (if allowed) + if (config.allow_row_operations) { + // Ctrl+N: Insert row at current position + if (ctx.input.keyPressed(.n) and ctx.input.modifiers.ctrl) { + const insert_idx: usize = if (table_state.selected_row >= 0) + @intCast(table_state.selected_row) + else + 0; + if (table_state.insertRow(insert_idx)) |new_idx| { + table_state.selectCell(new_idx, 0); + result.row_inserted = true; + result.selection_changed = true; + } else |_| {} + } + + // Ctrl+Delete: Delete current row + if (ctx.input.keyPressed(.delete) and ctx.input.modifiers.ctrl) { + 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; + } + } + } +} + +fn handleEditingKeyboard( + ctx: *Context, + table_state: *AdvancedTableState, + table_schema: *const TableSchema, + result: *AdvancedTableResult, +) void { + const config = table_schema.config; + + // Escape: cancel editing (1st = revert, 2nd = exit without save) + if (ctx.input.keyPressed(.escape)) { + table_state.escape_count += 1; + if (table_state.escape_count >= 2 or table_state.original_value == null) { + // Exit without saving + table_state.stopEditing(); + result.edit_ended = true; + } else { + // Revert to original value + if (table_state.original_value) |orig| { + var format_buf: [128]u8 = undefined; + const text = orig.format(&format_buf); + table_state.startEditing(text); + } + } + return; + } + + // Reset escape count on any other key + table_state.escape_count = 0; + + // Enter: confirm editing + if (ctx.input.keyPressed(.enter)) { + commitEdit(table_state, table_schema, result); + table_state.stopEditing(); + result.edit_ended = true; + return; + } + + // Tab: confirm and move to next cell + if (ctx.input.keyPressed(.tab) and config.handle_tab) { + commitEdit(table_state, table_schema, result); + table_state.stopEditing(); + result.edit_ended = true; + + // Move to next/prev cell + const shift = ctx.input.modifiers.shift; + const col_count = table_schema.columns.len; + const row_count = table_state.getRowCount(); + + if (shift) { + // 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; + return; + } + + // Cursor movement within edit buffer + if (ctx.input.keyPressed(.left)) { + if (table_state.edit_cursor > 0) { + table_state.edit_cursor -= 1; + } + return; + } + if (ctx.input.keyPressed(.right)) { + if (table_state.edit_cursor < table_state.edit_len) { + table_state.edit_cursor += 1; + } + return; + } + if (ctx.input.keyPressed(.home)) { + table_state.edit_cursor = 0; + return; + } + if (ctx.input.keyPressed(.end)) { + table_state.edit_cursor = table_state.edit_len; + return; + } + + // Backspace: delete char before cursor + if (ctx.input.keyPressed(.backspace)) { + if (table_state.edit_cursor > 0) { + // Shift characters left + var i: usize = table_state.edit_cursor - 1; + while (i < table_state.edit_len - 1) : (i += 1) { + table_state.edit_buffer[i] = table_state.edit_buffer[i + 1]; + } + table_state.edit_len -= 1; + table_state.edit_cursor -= 1; + } + return; + } + + // Delete: delete char at cursor + if (ctx.input.keyPressed(.delete)) { + if (table_state.edit_cursor < table_state.edit_len) { + // Shift characters left + var i: usize = table_state.edit_cursor; + while (i < table_state.edit_len - 1) : (i += 1) { + table_state.edit_buffer[i] = table_state.edit_buffer[i + 1]; + } + table_state.edit_len -= 1; + } + return; + } + + // Character input + if (ctx.input.text_input_len > 0) { + const text = ctx.input.text_input[0..ctx.input.text_input_len]; + for (text) |ch| { + if (ch >= 32 and ch < 127) { + if (table_state.edit_len < types.MAX_EDIT_BUFFER - 1) { + // Shift characters right + var i: usize = table_state.edit_len; + while (i > table_state.edit_cursor) : (i -= 1) { + table_state.edit_buffer[i] = table_state.edit_buffer[i - 1]; + } + table_state.edit_buffer[table_state.edit_cursor] = ch; + table_state.edit_len += 1; + table_state.edit_cursor += 1; + } + } + } + } +} + +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; + } +} + +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.scroll_row) { + table_state.scroll_row = row; + } else if (row >= table_state.scroll_row + visible_rows) { + table_state.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, + ); +} + +// ============================================================================= +// 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); +} diff --git a/src/widgets/advanced_table/schema.zig b/src/widgets/advanced_table/schema.zig new file mode 100644 index 0000000..e60e2de --- /dev/null +++ b/src/widgets/advanced_table/schema.zig @@ -0,0 +1,373 @@ +//! AdvancedTable Schema - Column and table definitions +//! +//! Schema-driven configuration for AdvancedTable. +//! Defines columns, their types, and behaviors. + +const std = @import("std"); +const types = @import("types.zig"); + +pub const CellValue = types.CellValue; +pub const ColumnType = types.ColumnType; +pub const RowLockState = types.RowLockState; +pub const Row = types.Row; +pub const ValidationResult = types.ValidationResult; +pub const TableColors = types.TableColors; +pub const TableConfig = types.TableConfig; +pub const ValidatorFn = types.ValidatorFn; +pub const FormatterFn = types.FormatterFn; +pub const ParserFn = types.ParserFn; +pub const GetRowLockStateFn = types.GetRowLockStateFn; +pub const OnRowSelectedFn = types.OnRowSelectedFn; +pub const OnCellChangedFn = types.OnCellChangedFn; +pub const OnActiveRowChangedFn = types.OnActiveRowChangedFn; + +// ============================================================================= +// Auto-Fill Mapping +// ============================================================================= + +/// Mapping for auto-fill after lookup +pub const AutoFillMapping = struct { + /// Field name in lookup table + source_field: []const u8, + /// Column name in this table to fill + target_column: []const u8, +}; + +// ============================================================================= +// Column Alignment +// ============================================================================= + +/// Text alignment for column content +pub const ColumnAlign = enum { + left, + center, + right, +}; + +// ============================================================================= +// Column Definition +// ============================================================================= + +/// Complete column definition +pub const ColumnDef = struct { + /// Internal name (key in row data) + name: []const u8, + + /// Display title in header + title: []const u8, + + /// Width in pixels + width: u32 = 100, + + /// Data type + column_type: ColumnType = .string, + + /// Is editable + editable: bool = true, + + /// Is sortable + sortable: bool = true, + + /// Minimum width when resizing + min_width: u32 = 40, + + /// Maximum width (0 = unlimited) + max_width: u32 = 0, + + /// Text alignment + text_align: ColumnAlign = .left, + + /// Is visible (can be hidden via config) + visible: bool = true, + + // ========================================================================= + // Lookup Configuration + // ========================================================================= + + /// Enable database lookup + enable_lookup: bool = false, + + /// Table name for lookup + lookup_table: ?[]const u8 = null, + + /// Column in lookup table to match against + lookup_key_column: ?[]const u8 = null, + + /// Columns to auto-fill after successful lookup + auto_fill_columns: ?[]const AutoFillMapping = null, + + // ========================================================================= + // Callbacks (per-column, optional) + // ========================================================================= + + /// Custom validator for this column + validator: ?ValidatorFn = null, + + /// Custom formatter for display + formatter: ?FormatterFn = null, + + /// Custom parser for text input + parser: ?ParserFn = null, + + // ========================================================================= + // Select options (for ColumnType.select) + // ========================================================================= + + /// Options for select dropdown + select_options: ?[]const SelectOption = null, + + // ========================================================================= + // Helper Methods + // ========================================================================= + + /// Get effective width (clamped to min/max) + pub fn getEffectiveWidth(self: *const ColumnDef, requested: u32) u32 { + var w = requested; + if (w < self.min_width) w = self.min_width; + if (self.max_width > 0 and w > self.max_width) w = self.max_width; + return w; + } + + /// Check if this column can be edited + pub fn canEdit(self: *const ColumnDef) bool { + return self.editable and self.visible; + } + + /// Check if this column has lookup enabled + pub fn hasLookup(self: *const ColumnDef) bool { + return self.enable_lookup and + self.lookup_table != null and + self.lookup_key_column != null; + } +}; + +/// Option for select dropdown +pub const SelectOption = struct { + /// Value stored in data + value: CellValue, + /// Display label + label: []const u8, +}; + +// ============================================================================= +// DataStore Interface +// ============================================================================= + +/// Interface for data persistence (database, file, API, etc.) +pub const DataStore = struct { + ptr: *anyopaque, + vtable: *const VTable, + + pub const VTable = struct { + /// Load all rows from data source + load: *const fn (ptr: *anyopaque, allocator: std.mem.Allocator) anyerror!std.ArrayList(Row), + + /// Save row (INSERT or UPDATE depending on ID) + save: *const fn (ptr: *anyopaque, row: *Row) anyerror!void, + + /// Delete row + delete: *const fn (ptr: *anyopaque, row: *Row) anyerror!void, + + /// Lookup in related table + lookup: *const fn (ptr: *anyopaque, table: []const u8, key_column: []const u8, key_value: CellValue, allocator: std.mem.Allocator) anyerror!?Row, + }; + + /// Load all rows + pub fn load(self: DataStore, allocator: std.mem.Allocator) !std.ArrayList(Row) { + return self.vtable.load(self.ptr, allocator); + } + + /// Save row (INSERT or UPDATE) + pub fn save(self: DataStore, row: *Row) !void { + return self.vtable.save(self.ptr, row); + } + + /// Delete row + pub fn delete(self: DataStore, row: *Row) !void { + return self.vtable.delete(self.ptr, row); + } + + /// Lookup in related table + pub fn lookup(self: DataStore, table: []const u8, key_column: []const u8, key_value: CellValue, allocator: std.mem.Allocator) !?Row { + return self.vtable.lookup(self.ptr, table, key_column, key_value, allocator); + } +}; + +// ============================================================================= +// Table Schema +// ============================================================================= + +/// Complete schema definition for AdvancedTable +pub const TableSchema = struct { + /// Table name (for DataStore operations) + table_name: []const u8, + + /// Column definitions + columns: []const ColumnDef, + + /// Configuration + config: TableConfig = .{}, + + /// Colors (optional override) + colors: ?*const TableColors = null, + + /// DataStore for persistence (optional) + data_store: ?DataStore = null, + + // ========================================================================= + // Global Callbacks + // ========================================================================= + + /// Called when row is selected + on_row_selected: ?OnRowSelectedFn = null, + + /// Called when cell value changes + on_cell_changed: ?OnCellChangedFn = null, + + /// Called when active row changes (for loading detail panels) + on_active_row_changed: ?OnActiveRowChangedFn = null, + + /// Called to get row lock state + get_row_lock_state: ?GetRowLockStateFn = null, + + // ========================================================================= + // Helper Methods + // ========================================================================= + + /// Get column by name + pub fn getColumn(self: *const TableSchema, name: []const u8) ?*const ColumnDef { + for (self.columns) |*col| { + if (std.mem.eql(u8, col.name, name)) { + return col; + } + } + return null; + } + + /// Get column index by name + pub fn getColumnIndex(self: *const TableSchema, name: []const u8) ?usize { + for (self.columns, 0..) |col, i| { + if (std.mem.eql(u8, col.name, name)) { + return i; + } + } + return null; + } + + /// Get visible column count + pub fn getVisibleColumnCount(self: *const TableSchema) usize { + var count: usize = 0; + for (self.columns) |col| { + if (col.visible) count += 1; + } + return count; + } + + /// Get total width of all visible columns + pub fn getTotalWidth(self: *const TableSchema) u32 { + var total: u32 = 0; + for (self.columns) |col| { + if (col.visible) total += col.width; + } + return total; + } + + /// Find first editable column + pub fn getFirstEditableColumn(self: *const TableSchema) ?usize { + for (self.columns, 0..) |col, i| { + if (col.canEdit()) { + return i; + } + } + return null; + } + + /// Find next editable column after given index + pub fn getNextEditableColumn(self: *const TableSchema, after: usize) ?usize { + var i = after + 1; + while (i < self.columns.len) : (i += 1) { + if (self.columns[i].canEdit()) { + return i; + } + } + return null; + } + + /// Find previous editable column before given index + pub fn getPrevEditableColumn(self: *const TableSchema, before: usize) ?usize { + if (before == 0) return null; + var i = before - 1; + while (true) { + if (self.columns[i].canEdit()) { + return i; + } + if (i == 0) break; + i -= 1; + } + return null; + } +}; + +// ============================================================================= +// Tests +// ============================================================================= + +test "ColumnDef getEffectiveWidth" { + const col = ColumnDef{ + .name = "test", + .title = "Test", + .width = 100, + .min_width = 50, + .max_width = 200, + }; + + try std.testing.expectEqual(@as(u32, 100), col.getEffectiveWidth(100)); + try std.testing.expectEqual(@as(u32, 50), col.getEffectiveWidth(30)); // clamped to min + try std.testing.expectEqual(@as(u32, 200), col.getEffectiveWidth(300)); // clamped to max +} + +test "ColumnDef canEdit" { + const editable = ColumnDef{ .name = "a", .title = "A", .editable = true, .visible = true }; + const not_editable = ColumnDef{ .name = "b", .title = "B", .editable = false, .visible = true }; + const hidden = ColumnDef{ .name = "c", .title = "C", .editable = true, .visible = false }; + + try std.testing.expect(editable.canEdit()); + try std.testing.expect(!not_editable.canEdit()); + try std.testing.expect(!hidden.canEdit()); +} + +test "TableSchema getColumn" { + const columns = [_]ColumnDef{ + .{ .name = "id", .title = "ID" }, + .{ .name = "name", .title = "Name" }, + .{ .name = "value", .title = "Value" }, + }; + + const schema = TableSchema{ + .table_name = "test", + .columns = &columns, + }; + + const name_col = schema.getColumn("name"); + try std.testing.expect(name_col != null); + try std.testing.expectEqualStrings("Name", name_col.?.title); + + const unknown_col = schema.getColumn("unknown"); + try std.testing.expect(unknown_col == null); +} + +test "TableSchema getFirstEditableColumn" { + const columns = [_]ColumnDef{ + .{ .name = "id", .title = "ID", .editable = false }, + .{ .name = "name", .title = "Name", .editable = true }, + .{ .name = "value", .title = "Value", .editable = true }, + }; + + const schema = TableSchema{ + .table_name = "test", + .columns = &columns, + }; + + const first = schema.getFirstEditableColumn(); + try std.testing.expectEqual(@as(?usize, 1), first); +} diff --git a/src/widgets/advanced_table/state.zig b/src/widgets/advanced_table/state.zig new file mode 100644 index 0000000..e4dfb95 --- /dev/null +++ b/src/widgets/advanced_table/state.zig @@ -0,0 +1,762 @@ +//! AdvancedTable State - Mutable state management +//! +//! Manages selection, editing, dirty tracking, sorting, and snapshots. + +const std = @import("std"); +const types = @import("types.zig"); +const schema_mod = @import("schema.zig"); + +pub const CellValue = types.CellValue; +pub const RowState = types.RowState; +pub const SortDirection = types.SortDirection; +pub const CRUDAction = types.CRUDAction; +pub const Row = types.Row; +pub const TableSchema = schema_mod.TableSchema; +pub const MAX_EDIT_BUFFER = types.MAX_EDIT_BUFFER; + +// ============================================================================= +// AdvancedTable State +// ============================================================================= + +/// Complete state for AdvancedTable +pub const AdvancedTableState = struct { + // ========================================================================= + // Data + // ========================================================================= + + /// Row data + rows: std.ArrayListUnmanaged(Row) = .{}, + + /// Allocator for dynamic allocations + allocator: std.mem.Allocator, + + // ========================================================================= + // Selection + // ========================================================================= + + /// Currently selected row (-1 = none) + selected_row: i32 = -1, + + /// Currently selected column (-1 = none) + selected_col: i32 = -1, + + /// Previous selected row (for callbacks) + prev_selected_row: i32 = -1, + + /// Previous selected column + prev_selected_col: i32 = -1, + + // ========================================================================= + // Editing + // ========================================================================= + + /// Is currently editing a cell + editing: bool = false, + + /// Edit buffer for current edit + edit_buffer: [MAX_EDIT_BUFFER]u8 = undefined, + + /// Length of text in edit buffer + edit_len: usize = 0, + + /// Cursor position in edit buffer + edit_cursor: usize = 0, + + /// Original value before editing (for revert on Escape) + original_value: ?CellValue = null, + + /// Escape count (1 = revert, 2 = cancel) + escape_count: u8 = 0, + + // ========================================================================= + // Sorting + // ========================================================================= + + /// Current sort column (-1 = none) + sort_column: i32 = -1, + + /// Sort direction + sort_direction: SortDirection = .none, + + /// Original order for restore (saved on first sort) + original_order: std.ArrayListUnmanaged(Row) = .{}, + + /// Whether original order has been saved + has_original_order: bool = false, + + // ========================================================================= + // Scrolling + // ========================================================================= + + /// First visible row + scroll_row: usize = 0, + + /// Horizontal scroll offset (in pixels) + scroll_x: usize = 0, + + // ========================================================================= + // State Maps (sparse - only modified rows) + // ========================================================================= + + /// Dirty (modified) rows + dirty_rows: std.AutoHashMap(usize, bool), + + /// New rows (not yet saved to DB) + new_rows: std.AutoHashMap(usize, bool), + + /// Deleted rows (marked for deletion) + deleted_rows: std.AutoHashMap(usize, bool), + + /// Rows with validation errors + validation_errors: std.AutoHashMap(usize, bool), + + // ========================================================================= + // Snapshots (for Auto-CRUD) + // ========================================================================= + + /// Row snapshots captured when entering row + row_snapshots: std.AutoHashMap(usize, Row), + + // ========================================================================= + // Focus + // ========================================================================= + + /// Does this table have focus + focused: bool = false, + + // ========================================================================= + // Lifecycle + // ========================================================================= + + pub fn init(allocator: std.mem.Allocator) AdvancedTableState { + return .{ + .allocator = allocator, + .dirty_rows = std.AutoHashMap(usize, bool).init(allocator), + .new_rows = std.AutoHashMap(usize, bool).init(allocator), + .deleted_rows = std.AutoHashMap(usize, bool).init(allocator), + .validation_errors = std.AutoHashMap(usize, bool).init(allocator), + .row_snapshots = std.AutoHashMap(usize, Row).init(allocator), + }; + } + + pub fn deinit(self: *AdvancedTableState) void { + // Deinit all rows + for (self.rows.items) |*row| { + row.deinit(); + } + self.rows.deinit(self.allocator); + + // Deinit state maps + self.dirty_rows.deinit(); + self.new_rows.deinit(); + self.deleted_rows.deinit(); + self.validation_errors.deinit(); + + // Deinit snapshots + var snapshot_iter = self.row_snapshots.valueIterator(); + while (snapshot_iter.next()) |row| { + var mutable_row = row.*; + mutable_row.deinit(); + } + self.row_snapshots.deinit(); + + // Deinit original order if exists + if (self.has_original_order) { + for (self.original_order.items) |*row| { + row.deinit(); + } + self.original_order.deinit(self.allocator); + } + } + + // ========================================================================= + // Data Access + // ========================================================================= + + /// Get row count + pub fn getRowCount(self: *const AdvancedTableState) usize { + return self.rows.items.len; + } + + /// Get row by index + pub fn getRow(self: *AdvancedTableState, index: usize) ?*Row { + if (index >= self.rows.items.len) return null; + return &self.rows.items[index]; + } + + /// Get row by index (const) + pub fn getRowConst(self: *const AdvancedTableState, index: usize) ?*const Row { + if (index >= self.rows.items.len) return null; + return &self.rows.items[index]; + } + + /// Set rows (replaces all data) + pub fn setRows(self: *AdvancedTableState, new_rows: []const Row) !void { + // Clear existing + for (self.rows.items) |*row| { + row.deinit(); + } + self.rows.clearRetainingCapacity(); + + // Copy new rows + for (new_rows) |row| { + const cloned = try row.clone(self.allocator); + try self.rows.append(self.allocator, cloned); + } + + // Reset state + self.clearAllState(); + } + + /// Clear all state maps + pub fn clearAllState(self: *AdvancedTableState) void { + self.dirty_rows.clearRetainingCapacity(); + self.new_rows.clearRetainingCapacity(); + self.deleted_rows.clearRetainingCapacity(); + self.validation_errors.clearRetainingCapacity(); + + // Clear snapshots + var snapshot_iter = self.row_snapshots.valueIterator(); + while (snapshot_iter.next()) |row| { + var mutable_row = row.*; + mutable_row.deinit(); + } + self.row_snapshots.clearRetainingCapacity(); + + // Clear selection + self.selected_row = -1; + self.selected_col = -1; + self.prev_selected_row = -1; + self.prev_selected_col = -1; + + // Clear editing + self.editing = false; + self.edit_len = 0; + self.escape_count = 0; + + // Clear sorting + self.sort_column = -1; + self.sort_direction = .none; + } + + // ========================================================================= + // Row Operations + // ========================================================================= + + /// Insert new empty row at index + pub fn insertRow(self: *AdvancedTableState, index: usize) !usize { + const actual_index = @min(index, self.rows.items.len); + + // Create empty row + const new_row = Row.init(self.allocator); + try self.rows.insert(self.allocator, actual_index, new_row); + + // Shift state maps + self.shiftRowIndicesDown(actual_index); + + // Mark as new + try self.new_rows.put(actual_index, true); + + return actual_index; + } + + /// Append new empty row at end + pub fn appendRow(self: *AdvancedTableState) !usize { + const new_row = Row.init(self.allocator); + try self.rows.append(self.allocator, new_row); + + const index = self.rows.items.len - 1; + try self.new_rows.put(index, true); + + return index; + } + + /// Delete row at index + pub fn deleteRow(self: *AdvancedTableState, index: usize) void { + if (index >= self.rows.items.len) return; + + // Deinit the row + self.rows.items[index].deinit(); + + // Remove from array + _ = self.rows.orderedRemove(index); + + // Shift state maps up + self.shiftRowIndicesUp(index); + + // Adjust selection + if (self.selected_row > @as(i32, @intCast(index))) { + self.selected_row -= 1; + } else if (self.selected_row == @as(i32, @intCast(index))) { + if (self.selected_row >= @as(i32, @intCast(self.rows.items.len))) { + self.selected_row = @as(i32, @intCast(self.rows.items.len)) - 1; + } + } + } + + /// Move row up + pub fn moveRowUp(self: *AdvancedTableState, index: usize) bool { + if (index == 0 or index >= self.rows.items.len) return false; + + // Swap rows + const temp = self.rows.items[index - 1]; + self.rows.items[index - 1] = self.rows.items[index]; + self.rows.items[index] = temp; + + // Swap state maps + self.swapRowStates(index - 1, index); + + return true; + } + + /// Move row down + pub fn moveRowDown(self: *AdvancedTableState, index: usize) bool { + if (index >= self.rows.items.len - 1) return false; + + // Swap rows + const temp = self.rows.items[index + 1]; + self.rows.items[index + 1] = self.rows.items[index]; + self.rows.items[index] = temp; + + // Swap state maps + self.swapRowStates(index, index + 1); + + return true; + } + + // ========================================================================= + // State Queries + // ========================================================================= + + /// Get row state + pub fn getRowState(self: *const AdvancedTableState, index: usize) RowState { + if (self.deleted_rows.get(index)) |_| return .deleted; + if (self.validation_errors.get(index)) |_| return .@"error"; + if (self.new_rows.get(index)) |_| return .new; + if (self.dirty_rows.get(index)) |_| return .modified; + return .normal; + } + + /// Mark row as dirty (modified) + pub fn markDirty(self: *AdvancedTableState, index: usize) void { + self.dirty_rows.put(index, true) catch {}; + } + + /// Mark row as new + pub fn markNew(self: *AdvancedTableState, index: usize) void { + self.new_rows.put(index, true) catch {}; + } + + /// Mark row as deleted + pub fn markDeleted(self: *AdvancedTableState, index: usize) void { + self.deleted_rows.put(index, true) catch {}; + } + + /// Mark row as having validation error + pub fn markError(self: *AdvancedTableState, index: usize) void { + self.validation_errors.put(index, true) catch {}; + } + + /// Clear all state for row + pub fn clearRowState(self: *AdvancedTableState, index: usize) void { + _ = self.dirty_rows.remove(index); + _ = self.new_rows.remove(index); + _ = self.deleted_rows.remove(index); + _ = self.validation_errors.remove(index); + } + + /// Check if row is dirty (modified) + pub fn isDirty(self: *const AdvancedTableState, index: usize) bool { + return self.dirty_rows.get(index) orelse false; + } + + /// Check if row is new + pub fn isNew(self: *const AdvancedTableState, index: usize) bool { + return self.new_rows.get(index) orelse false; + } + + /// Check if row is marked for deletion + pub fn isDeleted(self: *const AdvancedTableState, index: usize) bool { + return self.deleted_rows.get(index) orelse false; + } + + /// Check if row has validation error + pub fn hasError(self: *const AdvancedTableState, index: usize) bool { + return self.validation_errors.get(index) orelse false; + } + + /// Check if any row is dirty + pub fn hasAnyDirty(self: *const AdvancedTableState) bool { + return self.dirty_rows.count() > 0 or self.new_rows.count() > 0; + } + + // ========================================================================= + // Selection + // ========================================================================= + + /// Select cell + pub fn selectCell(self: *AdvancedTableState, row: usize, col: usize) void { + self.prev_selected_row = self.selected_row; + self.prev_selected_col = self.selected_col; + self.selected_row = @intCast(row); + self.selected_col = @intCast(col); + } + + /// Clear selection + pub fn clearSelection(self: *AdvancedTableState) void { + self.prev_selected_row = self.selected_row; + self.prev_selected_col = self.selected_col; + self.selected_row = -1; + self.selected_col = -1; + } + + /// Get selected cell (row, col) or null + pub fn getSelectedCell(self: *const AdvancedTableState) ?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), + }; + } + + /// Check if row changed from previous selection + pub fn rowChanged(self: *const AdvancedTableState) bool { + return self.selected_row != self.prev_selected_row; + } + + // ========================================================================= + // Editing + // ========================================================================= + + /// Start editing current cell + pub fn startEditing(self: *AdvancedTableState, initial_value: []const u8) void { + self.editing = true; + self.escape_count = 0; + + // Copy initial value to edit buffer + const len = @min(initial_value.len, MAX_EDIT_BUFFER); + @memcpy(self.edit_buffer[0..len], initial_value[0..len]); + self.edit_len = len; + self.edit_cursor = len; + } + + /// Stop editing + pub fn stopEditing(self: *AdvancedTableState) void { + self.editing = false; + self.edit_len = 0; + self.edit_cursor = 0; + self.escape_count = 0; + self.original_value = null; + } + + /// Get current edit text + pub fn getEditText(self: *const AdvancedTableState) []const u8 { + return self.edit_buffer[0..self.edit_len]; + } + + /// Insert text at cursor position + pub fn insertText(self: *AdvancedTableState, text: []const u8) void { + for (text) |c| { + if (self.edit_len < MAX_EDIT_BUFFER) { + // Shift text after cursor + var i = self.edit_len; + while (i > self.edit_cursor) : (i -= 1) { + self.edit_buffer[i] = self.edit_buffer[i - 1]; + } + self.edit_buffer[self.edit_cursor] = c; + self.edit_len += 1; + self.edit_cursor += 1; + } + } + } + + /// Delete character before cursor (backspace) + pub fn deleteBackward(self: *AdvancedTableState) void { + if (self.edit_cursor > 0) { + // Shift text after cursor + var i = self.edit_cursor - 1; + while (i < self.edit_len - 1) : (i += 1) { + self.edit_buffer[i] = self.edit_buffer[i + 1]; + } + self.edit_len -= 1; + self.edit_cursor -= 1; + } + } + + /// Delete character at cursor (delete) + pub fn deleteForward(self: *AdvancedTableState) void { + if (self.edit_cursor < self.edit_len) { + // Shift text after cursor + var i = self.edit_cursor; + while (i < self.edit_len - 1) : (i += 1) { + self.edit_buffer[i] = self.edit_buffer[i + 1]; + } + self.edit_len -= 1; + } + } + + // ========================================================================= + // Snapshots (for Auto-CRUD) + // ========================================================================= + + /// Capture snapshot of row for Auto-CRUD detection + pub fn captureSnapshot(self: *AdvancedTableState, index: usize) !void { + if (index >= self.rows.items.len) return; + + // Remove old snapshot if exists + if (self.row_snapshots.fetchRemove(index)) |kv| { + var old_row = kv.value; + old_row.deinit(); + } + + // Clone current row as snapshot + const snapshot = try self.rows.items[index].clone(self.allocator); + try self.row_snapshots.put(index, snapshot); + } + + /// Get snapshot for row + pub fn getSnapshot(self: *const AdvancedTableState, index: usize) ?*const Row { + return self.row_snapshots.getPtr(index); + } + + /// Clear snapshot for row + pub fn clearSnapshot(self: *AdvancedTableState, index: usize) void { + if (self.row_snapshots.fetchRemove(index)) |kv| { + var old_row = kv.value; + old_row.deinit(); + } + } + + // ========================================================================= + // Sorting + // ========================================================================= + + /// Toggle sort on column + pub fn toggleSort(self: *AdvancedTableState, col: usize) SortDirection { + if (self.sort_column == @as(i32, @intCast(col))) { + // 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 = @intCast(col); + self.sort_direction = .ascending; + } + return self.sort_direction; + } + + /// Clear sort + pub fn clearSort(self: *AdvancedTableState) void { + self.sort_column = -1; + self.sort_direction = .none; + } + + /// Get sort info + pub fn getSortInfo(self: *const AdvancedTableState) ?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, + }; + } + + // ========================================================================= + // Internal Helpers + // ========================================================================= + + const MAX_STATE_ENTRIES = 64; // Maximum entries we expect in state maps + + /// Shift row indices down (after insert) + fn shiftRowIndicesDown(self: *AdvancedTableState, insert_index: usize) void { + shiftMapIndicesDown(&self.dirty_rows, insert_index); + shiftMapIndicesDown(&self.new_rows, insert_index); + shiftMapIndicesDown(&self.deleted_rows, insert_index); + shiftMapIndicesDown(&self.validation_errors, insert_index); + } + + /// Shift row indices up (after delete) + fn shiftRowIndicesUp(self: *AdvancedTableState, delete_index: usize) void { + shiftMapIndicesUp(&self.dirty_rows, delete_index); + shiftMapIndicesUp(&self.new_rows, delete_index); + shiftMapIndicesUp(&self.deleted_rows, delete_index); + shiftMapIndicesUp(&self.validation_errors, delete_index); + } +}; + +// ============================================================================= +// Map Shifting Helpers (standalone functions to avoid allocator issues) +// ============================================================================= + +const Entry = struct { key: usize, value: bool }; + +fn shiftMapIndicesDown(map: *std.AutoHashMap(usize, bool), insert_index: usize) void { + // Use bounded array to avoid allocation + var entries: [AdvancedTableState.MAX_STATE_ENTRIES]Entry = undefined; + var count: usize = 0; + + // Collect entries that need shifting + var iter = map.iterator(); + while (iter.next()) |entry| { + if (count >= AdvancedTableState.MAX_STATE_ENTRIES) break; + if (entry.key_ptr.* >= insert_index) { + entries[count] = .{ .key = entry.key_ptr.* + 1, .value = entry.value_ptr.* }; + } else { + entries[count] = .{ .key = entry.key_ptr.*, .value = entry.value_ptr.* }; + } + count += 1; + } + + // Rebuild map + map.clearRetainingCapacity(); + for (entries[0..count]) |e| { + map.put(e.key, e.value) catch {}; + } +} + +fn shiftMapIndicesUp(map: *std.AutoHashMap(usize, bool), delete_index: usize) void { + // Use bounded array to avoid allocation + var entries: [AdvancedTableState.MAX_STATE_ENTRIES]Entry = undefined; + var count: usize = 0; + + // Collect entries, skipping deleted and shifting down + var iter = map.iterator(); + while (iter.next()) |entry| { + if (count >= AdvancedTableState.MAX_STATE_ENTRIES) break; + if (entry.key_ptr.* == delete_index) { + continue; // Skip deleted index + } else if (entry.key_ptr.* > delete_index) { + entries[count] = .{ .key = entry.key_ptr.* - 1, .value = entry.value_ptr.* }; + } else { + entries[count] = .{ .key = entry.key_ptr.*, .value = entry.value_ptr.* }; + } + count += 1; + } + + // Rebuild map + map.clearRetainingCapacity(); + for (entries[0..count]) |e| { + map.put(e.key, e.value) catch {}; + } +} + +// ============================================================================= +// Result Type +// ============================================================================= + +/// Result returned from advancedTable() call +pub const AdvancedTableResult = struct { + // Selection + selection_changed: bool = false, + selected_row: ?usize = null, + selected_col: ?usize = null, + + // Editing + edit_started: bool = false, + edit_ended: bool = false, + cell_edited: bool = false, + + // Sorting + sort_changed: bool = false, + sort_column: ?usize = null, + sort_direction: SortDirection = .none, + + // Row operations + row_inserted: bool = false, + row_deleted: bool = false, + row_moved: bool = false, + + // Auto-CRUD + crud_action: ?CRUDAction = null, + crud_success: bool = true, + + // Focus + clicked: bool = false, +}; + +// ============================================================================= +// Tests +// ============================================================================= + +test "AdvancedTableState init/deinit" { + var state = AdvancedTableState.init(std.testing.allocator); + defer state.deinit(); + + try std.testing.expectEqual(@as(usize, 0), state.getRowCount()); +} + +test "AdvancedTableState selection" { + var state = AdvancedTableState.init(std.testing.allocator); + defer state.deinit(); + + try std.testing.expect(state.getSelectedCell() == null); + + state.selectCell(5, 3); + const cell = state.getSelectedCell().?; + try std.testing.expectEqual(@as(usize, 5), cell.row); + try std.testing.expectEqual(@as(usize, 3), cell.col); + + state.clearSelection(); + try std.testing.expect(state.getSelectedCell() == null); +} + +test "AdvancedTableState row states" { + var state = AdvancedTableState.init(std.testing.allocator); + defer state.deinit(); + + try std.testing.expectEqual(RowState.normal, state.getRowState(0)); + + state.markDirty(0); + try std.testing.expectEqual(RowState.modified, state.getRowState(0)); + + state.markNew(1); + try std.testing.expectEqual(RowState.new, state.getRowState(1)); + + state.markDeleted(2); + try std.testing.expectEqual(RowState.deleted, state.getRowState(2)); + + state.clearRowState(0); + try std.testing.expectEqual(RowState.normal, state.getRowState(0)); +} + +test "AdvancedTableState editing" { + var state = AdvancedTableState.init(std.testing.allocator); + defer state.deinit(); + + try std.testing.expect(!state.editing); + + state.startEditing("Hello"); + try std.testing.expect(state.editing); + try std.testing.expectEqualStrings("Hello", state.getEditText()); + + state.insertText(" World"); + try std.testing.expectEqualStrings("Hello World", state.getEditText()); + + state.deleteBackward(); + try std.testing.expectEqualStrings("Hello Worl", state.getEditText()); + + state.stopEditing(); + try std.testing.expect(!state.editing); +} + +test "AdvancedTableState sorting" { + var state = AdvancedTableState.init(std.testing.allocator); + defer state.deinit(); + + try std.testing.expect(state.getSortInfo() == null); + + // First click - ascending + const dir1 = state.toggleSort(2); + try std.testing.expectEqual(SortDirection.ascending, dir1); + try std.testing.expectEqual(@as(usize, 2), state.getSortInfo().?.column); + + // Second click - descending + const dir2 = state.toggleSort(2); + try std.testing.expectEqual(SortDirection.descending, dir2); + + // Third click - none + const dir3 = state.toggleSort(2); + try std.testing.expectEqual(SortDirection.none, dir3); + try std.testing.expect(state.getSortInfo() == null); +} diff --git a/src/widgets/advanced_table/types.zig b/src/widgets/advanced_table/types.zig new file mode 100644 index 0000000..c9ca738 --- /dev/null +++ b/src/widgets/advanced_table/types.zig @@ -0,0 +1,369 @@ +//! AdvancedTable Types - Core type definitions +//! +//! Enums, configs, and fundamental types for AdvancedTable. + +const std = @import("std"); +const Style = @import("../../core/style.zig"); + +// ============================================================================= +// Column Types +// ============================================================================= + +/// Data types for columns +pub const ColumnType = enum { + string, + integer, + float, + money, + boolean, + date, + select, // Dropdown with options + lookup, // Lookup in related table +}; + +// ============================================================================= +// Row States +// ============================================================================= + +/// Row state for dirty tracking +pub const RowState = enum { + normal, // No changes + modified, // Edited, pending save + new, // New row, not in DB yet + deleted, // Marked for deletion + @"error", // Validation error +}; + +/// Row lock state (for certified documents like VeriFacTu) +pub const RowLockState = enum { + unlocked, // Editable + locked, // Requires password + read_only, // Read-only (certified) +}; + +// ============================================================================= +// Sort Direction +// ============================================================================= + +/// Sort direction for columns +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, + }; + } +}; + +// ============================================================================= +// Cell Value +// ============================================================================= + +/// Generic cell value that can hold any column type +pub const CellValue = union(enum) { + null_val: void, + string: []const u8, + integer: i64, + float: f64, + boolean: bool, + + /// Check if value is empty/null + pub fn isEmpty(self: CellValue) bool { + return switch (self) { + .null_val => true, + .string => |s| s.len == 0, + .integer => |i| i == 0, + .float => |f| f == 0.0, + .boolean => false, // booleans are never "empty" + }; + } + + /// Compare two values for equality + pub fn eql(self: CellValue, other: CellValue) bool { + if (@as(std.meta.Tag(CellValue), self) != @as(std.meta.Tag(CellValue), other)) { + return false; + } + return switch (self) { + .null_val => true, + .string => |s| std.mem.eql(u8, s, other.string), + .integer => |i| i == other.integer, + .float => |f| f == other.float, + .boolean => |b| b == other.boolean, + }; + } + + /// Format value as string for display + pub fn format(self: CellValue, buf: []u8) []const u8 { + return switch (self) { + .null_val => "", + .string => |s| s, + .integer => |i| blk: { + const result = std.fmt.bufPrint(buf, "{d}", .{i}) catch ""; + break :blk result; + }, + .float => |f| blk: { + const result = std.fmt.bufPrint(buf, "{d:.2}", .{f}) catch ""; + break :blk result; + }, + .boolean => |b| if (b) "Yes" else "No", + }; + } +}; + +// ============================================================================= +// Validation +// ============================================================================= + +/// Validation result +pub const ValidationResult = struct { + valid: bool, + message: []const u8 = "", + severity: Severity = .@"error", + + pub const Severity = enum { + info, + warning, + @"error", + }; + + pub fn ok() ValidationResult { + return .{ .valid = true }; + } + + pub fn err(message: []const u8) ValidationResult { + return .{ .valid = false, .message = message }; + } + + pub fn warning(message: []const u8) ValidationResult { + return .{ .valid = true, .message = message, .severity = .warning }; + } +}; + +// ============================================================================= +// CRUD Actions (for Auto-CRUD) +// ============================================================================= + +/// CRUD action detected by Auto-CRUD system +pub const CRUDAction = enum { + none, // No action needed + create, // CREATE new row (was empty, now has data) + update, // UPDATE existing row (data changed) + delete, // DELETE row (marked for deletion or emptied) +}; + +// ============================================================================= +// Colors +// ============================================================================= + +/// Color scheme for AdvancedTable +pub const TableColors = struct { + // Header + header_bg: Style.Color = Style.Color.rgb(50, 50, 55), + 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, + + // Rows + row_normal: Style.Color = Style.Color.rgb(35, 35, 35), + row_alternate: Style.Color = Style.Color.rgb(40, 40, 40), + row_hover: Style.Color = Style.Color.rgb(50, 50, 60), + + // Selection + selected_cell: Style.Color = Style.Color.rgb(66, 135, 245), + selected_row: Style.Color = Style.Color.rgb(50, 80, 120), + + // State colors + state_modified: Style.Color = Style.Color.rgb(255, 255, 100), // Yellow + state_new: Style.Color = Style.Color.rgb(100, 200, 100), // Green + state_deleted: Style.Color = Style.Color.rgb(200, 100, 100), // Red + state_error: Style.Color = Style.Color.rgb(255, 80, 80), // Bright red + + // Text + text_normal: Style.Color = Style.Color.rgb(220, 220, 220), + text_selected: Style.Color = Style.Color.rgb(255, 255, 255), + text_disabled: Style.Color = Style.Color.rgb(100, 100, 100), + + // Cell editing + cell_editing_bg: Style.Color = Style.Color.rgb(60, 60, 80), + cell_editing_border: Style.Color = Style.Color.primary, + + // Borders + border: Style.Color = Style.Color.rgb(60, 60, 60), + focus_ring: Style.Color = Style.Color.primary, + + // State indicators (column) + indicator_modified: Style.Color = Style.Color.rgb(255, 180, 0), // Orange + indicator_new: Style.Color = Style.Color.rgb(76, 175, 80), // Green + indicator_deleted: Style.Color = Style.Color.rgb(244, 67, 54), // Red +}; + +// ============================================================================= +// Configuration +// ============================================================================= + +/// AdvancedTable configuration +pub const TableConfig = struct { + // Dimensions + header_height: u32 = 28, + row_height: u32 = 24, + state_indicator_width: u32 = 24, + min_column_width: u32 = 40, + + // Features + show_headers: bool = true, + show_row_state_indicators: bool = true, + alternating_rows: bool = true, + + // Editing + allow_edit: bool = true, + allow_sorting: bool = true, + allow_row_operations: bool = true, + + // Auto-CRUD + auto_crud_enabled: bool = true, + always_show_empty_row: bool = false, + + // Navigation + keyboard_nav: bool = true, + handle_tab: bool = true, // Handle Tab internally vs external focus system + wrap_navigation: bool = true, // Wrap at row/column boundaries + + // Row locking + support_row_locking: bool = false, + + // Debounce + callback_debounce_ms: u32 = 150, +}; + +// ============================================================================= +// Callback Types +// ============================================================================= + +/// Callback for validation +pub const ValidatorFn = *const fn (value: CellValue) ValidationResult; + +/// Callback for formatting cell value for display +pub const FormatterFn = *const fn (value: CellValue, buf: []u8) []const u8; + +/// Callback for parsing text input to cell value +pub const ParserFn = *const fn (text: []const u8) ?CellValue; + +/// Callback for getting row lock state +pub const GetRowLockStateFn = *const fn (row_data: *const Row) RowLockState; + +/// Callback when row is selected +pub const OnRowSelectedFn = *const fn (row_index: usize, row_data: *const Row) void; + +/// Callback when cell value changes +pub const OnCellChangedFn = *const fn (row_index: usize, col_index: usize, old_value: CellValue, new_value: CellValue) void; + +/// Callback when active row changes (for loading detail panels) +pub const OnActiveRowChangedFn = *const fn (old_row: ?usize, new_row: usize, row_data: *const Row) void; + +// ============================================================================= +// Row Type +// ============================================================================= + +/// Row data - map of column name to value +pub const Row = struct { + data: std.StringHashMap(CellValue), + allocator: std.mem.Allocator, + + pub fn init(allocator: std.mem.Allocator) Row { + return .{ + .data = std.StringHashMap(CellValue).init(allocator), + .allocator = allocator, + }; + } + + pub fn deinit(self: *Row) void { + self.data.deinit(); + } + + pub fn get(self: *const Row, column: []const u8) CellValue { + return self.data.get(column) orelse .{ .null_val = {} }; + } + + pub fn set(self: *Row, column: []const u8, value: CellValue) !void { + try self.data.put(column, value); + } + + pub fn clone(self: *const Row, allocator: std.mem.Allocator) !Row { + var new_row = Row.init(allocator); + var iter = self.data.iterator(); + while (iter.next()) |entry| { + try new_row.data.put(entry.key_ptr.*, entry.value_ptr.*); + } + return new_row; + } +}; + +// ============================================================================= +// Constants +// ============================================================================= + +/// Maximum columns supported +pub const MAX_COLUMNS = 32; + +/// Maximum edit buffer size +pub const MAX_EDIT_BUFFER = 256; + +/// Maximum rows for selection operations +pub const MAX_SELECTED_ROWS = 256; + +// ============================================================================= +// Tests +// ============================================================================= + +test "CellValue isEmpty" { + const null_val = CellValue{ .null_val = {} }; + const empty_str = CellValue{ .string = "" }; + const str = CellValue{ .string = "hello" }; + const zero_int = CellValue{ .integer = 0 }; + const int_val = CellValue{ .integer = 42 }; + const bool_val = CellValue{ .boolean = false }; + + try std.testing.expect(null_val.isEmpty()); + try std.testing.expect(empty_str.isEmpty()); + try std.testing.expect(!str.isEmpty()); + try std.testing.expect(zero_int.isEmpty()); + try std.testing.expect(!int_val.isEmpty()); + try std.testing.expect(!bool_val.isEmpty()); // booleans never empty +} + +test "CellValue eql" { + const a = CellValue{ .integer = 42 }; + const b = CellValue{ .integer = 42 }; + const c = CellValue{ .integer = 43 }; + const d = CellValue{ .string = "42" }; + + try std.testing.expect(a.eql(b)); + try std.testing.expect(!a.eql(c)); + try std.testing.expect(!a.eql(d)); // different types +} + +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 "ValidationResult helpers" { + const ok = ValidationResult.ok(); + try std.testing.expect(ok.valid); + + const err_result = ValidationResult.err("error message"); + try std.testing.expect(!err_result.valid); + try std.testing.expectEqualStrings("error message", err_result.message); + + const warn = ValidationResult.warning("warning message"); + try std.testing.expect(warn.valid); + try std.testing.expectEqual(ValidationResult.Severity.warning, warn.severity); +} diff --git a/src/widgets/widgets.zig b/src/widgets/widgets.zig index 40cc8b7..7ec61af 100644 --- a/src/widgets/widgets.zig +++ b/src/widgets/widgets.zig @@ -62,6 +62,9 @@ pub const sheet = @import("sheet.zig"); pub const discloser = @import("discloser.zig"); pub const selectable = @import("selectable.zig"); +// Advanced widgets +pub const advanced_table = @import("advanced_table/advanced_table.zig"); + // ============================================================================= // Re-exports for convenience // =============================================================================