Archivos que estaban localmente pero no en el repo: - src/core/focus_group.zig (416 líneas) - src/widgets/focus.zig (272 líneas) - src/widgets/progress.zig (806 líneas) - src/widgets/table.zig (1592 líneas) - src/widgets/textarea.zig (871 líneas) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
272 lines
7.4 KiB
Zig
272 lines
7.4 KiB
Zig
//! 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 {
|
|
self.focusable_count = 0;
|
|
self.tab_pressed = false;
|
|
self.shift_tab_pressed = false;
|
|
|
|
// Apply pending focus
|
|
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) return;
|
|
|
|
if (self.tab_pressed) {
|
|
self.focusNext();
|
|
} else if (self.shift_tab_pressed) {
|
|
self.focusPrev();
|
|
}
|
|
}
|
|
|
|
/// 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());
|
|
}
|