diff --git a/docs/PLAN_REFACTOR_VIRTUAL_ADVANCED_TABLE.md b/docs/PLAN_REFACTOR_VIRTUAL_ADVANCED_TABLE.md new file mode 100644 index 0000000..023f3ea --- /dev/null +++ b/docs/PLAN_REFACTOR_VIRTUAL_ADVANCED_TABLE.md @@ -0,0 +1,85 @@ +# PLAN: Modularización de virtual_advanced_table.zig + +**Fecha:** 2025-12-29 +**Estado:** En progreso + +--- + +## Objetivo + +Dividir `virtual_advanced_table.zig` (1367 LOC) en módulos más pequeños. + +--- + +## Estado Actual + +``` +virtual_advanced_table/ +├── cell_editor.zig # 179 LOC ✓ +├── data_provider.zig # 296 LOC ✓ +├── paged_datasource.zig # 188 LOC ✓ +├── state.zig # 847 LOC (revisar después) +├── types.zig # 242 LOC ✓ +└── virtual_advanced_table.zig # 1367 LOC ⚠️ DIVIDIR +``` + +--- + +## Estructura Final + +``` +virtual_advanced_table/ +├── cell_editor.zig # 179 LOC (sin cambios) +├── data_provider.zig # 296 LOC (sin cambios) +├── paged_datasource.zig # 188 LOC (sin cambios) +├── state.zig # 847 LOC (sin cambios por ahora) +├── types.zig # 242 LOC (sin cambios) +├── drawing.zig # ~400 LOC (NUEVO) +├── input.zig # ~200 LOC (NUEVO) +└── virtual_advanced_table.zig # ~500 LOC (reducido) +``` + +--- + +## Mapeo de Funciones + +### → drawing.zig (~400 LOC) +| Función | Líneas orig | Descripción | +|---------|-------------|-------------| +| drawFilterBar | 604-830 | Barra de filtro superior | +| drawHeaderAt | 836-911 | Cabecera de tabla | +| drawRows | 917-1008 | Filas de datos | +| drawFooter | 1014-1056 | Pie de tabla | +| drawScrollbar | 1062-1087 | Scrollbar vertical | +| drawScrollbarH | 1093-1120 | Scrollbar horizontal | + +### → input.zig (~200 LOC) +| Función | Líneas orig | Descripción | +|---------|-------------|-------------| +| handleKeyboard | 1169-1273 | Manejo de teclado | +| handleMouseClick | 1279-1345 | Manejo de clicks | +| ensureColumnVisible | 1127-1160 | Scroll a columna | +| needsRefetch | 577-598 | Detectar necesidad de refetch | + +### → virtual_advanced_table.zig (reducido) +- VirtualAdvancedTableResult struct +- virtualAdvancedTable() (API pública) +- virtualAdvancedTableRect() (orquestación) + +--- + +## Fases + +- [ ] FASE 1: Extraer drawing.zig +- [ ] FASE 2: Extraer input.zig +- [ ] FASE 3: Actualizar imports en virtual_advanced_table.zig +- [ ] FASE 4: Verificar compilación +- [ ] FASE 5: Commit y push + +--- + +## Historial + +| Fecha | Hora | Acción | Estado | +|-------|------|--------|--------| +| 2025-12-29 | ~02:30 | Plan creado | En progreso | diff --git a/src/widgets/virtual_advanced_table/drawing.zig b/src/widgets/virtual_advanced_table/drawing.zig new file mode 100644 index 0000000..32b0516 --- /dev/null +++ b/src/widgets/virtual_advanced_table/drawing.zig @@ -0,0 +1,495 @@ +//! VirtualAdvancedTable - Funciones de Dibujo +//! +//! Funciones de renderizado extraídas del archivo principal para mejorar +//! modularidad y reducir el tamaño del archivo principal. + +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"); +const table_core = @import("../table_core/table_core.zig"); + +const types = @import("types.zig"); +const state_mod = @import("state.zig"); +const paged_datasource = @import("paged_datasource.zig"); + +pub const VirtualAdvancedTableState = state_mod.VirtualAdvancedTableState; +pub const VirtualAdvancedTableConfig = types.VirtualAdvancedTableConfig; +pub const FilterBarConfig = types.FilterBarConfig; +pub const VirtualAdvancedTableResult = @import("virtual_advanced_table.zig").VirtualAdvancedTableResult; + +// ============================================================================= +// Draw: FilterBar +// ============================================================================= + +pub 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; + const clear_btn_w: u32 = 22; + + // Background + ctx.pushCommand(Command.rect( + bounds.x, + bounds.y, + bounds.w, + bounds.h, + colors.header_background, + )); + + // Línea inferior + ctx.pushCommand(Command.rect( + bounds.x, + bounds.y + @as(i32, @intCast(bounds.h)) - 1, + bounds.w, + 1, + colors.border, + )); + + var current_x = bounds.x + padding; + const item_y = bounds.y + @divTrunc(@as(i32, @intCast(bounds.h)) - @as(i32, @intCast(chip_h)), 2); + const item_h = bounds.h -| @as(u32, @intCast(padding * 2)); + const mouse = ctx.input.mousePos(); + + // Draw Chips + if (config.chips.len > 0) { + for (config.chips, 0..) |chip, idx| { + const chip_idx: u4 = @intCast(idx); + const is_active = list_state.isChipActive(chip_idx); + + const label_len = chip.label.len; + const chip_w: u32 = @intCast(label_len * 7 + chip_padding * 2); + + const chip_bounds = Layout.Rect.init( + current_x, + item_y, + chip_w, + chip_h, + ); + + const chip_hovered = chip_bounds.contains(mouse.x, mouse.y); + + const chip_bg = if (is_active) + colors.row_selected + else if (chip_hovered) + Style.Color.rgb( + colors.header_background.r -| 15, + colors.header_background.g -| 15, + colors.header_background.b -| 15, + ) + else + colors.header_background; + + const chip_text_color = if (is_active) + colors.text_selected + else + colors.text; + + const chip_border = if (is_active) + colors.row_selected + else + colors.border; + + ctx.pushCommand(Command.roundedRect( + chip_bounds.x, + chip_bounds.y, + chip_bounds.w, + chip_bounds.h, + chip_bg, + chip_radius, + )); + + if (!is_active) { + ctx.pushCommand(Command.roundedRectOutline( + chip_bounds.x, + chip_bounds.y, + chip_bounds.w, + chip_bounds.h, + chip_border, + chip_radius, + )); + } + + ctx.pushCommand(Command.text( + chip_bounds.x + chip_padding, + chip_bounds.y + 4, + chip.label, + chip_text_color, + )); + + if (chip_hovered and ctx.input.mousePressed(.left)) { + list_state.activateChip(chip_idx, config.chip_mode); + result.chip_changed = true; + result.chip_index = chip_idx; + result.chip_active = list_state.isChipActive(chip_idx); + } + + current_x += @as(i32, @intCast(chip_w)) + chip_spacing; + } + + current_x += padding; + } + + // Draw Search Input + if (config.show_search) { + const clear_space: u32 = if (config.show_clear_button) clear_btn_w + @as(u32, @intCast(padding)) else 0; + const search_end = bounds.x + @as(i32, @intCast(bounds.w)) - padding - @as(i32, @intCast(clear_space)); + const search_w: u32 = @intCast(@max(60, search_end - current_x)); + + const search_bounds = Layout.Rect.init( + current_x, + item_y, + search_w, + item_h, + ); + + var text_state = text_input.TextInputState{ + .buffer = &list_state.filter_buf, + .len = list_state.filter_len, + .cursor = list_state.search_cursor, + .selection_start = list_state.search_selection_start, + .focused = list_state.search_has_focus, + }; + + 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 = ""; + } + } +} + +// ============================================================================= +// Draw: Header +// ============================================================================= + +pub 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; + + ctx.pushCommand(Command.rect( + bounds.x, + header_y, + bounds.w, + header_h, + colors.header_background, + )); + + var x: i32 = bounds.x - scroll_offset_x; + for (config.columns) |col| { + const col_end = x + @as(i32, @intCast(col.width)); + if (col_end > bounds.x and x < bounds.x + @as(i32, @intCast(bounds.w))) { + ctx.pushCommand(Command.text( + x + 4, + header_y + 3, + col.title, + colors.text, + )); + + 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, + indicator, + colors.text, + )); + } + } + + 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; + } + } + + ctx.pushCommand(Command.rect(col_end - 1, header_y, 1, header_h, colors.border)); + } + + x = col_end; + } + + ctx.pushCommand(Command.rect( + bounds.x, + header_y + @as(i32, @intCast(header_h)) - 1, + bounds.w, + 1, + colors.border, + )); +} + +// ============================================================================= +// Draw: Rows +// ============================================================================= + +pub 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; + + const pds_ptr = ctx.frameAllocator().create(paged_datasource.PagedDataSource) catch { + std.debug.print("[VT-ERROR] No se pudo crear PagedDataSource en frame arena\n", .{}); + return; + }; + pds_ptr.* = paged_datasource.PagedDataSource.init(list_state, config.columns, null); + const table_ds = pds_ptr.toDataSource(); + + const selected_row: i32 = if (list_state.findSelectedInWindow()) |window_idx| + @intCast(list_state.windowToGlobalIndex(window_idx)) + else + -1; + + const first_row = list_state.nav.scroll_row; + const window_rows = list_state.current_window.len; + const last_row = @min( + list_state.nav.scroll_row + visible_rows, + list_state.window_start + window_rows, + ); + + 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, + .visible = true, + }; + } + + 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), + .selected_cell_unfocus = Style.Color.rgb(150, 150, 160), + .text_normal = colors.text, + .text_selected = colors.text_selected, + .border = colors.border, + }; + + var cell_buffer: [256]u8 = undefined; + + const dirty_id: ?i64 = if (list_state.row_edit_buffer.has_changes) + list_state.row_edit_buffer.row_id + else + null; + + _ = 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], + .dirty_row_id = dirty_id, + .edit_buffer = &list_state.row_edit_buffer, + }, + &cell_buffer, + ); +} + +// ============================================================================= +// Draw: Footer +// ============================================================================= + +pub fn drawFooter( + ctx: *Context, + bounds: Layout.Rect, + colors: *const VirtualAdvancedTableConfig.Colors, + list_state: *VirtualAdvancedTableState, +) void { + ctx.pushCommand(Command.rect( + bounds.x, + bounds.y, + bounds.w, + bounds.h, + colors.header_background, + )); + + var count_buf: [64]u8 = undefined; + const count_info = list_state.getDisplayCount(); + const count_str = count_info.format(&count_buf); + + var pos_buf: [32]u8 = undefined; + const pos_str = if (list_state.selected_id != null) + if (list_state.findSelectedInWindow()) |idx| + std.fmt.bufPrint(&pos_buf, "{d}", .{list_state.windowToGlobalIndex(idx) + 1}) catch "?" + else + "?" + else + "-"; + + 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 Vertical +// ============================================================================= + +pub 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; + + table_core.drawVerticalScrollbar(ctx, .{ + .track_x = bounds.x + @as(i32, @intCast(bounds.w - scrollbar_w)), + .track_y = bounds.y + @as(i32, @intCast(header_h)), + .width = scrollbar_w, + .height = content_h, + .visible_count = visible_rows, + .total_count = total_rows, + .scroll_pos = list_state.nav.scroll_row, + .track_color = colors.row_alternate, + .thumb_color = colors.border, + }); +} + +// ============================================================================= +// Draw: Scrollbar Horizontal +// ============================================================================= + +pub 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; + const track_w = bounds.w -| scrollbar_v_w; + const total_width = available_width + @as(u32, @intCast(@max(0, max_scroll_x))); + + table_core.drawHorizontalScrollbar(ctx, .{ + .track_x = bounds.x, + .track_y = bounds.y + @as(i32, @intCast(bounds.h - footer_h - scrollbar_h)), + .width = track_w, + .height = scrollbar_h, + .visible_width = available_width, + .total_width = total_width, + .scroll_x = scroll_offset_x, + .max_scroll_x = max_scroll_x, + .track_color = colors.row_alternate, + .thumb_color = colors.border, + }); +} diff --git a/src/widgets/virtual_advanced_table/input.zig b/src/widgets/virtual_advanced_table/input.zig new file mode 100644 index 0000000..f05c084 --- /dev/null +++ b/src/widgets/virtual_advanced_table/input.zig @@ -0,0 +1,232 @@ +//! VirtualAdvancedTable - Funciones de Input +//! +//! Manejo de teclado y mouse extraído del archivo principal. + +const std = @import("std"); +const Context = @import("../../core/context.zig").Context; +const Layout = @import("../../core/layout.zig"); +const table_core = @import("../table_core/table_core.zig"); + +const types = @import("types.zig"); +const state_mod = @import("state.zig"); +const data_provider = @import("data_provider.zig"); + +pub const VirtualAdvancedTableState = state_mod.VirtualAdvancedTableState; +pub const VirtualAdvancedTableConfig = types.VirtualAdvancedTableConfig; +pub const DataProvider = data_provider.DataProvider; +pub const VirtualAdvancedTableResult = @import("virtual_advanced_table.zig").VirtualAdvancedTableResult; + +// ============================================================================= +// Helper: Check if refetch needed +// ============================================================================= + +pub fn needsRefetch(list_state: *VirtualAdvancedTableState, visible_rows: usize, buffer_size: usize) bool { + // Manual invalidation + 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; +} + +// ============================================================================= +// Auto-scroll horizontal helper +// ============================================================================= + +pub fn ensureColumnVisible( + list_state: *VirtualAdvancedTableState, + columns: []const types.ColumnDef, + visible_width: u32, + max_scroll_x: i32, +) void { + const active_col = list_state.nav.active_col; + if (active_col >= columns.len) return; + + // Calcular posición X de la columna activa + var col_start: i32 = 0; + for (columns, 0..) |col, i| { + if (i == active_col) break; + col_start += @as(i32, @intCast(col.width)); + } + const col_end = col_start + @as(i32, @intCast(columns[active_col].width)); + + // Posición visible + const visible_start = list_state.nav.scroll_x; + const visible_end = visible_start + @as(i32, @intCast(visible_width)); + + // Ajustar scroll + if (col_start < visible_start) { + list_state.nav.scroll_x = col_start; + } else if (col_end > visible_end) { + list_state.nav.scroll_x = col_end - @as(i32, @intCast(visible_width)); + } + + // Clamp + if (list_state.nav.scroll_x < 0) list_state.nav.scroll_x = 0; + if (list_state.nav.scroll_x > max_scroll_x) list_state.nav.scroll_x = max_scroll_x; +} + +// ============================================================================= +// Handle: Keyboard (Brain-in-Core pattern) +// ============================================================================= + +pub fn handleKeyboard( + ctx: *Context, + list_state: *VirtualAdvancedTableState, + provider: DataProvider, + visible_rows: usize, + total_rows: usize, + max_scroll_x: i32, + columns: []const types.ColumnDef, + visible_width: u32, + result: *VirtualAdvancedTableResult, +) void { + _ = provider; + + const h_scroll_step: i32 = 40; + const num_columns = columns.len; + + // Delegar al Core + const events = table_core.processTableEvents(ctx, list_state.isEditing()); + + if (!events.handled) return; + + const prev_col = list_state.nav.active_col; + + // Aplicar navegación + if (events.move_up) list_state.moveUp(); + if (events.move_down) list_state.moveDown(visible_rows); + if (events.move_left) list_state.moveToPrevCol(); + if (events.move_right) list_state.moveToNextCol(num_columns); + if (events.page_up) list_state.pageUp(visible_rows); + if (events.page_down) list_state.pageDown(visible_rows, total_rows); + if (events.go_to_first_col) list_state.goToFirstCol(); + if (events.go_to_last_col) list_state.goToLastCol(num_columns); + if (events.go_to_first_row) list_state.goToStart(); + if (events.go_to_last_row) list_state.goToEnd(visible_rows, total_rows); + if (events.scroll_left) list_state.scrollLeft(h_scroll_step); + if (events.scroll_right) list_state.scrollRight(h_scroll_step, max_scroll_x); + + // Auto-scroll horizontal + if (list_state.nav.active_col != prev_col) { + ensureColumnVisible(list_state, columns, visible_width, max_scroll_x); + } + + // Ctrl+N + if (events.insert_row) { + result.insert_row_requested = true; + list_state.enterInsertionMode(); + } + + // Ctrl+Delete/B + if (events.delete_row) { + result.delete_row_requested = true; + if (list_state.selected_id) |id| { + result.deleted_row_id = id; + } + } + + // Ordenación + if (events.sort_by_column) |col| { + if (col < num_columns) { + result.sort_requested = true; + result.sort_column_index = col; + } + } + + // Edición + if (events.start_editing) { + if (list_state.getActiveCell()) |cell| { + if (events.initial_char) |ch| { + list_state.startEditing(cell, "", ch, ctx.current_time_ms); + } else { + result.cell_committed = false; + result.edited_cell = cell; + } + } + } + + // Tab sin edición + if (events.tab_out and !list_state.isEditing()) { + result.tab_out = true; + result.tab_shift = events.tab_shift; + } +} + +// ============================================================================= +// Handle: Mouse Click +// ============================================================================= + +pub 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(); + const content_y = bounds.y + @as(i32, @intCast(filter_bar_h)) + @as(i32, @intCast(header_h)); + + if (mouse.y >= content_y) { + const relative_y = mouse.y - content_y; + const screen_row = @as(usize, @intCast(relative_y)) / config.row_height; + + 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 column + 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 + 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), + ); + + list_state.nav.double_click = dc_state; + + if (is_double_click and !list_state.isEditing()) { + const cell = types.CellId{ .row = global_row, .col = clicked_col }; + result.edited_cell = cell; + result.double_clicked = true; + result.double_click_id = list_state.current_window[data_idx].id; + } else { + list_state.selectById(list_state.current_window[data_idx].id); + list_state.nav.active_col = clicked_col; + } + } + } +} diff --git a/src/widgets/virtual_advanced_table/virtual_advanced_table.zig b/src/widgets/virtual_advanced_table/virtual_advanced_table.zig index 1e99331..3853328 100644 --- a/src/widgets/virtual_advanced_table/virtual_advanced_table.zig +++ b/src/widgets/virtual_advanced_table/virtual_advanced_table.zig @@ -3,30 +3,31 @@ //! 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) { ... } -//! ``` +//! ## Estructura Modular (2025-12-29) +//! +//! - types.zig: Tipos y configuración +//! - state.zig: Estado del widget +//! - drawing.zig: Funciones de renderizado +//! - input.zig: Manejo de teclado y mouse +//! - cell_editor.zig: Editor de celdas +//! - paged_datasource.zig: DataSource paginado +//! - data_provider.zig: Interface de proveedor de datos 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/table_core.zig"); -// Re-exports públicos +// Módulos internos 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"); +pub const drawing = @import("drawing.zig"); +pub const input = @import("input.zig"); // Tipos principales pub const RowData = types.RowData; @@ -67,93 +68,47 @@ pub const VirtualAdvancedTableResult = struct { sort_requested: bool = false, sort_column: ?[]const u8 = null, sort_direction: SortDirection = .none, - /// Índice de columna para ordenar (Ctrl+Shift+1..9, 0-based) sort_column_index: ?usize = null, /// 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) + // Edición CRUD Excel-style // ========================================================================= - /// 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, - - /// Ctrl+N: el usuario solicitó insertar nueva fila insert_row_requested: bool = false, - - /// Ctrl+Delete o Ctrl+B: el usuario solicitó eliminar fila actual delete_row_requested: bool = false, - - /// ID de la fila a eliminar (válido si delete_row_requested = true) deleted_row_id: i64 = -1, - - /// 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. + // 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, - // ========================================================================= - // Tips Proactivos (FASE I) - // ========================================================================= - - /// Tip actual para mostrar en StatusLine (null = sin tip) - /// Rota automáticamente cada ~10 segundos (600 frames @ 60fps) + // Tips current_tip: ?[]const u8 = 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]; } @@ -186,19 +141,13 @@ pub fn virtualAdvancedTableRect( 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); @@ -208,78 +157,58 @@ pub fn virtualAdvancedTableRect( 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 + // Calculate dimensions 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 scrollbar_v_w: u32 = 12; 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 footer_h: u32 = if (config.show_count) 16 else 0; 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 + // Fetch data if 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 (input.needsRefetch(list_state, visible_rows, buffer_size)) { 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 - } + } else |_| {} } - // 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 + // Draw FilterBar 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); + const filter_bounds = Layout.Rect.init(bounds.x, bounds.y, bounds.w, fb_config.height); + drawing.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 header + drawing.drawHeaderAt(ctx, bounds, header_y, config, &colors, list_state, &result, list_state.nav.scroll_x); - // Draw visible rows + // Draw rows const content_bounds = Layout.Rect.init( bounds.x, header_y + @as(i32, @intCast(header_h)), @@ -287,7 +216,6 @@ pub fn virtualAdvancedTableRect( content_h, ); - // Draw content background first (so empty space isn't black) ctx.pushCommand(Command.rect( content_bounds.x, content_bounds.y, @@ -296,13 +224,12 @@ pub fn virtualAdvancedTableRect( colors.row_normal, )); - drawRows(ctx, content_bounds, config, &colors, list_state, visible_rows, &result, list_state.nav.scroll_x); + drawing.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, @@ -313,7 +240,6 @@ pub fn virtualAdvancedTableRect( header_h, filter_bar_h, )) |geom| { - // Draw cell editor con colores del panel const editor_result = cell_editor.drawCellEditor( ctx, list_state, @@ -321,35 +247,21 @@ pub fn virtualAdvancedTableRect( cell_editor.CellEditorColors{ .background = colors.background, .text = colors.text, - .cursor = colors.text, // Cursor mismo color que texto + .cursor = colors.text, }, ); - // Handle editor results if (editor_result.committed) { const edited_cell = list_state.getEditingCell().?; const new_value = list_state.getEditText(); - std.debug.print("[VT-DEBUG] editor committed: cell=({},{}) value=\"{s}\" hasChanged={}\n", .{ - edited_cell.row, - edited_cell.col, - new_value, - list_state.hasValueChanged(), - }); - - // 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); - std.debug.print("[VT-DEBUG] addChange: col={} value=\"{s}\"\n", .{ 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) { @@ -362,10 +274,9 @@ pub fn virtualAdvancedTableRect( } } - // End clipping ctx.pushCommand(Command.clipEnd()); - // Draw footer with count + // Draw footer if (config.show_count) { const footer_bounds = Layout.Rect.init( bounds.x, @@ -373,46 +284,38 @@ pub fn virtualAdvancedTableRect( bounds.w, footer_h, ); - drawFooter(ctx, footer_bounds, &colors, list_state); + drawing.drawFooter(ctx, footer_bounds, &colors, list_state); } - // Draw vertical scrollbar if needed + // Draw scrollbars 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); + drawing.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); + drawing.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) + // Draw border 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, - )); + 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, bounds.w, &result); + input.handleKeyboard(ctx, list_state, provider, visible_rows, total_rows, max_scroll_x, config.columns, bounds.w, &result); } - // Handle mouse clicks on rows + // Handle mouse clicks if (clicked and hovered) { - handleMouseClick(ctx, bounds, filter_bar_h, header_h, config, list_state, &result); + input.handleMouseClick(ctx, bounds, filter_bar_h, header_h, config, list_state, &result); } // Update result @@ -423,19 +326,15 @@ pub fn virtualAdvancedTableRect( result.double_click_id = list_state.selected_id; } - // ========================================================================= - // Navegación Tab con commit automático (DRY: lógica en table_core) - // ========================================================================= + // Tab navigation with auto-commit if (result.navigate_direction != .none) { const is_tab = result.navigate_direction == .next_cell or result.navigate_direction == .prev_cell; if (is_tab) { - // Wrapper para DataProvider que implementa getRowId(usize) -> i64 const RowIdGetter = struct { prov: DataProvider, total: usize, pub fn getRowId(self: @This(), row: usize) i64 { - // Ghost row está al final if (row >= self.total) { return table_core.NEW_ROW_ID; } @@ -443,14 +342,10 @@ pub fn virtualAdvancedTableRect( } }; - const getter = RowIdGetter{ - .prov = provider, - .total = total_rows, - }; + const getter = RowIdGetter{ .prov = provider, .total = total_rows }; const current_row = list_state.getSelectedRow() orelse 0; const forward = result.navigate_direction == .next_cell; const num_cols = config.columns.len; - // +1 para ghost row const num_rows = total_rows + 1; const plan = table_core.planTabNavigation( @@ -460,18 +355,15 @@ pub fn virtualAdvancedTableRect( num_cols, num_rows, forward, - true, // wrap habilitado + true, getter, &result.row_changes, ); - // Ejecutar el plan switch (plan.action) { .move, .move_with_commit => { - // Actualizar columna list_state.nav.active_col = plan.new_col; - // Si cambió de fila, navegar if (plan.new_row != current_row) { if (plan.new_row == 0) { list_state.goToStart(); @@ -482,29 +374,17 @@ pub fn virtualAdvancedTableRect( } } - // Si hay commit, establecer flags - // IMPORTANTE: Solo hacer commit si REALMENTE cambió de fila - // Tab dentro de la misma fila NO debe triggerar commit (Excel-style) if (plan.action == .move_with_commit and plan.new_row != current_row) { - if (plan.commit_info) |info| { - std.debug.print("[VT-TAB] Commit real: row {} -> {}, is_insert={}\n", .{ - current_row, plan.new_row, info.is_insert, - }); + if (plan.commit_info) |info_val| { result.row_committed = true; - result.row_commit_id = info.row_id; - result.row_commit_is_insert = info.is_insert; - result.row_changes_count = info.change_count; + result.row_commit_id = info_val.row_id; + result.row_commit_is_insert = info_val.is_insert; + result.row_changes_count = info_val.change_count; result.row_changed = true; } - } else if (plan.action == .move_with_commit and plan.new_row == current_row) { - std.debug.print("[VT-TAB] Commit SUPRIMIDO: misma fila {}, manteniendo buffer\n", .{current_row}); - // NO limpiar el buffer - mantener cambios pendientes } - // Indicar al panel que debe auto-editar la nueva celda result.edited_cell = .{ .row = plan.new_row, .col = plan.new_col }; - - // Marcar que navegación fue procesada internamente result.navigate_direction = .none; }, .exit, .exit_with_commit => { @@ -512,32 +392,27 @@ pub fn virtualAdvancedTableRect( result.tab_shift = !forward; if (plan.action == .exit_with_commit) { - if (plan.commit_info) |info| { + if (plan.commit_info) |info_val| { result.row_committed = true; - result.row_commit_id = info.row_id; - result.row_commit_is_insert = info.is_insert; - result.row_changes_count = info.change_count; + result.row_commit_id = info_val.row_id; + result.row_commit_is_insert = info_val.is_insert; + result.row_changes_count = info_val.change_count; result.row_changed = true; } } - // Marcar que navegación fue procesada internamente result.navigate_direction = .none; }, } } } - // ========================================================================= - // Commit de fila al cambiar de selección (mouse/flechas - backup) - // ========================================================================= - // Si la selección cambió y hay cambios pendientes en otra fila, hacer commit + // Commit on selection change 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, @@ -553,14 +428,10 @@ pub fn virtualAdvancedTableRect( } } - // ========================================================================= - // Tips Proactivos (FASE I): Rotar tips cada ~10 segundos - // ========================================================================= - list_state.frame_count +%= 1; // Wrap around en overflow + // Tips + list_state.frame_count +%= 1; - // Solo mostrar tips si tiene focus y no está editando if (has_focus and !list_state.isEditing()) { - // Rotar tip cada TIP_ROTATION_FRAMES frames if (list_state.frame_count % table_core.TIP_ROTATION_FRAMES == 0) { list_state.tip_index = @intCast((list_state.tip_index + 1) % table_core.table_tips.len); } @@ -570,780 +441,6 @@ pub fn virtualAdvancedTableRect( 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 en memoria estable del frame (frame arena) - // Esto evita punteros inválidos cuando el stack se destruye - const pds_ptr = ctx.frameAllocator().create(paged_datasource.PagedDataSource) catch { - std.debug.print("[VT-ERROR] No se pudo crear PagedDataSource en frame arena\n", .{}); - return; - }; - pds_ptr.* = paged_datasource.PagedDataSource.init(list_state, config.columns, null); - const table_ds = pds_ptr.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 window_rows = list_state.current_window.len; - const last_row = @min( - list_state.nav.scroll_row + visible_rows, - list_state.window_start + window_rows, - ); - - // 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; - - // Determinar dirty_row_id (fila con cambios pendientes) - const dirty_id: ?i64 = if (list_state.row_edit_buffer.has_changes) - list_state.row_edit_buffer.row_id - else - null; - - // 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], - .dirty_row_id = dirty_id, - .edit_buffer = &list_state.row_edit_buffer, - }, - &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; - - // Usar función unificada de table_core - table_core.drawVerticalScrollbar(ctx, .{ - .track_x = bounds.x + @as(i32, @intCast(bounds.w - scrollbar_w)), - .track_y = bounds.y + @as(i32, @intCast(header_h)), - .width = scrollbar_w, - .height = content_h, - .visible_count = visible_rows, - .total_count = total_rows, - .scroll_pos = list_state.nav.scroll_row, - .track_color = colors.row_alternate, - .thumb_color = 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 - const track_w = bounds.w -| scrollbar_v_w; - const total_width = available_width + @as(u32, @intCast(@max(0, max_scroll_x))); - - // Usar función unificada de table_core - table_core.drawHorizontalScrollbar(ctx, .{ - .track_x = bounds.x, - .track_y = bounds.y + @as(i32, @intCast(bounds.h - footer_h - scrollbar_h)), - .width = track_w, - .height = scrollbar_h, - .visible_width = available_width, - .total_width = total_width, - .scroll_x = scroll_offset_x, - .max_scroll_x = max_scroll_x, - .track_color = colors.row_alternate, - .thumb_color = colors.border, - }); -} - -// ============================================================================= -// Auto-scroll horizontal helper -// ============================================================================= - -/// Asegura que la columna activa sea visible ajustando scroll_x si es necesario -fn ensureColumnVisible( - list_state: *VirtualAdvancedTableState, - columns: []const types.ColumnDef, - visible_width: u32, - max_scroll_x: i32, -) void { - const active_col = list_state.nav.active_col; - if (active_col >= columns.len) return; - - // Calcular posición X de la columna activa - var col_start: i32 = 0; - for (columns, 0..) |col, i| { - if (i == active_col) break; - col_start += @as(i32, @intCast(col.width)); - } - const col_end = col_start + @as(i32, @intCast(columns[active_col].width)); - - // Posición visible (considerando scroll actual) - const visible_start = list_state.nav.scroll_x; - const visible_end = visible_start + @as(i32, @intCast(visible_width)); - - // Ajustar scroll si la columna está fuera de vista - if (col_start < visible_start) { - // Columna está a la izquierda: scroll para mostrar inicio de columna - list_state.nav.scroll_x = col_start; - } else if (col_end > visible_end) { - // Columna está a la derecha: scroll para mostrar final de columna - list_state.nav.scroll_x = col_end - @as(i32, @intCast(visible_width)); - } - - // Clamp scroll_x a límites válidos - if (list_state.nav.scroll_x < 0) list_state.nav.scroll_x = 0; - if (list_state.nav.scroll_x > max_scroll_x) list_state.nav.scroll_x = max_scroll_x; -} - -// ============================================================================= -// Handle: Keyboard (Brain-in-Core pattern) -// ============================================================================= -// -// Arquitectura: TODA la lógica de decisión está en table_core.processTableEvents() -// Este handler solo traduce los flags a acciones sobre el state local. - -fn handleKeyboard( - ctx: *Context, - list_state: *VirtualAdvancedTableState, - provider: DataProvider, - visible_rows: usize, - total_rows: usize, - max_scroll_x: i32, - columns: []const types.ColumnDef, - visible_width: u32, - result: *VirtualAdvancedTableResult, -) void { - _ = provider; - - const h_scroll_step: i32 = 40; // Pixels per arrow key press - const num_columns = columns.len; - - // ========================================================================= - // BRAIN-IN-CORE: Delegar toda la lógica de decisión al Core - // ========================================================================= - const events = table_core.processTableEvents(ctx, list_state.isEditing()); - - if (!events.handled) return; - - // Guardar columna activa antes del cambio para detectar movimiento - const prev_col = list_state.nav.active_col; - - // ========================================================================= - // Aplicar acciones de navegación - // ========================================================================= - if (events.move_up) list_state.moveUp(); - if (events.move_down) list_state.moveDown(visible_rows); - if (events.move_left) list_state.moveToPrevCol(); - if (events.move_right) list_state.moveToNextCol(num_columns); - if (events.page_up) list_state.pageUp(visible_rows); - if (events.page_down) list_state.pageDown(visible_rows, total_rows); - if (events.go_to_first_col) list_state.goToFirstCol(); - if (events.go_to_last_col) list_state.goToLastCol(num_columns); - if (events.go_to_first_row) list_state.goToStart(); - if (events.go_to_last_row) list_state.goToEnd(visible_rows, total_rows); - if (events.scroll_left) list_state.scrollLeft(h_scroll_step); - if (events.scroll_right) list_state.scrollRight(h_scroll_step, max_scroll_x); - - // ========================================================================= - // Auto-scroll horizontal: asegurar que la columna activa sea visible - // ========================================================================= - if (list_state.nav.active_col != prev_col) { - ensureColumnVisible(list_state, columns, visible_width, max_scroll_x); - } - - // ========================================================================= - // Ctrl+N: Modo Inserción Cronológico (INSERT real en BD) - // ========================================================================= - if (events.insert_row) { - // Emitir evento para que el Panel haga INSERT real en BD - result.insert_row_requested = true; - // Entrar en modo inserción - list_state.enterInsertionMode(); - } - - // ========================================================================= - // Ctrl+Delete/B: Eliminar fila - // ========================================================================= - if (events.delete_row) { - result.delete_row_requested = true; - // Obtener el ID de la fila seleccionada - if (list_state.selected_id) |id| { - result.deleted_row_id = id; - } - } - - // ========================================================================= - // Ordenación - // ========================================================================= - if (events.sort_by_column) |col| { - if (col < num_columns) { - result.sort_requested = true; - result.sort_column_index = col; - } - } - - // ========================================================================= - // Inicio de edición - // ========================================================================= - if (events.start_editing) { - if (list_state.getActiveCell()) |cell| { - if (events.initial_char) |ch| { - // Tecla alfanumérica: iniciar con ese caracter - list_state.startEditing(cell, "", ch, ctx.current_time_ms); - } else { - // F2/Space: señalar al panel que debe iniciar edición - result.cell_committed = false; - result.edited_cell = cell; - } - } - } - - // ========================================================================= - // Tab sin edición activa (pasar focus al siguiente widget) - // ========================================================================= - // NOTA: Tab DURANTE edición se procesa en planTabNavigation más arriba - if (events.tab_out and !list_state.isEditing()) { - result.tab_out = true; - result.tab_shift = events.tab_shift; - } -} - -// ============================================================================= -// 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 // ============================================================================= @@ -1352,6 +449,8 @@ test "virtual_advanced_table module imports" { _ = types; _ = data_provider; _ = state_mod; + _ = drawing; + _ = input; _ = RowData; _ = ColumnDef; _ = DataProvider; @@ -1364,4 +463,6 @@ test { _ = @import("data_provider.zig"); _ = @import("state.zig"); _ = @import("cell_editor.zig"); + _ = @import("drawing.zig"); + _ = @import("input.zig"); }