refactor(tables): Add table_core.zig with shared rendering functions

- New module: table_core.zig with common table rendering logic
- drawCellActiveIndicator(): visual indicator for selected cell  
- detectDoubleClick(): timing-based double-click detection
- handleEditingKeyboard(): common keyboard handling for editing
- blendColor(), startsWithIgnoreCase(): utilities

VirtualAdvancedTable now uses table_core:
- Active cell indicator in drawRows (visible highlight on selected cell)
- Double-click detection in handleMouseClick
- Added state fields: last_click_time, last_click_row, last_click_col

AdvancedTable changes:
- Improved cell active indicator (alpha 0.35, double border)
- Added double-click fields to state
- Space starts editing with empty value
- Alphanumeric keys start editing in editable cells
This commit is contained in:
reugenio 2025-12-26 17:48:49 +01:00
parent 65f6782d24
commit 47fc5b28f7
7 changed files with 1063 additions and 63 deletions

View file

@ -403,12 +403,17 @@ fn drawRow(
const is_selected_cell = is_selected_row and table_state.selected_col == @as(i32, @intCast(col_idx)); const is_selected_cell = is_selected_row and table_state.selected_col == @as(i32, @intCast(col_idx));
const cell_clicked = cell_rect.contains(mouse.x, mouse.y) and ctx.input.mousePressed(.left); const cell_clicked = cell_rect.contains(mouse.x, mouse.y) and ctx.input.mousePressed(.left);
// Cell indicator for selected cell (outline instead of solid fill) // Cell indicator for selected cell - más visible que antes
if (is_selected_cell) { if (is_selected_cell and has_focus) {
// Subtle background tint // Fondo con tinte más visible (0.35 en lugar de 0.15)
ctx.pushCommand(Command.rect(col_x, bounds.y, col.width, config.row_height, blendColor(row_bg, colors.selected_cell, 0.15))); ctx.pushCommand(Command.rect(col_x, bounds.y, col.width, config.row_height, blendColor(row_bg, colors.selected_cell, 0.35)));
// Border outline // Borde doble para mayor visibilidad
ctx.pushCommand(Command.rectOutline(col_x, bounds.y, col.width, config.row_height, colors.selected_cell)); ctx.pushCommand(Command.rectOutline(col_x, bounds.y, col.width, config.row_height, colors.selected_cell));
ctx.pushCommand(Command.rectOutline(col_x + 1, bounds.y + 1, col.width -| 2, config.row_height -| 2, colors.selected_cell));
} else if (is_selected_cell) {
// Sin focus: indicación más sutil
ctx.pushCommand(Command.rect(col_x, bounds.y, col.width, config.row_height, blendColor(row_bg, colors.selected_cell, 0.15)));
ctx.pushCommand(Command.rectOutline(col_x, bounds.y, col.width, config.row_height, colors.border));
} }
// Get cell value // Get cell value
@ -427,14 +432,41 @@ fn drawRow(
ctx.pushCommand(Command.text(col_x + 4, text_y, text, text_color)); ctx.pushCommand(Command.text(col_x + 4, text_y, text, text_color));
} }
// Handle cell click // Handle cell click and double-click
if (cell_clicked) { if (cell_clicked) {
const current_time = ctx.current_time_ms;
const same_cell = table_state.last_click_row == @as(i32, @intCast(row_idx)) and
table_state.last_click_col == @as(i32, @intCast(col_idx));
const time_diff = current_time -| table_state.last_click_time;
const is_double_click = same_cell and time_diff < table_state.double_click_threshold_ms;
if (is_double_click and config.allow_edit and col.editable and !table_state.editing) {
// Double-click: start editing
if (table_state.getRow(row_idx)) |row| {
const value = row.get(col.name);
var format_buf: [128]u8 = undefined;
const edit_text = value.format(&format_buf);
table_state.startEditing(edit_text);
table_state.original_value = value;
result.edit_started = true;
}
// Reset click tracking
table_state.last_click_time = 0;
table_state.last_click_row = -1;
table_state.last_click_col = -1;
} else {
// Single click: select cell
if (!is_selected_cell) { if (!is_selected_cell) {
table_state.selectCell(row_idx, col_idx); table_state.selectCell(row_idx, col_idx);
result.selection_changed = true; result.selection_changed = true;
result.selected_row = row_idx; result.selected_row = row_idx;
result.selected_col = col_idx; result.selected_col = col_idx;
} }
// Update click tracking for potential double-click
table_state.last_click_time = current_time;
table_state.last_click_row = @intCast(row_idx);
table_state.last_click_col = @intCast(col_idx);
}
} }
col_x += @as(i32, @intCast(col.width)); col_x += @as(i32, @intCast(col.width));
@ -727,23 +759,33 @@ fn handleKeyboard(
} }
} }
// Start editing with F2 or Enter // Start editing with F2, Enter, or Space (empty)
if (config.allow_edit and (ctx.input.keyPressed(.f2) or ctx.input.keyPressed(.enter))) { if (config.allow_edit) {
const start_edit_key = ctx.input.keyPressed(.f2) or ctx.input.keyPressed(.enter);
const start_empty = ctx.input.keyPressed(.space);
if (start_edit_key or start_empty) {
if (table_state.selected_row >= 0 and table_state.selected_col >= 0) { if (table_state.selected_row >= 0 and table_state.selected_col >= 0) {
const col_idx: usize = @intCast(table_state.selected_col); const col_idx: usize = @intCast(table_state.selected_col);
if (col_idx < table_schema.columns.len and table_schema.columns[col_idx].editable) { if (col_idx < table_schema.columns.len and table_schema.columns[col_idx].editable) {
// Get current value
if (table_state.getRow(@intCast(table_state.selected_row))) |row| { if (table_state.getRow(@intCast(table_state.selected_row))) |row| {
const value = row.get(table_schema.columns[col_idx].name); const value = row.get(table_schema.columns[col_idx].name);
if (start_empty) {
// Espacio: empezar vacío
table_state.startEditing("");
} else {
// F2/Enter: empezar con valor actual
var format_buf: [128]u8 = undefined; var format_buf: [128]u8 = undefined;
const text = value.format(&format_buf); const text = value.format(&format_buf);
table_state.startEditing(text); table_state.startEditing(text);
}
table_state.original_value = value; table_state.original_value = value;
result.edit_started = true; result.edit_started = true;
} }
} }
} }
} }
}
// Row operations (if allowed) // Row operations (if allowed)
if (config.allow_row_operations) { if (config.allow_row_operations) {
@ -785,16 +827,40 @@ fn handleKeyboard(
result.selection_changed = true; result.selection_changed = true;
} }
// Incremental search (type-to-search) // Type-to-edit or incremental search
// Only when not editing and no modifiers pressed // Behavior: If current cell is editable start editing with typed char
// If not editable incremental search in first column
if (!ctx.input.modifiers.ctrl and !ctx.input.modifiers.alt) { if (!ctx.input.modifiers.ctrl and !ctx.input.modifiers.alt) {
if (ctx.input.text_input_len > 0) { if (ctx.input.text_input_len > 0) {
const text = ctx.input.text_input[0..ctx.input.text_input_len]; const text = ctx.input.text_input[0..ctx.input.text_input_len];
// Check if current cell is editable
const current_cell_editable = blk: {
if (!config.allow_edit) break :blk false;
if (table_state.selected_row < 0 or table_state.selected_col < 0) break :blk false;
const col_idx: usize = @intCast(table_state.selected_col);
if (col_idx >= table_schema.columns.len) break :blk false;
break :blk table_schema.columns[col_idx].editable;
};
if (current_cell_editable) {
// Start editing with first typed character
if (text.len > 0 and text[0] >= 32 and text[0] < 127) {
if (table_state.getRow(@intCast(table_state.selected_row))) |row| {
const col_idx: usize = @intCast(table_state.selected_col);
const value = row.get(table_schema.columns[col_idx].name);
// Start with the typed text (replaces content)
table_state.startEditing(text);
table_state.original_value = value;
result.edit_started = true;
}
}
} else {
// Incremental search (type-to-search) in first column
for (text) |char| { for (text) |char| {
if (char >= 32 and char < 127) { // Printable ASCII if (char >= 32 and char < 127) {
const search_term = table_state.addSearchChar(char, ctx.current_time_ms); const search_term = table_state.addSearchChar(char, ctx.current_time_ms);
// Search for matching row in first column
if (search_term.len > 0 and table_schema.columns.len > 0) { if (search_term.len > 0 and table_schema.columns.len > 0) {
const first_col_name = table_schema.columns[0].name; const first_col_name = table_schema.columns[0].name;
const start_row: usize = if (table_state.selected_row >= 0) const start_row: usize = if (table_state.selected_row >= 0)
@ -842,6 +908,7 @@ fn handleKeyboard(
} }
} }
} }
}
} }
fn handleEditingKeyboard( fn handleEditingKeyboard(

View file

@ -110,6 +110,22 @@ pub const AdvancedTableState = struct {
/// Escape count (1 = revert, 2 = cancel) /// Escape count (1 = revert, 2 = cancel)
escape_count: u8 = 0, escape_count: u8 = 0,
// =========================================================================
// Double-click detection
// =========================================================================
/// Time of last click (ms)
last_click_time: u64 = 0,
/// Row of last click
last_click_row: i32 = -1,
/// Column of last click
last_click_col: i32 = -1,
/// Double-click threshold in ms
double_click_threshold_ms: u64 = 400,
// ========================================================================= // =========================================================================
// Sorting // Sorting
// ========================================================================= // =========================================================================

View file

@ -0,0 +1,422 @@
//! Table Core - Funciones compartidas para renderizado de tablas
//!
//! Este módulo contiene la lógica común de renderizado utilizada por:
//! - AdvancedTable (datos en memoria)
//! - VirtualAdvancedTable (datos paginados desde DataProvider)
//!
//! Principio: Una sola implementación de UI, dos estrategias de datos.
const std = @import("std");
const Context = @import("../core/context.zig").Context;
const Command = @import("../core/command.zig");
const Layout = @import("../core/layout.zig");
const Style = @import("../core/style.zig");
// =============================================================================
// Tipos comunes
// =============================================================================
/// Colores para renderizado de tabla
pub const TableColors = struct {
// Fondos
background: Style.Color = Style.Color.rgb(30, 30, 35),
row_normal: Style.Color = Style.Color.rgb(35, 35, 40),
row_alternate: Style.Color = Style.Color.rgb(40, 40, 45),
row_hover: Style.Color = Style.Color.rgb(50, 50, 60),
selected_row: Style.Color = Style.Color.rgb(0, 90, 180),
selected_row_unfocus: Style.Color = Style.Color.rgb(60, 60, 70),
// Celda activa
selected_cell: Style.Color = Style.Color.rgb(100, 150, 255),
selected_cell_unfocus: Style.Color = Style.Color.rgb(80, 80, 90),
// Edición
cell_editing_bg: Style.Color = Style.Color.rgb(255, 255, 255),
cell_editing_border: Style.Color = Style.Color.rgb(0, 120, 215),
cell_editing_text: Style.Color = Style.Color.rgb(0, 0, 0),
// Header
header_bg: Style.Color = Style.Color.rgb(45, 45, 50),
header_fg: Style.Color = Style.Color.rgb(200, 200, 200),
// Texto
text_normal: Style.Color = Style.Color.rgb(220, 220, 220),
text_selected: Style.Color = Style.Color.rgb(255, 255, 255),
text_placeholder: Style.Color = Style.Color.rgb(128, 128, 128),
// Bordes
border: Style.Color = Style.Color.rgb(60, 60, 65),
focus_ring: Style.Color = Style.Color.rgb(0, 120, 215),
};
/// Información de una celda para renderizado
pub const CellRenderInfo = struct {
/// Texto a mostrar
text: []const u8,
/// Posición X de la celda
x: i32,
/// Ancho de la celda
width: u32,
/// Es la celda actualmente seleccionada
is_selected: bool = false,
/// Es editable
is_editable: bool = true,
/// Alineación del texto (0=left, 1=center, 2=right)
text_align: u2 = 0,
};
/// Estado de edición para renderizado
pub const EditState = struct {
/// Está en modo edición
editing: bool = false,
/// Fila en edición
edit_row: i32 = -1,
/// Columna en edición
edit_col: i32 = -1,
/// Buffer de texto actual
edit_text: []const u8 = "",
/// Posición del cursor
edit_cursor: usize = 0,
};
/// Estado de doble-click
pub const DoubleClickState = struct {
last_click_time: u64 = 0,
last_click_row: i32 = -1,
last_click_col: i32 = -1,
threshold_ms: u64 = 400,
};
/// Resultado de procesar click en celda
pub const CellClickResult = struct {
/// Hubo click
clicked: bool = false,
/// Fue doble-click
double_click: bool = false,
/// Fila clickeada
row: usize = 0,
/// Columna clickeada
col: usize = 0,
};
// =============================================================================
// Funciones de renderizado
// =============================================================================
/// Dibuja el indicador de celda activa (fondo + borde)
/// Llamar ANTES de dibujar el texto de la celda
pub fn drawCellActiveIndicator(
ctx: *Context,
x: i32,
y: i32,
width: u32,
height: u32,
row_bg: Style.Color,
colors: *const TableColors,
has_focus: bool,
) void {
if (has_focus) {
// Con focus: fondo más visible + borde doble
const tinted_bg = blendColor(row_bg, colors.selected_cell, 0.35);
ctx.pushCommand(Command.rect(x, y, width, height, tinted_bg));
ctx.pushCommand(Command.rectOutline(x, y, width, height, colors.selected_cell));
ctx.pushCommand(Command.rectOutline(x + 1, y + 1, width -| 2, height -| 2, colors.selected_cell));
} else {
// Sin focus: indicación más sutil
const tinted_bg = blendColor(row_bg, colors.selected_cell_unfocus, 0.15);
ctx.pushCommand(Command.rect(x, y, width, height, tinted_bg));
ctx.pushCommand(Command.rectOutline(x, y, width, height, colors.border));
}
}
/// Dibuja el overlay de edición de celda
pub fn drawEditingOverlay(
ctx: *Context,
x: i32,
y: i32,
width: u32,
height: u32,
edit_text: []const u8,
cursor_pos: usize,
colors: *const TableColors,
) void {
// Fondo blanco
ctx.pushCommand(Command.rect(x, y, width, height, colors.cell_editing_bg));
// Borde azul
ctx.pushCommand(Command.rectOutline(x, y, width, height, colors.cell_editing_border));
// Texto
const text_y = y + @as(i32, @intCast((height -| 16) / 2));
const text_to_show = if (edit_text.len > 0) edit_text else "";
ctx.pushCommand(Command.text(x + 4, text_y, text_to_show, colors.cell_editing_text));
// Cursor parpadeante (simplificado: siempre visible)
// Calcular posición X del cursor basado en caracteres
const cursor_x = x + 4 + @as(i32, @intCast(cursor_pos * 8)); // Asumiendo fuente monospace 8px
ctx.pushCommand(Command.rect(cursor_x, text_y, 2, 16, colors.cell_editing_border));
}
/// Dibuja el texto de una celda
pub fn drawCellText(
ctx: *Context,
x: i32,
y: i32,
width: u32,
height: u32,
text: []const u8,
color: Style.Color,
text_align: u2,
) void {
const text_y = y + @as(i32, @intCast((height -| 16) / 2));
const text_x = switch (text_align) {
0 => x + 4, // Left
1 => x + @as(i32, @intCast(width / 2)) - @as(i32, @intCast(text.len * 4)), // Center (aprox)
2 => x + @as(i32, @intCast(width)) - @as(i32, @intCast(text.len * 8 + 4)), // Right
3 => x + 4, // Default left
};
ctx.pushCommand(Command.text(text_x, text_y, text, color));
}
/// Detecta si un click es doble-click
pub fn detectDoubleClick(
state: *DoubleClickState,
current_time: u64,
row: i32,
col: i32,
) bool {
const same_cell = state.last_click_row == row and state.last_click_col == col;
const time_diff = current_time -| state.last_click_time;
const is_double = same_cell and time_diff < state.threshold_ms;
if (is_double) {
// Reset para no detectar triple-click
state.last_click_time = 0;
state.last_click_row = -1;
state.last_click_col = -1;
} else {
// Guardar para próximo click
state.last_click_time = current_time;
state.last_click_row = row;
state.last_click_col = col;
}
return is_double;
}
// =============================================================================
// Manejo de teclado para edición
// =============================================================================
/// Resultado de procesar teclado en modo edición
pub const EditKeyboardResult = struct {
/// Se confirmó la edición (Enter)
committed: bool = false,
/// Se canceló la edición (Escape)
cancelled: bool = false,
/// Se revirtió al valor original (primer Escape)
reverted: bool = false,
/// Se debe navegar a siguiente celda (Tab)
navigate_next: bool = false,
/// Se debe navegar a celda anterior (Shift+Tab)
navigate_prev: bool = false,
/// El buffer de edición cambió
text_changed: bool = false,
};
/// Procesa teclado en modo edición
/// Modifica edit_buffer, edit_len, edit_cursor según las teclas
pub fn handleEditingKeyboard(
ctx: *Context,
edit_buffer: []u8,
edit_len: *usize,
edit_cursor: *usize,
escape_count: *u8,
original_text: ?[]const u8,
) EditKeyboardResult {
var result = EditKeyboardResult{};
// Escape: cancelar o revertir
if (ctx.input.keyPressed(.escape)) {
escape_count.* += 1;
if (escape_count.* >= 2 or original_text == null) {
result.cancelled = true;
} else {
// Revertir al valor original
if (original_text) |orig| {
const len = @min(orig.len, edit_buffer.len);
@memcpy(edit_buffer[0..len], orig[0..len]);
edit_len.* = len;
edit_cursor.* = len;
result.reverted = true;
}
}
return result;
}
// Reset escape count en cualquier otra tecla
escape_count.* = 0;
// Enter: confirmar
if (ctx.input.keyPressed(.enter)) {
result.committed = true;
return result;
}
// Tab: confirmar y navegar
if (ctx.input.keyPressed(.tab)) {
result.committed = true;
if (ctx.input.modifiers.shift) {
result.navigate_prev = true;
} else {
result.navigate_next = true;
}
return result;
}
// Movimiento del cursor
if (ctx.input.keyPressed(.left)) {
if (edit_cursor.* > 0) edit_cursor.* -= 1;
return result;
}
if (ctx.input.keyPressed(.right)) {
if (edit_cursor.* < edit_len.*) edit_cursor.* += 1;
return result;
}
if (ctx.input.keyPressed(.home)) {
edit_cursor.* = 0;
return result;
}
if (ctx.input.keyPressed(.end)) {
edit_cursor.* = edit_len.*;
return result;
}
// Backspace
if (ctx.input.keyPressed(.backspace)) {
if (edit_cursor.* > 0) {
// Shift characters left
var i: usize = edit_cursor.* - 1;
while (i < edit_len.* - 1) : (i += 1) {
edit_buffer[i] = edit_buffer[i + 1];
}
edit_len.* -= 1;
edit_cursor.* -= 1;
result.text_changed = true;
}
return result;
}
// Delete
if (ctx.input.keyPressed(.delete)) {
if (edit_cursor.* < edit_len.*) {
var i: usize = edit_cursor.*;
while (i < edit_len.* - 1) : (i += 1) {
edit_buffer[i] = edit_buffer[i + 1];
}
edit_len.* -= 1;
result.text_changed = true;
}
return result;
}
// Character input
if (ctx.input.text_input_len > 0) {
const text = ctx.input.text_input[0..ctx.input.text_input_len];
for (text) |ch| {
if (ch >= 32 and ch < 127) {
if (edit_len.* < edit_buffer.len - 1) {
// Shift characters right
var i: usize = edit_len.*;
while (i > edit_cursor.*) : (i -= 1) {
edit_buffer[i] = edit_buffer[i - 1];
}
edit_buffer[edit_cursor.*] = ch;
edit_len.* += 1;
edit_cursor.* += 1;
result.text_changed = true;
}
}
}
}
return result;
}
// =============================================================================
// Utilidades
// =============================================================================
/// Mezcla dos colores con un factor alpha
pub fn blendColor(base: Style.Color, overlay: Style.Color, alpha: f32) Style.Color {
const inv_alpha = 1.0 - alpha;
return Style.Color.rgba(
@intFromFloat(@as(f32, @floatFromInt(base.r)) * inv_alpha + @as(f32, @floatFromInt(overlay.r)) * alpha),
@intFromFloat(@as(f32, @floatFromInt(base.g)) * inv_alpha + @as(f32, @floatFromInt(overlay.g)) * alpha),
@intFromFloat(@as(f32, @floatFromInt(base.b)) * inv_alpha + @as(f32, @floatFromInt(overlay.b)) * alpha),
base.a,
);
}
/// Compara strings case-insensitive para búsqueda incremental
pub fn startsWithIgnoreCase(haystack: []const u8, needle: []const u8) bool {
if (needle.len > haystack.len) return false;
if (needle.len == 0) return true;
for (needle, 0..) |needle_char, i| {
const haystack_char = haystack[i];
const needle_lower = if (needle_char >= 'A' and needle_char <= 'Z')
needle_char + 32
else
needle_char;
const haystack_lower = if (haystack_char >= 'A' and haystack_char <= 'Z')
haystack_char + 32
else
haystack_char;
if (needle_lower != haystack_lower) return false;
}
return true;
}
// =============================================================================
// Tests
// =============================================================================
test "blendColor" {
const white = Style.Color.rgb(255, 255, 255);
const black = Style.Color.rgb(0, 0, 0);
const gray = blendColor(white, black, 0.5);
try std.testing.expectEqual(@as(u8, 127), gray.r);
try std.testing.expectEqual(@as(u8, 127), gray.g);
try std.testing.expectEqual(@as(u8, 127), gray.b);
}
test "startsWithIgnoreCase" {
try std.testing.expect(startsWithIgnoreCase("Hello World", "Hello"));
try std.testing.expect(startsWithIgnoreCase("Hello World", "hello"));
try std.testing.expect(startsWithIgnoreCase("hello world", "HELLO"));
try std.testing.expect(startsWithIgnoreCase("anything", ""));
try std.testing.expect(!startsWithIgnoreCase("Hello", "World"));
try std.testing.expect(!startsWithIgnoreCase("Hi", "Hello World"));
}
test "detectDoubleClick" {
var state = DoubleClickState{};
// Primer click
const first = detectDoubleClick(&state, 1000, 0, 0);
try std.testing.expect(!first);
// Segundo click rápido en misma celda = doble click
const second = detectDoubleClick(&state, 1200, 0, 0);
try std.testing.expect(second);
// Tercer click (estado reseteado)
const third = detectDoubleClick(&state, 1400, 0, 0);
try std.testing.expect(!third);
}

422
src/widgets/table_core.zig Normal file
View file

@ -0,0 +1,422 @@
//! 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;
}
// =============================================================================
// Tests
// =============================================================================
test "blendColor" {
const white = Style.Color.rgb(255, 255, 255);
const black = Style.Color.rgb(0, 0, 0);
const gray = blendColor(white, black, 0.5);
try std.testing.expectEqual(@as(u8, 127), gray.r);
try std.testing.expectEqual(@as(u8, 127), gray.g);
try std.testing.expectEqual(@as(u8, 127), gray.b);
}
test "startsWithIgnoreCase" {
try std.testing.expect(startsWithIgnoreCase("Hello World", "Hello"));
try std.testing.expect(startsWithIgnoreCase("Hello World", "hello"));
try std.testing.expect(startsWithIgnoreCase("hello world", "HELLO"));
try std.testing.expect(startsWithIgnoreCase("anything", ""));
try std.testing.expect(!startsWithIgnoreCase("Hello", "World"));
try std.testing.expect(!startsWithIgnoreCase("Hi", "Hello World"));
}
test "detectDoubleClick" {
var state = DoubleClickState{};
// Primer click
const first = detectDoubleClick(&state, 1000, 0, 0);
try std.testing.expect(!first);
// Segundo click rápido en misma celda = doble click
const second = detectDoubleClick(&state, 1200, 0, 0);
try std.testing.expect(second);
// Tercer click (estado reseteado)
const third = detectDoubleClick(&state, 1400, 0, 0);
try std.testing.expect(!third);
}

View file

@ -27,6 +27,22 @@ pub const VirtualAdvancedTableState = struct {
/// Cuando el usuario navega con flechas o hace click, se actualiza /// Cuando el usuario navega con flechas o hace click, se actualiza
active_col: usize = 0, active_col: usize = 0,
// =========================================================================
// Double-click detection
// =========================================================================
/// Time of last click (ms)
last_click_time: u64 = 0,
/// Row of last click (global index)
last_click_row: i64 = -1,
/// Column of last click
last_click_col: i32 = -1,
/// Double-click threshold in ms
double_click_threshold_ms: u64 = 400,
// ========================================================================= // =========================================================================
// Scroll y ventana // Scroll y ventana
// ========================================================================= // =========================================================================

View file

@ -19,6 +19,7 @@ const Layout = @import("../../core/layout.zig");
const Style = @import("../../core/style.zig"); const Style = @import("../../core/style.zig");
const Input = @import("../../core/input.zig"); const Input = @import("../../core/input.zig");
const text_input = @import("../text_input.zig"); const text_input = @import("../text_input.zig");
const table_core = @import("../table_core.zig");
// Re-exports públicos // Re-exports públicos
pub const types = @import("types.zig"); pub const types = @import("types.zig");
@ -756,6 +757,29 @@ fn drawRows(
const col_end = x + @as(i32, @intCast(col.width)); const col_end = x + @as(i32, @intCast(col.width));
// Only draw if column is visible // Only draw if column is visible
if (col_end > content_bounds.x and x < content_bounds.x + @as(i32, @intCast(content_bounds.w))) { if (col_end > content_bounds.x and x < content_bounds.x + @as(i32, @intCast(content_bounds.w))) {
// Check if this is the active cell
const is_active_cell = is_selected and list_state.active_col == col_idx;
// Draw active cell indicator BEFORE text
if (is_active_cell) {
// Convertir colores del config a TableColors para table_core
const tc_colors = table_core.TableColors{
.selected_cell = colors.row_selected, // Usar color de selección pero más claro
.selected_cell_unfocus = colors.row_selected_unfocus,
.border = colors.border,
};
table_core.drawCellActiveIndicator(
ctx,
x,
row_y,
col.width,
row_h,
bg_color,
&tc_colors,
list_state.has_focus,
);
}
if (col_idx < row.values.len) { if (col_idx < row.values.len) {
const text_color = if (is_selected and list_state.has_focus) const text_color = if (is_selected and list_state.has_focus)
colors.text_selected colors.text_selected
@ -1000,8 +1024,6 @@ fn handleMouseClick(
list_state: *VirtualAdvancedTableState, list_state: *VirtualAdvancedTableState,
result: *VirtualAdvancedTableResult, result: *VirtualAdvancedTableResult,
) void { ) void {
_ = result;
const mouse = ctx.input.mousePos(); const mouse = ctx.input.mousePos();
// Content starts after FilterBar + Header // Content starts after FilterBar + Header
const content_y = bounds.y + @as(i32, @intCast(filter_bar_h)) + @as(i32, @intCast(header_h)); const content_y = bounds.y + @as(i32, @intCast(filter_bar_h)) + @as(i32, @intCast(header_h));
@ -1016,22 +1038,54 @@ fn handleMouseClick(
const data_idx = window_offset + screen_row; const data_idx = window_offset + screen_row;
if (data_idx < list_state.current_window.len) { if (data_idx < list_state.current_window.len) {
list_state.selectById(list_state.current_window[data_idx].id); const global_row = list_state.scroll_offset + screen_row;
// Detect which column was clicked // Detect which column was clicked
var clicked_col: usize = 0;
const relative_x = mouse.x - bounds.x + list_state.scroll_offset_x; const relative_x = mouse.x - bounds.x + list_state.scroll_offset_x;
var col_start: i32 = 0; var col_start: i32 = 0;
for (config.columns, 0..) |col, col_idx| { for (config.columns, 0..) |col, col_idx| {
const col_end = col_start + @as(i32, @intCast(col.width)); const col_end = col_start + @as(i32, @intCast(col.width));
if (relative_x >= col_start and relative_x < col_end) { if (relative_x >= col_start and relative_x < col_end) {
list_state.active_col = col_idx; clicked_col = col_idx;
break; break;
} }
col_start = col_end; col_start = col_end;
} }
// TODO: implement double click detection with timing // Double-click detection using table_core
// For now, double click is not supported var dc_state = table_core.DoubleClickState{
.last_click_time = list_state.last_click_time,
.last_click_row = list_state.last_click_row,
.last_click_col = list_state.last_click_col,
.threshold_ms = list_state.double_click_threshold_ms,
};
const is_double_click = table_core.detectDoubleClick(
&dc_state,
ctx.current_time_ms,
@intCast(global_row),
@intCast(clicked_col),
);
// Update state from detection
list_state.last_click_time = dc_state.last_click_time;
list_state.last_click_row = dc_state.last_click_row;
list_state.last_click_col = dc_state.last_click_col;
if (is_double_click and !list_state.isEditing()) {
// Double-click: start editing
const cell = types.CellId{ .row = global_row, .col = clicked_col };
// Signal to panel that editing was requested
// The panel should provide the current value via callback
result.edited_cell = cell;
result.double_clicked = true;
result.double_click_id = list_state.current_window[data_idx].id;
} else {
// Single click: select
list_state.selectById(list_state.current_window[data_idx].id);
list_state.active_col = clicked_col;
}
} }
} }
} }

View file

@ -63,6 +63,9 @@ pub const sheet = @import("sheet.zig");
pub const discloser = @import("discloser.zig"); pub const discloser = @import("discloser.zig");
pub const selectable = @import("selectable.zig"); pub const selectable = @import("selectable.zig");
// Core table utilities (shared between AdvancedTable and VirtualAdvancedTable)
pub const table_core = @import("table_core.zig");
// Advanced widgets // Advanced widgets
pub const advanced_table = @import("advanced_table/advanced_table.zig"); pub const advanced_table = @import("advanced_table/advanced_table.zig");