Compare commits

..

2 commits

Author SHA1 Message Date
067bac47fd feat(window): Añadir WindowState y Panel interface con app_ctx
Sistema de ventanas dinámicas donde:
- Panel interface usa app_ctx: ?*anyopaque (desacoplado)
- WindowState gestiona lista de paneles con BoundedArray(8)
- El ID del panel viaja con el panel (elimina errores de mismatch)
- Focus groups encapsulados con base_focus_group

Exports:
- WindowState, WindowPanel, WindowPanelInfo, makeWindowPanel
- window module completo

Parte de refactorización consensuada (R.Eugenio, Claude, Gemini)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 21:50:51 +01:00
1f2d4abb0b 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>
2026-01-04 02:40:54 +01:00
12 changed files with 1342 additions and 230 deletions

331
src/core/window.zig Normal file
View file

@ -0,0 +1,331 @@
//! Window State - Sistema de ventanas con paneles dinámicos
//!
//! Arquitectura LEGO donde:
//! - WindowState contiene una lista de paneles
//! - El ID del panel viaja con el panel (elimina errores de mismatch)
//! - Los paneles usan app_ctx opaco (desacoplado de aplicación)
//!
//! Diseño consensuado: R.Eugenio, Claude, Gemini (2026-01-04)
const std = @import("std");
const Context = @import("context.zig").Context;
const Layout = @import("layout.zig");
const Rect = Layout.Rect;
// =============================================================================
// Panel Interface (con contexto opaco)
// =============================================================================
/// Información del panel para layout y identificación
pub const PanelInfo = struct {
/// Nombre visible del panel
name: []const u8,
/// Identificador único - VIAJA CON EL PANEL
id: []const u8,
/// Ancho mínimo requerido
min_width: u32 = 200,
/// Alto mínimo requerido
min_height: u32 = 100,
/// Si el panel puede recibir foco
focusable: bool = true,
/// Zona preferida para layout
preferred_zone: Zone = .center,
/// Tecla de acceso rápido: Ctrl+focus_key (1-9, null = sin atajo)
focus_key: ?u8 = null,
};
/// Zonas de layout disponibles
pub const Zone = enum {
left,
center,
right,
top,
bottom,
modal,
};
/// Evento simplificado para paneles
pub const Event = union(enum) {
key: KeyEvent,
mouse: MouseEvent,
resize: ResizeEvent,
text_input: TextInputEvent,
quit,
pub const KeyEvent = struct {
key: u32,
pressed: bool,
modifiers: Modifiers,
};
pub const MouseEvent = struct {
x: i32,
y: i32,
button: ?MouseButton,
pressed: bool,
};
pub const ResizeEvent = struct {
width: u32,
height: u32,
};
pub const TextInputEvent = struct {
text: [32]u8,
len: usize,
};
pub const MouseButton = enum { left, middle, right };
pub const Modifiers = struct {
ctrl: bool = false,
shift: bool = false,
alt: bool = false,
};
};
/// Interface Panel con contexto opaco (desacoplado de aplicación)
pub const Panel = struct {
ptr: *anyopaque,
vtable: *const VTable,
pub const VTable = struct {
/// Dibuja el panel en el rectángulo asignado
/// app_ctx es opaco - la aplicación lo castea a su tipo (ej: *DataManager)
draw: *const fn (ptr: *anyopaque, ctx: *Context, rect: Rect, app_ctx: ?*anyopaque) void,
/// Maneja eventos de entrada
/// Retorna true si el evento fue consumido
handleEvent: *const fn (ptr: *anyopaque, event: Event, app_ctx: ?*anyopaque) bool,
/// Obtiene información del panel (incluye ID único)
getInfo: *const fn (ptr: *anyopaque) PanelInfo,
/// Llamado cuando el panel gana foco (opcional)
onFocus: ?*const fn (ptr: *anyopaque, app_ctx: ?*anyopaque) void = null,
/// Llamado cuando el panel pierde foco (opcional)
onBlur: ?*const fn (ptr: *anyopaque, app_ctx: ?*anyopaque) void = null,
};
/// Dibuja el panel
pub fn draw(self: Panel, ctx: *Context, rect: Rect, app_ctx: ?*anyopaque) void {
self.vtable.draw(self.ptr, ctx, rect, app_ctx);
}
/// Maneja un evento
pub fn handleEvent(self: Panel, event: Event, app_ctx: ?*anyopaque) bool {
return self.vtable.handleEvent(self.ptr, event, app_ctx);
}
/// Obtiene información del panel
pub fn getInfo(self: Panel) PanelInfo {
return self.vtable.getInfo(self.ptr);
}
/// Notifica que el panel ganó foco
pub fn onFocus(self: Panel, app_ctx: ?*anyopaque) void {
if (self.vtable.onFocus) |f| {
f(self.ptr, app_ctx);
}
}
/// Notifica que el panel perdió foco
pub fn onBlur(self: Panel, app_ctx: ?*anyopaque) void {
if (self.vtable.onBlur) |f| {
f(self.ptr, app_ctx);
}
}
};
/// Crea un Panel desde una implementación concreta
/// T debe tener: info (const PanelInfo), draw(), handleEvent()
/// Opcionales: onFocus(), onBlur()
pub fn makePanel(comptime T: type, impl: *T) Panel {
const gen = struct {
fn draw(ptr: *anyopaque, ctx: *Context, rect: Rect, app_ctx: ?*anyopaque) void {
const self: *T = @ptrCast(@alignCast(ptr));
self.draw(ctx, rect, app_ctx);
}
fn handleEvent(ptr: *anyopaque, event: Event, app_ctx: ?*anyopaque) bool {
const self: *T = @ptrCast(@alignCast(ptr));
return self.handleEvent(event, app_ctx);
}
fn getInfo(ptr: *anyopaque) PanelInfo {
_ = ptr;
return T.info;
}
fn onFocus(ptr: *anyopaque, app_ctx: ?*anyopaque) void {
if (@hasDecl(T, "onFocus")) {
const self: *T = @ptrCast(@alignCast(ptr));
self.onFocus(app_ctx);
}
}
fn onBlur(ptr: *anyopaque, app_ctx: ?*anyopaque) void {
if (@hasDecl(T, "onBlur")) {
const self: *T = @ptrCast(@alignCast(ptr));
self.onBlur(app_ctx);
}
}
const vtable = Panel.VTable{
.draw = draw,
.handleEvent = handleEvent,
.getInfo = getInfo,
.onFocus = if (@hasDecl(T, "onFocus")) onFocus else null,
.onBlur = if (@hasDecl(T, "onBlur")) onBlur else null,
};
};
return Panel{
.ptr = impl,
.vtable = &gen.vtable,
};
}
// =============================================================================
// WindowState
// =============================================================================
/// Máximo de paneles por ventana (decisión pragmática: rápido, suficiente)
pub const MAX_PANELS = 8;
/// Estado de una ventana con paneles dinámicos
pub const WindowState = struct {
/// Identificador de la ventana
id: []const u8,
/// Lista de paneles (BoundedArray - sin allocations dinámicas)
panels: std.BoundedArray(Panel, MAX_PANELS),
/// Rectángulos para cada panel (actualizados externamente por layout)
rects: [MAX_PANELS]Rect,
/// Índice del panel con foco (dentro de esta ventana)
focused_idx: usize,
/// Base de focus groups para esta ventana (ej: clientes=1, config=5)
base_focus_group: u64,
const Self = @This();
/// Inicializa una ventana vacía
pub fn init(id: []const u8, base_focus_group: u64) Self {
return .{
.id = id,
.panels = .{},
.rects = [_]Rect{.{ .x = 0, .y = 0, .w = 0, .h = 0 }} ** MAX_PANELS,
.focused_idx = 0,
.base_focus_group = base_focus_group,
};
}
/// Añade un panel a la ventana
pub fn addPanel(self: *Self, panel: Panel) error{Overflow}!void {
try self.panels.append(panel);
}
/// Actualiza los rectángulos de los paneles (llamado por layout externo)
pub fn updateRects(self: *Self, rects: []const Rect) void {
const len = @min(rects.len, self.panels.len);
for (0..len) |i| {
self.rects[i] = rects[i];
}
}
/// Dibuja todos los paneles - EL ID VIAJA CON EL PANEL
/// Elimina errores de mismatch de IDs por construcción
pub fn draw(self: *Self, ctx: *Context, app_ctx: ?*anyopaque) void {
for (self.panels.slice(), 0..) |panel, i| {
const info = panel.getInfo();
const rect = self.rects[i];
// Registrar área con el ID REAL del panel
ctx.setRegistrationGroup(self.base_focus_group + @as(u64, @intCast(i)));
// Solo dibujar si está dirty (optimización LEGO)
if (ctx.isPanelDirty(info.id)) {
panel.draw(ctx, rect, app_ctx);
}
}
}
/// Mueve el foco al siguiente panel (F6)
pub fn focusNext(self: *Self, ctx: *Context) void {
if (self.panels.len == 0) return;
// Notificar blur al panel actual
const current = self.panels.slice()[self.focused_idx];
current.onBlur(null);
// Mover al siguiente
self.focused_idx = (self.focused_idx + 1) % self.panels.len;
// Notificar focus al nuevo panel
const next = self.panels.slice()[self.focused_idx];
next.onFocus(null);
// Actualizar focus group activo
ctx.setActiveFocusGroup(self.base_focus_group + @as(u64, @intCast(self.focused_idx)));
}
/// Mueve el foco al panel anterior (Shift+F6)
pub fn focusPrev(self: *Self, ctx: *Context) void {
if (self.panels.len == 0) return;
// Notificar blur al panel actual
const current = self.panels.slice()[self.focused_idx];
current.onBlur(null);
// Mover al anterior
self.focused_idx = if (self.focused_idx == 0) self.panels.len - 1 else self.focused_idx - 1;
// Notificar focus al nuevo panel
const prev = self.panels.slice()[self.focused_idx];
prev.onFocus(null);
// Actualizar focus group activo
ctx.setActiveFocusGroup(self.base_focus_group + @as(u64, @intCast(self.focused_idx)));
}
/// Enfoca un panel por índice (Ctrl+1, Ctrl+2, etc.)
pub fn focusByIndex(self: *Self, ctx: *Context, idx: usize) void {
if (idx >= self.panels.len) return;
// Notificar blur al panel actual
const current = self.panels.slice()[self.focused_idx];
current.onBlur(null);
// Cambiar índice
self.focused_idx = idx;
// Notificar focus al nuevo panel
const target = self.panels.slice()[self.focused_idx];
target.onFocus(null);
// Actualizar focus group activo
ctx.setActiveFocusGroup(self.base_focus_group + @as(u64, @intCast(self.focused_idx)));
}
/// Obtiene el panel actualmente enfocado
pub fn getFocusedPanel(self: *Self) ?Panel {
if (self.panels.len == 0) return null;
return self.panels.slice()[self.focused_idx];
}
/// Despacha un evento al panel enfocado
pub fn handleEvent(self: *Self, event: Event, app_ctx: ?*anyopaque) bool {
if (self.panels.len == 0) return false;
const focused = self.panels.slice()[self.focused_idx];
return focused.handleEvent(event, app_ctx);
}
/// Número de paneles en la ventana
pub fn panelCount(self: *const Self) usize {
return self.panels.len;
}
};

