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:
parent
9b6210c76e
commit
7cde6370d8
12 changed files with 1511 additions and 869 deletions
80
CLAUDE.md
80
CLAUDE.md
|
|
@ -685,53 +685,69 @@ cd /mnt/cello2/arno/re/recode/zig/zcatgui
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## TRABAJO EN PROGRESO: UNIFICACIÓN SISTEMA DE FOCUS (2025-12-11)
|
## TAREA PENDIENTE PRIORITARIA: SISTEMA DE FOCUS (2025-12-11)
|
||||||
|
|
||||||
### Problema detectado
|
> **IMPORTANTE**: Esta seccion describe trabajo incompleto que DEBE completarse.
|
||||||
|
> La sesion anterior hizo multiples intentos sin exito. Se requiere un analisis
|
||||||
|
> profundo antes de hacer mas cambios.
|
||||||
|
|
||||||
zcatgui tenía **dos sistemas de focus paralelos** que no se comunicaban:
|
### Documento de transicion (LEER PRIMERO)
|
||||||
|
|
||||||
1. **`FocusManager`** en `widgets/focus.zig` - Usaba IDs u32, no integrado con Context
|
```
|
||||||
2. **`FocusGroupManager`** en `core/focus_group.zig` - Usaba IDs u64, no usado por widgets
|
/mnt/cello2/arno/re/recode/zig/zcatgui/docs/FOCUS_TRANSITION_2025-12-11.md
|
||||||
|
```
|
||||||
|
|
||||||
Esto causaba que el Tab no funcionara y los clics no cambiaran el focus correctamente.
|
### Resumen del problema
|
||||||
|
|
||||||
### Solución consensuada
|
El sistema de focus fue rediseñado (unificado de dos sistemas a uno) pero **NO FUNCIONA**:
|
||||||
|
|
||||||
1. **Eliminar duplicidad**: Usar SOLO `FocusGroupManager` como única fuente de verdad
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
450
docs/FOCUS_SYSTEM_REDESIGN.md
Normal file
450
docs/FOCUS_SYSTEM_REDESIGN.md
Normal 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
|
||||||
148
docs/FOCUS_TRANSITION_2025-12-11.md
Normal file
148
docs/FOCUS_TRANSITION_2025-12-11.md
Normal 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)*
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
c.SDL_WINDOWEVENT_RESIZED => {
|
||||||
break :blk Event{
|
break :blk Event{
|
||||||
.resize = .{
|
.resize = .{
|
||||||
.width = @intCast(event.window.data1),
|
.width = @intCast(event.window.data1),
|
||||||
.height = @intCast(event.window.data2),
|
.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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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);
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
|
/// 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
666
src/core/focus.zig
Normal 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());
|
||||||
|
}
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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());
|
|
||||||
}
|
|
||||||
|
|
@ -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,6 +1279,8 @@ fn handleKeyboard(
|
||||||
},
|
},
|
||||||
.tab => {
|
.tab => {
|
||||||
// Tab: next cell, Shift+Tab: previous cell
|
// Tab: next cell, Shift+Tab: previous cell
|
||||||
|
// Only handle if config.handle_tab is true
|
||||||
|
if (config.handle_tab) {
|
||||||
if (ctx.input.modifiers.shift) {
|
if (ctx.input.modifiers.shift) {
|
||||||
if (state.selected_col > 0) {
|
if (state.selected_col > 0) {
|
||||||
state.selected_col -= 1;
|
state.selected_col -= 1;
|
||||||
|
|
@ -1285,6 +1298,8 @@ fn handleKeyboard(
|
||||||
}
|
}
|
||||||
state.ensureVisible(visible_rows);
|
state.ensureVisible(visible_rows);
|
||||||
result.selection_changed = true;
|
result.selection_changed = true;
|
||||||
|
}
|
||||||
|
// If handle_tab is false, Tab is handled by external focus system
|
||||||
},
|
},
|
||||||
.enter => {
|
.enter => {
|
||||||
// Enter: start editing if not editing
|
// Enter: start editing if not editing
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue