zcatgui/src/core/focus_group.zig
reugenio 976d172501 feat: zcatgui v0.12.0 - Phase 6 Advanced Input
New Core Modules (4):

Clipboard - System clipboard integration
- getText()/setText() via SDL2 clipboard API
- hasText() and clear() utilities
- Cross-platform support

DragDrop - Drag and drop system
- DragData with typed data transfer
- DropZone registration with type filtering
- DragDropManager for coordinating operations
- Hover detection and drop results
- Helper functions: makeDraggable(), makeDropZone()

Shortcuts - Keyboard shortcuts system
- Shortcut struct with key + modifiers
- ShortcutManager for registration and checking
- Common shortcuts (Ctrl+C/V/X/Z, etc.)
- Human-readable formatting (formatShortcut)
- Enable/disable individual shortcuts

FocusGroup - Focus group management
- FocusGroup for widget tab order
- focusNext/Previous with wrap support
- FocusGroupManager for multiple groups
- Group switching (focusNextGroup)
- Tab/Shift+Tab navigation

All tests passing

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 13:41:43 +01:00

416 lines
11 KiB
Zig

//! 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);
}