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>
439 lines
13 KiB
Zig
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
|
|
}
|