Files
downterm/Downterm/CommandNotch/Managers/HotkeyManager.swift

253 lines
7.8 KiB
Swift

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 onApplySizePreset: ((TerminalSizePreset) -> 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 var sizePresets: [TerminalSizePreset] {
TerminalSizePresetStore.load()
}
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
}
for preset in sizePresets {
guard let binding = preset.hotkey else { continue }
if binding.matches(event) {
onApplySizePreset?(preset)
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
}
}