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
This commit is contained in:
reugenio 2025-12-27 16:11:16 +01:00
parent d16019d54f
commit 6819919060
2 changed files with 201 additions and 423 deletions

View file

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

View file

@ -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,