Files
korin-timer/src/titletimer/hotkey_capture.py
T
magges 7edbd09538
Build AppImage / build-appimage (push) Successful in 42s
Initial commit: Korin Timer
2026-08-03 20:42:26 +02:00

69 lines
2.8 KiB
Python

from __future__ import annotations
import selectors
import threading
from typing import Callable
from evdev import InputDevice, categorize, ecodes, list_devices
from .input_devices import is_keyboard
_CANCEL_KEY = "KEY_ESC"
def capture_next_combo(on_captured: Callable[[tuple[str, ...] | None], None]) -> None:
"""Wait in a background thread for the user to press (and release) a
key combo and report it as a sorted tuple of evdev key names - Escape
pressed alone reports None (cancelled) instead.
Tracks every key held down until they're *all* released again, using
the peak simultaneously-held set as the captured combo - so e.g.
holding Ctrl then F1 then releasing both captures ("KEY_F1",
"KEY_LEFTCTRL"), not just whichever key happened to complete the chord.
Used by the settings window's hotkey "click to record" buttons: since
hotkeys are matched by this same evdev keycode naming (see hotkeys.py/
state.py), capturing it directly avoids the user needing to know
evdev's naming (KEY_F1, KEY_KP1, ...) to type it in by hand.
`on_captured` runs on this background thread, not the GTK main thread -
callers touching GTK widgets from it must marshal via GLib.idle_add.
"""
def _run() -> None:
keyboards = [d for d in (InputDevice(p) for p in list_devices()) if is_keyboard(d)]
if not keyboards:
on_captured(None)
return
sel = selectors.DefaultSelector()
for dev in keyboards:
sel.register(dev, selectors.EVENT_READ)
held: set[str] = set()
peak: set[str] = set()
try:
while True:
for key, _mask in sel.select():
dev = key.fileobj
for raw_event in dev.read():
if raw_event.type != ecodes.EV_KEY:
continue
key_event = categorize(raw_event)
keycode = key_event.keycode
name = keycode[0] if isinstance(keycode, list) else keycode
if key_event.keystate == key_event.key_down:
held.add(name)
peak |= held
elif key_event.keystate == key_event.key_up:
held.discard(name)
if not held and peak:
if peak == {_CANCEL_KEY}:
on_captured(None)
else:
on_captured(tuple(sorted(peak)))
return
finally:
sel.close()
for dev in keyboards:
dev.close()
threading.Thread(target=_run, daemon=True).start()