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>
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
- Navegación errática con flechas: Pulsar flecha abajo mueve la selección una vez, la segunda vez no hace nada
- Tab afecta widgets equivocados: Al pulsar Tab en el panel derecho, eventualmente mueve la selección del panel izquierdo
- Focus visual incorrecto: Al hacer clic en un campo del panel derecho, ambos paneles muestran indicador de focus
- 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:
-
FocusManagerenwidgets/focus.zig:- IDs de tipo
u32 - No integrado con Context
- Usado parcialmente por algunos widgets
- IDs de tipo
-
FocusGroupManagerencore/focus_group.zig:- IDs de tipo
u64 - No usado por widgets
- Diseñado pero no implementado completamente
- IDs de tipo
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/
- Crear
core/focus.zigconFocusSystem - Estructura simple y autocontenida
- Tests unitarios
4.2 Fase 2: Integrar en Context
- Reemplazar campos actuales por
focus: FocusSystem - Context delega a FocusSystem
- beginFrame/endFrame llaman a focus.beginFrame/endFrame
4.3 Fase 3: Adaptar widgets
- TextInput: usar
ctx.focus.register(),ctx.focus.hasFocus(),ctx.focus.request() - Table: igual
- Otros widgets focusables: igual
4.4 Fase 4: Eliminar código obsoleto
- Eliminar
widgets/focus.zig(FocusManager viejo) - Eliminar
core/focus_group.zig(reemplazado por nuevo sistema) - Limpiar imports en
widgets.zigyzcatgui.zig
4.5 Fase 5: Adaptar zsimifactu
- Crear grupos de focus para cada panel
- Activar grupo antes de dibujar cada panel
- 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 repintarseSDL_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
- microui source
- Gio input architecture
- Gio focus newsletter
- Conversación de diseño: 2025-12-11/12