zcatgui/docs/FOCUS_SYSTEM_REDESIGN.md
reugenio 7cde6370d8 fix: Sistema de focus rediseñado y funcionando
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>
2025-12-11 17:55:08 +01:00

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