v0.2.1: Fix cancel key (Escape) being swallowed globally

The cancel key was consumed by rdev::grab at all times, not just during
recording/transcribing. This made the Escape key unusable system-wide
while Mouth was running. Now the cancel key only gets swallowed when
Mouth is actively recording or transcribing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 16:37:50 +01:00
parent 9ad870d260
commit 3fa4d102df
5 changed files with 25 additions and 10 deletions
Generated
+1 -1
View File
@@ -2224,7 +2224,7 @@ dependencies = [
[[package]] [[package]]
name = "mouth" name = "mouth"
version = "0.2.0" version = "0.2.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arboard", "arboard",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "mouth" name = "mouth"
version = "0.2.0" version = "0.2.1"
edition = "2024" edition = "2024"
description = "Offline speech-to-text with global hotkey and paste" description = "Offline speech-to-text with global hotkey and paste"
license-file = "LICENSE" license-file = "LICENSE"
+2 -1
View File
@@ -103,10 +103,11 @@ pub fn run() -> Result<()> {
}) })
.context("Failed to spawn recorder thread")?; .context("Failed to spawn recorder thread")?;
let hotkey_state = Arc::clone(&shared_state);
thread::Builder::new() thread::Builder::new()
.name("mouth-hotkey".into()) .name("mouth-hotkey".into())
.spawn(move || { .spawn(move || {
hotkey::listen(hotkey_combo, cancel_combo, hotkey_tx); hotkey::listen(hotkey_combo, cancel_combo, hotkey_tx, hotkey_state);
}) })
.context("Failed to spawn hotkey thread")?; .context("Failed to spawn hotkey thread")?;
+15 -7
View File
@@ -1,10 +1,14 @@
use anyhow::{bail, Result}; use anyhow::{bail, Result};
use rdev::{self, Event, EventType, Key}; use rdev::{self, Event, EventType, Key};
use std::cell::RefCell; use std::cell::RefCell;
use std::sync::atomic::Ordering;
use std::sync::mpsc; use std::sync::mpsc;
use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tracing::{debug, error, info}; use tracing::{debug, error, info};
use crate::shared_state::SharedState;
/// Events sent from the hotkey listener to the coordinator. /// Events sent from the hotkey listener to the coordinator.
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub enum HotkeyEvent { pub enum HotkeyEvent {
@@ -380,6 +384,7 @@ pub fn listen(
hotkey: HotkeyCombination, hotkey: HotkeyCombination,
cancel_key: HotkeyCombination, cancel_key: HotkeyCombination,
tx: mpsc::Sender<HotkeyEvent>, tx: mpsc::Sender<HotkeyEvent>,
shared_state: Arc<SharedState>,
) { ) {
let debounce_duration = Duration::from_millis(30); let debounce_duration = Duration::from_millis(30);
@@ -406,16 +411,19 @@ pub fn listen(
EventType::KeyPress(key) => { EventType::KeyPress(key) => {
s.modifier_state.update(&key, true); s.modifier_state.update(&key, true);
// Check cancel key — swallow it // Check cancel key — only swallow it when actively recording/transcribing
if key == cancel_key.key && s.modifier_state.all_held(&cancel_key.modifiers) { if key == cancel_key.key && s.modifier_state.all_held(&cancel_key.modifiers) {
if now.duration_since(s.last_event_time) >= debounce_duration { if shared_state.is_active.load(Ordering::Acquire) {
s.last_event_time = now; if now.duration_since(s.last_event_time) >= debounce_duration {
debug!("Cancel key pressed"); s.last_event_time = now;
if tx.send(HotkeyEvent::Cancel).is_err() { debug!("Cancel key pressed");
error!("Failed to send cancel event"); if tx.send(HotkeyEvent::Cancel).is_err() {
error!("Failed to send cancel event");
}
} }
return None;
} }
return None; // Not active — let the key pass through
} }
// Check hotkey — swallow it // Check hotkey — swallow it
+6
View File
@@ -1,9 +1,13 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::RwLock; use std::sync::RwLock;
use std::time::Instant; use std::time::Instant;
/// Thread-safe shared state accessible by the coordinator, IPC listener, and tray icon. /// Thread-safe shared state accessible by the coordinator, IPC listener, and tray icon.
pub struct SharedState { pub struct SharedState {
pub state: RwLock<String>, pub state: RwLock<String>,
/// True when recording or transcribing — the hotkey listener uses this to
/// decide whether to swallow the cancel key.
pub is_active: AtomicBool,
pub model: String, pub model: String,
pub accelerator: String, pub accelerator: String,
pub started_at: Instant, pub started_at: Instant,
@@ -13,6 +17,7 @@ impl SharedState {
pub fn new(model: String, accelerator: String) -> Self { pub fn new(model: String, accelerator: String) -> Self {
Self { Self {
state: RwLock::new("idle".to_string()), state: RwLock::new("idle".to_string()),
is_active: AtomicBool::new(false),
model, model,
accelerator, accelerator,
started_at: Instant::now(), started_at: Instant::now(),
@@ -23,6 +28,7 @@ impl SharedState {
if let Ok(mut s) = self.state.write() { if let Ok(mut s) = self.state.write() {
*s = state.to_string(); *s = state.to_string();
} }
self.is_active.store(state != "idle", Ordering::Release);
} }
pub fn get_state(&self) -> String { pub fn get_state(&self) -> String {