From 381b8a8bfb8e4322277621f344bf783e8aefe2ad Mon Sep 17 00:00:00 2001 From: Jackson <54308792+jacksongoode@users.noreply.github.com> Date: Fri, 28 Nov 2025 11:00:47 +0900 Subject: [PATCH] Add a cancel hotkey and configuration (#224) * From scratch * Cleanup * Small fixes * simplify --------- Co-authored-by: CJ Pais --- src-tauri/src/actions.rs | 33 +++++++- src-tauri/src/managers/audio.rs | 6 ++ src-tauri/src/settings.rs | 29 ++++++- src-tauri/src/shortcut.rs | 78 ++++++++++++++++--- src-tauri/src/utils.rs | 32 ++------ src/components/settings/HandyShortcut.tsx | 78 ++++++++++--------- .../settings/debug/DebugSettings.tsx | 10 +++ .../settings/general/GeneralSettings.tsx | 2 +- 8 files changed, 191 insertions(+), 77 deletions(-) diff --git a/src-tauri/src/actions.rs b/src-tauri/src/actions.rs index 26b9633..f8d490f 100644 --- a/src-tauri/src/actions.rs +++ b/src-tauri/src/actions.rs @@ -2,10 +2,10 @@ use crate::audio_feedback::{play_feedback_sound, play_feedback_sound_blocking, S use crate::managers::audio::AudioRecordingManager; use crate::managers::history::HistoryManager; use crate::managers::transcription::TranscriptionManager; -use crate::overlay::{show_recording_overlay, show_transcribing_overlay}; use crate::settings::{get_settings, AppSettings}; +use crate::shortcut; use crate::tray::{change_tray_icon, TrayIconState}; -use crate::utils; +use crate::utils::{self, show_recording_overlay, show_transcribing_overlay}; use async_openai::types::{ ChatCompletionRequestMessage, ChatCompletionRequestUserMessageArgs, CreateChatCompletionRequestArgs, @@ -225,6 +225,7 @@ impl ShortcutAction for TranscribeAction { let is_always_on = settings.always_on_microphone; debug!("Microphone mode - always_on: {}", is_always_on); + let mut recording_started = false; if is_always_on { // Always-on mode: Play audio feedback immediately, then apply mute after sound finishes debug!("Always-on mode: Playing audio feedback immediately"); @@ -237,7 +238,7 @@ impl ShortcutAction for TranscribeAction { rm_clone.apply_mute(); }); - let recording_started = rm.try_start_recording(&binding_id); + recording_started = rm.try_start_recording(&binding_id); debug!("Recording started: {}", recording_started); } else { // On-demand mode: Start recording first, then play audio feedback, then apply mute @@ -245,6 +246,7 @@ impl ShortcutAction for TranscribeAction { debug!("On-demand mode: Starting recording first, then audio feedback"); let recording_start_time = Instant::now(); if rm.try_start_recording(&binding_id) { + recording_started = true; debug!("Recording started in {:?}", recording_start_time.elapsed()); // Small delay to ensure microphone stream is active let app_clone = app.clone(); @@ -262,6 +264,11 @@ impl ShortcutAction for TranscribeAction { } } + if recording_started { + // Dynamically register the cancel shortcut in a separate task to avoid deadlock + shortcut::register_cancel_shortcut(app); + } + debug!( "TranscribeAction::start completed in {:?}", start_time.elapsed() @@ -269,6 +276,9 @@ impl ShortcutAction for TranscribeAction { } fn stop(&self, app: &AppHandle, binding_id: &str, _shortcut_str: &str) { + // Unregister the cancel shortcut when transcription stops + shortcut::unregister_cancel_shortcut(app); + let stop_time = Instant::now(); debug!("TranscribeAction::stop called for binding: {}", binding_id); @@ -406,6 +416,19 @@ impl ShortcutAction for TranscribeAction { } } +// Cancel Action +struct CancelAction; + +impl ShortcutAction for CancelAction { + fn start(&self, app: &AppHandle, _binding_id: &str, _shortcut_str: &str) { + utils::cancel_current_operation(app); + } + + fn stop(&self, _app: &AppHandle, _binding_id: &str, _shortcut_str: &str) { + // Nothing to do on stop for cancel + } +} + // Test Action struct TestAction; @@ -436,6 +459,10 @@ pub static ACTION_MAP: Lazy>> = Lazy::ne "transcribe".to_string(), Arc::new(TranscribeAction) as Arc, ); + map.insert( + "cancel".to_string(), + Arc::new(CancelAction) as Arc, + ); map.insert( "test".to_string(), Arc::new(TestAction) as Arc, diff --git a/src-tauri/src/managers/audio.rs b/src-tauri/src/managers/audio.rs index 8d86e61..0add01f 100644 --- a/src-tauri/src/managers/audio.rs +++ b/src-tauri/src/managers/audio.rs @@ -414,6 +414,12 @@ impl AudioRecordingManager { _ => None, } } + pub fn is_recording(&self) -> bool { + matches!( + *self.state.lock().unwrap(), + RecordingState::Recording { .. } + ) + } /// Cancel any ongoing recording without returning audio samples pub fn cancel_recording(&self) { diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index d5db704..0c3a0ba 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -432,6 +432,16 @@ pub fn get_default_settings() -> AppSettings { current_binding: default_shortcut.to_string(), }, ); + bindings.insert( + "cancel".to_string(), + ShortcutBinding { + id: "cancel".to_string(), + name: "Cancel".to_string(), + description: "Cancels the current recording.".to_string(), + default_binding: "escape".to_string(), + current_binding: "escape".to_string(), + }, + ); AppSettings { bindings, @@ -501,8 +511,25 @@ pub fn load_or_create_app_settings(app: &AppHandle) -> AppSettings { let settings = if let Some(settings_value) = store.get("settings") { // Parse the entire settings object match serde_json::from_value::(settings_value) { - Ok(settings) => { + Ok(mut settings) => { debug!("Found existing settings: {:?}", settings); + let default_settings = get_default_settings(); + let mut updated = false; + + // Merge default bindings into existing settings + for (key, value) in default_settings.bindings { + if !settings.bindings.contains_key(&key) { + debug!("Adding missing binding: {}", key); + settings.bindings.insert(key, value); + updated = true; + } + } + + if updated { + debug!("Settings updated with new bindings"); + store.set("settings", serde_json::to_value(&settings).unwrap()); + } + settings } Err(e) => { diff --git a/src-tauri/src/shortcut.rs b/src-tauri/src/shortcut.rs index e070e44..edfdb5c 100644 --- a/src-tauri/src/shortcut.rs +++ b/src-tauri/src/shortcut.rs @@ -1,11 +1,13 @@ use log::{error, warn}; use serde::Serialize; use specta::Type; +use std::sync::Arc; use tauri::{AppHandle, Emitter, Manager}; use tauri_plugin_autostart::ManagerExt; use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; use crate::actions::ACTION_MAP; +use crate::managers::audio::AudioRecordingManager; use crate::settings::ShortcutBinding; use crate::settings::{ self, get_settings, ClipboardHandling, LLMPrompt, OverlayPosition, PasteMethod, SoundTheme, @@ -13,12 +15,23 @@ use crate::settings::{ use crate::ManagedToggleState; pub fn init_shortcuts(app: &AppHandle) { - let settings = settings::load_or_create_app_settings(app); + let default_bindings = settings::get_default_settings().bindings; + let user_settings = settings::load_or_create_app_settings(app); - // Register shortcuts with the bindings from settings - for (_id, binding) in settings.bindings { - if let Err(e) = _register_shortcut(app, binding) { - error!("Failed to register shortcut {} during init: {}", _id, e); + + // Register all default shortcuts, applying user customizations + for (id, default_binding) in default_bindings { + if id == "cancel" { + continue; // Skip cancel shortcut, it will be registered dynamically + } + let binding = user_settings + .bindings + .get(&id) + .cloned() + .unwrap_or(default_binding); + + if let Err(e) = register_shortcut(app, binding) { + error!("Failed to register shortcut {} during init: {}", id, e); } } } @@ -52,9 +65,23 @@ pub fn change_binding( }); } }; + // If this is the cancel binding, just update the settings and return + // It's managed dynamically, so we don't register/unregister here + if id == "cancel" { + if let Some(mut b) = settings.bindings.get(&id).cloned() { + b.current_binding = binding; + settings.bindings.insert(id.clone(), b.clone()); + settings::write_settings(&app, settings); + return Ok(BindingResponse { + success: true, + binding: Some(b.clone()), + error: None, + }); + } + } // Unregister the existing binding - if let Err(e) = _unregister_shortcut(&app, binding_to_modify.clone()) { + if let Err(e) = unregister_shortcut(&app, binding_to_modify.clone()) { let error_msg = format!("Failed to unregister shortcut: {}", e); error!("change_binding error: {}", error_msg); } @@ -70,7 +97,7 @@ pub fn change_binding( updated_binding.current_binding = binding; // Register the new binding - if let Err(e) = _register_shortcut(&app, updated_binding.clone()) { + if let Err(e) = register_shortcut(&app, updated_binding.clone()) { let error_msg = format!("Failed to register shortcut: {}", e); error!("change_binding error: {}", error_msg); return Ok(BindingResponse { @@ -673,7 +700,7 @@ fn validate_shortcut_string(raw: &str) -> Result<(), String> { #[specta::specta] pub fn suspend_binding(app: AppHandle, id: String) -> Result<(), String> { if let Some(b) = settings::get_bindings(&app).get(&id).cloned() { - if let Err(e) = _unregister_shortcut(&app, b) { + if let Err(e) = unregister_shortcut(&app, b) { error!("suspend_binding error for id '{}': {}", id, e); return Err(e); } @@ -686,7 +713,7 @@ pub fn suspend_binding(app: AppHandle, id: String) -> Result<(), String> { #[specta::specta] pub fn resume_binding(app: AppHandle, id: String) -> Result<(), String> { if let Some(b) = settings::get_bindings(&app).get(&id).cloned() { - if let Err(e) = _register_shortcut(&app, b) { + if let Err(e) = register_shortcut(&app, b) { error!("resume_binding error for id '{}': {}", id, e); return Err(e); } @@ -694,7 +721,28 @@ pub fn resume_binding(app: AppHandle, id: String) -> Result<(), String> { Ok(()) } -fn _register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> { +pub fn register_cancel_shortcut(app: &AppHandle) { + let app_clone = app.clone(); + tauri::async_runtime::spawn(async move { + if let Some(cancel_binding) = get_settings(&app_clone).bindings.get("cancel").cloned() { + if let Err(e) = register_shortcut(&app_clone, cancel_binding) { + eprintln!("Failed to register cancel shortcut: {}", e); + } + } + }); +} + +pub fn unregister_cancel_shortcut(app: &AppHandle) { + let app_clone = app.clone(); + tauri::async_runtime::spawn(async move { + if let Some(cancel_binding) = get_settings(&app_clone).bindings.get("cancel").cloned() { + // We ignore errors here as it might already be unregistered + let _ = unregister_shortcut(&app_clone, cancel_binding); + } + }); +} + +pub fn register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> { // Validate human-level rules first if let Err(e) = validate_shortcut_string(&binding.current_binding) { warn!( @@ -734,7 +782,13 @@ fn _register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), S let settings = get_settings(ah); if let Some(action) = ACTION_MAP.get(&binding_id_for_closure) { - if settings.push_to_talk { + if binding_id_for_closure == "cancel" { + let audio_manager = ah.state::>(); + if audio_manager.is_recording() && event.state == ShortcutState::Pressed { + action.start(ah, &binding_id_for_closure, &shortcut_string); + } + return; + } else if settings.push_to_talk { if event.state == ShortcutState::Pressed { action.start(ah, &binding_id_for_closure, &shortcut_string); } else if event.state == ShortcutState::Released { @@ -780,7 +834,7 @@ fn _register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), S Ok(()) } -fn _unregister_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> { +pub fn unregister_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> { let shortcut = match binding.current_binding.parse::() { Ok(s) => s, Err(e) => { diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index 7a77d3b..636ef8e 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -1,5 +1,5 @@ -use crate::actions::ACTION_MAP; use crate::managers::audio::AudioRecordingManager; +use crate::shortcut; use crate::ManagedToggleState; use log::{info, warn}; use std::sync::Arc; @@ -16,31 +16,14 @@ pub use crate::tray::*; pub fn cancel_current_operation(app: &AppHandle) { info!("Initiating operation cancellation..."); - // First, reset all shortcut toggle states and call stop actions + // Unregister the cancel shortcut asynchronously + shortcut::unregister_cancel_shortcut(app); + + // First, reset all shortcut toggle states. // This is critical for non-push-to-talk mode where shortcuts toggle on/off let toggle_state_manager = app.state::(); if let Ok(mut states) = toggle_state_manager.lock() { - // For each currently active toggle, call its stop action and reset state - let active_bindings: Vec = states - .active_toggles - .iter() - .filter(|(_, &is_active)| is_active) - .map(|(binding_id, _)| binding_id.clone()) - .collect(); - - for binding_id in active_bindings { - info!("Stopping active action for binding: {}", binding_id); - - // Call the action's stop method to ensure proper cleanup - if let Some(action) = ACTION_MAP.get(&binding_id) { - action.stop(app, &binding_id, "cancelled"); - } - - // Reset the toggle state - if let Some(is_active) = states.active_toggles.get_mut(&binding_id) { - *is_active = false; - } - } + states.active_toggles.values_mut().for_each(|v| *v = false); } else { warn!("Failed to lock toggle state manager during cancellation"); } @@ -49,8 +32,9 @@ pub fn cancel_current_operation(app: &AppHandle) { let audio_manager = app.state::>(); audio_manager.cancel_recording(); - // Update tray icon and menu to idle state + // Update tray icon and hide overlay change_tray_icon(app, crate::tray::TrayIconState::Idle); + hide_recording_overlay(app); info!("Operation cancellation completed - returned to idle state"); } diff --git a/src/components/settings/HandyShortcut.tsx b/src/components/settings/HandyShortcut.tsx index 8b64e71..eb27a6d 100644 --- a/src/components/settings/HandyShortcut.tsx +++ b/src/components/settings/HandyShortcut.tsx @@ -15,11 +15,15 @@ import { toast } from "sonner"; interface HandyShortcutProps { descriptionMode?: "inline" | "tooltip"; grouped?: boolean; + shortcutId: string; + disabled?: boolean; } export const HandyShortcut: React.FC = ({ descriptionMode = "tooltip", grouped = false, + shortcutId, + disabled = false, }) => { const { getSetting, updateBinding, resetBinding, isUpdating, isLoading } = useSettings(); @@ -267,48 +271,50 @@ export const HandyShortcut: React.FC = ({ ); } + const binding = bindings[shortcutId]; + if (!binding) { + return ( + +
No shortcut configured
+
+ ); + } + return ( - {(() => { - const primaryBinding = Object.values(bindings)[0]; - const primaryId = Object.keys(bindings)[0]; - - if (!primaryBinding) { - return ( -
No shortcuts configured
- ); - } - - return ( -
- {editingShortcutId === primaryId ? ( -
setShortcutRef(primaryId, ref)} - className="px-2 py-1 text-sm font-semibold border border-logo-primary bg-logo-primary/30 rounded min-w-[120px] text-center" - > - {formatCurrentKeys()} -
- ) : ( -
startRecording(primaryId)} - > - {formatKeyCombination(primaryBinding.current_binding, osType)} -
- )} - resetBinding(primaryId)} - disabled={isUpdating(`binding_${primaryId}`)} - /> +
+ {editingShortcutId === shortcutId ? ( +
setShortcutRef(shortcutId, ref)} + className="px-2 py-1 text-sm font-semibold border border-logo-primary bg-logo-primary/30 rounded min-w-[120px] text-center" + > + {formatCurrentKeys()}
- ); - })()} + ) : ( +
startRecording(shortcutId)} + > + {formatKeyCombination(binding.current_binding, osType)} +
+ )} + resetBinding(shortcutId)} + disabled={isUpdating(`binding_${shortcutId}`)} + /> +
); }; diff --git a/src/components/settings/debug/DebugSettings.tsx b/src/components/settings/debug/DebugSettings.tsx index 35f20b9..dbc9a9a 100644 --- a/src/components/settings/debug/DebugSettings.tsx +++ b/src/components/settings/debug/DebugSettings.tsx @@ -10,8 +10,13 @@ import { PostProcessingToggle } from "../PostProcessingToggle"; import { MuteWhileRecording } from "../MuteWhileRecording"; import { RecordingRetentionPeriodSelector } from "../RecordingRetentionPeriod"; import { ClamshellMicrophoneSelector } from "../ClamshellMicrophoneSelector"; +import { HandyShortcut } from "../HandyShortcut"; +import { useSettings } from "../../../hooks/useSettings"; export const DebugSettings: React.FC = () => { + const { getSetting } = useSettings(); + const pushToTalk = getSetting("push_to_talk"); + return (
@@ -31,6 +36,11 @@ export const DebugSettings: React.FC = () => { +
); diff --git a/src/components/settings/general/GeneralSettings.tsx b/src/components/settings/general/GeneralSettings.tsx index fe14858..57bd9ec 100644 --- a/src/components/settings/general/GeneralSettings.tsx +++ b/src/components/settings/general/GeneralSettings.tsx @@ -14,7 +14,7 @@ export const GeneralSettings: React.FC = () => { return (
- +