From be5906409329ba13a8645b6af8b984edafbb935c Mon Sep 17 00:00:00 2001 From: Alexey Haidamaka Date: Fri, 29 Aug 2025 06:05:36 +0200 Subject: [PATCH] Mouse Middle Click To Toggle Recording --- VoiceInk/HotkeyManager.swift | 63 ++++++++++++++++++++++ VoiceInk/Views/Settings/SettingsView.swift | 42 +++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/VoiceInk/HotkeyManager.swift b/VoiceInk/HotkeyManager.swift index 4bf891c..407986e 100644 --- a/VoiceInk/HotkeyManager.swift +++ b/VoiceInk/HotkeyManager.swift @@ -26,6 +26,17 @@ class HotkeyManager: ObservableObject { setupHotkeyMonitoring() } } + @Published var isMiddleClickToggleEnabled: Bool { + didSet { + UserDefaults.standard.set(isMiddleClickToggleEnabled, forKey: "isMiddleClickToggleEnabled") + setupHotkeyMonitoring() + } + } + @Published var middleClickActivationDelay: Int { + didSet { + UserDefaults.standard.set(middleClickActivationDelay, forKey: "middleClickActivationDelay") + } + } private var whisperState: WhisperState private var miniRecorderShortcutManager: MiniRecorderShortcutManager @@ -39,6 +50,10 @@ class HotkeyManager: ObservableObject { private var globalEventMonitor: Any? private var localEventMonitor: Any? + // Middle-click event monitoring + private var middleClickMonitors: [Any?] = [] + private var middleClickTask: Task? + // Key state tracking private var currentKeyState = false private var keyPressStartTime: Date? @@ -118,6 +133,11 @@ class HotkeyManager: ObservableObject { // ---- normal initialisation ---- self.selectedHotkey1 = HotkeyOption(rawValue: UserDefaults.standard.string(forKey: "selectedHotkey1") ?? "") ?? .rightCommand self.selectedHotkey2 = HotkeyOption(rawValue: UserDefaults.standard.string(forKey: "selectedHotkey2") ?? "") ?? .none + + self.isMiddleClickToggleEnabled = UserDefaults.standard.bool(forKey: "isMiddleClickToggleEnabled") + let storedDelay = UserDefaults.standard.integer(forKey: "middleClickActivationDelay") + self.middleClickActivationDelay = storedDelay > 0 ? storedDelay : 200 + self.whisperState = whisperState self.miniRecorderShortcutManager = MiniRecorderShortcutManager(whisperState: whisperState) @@ -144,6 +164,7 @@ class HotkeyManager: ObservableObject { setupModifierKeyMonitoring() setupCustomShortcutMonitoring() + setupMiddleClickMonitoring() } private func setupModifierKeyMonitoring() { @@ -166,6 +187,40 @@ class HotkeyManager: ObservableObject { } } + private func setupMiddleClickMonitoring() { + guard isMiddleClickToggleEnabled else { return } + + // Mouse Down + let downMonitor = NSEvent.addGlobalMonitorForEvents(matching: .otherMouseDown) { [weak self] event in + guard let self = self, event.buttonNumber == 2 else { return } + + self.middleClickTask?.cancel() + self.middleClickTask = Task { + do { + let delay = UInt64(self.middleClickActivationDelay) * 1_000_000 // ms to ns + try await Task.sleep(nanoseconds: delay) + + guard self.isMiddleClickToggleEnabled, !Task.isCancelled else { return } + + Task { @MainActor in + guard self.canProcessHotkeyAction else { return } + await self.whisperState.handleToggleMiniRecorder() + } + } catch { + // Cancelled + } + } + } + + // Mouse Up + let upMonitor = NSEvent.addGlobalMonitorForEvents(matching: .otherMouseUp) { [weak self] event in + guard let self = self, event.buttonNumber == 2 else { return } + self.middleClickTask?.cancel() + } + + middleClickMonitors = [downMonitor, upMonitor] + } + private func setupCustomShortcutMonitoring() { // Hotkey 1 if selectedHotkey1 == .custom { @@ -198,6 +253,14 @@ class HotkeyManager: ObservableObject { localEventMonitor = nil } + for monitor in middleClickMonitors { + if let monitor = monitor { + NSEvent.removeMonitor(monitor) + } + } + middleClickMonitors = [] + middleClickTask?.cancel() + resetKeyStates() } diff --git a/VoiceInk/Views/Settings/SettingsView.swift b/VoiceInk/Views/Settings/SettingsView.swift index a9aa573..068e999 100644 --- a/VoiceInk/Views/Settings/SettingsView.swift +++ b/VoiceInk/Views/Settings/SettingsView.swift @@ -118,6 +118,48 @@ struct SettingsView: View { } } + SettingsSection( + icon: "computermouse.fill", + title: "Middle-Click Toggle", + subtitle: "Optionally use your middle mouse button to toggle recording" + ) { + VStack(alignment: .leading, spacing: 12) { + Toggle("Enable Middle-Click Toggle", isOn: $hotkeyManager.isMiddleClickToggleEnabled.animation()) + .toggleStyle(.switch) + + if hotkeyManager.isMiddleClickToggleEnabled { + HStack { + Text("Activation Delay") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.secondary) + + TextField("", value: $hotkeyManager.middleClickActivationDelay, formatter: { + let formatter = NumberFormatter() + formatter.numberStyle = .none + formatter.minimum = 0 + return formatter + }()) + .textFieldStyle(PlainTextFieldStyle()) + .padding(EdgeInsets(top: 3, leading: 6, bottom: 3, trailing: 6)) + .background(Color(NSColor.textBackgroundColor)) + .cornerRadius(5) + .frame(width: 70) + + Text("ms") + .foregroundColor(.secondary) + + Spacer() + } + .transition(.opacity.combined(with: .move(edge: .top))) + + Text("A short delay to prevent accidental toggles when closing browser tabs.") + .font(.system(size: 12)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + SettingsSection( icon: "speaker.wave.2.bubble.left.fill", title: "Recording Feedback",