diff --git a/Makefile b/Makefile index f1c15c4..2bbcca7 100644 --- a/Makefile +++ b/Makefile @@ -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) diff --git a/Sources/AppState.swift b/Sources/AppState.swift index a9097a8..1638da1 100644 --- a/Sources/AppState.swift +++ b/Sources/AppState.swift @@ -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 @@ -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) @@ -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? @@ -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) @@ -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 @@ -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() } @@ -1642,6 +1672,7 @@ final class AppState: ObservableObject, @unchecked Sendable { hotkeyMonitoringErrorMessage = nil hotkeyManager.onShortcutEvent = nil hotkeyManager.onCancelKeyPressed = nil + hotkeyManager.onMouseButtonEvent = nil hotkeyManager.stop() } @@ -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 @@ -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 @@ -1858,6 +1927,7 @@ final class AppState: ObservableObject, @unchecked Sendable { cancelPendingShortcutStart() shortcutSessionController.reset() + isMouseHoldSessionActive = false activeRecordingTriggerMode = nil audioRecorder.onRecordingReady = nil audioRecorder.onRecordingFailure = nil @@ -2167,6 +2237,7 @@ final class AppState: ObservableObject, @unchecked Sendable { pendingMicrophonePermissionManualCommandRequested = manualCommandRequested hotkeyManager.stop() shortcutSessionController.reset() + isMouseHoldSessionActive = false activeRecordingTriggerMode = nil cancelRecordingInitializationTimer() audioRecorder.onRecordingReady = nil @@ -2820,6 +2891,7 @@ final class AppState: ObservableObject, @unchecked Sendable { cancelPendingShortcutStart() cancelRecordingInitializationTimer() shortcutSessionController.reset() + isMouseHoldSessionActive = false activeRecordingTriggerMode = nil let sessionIntent = currentSessionIntent let cleanupSessionID = smartCleanupSessionID diff --git a/Sources/GlobalShortcutBackend.swift b/Sources/GlobalShortcutBackend.swift index 87b35cf..26dc96b 100644 --- a/Sources/GlobalShortcutBackend.swift +++ b/Sources/GlobalShortcutBackend.swift @@ -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)? @@ -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() } @@ -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) } @@ -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) } diff --git a/Sources/HotkeyManager.swift b/Sources/HotkeyManager.swift index a943a8c..13665cd 100644 --- a/Sources/HotkeyManager.swift +++ b/Sources/HotkeyManager.swift @@ -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 @@ -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 @@ -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 } @@ -43,6 +50,7 @@ final class HotkeyManager { backend.stop() backend.onInputEvent = nil backend.onCancelKeyPressed = nil + backend.onMouseButtonEvent = nil inputState = ShortcutInputState() } diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index 1838cbb..cfb8ebc 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -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 } @@ -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 { diff --git a/Sources/ShortcutComponents.swift b/Sources/ShortcutComponents.swift index 0583207..02c56bf 100644 --- a/Sources/ShortcutComponents.swift +++ b/Sources/ShortcutComponents.swift @@ -218,6 +218,95 @@ private struct ShortcutPresetRow: View { } } +/// Shows the bound mouse dictation button and lets the user rebind it by +/// pressing the desired button while capture is armed. Capture uses a local +/// NSEvent monitor (the press must land on a window of this app), and the +/// global tap is suspended meanwhile so the currently bound button cannot +/// start dictation mid-capture. +struct MouseDictationButtonRow: View { + @EnvironmentObject var appState: AppState + + @State private var isCapturing = false + @State private var captureMonitor: Any? + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center, spacing: 10) { + HStack(alignment: .center, spacing: 10) { + Image(systemName: "computermouse") + .foregroundStyle(isCapturing ? .blue : .secondary) + + VStack(alignment: .leading, spacing: 2) { + Text(MouseDictationButton.displayName(for: appState.mouseDictationButton)) + .font(.system(.body, design: .monospaced).weight(.semibold)) + .foregroundStyle(.primary) + Text(isCapturing ? "Waiting for a mouse button…" : "Hold this button to dictate.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + } + .padding(12) + .background(isCapturing ? Color.blue.opacity(0.1) : Color(nsColor: .controlBackgroundColor)) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(isCapturing ? Color.blue : Color.clear, lineWidth: 1.5) + ) + + Button(isCapturing ? "Cancel" : "Change…") { + if isCapturing { + cancelCapture() + } else { + startCapture() + } + } + .buttonStyle(.bordered) + } + + if isCapturing { + Label( + "With the pointer over this window, press the mouse button you want. Middle and side buttons only — left and right clicks keep working normally.", + systemImage: "computermouse" + ) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.blue) + } + } + .onChange(of: appState.isMouseDictationEnabled) { _, enabled in + if !enabled { + cancelCapture() + } + } + .onDisappear { + cancelCapture() + } + } + + private func startCapture() { + cancelCapture() + isCapturing = true + appState.suspendHotkeyMonitoringForShortcutCapture() + captureMonitor = NSEvent.addLocalMonitorForEvents(matching: .otherMouseDown) { event in + guard MouseDictationButton.isBindable(event.buttonNumber) else { return event } + appState.mouseDictationButton = event.buttonNumber + cancelCapture() + return nil + } + } + + private func cancelCapture() { + if let monitor = captureMonitor { + NSEvent.removeMonitor(monitor) + captureMonitor = nil + } + guard isCapturing else { return } + isCapturing = false + appState.resumeHotkeyMonitoringAfterShortcutCapture() + } +} + private struct ShortcutCaptureRow: View { let savedBinding: ShortcutBinding? let isSelected: Bool diff --git a/Sources/ShortcutCore/MouseDictationButton.swift b/Sources/ShortcutCore/MouseDictationButton.swift new file mode 100644 index 0000000..2b9713b --- /dev/null +++ b/Sources/ShortcutCore/MouseDictationButton.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Rules and labels for binding a mouse button as a hold-to-talk dictation +/// trigger. Button numbers use NSEvent/CGEvent numbering: 0 = left, +/// 1 = right, 2 = middle, 3+ = extra buttons (side/thumb buttons on +/// MMO mice and trackballs). +enum MouseDictationButton { + /// NSEvent button number for the middle button — the lowest bindable one. + static let middleButtonNumber = 2 + + /// Default binding: the 4th button (first thumb/side button on most mice). + static let defaultButtonNumber = 3 + + /// Left (0) and right (1) clicks must never trigger dictation; the + /// middle button (2) and anything above it is fair game. + static func isBindable(_ buttonNumber: Int) -> Bool { + buttonNumber >= middleButtonNumber + } + + /// Human-readable label using 1-based mouse-button naming + /// (NSEvent button 3 is "Button 4"). + static func displayName(for buttonNumber: Int) -> String { + guard isBindable(buttonNumber) else { return "Unsupported button" } + if buttonNumber == middleButtonNumber { return "Middle Button" } + return "Button \(buttonNumber + 1)" + } + + /// Clamps stored/legacy values to a bindable button so a corrupted or + /// hand-edited preference can never bind the left or right button. + static func normalized(_ buttonNumber: Int) -> Int { + isBindable(buttonNumber) ? buttonNumber : defaultButtonNumber + } +} diff --git a/Tests/AppContextServiceTests.swift b/Tests/AppContextServiceTests.swift index 5b8b03a..70fa9af 100644 --- a/Tests/AppContextServiceTests.swift +++ b/Tests/AppContextServiceTests.swift @@ -27,6 +27,7 @@ struct AppContextServiceTests { ScratchCommandMatcherTests.run() RawRevertEligibilityTests.run() ShortcutCancelBindingTests.run() + MouseDictationButtonTests.run() print("MegaphoneTests passed") } diff --git a/Tests/MouseDictationButtonTests.swift b/Tests/MouseDictationButtonTests.swift new file mode 100644 index 0000000..4398722 --- /dev/null +++ b/Tests/MouseDictationButtonTests.swift @@ -0,0 +1,71 @@ +import Foundation + +enum MouseDictationButtonTests { + static func run() { + testPrimaryButtonsAreNeverBindable() + testMiddleAndSideButtonsAreBindable() + testDisplayNamesUseOneBasedNumbering() + testNormalizationRepairsInvalidStoredValues() + testDefaultIsTheFourthButton() + } + + private static func testPrimaryButtonsAreNeverBindable() { + expect(!MouseDictationButton.isBindable(0), "Left click must never be bindable") + expect(!MouseDictationButton.isBindable(1), "Right click must never be bindable") + expect(!MouseDictationButton.isBindable(-1), "Negative button numbers must be rejected") + } + + private static func testMiddleAndSideButtonsAreBindable() { + for buttonNumber in [2, 3, 4, 5, 15] { + expect( + MouseDictationButton.isBindable(buttonNumber), + "Button number \(buttonNumber) should be bindable" + ) + } + } + + private static func testDisplayNamesUseOneBasedNumbering() { + expectEqual(MouseDictationButton.displayName(for: 2), "Middle Button") + expectEqual(MouseDictationButton.displayName(for: 3), "Button 4") + expectEqual(MouseDictationButton.displayName(for: 4), "Button 5") + expectEqual(MouseDictationButton.displayName(for: 9), "Button 10") + expectEqual(MouseDictationButton.displayName(for: 0), "Unsupported button") + expectEqual(MouseDictationButton.displayName(for: 1), "Unsupported button") + } + + private static func testNormalizationRepairsInvalidStoredValues() { + expectEqual(MouseDictationButton.normalized(0), MouseDictationButton.defaultButtonNumber) + expectEqual(MouseDictationButton.normalized(1), MouseDictationButton.defaultButtonNumber) + expectEqual(MouseDictationButton.normalized(-7), MouseDictationButton.defaultButtonNumber) + expectEqual(MouseDictationButton.normalized(2), 2) + expectEqual(MouseDictationButton.normalized(6), 6) + } + + private static func testDefaultIsTheFourthButton() { + expectEqual(MouseDictationButton.defaultButtonNumber, 3) + expect( + MouseDictationButton.isBindable(MouseDictationButton.defaultButtonNumber), + "The default button must itself be bindable" + ) + } + + private static func expectEqual( + _ actual: T, + _ expected: T, + file: StaticString = #file, + line: UInt = #line + ) { + expect(actual == expected, "Expected \(expected), got \(actual)", file: file, line: line) + } + + private static func expect( + _ condition: Bool, + _ message: String, + file: StaticString = #file, + line: UInt = #line + ) { + if !condition { + fatalError("\(file):\(line): \(message)") + } + } +}