diff --git a/README.md b/README.md index c2b01321..fc791596 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,6 @@ To understand how to use AutoSplit and how it works in-depth, please read the [t (If you don't have a GitHub account, you can try [nightly.link](https://nightly.link/Toufool/AutoSplit/workflows/lint-and-build/main)) - Linux users must ensure they are in the `tty` and `input` groups and have write access to `/dev/uinput`. You can run the following commands to do so: - ```shell sudo usermod -a -G tty,input $USER @@ -126,12 +125,6 @@ Not a developer? You can still help through the following methods: - - - - - - - - - - - - - - - - ## Credits diff --git a/pyproject.toml b/pyproject.toml index cb14399e..6f44919f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,12 +4,11 @@ version = "2.3.2" requires-python = ">=3.14" dependencies = [ "Levenshtein >=0.25", - "PyAutoGUI >=0.9.52", "PyWinCtl >=0.0.42", # py.typed - "keyboard @ git+https://github.com/boppreh/keyboard.git", # Fix install on macos and linux-ci https://github.com/boppreh/keyboard/pull/568 "numpy >=2.3.2", # Python 3.14 support "opencv-contrib-python-headless >=4.10", # NumPy 2 support "packaging >=20.0", # py.typed + "pynput >=1.8.1", "tomli-w >=1.1.0", # Typing fixes # When needed, PySide6 dev builds can be found at https://download.qt.io/snapshots/ci/pyside/dev?C=M;O=D @@ -58,8 +57,6 @@ dev = [ { include-group = "ruff" }, # # Types - "types-PyAutoGUI", - "types-keyboard", "types-pyinstaller", "types-python-xlib; sys_platform == 'linux'", "types-pywin32 >=306.0.0.20240130; sys_platform == 'win32'", @@ -70,12 +67,6 @@ environments = [ "sys_platform == 'linux'", "sys_platform == 'win32'", ] -dependency-metadata = [ - # PyAutoGUI installs extra libraries we don't want. We only use it for hotkeys - # PyScreeze -> pyscreenshot -> mss deps calls SetProcessDpiAwareness on Windows - { name = "PyAutoGUI", requires-dist = [] }, - { name = "types-PyAutoGUI", requires-dist = [] }, -] [tool.uv.sources] # Development channels beslogic-ruff-config = { git = "https://github.com/Beslogic/Beslogic-Ruff-Config" } diff --git a/res/settings.ui b/res/settings.ui index 3ae4d2f3..9a4d04bc 100644 --- a/res/settings.ui +++ b/res/settings.ui @@ -125,7 +125,7 @@ - QAbstractSpinBox::CorrectToNearestValue + QAbstractSpinBox::CorrectionMode::CorrectToNearestValue 20 @@ -147,7 +147,7 @@ - Qt::NoFocus + Qt::FocusPolicy::NoFocus Browse... @@ -367,7 +367,7 @@ After an image is matched, this is the amount of time in millseconds that will be delayed before splitting. - QAbstractSpinBox::CorrectToNearestValue + QAbstractSpinBox::CorrectionMode::CorrectToNearestValue 999999999 @@ -513,7 +513,7 @@ It is highly recommended to NOT use pHash if you use masked images, or it'll be Threshold that the live similarity will need to go above to consider the image a match. - QAbstractSpinBox::CorrectToNearestValue + QAbstractSpinBox::CorrectionMode::CorrectToNearestValue 1.000000000000000 @@ -538,7 +538,7 @@ It is highly recommended to NOT use pHash if you use masked images, or it'll be The amount of time in seconds that comparison will be paused before moving to the next image. - QAbstractSpinBox::CorrectToNearestValue + QAbstractSpinBox::CorrectionMode::CorrectToNearestValue 2 @@ -616,321 +616,186 @@ It is highly recommended to NOT use pHash if you use masked images, or it'll be Hotkeys - - - - 190 - 189 - 81 - 24 - - - - Qt::NoFocus - - - Set Hotkey - - - - - - 90 - 10 - 91 - 22 - - - - - - - true - - - - - - 10 - 73 - 71 - 16 - - - - Undo Split: - - - - - - 90 - 70 - 91 - 22 - - - - - - - true - - - - - - 190 - 39 - 81 - 24 - - - - Qt::NoFocus - - - Set Hotkey - - - - - - 10 - 133 - 71 - 16 - - - - Pause: - - - - - - 190 - 69 - 81 - 24 - - - - Qt::NoFocus - - - Set Hotkey - - - - - - 90 - 40 - 91 - 22 - - - - - - - true - - - - - - 10 - 103 - 71 - 16 - - - - Skip Split: - - - - - - 190 - 9 - 81 - 24 - - - - Qt::NoFocus - - - Set Hotkey - - - - - - 190 - 99 - 81 - 24 - - - - Qt::NoFocus - - - Set Hotkey - - - - - - 190 - 159 - 81 - 24 - - - - Qt::NoFocus - - - Set Hotkey - - - - - - 90 - 190 - 91 - 22 - - - - - - - true - - - - - - 190 - 129 - 81 - 24 - - - - Qt::NoFocus - - - Set Hotkey - - - - - - 10 - 13 - 71 - 16 - - - - Start / Split: - - - - - - 90 - 130 - 91 - 22 - - - - - - - true - - - - - - 10 - 186 - 71 - 32 - - - - Toggle auto + + + + 6 + 6 + 271 + 291 + + + + + + + true + + + + + + + Qt::FocusPolicy::NoFocus + + + Set Hotkey + + + + + + + Start / Split: + + + + + + + Qt::FocusPolicy::NoFocus + + + Set Hotkey + + + + + + + Undo Split: + + + + + + + Qt::FocusPolicy::NoFocus + + + Set Hotkey + + + + + + + true + + + + + + + Skip Split: + + + + + + + Reset: + + + + + + + Pause: + + + + + + + Screenshot: + + + + + + + Toggle auto reset image - - - - - - 10 - 43 - 71 - 16 - - - - Reset: - - - - - - 90 - 160 - 91 - 22 - - - - - - - true - - - - - - 10 - 163 - 71 - 16 - - - - Screenshot: - - - - - - 90 - 100 - 91 - 22 - - - - - - - true - + + + + + + + Qt::FocusPolicy::NoFocus + + + Set Hotkey + + + + + + + Qt::FocusPolicy::NoFocus + + + Set Hotkey + + + + + + + Qt::FocusPolicy::NoFocus + + + Set Hotkey + + + + + + + Qt::FocusPolicy::NoFocus + + + Set Hotkey + + + + + + + true + + + + + + + true + + + + + + + true + + + + + + + true + + + + + + + true + + + + diff --git a/src/AutoSplit.py b/src/AutoSplit.py index dd107a4c..0a95110e 100755 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -2,11 +2,10 @@ import os import sys -# Prevent PyAutoGUI and pywinctl from setting Process DPI Awareness, +# Prevent pywinctl from setting Process DPI Awareness, # which Qt tries to do then throws warnings about it. # The unittest workaround significantly increases # build time, boot time and build size with PyInstaller. -# https://github.com/asweigart/pyautogui/issues/663#issuecomment-1296719464 # QT doesn't call those from Python/ctypes, meaning we can stop other programs from setting it. if sys.platform == "win32": import ctypes @@ -15,7 +14,6 @@ def do_nothing(*_): ... - # pyautogui._pyautogui_win.py ctypes.windll.user32.SetProcessDPIAware = do_nothing # pyright: ignore[reportAttributeAccessIssue] # pymonctl._pymonctl_win.py # pywinbox._pywinbox_win.py @@ -45,13 +43,9 @@ def do_nothing(*_): ... from AutoControlledThread import AutoControlledThread from AutoSplitImage import START_KEYWORD, AutoSplitImage, ImageType from capture_method import CaptureMethodBase, CaptureMethodEnum -from hotkeys import ( - HOTKEYS, - KEYBOARD_GROUPS_ISSUE, - KEYBOARD_UINPUT_ISSUE, - after_setting_hotkey, - send_command, -) +from hotkey_constants import HOTKEYS +from hotkeys import KEYBOARD_GROUPS_ISSUE, KEYBOARD_UINPUT_ISSUE, after_setting_hotkey, send_command +from hotkeys_thread import HotKeyThread from menu_bar import ( about_qt, about_qt_for_python, @@ -250,6 +244,9 @@ def _update_checker_widget_signal_slot(latest_version: str, check_on_open: bool) # Automatic timer start self.timer_start_image.timeout.connect(self.__compare_capture_for_auto_start) + # thread handling hotkeys, it required to be created before reading settings + self.hotkey_thread = HotKeyThread(self) + self.show() try: @@ -266,6 +263,9 @@ def _update_checker_widget_signal_slot(latest_version: str, check_on_open: bool) if self.action_check_for_updates_on_open.isChecked(): check_for_updates(self, check_on_open=True) + # requires to be started once everything else is ready + self.hotkey_thread.start() + # FUNCTIONS def __browse(self): @@ -837,7 +837,7 @@ def gui_changes_on_start(self): # TODO: Do we actually need to disable setting new hotkeys once started? # What does this achieve? (See below TODO) - if self.SettingsWidget: + if self.SettingsWidget is not None: for hotkey in HOTKEYS: getattr(self.SettingsWidget, f"set_{hotkey}_hotkey_button").setEnabled(False) diff --git a/src/hotkey_constants.py b/src/hotkey_constants.py new file mode 100644 index 00000000..9e1915eb --- /dev/null +++ b/src/hotkey_constants.py @@ -0,0 +1,129 @@ +from typing import Literal + +from pynput.keyboard import Key, KeyCode + +SPECIAL_KEYS = ["ctrl", "alt", "shift", "cmd", "stealth", "pause"] +FUNC_KEYS = [f"f{i}" for i in range(1, 21)] + +# TODO: find something better +STR_TO_KEYS = { + "alt": Key.alt.value, + "altleft": Key.alt_l.value, + "altright": Key.alt_r.value, + "capslock": Key.caps_lock.value, + "ctrl": Key.ctrl.value, + "ctrlleft": Key.ctrl_l.value, + "ctrlright": Key.ctrl_r.value, + "shift": Key.shift.value, + "shiftleft": Key.shift_l.value, + "shiftright": Key.shift_r.value, + "win": Key.cmd.value, + "winleft": Key.cmd_l.value, + "winright": Key.cmd_r.value, + "command": Key.cmd.value, + "option": Key.alt.value, + "optionleft": Key.alt_l.value, + "optionright": Key.alt_r.value, + "fn": Key.f1.value, + "backspace": Key.backspace.value, + "enter": Key.enter.value, + "return": Key.enter.value, + "esc": Key.esc.value, + "escape": Key.esc.value, + "space": Key.space.value, + "tab": Key.tab.value, + "del": Key.delete.value, + "delete": Key.delete.value, + "home": Key.home.value, + "end": Key.end.value, + "pageup": Key.page_up.value, + "pgup": Key.page_up.value, + "pagedown": Key.page_down.value, + "pgdn": Key.page_down.value, + "insert": Key.insert.value, + "up": Key.up.value, + "down": Key.down.value, + "left": Key.left.value, + "right": Key.right.value, + "numlock": Key.num_lock.value, + "decimal": KeyCode.from_char("."), + "add": KeyCode.from_char("+"), + "subtract": KeyCode.from_char("-"), + "multiply": KeyCode.from_char("*"), + "divide": KeyCode.from_char("/"), + "printscreen": Key.print_screen.value, + "prntscrn": Key.print_screen.value, + "prtsc": Key.print_screen.value, + "prtscr": Key.print_screen.value, + "scrolllock": Key.scroll_lock.value, + "pause": Key.pause.value, + "volumemute": Key.media_volume_mute.value, + "volumeup": Key.media_volume_up.value, + "volumedown": Key.media_volume_down.value, + "playpause": Key.media_play_pause.value, + "nexttrack": Key.media_next.value, + "prevtrack": Key.media_previous.value, + "a": KeyCode.from_char("a"), + "b": KeyCode.from_char("b"), + "c": KeyCode.from_char("c"), + "d": KeyCode.from_char("d"), + "e": KeyCode.from_char("e"), + "f": KeyCode.from_char("f"), + "g": KeyCode.from_char("g"), + "h": KeyCode.from_char("h"), + "i": KeyCode.from_char("i"), + "j": KeyCode.from_char("j"), + "k": KeyCode.from_char("k"), + "l": KeyCode.from_char("l"), + "m": KeyCode.from_char("m"), + "n": KeyCode.from_char("n"), + "o": KeyCode.from_char("o"), + "p": KeyCode.from_char("p"), + "q": KeyCode.from_char("q"), + "r": KeyCode.from_char("r"), + "s": KeyCode.from_char("s"), + "t": KeyCode.from_char("t"), + "u": KeyCode.from_char("u"), + "v": KeyCode.from_char("v"), + "w": KeyCode.from_char("w"), + "x": KeyCode.from_char("x"), + "y": KeyCode.from_char("y"), + "z": KeyCode.from_char("z"), + # F1, F2, ... + **{f"f{i}": getattr(Key, f"f{i}").value for i in range(1, 21)}, + # 0, 1, ... + **{f"num{i}": KeyCode.from_char(str(i)) for i in range(10)}, + **{f"{i}": KeyCode.from_char(str(i)) for i in range(10)}, +} + +CommandStr = Literal["split", "start", "pause", "reset", "skip", "undo"] + +Hotkey = Literal[ + "split", + "reset", + "skip_split", + "undo_split", + "pause", + "screenshot", + "toggle_auto_reset_image", +] + +CommandToHotkey = { + "split": "split", + "pause": "pause", + "reset": "reset", + "skip": "skip_split", + "undo": "undo_split", +} + +HOTKEYS = ( + "split", + "reset", + "skip_split", + "undo_split", + "pause", + "screenshot", + "toggle_auto_reset_image", +) + +HOTKEYS_WHEN_AUTOCONTROLLED = {"screenshot", "toggle_auto_reset_image"} diff --git a/src/hotkeys.py b/src/hotkeys.py index 36d6870f..f94349e4 100644 --- a/src/hotkeys.py +++ b/src/hotkeys.py @@ -1,15 +1,13 @@ from __future__ import annotations import sys -from collections.abc import Callable -from typing import TYPE_CHECKING, Literal, cast +from typing import TYPE_CHECKING, cast -import keyboard -import pyautogui -from PySide6 import QtWidgets +from PySide6 import QtGui, QtWidgets import error_messages -from utils import fire_and_forget, is_digit, try_input_device_access +from hotkey_constants import HOTKEYS, SPECIAL_KEYS, CommandStr, Hotkey +from utils import try_input_device_access if sys.platform == "linux": import grp @@ -25,38 +23,9 @@ 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..." -CommandStr = Literal["split", "start", "pause", "reset", "skip", "undo"] -Hotkey = Literal[ - "split", - "reset", - "skip_split", - "undo_split", - "pause", - "screenshot", - "toggle_auto_reset_image", -] -HOTKEYS = ( - "split", - "reset", - "skip_split", - "undo_split", - "pause", - "screenshot", - "toggle_auto_reset_image", -) -HOTKEYS_WHEN_AUTOCONTROLLED = {"screenshot", "toggle_auto_reset_image"} - - -def remove_all_hotkeys(): - if not KEYBOARD_GROUPS_ISSUE and not KEYBOARD_UINPUT_ISSUE: - keyboard.unhook_all() - def before_setting_hotkey(autosplit: AutoSplit): """Do all of these after you click "Set Hotkey" but before you type the hotkey.""" @@ -95,212 +64,52 @@ def send_command(autosplit: AutoSplit, command: CommandStr): # But that is dependent on migrating to an observer pattern (#219) and # being able to reload all images. 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"]) + autosplit.hotkey_thread.run_action_from_cmd("reset") case _: - raise KeyError(f"{command!r} is not a valid command") + if command == "start": + command = "split" + autosplit.hotkey_thread.run_action_from_cmd(command) -def _unhook(hotkey_callback: Callable[[], None] | None): - try: - if hotkey_callback: - keyboard.unhook_key(hotkey_callback) - except AttributeError, KeyError, ValueError: - pass +def __is_valid_hotkey_name(hotkey_name: str): + return any(key and key not in SPECIAL_KEYS for key in hotkey_name.split("+")) -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 +def __pause_thread(autosplit: AutoSplit): + autosplit.hotkey_thread.is_paused = True + autosplit.hotkey_thread.stop_listener() - # Deal with regular inputs - # If an int or does not contain the following strings - if ( # fmt: skip - 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 # fmt: skip - 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: # 0-length - 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(navigate_image_only=True) - if hotkey == "undo_split": - return lambda: autosplit.undo_split(navigate_image_only=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("+") - ) +def __resume_thread(autosplit: AutoSplit): + autosplit.hotkey_thread.is_paused = False + # the run function will set again the listener # 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 = ""): +# TODO: reimplement already existing checks +def set_hotkey( + autosplit: AutoSplit, + hotkey: Hotkey, + hotkey_name: str | None = None, + input_ref: QtWidgets.QKeySequenceEdit | None = None, +): + __pause_thread(autosplit) + if KEYBOARD_GROUPS_ISSUE: - if not preselected_hotkey_name: + if hotkey_name is None: error_messages.linux_groups() + __resume_thread(autosplit) return if KEYBOARD_UINPUT_ISSUE: - if not preselected_hotkey_name: + if hotkey_name is None: error_messages.linux_uinput() + __resume_thread(autosplit) return - if autosplit.SettingsWidget: + if autosplit.SettingsWidget is not None: # Unfocus all fields cast(QtWidgets.QWidget, autosplit.SettingsWidget).setFocus() getattr(autosplit.SettingsWidget, f"set_{hotkey}_hotkey_button").setText(PRESS_A_KEY_TEXT) @@ -308,65 +117,30 @@ def set_hotkey(autosplit: AutoSplit, hotkey: Hotkey, preselected_hotkey_name: st # 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] - "" + try: + if hotkey_name is not None: + if autosplit.SettingsWidget is not None and len(hotkey_name) > 0: + hotkey_input: QtWidgets.QKeySequenceEdit = getattr( + autosplit.SettingsWidget, f"{hotkey}_input" ) - 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"] = ( # pyright: ignore[reportGeneralTypeIssues] - hotkey_name - ) - 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() + if not __is_valid_hotkey_name(hotkey_name): + autosplit.show_error_signal.emit( + lambda: error_messages.invalid_hotkey(hotkey_name) + ) + __resume_thread(autosplit) + return + + hotkey_input.setKeySequence(QtGui.QKeySequence(hotkey_name)) + + autosplit.hotkey_thread.set_sequence(hotkey, hotkey_name) + elif input_ref is not None: + autosplit.hotkey_thread.set_sequence(hotkey, input_ref.keySequence().toString().lower()) + else: + raise ValueError("set_hotkey: unexpected operating mode") + except Exception: # noqa: BLE001 # We really want to catch everything here + autosplit.show_error_signal.emit(lambda: error_messages.exception_traceback(exception)) + finally: + autosplit.after_setting_hotkey_signal.emit() + + __resume_thread(autosplit) diff --git a/src/hotkeys_thread.py b/src/hotkeys_thread.py new file mode 100644 index 00000000..49152b8b --- /dev/null +++ b/src/hotkeys_thread.py @@ -0,0 +1,188 @@ +import time +from typing import TYPE_CHECKING, Any + +from pynput import keyboard +from PySide6.QtCore import QThread + +import error_messages +from hotkey_constants import ( + FUNC_KEYS, + HOTKEYS, + SPECIAL_KEYS, + STR_TO_KEYS, + CommandStr, + CommandToHotkey, + Hotkey, +) + +if TYPE_CHECKING: + from AutoSplit import AutoSplit + + +def assert_and_show_error(autosplit: AutoSplit, cond: bool, msg: str): + try: + assert cond, msg + except Exception: + autosplit.show_error_signal.emit(lambda: error_messages.exception_traceback(exception)) + + +class HotKeyDef: + """Hotkey Definition, common settings per-hotkey.""" + + def __init__(self, autosplit: AutoSplit, name: Hotkey, action: Any): + """Sets the name, binds the action and declares empty sequence.""" + self.autosplit = autosplit + self.name = name + self.action = action + self.sequence_str: str | None = None + + # sequence used by the listener + self.sequence: str | None = None + + # sequence used by the sender + self.send_keys: list[keyboard.KeyCode] = [] + + def clear(self): + """Resets the hotkey definition, make sure to stop the listener when using this.""" + self.sequence_str = None + self.sequence = None + self.send_keys.clear() + + def set_sequence(self, sequence_str: str): + """Sets a sequence of keys to read/write.""" + # don't process empty sequences + if len(sequence_str) == 0: + return + + self.sequence_str = sequence_str + + # `keyboard.GlobalHotkeys` requires < and > to be wrapped around specific keys like modifiers (ctrl, alt, ...) + special_keys = SPECIAL_KEYS + FUNC_KEYS + + split = sequence_str.split("+") + sequence: list[str] = [] + + for elem in split: + sequence.append(f"<{elem}>" if elem in special_keys else elem) + assert_and_show_error(self.autosplit, elem in STR_TO_KEYS, f"unsupported key: {elem!r}") + self.send_keys.append(STR_TO_KEYS[elem]) + + self.sequence = "+".join(sequence) + + +class HotKeyThread(QThread): + """Hotkey thread, handles listening and sending inputs.""" + + def __init__(self, autosplit: AutoSplit): + """Initializes the thread.""" + super().__init__() + self.autosplit = autosplit + self.is_paused = False + self.listener: keyboard.GlobalHotKeys | None = None + + # hotkey_def = HotKeyDef(hotkey_name, self.get_hotkey_action(hotkey_name)) + for name in HOTKEYS: + setattr(self, f"{name}_def", HotKeyDef(autosplit, name, self.get_hotkey_action(name))) + + def get_hotkey_action(self, hotkey: Hotkey): + """Fetch the action corresponding to the target hotkey.""" + if hotkey == "split": + return self.autosplit.start_auto_splitter + if hotkey == "skip_split": + return lambda: self.autosplit.skip_split(navigate_image_only=True) + if hotkey == "undo_split": + return lambda: self.autosplit.undo_split(navigate_image_only=True) + if hotkey == "toggle_auto_reset_image": + + def toggle_auto_reset_image(): + new_value = not self.autosplit.settings_dict["enable_auto_reset"] + self.autosplit.settings_dict["enable_auto_reset"] = new_value + if self.autosplit.SettingsWidget: + self.autosplit.SettingsWidget.enable_auto_reset_image_checkbox.setChecked( + new_value + ) + + return toggle_auto_reset_image + return getattr(self.autosplit, f"{hotkey}_signal").emit + + def get_def(self, name: str | Hotkey) -> HotKeyDef: + """Returns the definition ref of the specific hotkey.""" + keydef: HotKeyDef | None = getattr(self, f"{name}_def") + assert_and_show_error(self.autosplit, keydef is not None, "key def is none") + return keydef + + def stop_listener(self): + """If the listener is running stop it and clear the reference.""" + if self.listener is not None: + self.listener.stop() + self.listener = None + + def set_sequence(self, name: Hotkey, sequence_str: str): + """Sets a new key sequence (restarts the listener).""" + self.stop_listener() + + # clear any hotkey sharing the same sequence + for hotkey in HOTKEYS: + if hotkey not in name: + hdef = self.get_def(hotkey) + + if hdef.sequence_str is not None and hdef.sequence_str == sequence_str: + hdef.clear() + + self.get_def(name).set_sequence(sequence_str) + self.autosplit.settings_dict[f"{name}_hotkey"] = sequence_str # pyright: ignore[reportGeneralTypeIssues] + + def remove_all_hotkeys(self): + """Clears all hotkeys (restarts the listener).""" + self.stop_listener() + + for name in HOTKEYS: + self.get_def(name).clear() + + def get_gh_map(self): + """Generates the map to use in `keyboard.GlobalHotkeys`.""" + # sequence string: action callback + gh_map: dict[str, Any] = {} + + for name in HOTKEYS: + keydef = self.get_def(name) + if keydef.sequence is not None and len(keydef.sequence) > 0: + gh_map[keydef.sequence] = keydef.action + + return gh_map + + def run(self): + """ + Thread's main loop, which is only used for the listener. + This function will automatically start again the listener if it was previously cleared by `stop_listener`. + """ + while True: + if self.is_paused: + continue + + if self.listener is None: + with keyboard.GlobalHotKeys(self.get_gh_map()) as listener: + self.listener = listener + listener.join() + + def run_action_from_cmd(self, cmd: CommandStr): + """Run the corresponding action by sending the inputs if the command is valid.""" + if cmd not in CommandToHotkey: + raise KeyError(f"{cmd!r} is not a valid command") + + keydef = self.get_def(CommandToHotkey[cmd]) + assert_and_show_error( + self.autosplit, len(keydef.send_keys) > 0, "can't send inputs: sequence is not set" + ) + controller = keyboard.Controller() + + # it seems the sleeps are required, in some cases the key is still considered as pressed when it shouldn't + for key in keydef.send_keys: + controller.press(key) + time.sleep(0.01) + + time.sleep(0.1) + + for key in reversed(keydef.send_keys): + controller.release(key) + time.sleep(0.01) diff --git a/src/menu_bar.py b/src/menu_bar.py index 4249e4d8..98091745 100644 --- a/src/menu_bar.py +++ b/src/menu_bar.py @@ -10,7 +10,7 @@ from gen import about, design, settings as settings_ui, update_checker from packaging.version import parse as version_parse -from PySide6 import QtCore, QtWidgets +from PySide6 import QtCore, QtGui, QtWidgets from PySide6.QtCore import Qt from PySide6.QtGui import QBrush, QPalette from PySide6.QtWidgets import QFileDialog @@ -24,7 +24,8 @@ change_capture_method, get_all_video_capture_devices, ) -from hotkeys import HOTKEYS, HOTKEYS_WHEN_AUTOCONTROLLED, CommandStr, set_hotkey +from hotkey_constants import HOTKEYS, HOTKEYS_WHEN_AUTOCONTROLLED, CommandStr +from hotkeys import set_hotkey from utils import AUTOSPLIT_VERSION, GITHUB_REPOSITORY, ONE_SECOND, decimal, fire_and_forget if TYPE_CHECKING: @@ -335,25 +336,76 @@ def __select_screenshot_directory(self): self._autosplit_ref.settings_dict["screenshot_directory"] ) + def eventFilter(self, watched: QtCore.QObject, event: QtCore.QEvent): + # catch enter and escape keys here, we can override more keys by adding more checks here! + + for hotkey in HOTKEYS: + hotkey_input: QtWidgets.QKeySequenceEdit = getattr(self, f"{hotkey}_input") + + # find the widget sending the event + if watched == hotkey_input: + clear_focus = False + + if event.type() == QtCore.QEvent.Type.KeyPress: + # clear the hotkey if escape is pressed + if event.key() == QtCore.Qt.Key.Key_Escape: + hotkey_input.clear() + self._autosplit_ref.settings_dict[f"{hotkey}_hotkey"] = "" # pyright: ignore[reportGeneralTypeIssues] + + if event.key() in { + QtCore.Qt.Key.Key_Return, + QtCore.Qt.Key.Key_Enter, + QtCore.Qt.Key.Key_Escape, + }: + clear_focus = True + elif event.type() in { + QtCore.QEvent.Type.MouseButtonPress, + QtCore.QEvent.Type.MouseButtonDblClick, + QtCore.QEvent.Type.MouseButtonRelease, + }: + clear_focus = True + + # clear the focus if either enter or escape are pressed (to confirm the input) + if clear_focus: + hotkey_input.clearFocus() + return True # mark the event as cleared + + # default behavior + return super().eventFilter(watched, event) + def __setup_bindings(self): # Hotkey initial values and bindings for hotkey in HOTKEYS: - hotkey_input: QtWidgets.QLineEdit = getattr(self, f"{hotkey}_input") + hotkey_input: QtWidgets.QKeySequenceEdit = getattr(self, f"{hotkey}_input") set_hotkey_hotkey_button: QtWidgets.QPushButton = getattr( self, f"set_{hotkey}_hotkey_button", ) - hotkey_input.setText(self._autosplit_ref.settings_dict.get(f"{hotkey}_hotkey", "")) + + # if a hotkey exists set it, otherwise clear the sequence to make sure it's accurate to the settings + sequence_str: str | None = self._autosplit_ref.settings_dict.get(f"{hotkey}_hotkey") + if sequence_str is not None: + hotkey_input.setKeySequence(QtGui.QKeySequence(sequence_str)) + else: + hotkey_input.clear() # Make it very clear that hotkeys are not used when auto-controlled if self._autosplit_ref.is_auto_controlled and hotkey not in HOTKEYS_WHEN_AUTOCONTROLLED: set_hotkey_hotkey_button.setEnabled(False) hotkey_input.setEnabled(False) else: - set_hotkey_hotkey_button.clicked.connect( - partial(set_hotkey, self._autosplit_ref, hotkey=hotkey) + # this is necessary to use the `eventFilter` function above + # the alternative would be a custom widget + hotkey_input.installEventFilter(self) + + # connect `set_hotkey` to the editing finished signal so it sets the hotkey when we're done typing + hotkey_input.editingFinished.connect( + partial(set_hotkey, self._autosplit_ref, hotkey=hotkey, input_ref=hotkey_input) ) + # if "set hotkey" is pressed simply focus the key sequence widget so we can start typing + set_hotkey_hotkey_button.clicked.connect(partial(hotkey_input.setFocus)) + # Debug screenshot selection checkboxes initial values and bindings screenshot_on_setting = self._autosplit_ref.settings_dict["screenshot_on"] for command in _DEBUG_SCREENSHOT_COMMANDS: @@ -473,15 +525,25 @@ def get_default_settings_from_ui(autosplit: AutoSplit): default_settings_dialog = settings_ui.Ui_SettingsWidget() default_settings_dialog.setupUi(temp_dialog) default_settings: user_profile.UserProfileDict = { - "split_hotkey": default_settings_dialog.split_input.text(), - "reset_hotkey": default_settings_dialog.reset_input.text(), - "undo_split_hotkey": default_settings_dialog.undo_split_input.text(), - "skip_split_hotkey": default_settings_dialog.skip_split_input.text(), - "pause_hotkey": default_settings_dialog.pause_input.text(), - "screenshot_hotkey": default_settings_dialog.screenshot_input.text(), - "toggle_auto_reset_image_hotkey": ( - default_settings_dialog.toggle_auto_reset_image_input.text() - ), + "split_hotkey": default_settings_dialog.split_input.keySequence().toString().lower(), + "reset_hotkey": default_settings_dialog.reset_input.keySequence().toString().lower(), + "undo_split_hotkey": default_settings_dialog.undo_split_input + .keySequence() + .toString() + .lower(), + "skip_split_hotkey": default_settings_dialog.skip_split_input + .keySequence() + .toString() + .lower(), + "pause_hotkey": default_settings_dialog.pause_input.keySequence().toString().lower(), + "screenshot_hotkey": default_settings_dialog.screenshot_input + .keySequence() + .toString() + .lower(), + "toggle_auto_reset_image_hotkey": default_settings_dialog.toggle_auto_reset_image_input + .keySequence() + .toString() + .lower(), "fps_limit": default_settings_dialog.fps_limit_spinbox.value(), "live_capture_region": default_settings_dialog.live_capture_region_checkbox.isChecked(), "capture_method": CAPTURE_METHODS.get_method_by_index( diff --git a/src/user_profile.py b/src/user_profile.py index c631ecdb..2f0c2b56 100644 --- a/src/user_profile.py +++ b/src/user_profile.py @@ -12,7 +12,8 @@ import error_messages from capture_method import CAPTURE_METHODS, CaptureMethodEnum, Region, change_capture_method -from hotkeys import HOTKEYS, CommandStr, remove_all_hotkeys, set_hotkey +from hotkey_constants import HOTKEYS, CommandStr +from hotkeys import set_hotkey from menu_bar import open_settings from utils import auto_split_directory @@ -155,13 +156,13 @@ def __load_settings_from_file(autosplit: AutoSplit, load_settings_file_path: str autosplit.show_error_signal.emit(error_messages.invalid_settings) return False - remove_all_hotkeys() + autosplit.hotkey_thread.remove_all_hotkeys() if not autosplit.is_auto_controlled: for hotkey in HOTKEYS: hotkey_name = f"{hotkey}_hotkey" hotkey_value = autosplit.settings_dict.get(hotkey_name) - if hotkey_value: - set_hotkey(autosplit, hotkey, hotkey_value) + if hotkey_value is not None: + set_hotkey(autosplit, hotkey, hotkey_name=hotkey_value) change_capture_method( cast(CaptureMethodEnum, autosplit.settings_dict["capture_method"]), diff --git a/uv.lock b/uv.lock index ba8e5819..8577e4be 100644 --- a/uv.lock +++ b/uv.lock @@ -11,14 +11,6 @@ supported-markers = [ "sys_platform == 'win32'", ] -[manifest] - -[[manifest.dependency-metadata]] -name = "pyautogui" - -[[manifest.dependency-metadata]] -name = "types-pyautogui" - [[package]] name = "altgraph" version = "0.17.4" @@ -33,15 +25,14 @@ name = "autosplit" version = "2.3.2" source = { virtual = "." } dependencies = [ - { name = "keyboard", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "levenshtein", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "numpy", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "opencv-contrib-python-headless", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "packaging", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "pillow", marker = "sys_platform == 'linux'" }, - { name = "pyautogui", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "pygrabber", marker = "sys_platform == 'win32'" }, { name = "pyinstaller", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pynput", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "pyside6-essentials", version = "6.8.0.2", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, { name = "pyside6-essentials", version = "6.9.1", source = { registry = "https://pypi.org/simple" }, marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or sys_platform == 'win32'" }, { name = "python-xlib", marker = "sys_platform == 'linux'" }, @@ -67,8 +58,6 @@ dev = [ { name = "pyright", extra = ["nodejs"], marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "qt6-applications", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "ruff", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "types-keyboard", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "types-pyautogui", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "types-pyinstaller", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "types-python-xlib", marker = "sys_platform == 'linux'" }, { name = "types-pywin32", marker = "sys_platform == 'win32'" }, @@ -80,15 +69,14 @@ ruff = [ [package.metadata] requires-dist = [ - { name = "keyboard", git = "https://github.com/boppreh/keyboard.git" }, { name = "levenshtein", specifier = ">=0.25" }, { name = "numpy", specifier = ">=2.3.2" }, { name = "opencv-contrib-python-headless", specifier = ">=4.10" }, { name = "packaging", specifier = ">=20.0" }, { name = "pillow", marker = "sys_platform == 'linux'", specifier = ">=12.1.1" }, - { name = "pyautogui", specifier = ">=0.9.52" }, { name = "pygrabber", marker = "sys_platform == 'win32'", specifier = ">=0.2" }, { name = "pyinstaller", specifier = ">=6.15.0" }, + { name = "pynput", specifier = ">=1.8.1" }, { name = "pyside6-essentials", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'", specifier = ">=6.9.0" }, { name = "pyside6-essentials", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'", specifier = "<6.8.1" }, { name = "python-xlib", marker = "sys_platform == 'linux'", specifier = ">=0.33" }, @@ -114,8 +102,6 @@ dev = [ { name = "pyright", extras = ["nodejs"], specifier = ">=1.1.400" }, { name = "qt6-applications", specifier = ">=6.5.0" }, { name = "ruff" }, - { name = "types-keyboard" }, - { name = "types-pyautogui" }, { name = "types-pyinstaller" }, { name = "types-python-xlib", marker = "sys_platform == 'linux'" }, { name = "types-pywin32", marker = "sys_platform == 'win32'", specifier = ">=306.0.0.20240130" }, @@ -152,6 +138,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/e4/74b93da58d764c08632477788661697b77c3ae186a70f5dfd9c245dfb9a4/dprint_py-0.52.0.0-py3-none-win_amd64.whl", hash = "sha256:b0ec81122348e573b3e7ffdf955b0f70517ea6e29c8fc21b23ff6aa61704caa5", size = 22999189, upload-time = "2026-02-25T15:20:10.646Z" }, ] +[[package]] +name = "evdev" +version = "1.9.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/f5/397b61091120a9ca5001041dd7bf76c385b3bfd67a0e5bcb74b852bd22a4/evdev-1.9.3.tar.gz", hash = "sha256:2c140e01ac8437758fa23fe5c871397412461f42d421aa20241dc8fe8cfccbc9", size = 32723, upload-time = "2026-02-05T21:54:24.987Z" } + [[package]] name = "ewmhlib" version = "0.2" @@ -164,11 +156,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2f/3a/46ca34abf0725a754bc44ef474ad34aedcc3ea23b052d97b18b76715a6a9/EWMHlib-0.2-py3-none-any.whl", hash = "sha256:f5b07d8cfd4c7734462ee744c32d490f2f3233fa7ab354240069344208d2f6f5", size = 46657, upload-time = "2024-04-17T08:15:56.338Z" }, ] -[[package]] -name = "keyboard" -version = "0.13.5" -source = { git = "https://github.com/boppreh/keyboard.git#d232de09bda50ecb5211ebcc59b85bc6da6aaa24" } - [[package]] name = "levenshtein" version = "0.27.1" @@ -346,12 +333,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, ] -[[package]] -name = "pyautogui" -version = "0.9.54" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ff/cdae0a8c2118a0de74b6cf4cbcdcaf8fd25857e6c3f205ce4b1794b27814/PyAutoGUI-0.9.54.tar.gz", hash = "sha256:dd1d29e8fd118941cb193f74df57e5c6ff8e9253b99c7b04f39cfc69f3ae04b2", size = 61236, upload-time = "2023-05-24T20:11:32.972Z" } - [[package]] name = "pygrabber" version = "0.2" @@ -418,6 +399,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2d/13/076a20da28b82be281f7e43e16d9da0f545090f5d14b2125699232b9feba/PyMonCtl-0.92-py3-none-any.whl", hash = "sha256:2495d8dab78f9a7dbce37e74543e60b8bd404a35c3108935697dda7768611b5a", size = 45945, upload-time = "2024-04-22T10:07:09.566Z" }, ] +[[package]] +name = "pynput" +version = "1.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "evdev", marker = "(sys_platform == 'linux' and 'linux' in sys_platform) or (sys_platform == 'win32' and 'linux' in sys_platform)" }, + { name = "python-xlib", marker = "(sys_platform == 'linux' and 'linux' in sys_platform) or (sys_platform == 'win32' and 'linux' in sys_platform)" }, + { name = "six", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/c3/dccf44c68225046df5324db0cc7d563a560635355b3e5f1d249468268a6f/pynput-1.8.1.tar.gz", hash = "sha256:70d7c8373ee98911004a7c938742242840a5628c004573d84ba849d4601df81e", size = 82289, upload-time = "2025-03-17T17:12:01.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/4f/ac3fa906ae8a375a536b12794128c5efacade9eaa917a35dfd27ce0c7400/pynput-1.8.1-py2.py3-none-any.whl", hash = "sha256:42dfcf27404459ca16ca889c8fb8ffe42a9fe54f722fd1a3e130728e59e768d2", size = 91693, upload-time = "2025-03-17T17:12:00.094Z" }, +] + [[package]] name = "pyright" version = "1.1.408" @@ -473,7 +468,7 @@ name = "python-xlib" version = "0.33" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "six", marker = "sys_platform == 'linux'" }, + { name = "six", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/86/f5/8c0653e5bb54e0cbdfe27bf32d41f27bc4e12faa8742778c17f2a71be2c0/python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32", size = 269068, upload-time = "2022-12-25T18:53:00.824Z" } wheels = [