fix: cancel recording works in wakeword mode + tray Cancel item (v2.03.26)

- Cancel hotkey now fires even when wakeword triggered the recording
  (ModifierScheme state was "idle" so the key was silently ignored)
- Tray menu: "✕ Cancel recording" — always visible, enabled while recording,
  grayed out at idle; works for both wakeword and manual recordings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-10 03:44:14 +02:00
parent 599eb08c5a
commit 834cc20553
4 changed files with 36 additions and 7 deletions

View File

@ -9,6 +9,21 @@ The version is defined in [`blitztext/__init__.py`](blitztext/__init__.py).
## [Unreleased]
## [2.03.26] - 2026-06-10
### Fixed
- **Cancel key now works during wakeword-triggered recording.** Previously the
`ModifierScheme` state machine stayed in "idle" when the wakeword fired
(it bypasses the key-press path), so the cancel hotkey was silently ignored.
It now checks `daemon.is_recording` as a fallback so it fires regardless of
how recording started.
### Added
- **"✕ Cancel recording" in the tray menu.** Always visible; grayed out when
idle, enabled as soon as recording starts (wakeword or manual). The primary
escape hatch when the wakeword fires on audiobook / TV audio and spoken
cancel words can't be heard over the background audio.
## [2.03.25] - 2026-06-10
### Changed

View File

@ -6,4 +6,4 @@ counterpart to the macOS Blitztext menu bar app: it runs natively on the host
(not in a container) so it can type into any application via xdotool.
"""
__version__ = "2.03.25"
__version__ = "2.03.26"

View File

@ -74,18 +74,21 @@ class ModifierScheme:
return
self._pressed.add(token)
# Cancel works while arming/armed AND when the daemon is recording via
# wakeword (state stays "idle" in the scheme because wakeword bypasses
# the key-press path entirely).
if self._is(token, self.cancel) and (
self._state != "idle" or self.daemon.is_recording):
self._state = "idle"
self.daemon.cancel_dictation()
return
if self._state == "idle":
if self.start.issubset(self._pressed):
self._state = "arming"
self.daemon.start_dictation()
return
# Cancel works while arming or armed.
if self._is(token, self.cancel):
self._state = "idle"
self.daemon.cancel_dictation()
return
if self._state == "armed":
if self._is(token, self.send):
self._state = "idle"

View File

@ -88,6 +88,15 @@ class Tray:
menu.append(Gtk.SeparatorMenuItem())
# Cancel recording — always visible, only sensitive while recording.
# Primary escape hatch when wakeword fires on audiobook/TV audio.
self.cancel_item = Gtk.MenuItem(label="✕ Cancel recording")
self.cancel_item.set_sensitive(False)
self.cancel_item.connect(
"activate", lambda _i: self.app.daemon.cancel_dictation())
menu.append(self.cancel_item)
menu.append(Gtk.SeparatorMenuItem())
# Hands-free wakeword: a reversible pause toggle. Without this, a stale
# /tmp/wake_muted flag would silently disable detection with no way back.
if getattr(self.app.cfg, "wakeword_enabled", False):
@ -120,6 +129,8 @@ class Tray:
def update_status(self, state: str, message: str) -> None:
self.indicator.set_icon_full(ICONS.get(state, ICONS["idle"]), state)
self.status_item.set_label(f"{message or state.title()}")
if hasattr(self, "cancel_item"):
self.cancel_item.set_sensitive(state in ("recording", "armed", "busy"))
def pump(self) -> None:
"""Service pending GLib/GTK events; called from tkinter's loop."""