diff --git a/OpenSuperWhisper/ModifierKeyMonitor.swift b/OpenSuperWhisper/ModifierKeyMonitor.swift index fdef9291..39d2b741 100644 --- a/OpenSuperWhisper/ModifierKeyMonitor.swift +++ b/OpenSuperWhisper/ModifierKeyMonitor.swift @@ -100,20 +100,20 @@ class ModifierKeyMonitor { private var runLoopSource: CFRunLoopSource? private var selectedModifierKey: ModifierKey = .none private var isModifierPressed = false - + var onKeyDown: (() -> Void)? var onKeyUp: (() -> Void)? - + private init() {} - + func start(modifierKey: ModifierKey) { guard modifierKey != .none else { stop() return } - + stop() - + selectedModifierKey = modifierKey isModifierPressed = false diff --git a/OpenSuperWhisper/Settings.swift b/OpenSuperWhisper/Settings.swift index 1dcadfb2..518b9d96 100644 --- a/OpenSuperWhisper/Settings.swift +++ b/OpenSuperWhisper/Settings.swift @@ -164,6 +164,13 @@ class SettingsViewModel: ObservableObject { AppPreferences.shared.holdToRecord = holdToRecord } } + + @Published var doublePressToTrigger: Bool { + didSet { + AppPreferences.shared.doublePressToTrigger = doublePressToTrigger + NotificationCenter.default.post(name: .hotkeySettingsChanged, object: nil) + } + } @Published var escCancelWithoutConfirmation: Bool { didSet { @@ -213,6 +220,7 @@ class SettingsViewModel: ObservableObject { self.modifierOnlyHotkey = ModifierKey(rawValue: prefs.modifierOnlyHotkey) ?? .none self.mouseButtonHotkey = MouseButton(rawValue: prefs.mouseButtonHotkey) ?? .none self.holdToRecord = prefs.holdToRecord + self.doublePressToTrigger = prefs.doublePressToTrigger self.escCancelWithoutConfirmation = prefs.escCancelWithoutConfirmation self.startHiddenInMenuBar = prefs.startHiddenInMenuBar self.addSpaceAfterSentence = prefs.addSpaceAfterSentence @@ -1248,10 +1256,30 @@ struct SettingsView: View { .background(Color(.textBackgroundColor).opacity(0.5)) .cornerRadius(8) - Text("One-tap to toggle recording") + Text(viewModel.doublePressToTrigger + ? "Double-tap to toggle recording" + : "One-tap to toggle recording") .font(.caption) .foregroundColor(.secondary) + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Double Tap to Trigger") + .font(.subheadline) + Text("Require two quick taps to avoid accidental activation") + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + Toggle("", isOn: $viewModel.doublePressToTrigger) + .toggleStyle(SwitchToggleStyle(tint: Color.accentColor)) + .labelsHidden() + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(Color(.textBackgroundColor).opacity(0.5)) + .cornerRadius(8) + permissionWarning( message: "⚠️ This mode requires Input Monitoring permission. macOS requires this to detect single modifier key presses globally. Only modifier key events (⌘, ⌥, ⇧, ⌃, Fn) are monitored — no regular keystrokes are captured.", isGranted: permissionsManager.isInputMonitoringPermissionGranted diff --git a/OpenSuperWhisper/ShortcutManager.swift b/OpenSuperWhisper/ShortcutManager.swift index 051272b7..db3e33d6 100644 --- a/OpenSuperWhisper/ShortcutManager.swift +++ b/OpenSuperWhisper/ShortcutManager.swift @@ -20,6 +20,8 @@ class ShortcutManager { private var holdMode = false private var useModifierOnlyHotkey = false private var useMouseButtonHotkey = false + private var lastPressDownTime: CFAbsoluteTime = 0 + private var pressConsumed = false private init() { print("ShortcutManager init") @@ -108,8 +110,10 @@ class ShortcutManager { self?.handleKeyUp() } + lastPressDownTime = 0 + pressConsumed = false ModifierKeyMonitor.shared.start(modifierKey: modifierKey) - print("ShortcutManager: Using modifier-only hotkey: \(modifierKey.displayName)") + print("ShortcutManager: Using modifier-only hotkey: \(modifierKey.displayName) (double-press: \(AppPreferences.shared.doublePressToTrigger))") } else { useMouseButtonHotkey = false useModifierOnlyHotkey = false @@ -121,10 +125,25 @@ class ShortcutManager { private func handleKeyDown() { holdWorkItem?.cancel() holdMode = false - + + // Require a double-tap only when starting a new recording. Once recording is + // active, a single press stops it so the user isn't forced to double-tap again. + if AppPreferences.shared.doublePressToTrigger && activeVm == nil { + let now = CFAbsoluteTimeGetCurrent() + let threshold = NSEvent.doubleClickInterval + if lastPressDownTime > 0 && now - lastPressDownTime <= threshold { + lastPressDownTime = 0 + } else { + lastPressDownTime = now + pressConsumed = false + return + } + } + pressConsumed = true + let holdToRecordEnabled = AppPreferences.shared.holdToRecord let isStartingRecording = activeVm == nil - + Task { @MainActor in if self.activeVm == nil { // Start recording immediately: resolving the caret position talks to @@ -144,7 +163,7 @@ class ShortcutManager { self.activeVm = nil } } - + // Arm hold mode only when this press starts a recording. Arming it on the // stopping press would trigger a second stop on key-up. if holdToRecordEnabled && isStartingRecording { @@ -155,7 +174,7 @@ class ShortcutManager { DispatchQueue.main.asyncAfter(deadline: .now() + holdThreshold, execute: workItem) } } - + /// Resolves the input anchor without letting a slow focused app delay the /// indicator: whichever finishes first wins — the AX resolution or the /// deadline. On timeout the caller falls back to the mouse position; the @@ -173,26 +192,29 @@ class ShortcutManager { } } } - + private actor AnchorGate { private var continuation: CheckedContinuation? - + init(_ continuation: CheckedContinuation) { self.continuation = continuation } - + func resume(_ value: NSPoint?) { continuation?.resume(returning: value) continuation = nil } } - + private func handleKeyUp() { holdWorkItem?.cancel() holdWorkItem = nil - + + guard pressConsumed else { return } + pressConsumed = false + let holdToRecordEnabled = AppPreferences.shared.holdToRecord - + Task { @MainActor in if holdToRecordEnabled && self.holdMode && self.activeVm != nil { IndicatorWindowManager.shared.stopRecording() diff --git a/OpenSuperWhisper/Utils/AppPreferences.swift b/OpenSuperWhisper/Utils/AppPreferences.swift index a9d85fad..9614e2c8 100644 --- a/OpenSuperWhisper/Utils/AppPreferences.swift +++ b/OpenSuperWhisper/Utils/AppPreferences.swift @@ -116,6 +116,9 @@ final class AppPreferences { @UserDefault(key: "holdToRecord", defaultValue: true) var holdToRecord: Bool + + @UserDefault(key: "doublePressToTrigger", defaultValue: false) + var doublePressToTrigger: Bool @UserDefault(key: "addSpaceAfterSentence", defaultValue: true) var addSpaceAfterSentence: Bool