From 9b6210c76e30bb74973fd25b24e3b2ead43f09d1 Mon Sep 17 00:00:00 2001 From: reugenio Date: Thu, 11 Dec 2025 00:26:22 +0100 Subject: [PATCH] refactor: Unificar sistema de focus - trabajo en progreso MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 50 +++++++++++++++ src/core/context.zig | 128 +++++++++++++++++++++++++++++++++++++ src/widgets/focus.zig | 18 ++++-- src/widgets/table.zig | 14 +++- src/widgets/text_input.zig | 24 +++++-- src/widgets/widgets.zig | 6 -- src/zcatgui.zig | 2 - 7 files changed, 221 insertions(+), 21 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b38edc4..c5d8c3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | diff --git a/src/core/context.zig b/src/core/context.zig index 670159f..bb36113 100644 --- a/src/core/context.zig +++ b/src/core/context.zig @@ -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(); diff --git a/src/widgets/focus.zig b/src/widgets/focus.zig index 127ae62..0246a85 100644 --- a/src/widgets/focus.zig +++ b/src/widgets/focus.zig @@ -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 diff --git a/src/widgets/table.zig b/src/widgets/table.zig index e6e2d58..97c1a2e 100644 --- a/src/widgets/table.zig +++ b/src/widgets/table.zig @@ -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; diff --git a/src/widgets/text_input.zig b/src/widgets/text_input.zig index b376042..b9072fe 100644 --- a/src/widgets/text_input.zig +++ b/src/widgets/text_input.zig @@ -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; diff --git a/src/widgets/widgets.zig b/src/widgets/widgets.zig index 8796bae..6ab6aca 100644 --- a/src/widgets/widgets.zig +++ b/src/widgets/widgets.zig @@ -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; diff --git a/src/zcatgui.zig b/src/zcatgui.zig index 413bbc7..f2fa087 100644 --- a/src/zcatgui.zig +++ b/src/zcatgui.zig @@ -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