feat(table_core): DRY - FilterBar y Footer compartidos + paridad AdvancedTable

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 <noreply@anthropic.com>
This commit is contained in:
R.Eugenio 2026-01-04 02:40:54 +01:00
parent 1155e904cb
commit 1f2d4abb0b
10 changed files with 1004 additions and 230 deletions

View file

@ -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);

View file

@ -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)
// =========================================================================

View file

@ -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
// =========================================================================

View file

@ -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)
// =========================================================================

View file

@ -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

View file

@ -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;
};

View file

@ -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());
}

View file

@ -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);
}

View file

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

View file

@ -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;
// 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 = list_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)) {
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;
}
// 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,
// 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,
};
const text_result = text_input.textInputRect(ctx, search_bounds, &text_state, .{
.placeholder = config.search_placeholder,
.padding = 3,
});
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,
};
list_state.filter_len = text_state.len;
list_state.search_cursor = text_state.cursor;
list_state.search_selection_start = text_state.selection_start;
// Crear adapter para sincronizar estado
var adapter = VirtualFilterBarAdapter{ .state = list_state };
var fb_state = adapter.toFilterBarState();
if (text_result.clicked) {
list_state.search_has_focus = true;
}
// Llamar a implementación compartida
var fb_result = table_core.FilterBarResult{};
table_core.drawFilterBar(ctx, bounds, tc_config, tc_colors, &fb_state, &fb_result);
if (text_result.changed) {
list_state.filter_text_changed = true;
// Sincronizar estado de vuelta
adapter.syncFromFilterBarState(&fb_state);
// Propagar resultados
if (fb_result.filter_changed) {
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,
));
);
}
// =============================================================================