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

13 KiB

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

// 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

// 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

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:

// 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:

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

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:

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:

pub const Context = struct {
    focus: FocusManager,        // Viejo
    focus_groups: FocusGroupManager,  // Viejo
    // ...
};

Después:

pub const Context = struct {
    focus: FocusSystem,  // Nuevo, unificado
    // ...
};

6.2 Cambios en widgets

Antes:

// 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:

// 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)

// 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:

// 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