zcatgui/src/widgets/table_core.zig
reugenio 08b10486d2 feat(table_core,virtual_table): Propagate injection info on commit
RowCommitInfo now includes:
- is_injected: bool (was this an injected row)
- injection_index: ?usize (where it was injected)

buildCommitInfo() copies these from RowEditBuffer.

VirtualAdvancedTable sets result.injection_committed and
result.injection_row_idx when committing an injected row,
for both Tab navigation and selection change commits.

This allows the panel to know when to INSERT vs UPDATE and
handle the special case of locally injected rows.
2025-12-28 02:27:44 +01:00

2138 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,
/// True si era una fila inyectada (Ctrl+N entre líneas)
is_injected: bool = false,
/// Índice visual donde fue inyectada (válido si is_injected = true)
injection_index: ?usize = null,
};
/// 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,
.is_injected = buffer.is_injected,
.injection_index = buffer.injection_index,
};
}
/// 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);
}