Refactor and Rename to CommandNotch
This commit is contained in:
241
Downterm/CommandNotch/Managers/HotkeyManager.swift
Normal file
241
Downterm/CommandNotch/Managers/HotkeyManager.swift
Normal file
@@ -0,0 +1,241 @@
|
||||
import AppKit
|
||||
import Carbon.HIToolbox
|
||||
|
||||
/// Manages global and local hotkeys.
|
||||
///
|
||||
/// The toggle hotkey uses Carbon's `RegisterEventHotKey` which works
|
||||
/// system-wide without Accessibility permission. Tab-level hotkeys
|
||||
/// use a local `NSEvent` monitor (only fires when our app is active).
|
||||
class HotkeyManager {
|
||||
|
||||
static let shared = HotkeyManager()
|
||||
|
||||
// MARK: - Callbacks
|
||||
|
||||
var onToggle: (() -> Void)?
|
||||
var onNewTab: (() -> Void)?
|
||||
var onCloseTab: (() -> Void)?
|
||||
var onNextTab: (() -> Void)?
|
||||
var onPreviousTab: (() -> Void)?
|
||||
var onDetachTab: (() -> Void)?
|
||||
var onSwitchToTab: ((Int) -> Void)?
|
||||
|
||||
/// Tab-level hotkeys only fire when the notch is open.
|
||||
var isNotchOpen: Bool = false
|
||||
|
||||
private var hotKeyRef: EventHotKeyRef?
|
||||
private var eventHandlerRef: EventHandlerRef?
|
||||
private var localMonitor: Any?
|
||||
private var defaultsObserver: NSObjectProtocol?
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Resolved bindings (live from UserDefaults)
|
||||
|
||||
private var toggleBinding: HotkeyBinding {
|
||||
binding(for: NotchSettings.Keys.hotkeyToggle) ?? .cmdReturn
|
||||
}
|
||||
private var newTabBinding: HotkeyBinding {
|
||||
binding(for: NotchSettings.Keys.hotkeyNewTab) ?? .cmdT
|
||||
}
|
||||
private var closeTabBinding: HotkeyBinding {
|
||||
binding(for: NotchSettings.Keys.hotkeyCloseTab) ?? .cmdW
|
||||
}
|
||||
private var nextTabBinding: HotkeyBinding {
|
||||
binding(for: NotchSettings.Keys.hotkeyNextTab) ?? .cmdShiftRB
|
||||
}
|
||||
private var prevTabBinding: HotkeyBinding {
|
||||
binding(for: NotchSettings.Keys.hotkeyPreviousTab) ?? .cmdShiftLB
|
||||
}
|
||||
private var detachBinding: HotkeyBinding {
|
||||
binding(for: NotchSettings.Keys.hotkeyDetachTab) ?? .cmdD
|
||||
}
|
||||
|
||||
private func binding(for key: String) -> HotkeyBinding? {
|
||||
guard let json = UserDefaults.standard.string(forKey: key) else { return nil }
|
||||
return HotkeyBinding.fromJSON(json)
|
||||
}
|
||||
|
||||
// MARK: - Start / Stop
|
||||
|
||||
func start() {
|
||||
installCarbonHandler()
|
||||
registerToggleHotkey()
|
||||
installLocalMonitor()
|
||||
observeToggleHotkeyChanges()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
unregisterToggleHotkey()
|
||||
removeCarbonHandler()
|
||||
removeLocalMonitor()
|
||||
if let obs = defaultsObserver {
|
||||
NotificationCenter.default.removeObserver(obs)
|
||||
defaultsObserver = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Carbon global hotkey (toggle)
|
||||
|
||||
/// Installs a Carbon event handler that receives `kEventHotKeyPressed`
|
||||
/// events when a registered hotkey fires — works system-wide.
|
||||
private func installCarbonHandler() {
|
||||
var eventType = EventTypeSpec(
|
||||
eventClass: OSType(kEventClassKeyboard),
|
||||
eventKind: UInt32(kEventHotKeyPressed)
|
||||
)
|
||||
|
||||
// Closure must not capture self — uses the singleton accessor instead.
|
||||
let status = InstallEventHandler(
|
||||
GetApplicationEventTarget(),
|
||||
{ (_: EventHandlerCallRef?, theEvent: EventRef?, _: UnsafeMutableRawPointer?) -> OSStatus in
|
||||
guard let theEvent else { return OSStatus(eventNotHandledErr) }
|
||||
|
||||
var hotKeyID = EventHotKeyID()
|
||||
let err = GetEventParameter(
|
||||
theEvent,
|
||||
EventParamName(kEventParamDirectObject),
|
||||
EventParamType(typeEventHotKeyID),
|
||||
nil,
|
||||
MemoryLayout<EventHotKeyID>.size,
|
||||
nil,
|
||||
&hotKeyID
|
||||
)
|
||||
guard err == noErr else { return err }
|
||||
|
||||
if hotKeyID.id == 1 {
|
||||
DispatchQueue.main.async {
|
||||
HotkeyManager.shared.onToggle?()
|
||||
}
|
||||
}
|
||||
return noErr
|
||||
},
|
||||
1,
|
||||
&eventType,
|
||||
nil,
|
||||
&eventHandlerRef
|
||||
)
|
||||
|
||||
if status != noErr {
|
||||
print("[HotkeyManager] Failed to install Carbon event handler: \(status)")
|
||||
}
|
||||
}
|
||||
|
||||
private func registerToggleHotkey() {
|
||||
unregisterToggleHotkey()
|
||||
|
||||
let binding = toggleBinding
|
||||
let carbonMods = carbonModifiers(from: binding.modifiers)
|
||||
var hotKeyID = EventHotKeyID(
|
||||
signature: OSType(0x444E5452), // "DNTR"
|
||||
id: 1
|
||||
)
|
||||
|
||||
let status = RegisterEventHotKey(
|
||||
UInt32(binding.keyCode),
|
||||
carbonMods,
|
||||
hotKeyID,
|
||||
GetApplicationEventTarget(),
|
||||
0,
|
||||
&hotKeyRef
|
||||
)
|
||||
|
||||
if status != noErr {
|
||||
print("[HotkeyManager] Failed to register toggle hotkey: \(status)")
|
||||
}
|
||||
}
|
||||
|
||||
private func unregisterToggleHotkey() {
|
||||
if let ref = hotKeyRef {
|
||||
UnregisterEventHotKey(ref)
|
||||
hotKeyRef = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func removeCarbonHandler() {
|
||||
if let ref = eventHandlerRef {
|
||||
RemoveEventHandler(ref)
|
||||
eventHandlerRef = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-register the toggle hotkey whenever the user changes it in settings.
|
||||
private func observeToggleHotkeyChanges() {
|
||||
defaultsObserver = NotificationCenter.default.addObserver(
|
||||
forName: UserDefaults.didChangeNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.registerToggleHotkey()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Local monitor (tab-level hotkeys, only when our app is active)
|
||||
|
||||
private func installLocalMonitor() {
|
||||
localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
||||
guard let self else { return event }
|
||||
return self.handleLocalKeyEvent(event) ? nil : event
|
||||
}
|
||||
}
|
||||
|
||||
private func removeLocalMonitor() {
|
||||
if let m = localMonitor {
|
||||
NSEvent.removeMonitor(m)
|
||||
localMonitor = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles tab-level hotkeys. Returns true if the event was consumed.
|
||||
private func handleLocalKeyEvent(_ event: NSEvent) -> Bool {
|
||||
// Tab hotkeys only when the notch is open and focused
|
||||
guard isNotchOpen else { return false }
|
||||
|
||||
if newTabBinding.matches(event) {
|
||||
onNewTab?()
|
||||
return true
|
||||
}
|
||||
if closeTabBinding.matches(event) {
|
||||
onCloseTab?()
|
||||
return true
|
||||
}
|
||||
if nextTabBinding.matches(event) {
|
||||
onNextTab?()
|
||||
return true
|
||||
}
|
||||
if prevTabBinding.matches(event) {
|
||||
onPreviousTab?()
|
||||
return true
|
||||
}
|
||||
if detachBinding.matches(event) {
|
||||
onDetachTab?()
|
||||
return true
|
||||
}
|
||||
|
||||
// Cmd+1 through Cmd+9
|
||||
if event.modifierFlags.contains(.command) {
|
||||
let digitKeyCodes: [UInt16: Int] = [
|
||||
18: 0, 19: 1, 20: 2, 21: 3, 23: 4,
|
||||
22: 5, 26: 6, 28: 7, 25: 8
|
||||
]
|
||||
if let tabIndex = digitKeyCodes[event.keyCode] {
|
||||
onSwitchToTab?(tabIndex)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Carbon modifier conversion
|
||||
|
||||
private func carbonModifiers(from nsModifiers: UInt) -> UInt32 {
|
||||
var carbon: UInt32 = 0
|
||||
let flags = NSEvent.ModifierFlags(rawValue: nsModifiers)
|
||||
if flags.contains(.command) { carbon |= UInt32(cmdKey) }
|
||||
if flags.contains(.shift) { carbon |= UInt32(shiftKey) }
|
||||
if flags.contains(.option) { carbon |= UInt32(optionKey) }
|
||||
if flags.contains(.control) { carbon |= UInt32(controlKey) }
|
||||
return carbon
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user