zcatgui/src/widgets/table_core.zig
reugenio 27b69cfcde refactor(table_core): Move Tab navigation logic to shared module (Norma #7 DRY)
ANTES: calculateNextCell/calculatePrevCell duplicados en:
  - AdvancedTableState
  - VirtualAdvancedTableState

AHORA: Lógica común en table_core.zig:
  - calculateNextCell() - calcula siguiente celda (Tab)
  - calculatePrevCell() - calcula celda anterior (Shift+Tab)
  - toggleSort() - alterna ordenación de columna
  - TabNavigateResult, CellPosition, SortDirection, SortToggleResult

Ambos widgets usan table_core, adaptando el resultado a su modelo:
  - AdvancedTable: selected_row/selected_col (índices)
  - VirtualAdvancedTable: selected_id + active_col (ID + columna)

Tests añadidos para calculateNextCell, calculatePrevCell, toggleSort
2025-12-27 01:49:56 +01:00

642 lines
22 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.
const std = @import("std");
const Context = @import("../core/context.zig").Context;
const Command = @import("../core/command.zig");
const Layout = @import("../core/layout.zig");
const Style = @import("../core/style.zig");
// =============================================================================
// Tipos comunes
// =============================================================================
/// Colores para renderizado de tabla
pub const TableColors = struct {
// Fondos
background: Style.Color = Style.Color.rgb(30, 30, 35),
row_normal: Style.Color = Style.Color.rgb(35, 35, 40),
row_alternate: Style.Color = Style.Color.rgb(40, 40, 45),
row_hover: Style.Color = Style.Color.rgb(50, 50, 60),
selected_row: Style.Color = Style.Color.rgb(0, 90, 180),
selected_row_unfocus: Style.Color = Style.Color.rgb(60, 60, 70),
// Celda activa
selected_cell: Style.Color = Style.Color.rgb(100, 150, 255),
selected_cell_unfocus: Style.Color = Style.Color.rgb(80, 80, 90),
// Edición
cell_editing_bg: Style.Color = Style.Color.rgb(255, 255, 255),
cell_editing_border: Style.Color = Style.Color.rgb(0, 120, 215),
cell_editing_text: Style.Color = Style.Color.rgb(0, 0, 0),
// Header
header_bg: Style.Color = Style.Color.rgb(45, 45, 50),
header_fg: Style.Color = Style.Color.rgb(200, 200, 200),
// Texto
text_normal: Style.Color = Style.Color.rgb(220, 220, 220),
text_selected: Style.Color = Style.Color.rgb(255, 255, 255),
text_placeholder: Style.Color = Style.Color.rgb(128, 128, 128),
// Bordes
border: Style.Color = Style.Color.rgb(60, 60, 65),
focus_ring: Style.Color = Style.Color.rgb(0, 120, 215),
};
/// Información de una celda para renderizado
pub const CellRenderInfo = struct {
/// Texto a mostrar
text: []const u8,
/// Posición X de la celda
x: i32,
/// Ancho de la celda
width: u32,
/// Es la celda actualmente seleccionada
is_selected: bool = false,
/// Es editable
is_editable: bool = true,
/// Alineación del texto (0=left, 1=center, 2=right)
text_align: u2 = 0,
};
/// Estado de edición para renderizado
pub const EditState = struct {
/// Está en modo edición
editing: bool = false,
/// Fila en edición
edit_row: i32 = -1,
/// Columna en edición
edit_col: i32 = -1,
/// Buffer de texto actual
edit_text: []const u8 = "",
/// Posición del cursor
edit_cursor: usize = 0,
};
/// Estado de doble-click
pub const DoubleClickState = struct {
last_click_time: u64 = 0,
last_click_row: 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,
colors: *const TableColors,
) void {
// Fondo blanco
ctx.pushCommand(Command.rect(x, y, width, height, colors.cell_editing_bg));
// Borde azul
ctx.pushCommand(Command.rectOutline(x, y, width, height, colors.cell_editing_border));
// Texto
const text_y = y + @as(i32, @intCast((height -| 16) / 2));
const text_to_show = if (edit_text.len > 0) edit_text else "";
ctx.pushCommand(Command.text(x + 4, text_y, text_to_show, colors.cell_editing_text));
// Cursor parpadeante (simplificado: siempre visible)
// Calcular posición X del cursor basado en caracteres
const cursor_x = x + 4 + @as(i32, @intCast(cursor_pos * 8)); // Asumiendo fuente monospace 8px
ctx.pushCommand(Command.rect(cursor_x, text_y, 2, 16, colors.cell_editing_border));
}
/// Dibuja el texto de una celda
pub fn drawCellText(
ctx: *Context,
x: i32,
y: i32,
width: u32,
height: u32,
text: []const u8,
color: Style.Color,
text_align: u2,
) void {
const text_y = y + @as(i32, @intCast((height -| 16) / 2));
const text_x = switch (text_align) {
0 => x + 4, // Left
1 => x + @as(i32, @intCast(width / 2)) - @as(i32, @intCast(text.len * 4)), // Center (aprox)
2 => x + @as(i32, @intCast(width)) - @as(i32, @intCast(text.len * 8 + 4)), // Right
3 => x + 4, // Default left
};
ctx.pushCommand(Command.text(text_x, text_y, text, color));
}
/// Detecta si un click es doble-click
pub fn detectDoubleClick(
state: *DoubleClickState,
current_time: u64,
row: 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
// =============================================================================
/// Resultado de procesar teclado en modo edición
pub const EditKeyboardResult = struct {
/// Se confirmó la edición (Enter)
committed: bool = false,
/// Se canceló la edición (Escape)
cancelled: bool = false,
/// Se revirtió al valor original (primer Escape)
reverted: bool = false,
/// Se debe navegar a siguiente celda (Tab)
navigate_next: bool = false,
/// Se debe navegar a celda anterior (Shift+Tab)
navigate_prev: bool = false,
/// El buffer de edición cambió
text_changed: bool = false,
};
/// Procesa teclado en modo edición
/// Modifica edit_buffer, edit_len, edit_cursor según las teclas
pub fn handleEditingKeyboard(
ctx: *Context,
edit_buffer: []u8,
edit_len: *usize,
edit_cursor: *usize,
escape_count: *u8,
original_text: ?[]const u8,
) EditKeyboardResult {
var result = EditKeyboardResult{};
// Escape: cancelar o revertir
if (ctx.input.keyPressed(.escape)) {
escape_count.* += 1;
if (escape_count.* >= 2 or original_text == null) {
result.cancelled = true;
} else {
// Revertir al valor original
if (original_text) |orig| {
const len = @min(orig.len, edit_buffer.len);
@memcpy(edit_buffer[0..len], orig[0..len]);
edit_len.* = len;
edit_cursor.* = len;
result.reverted = true;
}
}
return result;
}
// Reset escape count en cualquier otra tecla
escape_count.* = 0;
// Enter: confirmar
if (ctx.input.keyPressed(.enter)) {
result.committed = true;
return result;
}
// Tab: confirmar y navegar
if (ctx.input.keyPressed(.tab)) {
result.committed = true;
if (ctx.input.modifiers.shift) {
result.navigate_prev = true;
} else {
result.navigate_next = true;
}
return result;
}
// Movimiento del cursor
if (ctx.input.keyPressed(.left)) {
if (edit_cursor.* > 0) edit_cursor.* -= 1;
return result;
}
if (ctx.input.keyPressed(.right)) {
if (edit_cursor.* < edit_len.*) edit_cursor.* += 1;
return result;
}
if (ctx.input.keyPressed(.home)) {
edit_cursor.* = 0;
return result;
}
if (ctx.input.keyPressed(.end)) {
edit_cursor.* = edit_len.*;
return result;
}
// Backspace
if (ctx.input.keyPressed(.backspace)) {
if (edit_cursor.* > 0) {
// Shift characters left
var i: usize = edit_cursor.* - 1;
while (i < edit_len.* - 1) : (i += 1) {
edit_buffer[i] = edit_buffer[i + 1];
}
edit_len.* -= 1;
edit_cursor.* -= 1;
result.text_changed = true;
}
return result;
}
// Delete
if (ctx.input.keyPressed(.delete)) {
if (edit_cursor.* < edit_len.*) {
var i: usize = edit_cursor.*;
while (i < edit_len.* - 1) : (i += 1) {
edit_buffer[i] = edit_buffer[i + 1];
}
edit_len.* -= 1;
result.text_changed = true;
}
return result;
}
// Character input
if (ctx.input.text_input_len > 0) {
const text = ctx.input.text_input[0..ctx.input.text_input_len];
for (text) |ch| {
if (ch >= 32 and ch < 127) {
if (edit_len.* < edit_buffer.len - 1) {
// Shift characters right
var i: usize = edit_len.*;
while (i > edit_cursor.*) : (i -= 1) {
edit_buffer[i] = edit_buffer[i - 1];
}
edit_buffer[edit_cursor.*] = ch;
edit_len.* += 1;
edit_cursor.* += 1;
result.text_changed = true;
}
}
}
}
return result;
}
// =============================================================================
// Utilidades
// =============================================================================
/// Mezcla dos colores con un factor alpha
pub fn blendColor(base: Style.Color, overlay: Style.Color, alpha: f32) Style.Color {
const inv_alpha = 1.0 - alpha;
return Style.Color.rgba(
@intFromFloat(@as(f32, @floatFromInt(base.r)) * inv_alpha + @as(f32, @floatFromInt(overlay.r)) * alpha),
@intFromFloat(@as(f32, @floatFromInt(base.g)) * inv_alpha + @as(f32, @floatFromInt(overlay.g)) * alpha),
@intFromFloat(@as(f32, @floatFromInt(base.b)) * inv_alpha + @as(f32, @floatFromInt(overlay.b)) * alpha),
base.a,
);
}
/// Compara strings case-insensitive para búsqueda incremental
pub fn startsWithIgnoreCase(haystack: []const u8, needle: []const u8) bool {
if (needle.len > haystack.len) return false;
if (needle.len == 0) return true;
for (needle, 0..) |needle_char, i| {
const haystack_char = haystack[i];
const needle_lower = if (needle_char >= 'A' and needle_char <= 'Z')
needle_char + 32
else
needle_char;
const haystack_lower = if (haystack_char >= 'A' and haystack_char <= 'Z')
haystack_char + 32
else
haystack_char;
if (needle_lower != haystack_lower) return false;
}
return true;
}
// =============================================================================
// 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 };
}
// =============================================================================
// 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,
};
}
// =============================================================================
// 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);
}