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:
parent
47fc5b28f7
commit
702c33c13a
5 changed files with 85 additions and 36 deletions
|
|
@ -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
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,10 +580,9 @@ 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
|
||||||
|
|
||||||
// Actualizar última fila editada
|
|
||||||
self.last_edited_row = self.editing_cell.?.row;
|
self.last_edited_row = self.editing_cell.?.row;
|
||||||
|
}
|
||||||
|
|
||||||
self.editing_cell = null;
|
self.editing_cell = null;
|
||||||
self.escape_count = 0;
|
self.escape_count = 0;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue