feat(virtual_advanced_table): Add CRUD Excel-style editing state

- 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
This commit is contained in:
reugenio 2025-12-26 14:45:22 +01:00
parent 7f8870d890
commit 66816bcbf1
5 changed files with 297 additions and 61 deletions

View file

@ -1,6 +1,6 @@
//! DataProvider - Interface genérica para fuentes de datos
//!
//! Permite que VirtualList trabaje con cualquier fuente de datos
//! Permite que VirtualAdvancedTable trabaje con cualquier fuente de datos
//! (SQLite, arrays, APIs, etc.) mediante un vtable pattern.
//!
//! Ejemplo de implementación:

View file

@ -1,4 +1,4 @@
//! Estado del VirtualList
//! Estado del VirtualAdvancedTable
//!
//! Mantiene el estado de navegación, selección y caché del widget.
@ -7,9 +7,11 @@ const types = @import("types.zig");
const RowData = types.RowData;
const CountInfo = types.CountInfo;
const SortDirection = types.SortDirection;
const CellId = types.CellId;
const CellGeometry = types.CellGeometry;
/// Estado del widget VirtualList
pub const VirtualListState = struct {
/// Estado del widget VirtualAdvancedTable
pub const VirtualAdvancedTableState = struct {
// =========================================================================
// Selección
// =========================================================================
@ -120,6 +122,34 @@ pub const VirtualListState = struct {
footer_display_buf: [96]u8 = undefined,
footer_display_len: usize = 0,
// =========================================================================
// Estado de edición CRUD Excel-style
// =========================================================================
/// Celda actualmente en edición (null = no editando)
editing_cell: ?CellId = null,
/// Valor original de la celda (para Escape revertir)
original_value: [256]u8 = undefined,
original_value_len: usize = 0,
/// Contador de Escapes (1 = revertir celda, 2 = descartar fila)
escape_count: u8 = 0,
/// Fila actual tiene cambios sin guardar en BD
row_dirty: bool = false,
/// Última fila editada (para detectar cambio de fila)
last_edited_row: ?usize = null,
/// Buffer de edición (texto actual en el editor)
edit_buffer: [256]u8 = undefined,
edit_buffer_len: usize = 0,
edit_cursor: usize = 0,
/// Flag: celda requiere commit al terminar edición
cell_value_changed: bool = false,
const Self = @This();
// =========================================================================
@ -403,6 +433,192 @@ pub const VirtualListState = struct {
pub fn goToEndX(self: *Self, max_scroll: i32) void {
self.scroll_offset_x = max_scroll;
}
// =========================================================================
// Métodos de edición CRUD Excel-style
// =========================================================================
/// Verifica si hay una celda en edición
pub fn isEditing(self: *const Self) bool {
return self.editing_cell != null;
}
/// Inicia edición de una celda
/// initial_char: si viene de tecla alfanumérica, el caracter inicial (null = mostrar valor actual)
pub fn startEditing(self: *Self, cell: CellId, current_value: []const u8, initial_char: ?u8) void {
// Guardar valor original (para Escape)
const len = @min(current_value.len, self.original_value.len);
@memcpy(self.original_value[0..len], current_value[0..len]);
self.original_value_len = len;
// Inicializar buffer de edición
if (initial_char) |c| {
// Tecla alfanumérica: empezar con ese caracter
self.edit_buffer[0] = c;
self.edit_buffer_len = 1;
self.edit_cursor = 1;
} else {
// Doble-click/Space: mostrar valor actual
@memcpy(self.edit_buffer[0..len], current_value[0..len]);
self.edit_buffer_len = len;
self.edit_cursor = len;
}
self.editing_cell = cell;
self.escape_count = 0;
self.cell_value_changed = false;
}
/// Obtiene el texto actual del editor
pub fn getEditText(self: *const Self) []const u8 {
return self.edit_buffer[0..self.edit_buffer_len];
}
/// Establece el texto del editor
pub fn setEditText(self: *Self, text: []const u8) void {
const len = @min(text.len, self.edit_buffer.len);
@memcpy(self.edit_buffer[0..len], text[0..len]);
self.edit_buffer_len = len;
self.edit_cursor = len;
}
/// Obtiene el valor original (antes de editar)
pub fn getOriginalValue(self: *const Self) []const u8 {
return self.original_value[0..self.original_value_len];
}
/// Verifica si el valor ha cambiado respecto al original
pub fn hasValueChanged(self: *const Self) bool {
const current = self.getEditText();
const original = self.getOriginalValue();
return !std.mem.eql(u8, current, original);
}
/// Finaliza edición guardando cambios (retorna true si hubo cambios)
pub fn commitEdit(self: *Self) bool {
if (self.editing_cell == null) return false;
const changed = self.hasValueChanged();
if (changed) {
self.row_dirty = true;
self.cell_value_changed = true;
}
// Actualizar última fila editada
self.last_edited_row = self.editing_cell.?.row;
self.editing_cell = null;
self.escape_count = 0;
return changed;
}
/// Finaliza edición descartando cambios
pub fn cancelEdit(self: *Self) void {
self.editing_cell = null;
self.escape_count = 0;
self.cell_value_changed = false;
}
/// Revierte el texto de la celda al valor original (Escape 1)
pub fn revertCellText(self: *Self) void {
const original = self.getOriginalValue();
@memcpy(self.edit_buffer[0..original.len], original);
self.edit_buffer_len = original.len;
self.edit_cursor = original.len;
}
/// Maneja la tecla Escape (retorna acción a tomar)
pub const EscapeAction = enum {
/// Texto revertido, mantener edición
reverted,
/// Descartar cambios de fila
discard_row,
/// No estaba editando
none,
};
pub fn handleEscape(self: *Self) EscapeAction {
if (self.editing_cell == null) return .none;
self.escape_count += 1;
if (self.escape_count == 1) {
// Escape 1: Revertir texto a valor original
self.revertCellText();
return .reverted;
} else {
// Escape 2+: Descartar cambios de fila
self.cancelEdit();
self.row_dirty = false;
return .discard_row;
}
}
/// Verifica si cambió de fila (para auto-save)
pub fn isRowChange(self: *const Self, new_row: usize) bool {
if (self.last_edited_row) |last| {
return last != new_row;
}
return false;
}
/// Marca la fila como guardada (limpia dirty flag)
pub fn markRowSaved(self: *Self) void {
self.row_dirty = false;
}
/// Resetea el estado de edición completamente
pub fn resetEditState(self: *Self) void {
self.editing_cell = null;
self.escape_count = 0;
self.row_dirty = false;
self.last_edited_row = null;
self.edit_buffer_len = 0;
self.edit_cursor = 0;
self.cell_value_changed = false;
}
// =========================================================================
// Geometría de celdas
// =========================================================================
/// Calcula la geometría (posición y tamaño) de una celda visible
/// Retorna null si la celda no está visible en pantalla
pub fn getCellGeometry(
self: *const Self,
row: usize,
col: usize,
columns: []const types.ColumnDef,
row_height: u32,
bounds_x: i32,
bounds_y: i32,
header_height: u32,
filter_bar_height: u32,
) ?CellGeometry {
// Verificar si la fila está en la ventana visible
if (row < self.scroll_offset) return null;
const visible_row = row - self.scroll_offset;
// Calcular Y (después de filter bar + header)
const content_start_y = bounds_y + @as(i32, @intCast(filter_bar_height)) + @as(i32, @intCast(header_height));
const y = content_start_y + @as(i32, @intCast(visible_row * row_height));
// Verificar columna válida
if (col >= columns.len) return null;
// Calcular X (sumando anchos de columnas anteriores, menos scroll horizontal)
var x: i32 = bounds_x - self.scroll_offset_x;
for (columns[0..col]) |c| {
x += @as(i32, @intCast(c.width));
}
return CellGeometry{
.x = x,
.y = y,
.w = columns[col].width,
.h = row_height,
};
}
};
// =============================================================================
@ -411,8 +627,8 @@ pub const VirtualListState = struct {
const testing = std.testing;
test "VirtualListState selection" {
var state = VirtualListState{};
test "VirtualAdvancedTableState selection" {
var state = VirtualAdvancedTableState{};
// Initial state
try testing.expectEqual(@as(?i64, null), state.selected_id);
@ -437,8 +653,8 @@ test "VirtualListState selection" {
try testing.expect(state.selection_changed);
}
test "VirtualListState filter" {
var state = VirtualListState{};
test "VirtualAdvancedTableState filter" {
var state = VirtualAdvancedTableState{};
state.setFilter("test");
try testing.expectEqualStrings("test", state.getFilter());
@ -448,8 +664,8 @@ test "VirtualListState filter" {
try testing.expectEqualStrings("", state.getFilter());
}
test "VirtualListState sort" {
var state = VirtualListState{};
test "VirtualAdvancedTableState sort" {
var state = VirtualAdvancedTableState{};
// Initial: no sort
try testing.expectEqual(@as(?[]const u8, null), state.sort_column);
@ -475,8 +691,8 @@ test "VirtualListState sort" {
try testing.expectEqual(SortDirection.ascending, state.sort_direction);
}
test "VirtualListState window index conversion" {
var state = VirtualListState{};
test "VirtualAdvancedTableState window index conversion" {
var state = VirtualAdvancedTableState{};
state.window_start = 100;
const values = [_][]const u8{"test"};

View file

@ -1,4 +1,4 @@
//! Tipos para VirtualList
//! Tipos para VirtualAdvancedTable
//!
//! Tipos genéricos para listas virtualizadas que trabajan con cualquier
//! fuente de datos (WHO, DOC, etc.)
@ -29,6 +29,24 @@ pub const SortDirection = enum {
}
};
/// Identificador de celda (fila + columna)
pub const CellId = struct {
row: usize,
col: usize,
pub fn eql(self: CellId, other: CellId) bool {
return self.row == other.row and self.col == other.col;
}
};
/// Geometría de una celda (posición y tamaño en pixels)
pub const CellGeometry = struct {
x: i32,
y: i32,
w: u32,
h: u32,
};
/// Datos genéricos de una fila
/// El DataProvider convierte sus datos específicos a este formato
pub const RowData = struct {
@ -152,11 +170,11 @@ pub const FilterBarConfig = struct {
};
// =============================================================================
// VirtualListConfig
// VirtualAdvancedTableConfig
// =============================================================================
/// Configuración del VirtualList
pub const VirtualListConfig = struct {
/// Configuración del VirtualAdvancedTable
pub const VirtualAdvancedTableConfig = struct {
/// Altura de cada fila en pixels
row_height: u16 = 24,

View file

@ -1,11 +1,11 @@
//! VirtualList - Widget de lista virtualizada
//! VirtualAdvancedTable - 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).
//!
//! ## Uso
//! ```zig
//! const result = virtualList(ctx, rect, &state, provider, .{
//! const result = virtualAdvancedTable(ctx, rect, &state, provider, .{
//! .columns = &columns,
//! .virtualization_threshold = 500,
//! });
@ -31,16 +31,18 @@ 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 VirtualAdvancedTableConfig = types.VirtualAdvancedTableConfig;
pub const FilterBarConfig = types.FilterBarConfig;
pub const FilterChipDef = types.FilterChipDef;
pub const ChipSelectMode = types.ChipSelectMode;
pub const CellId = types.CellId;
pub const CellGeometry = types.CellGeometry;
pub const DataProvider = data_provider.DataProvider;
pub const VirtualListState = state_mod.VirtualListState;
pub const VirtualAdvancedTableState = state_mod.VirtualAdvancedTableState;
/// Resultado de renderizar el VirtualList
pub const VirtualListResult = struct {
/// Resultado de renderizar el VirtualAdvancedTable
pub const VirtualAdvancedTableResult = struct {
/// La selección cambió este frame
selection_changed: bool = false,
@ -81,26 +83,26 @@ pub const VirtualListResult = struct {
// Widget principal
// =============================================================================
/// Renderiza un VirtualList
pub fn virtualList(
/// Renderiza un VirtualAdvancedTable
pub fn virtualAdvancedTable(
ctx: *Context,
list_state: *VirtualListState,
list_state: *VirtualAdvancedTableState,
provider: DataProvider,
config: VirtualListConfig,
) VirtualListResult {
config: VirtualAdvancedTableConfig,
) VirtualAdvancedTableResult {
const bounds = ctx.layout.nextRect();
return virtualListRect(ctx, bounds, list_state, provider, config);
return virtualAdvancedTableRect(ctx, bounds, list_state, provider, config);
}
/// Renderiza un VirtualList en un rectángulo específico
pub fn virtualListRect(
/// Renderiza un VirtualAdvancedTable en un rectángulo específico
pub fn virtualAdvancedTableRect(
ctx: *Context,
bounds: Layout.Rect,
list_state: *VirtualListState,
list_state: *VirtualAdvancedTableState,
provider: DataProvider,
config: VirtualListConfig,
) VirtualListResult {
var result = VirtualListResult{};
config: VirtualAdvancedTableConfig,
) VirtualAdvancedTableResult {
var result = VirtualAdvancedTableResult{};
if (bounds.isEmpty() or config.columns.len == 0) return result;
@ -108,7 +110,7 @@ pub fn virtualListRect(
list_state.resetFrameFlags();
// Get colors
const colors = config.colors orelse VirtualListConfig.Colors{};
const colors = config.colors orelse VirtualAdvancedTableConfig.Colors{};
// Generate unique ID for focus system
const widget_id: u64 = @intFromPtr(list_state);
@ -284,7 +286,7 @@ pub fn virtualListRect(
// Helper: Check if refetch needed
// =============================================================================
fn needsRefetch(list_state: *const VirtualListState, visible_rows: usize, buffer_size: usize) bool {
fn needsRefetch(list_state: *const VirtualAdvancedTableState, visible_rows: usize, buffer_size: usize) bool {
// First load
if (list_state.current_window.len == 0) return true;
@ -309,9 +311,9 @@ fn drawFilterBar(
ctx: *Context,
bounds: Layout.Rect,
config: FilterBarConfig,
colors: *const VirtualListConfig.Colors,
list_state: *VirtualListState,
result: *VirtualListResult,
colors: *const VirtualAdvancedTableConfig.Colors,
list_state: *VirtualAdvancedTableState,
result: *VirtualAdvancedTableResult,
) void {
const padding: i32 = 6;
const chip_h: u32 = 22;
@ -541,10 +543,10 @@ fn drawHeaderAt(
ctx: *Context,
bounds: Layout.Rect,
header_y: i32,
config: VirtualListConfig,
colors: *const VirtualListConfig.Colors,
list_state: *VirtualListState,
result: *VirtualListResult,
config: VirtualAdvancedTableConfig,
colors: *const VirtualAdvancedTableConfig.Colors,
list_state: *VirtualAdvancedTableState,
result: *VirtualAdvancedTableResult,
scroll_offset_x: i32,
) void {
const header_h = config.row_height;
@ -621,11 +623,11 @@ fn drawHeaderAt(
fn drawRows(
ctx: *Context,
content_bounds: Layout.Rect,
config: VirtualListConfig,
colors: *const VirtualListConfig.Colors,
list_state: *VirtualListState,
config: VirtualAdvancedTableConfig,
colors: *const VirtualAdvancedTableConfig.Colors,
list_state: *VirtualAdvancedTableState,
visible_rows: usize,
result: *VirtualListResult,
result: *VirtualAdvancedTableResult,
scroll_offset_x: i32,
) void {
_ = result;
@ -698,8 +700,8 @@ fn drawRows(
fn drawFooter(
ctx: *Context,
bounds: Layout.Rect,
colors: *const VirtualListConfig.Colors,
list_state: *VirtualListState,
colors: *const VirtualAdvancedTableConfig.Colors,
list_state: *VirtualAdvancedTableState,
) void {
// Background
ctx.pushCommand(Command.rect(
@ -748,10 +750,10 @@ fn drawScrollbar(
bounds: Layout.Rect,
header_h: u32,
footer_h: u32,
list_state: *VirtualListState,
list_state: *VirtualAdvancedTableState,
visible_rows: usize,
total_rows: usize,
colors: *const VirtualListConfig.Colors,
colors: *const VirtualAdvancedTableConfig.Colors,
) void {
const scrollbar_w: u32 = 12;
const content_h = bounds.h -| header_h -| footer_h;
@ -785,7 +787,7 @@ fn drawScrollbarH(
scroll_offset_x: i32,
max_scroll_x: i32,
available_width: u32,
colors: *const VirtualListConfig.Colors,
colors: *const VirtualAdvancedTableConfig.Colors,
) void {
const scrollbar_v_w: u32 = 12; // Width of vertical scrollbar area
@ -817,12 +819,12 @@ fn drawScrollbarH(
fn handleKeyboard(
ctx: *Context,
list_state: *VirtualListState,
list_state: *VirtualAdvancedTableState,
provider: DataProvider,
visible_rows: usize,
total_rows: usize,
max_scroll_x: i32,
result: *VirtualListResult,
result: *VirtualAdvancedTableResult,
) void {
_ = provider;
_ = result;
@ -860,9 +862,9 @@ fn handleMouseClick(
bounds: Layout.Rect,
filter_bar_h: u32,
header_h: u32,
config: VirtualListConfig,
list_state: *VirtualListState,
result: *VirtualListResult,
config: VirtualAdvancedTableConfig,
list_state: *VirtualAdvancedTableState,
result: *VirtualAdvancedTableResult,
) void {
_ = result;
@ -892,15 +894,15 @@ fn handleMouseClick(
// Tests
// =============================================================================
test "virtual_list module imports" {
test "virtual_advanced_table module imports" {
_ = types;
_ = data_provider;
_ = state_mod;
_ = RowData;
_ = ColumnDef;
_ = DataProvider;
_ = VirtualListState;
_ = VirtualListResult;
_ = VirtualAdvancedTableState;
_ = VirtualAdvancedTableResult;
}
test {

View file

@ -41,7 +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");
pub const virtual_advanced_table = @import("virtual_advanced_table/virtual_advanced_table.zig");
// Gio parity widgets (Phase 1)
pub const switch_widget = @import("switch.zig");