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

16 KiB

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.

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.

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

// 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)

// 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

// 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

/// 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

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

/// 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

/// 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

/// 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

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

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:

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:

// 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)