diff --git a/src/widgets/virtual_list/virtual_list.zig b/src/widgets/virtual_list/virtual_list.zig index bd76954..199c36e 100644 --- a/src/widgets/virtual_list/virtual_list.zig +++ b/src/widgets/virtual_list/virtual_list.zig @@ -3,71 +3,26 @@ //! Lista escalable que solo carga en memoria los registros visibles + buffer. //! Diseñada para trabajar con bases de datos grandes (100k+ registros). //! -//! ## Características -//! - Carga solo los registros necesarios (ventana virtual) -//! - Selección por ID (persistente al scroll/ordenar/filtrar) -//! - Contador diferido "15 de 500+..." → "15 de 1,234" -//! - Ordenación y filtrado delegados al DataProvider -//! - Umbral configurable para activar virtualización -//! //! ## Uso //! ```zig -//! // 1. Crear DataProvider (implementación específica) -//! var who_provider = WhoDataProvider.init(data_manager); -//! const provider = who_provider.toDataProvider(); -//! -//! // 2. Configurar columnas -//! const columns = [_]ColumnDef{ -//! .{ .name = "codigo", .title = "Código", .width = 80 }, -//! .{ .name = "nombre", .title = "Nombre", .width = 200 }, -//! }; -//! -//! // 3. Crear estado -//! var state = VirtualListState{}; -//! -//! // 4. Renderizar //! const result = virtualList(ctx, rect, &state, provider, .{ //! .columns = &columns, //! .virtualization_threshold = 500, //! }); -//! -//! if (result.selection_changed) { -//! // Handle new selection -//! } -//! ``` -//! -//! ## Arquitectura -//! ``` -//! ┌─────────────────────────────────────────┐ -//! │ VirtualListWidget │ -//! │ - Renderiza filas visibles │ -//! │ - Gestiona scroll virtual │ -//! │ - Muestra contador "X de Y" │ -//! └─────────────────────────────────────────┘ -//! │ -//! ▼ -//! ┌─────────────────────────────────────────┐ -//! │ DataProvider (vtable) │ -//! │ - fetchWindow(offset, limit) │ -//! │ - getTotalCount() → CountInfo │ -//! │ - setFilter(), setSort() │ -//! └─────────────────────────────────────────┘ -//! │ -//! ▼ -//! ┌─────────────────────────────────────────┐ -//! │ Implementación específica │ -//! │ (WhoDataProvider, DocDataProvider) │ -//! │ - Queries SQLite LIMIT/OFFSET │ -//! │ - Arena allocator para ventana │ -//! └─────────────────────────────────────────┘ +//! 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 = @import("state.zig"); +pub const state_mod = @import("state.zig"); // Tipos principales pub const RowData = types.RowData; @@ -78,7 +33,7 @@ pub const CountInfo = types.CountInfo; pub const VirtualListConfig = types.VirtualListConfig; pub const DataProvider = data_provider.DataProvider; -pub const VirtualListState = state.VirtualListState; +pub const VirtualListState = state_mod.VirtualListState; /// Resultado de renderizar el VirtualList pub const VirtualListResult = struct { @@ -97,31 +52,472 @@ pub const VirtualListResult = struct { /// 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, }; -// TODO: Fase 3 - Implementar función principal del widget -// pub fn virtualList( -// ctx: *Context, -// rect: Rect, -// list_state: *VirtualListState, -// provider: DataProvider, -// config: VirtualListConfig, -// ) VirtualListResult { ... } +// ============================================================================= +// 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) 20 else 0; + 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 focus ring + 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.fill( + 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 + 4, + 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 + 4, + 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.fill(x - 1, bounds.y, 1, header_h, colors.border)); + } + + // Bottom border + ctx.pushCommand(Command.fill( + 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; + + // Draw each visible row + var row_idx: usize = 0; + while (row_idx < visible_rows and row_idx < list_state.current_window.len) : (row_idx += 1) { + const row_y = content_bounds.y + @as(i32, @intCast(row_idx * row_h)); + const global_idx = list_state.windowToGlobalIndex(row_idx); + const row = list_state.current_window[row_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: u32 = 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.fill( + 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 + 4, + 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.fill( + 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.fill(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.fill(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; + + if (ctx.input.keyPressed(.up)) { + list_state.moveUp(); + } else if (ctx.input.keyPressed(.down)) { + list_state.moveDown(visible_rows); + } else if (ctx.input.keyPressed(.page_up)) { + list_state.pageUp(visible_rows); + } else if (ctx.input.keyPressed(.page_down)) { + list_state.pageDown(visible_rows, total_rows); + } else if (ctx.input.keyPressed(.home)) { + list_state.goToStart(); + } else if (ctx.input.keyPressed(.end)) { + list_state.goToEnd(visible_rows, total_rows); + } +} + +// ============================================================================= +// 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 row_idx = @as(usize, @intCast(relative_y)) / config.row_height; + + if (row_idx < list_state.current_window.len) { + list_state.selectByWindowIndex(row_idx); + + // Check for double click + // TODO: implement double click detection with timing + } + } +} // ============================================================================= // Tests // ============================================================================= test "virtual_list module imports" { - // Verificar que todos los módulos se importan correctamente _ = types; _ = data_provider; - _ = state; - - // Verificar tipos principales + _ = state_mod; _ = RowData; _ = ColumnDef; _ = DataProvider; @@ -130,7 +526,6 @@ test "virtual_list module imports" { } test { - // Ejecutar tests de submódulos _ = @import("types.zig"); _ = @import("data_provider.zig"); _ = @import("state.zig");