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>
This commit is contained in:
reugenio 2025-12-09 13:41:43 +01:00
parent bb5b201203
commit 976d172501
5 changed files with 1276 additions and 0 deletions

103
src/core/clipboard.zig Normal file
View file

@ -0,0 +1,103 @@
//! Clipboard - System clipboard integration
//!
//! Provides clipboard operations for copy/paste functionality.
//! Uses SDL2 clipboard API for cross-platform support.
const std = @import("std");
const c = @cImport({
@cInclude("SDL2/SDL.h");
});
/// Clipboard error types
pub const ClipboardError = error{
SDLError,
AllocationError,
NoText,
};
/// Get text from clipboard
/// Caller owns returned memory and must free it
pub fn getText(allocator: std.mem.Allocator) ClipboardError!?[]u8 {
if (!hasText()) {
return null;
}
const sdl_text = c.SDL_GetClipboardText();
if (sdl_text == null) {
return ClipboardError.SDLError;
}
defer c.SDL_free(sdl_text);
// Get length
const len = std.mem.len(sdl_text);
if (len == 0) {
return null;
}
// Copy to Zig-managed memory
const result = allocator.alloc(u8, len) catch {
return ClipboardError.AllocationError;
};
@memcpy(result, sdl_text[0..len]);
return result;
}
/// Set text to clipboard
pub fn setText(text: []const u8) ClipboardError!void {
// SDL requires null-terminated string
// Create a temporary null-terminated copy
var buf: [4096]u8 = undefined;
if (text.len >= buf.len) {
return ClipboardError.AllocationError;
}
@memcpy(buf[0..text.len], text);
buf[text.len] = 0;
const result = c.SDL_SetClipboardText(&buf);
if (result != 0) {
return ClipboardError.SDLError;
}
}
/// Check if clipboard has text
pub fn hasText() bool {
return c.SDL_HasClipboardText() == c.SDL_TRUE;
}
/// Clear clipboard
pub fn clear() void {
_ = c.SDL_SetClipboardText("");
}
/// Get clipboard text length without copying
pub fn getTextLength() usize {
if (!hasText()) {
return 0;
}
const sdl_text = c.SDL_GetClipboardText();
if (sdl_text == null) {
return 0;
}
defer c.SDL_free(sdl_text);
return std.mem.len(sdl_text);
}
// =============================================================================
// Tests
// =============================================================================
test "clipboard operations" {
// Note: These tests require SDL2 to be initialized
// In a real test environment, SDL_Init would be called first
// Test hasText (should work without SDL init for checking)
_ = hasText();
}
test "clipboard text length" {
_ = getTextLength();
}

362
src/core/dragdrop.zig Normal file
View file

