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:
R.Eugenio 2025-12-23 12:27:23 +01:00
parent 59d102315d
commit 7d1919969f
5 changed files with 960 additions and 0 deletions

View 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);
}

View 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));
}

View 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));
}

View 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");
}

View file

@ -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");