feat: AdvancedTable widget - Fases 1-6 IMPLEMENTADO (pendiente aprobacion)

Widget AdvancedTable portado de Go (simifactu-fyne) a Zig.
2,526 LOC en 4 archivos, 370 tests pasan.

Archivos:
- types.zig (369 LOC): CellValue, ColumnType, RowState, TableColors
- schema.zig (373 LOC): ColumnDef, TableSchema, DataStore interface
- state.zig (762 LOC): Selection, editing, dirty tracking, snapshots
- advanced_table.zig (1,022 LOC): Widget, rendering, keyboard

Fases implementadas:
1. Core (types, schema, state)
2. Navigation (arrows, Tab, PgUp/Dn, Home/End, Ctrl+Home/End)
3. Cell Editing (F2/Enter start, Escape cancel, text input)
4. Sorting (header click, visual indicators)
5. Auto-CRUD (CREATE/UPDATE/DELETE detection on row change)
6. Row Operations (Ctrl+N insert, Ctrl+Delete remove)

Fases diferidas (7-8): Lookup & Auto-fill, Callbacks avanzados

ESTADO: Compilado y tests pasan. NO probado en uso real.
REQUIERE: Aprobacion antes de tag de version.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
reugenio 2025-12-17 11:25:48 +01:00
parent e0d7e99bb6
commit 83049a99be
6 changed files with 3111 additions and 0 deletions

View file

@ -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`

File diff suppressed because it is too large Load diff

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -62,6 +62,9 @@ pub const sheet = @import("sheet.zig");
pub const discloser = @import("discloser.zig"); pub const discloser = @import("discloser.zig");
pub const selectable = @import("selectable.zig"); pub const selectable = @import("selectable.zig");
// Advanced widgets
pub const advanced_table = @import("advanced_table/advanced_table.zig");
// ============================================================================= // =============================================================================
// Re-exports for convenience // Re-exports for convenience
// ============================================================================= // =============================================================================