whisper-dictation/whisper_app/hotkey.py

92 lines
3.2 KiB
Python

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