69 lines
2.8 KiB
Python
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()
|