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
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -104,14 +104,14 @@ endif
test: $(TEST_RUNNER)
@$(TEST_RUNNER)

$(TEST_RUNNER): Sources/AppContextService.swift Sources/AppleFoundationModelsPostProcessor.swift Sources/DictionaryStore.swift Sources/TranscriptTidier.swift Sources/TransformStore.swift Sources/WakePhraseMatcher.swift Tests/AppContextServiceTests.swift Tests/DictionaryStoreTests.swift Tests/TranscriptTidierTests.swift Tests/TransformStoreTests.swift Tests/WakePhraseMatcherTests.swift Sources/ScratchCommandMatcher.swift Tests/ScratchCommandMatcherTests.swift Sources/RawRevertEligibility.swift Tests/RawRevertEligibilityTests.swift Sources/ShortcutCore/ShortcutModels.swift Tests/ShortcutCancelBindingTests.swift
$(TEST_RUNNER): Sources/AppContextService.swift Sources/AppleFoundationModelsPostProcessor.swift Sources/DictionaryStore.swift Sources/TranscriptTidier.swift Sources/TransformStore.swift Sources/WakePhraseMatcher.swift Tests/AppContextServiceTests.swift Tests/DictionaryStoreTests.swift Tests/TranscriptTidierTests.swift Tests/TransformStoreTests.swift Tests/WakePhraseMatcherTests.swift Sources/ScratchCommandMatcher.swift Tests/ScratchCommandMatcherTests.swift Sources/RawRevertEligibility.swift Tests/RawRevertEligibilityTests.swift Sources/ShortcutCore/ShortcutModels.swift Tests/ShortcutCancelBindingTests.swift Sources/ShortcutCore/MouseDictationButton.swift Tests/MouseDictationButtonTests.swift
@mkdir -p "$(BUILD_DIR)"
swiftc \
-parse-as-library \
-o "$(TEST_RUNNER)" \
-sdk $(shell xcrun --show-sdk-path) \
-target $(ARCH)-apple-macosx26.0 \
Sources/AppContextService.swift Sources/AppleFoundationModelsPostProcessor.swift Sources/DictionaryStore.swift Sources/TranscriptTidier.swift Sources/TransformStore.swift Sources/WakePhraseMatcher.swift Tests/AppContextServiceTests.swift Tests/DictionaryStoreTests.swift Tests/TranscriptTidierTests.swift Tests/TransformStoreTests.swift Tests/WakePhraseMatcherTests.swift Sources/ScratchCommandMatcher.swift Tests/ScratchCommandMatcherTests.swift Sources/RawRevertEligibility.swift Tests/RawRevertEligibilityTests.swift Sources/ShortcutCore/ShortcutModels.swift Tests/ShortcutCancelBindingTests.swift
Sources/AppContextService.swift Sources/AppleFoundationModelsPostProcessor.swift Sources/DictionaryStore.swift Sources/TranscriptTidier.swift Sources/TransformStore.swift Sources/WakePhraseMatcher.swift Tests/AppContextServiceTests.swift Tests/DictionaryStoreTests.swift Tests/TranscriptTidierTests.swift Tests/TransformStoreTests.swift Tests/WakePhraseMatcherTests.swift Sources/ScratchCommandMatcher.swift Tests/ScratchCommandMatcherTests.swift Sources/RawRevertEligibility.swift Tests/RawRevertEligibilityTests.swift Sources/ShortcutCore/ShortcutModels.swift Tests/ShortcutCancelBindingTests.swift Sources/ShortcutCore/MouseDictationButton.swift Tests/MouseDictationButtonTests.swift

icon: $(ICON_ICNS)

Expand Down
74 changes: 73 additions & 1 deletion Sources/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,8 @@ final class AppState: ObservableObject, @unchecked Sendable {
private let outputLanguageStorageKey = "output_language"
private let writingFormalityByContextStorageKey = "writing_formality_by_context"
private let dictationAudioInterruptionEnabledStorageKey = "dictation_audio_interruption_enabled"
private let mouseDictationEnabledStorageKey = "mouse_dictation_enabled"
private let mouseDictationButtonStorageKey = "mouse_dictation_button"
private let pasteAfterShortcutReleaseDelay: TimeInterval = 0.03
private let pressEnterAfterPasteDelay: TimeInterval = 0.08
private let clipboardRestoreDelay: TimeInterval = 1.0
Expand Down Expand Up @@ -317,6 +319,20 @@ final class AppState: ObservableObject, @unchecked Sendable {
}
}

