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 1. Al iniciar app, ambos paneles muestran focus visual
2. **Integrar en Context**: Context expone métodos para crear grupos, registrar widgets, manejar focus 2. Teclado no responde hasta hacer clic (flechas, Tab)
3. **Widgets auto-registran**: TextInput, Table, etc. se registran automáticamente en el grupo activo 3. Despues de clic en panel derecho, flechas siguen moviendo tabla izquierda
4. **Grupos por panel**: Cada panel de la aplicación tiene su propio grupo de focus
### Cambios realizados ### Cambios realizados en esta sesion
**`core/context.zig`**: **Archivos creados**:
- Eliminado `FocusManager`, ahora usa `FocusGroupManager` - `core/focus.zig` - Nuevo FocusSystem unificado
- Añadidos métodos: `createFocusGroup()`, `setActiveFocusGroup()`, `hasFocus()`, `requestFocus()`, `registerFocusable()`, `handleTabKey()`
- Los widgets se registran en el grupo activo cuando se dibujan
**`widgets/text_input.zig`**: **Archivos eliminados**:
- Usa `@intFromPtr(state.buffer.ptr)` para ID único (u64) - `widgets/focus.zig` - FocusManager viejo
- Llama `ctx.registerFocusable(widget_id)` al dibujarse - `core/focus_group.zig` - FocusGroupManager viejo
- Llama `ctx.requestFocus(widget_id)` al recibir clic
- Usa `ctx.hasFocus(widget_id)` para determinar estado visual
**`widgets/table.zig`**: **Archivos modificados**:
- Ahora se registra como widget focusable - `core/context.zig` - Usa FocusSystem, metodos de conveniencia
- Usa `@intFromPtr(state)` para ID único - `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`**: ### Lo que funciona
- Eliminadas referencias a `focus.zig`, `FocusManager`, `FocusRing`
### 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 ### Lo que NO funciona
- **F6** (o similar): Cambia entre grupos de focus (paneles)
- **Click**: Activa el grupo que contiene el widget clickeado
### 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, text: [32]u8,
len: usize, len: usize,
}, },
/// Window needs redraw (exposed, focus gained, restored, etc.)
/// Application should trigger a full redraw when receiving this
window_exposed,
}; };
/// Abstract backend interface /// Abstract backend interface

View file

