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>
This commit is contained in:
reugenio 2025-12-11 17:55:08 +01:00
parent 9b6210c76e
commit 7cde6370d8
12 changed files with 1511 additions and 869 deletions

View file

@ -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.
---

View file

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

View file

@ -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)*

View file

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

View file

@ -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,

View file

@ -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();
}

666
src/core/focus.zig Normal file
View file

@ -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());
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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());
}

View file

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

View file

@ -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;