Browse Source

feat: default hotkey to Control + Space with Toggle mode

main
Ali Alavi 2 weeks ago
parent
commit
177cf9f5c5
  1. 3
      src/HUDOverlay.swift
  2. 109
      src/HotkeyManager.swift

3
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)
}

109
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()
}
}
}

Loading…
Cancel
Save