Skip to content
This repository was archived by the owner on Apr 26, 2023. It is now read-only.

Commit a1ac03c

Browse files
committed
Support modifiers the same way LiveSplit does. Closes #34
1 parent d55f271 commit a1ac03c

File tree

3 files changed

+118
-195
lines changed

3 files changed

+118
-195
lines changed

src/hotkeys.py

Lines changed: 106 additions & 183 deletions
Original file line numberDiff line numberDiff line change
@@ -5,43 +5,41 @@
55
if TYPE_CHECKING:
66
from AutoSplit import AutoSplit
77

8+
import locale
89
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+
1013
import keyboard # https://github.com/boppreh/keyboard/issues/505
1114
import pyautogui # https://github.com/asweigart/pyautogui/issues/645
1215
# While not usually recommended, we don'thread manipulate the mouse, and we don'thread want the extra delay
1316
pyautogui.FAILSAFE = False
1417

18+
1519
SET_HOTKEY_TEXT = "Set Hotkey"
1620
PRESS_A_KEY_TEXT = "Press a key..."
1721

22+
HotkeysType = Literal["split", "reset", "skip_split", "undo_split", "pause"]
23+
HOTKEYS: list[HotkeysType] = ["split", "reset", "skip_split", "undo_split", "pause"]
1824

1925
# do all of these after you click "Set Hotkey" but before you type the hotkey.
26+
27+
2028
def before_setting_hotkey(autosplit: AutoSplit):
2129
autosplit.start_auto_splitter_button.setEnabled(False)
2230
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)
2833

2934

3035
# do all of these things after you set a hotkey. a signal connects to this because
3136
# changing GUI stuff in the hotkey thread was causing problems
3237
def after_setting_hotkey(autosplit: AutoSplit):
3338
autosplit.start_auto_splitter_button.setEnabled(True)
3439
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)
4543

4644

4745
def is_digit(key: Optional[str]):
@@ -74,33 +72,35 @@ def send_command(autosplit: AutoSplit, command: Commands):
7472
raise KeyError(f"'{command}' is not a valid LiveSplit.AutoSplitIntegration command")
7573

7674

77-
def _unhook(hotkey: Optional[Callable[[], None]]):
75+
def _unhook(hotkey_callback: Optional[Callable[[], None]]):
7876
try:
79-
if hotkey:
80-
keyboard.unhook_key(hotkey)
77+
if hotkey_callback:
78+
keyboard.unhook_key(hotkey_callback)
8179
except (AttributeError, KeyError, ValueError):
8280
pass
8381

8482

85-
def _send_hotkey(key_or_scan_code: Union[int, str]):
83+
def _send_hotkey(hotkey_or_scan_code: Union[int, str, None]):
8684
"""
8785
Supports sending the appropriate scan code for all the special cases
8886
"""
89-
if not key_or_scan_code:
87+
if not hotkey_or_scan_code:
9088
return
9189

9290
# 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)
9695
return
9796

9897
# 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)
9999
# 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("+"))
101101

102102

103-
def __validate_keypad(expected_key: str, keyboard_event: KeyboardEvent) -> bool:
103+
def __validate_keypad(expected_key: str, keyboard_event: keyboard.KeyboardEvent) -> bool:
104104
# Prevent "(keypad)delete", "(keypad)./decimal" and "del" from triggering each other
105105
# as well as "." and "(keypad)./decimal"
106106
if keyboard_event.scan_code in {83, 52}:
@@ -129,191 +129,114 @@ def __validate_keypad(expected_key: str, keyboard_event: KeyboardEvent) -> bool:
129129

130130
# Since we reuse the key string we set to send to LiveSplit, we can'thread use fake names like "num home".
131131
# 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):
134134
action()
135135

136136

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"
138145
return f"num {keyboard_event.name}" \
139146
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)
141187

142188

143189
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]
149191

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.
150194

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 = ""):
155197
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)
157199

158200
# disable some buttons
159201
before_setting_hotkey(autosplit)
160202

161203
# new thread points to callback. this thread is needed or GUI will freeze
162204
# while the program waits for user input on the hotkey
163205
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):
182211
autosplit.after_setting_hotkey_signal.emit()
183212
return
184213

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-
188214
# We need to inspect the event to know if it comes from numpad because of _canonial_names.
189215
# See: https://github.com/boppreh/keyboard/issues/161#issuecomment-386825737
190216
# The best way to achieve this is make our own hotkey handling on top of hook
191217
# 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+
)
199233

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))
225234
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
228237
autosplit.after_setting_hotkey_signal.emit()
229238

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"))
318241
thread = threading.Thread(target=callback)
319242
thread.start()

0 commit comments

Comments
 (0)