zcatgui/src/widgets/virtual_list/state.zig
reugenio 1c284ed0f6 fix(virtual_list): Move footer display buffer to state struct
- Added footer_display_buf and footer_display_len to VirtualListState
- Fixes use-after-return bug: stack buffer passed to pushCommand() was
  invalidated when function returned, causing corrupted display in
  deferred rendering
2025-12-25 22:40:14 +01:00

503 lines
18 KiB
Zig

//! 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));
}