also looking at this
fix(shortcuts): make global hotkeys actually fire
#54
11 files
+2435
-417
| @@ -1,454 +1,212 @@ | ||
| 1 | //! `window.craft.shortcuts` — global hotkeys. | |
| 2 | //! | |
| 3 | //! A thin adapter: it decodes the action, hands the payload to | |
| 4 | //! `shortcut_registry.zig`, and turns the answer back into JavaScript. The | |
| 5 | //! decisions live in the registry, where they are testable; the key is | |
| 6 | //! actually reserved by `macos_hotkey.zig`. | |
| 7 | //! | |
| 8 | //! What used to be here, and what craft-native/craft#47 was about: an app | |
| 9 | //! could register a hotkey, be told it worked, and never receive a keystroke. | |
| 10 | //! `setupGlobalMonitor` computed an event mask, discarded it, installed | |
| 11 | //! nothing, and logged "Global monitor setup (polling mode)" — there was no | |
| 12 | //! polling either. The matcher it was supposed to feed was reachable only from | |
| 13 | //! a function with no callers anywhere in the tree. Three further mismatches | |
| 14 | //! sat on top of it, each individually fatal: | |
| 15 | //! | |
| 16 | //! * the JS bridge sends `{id, accelerator}`; this file read `key` and | |
| 17 | //! `modifiers`, so every registration failed its own validation before it | |
| 18 | //! got anywhere near the missing monitor; | |
| 19 | //! * `isRegistered` and `list` answered by calling `__craftShortcutList` and | |
| 20 | //! friends, which nothing defines — so those promises hung for the full | |
| 21 | //! 30-second request timeout and then rejected; | |
| 22 | //! * a triggered shortcut called `__craftShortcutCallback`, which nothing | |
| 23 | //! defines either, while the JS side was listening for a `craft:shortcut` | |
| 24 | //! event that was never dispatched. | |
| 25 | ||
| 1 | 26 | const std = ; |
| 2 | 27 | const builtin = ; |
| 3 | 28 | const bridge_error = ; |
| 4 | 29 | const logging = ; |
| 30 | const registry_mod = ; | |
| 31 | const accel = ; | |
| 5 | 32 | |
| 6 | 33 | const log = logging.shortcuts; |
| 7 | 34 | |
| 8 | 35 | const BridgeError = bridge_error.BridgeError; |
| 9 | 36 | |
| 10 | /// Modifier flags for keyboard shortcuts | |
| 11 | pub const Modifiers = struct { | |
| 12 | cmd: bool = false, | |
| 13 | ctrl: bool = false, | |
| 14 | alt: bool = false, | |
| 15 | shift: bool = false, | |
| 16 | ||
| 17 | pub fn toCocoaFlags(self: Modifiers) c_ulong { | |
| 18 | var flags: c_ulong = 0; | |
| 19 | if (self.cmd) flags |= (1 << 20); // NSEventModifierFlagCommand | |
| 20 | if (self.ctrl) flags |= (1 << 18); // NSEventModifierFlagControl | |
| 21 | if (self.alt) flags |= (1 << 19); // NSEventModifierFlagOption | |
| 22 | if (self.shift) flags |= (1 << 17); // NSEventModifierFlagShift | |
| 23 | return flags; | |
| 37 | /// Re-exported so callers that named these through this module keep working. | |
| 38 | pub const Modifiers = accel.Modifiers; | |
| 39 | pub const Shortcut = registry_mod.Registration; | |
| 40 | ||
| 41 | pub const action_register = registry_mod.action_register; | |
| 42 | pub const action_unregister = registry_mod.action_unregister; | |
| 43 | pub const action_unregister_all = registry_mod.action_unregister_all; | |
| 44 | pub const action_enable = registry_mod.action_enable; | |
| 45 | pub const action_disable = registry_mod.action_disable; | |
| 46 | pub const action_is_registered = registry_mod.action_is_registered; | |
| 47 | pub const action_list = registry_mod.action_list; | |
| 48 | ||
| 49 | /// The event a triggered shortcut arrives on, matching `_evt('craft:shortcut')` | |
| 50 | /// in `craft-bridge.js`. | |
| 51 | pub const event_triggered = "craft:shortcut"; | |
| 52 | ||
| 53 | /// Where a registration that could not be granted is reported. | |
| 54 | /// | |
| 55 | /// `register` is fire-and-forget on the JS side — its action name collides | |
| 56 | /// with other bridges' in the request queue, so it cannot be a request without | |
| 57 | /// mixing replies — which leaves an app no way to hear that the key it asked | |
| 58 | /// for belongs to someone else. This event is that way. | |
| 59 | pub const event_error = "craft:shortcut:error"; | |
| 60 | ||
| 61 | fn platform() registry_mod.Platform { | |
| 62 | if (comptime builtin.os.tag == .macos) { | |
| 63 | return .{ | |
| 64 | .register = struct { | |
| 65 | fn f(keycode: u16, modifiers: u32, hotkey_id: u32) registry_mod.Ref { | |
| 66 | return .register(keycode, modifiers, hotkey_id); | |
| 67 | } | |
| 68 | }.f, | |
| 69 | .unregister = struct { | |
| 70 | fn f(ref: registry_mod.Ref) void { | |
| 71 | .unregister(ref); | |
| 72 | } | |
| 73 | }.f, | |
| 74 | }; | |
| 24 | 75 | } |
| 25 | }; | |
| 26 | ||
| 27 | /// Registered shortcut | |
| 28 | pub const Shortcut = struct { | |
| 29 | id: []const u8, | |
| 30 | key: []const u8, | |
| 31 | modifiers: Modifiers, | |
| 32 | callback_id: []const u8, | |
| 33 | enabled: bool = true, | |
| 34 | }; | |
| 76 | // Linux and Windows have no global-hotkey implementation in craft yet. | |
| 77 | // Refusing every registration is the honest answer: the alternative — | |
| 78 | // accepting them — is the bug this file was rewritten to fix. | |
| 79 | return registry_mod.unsupported_platform; | |
| 80 | } | |
| 35 | 81 | |
| 36 | /// Bridge handler for global keyboard shortcuts | |
| 37 | 82 | pub const ShortcutsBridge = struct { |
| 38 | 83 | allocator: std.mem.Allocator, |
| 39 | shortcuts: std.StringHashMap(Shortcut), | |
| 40 | global_monitor: ?*anyopaque = null, | |
| 41 | local_monitor: ?*anyopaque = null, | |
| 84 | registry: registry_mod.Registry, | |
| 42 | 85 | |
| 43 | 86 | const Self = @This(); |
| 44 | 87 | |
| 45 | 88 | pub fn init(allocator: std.mem.Allocator) Self { |
| 46 | 89 | return .{ |
| 47 | 90 | .allocator = allocator, |
| 48 | .shortcuts = std.StringHashMap(Shortcut).init(allocator), | |
| 91 | .registry = registry_mod.Registry.init(allocator, platform()), | |
| 49 | 92 | }; |
| 50 | 93 | } |
| 51 | 94 | |
| 52 | /// Handle shortcut-related messages from JavaScript | |
| 95 | pub fn deinit(self: *Self) void { | |
| 96 | self.registry.deinit(); | |
| 97 | if (comptime builtin.os.tag == .macos) { | |
| 98 | .on_pressed = null; | |
| 99 | } | |
| 100 | const global_state = ; | |
| 101 | global_state.instance.setShortcutsBridge(null); | |
| 102 | } | |
| 103 | ||
| 53 | 104 | pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void { |
| 54 | 105 | self.handleMessageInternal(action, data) catch |err| { |
| 55 | self.reportError(action, err); | |
| 106 | const bridge_err: BridgeError = err; | |
| 107 | // The action name travels with the error so `__craftBridgeError` | |
| 108 | // rejects only that action's callers. | |
| 109 | bridge_error.sendErrorToJS(self.allocator, action, bridge_err); | |
| 110 | ||
| 111 | // `isRegistered` and `list` are requests: the line above rejects | |
| 112 | // the promise their caller is holding, and that is the whole | |
| 113 | // report. Every other verb is fire-and-forget, so its caller has | |
| 114 | // already moved on and there is nothing to reject — the error | |
| 115 | // event is the only way it hears anything at all. | |
| 116 | const is_request = std.mem.eql(u8, action, action_is_registered) or | |
| 117 | std.mem.eql(u8, action, action_list); | |
| 118 | if (!is_request) self.reportFailure(data, bridge_err); | |
| 56 | 119 | }; |
| 57 | 120 | } |
| 58 | 121 | |
| 59 | 122 | fn handleMessageInternal(self: *Self, action: []const u8, data: []const u8) !void { |
| 60 | if (std.mem.eql(u8, action, "register")) { | |
| 61 | try self.registerShortcut(data); | |
| 62 | } else if (std.mem.eql(u8, action, "unregister")) { | |
| 63 | try self.unregisterShortcut(data); | |
| 64 | } else if (std.mem.eql(u8, action, "unregisterAll")) { | |
| 65 | try self.unregisterAllShortcuts(); | |
| 66 | } else if (std.mem.eql(u8, action, "enable")) { | |
| 67 | try self.enableShortcut(data); | |
| 68 | } else if (std.mem.eql(u8, action, "disable")) { | |
| 69 | try self.disableShortcut(data); | |
| 70 | } else if (std.mem.eql(u8, action, "isRegistered")) { | |
| 71 | try self.isRegistered(data); | |
| 72 | } else if (std.mem.eql(u8, action, "list")) { | |
| 73 | try self.listShortcuts(); | |
| 123 | if (std.mem.eql(u8, action, action_register)) { | |
| 124 | self.ensureDelivery(); | |
| 125 | const entry = try self.registry.register(data); | |
| 126 | log.debug("registered {s} as {s}", .{ entry.id, entry.accelerator }); | |
| 127 | } else if (std.mem.eql(u8, action, action_unregister)) { | |
| 128 | try self.registry.unregister(data); | |
| 129 | } else if (std.mem.eql(u8, action, action_unregister_all)) { | |
| 130 | self.registry.unregisterAll(); | |
| 131 | } else if (std.mem.eql(u8, action, action_enable)) { | |
| 132 | try self.registry.setEnabled(data, true); | |
| 133 | } else if (std.mem.eql(u8, action, action_disable)) { | |
| 134 | try self.registry.setEnabled(data, false); | |
| 135 | } else if (std.mem.eql(u8, action, action_is_registered)) { | |
| 136 | const json = try self.registry.isRegisteredJson(data); | |
| 137 | defer self.allocator.free(json); | |
| 138 | bridge_error.sendResultToJS(self.allocator, action, json); | |
| 139 | } else if (std.mem.eql(u8, action, action_list)) { | |
| 140 | const json = try self.registry.listJson(); | |
| 141 | defer self.allocator.free(json); | |
| 142 | bridge_error.sendResultToJS(self.allocator, action, json); | |
| 74 | 143 | } else { |
| 75 | 144 | return BridgeError.UnknownAction; |
| 76 | 145 | } |
| 77 | 146 | } |
| 78 | 147 | |
| 79 | fn reportError(self: *Self, action: []const u8, err: anyerror) void { | |
| 80 | const bridge_err: BridgeError = switch (err) { | |
| 81 | BridgeError.MissingData => BridgeError.MissingData, | |
| 82 | BridgeError.InvalidJSON => BridgeError.InvalidJSON, | |
| 83 | else => BridgeError.NativeCallFailed, | |
| 84 | }; | |
| 85 | bridge_error.sendErrorToJS(self.allocator, action, bridge_err); | |
| 86 | } | |
| 87 | ||
| 88 | /// Register a global shortcut | |
| 89 | /// JSON: {"id": "toggle", "key": "Space", "modifiers": {"cmd": true, "shift": true}, "callback": "onToggle"} | |
| 90 | fn registerShortcut(self: *Self, data: []const u8) !void { | |
| 91 | // Parse shortcut data | |
| 92 | var id: []const u8 = ""; | |
| 93 | var key: []const u8 = ""; | |
| 94 | var callback_id: []const u8 = ""; | |
| 95 | var modifiers = Modifiers{}; | |
| 96 | ||
| 97 | // Parse id | |
| 98 | if (std.mem.indexOf(u8, data, "\"id\":\"")) |idx| { | |
| 99 | const start = idx + 6; | |
| 100 | if (std.mem.indexOfPos(u8, data, start, "\"")) |end| { | |
| 101 | id = data[start..end]; | |
| 102 | } | |
| 103 | } | |
| 104 | ||
| 105 | // Parse key | |
| 106 | if (std.mem.indexOf(u8, data, "\"key\":\"")) |idx| { | |
| 107 | const start = idx + 7; | |
| 108 | if (std.mem.indexOfPos(u8, data, start, "\"")) |end| { | |
| 109 | key = data[start..end]; | |
| 110 | } | |
| 111 | } | |
| 112 | ||
| 113 | // Parse callback | |
| 114 | if (std.mem.indexOf(u8, data, "\"callback\":\"")) |idx| { | |
| 115 | const start = idx + 12; | |
| 116 | if (std.mem.indexOfPos(u8, data, start, "\"")) |end| { | |
| 117 | callback_id = data[start..end]; | |
| 118 | } | |
| 119 | } | |
| 120 | ||
| 121 | // Parse modifiers | |
| 122 | if (std.mem.indexOf(u8, data, "\"cmd\":true")) |_| modifiers.cmd = true; | |
| 123 | if (std.mem.indexOf(u8, data, "\"ctrl\":true")) |_| modifiers.ctrl = true; | |
| 124 | if (std.mem.indexOf(u8, data, "\"alt\":true")) |_| modifiers.alt = true; | |
| 125 | if (std.mem.indexOf(u8, data, "\"shift\":true")) |_| modifiers.shift = true; | |
| 126 | ||
| 127 | if (id.len == 0 or key.len == 0) { | |
| 128 | return BridgeError.MissingData; | |
| 129 | } | |
| 130 | ||
| 131 | log.debug("register: id={s}, key={s}, cmd={}, ctrl={}, alt={}, shift={}", .{ id, key, modifiers.cmd, modifiers.ctrl, modifiers.alt, modifiers.shift }); | |
| 132 | ||
| 133 | // Store shortcut | |
| 134 | const id_owned = try self.allocator.dupe(u8, id); | |
| 135 | const key_owned = try self.allocator.dupe(u8, key); | |
| 136 | const callback_owned = try self.allocator.dupe(u8, callback_id); | |
| 137 | ||
| 138 | try self.shortcuts.put(id_owned, Shortcut{ | |
| 139 | .id = id_owned, | |
| 140 | .key = key_owned, | |
| 141 | .modifiers = modifiers, | |
| 142 | .callback_id = callback_owned, | |
| 143 | .enabled = true, | |
| 144 | }); | |
| 145 | ||
| 146 | // Setup global monitor if not already done | |
| 147 | if (self.global_monitor == null) { | |
| 148 | try self.setupGlobalMonitor(); | |
| 149 | } | |
| 150 | } | |
| 151 | ||
| 152 | /// Setup global event monitor | |
| 153 | fn setupGlobalMonitor(self: *Self) !void { | |
| 154 | if (comptime builtin.os.tag != .macos) return; | |
| 155 | ||
| 156 | const macos = ; | |
| 157 | ||
| 158 | // Create a global monitor for key down events | |
| 159 | // NSEventMaskKeyDown = 1 << 10 = 1024 | |
| 160 | const event_mask: c_ulonglong = 1 << 10; | |
| 161 | ||
| 162 | // We need to use addGlobalMonitorForEventsMatchingMask:handler: | |
| 163 | // This requires creating an Objective-C block, which is complex in Zig | |
| 164 | // For now, we'll use a polling approach via the run loop | |
| 165 | ||
| 166 | // Alternative: Use local monitor for when app is active | |
| 167 | const NSEvent = macos.getClass("NSEvent"); | |
| 168 | _ = NSEvent; | |
| 169 | _ = event_mask; | |
| 170 | ||
| 171 | // Store reference to self for callback | |
| 148 | /// Point the platform's hotkey callback at this bridge. Cheap and | |
| 149 | /// idempotent, so it runs before every registration rather than depending | |
| 150 | /// on an initialisation order. | |
| 151 | fn ensureDelivery(self: *Self) void { | |
| 172 | 152 | const global_state = ; |
| 173 | 153 | global_state.instance.setShortcutsBridge(self); |
| 174 | ||
| 175 | log.debug("Global monitor setup (polling mode)", .{}); | |
| 176 | } | |
| 177 | ||
| 178 | /// Check if a key event matches any registered shortcut | |
| 179 | pub fn checkKeyEvent(self: *Self, keycode: u16, flags: c_ulong) void { | |
| 180 | const key_name = keycodeToName(keycode); | |
| 181 | ||
| 182 | var it = self.shortcuts.iterator(); | |
| 183 | while (it.next()) |entry| { | |
| 184 | const shortcut = entry.value_ptr.*; | |
| 185 | if (!shortcut.enabled) continue; | |
| 186 | ||
| 187 | // Check if key matches | |
| 188 | if (!std.mem.eql(u8, shortcut.key, key_name)) continue; | |
| 189 | ||
| 190 | // Check modifiers | |
| 191 | const required_flags = shortcut.modifiers.toCocoaFlags(); | |
| 192 | const masked_flags = flags & ((1 << 17) | (1 << 18) | (1 << 19) | (1 << 20)); | |
| 193 | ||
| 194 | if (masked_flags == required_flags) { | |
| 195 | log.debug("Shortcut triggered: {s}", .{shortcut.id}); | |
| 196 | self.triggerCallback(shortcut.id, shortcut.callback_id); | |
| 197 | } | |
| 154 | if (comptime builtin.os.tag == .macos) { | |
| 155 | .on_pressed = onHotKeyPressed; | |
| 198 | 156 | } |
| 199 | 157 | } |
| 200 | 158 | |
| 201 | /// Trigger JavaScript callback for shortcut. | |
| 202 | /// Escapes both IDs before embedding them in the JS literal so a value | |
| 203 | /// containing `'` / `\` / newline cannot break out and inject code. | |
| 204 | fn triggerCallback(self: *Self, shortcut_id: []const u8, callback_id: []const u8) void { | |
| 205 | _ = self; | |
| 206 | ||
| 207 | const bridge = ; | |
| 208 | ||
| 209 | var sid_buf: [128]u8 = undefined; | |
| 210 | var cb_buf: [128]u8 = undefined; | |
| 211 | const sid_esc = bridge_error.escapeJsSingleQuoted(&sid_buf, shortcut_id) catch return; | |
| 212 | const cb_esc = bridge_error.escapeJsSingleQuoted(&cb_buf, callback_id) catch return; | |
| 213 | ||
| 214 | var buf: [512]u8 = undefined; | |
| 215 | const js = std.fmt.bufPrint(&buf, | |
| 216 | \\if(window.__craftShortcutCallback)window.__craftShortcutCallback('{s}','{s}'); | |
| 217 | , .{ sid_esc, cb_esc }) catch return; | |
| 159 | /// Called on the main thread when a registered combination is pressed | |
| 160 | /// anywhere in the system. | |
| 161 | pub fn deliver(self: *Self, hotkey_id: u32) void { | |
| 162 | const entry = self.registry.findByHotkeyId(hotkey_id) orelse return; | |
| 163 | // A hotkey disabled between the press and its delivery must not fire. | |
| 164 | if (!entry.enabled) return; | |
| 218 | 165 | |
| 219 | bridge.evalJS(js) catch |err| { | |
| 220 | log.debug("Failed to trigger callback: {}", .{err}); | |
| 221 | }; | |
| 222 | } | |
| 223 | ||
| 224 | /// Unregister a shortcut | |
| 225 | /// JSON: {"id": "toggle"} | |
| 226 | fn unregisterShortcut(self: *Self, data: []const u8) !void { | |
| 227 | var id: []const u8 = ""; | |
| 228 | if (std.mem.indexOf(u8, data, "\"id\":\"")) |idx| { | |
| 229 | const start = idx + 6; | |
| 230 | if (std.mem.indexOfPos(u8, data, start, "\"")) |end| { | |
| 231 | id = data[start..end]; | |
| 232 | } | |
| 233 | } | |
| 234 | ||
| 235 | if (id.len == 0) return BridgeError.MissingData; | |
| 236 | ||
| 237 | log.debug("unregister: {s}", .{id}); | |
| 238 | ||
| 239 | if (self.shortcuts.fetchRemove(id)) |kv| { | |
| 240 | self.allocator.free(kv.value.id); | |
| 241 | self.allocator.free(kv.value.key); | |
| 242 | self.allocator.free(kv.value.callback_id); | |
| 243 | } | |
| 244 | } | |
| 245 | ||
| 246 | /// Unregister all shortcuts | |
| 247 | fn unregisterAllShortcuts(self: *Self) !void { | |
| 248 | log.debug("unregisterAll", .{}); | |
| 249 | ||
| 250 | var it = self.shortcuts.iterator(); | |
| 251 | while (it.next()) |entry| { | |
| 252 | self.allocator.free(entry.value_ptr.id); | |
| 253 | self.allocator.free(entry.value_ptr.key); | |
| 254 | self.allocator.free(entry.value_ptr.callback_id); | |
| 255 | } | |
| 256 | self.shortcuts.clearAndFree(); | |
| 257 | } | |
| 258 | ||
| 259 | /// Enable a shortcut | |
| 260 | /// JSON: {"id": "toggle"} | |
| 261 | fn enableShortcut(self: *Self, data: []const u8) !void { | |
| 262 | var id: []const u8 = ""; | |
| 263 | if (std.mem.indexOf(u8, data, "\"id\":\"")) |idx| { | |
| 264 | const start = idx + 6; | |
| 265 | if (std.mem.indexOfPos(u8, data, start, "\"")) |end| { | |
| 266 | id = data[start..end]; | |
| 267 | } | |
| 268 | } | |
| 269 | ||
| 270 | if (self.shortcuts.getPtr(id)) |shortcut| { | |
| 271 | shortcut.enabled = true; | |
| 272 | log.debug("enabled: {s}", .{id}); | |
| 273 | } | |
| 274 | } | |
| 275 | ||
| 276 | /// Disable a shortcut | |
| 277 | /// JSON: {"id": "toggle"} | |
| 278 | fn disableShortcut(self: *Self, data: []const u8) !void { | |
| 279 | var id: []const u8 = ""; | |
| 280 | if (std.mem.indexOf(u8, data, "\"id\":\"")) |idx| { | |
| 281 | const start = idx + 6; | |
| 282 | if (std.mem.indexOfPos(u8, data, start, "\"")) |end| { | |
| 283 | id = data[start..end]; | |
| 284 | } | |
| 285 | } | |
| 286 | ||
| 287 | if (self.shortcuts.getPtr(id)) |shortcut| { | |
| 288 | shortcut.enabled = false; | |
| 289 | log.debug("disabled: {s}", .{id}); | |
| 290 | } | |
| 166 | log.debug("triggered {s} ({s})", .{ entry.id, entry.accelerator }); | |
| 167 | const detail = registry_mod.triggeredDetail(self.allocator, entry.id, entry.accelerator) catch return; | |
| 168 | defer self.allocator.free(detail); | |
| 169 | self.dispatch(event_triggered, detail); | |
| 291 | 170 | } |
| 292 | 171 | |
| 293 | /// Check if shortcut is registered | |
| 294 | /// JSON: {"id": "toggle"} | |
| 295 | fn isRegistered(self: *Self, data: []const u8) !void { | |
| 172 | fn reportFailure(self: *Self, data: []const u8, err: BridgeError) void { | |
| 173 | // Best effort: the id is read back out of the payload so the app can | |
| 174 | // tell which of its registrations failed. A payload too malformed to | |
| 175 | // yield an id still gets the error, just without one. | |
| 176 | const IdOnly = struct { id: []const u8 = "" }; | |
| 296 | 177 | var id: []const u8 = ""; |
| 297 | if (std.mem.indexOf(u8, data, "\"id\":\"")) |idx| { | |
| 298 | const start = idx + 6; | |
| 299 | if (std.mem.indexOfPos(u8, data, start, "\"")) |end| { | |
| 300 | id = data[start..end]; | |
| 301 | } | |
| 302 | } | |
| 303 | ||
| 304 | const registered = self.shortcuts.contains(id); | |
| 305 | ||
| 306 | const bridge = ; | |
| 307 | var id_buf: [128]u8 = undefined; | |
| 308 | const id_esc = bridge_error.escapeJsSingleQuoted(&id_buf, id) catch return; | |
| 309 | ||
| 310 | var buf: [256]u8 = undefined; | |
| 311 | const js = std.fmt.bufPrint(&buf, | |
| 312 | \\if(window.__craftShortcutRegistered)window.__craftShortcutRegistered('{s}',{}); | |
| 313 | , .{ id_esc, registered }) catch return; | |
| 314 | ||
| 315 | bridge.evalJS(js) catch |err| { | |
| 316 | std.log.debug("JS eval failed for shortcut registered callback: {}", .{err}); | |
| 317 | }; | |
| 318 | } | |
| 319 | ||
| 320 | /// List all registered shortcuts | |
| 321 | fn listShortcuts(self: *Self) !void { | |
| 178 | const parsed = std.json.parseFromSlice(IdOnly, self.allocator, data, .{ | |
| 179 | .ignore_unknown_fields = true, | |
| 180 | .allocate = .alloc_always, | |
| 181 | }) catch null; | |
| 182 | defer if (parsed) |p| p.deinit(); | |
| 183 | if (parsed) |p| id = p.value.id; | |
| 184 | ||
| 185 | const detail = registry_mod.errorDetail( | |
| 186 | self.allocator, | |
| 187 | id, | |
| 188 | bridge_error.errorCodeString(err), | |
| 189 | bridge_error.errorMessage(err), | |
| 190 | ) catch return; | |
| 191 | defer self.allocator.free(detail); | |
| 192 | self.dispatch(event_error, detail); | |
| 193 | } | |
| 194 | ||
| 195 | /// Emit `new CustomEvent(name, { detail })` into the page. | |
| 196 | fn dispatch(self: *Self, name: []const u8, detail: []const u8) void { | |
| 322 | 197 | const bridge = ; |
| 323 | 198 | |
| 324 | var buf: [2048]u8 = undefined; | |
| 325 | var pos: usize = 0; | |
| 326 | ||
| 327 | // Start JSON array | |
| 328 | buf[pos] = '['; | |
| 329 | pos += 1; | |
| 330 | ||
| 331 | var first = true; | |
| 332 | var it = self.shortcuts.iterator(); | |
| 333 | while (it.next()) |entry| { | |
| 334 | const s = entry.value_ptr.*; | |
| 335 | if (!first) { | |
| 336 | buf[pos] = ','; | |
| 337 | pos += 1; | |
| 338 | } | |
| 339 | first = false; | |
| 340 | ||
| 341 | const item = std.fmt.bufPrint(buf[pos..], | |
| 342 | \\ | |
| 343 | , .{ s.id, s.key, s.enabled }) catch break; | |
| 344 | pos += item.len; | |
| 345 | } | |
| 346 | ||
| 347 | buf[pos] = ']'; | |
| 348 | pos += 1; | |
| 349 | ||
| 350 | var js_buf: [2200]u8 = undefined; | |
| 351 | const js = std.fmt.bufPrint(&js_buf, | |
| 352 | \\if(window.__craftShortcutList)window.__craftShortcutList({s}); | |
| 353 | , .{buf[0..pos]}) catch return; | |
| 199 | const js = registry_mod.eventScript(self.allocator, name, detail) catch return; | |
| 200 | defer self.allocator.free(js); | |
| 354 | 201 | |
| 355 | bridge.evalJS(js) catch |err| { | |
| 356 | std.log.debug("JS eval failed for shortcut list callback: {}", .{err}); | |
| 202 | bridge.evalJS(js) catch |eval_err| { | |
| 203 | log.debug("could not deliver {s}: {}", .{ name, eval_err }); | |
| 357 | 204 | }; |
| 358 | 205 | } |
| 359 | ||
| 360 | pub fn deinit(self: *Self) void { | |
| 361 | var it = self.shortcuts.iterator(); | |
| 362 | while (it.next()) |entry| { | |
| 363 | self.allocator.free(entry.value_ptr.id); | |
| 364 | self.allocator.free(entry.value_ptr.key); | |
| 365 | self.allocator.free(entry.value_ptr.callback_id); | |
| 366 | } | |
| 367 | self.shortcuts.deinit(); | |
| 368 | const global_state = ; | |
| 369 | global_state.instance.setShortcutsBridge(null); | |
| 370 | } | |
| 371 | 206 | }; |
| 372 | 207 | |
| 373 | /// Convert keycode to key name | |
| 374 | fn keycodeToName(keycode: u16) []const u8 { | |
| 375 | return switch (keycode) { | |
| 376 | 0 => "A", | |
| 377 | 1 => "S", | |
| 378 | 2 => "D", | |
| 379 | 3 => "F", | |
| 380 | 4 => "H", | |
| 381 | 5 => "G", | |
| 382 | 6 => "Z", | |
| 383 | 7 => "X", | |
| 384 | 8 => "C", | |
| 385 | 9 => "V", | |
| 386 | 11 => "B", | |
| 387 | 12 => "Q", | |
| 388 | 13 => "W", | |
| 389 | 14 => "E", | |
| 390 | 15 => "R", | |
| 391 | 16 => "Y", | |
| 392 | 17 => "T", | |
| 393 | 18 => "1", | |
| 394 | 19 => "2", | |
| 395 | 20 => "3", | |
| 396 | 21 => "4", | |
| 397 | 22 => "6", | |
| 398 | 23 => "5", | |
| 399 | 24 => "=", | |
| 400 | 25 => "9", | |
| 401 | 26 => "7", | |
| 402 | 27 => "-", | |
| 403 | 28 => "8", | |
| 404 | 29 => "0", | |
| 405 | 30 => "]", | |
| 406 | 31 => "O", | |
| 407 | 32 => "U", | |
| 408 | 33 => "[", | |
| 409 | 34 => "I", | |
| 410 | 35 => "P", | |
| 411 | 36 => "Return", | |
| 412 | 37 => "L", | |
| 413 | 38 => "J", | |
| 414 | 39 => "'", | |
| 415 | 40 => "K", | |
| 416 | 41 => ";", | |
| 417 | 42 => "\\", | |
| 418 | 43 => ",", | |
| 419 | 44 => "/", | |
| 420 | 45 => "N", | |
| 421 | 46 => "M", | |
| 422 | 47 => ".", | |
| 423 | 48 => "Tab", | |
| 424 | 49 => "Space", | |
| 425 | 50 => "`", | |
| 426 | 51 => "Delete", | |
| 427 | 53 => "Escape", | |
| 428 | 96 => "F5", | |
| 429 | 97 => "F6", | |
| 430 | 98 => "F7", | |
| 431 | 99 => "F3", | |
| 432 | 100 => "F8", | |
| 433 | 101 => "F9", | |
| 434 | 103 => "F11", | |
| 435 | 109 => "F10", | |
| 436 | 111 => "F12", | |
| 437 | 118 => "F4", | |
| 438 | 120 => "F2", | |
| 439 | 122 => "F1", | |
| 440 | 123 => "Left", | |
| 441 | 124 => "Right", | |
| 442 | 125 => "Down", | |
| 443 | 126 => "Up", | |
| 444 | else => "Unknown", | |
| 445 | }; | |
| 446 | } | |
| 447 | ||
| 448 | /// Public function for external event handling | |
| 449 | pub fn handleGlobalKeyEvent(keycode: u16, flags: c_ulong) void { | |
| 208 | /// Bridge between the platform's C callback and the live bridge instance. | |
| 209 | fn onHotKeyPressed(hotkey_id: u32) void { | |
| 450 | 210 | const global_state = ; |
| 451 | if (global_state.instance.getShortcutsBridge()) |bridge| { | |
| 452 | bridge.checkKeyEvent(keycode, flags); | |
| 453 | } | |
| 211 | if (global_state.instance.getShortcutsBridge()) |bridge| bridge.deliver(hotkey_id); | |
| 454 | 212 | } |