281 lines
8.7 KiB
Swift
281 lines
8.7 KiB
Swift
import AppKit
|
|
import Carbon.HIToolbox
|
|
import Combine
|
|
|
|
/// Manages global and local hotkeys.
|
|
///
|
|
/// The toggle hotkey uses Carbon's `RegisterEventHotKey` which works
|
|
/// system-wide without Accessibility permission. Notch-scoped hotkeys
|
|
/// use a local `NSEvent` monitor (only fires when our app is active).
|
|
@MainActor
|
|
class HotkeyManager {
|
|
|
|
static let shared = HotkeyManager()
|
|
|
|
// MARK: - Callbacks
|
|
|
|
var onToggle: (() -> Void)?
|
|
var onNewTab: (() -> Void)?
|
|
var onCloseTab: (() -> Void)?
|
|
var onNextTab: (() -> Void)?
|
|
var onPreviousTab: (() -> Void)?
|
|
var onNextWorkspace: (() -> Void)?
|
|
var onPreviousWorkspace: (() -> Void)?
|
|
var onDetachTab: (() -> Void)?
|
|
var onApplySizePreset: ((TerminalSizePreset) -> Void)?
|
|
var onSwitchToTab: ((Int) -> Void)?
|
|
var onSwitchToWorkspace: ((WorkspaceID) -> Void)?
|
|
|
|
/// Notch-scoped 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 let settingsProvider: TerminalSessionConfigurationProviding
|
|
private let workspaceRegistry: WorkspaceRegistry
|
|
private var settingsCancellable: AnyCancellable?
|
|
|
|
init(
|
|
settingsProvider: TerminalSessionConfigurationProviding? = nil,
|
|
workspaceRegistry: WorkspaceRegistry? = nil
|
|
) {
|
|
self.settingsProvider = settingsProvider ?? AppSettingsController.shared
|
|
self.workspaceRegistry = workspaceRegistry ?? WorkspaceRegistry.shared
|
|
}
|
|
|
|
// MARK: - Resolved bindings from typed runtime settings
|
|
|
|
private var toggleBinding: HotkeyBinding {
|
|
settingsProvider.hotkeySettings.toggle
|
|
}
|
|
private var newTabBinding: HotkeyBinding {
|
|
settingsProvider.hotkeySettings.newTab
|
|
}
|
|
private var closeTabBinding: HotkeyBinding {
|
|
settingsProvider.hotkeySettings.closeTab
|
|
}
|
|
private var nextTabBinding: HotkeyBinding {
|
|
settingsProvider.hotkeySettings.nextTab
|
|
}
|
|
private var prevTabBinding: HotkeyBinding {
|
|
settingsProvider.hotkeySettings.previousTab
|
|
}
|
|
private var nextWorkspaceBinding: HotkeyBinding {
|
|
settingsProvider.hotkeySettings.nextWorkspace
|
|
}
|
|
private var previousWorkspaceBinding: HotkeyBinding {
|
|
settingsProvider.hotkeySettings.previousWorkspace
|
|
}
|
|
private var detachBinding: HotkeyBinding {
|
|
settingsProvider.hotkeySettings.detachTab
|
|
}
|
|
private var sizePresets: [TerminalSizePreset] {
|
|
settingsProvider.terminalSizePresets
|
|
}
|
|
|
|
// MARK: - Start / Stop
|
|
|
|
func start() {
|
|
installCarbonHandler()
|
|
registerToggleHotkey()
|
|
installLocalMonitor()
|
|
observeToggleHotkeyChanges()
|
|
}
|
|
|
|
func stop() {
|
|
unregisterToggleHotkey()
|
|
removeCarbonHandler()
|
|
removeLocalMonitor()
|
|
settingsCancellable = 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)
|
|
let 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 typed settings change.
|
|
private func observeToggleHotkeyChanges() {
|
|
guard let settingsProvider = settingsProvider as? AppSettingsController else { return }
|
|
|
|
settingsCancellable = settingsProvider.$settings
|
|
.map(\.hotkeys.toggle)
|
|
.removeDuplicates()
|
|
.dropFirst()
|
|
.sink { [weak self] _ in
|
|
self?.registerToggleHotkey()
|
|
}
|
|
}
|
|
|
|
// MARK: - Local monitor (notch-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 notch-scoped hotkeys. Returns true if the event was consumed.
|
|
private func handleLocalKeyEvent(_ event: NSEvent) -> Bool {
|
|
// Local shortcuts only fire 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 nextWorkspaceBinding.matches(event) {
|
|
onNextWorkspace?()
|
|
return true
|
|
}
|
|
if previousWorkspaceBinding.matches(event) {
|
|
onPreviousWorkspace?()
|
|
return true
|
|
}
|
|
if detachBinding.matches(event) {
|
|
onDetachTab?()
|
|
return true
|
|
}
|
|
for summary in workspaceRegistry.workspaceSummaries {
|
|
guard let binding = summary.hotkey else { continue }
|
|
if binding.matches(event) {
|
|
onSwitchToWorkspace?(summary.id)
|
|
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
|
|
}
|
|
}
|