refactor(table_core): Move Tab navigation logic to shared module (Norma #7 DRY)

ANTES: calculateNextCell/calculatePrevCell duplicados en:
  - AdvancedTableState
  - VirtualAdvancedTableState

AHORA: Lógica común en table_core.zig:
  - calculateNextCell() - calcula siguiente celda (Tab)
  - calculatePrevCell() - calcula celda anterior (Shift+Tab)
  - toggleSort() - alterna ordenación de columna
  - TabNavigateResult, CellPosition, SortDirection, SortToggleResult

Ambos widgets usan table_core, adaptando el resultado a su modelo:
  - AdvancedTable: selected_row/selected_col (índices)
  - VirtualAdvancedTable: selected_id + active_col (ID + columna)

Tests añadidos para calculateNextCell, calculatePrevCell, toggleSort
This commit is contained in:
reugenio 2025-12-27 01:49:45 +01:00
parent c2f0fbb19d
commit 27b69cfcde
3 changed files with 271 additions and 104 deletions

View file

@ -5,6 +5,7 @@
const std = @import("std"); const std = @import("std");
const types = @import("types.zig"); const types = @import("types.zig");
const schema_mod = @import("schema.zig"); const schema_mod = @import("schema.zig");
const table_core = @import("../table_core.zig");
pub const CellValue = types.CellValue; pub const CellValue = types.CellValue;
pub const RowState = types.RowState; pub const RowState = types.RowState;
@ -827,86 +828,48 @@ pub const AdvancedTableState = struct {
// ========================================================================= // =========================================================================
// Navegación Tab Excel-style (con wrap) // Navegación Tab Excel-style (con wrap)
// Usa table_core para la lógica común (Norma #7 DRY)
// ========================================================================= // =========================================================================
/// Resultado de navegación Tab /// Re-exporta TabNavigateResult desde table_core para compatibilidad
pub const TabNavigateResult = enum { pub const TabNavigateResult = table_core.TabNavigateResult;
/// Navegó a otra celda dentro del widget
navigated,
/// Salió del widget (Tab en última celda o Shift+Tab en primera)
tab_out,
};
/// Navega a siguiente celda (Tab) /// Navega a siguiente celda (Tab)
/// Si está en última columna, va a primera columna de siguiente fila. /// 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. /// 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 { pub fn tabToNextCell(self: *AdvancedTableState, num_cols: usize, wrap_to_start: bool) TabNavigateResult {
if (num_cols == 0) return .tab_out;
const row_count = self.getRowCount(); const row_count = self.getRowCount();
if (row_count == 0) return .tab_out;
const current_col: usize = if (self.selected_col >= 0) @intCast(self.selected_col) else 0; 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; const current_row: usize = if (self.selected_row >= 0) @intCast(self.selected_row) else 0;
if (current_col + 1 < num_cols) { // Usar función de table_core
// Siguiente columna en misma fila const pos = table_core.calculateNextCell(current_row, current_col, num_cols, row_count, wrap_to_start);
self.selected_col = @intCast(current_col + 1);
return .navigated; if (pos.result == .navigated) {
self.selected_row = @intCast(pos.row);
self.selected_col = @intCast(pos.col);
} }
// Última columna: ir a primera columna de siguiente fila return pos.result;
self.selected_col = 0;
if (current_row + 1 < row_count) {
// Hay siguiente fila
self.selected_row = @intCast(current_row + 1);
return .navigated;
}
// Última fila
if (wrap_to_start) {
self.selected_row = 0;
return .navigated;
}
return .tab_out;
} }
/// Navega a celda anterior (Shift+Tab) /// Navega a celda anterior (Shift+Tab)
/// Si está en primera columna, va a última columna de fila anterior. /// 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. /// 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 { pub fn tabToPrevCell(self: *AdvancedTableState, num_cols: usize, wrap_to_end: bool) TabNavigateResult {
if (num_cols == 0) return .tab_out;
const row_count = self.getRowCount(); const row_count = self.getRowCount();
if (row_count == 0) return .tab_out;
const current_col: usize = if (self.selected_col >= 0) @intCast(self.selected_col) else 0; 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; const current_row: usize = if (self.selected_row >= 0) @intCast(self.selected_row) else 0;
if (current_col > 0) { // Usar función de table_core
// Columna anterior en misma fila const pos = table_core.calculatePrevCell(current_row, current_col, num_cols, row_count, wrap_to_end);
self.selected_col = @intCast(current_col - 1);
return .navigated; if (pos.result == .navigated) {
self.selected_row = @intCast(pos.row);
self.selected_col = @intCast(pos.col);
} }
// Primera columna: ir a última columna de fila anterior return pos.result;
self.selected_col = @intCast(num_cols - 1);
if (current_row > 0) {
// Hay fila anterior
self.selected_row = @intCast(current_row - 1);
return .navigated;
}
// Primera fila
if (wrap_to_end) {
self.selected_row = @intCast(row_count - 1);
return .navigated;
}
return .tab_out;
} }
// ========================================================================= // =========================================================================

