//! VirtualList - 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 = virtualList(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"); // 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"); // 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 VirtualListConfig = types.VirtualListConfig; pub const DataProvider = data_provider.DataProvider; pub const VirtualListState = state_mod.VirtualListState; /// Resultado de renderizar el VirtualList pub const VirtualListResult = 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 cambió filter_changed: bool = false, /// El widget fue clickeado clicked: bool = false, }; // ============================================================================= // Widget principal // ============================================================================= /// Renderiza un VirtualList pub fn virtualList( ctx: *Context, list_state: *VirtualListState, provider: DataProvider, config: VirtualListConfig, ) VirtualListResult { const bounds = ctx.layout.nextRect(); return virtualListRect(ctx, bounds, list_state, provider, config); } /// Renderiza un VirtualList en un rectángulo específico pub fn virtualListRect( ctx: *Context, bounds: Layout.Rect, list_state: *VirtualListState, provider: DataProvider, config: VirtualListConfig, ) VirtualListResult { var result = VirtualListResult{}; if (bounds.isEmpty() or config.columns.len == 0) return result; // Reset frame flags list_state.resetFrameFlags(); // Get colors const colors = config.colors orelse VirtualListConfig.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 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 -| header_h -| footer_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.scroll_offset, buffer_size)) |window| { list_state.current_window = window; list_state.window_start = list_state.scroll_offset; } 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 header drawHeader(ctx, bounds, config, &colors, list_state, &result); // Draw visible rows const content_bounds = Layout.Rect.init( bounds.x, bounds.y + @as(i32, @intCast(header_h)), bounds.w, content_h, ); drawRows(ctx, content_bounds, config, &colors, list_state, visible_rows, &result); // 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 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, list_state, visible_rows, total_rows, &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, &result); } // Handle mouse clicks on rows if (clicked and hovered) { handleMouseClick(ctx, bounds, 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; } return result; } // ============================================================================= // Helper: Check if refetch needed // ============================================================================= fn needsRefetch(list_state: *const VirtualListState, visible_rows: usize, buffer_size: usize) bool { // First load if (list_state.current_window.len == 0) return true; // Check if scroll is outside current window const scroll = list_state.scroll_offset; 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: Header // ============================================================================= fn drawHeader( ctx: *Context, bounds: Layout.Rect, config: VirtualListConfig, colors: *const VirtualListConfig.Colors, list_state: *VirtualListState, result: *VirtualListResult, ) void { const header_h = config.row_height; // Header background ctx.pushCommand(Command.rect( bounds.x, bounds.y, bounds.w, header_h, colors.header_background, )); // Draw column headers var x: i32 = bounds.x; for (config.columns) |col| { // Column title ctx.pushCommand(Command.text( x + 4, bounds.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, bounds.y + 3, // Centrado vertical mejorado indicator, colors.text, )); } } // Check click on header for sorting if (col.sortable) { const header_bounds = Layout.Rect.init(x, bounds.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 x += @as(i32, @intCast(col.width)); ctx.pushCommand(Command.rect(x - 1, bounds.y, 1, header_h, colors.border)); } // Bottom border ctx.pushCommand(Command.rect( bounds.x, bounds.y + @as(i32, @intCast(header_h)) - 1, bounds.w, 1, colors.border, )); } // ============================================================================= // Draw: Rows // ============================================================================= fn drawRows( ctx: *Context, content_bounds: Layout.Rect, config: VirtualListConfig, colors: *const VirtualListConfig.Colors, list_state: *VirtualListState, visible_rows: usize, result: *VirtualListResult, ) void { _ = result; const row_h = config.row_height; // Calculate offset within the window buffer // scroll_offset es la posición global, window_start es donde empieza el buffer const window_offset = list_state.scroll_offset -| list_state.window_start; // Draw each visible row var row_idx: usize = 0; while (row_idx < visible_rows) : (row_idx += 1) { const data_idx = window_offset + row_idx; if (data_idx >= list_state.current_window.len) break; const row_y = content_bounds.y + @as(i32, @intCast(row_idx * row_h)); const global_idx = list_state.scroll_offset + row_idx; // Índice global real const row = list_state.current_window[data_idx]; // Determine row background const is_selected = list_state.selected_id != null and row.id == list_state.selected_id.?; const is_alternate = global_idx % 2 == 1; const bg_color: Style.Color = if (is_selected) if (list_state.has_focus) colors.row_selected else colors.row_selected_unfocus else if (is_alternate) colors.row_alternate else colors.row_normal; // Row background ctx.pushCommand(Command.rect( content_bounds.x, row_y, content_bounds.w, row_h, bg_color, )); // Draw cells var x: i32 = content_bounds.x; for (config.columns, 0..) |col, col_idx| { if (col_idx < row.values.len) { const text_color = if (is_selected and list_state.has_focus) colors.text_selected else colors.text; ctx.pushCommand(Command.text( x + 4, row_y + 3, // Centrado vertical mejorado row.values[col_idx], text_color, )); } x += @as(i32, @intCast(col.width)); } } } // ============================================================================= // Draw: Footer // ============================================================================= fn drawFooter( ctx: *Context, bounds: Layout.Rect, colors: *const VirtualListConfig.Colors, list_state: *VirtualListState, ) void { // Background ctx.pushCommand(Command.rect( bounds.x, bounds.y, bounds.w, bounds.h, colors.header_background, )); // Format count 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" var display_buf: [96]u8 = undefined; const display_str = std.fmt.bufPrint(&display_buf, "{s} de {s}", .{ pos_str, count_str }) catch "..."; ctx.pushCommand(Command.text( bounds.x + 4, bounds.y + 2, display_str, colors.text, )); } // ============================================================================= // Draw: Scrollbar // ============================================================================= fn drawScrollbar( ctx: *Context, bounds: Layout.Rect, header_h: u32, footer_h: u32, list_state: *VirtualListState, visible_rows: usize, total_rows: usize, colors: *const VirtualListConfig.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.scroll_offset)) / @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)); } // ============================================================================= // Handle: Keyboard // ============================================================================= fn handleKeyboard( ctx: *Context, list_state: *VirtualListState, provider: DataProvider, visible_rows: usize, total_rows: usize, result: *VirtualListResult, ) void { _ = provider; _ = result; // 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), .page_up => list_state.pageUp(visible_rows), .page_down => list_state.pageDown(visible_rows, total_rows), .home => list_state.goToStart(), .end => list_state.goToEnd(visible_rows, total_rows), else => {}, } } } // ============================================================================= // Handle: Mouse Click // ============================================================================= fn handleMouseClick( ctx: *Context, bounds: Layout.Rect, header_h: u32, config: VirtualListConfig, list_state: *VirtualListState, result: *VirtualListResult, ) void { _ = result; const mouse = ctx.input.mousePos(); const content_y = bounds.y + @as(i32, @intCast(header_h)); // Check if click is in content area (not header) 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.scroll_offset -| list_state.window_start; 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); // Check for double click // TODO: implement double click detection with timing } } } // ============================================================================= // Tests // ============================================================================= test "virtual_list module imports" { _ = types; _ = data_provider; _ = state_mod; _ = RowData; _ = ColumnDef; _ = DataProvider; _ = VirtualListState; _ = VirtualListResult; } test { _ = @import("types.zig"); _ = @import("data_provider.zig"); _ = @import("state.zig"); }