zcatgui/src/widgets/virtual_advanced_table/virtual_advanced_table.zig
reugenio 253c9b2449 refactor(tables): FASE 5 - Embeber NavigationState en AdvancedTableState y VirtualAdvancedTableState
- Añadido nav: table_core.NavigationState en ambos estados
- Eliminados campos duplicados (scroll_row, scroll_x, active_col, double_click)
- Actualizado todas las referencias a usar nav.*
- Conservado scroll_offset_pixels en Virtual (específico smooth scroll)
2025-12-27 19:47:43 +01:00

1165 lines
42 KiB
Zig

//! VirtualAdvancedTable - Widget de lista virtualizada
//!
//! Lista escalable que solo carga en memoria los registros visibles + buffer.
//! Diseñada para trabajar con bases de datos grandes (100k+ registros).
//!
//! ## Uso
//! ```zig
//! const result = virtualAdvancedTable(ctx, rect, &state, provider, .{
//! .columns = &columns,
//! .virtualization_threshold = 500,
//! });
//! if (result.selection_changed) { ... }
//! ```
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 Input = @import("../../core/input.zig");
const text_input = @import("../text_input.zig");
const table_core = @import("../table_core.zig");
// Re-exports públicos
pub const types = @import("types.zig");
pub const data_provider = @import("data_provider.zig");
pub const state_mod = @import("state.zig");
pub const cell_editor = @import("cell_editor.zig");
pub const paged_datasource = @import("paged_datasource.zig");
// Tipos principales
pub const RowData = types.RowData;
pub const ColumnDef = types.ColumnDef;
pub const SortDirection = types.SortDirection;
pub const LoadState = types.LoadState;
pub const CountInfo = types.CountInfo;
pub const VirtualAdvancedTableConfig = types.VirtualAdvancedTableConfig;
pub const FilterBarConfig = types.FilterBarConfig;
pub const FilterChipDef = types.FilterChipDef;
pub const ChipSelectMode = types.ChipSelectMode;
pub const CellId = types.CellId;
pub const CellGeometry = types.CellGeometry;
pub const DataProvider = data_provider.DataProvider;
pub const PagedDataSource = paged_datasource.PagedDataSource;
pub const CellEditorColors = cell_editor.CellEditorColors;
pub const CellEditorResult = cell_editor.CellEditorResult;
pub const NavigateDirection = cell_editor.NavigateDirection;
pub const drawCellEditor = cell_editor.drawCellEditor;
pub const VirtualAdvancedTableState = state_mod.VirtualAdvancedTableState;
/// Resultado de renderizar el VirtualAdvancedTable
pub const VirtualAdvancedTableResult = struct {
/// La selección cambió este frame
selection_changed: bool = false,
/// ID del registro seleccionado
selected_id: ?i64 = null,
/// Hubo doble click en un registro
double_clicked: bool = false,
/// ID del registro donde hubo doble click
double_click_id: ?i64 = null,
/// El usuario solicitó ordenar por una columna
sort_requested: bool = false,
sort_column: ?[]const u8 = null,
sort_direction: SortDirection = .none,
/// El filtro de texto cambió
filter_changed: bool = false,
/// Texto del filtro actual
filter_text: ?[]const u8 = null,
/// Un chip/prefiltro cambió
chip_changed: bool = false,
/// Índice del chip que cambió
chip_index: ?u4 = null,
/// El chip está activo después del cambio
chip_active: bool = false,
/// El widget fue clickeado
clicked: bool = false,
// =========================================================================
// Edición CRUD Excel-style (commit al abandonar fila)
// =========================================================================
/// Una fila fue completada (el usuario cambió de fila, tenía cambios pendientes)
/// Cuando es true, row_commit_id y row_changes contienen los datos
row_committed: bool = false,
/// ID de la fila que se hizo commit (NEW_ROW_ID = -1 para inserts)
row_commit_id: i64 = table_core.NEW_ROW_ID,
/// Es un INSERT (ghost row) o UPDATE (fila existente)
row_commit_is_insert: bool = false,
/// Cambios de la fila (válidos si row_committed = true)
/// Buffer estático que sobrevive el frame
row_changes: [table_core.MAX_PENDING_COLUMNS]table_core.PendingCellChange = undefined,
/// Número de cambios en row_changes
row_changes_count: usize = 0,
/// El usuario canceló edición (Escape 2x = descartar fila)
row_discarded: bool = false,
/// Navegación solicitada después de edición
navigate_direction: cell_editor.NavigateDirection = .none,
/// Tab presionado sin edición activa (pasar focus al siguiente widget)
tab_out: bool = false,
/// Shift estaba presionado con Tab (para tab_out inverso)
tab_shift: bool = false,
// =========================================================================
// Compatibilidad (DEPRECADO - usar row_committed)
// =========================================================================
/// @deprecated: Usar row_committed. Mantenido para compatibilidad.
cell_committed: bool = false,
/// @deprecated
row_changed: bool = false,
/// @deprecated
edited_cell: ?CellId = null,
/// @deprecated
edited_value: ?[]const u8 = null,
/// @deprecated
previous_row: ?usize = null,
/// Obtiene los cambios como slice (helper para compatibilidad)
pub fn getRowChanges(self: *const VirtualAdvancedTableResult) []const table_core.PendingCellChange {
return self.row_changes[0..self.row_changes_count];
}
};
// =============================================================================
// Widget principal
// =============================================================================
/// Renderiza un VirtualAdvancedTable
pub fn virtualAdvancedTable(
ctx: *Context,
list_state: *VirtualAdvancedTableState,
provider: DataProvider,
config: VirtualAdvancedTableConfig,
) VirtualAdvancedTableResult {
const bounds = ctx.layout.nextRect();
return virtualAdvancedTableRect(ctx, bounds, list_state, provider, config);
}
/// Renderiza un VirtualAdvancedTable en un rectángulo específico
pub fn virtualAdvancedTableRect(
ctx: *Context,
bounds: Layout.Rect,
list_state: *VirtualAdvancedTableState,
provider: DataProvider,
config: VirtualAdvancedTableConfig,
) VirtualAdvancedTableResult {
var result = VirtualAdvancedTableResult{};
if (bounds.isEmpty() or config.columns.len == 0) return result;
// Reset frame flags
list_state.resetFrameFlags();
// Get colors
const colors = config.colors orelse VirtualAdvancedTableConfig.Colors{};
// Generate unique ID for focus system
const widget_id: u64 = @intFromPtr(list_state);
// Register as focusable
ctx.registerFocusable(widget_id);
// Check mouse interaction
const mouse = ctx.input.mousePos();
const hovered = bounds.contains(mouse.x, mouse.y);
const clicked = hovered and ctx.input.mousePressed(.left);
if (clicked) {
ctx.requestFocus(widget_id);
result.clicked = true;
}
// Check if we have focus
const has_focus = ctx.hasFocus(widget_id);
list_state.has_focus = has_focus;
// Calculate total columns width
var total_columns_width: u32 = 0;
for (config.columns) |col| {
total_columns_width += col.width;
}
// Check if horizontal scroll is needed
const scrollbar_v_w: u32 = 12; // Width of vertical scrollbar
const available_width = bounds.w -| scrollbar_v_w;
const needs_h_scroll = total_columns_width > available_width;
const scrollbar_h_h: u32 = if (needs_h_scroll) 12 else 0;
// Calculate max horizontal scroll
const max_scroll_x: i32 = @max(0, @as(i32, @intCast(total_columns_width)) - @as(i32, @intCast(available_width)));
// Clamp current scroll_offset_x
if (list_state.nav.scroll_x > max_scroll_x) {
list_state.nav.scroll_x = max_scroll_x;
}
// Calculate FilterBar height
const filter_bar_h: u32 = if (config.filter_bar) |fb| fb.height else 0;
// Calculate dimensions
const header_h: u32 = config.row_height;
const footer_h: u32 = if (config.show_count) 16 else 0; // 16px para footer compacto
const content_h = bounds.h -| filter_bar_h -| header_h -| footer_h -| scrollbar_h_h;
const visible_rows: usize = @intCast(content_h / config.row_height);
// Calculate buffer size and check if refetch needed
const buffer_size = visible_rows * config.buffer_multiplier;
const needs_refetch = needsRefetch(list_state, visible_rows, buffer_size);
// Fetch window if needed
if (needs_refetch) {
if (provider.fetchWindow(list_state.nav.scroll_row, buffer_size)) |window| {
list_state.current_window = window;
list_state.window_start = list_state.nav.scroll_row;
} else |_| {
// Error fetching - keep current window
}
}
// Update counts from provider
list_state.total_count = provider.getTotalCount();
list_state.filtered_count = provider.getFilteredCount();
// Begin clipping
ctx.pushCommand(Command.clip(bounds.x, bounds.y, bounds.w, bounds.h));
// Draw FilterBar if configured
if (config.filter_bar) |fb_config| {
const filter_bounds = Layout.Rect.init(
bounds.x,
bounds.y,
bounds.w,
fb_config.height,
);
drawFilterBar(ctx, filter_bounds, fb_config, &colors, list_state, &result);
}
// Calculate header Y position (after FilterBar)
const header_y = bounds.y + @as(i32, @intCast(filter_bar_h));
// Draw header (with horizontal scroll offset)
drawHeaderAt(ctx, bounds, header_y, config, &colors, list_state, &result, list_state.nav.scroll_x);
// Draw visible rows
const content_bounds = Layout.Rect.init(
bounds.x,
header_y + @as(i32, @intCast(header_h)),
bounds.w,
content_h,
);
// Draw content background first (so empty space isn't black)
ctx.pushCommand(Command.rect(
content_bounds.x,
content_bounds.y,
content_bounds.w,
content_bounds.h,
colors.row_normal,
));
drawRows(ctx, content_bounds, config, &colors, list_state, visible_rows, &result, list_state.nav.scroll_x);
// Draw CellEditor overlay if editing
if (list_state.isEditing()) {
const editing = list_state.getEditingCell().?;
// Calculate cell geometry for the editing cell
if (list_state.getCellGeometry(
editing.row,
editing.col,
config.columns,
config.row_height,
bounds.x,
bounds.y,
header_h,
filter_bar_h,
)) |geom| {
// Draw cell editor con colores del panel
const editor_result = cell_editor.drawCellEditor(
ctx,
list_state,
geom,
cell_editor.CellEditorColors{
.background = colors.background,
.text = colors.text,
.cursor = colors.text, // Cursor mismo color que texto
},
);
// Handle editor results
if (editor_result.committed) {
const edited_cell = list_state.getEditingCell().?;
const new_value = list_state.getEditText();
// Añadir cambio al buffer de fila (NO commit inmediato)
// El commit real se hace cuando el usuario abandona la fila
if (list_state.hasValueChanged()) {
list_state.row_edit_buffer.addChange(edited_cell.col, new_value);
// Compatibilidad: mantener flags antiguos
result.cell_committed = true;
result.edited_cell = edited_cell;
result.edited_value = new_value;
}
// Finalizar edición de celda (sin commit a BD)
_ = list_state.commitEdit();
result.navigate_direction = editor_result.navigate;
} else if (editor_result.escaped) {
const action = list_state.handleEscape();
if (action == .discard_row) {
result.row_discarded = true;
list_state.row_edit_buffer.clear();
}
}
}
}
// End clipping
ctx.pushCommand(Command.clipEnd());
// Draw footer with count
if (config.show_count) {
const footer_bounds = Layout.Rect.init(
bounds.x,
bounds.y + @as(i32, @intCast(bounds.h - footer_h)),
bounds.w,
footer_h,
);
drawFooter(ctx, footer_bounds, &colors, list_state);
}
// Draw vertical scrollbar if needed
const total_rows = list_state.getDisplayCount().value;
if (total_rows > visible_rows and config.show_scrollbar) {
drawScrollbar(ctx, bounds, header_h, footer_h +| scrollbar_h_h, list_state, visible_rows, total_rows, &colors);
}
// Draw horizontal scrollbar if needed
if (needs_h_scroll and config.show_scrollbar) {
drawScrollbarH(ctx, bounds, footer_h, scrollbar_h_h, list_state.nav.scroll_x, max_scroll_x, available_width, &colors);
}
// Draw border around the entire list (always visible)
ctx.pushCommand(Command.rectOutline(bounds.x, bounds.y, bounds.w, bounds.h, colors.border));
// Draw focus ring (additional highlight when focused)
if (has_focus) {
if (Style.isFancy()) {
ctx.pushCommand(Command.focusRing(bounds.x, bounds.y, bounds.w, bounds.h, 4));
} else {
ctx.pushCommand(Command.rectOutline(
bounds.x - 1,
bounds.y - 1,
bounds.w + 2,
bounds.h + 2,
colors.border,
));
}
}
// Handle keyboard
if (has_focus) {
handleKeyboard(ctx, list_state, provider, visible_rows, total_rows, max_scroll_x, config.columns.len, &result);
}
// Handle mouse clicks on rows
if (clicked and hovered) {
handleMouseClick(ctx, bounds, filter_bar_h, header_h, config, list_state, &result);
}
// Update result
result.selection_changed = list_state.selection_changed;
result.selected_id = list_state.selected_id;
result.double_clicked = list_state.double_clicked;
if (list_state.double_clicked) {
result.double_click_id = list_state.selected_id;
}
// =========================================================================
// Commit de fila al cambiar de selección
// =========================================================================
// Si la selección cambió y hay cambios pendientes en otra fila, hacer commit
if (list_state.selection_changed and list_state.row_edit_buffer.has_changes) {
const new_row_id = list_state.selected_id orelse table_core.NEW_ROW_ID;
const new_row_idx = list_state.getSelectedRow() orelse 0;
const is_ghost = table_core.isGhostRow(new_row_id);
// checkRowChangeAndCommit compara row_ids y hace commit si son diferentes
if (table_core.checkRowChangeAndCommit(
&list_state.row_edit_buffer,
new_row_id,
new_row_idx,
is_ghost,
&result.row_changes,
)) |commit_info| {
result.row_committed = true;
result.row_commit_id = commit_info.row_id;
result.row_commit_is_insert = commit_info.is_insert;
result.row_changes_count = commit_info.change_count;
// Compatibilidad
result.row_changed = true;
}
}
return result;
}
// =============================================================================
// Helper: Check if refetch needed
// =============================================================================
fn needsRefetch(list_state: *VirtualAdvancedTableState, visible_rows: usize, buffer_size: usize) bool {
// Manual invalidation (después de editar datos)
if (list_state.needs_window_refresh) {
list_state.needs_window_refresh = false;
return true;
}
// First load
if (list_state.current_window.len == 0) return true;
// Check if scroll is outside current window
const scroll = list_state.nav.scroll_row;
const window_end = list_state.window_start + list_state.current_window.len;
// Refetch if scroll is near edges of window
const margin = visible_rows;
if (scroll < list_state.window_start + margin and list_state.window_start > 0) return true;
if (scroll + visible_rows + margin > window_end) return true;
_ = buffer_size;
return false;
}
// =============================================================================
// Draw: FilterBar
// =============================================================================
fn drawFilterBar(
ctx: *Context,
bounds: Layout.Rect,
config: FilterBarConfig,
colors: *const VirtualAdvancedTableConfig.Colors,
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 = 11; // Radio de esquinas redondeadas (mitad de altura = pill)
const clear_btn_w: u32 = 22;
// Background con gradiente sutil (color base ligeramente más oscuro abajo)
ctx.pushCommand(Command.rect(
bounds.x,
bounds.y,
bounds.w,
bounds.h,
colors.header_background,
));
// Línea sutil inferior para separación
ctx.pushCommand(Command.rect(
bounds.x,
bounds.y + @as(i32, @intCast(bounds.h)) - 1,
bounds.w,
1,
colors.border,
));
var current_x = bounds.x + padding;
const item_y = bounds.y + @divTrunc(@as(i32, @intCast(bounds.h)) - @as(i32, @intCast(chip_h)), 2);
const item_h = bounds.h -| @as(u32, @intCast(padding * 2));
const mouse = ctx.input.mousePos();
// =========================================================================
// Draw Chips (estilo pill/badge redondeado)
// =========================================================================
if (config.chips.len > 0) {
for (config.chips, 0..) |chip, idx| {
const chip_idx: u4 = @intCast(idx);
const is_active = list_state.isChipActive(chip_idx);
// Calcular ancho del chip basado en el texto
const label_len = chip.label.len;
const chip_w: u32 = @intCast(label_len * 7 + chip_padding * 2);
const chip_bounds = Layout.Rect.init(
current_x,
item_y,
chip_w,
chip_h,
);
const chip_hovered = chip_bounds.contains(mouse.x, mouse.y);
// Colores con mejor contraste para chips activos vs inactivos
const chip_bg = if (is_active)
colors.row_selected // Color primario para activo
else if (chip_hovered)
Style.Color.rgb(
colors.header_background.r -| 15,
colors.header_background.g -| 15,
colors.header_background.b -| 15,
) // Hover sutil
else
colors.header_background;
const chip_text_color = if (is_active)
colors.text_selected
else
colors.text;
const chip_border = if (is_active)
colors.row_selected // Sin borde visible cuando activo
else
colors.border;
// Fondo del chip (redondeado tipo pill)
ctx.pushCommand(Command.roundedRect(
chip_bounds.x,
chip_bounds.y,
chip_bounds.w,
chip_bounds.h,
chip_bg,
chip_radius,
));
// Borde del chip (solo si no activo, para efecto más limpio)
if (!is_active) {
ctx.pushCommand(Command.roundedRectOutline(
chip_bounds.x,
chip_bounds.y,
chip_bounds.w,
chip_bounds.h,
chip_border,
chip_radius,
));
}
// Texto del chip centrado verticalmente
ctx.pushCommand(Command.text(
chip_bounds.x + chip_padding,
chip_bounds.y + 4,
chip.label,
chip_text_color,
));
// Handle click
if (chip_hovered and ctx.input.mousePressed(.left)) {
list_state.activateChip(chip_idx, config.chip_mode);
result.chip_changed = true;
result.chip_index = chip_idx;
result.chip_active = list_state.isChipActive(chip_idx);
}
current_x += @as(i32, @intCast(chip_w)) + chip_spacing;
}
current_x += padding; // Espacio extra después de chips
}
// =========================================================================
// Draw Search Input
// =========================================================================
if (config.show_search) {
// Calcular espacio para clear button
const clear_space: u32 = if (config.show_clear_button) clear_btn_w + @as(u32, @intCast(padding)) else 0;
// Search ocupa el resto del espacio
const search_end = bounds.x + @as(i32, @intCast(bounds.w)) - padding - @as(i32, @intCast(clear_space));
const search_w: u32 = @intCast(@max(60, search_end - current_x));
const search_bounds = Layout.Rect.init(
current_x,
item_y,
search_w,
item_h,
);
// Create a temporary TextInputState from list_state fields
var text_state = text_input.TextInputState{
.buffer = &list_state.filter_buf,
.len = list_state.filter_len,
.cursor = list_state.search_cursor,
.selection_start = list_state.search_selection_start,
.focused = list_state.search_has_focus,
};
const text_result = text_input.textInputRect(ctx, search_bounds, &text_state, .{
.placeholder = config.search_placeholder,
.padding = 3,
});
// Update list_state from text_state
list_state.filter_len = text_state.len;
list_state.search_cursor = text_state.cursor;
list_state.search_selection_start = text_state.selection_start;
// Handle focus changes
if (text_result.clicked) {
list_state.search_has_focus = true;
}
// Handle text changes
if (text_result.changed) {
list_state.filter_text_changed = true;
result.filter_changed = true;
result.filter_text = list_state.filter_buf[0..list_state.filter_len];
}
current_x += @as(i32, @intCast(search_w)) + padding;
}
// =========================================================================
// Draw Clear Button (circular, estilo moderno)
// =========================================================================
if (config.show_clear_button and list_state.filter_len > 0) {
const clear_x = bounds.x + @as(i32, @intCast(bounds.w - clear_btn_w)) - padding;
const clear_bounds = Layout.Rect.init(
clear_x,
item_y,
clear_btn_w,
chip_h,
);
const clear_hovered = clear_bounds.contains(mouse.x, mouse.y);
// Color rojo sutil para indicar "eliminar"
const clear_bg = if (clear_hovered)
Style.Color.rgb(220, 80, 80) // Rojo hover
else
Style.Color.rgb(180, 60, 60); // Rojo base
const clear_text = Style.Color.rgb(255, 255, 255);
// Botón circular (radio = mitad altura)
ctx.pushCommand(Command.roundedRect(
clear_bounds.x,
clear_bounds.y,
clear_bounds.w,
clear_bounds.h,
clear_bg,
chip_radius,
));
// Draw "✕" centered (o "X" si no hay soporte unicode)
ctx.pushCommand(Command.text(
clear_bounds.x + 7,
clear_bounds.y + 4,
"X",
clear_text,
));
// Handle click
if (clear_hovered and ctx.input.mousePressed(.left)) {
list_state.clearFilterText();
list_state.search_cursor = 0;
list_state.search_selection_start = null;
result.filter_changed = true;
result.filter_text = "";
}
}
}
// =============================================================================
// Draw: Header
// =============================================================================
fn drawHeaderAt(
ctx: *Context,
bounds: Layout.Rect,
header_y: i32,
config: VirtualAdvancedTableConfig,
colors: *const VirtualAdvancedTableConfig.Colors,
list_state: *VirtualAdvancedTableState,
result: *VirtualAdvancedTableResult,
scroll_offset_x: i32,
) void {
const header_h = config.row_height;
// Header background
ctx.pushCommand(Command.rect(
bounds.x,
header_y,
bounds.w,
header_h,
colors.header_background,
));
// Draw column headers (with horizontal scroll offset)
var x: i32 = bounds.x - scroll_offset_x;
for (config.columns) |col| {
// Only draw if column is visible
const col_end = x + @as(i32, @intCast(col.width));
if (col_end > bounds.x and x < bounds.x + @as(i32, @intCast(bounds.w))) {
// Column title
ctx.pushCommand(Command.text(
x + 4,
header_y + 3, // Centrado vertical mejorado
col.title,
colors.text,
));
// Sort indicator
if (list_state.sort_column) |sort_col| {
if (std.mem.eql(u8, sort_col, col.name)) {
const indicator = list_state.sort_direction.symbol();
ctx.pushCommand(Command.text(
x + @as(i32, @intCast(col.width)) - 20,
header_y + 3, // Centrado vertical mejorado
indicator,
colors.text,
));
}
}
// Check click on header for sorting
if (col.sortable) {
const header_bounds = Layout.Rect.init(x, header_y, col.width, header_h);
const mouse = ctx.input.mousePos();
if (header_bounds.contains(mouse.x, mouse.y) and ctx.input.mousePressed(.left)) {
list_state.toggleSort(col.name);
result.sort_requested = true;
result.sort_column = col.name;
result.sort_direction = list_state.sort_direction;
}
}
// Column separator
ctx.pushCommand(Command.rect(col_end - 1, header_y, 1, header_h, colors.border));
}
x = col_end;
}
// Bottom border
ctx.pushCommand(Command.rect(
bounds.x,
header_y + @as(i32, @intCast(header_h)) - 1,
bounds.w,
1,
colors.border,
));
}
// =============================================================================
// Draw: Rows
// =============================================================================
fn drawRows(
ctx: *Context,
content_bounds: Layout.Rect,
config: VirtualAdvancedTableConfig,
colors: *const VirtualAdvancedTableConfig.Colors,
list_state: *VirtualAdvancedTableState,
visible_rows: usize,
result: *VirtualAdvancedTableResult,
scroll_offset_x: i32,
) void {
_ = result;
// Crear PagedDataSource para acceso unificado a datos
var datasource = paged_datasource.PagedDataSource.init(list_state, config.columns, null);
const table_ds = datasource.toDataSource();
// Convertir selected_id a selected_row (índice global)
const selected_row: i32 = if (list_state.findSelectedInWindow()) |window_idx|
@intCast(list_state.windowToGlobalIndex(window_idx))
else
-1;
// Calcular rango de filas a dibujar
const first_row = list_state.nav.scroll_row;
const last_row = @min(
list_state.nav.scroll_row + visible_rows,
list_state.window_start + list_state.current_window.len,
);
// Convertir columnas a ColumnRenderDef
var render_cols: [32]table_core.ColumnRenderDef = undefined;
const num_cols = @min(config.columns.len, 32);
for (config.columns[0..num_cols], 0..) |col, i| {
render_cols[i] = .{
.width = col.width,
.text_align = 0, // TODO: mapear col.alignment si existe
.visible = true,
};
}
// Convertir colores
const render_colors = table_core.RowRenderColors{
.row_normal = colors.row_normal,
.row_alternate = colors.row_alternate,
.selected_row = colors.row_selected,
.selected_row_unfocus = colors.row_selected_unfocus,
.selected_cell = Style.Color.rgb(100, 200, 255), // Cyan brillante
.selected_cell_unfocus = Style.Color.rgb(150, 150, 160),
.text_normal = colors.text,
.text_selected = colors.text_selected,
.border = colors.border,
};
// Buffer para valores de celda
var cell_buffer: [256]u8 = undefined;
// Llamar a la función unificada
_ = table_core.drawRowsWithDataSource(
ctx,
table_ds,
.{
.bounds_x = content_bounds.x,
.bounds_y = content_bounds.y,
.bounds_w = content_bounds.w,
.row_height = config.row_height,
.first_row = first_row,
.last_row = last_row,
.scroll_x = scroll_offset_x,
.alternating_rows = true,
.has_focus = list_state.has_focus,
.selected_row = selected_row,
.active_col = list_state.nav.active_col,
.colors = render_colors,
.columns = render_cols[0..num_cols],
},
&cell_buffer,
);
}
// =============================================================================
// Draw: Footer
// =============================================================================
fn drawFooter(
ctx: *Context,
bounds: Layout.Rect,
colors: *const VirtualAdvancedTableConfig.Colors,
list_state: *VirtualAdvancedTableState,
) void {
// Background
ctx.pushCommand(Command.rect(
bounds.x,
bounds.y,
bounds.w,
bounds.h,
colors.header_background,
));
// Format count (usar buffers temporales solo para strings intermedios)
var count_buf: [64]u8 = undefined;
const count_info = list_state.getDisplayCount();
const count_str = count_info.format(&count_buf);
// Find selected position
var pos_buf: [32]u8 = undefined;
const pos_str = if (list_state.selected_id != null)
if (list_state.findSelectedInWindow()) |idx|
std.fmt.bufPrint(&pos_buf, "{d}", .{list_state.windowToGlobalIndex(idx) + 1}) catch "?"
else
"?"
else
"-";
// Combine: "pos de total" - USAR BUFFER PERSISTENTE del state
// IMPORTANTE: Los buffers de stack se invalidan al retornar. El render
// diferido necesita buffers que sobrevivan hasta después del execute().
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;
ctx.pushCommand(Command.text(
bounds.x + 4,
bounds.y + 2,
list_state.footer_display_buf[0..list_state.footer_display_len],
colors.text,
));
}
// =============================================================================
// Draw: Scrollbar
// =============================================================================
fn drawScrollbar(
ctx: *Context,
bounds: Layout.Rect,
header_h: u32,
footer_h: u32,
list_state: *VirtualAdvancedTableState,
visible_rows: usize,
total_rows: usize,
colors: *const VirtualAdvancedTableConfig.Colors,
) void {
const scrollbar_w: u32 = 12;
const content_h = bounds.h -| header_h -| footer_h;
// Scrollbar track
const track_x = bounds.x + @as(i32, @intCast(bounds.w - scrollbar_w));
const track_y = bounds.y + @as(i32, @intCast(header_h));
ctx.pushCommand(Command.rect(track_x, track_y, scrollbar_w, content_h, colors.row_alternate));
// Thumb size and position
const visible_ratio = @as(f32, @floatFromInt(visible_rows)) / @as(f32, @floatFromInt(total_rows));
const thumb_h = @max(20, @as(u32, @intFromFloat(visible_ratio * @as(f32, @floatFromInt(content_h)))));
const scroll_ratio = @as(f32, @floatFromInt(list_state.nav.scroll_row)) /
@as(f32, @floatFromInt(@max(1, total_rows - visible_rows)));
const thumb_y = track_y + @as(i32, @intFromFloat(scroll_ratio * @as(f32, @floatFromInt(content_h - thumb_h))));
// Draw thumb
ctx.pushCommand(Command.rect(track_x + 2, thumb_y, scrollbar_w - 4, thumb_h, colors.border));
}
// =============================================================================
// Draw: Horizontal Scrollbar
// =============================================================================
fn drawScrollbarH(
ctx: *Context,
bounds: Layout.Rect,
footer_h: u32,
scrollbar_h: u32,
scroll_offset_x: i32,
max_scroll_x: i32,
available_width: u32,
colors: *const VirtualAdvancedTableConfig.Colors,
) void {
const scrollbar_v_w: u32 = 12; // Width of vertical scrollbar area
// Scrollbar track position (at the bottom, above footer)
const track_x = bounds.x;
const track_y = bounds.y + @as(i32, @intCast(bounds.h - footer_h - scrollbar_h));
const track_w = bounds.w -| scrollbar_v_w;
// Track background
ctx.pushCommand(Command.rect(track_x, track_y, track_w, scrollbar_h, colors.row_alternate));
// Calculate thumb size and position
if (max_scroll_x <= 0) return;
const total_width = available_width + @as(u32, @intCast(max_scroll_x));
const visible_ratio = @as(f32, @floatFromInt(available_width)) / @as(f32, @floatFromInt(total_width));
const thumb_w = @max(20, @as(u32, @intFromFloat(visible_ratio * @as(f32, @floatFromInt(track_w)))));
const scroll_ratio = @as(f32, @floatFromInt(scroll_offset_x)) / @as(f32, @floatFromInt(max_scroll_x));
const thumb_x = track_x + @as(i32, @intFromFloat(scroll_ratio * @as(f32, @floatFromInt(track_w - thumb_w))));
// Draw thumb
ctx.pushCommand(Command.rect(thumb_x, track_y + 2, thumb_w, scrollbar_h - 4, colors.border));
}
// =============================================================================
// Handle: Keyboard
// =============================================================================
fn handleKeyboard(
ctx: *Context,
list_state: *VirtualAdvancedTableState,
provider: DataProvider,
visible_rows: usize,
total_rows: usize,
max_scroll_x: i32,
num_columns: usize,
result: *VirtualAdvancedTableResult,
) void {
_ = provider;
// Si hay edición activa, el CellEditor maneja las teclas
if (list_state.isEditing()) return;
const h_scroll_step: i32 = 40; // Pixels per arrow key press
// Usar navKeyPressed() para soportar key repeat (tecla mantenida pulsada)
if (ctx.input.navKeyPressed()) |key| {
switch (key) {
.up => list_state.moveUp(),
.down => list_state.moveDown(visible_rows),
.left => {
// Con Ctrl: scroll horizontal
// Sin Ctrl: cambiar columna activa
if (ctx.input.modifiers.ctrl) {
list_state.scrollLeft(h_scroll_step);
} else {
list_state.moveToPrevCol();
}
},
.right => {
if (ctx.input.modifiers.ctrl) {
list_state.scrollRight(h_scroll_step, max_scroll_x);
} else {
list_state.moveToNextCol(num_columns);
}
},
.page_up => list_state.pageUp(visible_rows),
.page_down => list_state.pageDown(visible_rows, total_rows),
.home => {
if (ctx.input.modifiers.ctrl) {
list_state.goToStart();
list_state.goToFirstCol();
} else {
list_state.goToFirstCol();
}
},
.end => {
if (ctx.input.modifiers.ctrl) {
list_state.goToEnd(visible_rows, total_rows);
list_state.goToLastCol(num_columns);
} else {
list_state.goToLastCol(num_columns);
}
},
else => {},
}
}
// F2 o Space: iniciar edición de celda activa
// Tab: pasar focus al siguiente widget (solo si NO estamos editando)
for (ctx.input.getKeyEvents()) |event| {
if (!event.pressed) continue;
switch (event.key) {
.f2, .space => {
// Iniciar edición de celda activa
if (list_state.getActiveCell()) |cell| {
// El panel debe proveer el valor actual via callback
// Por ahora iniciamos con texto vacío - el panel debería llamar startEditing
result.cell_committed = false; // Flag especial: indica que se solicitó edición
result.edited_cell = cell;
}
},
.tab => {
// Tab sin edición activa: indica que el panel debe mover focus
// IMPORTANTE: Solo si CellEditor no procesó Tab (evita doble procesamiento)
if (result.navigate_direction == .none) {
result.tab_out = true;
result.tab_shift = event.modifiers.shift;
}
},
else => {},
}
}
// Teclas alfanuméricas: iniciar edición con ese caracter
const char_input = ctx.input.getTextInput();
if (char_input.len > 0 and !list_state.isEditing()) {
if (list_state.getActiveCell()) |cell| {
// Iniciar edición con el primer caracter
list_state.startEditing(cell, "", char_input[0], ctx.current_time_ms);
}
}
}
// =============================================================================
// Handle: Mouse Click
// =============================================================================
fn handleMouseClick(
ctx: *Context,
bounds: Layout.Rect,
filter_bar_h: u32,
header_h: u32,
config: VirtualAdvancedTableConfig,
list_state: *VirtualAdvancedTableState,
result: *VirtualAdvancedTableResult,
) void {
const mouse = ctx.input.mousePos();
// Content starts after FilterBar + Header
const content_y = bounds.y + @as(i32, @intCast(filter_bar_h)) + @as(i32, @intCast(header_h));
// Check if click is in content area (not header or filter bar)
if (mouse.y >= content_y) {
const relative_y = mouse.y - content_y;
const screen_row = @as(usize, @intCast(relative_y)) / config.row_height;
// Convert screen row to buffer index (accounting for scroll)
const window_offset = list_state.nav.scroll_row -| list_state.window_start;
const data_idx = window_offset + screen_row;
if (data_idx < list_state.current_window.len) {
const global_row = list_state.nav.scroll_row + screen_row;
// Detect which column was clicked
var clicked_col: usize = 0;
const relative_x = mouse.x - bounds.x + list_state.nav.scroll_x;
var col_start: i32 = 0;
for (config.columns, 0..) |col, col_idx| {
const col_end = col_start + @as(i32, @intCast(col.width));
if (relative_x >= col_start and relative_x < col_end) {
clicked_col = col_idx;
break;
}
col_start = col_end;
}
// Double-click detection using embedded nav.double_click
var dc_state = list_state.nav.double_click;
const is_double_click = table_core.detectDoubleClick(
&dc_state,
ctx.current_time_ms,
@intCast(global_row),
@intCast(clicked_col),
);
// Update state from detection
list_state.nav.double_click = dc_state;
if (is_double_click and !list_state.isEditing()) {
// Double-click: start editing
const cell = types.CellId{ .row = global_row, .col = clicked_col };
// Signal to panel that editing was requested
// The panel should provide the current value via callback
result.edited_cell = cell;
result.double_clicked = true;
result.double_click_id = list_state.current_window[data_idx].id;
} else {
// Single click: select
list_state.selectById(list_state.current_window[data_idx].id);
list_state.nav.active_col = clicked_col;
}
}
}
}
// =============================================================================
// Tests
// =============================================================================
test "virtual_advanced_table module imports" {
_ = types;
_ = data_provider;
_ = state_mod;
_ = RowData;
_ = ColumnDef;
_ = DataProvider;
_ = VirtualAdvancedTableState;
_ = VirtualAdvancedTableResult;
}
test {
_ = @import("types.zig");
_ = @import("data_provider.zig");
_ = @import("state.zig");
_ = @import("cell_editor.zig");
}