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 f5c10a9..0000000 Binary files a/off.png and /dev/null differ diff --git a/on.png b/on.png deleted file mode 100644 index c9c26d9..0000000 Binary files a/on.png and /dev/null differ 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()