80bef6f524
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
106 lines
3.5 KiB
Python
106 lines
3.5 KiB
Python
"""Global push-to-talk hotkey.
|
|
|
|
The wake word is the primary trigger, but it misfires in a noisy room and
|
|
won't fire at all if you're on a call — so there's a keyboard fallback that
|
|
works even when the pet has no focus (it's a frameless Qt.Tool window with no
|
|
taskbar entry, so an ordinary QShortcut would never see the key).
|
|
|
|
Needs `pynput`, which needs an X11/Win32/macOS input hook: on most Wayland
|
|
sessions it can't grab global keys, and on macOS it needs Accessibility
|
|
permission. All of that is a soft failure — start() reports the reason and
|
|
the wake word / tray keep working.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Callable, Optional
|
|
|
|
# Aliases for the names people actually type in a .env file.
|
|
_ALIASES = {
|
|
"control": "ctrl",
|
|
"ctl": "ctrl",
|
|
"option": "alt",
|
|
"opt": "alt",
|
|
"win": "cmd",
|
|
"windows": "cmd",
|
|
"super": "cmd",
|
|
"meta": "cmd",
|
|
"command": "cmd",
|
|
"return": "enter",
|
|
"escape": "esc",
|
|
"del": "delete",
|
|
"ins": "insert",
|
|
"pgup": "page_up",
|
|
"pgdn": "page_down",
|
|
}
|
|
|
|
|
|
class HotkeyError(Exception):
|
|
pass
|
|
|
|
|
|
def to_pynput_spec(spec: str) -> str:
|
|
""""ctrl+alt+space" -> "<ctrl>+<alt>+<space>" (pynput's GlobalHotKeys
|
|
syntax: named keys in angle brackets, literal characters bare)."""
|
|
tokens = [t.strip().lower() for t in (spec or "").split("+")]
|
|
tokens = [t for t in tokens if t]
|
|
if not tokens:
|
|
raise HotkeyError("empty hotkey")
|
|
parts = []
|
|
for token in tokens:
|
|
token = _ALIASES.get(token, token)
|
|
parts.append(token if len(token) == 1 else f"<{token}>")
|
|
return "+".join(parts)
|
|
|
|
|
|
class GlobalHotkey:
|
|
"""Fires *callback* whenever the hotkey is pressed, anywhere. Safe to
|
|
construct unconditionally — nothing happens until start(), and start()
|
|
reports failure instead of raising into the UI thread."""
|
|
|
|
def __init__(self, spec: str, callback: Callable[[], None]):
|
|
self.spec = spec
|
|
self._callback = callback
|
|
self._listener = None
|
|
|
|
@property
|
|
def running(self) -> bool:
|
|
return self._listener is not None
|
|
|
|
def start(self) -> Optional[str]:
|
|
"""None on success, otherwise a human-readable reason it's off."""
|
|
if not (self.spec or "").strip():
|
|
return None # explicitly disabled — not an error worth reporting
|
|
try:
|
|
pynput_spec = to_pynput_spec(self.spec)
|
|
except HotkeyError as exc:
|
|
return f"push-to-talk hotkey {self.spec!r} is invalid: {exc}"
|
|
try:
|
|
from pynput import keyboard
|
|
except Exception as exc: # ImportError, or a backend that won't load
|
|
return f"push-to-talk needs pynput ({exc}) — wake word still works"
|
|
try:
|
|
listener = keyboard.GlobalHotKeys({pynput_spec: self._safe_callback})
|
|
listener.daemon = True
|
|
listener.start()
|
|
except Exception as exc:
|
|
return f"push-to-talk unavailable on this session ({exc}) — wake word still works"
|
|
self._listener = listener
|
|
return None
|
|
|
|
def _safe_callback(self) -> None:
|
|
# This runs on pynput's listener thread; an exception there would
|
|
# silently kill the listener for the rest of the session.
|
|
try:
|
|
self._callback()
|
|
except Exception:
|
|
pass
|
|
|
|
def stop(self) -> None:
|
|
if self._listener is not None:
|
|
try:
|
|
self._listener.stop()
|
|
except Exception:
|
|
pass
|
|
self._listener = None
|