zcatgui/src/widgets/virtual_list/virtual_list.zig
R.Eugenio 7b2ba06035 fix(virtual_list): Borde completo + centrado texto
- Añadir borde alrededor de toda la lista (siempre visible)
- Mejorar centrado vertical del texto (y+4 → y+3)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 14:41:56 +01:00

545 lines
18 KiB
Zig

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