- Rename VirtualList → VirtualAdvancedTable - Add CellId and CellGeometry types - Add editing state fields (editing_cell, original_value, escape_count, etc.) - Add editing methods: startEditing, commitEdit, cancelEdit, handleEscape - Add getCellGeometry() for overlay positioning - Add row_dirty flag for change tracking
296 lines
9.7 KiB
Zig
296 lines
9.7 KiB
Zig
//! DataProvider - Interface genérica para fuentes de datos
|
|
//!
|
|
//! Permite que VirtualAdvancedTable 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);
|
|
}
|