From 681991906061d145c6da902a95e23df6a76725d0 Mon Sep 17 00:00:00 2001 From: reugenio Date: Sat, 27 Dec 2025 16:11:16 +0100 Subject: [PATCH] refactor(table_core): Add CellEditState + NavigationState for composition - Delete obsolete 'table_core (conflicted).zig' - Add memory ownership protocol documentation - Add CellEditState: embeddable edit state with buffer, cursor, escape handling - Add NavigationState: embeddable nav state with active_col, scroll, Tab methods - Maintains backward compatibility with existing EditState --- src/widgets/table_core (conflicted).zig | 422 ------------------------ src/widgets/table_core.zig | 202 +++++++++++- 2 files changed, 201 insertions(+), 423 deletions(-) delete mode 100644 src/widgets/table_core (conflicted).zig diff --git a/src/widgets/table_core (conflicted).zig b/src/widgets/table_core (conflicted).zig deleted file mode 100644 index cfd291f..0000000 --- a/src/widgets/table_core (conflicted).zig +++ /dev/null @@ -1,422 +0,0 @@ -//! Table Core - Funciones compartidas para renderizado de tablas -//! -//! Este módulo contiene la lógica común de renderizado utilizada por: -//! - AdvancedTable (datos en memoria) -//! - VirtualAdvancedTable (datos paginados desde DataProvider) -//! -//! Principio: Una sola implementación de UI, dos estrategias de datos. - -const std = @import("std"); -const Context = @import("../core/context.zig").Context; -const Command = @import("../core/command.zig"); -const Layout = @import("../core/layout.zig"); -const Style = @import("../core/style.zig"); - -// ============================================================================= -// Tipos comunes -// ============================================================================= - -/// Colores para renderizado de tabla -pub const TableColors = struct { - // Fondos - background: Style.Color = Style.Color.rgb(30, 30, 35), - row_normal: Style.Color = Style.Color.rgb(35, 35, 40), - row_alternate: Style.Color = Style.Color.rgb(40, 40, 45), - row_hover: Style.Color = Style.Color.rgb(50, 50, 60), - selected_row: Style.Color = Style.Color.rgb(0, 90, 180), - selected_row_unfocus: Style.Color = Style.Color.rgb(60, 60, 70), - - // Celda activa - selected_cell: Style.Color = Style.Color.rgb(100, 150, 255), - selected_cell_unfocus: Style.Color = Style.Color.rgb(80, 80, 90), - - // Edición - cell_editing_bg: Style.Color = Style.Color.rgb(255, 255, 255), - cell_editing_border: Style.Color = Style.Color.rgb(0, 120, 215), - cell_editing_text: Style.Color = Style.Color.rgb(0, 0, 0), - - // Header - header_bg: Style.Color = Style.Color.rgb(45, 45, 50), - header_fg: Style.Color = Style.Color.rgb(200, 200, 200), - - // Texto - text_normal: Style.Color = Style.Color.rgb(220, 220, 220), - text_selected: Style.Color = Style.Color.rgb(255, 255, 255), - text_placeholder: Style.Color = Style.Color.rgb(128, 128, 128), - - // Bordes - border: Style.Color = Style.Color.rgb(60, 60, 65), - focus_ring: Style.Color = Style.Color.rgb(0, 120, 215), -}; - -/// Información de una celda para renderizado -pub const CellRenderInfo = struct { - /// Texto a mostrar - text: []const u8, - /// Posición X de la celda - x: i32, - /// Ancho de la celda - width: u32, - /// Es la celda actualmente seleccionada - is_selected: bool = false, - /// Es editable - is_editable: bool = true, - /// Alineación del texto (0=left, 1=center, 2=right) - text_align: u2 = 0, -}; - -/// Estado de edición para renderizado -pub const EditState = struct { - /// Está en modo edición - editing: bool = false, - /// Fila en edición - edit_row: i32 = -1, - /// Columna en edición - edit_col: i32 = -1, - /// Buffer de texto actual - edit_text: []const u8 = "", - /// Posición del cursor - edit_cursor: usize = 0, -}; - -/// Estado de doble-click -pub const DoubleClickState = struct { - last_click_time: u64 = 0, - last_click_row: i32 = -1, - last_click_col: i32 = -1, - threshold_ms: u64 = 400, -}; - -/// Resultado de procesar click en celda -pub const CellClickResult = struct { - /// Hubo click - clicked: bool = false, - /// Fue doble-click - double_click: bool = false, - /// Fila clickeada - row: usize = 0, - /// Columna clickeada - col: usize = 0, -}; - -// ============================================================================= -// Funciones de renderizado -// ============================================================================= - -/// Dibuja el indicador de celda activa (fondo + borde) -/// Llamar ANTES de dibujar el texto de la celda -pub fn drawCellActiveIndicator( - ctx: *Context, - x: i32, - y: i32, - width: u32, - height: u32, - row_bg: Style.Color, - colors: *const TableColors, - has_focus: bool, -) void { - if (has_focus) { - // Con focus: fondo más visible + borde doble - const tinted_bg = blendColor(row_bg, colors.selected_cell, 0.35); - ctx.pushCommand(Command.rect(x, y, width, height, tinted_bg)); - ctx.pushCommand(Command.rectOutline(x, y, width, height, colors.selected_cell)); - ctx.pushCommand(Command.rectOutline(x + 1, y + 1, width -| 2, height -| 2, colors.selected_cell)); - } else { - // Sin focus: indicación más sutil - const tinted_bg = blendColor(row_bg, colors.selected_cell_unfocus, 0.15); - ctx.pushCommand(Command.rect(x, y, width, height, tinted_bg)); - ctx.pushCommand(Command.rectOutline(x, y, width, height, colors.border)); - } -} - -/// Dibuja el overlay de edición de celda -pub fn drawEditingOverlay( - ctx: *Context, - x: i32, - y: i32, - width: u32, - height: u32, - edit_text: []const u8, - cursor_pos: usize, - colors: *const TableColors, -) void { - // Fondo blanco - ctx.pushCommand(Command.rect(x, y, width, height, colors.cell_editing_bg)); - - // Borde azul - ctx.pushCommand(Command.rectOutline(x, y, width, height, colors.cell_editing_border)); - - // Texto - const text_y = y + @as(i32, @intCast((height -| 16) / 2)); - const text_to_show = if (edit_text.len > 0) edit_text else ""; - ctx.pushCommand(Command.text(x + 4, text_y, text_to_show, colors.cell_editing_text)); - - // Cursor parpadeante (simplificado: siempre visible) - // Calcular posición X del cursor basado en caracteres - const cursor_x = x + 4 + @as(i32, @intCast(cursor_pos * 8)); // Asumiendo fuente monospace 8px - ctx.pushCommand(Command.rect(cursor_x, text_y, 2, 16, colors.cell_editing_border)); -} - -/// Dibuja el texto de una celda -pub fn drawCellText( - ctx: *Context, - x: i32, - y: i32, - width: u32, - height: u32, - text: []const u8, - color: Style.Color, - text_align: u2, -) void { - const text_y = y + @as(i32, @intCast((height -| 16) / 2)); - - const text_x = switch (text_align) { - 0 => x + 4, // Left - 1 => x + @as(i32, @intCast(width / 2)) - @as(i32, @intCast(text.len * 4)), // Center (aprox) - 2 => x + @as(i32, @intCast(width)) - @as(i32, @intCast(text.len * 8 + 4)), // Right - 3 => x + 4, // Default left - }; - - ctx.pushCommand(Command.text(text_x, text_y, text, color)); -} - -/// Detecta si un click es doble-click -pub fn detectDoubleClick( - state: *DoubleClickState, - current_time: u64, - row: i32, - col: i32, -) bool { - const same_cell = state.last_click_row == row and state.last_click_col == col; - const time_diff = current_time -| state.last_click_time; - const is_double = same_cell and time_diff < state.threshold_ms; - - if (is_double) { - // Reset para no detectar triple-click - state.last_click_time = 0; - state.last_click_row = -1; - state.last_click_col = -1; - } else { - // Guardar para próximo click - state.last_click_time = current_time; - state.last_click_row = row; - state.last_click_col = col; - } - - return is_double; -} - -// ============================================================================= -// Manejo de teclado para edición -// ============================================================================= - -/// Resultado de procesar teclado en modo edición -pub const EditKeyboardResult = struct { - /// Se confirmó la edición (Enter) - committed: bool = false, - /// Se canceló la edición (Escape) - cancelled: bool = false, - /// Se revirtió al valor original (primer Escape) - reverted: bool = false, - /// Se debe navegar a siguiente celda (Tab) - navigate_next: bool = false, - /// Se debe navegar a celda anterior (Shift+Tab) - navigate_prev: bool = false, - /// El buffer de edición cambió - text_changed: bool = false, -}; - -/// Procesa teclado en modo edición -/// Modifica edit_buffer, edit_len, edit_cursor según las teclas -pub fn handleEditingKeyboard( - ctx: *Context, - edit_buffer: []u8, - edit_len: *usize, - edit_cursor: *usize, - escape_count: *u8, - original_text: ?[]const u8, -) EditKeyboardResult { - var result = EditKeyboardResult{}; - - // Escape: cancelar o revertir - if (ctx.input.keyPressed(.escape)) { - escape_count.* += 1; - if (escape_count.* >= 2 or original_text == null) { - result.cancelled = true; - } else { - // Revertir al valor original - if (original_text) |orig| { - const len = @min(orig.len, edit_buffer.len); - @memcpy(edit_buffer[0..len], orig[0..len]); - edit_len.* = len; - edit_cursor.* = len; - result.reverted = true; - } - } - return result; - } - - // Reset escape count en cualquier otra tecla - escape_count.* = 0; - - // Enter: confirmar - if (ctx.input.keyPressed(.enter)) { - result.committed = true; - return result; - } - - // Tab: confirmar y navegar - if (ctx.input.keyPressed(.tab)) { - result.committed = true; - if (ctx.input.modifiers.shift) { - result.navigate_prev = true; - } else { - result.navigate_next = true; - } - return result; - } - - // Movimiento del cursor - if (ctx.input.keyPressed(.left)) { - if (edit_cursor.* > 0) edit_cursor.* -= 1; - return result; - } - if (ctx.input.keyPressed(.right)) { - if (edit_cursor.* < edit_len.*) edit_cursor.* += 1; - return result; - } - if (ctx.input.keyPressed(.home)) { - edit_cursor.* = 0; - return result; - } - if (ctx.input.keyPressed(.end)) { - edit_cursor.* = edit_len.*; - return result; - } - - // Backspace - if (ctx.input.keyPressed(.backspace)) { - if (edit_cursor.* > 0) { - // Shift characters left - var i: usize = edit_cursor.* - 1; - while (i < edit_len.* - 1) : (i += 1) { - edit_buffer[i] = edit_buffer[i + 1]; - } - edit_len.* -= 1; - edit_cursor.* -= 1; - result.text_changed = true; - } - return result; - } - - // Delete - if (ctx.input.keyPressed(.delete)) { - if (edit_cursor.* < edit_len.*) { - var i: usize = edit_cursor.*; - while (i < edit_len.* - 1) : (i += 1) { - edit_buffer[i] = edit_buffer[i + 1]; - } - edit_len.* -= 1; - result.text_changed = true; - } - return result; - } - - // Character input - if (ctx.input.text_input_len > 0) { - const text = ctx.input.text_input[0..ctx.input.text_input_len]; - for (text) |ch| { - if (ch >= 32 and ch < 127) { - if (edit_len.* < edit_buffer.len - 1) { - // Shift characters right - var i: usize = edit_len.*; - while (i > edit_cursor.*) : (i -= 1) { - edit_buffer[i] = edit_buffer[i - 1]; - } - edit_buffer[edit_cursor.*] = ch; - edit_len.* += 1; - edit_cursor.* += 1; - result.text_changed = true; - } - } - } - } - - return result; -} - -// ============================================================================= -// Utilidades -// ============================================================================= - -/// Mezcla dos colores con un factor alpha -pub fn blendColor(base: Style.Color, overlay: Style.Color, alpha: f32) Style.Color { - const inv_alpha = 1.0 - alpha; - - return Style.Color.rgba( - @intFromFloat(@as(f32, @floatFromInt(base.r)) * inv_alpha + @as(f32, @floatFromInt(overlay.r)) * alpha), - @intFromFloat(@as(f32, @floatFromInt(base.g)) * inv_alpha + @as(f32, @floatFromInt(overlay.g)) * alpha), - @intFromFloat(@as(f32, @floatFromInt(base.b)) * inv_alpha + @as(f32, @floatFromInt(overlay.b)) * alpha), - base.a, - ); -} - -/// Compara strings case-insensitive para búsqueda incremental -pub fn startsWithIgnoreCase(haystack: []const u8, needle: []const u8) bool { - if (needle.len > haystack.len) return false; - if (needle.len == 0) return true; - - for (needle, 0..) |needle_char, i| { - const haystack_char = haystack[i]; - const needle_lower = if (needle_char >= 'A' and needle_char <= 'Z') - needle_char + 32 - else - needle_char; - const haystack_lower = if (haystack_char >= 'A' and haystack_char <= 'Z') - haystack_char + 32 - else - haystack_char; - - if (needle_lower != haystack_lower) return false; - } - return true; -} - -// ============================================================================= -// Tests -// ============================================================================= - -test "blendColor" { - const white = Style.Color.rgb(255, 255, 255); - const black = Style.Color.rgb(0, 0, 0); - - const gray = blendColor(white, black, 0.5); - try std.testing.expectEqual(@as(u8, 127), gray.r); - try std.testing.expectEqual(@as(u8, 127), gray.g); - try std.testing.expectEqual(@as(u8, 127), gray.b); -} - -test "startsWithIgnoreCase" { - try std.testing.expect(startsWithIgnoreCase("Hello World", "Hello")); - try std.testing.expect(startsWithIgnoreCase("Hello World", "hello")); - try std.testing.expect(startsWithIgnoreCase("hello world", "HELLO")); - try std.testing.expect(startsWithIgnoreCase("anything", "")); - try std.testing.expect(!startsWithIgnoreCase("Hello", "World")); - try std.testing.expect(!startsWithIgnoreCase("Hi", "Hello World")); -} - -test "detectDoubleClick" { - var state = DoubleClickState{}; - - // Primer click - const first = detectDoubleClick(&state, 1000, 0, 0); - try std.testing.expect(!first); - - // Segundo click rápido en misma celda = doble click - const second = detectDoubleClick(&state, 1200, 0, 0); - try std.testing.expect(second); - - // Tercer click (estado reseteado) - const third = detectDoubleClick(&state, 1400, 0, 0); - try std.testing.expect(!third); -} diff --git a/src/widgets/table_core.zig b/src/widgets/table_core.zig index 17cd480..2e3dfed 100644 --- a/src/widgets/table_core.zig +++ b/src/widgets/table_core.zig @@ -5,6 +5,20 @@ //! - VirtualAdvancedTable (datos paginados desde DataProvider) //! //! Principio: Una sola implementación de UI, dos estrategias de datos. +//! +//! ## Protocolo de Propiedad de Memoria +//! +//! 1. **Strings de celda:** El DataSource retorna punteros a memoria estable. +//! El widget NO libera estos strings. Son válidos hasta el próximo fetch. +//! +//! 2. **Buffers de edición:** El widget mantiene edit_buffer[256] propio. +//! Los cambios se copian al DataSource solo en commit. +//! +//! 3. **Rendering:** Todos los strings pasados a ctx.pushCommand() deben ser +//! estables durante todo el frame. Usar buffers persistentes, NO stack. +//! +//! 4. **getValueInto pattern:** Cuando se necesita formatear valores, +//! el caller provee el buffer destino para evitar memory ownership issues. const std = @import("std"); const Context = @import("../core/context.zig").Context; @@ -65,7 +79,8 @@ pub const CellRenderInfo = struct { text_align: u2 = 0, }; -/// Estado de edición para renderizado +/// Estado de edición para renderizado (info para draw) +/// NOTA: Para estado embebible en widgets, usar CellEditState pub const EditState = struct { /// Está en modo edición editing: bool = false, @@ -79,6 +94,191 @@ pub const EditState = struct { edit_cursor: usize = 0, }; +// ============================================================================= +// Estados embebibles (para composición en AdvancedTableState/VirtualAdvancedTableState) +// ============================================================================= + +/// Tamaño máximo del buffer de edición +pub const MAX_EDIT_BUFFER_SIZE: usize = 256; + +/// Estado completo de edición de celda +/// Diseñado para ser embebido en AdvancedTableState y VirtualAdvancedTableState +pub const CellEditState = struct { + /// Está en modo edición + editing: bool = false, + + /// Celda en edición (fila, columna) + edit_row: usize = 0, + edit_col: usize = 0, + + /// Buffer de texto actual + edit_buffer: [MAX_EDIT_BUFFER_SIZE]u8 = undefined, + edit_len: usize = 0, + + /// Posición del cursor + edit_cursor: usize = 0, + + /// Valor original (para revertir con Escape) + original_buffer: [MAX_EDIT_BUFFER_SIZE]u8 = undefined, + original_len: usize = 0, + + /// Contador de Escapes (1=revertir, 2=cancelar) + escape_count: u8 = 0, + + /// Flag: el valor cambió respecto al original + value_changed: bool = false, + + const Self = @This(); + + /// Inicia edición de una celda + pub fn startEditing(self: *Self, row: usize, col: usize, current_value: []const u8, initial_char: ?u8) void { + self.editing = true; + self.edit_row = row; + self.edit_col = col; + self.escape_count = 0; + self.value_changed = false; + + // Guardar valor original + const orig_len = @min(current_value.len, MAX_EDIT_BUFFER_SIZE); + @memcpy(self.original_buffer[0..orig_len], current_value[0..orig_len]); + self.original_len = orig_len; + + // Inicializar buffer de edición + if (initial_char) |c| { + // Tecla alfanumérica: empezar con ese caracter + self.edit_buffer[0] = c; + self.edit_len = 1; + self.edit_cursor = 1; + } else { + // F2/Space/DoubleClick: mostrar valor actual + @memcpy(self.edit_buffer[0..orig_len], current_value[0..orig_len]); + self.edit_len = orig_len; + self.edit_cursor = orig_len; + } + } + + /// Obtiene el texto actual del editor + pub fn getEditText(self: *const Self) []const u8 { + return self.edit_buffer[0..self.edit_len]; + } + + /// Obtiene el valor original + pub fn getOriginalValue(self: *const Self) []const u8 { + return self.original_buffer[0..self.original_len]; + } + + /// Verifica si el valor cambió + pub fn hasChanged(self: *const Self) bool { + const current = self.getEditText(); + const original = self.getOriginalValue(); + return !std.mem.eql(u8, current, original); + } + + /// Revierte al valor original (Escape 1) + pub fn revertToOriginal(self: *Self) void { + const orig = self.getOriginalValue(); + @memcpy(self.edit_buffer[0..orig.len], orig); + self.edit_len = orig.len; + self.edit_cursor = orig.len; + } + + /// Finaliza edición + pub fn stopEditing(self: *Self) void { + self.editing = false; + self.edit_len = 0; + self.edit_cursor = 0; + self.escape_count = 0; + } + + /// Resultado de handleEscape + pub const EscapeAction = enum { reverted, cancelled, none }; + + /// Maneja Escape (retorna acción a tomar) + pub fn handleEscape(self: *Self) EscapeAction { + if (!self.editing) return .none; + + self.escape_count += 1; + if (self.escape_count == 1) { + self.revertToOriginal(); + return .reverted; + } else { + self.stopEditing(); + return .cancelled; + } + } + + /// Convierte a EditState para funciones de renderizado + pub fn toEditState(self: *const Self) EditState { + return .{ + .editing = self.editing, + .edit_row = @intCast(self.edit_row), + .edit_col = @intCast(self.edit_col), + .edit_text = self.getEditText(), + .edit_cursor = self.edit_cursor, + }; + } +}; + +/// Estado de navegación compartido +/// Diseñado para ser embebido en AdvancedTableState y VirtualAdvancedTableState +pub const NavigationState = struct { + /// Columna activa (para Tab navigation) + active_col: usize = 0, + + /// Scroll vertical (en filas) + scroll_row: usize = 0, + + /// Scroll horizontal (en pixels) + scroll_x: i32 = 0, + + /// El widget tiene focus + has_focus: bool = false, + + /// Double-click state + double_click: DoubleClickState = .{}, + + const Self = @This(); + + /// Navega a siguiente celda (Tab) + /// Retorna nueva posición y si navegó o salió del widget + pub fn tabToNextCell(self: *Self, current_row: usize, num_cols: usize, num_rows: usize, wrap: bool) struct { row: usize, col: usize, result: TabNavigateResult } { + const pos = calculateNextCell(current_row, self.active_col, num_cols, num_rows, wrap); + if (pos.result == .navigated) { + self.active_col = pos.col; + } + return .{ .row = pos.row, .col = pos.col, .result = pos.result }; + } + + /// Navega a celda anterior (Shift+Tab) + pub fn tabToPrevCell(self: *Self, current_row: usize, num_cols: usize, num_rows: usize, wrap: bool) struct { row: usize, col: usize, result: TabNavigateResult } { + const pos = calculatePrevCell(current_row, self.active_col, num_cols, num_rows, wrap); + if (pos.result == .navigated) { + self.active_col = pos.col; + } + return .{ .row = pos.row, .col = pos.col, .result = pos.result }; + } + + /// Mueve a columna anterior + pub fn moveToPrevCol(self: *Self) void { + if (self.active_col > 0) self.active_col -= 1; + } + + /// Mueve a columna siguiente + pub fn moveToNextCol(self: *Self, max_cols: usize) void { + if (self.active_col + 1 < max_cols) self.active_col += 1; + } + + /// Va a primera columna + pub fn goToFirstCol(self: *Self) void { + self.active_col = 0; + } + + /// Va a última columna + pub fn goToLastCol(self: *Self, max_cols: usize) void { + if (max_cols > 0) self.active_col = max_cols - 1; + } +}; + /// Estado de doble-click pub const DoubleClickState = struct { last_click_time: u64 = 0,