//! Estado del VirtualAdvancedTable //! //! Mantiene el estado de navegación, selección y caché del widget. const std = @import("std"); const types = @import("types.zig"); const table_core = @import("../table_core.zig"); const RowData = types.RowData; const CountInfo = types.CountInfo; const SortDirection = types.SortDirection; const CellId = types.CellId; const CellGeometry = types.CellGeometry; /// Estado del widget VirtualAdvancedTable pub const VirtualAdvancedTableState = struct { // ========================================================================= // Selección // ========================================================================= /// ID del registro seleccionado (NO índice) /// Usamos ID porque el índice cambia al scroll/ordenar/filtrar selected_id: ?i64 = null, /// Índice de la fila con hover hover_index: ?usize = null, /// Columna activa (para edición) /// Cuando el usuario navega con flechas o hace click, se actualiza active_col: usize = 0, // ========================================================================= // Double-click detection // ========================================================================= /// Time of last click (ms) last_click_time: u64 = 0, /// Row of last click (global index) last_click_row: i64 = -1, /// Column of last click last_click_col: i32 = -1, /// Double-click threshold in ms double_click_threshold_ms: u64 = 400, // ========================================================================= // Scroll y ventana // ========================================================================= /// Offset actual del scroll vertical (en filas, no pixels) scroll_offset: usize = 0, /// Offset del scroll en pixels (para smooth scroll vertical) scroll_offset_pixels: i32 = 0, /// Offset del scroll horizontal (en pixels) scroll_offset_x: i32 = 0, /// Ventana de datos actual (propiedad del DataProvider) /// NO liberar - el provider lo gestiona current_window: []const RowData = &.{}, /// Offset donde empieza la ventana actual window_start: usize = 0, /// Forzar refetch en próximo frame (después de edición) needs_window_refresh: bool = false, // ========================================================================= // Estado de carga // ========================================================================= /// Indica si hay una carga en progreso is_loading: bool = false, /// Conteo total de registros total_count: CountInfo = .{}, /// Conteo después de aplicar filtro filtered_count: CountInfo = .{}, // ========================================================================= // Filtro y ordenación // ========================================================================= /// Buffer para texto de filtro filter_buf: [256]u8 = [_]u8{0} ** 256, filter_len: usize = 0, /// Columna de ordenación actual sort_column: ?[]const u8 = null, /// Dirección de ordenación sort_direction: SortDirection = .none, // ========================================================================= // FilterBar state // ========================================================================= /// Chips activos (bitset, máximo 16 chips) active_chips: u16 = 0, /// Flag: el filtro de texto cambió este frame filter_text_changed: bool = false, /// Flag: un chip cambió este frame chip_changed: bool = false, /// ID del chip que cambió (índice) changed_chip_index: ?u4 = null, /// Timestamp del último cambio de filtro (para debounce) last_filter_change_ms: i64 = 0, /// Flag: el campo de búsqueda tiene focus search_has_focus: bool = false, /// Cursor del campo de búsqueda (posición en bytes) search_cursor: usize = 0, /// Selección del campo de búsqueda (inicio) search_selection_start: ?usize = null, // ========================================================================= // Flags internos // ========================================================================= /// Indica si el widget tiene focus has_focus: bool = false, /// Indica si hubo cambio de selección este frame selection_changed: bool = false, /// Indica si hubo doble click este frame double_clicked: bool = false, /// Frame counter para animaciones frame_count: u32 = 0, // ========================================================================= // Buffers persistentes para texto formateado (evitar stack buffer escape) // ========================================================================= // IMPORTANTE: Los buffers de stack pasados a ctx.pushCommand() se invalidan // cuando la función retorna. El render diferido usaría memoria corrupta. footer_display_buf: [96]u8 = undefined, footer_display_len: usize = 0, // ========================================================================= // Estado de edición CRUD Excel-style // ========================================================================= /// Celda actualmente en edición (null = no editando) editing_cell: ?CellId = null, /// Valor original de la celda (para Escape revertir) original_value: [256]u8 = undefined, original_value_len: usize = 0, /// Contador de Escapes (1 = revertir celda, 2 = descartar fila) escape_count: u8 = 0, /// Fila actual tiene cambios sin guardar en BD row_dirty: bool = false, /// Última fila editada (para detectar cambio de fila) last_edited_row: ?usize = null, /// Buffer de edición (texto actual en el editor) edit_buffer: [256]u8 = undefined, edit_buffer_len: usize = 0, edit_cursor: usize = 0, /// Tiempo de última edición (para parpadeo cursor) last_edit_time_ms: u64 = 0, /// Flag: celda requiere commit al terminar edición cell_value_changed: bool = false, // ========================================================================= // Buffer de cambios de fila (commit al abandonar fila, estilo Excel) // ========================================================================= /// Buffer para acumular cambios de la fila actual antes de commit row_edit_buffer: table_core.RowEditBuffer = .{}, const Self = @This(); // ========================================================================= // Métodos de consulta // ========================================================================= /// Obtiene el texto del filtro actual pub fn getFilter(self: *const Self) []const u8 { return self.filter_buf[0..self.filter_len]; } /// Verifica si un índice global está en la ventana actual pub fn isInWindow(self: *const Self, global_index: usize) bool { return global_index >= self.window_start and global_index < self.window_start + self.current_window.len; } /// Convierte índice global a índice de ventana pub fn globalToWindowIndex(self: *const Self, global_index: usize) ?usize { if (!self.isInWindow(global_index)) return null; return global_index - self.window_start; } /// Convierte índice de ventana a índice global pub fn windowToGlobalIndex(self: *const Self, window_index: usize) usize { return self.window_start + window_index; } /// Busca el ID seleccionado en la ventana actual /// Retorna el índice de ventana o null si no está visible pub fn findSelectedInWindow(self: *const Self) ?usize { if (self.selected_id) |id| { for (self.current_window, 0..) |row, i| { if (row.id == id) return i; } } return null; } /// Obtiene el conteo a mostrar (filtrado si hay filtro, total si no) pub fn getDisplayCount(self: *const Self) CountInfo { if (self.filter_len > 0) { return self.filtered_count; } return self.total_count; } // ========================================================================= // Métodos de modificación // ========================================================================= /// Establece el texto del filtro pub fn setFilter(self: *Self, text: []const u8) void { const len = @min(text.len, self.filter_buf.len); @memcpy(self.filter_buf[0..len], text[0..len]); self.filter_len = len; } /// Limpia el filtro pub fn clearFilter(self: *Self) void { self.filter_len = 0; } /// Establece ordenación pub fn setSort(self: *Self, column: []const u8, direction: SortDirection) void { self.sort_column = column; self.sort_direction = direction; } /// Alterna ordenación de una columna pub fn toggleSort(self: *Self, column: []const u8) void { if (self.sort_column) |current| { if (std.mem.eql(u8, current, column)) { // Misma columna: ciclar dirección self.sort_direction = self.sort_direction.toggle(); if (self.sort_direction == .none) { self.sort_column = null; } return; } } // Nueva columna: empezar ascendente self.sort_column = column; self.sort_direction = .ascending; } /// Selecciona un registro por ID pub fn selectById(self: *Self, id: ?i64) void { if (self.selected_id != id) { self.selected_id = id; self.selection_changed = true; } } /// Selecciona el registro en un índice de ventana pub fn selectByWindowIndex(self: *Self, window_index: usize) void { if (window_index < self.current_window.len) { self.selectById(self.current_window[window_index].id); } } /// Limpia la selección pub fn clearSelection(self: *Self) void { self.selectById(null); } /// Resetea flags de frame (llamar al inicio de cada frame) pub fn resetFrameFlags(self: *Self) void { self.selection_changed = false; self.double_clicked = false; self.filter_text_changed = false; self.chip_changed = false; self.changed_chip_index = null; self.frame_count +%= 1; } // ========================================================================= // FilterBar methods // ========================================================================= /// Verifica si un chip está activo pub fn isChipActive(self: *const Self, chip_index: u4) bool { return (self.active_chips & (@as(u16, 1) << chip_index)) != 0; } /// Activa un chip pub fn activateChip(self: *Self, chip_index: u4, mode: types.ChipSelectMode) void { switch (mode) { .single => { // Desactivar todos y activar solo este self.active_chips = @as(u16, 1) << chip_index; }, .multi => { // Toggle del chip self.active_chips ^= @as(u16, 1) << chip_index; }, } self.chip_changed = true; self.changed_chip_index = chip_index; } /// Desactiva un chip (solo en modo multi) pub fn deactivateChip(self: *Self, chip_index: u4) void { self.active_chips &= ~(@as(u16, 1) << chip_index); self.chip_changed = true; self.changed_chip_index = chip_index; } /// Inicializa chips por defecto según configuración pub fn initDefaultChips(self: *Self, chips: []const types.FilterChipDef) void { self.active_chips = 0; for (chips, 0..) |chip, i| { if (chip.is_default and i < 16) { self.active_chips |= @as(u16, 1) << @intCast(i); } } } /// Establece el texto del filtro pub fn setFilterText(self: *Self, text: []const u8) void { const len = @min(text.len, self.filter_buf.len); @memcpy(self.filter_buf[0..len], text[0..len]); self.filter_len = len; self.filter_text_changed = true; } /// Limpia el filtro de texto pub fn clearFilterText(self: *Self) void { self.filter_len = 0; self.filter_text_changed = true; } // ========================================================================= // Navegación // ========================================================================= /// Mueve la selección hacia arriba pub fn moveUp(self: *Self) void { const window_offset = self.scroll_offset -| self.window_start; if (self.findSelectedInWindow()) |window_idx| { // Calcular posición en pantalla actual const screen_pos = window_idx -| window_offset; if (window_idx > 0) { // Hay item anterior en buffer, seleccionarlo self.selectByWindowIndex(window_idx - 1); // Si estábamos en la primera fila visible, hacer scroll if (screen_pos == 0 and self.scroll_offset > 0) { self.scroll_offset -= 1; } } else { // Estamos en el inicio del buffer // Scroll up para cargar más datos (si es posible) if (self.scroll_offset > 0) { self.scroll_offset -= 1; } } } else if (self.current_window.len > 0) { // Sin selección: seleccionar primera fila visible self.selectByWindowIndex(window_offset); } } /// Mueve la selección hacia abajo pub fn moveDown(self: *Self, visible_rows: usize) void { if (self.findSelectedInWindow()) |window_idx| { if (window_idx + 1 < self.current_window.len) { self.selectByWindowIndex(window_idx + 1); // Calcular posición en pantalla de la nueva selección const window_offset = self.scroll_offset -| self.window_start; const new_screen_pos = (window_idx + 1) -| window_offset; // Scroll cuando la nueva posición llega a la ÚLTIMA fila visible // (visible_rows - 1 es el índice de la última fila, 0-indexed) if (new_screen_pos >= visible_rows) { self.scroll_offset += 1; } } } else if (self.current_window.len > 0) { // Sin selección: seleccionar primera fila visible self.selectByWindowIndex(0); } } /// Mueve página arriba pub fn pageUp(self: *Self, visible_rows: usize) void { if (self.scroll_offset >= visible_rows) { self.scroll_offset -= visible_rows; } else { self.scroll_offset = 0; } } /// Mueve página abajo pub fn pageDown(self: *Self, visible_rows: usize, total_rows: usize) void { const max_offset = if (total_rows > visible_rows) total_rows - visible_rows else 0; self.scroll_offset = @min(self.scroll_offset + visible_rows, max_offset); } /// Va al inicio pub fn goToStart(self: *Self) void { self.scroll_offset = 0; if (self.current_window.len > 0) { self.selectByWindowIndex(0); } } /// Va al final pub fn goToEnd(self: *Self, visible_rows: usize, total_rows: usize) void { if (total_rows > visible_rows) { self.scroll_offset = total_rows - visible_rows; } if (self.current_window.len > 0) { self.selectByWindowIndex(self.current_window.len - 1); } } /// Invalida la ventana de datos para forzar refetch en próximo frame. /// Llamar después de modificar datos en el DataProvider. pub fn invalidateWindow(self: *Self) void { self.needs_window_refresh = true; } // ========================================================================= // Navegación horizontal // ========================================================================= /// Scroll horizontal a la izquierda pub fn scrollLeft(self: *Self, amount: i32) void { self.scroll_offset_x = @max(0, self.scroll_offset_x - amount); } /// Scroll horizontal a la derecha pub fn scrollRight(self: *Self, amount: i32, max_scroll: i32) void { self.scroll_offset_x = @min(max_scroll, self.scroll_offset_x + amount); } /// Va al inicio horizontal pub fn goToStartX(self: *Self) void { self.scroll_offset_x = 0; } /// Va al final horizontal pub fn goToEndX(self: *Self, max_scroll: i32) void { self.scroll_offset_x = max_scroll; } /// 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; } } // ========================================================================= // Navegación Tab Excel-style (con wrap) // Usa table_core para la lógica común (Norma #7 DRY) // ========================================================================= /// Re-exporta TabNavigateResult desde table_core para compatibilidad pub const TabNavigateResult = table_core.TabNavigateResult; /// Navega a siguiente celda (Tab) /// Si está en última columna, va a primera columna de siguiente fila. /// Si está en última fila, hace wrap a primera fila o retorna tab_out. pub fn tabToNextCell(self: *Self, num_cols: usize, visible_rows: usize, wrap_to_start: bool) TabNavigateResult { // Obtener fila actual const current_row = self.getSelectedRow() orelse 0; const total_rows = self.current_window.len + self.window_start; // Usar función de table_core const pos = table_core.calculateNextCell(current_row, self.active_col, num_cols, total_rows, wrap_to_start); if (pos.result == .navigated) { self.active_col = pos.col; // Navegar a la nueva fila si cambió if (pos.row != current_row) { if (pos.row == 0) { self.goToStart(); } else { self.moveDown(visible_rows); } } } return pos.result; } /// Navega a celda anterior (Shift+Tab) /// Si está en primera columna, va a última columna de fila anterior. /// Si está en primera fila, hace wrap a última fila o retorna tab_out. pub fn tabToPrevCell(self: *Self, num_cols: usize, visible_rows: usize, total_rows: usize, wrap_to_end: bool) TabNavigateResult { // Obtener fila actual const current_row = self.getSelectedRow() orelse 0; // Usar función de table_core const pos = table_core.calculatePrevCell(current_row, self.active_col, num_cols, total_rows, wrap_to_end); if (pos.result == .navigated) { self.active_col = pos.col; // Navegar a la nueva fila si cambió if (pos.row != current_row) { if (pos.row == total_rows - 1) { self.goToEnd(visible_rows, total_rows); } else { self.moveUp(); } } } return pos.result; } /// Obtiene la fila global seleccionada pub fn getSelectedRow(self: *const Self) ?usize { if (self.selected_id == null) return null; // Buscar en la ventana actual if (self.findSelectedInWindow()) |window_idx| { return self.windowToGlobalIndex(window_idx); } return null; } /// Obtiene la celda activa (fila seleccionada + columna activa) pub fn getActiveCell(self: *const Self) ?CellId { if (self.getSelectedRow()) |row| { return .{ .row = row, .col = self.active_col }; } return null; } // ========================================================================= // Métodos de edición CRUD Excel-style // ========================================================================= /// Verifica si hay una celda en edición pub fn isEditing(self: *const Self) bool { return self.editing_cell != null; } /// Inicia edición de una celda /// initial_char: si viene de tecla alfanumérica, el caracter inicial (null = mostrar valor actual) pub fn startEditing(self: *Self, cell: CellId, current_value: []const u8, initial_char: ?u8, current_time_ms: u64) void { // Guardar valor original (para Escape) const len = @min(current_value.len, self.original_value.len); @memcpy(self.original_value[0..len], current_value[0..len]); self.original_value_len = len; // Inicializar buffer de edición if (initial_char) |c| { // Tecla alfanumérica: empezar con ese caracter self.edit_buffer[0] = c; self.edit_buffer_len = 1; self.edit_cursor = 1; } else { // Doble-click/Space: mostrar valor actual @memcpy(self.edit_buffer[0..len], current_value[0..len]); self.edit_buffer_len = len; self.edit_cursor = len; } self.editing_cell = cell; self.escape_count = 0; self.last_edit_time_ms = current_time_ms; self.cell_value_changed = false; } /// Obtiene el texto actual del editor pub fn getEditText(self: *const Self) []const u8 { return self.edit_buffer[0..self.edit_buffer_len]; } /// Establece el texto del editor pub fn setEditText(self: *Self, text: []const u8) void { const len = @min(text.len, self.edit_buffer.len); @memcpy(self.edit_buffer[0..len], text[0..len]); self.edit_buffer_len = len; self.edit_cursor = len; } /// Obtiene el valor original (antes de editar) pub fn getOriginalValue(self: *const Self) []const u8 { return self.original_value[0..self.original_value_len]; } /// Verifica si el valor ha cambiado respecto al original pub fn hasValueChanged(self: *const Self) bool { const current = self.getEditText(); const original = self.getOriginalValue(); return !std.mem.eql(u8, current, original); } /// Finaliza edición guardando cambios (retorna true si hubo cambios) pub fn commitEdit(self: *Self) bool { if (self.editing_cell == null) return false; const changed = self.hasValueChanged(); if (changed) { self.row_dirty = true; self.cell_value_changed = true; // Solo actualizar última fila editada si hubo cambios reales self.last_edited_row = self.editing_cell.?.row; } self.editing_cell = null; self.escape_count = 0; return changed; } /// Finaliza edición descartando cambios pub fn cancelEdit(self: *Self) void { self.editing_cell = null; self.escape_count = 0; self.cell_value_changed = false; } /// Revierte el texto de la celda al valor original (Escape 1) pub fn revertCellText(self: *Self) void { const original = self.getOriginalValue(); @memcpy(self.edit_buffer[0..original.len], original); self.edit_buffer_len = original.len; self.edit_cursor = original.len; } /// Maneja la tecla Escape (retorna acción a tomar) pub const EscapeAction = enum { /// Texto revertido, mantener edición reverted, /// Descartar cambios de fila discard_row, /// No estaba editando none, }; pub fn handleEscape(self: *Self) EscapeAction { if (self.editing_cell == null) return .none; self.escape_count += 1; if (self.escape_count == 1) { // Escape 1: Revertir texto a valor original self.revertCellText(); return .reverted; } else { // Escape 2+: Descartar cambios de fila self.cancelEdit(); self.row_dirty = false; return .discard_row; } } /// Verifica si cambió de fila (para auto-save) pub fn isRowChange(self: *const Self, new_row: usize) bool { if (self.last_edited_row) |last| { return last != new_row; } return false; } /// Marca la fila como guardada (limpia dirty flag) pub fn markRowSaved(self: *Self) void { self.row_dirty = false; } /// Resetea el estado de edición completamente pub fn resetEditState(self: *Self) void { self.editing_cell = null; self.escape_count = 0; self.row_dirty = false; self.last_edited_row = null; self.edit_buffer_len = 0; self.edit_cursor = 0; self.cell_value_changed = false; self.row_edit_buffer.clear(); } // ========================================================================= // Geometría de celdas // ========================================================================= /// Calcula la geometría (posición y tamaño) de una celda visible /// Retorna null si la celda no está visible en pantalla pub fn getCellGeometry( self: *const Self, row: usize, col: usize, columns: []const types.ColumnDef, row_height: u32, bounds_x: i32, bounds_y: i32, header_height: u32, filter_bar_height: u32, ) ?CellGeometry { // Verificar si la fila está en la ventana visible if (row < self.scroll_offset) return null; const visible_row = row - self.scroll_offset; // Calcular Y (después de filter bar + header) const content_start_y = bounds_y + @as(i32, @intCast(filter_bar_height)) + @as(i32, @intCast(header_height)); const y = content_start_y + @as(i32, @intCast(visible_row * row_height)); // Verificar columna válida if (col >= columns.len) return null; // Calcular X (sumando anchos de columnas anteriores, menos scroll horizontal) var x: i32 = bounds_x - self.scroll_offset_x; for (columns[0..col]) |c| { x += @as(i32, @intCast(c.width)); } return CellGeometry{ .x = x, .y = y, .w = columns[col].width, .h = row_height, }; } }; // ============================================================================= // Tests // ============================================================================= const testing = std.testing; test "VirtualAdvancedTableState selection" { var state = VirtualAdvancedTableState{}; // Initial state try testing.expectEqual(@as(?i64, null), state.selected_id); try testing.expect(!state.selection_changed); // Select by ID state.selectById(42); try testing.expectEqual(@as(?i64, 42), state.selected_id); try testing.expect(state.selection_changed); // Reset flags state.resetFrameFlags(); try testing.expect(!state.selection_changed); // Select same ID (no change) state.selectById(42); try testing.expect(!state.selection_changed); // Clear selection state.clearSelection(); try testing.expectEqual(@as(?i64, null), state.selected_id); try testing.expect(state.selection_changed); } test "VirtualAdvancedTableState filter" { var state = VirtualAdvancedTableState{}; state.setFilter("test"); try testing.expectEqualStrings("test", state.getFilter()); try testing.expectEqual(@as(usize, 4), state.filter_len); state.clearFilter(); try testing.expectEqualStrings("", state.getFilter()); } test "VirtualAdvancedTableState sort" { var state = VirtualAdvancedTableState{}; // Initial: no sort try testing.expectEqual(@as(?[]const u8, null), state.sort_column); try testing.expectEqual(SortDirection.none, state.sort_direction); // Toggle once: ascending state.toggleSort("name"); try testing.expectEqualStrings("name", state.sort_column.?); try testing.expectEqual(SortDirection.ascending, state.sort_direction); // Toggle again: descending state.toggleSort("name"); try testing.expectEqual(SortDirection.descending, state.sort_direction); // Toggle again: none state.toggleSort("name"); try testing.expectEqual(SortDirection.none, state.sort_direction); try testing.expectEqual(@as(?[]const u8, null), state.sort_column); // Different column: starts ascending state.toggleSort("date"); try testing.expectEqualStrings("date", state.sort_column.?); try testing.expectEqual(SortDirection.ascending, state.sort_direction); } test "VirtualAdvancedTableState window index conversion" { var state = VirtualAdvancedTableState{}; state.window_start = 100; const values = [_][]const u8{"test"}; const rows = [_]RowData{ .{ .id = 1, .values = &values }, .{ .id = 2, .values = &values }, .{ .id = 3, .values = &values }, }; state.current_window = &rows; // In window try testing.expect(state.isInWindow(100)); try testing.expect(state.isInWindow(102)); try testing.expect(!state.isInWindow(99)); try testing.expect(!state.isInWindow(103)); // Conversions try testing.expectEqual(@as(?usize, 0), state.globalToWindowIndex(100)); try testing.expectEqual(@as(?usize, 2), state.globalToWindowIndex(102)); try testing.expectEqual(@as(?usize, null), state.globalToWindowIndex(50)); try testing.expectEqual(@as(usize, 100), state.windowToGlobalIndex(0)); try testing.expectEqual(@as(usize, 102), state.windowToGlobalIndex(2)); }