Cambios principales: - Nuevo FocusSystem unificado en core/focus.zig - Separación registration_group / active_group para multi-panel - Focus implícito para primer widget del grupo activo - Table inicializa selected_row/col a 0 cuando tiene datos - Corregido test navKeyPressed (usaba setKeyState en vez de handleKeyEvent) Bug resuelto: tabla no respondía a teclado sin clic previo Causa: selected_col quedaba en -1, selectedCell() retornaba null Documentación: docs/FOCUS_TRANSITION_2025-12-11.md 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
450 lines
13 KiB
Markdown
450 lines
13 KiB
Markdown
# Rediseño del Sistema de Focus en zcatgui
|
|
|
|
> **Fecha**: 2025-12-12
|
|
> **Estado**: EN IMPLEMENTACIÓN
|
|
> **Consensuado**: Sí
|
|
|
|
---
|
|
|
|
## 1. PROBLEMA DETECTADO
|
|
|
|
### 1.1 Síntomas observados en zsimifactu
|
|
|
|
1. **Navegación errática con flechas**: Pulsar flecha abajo mueve la selección una vez, la segunda vez no hace nada
|
|
2. **Tab afecta widgets equivocados**: Al pulsar Tab en el panel derecho, eventualmente mueve la selección del panel izquierdo
|
|
3. **Focus visual incorrecto**: Al hacer clic en un campo del panel derecho, ambos paneles muestran indicador de focus
|
|
4. **Tab requiere múltiples pulsaciones**: Hay que pulsar Tab 4 veces para que haga efecto
|
|
|
|
### 1.2 Causa raíz
|
|
|
|
zcatgui tenía **dos sistemas de focus paralelos** que no se comunicaban:
|
|
|
|
1. **`FocusManager`** en `widgets/focus.zig`:
|
|
- IDs de tipo `u32`
|
|
- No integrado con Context
|
|
- Usado parcialmente por algunos widgets
|
|
|
|
2. **`FocusGroupManager`** en `core/focus_group.zig`:
|
|
- IDs de tipo `u64`
|
|
- No usado por widgets
|
|
- Diseñado pero no implementado completamente
|
|
|
|
Esta duplicidad causaba:
|
|
- Widgets registrándose en sistemas diferentes
|
|
- Tab procesado inconsistentemente
|
|
- Estado de focus desincronizado
|
|
|
|
---
|
|
|
|
## 2. ANÁLISIS DE REFERENCIAS
|
|
|
|
### 2.1 microui (C) - Referencia principal
|
|
|
|
```c
|
|
// UN solo campo de focus en el contexto
|
|
struct mu_Context {
|
|
mu_Id hover; // Widget bajo el cursor
|
|
mu_Id focus; // Widget con focus de teclado
|
|
mu_Id last_id; // Último ID procesado
|
|
// ...
|
|
};
|
|
|
|
// Generación de ID con hash FNV-1a
|
|
mu_Id mu_get_id(mu_Context *ctx, const void *data, int size);
|
|
|
|
// Flujo:
|
|
// 1. Widget llama mu_update_control(ctx, id, rect)
|
|
// 2. Si mouse_over && click → ctx->focus = id
|
|
// 3. Widget consulta: if (ctx->focus == id) { procesar teclado }
|
|
```
|
|
|
|
**Características clave:**
|
|
- Focus plano (sin grupos)
|
|
- Tab NO implementado (aplicación lo maneja)
|
|
- Simple y funcional
|
|
|
|
### 2.2 Gio (Go) - Referencia moderna
|
|
|
|
```go
|
|
// Focus mediante tags (punteros)
|
|
type FocusCmd struct {
|
|
Tag event.Tag // Solicitar focus
|
|
}
|
|
|
|
type FocusEvent struct {
|
|
Focus bool // Notificación de cambio
|
|
}
|
|
|
|
// El Router maneja Tab automáticamente
|
|
Router.MoveFocus(dir key.FocusDirection)
|
|
```
|
|
|
|
**Características clave:**
|
|
- Focus plano (sin grupos)
|
|
- Tab/Shift+Tab manejado por el Router
|
|
- Widgets se registran con FocusFilter
|
|
|
|
---
|
|
|
|
## 3. DISEÑO CONSENSUADO
|
|
|
|
### 3.1 Principio
|
|
|
|
**"Grupos opcionales con comportamiento por defecto sensato"**
|
|
|
|
- Si la app NO crea grupos → existe un grupo global implícito (grupo 0)
|
|
- Tab navega todos los widgets registrados (como microui/Gio)
|
|
- Si la app crea grupos → Tab navega dentro del grupo activo
|
|
- API de aplicación para cambiar grupo activo (ej: F6)
|
|
|
|
### 3.2 Arquitectura
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ Context │
|
|
├─────────────────────────────────────────────────────────────┤
|
|
│ focus: FocusSystem │
|
|
│ ├── focused_id: ?u64 (widget con focus actual) │
|
|
│ ├── active_group: u64 (grupo activo, default=0) │
|
|
│ ├── groups: [MAX_GROUPS]Group (array de grupos) │
|
|
│ │ └── Group │
|
|
│ │ ├── id: u64 │
|
|
│ │ ├── widgets: [MAX]u64 (IDs registrados) │
|
|
│ │ └── count: usize │
|
|
│ └── hover_id: ?u64 (widget bajo cursor) │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### 3.3 Flujo de operación
|
|
|
|
```
|
|
INICIO DE FRAME (beginFrame):
|
|
1. Limpiar registros de widgets de todos los grupos
|
|
2. Mantener focused_id y active_group
|
|
|
|
DURANTE EL FRAME (widgets dibujándose):
|
|
1. Widget genera ID: @intFromPtr(&state) o hash
|
|
2. Widget llama ctx.focus.register(id)
|
|
→ Se registra en el grupo activo
|
|
3. Widget consulta ctx.focus.hasFocus(id)
|
|
→ true si focused_id == id
|
|
4. Si widget recibe clic → ctx.focus.request(id)
|
|
→ focused_id = id
|
|
|
|
PROCESAMIENTO DE TAB:
|
|
1. App llama ctx.focus.handleTab(shift)
|
|
2. Sistema busca siguiente widget en grupo activo
|
|
3. focused_id = siguiente widget
|
|
|
|
FIN DE FRAME (endFrame):
|
|
1. Si focused_id no está en ningún grupo → focused_id = null
|
|
2. (Limpieza de widgets no re-registrados)
|
|
```
|
|
|
|
### 3.4 API pública
|
|
|
|
```zig
|
|
pub const FocusSystem = struct {
|
|
// Estado
|
|
focused_id: ?u64 = null,
|
|
hover_id: ?u64 = null,
|
|
active_group: u64 = 0,
|
|
|
|
// Grupos (grupo 0 = global implícito)
|
|
groups: [MAX_GROUPS]FocusGroup = undefined,
|
|
group_count: usize = 1, // Siempre existe grupo 0
|
|
|
|
// ─────────────────────────────────────────────
|
|
// API para widgets
|
|
// ─────────────────────────────────────────────
|
|
|
|
/// Registrar widget como focusable (en grupo activo)
|
|
pub fn register(self: *FocusSystem, widget_id: u64) void;
|
|
|
|
/// ¿Este widget tiene focus?
|
|
pub fn hasFocus(self: *FocusSystem, widget_id: u64) bool;
|
|
|
|
/// Solicitar focus para este widget
|
|
pub fn request(self: *FocusSystem, widget_id: u64) void;
|
|
|
|
/// ¿Este widget tiene hover?
|
|
pub fn hasHover(self: *FocusSystem, widget_id: u64) bool;
|
|
|
|
/// Establecer hover
|
|
pub fn setHover(self: *FocusSystem, widget_id: u64) void;
|
|
|
|
// ─────────────────────────────────────────────
|
|
// API para aplicación
|
|
// ─────────────────────────────────────────────
|
|
|
|
/// Crear un nuevo grupo de focus
|
|
pub fn createGroup(self: *FocusSystem, group_id: u64) void;
|
|
|
|
/// Activar un grupo (Tab navegará dentro de él)
|
|
pub fn setActiveGroup(self: *FocusSystem, group_id: u64) void;
|
|
|
|
/// Obtener grupo activo
|
|
pub fn getActiveGroup(self: *FocusSystem) u64;
|
|
|
|
/// Mover focus al siguiente/anterior widget (Tab/Shift+Tab)
|
|
pub fn focusNext(self: *FocusSystem) void;
|
|
pub fn focusPrev(self: *FocusSystem) void;
|
|
|
|
/// Limpiar focus
|
|
pub fn clearFocus(self: *FocusSystem) void;
|
|
|
|
// ─────────────────────────────────────────────
|
|
// API interna (llamada por Context)
|
|
// ─────────────────────────────────────────────
|
|
|
|
/// Llamado al inicio de frame
|
|
pub fn beginFrame(self: *FocusSystem) void;
|
|
|
|
/// Llamado al final de frame
|
|
pub fn endFrame(self: *FocusSystem) void;
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## 4. PLAN DE IMPLEMENTACIÓN
|
|
|
|
### 4.1 Fase 1: Nuevo FocusSystem en core/
|
|
|
|
1. Crear `core/focus.zig` con `FocusSystem`
|
|
2. Estructura simple y autocontenida
|
|
3. Tests unitarios
|
|
|
|
### 4.2 Fase 2: Integrar en Context
|
|
|
|
1. Reemplazar campos actuales por `focus: FocusSystem`
|
|
2. Context delega a FocusSystem
|
|
3. beginFrame/endFrame llaman a focus.beginFrame/endFrame
|
|
|
|
### 4.3 Fase 3: Adaptar widgets
|
|
|
|
1. **TextInput**: usar `ctx.focus.register()`, `ctx.focus.hasFocus()`, `ctx.focus.request()`
|
|
2. **Table**: igual
|
|
3. **Otros widgets focusables**: igual
|
|
|
|
### 4.4 Fase 4: Eliminar código obsoleto
|
|
|
|
1. Eliminar `widgets/focus.zig` (FocusManager viejo)
|
|
2. Eliminar `core/focus_group.zig` (reemplazado por nuevo sistema)
|
|
3. Limpiar imports en `widgets.zig` y `zcatgui.zig`
|
|
|
|
### 4.5 Fase 5: Adaptar zsimifactu
|
|
|
|
1. Crear grupos de focus para cada panel
|
|
2. Activar grupo antes de dibujar cada panel
|
|
3. Manejar F6 para cambiar entre grupos
|
|
|
|
---
|
|
|
|
## 5. DETALLES DE IMPLEMENTACIÓN
|
|
|
|
### 5.1 Generación de IDs
|
|
|
|
Los widgets generan IDs únicos usando la dirección de memoria de su estado:
|
|
|
|
```zig
|
|
// En TextInput
|
|
const widget_id: u64 = @intFromPtr(state);
|
|
|
|
// En Table
|
|
const widget_id: u64 = @intFromPtr(state);
|
|
```
|
|
|
|
Esto garantiza unicidad sin necesidad de strings o hashes.
|
|
|
|
### 5.2 Grupo global implícito
|
|
|
|
El grupo 0 siempre existe y es el default:
|
|
|
|
```zig
|
|
pub fn init() FocusSystem {
|
|
var self = FocusSystem{};
|
|
// Grupo 0 siempre existe
|
|
self.groups[0] = FocusGroup.init(0);
|
|
self.group_count = 1;
|
|
return self;
|
|
}
|
|
```
|
|
|
|
### 5.3 Navegación Tab
|
|
|
|
```zig
|
|
pub fn focusNext(self: *FocusSystem) void {
|
|
const group = &self.groups[self.findGroupIndex(self.active_group)];
|
|
if (group.count == 0) return;
|
|
|
|
// Encontrar índice actual
|
|
const current_idx = if (self.focused_id) |fid|
|
|
group.indexOf(fid) orelse 0
|
|
else
|
|
group.count - 1; // Para que next sea 0
|
|
|
|
// Siguiente (circular)
|
|
const next_idx = (current_idx + 1) % group.count;
|
|
self.focused_id = group.widgets[next_idx];
|
|
}
|
|
```
|
|
|
|
### 5.4 Limpieza en endFrame
|
|
|
|
Los widgets que no se re-registran en un frame se consideran "desaparecidos".
|
|
Si el widget con focus desaparece, se limpia el focus:
|
|
|
|
```zig
|
|
pub fn endFrame(self: *FocusSystem) void {
|
|
// Si el widget con focus no está registrado en ningún grupo, limpiar
|
|
if (self.focused_id) |fid| {
|
|
var found = false;
|
|
for (self.groups[0..self.group_count]) |group| {
|
|
if (group.contains(fid)) {
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!found) {
|
|
self.focused_id = null;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 6. MIGRACIÓN
|
|
|
|
### 6.1 Cambios en Context
|
|
|
|
Antes:
|
|
```zig
|
|
pub const Context = struct {
|
|
focus: FocusManager, // Viejo
|
|
focus_groups: FocusGroupManager, // Viejo
|
|
// ...
|
|
};
|
|
```
|
|
|
|
Después:
|
|
```zig
|
|
pub const Context = struct {
|
|
focus: FocusSystem, // Nuevo, unificado
|
|
// ...
|
|
};
|
|
```
|
|
|
|
### 6.2 Cambios en widgets
|
|
|
|
Antes:
|
|
```zig
|
|
// TextInput
|
|
const widget_id = ctx.getId(state.buffer.ptr[0..1]);
|
|
ctx.focus.setFocus(widget_id);
|
|
const has_focus = ctx.focus.focused_id == widget_id;
|
|
```
|
|
|
|
Después:
|
|
```zig
|
|
// TextInput
|
|
const widget_id: u64 = @intFromPtr(state);
|
|
ctx.focus.register(widget_id);
|
|
if (clicked) ctx.focus.request(widget_id);
|
|
const has_focus = ctx.focus.hasFocus(widget_id);
|
|
```
|
|
|
|
### 6.3 Cambios en aplicación (zsimifactu)
|
|
|
|
```zig
|
|
// Crear grupos al inicio
|
|
ctx.focus.createGroup(1); // Panel lista
|
|
ctx.focus.createGroup(2); // Panel detalle
|
|
|
|
// En el render loop, antes de cada panel:
|
|
ctx.focus.setActiveGroup(1);
|
|
who_list_panel.draw(...);
|
|
|
|
ctx.focus.setActiveGroup(2);
|
|
who_detail_panel.draw(...);
|
|
|
|
// F6 para cambiar panel
|
|
if (key == .f6) {
|
|
const next_group = if (ctx.focus.getActiveGroup() == 1) 2 else 1;
|
|
ctx.focus.setActiveGroup(next_group);
|
|
}
|
|
|
|
// Tab manejado automáticamente por Context
|
|
```
|
|
|
|
---
|
|
|
|
## 7. TESTING
|
|
|
|
### 7.1 Tests unitarios (core/focus.zig)
|
|
|
|
- [ ] FocusSystem.init() crea grupo 0
|
|
- [ ] register() añade widget al grupo activo
|
|
- [ ] hasFocus() retorna true/false correctamente
|
|
- [ ] request() cambia focused_id
|
|
- [ ] focusNext() navega circularmente
|
|
- [ ] focusPrev() navega circularmente inverso
|
|
- [ ] createGroup() añade grupos
|
|
- [ ] setActiveGroup() cambia grupo activo
|
|
- [ ] endFrame() limpia focus de widgets no registrados
|
|
|
|
### 7.2 Tests de integración (zsimifactu)
|
|
|
|
- [ ] Tab navega entre campos del panel activo
|
|
- [ ] Tab NO afecta widgets de otros paneles
|
|
- [ ] F6 cambia entre paneles
|
|
- [ ] Clic en widget cambia focus a ese widget
|
|
- [ ] Un solo indicador visual de focus a la vez
|
|
|
|
---
|
|
|
|
## 8. PROBLEMA SECUNDARIO: REPINTADO
|
|
|
|
### 8.1 Síntomas
|
|
|
|
- Ventana no se refresca al volver a primer plano (Alt+Tab)
|
|
- Partes de la ventana quedan sin pintar si estaban tapadas
|
|
- El programa parece "colgado" pero solo está sin repintar
|
|
|
|
### 8.2 Causa
|
|
|
|
Las optimizaciones de CPU (SDL_WaitEventTimeout) son demasiado agresivas.
|
|
No se detectan eventos de ventana como:
|
|
- `SDL_WINDOWEVENT_EXPOSED` - Ventana necesita repintarse
|
|
- `SDL_WINDOWEVENT_FOCUS_GAINED` - Ventana ganó focus del sistema
|
|
|
|
### 8.3 Solución
|
|
|
|
Añadir manejo de estos eventos en el backend SDL2:
|
|
|
|
```zig
|
|
// En el event loop
|
|
.windowevent => |we| {
|
|
switch (we.event) {
|
|
SDL_WINDOWEVENT_EXPOSED,
|
|
SDL_WINDOWEVENT_FOCUS_GAINED,
|
|
SDL_WINDOWEVENT_RESTORED,
|
|
SDL_WINDOWEVENT_SHOWN => {
|
|
needs_redraw = true;
|
|
},
|
|
// ...
|
|
}
|
|
}
|
|
```
|
|
|
|
**Nota**: Este problema se abordará DESPUÉS de arreglar el sistema de focus.
|
|
|
|
---
|
|
|
|
## 9. REFERENCIAS
|
|
|
|
- [microui source](https://github.com/rxi/microui)
|
|
- [Gio input architecture](https://gioui.org/doc/architecture/input)
|
|
- [Gio focus newsletter](https://gioui.org/news/2022-02)
|
|
- Conversación de diseño: 2025-12-11/12
|