View file

@ -59,6 +59,11 @@ pub const MemoryDataSource = datasource.MemoryDataSource;
// Re-export table_core types // Re-export table_core types
pub const NavigateDirection = table_core.NavigateDirection; 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 // Re-export helpers for external use
pub const blendColor = helpers.blendColor; pub const blendColor = helpers.blendColor;
@ -134,24 +139,68 @@ pub fn advancedTableRect(
// Calculate dimensions // Calculate dimensions
const state_col_w: u32 = if (config.show_row_state_indicators) config.state_indicator_width else 0; 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 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); 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 // Begin clipping
ctx.pushCommand(Command.clip(bounds.x, bounds.y, bounds.w, bounds.h)); ctx.pushCommand(Command.clip(bounds.x, bounds.y, bounds.w, bounds.h));
// Draw header // Draw FilterBar (if configured)
if (config.show_headers) { if (table_schema.filter_bar) |fb_config| {
drawing.drawHeader(ctx, bounds, table_state, table_schema, state_col_w, colors, &result); 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 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) // 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 // Construir ColumnRenderDefs para la función unificada
var col_defs: [64]table_core.ColumnRenderDef = undefined; 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 // 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 // Esto asegura que tablas vacías o con pocas filas no muestren negro
ctx.pushCommand(Command.rect( ctx.pushCommand(Command.rect(
bounds.x, content_bounds.x,
bounds.y + @as(i32, @intCast(header_h)), content_bounds.y + @as(i32, @intCast(header_h)),
bounds.w, content_bounds.w,
content_h, content_h,
colors.row_normal, colors.row_normal,
)); ));
@ -199,9 +248,9 @@ pub fn advancedTableRect(
var cell_buffer: [256]u8 = undefined; var cell_buffer: [256]u8 = undefined;
_ = table_core.drawRowsWithDataSource(ctx, data_src, .{ _ = table_core.drawRowsWithDataSource(ctx, data_src, .{
.bounds_x = bounds.x, .bounds_x = content_bounds.x,
.bounds_y = bounds.y + @as(i32, @intCast(header_h)), .bounds_y = content_bounds.y + @as(i32, @intCast(header_h)),
.bounds_w = bounds.w, .bounds_w = content_bounds.w,
.row_height = config.row_height, .row_height = config.row_height,
.first_row = first_visible, .first_row = first_visible,
.last_row = last_visible, .last_row = last_visible,
@ -217,6 +266,49 @@ pub fn advancedTableRect(
.edit_buffer = &table_state.row_edit_buffer, .edit_buffer = &table_state.row_edit_buffer,
}, &cell_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 // End clipping
ctx.pushCommand(Command.clipEnd()); ctx.pushCommand(Command.clipEnd());
@ -236,8 +328,8 @@ pub fn advancedTableRect(
} }
// Draw scrollbar if needed // Draw scrollbar if needed
if (table_state.getRowCount() > visible_rows) { if (row_count > visible_rows) {
drawing.drawScrollbar(ctx, bounds, table_state, visible_rows, config, colors); drawing.drawScrollbar(ctx, content_bounds, table_state, visible_rows, config, colors);
} }
// Handle keyboard // Handle keyboard
@ -247,7 +339,7 @@ pub fn advancedTableRect(
input.handleEditingKeyboard(ctx, table_state, table_schema, &result); input.handleEditingKeyboard(ctx, table_state, table_schema, &result);
// Draw editing overlay // 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) { } else if (config.keyboard_nav) {
// Handle navigation keyboard // Handle navigation keyboard
input.handleKeyboard(ctx, table_state, table_schema, visible_rows, &result); input.handleKeyboard(ctx, table_state, table_schema, visible_rows, &result);

View file

@ -41,6 +41,25 @@ pub const AdvancedTableResult = struct {
// Focus // Focus
clicked: bool = false, 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) // Edición CRUD Excel-style (simétrico con VirtualAdvancedTableResult)
// ========================================================================= // =========================================================================

View file

@ -5,8 +5,13 @@
const std = @import("std"); const std = @import("std");
const types = @import("types.zig"); const types = @import("types.zig");
const table_core = @import("../table_core/table_core.zig");
pub const CellValue = types.CellValue; 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 ColumnType = types.ColumnType;
pub const RowLockState = types.RowLockState; pub const RowLockState = types.RowLockState;
pub const Row = types.Row; pub const Row = types.Row;
@ -214,6 +219,9 @@ pub const TableSchema = struct {
/// DataStore for persistence (optional) /// DataStore for persistence (optional)
data_store: ?DataStore = null, data_store: ?DataStore = null,
/// FilterBar configuration (optional, null = no filter bar)
filter_bar: ?FilterBarConfig = null,
// ========================================================================= // =========================================================================
// Global Callbacks // Global Callbacks
// ========================================================================= // =========================================================================

View file

@ -16,6 +16,9 @@ pub const CRUDAction = types.CRUDAction;
pub const Row = types.Row; pub const Row = types.Row;
pub const TableSchema = schema_mod.TableSchema; pub const TableSchema = schema_mod.TableSchema;
pub const MAX_EDIT_BUFFER = types.MAX_EDIT_BUFFER; 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 // Re-export AdvancedTableResult desde result.zig
pub const AdvancedTableResult = result_mod.AdvancedTableResult; pub const AdvancedTableResult = result_mod.AdvancedTableResult;
@ -78,6 +81,32 @@ pub const AdvancedTableState = struct {
/// Search timeout in ms (reset after this) /// Search timeout in ms (reset after this)
search_timeout_ms: u64 = 1000, 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) // Cell Validation (from Table widget)
// ========================================================================= // =========================================================================
@ -271,6 +300,9 @@ pub const AdvancedTableState = struct {
} }
self.original_order.deinit(self.allocator); 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; 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) // Cell Validation (from Table widget)
// ========================================================================= // =========================================================================

View file

@ -408,10 +408,12 @@ pub const TableConfig = struct {
row_height: u32 = 24, row_height: u32 = 24,
state_indicator_width: u32 = 24, state_indicator_width: u32 = 24,
min_column_width: u32 = 40, min_column_width: u32 = 40,
footer_height: u32 = 20,
// Features // Features
show_headers: bool = true, show_headers: bool = true,
show_row_state_indicators: bool = true, show_row_state_indicators: bool = true,
show_footer: bool = false, // PARIDAD VirtualAdvancedTable - Enero 2026
alternating_rows: bool = true, alternating_rows: bool = true,
// Editing // Editing

View file

@ -49,6 +49,12 @@ pub const TableDataSource = struct {
/// Invalida cache interno (para refresh) /// Invalida cache interno (para refresh)
invalidate: ?*const fn (ptr: *anyopaque) void = null, 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 { pub fn isGhostRow(self: TableDataSource, row: usize) bool {
return self.getRowId(row) == NEW_ROW_ID; 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 /// 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")) { if (@hasDecl(T, "invalidate")) {
vt.invalidate = @ptrCast(&T.invalidate); vt.invalidate = @ptrCast(&T.invalidate);
} }
if (@hasDecl(T, "validateCell")) {
vt.validateCell = @ptrCast(&T.validateCell);
}
break :blk vt; 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 blendColor = utils.blendColor;
pub const startsWithIgnoreCase = utils.startsWithIgnoreCase; 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) // Tests (re-export de todos los módulos)
// ============================================================================= // =============================================================================
@ -134,4 +155,6 @@ test {
_ = @import("rendering.zig"); _ = @import("rendering.zig");
_ = @import("scrollbars.zig"); _ = @import("scrollbars.zig");
_ = @import("utils.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 //! Funciones de renderizado extraídas del archivo principal para mejorar
//! modularidad y reducir el tamaño del archivo principal. //! 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 std = @import("std");
const Context = @import("../../core/context.zig").Context; 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; 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( pub fn drawFilterBar(
ctx: *Context, ctx: *Context,
bounds: Layout.Rect, bounds: Layout.Rect,
@ -32,200 +63,45 @@ pub fn drawFilterBar(
list_state: *VirtualAdvancedTableState, list_state: *VirtualAdvancedTableState,
result: *VirtualAdvancedTableResult, result: *VirtualAdvancedTableResult,
) void { ) void {
const padding: i32 = 6; // Convertir tipos de Virtual a tipos genéricos de table_core
const chip_h: u32 = 22; const tc_config = table_core.FilterBarConfig{
const chip_padding: i32 = 10; .show_search = config.show_search,
const chip_spacing: i32 = 6; .search_placeholder = config.search_placeholder,
const chip_radius: u8 = 4; // Z-Design V2: consistente con botones .search_debounce_ms = config.search_debounce_ms,
const clear_btn_w: u32 = 22; .chips = @ptrCast(config.chips), // FilterChipDef es compatible
.chip_mode = @enumFromInt(@intFromEnum(config.chip_mode)),
.show_clear_button = config.show_clear_button,
.height = config.height,
};
// Background const tc_colors = table_core.FilterBarColors{
ctx.pushCommand(Command.rect( .header_background = colors.header_background,
bounds.x, .border = colors.border,
bounds.y, .text = colors.text,
bounds.w, .row_selected = colors.row_selected,
bounds.h, .text_selected = colors.text_selected,
colors.header_background, };
));
// Línea inferior // Crear adapter para sincronizar estado
ctx.pushCommand(Command.rect( var adapter = VirtualFilterBarAdapter{ .state = list_state };
bounds.x, var fb_state = adapter.toFilterBarState();
bounds.y + @as(i32, @intCast(bounds.h)) - 1,
bounds.w,
1,
colors.border,
));
var current_x = bounds.x + padding; // Llamar a implementación compartida
const item_y = bounds.y + @divTrunc(@as(i32, @intCast(bounds.h)) - @as(i32, @intCast(chip_h)), 2); var fb_result = table_core.FilterBarResult{};
const item_h = bounds.h -| @as(u32, @intCast(padding * 2)); table_core.drawFilterBar(ctx, bounds, tc_config, tc_colors, &fb_state, &fb_result);
const mouse = ctx.input.mousePos();
// Draw Chips // Sincronizar estado de vuelta
if (config.chips.len > 0) { adapter.syncFromFilterBarState(&fb_state);
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; // Propagar resultados
const chip_w: u32 = @intCast(label_len * 7 + chip_padding * 2); if (fb_result.filter_changed) {
result.filter_changed = true;
const chip_bounds = Layout.Rect.init( result.filter_text = list_state.filter_buf[0..list_state.filter_len];
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;
} }
if (fb_result.chip_changed) {
// Draw Search Input result.chip_changed = true;
if (config.show_search) { result.chip_index = fb_result.chip_index;
const clear_space: u32 = if (config.show_clear_button) clear_btn_w + @as(u32, @intCast(padding)) else 0; result.chip_active = fb_result.chip_active;
const search_end = bounds.x + @as(i32, @intCast(bounds.w)) - padding - @as(i32, @intCast(clear_space));
const search_w: u32 = @intCast(@max(60, search_end - current_x));
const search_bounds = Layout.Rect.init(
current_x,
item_y,
search_w,
item_h,
);
var text_state = text_input.TextInputState{
.buffer = &list_state.filter_buf,
.len = list_state.filter_len,
.cursor = list_state.search_cursor,
.selection_start = list_state.search_selection_start,
.focused = list_state.search_has_focus,
};
const text_result = text_input.textInputRect(ctx, search_bounds, &text_state, .{
.placeholder = config.search_placeholder,
.padding = 3,
});
list_state.filter_len = text_state.len;
list_state.search_cursor = text_state.cursor;
list_state.search_selection_start = text_state.selection_start;
if (text_result.clicked) {
list_state.search_has_focus = true;
}
if (text_result.changed) {
list_state.filter_text_changed = true;
result.filter_changed = true;
result.filter_text = list_state.filter_buf[0..list_state.filter_len];
}
current_x += @as(i32, @intCast(search_w)) + padding;
}
// Draw Clear Button
if (config.show_clear_button and list_state.filter_len > 0) {
const clear_x = bounds.x + @as(i32, @intCast(bounds.w - clear_btn_w)) - padding;
const clear_bounds = Layout.Rect.init(
clear_x,
item_y,
clear_btn_w,
chip_h,
);
const clear_hovered = clear_bounds.contains(mouse.x, mouse.y);
const clear_bg = if (clear_hovered)
Style.Color.rgb(220, 80, 80)
else
Style.Color.rgb(180, 60, 60);
const clear_text = Style.Color.rgb(255, 255, 255);
ctx.pushCommand(Command.roundedRect(
clear_bounds.x,
clear_bounds.y,
clear_bounds.w,
clear_bounds.h,
clear_bg,
chip_radius,
));
ctx.pushCommand(Command.text(
clear_bounds.x + 7,
clear_bounds.y + 4,
"X",
clear_text,
));
if (clear_hovered and ctx.input.mousePressed(.left)) {
list_state.clearFilterText();
list_state.search_cursor = 0;
list_state.search_selection_start = null;
result.filter_changed = true;
result.filter_text = "";
}
} }
} }
@ -391,7 +267,7 @@ pub fn drawRows(
} }
// ============================================================================= // =============================================================================
// Draw: Footer // Draw: Footer (delega a table_core para DRY)
// ============================================================================= // =============================================================================
pub fn drawFooter( pub fn drawFooter(
@ -400,36 +276,47 @@ pub fn drawFooter(
colors: *const VirtualAdvancedTableConfig.Colors, colors: *const VirtualAdvancedTableConfig.Colors,
list_state: *VirtualAdvancedTableState, list_state: *VirtualAdvancedTableState,
) void { ) void {
ctx.pushCommand(Command.rect( // Construir información de posición
bounds.x, const current_pos: ?usize = if (list_state.selected_id != null)
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)
if (list_state.findSelectedInWindow()) |idx| if (list_state.findSelectedInWindow()) |idx|
std.fmt.bufPrint(&pos_buf, "{d}", .{list_state.windowToGlobalIndex(idx) + 1}) catch "?" list_state.windowToGlobalIndex(idx) + 1
else else
"?" null
else else
"-"; null;
const display_str = std.fmt.bufPrint(&list_state.footer_display_buf, "{s} de {s}", .{ pos_str, count_str }) catch "..."; // Convertir CountInfo de Virtual a table_core
list_state.footer_display_len = display_str.len; 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( const pos_info = table_core.FooterPositionInfo{
bounds.x + 4, .current_position = current_pos,
bounds.y + 2, .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], list_state.footer_display_buf[0..list_state.footer_display_len],
colors.text, );
));
} }
// ============================================================================= // =============================================================================

View file

@ -66,6 +66,13 @@ pub const GestureResult = gesture.Result;
pub const GestureConfig = gesture.Config; pub const GestureConfig = gesture.Config;
pub const SwipeDirection = gesture.SwipeDirection; pub const SwipeDirection = gesture.SwipeDirection;
// Window system (paneles dinámicos)
pub const window = @import("core/window.zig");
pub const WindowState = window.WindowState;
pub const WindowPanel = window.Panel;
pub const WindowPanelInfo = window.PanelInfo;
pub const makeWindowPanel = window.makePanel;
// ============================================================================= // =============================================================================
// Macro system // Macro system
// ============================================================================= // =============================================================================