Repository navigation
Expand file tree
/
Copy pathhotkey_util.py
More file actions
193 lines (156 loc) Β· 6.48 KB
/
Copy pathhotkey_util.py
File metadata and controls
193 lines (156 loc) Β· 6.48 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
"""Hotkey serialisation utilities.
Converts pynput Key/KeyCode objects to persistent strings and back,
and provides human-readable display names for the settings UI.
Hotkeys can be:
- A single pynput Key or KeyCode (e.g. Key.alt_gr, Key.f9)
- A combo tuple: (frozenset_of_modifier_strings, trigger_key)
e.g. (frozenset({"ctrl", "shift"}), KeyCode.from_vk(82)) for Ctrl+Shift+R
"""
from pynput.keyboard import Key, KeyCode
# ββ Modifier classification βββββββββββββββββββββββββββββββββββββββββββββββ
# Key.alt_gr is intentionally absent β it acts as a trigger key, not a modifier.
_MODIFIER_MAP: dict = {
Key.ctrl: "ctrl", Key.ctrl_l: "ctrl", Key.ctrl_r: "ctrl",
Key.shift: "shift", Key.shift_l: "shift", Key.shift_r: "shift",
Key.alt: "alt", Key.alt_l: "alt", Key.alt_r: "alt",
Key.cmd: "win", Key.cmd_l: "win", Key.cmd_r: "win",
}
def canonical_modifier(key) -> "str | None":
"""Return canonical modifier name ("ctrl"/"shift"/"alt"/"win") or None."""
return _MODIFIER_MAP.get(key)
# ββ Key equality ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def keys_match(a, b) -> bool:
"""Compare two single pynput keys, normalising KeyCode by VK code."""
if a == b:
return True
if isinstance(a, KeyCode) and isinstance(b, KeyCode):
if a.vk is not None and b.vk is not None:
return a.vk == b.vk
return False
def hotkeys_equal(a, b) -> bool:
"""Compare two hotkeys (single key or combo tuple) for equality."""
if isinstance(a, tuple) and isinstance(b, tuple):
return a[0] == b[0] and keys_match(a[1], b[1])
if isinstance(a, tuple) or isinstance(b, tuple):
return False
return keys_match(a, b)
# ββ Serialisation βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _single_key_to_str(k) -> str:
if isinstance(k, Key):
return f"Key.{k.name}"
if isinstance(k, KeyCode):
if k.vk is not None:
return f"KeyCode({k.vk})"
if k.char is not None:
return f"Char({k.char})"
return str(k)
def key_to_str(k) -> str:
"""Convert a hotkey (single key or combo tuple) to a storable string."""
if isinstance(k, tuple):
mods, trigger = k
mod_str = "+".join(sorted(mods))
return f"Combo:{mod_str}:{_single_key_to_str(trigger)}"
return _single_key_to_str(k)
def _parse_single_key(s: str):
if not s:
return None
if s.startswith("Key."):
return getattr(Key, s[4:], None)
if s.startswith("KeyCode(") and s.endswith(")"):
try:
return KeyCode.from_vk(int(s[8:-1]))
except (ValueError, TypeError):
return None
if s.startswith("Char(") and s.endswith(")"):
ch = s[5:-1]
return KeyCode.from_char(ch) if ch else None
return None
def str_to_key(s: str):
"""Convert a stored string back to a hotkey (single key or combo tuple).
Returns None if the string cannot be parsed.
"""
if not s:
return None
if s.startswith("Combo:"):
rest = s[6:]
colon_idx = rest.rfind(":")
if colon_idx < 0:
return None
mod_str, key_str = rest[:colon_idx], rest[colon_idx + 1:]
mods = frozenset(m for m in mod_str.split("+") if m)
trigger = _parse_single_key(key_str)
if trigger is None:
return None
return (mods, trigger)
return _parse_single_key(s)
# ββ Display names βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
_SPECIAL_NAMES: dict = {
Key.alt_gr: "AltGr",
Key.alt_l: "Alt Left",
Key.alt_r: "Alt Right",
Key.ctrl_l: "Ctrl Left",
Key.ctrl_r: "Ctrl Right",
Key.shift_l: "Shift Left",
Key.shift_r: "Shift Right",
Key.space: "Space",
Key.enter: "Enter",
Key.tab: "Tab",
Key.esc: "Esc",
Key.backspace: "Backspace",
Key.delete: "Delete",
Key.insert: "Insert",
Key.home: "Home",
Key.end: "End",
Key.page_up: "Page Up",
Key.page_down: "Page Down",
Key.caps_lock: "Caps Lock",
Key.num_lock: "Num Lock",
Key.scroll_lock: "Scroll Lock",
Key.pause: "Pause",
Key.print_screen: "Print Screen",
Key.menu: "Menu",
}
for _i in range(1, 21):
_k = getattr(Key, f"f{_i}", None)
if _k:
_SPECIAL_NAMES[_k] = f"F{_i}"
_MOD_DISPLAY = {"ctrl": "Ctrl", "shift": "Shift", "alt": "Alt", "win": "Win"}
_MOD_ORDER = {"ctrl": 0, "shift": 1, "alt": 2, "win": 3}
def _trigger_display(k) -> str:
"""Human-readable name for a single trigger key."""
if isinstance(k, Key):
return _SPECIAL_NAMES.get(k, k.name.replace("_", " ").title())
if isinstance(k, KeyCode):
if k.char and k.char.isprintable():
return k.char.upper()
if k.vk is not None:
if 65 <= k.vk <= 90:
return chr(k.vk) # A-Z
if 48 <= k.vk <= 57:
return chr(k.vk) # 0-9
return f"Key {k.vk}"
return str(k)
def key_display_name(k) -> str:
"""Return a human-readable name for a hotkey (single key or combo tuple)."""
if isinstance(k, tuple):
mods, trigger = k
sorted_mods = sorted(mods, key=lambda m: _MOD_ORDER.get(m, 99))
parts = [_MOD_DISPLAY.get(m, m.title()) for m in sorted_mods]
parts.append(_trigger_display(trigger))
return "+".join(parts)
return _trigger_display(k)
# ββ Blacklist (keys that should not be used as hotkeys) βββββββββββββββββββ
_BLOCKED_KEYS: set = {
Key.enter, Key.space, Key.backspace, Key.delete, Key.tab, Key.esc,
Key.shift_l, Key.shift_r,
}
def is_blocked(k) -> bool:
"""Return True if the key should not be assignable as a hotkey."""
if isinstance(k, tuple):
return False # combos are always allowed
if isinstance(k, Key):
return k in _BLOCKED_KEYS
if isinstance(k, KeyCode):
if k.char and k.char.isalnum():
return True
return False