From 7cde6370d800c51d4eb6d933406a25f9897c0c4b Mon Sep 17 00:00:00 2001 From: reugenio Date: Thu, 11 Dec 2025 17:55:08 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20Sistema=20de=20focus=20redise=C3=B1ado?= =?UTF-8?q?=20y=20funcionando?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 80 ++-- docs/FOCUS_SYSTEM_REDESIGN.md | 450 +++++++++++++++++++ docs/FOCUS_TRANSITION_2025-12-11.md | 148 +++++++ src/backend/backend.zig | 4 + src/backend/sdl2.zig | 27 +- src/core/context.zig | 247 ++++++----- src/core/focus.zig | 666 ++++++++++++++++++++++++++++ src/core/focus_group.zig | 416 ----------------- src/core/input.zig | 7 +- src/widgets/focus.zig | 282 ------------ src/widgets/table.zig | 47 +- src/zcatgui.zig | 6 +- 12 files changed, 1511 insertions(+), 869 deletions(-) create mode 100644 docs/FOCUS_SYSTEM_REDESIGN.md create mode 100644 docs/FOCUS_TRANSITION_2025-12-11.md create mode 100644 src/core/focus.zig delete mode 100644 src/core/focus_group.zig delete mode 100644 src/widgets/focus.zig diff --git a/CLAUDE.md b/CLAUDE.md index c5d8c3e..0c31da3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -685,53 +685,69 @@ cd /mnt/cello2/arno/re/recode/zig/zcatgui --- -## TRABAJO EN PROGRESO: UNIFICACIÓN SISTEMA DE FOCUS (2025-12-11) +## TAREA PENDIENTE PRIORITARIA: SISTEMA DE FOCUS (2025-12-11) -### Problema detectado +> **IMPORTANTE**: Esta seccion describe trabajo incompleto que DEBE completarse. +> La sesion anterior hizo multiples intentos sin exito. Se requiere un analisis +> profundo antes de hacer mas cambios. -zcatgui tenía **dos sistemas de focus paralelos** que no se comunicaban: +### Documento de transicion (LEER PRIMERO) -1. **`FocusManager`** en `widgets/focus.zig` - Usaba IDs u32, no integrado con Context -2. **`FocusGroupManager`** en `core/focus_group.zig` - Usaba IDs u64, no usado por widgets +``` +/mnt/cello2/arno/re/recode/zig/zcatgui/docs/FOCUS_TRANSITION_2025-12-11.md +``` -Esto causaba que el Tab no funcionara y los clics no cambiaran el focus correctamente. +### Resumen del problema -### Solución consensuada +El sistema de focus fue rediseñado (unificado de dos sistemas a uno) pero **NO FUNCIONA**: -1. **Eliminar duplicidad**: Usar SOLO `FocusGroupManager` como única fuente de verdad -2. **Integrar en Context**: Context expone métodos para crear grupos, registrar widgets, manejar focus -3. **Widgets auto-registran**: TextInput, Table, etc. se registran automáticamente en el grupo activo -4. **Grupos por panel**: Cada panel de la aplicación tiene su propio grupo de focus +1. Al iniciar app, ambos paneles muestran focus visual +2. Teclado no responde hasta hacer clic (flechas, Tab) +3. Despues de clic en panel derecho, flechas siguen moviendo tabla izquierda -### Cambios realizados +### Cambios realizados en esta sesion -**`core/context.zig`**: -- Eliminado `FocusManager`, ahora usa `FocusGroupManager` -- Añadidos métodos: `createFocusGroup()`, `setActiveFocusGroup()`, `hasFocus()`, `requestFocus()`, `registerFocusable()`, `handleTabKey()` -- Los widgets se registran en el grupo activo cuando se dibujan +**Archivos creados**: +- `core/focus.zig` - Nuevo FocusSystem unificado -**`widgets/text_input.zig`**: -- Usa `@intFromPtr(state.buffer.ptr)` para ID único (u64) -- Llama `ctx.registerFocusable(widget_id)` al dibujarse -- Llama `ctx.requestFocus(widget_id)` al recibir clic -- Usa `ctx.hasFocus(widget_id)` para determinar estado visual +**Archivos eliminados**: +- `widgets/focus.zig` - FocusManager viejo +- `core/focus_group.zig` - FocusGroupManager viejo -**`widgets/table.zig`**: -- Ahora se registra como widget focusable -- Usa `@intFromPtr(state)` para ID único +**Archivos modificados**: +- `core/context.zig` - Usa FocusSystem, metodos de conveniencia +- `widgets/table.zig` - Añadido `handle_tab` config, usa ctx.hasFocus() +- `widgets/text_input.zig` - Usa ctx.hasFocus(), ctx.requestFocus() +- `zcatgui.zig` - Exporta FocusSystem, FocusGroup +- `backend/backend.zig` - Nuevo evento window_exposed +- `backend/sdl2.zig` - Emite window_exposed -**`widgets/widgets.zig`** y **`zcatgui.zig`**: -- Eliminadas referencias a `focus.zig`, `FocusManager`, `FocusRing` +### Lo que funciona -### Comportamiento esperado +- Repintado al volver de Alt+Tab +- Navegacion por tabla post-clic +- Tab entre TextInputs post-clic +- Compilacion sin errores -- **Tab**: Navega entre widgets DENTRO del grupo de focus activo -- **F6** (o similar): Cambia entre grupos de focus (paneles) -- **Click**: Activa el grupo que contiene el widget clickeado +### Lo que NO funciona -### Estado: EN PROGRESO +- Focus inicial automatico +- Teclado antes de primer clic +- Focus visual exclusivo (ambos paneles lo muestran) +- Aislamiento de grupos (flechas afectan tabla aunque focus este en otro panel) -El sistema compila pero requiere más testing y posibles ajustes. +### Hipotesis del bug + +Ver documento de transicion para hipotesis detalladas. Resumen: +1. Cambio de `active_group` durante draw rompe logica +2. Focus visual no sincronizado con estado real +3. Table procesa teclado independientemente +4. Widgets no se registran en grupo correcto + +### Regla para continuar + +**Analizar primero, planificar despues, implementar al final.** +NO hacer cambios incrementales sin entender la causa raiz. --- diff --git a/docs/FOCUS_SYSTEM_REDESIGN.md b/docs/FOCUS_SYSTEM_REDESIGN.md new file mode 100644 index 0000000..eb5627c --- /dev/null +++ b/docs/FOCUS_SYSTEM_REDESIGN.md @@ -0,0 +1,450 @@ +# 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 diff --git a/docs/FOCUS_TRANSITION_2025-12-11.md b/docs/FOCUS_TRANSITION_2025-12-11.md new file mode 100644 index 0000000..3c1c908 --- /dev/null +++ b/docs/FOCUS_TRANSITION_2025-12-11.md @@ -0,0 +1,148 @@ +# Sistema de Focus - Resolucion Final (2025-12-11) + +> **ESTADO**: RESUELTO +> **Fecha resolucion**: 2025-12-11 ~19:00 +> **Probado en**: zsimifactu (tabla lista clientes + panel detalle) + +--- + +## RESUMEN EJECUTIVO + +El sistema de focus de zcatgui tenia dos bugs que impedian la navegacion por teclado al iniciar una aplicacion: + +1. **Bug 1**: `selected_col` se inicializaba a -1, causando que `selectedCell()` retornara null +2. **Bug 2**: Sin celda seleccionada valida, los cambios de seleccion no se propagaban + +**Solucion**: Inicializar `selected_row` y `selected_col` a 0 cuando la tabla tiene datos. + +--- + +## ANALISIS DEL PROBLEMA + +### Sintoma +- Al iniciar el programa, la tabla mostraba focus visual (borde azul) +- Las flechas del teclado NO movian la seleccion +- Al hacer clic con raton, todo funcionaba correctamente + +### Investigacion (debug prints) +``` +TABLE handleKeyboard: nav=down, selected_row=0 + DOWN: MOVED to row 1 +AFTER tableRect[frame=7]: selection_changed=true, selected_row=1, selected_col=-1 +SELECTION_CHANGED[frame=7]: selectedCell() returned null! <-- AQUI EL BUG +SYNC[frame=8]: DM idx=0, table row=1 -> forcing to 0 <-- DM no se actualizo +``` + +### Causa raiz +1. `TableState` inicializa `selected_row = -1` y `selected_col = -1` +2. El teclado solo modifica `selected_row`, no `selected_col` +3. `selectedCell()` retorna `null` si cualquiera es < 0 +4. Sin celda valida, el DataManager no se notifica del cambio +5. En el siguiente frame, el SYNC del DataManager restaura `selected_row` a 0 + +### Por que funcionaba con clic +El clic llama a `selectCell(row, col)` que inicializa AMBOS valores correctamente. + +--- + +## SOLUCION IMPLEMENTADA + +### Archivo: `zcatgui/src/widgets/table.zig` + +En `tableRectFull()`, despues de verificar que hay datos: + +```zig +// Ensure valid selection if table has data +// Without this, selected_row/col stay at -1 until user clicks, +// which breaks keyboard navigation and selectedCell() returns null +if (state.row_count > 0 and columns.len > 0) { + if (state.selected_row < 0) state.selected_row = 0; + if (state.selected_col < 0) state.selected_col = 0; +} +``` + +### Por que esta solucion es correcta +- Es logica: si una tabla tiene datos, debe tener una celda seleccionada por defecto +- Se hace en la libreria, no en la aplicacion (principio de resolver en origen) +- No rompe compatibilidad: las apps que ya usaban clic siguen funcionando +- Beneficia a todas las apps que usen zcatgui + +--- + +## OTROS CAMBIOS REALIZADOS (durante investigacion) + +### 1. Focus implicito (conservar) +**Archivo**: `zcatgui/src/core/focus.zig` + +`hasFocus()` y `getFocused()` ahora retornan el primer widget si `focused_index == null`: +- Permite que widgets respondan al teclado desde el primer frame +- `endFrame()` convierte el focus implicito en explicito + +### 2. Separacion registration_group / active_group (conservar) +**Archivos**: `focus.zig`, `context.zig` + +- `registration_group`: donde se registran widgets durante draw +- `active_group`: grupo con focus de teclado (solo cambia con F6/clic) + +### 3. Test de input corregido (conservar) +**Archivo**: `zcatgui/src/core/input.zig` + +El test `navKeyPressed` usaba `setKeyState()` pero debia usar `handleKeyEvent()`. + +--- + +## ESTADO DE PRUEBAS + +| Funcionalidad | Estado | Notas | +|---------------|--------|-------| +| Focus visual exclusivo | OK | Solo un panel con borde azul | +| F6 cambia entre paneles | OK | | +| Clic cambia focus | OK | | +| Flechas mueven seleccion tabla | OK | Ahora funciona desde inicio | +| Tab entre TextInputs | OK | | +| Seleccion se propaga a DataManager | OK | Panel detalle se actualiza | +| CPU idle ~0% | OK | SDL_WaitEventTimeout | + +--- + +## ARQUITECTURA FINAL DEL SISTEMA DE FOCUS + +``` + FocusSystem + | + +---------------+---------------+ + | | + FocusGroup 1 FocusGroup 2 + (Panel Lista) (Panel Detalle) + | | + Table widget TextInput widgets + | | + - registerFocusable() - registerFocusable() + - hasFocus() -> true/false - hasFocus() -> true/false +``` + +### Flujo por frame: +1. `beginFrame()` - limpia registros de widgets +2. `setRegistrationGroup(1)` - panel 1 registra sus widgets +3. `setRegistrationGroup(2)` - panel 2 registra sus widgets +4. Widgets preguntan `hasFocus()` durante draw +5. `endFrame()` - valida focus, procesa Tab pendiente + +### Invariantes: +- Solo UN grupo tiene `active_group` a la vez +- El primer widget del grupo activo tiene focus implicito si `focused_index == null` +- `selectedCell()` requiere `selected_row >= 0` Y `selected_col >= 0` + +--- + +## LECCION APRENDIDA + +**Siempre inicializar estado completo, no parcial.** + +El bug surgio porque `selected_row` se manejaba correctamente pero `selected_col` se ignoraba. +Cualquier funcion que dependa de multiples campos (como `selectedCell()`) fallara si alguno no esta inicializado. + +--- + +*Documento cerrado: 2025-12-11 ~19:00* +*Autores: Arno + Claude (Opus 4.5)* diff --git a/src/backend/backend.zig b/src/backend/backend.zig index 6eb332f..81e46ce 100644 --- a/src/backend/backend.zig +++ b/src/backend/backend.zig @@ -35,6 +35,10 @@ pub const Event = union(enum) { text: [32]u8, len: usize, }, + + /// Window needs redraw (exposed, focus gained, restored, etc.) + /// Application should trigger a full redraw when receiving this + window_exposed, }; /// Abstract backend interface diff --git a/src/backend/sdl2.zig b/src/backend/sdl2.zig index d971f54..571a8cb 100644 --- a/src/backend/sdl2.zig +++ b/src/backend/sdl2.zig @@ -205,15 +205,26 @@ pub const Sdl2Backend = struct { }, c.SDL_WINDOWEVENT => blk: { - if (event.window.event == c.SDL_WINDOWEVENT_RESIZED) { - break :blk Event{ - .resize = .{ - .width = @intCast(event.window.data1), - .height = @intCast(event.window.data2), - }, - }; + switch (event.window.event) { + c.SDL_WINDOWEVENT_RESIZED => { + break :blk Event{ + .resize = .{ + .width = @intCast(event.window.data1), + .height = @intCast(event.window.data2), + }, + }; + }, + // Window needs redraw: exposed (uncovered), focus gained, restored, shown + c.SDL_WINDOWEVENT_EXPOSED, + c.SDL_WINDOWEVENT_FOCUS_GAINED, + c.SDL_WINDOWEVENT_RESTORED, + c.SDL_WINDOWEVENT_SHOWN, + c.SDL_WINDOWEVENT_MAXIMIZED, + => { + break :blk Event.window_exposed; + }, + else => break :blk null, } - break :blk null; }, else => null, diff --git a/src/core/context.zig b/src/core/context.zig index bb36113..041ff6d 100644 --- a/src/core/context.zig +++ b/src/core/context.zig @@ -13,16 +13,28 @@ //! - Dirty rectangle tracking for minimal redraws //! //! ## Focus Management -//! The Context uses FocusGroupManager for organizing widgets into focus groups. -//! Each group (typically a panel) contains focusable widgets. -//! Tab/Shift+Tab navigates within the active group. +//! The Context uses FocusSystem for managing widget focus: +//! - Group 0 is the implicit global group (always exists) +//! - If no groups are created, Tab navigates all widgets (like microui/Gio) +//! - If groups are created, Tab navigates within active group //! -//! Usage: -//! 1. Application creates groups: `ctx.createFocusGroup(group_id)` -//! 2. Application sets active group: `ctx.setActiveFocusGroup(group_id)` -//! 3. Widgets register themselves: `ctx.registerFocusable(widget_id)` (into active group) -//! 4. Widgets check focus: `ctx.hasFocus(widget_id)` -//! 5. On click, widgets request focus: `ctx.requestFocus(widget_id)` +//! Usage (simple app - no groups needed): +//! ```zig +//! ctx.focus.register(widget_id); +//! if (ctx.focus.hasFocus(widget_id)) { ... } +//! ``` +//! +//! Usage (complex app with panels): +//! ```zig +//! _ = ctx.focus.createGroup(1); // Create group for panel 1 +//! _ = ctx.focus.createGroup(2); // Create group for panel 2 +//! +//! ctx.focus.setActiveGroup(1); +//! panel1.draw(); // widgets register in group 1 +//! +//! ctx.focus.setActiveGroup(2); +//! panel2.draw(); // widgets register in group 2 +//! ``` const std = @import("std"); const Allocator = std.mem.Allocator; @@ -33,9 +45,9 @@ const Layout = @import("layout.zig"); const Style = @import("style.zig"); const arena_mod = @import("../utils/arena.zig"); const FrameArena = arena_mod.FrameArena; -const focus_group = @import("focus_group.zig"); -const FocusGroup = focus_group.FocusGroup; -const FocusGroupManager = focus_group.FocusGroupManager; +const focus_mod = @import("focus.zig"); +const FocusSystem = focus_mod.FocusSystem; +const FocusGroup = focus_mod.FocusGroup; /// Central context for immediate mode UI pub const Context = struct { @@ -73,14 +85,8 @@ pub const Context = struct { /// Frame statistics stats: FrameStats, - /// Focus group manager for keyboard navigation between widgets - /// Widgets are organized into groups (typically one per panel) - /// Tab navigates within the active group - focus_groups: FocusGroupManager, - - /// Tab key state (set by handleTabKey, processed in endFrame) - tab_pressed: bool = false, - shift_tab_pressed: bool = false, + /// Unified focus management system + focus: FocusSystem, const Self = @This(); @@ -113,7 +119,7 @@ pub const Context = struct { .dirty_rects = .{}, .full_redraw = true, .stats = .{}, - .focus_groups = FocusGroupManager.init(), + .focus = FocusSystem.init(), }; } @@ -132,7 +138,7 @@ pub const Context = struct { .dirty_rects = .{}, .full_redraw = true, .stats = .{}, - .focus_groups = FocusGroupManager.init(), + .focus = FocusSystem.init(), }; } @@ -162,26 +168,16 @@ pub const Context = struct { self.stats.arena_bytes = 0; self.stats.dirty_rect_count = 0; - // Note: focus_groups state persists across frames - // Tab navigation is processed in endFrame + // Focus system frame start + self.focus.beginFrame(); self.frame += 1; } /// End the current frame pub fn endFrame(self: *Self) void { - // Process Tab/Shift+Tab navigation within active group - if (self.tab_pressed or self.shift_tab_pressed) { - if (self.focus_groups.getActiveGroup()) |group| { - if (self.shift_tab_pressed) { - _ = group.focusPrevious(); - } else { - _ = group.focusNext(); - } - } - self.tab_pressed = false; - self.shift_tab_pressed = false; - } + // Focus system frame end (processes Tab navigation) + self.focus.endFrame(); self.input.endFrame(); @@ -195,90 +191,67 @@ pub const Context = struct { } // ========================================================================= - // Focus Group Management + // Focus convenience methods (delegate to self.focus) + // These provide a cleaner API: ctx.hasFocus(id) instead of ctx.focus.hasFocus(id) // ========================================================================= - /// Create a new focus group (typically one per panel) - /// Returns pointer to the group for adding widgets - pub fn createFocusGroup(self: *Self, group_id: u64) *FocusGroup { - return self.focus_groups.createGroup(group_id); - } - - /// Set the active focus group - /// Tab navigation will only work within the active group - pub fn setActiveFocusGroup(self: *Self, group_id: u64) void { - self.focus_groups.setActiveGroup(group_id); - } - - /// Get the active focus group - pub fn getActiveFocusGroup(self: *Self) ?*FocusGroup { - return self.focus_groups.getActiveGroup(); - } - - /// Get the active group ID - pub fn getActiveFocusGroupId(self: *Self) ?u64 { - return self.focus_groups.active_group; - } - - // ========================================================================= - // Focus Management Helpers (widget-level) - // ========================================================================= - - /// Check if a widget has focus - /// A widget has focus if it's the focused widget in the active group - pub fn hasFocus(self: *Self, widget_id: u64) bool { - if (self.focus_groups.getActiveGroup()) |group| { - return group.hasFocus(widget_id); - } - return false; - } - - /// Request focus for a widget (e.g., when clicked) - /// This also activates the group containing the widget - pub fn requestFocus(self: *Self, widget_id: u64) void { - // Find which group contains this widget and activate it - for (self.focus_groups.groups[0..self.focus_groups.group_count]) |*group| { - if (group.setFocus(widget_id)) { - self.focus_groups.active_group = group.id; - return; - } - } - } - /// Register a widget as focusable in the active group - /// Call this during draw for each focusable widget pub fn registerFocusable(self: *Self, widget_id: u64) void { - if (self.focus_groups.getActiveGroup()) |group| { - // Only add if not already in the group - if (group.indexOf(widget_id) == null) { - group.add(widget_id); - } - } + self.focus.register(widget_id); } - /// Process Tab key for focus navigation - /// Call this when Tab is pressed in the input handler + /// Check if widget has focus + pub fn hasFocus(self: *Self, widget_id: u64) bool { + return self.focus.hasFocus(widget_id); + } + + /// Request focus for a widget + pub fn requestFocus(self: *Self, widget_id: u64) void { + self.focus.request(widget_id); + } + + /// Handle Tab key (call this when Tab is pressed) pub fn handleTabKey(self: *Self, shift: bool) void { - if (shift) { - self.shift_tab_pressed = true; - } else { - self.tab_pressed = true; - } + self.focus.handleTab(shift); } - /// Check if a group contains the currently focused widget - /// Useful for panels to know if they should show focus highlight - pub fn groupHasFocus(self: *Self, group_id: u64) bool { - if (self.focus_groups.active_group) |active_id| { - if (active_id == group_id) { - if (self.focus_groups.getGroup(group_id)) |group| { - return group.getFocused() != null; - } - } - } - return false; + /// Create a new focus group + pub fn createFocusGroup(self: *Self, group_id: u64) ?*FocusGroup { + return self.focus.createGroup(group_id); } + /// Set the active focus group (the group that receives keyboard input) + /// Use this when focus changes between panels (F6, click on panel, etc.) + pub fn setActiveFocusGroup(self: *Self, group_id: u64) void { + self.focus.setActiveGroup(group_id); + } + + /// Set the registration group (for widget registration during draw) + /// Use this before drawing each panel to register its widgets in the correct group. + /// This does NOT change which group has keyboard focus. + pub fn setRegistrationGroup(self: *Self, group_id: u64) void { + self.focus.setRegistrationGroup(group_id); + } + + /// Get the active focus group ID + pub fn getActiveFocusGroup(self: *Self) u64 { + return self.focus.getActiveGroup(); + } + + /// Check if a group is active + pub fn isGroupActive(self: *Self, group_id: u64) bool { + return self.focus.isGroupActive(group_id); + } + + /// Focus next group (for F6-style navigation) + pub fn focusNextGroup(self: *Self) void { + self.focus.focusNextGroup(); + } + + // ========================================================================= + // ID Management + // ========================================================================= + /// Get the frame allocator (use for per-frame allocations) pub fn frameAllocator(self: *Self) Allocator { return self.frame_arena.allocator(); @@ -560,3 +533,59 @@ test "Context stats" { try std.testing.expectEqual(@as(usize, 2), stats.command_count); try std.testing.expectEqual(@as(usize, 3), stats.widget_count); } + +test "Context focus integration" { + var ctx = try Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + ctx.beginFrame(); + + // Register focusable widgets + ctx.registerFocusable(100); + ctx.registerFocusable(200); + ctx.registerFocusable(300); + + // First widget has implicit focus immediately + try std.testing.expect(ctx.hasFocus(100)); + + // Request focus changes it + ctx.requestFocus(200); + try std.testing.expect(ctx.hasFocus(200)); + try std.testing.expect(!ctx.hasFocus(100)); + + ctx.endFrame(); +} + +test "Context focus groups" { + var ctx = try Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + // Create groups + _ = ctx.createFocusGroup(1); + _ = ctx.createFocusGroup(2); + + // Set group 1 as active (has keyboard focus) + ctx.setActiveFocusGroup(1); + + ctx.beginFrame(); + + // Register widgets in group 1 (use setRegistrationGroup, NOT setActiveFocusGroup) + ctx.setRegistrationGroup(1); + ctx.registerFocusable(100); + ctx.registerFocusable(101); + + // Register widgets in group 2 + ctx.setRegistrationGroup(2); + ctx.registerFocusable(200); + ctx.registerFocusable(201); + + // Group 1 is still active (keyboard focus unchanged by registration) + try std.testing.expectEqual(@as(u64, 1), ctx.getActiveFocusGroup()); + + // Request focus on widget in group 2 - this DOES change active group + ctx.requestFocus(200); + try std.testing.expect(ctx.hasFocus(200)); + try std.testing.expectEqual(@as(u64, 2), ctx.getActiveFocusGroup()); + + ctx.endFrame(); +} diff --git a/src/core/focus.zig b/src/core/focus.zig new file mode 100644 index 0000000..359b027 --- /dev/null +++ b/src/core/focus.zig @@ -0,0 +1,666 @@ +//! Focus System - Unified focus management for zcatgui +//! +//! This module provides a single, coherent focus management system that supports: +//! - Simple apps: All widgets in one global group, Tab navigates everything +//! - Complex apps: Multiple groups (one per panel), Tab navigates within active group +//! +//! ## Design Principles +//! - Group 0 is the implicit global group (always exists) +//! - If no groups are created, everything works like microui/Gio (flat focus) +//! - If groups are created, Tab navigates within active group +//! - Widgets self-register during draw (immediate mode pattern) +//! +//! ## Usage +//! ```zig +//! // Simple app (no groups needed): +//! ctx.focus.register(widget_id); +//! if (ctx.focus.hasFocus(widget_id)) { ... } +//! +//! // Complex app with panels: +//! ctx.focus.createGroup(1); // Panel 1 +//! ctx.focus.createGroup(2); // Panel 2 +//! +//! ctx.focus.setActiveGroup(1); +//! panel1.draw(); // widgets register in group 1 +//! +//! ctx.focus.setActiveGroup(2); +//! panel2.draw(); // widgets register in group 2 +//! ``` + +const std = @import("std"); + +/// Maximum widgets per group +pub const MAX_WIDGETS_PER_GROUP = 64; + +/// Maximum number of groups (including global group 0) +pub const MAX_GROUPS = 16; + +/// A focus group containing related widgets +pub const FocusGroup = struct { + /// Group identifier (0 = global implicit group) + id: u64, + + /// Widget IDs in this group (in registration/tab order) + widgets: [MAX_WIDGETS_PER_GROUP]u64 = undefined, + + /// Number of widgets registered this frame + count: usize = 0, + + /// Index of focused widget within this group + focused_index: ?usize = null, + + /// Wrap navigation at boundaries + wrap: bool = true, + + const Self = @This(); + + /// Initialize a group + pub fn init(id: u64) Self { + return .{ .id = id }; + } + + /// Clear all registered widgets (called at start of frame) + pub fn clear(self: *Self) void { + self.count = 0; + // Note: focused_index is NOT cleared - we preserve focus across frames + } + + /// Register a widget in this group + pub fn register(self: *Self, widget_id: u64) void { + // Don't add duplicates + if (self.contains(widget_id)) return; + + if (self.count >= MAX_WIDGETS_PER_GROUP) return; + self.widgets[self.count] = widget_id; + self.count += 1; + } + + /// Check if widget is in this group + pub fn contains(self: Self, widget_id: u64) bool { + for (self.widgets[0..self.count]) |id| { + if (id == widget_id) return true; + } + return false; + } + + /// Get index of widget in group + pub fn indexOf(self: Self, widget_id: u64) ?usize { + for (self.widgets[0..self.count], 0..) |id, i| { + if (id == widget_id) return i; + } + return null; + } + + /// Get the currently focused widget ID + /// If no widget has explicit focus but the group has widgets, + /// returns the first widget (implicit focus). + pub fn getFocused(self: Self) ?u64 { + if (self.focused_index) |idx| { + if (idx < self.count) { + return self.widgets[idx]; + } + // focused_index is stale (widget disappeared), fall through to implicit + } + // No explicit focus - return first widget (implicit focus) + if (self.count > 0) { + return self.widgets[0]; + } + return null; + } + + /// Check if widget has focus in this group + /// If no widget has explicit focus (focused_index == null), the first widget + /// in the group is considered to have implicit focus. This ensures that + /// widgets can respond to keyboard input on the first frame before endFrame() + /// has a chance to set focused_index. + pub fn hasFocus(self: Self, widget_id: u64) bool { + if (self.focused_index) |idx| { + // Explicit focus exists + if (idx < self.count) { + return self.widgets[idx] == widget_id; + } + return false; + } else { + // No explicit focus - first widget has implicit focus + if (self.count > 0) { + return self.widgets[0] == widget_id; + } + return false; + } + } + + /// Set focus to a specific widget + /// Returns true if widget was found and focused + pub fn setFocus(self: *Self, widget_id: u64) bool { + if (self.indexOf(widget_id)) |idx| { + self.focused_index = idx; + return true; + } + return false; + } + + /// Focus next widget (Tab) + pub fn focusNext(self: *Self) ?u64 { + if (self.count == 0) return null; + + if (self.focused_index) |idx| { + if (idx + 1 < self.count) { + self.focused_index = idx + 1; + } else if (self.wrap) { + self.focused_index = 0; + } + } else { + // No current focus, start at first + self.focused_index = 0; + } + + return self.getFocused(); + } + + /// Focus previous widget (Shift+Tab) + pub fn focusPrev(self: *Self) ?u64 { + if (self.count == 0) return null; + + if (self.focused_index) |idx| { + if (idx > 0) { + self.focused_index = idx - 1; + } else if (self.wrap) { + self.focused_index = self.count - 1; + } + } else { + // No current focus, start at last + self.focused_index = self.count - 1; + } + + return self.getFocused(); + } + + /// Clear focus in this group + pub fn clearFocus(self: *Self) void { + self.focused_index = null; + } + + /// Validate focus after frame (remove focus if widget disappeared) + pub fn validateFocus(self: *Self) void { + if (self.focused_index) |idx| { + if (idx >= self.count) { + // Widget at this index no longer exists + if (self.count > 0) { + self.focused_index = self.count - 1; + } else { + self.focused_index = null; + } + } + } + } +}; + +/// Unified Focus System +pub const FocusSystem = struct { + /// Focus groups (group 0 = global implicit) + groups: [MAX_GROUPS]FocusGroup = undefined, + + /// Number of groups (always >= 1 because group 0 exists) + group_count: usize = 1, + + /// Currently active group ID (the group that responds to keyboard input) + /// This is the "real" focus - which panel has keyboard control + active_group: u64 = 0, + + /// Registration group ID (used during draw to register widgets in correct groups) + /// This changes during draw but doesn't affect which group has real focus + registration_group: u64 = 0, + + /// Hover tracking (widget under cursor) + hover_id: ?u64 = null, + + /// Tab navigation pending + tab_pending: bool = false, + shift_tab_pending: bool = false, + + const Self = @This(); + + /// Initialize the focus system + /// Group 0 (global implicit) is always created + pub fn init() Self { + var self = Self{}; + self.groups[0] = FocusGroup.init(0); + self.group_count = 1; + self.active_group = 0; + self.registration_group = 0; + return self; + } + + // ========================================================================= + // Frame lifecycle + // ========================================================================= + + /// Called at start of frame - clears widget registrations + pub fn beginFrame(self: *Self) void { + // Clear all widget registrations (they re-register during draw) + for (self.groups[0..self.group_count]) |*group| { + group.clear(); + } + // Note: focus state and active_group persist + self.hover_id = null; + } + + /// Called at end of frame - process Tab and validate focus + pub fn endFrame(self: *Self) void { + // Auto-focus first widget in active group if it has no focus + // This ensures the active group always has a focused widget after first frame + if (self.getActiveGroupPtr()) |group| { + if (group.focused_index == null and group.count > 0) { + group.focused_index = 0; + } + } + + // Process Tab navigation + if (self.tab_pending or self.shift_tab_pending) { + if (self.getActiveGroupPtr()) |group| { + if (self.shift_tab_pending) { + _ = group.focusPrev(); + } else { + _ = group.focusNext(); + } + } + self.tab_pending = false; + self.shift_tab_pending = false; + } + + // Validate focus in all groups (handle disappeared widgets) + for (self.groups[0..self.group_count]) |*group| { + group.validateFocus(); + } + } + + // ========================================================================= + // Widget API (called by widgets during draw) + // ========================================================================= + + /// Register a widget as focusable in the current registration group + /// Note: Does NOT auto-focus. Focus is given in endFrame() to the active group's first widget + /// Use setRegistrationGroup() to change which group widgets are registered in during draw. + pub fn register(self: *Self, widget_id: u64) void { + if (self.getGroupPtr(self.registration_group)) |group| { + group.register(widget_id); + } + } + + /// Check if widget has focus + /// Widget has focus if: it's in the active group AND is the focused widget + pub fn hasFocus(self: *Self, widget_id: u64) bool { + if (self.getActiveGroupPtr()) |group| { + return group.hasFocus(widget_id); + } + return false; + } + + /// Request focus for a widget + /// This finds which group contains the widget and: + /// 1. Sets that group as active + /// 2. Sets focus to the widget within that group + pub fn request(self: *Self, widget_id: u64) void { + // First check active group + if (self.getActiveGroupPtr()) |group| { + if (group.setFocus(widget_id)) { + return; + } + } + + // Search all groups + for (self.groups[0..self.group_count]) |*group| { + if (group.setFocus(widget_id)) { + self.active_group = group.id; + return; + } + } + } + + /// Set hover for a widget + pub fn setHover(self: *Self, widget_id: u64) void { + self.hover_id = widget_id; + } + + /// Check if widget has hover + pub fn hasHover(self: Self, widget_id: u64) bool { + return self.hover_id == widget_id; + } + + // ========================================================================= + // Application API (called by app to manage groups) + // ========================================================================= + + /// Create a new focus group + /// Returns pointer to the group, or null if max groups reached + pub fn createGroup(self: *Self, group_id: u64) ?*FocusGroup { + // Don't create duplicates + if (self.getGroupPtr(group_id) != null) { + return self.getGroupPtr(group_id); + } + + if (self.group_count >= MAX_GROUPS) return null; + + self.groups[self.group_count] = FocusGroup.init(group_id); + const group = &self.groups[self.group_count]; + self.group_count += 1; + return group; + } + + /// Set the active group (the group that has keyboard focus) + /// Tab will navigate within this group, and widgets in this group will respond to keyboard. + /// This should be called when focus changes between panels (F6, click on panel, etc.) + pub fn setActiveGroup(self: *Self, group_id: u64) void { + // Verify group exists + if (self.getGroupPtr(group_id) != null) { + self.active_group = group_id; + } + } + + /// Set the registration group (used during draw) + /// Widgets call register() during draw, and this controls which group they register in. + /// This does NOT change which group has keyboard focus. + /// Typical usage: + /// setRegistrationGroup(1); // Widgets drawn next go in group 1 + /// panel1.draw(); + /// setRegistrationGroup(2); // Widgets drawn next go in group 2 + /// panel2.draw(); + pub fn setRegistrationGroup(self: *Self, group_id: u64) void { + // Verify group exists + if (self.getGroupPtr(group_id) != null) { + self.registration_group = group_id; + } + } + + /// Get the active group ID (the group with keyboard focus) + pub fn getActiveGroup(self: Self) u64 { + return self.active_group; + } + + /// Get the registration group ID + pub fn getRegistrationGroup(self: Self) u64 { + return self.registration_group; + } + + /// Get pointer to active group + pub fn getActiveGroupPtr(self: *Self) ?*FocusGroup { + return self.getGroupPtr(self.active_group); + } + + /// Get pointer to a group by ID + pub fn getGroupPtr(self: *Self, group_id: u64) ?*FocusGroup { + for (self.groups[0..self.group_count]) |*group| { + if (group.id == group_id) return group; + } + return null; + } + + /// Focus next widget in active group (Tab) + pub fn focusNext(self: *Self) void { + if (self.getActiveGroupPtr()) |group| { + _ = group.focusNext(); + } + } + + /// Focus previous widget in active group (Shift+Tab) + pub fn focusPrev(self: *Self) void { + if (self.getActiveGroupPtr()) |group| { + _ = group.focusPrev(); + } + } + + /// Clear focus in active group + pub fn clearFocus(self: *Self) void { + if (self.getActiveGroupPtr()) |group| { + group.clearFocus(); + } + } + + /// Handle Tab key press (schedules navigation for endFrame) + pub fn handleTab(self: *Self, shift: bool) void { + if (shift) { + self.shift_tab_pending = true; + } else { + self.tab_pending = true; + } + } + + /// Focus next group (for F6-style navigation) + pub fn focusNextGroup(self: *Self) void { + if (self.group_count <= 1) return; + + for (self.groups[0..self.group_count], 0..) |group, i| { + if (group.id == self.active_group) { + const next_idx = (i + 1) % self.group_count; + self.active_group = self.groups[next_idx].id; + return; + } + } + } + + /// Focus previous group + pub fn focusPrevGroup(self: *Self) void { + if (self.group_count <= 1) return; + + for (self.groups[0..self.group_count], 0..) |group, i| { + if (group.id == self.active_group) { + const prev_idx = if (i == 0) self.group_count - 1 else i - 1; + self.active_group = self.groups[prev_idx].id; + return; + } + } + } + + /// Check if a specific group is the active group + pub fn isGroupActive(self: Self, group_id: u64) bool { + return self.active_group == group_id; + } + + /// Get the focused widget ID in the active group + pub fn getFocusedWidget(self: *Self) ?u64 { + if (self.getActiveGroupPtr()) |group| { + return group.getFocused(); + } + return null; + } +}; + +// ============================================================================= +// Tests +// ============================================================================= + +test "FocusSystem init creates global group" { + const fs = FocusSystem.init(); + try std.testing.expectEqual(@as(usize, 1), fs.group_count); + try std.testing.expectEqual(@as(u64, 0), fs.active_group); +} + +test "FocusSystem simple usage (no explicit groups)" { + var fs = FocusSystem.init(); + + fs.beginFrame(); + + // Register widgets + fs.register(100); + fs.register(200); + fs.register(300); + + // First widget has IMPLICIT focus immediately after registration + // This is intentional - allows widgets to respond to keyboard on first frame + try std.testing.expect(fs.hasFocus(100)); + try std.testing.expect(!fs.hasFocus(200)); + + // endFrame makes implicit focus explicit + fs.endFrame(); + + try std.testing.expectEqual(@as(?u64, 100), fs.getFocusedWidget()); + + // Next frame - Tab to second + fs.beginFrame(); + fs.register(100); + fs.register(200); + fs.register(300); + + fs.handleTab(false); + fs.endFrame(); + + try std.testing.expectEqual(@as(?u64, 200), fs.getFocusedWidget()); + + // Next frame - Tab to third + fs.beginFrame(); + fs.register(100); + fs.register(200); + fs.register(300); + + fs.handleTab(false); + fs.endFrame(); + + try std.testing.expectEqual(@as(?u64, 300), fs.getFocusedWidget()); +} + +test "FocusSystem request focus" { + var fs = FocusSystem.init(); + + fs.beginFrame(); + fs.register(100); + fs.register(200); + fs.register(300); + + // Request focus on 200 + fs.request(200); + + try std.testing.expect(fs.hasFocus(200)); + try std.testing.expect(!fs.hasFocus(100)); + + fs.endFrame(); +} + +test "FocusSystem with multiple groups" { + var fs = FocusSystem.init(); + + // Create two groups + _ = fs.createGroup(1); + _ = fs.createGroup(2); + + try std.testing.expectEqual(@as(usize, 3), fs.group_count); // 0, 1, 2 + + // Set group 2 as active (keyboard focus) + fs.setActiveGroup(2); + + fs.beginFrame(); + + // Register widgets in group 1 (use setRegistrationGroup) + fs.setRegistrationGroup(1); + fs.register(100); + fs.register(101); + + // Register widgets in group 2 + fs.setRegistrationGroup(2); + fs.register(200); + fs.register(201); + + // BEFORE endFrame: First widget in active group (200) has implicit focus + try std.testing.expectEqual(@as(?u64, 200), fs.getFocusedWidget()); + + fs.endFrame(); + + // AFTER endFrame: Same result, now explicit + try std.testing.expectEqual(@as(?u64, 200), fs.getFocusedWidget()); + + // Switch active to group 1 + fs.setActiveGroup(1); + fs.beginFrame(); + fs.setRegistrationGroup(1); + fs.register(100); + fs.register(101); + fs.setRegistrationGroup(2); + fs.register(200); + fs.register(201); + + // Group 1 is now active, first widget (100) has implicit focus + try std.testing.expectEqual(@as(?u64, 100), fs.getFocusedWidget()); + + fs.endFrame(); // Makes implicit focus explicit + + try std.testing.expectEqual(@as(?u64, 100), fs.getFocusedWidget()); +} + +test "FocusSystem focus persists across frames" { + var fs = FocusSystem.init(); + + // Frame 1: register and focus + fs.beginFrame(); + fs.register(100); + fs.register(200); + fs.request(200); + fs.endFrame(); + + try std.testing.expectEqual(@as(?u64, 200), fs.getFocusedWidget()); + + // Frame 2: re-register same widgets + fs.beginFrame(); + fs.register(100); + fs.register(200); + // Don't request focus - should persist + fs.endFrame(); + + try std.testing.expectEqual(@as(?u64, 200), fs.getFocusedWidget()); +} + +test "FocusSystem focus clears if widget disappears" { + var fs = FocusSystem.init(); + + // Frame 1: register and focus + fs.beginFrame(); + fs.register(100); + fs.register(200); + fs.request(200); + fs.endFrame(); + + try std.testing.expectEqual(@as(?u64, 200), fs.getFocusedWidget()); + + // Frame 2: widget 200 not registered (disappeared) + fs.beginFrame(); + fs.register(100); + // 200 not registered + fs.endFrame(); + + // Focus should fall back to last valid widget + try std.testing.expectEqual(@as(?u64, 100), fs.getFocusedWidget()); +} + +test "FocusSystem focusNextGroup" { + var fs = FocusSystem.init(); + + _ = fs.createGroup(1); + _ = fs.createGroup(2); + + fs.setActiveGroup(0); + try std.testing.expectEqual(@as(u64, 0), fs.getActiveGroup()); + + fs.focusNextGroup(); + try std.testing.expectEqual(@as(u64, 1), fs.getActiveGroup()); + + fs.focusNextGroup(); + try std.testing.expectEqual(@as(u64, 2), fs.getActiveGroup()); + + fs.focusNextGroup(); // Wrap to 0 + try std.testing.expectEqual(@as(u64, 0), fs.getActiveGroup()); +} + +test "FocusGroup Tab wrapping" { + var group = FocusGroup.init(0); + group.register(100); + group.register(200); + group.register(300); + + // Tab through all + try std.testing.expectEqual(@as(?u64, 100), group.focusNext()); + try std.testing.expectEqual(@as(?u64, 200), group.focusNext()); + try std.testing.expectEqual(@as(?u64, 300), group.focusNext()); + try std.testing.expectEqual(@as(?u64, 100), group.focusNext()); // Wrap + + // Shift+Tab back + try std.testing.expectEqual(@as(?u64, 300), group.focusPrev()); // Wrap back + try std.testing.expectEqual(@as(?u64, 200), group.focusPrev()); +} diff --git a/src/core/focus_group.zig b/src/core/focus_group.zig deleted file mode 100644 index 87115fe..0000000 --- a/src/core/focus_group.zig +++ /dev/null @@ -1,416 +0,0 @@ -//! Focus Groups -//! -//! Manages focus navigation within groups of widgets. -//! Supports tab order, wrapping, and nested groups. - -const std = @import("std"); -const Input = @import("input.zig"); - -/// Maximum widgets per group -const MAX_GROUP_SIZE = 64; - -/// Maximum number of groups -const MAX_GROUPS = 32; - -/// Focus direction -pub const Direction = enum { - next, - previous, - up, - down, - left, - right, -}; - -/// A focus group containing related widgets -pub const FocusGroup = struct { - /// Group identifier - id: u64, - /// Widget IDs in this group (in tab order) - widgets: [MAX_GROUP_SIZE]u64 = undefined, - /// Number of widgets - count: usize = 0, - /// Currently focused widget index - focused_index: ?usize = null, - /// Wrap focus at boundaries - wrap: bool = true, - /// Is this group active - active: bool = true, - /// Parent group (for nested focus) - parent_group: ?u64 = null, - - const Self = @This(); - - /// Create a new focus group - pub fn init(id: u64) Self { - return .{ .id = id }; - } - - /// Add a widget to the group - pub fn add(self: *Self, widget_id: u64) void { - if (self.count >= MAX_GROUP_SIZE) return; - self.widgets[self.count] = widget_id; - self.count += 1; - } - - /// Remove a widget from the group - pub fn remove(self: *Self, widget_id: u64) void { - var i: usize = 0; - while (i < self.count) { - if (self.widgets[i] == widget_id) { - // Shift remaining widgets - var j = i; - while (j < self.count - 1) : (j += 1) { - self.widgets[j] = self.widgets[j + 1]; - } - self.count -= 1; - - // Adjust focused index - if (self.focused_index) |idx| { - if (idx == i) { - self.focused_index = if (self.count > 0) @min(idx, self.count - 1) else null; - } else if (idx > i) { - self.focused_index = idx - 1; - } - } - return; - } - i += 1; - } - } - - /// Get the currently focused widget ID - pub fn getFocused(self: Self) ?u64 { - if (self.focused_index) |idx| { - if (idx < self.count) { - return self.widgets[idx]; - } - } - return null; - } - - /// Set focus to a specific widget - pub fn setFocus(self: *Self, widget_id: u64) bool { - for (self.widgets[0..self.count], 0..) |id, i| { - if (id == widget_id) { - self.focused_index = i; - return true; - } - } - return false; - } - - /// Focus the next widget - pub fn focusNext(self: *Self) ?u64 { - if (self.count == 0) return null; - - if (self.focused_index) |idx| { - if (idx + 1 < self.count) { - self.focused_index = idx + 1; - } else if (self.wrap) { - self.focused_index = 0; - } - } else { - self.focused_index = 0; - } - - return self.getFocused(); - } - - /// Focus the previous widget - pub fn focusPrevious(self: *Self) ?u64 { - if (self.count == 0) return null; - - if (self.focused_index) |idx| { - if (idx > 0) { - self.focused_index = idx - 1; - } else if (self.wrap) { - self.focused_index = self.count - 1; - } - } else { - self.focused_index = self.count - 1; - } - - return self.getFocused(); - } - - /// Focus first widget - pub fn focusFirst(self: *Self) ?u64 { - if (self.count == 0) return null; - self.focused_index = 0; - return self.getFocused(); - } - - /// Focus last widget - pub fn focusLast(self: *Self) ?u64 { - if (self.count == 0) return null; - self.focused_index = self.count - 1; - return self.getFocused(); - } - - /// Clear focus - pub fn clearFocus(self: *Self) void { - self.focused_index = null; - } - - /// Check if a widget has focus - pub fn hasFocus(self: Self, widget_id: u64) bool { - if (self.getFocused()) |focused| { - return focused == widget_id; - } - return false; - } - - /// Get index of a widget - pub fn indexOf(self: Self, widget_id: u64) ?usize { - for (self.widgets[0..self.count], 0..) |id, i| { - if (id == widget_id) { - return i; - } - } - return null; - } -}; - -/// Focus Group Manager - manages multiple focus groups -pub const FocusGroupManager = struct { - groups: [MAX_GROUPS]FocusGroup = undefined, - group_count: usize = 0, - active_group: ?u64 = null, - - const Self = @This(); - - /// Initialize the manager - pub fn init() Self { - return .{}; - } - - /// Create a new group - pub fn createGroup(self: *Self, id: u64) *FocusGroup { - if (self.group_count >= MAX_GROUPS) { - // Return first group as fallback - return &self.groups[0]; - } - - self.groups[self.group_count] = FocusGroup.init(id); - const group = &self.groups[self.group_count]; - self.group_count += 1; - - // Set as active if first group - if (self.active_group == null) { - self.active_group = id; - } - - return group; - } - - /// Get a group by ID - pub fn getGroup(self: *Self, id: u64) ?*FocusGroup { - for (self.groups[0..self.group_count]) |*group| { - if (group.id == id) { - return group; - } - } - return null; - } - - /// Remove a group - pub fn removeGroup(self: *Self, id: u64) void { - var i: usize = 0; - while (i < self.group_count) { - if (self.groups[i].id == id) { - var j = i; - while (j < self.group_count - 1) : (j += 1) { - self.groups[j] = self.groups[j + 1]; - } - self.group_count -= 1; - - if (self.active_group == id) { - self.active_group = if (self.group_count > 0) self.groups[0].id else null; - } - return; - } - i += 1; - } - } - - /// Set the active group - pub fn setActiveGroup(self: *Self, id: u64) void { - if (self.getGroup(id) != null) { - self.active_group = id; - } - } - - /// Get the active group - pub fn getActiveGroup(self: *Self) ?*FocusGroup { - if (self.active_group) |id| { - return self.getGroup(id); - } - return null; - } - - /// Handle focus navigation input - pub fn handleInput(self: *Self, input: *const Input.InputState) ?u64 { - const group = self.getActiveGroup() orelse return null; - - if (input.keyPressed(.tab)) { - if (input.keyDown(.left_shift) or input.keyDown(.right_shift)) { - return group.focusPrevious(); - } else { - return group.focusNext(); - } - } - - return null; - } - - /// Get currently focused widget across all groups - pub fn getGlobalFocus(self: Self) ?u64 { - if (self.active_group) |active_id| { - for (self.groups[0..self.group_count]) |group| { - if (group.id == active_id) { - return group.getFocused(); - } - } - } - return null; - } - - /// Focus next group - pub fn focusNextGroup(self: *Self) void { - if (self.group_count <= 1) return; - - if (self.active_group) |active_id| { - for (self.groups[0..self.group_count], 0..) |group, i| { - if (group.id == active_id) { - const next_idx = (i + 1) % self.group_count; - self.active_group = self.groups[next_idx].id; - return; - } - } - } - } - - /// Focus previous group - pub fn focusPreviousGroup(self: *Self) void { - if (self.group_count <= 1) return; - - if (self.active_group) |active_id| { - for (self.groups[0..self.group_count], 0..) |group, i| { - if (group.id == active_id) { - const prev_idx = if (i == 0) self.group_count - 1 else i - 1; - self.active_group = self.groups[prev_idx].id; - return; - } - } - } - } -}; - -// ============================================================================= -// Tests -// ============================================================================= - -test "FocusGroup init" { - const group = FocusGroup.init(1); - try std.testing.expectEqual(@as(u64, 1), group.id); - try std.testing.expectEqual(@as(usize, 0), group.count); -} - -test "FocusGroup add and remove" { - var group = FocusGroup.init(1); - - group.add(100); - group.add(200); - group.add(300); - - try std.testing.expectEqual(@as(usize, 3), group.count); - - group.remove(200); - try std.testing.expectEqual(@as(usize, 2), group.count); - try std.testing.expectEqual(@as(u64, 100), group.widgets[0]); - try std.testing.expectEqual(@as(u64, 300), group.widgets[1]); -} - -test "FocusGroup navigation" { - var group = FocusGroup.init(1); - group.add(100); - group.add(200); - group.add(300); - - // Focus first - try std.testing.expectEqual(@as(?u64, 100), group.focusFirst()); - try std.testing.expectEqual(@as(?usize, 0), group.focused_index); - - // Focus next - try std.testing.expectEqual(@as(?u64, 200), group.focusNext()); - try std.testing.expectEqual(@as(?u64, 300), group.focusNext()); - - // Wrap around - try std.testing.expectEqual(@as(?u64, 100), group.focusNext()); - - // Focus previous - try std.testing.expectEqual(@as(?u64, 300), group.focusPrevious()); -} - -test "FocusGroup setFocus" { - var group = FocusGroup.init(1); - group.add(100); - group.add(200); - group.add(300); - - try std.testing.expect(group.setFocus(200)); - try std.testing.expectEqual(@as(?u64, 200), group.getFocused()); - - try std.testing.expect(!group.setFocus(999)); // Non-existent -} - -test "FocusGroup hasFocus" { - var group = FocusGroup.init(1); - group.add(100); - group.add(200); - _ = group.focusFirst(); - - try std.testing.expect(group.hasFocus(100)); - try std.testing.expect(!group.hasFocus(200)); -} - -test "FocusGroupManager create groups" { - var manager = FocusGroupManager.init(); - - const group1 = manager.createGroup(1); - const group2 = manager.createGroup(2); - - try std.testing.expectEqual(@as(usize, 2), manager.group_count); - try std.testing.expectEqual(@as(u64, 1), group1.id); - try std.testing.expectEqual(@as(u64, 2), group2.id); - - // First group should be active - try std.testing.expectEqual(@as(?u64, 1), manager.active_group); -} - -test "FocusGroupManager get and remove" { - var manager = FocusGroupManager.init(); - _ = manager.createGroup(1); - _ = manager.createGroup(2); - - const group = manager.getGroup(2); - try std.testing.expect(group != null); - try std.testing.expectEqual(@as(u64, 2), group.?.id); - - manager.removeGroup(1); - try std.testing.expectEqual(@as(usize, 1), manager.group_count); - try std.testing.expect(manager.getGroup(1) == null); -} - -test "FocusGroupManager active group" { - var manager = FocusGroupManager.init(); - _ = manager.createGroup(1); - _ = manager.createGroup(2); - - manager.setActiveGroup(2); - try std.testing.expectEqual(@as(?u64, 2), manager.active_group); - - manager.focusNextGroup(); - try std.testing.expectEqual(@as(?u64, 1), manager.active_group); -} diff --git a/src/core/input.zig b/src/core/input.zig index 9c372c7..55b1a89 100644 --- a/src/core/input.zig +++ b/src/core/input.zig @@ -460,12 +460,13 @@ test "InputState navKeyPressed" { try std.testing.expect(input.navKeyPressed() == null); - input.setKeyState(.down, true); + // navKeyPressed uses key_events, so we need handleKeyEvent, not setKeyState + input.handleKeyEvent(.{ .key = .down, .pressed = true, .modifiers = .{} }); try std.testing.expect(input.navKeyPressed() == .down); input.endFrame(); - try std.testing.expect(input.navKeyPressed() == null); // Not pressed, just held + try std.testing.expect(input.navKeyPressed() == null); // Events cleared after frame - input.setKeyState(.enter, true); + input.handleKeyEvent(.{ .key = .enter, .pressed = true, .modifiers = .{} }); try std.testing.expect(input.navKeyPressed() == .enter); } diff --git a/src/widgets/focus.zig b/src/widgets/focus.zig deleted file mode 100644 index 0246a85..0000000 --- a/src/widgets/focus.zig +++ /dev/null @@ -1,282 +0,0 @@ -//! Focus Management - Track and navigate widget focus -//! -//! Manages which widget has keyboard focus and provides -//! Tab/Shift+Tab navigation between focusable widgets. - -const std = @import("std"); -const Input = @import("../core/input.zig"); - -/// Maximum number of focusable widgets per frame -pub const MAX_FOCUSABLES = 64; - -/// Focus manager state -pub const FocusManager = struct { - /// Currently focused widget ID - focused_id: ?u32 = null, - - /// List of focusable widget IDs this frame (in order) - focusables: [MAX_FOCUSABLES]u32 = undefined, - focusable_count: usize = 0, - - /// Widget ID to focus next frame (from keyboard nav) - pending_focus: ?u32 = null, - - /// Whether Tab was pressed this frame - tab_pressed: bool = false, - shift_tab_pressed: bool = false, - - const Self = @This(); - - /// Reset for new frame - pub fn beginFrame(self: *Self) void { - // Reset focusable list - widgets will re-register during draw - self.focusable_count = 0; - // Note: tab_pressed/shift_tab_pressed are NOT reset here - // They persist from the event loop and are processed in endFrame() - - // Apply pending focus from previous frame's Tab navigation - if (self.pending_focus) |id| { - self.focused_id = id; - self.pending_focus = null; - } - } - - /// Process keyboard input for focus navigation - pub fn processInput(self: *Self, input: *const Input.InputState, key_events: []const Input.KeyEvent) void { - _ = input; - for (key_events) |event| { - if (event.key == .tab and event.pressed) { - if (event.modifiers.shift) { - self.shift_tab_pressed = true; - } else { - self.tab_pressed = true; - } - } - } - } - - /// Register a widget as focusable - pub fn registerFocusable(self: *Self, id: u32) void { - if (self.focusable_count >= MAX_FOCUSABLES) return; - self.focusables[self.focusable_count] = id; - self.focusable_count += 1; - } - - /// Check if a widget has focus - pub fn hasFocus(self: Self, id: u32) bool { - return self.focused_id == id; - } - - /// Request focus for a widget - pub fn requestFocus(self: *Self, id: u32) void { - self.focused_id = id; - } - - /// Clear focus - pub fn clearFocus(self: *Self) void { - self.focused_id = null; - } - - /// End of frame: process Tab navigation - pub fn endFrame(self: *Self) void { - if (self.focusable_count == 0) { - // Reset flags even if no focusables - self.tab_pressed = false; - self.shift_tab_pressed = false; - return; - } - - if (self.tab_pressed) { - self.focusNext(); - } else if (self.shift_tab_pressed) { - self.focusPrev(); - } - - // Reset flags after processing - self.tab_pressed = false; - self.shift_tab_pressed = false; - } - - /// Focus next widget in order - fn focusNext(self: *Self) void { - if (self.focusable_count == 0) return; - - if (self.focused_id) |current| { - // Find current index - for (self.focusables[0..self.focusable_count], 0..) |id, i| { - if (id == current) { - // Focus next (wrap around) - const next_idx = (i + 1) % self.focusable_count; - self.pending_focus = self.focusables[next_idx]; - return; - } - } - } - - // No current focus, focus first - self.pending_focus = self.focusables[0]; - } - - /// Focus previous widget in order - fn focusPrev(self: *Self) void { - if (self.focusable_count == 0) return; - - if (self.focused_id) |current| { - // Find current index - for (self.focusables[0..self.focusable_count], 0..) |id, i| { - if (id == current) { - // Focus previous (wrap around) - const prev_idx = if (i == 0) self.focusable_count - 1 else i - 1; - self.pending_focus = self.focusables[prev_idx]; - return; - } - } - } - - // No current focus, focus last - self.pending_focus = self.focusables[self.focusable_count - 1]; - } - - /// Focus specific index - pub fn focusIndex(self: *Self, idx: usize) void { - if (idx < self.focusable_count) { - self.pending_focus = self.focusables[idx]; - } - } - - /// Get the index of the focused widget - pub fn focusedIndex(self: Self) ?usize { - if (self.focused_id) |current| { - for (self.focusables[0..self.focusable_count], 0..) |id, i| { - if (id == current) { - return i; - } - } - } - return null; - } -}; - -/// Focus ring - circular focus navigation helper -pub const FocusRing = struct { - ids: [MAX_FOCUSABLES]u32 = undefined, - count: usize = 0, - current: usize = 0, - - const Self = @This(); - - /// Add a widget ID to the ring - pub fn add(self: *Self, id: u32) void { - if (self.count >= MAX_FOCUSABLES) return; - self.ids[self.count] = id; - self.count += 1; - } - - /// Get current focused ID - pub fn currentId(self: Self) ?u32 { - if (self.count == 0) return null; - return self.ids[self.current]; - } - - /// Move to next - pub fn next(self: *Self) void { - if (self.count == 0) return; - self.current = (self.current + 1) % self.count; - } - - /// Move to previous - pub fn prev(self: *Self) void { - if (self.count == 0) return; - self.current = if (self.current == 0) self.count - 1 else self.current - 1; - } - - /// Check if widget has focus - pub fn isFocused(self: Self, id: u32) bool { - if (self.count == 0) return false; - return self.ids[self.current] == id; - } - - /// Focus specific widget by ID - pub fn focusId(self: *Self, id: u32) bool { - for (self.ids[0..self.count], 0..) |widget_id, i| { - if (widget_id == id) { - self.current = i; - return true; - } - } - return false; - } - - /// Reset the ring - pub fn reset(self: *Self) void { - self.count = 0; - self.current = 0; - } -}; - -// ============================================================================= -// Tests -// ============================================================================= - -test "FocusManager navigation" { - var fm = FocusManager{}; - - fm.beginFrame(); - fm.registerFocusable(100); - fm.registerFocusable(200); - fm.registerFocusable(300); - - // No focus initially - try std.testing.expectEqual(@as(?u32, null), fm.focused_id); - - // Tab to first - fm.tab_pressed = true; - fm.endFrame(); - fm.beginFrame(); - - try std.testing.expectEqual(@as(?u32, 100), fm.focused_id); - - // Register again for new frame - fm.registerFocusable(100); - fm.registerFocusable(200); - fm.registerFocusable(300); - - // Tab to second - fm.tab_pressed = true; - fm.endFrame(); - fm.beginFrame(); - - try std.testing.expectEqual(@as(?u32, 200), fm.focused_id); -} - -test "FocusRing" { - var ring = FocusRing{}; - - ring.add(10); - ring.add(20); - ring.add(30); - - try std.testing.expectEqual(@as(?u32, 10), ring.currentId()); - try std.testing.expect(ring.isFocused(10)); - - ring.next(); - try std.testing.expectEqual(@as(?u32, 20), ring.currentId()); - - ring.prev(); - try std.testing.expectEqual(@as(?u32, 10), ring.currentId()); - - ring.prev(); // Wrap to end - try std.testing.expectEqual(@as(?u32, 30), ring.currentId()); -} - -test "FocusRing focusId" { - var ring = FocusRing{}; - - ring.add(100); - ring.add(200); - ring.add(300); - - const found = ring.focusId(200); - try std.testing.expect(found); - try std.testing.expectEqual(@as(?u32, 200), ring.currentId()); -} diff --git a/src/widgets/table.zig b/src/widgets/table.zig index 97c1a2e..6d3f9a2 100644 --- a/src/widgets/table.zig +++ b/src/widgets/table.zig @@ -83,8 +83,11 @@ pub const TableConfig = struct { show_state_indicators: bool = true, /// Width of state indicator column state_indicator_width: u32 = 24, - /// Allow keyboard navigation + /// Allow keyboard navigation (arrows, Page Up/Down, etc.) keyboard_nav: bool = true, + /// Handle Tab key internally (navigate between cells) + /// Set to false if you want Tab to be handled by external focus system + handle_tab: bool = true, /// Allow cell editing allow_edit: bool = true, /// Show column headers @@ -674,6 +677,14 @@ pub fn tableRectFull( if (bounds.isEmpty() or columns.len == 0) return result; + // Ensure valid selection if table has data + // Without this, selected_row/col stay at -1 until user clicks, + // which breaks keyboard navigation and selectedCell() returns null + if (state.row_count > 0 and columns.len > 0) { + if (state.selected_row < 0) state.selected_row = 0; + if (state.selected_col < 0) state.selected_col = 0; + } + // Generate unique ID for this table based on state address const widget_id: u64 = @intFromPtr(state); @@ -1268,23 +1279,27 @@ fn handleKeyboard( }, .tab => { // Tab: next cell, Shift+Tab: previous cell - if (ctx.input.modifiers.shift) { - if (state.selected_col > 0) { - state.selected_col -= 1; - } else if (state.selected_row > 0) { - state.selected_row -= 1; - state.selected_col = @as(i32, @intCast(col_count)) - 1; - } - } else { - if (state.selected_col < @as(i32, @intCast(col_count)) - 1) { - state.selected_col += 1; - } else if (state.selected_row < @as(i32, @intCast(state.row_count)) - 1) { - state.selected_row += 1; - state.selected_col = 0; + // Only handle if config.handle_tab is true + if (config.handle_tab) { + if (ctx.input.modifiers.shift) { + if (state.selected_col > 0) { + state.selected_col -= 1; + } else if (state.selected_row > 0) { + state.selected_row -= 1; + state.selected_col = @as(i32, @intCast(col_count)) - 1; + } + } else { + if (state.selected_col < @as(i32, @intCast(col_count)) - 1) { + state.selected_col += 1; + } else if (state.selected_row < @as(i32, @intCast(state.row_count)) - 1) { + state.selected_row += 1; + state.selected_col = 0; + } } + state.ensureVisible(visible_rows); + result.selection_changed = true; } - state.ensureVisible(visible_rows); - result.selection_changed = true; + // If handle_tab is false, Tab is handled by external focus system }, .enter => { // Enter: start editing if not editing diff --git a/src/zcatgui.zig b/src/zcatgui.zig index f2fa087..eda907d 100644 --- a/src/zcatgui.zig +++ b/src/zcatgui.zig @@ -50,9 +50,9 @@ pub const DropResult = dragdrop.DropResult; pub const shortcuts = @import("core/shortcuts.zig"); pub const ShortcutManager = shortcuts.ShortcutManager; pub const Shortcut = shortcuts.Shortcut; -pub const focus_group = @import("core/focus_group.zig"); -pub const FocusGroup = focus_group.FocusGroup; -pub const FocusGroupManager = focus_group.FocusGroupManager; +pub const focus = @import("core/focus.zig"); +pub const FocusSystem = focus.FocusSystem; +pub const FocusGroup = focus.FocusGroup; pub const accessibility = @import("core/accessibility.zig"); pub const A11yRole = accessibility.Role; pub const A11yState = accessibility.State;