From 611213be819747f073c8d41383a665bf8cdbe1aa Mon Sep 17 00:00:00 2001 From: DynamycSound Date: Thu, 11 Jun 2026 09:39:05 +0000 Subject: [PATCH] Rewrite as a modern open-source project - Replace the legacy Tkinter GUI + VBS/BAT launchers with a single CustomTkinter app (sidebar navigation: Home, Hotkeys, Settings, About) - Run the hotkey engine in-process instead of a subprocess with move_pixels.txt file IPC; hotkeys are unchanged - Move help and theme popups into proper pages inside the app - Persist settings to %APPDATA%\WindowControlTool\settings.json - Fix the broken multi-monitor hotkey (missing win32api import) and make it wrap around monitors - Add packaging (pyproject.toml, requirements.txt) and a GitHub Actions workflow that lints, builds WindowControlTool.exe and attaches it to releases on version tags - Add README, CONTRIBUTING, CODE_OF_CONDUCT, issue/PR templates, .gitignore; relicense under MIT - Remove the old scripts and assets https://claude.ai/code/session_011BNLUVMFyL8WymEQG8myTp --- .github/ISSUE_TEMPLATE/bug_report.yml | 43 +++ .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature_request.yml | 22 ++ .github/PULL_REQUEST_TEMPLATE.md | 15 + .github/workflows/build.yml | 61 ++++ .gitignore | 19 + CODE_OF_CONDUCT.md | 37 ++ CONTRIBUTING.md | 52 +++ GUI.vbs | 2 - LICENSE | 360 ++----------------- README.md | 109 +++++- move_pixels.txt | 1 - non-GUI.bat | 2 - off.png | Bin 8408 -> 0 bytes on.png | Bin 11709 -> 0 bytes pyproject.toml | 40 +++ requirements.txt | 3 + settings.json | 1 - src/window_control_tool/__init__.py | 4 + src/window_control_tool/__main__.py | 12 + src/window_control_tool/config.py | 56 +++ src/window_control_tool/gui.py | 385 +++++++++++++++++++++ src/window_control_tool/hotkeys.py | 110 ++++++ src/window_control_tool/window_actions.py | 112 ++++++ togglewindows.py | 212 ------------ togglewindowsGUI.py | 323 ----------------- 26 files changed, 1099 insertions(+), 887 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md delete mode 100644 GUI.vbs delete mode 100644 move_pixels.txt delete mode 100644 non-GUI.bat delete mode 100644 off.png delete mode 100644 on.png create mode 100644 pyproject.toml create mode 100644 requirements.txt delete mode 100644 settings.json create mode 100644 src/window_control_tool/__init__.py create mode 100644 src/window_control_tool/__main__.py create mode 100644 src/window_control_tool/config.py create mode 100644 src/window_control_tool/gui.py create mode 100644 src/window_control_tool/hotkeys.py create mode 100644 src/window_control_tool/window_actions.py delete mode 100644 togglewindows.py delete mode 100644 togglewindowsGUI.py diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..dac9d8f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,43 @@ +name: Bug report +description: Something isn't working as expected +labels: ["bug"] +body: + - type: textarea + id: what-happened + attributes: + label: What happened? + description: A clear description of the bug. + placeholder: When I press Shift + Right, the window... + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: | + 1. Open the app + 2. Enable hotkeys + 3. ... + validations: + required: true + - type: input + id: windows-version + attributes: + label: Windows version + placeholder: e.g. Windows 11 24H2 + validations: + required: true + - type: dropdown + id: install-method + attributes: + label: How are you running the tool? + options: + - Release exe + - From source (python -m window_control_tool) + validations: + required: true + - type: textarea + id: logs + attributes: + label: Activity log / error output + description: Paste anything relevant from the app's Activity log or the console. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..2d96629 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Questions & ideas + url: https://github.com/DynamycSound/window_control_tool/discussions + about: For general questions, use Discussions instead of issues. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..ab68489 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,22 @@ +name: Feature request +description: Suggest an idea or improvement +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: What problem would this solve? + placeholder: I always have to..., it would be easier if... + validations: + required: true + - type: textarea + id: solution + attributes: + label: Describe the solution you'd like + placeholder: A hotkey that..., a setting for... + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives you've considered diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..e8fe1d3 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +# Pull request + +## What does this PR do? + + + +## Related issue + + + +## Checklist + +- [ ] I tested the change on Windows (if it touches window/hotkey behaviour) +- [ ] `ruff check src` passes +- [ ] I updated the README / hotkey reference if user-facing behaviour changed diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..a43138d --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,61 @@ +name: Build + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + workflow_dispatch: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install ruff + run: pip install ruff + - name: Lint + run: ruff check src + + build-exe: + runs-on: windows-latest + needs: lint + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies + run: | + pip install -r requirements.txt + pip install pyinstaller + - name: Build exe + run: > + pyinstaller --noconfirm --onefile --windowed + --name WindowControlTool + --collect-all customtkinter + src/window_control_tool/__main__.py + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: WindowControlTool + path: dist/WindowControlTool.exe + + release: + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + needs: build-exe + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + name: WindowControlTool + - name: Create GitHub release + uses: softprops/action-gh-release@v2 + with: + files: WindowControlTool.exe + generate_release_notes: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..19b9b79 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +build/ +dist/ +*.spec.bak + +# Tooling +.ruff_cache/ +.pytest_cache/ + +# OS / editor +.DS_Store +Thumbs.db +.idea/ +.vscode/ diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..b57909d --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,37 @@ +# Code of Conduct + +## Our Pledge + +We as members, contributors, and maintainers pledge to make participation in +this project a harassment-free experience for everyone, regardless of age, +body size, visible or invisible disability, ethnicity, sex characteristics, +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 a positive environment: + +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Focusing on what is best for the community and the project + +Examples of unacceptable behavior: + +- Trolling, insulting or derogatory comments, and personal attacks +- Harassment in public or private +- Publishing others' private information without explicit permission + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by opening an issue or contacting the maintainer. All complaints +will be reviewed and investigated promptly and fairly. Maintainers may remove, +edit, or reject comments, commits, code, issues, and other contributions that +are not aligned with this Code of Conduct. + +## Attribution + +This Code of Conduct is adapted from the +[Contributor Covenant](https://www.contributor-covenant.org), version 2.1. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..548c5e2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,52 @@ +# Contributing to Window Control Tool + +Thanks for your interest in contributing! All kinds of contributions are +welcome: bug reports, feature ideas, documentation fixes and code. + +## Reporting bugs / requesting features + +- Use the [issue templates](https://github.com/DynamycSound/window_control_tool/issues/new/choose). +- For bugs, include your Windows version, Python version (if running from + source), and steps to reproduce. + +## Development setup + +```bash +git clone https://github.com/DynamycSound/window_control_tool.git +cd window_control_tool +python -m venv .venv +.venv\Scripts\activate +pip install -r requirements.txt +pip install ruff +python -m window_control_tool +``` + +The codebase lives in `src/window_control_tool/`: + +| File | Purpose | +| --- | --- | +| `gui.py` | The CustomTkinter GUI (pages, navigation, activity log) | +| `hotkeys.py` | Global hotkey registration and the hotkey reference table | +| `window_actions.py` | The actual win32 window operations | +| `config.py` | Settings persistence (`%APPDATA%\WindowControlTool`) | + +## Code style + +- Run `ruff check src` before committing — CI runs the same check. +- Keep functions small and prefer plain, readable code over cleverness. +- All win32 calls belong in `window_actions.py`; hotkey actions must never + raise (wrap risky calls so the listener keeps running). + +## Pull requests + +1. Fork the repo and create a branch from `main`. +2. Make your change, test it on Windows if it touches window behaviour. +3. Update the README / hotkey reference if you change user-facing behaviour. +4. Open a PR with a clear description of what and why. + +Small, focused PRs are much easier to review than large ones. + +## License + +By contributing, you agree that your contributions will be licensed under the +[MIT License](LICENSE). diff --git a/GUI.vbs b/GUI.vbs deleted file mode 100644 index e1873a8..0000000 --- a/GUI.vbs +++ /dev/null @@ -1,2 +0,0 @@ -Set WshShell = CreateObject("WScript.Shell") -WshShell.Run "python togglewindowsGUI.py", 0, False diff --git a/LICENSE b/LICENSE index d159169..97261e2 100644 --- a/LICENSE +++ b/LICENSE @@ -1,339 +1,21 @@ - GNU GENERAL PUBLIC LICENSE - Version 2, June 1991 - - Copyright (C) 1989, 1991 Free Software Foundation, Inc., - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The licenses for most software are designed to take away your -freedom to share and change it. By contrast, the GNU General Public -License is intended to guarantee your freedom to share and change free -software--to make sure the software is free for all its users. This -General Public License applies to most of the Free Software -Foundation's software and to any other program whose authors commit to -using it. (Some other Free Software Foundation software is covered by -the GNU Lesser General Public License instead.) You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -this service if you wish), that you receive source code or can get it -if you want it, that you can change the software or use pieces of it -in new free programs; and that you know you can do these things. - - To protect your rights, we need to make restrictions that forbid -anyone to deny you these rights or to ask you to surrender the rights. -These restrictions translate to certain responsibilities for you if you -distribute copies of the software, or if you modify it. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must give the recipients all the rights that -you have. You must make sure that they, too, receive or can get the -source code. And you must show them these terms so they know their -rights. - - We protect your rights with two steps: (1) copyright the software, and -(2) offer you this license which gives you legal permission to copy, -distribute and/or modify the software. - - Also, for each author's protection and ours, we want to make certain -that everyone understands that there is no warranty for this free -software. If the software is modified by someone else and passed on, we -want its recipients to know that what they have is not the original, so -that any problems introduced by others will not reflect on the original -authors' reputations. - - Finally, any free program is threatened constantly by software -patents. We wish to avoid the danger that redistributors of a free -program will individually obtain patent licenses, in effect making the -program proprietary. To prevent this, we have made it clear that any -patent must be licensed for everyone's free use or not licensed at all. - - The precise terms and conditions for copying, distribution and -modification follow. - - GNU GENERAL PUBLIC LICENSE - TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - - 0. This License applies to any program or other work which contains -a notice placed by the copyright holder saying it may be distributed -under the terms of this General Public License. The "Program", below, -refers to any such program or work, and a "work based on the Program" -means either the Program or any derivative work under copyright law: -that is to say, a work containing the Program or a portion of it, -either verbatim or with modifications and/or translated into another -language. (Hereinafter, translation is included without limitation in -the term "modification".) Each licensee is addressed as "you". - -Activities other than copying, distribution and modification are not -covered by this License; they are outside its scope. The act of -running the Program is not restricted, and the output from the Program -is covered only if its contents constitute a work based on the -Program (independent of having been made by running the Program). -Whether that is true depends on what the Program does. - - 1. You may copy and distribute verbatim copies of the Program's -source code as you receive it, in any medium, provided that you -conspicuously and appropriately publish on each copy an appropriate -copyright notice and disclaimer of warranty; keep intact all the -notices that refer to this License and to the absence of any warranty; -and give any other recipients of the Program a copy of this License -along with the Program. - -You may charge a fee for the physical act of transferring a copy, and -you may at your option offer warranty protection in exchange for a fee. - - 2. You may modify your copy or copies of the Program or any portion -of it, thus forming a work based on the Program, and copy and -distribute such modifications or work under the terms of Section 1 -above, provided that you also meet all of these conditions: - - a) You must cause the modified files to carry prominent notices - stating that you changed the files and the date of any change. - - b) You must cause any work that you distribute or publish, that in - whole or in part contains or is derived from the Program or any - part thereof, to be licensed as a whole at no charge to all third - parties under the terms of this License. - - c) If the modified program normally reads commands interactively - when run, you must cause it, when started running for such - interactive use in the most ordinary way, to print or display an - announcement including an appropriate copyright notice and a - notice that there is no warranty (or else, saying that you provide - a warranty) and that users may redistribute the program under - these conditions, and telling the user how to view a copy of this - License. (Exception: if the Program itself is interactive but - does not normally print such an announcement, your work based on - the Program is not required to print an announcement.) - -These requirements apply to the modified work as a whole. If -identifiable sections of that work are not derived from the Program, -and can be reasonably considered independent and separate works in -themselves, then this License, and its terms, do not apply to those -sections when you distribute them as separate works. But when you -distribute the same sections as part of a whole which is a work based -on the Program, the distribution of the whole must be on the terms of -this License, whose permissions for other licensees extend to the -entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest -your rights to work written entirely by you; rather, the intent is to -exercise the right to control the distribution of derivative or -collective works based on the Program. - -In addition, mere aggregation of another work not based on the Program -with the Program (or with a work based on the Program) on a volume of -a storage or distribution medium does not bring the other work under -the scope of this License. - - 3. You may copy and distribute the Program (or a work based on it, -under Section 2) in object code or executable form under the terms of -Sections 1 and 2 above provided that you also do one of the following: - - a) Accompany it with the complete corresponding machine-readable - source code, which must be distributed under the terms of Sections - 1 and 2 above on a medium customarily used for software interchange; or, - - b) Accompany it with a written offer, valid for at least three - years, to give any third party, for a charge no more than your - cost of physically performing source distribution, a complete - machine-readable copy of the corresponding source code, to be - distributed under the terms of Sections 1 and 2 above on a medium - customarily used for software interchange; or, - - c) Accompany it with the information you received as to the offer - to distribute corresponding source code. (This alternative is - allowed only for noncommercial distribution and only if you - received the program in object code or executable form with such - an offer, in accord with Subsection b above.) - -The source code for a work means the preferred form of the work for -making modifications to it. For an executable work, complete source -code means all the source code for all modules it contains, plus any -associated interface definition files, plus the scripts used to -control compilation and installation of the executable. However, as a -special exception, the source code distributed need not include -anything that is normally distributed (in either source or binary -form) with the major components (compiler, kernel, and so on) of the -operating system on which the executable runs, unless that component -itself accompanies the executable. - -If distribution of executable or object code is made by offering -access to copy from a designated place, then offering equivalent -access to copy the source code from the same place counts as -distribution of the source code, even though third parties are not -compelled to copy the source along with the object code. - - 4. You may not copy, modify, sublicense, or distribute the Program -except as expressly provided under this License. Any attempt -otherwise to copy, modify, sublicense or distribute the Program is -void, and will automatically terminate your rights under this License. -However, parties who have received copies, or rights, from you under -this License will not have their licenses terminated so long as such -parties remain in full compliance. - - 5. You are not required to accept this License, since you have not -signed it. However, nothing else grants you permission to modify or -distribute the Program or its derivative works. These actions are -prohibited by law if you do not accept this License. Therefore, by -modifying or distributing the Program (or any work based on the -Program), you indicate your acceptance of this License to do so, and -all its terms and conditions for copying, distributing or modifying -the Program or works based on it. - - 6. Each time you redistribute the Program (or any work based on the -Program), the recipient automatically receives a license from the -original licensor to copy, distribute or modify the Program subject to -these terms and conditions. You may not impose any further -restrictions on the recipients' exercise of the rights granted herein. -You are not responsible for enforcing compliance by third parties to -this License. - - 7. If, as a consequence of a court judgment or allegation of patent -infringement or for any other reason (not limited to patent issues), -conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot -distribute so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you -may not distribute the Program at all. For example, if a patent -license would not permit royalty-free redistribution of the Program by -all those who receive copies directly or indirectly through you, then -the only way you could satisfy both it and this License would be to -refrain entirely from distribution of the Program. - -If any portion of this section is held invalid or unenforceable under -any particular circumstance, the balance of the section is intended to -apply and the section as a whole is intended to apply in other -circumstances. - -It is not the purpose of this section to induce you to infringe any -patents or other property right claims or to contest validity of any -such claims; this section has the sole purpose of protecting the -integrity of the free software distribution system, which is -implemented by public license practices. Many people have made -generous contributions to the wide range of software distributed -through that system in reliance on consistent application of that -system; it is up to the author/donor to decide if he or she is willing -to distribute software through any other system and a licensee cannot -impose that choice. - -This section is intended to make thoroughly clear what is believed to -be a consequence of the rest of this License. - - 8. If the distribution and/or use of the Program is restricted in -certain countries either by patents or by copyrighted interfaces, the -original copyright holder who places the Program under this License -may add an explicit geographical distribution limitation excluding -those countries, so that distribution is permitted only in or among -countries not thus excluded. In such case, this License incorporates -the limitation as if written in the body of this License. - - 9. The Free Software Foundation may publish revised and/or new versions -of the General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - -Each version is given a distinguishing version number. If the Program -specifies a version number of this License which applies to it and "any -later version", you have the option of following the terms and conditions -either of that version or of any later version published by the Free -Software Foundation. If the Program does not specify a version number of -this License, you may choose any version ever published by the Free Software -Foundation. - - 10. If you wish to incorporate parts of the Program into other free -programs whose distribution conditions are different, write to the author -to ask for permission. For software which is copyrighted by the Free -Software Foundation, write to the Free Software Foundation; we sometimes -make exceptions for this. Our decision will be guided by the two goals -of preserving the free status of all derivatives of our free software and -of promoting the sharing and reuse of software generally. - - NO WARRANTY - - 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY -FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN -OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES -PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED -OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS -TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE -PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, -REPAIR OR CORRECTION. - - 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR -REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, -INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING -OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED -TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY -YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER -PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE -POSSIBILITY OF SUCH DAMAGES. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -convey the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software; you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation; either version 2 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along - with this program; if not, write to the Free Software Foundation, Inc., - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - -Also add information on how to contact you by electronic and paper mail. - -If the program is interactive, make it output a short notice like this -when it starts in an interactive mode: - - Gnomovision version 69, Copyright (C) year name of author - Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, the commands you use may -be called something other than `show w' and `show c'; they could even be -mouse-clicks or menu items--whatever suits your program. - -You should also get your employer (if you work as a programmer) or your -school, if any, to sign a "copyright disclaimer" for the program, if -necessary. Here is a sample; alter the names: - - Yoyodyne, Inc., hereby disclaims all copyright interest in the program - `Gnomovision' (which makes passes at compilers) written by James Hacker. - - , 1 April 1989 - Ty Coon, President of Vice - -This General Public License does not permit incorporating your program into -proprietary programs. If your program is a subroutine library, you may -consider it more useful to permit linking proprietary applications with the -library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. +MIT License + +Copyright (c) 2024-2026 Stefan M. (DynamycSound) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 9eae2bb..bd58879 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,104 @@ -Run GUI.vbs +# Window Control Tool -Figure out the rest :) +[![Build](https://github.com/DynamycSound/window_control_tool/actions/workflows/build.yml/badge.svg)](https://github.com/DynamycSound/window_control_tool/actions/workflows/build.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) +[![Platform](https://img.shields.io/badge/platform-Windows-0078d7.svg)](#requirements) -![Screenshot 2024-08-27 215223](https://github.com/user-attachments/assets/0cda86d9-0e60-4357-8621-fc8dcfea7dca) -![Screenshot 2024-08-27 215241](https://github.com/user-attachments/assets/c1acb6ce-b594-4f77-9b30-2a5a0857a923) -![Screenshot 2024-08-27 215250](https://github.com/user-attachments/assets/1757cfe9-e35b-488b-b877-9afd754cb081) -![Screenshot 2024-08-27 215328](https://github.com/user-attachments/assets/02496df1-023e-44d6-826d-227725ff9097) -![Screenshot 2024-08-27 220539](https://github.com/user-attachments/assets/11b23238-35c3-4010-925a-470d5dc9480d) +Move, resize and restyle **any window** on Windows with simple global hotkeys — +no mouse needed. Comes with a clean, modern dark/light GUI where you can tune +everything and see what's happening in real time. + +## ✨ Features + +- **Move windows** with the arrow keys — pixel-precise, configurable step size +- **Resize windows** with `Shift + Arrow keys` +- **Change opacity** of any window with `Ctrl + Up / Down` +- **Always on top** toggle with `Ctrl + Left / Right` +- **Send a window to the next monitor** with `Ctrl + Shift + M` +- Modern GUI with dark, light and system themes +- Built-in hotkey reference, activity log and settings — no popups +- Settings persist automatically between sessions + +## ⌨️ Hotkeys + +| Hotkey | Action | +| --- | --- | +| `↑ ↓ ← →` | Move the focused window | +| `Shift + ↑ ↓ ← →` | Resize the window (grow that edge) | +| `Ctrl + ↑` | Increase opacity | +| `Ctrl + ↓` | Decrease opacity | +| `Ctrl + →` | Set always on top | +| `Ctrl + ←` | Remove always on top | +| `Ctrl + Shift + M` | Move window to the next monitor | + +Hotkeys always act on the window that currently has focus, and only while the +switch in the app is turned **on**. + +## 🚀 Getting started + +### Option 1 — Download the exe (easiest) + +1. Grab `WindowControlTool.exe` from the + [latest release](https://github.com/DynamycSound/window_control_tool/releases/latest). +2. Run it. Flip the switch. Done — no Python required. + +> **Note:** Because the exe registers global hotkeys, some antivirus tools may +> flag it. The app is fully open source — you can read every line of code in +> this repository or build the exe yourself (see below). + +### Option 2 — Run from source + +Requires Python 3.9+ on Windows. + +```bash +git clone https://github.com/DynamycSound/window_control_tool.git +cd window_control_tool +pip install -r requirements.txt +python -m window_control_tool +``` + +(`python -m window_control_tool` works from the repo root because the package +lives in `src/`; alternatively install it with `pip install .` and launch it +with `window-control-tool`.) + +### Build the exe yourself + +```bash +pip install pyinstaller +pyinstaller --noconfirm --onefile --windowed --name WindowControlTool \ + --collect-all customtkinter src/window_control_tool/__main__.py +``` + +The exe appears in `dist/`. + +## 🖥️ Requirements + +- Windows 10 / 11 (the win32 APIs the tool uses are Windows-only) +- Python 3.9+ if running from source + +## ⚙️ Configuration + +Everything is configurable from the **Settings** page in the app: + +- **Move / resize step** — pixels per key press (default 40) +- **Opacity change per press** — 10 / 25 / 50 +- **Appearance** — dark, light or follow the system +- **Auto-enable hotkeys** when the app starts + +Settings are stored in `%APPDATA%\WindowControlTool\settings.json`. + +## 🤝 Contributing + +Contributions are welcome! Please read +[CONTRIBUTING.md](CONTRIBUTING.md) for how to set up a dev environment, the +code style, and how to submit pull requests. Bug reports and feature ideas go +in the [issue tracker](https://github.com/DynamycSound/window_control_tool/issues). + +## 📄 License + +This project is free and open source under the [MIT License](LICENSE) — +you may use, copy, modify and redistribute it, commercially or not. + +--- + +Made by **Stefan M. (DynamycSound)** diff --git a/move_pixels.txt b/move_pixels.txt deleted file mode 100644 index 56a6051..0000000 --- a/move_pixels.txt +++ /dev/null @@ -1 +0,0 @@ -1 \ No newline at end of file diff --git a/non-GUI.bat b/non-GUI.bat deleted file mode 100644 index 79e0591..0000000 --- a/non-GUI.bat +++ /dev/null @@ -1,2 +0,0 @@ -@echo off -python togglewindows.py diff --git a/off.png b/off.png deleted file mode 100644 index f5c10a94c95cef40db27d693d3a3bb2738812d42..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8408 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 z≪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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..530b501 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,40 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "window-control-tool" +version = "2.0.0" +description = "Move, resize and restyle any window on Windows with global hotkeys." +readme = "README.md" +license = { file = "LICENSE" } +authors = [{ name = "Stefan M. (DynamycSound)" }] +requires-python = ">=3.9" +keywords = ["windows", "hotkeys", "window-manager", "always-on-top", "opacity"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Win32 (MS Windows)", + "Intended Audience :: End Users/Desktop", + "Operating System :: Microsoft :: Windows", + "Programming Language :: Python :: 3", + "Topic :: Desktop Environment :: Window Managers", +] +dependencies = [ + "customtkinter>=5.2", + "keyboard>=0.13.5; sys_platform == 'win32'", + "pywin32>=306; sys_platform == 'win32'", +] + +[project.urls] +Homepage = "https://github.com/DynamycSound/window_control_tool" +Issues = "https://github.com/DynamycSound/window_control_tool/issues" + +[project.gui-scripts] +window-control-tool = "window_control_tool.__main__:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.ruff] +line-length = 100 +target-version = "py39" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5a07e1e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +customtkinter>=5.2 +keyboard>=0.13.5; sys_platform == 'win32' +pywin32>=306; sys_platform == 'win32' diff --git a/settings.json b/settings.json deleted file mode 100644 index c8aa8a5..0000000 --- a/settings.json +++ /dev/null @@ -1 +0,0 @@ -{"theme": "dark", "background_color": "#000000", "font_color": "#00FF00"} \ No newline at end of file diff --git a/src/window_control_tool/__init__.py b/src/window_control_tool/__init__.py new file mode 100644 index 0000000..47dba5b --- /dev/null +++ b/src/window_control_tool/__init__.py @@ -0,0 +1,4 @@ +"""Window Control Tool - control any window on Windows with global hotkeys.""" + +__version__ = "2.0.0" +__author__ = "Stefan M. (DynamycSound)" diff --git a/src/window_control_tool/__main__.py b/src/window_control_tool/__main__.py new file mode 100644 index 0000000..89adefe --- /dev/null +++ b/src/window_control_tool/__main__.py @@ -0,0 +1,12 @@ +"""Entry point: `python -m window_control_tool` or the packaged exe.""" + +import sys + + +def main() -> int: + from .gui import run + return run() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/window_control_tool/config.py b/src/window_control_tool/config.py new file mode 100644 index 0000000..b347b6b --- /dev/null +++ b/src/window_control_tool/config.py @@ -0,0 +1,56 @@ +"""Persistent user settings. + +Settings are stored as JSON in the per-user application data directory +(%APPDATA%\\WindowControlTool on Windows, ~/.config/window-control-tool +elsewhere) so the installed/portable exe never needs write access to its +own folder. +""" + +from __future__ import annotations + +import json +import os +from dataclasses import asdict, dataclass, field +from pathlib import Path + +DEFAULT_MOVE_STEP = 40 +DEFAULT_OPACITY_STEP = 25 + + +def _config_dir() -> Path: + appdata = os.environ.get("APPDATA") + if appdata: + return Path(appdata) / "WindowControlTool" + return Path.home() / ".config" / "window-control-tool" + + +CONFIG_FILE = _config_dir() / "settings.json" + + +@dataclass +class Settings: + move_step: int = DEFAULT_MOVE_STEP + opacity_step: int = DEFAULT_OPACITY_STEP + appearance_mode: str = "dark" # "dark", "light" or "system" + start_hotkeys_on_launch: bool = False + accent_theme: str = "blue" + extra: dict = field(default_factory=dict) + + @classmethod + def load(cls) -> "Settings": + try: + data = json.loads(CONFIG_FILE.read_text(encoding="utf-8")) + except (OSError, ValueError): + return cls() + known = {f for f in cls.__dataclass_fields__ if f != "extra"} + kwargs = {k: v for k, v in data.items() if k in known} + extra = {k: v for k, v in data.items() if k not in known} + settings = cls(**kwargs, extra=extra) + settings.move_step = max(1, int(settings.move_step)) + return settings + + def save(self) -> None: + data = asdict(self) + data.update(data.pop("extra")) + CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) + CONFIG_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8") diff --git a/src/window_control_tool/gui.py b/src/window_control_tool/gui.py new file mode 100644 index 0000000..3a4acef --- /dev/null +++ b/src/window_control_tool/gui.py @@ -0,0 +1,385 @@ +"""Modern CustomTkinter GUI for the Window Control Tool. + +Layout: a sidebar with four pages (Home, Hotkeys, Settings, About). Help +and theme options - previously separate popup windows - are regular pages +now, so everything lives in one window. +""" + +from __future__ import annotations + +import sys +import webbrowser +from datetime import datetime + +import customtkinter as ctk + +from . import __version__ +from .config import DEFAULT_MOVE_STEP, Settings +from .hotkeys import HOTKEY_REFERENCE, WINDOWS, HotkeyEngine + +REPO_URL = "https://github.com/DynamycSound/window_control_tool" + +ACCENT = "#3B8ED0" +GREEN = "#2FA572" +RED = "#D04B3B" + + +class App(ctk.CTk): + def __init__(self) -> None: + super().__init__() + self.settings = Settings.load() + ctk.set_appearance_mode(self.settings.appearance_mode) + + self.engine = HotkeyEngine(self.settings, self._on_engine_event) + + self.title("Window Control Tool") + self.geometry("760x520") + self.minsize(640, 460) + + self.grid_columnconfigure(1, weight=1) + self.grid_rowconfigure(0, weight=1) + + self._build_sidebar() + self._pages: dict[str, ctk.CTkFrame] = { + "home": self._build_home_page(), + "hotkeys": self._build_hotkeys_page(), + "settings": self._build_settings_page(), + "about": self._build_about_page(), + } + self._show_page("home") + + self.protocol("WM_DELETE_WINDOW", self._on_close) + + if not WINDOWS: + self._log("Preview mode: hotkeys only work on Windows.") + if self.settings.start_hotkeys_on_launch and WINDOWS: + self._toggle_hotkeys_from_switch(force_on=True) + + # ------------------------------------------------------------- sidebar + + def _build_sidebar(self) -> None: + sidebar = ctk.CTkFrame(self, width=180, corner_radius=0) + sidebar.grid(row=0, column=0, sticky="nsw") + sidebar.grid_rowconfigure(6, weight=1) + + ctk.CTkLabel( + sidebar, text="Window\nControl Tool", + font=ctk.CTkFont(size=20, weight="bold"), justify="left", + ).grid(row=0, column=0, padx=20, pady=(24, 4), sticky="w") + ctk.CTkLabel( + sidebar, text=f"v{__version__}", text_color="gray", + font=ctk.CTkFont(size=12), + ).grid(row=1, column=0, padx=20, pady=(0, 20), sticky="w") + + self._nav_buttons: dict[str, ctk.CTkButton] = {} + for row, (key, label) in enumerate( + [("home", " Home"), ("hotkeys", " Hotkeys"), + ("settings", " Settings"), ("about", " About")], + start=2, + ): + button = ctk.CTkButton( + sidebar, text=label, anchor="w", height=40, + fg_color="transparent", text_color=("gray10", "gray90"), + hover_color=("gray80", "gray25"), + command=lambda k=key: self._show_page(k), + ) + button.grid(row=row, column=0, padx=12, pady=2, sticky="ew") + self._nav_buttons[key] = button + + self.sidebar_status = ctk.CTkLabel( + sidebar, text="● Hotkeys off", text_color="gray", + font=ctk.CTkFont(size=13), + ) + self.sidebar_status.grid(row=7, column=0, padx=20, pady=16, sticky="w") + + def _show_page(self, key: str) -> None: + for name, page in self._pages.items(): + page.grid_forget() + self._pages[key].grid(row=0, column=1, sticky="nsew", padx=16, pady=16) + for name, button in self._nav_buttons.items(): + button.configure( + fg_color=("gray75", "gray28") if name == key else "transparent" + ) + + # ---------------------------------------------------------------- home + + def _build_home_page(self) -> ctk.CTkFrame: + page = ctk.CTkFrame(self, fg_color="transparent") + page.grid_columnconfigure(0, weight=1) + page.grid_rowconfigure(3, weight=1) + + # Power card + card = ctk.CTkFrame(page) + card.grid(row=0, column=0, sticky="ew") + card.grid_columnconfigure(0, weight=1) + ctk.CTkLabel( + card, text="Global hotkeys", + font=ctk.CTkFont(size=17, weight="bold"), + ).grid(row=0, column=0, padx=16, pady=(14, 0), sticky="w") + self.status_label = ctk.CTkLabel( + card, text="Off - flip the switch to start controlling windows", + text_color="gray", + ) + self.status_label.grid(row=1, column=0, padx=16, pady=(0, 14), sticky="w") + self.power_switch = ctk.CTkSwitch( + card, text="", width=80, switch_height=28, switch_width=56, + progress_color=GREEN, command=self._toggle_hotkeys_from_switch, + ) + self.power_switch.grid(row=0, column=1, rowspan=2, padx=16) + + # Move step card + step_card = ctk.CTkFrame(page) + step_card.grid(row=1, column=0, sticky="ew", pady=(12, 0)) + step_card.grid_columnconfigure(1, weight=1) + ctk.CTkLabel( + step_card, text="Move / resize step", + font=ctk.CTkFont(size=15, weight="bold"), + ).grid(row=0, column=0, columnspan=3, padx=16, pady=(12, 2), sticky="w") + ctk.CTkLabel( + step_card, text="How many pixels each hotkey press moves or resizes " + "the window.", text_color="gray", + ).grid(row=1, column=0, columnspan=3, padx=16, sticky="w") + + self.step_slider = ctk.CTkSlider( + step_card, from_=1, to=200, number_of_steps=199, + command=self._on_step_slider, + ) + self.step_slider.set(self.settings.move_step) + self.step_slider.grid(row=2, column=0, columnspan=2, padx=16, + pady=12, sticky="ew") + + entry_row = ctk.CTkFrame(step_card, fg_color="transparent") + entry_row.grid(row=2, column=2, padx=(0, 16), pady=12) + self.step_entry = ctk.CTkEntry(entry_row, width=64, justify="center") + self.step_entry.insert(0, str(self.settings.move_step)) + self.step_entry.bind("", lambda _e: self._on_step_entry()) + self.step_entry.bind("", lambda _e: self._on_step_entry()) + self.step_entry.pack(side="left", padx=(0, 6)) + ctk.CTkLabel(entry_row, text="px", text_color="gray").pack(side="left") + ctk.CTkButton( + entry_row, text="Reset", width=56, + fg_color="transparent", border_width=1, + text_color=("gray10", "gray90"), + command=lambda: self._set_step(DEFAULT_MOVE_STEP), + ).pack(side="left", padx=(10, 0)) + + # Activity log + ctk.CTkLabel( + page, text="Activity", font=ctk.CTkFont(size=15, weight="bold"), + ).grid(row=2, column=0, sticky="w", pady=(14, 4)) + self.log_box = ctk.CTkTextbox(page, state="disabled", + font=ctk.CTkFont(size=12)) + self.log_box.grid(row=3, column=0, sticky="nsew") + return page + + # ------------------------------------------------------------- hotkeys + + def _build_hotkeys_page(self) -> ctk.CTkFrame: + page = ctk.CTkScrollableFrame(self, fg_color="transparent") + page.grid_columnconfigure(1, weight=1) + + ctk.CTkLabel( + page, text="Hotkeys", font=ctk.CTkFont(size=20, weight="bold"), + ).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 4)) + ctk.CTkLabel( + page, text="These work system-wide while hotkeys are enabled. " + "They always act on the window that currently has focus.", + text_color="gray", wraplength=480, justify="left", + ).grid(row=1, column=0, columnspan=2, sticky="w", pady=(0, 14)) + + for row, (combo, description) in enumerate(HOTKEY_REFERENCE, start=2): + chip = ctk.CTkLabel( + page, text=f" {combo} ", + font=ctk.CTkFont(size=13, weight="bold"), + fg_color=("gray80", "gray25"), corner_radius=6, + ) + chip.grid(row=row, column=0, sticky="w", pady=5) + ctk.CTkLabel(page, text=description, anchor="w").grid( + row=row, column=1, sticky="w", padx=14, pady=5 + ) + + tip = ctk.CTkFrame(page) + tip.grid(row=len(HOTKEY_REFERENCE) + 2, column=0, columnspan=2, + sticky="ew", pady=(18, 0)) + ctk.CTkLabel( + tip, text="Tip", font=ctk.CTkFont(size=13, weight="bold"), + text_color=ACCENT, + ).pack(anchor="w", padx=14, pady=(10, 0)) + ctk.CTkLabel( + tip, text="The arrow keys are captured globally while hotkeys are " + "on, so turn the switch off when you need normal arrow-key " + "behaviour (e.g. while typing in a document).", + text_color="gray", wraplength=460, justify="left", + ).pack(anchor="w", padx=14, pady=(2, 12)) + return page + + # ------------------------------------------------------------ settings + + def _build_settings_page(self) -> ctk.CTkFrame: + page = ctk.CTkFrame(self, fg_color="transparent") + page.grid_columnconfigure(0, weight=1) + + ctk.CTkLabel( + page, text="Settings", font=ctk.CTkFont(size=20, weight="bold"), + ).grid(row=0, column=0, sticky="w", pady=(0, 14)) + + card = ctk.CTkFrame(page) + card.grid(row=1, column=0, sticky="ew") + card.grid_columnconfigure(0, weight=1) + + ctk.CTkLabel(card, text="Appearance", anchor="w").grid( + row=0, column=0, padx=16, pady=12, sticky="w") + self.appearance_menu = ctk.CTkOptionMenu( + card, values=["Dark", "Light", "System"], + command=self._on_appearance_change, width=130, + ) + self.appearance_menu.set(self.settings.appearance_mode.capitalize()) + self.appearance_menu.grid(row=0, column=1, padx=16, pady=12) + + ctk.CTkLabel(card, text="Opacity change per press", anchor="w").grid( + row=1, column=0, padx=16, pady=12, sticky="w") + self.opacity_menu = ctk.CTkOptionMenu( + card, values=["10", "25", "50"], width=130, + command=self._on_opacity_step_change, + ) + self.opacity_menu.set(str(self.settings.opacity_step)) + self.opacity_menu.grid(row=1, column=1, padx=16, pady=12) + + ctk.CTkLabel(card, text="Enable hotkeys when the app starts", + anchor="w").grid(row=2, column=0, padx=16, pady=12, + sticky="w") + self.autostart_switch = ctk.CTkSwitch( + card, text="", command=self._on_autostart_change, + progress_color=GREEN, + ) + if self.settings.start_hotkeys_on_launch: + self.autostart_switch.select() + self.autostart_switch.grid(row=2, column=1, padx=16, pady=12) + + ctk.CTkLabel( + page, text="Settings are saved automatically.", + text_color="gray", font=ctk.CTkFont(size=12), + ).grid(row=2, column=0, sticky="w", pady=10) + return page + + # --------------------------------------------------------------- about + + def _build_about_page(self) -> ctk.CTkFrame: + page = ctk.CTkFrame(self, fg_color="transparent") + ctk.CTkLabel( + page, text="About", font=ctk.CTkFont(size=20, weight="bold"), + ).pack(anchor="w", pady=(0, 14)) + + card = ctk.CTkFrame(page) + card.pack(fill="x") + ctk.CTkLabel( + card, text="Window Control Tool", + font=ctk.CTkFont(size=16, weight="bold"), + ).pack(anchor="w", padx=16, pady=(14, 0)) + ctk.CTkLabel( + card, + text=( + f"Version {__version__}\n" + "Move, resize and restyle any window with global hotkeys.\n\n" + "Made by Stefan M. (DynamycSound)\n" + "Free and open source under the MIT License - you may use, " + "modify and redistribute it freely." + ), + text_color="gray", justify="left", wraplength=460, + ).pack(anchor="w", padx=16, pady=(4, 14)) + + buttons = ctk.CTkFrame(page, fg_color="transparent") + buttons.pack(fill="x", pady=12) + ctk.CTkButton( + buttons, text="GitHub repository", + command=lambda: webbrowser.open(REPO_URL), + ).pack(side="left") + ctk.CTkButton( + buttons, text="Report an issue", fg_color="transparent", + border_width=1, text_color=("gray10", "gray90"), + command=lambda: webbrowser.open(f"{REPO_URL}/issues/new/choose"), + ).pack(side="left", padx=10) + return page + + # ------------------------------------------------------------ handlers + + def _toggle_hotkeys_from_switch(self, force_on: bool = False) -> None: + if force_on: + self.power_switch.select() + if self.power_switch.get(): + self.engine.start() + else: + self.engine.stop() + self._refresh_status() + + def _refresh_status(self) -> None: + if self.engine.running: + self.status_label.configure( + text="On - hotkeys are active system-wide", text_color=GREEN) + self.sidebar_status.configure(text="● Hotkeys on", + text_color=GREEN) + else: + self.status_label.configure( + text="Off - flip the switch to start controlling windows", + text_color="gray") + self.sidebar_status.configure(text="● Hotkeys off", + text_color="gray") + self.power_switch.deselect() + + def _set_step(self, value: int) -> None: + value = max(1, min(999, value)) + self.settings.move_step = value + self.step_slider.set(min(value, 200)) + self.step_entry.delete(0, "end") + self.step_entry.insert(0, str(value)) + self.settings.save() + + def _on_step_slider(self, value: float) -> None: + self._set_step(int(value)) + + def _on_step_entry(self) -> None: + try: + value = int(self.step_entry.get()) + except ValueError: + self._set_step(self.settings.move_step) + self._log("Invalid step value - keeping previous setting.") + return + self._set_step(value) + + def _on_appearance_change(self, choice: str) -> None: + self.settings.appearance_mode = choice.lower() + ctk.set_appearance_mode(self.settings.appearance_mode) + self.settings.save() + + def _on_opacity_step_change(self, choice: str) -> None: + self.settings.opacity_step = int(choice) + self.settings.save() + + def _on_autostart_change(self) -> None: + self.settings.start_hotkeys_on_launch = bool(self.autostart_switch.get()) + self.settings.save() + + def _on_engine_event(self, message: str) -> None: + # Called from the keyboard listener thread - marshal to the UI thread. + self.after(0, self._log, message) + + def _log(self, message: str) -> None: + timestamp = datetime.now().strftime("%H:%M:%S") + self.log_box.configure(state="normal") + self.log_box.insert("end", f"[{timestamp}] {message}\n") + self.log_box.see("end") + self.log_box.configure(state="disabled") + self._refresh_status() + + def _on_close(self) -> None: + self.engine.stop() + self.destroy() + + +def run() -> int: + if sys.platform != "win32": + print("Note: Window Control Tool targets Windows; " + "the GUI will open in preview mode without hotkeys.") + app = App() + app.mainloop() + return 0 diff --git a/src/window_control_tool/hotkeys.py b/src/window_control_tool/hotkeys.py new file mode 100644 index 0000000..3f368bf --- /dev/null +++ b/src/window_control_tool/hotkeys.py @@ -0,0 +1,110 @@ +"""Global hotkey engine. + +Registers the system-wide hotkeys with the `keyboard` library and routes +them to the win32 window actions. The hotkey map is intentionally identical +to the original tool: + + Arrow keys move the focused window + Shift + Arrow keys grow the window in that direction + Ctrl + Up / Down increase / decrease opacity + Ctrl + Right set always on top + Ctrl + Left remove always on top + Ctrl + Shift + M move window to the next monitor +""" + +from __future__ import annotations + +import sys +import threading +from typing import Callable + +from . import window_actions +from .config import Settings + +WINDOWS = sys.platform == "win32" + +if WINDOWS: + import keyboard + +# (hotkey, description) - single source of truth, also rendered on the +# GUI's Hotkeys page. +HOTKEY_REFERENCE = [ + ("↑ ↓ ← →", "Move the focused window"), + ("Shift + ↑ ↓ ← →", "Resize the window (grow that edge)"), + ("Ctrl + ↑", "Increase opacity"), + ("Ctrl + ↓", "Decrease opacity"), + ("Ctrl + →", "Set always on top"), + ("Ctrl + ←", "Remove always on top"), + ("Ctrl + Shift + M", "Move window to next monitor"), +] + + +class HotkeyEngine: + """Owns the global hotkey registrations. + + `on_event` is called (from the keyboard library's listener thread) with + a status string after each action, so the GUI can show what happened. + """ + + def __init__(self, settings: Settings, on_event: Callable[[str], None]): + self.settings = settings + self.on_event = on_event + self._lock = threading.Lock() + self._handles: list = [] + + @property + def running(self) -> bool: + return bool(self._handles) + + def start(self) -> None: + if not WINDOWS: + self.on_event("Hotkeys require Windows - running in preview mode") + return + with self._lock: + if self._handles: + return + step = lambda: self.settings.move_step # noqa: E731 + ostep = lambda: self.settings.opacity_step # noqa: E731 + bindings = { + "up": lambda: window_actions.move(0, -1, step()), + "down": lambda: window_actions.move(0, 1, step()), + "left": lambda: window_actions.move(-1, 0, step()), + "right": lambda: window_actions.move(1, 0, step()), + "shift+up": lambda: window_actions.resize(0, 1, 0, 0, step()), + "shift+down": lambda: window_actions.resize(0, 0, 0, 1, step()), + "shift+left": lambda: window_actions.resize(1, 0, 0, 0, step()), + "shift+right": lambda: window_actions.resize(0, 0, 1, 0, step()), + "ctrl+up": lambda: window_actions.change_opacity(True, ostep()), + "ctrl+down": lambda: window_actions.change_opacity(False, ostep()), + "ctrl+right": lambda: window_actions.set_always_on_top(True), + "ctrl+left": lambda: window_actions.set_always_on_top(False), + "ctrl+shift+m": window_actions.move_to_next_monitor, + } + for combo, action in bindings.items(): + self._handles.append( + keyboard.add_hotkey(combo, self._wrap(action), suppress=False) + ) + self.on_event("Hotkeys enabled") + + def stop(self) -> None: + with self._lock: + if not self._handles: + return + for handle in self._handles: + try: + keyboard.remove_hotkey(handle) + except (KeyError, ValueError): + pass + self._handles.clear() + self.on_event("Hotkeys disabled") + + def _wrap(self, action: Callable[[], str]) -> Callable[[], None]: + def run() -> None: + try: + message = action() + except Exception as exc: # never let a hotkey kill the listener + message = f"Error: {exc}" + if message: + self.on_event(message) + + return run diff --git a/src/window_control_tool/window_actions.py b/src/window_control_tool/window_actions.py new file mode 100644 index 0000000..f9fddcd --- /dev/null +++ b/src/window_control_tool/window_actions.py @@ -0,0 +1,112 @@ +"""Win32 operations on the currently focused (foreground) window. + +Every public function returns a short human-readable status string that the +GUI shows in its activity log. All win32 errors are caught and reported as +text instead of crashing the hotkey listener. +""" + +from __future__ import annotations + +import sys + +WINDOWS = sys.platform == "win32" + +if WINDOWS: + import win32api + import win32con + import win32gui + + +def _foreground(): + hwnd = win32gui.GetForegroundWindow() + if not hwnd: + return None, "" + return hwnd, win32gui.GetWindowText(hwnd) or "" + + +def move(dx: int, dy: int, step: int) -> str: + hwnd, title = _foreground() + if not hwnd: + return "No active window" + left, top, right, bottom = win32gui.GetWindowRect(hwnd) + win32gui.SetWindowPos( + hwnd, None, + left + dx * step, top + dy * step, + right - left, bottom - top, + win32con.SWP_NOZORDER, + ) + return f"Moved '{title}'" + + +def resize(d_left: int, d_top: int, d_right: int, d_bottom: int, step: int) -> str: + hwnd, title = _foreground() + if not hwnd: + return "No active window" + left, top, right, bottom = win32gui.GetWindowRect(hwnd) + new_left = left - d_left * step + new_top = top - d_top * step + new_right = right + d_right * step + new_bottom = bottom + d_bottom * step + win32gui.SetWindowPos( + hwnd, None, + new_left, new_top, + max(50, new_right - new_left), max(50, new_bottom - new_top), + win32con.SWP_NOZORDER, + ) + return f"Resized '{title}'" + + +def change_opacity(increase: bool, step: int) -> str: + hwnd, title = _foreground() + if not hwnd: + return "No active window" + style = win32gui.GetWindowLong(hwnd, win32con.GWL_EXSTYLE) + if not style & win32con.WS_EX_LAYERED: + win32gui.SetWindowLong(hwnd, win32con.GWL_EXSTYLE, style | win32con.WS_EX_LAYERED) + opacity = 255 + else: + opacity = win32gui.GetLayeredWindowAttributes(hwnd)[1] + # Never go fully invisible from a hotkey - 25 stays barely visible + opacity = min(255, opacity + step) if increase else max(25, opacity - step) + win32gui.SetLayeredWindowAttributes(hwnd, 0, opacity, win32con.LWA_ALPHA) + return f"Opacity of '{title}': {round(opacity / 255 * 100)}%" + + +def set_always_on_top(on_top: bool) -> str: + hwnd, title = _foreground() + if not hwnd: + return "No active window" + win32gui.SetWindowPos( + hwnd, + win32con.HWND_TOPMOST if on_top else win32con.HWND_NOTOPMOST, + 0, 0, 0, 0, + win32con.SWP_NOMOVE | win32con.SWP_NOSIZE, + ) + state = "ON" if on_top else "OFF" + return f"Always on top {state} for '{title}'" + + +def move_to_next_monitor() -> str: + hwnd, title = _foreground() + if not hwnd: + return "No active window" + monitors = win32api.EnumDisplayMonitors() + if len(monitors) < 2: + return "Only one monitor detected" + current = win32api.MonitorFromWindow(hwnd, win32con.MONITOR_DEFAULTTONEAREST) + handles = [int(m[0]) for m in monitors] + try: + index = handles.index(int(current)) + except ValueError: + index = 0 + target = monitors[(index + 1) % len(monitors)][2] # work area rect of next monitor + + left, top, right, bottom = win32gui.GetWindowRect(hwnd) + width, height = right - left, bottom - top + new_x = target[0] + max(0, (target[2] - target[0] - width) // 2) + new_y = target[1] + max(0, (target[3] - target[1] - height) // 2) + win32gui.SetWindowPos( + hwnd, None, new_x, new_y, 0, 0, + win32con.SWP_NOSIZE | win32con.SWP_NOZORDER, + ) + return f"Moved '{title}' to next monitor" diff --git a/togglewindows.py b/togglewindows.py deleted file mode 100644 index 42458fa..0000000 --- a/togglewindows.py +++ /dev/null @@ -1,212 +0,0 @@ -import win32gui -import win32con -import keyboard -import os -import tkinter as tk - -# Global variables -default_move_pixels = 40 # Default value for moving pixels -resize_pixels = 1 # Fixed pixel increase for resizing - -# Function to read move pixels from file -def get_move_pixels(): - if os.path.exists('move_pixels.txt'): - with open('move_pixels.txt', 'r') as f: - try: - return int(f.read().strip()) - except ValueError: - return default_move_pixels - return default_move_pixels - -# Function to move the window up -def move_window_up(hwnd): - move_pixels = get_move_pixels() - rect = win32gui.GetWindowRect(hwnd) - win32gui.SetWindowPos( - hwnd, None, rect[0], rect[1] - move_pixels, rect[2] - rect[0], rect[3] - rect[1], win32con.SWP_NOZORDER - ) - -# Function to move the window down -def move_window_down(hwnd): - move_pixels = get_move_pixels() - rect = win32gui.GetWindowRect(hwnd) - win32gui.SetWindowPos( - hwnd, None, rect[0], rect[1] + move_pixels, rect[2] - rect[0], rect[3] - rect[1], win32con.SWP_NOZORDER - ) - -# Function to move the window left -def move_window_left(hwnd): - move_pixels = get_move_pixels() - rect = win32gui.GetWindowRect(hwnd) - win32gui.SetWindowPos( - hwnd, None, rect[0] - move_pixels, rect[1], rect[2] - rect[0], rect[3] - rect[1], win32con.SWP_NOZORDER - ) - -# Function to move the window right -def move_window_right(hwnd): - move_pixels = get_move_pixels() - rect = win32gui.GetWindowRect(hwnd) - win32gui.SetWindowPos( - hwnd, None, rect[0] + move_pixels, rect[1], rect[2] - rect[0], rect[3] - rect[1], win32con.SWP_NOZORDER - ) - -# Function to resize window -def resize_window(hwnd, left, right, top, bottom): - move_pixels = get_move_pixels() - rect = win32gui.GetWindowRect(hwnd) - new_left = rect[0] - (left * move_pixels) - new_top = rect[1] - (top * move_pixels) - new_right = rect[2] + (right * move_pixels) - new_bottom = rect[3] + (bottom * move_pixels) - win32gui.SetWindowPos( - hwnd, None, new_left, new_top, - new_right - new_left, new_bottom - new_top, win32con.SWP_NOZORDER - ) - -# Function to change opacity -def change_opacity(hwnd, increase=True): - current_style = win32gui.GetWindowLong(hwnd, win32con.GWL_EXSTYLE) - if not current_style & win32con.WS_EX_LAYERED: - win32gui.SetWindowLong(hwnd, win32con.GWL_EXSTYLE, current_style | win32con.WS_EX_LAYERED) - new_opacity = 255 # Start with 100% opacity - else: - new_opacity = win32gui.GetLayeredWindowAttributes(hwnd)[1] - - new_opacity = min(255, new_opacity + 25) if increase else max(0, new_opacity - 25) - win32gui.SetLayeredWindowAttributes(hwnd, 0, new_opacity, win32con.LWA_ALPHA) - -# Function to display a message on the screen -def display_message(hwnd, message): - title = win32gui.GetWindowText(hwnd) - rect = win32gui.GetWindowRect(hwnd) - - overlay = tk.Tk() - overlay.overrideredirect(True) - overlay.attributes("-topmost", True) - overlay.geometry(f"+{rect[0]}+{rect[1]}") - - label = tk.Label(overlay, text=f"{title}\n{message}", font=('Helvetica', 12), bg='yellow', fg='black') - label.pack() - - overlay.update_idletasks() - overlay.lift() - overlay.after(2000, overlay.destroy) - overlay.mainloop() - -# Function to toggle always on top -def toggle_always_on_top(hwnd, always_on_top=True): - win32gui.SetWindowPos( - hwnd, win32con.HWND_TOPMOST if always_on_top else win32con.HWND_NOTOPMOST, - 0, 0, 0, 0, - win32con.SWP_NOMOVE | win32con.SWP_NOSIZE - ) - display_message(hwnd, "Always on top turned on" if always_on_top else "Always on top turned off") - -# Function to move window to another monitor -def move_to_next_monitor(): - hwnd = win32gui.GetForegroundWindow() - if hwnd: - rect = win32gui.GetWindowRect(hwnd) - current_monitor = win32api.MonitorFromWindow(hwnd) - monitors = win32api.EnumDisplayMonitors() - - next_monitor = None - for i, monitor in enumerate(monitors): - if monitor[0] == current_monitor and i + 1 < len(monitors): - next_monitor = monitors[i + 1] - break - - if next_monitor: - next_monitor_rect = next_monitor[2] - new_x = next_monitor_rect[0] + (rect[0] - rect[2]) // 2 - new_y = next_monitor_rect[1] + (rect[1] - rect[3]) // 2 - win32gui.SetWindowPos(hwnd, None, new_x, new_y, 0, 0, win32con.SWP_NOSIZE | win32con.SWP_NOZORDER) - -# Hotkey functions -def move_foreground_window_up(): - hwnd = win32gui.GetForegroundWindow() - if hwnd: - move_window_up(hwnd) - -def move_foreground_window_down(): - hwnd = win32gui.GetForegroundWindow() - if hwnd: - move_window_down(hwnd) - -def move_foreground_window_left(): - hwnd = win32gui.GetForegroundWindow() - if hwnd: - move_window_left(hwnd) - -def move_foreground_window_right(): - hwnd = win32gui.GetForegroundWindow() - if hwnd: - move_window_right(hwnd) - -def increase_left_side(): - hwnd = win32gui.GetForegroundWindow() - if hwnd: - resize_window(hwnd, 1, 0, 0, 0) - -def increase_right_side(): - hwnd = win32gui.GetForegroundWindow() - if hwnd: - resize_window(hwnd, 0, 1, 0, 0) - -def increase_bottom_side(): - hwnd = win32gui.GetForegroundWindow() - if hwnd: - resize_window(hwnd, 0, 0, 0, 1) - -def increase_top_side(): - hwnd = win32gui.GetForegroundWindow() - if hwnd: - resize_window(hwnd, 0, 0, 1, 0) - -def increase_opacity(): - hwnd = win32gui.GetForegroundWindow() - if hwnd: - change_opacity(hwnd, increase=True) - -def decrease_opacity(): - hwnd = win32gui.GetForegroundWindow() - if hwnd: - change_opacity(hwnd, increase=False) - -def set_always_on_top(): - hwnd = win32gui.GetForegroundWindow() - if hwnd: - toggle_always_on_top(hwnd, always_on_top=True) - -def remove_always_on_top(): - hwnd = win32gui.GetForegroundWindow() - if hwnd: - toggle_always_on_top(hwnd, always_on_top=False) - -# Registering hotkeys -def check_hotkeys(): - keyboard.add_hotkey('up', move_foreground_window_up) - keyboard.add_hotkey('down', move_foreground_window_down) - keyboard.add_hotkey('left', move_foreground_window_left) - keyboard.add_hotkey('right', move_foreground_window_right) - keyboard.add_hotkey('shift+left', increase_left_side) - keyboard.add_hotkey('shift+right', increase_right_side) - keyboard.add_hotkey('shift+down', increase_bottom_side) - keyboard.add_hotkey('shift+up', increase_top_side) - keyboard.add_hotkey('ctrl+up', increase_opacity) - keyboard.add_hotkey('ctrl+down', decrease_opacity) - keyboard.add_hotkey('ctrl+left', remove_always_on_top) - keyboard.add_hotkey('ctrl+right', set_always_on_top) - keyboard.add_hotkey('ctrl+shift+m', move_to_next_monitor) - -def main(): - check_hotkeys() - print("Hotkey listener started.") - print("Use arrow keys to move the window and 'Shift + Arrow keys' to resize the window.") - print("Use 'Ctrl + Up/Down' to change opacity, 'Ctrl + Left/Right' to toggle always on top.") - print("Use 'Ctrl + Shift + M' to move the window to the next monitor.") - # Prevent the script from exiting - keyboard.wait() - -if __name__ == "__main__": - main() diff --git a/togglewindowsGUI.py b/togglewindowsGUI.py deleted file mode 100644 index 24b2011..0000000 --- a/togglewindowsGUI.py +++ /dev/null @@ -1,323 +0,0 @@ -import tkinter as tk -from tkinter import ttk, colorchooser -from PIL import Image, ImageTk -import subprocess -import threading -import os -import json - -# Global variables -background_process = None -move_pixels = 40 # Default value for moving pixels -settings_file = 'settings.json' -default_settings = { - 'theme': 'dark', - 'background_color': '#000000', - 'font_color': '#00FF00' -} - -def load_settings(): - if os.path.exists(settings_file): - with open(settings_file, 'r') as f: - settings = json.load(f) - # Ensure default settings are present - for key, value in default_settings.items(): - settings.setdefault(key, value) - return settings - return default_settings - -def save_settings(settings): - with open(settings_file, 'w') as f: - json.dump(settings, f) - -def apply_settings(settings): - global background_color, font_color - background_color = settings['background_color'] - font_color = settings['font_color'] - root.configure(bg=background_color) - style.configure('TButton', background=background_color, foreground=font_color) - style.configure('TLabel', background=background_color, foreground=font_color) - style.configure("Custom.TEntry", fieldbackground=background_color, foreground=font_color, insertcolor=font_color) - - move_pixels_entry.configure(style="Custom.TEntry") - help_button.configure(bg=background_color, fg=font_color) - theme_button.configure(bg=background_color, fg=font_color) - confirm_button.configure(bg=background_color, fg=font_color) - toggle_button.configure(bg=background_color) - - for button in [decrease_button_1, decrease_button_5, increase_button_1, increase_button_5]: - button.configure(bg=background_color, fg=font_color) - - for label in [label_move_pixels, label_default, output_label, console_output_label, author_label]: - label.configure(background=background_color, foreground=font_color) - - frame.configure(bg=background_color) - -def update_theme_window(window): - for widget in window.winfo_children(): - try: - widget.configure(bg=settings['background_color'], fg=settings['font_color']) - except tk.TclError: - pass - -def start_or_restart_script(): - global background_process - - if background_process and background_process.poll() is None: - background_process.terminate() - - try: - global move_pixels - move_pixels = int(move_pixels_entry.get()) - except ValueError: - console_output.set("Invalid input for pixels. Default: 40") - move_pixels = 40 - move_pixels_entry.delete(0, tk.END) - move_pixels_entry.insert(0, move_pixels) - return - - with open('move_pixels.txt', 'w') as f: - f.write(str(move_pixels)) - - background_process = subprocess.Popen(['python', 'togglewindows.py'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - threading.Thread(target=read_output, daemon=True).start() - console_output.set("") - update_status_indicator() - -def stop_script(): - global background_process - if background_process and background_process.poll() is None: - background_process.terminate() - console_output.set("Script stopped") - update_status_indicator() - -def update_move_pixels(): - global move_pixels - try: - move_pixels = int(move_pixels_entry.get()) - with open('move_pixels.txt', 'w') as f: - f.write(str(move_pixels)) - console_output.set(f"Move pixels updated to {move_pixels}") - except ValueError: - console_output.set("Invalid input for pixels. Please enter a valid number.") - -def decrease_move_pixels(by_value=1): - current_value = int(move_pixels_entry.get()) - new_value = max(current_value - by_value, 0) - move_pixels_entry.delete(0, tk.END) - move_pixels_entry.insert(0, new_value) - -def increase_move_pixels(by_value=1): - current_value = int(move_pixels_entry.get()) - new_value = current_value + by_value - move_pixels_entry.delete(0, tk.END) - move_pixels_entry.insert(0, new_value) - -def update_status_indicator(): - if "Script stopped" in console_output.get() or "Press power button to start the script" in console_output.get(): - toggle_button.config(image=off_image) - else: - toggle_button.config(image=on_image) - -def toggle_script(): - if "Script stopped" in console_output.get() or "Press power button to start the script" in console_output.get(): - start_or_restart_script() - else: - stop_script() - -def read_output(): - global background_process - while background_process and background_process.poll() is None: - output_line = background_process.stdout.readline() - if output_line: - console_output.set(output_line.strip()) - update_status_indicator() - -def show_help(): - help_window = tk.Toplevel(root) - help_window.title("Help") - help_window.geometry("300x400") - help_window.configure(bg=background_color) - help_text = ( - "Window Control Tool Help\n\n" - "Hotkeys:\n" - "- Arrow keys: Move window\n" - "- Shift + Arrow keys: Resize window\n" - "- Ctrl + Up: Increase opacity\n" - "- Ctrl + Down: Decrease opacity\n" - "- Ctrl + Left: Remove always on top\n" - "- Ctrl + Right: Set always on top\n\n" - "Buttons:\n" - "- Start: Start the script\n" - "- Stop: Stop the script\n" - "- Confirm: Update move pixels value\n" - ) - ttk.Label(help_window, text=help_text, background=background_color, foreground=font_color, justify='left', wraplength=280).pack(pady=10, padx=10) - update_theme_window(help_window) - -def show_theme_settings(): - theme_window = tk.Toplevel(root) - theme_window.title("Theme Settings") - theme_window.geometry("300x400") - theme_window.configure(bg=background_color) - - def apply_theme(theme): - if theme == 'dark': - settings.update({'theme': 'dark', 'background_color': '#000000', 'font_color': '#00FF00'}) - elif theme == 'light': - settings.update({'theme': 'light', 'background_color': '#FFFFFF', 'font_color': '#000000'}) - apply_settings(settings) - save_settings(settings) - theme_window.configure(bg=settings['background_color']) - update_theme_window(theme_window) - for window in root.winfo_children(): - if isinstance(window, tk.Toplevel): - update_theme_window(window) - - def set_custom_color(setting_key): - current_color = settings[setting_key] - color = colorchooser.askcolor(initialcolor=current_color, title=f"Choose {setting_key.replace('_', ' ')}")[1] - if color: - settings[setting_key] = color - apply_settings(settings) - save_settings(settings) - theme_window.configure(bg=settings['background_color']) - update_theme_window(theme_window) - for window in root.winfo_children(): - if isinstance(window, tk.Toplevel): - update_theme_window(window) - - for widget in theme_window.winfo_children(): - widget.destroy() - - ttk.Button(theme_window, text="Dark Mode", command=lambda: apply_theme('dark')).pack(pady=10) - ttk.Button(theme_window, text="Light Mode", command=lambda: apply_theme('light')).pack(pady=10) - - custom_mode_button = ttk.Button(theme_window, text="Custom Mode", command=lambda: show_custom_mode_options(theme_window, update_theme_window)) - custom_mode_button.pack(pady=10) - - update_theme_window(theme_window) - -def show_custom_mode_options(theme_window, update_theme_window): - def set_custom_color(setting_key): - current_color = settings[setting_key] - color = colorchooser.askcolor(initialcolor=current_color, title=f"Choose {setting_key.replace('_', ' ')}")[1] - if color: - settings[setting_key] = color - apply_settings(settings) - save_settings(settings) - theme_window.configure(bg=settings['background_color']) - update_theme_window(theme_window) - for window in root.winfo_children(): - if isinstance(window, tk.Toplevel): - update_theme_window(window) - - for widget in theme_window.winfo_children(): - if widget.cget("text") in ["Background Color", "Font Color"]: - widget.destroy() - ttk.Button(theme_window, text="Background Color", command=lambda: set_custom_color('background_color')).pack(pady=5) - ttk.Button(theme_window, text="Font Color", command=lambda: set_custom_color('font_color')).pack(pady=5) - -def main(): - global move_pixels_entry, background_process, console_output, toggle_button, root, on_image, off_image, background_color, font_color, style, help_button, theme_button, confirm_button, output_label, console_output_label, author_label, decrease_button_1, decrease_button_5, increase_button_1, increase_button_5, label_move_pixels, label_default, settings, frame - - settings = load_settings() - background_color = settings['background_color'] - font_color = settings['font_color'] - - # Read initial move_pixels value from file - if os.path.exists('move_pixels.txt'): - with open('move_pixels.txt', 'r') as f: - try: - move_pixels = int(f.read().strip()) - except ValueError: - move_pixels = 40 - - root = tk.Tk() - root.title("Window Control Tool") - root.geometry("300x500") - root.pack_propagate(False) - - style = ttk.Style(root) - style.theme_use('clam') - style.configure('TButton', font=('Helvetica', 14), padding=5, background=background_color, foreground=font_color) - style.configure('TLabel', font=('Helvetica', 14), padding=5, background=background_color, foreground=font_color) - style.configure('TEntry', font=('Helvetica', 14), padding=5) - style.configure("Custom.TEntry", - fieldbackground=background_color, - foreground=font_color, - insertcolor=font_color) - - root.configure(bg=background_color) - - label_move_pixels = ttk.Label(root, text="Enter move pixels:", background=background_color, foreground=font_color) - label_move_pixels.pack(pady=5) - - frame = tk.Frame(root, bg=background_color) - frame.pack(pady=5) - - decrease_button_1 = tk.Button(frame, text="-1", width=3, command=lambda: decrease_move_pixels(1), bg=background_color, fg=font_color) - decrease_button_1.grid(row=0, column=0, padx=2) - - decrease_button_5 = tk.Button(frame, text="-5", width=3, command=lambda: decrease_move_pixels(5), bg=background_color, fg=font_color) - decrease_button_5.grid(row=0, column=1, padx=2) - - move_pixels_entry = ttk.Entry(frame, width=10, style="Custom.TEntry") - move_pixels_entry.insert(0, move_pixels) - move_pixels_entry.grid(row=0, column=2) - - increase_button_1 = tk.Button(frame, text="+1", width=3, command=lambda: increase_move_pixels(1), bg=background_color, fg=font_color) - increase_button_1.grid(row=0, column=3, padx=2) - - increase_button_5 = tk.Button(frame, text="+5", width=3, command=lambda: increase_move_pixels(5), bg=background_color, fg=font_color) - increase_button_5.grid(row=0, column=4, padx=2) - - confirm_button = tk.Button(root, text="Confirm", command=update_move_pixels, bg=background_color, fg=font_color) - confirm_button.pack(pady=5) - - label_default = ttk.Label(root, text="Default: 40", background=background_color, foreground=font_color) - label_default.pack(pady=5) - - # Load images for toggle button - on_image = ImageTk.PhotoImage(Image.open("on.png").resize((40, 40))) - off_image = ImageTk.PhotoImage(Image.open("off.png").resize((40, 40))) - - # Create toggle button with image - toggle_button = tk.Button(root, image=off_image, command=toggle_script, bg=background_color) - toggle_button.pack(pady=5) - - output_text = tk.StringVar() - output_text.set("Output:") - output_label = ttk.Label(root, textvariable=output_text, background=background_color, foreground=font_color) - output_label.pack(pady=5) - - # Console output area - console_output = tk.StringVar() - console_output_label = ttk.Label(root, textvariable=console_output, background=background_color, foreground=font_color, anchor='center', justify='center', wraplength=250) - console_output_label.pack(pady=5, fill='x', padx=5) - - # Help button - help_button = tk.Button(root, text="How to use this?", command=show_help, bg=background_color, fg=font_color) - help_button.pack(pady=5) - - # Theme settings button - theme_button = tk.Button(root, text="Theme Settings", command=show_theme_settings, bg=background_color, fg=font_color) - theme_button.pack(pady=5) - - # Author label - author_label = ttk.Label(root, text="Made by Stefan M.", background=background_color, foreground=font_color, anchor='center', justify='center') - author_label.place(relx=0.5, rely=1.0, anchor='s', y=-5) - - # Initialize console with initial instruction - console_output.set("Press power button to start the script") - - def on_closing(): - stop_script() - root.destroy() - - root.protocol("WM_DELETE_WINDOW", on_closing) - apply_settings(settings) - root.mainloop() - -if __name__ == "__main__": - main()