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()