Compare commits

...

6 commits

Author SHA1 Message Date
d16019d54f feat(table_core): RowEditBuffer + commit al abandonar fila (Excel-style)
- 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
2025-12-27 14:58:01 +01:00
0026dbff2a docs(TABLES_ARCHITECTURE): Add 'Lecciones Aprendidas' section
Document the Tab double-processing bug and its solution:
- Symptom: Row color alternating on Tab press
- Root cause: Tab processed twice (widget + app level)
- Solution: NavigateDirection + handled flag + app check
- General rule for keyboard handling in immediate-mode widgets

Also updated EditKeyboardResult API documentation with new fields.
2025-12-27 12:53:21 +01:00
60c3f9d456 refactor(advanced_table): Use table_core.handleEditingKeyboard (DRY)
- Remove 160 lines of duplicated keyboard handling code
- Use shared table_core.handleEditingKeyboard() for edit buffer operations
- Export NavigateDirection from table_core for external use
- Keep AdvancedTable-specific navigation logic (cell movement, auto-edit)
2025-12-27 12:24:50 +01:00
91969cb728 fix(virtual_advanced_table): Prevent double Tab processing
- Check navigate_direction before setting tab_out in handleKeyboard
- Export NavigateDirection from virtual_advanced_table module
- Use cell_editor.NavigateDirection in VirtualAdvancedTableResult

This fixes the bug where Tab was processed twice: once by CellEditor
(for cell navigation) and again by handleKeyboard (for tab_out).
2025-12-27 12:13:51 +01:00
9b2cf2a3dd refactor(cell_editor): Use table_core.handleEditingKeyboard instead of duplicated code
- Import and use table_core for keyboard handling
- Re-export NavigateDirection from table_core
- Map table_core.EditKeyboardResult to CellEditorResult
- Remove ~100 lines of duplicated keyboard handling code
- Add 'handled' field to CellEditorResult
2025-12-27 12:10:59 +01:00
49cf12f7b9 refactor(table_core): Unify EditKeyboardResult with NavigateDirection and handled flag
- Add NavigateDirection enum (none, next_cell, prev_cell, next_row, prev_row)
- Replace navigate_next/navigate_prev bools with single navigate field
- Add 'handled' flag to prevent double processing of Tab
- Add Up/Down arrow handling for row navigation
- Use getKeyEvents() loop instead of keyPressed() for consistency
2025-12-27 12:09:47 +01:00
6 changed files with 567 additions and 391 deletions

View file