@ -205,15 +205,26 @@ pub const Sdl2Backend = struct {
}, },
c.SDL_WINDOWEVENT => blk: { c.SDL_WINDOWEVENT => blk: {
if (event.window.event == c.SDL_WINDOWEVENT_RESIZED) { switch (event.window.event) {
break :blk Event{ c.SDL_WINDOWEVENT_RESIZED => {
.resize = .{ break :blk Event{
.width = @intCast(event.window.data1), .resize = .{
.height = @intCast(event.window.data2), .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, else => null,

View file

@ -13,16 +13,28 @@
//! - Dirty rectangle tracking for minimal redraws //! - Dirty rectangle tracking for minimal redraws
//! //!
//! ## Focus Management //! ## Focus Management
//! The Context uses FocusGroupManager for organizing widgets into focus groups. //! The Context uses FocusSystem for managing widget focus:
//! Each group (typically a panel) contains focusable widgets. //! - Group 0 is the implicit global group (always exists)
//! Tab/Shift+Tab navigates within the active group. //! - If no groups are created, Tab navigates all widgets (like microui/Gio)
//! - If groups are created, Tab navigates within active group
//! //!
//! Usage: //! Usage (simple app - no groups needed):
//! 1. Application creates groups: `ctx.createFocusGroup(group_id)` //! ```zig
//! 2. Application sets active group: `ctx.setActiveFocusGroup(group_id)` //! ctx.focus.register(widget_id);
//! 3. Widgets register themselves: `ctx.registerFocusable(widget_id)` (into active group) //! if (ctx.focus.hasFocus(widget_id)) { ... }
//! 4. Widgets check focus: `ctx.hasFocus(widget_id)` //! ```
//! 5. On click, widgets request focus: `ctx.requestFocus(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 std = @import("std");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
@ -33,9 +45,9 @@ const Layout = @import("layout.zig");
const Style = @import("style.zig"); const Style = @import("style.zig");
const arena_mod = @import("../utils/arena.zig"); const arena_mod = @import("../utils/arena.zig");
const FrameArena = arena_mod.FrameArena; const FrameArena = arena_mod.FrameArena;
const focus_group = @import("focus_group.zig"); const focus_mod = @import("focus.zig");
const FocusGroup = focus_group.FocusGroup; const FocusSystem = focus_mod.FocusSystem;
const FocusGroupManager = focus_group.FocusGroupManager; const FocusGroup = focus_mod.FocusGroup;
/// Central context for immediate mode UI /// Central context for immediate mode UI
pub const Context = struct { pub const Context = struct {
@ -73,14 +85,8 @@ pub const Context = struct {
/// Frame statistics /// Frame statistics
stats: FrameStats, stats: FrameStats,
/// Focus group manager for keyboard navigation between widgets /// Unified focus management system
/// Widgets are organized into groups (typically one per panel) focus: FocusSystem,
/// 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,
const Self = @This(); const Self = @This();
@ -113,7 +119,7 @@ pub const Context = struct {
.dirty_rects = .{}, .dirty_rects = .{},
.full_redraw = true, .full_redraw = true,
.stats = .{}, .stats = .{},
.focus_groups = FocusGroupManager.init(), .focus = FocusSystem.init(),
}; };
} }
@ -132,7 +138,7 @@ pub const Context = struct {
.dirty_rects = .{}, .dirty_rects = .{},
.full_redraw = true, .full_redraw = true,
.stats = .{}, .stats = .{},
.focus_groups = FocusGroupManager.init(), .focus = FocusSystem.init(),
}; };
} }
@ -162,26 +168,16 @@ pub const Context = struct {
self.stats.arena_bytes = 0; self.stats.arena_bytes = 0;
self.stats.dirty_rect_count = 0; self.stats.dirty_rect_count = 0;
// Note: focus_groups state persists across frames // Focus system frame start
// Tab navigation is processed in endFrame self.focus.beginFrame();
self.frame += 1; self.frame += 1;
} }
/// End the current frame /// End the current frame
pub fn endFrame(self: *Self) void { pub fn endFrame(self: *Self) void {
// Process Tab/Shift+Tab navigation within active group // Focus system frame end (processes Tab navigation)
if (self.tab_pressed or self.shift_tab_pressed) { self.focus.endFrame();
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;
}
self.input.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 /// 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 { pub fn registerFocusable(self: *Self, widget_id: u64) void {
if (self.focus_groups.getActiveGroup()) |group| { self.focus.register(widget_id);
// Only add if not already in the group
if (group.indexOf(widget_id) == null) {
group.add(widget_id);
}
}
} }
/// Process Tab key for focus navigation /// Check if widget has focus
/// Call this when Tab is pressed in the input handler 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 { pub fn handleTabKey(self: *Self, shift: bool) void {
if (shift) { self.focus.handleTab(shift);
self.shift_tab_pressed = true;
} else {
self.tab_pressed = true;
}
} }
/// Check if a group contains the currently focused widget /// Create a new focus group
/// Useful for panels to know if they should show focus highlight pub fn createFocusGroup(self: *Self, group_id: u64) ?*FocusGroup {
pub fn groupHasFocus(self: *Self, group_id: u64) bool { return self.focus.createGroup(group_id);
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;
} }
/// 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) /// Get the frame allocator (use for per-frame allocations)
pub fn frameAllocator(self: *Self) Allocator { pub fn frameAllocator(self: *Self) Allocator {
return self.frame_arena.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, 2), stats.command_count);
try std.testing.expectEqual(@as(usize, 3), stats.widget_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); 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); try std.testing.expect(input.navKeyPressed() == .down);
input.endFrame(); 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); 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, show_state_indicators: bool = true,
/// Width of state indicator column /// Width of state indicator column
state_indicator_width: u32 = 24, state_indicator_width: u32 = 24,
/// Allow keyboard navigation /// Allow keyboard navigation (arrows, Page Up/Down, etc.)
keyboard_nav: bool = true, 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 cell editing
allow_edit: bool = true, allow_edit: bool = true,
/// Show column headers /// Show column headers
@ -674,6 +677,14 @@ pub fn tableRectFull(
if (bounds.isEmpty() or columns.len == 0) return result; 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 // Generate unique ID for this table based on state address
const widget_id: u64 = @intFromPtr(state); const widget_id: u64 = @intFromPtr(state);
@ -1268,23 +1279,27 @@ fn handleKeyboard(
}, },
.tab => { .tab => {
// Tab: next cell, Shift+Tab: previous cell // Tab: next cell, Shift+Tab: previous cell
if (ctx.input.modifiers.shift) { // Only handle if config.handle_tab is true
if (state.selected_col > 0) { if (config.handle_tab) {
state.selected_col -= 1; if (ctx.input.modifiers.shift) {
} else if (state.selected_row > 0) { if (state.selected_col > 0) {
state.selected_row -= 1; state.selected_col -= 1;
state.selected_col = @as(i32, @intCast(col_count)) - 1; } else if (state.selected_row > 0) {
} state.selected_row -= 1;
} else { state.selected_col = @as(i32, @intCast(col_count)) - 1;
if (state.selected_col < @as(i32, @intCast(col_count)) - 1) { }
state.selected_col += 1; } else {
} else if (state.selected_row < @as(i32, @intCast(state.row_count)) - 1) { if (state.selected_col < @as(i32, @intCast(col_count)) - 1) {
state.selected_row += 1; state.selected_col += 1;
state.selected_col = 0; } 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); // If handle_tab is false, Tab is handled by external focus system
result.selection_changed = true;
}, },
.enter => { .enter => {
// Enter: start editing if not editing // 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 shortcuts = @import("core/shortcuts.zig");
pub const ShortcutManager = shortcuts.ShortcutManager; pub const ShortcutManager = shortcuts.ShortcutManager;
pub const Shortcut = shortcuts.Shortcut; pub const Shortcut = shortcuts.Shortcut;
pub const focus_group = @import("core/focus_group.zig"); pub const focus = @import("core/focus.zig");
pub const FocusGroup = focus_group.FocusGroup; pub const FocusSystem = focus.FocusSystem;
pub const FocusGroupManager = focus_group.FocusGroupManager; pub const FocusGroup = focus.FocusGroup;
pub const accessibility = @import("core/accessibility.zig"); pub const accessibility = @import("core/accessibility.zig");
pub const A11yRole = accessibility.Role; pub const A11yRole = accessibility.Role;
pub const A11yState = accessibility.State; pub const A11yState = accessibility.State;