_pynput_loaded = False Key = KeyCode = KeyboardListener = None def _ensure_pynput(): global _pynput_loaded, Key, KeyCode, KeyboardListener, _MODIFIER_MAP, _KEY_MAP if _pynput_loaded: return from pynput.keyboard import Listener as _Listener, Key as _Key, KeyCode as _KeyCode Key = _Key KeyCode = _KeyCode KeyboardListener = _Listener _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}, }) _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): _KEY_MAP[f"f{i}"] = getattr(Key, f"f{i}") _pynput_loaded = True _MODIFIER_MAP = {} _KEY_MAP = {} def _parse_hotkey(hotkey_str): """Parse hotkey string into (modifier_sets, trigger_key). Returns: (list of sets-of-pynput-keys for each modifier, pynput key for trigger) """ _ensure_pynput() parts = [p.strip().lower() for p in hotkey_str.split("+")] modifiers = [] for p in parts[:-1]: if p in _MODIFIER_MAP: modifiers.append(_MODIFIER_MAP[p]) elif p in _KEY_MAP: modifiers.append({_KEY_MAP[p]}) else: modifiers.append({KeyCode.from_char(p)}) trigger_part = parts[-1] if trigger_part in _KEY_MAP: trigger = _KEY_MAP[trigger_part] elif trigger_part in _MODIFIER_MAP: trigger = next(iter(_MODIFIER_MAP[trigger_part])) else: trigger = KeyCode.from_char(trigger_part) return modifiers, trigger class HotkeyListener: """Hold-to-record hotkey using pynput. No root required on X11.""" def __init__(self, hotkey_str, on_press, on_release): self._modifiers, self._trigger = _parse_hotkey(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 _matches_trigger(self, key): return key == self._trigger def _modifiers_held(self): return all(any(k in self._pressed for k in mod_set) for mod_set in self._modifiers) def _key_down(self, key): self._pressed.add(key) if not self._active and self._matches_trigger(key) and self._modifiers_held(): self._active = True self._on_press() def _key_up(self, key): self._pressed.discard(key) if self._active and self._matches_trigger(key): self._active = False self._on_release() def stop(self): self._listener.stop()