@ -241,19 +241,31 @@ pub fn toggleSort(current_column, current_direction, clicked_column) SortToggleR
### Edición de Celda
```zig
pub const EditKeyboardResult = struct {
committed: bool, // Enter presionado
cancelled: bool, // Escape 2x
reverted: bool, // Escape 1x (revertir a original)
navigate_next: bool, // Tab
navigate_prev: bool, // Shift+Tab
text_changed: bool, // Texto modificado
/// Dirección de navegación después de commit
pub const NavigateDirection = enum {
none, // Sin navegación
next_cell, // Tab → siguiente celda
prev_cell, // Shift+Tab → celda anterior
next_row, // Enter/↓ → siguiente fila
prev_row, // ↑ → fila anterior
};
/// Procesa teclado en modo edición
pub const EditKeyboardResult = struct {
committed: bool, // Enter/Tab/flechas presionados
cancelled: bool, // Escape 2x
reverted: bool, // Escape 1x (revertir a original)
navigate: NavigateDirection, // Dirección de navegación post-commit
text_changed: bool, // Texto modificado
handled: bool, // IMPORTANTE: evento fue procesado
};
/// Procesa teclado en modo edición (DRY - un solo lugar)
pub fn handleEditingKeyboard(ctx, edit_buffer, edit_len, edit_cursor, escape_count, original_text) EditKeyboardResult
```
> **IMPORTANTE:** El campo `handled` indica si table_core procesó el evento de teclado.
> Esto es crítico para evitar doble procesamiento de Tab (ver "Lecciones Aprendidas").
### Renderizado
```zig
@ -353,10 +365,55 @@ Tests incluidos:
---
## Lecciones Aprendidas
### Bug: Color de fila alternando al presionar Tab (2025-12-27)
**Síntoma:** Al navegar entre celdas con Tab, el color de la fila seleccionada alternaba
entre azul (con focus) y marrón/gris (sin focus), independientemente de si se modificaba
el contenido.
**Causa raíz:** Tab se procesaba DOS veces:
1. Por VirtualAdvancedTable/CellEditor → navegación entre celdas
2. Por main.zig de la aplicación → cambio de focus entre widgets (`ctx.handleTabKey()`)
Esto causaba que el focus del widget alternara incorrectamente cada frame.
**Solución (3 partes):**
1. **table_core.zig:** Añadir campo `handled` a `EditKeyboardResult` para indicar que el
evento fue procesado.
2. **VirtualAdvancedTable:** Verificar `navigate_direction != .none` antes de procesar
Tab como `tab_out`. Si la tabla ya procesó Tab para navegación interna, no marcar
`tab_out = true`.
3. **Aplicación (main.zig):** No llamar `ctx.handleTabKey()` cuando el panel activo
maneja Tab internamente (ej: cuando `active_tab == .configuracion`).
**Regla general:**
> Cuando un widget procesa teclado internamente en `draw()`, la aplicación debe
> respetar esa decisión y NO procesar el mismo evento a nivel global.
**Código clave:**
```zig
// VirtualAdvancedTable - handleKeyboard()
.tab => {
// IMPORTANTE: Solo si CellEditor no procesó Tab
if (result.navigate_direction == .none) {
result.tab_out = true;
result.tab_shift = event.modifiers.shift;
}
},
```
---
## Historial de Cambios
| Fecha | Cambio |
|-------|--------|
| 2025-12-27 | Fix bug colores alternando + campo `handled` en EditKeyboardResult |
| 2025-12-27 | Refactorización DRY: lógica común movida a table_core.zig |
| 2025-12-26 | table_core.zig creado con funciones de renderizado compartidas |
| 2025-12-17 | VirtualAdvancedTable añadido para tablas grandes |

View file

@ -17,6 +17,7 @@ const Context = @import("../../core/context.zig").Context;
const Command = @import("../../core/command.zig");
const Layout = @import("../../core/layout.zig");
const Style = @import("../../core/style.zig");
const table_core = @import("../table_core.zig");
// Re-export types
pub const types = @import("types.zig");
@ -46,6 +47,9 @@ pub const state = @import("state.zig");
pub const AdvancedTableState = state.AdvancedTableState;
pub const AdvancedTableResult = state.AdvancedTableResult;
// Re-export table_core types
pub const NavigateDirection = table_core.NavigateDirection;
// =============================================================================
// Public API
// =============================================================================
@ -919,155 +923,94 @@ fn handleEditingKeyboard(
) void {
const config = table_schema.config;
// Escape: cancel editing (1st = revert, 2nd = exit without save)
if (ctx.input.keyPressed(.escape)) {
table_state.escape_count += 1;
if (table_state.escape_count >= 2 or table_state.original_value == null) {
// Exit without saving
table_state.stopEditing();
result.edit_ended = true;
} else {
// Revert to original value
if (table_state.original_value) |orig| {
var format_buf: [128]u8 = undefined;
const text = orig.format(&format_buf);
table_state.startEditing(text);
}
}
return;
}
// Obtener texto original para revert
var orig_format_buf: [128]u8 = undefined;
const original_text: ?[]const u8 = if (table_state.original_value) |orig|
orig.format(&orig_format_buf)
else
null;
// Reset escape count on any other key
table_state.escape_count = 0;
// Usar table_core para procesamiento de teclado (DRY)
const kb_result = table_core.handleEditingKeyboard(
ctx,
&table_state.edit_buffer,
&table_state.edit_len,
&table_state.edit_cursor,
&table_state.escape_count,
original_text,
);
// Enter: confirm editing
if (ctx.input.keyPressed(.enter)) {
commitEdit(table_state, table_schema, result);
// Si no se procesó ningún evento, salir
if (!kb_result.handled) return;
// Escape canceló la edición
if (kb_result.cancelled) {
table_state.stopEditing();
result.edit_ended = true;
return;
}
// Tab: confirm and move to next cell
if (ctx.input.keyPressed(.tab) and config.handle_tab) {
// Commit (Enter, Tab, flechas) y navegación
if (kb_result.committed) {
commitEdit(table_state, table_schema, result);
table_state.stopEditing();
result.edit_ended = true;
// Move to next/prev cell
const shift = ctx.input.modifiers.shift;
const col_count = table_schema.columns.len;
const row_count = table_state.getRowCount();
// Procesar navegación después de commit
switch (kb_result.navigate) {
.next_cell, .prev_cell => {
if (!config.handle_tab) return;
if (shift) {
// Shift+Tab: move left
if (table_state.selected_col > 0) {
table_state.selectCell(
@intCast(@max(0, table_state.selected_row)),
@intCast(table_state.selected_col - 1),
);
} else if (table_state.selected_row > 0 and config.wrap_navigation) {
table_state.selectCell(
@intCast(table_state.selected_row - 1),
col_count - 1,
);
}
} else {
// Tab: move right
if (table_state.selected_col < @as(i32, @intCast(col_count)) - 1) {
table_state.selectCell(
@intCast(@max(0, table_state.selected_row)),
@intCast(table_state.selected_col + 1),
);
} else if (table_state.selected_row < @as(i32, @intCast(row_count)) - 1 and config.wrap_navigation) {
table_state.selectCell(
@intCast(table_state.selected_row + 1),
0,
);
}
}
const col_count = table_schema.columns.len;
const row_count = table_state.getRowCount();
// Auto-start editing in new cell if editable
const new_col: usize = @intCast(@max(0, table_state.selected_col));
if (new_col < table_schema.columns.len and table_schema.columns[new_col].editable) {
if (table_state.getRow(@intCast(@max(0, table_state.selected_row)))) |row| {
const value = row.get(table_schema.columns[new_col].name);
var format_buf: [128]u8 = undefined;
const text = value.format(&format_buf);
table_state.startEditing(text);
result.edit_started = true;
}
}
result.selection_changed = true;
return;
}
// Cursor movement within edit buffer
if (ctx.input.keyPressed(.left)) {
if (table_state.edit_cursor > 0) {
table_state.edit_cursor -= 1;
}
return;
}
if (ctx.input.keyPressed(.right)) {
if (table_state.edit_cursor < table_state.edit_len) {
table_state.edit_cursor += 1;
}
return;
}
if (ctx.input.keyPressed(.home)) {
table_state.edit_cursor = 0;
return;
}
if (ctx.input.keyPressed(.end)) {
table_state.edit_cursor = table_state.edit_len;
return;
}
// Backspace: delete char before cursor
if (ctx.input.keyPressed(.backspace)) {
if (table_state.edit_cursor > 0) {
// Shift characters left
var i: usize = table_state.edit_cursor - 1;
while (i < table_state.edit_len - 1) : (i += 1) {
table_state.edit_buffer[i] = table_state.edit_buffer[i + 1];
}
table_state.edit_len -= 1;
table_state.edit_cursor -= 1;
}
return;
}
// Delete: delete char at cursor
if (ctx.input.keyPressed(.delete)) {
if (table_state.edit_cursor < table_state.edit_len) {
// Shift characters left
var i: usize = table_state.edit_cursor;
while (i < table_state.edit_len - 1) : (i += 1) {
table_state.edit_buffer[i] = table_state.edit_buffer[i + 1];
}
table_state.edit_len -= 1;
}
return;
}
// Character input
if (ctx.input.text_input_len > 0) {
const text = ctx.input.text_input[0..ctx.input.text_input_len];
for (text) |ch| {
if (ch >= 32 and ch < 127) {
if (table_state.edit_len < types.MAX_EDIT_BUFFER - 1) {
// Shift characters right
var i: usize = table_state.edit_len;
while (i > table_state.edit_cursor) : (i -= 1) {
table_state.edit_buffer[i] = table_state.edit_buffer[i - 1];
if (kb_result.navigate == .prev_cell) {
// Shift+Tab: move left
if (table_state.selected_col > 0) {
table_state.selectCell(
@intCast(@max(0, table_state.selected_row)),
@intCast(table_state.selected_col - 1),
);
} else if (table_state.selected_row > 0 and config.wrap_navigation) {
table_state.selectCell(
@intCast(table_state.selected_row - 1),
col_count - 1,
);
}
} else {
// Tab: move right
if (table_state.selected_col < @as(i32, @intCast(col_count)) - 1) {
table_state.selectCell(
@intCast(@max(0, table_state.selected_row)),
@intCast(table_state.selected_col + 1),
);
} else if (table_state.selected_row < @as(i32, @intCast(row_count)) - 1 and config.wrap_navigation) {
table_state.selectCell(
@intCast(table_state.selected_row + 1),
0,
);
}
table_state.edit_buffer[table_state.edit_cursor] = ch;
table_state.edit_len += 1;
table_state.edit_cursor += 1;
}
}
// Auto-start editing in new cell if editable
const new_col: usize = @intCast(@max(0, table_state.selected_col));
if (new_col < table_schema.columns.len and table_schema.columns[new_col].editable) {
if (table_state.getRow(@intCast(@max(0, table_state.selected_row)))) |row| {
const value = row.get(table_schema.columns[new_col].name);
var format_buf: [128]u8 = undefined;
const text = value.format(&format_buf);
table_state.startEditing(text);
result.edit_started = true;
}
}
result.selection_changed = true;
},
.next_row, .prev_row => {
// Enter o flechas arriba/abajo: solo commit, sin navegación adicional aquí
// (La navegación entre filas se maneja en otro lugar si es necesario)
},
.none => {},
}
}
}

View file

@ -210,24 +210,34 @@ pub fn detectDoubleClick(
// Manejo de teclado para edición
// =============================================================================
/// Dirección de navegación después de edición
pub const NavigateDirection = enum {
none,
next_cell, // Tab
prev_cell, // Shift+Tab
next_row, // Enter o
prev_row, //
};
/// Resultado de procesar teclado en modo edición
pub const EditKeyboardResult = struct {
/// Se confirmó la edición (Enter)
/// Se confirmó la edición (Enter, Tab, flechas)
committed: bool = false,
/// Se canceló la edición (Escape)
/// Se canceló la edición (Escape 2x)
cancelled: bool = false,
/// Se revirtió al valor original (primer Escape)
/// Se revirtió al valor original (Escape 1x)
reverted: bool = false,
/// Se debe navegar a siguiente celda (Tab)
navigate_next: bool = false,
/// Se debe navegar a celda anterior (Shift+Tab)
navigate_prev: bool = false,
/// Dirección de navegación después de commit
navigate: NavigateDirection = .none,
/// El buffer de edición cambió
text_changed: bool = false,
/// Indica que se procesó un evento de teclado (para evitar doble procesamiento)
handled: bool = false,
};
/// Procesa teclado en modo edición
/// Modifica edit_buffer, edit_len, edit_cursor según las teclas
/// Retorna resultado con flags de navegación y si se procesó algún evento
pub fn handleEditingKeyboard(
ctx: *Context,
edit_buffer: []u8,
@ -238,113 +248,310 @@ pub fn handleEditingKeyboard(
) EditKeyboardResult {
var result = EditKeyboardResult{};
// Escape: cancelar o revertir
if (ctx.input.keyPressed(.escape)) {
escape_count.* += 1;
if (escape_count.* >= 2 or original_text == null) {
result.cancelled = true;
} else {
// Revertir al valor original
if (original_text) |orig| {
const len = @min(orig.len, edit_buffer.len);
@memcpy(edit_buffer[0..len], orig[0..len]);
edit_len.* = len;
edit_cursor.* = len;
result.reverted = true;
}
// Procesar eventos de tecla
for (ctx.input.getKeyEvents()) |event| {
if (!event.pressed) continue;
switch (event.key) {
.escape => {
escape_count.* += 1;
if (escape_count.* >= 2 or original_text == null) {
result.cancelled = true;
} else {
// Revertir al valor original
if (original_text) |orig| {
const len = @min(orig.len, edit_buffer.len);
@memcpy(edit_buffer[0..len], orig[0..len]);
edit_len.* = len;
edit_cursor.* = len;
result.reverted = true;
}
}
result.handled = true;
return result;
},
.enter => {
result.committed = true;
result.navigate = .next_row;
result.handled = true;
return result;
},
.tab => {
result.committed = true;
result.navigate = if (event.modifiers.shift) .prev_cell else .next_cell;
result.handled = true;
return result;
},
.up => {
result.committed = true;
result.navigate = .prev_row;
result.handled = true;
return result;
},
.down => {
result.committed = true;
result.navigate = .next_row;
result.handled = true;
return result;
},
.left => {
if (edit_cursor.* > 0) edit_cursor.* -= 1;
result.handled = true;
// Reset escape count
escape_count.* = 0;
},
.right => {
if (edit_cursor.* < edit_len.*) edit_cursor.* += 1;
result.handled = true;
escape_count.* = 0;
},
.home => {
edit_cursor.* = 0;
result.handled = true;
escape_count.* = 0;
},
.end => {
edit_cursor.* = edit_len.*;
result.handled = true;
escape_count.* = 0;
},
.backspace => {
if (edit_cursor.* > 0 and edit_len.* > 0) {
// Shift characters left
const pos = edit_cursor.* - 1;
var i: usize = pos;
while (i < edit_len.* - 1) : (i += 1) {
edit_buffer[i] = edit_buffer[i + 1];
}
edit_len.* -= 1;
edit_cursor.* -= 1;
result.text_changed = true;
}
result.handled = true;
escape_count.* = 0;
},
.delete => {
if (edit_cursor.* < edit_len.*) {
var i: usize = edit_cursor.*;
while (i < edit_len.* - 1) : (i += 1) {
edit_buffer[i] = edit_buffer[i + 1];
}
edit_len.* -= 1;
result.text_changed = true;
}
result.handled = true;
escape_count.* = 0;
},
else => {},
}
return result;
}
// Reset escape count en cualquier otra tecla
escape_count.* = 0;
// Enter: confirmar
if (ctx.input.keyPressed(.enter)) {
result.committed = true;
return result;
}
// Tab: confirmar y navegar
if (ctx.input.keyPressed(.tab)) {
result.committed = true;
if (ctx.input.modifiers.shift) {
result.navigate_prev = true;
} else {
result.navigate_next = true;
}
return result;
}
// Movimiento del cursor
if (ctx.input.keyPressed(.left)) {
if (edit_cursor.* > 0) edit_cursor.* -= 1;
return result;
}
if (ctx.input.keyPressed(.right)) {
if (edit_cursor.* < edit_len.*) edit_cursor.* += 1;
return result;
}
if (ctx.input.keyPressed(.home)) {
edit_cursor.* = 0;
return result;
}
if (ctx.input.keyPressed(.end)) {
edit_cursor.* = edit_len.*;
return result;
}
// Backspace
if (ctx.input.keyPressed(.backspace)) {
if (edit_cursor.* > 0) {
// Shift characters left
var i: usize = edit_cursor.* - 1;
while (i < edit_len.* - 1) : (i += 1) {
edit_buffer[i] = edit_buffer[i + 1];
}
edit_len.* -= 1;
edit_cursor.* -= 1;
result.text_changed = true;
}
return result;
}
// Delete
if (ctx.input.keyPressed(.delete)) {
if (edit_cursor.* < edit_len.*) {
var i: usize = edit_cursor.*;
while (i < edit_len.* - 1) : (i += 1) {
edit_buffer[i] = edit_buffer[i + 1];
}
edit_len.* -= 1;
result.text_changed = true;
}
return result;
}
// Character input
if (ctx.input.text_input_len > 0) {
const text = ctx.input.text_input[0..ctx.input.text_input_len];
for (text) |ch| {
if (ch >= 32 and ch < 127) {
if (edit_len.* < edit_buffer.len - 1) {
// Shift characters right
var i: usize = edit_len.*;
// Procesar texto ingresado (caracteres imprimibles)
const text_input = ctx.input.getTextInput();
if (text_input.len > 0) {
for (text_input) |ch| {
if (edit_len.* < edit_buffer.len - 1) {
// Hacer espacio moviendo caracteres hacia la derecha
if (edit_cursor.* < edit_len.*) {
var i = edit_len.*;
while (i > edit_cursor.*) : (i -= 1) {
edit_buffer[i] = edit_buffer[i - 1];
}
edit_buffer[edit_cursor.*] = ch;
edit_len.* += 1;
edit_cursor.* += 1;
result.text_changed = true;
}
edit_buffer[edit_cursor.*] = ch;
edit_len.* += 1;
edit_cursor.* += 1;
result.text_changed = true;
result.handled = true;
}
}
escape_count.* = 0;
}
return result;
}
// =============================================================================
// Edición de fila completa (commit al abandonar fila, estilo Excel)
// =============================================================================
/// Máximo de columnas soportadas para cambios pendientes
pub const MAX_PENDING_COLUMNS: usize = 32;
/// Máximo tamaño de valor por celda
pub const MAX_CELL_VALUE_LEN: usize = 256;
/// ID especial para filas nuevas (ghost row)
pub const NEW_ROW_ID: i64 = -1;
/// Cambio pendiente en una columna
pub const PendingCellChange = struct {
/// Índice de columna
col: usize,
/// Valor nuevo (slice al buffer interno)
value: []const u8,
};
/// Buffer para acumular cambios de una fila antes de commit
/// Usado por los states de los widgets, procesado por funciones de table_core
pub const RowEditBuffer = struct {
/// ID de la fila siendo editada (NEW_ROW_ID si es ghost row)
row_id: i64 = NEW_ROW_ID,
/// Índice de fila (para navegación)
row_index: usize = 0,
/// Es una fila nueva (ghost row que el usuario está rellenando)
is_new_row: bool = false,
/// Hay cambios pendientes
has_changes: bool = false,
/// Buffers de valores por columna (almacenamiento fijo)
value_buffers: [MAX_PENDING_COLUMNS][MAX_CELL_VALUE_LEN]u8 = undefined,
/// Longitudes de cada valor
value_lens: [MAX_PENDING_COLUMNS]usize = [_]usize{0} ** MAX_PENDING_COLUMNS,
/// Flags: qué columnas tienen cambios
changed_cols: [MAX_PENDING_COLUMNS]bool = [_]bool{false} ** MAX_PENDING_COLUMNS,
/// Número de columnas con cambios
change_count: usize = 0,
/// Inicializa/resetea el buffer para una nueva fila
pub fn startEdit(self: *RowEditBuffer, row_id: i64, row_index: usize, is_new: bool) void {
self.row_id = row_id;
self.row_index = row_index;
self.is_new_row = is_new;
self.has_changes = false;
self.change_count = 0;
for (0..MAX_PENDING_COLUMNS) |i| {
self.changed_cols[i] = false;
self.value_lens[i] = 0;
}
}
/// Añade un cambio pendiente para una columna
pub fn addChange(self: *RowEditBuffer, col: usize, value: []const u8) void {
if (col >= MAX_PENDING_COLUMNS) return;
// Copiar valor al buffer interno
const len = @min(value.len, MAX_CELL_VALUE_LEN);
@memcpy(self.value_buffers[col][0..len], value[0..len]);
self.value_lens[col] = len;
// Marcar como cambiado
if (!self.changed_cols[col]) {
self.changed_cols[col] = true;
self.change_count += 1;
}
self.has_changes = true;
}
/// Obtiene el valor pendiente de una columna (si hay cambio)
pub fn getPendingValue(self: *const RowEditBuffer, col: usize) ?[]const u8 {
if (col >= MAX_PENDING_COLUMNS) return null;
if (!self.changed_cols[col]) return null;
return self.value_buffers[col][0..self.value_lens[col]];
}
/// Limpia el buffer (después de commit o discard)
pub fn clear(self: *RowEditBuffer) void {
self.row_id = NEW_ROW_ID;
self.row_index = 0;
self.is_new_row = false;
self.has_changes = false;
self.change_count = 0;
for (0..MAX_PENDING_COLUMNS) |i| {
self.changed_cols[i] = false;
self.value_lens[i] = 0;
}
}
};
/// Información para hacer commit de los cambios de una fila
/// Retornada cuando el usuario abandona una fila editada
pub const RowCommitInfo = struct {
/// ID de la fila (NEW_ROW_ID si es INSERT)
row_id: i64,
/// Es INSERT (nueva fila) o UPDATE (fila existente)
is_insert: bool,
/// Lista de cambios (columna, valor)
changes: []const PendingCellChange,
/// Número de cambios
change_count: usize,
};
/// Construye la info de commit desde un RowEditBuffer
/// El caller debe proveer el array para almacenar los cambios
pub fn buildCommitInfo(
buffer: *const RowEditBuffer,
changes_out: []PendingCellChange,
) ?RowCommitInfo {
if (!buffer.has_changes) return null;
var count: usize = 0;
for (0..MAX_PENDING_COLUMNS) |col| {
if (buffer.changed_cols[col] and count < changes_out.len) {
changes_out[count] = .{
.col = col,
.value = buffer.value_buffers[col][0..buffer.value_lens[col]],
};
count += 1;
}
}
return RowCommitInfo{
.row_id = buffer.row_id,
.is_insert = buffer.is_new_row,
.changes = changes_out[0..count],
.change_count = count,
};
}
/// Verifica si hay que hacer commit antes de editar nueva celda.
/// Si la fila cambió y hay cambios pendientes, retorna commit info.
/// Siempre inicializa el buffer para la nueva fila.
///
/// Uso típico en widget:
/// ```
/// if (table_core.checkRowChangeAndCommit(&state.row_edit_buffer, new_id, new_idx, is_ghost, &changes)) |info| {
/// result.row_committed = true;
/// result.commit_info = info;
/// }
/// ```
pub fn checkRowChangeAndCommit(
buffer: *RowEditBuffer,
new_row_id: i64,
new_row_index: usize,
is_new_row: bool,
changes_out: []PendingCellChange,
) ?RowCommitInfo {
// Si es la misma fila, no hacer nada
if (buffer.row_id == new_row_id) return null;
// Si hay cambios pendientes en la fila anterior, construir commit
var commit_info: ?RowCommitInfo = null;
if (buffer.has_changes) {
commit_info = buildCommitInfo(buffer, changes_out);
}
// Iniciar edición de la nueva fila
buffer.startEdit(new_row_id, new_row_index, is_new_row);
return commit_info;
}
/// Verifica si un row_id corresponde a la ghost row (fila nueva)
pub fn isGhostRow(row_id: i64) bool {
return row_id == NEW_ROW_ID;
}
// =============================================================================
// Utilidades
// =============================================================================

