zcatgui/src/core/shortcuts.zig
reugenio 05e4f2c926 feat: Implement keyboard system improvements (4 phases)
Phase 1: Frame timing in Context
- Added current_time_ms and frame_delta_ms to Context
- Added setFrameTime() method for applications to provide timing

Phase 2: Centralized shortcuts system
- Added StandardShortcut enum with common shortcuts (copy, paste, etc.)
- Added isStandardActive() function for checking shortcuts
- Updated TextInput to use centralized shortcuts

Phase 3: Incremental search in table
- Added search_buffer, search_len, search_last_time to TableState
- Added addSearchChar(), getSearchTerm(), clearSearch() methods
- Typing in focused table searches first column (case-insensitive)
- 1 second timeout resets search buffer

Phase 4: Blinking cursor in TextInput
- Cursor blinks every 500ms when field is focused
- Uses current_time_ms from Context for timing

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 22:58:22 +01:00

439 lines
13 KiB
Zig

//! 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.modifiers.super, // Use tracked modifier state
};
}
};
/// 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);
}
};
/// Standard shortcut IDs for quick checking without registration
pub const StandardShortcut = enum {
select_all,
copy,
paste,
cut,
undo,
redo,
save,
new,
open,
close,
find,
quit,
help,
cancel,
};
/// Check if a standard shortcut is active (convenience function)
/// Works without ShortcutManager - checks input directly
pub fn isStandardActive(input: *const Input.InputState, shortcut: StandardShortcut) bool {
const mods = Modifiers.fromInput(input);
return switch (shortcut) {
.select_all => input.keyPressed(.a) and mods.eql(.{ .ctrl = true }),
.copy => input.keyPressed(.c) and mods.eql(.{ .ctrl = true }),
.paste => input.keyPressed(.v) and mods.eql(.{ .ctrl = true }),
.cut => input.keyPressed(.x) and mods.eql(.{ .ctrl = true }),
.undo => input.keyPressed(.z) and mods.eql(.{ .ctrl = true }),
.redo => (input.keyPressed(.z) and mods.eql(.{ .ctrl = true, .shift = true })) or
(input.keyPressed(.y) and mods.eql(.{ .ctrl = true })),
.save => input.keyPressed(.s) and mods.eql(.{ .ctrl = true }),
.new => input.keyPressed(.n) and mods.eql(.{ .ctrl = true }),
.open => input.keyPressed(.o) and mods.eql(.{ .ctrl = true }),
.close => input.keyPressed(.w) and mods.eql(.{ .ctrl = true }),
.find => input.keyPressed(.f) and mods.eql(.{ .ctrl = true }),
.quit => input.keyPressed(.q) and mods.eql(.{ .ctrl = true }),
.help => input.keyPressed(.f1) and mods.eql(.{}),
.cancel => input.keyPressed(.escape) and mods.eql(.{}),
};
}
/// 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);
}
test "isStandardActive" {
var input = Input.InputState.init();
// Simulate Ctrl being held
input.keys_down[@intFromEnum(Input.Key.left_ctrl)] = true;
// Simulate 'a' key press this frame
input.keys_down[@intFromEnum(Input.Key.a)] = true;
input.keys_down_prev[@intFromEnum(Input.Key.a)] = false;
try std.testing.expect(isStandardActive(&input, .select_all));
try std.testing.expect(!isStandardActive(&input, .copy)); // 'c' not pressed
}