ReviewOS

also looking at this

craft-native/craft

fix(shortcuts): make global hotkeys actually fire

#54
Merged glennmichael123 wants to merge fix/global-shortcuts-never-fire into main
11 files +2435 -417
packages/zig/src/accelerator.zigadded+336-0
Changes to packages/zig/src/accelerator.zig
@@ -0,0 +1,336 @@
1//! Accelerator strings — `"Cmd+Shift+H"` — parsed into a key and modifiers.
2//!
3//! This is the shape `window.craft.shortcuts.register(id, accelerator)` sends,
4//! and it is deliberately the same spelling Electron and the web use, so an
5//! accelerator copied out of existing app code means here what it meant there.
6//!
7//! Pure: no Objective-C, no Carbon, nothing platform-specific. That is what
8//! lets the whole of it be tested without a window, a key press or a running
9//! app — which matters, because the bug this file exists to fix
10//! (craft-native/craft#47) was precisely that nothing between the JS call and
11//! the keyboard was exercised by anything.
12
13const std = ;
14const builtin = ;
15const key_codes = ;
16
17pub const Modifiers = struct {
18 cmd: bool = false,
19 ctrl: bool = false,
20 alt: bool = false,
21 shift: bool = false,
22
23 /// `NSEventModifierFlag` bits, for matching against a Cocoa event.
24 pub fn toCocoaFlags(self: Modifiers) c_ulong {
25 var flags: c_ulong = 0;
26 if (self.shift) flags |= (1 << 17); // NSEventModifierFlagShift
27 if (self.ctrl) flags |= (1 << 18); // NSEventModifierFlagControl
28 if (self.alt) flags |= (1 << 19); // NSEventModifierFlagOption
29 if (self.cmd) flags |= (1 << 20); // NSEventModifierFlagCommand
30 return flags;
31 }
32
33 /// Carbon modifier bits, for `RegisterEventHotKey`. A different set of
34 /// values entirely from the Cocoa ones above: Cocoa's command bit is
35 /// 1<<20, Carbon's is 1<<8, and passing one where the other is expected
36 /// registers a hotkey nobody can press.
37 ///
38 /// From `<Carbon/HIToolbox/Events.h>`.
39 pub const carbon_command: u32 = 1 << 8;
40 pub const carbon_shift: u32 = 1 << 9;
41 pub const carbon_option: u32 = 1 << 11;
42 pub const carbon_control: u32 = 1 << 12;
43
44 pub fn toCarbonFlags(self: Modifiers) u32 {
45 var flags: u32 = 0;
46 if (self.cmd) flags |= carbon_command;
47 if (self.shift) flags |= carbon_shift;
48 if (self.alt) flags |= carbon_option;
49 if (self.ctrl) flags |= carbon_control;
50 return flags;
51 }
52
53 /// Whether any modifier that changes what a key *does*, rather than which
54 /// character it produces, is held. Shift is excluded on purpose: Shift+H
55 /// is just how you type H.
56 pub fn hasCommandingModifier(self: Modifiers) bool {
57 return self.cmd or self.ctrl or self.alt;
58 }
59
60 pub fn eql(self: Modifiers, other: Modifiers) bool {
61 return self.cmd == other.cmd and self.ctrl == other.ctrl and
62 self.alt == other.alt and self.shift == other.shift;
63 }
64};
65
66pub const Binding = struct {
67 /// Canonical name of the key, from `key_codes.zig` — so a shortcut
68 /// registered as `"Cmd+Backspace"` reports itself as `Cmd+Delete`.
69 key: []const u8,
70 /// macOS virtual key code.
71 keycode: u16,
72 modifiers: Modifiers,
73
74 pub fn eql(self: Binding, other: Binding) bool {
75 return self.keycode == other.keycode and self.modifiers.eql(other.modifiers);
76 }
77};
78
79pub const ParseError = error{
80 /// The accelerator was empty, or was nothing but modifiers.
81 MissingKey,
82 /// A component was empty — `"Cmd++"`, `"+H"`, `"Cmd+ +H"`.
83 EmptyComponent,
84 /// The final component names no key craft knows.
85 UnknownKey,
86 /// A component before the key is neither a modifier nor a key.
87 UnknownModifier,
88 /// The same modifier was named twice.
89 DuplicateModifier,
90 /// A global hotkey with no commanding modifier would make the key
91 /// untypeable in every app on the system.
92 ModifierRequired,
93};
94
95/// Function keys are the exception to `ModifierRequired`: they produce no
96/// text, so reserving F13 on its own costs the user nothing. Everything else
97/// bare — `"H"`, `"Space"`, `"Shift+H"` — would take a key away system-wide.
98fn isBareBindable(name: []const u8) bool {
99 if (name.len < 2 or (name[0] != 'F' and name[0] != 'f')) return false;
100 _ = std.fmt.parseInt(u8, name[1..], 10) catch return false;
101 return true;
102}
103
104fn applyModifier(name: []const u8, mods: *Modifiers) ParseError!void {
105 const Target = enum { cmd, ctrl, alt, shift };
106 const target: Target = blk: {
107 // `CmdOrCtrl` is the portable spelling: command where command is what
108 // apps use, control everywhere else. Resolved here rather than being
109 // carried through, so everything downstream sees a concrete modifier.
110 if (eq(name, "cmdorctrl") or eq(name, "commandorcontrol") or eq(name, "mod"))
111 break :blk if (builtin.os.tag == .macos) .cmd else .ctrl;
112 if (eq(name, "cmd") or eq(name, "command") or eq(name, "meta") or
113 eq(name, "super") or eq(name, "win") or eq(name, "\u{2318}")) break :blk .cmd;
114 if (eq(name, "ctrl") or eq(name, "control") or eq(name, "\u{2303}")) break :blk .ctrl;
115 if (eq(name, "alt") or eq(name, "option") or eq(name, "opt") or
116 eq(name, "\u{2325}")) break :blk .alt;
117 if (eq(name, "shift") or eq(name, "\u{21E7}")) break :blk .shift;
118 return ParseError.UnknownModifier;
119 };
120
121 const slot = switch (target) {
122 .cmd => &mods.cmd,
123 .ctrl => &mods.ctrl,
124 .alt => &mods.alt,
125 .shift => &mods.shift,
126 };
127 // Naming a modifier twice is a typo, and silently accepting it hides which
128 // half of `"Cmd+Cmd+Shift"` the author meant to write.
129 if (slot.*) return ParseError.DuplicateModifier;
130 slot.* = true;
131}
132
133fn eq(a: []const u8, comptime b: []const u8) bool {
134 return std.ascii.eqlIgnoreCase(a, b);
135}
136
137/// Parse `"Cmd+Shift+H"` into the key and modifiers it names.
138///
139/// The last `+`-separated component is the key; everything before it is a
140/// modifier. Components are matched case-insensitively.
141pub fn parse(input: []const u8) ParseError!Binding {
142 const trimmed = std.mem.trim(u8, input, " \t");
143 if (trimmed.len == 0) return ParseError.MissingKey;
144
145 var mods = Modifiers{};
146 var key_part: ?[]const u8 = null;
147
148 var it = std.mem.splitScalar(u8, trimmed, '+');
149 while (it.next()) |raw| {
150 const part = std.mem.trim(u8, raw, " \t");
151 if (part.len == 0) return ParseError.EmptyComponent;
152
153 // The key is whatever is last, so a component only becomes a modifier
154 // once another component turns up behind it.
155 if (key_part) |previous| try applyModifier(previous, &mods);
156 key_part = part;
157 }
158
159 const key_name = key_part orelse return ParseError.MissingKey;
160 const keycode = key_codes.codeFor(key_name) orelse return ParseError.UnknownKey;
161 const canonical = key_codes.nameFor(keycode).?;
162
163 if (!mods.hasCommandingModifier() and !isBareBindable(canonical)) {
164 return ParseError.ModifierRequired;
165 }
166
167 return .{ .key = canonical, .keycode = keycode, .modifiers = mods };
168}
169
170/// Render a binding back into the canonical accelerator string, so what an
171/// app reads out of `craft.shortcuts.list()` is something it could pass
172/// straight back to `register()`.
173pub fn format(buf: []u8, binding: Binding) ![]const u8 {
174 var pos: usize = 0;
175 const parts = [_]struct { on: bool, name: []const u8 }{
176 .{ .on = binding.modifiers.cmd, .name = "Cmd" },
177 .{ .on = binding.modifiers.ctrl, .name = "Ctrl" },
178 .{ .on = binding.modifiers.alt, .name = "Alt" },
179 .{ .on = binding.modifiers.shift, .name = "Shift" },
180 };
181 for (parts) |part| {
182 if (!part.on) continue;
183 if (pos + part.name.len + 1 > buf.len) return error.NoSpaceLeft;
184 @memcpy(buf[pos..][0..part.name.len], part.name);
185 pos += part.name.len;
186 buf[pos] = '+';
187 pos += 1;
188 }
189 if (pos + binding.key.len > buf.len) return error.NoSpaceLeft;
190 @memcpy(buf[pos..][0..binding.key.len], binding.key);
191 return buf[0 .. pos + binding.key.len];
192}
193
194// =============================================================================
195// Tests
196// =============================================================================
197
198test "the accelerator the downstream bug report names parses" {
199 // stacksjs/harness registers exactly this and has never received a key.
200 const b = try parse("Cmd+Shift+H");
201 try std.testing.expectEqualStrings("H", b.key);
202 try std.testing.expectEqual(@as(u16, 0x04), b.keycode);
203 try std.testing.expect(b.modifiers.cmd);
204 try std.testing.expect(b.modifiers.shift);
205 try std.testing.expect(!b.modifiers.ctrl);
206 try std.testing.expect(!b.modifiers.alt);
207}
208
209test "modifier spellings are interchangeable" {
210 const expected = try parse("Cmd+Alt+K");
211 for ([_][]const u8{
212 "cmd+alt+k",
213 "Command+Option+K",
214 "COMMAND+OPT+K",
215 "Meta+Alt+K",
216 "Super+Option+K",
217 "\u{2318}+\u{2325}+K",
218 }) |spelling| {
219 const b = try parse(spelling);
220 try std.testing.expect(b.eql(expected));
221 }
222}
223
224test "CmdOrCtrl resolves to this platform's modifier" {
225 const b = try parse("CmdOrCtrl+S");
226 if (builtin.os.tag == .macos) {
227 try std.testing.expect(b.modifiers.cmd);
228 try std.testing.expect(!b.modifiers.ctrl);
229 } else {
230 try std.testing.expect(b.modifiers.ctrl);
231 try std.testing.expect(!b.modifiers.cmd);
232 }
233}
234
235test "the key reported back is canonical, not the spelling that arrived" {
236 const b = try parse("Cmd+Backspace");
237 try std.testing.expectEqualStrings("Delete", b.key);
238
239 var buf: [64]u8 = undefined;
240 try std.testing.expectEqualStrings("Cmd+Delete", try format(&buf, b));
241}
242
243test "format round-trips through parse" {
244 var buf: [64]u8 = undefined;
245 for ([_][]const u8{
246 "Cmd+Shift+H",
247 "Ctrl+Alt+Delete",
248 "Cmd+Ctrl+Alt+Shift+Space",
249 "Alt+Left",
250 "F13",
251 }) |source| {
252 const first = try parse(source);
253 const rendered = try format(&buf, first);
254 const again = try parse(rendered);
255 try std.testing.expect(first.eql(again));
256 try std.testing.expectEqualStrings(first.key, again.key);
257 }
258}
259
260test "a bare key is refused rather than made untypeable everywhere" {
261 // A global hotkey on `H` means no application on the system, craft's
262 // included, ever sees the user type an h again. Failing the registration
263 // is recoverable; that is not.
264 try std.testing.expectError(ParseError.ModifierRequired, parse("H"));
265 try std.testing.expectError(ParseError.ModifierRequired, parse("Space"));
266 // Shift does not count: Shift+H is how you type a capital H.
267 try std.testing.expectError(ParseError.ModifierRequired, parse("Shift+H"));
268}
269
270test "function keys may be bound bare" {
271 // They produce no text, so reserving one costs the user nothing.
272 const b = try parse("F13");
273 try std.testing.expectEqualStrings("F13", b.key);
274 try std.testing.expect(!b.modifiers.hasCommandingModifier());
275 _ = try parse("F5");
276 _ = try parse("Shift+F1");
277}
278
279test "malformed accelerators name what is wrong with them" {
280 try std.testing.expectError(ParseError.MissingKey, parse(""));
281 try std.testing.expectError(ParseError.MissingKey, parse(" "));
282 try std.testing.expectError(ParseError.EmptyComponent, parse("Cmd+"));
283 try std.testing.expectError(ParseError.EmptyComponent, parse("+H"));
284 try std.testing.expectError(ParseError.EmptyComponent, parse("Cmd++H"));
285 try std.testing.expectError(ParseError.UnknownKey, parse("Cmd+Nonsense"));
286 try std.testing.expectError(ParseError.UnknownModifier, parse("Hyper+Cmd+H"));
287 try std.testing.expectError(ParseError.DuplicateModifier, parse("Cmd+Cmd+H"));
288}
289
290test "surrounding whitespace is tolerated" {
291 const spaced = try parse(" Cmd + Shift + H ");
292 const tight = try parse("Cmd+Shift+H");
293 try std.testing.expect(spaced.eql(tight));
294}
295
296test "a modifier name in the key position is the key, not a modifier" {
297 // `"Cmd+Shift"` names no key: Shift is the last component, so it is where
298 // the key should be, and it is not one.
299 try std.testing.expectError(ParseError.UnknownKey, parse("Cmd+Shift"));
300}
301
302test "Cocoa and Carbon flag sets do not agree, and both are right" {
303 const mods = Modifiers{ .cmd = true, .shift = true };
304 try std.testing.expectEqual(@as(c_ulong, (1 << 20) | (1 << 17)), mods.toCocoaFlags());
305 try std.testing.expectEqual(@as(u32, 0x0100 | 0x0200), mods.toCarbonFlags());
306
307 // Pin the Carbon values against the header. The two sets overlap in
308 // meaning and in nothing else, and a hotkey registered with Cocoa's 1<<20
309 // for command asks for a combination involving no modifier the user can
310 // press.
311 try std.testing.expectEqual(@as(u32, 0x0100), Modifiers.carbon_command);
312 try std.testing.expectEqual(@as(u32, 0x0200), Modifiers.carbon_shift);
313 try std.testing.expectEqual(@as(u32, 0x0800), Modifiers.carbon_option);
314 try std.testing.expectEqual(@as(u32, 0x1000), Modifiers.carbon_control);
315 try std.testing.expect(Modifiers.carbon_command != (1 << 20));
316}
317
318test "every modifier maps to a distinct bit in both sets" {
319 const each = [_]Modifiers{
320 .{ .cmd = true }, .{ .ctrl = true }, .{ .alt = true }, .{ .shift = true },
321 };
322 for (each, 0..) |a, i| {
323 try std.testing.expect(a.toCocoaFlags() != 0);
324 try std.testing.expect(a.toCarbonFlags() != 0);
325 for (each[i + 1 ..]) |b| {
326 try std.testing.expect(a.toCocoaFlags() != b.toCocoaFlags());
327 try std.testing.expect(a.toCarbonFlags() != b.toCarbonFlags());
328 }
329 }
330}
331
332test "keypad keys are bindable and distinct from the number row" {
333 const pad = try parse("Cmd+Keypad5");
334 const row = try parse("Cmd+5");
335 try std.testing.expect(!pad.eql(row));
336}