zcatgui/src/widgets/table_core.zig
reugenio 40743b98d2 feat(table_core): Add injection support to RowEditBuffer
- Add is_injected: bool to distinguish injected rows
- Add injection_index: ?usize for insertion position
- Add startInjectedEdit() method for Ctrl+N between lines
- Update startEdit/clear to reset new fields
2025-12-28 02:19:04 +01:00

2130 lines
76 KiB
Zig

//! 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.
//!
//! ## 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;
const Command = @import("../core/command.zig");
const Layout = @import("../core/layout.zig");
const Style = @import("../core/style.zig");
// =============================================================================
// Tips Proactivos (FASE I)
// =============================================================================
/// Tips de atajos de teclado para mostrar en StatusLine
/// Rotan cada ~10 segundos para enseñar atajos al usuario
pub const table_tips = [_][]const u8{
"Tip: F2 o Space para editar celda",
"Tip: Tab/Shift+Tab navega entre celdas",
"Tip: Ctrl+N crea nuevo registro",
"Tip: Ctrl+Delete o Ctrl+B borra registro",
"Tip: Ctrl+Shift+1..9 ordena por columna",
"Tip: Ctrl+Home/End va al inicio/fin",
"Tip: Enter confirma y baja, Escape cancela",
"Tip: Al editar, tecla directa reemplaza todo",
};
/// Frames entre rotación de tips (~10s @ 60fps)
pub const TIP_ROTATION_FRAMES: u32 = 600;
// =============================================================================
// 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),
cell_selection_bg: Style.Color = Style.Color.rgb(0, 120, 215), // Azul para selección
// 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 (info para draw)
/// NOTA: Para estado embebible en widgets, usar CellEditState
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 una fila (para indicadores visuales)
/// Compatible con advanced_table.types.RowState
pub const RowState = enum {
normal, // Sin cambios
modified, // Editada, pendiente de guardar
new, // Fila nueva, no existe en BD
deleted, // Marcada para eliminar
@"error", // Error de validación
};
// =============================================================================
// 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,
/// Selección de texto (Excel-style: todo seleccionado al entrar con F2)
/// Si selection_start == selection_end, no hay selección (solo cursor)
selection_start: usize = 0,
selection_end: usize = 0,
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, sin selección
self.edit_buffer[0] = c;
self.edit_len = 1;
self.edit_cursor = 1;
self.selection_start = 0;
self.selection_end = 0;
} else {
// F2/Space/DoubleClick: mostrar valor actual con TODO seleccionado (Excel-style)
@memcpy(self.edit_buffer[0..orig_len], current_value[0..orig_len]);
self.edit_len = orig_len;
self.edit_cursor = orig_len;
// Seleccionar todo el texto
self.selection_start = 0;
self.selection_end = 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);
}
/// Verifica si hay texto seleccionado
pub fn hasSelection(self: *const Self) bool {
return self.selection_start != self.selection_end;
}
/// Limpia la selección (pero mantiene el cursor)
pub fn clearSelection(self: *Self) void {
self.selection_start = 0;
self.selection_end = 0;
}
/// 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;
// Limpiar selección al revertir
self.clearSelection();
}
/// Finaliza edición
pub fn stopEditing(self: *Self) void {
self.editing = false;
self.edit_len = 0;
self.edit_cursor = 0;
self.escape_count = 0;
self.clearSelection();
}
/// 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,
last_click_row: i64 = -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,
selection_start: usize,
selection_end: 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 "";
// Dibujar selección si existe (Excel-style highlight)
if (selection_start != selection_end) {
const sel_min = @min(selection_start, selection_end);
const sel_max = @max(selection_start, selection_end);
const sel_x = x + 4 + @as(i32, @intCast(sel_min * 8)); // 8px por caracter (monospace)
const sel_width = @as(u32, @intCast((sel_max - sel_min) * 8));
// Color azul semitransparente para selección
ctx.pushCommand(Command.rect(sel_x, text_y, sel_width, 16, colors.cell_selection_bg));
}
// Texto (encima de la selección)
ctx.pushCommand(Command.text(x + 4, text_y, text_to_show, colors.cell_editing_text));
// Cursor parpadeante (simplificado: siempre visible)
// Solo mostrar cursor si NO hay selección completa
if (selection_start == selection_end) {
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));
}
/// Dibuja el indicador de estado de fila (círculo/cuadrado pequeño)
/// Llamado desde drawRowsWithDataSource cuando state_indicator_width > 0
pub fn drawStateIndicator(
ctx: *Context,
x: i32,
y: i32,
w: u32,
h: u32,
row_state: RowState,
colors: *const RowRenderColors,
) void {
// No dibujar nada para estado normal
if (row_state == .normal) return;
const indicator_size: u32 = 8;
const indicator_x = x + @as(i32, @intCast((w -| indicator_size) / 2));
const indicator_y = y + @as(i32, @intCast((h -| indicator_size) / 2));
const color = switch (row_state) {
.modified => colors.state_modified,
.new => colors.state_new,
.deleted => colors.state_deleted,
.@"error" => colors.state_error,
.normal => unreachable, // Ya verificado arriba
};
// Dibujar cuadrado indicador
ctx.pushCommand(Command.rect(indicator_x, indicator_y, indicator_size, indicator_size, color));
}
// =============================================================================
// Renderizado unificado de filas (FASE 4)
// =============================================================================
/// Definición de columna para renderizado unificado
pub const ColumnRenderDef = struct {
/// Ancho de la columna en pixels
width: u32,
/// Alineación: 0=left, 1=center, 2=right
text_align: u2 = 0,
/// Columna visible
visible: bool = true,
};
/// Colores para renderizado unificado de filas
pub const RowRenderColors = struct {
// Colores base de fila
row_normal: Style.Color,
row_alternate: Style.Color,
selected_row: Style.Color,
selected_row_unfocus: Style.Color,
selected_cell: Style.Color,
selected_cell_unfocus: Style.Color,
text_normal: Style.Color,
text_selected: Style.Color,
border: Style.Color,
// Colores de estado (para blending)
state_modified: Style.Color = Style.Color.rgb(255, 200, 100), // Naranja
state_new: Style.Color = Style.Color.rgb(100, 200, 100), // Verde
state_deleted: Style.Color = Style.Color.rgb(255, 100, 100), // Rojo
state_error: Style.Color = Style.Color.rgb(255, 50, 50), // Rojo intenso
/// Crea RowRenderColors desde TableColors
pub fn fromTableColors(tc: *const TableColors) RowRenderColors {
return .{
.row_normal = tc.row_normal,
.row_alternate = tc.row_alternate,
.selected_row = tc.selected_row,
.selected_row_unfocus = tc.selected_row_unfocus,
.selected_cell = tc.selected_cell,
.selected_cell_unfocus = tc.selected_cell_unfocus,
.text_normal = tc.text_normal,
.text_selected = tc.text_selected,
.border = tc.border,
};
}
};
/// Configuración para drawRowsWithDataSource
pub const DrawRowsConfig = struct {
/// Bounds del área de contenido
bounds_x: i32,
bounds_y: i32,
bounds_w: u32,
/// Altura de cada fila
row_height: u32,
/// Primera fila a dibujar (índice global)
first_row: usize,
/// Última fila a dibujar (exclusivo)
last_row: usize,
/// Offset horizontal de scroll
scroll_x: i32 = 0,
/// Usar colores alternados
alternating_rows: bool = true,
/// Widget tiene focus
has_focus: bool = false,
/// Fila seleccionada (-1 = ninguna)
selected_row: i32 = -1,
/// Columna activa
active_col: usize = 0,
/// Colores
colors: RowRenderColors,
/// Columnas
columns: []const ColumnRenderDef,
/// Ancho de columna de indicadores de estado (0 = deshabilitada)
state_indicator_width: u32 = 0,
/// Aplicar blending de color según estado de fila
apply_state_colors: bool = false,
/// Dibujar borde inferior en cada fila
draw_row_borders: bool = false,
/// ID de fila con cambios pendientes (dirty tracking visual)
/// Si no es null y coincide con el row_id actual, se aplica blend naranja
dirty_row_id: ?i64 = null,
/// Buffer de edición de fila para priorizar valores pendientes en renderizado
/// Permite mostrar lo que el usuario ha tecleado antes de que se guarde en BD
edit_buffer: ?*const RowEditBuffer = null,
};
/// Dibuja las filas de una tabla usando TableDataSource.
/// Esta es la función unificada que usan tanto AdvancedTable como VirtualAdvancedTable.
///
/// Parámetros:
/// - ctx: Contexto de renderizado
/// - datasource: Fuente de datos (MemoryDataSource o PagedDataSource)
/// - config: Configuración del renderizado
/// - cell_buffer: Buffer para formatear valores de celda (debe persistir durante el frame)
///
/// Retorna el número de filas dibujadas.
pub fn drawRowsWithDataSource(
ctx: *Context,
datasource: TableDataSource,
config: DrawRowsConfig,
cell_buffer: []u8,
) usize {
var rows_drawn: usize = 0;
var row_y = config.bounds_y;
var row_idx = config.first_row;
while (row_idx < config.last_row) : (row_idx += 1) {
const is_selected = config.selected_row >= 0 and
@as(usize, @intCast(config.selected_row)) == row_idx;
// Obtener estado de la fila
const row_state = datasource.getRowState(row_idx);
// Determinar color de fondo base
const is_alternate = config.alternating_rows and row_idx % 2 == 1;
var row_bg: Style.Color = if (is_alternate)
config.colors.row_alternate
else
config.colors.row_normal;
// Aplicar blending de color según estado (si está habilitado)
if (config.apply_state_colors) {
row_bg = switch (row_state) {
.modified => blendColor(row_bg, config.colors.state_modified, 0.2),
.new => blendColor(row_bg, config.colors.state_new, 0.2),
.deleted => blendColor(row_bg, config.colors.state_deleted, 0.3),
.@"error" => blendColor(row_bg, config.colors.state_error, 0.3),
.normal => row_bg,
};
}
// Dirty tracking: si la fila tiene cambios pendientes sin guardar
if (config.dirty_row_id) |dirty_id| {
const row_id = datasource.getRowId(row_idx);
if (row_id == dirty_id) {
// Blend naranja 25% para indicar cambios pendientes
row_bg = blendColor(row_bg, config.colors.state_modified, 0.25);
}
}
// Aplicar selección (override del estado)
if (is_selected) {
row_bg = if (config.has_focus) config.colors.selected_row else config.colors.selected_row_unfocus;
}
// Dibujar fondo de fila
ctx.pushCommand(Command.rect(
config.bounds_x,
row_y,
config.bounds_w,
config.row_height,
row_bg,
));
// Posición X inicial (después de state indicator si existe)
var col_x = config.bounds_x - config.scroll_x;
// Dibujar columna de indicador de estado (si está habilitada)
if (config.state_indicator_width > 0) {
drawStateIndicator(ctx, config.bounds_x, row_y, config.state_indicator_width, config.row_height, row_state, &config.colors);
col_x += @as(i32, @intCast(config.state_indicator_width));
}
// Dibujar celdas de datos
for (config.columns, 0..) |col, col_idx| {
if (!col.visible) continue;
const col_end = col_x + @as(i32, @intCast(col.width));
// Solo dibujar si la columna es visible en pantalla
if (col_end > config.bounds_x and
col_x < config.bounds_x + @as(i32, @intCast(config.bounds_w)))
{
const is_active_cell = is_selected and config.active_col == col_idx;
// Indicador de celda activa
if (is_active_cell) {
drawCellActiveIndicator(
ctx,
col_x,
row_y,
col.width,
config.row_height,
row_bg,
&TableColors{
.selected_cell = config.colors.selected_cell,
.selected_cell_unfocus = config.colors.selected_cell_unfocus,
.border = config.colors.border,
},
config.has_focus,
);
}
// Obtener texto de la celda
// PRIORIDAD 1: Valor pendiente en RowEditBuffer (lo que el usuario tecleó)
// PRIORIDAD 2: Valor del DataSource (BD o memoria)
var cell_text: []const u8 = "";
const row_id = datasource.getRowId(row_idx);
if (config.edit_buffer) |eb| {
if (eb.row_id == row_id) {
if (eb.getPendingValue(col_idx)) |pending| {
cell_text = pending;
}
}
}
if (cell_text.len == 0) {
cell_text = datasource.getCellValueInto(row_idx, col_idx, cell_buffer);
}
// Copiar a frame allocator para persistencia durante render
const text_to_draw = ctx.frameAllocator().dupe(u8, cell_text) catch cell_text;
// Color de texto
const text_color = if (is_selected and config.has_focus)
config.colors.text_selected
else
config.colors.text_normal;
// Dibujar texto
drawCellText(
ctx,
col_x,
row_y,
col.width,
config.row_height,
text_to_draw,
text_color,
col.text_align,
);
}
col_x = col_end;
}
// Dibujar borde inferior de fila (si está habilitado)
if (config.draw_row_borders) {
ctx.pushCommand(Command.rect(
config.bounds_x,
row_y + @as(i32, @intCast(config.row_height)) - 1,
config.bounds_w,
1,
config.colors.border,
));
}
row_y += @as(i32, @intCast(config.row_height));
rows_drawn += 1;
}
return rows_drawn;
}
/// Detecta si un click es doble-click
pub fn detectDoubleClick(
state: *DoubleClickState,
current_time: u64,
row: i64,
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
// =============================================================================
/// Dirección de navegación después de edición
pub const NavigateDirection = enum {
none,
next_cell, // Tab
prev_cell, // Shift+Tab
next_row, // Enter o ↓
prev_row, // ↑
};
/// Resultado de procesar teclado en modo edición
pub const EditKeyboardResult = struct {
/// Se confirmó la edición (Enter, Tab, flechas)
committed: bool = false,
/// Se canceló la edición (Escape 2x)
cancelled: bool = false,
/// Se revirtió al valor original (Escape 1x)
reverted: bool = false,
/// Dirección de navegación después de commit
navigate: NavigateDirection = .none,
/// El buffer de edición cambió
text_changed: bool = false,
/// Indica que se procesó un evento de teclado (para evitar doble procesamiento)
handled: bool = false,
};
/// Procesa teclado en modo edición
/// Modifica edit_buffer, edit_len, edit_cursor según las teclas
/// Soporta selección Excel-style: typing reemplaza selección
/// Retorna resultado con flags de navegación y si se procesó algún evento
pub fn handleEditingKeyboard(
ctx: *Context,
edit_buffer: []u8,
edit_len: *usize,
edit_cursor: *usize,
escape_count: *u8,
original_text: ?[]const u8,
selection_start: ?*usize,
selection_end: ?*usize,
) EditKeyboardResult {
var result = EditKeyboardResult{};
// Helper para eliminar texto seleccionado
const deleteSelection = struct {
fn f(buf: []u8, len: *usize, cursor: *usize, sel_start: *usize, sel_end: *usize) bool {
if (sel_start.* == sel_end.*) return false;
const min_pos = @min(sel_start.*, sel_end.*);
const max_pos = @min(@max(sel_start.*, sel_end.*), len.*);
if (max_pos <= min_pos) return false;
// Mover caracteres después de la selección hacia atrás
const chars_to_delete = max_pos - min_pos;
var i: usize = min_pos;
while (i + chars_to_delete < len.*) : (i += 1) {
buf[i] = buf[i + chars_to_delete];
}
len.* -= chars_to_delete;
cursor.* = min_pos;
sel_start.* = 0;
sel_end.* = 0;
return true;
}
}.f;
// Helper para limpiar selección
const clearSelection = struct {
fn f(sel_start: ?*usize, sel_end: ?*usize) void {
if (sel_start) |s| s.* = 0;
if (sel_end) |e| e.* = 0;
}
}.f;
// Procesar eventos de tecla
for (ctx.input.getKeyEvents()) |event| {
if (!event.pressed) continue;
switch (event.key) {
.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;
}
}
result.handled = true;
return result;
},
.enter => {
result.committed = true;
result.navigate = .next_row;
result.handled = true;
return result;
},
.tab => {
result.committed = true;
result.navigate = if (event.modifiers.shift) .prev_cell else .next_cell;
result.handled = true;
return result;
},
.up => {
result.committed = true;
result.navigate = .prev_row;
result.handled = true;
return result;
},
.down => {
result.committed = true;
result.navigate = .next_row;
result.handled = true;
return result;
},
.left => {
clearSelection(selection_start, selection_end);
if (edit_cursor.* > 0) edit_cursor.* -= 1;
result.handled = true;
escape_count.* = 0;
},
.right => {
clearSelection(selection_start, selection_end);
if (edit_cursor.* < edit_len.*) edit_cursor.* += 1;
result.handled = true;
escape_count.* = 0;
},
.home => {
clearSelection(selection_start, selection_end);
edit_cursor.* = 0;
result.handled = true;
escape_count.* = 0;
},
.end => {
clearSelection(selection_start, selection_end);
edit_cursor.* = edit_len.*;
result.handled = true;
escape_count.* = 0;
},
.backspace => {
// Si hay selección, borrar selección
if (selection_start != null and selection_end != null) {
if (deleteSelection(edit_buffer, edit_len, edit_cursor, selection_start.?, selection_end.?)) {
result.text_changed = true;
result.handled = true;
escape_count.* = 0;
continue;
}
}
// Sin selección: borrar caracter antes del cursor
if (edit_cursor.* > 0 and edit_len.* > 0) {
const pos = edit_cursor.* - 1;
var i: usize = pos;
while (i < edit_len.* - 1) : (i += 1) {
edit_buffer[i] = edit_buffer[i + 1];
}
edit_len.* -= 1;
edit_cursor.* -= 1;
result.text_changed = true;
}
result.handled = true;
escape_count.* = 0;
},
.delete => {
// Si hay selección, borrar selección
if (selection_start != null and selection_end != null) {
if (deleteSelection(edit_buffer, edit_len, edit_cursor, selection_start.?, selection_end.?)) {
result.text_changed = true;
result.handled = true;
escape_count.* = 0;
continue;
}
}
// Sin selección: borrar caracter después del cursor
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;
}
result.handled = true;
escape_count.* = 0;
},
else => {},
}
}
// Procesar texto ingresado (caracteres imprimibles)
const text_input = ctx.input.getTextInput();
if (text_input.len > 0) {
// Si hay selección, borrarla primero (comportamiento Excel/Word)
if (selection_start != null and selection_end != null) {
if (selection_start.?.* != selection_end.?.*) {
_ = deleteSelection(edit_buffer, edit_len, edit_cursor, selection_start.?, selection_end.?);
}
}
for (text_input) |ch| {
if (edit_len.* < edit_buffer.len - 1) {
// Hacer espacio moviendo caracteres hacia la derecha
if (edit_cursor.* < edit_len.*) {
var i = 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;
result.handled = true;
}
}
escape_count.* = 0;
}
return result;
}
// =============================================================================
// BRAIN-IN-CORE: Procesamiento Unificado de Eventos de Tabla (FASE C)
// =============================================================================
//
// Arquitectura "Brain-in-Core" (diseñado por Gemini):
// - TODA la lógica de decisión vive aquí
// - Los widgets solo pasan eventos y reaccionan a los resultados
// - Cualquier nueva tabla (CloudTable, etc.) hereda esta potencia automáticamente
/// Resultado completo del procesamiento de eventos de tabla.
/// Contiene flags para TODAS las acciones posibles.
pub const TableEventResult = struct {
// =========================================================================
// Navegación básica (flechas, PageUp/Down)
// =========================================================================
move_up: bool = false,
move_down: bool = false,
move_left: bool = false, // Sin Ctrl: cambiar columna
move_right: bool = false, // Sin Ctrl: cambiar columna
page_up: bool = false,
page_down: bool = false,
// =========================================================================
// Navegación a extremos
// =========================================================================
go_to_first_col: bool = false, // Home sin Ctrl
go_to_last_col: bool = false, // End sin Ctrl
go_to_first_row: bool = false, // Ctrl+Home: primera fila de datos
go_to_last_row: bool = false, // Ctrl+End: última fila de datos
// =========================================================================
// Scroll horizontal (Ctrl+Left/Right)
// =========================================================================
scroll_left: bool = false,
scroll_right: bool = false,
// =========================================================================
// CRUD (Ctrl+N, Ctrl+B, Ctrl+Delete)
// =========================================================================
insert_row: bool = false, // Ctrl+N: insertar nueva fila
delete_row: bool = false, // Ctrl+Delete o Ctrl+B: eliminar fila
// =========================================================================
// Ordenación (Ctrl+Shift+1..9)
// =========================================================================
sort_by_column: ?usize = null, // Índice de columna (0-based)
// =========================================================================
// Edición (F2, Space, tecla alfanumérica)
// =========================================================================
start_editing: bool = false, // Iniciar edición de celda activa
initial_char: ?u8 = null, // Caracter inicial (si fue tecla alfa)
// =========================================================================
// Tab navigation
// =========================================================================
tab_out: bool = false, // Tab presionado (pasar focus a otro widget)
tab_shift: bool = false, // Fue Shift+Tab (dirección inversa)
// =========================================================================
// Flag general
// =========================================================================
handled: bool = false, // Se procesó algún evento
};
/// Procesa TODOS los eventos de teclado de una tabla.
/// Esta es la función maestra "Brain-in-Core" que centraliza toda la lógica.
///
/// Parámetros:
/// - ctx: Contexto de renderizado (acceso a input)
/// - is_editing: Si hay una celda en modo edición (ignora navegación)
///
/// El widget debe reaccionar a los flags retornados y actualizar su estado.
///
/// Ejemplo de uso en widget:
/// ```zig
/// const events = table_core.processTableEvents(ctx, list_state.isEditing());
/// if (events.move_up) list_state.moveUp();
/// if (events.move_down) list_state.moveDown(visible_rows);
/// if (events.go_to_first_row) list_state.goToStart();
/// if (events.insert_row) result.insert_row = true;
/// // ... etc
/// ```
pub fn processTableEvents(ctx: *Context, is_editing: bool) TableEventResult {
var result = TableEventResult{};
// Si hay edición activa, el CellEditor maneja las teclas
// Solo procesamos Tab para salir del widget
if (is_editing) {
for (ctx.input.getKeyEvents()) |event| {
if (!event.pressed) continue;
if (event.key == .tab) {
result.tab_out = true;
result.tab_shift = event.modifiers.shift;
result.handled = true;
return result;
}
}
return result;
}
// =========================================================================
// 1. Navegación con navKeyPressed (soporta key repeat)
// =========================================================================
if (ctx.input.navKeyPressed()) |key| {
const ctrl = ctx.input.modifiers.ctrl;
switch (key) {
.up => {
result.move_up = true;
result.handled = true;
},
.down => {
result.move_down = true;
result.handled = true;
},
.left => {
if (ctrl) {
result.scroll_left = true;
} else {
result.move_left = true;
}
result.handled = true;
},
.right => {
if (ctrl) {
result.scroll_right = true;
} else {
result.move_right = true;
}
result.handled = true;
},
.page_up => {
result.page_up = true;
result.handled = true;
},
.page_down => {
result.page_down = true;
result.handled = true;
},
.home => {
if (ctrl) {
result.go_to_first_row = true;
result.go_to_first_col = true;
} else {
result.go_to_first_col = true;
}
result.handled = true;
},
.end => {
if (ctrl) {
result.go_to_last_row = true;
result.go_to_last_col = true;
} else {
result.go_to_last_col = true;
}
result.handled = true;
},
else => {},
}
}
// =========================================================================
// 2. Atajos con Ctrl y teclas especiales (getKeyEvents)
// =========================================================================
for (ctx.input.getKeyEvents()) |event| {
if (!event.pressed) continue;
// F2 o Space: iniciar edición
if (event.key == .f2 or event.key == .space) {
result.start_editing = true;
result.handled = true;
return result;
}
// Tab: pasar focus al siguiente widget
if (event.key == .tab) {
result.tab_out = true;
result.tab_shift = event.modifiers.shift;
result.handled = true;
return result;
}
// Atajos con Ctrl
if (event.modifiers.ctrl) {
switch (event.key) {
.n => {
// Ctrl+N: insertar nueva fila
result.insert_row = true;
result.handled = true;
return result;
},
.b, .delete => {
// Ctrl+B o Ctrl+Delete: eliminar fila
result.delete_row = true;
result.handled = true;
return result;
},
// Ctrl+Shift+1..9: ordenar por columna
.@"1" => {
if (event.modifiers.shift) {
result.sort_by_column = 0;
result.handled = true;
return result;
}
},
.@"2" => {
if (event.modifiers.shift) {
result.sort_by_column = 1;
result.handled = true;
return result;
}
},
.@"3" => {
if (event.modifiers.shift) {
result.sort_by_column = 2;
result.handled = true;
return result;
}
},
.@"4" => {
if (event.modifiers.shift) {
result.sort_by_column = 3;
result.handled = true;
return result;
}
},
.@"5" => {
if (event.modifiers.shift) {
result.sort_by_column = 4;
result.handled = true;
return result;
}
},
.@"6" => {
if (event.modifiers.shift) {
result.sort_by_column = 5;
result.handled = true;
return result;
}
},
.@"7" => {
if (event.modifiers.shift) {
result.sort_by_column = 6;
result.handled = true;
return result;
}
},
.@"8" => {
if (event.modifiers.shift) {
result.sort_by_column = 7;
result.handled = true;
return result;
}
},
.@"9" => {
if (event.modifiers.shift) {
result.sort_by_column = 8;
result.handled = true;
return result;
}
},
else => {},
}
}
}
// =========================================================================
// 3. Teclas alfanuméricas: iniciar edición con ese caracter
// =========================================================================
const char_input = ctx.input.getTextInput();
if (char_input.len > 0) {
result.start_editing = true;
result.initial_char = char_input[0];
result.handled = true;
}
return result;
}
// Alias para compatibilidad (DEPRECADO - usar processTableEvents)
pub const TableKeyboardResult = TableEventResult;
pub const handleTableKeyboard = processTableEvents;
// =============================================================================
// Edición de fila completa (commit al abandonar fila, estilo Excel)
// =============================================================================
/// Máximo de columnas soportadas para cambios pendientes
pub const MAX_PENDING_COLUMNS: usize = 32;
/// Máximo tamaño de valor por celda
pub const MAX_CELL_VALUE_LEN: usize = 256;
/// ID especial para filas nuevas (ghost row)
pub const NEW_ROW_ID: i64 = -1;
/// Cambio pendiente en una columna
pub const PendingCellChange = struct {
/// Índice de columna
col: usize,
/// Valor nuevo (slice al buffer interno)
value: []const u8,
};
/// Buffer para acumular cambios de una fila antes de commit
/// Usado por los states de los widgets, procesado por funciones de table_core
pub const RowEditBuffer = struct {
/// ID de la fila siendo editada (NEW_ROW_ID si es ghost row o inyectada)
row_id: i64 = NEW_ROW_ID,
/// Índice de fila (para navegación)
row_index: usize = 0,
/// Es una fila nueva (ghost row que el usuario está rellenando)
is_new_row: bool = false,
/// Hay cambios pendientes
has_changes: bool = false,
/// True si es una fila inyectada (Ctrl+N entre líneas)
is_injected: bool = false,
/// Índice donde se insertó la fila inyectada (null si no es inyección)
injection_index: ?usize = null,
/// Buffers de valores por columna (almacenamiento fijo)
value_buffers: [MAX_PENDING_COLUMNS][MAX_CELL_VALUE_LEN]u8 = undefined,
/// Longitudes de cada valor
value_lens: [MAX_PENDING_COLUMNS]usize = [_]usize{0} ** MAX_PENDING_COLUMNS,
/// Flags: qué columnas tienen cambios
changed_cols: [MAX_PENDING_COLUMNS]bool = [_]bool{false} ** MAX_PENDING_COLUMNS,
/// Número de columnas con cambios
change_count: usize = 0,
/// Inicializa/resetea el buffer para una nueva fila
pub fn startEdit(self: *RowEditBuffer, row_id: i64, row_index: usize, is_new: bool) void {
self.row_id = row_id;
self.row_index = row_index;
self.is_new_row = is_new;
self.has_changes = false;
self.is_injected = false;
self.injection_index = null;
self.change_count = 0;
for (0..MAX_PENDING_COLUMNS) |i| {
self.changed_cols[i] = false;
self.value_lens[i] = 0;
}
}
/// Inicializa buffer para una fila inyectada (Ctrl+N entre líneas)
/// insertion_idx es el índice visual donde aparece la fila nueva
pub fn startInjectedEdit(self: *RowEditBuffer, insertion_idx: usize) void {
self.row_id = NEW_ROW_ID;
self.row_index = insertion_idx;
self.is_new_row = true;
self.has_changes = false;
self.is_injected = true;
self.injection_index = insertion_idx;
self.change_count = 0;
for (0..MAX_PENDING_COLUMNS) |i| {
self.changed_cols[i] = false;
self.value_lens[i] = 0;
}
}
/// Añade un cambio pendiente para una columna
pub fn addChange(self: *RowEditBuffer, col: usize, value: []const u8) void {
if (col >= MAX_PENDING_COLUMNS) return;
// Copiar valor al buffer interno
const len = @min(value.len, MAX_CELL_VALUE_LEN);
@memcpy(self.value_buffers[col][0..len], value[0..len]);
self.value_lens[col] = len;
// Marcar como cambiado
if (!self.changed_cols[col]) {
self.changed_cols[col] = true;
self.change_count += 1;
}
self.has_changes = true;
}
/// Obtiene el valor pendiente de una columna (si hay cambio)
pub fn getPendingValue(self: *const RowEditBuffer, col: usize) ?[]const u8 {
if (col >= MAX_PENDING_COLUMNS) return null;
if (!self.changed_cols[col]) return null;
return self.value_buffers[col][0..self.value_lens[col]];
}
/// Limpia el buffer (después de commit o discard)
pub fn clear(self: *RowEditBuffer) void {
self.row_id = NEW_ROW_ID;
self.row_index = 0;
self.is_new_row = false;
self.has_changes = false;
self.is_injected = false;
self.injection_index = null;
self.change_count = 0;
for (0..MAX_PENDING_COLUMNS) |i| {
self.changed_cols[i] = false;
self.value_lens[i] = 0;
}
}
};
/// Información para hacer commit de los cambios de una fila
/// Retornada cuando el usuario abandona una fila editada
pub const RowCommitInfo = struct {
/// ID de la fila (NEW_ROW_ID si es INSERT)
row_id: i64,
/// Es INSERT (nueva fila) o UPDATE (fila existente)
is_insert: bool,
/// Lista de cambios (columna, valor)
changes: []const PendingCellChange,
/// Número de cambios
change_count: usize,
};
/// Construye la info de commit desde un RowEditBuffer
/// El caller debe proveer el array para almacenar los cambios
pub fn buildCommitInfo(
buffer: *const RowEditBuffer,
changes_out: []PendingCellChange,
) ?RowCommitInfo {
if (!buffer.has_changes) return null;
var count: usize = 0;
for (0..MAX_PENDING_COLUMNS) |col| {
if (buffer.changed_cols[col] and count < changes_out.len) {
changes_out[count] = .{
.col = col,
.value = buffer.value_buffers[col][0..buffer.value_lens[col]],
};
count += 1;
}
}
return RowCommitInfo{
.row_id = buffer.row_id,
.is_insert = buffer.is_new_row,
.changes = changes_out[0..count],
.change_count = count,
};
}
/// Verifica si hay que hacer commit antes de editar nueva celda.
/// Si la fila cambió y hay cambios pendientes, retorna commit info.
/// Siempre inicializa el buffer para la nueva fila.
///
/// Uso típico en widget:
/// ```
/// if (table_core.checkRowChangeAndCommit(&state.row_edit_buffer, new_id, new_idx, is_ghost, &changes)) |info| {
/// result.row_committed = true;
/// result.commit_info = info;
/// }
/// ```
pub fn checkRowChangeAndCommit(
buffer: *RowEditBuffer,
new_row_id: i64,
new_row_index: usize,
is_new_row: bool,
changes_out: []PendingCellChange,
) ?RowCommitInfo {
// Si es la misma fila, no hacer nada
if (buffer.row_id == new_row_id) return null;
// Si hay cambios pendientes en la fila anterior, construir commit
var commit_info: ?RowCommitInfo = null;
if (buffer.has_changes) {
commit_info = buildCommitInfo(buffer, changes_out);
}
// Iniciar edición de la nueva fila
buffer.startEdit(new_row_id, new_row_index, is_new_row);
return commit_info;
}
/// Verifica si un row_id corresponde a la ghost row (fila nueva)
pub fn isGhostRow(row_id: i64) bool {
return row_id == NEW_ROW_ID;
}
// =============================================================================
// 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;
}
// =============================================================================
// Navegación Tab Excel-style (compartida por AdvancedTable y VirtualAdvancedTable)
// =============================================================================
/// Resultado de navegación Tab
pub const TabNavigateResult = enum {
/// Navegó a otra celda dentro del widget
navigated,
/// Salió del widget (Tab en última celda o Shift+Tab en primera)
tab_out,
};
/// Resultado del cálculo de nueva posición de celda
pub const CellPosition = struct {
row: usize,
col: usize,
result: TabNavigateResult,
};
/// Calcula la siguiente celda después de Tab
/// Parámetros genéricos para que funcione con ambos tipos de tabla.
pub fn calculateNextCell(
current_row: usize,
current_col: usize,
num_cols: usize,
num_rows: usize,
wrap_to_start: bool,
) CellPosition {
if (num_cols == 0 or num_rows == 0) {
return .{ .row = current_row, .col = current_col, .result = .tab_out };
}
var new_row = current_row;
var new_col = current_col;
if (current_col + 1 < num_cols) {
// Siguiente columna en misma fila
new_col = current_col + 1;
return .{ .row = new_row, .col = new_col, .result = .navigated };
}
// Última columna: ir a primera columna de siguiente fila
new_col = 0;
if (current_row + 1 < num_rows) {
// Hay siguiente fila
new_row = current_row + 1;
return .{ .row = new_row, .col = new_col, .result = .navigated };
}
// Última fila
if (wrap_to_start) {
new_row = 0;
return .{ .row = new_row, .col = new_col, .result = .navigated };
}
return .{ .row = current_row, .col = current_col, .result = .tab_out };
}
/// Calcula la celda anterior después de Shift+Tab
pub fn calculatePrevCell(
current_row: usize,
current_col: usize,
num_cols: usize,
num_rows: usize,
wrap_to_end: bool,
) CellPosition {
if (num_cols == 0 or num_rows == 0) {
return .{ .row = current_row, .col = current_col, .result = .tab_out };
}
var new_row = current_row;
var new_col = current_col;
if (current_col > 0) {
// Columna anterior en misma fila
new_col = current_col - 1;
return .{ .row = new_row, .col = new_col, .result = .navigated };
}
// Primera columna: ir a última columna de fila anterior
new_col = num_cols - 1;
if (current_row > 0) {
// Hay fila anterior
new_row = current_row - 1;
return .{ .row = new_row, .col = new_col, .result = .navigated };
}
// Primera fila
if (wrap_to_end) {
new_row = num_rows - 1;
return .{ .row = new_row, .col = new_col, .result = .navigated };
}
return .{ .row = current_row, .col = current_col, .result = .tab_out };
}
/// Acción a ejecutar después de navegación Tab
pub const TabAction = enum {
/// Navegar a nueva celda, sin commit
move,
/// Navegar a nueva celda, con commit de fila anterior
move_with_commit,
/// Salir del widget, sin commit
exit,
/// Salir del widget, con commit de fila actual
exit_with_commit,
};
/// Plan completo de navegación Tab (resultado de planTabNavigation)
pub const TabNavigationPlan = struct {
action: TabAction,
new_row: usize,
new_col: usize,
commit_info: ?RowCommitInfo,
};
/// Planifica navegación Tab con commit automático al cambiar de fila.
///
/// Esta es la función central DRY para navegación Excel-style.
/// El widget solo pasa parámetros y recibe el plan completo.
///
/// Parámetros:
/// - buffer: RowEditBuffer con cambios pendientes
/// - current_row/col: posición actual
/// - num_cols/rows: dimensiones de la tabla
/// - forward: true=Tab, false=Shift+Tab
/// - wrap: si hacer wrap al llegar al final
/// - row_id_getter: cualquier tipo con fn getRowId(usize) i64
/// - changes_out: buffer para almacenar cambios del commit
///
/// El widget ejecuta el plan:
/// - .move: actualizar posición
/// - .move_with_commit: guardar commit_info en BD, luego actualizar posición
/// - .exit: establecer tab_out=true
/// - .exit_with_commit: guardar commit_info, luego tab_out=true
pub fn planTabNavigation(
buffer: *RowEditBuffer,
current_row: usize,
current_col: usize,
num_cols: usize,
num_rows: usize,
forward: bool,
wrap: bool,
row_id_getter: anytype,
changes_out: []PendingCellChange,
) TabNavigationPlan {
// 1. Calcular nueva posición
const pos = if (forward)
calculateNextCell(current_row, current_col, num_cols, num_rows, wrap)
else
calculatePrevCell(current_row, current_col, num_cols, num_rows, wrap);
// 2. Si es tab_out, verificar si hay commit pendiente
if (pos.result == .tab_out) {
if (buffer.has_changes) {
const info = buildCommitInfo(buffer, changes_out);
buffer.clear();
return .{
.action = .exit_with_commit,
.new_row = pos.row,
.new_col = pos.col,
.commit_info = info,
};
}
return .{
.action = .exit,
.new_row = pos.row,
.new_col = pos.col,
.commit_info = null,
};
}
// 3. Navegación dentro del widget - verificar si cambió de fila
const current_row_id = buffer.row_id;
const new_row_id = row_id_getter.getRowId(pos.row);
if (current_row_id != new_row_id and buffer.has_changes) {
// Cambió de fila con cambios pendientes → commit
const info = buildCommitInfo(buffer, changes_out);
// Iniciar buffer para nueva fila
buffer.startEdit(new_row_id, pos.row, isGhostRow(new_row_id));
return .{
.action = .move_with_commit,
.new_row = pos.row,
.new_col = pos.col,
.commit_info = info,
};
}
// Sin cambio de fila o sin cambios pendientes
if (current_row_id != new_row_id) {
// Cambió de fila pero sin cambios → solo actualizar buffer
buffer.startEdit(new_row_id, pos.row, isGhostRow(new_row_id));
}
return .{
.action = .move,
.new_row = pos.row,
.new_col = pos.col,
.commit_info = null,
};
}
// =============================================================================
// Ordenación (compartida)
// =============================================================================
/// Dirección de ordenación
pub const SortDirection = enum {
none,
ascending,
descending,
/// Alterna la dirección: none → asc → desc → none
pub fn toggle(self: SortDirection) SortDirection {
return switch (self) {
.none => .ascending,
.ascending => .descending,
.descending => .none,
};
}
};
/// Resultado de toggle de ordenación en columna
pub const SortToggleResult = struct {
/// Nueva columna de ordenación (null si se desactivó)
column: ?usize,
/// Nueva dirección
direction: SortDirection,
};
/// Calcula el nuevo estado de ordenación al hacer click en una columna
pub fn toggleSort(
current_column: ?usize,
current_direction: SortDirection,
clicked_column: usize,
) SortToggleResult {
if (current_column) |col| {
if (col == clicked_column) {
// Misma columna: ciclar dirección
const new_dir = current_direction.toggle();
return .{
.column = if (new_dir == .none) null else clicked_column,
.direction = new_dir,
};
}
}
// Columna diferente o sin ordenación: empezar ascendente
return .{
.column = clicked_column,
.direction = .ascending,
};
}
// =============================================================================
// TableDataSource Interface (FASE 3)
// =============================================================================
//
// ## Interfaz TableDataSource
//
// Abstrae el origen de datos para tablas, permitiendo que el mismo widget
// renderice datos desde memoria (AdvancedTable) o desde BD paginada (VirtualAdvancedTable).
//
// ### Protocolo de Memoria
//
// `getCellValueInto` escribe directamente en el buffer proporcionado por el widget.
// Esto elimina problemas de ownership: el widget controla la vida del buffer.
//
// ### Ejemplo de uso:
// ```zig
// var buf: [256]u8 = undefined;
// const value = data_source.getCellValueInto(row, col, &buf);
// // value es un slice de buf, válido mientras buf exista
// ```
/// Interfaz genérica para proveer datos a tablas
/// Usa vtable pattern para polimorfismo en runtime
pub const TableDataSource = struct {
ptr: *anyopaque,
vtable: *const VTable,
pub const VTable = struct {
/// Retorna el número total de filas en el datasource
getRowCount: *const fn (ptr: *anyopaque) usize,
/// Escribe el valor de una celda en el buffer proporcionado
/// Retorna el slice del buffer con el contenido escrito
/// Si la celda no existe o está vacía, retorna ""
getCellValueInto: *const fn (ptr: *anyopaque, row: usize, col: usize, buf: []u8) []const u8,
/// Retorna el ID único de una fila (para selección persistente)
/// NEW_ROW_ID (-1) indica fila nueva no guardada
getRowId: *const fn (ptr: *anyopaque, row: usize) i64,
/// Verifica si una celda es editable (opcional, default true)
isCellEditable: ?*const fn (ptr: *anyopaque, row: usize, col: usize) bool = null,
/// Retorna el estado de una fila (opcional, default .normal)
/// Usado para colores de estado (modified, new, deleted, error)
getRowState: ?*const fn (ptr: *anyopaque, row: usize) RowState = null,
/// Invalida cache interno (para refresh)
invalidate: ?*const fn (ptr: *anyopaque) void = null,
};
// =========================================================================
// Métodos de conveniencia
// =========================================================================
/// Obtiene el número de filas
pub fn getRowCount(self: TableDataSource) usize {
return self.vtable.getRowCount(self.ptr);
}
/// Escribe valor de celda en buffer
pub fn getCellValueInto(self: TableDataSource, row: usize, col: usize, buf: []u8) []const u8 {
return self.vtable.getCellValueInto(self.ptr, row, col, buf);
}
/// Obtiene ID de fila
pub fn getRowId(self: TableDataSource, row: usize) i64 {
return self.vtable.getRowId(self.ptr, row);
}
/// Verifica si celda es editable
pub fn isCellEditable(self: TableDataSource, row: usize, col: usize) bool {
if (self.vtable.isCellEditable) |func| {
return func(self.ptr, row, col);
}
return true; // Default: todas editables
}
/// Invalida cache
pub fn invalidate(self: TableDataSource) void {
if (self.vtable.invalidate) |func| {
func(self.ptr);
}
}
/// Obtiene el estado de una fila
pub fn getRowState(self: TableDataSource, row: usize) RowState {
if (self.vtable.getRowState) |func| {
return func(self.ptr, row);
}
return .normal; // Default: estado normal
}
/// Verifica si la fila es la ghost row (nueva)
pub fn isGhostRow(self: TableDataSource, row: usize) bool {
return self.getRowId(row) == NEW_ROW_ID;
}
};
/// Helper para crear TableDataSource desde un tipo concreto
/// El tipo T debe tener los métodos: getRowCount, getCellValueInto, getRowId
pub fn makeTableDataSource(comptime T: type, impl: *T) TableDataSource {
const vtable = comptime blk: {
var vt: TableDataSource.VTable = .{
.getRowCount = @ptrCast(&T.getRowCount),
.getCellValueInto = @ptrCast(&T.getCellValueInto),
.getRowId = @ptrCast(&T.getRowId),
};
// Métodos opcionales
if (@hasDecl(T, "isCellEditable")) {
vt.isCellEditable = @ptrCast(&T.isCellEditable);
}
if (@hasDecl(T, "getRowState")) {
vt.getRowState = @ptrCast(&T.getRowState);
}
if (@hasDecl(T, "invalidate")) {
vt.invalidate = @ptrCast(&T.invalidate);
}
break :blk vt;
};
return .{
.ptr = impl,
.vtable = &vtable,
};
}
// =============================================================================
// Renderizado de Scrollbars (FASE 6)
// =============================================================================
/// Parámetros para dibujar scrollbar vertical
pub const VerticalScrollbarParams = struct {
/// Posición X del track
track_x: i32,
/// Posición Y del track
track_y: i32,
/// Ancho del scrollbar
width: u32 = 12,
/// Altura del track
height: u32,
/// Número de elementos visibles
visible_count: usize,
/// Número total de elementos
total_count: usize,
/// Posición actual del scroll (0-based)
scroll_pos: usize,
/// Color del track (fondo)
track_color: Style.Color,
/// Color del thumb (control deslizante)
thumb_color: Style.Color,
};
/// Dibuja un scrollbar vertical.
/// Función genérica usada por AdvancedTable y VirtualAdvancedTable.
pub fn drawVerticalScrollbar(ctx: *Context, params: VerticalScrollbarParams) void {
if (params.total_count == 0 or params.visible_count >= params.total_count) return;
// Track (fondo)
ctx.pushCommand(Command.rect(
params.track_x,
params.track_y,
params.width,
params.height,
params.track_color,
));
// Calcular tamaño del thumb
const visible_ratio = @as(f32, @floatFromInt(params.visible_count)) /
@as(f32, @floatFromInt(params.total_count));
const thumb_h = @max(20, @as(u32, @intFromFloat(visible_ratio * @as(f32, @floatFromInt(params.height)))));
// Calcular posición del thumb
const max_scroll = params.total_count - params.visible_count;
const scroll_ratio = @as(f32, @floatFromInt(params.scroll_pos)) /
@as(f32, @floatFromInt(@max(1, max_scroll)));
const thumb_y_offset = @as(u32, @intFromFloat(scroll_ratio * @as(f32, @floatFromInt(params.height - thumb_h))));
// Thumb (control deslizante)
ctx.pushCommand(Command.rect(
params.track_x + 2,
params.track_y + @as(i32, @intCast(thumb_y_offset)),
params.width - 4,
thumb_h,
params.thumb_color,
));
}
/// Parámetros para dibujar scrollbar horizontal
pub const HorizontalScrollbarParams = struct {
/// Posición X del track
track_x: i32,
/// Posición Y del track
track_y: i32,
/// Ancho del track
width: u32,
/// Altura del scrollbar
height: u32 = 12,
/// Ancho visible del contenido
visible_width: u32,
/// Ancho total del contenido
total_width: u32,
/// Posición actual del scroll horizontal (pixels)
scroll_x: i32,
/// Máximo scroll horizontal (pixels)
max_scroll_x: i32,
/// Color del track (fondo)
track_color: Style.Color,
/// Color del thumb (control deslizante)
thumb_color: Style.Color,
};
/// Dibuja un scrollbar horizontal.
/// Función genérica usada por VirtualAdvancedTable.
pub fn drawHorizontalScrollbar(ctx: *Context, params: HorizontalScrollbarParams) void {
if (params.max_scroll_x <= 0) return;
// Track (fondo)
ctx.pushCommand(Command.rect(
params.track_x,
params.track_y,
params.width,
params.height,
params.track_color,
));
// Calcular tamaño del thumb
const visible_ratio = @as(f32, @floatFromInt(params.visible_width)) /
@as(f32, @floatFromInt(params.total_width));
const thumb_w = @max(20, @as(u32, @intFromFloat(visible_ratio * @as(f32, @floatFromInt(params.width)))));
// Calcular posición del thumb
const scroll_ratio = @as(f32, @floatFromInt(params.scroll_x)) /
@as(f32, @floatFromInt(params.max_scroll_x));
const thumb_x_offset = @as(u32, @intFromFloat(scroll_ratio * @as(f32, @floatFromInt(params.width - thumb_w))));
// Thumb (control deslizante)
ctx.pushCommand(Command.rect(
params.track_x + @as(i32, @intCast(thumb_x_offset)),
params.track_y + 2,
thumb_w,
params.height - 4,
params.thumb_color,
));
}
// =============================================================================
// 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);
}
test "calculateNextCell - basic navigation" {
// Tabla 3x4 (3 columnas, 4 filas)
// Celda (0,0) -> (0,1)
const r1 = calculateNextCell(0, 0, 3, 4, false);
try std.testing.expectEqual(@as(usize, 0), r1.row);
try std.testing.expectEqual(@as(usize, 1), r1.col);
try std.testing.expectEqual(TabNavigateResult.navigated, r1.result);
// Última columna -> primera columna de siguiente fila
const r2 = calculateNextCell(0, 2, 3, 4, false);
try std.testing.expectEqual(@as(usize, 1), r2.row);
try std.testing.expectEqual(@as(usize, 0), r2.col);
try std.testing.expectEqual(TabNavigateResult.navigated, r2.result);
// Última celda sin wrap -> tab_out
const r3 = calculateNextCell(3, 2, 3, 4, false);
try std.testing.expectEqual(TabNavigateResult.tab_out, r3.result);
// Última celda con wrap -> primera celda
const r4 = calculateNextCell(3, 2, 3, 4, true);
try std.testing.expectEqual(@as(usize, 0), r4.row);
try std.testing.expectEqual(@as(usize, 0), r4.col);
try std.testing.expectEqual(TabNavigateResult.navigated, r4.result);
}
test "calculatePrevCell - basic navigation" {
// Celda (0,2) -> (0,1)
const r1 = calculatePrevCell(0, 2, 3, 4, false);
try std.testing.expectEqual(@as(usize, 0), r1.row);
try std.testing.expectEqual(@as(usize, 1), r1.col);
try std.testing.expectEqual(TabNavigateResult.navigated, r1.result);
// Primera columna -> última columna de fila anterior
const r2 = calculatePrevCell(1, 0, 3, 4, false);
try std.testing.expectEqual(@as(usize, 0), r2.row);
try std.testing.expectEqual(@as(usize, 2), r2.col);
try std.testing.expectEqual(TabNavigateResult.navigated, r2.result);
// Primera celda sin wrap -> tab_out
const r3 = calculatePrevCell(0, 0, 3, 4, false);
try std.testing.expectEqual(TabNavigateResult.tab_out, r3.result);
// Primera celda con wrap -> última celda
const r4 = calculatePrevCell(0, 0, 3, 4, true);
try std.testing.expectEqual(@as(usize, 3), r4.row);
try std.testing.expectEqual(@as(usize, 2), r4.col);
try std.testing.expectEqual(TabNavigateResult.navigated, r4.result);
}
test "toggleSort" {
// Sin ordenación -> ascendente en columna 2
const r1 = toggleSort(null, .none, 2);
try std.testing.expectEqual(@as(?usize, 2), r1.column);
try std.testing.expectEqual(SortDirection.ascending, r1.direction);
// Ascendente en columna 2 -> descendente
const r2 = toggleSort(2, .ascending, 2);
try std.testing.expectEqual(@as(?usize, 2), r2.column);
try std.testing.expectEqual(SortDirection.descending, r2.direction);
// Descendente -> none (columna null)
const r3 = toggleSort(2, .descending, 2);
try std.testing.expectEqual(@as(?usize, null), r3.column);
try std.testing.expectEqual(SortDirection.none, r3.direction);
// Click en columna diferente -> ascendente en nueva columna
const r4 = toggleSort(2, .ascending, 5);
try std.testing.expectEqual(@as(?usize, 5), r4.column);
try std.testing.expectEqual(SortDirection.ascending, r4.direction);
}