- RowEditBuffer: acumula cambios de fila antes de commit - checkRowChangeAndCommit(): detecta cambio fila + commit automático - buildCommitInfo(), isGhostRow(), NEW_ROW_ID - VirtualAdvancedTableState: row_edit_buffer field - VirtualAdvancedTableResult: row_committed, row_changes[], etc. - Comportamiento: Tab entre celdas acumula, cambiar fila hace commit
864 lines
30 KiB
Zig
864 lines
30 KiB
Zig
//! Estado del VirtualAdvancedTable
|
|
//!
|
|
//! Mantiene el estado de navegación, selección y caché del widget.
|
|
|
|
const std = @import("std");
|
|
const types = @import("types.zig");
|
|
const table_core = @import("../table_core.zig");
|
|
const RowData = types.RowData;
|
|
const CountInfo = types.CountInfo;
|
|
const SortDirection = types.SortDirection;
|
|
const CellId = types.CellId;
|
|
const CellGeometry = types.CellGeometry;
|
|
|
|
/// Estado del widget VirtualAdvancedTable
|
|
pub const VirtualAdvancedTableState = struct {
|
|
// =========================================================================
|
|
// Selección
|
|
// =========================================================================
|
|
|
|
/// ID del registro seleccionado (NO índice)
|
|
/// Usamos ID porque el índice cambia al scroll/ordenar/filtrar
|
|
selected_id: ?i64 = null,
|
|
|
|
/// Índice de la fila con hover
|
|
hover_index: ?usize = null,
|
|
|
|
/// Columna activa (para edición)
|
|
/// Cuando el usuario navega con flechas o hace click, se actualiza
|
|
active_col: usize = 0,
|
|
|
|
// =========================================================================
|
|
// Double-click detection
|
|
// =========================================================================
|
|
|
|
/// Time of last click (ms)
|
|
last_click_time: u64 = 0,
|
|
|
|
/// Row of last click (global index)
|
|
last_click_row: i64 = -1,
|
|
|
|
/// Column of last click
|
|
last_click_col: i32 = -1,
|
|
|
|
/// Double-click threshold in ms
|
|
double_click_threshold_ms: u64 = 400,
|
|
|
|
// =========================================================================
|
|
// Scroll y ventana
|
|
// =========================================================================
|
|
|
|
/// Offset actual del scroll vertical (en filas, no pixels)
|
|
scroll_offset: usize = 0,
|
|
|
|
/// Offset del scroll en pixels (para smooth scroll vertical)
|
|
scroll_offset_pixels: i32 = 0,
|
|
|
|
/// Offset del scroll horizontal (en pixels)
|
|
scroll_offset_x: i32 = 0,
|
|
|
|
/// Ventana de datos actual (propiedad del DataProvider)
|
|
/// NO liberar - el provider lo gestiona
|
|
current_window: []const RowData = &.{},
|
|
|
|
/// Offset donde empieza la ventana actual
|
|
window_start: usize = 0,
|
|
|
|
/// Forzar refetch en próximo frame (después de edición)
|
|
needs_window_refresh: bool = false,
|
|
|
|
// =========================================================================
|
|
// Estado de carga
|
|
// =========================================================================
|
|
|
|
/// Indica si hay una carga en progreso
|
|
is_loading: bool = false,
|
|
|
|
/// Conteo total de registros
|
|
total_count: CountInfo = .{},
|
|
|
|
/// Conteo después de aplicar filtro
|
|
filtered_count: CountInfo = .{},
|
|
|
|
// =========================================================================
|
|
// Filtro y ordenación
|
|
// =========================================================================
|
|
|
|
/// Buffer para texto de filtro
|
|
filter_buf: [256]u8 = [_]u8{0} ** 256,
|
|
filter_len: usize = 0,
|
|
|
|
/// Columna de ordenación actual
|
|
sort_column: ?[]const u8 = null,
|
|
|
|
/// Dirección de ordenación
|
|
sort_direction: SortDirection = .none,
|
|
|
|
// =========================================================================
|
|
// FilterBar state
|
|
// =========================================================================
|
|
|
|
/// Chips activos (bitset, máximo 16 chips)
|
|
active_chips: u16 = 0,
|
|
|
|
/// Flag: el filtro de texto cambió este frame
|
|
filter_text_changed: bool = false,
|
|
|
|
/// Flag: un chip cambió este frame
|
|
chip_changed: bool = false,
|
|
|
|
/// ID del chip que cambió (índice)
|
|
changed_chip_index: ?u4 = null,
|
|
|
|
/// Timestamp del último cambio de filtro (para debounce)
|
|
last_filter_change_ms: i64 = 0,
|
|
|
|
/// Flag: el campo de búsqueda tiene focus
|
|
search_has_focus: bool = false,
|
|
|
|
/// Cursor del campo de búsqueda (posición en bytes)
|
|
search_cursor: usize = 0,
|
|
|
|
/// Selección del campo de búsqueda (inicio)
|
|
search_selection_start: ?usize = null,
|
|
|
|
// =========================================================================
|
|
// Flags internos
|
|
// =========================================================================
|
|
|
|
/// Indica si el widget tiene focus
|
|
has_focus: bool = false,
|
|
|
|
/// Indica si hubo cambio de selección este frame
|
|
selection_changed: bool = false,
|
|
|
|
/// Indica si hubo doble click este frame
|
|
double_clicked: bool = false,
|
|
|
|
/// Frame counter para animaciones
|
|
frame_count: u32 = 0,
|
|
|
|
// =========================================================================
|
|
// Buffers persistentes para texto formateado (evitar stack buffer escape)
|
|
// =========================================================================
|
|
// IMPORTANTE: Los buffers de stack pasados a ctx.pushCommand() se invalidan
|
|
// cuando la función retorna. El render diferido usaría memoria corrupta.
|
|
footer_display_buf: [96]u8 = undefined,
|
|
footer_display_len: usize = 0,
|
|
|
|
// =========================================================================
|
|
// Estado de edición CRUD Excel-style
|
|
// =========================================================================
|
|
|
|
/// Celda actualmente en edición (null = no editando)
|
|
editing_cell: ?CellId = null,
|
|
|
|
/// Valor original de la celda (para Escape revertir)
|
|
original_value: [256]u8 = undefined,
|
|
original_value_len: usize = 0,
|
|
|
|
/// Contador de Escapes (1 = revertir celda, 2 = descartar fila)
|
|
escape_count: u8 = 0,
|
|
|
|
/// Fila actual tiene cambios sin guardar en BD
|
|
row_dirty: bool = false,
|
|
|
|
/// Última fila editada (para detectar cambio de fila)
|
|
last_edited_row: ?usize = null,
|
|
|
|
/// Buffer de edición (texto actual en el editor)
|
|
edit_buffer: [256]u8 = undefined,
|
|
edit_buffer_len: usize = 0,
|
|
edit_cursor: usize = 0,
|
|
|
|
/// Tiempo de última edición (para parpadeo cursor)
|
|
last_edit_time_ms: u64 = 0,
|
|
|
|
/// Flag: celda requiere commit al terminar edición
|
|
cell_value_changed: bool = false,
|
|
|
|
// =========================================================================
|
|
// Buffer de cambios de fila (commit al abandonar fila, estilo Excel)
|
|
// =========================================================================
|
|
|
|
/// Buffer para acumular cambios de la fila actual antes de commit
|
|
row_edit_buffer: table_core.RowEditBuffer = .{},
|
|
|
|
const Self = @This();
|
|
|
|
// =========================================================================
|
|
// Métodos de consulta
|
|
// =========================================================================
|
|
|
|
/// Obtiene el texto del filtro actual
|
|
pub fn getFilter(self: *const Self) []const u8 {
|
|
return self.filter_buf[0..self.filter_len];
|
|
}
|
|
|
|
/// Verifica si un índice global está en la ventana actual
|
|
pub fn isInWindow(self: *const Self, global_index: usize) bool {
|
|
return global_index >= self.window_start and
|
|
global_index < self.window_start + self.current_window.len;
|
|
}
|
|
|
|
/// Convierte índice global a índice de ventana
|
|
pub fn globalToWindowIndex(self: *const Self, global_index: usize) ?usize {
|
|
if (!self.isInWindow(global_index)) return null;
|
|
return global_index - self.window_start;
|
|
}
|
|
|
|
/// Convierte índice de ventana a índice global
|
|
pub fn windowToGlobalIndex(self: *const Self, window_index: usize) usize {
|
|
return self.window_start + window_index;
|
|
}
|
|
|
|
/// Busca el ID seleccionado en la ventana actual
|
|
/// Retorna el índice de ventana o null si no está visible
|
|
pub fn findSelectedInWindow(self: *const Self) ?usize {
|
|
if (self.selected_id) |id| {
|
|
for (self.current_window, 0..) |row, i| {
|
|
if (row.id == id) return i;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Obtiene el conteo a mostrar (filtrado si hay filtro, total si no)
|
|
pub fn getDisplayCount(self: *const Self) CountInfo {
|
|
if (self.filter_len > 0) {
|
|
return self.filtered_count;
|
|
}
|
|
return self.total_count;
|
|
}
|
|
|
|
// =========================================================================
|
|
// Métodos de modificación
|
|
// =========================================================================
|
|
|
|
/// Establece el texto del filtro
|
|
pub fn setFilter(self: *Self, text: []const u8) void {
|
|
const len = @min(text.len, self.filter_buf.len);
|
|
@memcpy(self.filter_buf[0..len], text[0..len]);
|
|
self.filter_len = len;
|
|
}
|
|
|
|
/// Limpia el filtro
|
|
pub fn clearFilter(self: *Self) void {
|
|
self.filter_len = 0;
|
|
}
|
|
|
|
/// Establece ordenación
|
|
pub fn setSort(self: *Self, column: []const u8, direction: SortDirection) void {
|
|
self.sort_column = column;
|
|
self.sort_direction = direction;
|
|
}
|
|
|
|
/// Alterna ordenación de una columna
|
|
pub fn toggleSort(self: *Self, column: []const u8) void {
|
|
if (self.sort_column) |current| {
|
|
if (std.mem.eql(u8, current, column)) {
|
|
// Misma columna: ciclar dirección
|
|
self.sort_direction = self.sort_direction.toggle();
|
|
if (self.sort_direction == .none) {
|
|
self.sort_column = null;
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
// Nueva columna: empezar ascendente
|
|
self.sort_column = column;
|
|
self.sort_direction = .ascending;
|
|
}
|
|
|
|
/// Selecciona un registro por ID
|
|
pub fn selectById(self: *Self, id: ?i64) void {
|
|
if (self.selected_id != id) {
|
|
self.selected_id = id;
|
|
self.selection_changed = true;
|
|
}
|
|
}
|
|
|
|
/// Selecciona el registro en un índice de ventana
|
|
pub fn selectByWindowIndex(self: *Self, window_index: usize) void {
|
|
if (window_index < self.current_window.len) {
|
|
self.selectById(self.current_window[window_index].id);
|
|
}
|
|
}
|
|
|
|
/// Limpia la selección
|
|
pub fn clearSelection(self: *Self) void {
|
|
self.selectById(null);
|
|
}
|
|
|
|
/// Resetea flags de frame (llamar al inicio de cada frame)
|
|
pub fn resetFrameFlags(self: *Self) void {
|
|
self.selection_changed = false;
|
|
self.double_clicked = false;
|
|
self.filter_text_changed = false;
|
|
self.chip_changed = false;
|
|
self.changed_chip_index = null;
|
|
self.frame_count +%= 1;
|
|
}
|
|
|
|
// =========================================================================
|
|
// FilterBar methods
|
|
// =========================================================================
|
|
|
|
/// Verifica si un chip está activo
|
|
pub fn isChipActive(self: *const Self, chip_index: u4) bool {
|
|
return (self.active_chips & (@as(u16, 1) << chip_index)) != 0;
|
|
}
|
|
|
|
/// Activa un chip
|
|
pub fn activateChip(self: *Self, chip_index: u4, mode: types.ChipSelectMode) void {
|
|
switch (mode) {
|
|
.single => {
|
|
// Desactivar todos y activar solo este
|
|
self.active_chips = @as(u16, 1) << chip_index;
|
|
},
|
|
.multi => {
|
|
// Toggle del chip
|
|
self.active_chips ^= @as(u16, 1) << chip_index;
|
|
},
|
|
}
|
|
self.chip_changed = true;
|
|
self.changed_chip_index = chip_index;
|
|
}
|
|
|
|
/// Desactiva un chip (solo en modo multi)
|
|
pub fn deactivateChip(self: *Self, chip_index: u4) void {
|
|
self.active_chips &= ~(@as(u16, 1) << chip_index);
|
|
self.chip_changed = true;
|
|
self.changed_chip_index = chip_index;
|
|
}
|
|
|
|
/// Inicializa chips por defecto según configuración
|
|
pub fn initDefaultChips(self: *Self, chips: []const types.FilterChipDef) void {
|
|
self.active_chips = 0;
|
|
for (chips, 0..) |chip, i| {
|
|
if (chip.is_default and i < 16) {
|
|
self.active_chips |= @as(u16, 1) << @intCast(i);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Establece el texto del filtro
|
|
pub fn setFilterText(self: *Self, text: []const u8) void {
|
|
const len = @min(text.len, self.filter_buf.len);
|
|
@memcpy(self.filter_buf[0..len], text[0..len]);
|
|
self.filter_len = len;
|
|
self.filter_text_changed = true;
|
|
}
|
|
|
|
/// Limpia el filtro de texto
|
|
pub fn clearFilterText(self: *Self) void {
|
|
self.filter_len = 0;
|
|
self.filter_text_changed = true;
|
|
}
|
|
|
|
// =========================================================================
|
|
// Navegación
|
|
// =========================================================================
|
|
|
|
/// Mueve la selección hacia arriba
|
|
pub fn moveUp(self: *Self) void {
|
|
const window_offset = self.scroll_offset -| self.window_start;
|
|
|
|
if (self.findSelectedInWindow()) |window_idx| {
|
|
// Calcular posición en pantalla actual
|
|
const screen_pos = window_idx -| window_offset;
|
|
|
|
if (window_idx > 0) {
|
|
// Hay item anterior en buffer, seleccionarlo
|
|
self.selectByWindowIndex(window_idx - 1);
|
|
|
|
// Si estábamos en la primera fila visible, hacer scroll
|
|
if (screen_pos == 0 and self.scroll_offset > 0) {
|
|
self.scroll_offset -= 1;
|
|
}
|
|
} else {
|
|
// Estamos en el inicio del buffer
|
|
// Scroll up para cargar más datos (si es posible)
|
|
if (self.scroll_offset > 0) {
|
|
self.scroll_offset -= 1;
|
|
}
|
|
}
|
|
} else if (self.current_window.len > 0) {
|
|
// Sin selección: seleccionar primera fila visible
|
|
self.selectByWindowIndex(window_offset);
|
|
}
|
|
}
|
|
|
|
/// Mueve la selección hacia abajo
|
|
pub fn moveDown(self: *Self, visible_rows: usize) void {
|
|
if (self.findSelectedInWindow()) |window_idx| {
|
|
if (window_idx + 1 < self.current_window.len) {
|
|
self.selectByWindowIndex(window_idx + 1);
|
|
|
|
// Calcular posición en pantalla de la nueva selección
|
|
const window_offset = self.scroll_offset -| self.window_start;
|
|
const new_screen_pos = (window_idx + 1) -| window_offset;
|
|
|
|
// Scroll cuando la nueva posición llega a la ÚLTIMA fila visible
|
|
// (visible_rows - 1 es el índice de la última fila, 0-indexed)
|
|
if (new_screen_pos >= visible_rows) {
|
|
self.scroll_offset += 1;
|
|
}
|
|
}
|
|
} else if (self.current_window.len > 0) {
|
|
// Sin selección: seleccionar primera fila visible
|
|
self.selectByWindowIndex(0);
|
|
}
|
|
}
|
|
|
|
/// Mueve página arriba
|
|
pub fn pageUp(self: *Self, visible_rows: usize) void {
|
|
if (self.scroll_offset >= visible_rows) {
|
|
self.scroll_offset -= visible_rows;
|
|
} else {
|
|
self.scroll_offset = 0;
|
|
}
|
|
}
|
|
|
|
/// Mueve página abajo
|
|
pub fn pageDown(self: *Self, visible_rows: usize, total_rows: usize) void {
|
|
const max_offset = if (total_rows > visible_rows) total_rows - visible_rows else 0;
|
|
self.scroll_offset = @min(self.scroll_offset + visible_rows, max_offset);
|
|
}
|
|
|
|
/// Va al inicio
|
|
pub fn goToStart(self: *Self) void {
|
|
self.scroll_offset = 0;
|
|
if (self.current_window.len > 0) {
|
|
self.selectByWindowIndex(0);
|
|
}
|
|
}
|
|
|
|
/// Va al final
|
|
pub fn goToEnd(self: *Self, visible_rows: usize, total_rows: usize) void {
|
|
if (total_rows > visible_rows) {
|
|
self.scroll_offset = total_rows - visible_rows;
|
|
}
|
|
if (self.current_window.len > 0) {
|
|
self.selectByWindowIndex(self.current_window.len - 1);
|
|
}
|
|
}
|
|
|
|
/// Invalida la ventana de datos para forzar refetch en próximo frame.
|
|
/// Llamar después de modificar datos en el DataProvider.
|
|
pub fn invalidateWindow(self: *Self) void {
|
|
self.needs_window_refresh = true;
|
|
}
|
|
|
|
// =========================================================================
|
|
// Navegación horizontal
|
|
// =========================================================================
|
|
|
|
/// Scroll horizontal a la izquierda
|
|
pub fn scrollLeft(self: *Self, amount: i32) void {
|
|
self.scroll_offset_x = @max(0, self.scroll_offset_x - amount);
|
|
}
|
|
|
|
/// Scroll horizontal a la derecha
|
|
pub fn scrollRight(self: *Self, amount: i32, max_scroll: i32) void {
|
|
self.scroll_offset_x = @min(max_scroll, self.scroll_offset_x + amount);
|
|
}
|
|
|
|
/// Va al inicio horizontal
|
|
pub fn goToStartX(self: *Self) void {
|
|
self.scroll_offset_x = 0;
|
|
}
|
|
|
|
/// Va al final horizontal
|
|
pub fn goToEndX(self: *Self, max_scroll: i32) void {
|
|
self.scroll_offset_x = max_scroll;
|
|
}
|
|
|
|
/// Mueve a columna anterior
|
|
pub fn moveToPrevCol(self: *Self) void {
|
|
if (self.active_col > 0) {
|
|
self.active_col -= 1;
|
|
}
|
|
}
|
|
|
|
/// Mueve a columna siguiente
|
|
pub fn moveToNextCol(self: *Self, max_cols: usize) void {
|
|
if (self.active_col + 1 < max_cols) {
|
|
self.active_col += 1;
|
|
}
|
|
}
|
|
|
|
/// Va a primera columna
|
|
pub fn goToFirstCol(self: *Self) void {
|
|
self.active_col = 0;
|
|
}
|
|
|
|
/// Va a última columna
|
|
pub fn goToLastCol(self: *Self, max_cols: usize) void {
|
|
if (max_cols > 0) {
|
|
self.active_col = max_cols - 1;
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// 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: *Self, num_cols: usize, visible_rows: usize, wrap_to_start: bool) TabNavigateResult {
|
|
// Obtener fila actual
|
|
const current_row = self.getSelectedRow() orelse 0;
|
|
const total_rows = self.current_window.len + self.window_start;
|
|
|
|
// Usar función de table_core
|
|
const pos = table_core.calculateNextCell(current_row, self.active_col, num_cols, total_rows, wrap_to_start);
|
|
|
|
if (pos.result == .navigated) {
|
|
self.active_col = pos.col;
|
|
// Navegar a la nueva fila si cambió
|
|
if (pos.row != current_row) {
|
|
if (pos.row == 0) {
|
|
self.goToStart();
|
|
} else {
|
|
self.moveDown(visible_rows);
|
|
}
|
|
}
|
|
}
|
|
|
|
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: *Self, num_cols: usize, visible_rows: usize, total_rows: usize, wrap_to_end: bool) TabNavigateResult {
|
|
// Obtener fila actual
|
|
const current_row = self.getSelectedRow() orelse 0;
|
|
|
|
// Usar función de table_core
|
|
const pos = table_core.calculatePrevCell(current_row, self.active_col, num_cols, total_rows, wrap_to_end);
|
|
|
|
if (pos.result == .navigated) {
|
|
self.active_col = pos.col;
|
|
// Navegar a la nueva fila si cambió
|
|
if (pos.row != current_row) {
|
|
if (pos.row == total_rows - 1) {
|
|
self.goToEnd(visible_rows, total_rows);
|
|
} else {
|
|
self.moveUp();
|
|
}
|
|
}
|
|
}
|
|
|
|
return pos.result;
|
|
}
|
|
|
|
/// Obtiene la fila global seleccionada
|
|
pub fn getSelectedRow(self: *const Self) ?usize {
|
|
if (self.selected_id == null) return null;
|
|
|
|
// Buscar en la ventana actual
|
|
if (self.findSelectedInWindow()) |window_idx| {
|
|
return self.windowToGlobalIndex(window_idx);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Obtiene la celda activa (fila seleccionada + columna activa)
|
|
pub fn getActiveCell(self: *const Self) ?CellId {
|
|
if (self.getSelectedRow()) |row| {
|
|
return .{ .row = row, .col = self.active_col };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// =========================================================================
|
|
// Métodos de edición CRUD Excel-style
|
|
// =========================================================================
|
|
|
|
/// Verifica si hay una celda en edición
|
|
pub fn isEditing(self: *const Self) bool {
|
|
return self.editing_cell != null;
|
|
}
|
|
|
|
/// Inicia edición de una celda
|
|
/// initial_char: si viene de tecla alfanumérica, el caracter inicial (null = mostrar valor actual)
|
|
pub fn startEditing(self: *Self, cell: CellId, current_value: []const u8, initial_char: ?u8, current_time_ms: u64) void {
|
|
// Guardar valor original (para Escape)
|
|
const len = @min(current_value.len, self.original_value.len);
|
|
@memcpy(self.original_value[0..len], current_value[0..len]);
|
|
self.original_value_len = len;
|
|
|
|
// Inicializar buffer de edición
|
|
if (initial_char) |c| {
|
|
// Tecla alfanumérica: empezar con ese caracter
|
|
self.edit_buffer[0] = c;
|
|
self.edit_buffer_len = 1;
|
|
self.edit_cursor = 1;
|
|
} else {
|
|
// Doble-click/Space: mostrar valor actual
|
|
@memcpy(self.edit_buffer[0..len], current_value[0..len]);
|
|
self.edit_buffer_len = len;
|
|
self.edit_cursor = len;
|
|
}
|
|
|
|
self.editing_cell = cell;
|
|
self.escape_count = 0;
|
|
self.last_edit_time_ms = current_time_ms;
|
|
self.cell_value_changed = false;
|
|
}
|
|
|
|
/// Obtiene el texto actual del editor
|
|
pub fn getEditText(self: *const Self) []const u8 {
|
|
return self.edit_buffer[0..self.edit_buffer_len];
|
|
}
|
|
|
|
/// Establece el texto del editor
|
|
pub fn setEditText(self: *Self, text: []const u8) void {
|
|
const len = @min(text.len, self.edit_buffer.len);
|
|
@memcpy(self.edit_buffer[0..len], text[0..len]);
|
|
self.edit_buffer_len = len;
|
|
self.edit_cursor = len;
|
|
}
|
|
|
|
/// Obtiene el valor original (antes de editar)
|
|
pub fn getOriginalValue(self: *const Self) []const u8 {
|
|
return self.original_value[0..self.original_value_len];
|
|
}
|
|
|
|
/// Verifica si el valor ha cambiado respecto al original
|
|
pub fn hasValueChanged(self: *const Self) bool {
|
|
const current = self.getEditText();
|
|
const original = self.getOriginalValue();
|
|
return !std.mem.eql(u8, current, original);
|
|
}
|
|
|
|
/// Finaliza edición guardando cambios (retorna true si hubo cambios)
|
|
pub fn commitEdit(self: *Self) bool {
|
|
if (self.editing_cell == null) return false;
|
|
|
|
const changed = self.hasValueChanged();
|
|
if (changed) {
|
|
self.row_dirty = true;
|
|
self.cell_value_changed = true;
|
|
// Solo actualizar última fila editada si hubo cambios reales
|
|
self.last_edited_row = self.editing_cell.?.row;
|
|
}
|
|
|
|
self.editing_cell = null;
|
|
self.escape_count = 0;
|
|
return changed;
|
|
}
|
|
|
|
/// Finaliza edición descartando cambios
|
|
pub fn cancelEdit(self: *Self) void {
|
|
self.editing_cell = null;
|
|
self.escape_count = 0;
|
|
self.cell_value_changed = false;
|
|
}
|
|
|
|
/// Revierte el texto de la celda al valor original (Escape 1)
|
|
pub fn revertCellText(self: *Self) void {
|
|
const original = self.getOriginalValue();
|
|
@memcpy(self.edit_buffer[0..original.len], original);
|
|
self.edit_buffer_len = original.len;
|
|
self.edit_cursor = original.len;
|
|
}
|
|
|
|
/// Maneja la tecla Escape (retorna acción a tomar)
|
|
pub const EscapeAction = enum {
|
|
/// Texto revertido, mantener edición
|
|
reverted,
|
|
/// Descartar cambios de fila
|
|
discard_row,
|
|
/// No estaba editando
|
|
none,
|
|
};
|
|
|
|
pub fn handleEscape(self: *Self) EscapeAction {
|
|
if (self.editing_cell == null) return .none;
|
|
|
|
self.escape_count += 1;
|
|
|
|
if (self.escape_count == 1) {
|
|
// Escape 1: Revertir texto a valor original
|
|
self.revertCellText();
|
|
return .reverted;
|
|
} else {
|
|
// Escape 2+: Descartar cambios de fila
|
|
self.cancelEdit();
|
|
self.row_dirty = false;
|
|
return .discard_row;
|
|
}
|
|
}
|
|
|
|
/// Verifica si cambió de fila (para auto-save)
|
|
pub fn isRowChange(self: *const Self, new_row: usize) bool {
|
|
if (self.last_edited_row) |last| {
|
|
return last != new_row;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Marca la fila como guardada (limpia dirty flag)
|
|
pub fn markRowSaved(self: *Self) void {
|
|
self.row_dirty = false;
|
|
}
|
|
|
|
/// Resetea el estado de edición completamente
|
|
pub fn resetEditState(self: *Self) void {
|
|
self.editing_cell = null;
|
|
self.escape_count = 0;
|
|
self.row_dirty = false;
|
|
self.last_edited_row = null;
|
|
self.edit_buffer_len = 0;
|
|
self.edit_cursor = 0;
|
|
self.cell_value_changed = false;
|
|
self.row_edit_buffer.clear();
|
|
}
|
|
|
|
// =========================================================================
|
|
// Geometría de celdas
|
|
// =========================================================================
|
|
|
|
/// Calcula la geometría (posición y tamaño) de una celda visible
|
|
/// Retorna null si la celda no está visible en pantalla
|
|
pub fn getCellGeometry(
|
|
self: *const Self,
|
|
row: usize,
|
|
col: usize,
|
|
columns: []const types.ColumnDef,
|
|
row_height: u32,
|
|
bounds_x: i32,
|
|
bounds_y: i32,
|
|
header_height: u32,
|
|
filter_bar_height: u32,
|
|
) ?CellGeometry {
|
|
// Verificar si la fila está en la ventana visible
|
|
if (row < self.scroll_offset) return null;
|
|
const visible_row = row - self.scroll_offset;
|
|
|
|
// Calcular Y (después de filter bar + header)
|
|
const content_start_y = bounds_y + @as(i32, @intCast(filter_bar_height)) + @as(i32, @intCast(header_height));
|
|
const y = content_start_y + @as(i32, @intCast(visible_row * row_height));
|
|
|
|
// Verificar columna válida
|
|
if (col >= columns.len) return null;
|
|
|
|
// Calcular X (sumando anchos de columnas anteriores, menos scroll horizontal)
|
|
var x: i32 = bounds_x - self.scroll_offset_x;
|
|
for (columns[0..col]) |c| {
|
|
x += @as(i32, @intCast(c.width));
|
|
}
|
|
|
|
return CellGeometry{
|
|
.x = x,
|
|
.y = y,
|
|
.w = columns[col].width,
|
|
.h = row_height,
|
|
};
|
|
}
|
|
};
|
|
|
|
// =============================================================================
|
|
// Tests
|
|
// =============================================================================
|
|
|
|
const testing = std.testing;
|
|
|
|
test "VirtualAdvancedTableState selection" {
|
|
var state = VirtualAdvancedTableState{};
|
|
|
|
// Initial state
|
|
try testing.expectEqual(@as(?i64, null), state.selected_id);
|
|
try testing.expect(!state.selection_changed);
|
|
|
|
// Select by ID
|
|
state.selectById(42);
|
|
try testing.expectEqual(@as(?i64, 42), state.selected_id);
|
|
try testing.expect(state.selection_changed);
|
|
|
|
// Reset flags
|
|
state.resetFrameFlags();
|
|
try testing.expect(!state.selection_changed);
|
|
|
|
// Select same ID (no change)
|
|
state.selectById(42);
|
|
try testing.expect(!state.selection_changed);
|
|
|
|
// Clear selection
|
|
state.clearSelection();
|
|
try testing.expectEqual(@as(?i64, null), state.selected_id);
|
|
try testing.expect(state.selection_changed);
|
|
}
|
|
|
|
test "VirtualAdvancedTableState filter" {
|
|
var state = VirtualAdvancedTableState{};
|
|
|
|
state.setFilter("test");
|
|
try testing.expectEqualStrings("test", state.getFilter());
|
|
try testing.expectEqual(@as(usize, 4), state.filter_len);
|
|
|
|
state.clearFilter();
|
|
try testing.expectEqualStrings("", state.getFilter());
|
|
}
|
|
|
|
test "VirtualAdvancedTableState sort" {
|
|
var state = VirtualAdvancedTableState{};
|
|
|
|
// Initial: no sort
|
|
try testing.expectEqual(@as(?[]const u8, null), state.sort_column);
|
|
try testing.expectEqual(SortDirection.none, state.sort_direction);
|
|
|
|
// Toggle once: ascending
|
|
state.toggleSort("name");
|
|
try testing.expectEqualStrings("name", state.sort_column.?);
|
|
try testing.expectEqual(SortDirection.ascending, state.sort_direction);
|
|
|
|
// Toggle again: descending
|
|
state.toggleSort("name");
|
|
try testing.expectEqual(SortDirection.descending, state.sort_direction);
|
|
|
|
// Toggle again: none
|
|
state.toggleSort("name");
|
|
try testing.expectEqual(SortDirection.none, state.sort_direction);
|
|
try testing.expectEqual(@as(?[]const u8, null), state.sort_column);
|
|
|
|
// Different column: starts ascending
|
|
state.toggleSort("date");
|
|
try testing.expectEqualStrings("date", state.sort_column.?);
|
|
try testing.expectEqual(SortDirection.ascending, state.sort_direction);
|
|
}
|
|
|
|
test "VirtualAdvancedTableState window index conversion" {
|
|
var state = VirtualAdvancedTableState{};
|
|
state.window_start = 100;
|
|
|
|
const values = [_][]const u8{"test"};
|
|
const rows = [_]RowData{
|
|
.{ .id = 1, .values = &values },
|
|
.{ .id = 2, .values = &values },
|
|
.{ .id = 3, .values = &values },
|
|
};
|
|
state.current_window = &rows;
|
|
|
|
// In window
|
|
try testing.expect(state.isInWindow(100));
|
|
try testing.expect(state.isInWindow(102));
|
|
try testing.expect(!state.isInWindow(99));
|
|
try testing.expect(!state.isInWindow(103));
|
|
|
|
// Conversions
|
|
try testing.expectEqual(@as(?usize, 0), state.globalToWindowIndex(100));
|
|
try testing.expectEqual(@as(?usize, 2), state.globalToWindowIndex(102));
|
|
try testing.expectEqual(@as(?usize, null), state.globalToWindowIndex(50));
|
|
|
|
try testing.expectEqual(@as(usize, 100), state.windowToGlobalIndex(0));
|
|
try testing.expectEqual(@as(usize, 102), state.windowToGlobalIndex(2));
|
|
}
|