from __future__ import annotations import logging import threading from typing import Callable from evdev import InputDevice, categorize, ecodes, list_devices from .input_devices import inaccessible_keyboard_names as _inaccessible_keyboard_names from .input_devices import is_keyboard as _is_keyboard logger = logging.getLogger(__name__) class HotkeyListener: """Reads raw key-down events from /dev/input via evdev and reports, on every key-down, the full set of currently-held keys plus which one was just pressed - so titles can be bound to multi-key combos (e.g. Ctrl+F1) that still fire while an unrelated key (a movement key, most likely) happens to also be held; see TitleTimerEngine.handle_hotkey for the actual matching rules. This works regardless of which Wayland compositor is running (unlike compositor-specific global-shortcut APIs), because it reads the input device directly rather than going through the window system. The user's account needs read access to /dev/input/event*, i.e. membership in the `input` group. """ def __init__(self, on_hotkey: Callable[[frozenset[str], str], None]): self._on_hotkey = on_hotkey self._threads: list[threading.Thread] = [] self._stop = threading.Event() # Shared across every keyboard-reading thread (in case someone has # more than one keyboard plugged in) - a combo could span devices. self._held: set[str] = set() self._lock = threading.Lock() def start(self) -> None: devices = [InputDevice(path) for path in list_devices()] logger.debug( "Input devices seen: %s", [(d.path, d.name) for d in devices], ) missing = _inaccessible_keyboard_names() if missing: logger.warning( "Kernel reports keyboard device(s) that this process can't " "actually open (permission denied): %s. These will NOT " "receive hotkeys. If you just added your user to the " "'input' group, a full logout/login is required for that " "to take effect - a new terminal in the same session isn't " "enough.", missing, ) keyboards = [d for d in devices if _is_keyboard(d)] if not keyboards: raise RuntimeError( "No keyboard-like input device found under /dev/input. " "Make sure your user is in the 'input' group (and re-login)." ) logger.info( "Listening on %d keyboard device(s): %s", len(keyboards), [(d.path, d.name) for d in keyboards], ) for dev in keyboards: t = threading.Thread(target=self._read_loop, args=(dev,), daemon=True) t.start() self._threads.append(t) def stop(self) -> None: self._stop.set() def _read_loop(self, dev: InputDevice) -> None: try: for event in dev.read_loop(): if self._stop.is_set(): return if event.type != ecodes.EV_KEY: continue key_event = categorize(event) keycode = key_event.keycode # evdev sometimes reports a list of aliases for one scancode name = keycode[0] if isinstance(keycode, list) else keycode with self._lock: if key_event.keystate == key_event.key_down: self._held.add(name) held = frozenset(self._held) elif key_event.keystate == key_event.key_up: self._held.discard(name) continue else: continue logger.debug("Key down: %s (device %s), held=%s", name, dev.path, held) self._on_hotkey(held, name) except OSError: logger.warning("Input device %s disconnected", dev.path)