diff --git a/src/HUDOverlay.swift b/src/HUDOverlay.swift index 24f7ca6..96083a7 100644 --- a/src/HUDOverlay.swift +++ b/src/HUDOverlay.swift @@ -119,7 +119,8 @@ public final class HUDOverlayController { subtitleLabel?.stringValue = preview subtitleLabel?.textColor = NSColor.systemGreen.withAlphaComponent(0.95) } else { - let mode = HotkeyManager.shared.currentMode == .toggle ? "پایان: کلیک مجدد" : "رها کردن کلید ⌥ جهت درج متن" + let hotkey = HotkeyManager.shared.currentPreset.rawValue + let mode = HotkeyManager.shared.currentMode == .toggle ? "پایان: \(hotkey)" : "رها کردن کلید جهت درج" subtitleLabel?.stringValue = mode subtitleLabel?.textColor = NSColor.systemRed.withAlphaComponent(0.9) } diff --git a/src/HotkeyManager.swift b/src/HotkeyManager.swift index b5f68a7..292e719 100644 --- a/src/HotkeyManager.swift +++ b/src/HotkeyManager.swift @@ -3,23 +3,23 @@ import Carbon import ApplicationServices public enum DictationMode: String, CaseIterable { - case pushToTalk = "pushToTalk" // Hold to record, release to transcribe case toggle = "toggle" // Press once to start, press again to stop + case pushToTalk = "pushToTalk" // Hold to record, release to transcribe public var localizedTitle: String { switch self { - case .pushToTalk: - return "نگه‌داشتن برای صحبت (Hold to Talk)" case .toggle: return "فشردن برای شروع / توقف (Toggle)" + case .pushToTalk: + return "نگه‌داشتن برای صحبت (Push to Talk)" } } } public enum HotkeyPreset: String, CaseIterable { + case controlSpace = "Control + Space" case option = "Option (⌥ نگه‌داشتن)" case capsLock = "Caps Lock" - case controlSpace = "Control + Space" case optionSpace = "Option + Space" case cmdShiftSpace = "Cmd + Shift + Space" case f8 = "F8" @@ -27,12 +27,12 @@ public enum HotkeyPreset: String, CaseIterable { public var keyCode: UInt32 { switch self { + case .controlSpace, .optionSpace, .cmdShiftSpace: + return UInt32(kVK_Space) // 49 case .option: return UInt32(kVK_Option) // 58 case .capsLock: return UInt32(kVK_CapsLock) // 57 - case .controlSpace, .optionSpace, .cmdShiftSpace: - return UInt32(kVK_Space) case .f8: return UInt32(kVK_F8) case .f5: @@ -42,15 +42,13 @@ public enum HotkeyPreset: String, CaseIterable { public var carbonModifiers: UInt32 { switch self { - case .option, .capsLock: - return 0 case .controlSpace: return UInt32(controlKey) case .optionSpace: return UInt32(optionKey) case .cmdShiftSpace: return UInt32(cmdKey | shiftKey) - case .f8, .f5: + case .option, .capsLock, .f8, .f5: return 0 } } @@ -70,12 +68,12 @@ public final class HotkeyManager { private var isOptionPhysicallyDown = false private var isCapsLockPhysicallyDown = false - private var isKeyDown = false + private var lastTriggerTime: TimeInterval = 0 public var currentPreset: HotkeyPreset { get { - let val = UserDefaults.standard.string(forKey: "SonioxHotkeyPreset") ?? HotkeyPreset.option.rawValue - return HotkeyPreset(rawValue: val) ?? .option + let val = UserDefaults.standard.string(forKey: "SonioxHotkeyPreset") ?? HotkeyPreset.controlSpace.rawValue + return HotkeyPreset(rawValue: val) ?? .controlSpace } set { UserDefaults.standard.set(newValue.rawValue, forKey: "SonioxHotkeyPreset") @@ -85,8 +83,8 @@ public final class HotkeyManager { public var currentMode: DictationMode { get { - let val = UserDefaults.standard.string(forKey: "SonioxDictationMode") ?? DictationMode.pushToTalk.rawValue - return DictationMode(rawValue: val) ?? .pushToTalk + let val = UserDefaults.standard.string(forKey: "SonioxDictationMode") ?? DictationMode.toggle.rawValue + return DictationMode(rawValue: val) ?? .toggle } set { UserDefaults.standard.set(newValue.rawValue, forKey: "SonioxDictationMode") @@ -100,7 +98,7 @@ public final class HotkeyManager { unregisterHotkeys() let preset = currentPreset - print("Registering Hotkey for preset:", preset.rawValue, "mode:", currentMode.rawValue) + print("HotkeyManager: Registering hotkey for '\(preset.rawValue)', mode: '\(currentMode.rawValue)'") // 1. Carbon HotKey for multi-key combos (Control+Space, etc.) if preset != .option && preset != .capsLock { @@ -116,12 +114,12 @@ public final class HotkeyManager { let kind = GetEventKind(eventRef) if kind == UInt32(kEventHotKeyPressed) { - DispatchQueue.main.async { - manager.onHotkeyPressed?() - } + manager.triggerAction() } else if kind == UInt32(kEventHotKeyReleased) { - DispatchQueue.main.async { - manager.onHotkeyReleased?() + if manager.currentMode == .pushToTalk { + DispatchQueue.main.async { + manager.onHotkeyReleased?() + } } } return noErr @@ -140,13 +138,22 @@ public final class HotkeyManager { ) } - // 2. Global Event Tap for single modifier keys (Option, CapsLock) + // 2. Global Event Tap for high-priority interception (Swallow Control+Space & modifier handling) setupEventTap() - // 3. Secondary NSEvent Global Monitor as backup + // 3. Fallback NSEvent monitor setupGlobalMonitor() } + private func triggerAction() { + let now = Date().timeIntervalSince1970 + guard (now - lastTriggerTime) > 0.35 else { return } + lastTriggerTime = now + DispatchQueue.main.async { [weak self] in + self?.onHotkeyPressed?() + } + } + private func setupEventTap() { let mask = (1 << CGEventType.flagsChanged.rawValue) | (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue) let selfPtr = Unmanaged.passUnretained(self).toOpaque() @@ -158,16 +165,27 @@ public final class HotkeyManager { let flags = event.flags.rawValue let keyCode = event.getIntegerValueField(.keyboardEventKeycode) - // 1. Option Key (Hold / Push-to-Talk) - if manager.currentPreset == .option { - let isAlt = (flags & CGEventFlags.maskAlternate.rawValue) != 0 - if isAlt != manager.isOptionPhysicallyDown { - manager.isOptionPhysicallyDown = isAlt - DispatchQueue.main.async { + // 1. Control + Space (Swallow keystroke and toggle speech) + if manager.currentPreset == .controlSpace { + if keyCode == 49 && type == .keyDown { // 49 = Space + let isCtrl = (flags & CGEventFlags.maskControl.rawValue) != 0 + if isCtrl { + manager.triggerAction() + return nil // Swallow event so space is not typed in the active app + } + } + } + + // 2. Option Key + else if manager.currentPreset == .option { + if keyCode == 58 || keyCode == 61 { + let isAlt = (flags & CGEventFlags.maskAlternate.rawValue) != 0 + if isAlt != manager.isOptionPhysicallyDown { + manager.isOptionPhysicallyDown = isAlt if isAlt { - manager.onHotkeyPressed?() - } else { - if manager.currentMode == .pushToTalk { + manager.triggerAction() + } else if manager.currentMode == .pushToTalk { + DispatchQueue.main.async { manager.onHotkeyReleased?() } } @@ -175,14 +193,12 @@ public final class HotkeyManager { } } - // 2. CapsLock Key + // 3. CapsLock Key else if manager.currentPreset == .capsLock { if keyCode == 57 { if !manager.isCapsLockPhysicallyDown { manager.isCapsLockPhysicallyDown = true - DispatchQueue.main.async { - manager.onHotkeyPressed?() - } + manager.triggerAction() } else { manager.isCapsLockPhysicallyDown = false if manager.currentMode == .pushToTalk { @@ -211,28 +227,17 @@ public final class HotkeyManager { self.runLoopSource = source CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) CGEvent.tapEnable(tap: tap, enable: true) - print("CGEventTap created and enabled successfully.") - } else { - print("CGEventTap creation failed. Falling back to NSEvent global monitor.") + print("HotkeyManager: CGEventTap registered successfully.") } } private func setupGlobalMonitor() { - globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.flagsChanged, .keyDown, .keyUp]) { [weak self] event in - guard let self = self else { return } + globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.keyDown, .flagsChanged]) { [weak self] event in + guard let self = self, self.eventTapPort == nil else { return } - // Only use as fallback if event tap is inactive - if self.eventTapPort == nil { - if self.currentPreset == .option { - let isAlt = event.modifierFlags.contains(.option) - if isAlt != self.isOptionPhysicallyDown { - self.isOptionPhysicallyDown = isAlt - if isAlt { - self.onHotkeyPressed?() - } else if self.currentMode == .pushToTalk { - self.onHotkeyReleased?() - } - } + if self.currentPreset == .controlSpace { + if event.keyCode == 49 && event.modifierFlags.contains(.control) { + self.triggerAction() } } }