Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions OpenSuperWhisper/ModifierKeyMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
30 changes: 29 additions & 1 deletion OpenSuperWhisper/Settings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
44 changes: 33 additions & 11 deletions OpenSuperWhisper/ShortcutManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -173,26 +192,29 @@ class ShortcutManager {
}
}
}

private actor AnchorGate {
private var continuation: CheckedContinuation<NSPoint?, Never>?

init(_ continuation: CheckedContinuation<NSPoint?, Never>) {
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()
Expand Down
3 changes: 3 additions & 0 deletions OpenSuperWhisper/Utils/AppPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading