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:
parent
bb5b201203
commit
976d172501
5 changed files with 1276 additions and 0 deletions
103
src/core/clipboard.zig
Normal file
103
src/core/clipboard.zig
Normal 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
362
src/core/dragdrop.zig
Normal 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
416
src/core/focus_group.zig
Normal 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
383
src/core/shortcuts.zig
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue