Initial commit: Korin Timer
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user