diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index f74d7190..55844c5a 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -39,7 +39,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v1 + uses: github/codeql-action/init@v2 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -50,7 +50,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v1 + uses: github/codeql-action/autobuild@v2 # ℹī¸ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -64,4 +64,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + uses: github/codeql-action/analyze@v2 diff --git a/README.md b/README.md index 55d2295b..c53827e9 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=Avasam_Auto-Split&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=Avasam_Auto-Split) [![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=Avasam_Auto-Split&metric=reliability_rating)](https://sonarcloud.io/dashboard?id=Avasam_Auto-Split) [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=Avasam_Auto-Split&metric=security_rating)](https://sonarcloud.io/dashboard?id=Avasam_Auto-Split) -[![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=Avasam_Auto-Split&metric=duplicated_lines_density)](https://sonarcloud.io/dashboard?id=Avasam_Auto-Split) [![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=Avasam_Auto-Split&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=Avasam_Auto-Split) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=Avasam_Auto-Split&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=Avasam_Auto-Split) +[![SemVer](https://badgen.net/badge/_/SemVer%20compliant/grey?label)](https://semver.org/) Easy to use image comparison based auto splitter for speedrunning on console or PC. @@ -18,28 +18,29 @@ This program can be used to automatically start, split, and reset your preferred ## DOWNLOAD AND OPEN -### Compatibility - -- Windows 7, 10, and 11. +- Download the [latest version](/../../releases/latest) -### Opening the program +### Compatibility -- Download the [latest version](/../../releases/latest) -- Extract the file and open AutoSplit.exe. +- Windows 10 and 11. ### Building (This is not required for normal use) -- Microsoft Visual C++ 14.0 or greater may be required to build the executable. Get it with [Microsoft C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) +- Python 3.9 - 3.10. +- Microsoft Visual C++ 14.0 or greater may be required to build the executable. Get it with [Microsoft C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/). - Node is optional, but required for complete linting (using Pyright). -- Read [requirements.txt](/scripts/requirements.txt) for more information on how to install, run and build the python code - - Run `.\scripts\install.bat` to install all dependencies - - Run the app directly with `.\scripts\start.bat [--auto-controlled]` - - Run `.\scripts\build.bat` to build an executable -- Recompile resources after modifications by running `.\scripts\compile_resources.bat` +- Read [requirements.txt](/scripts/requirements.txt) for more information on how to install, run and build the python code. + - Run `./scripts/install.ps1` to install all dependencies. + - Run the app directly with `./scripts/start.ps1 [--auto-controlled]`. + - Run `./scripts/build.ps1` to build an executable. +- Recompile resources after modifications by running `./scripts/compile_resources.ps1`. +- All configured for VSCode, including Run (F5) and Build (Ctrl+Shift+B) commands. -## Split Image Folder +## OPTIONS + +#### Split Image Folder - Supported image file types: .png, .jpg, .jpeg, .bmp, and [more](https://docs.opencv.org/3.0-beta/modules/imgcodecs/doc/reading_and_writing_images.html#imread). - Images can be any size. @@ -48,31 +49,31 @@ This program can be used to automatically start, split, and reset your preferred - Custom split image settings are handled in the filename. See how [here](#custom-split-image-settings). - To create split images, it is recommended to use AutoSplit's Take Screenshot button for accuracy. However, images can be created using any method including Print Screen and [Snipping Tool](https://support.microsoft.com/en-us/help/4027213/windows-10-open-snipping-tool-and-take-a-screenshot). -## Capture Region +#### Capture Region - This is the region that your split images are compared to. Usually, this is going to be the full game screen. -- Click "Select Region" +- Click "Select Region". - Click and drag to form a rectangle over the region you want to capture. - Adjust the x, y, width, and height of the capture region manually to make adjustments as needed. -- If you want to align your capture region by using a reference image, click "Align Region" +- If you want to align your capture region by using a reference image, click "Align Region". - You can freely move the window that the program is capturing, but resizing the window will cause the capture region to change. - Once you are happy with your capture region, you may unselect Live Capture Region to decrease CPU usage if you wish. - You can save a screenshot of the capture region to your split image folder using the Take Screenshot button. -## Avg. FPS +#### Avg. FPS - Calculates the average comparison rate of the capture region to split images. This value will likely be much higher than needed, so it is highly recommended to limit your FPS depending on the frame rate of the game you are capturing. -## OPTIONS +### Settings -### Comparison Method +#### Comparison Method - There are three comparison methods to choose from: L2 Norm, Histograms, and Perceptual Hash (or pHash). - - L2 Norm: This method should be fine to use for most cases. it finds the difference between each pixel, squares it, and sums it over the entire image and takes the square root. This is very fast but is a problem if your image is high frequency. Any translational movement or rotation can cause similarity to be very different. + - L2 Norm: This method should be fine to use for most cases. It finds the difference between each pixel, squares it, sums it over the entire image and takes the square root. This is very fast but is a problem if your image is high frequency. Any translational movement or rotation can cause similarity to be very different. - Histograms: An explanation on Histograms comparison can be found [here](https://mpatacchiola.github.io/blog/2016/11/12/the-simplest-classifier-histogram-intersection.html). This is a great method to use if you are using several masked images. - Perceptual Hash: An explanation on pHash comparison can be found [here](http://www.hackerfactor.com/blog/index.php?/archives/432-Looks-Like-It.html). It is highly recommended to NOT use pHash if you use masked images. It is very inaccurate. -### Capture Method +#### Capture Method - **BitBlt** (fastest, least compatible) A good default fast option. But it cannot properly record OpenGL, Hardware Accelerated or Exclusive Fullscreen windows. @@ -96,34 +97,59 @@ This program can be used to automatically start, split, and reset your preferred There are currently performance issues, but it might be more convenient. If you want to use this with OBS' Virtual Camera, use the [Virtualcam plugin](https://obsproject.com/forum/resources/obs-virtualcam.949/) instead. -### Show Live Similarity +#### Show Live Similarity - Displays the live similarity between the capture region and the current split image. This number is between 0 and 1, with 1 being a perfect match. -### Show Highest Similarity +#### Show Highest Similarity - Shows the highest similarity between the capture region and current split image. -### Current Similarity Threshold +#### Current Similarity Threshold - When the live similarity goes above this value, the program hits your split hotkey and moves to the next split image. -### Default Similarity Threshold +#### Default Similarity Threshold - This value will be set as the threshold for an image if there is no custom threshold set for that image. -### Pause Time +#### Pause Time - Time in seconds that the program stops comparison after a split. Useful for if you have two of the same split images in a row and want to avoid double-splitting. Also useful for reducing CPU usage. -### Default Pause Time +#### Default Pause Time - This value will be set as the Pause Time for an image if there is no custom Pause Time set for that image. -### Delay Time +#### Delay Time - Time in milliseconds that the program waits before hitting the split hotkey for that specific split. +#### Dummy splits when undoing / skipping + +AutoSplit will group dummy splits together with a real split when undoing/skipping. This basically allows you to tie one or more dummy splits to a real split to keep it as in sync as possible with the real splits in LiveSplit/wsplit. If they are out of sync, you can always use "Previous Image" and "Next Image". + +Examples: +Given these splits: 1 dummy, 2 normal, 3 dummy, 4 dummy, 5 normal, 6 normal. + +In this situation you would have only 3 splits in LiveSplit/wsplit (even though there are 6 split images, only 3 are "real" splits). This basically results in 3 groups of splits: 1st split is images 1 and 2. 2nd split is images 3, 4 and 5. 3rd split is image 6. + +- If you are in the 1st or 2nd image and press the skip key, it will end up on the 3rd image +- If you are in the 3rd, 4th or 5th image and press the undo key, it will end up on the 2nd image +- If you are in the 3rd, 4th or 5th image and press the skip key, it will end up on the 6th image +- If you are in the 6th image and press the undo key, it will end up on the 5th image + +#### Loop Split Images + +If this option is enabled, when the last split meets the threshold and splits, AutoSplit will loop back to the first split image and continue comparisons. +If this option is disabled, when the last split meets the threshold and splits, AutoSplit will stop running comparisons. +This option does not loop single, specific images. See the Custom Split Image Settings section above for this feature. + +#### Auto Start On Reset + +If this option is enabled, when the reset hotkey is hit, the reset button is pressed, or the reset split image meets its threshold, AutoSplit will reset and automatically start again back at the first split image. +If this option is disabled, when the reset hotkey is hit, the reset button is pressed, or the reset split image meets its threshold, AutoSplit will stop running comparisons. + ### Custom Split Image Settings - Each split image can have different thresholds, pause times, delay split times, loop amounts, and can be flagged. @@ -131,17 +157,23 @@ This program can be used to automatically start, split, and reset your preferred - Custom thresholds are place between parenthesis `()` in the filename. This value will override the default threshold. - Custom pause times are placed between square brackets `[]` in the filename. This value will override the default pause time. - Custom delay times are placed between hash signs `##` in the filename. Note that these are in milliseconds. For example, a 10 second split delay would be `#10000#`. You cannot skip or undo splits during split delays. +- A different comparison method can be specified with their 0-base index between carets `^^`: + - `^0^`: L2 Norm + - `^1^`: Histogram + - `^2^`: Perceptual Hash - Image loop amounts are placed between at symbols `@@` in the filename. For example, a specific image that you want to split 5 times in a row would be `@5@`. The current loop # is conveniently located beneath the current split image. - Flags are placed between curly brackets `{}` in the filename. Multiple flags are placed in the same set of curly brackets. Current available flags: - - {d} dummy split image. When matched, it moves to the next image without hitting your split hotkey. - - {b} split when similarity goes below the threshold rather than above. When a split image filename has this flag, the split image similarity will go above the threshold, do nothing, and then split the next time the similarity goes below the threshold. - - {p} pause flag. When a split image filename has this flag, it will hit your pause hotkey rather than your split hokey. + - `{d}` dummy split image. When matched, it moves to the next image without hitting your split hotkey. + - `{b}` split when similarity goes below the threshold rather than above. When a split image filename has this flag, the split image similarity will go above the threshold, do nothing, and then split the next time the similarity goes below the threshold. + - `{p}` pause flag. When a split image filename has this flag, it will hit your pause hotkey rather than your split hokey. - Filename examples: - `001_SplitName_(0.9)_[10].png` is a split image with a threshold of 0.9 and a pause time of 10 seconds. - `002_SplitName_(0.9)_[10]_{d}.png` is the second split image with a threshold of 0.9, pause time of 10, and is a dummy split. - `003_SplitName_(0.85)_[20]_#3500#.png` is the third split image with a threshold of 0.85, pause time of 20 and has a delay split time of 3.5 seconds. - `004_SplitName_(0.9)_[10]_#3500#_@3@_{b}.png` is the fourth split image with a threshold of 0.9, pause time of 10 seconds, delay split time of 3.5 seconds, will loop 3 times, and will split when similarity is below the threshold rather than above. +## Special images + ### How to Create a Masked Image Masked images are very useful if only a certain part of the capture region is consistent (for example, consistent text on the screen, but the background is always different). Histogram or L2 norm comparison is recommended if you use any masked images. It is highly recommended that you do NOT use pHash comparison if you use any masked images, as it is very inaccurate. @@ -158,38 +190,6 @@ You can have one (and only one) image with the keyword `reset` in its name. Auto The start image is similar to the reset image. You can only have one start image with the keyword `start_auto_splitter`.You can reload the image using the "`Reload Start Image`" button. The pause time is the amount of seconds AutoSplit will wait before checking for the start image once a run ends/is reset. Delay times will be used to delay starting your timer after the threshold is met. If you need to pause comparison for any amount of time after the Start Image, the best option right now is to use a dummy split image `{d}` with a similarity threshold of `(0.00)` and a pause threshold `[]` of however long you need to pause comparison as your first split image. This will trigger the pause immediately after your timer is started. -### Timer Global Hotkeys - -- Click "Set Hotkey" on each hotkey to set the hotkeys to AutoSplit. The Start / Split hotkey and Pause hotkey must be the same as the one used in your preferred timer program in order for the splitting/pausing to work properly. -- Make sure that Global Hotkeys are enabled in your speedrun timer. -- All of these actions can also be handled by their corresponding buttons. -- Note that pressing your Pause Hotkey does not serve any function in AutoSplit itself and is strictly used for the Pause flag. - -### Dummy splits when undoing / skipping - -AutoSplit will group dummy splits together with a real split when undoing/skipping. This basically allows you to tie one or more dummy splits to a real split to keep it as in sync as possible with the real splits in LiveSplit/wsplit. If they are out of sync, you can always use "Previous Image" and "Next Image". - -Examples: -Given these splits: 1 dummy, 2 normal, 3 dummy, 4 dummy, 5 normal, 6 normal. - -In this situation you would have only 3 splits in LiveSplit/wsplit (even though there are 6 split images, only 3 are "real" splits). This basically results in 3 groups of splits: 1st split is images 1 and 2. 2nd split is images 3, 4 and 5. 3rd split is image 6. - -- If you are in the 1st or 2nd image and press the skip key, it will end up on the 3rd image -- If you are in the 3rd, 4th or 5th image and press the undo key, it will end up on the 2nd image -- If you are in the 3rd, 4th or 5th image and press the skip key, it will end up on the 6th image -- If you are in the 6th image and press the undo key, it will end up on the 5th image - -### Loop Split Images - -If this option is enabled, when the last split meets the threshold and splits, AutoSplit will loop back to the first split image and continue comparisons. -If this option is disabled, when the last split meets the threshold and splits, AutoSplit will stop running comparisons. -This option does not loop single, specific images. See the Custom Split Image Settings section above for this feature. - -### Auto Start On Reset - -If this option is enabled, when the reset hotkey is hit, the reset button is pressed, or the reset split image meets its threshold, AutoSplit will reset and automatically start again back at the first split image. -If this option is disabled, when the reset hotkey is hit, the reset button is pressed, or the reset split image meets its threshold, AutoSplit will stop running comparisons. - ### Profiles - Profiles are saved under `%appdata%\AutoSplit\profiles` and use the extension `.toml`. Profiles can be saved and loaded by using File -> Save Profile As... and File -> Load Profile. @@ -197,14 +197,23 @@ If this option is disabled, when the reset hotkey is hit, the reset button is pr - You can save multiple profiles, which is useful if you speedrun multiple games. - If you change your display setup (like using a new monitor, or upgrading to Windows 11), you may need to readjust or reselect your Capture Region. -## LiveSplit Integration +## Timer Integration + +### Timer Global Hotkeys + +- Click "Set Hotkey" on each hotkey to set the hotkeys to AutoSplit. The Start / Split hotkey and Pause hotkey must be the same as the one used in your preferred timer program in order for the splitting/pausing to work properly. +- Make sure that Global Hotkeys are enabled in your speedrun timer. +- All of these actions can also be handled by their corresponding buttons. +- Note that pressing your Pause Hotkey does not serve any function in AutoSplit itself and is strictly used for the Pause flag. + +### LiveSplit Integration The AutoSplit LiveSplit Component will directly connect AutoSplit with LiveSplit. LiveSplit integration is only supported in AutoSplit v1.6.0 or higher. This integration will allow you to: - Use hotkeys directly from LiveSplit to control AutoSplit and LiveSplit together - Load AutoSplit and any AutoSplit profile automatically when opening a LiveSplit layout. -### LiveSplit Integration Tutorial +#### LiveSplit Integration Tutorial - Click [here](https://github.com/Toufool/LiveSplit.AutoSplitIntegration/raw/main/update/Components/LiveSplit.AutoSplitIntegration.dll) to download the latest component. - Place the .dll file into your `[...]\LiveSplit\Components` folder. diff --git a/res/about.ui b/res/about.ui index 4cb2cc3d..05e4914a 100644 --- a/res/about.ui +++ b/res/about.ui @@ -52,7 +52,7 @@ 10 44 - 161 + 171 32 @@ -112,10 +112,10 @@ consider donating. Thank you! - 190 + 181 17 - 62 - 71 + 64 + 64 @@ -124,7 +124,13 @@ consider donating. Thank you! :/resources/icon.ico + + true + + icon_label + version_label + created_by_label diff --git a/res/design.ui b/res/design.ui index c53cc31f..a34632df 100644 --- a/res/design.ui +++ b/res/design.ui @@ -40,12 +40,6 @@ :/resources/icon.ico:/resources/icon.ico - - - - - Qt::LeftToRight - @@ -172,7 +166,7 @@ 92 - 255 + 254 20 20 @@ -184,8 +178,8 @@ - 119 - 68 + 120 + 67 320 240 @@ -212,8 +206,8 @@ - 450 - 68 + 449 + 67 320 240 @@ -231,7 +225,7 @@ - 451 + 449 31 318 20 @@ -274,7 +268,7 @@ 65 - 255 + 254 26 20 @@ -289,9 +283,12 @@ 11 200 44 - 22 + 24 + + QAbstractSpinBox::CorrectToNearestValue + 1 @@ -308,9 +305,12 @@ 66 200 44 - 22 + 24 + + QAbstractSpinBox::CorrectToNearestValue + 1 @@ -324,9 +324,9 @@ - 119 + 120 31 - 319 + 318 20 @@ -340,7 +340,7 @@ - 478 + 477 49 264 20 @@ -375,15 +375,15 @@ 11 160 44 - 22 + 24 - - false - QAbstractSpinBox::UpDownArrows + + QAbstractSpinBox::CorrectToNearestValue + 0 @@ -403,14 +403,11 @@ 66 160 44 - 22 + 24 - - false - - - QAbstractSpinBox::UpDownArrows + + QAbstractSpinBox::CorrectToNearestValue 0 @@ -813,7 +810,7 @@ 449 344 - 98 + 101 16 @@ -824,7 +821,7 @@ - 550 + 560 344 98 16 @@ -853,7 +850,7 @@ - 449 + 448 49 27 18 @@ -875,7 +872,7 @@ - 744 + 743 49 27 18 @@ -944,7 +941,10 @@ + + + @@ -963,75 +963,83 @@ View Help + + F1 + - About + About AutoSplit - - - - Split Image Settings + + QAction::AboutRole Save Profile + + Ctrl+S + Load Profile + + Ctrl+O + Save Profile As... + + Ctrl+Shift+S + Check For Updates - + + + Settings + + + Ctrl+, + + + QAction::PreferencesRole + + + true true - - true - - - Check for Updates on Open - - - Check For Updates On Open - + - Dummy Splits When Undoing/Skipping + About Qt - - - - Settings + + QAction::AboutQtRole - - - true - - - true - + - Check For Updates On Open + About Qt for Python + + + QAction::AboutQtRole diff --git a/res/icon.ico b/res/icon.ico index fae0f994..485824af 100644 Binary files a/res/icon.ico and b/res/icon.ico differ diff --git a/res/settings.ui b/res/settings.ui index ba2984fb..239356da 100644 --- a/res/settings.ui +++ b/res/settings.ui @@ -28,12 +28,18 @@ 621 - - ArrowCursor + + + 9 + Settings + + + :/resources/icon.ico:/resources/icon.ico + false @@ -61,8 +67,8 @@ 22 - - ArrowCursor + + QAbstractSpinBox::CorrectToNearestValue 20 @@ -86,9 +92,6 @@ This value will limit the amount of frames per second that AutoSplit will run comparisons - - - Comparison FPS Limit: @@ -253,9 +256,6 @@ It is highly recommended to NOT use pHash if you use masked images. It is very i 16 - - - Default Pause Time (sec): @@ -272,6 +272,9 @@ It is highly recommended to NOT use pHash if you use masked images. It is very i The amount of time in seconds that comparison will be paused before moving to the next image. + + QAbstractSpinBox::CorrectToNearestValue + 2 @@ -294,12 +297,6 @@ It is highly recommended to NOT use pHash if you use masked images. It is very i 16 - - - - - - Default Similarity Threshold: @@ -316,6 +313,9 @@ It is highly recommended to NOT use pHash if you use masked images. It is very i Threshold that the live similarity will need to go above to consider the image a match. + + QAbstractSpinBox::CorrectToNearestValue + 1.000000000000000 @@ -363,9 +363,6 @@ It is highly recommended to NOT use pHash if you use masked images. It is very i true - - teset - <html><head/><body><p>Custom image settings and flags are set in the <br></br> image file name. These will override the default <br></br> values. View the <a href="https://github.com/Toufool/Auto-Split#readme"><span style=" text-decoration: underline; color:#0000ff;">README</span></a> for full details on all <br></br> available custom image settings.</p></body></html> @@ -379,12 +376,6 @@ It is highly recommended to NOT use pHash if you use masked images. It is very i 16 - - - - - - Default Delay Time (ms): @@ -398,12 +389,12 @@ It is highly recommended to NOT use pHash if you use masked images. It is very i 22 - - ArrowCursor - After an image is matched, this is the amount of time in millseconds that will be delayed before splitting. + + QAbstractSpinBox::CorrectToNearestValue + 999999999 @@ -474,9 +465,6 @@ It is highly recommended to NOT use pHash if you use masked images. It is very i 20 - - Qt::StrongFocus - @@ -506,9 +494,6 @@ It is highly recommended to NOT use pHash if you use masked images. It is very i 20 - - IBeamCursor - @@ -599,9 +584,6 @@ It is highly recommended to NOT use pHash if you use masked images. It is very i 20 - - Qt::StrongFocus - @@ -670,13 +652,18 @@ It is highly recommended to NOT use pHash if you use masked images. It is very i - split_input - reset_input - undo_split_input - skip_split_input - pause_input + set_split_hotkey_button + set_reset_hotkey_button + set_undo_split_hotkey_button + set_skip_split_hotkey_button + set_pause_hotkey_button + fps_limit_spinbox + live_capture_region_checkbox + capture_method_combobox + capture_device_combobox default_comparison_method default_similarity_threshold_spinbox + default_delay_time_spinbox default_pause_time_spinbox loop_splits_checkbox fps_limit_spinbox diff --git a/res/splash.png b/res/splash.png new file mode 100644 index 00000000..2858bdb9 Binary files /dev/null and b/res/splash.png differ diff --git a/scripts/build.ps1 b/scripts/build.ps1 index cbd5631f..4ba5a3f8 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -1,8 +1,8 @@ & "$PSScriptRoot/compile_resources.ps1" - pyinstaller ` --windowed ` --onefile ` --additional-hooks-dir=Pyinstaller/hooks ` --icon=res/icon.ico ` + --splash=res/splash.png ` "$PSScriptRoot/../src/AutoSplit.py" diff --git a/scripts/requirements-dev.txt b/scripts/requirements-dev.txt index 5a8cd923..99f7134e 100644 --- a/scripts/requirements-dev.txt +++ b/scripts/requirements-dev.txt @@ -6,7 +6,7 @@ # Dependencies -r requirements.txt # -# Linting and formatters +# Linters and formatters bandit flake8>=5 # flake8-pyi deprecation warnings flake8-builtins diff --git a/scripts/requirements.txt b/scripts/requirements.txt index 5bc12d6c..00d56180 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -7,13 +7,13 @@ # If you're having issues with the libraries, you might want to first run: # pip uninstall -y -r ./scripts/requirements-dev.txt # -# Creating anAutoSplit executable with PyInstaller: ./scripts/build.ps1 +# Creating an AutoSplit executable with PyInstaller: ./scripts/build.ps1 # # Dependencies: numpy>=1.23 # Updated types opencv-python-headless>=4.5.4,<4.6 # https://github.com/pyinstaller/pyinstaller/issues/6889 PyQt6>=6.2.1 # Python 3.10 support -git+https://github.com/JohannesBuchner/imagehash#egg=ImageHash # Contains type information +git+https://github.com/Avasam/imagehash.git@patch-2#egg=ImageHash # Contains type information + setup as package not module keyboard packaging Pillow diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 0c3e71c2..949e7e8d 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -1,24 +1,18 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- - -# Imports grouping: -# - Typings -# - Standards -# - Externals -# - Internals from __future__ import annotations import ctypes import os import signal import sys -import traceback from collections.abc import Callable from time import time -from types import FunctionType, TracebackType +from types import FunctionType import certifi import cv2 +from psutil import process_iter from PyQt6 import QtCore, QtGui from PyQt6.QtTest import QTest from PyQt6.QtWidgets import QApplication, QFileDialog, QLabel, QMainWindow, QMessageBox, QWidget @@ -26,39 +20,26 @@ import error_messages import user_profile from AutoControlledWorker import AutoControlledWorker -from AutoSplitImage import COMPARISON_RESIZE, AutoSplitImage, ImageType +from AutoSplitImage import COMPARISON_RESIZE, START_KEYWORD, AutoSplitImage, ImageType from capture_method import CaptureMethodEnum, CaptureMethodInterface from gen import about, design, settings, update_checker -from hotkeys import after_setting_hotkey, send_command -from menu_bar import (VERSION, check_for_updates, get_default_settings_from_ui, open_about, open_settings, - open_update_checker, view_help) +from hotkeys import HOTKEYS, after_setting_hotkey, send_command +from menu_bar import (about_qt, about_qt_for_python, check_for_updates, get_default_settings_from_ui, open_about, + open_settings, open_update_checker, view_help) from region_selection import align_region, select_region, select_window, validate_before_parsing from split_parser import BELOW_FLAG, DUMMY_FLAG, PAUSE_FLAG, parse_and_validate_images from user_profile import DEFAULT_PROFILE -from utils import FROZEN, START_AUTO_SPLITTER_TEXT, is_valid_image +from utils import (AUTOSPLIT_VERSION, FIRST_WIN_11_BUILD, FROZEN, START_AUTO_SPLITTER_TEXT, WINDOWS_BUILD_NUMBER, + auto_split_directory, decimal, is_valid_image) CHECK_FPS_ITERATIONS = 10 # Needed when compiled, along with the custom hook-requests PyInstaller hook os.environ["REQUESTS_CA_BUNDLE"] = certifi.where() -myappid = f"Toufool.AutoSplit.v{VERSION}" +myappid = f"Toufool.AutoSplit.v{AUTOSPLIT_VERSION}" ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) -def make_excepthook(autosplit: AutoSplit): - def excepthook(exception_type: type[BaseException], exception: BaseException, _traceback: TracebackType | None): - # Catch Keyboard Interrupts for a clean close - if exception_type is KeyboardInterrupt or isinstance(exception, KeyboardInterrupt): - sys.exit(0) - autosplit.show_error_signal.emit( - lambda: error_messages.exception_traceback( - "AutoSplit encountered an unhandled exception and will try to recover, " - + "however, there is no guarantee it will keep working properly." - + error_messages.CREATE_NEW_ISSUE_MESSAGE, - exception)) - return excepthook - - class AutoSplit(QMainWindow, design.Ui_MainWindow): # Parse command line args is_auto_controlled = "--auto-controlled" in sys.argv @@ -112,7 +93,7 @@ class AutoSplit(QMainWindow, design.Ui_MainWindow): highest_similarity = 0.0 reset_highest_similarity = 0.0 - # Define all other attributes + # Ensure all other attributes are defined start_image_split_below_threshold = False waiting_for_split_delay = False split_below_threshold = False @@ -121,6 +102,7 @@ class AutoSplit(QMainWindow, design.Ui_MainWindow): reset_image: AutoSplitImage | None = None split_images: list[AutoSplitImage] = [] split_image: AutoSplitImage | None = None + update_auto_control: QtCore.QThread | None = None def __init__(self, parent: QWidget | None = None): # pylint: disable=too-many-statements super().__init__(parent) @@ -128,35 +110,29 @@ def __init__(self, parent: QWidget | None = None): # pylint: disable=too-many-s # Setup global error handling self.show_error_signal.connect(lambda errorMessageBox: errorMessageBox()) # Whithin LiveSplit excepthook needs to use MainWindow's signals to show errors - sys.excepthook = make_excepthook(self) + sys.excepthook = error_messages.make_excepthook(self) self.setupUi(self) + self.setWindowTitle(f"AutoSplit v{AUTOSPLIT_VERSION}") + # Spinbox frame disappears and reappears on Windows 11. It's much cleaner to just disable them. + # Most likely related: https://bugreports.qt.io/browse/QTBUG-95215?jql=labels%20%3D%20Windows11 + # Arrow buttons tend to move a lot as well + if WINDOWS_BUILD_NUMBER >= FIRST_WIN_11_BUILD: + self.x_spinbox.setFrame(False) + self.y_spinbox.setFrame(False) + self.width_spinbox.setFrame(False) + self.height_spinbox.setFrame(False) # Get default values defined in SettingsDialog self.settings_dict = get_default_settings_from_ui(self) user_profile.load_check_for_updates_on_open(self) - self.action_view_help.triggered.connect(view_help) - self.action_about.triggered.connect(lambda: open_about(self)) - self.action_check_for_updates.triggered.connect(lambda: check_for_updates(self)) - self.action_settings.triggered.connect(lambda: open_settings(self)) - self.action_save_profile.triggered.connect(lambda: user_profile.save_settings(self)) - self.action_save_profile_as.triggered.connect(lambda: user_profile.save_settings_as(self)) - self.action_load_profile.triggered.connect(lambda: user_profile.load_settings(self)) - - if self.SettingsWidget: - self.SettingsWidget.split_input.setEnabled(False) - self.SettingsWidget.reset_input.setEnabled(False) - self.SettingsWidget.skip_split_input.setEnabled(False) - self.SettingsWidget.undo_split_input.setEnabled(False) - self.SettingsWidget.pause_input.setEnabled(False) - if self.is_auto_controlled: self.start_auto_splitter_button.setEnabled(False) # Send version and process ID to stdout # THIS HAS TO BE THE FIRST TWO LINES SENT - print(f"{VERSION}\n{os.getpid()}", flush=True) + print(f"{AUTOSPLIT_VERSION}\n{os.getpid()}", flush=True) # Use and Start the thread that checks for updates from LiveSplit self.update_auto_control = QtCore.QThread() @@ -168,6 +144,25 @@ def __init__(self, parent: QWidget | None = None): # pylint: disable=too-many-s # split image folder line edit text self.split_image_folder_input.setText("No Folder Selected") + # Connecting menu actions + self.action_view_help.triggered.connect(view_help) + self.action_about.triggered.connect(lambda: open_about(self)) + self.action_about_qt.triggered.connect(lambda: about_qt) + self.action_about_qt_for_python.triggered.connect(lambda: about_qt_for_python) + self.action_check_for_updates.triggered.connect(lambda: check_for_updates(self)) + self.action_settings.triggered.connect(lambda: open_settings(self)) + self.action_save_profile.triggered.connect(lambda: user_profile.save_settings(self)) + self.action_save_profile_as.triggered.connect(lambda: user_profile.save_settings_as(self)) + self.action_load_profile.triggered.connect(lambda: user_profile.load_settings(self)) + + # Shortcut context can't be set through the designer because of a bug in pyuic6 that generates invalid code + # Email sent to pyqt@riverbankcomputing.com + self.action_view_help.setShortcutContext(QtCore.Qt.ShortcutContext.ApplicationShortcut) + self.action_settings.setShortcutContext(QtCore.Qt.ShortcutContext.ApplicationShortcut) + self.action_save_profile.setShortcutContext(QtCore.Qt.ShortcutContext.ApplicationShortcut) + self.action_save_profile_as.setShortcutContext(QtCore.Qt.ShortcutContext.ApplicationShortcut) + self.action_load_profile.setShortcutContext(QtCore.Qt.ShortcutContext.ApplicationShortcut) + # Connecting button clicks to functions self.browse_button.clicked.connect(self.__browse) self.select_region_button.clicked.connect(lambda: select_region(self)) @@ -175,10 +170,10 @@ def __init__(self, parent: QWidget | None = None): # pylint: disable=too-many-s self.start_auto_splitter_button.clicked.connect(self.__auto_splitter) self.check_fps_button.clicked.connect(self.__check_fps) self.reset_button.clicked.connect(self.reset) - self.skip_split_button.clicked.connect(self.__skip_split) - self.undo_split_button.clicked.connect(self.__undo_split) - self.next_image_button.clicked.connect(lambda: self.__skip_split(True)) - self.previous_image_button.clicked.connect(lambda: self.__undo_split(True)) + self.skip_split_button.clicked.connect(self.skip_split) + self.undo_split_button.clicked.connect(self.undo_split) + self.next_image_button.clicked.connect(lambda: self.skip_split(True)) + self.previous_image_button.clicked.connect(lambda: self.undo_split(True)) self.align_region_button.clicked.connect(lambda: align_region(self)) self.select_window_button.clicked.connect(lambda: select_window(self)) self.reload_start_image_button.clicked.connect(lambda: self.__load_start_image(True, True)) @@ -202,8 +197,8 @@ def __init__(self, parent: QWidget | None = None): # pylint: disable=too-many-s self.load_start_image_signal[bool].connect(self.__load_start_image) self.load_start_image_signal[bool, bool].connect(self.__load_start_image) self.reset_signal.connect(self.reset) - self.skip_split_signal.connect(self.__skip_split) - self.undo_split_signal.connect(self.__undo_split) + self.skip_split_signal.connect(self.skip_split) + self.undo_split_signal.connect(self.undo_split) self.pause_signal.connect(self.pause) # live image checkbox @@ -213,12 +208,18 @@ def __init__(self, parent: QWidget | None = None): # pylint: disable=too-many-s # Automatic timer start self.timer_start_image.timeout.connect(self.__start_image_function) - if not self.is_auto_controlled: - user_profile.load_settings_on_open(self) - self.show() - # Needs to be after Ui_MainWindow.show() to be shown overtop + try: + import pyi_splash # type: ignore # pylint: disable=import-outside-toplevel + pyi_splash.close() + except ModuleNotFoundError: + pass + + # Needs to be after Ui_MainWindow.show() to be shown on top + if not self.is_auto_controlled: + # Must also be done later to help load the saved capture window + user_profile.load_settings_on_open(self) if self.action_check_for_updates_on_open.isChecked(): check_for_updates(self, check_on_open=True) @@ -229,7 +230,7 @@ def __browse(self): new_split_image_directory = QFileDialog.getExistingDirectory( self, "Select Split Image Directory", - os.path.join(self.settings_dict["split_image_directory"] or user_profile.auto_split_directory, "..")) + os.path.join(self.settings_dict["split_image_directory"] or auto_split_directory, "..")) # If the user doesn't select a folder, it defaults to "". if new_split_image_directory: @@ -258,21 +259,21 @@ def __load_start_image(self, started_by_button: bool = False, wait_for_delay: bo self.current_image_file_label.setText("-") self.start_image_status_value_label.setText("not found") - if not self.is_auto_controlled \ - and (not self.settings_dict["split_hotkey"] - or not self.settings_dict["reset_hotkey"] - or not self.settings_dict["pause_hotkey"]): - error_messages.load_start_image() - QApplication.processEvents() - return - if not (validate_before_parsing(self, started_by_button) and parse_and_validate_images(self)): QApplication.processEvents() return - if self.start_image is None: + if self.start_image: + if not self.is_auto_controlled \ + and (not self.settings_dict["split_hotkey"] + or not self.settings_dict["reset_hotkey"] + or not self.settings_dict["pause_hotkey"]): + error_messages.load_start_image() + QApplication.processEvents() + return + else: if started_by_button: - error_messages.no_keyword_image("start_auto_splitter") + error_messages.no_keyword_image(START_KEYWORD) QApplication.processEvents() return @@ -304,24 +305,21 @@ def __start_image_function(self): capture, _ = self.__get_capture_for_comparison() start_image_threshold = self.start_image.get_similarity_threshold(self) start_image_similarity = self.start_image.compare_with_capture(self, capture) - self.table_current_image_threshold_label.setText(f"{start_image_threshold:.2f}") - - # Show live similarity if the checkbox is checked - self.table_current_image_live_label.setText(f"{start_image_similarity:.2f}") # If the similarity becomes higher than highest similarity, set it as such. if start_image_similarity > self.highest_similarity: self.highest_similarity = start_image_similarity - # Show live highest similarity if the checkbox is checked - self.table_current_image_highest_label.setText(f"{self.highest_similarity:.2f}") + self.table_current_image_threshold_label.setText(decimal(start_image_threshold)) + self.table_current_image_live_label.setText(decimal(start_image_similarity)) + self.table_current_image_highest_label.setText(decimal(self.highest_similarity)) # If the {b} flag is set, let similarity go above threshold first, then split on similarity below threshold # Otherwise just split when similarity goes above threshold below_flag = self.start_image.check_flag(BELOW_FLAG) # Negative means belove threshold, positive means above - similarity_diff = start_image_threshold - start_image_similarity + similarity_diff = start_image_similarity - start_image_threshold if below_flag \ and not self.start_image_split_below_threshold \ and similarity_diff >= 0: @@ -389,8 +387,10 @@ def __take_screenshot(self): os.startfile(screenshot_path) # nosec def __check_fps(self): - self.fps_value_label.clear() + self.fps_value_label.setText("...") + QApplication.processEvents() if not (validate_before_parsing(self) and parse_and_validate_images(self)): + self.fps_value_label.clear() return images = self.split_images @@ -418,9 +418,9 @@ def __is_current_split_out_of_range(self): 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): + def undo_split(self, navigate_image_only: bool = False): """ - "Undo Split" and "Prev. Img." buttons and hotkey connect to here + "Undo Split" and "Prev. Img." buttons connect to here """ # Can't undo until timer is started # or Undoing past the first image @@ -442,9 +442,9 @@ def __undo_split(self, navigate_image_only: bool = False): if not navigate_image_only: send_command(self, "undo") - def __skip_split(self, navigate_image_only: bool = False): + def skip_split(self, navigate_image_only: bool = False): """ - "Skip Split" and "Next Img." buttons and hotkey connect to here + "Skip Split" and "Next Img." buttons connect to here """ # Can't skip or split until timer is started # or Splitting/skipping when there are no images left @@ -496,18 +496,18 @@ def __check_for_reset_state_update_ui(self): if self.settings_dict["loop_splits"]: self.start_auto_splitter_signal.emit() else: - self.gui_changes_on_reset() + self.gui_changes_on_reset(True) return True return False def __auto_splitter(self): if not self.settings_dict["split_hotkey"] and not self.is_auto_controlled: - self.gui_changes_on_reset() + self.gui_changes_on_reset(True) error_messages.split_hotkey() return if not (validate_before_parsing(self) and parse_and_validate_images(self)): - self.gui_changes_on_reset() + self.gui_changes_on_reset(True) return # Construct a list of images + loop count tuples. @@ -539,7 +539,7 @@ def __auto_splitter(self): dummy_splits_array = [image_loop[0].check_flag(DUMMY_FLAG) for image_loop in self.split_images_and_loop_number] self.run_start_time = time() - # First while loop: stays in this loop until all of the split images have been split + # First loop: stays in this loop until all of the split images have been split while self.split_image_number < number_of_split_images: # Check if we are not waiting for the split delay to send the key press @@ -555,7 +555,7 @@ def __auto_splitter(self): if not self.split_image: return - # Second while loop: stays in this loop until similarity threshold is met + # Second loop: stays in this loop until similarity threshold is met if self.__similarity_threshold_loop(number_of_split_images, dummy_splits_array): return @@ -595,7 +595,7 @@ def __auto_splitter(self): return # loop breaks to here when the last image splits - self.gui_changes_on_reset() + self.gui_changes_on_reset(True) def __similarity_threshold_loop(self, number_of_split_images: int, dummy_splits_array: list[bool]): """ @@ -617,14 +617,14 @@ def __similarity_threshold_loop(self, number_of_split_images: int, dummy_splits_ similarity = self.split_image.compare_with_capture(self, capture) # Show live similarity - self.table_current_image_live_label.setText(f"{similarity:.2f}") + self.table_current_image_live_label.setText(decimal(similarity)) # if the similarity becomes higher than highest similarity, set it as such. if similarity > self.highest_similarity: self.highest_similarity = similarity # show live highest similarity if the checkbox is checked - self.table_current_image_highest_label.setText(f"{self.highest_similarity:.2f}") + self.table_current_image_highest_label.setText(decimal(self.highest_similarity)) # If its the last split image and last loop number, disable the next image button # If its the first split image, disable the undo split and previous image buttons @@ -700,12 +700,11 @@ def gui_changes_on_start(self): self.previous_image_button.setEnabled(True) self.next_image_button.setEnabled(True) + # TODO: Do we actually need to disable setting new hotkeys once started? + # What does this achieve? (See below TODO) if self.SettingsWidget: - self.SettingsWidget.set_split_hotkey_button.setEnabled(False) - self.SettingsWidget.set_reset_hotkey_button.setEnabled(False) - self.SettingsWidget.set_skip_split_hotkey_button.setEnabled(False) - self.SettingsWidget.set_undo_split_hotkey_button.setEnabled(False) - self.SettingsWidget.set_pause_hotkey_button.setEnabled(False) + for hotkey in HOTKEYS: + getattr(self.SettingsWidget, f"set_{hotkey}_hotkey_button").setEnabled(False) if not self.is_auto_controlled: self.start_auto_splitter_button.setEnabled(False) @@ -715,7 +714,7 @@ def gui_changes_on_start(self): QApplication.processEvents() - def gui_changes_on_reset(self): + def gui_changes_on_reset(self, safe_to_reload_start_image: bool = False): self.start_auto_splitter_button.setText(START_AUTO_SPLITTER_TEXT) self.image_loop_value_label.setText("N/A") self.current_split_image.clear() @@ -731,12 +730,12 @@ def gui_changes_on_reset(self): self.previous_image_button.setEnabled(False) self.next_image_button.setEnabled(False) - if self.SettingsWidget: - self.SettingsWidget.set_split_hotkey_button.setEnabled(True) - self.SettingsWidget.set_reset_hotkey_button.setEnabled(True) - self.SettingsWidget.set_skip_split_hotkey_button.setEnabled(True) - self.SettingsWidget.set_undo_split_hotkey_button.setEnabled(True) - self.SettingsWidget.set_pause_hotkey_button.setEnabled(True) + # TODO: Do we actually need to disable setting new hotkeys once started? + # What does this achieve? (see above TODO) + if self.SettingsWidget and not self.is_auto_controlled: + for hotkey in HOTKEYS: + getattr(self.SettingsWidget, f"set_{hotkey}_hotkey_button").setEnabled(True) + if not self.is_auto_controlled: self.start_auto_splitter_button.setEnabled(True) self.reset_button.setEnabled(False) @@ -744,7 +743,8 @@ def gui_changes_on_reset(self): self.skip_split_button.setEnabled(False) QApplication.processEvents() - self.load_start_image_signal[bool, bool].emit(False, False) + if safe_to_reload_start_image: + self.load_start_image_signal[bool, bool].emit(False, False) def __get_capture_for_comparison(self): """ @@ -768,23 +768,26 @@ def __get_capture_for_comparison(self): else cv2.resize(capture, COMPARISON_RESIZE, interpolation=cv2.INTER_NEAREST), is_old_image) - def __reset_if_should(self, capture: cv2.ndarray | None): + def __reset_if_should(self, capture: cv2.Mat | None): """ - Check if we should reset, resets if it's the case, and returns the result + Checks if we should reset, resets if it's the case, and returns the result """ if self.reset_image: similarity = self.reset_image.compare_with_capture(self, capture) threshold = self.reset_image.get_similarity_threshold(self) - if similarity > self.reset_highest_similarity: - self.reset_highest_similarity = similarity - - self.table_reset_image_live_label.setText(f"{similarity:.2f}") - self.table_reset_image_highest_label.setText(f"{self.reset_highest_similarity:.2f}") - self.table_reset_image_threshold_label.setText(f"{threshold:.2f}") + paused = time() - self.run_start_time <= self.reset_image.get_pause_time(self) + if paused: + should_reset = False + self.table_reset_image_live_label.setText("paused") + else: + should_reset = similarity >= threshold + if similarity > self.reset_highest_similarity: + self.reset_highest_similarity = similarity + self.table_reset_image_highest_label.setText(decimal(self.reset_highest_similarity)) + self.table_reset_image_live_label.setText(decimal(similarity)) - should_reset = similarity >= threshold \ - and time() - self.run_start_time > self.reset_image.get_pause_time(self) + self.table_reset_image_threshold_label.setText(decimal(threshold)) if should_reset: send_command(self, "reset") @@ -806,9 +809,9 @@ def __update_split_image(self, specific_image: AutoSplitImage | None = None): set_preview_image(self.current_split_image, self.split_image.byte_array, True) self.current_image_file_label.setText(self.split_image.filename) - self.table_current_image_threshold_label.setText(f"{self.split_image.get_similarity_threshold(self):.2f}") + self.table_current_image_threshold_label.setText(decimal(self.split_image.get_similarity_threshold(self))) - # Set Image Loop # + # Set Image Loop number if specific_image and specific_image.image_type == ImageType.START: self.image_loop_value_label.setText("N/A") else: @@ -825,12 +828,12 @@ def closeEvent(self, a0: QtGui.QCloseEvent | None = None): """ def exit_program(): + if self.update_auto_control: + self.update_auto_control.terminate() if a0 is not None: a0.accept() if self.is_auto_controlled: - self.update_auto_control.terminate() # stop main thread (which is probably blocked reading input) via an interrupt signal - # only available for windows in version 3.2 or higher os.kill(os.getpid(), signal.SIGINT) sys.exit() @@ -844,7 +847,7 @@ def exit_program(): # Give a different warning if there was never a settings file that was loaded successfully, # and "save as" instead of "save". settings_file_name = "Untitled" \ - if self.last_successfully_loaded_settings_file_path is None \ + if not self.last_successfully_loaded_settings_file_path \ else os.path.basename(self.last_successfully_loaded_settings_file_path) warning = QMessageBox.warning( @@ -892,11 +895,28 @@ def seconds_remaining_text(seconds: float): return f"{seconds:.1f} second{'' if 0 < seconds <= 1 else 's'} remaining" +def is_already_running(): + # When running directly in Python, any AutoSplit process means it's already open + # When bundled, we must ignore itself and the splash screen + max_processes = 3 if FROZEN else 1 + process_count = 0 + for process in process_iter(): + if process.name() == "AutoSplit.exe": + process_count += 1 + if process_count >= max_processes: + return True + return False + + def main(): # Call to QApplication outside the try-except so we can show error messages app = QApplication(sys.argv) try: app.setWindowIcon(QtGui.QIcon(":/resources/icon.ico")) + + if is_already_running(): + error_messages.already_running() + AutoSplit() if not FROZEN: @@ -907,14 +927,7 @@ def main(): exit_code = app.exec() except Exception as exception: # pylint: disable=broad-except # We really want to catch everything here - message = "AutoSplit encountered an unrecoverable exception and will now close." \ - + error_messages.CREATE_NEW_ISSUE_MESSAGE - # Print error to console if not running in executable - if FROZEN: - error_messages.exception_traceback(message, exception) - else: - traceback.print_exception(type(exception), exception, exception.__traceback__) - sys.exit(1) + error_messages.handle_top_level_exceptions(exception) # Catch Keyboard Interrupts for a clean close signal.signal(signal.SIGINT, lambda code, _: sys.exit(code)) diff --git a/src/AutoSplitImage.py b/src/AutoSplitImage.py index 40fa7b54..fe747356 100644 --- a/src/AutoSplitImage.py +++ b/src/AutoSplitImage.py @@ -19,6 +19,10 @@ COMPARISON_RESIZE_WIDTH = 320 COMPARISON_RESIZE_HEIGHT = 240 COMPARISON_RESIZE = (COMPARISON_RESIZE_WIDTH, COMPARISON_RESIZE_HEIGHT) +LOWER_BOUND = np.array([0, 0, 0, 1], dtype="uint8") +UPPER_BOUND = np.array([MAXBYTE, MAXBYTE, MAXBYTE, MAXBYTE], dtype="uint8") +START_KEYWORD = "start_auto_splitter" +RESET_KEYWORD = "reset" class ImageType(Enum): @@ -33,11 +37,11 @@ class AutoSplitImage(): flags: int loops: int image_type: ImageType - byte_array: cv2.ndarray | None = None - mask: cv2.ndarray | None = None + byte_array: cv2.Mat | None = None + mask: cv2.Mat | None = None # This value is internal, check for mask instead _has_transparency = False - # These values should be overriden by Defaults if None. Use getters instead + # These values should be overriden by some Defaults if None. Use getters instead __delay_time: float | None = None __comparison_method: int | None = None __pause_time: float | None = None @@ -90,9 +94,9 @@ def __init__(self, path: str): self.__similarity_threshold = threshold_from_filename(self.filename) self.__read_image_bytes(path) - if "start_auto_splitter" in self.filename: + if START_KEYWORD in self.filename: self.image_type = ImageType.START - elif "reset" in self.filename: + elif RESET_KEYWORD in self.filename: self.image_type = ImageType.RESET else: self.image_type = ImageType.SPLIT @@ -109,9 +113,7 @@ def __read_image_bytes(self, path: str): # If image has transparency, create a mask if self._has_transparency: # Create mask based on resized, nearest neighbor interpolated split image - lower = np.array([0, 0, 0, 1], dtype="uint8") - upper = np.array([MAXBYTE, MAXBYTE, MAXBYTE, MAXBYTE], dtype="uint8") - self.mask = cv2.inRange(image, lower, upper) + self.mask = cv2.inRange(image, LOWER_BOUND, UPPER_BOUND) # Add Alpha channel if missing elif image.shape[2] == 3: image = cv2.cvtColor(image, cv2.COLOR_BGR2BGRA) @@ -124,7 +126,7 @@ def check_flag(self, flag: int): def compare_with_capture( self, default: AutoSplit | int, - capture: cv2.ndarray | None + capture: cv2.Mat | None ): """ Compare image with capture using image's comparison method. Falls back to combobox diff --git a/src/compare.py b/src/compare.py index b76b27b4..28061777 100644 --- a/src/compare.py +++ b/src/compare.py @@ -1,9 +1,7 @@ from __future__ import annotations -from typing import cast - import cv2 -import imagehash # https://github.com/JohannesBuchner/imagehash/issues/151 +import imagehash import numpy as np from PIL import Image from win32con import MAXBYTE @@ -11,12 +9,13 @@ from utils import is_valid_image MAXRANGE = MAXBYTE + 1 -channels = [0, 1, 2] -histogram_size = [8, 8, 8] -ranges = [0, MAXRANGE, 0, MAXRANGE, 0, MAXRANGE] +CHANNELS = [0, 1, 2] +HISTOGRAM_SIZE = [8, 8, 8] +RANGES = [0, MAXRANGE, 0, MAXRANGE, 0, MAXRANGE] +MASK_SIZE_MULTIPLIER = 3 * MAXBYTE * MAXBYTE -def compare_histograms(source: cv2.ndarray, capture: cv2.ndarray, mask: cv2.ndarray | None = None): +def compare_histograms(source: cv2.Mat, capture: cv2.Mat, mask: cv2.Mat | None = None): """ Compares two images by calculating their histograms, normalizing them, and then comparing them using Bhattacharyya distance. @@ -27,8 +26,8 @@ def compare_histograms(source: cv2.ndarray, capture: cv2.ndarray, mask: cv2.ndar @return: The similarity between the histograms as a number 0 to 1. """ - source_hist = cv2.calcHist([source], channels, mask, histogram_size, ranges) - capture_hist = cv2.calcHist([capture], channels, mask, histogram_size, ranges) + source_hist = cv2.calcHist([source], CHANNELS, mask, HISTOGRAM_SIZE, RANGES) + capture_hist = cv2.calcHist([capture], CHANNELS, mask, HISTOGRAM_SIZE, RANGES) cv2.normalize(source_hist, source_hist) cv2.normalize(capture_hist, capture_hist) @@ -36,7 +35,7 @@ def compare_histograms(source: cv2.ndarray, capture: cv2.ndarray, mask: cv2.ndar return 1 - cv2.compareHist(source_hist, capture_hist, cv2.HISTCMP_BHATTACHARYYA) -def compare_l2_norm(source: cv2.ndarray, capture: cv2.ndarray, mask: cv2.ndarray | None = None): +def compare_l2_norm(source: cv2.Mat, capture: cv2.Mat, mask: cv2.Mat | None = None): """ Compares two images by calculating the L2 Error (square-root of sum of squared error) @param source: Image of any given shape @@ -57,7 +56,7 @@ def compare_l2_norm(source: cv2.ndarray, capture: cv2.ndarray, mask: cv2.ndarray return 1 - (error / max_error) -def compare_template(source: cv2.ndarray, capture: cv2.ndarray, mask: cv2.ndarray | None = None): +def compare_template(source: cv2.Mat, capture: cv2.Mat, mask: cv2.Mat | None = None): """ Checks if the source is located within the capture by using the sum of square differences. The mask is used to search for non-rectangular images within the capture @@ -81,7 +80,7 @@ def compare_template(source: cv2.ndarray, capture: cv2.ndarray, mask: cv2.ndarra return 1 - (min_val / max_error) -def compare_phash(source: cv2.ndarray, capture: cv2.ndarray, mask: cv2.ndarray | None = None): +def compare_phash(source: cv2.Mat, capture: cv2.Mat, mask: cv2.Mat | None = None): """ Compares the Perceptual Hash of the two given images and returns the similarity between the two. @@ -108,11 +107,11 @@ def compare_phash(source: cv2.ndarray, capture: cv2.ndarray, mask: cv2.ndarray | return 1 - (hash_diff / 64.0) -def check_if_image_has_transparency(image: cv2.ndarray): +def check_if_image_has_transparency(image: cv2.Mat): # Check if there's a transparency channel (4th channel) and if at least one pixel is transparent (< 255) if image.shape[2] != 4: return False - mean = cast(float, np.mean(image[:, :, 3])) + mean: float = image[:, :, 3].mean() if mean == 0: # Non-transparent images code path is usually faster and simpler, so let's return that return False diff --git a/src/error_messages.py b/src/error_messages.py index c670c7c3..d93d295b 100644 --- a/src/error_messages.py +++ b/src/error_messages.py @@ -1,15 +1,41 @@ +"""Error messages""" +from __future__ import annotations + +import os +import signal +import sys import traceback +from types import TracebackType +from typing import TYPE_CHECKING from PyQt6 import QtCore, QtWidgets +from utils import FROZEN, GITHUB_REPOSITORY + +if TYPE_CHECKING: + from AutoSplit import AutoSplit + + +def __exit_program(): + # stop main thread (which is probably blocked reading input) via an interrupt signal + os.kill(os.getpid(), signal.SIGINT) + sys.exit(1) + -def set_text_message(message: str, details: str = ""): +def set_text_message(message: str, details: str = "", kill_button: str = "", accept_button: str = ""): message_box = QtWidgets.QMessageBox() message_box.setWindowTitle("Error") message_box.setTextFormat(QtCore.Qt.TextFormat.RichText) message_box.setText(message) + # Button order is important for default focus + if accept_button: + message_box.addButton(accept_button, QtWidgets.QMessageBox.ButtonRole.AcceptRole) + if kill_button: + force_quit_button = message_box.addButton(kill_button, QtWidgets.QMessageBox.ButtonRole.ResetRole) + force_quit_button.clicked.connect(__exit_program) if details: message_box.setDetailedText(details) + # Preopen the details for button in message_box.buttons(): if message_box.buttonRole(button) == QtWidgets.QMessageBox.ButtonRole.ActionRole: button.click() @@ -77,6 +103,10 @@ def invalid_settings(): set_text_message("Invalid settings file.") +def invalid_hotkey(hotkey_name: str): + set_text_message(f'Invalid hotkey "{hotkey_name}"') + + def no_settings_file_on_open(): set_text_message("No settings file found. One can be loaded on open if placed in the same folder as AutoSplit.exe") @@ -99,12 +129,51 @@ def stdin_lost(): set_text_message("stdin not supported or lost, external control like LiveSplit integration will not work.") +def already_running(): + set_text_message( + "An instance of AutoSplit is already running.
Are you sure you want to open a another one?", + "", + "Don't open", + "Ignore") + + def exception_traceback(message: str, exception: BaseException): set_text_message( message, - "\n".join(traceback.format_exception(None, exception, exception.__traceback__))) + "\n".join(traceback.format_exception(None, exception, exception.__traceback__)), + "Close AutoSplit") CREATE_NEW_ISSUE_MESSAGE = ( - "Please create a New Issue at " - + "github.com/Toufool/Auto-Split/issues, describe what happened, and copy & paste the error message below") + f"Please create a New Issue at " + + f"github.com/{GITHUB_REPOSITORY}/issues, describe what happened, " + + "and copy & paste the entire error message below") + + +def make_excepthook(autosplit: AutoSplit): + def excepthook(exception_type: type[BaseException], exception: BaseException, _traceback: TracebackType | None): + # Catch Keyboard Interrupts for a clean close + if exception_type is KeyboardInterrupt or isinstance(exception, KeyboardInterrupt): + sys.exit(0) + # HACK: Can happen when starting the region selector while capturing with WindowsGraphicsCapture + if ( + exception_type is SystemError + and str(exception) == " returned a result with an error set" + ): + return + # Whithin LiveSplit excepthook needs to use MainWindow's signals to show errors + autosplit.show_error_signal.emit(lambda: exception_traceback( + "AutoSplit encountered an unhandled exception and will try to recover, " + + f"however, there is no guarantee it will keep working properly. {CREATE_NEW_ISSUE_MESSAGE}", + exception)) + return excepthook + + +def handle_top_level_exceptions(exception: Exception): + message = f"AutoSplit encountered an unrecoverable exception and will now close. {CREATE_NEW_ISSUE_MESSAGE}" + # Print error to console if not running in executable + if FROZEN: + exception_traceback(message, exception) + else: + traceback.print_exception(type(exception), exception, exception.__traceback__) + sys.exit(1) diff --git a/src/hotkeys.py b/src/hotkeys.py index 55c8987d..d5fa6f90 100644 --- a/src/hotkeys.py +++ b/src/hotkeys.py @@ -1,11 +1,14 @@ from __future__ import annotations -import threading from collections.abc import Callable -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, Literal, cast import keyboard import pyautogui +from PyQt6 import QtWidgets + +from error_messages import invalid_hotkey +from utils import START_AUTO_SPLITTER_TEXT, fire_and_forget, is_digit if TYPE_CHECKING: from AutoSplit import AutoSplit @@ -36,25 +39,14 @@ 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 """ - autosplit.start_auto_splitter_button.setEnabled(True) + if autosplit.start_auto_splitter_button.text() == START_AUTO_SPLITTER_TEXT: + 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 is_digit(key: str | int | None): - """ - Checks if `key` is a single-digit string from 0-9 - """ - if key is None: - return False - try: - return 0 <= int(key) <= 9 - except ValueError: - return False - - def send_command(autosplit: AutoSplit, command: Commands): if autosplit.is_auto_controlled: print(command, flush=True) @@ -193,8 +185,31 @@ def __read_hotkey(): return __get_hotkey_name(names) -def __is_key_already_set(autosplit: AutoSplit, key_name: str): - return key_name in [autosplit.settings_dict[f"{hotkey}_hotkey"] for hotkey in HOTKEYS] +def __remove_key_already_set(autosplit: AutoSplit, key_name: str): + for hotkey in HOTKEYS: + settings_key = f"{hotkey}_hotkey" + if autosplit.settings_dict[settings_key] == key_name: + _unhook(getattr(autosplit, f"{hotkey}_hotkey")) + autosplit.settings_dict[settings_key] = "" + if autosplit.SettingsWidget: + getattr(autosplit.SettingsWidget, f"{hotkey}_input").setText("") + + +def __get_hotkey_action(autosplit: AutoSplit, hotkey: Hotkeys): + 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) + 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. @@ -202,6 +217,8 @@ def __is_key_already_set(autosplit: AutoSplit, key_name: str): def set_hotkey(autosplit: AutoSplit, hotkey: Hotkeys, preselected_hotkey_name: str = ""): if autosplit.SettingsWidget: + # Unfocus all fields + cast(QtWidgets.QDialog, autosplit.SettingsWidget).setFocus() getattr(autosplit.SettingsWidget, f"set_{hotkey}_hotkey_button").setText(PRESS_A_KEY_TEXT) # Disable some buttons @@ -209,20 +226,21 @@ def set_hotkey(autosplit: AutoSplit, hotkey: Hotkeys, preselected_hotkey_name: s # New thread points to callback. this thread is needed or GUI will freeze # while the program waits for user input on the hotkey + @fire_and_forget def callback(): hotkey_name = preselected_hotkey_name if preselected_hotkey_name else __read_hotkey() - # If the key the user presses is equal to itself or another hotkey already set, - # this causes issues. so here, it catches that, and will make no changes to the hotkey. - if __is_key_already_set(autosplit, hotkey_name): - autosplit.after_setting_hotkey_signal.emit() + if not is_valid_hotkey_name(hotkey_name): + autosplit.show_error_signal.emit(lambda: invalid_hotkey(hotkey_name)) return - # 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 - action = autosplit.start_auto_splitter if hotkey == "split" else getattr(autosplit, f"{hotkey}_signal").emit + # 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", @@ -232,6 +250,10 @@ def callback(): # 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)) @@ -242,6 +264,4 @@ def callback(): autosplit.settings_dict[f"{hotkey}_hotkey"] = hotkey_name autosplit.after_setting_hotkey_signal.emit() - # Try to remove the previously set hotkey if there is one. - _unhook(getattr(autosplit, f"{hotkey}_hotkey")) - threading.Thread(target=callback).start() + callback() diff --git a/src/menu_bar.py b/src/menu_bar.py index 55c8042d..98458605 100644 --- a/src/menu_bar.py +++ b/src/menu_bar.py @@ -5,9 +5,8 @@ from typing import TYPE_CHECKING, Any, cast import requests -from packaging import version +from packaging.version import parse as version_parse from PyQt6 import QtCore, QtWidgets -from PyQt6.QtCore import QThread from requests.exceptions import RequestException import error_messages @@ -15,42 +14,43 @@ from capture_method import (CAPTURE_METHODS, CameraInfo, CaptureMethodEnum, change_capture_method, get_all_video_capture_devices) from gen import about, design, resources_rc, settings as settings_ui, update_checker # noqa F401 -from hotkeys import set_hotkey -from utils import fire_and_forget +from hotkeys import HOTKEYS, Hotkeys, set_hotkey +from utils import (AUTOSPLIT_VERSION, FIRST_WIN_11_BUILD, GITHUB_REPOSITORY, WINDOWS_BUILD_NUMBER, decimal, + fire_and_forget) if TYPE_CHECKING: from AutoSplit import AutoSplit -# AutoSplit Version number -VERSION = "1.6.1" - -# About Window class __AboutWidget(QtWidgets.QWidget, about.Ui_AboutAutoSplitWidget): + """About Window""" + def __init__(self): super().__init__() self.setupUi(self) self.created_by_label.setOpenExternalLinks(True) self.donate_button_label.setOpenExternalLinks(True) - self.version_label.setText(f"Version: {VERSION}") + self.version_label.setText(f"Version: {AUTOSPLIT_VERSION}") self.show() def open_about(autosplit: AutoSplit): - autosplit.AboutWidget = __AboutWidget() + if not autosplit.AboutWidget or cast(QtWidgets.QWidget, autosplit.AboutWidget).isHidden(): + autosplit.AboutWidget = __AboutWidget() class __UpdateCheckerWidget(QtWidgets.QWidget, update_checker.Ui_UpdateChecker): def __init__(self, latest_version: str, design_window: design.Ui_MainWindow, check_on_open: bool = False): super().__init__() self.setupUi(self) - self.current_version_number_label.setText(VERSION) + self.current_version_number_label.setText(AUTOSPLIT_VERSION) self.latest_version_number_label.setText(latest_version) self.left_button.clicked.connect(self.open_update) self.do_not_ask_again_checkbox.stateChanged.connect(self.do_not_ask_me_again_state_changed) self.design_window = design_window - if version.parse(latest_version) > version.parse(VERSION): + if version_parse(latest_version) > version_parse(AUTOSPLIT_VERSION): self.do_not_ask_again_checkbox.setVisible(check_on_open) + self.left_button.setFocus() self.show() elif not check_on_open: self.update_status_label.setText("You are on the latest AutoSplit version.") @@ -61,7 +61,7 @@ def __init__(self, latest_version: str, design_window: design.Ui_MainWindow, che self.show() def open_update(self): - webbrowser.open("https://github.com/Toufool/Auto-Split/releases/latest") + webbrowser.open(f"https://github.com/{GITHUB_REPOSITORY}/releases/latest") self.close() def do_not_ask_me_again_state_changed(self): @@ -71,14 +71,15 @@ def do_not_ask_me_again_state_changed(self): def open_update_checker(autosplit: AutoSplit, latest_version: str, check_on_open: bool): - autosplit.UpdateCheckerWidget = __UpdateCheckerWidget(latest_version, autosplit, check_on_open) + if not autosplit.UpdateCheckerWidget or cast(QtWidgets.QWidget, autosplit.UpdateCheckerWidget).isHidden(): + autosplit.UpdateCheckerWidget = __UpdateCheckerWidget(latest_version, autosplit, check_on_open) def view_help(): - webbrowser.open("https://github.com/Toufool/Auto-Split#tutorial") + webbrowser.open(f"https://github.com/{GITHUB_REPOSITORY}#tutorial") -class __CheckForUpdatesThread(QThread): +class __CheckForUpdatesThread(QtCore.QThread): def __init__(self, autosplit: AutoSplit, check_on_open: bool): super().__init__() self.autosplit = autosplit @@ -86,7 +87,7 @@ def __init__(self, autosplit: AutoSplit, check_on_open: bool): def run(self): try: - response = requests.get("https://api.github.com/repos/Toufool/Auto-Split/releases/latest") + response = requests.get(f"https://api.github.com/repos/{GITHUB_REPOSITORY}/releases/latest") latest_version = str(response.json()["name"]).split("v")[1] self.autosplit.update_checker_widget_signal.emit(latest_version, self.check_on_open) except (RequestException, KeyError): @@ -94,6 +95,14 @@ def run(self): self.autosplit.show_error_signal.emit(error_messages.check_for_updates) +def about_qt(): + webbrowser.open("https://wiki.qt.io/About_Qt") + + +def about_qt_for_python(): + webbrowser.open("https://wiki.qt.io/Qt_for_Python") + + def check_for_updates(autosplit: AutoSplit, check_on_open: bool = False): autosplit.CheckForUpdatesThread = __CheckForUpdatesThread(autosplit, check_on_open) autosplit.CheckForUpdatesThread.start() @@ -119,11 +128,11 @@ class __SettingsWidget(QtWidgets.QDialog, settings_ui.Ui_DialogSettings): def __update_default_threshold(self, value: Any): self.__set_value("default_similarity_threshold", value) self.autosplit.table_current_image_threshold_label.setText( - f"{self.autosplit.split_image.get_similarity_threshold(self.autosplit):.2f}" + decimal(self.autosplit.split_image.get_similarity_threshold(self.autosplit)) if self.autosplit.split_image else "-") self.autosplit.table_reset_image_threshold_label.setText( - f"{self.autosplit.reset_image.get_similarity_threshold(self.autosplit):.2f}" + decimal(self.autosplit.reset_image.get_similarity_threshold(self.autosplit)) if self.autosplit.reset_image else "-") @@ -175,6 +184,21 @@ def __init__(self, autosplit: AutoSplit): super().__init__() self.setupUi(self) self.autosplit = autosplit + # Spinbox frame disappears and reappears on Windows 11. It's much cleaner to just disable them. + # Most likely related: https://bugreports.qt.io/browse/QTBUG-95215?jql=labels%20%3D%20Windows11 + # Arrow buttons tend to move a lot as well + if WINDOWS_BUILD_NUMBER >= FIRST_WIN_11_BUILD: + self.fps_limit_spinbox.setFrame(False) + self.default_similarity_threshold_spinbox.setFrame(False) + self.default_delay_time_spinbox.setFrame(False) + self.default_pause_time_spinbox.setFrame(False) + # Don't autofocus any particular field + self.setFocus() + + self.custom_image_settings_info_label.setText( + self.custom_image_settings_info_label + .text() + .format(GITHUB_REPOSITORY=GITHUB_REPOSITORY)) # region Build the Capture method combobox capture_method_values = CAPTURE_METHODS.values() @@ -199,14 +223,21 @@ def __init__(self, autosplit: AutoSplit): for method in capture_method_values])) # endregion -# region Set initial values - # Hotkeys - self.split_input.setText(autosplit.settings_dict["split_hotkey"]) - self.reset_input.setText(autosplit.settings_dict["reset_hotkey"]) - self.undo_split_input.setText(autosplit.settings_dict["undo_split_hotkey"]) - self.skip_split_input.setText(autosplit.settings_dict["skip_split_hotkey"]) - self.pause_input.setText(autosplit.settings_dict["pause_hotkey"]) + # Hotkeys initial values and bindings + def hotkey_connect(hotkey: Hotkeys): + return lambda: set_hotkey(self.autosplit, hotkey) + for hotkey in HOTKEYS: + hotkey_input: QtWidgets.QLineEdit = getattr(self, f"{hotkey}_input") + set_hotkey_hotkey_button: QtWidgets.QPushButton = getattr(self, f"set_{hotkey}_hotkey_button") + hotkey_input.setText(cast(str, autosplit.settings_dict[f"{hotkey}_hotkey"])) + + set_hotkey_hotkey_button.clicked.connect(hotkey_connect(hotkey)) + # Make it very clear that hotkeys are not used when auto-controlled + if autosplit.is_auto_controlled: + set_hotkey_hotkey_button.setEnabled(False) + hotkey_input.setEnabled(False) +# region Set initial values # Capture Settings self.fps_limit_spinbox.setValue(autosplit.settings_dict["fps_limit"]) self.live_capture_region_checkbox.setChecked(autosplit.settings_dict["live_capture_region"]) @@ -224,13 +255,6 @@ def __init__(self, autosplit: AutoSplit): self.loop_splits_checkbox.setChecked(autosplit.settings_dict["loop_splits"]) # endregion # region Binding - # Hotkeys - self.set_split_hotkey_button.clicked.connect(lambda: set_hotkey(self.autosplit, "split")) - self.set_reset_hotkey_button.clicked.connect(lambda: set_hotkey(self.autosplit, "reset")) - self.set_skip_split_hotkey_button.clicked.connect(lambda: set_hotkey(self.autosplit, "skip_split")) - self.set_undo_split_hotkey_button.clicked.connect(lambda: set_hotkey(self.autosplit, "undo_split")) - self.set_pause_hotkey_button.clicked.connect(lambda: set_hotkey(self.autosplit, "pause")) - # Capture Settings self.fps_limit_spinbox.valueChanged.connect(lambda: self.__set_value( "fps_limit", @@ -264,7 +288,8 @@ def __init__(self, autosplit: AutoSplit): def open_settings(autosplit: AutoSplit): - autosplit.SettingsWidget = __SettingsWidget(autosplit) + if not autosplit.SettingsWidget or cast(QtWidgets.QDialog, autosplit.SettingsWidget).isHidden(): + autosplit.SettingsWidget = __SettingsWidget(autosplit) def get_default_settings_from_ui(autosplit: AutoSplit): diff --git a/src/region_selection.py b/src/region_selection.py index 5eb6b7aa..4ea266cd 100644 --- a/src/region_selection.py +++ b/src/region_selection.py @@ -22,6 +22,25 @@ if TYPE_CHECKING: from AutoSplit import AutoSplit +SUPPORTED_IMREAD_FORMATS = [ + ("Windows bitmaps", "*.bmp *.dib"), + ("JPEG files", "*.jpeg *.jpg *.jpe"), + ("JPEG 2000 files", "*.jp2"), + ("Portable Network Graphics", "*.png"), + ("WebP", "*.webp"), + ("Portable image format", "*.pbm *.pgm *.ppm *.pxm *.pnm"), + ("PFM files", "*.pfm"), + ("Sun rasters", "*.sr *.ras"), + ("TIFF files", "*.tiff *.tif"), + ("OpenEXR Image files", "*.exr"), + ("Radiance HDR", "*.hdr *.pic"), +] +"""https://docs.opencv.org/4.5.4/d4/da8/group__imgcodecs.html#ga288b8b3da0892bd651fce07b3bbd3a56""" +IMREAD_EXT_FILTER = "All Files (" \ + + " ".join([f"{extensions}" for _, extensions in SUPPORTED_IMREAD_FORMATS]) \ + + ");;"\ + + ";;".join([f"{imread_format} ({extensions})" for imread_format, extensions in SUPPORTED_IMREAD_FORMATS]) + user32 = ctypes.windll.user32 @@ -148,7 +167,7 @@ def align_region(autosplit: AutoSplit): autosplit, "Select Reference Image", "", - "Image Files (*.png *.jpg *.jpeg *.jpe *.jp2 *.bmp *.tiff *.tif *.dib *.webp *.pbm *.pgm *.ppm *.sr *.ras)", + IMREAD_EXT_FILTER, )[0] # Return if the user presses cancel diff --git a/src/split_parser.py b/src/split_parser.py index 26ad1521..c8c9699d 100644 --- a/src/split_parser.py +++ b/src/split_parser.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, TypeVar import error_messages -from AutoSplitImage import AutoSplitImage, ImageType +from AutoSplitImage import RESET_KEYWORD, START_KEYWORD, AutoSplitImage, ImageType from utils import is_valid_image if TYPE_CHECKING: @@ -17,6 +17,9 @@ T = TypeVar("T", str, int, float) +# Note, the following symbols cannot be used in a filename: +# / \ : * ? " < > | + def __value_from_filename( filename: str, @@ -111,7 +114,7 @@ def comparison_method_from_filename(filename: str): # Check to make sure there is a valid delay time between brackets # of the filename - value = __value_from_filename(filename, "<>", -1) + value = __value_from_filename(filename, "^^", -1) # Comparison method should always be positive or zero return value if value >= 0 else None @@ -208,12 +211,12 @@ def parse_and_validate_images(autosplit: AutoSplit): error_messages.reset_hotkey() return False autosplit.gui_changes_on_reset() - error_messages.multiple_keyword_images("reset") + error_messages.multiple_keyword_images(RESET_KEYWORD) return False # Check that there's only one start image if image.image_type == ImageType.START: autosplit.gui_changes_on_reset() - error_messages.multiple_keyword_images("start_auto_splitter") + error_messages.multiple_keyword_images(START_KEYWORD) return False return True diff --git a/src/user_profile.py b/src/user_profile.py index 54e13267..a0990f4f 100644 --- a/src/user_profile.py +++ b/src/user_profile.py @@ -1,7 +1,6 @@ from __future__ import annotations import os -import sys from typing import TYPE_CHECKING, TypedDict, cast import keyboard @@ -11,14 +10,11 @@ import error_messages from capture_method import CAPTURE_METHODS, CaptureMethodEnum, Region, change_capture_method from gen import design -from hotkeys import set_hotkey +from hotkeys import HOTKEYS, set_hotkey +from utils import auto_split_directory if TYPE_CHECKING: from AutoSplit import AutoSplit -# Keyword "frozen" is for setting basedir while in onefile mode in pyinstaller -FROZEN = hasattr(sys, "frozen") -# Get the directory of either AutoSplit.exe or AutoSplit.py -auto_split_directory = os.path.dirname(sys.executable if FROZEN else os.path.abspath(__file__)) class UserProfileDict(TypedDict): @@ -73,14 +69,9 @@ def save_settings(autosplit: AutoSplit): """ @return: The save settings filepath. Or None if "Save Settings As" is cancelled """ - if not autosplit.last_successfully_loaded_settings_file_path: - return save_settings_as(autosplit) - - autosplit.last_saved_settings = autosplit.settings_dict - # Save settings to a .toml file - with open(autosplit.last_successfully_loaded_settings_file_path, "w", encoding="utf-8") as file: - toml.dump(autosplit.last_saved_settings, file) - return autosplit.last_successfully_loaded_settings_file_path + return __save_settings_to_file(autosplit, autosplit.last_successfully_loaded_settings_file_path) \ + if autosplit.last_successfully_loaded_settings_file_path \ + else save_settings_as(autosplit) def save_settings_as(autosplit: AutoSplit): @@ -99,12 +90,14 @@ def save_settings_as(autosplit: AutoSplit): if not save_settings_file_path: return "" - autosplit.last_saved_settings = autosplit.settings_dict + return __save_settings_to_file(autosplit, save_settings_file_path) + +def __save_settings_to_file(autosplit: AutoSplit, save_settings_file_path: str): + autosplit.last_saved_settings = autosplit.settings_dict # Save settings to a .toml file with open(save_settings_file_path, "w", encoding="utf-8") as file: toml.dump(autosplit.last_saved_settings, file) - autosplit.last_successfully_loaded_settings_file_path = save_settings_file_path return save_settings_file_path @@ -129,23 +122,16 @@ def __load_settings_from_file(autosplit: AutoSplit, load_settings_file_path: str autosplit.y_spinbox.setValue(autosplit.settings_dict["capture_region"]["y"]) autosplit.width_spinbox.setValue(autosplit.settings_dict["capture_region"]["width"]) autosplit.height_spinbox.setValue(autosplit.settings_dict["capture_region"]["height"]) + autosplit.split_image_folder_input.setText(autosplit.settings_dict["split_image_directory"]) except (FileNotFoundError, MemoryError, TypeError, toml.TomlDecodeError): autosplit.show_error_signal.emit(error_messages.invalid_settings) return False - autosplit.split_image_folder_input.setText(autosplit.settings_dict["split_image_directory"]) keyboard.unhook_all() if not autosplit.is_auto_controlled: - if autosplit.settings_dict["split_hotkey"]: - set_hotkey(autosplit, "split", autosplit.settings_dict["split_hotkey"]) - if autosplit.settings_dict["reset_hotkey"]: - set_hotkey(autosplit, "reset", autosplit.settings_dict["reset_hotkey"]) - if autosplit.settings_dict["skip_split_hotkey"]: - set_hotkey(autosplit, "skip_split", autosplit.settings_dict["skip_split_hotkey"]) - if autosplit.settings_dict["undo_split_hotkey"]: - set_hotkey(autosplit, "undo_split", autosplit.settings_dict["undo_split_hotkey"]) - if autosplit.settings_dict["pause_hotkey"]: - set_hotkey(autosplit, "pause", autosplit.settings_dict["pause_hotkey"]) + for hotkey, hotkey_name in [(hotkey, f"{hotkey}_hotkey") for hotkey in HOTKEYS]: + if autosplit.settings_dict[hotkey_name]: + set_hotkey(autosplit, hotkey, cast(str, autosplit.settings_dict[hotkey_name])) change_capture_method(cast(CaptureMethodEnum, autosplit.settings_dict["capture_method"]), autosplit) autosplit.capture_method.recover_window(autosplit.settings_dict["captured_window_title"], autosplit) @@ -157,10 +143,7 @@ def __load_settings_from_file(autosplit: AutoSplit, load_settings_file_path: str return True -def load_settings( - autosplit: AutoSplit, - from_path: str = "" -): +def load_settings(autosplit: AutoSplit, from_path: str = ""): load_settings_file_path = from_path or QtWidgets.QFileDialog.getOpenFileName( autosplit, "Load Profile", diff --git a/src/utils.py b/src/utils.py index 07f8c23d..b4147944 100644 --- a/src/utils.py +++ b/src/utils.py @@ -13,7 +13,7 @@ import cv2 from win32 import win32gui -from gen.build_vars import AUTOSPLIT_BUILD_NUMBER +from gen.build_vars import AUTOSPLIT_BUILD_NUMBER, AUTOSPLIT_GITHUB_REPOSITORY if TYPE_CHECKING: from typing_extensions import TypeGuard @@ -107,3 +107,4 @@ def wrapped(*args: Any, **kwargs: Any): """Set DIRTY_VERSION_EXTENSION to an empty string to generate a clean version number""" AUTOSPLIT_VERSION = "2.0.0-alpha.4" + DIRTY_VERSION_EXTENSION START_AUTO_SPLITTER_TEXT = "Start Auto Splitter" +GITHUB_REPOSITORY = AUTOSPLIT_GITHUB_REPOSITORY diff --git a/typings/keyboard/__init__.pyi b/typings/keyboard/__init__.pyi index 44268b9c..6d9f5e54 100644 --- a/typings/keyboard/__init__.pyi +++ b/typings/keyboard/__init__.pyi @@ -1,18 +1,18 @@ from collections import Counter, defaultdict, deque -from collections.abc import Callable, Generator, Iterable, Sequence +from collections.abc import Callable, Generator, Sequence from queue import Queue from threading import Event as _UninterruptibleEvent +from typing import Optional # noqa: Y037 # mypy issue +from keyboard._canonical_names import all_modifiers as all_modifiers, sided_modifiers as sided_modifiers +from keyboard._generic import GenericListener as _GenericListener +from keyboard._keyboard_event import KEY_DOWN as KEY_DOWN, KEY_UP as KEY_UP, KeyboardEvent as KeyboardEvent from typing_extensions import Literal, TypeAlias -from ._canonical_names import all_modifiers as all_modifiers, sided_modifiers as sided_modifiers -from ._generic import GenericListener as _GenericListener -from ._keyboard_event import KEY_DOWN as KEY_DOWN, KEY_UP as KEY_UP, KeyboardEvent as KeyboardEvent - _Key: TypeAlias = int | str _ScanCodeList: TypeAlias = list[int] | tuple[int, ...] _ParseableHotkey: TypeAlias = _Key | list[int | _ScanCodeList] | tuple[int | _ScanCodeList, ...] -_Callback: TypeAlias = Callable[[KeyboardEvent], bool | None] | Callable[[], bool | None] +_Callback: TypeAlias = Callable[[KeyboardEvent], Optional[bool]] | Callable[[], Optional[bool]] # Can't use ParamSpecArgs on `args`, only on `*args` # _P = ParamSpec("_P") _P: TypeAlias = tuple[object, ...] @@ -161,15 +161,15 @@ unremap_hotkey = remove_hotkey def stash_state() -> list[int]: ... -def restore_state(scan_codes: Iterable[int]) -> None: ... -def restore_modifiers(scan_codes: Iterable[int]) -> None: ... +def restore_state(scan_codes: Sequence[int]) -> None: ... +def restore_modifiers(scan_codes: Sequence[int]) -> None: ... def write(text: str, delay: float = ..., restore_state_after: bool = ..., exact: bool | None = ...) -> None: ... def wait(hotkey: _ParseableHotkey | None = ..., suppress: bool = ..., trigger_on_release: bool = ...) -> None: ... -def get_hotkey_name(names: Iterable[str] | None = ...) -> str: ... +def get_hotkey_name(names: Sequence[str] | None = ...) -> str: ... def read_event(suppress: bool = ...) -> KeyboardEvent: ... def read_key(suppress: bool = ...) -> _Key: ... def read_hotkey(suppress: bool = ...) -> str: ... -def get_typed_strings(events: Iterable[KeyboardEvent], allow_backspace: bool = ...) -> Generator[str, None, None]: ... +def get_typed_strings(events: Sequence[KeyboardEvent], allow_backspace: bool = ...) -> Generator[str, None, None]: ... def start_recording( @@ -177,7 +177,7 @@ def start_recording( ) -> tuple[Queue[KeyboardEvent], Callable[[], None]]: ... def stop_recording() -> list[deque[KeyboardEvent]]: ... def record(until: str = ..., suppress: bool = ..., trigger_on_release: bool = ...) -> list[deque[KeyboardEvent]]: ... -def play(events: Iterable[KeyboardEvent], speed_factor: float = ...) -> None: ... +def play(events: Sequence[KeyboardEvent], speed_factor: float = ...) -> None: ... replay = play diff --git a/typings/keyboard/_darwinkeyboard.pyi b/typings/keyboard/_darwinkeyboard.pyi new file mode 100644 index 00000000..b0760a1b --- /dev/null +++ b/typings/keyboard/_darwinkeyboard.pyi @@ -0,0 +1,57 @@ +from collections.abc import Callable, Generator, Sequence +from ctypes import CDLL +from typing import TypeVar + +from _typeshed import Incomplete +from keyboard._canonical_names import normalize_name as normalize_name +from keyboard._keyboard_event import KEY_DOWN as KEY_DOWN, KEY_UP as KEY_UP, KeyboardEvent as KeyboardEvent +from typing_extensions import TypeAlias + +_Unused: TypeAlias = object + +# https://github.com/ronaldoussoren/pyobjc/milestone/3 +_CGEventTap: TypeAlias = Incomplete # Quartz.CGEventTap +_KCGEvent: TypeAlias = Incomplete # Quartz.kCGEvent +_T = TypeVar("_T") + +unichr = chr +Carbon: CDLL + + +class KeyMap: + non_layout_keys: dict[int, str] + layout_specific_keys: dict[int, tuple[str, str]] + def character_to_vk(self, character: str) -> tuple[int, list[str]]: ... + def vk_to_character(self, vk: int, modifiers: Sequence[str] = ...): ... + + +class KeyController: + key_map: KeyMap + current_modifiers: dict[str, bool] + media_keys: dict[str, int] + def press(self, key_code: int) -> None: ... + def release(self, key_code: int) -> None: ... + def map_char(self, character: str) -> tuple[int, list[str]]: ... + def map_scan_code(self, scan_code: int) -> int | str | None: ... + + +class KeyEventListener: + blocking: bool + callback: Callable[[KeyboardEvent], None] + listening: bool + tap: _CGEventTap + def __init__(self, callback: Callable[[KeyboardEvent], None], blocking: bool = ...) -> None: ... + def run(self) -> None: ... + def handler(self, proxy: _Unused, e_type: _KCGEvent, event: _T, refcon: _Unused) -> _T: ... + + +key_controller: KeyController + + +def init() -> None: ... +def press(scan_code: int) -> None: ... +def release(scan_code: int) -> None: ... +def map_name(name: str) -> Generator[tuple[int, list[str]], None, None]: ... +def name_from_scancode(scan_code: int) -> int | str | None: ... +def listen(callback: Callable[[KeyboardEvent], None]) -> None: ... +def type_unicode(character: str) -> None: ... diff --git a/typings/keyboard/_darwinmouse.pyi b/typings/keyboard/_darwinmouse.pyi new file mode 100644 index 00000000..61711c0f --- /dev/null +++ b/typings/keyboard/_darwinmouse.pyi @@ -0,0 +1,36 @@ +from collections.abc import Callable +from queue import Queue +from typing import TypeVar + +from _typeshed import Incomplete +from keyboard._keyboard_event import KeyboardEvent +from keyboard._mouse_event import (DOWN as DOWN, LEFT as LEFT, MIDDLE as MIDDLE, RIGHT as RIGHT, UP as UP, X2 as X2, + ButtonEvent as ButtonEvent, MoveEvent as MoveEvent, WheelEvent as WheelEvent, X as X, + _MouseButton) +from typing_extensions import TypeAlias + +_Unused: TypeAlias = object + +# https://github.com/ronaldoussoren/pyobjc/milestone/3 +_CGEventTap: TypeAlias = Incomplete # Quartz.CGEventTap +_KCGEventKey: TypeAlias = Incomplete # Quartz.kCGEventKey +_T = TypeVar("_T") + + +class MouseEventListener: + blocking: bool + callback: Callable[[KeyboardEvent], None] + listening: bool + def __init__(self, callback: Callable[[KeyboardEvent], None], blocking: bool = ...) -> None: ... + tap: _CGEventTap + def run(self) -> None: ... + def handler(self, proxy: _Unused, e_type: _KCGEventKey, event: _T, refcon: _Unused) -> _T: ... + + +def init() -> None: ... +def listen(queue: Queue[KeyboardEvent]) -> None: ... +def press(button: _MouseButton = ...) -> None: ... +def release(button: _MouseButton = ...) -> None: ... +def wheel(delta: int = ...) -> None: ... +def move_to(x: int, y: int) -> None: ... +def get_position() -> tuple[int, int]: ... diff --git a/typings/keyboard/_generic.pyi b/typings/keyboard/_generic.pyi index ed33c29a..9c62225f 100644 --- a/typings/keyboard/_generic.pyi +++ b/typings/keyboard/_generic.pyi @@ -2,12 +2,11 @@ from collections.abc import Callable from queue import Queue from threading import Lock, Thread +from keyboard._keyboard_event import KeyboardEvent +from keyboard._mouse_event import ButtonEvent, MoveEvent, WheelEvent from typing_extensions import Literal, TypeAlias -from ._keyboard_event import KeyboardEvent -from ._mouse_event import _MouseEvent - -_Event: TypeAlias = KeyboardEvent | _MouseEvent +_Event: TypeAlias = KeyboardEvent | ButtonEvent | WheelEvent | MoveEvent class GenericListener: diff --git a/typings/keyboard/_keyboard_event.pyi b/typings/keyboard/_keyboard_event.pyi index cdeac60f..1e0453fc 100644 --- a/typings/keyboard/_keyboard_event.pyi +++ b/typings/keyboard/_keyboard_event.pyi @@ -1,7 +1,6 @@ +from keyboard._canonical_names import canonical_names as canonical_names, normalize_name as normalize_name from typing_extensions import Literal -from ._canonical_names import canonical_names as canonical_names, normalize_name as normalize_name - basestring = str KEY_DOWN: Literal["down"] KEY_UP: Literal["up"] diff --git a/typings/keyboard/_mouse_event.pyi b/typings/keyboard/_mouse_event.pyi index 912a4bbf..9d1b5f74 100644 --- a/typings/keyboard/_mouse_event.pyi +++ b/typings/keyboard/_mouse_event.pyi @@ -3,8 +3,6 @@ from typing import NamedTuple from typing_extensions import Literal, TypeAlias -_MouseEvent: TypeAlias = ButtonEvent | WheelEvent | MoveEvent # noqa: Y047 # Used outside - LEFT: Literal["left"] RIGHT: Literal["right"] MIDDLE: Literal["middle"] @@ -25,13 +23,13 @@ else: _MouseButton: TypeAlias = Literal["left", "right", "middle"] if sys.platform == "win32": - _MouseEventType: TypeAlias = Literal["up", "down", "double", "wheel"] + _MouseEvent: TypeAlias = Literal["up", "down", "double", "wheel"] else: - _MouseEventType: TypeAlias = Literal["up", "down"] + _MouseEvent: TypeAlias = Literal["up", "down"] class ButtonEvent(NamedTuple): - event_type: _MouseEventType + event_type: _MouseEvent button: _MouseButton time: float diff --git a/typings/keyboard/_nixcommon.pyi b/typings/keyboard/_nixcommon.pyi new file mode 100644 index 00000000..3c616d86 --- /dev/null +++ b/typings/keyboard/_nixcommon.pyi @@ -0,0 +1,52 @@ +from collections.abc import Generator, Sequence +from io import BufferedReader, BufferedWriter +from queue import Queue +from typing import NamedTuple + +from _typeshed import Incomplete +from typing_extensions import Literal + +event_bin_format: Literal["llHHI"] +EV_SYN: Literal[0] +EV_KEY: Literal[1] +EV_REL: Literal[2] +EV_ABS: Literal[3] +EV_MSC: Literal[4] + + +def make_uinput() -> BufferedWriter: ... + + +class EventDevice: + path: str + def __init__(self, path: str) -> None: ... + @property + def input_file(self) -> BufferedReader: ... + @property + def output_file(self) -> BufferedWriter: ... + def read_event(self) -> tuple[float, Incomplete, Incomplete, Incomplete, str]: ... + def write_event(self, type, code, value) -> None: ... + + +class AggregatedEventDevice: + event_queue: Queue[tuple[float, Incomplete, Incomplete, Incomplete, str]] + devices: Sequence[EventDevice] + output: EventDevice + def __init__(self, devices: Sequence[EventDevice], output: EventDevice | None = ...) -> None: ... + def read_event(self) -> EventDevice: ... + def write_event(self, type, code, value) -> None: ... + + +class DeviceDescription(NamedTuple): + event_file: str + is_mouse: bool + is_keyboard: bool + + +device_pattern: str + + +def list_devices_from_proc(type_name: str) -> Generator[EventDevice, None, None]: ... +def list_devices_from_by_id(name_suffix: str, by_id: bool = ...) -> Generator[EventDevice, None, None]: ... +def aggregate_devices(type_name: str) -> AggregatedEventDevice | EventDevice: ... +def ensure_root() -> None: ... diff --git a/typings/keyboard/_nixkeyboard.pyi b/typings/keyboard/_nixkeyboard.pyi new file mode 100644 index 00000000..226b101c --- /dev/null +++ b/typings/keyboard/_nixkeyboard.pyi @@ -0,0 +1,40 @@ +from collections import defaultdict +from collections.abc import Callable, Generator + +from keyboard._canonical_names import all_modifiers as all_modifiers, normalize_name as normalize_name +from keyboard._keyboard_event import KEY_DOWN as KEY_DOWN, KEY_UP as KEY_UP, KeyboardEvent as KeyboardEvent +from keyboard._nixcommon import (EV_KEY as EV_KEY, AggregatedEventDevice, EventDevice, + aggregate_devices as aggregate_devices, ensure_root as ensure_root) + +# + + +def cleanup_key(name: str) -> tuple[str, bool]: ... +def cleanup_modifier(modifier: str) -> str: ... + + +to_name: defaultdict[tuple[int, tuple[str, ...]], list[str] | str] +from_name: defaultdict[str, list[tuple[int, tuple[str, ...]]]] +keypad_scan_codes: set[str] + + +def register_key(key_and_modifiers: tuple[int, tuple[str, ...]], name: str) -> None: ... +def build_tables() -> None: ... + + +device: AggregatedEventDevice | EventDevice | None + + +def build_device() -> None: ... +def init() -> None: ... + + +pressed_modifiers: set[str] + + +def listen(callback: Callable[[KeyboardEvent], None]) -> None: ... +def write_event(scan_code: int, is_down: bool | int) -> None: ... +def map_name(name: str) -> Generator[tuple[int, tuple[str, ...]], None, None]: ... +def press(scan_code: int) -> None: ... +def release(scan_code: int) -> None: ... +def type_unicode(character: str | bytes | bytearray) -> None: ... diff --git a/typings/keyboard/_nixmouse.pyi b/typings/keyboard/_nixmouse.pyi new file mode 100644 index 00000000..d292cb28 --- /dev/null +++ b/typings/keyboard/_nixmouse.pyi @@ -0,0 +1,55 @@ +from ctypes import CDLL +from queue import Queue +from subprocess import check_output as check_output + +from _typeshed import Incomplete +from keyboard._mouse_event import (DOWN as DOWN, LEFT as LEFT, MIDDLE as MIDDLE, RIGHT as RIGHT, UP as UP, X2 as X2, + ButtonEvent as ButtonEvent, MoveEvent as MoveEvent, WheelEvent as WheelEvent, X as X, + _MouseButton) +from keyboard._nixcommon import (EV_ABS as EV_ABS, EV_KEY as EV_KEY, EV_MSC as EV_MSC, EV_REL as EV_REL, + EV_SYN as EV_SYN, AggregatedEventDevice, EventDevice, + aggregate_devices as aggregate_devices, ensure_root as ensure_root) +from typing_extensions import Literal + +x11: CDLL | None +display: Incomplete # x11.XOpenDisplay(None) +window: Incomplete # x11.XDefaultRootWindow(display) + + +def build_display() -> None: ... +def get_position() -> tuple[int, int]: ... +def move_to(x, y) -> None: ... + + +REL_X: Literal[0] +REL_Y: Literal[1] +REL_Z: Literal[2] +REL_HWHEEL: Literal[6] +REL_WHEEL: Literal[8] + +ABS_X: Literal[0] +ABS_Y: Literal[1] + +BTN_MOUSE: Literal[272] +BTN_LEFT: Literal[272] +BTN_RIGHT: Literal[273] +BTN_MIDDLE: Literal[274] +BTN_SIDE: Literal[275] +BTN_EXTRA: Literal[276] + +button_by_code: dict[int, str] +code_by_button: dict[str, int] +device: AggregatedEventDevice | EventDevice | None + + +def build_device() -> None: ... + + +init = build_device + + +def listen(queue: Queue[ButtonEvent | WheelEvent | MoveEvent]) -> None: ... +def press(button: _MouseButton = ...) -> None: ... +def release(button: _MouseButton = ...) -> None: ... +def move_relative(x: int, y: int) -> None: ... +def wheel(delta: int = ...) -> None: ... diff --git a/typings/keyboard/_winkeyboard.pyi b/typings/keyboard/_winkeyboard.pyi new file mode 100644 index 00000000..7d273869 --- /dev/null +++ b/typings/keyboard/_winkeyboard.pyi @@ -0,0 +1,238 @@ +import sys +from collections import defaultdict +from collections.abc import Callable, Generator, Sequence +from ctypes import (Array, Structure, Union, c_char as c_char, c_int, c_int32 as c_int32, c_long, c_short, c_uint, + c_uint8, c_uint32 as c_uint32, c_wchar) +from ctypes.wintypes import (BOOL, DWORD, HHOOK, HMODULE, LONG, LPARAM, LPCWSTR, LPDWORD, LPMSG as LPMSG, LPWSTR, WCHAR, + WORD, WPARAM) +from threading import Lock + +from _typeshed import Incomplete +from keyboard._canonical_names import normalize_name as normalize_name +from keyboard._keyboard_event import KEY_DOWN as KEY_DOWN, KEY_UP as KEY_UP, KeyboardEvent as KeyboardEvent +from typing_extensions import Literal, TypeAlias + +if sys.platform == "win32": + from ctypes import WinDLL + + kernel32: WinDLL + user32: WinDLL + +ULONG_PTR: TypeAlias = LPDWORD +GetModuleHandleW: Callable[[LPCWSTR | None], HMODULE] +VK_PACKET: Literal[231] +INPUT_MOUSE: Literal[0] +INPUT_KEYBOARD: Literal[1] +INPUT_HARDWARE: Literal[2] +KEYEVENTF_KEYUP: Literal[2] +KEYEVENTF_UNICODE: Literal[4] + + +class KBDLLHOOKSTRUCT(Structure): + vk_code: DWORD + scan_code: DWORD + flags: DWORD + time: c_int + dwExtraInfo: ULONG_PTR + + +class MOUSEINPUT(Structure): + dx: LONG + dy: LONG + mouseData: DWORD + dwFlags: DWORD + time: DWORD + dwExtraInfo: ULONG_PTR + + +class KEYBDINPUT(Structure): + wVk: WORD + wScan: WORD + dwFlags: DWORD + time: DWORD + dwExtraInfo: ULONG_PTR + + +class HARDWAREINPUT(Structure): + uMsg: DWORD + wParamL: WORD + wParamH: WORD + + +class _INPUTunion(Union): + mi: MOUSEINPUT + ki: KEYBDINPUT + hi: HARDWAREINPUT + + +class INPUT(Structure): + type: DWORD + union: _INPUTunion + + +_POINTER_KBDLLHOOKSTRUCT: TypeAlias = Incomplete # POINTER(KBDLLHOOKSTRUCT) +_LRESULT: TypeAlias = Incomplete +_POINTER_INPUT: TypeAlias = Incomplete # POINTER(INPUT) +_LowLevelKeyboardProc: TypeAlias = Callable[[Callable[[c_int, WPARAM, LPARAM], c_int]], _POINTER_KBDLLHOOKSTRUCT] +LowLevelKeyboardProc: _LowLevelKeyboardProc +SetWindowsHookEx: Callable[[c_int, _LowLevelKeyboardProc, c_int, c_int], HHOOK] +CallNextHookEx: Callable[[c_int, c_int, WPARAM, LPARAM], c_int] +UnhookWindowsHookEx: Callable[[HHOOK], BOOL] +GetMessage: Callable[[LPMSG, c_int, c_int, c_int], BOOL] +TranslateMessage: Callable[[LPMSG], BOOL] +DispatchMessage: Callable[[LPMSG], _LRESULT] +_KeyboardStateType: TypeAlias = Array[c_uint8] +keyboard_state_type: _KeyboardStateType +GetKeyboardState: Callable[[_KeyboardStateType], BOOL] +GetKeyNameText: Callable[[c_long, LPWSTR, c_int], c_int] +MapVirtualKey: Callable[[c_uint, c_uint], c_uint] +ToUnicode: Callable[[c_uint, c_uint, _KeyboardStateType, LPWSTR, c_int, c_uint], c_int] +SendInput: Callable[[c_uint, _POINTER_INPUT, c_int], c_uint] +MAPVK_VK_TO_CHAR: Literal[2] +MAPVK_VK_TO_VSC: Literal[0] +MAPVK_VSC_TO_VK: Literal[1] +MAPVK_VK_TO_VSC_EX: Literal[4] +MAPVK_VSC_TO_VK_EX: Literal[3] +VkKeyScan: Callable[[WCHAR], c_short] +LLKHF_INJECTED: Literal[16] + +WM_KEYDOWN: Literal[256] +WM_KEYUP: Literal[257] +WM_SYSKEYDOWN: Literal[260] +WM_SYSKEYUP: Literal[261] +keyboard_event_types: dict[int, str] +official_virtual_keys: dict[ + int, + tuple[Literal["control-break processing"], Literal[False]] + | tuple[Literal["backspace"], Literal[False]] + | tuple[Literal["tab"], Literal[False]] + | tuple[Literal["clear"], Literal[False]] + | tuple[Literal["enter"], Literal[False]] + | tuple[Literal["shift"], Literal[False]] + | tuple[Literal["ctrl"], Literal[False]] + | tuple[Literal["alt"], Literal[False]] + | tuple[Literal["pause"], Literal[False]] + | tuple[Literal["caps lock"], Literal[False]] + | tuple[Literal["ime kana mode"], Literal[False]] + | tuple[Literal["ime hanguel mode"], Literal[False]] + | tuple[Literal["ime hangul mode"], Literal[False]] + | tuple[Literal["ime junja mode"], Literal[False]] + | tuple[Literal["ime final mode"], Literal[False]] + | tuple[Literal["ime hanja mode"], Literal[False]] + | tuple[Literal["ime kanji mode"], Literal[False]] + | tuple[Literal["esc"], Literal[False]] + | tuple[Literal["ime convert"], Literal[False]] + | tuple[Literal["ime nonconvert"], Literal[False]] + | tuple[Literal["ime accept"], Literal[False]] + | tuple[Literal["ime mode change request"], Literal[False]] + | tuple[Literal["spacebar"], Literal[False]] + | tuple[Literal["page up"], Literal[False]] + | tuple[Literal["page down"], Literal[False]] + | tuple[Literal["end"], Literal[False]] + | tuple[Literal["home"], Literal[False]] + | tuple[Literal["left"], Literal[False]] + | tuple[Literal["up"], Literal[False]] + | tuple[Literal["right"], Literal[False]] + | tuple[Literal["down"], Literal[False]] + | tuple[Literal["select"], Literal[False]] + | tuple[Literal["print"], Literal[False]] + | tuple[Literal["execute"], Literal[False]] + | tuple[Literal["print screen"], Literal[False]] + | tuple[Literal["insert"], Literal[False]] + | tuple[Literal["delete"], Literal[False]] + | tuple[Literal["help"], Literal[False]] + | tuple[Literal["0"], Literal[False]] + | tuple[Literal["1"], Literal[False]] + | tuple[Literal["2"], Literal[False]] + | tuple[Literal["3"], Literal[False]] + | tuple[Literal["4"], Literal[False]] + | tuple[Literal["5"], Literal[False]] + | tuple[Literal["6"], Literal[False]] + | tuple[Literal["7"], Literal[False]] + | tuple[Literal["8"], Literal[False]] + | tuple[Literal["9"], Literal[False]] + | tuple[Literal["a"], Literal[False]] + | tuple[Literal["b"], Literal[False]] + | tuple[Literal["c"], Literal[False]] + | tuple[Literal["d"], Literal[False]] + | tuple[Literal["e"], Literal[False]] + | tuple[Literal["f"], Literal[False]] + | tuple[Literal["g"], Literal[False]] + | tuple[Literal["h"], Literal[False]] + | tuple[Literal["i"], Literal[False]] + | tuple[Literal["j"], Literal[False]] + | tuple[Literal["k"], Literal[False]] + | tuple[Literal["l"], Literal[False]] + | tuple[Literal["m"], Literal[False]] + | tuple[Literal["n"], Literal[False]] + | tuple[Literal["o"], Literal[False]] + | tuple[Literal["p"], Literal[False]], +] +tables_lock: Lock +to_name: defaultdict[tuple[int, int, int, str | int | tuple[str, ...]] | tuple[int, int, int, int, int], list[str]] +from_name: defaultdict[str, list[tuple[int, tuple[str | int | tuple[str, ...], ...]]]] +scan_code_to_vk: dict[int, int] +distinct_modifiers: list[ + tuple[()] + | tuple[Literal["shift"]] + | tuple[Literal["alt gr"]] + | tuple[Literal["num lock"]] + | tuple[Literal["shift"], Literal["num lock"]] + | tuple[Literal["caps lock"]] + | tuple[Literal["shift"], Literal["caps lock"]] + | tuple[Literal["alt gr"], Literal["num lock"]] +] +name_buffer: Array[c_wchar] +unicode_buffer: Array[c_wchar] +keyboard_state: _KeyboardStateType + + +def get_event_names( + scan_code: int, vk: int, is_extended: Literal[0, 1], modifiers: Sequence[str] +) -> Generator[str, None, None]: ... + + +init: Callable[[], None] +keypad_keys: list[ + tuple[Literal[126], Literal[194], Literal[0]] + | tuple[Literal[28], Literal[13], Literal[1]] + | tuple[Literal[53], Literal[111], Literal[1]] + | tuple[Literal[55], Literal[106], Literal[0]] + | tuple[Literal[69], Literal[144], Literal[1]] + | tuple[Literal[71], Literal[103], Literal[0]] + | tuple[Literal[71], Literal[36], Literal[0]] + | tuple[Literal[72], Literal[104], Literal[0]] + | tuple[Literal[72], Literal[38], Literal[0]] + | tuple[Literal[73], Literal[105], Literal[0]] + | tuple[Literal[73], Literal[33], Literal[0]] + | tuple[Literal[74], Literal[109], Literal[0]] + | tuple[Literal[75], Literal[100], Literal[0]] + | tuple[Literal[75], Literal[37], Literal[0]] + | tuple[Literal[76], Literal[101], Literal[0]] + | tuple[Literal[76], Literal[12], Literal[0]] + | tuple[Literal[77], Literal[102], Literal[0]] + | tuple[Literal[77], Literal[39], Literal[0]] + | tuple[Literal[78], Literal[107], Literal[0]] + | tuple[Literal[79], Literal[35], Literal[0]] + | tuple[Literal[79], Literal[97], Literal[0]] + | tuple[Literal[80], Literal[40], Literal[0]] + | tuple[Literal[80], Literal[98], Literal[0]] + | tuple[Literal[81], Literal[34], Literal[0]] + | tuple[Literal[81], Literal[99], Literal[0]] + | tuple[Literal[82], Literal[45], Literal[0]] + | tuple[Literal[82], Literal[96], Literal[0]] + | tuple[Literal[83], Literal[110], Literal[0]] + | tuple[Literal[83], Literal[46], Literal[0]] +] +shift_is_pressed: bool +altgr_is_pressed: bool +ignore_next_right_alt: bool +shift_vks: set[int] + + +def prepare_intercept(callback: Callable[[KeyboardEvent], bool]) -> None: ... +def listen(callback: Callable[[KeyboardEvent], bool]) -> None: ... +def map_name(name: str) -> Generator[tuple[int, str | int | tuple[str, ...]], None, None]: ... +def press(code: int) -> None: ... +def release(code: int) -> None: ... +def type_unicode(character: str) -> None: ... diff --git a/typings/keyboard/_winmouse.pyi b/typings/keyboard/_winmouse.pyi new file mode 100644 index 00000000..d9b5fc7b --- /dev/null +++ b/typings/keyboard/_winmouse.pyi @@ -0,0 +1,118 @@ +import sys +from collections.abc import Callable +from ctypes import (Structure, c_char as c_char, c_int, c_int32, c_long, c_short as c_short, c_uint as c_uint, + c_uint8 as c_uint8, c_uint32 as c_uint32) +from ctypes.wintypes import BOOL, DWORD, HHOOK, LPARAM, LPMSG as LPMSG, LPWSTR as LPWSTR, WCHAR as WCHAR, WPARAM +from queue import Queue + +from _typeshed import Incomplete +from keyboard._mouse_event import (DOUBLE as DOUBLE, DOWN as DOWN, HORIZONTAL as HORIZONTAL, LEFT as LEFT, + MIDDLE as MIDDLE, RIGHT as RIGHT, UP as UP, VERTICAL as VERTICAL, WHEEL as WHEEL, + X2 as X2, ButtonEvent as ButtonEvent, MoveEvent as MoveEvent, + WheelEvent as WheelEvent, X as X, _MouseButton) +from typing_extensions import Literal, TypeAlias + +if sys.platform == "win32": + from ctypes import WinDLL + + user32: WinDLL + + +class MSLLHOOKSTRUCT(Structure): + x: c_long + y: c_long + data: c_int32 + reserved: c_int32 + flags: DWORD + time: c_int + + +_POINTER_MSLLHOOKSTRUCT: TypeAlias = Incomplete # POINTER(MSLLHOOKSTRUCT) +_LRESULT: TypeAlias = Incomplete +LowLevelMouseProc: TypeAlias = Callable[[Callable[[c_int, WPARAM, LPARAM], c_int]], _POINTER_MSLLHOOKSTRUCT] +SetWindowsHookEx: Callable[[c_int, LowLevelMouseProc, c_int, c_int], HHOOK] +CallNextHookEx: Callable[[c_int, c_int, WPARAM, LPARAM], c_int] +UnhookWindowsHookEx: Callable[[HHOOK], BOOL] +GetMessage: Callable[[LPMSG, c_int, c_int, c_int], BOOL] +TranslateMessage: Callable[[LPMSG], BOOL] +DispatchMessage: Callable[[LPMSG], _LRESULT] +WM_MOUSEMOVE: Literal[512] +WM_LBUTTONDOWN: Literal[513] +WM_LBUTTONUP: Literal[514] +WM_LBUTTONDBLCLK: Literal[515] +WM_RBUTTONDOWN: Literal[516] +WM_RBUTTONUP: Literal[517] +WM_RBUTTONDBLCLK: Literal[518] +WM_MBUTTONDOWN: Literal[519] +WM_MBUTTONUP: Literal[520] +WM_MBUTTONDBLCLK: Literal[521] +WM_MOUSEWHEEL: Literal[522] +WM_XBUTTONDOWN: Literal[523] +WM_XBUTTONUP: Literal[524] +WM_XBUTTONDBLCLK: Literal[525] +WM_NCXBUTTONDOWN: Literal[171] +WM_NCXBUTTONUP: Literal[172] +WM_NCXBUTTONDBLCLK: Literal[173] +WM_MOUSEHWHEEL: Literal[526] +buttons_by_wm_code: dict[ + int, + tuple[Literal["down"], Literal["left"]] + | tuple[Literal["up"], Literal["left"]] + | tuple[Literal["double"], Literal["left"]] + | tuple[Literal["down"], Literal["right"]] + | tuple[Literal["up"], Literal["right"]] + | tuple[Literal["double"], Literal["right"]] + | tuple[Literal["down"], Literal["middle"]] + | tuple[Literal["up"], Literal["middle"]] + | tuple[Literal["double"], Literal["middle"]] + | tuple[Literal["down"], Literal["x"]] + | tuple[Literal["up"], Literal["x"]] + | tuple[Literal["double"], Literal["x"]], +] +MOUSEEVENTF_ABSOLUTE: Literal[32768] +MOUSEEVENTF_MOVE: Literal[1] +MOUSEEVENTF_WHEEL: Literal[2048] +MOUSEEVENTF_HWHEEL: Literal[4096] +MOUSEEVENTF_LEFTDOWN: Literal[2] +MOUSEEVENTF_LEFTUP: Literal[4] +MOUSEEVENTF_RIGHTDOWN: Literal[8] +MOUSEEVENTF_RIGHTUP: Literal[16] +MOUSEEVENTF_MIDDLEDOWN: Literal[32] +MOUSEEVENTF_MIDDLEUP: Literal[64] +MOUSEEVENTF_XDOWN: Literal[128] +MOUSEEVENTF_XUP: Literal[256] +simulated_mouse_codes: dict[ + tuple[Literal["wheel"], Literal["horizontal"]] + | tuple[Literal["wheel"], Literal["vertical"]] + | tuple[Literal["down"], Literal["left"]] + | tuple[Literal["up"], Literal["left"]] + | tuple[Literal["down"], Literal["right"]] + | tuple[Literal["up"], Literal["right"]] + | tuple[Literal["down"], Literal["middle"]] + | tuple[Literal["up"], Literal["middle"]] + | tuple[Literal["down"], Literal["x"]] + | tuple[Literal["up"], Literal["x"]], + int, +] +NULL: c_long +WHEEL_DELTA: Literal[120] +init: Callable[[], None] + + +def listen(queue: Queue[MoveEvent | WheelEvent | ButtonEvent]): + ... + + +def press(button: _MouseButton = ...) -> None: ... +def release(button: _MouseButton = ...) -> None: ... +def wheel(delta: int = ...) -> None: ... +def move_to(x: int | c_long, y: int | c_long) -> None: ... +def move_relative(x: int | c_long, y: int | c_long) -> None: ... + + +class POINT(Structure): + x: c_long + y: c_long + + +def get_position() -> tuple[c_long, c_long]: ... diff --git a/typings/keyboard/mouse.pyi b/typings/keyboard/mouse.pyi index 9db6f2be..1de736be 100644 --- a/typings/keyboard/mouse.pyi +++ b/typings/keyboard/mouse.pyi @@ -1,24 +1,25 @@ import sys -from collections.abc import Callable, Iterable +from collections.abc import Callable, Sequence from ctypes import c_long +from typing import TypeVar from keyboard._generic import GenericListener as _GenericListener from keyboard._mouse_event import (DOUBLE as DOUBLE, DOWN as DOWN, LEFT as LEFT, MIDDLE as MIDDLE, RIGHT as RIGHT, UP as UP, X2 as X2, ButtonEvent as ButtonEvent, MoveEvent as MoveEvent, - WheelEvent as WheelEvent, X as X, _MouseButton, _MouseEvent, _MouseEventType) + WheelEvent as WheelEvent, X as X, _MouseButton, _MouseEvent) from typing_extensions import Literal, TypeAlias # Can't use ParamSpecArgs on `args`, only on `*args` # _P = ParamSpec("_P") _P: TypeAlias = tuple[object, ...] -_Callback: TypeAlias = Callable[[_MouseEvent], bool | None] +_Callback: TypeAlias = Callable[[ButtonEvent | WheelEvent | MoveEvent], bool | None] class _MouseListener(_GenericListener): def init(self) -> None: ... def pre_process_event( - self, event: _MouseEvent + self, event: ButtonEvent | MoveEvent | WheelEvent ) -> Literal[True]: ... def listen(self) -> None: ... @@ -41,13 +42,13 @@ def on_button( callback: Callable[..., None], args: _P = ..., buttons: list[_MouseButton] | tuple[_MouseButton, ...] | _MouseButton = ..., - types: list[_MouseEventType] | tuple[_MouseEventType, ...] | _MouseEventType = ..., + types: list[_MouseEvent] | tuple[_MouseEvent, ...] | _MouseEvent = ..., ) -> _Callback: ... def on_click(callback: Callable[..., None], args: _P = ...) -> _Callback: ... def on_double_click(callback: Callable[..., None], args: _P = ...) -> _Callback: ... def on_right_click(callback: Callable[..., None], args: _P = ...) -> _Callback: ... def on_middle_click(callback: Callable[..., None], args: _P = ...) -> _Callback: ... -def wait(button: _MouseButton = ..., target_types: tuple[_MouseEventType] = ...) -> None: ... +def wait(button: _MouseButton = ..., target_types: tuple[_MouseEvent] = ...) -> None: ... if sys.platform == "win32": @@ -60,11 +61,14 @@ else: def hook(callback: _Callback) -> _Callback: ... def unhook(callback: _Callback) -> None: ... def unhook_all() -> None: ... -def record(button: _MouseButton = ..., target_types: tuple[_MouseEventType] = ...) -> _MouseEvent: ... + + +def record(button: _MouseButton = ..., target_types: tuple[_MouseEvent] + = ...) -> ButtonEvent | WheelEvent | MoveEvent: ... def play( - events: Iterable[_MouseEvent], + events: Sequence[ButtonEvent | WheelEvent | MoveEvent], speed_factor: float = ..., include_clicks: bool = ..., include_moves: bool = ..., diff --git a/typings/pyautogui/__init__.pyi b/typings/pyautogui/__init__.pyi index 102b8a52..0fe65284 100644 --- a/typings/pyautogui/__init__.pyi +++ b/typings/pyautogui/__init__.pyi @@ -1,7 +1,9 @@ -from collections.abc import Callable, Generator, Iterable, Sequence +import collections.abc +from collections.abc import Callable, Generator from datetime import datetime from typing import NamedTuple, TypeVar, overload +from _typeshed import Incomplete from typing_extensions import ParamSpec # from pyscreeze import Box @@ -19,12 +21,13 @@ class ImageNotFoundException(PyAutoGUIException): ... +collectionsSequence = collections.abc.Sequence + _P = ParamSpec("_P") _R = TypeVar("_R") -# TODO: Complete types with pyscreeze once we can import it as a type dependency -# Actually `pyscreeze.Box`, but typeshed doesn't currently have stubs for pyscreeze -# (and the library doesn't have type annotations either) +# TODO: Complete types with pyscreeze once we can import non-types dependencies +# See: https://github.com/python/typeshed/issues/5768 class _Box(NamedTuple): @@ -35,16 +38,12 @@ class _Box(NamedTuple): def raisePyAutoGUIImageNotFoundException(wrappedFunction: Callable[_P, _R]) -> Callable[_P, _R]: ... - -# These functions reuse pyscreeze functions directly. See above TODO. - - -def locate(*args, **kwargs) -> _Box | None: ... -def locateAll(*args, **kwargs) -> Generator[_Box, None, None]: ... -def locateAllOnScreen(*args, **kwargs) -> Generator[_Box, None, None]: ... -def locateCenterOnScreen(*args, **kwargs) -> Point | None: ... -def locateOnScreen(*args, **kwargs) -> _Box | None: ... -def locateOnWindow(*args, **kwargs) -> _Box | None: ... +def locate(*args: Incomplete, **kwargs: Incomplete) -> _Box | None: ... +def locateAll(*args: Incomplete, **kwargs: Incomplete) -> Generator[_Box, None, None]: ... +def locateAllOnScreen(*args: Incomplete, **kwargs: Incomplete) -> Generator[_Box, None, None]: ... +def locateCenterOnScreen(*args: Incomplete, **kwargs: Incomplete) -> Point | None: ... +def locateOnScreen(*args: Incomplete, **kwargs: Incomplete) -> _Box | None: ... +def locateOnWindow(*args: Incomplete, **kwargs: Incomplete) -> _Box | None: ... def mouseInfo() -> None: ... def useImageNotFoundException(value: bool | None = ...) -> None: ... @@ -89,15 +88,14 @@ def linear(n: float) -> float: ... def position(x: int | None = ..., y: int | None = ...) -> Point: ... def size() -> Size: ... @overload -def onScreen(x: tuple[float, float], y: None = ...) -> bool: ... +def onScreen(xy: tuple[float, float]) -> bool: ... @overload def onScreen(x: float, y: float) -> bool: ... def mouseDown( - x: float | Sequence[float] | str | None = ..., - y: float | None = ..., - # Docstring says `button` can also be `int`, but `.lower()` is called unconditionally in `_normalizeButton()` + x: float | collectionsSequence[int] | None = ..., + y: float | collectionsSequence[int] | None = ..., button: str = ..., duration: float = ..., tween: Callable[[float], float] = ..., @@ -107,9 +105,8 @@ def mouseDown( def mouseUp( - x: float | Sequence[float] | str | None = ..., - y: float | None = ..., - # Docstring says `button` can also be `int`, but `.lower()` is called unconditionally in `_normalizeButton()` + x: float | collectionsSequence[int] | None = ..., + y: float | collectionsSequence[int] | None = ..., button: str = ..., duration: float = ..., tween: Callable[[float], float] = ..., @@ -119,11 +116,10 @@ def mouseUp( def click( - x: float | Sequence[float] | str | None = ..., - y: float | None = ..., + x: float | collectionsSequence[int] | None = ..., + y: float | collectionsSequence[int] | None = ..., clicks: int = ..., interval: float = ..., - # Docstring says `button` can also be `int`, but `.lower()` is called unconditionally in `_normalizeButton()` button: str = ..., duration: float = ..., tween: Callable[[float], float] = ..., @@ -133,8 +129,8 @@ def click( def leftClick( - x: float | Sequence[float] | str | None = ..., - y: float | None = ..., + x: float | collectionsSequence[int] | None = ..., + y: float | collectionsSequence[int] | None = ..., interval: float = ..., duration: float = ..., tween: Callable[[float], float] = ..., @@ -144,8 +140,8 @@ def leftClick( def rightClick( - x: float | Sequence[float] | str | None = ..., - y: float | None = ..., + x: float | collectionsSequence[int] | None = ..., + y: float | collectionsSequence[int] | None = ..., interval: float = ..., duration: float = ..., tween: Callable[[float], float] = ..., @@ -155,8 +151,8 @@ def rightClick( def middleClick( - x: float | Sequence[float] | str | None = ..., - y: float | None = ..., + x: float | collectionsSequence[int] | None = ..., + y: float | collectionsSequence[int] | None = ..., interval: float = ..., duration: float = ..., tween: Callable[[float], float] = ..., @@ -166,10 +162,9 @@ def middleClick( def doubleClick( - x: float | Sequence[float] | str | None = ..., - y: float | None = ..., + x: float | collectionsSequence[int] | None = ..., + y: float | collectionsSequence[int] | None = ..., interval: float = ..., - # Docstring says `button` can also be `int`, but `.lower()` is called unconditionally in `_normalizeButton()` button: str = ..., duration: float = ..., tween: Callable[[float], float] = ..., @@ -179,10 +174,9 @@ def doubleClick( def tripleClick( - x: float | Sequence[float] | str | None = ..., - y: float | None = ..., + x: float | collectionsSequence[int] | None = ..., + y: float | collectionsSequence[int] | None = ..., interval: float = ..., - # Docstring says `button` can also be `int`, but `.lower()` is called unconditionally in `_normalizeButton()` button: str = ..., duration: float = ..., tween: Callable[[float], float] = ..., @@ -193,8 +187,8 @@ def tripleClick( def scroll( clicks: float, - x: float | Sequence[float] | str | None = ..., - y: float | None = ..., + x: float | collectionsSequence[int] | None = ..., + y: float | collectionsSequence[int] | None = ..., logScreenshot: bool | None = ..., _pause: bool = ..., ) -> None: ... @@ -202,8 +196,8 @@ def scroll( def hscroll( clicks: float, - x: float | Sequence[float] | str | None = ..., - y: float | None = ..., + x: float | collectionsSequence[int] | None = ..., + y: float | collectionsSequence[int] | None = ..., logScreenshot: bool | None = ..., _pause: bool = ..., ) -> None: ... @@ -211,16 +205,16 @@ def hscroll( def vscroll( clicks: float, - x: float | Sequence[float] | str | None = ..., - y: float | None = ..., + x: float | collectionsSequence[int] | None = ..., + y: float | collectionsSequence[int] | None = ..., logScreenshot: bool | None = ..., _pause: bool = ..., ) -> None: ... def moveTo( - x: float | Sequence[float] | str | None = ..., - y: float | None = ..., + x: float | collectionsSequence[int] | None = ..., + y: float | collectionsSequence[int] | None = ..., duration: float = ..., tween: Callable[[float], float] = ..., logScreenshot: bool = ..., @@ -229,8 +223,8 @@ def moveTo( def moveRel( - xOffset: float | Sequence[float] | str | None = ..., - yOffset: float | None = ..., + xOffset: float | collectionsSequence[int] | None = ..., + yOffset: float | collectionsSequence[int] | None = ..., duration: float = ..., tween: Callable[[float], float] = ..., logScreenshot: bool = ..., @@ -242,11 +236,10 @@ move = moveRel def dragTo( - x: float | Sequence[float] | str | None = ..., - y: float | None = ..., + x: float | collectionsSequence[int] | None = ..., + y: float | collectionsSequence[int] | None = ..., duration: float = ..., tween: Callable[[float], float] = ..., - # Docstring says `button` can also be `int`, but `.lower()` is called unconditionally in `_normalizeButton()` button: str = ..., logScreenshot: bool | None = ..., _pause: bool = ..., @@ -255,11 +248,10 @@ def dragTo( def dragRel( - xOffset: float | Sequence[float] | str = ..., - yOffset: float = ..., + xOffset: float | collectionsSequence[int] = ..., + yOffset: float | collectionsSequence[int] = ..., duration: float = ..., tween: Callable[[float], float] = ..., - # Docstring says `button` can also be `int`, but `.lower()` is called unconditionally in `_normalizeButton()` button: str = ..., logScreenshot: bool | None = ..., _pause: bool = ..., @@ -275,15 +267,11 @@ def keyDown(key: str, logScreenshot: bool | None = ..., _pause: bool = ...) -> N def keyUp(key: str, logScreenshot: bool | None = ..., _pause: bool = ...) -> None: ... -def press( - keys: str | Iterable[str], - presses: int = ..., - interval: float = ..., - logScreenshot: bool | None = ..., - _pause: bool = ...) -> None: ... +def press(keys: str | list[str], presses: int = ..., interval: float = ..., + logScreenshot: bool | None = ..., _pause: bool = ...) -> None: ... -def hold(keys: str | Iterable[str], +def hold(keys: str | list[str], logScreenshot: bool | None = ..., _pause: bool = ...) -> Generator[None, None, @@ -291,18 +279,20 @@ def hold(keys: str | Iterable[str], def typewrite( - message: str | Sequence[str], interval: float = ..., logScreenshot: bool | None = ..., _pause: bool = ... -) -> None: ... + message: str | list[str], + interval: float = ..., + logScreenshot: bool | None = ..., + _pause: bool = ...) -> None: ... write = typewrite -def hotkey(*args: str, logScreenshot: bool | None = ..., interval: float = ...) -> None: ... +def hotkey(*args: str, **kwargs: float | bool | None) -> None: ... def failSafeCheck() -> None: ... def displayMousePosition(xOffset: float = ..., yOffset: float = ...) -> None: ... -def sleep(seconds: float) -> None: ... +def sleep(seconds: int) -> None: ... def countdown(seconds: int) -> None: ... -def run(commandStr: str, _ssCount: Sequence[int] | None = ...) -> None: ... +def run(commandStr: str, _ssCount: list[int] | None = ...) -> None: ... def printInfo(dontPrint: bool = ...) -> str: ... def getInfo() -> tuple[str, str, str, str, Size, datetime]: ... diff --git a/typings/pyautogui/_pyautogui_osx.pyi b/typings/pyautogui/_pyautogui_osx.pyi new file mode 100644 index 00000000..569791ac --- /dev/null +++ b/typings/pyautogui/_pyautogui_osx.pyi @@ -0,0 +1,2 @@ +keyboardMapping: dict[str, int] +special_key_translate_table: dict[str, int] diff --git a/typings/pyautogui/_pyautogui_win.pyi b/typings/pyautogui/_pyautogui_win.pyi new file mode 100644 index 00000000..738766ce --- /dev/null +++ b/typings/pyautogui/_pyautogui_win.pyi @@ -0,0 +1,39 @@ +import ctypes + +MOUSEEVENTF_MOVE: int +MOUSEEVENTF_LEFTDOWN: int +MOUSEEVENTF_LEFTUP: int +MOUSEEVENTF_LEFTCLICK: int +MOUSEEVENTF_RIGHTDOWN: int +MOUSEEVENTF_RIGHTUP: int +MOUSEEVENTF_RIGHTCLICK: int +MOUSEEVENTF_MIDDLEDOWN: int +MOUSEEVENTF_MIDDLEUP: int +MOUSEEVENTF_MIDDLECLICK: int +MOUSEEVENTF_ABSOLUTE: int +MOUSEEVENTF_WHEEL: int +MOUSEEVENTF_HWHEEL: int +KEYEVENTF_KEYDOWN: int +KEYEVENTF_KEYUP: int +INPUT_MOUSE: int +INPUT_KEYBOARD: int + + +class MOUSEINPUT(ctypes.Structure): + ... + + +class KEYBDINPUT(ctypes.Structure): + ... + + +class HARDWAREINPUT(ctypes.Structure): + ... + + +class INPUT(ctypes.Structure): + class _I(ctypes.Union): + ... + + +keyboardMapping: dict[str, int] diff --git a/typings/pyautogui/_pyautogui_x11.pyi b/typings/pyautogui/_pyautogui_x11.pyi new file mode 100644 index 00000000..24d90ecc --- /dev/null +++ b/typings/pyautogui/_pyautogui_x11.pyi @@ -0,0 +1,2 @@ +BUTTON_NAME_MAPPING: dict[str | int, int] +keyboardMapping: dict[str, int]