@ -0,0 +1,362 @@
//! Drag & Drop System
//!
//! Provides generic drag and drop functionality for widgets.
//! Supports typed data transfer between drag sources and drop zones.
const std = @import("std");
const Layout = @import("layout.zig");
const Input = @import("input.zig");
/// Maximum number of drop zones that can be registered
const MAX_DROP_ZONES = 64;
/// Drag data that can be transferred
pub const DragData = struct {
/// Unique identifier of the drag source
source_id: u64,
/// Type identifier for the data (e.g., "file", "item", "node")
data_type: []const u8,
/// Optional user data pointer
user_data: ?*anyopaque = null,
/// Display text for drag preview
preview_text: ?[]const u8 = null,
/// Start position of drag
start_x: i32 = 0,
start_y: i32 = 0,
};
/// Drop zone configuration
pub const DropZone = struct {
/// Unique identifier for this zone
id: u64,
/// Bounds of the drop zone
bounds: Layout.Rect,
/// Types this zone accepts (empty = accept all)
accepts: []const []const u8,
/// Highlight when drag hovers over
highlight_on_hover: bool = true,
/// User data for this zone
user_data: ?*anyopaque = null,
};
/// Result of a drop operation
pub const DropResult = struct {
/// Whether a drop occurred
dropped: bool = false,
/// The dropped data
data: ?DragData = null,
/// The zone that received the drop
zone_id: u64 = 0,
};
/// Drag & Drop manager
pub const DragDropManager = struct {
/// Currently dragged data
current_drag: ?DragData = null,
/// Is drag in progress
dragging: bool = false,
/// Current mouse position during drag
drag_x: i32 = 0,
drag_y: i32 = 0,
/// Registered drop zones
drop_zones: [MAX_DROP_ZONES]DropZone = undefined,
/// Number of registered zones
zone_count: usize = 0,
/// Currently hovered zone index
hovered_zone: ?usize = null,
const Self = @This();
/// Initialize a new drag drop manager
pub fn init() Self {
return .{};
}
/// Start a drag operation
pub fn startDrag(self: *Self, data: DragData) void {
self.current_drag = data;
self.dragging = true;
self.drag_x = data.start_x;
self.drag_y = data.start_y;
}
/// Update drag position
pub fn updateDrag(self: *Self, x: i32, y: i32) void {
if (!self.dragging) return;
self.drag_x = x;
self.drag_y = y;
// Update hovered zone
self.hovered_zone = null;
for (self.drop_zones[0..self.zone_count], 0..) |zone, i| {
if (zone.bounds.contains(x, y)) {
// Check if zone accepts this data type
if (self.current_drag) |drag| {
if (self.zoneAccepts(zone, drag.data_type)) {
self.hovered_zone = i;
break;
}
}
}
}
}
/// End drag and potentially drop
pub fn endDrag(self: *Self) DropResult {
var result = DropResult{};
if (self.dragging and self.current_drag != null) {
if (self.hovered_zone) |zone_idx| {
const zone = self.drop_zones[zone_idx];
result.dropped = true;
result.data = self.current_drag;
result.zone_id = zone.id;
}
}
// Reset state
self.current_drag = null;
self.dragging = false;
self.hovered_zone = null;
return result;
}
/// Cancel drag without dropping
pub fn cancelDrag(self: *Self) void {
self.current_drag = null;
self.dragging = false;
self.hovered_zone = null;
}
/// Check if drag is in progress
pub fn isDragging(self: Self) bool {
return self.dragging;
}
/// Get current drag data
pub fn getDragData(self: Self) ?DragData {
return self.current_drag;
}
/// Register a drop zone (valid for one frame)
pub fn registerDropZone(self: *Self, zone: DropZone) void {
if (self.zone_count >= MAX_DROP_ZONES) return;
self.drop_zones[self.zone_count] = zone;
self.zone_count += 1;
}
/// Clear all drop zones (call at frame start)
pub fn clearDropZones(self: *Self) void {
self.zone_count = 0;
self.hovered_zone = null;
}
/// Get the currently hovered drop zone
pub fn getHoveredZone(self: Self) ?DropZone {
if (self.hovered_zone) |idx| {
return self.drop_zones[idx];
}
return null;
}
/// Check if a zone is being hovered
pub fn isZoneHovered(self: Self, zone_id: u64) bool {
if (self.hovered_zone) |idx| {
return self.drop_zones[idx].id == zone_id;
}
return false;
}
/// Check if a zone accepts a data type
fn zoneAccepts(self: Self, zone: DropZone, data_type: []const u8) bool {
_ = self;
// Empty accepts list means accept all
if (zone.accepts.len == 0) return true;
for (zone.accepts) |accepted| {
if (std.mem.eql(u8, accepted, data_type)) {
return true;
}
}
return false;
}
/// Get drag offset from start position
pub fn getDragOffset(self: Self) struct { x: i32, y: i32 } {
if (self.current_drag) |drag| {
return .{
.x = self.drag_x - drag.start_x,
.y = self.drag_y - drag.start_y,
};
}
return .{ .x = 0, .y = 0 };
}
};
/// Helper to make a widget draggable
pub fn makeDraggable(
manager: *DragDropManager,
input: *const Input.InputState,
bounds: Layout.Rect,
source_id: u64,
data_type: []const u8,
) bool {
const mouse = input.mousePos();
const left_pressed = input.mousePressed(.left);
const left_down = input.mouseDown(.left);
const left_released = input.mouseReleased(.left);
// Check if starting drag
if (bounds.contains(mouse.x, mouse.y) and left_pressed and !manager.isDragging()) {
manager.startDrag(.{
.source_id = source_id,
.data_type = data_type,
.start_x = mouse.x,
.start_y = mouse.y,
});
return true;
}
// Update drag position
if (manager.isDragging() and left_down) {
manager.updateDrag(mouse.x, mouse.y);
}
// End drag on release
if (manager.isDragging() and left_released) {
_ = manager.endDrag();
}
return manager.isDragging();
}
/// Helper to register a drop zone
pub fn makeDropZone(
manager: *DragDropManager,
bounds: Layout.Rect,
zone_id: u64,
accepts: []const []const u8,
) void {
manager.registerDropZone(.{
.id = zone_id,
.bounds = bounds,
.accepts = accepts,
});
}
// =============================================================================
// Tests
// =============================================================================
test "DragDropManager init" {
var manager = DragDropManager.init();
try std.testing.expect(!manager.isDragging());
try std.testing.expect(manager.getDragData() == null);
}
test "DragDropManager drag lifecycle" {
var manager = DragDropManager.init();
// Start drag
manager.startDrag(.{
.source_id = 1,
.data_type = "item",
.start_x = 100,
.start_y = 100,
});
try std.testing.expect(manager.isDragging());
try std.testing.expect(manager.getDragData() != null);
// Update position
manager.updateDrag(150, 150);
try std.testing.expectEqual(@as(i32, 150), manager.drag_x);
// End drag
const result = manager.endDrag();
try std.testing.expect(!result.dropped); // No drop zone registered
try std.testing.expect(!manager.isDragging());
}
test "DragDropManager drop zones" {
var manager = DragDropManager.init();
// Register drop zone
manager.registerDropZone(.{
.id = 1,
.bounds = Layout.Rect.init(0, 0, 100, 100),
.accepts = &.{},
});
try std.testing.expectEqual(@as(usize, 1), manager.zone_count);
// Start drag
manager.startDrag(.{
.source_id = 1,
.data_type = "item",
.start_x = 50,
.start_y = 50,
});
// Move to drop zone
manager.updateDrag(50, 50);
try std.testing.expect(manager.hovered_zone != null);
// Drop
const result = manager.endDrag();
try std.testing.expect(result.dropped);
try std.testing.expectEqual(@as(u64, 1), result.zone_id);
}
test "DragDropManager clear zones" {
var manager = DragDropManager.init();
manager.registerDropZone(.{
.id = 1,
.bounds = Layout.Rect.init(0, 0, 100, 100),
.accepts = &.{},
});
try std.testing.expectEqual(@as(usize, 1), manager.zone_count);
manager.clearDropZones();
try std.testing.expectEqual(@as(usize, 0), manager.zone_count);
}
test "DragDropManager type filtering" {
var manager = DragDropManager.init();
const accepted_types = [_][]const u8{"file"};
manager.registerDropZone(.{
.id = 1,
.bounds = Layout.Rect.init(0, 0, 100, 100),
.accepts = &accepted_types,
});
// Drag wrong type
manager.startDrag(.{
.source_id = 1,
.data_type = "item", // Not "file"
.start_x = 50,
.start_y = 50,
});
manager.updateDrag(50, 50);
try std.testing.expect(manager.hovered_zone == null); // Not accepted
manager.cancelDrag();
// Drag correct type
manager.startDrag(.{
.source_id = 1,
.data_type = "file",
.start_x = 50,
.start_y = 50,
});
manager.updateDrag(50, 50);
try std.testing.expect(manager.hovered_zone != null); // Accepted
}