@Published var isMouseDictationEnabled: Bool {
didSet {
UserDefaults.standard.set(isMouseDictationEnabled, forKey: mouseDictationEnabledStorageKey)
restartHotkeyMonitoring()
}
}

@Published var mouseDictationButton: Int {
didSet {
UserDefaults.standard.set(mouseDictationButton, forKey: mouseDictationButtonStorageKey)
restartHotkeyMonitoring()
}
}

@Published private(set) var savedCancelCustomShortcut: ShortcutBinding? {
didSet {
persistOptionalShortcut(savedCancelCustomShortcut, key: savedCancelCustomShortcutStorageKey)
Expand Down Expand Up @@ -650,6 +666,10 @@ final class AppState: ObservableObject, @unchecked Sendable {
private var shouldMonitorHotkeys = false
private var isCapturingShortcut = false
private var isAwaitingMicrophonePermission = false
/// True from the configured mouse button's down until its up (or until
/// something else ends the session first). Gates which otherMouse events
/// the tap consumes and whether the up should stop dictation.
private var isMouseHoldSessionActive = false
private var pendingMicrophonePermissionTriggerMode: RecordingTriggerMode?
private var pendingMicrophonePermissionSelectionSnapshot: AppSelectionSnapshot?
private var pendingMicrophonePermissionManualCommandRequested: Bool?
Expand Down Expand Up @@ -713,6 +733,11 @@ final class AppState: ObservableObject, @unchecked Sendable {
let commandModeManualModifier = CommandModeManualModifier(
rawValue: UserDefaults.standard.string(forKey: commandModeManualModifierStorageKey) ?? ""
) ?? .option
let isMouseDictationEnabled = UserDefaults.standard.bool(forKey: mouseDictationEnabledStorageKey)
let mouseDictationButton = MouseDictationButton.normalized(
UserDefaults.standard.object(forKey: mouseDictationButtonStorageKey) as? Int
?? MouseDictationButton.defaultButtonNumber
)
let preserveClipboard = UserDefaults.standard.object(forKey: preserveClipboardStorageKey) == nil
? true
: UserDefaults.standard.bool(forKey: preserveClipboardStorageKey)
Expand Down Expand Up @@ -790,6 +815,8 @@ final class AppState: ObservableObject, @unchecked Sendable {
self.isCommandModeEnabled = isCommandModeEnabled
self.commandModeStyle = commandModeStyle
self.commandModeManualModifier = commandModeManualModifier
self.isMouseDictationEnabled = isMouseDictationEnabled
self.mouseDictationButton = mouseDictationButton
self.customVocabulary = customVocabulary
self.wordCorrections = wordCorrections
self.smartCleanupMode = smartCleanupMode
Expand Down Expand Up @@ -1634,6 +1661,9 @@ final class AppState: ObservableObject, @unchecked Sendable {
hotkeyManager.onCancelKeyPressed = { [weak self] in
self?.handleCancelKeyPress() ?? false
}
hotkeyManager.onMouseButtonEvent = { [weak self] isDown in
self?.handleMouseDictationButtonEvent(isDown: isDown) ?? false
}
restartHotkeyMonitoring()
}

Expand All @@ -1642,6 +1672,7 @@ final class AppState: ObservableObject, @unchecked Sendable {
hotkeyMonitoringErrorMessage = nil
hotkeyManager.onShortcutEvent = nil
hotkeyManager.onCancelKeyPressed = nil
hotkeyManager.onMouseButtonEvent = nil
hotkeyManager.stop()
}

Expand Down Expand Up @@ -1679,7 +1710,10 @@ final class AppState: ObservableObject, @unchecked Sendable {
}

do {
try hotkeyManager.start(configuration: activeShortcutConfiguration)
try hotkeyManager.start(
configuration: activeShortcutConfiguration,
mouseButtonNumber: isMouseDictationEnabled ? mouseDictationButton : nil
)
hotkeyMonitoringErrorMessage = nil
} catch {
hotkeyMonitoringErrorMessage = error.localizedDescription
Expand Down Expand Up @@ -1719,6 +1753,41 @@ final class AppState: ObservableObject, @unchecked Sendable {
}
}

/// Mouse push-to-talk. Down starts a hold session through the exact same
/// funnel the keyboard uses (handleShortcutEvent → session controller);
/// up ends it. Runs synchronously inside the event-tap callback, so the
/// consume decision is made from cheap state checks and the actual
/// session work is deferred to the next main-queue turn — mirroring how
/// keyboard shortcut events are dispatched.
/// Returns true to swallow the click so it never reaches the target app.
private func handleMouseDictationButtonEvent(isDown: Bool) -> Bool {
if isDown {
// A keyboard-triggered session (or in-flight transcription) wins:
// let the click through untouched.
guard shortcutSessionController.activeMode == nil, !isTranscribing else {
return false
}
isMouseHoldSessionActive = true
DispatchQueue.main.async { [weak self] in
guard let self, self.isMouseHoldSessionActive else { return }
self.handleShortcutEvent(.holdActivated)
if self.shortcutSessionController.activeMode != .hold {
self.isMouseHoldSessionActive = false
}
}
return true
}

guard isMouseHoldSessionActive else { return false }
DispatchQueue.main.async { [weak self] in
guard let self, self.isMouseHoldSessionActive else { return }
self.isMouseHoldSessionActive = false
self.handleShortcutEvent(.holdDeactivated)
}
return true
}


/// Consumes the configured cancel key only while a session it can cancel
/// is active (transcribing, or a pending/active toggle dictation). In
/// every other situation this returns false and the key event passes
Expand Down Expand Up @@ -1858,6 +1927,7 @@ final class AppState: ObservableObject, @unchecked Sendable {

cancelPendingShortcutStart()
shortcutSessionController.reset()
isMouseHoldSessionActive = false
activeRecordingTriggerMode = nil
audioRecorder.onRecordingReady = nil
audioRecorder.onRecordingFailure = nil
Expand Down Expand Up @@ -2167,6 +2237,7 @@ final class AppState: ObservableObject, @unchecked Sendable {
pendingMicrophonePermissionManualCommandRequested = manualCommandRequested
hotkeyManager.stop()
shortcutSessionController.reset()
isMouseHoldSessionActive = false
activeRecordingTriggerMode = nil
cancelRecordingInitializationTimer()
audioRecorder.onRecordingReady = nil
Expand Down Expand Up @@ -2820,6 +2891,7 @@ final class AppState: ObservableObject, @unchecked Sendable {
cancelPendingShortcutStart()
cancelRecordingInitializationTimer()
shortcutSessionController.reset()
isMouseHoldSessionActive = false
activeRecordingTriggerMode = nil
let sessionIntent = currentSessionIntent
let cleanupSessionID = smartCleanupSessionID
Expand Down
30 changes: 24 additions & 6 deletions Sources/GlobalShortcutBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ final class GlobalShortcutBackend {
private var eventTap: CFMachPort?
private var eventTapRunLoopSource: CFRunLoopSource?
private var fnKeyIsDown = false
private var mouseButtonNumber: Int64?

var onInputEvent: ((ShortcutInputEvent) -> ShortcutConsumeDecision)?
var onCancelKeyPressed: (() -> Bool)?
Expand All @@ -29,15 +30,20 @@ final class GlobalShortcutBackend {
/// that a session was actually cancelled, so the key types normally the
/// rest of the time.
var cancelBinding: ShortcutBinding = .defaultCancel
/// Fired for down/up of the configured non-primary mouse button.
/// Return true to consume the click so it never reaches the target app.
var onMouseButtonEvent: ((_ isDown: Bool) -> Bool)?

func start() throws {
func start(mouseButtonNumber: Int? = nil) throws {
stop()
self.mouseButtonNumber = mouseButtonNumber.map(Int64.init)
try installEventTap()
fnKeyIsDown = ModifierKeyEventState.currentFunctionKeyIsDown()
}

func stop() {
tearDownEventTap()
mouseButtonNumber = nil
notifyBackendReset()
}

Expand All @@ -46,11 +52,15 @@ final class GlobalShortcutBackend {
}

private func installEventTap() throws {
let eventMask = [
CGEventType.flagsChanged,
CGEventType.keyDown,
CGEventType.keyUp
].reduce(CGEventMask(0)) { partialResult, eventType in
var eventTypes: [CGEventType] = [
.flagsChanged,
.keyDown,
.keyUp
]
if mouseButtonNumber != nil {
eventTypes.append(contentsOf: [.otherMouseDown, .otherMouseUp])
}
let eventMask = eventTypes.reduce(CGEventMask(0)) { partialResult, eventType in
partialResult | (CGEventMask(1) << eventType.rawValue)
}

Expand Down Expand Up @@ -133,6 +143,14 @@ final class GlobalShortcutBackend {

return shouldConsume ? nil : Unmanaged.passUnretained(event)

case .otherMouseDown, .otherMouseUp:
guard let mouseButtonNumber,
event.getIntegerValueField(.mouseEventButtonNumber) == mouseButtonNumber else {
return Unmanaged.passUnretained(event)
}
let shouldConsume = onMouseButtonEvent?(type == .otherMouseDown) ?? false
return shouldConsume ? nil : Unmanaged.passUnretained(event)

default:
return Unmanaged.passUnretained(event)
}
Expand Down
12 changes: 10 additions & 2 deletions Sources/HotkeyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ final class HotkeyManager {

var onShortcutEvent: ((ShortcutEvent) -> Void)?
var onCancelKeyPressed: (() -> Bool)?
/// Down/up of the configured mouse dictation button. Return true to
/// consume the click. Only fired when start(...) received a button.
var onMouseButtonEvent: ((_ isDown: Bool) -> Bool)?

var currentPressedModifiers: ShortcutModifiers {
inputState.currentModifiers
Expand All @@ -19,7 +22,7 @@ final class HotkeyManager {
inputState.hasPressedShortcutInputs(configuration: configuration)
}

func start(configuration: ShortcutConfiguration) throws {
func start(configuration: ShortcutConfiguration, mouseButtonNumber: Int? = nil) throws {
stop()
self.configuration = configuration
backend.onInputEvent = { [weak self] event in
Expand All @@ -28,12 +31,16 @@ final class HotkeyManager {
backend.onCancelKeyPressed = { [weak self] in
self?.onCancelKeyPressed?() ?? false
}
backend.onMouseButtonEvent = { [weak self] isDown in
self?.onMouseButtonEvent?(isDown) ?? false
}
backend.cancelBinding = ShortcutBinding.sanitizedCancelBinding(configuration.cancel)
do {
try backend.start()
try backend.start(mouseButtonNumber: mouseButtonNumber)
} catch {
backend.onInputEvent = nil
backend.onCancelKeyPressed = nil
backend.onMouseButtonEvent = nil
inputState = ShortcutInputState()
throw error
}
Expand All @@ -43,6 +50,7 @@ final class HotkeyManager {
backend.stop()
backend.onInputEvent = nil
backend.onCancelKeyPressed = nil
backend.onMouseButtonEvent = nil
inputState = ShortcutInputState()
}

Expand Down
19 changes: 19 additions & 0 deletions Sources/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,9 @@ struct GeneralSettingsView: View {
SettingsCard("Dictation Shortcuts", icon: "keyboard.fill") {
hotkeySection
}
SettingsCard("Mouse Dictation", icon: "computermouse.fill") {
mouseDictationSection
}
SettingsCard("Ask Megaphone", icon: "sparkles") {
wakeCommandSection
}
Expand Down Expand Up @@ -827,6 +830,22 @@ struct GeneralSettingsView: View {
}
}

// MARK: Mouse Dictation

private var mouseDictationSection: some View {
VStack(alignment: .leading, spacing: 10) {
Toggle("Enable mouse dictation", isOn: $appState.isMouseDictationEnabled)

MouseDictationButtonRow()
.disabled(!appState.isMouseDictationEnabled)
.opacity(appState.isMouseDictationEnabled ? 1 : 0.5)

Text("Hold the bound mouse button to dictate and release to stop — push-to-talk for the extra buttons on MMO mice and trackballs. While bound, that button's clicks are swallowed so they never press anything in the app under the cursor. Left and right buttons can't be used.")
.font(.caption)
.foregroundStyle(.secondary)
}
}

// MARK: Ask Megaphone

private var wakeCommandSection: some View {
Expand Down
Loading