feat(virtual_list): Fase 3 - Widget completo con renderizado
Implementación del widget VirtualList (~530 LOC): virtualListRect(): - Registra focusable, maneja mouse/keyboard - Calcula filas visibles y buffer size - Detecta si necesita refetch (needsRefetch) - Llama a DataProvider.fetchWindow() cuando necesario - Actualiza counts desde provider Renderizado: - drawHeader(): títulos columnas, indicadores sort (^/v), click para ordenar - drawRows(): filas con alternancia, selección con/sin focus - drawFooter(): contador "pos de total" con soporte para "..." - drawScrollbar(): thumb proporcional al scroll Interacción: - handleKeyboard(): ↑↓, PgUp/Dn, Home/End - handleMouseClick(): click en fila selecciona Características: - Colores configurables (row_selected, row_selected_unfocus, etc.) - Focus ring (fancy o simple) - Clipping para contenido - Footer muestra "15 de 500+..." → "15 de 1,234" 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
7d1919969f
commit
2abb8547a5
1 changed files with 461 additions and 66 deletions
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue