diff --git a/src/widgets/virtual_list/data_provider.zig b/src/widgets/virtual_list/data_provider.zig new file mode 100644 index 0000000..9966b4a --- /dev/null +++ b/src/widgets/virtual_list/data_provider.zig @@ -0,0 +1,296 @@ +//! DataProvider - Interface genérica para fuentes de datos +//! +//! Permite que VirtualList trabaje con cualquier fuente de datos +//! (SQLite, arrays, APIs, etc.) mediante un vtable pattern. +//! +//! Ejemplo de implementación: +//! ```zig +//! const WhoDataProvider = struct { +//! dm: *DataManager, +//! +//! pub fn toDataProvider(self: *WhoDataProvider) DataProvider { +//! return DataProvider.init(self, &vtable); +//! } +//! +//! const vtable = DataProvider.VTable{ +//! .fetchWindow = fetchWindow, +//! .getTotalCount = getTotalCount, +//! // ... +//! }; +//! +//! fn fetchWindow(ptr: *anyopaque, offset: usize, limit: usize) ![]const RowData { +//! const self: *WhoDataProvider = @ptrCast(@alignCast(ptr)); +//! // Query BD con LIMIT/OFFSET +//! } +//! }; +//! ``` + +const std = @import("std"); +const types = @import("types.zig"); +const RowData = types.RowData; +const CountInfo = types.CountInfo; +const SortDirection = types.SortDirection; + +/// Interface genérica para proveedores de datos +pub const DataProvider = struct { + ptr: *anyopaque, + vtable: *const VTable, + + pub const VTable = struct { + /// Obtiene una ventana de datos + /// Los datos retornados son válidos hasta el próximo fetchWindow + /// @param offset: índice del primer registro + /// @param limit: número máximo de registros a retornar + /// @return slice de RowData (propiedad del provider, no liberar) + fetchWindow: *const fn ( + ptr: *anyopaque, + offset: usize, + limit: usize, + ) error{OutOfMemory}![]const RowData, + + /// Obtiene información del conteo total + /// Puede retornar estado parcial mientras calcula + getTotalCount: *const fn (ptr: *anyopaque) CountInfo, + + /// Obtiene información del conteo filtrado + /// Puede retornar estado parcial mientras calcula + getFilteredCount: *const fn (ptr: *anyopaque) CountInfo, + + /// Aplica filtro de búsqueda + /// @param filter: texto a buscar (vacío = sin filtro) + setFilter: *const fn (ptr: *anyopaque, filter: []const u8) void, + + /// Aplica ordenación + /// @param column: nombre de columna (de ColumnDef.name) + /// @param direction: dirección de ordenación + setSort: *const fn ( + ptr: *anyopaque, + column: []const u8, + direction: SortDirection, + ) void, + + /// Obtiene el ID del registro en un índice global + /// Útil para mantener selección después de scroll + /// @param index: índice global (no offset de ventana) + /// @return ID del registro o null si fuera de rango + getRowId: *const fn (ptr: *anyopaque, index: usize) ?i64, + + /// Procesa tareas en background (conteos diferidos, etc.) + /// Llamar desde el main loop cuando hay tiempo idle + /// @return true si hay más trabajo pendiente + processBackgroundTasks: *const fn (ptr: *anyopaque) bool, + + /// Invalida caché y fuerza recarga + invalidate: *const fn (ptr: *anyopaque) void, + + /// Libera recursos del provider + deinit: *const fn (ptr: *anyopaque) void, + }; + + /// Crea un DataProvider desde una implementación concreta + pub fn init(ptr: anytype, vtable: *const VTable) DataProvider { + const Ptr = @TypeOf(ptr); + const ptr_info = @typeInfo(Ptr); + + if (ptr_info != .pointer) { + @compileError("ptr must be a pointer"); + } + + return .{ + .ptr = @ptrCast(ptr), + .vtable = vtable, + }; + } + + // ========================================================================= + // Métodos wrapper para facilitar uso + // ========================================================================= + + /// Obtiene una ventana de datos + pub fn fetchWindow(self: DataProvider, offset: usize, limit: usize) ![]const RowData { + return self.vtable.fetchWindow(self.ptr, offset, limit); + } + + /// Obtiene información del conteo total + pub fn getTotalCount(self: DataProvider) CountInfo { + return self.vtable.getTotalCount(self.ptr); + } + + /// Obtiene información del conteo filtrado + pub fn getFilteredCount(self: DataProvider) CountInfo { + return self.vtable.getFilteredCount(self.ptr); + } + + /// Aplica filtro de búsqueda + pub fn setFilter(self: DataProvider, filter: []const u8) void { + self.vtable.setFilter(self.ptr, filter); + } + + /// Aplica ordenación + pub fn setSort(self: DataProvider, column: []const u8, direction: SortDirection) void { + self.vtable.setSort(self.ptr, column, direction); + } + + /// Obtiene el ID del registro en un índice + pub fn getRowId(self: DataProvider, index: usize) ?i64 { + return self.vtable.getRowId(self.ptr, index); + } + + /// Procesa tareas en background + pub fn processBackgroundTasks(self: DataProvider) bool { + return self.vtable.processBackgroundTasks(self.ptr); + } + + /// Invalida caché + pub fn invalidate(self: DataProvider) void { + self.vtable.invalidate(self.ptr); + } + + /// Libera recursos + pub fn deinit(self: DataProvider) void { + self.vtable.deinit(self.ptr); + } +}; + +// ============================================================================= +// Tests +// ============================================================================= + +const testing = std.testing; + +/// Provider de prueba para tests +const MockProvider = struct { + data: []const RowData, + total: usize, + filter_applied: bool = false, + sort_column: ?[]const u8 = null, + invalidated: bool = false, + + pub fn toDataProvider(self: *MockProvider) DataProvider { + return DataProvider.init(self, &vtable); + } + + const vtable = DataProvider.VTable{ + .fetchWindow = fetchWindow, + .getTotalCount = getTotalCount, + .getFilteredCount = getFilteredCount, + .setFilter = setFilter, + .setSort = setSort, + .getRowId = getRowId, + .processBackgroundTasks = processBackgroundTasks, + .invalidate = invalidate, + .deinit = deinitFn, + }; + + fn fetchWindow(ptr: *anyopaque, offset: usize, limit: usize) ![]const RowData { + const self: *MockProvider = @ptrCast(@alignCast(ptr)); + const end = @min(offset + limit, self.data.len); + if (offset >= self.data.len) return &.{}; + return self.data[offset..end]; + } + + fn getTotalCount(ptr: *anyopaque) CountInfo { + const self: *MockProvider = @ptrCast(@alignCast(ptr)); + return .{ .value = self.total, .state = .ready }; + } + + fn getFilteredCount(ptr: *anyopaque) CountInfo { + const self: *MockProvider = @ptrCast(@alignCast(ptr)); + return .{ .value = self.data.len, .state = .ready }; + } + + fn setFilter(ptr: *anyopaque, _: []const u8) void { + const self: *MockProvider = @ptrCast(@alignCast(ptr)); + self.filter_applied = true; + } + + fn setSort(ptr: *anyopaque, column: []const u8, _: SortDirection) void { + const self: *MockProvider = @ptrCast(@alignCast(ptr)); + self.sort_column = column; + } + + fn getRowId(ptr: *anyopaque, index: usize) ?i64 { + const self: *MockProvider = @ptrCast(@alignCast(ptr)); + if (index < self.data.len) return self.data[index].id; + return null; + } + + fn processBackgroundTasks(_: *anyopaque) bool { + return false; + } + + fn invalidate(ptr: *anyopaque) void { + const self: *MockProvider = @ptrCast(@alignCast(ptr)); + self.invalidated = true; + } + + fn deinitFn(_: *anyopaque) void {} +}; + +test "DataProvider basic operations" { + const values1 = [_][]const u8{ "1", "Alice" }; + const values2 = [_][]const u8{ "2", "Bob" }; + const values3 = [_][]const u8{ "3", "Charlie" }; + + const rows = [_]RowData{ + .{ .id = 1, .values = &values1 }, + .{ .id = 2, .values = &values2 }, + .{ .id = 3, .values = &values3 }, + }; + + var mock = MockProvider{ + .data = &rows, + .total = 100, + }; + + const provider = mock.toDataProvider(); + + // Test fetchWindow + const window = try provider.fetchWindow(0, 2); + try testing.expectEqual(@as(usize, 2), window.len); + try testing.expectEqual(@as(i64, 1), window[0].id); + try testing.expectEqual(@as(i64, 2), window[1].id); + + // Test counts + const total = provider.getTotalCount(); + try testing.expectEqual(@as(usize, 100), total.value); + try testing.expectEqual(types.LoadState.ready, total.state); + + // Test getRowId + try testing.expectEqual(@as(?i64, 2), provider.getRowId(1)); + try testing.expectEqual(@as(?i64, null), provider.getRowId(100)); + + // Test setFilter + provider.setFilter("test"); + try testing.expect(mock.filter_applied); + + // Test setSort + provider.setSort("name", .ascending); + try testing.expectEqualStrings("name", mock.sort_column.?); + + // Test invalidate + provider.invalidate(); + try testing.expect(mock.invalidated); +} + +test "DataProvider fetchWindow bounds" { + const values1 = [_][]const u8{"1"}; + const rows = [_]RowData{ + .{ .id = 1, .values = &values1 }, + }; + + var mock = MockProvider{ + .data = &rows, + .total = 1, + }; + + const provider = mock.toDataProvider(); + + // Offset beyond data + const empty = try provider.fetchWindow(100, 10); + try testing.expectEqual(@as(usize, 0), empty.len); + + // Limit beyond data + const partial = try provider.fetchWindow(0, 100); + try testing.expectEqual(@as(usize, 1), partial.len); +} diff --git a/src/widgets/virtual_list/state.zig b/src/widgets/virtual_list/state.zig new file mode 100644 index 0000000..87129d3 --- /dev/null +++ b/src/widgets/virtual_list/state.zig @@ -0,0 +1,362 @@ +//! 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 (en filas, no pixels) + scroll_offset: usize = 0, + + /// Offset del scroll en pixels (para smooth scroll) + scroll_offset_pixels: 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, + + // ========================================================================= + // 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, + + 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.frame_count +%= 1; + } + + // ========================================================================= + // Navegación + // ========================================================================= + + /// Mueve la selección hacia arriba + pub fn moveUp(self: *Self) void { + if (self.findSelectedInWindow()) |window_idx| { + if (window_idx > 0) { + self.selectByWindowIndex(window_idx - 1); + } else if (self.window_start > 0) { + // Necesita scroll up - el widget debe manejar esto + self.scroll_offset = if (self.scroll_offset > 0) self.scroll_offset - 1 else 0; + } + } else if (self.current_window.len > 0) { + // Sin selección: seleccionar primera fila visible + self.selectByWindowIndex(0); + } + } + + /// 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); + + // Si la nueva selección está cerca del final visible, scroll + if (window_idx + 1 >= visible_rows - 1) { + 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); + } + } +}; + +// ============================================================================= +// 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)); +} diff --git a/src/widgets/virtual_list/types.zig b/src/widgets/virtual_list/types.zig new file mode 100644 index 0000000..ba57cc5 --- /dev/null +++ b/src/widgets/virtual_list/types.zig @@ -0,0 +1,164 @@ +//! Tipos para VirtualList +//! +//! Tipos genéricos para listas virtualizadas que trabajan con cualquier +//! fuente de datos (WHO, DOC, etc.) + +const std = @import("std"); + +/// Dirección de ordenación +pub const SortDirection = enum { + none, + ascending, + descending, + + pub fn toggle(self: SortDirection) SortDirection { + return switch (self) { + .none => .ascending, + .ascending => .descending, + .descending => .none, + }; + } + + pub fn symbol(self: SortDirection) []const u8 { + return switch (self) { + .none => "", + .ascending => " ^", + .descending => " v", + }; + } +}; + +/// Datos genéricos de una fila +/// El DataProvider convierte sus datos específicos a este formato +pub const RowData = struct { + /// ID único del registro (para selección persistente) + /// Usado porque el índice cambia al scroll/ordenar/filtrar + id: i64, + + /// Valores de columnas como strings para renderizado + /// El índice corresponde a las columnas definidas en config + values: []const []const u8, + + /// Libera los valores (si fueron alocados) + pub fn deinit(self: *RowData, allocator: std.mem.Allocator) void { + allocator.free(self.values); + } +}; + +/// Definición de una columna +pub const ColumnDef = struct { + /// Nombre interno (para ordenación) + name: []const u8, + + /// Título visible en header + title: []const u8, + + /// Ancho en pixels + width: u16 = 100, + + /// Alineación del contenido + alignment: Alignment = .left, + + /// Puede ordenarse por esta columna + sortable: bool = true, + + pub const Alignment = enum { left, center, right }; +}; + +/// Estado de carga para contadores +pub const LoadState = enum { + /// No se ha iniciado la carga + unknown, + + /// Carga en progreso (mostrar "...") + loading, + + /// Carga parcial (mostrar "500+...") + partial, + + /// Carga completa (mostrar número final) + ready, +}; + +/// Información de conteo con estado +pub const CountInfo = struct { + /// Valor actual (puede ser parcial) + value: usize = 0, + + /// Estado de la carga + state: LoadState = .unknown, + + /// Formato para mostrar según estado + pub fn format(self: CountInfo, buf: []u8) []const u8 { + return switch (self.state) { + .unknown, .loading => "...", + .partial => std.fmt.bufPrint(buf, "{d}+...", .{self.value}) catch "...", + .ready => std.fmt.bufPrint(buf, "{d}", .{self.value}) catch "?", + }; + } +}; + +/// Configuración del VirtualList +pub const VirtualListConfig = struct { + /// Altura de cada fila en pixels + row_height: u16 = 24, + + /// Multiplicador del buffer (visible_rows × buffer_multiplier) + /// Ejemplo: 5 significa cargar 5x las filas visibles + buffer_multiplier: u8 = 5, + + /// Umbral para activar virtualización + /// Si total_count <= umbral: carga todo en memoria (simple) + /// Si total_count > umbral: usa ventana virtual (escalable) + virtualization_threshold: usize = 500, + + /// Mostrar contador "X de Y" + show_count: bool = true, + + /// Mostrar scrollbar + show_scrollbar: bool = true, + + /// Columnas a mostrar + columns: []const ColumnDef = &.{}, + + /// Colores personalizados (opcional) + colors: ?Colors = null, + + pub const Colors = struct { + background: u32 = 0xFFFFFFFF, + header_background: u32 = 0xFFE0E0E0, + row_normal: u32 = 0xFFFFFFFF, + row_alternate: u32 = 0xFFF8F8F8, + row_selected: u32 = 0xFF0078D4, + row_selected_unfocus: u32 = 0xFFD0D0D0, + text: u32 = 0xFF000000, + text_selected: u32 = 0xFFFFFFFF, + border: u32 = 0xFFCCCCCC, + }; +}; + +test "SortDirection toggle" { + const std_testing = @import("std").testing; + + var dir = SortDirection.none; + dir = dir.toggle(); + try std_testing.expectEqual(SortDirection.ascending, dir); + dir = dir.toggle(); + try std_testing.expectEqual(SortDirection.descending, dir); + dir = dir.toggle(); + try std_testing.expectEqual(SortDirection.none, dir); +} + +test "CountInfo format" { + const std_testing = @import("std").testing; + var buf: [32]u8 = undefined; + + var info = CountInfo{ .state = .loading }; + try std_testing.expectEqualStrings("...", info.format(&buf)); + + info = CountInfo{ .value = 500, .state = .partial }; + try std_testing.expectEqualStrings("500+...", info.format(&buf)); + + info = CountInfo{ .value = 1234, .state = .ready }; + try std_testing.expectEqualStrings("1234", info.format(&buf)); +} diff --git a/src/widgets/virtual_list/virtual_list.zig b/src/widgets/virtual_list/virtual_list.zig new file mode 100644 index 0000000..bd76954 --- /dev/null +++ b/src/widgets/virtual_list/virtual_list.zig @@ -0,0 +1,137 @@ +//! VirtualList - Widget de lista virtualizada +//! +//! Lista escalable que solo carga en memoria los registros visibles + buffer. +//! Diseñada para trabajar con bases de datos grandes (100k+ registros). +//! +//! ## Características +//! - Carga solo los registros necesarios (ventana virtual) +//! - Selección por ID (persistente al scroll/ordenar/filtrar) +//! - Contador diferido "15 de 500+..." → "15 de 1,234" +//! - Ordenación y filtrado delegados al DataProvider +//! - Umbral configurable para activar virtualización +//! +//! ## Uso +//! ```zig +//! // 1. Crear DataProvider (implementación específica) +//! var who_provider = WhoDataProvider.init(data_manager); +//! const provider = who_provider.toDataProvider(); +//! +//! // 2. Configurar columnas +//! const columns = [_]ColumnDef{ +//! .{ .name = "codigo", .title = "Código", .width = 80 }, +//! .{ .name = "nombre", .title = "Nombre", .width = 200 }, +//! }; +//! +//! // 3. Crear estado +//! var state = VirtualListState{}; +//! +//! // 4. Renderizar +//! const result = virtualList(ctx, rect, &state, provider, .{ +//! .columns = &columns, +//! .virtualization_threshold = 500, +//! }); +//! +//! if (result.selection_changed) { +//! // Handle new selection +//! } +//! ``` +//! +//! ## Arquitectura +//! ``` +//! ┌─────────────────────────────────────────┐ +//! │ VirtualListWidget │ +//! │ - Renderiza filas visibles │ +//! │ - Gestiona scroll virtual │ +//! │ - Muestra contador "X de Y" │ +//! └─────────────────────────────────────────┘ +//! │ +//! ▼ +//! ┌─────────────────────────────────────────┐ +//! │ DataProvider (vtable) │ +//! │ - fetchWindow(offset, limit) │ +//! │ - getTotalCount() → CountInfo │ +//! │ - setFilter(), setSort() │ +//! └─────────────────────────────────────────┘ +//! │ +//! ▼ +//! ┌─────────────────────────────────────────┐ +//! │ Implementación específica │ +//! │ (WhoDataProvider, DocDataProvider) │ +//! │ - Queries SQLite LIMIT/OFFSET │ +//! │ - Arena allocator para ventana │ +//! └─────────────────────────────────────────┘ +//! ``` + +const std = @import("std"); + +// Re-exports públicos +pub const types = @import("types.zig"); +pub const data_provider = @import("data_provider.zig"); +pub const state = @import("state.zig"); + +// Tipos principales +pub const RowData = types.RowData; +pub const ColumnDef = types.ColumnDef; +pub const SortDirection = types.SortDirection; +pub const LoadState = types.LoadState; +pub const CountInfo = types.CountInfo; +pub const VirtualListConfig = types.VirtualListConfig; + +pub const DataProvider = data_provider.DataProvider; +pub const VirtualListState = state.VirtualListState; + +/// Resultado de renderizar el VirtualList +pub const VirtualListResult = struct { + /// La selección cambió este frame + selection_changed: bool = false, + + /// ID del registro seleccionado + selected_id: ?i64 = null, + + /// Hubo doble click en un registro + double_clicked: bool = false, + + /// ID del registro donde hubo doble click + double_click_id: ?i64 = null, + + /// El usuario solicitó ordenar por una columna + sort_requested: bool = false, + sort_column: ?[]const u8 = null, + + /// El filtro cambió + filter_changed: bool = false, +}; + +// TODO: Fase 3 - Implementar función principal del widget +// pub fn virtualList( +// ctx: *Context, +// rect: Rect, +// list_state: *VirtualListState, +// provider: DataProvider, +// config: VirtualListConfig, +// ) VirtualListResult { ... } + +// ============================================================================= +// Tests +// ============================================================================= + +test "virtual_list module imports" { + // Verificar que todos los módulos se importan correctamente + _ = types; + _ = data_provider; + _ = state; + + // Verificar tipos principales + _ = RowData; + _ = ColumnDef; + _ = DataProvider; + _ = VirtualListState; + _ = VirtualListResult; +} + +test { + // Ejecutar tests de submódulos + _ = @import("types.zig"); + _ = @import("data_provider.zig"); + _ = @import("state.zig"); +} diff --git a/src/widgets/widgets.zig b/src/widgets/widgets.zig index 7ec61af..d39c93b 100644 --- a/src/widgets/widgets.zig +++ b/src/widgets/widgets.zig @@ -41,6 +41,7 @@ pub const canvas = @import("canvas.zig"); pub const chart = @import("chart.zig"); pub const icon = @import("icon.zig"); pub const virtual_scroll = @import("virtual_scroll.zig"); +pub const virtual_list = @import("virtual_list/virtual_list.zig"); // Gio parity widgets (Phase 1) pub const switch_widget = @import("switch.zig");