View file

@ -1,19 +1,22 @@
//! CellEditor - Editor de celda overlay para edición inline
//!
//! Dibuja un campo de texto sobre una celda para edición estilo Excel.
//! Utiliza el edit_buffer del VirtualAdvancedTableState.
//! Utiliza table_core.handleEditingKeyboard() para procesar teclado.
const std = @import("std");
const Context = @import("../../core/context.zig").Context;
const Command = @import("../../core/command.zig");
const Style = @import("../../core/style.zig");
const Input = @import("../../core/input.zig");
const types = @import("types.zig");
const state_mod = @import("state.zig");
const table_core = @import("../table_core.zig");
const CellGeometry = types.CellGeometry;
const VirtualAdvancedTableState = state_mod.VirtualAdvancedTableState;
// Re-exportar NavigateDirection desde table_core para compatibilidad
pub const NavigateDirection = table_core.NavigateDirection;
/// Colores del editor de celda
pub const CellEditorColors = struct {
background: Style.Color = Style.Color.rgb(255, 255, 255),
@ -24,8 +27,9 @@ pub const CellEditorColors = struct {
};
/// Resultado del procesamiento del CellEditor
/// Usa table_core.NavigateDirection para navegación
pub const CellEditorResult = struct {
/// El usuario presionó Enter o Tab (commit)
/// El usuario presionó Enter, Tab, o flechas (commit)
committed: bool = false,
/// El usuario presionó Escape
@ -37,13 +41,8 @@ pub const CellEditorResult = struct {
/// Navegación solicitada después de commit
navigate: NavigateDirection = .none,
pub const NavigateDirection = enum {
none,
next_cell, // Tab
prev_cell, // Shift+Tab
next_row, // Enter o
prev_row, //
};
/// Se procesó algún evento de teclado
handled: bool = false,
};
/// Dibuja el editor de celda overlay
@ -106,8 +105,28 @@ pub fn drawCellEditor(
));
}
// Procesar input de teclado
result = handleCellEditorInput(ctx, state);
// Procesar input de teclado usando table_core
const original_text = state.getOriginalValue();
const kb_result = table_core.handleEditingKeyboard(
ctx,
&state.edit_buffer,
&state.edit_buffer_len,
&state.edit_cursor,
&state.escape_count,
if (original_text.len > 0) original_text else null,
);
// Mapear resultado de table_core a CellEditorResult
result.committed = kb_result.committed;
result.escaped = kb_result.cancelled;
result.text_changed = kb_result.text_changed;
result.navigate = kb_result.navigate;
result.handled = kb_result.handled;
// Escape con revert no es "escaped" sino solo revertir texto
if (kb_result.reverted) {
result.escaped = false; // No cerrar editor, solo revertir
}
// Actualizar tiempo de última edición si hubo cambios
if (result.text_changed) {
@ -117,116 +136,6 @@ pub fn drawCellEditor(
return result;
}
/// Procesa input de teclado para el editor
fn handleCellEditorInput(ctx: *Context, state: *VirtualAdvancedTableState) CellEditorResult {
var result = CellEditorResult{};
// Procesar eventos de tecla
for (ctx.input.getKeyEvents()) |event| {
if (!event.pressed) continue;
switch (event.key) {
.escape => {
result.escaped = true;
return result;
},
.enter => {
result.committed = true;
result.navigate = .next_row;
return result;
},
.tab => {
result.committed = true;
if (event.modifiers.shift) {
result.navigate = .prev_cell;
} else {
result.navigate = .next_cell;
}
return result;
},
.up => {
result.committed = true;
result.navigate = .prev_row;
return result;
},
.down => {
result.committed = true;
result.navigate = .next_row;
return result;
},
.left => {
// Mover cursor a la izquierda
if (state.edit_cursor > 0) {
state.edit_cursor -= 1;
}
},
.right => {
// Mover cursor a la derecha
if (state.edit_cursor < state.edit_buffer_len) {
state.edit_cursor += 1;
}
},
.home => {
state.edit_cursor = 0;
},
.end => {
state.edit_cursor = state.edit_buffer_len;
},
.backspace => {
if (state.edit_cursor > 0 and state.edit_buffer_len > 0) {
// Eliminar caracter antes del cursor
const pos = state.edit_cursor - 1;
// Mover caracteres hacia la izquierda
std.mem.copyForwards(
u8,
state.edit_buffer[pos .. state.edit_buffer_len - 1],
state.edit_buffer[pos + 1 .. state.edit_buffer_len],
);
state.edit_buffer_len -= 1;
state.edit_cursor -= 1;
result.text_changed = true;
}
},
.delete => {
if (state.edit_cursor < state.edit_buffer_len) {
// Eliminar caracter en el cursor
std.mem.copyForwards(
u8,
state.edit_buffer[state.edit_cursor .. state.edit_buffer_len - 1],
state.edit_buffer[state.edit_cursor + 1 .. state.edit_buffer_len],
);
state.edit_buffer_len -= 1;
result.text_changed = true;
}
},
else => {},
}
}
// Procesar texto ingresado (caracteres imprimibles)
const text_input = ctx.input.getTextInput();
if (text_input.len > 0) {
// Insertar caracteres en la posición del cursor
for (text_input) |c| {
if (state.edit_buffer_len < state.edit_buffer.len - 1) {
// Hacer espacio moviendo caracteres hacia la derecha
if (state.edit_cursor < state.edit_buffer_len) {
var i = state.edit_buffer_len;
while (i > state.edit_cursor) : (i -= 1) {
state.edit_buffer[i] = state.edit_buffer[i - 1];
}
}
state.edit_buffer[state.edit_cursor] = c;
state.edit_buffer_len += 1;
state.edit_cursor += 1;
result.text_changed = true;
}
}
}
return result;
}
// =============================================================================
// Tests
// =============================================================================

View file

@ -177,6 +177,13 @@ pub const VirtualAdvancedTableState = struct {
/// 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();
// =========================================================================
@ -713,6 +720,7 @@ pub const VirtualAdvancedTableState = struct {
self.edit_buffer_len = 0;
self.edit_cursor = 0;
self.cell_value_changed = false;
self.row_edit_buffer.clear();
}
// =========================================================================

View file

@ -43,6 +43,7 @@ pub const CellGeometry = types.CellGeometry;
pub const DataProvider = data_provider.DataProvider;
pub const CellEditorColors = cell_editor.CellEditorColors;
pub const CellEditorResult = cell_editor.CellEditorResult;
pub const NavigateDirection = cell_editor.NavigateDirection;
pub const drawCellEditor = cell_editor.drawCellEditor;
pub const VirtualAdvancedTableState = state_mod.VirtualAdvancedTableState;
@ -84,35 +85,57 @@ pub const VirtualAdvancedTableResult = struct {
clicked: bool = false,
// =========================================================================
// Edición CRUD Excel-style
// Edición CRUD Excel-style (commit al abandonar fila)
// =========================================================================
/// Una celda fue modificada (el usuario hizo commit con Enter/Tab)
cell_committed: bool = false,
/// Una fila fue completada (el usuario cambió de fila, tenía cambios pendientes)
/// Cuando es true, row_commit_id y row_changes contienen los datos
row_committed: bool = false,
/// La fila cambió de edición (auto-save requerido)
row_changed: bool = false,
/// ID de la fila que se hizo commit (NEW_ROW_ID = -1 para inserts)
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)
/// Buffer estático que sobrevive el frame
row_changes: [table_core.MAX_PENDING_COLUMNS]table_core.PendingCellChange = undefined,
/// Número de cambios en row_changes
row_changes_count: usize = 0,
/// El usuario canceló edición (Escape 2x = descartar fila)
row_discarded: bool = false,
/// Celda que fue editada (si cell_committed = true)
edited_cell: ?CellId = null,
/// Nuevo valor de la celda (si cell_committed = true)
edited_value: ?[]const u8 = null,
/// Fila anterior (si row_changed = true, para auto-save)
previous_row: ?usize = null,
/// Navegación solicitada después de commit
navigate_direction: cell_editor.CellEditorResult.NavigateDirection = .none,
/// Navegación solicitada después de edición
navigate_direction: cell_editor.NavigateDirection = .none,
/// Tab presionado sin edición activa (pasar focus al siguiente widget)
tab_out: bool = false,
/// Shift estaba presionado con Tab (para tab_out inverso)
tab_shift: bool = false,
// =========================================================================
// Compatibilidad (DEPRECADO - usar row_committed)
// =========================================================================
/// @deprecated: Usar row_committed. Mantenido para compatibilidad.
cell_committed: bool = false,
/// @deprecated
row_changed: bool = false,
/// @deprecated
edited_cell: ?CellId = null,
/// @deprecated
edited_value: ?[]const u8 = null,
/// @deprecated
previous_row: ?usize = null,
/// Obtiene los cambios como slice (helper para compatibilidad)
pub fn getRowChanges(self: *const VirtualAdvancedTableResult) []const table_core.PendingCellChange {
return self.row_changes[0..self.row_changes_count];
}
};
// =============================================================================
@ -286,26 +309,25 @@ pub fn virtualAdvancedTableRect(
const edited_cell = list_state.editing_cell.?;
const new_value = list_state.getEditText();
// Check if row changed (for auto-save)
if (list_state.last_edited_row) |last_row| {
if (edited_cell.row != last_row and list_state.row_dirty) {
result.row_changed = true;
result.previous_row = last_row;
}
}
// Añadir cambio al buffer de fila (NO commit inmediato)
// El commit real se hace cuando el usuario abandona la fila
if (list_state.hasValueChanged()) {
list_state.row_edit_buffer.addChange(edited_cell.col, new_value);
// Commit the edit
if (list_state.commitEdit()) {
// Compatibilidad: mantener flags antiguos
result.cell_committed = true;
result.edited_cell = edited_cell;
result.edited_value = new_value;
}
// Finalizar edición de celda (sin commit a BD)
_ = list_state.commitEdit();
result.navigate_direction = editor_result.navigate;
} else if (editor_result.escaped) {
const action = list_state.handleEscape();
if (action == .discard_row) {
result.row_discarded = true;
list_state.row_edit_buffer.clear();
}
}
}
@ -372,6 +394,33 @@ pub fn virtualAdvancedTableRect(
result.double_click_id = list_state.selected_id;
}
// =========================================================================
// Commit de fila al cambiar de selección
// =========================================================================
// Si la selección cambió y hay cambios pendientes en otra fila, hacer commit
if (list_state.selection_changed and list_state.row_edit_buffer.has_changes) {
const new_row_id = list_state.selected_id orelse table_core.NEW_ROW_ID;
const new_row_idx = list_state.getSelectedRow() orelse 0;
const is_ghost = table_core.isGhostRow(new_row_id);
// checkRowChangeAndCommit compara row_ids y hace commit si son diferentes
if (table_core.checkRowChangeAndCommit(
&list_state.row_edit_buffer,
new_row_id,
new_row_idx,
is_ghost,
&result.row_changes,
)) |commit_info| {
result.row_committed = true;
result.row_commit_id = commit_info.row_id;
result.row_commit_is_insert = commit_info.is_insert;
result.row_changes_count = commit_info.change_count;
// Compatibilidad
result.row_changed = true;
}
}
return result;
}
@ -1017,8 +1066,11 @@ fn handleKeyboard(
},
.tab => {
// Tab sin edición activa: indica que el panel debe mover focus
result.tab_out = true;
result.tab_shift = event.modifiers.shift;
// IMPORTANTE: Solo si CellEditor no procesó Tab (evita doble procesamiento)
if (result.navigate_direction == .none) {
result.tab_out = true;
result.tab_shift = event.modifiers.shift;
}
},
else => {},
}