Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add configurable menu keyboard focus shortcut

authored by

Esteban Uribe and committed by
prompt.ac/@jeffrey
c9873c03 8a92e4ec

+409 -24
+120 -13
slab/menuband/Sources/MenuBand/AppDelegate.swift
··· 8 8 private var hoveredElement: KeyboardIconRenderer.HitResult? = nil 9 9 private var trackingArea: NSTrackingArea? 10 10 private var typeModeHotkey: GlobalHotkey? 11 + private var focusCaptureHotkey: GlobalHotkey? 11 12 private let popover = NSPopover() 12 13 private var popoverVC: MenuBandPopoverViewController? 14 + private var appBeforeFocusCapture: NSRunningApplication? 15 + private var focusCaptureArmedByShortcut = false 13 16 14 17 /// Periodic check that the status item is actually visible in the 15 18 /// menu bar. macOS silently hides items when there's no room (notch + ··· 156 159 } 157 160 updateIcon() 158 161 159 - // Global hotkey: Ctrl+Opt+Cmd+P toggles TYPE. 160 - let hotkey = GlobalHotkey { [weak self] in 161 - self?.menuBand.toggleTypeMode() 162 - } 163 - let modMask: UInt32 = UInt32(cmdKey | controlKey | optionKey) 164 - hotkey.register(keyCode: UInt32(kVK_ANSI_P), modifiers: modMask) 165 - typeModeHotkey = hotkey 162 + registerTypeModeHotkey() 163 + _ = registerFocusCaptureHotkey(MenuBandShortcutPreferences.focusShortcut) 166 164 167 165 // Dev affordance: post the 168 166 // `computer.aestheticcomputer.menuband.showPopover` ··· 185 183 // wants to release focus without clicking another app. 186 184 if isDown && keyCode == 53 /* kVK_Escape */ { 187 185 NSSound(named: NSSound.Name("Tink"))?.play() 188 - self.localCapture.disarm() 186 + self.localCapture.disarm(reason: .cancelled) 189 187 return true 190 188 } 191 189 let consumed = self.menuBand.handleLocalKey( ··· 201 199 } 202 200 return consumed 203 201 } 204 - localCapture.onCaptureEnd = { [weak self] in 202 + localCapture.onCaptureEnd = { [weak self] reason in 205 203 // Focus lost (user clicked another app). Drop the ghost and 206 204 // any held notes so we don't leave anything hanging. 207 - self?.ghostUntil = 0 208 - self?.ghostRefreshTimer?.invalidate() 209 - self?.ghostRefreshTimer = nil 210 - self?.updateIcon() 205 + self?.finishLocalCapture(reason: reason) 211 206 } 212 207 213 208 // Pre-instance the popover + force its view to load now so the ··· 217 212 let vc = MenuBandPopoverViewController() 218 213 vc.menuBand = menuBand 219 214 vc.popover = popover 215 + vc.onFocusShortcutChange = { [weak self] shortcut in 216 + self?.applyFocusShortcut(shortcut) ?? false 217 + } 218 + vc.onFocusShortcutRecordingChanged = { [weak self] isRecording in 219 + self?.setShortcutRecording(isRecording) 220 + } 220 221 popoverVC = vc 221 222 popover.contentViewController = vc 222 223 // .applicationDefined: never auto-close. We manage closing manually ··· 239 240 ) { _ in 240 241 IconTinter.applyTintedIcon() 241 242 } 243 + } 244 + 245 + // MARK: - Global shortcuts 246 + 247 + private func registerTypeModeHotkey() { 248 + let hotkey = GlobalHotkey( 249 + signature: OSType(0x4E544B59), // 'NTKY' 250 + id: 1 251 + ) { [weak self] in 252 + self?.menuBand.toggleTypeMode() 253 + } 254 + let shortcut = MenuBandShortcut.typeMode 255 + if hotkey.register(keyCode: shortcut.keyCode, modifiers: shortcut.modifiers) { 256 + typeModeHotkey = hotkey 257 + } 258 + } 259 + 260 + @discardableResult 261 + private func registerFocusCaptureHotkey(_ shortcut: MenuBandShortcut) -> Bool { 262 + let hotkey = GlobalHotkey( 263 + signature: OSType(0x4D42464B), // 'MBFK' 264 + id: 1 265 + ) { [weak self] in 266 + self?.toggleFocusCaptureFromShortcut() 267 + } 268 + guard hotkey.register(keyCode: shortcut.keyCode, modifiers: shortcut.modifiers) else { 269 + return false 270 + } 271 + focusCaptureHotkey = hotkey 272 + localCapture.cancelShortcut = shortcut 273 + return true 274 + } 275 + 276 + private func applyFocusShortcut(_ shortcut: MenuBandShortcut) -> Bool { 277 + guard shortcut.isValidForRecording, !shortcut.isReservedForTypeMode else { 278 + return false 279 + } 280 + let previous = MenuBandShortcutPreferences.focusShortcut 281 + focusCaptureHotkey?.unregister() 282 + focusCaptureHotkey = nil 283 + guard registerFocusCaptureHotkey(shortcut) else { 284 + _ = registerFocusCaptureHotkey(previous) 285 + return false 286 + } 287 + MenuBandShortcutPreferences.focusShortcut = shortcut 288 + return true 289 + } 290 + 291 + private func setShortcutRecording(_ isRecording: Bool) { 292 + if isRecording { 293 + typeModeHotkey?.unregister() 294 + typeModeHotkey = nil 295 + focusCaptureHotkey?.unregister() 296 + focusCaptureHotkey = nil 297 + } else { 298 + if typeModeHotkey == nil { registerTypeModeHotkey() } 299 + if focusCaptureHotkey == nil { 300 + _ = registerFocusCaptureHotkey(MenuBandShortcutPreferences.focusShortcut) 301 + } 302 + } 303 + } 304 + 305 + private func toggleFocusCaptureFromShortcut() { 306 + if localCapture.isArmed, focusCaptureArmedByShortcut { 307 + localCapture.disarm(reason: .cancelled) 308 + return 309 + } 310 + beginFocusCaptureFromShortcut() 311 + } 312 + 313 + private func beginFocusCaptureFromShortcut() { 314 + let frontmost = NSWorkspace.shared.frontmostApplication 315 + if frontmost?.bundleIdentifier == Bundle.main.bundleIdentifier { 316 + appBeforeFocusCapture = nil 317 + } else { 318 + appBeforeFocusCapture = frontmost 319 + } 320 + closePopover() 321 + if menuBand.typeMode { 322 + menuBand.disableTypeModeForFocusCapture() 323 + } 324 + focusCaptureArmedByShortcut = true 325 + localCapture.arm() 326 + updateIcon() 327 + } 328 + 329 + private func finishLocalCapture(reason: LocalKeyCapture.EndReason) { 330 + let shouldRestoreFocus = focusCaptureArmedByShortcut && reason == .cancelled 331 + focusCaptureArmedByShortcut = false 332 + menuBand.releaseAllHeldNotes() 333 + ghostUntil = 0 334 + ghostRefreshTimer?.invalidate() 335 + ghostRefreshTimer = nil 336 + updateIcon() 337 + if shouldRestoreFocus { 338 + restorePreviousAppFocus() 339 + } 340 + appBeforeFocusCapture = nil 341 + } 342 + 343 + private func restorePreviousAppFocus() { 344 + guard let app = appBeforeFocusCapture, 345 + !app.isTerminated, 346 + app.bundleIdentifier != Bundle.main.bundleIdentifier else { return } 347 + app.activate(options: [.activateIgnoringOtherApps]) 242 348 } 243 349 244 350 // MARK: - Adaptive menubar layout ··· 609 715 /// starts at the pivot and ripples outward; fade-out starts at the 610 716 /// outermost cell and retreats toward the pivot. 611 717 private func letterAlpha(for midi: UInt8) -> CGFloat { 718 + if focusCaptureArmedByShortcut { return 1.0 } 612 719 return letterAlphas[midi] ?? 0 613 720 } 614 721
+22 -4
slab/menuband/Sources/MenuBand/GlobalHotkey.swift
··· 6 6 // shortcut that toggles MenuBand TYPE mode regardless of which app is 7 7 // frontmost. 8 8 final class GlobalHotkey { 9 + private let signature: OSType 10 + private let id: UInt32 9 11 private var hotKeyRef: EventHotKeyRef? 10 12 private var eventHandler: EventHandlerRef? 11 13 private let onTrigger: () -> Void 12 14 13 - init(onTrigger: @escaping () -> Void) { 15 + init(signature: OSType = OSType(0x4E544B59), id: UInt32 = 1, onTrigger: @escaping () -> Void) { 16 + self.signature = signature 17 + self.id = id 14 18 self.onTrigger = onTrigger 15 19 } 16 20 ··· 19 23 @discardableResult 20 24 func register(keyCode: UInt32, modifiers: UInt32) -> Bool { 21 25 unregister() 22 - let hotKeyID = EventHotKeyID(signature: OSType(0x4E544B59), // 'NTKY' (MenuBand key) 23 - id: 1) 26 + let hotKeyID = EventHotKeyID(signature: signature, id: id) 24 27 var ref: EventHotKeyRef? 25 28 let regOK = RegisterEventHotKey(keyCode, modifiers, hotKeyID, 26 29 GetApplicationEventTarget(), 0, &ref) ··· 33 36 var spec = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), 34 37 eventKind: UInt32(kEventHotKeyPressed)) 35 38 let opaque = Unmanaged.passUnretained(self).toOpaque() 36 - let handler: EventHandlerUPP = { _, _, userData -> OSStatus in 39 + let handler: EventHandlerUPP = { _, event, userData -> OSStatus in 37 40 guard let userData = userData else { return noErr } 38 41 let hk = Unmanaged<GlobalHotkey>.fromOpaque(userData).takeUnretainedValue() 42 + guard let event = event else { return OSStatus(eventNotHandledErr) } 43 + var incomingID = EventHotKeyID() 44 + let status = GetEventParameter( 45 + event, 46 + EventParamName(kEventParamDirectObject), 47 + EventParamType(typeEventHotKeyID), 48 + nil, 49 + MemoryLayout<EventHotKeyID>.size, 50 + nil, 51 + &incomingID 52 + ) 53 + guard status == noErr else { return status } 54 + guard incomingID.signature == hk.signature, incomingID.id == hk.id else { 55 + return OSStatus(eventNotHandledErr) 56 + } 39 57 DispatchQueue.main.async { hk.onTrigger() } 40 58 return noErr 41 59 }
+15 -5
slab/menuband/Sources/MenuBand/LocalKeyCapture.swift
··· 11 11 /// The current global-tap mode (Notepat / Ableton in the popover) survives 12 12 /// any focus change, but requires Accessibility and isn't App-Store-eligible. 13 13 final class LocalKeyCapture { 14 + enum EndReason { 15 + case cancelled 16 + case resignedKey 17 + } 18 + 14 19 /// Called on every keyDown the panel observes. Return `true` to consume 15 20 /// (so cmd-q etc. can still fall through if false). Receives the same 16 21 /// (keyCode, isDown, isRepeat, flags) shape as the global tap so the ··· 18 23 var onKey: ((UInt16, Bool, Bool, NSEvent.ModifierFlags) -> Bool)? 19 24 /// Called when the panel resigns key — capture has ended naturally 20 25 /// because the user clicked another app. 21 - var onCaptureEnd: (() -> Void)? 26 + var onCaptureEnd: ((EndReason) -> Void)? 27 + var cancelShortcut: MenuBandShortcut? 22 28 23 29 private var panel: NSPanel? 24 30 private var monitor: Any? ··· 50 56 monitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp]) { [weak self] event in 51 57 guard let self = self else { return event } 52 58 let isDown = (event.type == .keyDown) 59 + if isDown, self.cancelShortcut?.matches(event: event) == true { 60 + self.disarm(reason: .cancelled) 61 + return nil 62 + } 53 63 let consumed = self.onKey?(event.keyCode, isDown, event.isARepeat, event.modifierFlags) ?? false 54 64 return consumed ? nil : event 55 65 } ··· 66 76 ) { [weak self] _ in 67 77 guard let self = self else { return } 68 78 let stillKeyInApp = NSApp.windows.contains { $0.isKeyWindow } 69 - if !stillKeyInApp { self.disarm() } 79 + if !stillKeyInApp { self.disarm(reason: .resignedKey) } 70 80 } 71 81 } 72 82 isArmed = true ··· 74 84 75 85 /// Tear down the panel + monitor. Called when the user clicks another 76 86 /// app (panel resigns key) or explicitly when we want to drop capture. 77 - func disarm() { 87 + func disarm(reason: EndReason = .cancelled) { 78 88 guard isArmed else { return } 79 89 isArmed = false 80 90 if let m = monitor { ··· 86 96 resignKeyObserver = nil 87 97 } 88 98 panel?.orderOut(nil) 89 - onCaptureEnd?() 99 + onCaptureEnd?(reason) 90 100 } 91 101 92 102 private func buildPanel() { ··· 111 121 panel = p 112 122 } 113 123 114 - deinit { disarm() } 124 + deinit { disarm(reason: .cancelled) } 115 125 } 116 126 117 127 /// `NSPanel` subclass that overrides `canBecomeKey` to return true. Without
+6 -1
slab/menuband/Sources/MenuBand/MenuBandController.swift
··· 370 370 } 371 371 } 372 372 373 + func disableTypeModeForFocusCapture() { 374 + guard typeMode else { return } 375 + disableTypeMode() 376 + } 377 + 373 378 func shutdown() { 374 379 disableTypeMode() 375 380 disableMIDIMode() ··· 906 911 } 907 912 } 908 913 909 - private func releaseAllHeldNotes() { 914 + func releaseAllHeldNotes() { 910 915 heldLock.lock() 911 916 let noteSnapshot = heldNotes 912 917 let chanSnapshot = heldKeyChannel
+126 -1
slab/menuband/Sources/MenuBand/MenuBandPopover.swift
··· 1 1 import AppKit 2 + import Carbon 2 3 3 4 /// NSSegmentedControl subclass that reports the currently-hovered segment 4 5 /// (or nil when the cursor leaves). Used by the input-mode picker so ··· 61 62 /// we don't extend its lifetime; used to animate `contentSize` when the 62 63 /// instrument palette collapses / expands. 63 64 weak var popover: NSPopover? 65 + var onFocusShortcutChange: ((MenuBandShortcut) -> Bool)? 66 + var onFocusShortcutRecordingChanged: ((Bool) -> Void)? 64 67 65 68 private var inputSegmented: HoverSegmentedControl! // legacy reference; no longer added to stack 66 69 private var modeButtons: [NSButton] = [] // vertical stack: Mouse Only / Notepat.com / Ableton MIDI Keys 70 + private var focusShortcutButton: NSButton! 71 + private var focusShortcutStatusLabel: NSTextField! 72 + private var focusShortcutRecorderMonitor: Any? 73 + private var isRecordingFocusShortcut = false 67 74 private var midiSwitch: NSSwitch! 68 75 private var midiInlineLabel: NSTextField! 69 76 private var midiSelfTestLabel: NSTextField! // legacy — created but never added to stack ··· 89 96 private var updateLabel: NSTextField! 90 97 private var waveformView: WaveformView! 91 98 private var waveformBezel: NSView! 99 + 100 + deinit { 101 + if let monitor = focusShortcutRecorderMonitor { 102 + NSEvent.removeMonitor(monitor) 103 + } 104 + } 92 105 93 106 override func loadView() { 94 107 // Plain solid-color background — no NSVisualEffectView. The visual ··· 271 284 inputLabel.font = NSFont.systemFont(ofSize: 11, weight: .semibold) 272 285 inputLabel.textColor = .labelColor 273 286 287 + let shortcuts = NSStackView() 288 + shortcuts.orientation = .horizontal 289 + shortcuts.alignment = .centerY 290 + shortcuts.distribution = .fill 291 + shortcuts.spacing = 8 292 + shortcuts.translatesAutoresizingMaskIntoConstraints = false 293 + 294 + let focusLabel = NSTextField(labelWithString: "Focus menu piano") 295 + focusLabel.font = NSFont.systemFont(ofSize: 11) 296 + focusLabel.textColor = .labelColor 297 + shortcuts.addArrangedSubview(focusLabel) 298 + 299 + let focusSpacer = NSView() 300 + focusSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal) 301 + shortcuts.addArrangedSubview(focusSpacer) 302 + 303 + focusShortcutButton = NSButton( 304 + title: MenuBandShortcutPreferences.focusShortcut.displayString, 305 + target: self, 306 + action: #selector(focusShortcutButtonClicked(_:)) 307 + ) 308 + focusShortcutButton.bezelStyle = .recessed 309 + focusShortcutButton.controlSize = .small 310 + focusShortcutButton.translatesAutoresizingMaskIntoConstraints = false 311 + focusShortcutButton.widthAnchor.constraint(equalToConstant: 96).isActive = true 312 + shortcuts.addArrangedSubview(focusShortcutButton) 313 + 314 + shortcuts.widthAnchor.constraint( 315 + equalToConstant: InstrumentListView.preferredWidth 316 + ).isActive = true 317 + 318 + focusShortcutStatusLabel = NSTextField(labelWithString: "") 319 + focusShortcutStatusLabel.font = NSFont.systemFont(ofSize: 10) 320 + focusShortcutStatusLabel.textColor = .secondaryLabelColor 321 + focusShortcutStatusLabel.lineBreakMode = .byTruncatingTail 322 + focusShortcutStatusLabel.widthAnchor.constraint( 323 + equalToConstant: InstrumentListView.preferredWidth 324 + ).isActive = true 325 + 274 326 // Vertical mode buttons — full labels fit without truncation, each 275 327 // button is the full content width with an SF Symbol leading the 276 328 // text so the mode is recognizable at a glance. ··· 328 380 // popover stack farther down — see the `palettePanel` 329 381 // insertion point. 330 382 let layoutBlock = (label: inputLabel, picker: modeStack, hint: inputHint) 383 + 384 + let shortcutLabel = NSTextField(labelWithString: "Key Shortcuts") 385 + shortcutLabel.font = NSFont.systemFont(ofSize: 11, weight: .semibold) 386 + shortcutLabel.textColor = .labelColor 331 387 332 388 // Live segmented LED meter of the local synth output. Hidden in 333 389 // MIDI mode (DAW handles audio there; our local mixer is silent ··· 612 668 stack.addArrangedSubview(layoutBlock.label) 613 669 stack.addArrangedSubview(layoutBlock.picker) 614 670 stack.addArrangedSubview(layoutBlock.hint) 671 + stack.addArrangedSubview(shortcutLabel) 672 + stack.addArrangedSubview(shortcuts) 673 + stack.addArrangedSubview(focusShortcutStatusLabel) 615 674 616 675 // No divider above the about/brand block — the palette + Layout 617 676 // section above gives plenty of separation. Custom airspace 618 677 // before the about block. 619 - stack.setCustomSpacing(14, after: layoutBlock.hint) 678 + stack.setCustomSpacing(14, after: focusShortcutStatusLabel) 620 679 621 680 // About + Crash logs in a side-by-side row. About has low hugging 622 681 // so it expands when the crash column is hidden (no reports) — ··· 780 839 781 840 override func viewDidDisappear() { 782 841 super.viewDidDisappear() 842 + stopFocusShortcutRecording(status: nil) 783 843 waveformView.isLive = false 784 844 } 785 845 ··· 878 938 for (i, btn) in modeButtons.enumerated() { 879 939 btn.state = (i == segIdx) ? .on : .off 880 940 } 941 + updateFocusShortcutControls() 881 942 instrumentList.selectedProgram = n.melodicProgram 882 943 applyAppearanceToVisualizer() 883 944 updateInstrumentReadout() ··· 937 998 /// (no leading "078" program number — the cell's own backdrop color 938 999 /// is the visual identifier; the number was redundant noise). A 939 1000 /// hair space on each side keeps the chip from touching its text. 1001 + private func updateFocusShortcutControls(status: String? = nil) { 1002 + guard focusShortcutButton != nil else { return } 1003 + if isRecordingFocusShortcut { 1004 + focusShortcutButton.title = "Press keys" 1005 + } else { 1006 + focusShortcutButton.title = MenuBandShortcutPreferences.focusShortcut.displayString 1007 + } 1008 + focusShortcutStatusLabel.stringValue = status ?? "" 1009 + } 1010 + 1011 + private func startFocusShortcutRecording() { 1012 + guard focusShortcutRecorderMonitor == nil else { return } 1013 + isRecordingFocusShortcut = true 1014 + onFocusShortcutRecordingChanged?(true) 1015 + updateFocusShortcutControls(status: "Use ⌘, ⌃, or ⌥") 1016 + focusShortcutRecorderMonitor = NSEvent.addLocalMonitorForEvents( 1017 + matching: [.keyDown] 1018 + ) { [weak self] event in 1019 + self?.handleFocusShortcutRecording(event) 1020 + return nil 1021 + } 1022 + } 1023 + 1024 + private func stopFocusShortcutRecording(status: String?) { 1025 + guard isRecordingFocusShortcut || focusShortcutRecorderMonitor != nil else { return } 1026 + if let monitor = focusShortcutRecorderMonitor { 1027 + NSEvent.removeMonitor(monitor) 1028 + focusShortcutRecorderMonitor = nil 1029 + } 1030 + isRecordingFocusShortcut = false 1031 + onFocusShortcutRecordingChanged?(false) 1032 + updateFocusShortcutControls(status: status) 1033 + } 1034 + 1035 + private func handleFocusShortcutRecording(_ event: NSEvent) { 1036 + if event.keyCode == UInt16(kVK_Escape) { 1037 + stopFocusShortcutRecording(status: nil) 1038 + return 1039 + } 1040 + let shortcut = MenuBandShortcut( 1041 + keyCode: UInt32(event.keyCode), 1042 + modifiers: MenuBandShortcut.carbonModifiers(from: event.modifierFlags) 1043 + ) 1044 + guard shortcut.isValidForRecording else { 1045 + updateFocusShortcutControls(status: "Use ⌘, ⌃, or ⌥") 1046 + return 1047 + } 1048 + guard !shortcut.isReservedForTypeMode else { 1049 + updateFocusShortcutControls(status: "⌃⌥⌘P is reserved") 1050 + return 1051 + } 1052 + let saved = onFocusShortcutChange?(shortcut) ?? false 1053 + stopFocusShortcutRecording( 1054 + status: saved ? "Saved \(shortcut.displayString)" : "Shortcut unavailable" 1055 + ) 1056 + } 940 1057 private func updateInstrumentReadout() { 941 1058 guard let m = menuBand else { return } 942 1059 let safe = max(0, min(127, Int(m.melodicProgram))) ··· 1326 1443 m.keymap = .ableton 1327 1444 if !m.typeMode { m.toggleTypeMode() } 1328 1445 default: break 1446 + } 1447 + } 1448 + 1449 + @objc private func focusShortcutButtonClicked(_ sender: NSButton) { 1450 + if isRecordingFocusShortcut { 1451 + stopFocusShortcutRecording(status: nil) 1452 + } else { 1453 + startFocusShortcutRecording() 1329 1454 } 1330 1455 } 1331 1456
+120
slab/menuband/Sources/MenuBand/MenuBandShortcut.swift
··· 1 + import AppKit 2 + import Carbon 3 + 4 + struct MenuBandShortcut: Equatable { 5 + let keyCode: UInt32 6 + let modifiers: UInt32 7 + 8 + static let defaultFocus = MenuBandShortcut( 9 + keyCode: UInt32(kVK_ANSI_K), 10 + modifiers: UInt32(cmdKey | controlKey | optionKey) 11 + ) 12 + 13 + static let typeMode = MenuBandShortcut( 14 + keyCode: UInt32(kVK_ANSI_P), 15 + modifiers: UInt32(cmdKey | controlKey | optionKey) 16 + ) 17 + 18 + var isValidForRecording: Bool { 19 + (modifiers & UInt32(cmdKey | controlKey | optionKey)) != 0 20 + } 21 + 22 + var isReservedForTypeMode: Bool { 23 + self == Self.typeMode 24 + } 25 + 26 + var displayString: String { 27 + var parts = "" 28 + if (modifiers & UInt32(controlKey)) != 0 { parts += "⌃" } 29 + if (modifiers & UInt32(optionKey)) != 0 { parts += "⌥" } 30 + if (modifiers & UInt32(shiftKey)) != 0 { parts += "⇧" } 31 + if (modifiers & UInt32(cmdKey)) != 0 { parts += "⌘" } 32 + return parts + Self.keyLabel(for: keyCode) 33 + } 34 + 35 + func matches(event: NSEvent) -> Bool { 36 + UInt32(event.keyCode) == keyCode && 37 + Self.carbonModifiers(from: event.modifierFlags) == modifiers 38 + } 39 + 40 + static func carbonModifiers(from flags: NSEvent.ModifierFlags) -> UInt32 { 41 + var mask: UInt32 = 0 42 + if flags.contains(.command) { mask |= UInt32(cmdKey) } 43 + if flags.contains(.control) { mask |= UInt32(controlKey) } 44 + if flags.contains(.option) { mask |= UInt32(optionKey) } 45 + if flags.contains(.shift) { mask |= UInt32(shiftKey) } 46 + return mask 47 + } 48 + 49 + private static func keyLabel(for keyCode: UInt32) -> String { 50 + if let label = ansiKeyLabels[keyCode] { return label } 51 + if let label = specialKeyLabels[keyCode] { return label } 52 + return "Key \(keyCode)" 53 + } 54 + 55 + private static let ansiKeyLabels: [UInt32: String] = [ 56 + 0: "A", 1: "S", 2: "D", 3: "F", 4: "H", 5: "G", 6: "Z", 57 + 7: "X", 8: "C", 9: "V", 11: "B", 12: "Q", 13: "W", 58 + 14: "E", 15: "R", 16: "Y", 17: "T", 18: "1", 19: "2", 59 + 20: "3", 21: "4", 22: "6", 23: "5", 24: "=", 25: "9", 60 + 26: "7", 27: "-", 28: "8", 29: "0", 30: "]", 31: "O", 61 + 32: "U", 33: "[", 34: "I", 35: "P", 37: "L", 38: "J", 62 + 39: "'", 40: "K", 41: ";", 42: "\\", 43: ",", 44: "/", 63 + 45: "N", 46: "M", 47: ".", 50: "`" 64 + ] 65 + 66 + private static let specialKeyLabels: [UInt32: String] = [ 67 + UInt32(kVK_Space): "Space", 68 + UInt32(kVK_Return): "Return", 69 + UInt32(kVK_Tab): "Tab", 70 + UInt32(kVK_Escape): "Esc", 71 + UInt32(kVK_Delete): "Delete", 72 + UInt32(kVK_ForwardDelete): "FwdDel", 73 + UInt32(kVK_Home): "Home", 74 + UInt32(kVK_End): "End", 75 + UInt32(kVK_PageUp): "PgUp", 76 + UInt32(kVK_PageDown): "PgDn", 77 + UInt32(kVK_LeftArrow): "←", 78 + UInt32(kVK_RightArrow): "→", 79 + UInt32(kVK_UpArrow): "↑", 80 + UInt32(kVK_DownArrow): "↓", 81 + UInt32(kVK_F1): "F1", 82 + UInt32(kVK_F2): "F2", 83 + UInt32(kVK_F3): "F3", 84 + UInt32(kVK_F4): "F4", 85 + UInt32(kVK_F5): "F5", 86 + UInt32(kVK_F6): "F6", 87 + UInt32(kVK_F7): "F7", 88 + UInt32(kVK_F8): "F8", 89 + UInt32(kVK_F9): "F9", 90 + UInt32(kVK_F10): "F10", 91 + UInt32(kVK_F11): "F11", 92 + UInt32(kVK_F12): "F12" 93 + ] 94 + } 95 + 96 + enum MenuBandShortcutPreferences { 97 + private static let focusKeyCodeKey = "notepat.focusShortcut.keyCode" 98 + private static let focusModifiersKey = "notepat.focusShortcut.modifiers" 99 + 100 + static var focusShortcut: MenuBandShortcut { 101 + get { 102 + let defaults = UserDefaults.standard 103 + guard defaults.object(forKey: focusKeyCodeKey) != nil, 104 + defaults.object(forKey: focusModifiersKey) != nil else { 105 + return .defaultFocus 106 + } 107 + let shortcut = MenuBandShortcut( 108 + keyCode: UInt32(defaults.integer(forKey: focusKeyCodeKey)), 109 + modifiers: UInt32(defaults.integer(forKey: focusModifiersKey)) 110 + ) 111 + return shortcut.isValidForRecording && !shortcut.isReservedForTypeMode 112 + ? shortcut 113 + : .defaultFocus 114 + } 115 + set { 116 + UserDefaults.standard.set(Int(newValue.keyCode), forKey: focusKeyCodeKey) 117 + UserDefaults.standard.set(Int(newValue.modifiers), forKey: focusModifiersKey) 118 + } 119 + } 120 + }