feat(virtual_table): Tab navigation + cursor fixes + cell editing

- Tab/Shift+Tab with wrap navigation in editing mode
- tab_out/tab_shift result fields for application handling
- Cursor position, blinking, background color fixes
- markRowSaved() to clear dirty state after save
- last_edited_row only set when actual changes made
This commit is contained in:
reugenio 2025-12-26 17:55:12 +01:00
parent 47fc5b28f7
commit 702c33c13a
5 changed files with 85 additions and 36 deletions

View file

@ -111,7 +111,7 @@ pub const Context = struct {
/// Idle timeout for cursor blinking (ms). After this time without input,
/// cursor becomes solid and no animation frames are needed.
pub const CURSOR_IDLE_TIMEOUT_MS: u64 = 5000;
pub const CURSOR_IDLE_TIMEOUT_MS: u64 = 20000;
/// Cursor blink period (ms). Cursor toggles visibility at this rate.
/// 300ms = ~3.3 blinks/sec (faster for better editing feedback)
@ -363,6 +363,26 @@ pub const Context = struct {
return @intCast(@max(time_until_toggle, 16)); // Minimum 16ms to avoid busy loop
}
/// Determina si el cursor debe ser visible basado en tiempo de actividad.
/// Usa parpadeo mientras hay actividad reciente, sólido cuando está idle.
/// @param last_activity_time_ms: Última vez que hubo actividad (edición, input)
/// @return true si el cursor debe dibujarse visible
pub fn isCursorVisible(self: Self, last_activity_time_ms: u64) bool {
// Si no hay timing disponible, mostrar cursor siempre
if (self.current_time_ms == 0) return true;
// Calcular tiempo idle desde última actividad
const idle_time = self.current_time_ms -| last_activity_time_ms;
if (idle_time >= CURSOR_IDLE_TIMEOUT_MS) {
// Idle: cursor siempre visible (sin parpadeo, ahorra batería)
return true;
} else {
// Activo: cursor parpadea
return (self.current_time_ms / CURSOR_BLINK_PERIOD_MS) % 2 == 0;
}
}
// =========================================================================
// ID Management
// =========================================================================

View file

@ -376,22 +376,8 @@ pub fn textInputRect(
const cursor_x = inner.x + @as(i32, @intCast(cursor_offset));
const cursor_color = theme.foreground;
// Determine if cursor should be visible
const cursor_visible = blk: {
// If no timing available, always show cursor
if (ctx.current_time_ms == 0) break :blk true;
// Check idle time (time since last user input)
const idle_time = ctx.current_time_ms -| ctx.last_input_time_ms;
if (idle_time >= Context.CURSOR_IDLE_TIMEOUT_MS) {
// Idle: cursor always visible (solid, no blink)
break :blk true;
} else {
// Active: cursor blinks
break :blk (ctx.current_time_ms / Context.CURSOR_BLINK_PERIOD_MS) % 2 == 0;
}
};
// Visibilidad del cursor usando función compartida de Context
const cursor_visible = ctx.isCursorVisible(ctx.last_input_time_ms);
if (cursor_visible) {
ctx.pushCommand(Command.rect(

View file

@ -81,24 +81,27 @@ pub fn drawCellEditor(
// Texto actual
const text = state.getEditText();
const text_y = geom.y + @as(i32, @intCast((geom.h -| 16) / 2)); // Centrado vertical
ctx.pushCommand(Command.text(
geom.x + padding,
geom.y + padding,
text_y,
text,
colors.text,
));
// Cursor (línea vertical parpadeante)
// Calcular posición X del cursor basado en edit_cursor
const cursor_x = geom.x + padding + @as(i32, @intCast(state.edit_cursor * 7)); // ~7px por caracter (monospace)
const cursor_visible = (state.frame_count / 30) % 2 == 0; // Parpadeo cada 30 frames
// Cursor: posición calculada con measureTextToCursor (TTF-aware)
const cursor_offset = ctx.measureTextToCursor(text, state.edit_cursor);
const cursor_x = geom.x + padding + @as(i32, @intCast(cursor_offset));
// Visibilidad del cursor usando función compartida de Context
const cursor_visible = ctx.isCursorVisible(state.last_edit_time_ms);
if (cursor_visible) {
ctx.pushCommand(Command.rect(
cursor_x,
geom.y + padding,
1, // 1px de ancho
geom.h - (padding * 2),
text_y,
2, // 2px de ancho (más visible)
16, // Altura del texto
colors.cursor,
));
}
@ -106,6 +109,11 @@ pub fn drawCellEditor(
// Procesar input de teclado
result = handleCellEditorInput(ctx, state);
// Actualizar tiempo de última edición si hubo cambios
if (result.text_changed) {
state.last_edit_time_ms = ctx.current_time_ms;
}
return result;
}

View file

@ -63,6 +63,9 @@ pub const VirtualAdvancedTableState = struct {
/// 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
// =========================================================================
@ -167,6 +170,9 @@ pub const VirtualAdvancedTableState = struct {
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,
@ -430,6 +436,12 @@ pub const VirtualAdvancedTableState = struct {
}
}
/// 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
// =========================================================================
@ -510,7 +522,7 @@ pub const VirtualAdvancedTableState = struct {
/// 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) void {
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]);
@ -531,6 +543,7 @@ pub const VirtualAdvancedTableState = struct {
self.editing_cell = cell;
self.escape_count = 0;
self.last_edit_time_ms = current_time_ms;
self.cell_value_changed = false;
}
@ -567,10 +580,9 @@ pub const VirtualAdvancedTableState = struct {
if (changed) {
self.row_dirty = true;
self.cell_value_changed = true;
}
// Actualizar última fila editada
// 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;

View file

@ -107,6 +107,12 @@ pub const VirtualAdvancedTableResult = struct {
/// Navegación solicitada después de commit
navigate_direction: cell_editor.CellEditorResult.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,
};
// =============================================================================
@ -263,12 +269,16 @@ pub fn virtualAdvancedTableRect(
header_h,
filter_bar_h,
)) |geom| {
// Draw cell editor
// Draw cell editor con colores del panel
const editor_result = cell_editor.drawCellEditor(
ctx,
list_state,
geom,
cell_editor.CellEditorColors{},
cell_editor.CellEditorColors{
.background = colors.background,
.text = colors.text,
.cursor = colors.text, // Cursor mismo color que texto
},
);
// Handle editor results
@ -369,7 +379,13 @@ pub fn virtualAdvancedTableRect(
// Helper: Check if refetch needed
// =============================================================================
fn needsRefetch(list_state: *const VirtualAdvancedTableState, visible_rows: usize, buffer_size: usize) bool {
fn needsRefetch(list_state: *VirtualAdvancedTableState, visible_rows: usize, buffer_size: usize) bool {
// Manual invalidation (después de editar datos)
if (list_state.needs_window_refresh) {
list_state.needs_window_refresh = false;
return true;
}
// First load
if (list_state.current_window.len == 0) return true;
@ -762,10 +778,11 @@ fn drawRows(
// Draw active cell indicator BEFORE text
if (is_active_cell) {
// Convertir colores del config a TableColors para table_core
// Usar colores contrastantes para el indicador
const tc_colors = table_core.TableColors{
.selected_cell = colors.row_selected, // Usar color de selección pero más claro
.selected_cell_unfocus = colors.row_selected_unfocus,
// Blanco/cyan brillante para máximo contraste
.selected_cell = Style.Color.rgb(100, 200, 255),
.selected_cell_unfocus = Style.Color.rgb(150, 150, 160),
.border = colors.border,
};
table_core.drawCellActiveIndicator(
@ -984,6 +1001,7 @@ fn handleKeyboard(
}
// F2 o Space: iniciar edición de celda activa
// Tab: pasar focus al siguiente widget (solo si NO estamos editando)
for (ctx.input.getKeyEvents()) |event| {
if (!event.pressed) continue;
@ -997,6 +1015,11 @@ fn handleKeyboard(
result.edited_cell = cell;
}
},
.tab => {
// Tab sin edición activa: indica que el panel debe mover focus
result.tab_out = true;
result.tab_shift = event.modifiers.shift;
},
else => {},
}
}
@ -1006,7 +1029,7 @@ fn handleKeyboard(
if (char_input.len > 0 and !list_state.isEditing()) {
if (list_state.getActiveCell()) |cell| {
// Iniciar edición con el primer caracter
list_state.startEditing(cell, "", char_input[0]);
list_state.startEditing(cell, "", char_input[0], ctx.current_time_ms);
}
}
}