diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..954a4345 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,26 @@ +name: Lint + +on: + push: + branches-ignore: + - main + +jobs: + check: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10"] + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install ruff + - name: Analysing the code with ruff + run: | + ruff check . diff --git a/.github/workflows/windows-build-release.yml b/.github/workflows/windows-build-release.yml new file mode 100644 index 00000000..308b1a50 --- /dev/null +++ b/.github/workflows/windows-build-release.yml @@ -0,0 +1,69 @@ +on: + push: + tags: + - 'v*.*.*' + +permissions: + contents: write + +jobs: + + check: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10"] + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install ruff + - name: Analysing the code with ruff + run: | + ruff check . + + # The build job runs on a Windows machine and performs various build steps. + build: + runs-on: windows-latest + needs: check + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.10 + uses: actions/setup-python@v3 + with: + python-version: "3.10" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Freeze Installer + run: | + pyinstaller build.spec + - name: Build Installer + run: | + iscc installer.iss + - name: Freeze Portable + run: | + pyinstaller --distpath dist-portable build-portable.spec + + - name: Zip Portable + shell: pwsh + run: | + Copy-Item -Path assets -Destination dist-portable\ -Recurse + Copy-Item -Path configs -Destination dist-portable\ -Recurse + Compress-Archive -Path dist-portable -DestinationPath Grimassist-Portable-${{github.ref_name}}.zip + + - name: release + uses: softprops/action-gh-release@v1 + with: + files: | + Output/Grimassist-Installer-${{github.ref_name}}.exe + Grimassist-Portable-${{github.ref_name}}.zip + tag_name: ${{github.ref_name}} + draft: false + prerelease: false diff --git a/.gitignore b/.gitignore index 9029d3bc..8fdaee6f 100644 --- a/.gitignore +++ b/.gitignore @@ -74,6 +74,12 @@ docs/_build/ # PyBuilder target/ +# VsCode +.vscode/ + +# PyCharm +.idea/ + # Jupyter Notebook .ipynb_checkpoints @@ -129,3 +135,10 @@ dmypy.json .pyre/ *.bat log.txt + +# Ruff +.ruff/ +.ruff_cache/ + +# Output directory +output/ \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index 8ba5767e..00000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,93 +0,0 @@ -# Code of Conduct - -## Our Pledge - -In the interest of fostering an open and welcoming environment, we as -contributors and maintainers pledge to making participation in our project and -our community a harassment-free experience for everyone, regardless of age, body -size, disability, ethnicity, gender identity and expression, level of -experience, education, socio-economic status, nationality, personal appearance, -race, religion, or sexual identity and orientation. - -## Our Standards - -Examples of behavior that contributes to creating a positive environment -include: - -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members - -Examples of unacceptable behavior by participants include: - -* The use of sexualized language or imagery and unwelcome sexual attention or - advances -* Trolling, insulting/derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or electronic - address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Our Responsibilities - -Project maintainers are responsible for clarifying the standards of acceptable -behavior and are expected to take appropriate and fair corrective action in -response to any instances of unacceptable behavior. - -Project maintainers have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, or to ban temporarily or permanently any -contributor for other behaviors that they deem inappropriate, threatening, -offensive, or harmful. - -## Scope - -This Code of Conduct applies both within project spaces and in public spaces -when an individual is representing the project or its community. Examples of -representing a project or community include using an official project e-mail -address, posting via an official social media account, or acting as an appointed -representative at an online or offline event. Representation of a project may be -further defined and clarified by project maintainers. - -This Code of Conduct also applies outside the project spaces when the Project -Steward has a reasonable belief that an individual's behavior may have a -negative impact on the project or its community. - -## Conflict Resolution - -We do not believe that all conflict is bad; healthy debate and disagreement -often yield positive results. However, it is never okay to be disrespectful or -to engage in behavior that violates the project’s code of conduct. - -If you see someone violating the code of conduct, you are encouraged to address -the behavior directly with those involved. Many issues can be resolved quickly -and easily, and this gives people more control over the outcome of their -dispute. If you are unable to resolve the matter for any reason, or if the -behavior is threatening or harassing, report it. We are dedicated to providing -an environment where participants feel welcome and safe. - -Reports should be directed to *[PROJECT STEWARD NAME(s) AND EMAIL(s)]*, the -Project Steward(s) for *[PROJECT NAME]*. It is the Project Steward’s duty to -receive and address reported violations of the code of conduct. They will then -work with a committee consisting of representatives from the Open Source -Programs Office and the Google Open Source Strategy team. If for any reason you -are uncomfortable reaching out to the Project Steward, please email -opensource@google.com. - -We will investigate every complaint, but you may not receive a direct response. -We will use our discretion in determining when and how to follow up on reported -incidents, which may range from not taking action to permanent expulsion from -the project and project-sponsored spaces. We will notify the accused of the -report and provide them an opportunity to discuss it before any action is taken. -The identity of the reporter will be omitted from the details of the report -supplied to the accused. In potentially harmful situations, such as ongoing -harassment or threats to anyone's safety, we may take action without notice. - -## Attribution - -This Code of Conduct is adapted from the Contributor Covenant, version 1.4, -available at -https://www.contributor-covenant.org/version/1/4/code-of-conduct.html \ No newline at end of file diff --git a/CONTRIBUTING b/CONTRIBUTING deleted file mode 100644 index 9d7656be..00000000 --- a/CONTRIBUTING +++ /dev/null @@ -1,29 +0,0 @@ -# How to Contribute - -We'd love to accept your patches and contributions to this project. There are -just a few small guidelines you need to follow. - -## Contributor License Agreement - -Contributions to this project must be accompanied by a Contributor License -Agreement (CLA). You (or your employer) retain the copyright to your -contribution; this simply gives us permission to use and redistribute your -contributions as part of the project. Head over to - to see your current agreements on file or -to sign a new one. - -You generally only need to submit a CLA once, so if you've already submitted one -(even if it was for a different project), you probably don't need to do it -again. - -## Code Reviews - -All submissions, including submissions by project members, require review. We -use GitHub pull requests for this purpose. Consult -[GitHub Help](https://help.github.com/articles/about-pull-requests/) for more -information on using pull requests. - -## Community Guidelines - -This project follows -[Google's Open Source Community Guidelines](https://opensource.google/conduct/). \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..51ac0f52 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,15 @@ +# How to Contribute + +We'd love to accept your patches and contributions to this project. There are +just a few small guidelines you need to follow. + +## Code Reviews + +All submissions, including submissions by project members, require review. We +use GitHub pull requests for this purpose. Consult +[GitHub Help](https://help.github.com/articles/about-pull-requests/) for more +information on using pull requests. + +## Community Guidelines + +Be a decent human being, bot, or whatever. \ No newline at end of file diff --git a/README.md b/README.md index 29d86a7a..d09fccc5 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,21 @@ -# Project Gameface -Project Gameface helps gamers control their mouse cursor using their head movement and facial gestures. +# Grimassist +Grimassist helps gamers control their mouse cursor using their head movement and facial gestures. -# Download +# Download + +## Single portable directory + 1. Download the program from [Release section](../../releases/) -2. Run `run_app.exe` +2. Run `grimassist.exe` + + +## Installer +1. Download the Grimassist-Installer.exe from [Release section](../../releases/) +2. Install it +3. Run from your Windows shortcuts/desktop # Model used @@ -31,7 +40,7 @@ MediaPipe Face Landmark Detection API [Task Guide](https://developers.google.com ## Installation > Environment >- Windows ->- Python 3.9 +>- Python 3.10 ``` pip install -r requirements.txt ``` @@ -39,7 +48,7 @@ pip install -r requirements.txt ## Quick start 1. Run main application ``` - python run_app.py + python grimassist.py ``` @@ -52,20 +61,22 @@ pip install -r requirements.txt |-----------|---------------------------------------| | camera_id | Default camera index on your machine. | | tracking_vert_idxs | Tracking points for controlling cursor ([see](assets/images/uv_unwrap_full.png)) | -| camera_id | Default camera index on your machine. | | spd_up | Cursor speed in the upward direction | | spd_down | Cursor speed in downward direction | | spd_left | Cursor speed in left direction | | spd_right | Cursor speed in right direction | -| pointer_smooth | Amount of cursor smoothness | -| shape_smooth | Reduces the flickering of the action | -| hold_trigger_ms | Hold action trigger delay in milliseconds | -| auto_play | Automatically begin playing when you launch the program | -| mouse_acceleration | Make the cursor move faster when the head moves quickly | -| use_transformation_matrix | Control cursor using head direction (tracking_vert_idxs will be ignored) | +| pointer_smooth | Amount of cursor smoothness | +| shape_smooth | Reduces the flickering of the action | +| tick_interval_ms | interval between each tick of the pipeline in milliseconds | +| hold_trigger_ms | Hold action trigger delay in milliseconds | +| rapid_fire_interval_ms | interval between each activation of the action in milliseconds | +| auto_play | Automatically begin playing when you launch the program | +| enable | Enable cursor control | +| mouse_acceleration | Make the cursor move faster when the head moves quickly | +| use_transformation_matrix | Control cursor using head direction (tracking_vert_idxs will be ignored) | -## Keybinds configs +## Keybinding configs >[mouse_bindings.json](configs/default/mouse_bindings.json) >[keyboard_bindings.json](configs/default/keyboard_bindings.json) @@ -75,20 +86,30 @@ gesture_name: [device_name, action_name, threshold, trigger_type] ``` -| | | -|--------------|-------------------------------------------------------------------------------------------| -| gesture_name | Face expression name, see the [list](src/shape_list.py#L16) | -| device_name | "mouse" or "keyboard" | -| action_name | "left", "right" and "middle" for mouse. "" for keyboard, for instance, "w" for the W key. | -| threshold | The action trigger threshold has values ranging from 0.0 to 1.0. | -| trigger_type | Action trigger type, use "single" for a single trigger, "hold" for ongoing action. | +| | | +|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| gesture_name | Face expression name, see the [list](src/shape_list.py#L16) | +| device_name | "meta", "mouse", or "keyboard" | +| action_name | name of the action e.g. "left" for mouse.
e.g. "ctrl" for keyboard
e.g. "pause" for meta | +| threshold | The action trigger threshold has values ranging from 0.0 to 1.0. | +| trigger_type | "single" for a single trigger
"hold" for ongoing action.
"dynamic" for a mixture of single and hold. It first acts like single and after passing the amount of miliseconds from hold_trigger_ms like hold. Note: this is the default behaviour for mouse buttons
"toggle" to switch an action on and off
"rapid" trigger an action every "rapid_fire_interval_ms" | # Build + +## Pyinstaller / Frozen app ``` pyinstaller build.spec ``` +# Build Installer + +1. Install [inno6](https://jrsoftware.org/isdl.php#stable) +2. Build using the `installer.iss` file + +# Attribution +Blink graphics in the user interface are based on +[Eye icons created by Kiranshastry - Flaticon](https://www.flaticon.com/free-icons/eye). diff --git a/assets/images/dropdowns/Eye blink left.png b/assets/images/dropdowns/Eye blink left.png new file mode 100644 index 00000000..6a14e9f6 Binary files /dev/null and b/assets/images/dropdowns/Eye blink left.png differ diff --git a/assets/images/dropdowns/Eye blink left.xcf b/assets/images/dropdowns/Eye blink left.xcf new file mode 100644 index 00000000..7659b94c Binary files /dev/null and b/assets/images/dropdowns/Eye blink left.xcf differ diff --git a/assets/images/dropdowns/Eye blink right.png b/assets/images/dropdowns/Eye blink right.png new file mode 100644 index 00000000..9b39d56d Binary files /dev/null and b/assets/images/dropdowns/Eye blink right.png differ diff --git a/assets/images/dropdowns/Eye blink right.xcf b/assets/images/dropdowns/Eye blink right.xcf new file mode 100644 index 00000000..4ac9324d Binary files /dev/null and b/assets/images/dropdowns/Eye blink right.xcf differ diff --git a/assets/images/dropdowns/eye.xcf b/assets/images/dropdowns/eye.xcf new file mode 100644 index 00000000..a7d54838 Binary files /dev/null and b/assets/images/dropdowns/eye.xcf differ diff --git a/assets/images/menu_btn_about.png b/assets/images/menu_btn_about.png new file mode 100644 index 00000000..49b655fd Binary files /dev/null and b/assets/images/menu_btn_about.png differ diff --git a/assets/images/menu_btn_about.xcf b/assets/images/menu_btn_about.xcf new file mode 100644 index 00000000..ecd540ce Binary files /dev/null and b/assets/images/menu_btn_about.xcf differ diff --git a/assets/images/menu_btn_about_selected.png b/assets/images/menu_btn_about_selected.png new file mode 100644 index 00000000..d14f2b36 Binary files /dev/null and b/assets/images/menu_btn_about_selected.png differ diff --git a/assets/images/menu_btn_about_selected.xcf b/assets/images/menu_btn_about_selected.xcf new file mode 100644 index 00000000..94b1c2eb Binary files /dev/null and b/assets/images/menu_btn_about_selected.xcf differ diff --git a/assets/themes/google_theme.json b/assets/themes/google_theme.json index 6f6afb20..25fcd8c2 100644 --- a/assets/themes/google_theme.json +++ b/assets/themes/google_theme.json @@ -48,7 +48,7 @@ "corner_radius": 500, "border_width": 0, "button_length": 0, - "fg_Color": ["#444746", "#4A4D50"], + "fg_color": ["#444746", "#4A4D50"], "progress_color": ["#64DD17", "#1f538d"], "button_color": ["#8F8F8F", "#D5D9DE"], "button_hover_color": ["gray20", "gray100"], diff --git a/build-portable.spec b/build-portable.spec new file mode 100644 index 00000000..c72a6b51 --- /dev/null +++ b/build-portable.spec @@ -0,0 +1,57 @@ +# -*- mode: python ; coding: utf-8 -*- + +from pathlib import Path +import mediapipe +import customtkinter + +block_cipher = None + +mp_init = Path(mediapipe.__file__) +mp_modules = Path(mp_init.parent,"modules") + +ctk_init = Path(customtkinter.__file__) +ctk_modules = Path(ctk_init.parent,"modules") + + + +app = Analysis( + ['grimassist.py'], + pathex=[], + binaries=[], + datas=[(mp_modules.as_posix(), 'mediapipe/modules'), + ('assets','assets'), + ('configs','configs'), + (ctk_init.parent.as_posix(), 'customtkinter')], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) +pyz_app = PYZ(app.pure, app.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz_app, + app.scripts, + app.binaries, + app.zipfiles, + app.datas, + [], + name='grimassist', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon='assets/images/icon.ico', +) diff --git a/build.spec b/build.spec index f9e3c58d..95529eba 100644 --- a/build.spec +++ b/build.spec @@ -15,7 +15,7 @@ ctk_modules = Path(ctk_init.parent,"modules") app = Analysis( - ['run_app.py'], + ['grimassist.py'], pathex=[], binaries=[], datas=[(mp_modules.as_posix(), 'mediapipe/modules'), @@ -39,7 +39,7 @@ exe_app = EXE( app.scripts, [], exclude_binaries=True, - name='run_app', + name='grimassist', debug=False, bootloader_ignore_signals=False, strip=False, @@ -48,6 +48,7 @@ exe_app = EXE( disable_windowed_traceback=False, argv_emulation=False, target_arch=None, + icon='assets/images/icon.ico', codesign_identity=None, entitlements_file=None, ) @@ -61,5 +62,5 @@ coll = COLLECT( strip=False, upx=True, upx_exclude=[], - name='project_gameface', + name='grimassist', ) diff --git a/configs/default/cursor.json b/configs/default/cursor.json index e7ebe6ec..098890a3 100644 --- a/configs/default/cursor.json +++ b/configs/default/cursor.json @@ -12,8 +12,10 @@ "pointer_smooth": 6, "shape_smooth": 10, "tick_interval_ms": 16, - "hold_trigger_ms": 500, + "hold_trigger_ms": 500, + "rapid_fire_interval_ms": 100, "auto_play": false, + "enable": 1, "mouse_acceleration": false, "use_transformation_matrix": false } \ No newline at end of file diff --git a/configs/profile_1/cursor.json b/configs/profile_1/cursor.json index 2e9a023b..0a8bb740 100644 --- a/configs/profile_1/cursor.json +++ b/configs/profile_1/cursor.json @@ -5,15 +5,17 @@ "tracking_vert_idxs": [ 8 ], - "spd_up": 41, - "spd_down": 41, - "spd_left": 41, - "spd_right": 41, - "pointer_smooth": 15, - "shape_smooth": 10, - "tick_interval_ms": 16, - "hold_trigger_ms": 500, - "auto_play": false, - "mouse_acceleration": false, + "spd_up": 40, + "spd_down": 40, + "spd_left": 40, + "spd_right": 40, + "pointer_smooth": 6, + "shape_smooth": 10, + "tick_interval_ms": 16, + "hold_trigger_ms": 500, + "rapid_fire_interval_ms": 100, + "auto_play": false, + "enable": 1, + "mouse_acceleration": false, "use_transformation_matrix": false } \ No newline at end of file diff --git a/configs/profile_2/cursor.json b/configs/profile_2/cursor.json index 69bd284f..0a8bb740 100644 --- a/configs/profile_2/cursor.json +++ b/configs/profile_2/cursor.json @@ -5,15 +5,17 @@ "tracking_vert_idxs": [ 8 ], - "spd_up": 42, - "spd_down": 42, - "spd_left": 42, - "spd_right": 42, - "pointer_smooth": 15, - "shape_smooth": 10, - "tick_interval_ms": 16, - "hold_trigger_ms": 500, - "auto_play": false, - "mouse_acceleration": false, + "spd_up": 40, + "spd_down": 40, + "spd_left": 40, + "spd_right": 40, + "pointer_smooth": 6, + "shape_smooth": 10, + "tick_interval_ms": 16, + "hold_trigger_ms": 500, + "rapid_fire_interval_ms": 100, + "auto_play": false, + "enable": 1, + "mouse_acceleration": false, "use_transformation_matrix": false } \ No newline at end of file diff --git a/developer.md b/developer.md new file mode 100644 index 00000000..f038fd49 --- /dev/null +++ b/developer.md @@ -0,0 +1,104 @@ +# Developer getting started guide +Follow these instructions to get started as a developer for this project. + +1. Install Python 3.10 for Windows. + + You can download an installer from here for example. + [python.org/downloads/release/python-31011/](https://www.python.org/downloads/release/python-31011/) + + There isn't a mediapipe for 3.12 Python, which is the latest at time of + writing. + +2. Check the Python version is installed, on the path, and so on. + + If you run this command you should get this output. + + > python --version + Python 3.10.11 + + If you don't then restart Powershell or VSCode or VSCodium, or the whole + machine. Or check the PATH environment variable. + +3. Create a Python virtual environment (venv). + + Python venv is now the best practice for programs that require PIP modules. + + Run commands like these to create the venv in a sub-directory also named + `venv`. The repository is already configured to ignore that sub-directory. + + cd /path/where/you/cloned/FaceCommander + python -m venv venv + + Going forwards, you will run Python like this. + `.\venv\Scripts\python.exe `... other command line options ... + +4. Install the required PIP modules. + + Run commands like these to update PIP and then install the required modules. + + cd /path/where/you/cloned/FaceCommander + .\venv\Scripts\python.exe -m pip install --upgrade pip + .\venv\Scripts\python.exe -m pip install -r .\requirements.txt + +5. Run the program. + + Run commands like this. + + cd /path/where/you/cloned/FaceCommander + .\venv\Scripts\python.exe grimassist.py + +The program should start. Its print logging should appear in the terminal or +Powershell session. + +# Tips for Git on Windows +Git for Windows can be installed with winget as described here. +[git-scm.com/download/win](https://git-scm.com/download/win) + +You can activate OpenSSH in Windows 10 as described here. +[stackoverflow.com/a/40720527/7657675](https://stackoverflow.com/a/40720527/7657675) + +You can then set up a private key for GitHub authentication and configure SSH in +the usual way, by creating a `.ssh` sub-directory under your `users` directory, +for example `C:\Users\Jim\.ssh`. For example, you could create a `config` file +there with these settings. + + Host github.com + IdentityFile ~/.ssh/ + User + + Host * + AddKeysToAgent yes + +That will cause the SSH identity you use for GitHub to be loaded in the agent so +you don't have to retype the private key passcode every time. + +You can discover the OpenSSH executable path by running a Powershell command +like this. + + gcm ssh + +The output could be like this (spaces have been compressed). + + CommandType Name Version Source + ----------- ---- ------- ------ + Application ssh-add.exe 8.1.0.1 C:\Windows\System32\OpenSSH\ssh.exe + +You can configure Git to use OpenSSH in the current Powershell session by +setting an environment variable, like this. + + $env:GIT_SSH = "C:\Windows\System32\OpenSSH\ssh.exe" + +You can configure Git to use OpenSSH in all your future Powershell sessions by +configuring a permanent environment variable, like this. + + [Environment]::SetEnvironmentVariable( + "GIT_SSH", "C:\Windows\System32\OpenSSH\ssh.exe", "User") + +TOTH [stackoverflow.com/a/55389713/7657675](https://stackoverflow.com/a/55389713/7657675) +Looks like you have to exit the Powershell session in which you ran that +command for it to take effect. + +You can check the value has been set by printing all environment variables. To +do that run a command like this. + + get-childitem env: diff --git a/run_app.py b/grimassist.py similarity index 53% rename from run_app.py rename to grimassist.py index 93637bca..1bdf3f31 100644 --- a/run_app.py +++ b/grimassist.py @@ -1,37 +1,29 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import logging import sys - +import os import customtkinter -import src.gui as gui +from src.gui import MainGui from src.pipeline import Pipeline from src.task_killer import TaskKiller FORMAT = "%(asctime)s %(levelname)s %(name)s: %(funcName)s: %(message)s" -logging.basicConfig(format=FORMAT, - level=logging.INFO, - handlers=[ - logging.FileHandler("log.txt", mode='w'), - logging.StreamHandler(sys.stdout) - ]) +log_path = os.environ["USERPROFILE"] + "\Grimassist" +if not os.path.isdir(log_path): + os.mkdir(log_path) + +logging.basicConfig( + format=FORMAT, + level=logging.INFO, + handlers=[ + logging.FileHandler(log_path + "\log.txt", mode="w"), + logging.StreamHandler(sys.stdout), + ], +) -class MainApp(gui.MainGui, Pipeline): +class MainApp(MainGui, Pipeline): def __init__(self, tk_root): super().__init__(tk_root) # Wait for window drawing. @@ -54,7 +46,7 @@ def anim_loop(self): def close_all(self): logging.info("Close all") self.is_active = False - # Completely clost this process + # Completely close this process TaskKiller().exit() diff --git a/here.code-workspace b/here.code-workspace new file mode 100644 index 00000000..d9daf06e --- /dev/null +++ b/here.code-workspace @@ -0,0 +1,30 @@ +// This is a workspace declaration to facilitate use of VSCode and VSCodium to +// edit the contents of this directory. +// See https://code.visualstudio.com/docs/editor/multi-root-workspaces +{ + "folders": [ + { "path": ".", "name": "Face Commander"} + ], + "settings": { + // Variable names are documented in the VSCod[e|ium] Settings. + // Search for "window title". + "window.title": "${activeEditorShort}${separator}${folderName}" + }, + "extensions": { + "recommendations": [ + // Plugin to open diagrams.net aka draw.io diagrams in VSCod[e|ium]. + "hediet.vscode-drawio", + + // Word wrapping for Markdown and comments. + "stkb.rewrap", + + // Plugin to open PDF files in VSCod[e|ium]. + "tomoki1207.pdf", + + // Plugin to count words in the file or in the selection. + "witulski.selection-word-count" + ] + } +} +// (c) 2024 The ACE Centre-North, UK registered charity 1089313. +// MIT licensed, see https://opensource.org/licenses/MIT \ No newline at end of file diff --git a/installer.iss b/installer.iss new file mode 100644 index 00000000..06d2749f --- /dev/null +++ b/installer.iss @@ -0,0 +1,46 @@ +; Script generated by the Inno Setup Script Wizard. +; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! + +#define MyAppName "Grimassist" +#define MyAppVersion "0.5.1" +#define MyAppExeName "grimassist.exe" + +[Setup] +; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. +; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) +AppId={{B266B614-4113-4DB7-9A30-4250FECA5009} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +;AppVerName={#MyAppName} {#MyAppVersion} +DefaultDirName={autopf}\{#MyAppName} +DisableProgramGroupPage=yes +; Uncomment the following line to run in non administrative install mode (install for current user only.) +;PrivilegesRequired=lowest +OutputBaseFilename=Grimassist-Installer-v{#MyAppVersion} +SetupIconFile=assets\images\icon.ico +Compression=lzma +SolidCompression=yes +WizardStyle=modern + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; +Name: "autostarticon"; Description: "{cm:AutoStartProgram,{#MyAppName}}"; GroupDescription: "{cm:AdditionalIcons}"; + + +[Files] +Source: "dist\grimassist\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion +Source: "dist\grimassist\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +; NOTE: Don't use "Flags: ignoreversion" on any shared system files + +[Icons] +Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon +Name: "{userstartup}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Parameters: "/auto"; Tasks: autostarticon +; Name: "{commonstartup}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Parameters: "/auto"; Tasks: autostarticon + +[Run] +Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Verb: runas; Flags: postinstall skipifsilent shellexec runascurrentuser waituntilterminated; + diff --git a/requirements.txt b/requirements.txt index aba282b7..4a97dcda 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,13 @@ flatbuffers==2.0.0 matplotlib==3.7.1 -opencv-contrib-python==4.7.0.72 +opencv-contrib-python==4.8.1.78 psutil==5.9.4 pyautogui==0.9.53 -customtkinter==5.1.2 -PyDirectInput==1.0.4 -pywin32==306 -mediapipe==0.9.3.0 \ No newline at end of file +customtkinter==5.2.0 +mediapipe==0.9.3.0 +PyDirectInput==1.0.4; sys_platform == 'win32' +pywin32==306; sys_platform == 'win32' +pygrabber; sys_platform == 'win32' +pyinstaller==5.11.0 +Pillow==10.1.0 +numpy==1.26.2 \ No newline at end of file diff --git a/src/accel_graph.py b/src/accel_graph.py index c60748a4..90e10fab 100644 --- a/src/accel_graph.py +++ b/src/accel_graph.py @@ -1,23 +1,8 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import abc import math class AccelGraph(metaclass=abc.ABCMeta): - def __init__(self): pass @@ -27,8 +12,8 @@ def __call__(self, x: float) -> float: class SigmoidAccel(AccelGraph): - def __init__(self, shift_x=5, slope=0.3, multiply=1.2): + super().__init__() self.shift_x = shift_x self.slope = slope self.multiply = multiply diff --git a/src/camera_manager.py b/src/camera_manager.py index cdf3fcc4..170555b3 100644 --- a/src/camera_manager.py +++ b/src/camera_manager.py @@ -1,17 +1,3 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import concurrent.futures as futures import logging import threading @@ -25,7 +11,7 @@ import src.utils as utils from src.config_manager import ConfigManager -from src.controllers import MouseController +from src.controllers import Keybinder from src.singleton_meta import Singleton MAX_SEARCH_CAMS = 5 @@ -34,41 +20,41 @@ def add_overlay(background, overlay, x, y, width, height): - - background_section = background[y:y + height, x:x + width] - overlay_section = overlay[y:y + height, x:x + width] - background[y:y + height, - x:x + width] = cv2.addWeighted(background_section, 0.3, - overlay_section, 0.7, 0) + background_section = background[y : y + height, x : x + width] + overlay_section = overlay[y : y + height, x : x + width] + background[y : y + height, x : x + width] = cv2.addWeighted( + background_section, 0.3, overlay_section, 0.7, 0 + ) return background class CameraManager(metaclass=Singleton): - def __init__(self): - logger.info("Intialize CameraManager singleton") + logger.info("Initialize CameraManager singleton") self.thread_cameras = None # Load placeholder image self.placeholder_im = Image.open("assets/images/placeholder.png") - self.placeholder_im = np.array(self.placeholder_im.convert('RGB')) + self.placeholder_im = np.array(self.placeholder_im.convert("RGB")) # Overlays self.overlay_active = cv2.cvtColor( - cv2.imread("assets/images/overlays/active.png", - cv2.IMREAD_UNCHANGED), cv2.COLOR_BGRA2RGB) + cv2.imread("assets/images/overlays/active.png", cv2.IMREAD_UNCHANGED), + cv2.COLOR_BGRA2RGB, + ) self.overlay_disabled = cv2.cvtColor( - cv2.imread("assets/images/overlays/disabled.png", - cv2.IMREAD_UNCHANGED), cv2.COLOR_BGRA2RGB) + cv2.imread("assets/images/overlays/disabled.png", cv2.IMREAD_UNCHANGED), + cv2.COLOR_BGRA2RGB, + ) self.overlay_face_not_detected = cv2.cvtColor( - cv2.imread("assets/images/overlays/face_not_detected.png", - cv2.IMREAD_UNCHANGED), cv2.COLOR_BGRA2RGB) + cv2.imread( + "assets/images/overlays/face_not_detected.png", cv2.IMREAD_UNCHANGED + ), + cv2.COLOR_BGRA2RGB, + ) # Use dict for pass as reference - self.frame_buffers = { - "raw": self.placeholder_im, - "debug": self.placeholder_im - } + self.frame_buffers = {"raw": self.placeholder_im, "debug": self.placeholder_im} self.is_active = False self.is_destroyed = False @@ -81,17 +67,17 @@ def start(self): def get_camera_list(self) -> list[int]: if not self.is_active: return [] - ret_caps = list(self.thread_cameras.caps.keys()) + cameras = list(self.thread_cameras.cameras.keys()) - return sorted(ret_caps) + return sorted(cameras) - def get_current_camera_id(self) -> int: + def get_current_camera_id(self) -> int | None: if not self.is_active: return None else: - return self.thread_cameras.curr_id + return self.thread_cameras.current_id - def pick_camera(self, camera_id: int) -> None: + def pick_camera(self, camera_id: int): logger.info(f"Swapping to camera id: {camera_id}") # Assign to ref self.frame_buffers["raw"] = self.placeholder_im @@ -104,7 +90,7 @@ def get_raw_frame(self): def get_debug_frame(self): return self.frame_buffers["debug"] - def put_debug_frame(self, frame_debug: npt.ArrayLike) -> None: + def put_debug_frame(self, frame_debug: npt.ArrayLike): self.frame_buffers["debug"] = frame_debug def leave(self): @@ -116,24 +102,29 @@ def destroy(self): if self.thread_cameras is not None: self.thread_cameras.destroy() - def draw_overlay(self, track_loc): + def draw_overlay(self, tracking_location): if not self.is_active: return self.frame_buffers["debug"] = self.frame_buffers["raw"].copy() # Disabled - if not MouseController().is_active.get(): + if not Keybinder().is_active.get(): self.frame_buffers["debug"] = add_overlay( - self.frame_buffers["debug"], self.overlay_disabled, 0, 0, 640, - 108) + self.frame_buffers["debug"], self.overlay_disabled, 0, 0, 640, 108 + ) return # Face not detected - if (track_loc is None): + if tracking_location is None: self.frame_buffers["debug"] = add_overlay( - self.frame_buffers["debug"], self.overlay_face_not_detected, 0, - 0, 640, 108) + self.frame_buffers["debug"], + self.overlay_face_not_detected, + 0, + 0, + 640, + 108, + ) return @@ -142,17 +133,30 @@ def draw_overlay(self, track_loc): if ConfigManager().config["use_transformation_matrix"]: cx = ConfigManager().config["fix_width"] // 2 cy = ConfigManager().config["fix_height"] // 2 - cv2.line(self.frame_buffers["debug"], (cx, cy), - (int(track_loc[0]), int(track_loc[1])), (0, 255, 0), 3) - - cv2.circle(self.frame_buffers["debug"], - (int(track_loc[0]), int(track_loc[1])), 6, (255, 0, 0), - -1) + cv2.line( + self.frame_buffers["debug"], + (cx, cy), + (int(tracking_location[0]), int(tracking_location[1])), + (0, 255, 0), + 3, + ) + + cv2.circle( + self.frame_buffers["debug"], + (int(tracking_location[0]), int(tracking_location[1])), + 6, + (255, 0, 0), + -1, + ) else: - cv2.circle(self.frame_buffers["debug"], - (int(track_loc[0]), int(track_loc[1])), 4, - (255, 255, 255), -1) + cv2.circle( + self.frame_buffers["debug"], + (int(tracking_location[0]), int(tracking_location[1])), + 4, + (255, 255, 255), + -1, + ) # ---------------------------------------------------------------------------- # @@ -160,10 +164,9 @@ def draw_overlay(self, track_loc): # ---------------------------------------------------------------------------- # -class ThreadCameras(): - +class ThreadCameras: def __init__(self, frame_buffers: dict): - logger.info("Intializing Threadcamera") + logger.info("Initializing ThreadCamera") self.lock = threading.Lock() self.pool = futures.ThreadPoolExecutor(max_workers=8) self.stop_flag = threading.Event() @@ -171,37 +174,37 @@ def __init__(self, frame_buffers: dict): self.frame_buffers = frame_buffers # Open all cameras - self.caps = {} + self.cameras = {} - self.assign_exe = Thread(target=utils.assign_caps_queue, - args=(self.caps, self.assign_done, - MAX_SEARCH_CAMS), - daemon=True) + self.assign_exe = Thread( + target=utils.assign_cameras_queue, + args=(self.cameras, self.assign_done, MAX_SEARCH_CAMS), + daemon=True, + ) self.assign_exe.start() # Decide what camera to use - self.curr_id = None #ConfigManager().config["camera_id"] - logger.info(f"Found default camera_id {self.curr_id}") + self.current_id = None # ConfigManager().config["camera_id"] + logger.info(f"Found default camera_id {self.current_id}") - self.loop_exe = Thread(target=self.read_camera_loop, - args=(self.stop_flag,), - daemon=True) + self.loop_exe = Thread( + target=self.read_camera_loop, args=(self.stop_flag,), daemon=True + ) self.loop_exe.start() def assign_done(self): - """Set default camera after assign_caps is done - """ - logger.info(f"Assign cameras completed. Found {self.caps}") + """Set default camera after assign_cameras is done""" + logger.info(f"Assign cameras completed. Found {self.cameras}") init_id = ConfigManager().config["camera_id"] # pick first camera available if camera in config not found - if init_id not in self.caps: - self.curr_id = list(self.caps.keys())[0] + if init_id not in self.cameras: + self.current_id = list(self.cameras.keys())[0] else: - self.curr_id = init_id + self.current_id = init_id - logger.info(f"Try to use camera {self.curr_id}") - self.pick_camera(self.curr_id) + logger.info(f"Try to use camera {self.current_id}") + self.pick_camera(self.current_id) self.assign_done_flag.set() def pick_camera(self, new_id: int) -> None: @@ -213,34 +216,34 @@ def pick_camera(self, new_id: int) -> None: logger.info(f"Pick camera {new_id}, Releasing others...") - if new_id not in self.caps: + if new_id not in self.cameras: logger.error(f"Camera {new_id} not found") return - for cam_id, _ in self.caps.items(): + for cam_id, _ in self.cameras.items(): if cam_id == new_id: - if self.caps[new_id] is not None: + if self.cameras[new_id] is not None: continue - utils.open_camera(self.caps, cam_id) + utils.open_camera(self.cameras, cam_id) else: - if self.caps[cam_id] is not None: - self.caps[cam_id].release() - self.caps[cam_id] = None + if self.cameras[cam_id] is not None: + self.cameras[cam_id].release() + self.cameras[cam_id] = None - self.curr_id = new_id + self.current_id = new_id def release_all_cameras(self): - if self.caps is not None: - for cam_id, _ in self.caps.items(): - if self.caps[cam_id] is not None: - self.caps[cam_id].release() - self.caps[cam_id] = None + if self.cameras is not None: + for cam_id, _ in self.cameras.items(): + if self.cameras[cam_id] is not None: + self.cameras[cam_id].release() + self.cameras[cam_id] = None def read_camera_loop(self, stop_flag) -> None: - logger.info("Threadcamera main_loop started.") + logger.info("ThreadCamera main_loop started.") while not stop_flag.is_set(): - if self.curr_id is None: + if self.current_id is None: time.sleep(1) continue @@ -248,9 +251,10 @@ def read_camera_loop(self, stop_flag) -> None: time.sleep(1) continue - if (self.curr_id in self.caps) and (self.caps[self.curr_id] - is not None): - ret, frame = self.caps[self.curr_id].read() + if (self.current_id in self.cameras) and ( + self.cameras[self.current_id] is not None + ): + ret, frame = self.cameras[self.current_id].read() cv2.waitKey(1) if not ret: logger.error("No frame returned") @@ -264,17 +268,23 @@ def read_camera_loop(self, stop_flag) -> None: h, w, _ = frame.shape # Trim image - if h != ConfigManager().config["fix_height"] or w != ConfigManager( - ).config["fix_width"]: + if ( + h != ConfigManager().config["fix_height"] + or w != ConfigManager().config["fix_width"] + ): target_width = int(h * 4 / 3) if w > target_width: trim_width = w - target_width trim_left = trim_width // 2 trim_right = trim_width - trim_left frame = frame[:, trim_left:-trim_right, :] - frame = cv2.resize(frame, - (ConfigManager().config["fix_width"], - ConfigManager().config["fix_height"])) + frame = cv2.resize( + frame, + ( + ConfigManager().config["fix_width"], + ConfigManager().config["fix_height"], + ), + ) frame = cv2.flip(frame, 1) self.frame_buffers["raw"] = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) @@ -284,7 +294,7 @@ def leave(self): pass def destroy(self): - logger.info("Destroying Threadcamera") + logger.info("Destroying ThreadCamera") self.stop_flag.set() self.assign_exe.join() self.loop_exe.join() @@ -292,6 +302,6 @@ def destroy(self): # Release all cameras self.release_all_cameras() self.frame_buffers = None - self.caps = None + self.cameras = None - logger.info("Threadcamera destroyed") + logger.info("ThreadCamera destroyed") diff --git a/src/config_manager.py b/src/config_manager.py index cbfd5a84..e8004537 100644 --- a/src/config_manager.py +++ b/src/config_manager.py @@ -1,40 +1,51 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import copy import json import logging import shutil import time import tkinter as tk +import os from pathlib import Path from src.singleton_meta import Singleton from src.task_killer import TaskKiller +from src.utils.Trigger import Trigger -VERSION = "0.3.33" +VERSION = "0.5.1" -DEFAULT_JSON = Path("configs/default.json") -BACKUP_PROFILE = Path("configs/default") +DEFAULT_JSON = Path(f"C:/Users/{os.getlogin()}/Grimassist/configs/default.json") +BACKUP_PROFILE = Path(f"C:/Users/{os.getlogin()}/Grimassist/configs/default") logger = logging.getLogger("ConfigManager") +config_dir = f"C:/Users/{os.getlogin()}/Grimassist/configs/" +default_dir = os.path.join(config_dir, "default") -class ConfigManager(metaclass=Singleton): +# Create the main config directory if it doesn't exist +if not os.path.isdir(config_dir): + os.makedirs(config_dir, exist_ok=True) + shutil.copytree("configs", config_dir, dirs_exist_ok=True) + +# Create the default directory inside the config directory if it doesn't exist +if not os.path.isdir(default_dir): + os.mkdir(default_dir) + +if not os.path.isdir(f"C:/Users/{os.getlogin()}/Grimassist/configs/"): + shutil.copytree("configs", f"C:/Users/{os.getlogin()}/Grimassist/configs/") + os.mkdir(f"C:/Users/{os.getlogin()}/Grimassist/configs/") +if not os.path.isdir(f"C:/Users/{os.getlogin()}/Grimassist/configs/default"): + os.mkdir(f"C:/Users/{os.getlogin()}/Grimassist/configs/default") + + +class ConfigManager(metaclass=Singleton): def __init__(self): - logger.info("Intialize ConfigManager singleton") + self.temp_keyboard_bindings = None + self.temp_mouse_bindings = None + self.temp_config = None + self.keyboard_bindings = None + self.mouse_bindings = None + logger.info("Initialize ConfigManager singleton") self.version = VERSION self.unsave_configs = False self.unsave_mouse_bindings = False @@ -42,8 +53,8 @@ def __init__(self): self.config = None # Load config - self.curr_profile_path = None - self.curr_profile_name = tk.StringVar() + self.current_profile_path = None + self.current_profile_name = tk.StringVar() self.is_started = False self.profiles = self.list_profile() @@ -84,24 +95,23 @@ def add_profile(self): # Random name base on local timestamp new_profile_name = "profile_z" + str(hex(int(time.time() * 1000)))[2:] logger.info(f"Add profile {new_profile_name}") - shutil.copytree(BACKUP_PROFILE, - Path(DEFAULT_JSON.parent, new_profile_name)) + shutil.copytree(BACKUP_PROFILE, Path(DEFAULT_JSON.parent, new_profile_name)) self.profiles.append(new_profile_name) logger.info(f"Current profiles: {self.profiles}") def rename_profile(self, old_profile_name, new_profile_name): logger.info(f"Rename profile {old_profile_name} to {new_profile_name}") - shutil.move(Path(DEFAULT_JSON.parent, old_profile_name), - Path(DEFAULT_JSON.parent, new_profile_name)) + shutil.move( + Path(DEFAULT_JSON.parent, old_profile_name), + Path(DEFAULT_JSON.parent, new_profile_name), + ) self.profiles.remove(old_profile_name) self.profiles.append(new_profile_name) - if self.curr_profile_name.get() == old_profile_name: - self.curr_profile_name.set(new_profile_name) + if self.current_profile_name.get() == old_profile_name: + self.current_profile_name.set(new_profile_name) - - - def load_profile(self, profile_name: str) -> list[bool, Path]: + def load_profile(self, profile_name: str): profile_path = Path(DEFAULT_JSON.parent, profile_name) logger.info(f"Loading profile: {profile_path}") @@ -109,9 +119,11 @@ def load_profile(self, profile_name: str) -> list[bool, Path]: mouse_bindings_file = Path(profile_path, "mouse_bindings.json") keyboard_bindings_file = Path(profile_path, "keyboard_bindings.json") - if (not cursor_config_file.is_file()) or ( - not mouse_bindings_file.is_file()) or ( - not keyboard_bindings_file.is_file()): + if ( + (not cursor_config_file.is_file()) + or (not mouse_bindings_file.is_file()) + or (not keyboard_bindings_file.is_file()) + ): logger.critical( f"{profile_path.as_posix()} Invalid configuration files or missing files, exit program..." ) @@ -133,8 +145,8 @@ def load_profile(self, profile_name: str) -> list[bool, Path]: self.temp_mouse_bindings = copy.deepcopy(self.mouse_bindings) self.temp_keyboard_bindings = copy.deepcopy(self.keyboard_bindings) - self.curr_profile_path = profile_path - self.curr_profile_name.set(profile_name) + self.current_profile_path = profile_path + self.current_profile_name.set(profile_name) def switch_profile(self, profile_name: str): logger.info(f"Switching to profile: {profile_name}") @@ -150,10 +162,10 @@ def set_temp_config(self, field: str, value): self.unsave_configs = True def write_config_file(self): - cursor_config_file = Path(self.curr_profile_path, "cursor.json") + cursor_config_file = Path(self.current_profile_path, "cursor.json") logger.info(f"Writing config file {cursor_config_file}") - with open(cursor_config_file, 'w') as f: - json.dump(self.config, f, indent=4, separators=(', ', ': ')) + with open(cursor_config_file, "w") as f: + json.dump(self.config, f, indent=4, separators=(", ", ": ")) def apply_config(self): logger.info("Applying config") @@ -163,88 +175,104 @@ def apply_config(self): # ------------------------------ MOUSE BINDINGS CONFIG ----------------------------- # - def set_temp_mouse_binding(self, gesture, device: str, action: str, - threshold: float, trigger_type: str): - + def set_temp_mouse_binding( + self, gesture, device: str, action: str, threshold: float, trigger: Trigger + ): logger.info( - "setting keybind for gesture: %s, device: %s, key: %s, threshold: %s, trigger_type: %s", - gesture, device, action, threshold, trigger_type) + "setting keybind for gesture: %s, device: %s, key: %s, threshold: %s, trigger: %s", + gesture, + device, + action, + threshold, + trigger.value, + ) - # Remove duplicate keybinds + # Remove duplicate keybindings self.remove_temp_mouse_binding(device, action) # Assign self.temp_mouse_bindings[gesture] = [ - device, action, float(threshold), trigger_type + device, + action, + float(threshold), + trigger.value, ] self.unsave_mouse_bindings = True def remove_temp_mouse_binding(self, device: str, action: str): - logger.info( - f"remove_temp_mouse_binding for device: {device}, key: {action}") - out_keybinds = {} + logger.info(f"remove_temp_mouse_binding for device: {device}, key: {action}") + out_keybindings = {} for key, vals in self.temp_mouse_bindings.items(): if (device == vals[0]) and (action == vals[1]): continue - out_keybinds[key] = vals - self.temp_mouse_bindings = out_keybinds + out_keybindings[key] = vals + self.temp_mouse_bindings = out_keybindings self.unsave_mouse_bindings = True def apply_mouse_bindings(self): - logger.info("Applying keybinds") + logger.info("Applying keybindings") self.mouse_bindings = copy.deepcopy(self.temp_mouse_bindings) self.write_mouse_bindings_file() self.unsave_mouse_bindings = False def write_mouse_bindings_file(self): - mouse_bindings_file = Path(self.curr_profile_path, - "mouse_bindings.json") - logger.info(f"Writing keybinds file {mouse_bindings_file}") + mouse_bindings_file = Path(self.current_profile_path, "mouse_bindings.json") + logger.info(f"Writing keybindings file {mouse_bindings_file}") - with open(mouse_bindings_file, 'w') as f: + with open(mouse_bindings_file, "w") as f: out_json = dict(sorted(self.mouse_bindings.items())) - json.dump(out_json, f, indent=4, separators=(', ', ': ')) + json.dump(out_json, f, indent=4, separators=(", ", ": ")) # ------------------------------ KEYBOARD BINDINGS CONFIG ----------------------------- # - def set_temp_keyboard_binding(self, device: str, key_action: str, - gesture: str, threshold: float, - trigger_type: str): + def set_temp_keyboard_binding( + self, + device: str, + key_action: str, + gesture: str, + threshold: float, + trigger: Trigger, + ): logger.info( - "setting keybind for gesture: %s, device: %s, key: %s, threshold: %s, trigger_type: %s", - gesture, device, key_action, threshold, trigger_type) + "setting keybind for gesture: %s, device: %s, key: %s, threshold: %s, trigger: %s", + gesture, + device, + key_action, + threshold, + trigger.value, + ) - # Remove duplicate keybinds + # Remove duplicate keybindings self.remove_temp_keyboard_binding(device, key_action, gesture) # Assign self.temp_keyboard_bindings[gesture] = [ - device, key_action, - float(threshold), trigger_type + device, + key_action, + float(threshold), + trigger.value, ] self.unsave_keyboard_bindings = True - def remove_temp_keyboard_binding(self, - device: str, - key_action: str = "None", - gesture: str = "None"): - """Remove binding from config by providing either key_action or gesture. - """ + def remove_temp_keyboard_binding( + self, device: str, key_action: str = "None", gesture: str = "None" + ): + """Remove binding from config by providing either key_action or gesture.""" logger.info( f"remove_temp_keyboard_binding for device: {device}, key: {key_action} or gesture {gesture}" ) - out_keybinds = {} + out_keybindings = {} for ges, vals in self.temp_keyboard_bindings.items(): - if (gesture == ges): + if gesture == ges: continue - if (key_action == vals[1]): + if key_action == vals[1]: continue - out_keybinds[ges] = vals + out_keybindings[ges] = vals - self.temp_keyboard_bindings = out_keybinds + self.temp_keyboard_bindings = out_keybindings self.unsave_keyboard_bindings = True return @@ -257,13 +285,14 @@ def apply_keyboard_bindings(self): self.unsave_keyboard_bindings = False def write_keyboard_bindings_file(self): - keyboard_bindings_file = Path(self.curr_profile_path, - "keyboard_bindings.json") + keyboard_bindings_file = Path( + self.current_profile_path, "keyboard_bindings.json" + ) logger.info(f"Writing keyboard bindings file {keyboard_bindings_file}") - with open(keyboard_bindings_file, 'w') as f: + with open(keyboard_bindings_file, "w") as f: out_json = dict(sorted(self.keyboard_bindings.items())) - json.dump(out_json, f, indent=4, separators=(', ', ': ')) + json.dump(out_json, f, indent=4, separators=(", ", ": ")) # ---------------------------------------------------------------------------- # def apply_all(self): @@ -272,4 +301,4 @@ def apply_all(self): self.apply_keyboard_bindings() def destroy(self): - logger.info("Destory") + logger.info("Destroy") diff --git a/src/controllers/__init__.py b/src/controllers/__init__.py index 4f5d8c10..e9725379 100644 --- a/src/controllers/__init__.py +++ b/src/controllers/__init__.py @@ -1,16 +1,3 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from .keybinder import * -from .mouse_controller import * +__all__ = ["Keybinder", "MouseController"] +from .keybinder import Keybinder +from .mouse_controller import MouseController diff --git a/src/controllers/keybinder.py b/src/controllers/keybinder.py index e3a6c38d..71e5c93f 100644 --- a/src/controllers/keybinder.py +++ b/src/controllers/keybinder.py @@ -1,17 +1,3 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import copy import logging import math @@ -19,11 +5,12 @@ import pydirectinput import win32api +import tkinter as tk import src.shape_list as shape_list from src.config_manager import ConfigManager -from src.controllers.mouse_controller import MouseController from src.singleton_meta import Singleton +from src.utils.Trigger import Trigger logger = logging.getLogger("Keybinder") @@ -33,15 +20,21 @@ class Keybinder(metaclass=Singleton): - def __init__(self) -> None: - logger.info("Intialize Keybinder singleton") + self.delay_count = None + self.key_states = None + self.schedule_toggle_off = {} + self.schedule_toggle_on = {} + self.monitors = None + self.screen_h = None + self.screen_w = None + logger.info("Initialize Keybinder singleton") self.top_count = 0 - self.triggered = False - self.start_hold_ts = math.inf - self.holding = False + self.start_hold_ts = {} + self.holding = {} self.is_started = False - self.last_know_keybinds = {} + self.last_know_keybindings = {} + self.is_active = None def start(self): if not self.is_started: @@ -51,19 +44,29 @@ def start(self): self.monitors = self.get_monitors() self.is_started = True + self.is_active = tk.BooleanVar() + self.is_active.set(ConfigManager().config["auto_play"]) + def init_states(self) -> None: - """Re initializes the state of the keybinder. - If new keybinds are added. + """Re-initializes the state of the keybinder. + If new keybindings are added. """ # keep states for all registered keys. self.key_states = {} - for _, v in (ConfigManager().mouse_bindings | - ConfigManager().keyboard_bindings).items(): - self.key_states[v[0] + "_" + v[1]] = False - self.key_states["holding"] = False - self.last_know_keybinds = copy.deepcopy( - (ConfigManager().mouse_bindings | - ConfigManager().keyboard_bindings)) + self.start_hold_ts = {} + for _, v in ( + ConfigManager().mouse_bindings | ConfigManager().keyboard_bindings + ).items(): + state_name = v[0] + "_" + v[1] + self.key_states[state_name] = False + self.schedule_toggle_off[state_name] = False + self.schedule_toggle_on[state_name] = True + self.start_hold_ts[state_name] = math.inf + self.holding[state_name] = False + + self.last_know_keybindings = copy.deepcopy( + (ConfigManager().mouse_bindings | ConfigManager().keyboard_bindings) + ) def get_monitors(self) -> list[dict]: out_list = [] @@ -81,68 +84,221 @@ def get_monitors(self) -> list[dict]: return out_list - def get_curr_monitor(self): - + def get_current_monitor(self) -> int: x, y = pydirectinput.position() for mon_id, mon in enumerate(self.monitors): - if x >= mon["x1"] and x <= mon["x2"] and y >= mon[ - "y1"] and y <= mon["y2"]: + if x >= mon["x1"] and x <= mon["x2"] and y >= mon["y1"] and y <= mon["y2"]: return mon_id - #raise Exception("Monitor not found") + # raise Exception("Monitor not found") return 0 - def mouse_action(self, val, action, thres, mode) -> None: - state_name = "mouse_" + action + def meta_action(self, val, action, threshold, is_active: bool) -> None: + state_name = "meta_" + action - mode = "hold" if self.key_states["holding"] else "single" + if action == "pause": + if (val > threshold) and (self.key_states[state_name] is False): + mon_id = self.get_current_monitor() + if mon_id is None: + return - if mode == "hold": - if (val > thres) and (self.key_states[state_name] is False): - pydirectinput.mouseDown(action) + self.toggle_active() self.key_states[state_name] = True - - elif (val < thres) and (self.key_states[state_name] is True): - pydirectinput.mouseUp(action) + elif (val < threshold) and (self.key_states[state_name] is True): self.key_states[state_name] = False - elif mode == "single": - if (val > thres): - if not self.key_states[state_name]: + if is_active: + if action == "reset": + if (val > threshold) and (self.key_states[state_name] is False): + mon_id = self.get_current_monitor() + if mon_id is None: + return + + pydirectinput.moveTo( + self.monitors[mon_id]["center_x"], + self.monitors[mon_id]["center_y"], + ) + self.key_states[state_name] = True + elif (val < threshold) and (self.key_states[state_name] is True): + self.key_states[state_name] = False + + elif action == "cycle": + if (val > threshold) and (self.key_states[state_name] is False): + mon_id = self.get_current_monitor() + next_mon_id = (mon_id + 1) % len(self.monitors) + pydirectinput.moveTo( + self.monitors[next_mon_id]["center_x"], + self.monitors[next_mon_id]["center_y"], + ) + self.key_states[state_name] = True + elif (val < threshold) and (self.key_states[state_name] is True): + self.key_states[state_name] = False + + def mouse_action(self, val, action, threshold, mode) -> None: + state_name = "mouse_" + action + + if mode == Trigger.SINGLE: + if val > threshold: + if self.key_states[state_name] is False: pydirectinput.click(button=action) - self.start_hold_ts = time.time() + self.key_states[state_name] = True + if val < threshold: + self.key_states[state_name] = False + elif mode == Trigger.HOLD: + if (val > threshold) and (self.key_states[state_name] is False): + pydirectinput.mouseDown(button=action) self.key_states[state_name] = True - if not self.holding and ( - ((time.time() - self.start_hold_ts) * 1000) >= - ConfigManager().config["hold_trigger_ms"]): + elif (val < threshold) and (self.key_states[state_name] is True): + pydirectinput.mouseUp(button=action) + self.key_states[state_name] = False - pydirectinput.mouseDown(button=action) - self.holding = True + elif mode == Trigger.DYNAMIC: + if val > threshold: + if self.key_states[state_name] is False: + pydirectinput.click(button=action) + self.start_hold_ts[state_name] = time.time() + self.key_states[state_name] = True - elif (val < thres) and (self.key_states[state_name] is True): + if self.holding[state_name] is False and ( + ((time.time() - self.start_hold_ts[state_name]) * 1000) + >= ConfigManager().config["hold_trigger_ms"] + ): + pydirectinput.mouseDown(button=action) + self.holding[state_name] = True + elif (val < threshold) and (self.key_states[state_name] is True): self.key_states[state_name] = False - if self.holding: + if self.holding[state_name]: pydirectinput.mouseUp(button=action) - self.holding = False - self.start_hold_ts = math.inf - - def keyboard_action(self, val, keysym, thres, mode): + self.holding[state_name] = False + self.start_hold_ts[state_name] = math.inf + + elif mode == Trigger.TOGGLE: + if val > threshold: + if self.key_states[state_name] is False: + if self.schedule_toggle_on[state_name] is True: + pydirectinput.mouseDown(button=action) + self.key_states[state_name] = True + + if self.key_states[state_name] is True: + if self.schedule_toggle_off[state_name] is True: + pydirectinput.mouseUp(button=action) + self.key_states[state_name] = False + + if val < threshold: + if self.key_states[state_name] is True: + self.schedule_toggle_off[state_name] = True + self.schedule_toggle_on[state_name] = False + if self.key_states[state_name] is False: + self.schedule_toggle_on[state_name] = True + self.schedule_toggle_off[state_name] = False + + elif mode == Trigger.RAPID: + if val > threshold: + if self.key_states[state_name] is False: + pydirectinput.click(button=action) + self.key_states[state_name] = True + self.start_hold_ts[state_name] = time.time() + + if self.key_states[state_name] is True: + if ( + (time.time() - self.start_hold_ts[state_name]) * 1000 + ) >= ConfigManager().config["rapid_fire_interval_ms"]: + pydirectinput.click(button=action) + self.holding[state_name] = True + self.start_hold_ts[state_name] = time.time() + + if val < threshold: + if self.key_states[state_name] is True: + self.key_states[state_name] = False + self.start_hold_ts[state_name] = math.inf + def keyboard_action(self, val, keysym, threshold, mode): state_name = "keyboard_" + keysym - if (self.key_states[state_name] is False) and (val > thres): - pydirectinput.keyDown(keysym) - self.key_states[state_name] = True + if mode == Trigger.SINGLE: + if val > threshold: + if self.key_states[state_name] is False: + pydirectinput.press(keys=keysym) + self.key_states[state_name] = True + if val < threshold: + self.key_states[state_name] = False - elif (self.key_states[state_name] is True) and (val < thres): - pydirectinput.keyUp(keysym) - self.key_states[state_name] = False + elif mode == Trigger.HOLD: + if (val > threshold) and (self.key_states[state_name] is False): + pydirectinput.keyDown(key=keysym) + self.key_states[state_name] = True + + elif (val < threshold) and (self.key_states[state_name] is True): + pydirectinput.keyUp(key=keysym) + self.key_states[state_name] = False + + elif mode == Trigger.DYNAMIC: + if val > threshold: + if self.key_states[state_name] is False: + pydirectinput.press(keys=keysym) + self.start_hold_ts[state_name] = time.time() + self.key_states[state_name] = True + + if self.holding[state_name] is False and ( + ((time.time() - self.start_hold_ts[state_name]) * 1000) + >= ConfigManager().config["hold_trigger_ms"] + ): + pydirectinput.keyDown(key=keysym) + self.holding[state_name] = True + + elif (val < threshold) and (self.key_states[state_name] is True): + self.key_states[state_name] = False + + if self.holding[state_name]: + pydirectinput.keyUp(key=keysym) + self.holding[state_name] = False + self.start_hold_ts[state_name] = math.inf + + elif mode == Trigger.TOGGLE: + if val > threshold: + if self.key_states[state_name] is False: + if self.schedule_toggle_on[state_name] is True: + pydirectinput.keyDown(key=keysym) + self.key_states[state_name] = True + + if self.key_states[state_name] is True: + if self.schedule_toggle_off[state_name] is True: + pydirectinput.keyUp(key=keysym) + self.key_states[state_name] = False + + if val < threshold: + if self.key_states[state_name] is True: + self.schedule_toggle_off[state_name] = True + self.schedule_toggle_on[state_name] = False + if self.key_states[state_name] is False: + self.schedule_toggle_on[state_name] = True + self.schedule_toggle_off[state_name] = False + + elif mode == Trigger.RAPID: + if val > threshold: + if self.key_states[state_name] is False: + pydirectinput.press(keys=keysym) + self.key_states[state_name] = True + self.start_hold_ts[state_name] = time.time() + + if self.key_states[state_name] is True: + if ( + (time.time() - self.start_hold_ts[state_name]) * 1000 + ) >= ConfigManager().config["rapid_fire_interval_ms"]: + pydirectinput.press(keys=keysym) + self.holding[state_name] = True + self.start_hold_ts[state_name] = time.time() + + if val < threshold: + if self.key_states[state_name] is True: + self.key_states[state_name] = False + self.start_hold_ts[state_name] = math.inf - def act(self, blendshape_values) -> dict: + def act(self, blendshape_values) -> None: """Trigger devices action base on blendshape values Args: @@ -155,74 +311,56 @@ def act(self, blendshape_values) -> dict: if blendshape_values is None: return - if (ConfigManager().mouse_bindings | - ConfigManager().keyboard_bindings) != self.last_know_keybinds: + if ( + ConfigManager().mouse_bindings | ConfigManager().keyboard_bindings + ) != self.last_know_keybindings: self.init_states() - for shape_name, v in (ConfigManager().mouse_bindings | - ConfigManager().keyboard_bindings).items(): + for shape_name, v in ( + ConfigManager().mouse_bindings | ConfigManager().keyboard_bindings + ).items(): if shape_name not in shape_list.blendshape_names: continue - device, action, thres, mode = v + device, action, threshold, mode = v + mode = Trigger(mode.lower()) # Get blendshape value idx = shape_list.blendshape_indices[shape_name] val = blendshape_values[idx] - if (device == "mouse") and (action == "pause"): - state_name = "mouse_" + action - - if (val > thres) and (self.key_states[state_name] is False): - mon_id = self.get_curr_monitor() - if mon_id is None: - return - - MouseController().toggle_active() - - self.key_states[state_name] = True - elif (val < thres) and (self.key_states[state_name] is True): - self.key_states[state_name] = False - - elif MouseController().is_active.get(): + if device == "meta": + self.meta_action(val, action, threshold, self.is_active.get()) + if self.is_active.get(): if device == "mouse": - - if action == "reset": - state_name = "mouse_" + action - if (val > thres) and (self.key_states[state_name] is - False): - mon_id = self.get_curr_monitor() - if mon_id is None: - return - - pydirectinput.moveTo( - self.monitors[mon_id]["center_x"], - self.monitors[mon_id]["center_y"]) - self.key_states[state_name] = True - elif (val < thres) and (self.key_states[state_name] is - True): - self.key_states[state_name] = False - - elif action == "cycle": - state_name = "mouse_" + action - if (val > thres) and (self.key_states[state_name] is - False): - mon_id = self.get_curr_monitor() - next_mon_id = (mon_id + 1) % len(self.monitors) - pydirectinput.moveTo( - self.monitors[next_mon_id]["center_x"], - self.monitors[next_mon_id]["center_y"]) - self.key_states[state_name] = True - elif (val < thres) and (self.key_states[state_name] is - True): - self.key_states[state_name] = False - - else: - self.mouse_action(val, action, thres, mode) + self.mouse_action(val, action, threshold, mode) elif device == "keyboard": - self.keyboard_action(val, action, thres, mode) + self.keyboard_action(val, action, threshold, mode) + + def set_active(self, flag: bool) -> None: + self.is_active.set(flag) + if flag: + self.delay_count = 0 + + def toggle_active(self): + logging.info("Toggle active") + current_state = self.is_active.get() + self.set_active(not current_state) def destroy(self): """Destroy the keybinder""" + logger.info("releasing all keys...") + for state_name in self.key_states: + # TODO: too many python shenanigans. Might break if you look wrong at it + device, action = state_name.split("_") + if device == "mouse": + logger.info(f"releasing {state_name}") + pydirectinput.mouseUp(button=action) + if device == "keyboard": + logger.info(f"releasing {state_name}") + pydirectinput.keyUp(key=action) + elif device == "meta": + pass + return diff --git a/src/controllers/mouse_controller.py b/src/controllers/mouse_controller.py index 25bafd15..25c8822d 100644 --- a/src/controllers/mouse_controller.py +++ b/src/controllers/mouse_controller.py @@ -1,16 +1,3 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. import concurrent.futures as futures import logging import threading @@ -36,32 +23,37 @@ class MouseController(metaclass=Singleton): - def __init__(self): - logger.info("Intialize MouseController singleton") + self.screen_h = None + self.screen_w = None + self.pool = None + self.accel = None + self.buffer = None + logger.info("Initialize MouseController singleton") self.prev_x = 0 self.prev_y = 0 - self.curr_track_loc = None + self.current_tracking_location = None self.smooth_kernel = None self.delay_count = 0 self.top_count = 0 self.is_started = False self.is_destroyed = False self.stop_flag = None - self.is_active = None + self.is_active = tk.BooleanVar() + self.is_enabled = None def start(self): if not self.is_started: logger.info("Start MouseController singleton") - # Trackpoint buffer x, y + # Track-point buffer x, y self.buffer = np.zeros([N_BUFFER, 2]) self.accel = SigmoidAccel() self.pool = futures.ThreadPoolExecutor(max_workers=1) self.screen_w, self.screen_h = pyautogui.size() self.calc_smooth_kernel() - self.is_active = tk.BooleanVar() - self.is_active.set(ConfigManager().config["auto_play"]) + self.is_enabled = tk.BooleanVar() + self.is_enabled.set(ConfigManager().config["enable"]) self.stop_flag = threading.Event() self.pool.submit(self.main_loop) @@ -78,7 +70,7 @@ def calc_smooth_kernel(self): else: pass - def asymmetry_scale(self, vel_x, vel_y): + def asymmetry_scale(self, vel_x, vel_y) -> tuple[int, int]: if vel_x > 0: vel_x *= ConfigManager().config["spd_right"] else: @@ -91,27 +83,27 @@ def asymmetry_scale(self, vel_x, vel_y): return vel_x, vel_y - def act(self, track_loc: npt.ArrayLike): - self.curr_track_loc = track_loc + def act(self, tracking_location: npt.ArrayLike): + self.current_tracking_location = tracking_location def main_loop(self) -> None: - """ Separate thread for mouse controller - """ + """Separate thread for mouse controller""" if self.is_destroyed: return while not self.stop_flag.is_set(): - if not self.is_active.get(): + if not self.is_active.get() or not self.is_enabled.get(): time.sleep(0.001) continue self.buffer = np.roll(self.buffer, shift=-1, axis=0) - self.buffer[-1] = self.curr_track_loc + self.buffer[-1] = self.current_tracking_location # Get latest x, y and smooth. smooth_px, smooth_py = utils.apply_smoothing( - self.buffer, self.smooth_kernel) + self.buffer, self.smooth_kernel + ) vel_x = smooth_px - self.prev_x vel_y = smooth_py - self.prev_y @@ -136,6 +128,11 @@ def main_loop(self) -> None: time.sleep(ConfigManager().config["tick_interval_ms"] / 1000) + def set_enabled(self, flag: bool) -> None: + self.is_enabled.set(flag) + if flag: + self.delay_count = 0 + def set_active(self, flag: bool) -> None: self.is_active.set(flag) if flag: @@ -143,8 +140,8 @@ def set_active(self, flag: bool) -> None: def toggle_active(self): logging.info("Toggle active") - curr_state = self.is_active.get() - self.set_active(not curr_state) + current_state = self.is_active.get() + self.set_active(not current_state) def destroy(self): if self.is_active is not None: diff --git a/src/detectors/__init__.py b/src/detectors/__init__.py index 688689dc..ec18a08e 100644 --- a/src/detectors/__init__.py +++ b/src/detectors/__init__.py @@ -1,15 +1,2 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from .facemesh import * +__all__ = ["FaceMesh"] +from .facemesh import FaceMesh diff --git a/src/detectors/facemesh.py b/src/detectors/facemesh.py index c974a767..23732158 100644 --- a/src/detectors/facemesh.py +++ b/src/detectors/facemesh.py @@ -1,25 +1,16 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import logging import time +from typing import Any import mediapipe as mp import numpy as np import numpy.typing as npt +from mediapipe.framework.formats.landmark_pb2 import NormalizedLandmark +from mediapipe.python._framework_bindings import image as mediapipe_image from mediapipe.tasks import python from mediapipe.tasks.python import vision +from mediapipe.tasks.python.vision import FaceLandmarkerResult +from numpy import ndarray, dtype import src.utils as utils from src.config_manager import ConfigManager @@ -35,11 +26,11 @@ class FaceMesh(metaclass=Singleton): - def __init__(self): - logger.info("Intialize FaceMesh singleton") + self.smooth_kernel = None + logger.info("Initialize FaceMesh singleton") self.mp_landmarks = None - self.track_loc = None + self.tracking_location = None self.blendshapes_buffer = np.zeros([BLENDS_MAX_BUFFER, N_SHAPES]) self.smooth_blendshapes = None self.model = None @@ -59,16 +50,20 @@ def start(self): output_facial_transformation_matrixes=True, running_mode=mp.tasks.vision.RunningMode.LIVE_STREAM, num_faces=1, - result_callback=self.mp_callback) + result_callback=self.mp_callback, + ) self.model = vision.FaceLandmarker.create_from_options(options) self.calc_smooth_kernel() def calc_smooth_kernel(self): self.smooth_kernel = utils.calc_smooth_kernel( - ConfigManager().config["shape_smooth"]) + ConfigManager().config["shape_smooth"] + ) - def calc_track_loc(self, mp_result, use_transformation_matrix=False): + def calculate_tracking_location( + self, mp_result, use_transformation_matrix=False + ) -> ndarray[Any, dtype[Any]]: screen_w = ConfigManager().config["fix_width"] screen_h = ConfigManager().config["fix_height"] landmarks = mp_result.face_landmarks[0] @@ -101,30 +96,35 @@ def calc_track_loc(self, mp_result, use_transformation_matrix=False): return np.array([x_pixel, y_pixel], np.float32) - def mp_callback(self, mp_result, output_image, timestamp_ms: int): - if len(mp_result.face_landmarks) >= 1 and len( - mp_result.face_blendshapes) >= 1: + def mp_callback( + self, + mp_result: FaceLandmarkerResult, + output_image: mediapipe_image.Image, + timestamp_ms: int, + ) -> None: + if len(mp_result.face_landmarks) >= 1 and len(mp_result.face_blendshapes) >= 1: self.mp_landmarks = mp_result.face_landmarks[0] # Point for moving pointer - self.track_loc = self.calc_track_loc( + self.tracking_location = self.calculate_tracking_location( mp_result, - use_transformation_matrix=ConfigManager( - ).config["use_transformation_matrix"]) - self.blendshapes_buffer = np.roll(self.blendshapes_buffer, - shift=-1, - axis=0) + use_transformation_matrix=ConfigManager().config[ + "use_transformation_matrix" + ], + ) + self.blendshapes_buffer = np.roll(self.blendshapes_buffer, shift=-1, axis=0) self.blendshapes_buffer[-1] = np.array( - [b.score for b in mp_result.face_blendshapes[0]]) + [b.score for b in mp_result.face_blendshapes[0]] + ) self.smooth_blendshapes = utils.apply_smoothing( - self.blendshapes_buffer, self.smooth_kernel) + self.blendshapes_buffer, self.smooth_kernel + ) else: self.mp_landmarks = None - self.track_loc = None + self.tracking_location = None def detect_frame(self, frame_np: npt.ArrayLike): - t_ms = int(time.time() * 1000) if t_ms <= self.latest_time_ms: return @@ -133,13 +133,13 @@ def detect_frame(self, frame_np: npt.ArrayLike): self.model.detect_async(frame_mp, t_ms) self.latest_time_ms = t_ms - def get_landmarks(self): + def get_landmarks(self) -> list[NormalizedLandmark]: return self.mp_landmarks - def get_track_loc(self): - return self.track_loc + def get_tracking_location(self) -> ndarray: + return self.tracking_location - def get_blendshapes(self): + def get_blendshapes(self) -> npt.ArrayLike: return self.smooth_blendshapes def destroy(self): diff --git a/src/gui/__init__.py b/src/gui/__init__.py index 3d280881..a574fa27 100644 --- a/src/gui/__init__.py +++ b/src/gui/__init__.py @@ -1,15 +1,2 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from .main_gui import * +__all__ = ["MainGui"] +from .main_gui import MainGui diff --git a/src/gui/balloon.py b/src/gui/balloon.py index a2c7da15..654e8e2e 100644 --- a/src/gui/balloon.py +++ b/src/gui/balloon.py @@ -1,17 +1,3 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from functools import partial import customtkinter @@ -20,10 +6,8 @@ BALLOON_SIZE = (305, 80) -class Balloon(): - +class Balloon: def __init__(self, master, image_path: str): - self.float_window = customtkinter.CTkToplevel(master) self.float_window.wm_overrideredirect(True) self.float_window.lift() @@ -32,21 +16,23 @@ def __init__(self, master, image_path: str): self.float_window.wm_attributes("-transparentcolor", "white") # Hide icon in taskbar - self.float_window.wm_attributes('-toolwindow', 'True') + self.float_window.wm_attributes("-toolwindow", "True") self.balloon_image = customtkinter.CTkImage( - Image.open(image_path).resize(BALLOON_SIZE), size=BALLOON_SIZE) + Image.open(image_path).resize(BALLOON_SIZE), size=BALLOON_SIZE + ) self.label = customtkinter.CTkLabel( self.float_window, text="", - compound='center', - #anchor="nw", - justify='left', + compound="center", + # anchor="nw", + justify="left", width=BALLOON_SIZE[0], height=BALLOON_SIZE[1], text_color="#3C4043", - image=self.balloon_image) + image=self.balloon_image, + ) self.label.cget("font").configure(size=16) self.label.grid(row=0, column=0, sticky="nsew") @@ -61,7 +47,6 @@ def register_widget(self, widget, text: str): widget.bind("", partial(self.hide_balloon, widget)) def show_balloon(self, widget, text, event): - if not self._displayed: self.label.configure(text=text) self._displayed = True @@ -73,8 +58,6 @@ def show_balloon(self, widget, text, event): self.float_window.deiconify() def hide_balloon(self, widget, event): - if self._displayed: - self._displayed = False self.float_window.withdraw() diff --git a/src/gui/dropdown.py b/src/gui/dropdown.py index 691b46f7..33381f68 100644 --- a/src/gui/dropdown.py +++ b/src/gui/dropdown.py @@ -1,17 +1,3 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from functools import partial import customtkinter @@ -32,14 +18,18 @@ def mouse_in_widget(mouse_x, mouse_y, widget, expand_x=(0, 0), expand_y=(0, 0)): widget_y1 = widget.winfo_rooty() - expand_y[0] widget_x2 = widget_x1 + widget.winfo_width() + expand_x[0] + expand_x[1] widget_y2 = widget_y1 + widget.winfo_height() + expand_y[0] + expand_y[1] - if mouse_x >= widget_x1 and mouse_x <= widget_x2 and mouse_y >= widget_y1 and mouse_y <= widget_y2: + if ( + mouse_x >= widget_x1 + and mouse_x <= widget_x2 + and mouse_y >= widget_y1 + and mouse_y <= widget_y2 + ): return True else: return False -class Dropdown(): - +class Dropdown: def __init__(self, master, dropdown_items: dict, width, callback: callable): self.master_toplevel = master.winfo_toplevel() @@ -50,10 +40,10 @@ def __init__(self, master, dropdown_items: dict, width, callback: callable): self.float_window.wm_attributes("-topmost", True) # Hide icon in taskbar - self.float_window.wm_attributes('-toolwindow', 'True') + self.float_window.wm_attributes("-toolwindow", "True") self.float_window.grid_rowconfigure(MAX_ROWS, weight=1) self.float_window.grid_columnconfigure(1, weight=1) - #self.float_window.group(master) + # self.float_window.group(master) self._displayed = True self.dropdown_keys = list(dropdown_items.keys()) @@ -75,36 +65,34 @@ def create_divs(self, master, ges_images: dict, width: int) -> dict: divs = {} for row, (gesture, image_path) in enumerate(ges_images.items()): image = customtkinter.CTkImage( - Image.open(image_path).resize(ICON_SIZE), size=ICON_SIZE) + Image.open(image_path).resize(ICON_SIZE), size=ICON_SIZE + ) # Label ? - row_btn = customtkinter.CTkButton(master=master, - width=width, - height=ITEM_HEIGHT, - text=gesture, - border_width=0, - corner_radius=0, - image=image, - hover=True, - fg_color=LIGHT_BLUE, - hover_color="gray90", - text_color_disabled="gray80", - compound="left", - anchor="nw") - - row_btn.grid(row=row, - column=0, - padx=(0, 0), - pady=(0, 0), - sticky="nsew") + row_btn = customtkinter.CTkButton( + master=master, + width=width, + height=ITEM_HEIGHT, + text=gesture, + border_width=0, + corner_radius=0, + image=image, + hover=True, + fg_color=LIGHT_BLUE, + hover_color="gray90", + text_color_disabled="gray80", + compound="left", + anchor="nw", + ) + + row_btn.grid(row=row, column=0, padx=(0, 0), pady=(0, 0), sticky="nsew") divs[gesture] = {"button": row_btn, "image": image} return divs def mouse_release(self, event): - """Release mouse and trigger button - """ + """Release mouse and trigger button""" # Check if release which button for gesture, div in self.divs.items(): @@ -119,10 +107,9 @@ def mouse_release(self, event): return def mouse_motion(self, event): - if not mouse_in_widget(event.x_root, - event.y_root, - self.float_window, - expand_y=(Y_OFFSET, 0)): + if not mouse_in_widget( + event.x_root, event.y_root, self.float_window, expand_y=(Y_OFFSET, 0) + ): self.hide_dropdown() return @@ -163,12 +150,12 @@ def enable_all_except(self, target_gestures: list): def refresh_items(self): self.enable_all_except( - list(ConfigManager().mouse_bindings.keys()) + - list(ConfigManager().keyboard_bindings.keys())) + list(ConfigManager().mouse_bindings.keys()) + + list(ConfigManager().keyboard_bindings.keys()) + ) def register_widget(self, widget, name): - widget.bind("", partial(self.show_dropdown, widget, - name)) + widget.bind("", partial(self.show_dropdown, widget, name)) def show_dropdown(self, widget, name, event): # Close the opening dropdown first @@ -176,7 +163,6 @@ def show_dropdown(self, widget, name, event): self.hide_dropdown() if not self._displayed: - self.refresh_items() draw_x = widget.winfo_rootx() @@ -188,22 +174,23 @@ def show_dropdown(self, widget, name, event): # Use bind_all in case hold over label, canvas self.bind_id_release = self.float_window.bind_all( - "", self.mouse_release) + "", self.mouse_release + ) self.bind_id_motion = self.float_window.bind_all( - "", self.mouse_motion) - self.float_window.wm_attributes('-disabled', False) + "", self.mouse_motion + ) + self.float_window.wm_attributes("-disabled", False) # Set current user self.current_user = name self._displayed = True def hide_dropdown(self, event=None): - if self._displayed: # Remove bindings and completely disable the window self.float_window.unbind("", self.bind_id_release) self.float_window.unbind("", self.bind_id_motion) - self.float_window.wm_attributes('-disabled', True) + self.float_window.wm_attributes("-disabled", True) self._displayed = False diff --git a/src/gui/frames/__init__.py b/src/gui/frames/__init__.py index 3fc7740c..05d43e18 100644 --- a/src/gui/frames/__init__.py +++ b/src/gui/frames/__init__.py @@ -1,19 +1,12 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from .frame_cam_preview import * -from .frame_menu import * -from .frame_profile_editor import * -from .frame_profile_switcher import * -from .safe_disposable_frame import * +__all__ = [ + "FrameCamPreview", + "FrameMenu", + "FrameProfileEditor", + "FrameProfileSwitcher", + "SafeDisposableFrame", +] +from .frame_cam_preview import FrameCamPreview +from .frame_menu import FrameMenu +from .frame_profile_editor import FrameProfileEditor +from .frame_profile_switcher import FrameProfileSwitcher +from .safe_disposable_frame import SafeDisposableFrame diff --git a/src/gui/frames/frame_cam_preview.py b/src/gui/frames/frame_cam_preview.py index 6fb38be0..54499074 100644 --- a/src/gui/frames/frame_cam_preview.py +++ b/src/gui/frames/frame_cam_preview.py @@ -1,17 +1,3 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import tkinter import customtkinter @@ -19,7 +5,7 @@ from src.camera_manager import CameraManager from src.config_manager import ConfigManager -from src.controllers import MouseController +from src.controllers import Keybinder from src.gui.frames.safe_disposable_frame import SafeDisposableFrame CANVAS_WIDTH = 216 @@ -29,7 +15,6 @@ class FrameCamPreview(SafeDisposableFrame): - def __init__(self, master, master_callback: callable, **kwargs): super().__init__(master, **kwargs) @@ -40,29 +25,30 @@ def __init__(self, master, master_callback: callable, **kwargs): # Canvas. self.placeholder_im = Image.open("assets/images/placeholder.png") self.placeholder_im = ImageTk.PhotoImage( - image=self.placeholder_im.resize((CANVAS_WIDTH, CANVAS_HEIGHT))) - - self.canvas = tkinter.Canvas(master=self, - width=CANVAS_WIDTH, - height=CANVAS_HEIGHT, - bg=LIGHT_BLUE, - bd=0, - relief='ridge', - highlightthickness=0) + image=self.placeholder_im.resize((CANVAS_WIDTH, CANVAS_HEIGHT)) + ) + + self.canvas = tkinter.Canvas( + master=self, + width=CANVAS_WIDTH, + height=CANVAS_HEIGHT, + bg=LIGHT_BLUE, + bd=0, + relief="ridge", + highlightthickness=0, + ) self.canvas.grid(row=0, column=0, padx=10, pady=10, sticky="sw") # Toggle label - self.toggle_label = customtkinter.CTkLabel(master=self, - compound='right', - text="Face control", - text_color="black", - justify=tkinter.LEFT) + self.toggle_label = customtkinter.CTkLabel( + master=self, + compound="right", + text="Face control", + text_color="black", + justify=tkinter.LEFT, + ) self.toggle_label.cget("font").configure(size=14) - self.toggle_label.grid(row=1, - column=0, - padx=(10, 0), - pady=5, - sticky="nw") + self.toggle_label.grid(row=1, column=0, padx=(10, 0), pady=5, sticky="nw") # Toggle switch self.toggle_switch = customtkinter.CTkSwitch( @@ -72,40 +58,33 @@ def __init__(self, master, master_callback: callable, **kwargs): border_color="transparent", switch_height=18, switch_width=32, - variable=MouseController().is_active, + variable=Keybinder().is_active, command=lambda: master_callback( - "toggle_switch", {"switch_status": self.toggle_switch.get()}), + "toggle_switch", {"switch_status": self.toggle_switch.get()} + ), onvalue=1, offvalue=0, ) if ConfigManager().config["auto_play"]: self.toggle_switch.select() - self.toggle_switch.grid(row=1, - column=0, - padx=(100, 0), - pady=5, - sticky="nw") + self.toggle_switch.grid(row=1, column=0, padx=(100, 0), pady=5, sticky="nw") - # Toggle label + # Toggle description label self.toggle_label = customtkinter.CTkLabel( master=self, - compound='right', + compound="right", text="Allow facial gestures to control\nyour actions. ", text_color="#444746", - justify=tkinter.LEFT) + justify=tkinter.LEFT, + ) self.toggle_label.cget("font").configure(size=12) - self.toggle_label.grid(row=2, - column=0, - padx=(10, 0), - pady=5, - sticky="nw") + self.toggle_label.grid(row=2, column=0, padx=(10, 0), pady=5, sticky="nw") # Set first image. - self.canvas_image = self.canvas.create_image(0, - 0, - image=self.placeholder_im, - anchor=tkinter.NW) + self.canvas_image = self.canvas.create_image( + 0, 0, image=self.placeholder_im, anchor=tkinter.NW + ) self.new_photo = None self.after(1, self.camera_loop) @@ -118,13 +97,12 @@ def camera_loop(self): frame_rgb = CameraManager().get_debug_frame() # Assign ref to avoid garbage collected self.new_photo = ImageTk.PhotoImage( - image=Image.fromarray(frame_rgb).resize((CANVAS_WIDTH, - CANVAS_HEIGHT))) + image=Image.fromarray(frame_rgb).resize((CANVAS_WIDTH, CANVAS_HEIGHT)) + ) self.canvas.itemconfig(self.canvas_image, image=self.new_photo) self.canvas.update() - self.after(ConfigManager().config["tick_interval_ms"], - self.camera_loop) + self.after(ConfigManager().config["tick_interval_ms"], self.camera_loop) def enter(self): super().enter() diff --git a/src/gui/frames/frame_menu.py b/src/gui/frames/frame_menu.py index 404d3e49..348eb93e 100644 --- a/src/gui/frames/frame_menu.py +++ b/src/gui/frames/frame_menu.py @@ -1,23 +1,16 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from functools import partial import customtkinter from PIL import Image from src.config_manager import ConfigManager +from src.gui.pages import ( + PageSelectCamera, + PageCursor, + PageSelectGestures, + PageKeyboard, + PageAbout, +) from src.gui.frames.safe_disposable_frame import SafeDisposableFrame LIGHT_BLUE = "#F9FBFE" @@ -26,7 +19,6 @@ class FrameMenu(SafeDisposableFrame): - def __init__(self, master, master_callback: callable, **kwargs): super().__init__(master, **kwargs) @@ -38,106 +30,120 @@ def __init__(self, master, master_callback: callable, **kwargs): self.master_callback = master_callback self.menu_btn_images = { - "page_home": [ + PageSelectCamera.__name__: [ customtkinter.CTkImage( - Image.open("assets/images/menu_btn_home.png"), - size=BTN_SIZE), - customtkinter.CTkImage( - Image.open("assets/images/menu_btn_home_selected.png"), - size=BTN_SIZE) - ], - "page_camera": [ - customtkinter.CTkImage( - Image.open("assets/images/menu_btn_camera.png"), - size=BTN_SIZE), + Image.open("assets/images/menu_btn_camera.png"), size=BTN_SIZE + ), customtkinter.CTkImage( Image.open("assets/images/menu_btn_camera_selected.png"), - size=BTN_SIZE) + size=BTN_SIZE, + ), ], - "page_cursor": [ + PageCursor.__name__: [ customtkinter.CTkImage( - Image.open("assets/images/menu_btn_cursor.png"), - size=BTN_SIZE), + Image.open("assets/images/menu_btn_cursor.png"), size=BTN_SIZE + ), customtkinter.CTkImage( Image.open("assets/images/menu_btn_cursor_selected.png"), - size=BTN_SIZE) + size=BTN_SIZE, + ), ], - "page_gestures": [ + PageSelectGestures.__name__: [ customtkinter.CTkImage( - Image.open("assets/images/menu_btn_gestures.png"), - size=BTN_SIZE), + Image.open("assets/images/menu_btn_gestures.png"), size=BTN_SIZE + ), customtkinter.CTkImage( Image.open("assets/images/menu_btn_gestures_selected.png"), - size=BTN_SIZE) + size=BTN_SIZE, + ), ], - "page_keyboard": [ + PageKeyboard.__name__: [ customtkinter.CTkImage( - Image.open("assets/images/menu_btn_keyboard.png"), - size=BTN_SIZE), + Image.open("assets/images/menu_btn_keyboard.png"), size=BTN_SIZE + ), customtkinter.CTkImage( Image.open("assets/images/menu_btn_keyboard_selected.png"), - size=BTN_SIZE) - ] + size=BTN_SIZE, + ), + ], + PageAbout.__name__: [ + customtkinter.CTkImage( + Image.open("assets/images/menu_btn_about.png"), size=BTN_SIZE + ), + customtkinter.CTkImage( + Image.open("assets/images/menu_btn_about_selected.png"), + size=BTN_SIZE, + ), + ], } # Profile button prof_drop = customtkinter.CTkImage( - Image.open("assets/images/prof_drop_head.png"), size=PROF_DROP_SIZE) + Image.open("assets/images/prof_drop_head.png"), size=PROF_DROP_SIZE + ) profile_btn = customtkinter.CTkLabel( master=self, - textvariable=ConfigManager().curr_profile_name, + textvariable=ConfigManager().current_profile_name, image=prof_drop, height=42, compound="center", anchor="w", cursor="hand2", ) - profile_btn.bind("", - partial(self.master_callback, "show_profile_switcher")) + profile_btn.bind( + "", partial(self.master_callback, "show_profile_switcher") + ) - profile_btn.grid(row=0, - column=0, - padx=35, - pady=10, - ipadx=0, - ipady=0, - sticky="nw", - columnspan=1, - rowspan=1) + profile_btn.grid( + row=0, + column=0, + padx=35, + pady=10, + ipadx=0, + ipady=0, + sticky="nw", + columnspan=1, + rowspan=1, + ) - self.btns = {} - self.btns = self.create_tab_btn(self.menu_btn_images, offset=1) + self.buttons = {} + self.buttons = self.create_tab_btn(self.menu_btn_images, offset=1) + self.set_tab_active(PageSelectCamera.__name__) def create_tab_btn(self, btns: dict, offset): - out_dict = {} for idx, (k, im_paths) in enumerate(btns.items()): - btn = customtkinter.CTkButton(master=self, - image=im_paths[0], - anchor="nw", - border_spacing=0, - border_width=0, - hover=False, - corner_radius=0, - text="", - command=partial( - self.master_callback, - function_name="change_page", - args={"target": k})) - - btn.grid(row=idx + offset, - column=0, - padx=(0, 0), - pady=0, - ipadx=0, - ipady=0, - sticky="nw") + btn = customtkinter.CTkButton( + master=self, + image=im_paths[0], + anchor="nw", + border_spacing=0, + border_width=0, + hover=False, + corner_radius=0, + text="", + command=partial( + self.master_callback, + function_name="change_page", + args={"target": k}, + ), + ) + + btn.grid( + row=idx + offset, + column=0, + padx=(0, 0), + pady=0, + ipadx=0, + ipady=0, + sticky="nw", + ) btn.configure(fg_color=LIGHT_BLUE, hover=False) out_dict[k] = btn return out_dict def set_tab_active(self, tab_name: str): - for k, btn in self.btns.items(): + for k, btn in self.buttons.items(): im_normal, im_active = self.menu_btn_images[k] if k == tab_name: btn.configure(image=im_active) diff --git a/src/gui/frames/frame_profile.py b/src/gui/frames/frame_profile.py index a5285b89..c2f84d27 100644 --- a/src/gui/frames/frame_profile.py +++ b/src/gui/frames/frame_profile.py @@ -1,17 +1,3 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import time import logging import tkinter as tk @@ -23,8 +9,11 @@ from src.config_manager import ConfigManager from src.task_killer import TaskKiller -from src.gui.frames.safe_disposable_frame import (SafeDisposableFrame, - SafeDisposableScrollableFrame) +from src.gui.frames.safe_disposable_frame import SafeDisposableFrame +from src.gui.frames.safe_disposable_scrollable_frame import ( + SafeDisposableScrollableFrame, +) + logger = logging.getLogger("FrameProfile") @@ -41,22 +30,16 @@ DARK_BLUE = "#1A73E8" BACKUP_PROFILE_NAME = "default" -DIV_COLORS = { - "default": "white", - "hovering": LIGHT_BLUE, - "selected": MEDIUM_BLUE -} +DIV_COLORS = {"default": "white", "hovering": LIGHT_BLUE, "selected": MEDIUM_BLUE} class FrameProfileItems(SafeDisposableScrollableFrame): - def __init__( self, master, refresh_master_fn, **kwargs, ): - super().__init__(master, **kwargs) self.refresh_master_fn = refresh_master_fn self.is_active = False @@ -66,21 +49,21 @@ def __init__( self.edit_image = customtkinter.CTkImage( Image.open("assets/images/edit.png").resize(EDIT_ICON_SIZE), - size=EDIT_ICON_SIZE) + size=EDIT_ICON_SIZE, + ) self.bin_image = customtkinter.CTkImage( Image.open("assets/images/bin.png").resize(BIN_ICON_SIZE), - size=BIN_ICON_SIZE) + size=BIN_ICON_SIZE, + ) self.divs = self.load_initial_profiles() - div_id = self.get_div_id(ConfigManager().curr_profile_name.get()) + div_id = self.get_div_id(ConfigManager().current_profile_name.get()) self.set_div_selected(self.divs[div_id]) - def load_initial_profiles(self): - """Create div according to profiles in config - """ + """Create div according to profiles in config""" profile_names = ConfigManager().list_profile() divs = {} @@ -98,9 +81,7 @@ def hover_enter(self, div, event): if div["is_selected"]: return for widget_name, widget in div.items(): - target_widgets = [ - "wrap_label", "entry", "edit_button", "bin_button" - ] + target_widgets = ["wrap_label", "entry", "edit_button", "bin_button"] if widget is None: continue if div["is_editing"]: @@ -114,9 +95,7 @@ def hover_leave(self, div, event): if div["is_selected"]: return for widget_name, widget in div.items(): - target_widgets = [ - "wrap_label", "entry", "edit_button", "bin_button" - ] + target_widgets = ["wrap_label", "entry", "edit_button", "bin_button"] if widget is None: continue if div["is_editing"]: @@ -126,8 +105,7 @@ def hover_leave(self, div, event): div["is_hovering"] = False def get_div_id(self, profile_name: str): - """Get div unique id from profile name - """ + """Get div unique id from profile name""" for div_id, div in self.divs.items(): if div["profile_name"] == profile_name: return div_id @@ -136,9 +114,7 @@ def get_div_id(self, profile_name: str): def set_div_inactive(self, target_div): for widget_name, widget in target_div.items(): - target_widgets = [ - "wrap_label", "entry", "edit_button", "bin_button" - ] + target_widgets = ["wrap_label", "entry", "edit_button", "bin_button"] if widget is None: continue @@ -161,9 +137,7 @@ def set_div_selected(self, div: dict, event=None): d["is_selected"] = False for widget_name, widget in div.items(): - target_widgets = [ - "wrap_label", "entry", "edit_button", "bin_button" - ] + target_widgets = ["wrap_label", "entry", "edit_button", "bin_button"] if widget is None: continue @@ -184,8 +158,8 @@ def remove_div(self, div_name): for widget in div.values(): if isinstance( - widget, customtkinter.windows.widgets.core_widget_classes. - CTkBaseClass): + widget, customtkinter.windows.widgets.core_widget_classes.CTkBaseClass + ): widget.grid_forget() widget.destroy() self.refresh_scrollbar() @@ -196,8 +170,7 @@ def clear_divs(self): self.divs = {} def refresh_frame(self): - """Refresh the divs if profile directory has changed - """ + """Refresh the divs if profile directory has changed""" logger.info("Refresh frame_profile") @@ -210,7 +183,7 @@ def refresh_frame(self): # Delete all divs and re-create self.clear_divs() self.divs = self.load_initial_profiles() - current_profile = ConfigManager().curr_profile_name.get() + current_profile = ConfigManager().current_profile_name.get() # Check if selected profile exist new_name_list = [div["profile_name"] for _, div in self.divs.items()] @@ -220,7 +193,6 @@ def refresh_frame(self): # Set and highlight selected profile for div_id, div in self.divs.items(): - if div["profile_name"] == current_profile: self.set_div_selected(div) else: @@ -236,15 +208,14 @@ def rename_button_callback(self, div: dict): hdiv["edit_button"].grid_remove() div["is_editing"] = True - div["entry"].configure(state="normal", - border_width=2, - border_color=LIGHT_GREEN, - fg_color="white") + div["entry"].configure( + state="normal", border_width=2, border_color=LIGHT_GREEN, fg_color="white" + ) div["entry"].focus_set() div["entry"].icursor("end") def check_profile_name_valid(self, div, var, index, mode): - pattern = re.compile(r'^[a-zA-Z0-9_-]+$') + pattern = re.compile(r"^[a-zA-Z0-9_-]+$") is_valid_input = bool(pattern.match(div["entry_var"].get())) # Change border color @@ -256,7 +227,6 @@ def check_profile_name_valid(self, div, var, index, mode): return is_valid_input def finish_rename(self, div, event): - if not self.check_profile_name_valid(div, None, None, None): logger.warning("Invalid profile name") return @@ -270,11 +240,8 @@ def finish_rename(self, div, event): else: new_color = DIV_COLORS["default"] - div["entry"].configure(state="disabled", - fg_color=new_color, - border_width=0) - ConfigManager().rename_profile(div["profile_name"], - div["entry_var"].get()) + div["entry"].configure(state="disabled", fg_color=new_color, border_width=0) + ConfigManager().rename_profile(div["profile_name"], div["entry_var"].get()) ConfigManager().switch_profile(div["entry_var"].get()) div["profile_name"] = div["entry_var"].get() @@ -286,7 +253,7 @@ def finish_rename(self, div, event): def remove_button_callback(self, div): ConfigManager().remove_profile(div["profile_name"]) - if div["profile_name"] == ConfigManager().curr_profile_name.get(): + if div["profile_name"] == ConfigManager().current_profile_name.get(): default_div_id = self.get_div_id(BACKUP_PROFILE_NAME) self.set_div_selected(self.divs[default_div_id]) @@ -294,82 +261,84 @@ def remove_button_callback(self, div): def create_div(self, row: int, div_id: str, profile_name) -> dict: # Box wrapper - wrap_label = customtkinter.CTkLabel(self, - text="", - height=54, - fg_color="white", - corner_radius=10) + wrap_label = customtkinter.CTkLabel( + self, text="", height=54, fg_color="white", corner_radius=10 + ) wrap_label.grid(row=row, column=0, padx=10, pady=4, sticky="new") # Edit button if profile_name != BACKUP_PROFILE_NAME: - edit_button = customtkinter.CTkButton(self, - text="", - width=20, - border_width=0, - corner_radius=0, - image=self.edit_image, - hover=False, - compound="right", - fg_color="transparent", - anchor="e", - command=None) - - edit_button.grid(row=row, - column=0, - padx=(0, 55), - pady=(20, 0), - sticky="ne", - columnspan=10, - rowspan=10) + edit_button = customtkinter.CTkButton( + self, + text="", + width=20, + border_width=0, + corner_radius=0, + image=self.edit_image, + hover=False, + compound="right", + fg_color="transparent", + anchor="e", + command=None, + ) + + edit_button.grid( + row=row, + column=0, + padx=(0, 55), + pady=(20, 0), + sticky="ne", + columnspan=10, + rowspan=10, + ) else: edit_button = None # Bin button if profile_name != BACKUP_PROFILE_NAME: - bin_button = customtkinter.CTkButton(self, - text="", - width=20, - border_width=0, - corner_radius=0, - image=self.bin_image, - hover=False, - compound="right", - fg_color="transparent", - anchor="e", - command=None) - - bin_button.grid(row=row, - column=0, - padx=(0, 20), - pady=(20, 0), - sticky="ne", - columnspan=10, - rowspan=10) + bin_button = customtkinter.CTkButton( + self, + text="", + width=20, + border_width=0, + corner_radius=0, + image=self.bin_image, + hover=False, + compound="right", + fg_color="transparent", + anchor="e", + command=None, + ) + + bin_button.grid( + row=row, + column=0, + padx=(0, 20), + pady=(20, 0), + sticky="ne", + columnspan=10, + rowspan=10, + ) else: bin_button = None # Entry entry_var = tk.StringVar() entry_var.set(profile_name) - entry = customtkinter.CTkEntry(self, - textvariable=entry_var, - placeholder_text="", - width=170, - height=30, - corner_radius=0, - state="disabled", - border_width=0, - insertborderwidth=0, - fg_color="white") + entry = customtkinter.CTkEntry( + self, + textvariable=entry_var, + placeholder_text="", + width=170, + height=30, + corner_radius=0, + state="disabled", + border_width=0, + insertborderwidth=0, + fg_color="white", + ) entry.cget("font").configure(size=16) - entry.grid(row=row, - column=0, - padx=20, - pady=20, - ipadx=10, - ipady=0, - sticky="nw") + entry.grid(row=row, column=0, padx=20, pady=20, ipadx=10, ipady=0, sticky="nw") div = { "div_id": div_id, @@ -381,15 +350,15 @@ def create_div(self, row: int, div_id: str, profile_name) -> dict: "bin_button": bin_button, "is_hovering": False, "is_editing": False, - "is_selected": False + "is_selected": False, } # Hover effect for widget in [wrap_label, entry, edit_button, bin_button]: if widget is None: continue - widget.bind('', partial(self.hover_enter, div)) - widget.bind('', partial(self.hover_leave, div)) + widget.bind("", partial(self.hover_enter, div)) + widget.bind("", partial(self.hover_leave, div)) # Click label : swap profile function for widget in [wrap_label, entry]: @@ -397,16 +366,13 @@ def create_div(self, row: int, div_id: str, profile_name) -> dict: # Bin button : remove div function if bin_button is not None: - bin_button.configure( - command=partial(self.remove_button_callback, div)) + bin_button.configure(command=partial(self.remove_button_callback, div)) # Edit button : rename profile function if edit_button is not None: - edit_button.configure( - command=partial(self.rename_button_callback, div)) - entry_var_trace_id = entry_var.trace( - "w", partial(self.check_profile_name_valid, div)) - entry.bind('', command=partial(self.finish_rename, div)) + edit_button.configure(command=partial(self.rename_button_callback, div)) + entry_var.trace("w", partial(self.check_profile_name_valid, div)) + entry.bind("", command=partial(self.finish_rename, div)) return div @@ -420,7 +386,6 @@ def leave(self): class FrameProfile(SafeDisposableFrame): - def __init__(self, master, refresh_master_fn: callable, **kwargs): super().__init__(master, **kwargs) self.master_window = master @@ -428,11 +393,11 @@ def __init__(self, master, refresh_master_fn: callable, **kwargs): self.float_window.wm_overrideredirect(True) self.float_window.lift() self.float_window.wm_attributes("-disabled", True) - self.float_window.wm_attributes('-toolwindow', 'True') + self.float_window.wm_attributes("-toolwindow", "True") self.float_window.grid_rowconfigure(3, weight=1) self.float_window.grid_columnconfigure(0, weight=1) self.float_window.configure(fg_color="white") - #self.float_window.attributes('-topmost', True) + # self.float_window.attributes('-topmost', True) self.float_window.geometry( f"{POPUP_SIZE[0]}x{POPUP_SIZE[1]}+{POPUP_OFFSET[0]}+{POPUP_OFFSET[1]}" ) @@ -443,66 +408,63 @@ def __init__(self, master, refresh_master_fn: callable, **kwargs): self.shadow_window.wm_attributes("-alpha", 0.7) self.shadow_window.wm_overrideredirect(True) self.shadow_window.lift() - self.shadow_window.wm_attributes('-toolwindow', 'True') - #self.shadow_window.attributes('-topmost', True) + self.shadow_window.wm_attributes("-toolwindow", "True") + # self.shadow_window.attributes('-topmost', True) self.shadow_window.geometry( f"{self.master_window.winfo_width()}x{self.master_window.winfo_height()}" ) # Label - top_label = customtkinter.CTkLabel(master=self.float_window, - text="User profiles") + top_label = customtkinter.CTkLabel( + master=self.float_window, text="User profiles" + ) top_label.cget("font").configure(size=24) - top_label.grid(row=0, - column=0, - padx=20, - pady=20, - sticky="nw", - columnspan=1) - - # Description label - des_label = customtkinter.CTkLabel(master=self.float_window, - text="With profile manager you can create and manage multiple profiles for each usage, so that you can easily switch between them.", - wraplength=300, - justify=tk.LEFT) + top_label.grid(row=0, column=0, padx=20, pady=20, sticky="nw", columnspan=1) + + # Description label + des_label = customtkinter.CTkLabel( + master=self.float_window, + text="With profile manager you can create and manage multiple profiles for each usage, so that you can easily switch between them.", + wraplength=300, + justify=tk.LEFT, + ) des_label.cget("font").configure(size=14) des_label.grid(row=1, column=0, padx=20, pady=10, sticky="nw") - # Close button self.close_icon = customtkinter.CTkImage( Image.open("assets/images/close.png").resize(CLOSE_ICON_SIZE), - size=CLOSE_ICON_SIZE) - - close_btn = customtkinter.CTkButton(master=self.float_window, - text="", - image=self.close_icon, - fg_color="white", - hover_color="white", - border_width=0, - corner_radius=4, - width=24, - command=self.hide_window) - close_btn.grid(row=0, - column=0, - padx=10, - pady=10, - sticky="ne", - columnspan=1, - rowspan=1) - - # Add butotn - add_button = customtkinter.CTkButton(master=self.float_window, - text="+ Add profile", - fg_color="white", - width=100, - text_color=DARK_BLUE, - command=self.add_button_callback) + size=CLOSE_ICON_SIZE, + ) + + close_btn = customtkinter.CTkButton( + master=self.float_window, + text="", + image=self.close_icon, + fg_color="white", + hover_color="white", + border_width=0, + corner_radius=4, + width=24, + command=self.hide_window, + ) + close_btn.grid( + row=0, column=0, padx=10, pady=10, sticky="ne", columnspan=1, rowspan=1 + ) + + # Add button + add_button = customtkinter.CTkButton( + master=self.float_window, + text="+ Add profile", + fg_color="white", + width=100, + text_color=DARK_BLUE, + command=self.add_button_callback, + ) add_button.grid(row=2, column=0, padx=15, pady=5, sticky="nw") # Inner scrollable frame - self.inner_frame = FrameProfileItems(self.float_window, - refresh_master_fn) + self.inner_frame = FrameProfileItems(self.float_window, refresh_master_fn) self.inner_frame.grid(row=3, column=0, padx=5, pady=5, sticky="nswe") self._displayed = True @@ -517,17 +479,14 @@ def __init__(self, master, refresh_master_fn: callable, **kwargs): def add_button_callback(self): ConfigManager().add_profile() self.inner_frame.refresh_frame() - def lift_window(self, event): - """Lift windows when root window get focus - """ + """Lift windows when root window get focus""" self.shadow_window.lift() self.float_window.lift() def follow_window(self, event): - """Move profile window when root window is moved - """ + """Move profile window when root window is moved""" if self.prev_event is None: self.prev_event = event @@ -540,7 +499,8 @@ def follow_window(self, event): shift_x = self.master_window.winfo_rootx() shift_y = self.master_window.winfo_rooty() self.float_window.geometry( - f"+{POPUP_OFFSET[0]+shift_x}+{POPUP_OFFSET[1]+shift_y}") + f"+{POPUP_OFFSET[0]+shift_x}+{POPUP_OFFSET[1]+shift_y}" + ) self.shadow_window.geometry(f"+{shift_x}+{shift_y}") self.prev_event = event @@ -567,21 +527,21 @@ def show_window(self): self.shadow_window.geometry(f"+{shift_x}+{shift_y}") self.shadow_window.deiconify() self.shadow_window.lift() - self.shadow_window.wm_attributes('-disabled', True) + self.shadow_window.wm_attributes("-disabled", True) # Popup self.float_window.geometry( - f"+{POPUP_OFFSET[0]+shift_x}+{POPUP_OFFSET[1]+shift_y}") + f"+{POPUP_OFFSET[0]+shift_x}+{POPUP_OFFSET[1]+shift_y}" + ) self.float_window.deiconify() self.float_window.lift() - self.float_window.wm_attributes('-disabled', False) + self.float_window.wm_attributes("-disabled", False) self._displayed = True def hide_window(self, event=None): - if self._displayed: logger.info("hide") - self.float_window.wm_attributes('-disabled', True) + self.float_window.wm_attributes("-disabled", True) self._displayed = False self.float_window.withdraw() diff --git a/src/gui/frames/frame_profile_editor.py b/src/gui/frames/frame_profile_editor.py index 2ab26b67..357a09b4 100644 --- a/src/gui/frames/frame_profile_editor.py +++ b/src/gui/frames/frame_profile_editor.py @@ -1,17 +1,3 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import logging import re import time @@ -22,7 +8,9 @@ from PIL import Image from src.config_manager import ConfigManager -from src.gui.frames.safe_disposable_frame import SafeDisposableScrollableFrame +from src.gui.frames.safe_disposable_scrollable_frame import ( + SafeDisposableScrollableFrame, +) from src.task_killer import TaskKiller logger = logging.getLogger("FrameProfileEditor") @@ -48,7 +36,6 @@ def random_name(row): class ItemProfileEditor(SafeDisposableScrollableFrame): - def __init__( self, owner_frame, @@ -56,7 +43,6 @@ def __init__( main_gui_callback, **kwargs, ): - super().__init__(top_level, **kwargs) self.main_gui_callback = main_gui_callback self.is_active = False @@ -66,19 +52,18 @@ def __init__( self.edit_image = customtkinter.CTkImage( Image.open("assets/images/rename.png").resize(EDIT_ICON_SIZE), - size=EDIT_ICON_SIZE) + size=EDIT_ICON_SIZE, + ) self.bin_image = customtkinter.CTkImage( Image.open("assets/images/bin.png").resize(BIN_ICON_SIZE), - size=BIN_ICON_SIZE) + size=BIN_ICON_SIZE, + ) self.divs = self.load_initial_profiles() - div_id = self.get_div_id(ConfigManager().curr_profile_name.get()) - def load_initial_profiles(self): - """Create div according to profiles in config - """ + """Create div according to profiles in config""" profile_names = ConfigManager().list_profile() divs = {} @@ -93,8 +78,7 @@ def load_initial_profiles(self): return divs def get_div_id(self, profile_name: str): - """Get div unique id from profile name - """ + """Get div unique id from profile name""" for div_id, div in self.divs.items(): if div["profile_name"] == profile_name: return div_id @@ -107,8 +91,8 @@ def remove_div(self, div_name): for widget in div.values(): if isinstance( - widget, customtkinter.windows.widgets.core_widget_classes. - CTkBaseClass): + widget, customtkinter.windows.widgets.core_widget_classes.CTkBaseClass + ): widget.grid_forget() widget.destroy() self.refresh_scrollbar() @@ -119,16 +103,13 @@ def clear_divs(self): self.divs = {} def refresh_frame(self): - """Refresh the divs if profile directory has changed - """ + """Refresh the divs if profile directory has changed""" logger.info("Refresh frame_profile") # Check if folders same as divs name_list = [div["profile_name"] for _, div in self.divs.items()] - - if set(ConfigManager().list_profile()) == set(name_list): return logger.info("Profile directory changed, reload...") @@ -136,16 +117,14 @@ def refresh_frame(self): # Delete all divs and re-create self.clear_divs() self.divs = self.load_initial_profiles() - current_profile = ConfigManager().curr_profile_name.get() + current_profile = ConfigManager().current_profile_name.get() # Check if selected profile exist new_name_list = [div["profile_name"] for _, div in self.divs.items()] if current_profile not in new_name_list: - logger.critical( - f"Profile {current_profile} not found in {new_name_list}") + logger.critical(f"Profile {current_profile} not found in {new_name_list}") TaskKiller().exit() - self.refresh_scrollbar() logger.info(f"Current selected profile {current_profile}") @@ -157,16 +136,14 @@ def rename_button_callback(self, div: dict): hdiv["edit_button"].grid_remove() div["is_editing"] = True - div["entry"].configure(state="normal", - border_width=2, - border_color=LIGHT_GREEN, - fg_color="white") + div["entry"].configure( + state="normal", border_width=2, border_color=LIGHT_GREEN, fg_color="white" + ) div["entry"].focus_set() div["entry"].icursor("end") - def check_profile_name_valid(self, div, var, index, mode): - pattern = re.compile(r'^[a-zA-Z0-9_-]+$') + pattern = re.compile(r"^[a-zA-Z0-9_-]+$") is_valid_input = bool(pattern.match(div["entry_var"].get())) # Change border color @@ -178,7 +155,6 @@ def check_profile_name_valid(self, div, var, index, mode): return is_valid_input def finish_rename(self, div, event): - if not self.check_profile_name_valid(div, None, None, None): logger.warning("Invalid profile name") return @@ -187,8 +163,7 @@ def finish_rename(self, div, event): div["entry"].configure(state="disabled", border_width=0) - ConfigManager().rename_profile(div["profile_name"], - div["entry_var"].get()) + ConfigManager().rename_profile(div["profile_name"], div["entry_var"].get()) div["profile_name"] = div["entry_var"].get() @@ -203,7 +178,7 @@ def remove_button_callback(self, div): # If user remove an active profile, roll back to default if div["profile_name"] == ConfigManager().curr_profile_name.get(): - logger.warning(f"Removing active profile, rollback to default") + logger.warning("Removing active profile, rollback to default") ConfigManager().switch_profile(BACKUP_PROFILE_NAME) # Refresh values in each page @@ -213,84 +188,86 @@ def remove_button_callback(self, div): def create_div(self, row: int, div_id: str, profile_name) -> dict: # Box wrapper - wrap_label = customtkinter.CTkLabel(self, - text="", - height=54, - fg_color="white", - corner_radius=10) + wrap_label = customtkinter.CTkLabel( + self, text="", height=54, fg_color="white", corner_radius=10 + ) wrap_label.grid(row=row, column=0, padx=10, pady=4, sticky="new") # Edit button if profile_name != BACKUP_PROFILE_NAME: - edit_button = customtkinter.CTkButton(self, - text="", - width=20, - border_width=0, - corner_radius=0, - image=self.edit_image, - hover=False, - compound="right", - fg_color="transparent", - anchor="e", - command=None) - - edit_button.grid(row=row, - column=0, - padx=(0, 55), - pady=(20, 0), - sticky="ne", - columnspan=10, - rowspan=10) + edit_button = customtkinter.CTkButton( + self, + text="", + width=20, + border_width=0, + corner_radius=0, + image=self.edit_image, + hover=False, + compound="right", + fg_color="transparent", + anchor="e", + command=None, + ) + + edit_button.grid( + row=row, + column=0, + padx=(0, 55), + pady=(20, 0), + sticky="ne", + columnspan=10, + rowspan=10, + ) else: edit_button = None # Bin button if profile_name != BACKUP_PROFILE_NAME: - bin_button = customtkinter.CTkButton(self, - text="", - width=20, - border_width=0, - corner_radius=0, - image=self.bin_image, - hover=False, - compound="right", - fg_color="transparent", - anchor="e", - command=None) - - bin_button.grid(row=row, - column=0, - padx=(0, 20), - pady=(20, 0), - sticky="ne", - columnspan=10, - rowspan=10) + bin_button = customtkinter.CTkButton( + self, + text="", + width=20, + border_width=0, + corner_radius=0, + image=self.bin_image, + hover=False, + compound="right", + fg_color="transparent", + anchor="e", + command=None, + ) + + bin_button.grid( + row=row, + column=0, + padx=(0, 20), + pady=(20, 0), + sticky="ne", + columnspan=10, + rowspan=10, + ) else: bin_button = None # Entry entry_var = tk.StringVar() entry_var.set(profile_name) - entry = customtkinter.CTkEntry(self, - textvariable=entry_var, - placeholder_text="", - width=170, - height=30, - corner_radius=0, - state="disabled", - border_width=0, - insertborderwidth=0, - fg_color="white") + entry = customtkinter.CTkEntry( + self, + textvariable=entry_var, + placeholder_text="", + width=170, + height=30, + corner_radius=0, + state="disabled", + border_width=0, + insertborderwidth=0, + fg_color="white", + ) entry.cget("font").configure(size=16) - entry.grid(row=row, - column=0, - padx=20, - pady=20, - ipadx=10, - ipady=0, - sticky="nw") - - sep = tk.ttk.Separator(wrap_label, orient='horizontal') + entry.grid(row=row, column=0, padx=20, pady=20, ipadx=10, ipady=0, sticky="nw") + + sep = tk.ttk.Separator(wrap_label, orient="horizontal") sep.grid(row=row, column=0, padx=0, pady=0, sticky="sew") div = { @@ -301,21 +278,18 @@ def create_div(self, row: int, div_id: str, profile_name) -> dict: "entry_var": entry_var, "edit_button": edit_button, "bin_button": bin_button, - "is_editing": False + "is_editing": False, } # Bin button : remove div function if bin_button is not None: - bin_button.configure( - command=partial(self.remove_button_callback, div)) + bin_button.configure(command=partial(self.remove_button_callback, div)) # Edit button : rename profile function if edit_button is not None: - edit_button.configure( - command=partial(self.rename_button_callback, div)) - entry_var_trace_id = entry_var.trace( - "w", partial(self.check_profile_name_valid, div)) - entry.bind('', command=partial(self.finish_rename, div)) + edit_button.configure(command=partial(self.rename_button_callback, div)) + entry_var.trace("w", partial(self.check_profile_name_valid, div)) + entry.bind("", command=partial(self.finish_rename, div)) return div @@ -328,21 +302,19 @@ def leave(self): super().leave() -class FrameProfileEditor(): - +class FrameProfileEditor: def __init__(self, root_window, main_gui_callback: callable, **kwargs): - self.root_window = root_window self.main_gui_callback = main_gui_callback self.float_window = customtkinter.CTkToplevel(root_window) self.float_window.wm_overrideredirect(True) self.float_window.lift() self.float_window.wm_attributes("-disabled", True) - self.float_window.wm_attributes('-toolwindow', 'True') + self.float_window.wm_attributes("-toolwindow", "True") self.float_window.grid_rowconfigure(3, weight=1) self.float_window.grid_columnconfigure(0, weight=1) self.float_window.configure(fg_color="white") - #self.float_window.attributes('-topmost', True) + # self.float_window.attributes('-topmost', True) self.float_window.geometry( f"{POPUP_SIZE[0]}x{POPUP_SIZE[1]}+{POPUP_OFFSET[0]}+{POPUP_OFFSET[1]}" ) @@ -353,72 +325,71 @@ def __init__(self, root_window, main_gui_callback: callable, **kwargs): self.shadow_window.wm_attributes("-alpha", 0.7) self.shadow_window.wm_overrideredirect(True) self.shadow_window.lift() - self.shadow_window.wm_attributes('-toolwindow', 'True') - #self.shadow_window.attributes('-topmost', True) + self.shadow_window.wm_attributes("-toolwindow", "True") + # self.shadow_window.attributes('-topmost', True) self.shadow_window.geometry( f"{self.root_window.winfo_width()}x{self.root_window.winfo_height()}" ) # Label - top_label = customtkinter.CTkLabel(master=self.float_window, - text="User profiles") + top_label = customtkinter.CTkLabel( + master=self.float_window, text="User profiles" + ) top_label.cget("font").configure(size=24) - top_label.grid(row=0, - column=0, - padx=20, - pady=20, - sticky="nw", - columnspan=1) + top_label.grid(row=0, column=0, padx=20, pady=20, sticky="nw", columnspan=1) # Description label des_label = customtkinter.CTkLabel( master=self.float_window, - text= - "With profile manager you can create and manage multiple profiles for each usage, so that you can easily switch between them.", + text="With profile manager you can create and manage multiple profiles for each usage, so that you can easily switch between them.", wraplength=300, - justify=tk.LEFT) + justify=tk.LEFT, + ) des_label.cget("font").configure(size=14) des_label.grid(row=1, column=0, padx=20, pady=10, sticky="nw") # Close button self.close_icon = customtkinter.CTkImage( Image.open("assets/images/close.png").resize(CLOSE_ICON_SIZE), - size=CLOSE_ICON_SIZE) - - close_btn = customtkinter.CTkButton(master=self.float_window, - text="", - image=self.close_icon, - fg_color="white", - hover_color="white", - border_width=0, - corner_radius=4, - width=24, - command=self.hide_window) - close_btn.grid(row=0, - column=0, - padx=10, - pady=10, - sticky="ne", - columnspan=1, - rowspan=1) - - # Add butotn + size=CLOSE_ICON_SIZE, + ) + + close_btn = customtkinter.CTkButton( + master=self.float_window, + text="", + image=self.close_icon, + fg_color="white", + hover_color="white", + border_width=0, + corner_radius=4, + width=24, + command=self.hide_window, + ) + close_btn.grid( + row=0, column=0, padx=10, pady=10, sticky="ne", columnspan=1, rowspan=1 + ) + + # Add button add_prof_image = customtkinter.CTkImage( - Image.open("assets/images/add_prof.png"), size=(16, 12)) - add_button = customtkinter.CTkButton(master=self.float_window, - text="Add profile", - image=add_prof_image, - fg_color="white", - width=100, - text_color=DARK_BLUE, - command=self.add_button_callback) + Image.open("assets/images/add_prof.png"), size=(16, 12) + ) + add_button = customtkinter.CTkButton( + master=self.float_window, + text="Add profile", + image=add_prof_image, + fg_color="white", + width=100, + text_color=DARK_BLUE, + command=self.add_button_callback, + ) add_button.grid(row=2, column=0, padx=15, pady=5, sticky="nw") # Inner scrollable frame self.inner_frame = ItemProfileEditor( owner_frame=self, top_level=self.float_window, - main_gui_callback=main_gui_callback) + main_gui_callback=main_gui_callback, + ) self.inner_frame.grid(row=3, column=0, padx=5, pady=5, sticky="nswe") self._displayed = True @@ -431,14 +402,12 @@ def add_button_callback(self): self.inner_frame.refresh_frame() def lift_window(self, event): - """Lift windows when root window get focus - """ + """Lift windows when root window get focus""" self.shadow_window.lift() self.float_window.lift() def follow_window(self, event): - """Move profile window when root window is moved - """ + """Move profile window when root window is moved""" if self.prev_event is None: self.prev_event = event @@ -451,7 +420,8 @@ def follow_window(self, event): shift_x = self.root_window.winfo_rootx() shift_y = self.root_window.winfo_rooty() self.float_window.geometry( - f"+{POPUP_OFFSET[0]+shift_x}+{POPUP_OFFSET[1]+shift_y}") + f"+{POPUP_OFFSET[0]+shift_x}+{POPUP_OFFSET[1]+shift_y}" + ) self.shadow_window.geometry(f"+{shift_x}+{shift_y}") self.prev_event = event @@ -482,31 +452,30 @@ def show_window(self): self.shadow_window.geometry(f"+{shift_x}+{shift_y}") self.shadow_window.deiconify() self.shadow_window.lift() - self.shadow_window.wm_attributes('-disabled', True) + self.shadow_window.wm_attributes("-disabled", True) # Popup self.float_window.geometry( - f"+{POPUP_OFFSET[0]+shift_x}+{POPUP_OFFSET[1]+shift_y}") + f"+{POPUP_OFFSET[0]+shift_x}+{POPUP_OFFSET[1]+shift_y}" + ) self.float_window.deiconify() self.float_window.lift() - self.float_window.wm_attributes('-disabled', False) + self.float_window.wm_attributes("-disabled", False) self._displayed = True def hide_window(self, event=None): - if self._displayed: - self.root_window.unbind_all("") self.root_window.unbind_all("") logger.info("hide") - self.float_window.wm_attributes('-disabled', True) + self.float_window.wm_attributes("-disabled", True) self._displayed = False self.float_window.withdraw() self.shadow_window.withdraw() - def enter(self): + def enter(self): self.inner_frame.enter() self.show_window() logger.info("enter") diff --git a/src/gui/frames/frame_profile_switcher.py b/src/gui/frames/frame_profile_switcher.py index 7fa7fbe8..48fdfc47 100644 --- a/src/gui/frames/frame_profile_switcher.py +++ b/src/gui/frames/frame_profile_switcher.py @@ -1,19 +1,4 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import logging -import re import time import tkinter as tk from functools import partial @@ -41,11 +26,7 @@ DARK_BLUE = "#1A73E8" BACKUP_PROFILE_NAME = "default" -DIV_COLORS = { - "default": "white", - "hovering": LIGHT_BLUE, - "selected": MEDIUM_BLUE -} +DIV_COLORS = {"default": "white", "hovering": LIGHT_BLUE, "selected": MEDIUM_BLUE} def random_name(row): @@ -53,7 +34,6 @@ def random_name(row): class ItemProfileSwitcher(SafeDisposableFrame): - def __init__( self, owner_frame, @@ -61,7 +41,6 @@ def __init__( main_gui_callback, **kwargs, ): - super().__init__(top_level, **kwargs) self.owner_frame = owner_frame self.main_gui_callback = main_gui_callback @@ -72,20 +51,18 @@ def __init__( self.divs = self.load_initial_profiles() - div_id = self.get_div_id(ConfigManager().curr_profile_name.get()) # self.set_div_selected(self.divs[div_id]) # Custom border self.configure(border_color="gray60") self.configure(fg_color="transparent") self.configure(bg_color="transparent") - self.configure(background_corner_colors=[ - "#000000", "#000000", "#000000", "#000000" - ]) + self.configure( + background_corner_colors=["#000000", "#000000", "#000000", "#000000"] + ) def load_initial_profiles(self): - """Create div according to profiles in config - """ + """Create div according to profiles in config""" profile_names = ConfigManager().list_profile() divs = {} @@ -100,26 +77,22 @@ def load_initial_profiles(self): row += 1 # Create add profile button - drop_add_div_id = random_name(row ) + drop_add_div_id = random_name(row) addp_div = self.create_add_profiles_div(row, drop_add_div_id) addp_div["wrap_label"].grid() divs[drop_add_div_id] = addp_div - row += 1 + row += 1 # Create edit profile button edit_div_id = random_name(row) edit_div = self.create_edit_profiles_div(row, edit_div_id) edit_div["wrap_label"].grid() divs[edit_div_id] = edit_div - return divs - - def get_div_id(self, profile_name: str): - """Get div unique id from profile name - """ + """Get div unique id from profile name""" for div_id, div in self.divs.items(): if div["profile_name"] == profile_name: return div_id @@ -148,23 +121,19 @@ def hover_leave(self, div, event): div["is_hovering"] = False - - - def remove_div(self, div_name): logger.info(f"Remove {div_name}") div = self.divs[div_name] for widget in div.values(): if isinstance( - widget, customtkinter.windows.widgets.core_widget_classes. - CTkBaseClass): + widget, customtkinter.windows.widgets.core_widget_classes.CTkBaseClass + ): widget.grid_forget() widget.destroy() def refresh_frame(self): - """Refresh the divs if profile directory has changed - """ + """Refresh the divs if profile directory has changed""" logger.info("Refresh frame_profile") @@ -182,7 +151,7 @@ def refresh_frame(self): # Delete all divs and re-create self.clear_divs() self.divs = self.load_initial_profiles() - current_profile = ConfigManager().curr_profile_name.get() + current_profile = ConfigManager().current_profile_name.get() # Check if selected profile exist new_name_list = [div["profile_name"] for _, div in self.divs.items()] @@ -207,55 +176,53 @@ def switch_div_profile(self, div, event): def create_div(self, row: int, div_id: str, profile_name) -> dict: prefix_icon = customtkinter.CTkImage( - Image.open("assets/images/proj_icon_blank.png"), - size=PREFIX_ICON_SIZE) + Image.open("assets/images/proj_icon_blank.png"), size=PREFIX_ICON_SIZE + ) # Box entry_var = tk.StringVar() entry_var.set(profile_name) - wrap_label = customtkinter.CTkLabel(self, - text="", - textvariable=entry_var, - height=40, - image=prefix_icon, - compound="left", - anchor="w", - cursor="hand2", - fg_color="white", - corner_radius=0) + wrap_label = customtkinter.CTkLabel( + self, + text="", + textvariable=entry_var, + height=40, + image=prefix_icon, + compound="left", + anchor="w", + cursor="hand2", + fg_color="white", + corner_radius=0, + ) top_pad = TOP_PAD if row == 0 else 0 - wrap_label.grid(row=row, - column=0, - padx=(1, 2), - pady=(top_pad, 0), - ipadx=0, - ipady=0, - sticky="new") - - sep = tk.ttk.Separator(wrap_label, orient='horizontal') - sep.grid(row=row, - column=0, - padx=0, - pady=0, - ipadx=0, - ipady=0, - sticky="sew") + wrap_label.grid( + row=row, + column=0, + padx=(1, 2), + pady=(top_pad, 0), + ipadx=0, + ipady=0, + sticky="new", + ) + + sep = tk.ttk.Separator(wrap_label, orient="horizontal") + sep.grid(row=row, column=0, padx=0, pady=0, ipadx=0, ipady=0, sticky="sew") div = { "div_id": div_id, "profile_name": profile_name, "wrap_label": wrap_label, "entry_var": entry_var, - "is_hovering": False + "is_hovering": False, } # Hover effect for widget in [wrap_label]: if widget is None: continue - widget.bind('', partial(self.hover_enter, div)) - widget.bind('', partial(self.hover_leave, div)) + widget.bind("", partial(self.hover_enter, div)) + widget.bind("", partial(self.hover_leave, div)) # Click label : swap profile function for widget in [wrap_label]: @@ -265,42 +232,47 @@ def create_div(self, row: int, div_id: str, profile_name) -> dict: def create_edit_profiles_div(self, row: int, div_id: str) -> dict: prefix_icon = customtkinter.CTkImage( - Image.open("assets/images/edit.png"), size=PREFIX_ICON_SIZE) + Image.open("assets/images/edit.png"), size=PREFIX_ICON_SIZE + ) # Box - wrap_label = customtkinter.CTkLabel(self, - text="Manage Profiles", - height=40, - image=prefix_icon, - compound="left", - justify="left", - anchor="w", - cursor="hand2", - fg_color="white", - corner_radius=0) + wrap_label = customtkinter.CTkLabel( + self, + text="Manage Profiles", + height=40, + image=prefix_icon, + compound="left", + justify="left", + anchor="w", + cursor="hand2", + fg_color="white", + corner_radius=0, + ) top_pad = TOP_PAD if row == 0 else 0 - wrap_label.grid(row=row, - column=0, - padx=(1, 2), - pady=(top_pad, 0), - ipadx=0, - ipady=0, - sticky="new") + wrap_label.grid( + row=row, + column=0, + padx=(1, 2), + pady=(top_pad, 0), + ipadx=0, + ipady=0, + sticky="new", + ) div = { "div_id": div_id, "profile_name": "Manage Profiles", "wrap_label": wrap_label, - "is_hovering": False + "is_hovering": False, } # Hover effect for widget in [wrap_label]: if widget is None: continue - widget.bind('', partial(self.hover_enter, div)) - widget.bind('', partial(self.hover_leave, div)) + widget.bind("", partial(self.hover_enter, div)) + widget.bind("", partial(self.hover_leave, div)) # Click label : swap profile function for widget in [wrap_label]: @@ -310,42 +282,47 @@ def create_edit_profiles_div(self, row: int, div_id: str) -> dict: def create_add_profiles_div(self, row: int, div_id: str) -> dict: prefix_icon = customtkinter.CTkImage( - Image.open("assets/images/add_drop.png"), size=PREFIX_ICON_SIZE) + Image.open("assets/images/add_drop.png"), size=PREFIX_ICON_SIZE + ) # Box - wrap_label = customtkinter.CTkLabel(self, - text="Add Profile", - height=40, - image=prefix_icon, - compound="left", - justify="left", - anchor="w", - cursor="hand2", - fg_color="white", - corner_radius=0) + wrap_label = customtkinter.CTkLabel( + self, + text="Add Profile", + height=40, + image=prefix_icon, + compound="left", + justify="left", + anchor="w", + cursor="hand2", + fg_color="white", + corner_radius=0, + ) top_pad = TOP_PAD if row == 0 else 0 - wrap_label.grid(row=row, - column=0, - padx=(1, 2), - pady=(top_pad, 0), - ipadx=0, - ipady=0, - sticky="new") + wrap_label.grid( + row=row, + column=0, + padx=(1, 2), + pady=(top_pad, 0), + ipadx=0, + ipady=0, + sticky="new", + ) div = { "div_id": div_id, "profile_name": "Add Profile", "wrap_label": wrap_label, - "is_hovering": False + "is_hovering": False, } # Hover effect for widget in [wrap_label]: if widget is None: continue - widget.bind('', partial(self.hover_enter, div)) - widget.bind('', partial(self.hover_leave, div)) + widget.bind("", partial(self.hover_enter, div)) + widget.bind("", partial(self.hover_leave, div)) # Click label : swap profile function for widget in [wrap_label]: @@ -353,33 +330,29 @@ def create_add_profiles_div(self, row: int, div_id: str) -> dict: return div - def enter(self): - super().enter() + super().enter() self.refresh_frame() - def leave(self): super().leave() -class FrameProfileSwitcher(): - +class FrameProfileSwitcher: def __init__(self, root_window, main_gui_callback: callable, **kwargs): - self.root_window = root_window self.main_gui_callback = main_gui_callback self.float_window = customtkinter.CTkToplevel(root_window) self.float_window.wm_overrideredirect(True) self.float_window.lift() self.float_window.wm_attributes("-disabled", True) - self.float_window.wm_attributes('-toolwindow', 'True') + self.float_window.wm_attributes("-toolwindow", "True") self.float_window.grid_rowconfigure(3, weight=1) self.float_window.grid_columnconfigure(0, weight=1) self.float_window.configure(fg_color="white") self._displayed = True - # Rounded corder - self.float_window.config(background='#000000') + # Rounded corner + self.float_window.config(background="#000000") self.float_window.attributes("-transparentcolor", "#000000") # Custom border @@ -394,7 +367,8 @@ def __init__(self, root_window, main_gui_callback: callable, **kwargs): self.inner_frame = ItemProfileSwitcher( owner_frame=self, top_level=self.float_window, - main_gui_callback=main_gui_callback) + main_gui_callback=main_gui_callback, + ) self.inner_frame.grid(row=3, column=0, padx=5, pady=5, sticky="nswe") self.hide_window() @@ -410,7 +384,6 @@ def change_profile(self, target): self.pages["page_keyboard"].refresh_profile() def show_window(self): - self.root_window.bind("", self.hide_window) shift_x = self.root_window.winfo_rootx() shift_y = self.root_window.winfo_rooty() @@ -418,23 +391,21 @@ def show_window(self): # Popup n_rows = len(ConfigManager().profiles) + 2 - - self.float_window.geometry( f"{PROFILE_ITEM_SIZE[0]}x{PROFILE_ITEM_SIZE[1]*n_rows+EXTEND_PAD}+{POPUP_OFFSET[0]+shift_x}+{POPUP_OFFSET[1]+shift_y}" ) self.float_window.deiconify() self.float_window.lift() - self.float_window.wm_attributes('-disabled', False) + self.float_window.wm_attributes("-disabled", False) self._displayed = True - def hide_window(self, event=None): + def hide_window(self, event=None): if self._displayed: logger.info("hide") self.root_window.unbind_all("") - self.float_window.wm_attributes('-disabled', True) + self.float_window.wm_attributes("-disabled", True) self._displayed = False self.float_window.withdraw() @@ -443,15 +414,12 @@ def show_profile_editor(self, event, **kwargs): self.hide_window() self.main_gui_callback("show_profile_editor") - def dropdown_add_profile(self, event, **kwargs): ConfigManager().add_profile() self.inner_frame.refresh_frame() self.show_window() - def enter(self): - # refresh UI self.inner_frame.enter() self.show_window() diff --git a/src/gui/frames/safe_disposable_frame.py b/src/gui/frames/safe_disposable_frame.py index 8e2e5b00..7238bc25 100644 --- a/src/gui/frames/safe_disposable_frame.py +++ b/src/gui/frames/safe_disposable_frame.py @@ -1,24 +1,9 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import logging import customtkinter class SafeDisposableFrame(customtkinter.CTkFrame): - def __init__(self, master, logger_name: str = "", **kwargs): super().__init__(master, **kwargs) self.is_active = False @@ -43,40 +28,3 @@ def destroy(self): self.canvas_im = None self.new_photo = None self.placeholder_im = None - - -class SafeDisposableScrollableFrame(customtkinter.CTkScrollableFrame): - - def __init__(self, master, logger_name: str = "", **kwargs): - super().__init__(master, **kwargs) - self.is_active = False - self.is_destroyed = False - self.canvas_im = None - self.new_photo = None - self.placeholder_im = None - self.logger = logging.getLogger(logger_name) - - self.refresh_scrollbar() - - def refresh_scrollbar(self): - bar_start, bar_end = self._scrollbar.get() - if (bar_end - bar_start) < 1.0: - self._scrollbar.grid() - else: - self._scrollbar.grid_remove() - - def enter(self): - self.logger.info("enter") - self.is_active = True - - def leave(self): - self.logger.info("leave") - self.is_active = False - - def destroy(self): - self.logger.info("destroy") - self.is_active = False - self.is_destroyed = True - self.canvas_im = None - self.new_photo = None - self.placeholder_im = None diff --git a/src/gui/frames/safe_disposable_scrollable_frame.py b/src/gui/frames/safe_disposable_scrollable_frame.py new file mode 100644 index 00000000..c25f0c76 --- /dev/null +++ b/src/gui/frames/safe_disposable_scrollable_frame.py @@ -0,0 +1,39 @@ +import logging + +import customtkinter + + +class SafeDisposableScrollableFrame(customtkinter.CTkScrollableFrame): + def __init__(self, master, logger_name: str = "", **kwargs): + super().__init__(master, **kwargs) + self.is_active = False + self.is_destroyed = False + self.canvas_im = None + self.new_photo = None + self.placeholder_im = None + self.logger = logging.getLogger(logger_name) + + self.refresh_scrollbar() + + def refresh_scrollbar(self): + bar_start, bar_end = self._scrollbar.get() + if (bar_end - bar_start) < 1.0: + self._scrollbar.grid() + else: + self._scrollbar.grid_remove() + + def enter(self): + self.logger.info("enter") + self.is_active = True + + def leave(self): + self.logger.info("leave") + self.is_active = False + + def destroy(self): + self.logger.info("destroy") + self.is_active = False + self.is_destroyed = True + self.canvas_im = None + self.new_photo = None + self.placeholder_im = None diff --git a/src/gui/main_gui.py b/src/gui/main_gui.py index ff052b7c..344a3ef4 100644 --- a/src/gui/main_gui.py +++ b/src/gui/main_gui.py @@ -1,27 +1,16 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import logging -import tkinter as tk - import customtkinter -from PIL import Image -import src.gui.frames as frames -import src.gui.pages as pages +from src.gui import frames from src.config_manager import ConfigManager -from src.controllers import MouseController +from src.controllers import Keybinder, MouseController +from src.gui.pages import ( + PageSelectCamera, + PageCursor, + PageSelectGestures, + PageKeyboard, + PageAbout, +) customtkinter.set_appearance_mode("light") customtkinter.set_default_color_theme("assets/themes/google_theme.json") @@ -29,103 +18,75 @@ logger = logging.getLogger("MainGUi") -class MainGui(): - +class MainGui: def __init__(self, tk_root): logger.info("Init MainGui") super().__init__() self.tk_root = tk_root - self.tk_root.geometry("1024x658") - self.tk_root.title(f"Project Gameface {ConfigManager().version}") + self.tk_root.geometry("1024x800") + self.tk_root.title(f"Grimassist {ConfigManager().version}") self.tk_root.iconbitmap("assets/images/icon.ico") - self.tk_root.resizable(width=False, height=False) + self.tk_root.resizable(width=True, height=True) self.tk_root.grid_rowconfigure(1, weight=1) self.tk_root.grid_columnconfigure(1, weight=1) # Create menu frame and assign callbacks - self.frame_menu = frames.FrameMenu(self.tk_root, - self.root_function_callback, - height=360, - width=260, - logger_name="frame_menu") - self.frame_menu.grid(row=0, - column=0, - padx=0, - pady=0, - sticky="nsew", - columnspan=1, - rowspan=3) + self.frame_menu = frames.FrameMenu( + self.tk_root, + self.root_function_callback, + height=360, + width=260, + logger_name="frame_menu", + ) + self.frame_menu.grid( + row=0, column=0, padx=0, pady=0, sticky="nsew", columnspan=1, rowspan=3 + ) # Create Preview frame - self.frame_preview = frames.FrameCamPreview(self.tk_root, - self.cam_preview_callback, - logger_name="frame_preview") - self.frame_preview.grid(row=1, - column=0, - padx=0, - pady=0, - sticky="sew", - columnspan=1) + self.frame_preview = frames.FrameCamPreview( + self.tk_root, self.cam_preview_callback, logger_name="frame_preview" + ) + self.frame_preview.grid( + row=1, column=0, padx=0, pady=0, sticky="sew", columnspan=1 + ) self.frame_preview.enter() # Create all wizard pages and grid them. - self.pages = { - "page_home": - pages.PageHome(master=self.tk_root, - logger_name="page_home", - root_callback=self.root_function_callback), - "page_camera": - pages.PageSelectCamera( - master=self.tk_root, - logger_name="page_camera", - ), - "page_cursor": - pages.PageCursor( - master=self.tk_root, - logger_name="page_cursor", - ), - "page_gestures": - pages.PageSelectGestures( - master=self.tk_root, - logger_name="page_gestures", - ), - "page_keyboard": - pages.PageKeyboard( - master=self.tk_root, - logger_name="page_keyboard", - ) - } - - self.page_names = list(self.pages.keys()) - self.curr_page_name = None - for name, page in self.pages.items(): - # Page home extended full window - if name == "page_home": - page.grid(row=0, - column=0, - padx=5, - pady=5, - sticky="nsew", - rowspan=2, - columnspan=2) - else: - page.grid(row=0, - column=1, - padx=5, - pady=5, - sticky="nsew", - rowspan=2, - columnspan=1) - - self.change_page("page_home") + self.pages = [ + PageSelectCamera( + master=self.tk_root, + ), + PageCursor( + master=self.tk_root, + ), + PageSelectGestures( + master=self.tk_root, + ), + PageKeyboard( + master=self.tk_root, + ), + PageAbout( + master=self.tk_root, + ), + ] + + self.current_page_name = None + for page in self.pages: + page.grid( + row=0, column=1, padx=5, pady=5, sticky="nsew", rowspan=2, columnspan=1 + ) + + self.change_page(PageSelectCamera.__name__) # Profile UI self.frame_profile_switcher = frames.FrameProfileSwitcher( - self.tk_root, main_gui_callback=self.root_function_callback) + self.tk_root, main_gui_callback=self.root_function_callback + ) self.frame_profile_editor = frames.FrameProfileEditor( - self.tk_root, main_gui_callback=self.root_function_callback) + self.tk_root, main_gui_callback=self.root_function_callback + ) def root_function_callback(self, function_name, args: dict = {}, **kwargs): logger.info(f"root_function_callback {function_name} with {args}") @@ -143,10 +104,8 @@ def root_function_callback(self, function_name, args: dict = {}, **kwargs): elif function_name == "refresh_profiles": logger.info("refresh_profile") - self.pages["page_gestures"].refresh_profile() - self.pages["page_camera"].refresh_profile() - self.pages["page_cursor"].refresh_profile() - self.pages["page_keyboard"].refresh_profile() + for page in self.pages: + page.refresh_profile() def cam_preview_callback(self, function_name, args: dict, **kwargs): logger.info(f"cam_preview_callback {function_name} with {args}") @@ -156,20 +115,21 @@ def cam_preview_callback(self, function_name, args: dict, **kwargs): def set_mediapipe_mouse_enable(self, new_state: bool): if new_state: + Keybinder().set_active(True) MouseController().set_active(True) else: + Keybinder().set_active(False) MouseController().set_active(False) def change_page(self, target_page_name: str): - - if self.curr_page_name == target_page_name: + if self.current_page_name == target_page_name: return - for name, page in self.pages.items(): - if name == target_page_name: + for page in self.pages: + if page.__class__.__name__ == target_page_name: page.grid() - self.pages[target_page_name].enter() - self.curr_page_name = target_page_name + page.enter() + self.current_page_name = page.__class__.__name__ else: page.grid_remove() @@ -182,7 +142,7 @@ def del_main_gui(self): self.frame_preview.destroy() self.frame_menu.leave() self.frame_menu.destroy() - for page in self.pages.values(): + for page in self.pages: page.leave() page.destroy() diff --git a/src/gui/pages/__init__.py b/src/gui/pages/__init__.py index 7b578760..78ac973d 100644 --- a/src/gui/pages/__init__.py +++ b/src/gui/pages/__init__.py @@ -1,19 +1,14 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from .page_cursor import * -from .page_home import * -from .page_keyboard import * -from .page_select_camera import * -from .page_select_gestures import * +__all__ = [ + "PageCursor", + "PageHome", + "PageKeyboard", + "PageSelectCamera", + "PageSelectGestures", + "PageAbout", +] +from .page_cursor import PageCursor +from .page_home import PageHome +from .page_keyboard import PageKeyboard +from .page_select_camera import PageSelectCamera +from .page_select_gestures import PageSelectGestures +from .page_about import PageAbout diff --git a/src/gui/pages/page_about.py b/src/gui/pages/page_about.py new file mode 100644 index 00000000..1400cc01 --- /dev/null +++ b/src/gui/pages/page_about.py @@ -0,0 +1,240 @@ +# Standard library imports. +# +# Logging module. +# https://docs.python.org/3.8/library/logging.html +import logging + +# +# Object oriented path handling. +# https://docs.python.org/3/library/pathlib.html + +# +# Tcl/Tk user interface module. +# https://docs.python.org/3/library/tkinter.html +# https://tkdocs.com/tutorial/text.html +from tkinter import Text +from tkinter.ttk import Label +from tkinter.font import Font + +# +# Browser launcher module. +# https://docs.python.org/3/library/webbrowser.html +import webbrowser + +# +# PIP modules. +# +# +# Local imports. +# +from src.gui.frames.safe_disposable_frame import SafeDisposableFrame +from src.config_manager import ConfigManager + +logger = logging.getLogger("PageAbout") + + +def log_path(description, path): + logger.info( + " ".join( + ( + description, + "".join(('"', str(path), '"')), + "Exists." if path.exists() else "Doesn't exist.", + ) + ) + ) + + +# Jim initially was using Python lambda expressions for the event handlers. That +# seemed to result in all tag_bind() calls being overridden to whichever was the +# last one called. So now the tags are set up here, outside the class, and +# lambda expressions aren't used. +addressTags = {} + + +def add_address_tag(pageAbout, address): + def _enter(event): + pageAbout.hover_enter(address, event) + + def _leave(event): + pageAbout.hover_leave(address, event) + + def _click(event): + pageAbout.open_in_browser(address, event) + + addressTag = { + "tag": f"address{len(addressTags)}", + "enter": _enter, + "leave": _leave, + "click": _click, + } + tagText = addressTag["tag"] + + pageAbout.text.tag_configure(tagText) + + # TOTH using tag_bind. https://stackoverflow.com/a/65733556/7657675 + # TOTH list of events that makes clear they have to be in angle brackets. + # https://stackoverflow.com/a/32289245/7657675 + # + # - is triggered when the pointer hovers here. + # - is triggered when the pointer stops hovering here. + # - <1> is triggered when this is clicked. It seems to be a shorthand for + # button-1. + pageAbout.text.tag_bind(tagText, "", addressTag["enter"]) + pageAbout.text.tag_bind(tagText, "", addressTag["leave"]) + pageAbout.text.tag_bind(tagText, "<1>", addressTag["click"]) + + addressTags[address] = addressTag + return addressTag + + +def add_address_tags(pageAbout, addresses): + # TOTH Underlining https://stackoverflow.com/a/44890599/7657675 + pageAbout.text.tag_configure("link", underline=True, foreground="#0000EE") + + for address in addresses: + add_address_tag(pageAbout, address) + + return addressTags + + +def tags_for(address): + try: + return ("link", addressTags[address]["tag"]) + except KeyError as keyError: + raise ValueError( + "Address hasn't been registered." + f' Add add_address_tag(pageAbout,"{address}")' + ) from keyError + + +class PageAbout(SafeDisposableFrame): + hoverCursor = "hand2" + + def __init__(self, master, **kwargs): + super().__init__(master, **kwargs) + + self.grid_rowconfigure(0, weight=1) + self.grid_rowconfigure(1, weight=0) + self.grid_columnconfigure(0, weight=1) + self.is_active = False + self.grid_propagate(False) + + # Create font objects for the fonts used on this page. + font24 = Font(family="Google Sans", size=24) + font18 = Font(family="Google Sans", size=18) + font12 = Font(family="Google Sans", size=12) + + # Handy code to log all font families. + # logger.info(font_families()) + + # Create the about page as a Text widget. The text won't be editable but + # that can't be set here because the widget will ignore the + # text.insert() method. Instead editing is disabled after the insert(). + self.text = Text( + self, + wrap="word", + borderwidth=0, + font=font12, + spacing1=20, + height=10, # height value has to be guessed it seems. + ) + + # Create tags for styling the page content. + self.text.tag_configure("h1", font=font24) + self.text.tag_configure("h2", font=font18) + + # Create a tag for every address in the about page. That seems to be the + # only way that event handlers can be bound. + add_address_tags( + self, + ( + "https://www.flaticon.com/free-icons/eye", + "https://github.com/acidcoke/Grimassist/", + "https://github.com/google/project-gameface", + ), + ) + logger.info(f"addressTags{addressTags}") + + # At time of coding, the app windows seems to be fixed size and the + # about page content fits in it. So there's no need for a scroll bar. If + # that changes then uncomment this code to add a scroll bar. + # + # from tkinter.ttk import Scrollbar + # self.scrollY = Scrollbar( + # self, orient = 'vertical', command = self.text.yview) + # self.text['yscrollcommand'] = self.scrollY.set + # self.scrollY.grid(column = 1, row = 0, sticky = 'ns') + + self.text.grid(row=0, column=0, padx=0, pady=5, sticky="new") + + # The argument tail is an alternating sequence of texts and tag lists. + # If the text is to be set in the default style then an empty tag list + # () is given. + self.text.insert( + "1.0", + "About Grimassist\n", + "h1", + f"Version {ConfigManager().version}\n" + "Control and move the pointer using head movements and facial" + " gestures.\nDisclaimer: This software isn't intended for medical" + " use.\nGrimassist is an Open Source project\n", + (), + "Attribution\n", + "h2", + "Blink graphics in the user interface are based on ", + (), + "Eye icons created by Kiranshastry - Flaticon", + tags_for("https://www.flaticon.com/free-icons/eye"), + ".\nThis software is based on ", + (), + "Google GameFace", + tags_for("https://github.com/google/project-gameface"), + ".", + (), + ) + self.text.configure(state="disabled") + + # When the pointer hovers over a link, change it to a hand. It might + # seem like that could be done by adding a configuration to the tag, + # which is how links are underlined. However, it seems like that doesn't + # work and the cursor cannot be configured at the tag level. The + # solution is to configure and reconfigure it dynmically, at the widget + # level, in the hover handlers. + # + # Discover the default cursor configuration here and store it. The value + # is used in the hover handlers. + self.initialCursor = self.text["cursor"] + + # Label to display the address of a link when it's hovered over. + # + # TBD make it transparent. For now it takes the background colour of the + # text control, above. + self.hoverLabel = Label( + self, text="", font=font12, background=self.text["background"] + ) + self.hoverLabel.grid(row=1, column=0, sticky="sw") + + def hover_enter(self, address, event): + logger.info(f"hover({address}, {event}) {event.type}") + # TOTH how to set the text of a label. + # https://stackoverflow.com/a/17126015/7657675 + self.hoverLabel.configure(text=address) + self.text.configure(cursor=self.hoverCursor) + + def hover_leave(self, address, event): + logger.info(f"hover({address}, {event}) {event.type}") + # TOTH how to set the text of a label. + # https://stackoverflow.com/a/17126015/7657675 + self.hoverLabel.configure(text="") + self.text.configure(cursor=self.initialCursor) + + def open_in_browser(self, address, event): + logger.info(f"open_in_browser({address}, {event})") + webbrowser.open(address) + + def enter(self): + super().enter() + + # Next line would opens the About file in the browser. + # open_in_browser(aboutHTML.as_uri(), None) diff --git a/src/gui/pages/page_cursor.py b/src/gui/pages/page_cursor.py index 20c4466c..4d3cf29b 100644 --- a/src/gui/pages/page_cursor.py +++ b/src/gui/pages/page_cursor.py @@ -1,17 +1,3 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import logging import tkinter from functools import partial @@ -32,7 +18,6 @@ class FrameSelectGesture(SafeDisposableFrame): - def __init__( self, master, @@ -45,100 +30,136 @@ def __init__( self.slider_dragging = False self.help_icon = customtkinter.CTkImage( Image.open("assets/images/help.png").resize(HELP_ICON_SIZE), - size=HELP_ICON_SIZE) + size=HELP_ICON_SIZE, + ) - self.shared_info_balloon = Balloon( - self, image_path="assets/images/balloon.png") + self.shared_info_balloon = Balloon(self, image_path="assets/images/balloon.png") # Slider divs - self.divs = self.create_divs({ - "Move up": ["spd_up", "", 1, 100], - "Move down": ["spd_down", "", 1, 100], - "Move right": ["spd_right", "", 1, 100], - "Move left": ["spd_left", "", 1, 100], - "(Advanced) Smooth pointer": [ - "pointer_smooth", - "Controls the smoothness of the\nmouse cursor. Enables the user\nto reduce jitteriness", - 1, 100 - ], - "(Advanced) Smooth blendshapes": [ - "shape_smooth", "Reduces the flickering of the action\ntrigger", - 1, 100 - ], - "(Advanced) Hold trigger delay(ms)": [ - "hold_trigger_ms", - "Controls how long the user should\nhold a gesture in milliseconds\nfor an action to trigger", - 1, MAX_HOLD_TRIG - ] - }) - + self.divs = self.create_divs( + { + "Move up": ["spd_up", "", 1, 100], + "Move down": ["spd_down", "", 1, 100], + "Move right": ["spd_right", "", 1, 100], + "Move left": ["spd_left", "", 1, 100], + "(Advanced) Smooth pointer": [ + "pointer_smooth", + "Controls the smoothness of the\nmouse cursor. Enables the user\nto reduce jitteriness", + 1, + 100, + ], + "(Advanced) Smooth blendshapes": [ + "shape_smooth", + "Reduces the flickering of the action\ntrigger", + 1, + 100, + ], + "(Advanced) Hold trigger delay(ms)": [ + "hold_trigger_ms", + "Controls how long the user should\nhold a gesture in milliseconds\nfor an action to trigger", + 1, + MAX_HOLD_TRIG, + ], + "(Advanced) Rapid fire interval(ms)": [ + "rapid_fire_interval_ms", + "Controls how much time should pass\nbetween each individual\ntriggering of the action", + 1, + MAX_HOLD_TRIG, + ], + } + ) + # Toggle label + self.toggle_label = customtkinter.CTkLabel( + master=self, compound="right", text="Cursor control", justify=tkinter.LEFT + ) + self.toggle_label.cget("font").configure(weight="bold") + self.toggle_label.grid(row=0, column=0, padx=(20, 0), pady=5, sticky="nw") + + # Toggle switch + self.toggle_switch = customtkinter.CTkSwitch( + master=self, + text="", + width=20, + border_color="transparent", + switch_height=18, + switch_width=32, + command=lambda: self.cursor_toggle_callback( + "toggle_switch", {"switch_status": self.toggle_switch.get()} + ), + variable=MouseController().is_enabled, + onvalue=1, + offvalue=0, + ) + if ConfigManager().config["enable"]: + self.toggle_switch.select() + + self.toggle_switch.grid(row=0, column=0, padx=(150, 0), pady=5, sticky="nw") self.load_initial_config() def load_initial_config(self): - """Load default from config and set the UI - """ + """Load default from config and set the UI""" for cfg_name, div in self.divs.items(): - cfg_value = int( - np.clip(ConfigManager().config[cfg_name], - a_min=1, - a_max=MAX_HOLD_TRIG)) + np.clip(ConfigManager().config[cfg_name], a_min=1, a_max=MAX_HOLD_TRIG) + ) div["slider"].set(cfg_value) # Temporary remove trace, adjust the value and put it back div["entry_var"].trace_vdelete("w", div["entry_trace_id"]) div["entry_var"].set(cfg_value) - div["entry_trace_id"] = div["entry_var"].trace( - "w", div["entry_trace_fn"]) + div["entry_trace_id"] = div["entry_var"].trace("w", div["entry_trace_fn"]) def create_divs(self, directions: dict): out_dict = {} - for idx, (show_name, (cfg_name, balloon_text, slider_min, - slider_max)) in enumerate(directions.items()): - + for idx, ( + show_name, + (cfg_name, balloon_text, slider_min, slider_max), + ) in enumerate(directions.items()): help_image = self.help_icon if balloon_text != "" else None # Label - label = customtkinter.CTkLabel(master=self, - image=help_image, - compound='right', - text=show_name, - justify=tkinter.LEFT) - label.cget("font").configure(weight='bold') - label.grid(row=idx, column=0, padx=20, pady=(10, 10), sticky="nw") + label = customtkinter.CTkLabel( + master=self, + image=help_image, + compound="right", + text=show_name, + justify=tkinter.LEFT, + ) + label.cget("font").configure(weight="bold") + label.grid(row=idx + 2, column=0, padx=20, pady=(10, 10), sticky="nw") self.shared_info_balloon.register_widget(label, balloon_text) # Slider - slider = customtkinter.CTkSlider(master=self, - from_=slider_min, - to=slider_max, - width=250, - number_of_steps=99, - command=partial( - self.slider_drag_callback, - cfg_name)) - slider.bind("", - partial(self.slider_mouse_down_callback, cfg_name)) - slider.bind("", - partial(self.slider_mouse_up_callback, cfg_name)) - slider.grid(row=idx, column=0, padx=30, pady=(40, 10), sticky="nw") + slider = customtkinter.CTkSlider( + master=self, + from_=slider_min, + to=slider_max, + width=250, + number_of_steps=99, + command=partial(self.slider_drag_callback, cfg_name), + ) + slider.bind( + "", partial(self.slider_mouse_down_callback, cfg_name) + ) + slider.bind( + "", partial(self.slider_mouse_up_callback, cfg_name) + ) + slider.grid(row=idx + 2, column=0, padx=30, pady=(40, 10), sticky="nw") # Number entry entry_var = tkinter.StringVar() - entry_trace_fn = partial(self.entry_changed_callback, cfg_name, - slider_min, slider_max) + entry_trace_fn = partial( + self.entry_changed_callback, cfg_name, slider_min, slider_max + ) entry_var_trace_id = entry_var.trace("w", entry_trace_fn) entry = customtkinter.CTkEntry( master=self, - validate='all', + validate="all", textvariable=entry_var, - #validatecommand=vcmd, - width=62) - entry.grid(row=idx, - column=0, - padx=(300, 5), - pady=(34, 10), - sticky="nw") + # validatecommand=vcmd, + width=62, + ) + entry.grid(row=idx + 2, column=0, padx=(300, 5), pady=(34, 10), sticky="nw") out_dict[cfg_name] = { "label": label, @@ -146,7 +167,7 @@ def create_divs(self, directions: dict): "entry": entry, "entry_var": entry_var, "entry_trace_id": entry_var_trace_id, - "entry_trace_fn": entry_trace_fn + "entry_trace_fn": entry_trace_fn, } return out_dict @@ -166,10 +187,10 @@ def validate_entry_input(self, P, slider_min, slider_max): else: return False - def entry_changed_callback(self, div_name, slider_min, slider_max, var, - index, mode): - """Update value with entery text - """ + def entry_changed_callback( + self, div_name, slider_min, slider_max, var, index, mode + ): + """Update value with entry text""" is_valid_input = True div = self.divs[div_name] @@ -180,7 +201,7 @@ def entry_changed_callback(self, div_name, slider_min, slider_max, var, is_valid_input = False else: new_value = int(entry_value) - if not new_value in range(slider_min, slider_max): + if new_value not in range(slider_min, slider_max): is_valid_input = False # Update slider and config @@ -197,8 +218,7 @@ def entry_changed_callback(self, div_name, slider_min, slider_max, var, div["entry"].configure(fg_color="#ee9e9d") def slider_drag_callback(self, div_name: str, new_value: str): - """Update value when slider being drag - """ + """Update value when slider being drag""" self.slider_dragging = True new_value = int(new_value) div = self.divs[div_name] @@ -218,40 +238,75 @@ def slider_mouse_up_callback(self, div_name: str, event): def inner_refresh_profile(self): self.load_initial_config() + def enable_cursor(self, new_state: bool): + new = {} + if new_state: + for cfg_name, div in self.divs.items(): + slider = div["slider"] + slider.configure( + state="normal", + fg_color="#D2E3FC", + progress_color="#1A73E8", + button_color="#1A73E8", + ) + div["slider"] = slider + new.update({cfg_name: div}) + else: + for cfg_name, div in self.divs.items(): + slider = div["slider"] + slider.configure( + state="disabled", + fg_color="lightgray", + progress_color="gray", + button_color="gray", + ) + div["slider"] = slider + new.update({cfg_name: div}) + self.divs = new + ConfigManager().set_temp_config(field="enable", value=new_state) + ConfigManager().apply_config() -class PageCursor(SafeDisposableFrame): + def cursor_toggle_callback(self, command, args: dict): + logger.info(f"cursor_toggle_callback {command} with {args}") + + if command == "toggle_switch": + self.enable_cursor(new_state=args["switch_status"]) + self.set_mediapipe_mouse_enable(new_state=args["switch_status"]) + def set_mediapipe_mouse_enable(self, new_state: bool): + if new_state: + MouseController().set_enabled(True) + else: + MouseController().set_enabled(False) + + +class PageCursor(SafeDisposableFrame): def __init__(self, master, **kwargs): super().__init__(master, **kwargs) - self.grid_rowconfigure(2, weight=1) - self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(2, weight=0) + self.grid_columnconfigure(0, weight=0) self.is_active = False self.task = {} # Top label. - self.top_label = customtkinter.CTkLabel(master=self, - text="Cursor speed") + self.top_label = customtkinter.CTkLabel(master=self, text="Cursor speed") self.top_label.cget("font").configure(size=24) - self.top_label.grid(row=0, - column=0, - padx=20, - pady=10, - sticky="nw", - columnspan=1) + self.top_label.grid( + row=0, column=0, padx=20, pady=10, sticky="nw", columnspan=1 + ) # Description. - des_txt = "Mouse cursor moves with your head movement. Use this settings to adjust how fast your mouse moves in each direction." - des_label = customtkinter.CTkLabel(master=self, - text=des_txt, - wraplength=300, - justify=tkinter.LEFT) + des_txt = "Adjust how the mouse cursor responds to your head movements." + des_label = customtkinter.CTkLabel( + master=self, text=des_txt, wraplength=300, justify=tkinter.LEFT + ) des_label.cget("font").configure(size=14) des_label.grid(row=1, column=0, padx=20, pady=5, sticky="nw") # Inner frame self.inner_frame = FrameSelectGesture(self) - self.inner_frame.grid(row=2, column=0, padx=5, pady=5, sticky="nw") + self.inner_frame.grid(row=4, column=0, padx=5, pady=5, sticky="nw") def refresh_profile(self): self.inner_frame.inner_refresh_profile() diff --git a/src/gui/pages/page_home.py b/src/gui/pages/page_home.py index 11b071f8..b0de4f83 100644 --- a/src/gui/pages/page_home.py +++ b/src/gui/pages/page_home.py @@ -1,17 +1,3 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import logging import tkinter from functools import partial @@ -26,7 +12,6 @@ class PageHome(SafeDisposableFrame): - def __init__(self, master, root_callback: callable, **kwargs): super().__init__(master, **kwargs) logging.info("Create PageHome") @@ -39,106 +24,112 @@ def __init__(self, master, root_callback: callable, **kwargs): # Top text top_label = customtkinter.CTkLabel( - master=self, text="Project Gameface Gesture Settings") + master=self, text="Grimassist Gesture Settings" + ) top_label.cget("font").configure(size=24) - top_label.grid(row=0, - column=0, - padx=20, - pady=20, - sticky="new", - columnspan=2) + top_label.grid(row=0, column=0, padx=20, pady=20, sticky="new", columnspan=2) # Description - des_txt = "Project Gameface helps gamers control their mouse cursor using their head movement and facial gestures." - des_label = customtkinter.CTkLabel(master=self, - text=des_txt, - wraplength=400, - justify=tkinter.CENTER) + des_txt = "Grimassist helps gamers control their mouse cursor using their head movement and facial gestures." + des_label = customtkinter.CTkLabel( + master=self, text=des_txt, wraplength=400, justify=tkinter.CENTER + ) des_label.cget("font").configure(size=14) - des_label.grid(row=1, - column=0, - padx=20, - pady=(5, 5), - sticky="new", - columnspan=2) + des_label.grid( + row=1, column=0, padx=20, pady=(5, 5), sticky="new", columnspan=2 + ) # Disclaimer - disc_txt = "Disclaimer: Project Gameface is not intended for medical use." - disc_label = customtkinter.CTkLabel(master=self, - text=disc_txt, - wraplength=700, - text_color="gray60", - justify=tkinter.CENTER) + disc_txt = "Disclaimer: Grimassist is not intended for medical use." + disc_label = customtkinter.CTkLabel( + master=self, + text=disc_txt, + wraplength=700, + text_color="gray60", + justify=tkinter.CENTER, + ) disc_label.cget("font").configure(size=14) - disc_label.grid(row=2, - column=0, - padx=20, - pady=(5, 10), - sticky="new", - columnspan=2) + disc_label.grid( + row=2, column=0, padx=20, pady=(5, 10), sticky="new", columnspan=2 + ) # Page camera btn page_camera_btn_im = customtkinter.CTkImage( - Image.open("assets/images/page_camera_btn.png"), size=BTN_SIZE) + Image.open("assets/images/page_camera_btn.png"), size=BTN_SIZE + ) page_camera_btn = customtkinter.CTkButton( master=self, text="", border_width=0, corner_radius=12, image=page_camera_btn_im, - command=partial(root_callback, - function_name="change_page", - args={"target": "page_camera"})) + command=partial( + root_callback, + function_name="change_page", + args={"target": "page_camera"}, + ), + ) page_camera_btn.grid(row=3, column=0, padx=80, pady=10, sticky="nw") # Page cursor btn page_cursor_btn_im = customtkinter.CTkImage( - Image.open("assets/images/page_cursor_btn.png"), size=BTN_SIZE) + Image.open("assets/images/page_cursor_btn.png"), size=BTN_SIZE + ) page_cursor_btn = customtkinter.CTkButton( master=self, text="", border_width=0, corner_radius=12, image=page_cursor_btn_im, - command=partial(root_callback, - function_name="change_page", - args={"target": "page_cursor"})) + command=partial( + root_callback, + function_name="change_page", + args={"target": "page_cursor"}, + ), + ) page_cursor_btn.grid(row=4, column=0, padx=80, pady=10, sticky="nw") # Page gestures btn page_gestures_btn_im = customtkinter.CTkImage( - Image.open("assets/images/page_gestures_btn.png"), size=BTN_SIZE) + Image.open("assets/images/page_gestures_btn.png"), size=BTN_SIZE + ) page_gestures_btn = customtkinter.CTkButton( master=self, text="", border_width=0, corner_radius=12, image=page_gestures_btn_im, - command=partial(root_callback, - function_name="change_page", - args={"target": "page_gestures"})) + command=partial( + root_callback, + function_name="change_page", + args={"target": "page_gestures"}, + ), + ) page_gestures_btn.grid(row=5, column=0, padx=80, pady=10, sticky="nw") # Page keyboard btn page_keyboard_btn_im = customtkinter.CTkImage( - Image.open("assets/images/page_keyboard_btn.png"), size=BTN_SIZE) + Image.open("assets/images/page_keyboard_btn.png"), size=BTN_SIZE + ) page_keyboard_btn = customtkinter.CTkButton( master=self, text="", border_width=0, corner_radius=12, image=page_keyboard_btn_im, - command=partial(root_callback, - function_name="change_page", - args={"target": "page_keyboard"})) + command=partial( + root_callback, + function_name="change_page", + args={"target": "page_keyboard"}, + ), + ) page_keyboard_btn.grid(row=6, column=0, padx=80, pady=10, sticky="nw") # home image home_im = customtkinter.CTkImage( - Image.open("assets/images/home_im.png"), size=HOME_IM_SIZE) - label = customtkinter.CTkLabel(self, - image=home_im, - width=HOME_IM_SIZE[0], - height=HOME_IM_SIZE[1], - text="") + Image.open("assets/images/home_im.png"), size=HOME_IM_SIZE + ) + label = customtkinter.CTkLabel( + self, image=home_im, width=HOME_IM_SIZE[0], height=HOME_IM_SIZE[1], text="" + ) label.grid(row=3, column=1, padx=20, pady=20, rowspan=3, sticky="we") diff --git a/src/gui/pages/page_keyboard.py b/src/gui/pages/page_keyboard.py index 464d2c48..2236e99d 100644 --- a/src/gui/pages/page_keyboard.py +++ b/src/gui/pages/page_keyboard.py @@ -1,17 +1,3 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import logging import tkinter as tk import uuid @@ -25,7 +11,11 @@ from src.detectors import FaceMesh from src.gui.balloon import Balloon from src.gui.dropdown import Dropdown -from src.gui.frames.safe_disposable_frame import SafeDisposableFrame, SafeDisposableScrollableFrame +from src.gui.frames.safe_disposable_frame import SafeDisposableFrame +from src.gui.frames.safe_disposable_scrollable_frame import ( + SafeDisposableScrollableFrame, +) +from src.utils.Trigger import Trigger logger = logging.getLogger("PageKeyboard") @@ -46,7 +36,6 @@ class FrameSelectKeyboard(SafeDisposableScrollableFrame): - def __init__( self, master, @@ -59,33 +48,38 @@ def __init__( self.grid_columnconfigure(1, weight=1) # Float UIs - self.shared_info_balloon = Balloon( - self, image_path="assets/images/balloon.png") + self.shared_info_balloon = Balloon(self, image_path="assets/images/balloon.png") self.shared_dropdown = Dropdown( self, dropdown_items=shape_list.available_gestures, width=DIV_WIDTH, - callback=self.dropdown_callback) + callback=self.dropdown_callback, + ) self.help_icon = customtkinter.CTkImage( Image.open("assets/images/help.png").resize(HELP_ICON_SIZE), - size=HELP_ICON_SIZE) + size=HELP_ICON_SIZE, + ) self.a_button_image = customtkinter.CTkImage( Image.open("assets/images/a_button.png").resize(A_BUTTON_SIZE), - size=A_BUTTON_SIZE) + size=A_BUTTON_SIZE, + ) - self.blank_a_button_image = customtkinter.CTkImage(Image.open( - "assets/images/blank_a_button.png").resize(A_BUTTON_SIZE), - size=A_BUTTON_SIZE) + self.blank_a_button_image = customtkinter.CTkImage( + Image.open("assets/images/blank_a_button.png").resize(A_BUTTON_SIZE), + size=A_BUTTON_SIZE, + ) - self.a_button_active_image = customtkinter.CTkImage(Image.open( - "assets/images/a_button_active.png").resize(A_BUTTON_SIZE), - size=A_BUTTON_SIZE) + self.a_button_active_image = customtkinter.CTkImage( + Image.open("assets/images/a_button_active.png").resize(A_BUTTON_SIZE), + size=A_BUTTON_SIZE, + ) self.bin_image = customtkinter.CTkImage( Image.open("assets/images/bin.png").resize(BIN_ICON_SIZE), - size=BIN_ICON_SIZE) + size=BIN_ICON_SIZE, + ) self.wait_for_key_bind_id = None self.next_empty_row = 0 @@ -95,17 +89,16 @@ def __init__( # Divs self.divs = {} - self.load_initial_keybinds() + self.load_initial_keybindings() - def load_initial_keybinds(self): - """Load default from config and set the UI - """ - for gesture_name, bind_info in ConfigManager().keyboard_bindings.items( - ): + def load_initial_keybindings(self): + """Load default from config and set the UI""" + for gesture_name, bind_info in ConfigManager().keyboard_bindings.items(): div_name = f"div_{self.next_empty_row}" - div = self.create_div(self.next_empty_row, div_name, gesture_name, - bind_info) + div = self.create_div( + self.next_empty_row, div_name, gesture_name, bind_info + ) # Show elements related to gesture div["selected_gesture"] = gesture_name @@ -117,6 +110,7 @@ def load_initial_keybinds(self): div["subtle_label"].grid() div["slider"].grid() div["volume_bar"].grid() + div["trigger_dropdown"].grid() self.shared_dropdown.disable_item(gesture_name) self.divs[div_name] = div self.next_empty_row += 1 @@ -128,10 +122,12 @@ def add_blank_div(self): new_uuid = uuid.uuid1() div_name = f"div_{new_uuid}" logger.info(f"Add {div_name}") - div = self.create_div(row=self.next_empty_row, - div_name=div_name, - gesture_name="None", - bind_info=["keyboard", "None", 0.5, "hold"]) + div = self.create_div( + row=self.next_empty_row, + div_name=div_name, + gesture_name="None", + bind_info=["keyboard", "None", 0.5, "hold"], + ) self.divs[div_name] = div self.next_empty_row += 1 @@ -140,9 +136,8 @@ def add_blank_div(self): def remove_keybind(self, selected_key_action, selected_gesture): logger.info(f"Remove keyboard binding {selected_key_action}") ConfigManager().remove_temp_keyboard_binding( - device="keyboard", - key_action=selected_key_action, - gesture=selected_gesture) + device="keyboard", key_action=selected_key_action, gesture=selected_gesture + ) ConfigManager().apply_keyboard_bindings() self.shared_dropdown.hide_dropdown() self.shared_dropdown.enable_item(selected_gesture) @@ -161,77 +156,76 @@ def remove_div(self, div_name): for widget in div.values(): if isinstance( - widget, customtkinter.windows.widgets.core_widget_classes. - CTkBaseClass): + widget, customtkinter.windows.widgets.core_widget_classes.CTkBaseClass + ): widget.grid_forget() widget.destroy() - def create_div(self, row: int, div_name: str, gesture_name: str, - bind_info: list): + def create_div(self, row: int, div_name: str, gesture_name: str, bind_info: list): _, key_action, thres, _ = bind_info # Bin button - remove_button = customtkinter.CTkButton(master=self, - text="", - hover=False, - image=self.bin_image, - fg_color="white", - anchor="e", - cursor="hand2", - width=25) + remove_button = customtkinter.CTkButton( + master=self, + text="", + hover=False, + image=self.bin_image, + fg_color="white", + anchor="e", + cursor="hand2", + width=25, + ) remove_button.cget("font").configure(size=18) - remove_button.bind("", - partial(self.bin_button_callback, div_name)) + remove_button.bind( + "", partial(self.bin_button_callback, div_name) + ) - remove_button.grid(row=row, - column=0, - padx=(142, 0), - pady=(18, 10), - sticky="nw") + remove_button.grid(row=row, column=0, padx=(142, 0), pady=(18, 10), sticky="nw") # Key entry field_txt = "" if key_action == "None" else key_action - entry_field = customtkinter.CTkLabel(master=self, - text=field_txt, - image=self.a_button_image, - width=A_BUTTON_SIZE[0], - height=A_BUTTON_SIZE[1], - cursor="hand2") + entry_field = customtkinter.CTkLabel( + master=self, + text=field_txt, + image=self.a_button_image, + width=A_BUTTON_SIZE[0], + height=A_BUTTON_SIZE[1], + cursor="hand2", + ) entry_field.cget("font").configure(size=17) entry_field.bind( "", - partial(self.button_click_callback, div_name, entry_field)) + partial(self.button_click_callback, div_name, entry_field), + ) - entry_field.grid(row=row, - column=0, - padx=PAD_X, - pady=(10, 10), - sticky="nw") + entry_field.grid(row=row, column=0, padx=PAD_X, pady=(10, 10), sticky="nw") # Combobox - drop = customtkinter.CTkOptionMenu(master=self, - values=[gesture_name], - width=DIV_WIDTH, - dynamic_resizing=False, - state="disabled") + drop = customtkinter.CTkOptionMenu( + master=self, + values=[gesture_name], + width=DIV_WIDTH, + dynamic_resizing=False, + state="disabled", + ) drop.grid(row=row, column=0, padx=PAD_X, pady=(64, 10), sticky="nw") + drop.grid_remove() self.shared_dropdown.register_widget(drop, div_name) # Label ? - tips_label = customtkinter.CTkLabel(master=self, - image=self.help_icon, - compound='right', - text="Gesture size", - text_color="#5E5E5E", - justify='left') + tips_label = customtkinter.CTkLabel( + master=self, + image=self.help_icon, + compound="right", + text="Gesture size", + text_color="#5E5E5E", + justify="left", + ) tips_label.cget("font").configure(size=12) - tips_label.grid(row=row, - column=0, - padx=PAD_X, - pady=(92, 10), - sticky="nw") + tips_label.grid(row=row, column=0, padx=PAD_X, pady=(92, 10), sticky="nw") + tips_label.grid_remove() self.shared_info_balloon.register_widget(tips_label, BALLOON_TXT) # Volume bar @@ -240,52 +234,55 @@ def create_div(self, row: int, div_name: str, gesture_name: str, width=DIV_WIDTH, ) - volume_bar.grid(row=row, - column=0, - padx=PAD_X, - pady=(122, 10), - sticky="nw") + volume_bar.grid(row=row, column=0, padx=PAD_X, pady=(122, 10), sticky="nw") + + volume_bar.grid_remove() # Slider - slider = customtkinter.CTkSlider(master=self, - from_=1, - to=100, - width=DIV_WIDTH + 10, - number_of_steps=100, - command=partial( - self.slider_drag_callback, - div_name)) + slider = customtkinter.CTkSlider( + master=self, + from_=1, + to=100, + width=DIV_WIDTH + 10, + number_of_steps=100, + command=partial(self.slider_drag_callback, div_name), + ) slider.set(thres * 100) - slider.bind("", - partial(self.slider_mouse_down_callback, div_name)) - slider.bind("", - partial(self.slider_mouse_up_callback, div_name)) + slider.bind("", partial(self.slider_mouse_down_callback, div_name)) + slider.bind( + "", partial(self.slider_mouse_up_callback, div_name) + ) - slider.grid(row=row, - column=0, - padx=PAD_X - 5, - pady=(142, 10), - sticky="nw") + slider.grid(row=row, column=0, padx=PAD_X - 5, pady=(142, 10), sticky="nw") + + slider.grid_remove() # Subtle, Exaggerated - subtle_label = customtkinter.CTkLabel(master=self, - text="Subtle\t\t\t Exaggerated", - text_color="#868686", - justify=tk.LEFT) + subtle_label = customtkinter.CTkLabel( + master=self, + text="Subtle\t\t\t Exaggerated", + text_color="#868686", + justify=tk.LEFT, + ) subtle_label.cget("font").configure(size=11) - subtle_label.grid(row=row, - column=0, - padx=PAD_X, - pady=(158, 10), - sticky="nw") - - # Hide element related to gesture - drop.grid_remove() - tips_label.grid_remove() - slider.grid_remove() - volume_bar.grid_remove() + subtle_label.grid(row=row, column=0, padx=PAD_X, pady=(158, 10), sticky="nw") subtle_label.grid_remove() + # Trigger dropdown + trigger_list = [t.value for t in Trigger] + trigger_dropdown = customtkinter.CTkOptionMenu( + master=self, + values=trigger_list, + width=240, + dynamic_resizing=False, + state="normal", + ) + trigger_dropdown.grid( + row=row, column=0, padx=PAD_X, pady=(186, 10), sticky="nw" + ) + + trigger_dropdown.grid_remove() + return { "entry_field": entry_field, "combobox": drop, @@ -295,34 +292,37 @@ def create_div(self, row: int, div_name: str, gesture_name: str, "subtle_label": subtle_label, "selected_gesture": gesture_name, "selected_key_action": key_action, - "remove_button": remove_button + "remove_button": remove_button, + "trigger_dropdown": trigger_dropdown, } def set_new_keyboard_binding(self, div): - # Remove keybind if set to invalid key - if (div["selected_gesture"] == "None") or (div["selected_key_action"] - == "None"): + if (div["selected_gesture"] == "None") or ( + div["selected_key_action"] == "None" + ): logger.info(f"Remove keyboard binding {div['selected_key_action']}") ConfigManager().remove_temp_keyboard_binding( - device="keyboard", gesture=div["selected_gesture"]) + device="keyboard", gesture=div["selected_gesture"] + ) ConfigManager().apply_keyboard_bindings() return # Set the keybinding thres_value = div["slider"].get() / 100 + trigger = Trigger(div["trigger_dropdown"].get()) ConfigManager().set_temp_keyboard_binding( device="keyboard", key_action=div["selected_key_action"], gesture=div["selected_gesture"], threshold=thres_value, - trigger_type=DEFAULT_TRIGGER_TYPE) + trigger=trigger, + ) ConfigManager().apply_keyboard_bindings() def wait_for_key(self, div_name: str, entry_button, keydown: tk.Event): - """Wait for user to press any key then set the config - """ + """Wait for user to press any key then set the config""" if div_name not in self.divs: return if self.waiting_div is None: @@ -330,19 +330,18 @@ def wait_for_key(self, div_name: str, entry_button, keydown: tk.Event): div = self.divs[div_name] - keydown_txt = keydown.keysym.lower() if isinstance( - keydown, tk.Event) else keydown + keydown_txt = ( + keydown.keysym.lower() if isinstance(keydown, tk.Event) else keydown + ) logger.info(f"Key press: <{div_name}> {keydown_txt}") - occupied_keys = [ - div["entry_field"].cget("text") for div in self.divs.values() - ] + occupied_keys = [div["entry_field"].cget("text") for div in self.divs.values()] # Not valid key if (keydown_txt in occupied_keys) or ( - keydown_txt not in shape_list.available_keyboard_keys): - logger.info( - f"Key action <{keydown_txt}> not found in available list") + keydown_txt not in shape_list.available_keyboard_keys + ): + logger.info(f"Key action <{keydown_txt}> not found in available list") entry_button.configure(image=self.a_button_image) div["selected_key_action"] = "None" div["slider"].grid_remove() @@ -350,6 +349,7 @@ def wait_for_key(self, div_name: str, entry_button, keydown: tk.Event): div["volume_bar"].grid_remove() div["tips_label"].grid_remove() div["subtle_label"].grid_remove() + div["trigger_dropdown"].grid_remove() self.set_new_keyboard_binding(div) # Valid key @@ -370,6 +370,7 @@ def wait_for_key(self, div_name: str, entry_button, keydown: tk.Event): div["volume_bar"].grid() div["tips_label"].grid() div["subtle_label"].grid() + div["trigger_dropdown"].grid() if self.wait_for_key_bind_id is not None: self.waiting_button.unbind("", self.wait_for_key_bind_id) @@ -378,15 +379,15 @@ def wait_for_key(self, div_name: str, entry_button, keydown: tk.Event): self.wait_for_key_bind_id = None def button_click_callback(self, div_name, entry_button, event): - """Start wait_for_key after clicked the button - """ - # Cancel old waiting funciontion + """Start wait_for_key after clicked the button""" + # Cancel old waiting function if self.waiting_div is not None: self.wait_for_key(self.waiting_div, self.waiting_button, "cancel") # Start waiting for key press self.wait_for_key_bind_id = entry_button.bind( - "", partial(self.wait_for_key, div_name, entry_button)) + "", partial(self.wait_for_key, div_name, entry_button) + ) entry_button.focus_set() entry_button.configure(text="") @@ -395,7 +396,6 @@ def button_click_callback(self, div_name, entry_button, event): self.waiting_button = entry_button def dropdown_callback(self, div_name: str, target_gesture: str): - div = self.divs[div_name] # Release old item @@ -409,18 +409,19 @@ def dropdown_callback(self, div_name: str, target_gesture: str): div["volume_bar"].grid() div["tips_label"].grid() div["subtle_label"].grid() + div["trigger_dropdown"].grid() else: div["slider"].grid_remove() div["volume_bar"].grid_remove() div["tips_label"].grid_remove() div["subtle_label"].grid_remove() + div["trigger_dropdown"].grid_remove() self.set_new_keyboard_binding(div) self.refresh_scrollbar() def slider_drag_callback(self, div_name: str, new_value: str): - """Update value when slider being drag - """ + """Update value when slider being drag""" self.slider_dragging = True new_value = int(new_value) if div_name in self.divs: @@ -432,17 +433,14 @@ def slider_mouse_down_callback(self, div_name: str, event): self.slider_dragging = True def slider_mouse_up_callback(self, div_name: str, event): - self.slider_dragging = False div = self.divs[div_name] self.set_new_keyboard_binding(div) def update_volume_preview(self): - bs = FaceMesh().get_blendshapes() for div in self.divs.values(): - if div["selected_gesture"] == "None": continue @@ -467,8 +465,7 @@ def frame_loop(self): return def inner_refresh_profile(self): - """Refresh the page divs to match the new profile - """ + """Refresh the page divs to match the new profile""" # Remove old divs self.next_empty_row = 0 for div_name, div in self.divs.items(): @@ -476,7 +473,7 @@ def inner_refresh_profile(self): self.divs = {} # Create new divs form the new profile - self.load_initial_keybinds() + self.load_initial_keybindings() def enter(self): super().enter() @@ -488,7 +485,6 @@ def leave(self): class PageKeyboard(SafeDisposableFrame): - def __init__(self, master, **kwargs): super().__init__(master, **kwargs) @@ -499,42 +495,31 @@ def __init__(self, master, **kwargs): self.bind_id_leave = None # Top label. - self.top_label = customtkinter.CTkLabel(master=self, - text="Keyboard binding") + self.top_label = customtkinter.CTkLabel(master=self, text="Keyboard binding") self.top_label.cget("font").configure(size=24) - self.top_label.grid(row=0, - column=0, - padx=20, - pady=5, - sticky="nw", - columnspan=1) + self.top_label.grid(row=0, column=0, padx=20, pady=5, sticky="nw", columnspan=1) # Description. des_txt = "Select a facial gesture that you would like to bind to a specific keyboard key. Sensitivity allows you to control the extent to which you need to gesture to trigger the keyboard key press" - des_label = customtkinter.CTkLabel(master=self, - text=des_txt, - wraplength=300, - justify=tk.LEFT) # + des_label = customtkinter.CTkLabel( + master=self, text=des_txt, wraplength=300, justify=tk.LEFT + ) # des_label.cget("font").configure(size=14) des_label.grid(row=1, column=0, padx=20, pady=(10, 40), sticky="nw") # Inner frame - self.inner_frame = FrameSelectKeyboard( - self, logger_name="FrameSelectKeyboard") + self.inner_frame = FrameSelectKeyboard(self, logger_name="FrameSelectKeyboard") self.inner_frame.grid(row=3, column=0, padx=5, pady=5, sticky="nswe") - # Add binding butotn + # Add binding button self.add_binding_button = customtkinter.CTkButton( master=self, text="+ Add binding", fg_color="white", text_color=BLUE, - command=self.inner_frame.add_blank_div) - self.add_binding_button.grid(row=2, - column=0, - padx=5, - pady=5, - sticky="nw") + command=self.inner_frame.add_blank_div, + ) + self.add_binding_button.grid(row=2, column=0, padx=5, pady=5, sticky="nw") def enter(self): super().enter() @@ -542,7 +527,8 @@ def enter(self): # Hide dropdown when mouse leave the frame self.bind_id_leave = self.bind( - "", self.inner_frame.shared_dropdown.hide_dropdown) + "", self.inner_frame.shared_dropdown.hide_dropdown + ) def refresh_profile(self): self.inner_frame.inner_refresh_profile() diff --git a/src/gui/pages/page_select_camera.py b/src/gui/pages/page_select_camera.py index e718fe8c..04950fb1 100644 --- a/src/gui/pages/page_select_camera.py +++ b/src/gui/pages/page_select_camera.py @@ -1,20 +1,7 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import logging import tkinter +from src import utils import customtkinter from PIL import Image, ImageTk @@ -31,7 +18,6 @@ class PageSelectCamera(SafeDisposableFrame): - def __init__(self, master, **kwargs): super().__init__(master, **kwargs) @@ -41,12 +27,7 @@ def __init__(self, master, **kwargs): # Top text top_label = customtkinter.CTkLabel(master=self, text="Camera") top_label.cget("font").configure(size=24) - top_label.grid(row=0, - column=0, - padx=20, - pady=20, - sticky="nw", - columnspan=2) + top_label.grid(row=0, column=0, padx=20, pady=20, sticky="nw", columnspan=2) # Label self.label = customtkinter.CTkLabel(master=self, text="Select a Camera") @@ -54,70 +35,75 @@ def __init__(self, master, **kwargs): self.label.grid(row=1, column=0, padx=10, pady=(20, 10), sticky="nw") # Empty radio buttons - self.radio_var = tkinter.IntVar(self, 0) + self.radio_var = tkinter.IntVar(value=0) self.prev_radio_value = None - self.radios = [] + self.radio_buttons = [] # Camera canvas - self.placeholder_im = Image.open( - "assets/images/placeholder.png").resize( - (CANVAS_WIDTH, CANVAS_HEIGHT)) + self.placeholder_im = Image.open("assets/images/placeholder.png").resize( + (CANVAS_WIDTH, CANVAS_HEIGHT) + ) self.placeholder_im = ImageTk.PhotoImage(self.placeholder_im) - self.canvas = tkinter.Canvas(master=self, - width=CANVAS_WIDTH, - height=CANVAS_HEIGHT) - self.canvas.grid(row=1, - column=1, - padx=(10, 50), - pady=10, - sticky="e", - rowspan=MAX_ROWS) + self.canvas = tkinter.Canvas( + master=self, width=CANVAS_WIDTH, height=CANVAS_HEIGHT + ) + self.canvas.grid( + row=1, column=1, padx=(10, 50), pady=10, sticky="e", rowspan=MAX_ROWS + ) # Set first image. - self.canvas_im = self.canvas.create_image(0, - 0, - image=self.placeholder_im, - anchor=tkinter.NW) + self.canvas_im = self.canvas.create_image( + 0, 0, image=self.placeholder_im, anchor=tkinter.NW + ) self.new_photo = None self.latest_camera_list = [] - def load_initial_config(self): - """ Update radio buttons to match CameraManager - """ - logger.info("Refresh radio buttons") - for old_radio in self.radios: - old_radio.destroy() - + def update_radio_buttons(self): + """Update radio_buttons to match CameraManager""" new_camera_list = CameraManager().get_camera_list() - logger.info(f"Get camera list {new_camera_list}") - radios = [] - for row_i, cam_id in enumerate(new_camera_list): - - radio = customtkinter.CTkRadioButton(master=self, - text=f"Camera {cam_id}", - command=self.radiobutton_event, - variable=self.radio_var, - value=cam_id) - - radio.grid(row=row_i + 2, column=0, padx=50, pady=10, sticky="w") - radios.append(radio) - - # Set radio select - target_id = ConfigManager().config["camera_id"] - self.radios = radios - for radio in self.radios: - if f"Camera {target_id}" == radio.cget("text"): - radio.select() - self.prev_radio_value = self.radio_var.get() - logger.info(f"Set initial camera to {target_id}") - break + if len(self.latest_camera_list) != len(new_camera_list): + self.latest_camera_list = new_camera_list + logger.info("Refresh radio_buttons") + old_radios = self.radio_buttons + + logger.info(f"Get camera list {new_camera_list}") + radio_buttons = [] + for row_i, cam_id in enumerate(new_camera_list): + radio_text = f"Camera {cam_id}" + + cam_name = utils.get_camera_name(cam_id) + if cam_name is not None: + radio_text = f"{radio_text}: {cam_name}" + + radio_button = customtkinter.CTkRadioButton( + master=self, + text=radio_text, + command=self.radiobutton_event, + variable=self.radio_var, + value=cam_id, + ) + + radio_button.grid(row=row_i + 2, column=0, padx=50, pady=10, sticky="w") + radio_buttons.append(radio_button) + + # Set selected radio_button + target_id = ConfigManager().config["camera_id"] + self.radio_buttons = radio_buttons + for radio_button in self.radio_buttons: + if f"Camera {target_id}" == radio_button.cget("text"): + radio_button.select() + self.prev_radio_value = self.radio_var.get() + logger.info(f"Set initial camera to {target_id}") + break + for old_radio in old_radios: + old_radio.destroy() def radiobutton_event(self): # Open new camera. new_radio_value = self.radio_var.get() if new_radio_value == self.prev_radio_value: return - logger.info(f"Change cameara: {new_radio_value}") + logger.info(f"Change camera: {new_radio_value}") CameraManager().pick_camera(new_radio_value) ConfigManager().set_temp_config("camera_id", new_radio_value) ConfigManager().apply_config() @@ -128,24 +114,24 @@ def page_loop(self): return if self.is_active: - frame_rgb = CameraManager().get_raw_frame() # Assign ref to avoid garbage collected self.new_photo = ImageTk.PhotoImage( - image=Image.fromarray(frame_rgb).resize((CANVAS_WIDTH, - CANVAS_HEIGHT))) + image=Image.fromarray(frame_rgb).resize((CANVAS_WIDTH, CANVAS_HEIGHT)) + ) self.canvas.itemconfig(self.canvas_im, image=self.new_photo) self.canvas.update() - self.after(ConfigManager().config["tick_interval_ms"], - self.page_loop) + CameraManager().thread_cameras.assign_done_flag.wait() + self.update_radio_buttons() + self.after(ConfigManager().config["tick_interval_ms"], self.page_loop) def enter(self): super().enter() - self.load_initial_config() + self.update_radio_buttons() self.after(1, self.page_loop) def refresh_profile(self): - self.load_initial_config() + self.update_radio_buttons() new_camera_id = ConfigManager().config["camera_id"] CameraManager().pick_camera(new_camera_id) diff --git a/src/gui/pages/page_select_gestures.py b/src/gui/pages/page_select_gestures.py index 2a070987..fef800d9 100644 --- a/src/gui/pages/page_select_gestures.py +++ b/src/gui/pages/page_select_gestures.py @@ -1,17 +1,3 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import tkinter as tk from functools import partial @@ -24,11 +10,12 @@ from src.gui.balloon import Balloon from src.gui.dropdown import Dropdown from src.gui.frames.safe_disposable_frame import SafeDisposableFrame +from src.utils.Trigger import Trigger MAX_ROWS = 2 HELP_ICON_SIZE = (18, 18) DIV_WIDTH = 240 -DEFAULT_TRIGGER_TYPE = "single" +DEFAULT_TRIGGER_TYPE = "dynamic" GREEN = "#34A853" YELLOW = "#FABB05" @@ -36,7 +23,6 @@ class FrameSelectGesture(SafeDisposableFrame): - def __init__( self, master, @@ -49,22 +35,24 @@ def __init__( self.grid_columnconfigure(1, weight=1) # Float UIs - self.shared_info_balloon = Balloon( - self, image_path="assets/images/balloon.png") + self.shared_info_balloon = Balloon(self, image_path="assets/images/balloon.png") self.shared_dropdown = Dropdown( self, dropdown_items=shape_list.available_gestures, width=DIV_WIDTH, - callback=self.dropdown_callback) + callback=self.dropdown_callback, + ) self.help_icon = customtkinter.CTkImage( Image.open("assets/images/help.png").resize(HELP_ICON_SIZE), - size=HELP_ICON_SIZE) + size=HELP_ICON_SIZE, + ) # Divs - self.divs = self.create_divs(shape_list.available_actions_keys, - shape_list.available_gestures_keys) - self.load_initial_keybinds() + self.divs = self.create_divs( + shape_list.available_actions_keys, shape_list.available_gestures_keys + ) + self.load_initial_keybindings() self.slider_dragging = False def set_div_inactive(self, div): @@ -76,35 +64,41 @@ def set_div_inactive(self, div): div["subtle_label"].grid_remove() div["slider"].grid_remove() div["volume_bar"].grid_remove() + div["trigger_dropdown"].set(DEFAULT_TRIGGER_TYPE) + div["trigger_dropdown"].grid_remove() - def set_div_active(self, div, gesture_name, thres): + def set_div_active(self, div, gesture_name, thres, trigger): div["selected_gesture"] = gesture_name div["combobox"].set(gesture_name) div["slider"].set(int(thres * 100)) div["slider"].configure(state="normal") + div["trigger_dropdown"].configure(state="normal") + div["trigger_dropdown"].set(trigger) div["tips_label"].grid() div["subtle_label"].grid() div["slider"].grid() div["volume_bar"].grid() + div["trigger_dropdown"].grid() - def load_initial_keybinds(self): - """Load default from config and set the UI - """ + def load_initial_keybindings(self): + """Load default from config and set the UI""" for div_name, div in self.divs.items(): self.set_div_inactive(div) for gesture_name, ( - device, action_key, thres, - trigger_type) in ConfigManager().mouse_bindings.items(): + device, + action_key, + thres, + trigger_type, + ) in ConfigManager().mouse_bindings.items(): if [device, action_key] not in shape_list.available_actions_values: continue - action_idx = shape_list.available_actions_values.index( - [device, action_key]) + action_idx = shape_list.available_actions_values.index([device, action_key]) target_action_name = shape_list.available_actions_keys[action_idx] div = self.divs[target_action_name] - self.set_div_active(div, gesture_name, thres) + self.set_div_active(div, gesture_name, thres, trigger_type) self.shared_dropdown.disable_item(gesture_name) self.shared_dropdown.refresh_items() @@ -116,45 +110,41 @@ def create_divs(self, action_list: list, gesture_list: list): column = idx // (MAX_ROWS + 1) # Action label - label = customtkinter.CTkLabel(master=self, - text=action_name, - height=175, - width=300, - anchor='nw', - justify=tk.LEFT) - label.cget("font").configure(weight='bold') - label.grid(row=row, - column=column, - padx=(20, 20), - pady=(0, 0), - sticky="nw") + label = customtkinter.CTkLabel( + master=self, + text=action_name, + height=200, + width=300, + anchor="nw", + justify=tk.LEFT, + ) + label.cget("font").configure(weight="bold") + label.grid(row=row, column=column, padx=(20, 20), pady=(0, 0), sticky="nw") # Combobox - drop = customtkinter.CTkOptionMenu(master=self, - values=[gesture_list[0]], - width=240, - dynamic_resizing=False, - state="disabled") - drop.grid(row=row, - column=column, - padx=(20, 20), - pady=(28, 10), - sticky="nw") + drop = customtkinter.CTkOptionMenu( + master=self, + values=[gesture_list[0]], + width=240, + dynamic_resizing=False, + state="disabled", + ) + drop.grid(row=row, column=column, padx=(20, 20), pady=(28, 10), sticky="nw") self.shared_dropdown.register_widget(drop, action_name) # Label ? - tips_label = customtkinter.CTkLabel(master=self, - image=self.help_icon, - compound='right', - text="Gesture size", - text_color="#5E5E5E", - justify='left') + tips_label = customtkinter.CTkLabel( + master=self, + image=self.help_icon, + compound="right", + text="Gesture size", + text_color="#5E5E5E", + justify="left", + ) tips_label.cget("font").configure(size=12) - tips_label.grid(row=row, - column=column, - padx=(20, 20), - pady=(62, 10), - sticky="nw") + tips_label.grid( + row=row, column=column, padx=(20, 20), pady=(62, 10), sticky="nw" + ) tips_label.grid_remove() self.shared_info_balloon.register_widget(tips_label, BALLOON_TXT) @@ -163,32 +153,30 @@ def create_divs(self, action_list: list, gesture_list: list): master=self, width=240, ) - volume_bar.grid(row=row, - column=column, - padx=(20, 20), - pady=(92, 10), - sticky="nw") + volume_bar.grid( + row=row, column=column, padx=(20, 20), pady=(92, 10), sticky="nw" + ) volume_bar.grid_remove() # Slider - slider = customtkinter.CTkSlider(master=self, - from_=1, - to=100, - width=250, - number_of_steps=100, - command=partial( - self.slider_drag_callback, - action_name)) - slider.bind("", - partial(self.slider_mouse_down_callback, action_name)) - slider.bind("", - partial(self.slider_mouse_up_callback, action_name)) + slider = customtkinter.CTkSlider( + master=self, + from_=1, + to=100, + width=250, + number_of_steps=100, + command=partial(self.slider_drag_callback, action_name), + ) + slider.bind( + "", partial(self.slider_mouse_down_callback, action_name) + ) + slider.bind( + "", partial(self.slider_mouse_up_callback, action_name) + ) slider.configure(state="disabled", hover=False) - slider.grid(row=row, - column=column, - padx=(15, 20), - pady=(112, 10), - sticky="nw") + slider.grid( + row=row, column=column, padx=(15, 20), pady=(112, 10), sticky="nw" + ) slider.grid_remove() # Subtle, Exaggerated @@ -196,15 +184,27 @@ def create_divs(self, action_list: list, gesture_list: list): master=self, text="Subtle\t\t\t Exaggerated", text_color="#868686", - justify=tk.LEFT) + justify=tk.LEFT, + ) subtle_label.cget("font").configure(size=11) - subtle_label.grid(row=row, - column=column, - padx=(20, 20), - pady=(128, 10), - sticky="nw") + subtle_label.grid( + row=row, column=column, padx=(20, 20), pady=(128, 10), sticky="nw" + ) subtle_label.grid_remove() + # Trigger dropdown + trigger_list = [t.value for t in Trigger] + trigger_dropdown = customtkinter.CTkOptionMenu( + master=self, + values=trigger_list, + width=240, + dynamic_resizing=False, + state="disabled", + ) + trigger_dropdown.grid( + row=row, column=column, padx=(20, 20), pady=(156, 10), sticky="nw" + ) + out_dict[action_name] = { "label": label, "combobox": drop, @@ -213,6 +213,7 @@ def create_divs(self, action_list: list, gesture_list: list): "volume_bar": volume_bar, "subtle_label": subtle_label, "selected_gesture": gesture_list[0], + "trigger_dropdown": trigger_dropdown, } return out_dict @@ -231,12 +232,15 @@ def slider_mouse_up_callback(self, caller_name: str, event): # change int [0,100] to float [0,1] thres_value = div["slider"].get() / 100 + trigger = Trigger(div["trigger_dropdown"].get()) + ConfigManager().set_temp_mouse_binding( div["selected_gesture"], device=target_device, action=target_action, threshold=thres_value, - trigger_type=DEFAULT_TRIGGER_TYPE) + trigger=trigger, + ) ConfigManager().apply_mouse_bindings() def dropdown_callback(self, caller_name: str, target_gesture: str): @@ -258,13 +262,17 @@ def dropdown_callback(self, caller_name: str, target_gesture: str): div["volume_bar"].grid() div["tips_label"].grid() div["subtle_label"].grid() + div["trigger_dropdown"].grid() thres_value = div["slider"].get() / 100 + trigger = Trigger(div["trigger_dropdown"].get()) + ConfigManager().set_temp_mouse_binding( target_gesture, device=target_device, action=target_action, threshold=thres_value, - trigger_type=DEFAULT_TRIGGER_TYPE) + trigger=trigger, + ) # Remove keybind if "None" else: @@ -273,17 +281,17 @@ def dropdown_callback(self, caller_name: str, target_gesture: str): div["volume_bar"].grid_remove() div["tips_label"].grid_remove() div["subtle_label"].grid_remove() - ConfigManager().remove_temp_mouse_binding(device=target_device, - action=target_action) + div["trigger_dropdown"].grid_remove() + ConfigManager().remove_temp_mouse_binding( + device=target_device, action=target_action + ) ConfigManager().apply_mouse_bindings() def update_volume_preview(self): - bs = FaceMesh().get_blendshapes() for div_name, div in self.divs.items(): - if div["selected_gesture"] == "None": continue @@ -309,11 +317,11 @@ def frame_loop(self): def inner_refresh_profile(self): # Create new divs form the new profile - self.load_initial_keybinds() + self.load_initial_keybindings() def enter(self): super().enter() - #self.load_initial_keybinds() + # self.load_initial_keybindings() self.after(1, self.frame_loop) def leave(self): @@ -321,7 +329,6 @@ def leave(self): class PageSelectGestures(SafeDisposableFrame): - def __init__(self, master, **kwargs): super().__init__(master, **kwargs) @@ -332,28 +339,20 @@ def __init__(self, master, **kwargs): self.bind_id_leave = None # Top label. - self.top_label = customtkinter.CTkLabel(master=self, - text="Mouse binding") + self.top_label = customtkinter.CTkLabel(master=self, text="Mouse binding") self.top_label.cget("font").configure(size=24) - self.top_label.grid(row=0, - column=0, - padx=20, - pady=5, - sticky="nw", - columnspan=1) + self.top_label.grid(row=0, column=0, padx=20, pady=5, sticky="nw", columnspan=1) # Description. des_txt = "Select a facial gesture that you would like to bind to a specific mouse action. Sensitivity allows you to control the extent to which you need to gesture to trigger the mouse action" - des_label = customtkinter.CTkLabel(master=self, - text=des_txt, - wraplength=300, - justify=tk.LEFT) # + des_label = customtkinter.CTkLabel( + master=self, text=des_txt, wraplength=300, justify=tk.LEFT + ) # des_label.cget("font").configure(size=14) des_label.grid(row=1, column=0, padx=20, pady=10, sticky="nw") # Inner frame - self.inner_frame = FrameSelectGesture(self, - logger_name="FrameSelectGesture") + self.inner_frame = FrameSelectGesture(self, logger_name="FrameSelectGesture") self.inner_frame.grid(row=2, column=0, padx=5, pady=5, sticky="nw") def enter(self): @@ -362,7 +361,8 @@ def enter(self): # Hide dropdown when mouse leave the frame self.bind_id_leave = self.bind( - "", self.inner_frame.shared_dropdown.hide_dropdown) + "", self.inner_frame.shared_dropdown.hide_dropdown + ) def refresh_profile(self): self.inner_frame.inner_refresh_profile() diff --git a/src/pipeline.py b/src/pipeline.py index 97b0f6b4..bda80da4 100644 --- a/src/pipeline.py +++ b/src/pipeline.py @@ -1,16 +1,3 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. import logging from src.camera_manager import CameraManager @@ -19,30 +6,28 @@ class Pipeline: - def __init__(self): logging.info("Init Pipeline") def pipeline_tick(self) -> None: - frame_rgb = CameraManager().get_raw_frame() - # Detect landmarks (async) and save in it's buffer + # Detect landmarks (async) and save in its buffer FaceMesh().detect_frame(frame_rgb) # Get facial landmarks landmarks = FaceMesh().get_landmarks() - if (landmarks is None): - CameraManager().draw_overlay(track_loc=None) + if landmarks is None: + CameraManager().draw_overlay(tracking_location=None) return # Control mouse position - track_loc = FaceMesh().get_track_loc() - MouseController().act(track_loc) + tracking_location = FaceMesh().get_tracking_location() + MouseController().act(tracking_location) # Control keyboard blendshape_values = FaceMesh().get_blendshapes() Keybinder().act(blendshape_values) # Draw frame overlay - CameraManager().draw_overlay(track_loc) + CameraManager().draw_overlay(tracking_location) diff --git a/src/shape_list.py b/src/shape_list.py index 3a89ca7f..3263bf59 100644 --- a/src/shape_list.py +++ b/src/shape_list.py @@ -23,8 +23,8 @@ "cheekPuff", "cheekSquintRight", "cheekSquintLeft", - "eyeBlinkRight", - "eyeBlinkLeft", + "Eye blink right", + "Eye blink left", "eyeLookDownRight", "eyeLookDownLeft", "eyeLookInRight", @@ -73,24 +73,31 @@ "Mouse left click": ["mouse", "left"], "Mouse right click": ["mouse", "right"], "Mouse middle click": ["mouse", "middle"], - "Mouse pause / unpause": ["mouse", "pause"], - "Reset cursor to center": ["mouse", "reset"], - "Switch focus between monitors": ["mouse", "cycle"] + "Mouse pause / unpause": ["meta", "pause"], + "Reset cursor to center": ["meta", "reset"], + "Switch focus between monitors": ["meta", "cycle"], } available_actions_keys = list(available_actions.keys()) available_actions_values = list(available_actions.values()) available_gestures = { - "None": "assets/images/dropdowns/None.png", - "Open mouth": "assets/images/dropdowns/Open mouth.png", - "Mouth left": "assets/images/dropdowns/Mouth left.png", - "Mouth right": "assets/images/dropdowns/Mouth right.png", - "Roll lower mouth": "assets/images/dropdowns/Roll lower mouth.png", - "Raise left eyebrow": "assets/images/dropdowns/Raise left eyebrow.png", - "Lower left eyebrow": "assets/images/dropdowns/Lower left eyebrow.png", - "Raise right eyebrow": "assets/images/dropdowns/Raise right eyebrow.png", - "Lower right eyebrow": "assets/images/dropdowns/Lower right eyebrow.png", + name: "assets/images/dropdowns/" + name + ".png" + for name in ( + "None", + "Eye blink right", + "Eye blink left", + "Open mouth", + "Mouth left", + "Mouth right", + "Roll lower mouth", + "Raise left eyebrow", + "Lower left eyebrow", + "Raise right eyebrow", + "Lower right eyebrow", + ) } + + for k, v in available_gestures.items(): assert k in blendshape_names, f"{k} not in blendshape_names" available_gestures_keys = list(available_gestures.keys()) @@ -108,7 +115,6 @@ "7": "7", "8": "8", "9": "9", - # Functions "f1": "f1", "f2": "f2", @@ -122,7 +128,6 @@ "f10": "f10", "f11": "f11", "f12": "f12", - # Letters "a": "a", "b": "b", @@ -150,7 +155,6 @@ "x": "x", "y": "y", "z": "z", - # Special characters "exclam": "!", "at": "@", @@ -174,7 +178,7 @@ "semicolon": ";", "colon": ":", "apostrophe": "'", - "quotedbl": "\"", + "quotedbl": '"', "grave": "`", "comma": ",", "less": "<", @@ -184,7 +188,6 @@ "asciitilde": "~", "bar": "|", "period": ".", - # Miscellaneous "return": "enter", "backspace": "backspace", @@ -204,7 +207,6 @@ "alt_l": "altleft", "alt_r": "altright", "num_lock": "numlock", - # Directions "up": "up", "down": "down", diff --git a/src/singleton_meta.py b/src/singleton_meta.py index 7b27edfc..520bbdb0 100644 --- a/src/singleton_meta.py +++ b/src/singleton_meta.py @@ -1,24 +1,8 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - class Singleton(type): _instances = {} def __call__(cls, *args, **kwargs): if cls not in cls._instances: - cls._instances[cls] = super(Singleton, - cls).__call__(*args, **kwargs) + cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) return cls._instances[cls] diff --git a/src/task_killer.py b/src/task_killer.py index 57407c30..e52b6a63 100644 --- a/src/task_killer.py +++ b/src/task_killer.py @@ -1,17 +1,3 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import logging import os import signal @@ -25,11 +11,10 @@ class TaskKiller(metaclass=Singleton): - """Singleton class for saftly killing the process and free the memory - """ + """Singleton class for softly killing the process and freeing the memory""" def __init__(self): - logger.info("Intialize TaskKiller singleton") + logger.info("Initialize TaskKiller singleton") self.is_started = False def start(self): @@ -39,16 +24,20 @@ def start(self): # Start singletons from src.config_manager import ConfigManager + ConfigManager().start() from src.camera_manager import CameraManager + CameraManager().start() from src.controllers import Keybinder, MouseController + MouseController().start() Keybinder().start() from src.detectors import FaceMesh + FaceMesh().start() self.is_started = True @@ -72,7 +61,6 @@ def exit(self): logging.info(f"Kill {parent}, {children}") for c in children: try: - c.send_signal(signal.SIGTERM) except psutil.NoSuchProcess: pass diff --git a/src/utils/Trigger.py b/src/utils/Trigger.py new file mode 100644 index 00000000..695cd08d --- /dev/null +++ b/src/utils/Trigger.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class Trigger(Enum): + DYNAMIC = "dynamic" + RAPID = "rapid" + SINGLE = "single" + HOLD = "hold" + TOGGLE = "toggle" diff --git a/src/utils/__init__.py b/src/utils/__init__.py index 07b7fe3d..b1d798fc 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -1,17 +1,18 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from .install_font import * -from .list_cameras import * -from .smoothing import * +__all__ = [ + "calc_smooth_kernel", + "apply_smoothing", + "open_camera", + "get_camera_name", + "assign_cameras_queue", + "assign_cameras_unblock", + "install_fonts", + "remove_fonts", +] +from .install_font import install_fonts, remove_fonts +from .list_cameras import ( + assign_cameras_queue, + assign_cameras_unblock, + open_camera, + get_camera_name, +) +from .smoothing import calc_smooth_kernel, apply_smoothing diff --git a/src/utils/install_font.py b/src/utils/install_font.py index 4c7a737a..93b22b5d 100644 --- a/src/utils/install_font.py +++ b/src/utils/install_font.py @@ -1,17 +1,3 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import ctypes import logging from pathlib import Path @@ -19,7 +5,7 @@ def install_fonts(font_dir: str) -> None: font_dir = Path(font_dir) - gdi32 = ctypes.WinDLL('gdi32') + gdi32 = ctypes.WinDLL("gdi32") for font_file in font_dir.glob("*.ttf"): logging.info(f"Installing font {font_file.as_posix()}") @@ -28,7 +14,7 @@ def install_fonts(font_dir: str) -> None: def remove_fonts(font_dir: str) -> None: font_dir = Path(font_dir) - gdi32 = ctypes.WinDLL('gdi32') + gdi32 = ctypes.WinDLL("gdi32") for font_file in font_dir.glob("*.ttf"): logging.info(f"Removing font {font_file.as_posix()}") gdi32.RemoveFontResourceW(font_file.as_posix()) diff --git a/src/utils/list_cameras.py b/src/utils/list_cameras.py index 193dc465..cfad32d8 100644 --- a/src/utils/list_cameras.py +++ b/src/utils/list_cameras.py @@ -1,40 +1,30 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. import concurrent.futures as futures import logging +import platform import cv2 +if platform.system() == "Windows": + import pygrabber.dshow_graph + logger = logging.getLogger("ListCamera") def __open_camera_task(i): - - logger.info(f"Try openning camera: {i}") + logger.info(f"Try opening camera: {i}") try: - cap = cv2.VideoCapture(cv2.CAP_DSHOW + i) + camera = cv2.VideoCapture(cv2.CAP_DSHOW + i) - if cap.getBackendName() != "DSHOW": - logger.info(f"Camera {i}: {cap.getBackendName()} is not supported") + if camera.getBackendName() != "DSHOW": + logger.info(f"Camera {i}: {camera.getBackendName()} is not supported") return (False, i, None) - if cap.get(cv2.CAP_PROP_FRAME_WIDTH) <= 0: + if camera.get(cv2.CAP_PROP_FRAME_WIDTH) <= 0: logger.info(f"Camera {i}: frame size error.") return False, i, None - ret, frame = cap.read() + ret, frame = camera.read() cv2.waitKey(1) if not ret: @@ -42,42 +32,46 @@ def __open_camera_task(i): return (False, i, None) h, w, _ = frame.shape - logger.info(f"Camera {i}: {cap} height: {h} width: {w}") + logger.info(f"Camera {i}: {camera} height: {h} width: {w}") - return (True, i, cap) + return (True, i, camera) except Exception as e: logger.warning(f"Camera {i}: not found {e}") return (False, i, None) -def assign_caps_unblock(caps, i): - ret, _, cap = __open_camera_task(i) +def assign_cameras_unblock(cameras, i): + ret, _, camera = __open_camera_task(i) if not ret: logger.info(f"Camera {i}: Failed to open") - if cap is not None: - caps[i] = cap + if camera is not None: + cameras[i] = camera else: - if i in caps: - del caps[i] + if i in cameras: + del cameras[i] -def assign_caps_queue(caps, done_callback: callable, max_search: int): - +def assign_cameras_queue(cameras, done_callback: callable, max_search: int): for i in range(max_search): - # block - ret, _, cap = __open_camera_task(i) + ret, _, camera = __open_camera_task(i) if not ret: logger.info(f"Camera {i}: Failed to open") - if cap is not None: - caps[i] = cap + if camera is not None: + cameras[i] = camera done_callback() -def open_camera(caps, i): - """For swapping camera - """ +def open_camera(cameras, i): + """For swapping camera""" pool = futures.ThreadPoolExecutor(max_workers=1) - pool.submit(assign_caps_unblock, caps, i) + pool.submit(assign_cameras_unblock, cameras, i) + + +def get_camera_name(i: int) -> str | None: + if platform.system() == "Windows": + return str(pygrabber.dshow_graph.FilterGraph().get_input_devices()[i]) + else: + return None diff --git a/src/utils/smoothing.py b/src/utils/smoothing.py index 35a2e6b0..257f0bcd 100644 --- a/src/utils/smoothing.py +++ b/src/utils/smoothing.py @@ -1,17 +1,3 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import numpy as np import numpy.typing as npt @@ -23,7 +9,6 @@ def calc_smooth_kernel(n: int) -> npt.ArrayLike: return kernel.reshape(n, 1) -def apply_smoothing(data: npt.ArrayLike, - kernel: npt.ArrayLike) -> npt.ArrayLike: +def apply_smoothing(data: npt.ArrayLike, kernel: npt.ArrayLike) -> npt.ArrayLike: smooth_n = len(kernel) return sum(kernel * data[-smooth_n:])