refactor: Unificar sistema de focus - trabajo en progreso

PROBLEMA DETECTADO:
- Existían dos sistemas de focus paralelos que no se comunicaban
- FocusManager (widgets/focus.zig) usaba IDs u32
- FocusGroupManager (core/focus_group.zig) usaba IDs u64
- Esto causaba que Tab no funcionara y clics no cambiaran focus

SOLUCIÓN CONSENSUADA:
- Usar SOLO FocusGroupManager como fuente de verdad
- Integrar en Context con métodos públicos
- Widgets se auto-registran en el grupo activo al dibujarse
- Tab navega DENTRO del grupo activo
- F6 (u otro) cambia entre grupos/paneles

CAMBIOS:
- context.zig: Añadidos createFocusGroup(), setActiveFocusGroup(),
  hasFocus(), requestFocus(), registerFocusable(), handleTabKey()
- text_input.zig: Usa @intFromPtr para ID único, se auto-registra
- table.zig: Ahora se registra como widget focusable
- widgets.zig/zcatgui.zig: Eliminadas referencias antiguas a FocusManager
- CLAUDE.md: Documentado el trabajo en progreso

ESTADO: EN PROGRESO - Compila pero requiere más testing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
reugenio 2025-12-11 00:26:22 +01:00
parent 88f5a15491
commit 9b6210c76e
7 changed files with 221 additions and 21 deletions

View file

@ -685,6 +685,56 @@ cd /mnt/cello2/arno/re/recode/zig/zcatgui
---
## TRABAJO EN PROGRESO: UNIFICACIÓN SISTEMA DE FOCUS (2025-12-11)
### Problema detectado
zcatgui tenía **dos sistemas de focus paralelos** que no se comunicaban:
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
Esto causaba que el Tab no funcionara y los clics no cambiaran el focus correctamente.
### Solución consensuada
1. **Eliminar duplicidad**: Usar SOLO `FocusGroupManager` como única fuente de verdad
2. **Integrar en Context**: Context expone métodos para crear grupos, registrar widgets, manejar focus
3. **Widgets auto-registran**: TextInput, Table, etc. se registran automáticamente en el grupo activo
4. **Grupos por panel**: Cada panel de la aplicación tiene su propio grupo de focus
### Cambios realizados
**`core/context.zig`**:
- Eliminado `FocusManager`, ahora usa `FocusGroupManager`
- Añadidos métodos: `createFocusGroup()`, `setActiveFocusGroup()`, `hasFocus()`, `requestFocus()`, `registerFocusable()`, `handleTabKey()`
- Los widgets se registran en el grupo activo cuando se dibujan
**`widgets/text_input.zig`**:
- Usa `@intFromPtr(state.buffer.ptr)` para ID único (u64)
- Llama `ctx.registerFocusable(widget_id)` al dibujarse
- Llama `ctx.requestFocus(widget_id)` al recibir clic
- Usa `ctx.hasFocus(widget_id)` para determinar estado visual
**`widgets/table.zig`**:
- Ahora se registra como widget focusable
- Usa `@intFromPtr(state)` para ID único
**`widgets/widgets.zig`** y **`zcatgui.zig`**:
- Eliminadas referencias a `focus.zig`, `FocusManager`, `FocusRing`
### Comportamiento esperado
- **Tab**: Navega entre widgets DENTRO del grupo de focus activo
- **F6** (o similar): Cambia entre grupos de focus (paneles)
- **Click**: Activa el grupo que contiene el widget clickeado
### Estado: EN PROGRESO
El sistema compila pero requiere más testing y posibles ajustes.
---
## PROYECTOS RELACIONADOS
| Proyecto | Ruta | Descripción |

View file

