//! 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); }