zcatgui/docs/TABLES_ARCHITECTURE.md
reugenio 0026dbff2a docs(TABLES_ARCHITECTURE): Add 'Lecciones Aprendidas' section
Document the Tab double-processing bug and its solution:
- Symptom: Row color alternating on Tab press
- Root cause: Tab processed twice (widget + app level)
- Solution: NavigateDirection + handled flag + app check
- General rule for keyboard handling in immediate-mode widgets

Also updated EditKeyboardResult API documentation with new fields.
2025-12-27 12:53:21 +01:00

429 lines
16 KiB
Markdown

# Arquitectura de Tablas en zcatgui
> **Documento de referencia definitivo** para entender, usar y extender los widgets de tabla.
> Actualizado: 2025-12-27
---
## Resumen Ejecutivo
zcatgui proporciona **dos widgets de tabla** que comparten lógica común:
| Widget | Uso | Datos |
|--------|-----|-------|
| **AdvancedTable** | Tablas pequeñas/medianas | En memoria (ArrayList) |
| **VirtualAdvancedTable** | Tablas grandes (60k+ filas) | Paginados via DataProvider |
**Principio DRY:** Toda la lógica común está en `table_core.zig`. Los widgets adaptan esa lógica a su modelo de datos.
---
## Arquitectura de Capas
```
┌─────────────────────────────────────────────────────────────────────────┐
│ APLICACIÓN │
│ (zsimifactu, etc.) │
│ - Usa AdvancedTable o VirtualAdvancedTable según necesidad │
│ - Implementa DataProvider para VirtualAdvancedTable │
│ - Maneja lógica de negocio (guardar en BD, validar, etc.) │
└─────────────────────────────────────────────────────────────────────────┘
│ usa
┌─────────────────────────────────────────────────────────────────────────┐
│ WIDGETS DE TABLA │
│ │
│ ┌─────────────────────┐ ┌──────────────────────────┐ │
│ │ AdvancedTable │ │ VirtualAdvancedTable │ │
│ │ │ │ │ │
│ │ - Datos en memoria │ │ - Datos paginados │ │
│ │ - selected_row/col │ │ - selected_id + active_col│ │
│ │ - Ordenación local │ │ - Scroll virtual │ │
│ │ - Multi-selección │ │ - FilterBar integrada │ │
│ └──────────┬──────────┘ └────────────┬─────────────┘ │
│ │ │ │
│ │ ambos usan │ │
│ └──────────────┬───────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ table_core.zig │ │
│ │ │ │
│ │ LÓGICA COMÚN (un solo lugar para modificar): │ │
│ │ - calculateNextCell() / calculatePrevCell() [Tab navigation] │ │
│ │ - toggleSort() [Ordenación] │ │
│ │ - handleEditingKeyboard() [Edición celda] │ │
│ │ - detectDoubleClick() [Doble-click] │ │
│ │ - drawCellActiveIndicator() [Renderizado] │ │
│ │ - drawEditingOverlay() [Overlay edición] │ │
│ │ - blendColor(), startsWithIgnoreCase() [Utilidades] │ │
│ │ │ │
│ │ TIPOS COMPARTIDOS: │ │
│ │ - TabNavigateResult, CellPosition │ │
│ │ - SortDirection, SortToggleResult │ │
│ │ - TableColors, CellRenderInfo, EditState │ │
│ │ - EditKeyboardResult, DoubleClickState │ │
│ └─────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
```
---
## Estructura de Archivos
```
src/widgets/
├── table_core.zig # LÓGICA COMÚN - modificar aquí
├── advanced_table/ # Tabla con datos en memoria
│ ├── advanced_table.zig # Widget principal
│ ├── state.zig # Estado (usa table_core)
│ ├── types.zig # Tipos específicos
│ └── schema.zig # Definición de columnas
└── virtual_advanced_table/ # Tabla con datos paginados
├── virtual_advanced_table.zig # Widget principal
├── state.zig # Estado (usa table_core)
├── types.zig # Tipos específicos
└── data_provider.zig # Interface para datos
```
---
## Cuándo Usar Cada Widget
### AdvancedTable
**Usar cuando:**
- Datos caben en memoria (< 10,000 filas típicamente)
- Necesitas ordenación/filtrado local rápido
- Multi-selección de filas (Ctrl+Click, Shift+Click)
- Los datos ya están cargados
**Ejemplo:** Lista de tipos de IVA, categorías, configuraciones.
```zig
const state = AdvancedTableState.init(allocator);
defer state.deinit();
// Cargar datos
try state.setRows(&my_rows);
// En draw():
const result = advancedTable(ctx, rect, &state, &schema, colors);
if (result.selection_changed) {
// Manejar selección
}
```
### VirtualAdvancedTable
**Usar cuando:**
- Miles o millones de filas (60k+ poblaciones, logs, etc.)
- Datos vienen de BD/API con paginación
- Necesitas FilterBar integrada
- Memoria limitada
**Ejemplo:** Lista de poblaciones, historial de documentos.
```zig
var state = VirtualAdvancedTableState{};
const provider = MyDataProvider.init(db);
// En draw():
const result = virtualAdvancedTableRect(ctx, rect, &state, provider.toDataProvider(), config);
if (result.selection_changed) {
// Manejar selección
}
```
---
## Cómo Extender la Funcionalidad
### Regla de Oro (Norma #7 DRY)
> **Si la funcionalidad es común a ambas tablas, va en `table_core.zig`.**
> Los widgets solo adaptan el resultado a su modelo de datos.
### Ejemplo: Añadir nueva navegación
**PASO 1:** Añadir la lógica en `table_core.zig`
```zig
// table_core.zig
/// Calcula la celda de la siguiente página
pub fn calculatePageDown(
current_row: usize,
current_col: usize,
num_rows: usize,
page_size: usize,
) CellPosition {
const new_row = @min(current_row + page_size, num_rows - 1);
return .{ .row = new_row, .col = current_col, .result = .navigated };
}
```
**PASO 2:** Usar en ambos states (adaptan a su modelo)
```zig
// advanced_table/state.zig
pub fn pageDown(self: *AdvancedTableState, page_size: usize) void {
const pos = table_core.calculatePageDown(
@intCast(self.selected_row),
@intCast(self.selected_col),
self.getRowCount(),
page_size,
);
self.selected_row = @intCast(pos.row);
}
// virtual_advanced_table/state.zig
pub fn pageDown(self: *Self, page_size: usize) void {
const current_row = self.getSelectedRow() orelse 0;
const pos = table_core.calculatePageDown(current_row, self.active_col, total_rows, page_size);
// Adaptar a scroll virtual...
}
```
### Ejemplo: Añadir nuevo renderizado
```zig
// table_core.zig
/// Dibuja indicador de celda con error de validación
pub fn drawCellErrorIndicator(
ctx: *Context,
x: i32,
y: i32,
width: u32,
height: u32,
) void {
// Borde rojo
ctx.pushCommand(Command.rectOutline(x, y, width, height, Color.rgb(255, 0, 0)));
// Icono de error en esquina
ctx.pushCommand(Command.text(x + width - 12, y + 2, "!", Color.rgb(255, 0, 0)));
}
```
---
## API de table_core.zig
### Navegación Tab
```zig
/// Resultado de navegación
pub const TabNavigateResult = enum { navigated, tab_out };
/// Posición de celda calculada
pub const CellPosition = struct { row: usize, col: usize, result: TabNavigateResult };
/// Calcula siguiente celda (Tab)
pub fn calculateNextCell(current_row, current_col, num_cols, num_rows, wrap) CellPosition
/// Calcula celda anterior (Shift+Tab)
pub fn calculatePrevCell(current_row, current_col, num_cols, num_rows, wrap) CellPosition
```
### Ordenación
```zig
pub const SortDirection = enum { none, ascending, descending };
pub const SortToggleResult = struct { column: ?usize, direction: SortDirection };
/// Calcula nuevo estado de ordenación al click en columna
pub fn toggleSort(current_column, current_direction, clicked_column) SortToggleResult
```
### Edición de Celda
```zig
/// Dirección de navegación después de commit
pub const NavigateDirection = enum {
none, // Sin navegación
next_cell, // Tab → siguiente celda
prev_cell, // Shift+Tab → celda anterior
next_row, // Enter/↓ → siguiente fila
prev_row, // ↑ → fila anterior
};
pub const EditKeyboardResult = struct {
committed: bool, // Enter/Tab/flechas presionados
cancelled: bool, // Escape 2x
reverted: bool, // Escape 1x (revertir a original)
navigate: NavigateDirection, // Dirección de navegación post-commit
text_changed: bool, // Texto modificado
handled: bool, // IMPORTANTE: evento fue procesado
};
/// Procesa teclado en modo edición (DRY - un solo lugar)
pub fn handleEditingKeyboard(ctx, edit_buffer, edit_len, edit_cursor, escape_count, original_text) EditKeyboardResult
```
> **IMPORTANTE:** El campo `handled` indica si table_core procesó el evento de teclado.
> Esto es crítico para evitar doble procesamiento de Tab (ver "Lecciones Aprendidas").
### Renderizado
```zig
/// Dibuja indicador de celda activa
pub fn drawCellActiveIndicator(ctx, x, y, width, height, row_bg, colors, has_focus) void
/// Dibuja overlay de edición
pub fn drawEditingOverlay(ctx, x, y, width, height, edit_text, cursor_pos, colors) void
/// Dibuja texto de celda
pub fn drawCellText(ctx, x, y, width, height, text, color, text_align) void
```
### Utilidades
```zig
/// Detecta doble-click
pub fn detectDoubleClick(state, current_time, row, col) bool
/// Mezcla dos colores
pub fn blendColor(base, overlay, alpha) Color
/// Compara strings case-insensitive
pub fn startsWithIgnoreCase(haystack, needle) bool
```
---
## Modelo de Datos
### AdvancedTable
```zig
pub const AdvancedTableState = struct {
// Datos
rows: ArrayListUnmanaged(Row),
// Selección (por ÍNDICE)
selected_row: i32, // -1 = ninguna
selected_col: i32,
// Multi-selección
selected_rows: [128]u8, // Bitset para 1024 filas
// Edición
editing: bool,
edit_buffer: [256]u8,
edit_cursor: usize,
// Ordenación
sort_column: i32,
sort_direction: SortDirection,
};
```
### VirtualAdvancedTable
```zig
pub const VirtualAdvancedTableState = struct {
// Selección (por ID, no índice)
selected_id: ?i64, // ID del registro
active_col: usize, // Columna activa
// Ventana virtual
scroll_offset: usize, // Offset en filas
current_window: []RowData, // Datos visibles
window_start: usize, // Índice inicial de ventana
// Edición
editing_cell: ?CellId,
edit_buffer: [256]u8,
row_dirty: bool,
// Filtro
filter_buf: [256]u8,
active_chips: u16, // Bitset de chips activos
};
```
---
## Tests
Los tests de `table_core.zig` verifican la lógica común:
```bash
cd zcatgui && zig build test
```
Tests incluidos:
- `calculateNextCell` - navegación básica, wrap, tab_out
- `calculatePrevCell` - navegación básica, wrap, tab_out
- `toggleSort` - ciclo de direcciones, cambio de columna
- `detectDoubleClick` - detección correcta de doble-click
- `blendColor` - mezcla de colores
- `startsWithIgnoreCase` - búsqueda case-insensitive
---
## Lecciones Aprendidas
### Bug: Color de fila alternando al presionar Tab (2025-12-27)
**Síntoma:** Al navegar entre celdas con Tab, el color de la fila seleccionada alternaba
entre azul (con focus) y marrón/gris (sin focus), independientemente de si se modificaba
el contenido.
**Causa raíz:** Tab se procesaba DOS veces:
1. Por VirtualAdvancedTable/CellEditor navegación entre celdas
2. Por main.zig de la aplicación cambio de focus entre widgets (`ctx.handleTabKey()`)
Esto causaba que el focus del widget alternara incorrectamente cada frame.
**Solución (3 partes):**
1. **table_core.zig:** Añadir campo `handled` a `EditKeyboardResult` para indicar que el
evento fue procesado.
2. **VirtualAdvancedTable:** Verificar `navigate_direction != .none` antes de procesar
Tab como `tab_out`. Si la tabla ya procesó Tab para navegación interna, no marcar
`tab_out = true`.
3. **Aplicación (main.zig):** No llamar `ctx.handleTabKey()` cuando el panel activo
maneja Tab internamente (ej: cuando `active_tab == .configuracion`).
**Regla general:**
> Cuando un widget procesa teclado internamente en `draw()`, la aplicación debe
> respetar esa decisión y NO procesar el mismo evento a nivel global.
**Código clave:**
```zig
// VirtualAdvancedTable - handleKeyboard()
.tab => {
// IMPORTANTE: Solo si CellEditor no procesó Tab
if (result.navigate_direction == .none) {
result.tab_out = true;
result.tab_shift = event.modifiers.shift;
}
},
```
---
## Historial de Cambios
| Fecha | Cambio |
|-------|--------|
| 2025-12-27 | Fix bug colores alternando + campo `handled` en EditKeyboardResult |
| 2025-12-27 | Refactorización DRY: lógica común movida a table_core.zig |
| 2025-12-26 | table_core.zig creado con funciones de renderizado compartidas |
| 2025-12-17 | VirtualAdvancedTable añadido para tablas grandes |
| 2025-12-16 | AdvancedTable implementado con CRUD Excel-style |
---
## Referencias
- `src/widgets/table_core.zig` - Código fuente de lógica común
- `src/widgets/advanced_table/` - AdvancedTable completo
- `src/widgets/virtual_advanced_table/` - VirtualAdvancedTable completo
- `docs/ADVANCED_TABLE_DESIGN.md` - Diseño original (histórico)