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>
416 lines
11 KiB
Zig
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);
|
|
}
|