//! VirtualAdvancedTable - Widget de lista virtualizada //! //! Lista escalable que solo carga en memoria los registros visibles + buffer. //! Diseñada para trabajar con bases de datos grandes (100k+ registros). //! //! ## Uso //! ```zig //! const result = virtualAdvancedTable(ctx, rect, &state, provider, .{ //! .columns = &columns, //! .virtualization_threshold = 500, //! }); //! if (result.selection_changed) { ... } //! ``` 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"); 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"); pub const data_provider = @import("data_provider.zig"); pub const state_mod = @import("state.zig"); pub const cell_editor = @import("cell_editor.zig"); pub const paged_datasource = @import("paged_datasource.zig"); // Tipos principales pub const RowData = types.RowData; pub const ColumnDef = types.ColumnDef; pub const SortDirection = types.SortDirection; pub const LoadState = types.LoadState; pub const CountInfo = types.CountInfo; pub const VirtualAdvancedTableConfig = types.VirtualAdvancedTableConfig; pub const FilterBarConfig = types.FilterBarConfig; pub const FilterChipDef = types.FilterChipDef; pub const ChipSelectMode = types.ChipSelectMode; pub const CellId = types.CellId; pub const CellGeometry = types.CellGeometry; pub const DataProvider = data_provider.DataProvider; pub const PagedDataSource = paged_datasource.PagedDataSource; pub const CellEditorColors = cell_editor.CellEditorColors; pub const CellEditorResult = cell_editor.CellEditorResult; pub const NavigateDirection = cell_editor.NavigateDirection; pub const drawCellEditor = cell_editor.drawCellEditor; pub const VirtualAdvancedTableState = state_mod.VirtualAdvancedTableState; /// Resultado de renderizar el VirtualAdvancedTable pub const VirtualAdvancedTableResult = struct { /// La selección cambió este frame selection_changed: bool = false, /// ID del registro seleccionado selected_id: ?i64 = null, /// Hubo doble click en un registro double_clicked: bool = false, /// ID del registro donde hubo doble click double_click_id: ?i64 = null, /// El usuario solicitó ordenar por una columna sort_requested: bool = false, sort_column: ?[]const u8 = null, sort_direction: SortDirection = .none, /// El filtro de texto cambió filter_changed: bool = false, /// Texto del filtro actual filter_text: ?[]const u8 = null, /// Un chip/prefiltro cambió chip_changed: bool = false, /// Índice del chip que cambió chip_index: ?u4 = null, /// El chip está activo después del cambio chip_active: bool = false, /// El widget fue clickeado clicked: bool = false, // ========================================================================= // Edición CRUD Excel-style (commit al abandonar fila) // ========================================================================= /// Una fila fue completada (el usuario cambió de fila, tenía cambios pendientes) /// Cuando es true, row_commit_id y row_changes contienen los datos row_committed: bool = false, /// ID de la fila que se hizo commit (NEW_ROW_ID = -1 para inserts) row_commit_id: i64 = table_core.NEW_ROW_ID, /// Es un INSERT (ghost row) o UPDATE (fila existente) row_commit_is_insert: bool = false, /// Cambios de la fila (válidos si row_committed = true) /// Buffer estático que sobrevive el frame row_changes: [table_core.MAX_PENDING_COLUMNS]table_core.PendingCellChange = undefined, /// Número de cambios en row_changes row_changes_count: usize = 0, /// El usuario canceló edición (Escape 2x = descartar fila) row_discarded: bool = false, /// Navegación solicitada después de edición navigate_direction: cell_editor.NavigateDirection = .none, /// Tab presionado sin edición activa (pasar focus al siguiente widget) tab_out: bool = false, /// Shift estaba presionado con Tab (para tab_out inverso) tab_shift: bool = false, // ========================================================================= // Compatibilidad (DEPRECADO - usar row_committed) // ========================================================================= /// @deprecated: Usar row_committed. Mantenido para compatibilidad. cell_committed: bool = false, /// @deprecated row_changed: bool = false, /// @deprecated edited_cell: ?CellId = null, /// @deprecated edited_value: ?[]const u8 = null, /// @deprecated previous_row: ?usize = null, /// Obtiene los cambios como slice (helper para compatibilidad) pub fn getRowChanges(self: *const VirtualAdvancedTableResult) []const table_core.PendingCellChange { return self.row_changes[0..self.row_changes_count]; } }; // ============================================================================= // Widget principal // ============================================================================= /// Renderiza un VirtualAdvancedTable pub fn virtualAdvancedTable( ctx: *Context, list_state: *VirtualAdvancedTableState, provider: DataProvider, config: VirtualAdvancedTableConfig, ) VirtualAdvancedTableResult { const bounds = ctx.layout.nextRect(); return virtualAdvancedTableRect(ctx, bounds, list_state, provider, config); } /// Renderiza un VirtualAdvancedTable en un rectángulo específico pub fn virtualAdvancedTableRect( ctx: *Context, bounds: Layout.Rect, list_state: *VirtualAdvancedTableState, provider: DataProvider, config: VirtualAdvancedTableConfig, ) VirtualAdvancedTableResult { var result = VirtualAdvancedTableResult{}; if (bounds.isEmpty() or config.columns.len == 0) return result; // Reset frame flags list_state.resetFrameFlags(); // Get colors const colors = config.colors orelse VirtualAdvancedTableConfig.Colors{}; // Generate unique ID for focus system const widget_id: u64 = @intFromPtr(list_state); // Register as focusable ctx.registerFocusable(widget_id); // Check mouse interaction const mouse = ctx.input.mousePos(); const hovered = bounds.contains(mouse.x, mouse.y); const clicked = hovered and ctx.input.mousePressed(.left); if (clicked) { ctx.requestFocus(widget_id); result.clicked = true; } // Check if we have focus const has_focus = ctx.hasFocus(widget_id); list_state.has_focus = has_focus; // Calculate total columns width var total_columns_width: u32 = 0; for (config.columns) |col| { total_columns_width += col.width; } // Check if horizontal scroll is needed const scrollbar_v_w: u32 = 12; // Width of vertical scrollbar const available_width = bounds.w -| scrollbar_v_w; const needs_h_scroll = total_columns_width > available_width; const scrollbar_h_h: u32 = if (needs_h_scroll) 12 else 0; // Calculate max horizontal scroll const max_scroll_x: i32 = @max(0, @as(i32, @intCast(total_columns_width)) - @as(i32, @intCast(available_width))); // Clamp current scroll_offset_x if (list_state.nav.scroll_x > max_scroll_x) { list_state.nav.scroll_x = max_scroll_x; } // Calculate FilterBar height const filter_bar_h: u32 = if (config.filter_bar) |fb| fb.height else 0; // Calculate dimensions const header_h: u32 = config.row_height; const footer_h: u32 = if (config.show_count) 16 else 0; // 16px para footer compacto const content_h = bounds.h -| filter_bar_h -| header_h -| footer_h -| scrollbar_h_h; const visible_rows: usize = @intCast(content_h / config.row_height); // Calculate buffer size and check if refetch needed const buffer_size = visible_rows * config.buffer_multiplier; const needs_refetch = needsRefetch(list_state, visible_rows, buffer_size); // Fetch window if needed if (needs_refetch) { if (provider.fetchWindow(list_state.nav.scroll_row, buffer_size)) |window| { list_state.current_window = window; list_state.window_start = list_state.nav.scroll_row; } else |_| { // Error fetching - keep current window } } // Update counts from provider list_state.total_count = provider.getTotalCount(); list_state.filtered_count = provider.getFilteredCount(); // Begin clipping ctx.pushCommand(Command.clip(bounds.x, bounds.y, bounds.w, bounds.h)); // Draw FilterBar if configured if (config.filter_bar) |fb_config| { const filter_bounds = Layout.Rect.init( bounds.x, bounds.y, bounds.w, fb_config.height, ); drawFilterBar(ctx, filter_bounds, fb_config, &colors, list_state, &result); } // Calculate header Y position (after FilterBar) const header_y = bounds.y + @as(i32, @intCast(filter_bar_h)); // Draw header (with horizontal scroll offset) drawHeaderAt(ctx, bounds, header_y, config, &colors, list_state, &result, list_state.nav.scroll_x); // Draw visible rows const content_bounds = Layout.Rect.init( bounds.x, header_y + @as(i32, @intCast(header_h)), bounds.w, content_h, ); // Draw content background first (so empty space isn't black) ctx.pushCommand(Command.rect( content_bounds.x, content_bounds.y, content_bounds.w, content_bounds.h, colors.row_normal, )); drawRows(ctx, content_bounds, config, &colors, list_state, visible_rows, &result, list_state.nav.scroll_x); // Draw CellEditor overlay if editing if (list_state.isEditing()) { const editing = list_state.getEditingCell().?; // Calculate cell geometry for the editing cell if (list_state.getCellGeometry( editing.row, editing.col, config.columns, config.row_height, bounds.x, bounds.y, header_h, filter_bar_h, )) |geom| { // Draw cell editor con colores del panel const editor_result = cell_editor.drawCellEditor( ctx, list_state, geom, cell_editor.CellEditorColors{ .background = colors.background, .text = colors.text, .cursor = colors.text, // Cursor mismo color que texto }, ); // Handle editor results if (editor_result.committed) { const edited_cell = list_state.getEditingCell().?; const new_value = list_state.getEditText(); // Añadir cambio al buffer de fila (NO commit inmediato) // El commit real se hace cuando el usuario abandona la fila if (list_state.hasValueChanged()) { list_state.row_edit_buffer.addChange(edited_cell.col, new_value); // Compatibilidad: mantener flags antiguos result.cell_committed = true; result.edited_cell = edited_cell; result.edited_value = new_value; } // Finalizar edición de celda (sin commit a BD) _ = list_state.commitEdit(); result.navigate_direction = editor_result.navigate; } else if (editor_result.escaped) { const action = list_state.handleEscape(); if (action == .discard_row) { result.row_discarded = true; list_state.row_edit_buffer.clear(); } } } } // End clipping ctx.pushCommand(Command.clipEnd()); // Draw footer with count if (config.show_count) { const footer_bounds = Layout.Rect.init( bounds.x, bounds.y + @as(i32, @intCast(bounds.h - footer_h)), bounds.w, footer_h, ); drawFooter(ctx, footer_bounds, &colors, list_state); } // Draw vertical scrollbar if needed const total_rows = list_state.getDisplayCount().value; if (total_rows > visible_rows and config.show_scrollbar) { drawScrollbar(ctx, bounds, header_h, footer_h +| scrollbar_h_h, list_state, visible_rows, total_rows, &colors); } // Draw horizontal scrollbar if needed if (needs_h_scroll and config.show_scrollbar) { drawScrollbarH(ctx, bounds, footer_h, scrollbar_h_h, list_state.nav.scroll_x, max_scroll_x, available_width, &colors); } // Draw border around the entire list (always visible) ctx.pushCommand(Command.rectOutline(bounds.x, bounds.y, bounds.w, bounds.h, colors.border)); // Draw focus ring (additional highlight when focused) if (has_focus) { if (Style.isFancy()) { ctx.pushCommand(Command.focusRing(bounds.x, bounds.y, bounds.w, bounds.h, 4)); } else { ctx.pushCommand(Command.rectOutline( bounds.x - 1, bounds.y - 1, bounds.w + 2, bounds.h + 2, colors.border, )); } } // Handle keyboard if (has_focus) { handleKeyboard(ctx, list_state, provider, visible_rows, total_rows, max_scroll_x, config.columns.len, &result); } // Handle mouse clicks on rows if (clicked and hovered) { handleMouseClick(ctx, bounds, filter_bar_h, header_h, config, list_state, &result); } // Update result result.selection_changed = list_state.selection_changed; result.selected_id = list_state.selected_id; result.double_clicked = list_state.double_clicked; if (list_state.double_clicked) { result.double_click_id = list_state.selected_id; } // ========================================================================= // Commit de fila al cambiar de selección // ========================================================================= // Si la selección cambió y hay cambios pendientes en otra fila, hacer commit if (list_state.selection_changed and list_state.row_edit_buffer.has_changes) { const new_row_id = list_state.selected_id orelse table_core.NEW_ROW_ID; const new_row_idx = list_state.getSelectedRow() orelse 0; const is_ghost = table_core.isGhostRow(new_row_id); // checkRowChangeAndCommit compara row_ids y hace commit si son diferentes if (table_core.checkRowChangeAndCommit( &list_state.row_edit_buffer, new_row_id, new_row_idx, is_ghost, &result.row_changes, )) |commit_info| { result.row_committed = true; result.row_commit_id = commit_info.row_id; result.row_commit_is_insert = commit_info.is_insert; result.row_changes_count = commit_info.change_count; // Compatibilidad result.row_changed = true; } } return result; } // ============================================================================= // Helper: Check if refetch needed // ============================================================================= fn needsRefetch(list_state: *VirtualAdvancedTableState, visible_rows: usize, buffer_size: usize) bool { // Manual invalidation (después de editar datos) if (list_state.needs_window_refresh) { list_state.needs_window_refresh = false; return true; } // First load if (list_state.current_window.len == 0) return true; // Check if scroll is outside current window const scroll = list_state.nav.scroll_row; const window_end = list_state.window_start + list_state.current_window.len; // Refetch if scroll is near edges of window const margin = visible_rows; if (scroll < list_state.window_start + margin and list_state.window_start > 0) return true; if (scroll + visible_rows + margin > window_end) return true; _ = buffer_size; return false; } // ============================================================================= // Draw: FilterBar // ============================================================================= fn drawFilterBar( ctx: *Context, bounds: Layout.Rect, config: FilterBarConfig, colors: *const VirtualAdvancedTableConfig.Colors, list_state: *VirtualAdvancedTableState, result: *VirtualAdvancedTableResult, ) void { const padding: i32 = 6; const chip_h: u32 = 22; const chip_padding: i32 = 10; const chip_spacing: i32 = 6; const chip_radius: u8 = 11; // Radio de esquinas redondeadas (mitad de altura = pill) const clear_btn_w: u32 = 22; // Background con gradiente sutil (color base ligeramente más oscuro abajo) ctx.pushCommand(Command.rect( bounds.x, bounds.y, bounds.w, bounds.h, colors.header_background, )); // Línea sutil inferior para separación ctx.pushCommand(Command.rect( bounds.x, bounds.y + @as(i32, @intCast(bounds.h)) - 1, bounds.w, 1, colors.border, )); var current_x = bounds.x + padding; const item_y = bounds.y + @divTrunc(@as(i32, @intCast(bounds.h)) - @as(i32, @intCast(chip_h)), 2); const item_h = bounds.h -| @as(u32, @intCast(padding * 2)); const mouse = ctx.input.mousePos(); // ========================================================================= // Draw Chips (estilo pill/badge redondeado) // ========================================================================= if (config.chips.len > 0) { for (config.chips, 0..) |chip, idx| { const chip_idx: u4 = @intCast(idx); const is_active = list_state.isChipActive(chip_idx); // Calcular ancho del chip basado en el texto const label_len = chip.label.len; const chip_w: u32 = @intCast(label_len * 7 + chip_padding * 2); const chip_bounds = Layout.Rect.init( current_x, item_y, chip_w, chip_h, ); const chip_hovered = chip_bounds.contains(mouse.x, mouse.y); // Colores con mejor contraste para chips activos vs inactivos const chip_bg = if (is_active) colors.row_selected // Color primario para activo else if (chip_hovered) Style.Color.rgb( colors.header_background.r -| 15, colors.header_background.g -| 15, colors.header_background.b -| 15, ) // Hover sutil else colors.header_background; const chip_text_color = if (is_active) colors.text_selected else colors.text; const chip_border = if (is_active) colors.row_selected // Sin borde visible cuando activo else colors.border; // Fondo del chip (redondeado tipo pill) ctx.pushCommand(Command.roundedRect( chip_bounds.x, chip_bounds.y, chip_bounds.w, chip_bounds.h, chip_bg, chip_radius, )); // Borde del chip (solo si no activo, para efecto más limpio) if (!is_active) { ctx.pushCommand(Command.roundedRectOutline( chip_bounds.x, chip_bounds.y, chip_bounds.w, chip_bounds.h, chip_border, chip_radius, )); } // Texto del chip centrado verticalmente ctx.pushCommand(Command.text( chip_bounds.x + chip_padding, chip_bounds.y + 4, chip.label, chip_text_color, )); // Handle click if (chip_hovered and ctx.input.mousePressed(.left)) { list_state.activateChip(chip_idx, config.chip_mode); result.chip_changed = true; result.chip_index = chip_idx; result.chip_active = list_state.isChipActive(chip_idx); } current_x += @as(i32, @intCast(chip_w)) + chip_spacing; } current_x += padding; // Espacio extra después de chips } // ========================================================================= // Draw Search Input // ========================================================================= if (config.show_search) { // Calcular espacio para clear button const clear_space: u32 = if (config.show_clear_button) clear_btn_w + @as(u32, @intCast(padding)) else 0; // Search ocupa el resto del espacio const search_end = bounds.x + @as(i32, @intCast(bounds.w)) - padding - @as(i32, @intCast(clear_space)); const search_w: u32 = @intCast(@max(60, search_end - current_x)); const search_bounds = Layout.Rect.init( current_x, item_y, search_w, item_h, ); // Create a temporary TextInputState from list_state fields var text_state = text_input.TextInputState{ .buffer = &list_state.filter_buf, .len = list_state.filter_len, .cursor = list_state.search_cursor, .selection_start = list_state.search_selection_start, .focused = list_state.search_has_focus, }; const text_result = text_input.textInputRect(ctx, search_bounds, &text_state, .{ .placeholder = config.search_placeholder, .padding = 3, }); // Update list_state from text_state list_state.filter_len = text_state.len; list_state.search_cursor = text_state.cursor; list_state.search_selection_start = text_state.selection_start; // Handle focus changes if (text_result.clicked) { list_state.search_has_focus = true; } // Handle text changes if (text_result.changed) { list_state.filter_text_changed = true; result.filter_changed = true; result.filter_text = list_state.filter_buf[0..list_state.filter_len]; } current_x += @as(i32, @intCast(search_w)) + padding; } // ========================================================================= // Draw Clear Button (circular, estilo moderno) // ========================================================================= if (config.show_clear_button and list_state.filter_len > 0) { const clear_x = bounds.x + @as(i32, @intCast(bounds.w - clear_btn_w)) - padding; const clear_bounds = Layout.Rect.init( clear_x, item_y, clear_btn_w, chip_h, ); const clear_hovered = clear_bounds.contains(mouse.x, mouse.y); // Color rojo sutil para indicar "eliminar" const clear_bg = if (clear_hovered) Style.Color.rgb(220, 80, 80) // Rojo hover else Style.Color.rgb(180, 60, 60); // Rojo base const clear_text = Style.Color.rgb(255, 255, 255); // Botón circular (radio = mitad altura) ctx.pushCommand(Command.roundedRect( clear_bounds.x, clear_bounds.y, clear_bounds.w, clear_bounds.h, clear_bg, chip_radius, )); // Draw "✕" centered (o "X" si no hay soporte unicode) ctx.pushCommand(Command.text( clear_bounds.x + 7, clear_bounds.y + 4, "X", clear_text, )); // Handle click if (clear_hovered and ctx.input.mousePressed(.left)) { list_state.clearFilterText(); list_state.search_cursor = 0; list_state.search_selection_start = null; result.filter_changed = true; result.filter_text = ""; } } } // ============================================================================= // Draw: Header // ============================================================================= fn drawHeaderAt( ctx: *Context, bounds: Layout.Rect, header_y: i32, config: VirtualAdvancedTableConfig, colors: *const VirtualAdvancedTableConfig.Colors, list_state: *VirtualAdvancedTableState, result: *VirtualAdvancedTableResult, scroll_offset_x: i32, ) void { const header_h = config.row_height; // Header background ctx.pushCommand(Command.rect( bounds.x, header_y, bounds.w, header_h, colors.header_background, )); // Draw column headers (with horizontal scroll offset) var x: i32 = bounds.x - scroll_offset_x; for (config.columns) |col| { // Only draw if column is visible const col_end = x + @as(i32, @intCast(col.width)); if (col_end > bounds.x and x < bounds.x + @as(i32, @intCast(bounds.w))) { // Column title ctx.pushCommand(Command.text( x + 4, header_y + 3, // Centrado vertical mejorado col.title, colors.text, )); // Sort indicator if (list_state.sort_column) |sort_col| { if (std.mem.eql(u8, sort_col, col.name)) { const indicator = list_state.sort_direction.symbol(); ctx.pushCommand(Command.text( x + @as(i32, @intCast(col.width)) - 20, header_y + 3, // Centrado vertical mejorado indicator, colors.text, )); } } // Check click on header for sorting if (col.sortable) { const header_bounds = Layout.Rect.init(x, header_y, col.width, header_h); const mouse = ctx.input.mousePos(); if (header_bounds.contains(mouse.x, mouse.y) and ctx.input.mousePressed(.left)) { list_state.toggleSort(col.name); result.sort_requested = true; result.sort_column = col.name; result.sort_direction = list_state.sort_direction; } } // Column separator ctx.pushCommand(Command.rect(col_end - 1, header_y, 1, header_h, colors.border)); } x = col_end; } // Bottom border ctx.pushCommand(Command.rect( bounds.x, header_y + @as(i32, @intCast(header_h)) - 1, bounds.w, 1, colors.border, )); } // ============================================================================= // Draw: Rows // ============================================================================= fn drawRows( ctx: *Context, content_bounds: Layout.Rect, config: VirtualAdvancedTableConfig, colors: *const VirtualAdvancedTableConfig.Colors, list_state: *VirtualAdvancedTableState, visible_rows: usize, result: *VirtualAdvancedTableResult, scroll_offset_x: i32, ) void { _ = result; // Crear PagedDataSource para acceso unificado a datos var datasource = paged_datasource.PagedDataSource.init(list_state, config.columns, null); const table_ds = datasource.toDataSource(); // Convertir selected_id a selected_row (índice global) const selected_row: i32 = if (list_state.findSelectedInWindow()) |window_idx| @intCast(list_state.windowToGlobalIndex(window_idx)) else -1; // Calcular rango de filas a dibujar const first_row = list_state.nav.scroll_row; const last_row = @min( list_state.nav.scroll_row + visible_rows, list_state.window_start + list_state.current_window.len, ); // Convertir columnas a ColumnRenderDef var render_cols: [32]table_core.ColumnRenderDef = undefined; const num_cols = @min(config.columns.len, 32); for (config.columns[0..num_cols], 0..) |col, i| { render_cols[i] = .{ .width = col.width, .text_align = 0, // TODO: mapear col.alignment si existe .visible = true, }; } // Convertir colores const render_colors = table_core.RowRenderColors{ .row_normal = colors.row_normal, .row_alternate = colors.row_alternate, .selected_row = colors.row_selected, .selected_row_unfocus = colors.row_selected_unfocus, .selected_cell = Style.Color.rgb(100, 200, 255), // Cyan brillante .selected_cell_unfocus = Style.Color.rgb(150, 150, 160), .text_normal = colors.text, .text_selected = colors.text_selected, .border = colors.border, }; // Buffer para valores de celda var cell_buffer: [256]u8 = undefined; // Llamar a la función unificada _ = table_core.drawRowsWithDataSource( ctx, table_ds, .{ .bounds_x = content_bounds.x, .bounds_y = content_bounds.y, .bounds_w = content_bounds.w, .row_height = config.row_height, .first_row = first_row, .last_row = last_row, .scroll_x = scroll_offset_x, .alternating_rows = true, .has_focus = list_state.has_focus, .selected_row = selected_row, .active_col = list_state.nav.active_col, .colors = render_colors, .columns = render_cols[0..num_cols], }, &cell_buffer, ); } // ============================================================================= // Draw: Footer // ============================================================================= fn drawFooter( ctx: *Context, bounds: Layout.Rect, colors: *const VirtualAdvancedTableConfig.Colors, list_state: *VirtualAdvancedTableState, ) void { // Background ctx.pushCommand(Command.rect( bounds.x, bounds.y, bounds.w, bounds.h, colors.header_background, )); // Format count (usar buffers temporales solo para strings intermedios) var count_buf: [64]u8 = undefined; const count_info = list_state.getDisplayCount(); const count_str = count_info.format(&count_buf); // Find selected position var pos_buf: [32]u8 = undefined; const pos_str = if (list_state.selected_id != null) if (list_state.findSelectedInWindow()) |idx| std.fmt.bufPrint(&pos_buf, "{d}", .{list_state.windowToGlobalIndex(idx) + 1}) catch "?" else "?" else "-"; // Combine: "pos de total" - USAR BUFFER PERSISTENTE del state // IMPORTANTE: Los buffers de stack se invalidan al retornar. El render // diferido necesita buffers que sobrevivan hasta después del execute(). const display_str = std.fmt.bufPrint(&list_state.footer_display_buf, "{s} de {s}", .{ pos_str, count_str }) catch "..."; list_state.footer_display_len = display_str.len; ctx.pushCommand(Command.text( bounds.x + 4, bounds.y + 2, list_state.footer_display_buf[0..list_state.footer_display_len], colors.text, )); } // ============================================================================= // Draw: Scrollbar // ============================================================================= fn drawScrollbar( ctx: *Context, bounds: Layout.Rect, header_h: u32, footer_h: u32, list_state: *VirtualAdvancedTableState, visible_rows: usize, total_rows: usize, colors: *const VirtualAdvancedTableConfig.Colors, ) void { const scrollbar_w: u32 = 12; const content_h = bounds.h -| header_h -| footer_h; // Scrollbar track const track_x = bounds.x + @as(i32, @intCast(bounds.w - scrollbar_w)); const track_y = bounds.y + @as(i32, @intCast(header_h)); ctx.pushCommand(Command.rect(track_x, track_y, scrollbar_w, content_h, colors.row_alternate)); // Thumb size and position const visible_ratio = @as(f32, @floatFromInt(visible_rows)) / @as(f32, @floatFromInt(total_rows)); const thumb_h = @max(20, @as(u32, @intFromFloat(visible_ratio * @as(f32, @floatFromInt(content_h))))); const scroll_ratio = @as(f32, @floatFromInt(list_state.nav.scroll_row)) / @as(f32, @floatFromInt(@max(1, total_rows - visible_rows))); const thumb_y = track_y + @as(i32, @intFromFloat(scroll_ratio * @as(f32, @floatFromInt(content_h - thumb_h)))); // Draw thumb ctx.pushCommand(Command.rect(track_x + 2, thumb_y, scrollbar_w - 4, thumb_h, colors.border)); } // ============================================================================= // Draw: Horizontal Scrollbar // ============================================================================= fn drawScrollbarH( ctx: *Context, bounds: Layout.Rect, footer_h: u32, scrollbar_h: u32, scroll_offset_x: i32, max_scroll_x: i32, available_width: u32, colors: *const VirtualAdvancedTableConfig.Colors, ) void { const scrollbar_v_w: u32 = 12; // Width of vertical scrollbar area // Scrollbar track position (at the bottom, above footer) const track_x = bounds.x; const track_y = bounds.y + @as(i32, @intCast(bounds.h - footer_h - scrollbar_h)); const track_w = bounds.w -| scrollbar_v_w; // Track background ctx.pushCommand(Command.rect(track_x, track_y, track_w, scrollbar_h, colors.row_alternate)); // Calculate thumb size and position if (max_scroll_x <= 0) return; const total_width = available_width + @as(u32, @intCast(max_scroll_x)); const visible_ratio = @as(f32, @floatFromInt(available_width)) / @as(f32, @floatFromInt(total_width)); const thumb_w = @max(20, @as(u32, @intFromFloat(visible_ratio * @as(f32, @floatFromInt(track_w))))); const scroll_ratio = @as(f32, @floatFromInt(scroll_offset_x)) / @as(f32, @floatFromInt(max_scroll_x)); const thumb_x = track_x + @as(i32, @intFromFloat(scroll_ratio * @as(f32, @floatFromInt(track_w - thumb_w)))); // Draw thumb ctx.pushCommand(Command.rect(thumb_x, track_y + 2, thumb_w, scrollbar_h - 4, colors.border)); } // ============================================================================= // Handle: Keyboard // ============================================================================= fn handleKeyboard( ctx: *Context, list_state: *VirtualAdvancedTableState, provider: DataProvider, visible_rows: usize, total_rows: usize, max_scroll_x: i32, num_columns: usize, result: *VirtualAdvancedTableResult, ) void { _ = provider; // Si hay edición activa, el CellEditor maneja las teclas if (list_state.isEditing()) return; const h_scroll_step: i32 = 40; // Pixels per arrow key press // Usar navKeyPressed() para soportar key repeat (tecla mantenida pulsada) if (ctx.input.navKeyPressed()) |key| { switch (key) { .up => list_state.moveUp(), .down => list_state.moveDown(visible_rows), .left => { // Con Ctrl: scroll horizontal // Sin Ctrl: cambiar columna activa if (ctx.input.modifiers.ctrl) { list_state.scrollLeft(h_scroll_step); } else { list_state.moveToPrevCol(); } }, .right => { if (ctx.input.modifiers.ctrl) { list_state.scrollRight(h_scroll_step, max_scroll_x); } else { list_state.moveToNextCol(num_columns); } }, .page_up => list_state.pageUp(visible_rows), .page_down => list_state.pageDown(visible_rows, total_rows), .home => { if (ctx.input.modifiers.ctrl) { list_state.goToStart(); list_state.goToFirstCol(); } else { list_state.goToFirstCol(); } }, .end => { if (ctx.input.modifiers.ctrl) { list_state.goToEnd(visible_rows, total_rows); list_state.goToLastCol(num_columns); } else { list_state.goToLastCol(num_columns); } }, else => {}, } } // F2 o Space: iniciar edición de celda activa // Tab: pasar focus al siguiente widget (solo si NO estamos editando) for (ctx.input.getKeyEvents()) |event| { if (!event.pressed) continue; switch (event.key) { .f2, .space => { // Iniciar edición de celda activa if (list_state.getActiveCell()) |cell| { // El panel debe proveer el valor actual via callback // Por ahora iniciamos con texto vacío - el panel debería llamar startEditing result.cell_committed = false; // Flag especial: indica que se solicitó edición result.edited_cell = cell; } }, .tab => { // Tab sin edición activa: indica que el panel debe mover focus // IMPORTANTE: Solo si CellEditor no procesó Tab (evita doble procesamiento) if (result.navigate_direction == .none) { result.tab_out = true; result.tab_shift = event.modifiers.shift; } }, else => {}, } } // Teclas alfanuméricas: iniciar edición con ese caracter const char_input = ctx.input.getTextInput(); if (char_input.len > 0 and !list_state.isEditing()) { if (list_state.getActiveCell()) |cell| { // Iniciar edición con el primer caracter list_state.startEditing(cell, "", char_input[0], ctx.current_time_ms); } } } // ============================================================================= // Handle: Mouse Click // ============================================================================= fn handleMouseClick( ctx: *Context, bounds: Layout.Rect, filter_bar_h: u32, header_h: u32, config: VirtualAdvancedTableConfig, list_state: *VirtualAdvancedTableState, result: *VirtualAdvancedTableResult, ) void { 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)); // Check if click is in content area (not header or filter bar) if (mouse.y >= content_y) { const relative_y = mouse.y - content_y; const screen_row = @as(usize, @intCast(relative_y)) / config.row_height; // Convert screen row to buffer index (accounting for scroll) const window_offset = list_state.nav.scroll_row -| list_state.window_start; const data_idx = window_offset + screen_row; if (data_idx < list_state.current_window.len) { const global_row = list_state.nav.scroll_row + screen_row; // Detect which column was clicked var clicked_col: usize = 0; const relative_x = mouse.x - bounds.x + list_state.nav.scroll_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) { clicked_col = col_idx; break; } col_start = col_end; } // Double-click detection using embedded nav.double_click var dc_state = list_state.nav.double_click; 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.nav.double_click = dc_state; 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.nav.active_col = clicked_col; } } } } // ============================================================================= // Tests // ============================================================================= test "virtual_advanced_table module imports" { _ = types; _ = data_provider; _ = state_mod; _ = RowData; _ = ColumnDef; _ = DataProvider; _ = VirtualAdvancedTableState; _ = VirtualAdvancedTableResult; } test { _ = @import("types.zig"); _ = @import("data_provider.zig"); _ = @import("state.zig"); _ = @import("cell_editor.zig"); }