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 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
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue