feat(virtual_list): FilterBar visual + click offset fix
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
7b2ba06035
commit
b0b8346355
3 changed files with 603 additions and 67 deletions
|
|
@ -25,12 +25,15 @@ pub const VirtualListState = struct {
|
||||||
// Scroll y ventana
|
// Scroll y ventana
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
/// Offset actual del scroll (en filas, no pixels)
|
/// Offset actual del scroll vertical (en filas, no pixels)
|
||||||
scroll_offset: usize = 0,
|
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,
|
scroll_offset_pixels: i32 = 0,
|
||||||
|
|
||||||
|
/// Offset del scroll horizontal (en pixels)
|
||||||
|
scroll_offset_x: i32 = 0,
|
||||||
|
|
||||||
/// Ventana de datos actual (propiedad del DataProvider)
|
/// Ventana de datos actual (propiedad del DataProvider)
|
||||||
/// NO liberar - el provider lo gestiona
|
/// NO liberar - el provider lo gestiona
|
||||||
current_window: []const RowData = &.{},
|
current_window: []const RowData = &.{},
|
||||||
|
|
@ -65,6 +68,34 @@ pub const VirtualListState = struct {
|
||||||
/// Dirección de ordenación
|
/// Dirección de ordenación
|
||||||
sort_direction: SortDirection = .none,
|
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
|
// Flags internos
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
@ -191,9 +222,68 @@ pub const VirtualListState = struct {
|
||||||
pub fn resetFrameFlags(self: *Self) void {
|
pub fn resetFrameFlags(self: *Self) void {
|
||||||
self.selection_changed = false;
|
self.selection_changed = false;
|
||||||
self.double_clicked = false;
|
self.double_clicked = false;
|
||||||
|
self.filter_text_changed = false;
|
||||||
|
self.chip_changed = false;
|
||||||
|
self.changed_chip_index = null;
|
||||||
self.frame_count +%= 1;
|
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
|
// Navegación
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
@ -281,6 +371,30 @@ pub const VirtualListState = struct {
|
||||||
self.selectByWindowIndex(self.current_window.len - 1);
|
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;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -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
|
/// Configuración del VirtualList
|
||||||
pub const VirtualListConfig = struct {
|
pub const VirtualListConfig = struct {
|
||||||
/// Altura de cada fila en pixels
|
/// Altura de cada fila en pixels
|
||||||
|
|
@ -125,6 +181,9 @@ pub const VirtualListConfig = struct {
|
||||||
/// Colores personalizados (opcional)
|
/// Colores personalizados (opcional)
|
||||||
colors: ?Colors = null,
|
colors: ?Colors = null,
|
||||||
|
|
||||||
|
/// Configuración de FilterBar (opcional, null = sin barra de filtros)
|
||||||
|
filter_bar: ?FilterBarConfig = null,
|
||||||
|
|
||||||
pub const Colors = struct {
|
pub const Colors = struct {
|
||||||
background: Style.Color = Style.Color.rgb(255, 255, 255),
|
background: Style.Color = Style.Color.rgb(255, 255, 255),
|
||||||
header_background: Style.Color = Style.Color.rgb(224, 224, 224),
|
header_background: Style.Color = Style.Color.rgb(224, 224, 224),
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ const Command = @import("../../core/command.zig");
|
||||||
const Layout = @import("../../core/layout.zig");
|
const Layout = @import("../../core/layout.zig");
|
||||||
const Style = @import("../../core/style.zig");
|
const Style = @import("../../core/style.zig");
|
||||||
const Input = @import("../../core/input.zig");
|
const Input = @import("../../core/input.zig");
|
||||||
|
const text_input = @import("../text_input.zig");
|
||||||
|
|
||||||
// Re-exports públicos
|
// Re-exports públicos
|
||||||
pub const types = @import("types.zig");
|
pub const types = @import("types.zig");
|
||||||
|
|
@ -31,6 +32,9 @@ pub const SortDirection = types.SortDirection;
|
||||||
pub const LoadState = types.LoadState;
|
pub const LoadState = types.LoadState;
|
||||||
pub const CountInfo = types.CountInfo;
|
pub const CountInfo = types.CountInfo;
|
||||||
pub const VirtualListConfig = types.VirtualListConfig;
|
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 DataProvider = data_provider.DataProvider;
|
||||||
pub const VirtualListState = state_mod.VirtualListState;
|
pub const VirtualListState = state_mod.VirtualListState;
|
||||||
|
|
@ -54,9 +58,21 @@ pub const VirtualListResult = struct {
|
||||||
sort_column: ?[]const u8 = null,
|
sort_column: ?[]const u8 = null,
|
||||||
sort_direction: SortDirection = .none,
|
sort_direction: SortDirection = .none,
|
||||||
|
|
||||||
/// El filtro cambió
|
/// El filtro de texto cambió
|
||||||
filter_changed: bool = false,
|
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
|
/// El widget fue clickeado
|
||||||
clicked: bool = false,
|
clicked: bool = false,
|
||||||
};
|
};
|
||||||
|
|
@ -114,10 +130,33 @@ pub fn virtualListRect(
|
||||||
const has_focus = ctx.hasFocus(widget_id);
|
const has_focus = ctx.hasFocus(widget_id);
|
||||||
list_state.has_focus = has_focus;
|
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
|
// Calculate dimensions
|
||||||
const header_h: u32 = config.row_height;
|
const header_h: u32 = config.row_height;
|
||||||
const footer_h: u32 = if (config.show_count) 16 else 0; // 16px para footer compacto
|
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);
|
const visible_rows: usize = @intCast(content_h / config.row_height);
|
||||||
|
|
||||||
// Calculate buffer size and check if refetch needed
|
// Calculate buffer size and check if refetch needed
|
||||||
|
|
@ -141,17 +180,41 @@ pub fn virtualListRect(
|
||||||
// Begin clipping
|
// Begin clipping
|
||||||
ctx.pushCommand(Command.clip(bounds.x, bounds.y, bounds.w, bounds.h));
|
ctx.pushCommand(Command.clip(bounds.x, bounds.y, bounds.w, bounds.h));
|
||||||
|
|
||||||
// Draw header
|
// Draw FilterBar if configured
|
||||||
drawHeader(ctx, bounds, config, &colors, list_state, &result);
|
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
|
// Draw visible rows
|
||||||
const content_bounds = Layout.Rect.init(
|
const content_bounds = Layout.Rect.init(
|
||||||
bounds.x,
|
bounds.x,
|
||||||
bounds.y + @as(i32, @intCast(header_h)),
|
header_y + @as(i32, @intCast(header_h)),
|
||||||
bounds.w,
|
bounds.w,
|
||||||
content_h,
|
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
|
// End clipping
|
||||||
ctx.pushCommand(Command.clipEnd());
|
ctx.pushCommand(Command.clipEnd());
|
||||||
|
|
@ -167,10 +230,15 @@ pub fn virtualListRect(
|
||||||
drawFooter(ctx, footer_bounds, &colors, list_state);
|
drawFooter(ctx, footer_bounds, &colors, list_state);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw scrollbar if needed
|
// Draw vertical scrollbar if needed
|
||||||
const total_rows = list_state.getDisplayCount().value;
|
const total_rows = list_state.getDisplayCount().value;
|
||||||
if (total_rows > visible_rows and config.show_scrollbar) {
|
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)
|
// Draw border around the entire list (always visible)
|
||||||
|
|
@ -193,12 +261,12 @@ pub fn virtualListRect(
|
||||||
|
|
||||||
// Handle keyboard
|
// Handle keyboard
|
||||||
if (has_focus) {
|
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
|
// Handle mouse clicks on rows
|
||||||
if (clicked and hovered) {
|
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
|
// Update result
|
||||||
|
|
@ -233,74 +301,313 @@ fn needsRefetch(list_state: *const VirtualListState, visible_rows: usize, buffer
|
||||||
return false;
|
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
|
// Draw: Header
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
fn drawHeader(
|
fn drawHeaderAt(
|
||||||
ctx: *Context,
|
ctx: *Context,
|
||||||
bounds: Layout.Rect,
|
bounds: Layout.Rect,
|
||||||
|
header_y: i32,
|
||||||
config: VirtualListConfig,
|
config: VirtualListConfig,
|
||||||
colors: *const VirtualListConfig.Colors,
|
colors: *const VirtualListConfig.Colors,
|
||||||
list_state: *VirtualListState,
|
list_state: *VirtualListState,
|
||||||
result: *VirtualListResult,
|
result: *VirtualListResult,
|
||||||
|
scroll_offset_x: i32,
|
||||||
) void {
|
) void {
|
||||||
const header_h = config.row_height;
|
const header_h = config.row_height;
|
||||||
|
|
||||||
// Header background
|
// Header background
|
||||||
ctx.pushCommand(Command.rect(
|
ctx.pushCommand(Command.rect(
|
||||||
bounds.x,
|
bounds.x,
|
||||||
bounds.y,
|
header_y,
|
||||||
bounds.w,
|
bounds.w,
|
||||||
header_h,
|
header_h,
|
||||||
colors.header_background,
|
colors.header_background,
|
||||||
));
|
));
|
||||||
|
|
||||||
// Draw column headers
|
// Draw column headers (with horizontal scroll offset)
|
||||||
var x: i32 = bounds.x;
|
var x: i32 = bounds.x - scroll_offset_x;
|
||||||
for (config.columns) |col| {
|
for (config.columns) |col| {
|
||||||
// Column title
|
// Only draw if column is visible
|
||||||
ctx.pushCommand(Command.text(
|
const col_end = x + @as(i32, @intCast(col.width));
|
||||||
x + 4,
|
if (col_end > bounds.x and x < bounds.x + @as(i32, @intCast(bounds.w))) {
|
||||||
bounds.y + 3, // Centrado vertical mejorado
|
// Column title
|
||||||
col.title,
|
ctx.pushCommand(Command.text(
|
||||||
colors.text,
|
x + 4,
|
||||||
));
|
header_y + 3, // Centrado vertical mejorado
|
||||||
|
col.title,
|
||||||
|
colors.text,
|
||||||
|
));
|
||||||
|
|
||||||
// Sort indicator
|
// Sort indicator
|
||||||
if (list_state.sort_column) |sort_col| {
|
if (list_state.sort_column) |sort_col| {
|
||||||
if (std.mem.eql(u8, sort_col, col.name)) {
|
if (std.mem.eql(u8, sort_col, col.name)) {
|
||||||
const indicator = list_state.sort_direction.symbol();
|
const indicator = list_state.sort_direction.symbol();
|
||||||
ctx.pushCommand(Command.text(
|
ctx.pushCommand(Command.text(
|
||||||
x + @as(i32, @intCast(col.width)) - 20,
|
x + @as(i32, @intCast(col.width)) - 20,
|
||||||
bounds.y + 3, // Centrado vertical mejorado
|
header_y + 3, // Centrado vertical mejorado
|
||||||
indicator,
|
indicator,
|
||||||
colors.text,
|
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
|
x = col_end;
|
||||||
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
|
// Bottom border
|
||||||
ctx.pushCommand(Command.rect(
|
ctx.pushCommand(Command.rect(
|
||||||
bounds.x,
|
bounds.x,
|
||||||
bounds.y + @as(i32, @intCast(header_h)) - 1,
|
header_y + @as(i32, @intCast(header_h)) - 1,
|
||||||
bounds.w,
|
bounds.w,
|
||||||
1,
|
1,
|
||||||
colors.border,
|
colors.border,
|
||||||
|
|
@ -319,6 +626,7 @@ fn drawRows(
|
||||||
list_state: *VirtualListState,
|
list_state: *VirtualListState,
|
||||||
visible_rows: usize,
|
visible_rows: usize,
|
||||||
result: *VirtualListResult,
|
result: *VirtualListResult,
|
||||||
|
scroll_offset_x: i32,
|
||||||
) void {
|
) void {
|
||||||
_ = result;
|
_ = result;
|
||||||
|
|
||||||
|
|
@ -358,23 +666,27 @@ fn drawRows(
|
||||||
bg_color,
|
bg_color,
|
||||||
));
|
));
|
||||||
|
|
||||||
// Draw cells
|
// Draw cells (with horizontal scroll offset)
|
||||||
var x: i32 = content_bounds.x;
|
var x: i32 = content_bounds.x - scroll_offset_x;
|
||||||
for (config.columns, 0..) |col, col_idx| {
|
for (config.columns, 0..) |col, col_idx| {
|
||||||
if (col_idx < row.values.len) {
|
const col_end = x + @as(i32, @intCast(col.width));
|
||||||
const text_color = if (is_selected and list_state.has_focus)
|
// Only draw if column is visible
|
||||||
colors.text_selected
|
if (col_end > content_bounds.x and x < content_bounds.x + @as(i32, @intCast(content_bounds.w))) {
|
||||||
else
|
if (col_idx < row.values.len) {
|
||||||
colors.text;
|
const text_color = if (is_selected and list_state.has_focus)
|
||||||
|
colors.text_selected
|
||||||
|
else
|
||||||
|
colors.text;
|
||||||
|
|
||||||
ctx.pushCommand(Command.text(
|
ctx.pushCommand(Command.text(
|
||||||
x + 4,
|
x + 4,
|
||||||
row_y + 3, // Centrado vertical mejorado
|
row_y + 3, // Centrado vertical mejorado
|
||||||
row.values[col_idx],
|
row.values[col_idx],
|
||||||
text_color,
|
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));
|
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
|
// Handle: Keyboard
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -469,20 +819,31 @@ fn handleKeyboard(
|
||||||
provider: DataProvider,
|
provider: DataProvider,
|
||||||
visible_rows: usize,
|
visible_rows: usize,
|
||||||
total_rows: usize,
|
total_rows: usize,
|
||||||
|
max_scroll_x: i32,
|
||||||
result: *VirtualListResult,
|
result: *VirtualListResult,
|
||||||
) void {
|
) void {
|
||||||
_ = provider;
|
_ = provider;
|
||||||
_ = result;
|
_ = result;
|
||||||
|
|
||||||
|
const h_scroll_step: i32 = 40; // Pixels per arrow key press
|
||||||
|
|
||||||
// Usar navKeyPressed() para soportar key repeat (tecla mantenida pulsada)
|
// Usar navKeyPressed() para soportar key repeat (tecla mantenida pulsada)
|
||||||
if (ctx.input.navKeyPressed()) |key| {
|
if (ctx.input.navKeyPressed()) |key| {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
.up => list_state.moveUp(),
|
.up => list_state.moveUp(),
|
||||||
.down => list_state.moveDown(visible_rows),
|
.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_up => list_state.pageUp(visible_rows),
|
||||||
.page_down => list_state.pageDown(visible_rows, total_rows),
|
.page_down => list_state.pageDown(visible_rows, total_rows),
|
||||||
.home => list_state.goToStart(),
|
.home => {
|
||||||
.end => list_state.goToEnd(visible_rows, total_rows),
|
list_state.goToStart();
|
||||||
|
list_state.goToStartX();
|
||||||
|
},
|
||||||
|
.end => {
|
||||||
|
list_state.goToEnd(visible_rows, total_rows);
|
||||||
|
list_state.goToEndX(max_scroll_x);
|
||||||
|
},
|
||||||
else => {},
|
else => {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -495,6 +856,7 @@ fn handleKeyboard(
|
||||||
fn handleMouseClick(
|
fn handleMouseClick(
|
||||||
ctx: *Context,
|
ctx: *Context,
|
||||||
bounds: Layout.Rect,
|
bounds: Layout.Rect,
|
||||||
|
filter_bar_h: u32,
|
||||||
header_h: u32,
|
header_h: u32,
|
||||||
config: VirtualListConfig,
|
config: VirtualListConfig,
|
||||||
list_state: *VirtualListState,
|
list_state: *VirtualListState,
|
||||||
|
|
@ -503,9 +865,10 @@ fn handleMouseClick(
|
||||||
_ = result;
|
_ = result;
|
||||||
|
|
||||||
const mouse = ctx.input.mousePos();
|
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) {
|
if (mouse.y >= content_y) {
|
||||||
const relative_y = mouse.y - content_y;
|
const relative_y = mouse.y - content_y;
|
||||||
const screen_row = @as(usize, @intCast(relative_y)) / config.row_height;
|
const screen_row = @as(usize, @intCast(relative_y)) / config.row_height;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue