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
This commit is contained in:
parent
7141a3daf4
commit
00942c4e6c
@ -341,6 +341,11 @@ class HotkeyManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func refreshCancelRecordingShortcut() {
|
||||
// Called when cancel recording shortcut settings change
|
||||
miniRecorderShortcutManager.refreshCancelShortcut()
|
||||
}
|
||||
|
||||
deinit {
|
||||
Task { @MainActor in
|
||||
removeAllMonitoring()
|
||||
|
||||
@ -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<Void, Never>?
|
||||
|
||||
// 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<Void, Never>?
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user