|
5 | 5 | if TYPE_CHECKING:
|
6 | 6 | from AutoSplit import AutoSplit
|
7 | 7 |
|
| 8 | +import locale |
8 | 9 | import threading
|
9 |
| -from keyboard._keyboard_event import KeyboardEvent, KEY_DOWN |
| 10 | +# MUST be done before keyboard is initialized |
| 11 | +locale.setlocale(locale.LC_ALL, "en_US") |
| 12 | + |
10 | 13 | import keyboard # https://github.com/boppreh/keyboard/issues/505
|
11 | 14 | import pyautogui # https://github.com/asweigart/pyautogui/issues/645
|
12 | 15 | # While not usually recommended, we don'thread manipulate the mouse, and we don'thread want the extra delay
|
13 | 16 | pyautogui.FAILSAFE = False
|
14 | 17 |
|
| 18 | + |
15 | 19 | SET_HOTKEY_TEXT = "Set Hotkey"
|
16 | 20 | PRESS_A_KEY_TEXT = "Press a key..."
|
17 | 21 |
|
| 22 | +HotkeysType = Literal["split", "reset", "skip_split", "undo_split", "pause"] |
| 23 | +HOTKEYS: list[HotkeysType] = ["split", "reset", "skip_split", "undo_split", "pause"] |
18 | 24 |
|
19 | 25 | # do all of these after you click "Set Hotkey" but before you type the hotkey.
|
| 26 | + |
| 27 | + |
20 | 28 | def before_setting_hotkey(autosplit: AutoSplit):
|
21 | 29 | autosplit.start_auto_splitter_button.setEnabled(False)
|
22 | 30 | if autosplit.SettingsWidget:
|
23 |
| - autosplit.SettingsWidget.set_split_hotkey_button.setEnabled(False) |
24 |
| - autosplit.SettingsWidget.set_reset_hotkey_button.setEnabled(False) |
25 |
| - autosplit.SettingsWidget.set_skip_split_hotkey_button.setEnabled(False) |
26 |
| - autosplit.SettingsWidget.set_undo_split_hotkey_button.setEnabled(False) |
27 |
| - autosplit.SettingsWidget.set_pause_hotkey_button.setEnabled(False) |
| 31 | + for hotkey in HOTKEYS: |
| 32 | + getattr(autosplit.SettingsWidget, f"set_{hotkey}_hotkey_button").setEnabled(False) |
28 | 33 |
|
29 | 34 |
|
30 | 35 | # do all of these things after you set a hotkey. a signal connects to this because
|
31 | 36 | # changing GUI stuff in the hotkey thread was causing problems
|
32 | 37 | def after_setting_hotkey(autosplit: AutoSplit):
|
33 | 38 | autosplit.start_auto_splitter_button.setEnabled(True)
|
34 | 39 | if autosplit.SettingsWidget:
|
35 |
| - autosplit.SettingsWidget.set_split_hotkey_button.setText(SET_HOTKEY_TEXT) |
36 |
| - autosplit.SettingsWidget.set_reset_hotkey_button.setText(SET_HOTKEY_TEXT) |
37 |
| - autosplit.SettingsWidget.set_skip_split_hotkey_button.setText(SET_HOTKEY_TEXT) |
38 |
| - autosplit.SettingsWidget.set_undo_split_hotkey_button.setText(SET_HOTKEY_TEXT) |
39 |
| - autosplit.SettingsWidget.set_pause_hotkey_button.setText(SET_HOTKEY_TEXT) |
40 |
| - autosplit.SettingsWidget.set_split_hotkey_button.setEnabled(True) |
41 |
| - autosplit.SettingsWidget.set_reset_hotkey_button.setEnabled(True) |
42 |
| - autosplit.SettingsWidget.set_skip_split_hotkey_button.setEnabled(True) |
43 |
| - autosplit.SettingsWidget.set_undo_split_hotkey_button.setEnabled(True) |
44 |
| - autosplit.SettingsWidget.set_pause_hotkey_button.setEnabled(True) |
| 40 | + for hotkey in HOTKEYS: |
| 41 | + getattr(autosplit.SettingsWidget, f"set_{hotkey}_hotkey_button").setText(SET_HOTKEY_TEXT) |
| 42 | + getattr(autosplit.SettingsWidget, f"set_{hotkey}_hotkey_button").setEnabled(True) |
45 | 43 |
|
46 | 44 |
|
47 | 45 | def is_digit(key: Optional[str]):
|
@@ -74,33 +72,35 @@ def send_command(autosplit: AutoSplit, command: Commands):
|
74 | 72 | raise KeyError(f"'{command}' is not a valid LiveSplit.AutoSplitIntegration command")
|
75 | 73 |
|
76 | 74 |
|
77 |
| -def _unhook(hotkey: Optional[Callable[[], None]]): |
| 75 | +def _unhook(hotkey_callback: Optional[Callable[[], None]]): |
78 | 76 | try:
|
79 |
| - if hotkey: |
80 |
| - keyboard.unhook_key(hotkey) |
| 77 | + if hotkey_callback: |
| 78 | + keyboard.unhook_key(hotkey_callback) |
81 | 79 | except (AttributeError, KeyError, ValueError):
|
82 | 80 | pass
|
83 | 81 |
|
84 | 82 |
|
85 |
| -def _send_hotkey(key_or_scan_code: Union[int, str]): |
| 83 | +def _send_hotkey(hotkey_or_scan_code: Union[int, str, None]): |
86 | 84 | """
|
87 | 85 | Supports sending the appropriate scan code for all the special cases
|
88 | 86 | """
|
89 |
| - if not key_or_scan_code: |
| 87 | + if not hotkey_or_scan_code: |
90 | 88 | return
|
91 | 89 |
|
92 | 90 | # Deal with regular inputs
|
93 |
| - if isinstance(key_or_scan_code, int) \ |
94 |
| - or not (key_or_scan_code.startswith("num ") or key_or_scan_code == "decimal"): |
95 |
| - keyboard.send(key_or_scan_code) |
| 91 | + # If an int or does not contain the following strings |
| 92 | + if isinstance(hotkey_or_scan_code, int) \ |
| 93 | + or not ("num " in hotkey_or_scan_code or "decimal" in hotkey_or_scan_code or "+" in hotkey_or_scan_code): |
| 94 | + keyboard.send(hotkey_or_scan_code) |
96 | 95 | return
|
97 | 96 |
|
98 | 97 | # Deal with problematic keys. Even by sending specific scan code "keyboard" still sends the default (wrong) key
|
| 98 | + # keyboard also has issues with capitalization modifier (shift+A) |
99 | 99 | # keyboard.send(keyboard.key_to_scan_codes(key_or_scan_code)[1])
|
100 |
| - pyautogui.hotkey(key_or_scan_code.replace(" ", "")) |
| 100 | + pyautogui.hotkey(*hotkey_or_scan_code.replace(" ", "").split("+")) |
101 | 101 |
|
102 | 102 |
|
103 |
| -def __validate_keypad(expected_key: str, keyboard_event: KeyboardEvent) -> bool: |
| 103 | +def __validate_keypad(expected_key: str, keyboard_event: keyboard.KeyboardEvent) -> bool: |
104 | 104 | # Prevent "(keypad)delete", "(keypad)./decimal" and "del" from triggering each other
|
105 | 105 | # as well as "." and "(keypad)./decimal"
|
106 | 106 | if keyboard_event.scan_code in {83, 52}:
|
@@ -129,191 +129,114 @@ def __validate_keypad(expected_key: str, keyboard_event: KeyboardEvent) -> bool:
|
129 | 129 |
|
130 | 130 | # Since we reuse the key string we set to send to LiveSplit, we can'thread use fake names like "num home".
|
131 | 131 | # We're also trying to achieve the same hotkey behaviour as LiveSplit has.
|
132 |
| -def _hotkey_action(keyboard_event: KeyboardEvent, key_name: str, action: Callable[[], None]): |
133 |
| - if keyboard_event.event_type == KEY_DOWN and __validate_keypad(key_name, keyboard_event): |
| 132 | +def _hotkey_action(keyboard_event: keyboard.KeyboardEvent, key_name: str, action: Callable[[], None]): |
| 133 | + if keyboard_event.event_type == keyboard.KEY_DOWN and __validate_keypad(key_name, keyboard_event): |
134 | 134 | action()
|
135 | 135 |
|
136 | 136 |
|
137 |
| -def __get_key_name(keyboard_event: KeyboardEvent): |
| 137 | +def __get_key_name(keyboard_event: keyboard.KeyboardEvent): |
| 138 | + """ |
| 139 | + Ensures proper keypad name |
| 140 | + """ |
| 141 | + event_name = str(keyboard_event.name) |
| 142 | + # Normally this is done by keyboard.get_hotkey_name. But our code won't always get there. |
| 143 | + if event_name == "+": |
| 144 | + return "plus" |
138 | 145 | return f"num {keyboard_event.name}" \
|
139 | 146 | if keyboard_event.is_keypad and is_digit(keyboard_event.name) \
|
140 |
| - else str(keyboard_event.name) |
| 147 | + else event_name |
| 148 | + |
| 149 | + |
| 150 | +def __get_hotkey_name(names: list[str]): |
| 151 | + """ |
| 152 | + Uses keyboard.get_hotkey_name but works with non-english modifiers and keypad |
| 153 | + # See: https://github.com/boppreh/keyboard/issues/516 |
| 154 | + """ |
| 155 | + def sorting_key(key: str): |
| 156 | + return not keyboard.is_modifier(keyboard.key_to_scan_codes(key)[0]) |
| 157 | + |
| 158 | + if len(names) == 1: |
| 159 | + return names[0] |
| 160 | + clean_names = sorted(keyboard.get_hotkey_name(names).split("+"), key=sorting_key) |
| 161 | + # Replace the last key in hotkey_name with what we actually got as a last key_name |
| 162 | + # This ensures we keep proper keypad names |
| 163 | + return "+".join(clean_names[:-1] + names[-1:]) |
| 164 | + |
| 165 | + |
| 166 | +def __read_hotkey(): |
| 167 | + """ |
| 168 | + Blocks until a hotkey combination is read. |
| 169 | + Returns the hotkey_name and last KeyboardEvent |
| 170 | + """ |
| 171 | + names: list[str] = [] |
| 172 | + while True: |
| 173 | + keyboard_event = keyboard.read_event(True) |
| 174 | + # LiveSplit supports modifier keys as the last key, so any keyup means end of hotkey |
| 175 | + if keyboard_event.event_type == keyboard.KEY_UP: |
| 176 | + break |
| 177 | + key_name = __get_key_name(keyboard_event) |
| 178 | + print(key_name) |
| 179 | + # Ignore long presses |
| 180 | + if names and names[-1] == key_name: |
| 181 | + continue |
| 182 | + names.append(__get_key_name(keyboard_event)) |
| 183 | + # Stop at the first non-modifier to prevent registering a hotkey with multiple regular keys |
| 184 | + if not keyboard.is_modifier(keyboard_event.scan_code): |
| 185 | + break |
| 186 | + return __get_hotkey_name(names) |
141 | 187 |
|
142 | 188 |
|
143 | 189 | def __is_key_already_set(autosplit: AutoSplit, key_name: str):
|
144 |
| - return key_name in (autosplit.settings_dict["split_hotkey"], |
145 |
| - autosplit.settings_dict["reset_hotkey"], |
146 |
| - autosplit.settings_dict["skip_split_hotkey"], |
147 |
| - autosplit.settings_dict["undo_split_hotkey"], |
148 |
| - autosplit.settings_dict["pause_hotkey"]) |
| 190 | + return key_name in [autosplit.settings_dict[f"{hotkey}_hotkey"] for hotkey in HOTKEYS] |
149 | 191 |
|
| 192 | +# TODO: using getattr/setattr is NOT a good way to go about this. It was only temporarily done to |
| 193 | +# reduce duplicated code. We should use a dictionary of hotkey class or something. |
150 | 194 |
|
151 |
| -# --------------------HOTKEYS-------------------------- |
152 |
| -# TODO: Refactor to de-duplicate all this code, including settings_file.py |
153 |
| -# Going to comment on one func, and others will be similar. |
154 |
| -def set_split_hotkey(autosplit: AutoSplit, preselected_key: str = ""): |
| 195 | + |
| 196 | +def set_hotkey(autosplit: AutoSplit, hotkey: HotkeysType, preselected_hotkey_name: str = ""): |
155 | 197 | if autosplit.SettingsWidget:
|
156 |
| - autosplit.SettingsWidget.set_split_hotkey_button.setText(PRESS_A_KEY_TEXT) |
| 198 | + getattr(autosplit.SettingsWidget, f"set_{hotkey}_hotkey_button").setText(PRESS_A_KEY_TEXT) |
157 | 199 |
|
158 | 200 | # disable some buttons
|
159 | 201 | before_setting_hotkey(autosplit)
|
160 | 202 |
|
161 | 203 | # new thread points to callback. this thread is needed or GUI will freeze
|
162 | 204 | # while the program waits for user input on the hotkey
|
163 | 205 | def callback():
|
164 |
| - # use the selected key OR |
165 |
| - # wait until user presses the hotkey, then keyboard module reads the input |
166 |
| - key_name = preselected_key if preselected_key else __get_key_name(keyboard.read_event(True)) |
167 |
| - try: |
168 |
| - # If the key the user presses is equal to itself or another hotkey already set, |
169 |
| - # this causes issues. so here, it catches that, and will make no changes to the hotkey. |
170 |
| - |
171 |
| - # or |
172 |
| - |
173 |
| - # keyboard module allows you to hit multiple keys for a hotkey. they are joined |
174 |
| - # together by +. If user hits two keys at the same time, make no changes to the |
175 |
| - # hotkey. A try and except is needed if a hotkey hasn'thread been set yet. I'm not |
176 |
| - # allowing for these multiple-key hotkeys because it can cause crashes, and |
177 |
| - # not many people are going to really use or need this. |
178 |
| - if __is_key_already_set(autosplit, key_name) or (key_name != "+" and "+" in key_name): |
179 |
| - autosplit.after_setting_hotkey_signal.emit() |
180 |
| - return |
181 |
| - except AttributeError: |
| 206 | + hotkey_name = preselected_hotkey_name if preselected_hotkey_name else __read_hotkey() |
| 207 | + |
| 208 | + # If the key the user presses is equal to itself or another hotkey already set, |
| 209 | + # this causes issues. so here, it catches that, and will make no changes to the hotkey. |
| 210 | + if __is_key_already_set(autosplit, hotkey_name): |
182 | 211 | autosplit.after_setting_hotkey_signal.emit()
|
183 | 212 | return
|
184 | 213 |
|
185 |
| - # add the key as the hotkey, set the text into the _input, set it as old_xxx_key, |
186 |
| - # then emite a signal to re-enable some buttons and change some text in GUI. |
187 |
| - |
188 | 214 | # We need to inspect the event to know if it comes from numpad because of _canonial_names.
|
189 | 215 | # See: https://github.com/boppreh/keyboard/issues/161#issuecomment-386825737
|
190 | 216 | # The best way to achieve this is make our own hotkey handling on top of hook
|
191 | 217 | # See: https://github.com/boppreh/keyboard/issues/216#issuecomment-431999553
|
192 |
| - autosplit.split_hotkey = keyboard.hook_key( |
193 |
| - key_name, |
194 |
| - lambda error: _hotkey_action(error, key_name, autosplit.start_auto_splitter)) |
195 |
| - if autosplit.SettingsWidget: |
196 |
| - autosplit.SettingsWidget.split_input.setText(key_name) |
197 |
| - autosplit.settings_dict["split_hotkey"] = key_name |
198 |
| - autosplit.after_setting_hotkey_signal.emit() |
| 218 | + action = autosplit.start_auto_splitter if hotkey == "split" else getattr(autosplit, f"{hotkey}_signal").emit |
| 219 | + setattr( |
| 220 | + autosplit, |
| 221 | + f"{hotkey}_hotkey", |
| 222 | + # keyboard.add_hotkey doesn't give the last keyboard event, so we can't __validate_keypad |
| 223 | + # This means "ctrl + num 5" and "ctrl + 5" will both be registered |
| 224 | + # keyboard module allows you to hit multiple keys for a hotkey. they are joined together by +. |
| 225 | + keyboard.add_hotkey( |
| 226 | + hotkey_name, |
| 227 | + action) |
| 228 | + if "+" in hotkey_name |
| 229 | + else keyboard.hook_key( |
| 230 | + hotkey_name, |
| 231 | + lambda keyboard_event: _hotkey_action(keyboard_event, hotkey_name, action)) |
| 232 | + ) |
199 | 233 |
|
200 |
| - # try to remove the previously set hotkey if there is one. |
201 |
| - _unhook(autosplit.split_hotkey) |
202 |
| - thread = threading.Thread(target=callback) |
203 |
| - thread.start() |
204 |
| - |
205 |
| - |
206 |
| -def set_reset_hotkey(autosplit: AutoSplit, preselected_key: str = ""): |
207 |
| - if autosplit.SettingsWidget: |
208 |
| - autosplit.SettingsWidget.set_reset_hotkey_button.setText(PRESS_A_KEY_TEXT) |
209 |
| - before_setting_hotkey(autosplit) |
210 |
| - |
211 |
| - def callback(): |
212 |
| - key_name = preselected_key if preselected_key else __get_key_name(keyboard.read_event(True)) |
213 |
| - |
214 |
| - try: |
215 |
| - if __is_key_already_set(autosplit, key_name) or (key_name != "+" and "+" in key_name): |
216 |
| - autosplit.after_setting_hotkey_signal.emit() |
217 |
| - return |
218 |
| - except AttributeError: |
219 |
| - autosplit.after_setting_hotkey_signal.emit() |
220 |
| - return |
221 |
| - |
222 |
| - autosplit.reset_hotkey = keyboard.hook_key( |
223 |
| - key_name, |
224 |
| - lambda error: _hotkey_action(error, key_name, autosplit.reset_signal.emit)) |
225 | 234 | if autosplit.SettingsWidget:
|
226 |
| - autosplit.SettingsWidget.reset_input.setText(key_name) |
227 |
| - autosplit.settings_dict["reset_hotkey"] = key_name |
| 235 | + getattr(autosplit.SettingsWidget, f"{hotkey}_input").setText(hotkey_name) |
| 236 | + autosplit.settings_dict[f"{hotkey}_hotkey"] = hotkey_name |
228 | 237 | autosplit.after_setting_hotkey_signal.emit()
|
229 | 238 |
|
230 |
| - _unhook(autosplit.reset_hotkey) |
231 |
| - thread = threading.Thread(target=callback) |
232 |
| - thread.start() |
233 |
| - |
234 |
| - |
235 |
| -def set_skip_split_hotkey(autosplit: AutoSplit, preselected_key: str = ""): |
236 |
| - if autosplit.SettingsWidget: |
237 |
| - autosplit.SettingsWidget.set_skip_split_hotkey_button.setText(PRESS_A_KEY_TEXT) |
238 |
| - before_setting_hotkey(autosplit) |
239 |
| - |
240 |
| - def callback(): |
241 |
| - key_name = preselected_key if preselected_key else __get_key_name(keyboard.read_event(True)) |
242 |
| - |
243 |
| - try: |
244 |
| - if __is_key_already_set(autosplit, key_name) or (key_name != "+" and "+" in key_name): |
245 |
| - autosplit.after_setting_hotkey_signal.emit() |
246 |
| - return |
247 |
| - except AttributeError: |
248 |
| - autosplit.after_setting_hotkey_signal.emit() |
249 |
| - return |
250 |
| - |
251 |
| - autosplit.skip_split_hotkey = keyboard.hook_key( |
252 |
| - key_name, |
253 |
| - lambda error: _hotkey_action(error, key_name, autosplit.skip_split_signal.emit)) |
254 |
| - if autosplit.SettingsWidget: |
255 |
| - autosplit.SettingsWidget.skip_split_input.setText(key_name) |
256 |
| - autosplit.settings_dict["skip_split_hotkey"] = key_name |
257 |
| - autosplit.after_setting_hotkey_signal.emit() |
258 |
| - |
259 |
| - _unhook(autosplit.skip_split_hotkey) |
260 |
| - thread = threading.Thread(target=callback) |
261 |
| - thread.start() |
262 |
| - |
263 |
| - |
264 |
| -def set_undo_split_hotkey(autosplit: AutoSplit, preselected_key: str = ""): |
265 |
| - if autosplit.SettingsWidget: |
266 |
| - autosplit.SettingsWidget.set_undo_split_hotkey_button.setText(PRESS_A_KEY_TEXT) |
267 |
| - before_setting_hotkey(autosplit) |
268 |
| - |
269 |
| - def callback(): |
270 |
| - key_name = preselected_key if preselected_key else __get_key_name(keyboard.read_event(True)) |
271 |
| - |
272 |
| - try: |
273 |
| - if __is_key_already_set(autosplit, key_name) or (key_name != "+" and "+" in key_name): |
274 |
| - autosplit.after_setting_hotkey_signal.emit() |
275 |
| - return |
276 |
| - except AttributeError: |
277 |
| - autosplit.after_setting_hotkey_signal.emit() |
278 |
| - return |
279 |
| - |
280 |
| - autosplit.undo_split_hotkey = keyboard.hook_key( |
281 |
| - key_name, |
282 |
| - lambda error: _hotkey_action(error, key_name, autosplit.undo_split_signal.emit)) |
283 |
| - if autosplit.SettingsWidget: |
284 |
| - autosplit.SettingsWidget.undo_split_input.setText(key_name) |
285 |
| - autosplit.settings_dict["undo_split_hotkey"] = key_name |
286 |
| - autosplit.after_setting_hotkey_signal.emit() |
287 |
| - |
288 |
| - _unhook(autosplit.undo_split_hotkey) |
289 |
| - thread = threading.Thread(target=callback) |
290 |
| - thread.start() |
291 |
| - |
292 |
| - |
293 |
| -def set_pause_hotkey(autosplit: AutoSplit, preselected_key: str = ""): |
294 |
| - if autosplit.SettingsWidget: |
295 |
| - autosplit.SettingsWidget.set_pause_hotkey_button.setText(PRESS_A_KEY_TEXT) |
296 |
| - before_setting_hotkey(autosplit) |
297 |
| - |
298 |
| - def callback(): |
299 |
| - key_name = preselected_key if preselected_key else __get_key_name(keyboard.read_event(True)) |
300 |
| - |
301 |
| - try: |
302 |
| - if __is_key_already_set(autosplit, key_name) or (key_name != "+" and "+" in key_name): |
303 |
| - autosplit.after_setting_hotkey_signal.emit() |
304 |
| - return |
305 |
| - except AttributeError: |
306 |
| - autosplit.after_setting_hotkey_signal.emit() |
307 |
| - return |
308 |
| - |
309 |
| - autosplit.pause_hotkey = keyboard.hook_key( |
310 |
| - key_name, |
311 |
| - lambda error: _hotkey_action(error, key_name, autosplit.pause_signal.emit)) |
312 |
| - if autosplit.SettingsWidget: |
313 |
| - autosplit.SettingsWidget.pause_input.setText(key_name) |
314 |
| - autosplit.settings_dict["pause_hotkey"] = key_name |
315 |
| - autosplit.after_setting_hotkey_signal.emit() |
316 |
| - |
317 |
| - _unhook(autosplit.pause_hotkey) |
| 239 | + # try to remove the previously set hotkey if there is one. |
| 240 | + _unhook(getattr(autosplit, f"{hotkey}_hotkey")) |
318 | 241 | thread = threading.Thread(target=callback)
|
319 | 242 | thread.start()
|
0 commit comments