diff --git a/src/AutoSplit.py b/src/AutoSplit.py index da1238fd..542091c3 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -432,8 +432,10 @@ def __check_fps(self): self.fps_value_label.setText(str(fps)) def __is_current_split_out_of_range(self): - return self.split_image_number < 0 \ + return ( + self.split_image_number < 0 or self.split_image_number > len(self.split_images_and_loop_number) - 1 + ) def undo_split(self, navigate_image_only: bool = False): """Undo Split" and "Prev. Img." buttons connect to here.""" @@ -497,8 +499,10 @@ def reset(self): # Functions for the hotkeys to return to the main thread from signals and start their corresponding functions def start_auto_splitter(self): # If the auto splitter is already running or the button is disabled, don't emit the signal to start it. - if self.is_running \ - or (not self.start_auto_splitter_button.isEnabled() and not self.is_auto_controlled): + if ( + self.is_running + or (not self.start_auto_splitter_button.isEnabled() and not self.is_auto_controlled) + ): return start_label = self.start_image_status_value_label.text() diff --git a/src/capture_method/__init__.py b/src/capture_method/__init__.py index f4a24d2f..bb58bc14 100644 --- a/src/capture_method/__init__.py +++ b/src/capture_method/__init__.py @@ -188,16 +188,20 @@ async def get_camera_info(index: int, device_name: str): # backend = video_capture.getBackendName() # STS_ASSERT # video_capture.grab() # STS_ERROR # except cv2.error as error: - # return CameraInfo(index, device_name, True, backend) \ - # if error.code in (cv2.Error.STS_ERROR, cv2.Error.STS_ASSERT) \ + # return ( + # CameraInfo(index, device_name, True, backend) + # if error.code in (cv2.Error.STS_ERROR, cv2.Error.STS_ASSERT) # else None + # ) # finally: # video_capture.release() resolution = get_input_device_resolution(index) - return CameraInfo(index, device_name, False, backend, resolution) \ - if resolution is not None \ + return ( + CameraInfo(index, device_name, False, backend, resolution) + if resolution is not None else None + ) return [ camera_info diff --git a/src/compare.py b/src/compare.py index fe021e34..6e3bd990 100644 --- a/src/compare.py +++ b/src/compare.py @@ -44,9 +44,11 @@ def compare_l2_norm(source: MatLike, capture: MatLike, mask: MatLike | None = No error = cv2.norm(source, capture, cv2.NORM_L2, mask) # The L2 Error is summed across all pixels, so this normalizes - max_error = sqrt(source.size) * MAXBYTE \ - if not is_valid_image(mask)\ + max_error = ( + sqrt(source.size) * MAXBYTE + if not is_valid_image(mask) else sqrt(cv2.countNonZero(mask) * MASK_SIZE_MULTIPLIER) + ) if not max_error: return 0.0 @@ -69,9 +71,11 @@ def compare_template(source: MatLike, capture: MatLike, mask: MatLike | None = N # matchTemplate returns the sum of square differences, this is the max # that the value can be. Used for normalizing from 0 to 1. - max_error = source.size * MAXBYTE * MAXBYTE \ - if not is_valid_image(mask) \ + max_error = ( + source.size * MAXBYTE * MAXBYTE + if not is_valid_image(mask) else cv2.countNonZero(mask) + ) return 1 - (min_val / max_error) diff --git a/src/hotkeys.py b/src/hotkeys.py index 1109cedb..7dec1a16 100644 --- a/src/hotkeys.py +++ b/src/hotkeys.py @@ -1,309 +1,311 @@ -from collections.abc import Callable -from typing import TYPE_CHECKING, Literal, cast - -import keyboard -import pyautogui -from PySide6 import QtWidgets - -import error_messages -from utils import fire_and_forget, is_digit - -if TYPE_CHECKING: - from AutoSplit import AutoSplit - -# While not usually recommended, we don't manipulate the mouse, and we don't want the extra delay -pyautogui.FAILSAFE = False - -SET_HOTKEY_TEXT = "Set Hotkey" -PRESS_A_KEY_TEXT = "Press a key..." - -Commands = Literal["split", "start", "pause", "reset", "skip", "undo"] -Hotkey = Literal["split", "reset", "skip_split", "undo_split", "pause", "screenshot", "toggle_auto_reset_image"] -HOTKEYS: list[Hotkey] = ["split", "reset", "skip_split", "undo_split", "pause", "screenshot", "toggle_auto_reset_image"] - - -def remove_all_hotkeys(): - keyboard.unhook_all() - - -def before_setting_hotkey(autosplit: "AutoSplit"): - """Do all of these after you click "Set Hotkey" but before you type the hotkey.""" - autosplit.start_auto_splitter_button.setEnabled(False) - if autosplit.SettingsWidget: - for hotkey in HOTKEYS: - getattr(autosplit.SettingsWidget, f"set_{hotkey}_hotkey_button").setEnabled(False) - - -def after_setting_hotkey(autosplit: "AutoSplit"): - """ - Do all of these things after you set a hotkey. - A signal connects to this because changing GUI stuff is only possible in the main thread. - """ - if not autosplit.is_running: - autosplit.start_auto_splitter_button.setEnabled(True) - if autosplit.SettingsWidget: - for hotkey in HOTKEYS: - getattr(autosplit.SettingsWidget, f"set_{hotkey}_hotkey_button").setText(SET_HOTKEY_TEXT) - getattr(autosplit.SettingsWidget, f"set_{hotkey}_hotkey_button").setEnabled(True) - - -def send_command(autosplit: "AutoSplit", command: Commands): - # Note: Rather than having the start image able to also reset the timer, - # having the reset image check be active at all time would be a better, more organic solution, - # but that is dependent on migrating to an observer pattern (#219) and being able to reload all images. - match command: - case _ if autosplit.settings_dict["start_also_resets"]: - if command == "start" and autosplit.settings_dict["start_also_resets"]: - print("reset", flush=True) - print(command, flush=True) - case "start" if autosplit.settings_dict["start_also_resets"]: - _send_hotkey(autosplit.settings_dict["reset_hotkey"]) - case "reset": - _send_hotkey(autosplit.settings_dict["reset_hotkey"]) - case "start" | "split": - _send_hotkey(autosplit.settings_dict["split_hotkey"]) - case "pause": - _send_hotkey(autosplit.settings_dict["pause_hotkey"]) - case "skip": - _send_hotkey(autosplit.settings_dict["skip_split_hotkey"]) - case "undo": - _send_hotkey(autosplit.settings_dict["undo_split_hotkey"]) - case _: # pyright: ignore[reportUnnecessaryComparison] - raise KeyError(f"{command!r} is not a valid command") - - -def _unhook(hotkey_callback: Callable[[], None] | None): - try: - if hotkey_callback: - keyboard.unhook_key(hotkey_callback) - except (AttributeError, KeyError, ValueError): - pass - - -def _send_hotkey(hotkey_or_scan_code: int | str | None): - """Supports sending the appropriate scan code for all the special cases.""" - if not hotkey_or_scan_code: - return - - # Deal with regular inputs - # If an int or does not contain the following strings - if ( - isinstance(hotkey_or_scan_code, int) - or not any(key in hotkey_or_scan_code for key in ("num ", "decimal", "+")) - ): - keyboard.send(hotkey_or_scan_code) - return - - # FIXME: Localized keys won't work here - # Deal with problematic keys. Even by sending specific scan code "keyboard" still sends the default (wrong) key - # keyboard also has issues with capitalization modifier (shift+A) - # keyboard.send(keyboard.key_to_scan_codes(key_or_scan_code)[1]) - pyautogui.hotkey( - *[ - "+" if key == "plus" else key - for key - in hotkey_or_scan_code.replace(" ", "").split("+") - ], - ) - - -def __validate_keypad(expected_key: str, keyboard_event: keyboard.KeyboardEvent) -> bool: - """ - NOTE: This is a workaround very specific to numpads. - Windows reports different physical keys with the same scan code. - For example, "Home", "Num Home" and "Num 7" are all `71`. - See: https://github.com/boppreh/keyboard/issues/171#issuecomment-390437684 . - - Since we reuse the key string we set to send to LiveSplit, we can't use fake names like "num home". - We're also trying to achieve the same hotkey behaviour as LiveSplit has. - """ - # Prevent "(keypad)delete", "(keypad)./decimal" and "del" from triggering each other - # as well as "." and "(keypad)./decimal" - if keyboard_event.scan_code in {83, 52}: - # TODO: "del" won't work with "(keypad)delete" if localized in non-english (ie: "suppr" in french) - return expected_key == keyboard_event.name - # Prevent "action keys" from triggering "keypad keys" - if keyboard_event.name and is_digit(keyboard_event.name[-1]): - # Prevent "regular numbers" and "keypad numbers" from activating each other - return bool( - keyboard_event.is_keypad - if expected_key.startswith("num ") - else not keyboard_event.is_keypad, - ) - - # Prevent "keypad action keys" from triggering "regular numbers" and "keypad numbers" - # Still allow the same key that might be localized differently on keypad vs non-keypad - return not is_digit(expected_key[-1]) - - -def _hotkey_action(keyboard_event: keyboard.KeyboardEvent, key_name: str, action: Callable[[], None]): - """ - We're doing the check here instead of saving the key code because - the non-keypad shared keys are localized while the keypad ones aren't. - They also share scan codes on Windows. - """ - if keyboard_event.event_type == keyboard.KEY_DOWN and __validate_keypad(key_name, keyboard_event): - action() - - -def __get_key_name(keyboard_event: keyboard.KeyboardEvent): - """Ensures proper keypad name.""" - event_name = str(keyboard_event.name) - # Normally this is done by keyboard.get_hotkey_name. But our code won't always get there. - if event_name == "+": - return "plus" - return f"num {keyboard_event.name}" \ - if keyboard_event.is_keypad and is_digit(keyboard_event.name) \ - else event_name - - -def __get_hotkey_name(names: list[str]): - """ - Uses keyboard.get_hotkey_name but works with non-english modifiers and keypad - See: https://github.com/boppreh/keyboard/issues/516 . - """ - if not names: - return "" - - if len(names) == 1: - return names[0] - - def sorting_key(key: str): - return not keyboard.is_modifier(keyboard.key_to_scan_codes(key)[0]) - - clean_names = sorted(keyboard.get_hotkey_name(names).split("+"), key=sorting_key) - # Replace the last key in hotkey_name with what we actually got as a last key_name - # This ensures we keep proper keypad names - return "+".join(clean_names[:-1] + names[-1:]) - - -def __read_hotkey(): - """ - Blocks until a hotkey combination is read. - Returns the hotkey_name and last KeyboardEvent. - """ - names: list[str] = [] - while True: - keyboard_event = keyboard.read_event(True) - # LiveSplit supports modifier keys as the last key, so any keyup means end of hotkey - if keyboard_event.event_type == keyboard.KEY_UP: - # Unless keyup is also the very first event, - # which can happen from a very fast press at the same time we start reading - if not names: - continue - break - key_name = __get_key_name(keyboard_event) - # Ignore long presses - if names and names[-1] == key_name: - continue - names.append(__get_key_name(keyboard_event)) - # Stop at the first non-modifier to prevent registering a hotkey with multiple regular keys - if not keyboard.is_modifier(keyboard_event.scan_code): - break - return __get_hotkey_name(names) - - -def __remove_key_already_set(autosplit: "AutoSplit", key_name: str): - for hotkey in HOTKEYS: - settings_key = f"{hotkey}_hotkey" - if autosplit.settings_dict.get(settings_key) == key_name: - _unhook(getattr(autosplit, f"{hotkey}_hotkey")) - autosplit.settings_dict[settings_key] = "" # pyright: ignore[reportGeneralTypeIssues] - if autosplit.SettingsWidget: - getattr(autosplit.SettingsWidget, f"{hotkey}_input").setText("") - - -def __get_hotkey_action(autosplit: "AutoSplit", hotkey: Hotkey): - if hotkey == "split": - return autosplit.start_auto_splitter - if hotkey == "skip_split": - return lambda: autosplit.skip_split(True) - if hotkey == "undo_split": - return lambda: autosplit.undo_split(True) - if hotkey == "toggle_auto_reset_image": - - def toggle_auto_reset_image(): - new_value = not autosplit.settings_dict["enable_auto_reset"] - autosplit.settings_dict["enable_auto_reset"] = new_value - if autosplit.SettingsWidget: - autosplit.SettingsWidget.enable_auto_reset_image_checkbox.setChecked(new_value) - - return toggle_auto_reset_image - return getattr(autosplit, f"{hotkey}_signal").emit - - -def is_valid_hotkey_name(hotkey_name: str): - return any( - key and not keyboard.is_modifier(keyboard.key_to_scan_codes(key)[0]) - for key - in hotkey_name.split("+") - ) - -# TODO: using getattr/setattr is NOT a good way to go about this. It was only temporarily done to -# reduce duplicated code. We should use a dictionary of hotkey class or something. - - -def set_hotkey(autosplit: "AutoSplit", hotkey: Hotkey, preselected_hotkey_name: str = ""): - if autosplit.SettingsWidget: - # Unfocus all fields - cast(QtWidgets.QWidget, autosplit.SettingsWidget).setFocus() - getattr(autosplit.SettingsWidget, f"set_{hotkey}_hotkey_button").setText(PRESS_A_KEY_TEXT) - - # Disable some buttons - before_setting_hotkey(autosplit) - - # New thread points to read_and_set_hotkey. this thread is needed or GUI will freeze - # while the program waits for user input on the hotkey - @fire_and_forget - def read_and_set_hotkey(): - try: - hotkey_name = preselected_hotkey_name or __read_hotkey() - - # Unset hotkey by pressing "Escape". This is the same behaviour as LiveSplit - if hotkey_name == "esc": - _unhook(getattr(autosplit, f"{hotkey}_hotkey")) - autosplit.settings_dict[f"{hotkey}_hotkey"] = "" # pyright: ignore[reportGeneralTypeIssues] - if autosplit.SettingsWidget: - getattr(autosplit.SettingsWidget, f"{hotkey}_input").setText("") - return - - if not is_valid_hotkey_name(hotkey_name): - autosplit.show_error_signal.emit(lambda: error_messages.invalid_hotkey(hotkey_name)) - return - - # Try to remove the previously set hotkey if there is one - _unhook(getattr(autosplit, f"{hotkey}_hotkey")) - - # Remove any hotkey using the same key combination - __remove_key_already_set(autosplit, hotkey_name) - - action = __get_hotkey_action(autosplit, hotkey) - setattr( - autosplit, - f"{hotkey}_hotkey", - # keyboard.add_hotkey doesn't give the last keyboard event, so we can't __validate_keypad. - # This means "ctrl + num 5" and "ctrl + 5" will both be registered. - # For that reason, we still prefer keyboard.hook_key for single keys. - # keyboard module allows you to hit multiple keys for a hotkey. they are joined together by +. - keyboard.add_hotkey(hotkey_name, action) - if "+" in hotkey_name - # We need to inspect the event to know if it comes from numpad because of _canonial_names. - # See: https://github.com/boppreh/keyboard/issues/161#issuecomment-386825737 - # The best way to achieve this is make our own hotkey handling on top of hook - # See: https://github.com/boppreh/keyboard/issues/216#issuecomment-431999553 - else keyboard.hook_key( - hotkey_name, - lambda keyboard_event: _hotkey_action(keyboard_event, hotkey_name, action), - ), - ) - - if autosplit.SettingsWidget: - getattr(autosplit.SettingsWidget, f"{hotkey}_input").setText(hotkey_name) - autosplit.settings_dict[f"{hotkey}_hotkey"] = hotkey_name # pyright: ignore[reportGeneralTypeIssues] - except Exception as exception: # noqa: BLE001 # We really want to catch everything here - error = exception - autosplit.show_error_signal.emit(lambda: error_messages.exception_traceback(error)) - finally: - autosplit.after_setting_hotkey_signal.emit() - - read_and_set_hotkey() +from collections.abc import Callable +from typing import TYPE_CHECKING, Literal, cast + +import keyboard +import pyautogui +from PySide6 import QtWidgets + +import error_messages +from utils import fire_and_forget, is_digit + +if TYPE_CHECKING: + from AutoSplit import AutoSplit + +# While not usually recommended, we don't manipulate the mouse, and we don't want the extra delay +pyautogui.FAILSAFE = False + +SET_HOTKEY_TEXT = "Set Hotkey" +PRESS_A_KEY_TEXT = "Press a key..." + +Commands = Literal["split", "start", "pause", "reset", "skip", "undo"] +Hotkey = Literal["split", "reset", "skip_split", "undo_split", "pause", "screenshot", "toggle_auto_reset_image"] +HOTKEYS: list[Hotkey] = ["split", "reset", "skip_split", "undo_split", "pause", "screenshot", "toggle_auto_reset_image"] + + +def remove_all_hotkeys(): + keyboard.unhook_all() + + +def before_setting_hotkey(autosplit: "AutoSplit"): + """Do all of these after you click "Set Hotkey" but before you type the hotkey.""" + autosplit.start_auto_splitter_button.setEnabled(False) + if autosplit.SettingsWidget: + for hotkey in HOTKEYS: + getattr(autosplit.SettingsWidget, f"set_{hotkey}_hotkey_button").setEnabled(False) + + +def after_setting_hotkey(autosplit: "AutoSplit"): + """ + Do all of these things after you set a hotkey. + A signal connects to this because changing GUI stuff is only possible in the main thread. + """ + if not autosplit.is_running: + autosplit.start_auto_splitter_button.setEnabled(True) + if autosplit.SettingsWidget: + for hotkey in HOTKEYS: + getattr(autosplit.SettingsWidget, f"set_{hotkey}_hotkey_button").setText(SET_HOTKEY_TEXT) + getattr(autosplit.SettingsWidget, f"set_{hotkey}_hotkey_button").setEnabled(True) + + +def send_command(autosplit: "AutoSplit", command: Commands): + # Note: Rather than having the start image able to also reset the timer, + # having the reset image check be active at all time would be a better, more organic solution, + # but that is dependent on migrating to an observer pattern (#219) and being able to reload all images. + match command: + case _ if autosplit.settings_dict["start_also_resets"]: + if command == "start" and autosplit.settings_dict["start_also_resets"]: + print("reset", flush=True) + print(command, flush=True) + case "start" if autosplit.settings_dict["start_also_resets"]: + _send_hotkey(autosplit.settings_dict["reset_hotkey"]) + case "reset": + _send_hotkey(autosplit.settings_dict["reset_hotkey"]) + case "start" | "split": + _send_hotkey(autosplit.settings_dict["split_hotkey"]) + case "pause": + _send_hotkey(autosplit.settings_dict["pause_hotkey"]) + case "skip": + _send_hotkey(autosplit.settings_dict["skip_split_hotkey"]) + case "undo": + _send_hotkey(autosplit.settings_dict["undo_split_hotkey"]) + case _: # pyright: ignore[reportUnnecessaryComparison] + raise KeyError(f"{command!r} is not a valid command") + + +def _unhook(hotkey_callback: Callable[[], None] | None): + try: + if hotkey_callback: + keyboard.unhook_key(hotkey_callback) + except (AttributeError, KeyError, ValueError): + pass + + +def _send_hotkey(hotkey_or_scan_code: int | str | None): + """Supports sending the appropriate scan code for all the special cases.""" + if not hotkey_or_scan_code: + return + + # Deal with regular inputs + # If an int or does not contain the following strings + if ( + isinstance(hotkey_or_scan_code, int) + or not any(key in hotkey_or_scan_code for key in ("num ", "decimal", "+")) + ): + keyboard.send(hotkey_or_scan_code) + return + + # FIXME: Localized keys won't work here + # Deal with problematic keys. Even by sending specific scan code "keyboard" still sends the default (wrong) key + # keyboard also has issues with capitalization modifier (shift+A) + # keyboard.send(keyboard.key_to_scan_codes(key_or_scan_code)[1]) + pyautogui.hotkey( + *[ + "+" if key == "plus" else key + for key + in hotkey_or_scan_code.replace(" ", "").split("+") + ], + ) + + +def __validate_keypad(expected_key: str, keyboard_event: keyboard.KeyboardEvent) -> bool: + """ + NOTE: This is a workaround very specific to numpads. + Windows reports different physical keys with the same scan code. + For example, "Home", "Num Home" and "Num 7" are all `71`. + See: https://github.com/boppreh/keyboard/issues/171#issuecomment-390437684 . + + Since we reuse the key string we set to send to LiveSplit, we can't use fake names like "num home". + We're also trying to achieve the same hotkey behaviour as LiveSplit has. + """ + # Prevent "(keypad)delete", "(keypad)./decimal" and "del" from triggering each other + # as well as "." and "(keypad)./decimal" + if keyboard_event.scan_code in {83, 52}: + # TODO: "del" won't work with "(keypad)delete" if localized in non-english (ie: "suppr" in french) + return expected_key == keyboard_event.name + # Prevent "action keys" from triggering "keypad keys" + if keyboard_event.name and is_digit(keyboard_event.name[-1]): + # Prevent "regular numbers" and "keypad numbers" from activating each other + return bool( + keyboard_event.is_keypad + if expected_key.startswith("num ") + else not keyboard_event.is_keypad, + ) + + # Prevent "keypad action keys" from triggering "regular numbers" and "keypad numbers" + # Still allow the same key that might be localized differently on keypad vs non-keypad + return not is_digit(expected_key[-1]) + + +def _hotkey_action(keyboard_event: keyboard.KeyboardEvent, key_name: str, action: Callable[[], None]): + """ + We're doing the check here instead of saving the key code because + the non-keypad shared keys are localized while the keypad ones aren't. + They also share scan codes on Windows. + """ + if keyboard_event.event_type == keyboard.KEY_DOWN and __validate_keypad(key_name, keyboard_event): + action() + + +def __get_key_name(keyboard_event: keyboard.KeyboardEvent): + """Ensures proper keypad name.""" + event_name = str(keyboard_event.name) + # Normally this is done by keyboard.get_hotkey_name. But our code won't always get there. + if event_name == "+": + return "plus" + return ( + f"num {keyboard_event.name}" + if keyboard_event.is_keypad and is_digit(keyboard_event.name) + else event_name + ) + + +def __get_hotkey_name(names: list[str]): + """ + Uses keyboard.get_hotkey_name but works with non-english modifiers and keypad + See: https://github.com/boppreh/keyboard/issues/516 . + """ + if not names: + return "" + + if len(names) == 1: + return names[0] + + def sorting_key(key: str): + return not keyboard.is_modifier(keyboard.key_to_scan_codes(key)[0]) + + clean_names = sorted(keyboard.get_hotkey_name(names).split("+"), key=sorting_key) + # Replace the last key in hotkey_name with what we actually got as a last key_name + # This ensures we keep proper keypad names + return "+".join(clean_names[:-1] + names[-1:]) + + +def __read_hotkey(): + """ + Blocks until a hotkey combination is read. + Returns the hotkey_name and last KeyboardEvent. + """ + names: list[str] = [] + while True: + keyboard_event = keyboard.read_event(True) + # LiveSplit supports modifier keys as the last key, so any keyup means end of hotkey + if keyboard_event.event_type == keyboard.KEY_UP: + # Unless keyup is also the very first event, + # which can happen from a very fast press at the same time we start reading + if not names: + continue + break + key_name = __get_key_name(keyboard_event) + # Ignore long presses + if names and names[-1] == key_name: + continue + names.append(__get_key_name(keyboard_event)) + # Stop at the first non-modifier to prevent registering a hotkey with multiple regular keys + if not keyboard.is_modifier(keyboard_event.scan_code): + break + return __get_hotkey_name(names) + + +def __remove_key_already_set(autosplit: "AutoSplit", key_name: str): + for hotkey in HOTKEYS: + settings_key = f"{hotkey}_hotkey" + if autosplit.settings_dict.get(settings_key) == key_name: + _unhook(getattr(autosplit, f"{hotkey}_hotkey")) + autosplit.settings_dict[settings_key] = "" # pyright: ignore[reportGeneralTypeIssues] + if autosplit.SettingsWidget: + getattr(autosplit.SettingsWidget, f"{hotkey}_input").setText("") + + +def __get_hotkey_action(autosplit: "AutoSplit", hotkey: Hotkey): + if hotkey == "split": + return autosplit.start_auto_splitter + if hotkey == "skip_split": + return lambda: autosplit.skip_split(True) + if hotkey == "undo_split": + return lambda: autosplit.undo_split(True) + if hotkey == "toggle_auto_reset_image": + + def toggle_auto_reset_image(): + new_value = not autosplit.settings_dict["enable_auto_reset"] + autosplit.settings_dict["enable_auto_reset"] = new_value + if autosplit.SettingsWidget: + autosplit.SettingsWidget.enable_auto_reset_image_checkbox.setChecked(new_value) + + return toggle_auto_reset_image + return getattr(autosplit, f"{hotkey}_signal").emit + + +def is_valid_hotkey_name(hotkey_name: str): + return any( + key and not keyboard.is_modifier(keyboard.key_to_scan_codes(key)[0]) + for key + in hotkey_name.split("+") + ) + +# TODO: using getattr/setattr is NOT a good way to go about this. It was only temporarily done to +# reduce duplicated code. We should use a dictionary of hotkey class or something. + + +def set_hotkey(autosplit: "AutoSplit", hotkey: Hotkey, preselected_hotkey_name: str = ""): + if autosplit.SettingsWidget: + # Unfocus all fields + cast(QtWidgets.QWidget, autosplit.SettingsWidget).setFocus() + getattr(autosplit.SettingsWidget, f"set_{hotkey}_hotkey_button").setText(PRESS_A_KEY_TEXT) + + # Disable some buttons + before_setting_hotkey(autosplit) + + # New thread points to read_and_set_hotkey. this thread is needed or GUI will freeze + # while the program waits for user input on the hotkey + @fire_and_forget + def read_and_set_hotkey(): + try: + hotkey_name = preselected_hotkey_name or __read_hotkey() + + # Unset hotkey by pressing "Escape". This is the same behaviour as LiveSplit + if hotkey_name == "esc": + _unhook(getattr(autosplit, f"{hotkey}_hotkey")) + autosplit.settings_dict[f"{hotkey}_hotkey"] = "" # pyright: ignore[reportGeneralTypeIssues] + if autosplit.SettingsWidget: + getattr(autosplit.SettingsWidget, f"{hotkey}_input").setText("") + return + + if not is_valid_hotkey_name(hotkey_name): + autosplit.show_error_signal.emit(lambda: error_messages.invalid_hotkey(hotkey_name)) + return + + # Try to remove the previously set hotkey if there is one + _unhook(getattr(autosplit, f"{hotkey}_hotkey")) + + # Remove any hotkey using the same key combination + __remove_key_already_set(autosplit, hotkey_name) + + action = __get_hotkey_action(autosplit, hotkey) + setattr( + autosplit, + f"{hotkey}_hotkey", + # keyboard.add_hotkey doesn't give the last keyboard event, so we can't __validate_keypad. + # This means "ctrl + num 5" and "ctrl + 5" will both be registered. + # For that reason, we still prefer keyboard.hook_key for single keys. + # keyboard module allows you to hit multiple keys for a hotkey. they are joined together by +. + keyboard.add_hotkey(hotkey_name, action) + if "+" in hotkey_name + # We need to inspect the event to know if it comes from numpad because of _canonial_names. + # See: https://github.com/boppreh/keyboard/issues/161#issuecomment-386825737 + # The best way to achieve this is make our own hotkey handling on top of hook + # See: https://github.com/boppreh/keyboard/issues/216#issuecomment-431999553 + else keyboard.hook_key( + hotkey_name, + lambda keyboard_event: _hotkey_action(keyboard_event, hotkey_name, action), + ), + ) + + if autosplit.SettingsWidget: + getattr(autosplit.SettingsWidget, f"{hotkey}_input").setText(hotkey_name) + autosplit.settings_dict[f"{hotkey}_hotkey"] = hotkey_name # pyright: ignore[reportGeneralTypeIssues] + except Exception as exception: # noqa: BLE001 # We really want to catch everything here + error = exception + autosplit.show_error_signal.emit(lambda: error_messages.exception_traceback(error)) + finally: + autosplit.after_setting_hotkey_signal.emit() + + read_and_set_hotkey() diff --git a/src/user_profile.py b/src/user_profile.py index cd8e85ef..d437a6d2 100644 --- a/src/user_profile.py +++ b/src/user_profile.py @@ -205,9 +205,11 @@ def load_check_for_updates_on_open(autosplit: "AutoSplit"): # Type not infered by PySide6: https://bugreports.qt.io/browse/PYSIDE-2542 value = cast( bool, - QtCore - .QSettings("AutoSplit", "Check For Updates On Open") - .value("check_for_updates_on_open", True, type=bool), + QtCore.QSettings( + "AutoSplit", "Check For Updates On Open", + ).value( + "check_for_updates_on_open", True, type=bool, + ), ) autosplit.action_check_for_updates_on_open.setChecked(value) @@ -215,6 +217,8 @@ def load_check_for_updates_on_open(autosplit: "AutoSplit"): def set_check_for_updates_on_open(design_window: design.Ui_MainWindow, value: bool): """Sets the "Check For Updates On Open" QSettings value and the checkbox state.""" design_window.action_check_for_updates_on_open.setChecked(value) - QtCore \ - .QSettings("AutoSplit", "Check For Updates On Open") \ - .setValue("check_for_updates_on_open", value) + QtCore.QSettings( + "AutoSplit", "Check For Updates On Open", + ).setValue( + "check_for_updates_on_open", value, + )