BREAKING: table_core.zig ahora es carpeta table_core/ Módulos creados: - types.zig: Enums, structs, constantes - state.zig: CellEditState, NavigationState - datasource.zig: TableDataSource interface - row_buffer.zig: Excel-style commit logic - keyboard.zig: Manejo de teclado - navigation.zig: Tab, sorting, double-click - rendering.zig: Funciones de dibujo - scrollbars.zig: Scrollbars vertical/horizontal - utils.zig: blendColor, startsWithIgnoreCase - table_core.zig: Hub de re-exports Beneficios: - 2115 LOC → 10 archivos de ~100-270 LOC - Debugging focalizado por módulo - Imports actualizados en 7 archivos de widgets
1235 lines
45 KiB
Zig
1235 lines
45 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");
|
|
|
|
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());
|
|
}
|