//! Table Core - Funciones compartidas para renderizado de tablas //! //! Este módulo contiene la lógica común de renderizado utilizada por: //! - AdvancedTable (datos en memoria) //! - VirtualAdvancedTable (datos paginados desde DataProvider) //! //! Principio: Una sola implementación de UI, dos estrategias de datos. //! //! ## Protocolo de Propiedad de Memoria //! //! 1. **Strings de celda:** El DataSource retorna punteros a memoria estable. //! El widget NO libera estos strings. Son válidos hasta el próximo fetch. //! //! 2. **Buffers de edición:** El widget mantiene edit_buffer[256] propio. //! Los cambios se copian al DataSource solo en commit. //! //! 3. **Rendering:** Todos los strings pasados a ctx.pushCommand() deben ser //! estables durante todo el frame. Usar buffers persistentes, NO stack. //! //! 4. **getValueInto pattern:** Cuando se necesita formatear valores, //! el caller provee el buffer destino para evitar memory ownership issues. const std = @import("std"); const Context = @import("../core/context.zig").Context; const Command = @import("../core/command.zig"); const Layout = @import("../core/layout.zig"); const Style = @import("../core/style.zig"); // ============================================================================= // Tips Proactivos (FASE I) // ============================================================================= /// Tips de atajos de teclado para mostrar en StatusLine /// Rotan cada ~10 segundos para enseñar atajos al usuario pub const table_tips = [_][]const u8{ "Tip: F2 o Space para editar celda", "Tip: Tab/Shift+Tab navega entre celdas", "Tip: Ctrl+N crea nuevo registro", "Tip: Ctrl+Delete o Ctrl+B borra registro", "Tip: Ctrl+Shift+1..9 ordena por columna", "Tip: Ctrl+Home/End va al inicio/fin", "Tip: Enter confirma y baja, Escape cancela", "Tip: Al editar, tecla directa reemplaza todo", }; /// Frames entre rotación de tips (~10s @ 60fps) pub const TIP_ROTATION_FRAMES: u32 = 600; // ============================================================================= // Tipos comunes // ============================================================================= /// Colores para renderizado de tabla pub const TableColors = struct { // Fondos background: Style.Color = Style.Color.rgb(30, 30, 35), row_normal: Style.Color = Style.Color.rgb(35, 35, 40), row_alternate: Style.Color = Style.Color.rgb(40, 40, 45), row_hover: Style.Color = Style.Color.rgb(50, 50, 60), selected_row: Style.Color = Style.Color.rgb(0, 90, 180), selected_row_unfocus: Style.Color = Style.Color.rgb(60, 60, 70), // Celda activa selected_cell: Style.Color = Style.Color.rgb(100, 150, 255), selected_cell_unfocus: Style.Color = Style.Color.rgb(80, 80, 90), // Edición cell_editing_bg: Style.Color = Style.Color.rgb(255, 255, 255), cell_editing_border: Style.Color = Style.Color.rgb(0, 120, 215), cell_editing_text: Style.Color = Style.Color.rgb(0, 0, 0), cell_selection_bg: Style.Color = Style.Color.rgb(0, 120, 215), // Azul para selección // Header header_bg: Style.Color = Style.Color.rgb(45, 45, 50), header_fg: Style.Color = Style.Color.rgb(200, 200, 200), // Texto text_normal: Style.Color = Style.Color.rgb(220, 220, 220), text_selected: Style.Color = Style.Color.rgb(255, 255, 255), text_placeholder: Style.Color = Style.Color.rgb(128, 128, 128), // Bordes border: Style.Color = Style.Color.rgb(60, 60, 65), focus_ring: Style.Color = Style.Color.rgb(0, 120, 215), }; /// Información de una celda para renderizado pub const CellRenderInfo = struct { /// Texto a mostrar text: []const u8, /// Posición X de la celda x: i32, /// Ancho de la celda width: u32, /// Es la celda actualmente seleccionada is_selected: bool = false, /// Es editable is_editable: bool = true, /// Alineación del texto (0=left, 1=center, 2=right) text_align: u2 = 0, }; /// Estado de edición para renderizado (info para draw) /// NOTA: Para estado embebible en widgets, usar CellEditState pub const EditState = struct { /// Está en modo edición editing: bool = false, /// Fila en edición edit_row: i32 = -1, /// Columna en edición edit_col: i32 = -1, /// Buffer de texto actual edit_text: []const u8 = "", /// Posición del cursor edit_cursor: usize = 0, }; /// Estado de una fila (para indicadores visuales) /// Compatible con advanced_table.types.RowState pub const RowState = enum { normal, // Sin cambios modified, // Editada, pendiente de guardar new, // Fila nueva, no existe en BD deleted, // Marcada para eliminar @"error", // Error de validación }; // ============================================================================= // Estados embebibles (para composición en AdvancedTableState/VirtualAdvancedTableState) // ============================================================================= /// Tamaño máximo del buffer de edición pub const MAX_EDIT_BUFFER_SIZE: usize = 256; /// Estado completo de edición de celda /// Diseñado para ser embebido en AdvancedTableState y VirtualAdvancedTableState pub const CellEditState = struct { /// Está en modo edición editing: bool = false, /// Celda en edición (fila, columna) edit_row: usize = 0, edit_col: usize = 0, /// Buffer de texto actual edit_buffer: [MAX_EDIT_BUFFER_SIZE]u8 = undefined, edit_len: usize = 0, /// Posición del cursor edit_cursor: usize = 0, /// Valor original (para revertir con Escape) original_buffer: [MAX_EDIT_BUFFER_SIZE]u8 = undefined, original_len: usize = 0, /// Contador de Escapes (1=revertir, 2=cancelar) escape_count: u8 = 0, /// Flag: el valor cambió respecto al original value_changed: bool = false, /// Selección de texto (Excel-style: todo seleccionado al entrar con F2) /// Si selection_start == selection_end, no hay selección (solo cursor) selection_start: usize = 0, selection_end: usize = 0, const Self = @This(); /// Inicia edición de una celda pub fn startEditing(self: *Self, row: usize, col: usize, current_value: []const u8, initial_char: ?u8) void { self.editing = true; self.edit_row = row; self.edit_col = col; self.escape_count = 0; self.value_changed = false; // Guardar valor original const orig_len = @min(current_value.len, MAX_EDIT_BUFFER_SIZE); @memcpy(self.original_buffer[0..orig_len], current_value[0..orig_len]); self.original_len = orig_len; // Inicializar buffer de edición if (initial_char) |c| { // Tecla alfanumérica: empezar con ese caracter, sin selección self.edit_buffer[0] = c; self.edit_len = 1; self.edit_cursor = 1; self.selection_start = 0; self.selection_end = 0; } else { // F2/Space/DoubleClick: mostrar valor actual con TODO seleccionado (Excel-style) @memcpy(self.edit_buffer[0..orig_len], current_value[0..orig_len]); self.edit_len = orig_len; self.edit_cursor = orig_len; // Seleccionar todo el texto self.selection_start = 0; self.selection_end = orig_len; } } /// Obtiene el texto actual del editor pub fn getEditText(self: *const Self) []const u8 { return self.edit_buffer[0..self.edit_len]; } /// Obtiene el valor original pub fn getOriginalValue(self: *const Self) []const u8 { return self.original_buffer[0..self.original_len]; } /// Verifica si el valor cambió pub fn hasChanged(self: *const Self) bool { const current = self.getEditText(); const original = self.getOriginalValue(); return !std.mem.eql(u8, current, original); } /// Verifica si hay texto seleccionado pub fn hasSelection(self: *const Self) bool { return self.selection_start != self.selection_end; } /// Limpia la selección (pero mantiene el cursor) pub fn clearSelection(self: *Self) void { self.selection_start = 0; self.selection_end = 0; } /// Revierte al valor original (Escape 1) pub fn revertToOriginal(self: *Self) void { const orig = self.getOriginalValue(); @memcpy(self.edit_buffer[0..orig.len], orig); self.edit_len = orig.len; self.edit_cursor = orig.len; // Limpiar selección al revertir self.clearSelection(); } /// Finaliza edición pub fn stopEditing(self: *Self) void { self.editing = false; self.edit_len = 0; self.edit_cursor = 0; self.escape_count = 0; self.clearSelection(); } /// Resultado de handleEscape pub const EscapeAction = enum { reverted, cancelled, none }; /// Maneja Escape (retorna acción a tomar) pub fn handleEscape(self: *Self) EscapeAction { if (!self.editing) return .none; self.escape_count += 1; if (self.escape_count == 1) { self.revertToOriginal(); return .reverted; } else { self.stopEditing(); return .cancelled; } } /// Convierte a EditState para funciones de renderizado pub fn toEditState(self: *const Self) EditState { return .{ .editing = self.editing, .edit_row = @intCast(self.edit_row), .edit_col = @intCast(self.edit_col), .edit_text = self.getEditText(), .edit_cursor = self.edit_cursor, }; } }; /// Estado de navegación compartido /// Diseñado para ser embebido en AdvancedTableState y VirtualAdvancedTableState pub const NavigationState = struct { /// Columna activa (para Tab navigation) active_col: usize = 0, /// Scroll vertical (en filas) scroll_row: usize = 0, /// Scroll horizontal (en pixels) scroll_x: i32 = 0, /// El widget tiene focus has_focus: bool = false, /// Double-click state double_click: DoubleClickState = .{}, const Self = @This(); /// Navega a siguiente celda (Tab) /// Retorna nueva posición y si navegó o salió del widget pub fn tabToNextCell(self: *Self, current_row: usize, num_cols: usize, num_rows: usize, wrap: bool) struct { row: usize, col: usize, result: TabNavigateResult } { const pos = calculateNextCell(current_row, self.active_col, num_cols, num_rows, wrap); if (pos.result == .navigated) { self.active_col = pos.col; } return .{ .row = pos.row, .col = pos.col, .result = pos.result }; } /// Navega a celda anterior (Shift+Tab) pub fn tabToPrevCell(self: *Self, current_row: usize, num_cols: usize, num_rows: usize, wrap: bool) struct { row: usize, col: usize, result: TabNavigateResult } { const pos = calculatePrevCell(current_row, self.active_col, num_cols, num_rows, wrap); if (pos.result == .navigated) { self.active_col = pos.col; } return .{ .row = pos.row, .col = pos.col, .result = pos.result }; } /// Mueve a columna anterior pub fn moveToPrevCol(self: *Self) void { if (self.active_col > 0) self.active_col -= 1; } /// Mueve a columna siguiente pub fn moveToNextCol(self: *Self, max_cols: usize) void { if (self.active_col + 1 < max_cols) self.active_col += 1; } /// Va a primera columna pub fn goToFirstCol(self: *Self) void { self.active_col = 0; } /// Va a última columna pub fn goToLastCol(self: *Self, max_cols: usize) void { if (max_cols > 0) self.active_col = max_cols - 1; } }; /// Estado de doble-click pub const DoubleClickState = struct { last_click_time: u64 = 0, last_click_row: i64 = -1, last_click_col: i32 = -1, threshold_ms: u64 = 400, }; /// Resultado de procesar click en celda pub const CellClickResult = struct { /// Hubo click clicked: bool = false, /// Fue doble-click double_click: bool = false, /// Fila clickeada row: usize = 0, /// Columna clickeada col: usize = 0, }; // ============================================================================= // Funciones de renderizado // ============================================================================= /// Dibuja el indicador de celda activa (fondo + borde) /// Llamar ANTES de dibujar el texto de la celda pub fn drawCellActiveIndicator( ctx: *Context, x: i32, y: i32, width: u32, height: u32, row_bg: Style.Color, colors: *const TableColors, has_focus: bool, ) void { if (has_focus) { // Con focus: fondo más visible + borde doble const tinted_bg = blendColor(row_bg, colors.selected_cell, 0.35); ctx.pushCommand(Command.rect(x, y, width, height, tinted_bg)); ctx.pushCommand(Command.rectOutline(x, y, width, height, colors.selected_cell)); ctx.pushCommand(Command.rectOutline(x + 1, y + 1, width -| 2, height -| 2, colors.selected_cell)); } else { // Sin focus: indicación más sutil const tinted_bg = blendColor(row_bg, colors.selected_cell_unfocus, 0.15); ctx.pushCommand(Command.rect(x, y, width, height, tinted_bg)); ctx.pushCommand(Command.rectOutline(x, y, width, height, colors.border)); } } /// Dibuja el overlay de edición de celda pub fn drawEditingOverlay( ctx: *Context, x: i32, y: i32, width: u32, height: u32, edit_text: []const u8, cursor_pos: usize, selection_start: usize, selection_end: usize, colors: *const TableColors, ) void { // Fondo blanco ctx.pushCommand(Command.rect(x, y, width, height, colors.cell_editing_bg)); // Borde azul ctx.pushCommand(Command.rectOutline(x, y, width, height, colors.cell_editing_border)); // Texto const text_y = y + @as(i32, @intCast((height -| 16) / 2)); const text_to_show = if (edit_text.len > 0) edit_text else ""; // Dibujar selección si existe (Excel-style highlight) if (selection_start != selection_end) { const sel_min = @min(selection_start, selection_end); const sel_max = @max(selection_start, selection_end); const sel_x = x + 4 + @as(i32, @intCast(sel_min * 8)); // 8px por caracter (monospace) const sel_width = @as(u32, @intCast((sel_max - sel_min) * 8)); // Color azul semitransparente para selección ctx.pushCommand(Command.rect(sel_x, text_y, sel_width, 16, colors.cell_selection_bg)); } // Texto (encima de la selección) ctx.pushCommand(Command.text(x + 4, text_y, text_to_show, colors.cell_editing_text)); // Cursor parpadeante (simplificado: siempre visible) // Solo mostrar cursor si NO hay selección completa if (selection_start == selection_end) { const cursor_x = x + 4 + @as(i32, @intCast(cursor_pos * 8)); // Asumiendo fuente monospace 8px ctx.pushCommand(Command.rect(cursor_x, text_y, 2, 16, colors.cell_editing_border)); } } /// Dibuja el texto de una celda pub fn drawCellText( ctx: *Context, x: i32, y: i32, width: u32, height: u32, text: []const u8, color: Style.Color, text_align: u2, ) void { const text_y = y + @as(i32, @intCast((height -| 16) / 2)); const text_x = switch (text_align) { 0 => x + 4, // Left 1 => x + @as(i32, @intCast(width / 2)) - @as(i32, @intCast(text.len * 4)), // Center (aprox) 2 => x + @as(i32, @intCast(width)) - @as(i32, @intCast(text.len * 8 + 4)), // Right 3 => x + 4, // Default left }; ctx.pushCommand(Command.text(text_x, text_y, text, color)); } /// Dibuja el indicador de estado de fila (círculo/cuadrado pequeño) /// Llamado desde drawRowsWithDataSource cuando state_indicator_width > 0 pub fn drawStateIndicator( ctx: *Context, x: i32, y: i32, w: u32, h: u32, row_state: RowState, colors: *const RowRenderColors, ) void { // No dibujar nada para estado normal if (row_state == .normal) return; const indicator_size: u32 = 8; const indicator_x = x + @as(i32, @intCast((w -| indicator_size) / 2)); const indicator_y = y + @as(i32, @intCast((h -| indicator_size) / 2)); const color = switch (row_state) { .modified => colors.state_modified, .new => colors.state_new, .deleted => colors.state_deleted, .@"error" => colors.state_error, .normal => unreachable, // Ya verificado arriba }; // Dibujar cuadrado indicador ctx.pushCommand(Command.rect(indicator_x, indicator_y, indicator_size, indicator_size, color)); } // ============================================================================= // Renderizado unificado de filas (FASE 4) // ============================================================================= /// Definición de columna para renderizado unificado pub const ColumnRenderDef = struct { /// Ancho de la columna en pixels width: u32, /// Alineación: 0=left, 1=center, 2=right text_align: u2 = 0, /// Columna visible visible: bool = true, }; /// Colores para renderizado unificado de filas pub const RowRenderColors = struct { // Colores base de fila row_normal: Style.Color, row_alternate: Style.Color, selected_row: Style.Color, selected_row_unfocus: Style.Color, selected_cell: Style.Color, selected_cell_unfocus: Style.Color, text_normal: Style.Color, text_selected: Style.Color, border: Style.Color, // Colores de estado (para blending) state_modified: Style.Color = Style.Color.rgb(255, 200, 100), // Naranja state_new: Style.Color = Style.Color.rgb(100, 200, 100), // Verde state_deleted: Style.Color = Style.Color.rgb(255, 100, 100), // Rojo state_error: Style.Color = Style.Color.rgb(255, 50, 50), // Rojo intenso /// Crea RowRenderColors desde TableColors pub fn fromTableColors(tc: *const TableColors) RowRenderColors { return .{ .row_normal = tc.row_normal, .row_alternate = tc.row_alternate, .selected_row = tc.selected_row, .selected_row_unfocus = tc.selected_row_unfocus, .selected_cell = tc.selected_cell, .selected_cell_unfocus = tc.selected_cell_unfocus, .text_normal = tc.text_normal, .text_selected = tc.text_selected, .border = tc.border, }; } }; /// Configuración para drawRowsWithDataSource pub const DrawRowsConfig = struct { /// Bounds del área de contenido bounds_x: i32, bounds_y: i32, bounds_w: u32, /// Altura de cada fila row_height: u32, /// Primera fila a dibujar (índice global) first_row: usize, /// Última fila a dibujar (exclusivo) last_row: usize, /// Offset horizontal de scroll scroll_x: i32 = 0, /// Usar colores alternados alternating_rows: bool = true, /// Widget tiene focus has_focus: bool = false, /// Fila seleccionada (-1 = ninguna) selected_row: i32 = -1, /// Columna activa active_col: usize = 0, /// Colores colors: RowRenderColors, /// Columnas columns: []const ColumnRenderDef, /// Ancho de columna de indicadores de estado (0 = deshabilitada) state_indicator_width: u32 = 0, /// Aplicar blending de color según estado de fila apply_state_colors: bool = false, /// Dibujar borde inferior en cada fila draw_row_borders: bool = false, /// ID de fila con cambios pendientes (dirty tracking visual) /// Si no es null y coincide con el row_id actual, se aplica blend naranja dirty_row_id: ?i64 = null, /// Buffer de edición de fila para priorizar valores pendientes en renderizado /// Permite mostrar lo que el usuario ha tecleado antes de que se guarde en BD edit_buffer: ?*const RowEditBuffer = null, }; /// Dibuja las filas de una tabla usando TableDataSource. /// Esta es la función unificada que usan tanto AdvancedTable como VirtualAdvancedTable. /// /// Parámetros: /// - ctx: Contexto de renderizado /// - datasource: Fuente de datos (MemoryDataSource o PagedDataSource) /// - config: Configuración del renderizado /// - cell_buffer: Buffer para formatear valores de celda (debe persistir durante el frame) /// /// Retorna el número de filas dibujadas. pub fn drawRowsWithDataSource( ctx: *Context, datasource: TableDataSource, config: DrawRowsConfig, cell_buffer: []u8, ) usize { var rows_drawn: usize = 0; var row_y = config.bounds_y; var row_idx = config.first_row; while (row_idx < config.last_row) : (row_idx += 1) { const is_selected = config.selected_row >= 0 and @as(usize, @intCast(config.selected_row)) == row_idx; // Obtener estado de la fila const row_state = datasource.getRowState(row_idx); // Determinar color de fondo base const is_alternate = config.alternating_rows and row_idx % 2 == 1; var row_bg: Style.Color = if (is_alternate) config.colors.row_alternate else config.colors.row_normal; // Aplicar blending de color según estado (si está habilitado) if (config.apply_state_colors) { row_bg = switch (row_state) { .modified => blendColor(row_bg, config.colors.state_modified, 0.2), .new => blendColor(row_bg, config.colors.state_new, 0.2), .deleted => blendColor(row_bg, config.colors.state_deleted, 0.3), .@"error" => blendColor(row_bg, config.colors.state_error, 0.3), .normal => row_bg, }; } // Dirty tracking: si la fila tiene cambios pendientes sin guardar if (config.dirty_row_id) |dirty_id| { const row_id = datasource.getRowId(row_idx); if (row_id == dirty_id) { // Blend naranja 25% para indicar cambios pendientes row_bg = blendColor(row_bg, config.colors.state_modified, 0.25); } } // Aplicar selección (override del estado) if (is_selected) { row_bg = if (config.has_focus) config.colors.selected_row else config.colors.selected_row_unfocus; } // Dibujar fondo de fila ctx.pushCommand(Command.rect( config.bounds_x, row_y, config.bounds_w, config.row_height, row_bg, )); // Posición X inicial (después de state indicator si existe) var col_x = config.bounds_x - config.scroll_x; // Dibujar columna de indicador de estado (si está habilitada) if (config.state_indicator_width > 0) { drawStateIndicator(ctx, config.bounds_x, row_y, config.state_indicator_width, config.row_height, row_state, &config.colors); col_x += @as(i32, @intCast(config.state_indicator_width)); } // Dibujar celdas de datos for (config.columns, 0..) |col, col_idx| { if (!col.visible) continue; const col_end = col_x + @as(i32, @intCast(col.width)); // Solo dibujar si la columna es visible en pantalla if (col_end > config.bounds_x and col_x < config.bounds_x + @as(i32, @intCast(config.bounds_w))) { const is_active_cell = is_selected and config.active_col == col_idx; // Indicador de celda activa if (is_active_cell) { drawCellActiveIndicator( ctx, col_x, row_y, col.width, config.row_height, row_bg, &TableColors{ .selected_cell = config.colors.selected_cell, .selected_cell_unfocus = config.colors.selected_cell_unfocus, .border = config.colors.border, }, config.has_focus, ); } // Obtener texto de la celda // PRIORIDAD 1: Fila con cambios pendientes → leer del buffer // PRIORIDAD 2: Leer del DataSource (BD o memoria) var cell_text: []const u8 = ""; const row_id = datasource.getRowId(row_idx); // Intentar leer del buffer si tiene cambios pendientes if (config.edit_buffer) |eb| { if (eb.row_id == row_id) { if (eb.getPendingValue(col_idx)) |pending| { cell_text = pending; } } } // Ir al datasource si no tenemos texto del buffer if (cell_text.len == 0) { cell_text = datasource.getCellValueInto(row_idx, col_idx, cell_buffer); } // Copiar a frame allocator para persistencia durante render const text_to_draw = ctx.frameAllocator().dupe(u8, cell_text) catch cell_text; // Color de texto const text_color = if (is_selected and config.has_focus) config.colors.text_selected else config.colors.text_normal; // Dibujar texto drawCellText( ctx, col_x, row_y, col.width, config.row_height, text_to_draw, text_color, col.text_align, ); } col_x = col_end; } // Dibujar borde inferior de fila (si está habilitado) if (config.draw_row_borders) { ctx.pushCommand(Command.rect( config.bounds_x, row_y + @as(i32, @intCast(config.row_height)) - 1, config.bounds_w, 1, config.colors.border, )); } row_y += @as(i32, @intCast(config.row_height)); rows_drawn += 1; } return rows_drawn; } /// Detecta si un click es doble-click pub fn detectDoubleClick( state: *DoubleClickState, current_time: u64, row: i64, col: i32, ) bool { const same_cell = state.last_click_row == row and state.last_click_col == col; const time_diff = current_time -| state.last_click_time; const is_double = same_cell and time_diff < state.threshold_ms; if (is_double) { // Reset para no detectar triple-click state.last_click_time = 0; state.last_click_row = -1; state.last_click_col = -1; } else { // Guardar para próximo click state.last_click_time = current_time; state.last_click_row = row; state.last_click_col = col; } return is_double; } // ============================================================================= // Manejo de teclado para edición // ============================================================================= /// Dirección de navegación después de edición pub const NavigateDirection = enum { none, next_cell, // Tab prev_cell, // Shift+Tab next_row, // Enter o ↓ prev_row, // ↑ }; /// Resultado de procesar teclado en modo edición pub const EditKeyboardResult = struct { /// Se confirmó la edición (Enter, Tab, flechas) committed: bool = false, /// Se canceló la edición (Escape 2x) cancelled: bool = false, /// Se revirtió al valor original (Escape 1x) reverted: bool = false, /// Dirección de navegación después de commit navigate: NavigateDirection = .none, /// El buffer de edición cambió text_changed: bool = false, /// Indica que se procesó un evento de teclado (para evitar doble procesamiento) handled: bool = false, }; /// Procesa teclado en modo edición /// Modifica edit_buffer, edit_len, edit_cursor según las teclas /// Soporta selección Excel-style: typing reemplaza selección /// Retorna resultado con flags de navegación y si se procesó algún evento pub fn handleEditingKeyboard( ctx: *Context, edit_buffer: []u8, edit_len: *usize, edit_cursor: *usize, escape_count: *u8, original_text: ?[]const u8, selection_start: ?*usize, selection_end: ?*usize, ) EditKeyboardResult { var result = EditKeyboardResult{}; // Helper para eliminar texto seleccionado const deleteSelection = struct { fn f(buf: []u8, len: *usize, cursor: *usize, sel_start: *usize, sel_end: *usize) bool { if (sel_start.* == sel_end.*) return false; const min_pos = @min(sel_start.*, sel_end.*); const max_pos = @min(@max(sel_start.*, sel_end.*), len.*); if (max_pos <= min_pos) return false; // Mover caracteres después de la selección hacia atrás const chars_to_delete = max_pos - min_pos; var i: usize = min_pos; while (i + chars_to_delete < len.*) : (i += 1) { buf[i] = buf[i + chars_to_delete]; } len.* -= chars_to_delete; cursor.* = min_pos; sel_start.* = 0; sel_end.* = 0; return true; } }.f; // Helper para limpiar selección const clearSelection = struct { fn f(sel_start: ?*usize, sel_end: ?*usize) void { if (sel_start) |s| s.* = 0; if (sel_end) |e| e.* = 0; } }.f; // Procesar eventos de tecla for (ctx.input.getKeyEvents()) |event| { if (!event.pressed) continue; switch (event.key) { .escape => { escape_count.* += 1; if (escape_count.* >= 2 or original_text == null) { result.cancelled = true; } else { // Revertir al valor original if (original_text) |orig| { const len = @min(orig.len, edit_buffer.len); @memcpy(edit_buffer[0..len], orig[0..len]); edit_len.* = len; edit_cursor.* = len; result.reverted = true; } } result.handled = true; return result; }, .enter => { result.committed = true; result.navigate = .next_row; result.handled = true; return result; }, .tab => { result.committed = true; result.navigate = if (event.modifiers.shift) .prev_cell else .next_cell; result.handled = true; return result; }, .up => { result.committed = true; result.navigate = .prev_row; result.handled = true; return result; }, .down => { result.committed = true; result.navigate = .next_row; result.handled = true; return result; }, .left => { clearSelection(selection_start, selection_end); if (edit_cursor.* > 0) edit_cursor.* -= 1; result.handled = true; escape_count.* = 0; }, .right => { clearSelection(selection_start, selection_end); if (edit_cursor.* < edit_len.*) edit_cursor.* += 1; result.handled = true; escape_count.* = 0; }, .home => { clearSelection(selection_start, selection_end); edit_cursor.* = 0; result.handled = true; escape_count.* = 0; }, .end => { clearSelection(selection_start, selection_end); edit_cursor.* = edit_len.*; result.handled = true; escape_count.* = 0; }, .backspace => { // Si hay selección, borrar selección if (selection_start != null and selection_end != null) { if (deleteSelection(edit_buffer, edit_len, edit_cursor, selection_start.?, selection_end.?)) { result.text_changed = true; result.handled = true; escape_count.* = 0; continue; } } // Sin selección: borrar caracter antes del cursor if (edit_cursor.* > 0 and edit_len.* > 0) { const pos = edit_cursor.* - 1; var i: usize = pos; while (i < edit_len.* - 1) : (i += 1) { edit_buffer[i] = edit_buffer[i + 1]; } edit_len.* -= 1; edit_cursor.* -= 1; result.text_changed = true; } result.handled = true; escape_count.* = 0; }, .delete => { // Si hay selección, borrar selección if (selection_start != null and selection_end != null) { if (deleteSelection(edit_buffer, edit_len, edit_cursor, selection_start.?, selection_end.?)) { result.text_changed = true; result.handled = true; escape_count.* = 0; continue; } } // Sin selección: borrar caracter después del cursor if (edit_cursor.* < edit_len.*) { var i: usize = edit_cursor.*; while (i < edit_len.* - 1) : (i += 1) { edit_buffer[i] = edit_buffer[i + 1]; } edit_len.* -= 1; result.text_changed = true; } result.handled = true; escape_count.* = 0; }, else => {}, } } // Procesar texto ingresado (caracteres imprimibles) const text_input = ctx.input.getTextInput(); if (text_input.len > 0) { // Si hay selección, borrarla primero (comportamiento Excel/Word) if (selection_start != null and selection_end != null) { if (selection_start.?.* != selection_end.?.*) { _ = deleteSelection(edit_buffer, edit_len, edit_cursor, selection_start.?, selection_end.?); } } for (text_input) |ch| { if (edit_len.* < edit_buffer.len - 1) { // Hacer espacio moviendo caracteres hacia la derecha if (edit_cursor.* < edit_len.*) { var i = edit_len.*; while (i > edit_cursor.*) : (i -= 1) { edit_buffer[i] = edit_buffer[i - 1]; } } edit_buffer[edit_cursor.*] = ch; edit_len.* += 1; edit_cursor.* += 1; result.text_changed = true; result.handled = true; } } escape_count.* = 0; } return result; } // ============================================================================= // BRAIN-IN-CORE: Procesamiento Unificado de Eventos de Tabla (FASE C) // ============================================================================= // // Arquitectura "Brain-in-Core" (diseñado por Gemini): // - TODA la lógica de decisión vive aquí // - Los widgets solo pasan eventos y reaccionan a los resultados // - Cualquier nueva tabla (CloudTable, etc.) hereda esta potencia automáticamente /// Resultado completo del procesamiento de eventos de tabla. /// Contiene flags para TODAS las acciones posibles. pub const TableEventResult = struct { // ========================================================================= // Navegación básica (flechas, PageUp/Down) // ========================================================================= move_up: bool = false, move_down: bool = false, move_left: bool = false, // Sin Ctrl: cambiar columna move_right: bool = false, // Sin Ctrl: cambiar columna page_up: bool = false, page_down: bool = false, // ========================================================================= // Navegación a extremos // ========================================================================= go_to_first_col: bool = false, // Home sin Ctrl go_to_last_col: bool = false, // End sin Ctrl go_to_first_row: bool = false, // Ctrl+Home: primera fila de datos go_to_last_row: bool = false, // Ctrl+End: última fila de datos // ========================================================================= // Scroll horizontal (Ctrl+Left/Right) // ========================================================================= scroll_left: bool = false, scroll_right: bool = false, // ========================================================================= // CRUD (Ctrl+N, Ctrl+B, Ctrl+Delete) // ========================================================================= insert_row: bool = false, // Ctrl+N: insertar nueva fila delete_row: bool = false, // Ctrl+Delete o Ctrl+B: eliminar fila // ========================================================================= // Ordenación (Ctrl+Shift+1..9) // ========================================================================= sort_by_column: ?usize = null, // Índice de columna (0-based) // ========================================================================= // Edición (F2, Space, tecla alfanumérica) // ========================================================================= start_editing: bool = false, // Iniciar edición de celda activa initial_char: ?u8 = null, // Caracter inicial (si fue tecla alfa) // ========================================================================= // Tab navigation // ========================================================================= tab_out: bool = false, // Tab presionado (pasar focus a otro widget) tab_shift: bool = false, // Fue Shift+Tab (dirección inversa) // ========================================================================= // Flag general // ========================================================================= handled: bool = false, // Se procesó algún evento }; /// Procesa TODOS los eventos de teclado de una tabla. /// Esta es la función maestra "Brain-in-Core" que centraliza toda la lógica. /// /// Parámetros: /// - ctx: Contexto de renderizado (acceso a input) /// - is_editing: Si hay una celda en modo edición (ignora navegación) /// /// El widget debe reaccionar a los flags retornados y actualizar su estado. /// /// Ejemplo de uso en widget: /// ```zig /// const events = table_core.processTableEvents(ctx, list_state.isEditing()); /// if (events.move_up) list_state.moveUp(); /// if (events.move_down) list_state.moveDown(visible_rows); /// if (events.go_to_first_row) list_state.goToStart(); /// if (events.insert_row) result.insert_row = true; /// // ... etc /// ``` pub fn processTableEvents(ctx: *Context, is_editing: bool) TableEventResult { var result = TableEventResult{}; // Si hay edición activa, el CellEditor maneja las teclas // Solo procesamos Tab para salir del widget if (is_editing) { for (ctx.input.getKeyEvents()) |event| { if (!event.pressed) continue; if (event.key == .tab) { result.tab_out = true; result.tab_shift = event.modifiers.shift; result.handled = true; return result; } } return result; } // ========================================================================= // 1. Navegación con navKeyPressed (soporta key repeat) // ========================================================================= if (ctx.input.navKeyPressed()) |key| { const ctrl = ctx.input.modifiers.ctrl; switch (key) { .up => { result.move_up = true; result.handled = true; }, .down => { result.move_down = true; result.handled = true; }, .left => { if (ctrl) { result.scroll_left = true; } else { result.move_left = true; } result.handled = true; }, .right => { if (ctrl) { result.scroll_right = true; } else { result.move_right = true; } result.handled = true; }, .page_up => { result.page_up = true; result.handled = true; }, .page_down => { result.page_down = true; result.handled = true; }, .home => { if (ctrl) { result.go_to_first_row = true; result.go_to_first_col = true; } else { result.go_to_first_col = true; } result.handled = true; }, .end => { if (ctrl) { result.go_to_last_row = true; result.go_to_last_col = true; } else { result.go_to_last_col = true; } result.handled = true; }, else => {}, } } // ========================================================================= // 2. Atajos con Ctrl y teclas especiales (getKeyEvents) // ========================================================================= for (ctx.input.getKeyEvents()) |event| { if (!event.pressed) continue; // F2 o Space: iniciar edición if (event.key == .f2 or event.key == .space) { result.start_editing = true; result.handled = true; return result; } // Tab: pasar focus al siguiente widget if (event.key == .tab) { result.tab_out = true; result.tab_shift = event.modifiers.shift; result.handled = true; return result; } // Atajos con Ctrl if (event.modifiers.ctrl) { switch (event.key) { .n => { // Ctrl+N: insertar nueva fila result.insert_row = true; result.handled = true; return result; }, .b, .delete => { // Ctrl+B o Ctrl+Delete: eliminar fila result.delete_row = true; result.handled = true; return result; }, // Ctrl+Shift+1..9: ordenar por columna .@"1" => { if (event.modifiers.shift) { result.sort_by_column = 0; result.handled = true; return result; } }, .@"2" => { if (event.modifiers.shift) { result.sort_by_column = 1; result.handled = true; return result; } }, .@"3" => { if (event.modifiers.shift) { result.sort_by_column = 2; result.handled = true; return result; } }, .@"4" => { if (event.modifiers.shift) { result.sort_by_column = 3; result.handled = true; return result; } }, .@"5" => { if (event.modifiers.shift) { result.sort_by_column = 4; result.handled = true; return result; } }, .@"6" => { if (event.modifiers.shift) { result.sort_by_column = 5; result.handled = true; return result; } }, .@"7" => { if (event.modifiers.shift) { result.sort_by_column = 6; result.handled = true; return result; } }, .@"8" => { if (event.modifiers.shift) { result.sort_by_column = 7; result.handled = true; return result; } }, .@"9" => { if (event.modifiers.shift) { result.sort_by_column = 8; result.handled = true; return result; } }, else => {}, } } } // ========================================================================= // 3. Teclas alfanuméricas: iniciar edición con ese caracter // ========================================================================= const char_input = ctx.input.getTextInput(); if (char_input.len > 0) { result.start_editing = true; result.initial_char = char_input[0]; result.handled = true; } return result; } // Alias para compatibilidad (DEPRECADO - usar processTableEvents) pub const TableKeyboardResult = TableEventResult; pub const handleTableKeyboard = processTableEvents; // ============================================================================= // Edición de fila completa (commit al abandonar fila, estilo Excel) // ============================================================================= /// Máximo de columnas soportadas para cambios pendientes pub const MAX_PENDING_COLUMNS: usize = 32; /// Máximo tamaño de valor por celda pub const MAX_CELL_VALUE_LEN: usize = 256; /// ID especial para filas nuevas (ghost row) pub const NEW_ROW_ID: i64 = -1; /// Cambio pendiente en una columna pub const PendingCellChange = struct { /// Índice de columna col: usize, /// Valor nuevo (slice al buffer interno) value: []const u8, }; /// Buffer para acumular cambios de una fila antes de commit /// Usado por los states de los widgets, procesado por funciones de table_core pub const RowEditBuffer = struct { /// ID de la fila siendo editada (NEW_ROW_ID si es ghost row o inyectada) row_id: i64 = NEW_ROW_ID, /// Índice de fila (para navegación) row_index: usize = 0, /// Es una fila nueva (ghost row que el usuario está rellenando) is_new_row: bool = false, /// Hay cambios pendientes has_changes: bool = false, /// Buffers de valores por columna (almacenamiento fijo) value_buffers: [MAX_PENDING_COLUMNS][MAX_CELL_VALUE_LEN]u8 = undefined, /// Longitudes de cada valor value_lens: [MAX_PENDING_COLUMNS]usize = [_]usize{0} ** MAX_PENDING_COLUMNS, /// Flags: qué columnas tienen cambios changed_cols: [MAX_PENDING_COLUMNS]bool = [_]bool{false} ** MAX_PENDING_COLUMNS, /// Número de columnas con cambios change_count: usize = 0, /// Inicializa/resetea el buffer para una nueva fila pub fn startEdit(self: *RowEditBuffer, row_id: i64, row_index: usize, is_new: bool) void { self.row_id = row_id; self.row_index = row_index; self.is_new_row = is_new; self.has_changes = false; self.change_count = 0; for (0..MAX_PENDING_COLUMNS) |i| { self.changed_cols[i] = false; self.value_lens[i] = 0; } } /// Añade un cambio pendiente para una columna pub fn addChange(self: *RowEditBuffer, col: usize, value: []const u8) void { if (col >= MAX_PENDING_COLUMNS) return; // Copiar valor al buffer interno const len = @min(value.len, MAX_CELL_VALUE_LEN); @memcpy(self.value_buffers[col][0..len], value[0..len]); self.value_lens[col] = len; // Marcar como cambiado if (!self.changed_cols[col]) { self.changed_cols[col] = true; self.change_count += 1; } self.has_changes = true; } /// Obtiene el valor pendiente de una columna (si hay cambio) pub fn getPendingValue(self: *const RowEditBuffer, col: usize) ?[]const u8 { if (col >= MAX_PENDING_COLUMNS) return null; if (!self.changed_cols[col]) return null; return self.value_buffers[col][0..self.value_lens[col]]; } /// Limpia el buffer (después de commit o discard) pub fn clear(self: *RowEditBuffer) void { self.row_id = NEW_ROW_ID; self.row_index = 0; self.is_new_row = false; self.has_changes = false; self.change_count = 0; for (0..MAX_PENDING_COLUMNS) |i| { self.changed_cols[i] = false; self.value_lens[i] = 0; } } }; /// Información para hacer commit de los cambios de una fila /// Retornada cuando el usuario abandona una fila editada pub const RowCommitInfo = struct { /// ID de la fila (NEW_ROW_ID si es INSERT) row_id: i64, /// Es INSERT (nueva fila) o UPDATE (fila existente) is_insert: bool, /// Lista de cambios (columna, valor) changes: []const PendingCellChange, /// Número de cambios change_count: usize, }; /// Construye la info de commit desde un RowEditBuffer /// El caller debe proveer el array para almacenar los cambios pub fn buildCommitInfo( buffer: *const RowEditBuffer, changes_out: []PendingCellChange, ) ?RowCommitInfo { if (!buffer.has_changes) return null; var count: usize = 0; for (0..MAX_PENDING_COLUMNS) |col| { if (buffer.changed_cols[col] and count < changes_out.len) { changes_out[count] = .{ .col = col, .value = buffer.value_buffers[col][0..buffer.value_lens[col]], }; count += 1; } } return RowCommitInfo{ .row_id = buffer.row_id, .is_insert = buffer.is_new_row, .changes = changes_out[0..count], .change_count = count, }; } /// Verifica si hay que hacer commit antes de editar nueva celda. /// Si la fila cambió y hay cambios pendientes, retorna commit info. /// Siempre inicializa el buffer para la nueva fila. /// /// Uso típico en widget: /// ``` /// if (table_core.checkRowChangeAndCommit(&state.row_edit_buffer, new_id, new_idx, is_ghost, &changes)) |info| { /// result.row_committed = true; /// result.commit_info = info; /// } /// ``` pub fn checkRowChangeAndCommit( buffer: *RowEditBuffer, new_row_id: i64, new_row_index: usize, is_new_row: bool, changes_out: []PendingCellChange, ) ?RowCommitInfo { // Si es la misma fila, no hacer nada if (buffer.row_id == new_row_id) return null; // Si hay cambios pendientes en la fila anterior, construir commit var commit_info: ?RowCommitInfo = null; if (buffer.has_changes) { commit_info = buildCommitInfo(buffer, changes_out); } // Iniciar edición de la nueva fila buffer.startEdit(new_row_id, new_row_index, is_new_row); return commit_info; } /// Verifica si un row_id corresponde a la ghost row (fila nueva) pub fn isGhostRow(row_id: i64) bool { return row_id == NEW_ROW_ID; } // ============================================================================= // Utilidades // ============================================================================= /// Mezcla dos colores con un factor alpha pub fn blendColor(base: Style.Color, overlay: Style.Color, alpha: f32) Style.Color { const inv_alpha = 1.0 - alpha; return Style.Color.rgba( @intFromFloat(@as(f32, @floatFromInt(base.r)) * inv_alpha + @as(f32, @floatFromInt(overlay.r)) * alpha), @intFromFloat(@as(f32, @floatFromInt(base.g)) * inv_alpha + @as(f32, @floatFromInt(overlay.g)) * alpha), @intFromFloat(@as(f32, @floatFromInt(base.b)) * inv_alpha + @as(f32, @floatFromInt(overlay.b)) * alpha), base.a, ); } /// Compara strings case-insensitive para búsqueda incremental pub fn startsWithIgnoreCase(haystack: []const u8, needle: []const u8) bool { if (needle.len > haystack.len) return false; if (needle.len == 0) return true; for (needle, 0..) |needle_char, i| { const haystack_char = haystack[i]; const needle_lower = if (needle_char >= 'A' and needle_char <= 'Z') needle_char + 32 else needle_char; const haystack_lower = if (haystack_char >= 'A' and haystack_char <= 'Z') haystack_char + 32 else haystack_char; if (needle_lower != haystack_lower) return false; } return true; } // ============================================================================= // Navegación Tab Excel-style (compartida por AdvancedTable y VirtualAdvancedTable) // ============================================================================= /// Resultado de navegación Tab pub const TabNavigateResult = enum { /// Navegó a otra celda dentro del widget navigated, /// Salió del widget (Tab en última celda o Shift+Tab en primera) tab_out, }; /// Resultado del cálculo de nueva posición de celda pub const CellPosition = struct { row: usize, col: usize, result: TabNavigateResult, }; /// Calcula la siguiente celda después de Tab /// Parámetros genéricos para que funcione con ambos tipos de tabla. pub fn calculateNextCell( current_row: usize, current_col: usize, num_cols: usize, num_rows: usize, wrap_to_start: bool, ) CellPosition { if (num_cols == 0 or num_rows == 0) { return .{ .row = current_row, .col = current_col, .result = .tab_out }; } var new_row = current_row; var new_col = current_col; if (current_col + 1 < num_cols) { // Siguiente columna en misma fila new_col = current_col + 1; return .{ .row = new_row, .col = new_col, .result = .navigated }; } // Última columna: ir a primera columna de siguiente fila new_col = 0; if (current_row + 1 < num_rows) { // Hay siguiente fila new_row = current_row + 1; return .{ .row = new_row, .col = new_col, .result = .navigated }; } // Última fila if (wrap_to_start) { new_row = 0; return .{ .row = new_row, .col = new_col, .result = .navigated }; } return .{ .row = current_row, .col = current_col, .result = .tab_out }; } /// Calcula la celda anterior después de Shift+Tab pub fn calculatePrevCell( current_row: usize, current_col: usize, num_cols: usize, num_rows: usize, wrap_to_end: bool, ) CellPosition { if (num_cols == 0 or num_rows == 0) { return .{ .row = current_row, .col = current_col, .result = .tab_out }; } var new_row = current_row; var new_col = current_col; if (current_col > 0) { // Columna anterior en misma fila new_col = current_col - 1; return .{ .row = new_row, .col = new_col, .result = .navigated }; } // Primera columna: ir a última columna de fila anterior new_col = num_cols - 1; if (current_row > 0) { // Hay fila anterior new_row = current_row - 1; return .{ .row = new_row, .col = new_col, .result = .navigated }; } // Primera fila if (wrap_to_end) { new_row = num_rows - 1; return .{ .row = new_row, .col = new_col, .result = .navigated }; } return .{ .row = current_row, .col = current_col, .result = .tab_out }; } /// Acción a ejecutar después de navegación Tab pub const TabAction = enum { /// Navegar a nueva celda, sin commit move, /// Navegar a nueva celda, con commit de fila anterior move_with_commit, /// Salir del widget, sin commit exit, /// Salir del widget, con commit de fila actual exit_with_commit, }; /// Plan completo de navegación Tab (resultado de planTabNavigation) pub const TabNavigationPlan = struct { action: TabAction, new_row: usize, new_col: usize, commit_info: ?RowCommitInfo, }; /// Planifica navegación Tab con commit automático al cambiar de fila. /// /// Esta es la función central DRY para navegación Excel-style. /// El widget solo pasa parámetros y recibe el plan completo. /// /// Parámetros: /// - buffer: RowEditBuffer con cambios pendientes /// - current_row/col: posición actual /// - num_cols/rows: dimensiones de la tabla /// - forward: true=Tab, false=Shift+Tab /// - wrap: si hacer wrap al llegar al final /// - row_id_getter: cualquier tipo con fn getRowId(usize) i64 /// - changes_out: buffer para almacenar cambios del commit /// /// El widget ejecuta el plan: /// - .move: actualizar posición /// - .move_with_commit: guardar commit_info en BD, luego actualizar posición /// - .exit: establecer tab_out=true /// - .exit_with_commit: guardar commit_info, luego tab_out=true pub fn planTabNavigation( buffer: *RowEditBuffer, current_row: usize, current_col: usize, num_cols: usize, num_rows: usize, forward: bool, wrap: bool, row_id_getter: anytype, changes_out: []PendingCellChange, ) TabNavigationPlan { // 1. Calcular nueva posición const pos = if (forward) calculateNextCell(current_row, current_col, num_cols, num_rows, wrap) else calculatePrevCell(current_row, current_col, num_cols, num_rows, wrap); // 2. Si es tab_out, verificar si hay commit pendiente if (pos.result == .tab_out) { if (buffer.has_changes) { const info = buildCommitInfo(buffer, changes_out); buffer.clear(); return .{ .action = .exit_with_commit, .new_row = pos.row, .new_col = pos.col, .commit_info = info, }; } return .{ .action = .exit, .new_row = pos.row, .new_col = pos.col, .commit_info = null, }; } // 3. Navegación dentro del widget - verificar si cambió de fila const current_row_id = buffer.row_id; const new_row_id = row_id_getter.getRowId(pos.row); std.debug.print("[PLAN-TAB] current_row={} current_col={} -> new_row={} new_col={}\n", .{ current_row, current_col, pos.row, pos.col, }); std.debug.print("[PLAN-TAB] buffer.row_id={} getter.getRowId({})={} has_changes={}\n", .{ current_row_id, pos.row, new_row_id, buffer.has_changes, }); if (current_row_id != new_row_id and buffer.has_changes) { // Cambió de fila con cambios pendientes → commit const info = buildCommitInfo(buffer, changes_out); // Iniciar buffer para nueva fila buffer.startEdit(new_row_id, pos.row, isGhostRow(new_row_id)); return .{ .action = .move_with_commit, .new_row = pos.row, .new_col = pos.col, .commit_info = info, }; } // Sin cambio de fila o sin cambios pendientes if (current_row_id != new_row_id) { // Cambió de fila pero sin cambios → solo actualizar buffer buffer.startEdit(new_row_id, pos.row, isGhostRow(new_row_id)); } return .{ .action = .move, .new_row = pos.row, .new_col = pos.col, .commit_info = null, }; } // ============================================================================= // Ordenación (compartida) // ============================================================================= /// Dirección de ordenación pub const SortDirection = enum { none, ascending, descending, /// Alterna la dirección: none → asc → desc → none pub fn toggle(self: SortDirection) SortDirection { return switch (self) { .none => .ascending, .ascending => .descending, .descending => .none, }; } }; /// Resultado de toggle de ordenación en columna pub const SortToggleResult = struct { /// Nueva columna de ordenación (null si se desactivó) column: ?usize, /// Nueva dirección direction: SortDirection, }; /// Calcula el nuevo estado de ordenación al hacer click en una columna pub fn toggleSort( current_column: ?usize, current_direction: SortDirection, clicked_column: usize, ) SortToggleResult { if (current_column) |col| { if (col == clicked_column) { // Misma columna: ciclar dirección const new_dir = current_direction.toggle(); return .{ .column = if (new_dir == .none) null else clicked_column, .direction = new_dir, }; } } // Columna diferente o sin ordenación: empezar ascendente return .{ .column = clicked_column, .direction = .ascending, }; } // ============================================================================= // TableDataSource Interface (FASE 3) // ============================================================================= // // ## Interfaz TableDataSource // // Abstrae el origen de datos para tablas, permitiendo que el mismo widget // renderice datos desde memoria (AdvancedTable) o desde BD paginada (VirtualAdvancedTable). // // ### Protocolo de Memoria // // `getCellValueInto` escribe directamente en el buffer proporcionado por el widget. // Esto elimina problemas de ownership: el widget controla la vida del buffer. // // ### Ejemplo de uso: // ```zig // var buf: [256]u8 = undefined; // const value = data_source.getCellValueInto(row, col, &buf); // // value es un slice de buf, válido mientras buf exista // ``` /// Interfaz genérica para proveer datos a tablas /// Usa vtable pattern para polimorfismo en runtime pub const TableDataSource = struct { ptr: *anyopaque, vtable: *const VTable, pub const VTable = struct { /// Retorna el número total de filas en el datasource getRowCount: *const fn (ptr: *anyopaque) usize, /// Escribe el valor de una celda en el buffer proporcionado /// Retorna el slice del buffer con el contenido escrito /// Si la celda no existe o está vacía, retorna "" getCellValueInto: *const fn (ptr: *anyopaque, row: usize, col: usize, buf: []u8) []const u8, /// Retorna el ID único de una fila (para selección persistente) /// NEW_ROW_ID (-1) indica fila nueva no guardada getRowId: *const fn (ptr: *anyopaque, row: usize) i64, /// Verifica si una celda es editable (opcional, default true) isCellEditable: ?*const fn (ptr: *anyopaque, row: usize, col: usize) bool = null, /// Retorna el estado de una fila (opcional, default .normal) /// Usado para colores de estado (modified, new, deleted, error) getRowState: ?*const fn (ptr: *anyopaque, row: usize) RowState = null, /// Invalida cache interno (para refresh) invalidate: ?*const fn (ptr: *anyopaque) void = null, }; // ========================================================================= // Métodos de conveniencia // ========================================================================= /// Obtiene el número de filas pub fn getRowCount(self: TableDataSource) usize { return self.vtable.getRowCount(self.ptr); } /// Escribe valor de celda en buffer pub fn getCellValueInto(self: TableDataSource, row: usize, col: usize, buf: []u8) []const u8 { return self.vtable.getCellValueInto(self.ptr, row, col, buf); } /// Obtiene ID de fila pub fn getRowId(self: TableDataSource, row: usize) i64 { return self.vtable.getRowId(self.ptr, row); } /// Verifica si celda es editable pub fn isCellEditable(self: TableDataSource, row: usize, col: usize) bool { if (self.vtable.isCellEditable) |func| { return func(self.ptr, row, col); } return true; // Default: todas editables } /// Invalida cache pub fn invalidate(self: TableDataSource) void { if (self.vtable.invalidate) |func| { func(self.ptr); } } /// Obtiene el estado de una fila pub fn getRowState(self: TableDataSource, row: usize) RowState { if (self.vtable.getRowState) |func| { return func(self.ptr, row); } return .normal; // Default: estado normal } /// Verifica si la fila es la ghost row (nueva) pub fn isGhostRow(self: TableDataSource, row: usize) bool { return self.getRowId(row) == NEW_ROW_ID; } }; /// Helper para crear TableDataSource desde un tipo concreto /// El tipo T debe tener los métodos: getRowCount, getCellValueInto, getRowId pub fn makeTableDataSource(comptime T: type, impl: *T) TableDataSource { const vtable = comptime blk: { var vt: TableDataSource.VTable = .{ .getRowCount = @ptrCast(&T.getRowCount), .getCellValueInto = @ptrCast(&T.getCellValueInto), .getRowId = @ptrCast(&T.getRowId), }; // Métodos opcionales if (@hasDecl(T, "isCellEditable")) { vt.isCellEditable = @ptrCast(&T.isCellEditable); } if (@hasDecl(T, "getRowState")) { vt.getRowState = @ptrCast(&T.getRowState); } if (@hasDecl(T, "invalidate")) { vt.invalidate = @ptrCast(&T.invalidate); } break :blk vt; }; return .{ .ptr = impl, .vtable = &vtable, }; } // ============================================================================= // Renderizado de Scrollbars (FASE 6) // ============================================================================= /// Parámetros para dibujar scrollbar vertical pub const VerticalScrollbarParams = struct { /// Posición X del track track_x: i32, /// Posición Y del track track_y: i32, /// Ancho del scrollbar width: u32 = 12, /// Altura del track height: u32, /// Número de elementos visibles visible_count: usize, /// Número total de elementos total_count: usize, /// Posición actual del scroll (0-based) scroll_pos: usize, /// Color del track (fondo) track_color: Style.Color, /// Color del thumb (control deslizante) thumb_color: Style.Color, }; /// Dibuja un scrollbar vertical. /// Función genérica usada por AdvancedTable y VirtualAdvancedTable. pub fn drawVerticalScrollbar(ctx: *Context, params: VerticalScrollbarParams) void { if (params.total_count == 0 or params.visible_count >= params.total_count) return; // Track (fondo) ctx.pushCommand(Command.rect( params.track_x, params.track_y, params.width, params.height, params.track_color, )); // Calcular tamaño del thumb const visible_ratio = @as(f32, @floatFromInt(params.visible_count)) / @as(f32, @floatFromInt(params.total_count)); const thumb_h = @max(20, @as(u32, @intFromFloat(visible_ratio * @as(f32, @floatFromInt(params.height))))); // Calcular posición del thumb const max_scroll = params.total_count - params.visible_count; const scroll_ratio = @as(f32, @floatFromInt(params.scroll_pos)) / @as(f32, @floatFromInt(@max(1, max_scroll))); const thumb_y_offset = @as(u32, @intFromFloat(scroll_ratio * @as(f32, @floatFromInt(params.height - thumb_h)))); // Thumb (control deslizante) ctx.pushCommand(Command.rect( params.track_x + 2, params.track_y + @as(i32, @intCast(thumb_y_offset)), params.width - 4, thumb_h, params.thumb_color, )); } /// Parámetros para dibujar scrollbar horizontal pub const HorizontalScrollbarParams = struct { /// Posición X del track track_x: i32, /// Posición Y del track track_y: i32, /// Ancho del track width: u32, /// Altura del scrollbar height: u32 = 12, /// Ancho visible del contenido visible_width: u32, /// Ancho total del contenido total_width: u32, /// Posición actual del scroll horizontal (pixels) scroll_x: i32, /// Máximo scroll horizontal (pixels) max_scroll_x: i32, /// Color del track (fondo) track_color: Style.Color, /// Color del thumb (control deslizante) thumb_color: Style.Color, }; /// Dibuja un scrollbar horizontal. /// Función genérica usada por VirtualAdvancedTable. pub fn drawHorizontalScrollbar(ctx: *Context, params: HorizontalScrollbarParams) void { if (params.max_scroll_x <= 0) return; // Track (fondo) ctx.pushCommand(Command.rect( params.track_x, params.track_y, params.width, params.height, params.track_color, )); // Calcular tamaño del thumb const visible_ratio = @as(f32, @floatFromInt(params.visible_width)) / @as(f32, @floatFromInt(params.total_width)); const thumb_w = @max(20, @as(u32, @intFromFloat(visible_ratio * @as(f32, @floatFromInt(params.width))))); // Calcular posición del thumb const scroll_ratio = @as(f32, @floatFromInt(params.scroll_x)) / @as(f32, @floatFromInt(params.max_scroll_x)); const thumb_x_offset = @as(u32, @intFromFloat(scroll_ratio * @as(f32, @floatFromInt(params.width - thumb_w)))); // Thumb (control deslizante) ctx.pushCommand(Command.rect( params.track_x + @as(i32, @intCast(thumb_x_offset)), params.track_y + 2, thumb_w, params.height - 4, params.thumb_color, )); } // ============================================================================= // Tests // ============================================================================= test "blendColor" { const white = Style.Color.rgb(255, 255, 255); const black = Style.Color.rgb(0, 0, 0); const gray = blendColor(white, black, 0.5); try std.testing.expectEqual(@as(u8, 127), gray.r); try std.testing.expectEqual(@as(u8, 127), gray.g); try std.testing.expectEqual(@as(u8, 127), gray.b); } test "startsWithIgnoreCase" { try std.testing.expect(startsWithIgnoreCase("Hello World", "Hello")); try std.testing.expect(startsWithIgnoreCase("Hello World", "hello")); try std.testing.expect(startsWithIgnoreCase("hello world", "HELLO")); try std.testing.expect(startsWithIgnoreCase("anything", "")); try std.testing.expect(!startsWithIgnoreCase("Hello", "World")); try std.testing.expect(!startsWithIgnoreCase("Hi", "Hello World")); } test "detectDoubleClick" { var state = DoubleClickState{}; // Primer click const first = detectDoubleClick(&state, 1000, 0, 0); try std.testing.expect(!first); // Segundo click rápido en misma celda = doble click const second = detectDoubleClick(&state, 1200, 0, 0); try std.testing.expect(second); // Tercer click (estado reseteado) const third = detectDoubleClick(&state, 1400, 0, 0); try std.testing.expect(!third); } test "calculateNextCell - basic navigation" { // Tabla 3x4 (3 columnas, 4 filas) // Celda (0,0) -> (0,1) const r1 = calculateNextCell(0, 0, 3, 4, false); try std.testing.expectEqual(@as(usize, 0), r1.row); try std.testing.expectEqual(@as(usize, 1), r1.col); try std.testing.expectEqual(TabNavigateResult.navigated, r1.result); // Última columna -> primera columna de siguiente fila const r2 = calculateNextCell(0, 2, 3, 4, false); try std.testing.expectEqual(@as(usize, 1), r2.row); try std.testing.expectEqual(@as(usize, 0), r2.col); try std.testing.expectEqual(TabNavigateResult.navigated, r2.result); // Última celda sin wrap -> tab_out const r3 = calculateNextCell(3, 2, 3, 4, false); try std.testing.expectEqual(TabNavigateResult.tab_out, r3.result); // Última celda con wrap -> primera celda const r4 = calculateNextCell(3, 2, 3, 4, true); try std.testing.expectEqual(@as(usize, 0), r4.row); try std.testing.expectEqual(@as(usize, 0), r4.col); try std.testing.expectEqual(TabNavigateResult.navigated, r4.result); } test "calculatePrevCell - basic navigation" { // Celda (0,2) -> (0,1) const r1 = calculatePrevCell(0, 2, 3, 4, false); try std.testing.expectEqual(@as(usize, 0), r1.row); try std.testing.expectEqual(@as(usize, 1), r1.col); try std.testing.expectEqual(TabNavigateResult.navigated, r1.result); // Primera columna -> última columna de fila anterior const r2 = calculatePrevCell(1, 0, 3, 4, false); try std.testing.expectEqual(@as(usize, 0), r2.row); try std.testing.expectEqual(@as(usize, 2), r2.col); try std.testing.expectEqual(TabNavigateResult.navigated, r2.result); // Primera celda sin wrap -> tab_out const r3 = calculatePrevCell(0, 0, 3, 4, false); try std.testing.expectEqual(TabNavigateResult.tab_out, r3.result); // Primera celda con wrap -> última celda const r4 = calculatePrevCell(0, 0, 3, 4, true); try std.testing.expectEqual(@as(usize, 3), r4.row); try std.testing.expectEqual(@as(usize, 2), r4.col); try std.testing.expectEqual(TabNavigateResult.navigated, r4.result); } test "toggleSort" { // Sin ordenación -> ascendente en columna 2 const r1 = toggleSort(null, .none, 2); try std.testing.expectEqual(@as(?usize, 2), r1.column); try std.testing.expectEqual(SortDirection.ascending, r1.direction); // Ascendente en columna 2 -> descendente const r2 = toggleSort(2, .ascending, 2); try std.testing.expectEqual(@as(?usize, 2), r2.column); try std.testing.expectEqual(SortDirection.descending, r2.direction); // Descendente -> none (columna null) const r3 = toggleSort(2, .descending, 2); try std.testing.expectEqual(@as(?usize, null), r3.column); try std.testing.expectEqual(SortDirection.none, r3.direction); // Click en columna diferente -> ascendente en nueva columna const r4 = toggleSort(2, .ascending, 5); try std.testing.expectEqual(@as(?usize, 5), r4.column); try std.testing.expectEqual(SortDirection.ascending, r4.direction); }