import os import threading # ── evdev key name → hotkey string mapping ─────────────────────────────────── _EVDEV_MODIFIER_MAP = { "ctrl": {"KEY_LEFTCTRL", "KEY_RIGHTCTRL"}, "ctrl_l": {"KEY_LEFTCTRL"}, "ctrl_r": {"KEY_RIGHTCTRL"}, "shift": {"KEY_LEFTSHIFT", "KEY_RIGHTSHIFT"}, "shift_l": {"KEY_LEFTSHIFT"}, "shift_r": {"KEY_RIGHTSHIFT"}, "alt": {"KEY_LEFTALT", "KEY_RIGHTALT"}, "alt_l": {"KEY_LEFTALT"}, "alt_r": {"KEY_RIGHTALT"}, } _EVDEV_KEY_MAP = { "space": "KEY_SPACE", "tab": "KEY_TAB", "enter": "KEY_ENTER", "esc": "KEY_ESC", "escape": "KEY_ESC", "up": "KEY_UP", "down": "KEY_DOWN", "left": "KEY_LEFT", "right": "KEY_RIGHT", "home": "KEY_HOME", "end": "KEY_END", "page_up": "KEY_PAGEUP", "page_down": "KEY_PAGEDOWN", "insert": "KEY_INSERT", "delete": "KEY_DELETE", "backspace": "KEY_BACKSPACE", } for _i in range(1, 13): _EVDEV_KEY_MAP[f"f{_i}"] = f"KEY_F{_i}" def _parse_hotkey_evdev(hotkey_str): """Parse hotkey string into evdev key code sets.""" parts = [p.strip().lower() for p in hotkey_str.split("+")] modifiers = [] for p in parts[:-1]: if p in _EVDEV_MODIFIER_MAP: modifiers.append(_EVDEV_MODIFIER_MAP[p]) elif p in _EVDEV_KEY_MAP: modifiers.append({_EVDEV_KEY_MAP[p]}) else: modifiers.append({f"KEY_{p.upper()}"}) trigger_part = parts[-1] if trigger_part in _EVDEV_KEY_MAP: trigger = _EVDEV_KEY_MAP[trigger_part] elif trigger_part in _EVDEV_MODIFIER_MAP: trigger = next(iter(_EVDEV_MODIFIER_MAP[trigger_part])) else: trigger = f"KEY_{trigger_part.upper()}" return modifiers, trigger def _can_use_evdev(): """Check if evdev is available and we have access to input devices.""" if os.name == "nt": return False try: import evdev devices = [evdev.InputDevice(p) for p in evdev.list_devices()] keyboards = [d for d in devices if 1 in d.capabilities()] # EV_KEY for d in devices: d.close() return len(keyboards) > 0 except Exception: return False class _EvdevListener: """Low-latency hotkey listener using evdev (Linux kernel input).""" def __init__(self, hotkey_str, on_press, on_release): self._modifiers, self._trigger = _parse_hotkey_evdev(hotkey_str) self._on_press = on_press self._on_release = on_release self._pressed = set() self._active = False self._stop_event = threading.Event() self._thread = threading.Thread(target=self._run, daemon=True) self._thread.start() def _run(self): import evdev import select devices = [evdev.InputDevice(p) for p in evdev.list_devices()] keyboards = [d for d in devices if 1 in d.capabilities()] # close non-keyboard devices for d in devices: if d not in keyboards: d.close() if not keyboards: return while not self._stop_event.is_set(): r, _, _ = select.select(keyboards, [], [], 0.5) for dev in r: try: for event in dev.read(): if event.type != 1: # EV_KEY continue code_name = evdev.ecodes.KEY.get(event.code) if isinstance(code_name, list): code_name = code_name[0] if code_name is None: continue if event.value == 1: # key down self._key_down(code_name) elif event.value == 0: # key up self._key_up(code_name) except OSError: pass for d in keyboards: d.close() def _key_down(self, code_name): self._pressed.add(code_name) if not self._active and code_name == self._trigger and self._modifiers_held(): self._active = True self._on_press() def _key_up(self, code_name): self._pressed.discard(code_name) if self._active and code_name == self._trigger: self._active = False self._on_release() def _modifiers_held(self): return all( any(k in self._pressed for k in mod_set) for mod_set in self._modifiers ) def stop(self): self._stop_event.set() # ── pynput fallback (Windows, or Linux without evdev/input group) ──────────── _pynput_loaded = False Key = KeyCode = KeyboardListener = None _PYNPUT_MODIFIER_MAP = {} _PYNPUT_KEY_MAP = {} def _ensure_pynput(): global _pynput_loaded, Key, KeyCode, KeyboardListener if _pynput_loaded: return from pynput.keyboard import Listener as _Listener, Key as _Key, KeyCode as _KeyCode Key = _Key KeyCode = _KeyCode KeyboardListener = _Listener _PYNPUT_MODIFIER_MAP.update({ "ctrl": {Key.ctrl_l, Key.ctrl_r}, "ctrl_l": {Key.ctrl_l}, "ctrl_r": {Key.ctrl_r}, "shift": {Key.shift_l, Key.shift_r}, "shift_l": {Key.shift_l}, "shift_r": {Key.shift_r}, "alt": {Key.alt_l, Key.alt_r}, "alt_l": {Key.alt_l}, "alt_r": {Key.alt_r}, }) _PYNPUT_KEY_MAP.update({ "space": Key.space, "tab": Key.tab, "enter": Key.enter, "esc": Key.esc, "escape": Key.esc, "up": Key.up, "down": Key.down, "left": Key.left, "right": Key.right, "home": Key.home, "end": Key.end, "page_up": Key.page_up, "page_down": Key.page_down, "insert": Key.insert, "delete": Key.delete, "backspace": Key.backspace, }) for i in range(1, 13): _PYNPUT_KEY_MAP[f"f{i}"] = getattr(Key, f"f{i}") _pynput_loaded = True def _parse_hotkey_pynput(hotkey_str): _ensure_pynput() parts = [p.strip().lower() for p in hotkey_str.split("+")] modifiers = [] for p in parts[:-1]: if p in _PYNPUT_MODIFIER_MAP: modifiers.append(_PYNPUT_MODIFIER_MAP[p]) elif p in _PYNPUT_KEY_MAP: modifiers.append({_PYNPUT_KEY_MAP[p]}) else: modifiers.append({KeyCode.from_char(p)}) trigger_part = parts[-1] if trigger_part in _PYNPUT_KEY_MAP: trigger = _PYNPUT_KEY_MAP[trigger_part] elif trigger_part in _PYNPUT_MODIFIER_MAP: trigger = next(iter(_PYNPUT_MODIFIER_MAP[trigger_part])) else: trigger = KeyCode.from_char(trigger_part) return modifiers, trigger class _PynputListener: """Hotkey listener using pynput (Windows / fallback).""" def __init__(self, hotkey_str, on_press, on_release): self._modifiers, self._trigger = _parse_hotkey_pynput(hotkey_str) self._on_press = on_press self._on_release = on_release self._pressed = set() self._active = False self._listener = KeyboardListener(on_press=self._key_down, on_release=self._key_up) self._listener.daemon = True self._listener.start() def _key_down(self, key): self._pressed.add(key) if not self._active and key == self._trigger and self._modifiers_held(): self._active = True self._on_press() def _key_up(self, key): self._pressed.discard(key) if self._active and key == self._trigger: self._active = False self._on_release() def _modifiers_held(self): return all(any(k in self._pressed for k in mod_set) for mod_set in self._modifiers) def stop(self): self._listener.stop() # ── Public API ─────────────────────────────────────────────────────────────── def HotkeyListener(hotkey_str, on_press, on_release): """Create the best available hotkey listener for the current platform.""" if _can_use_evdev(): return _EvdevListener(hotkey_str, on_press, on_release) return _PynputListener(hotkey_str, on_press, on_release)