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:
parent
1155e904cb
commit
1f2d4abb0b
10 changed files with 1004 additions and 230 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
409
src/widgets/table_core/filter_bar.zig
Normal file
409
src/widgets/table_core/filter_bar.zig
Normal 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());
|
||||
}
|
||||
206
src/widgets/table_core/footer.zig
Normal file
206
src/widgets/table_core/footer.zig
Normal 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);
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
));
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
Loading…
Reference in a new issue