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:
parent
e0d7e99bb6
commit
83049a99be
6 changed files with 3111 additions and 0 deletions
582
docs/ADVANCED_TABLE_DESIGN.md
Normal file
582
docs/ADVANCED_TABLE_DESIGN.md
Normal 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`
|
||||
1022
src/widgets/advanced_table/advanced_table.zig
Normal file
1022
src/widgets/advanced_table/advanced_table.zig
Normal file
File diff suppressed because it is too large
Load diff
373
src/widgets/advanced_table/schema.zig
Normal file
373
src/widgets/advanced_table/schema.zig
Normal 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);
|
||||
}
|
||||
762
src/widgets/advanced_table/state.zig
Normal file
762
src/widgets/advanced_table/state.zig
Normal 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);
|
||||
}
|
||||
369
src/widgets/advanced_table/types.zig
Normal file
369
src/widgets/advanced_table/types.zig
Normal 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);
|
||||
}
|
||||
|
|
@ -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
|
||||
// =============================================================================
|
||||
|
|
|
|||
Loading…
Reference in a new issue