From 1f2d4abb0be65d78940daa013b083a061c30e7fd Mon Sep 17 00:00:00 2001 From: "R.Eugenio" Date: Sun, 4 Jan 2026 02:40:54 +0100 Subject: [PATCH] feat(table_core): DRY - FilterBar y Footer compartidos + paridad AdvancedTable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FASES 1-7 completadas: - FilterBar extraído a table_core/filter_bar.zig (DRY) - Footer extraído a table_core/footer.zig (DRY) - AdvancedTable ahora soporta FilterBar + Footer opcional - validateCell añadido a TableDataSource VTable - VirtualAdvancedTable migrado a usar componentes compartidos Paridad UX completa entre AdvancedTable y VirtualAdvancedTable. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/widgets/advanced_table/advanced_table.zig | 124 +++++- src/widgets/advanced_table/result.zig | 19 + src/widgets/advanced_table/schema.zig | 8 + src/widgets/advanced_table/state.zig | 110 +++++ src/widgets/advanced_table/types.zig | 2 + src/widgets/table_core/datasource.zig | 18 + src/widgets/table_core/filter_bar.zig | 409 ++++++++++++++++++ src/widgets/table_core/footer.zig | 206 +++++++++ src/widgets/table_core/table_core.zig | 23 + .../virtual_advanced_table/drawing.zig | 315 +++++--------- 10 files changed, 1004 insertions(+), 230 deletions(-) create mode 100644 src/widgets/table_core/filter_bar.zig create mode 100644 src/widgets/table_core/footer.zig diff --git a/src/widgets/advanced_table/advanced_table.zig b/src/widgets/advanced_table/advanced_table.zig index 769a09e..d05d0e7 100644 --- a/src/widgets/advanced_table/advanced_table.zig +++ b/src/widgets/advanced_table/advanced_table.zig @@ -59,6 +59,11 @@ pub const MemoryDataSource = datasource.MemoryDataSource; // Re-export table_core types pub const NavigateDirection = table_core.NavigateDirection; +// Re-export FilterBar types for consumers +pub const FilterBarConfig = schema.FilterBarConfig; +pub const FilterChipDef = schema.FilterChipDef; +pub const ChipSelectMode = schema.ChipSelectMode; +pub const FilterBarState = state.FilterBarState; // Re-export helpers for external use pub const blendColor = helpers.blendColor; @@ -134,24 +139,68 @@ pub fn advancedTableRect( // Calculate dimensions const state_col_w: u32 = if (config.show_row_state_indicators) config.state_indicator_width else 0; + const filter_bar_h: u32 = if (table_schema.filter_bar) |fb| fb.height else 0; const header_h: u32 = if (config.show_headers) config.header_height else 0; - const content_h = bounds.h -| header_h; + const footer_h: u32 = if (config.show_footer) config.footer_height else 0; + const content_h = bounds.h -| filter_bar_h -| header_h -| footer_h; const visible_rows: usize = @intCast(content_h / config.row_height); + // Adjusted bounds for content area (after filter bar, before footer) + const content_bounds = Layout.Rect.init( + bounds.x, + bounds.y + @as(i32, @intCast(filter_bar_h)), + bounds.w, + bounds.h -| filter_bar_h -| footer_h, + ); + // Begin clipping ctx.pushCommand(Command.clip(bounds.x, bounds.y, bounds.w, bounds.h)); - // Draw header - if (config.show_headers) { - drawing.drawHeader(ctx, bounds, table_state, table_schema, state_col_w, colors, &result); + // Draw FilterBar (if configured) + if (table_schema.filter_bar) |fb_config| { + const filter_bounds = Layout.Rect.init(bounds.x, bounds.y, bounds.w, fb_config.height); + + // Convert TableColors to FilterBarColors + const fb_colors = table_core.FilterBarColors{ + .header_background = colors.header_bg, + .border = colors.border, + .text = colors.text_normal, + .row_selected = colors.selected_row, + .text_selected = colors.text_selected, + }; + + // Draw FilterBar using shared implementation + var fb_result = table_core.FilterBarResult{}; + table_core.drawFilterBar(ctx, filter_bounds, fb_config, fb_colors, &table_state.filter_bar_state, &fb_result); + + // Handle FilterBar results + if (fb_result.filter_changed) { + result.filter_changed = true; + result.filter_text = table_state.filter_bar_state.getFilterText(); + table_state.filter_changed = true; + + // Apply filter + table_state.applyFilter(table_schema.columns) catch {}; + } + if (fb_result.chip_changed) { + result.chip_changed = true; + result.chip_index = fb_result.chip_index; + result.chip_active = fb_result.chip_active; + } } - // Calculate visible row range + // Draw header + if (config.show_headers) { + drawing.drawHeader(ctx, content_bounds, table_state, table_schema, state_col_w, colors, &result); + } + + // Calculate visible row range (use filtered count if filter active) + const row_count = if (table_state.filter_active) table_state.getVisibleRowCount() else table_state.getRowCount(); const first_visible = table_state.nav.scroll_row; - const last_visible = @min(first_visible + visible_rows, table_state.getRowCount()); + const last_visible = @min(first_visible + visible_rows, row_count); // Manejar clicks en filas (separado del renderizado) - input.handleRowClicks(ctx, bounds, table_state, table_schema, header_h, state_col_w, first_visible, last_visible, &result); + input.handleRowClicks(ctx, content_bounds, table_state, table_schema, header_h, state_col_w, first_visible, last_visible, &result); // Construir ColumnRenderDefs para la función unificada var col_defs: [64]table_core.ColumnRenderDef = undefined; @@ -173,9 +222,9 @@ pub fn advancedTableRect( // Z-Design: Pintar fondo del área de contenido ANTES de las filas // Esto asegura que tablas vacías o con pocas filas no muestren negro ctx.pushCommand(Command.rect( - bounds.x, - bounds.y + @as(i32, @intCast(header_h)), - bounds.w, + content_bounds.x, + content_bounds.y + @as(i32, @intCast(header_h)), + content_bounds.w, content_h, colors.row_normal, )); @@ -199,9 +248,9 @@ pub fn advancedTableRect( var cell_buffer: [256]u8 = undefined; _ = table_core.drawRowsWithDataSource(ctx, data_src, .{ - .bounds_x = bounds.x, - .bounds_y = bounds.y + @as(i32, @intCast(header_h)), - .bounds_w = bounds.w, + .bounds_x = content_bounds.x, + .bounds_y = content_bounds.y + @as(i32, @intCast(header_h)), + .bounds_w = content_bounds.w, .row_height = config.row_height, .first_row = first_visible, .last_row = last_visible, @@ -217,6 +266,49 @@ pub fn advancedTableRect( .edit_buffer = &table_state.row_edit_buffer, }, &cell_buffer); + // Draw Footer (if configured) + if (config.show_footer) { + const footer_y = bounds.y + @as(i32, @intCast(bounds.h - footer_h)); + const footer_bounds = Layout.Rect.init(bounds.x, footer_y, bounds.w, footer_h); + + // Build position info + const current_pos: ?usize = if (table_state.selected_row >= 0) + @as(usize, @intCast(table_state.selected_row)) + 1 + else + null; + + const pos_info = table_core.FooterPositionInfo{ + .current_position = current_pos, + .total_count = .{ + .value = table_state.rows.items.len, + .state = .ready, + }, + .filtered_count = if (table_state.filter_active) + .{ .value = table_state.filtered_indices.items.len, .state = .ready } + else + null, + }; + + // Format and draw footer + var footer_state = table_core.FooterState{}; + const display_text = footer_state.formatDisplay(pos_info); + + // Copy to persistent buffer + @memcpy(table_state.footer_display_buf[0..display_text.len], display_text); + table_state.footer_display_len = display_text.len; + + table_core.drawFooter( + ctx, + footer_bounds, + table_core.FooterColors{ + .background = colors.header_bg, + .text = colors.text_normal, + .border = colors.border, + }, + table_state.footer_display_buf[0..table_state.footer_display_len], + ); + } + // End clipping ctx.pushCommand(Command.clipEnd()); @@ -236,8 +328,8 @@ pub fn advancedTableRect( } // Draw scrollbar if needed - if (table_state.getRowCount() > visible_rows) { - drawing.drawScrollbar(ctx, bounds, table_state, visible_rows, config, colors); + if (row_count > visible_rows) { + drawing.drawScrollbar(ctx, content_bounds, table_state, visible_rows, config, colors); } // Handle keyboard @@ -247,7 +339,7 @@ pub fn advancedTableRect( input.handleEditingKeyboard(ctx, table_state, table_schema, &result); // Draw editing overlay - drawing.drawEditingOverlay(ctx, bounds, table_state, table_schema, header_h, state_col_w, colors); + drawing.drawEditingOverlay(ctx, content_bounds, table_state, table_schema, header_h, state_col_w, colors); } else if (config.keyboard_nav) { // Handle navigation keyboard input.handleKeyboard(ctx, table_state, table_schema, visible_rows, &result); diff --git a/src/widgets/advanced_table/result.zig b/src/widgets/advanced_table/result.zig index 1fb1eee..300cfe4 100644 --- a/src/widgets/advanced_table/result.zig +++ b/src/widgets/advanced_table/result.zig @@ -41,6 +41,25 @@ pub const AdvancedTableResult = struct { // Focus clicked: bool = false, + // ========================================================================= + // FilterBar (PARIDAD con VirtualAdvancedTable - Enero 2026) + // ========================================================================= + + /// El filtro de texto cambió + filter_changed: bool = false, + + /// Texto del filtro (válido si filter_changed) + filter_text: ?[]const u8 = null, + + /// Un chip cambió de estado + chip_changed: bool = false, + + /// Índice del chip que cambió + chip_index: ?u4 = null, + + /// ¿El chip está ahora activo? + chip_active: bool = false, + // ========================================================================= // Edición CRUD Excel-style (simétrico con VirtualAdvancedTableResult) // ========================================================================= diff --git a/src/widgets/advanced_table/schema.zig b/src/widgets/advanced_table/schema.zig index e60e2de..74c1ef0 100644 --- a/src/widgets/advanced_table/schema.zig +++ b/src/widgets/advanced_table/schema.zig @@ -5,8 +5,13 @@ const std = @import("std"); const types = @import("types.zig"); +const table_core = @import("../table_core/table_core.zig"); pub const CellValue = types.CellValue; +// Re-export FilterBar types from table_core +pub const FilterBarConfig = table_core.FilterBarConfig; +pub const FilterChipDef = table_core.FilterChipDef; +pub const ChipSelectMode = table_core.ChipSelectMode; pub const ColumnType = types.ColumnType; pub const RowLockState = types.RowLockState; pub const Row = types.Row; @@ -214,6 +219,9 @@ pub const TableSchema = struct { /// DataStore for persistence (optional) data_store: ?DataStore = null, + /// FilterBar configuration (optional, null = no filter bar) + filter_bar: ?FilterBarConfig = null, + // ========================================================================= // Global Callbacks // ========================================================================= diff --git a/src/widgets/advanced_table/state.zig b/src/widgets/advanced_table/state.zig index 90682d6..c0d080a 100644 --- a/src/widgets/advanced_table/state.zig +++ b/src/widgets/advanced_table/state.zig @@ -16,6 +16,9 @@ pub const CRUDAction = types.CRUDAction; pub const Row = types.Row; pub const TableSchema = schema_mod.TableSchema; pub const MAX_EDIT_BUFFER = types.MAX_EDIT_BUFFER; +// FilterBar types +pub const FilterBarState = table_core.FilterBarState; +pub const FilterBarResult = table_core.FilterBarResult; // Re-export AdvancedTableResult desde result.zig pub const AdvancedTableResult = result_mod.AdvancedTableResult; @@ -78,6 +81,32 @@ pub const AdvancedTableState = struct { /// Search timeout in ms (reset after this) search_timeout_ms: u64 = 1000, + // ========================================================================= + // FilterBar State (PARIDAD con VirtualAdvancedTable - Enero 2026) + // ========================================================================= + + /// Estado del FilterBar (buffer de texto, cursor, chips activos) + filter_bar_state: FilterBarState = .{}, + + /// Índices de filas que pasan el filtro (si filter_active) + filtered_indices: std.ArrayListUnmanaged(usize) = .{}, + + /// ¿Hay un filtro activo? + filter_active: bool = false, + + /// ¿El filtro cambió este frame? + filter_changed: bool = false, + + // ========================================================================= + // Footer State (PARIDAD con VirtualAdvancedTable - Enero 2026) + // ========================================================================= + + /// Buffer de display del footer + footer_display_buf: [96]u8 = [_]u8{0} ** 96, + + /// Longitud del texto del footer + footer_display_len: usize = 0, + // ========================================================================= // Cell Validation (from Table widget) // ========================================================================= @@ -271,6 +300,9 @@ pub const AdvancedTableState = struct { } self.original_order.deinit(self.allocator); } + + // Deinit filtered indices + self.filtered_indices.deinit(self.allocator); } // ========================================================================= @@ -657,6 +689,84 @@ pub const AdvancedTableState = struct { self.search_len = 0; } + // ========================================================================= + // FilterBar Methods (PARIDAD con VirtualAdvancedTable - Enero 2026) + // ========================================================================= + + /// Obtiene el número de filas visible (filtradas o todas) + pub fn getVisibleRowCount(self: *const AdvancedTableState) usize { + if (self.filter_active) { + return self.filtered_indices.items.len; + } + return self.rows.items.len; + } + + /// Convierte índice visible a índice real + /// Si hay filtro activo: índice en filtered_indices -> índice en rows + /// Si no: identidad + pub fn visibleToRealIndex(self: *const AdvancedTableState, visible_idx: usize) usize { + if (self.filter_active) { + if (visible_idx < self.filtered_indices.items.len) { + return self.filtered_indices.items[visible_idx]; + } + return visible_idx; // fallback + } + return visible_idx; + } + + /// Convierte índice real a índice visible + /// Retorna null si la fila no está visible (filtrada) + pub fn realToVisibleIndex(self: *const AdvancedTableState, real_idx: usize) ?usize { + if (self.filter_active) { + for (self.filtered_indices.items, 0..) |idx, visible| { + if (idx == real_idx) return visible; + } + return null; + } + return real_idx; + } + + /// Aplica el filtro sobre las filas + /// search_columns: columnas a buscar (null = todas) + pub fn applyFilter(self: *AdvancedTableState, columns: []const schema_mod.ColumnDef) !void { + const filter_text = self.filter_bar_state.getFilterText(); + + self.filtered_indices.clearRetainingCapacity(); + + if (filter_text.len == 0) { + // Sin filtro: todas las filas visibles + self.filter_active = false; + return; + } + + self.filter_active = true; + + // Filtrar filas que contienen el texto (case-insensitive) + var format_buf: [256]u8 = undefined; + for (self.rows.items, 0..) |row, i| { + for (columns) |col| { + const value = row.get(col.name); + const text = value.format(&format_buf); + if (table_core.startsWithIgnoreCase(text, filter_text)) { + try self.filtered_indices.append(self.allocator, i); + break; + } + } + } + } + + /// Limpia el filtro + pub fn clearFilter(self: *AdvancedTableState) void { + self.filter_bar_state.clearFilterText(); + self.filter_active = false; + self.filtered_indices.clearRetainingCapacity(); + } + + /// Obtiene el texto del filtro actual + pub fn getFilterText(self: *const AdvancedTableState) []const u8 { + return self.filter_bar_state.getFilterText(); + } + // ========================================================================= // Cell Validation (from Table widget) // ========================================================================= diff --git a/src/widgets/advanced_table/types.zig b/src/widgets/advanced_table/types.zig index c4e020f..b402600 100644 --- a/src/widgets/advanced_table/types.zig +++ b/src/widgets/advanced_table/types.zig @@ -408,10 +408,12 @@ pub const TableConfig = struct { row_height: u32 = 24, state_indicator_width: u32 = 24, min_column_width: u32 = 40, + footer_height: u32 = 20, // Features show_headers: bool = true, show_row_state_indicators: bool = true, + show_footer: bool = false, // PARIDAD VirtualAdvancedTable - Enero 2026 alternating_rows: bool = true, // Editing diff --git a/src/widgets/table_core/datasource.zig b/src/widgets/table_core/datasource.zig index 5454c54..e35428f 100644 --- a/src/widgets/table_core/datasource.zig +++ b/src/widgets/table_core/datasource.zig @@ -49,6 +49,12 @@ pub const TableDataSource = struct { /// Invalida cache interno (para refresh) invalidate: ?*const fn (ptr: *anyopaque) void = null, + + /// Valida el valor de una celda antes de commit (opcional) + /// Retorna null si es válido, o un mensaje de error si no lo es. + /// El CellEditor debe llamar a esta función antes de guardar. + /// PARIDAD VirtualAdvancedTable - Enero 2026 + validateCell: ?*const fn (ptr: *anyopaque, row: usize, col: usize, value: []const u8) ?[]const u8 = null, }; // ========================================================================= @@ -97,6 +103,15 @@ pub const TableDataSource = struct { pub fn isGhostRow(self: TableDataSource, row: usize) bool { return self.getRowId(row) == NEW_ROW_ID; } + + /// Valida el valor de una celda + /// Retorna null si válido, mensaje de error si no + pub fn validateCell(self: TableDataSource, row: usize, col: usize, value: []const u8) ?[]const u8 { + if (self.vtable.validateCell) |func| { + return func(self.ptr, row, col, value); + } + return null; // Default: siempre válido + } }; /// Helper para crear TableDataSource desde un tipo concreto @@ -118,6 +133,9 @@ pub fn makeTableDataSource(comptime T: type, impl: *T) TableDataSource { if (@hasDecl(T, "invalidate")) { vt.invalidate = @ptrCast(&T.invalidate); } + if (@hasDecl(T, "validateCell")) { + vt.validateCell = @ptrCast(&T.validateCell); + } break :blk vt; }; diff --git a/src/widgets/table_core/filter_bar.zig b/src/widgets/table_core/filter_bar.zig new file mode 100644 index 0000000..83aded6 --- /dev/null +++ b/src/widgets/table_core/filter_bar.zig @@ -0,0 +1,409 @@ +//! FilterBar - Barra de filtros compartida para tablas +//! +//! Componente visual con chips y campo de búsqueda. +//! Usado por AdvancedTable y VirtualAdvancedTable. + +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 text_input = @import("../text_input.zig"); + +// ============================================================================= +// 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, +}; + +/// Colores para la barra de filtros +pub const FilterBarColors = struct { + header_background: Style.Color = Style.Color.rgb(224, 224, 224), + border: Style.Color = Style.Color.rgb(204, 204, 204), + text: Style.Color = Style.Color.rgb(0, 0, 0), + row_selected: Style.Color = Style.Color.rgb(0, 120, 212), + text_selected: Style.Color = Style.Color.rgb(255, 255, 255), +}; + +/// Estado del FilterBar (para almacenar en el state del componente padre) +pub const FilterBarState = struct { + /// Buffer de texto del filtro + filter_buf: [256]u8 = [_]u8{0} ** 256, + + /// Longitud del texto del filtro + filter_len: usize = 0, + + /// Cursor del campo de búsqueda + search_cursor: usize = 0, + + /// Inicio de selección (null = sin selección) + search_selection_start: ?usize = null, + + /// ¿El campo de búsqueda tiene focus? + search_has_focus: bool = false, + + /// Chips activos (bitfield, max 16 chips) + active_chips: u16 = 0, + + /// Flag para notificar cambios de texto + filter_text_changed: bool = false, + + /// Verifica si un chip está activo + pub fn isChipActive(self: *const FilterBarState, idx: u4) bool { + return (self.active_chips & (@as(u16, 1) << idx)) != 0; + } + + /// Activa/desactiva un chip según el modo + pub fn activateChip(self: *FilterBarState, idx: u4, mode: ChipSelectMode) void { + const mask = @as(u16, 1) << idx; + switch (mode) { + .single => { + // En modo single, solo uno activo a la vez + self.active_chips = mask; + }, + .multi => { + // En modo multi, toggle + self.active_chips ^= mask; + }, + } + } + + /// Limpia el texto del filtro + pub fn clearFilterText(self: *FilterBarState) void { + self.filter_len = 0; + @memset(&self.filter_buf, 0); + } + + /// Obtiene el texto del filtro actual + pub fn getFilterText(self: *const FilterBarState) []const u8 { + return self.filter_buf[0..self.filter_len]; + } + + /// Inicializa el estado con chips por defecto + pub fn initWithDefaults(self: *FilterBarState, chips: []const FilterChipDef) void { + self.active_chips = 0; + for (chips, 0..) |chip, idx| { + if (chip.is_default and idx < 16) { + self.active_chips |= @as(u16, 1) << @intCast(idx); + } + } + } +}; + +/// Resultado del FilterBar (cambios a reportar al padre) +pub const FilterBarResult = struct { + /// El texto del filtro cambió + filter_changed: bool = false, + + /// Texto actual del filtro (solo válido si filter_changed) + filter_text: []const u8 = "", + + /// Un chip cambió de estado + chip_changed: bool = false, + + /// Índice del chip que cambió (solo válido si chip_changed) + chip_index: u4 = 0, + + /// ¿El chip está ahora activo? + chip_active: bool = false, +}; + +// ============================================================================= +// Drawing +// ============================================================================= + +/// Dibuja la barra de filtros +pub fn drawFilterBar( + ctx: *Context, + bounds: Layout.Rect, + config: FilterBarConfig, + colors: FilterBarColors, + state: *FilterBarState, + result: *FilterBarResult, +) void { + const padding: i32 = 6; + const chip_h: u32 = 22; + const chip_padding: i32 = 10; + const chip_spacing: i32 = 6; + const chip_radius: u8 = 4; // Z-Design V2: consistente con botones + const clear_btn_w: u32 = 22; + + // Background + ctx.pushCommand(Command.rect( + bounds.x, + bounds.y, + bounds.w, + bounds.h, + colors.header_background, + )); + + // Línea inferior + 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 + if (config.chips.len > 0) { + for (config.chips, 0..) |chip, idx| { + const chip_idx: u4 = @intCast(idx); + const is_active = state.isChipActive(chip_idx); + + 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); + + const chip_bg = if (is_active) + colors.row_selected + else if (chip_hovered) + Style.Color.rgb( + colors.header_background.r -| 15, + colors.header_background.g -| 15, + colors.header_background.b -| 15, + ) + 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 + else + colors.border; + + ctx.pushCommand(Command.roundedRect( + chip_bounds.x, + chip_bounds.y, + chip_bounds.w, + chip_bounds.h, + chip_bg, + chip_radius, + )); + + if (!is_active) { + ctx.pushCommand(Command.roundedRectOutline( + chip_bounds.x, + chip_bounds.y, + chip_bounds.w, + chip_bounds.h, + chip_border, + chip_radius, + )); + } + + ctx.pushCommand(Command.text( + chip_bounds.x + chip_padding, + chip_bounds.y + 4, + chip.label, + chip_text_color, + )); + + if (chip_hovered and ctx.input.mousePressed(.left)) { + state.activateChip(chip_idx, config.chip_mode); + result.chip_changed = true; + result.chip_index = chip_idx; + result.chip_active = state.isChipActive(chip_idx); + } + + current_x += @as(i32, @intCast(chip_w)) + chip_spacing; + } + + current_x += padding; + } + + // Draw Search Input + if (config.show_search) { + const clear_space: u32 = if (config.show_clear_button) clear_btn_w + @as(u32, @intCast(padding)) else 0; + 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, + ); + + var text_state = text_input.TextInputState{ + .buffer = &state.filter_buf, + .len = state.filter_len, + .cursor = state.search_cursor, + .selection_start = state.search_selection_start, + .focused = state.search_has_focus, + }; + + const text_result = text_input.textInputRect(ctx, search_bounds, &text_state, .{ + .placeholder = config.search_placeholder, + .padding = 3, + }); + + state.filter_len = text_state.len; + state.search_cursor = text_state.cursor; + state.search_selection_start = text_state.selection_start; + + if (text_result.clicked) { + state.search_has_focus = true; + } + + if (text_result.changed) { + state.filter_text_changed = true; + result.filter_changed = true; + result.filter_text = state.filter_buf[0..state.filter_len]; + } + + current_x += @as(i32, @intCast(search_w)) + padding; + } + + // Draw Clear Button + if (config.show_clear_button and 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); + + const clear_bg = if (clear_hovered) + Style.Color.rgb(220, 80, 80) + else + Style.Color.rgb(180, 60, 60); + + const clear_text = Style.Color.rgb(255, 255, 255); + + ctx.pushCommand(Command.roundedRect( + clear_bounds.x, + clear_bounds.y, + clear_bounds.w, + clear_bounds.h, + clear_bg, + chip_radius, + )); + + ctx.pushCommand(Command.text( + clear_bounds.x + 7, + clear_bounds.y + 4, + "X", + clear_text, + )); + + if (clear_hovered and ctx.input.mousePressed(.left)) { + state.clearFilterText(); + state.search_cursor = 0; + state.search_selection_start = null; + result.filter_changed = true; + result.filter_text = ""; + } + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +test "FilterBarState chip operations" { + var state = FilterBarState{}; + + // Test single mode + state.activateChip(0, .single); + try std.testing.expect(state.isChipActive(0)); + try std.testing.expect(!state.isChipActive(1)); + + state.activateChip(1, .single); + try std.testing.expect(!state.isChipActive(0)); + try std.testing.expect(state.isChipActive(1)); + + // Test multi mode + state.active_chips = 0; + state.activateChip(0, .multi); + state.activateChip(2, .multi); + try std.testing.expect(state.isChipActive(0)); + try std.testing.expect(!state.isChipActive(1)); + try std.testing.expect(state.isChipActive(2)); + + // Toggle off + state.activateChip(0, .multi); + try std.testing.expect(!state.isChipActive(0)); +} + +test "FilterBarState filter text" { + var state = FilterBarState{}; + + // Set filter text + const text = "test"; + @memcpy(state.filter_buf[0..text.len], text); + state.filter_len = text.len; + + try std.testing.expectEqualStrings("test", state.getFilterText()); + + // Clear + state.clearFilterText(); + try std.testing.expectEqualStrings("", state.getFilterText()); +} diff --git a/src/widgets/table_core/footer.zig b/src/widgets/table_core/footer.zig new file mode 100644 index 0000000..130deca --- /dev/null +++ b/src/widgets/table_core/footer.zig @@ -0,0 +1,206 @@ +//! Footer - Pie de tabla compartido +//! +//! Muestra información de posición y conteo: "X de Y" +//! Usado por AdvancedTable y VirtualAdvancedTable. + +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"); + +// ============================================================================= +// Types +// ============================================================================= + +/// Estado de carga para contadores +pub const LoadState = enum { + /// No se ha iniciado la carga + unknown, + + /// Carga en progreso (mostrar "...") + loading, + + /// Carga parcial (mostrar "500+...") + partial, + + /// Carga completa (mostrar número final) + ready, +}; + +/// Información de conteo con estado +pub const CountInfo = struct { + /// Valor actual (puede ser parcial) + value: usize = 0, + + /// Estado de la carga + state: LoadState = .unknown, + + /// Formato para mostrar según estado + pub fn format(self: CountInfo, buf: []u8) []const u8 { + return switch (self.state) { + .unknown, .loading => "...", + .partial => std.fmt.bufPrint(buf, "{d}+...", .{self.value}) catch "...", + .ready => std.fmt.bufPrint(buf, "{d}", .{self.value}) catch "?", + }; + } +}; + +/// Configuración del footer +pub const FooterConfig = struct { + /// Altura del footer + height: u16 = 20, +}; + +/// Colores del footer +pub const FooterColors = struct { + background: Style.Color = Style.Color.rgb(224, 224, 224), + text: Style.Color = Style.Color.rgb(0, 0, 0), + border: Style.Color = Style.Color.rgb(204, 204, 204), +}; + +/// Información de posición para el footer +pub const FooterPositionInfo = struct { + /// Posición actual (1-based, o null si no hay selección) + current_position: ?usize = null, + + /// Total de elementos + total_count: CountInfo = .{}, + + /// Total filtrado (si hay filtro activo) + filtered_count: ?CountInfo = null, +}; + +/// Estado del footer (buffer de display) +pub const FooterState = struct { + display_buf: [96]u8 = [_]u8{0} ** 96, + display_len: usize = 0, + + /// Formatea y almacena el texto de display + pub fn formatDisplay(self: *FooterState, info: FooterPositionInfo) []const u8 { + var pos_buf: [32]u8 = undefined; + var count_buf: [64]u8 = undefined; + + const pos_str = if (info.current_position) |pos| + std.fmt.bufPrint(&pos_buf, "{d}", .{pos}) catch "?" + else + "-"; + + const count_str = if (info.filtered_count) |filtered| blk: { + var total_buf: [32]u8 = undefined; + const total_str = info.total_count.format(&total_buf); + const filtered_str = filtered.format(&count_buf); + break :blk std.fmt.bufPrint(&self.display_buf, "{s} de {s} ({s})", .{ pos_str, filtered_str, total_str }) catch "..."; + } else blk: { + const total_str = info.total_count.format(&count_buf); + break :blk std.fmt.bufPrint(&self.display_buf, "{s} de {s}", .{ pos_str, total_str }) catch "..."; + }; + + self.display_len = count_str.len; + return count_str; + } +}; + +// ============================================================================= +// Drawing +// ============================================================================= + +/// Dibuja el footer con información de posición +pub fn drawFooter( + ctx: *Context, + bounds: Layout.Rect, + colors: FooterColors, + display_text: []const u8, +) void { + // Background + ctx.pushCommand(Command.rect( + bounds.x, + bounds.y, + bounds.w, + bounds.h, + colors.background, + )); + + // Línea superior (separador) + ctx.pushCommand(Command.rect( + bounds.x, + bounds.y, + bounds.w, + 1, + colors.border, + )); + + // Texto + ctx.pushCommand(Command.text( + bounds.x + 4, + bounds.y + 2, + display_text, + colors.text, + )); +} + +/// Versión con formateo automático +pub fn drawFooterWithInfo( + ctx: *Context, + bounds: Layout.Rect, + colors: FooterColors, + info: FooterPositionInfo, + state: *FooterState, +) void { + const display_text = state.formatDisplay(info); + drawFooter(ctx, bounds, colors, display_text); +} + +// ============================================================================= +// Tests +// ============================================================================= + +test "CountInfo format" { + var buf: [32]u8 = undefined; + + var info = CountInfo{ .state = .loading }; + try std.testing.expectEqualStrings("...", info.format(&buf)); + + info = CountInfo{ .value = 500, .state = .partial }; + try std.testing.expectEqualStrings("500+...", info.format(&buf)); + + info = CountInfo{ .value = 1234, .state = .ready }; + try std.testing.expectEqualStrings("1234", info.format(&buf)); +} + +test "FooterState formatDisplay basic" { + var state = FooterState{}; + + const info = FooterPositionInfo{ + .current_position = 5, + .total_count = .{ .value = 100, .state = .ready }, + }; + + const result = state.formatDisplay(info); + try std.testing.expectEqualStrings("5 de 100", result); +} + +test "FooterState formatDisplay with filter" { + var state = FooterState{}; + + const info = FooterPositionInfo{ + .current_position = 3, + .total_count = .{ .value = 1000, .state = .ready }, + .filtered_count = .{ .value = 25, .state = .ready }, + }; + + const result = state.formatDisplay(info); + try std.testing.expectEqualStrings("3 de 25 (1000)", result); +} + +test "FooterState formatDisplay no selection" { + var state = FooterState{}; + + const info = FooterPositionInfo{ + .current_position = null, + .total_count = .{ .value = 50, .state = .ready }, + }; + + const result = state.formatDisplay(info); + try std.testing.expectEqualStrings("- de 50", result); +} diff --git a/src/widgets/table_core/table_core.zig b/src/widgets/table_core/table_core.zig index 9894670..2055966 100644 --- a/src/widgets/table_core/table_core.zig +++ b/src/widgets/table_core/table_core.zig @@ -120,6 +120,27 @@ pub const utils = @import("utils.zig"); pub const blendColor = utils.blendColor; pub const startsWithIgnoreCase = utils.startsWithIgnoreCase; +// FilterBar - Barra de filtros compartida +pub const filter_bar = @import("filter_bar.zig"); +pub const FilterBarConfig = filter_bar.FilterBarConfig; +pub const FilterBarColors = filter_bar.FilterBarColors; +pub const FilterBarState = filter_bar.FilterBarState; +pub const FilterBarResult = filter_bar.FilterBarResult; +pub const FilterChipDef = filter_bar.FilterChipDef; +pub const ChipSelectMode = filter_bar.ChipSelectMode; +pub const drawFilterBar = filter_bar.drawFilterBar; + +// Footer - Pie de tabla compartido +pub const footer = @import("footer.zig"); +pub const FooterConfig = footer.FooterConfig; +pub const FooterColors = footer.FooterColors; +pub const FooterState = footer.FooterState; +pub const FooterPositionInfo = footer.FooterPositionInfo; +pub const CountInfo = footer.CountInfo; +pub const LoadState = footer.LoadState; +pub const drawFooter = footer.drawFooter; +pub const drawFooterWithInfo = footer.drawFooterWithInfo; + // ============================================================================= // Tests (re-export de todos los módulos) // ============================================================================= @@ -134,4 +155,6 @@ test { _ = @import("rendering.zig"); _ = @import("scrollbars.zig"); _ = @import("utils.zig"); + _ = @import("filter_bar.zig"); + _ = @import("footer.zig"); } diff --git a/src/widgets/virtual_advanced_table/drawing.zig b/src/widgets/virtual_advanced_table/drawing.zig index ea36d2f..8c68877 100644 --- a/src/widgets/virtual_advanced_table/drawing.zig +++ b/src/widgets/virtual_advanced_table/drawing.zig @@ -2,6 +2,9 @@ //! //! Funciones de renderizado extraídas del archivo principal para mejorar //! modularidad y reducir el tamaño del archivo principal. +//! +//! NOTA DRY (2026-01): drawFilterBar y drawFooter delegan a table_core +//! para compartir código con AdvancedTable. const std = @import("std"); const Context = @import("../../core/context.zig").Context; @@ -21,9 +24,37 @@ pub const FilterBarConfig = types.FilterBarConfig; pub const VirtualAdvancedTableResult = @import("virtual_advanced_table.zig").VirtualAdvancedTableResult; // ============================================================================= -// Draw: FilterBar +// Draw: FilterBar (delega a table_core para DRY) // ============================================================================= +/// Wrapper que adapta VirtualAdvancedTableState a FilterBarState interface +const VirtualFilterBarAdapter = struct { + state: *VirtualAdvancedTableState, + + fn toFilterBarState(self: *VirtualFilterBarAdapter) table_core.FilterBarState { + var fb_state = table_core.FilterBarState{ + .filter_len = self.state.filter_len, + .search_cursor = self.state.search_cursor, + .search_selection_start = self.state.search_selection_start, + .search_has_focus = self.state.search_has_focus, + .active_chips = self.state.active_chips, + .filter_text_changed = self.state.filter_text_changed, + }; + @memcpy(&fb_state.filter_buf, &self.state.filter_buf); + return fb_state; + } + + fn syncFromFilterBarState(self: *VirtualFilterBarAdapter, fb_state: *const table_core.FilterBarState) void { + @memcpy(&self.state.filter_buf, &fb_state.filter_buf); + self.state.filter_len = fb_state.filter_len; + self.state.search_cursor = fb_state.search_cursor; + self.state.search_selection_start = fb_state.search_selection_start; + self.state.search_has_focus = fb_state.search_has_focus; + self.state.active_chips = fb_state.active_chips; + self.state.filter_text_changed = fb_state.filter_text_changed; + } +}; + pub fn drawFilterBar( ctx: *Context, bounds: Layout.Rect, @@ -32,200 +63,45 @@ pub fn drawFilterBar( list_state: *VirtualAdvancedTableState, result: *VirtualAdvancedTableResult, ) void { - const padding: i32 = 6; - const chip_h: u32 = 22; - const chip_padding: i32 = 10; - const chip_spacing: i32 = 6; - const chip_radius: u8 = 4; // Z-Design V2: consistente con botones - const clear_btn_w: u32 = 22; + // Convertir tipos de Virtual a tipos genéricos de table_core + const tc_config = table_core.FilterBarConfig{ + .show_search = config.show_search, + .search_placeholder = config.search_placeholder, + .search_debounce_ms = config.search_debounce_ms, + .chips = @ptrCast(config.chips), // FilterChipDef es compatible + .chip_mode = @enumFromInt(@intFromEnum(config.chip_mode)), + .show_clear_button = config.show_clear_button, + .height = config.height, + }; - // Background - ctx.pushCommand(Command.rect( - bounds.x, - bounds.y, - bounds.w, - bounds.h, - colors.header_background, - )); + const tc_colors = table_core.FilterBarColors{ + .header_background = colors.header_background, + .border = colors.border, + .text = colors.text, + .row_selected = colors.row_selected, + .text_selected = colors.text_selected, + }; - // Línea inferior - ctx.pushCommand(Command.rect( - bounds.x, - bounds.y + @as(i32, @intCast(bounds.h)) - 1, - bounds.w, - 1, - colors.border, - )); + // Crear adapter para sincronizar estado + var adapter = VirtualFilterBarAdapter{ .state = list_state }; + var fb_state = adapter.toFilterBarState(); - 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(); + // Llamar a implementación compartida + var fb_result = table_core.FilterBarResult{}; + table_core.drawFilterBar(ctx, bounds, tc_config, tc_colors, &fb_state, &fb_result); - // Draw Chips - 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); + // Sincronizar estado de vuelta + adapter.syncFromFilterBarState(&fb_state); - 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); - - const chip_bg = if (is_active) - colors.row_selected - else if (chip_hovered) - Style.Color.rgb( - colors.header_background.r -| 15, - colors.header_background.g -| 15, - colors.header_background.b -| 15, - ) - 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 - else - colors.border; - - ctx.pushCommand(Command.roundedRect( - chip_bounds.x, - chip_bounds.y, - chip_bounds.w, - chip_bounds.h, - chip_bg, - chip_radius, - )); - - if (!is_active) { - ctx.pushCommand(Command.roundedRectOutline( - chip_bounds.x, - chip_bounds.y, - chip_bounds.w, - chip_bounds.h, - chip_border, - chip_radius, - )); - } - - ctx.pushCommand(Command.text( - chip_bounds.x + chip_padding, - chip_bounds.y + 4, - chip.label, - chip_text_color, - )); - - 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; + // Propagar resultados + if (fb_result.filter_changed) { + result.filter_changed = true; + result.filter_text = list_state.filter_buf[0..list_state.filter_len]; } - - // Draw Search Input - if (config.show_search) { - const clear_space: u32 = if (config.show_clear_button) clear_btn_w + @as(u32, @intCast(padding)) else 0; - 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, - ); - - 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, - }); - - list_state.filter_len = text_state.len; - list_state.search_cursor = text_state.cursor; - list_state.search_selection_start = text_state.selection_start; - - if (text_result.clicked) { - list_state.search_has_focus = true; - } - - 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 - 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); - - const clear_bg = if (clear_hovered) - Style.Color.rgb(220, 80, 80) - else - Style.Color.rgb(180, 60, 60); - - const clear_text = Style.Color.rgb(255, 255, 255); - - ctx.pushCommand(Command.roundedRect( - clear_bounds.x, - clear_bounds.y, - clear_bounds.w, - clear_bounds.h, - clear_bg, - chip_radius, - )); - - ctx.pushCommand(Command.text( - clear_bounds.x + 7, - clear_bounds.y + 4, - "X", - clear_text, - )); - - 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 = ""; - } + if (fb_result.chip_changed) { + result.chip_changed = true; + result.chip_index = fb_result.chip_index; + result.chip_active = fb_result.chip_active; } } @@ -391,7 +267,7 @@ pub fn drawRows( } // ============================================================================= -// Draw: Footer +// Draw: Footer (delega a table_core para DRY) // ============================================================================= pub fn drawFooter( @@ -400,36 +276,47 @@ pub fn drawFooter( colors: *const VirtualAdvancedTableConfig.Colors, list_state: *VirtualAdvancedTableState, ) void { - ctx.pushCommand(Command.rect( - bounds.x, - bounds.y, - bounds.w, - bounds.h, - colors.header_background, - )); - - var count_buf: [64]u8 = undefined; - const count_info = list_state.getDisplayCount(); - const count_str = count_info.format(&count_buf); - - var pos_buf: [32]u8 = undefined; - const pos_str = if (list_state.selected_id != null) + // Construir información de posición + const current_pos: ?usize = if (list_state.selected_id != null) if (list_state.findSelectedInWindow()) |idx| - std.fmt.bufPrint(&pos_buf, "{d}", .{list_state.windowToGlobalIndex(idx) + 1}) catch "?" + list_state.windowToGlobalIndex(idx) + 1 else - "?" + null else - "-"; + null; - const display_str = std.fmt.bufPrint(&list_state.footer_display_buf, "{s} de {s}", .{ pos_str, count_str }) catch "..."; - list_state.footer_display_len = display_str.len; + // Convertir CountInfo de Virtual a table_core + const display_count = list_state.getDisplayCount(); + const tc_total = table_core.CountInfo{ + .value = display_count.value, + .state = @enumFromInt(@intFromEnum(display_count.state)), + }; - ctx.pushCommand(Command.text( - bounds.x + 4, - bounds.y + 2, + const pos_info = table_core.FooterPositionInfo{ + .current_position = current_pos, + .total_count = tc_total, + .filtered_count = null, // VirtualAdvancedTable ya usa filtered_count internamente + }; + + // Usar buffer del state para persistencia + var footer_state = table_core.FooterState{}; + const display_text = footer_state.formatDisplay(pos_info); + + // Copiar al buffer persistente del list_state + @memcpy(list_state.footer_display_buf[0..display_text.len], display_text); + list_state.footer_display_len = display_text.len; + + // Llamar a implementación compartida + table_core.drawFooter( + ctx, + bounds, + table_core.FooterColors{ + .background = colors.header_background, + .text = colors.text, + .border = colors.border, + }, list_state.footer_display_buf[0..list_state.footer_display_len], - colors.text, - )); + ); } // =============================================================================