@ -5,11 +5,24 @@
//! - Command list (draw commands)
//! - Layout state
//! - ID tracking for widgets
//! - Focus management for keyboard navigation
//!
//! ## Performance Features
//! - FrameArena for O(1) per-frame allocations
//! - Command pooling for zero-allocation hot paths
//! - Dirty rectangle tracking for minimal redraws
//!
//! ## Focus Management
//! The Context uses FocusGroupManager for organizing widgets into focus groups.
//! Each group (typically a panel) contains focusable widgets.
//! Tab/Shift+Tab navigates within the active group.
//!
//! Usage:
//! 1. Application creates groups: `ctx.createFocusGroup(group_id)`
//! 2. Application sets active group: `ctx.setActiveFocusGroup(group_id)`
//! 3. Widgets register themselves: `ctx.registerFocusable(widget_id)` (into active group)
//! 4. Widgets check focus: `ctx.hasFocus(widget_id)`
//! 5. On click, widgets request focus: `ctx.requestFocus(widget_id)`
const std = @import("std");
const Allocator = std.mem.Allocator;
@ -20,6 +33,9 @@ const Layout = @import("layout.zig");
const Style = @import("style.zig");
const arena_mod = @import("../utils/arena.zig");
const FrameArena = arena_mod.FrameArena;
const focus_group = @import("focus_group.zig");
const FocusGroup = focus_group.FocusGroup;
const FocusGroupManager = focus_group.FocusGroupManager;
/// Central context for immediate mode UI
pub const Context = struct {
@ -57,6 +73,15 @@ pub const Context = struct {
/// Frame statistics
stats: FrameStats,
/// Focus group manager for keyboard navigation between widgets
/// Widgets are organized into groups (typically one per panel)
/// Tab navigates within the active group
focus_groups: FocusGroupManager,
/// Tab key state (set by handleTabKey, processed in endFrame)
tab_pressed: bool = false,
shift_tab_pressed: bool = false,
const Self = @This();
/// Frame statistics for performance monitoring
@ -88,6 +113,7 @@ pub const Context = struct {
.dirty_rects = .{},
.full_redraw = true,
.stats = .{},
.focus_groups = FocusGroupManager.init(),
};
}
@ -106,6 +132,7 @@ pub const Context = struct {
.dirty_rects = .{},
.full_redraw = true,
.stats = .{},
.focus_groups = FocusGroupManager.init(),
};
}
@ -135,11 +162,27 @@ pub const Context = struct {
self.stats.arena_bytes = 0;
self.stats.dirty_rect_count = 0;
// Note: focus_groups state persists across frames
// Tab navigation is processed in endFrame
self.frame += 1;
}
/// End the current frame
pub fn endFrame(self: *Self) void {
// Process Tab/Shift+Tab navigation within active group
if (self.tab_pressed or self.shift_tab_pressed) {
if (self.focus_groups.getActiveGroup()) |group| {
if (self.shift_tab_pressed) {
_ = group.focusPrevious();
} else {
_ = group.focusNext();
}
}
self.tab_pressed = false;
self.shift_tab_pressed = false;
}
self.input.endFrame();
// Update final stats
@ -151,6 +194,91 @@ pub const Context = struct {
self.full_redraw = false;
}
// =========================================================================
// Focus Group Management
// =========================================================================
/// Create a new focus group (typically one per panel)
/// Returns pointer to the group for adding widgets
pub fn createFocusGroup(self: *Self, group_id: u64) *FocusGroup {
return self.focus_groups.createGroup(group_id);
}
/// Set the active focus group
/// Tab navigation will only work within the active group
pub fn setActiveFocusGroup(self: *Self, group_id: u64) void {
self.focus_groups.setActiveGroup(group_id);
}
/// Get the active focus group
pub fn getActiveFocusGroup(self: *Self) ?*FocusGroup {
return self.focus_groups.getActiveGroup();
}
/// Get the active group ID
pub fn getActiveFocusGroupId(self: *Self) ?u64 {
return self.focus_groups.active_group;
}
// =========================================================================
// Focus Management Helpers (widget-level)
// =========================================================================
/// Check if a widget has focus
/// A widget has focus if it's the focused widget in the active group
pub fn hasFocus(self: *Self, widget_id: u64) bool {
if (self.focus_groups.getActiveGroup()) |group| {
return group.hasFocus(widget_id);
}
return false;
}
/// Request focus for a widget (e.g., when clicked)
/// This also activates the group containing the widget
pub fn requestFocus(self: *Self, widget_id: u64) void {
// Find which group contains this widget and activate it
for (self.focus_groups.groups[0..self.focus_groups.group_count]) |*group| {
if (group.setFocus(widget_id)) {
self.focus_groups.active_group = group.id;
return;
}
}
}
/// Register a widget as focusable in the active group
/// Call this during draw for each focusable widget
pub fn registerFocusable(self: *Self, widget_id: u64) void {
if (self.focus_groups.getActiveGroup()) |group| {
// Only add if not already in the group
if (group.indexOf(widget_id) == null) {
group.add(widget_id);
}
}
}
/// Process Tab key for focus navigation
/// Call this when Tab is pressed in the input handler
pub fn handleTabKey(self: *Self, shift: bool) void {
if (shift) {
self.shift_tab_pressed = true;
} else {
self.tab_pressed = true;
}
}
/// Check if a group contains the currently focused widget
/// Useful for panels to know if they should show focus highlight
pub fn groupHasFocus(self: *Self, group_id: u64) bool {
if (self.focus_groups.active_group) |active_id| {
if (active_id == group_id) {
if (self.focus_groups.getGroup(group_id)) |group| {
return group.getFocused() != null;
}
}
}
return false;
}
/// Get the frame allocator (use for per-frame allocations)
pub fn frameAllocator(self: *Self) Allocator {
return self.frame_arena.allocator();

View file

@ -29,11 +29,12 @@ pub const FocusManager = struct {
/// Reset for new frame
pub fn beginFrame(self: *Self) void {
// Reset focusable list - widgets will re-register during draw
self.focusable_count = 0;
self.tab_pressed = false;
self.shift_tab_pressed = false;
// Note: tab_pressed/shift_tab_pressed are NOT reset here
// They persist from the event loop and are processed in endFrame()
// Apply pending focus
// Apply pending focus from previous frame's Tab navigation
if (self.pending_focus) |id| {
self.focused_id = id;
self.pending_focus = null;
@ -78,13 +79,22 @@ pub const FocusManager = struct {
/// End of frame: process Tab navigation
pub fn endFrame(self: *Self) void {
if (self.focusable_count == 0) return;
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

View file

@ -674,14 +674,24 @@ pub fn tableRectFull(
if (bounds.isEmpty() or columns.len == 0) return result;
// Generate unique ID for this table based on state address
const widget_id: u64 = @intFromPtr(state);
// Register as focusable in the active focus group
ctx.registerFocusable(widget_id);
const mouse = ctx.input.mousePos();
const table_hovered = bounds.contains(mouse.x, mouse.y);
// Click for focus
// Click for focus - use the new focus system
if (table_hovered and ctx.input.mousePressed(.left)) {
state.focused = true;
ctx.requestFocus(widget_id);
}
// Check if this table has focus (via focus group system)
const has_focus = ctx.hasFocus(widget_id);
state.focused = has_focus;
// Calculate dimensions
const header_h = if (config.show_headers) config.header_height else 0;
const state_col_w = if (config.show_state_indicators) config.state_indicator_width else 0;

View file

@ -248,8 +248,11 @@ pub fn textInputRect(
if (bounds.isEmpty()) return result;
const id = ctx.getId(state.buffer.ptr[0..1]);
_ = id;
// Generate unique ID for this widget based on buffer memory address
const widget_id: u64 = @intFromPtr(state.buffer.ptr);
// Register as focusable in the active focus group
ctx.registerFocusable(widget_id);
// Check mouse interaction
const mouse = ctx.input.mousePos();
@ -257,14 +260,21 @@ pub fn textInputRect(
const clicked = hovered and ctx.input.mousePressed(.left);
if (clicked) {
state.focused = true;
// Request focus - this also activates the group containing this widget
ctx.requestFocus(widget_id);
result.clicked = true;
}
// Check if this widget has focus (is focused widget in active group)
const has_focus = ctx.hasFocus(widget_id);
// Sync state.focused for backwards compatibility
state.focused = has_focus;
// Theme colors
const theme = Style.Theme.dark;
const bg_color = if (state.focused) theme.input_bg.lighten(5) else theme.input_bg;
const border_color = if (state.focused) theme.primary else theme.input_border;
const bg_color = if (has_focus) theme.input_bg.lighten(5) else theme.input_bg;
const border_color = if (has_focus) theme.primary else theme.input_border;
const text_color = theme.input_fg;
const placeholder_color = theme.secondary;
@ -279,7 +289,7 @@ pub fn textInputRect(
if (inner.isEmpty()) return result;
// Handle keyboard input if focused
if (state.focused and !config.readonly) {
if (has_focus and !config.readonly) {
// Handle special keys (navigation, deletion)
for (ctx.input.key_events[0..ctx.input.key_event_count]) |key_event| {
if (key_event.pressed) {
@ -342,7 +352,7 @@ pub fn textInputRect(
}
// Draw cursor if focused
if (state.focused and !config.readonly) {
if (has_focus and !config.readonly) {
const char_width: u32 = 8;
const cursor_x = inner.x + @as(i32, @intCast(state.cursor * char_width));
const cursor_color = theme.foreground;

View file

@ -14,7 +14,6 @@ pub const text_input = @import("text_input.zig");
pub const checkbox = @import("checkbox.zig");
pub const select = @import("select.zig");
pub const list = @import("list.zig");
pub const focus = @import("focus.zig");
pub const table = @import("table.zig");
pub const split = @import("split.zig");
pub const panel = @import("panel.zig");
@ -99,11 +98,6 @@ pub const ListState = list.ListState;
pub const ListConfig = list.ListConfig;
pub const ListResult = list.ListResult;
// Focus
pub const Focus = focus;
pub const FocusManager = focus.FocusManager;
pub const FocusRing = focus.FocusRing;
// Table
pub const Table = table;
pub const TableState = table.TableState;

View file

@ -244,8 +244,6 @@ pub const list = widgets.list.list;
pub const listEx = widgets.list.listEx;
pub const ListState = widgets.ListState;
pub const FocusManager = widgets.FocusManager;
pub const FocusRing = widgets.FocusRing;
// =============================================================================
// Re-exports for convenience