Add a cancel hotkey and configuration (#224)

* From scratch

* Cleanup

* Small fixes

* simplify

---------

Co-authored-by: CJ Pais <cj@cjpais.com>
This commit is contained in:
Jackson 2025-11-28 11:00:47 +09:00 committed by GitHub
parent 8136e6ba1f
commit 381b8a8bfb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 191 additions and 77 deletions

View file

@ -2,10 +2,10 @@ use crate::audio_feedback::{play_feedback_sound, play_feedback_sound_blocking, S
use crate::managers::audio::AudioRecordingManager; use crate::managers::audio::AudioRecordingManager;
use crate::managers::history::HistoryManager; use crate::managers::history::HistoryManager;
use crate::managers::transcription::TranscriptionManager; use crate::managers::transcription::TranscriptionManager;
use crate::overlay::{show_recording_overlay, show_transcribing_overlay};
use crate::settings::{get_settings, AppSettings}; use crate::settings::{get_settings, AppSettings};
use crate::shortcut;
use crate::tray::{change_tray_icon, TrayIconState}; use crate::tray::{change_tray_icon, TrayIconState};
use crate::utils; use crate::utils::{self, show_recording_overlay, show_transcribing_overlay};
use async_openai::types::{ use async_openai::types::{
ChatCompletionRequestMessage, ChatCompletionRequestUserMessageArgs, ChatCompletionRequestMessage, ChatCompletionRequestUserMessageArgs,
CreateChatCompletionRequestArgs, CreateChatCompletionRequestArgs,
@ -225,6 +225,7 @@ impl ShortcutAction for TranscribeAction {
let is_always_on = settings.always_on_microphone; let is_always_on = settings.always_on_microphone;
debug!("Microphone mode - always_on: {}", is_always_on); debug!("Microphone mode - always_on: {}", is_always_on);
let mut recording_started = false;
if is_always_on { if is_always_on {
// Always-on mode: Play audio feedback immediately, then apply mute after sound finishes // Always-on mode: Play audio feedback immediately, then apply mute after sound finishes
debug!("Always-on mode: Playing audio feedback immediately"); debug!("Always-on mode: Playing audio feedback immediately");
@ -237,7 +238,7 @@ impl ShortcutAction for TranscribeAction {
rm_clone.apply_mute(); 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); debug!("Recording started: {}", recording_started);
} else { } else {
// On-demand mode: Start recording first, then play audio feedback, then apply mute // 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"); debug!("On-demand mode: Starting recording first, then audio feedback");
let recording_start_time = Instant::now(); let recording_start_time = Instant::now();
if rm.try_start_recording(&binding_id) { if rm.try_start_recording(&binding_id) {
recording_started = true;
debug!("Recording started in {:?}", recording_start_time.elapsed()); debug!("Recording started in {:?}", recording_start_time.elapsed());
// Small delay to ensure microphone stream is active // Small delay to ensure microphone stream is active
let app_clone = app.clone(); 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!( debug!(
"TranscribeAction::start completed in {:?}", "TranscribeAction::start completed in {:?}",
start_time.elapsed() start_time.elapsed()
@ -269,6 +276,9 @@ impl ShortcutAction for TranscribeAction {
} }
fn stop(&self, app: &AppHandle, binding_id: &str, _shortcut_str: &str) { 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(); let stop_time = Instant::now();
debug!("TranscribeAction::stop called for binding: {}", binding_id); 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 // Test Action
struct TestAction; struct TestAction;
@ -436,6 +459,10 @@ pub static ACTION_MAP: Lazy<HashMap<String, Arc<dyn ShortcutAction>>> = Lazy::ne
"transcribe".to_string(), "transcribe".to_string(),
Arc::new(TranscribeAction) as Arc<dyn ShortcutAction>, Arc::new(TranscribeAction) as Arc<dyn ShortcutAction>,
); );
map.insert(
"cancel".to_string(),
Arc::new(CancelAction) as Arc<dyn ShortcutAction>,
);
map.insert( map.insert(
"test".to_string(), "test".to_string(),
Arc::new(TestAction) as Arc<dyn ShortcutAction>, Arc::new(TestAction) as Arc<dyn ShortcutAction>,

View file

@ -414,6 +414,12 @@ impl AudioRecordingManager {
_ => None, _ => None,
} }
} }
pub fn is_recording(&self) -> bool {
matches!(
*self.state.lock().unwrap(),
RecordingState::Recording { .. }
)
}
/// Cancel any ongoing recording without returning audio samples /// Cancel any ongoing recording without returning audio samples
pub fn cancel_recording(&self) { pub fn cancel_recording(&self) {

View file

@ -432,6 +432,16 @@ pub fn get_default_settings() -> AppSettings {
current_binding: default_shortcut.to_string(), 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 { AppSettings {
bindings, 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") { let settings = if let Some(settings_value) = store.get("settings") {
// Parse the entire settings object // Parse the entire settings object
match serde_json::from_value::<AppSettings>(settings_value) { match serde_json::from_value::<AppSettings>(settings_value) {
Ok(settings) => { Ok(mut settings) => {
debug!("Found existing settings: {:?}", 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 settings
} }
Err(e) => { Err(e) => {

View file

@ -1,11 +1,13 @@
use log::{error, warn}; use log::{error, warn};
use serde::Serialize; use serde::Serialize;
use specta::Type; use specta::Type;
use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager}; use tauri::{AppHandle, Emitter, Manager};
use tauri_plugin_autostart::ManagerExt; use tauri_plugin_autostart::ManagerExt;
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
use crate::actions::ACTION_MAP; use crate::actions::ACTION_MAP;
use crate::managers::audio::AudioRecordingManager;
use crate::settings::ShortcutBinding; use crate::settings::ShortcutBinding;
use crate::settings::{ use crate::settings::{
self, get_settings, ClipboardHandling, LLMPrompt, OverlayPosition, PasteMethod, SoundTheme, self, get_settings, ClipboardHandling, LLMPrompt, OverlayPosition, PasteMethod, SoundTheme,
@ -13,12 +15,23 @@ use crate::settings::{
use crate::ManagedToggleState; use crate::ManagedToggleState;
pub fn init_shortcuts(app: &AppHandle) { 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 { // Register all default shortcuts, applying user customizations
if let Err(e) = _register_shortcut(app, binding) { for (id, default_binding) in default_bindings {
error!("Failed to register shortcut {} during init: {}", _id, e); 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 // 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); let error_msg = format!("Failed to unregister shortcut: {}", e);
error!("change_binding error: {}", error_msg); error!("change_binding error: {}", error_msg);
} }
@ -70,7 +97,7 @@ pub fn change_binding(
updated_binding.current_binding = binding; updated_binding.current_binding = binding;
// Register the new 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); let error_msg = format!("Failed to register shortcut: {}", e);
error!("change_binding error: {}", error_msg); error!("change_binding error: {}", error_msg);
return Ok(BindingResponse { return Ok(BindingResponse {
@ -673,7 +700,7 @@ fn validate_shortcut_string(raw: &str) -> Result<(), String> {
#[specta::specta] #[specta::specta]
pub fn suspend_binding(app: AppHandle, id: String) -> Result<(), String> { pub fn suspend_binding(app: AppHandle, id: String) -> Result<(), String> {
if let Some(b) = settings::get_bindings(&app).get(&id).cloned() { 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); error!("suspend_binding error for id '{}': {}", id, e);
return Err(e); return Err(e);
} }
@ -686,7 +713,7 @@ pub fn suspend_binding(app: AppHandle, id: String) -> Result<(), String> {
#[specta::specta] #[specta::specta]
pub fn resume_binding(app: AppHandle, id: String) -> Result<(), String> { pub fn resume_binding(app: AppHandle, id: String) -> Result<(), String> {
if let Some(b) = settings::get_bindings(&app).get(&id).cloned() { 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); error!("resume_binding error for id '{}': {}", id, e);
return Err(e); return Err(e);
} }
@ -694,7 +721,28 @@ pub fn resume_binding(app: AppHandle, id: String) -> Result<(), String> {
Ok(()) 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 // Validate human-level rules first
if let Err(e) = validate_shortcut_string(&binding.current_binding) { if let Err(e) = validate_shortcut_string(&binding.current_binding) {
warn!( warn!(
@ -734,7 +782,13 @@ fn _register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), S
let settings = get_settings(ah); let settings = get_settings(ah);
if let Some(action) = ACTION_MAP.get(&binding_id_for_closure) { 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::<Arc<AudioRecordingManager>>();
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 { if event.state == ShortcutState::Pressed {
action.start(ah, &binding_id_for_closure, &shortcut_string); action.start(ah, &binding_id_for_closure, &shortcut_string);
} else if event.state == ShortcutState::Released { } else if event.state == ShortcutState::Released {
@ -780,7 +834,7 @@ fn _register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), S
Ok(()) 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::<Shortcut>() { let shortcut = match binding.current_binding.parse::<Shortcut>() {
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {

View file

@ -1,5 +1,5 @@
use crate::actions::ACTION_MAP;
use crate::managers::audio::AudioRecordingManager; use crate::managers::audio::AudioRecordingManager;
use crate::shortcut;
use crate::ManagedToggleState; use crate::ManagedToggleState;
use log::{info, warn}; use log::{info, warn};
use std::sync::Arc; use std::sync::Arc;
@ -16,31 +16,14 @@ pub use crate::tray::*;
pub fn cancel_current_operation(app: &AppHandle) { pub fn cancel_current_operation(app: &AppHandle) {
info!("Initiating operation cancellation..."); 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 // This is critical for non-push-to-talk mode where shortcuts toggle on/off
let toggle_state_manager = app.state::<ManagedToggleState>(); let toggle_state_manager = app.state::<ManagedToggleState>();
if let Ok(mut states) = toggle_state_manager.lock() { if let Ok(mut states) = toggle_state_manager.lock() {
// For each currently active toggle, call its stop action and reset state states.active_toggles.values_mut().for_each(|v| *v = false);
let active_bindings: Vec<String> = 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;
}
}
} else { } else {
warn!("Failed to lock toggle state manager during cancellation"); 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::<Arc<AudioRecordingManager>>(); let audio_manager = app.state::<Arc<AudioRecordingManager>>();
audio_manager.cancel_recording(); 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); change_tray_icon(app, crate::tray::TrayIconState::Idle);
hide_recording_overlay(app);
info!("Operation cancellation completed - returned to idle state"); info!("Operation cancellation completed - returned to idle state");
} }

View file

@ -15,11 +15,15 @@ import { toast } from "sonner";
interface HandyShortcutProps { interface HandyShortcutProps {
descriptionMode?: "inline" | "tooltip"; descriptionMode?: "inline" | "tooltip";
grouped?: boolean; grouped?: boolean;
shortcutId: string;
disabled?: boolean;
} }
export const HandyShortcut: React.FC<HandyShortcutProps> = ({ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
descriptionMode = "tooltip", descriptionMode = "tooltip",
grouped = false, grouped = false,
shortcutId,
disabled = false,
}) => { }) => {
const { getSetting, updateBinding, resetBinding, isUpdating, isLoading } = const { getSetting, updateBinding, resetBinding, isUpdating, isLoading } =
useSettings(); useSettings();
@ -267,48 +271,50 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
); );
} }
const binding = bindings[shortcutId];
if (!binding) {
return (
<SettingContainer
title="Shortcut"
description="Shortcut not found"
descriptionMode={descriptionMode}
grouped={grouped}
>
<div className="text-sm text-mid-gray">No shortcut configured</div>
</SettingContainer>
);
}
return ( return (
<SettingContainer <SettingContainer
title="Handy Shortcut" title={binding.name}
description="Set the keyboard shortcut to start and stop speech-to-text recording" description={binding.description}
descriptionMode={descriptionMode} descriptionMode={descriptionMode}
grouped={grouped} grouped={grouped}
tooltipPosition="bottom" disabled={disabled}
layout="horizontal"
> >
{(() => { <div className="flex items-center space-x-1">
const primaryBinding = Object.values(bindings)[0]; {editingShortcutId === shortcutId ? (
const primaryId = Object.keys(bindings)[0]; <div
ref={(ref) => setShortcutRef(shortcutId, ref)}
if (!primaryBinding) { className="px-2 py-1 text-sm font-semibold border border-logo-primary bg-logo-primary/30 rounded min-w-[120px] text-center"
return ( >
<div className="text-sm text-mid-gray">No shortcuts configured</div> {formatCurrentKeys()}
);
}
return (
<div className="flex items-center space-x-1">
{editingShortcutId === primaryId ? (
<div
ref={(ref) => 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()}
</div>
) : (
<div
className="px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 hover:bg-logo-primary/10 rounded cursor-pointer hover:border-logo-primary"
onClick={() => startRecording(primaryId)}
>
{formatKeyCombination(primaryBinding.current_binding, osType)}
</div>
)}
<ResetButton
onClick={() => resetBinding(primaryId)}
disabled={isUpdating(`binding_${primaryId}`)}
/>
</div> </div>
); ) : (
})()} <div
className="px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 hover:bg-logo-primary/10 rounded cursor-pointer hover:border-logo-primary"
onClick={() => startRecording(shortcutId)}
>
{formatKeyCombination(binding.current_binding, osType)}
</div>
)}
<ResetButton
onClick={() => resetBinding(shortcutId)}
disabled={isUpdating(`binding_${shortcutId}`)}
/>
</div>
</SettingContainer> </SettingContainer>
); );
}; };

View file

@ -10,8 +10,13 @@ import { PostProcessingToggle } from "../PostProcessingToggle";
import { MuteWhileRecording } from "../MuteWhileRecording"; import { MuteWhileRecording } from "../MuteWhileRecording";
import { RecordingRetentionPeriodSelector } from "../RecordingRetentionPeriod"; import { RecordingRetentionPeriodSelector } from "../RecordingRetentionPeriod";
import { ClamshellMicrophoneSelector } from "../ClamshellMicrophoneSelector"; import { ClamshellMicrophoneSelector } from "../ClamshellMicrophoneSelector";
import { HandyShortcut } from "../HandyShortcut";
import { useSettings } from "../../../hooks/useSettings";
export const DebugSettings: React.FC = () => { export const DebugSettings: React.FC = () => {
const { getSetting } = useSettings();
const pushToTalk = getSetting("push_to_talk");
return ( return (
<div className="max-w-3xl w-full mx-auto space-y-6"> <div className="max-w-3xl w-full mx-auto space-y-6">
<SettingsGroup title="Debug"> <SettingsGroup title="Debug">
@ -31,6 +36,11 @@ export const DebugSettings: React.FC = () => {
<ClamshellMicrophoneSelector descriptionMode="tooltip" grouped={true} /> <ClamshellMicrophoneSelector descriptionMode="tooltip" grouped={true} />
<PostProcessingToggle descriptionMode="tooltip" grouped={true} /> <PostProcessingToggle descriptionMode="tooltip" grouped={true} />
<MuteWhileRecording descriptionMode="tooltip" grouped={true} /> <MuteWhileRecording descriptionMode="tooltip" grouped={true} />
<HandyShortcut
shortcutId="cancel"
grouped={true}
disabled={pushToTalk}
/>
</SettingsGroup> </SettingsGroup>
</div> </div>
); );

View file

@ -14,7 +14,7 @@ export const GeneralSettings: React.FC = () => {
return ( return (
<div className="max-w-3xl w-full mx-auto space-y-6"> <div className="max-w-3xl w-full mx-auto space-y-6">
<SettingsGroup title="General"> <SettingsGroup title="General">
<HandyShortcut descriptionMode="tooltip" grouped={true} /> <HandyShortcut shortcutId="transcribe" grouped={true} />
<LanguageSelector descriptionMode="tooltip" grouped={true} /> <LanguageSelector descriptionMode="tooltip" grouped={true} />
<PushToTalk descriptionMode="tooltip" grouped={true} /> <PushToTalk descriptionMode="tooltip" grouped={true} />
</SettingsGroup> </SettingsGroup>