View file

@ -382,6 +382,155 @@ pub fn startsWithIgnoreCase(haystack: []const u8, needle: []const u8) bool {
return true; return true;
} }
// =============================================================================
// Navegación Tab Excel-style (compartida por AdvancedTable y VirtualAdvancedTable)
// =============================================================================
/// Resultado de navegación Tab
pub const TabNavigateResult = enum {
/// Navegó a otra celda dentro del widget
navigated,
/// Salió del widget (Tab en última celda o Shift+Tab en primera)
tab_out,
};
/// Resultado del cálculo de nueva posición de celda
pub const CellPosition = struct {
row: usize,
col: usize,
result: TabNavigateResult,
};
/// Calcula la siguiente celda después de Tab
/// Parámetros genéricos para que funcione con ambos tipos de tabla.
pub fn calculateNextCell(
current_row: usize,
current_col: usize,
num_cols: usize,
num_rows: usize,
wrap_to_start: bool,
) CellPosition {
if (num_cols == 0 or num_rows == 0) {
return .{ .row = current_row, .col = current_col, .result = .tab_out };
}
var new_row = current_row;
var new_col = current_col;
if (current_col + 1 < num_cols) {
// Siguiente columna en misma fila
new_col = current_col + 1;
return .{ .row = new_row, .col = new_col, .result = .navigated };
}
// Última columna: ir a primera columna de siguiente fila
new_col = 0;
if (current_row + 1 < num_rows) {
// Hay siguiente fila
new_row = current_row + 1;
return .{ .row = new_row, .col = new_col, .result = .navigated };
}
// Última fila
if (wrap_to_start) {
new_row = 0;
return .{ .row = new_row, .col = new_col, .result = .navigated };
}
return .{ .row = current_row, .col = current_col, .result = .tab_out };
}
/// Calcula la celda anterior después de Shift+Tab
pub fn calculatePrevCell(
current_row: usize,
current_col: usize,
num_cols: usize,
num_rows: usize,
wrap_to_end: bool,
) CellPosition {
if (num_cols == 0 or num_rows == 0) {
return .{ .row = current_row, .col = current_col, .result = .tab_out };
}
var new_row = current_row;
var new_col = current_col;
if (current_col > 0) {
// Columna anterior en misma fila
new_col = current_col - 1;
return .{ .row = new_row, .col = new_col, .result = .navigated };
}
// Primera columna: ir a última columna de fila anterior
new_col = num_cols - 1;
if (current_row > 0) {
// Hay fila anterior
new_row = current_row - 1;
return .{ .row = new_row, .col = new_col, .result = .navigated };
}
// Primera fila
if (wrap_to_end) {
new_row = num_rows - 1;
return .{ .row = new_row, .col = new_col, .result = .navigated };
}
return .{ .row = current_row, .col = current_col, .result = .tab_out };
}
// =============================================================================
// Ordenación (compartida)
// =============================================================================
/// Dirección de ordenación
pub const SortDirection = enum {
none,
ascending,
descending,
/// Alterna la dirección: none asc desc none
pub fn toggle(self: SortDirection) SortDirection {
return switch (self) {
.none => .ascending,
.ascending => .descending,
.descending => .none,
};
}
};
/// Resultado de toggle de ordenación en columna
pub const SortToggleResult = struct {
/// Nueva columna de ordenación (null si se desactivó)
column: ?usize,
/// Nueva dirección
direction: SortDirection,
};
/// Calcula el nuevo estado de ordenación al hacer click en una columna
pub fn toggleSort(
current_column: ?usize,
current_direction: SortDirection,
clicked_column: usize,
) SortToggleResult {
if (current_column) |col| {
if (col == clicked_column) {
// Misma columna: ciclar dirección
const new_dir = current_direction.toggle();
return .{
.column = if (new_dir == .none) null else clicked_column,
.direction = new_dir,
};
}
}
// Columna diferente o sin ordenación: empezar ascendente
return .{
.column = clicked_column,
.direction = .ascending,
};
}
// ============================================================================= // =============================================================================
// Tests // Tests
// ============================================================================= // =============================================================================
@ -420,3 +569,74 @@ test "detectDoubleClick" {
const third = detectDoubleClick(&state, 1400, 0, 0); const third = detectDoubleClick(&state, 1400, 0, 0);
try std.testing.expect(!third); try std.testing.expect(!third);
} }
test "calculateNextCell - basic navigation" {
// Tabla 3x4 (3 columnas, 4 filas)
// Celda (0,0) -> (0,1)
const r1 = calculateNextCell(0, 0, 3, 4, false);
try std.testing.expectEqual(@as(usize, 0), r1.row);
try std.testing.expectEqual(@as(usize, 1), r1.col);
try std.testing.expectEqual(TabNavigateResult.navigated, r1.result);
// Última columna -> primera columna de siguiente fila
const r2 = calculateNextCell(0, 2, 3, 4, false);
try std.testing.expectEqual(@as(usize, 1), r2.row);
try std.testing.expectEqual(@as(usize, 0), r2.col);
try std.testing.expectEqual(TabNavigateResult.navigated, r2.result);
// Última celda sin wrap -> tab_out
const r3 = calculateNextCell(3, 2, 3, 4, false);
try std.testing.expectEqual(TabNavigateResult.tab_out, r3.result);
// Última celda con wrap -> primera celda
const r4 = calculateNextCell(3, 2, 3, 4, true);
try std.testing.expectEqual(@as(usize, 0), r4.row);
try std.testing.expectEqual(@as(usize, 0), r4.col);
try std.testing.expectEqual(TabNavigateResult.navigated, r4.result);
}
test "calculatePrevCell - basic navigation" {
// Celda (0,2) -> (0,1)
const r1 = calculatePrevCell(0, 2, 3, 4, false);
try std.testing.expectEqual(@as(usize, 0), r1.row);
try std.testing.expectEqual(@as(usize, 1), r1.col);
try std.testing.expectEqual(TabNavigateResult.navigated, r1.result);
// Primera columna -> última columna de fila anterior
const r2 = calculatePrevCell(1, 0, 3, 4, false);
try std.testing.expectEqual(@as(usize, 0), r2.row);
try std.testing.expectEqual(@as(usize, 2), r2.col);
try std.testing.expectEqual(TabNavigateResult.navigated, r2.result);
// Primera celda sin wrap -> tab_out
const r3 = calculatePrevCell(0, 0, 3, 4, false);
try std.testing.expectEqual(TabNavigateResult.tab_out, r3.result);
// Primera celda con wrap -> última celda
const r4 = calculatePrevCell(0, 0, 3, 4, true);
try std.testing.expectEqual(@as(usize, 3), r4.row);
try std.testing.expectEqual(@as(usize, 2), r4.col);
try std.testing.expectEqual(TabNavigateResult.navigated, r4.result);
}
test "toggleSort" {
// Sin ordenación -> ascendente en columna 2
const r1 = toggleSort(null, .none, 2);
try std.testing.expectEqual(@as(?usize, 2), r1.column);
try std.testing.expectEqual(SortDirection.ascending, r1.direction);
// Ascendente en columna 2 -> descendente
const r2 = toggleSort(2, .ascending, 2);
try std.testing.expectEqual(@as(?usize, 2), r2.column);
try std.testing.expectEqual(SortDirection.descending, r2.direction);
// Descendente -> none (columna null)
const r3 = toggleSort(2, .descending, 2);
try std.testing.expectEqual(@as(?usize, null), r3.column);
try std.testing.expectEqual(SortDirection.none, r3.direction);
// Click en columna diferente -> ascendente en nueva columna
const r4 = toggleSort(2, .ascending, 5);
try std.testing.expectEqual(@as(?usize, 5), r4.column);
try std.testing.expectEqual(SortDirection.ascending, r4.direction);
}

