zcatgui/src/widgets/advanced_table/state.zig
reugenio 7d4d4190b8 refactor(advanced_table): Extraer result.zig y state_helpers.zig de state.zig
- Extraer AdvancedTableResult a result.zig (73 LOC)
- Extraer funciones de map shifting a state_helpers.zig (141 LOC con tests)
- Reducir state.zig de 1235 LOC a 1123 LOC
- Añadir swapMapEntries para operaciones de move row
- Mantener re-exports para compatibilidad
2025-12-29 10:23:41 +01:00

1123 lines
41 KiB
Zig

//! 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");
const state_helpers = @import("state_helpers.zig");
const result_mod = @import("result.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;
// Re-export AdvancedTableResult desde result.zig
pub const AdvancedTableResult = result_mod.AdvancedTableResult;
// =============================================================================
// 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 (delegados a state_helpers.zig)
// =========================================================================
/// Shift row indices down (after insert)
fn shiftRowIndicesDown(self: *AdvancedTableState, insert_index: usize) void {
state_helpers.shiftMapIndicesDown(&self.dirty_rows, insert_index);
state_helpers.shiftMapIndicesDown(&self.new_rows, insert_index);
state_helpers.shiftMapIndicesDown(&self.deleted_rows, insert_index);
state_helpers.shiftMapIndicesDown(&self.validation_errors, insert_index);
}
/// Shift row indices up (after delete)
fn shiftRowIndicesUp(self: *AdvancedTableState, delete_index: usize) void {
state_helpers.shiftMapIndicesUp(&self.dirty_rows, delete_index);
state_helpers.shiftMapIndicesUp(&self.new_rows, delete_index);
state_helpers.shiftMapIndicesUp(&self.deleted_rows, delete_index);
state_helpers.shiftMapIndicesUp(&self.validation_errors, delete_index);
}
/// Swap state map entries between two row indices
fn swapRowStates(self: *AdvancedTableState, idx_a: usize, idx_b: usize) void {
state_helpers.swapMapEntries(&self.dirty_rows, idx_a, idx_b);
state_helpers.swapMapEntries(&self.new_rows, idx_a, idx_b);
state_helpers.swapMapEntries(&self.deleted_rows, idx_a, idx_b);
state_helpers.swapMapEntries(&self.validation_errors, idx_a, idx_b);
}
};
// =============================================================================
// 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());
}