//! Estado del VirtualList //! //! Mantiene el estado de navegación, selección y caché del widget. const std = @import("std"); const types = @import("types.zig"); const RowData = types.RowData; const CountInfo = types.CountInfo; const SortDirection = types.SortDirection; /// Estado del widget VirtualList pub const VirtualListState = 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, // ========================================================================= // 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, // ========================================================================= // 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, 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); } } // ========================================================================= // 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; } }; // ============================================================================= // Tests // ============================================================================= const testing = std.testing; test "VirtualListState selection" { var state = VirtualListState{}; // 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 "VirtualListState filter" { var state = VirtualListState{}; 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 "VirtualListState sort" { var state = VirtualListState{}; // 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 "VirtualListState window index conversion" { var state = VirtualListState{}; 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)); }