View file

@ -4,6 +4,7 @@
const std = @import("std"); const std = @import("std");
const types = @import("types.zig"); const types = @import("types.zig");
const table_core = @import("../table_core.zig");
const RowData = types.RowData; const RowData = types.RowData;
const CountInfo = types.CountInfo; const CountInfo = types.CountInfo;
const SortDirection = types.SortDirection; const SortDirection = types.SortDirection;
@ -494,78 +495,61 @@ pub const VirtualAdvancedTableState = struct {
// ========================================================================= // =========================================================================
// Navegación Tab Excel-style (con wrap) // Navegación Tab Excel-style (con wrap)
// Usa table_core para la lógica común (Norma #7 DRY)
// ========================================================================= // =========================================================================
/// Resultado de navegación Tab /// Re-exporta TabNavigateResult desde table_core para compatibilidad
pub const TabNavigateResult = enum { pub const TabNavigateResult = table_core.TabNavigateResult;
/// Navegó a otra celda dentro del widget
navigated,
/// Salió del widget (Tab en última celda o Shift+Tab en primera)
tab_out,
};
/// Navega a siguiente celda (Tab) /// Navega a siguiente celda (Tab)
/// Si está en última columna, va a primera columna de siguiente fila. /// 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. /// 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 { pub fn tabToNextCell(self: *Self, num_cols: usize, visible_rows: usize, wrap_to_start: bool) TabNavigateResult {
if (num_cols == 0) return .tab_out; // Obtener fila actual
const current_row = self.getSelectedRow() orelse 0;
const total_rows = self.current_window.len + self.window_start;
if (self.active_col + 1 < num_cols) { // Usar función de table_core
// Siguiente columna en misma fila const pos = table_core.calculateNextCell(current_row, self.active_col, num_cols, total_rows, wrap_to_start);
self.active_col += 1;
return .navigated;
}
// Última columna: ir a primera columna de siguiente fila if (pos.result == .navigated) {
self.active_col = 0; self.active_col = pos.col;
// Navegar a la nueva fila si cambió
if (self.findSelectedInWindow()) |window_idx| { if (pos.row != current_row) {
if (window_idx + 1 < self.current_window.len) { if (pos.row == 0) {
// Hay siguiente fila self.goToStart();
self.moveDown(visible_rows); } else {
return .navigated; self.moveDown(visible_rows);
}
} }
} }
// Última fila return pos.result;
if (wrap_to_start) {
self.goToStart();
return .navigated;
}
return .tab_out;
} }
/// Navega a celda anterior (Shift+Tab) /// Navega a celda anterior (Shift+Tab)
/// Si está en primera columna, va a última columna de fila anterior. /// 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. /// 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 { pub fn tabToPrevCell(self: *Self, num_cols: usize, visible_rows: usize, total_rows: usize, wrap_to_end: bool) TabNavigateResult {
if (num_cols == 0) return .tab_out; // Obtener fila actual
const current_row = self.getSelectedRow() orelse 0;
if (self.active_col > 0) { // Usar función de table_core
// Columna anterior en misma fila const pos = table_core.calculatePrevCell(current_row, self.active_col, num_cols, total_rows, wrap_to_end);
self.active_col -= 1;
return .navigated;
}
// Primera columna: ir a última columna de fila anterior if (pos.result == .navigated) {
self.active_col = num_cols - 1; self.active_col = pos.col;
// Navegar a la nueva fila si cambió
if (self.findSelectedInWindow()) |window_idx| { if (pos.row != current_row) {
if (window_idx > 0) { if (pos.row == total_rows - 1) {
// Hay fila anterior self.goToEnd(visible_rows, total_rows);
self.moveUp(); } else {
return .navigated; self.moveUp();
}
} }
} }
// Primera fila return pos.result;
if (wrap_to_end) {
self.goToEnd(visible_rows, total_rows);
return .navigated;
}
return .tab_out;
} }
/// Obtiene la fila global seleccionada /// Obtiene la fila global seleccionada