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::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<HashMap<String, Arc<dyn ShortcutAction>>> = Lazy::ne
"transcribe".to_string(),
Arc::new(TranscribeAction) as Arc<dyn ShortcutAction>,
);
map.insert(
"cancel".to_string(),
Arc::new(CancelAction) as Arc<dyn ShortcutAction>,
);
map.insert(
"test".to_string(),
Arc::new(TestAction) as Arc<dyn ShortcutAction>,

View file

@ -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) {

View file

@ -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::<AppSettings>(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) => {

View file

@ -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::<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 {
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::<Shortcut>() {
Ok(s) => s,
Err(e) => {

View file

@ -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::<ManagedToggleState>();
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<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;
}
}
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::<Arc<AudioRecordingManager>>();
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");
}

View file

@ -15,11 +15,15 @@ import { toast } from "sonner";
interface HandyShortcutProps {
descriptionMode?: "inline" | "tooltip";
grouped?: boolean;
shortcutId: string;
disabled?: boolean;
}
export const HandyShortcut: React.FC<HandyShortcutProps> = ({
descriptionMode = "tooltip",
grouped = false,
shortcutId,
disabled = false,
}) => {
const { getSetting, updateBinding, resetBinding, isUpdating, isLoading } =
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 (
<SettingContainer
title="Handy Shortcut"
description="Set the keyboard shortcut to start and stop speech-to-text recording"
title={binding.name}
description={binding.description}
descriptionMode={descriptionMode}
grouped={grouped}
tooltipPosition="bottom"
disabled={disabled}
layout="horizontal"
>
{(() => {
const primaryBinding = Object.values(bindings)[0];
const primaryId = Object.keys(bindings)[0];
if (!primaryBinding) {
return (
<div className="text-sm text-mid-gray">No shortcuts configured</div>
);
}
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 className="flex items-center space-x-1">
{editingShortcutId === shortcutId ? (
<div
ref={(ref) => 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()}
</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>
);
};

View file

@ -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 (
<div className="max-w-3xl w-full mx-auto space-y-6">
<SettingsGroup title="Debug">
@ -31,6 +36,11 @@ export const DebugSettings: React.FC = () => {
<ClamshellMicrophoneSelector descriptionMode="tooltip" grouped={true} />
<PostProcessingToggle descriptionMode="tooltip" grouped={true} />
<MuteWhileRecording descriptionMode="tooltip" grouped={true} />
<HandyShortcut
shortcutId="cancel"
grouped={true}
disabled={pushToTalk}
/>
</SettingsGroup>
</div>
);

View file

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