From 976d172501bc9b362e42a71ab00edfc12d574084 Mon Sep 17 00:00:00 2001 From: reugenio Date: Tue, 9 Dec 2025 13:41:43 +0100 Subject: [PATCH] feat: zcatgui v0.12.0 - Phase 6 Advanced Input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/core/clipboard.zig | 103 ++++++++++ src/core/dragdrop.zig | 362 ++++++++++++++++++++++++++++++++++ src/core/focus_group.zig | 416 +++++++++++++++++++++++++++++++++++++++ src/core/shortcuts.zig | 383 +++++++++++++++++++++++++++++++++++ src/zcatgui.zig | 12 ++ 5 files changed, 1276 insertions(+) create mode 100644 src/core/clipboard.zig create mode 100644 src/core/dragdrop.zig create mode 100644 src/core/focus_group.zig create mode 100644 src/core/shortcuts.zig diff --git a/src/core/clipboard.zig b/src/core/clipboard.zig new file mode 100644 index 0000000..2743b00 --- /dev/null +++ b/src/core/clipboard.zig @@ -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(); +} diff --git a/src/core/dragdrop.zig b/src/core/dragdrop.zig new file mode 100644 index 0000000..35be411 --- /dev/null +++ b/src/core/dragdrop.zig @@ -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 +} diff --git a/src/core/focus_group.zig b/src/core/focus_group.zig new file mode 100644 index 0000000..87115fe --- /dev/null +++ b/src/core/focus_group.zig @@ -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); +} diff --git a/src/core/shortcuts.zig b/src/core/shortcuts.zig new file mode 100644 index 0000000..02bbd70 --- /dev/null +++ b/src/core/shortcuts.zig @@ -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); +} diff --git a/src/zcatgui.zig b/src/zcatgui.zig index 984ff83..c8d1b34 100644 --- a/src/zcatgui.zig +++ b/src/zcatgui.zig @@ -38,6 +38,18 @@ pub const Layout = @import("core/layout.zig"); pub const Style = @import("core/style.zig"); pub const Input = @import("core/input.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