From b0b8346355330e5d798246c270627793dd51a116 Mon Sep 17 00:00:00 2001 From: "R.Eugenio" Date: Wed, 24 Dec 2025 00:09:57 +0100 Subject: [PATCH] feat(virtual_list): FilterBar visual + click offset fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FilterBar chips con forma pill (border-radius completo) - Esquinas redondeadas en TextInput del filtro - Fix click offset: ahora cuenta filter_bar_h + header_h - Corrección selección de fila por click 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/widgets/virtual_list/state.zig | 118 +++++- src/widgets/virtual_list/types.zig | 59 +++ src/widgets/virtual_list/virtual_list.zig | 493 +++++++++++++++++++--- 3 files changed, 603 insertions(+), 67 deletions(-) diff --git a/src/widgets/virtual_list/state.zig b/src/widgets/virtual_list/state.zig index be47ed9..3c25cf0 100644 --- a/src/widgets/virtual_list/state.zig +++ b/src/widgets/virtual_list/state.zig @@ -25,12 +25,15 @@ pub const VirtualListState = struct { // Scroll y ventana // ========================================================================= - /// Offset actual del scroll (en filas, no pixels) + /// Offset actual del scroll vertical (en filas, no pixels) scroll_offset: usize = 0, - /// Offset del scroll en pixels (para smooth scroll) + /// Offset del scroll en pixels (para smooth scroll vertical) scroll_offset_pixels: i32 = 0, + /// Offset del scroll horizontal (en pixels) + scroll_offset_x: i32 = 0, + /// Ventana de datos actual (propiedad del DataProvider) /// NO liberar - el provider lo gestiona current_window: []const RowData = &.{}, @@ -65,6 +68,34 @@ pub const VirtualListState = struct { /// Dirección de ordenación sort_direction: SortDirection = .none, + // ========================================================================= + // FilterBar state + // ========================================================================= + + /// Chips activos (bitset, máximo 16 chips) + active_chips: u16 = 0, + + /// Flag: el filtro de texto cambió este frame + filter_text_changed: bool = false, + + /// Flag: un chip cambió este frame + chip_changed: bool = false, + + /// ID del chip que cambió (índice) + changed_chip_index: ?u4 = null, + + /// Timestamp del último cambio de filtro (para debounce) + last_filter_change_ms: i64 = 0, + + /// Flag: el campo de búsqueda tiene focus + search_has_focus: bool = false, + + /// Cursor del campo de búsqueda (posición en bytes) + search_cursor: usize = 0, + + /// Selección del campo de búsqueda (inicio) + search_selection_start: ?usize = null, + // ========================================================================= // Flags internos // ========================================================================= @@ -191,9 +222,68 @@ pub const VirtualListState = struct { pub fn resetFrameFlags(self: *Self) void { self.selection_changed = false; self.double_clicked = false; + self.filter_text_changed = false; + self.chip_changed = false; + self.changed_chip_index = null; self.frame_count +%= 1; } + // ========================================================================= + // FilterBar methods + // ========================================================================= + + /// Verifica si un chip está activo + pub fn isChipActive(self: *const Self, chip_index: u4) bool { + return (self.active_chips & (@as(u16, 1) << chip_index)) != 0; + } + + /// Activa un chip + pub fn activateChip(self: *Self, chip_index: u4, mode: types.ChipSelectMode) void { + switch (mode) { + .single => { + // Desactivar todos y activar solo este + self.active_chips = @as(u16, 1) << chip_index; + }, + .multi => { + // Toggle del chip + self.active_chips ^= @as(u16, 1) << chip_index; + }, + } + self.chip_changed = true; + self.changed_chip_index = chip_index; + } + + /// Desactiva un chip (solo en modo multi) + pub fn deactivateChip(self: *Self, chip_index: u4) void { + self.active_chips &= ~(@as(u16, 1) << chip_index); + self.chip_changed = true; + self.changed_chip_index = chip_index; + } + + /// Inicializa chips por defecto según configuración + pub fn initDefaultChips(self: *Self, chips: []const types.FilterChipDef) void { + self.active_chips = 0; + for (chips, 0..) |chip, i| { + if (chip.is_default and i < 16) { + self.active_chips |= @as(u16, 1) << @intCast(i); + } + } + } + + /// Establece el texto del filtro + pub fn setFilterText(self: *Self, text: []const u8) void { + const len = @min(text.len, self.filter_buf.len); + @memcpy(self.filter_buf[0..len], text[0..len]); + self.filter_len = len; + self.filter_text_changed = true; + } + + /// Limpia el filtro de texto + pub fn clearFilterText(self: *Self) void { + self.filter_len = 0; + self.filter_text_changed = true; + } + // ========================================================================= // Navegación // ========================================================================= @@ -281,6 +371,30 @@ pub const VirtualListState = struct { self.selectByWindowIndex(self.current_window.len - 1); } } + + // ========================================================================= + // Navegación horizontal + // ========================================================================= + + /// Scroll horizontal a la izquierda + pub fn scrollLeft(self: *Self, amount: i32) void { + self.scroll_offset_x = @max(0, self.scroll_offset_x - amount); + } + + /// Scroll horizontal a la derecha + pub fn scrollRight(self: *Self, amount: i32, max_scroll: i32) void { + self.scroll_offset_x = @min(max_scroll, self.scroll_offset_x + amount); + } + + /// Va al inicio horizontal + pub fn goToStartX(self: *Self) void { + self.scroll_offset_x = 0; + } + + /// Va al final horizontal + pub fn goToEndX(self: *Self, max_scroll: i32) void { + self.scroll_offset_x = max_scroll; + } }; // ============================================================================= diff --git a/src/widgets/virtual_list/types.zig b/src/widgets/virtual_list/types.zig index 9f959ff..31f5a9f 100644 --- a/src/widgets/virtual_list/types.zig +++ b/src/widgets/virtual_list/types.zig @@ -99,6 +99,62 @@ pub const CountInfo = struct { } }; +// ============================================================================= +// FilterBar Types +// ============================================================================= + +/// Definición de un chip/prefiltro +pub const FilterChipDef = struct { + /// ID único del chip (ej: "todos", "pendientes") + id: []const u8, + + /// Texto visible (ej: "[T]odos", "[P]end.") + label: []const u8, + + /// Tecla de atajo con Ctrl (ej: 'T' para Ctrl+T), null si no tiene + shortcut: ?u8 = null, + + /// Activo por defecto al iniciar + is_default: bool = false, +}; + +/// Modo de selección de chips +pub const ChipSelectMode = enum { + /// Solo uno puede estar activo (radio buttons) + single, + + /// Varios pueden estar activos (checkboxes) + multi, +}; + +/// Configuración de la barra de filtros +pub const FilterBarConfig = struct { + /// Mostrar campo de búsqueda + show_search: bool = true, + + /// Placeholder del campo búsqueda + search_placeholder: []const u8 = "Buscar...", + + /// Debounce en milisegundos (0 = sin debounce) + search_debounce_ms: u32 = 300, + + /// Chips/prefiltros a mostrar + chips: []const FilterChipDef = &.{}, + + /// Modo de selección de chips + chip_mode: ChipSelectMode = .single, + + /// Mostrar botón limpiar (✖) + show_clear_button: bool = true, + + /// Altura de la barra de filtros + height: u16 = 28, +}; + +// ============================================================================= +// VirtualListConfig +// ============================================================================= + /// Configuración del VirtualList pub const VirtualListConfig = struct { /// Altura de cada fila en pixels @@ -125,6 +181,9 @@ pub const VirtualListConfig = struct { /// Colores personalizados (opcional) colors: ?Colors = null, + /// Configuración de FilterBar (opcional, null = sin barra de filtros) + filter_bar: ?FilterBarConfig = null, + pub const Colors = struct { background: Style.Color = Style.Color.rgb(255, 255, 255), header_background: Style.Color = Style.Color.rgb(224, 224, 224), diff --git a/src/widgets/virtual_list/virtual_list.zig b/src/widgets/virtual_list/virtual_list.zig index 14e2ea4..5a4c723 100644 --- a/src/widgets/virtual_list/virtual_list.zig +++ b/src/widgets/virtual_list/virtual_list.zig @@ -18,6 +18,7 @@ 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"); // Re-exports públicos pub const types = @import("types.zig"); @@ -31,6 +32,9 @@ pub const SortDirection = types.SortDirection; pub const LoadState = types.LoadState; pub const CountInfo = types.CountInfo; pub const VirtualListConfig = types.VirtualListConfig; +pub const FilterBarConfig = types.FilterBarConfig; +pub const FilterChipDef = types.FilterChipDef; +pub const ChipSelectMode = types.ChipSelectMode; pub const DataProvider = data_provider.DataProvider; pub const VirtualListState = state_mod.VirtualListState; @@ -54,9 +58,21 @@ pub const VirtualListResult = struct { sort_column: ?[]const u8 = null, sort_direction: SortDirection = .none, - /// El filtro cambió + /// 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, }; @@ -114,10 +130,33 @@ pub fn virtualListRect( 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.scroll_offset_x > max_scroll_x) { + list_state.scroll_offset_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 -| header_h -| footer_h; + 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 @@ -141,17 +180,41 @@ pub fn virtualListRect( // 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 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.scroll_offset_x); // Draw visible rows const content_bounds = Layout.Rect.init( bounds.x, - bounds.y + @as(i32, @intCast(header_h)), + header_y + @as(i32, @intCast(header_h)), bounds.w, content_h, ); - drawRows(ctx, content_bounds, config, &colors, list_state, visible_rows, &result); + + // 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.scroll_offset_x); // End clipping ctx.pushCommand(Command.clipEnd()); @@ -167,10 +230,15 @@ pub fn virtualListRect( drawFooter(ctx, footer_bounds, &colors, list_state); } - // Draw scrollbar if needed + // 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, list_state, visible_rows, total_rows, &colors); + 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.scroll_offset_x, max_scroll_x, available_width, &colors); } // Draw border around the entire list (always visible) @@ -193,12 +261,12 @@ pub fn virtualListRect( // Handle keyboard if (has_focus) { - handleKeyboard(ctx, list_state, provider, visible_rows, total_rows, &result); + handleKeyboard(ctx, list_state, provider, visible_rows, total_rows, max_scroll_x, &result); } // Handle mouse clicks on rows if (clicked and hovered) { - handleMouseClick(ctx, bounds, header_h, config, list_state, &result); + handleMouseClick(ctx, bounds, filter_bar_h, header_h, config, list_state, &result); } // Update result @@ -233,74 +301,313 @@ fn needsRefetch(list_state: *const VirtualListState, visible_rows: usize, buffer return false; } +// ============================================================================= +// Draw: FilterBar +// ============================================================================= + +fn drawFilterBar( + ctx: *Context, + bounds: Layout.Rect, + config: FilterBarConfig, + colors: *const VirtualListConfig.Colors, + list_state: *VirtualListState, + result: *VirtualListResult, +) 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 drawHeader( +fn drawHeaderAt( ctx: *Context, bounds: Layout.Rect, + header_y: i32, config: VirtualListConfig, colors: *const VirtualListConfig.Colors, list_state: *VirtualListState, result: *VirtualListResult, + scroll_offset_x: i32, ) void { const header_h = config.row_height; // Header background ctx.pushCommand(Command.rect( bounds.x, - bounds.y, + header_y, bounds.w, header_h, colors.header_background, )); - // Draw column headers - var x: i32 = bounds.x; + // Draw column headers (with horizontal scroll offset) + var x: i32 = bounds.x - scroll_offset_x; for (config.columns) |col| { - // Column title - ctx.pushCommand(Command.text( - x + 4, - bounds.y + 3, // Centrado vertical mejorado - col.title, - colors.text, - )); + // 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, - bounds.y + 3, // Centrado vertical mejorado - indicator, - 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)); } - // 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)); + x = col_end; } // Bottom border ctx.pushCommand(Command.rect( bounds.x, - bounds.y + @as(i32, @intCast(header_h)) - 1, + header_y + @as(i32, @intCast(header_h)) - 1, bounds.w, 1, colors.border, @@ -319,6 +626,7 @@ fn drawRows( list_state: *VirtualListState, visible_rows: usize, result: *VirtualListResult, + scroll_offset_x: i32, ) void { _ = result; @@ -358,23 +666,27 @@ fn drawRows( bg_color, )); - // Draw cells - var x: i32 = content_bounds.x; + // Draw cells (with horizontal scroll offset) + var x: i32 = content_bounds.x - scroll_offset_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; + const col_end = x + @as(i32, @intCast(col.width)); + // Only draw if column is visible + if (col_end > content_bounds.x and x < content_bounds.x + @as(i32, @intCast(content_bounds.w))) { + 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, - )); + ctx.pushCommand(Command.text( + x + 4, + row_y + 3, // Centrado vertical mejorado + row.values[col_idx], + text_color, + )); + } } - x += @as(i32, @intCast(col.width)); + x = col_end; } } } @@ -459,6 +771,44 @@ fn drawScrollbar( 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 VirtualListConfig.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 // ============================================================================= @@ -469,20 +819,31 @@ fn handleKeyboard( provider: DataProvider, visible_rows: usize, total_rows: usize, + max_scroll_x: i32, result: *VirtualListResult, ) void { _ = provider; _ = result; + 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 => list_state.scrollLeft(h_scroll_step), + .right => list_state.scrollRight(h_scroll_step, max_scroll_x), .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), + .home => { + list_state.goToStart(); + list_state.goToStartX(); + }, + .end => { + list_state.goToEnd(visible_rows, total_rows); + list_state.goToEndX(max_scroll_x); + }, else => {}, } } @@ -495,6 +856,7 @@ fn handleKeyboard( fn handleMouseClick( ctx: *Context, bounds: Layout.Rect, + filter_bar_h: u32, header_h: u32, config: VirtualListConfig, list_state: *VirtualListState, @@ -503,9 +865,10 @@ fn handleMouseClick( _ = result; const mouse = ctx.input.mousePos(); - const content_y = bounds.y + @as(i32, @intCast(header_h)); + // 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) + // 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;