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, /// Idle timeout for cursor blinking (ms). After this time without input,
/// cursor becomes solid and no animation frames are needed. /// 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. /// Cursor blink period (ms). Cursor toggles visibility at this rate.
/// 300ms = ~3.3 blinks/sec (faster for better editing feedback) /// 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 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 // ID Management
// ========================================================================= // =========================================================================

View file

@ -376,22 +376,8 @@ pub fn textInputRect(
const cursor_x = inner.x + @as(i32, @intCast(cursor_offset)); const cursor_x = inner.x + @as(i32, @intCast(cursor_offset));
const cursor_color = theme.foreground; const cursor_color = theme.foreground;
// Determine if cursor should be visible // Visibilidad del cursor usando función compartida de Context
const cursor_visible = blk: { const cursor_visible = ctx.isCursorVisible(ctx.last_input_time_ms);
// 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;
}
};
if (cursor_visible) { if (cursor_visible) {
ctx.pushCommand(Command.rect( ctx.pushCommand(Command.rect(

View file

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

View file

@ -63,6 +63,9 @@ pub const VirtualAdvancedTableState = struct {
/// Offset donde empieza la ventana actual /// Offset donde empieza la ventana actual
window_start: usize = 0, window_start: usize = 0,
/// Forzar refetch en próximo frame (después de edición)
needs_window_refresh: bool = false,
// ========================================================================= // =========================================================================
// Estado de carga // Estado de carga
// ========================================================================= // =========================================================================
@ -167,6 +170,9 @@ pub const VirtualAdvancedTableState = struct {
edit_buffer_len: usize = 0, edit_buffer_len: usize = 0,
edit_cursor: 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 /// Flag: celda requiere commit al terminar edición
cell_value_changed: bool = false, 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 // Navegación horizontal
// ========================================================================= // =========================================================================
@ -510,7 +522,7 @@ pub const VirtualAdvancedTableState = struct {
/// Inicia edición de una celda /// Inicia edición de una celda
/// initial_char: si viene de tecla alfanumérica, el caracter inicial (null = mostrar valor actual) /// 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) // Guardar valor original (para Escape)
const len = @min(current_value.len, self.original_value.len); const len = @min(current_value.len, self.original_value.len);
@memcpy(self.original_value[0..len], current_value[0..len]); @memcpy(self.original_value[0..len], current_value[0..len]);
@ -531,6 +543,7 @@ pub const VirtualAdvancedTableState = struct {
self.editing_cell = cell; self.editing_cell = cell;
self.escape_count = 0; self.escape_count = 0;
self.last_edit_time_ms = current_time_ms;
self.cell_value_changed = false; self.cell_value_changed = false;
} }
@ -567,11 +580,10 @@ pub const VirtualAdvancedTableState = struct {
if (changed) { if (changed) {
self.row_dirty = true; self.row_dirty = true;
self.cell_value_changed = true; self.cell_value_changed = true;
// Solo actualizar última fila editada si hubo cambios reales
self.last_edited_row = self.editing_cell.?.row;
} }
// Actualizar última fila editada
self.last_edited_row = self.editing_cell.?.row;
self.editing_cell = null; self.editing_cell = null;
self.escape_count = 0; self.escape_count = 0;
return changed; return changed;

View file

@ -107,6 +107,12 @@ pub const VirtualAdvancedTableResult = struct {
/// Navegación solicitada después de commit /// Navegación solicitada después de commit
navigate_direction: cell_editor.CellEditorResult.NavigateDirection = .none, 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, header_h,
filter_bar_h, filter_bar_h,
)) |geom| { )) |geom| {
// Draw cell editor // Draw cell editor con colores del panel
const editor_result = cell_editor.drawCellEditor( const editor_result = cell_editor.drawCellEditor(
ctx, ctx,
list_state, list_state,
geom, geom,
cell_editor.CellEditorColors{}, cell_editor.CellEditorColors{
.background = colors.background,
.text = colors.text,
.cursor = colors.text, // Cursor mismo color que texto
},
); );
// Handle editor results // Handle editor results
@ -369,7 +379,13 @@ pub fn virtualAdvancedTableRect(
// Helper: Check if refetch needed // 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 // First load
if (list_state.current_window.len == 0) return true; if (list_state.current_window.len == 0) return true;
@ -762,10 +778,11 @@ fn drawRows(
// Draw active cell indicator BEFORE text // Draw active cell indicator BEFORE text
if (is_active_cell) { 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{ const tc_colors = table_core.TableColors{
.selected_cell = colors.row_selected, // Usar color de selección pero más claro // Blanco/cyan brillante para máximo contraste
.selected_cell_unfocus = colors.row_selected_unfocus, .selected_cell = Style.Color.rgb(100, 200, 255),
.selected_cell_unfocus = Style.Color.rgb(150, 150, 160),
.border = colors.border, .border = colors.border,
}; };
table_core.drawCellActiveIndicator( table_core.drawCellActiveIndicator(
@ -984,6 +1001,7 @@ fn handleKeyboard(
} }
// F2 o Space: iniciar edición de celda activa // 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| { for (ctx.input.getKeyEvents()) |event| {
if (!event.pressed) continue; if (!event.pressed) continue;
@ -997,6 +1015,11 @@ fn handleKeyboard(
result.edited_cell = cell; 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 => {}, else => {},
} }
} }
@ -1006,7 +1029,7 @@ fn handleKeyboard(
if (char_input.len > 0 and !list_state.isEditing()) { if (char_input.len > 0 and !list_state.isEditing()) {
if (list_state.getActiveCell()) |cell| { if (list_state.getActiveCell()) |cell| {
// Iniciar edición con el primer caracter // 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);
} }
} }
} }