feat(virtual_list): Fase 2 - DataProvider interface + tipos + state
Nuevo módulo virtual_list para listas virtualizadas: types.zig: - RowData: datos genéricos de fila con ID persistente - ColumnDef: definición de columnas (name, title, width) - SortDirection: enum con toggle() - CountInfo: conteo con estado (loading, partial, ready) - VirtualListConfig: configuración con umbral configurable data_provider.zig: - DataProvider: interface vtable para fuentes de datos - Métodos: fetchWindow, getTotalCount, setFilter, setSort - MockProvider para tests - Tests completos state.zig: - VirtualListState: estado de navegación y selección - Selección por ID (no índice) - Conversión global ↔ window index - Navegación: moveUp/Down, pageUp/Down, goToStart/End - Tests completos virtual_list.zig: - Re-exports de módulos - Documentación de arquitectura - VirtualListResult struct - TODO: widget principal (Fase 3) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
59d102315d
commit
7d1919969f
5 changed files with 960 additions and 0 deletions
296
src/widgets/virtual_list/data_provider.zig
Normal file
296
src/widgets/virtual_list/data_provider.zig
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
362
src/widgets/virtual_list/state.zig
Normal file
362
src/widgets/virtual_list/state.zig
Normal file
|
|
@ -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));
|
||||||
|
}
|
||||||
164
src/widgets/virtual_list/types.zig
Normal file
164
src/widgets/virtual_list/types.zig
Normal file
|
|
@ -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));
|
||||||
|
}
|
||||||
137
src/widgets/virtual_list/virtual_list.zig
Normal file
137
src/widgets/virtual_list/virtual_list.zig
Normal file
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
@ -41,6 +41,7 @@ pub const canvas = @import("canvas.zig");
|
||||||
pub const chart = @import("chart.zig");
|
pub const chart = @import("chart.zig");
|
||||||
pub const icon = @import("icon.zig");
|
pub const icon = @import("icon.zig");
|
||||||
pub const virtual_scroll = @import("virtual_scroll.zig");
|
pub const virtual_scroll = @import("virtual_scroll.zig");
|
||||||
|
pub const virtual_list = @import("virtual_list/virtual_list.zig");
|
||||||
|
|
||||||
// Gio parity widgets (Phase 1)
|
// Gio parity widgets (Phase 1)
|
||||||
pub const switch_widget = @import("switch.zig");
|
pub const switch_widget = @import("switch.zig");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue