Upload
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user