Scope Esc cancel listener to active recording

This commit is contained in:
Thales Maciel 2026-02-26 16:28:49 -03:00
parent e5d709a393
commit 64c8c26bce
6 changed files with 105 additions and 7 deletions

View file

@ -77,6 +77,18 @@ class Daemon:
self.vocabulary = VocabularyEngine(cfg.vocabulary)
self._stt_hint_kwargs_cache: dict[str, Any] | None = None
def _arm_cancel_listener(self):
try:
self.desktop.start_cancel_listener(lambda: self.cancel_recording())
except Exception as exc:
logging.error("failed to start cancel listener: %s", exc)
def _disarm_cancel_listener(self):
try:
self.desktop.stop_cancel_listener()
except Exception as exc:
logging.debug("failed to stop cancel listener: %s", exc)
def set_state(self, state: str):
with self.lock:
prev = self.state
@ -84,7 +96,7 @@ class Daemon:
if prev != state:
logging.debug("state: %s -> %s", prev, state)
else:
logging.warning("redundant state set: %s, kindly inform the dev", state)
logging.debug("redundant state set: %s", state)
def get_state(self):
with self.lock:
@ -123,6 +135,7 @@ class Daemon:
prev = self.state
self.state = State.RECORDING
logging.debug("state: %s -> %s", prev, self.state)
self._arm_cancel_listener()
logging.info("recording started")
if self.timer:
self.timer.cancel()
@ -150,6 +163,7 @@ class Daemon:
if self.timer:
self.timer.cancel()
self.timer = None
self._disarm_cancel_listener()
prev = self.state
self.state = State.STT
logging.debug("state: %s -> %s", prev, self.state)
@ -179,7 +193,6 @@ class Daemon:
return
try:
self.set_state(State.STT)
logging.info("stt started")
text = self._transcribe(audio)
except Exception as exc:
@ -256,6 +269,7 @@ class Daemon:
def shutdown(self, timeout: float = 5.0) -> bool:
self.request_shutdown()
self._disarm_cancel_listener()
self.stop_recording(trigger="shutdown", process_audio=False)
return self.wait_for_idle(timeout)
@ -402,7 +416,6 @@ def main():
cfg.daemon.hotkey,
lambda: logging.info("hotkey pressed (dry-run)") if args.dry_run else daemon.toggle(),
)
desktop.start_cancel_listener(lambda: daemon.cancel_recording())
except Exception as exc:
logging.error("hotkey setup failed: %s", exc)
raise SystemExit(1)

View file

@ -11,6 +11,9 @@ class DesktopAdapter(Protocol):
def start_cancel_listener(self, callback: Callable[[], None]) -> None:
raise NotImplementedError
def stop_cancel_listener(self) -> None:
raise NotImplementedError
def inject_text(
self,
text: str,

View file

@ -10,6 +10,9 @@ class WaylandAdapter:
def start_cancel_listener(self, _callback: Callable[[], None]) -> None:
raise SystemExit("Wayland hotkeys are not supported yet.")
def stop_cancel_listener(self) -> None:
raise SystemExit("Wayland hotkeys are not supported yet.")
def inject_text(
self,
_text: str,

View file

@ -42,6 +42,9 @@ class X11Adapter:
self.indicator = None
self.status_icon = None
self.menu = None
self._cancel_listener_lock = threading.Lock()
self._cancel_listener_stop_event: threading.Event | None = None
self._cancel_listener_callback: Callable[[], None] | None = None
if AppIndicator3 is not None:
self.indicator = AppIndicator3.Indicator.new(
"aman",
@ -72,9 +75,35 @@ class X11Adapter:
def start_cancel_listener(self, callback: Callable[[], None]) -> None:
mods, keysym = self._parse_hotkey("Escape")
thread = threading.Thread(target=self._listen, args=(mods, keysym, callback), daemon=True)
with self._cancel_listener_lock:
if self._cancel_listener_stop_event is not None:
self._cancel_listener_callback = callback
return
stop_event = threading.Event()
self._cancel_listener_stop_event = stop_event
self._cancel_listener_callback = callback
thread = threading.Thread(
target=self._listen,
args=(mods, keysym, self._dispatch_cancel_listener, stop_event),
daemon=True,
)
thread.start()
def stop_cancel_listener(self) -> None:
with self._cancel_listener_lock:
stop_event = self._cancel_listener_stop_event
self._cancel_listener_stop_event = None
self._cancel_listener_callback = None
if stop_event is not None:
stop_event.set()
def _dispatch_cancel_listener(self) -> None:
with self._cancel_listener_lock:
callback = self._cancel_listener_callback
if callback is not None:
callback()
def inject_text(
self,
text: str,
@ -126,7 +155,14 @@ class X11Adapter:
finally:
self.request_quit()
def _listen(self, mods: int, keysym: int, callback: Callable[[], None]) -> None:
def _listen(
self,
mods: int,
keysym: int,
callback: Callable[[], None],
stop_event: threading.Event | None = None,
) -> None:
local_stop = stop_event or threading.Event()
disp = None
root = None
keycode = None
@ -134,14 +170,18 @@ class X11Adapter:
disp = display.Display()
root = disp.screen().root
keycode = self._grab_hotkey(disp, root, mods, keysym)
while True:
while not local_stop.is_set():
if disp.pending_events() == 0:
time.sleep(0.05)
continue
ev = disp.next_event()
if ev.type == X.KeyPress and ev.detail == keycode:
state = ev.state & ~(X.LockMask | X.Mod2Mask)
if state == mods:
callback()
except Exception as exc:
logging.error("hotkey listener stopped: %s", exc)
if not local_stop.is_set():
logging.error("hotkey listener stopped: %s", exc)
finally:
if root is not None and keycode is not None and disp is not None:
try:
@ -149,6 +189,11 @@ class X11Adapter:
disp.sync()
except Exception:
pass
if disp is not None:
try:
disp.close()
except Exception:
pass
def _parse_hotkey(self, hotkey: str):
mods = 0