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]]
name = "mouth"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"anyhow",
"arboard",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "mouth"
version = "0.2.0"
version = "0.2.1"
edition = "2024"
description = "Offline speech-to-text with global hotkey and paste"
license-file = "LICENSE"
+2 -1
View File
@@ -103,10 +103,11 @@ pub fn run() -> Result<()> {
})
.context("Failed to spawn recorder thread")?;
let hotkey_state = Arc::clone(&shared_state);
thread::Builder::new()
.name("mouth-hotkey".into())
.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")?;
+15 -7
View File
@@ -1,10 +1,14 @@
use anyhow::{bail, Result};
use rdev::{self, Event, EventType, Key};
use std::cell::RefCell;
use std::sync::atomic::Ordering;
use std::sync::mpsc;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tracing::{debug, error, info};
use crate::shared_state::SharedState;
/// Events sent from the hotkey listener to the coordinator.
#[derive(Debug, Clone, Copy)]
pub enum HotkeyEvent {
@@ -380,6 +384,7 @@ pub fn listen(
hotkey: HotkeyCombination,
cancel_key: HotkeyCombination,
tx: mpsc::Sender<HotkeyEvent>,
shared_state: Arc<SharedState>,
) {
let debounce_duration = Duration::from_millis(30);
@@ -406,16 +411,19 @@ pub fn listen(
EventType::KeyPress(key) => {
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 now.duration_since(s.last_event_time) >= debounce_duration {
s.last_event_time = now;
debug!("Cancel key pressed");
if tx.send(HotkeyEvent::Cancel).is_err() {
error!("Failed to send cancel event");
if shared_state.is_active.load(Ordering::Acquire) {
if now.duration_since(s.last_event_time) >= debounce_duration {
s.last_event_time = now;
debug!("Cancel key pressed");
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
+6
View File
@@ -1,9 +1,13 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::RwLock;
use std::time::Instant;
/// Thread-safe shared state accessible by the coordinator, IPC listener, and tray icon.
pub struct SharedState {
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 accelerator: String,
pub started_at: Instant,
@@ -13,6 +17,7 @@ impl SharedState {
pub fn new(model: String, accelerator: String) -> Self {
Self {
state: RwLock::new("idle".to_string()),
is_active: AtomicBool::new(false),
model,
accelerator,
started_at: Instant::now(),
@@ -23,6 +28,7 @@ impl SharedState {
if let Ok(mut s) = self.state.write() {
*s = state.to_string();
}
self.is_active.store(state != "idle", Ordering::Release);
}
pub fn get_state(&self) -> String {