also looking at this
fix(shortcuts): make global hotkeys actually fire
#54
11 files
+2435
-417
| @@ -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 | ||
| 13 | const std = ; | |
| 14 | const builtin = ; | |
| 15 | const key_codes = ; | |
| 16 | ||
| 17 | pub 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 | ||
| 66 | pub 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 | ||
| 79 | pub 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. | |
| 98 | fn 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 | ||
| 104 | fn 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 | ||
| 133 | fn 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. | |
| 141 | pub 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()`. | |
| 173 | pub 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 | ||
| 198 | test "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 | ||
| 209 | test "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 | ||
| 224 | test "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 | ||
| 235 | test "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 | ||
| 243 | test "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 | ||
| 260 | test "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 | ||
| 270 | test "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 | ||
| 279 | test "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 | ||
| 290 | test "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 | ||
| 296 | test "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 | ||
| 302 | test "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 | ||
| 318 | test "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 | ||
| 332 | test "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 | } | |