From 47fc5b28f747bb5f855857628ff0e3b715b3d02b Mon Sep 17 00:00:00 2001 From: reugenio Date: Fri, 26 Dec 2025 17:48:49 +0100 Subject: [PATCH] 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 --- src/widgets/advanced_table/advanced_table.zig | 181 +++++--- src/widgets/advanced_table/state.zig | 16 + src/widgets/table_core (conflicted).zig | 422 ++++++++++++++++++ src/widgets/table_core.zig | 422 ++++++++++++++++++ src/widgets/virtual_advanced_table/state.zig | 16 + .../virtual_advanced_table.zig | 66 ++- src/widgets/widgets.zig | 3 + 7 files changed, 1063 insertions(+), 63 deletions(-) create mode 100644 src/widgets/table_core (conflicted).zig create mode 100644 src/widgets/table_core.zig diff --git a/src/widgets/advanced_table/advanced_table.zig b/src/widgets/advanced_table/advanced_table.zig index cbe7ffb..6f364ae 100644 --- a/src/widgets/advanced_table/advanced_table.zig +++ b/src/widgets/advanced_table/advanced_table.zig @@ -403,12 +403,17 @@ fn drawRow( 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); - // Cell indicator for selected cell (outline instead of solid fill) - if (is_selected_cell) { - // Subtle background tint - ctx.pushCommand(Command.rect(col_x, bounds.y, col.width, config.row_height, blendColor(row_bg, colors.selected_cell, 0.15))); - // Border outline + // Cell indicator for selected cell - más visible que antes + if (is_selected_cell and has_focus) { + // 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.35))); + // 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 + 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 @@ -427,13 +432,40 @@ fn drawRow( 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 (!is_selected_cell) { - table_state.selectCell(row_idx, col_idx); - result.selection_changed = true; - result.selected_row = row_idx; - result.selected_col = col_idx; + 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) { + table_state.selectCell(row_idx, col_idx); + result.selection_changed = true; + result.selected_row = row_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); } } @@ -727,19 +759,29 @@ fn handleKeyboard( } } - // Start editing with F2 or Enter - if (config.allow_edit and (ctx.input.keyPressed(.f2) or ctx.input.keyPressed(.enter))) { - if (table_state.selected_row >= 0 and table_state.selected_col >= 0) { - const col_idx: usize = @intCast(table_state.selected_col); - 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| { - const value = row.get(table_schema.columns[col_idx].name); - var format_buf: [128]u8 = undefined; - const text = value.format(&format_buf); - table_state.startEditing(text); - table_state.original_value = value; - result.edit_started = true; + // Start editing with F2, Enter, or Space (empty) + 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) { + const col_idx: usize = @intCast(table_state.selected_col); + if (col_idx < table_schema.columns.len and table_schema.columns[col_idx].editable) { + if (table_state.getRow(@intCast(table_state.selected_row))) |row| { + 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; + const text = value.format(&format_buf); + table_state.startEditing(text); + } + table_state.original_value = value; + result.edit_started = true; + } } } } @@ -785,41 +827,51 @@ fn handleKeyboard( result.selection_changed = true; } - // Incremental search (type-to-search) - // Only when not editing and no modifiers pressed + // Type-to-edit or incremental search + // 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.text_input_len > 0) { const text = ctx.input.text_input[0..ctx.input.text_input_len]; - for (text) |char| { - if (char >= 32 and char < 127) { // Printable ASCII - 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) { - const first_col_name = table_schema.columns[0].name; - const start_row: usize = if (table_state.selected_row >= 0) - @intCast(table_state.selected_row) - else - 0; + // 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; + }; - var found_row: ?usize = null; + 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| { + if (char >= 32 and char < 127) { + const search_term = table_state.addSearchChar(char, ctx.current_time_ms); - // Search from current position to end - for (start_row..row_count) |row| { - if (table_state.getRowConst(row)) |row_data| { - const cell_value = row_data.get(first_col_name); - var format_buf: [128]u8 = undefined; - const cell_text = cell_value.format(&format_buf); - if (startsWithIgnoreCase(cell_text, search_term)) { - found_row = row; - break; - } - } - } + if (search_term.len > 0 and table_schema.columns.len > 0) { + const first_col_name = table_schema.columns[0].name; + const start_row: usize = if (table_state.selected_row >= 0) + @intCast(table_state.selected_row) + else + 0; - // Wrap to beginning if not found - if (found_row == null and start_row > 0) { - for (0..start_row) |row| { + var found_row: ?usize = null; + + // Search from current position to end + for (start_row..row_count) |row| { if (table_state.getRowConst(row)) |row_data| { const cell_value = row_data.get(first_col_name); var format_buf: [128]u8 = undefined; @@ -830,12 +882,27 @@ fn handleKeyboard( } } } - } - // Move selection if found - if (found_row) |row_idx| { - table_state.selectCell(row_idx, @intCast(@max(0, table_state.selected_col))); - result.selection_changed = true; + // Wrap to beginning if not found + if (found_row == null and start_row > 0) { + for (0..start_row) |row| { + if (table_state.getRowConst(row)) |row_data| { + const cell_value = row_data.get(first_col_name); + var format_buf: [128]u8 = undefined; + const cell_text = cell_value.format(&format_buf); + if (startsWithIgnoreCase(cell_text, search_term)) { + found_row = row; + break; + } + } + } + } + + // Move selection if found + if (found_row) |row_idx| { + table_state.selectCell(row_idx, @intCast(@max(0, table_state.selected_col))); + result.selection_changed = true; + } } } } diff --git a/src/widgets/advanced_table/state.zig b/src/widgets/advanced_table/state.zig index a7f05c6..00e69dd 100644 --- a/src/widgets/advanced_table/state.zig +++ b/src/widgets/advanced_table/state.zig @@ -110,6 +110,22 @@ pub const AdvancedTableState = struct { /// Escape count (1 = revert, 2 = cancel) 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 // ========================================================================= diff --git a/src/widgets/table_core (conflicted).zig b/src/widgets/table_core (conflicted).zig new file mode 100644 index 0000000..cfd291f --- /dev/null +++ b/src/widgets/table_core (conflicted).zig @@ -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); +} diff --git a/src/widgets/table_core.zig b/src/widgets/table_core.zig new file mode 100644 index 0000000..4dc380a --- /dev/null +++ b/src/widgets/table_core.zig @@ -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); +} diff --git a/src/widgets/virtual_advanced_table/state.zig b/src/widgets/virtual_advanced_table/state.zig index 9703804..6381e64 100644 --- a/src/widgets/virtual_advanced_table/state.zig +++ b/src/widgets/virtual_advanced_table/state.zig @@ -27,6 +27,22 @@ pub const VirtualAdvancedTableState = struct { /// Cuando el usuario navega con flechas o hace click, se actualiza 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 // ========================================================================= diff --git a/src/widgets/virtual_advanced_table/virtual_advanced_table.zig b/src/widgets/virtual_advanced_table/virtual_advanced_table.zig index a25e0e0..7a6f643 100644 --- a/src/widgets/virtual_advanced_table/virtual_advanced_table.zig +++ b/src/widgets/virtual_advanced_table/virtual_advanced_table.zig @@ -19,6 +19,7 @@ const Layout = @import("../../core/layout.zig"); const Style = @import("../../core/style.zig"); const Input = @import("../../core/input.zig"); const text_input = @import("../text_input.zig"); +const table_core = @import("../table_core.zig"); // Re-exports públicos pub const types = @import("types.zig"); @@ -756,6 +757,29 @@ fn drawRows( const col_end = x + @as(i32, @intCast(col.width)); // Only draw if column is visible 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) { const text_color = if (is_selected and list_state.has_focus) colors.text_selected @@ -1000,8 +1024,6 @@ fn handleMouseClick( list_state: *VirtualAdvancedTableState, result: *VirtualAdvancedTableResult, ) void { - _ = result; - const mouse = ctx.input.mousePos(); // Content starts after FilterBar + Header 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; 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 + var clicked_col: usize = 0; const relative_x = mouse.x - bounds.x + list_state.scroll_offset_x; var col_start: i32 = 0; for (config.columns, 0..) |col, col_idx| { const col_end = col_start + @as(i32, @intCast(col.width)); if (relative_x >= col_start and relative_x < col_end) { - list_state.active_col = col_idx; + clicked_col = col_idx; break; } col_start = col_end; } - // TODO: implement double click detection with timing - // For now, double click is not supported + // Double-click detection using table_core + 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; + } } } } diff --git a/src/widgets/widgets.zig b/src/widgets/widgets.zig index 7abbf36..0f4c630 100644 --- a/src/widgets/widgets.zig +++ b/src/widgets/widgets.zig @@ -63,6 +63,9 @@ pub const sheet = @import("sheet.zig"); pub const discloser = @import("discloser.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 pub const advanced_table = @import("advanced_table/advanced_table.zig");