416
src/core/focus_group.zig Normal file
View file

@ -0,0 +1,416 @@
//! 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);
}

383
src/core/shortcuts.zig Normal file
View file

@ -0,0 +1,383 @@
//! Keyboard Shortcuts System
//!
//! Manages application-wide keyboard shortcuts with modifier support.
//! Provides human-readable shortcut text (e.g., "Ctrl+S").
const std = @import("std");
const Input = @import("input.zig");
/// Maximum shortcuts that can be registered
const MAX_SHORTCUTS = 128;
/// Key modifiers
pub const Modifiers = packed struct {
ctrl: bool = false,
shift: bool = false,
alt: bool = false,
super: bool = false, // Windows/Command key
pub fn none() Modifiers {
return .{};
}
pub fn ctrl_only() Modifiers {
return .{ .ctrl = true };
}
pub fn ctrl_shift() Modifiers {
return .{ .ctrl = true, .shift = true };
}
pub fn alt_only() Modifiers {
return .{ .alt = true };
}
pub fn eql(self: Modifiers, other: Modifiers) bool {
return self.ctrl == other.ctrl and
self.shift == other.shift and
self.alt == other.alt and
self.super == other.super;
}
pub fn fromInput(input: *const Input.InputState) Modifiers {
return .{
.ctrl = input.keyDown(.left_ctrl) or input.keyDown(.right_ctrl),
.shift = input.keyDown(.left_shift) or input.keyDown(.right_shift),
.alt = input.keyDown(.left_alt) or input.keyDown(.right_alt),
.super = input.keyDown(.left_super) or input.keyDown(.right_super),
};
}
};
/// A keyboard shortcut definition
pub const Shortcut = struct {
/// The key code
key: Input.Key,
/// Required modifiers
modifiers: Modifiers,
/// Action identifier
action: []const u8,
/// Human-readable description
description: []const u8 = "",
/// Is this shortcut enabled
enabled: bool = true,
/// Create a shortcut with Ctrl modifier
pub fn ctrl(key: Input.Key, action: []const u8) Shortcut {
return .{
.key = key,
.modifiers = Modifiers.ctrl_only(),
.action = action,
};
}
/// Create a shortcut with Ctrl+Shift modifiers
pub fn ctrlShift(key: Input.Key, action: []const u8) Shortcut {
return .{
.key = key,
.modifiers = Modifiers.ctrl_shift(),
.action = action,
};
}
/// Create a shortcut with Alt modifier
pub fn alt(key: Input.Key, action: []const u8) Shortcut {
return .{
.key = key,
.modifiers = Modifiers.alt_only(),
.action = action,
};
}
/// Create a shortcut with no modifiers
pub fn key_only(key: Input.Key, action: []const u8) Shortcut {
return .{
.key = key,
.modifiers = Modifiers.none(),
.action = action,
};
}
/// Check if this shortcut matches the given key and modifiers
pub fn matches(self: Shortcut, key: Input.Key, mods: Modifiers) bool {
return self.enabled and self.key == key and self.modifiers.eql(mods);
}
};
/// Shortcut manager for registering and checking shortcuts
pub const ShortcutManager = struct {
shortcuts: [MAX_SHORTCUTS]Shortcut = undefined,
count: usize = 0,
const Self = @This();
/// Initialize a new shortcut manager
pub fn init() Self {
return .{};
}
/// Register a shortcut
pub fn register(self: *Self, shortcut: Shortcut) void {
if (self.count >= MAX_SHORTCUTS) return;
self.shortcuts[self.count] = shortcut;
self.count += 1;
}
/// Register common shortcuts (Ctrl+C, Ctrl+V, etc.)
pub fn registerCommon(self: *Self) void {
self.register(Shortcut.ctrl(.c, "copy"));
self.register(Shortcut.ctrl(.v, "paste"));
self.register(Shortcut.ctrl(.x, "cut"));
self.register(Shortcut.ctrl(.z, "undo"));
self.register(Shortcut.ctrlShift(.z, "redo"));
self.register(Shortcut.ctrl(.y, "redo"));
self.register(Shortcut.ctrl(.s, "save"));
self.register(Shortcut.ctrl(.a, "select_all"));
self.register(Shortcut.ctrl(.n, "new"));
self.register(Shortcut.ctrl(.o, "open"));
self.register(Shortcut.ctrl(.w, "close"));
self.register(Shortcut.ctrl(.q, "quit"));
self.register(Shortcut.ctrl(.f, "find"));
self.register(Shortcut.key_only(.f1, "help"));
self.register(Shortcut.key_only(.escape, "cancel"));
}
/// Unregister a shortcut by action
pub fn unregister(self: *Self, action: []const u8) void {
var i: usize = 0;
while (i < self.count) {
if (std.mem.eql(u8, self.shortcuts[i].action, action)) {
// Shift remaining shortcuts down
var j = i;
while (j < self.count - 1) : (j += 1) {
self.shortcuts[j] = self.shortcuts[j + 1];
}
self.count -= 1;
} else {
i += 1;
}
}
}
/// Enable/disable a shortcut by action
pub fn setEnabled(self: *Self, action: []const u8, enabled: bool) void {
for (self.shortcuts[0..self.count]) |*shortcut| {
if (std.mem.eql(u8, shortcut.action, action)) {
shortcut.enabled = enabled;
}
}
}
/// Check if any shortcut was triggered
/// Returns the action string if a shortcut matched, null otherwise
pub fn check(self: *Self, input: *const Input.InputState) ?[]const u8 {
const mods = Modifiers.fromInput(input);
for (self.shortcuts[0..self.count]) |shortcut| {
if (input.keyPressed(shortcut.key) and shortcut.matches(shortcut.key, mods)) {
return shortcut.action;
}
}
return null;
}
/// Get shortcut by action
pub fn getByAction(self: Self, action: []const u8) ?Shortcut {
for (self.shortcuts[0..self.count]) |shortcut| {
if (std.mem.eql(u8, shortcut.action, action)) {
return shortcut;
}
}
return null;
}
/// Get human-readable text for a shortcut (e.g., "Ctrl+S")
pub fn getShortcutText(self: Self, buf: []u8, action: []const u8) []const u8 {
if (self.getByAction(action)) |shortcut| {
return formatShortcut(buf, shortcut);
}
return "";
}
/// Clear all shortcuts
pub fn clear(self: *Self) void {
self.count = 0;
}
};
/// Format a shortcut as human-readable text
pub fn formatShortcut(buf: []u8, shortcut: Shortcut) []const u8 {
var stream = std.io.fixedBufferStream(buf);
const writer = stream.writer();
if (shortcut.modifiers.ctrl) {
writer.writeAll("Ctrl+") catch return "";
}
if (shortcut.modifiers.alt) {
writer.writeAll("Alt+") catch return "";
}
if (shortcut.modifiers.shift) {
writer.writeAll("Shift+") catch return "";
}
if (shortcut.modifiers.super) {
writer.writeAll("Super+") catch return "";
}
const key_name = keyName(shortcut.key);
writer.writeAll(key_name) catch return "";
return buf[0..stream.pos];
}
/// Get human-readable name for a key
pub fn keyName(key: Input.Key) []const u8 {
return switch (key) {
.a => "A",
.b => "B",
.c => "C",
.d => "D",
.e => "E",
.f => "F",
.g => "G",
.h => "H",
.i => "I",
.j => "J",
.k => "K",
.l => "L",
.m => "M",
.n => "N",
.o => "O",
.p => "P",
.q => "Q",
.r => "R",
.s => "S",
.t => "T",
.u => "U",
.v => "V",
.w => "W",
.x => "X",
.y => "Y",
.z => "Z",
.@"0" => "0",
.@"1" => "1",
.@"2" => "2",
.@"3" => "3",
.@"4" => "4",
.@"5" => "5",
.@"6" => "6",
.@"7" => "7",
.@"8" => "8",
.@"9" => "9",
.f1 => "F1",
.f2 => "F2",
.f3 => "F3",
.f4 => "F4",
.f5 => "F5",
.f6 => "F6",
.f7 => "F7",
.f8 => "F8",
.f9 => "F9",
.f10 => "F10",
.f11 => "F11",
.f12 => "F12",
.escape => "Esc",
.enter => "Enter",
.tab => "Tab",
.backspace => "Backspace",
.insert => "Insert",
.delete => "Delete",
.home => "Home",
.end => "End",
.page_up => "PageUp",
.page_down => "PageDown",
.up => "Up",
.down => "Down",
.left => "Left",
.right => "Right",
.space => "Space",
else => "?",
};
}
// =============================================================================
// Tests
// =============================================================================
test "Modifiers equality" {
const m1 = Modifiers{ .ctrl = true };
const m2 = Modifiers{ .ctrl = true };
const m3 = Modifiers{ .shift = true };
try std.testing.expect(m1.eql(m2));
try std.testing.expect(!m1.eql(m3));
}
test "Shortcut creation" {
const shortcut = Shortcut.ctrl(.s, "save");
try std.testing.expectEqual(Input.Key.s, shortcut.key);
try std.testing.expect(shortcut.modifiers.ctrl);
try std.testing.expectEqualStrings("save", shortcut.action);
}
test "Shortcut matches" {
const shortcut = Shortcut.ctrl(.s, "save");
try std.testing.expect(shortcut.matches(.s, .{ .ctrl = true }));
try std.testing.expect(!shortcut.matches(.s, .{ .shift = true }));
try std.testing.expect(!shortcut.matches(.a, .{ .ctrl = true }));
}
test "ShortcutManager register" {
var manager = ShortcutManager.init();
manager.register(Shortcut.ctrl(.s, "save"));
manager.register(Shortcut.ctrl(.z, "undo"));
try std.testing.expectEqual(@as(usize, 2), manager.count);
}
test "ShortcutManager getByAction" {
var manager = ShortcutManager.init();
manager.register(Shortcut.ctrl(.s, "save"));
const shortcut = manager.getByAction("save");
try std.testing.expect(shortcut != null);
try std.testing.expectEqual(Input.Key.s, shortcut.?.key);
const missing = manager.getByAction("nonexistent");
try std.testing.expect(missing == null);
}
test "ShortcutManager unregister" {
var manager = ShortcutManager.init();
manager.register(Shortcut.ctrl(.s, "save"));
manager.register(Shortcut.ctrl(.z, "undo"));
try std.testing.expectEqual(@as(usize, 2), manager.count);
manager.unregister("save");
try std.testing.expectEqual(@as(usize, 1), manager.count);
try std.testing.expect(manager.getByAction("save") == null);
try std.testing.expect(manager.getByAction("undo") != null);
}
test "formatShortcut" {
var buf: [32]u8 = undefined;
const ctrl_s = Shortcut.ctrl(.s, "save");
const text1 = formatShortcut(&buf, ctrl_s);
try std.testing.expectEqualStrings("Ctrl+S", text1);
const ctrl_shift_z = Shortcut.ctrlShift(.z, "redo");
const text2 = formatShortcut(&buf, ctrl_shift_z);
try std.testing.expectEqualStrings("Ctrl+Shift+Z", text2);
}
test "registerCommon" {
var manager = ShortcutManager.init();
manager.registerCommon();
try std.testing.expect(manager.count >= 10);
try std.testing.expect(manager.getByAction("copy") != null);
try std.testing.expect(manager.getByAction("paste") != null);
try std.testing.expect(manager.getByAction("undo") != null);
}

View file

@ -38,6 +38,18 @@ pub const Layout = @import("core/layout.zig");
pub const Style = @import("core/style.zig"); pub const Style = @import("core/style.zig");
pub const Input = @import("core/input.zig"); pub const Input = @import("core/input.zig");
pub const Command = @import("core/command.zig"); pub const Command = @import("core/command.zig");
pub const clipboard = @import("core/clipboard.zig");
pub const dragdrop = @import("core/dragdrop.zig");
pub const DragDropManager = dragdrop.DragDropManager;
pub const DragData = dragdrop.DragData;
pub const DropZone = dragdrop.DropZone;
pub const DropResult = dragdrop.DropResult;
pub const shortcuts = @import("core/shortcuts.zig");
pub const ShortcutManager = shortcuts.ShortcutManager;
pub const Shortcut = shortcuts.Shortcut;
pub const focus_group = @import("core/focus_group.zig");
pub const FocusGroup = focus_group.FocusGroup;
pub const FocusGroupManager = focus_group.FocusGroupManager;
// ============================================================================= // =============================================================================
// Macro system // Macro system