//! AdvancedTable State - Mutable state management //! //! Manages selection, editing, dirty tracking, sorting, and snapshots. const std = @import("std"); const types = @import("types.zig"); const schema_mod = @import("schema.zig"); const table_core = @import("../table_core/table_core.zig"); pub const CellValue = types.CellValue; pub const RowState = types.RowState; pub const SortDirection = types.SortDirection; pub const CRUDAction = types.CRUDAction; pub const Row = types.Row; pub const TableSchema = schema_mod.TableSchema; pub const MAX_EDIT_BUFFER = types.MAX_EDIT_BUFFER; // ============================================================================= // AdvancedTable State // ============================================================================= /// Complete state for AdvancedTable pub const AdvancedTableState = struct { // ========================================================================= // Data // ========================================================================= /// Row data rows: std.ArrayListUnmanaged(Row) = .{}, /// Allocator for dynamic allocations allocator: std.mem.Allocator, // ========================================================================= // Selection // ========================================================================= /// Currently selected row (-1 = none) selected_row: i32 = -1, /// Currently selected column (-1 = none) selected_col: i32 = -1, /// Previous selected row (for callbacks) prev_selected_row: i32 = -1, /// Previous selected column prev_selected_col: i32 = -1, // ========================================================================= // Multi-Row Selection (from Table widget) // ========================================================================= /// Multi-row selection (bit array for first 1024 rows) selected_rows: [128]u8 = [_]u8{0} ** 128, // 1024 bits /// Selection anchor for shift-click range selection selection_anchor: i32 = -1, // ========================================================================= // Incremental Search (from Table widget) // ========================================================================= /// Search buffer for type-to-search search_buffer: [64]u8 = [_]u8{0} ** 64, /// Length of search term search_len: usize = 0, /// Last search keypress time (for timeout reset) search_last_time: u64 = 0, /// Search timeout in ms (reset after this) search_timeout_ms: u64 = 1000, // ========================================================================= // Cell Validation (from Table widget) // ========================================================================= /// Cells with validation errors (row * MAX_COLUMNS + col) cell_validation_errors: [256]u32 = [_]u32{0xFFFFFFFF} ** 256, /// Number of cells with validation errors cell_validation_error_count: usize = 0, /// Last validation error message last_validation_message: [128]u8 = [_]u8{0} ** 128, /// Length of last validation message last_validation_message_len: usize = 0, // ========================================================================= // Editing (usa CellEditState de table_core para composición) // ========================================================================= /// Estado de edición embebido (Fase 2 refactor) cell_edit: table_core.CellEditState = .{}, /// Buffer de edición de fila Excel-style (acumula cambios antes de commit) row_edit_buffer: table_core.RowEditBuffer = .{}, /// Original value before editing (for revert on Escape) /// NOTA: Mantenemos esto porque CellValue es más rico que buffer crudo original_value: ?CellValue = null, // ========================================================================= // Navegación (usa NavigationState de table_core para composición) // ========================================================================= /// Estado de navegación embebido (FASE 5 refactor) /// Incluye: active_col, scroll_row, scroll_x, has_focus, double_click nav: table_core.NavigationState = .{}, // Aliases para backwards compatibility: // - scroll_row → nav.scroll_row (acceso directo abajo) // - double_click_* → nav.double_click.* (acceso directo abajo) // ========================================================================= // Sorting // ========================================================================= /// Current sort column (-1 = none) sort_column: i32 = -1, /// Sort direction sort_direction: SortDirection = .none, /// Original order for restore (saved on first sort) original_order: std.ArrayListUnmanaged(Row) = .{}, /// Whether original order has been saved has_original_order: bool = false, // ========================================================================= // Scrolling (delegado a nav - ver NavigationState) // ========================================================================= // scroll_row y scroll_x ahora están en nav.scroll_row y nav.scroll_x // ========================================================================= // State Maps (sparse - only modified rows) // ========================================================================= /// Dirty (modified) rows dirty_rows: std.AutoHashMap(usize, bool), /// New rows (not yet saved to DB) new_rows: std.AutoHashMap(usize, bool), /// Deleted rows (marked for deletion) deleted_rows: std.AutoHashMap(usize, bool), /// Rows with validation errors validation_errors: std.AutoHashMap(usize, bool), // ========================================================================= // Snapshots (for Auto-CRUD) // ========================================================================= /// Row snapshots captured when entering row row_snapshots: std.AutoHashMap(usize, Row), // ========================================================================= // Callbacks & Debounce (Phase 8) // ========================================================================= /// Last time a callback was invoked (for debouncing) last_callback_time_ms: u64 = 0, /// Last row that triggered on_active_row_changed (to avoid duplicate calls) last_notified_row: i32 = -1, // ========================================================================= // Propiedades de compatibilidad (FASE 5 - delegación a nav) // ========================================================================= /// Alias para nav.scroll_row (backwards compatibility) pub fn getScrollRow(self: *const AdvancedTableState) usize { return self.nav.scroll_row; } pub fn setScrollRow(self: *AdvancedTableState, row: usize) void { self.nav.scroll_row = row; } /// Alias para nav.has_focus (backwards compatibility) pub fn hasFocus(self: *const AdvancedTableState) bool { return self.nav.has_focus; } pub fn setFocus(self: *AdvancedTableState, focused: bool) void { self.nav.has_focus = focused; } /// Double-click: último tiempo de click pub fn getLastClickTime(self: *const AdvancedTableState) u64 { return self.nav.double_click.last_click_time; } pub fn setLastClickTime(self: *AdvancedTableState, time: u64) void { self.nav.double_click.last_click_time = time; } /// Double-click: última fila clickeada pub fn getLastClickRow(self: *const AdvancedTableState) i64 { return self.nav.double_click.last_click_row; } pub fn setLastClickRow(self: *AdvancedTableState, row: i64) void { self.nav.double_click.last_click_row = row; } /// Double-click: última columna clickeada pub fn getLastClickCol(self: *const AdvancedTableState) i32 { return self.nav.double_click.last_click_col; } pub fn setLastClickCol(self: *AdvancedTableState, col: i32) void { self.nav.double_click.last_click_col = col; } /// Double-click: threshold en ms pub fn getDoubleClickThreshold(self: *const AdvancedTableState) u64 { return self.nav.double_click.threshold_ms; } // ========================================================================= // Lifecycle // ========================================================================= pub fn init(allocator: std.mem.Allocator) AdvancedTableState { return .{ .allocator = allocator, .dirty_rows = std.AutoHashMap(usize, bool).init(allocator), .new_rows = std.AutoHashMap(usize, bool).init(allocator), .deleted_rows = std.AutoHashMap(usize, bool).init(allocator), .validation_errors = std.AutoHashMap(usize, bool).init(allocator), .row_snapshots = std.AutoHashMap(usize, Row).init(allocator), }; } pub fn deinit(self: *AdvancedTableState) void { // Deinit all rows for (self.rows.items) |*row| { row.deinit(); } self.rows.deinit(self.allocator); // Deinit state maps self.dirty_rows.deinit(); self.new_rows.deinit(); self.deleted_rows.deinit(); self.validation_errors.deinit(); // Deinit snapshots var snapshot_iter = self.row_snapshots.valueIterator(); while (snapshot_iter.next()) |row| { var mutable_row = row.*; mutable_row.deinit(); } self.row_snapshots.deinit(); // Deinit original order if exists if (self.has_original_order) { for (self.original_order.items) |*row| { row.deinit(); } self.original_order.deinit(self.allocator); } } // ========================================================================= // Data Access // ========================================================================= /// Get row count pub fn getRowCount(self: *const AdvancedTableState) usize { return self.rows.items.len; } /// Get row by index /// ADVERTENCIA: El puntero retornado se invalida tras sortRows() o setRows(). /// No guardar el puntero entre frames - obtenerlo de nuevo cuando sea necesario. pub fn getRow(self: *AdvancedTableState, index: usize) ?*Row { if (index >= self.rows.items.len) return null; return &self.rows.items[index]; } /// Get row by index (const) /// ADVERTENCIA: El puntero retornado se invalida tras sortRows() o setRows(). /// No guardar el puntero entre frames - obtenerlo de nuevo cuando sea necesario. pub fn getRowConst(self: *const AdvancedTableState, index: usize) ?*const Row { if (index >= self.rows.items.len) return null; return &self.rows.items[index]; } /// Set rows (replaces all data) pub fn setRows(self: *AdvancedTableState, new_rows: []const Row) !void { // Clear existing for (self.rows.items) |*row| { row.deinit(); } self.rows.clearRetainingCapacity(); // Copy new rows for (new_rows) |row| { const cloned = try row.clone(self.allocator); try self.rows.append(self.allocator, cloned); } // Reset state self.clearAllState(); } /// Clear all state maps pub fn clearAllState(self: *AdvancedTableState) void { self.dirty_rows.clearRetainingCapacity(); self.new_rows.clearRetainingCapacity(); self.deleted_rows.clearRetainingCapacity(); self.validation_errors.clearRetainingCapacity(); // Clear snapshots var snapshot_iter = self.row_snapshots.valueIterator(); while (snapshot_iter.next()) |row| { var mutable_row = row.*; mutable_row.deinit(); } self.row_snapshots.clearRetainingCapacity(); // Clear selection self.selected_row = -1; self.selected_col = -1; self.prev_selected_row = -1; self.prev_selected_col = -1; // Clear editing self.cell_edit.stopEditing(); // Clear sorting self.sort_column = -1; self.sort_direction = .none; } // ========================================================================= // Row Operations // ========================================================================= /// Insert new empty row at index pub fn insertRow(self: *AdvancedTableState, index: usize) !usize { const actual_index = @min(index, self.rows.items.len); // Create empty row const new_row = Row.init(self.allocator); try self.rows.insert(self.allocator, actual_index, new_row); // Shift state maps self.shiftRowIndicesDown(actual_index); // Mark as new try self.new_rows.put(actual_index, true); return actual_index; } /// Append new empty row at end pub fn appendRow(self: *AdvancedTableState) !usize { const new_row = Row.init(self.allocator); try self.rows.append(self.allocator, new_row); const index = self.rows.items.len - 1; try self.new_rows.put(index, true); return index; } /// Delete row at index pub fn deleteRow(self: *AdvancedTableState, index: usize) void { if (index >= self.rows.items.len) return; // Deinit the row self.rows.items[index].deinit(); // Remove from array _ = self.rows.orderedRemove(index); // Shift state maps up self.shiftRowIndicesUp(index); // Adjust selection if (self.selected_row > @as(i32, @intCast(index))) { self.selected_row -= 1; } else if (self.selected_row == @as(i32, @intCast(index))) { if (self.selected_row >= @as(i32, @intCast(self.rows.items.len))) { self.selected_row = @as(i32, @intCast(self.rows.items.len)) - 1; } } } /// Move row up pub fn moveRowUp(self: *AdvancedTableState, index: usize) bool { if (index == 0 or index >= self.rows.items.len) return false; // Swap rows const temp = self.rows.items[index - 1]; self.rows.items[index - 1] = self.rows.items[index]; self.rows.items[index] = temp; // Swap state maps self.swapRowStates(index - 1, index); return true; } /// Move row down pub fn moveRowDown(self: *AdvancedTableState, index: usize) bool { if (index >= self.rows.items.len - 1) return false; // Swap rows const temp = self.rows.items[index + 1]; self.rows.items[index + 1] = self.rows.items[index]; self.rows.items[index] = temp; // Swap state maps self.swapRowStates(index, index + 1); return true; } // ========================================================================= // State Queries // ========================================================================= /// Get row state pub fn getRowState(self: *const AdvancedTableState, index: usize) RowState { if (self.deleted_rows.get(index)) |_| return .deleted; if (self.validation_errors.get(index)) |_| return .@"error"; if (self.new_rows.get(index)) |_| return .new; if (self.dirty_rows.get(index)) |_| return .modified; return .normal; } /// Mark row as dirty (modified) pub fn markDirty(self: *AdvancedTableState, index: usize) void { self.dirty_rows.put(index, true) catch {}; } /// Mark row as new pub fn markNew(self: *AdvancedTableState, index: usize) void { self.new_rows.put(index, true) catch {}; } /// Mark row as deleted pub fn markDeleted(self: *AdvancedTableState, index: usize) void { self.deleted_rows.put(index, true) catch {}; } /// Mark row as having validation error pub fn markError(self: *AdvancedTableState, index: usize) void { self.validation_errors.put(index, true) catch {}; } /// Clear all state for row pub fn clearRowState(self: *AdvancedTableState, index: usize) void { _ = self.dirty_rows.remove(index); _ = self.new_rows.remove(index); _ = self.deleted_rows.remove(index); _ = self.validation_errors.remove(index); } /// Check if row is dirty (modified) pub fn isDirty(self: *const AdvancedTableState, index: usize) bool { return self.dirty_rows.get(index) orelse false; } /// Check if row is new pub fn isNew(self: *const AdvancedTableState, index: usize) bool { return self.new_rows.get(index) orelse false; } /// Check if row is marked for deletion pub fn isDeleted(self: *const AdvancedTableState, index: usize) bool { return self.deleted_rows.get(index) orelse false; } /// Check if row has validation error pub fn hasError(self: *const AdvancedTableState, index: usize) bool { return self.validation_errors.get(index) orelse false; } /// Check if any row is dirty pub fn hasAnyDirty(self: *const AdvancedTableState) bool { return self.dirty_rows.count() > 0 or self.new_rows.count() > 0; } // ========================================================================= // Selection // ========================================================================= /// Select cell pub fn selectCell(self: *AdvancedTableState, row: usize, col: usize) void { self.prev_selected_row = self.selected_row; self.prev_selected_col = self.selected_col; self.selected_row = @intCast(row); self.selected_col = @intCast(col); } /// Clear selection pub fn clearSelection(self: *AdvancedTableState) void { self.prev_selected_row = self.selected_row; self.prev_selected_col = self.selected_col; self.selected_row = -1; self.selected_col = -1; } /// Get selected cell (row, col) or null pub fn getSelectedCell(self: *const AdvancedTableState) ?struct { row: usize, col: usize } { if (self.selected_row < 0 or self.selected_col < 0) return null; return .{ .row = @intCast(self.selected_row), .col = @intCast(self.selected_col), }; } /// Check if row changed from previous selection pub fn rowChanged(self: *const AdvancedTableState) bool { return self.selected_row != self.prev_selected_row; } // ========================================================================= // Multi-Row Selection (from Table widget) // ========================================================================= /// Check if a row is in multi-selection pub fn isRowSelected(self: *const AdvancedTableState, row: usize) bool { if (row >= 1024) return false; const byte_idx = row / 8; const bit_idx: u3 = @intCast(row % 8); return (self.selected_rows[byte_idx] & (@as(u8, 1) << bit_idx)) != 0; } /// Add a row to multi-selection pub fn addRowToSelection(self: *AdvancedTableState, row: usize) void { if (row >= 1024) return; const byte_idx = row / 8; const bit_idx: u3 = @intCast(row % 8); self.selected_rows[byte_idx] |= (@as(u8, 1) << bit_idx); } /// Remove a row from multi-selection pub fn removeRowFromSelection(self: *AdvancedTableState, row: usize) void { if (row >= 1024) return; const byte_idx = row / 8; const bit_idx: u3 = @intCast(row % 8); self.selected_rows[byte_idx] &= ~(@as(u8, 1) << bit_idx); } /// Toggle row in multi-selection pub fn toggleRowSelection(self: *AdvancedTableState, row: usize) void { if (self.isRowSelected(row)) { self.removeRowFromSelection(row); } else { self.addRowToSelection(row); } } /// Clear all multi-row selections pub fn clearRowSelection(self: *AdvancedTableState) void { @memset(&self.selected_rows, 0); } /// Select all rows (for Ctrl+A) pub fn selectAllRows(self: *AdvancedTableState) void { const row_count = self.getRowCount(); if (row_count == 0) return; const full_bytes = row_count / 8; const remaining_bits: u3 = @intCast(row_count % 8); for (0..full_bytes) |i| { self.selected_rows[i] = 0xFF; } if (remaining_bits > 0 and full_bytes < self.selected_rows.len) { self.selected_rows[full_bytes] = (@as(u8, 1) << remaining_bits) - 1; } } /// Select range of rows (for Shift+click) pub fn selectRowRange(self: *AdvancedTableState, from: usize, to: usize) void { const start = @min(from, to); const end = @max(from, to); for (start..end + 1) |row| { self.addRowToSelection(row); } } /// Get count of selected rows (uses popcount for efficiency) pub fn getSelectedRowCount(self: *const AdvancedTableState) usize { var count: usize = 0; for (self.selected_rows) |byte| { count += @popCount(byte); } return count; } /// Get list of selected row indices (up to buffer.len) pub fn getSelectedRows(self: *const AdvancedTableState, buffer: []usize) usize { var count: usize = 0; for (0..1024) |row| { if (self.isRowSelected(row) and count < buffer.len) { buffer[count] = row; count += 1; } } return count; } /// Select a single row (clears others, sets anchor) pub fn selectSingleRow(self: *AdvancedTableState, row: usize) void { self.clearRowSelection(); self.addRowToSelection(row); self.selected_row = @intCast(row); self.selection_anchor = @intCast(row); } // ========================================================================= // Incremental Search (from Table widget) // ========================================================================= /// Add character to search buffer (returns current search term) pub fn addSearchChar(self: *AdvancedTableState, char: u8, current_time: u64) []const u8 { // Reset search if timeout expired if (current_time > self.search_last_time + self.search_timeout_ms) { self.search_len = 0; } // Add character if room if (self.search_len < self.search_buffer.len) { self.search_buffer[self.search_len] = char; self.search_len += 1; } self.search_last_time = current_time; return self.search_buffer[0..self.search_len]; } /// Get current search term pub fn getSearchTerm(self: *const AdvancedTableState) []const u8 { return self.search_buffer[0..self.search_len]; } /// Clear search buffer pub fn clearSearch(self: *AdvancedTableState) void { self.search_len = 0; } // ========================================================================= // Cell Validation (from Table widget) // ========================================================================= /// Check if a specific cell has a validation error pub fn hasCellError(self: *const AdvancedTableState, row: usize, col: usize) bool { const cell_id = @as(u32, @intCast(row)) * types.MAX_COLUMNS + @as(u32, @intCast(col)); for (0..self.cell_validation_error_count) |i| { if (self.cell_validation_errors[i] == cell_id) { return true; } } return false; } /// Add a validation error for a cell pub fn addCellError(self: *AdvancedTableState, row: usize, col: usize, message: []const u8) void { // Store message const copy_len = @min(message.len, self.last_validation_message.len); for (0..copy_len) |i| { self.last_validation_message[i] = message[i]; } self.last_validation_message_len = copy_len; // Don't add duplicate if (self.hasCellError(row, col)) return; if (self.cell_validation_error_count >= self.cell_validation_errors.len) return; const cell_id = @as(u32, @intCast(row)) * types.MAX_COLUMNS + @as(u32, @intCast(col)); self.cell_validation_errors[self.cell_validation_error_count] = cell_id; self.cell_validation_error_count += 1; } /// Clear validation error for a cell pub fn clearCellError(self: *AdvancedTableState, row: usize, col: usize) void { const cell_id = @as(u32, @intCast(row)) * types.MAX_COLUMNS + @as(u32, @intCast(col)); for (0..self.cell_validation_error_count) |i| { if (self.cell_validation_errors[i] == cell_id) { // Move last error to this slot if (self.cell_validation_error_count > 1) { self.cell_validation_errors[i] = self.cell_validation_errors[self.cell_validation_error_count - 1]; } self.cell_validation_error_count -= 1; return; } } } /// Clear all cell validation errors pub fn clearAllCellErrors(self: *AdvancedTableState) void { self.cell_validation_error_count = 0; self.last_validation_message_len = 0; } /// Check if any cell has validation errors pub fn hasAnyCellErrors(self: *const AdvancedTableState) bool { return self.cell_validation_error_count > 0; } /// Get last validation message pub fn getLastValidationMessage(self: *const AdvancedTableState) []const u8 { return self.last_validation_message[0..self.last_validation_message_len]; } // ========================================================================= // Editing (delega a cell_edit embebido) // ========================================================================= /// Start editing current cell /// Usa la celda seleccionada (selected_row, selected_col) pub fn startEditing(self: *AdvancedTableState, initial_value: []const u8) void { const row: usize = if (self.selected_row >= 0) @intCast(self.selected_row) else 0; const col: usize = if (self.selected_col >= 0) @intCast(self.selected_col) else 0; self.cell_edit.startEditing(row, col, initial_value, null); } /// Stop editing pub fn stopEditing(self: *AdvancedTableState) void { self.cell_edit.stopEditing(); self.original_value = null; } /// Get current edit text pub fn getEditText(self: *const AdvancedTableState) []const u8 { return self.cell_edit.getEditText(); } /// Check if currently editing pub fn isEditing(self: *const AdvancedTableState) bool { return self.cell_edit.editing; } /// Insert text at cursor position pub fn insertText(self: *AdvancedTableState, text: []const u8) void { for (text) |c| { if (self.cell_edit.edit_len < table_core.MAX_EDIT_BUFFER_SIZE) { // Shift text after cursor var i = self.cell_edit.edit_len; while (i > self.cell_edit.edit_cursor) : (i -= 1) { self.cell_edit.edit_buffer[i] = self.cell_edit.edit_buffer[i - 1]; } self.cell_edit.edit_buffer[self.cell_edit.edit_cursor] = c; self.cell_edit.edit_len += 1; self.cell_edit.edit_cursor += 1; } } } /// Delete character before cursor (backspace) pub fn deleteBackward(self: *AdvancedTableState) void { if (self.cell_edit.edit_cursor > 0) { var i = self.cell_edit.edit_cursor - 1; while (i < self.cell_edit.edit_len - 1) : (i += 1) { self.cell_edit.edit_buffer[i] = self.cell_edit.edit_buffer[i + 1]; } self.cell_edit.edit_len -= 1; self.cell_edit.edit_cursor -= 1; } } /// Delete character at cursor (delete) pub fn deleteForward(self: *AdvancedTableState) void { if (self.cell_edit.edit_cursor < self.cell_edit.edit_len) { var i = self.cell_edit.edit_cursor; while (i < self.cell_edit.edit_len - 1) : (i += 1) { self.cell_edit.edit_buffer[i] = self.cell_edit.edit_buffer[i + 1]; } self.cell_edit.edit_len -= 1; } } /// Handle Escape key (revert or cancel) pub fn handleEditEscape(self: *AdvancedTableState) table_core.CellEditState.EscapeAction { const action = self.cell_edit.handleEscape(); if (action == .cancelled) { self.original_value = null; } return action; } // ========================================================================= // Snapshots (for Auto-CRUD) // ========================================================================= /// Capture snapshot of row for Auto-CRUD detection pub fn captureSnapshot(self: *AdvancedTableState, index: usize) !void { if (index >= self.rows.items.len) return; // Remove old snapshot if exists if (self.row_snapshots.fetchRemove(index)) |kv| { var old_row = kv.value; old_row.deinit(); } // Clone current row as snapshot const snapshot = try self.rows.items[index].clone(self.allocator); try self.row_snapshots.put(index, snapshot); } /// Get snapshot for row pub fn getSnapshot(self: *const AdvancedTableState, index: usize) ?*const Row { return self.row_snapshots.getPtr(index); } /// Clear snapshot for row pub fn clearSnapshot(self: *AdvancedTableState, index: usize) void { if (self.row_snapshots.fetchRemove(index)) |kv| { var old_row = kv.value; old_row.deinit(); } } // ========================================================================= // Sorting // ========================================================================= /// Toggle sort on column pub fn toggleSort(self: *AdvancedTableState, col: usize) SortDirection { if (self.sort_column == @as(i32, @intCast(col))) { // Same column - toggle direction self.sort_direction = self.sort_direction.toggle(); if (self.sort_direction == .none) { self.sort_column = -1; } } else { // Different column - start ascending self.sort_column = @intCast(col); self.sort_direction = .ascending; } return self.sort_direction; } /// Clear sort pub fn clearSort(self: *AdvancedTableState) void { self.sort_column = -1; self.sort_direction = .none; } /// Get sort info pub fn getSortInfo(self: *const AdvancedTableState) ?struct { column: usize, direction: SortDirection } { if (self.sort_column < 0 or self.sort_direction == .none) return null; return .{ .column = @intCast(self.sort_column), .direction = self.sort_direction, }; } // ========================================================================= // Navegación Tab Excel-style (con wrap) // Usa table_core para la lógica común (Norma #7 DRY) // ========================================================================= /// Re-exporta TabNavigateResult desde table_core para compatibilidad pub const TabNavigateResult = table_core.TabNavigateResult; /// Navega a siguiente celda (Tab) /// Si está en última columna, va a primera columna de siguiente fila. /// Si está en última fila, hace wrap a primera fila o retorna tab_out. pub fn tabToNextCell(self: *AdvancedTableState, num_cols: usize, wrap_to_start: bool) TabNavigateResult { const row_count = self.getRowCount(); const current_col: usize = if (self.selected_col >= 0) @intCast(self.selected_col) else 0; const current_row: usize = if (self.selected_row >= 0) @intCast(self.selected_row) else 0; // Usar función de table_core const pos = table_core.calculateNextCell(current_row, current_col, num_cols, row_count, wrap_to_start); if (pos.result == .navigated) { self.selected_row = @intCast(pos.row); self.selected_col = @intCast(pos.col); } return pos.result; } /// Navega a celda anterior (Shift+Tab) /// Si está en primera columna, va a última columna de fila anterior. /// Si está en primera fila, hace wrap a última fila o retorna tab_out. pub fn tabToPrevCell(self: *AdvancedTableState, num_cols: usize, wrap_to_end: bool) TabNavigateResult { const row_count = self.getRowCount(); const current_col: usize = if (self.selected_col >= 0) @intCast(self.selected_col) else 0; const current_row: usize = if (self.selected_row >= 0) @intCast(self.selected_row) else 0; // Usar función de table_core const pos = table_core.calculatePrevCell(current_row, current_col, num_cols, row_count, wrap_to_end); if (pos.result == .navigated) { self.selected_row = @intCast(pos.row); self.selected_col = @intCast(pos.col); } return pos.result; } // ========================================================================= // Internal Helpers // ========================================================================= const MAX_STATE_ENTRIES = 64; // Maximum entries we expect in state maps /// Shift row indices down (after insert) fn shiftRowIndicesDown(self: *AdvancedTableState, insert_index: usize) void { shiftMapIndicesDown(&self.dirty_rows, insert_index); shiftMapIndicesDown(&self.new_rows, insert_index); shiftMapIndicesDown(&self.deleted_rows, insert_index); shiftMapIndicesDown(&self.validation_errors, insert_index); } /// Shift row indices up (after delete) fn shiftRowIndicesUp(self: *AdvancedTableState, delete_index: usize) void { shiftMapIndicesUp(&self.dirty_rows, delete_index); shiftMapIndicesUp(&self.new_rows, delete_index); shiftMapIndicesUp(&self.deleted_rows, delete_index); shiftMapIndicesUp(&self.validation_errors, delete_index); } }; // ============================================================================= // Map Shifting Helpers (standalone functions to avoid allocator issues) // ============================================================================= const Entry = struct { key: usize, value: bool }; fn shiftMapIndicesDown(map: *std.AutoHashMap(usize, bool), insert_index: usize) void { // Use bounded array to avoid allocation var entries: [AdvancedTableState.MAX_STATE_ENTRIES]Entry = undefined; var count: usize = 0; // Collect entries that need shifting var iter = map.iterator(); while (iter.next()) |entry| { if (count >= AdvancedTableState.MAX_STATE_ENTRIES) break; if (entry.key_ptr.* >= insert_index) { entries[count] = .{ .key = entry.key_ptr.* + 1, .value = entry.value_ptr.* }; } else { entries[count] = .{ .key = entry.key_ptr.*, .value = entry.value_ptr.* }; } count += 1; } // Rebuild map map.clearRetainingCapacity(); for (entries[0..count]) |e| { map.put(e.key, e.value) catch {}; } } fn shiftMapIndicesUp(map: *std.AutoHashMap(usize, bool), delete_index: usize) void { // Use bounded array to avoid allocation var entries: [AdvancedTableState.MAX_STATE_ENTRIES]Entry = undefined; var count: usize = 0; // Collect entries, skipping deleted and shifting down var iter = map.iterator(); while (iter.next()) |entry| { if (count >= AdvancedTableState.MAX_STATE_ENTRIES) break; if (entry.key_ptr.* == delete_index) { continue; // Skip deleted index } else if (entry.key_ptr.* > delete_index) { entries[count] = .{ .key = entry.key_ptr.* - 1, .value = entry.value_ptr.* }; } else { entries[count] = .{ .key = entry.key_ptr.*, .value = entry.value_ptr.* }; } count += 1; } // Rebuild map map.clearRetainingCapacity(); for (entries[0..count]) |e| { map.put(e.key, e.value) catch {}; } } // ============================================================================= // Result Type // ============================================================================= /// Result returned from advancedTable() call pub const AdvancedTableResult = struct { // Selection selection_changed: bool = false, selected_row: ?usize = null, selected_col: ?usize = null, // Editing edit_started: bool = false, edit_ended: bool = false, cell_edited: bool = false, // Sorting sort_changed: bool = false, sort_column: ?usize = null, sort_direction: SortDirection = .none, // Row operations row_inserted: bool = false, row_deleted: bool = false, row_moved: bool = false, // Auto-CRUD crud_action: ?CRUDAction = null, crud_success: bool = true, // Lookup (Phase 7) lookup_success: ?bool = null, // null = no lookup, true = found, false = not found // Focus clicked: bool = false, // ========================================================================= // Edición CRUD Excel-style (simétrico con VirtualAdvancedTableResult) // ========================================================================= /// Una fila fue completada (el usuario cambió de fila, tenía cambios pendientes) row_committed: bool = false, /// ID de la fila que se hizo commit (índice en AdvancedTable) 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) row_changes: [table_core.MAX_PENDING_COLUMNS]table_core.PendingCellChange = undefined, /// Número de cambios en row_changes row_changes_count: usize = 0, /// Tab presionado para salir del widget tab_out: bool = false, /// Shift estaba presionado con Tab tab_shift: bool = false, /// Obtiene los cambios como slice pub fn getRowChanges(self: *const AdvancedTableResult) []const table_core.PendingCellChange { return self.row_changes[0..self.row_changes_count]; } }; // ============================================================================= // Tests // ============================================================================= test "AdvancedTableState init/deinit" { var state = AdvancedTableState.init(std.testing.allocator); defer state.deinit(); try std.testing.expectEqual(@as(usize, 0), state.getRowCount()); } test "AdvancedTableState selection" { var state = AdvancedTableState.init(std.testing.allocator); defer state.deinit(); try std.testing.expect(state.getSelectedCell() == null); state.selectCell(5, 3); const cell = state.getSelectedCell().?; try std.testing.expectEqual(@as(usize, 5), cell.row); try std.testing.expectEqual(@as(usize, 3), cell.col); state.clearSelection(); try std.testing.expect(state.getSelectedCell() == null); } test "AdvancedTableState row states" { var state = AdvancedTableState.init(std.testing.allocator); defer state.deinit(); try std.testing.expectEqual(RowState.normal, state.getRowState(0)); state.markDirty(0); try std.testing.expectEqual(RowState.modified, state.getRowState(0)); state.markNew(1); try std.testing.expectEqual(RowState.new, state.getRowState(1)); state.markDeleted(2); try std.testing.expectEqual(RowState.deleted, state.getRowState(2)); state.clearRowState(0); try std.testing.expectEqual(RowState.normal, state.getRowState(0)); } test "AdvancedTableState editing" { var state = AdvancedTableState.init(std.testing.allocator); defer state.deinit(); try std.testing.expect(!state.isEditing()); state.startEditing("Hello"); try std.testing.expect(state.isEditing()); try std.testing.expectEqualStrings("Hello", state.getEditText()); state.insertText(" World"); try std.testing.expectEqualStrings("Hello World", state.getEditText()); state.deleteBackward(); try std.testing.expectEqualStrings("Hello Worl", state.getEditText()); state.stopEditing(); try std.testing.expect(!state.isEditing()); } test "AdvancedTableState sorting" { var state = AdvancedTableState.init(std.testing.allocator); defer state.deinit(); try std.testing.expect(state.getSortInfo() == null); // First click - ascending const dir1 = state.toggleSort(2); try std.testing.expectEqual(SortDirection.ascending, dir1); try std.testing.expectEqual(@as(usize, 2), state.getSortInfo().?.column); // Second click - descending const dir2 = state.toggleSort(2); try std.testing.expectEqual(SortDirection.descending, dir2); // Third click - none const dir3 = state.toggleSort(2); try std.testing.expectEqual(SortDirection.none, dir3); try std.testing.expect(state.getSortInfo() == null); } test "AdvancedTableState multi-row selection" { var state = AdvancedTableState.init(std.testing.allocator); defer state.deinit(); // Initially no rows selected try std.testing.expect(!state.isRowSelected(0)); try std.testing.expect(!state.isRowSelected(5)); // Add rows to selection state.addRowToSelection(0); state.addRowToSelection(5); state.addRowToSelection(10); try std.testing.expect(state.isRowSelected(0)); try std.testing.expect(state.isRowSelected(5)); try std.testing.expect(state.isRowSelected(10)); try std.testing.expect(!state.isRowSelected(3)); try std.testing.expectEqual(@as(usize, 3), state.getSelectedRowCount()); // Toggle selection state.toggleRowSelection(5); try std.testing.expect(!state.isRowSelected(5)); try std.testing.expectEqual(@as(usize, 2), state.getSelectedRowCount()); // Clear selection state.clearRowSelection(); try std.testing.expectEqual(@as(usize, 0), state.getSelectedRowCount()); } test "AdvancedTableState select row range" { var state = AdvancedTableState.init(std.testing.allocator); defer state.deinit(); // Select range 3 to 7 state.selectRowRange(3, 7); try std.testing.expect(!state.isRowSelected(2)); try std.testing.expect(state.isRowSelected(3)); try std.testing.expect(state.isRowSelected(5)); try std.testing.expect(state.isRowSelected(7)); try std.testing.expect(!state.isRowSelected(8)); try std.testing.expectEqual(@as(usize, 5), state.getSelectedRowCount()); } test "AdvancedTableState incremental search" { var state = AdvancedTableState.init(std.testing.allocator); defer state.deinit(); // Add characters var term = state.addSearchChar('a', 1000); try std.testing.expectEqualStrings("a", term); term = state.addSearchChar('b', 1100); try std.testing.expectEqualStrings("ab", term); term = state.addSearchChar('c', 1200); try std.testing.expectEqualStrings("abc", term); // After timeout, buffer resets term = state.addSearchChar('x', 3000); // > 1000ms later try std.testing.expectEqualStrings("x", term); // Clear search state.clearSearch(); try std.testing.expectEqualStrings("", state.getSearchTerm()); } test "AdvancedTableState cell validation" { var state = AdvancedTableState.init(std.testing.allocator); defer state.deinit(); // Initially no errors try std.testing.expect(!state.hasCellError(0, 0)); try std.testing.expect(!state.hasAnyCellErrors()); // Add error state.addCellError(2, 3, "Invalid format"); try std.testing.expect(state.hasCellError(2, 3)); try std.testing.expect(state.hasAnyCellErrors()); try std.testing.expectEqualStrings("Invalid format", state.getLastValidationMessage()); // Add another error state.addCellError(5, 1, "Required field"); try std.testing.expect(state.hasCellError(5, 1)); // Clear specific error state.clearCellError(2, 3); try std.testing.expect(!state.hasCellError(2, 3)); try std.testing.expect(state.hasCellError(5, 1)); // Clear all state.clearAllCellErrors(); try std.testing.expect(!state.hasAnyCellErrors()); }