From 00942c4e6c5789ba7f690ba6468fd1203e805032 Mon Sep 17 00:00:00 2001 From: Ugo Lafosse Date: Fri, 11 Jul 2025 02:43:22 +0200 Subject: [PATCH 1/3] Add customizable cancel recording shortcut feature - Add cancelRecorder KeyboardShortcut name extension - Implement mutually exclusive behavior: double-tap Escape OR custom shortcut - Add toggle UI in Settings with progressive disclosure - Fix double-tap escape bug by setting handlers once in init() - Handlers check internally for mutually exclusive behavior - Only manage shortcut bindings, never remove handlers --- VoiceInk/HotkeyManager.swift | 5 ++ VoiceInk/MiniRecorderShortcutManager.swift | 94 +++++++++++++++++++--- VoiceInk/Views/Settings/SettingsView.swift | 47 ++++++++++- 3 files changed, 135 insertions(+), 11 deletions(-) diff --git a/VoiceInk/HotkeyManager.swift b/VoiceInk/HotkeyManager.swift index 66d2975..064b60d 100644 --- a/VoiceInk/HotkeyManager.swift +++ b/VoiceInk/HotkeyManager.swift @@ -341,6 +341,11 @@ class HotkeyManager: ObservableObject { } } + func refreshCancelRecordingShortcut() { + // Called when cancel recording shortcut settings change + miniRecorderShortcutManager.refreshCancelShortcut() + } + deinit { Task { @MainActor in removeAllMonitoring() diff --git a/VoiceInk/MiniRecorderShortcutManager.swift b/VoiceInk/MiniRecorderShortcutManager.swift index b385c18..3c777d9 100644 --- a/VoiceInk/MiniRecorderShortcutManager.swift +++ b/VoiceInk/MiniRecorderShortcutManager.swift @@ -4,6 +4,7 @@ import AppKit extension KeyboardShortcuts.Name { static let escapeRecorder = Self("escapeRecorder") + static let cancelRecorder = Self("cancelRecorder") static let toggleEnhancement = Self("toggleEnhancement") // Power Mode selection shortcuts static let selectPowerMode1 = Self("selectPowerMode1") @@ -22,26 +23,34 @@ class MiniRecorderShortcutManager: ObservableObject { private var whisperState: WhisperState private var visibilityTask: Task? - // Add double-press Escape handling properties + private var isCancelHandlerSetup = false + + // Double-tap Escape handling (default behavior) private var escFirstPressTime: Date? = nil private let escSecondPressThreshold: TimeInterval = 1.5 // seconds private var isEscapeHandlerSetup = false + private var escapeTimeoutTask: Task? init(whisperState: WhisperState) { + print("🔍 DEBUG: MiniRecorderShortcutManager INIT called") self.whisperState = whisperState setupVisibilityObserver() setupEnhancementShortcut() + setupEscapeHandlerOnce() // Set up handler once and never remove it + setupCancelHandlerOnce() // Set up handler once and never remove it } private func setupVisibilityObserver() { visibilityTask = Task { @MainActor in for await isVisible in whisperState.$isMiniRecorderVisible.values { if isVisible { - setupEscapeShortcut() + activateEscapeShortcut() // Only manage shortcut binding, not handler + activateCancelShortcut() // Only manage shortcut binding, not handler KeyboardShortcuts.setShortcut(.init(.e, modifiers: .command), for: .toggleEnhancement) setupPowerModeShortcuts() } else { - removeEscapeShortcut() + deactivateEscapeShortcut() + deactivateCancelShortcut() removeEnhancementShortcut() removePowerModeShortcuts() } @@ -49,22 +58,30 @@ class MiniRecorderShortcutManager: ObservableObject { } } - private func setupEscapeShortcut() { - KeyboardShortcuts.setShortcut(.init(.escape), for: .escapeRecorder) + // Set up escape handler ONCE and never remove it + private func setupEscapeHandlerOnce() { guard !isEscapeHandlerSetup else { return } isEscapeHandlerSetup = true + print("🔍 DEBUG: setupEscapeHandlerOnce - Setting up PERMANENT escape handler") + KeyboardShortcuts.onKeyDown(for: .escapeRecorder) { [weak self] in Task { @MainActor in guard let self = self, await self.whisperState.isMiniRecorderVisible else { return } + // Don't process escape if custom shortcut is configured (mutually exclusive) + guard KeyboardShortcuts.getShortcut(for: .cancelRecorder) == nil else { return } + let now = Date() + print("🔍 DEBUG: Escape handler fired. escFirstPressTime: \(String(describing: self.escFirstPressTime))") if let firstTime = self.escFirstPressTime, now.timeIntervalSince(firstTime) <= self.escSecondPressThreshold { + print("🔍 DEBUG: SECOND PRESS detected - dismissing") self.escFirstPressTime = nil SoundManager.shared.playEscSound() await self.whisperState.dismissMiniRecorder() } else { + print("🔍 DEBUG: FIRST PRESS detected - setting timer") self.escFirstPressTime = now SoundManager.shared.playEscSound() NotificationManager.shared.showNotification( @@ -72,7 +89,7 @@ class MiniRecorderShortcutManager: ObservableObject { type: .info, duration: self.escSecondPressThreshold ) - Task { [weak self] in + self.escapeTimeoutTask = Task { [weak self] in try? await Task.sleep(nanoseconds: UInt64((self?.escSecondPressThreshold ?? 1.5) * 1_000_000_000)) await MainActor.run { self?.escFirstPressTime = nil @@ -83,9 +100,67 @@ class MiniRecorderShortcutManager: ObservableObject { } } - private func removeEscapeShortcut() { + // Only manage shortcut binding, never touch the handler + private func activateEscapeShortcut() { + // Don't activate escape if custom shortcut is configured (mutually exclusive) + guard KeyboardShortcuts.getShortcut(for: .cancelRecorder) == nil else { + print("🔍 DEBUG: activateEscapeShortcut SKIPPED - custom shortcut exists") + return + } + print("🔍 DEBUG: activateEscapeShortcut - Setting shortcut binding") + KeyboardShortcuts.setShortcut(.init(.escape), for: .escapeRecorder) + } + + // Set up cancel handler ONCE and never remove it + private func setupCancelHandlerOnce() { + guard !isCancelHandlerSetup else { return } + isCancelHandlerSetup = true + print("🔍 DEBUG: setupCancelHandlerOnce - Setting up PERMANENT cancel handler") + + KeyboardShortcuts.onKeyDown(for: .cancelRecorder) { [weak self] in + Task { @MainActor in + guard let self = self, + await self.whisperState.isMiniRecorderVisible else { return } + + // Only process if custom shortcut is actually configured + guard KeyboardShortcuts.getShortcut(for: .cancelRecorder) != nil else { return } + + print("🔍 DEBUG: Custom cancel handler fired") + SoundManager.shared.playEscSound() + await self.whisperState.dismissMiniRecorder() + } + } + } + + // Only manage whether shortcut should be active, never touch the handler + private func activateCancelShortcut() { + // Nothing to do - shortcut is set by user in settings + // Handler is already set up permanently and will check if shortcut exists + print("🔍 DEBUG: activateCancelShortcut - shortcut managed by settings") + } + + // Only remove shortcut binding, never touch the handler + private func deactivateEscapeShortcut() { + print("🔍 DEBUG: deactivateEscapeShortcut - Removing shortcut binding") KeyboardShortcuts.setShortcut(nil, for: .escapeRecorder) - escFirstPressTime = nil + escFirstPressTime = nil // Reset state for next session + escapeTimeoutTask?.cancel() + escapeTimeoutTask = nil + } + + // Only deactivate, never remove the handler + private func deactivateCancelShortcut() { + print("🔍 DEBUG: deactivateCancelShortcut - Nothing to do (shortcut managed by settings)") + // Don't remove the shortcut itself - that's managed by user settings + // Handler remains active but will check if shortcut exists + } + + // Public method to refresh cancel shortcut when settings change + func refreshCancelShortcut() { + print("🔍 DEBUG: refreshCancelShortcut called - handlers are permanent, no action needed") + // Handlers are set up once and never removed + // They check internally whether they should process based on shortcut existence + // This maintains mutually exclusive behavior without handler duplication } private func setupEnhancementShortcut() { @@ -174,7 +249,8 @@ class MiniRecorderShortcutManager: ObservableObject { deinit { visibilityTask?.cancel() Task { @MainActor in - removeEscapeShortcut() + deactivateEscapeShortcut() + deactivateCancelShortcut() removeEnhancementShortcut() removePowerModeShortcuts() } diff --git a/VoiceInk/Views/Settings/SettingsView.swift b/VoiceInk/Views/Settings/SettingsView.swift index ee4486c..97b6032 100644 --- a/VoiceInk/Views/Settings/SettingsView.swift +++ b/VoiceInk/Views/Settings/SettingsView.swift @@ -16,6 +16,7 @@ struct SettingsView: View { @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = true @State private var showResetOnboardingAlert = false @State private var currentShortcut = KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) + @State private var isCustomCancelEnabled = false var body: some View { ScrollView { @@ -60,12 +61,50 @@ struct SettingsView: View { .foregroundColor(.accentColor) } } - + Text("Quick tap to start hands-free recording (tap again to stop). Press and hold for push-to-talk (release to stop recording).") .font(.system(size: 12)) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) - .padding(.top, 8) + + Divider() + + // Cancel Recording Override Toggle + Toggle(isOn: $isCustomCancelEnabled) { + Text("Override default double-tap Escape cancellation") + } + .toggleStyle(.switch) + .onChange(of: isCustomCancelEnabled) { _, newValue in + if !newValue { + KeyboardShortcuts.setShortcut(nil, for: .cancelRecorder) + } + } + + // Show shortcut recorder only when override is enabled + if isCustomCancelEnabled { + HStack(spacing: 12) { + Text("Custom Cancel Shortcut") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.secondary) + + KeyboardShortcuts.Recorder(for: .cancelRecorder) + .controlSize(.small) + .onChange(of: KeyboardShortcuts.getShortcut(for: .cancelRecorder)) { _, _ in + // Refresh the shortcut handler when it changes + hotkeyManager.refreshCancelRecordingShortcut() + } + + Spacer() + } + .padding(.leading, 16) + .transition(.opacity.combined(with: .move(edge: .top))) + } + + Text("By default, double-tap Escape to cancel recordings. Enable override above for single-press custom cancellation (useful for Vim users).") + .font(.system(size: 12)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 8) } } @@ -260,6 +299,10 @@ struct SettingsView: View { .padding(.vertical, 6) } .background(Color(NSColor.controlBackgroundColor)) + .onAppear { + // Initialize custom cancel shortcut state from stored preferences + isCustomCancelEnabled = KeyboardShortcuts.getShortcut(for: .cancelRecorder) != nil + } .alert("Reset Onboarding", isPresented: $showResetOnboardingAlert) { Button("Cancel", role: .cancel) { } Button("Reset", role: .destructive) { From f2e032693d981171d5247e63f4bf3ac36cc78c21 Mon Sep 17 00:00:00 2001 From: Ugo Lafosse Date: Fri, 11 Jul 2025 02:51:40 +0200 Subject: [PATCH 2/3] Log cleanup --- VoiceInk/.claude/settings.local.json | 9 +++++++++ VoiceInk/MiniRecorderShortcutManager.swift | 13 ------------- 2 files changed, 9 insertions(+), 13 deletions(-) create mode 100644 VoiceInk/.claude/settings.local.json diff --git a/VoiceInk/.claude/settings.local.json b/VoiceInk/.claude/settings.local.json new file mode 100644 index 0000000..a3f0184 --- /dev/null +++ b/VoiceInk/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "mcp__tavily-mcp__tavily-extract", + "mcp__tavily-mcp__tavily-search" + ], + "deny": [] + } +} \ No newline at end of file diff --git a/VoiceInk/MiniRecorderShortcutManager.swift b/VoiceInk/MiniRecorderShortcutManager.swift index 3c777d9..5e5d173 100644 --- a/VoiceInk/MiniRecorderShortcutManager.swift +++ b/VoiceInk/MiniRecorderShortcutManager.swift @@ -32,7 +32,6 @@ class MiniRecorderShortcutManager: ObservableObject { private var escapeTimeoutTask: Task? init(whisperState: WhisperState) { - print("🔍 DEBUG: MiniRecorderShortcutManager INIT called") self.whisperState = whisperState setupVisibilityObserver() setupEnhancementShortcut() @@ -62,7 +61,6 @@ class MiniRecorderShortcutManager: ObservableObject { private func setupEscapeHandlerOnce() { guard !isEscapeHandlerSetup else { return } isEscapeHandlerSetup = true - print("🔍 DEBUG: setupEscapeHandlerOnce - Setting up PERMANENT escape handler") KeyboardShortcuts.onKeyDown(for: .escapeRecorder) { [weak self] in Task { @MainActor in @@ -73,15 +71,12 @@ class MiniRecorderShortcutManager: ObservableObject { guard KeyboardShortcuts.getShortcut(for: .cancelRecorder) == nil else { return } let now = Date() - print("🔍 DEBUG: Escape handler fired. escFirstPressTime: \(String(describing: self.escFirstPressTime))") if let firstTime = self.escFirstPressTime, now.timeIntervalSince(firstTime) <= self.escSecondPressThreshold { - print("🔍 DEBUG: SECOND PRESS detected - dismissing") self.escFirstPressTime = nil SoundManager.shared.playEscSound() await self.whisperState.dismissMiniRecorder() } else { - print("🔍 DEBUG: FIRST PRESS detected - setting timer") self.escFirstPressTime = now SoundManager.shared.playEscSound() NotificationManager.shared.showNotification( @@ -104,10 +99,8 @@ class MiniRecorderShortcutManager: ObservableObject { private func activateEscapeShortcut() { // Don't activate escape if custom shortcut is configured (mutually exclusive) guard KeyboardShortcuts.getShortcut(for: .cancelRecorder) == nil else { - print("🔍 DEBUG: activateEscapeShortcut SKIPPED - custom shortcut exists") return } - print("🔍 DEBUG: activateEscapeShortcut - Setting shortcut binding") KeyboardShortcuts.setShortcut(.init(.escape), for: .escapeRecorder) } @@ -115,7 +108,6 @@ class MiniRecorderShortcutManager: ObservableObject { private func setupCancelHandlerOnce() { guard !isCancelHandlerSetup else { return } isCancelHandlerSetup = true - print("🔍 DEBUG: setupCancelHandlerOnce - Setting up PERMANENT cancel handler") KeyboardShortcuts.onKeyDown(for: .cancelRecorder) { [weak self] in Task { @MainActor in @@ -125,7 +117,6 @@ class MiniRecorderShortcutManager: ObservableObject { // Only process if custom shortcut is actually configured guard KeyboardShortcuts.getShortcut(for: .cancelRecorder) != nil else { return } - print("🔍 DEBUG: Custom cancel handler fired") SoundManager.shared.playEscSound() await self.whisperState.dismissMiniRecorder() } @@ -136,12 +127,10 @@ class MiniRecorderShortcutManager: ObservableObject { private func activateCancelShortcut() { // Nothing to do - shortcut is set by user in settings // Handler is already set up permanently and will check if shortcut exists - print("🔍 DEBUG: activateCancelShortcut - shortcut managed by settings") } // Only remove shortcut binding, never touch the handler private func deactivateEscapeShortcut() { - print("🔍 DEBUG: deactivateEscapeShortcut - Removing shortcut binding") KeyboardShortcuts.setShortcut(nil, for: .escapeRecorder) escFirstPressTime = nil // Reset state for next session escapeTimeoutTask?.cancel() @@ -150,14 +139,12 @@ class MiniRecorderShortcutManager: ObservableObject { // Only deactivate, never remove the handler private func deactivateCancelShortcut() { - print("🔍 DEBUG: deactivateCancelShortcut - Nothing to do (shortcut managed by settings)") // Don't remove the shortcut itself - that's managed by user settings // Handler remains active but will check if shortcut exists } // Public method to refresh cancel shortcut when settings change func refreshCancelShortcut() { - print("🔍 DEBUG: refreshCancelShortcut called - handlers are permanent, no action needed") // Handlers are set up once and never removed // They check internally whether they should process based on shortcut existence // This maintains mutually exclusive behavior without handler duplication From 1e4575b0e3ed8a35ad378c2fe71327f1c527eea2 Mon Sep 17 00:00:00 2001 From: Ugo Lafosse Date: Fri, 11 Jul 2025 03:16:39 +0200 Subject: [PATCH 3/3] Remove .claude config file from tracking --- VoiceInk/.claude/settings.local.json | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 VoiceInk/.claude/settings.local.json diff --git a/VoiceInk/.claude/settings.local.json b/VoiceInk/.claude/settings.local.json deleted file mode 100644 index a3f0184..0000000 --- a/VoiceInk/.claude/settings.local.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "permissions": { - "allow": [ - "mcp__tavily-mcp__tavily-extract", - "mcp__tavily-mcp__tavily-search" - ], - "deny": [] - } -} \ No newline at end of file