├── .flake8 ├── .github ├── CONTRIBUTING.md ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── plugin_request.md ├── dependabot.yml ├── images │ ├── header.png │ └── settings.png └── workflows │ ├── build-and-release.yml │ └── python-ci.yml ├── .gitignore ├── .pylintrc ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── designer └── main_window.ui ├── flatpak-poetry-generator.py ├── generated-poetry-sources.json ├── poetry.lock ├── pyproject.toml ├── resources ├── README.md ├── icon.svg ├── resources.qrc ├── sh.oskar.yin_yang.metainfo.xml ├── translations │ ├── yin_yang.de_DE.qm │ ├── yin_yang.de_DE.ts │ ├── yin_yang.nl_NL.qm │ ├── yin_yang.nl_NL.ts │ ├── yin_yang.zh_CN.qm │ ├── yin_yang.zh_CN.ts │ ├── yin_yang.zh_TW.qm │ └── yin_yang.zh_TW.ts ├── yin_yang ├── yin_yang.json ├── yin_yang.service └── yin_yang.timer ├── scripts ├── build_ui.sh ├── install.sh ├── runner.sh └── uninstall.sh ├── sh.oskar.yin_yang.json ├── tests ├── __init__.py ├── test_communication.py ├── test_config.py ├── test_daemon.py ├── test_daemon_handler.py ├── test_plugin_class.py └── test_plugins.py └── yin_yang ├── __init__.py ├── __main__.py ├── communicate.py ├── config.py ├── daemon_handler.py ├── helpers.py ├── meta.py ├── notification_handler.py ├── plugins ├── __init__.py ├── _plugin.py ├── colors.py ├── custom.py ├── firefox.py ├── gtk.py ├── icons.py ├── konsole.py ├── kvantum.py ├── notify.py ├── okular.py ├── only_office.py ├── system.py ├── vscode.py └── wallpaper.py ├── position.py ├── repeat_timer.py ├── theme_switcher.py └── ui ├── __init__.py ├── main_window.py ├── main_window_connector.py └── resources_rc.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = 3 | yin_yang/ui/resources_rc.py, 4 | yin_yang/ui/main_window.py, 5 | .venv, 6 | build, 7 | dist 8 | max-line-length = 127 9 | max-complexity = 10 10 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # 📝 Creating issues 2 | 1. Use a fitting template whenever possible. 3 | 2. Only talk about one topic (one bug, one feature) per issue. 4 | 3. Make sure your problem is reproducable. 5 | 4. Give as much useful information as possible, for example the steps you took that lead to a problem. 6 | 7 | # 🛠️ Making pull requests 8 | 9 | Thank you for helping out! By the way, take a look at [our documentation](https://github.com/oskarsh/Yin-Yang/wiki). 10 | 11 | 1. Give it a short and fitting title. 12 | 2. Create a draft if you are still working on it. That makes it possible for others to see what you are working on, while keeping maintainers from reviewing or merging it. 13 | 3. Make sure everything works before you mark it as "ready for review". 14 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: L0drex # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # liberapay user name 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with a single custom sponsorship URL 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | labels: ["bug"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thanks for taking the time to fill out this bug report! 9 | - type: textarea 10 | id: what-happened 11 | attributes: 12 | label: What happened? 13 | description: Also tell us, what did you expect to happen? 14 | placeholder: Tell us what you see! 15 | value: "A bug happened!" 16 | validations: 17 | required: true 18 | - type: input 19 | id: version 20 | attributes: 21 | label: Version 22 | description: What version of Yin & Yang are you running? 23 | validations: 24 | required: true 25 | - type: dropdown 26 | id: source 27 | attributes: 28 | label: How did you install Yin & Yang? 29 | options: 30 | - Git clone + running install script 31 | - AUR 32 | - Flatpak 33 | validations: 34 | required: true 35 | - type: dropdown 36 | id: desktop 37 | attributes: 38 | label: What desktop environments are you seeing the problem on? 39 | multiple: true 40 | options: 41 | - KDE 42 | - Gnome 43 | - Xfce 44 | - Mate 45 | - Cinnamon 46 | - other 47 | - type: dropdown 48 | id: plugins 49 | attributes: 50 | label: Which plugin causes the issue? 51 | options: 52 | - Atom 53 | - Brave 54 | - Colors 55 | - Custom Script 56 | - Firefox 57 | - Gedit 58 | - GTK 59 | - Konsole 60 | - Kvantum 61 | - Only Office 62 | - System 63 | - VSCode 64 | - Wallpaper 65 | - type: input 66 | id: plugin_version 67 | attributes: 68 | label: What software version do you use? 69 | description: For example, if you see a problem with the VSCode plugin, this would refer to the version of VSCode you have installed. 70 | - type: textarea 71 | id: logs 72 | attributes: 73 | label: Relevant log output 74 | description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. 75 | render: shell 76 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/plugin_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Plugin request 3 | about: Request a new plugin to be added. A plugin controls the theme of some app or desktop environment. 4 | title: '' 5 | labels: plugin support 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### What is the plugin for? 11 | 12 | 13 | ### Why do you need it? 14 | 15 | - [ ] Application does not offer automatic theme-change 16 | - [ ] Implemented auto-theme doesn't work 17 | 18 | ### How can the theme be changed? 19 | 20 | 21 | ### Additional notes 22 | 23 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Basic `dependabot.yml` file 2 | version: 2 3 | updates: 4 | - package-ecosystem: "pip" 5 | directory: "/" 6 | schedule: 7 | interval: "monthly" 8 | target-branch: "beta" 9 | labels: 10 | - "update" 11 | - package-ecosystem: "github-actions" 12 | directory: "/" 13 | schedule: 14 | interval: "monthly" 15 | labels: 16 | - "update" 17 | target-branch: "beta" 18 | -------------------------------------------------------------------------------- /.github/images/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oskarsh/Yin-Yang/19feb5938cdf52a4cf2592ef5b4a081c4911feb8/.github/images/header.png -------------------------------------------------------------------------------- /.github/images/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oskarsh/Yin-Yang/19feb5938cdf52a4cf2592ef5b4a081c4911feb8/.github/images/settings.png -------------------------------------------------------------------------------- /.github/workflows/build-and-release.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release Yin-Yang 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build: 9 | name: "Build application as Whl" 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | python-version: ["3.12"] 14 | os: [ubuntu-24.04] 15 | runs-on: ${{matrix.os}} 16 | steps: 17 | # Checkout repo and set up python 18 | - uses: actions/checkout@v4 19 | - name: Install Python 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: ${{matrix.python-version}} 23 | # Install and configure poetry 24 | - name: Install Poetry 25 | uses: abatilo/actions-poetry@v2 26 | - name: Set up local virtual environment 27 | run: | 28 | poetry config virtualenvs.create true --local 29 | poetry config virtualenvs.in-project true --local 30 | # Load cached venv if it exists 31 | - name: Cache packages 32 | id: cached-poetry-dependencies 33 | uses: actions/cache@v4 34 | with: 35 | # This path is specific to ubuntu 36 | path: ./.venv 37 | key: venv-${{ hashFiles('poetry.lock') }} 38 | # Install dependencies of cache does not exist 39 | - name: Install system dependencies 40 | run: | 41 | sudo apt update 42 | sudo apt install -y qt6-base-dev libsystemd-dev gcc 43 | - name: Install Poetry dependencies 44 | # if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' 45 | run: | 46 | poetry sync --no-interaction 47 | # Compile and build Yin-Yang 48 | - name: Compile ui, translations and resources 49 | run: poetry run ./scripts/build_ui.sh 50 | - name: Build Whl for release 51 | run: poetry build -f wheel -n -o . 52 | # Upload build artifacts for later use 53 | - name: Upload yin_yang whl for flatpak build 54 | uses: actions/upload-artifact@v4 55 | with: 56 | name: yin_yang-${{ github.sha }}-py3-none-any.whl 57 | path: '*.whl' 58 | 59 | flatpak: 60 | name: "Build flatpak file" 61 | runs-on: ubuntu-24.04 62 | needs: build 63 | container: 64 | image: ghcr.io/flathub-infra/flatpak-github-actions:kde-6.8 65 | options: --privileged 66 | strategy: 67 | matrix: 68 | arch: [x86_64] 69 | steps: 70 | - uses: actions/checkout@v4 71 | - name: Download build from last step 72 | uses: actions/download-artifact@v4 73 | with: 74 | path: dist/ 75 | name: yin_yang-${{ github.sha }}-py3-none-any.whl 76 | - uses: flatpak/flatpak-github-actions/flatpak-builder@v6 77 | with: 78 | bundle: yin_yang.flatpak 79 | manifest-path: sh.oskar.yin_yang.json 80 | cache-key: flatpak-builder-${{ github.sha }} 81 | arch: x86_64 -------------------------------------------------------------------------------- /.github/workflows/python-ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | 3 | name: Python CI 4 | 5 | on: 6 | pull_request: 7 | branches: [master, beta] 8 | 9 | jobs: 10 | ci: 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | python-version: ["3.12"] 15 | os: [ubuntu-24.04] 16 | runs-on: ${{matrix.os}} 17 | steps: 18 | # Checkout repo and set up python 19 | - uses: actions/checkout@v4 20 | - name: Install Python 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: ${{matrix.python-version}} 24 | # Install and configure poetry 25 | - name: Install Poetry 26 | uses: abatilo/actions-poetry@v2 27 | - name: Set up local virtual environment 28 | run: | 29 | poetry config virtualenvs.create true --local 30 | poetry config virtualenvs.in-project true --local 31 | # Load cached venv if it exists 32 | - name: Cache packages 33 | id: cached-poetry-dependencies 34 | uses: actions/cache@v4 35 | with: 36 | # This path is specific to ubuntu 37 | path: ./.venv 38 | key: venv-${{ hashFiles('poetry.lock') }} 39 | # Install dependencies of cache does not exist 40 | - name: Install system dependencies 41 | run: | 42 | sudo apt update 43 | sudo apt install -y qt6-base-dev libsystemd-dev gcc 44 | - name: Install Poetry dependencies 45 | # if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' 46 | run: | 47 | poetry sync --no-interaction 48 | # Build and test Yin-Yang 49 | - name: Compile ui, translations and resources 50 | run: poetry run ./scripts/build_ui.sh 51 | - name: Lint with flake8 52 | run: | 53 | # stop the build if there are Python syntax errors or undefined names 54 | poetry run flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 55 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 56 | poetry run flake8 . --count --statistics 57 | - name: Test with pytest 58 | run: | 59 | poetry run pytest -v 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/flatpak,python,visualstudiocode 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=flatpak,python,visualstudiocode 3 | *~ 4 | __pycache__ 5 | build-test* 6 | build-ui-* 7 | yin_yang/build.py 8 | setup.py 9 | 10 | ### Flatpak ### 11 | .flatpak-builder 12 | build 13 | build-dir 14 | repo 15 | 16 | ### Python ### 17 | # Byte-compiled / optimized / DLL files 18 | __pycache__/ 19 | *.py[cod] 20 | *$py.class 21 | 22 | # C extensions 23 | *.so 24 | 25 | # Distribution / packaging 26 | .Python 27 | build/ 28 | develop-eggs/ 29 | dist/ 30 | downloads/ 31 | eggs/ 32 | .eggs/ 33 | lib/ 34 | lib64/ 35 | parts/ 36 | sdist/ 37 | var/ 38 | wheels/ 39 | share/python-wheels/ 40 | *.egg-info/ 41 | .installed.cfg 42 | *.egg 43 | MANIFEST 44 | 45 | # PyInstaller 46 | # Usually these files are written by a python script from a template 47 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 48 | *.manifest 49 | *.spec 50 | 51 | # Installer logs 52 | pip-log.txt 53 | pip-delete-this-directory.txt 54 | 55 | # Unit test / coverage reports 56 | htmlcov/ 57 | .tox/ 58 | .nox/ 59 | .coverage 60 | .coverage.* 61 | .cache 62 | nosetests.xml 63 | coverage.xml 64 | *.cover 65 | *.py,cover 66 | .hypothesis/ 67 | .pytest_cache/ 68 | cover/ 69 | 70 | # Translations 71 | *.mo 72 | *.pot 73 | 74 | # Django stuff: 75 | *.log 76 | local_settings.py 77 | db.sqlite3 78 | db.sqlite3-journal 79 | 80 | # Flask stuff: 81 | instance/ 82 | .webassets-cache 83 | 84 | # Scrapy stuff: 85 | .scrapy 86 | 87 | # Sphinx documentation 88 | docs/_build/ 89 | 90 | # PyBuilder 91 | .pybuilder/ 92 | target/ 93 | 94 | # Jupyter Notebook 95 | .ipynb_checkpoints 96 | 97 | # IPython 98 | profile_default/ 99 | ipython_config.py 100 | 101 | # pyenv 102 | # For a library or package, you might want to ignore these files since the code is 103 | # intended to run in multiple environments; otherwise, check them in: 104 | # .python-version 105 | 106 | # pipenv 107 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 108 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 109 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 110 | # install all needed dependencies. 111 | #Pipfile.lock 112 | 113 | # poetry 114 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 115 | # This is especially recommended for binary packages to ensure reproducibility, and is more 116 | # commonly ignored for libraries. 117 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 118 | #poetry.lock 119 | 120 | # pdm 121 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 122 | #pdm.lock 123 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 124 | # in version control. 125 | # https://pdm.fming.dev/#use-with-ide 126 | .pdm.toml 127 | 128 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 129 | __pypackages__/ 130 | 131 | # Celery stuff 132 | celerybeat-schedule 133 | celerybeat.pid 134 | 135 | # SageMath parsed files 136 | *.sage.py 137 | 138 | # Environments 139 | .env 140 | .venv 141 | env/ 142 | venv/ 143 | ENV/ 144 | env.bak/ 145 | venv.bak/ 146 | 147 | # Spyder project settings 148 | .spyderproject 149 | .spyproject 150 | 151 | # Rope project settings 152 | .ropeproject 153 | 154 | # mkdocs documentation 155 | /site 156 | 157 | # mypy 158 | .mypy_cache/ 159 | .dmypy.json 160 | dmypy.json 161 | 162 | # Pyre type checker 163 | .pyre/ 164 | 165 | # pytype static type analyzer 166 | .pytype/ 167 | 168 | # Cython debug symbols 169 | cython_debug/ 170 | 171 | # PyCharm 172 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 173 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 174 | # and can be added to the global gitignore or merged into this file. For a more nuclear 175 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 176 | #.idea/ 177 | 178 | ### Python Patch ### 179 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 180 | poetry.toml 181 | 182 | # ruff 183 | .ruff_cache/ 184 | 185 | # LSP config files 186 | pyrightconfig.json 187 | 188 | ### VisualStudioCode ### 189 | .vscode/* 190 | !.vscode/settings.json 191 | !.vscode/tasks.json 192 | !.vscode/launch.json 193 | !.vscode/extensions.json 194 | !.vscode/*.code-snippets 195 | 196 | # Local History for Visual Studio Code 197 | .history/ 198 | 199 | # Built Visual Studio Code Extensions 200 | *.vsix 201 | 202 | ### VisualStudioCode Patch ### 203 | # Ignore all local history of files 204 | .history 205 | .ionide 206 | 207 | # End of https://www.toptal.com/developers/gitignore/api/flatpak,python,visualstudiocode 208 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | extension-pkg-whitelist=PyQt5 -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Debug Yin-Yang", 9 | "type": "debugpy", 10 | "request": "launch", 11 | "console": "integratedTerminal", 12 | "module": "yin_yang", 13 | "justMyCode": true 14 | }, 15 | { 16 | "name": "Debug Poetry Generator", 17 | "type": "debugpy", 18 | "request": "launch", 19 | "console": "integratedTerminal", 20 | "program": "flatpak-poetry-generator.py", 21 | "justMyCode": true, 22 | "args": ["poetry.lock"] 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.pytestArgs": [ 3 | "tests" 4 | ], 5 | "python.testing.unittestEnabled": false, 6 | "python.testing.pytestEnabled": true, 7 | "black-formatter.args": [ 8 | "--skip-string-normalization" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Oskar Schachtschneider 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![Yin & Yang logo](resources/icon.svg) Yin-Yang 2 | 3 | ![](https://img.shields.io/github/v/release/oskarsh/yin-yang) 4 | ![](https://img.shields.io/github/v/release/oskarsh/yin-yang?include_prereleases) 5 | ![](https://img.shields.io/github/downloads/oskarsh/yin-yang/total) 6 | ![](https://img.shields.io/badge/Build%20with-Python-yellow) 7 | ![](https://img.shields.io/github/license/oskarsh/yin-yang) 8 | 9 | Auto Night-mode for Linux, it supports popular Desktops like KDE, GNOME, Budgie 10 | and also themes your favourite editors like VSCode or Atom. 11 | 12 | You might also want to take a look at our [**discussions page**](https://github.com/oskarsh/Yin-Yang/discussions), where we talk about the future of the app and other cool stuff! 13 | 14 | ![Visualization](.github/images/header.png) 15 | ![App configuration](.github/images/settings.png) 16 | 17 | ## Features 18 | 19 | - Changes your themes at certain times or sunrise and sunset 20 | - Supported Desktops: 21 | - GNOME 22 | - Budgie 23 | - KDE Plasma 24 | - Supported applications: 25 | - VSCode, Atom, gedit 26 | - Firefox & Brave 27 | - Kvantum 28 | - Konsole 29 | - OnlyOffice 30 | - and more... 31 | - Miscellaneous: 32 | - Wallpaper change 33 | - Notifications on theme change 34 | - Play a sound 35 | - Ability to run custom scripts 36 | 37 | > To see planned features and the development status, visit the [project status page](https://github.com/oskarsh/Yin-Yang/projects?type=classic). 38 | 39 | ## Installation 40 | 41 | ### Flatpak 42 | 43 | ```bash 44 | # follow the development setup 45 | poetry build 46 | # see https://github.com/flatpak/flatpak-builder/issues/237 if you have issues with rofiles 47 | flatpak-builder --install --user build sh.oskar.yin_yang.json --force-clean 48 | ``` 49 | 50 | ### Arch-based distributions 51 | 52 | Yin-Yang can be downloaded from AUR as [yin-yang](https://aur.archlinux.org/packages/yin-yang) package. 53 | 54 | ### Source 55 | 56 | Yin-Yang depends on `python-systemd` and `pyside6` from pypi. `python-systemd` requires you have installed the systemd-headers from your package manager. You also need python development headers (e.g. `python3-devel`) and the poetry build system for python. 57 | Preferably install `PySide6-Essentials` and `PySide6-Addons` from your system package manager as well. 58 | If they are not available there, uncomment the dependencies in `pyproject.toml`. 59 | 60 | For CentOS, RHEL, and Fedora: 61 | 62 | ```bash 63 | sudo dnf install gcc systemd-devel python3-devel libnotify poetry python3-pyside6 64 | ``` 65 | 66 | For OpenSUSE: 67 | 68 | ```bash 69 | sudo zypper refresh 70 | sudo zypper install gcc systemd-devel libnotify python311-poetry python3-PySide6 71 | ``` 72 | 73 | For Debian, Ubuntu, etc. 74 | 75 | ```bash 76 | sudo apt update 77 | sudo apt install libsystemd-dev gcc pkg-config python3-dev libnotify-bin python3-poetry python3-qtpy-pyside6 78 | ``` 79 | 80 | Then you can install Yin-Yang in a python virtual environment: 81 | 82 | ```bash 83 | # bash is necessary to run the source command 84 | bash 85 | # Clones the code to your local machine 86 | git clone https://github.com/oskarsh/Yin-Yang.git 87 | cd Yin-Yang 88 | # Installs Yin-Yang 89 | ./scripts/install.sh 90 | ``` 91 | 92 | For development, skip the installation and instead build python using Poetry. A virtual environment will be created for you: 93 | 94 | ```bash 95 | # Load into virtual environment 96 | poetry env use python 97 | # Install dependencies 98 | poetry sync 99 | # Load Yin-Yang 100 | poetry run python -m yin_yang 101 | ``` 102 | 103 | Make sure to run `flake8` on your files to avoid errors from the ci in PRs: 104 | ```bash 105 | poetry run flake8 106 | ``` 107 | 108 | ### Uninstall 109 | 110 | Run `scripts/uninstall.sh` from a terminal and fill out the password. 111 | 112 | ## Documentation 113 | 114 | Want to help out? Check out the wiki to learn how to contribute translations, plugins and more! 115 | 116 | [![Generic badge](https://img.shields.io/badge/Visit-Wiki-BLUE.svg)](https://github.com/oskarsh/Yin-Yang/wiki) 117 | 118 | ## Related or similar projects 119 | 120 | - Auto dark mode for Windows: https://github.com/AutoDarkMode/Windows-Auto-Night-Mode 121 | - Auto dark mode extension for GNOME: https://extensions.gnome.org/extension/2236/night-theme-switcher/ 122 | - Auto dark mode for Jetbrains IDEs: https://github.com/weisJ/auto-dark-mode 123 | - Sync dark mode with KDEs night color: https://github.com/adrium/knightadjuster 124 | - darkman: https://gitlab.com/WhyNotHugo/darkman 125 | - In Firefox, you can use the system theme to sync Firefox itself and supported applications with the theme of the system. When you use [dark reader](https://darkreader.org/), you can enable the system color automation. 126 | 127 | ## Thanks to all Contributors 128 | 129 | ### Code Contributors 130 | 131 | This project exists thanks to all the people who contribute. [[Contribute](https://github.com/oskarsh/Yin-Yang/wiki/Contributing)]. 132 | 133 | [![](https://opencollective.com/Yin-Yang/contributors.svg?button=false)](https://github.com/oskarsh/Yin-Yang/graphs/contributors) 134 | 135 | ### Donate 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | -------------------------------------------------------------------------------- /flatpak-poetry-generator.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | This file is a modified version of the file available here: 5 | https://github.com/flatpak/flatpak-builder-tools/blob/master/poetry/flatpak-poetry-generator.py 6 | 7 | The file above has a long-standing issue pulling dependencies for Pyside6. This modified version 8 | is hard-coded to work with Pyside6. Hopefully there is a better long-term fix in the future. 9 | 10 | Chase Christiansen, 04/13/2024 11 | """ 12 | __license__ = "MIT" 13 | 14 | import argparse 15 | import json 16 | import re 17 | import sys 18 | import urllib.parse 19 | import urllib.request 20 | from collections import OrderedDict 21 | import itertools 22 | 23 | import toml 24 | 25 | 26 | def get_pypi_source(name: str, version: str, hashes: list) -> tuple: 27 | """Get the source information for a dependency. 28 | 29 | Args: 30 | name (str): The package name. 31 | version (str): The package version. 32 | hashes (list): The list of hashes for the package version. 33 | 34 | Returns (tuple): The url and sha256 hash. 35 | 36 | """ 37 | url = "https://pypi.org/pypi/{}/json".format(name) 38 | print("Extracting download url and hash for {}, version {}".format(name, version)) 39 | with urllib.request.urlopen(url) as response: 40 | body = json.loads(response.read().decode("utf-8")) 41 | for release, source_list in body["releases"].items(): 42 | if release != version: 43 | continue 44 | 45 | for source in source_list: 46 | if ( 47 | name == "pyside6-addons" 48 | or name == "pyside6-essentials" 49 | or name == "shiboken6" 50 | ): 51 | if ( 52 | source["filename"].endswith("x86_64.whl") 53 | and "manylinux" in source["filename"] 54 | ): 55 | return source["url"], source["digests"]["sha256"] 56 | if ( 57 | source["packagetype"] == "bdist_wheel" 58 | and "py3" in source["python_version"] 59 | and source["digests"]["sha256"] in hashes 60 | ): 61 | return source["url"], source["digests"]["sha256"] 62 | for source in source_list: 63 | if ( 64 | source["packagetype"] == "sdist" 65 | and "source" in source["python_version"] 66 | and source["digests"]["sha256"] in hashes 67 | ): 68 | return source["url"], source["digests"]["sha256"] 69 | else: 70 | raise Exception("Failed to extract url and hash from {}".format(url)) 71 | 72 | 73 | def get_module_sources(parsed_lockfile: dict, include_devel: bool = True) -> list: 74 | """Gets the list of sources from a toml parsed lockfile. 75 | 76 | Args: 77 | parsed_lockfile (dict): The dictionary of the parsed lockfile. 78 | include_devel (bool): Include dev dependencies, defaults to True. 79 | 80 | Returns (list): The sources. 81 | 82 | """ 83 | sources = [] 84 | hash_re = re.compile(r"(sha1|sha224|sha384|sha256|sha512|md5):([a-f0-9]+)") 85 | for section, packages in parsed_lockfile.items(): 86 | if section != "package": 87 | continue 88 | 89 | for package in packages: 90 | if "category" in package and ( 91 | package.get("category") != "dev" or not include_devel or package.get("optional")) and ( 92 | package.get("category") != "main" or package.get("optional")): 93 | continue 94 | 95 | hashes = [] 96 | # Check for old metadata format (poetry version < 1.0.0b2) 97 | if "hashes" in parsed_lockfile["metadata"]: 98 | hashes = parsed_lockfile["metadata"]["hashes"][package["name"]] 99 | # metadata format 1.1 100 | elif "files" in parsed_lockfile["metadata"]: 101 | hashes.append(get_sources_11(package, parsed_lockfile, hash_re)) 102 | # metadata format 2.0 103 | else: 104 | hashes.append(get_sources_13(package, hash_re)) 105 | 106 | # make the list flat 107 | hashes_flat = list(itertools.chain.from_iterable(hashes)) 108 | 109 | url, hash = get_pypi_source( 110 | package["name"], package["version"], hashes_flat 111 | ) 112 | source = {"type": "file", "url": url, "sha256": hash} 113 | sources.append(source) 114 | return sources 115 | 116 | 117 | def get_sources_11(package, parsed_lockfile, hash_re) -> list: 118 | hashes = [] 119 | for package_name in parsed_lockfile["metadata"]["files"]: 120 | if package_name != package["name"]: 121 | continue 122 | 123 | package_files = parsed_lockfile["metadata"]["files"][ 124 | package["name"] 125 | ] 126 | num_files = len(package_files) 127 | for num in range(num_files): 128 | match = hash_re.search(package_files[num]["hash"]) 129 | if not match: 130 | continue 131 | 132 | hashes.append(match.group(2)) 133 | 134 | return hashes 135 | 136 | 137 | def get_sources_13(package, hash_re) -> list: 138 | hashes = [] 139 | 140 | for file in package["files"]: 141 | match = hash_re.search(file["hash"]) 142 | if not match: 143 | continue 144 | 145 | hashes.append(match.group(2)) 146 | 147 | return hashes 148 | 149 | 150 | def get_dep_names(parsed_lockfile: dict, include_devel: bool = True) -> list: 151 | """Gets the list of dependency names. 152 | 153 | Args: 154 | parsed_lockfile (dict): The dictionary of the parsed lockfile. 155 | include_devel (bool): Include dev dependencies, defaults to True. 156 | 157 | Returns (list): The dependency names. 158 | 159 | """ 160 | dep_names = [] 161 | for section, packages in parsed_lockfile.items(): 162 | if section != "package": 163 | continue 164 | 165 | for package in packages: 166 | if "category" in package and ( 167 | package.get("category") != "dev" or not include_devel or package.get("optional")) and ( 168 | package.get("category") != "main" or package.get("optional")): 169 | continue 170 | 171 | dep_names.append(package["name"]) 172 | 173 | return dep_names 174 | 175 | 176 | def main(): 177 | parser = argparse.ArgumentParser(description="Flatpak Poetry generator") 178 | parser.add_argument("lockfile", type=str) 179 | parser.add_argument( 180 | "-o", type=str, dest="outfile", default="generated-poetry-sources.json" 181 | ) 182 | parser.add_argument("--production", action="store_true", default=False) 183 | args = parser.parse_args() 184 | 185 | include_devel = not args.production 186 | outfile = args.outfile 187 | lockfile = args.lockfile 188 | 189 | print('Scanning "%s" ' % lockfile, file=sys.stderr) 190 | 191 | with open(lockfile, "r") as f: 192 | parsed_lockfile = toml.load(f) 193 | dep_names = get_dep_names(parsed_lockfile, include_devel=include_devel) 194 | pip_command = [ 195 | "pip3", 196 | "install", 197 | "--no-index", 198 | '--find-links="file://${PWD}"', 199 | "--prefix=${FLATPAK_DEST}", 200 | " ".join(dep_names), 201 | ] 202 | main_module = OrderedDict( 203 | [ 204 | ("name", "poetry-deps"), 205 | ("buildsystem", "simple"), 206 | ("build-commands", [" ".join(pip_command)]), 207 | ] 208 | ) 209 | sources = get_module_sources(parsed_lockfile, include_devel=include_devel) 210 | main_module["sources"] = sources 211 | 212 | print(" ... %d new entries" % len(sources), file=sys.stderr) 213 | 214 | print('Writing to "%s"' % outfile) 215 | with open(outfile, "w") as f: 216 | f.write(json.dumps(main_module, indent=4)) 217 | 218 | 219 | if __name__ == "__main__": 220 | main() 221 | -------------------------------------------------------------------------------- /generated-poetry-sources.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "poetry-deps", 3 | "buildsystem": "simple", 4 | "build-commands": [ 5 | "pip3 install --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} certifi charset-normalizer colorama cython exceptiongroup flake8 idna iniconfig mccabe packaging pluggy psutil pycodestyle pyflakes pyside6-addons pyside6-essentials pytest python-dateutil pyyaml requests setuptools shiboken6 six suntime systemd-python toml tomli urllib3 wheel" 6 | ], 7 | "sources": [ 8 | { 9 | "type": "file", 10 | "url": "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", 11 | "sha256": "30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3" 12 | }, 13 | { 14 | "type": "file", 15 | "url": "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", 16 | "sha256": "7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0" 17 | }, 18 | { 19 | "type": "file", 20 | "url": "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", 21 | "sha256": "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" 22 | }, 23 | { 24 | "type": "file", 25 | "url": "https://files.pythonhosted.org/packages/7e/26/9d8de10005fedb1eceabe713348d43bae1dbab1786042ca0751a2e2b0f8c/Cython-0.29.37-py2.py3-none-any.whl", 26 | "sha256": "95f1d6a83ef2729e67b3fa7318c829ce5b07ac64c084cd6af11c228e0364662c" 27 | }, 28 | { 29 | "type": "file", 30 | "url": "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", 31 | "sha256": "3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b" 32 | }, 33 | { 34 | "type": "file", 35 | "url": "https://files.pythonhosted.org/packages/83/5c/0627be4c9976d56b1217cb5187b7504e7fd7d3503f8bfd312a04077bd4f7/flake8-7.2.0-py2.py3-none-any.whl", 36 | "sha256": "93b92ba5bdb60754a6da14fa3b93a9361fd00a59632ada61fd7b130436c40343" 37 | }, 38 | { 39 | "type": "file", 40 | "url": "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", 41 | "sha256": "946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" 42 | }, 43 | { 44 | "type": "file", 45 | "url": "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", 46 | "sha256": "9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760" 47 | }, 48 | { 49 | "type": "file", 50 | "url": "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", 51 | "sha256": "6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e" 52 | }, 53 | { 54 | "type": "file", 55 | "url": "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", 56 | "sha256": "29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484" 57 | }, 58 | { 59 | "type": "file", 60 | "url": "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", 61 | "sha256": "44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669" 62 | }, 63 | { 64 | "type": "file", 65 | "url": "https://files.pythonhosted.org/packages/2a/80/336820c1ad9286a4ded7e845b2eccfcb27851ab8ac6abece774a6ff4d3de/psutil-7.0.0.tar.gz", 66 | "sha256": "7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456" 67 | }, 68 | { 69 | "type": "file", 70 | "url": "https://files.pythonhosted.org/packages/07/be/b00116df1bfb3e0bb5b45e29d604799f7b91dd861637e4d448b4e09e6a3e/pycodestyle-2.13.0-py2.py3-none-any.whl", 71 | "sha256": "35863c5974a271c7a726ed228a14a4f6daf49df369d8c50cd9a6f58a5e143ba9" 72 | }, 73 | { 74 | "type": "file", 75 | "url": "https://files.pythonhosted.org/packages/15/40/b293a4fa769f3b02ab9e387c707c4cbdc34f073f945de0386107d4e669e6/pyflakes-3.3.2-py2.py3-none-any.whl", 76 | "sha256": "5039c8339cbb1944045f4ee5466908906180f13cc99cc9949348d10f82a5c32a" 77 | }, 78 | { 79 | "type": "file", 80 | "url": "https://files.pythonhosted.org/packages/58/c1/21224090a7ee7e9ce5699e5bf16b84d576b7587f0712ccb6862a8b28476c/PySide6_Addons-6.9.0-cp39-abi3-manylinux_2_28_x86_64.whl", 81 | "sha256": "fc9dcd63a0ce7565f238cb11c44494435a50eb6cb72b8dbce3b709618989c3dc" 82 | }, 83 | { 84 | "type": "file", 85 | "url": "https://files.pythonhosted.org/packages/9e/fd/46b713827007162de9108b22d01702868e75f31585da7eca5a79e3435590/PySide6_Essentials-6.9.0-cp39-abi3-manylinux_2_28_x86_64.whl", 86 | "sha256": "45eaf7f17688d1991f39680dbfd3c41674f3cbb78f278aa10fe0b5f2f31c1989" 87 | }, 88 | { 89 | "type": "file", 90 | "url": "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", 91 | "sha256": "c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820" 92 | }, 93 | { 94 | "type": "file", 95 | "url": "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", 96 | "sha256": "a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" 97 | }, 98 | { 99 | "type": "file", 100 | "url": "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", 101 | "sha256": "d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e" 102 | }, 103 | { 104 | "type": "file", 105 | "url": "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", 106 | "sha256": "70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6" 107 | }, 108 | { 109 | "type": "file", 110 | "url": "https://files.pythonhosted.org/packages/f7/29/13965af254e3373bceae8fb9a0e6ea0d0e571171b80d6646932131d6439b/setuptools-69.5.1-py3-none-any.whl", 111 | "sha256": "c636ac361bc47580504644275c9ad802c50415c7522212252c033bd15f301f32" 112 | }, 113 | { 114 | "type": "file", 115 | "url": "https://files.pythonhosted.org/packages/45/d3/f6ddef22d4f2ac11c079157ad3714d9b1fb9324d9cd3b200f824923fe2ba/shiboken6-6.9.0-cp39-abi3-manylinux_2_28_x86_64.whl", 116 | "sha256": "3f585caae5b814a7e23308db0a077355a7dc20c34d58ca4c339ff7625e9a1936" 117 | }, 118 | { 119 | "type": "file", 120 | "url": "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", 121 | "sha256": "4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274" 122 | }, 123 | { 124 | "type": "file", 125 | "url": "https://files.pythonhosted.org/packages/d1/d5/bb9997169b8b64d48f9a807fb2ec2413ff5e75c4b77612e75dd0aac8369c/suntime-1.3.2-py3-none-any.whl", 126 | "sha256": "33ac6ec2a3e14758cc690f7573f689d19c3131a6c9753f1bb54460bd70372ca4" 127 | }, 128 | { 129 | "type": "file", 130 | "url": "https://files.pythonhosted.org/packages/10/9e/ab4458e00367223bda2dd7ccf0849a72235ee3e29b36dce732685d9b7ad9/systemd-python-235.tar.gz", 131 | "sha256": "4e57f39797fd5d9e2d22b8806a252d7c0106c936039d1e71c8c6b8008e695c0a" 132 | }, 133 | { 134 | "type": "file", 135 | "url": "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", 136 | "sha256": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" 137 | }, 138 | { 139 | "type": "file", 140 | "url": "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", 141 | "sha256": "cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc" 142 | }, 143 | { 144 | "type": "file", 145 | "url": "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", 146 | "sha256": "4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813" 147 | }, 148 | { 149 | "type": "file", 150 | "url": "https://files.pythonhosted.org/packages/7d/cd/d7460c9a869b16c3dd4e1e403cce337df165368c71d6af229a74699622ce/wheel-0.43.0-py3-none-any.whl", 151 | "sha256": "55c570405f142630c6b9f72fe09d9b67cf1477fcf543ae5b8dcb1f5b7377da81" 152 | } 153 | ] 154 | } -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "yin_yang" 3 | version = "4.0.0" 4 | license = "MIT" 5 | description = "Auto Nightmode for KDE, Gnome, Budgie, VSCode, Atom and more." 6 | authors = [ 7 | { name = "Oskar Schachtschneider" } 8 | ] 9 | readme = "README.md" 10 | requires-python = ">=3.10, <3.14" 11 | dependencies = [ 12 | # PySide is already included in the Flatpak base app. 13 | # If building from source, preferably install these from your system package manager. 14 | # "PySide6-Essentials==6.8.1", 15 | # "PySide6-Addons==6.8.1", 16 | "psutil (>=7.0.0)", 17 | "shiboken6 (>=6.8.0)", 18 | "suntime~=1.3.2", 19 | "systemd-python==235", 20 | "requests==2.32.3", 21 | "python-dateutil~=2.9.0.post0" 22 | ] 23 | 24 | [project.urls] 25 | repository = "https://github.com/oskarsh/Yin-Yang" 26 | 27 | [project.scripts] 28 | yin_yang = "yin_yang:__main__" 29 | 30 | [tool.poetry] 31 | requires-poetry = ">=2.0" 32 | packages = [ 33 | { include = "yin_yang" } 34 | ] 35 | 36 | [tool.poetry.group.DEV.dependencies] 37 | flake8 = "^7.0.0" 38 | pytest = "^8.1.1" 39 | pyyaml = "^6.0.1" 40 | toml = "^0.10.2" 41 | setuptools = "^69.5.1" 42 | wheel = "^0.43.0" 43 | cython = "<3.0" 44 | # necessary to compile ui files 45 | PySide6-Essentials="^6.8.1" 46 | PySide6-Addons="^6.8.1" 47 | 48 | [build-system] 49 | requires = ["poetry-core"] 50 | build-backend = "poetry.core.masonry.api" 51 | 52 | [tool.pyright] 53 | include = ["yin_yang"] 54 | exclude = ["**/node_modules", 55 | "**/__pycache__", 56 | "build", 57 | "pytest_cache", 58 | ".flatpak-builder" 59 | ] 60 | 61 | [tool.pytest.ini_options] 62 | addopts = "--import-mode=importlib" -------------------------------------------------------------------------------- /resources/README.md: -------------------------------------------------------------------------------- 1 | # Info for packagers 2 | 3 | The files in this directory should be installed to these locations: 4 | 5 | | File | Path | Description | 6 | |--------------------------------------|--------------------------------------------|--------------------------------------------------| 7 | | `yin_yang` | `/usr/bin` | Executable for the terminal | 8 | | `yin_yang.desktop` | `~/.local/share/applications/` | Desktop file to start the application from menus | 9 | | `logo.svg` | `/usr/share/icons/hicolor/scalable/apps/` | Logo of the application | 10 | | `yin_yang.service`, `yin_yang.timer` | `~/.local/share/systemd/user/` | systemd unit files | 11 | | `yin_yang.json` | `/usr/lib/mozilla/native-messaging-hosts/` | Manifest file for the Firefox extension | 12 | 13 | There is an installation script available under `./scripts/install.sh` 14 | 15 | After installation, the systemd timer must be enabled: 16 | ```shell 17 | systemctl --user enable yin_yang.timer 18 | ``` 19 | -------------------------------------------------------------------------------- /resources/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 16 | 35 | 38 | 39 | 41 | 44 | 48 | 52 | 53 | 56 | 60 | 64 | 65 | 67 | 72 | 73 | 75 | 80 | 81 | 90 | 99 | 107 | 112 | 113 | 119 | 124 | 128 | 129 | 139 | 149 | 150 | 155 | 167 | 168 | 172 | 179 | 188 | 195 | 203 | 211 | 212 | 218 | 230 | 231 | 232 | -------------------------------------------------------------------------------- /resources/resources.qrc: -------------------------------------------------------------------------------- 1 | 2 | 3 | icon.svg 4 | 5 | 6 | translations/yin_yang.de_DE.qm 7 | translations/yin_yang.nl_NL.ts 8 | translations/yin_yang.zh_CN.ts 9 | translations/yin_yang.zh_TW.ts 10 | 11 | 12 | -------------------------------------------------------------------------------- /resources/sh.oskar.yin_yang.metainfo.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | sh.oskar.yin_yang 4 | CC0-1.0 5 | MIT 6 | 7 | 8 | Yin & Yang 9 | Automatically switch between light and dark themes 10 | sh.oskar.yin_yang 11 | 12 |

13 | Yin & Yang allows you to automatically switch between light and dark themes in various 14 | desktop environments and apps. The change can happen at certain times defined by you, 15 | or even at sunset and sunrise times of your current location. 16 | If you prefer, you can also manually trigger a theme switch through an icon in the taskbar. 17 |

18 |
19 | 20 | 21 | Lorenz Hoffmann 22 | 23 | 24 | 25 | Utility 26 | System 27 | Settings 28 | 29 | 30 | night 31 | day 32 | dark 33 | light 34 | color 35 | theme 36 | 37 | 38 | https://github.com/oskarsh/Yin-Yang 39 | https://github.com/oskarsh/Yin-Yang/issues 40 | https://www.patreon.com/L0drex 41 | 42 | 43 | 44 | https://raw.githubusercontent.com/oskarsh/Yin-Yang/master/.github/images/header.png 45 | 46 | 47 | https://raw.githubusercontent.com/oskarsh/Yin-Yang/master/.github/images/settings.png 48 | 49 | 50 | 51 | 52 | #e0e0e0 53 | #0c0c0c 54 | 55 | 56 | 57 | 600 58 | 59 | 60 | sh.oskar.yin_yang.desktop 61 | 62 | 63 | yin_yang 64 | yin_yang 65 | 66 | 67 | yin_yang 68 | yin_yang 69 | yin_yang 70 | yin_yang 71 | yin_yang 72 | 73 | 74 | 75 | https://github.com/oskarsh/Yin-Yang/releases/tag/v3.4 76 | 77 |
    78 |
  • Added support for Budgie
  • 79 |
  • Improved support for VS Code and derivatives
  • 80 |
  • Improved support for Gtk on KDE and Wallpaper on Gnome
  • 81 |
  • Removed support for Atom, Gedit and Brave
  • 82 |
  • Removed the option to make a sound on theme change
  • 83 |
  • Added taiwanese translations
  • 84 |
  • Added a command line option to start only the tray icon
  • 85 |
86 |
87 |
88 | 89 | https://github.com/oskarsh/Yin-Yang/releases/tag/v3.3 90 | 91 |
    92 |
  • Added support for Okular, Mate, Cinnamon
  • 93 |
  • Added a system tray icon and buttons in the UI to change the theme manually
  • 94 |
95 |
96 |
97 | 98 | https://github.com/oskarsh/Yin-Yang/releases/tag/v3.2.4 99 | 100 |
    101 |
  • Improved Konsole plugin, so it can change its theme without a restart
  • 102 |
103 |
104 |
105 | 106 | https://github.com/oskarsh/Yin-Yang/releases/tag/v3.2.0 107 | 108 |
    109 |
  • Added support for Brave, Only Office, Gedit, Konsole and custom scripts
  • 110 |
  • Improved support for XFCE and Kvantum
  • 111 |
  • Changing only the KDE color scheme is now possible
  • 112 |
113 |
114 |
115 | 116 | https://github.com/oskarsh/Yin-Yang/releases/tag/v3.1.0 117 | 118 |
    119 |
  • Systemd is used for scheduling
  • 120 |
  • User is now correctly warned about unsaved changed when closing the app.
  • 121 |
  • VS Code plugin no longer crashes the app
  • 122 |
123 |
124 |
125 | 126 | https://github.com/oskarsh/Yin-Yang/releases/tag/v3.0.0 127 | 128 |
    129 |
  • Option to follow the sun position is now accessible from the UI
  • 130 |
  • Translations are now possible
  • 131 |
  • Notifications can optionally be shown when the theme changes
  • 132 |
  • Logo looks a bit cleaner and is now themeable
  • 133 |
134 |
135 |
136 | 137 | https://github.com/oskarsh/Yin-Yang/releases/tag/v1.0-beta 138 | 139 |
    140 |
  • Added support for Gnome, Budgie
  • 141 |
  • Scheduler should work more reliably
  • 142 |
  • KDE is now more reliably recognized
  • 143 |
144 |
145 |
146 |
147 |
-------------------------------------------------------------------------------- /resources/translations/yin_yang.de_DE.qm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oskarsh/Yin-Yang/19feb5938cdf52a4cf2592ef5b4a081c4911feb8/resources/translations/yin_yang.de_DE.qm -------------------------------------------------------------------------------- /resources/translations/yin_yang.de_DE.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | main_window 6 | 7 | 8 | Settings 9 | Einstellungen 10 | 11 | 12 | 13 | Automatic theme switching 14 | Automatischer Themenwechsel 15 | 16 | 17 | 18 | Custom Schedule 19 | Benutzerdefinierter Zeitraum 20 | 21 | 22 | 23 | Sunset to Sunrise 24 | Sonnenaufgang bis Sonnenuntergang 25 | 26 | 27 | 28 | Light: 29 | Hell: 30 | 31 | 32 | 33 | Dark: 34 | Dunkel: 35 | 36 | 37 | 38 | Longitude: 39 | Längengrad: 40 | 41 | 42 | 43 | Latitude: 44 | Breitengrad: 45 | 46 | 47 | 48 | update location automatically 49 | Position automatisch bestimmen 50 | 51 | 52 | 53 | Light 54 | Hell 55 | 56 | 57 | 58 | Dark 59 | Dunkel 60 | 61 | 62 | 63 | Send a notification 64 | Sende eine Benachrichtigung 65 | 66 | 67 | 68 | Time to wait until the system finished booting. Default value is 10 seconds. 69 | Zeit die gewartet werden soll während das System startet. Standardwert ist 10 Sekunden. 70 | 71 | 72 | 73 | Delay after boot: 74 | Verzögerung nach Start 75 | 76 | 77 | 78 | s 79 | 80 | 81 | 82 | 83 | Plugins 84 | 85 | 86 | 87 | 88 | systray 89 | 90 | 91 | Open Yin Yang 92 | Context menu action in the systray 93 | Yin Yang öffnen 94 | 95 | 96 | 97 | Toggle theme 98 | Context menu action in the systray 99 | Farbschema wechseln 100 | 101 | 102 | 103 | Quit 104 | Context menu action in the systray 105 | Beenden 106 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /resources/translations/yin_yang.nl_NL.qm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oskarsh/Yin-Yang/19feb5938cdf52a4cf2592ef5b4a081c4911feb8/resources/translations/yin_yang.nl_NL.qm -------------------------------------------------------------------------------- /resources/translations/yin_yang.nl_NL.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | main_window 6 | 7 | 8 | Settings 9 | Instellingen 10 | 11 | 12 | 13 | Automatic theme switching 14 | Licht/Donker thema automatisch instellen 15 | 16 | 17 | 18 | Custom Schedule 19 | Eigen tijdschema 20 | 21 | 22 | 23 | Sunset to Sunrise 24 | Van zonsopkomst tot zonsondergang 25 | 26 | 27 | 28 | Light: 29 | Licht: 30 | 31 | 32 | 33 | Dark: 34 | Donker: 35 | 36 | 37 | 38 | Longitude: 39 | Lengtegraad: 40 | 41 | 42 | 43 | Latitude: 44 | Breedtegraad: 45 | 46 | 47 | 48 | update location automatically 49 | 50 | 51 | 52 | 53 | Light 54 | 55 | 56 | 57 | 58 | Dark 59 | 60 | 61 | 62 | 63 | Send a notification 64 | Melding tonen 65 | 66 | 67 | 68 | Time to wait until the system finished booting. Default value is 10 seconds. 69 | 70 | 71 | 72 | 73 | Delay after boot: 74 | 75 | 76 | 77 | 78 | s 79 | 80 | 81 | 82 | 83 | Plugins 84 | Plug-ins 85 | 86 | 87 | 88 | systray 89 | 90 | 91 | Open Yin Yang 92 | Context menu action in the systray 93 | 94 | 95 | 96 | 97 | Toggle theme 98 | Context menu action in the systray 99 | 100 | 101 | 102 | 103 | Quit 104 | Context menu action in the systray 105 | 106 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /resources/translations/yin_yang.zh_CN.qm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oskarsh/Yin-Yang/19feb5938cdf52a4cf2592ef5b4a081c4911feb8/resources/translations/yin_yang.zh_CN.qm -------------------------------------------------------------------------------- /resources/translations/yin_yang.zh_CN.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | main_window 6 | 7 | 8 | Settings 9 | 设置 10 | 11 | 12 | 13 | Automatic theme switching 14 | 自动切换主题 15 | 16 | 17 | 18 | Custom Schedule 19 | 自定义切换时间 20 | 21 | 22 | 23 | Light: 24 | 明亮模式: 25 | 26 | 27 | 28 | Dark: 29 | 暗黑模式: 30 | 31 | 32 | 33 | Sunset to Sunrise 34 | 日落至日出 35 | 36 | 37 | 38 | Longitude: 39 | 经度: 40 | 41 | 42 | 43 | Latitude: 44 | 纬度: 45 | 46 | 47 | 48 | update location automatically 49 | 自动更新经纬度 50 | 51 | 52 | 53 | Light 54 | 明亮模式 55 | 56 | 57 | 58 | Dark 59 | 暗黑模式 60 | 61 | 62 | 63 | Send a notification 64 | 切换时发送通知 65 | 66 | 67 | 68 | Time to wait until the system finished booting. Default value is 10 seconds. 69 | 开机后延迟秒数。默认值为 10 秒。 70 | 71 | 72 | 73 | Delay after boot: 74 | 系统启动后生效延迟: 75 | 76 | 77 | 78 | s 79 | 80 | 81 | 82 | 83 | Plugins 84 | 插件 85 | 86 | 87 | 88 | systray 89 | 90 | 91 | Open Yin Yang 92 | Context menu action in the systray 93 | 打开 Yin Yang 94 | 95 | 96 | 97 | Toggle theme 98 | Context menu action in the systray 99 | 切换主题 100 | 101 | 102 | 103 | Quit 104 | Context menu action in the systray 105 | 退出 106 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /resources/translations/yin_yang.zh_TW.qm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oskarsh/Yin-Yang/19feb5938cdf52a4cf2592ef5b4a081c4911feb8/resources/translations/yin_yang.zh_TW.qm -------------------------------------------------------------------------------- /resources/translations/yin_yang.zh_TW.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | main_window 6 | 7 | 8 | Settings 9 | 設定 10 | 11 | 12 | 13 | Automatic theme switching 14 | 自動切換主題 15 | 16 | 17 | 18 | Custom Schedule 19 | 自訂時間排程 20 | 21 | 22 | 23 | Light: 24 | 明亮模式: 25 | 26 | 27 | 28 | Dark: 29 | 暗黑模式: 30 | 31 | 32 | 33 | Sunset to Sunrise 34 | 日落至日出 35 | 36 | 37 | 38 | Longitude: 39 | 經度: 40 | 41 | 42 | 43 | Latitude: 44 | 緯度: 45 | 46 | 47 | 48 | update location automatically 49 | 自動更新經緯度 50 | 51 | 52 | 53 | Light 54 | 明亮模式 55 | 56 | 57 | 58 | Dark 59 | 暗黑模式 60 | 61 | 62 | 63 | Send a notification 64 | 推送通知 65 | 66 | 67 | 68 | Time to wait until the system finished booting. Default value is 10 seconds. 69 | 開機後延遲秒數。預設為十秒。 70 | 71 | 72 | 73 | Delay after boot: 74 | 延遲秒數: 75 | 76 | 77 | 78 | s 79 | 80 | 81 | 82 | 83 | Plugins 84 | 擴充功能 85 | 86 | 87 | 88 | systray 89 | 90 | 91 | Open Yin Yang 92 | Context menu action in the systray 93 | 94 | 95 | 96 | 97 | Toggle theme 98 | Context menu action in the systray 99 | 100 | 101 | 102 | 103 | Quit 104 | Context menu action in the systray 105 | 106 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /resources/yin_yang: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd /opt/yin_yang/ || exit 1 3 | # check whether the activate script is readable, then activate the venv 4 | [[ -r .venv/bin/activate ]] && source .venv/bin/activate 5 | python3 -Om yin_yang "$@" 6 | -------------------------------------------------------------------------------- /resources/yin_yang.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yin_yang", 3 | "description": "Yin-Yang on your pc.", 4 | "path": "/opt/yin_yang/communicate.py", 5 | "type": "stdio", 6 | "allowed_extensions": [ "firefox-extension@yin-yang.org" ] 7 | } 8 | -------------------------------------------------------------------------------- /resources/yin_yang.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Automatic light and dark mode 3 | After=suspend.target 4 | 5 | [Service] 6 | ExecStart=/usr/bin/yin_yang --systemd 7 | -------------------------------------------------------------------------------- /resources/yin_yang.timer: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Automatic light and dark mode 3 | 4 | [Timer] 5 | OnCalendar=08:00:00 6 | OnCalendar=20:00:00 7 | OnStartupSec=10 8 | 9 | [Install] 10 | WantedBy=timers.target 11 | -------------------------------------------------------------------------------- /scripts/build_ui.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # resource file 4 | pyside6-rcc ./resources/resources.qrc -o ./yin_yang/ui/resources_rc.py 5 | # ui file from qt designer 6 | pyside6-uic --from-imports ./designer/main_window.ui -o ./yin_yang/ui/main_window.py 7 | # extract strings to translate (doesn't work with .pro file unfortunately) 8 | pyside6-lupdate ./designer/main_window.ui ./yin_yang/* \ 9 | -ts resources/translations/yin_yang.*.ts -no-obsolete 10 | # generate binary translation files 11 | pyside6-lrelease ./resources/translations/yin_yang.*.ts 12 | -------------------------------------------------------------------------------- /scripts/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | USER_HOME=${1-${HOME}} 6 | 7 | if test ${EUID} -ne 0; then 8 | echo enter password in order to install Yin-Yang correctly 9 | exec sudo su -c "\"${0}\" \"${USER_HOME}\"" 10 | exit 0 11 | fi 12 | 13 | echo "Uninstalling old version, if it exists" 14 | ./scripts/uninstall.sh 15 | 16 | echo "Installing dependencies …" 17 | # Tell Poetry not to use a keyring 18 | export PYTHON_KEYRING_BACKEND=keyring.backends.null.Keyring 19 | # create virtual environment and install packages 20 | poetry env use python 21 | poetry install --sync 22 | poetry build 23 | pip install ./dist/yin_yang-*-py3-none-any.whl 24 | 25 | echo "Installing yin yang" 26 | #check if /opt/ directory exists else create 27 | if [ ! -d /opt/ ]; then 28 | mkdir -p /opt/ 29 | fi 30 | #check if /opt/ directory exists else create 31 | if [ ! -d /opt/yin_yang/ ]; then 32 | mkdir -p /opt/yin_yang/ 33 | fi 34 | # check directories for extension 35 | if [ ! -d /usr/lib/mozilla ]; then 36 | mkdir -p /usr/lib/mozilla 37 | fi 38 | if [ ! -d /usr/lib/mozilla/native-messaging-hosts/ ]; then 39 | mkdir -p /usr/lib/mozilla/native-messaging-hosts/ 40 | fi 41 | if [ ! -d "$USER_HOME/.local/share/applications/" ]; then 42 | mkdir -p "$USER_HOME/.local/share/applications/" 43 | fi 44 | # copy files TODO this copies a bunch of unnecessary files 45 | cp -r ./* /opt/yin_yang/ 46 | # copy manifest for firefox extension 47 | cp ./resources/yin_yang.json /usr/lib/mozilla/native-messaging-hosts/ 48 | # copy terminal executive 49 | cp ./resources/yin_yang /usr/bin/ 50 | # copy .desktop file 51 | appstreamcli make-desktop-file resources/sh.oskar.yin_yang.metainfo.xml "$USER_HOME/.local/share/applications/yin_yang.desktop" 52 | # copy icon 53 | cp ./resources/icon.svg /usr/share/icons/hicolor/scalable/apps/sh.oskar.yin_yang.svg 54 | # systemd unit files will be installed by the app 55 | 56 | cat << "EOF" 57 | __ ___ __ __ 58 | \ \ / (_) \ \ / / 59 | \ \_/ / _ _ __ _____\ \_/ /_ _ _ __ __ _ 60 | \ / | | '_ \______\ / _` | '_ \ / _` | 61 | | | | | | | | | | (_| | | | | (_| | 62 | |_| |_|_| |_| |_|\__,_|_| |_|\__, | 63 | __/ | 64 | |___/ 65 | EOF 66 | echo "" 67 | echo "Yin & Yang brings Auto Night mode for Linux" 68 | echo "" 69 | cat << "EOF" 70 | _..oo8"""Y8b.._ 71 | .88888888o. "Yb. 72 | .d888P""Y8888b "b. 73 | o88888 88888) "b 74 | d888888b..d8888P 'b 75 | 88888888888888" 8 76 | (88DWB8888888P 8) 77 | 8888888888P 8 78 | Y88888888P ee .P 79 | Y888888( 8888 oP 80 | "Y88888b "" oP" 81 | "Y8888o._ _.oP" 82 | `""Y888boodP""' 83 | 84 | EOF 85 | echo "" 86 | echo "" 87 | echo "checkout https://github.com/daehruoydeef/Yin-Yang for help" 88 | echo "Yin & Yang is now installed" 89 | echo "Please make sure you have systemd and python-systemd installed on your system via your package manager!" 90 | -------------------------------------------------------------------------------- /scripts/runner.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | python3 -Om yin_yang "$@" 3 | -------------------------------------------------------------------------------- /scripts/uninstall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # this script will uninstall Yin & Yang and will also delete its config files 4 | 5 | set -euo pipefail 6 | 7 | # check, if sudo 8 | if test ${EUID} -ne 0; then 9 | echo "enter password in order to install Yin & Yang correctly" 10 | exec sudo su -c "${0} ${HOME}" 11 | exit 0 12 | fi 13 | 14 | echo "Removing config and .desktop file" 15 | rm -f "$HOME/.local/share/applications/yin_yang.desktop" 16 | rm -f "$HOME/.local/share/yin_yang.log" 17 | rm -f "/usr/share/icons/hicolor/scalable/apps/yin_yang.svg" 18 | # rm -rf "$HOME/.config/yin_yang" 19 | 20 | echo "Removing program and terminal execution" 21 | rm -rf /opt/yin_yang /usr/bin/yin_yang 22 | 23 | echo "Removing manifest" 24 | rm -f /usr/lib/mozilla/native-messaging-hosts/yin_yang.json 25 | 26 | echo "Removing systemd units" 27 | rm -f "$HOME/.local/share/systemd/user/yin_yang.timer" 28 | rm -f "$HOME/.local/share/systemd/user/yin_yang.service" 29 | 30 | echo "Yin & Yang uninstalled succesfully" 31 | echo have a nice day ... 32 | -------------------------------------------------------------------------------- /sh.oskar.yin_yang.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "sh.oskar.yin_yang", 3 | "runtime": "org.kde.Platform", 4 | "runtime-version": "6.8", 5 | "sdk": "org.kde.Sdk", 6 | "base": "io.qt.PySide.BaseApp", 7 | "base-version": "6.8", 8 | "command": "yin_yang", 9 | "build-options":{ 10 | "env": [ 11 | "BASEAPP_REMOVE_WEBENGINE=1", 12 | "BASEAPP_DISABLE_NUMPY=1" 13 | ] 14 | }, 15 | "cleanup-commands": [ 16 | "/app/cleanup-BaseApp.sh" 17 | ], 18 | "finish-args": [ 19 | "--share=network", 20 | "--socket=x11", 21 | "--socket=wayland", 22 | "--talk-name=org.xfce.Xfconf", 23 | "--talk-name=org.kde.plasmashell", 24 | "--talk-name=org.kde.GtkConfig", 25 | "--talk-name=org.kde.yakuake", 26 | "--talk-name=org.freedesktop.Flatpak", 27 | "--talk-name=org.kde.StatusNotifierWatcher", 28 | "--share=ipc", 29 | "--device=dri", 30 | "--filesystem=host:rw", 31 | "--filesystem=~/.mozilla:rw" 32 | ], 33 | "modules": [ 34 | "generated-poetry-sources.json", 35 | { 36 | "name": "yin_yang", 37 | "buildsystem": "simple", 38 | "build-commands": [ 39 | "find dist -name 'yin_yang-*-py3-none-any.whl' -exec pip install --no-deps --no-build-isolation --prefix=/app {} \\;", 40 | "install -D scripts/runner.sh /app/bin/yin_yang.sh" 41 | ], 42 | "sources": [ 43 | { 44 | "type": "dir", 45 | "path": "." 46 | } 47 | ] 48 | }, 49 | { 50 | "name": "yin_yang-metadata", 51 | "buildsystem": "simple", 52 | "build-commands": [ 53 | "install -Dm664 icon.svg /app/share/icons/hicolor/scalable/apps/sh.oskar.yin_yang.svg", 54 | "install -Dm664 sh.oskar.yin_yang.metainfo.xml /app/share/metainfo/sh.oskar.yin_yang.metainfo.xml", 55 | "appstreamcli make-desktop-file sh.oskar.yin_yang.metainfo.xml yin_yang.desktop", 56 | "install -Dm644 yin_yang.desktop /app/share/applications/sh.oskar.yin_yang.desktop" 57 | ], 58 | "sources": [ 59 | { 60 | "type": "file", 61 | "path": "./resources/icon.svg" 62 | }, 63 | { 64 | "type": "file", 65 | "path": "./resources/sh.oskar.yin_yang.metainfo.xml" 66 | } 67 | ] 68 | } 69 | ] 70 | } -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oskarsh/Yin-Yang/19feb5938cdf52a4cf2592ef5b4a081c4911feb8/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_communication.py: -------------------------------------------------------------------------------- 1 | import json 2 | import struct 3 | import sys 4 | import unittest 5 | from datetime import datetime, time 6 | from subprocess import Popen, PIPE 7 | 8 | from yin_yang import communicate 9 | from yin_yang.meta import PluginKey 10 | from yin_yang.config import config 11 | from yin_yang.theme_switcher import should_be_dark 12 | 13 | 14 | def should_be_dark_extensions(time_current: int, time_dark: int): 15 | """Determines if dark mode should be active like the extensions do""" 16 | return time_dark <= time_current 17 | 18 | 19 | class CommunicationTest(unittest.TestCase): 20 | def test_move_time(self): 21 | time_light = time.fromisoformat('07:00') 22 | time_dark = time.fromisoformat('20:00') 23 | 24 | times = [ 25 | # morning 26 | datetime.strptime('03:00', '%H:%M'), 27 | # day 28 | datetime.strptime('12:00', '%H:%M'), 29 | # night 30 | datetime.strptime('22:00', '%H:%M') 31 | ] 32 | 33 | for time_current in times: 34 | time_current_str = time_current.strftime('%H:%M') 35 | with self.subTest('Current time should always be between dark and light', 36 | time_current=time_current_str): 37 | time_current_unix = time_current.timestamp() 38 | time_light_unix, time_dark_unix = communicate._move_times(time_current, time_light, time_dark) 39 | self.assertIsInstance(time_light_unix, int) 40 | self.assertIsInstance(time_dark_unix, int) 41 | self.assertTrue(time_light_unix <= time_current_unix <= time_dark_unix or 42 | time_dark_unix <= time_current_unix <= time_light_unix) 43 | 44 | # test with swapped times 45 | time_light_unix, time_dark_unix = communicate._move_times(time_current, time_dark, time_light) 46 | self.assertTrue(time_light_unix <= time_current_unix <= time_dark_unix or 47 | time_dark_unix <= time_current_unix <= time_light_unix) 48 | 49 | @unittest.skipUnless(config.get_plugin_key('firefox', PluginKey.ENABLED), 'Firefox plugin is disabled') 50 | def test_message_build(self): 51 | message = communicate.send_config('firefox') 52 | self.assertNotEqual(message, None, 53 | 'Message should not be empty') 54 | self.assertNotEqual(message, {}, 55 | 'Message should not be empty') 56 | self.assertIsInstance(message['enabled'], bool) 57 | self.assertIsInstance(message['dark_mode'], bool) 58 | if message['enabled']: 59 | self.assertIsInstance(message['scheduled'], bool) 60 | self.assertIsInstance(message['themes'][0], str) 61 | self.assertIsInstance(message['themes'][1], str) 62 | if message['scheduled']: 63 | time_dark, time_light = message['times'] 64 | self.assertIsInstance(time_light, int) 65 | self.assertIsInstance(time_dark, int) 66 | time_now = datetime.today().timestamp() 67 | self.assertTrue(time_light <= time_now < time_dark or time_dark <= time_now < time_light, 68 | 'Current time should always be between light and dark times') 69 | 70 | @unittest.skipUnless(config.get_plugin_key('firefox', PluginKey.ENABLED), 'Firefox plugin is disabled') 71 | def test_encode_decode(self): 72 | process = Popen([sys.executable, '../communicate.py'], 73 | stdin=PIPE, stdout=PIPE) 74 | plugins = ['firefox'] 75 | 76 | for plugin in plugins: 77 | if not config.get_plugin_key(plugin, PluginKey.ENABLED): 78 | print('Skipped test for ' + plugin) 79 | continue 80 | 81 | with self.subTest('Returned message should be correct', plugin=plugin): 82 | # build call 83 | call_encoded = json.dumps(plugin).encode('utf-8') 84 | call_encoded = struct.pack(str(len(call_encoded)) + 's', 85 | call_encoded) 86 | msg_length = struct.pack('=I', len(call_encoded)) 87 | 88 | # send call and get response 89 | process.stdin.write(msg_length) 90 | process.stdin.write(call_encoded) 91 | process.stdin.flush() 92 | process.stdin.close() 93 | response = process.stdout.readline() 94 | process.terminate() 95 | 96 | self.assertTrue(response is not None and len(response) > 0, 97 | 'Response should not be empty') 98 | 99 | # decode response 100 | response_length = struct.unpack('=I', response[:4])[0] 101 | response = response[4:] 102 | response_decoded = response[:response_length].decode('utf-8') 103 | response_decoded = json.loads(response_decoded) 104 | 105 | # test if correct 106 | message_expected = communicate.send_config(plugin) 107 | self.assertDictEqual(message_expected, response_decoded, 108 | 'Returned message should be equal to the message') 109 | 110 | process.__exit__(None, None, None) 111 | 112 | def test_dark_mode_detection(self): 113 | time_light, time_dark = config.times 114 | 115 | # get unix times 116 | time_current = datetime.today() 117 | time_light_unix, time_dark_unix = communicate._move_times(time_current, time_light, time_dark) 118 | 119 | is_dark = should_be_dark(time_current.time(), time_light, time_dark) 120 | # NOTE: this should be equal to how the extension calculates the theme 121 | detected_dark = should_be_dark_extensions(int(time_current.timestamp()), 122 | time_dark_unix) 123 | 124 | self.assertEqual(is_dark, detected_dark, 125 | f'Dark mode should be {"active" if is_dark else "inactive"} at {time_current.isoformat()}') 126 | 127 | 128 | if __name__ == '__main__': 129 | unittest.main() 130 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from datetime import time 3 | from pathlib import Path 4 | from typing import Optional 5 | 6 | from yin_yang.config import config, ConfigWatcher, update_config 7 | from yin_yang.meta import Desktop, Modes, PluginKey, ConfigEvent 8 | 9 | config_path = f"{Path.home()}/.config/yin_yang/yin_yang_dev.json" 10 | 11 | old_configs = { 12 | 2.1: { 13 | "version": 2.1, 14 | "desktop": "kde", 15 | "followSun": False, 16 | "latitude": "", 17 | "longitude": "", 18 | "schedule": True, 19 | "switchToDark": "20:0", 20 | "switchToLight": "7:0", 21 | "running": False, 22 | "theme": "", 23 | "codeLightTheme": "Default Light+", 24 | "codeDarkTheme": "Default Dark+", 25 | "codeEnabled": False, 26 | "kdeLightTheme": "org.kde.breeze.desktop", 27 | "kdeDarkTheme": "org.kde.breezedark.desktop", 28 | "kdeEnabled": False, 29 | "gtkLightTheme": "", 30 | "gtkDarkTheme": "", 31 | "atomLightTheme": "", 32 | "atomDarkTheme": "", 33 | "atomEnabled": False, 34 | "gtkEnabled": False, 35 | "wallpaperLightTheme": "", 36 | "wallpaperDarkTheme": "", 37 | "wallpaperEnabled": False, 38 | "firefoxEnabled": False, 39 | "firefoxDarkTheme": "firefox-compact-dark@mozilla.org", 40 | "firefoxLightTheme": "firefox-compact-light@mozilla.org", 41 | "firefoxActiveTheme": "firefox-compact-light@mozilla.org", 42 | "gnomeEnabled": False, 43 | "gnomeLightTheme": "", 44 | "gnomeDarkTheme": "", 45 | "kvantumEnabled": False, 46 | "kvantumLightTheme": "", 47 | "kvantumDarkTheme": "", 48 | "soundEnabled": False 49 | }, 50 | 2.2: { 51 | "version": 2.2, 52 | "desktop": "kde", 53 | "followSun": False, 54 | "latitude": "", 55 | "longitude": "", 56 | "schedule": True, 57 | "switchToDark": "20:0", 58 | "switchToLight": "7:0", 59 | "running": False, 60 | "theme": "", 61 | "codeLightTheme": "Default Light+", 62 | "codeDarkTheme": "Default Dark+", 63 | "codeEnabled": False, 64 | "systemLightTheme": "org.kde.breeze.desktop", 65 | "systemDarkTheme": "org.kde.breezedark.desktop", 66 | "systemEnabled": False, 67 | "gtkLightTheme": "", 68 | "gtkDarkTheme": "", 69 | "atomLightTheme": "", 70 | "atomDarkTheme": "", 71 | "atomEnabled": False, 72 | "gtkEnabled": False, 73 | "wallpaperLightTheme": "", 74 | "wallpaperDarkTheme": "", 75 | "wallpaperEnabled": False, 76 | "firefoxEnabled": False, 77 | "firefoxDarkTheme": "firefox-compact-dark@mozilla.org", 78 | "firefoxLightTheme": "firefox-compact-light@mozilla.org", 79 | "firefoxActiveTheme": "firefox-compact-light@mozilla.org", 80 | "kvantumEnabled": False, 81 | "kvantumLightTheme": "", 82 | "kvantumDarkTheme": "", 83 | "soundEnabled": False 84 | } 85 | } 86 | 87 | 88 | def use_all_versions(func): 89 | def inner(self): 90 | for version in old_configs: 91 | with self.subTest('Testing update from old version', version=version): 92 | old_config = old_configs[version] 93 | config.update(update_config(old_config.copy(), config.defaults)) 94 | func(self, version, old_config) 95 | 96 | return inner 97 | 98 | 99 | class ConfigTest(unittest.TestCase): 100 | def setUp(self) -> None: 101 | super().setUp() 102 | config.reset() 103 | config.save() 104 | 105 | @use_all_versions 106 | def test_update_old_configs(self, version, old_config): 107 | match version: 108 | case 2.1: 109 | for plugin_property in ['Enabled', 'LightTheme', 'DarkTheme']: 110 | self.assertEqual( 111 | old_config['wallpaper' + plugin_property], 112 | config.get_plugin_key( 113 | 'wallpaper', 114 | plpr_str_to_enum(plugin_property.replace('Theme', '_theme').lower())), 115 | 'Updating old config files should apply correct values') 116 | case 2.2: 117 | self.assertEqual(config.mode, Modes.SCHEDULED) 118 | self.assertEqual(old_config['wallpaperEnabled'], config.get_plugin_key('wallpaper', PluginKey.ENABLED)) 119 | self.assertEqual(old_config['wallpaperLightTheme'], config.get_plugin_key('wallpaper', PluginKey.THEME_LIGHT)) 120 | 121 | @unittest.skipIf(config.desktop == Desktop.UNKNOWN, 'Desktop is unsupported') 122 | @use_all_versions 123 | def test_updates_system_plugin_values(self, version, old_config): 124 | match version: 125 | case 2.1: 126 | for plugin_property in ['Enabled', 'LightTheme', 'DarkTheme']: 127 | self.assertEqual( 128 | old_config['wallpaper' + plugin_property], 129 | config.get_plugin_key( 130 | 'wallpaper', 131 | plpr_str_to_enum(plugin_property.replace('Theme', '_theme').lower())), 132 | 'Updating old config files should apply correct values') 133 | 134 | case 2.2: 135 | self.assertEqual(config.mode, Modes.SCHEDULED) 136 | self.assertEqual(old_config['wallpaperEnabled'], config.get_plugin_key('wallpaper', PluginKey.ENABLED)) 137 | self.assertEqual(old_config['wallpaperLightTheme'], 138 | config.get_plugin_key('wallpaper', PluginKey.THEME_LIGHT)) 139 | 140 | def test_notifies_on_change(self): 141 | class Watcher(ConfigWatcher): 142 | def __init__(self): 143 | self.updates: [dict] = [] 144 | 145 | def notify(self, event, values): 146 | self.updates.append(values) 147 | 148 | watcher = Watcher() 149 | config.add_event_listener(ConfigEvent.CHANGE, watcher) 150 | 151 | config.mode = Modes.SCHEDULED 152 | self.assertIn( 153 | { 154 | 'key': 'mode', 155 | 'old_value': Modes.MANUAL.value, 156 | 'new_value': Modes.SCHEDULED.value, 157 | 'plugin': None 158 | }, watcher.updates) 159 | watcher.updates = [] 160 | 161 | config.add_event_listener(ConfigEvent.CHANGE, watcher) 162 | config.mode = Modes.MANUAL 163 | self.assertEqual(1, len(watcher.updates), 'Watcher should only be added once') 164 | 165 | config.mode = Modes.MANUAL 166 | self.assertNotIn( 167 | { 168 | 'key': 'mode', 169 | 'old_value': Modes.MANUAL.value, 170 | 'new_value': Modes.MANUAL.value, 171 | 'plugin': None 172 | }, watcher.updates) 173 | watcher.updates = [] 174 | 175 | config.update_plugin_key('wallpaper', PluginKey.ENABLED, True) 176 | self.assertIn( 177 | { 178 | 'key': PluginKey.ENABLED.value, 179 | 'old_value': False, 180 | 'new_value': True, 181 | 'plugin': 'wallpaper' 182 | }, watcher.updates) 183 | watcher.updates = [] 184 | 185 | try: 186 | config.update_plugin_key('abcd', PluginKey.ENABLED, True) 187 | except KeyError: 188 | pass 189 | self.assertTrue(len(watcher.updates) == 0) 190 | 191 | def test_removes_listener(self): 192 | class Watcher(ConfigWatcher): 193 | def __init__(self): 194 | self.notified = 0 195 | 196 | def notify(self, event: ConfigEvent, values: Optional[dict]): 197 | self.notified += 1 198 | 199 | watcher = Watcher() 200 | config.add_event_listener(ConfigEvent.CHANGE, watcher) 201 | config.mode = Modes.SCHEDULED 202 | config.remove_event_listener(ConfigEvent.CHANGE, watcher) 203 | config.mode = Modes.MANUAL 204 | self.assertEqual(1, watcher.notified) 205 | 206 | def test_notify_when_saved(self): 207 | class Watcher(ConfigWatcher): 208 | def __init__(self): 209 | self.saved = False 210 | 211 | def notify(self, event: ConfigEvent, values: dict): 212 | self.saved = True 213 | 214 | watcher = Watcher() 215 | config.add_event_listener(ConfigEvent.SAVE, watcher) 216 | self.assertFalse(watcher.saved) 217 | config.mode = Modes.SCHEDULED 218 | config.save() 219 | self.assertTrue(watcher.saved) 220 | 221 | def test_write_when_changed(self): 222 | self.assertFalse(config.save()) 223 | 224 | config.mode = Modes.SCHEDULED 225 | self.assertTrue(config.save()) 226 | self.assertFalse(config.save()) 227 | 228 | config.update_plugin_key('wallpaper', PluginKey.ENABLED, True) 229 | self.assertTrue(config.save()) 230 | self.assertFalse(config.save()) 231 | 232 | def test_position(self): 233 | config.mode = Modes.FOLLOW_SUN 234 | config.update_location = True 235 | 236 | lat, long = config.location 237 | self.assertIsInstance(lat, float) 238 | self.assertIsInstance(long, float) 239 | 240 | def test_follow_sun(self): 241 | config.mode = Modes.SCHEDULED 242 | time_light_man = time(5, 9) 243 | time_dark_man = time(20, 0) 244 | config.times = time_light_man, time_dark_man 245 | 246 | config.mode = Modes.FOLLOW_SUN 247 | config.location = 0, 0 248 | time_light, time_dark = config.times 249 | 250 | self.assertNotEqual(time_light, time_light_man) 251 | self.assertNotEqual(time_dark, time_dark_man) 252 | 253 | config.update_location = True 254 | time_light, time_dark = config.times 255 | 256 | self.assertNotEqual(time_light, time_light_man) 257 | self.assertNotEqual(time_dark, time_dark_man) 258 | 259 | 260 | def plpr_str_to_enum(string: str): 261 | """Returns the matching enum value from a plugin property string""" 262 | match string: 263 | case 'enabled': 264 | return PluginKey.ENABLED 265 | case 'dark_theme': 266 | return PluginKey.THEME_DARK 267 | case 'light_theme': 268 | return PluginKey.THEME_LIGHT 269 | 270 | 271 | if __name__ == '__main__': 272 | unittest.main() 273 | -------------------------------------------------------------------------------- /tests/test_daemon.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from datetime import time 3 | 4 | from yin_yang.theme_switcher import should_be_dark 5 | 6 | 7 | class DaemonTest(unittest.TestCase): 8 | def test_compare_time(self): 9 | time_light = time.fromisoformat('08:00') 10 | time_dark = time.fromisoformat('20:00') 11 | 12 | for time_current in [time.fromisoformat('05:00'), 13 | time_dark, 14 | time.fromisoformat('22:00'), 15 | time.fromisoformat('00:00')]: 16 | with self.subTest('Dark mode should be activated!', time_current=time_current, light_before_dark=True): 17 | self.assertTrue(should_be_dark(time_current, time_light, time_dark)) 18 | with self.subTest('Light mode should be activated', time_current=time_current, light_before_dark=False): 19 | self.assertFalse(should_be_dark(time_current, time_dark, time_light)) 20 | 21 | for time_current in [time_light, 22 | time.fromisoformat('12:00')]: 23 | with self.subTest('Light mode should be activated!', time_current=time_current, light_before_dark=True): 24 | self.assertFalse(should_be_dark(time_current, time_light, time_dark)) 25 | with self.subTest('Dark mode should be activated!', time_current=time_current, light_before_dark=False): 26 | self.assertTrue(should_be_dark(time_current, time_dark, time_light)) 27 | 28 | message = 'Light mode should always be enabled if times are equal' 29 | self.assertFalse(should_be_dark(time.fromisoformat('05:00'), time_dark, time_dark), message) 30 | self.assertFalse(should_be_dark(time_dark, time_dark, time_dark), message) 31 | self.assertFalse(should_be_dark(time.fromisoformat('22:00'), time_dark, time_dark), message) 32 | 33 | 34 | if __name__ == '__main__': 35 | unittest.main() 36 | -------------------------------------------------------------------------------- /tests/test_daemon_handler.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import re 3 | import shutil 4 | import subprocess 5 | import unittest 6 | from datetime import time 7 | from os.path import isfile 8 | 9 | from yin_yang import daemon_handler, helpers 10 | from yin_yang.config import config 11 | from yin_yang.meta import Modes, ConfigEvent 12 | 13 | 14 | class DaemonTest(unittest.TestCase): 15 | def setUp(self) -> None: 16 | super().setUp() 17 | config.reset() 18 | config.add_event_listener(ConfigEvent.CHANGE, daemon_handler.watcher) 19 | config.add_event_listener(ConfigEvent.SAVE, daemon_handler.watcher) 20 | 21 | def tearDown(self) -> None: 22 | super().tearDown() 23 | config.remove_event_listener(ConfigEvent.CHANGE, daemon_handler.watcher) 24 | config.remove_event_listener(ConfigEvent.SAVE, daemon_handler.watcher) 25 | 26 | @classmethod 27 | def setUpClass(cls) -> None: 28 | super().setUpClass() 29 | if not isfile(daemon_handler.TIMER_PATH): 30 | Path(daemon_handler.SYSTEMD_PATH).mkdir(parents=True, exist_ok=True) 31 | shutil.copyfile('./resources/yin_yang.timer', daemon_handler.TIMER_PATH) 32 | shutil.copyfile('./resources/yin_yang.service', daemon_handler.SERVICE_PATH) 33 | # If we're in a flatpak, the service file needs to be updated 34 | if (helpers.is_flatpak()): 35 | with open(daemon_handler.SERVICE_PATH, 'r') as service: 36 | lines = service.readlines() 37 | with open(daemon_handler.SERVICE_PATH, 'w') as service: 38 | flatpak_exec = f'ExecStart={str(Path.home())}/.local/share/flatpak/exports/bin/sh.oskar.yin_yang --systemd' 39 | for line in lines: 40 | service.write(re.sub('ExecStart=/usr/bin/yin_yang --systemd', flatpak_exec, line)) 41 | shutil.copyfile(daemon_handler.TIMER_PATH, daemon_handler.TIMER_PATH.with_suffix('.timer_backup')) 42 | 43 | @classmethod 44 | def tearDownClass(cls) -> None: 45 | super().tearDownClass() 46 | shutil.move(daemon_handler.TIMER_PATH.with_suffix('.timer_backup'), daemon_handler.TIMER_PATH) 47 | 48 | def test_starts_stops(self): 49 | config.mode = Modes.SCHEDULED 50 | config.save() 51 | output = daemon_handler.run_command('is-active', stdout=subprocess.PIPE).stdout 52 | self.assertEqual(b'active\n', output) 53 | 54 | config.mode = Modes.MANUAL 55 | config.save() 56 | output = daemon_handler.run_command('is-active', stdout=subprocess.PIPE).stdout 57 | self.assertEqual(b'inactive\n', output) 58 | 59 | config.mode = Modes.FOLLOW_SUN 60 | config.save() 61 | output = daemon_handler.run_command('is-active', stdout=subprocess.PIPE).stdout 62 | self.assertEqual(b'active\n', output) 63 | 64 | def test_updates_times(self): 65 | config.mode = Modes.SCHEDULED 66 | time_light = time(6, 0) 67 | time_dark = time(18, 0) 68 | config.times = time_light, time_dark 69 | config.save() 70 | 71 | light, dark = self.read_times() 72 | self.assertEqual(f'OnCalendar={time_light.isoformat()}\n', light) 73 | self.assertEqual(f'OnCalendar={time_dark.isoformat()}\n', dark) 74 | 75 | config.mode = Modes.FOLLOW_SUN 76 | time_light, time_dark = config.times 77 | config.save() 78 | 79 | light, dark = self.read_times() 80 | self.assertEqual(f'OnCalendar={time_light.isoformat()}\n', light) 81 | self.assertEqual(f'OnCalendar={time_dark.isoformat()}\n', dark) 82 | 83 | @staticmethod 84 | def read_times(): 85 | with open(daemon_handler.TIMER_PATH, 'r') as file: 86 | lines = file.readlines() 87 | light, dark = lines[4:6] 88 | return light, dark 89 | 90 | def test_updates_timer(self): 91 | config.mode = Modes.SCHEDULED 92 | config.save() 93 | 94 | output = daemon_handler.run_command('is-active', stdout=subprocess.PIPE).stdout 95 | self.assertEqual(b'active\n', output) 96 | 97 | config.mode = Modes.MANUAL 98 | config.save() 99 | 100 | output = daemon_handler.run_command('is-active', stdout=subprocess.PIPE).stdout 101 | self.assertEqual(b'inactive\n', output) 102 | 103 | config.mode = Modes.FOLLOW_SUN 104 | config.save() 105 | 106 | output = daemon_handler.run_command('is-active', stdout=subprocess.PIPE).stdout 107 | self.assertEqual(b'active\n', output) 108 | 109 | 110 | if __name__ == '__main__': 111 | unittest.main() 112 | -------------------------------------------------------------------------------- /tests/test_plugin_class.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from PySide6.QtGui import QColor 4 | 5 | from yin_yang.plugins._plugin import PluginCommandline, Plugin, get_qcolor_from_int, get_int_from_qcolor 6 | 7 | 8 | class MinimalPlugin(Plugin): 9 | def __init__(self, theme_dark, theme_light): 10 | super().__init__() 11 | self._theme_dark_value = theme_dark 12 | self._theme_light_value = theme_light 13 | self._enabled_value = True 14 | 15 | def set_theme(self, theme: str): 16 | print(f'Changing to theme {theme}') 17 | if not (self.enabled and self.available): 18 | return 19 | 20 | @property 21 | def theme_dark(self) -> str: 22 | return self._theme_dark_value 23 | 24 | @theme_dark.setter 25 | def theme_dark(self, value: str): 26 | self._theme_dark_value = value 27 | 28 | @property 29 | def theme_light(self) -> str: 30 | return self._theme_light_value 31 | 32 | @theme_light.setter 33 | def theme_light(self, value: str): 34 | self._theme_light_value = value 35 | 36 | @property 37 | def enabled(self) -> bool: 38 | return self._enabled_value 39 | 40 | @enabled.setter 41 | def enabled(self, value: bool): 42 | self._enabled_value = value 43 | 44 | 45 | class PluginCommandlineTest(PluginCommandline): 46 | def __init__(self, command: list, theme_dark: str = None, theme_light: str = None): 47 | super().__init__(command) 48 | self._theme_light_value = theme_light 49 | self._theme_dark_value = theme_dark 50 | 51 | @property 52 | def theme_light(self) -> str: 53 | return self._theme_light_value 54 | 55 | @theme_light.setter 56 | def theme_light(self, value: str): 57 | self._theme_light_value = value 58 | 59 | @property 60 | def theme_dark(self) -> str: 61 | return self._theme_dark_value 62 | 63 | @theme_dark.setter 64 | def theme_dark(self, value: str): 65 | self._theme_dark_value = value 66 | 67 | 68 | class GenericTest(unittest.TestCase): 69 | def setUp(self) -> None: 70 | super().setUp() 71 | self.plugin = MinimalPlugin(theme_dark='dark', theme_light='light') 72 | 73 | def test_initialization(self): 74 | self.assertEqual('dark', self.plugin.theme_dark) 75 | self.assertEqual('light', self.plugin.theme_light) 76 | self.assertEqual('MinimalPlugin', self.plugin.name, 77 | 'All plugins should use their class name as name by default') 78 | self.assertEqual('minimalplugin', str(self.plugin), 79 | 'The string method should return an all lowercase version') 80 | 81 | def test_properties(self): 82 | self.assertTrue(self.plugin.available, 83 | 'All plugins should be available by default') 84 | self.assertDictEqual({}, self.plugin.available_themes, 85 | 'Available plugins should be empty if not implemented') 86 | 87 | def test_set_theme(self): 88 | self.plugin.enabled = False 89 | self.assertEqual(False, self.plugin.set_mode(False), 90 | 'Plugin should return False if it is not enabled') 91 | 92 | self.plugin.enabled = True 93 | self.assertEqual(True, self.plugin.set_mode(False)) 94 | self.assertEqual(True, self.plugin.set_mode(True)) 95 | 96 | 97 | class CommandlineTest(unittest.TestCase): 98 | def test_command_substitution(self): 99 | plugin = PluginCommandlineTest(['command', '{theme}', 'argument'], 'light', 'dark') 100 | self.assertEqual(['command', 'theme', 'argument'], plugin.insert_theme('theme'), 101 | 'insert_theme should replace %t with the theme name') 102 | 103 | plugin = PluginCommandlineTest(['command', '{theme}argument'], 'light', 'dark') 104 | self.assertEqual(['command', 'themeargument'], 105 | plugin.insert_theme('theme'), 106 | 'insert_theme should replace %t with the theme name, even if it is inside of an argument') 107 | 108 | plugin = PluginCommandlineTest(['command', 'argument{theme}'], 'light', 'dark') 109 | self.assertEqual(['command', 'argumenttheme'], 110 | plugin.insert_theme('theme'), 111 | 'insert_theme should replace %t with the theme name, even if it is inside of an argument') 112 | 113 | plugin = PluginCommandlineTest(['command', 'argu{theme}ment'], 'light', 'dark') 114 | self.assertEqual(['command', 'arguthemement'], 115 | plugin.insert_theme('theme'), 116 | 'insert_theme should replace %t with the theme name, even if it is inside of an argument') 117 | 118 | 119 | class UtilityTest(unittest.TestCase): 120 | def test_color_conversion(self): 121 | # white 122 | color_int = -1 123 | color = get_qcolor_from_int(color_int) 124 | self.assertEqual(QColor.fromRgb(255, 255, 255), color) 125 | self.assertEqual(get_int_from_qcolor(color), color_int) 126 | 127 | # black 128 | color_int = -16777216 129 | color = get_qcolor_from_int(color_int) 130 | self.assertEqual(QColor.fromRgb(0, 0, 0), color) 131 | self.assertEqual(get_int_from_qcolor(color), color_int) 132 | 133 | 134 | if __name__ == '__main__': 135 | unittest.main() 136 | -------------------------------------------------------------------------------- /tests/test_plugins.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from yin_yang.config import config 4 | from yin_yang.config import plugins 5 | from yin_yang.plugins._plugin import Plugin, ExternalPlugin 6 | 7 | 8 | class PluginsTest(unittest.TestCase): 9 | def test_setup(self): 10 | for pl in plugins: 11 | with self.subTest(plugin=pl.name): 12 | self.assertIsInstance(pl, Plugin, 13 | 'Every plugin should extend the Plugin class') 14 | self.assertTrue(pl.name != '', 15 | 'Every plugin needs a name for the config and the gui.') 16 | if pl.available: 17 | self.assertIsInstance(pl.available_themes, dict, 18 | 'Available themes always should be a dict.') 19 | 20 | def test_set_empty_theme(self): 21 | for pl in plugins: 22 | with self.subTest(plugin=pl): 23 | try: 24 | pl.set_theme('') 25 | except ValueError as e: 26 | self.assertEqual('Theme \"\" is invalid', str(e), 27 | 'set_theme() should throw an exception if the theme is empty') 28 | return 29 | 30 | self.assertTrue(False, 31 | 'set_theme() should throw an exception if the theme is empty!') 32 | # try to return to previous theme 33 | pl.set_theme(config.get_plugin_key(pl.name + config.get_plugin_key('theme').title() + 'Theme')) 34 | 35 | def test_set_theme_invalid_state(self): 36 | for pl in plugins: 37 | with self.subTest(plugin=pl): 38 | pl.enabled = False 39 | 40 | self.assertFalse(pl.set_mode(True), 41 | 'set_theme() should not be successful if the plugin is disabled') 42 | 43 | # NOTE if you want to test that your theme changes, set this value to true 44 | @unittest.skipUnless(False, 'test_theme_changes is disabled') 45 | def test_set_theme_works(self): 46 | for pl in filter(lambda p: not isinstance(p, ExternalPlugin) and p.enabled, plugins): 47 | with self.subTest('Changing the theme should be successful', plugin=pl.name): 48 | self.assertTrue(pl.set_mode(True), 49 | 'set_mode() should be true, indicating that it was successful') 50 | self.assertTrue(pl.set_mode(False), 51 | 'set_mode() should be true, indicating that it was successful') 52 | 53 | 54 | if __name__ == '__main__': 55 | unittest.main() 56 | -------------------------------------------------------------------------------- /yin_yang/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oskarsh/Yin-Yang/19feb5938cdf52a4cf2592ef5b4a081c4911feb8/yin_yang/__init__.py -------------------------------------------------------------------------------- /yin_yang/__main__.py: -------------------------------------------------------------------------------- 1 | #!/bin/env python3 2 | 3 | import logging 4 | import sys 5 | from argparse import ArgumentParser 6 | from logging.handlers import RotatingFileHandler 7 | from pathlib import Path 8 | 9 | from PySide6 import QtWidgets 10 | from PySide6.QtCore import QTranslator, QLibraryInfo, QLocale 11 | from PySide6.QtGui import QIcon 12 | from PySide6.QtWidgets import QSystemTrayIcon, QMenu 13 | from systemd import journal 14 | 15 | from yin_yang import daemon_handler 16 | from yin_yang import theme_switcher 17 | from yin_yang.config import config, Modes 18 | from yin_yang.helpers import is_flatpak 19 | from yin_yang.meta import ConfigEvent 20 | from yin_yang.notification_handler import NotificationHandler 21 | from yin_yang.repeat_timer import RepeatTimer 22 | from yin_yang.ui import main_window_connector 23 | 24 | logger = logging.getLogger() 25 | timer = RepeatTimer(45, theme_switcher.set_desired_theme) 26 | 27 | if is_flatpak(): 28 | timer.daemon = True 29 | timer.name = "Yin-Yang Timer" 30 | timer.start() 31 | 32 | 33 | def setup_logger(use_systemd_journal: bool): 34 | notification_handler = NotificationHandler() 35 | notification_handler.addFilter(lambda record: record.levelno > logging.WARNING) 36 | logger.addHandler(notification_handler) 37 | 38 | if use_systemd_journal: 39 | logger.addHandler(journal.JournalHandler(SYSLOG_IDENTIFIER='yin_yang')) 40 | 41 | # __debug__ is true when you run __main__.py without the -O argument (python __main__.py) 42 | # noinspection PyUnreachableCode 43 | if __debug__: 44 | # noinspection SpellCheckingInspection 45 | logging.basicConfig( 46 | level=logging.DEBUG, 47 | format='%(asctime)s %(levelname)s - %(name)s: %(message)s' 48 | ) 49 | else: 50 | # if you run it with "python -O __main__.py" instead, debug is false 51 | 52 | # let the default logger print to the console 53 | # noinspection SpellCheckingInspection 54 | logging.basicConfig( 55 | level=logging.WARNING, 56 | format='%(asctime)s %(levelname)s - %(name)s: %(message)s' 57 | ) 58 | # and add a handler that limits the size to 1 GB 59 | file_handler = RotatingFileHandler( 60 | str(Path.home()) + '/.local/share/yin_yang.log', 61 | maxBytes=10**9, backupCount=1 62 | ) 63 | logging.root.addHandler(file_handler) 64 | 65 | 66 | def systray_icon_clicked(reason: QSystemTrayIcon.ActivationReason): 67 | match reason: 68 | case QSystemTrayIcon.ActivationReason.MiddleClick: 69 | theme_switcher.set_mode(not config.dark_mode) 70 | case QSystemTrayIcon.ActivationReason.Trigger: 71 | window.show() 72 | 73 | 74 | # using ArgumentParser for parsing arguments 75 | parser = ArgumentParser() 76 | parser.add_argument('-t', '--toggle', 77 | help='toggles Yin-Yang', 78 | action='store_true') 79 | parser.add_argument('--systemd', help='uses systemd journal handler and applies desired theme', action='store_true') 80 | parser.add_argument('--minimized', help='starts the program to tray bar', action='store_true') 81 | arguments = parser.parse_args() 82 | setup_logger(arguments.systemd) 83 | 84 | if arguments.toggle: 85 | # terminate any running instances 86 | config.running = False 87 | config.mode = Modes.MANUAL 88 | theme_switcher.set_mode(not config.dark_mode) 89 | 90 | elif arguments.systemd: 91 | theme_switcher.set_desired_theme() 92 | 93 | 94 | else: 95 | # load GUI 96 | config.add_event_listener(ConfigEvent.SAVE, daemon_handler.watcher) 97 | config.add_event_listener(ConfigEvent.CHANGE, daemon_handler.watcher) 98 | app = QtWidgets.QApplication(sys.argv) 99 | # fixes icon on wayland 100 | app.setDesktopFileName('sh.oskar.yin_yang') 101 | # fixes icon on x11 102 | app.setApplicationName('Yin & Yang') 103 | app.setApplicationDisplayName('Yin & Yang') 104 | 105 | # load translation 106 | try: 107 | lang = QLocale().name() 108 | logger.debug(f'Using language {lang}') 109 | 110 | # system translations 111 | path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath) 112 | translator = QTranslator(app) 113 | if translator.load(QLocale.system(), 'qtbase', '_', path): 114 | app.installTranslator(translator) 115 | else: 116 | raise FileNotFoundError('Error while loading system translations!') 117 | 118 | # application translations 119 | translator = QTranslator(app) 120 | path = ':translations' 121 | if translator.load(QLocale.system(), 'yin_yang', '.', path): 122 | app.installTranslator(translator) 123 | else: 124 | raise FileNotFoundError('Error while loading application translations!') 125 | 126 | except Exception as e: 127 | logger.warning(str(e)) 128 | print('The app has not been translated to your language yet. Using default language.') 129 | 130 | # show systray icon 131 | if QSystemTrayIcon.isSystemTrayAvailable(): 132 | app.setQuitOnLastWindowClosed(False) 133 | 134 | icon = QSystemTrayIcon(QIcon(u':/icons/icon'), app) 135 | icon.activated.connect(systray_icon_clicked) 136 | icon.setToolTip('Yin & Yang') 137 | 138 | menu = QMenu('Yin & Yang') 139 | menu.addAction( 140 | app.translate('systray', 'Open Yin Yang', 'Context menu action in the systray'), 141 | lambda: window.show()) 142 | menu.addAction( 143 | app.translate('systray', 'Toggle theme', 'Context menu action in the systray'), 144 | lambda: theme_switcher.set_mode(not config.dark_mode)) 145 | menu.addAction(QIcon.fromTheme('application-exit'), 146 | app.translate('systray', 'Quit', 'Context menu action in the systray'), 147 | app.quit) 148 | 149 | icon.setContextMenu(menu) 150 | icon.show() 151 | else: 152 | logger.debug('System tray is unsupported') 153 | 154 | if arguments.minimized: 155 | sys.exit(app.exec()) 156 | else: 157 | window = main_window_connector.MainWindow() 158 | window.show() 159 | sys.exit(app.exec()) 160 | -------------------------------------------------------------------------------- /yin_yang/communicate.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # This file allows external extensions to communicate with yin_yang. 4 | # It's based on https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Native_messaging, 5 | # as it was originally used for the firefox plugin only 6 | 7 | import logging 8 | import sys 9 | import json 10 | import struct 11 | import time 12 | from datetime import datetime, time as dt_time 13 | from pathlib import Path 14 | 15 | from .meta import PluginKey 16 | from .config import config 17 | 18 | logging.basicConfig(filename=str(Path.home()) + '/.local/share/yin_yang.log', level=logging.DEBUG, 19 | format='%(asctime)s %(levelname)s - %(name)s: %(message)s') 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | def _move_times(time_now: datetime, time_light: dt_time, time_dark: dt_time) -> list[int]: 24 | """ 25 | Converts a time string to seconds since the epoch 26 | :param time_now: the current time 27 | :param time_light: the time when light mode starts 28 | :param time_dark: the time when dark mode starts 29 | """ 30 | 31 | # convert all times to unix times on current day 32 | time_now_unix: int = int(time_now.timestamp()) 33 | time_light_unix: int = int(time.mktime( 34 | datetime.combine(time_now.date(), time_light).timetuple())) 35 | time_dark_unix: int = int(time.mktime( 36 | datetime.combine(time_now.date(), time_dark).timetuple())) 37 | 38 | # move times so that the next is always in the future and the other in the past 39 | one_day = 60 * 60 * 24 40 | if time_now_unix < time_light_unix and time_now_unix < time_dark_unix: 41 | if time_dark_unix > time_light_unix: 42 | # expected behaviour 43 | time_dark_unix -= one_day 44 | else: 45 | # edge case where time_dark if after 00:00 46 | time_light_unix -= one_day 47 | elif time_now_unix > time_light_unix and time_now_unix > time_dark_unix: 48 | if time_dark_unix > time_light_unix: 49 | time_light_unix += one_day 50 | else: 51 | time_dark_unix += one_day 52 | 53 | return [time_light_unix, time_dark_unix] 54 | 55 | 56 | def send_config(plugin: str) -> dict: 57 | """ 58 | Returns the configuration for the plugin plus some general necessary stuff (scheduled, dark_mode, times) 59 | :param plugin: the plugin for which the configuration should be returned 60 | :return: a dictionary containing config information 61 | """ 62 | logger.debug('Building message') 63 | 64 | enabled = config.get_plugin_key(plugin, PluginKey.ENABLED) 65 | message = { 66 | 'enabled': enabled, 67 | 'dark_mode': config.dark_mode 68 | } 69 | 70 | if enabled: 71 | mode = config.mode 72 | 73 | message['scheduled'] = mode != 'manual' 74 | message['themes'] = [ 75 | config.get_plugin_key(plugin, PluginKey.THEME_LIGHT), 76 | config.get_plugin_key(plugin, PluginKey.THEME_DARK) 77 | ] 78 | if message['scheduled']: 79 | # time string is parsed to time object 80 | time_light, time_dark = config.times 81 | time_now = datetime.now() 82 | 83 | message['times'] = _move_times(time_now, time_light, time_dark) 84 | 85 | return message 86 | 87 | 88 | def _encode_message(message_content: dict) -> dict[str, bytes]: 89 | """ 90 | Encode a message for transmission, given its content. 91 | :param message_content: a message 92 | """ 93 | encoded_content = json.dumps(message_content).encode('utf-8') 94 | encoded_length = struct.pack('=I', len(encoded_content)) 95 | # use struct.pack("10s", bytes) 96 | # to pack a string of the length of 10 characters 97 | 98 | encoded_message = { 99 | 'length': encoded_length, 100 | 'content': struct.pack(str(len(encoded_content)) + 's', 101 | encoded_content)} 102 | logger.debug('Encoded message with length ' + str(len(encoded_content))) 103 | return encoded_message 104 | 105 | 106 | # Send an encoded message to stdout. 107 | def _send_message(encoded_message: dict[str, bytes]): 108 | """ 109 | Send a message. 110 | :param encoded_message: message as json 111 | """ 112 | logger.debug('Sending message') 113 | sys.stdout.buffer.write(encoded_message['length']) 114 | sys.stdout.buffer.write(encoded_message['content']) 115 | sys.stdout.buffer.flush() 116 | 117 | 118 | # Read a message from stdin and decode it. 119 | def _decode_message(): 120 | """ 121 | Decodes a message in stdout and returns it. 122 | """ 123 | raw_length = sys.stdin.buffer.read(4) 124 | 125 | if not raw_length: 126 | sys.exit(0) 127 | message_length = struct.unpack('=I', raw_length)[0] 128 | message = sys.stdin.buffer.read(message_length).decode('utf-8') 129 | 130 | return json.loads(message) 131 | 132 | 133 | if __name__ == '__main__': 134 | while True: 135 | try: 136 | message_received: str = _decode_message() 137 | if message_received is not None: 138 | logger.debug('Message received from ' + message_received) 139 | 140 | if message_received == 'firefox': 141 | _send_message(_encode_message(send_config('firefox'))) 142 | except Exception as e: 143 | logger.error(e) 144 | -------------------------------------------------------------------------------- /yin_yang/daemon_handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | import shutil 4 | from enum import Enum, auto 5 | from pathlib import Path 6 | 7 | from yin_yang import helpers 8 | 9 | from .config import ConfigWatcher, config 10 | from .meta import ConfigEvent, Modes 11 | 12 | logger = logging.getLogger(__name__) 13 | SYSTEMD_PATH = Path.home() / '.local/share/systemd/user' 14 | TIMER_PATH = SYSTEMD_PATH / 'yin_yang.timer' 15 | SERVICE_PATH = SYSTEMD_PATH / 'yin_yang.service' 16 | 17 | 18 | def create_files(): 19 | logger.debug('Creating systemd files') 20 | if not SYSTEMD_PATH.is_dir(): 21 | SYSTEMD_PATH.mkdir(parents=True, exist_ok=True) 22 | if not TIMER_PATH.is_file(): 23 | shutil.copy('./resources/yin_yang.timer', TIMER_PATH) 24 | if not SERVICE_PATH.is_file(): 25 | shutil.copy('./resources/yin_yang.service', SERVICE_PATH) 26 | if helpers.is_flatpak(): 27 | with open(SERVICE_PATH, 'r') as service: 28 | lines = service.readlines() 29 | with open(SERVICE_PATH, 'w') as service: 30 | for line in lines: 31 | service.write( 32 | re.sub( 33 | 'ExecStart=/usr/bin/yin_yang --systemd', 34 | 'ExecStart=' 35 | + str(Path.home()) 36 | + '/.local/share/flatpak/exports/bin/sh.oskar.yin_yang --systemd', 37 | line, 38 | ) 39 | ) 40 | 41 | 42 | def run_command(command, **kwargs): 43 | return helpers.run(['systemctl', '--user', command, 'yin_yang.timer'], **kwargs) 44 | 45 | 46 | def update_times(): 47 | create_files() 48 | 49 | if config.mode == Modes.MANUAL: 50 | run_command('stop') 51 | logger.debug('Stopping systemd timer') 52 | return 53 | 54 | logger.debug('Updating systemd timer') 55 | # update timer times 56 | with TIMER_PATH.open('r') as file: 57 | lines = file.readlines() 58 | 59 | time_light, time_dark = config.times 60 | lines[4] = f'OnCalendar={time_light}\n' 61 | lines[5] = f'OnCalendar={time_dark}\n' 62 | lines[6] = f'OnStartupSec={config.boot_offset}\n' 63 | 64 | with TIMER_PATH.open('w') as file: 65 | file.writelines(lines) 66 | 67 | helpers.run(['systemctl', '--user', 'daemon-reload']) 68 | run_command('start') 69 | 70 | 71 | class SaveWatcher(ConfigWatcher): 72 | class _UpdateTimerStatus(Enum): 73 | NO_UPDATE = auto() 74 | UPDATE_TIMES = auto() # times are also updated at start 75 | START = auto() 76 | STOP = auto() 77 | 78 | def __init__(self): 79 | self._next_timer_update: SaveWatcher._UpdateTimerStatus = ( 80 | SaveWatcher._UpdateTimerStatus.NO_UPDATE 81 | ) 82 | 83 | def _set_needed_updates(self, change_values): 84 | assert change_values['old_value'] != change_values['new_value'], 'No change!' 85 | 86 | match change_values['key']: 87 | case 'mode': 88 | # careful with changes from scheduled to follow sun here! xD 89 | if change_values['old_value'] == Modes.MANUAL.value: 90 | self._next_timer_update = SaveWatcher._UpdateTimerStatus.START 91 | elif change_values['new_value'] == Modes.MANUAL.value: 92 | self._next_timer_update = SaveWatcher._UpdateTimerStatus.STOP 93 | else: 94 | self._next_timer_update = ( 95 | SaveWatcher._UpdateTimerStatus.UPDATE_TIMES 96 | ) 97 | case 'times' | 'coordinates' | 'boot_offset': 98 | self._next_timer_update = SaveWatcher._UpdateTimerStatus.UPDATE_TIMES 99 | 100 | def _update_timer(self): 101 | match self._next_timer_update: 102 | case SaveWatcher._UpdateTimerStatus.STOP: 103 | run_command('stop') 104 | case ( 105 | SaveWatcher._UpdateTimerStatus.UPDATE_TIMES 106 | | SaveWatcher._UpdateTimerStatus.START 107 | ): 108 | update_times() 109 | 110 | self._next_timer_update = SaveWatcher._UpdateTimerStatus.NO_UPDATE 111 | 112 | def notify(self, event: ConfigEvent, values): 113 | match event: 114 | case ConfigEvent.CHANGE: 115 | self._set_needed_updates(values) 116 | case ConfigEvent.SAVE: 117 | self._update_timer() 118 | 119 | 120 | watcher = SaveWatcher() 121 | -------------------------------------------------------------------------------- /yin_yang/helpers.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | 4 | """Check output of a command. 5 | 6 | This is a helper method which will change how we check output depending on if 7 | The application is running in a Flatpak or not. 8 | """ 9 | 10 | """Base Flatpak Arguments 11 | 12 | These are the base arguments we use to execute commands when running in 13 | a flatpak environment. 14 | """ 15 | base_flatpak_args = ['flatpak-spawn', '--host'] 16 | 17 | 18 | def check_output(args, universal_newlines=False) -> bytes: 19 | try: 20 | output = subprocess.check_output( 21 | args=args, universal_newlines=universal_newlines 22 | ) 23 | return output 24 | except FileNotFoundError: 25 | flatpak_args = base_flatpak_args + args 26 | return subprocess.check_output( 27 | args=flatpak_args, universal_newlines=universal_newlines 28 | ) 29 | 30 | 31 | def check_call(command, stdout=None) -> int: 32 | try: 33 | return subprocess.check_call(command, stdout=stdout) 34 | except FileNotFoundError: 35 | flatpak_args = base_flatpak_args + command 36 | return subprocess.check_call(flatpak_args, stdout=stdout) 37 | 38 | 39 | def is_flatpak() -> bool: 40 | return os.path.isfile('/.flatpak-info') 41 | 42 | 43 | def get_usr() -> str: 44 | """ 45 | Returns the proper path to /usr. 46 | This is need as the path to /usr is different in a flatpak environment. 47 | :return: The path to /usr with a trailing / 48 | """ 49 | if is_flatpak(): 50 | return '/var/run/host/usr/' 51 | return '/usr/' 52 | 53 | 54 | def get_etc() -> str: 55 | """ 56 | Returns the proper path to /etc. 57 | This is need as the path to /etc is different in a flatpak environment. 58 | :return: The path to /etc with a trailing / 59 | """ 60 | if is_flatpak(): 61 | return '/var/run/host/etc/' 62 | return '/etc/' 63 | 64 | 65 | def run( 66 | command: list[str], kwargs: list[str] = [], stdout=None 67 | ) -> subprocess.CompletedProcess[str]: 68 | try: 69 | if len(kwargs) == 0: 70 | return subprocess.run(command, stdout=stdout) 71 | else: 72 | return subprocess.run(command, **kwargs, stdout=stdout) 73 | except FileNotFoundError: 74 | flatpak_args = base_flatpak_args + command 75 | if len(kwargs) == 0: 76 | return subprocess.run(flatpak_args, stdout=stdout) 77 | else: 78 | return subprocess.run(flatpak_args, **kwargs, stdout=stdout) 79 | -------------------------------------------------------------------------------- /yin_yang/meta.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, auto 2 | 3 | 4 | class Modes(Enum): 5 | """Different modes for determining the theme that should be used""" 6 | 7 | MANUAL = 'manual' 8 | SCHEDULED = 'manual time' 9 | FOLLOW_SUN = 'sunset to sunrise' 10 | 11 | 12 | class Desktop(Enum): 13 | KDE = 'kde' 14 | GNOME = 'gnome' 15 | XFCE = 'xfce' 16 | UNKNOWN = 'unknown' 17 | MATE = 'mate' 18 | CINNAMON = 'cinnamon' 19 | BUDGIE = 'budgie' 20 | 21 | 22 | class PluginKey(Enum): 23 | ENABLED = 'enabled' 24 | THEME_LIGHT = 'light_theme' 25 | THEME_DARK = 'dark_theme' 26 | 27 | 28 | class ConfigEvent(Enum): 29 | CHANGE = auto() 30 | SAVE = auto() 31 | 32 | 33 | class FileFormat(Enum): 34 | PLAIN = auto() 35 | JSON = auto() 36 | CONFIG = auto() 37 | 38 | 39 | class UnsupportedDesktopError(NotImplementedError): 40 | pass 41 | -------------------------------------------------------------------------------- /yin_yang/notification_handler.py: -------------------------------------------------------------------------------- 1 | from logging import Handler 2 | 3 | from PySide6.QtDBus import QDBusConnection, QDBusMessage 4 | 5 | 6 | def create_dbus_message(title: str, body: str): 7 | message = QDBusMessage.createMethodCall( 8 | 'org.freedesktop.portal.Desktop', 9 | '/org/freedesktop/portal/desktop', 10 | 'org.freedesktop.portal.Notification', 11 | 'AddNotification', 12 | ) 13 | 14 | notification = { 15 | 'title': title, 16 | 'body': body, 17 | 'icon': 'yin_yang', 18 | 'priority': 'low', 19 | } 20 | 21 | message.setArguments(['YingYang.ThemeChanged', notification]) 22 | 23 | return message 24 | 25 | 26 | class NotificationHandler(Handler): 27 | """Shows logs as notifications""" 28 | 29 | def emit(self, record): 30 | connection = QDBusConnection.sessionBus() 31 | message = create_dbus_message(record.levelname, str(record.msg)) 32 | connection.call(message) 33 | -------------------------------------------------------------------------------- /yin_yang/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | from ..meta import Desktop 2 | from . import system, colors, gtk, icons, kvantum, wallpaper, custom 3 | from . import firefox, only_office, okular 4 | from . import vscode, konsole 5 | from . import notify 6 | 7 | # NOTE initialize your plugin over here: 8 | # The order in the list specifies the order in the config gui 9 | from yin_yang.plugins._plugin import Plugin, ExternalPlugin 10 | 11 | 12 | def get_plugins(desktop: Desktop) -> list[Plugin]: 13 | return [ 14 | system.System(desktop), 15 | colors.Colors(desktop), 16 | gtk.Gtk(desktop), 17 | icons.Icons(desktop), 18 | kvantum.Kvantum(), 19 | wallpaper.Wallpaper(desktop), 20 | firefox.Firefox(), 21 | vscode.Vscode(), 22 | only_office.OnlyOffice(), 23 | okular.Okular(), 24 | konsole.Konsole(), 25 | custom.Custom(), 26 | notify.Notification() 27 | ] 28 | 29 | 30 | # this lets us skip all external plugins in theme_switcher.py while keeping _plugin "private" 31 | ExternalPlugin = ExternalPlugin 32 | -------------------------------------------------------------------------------- /yin_yang/plugins/colors.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from yin_yang import helpers 4 | 5 | from ..meta import Desktop 6 | from ._plugin import Plugin, PluginCommandline, PluginDesktopDependent 7 | 8 | 9 | class Colors(PluginDesktopDependent): 10 | def __init__(self, desktop: Desktop): 11 | if desktop == Desktop.KDE: 12 | super().__init__(_KDEColors()) 13 | else: 14 | super().__init__(None) 15 | 16 | @property 17 | def strategy(self) -> Plugin: 18 | return self._strategy_instance 19 | 20 | 21 | class _KDEColors(PluginCommandline): 22 | name = 'Colors' 23 | translations = {} 24 | 25 | def __init__(self): 26 | super().__init__(['plasma-apply-colorscheme', '{theme}']) 27 | 28 | @property 29 | def available_themes(self) -> dict: 30 | 31 | if self.translations: 32 | return self.translations 33 | 34 | colors = str( 35 | helpers.check_output( 36 | ['plasma-apply-colorscheme', '--list-schemes'], universal_newlines=True 37 | ) 38 | ) 39 | 40 | colors = colors.splitlines() 41 | del colors[0] 42 | 43 | for color in colors: 44 | color = color.replace(' * ', '') 45 | color = re.sub(r'\((.*?)\)', '', color).strip() 46 | self.translations[color] = color 47 | 48 | return self.translations 49 | -------------------------------------------------------------------------------- /yin_yang/plugins/custom.py: -------------------------------------------------------------------------------- 1 | from PySide6.QtWidgets import QLineEdit 2 | 3 | from yin_yang import helpers 4 | 5 | from ._plugin import PluginCommandline 6 | 7 | 8 | class Custom(PluginCommandline): 9 | def __init__(self): 10 | super().__init__([]) 11 | 12 | def insert_theme(self, theme: str) -> list: 13 | return [theme] 14 | 15 | @property 16 | def available(self) -> bool: 17 | return True 18 | 19 | def get_input(self, widget): 20 | inputs: list[QLineEdit | QLineEdit] = super().get_input(widget) 21 | inputs[0].setPlaceholderText('Light script') 22 | inputs[1].setPlaceholderText('Dark script') 23 | return inputs 24 | 25 | def set_theme(self, theme: str): 26 | if not theme: 27 | raise ValueError(f'Theme "{theme}" is invalid') 28 | 29 | if not (self.available and self.enabled): 30 | return 31 | 32 | # insert theme in command and run it 33 | command = self.insert_theme(theme) 34 | # set shell=True to avoid having to separate between arguments 35 | helpers.check_call(command, shell=True) 36 | -------------------------------------------------------------------------------- /yin_yang/plugins/firefox.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from configparser import ConfigParser 4 | from os.path import isdir 5 | from pathlib import Path 6 | 7 | from PySide6.QtWidgets import QGroupBox 8 | 9 | from ._plugin import ExternalPlugin 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def get_profile_paths() -> Path: 15 | path = Path.home() / '.mozilla/firefox/' 16 | config_parser = ConfigParser() 17 | config_parser.read(path / 'profiles.ini') 18 | for section in config_parser: 19 | if not section.startswith('Profile'): 20 | continue 21 | yield path / config_parser[section]['Path'] 22 | 23 | 24 | class Firefox(ExternalPlugin): 25 | """This class has no functionality except providing a section in the config""" 26 | 27 | def __init__(self): 28 | super().__init__('https://addons.mozilla.org/de/firefox/addon/yin-yang-linux/') 29 | self.theme_light = 'firefox-compact-light@mozilla.org' 30 | self.theme_dark = 'firefox-compact-dark@mozilla.org' 31 | 32 | @property 33 | def available_themes(self) -> dict: 34 | if not self.available: 35 | return {} 36 | 37 | paths = (p / 'extensions.json' for p in get_profile_paths()) 38 | themes: dict[str, str] = {} 39 | 40 | for path in paths: 41 | try: 42 | with open(path, 'r') as file: 43 | content = json.load(file) 44 | for addon in content['addons']: 45 | if addon['type'] == 'theme': 46 | themes[addon['id']] = addon['defaultLocale']['name'] 47 | except FileNotFoundError: 48 | logger.warning(f'Firefox profile has no extensions installed: {path}') 49 | continue 50 | 51 | assert themes != {}, 'No themes found!' 52 | return themes 53 | 54 | @property 55 | def available(self) -> bool: 56 | return isdir(str(Path.home()) + '/.mozilla/firefox/') 57 | 58 | def get_widget(self, area) -> QGroupBox: 59 | widget = super().get_widget(area) 60 | widget.setToolTip("You need to install the Yin-Yang extension for Firefox") 61 | 62 | return widget 63 | -------------------------------------------------------------------------------- /yin_yang/plugins/gtk.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | 4 | from PySide6.QtDBus import QDBusMessage 5 | 6 | from yin_yang import helpers 7 | from ._plugin import ( 8 | DBusPlugin, 9 | PluginCommandline, 10 | PluginDesktopDependent, 11 | themes_from_theme_directories, 12 | ) 13 | from .system import test_gnome_availability 14 | from ..meta import Desktop 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | class Gtk(PluginDesktopDependent): 20 | name = 'GTK' 21 | 22 | def __init__(self, desktop: Desktop): 23 | match desktop: 24 | case Desktop.KDE: 25 | super().__init__(_Kde()) 26 | case Desktop.GNOME: 27 | super().__init__(_Gnome()) 28 | if not self.strategy.available: 29 | print( 30 | 'You need to install an extension for gnome to use it. \n' 31 | 'You can get it from here: https://extensions.gnome.org/extension/19/user-themes/' 32 | ) 33 | case Desktop.MATE: 34 | super().__init__(_Mate()) 35 | case Desktop.XFCE: 36 | super().__init__(_Xfce()) 37 | case Desktop.CINNAMON: 38 | super().__init__(_Cinnamon()) 39 | case Desktop.BUDGIE: 40 | super().__init__(_Budgie()) 41 | case _: 42 | super().__init__(None) 43 | 44 | @property 45 | def available_themes(self) -> dict: 46 | themes = themes_from_theme_directories('gtk-3.0') 47 | return {t: t for t in themes} 48 | 49 | 50 | class _Gnome(PluginCommandline): 51 | name = 'GTK' 52 | 53 | def __init__(self): 54 | super().__init__( 55 | ['gsettings', 'set', 'org.gnome.desktop.interface', 'gtk-theme', '{theme}'] 56 | ) 57 | self.theme_light = 'Default' 58 | self.theme_dark = 'Default' 59 | 60 | @property 61 | def available(self) -> bool: 62 | return test_gnome_availability(self.command) 63 | 64 | 65 | class _Budgie(PluginCommandline): 66 | name = 'GTK' 67 | 68 | def __init__(self): 69 | super().__init__( 70 | ['gsettings', 'set', 'org.gnome.desktop.interface', 'gtk-theme', '{theme}'] 71 | ) 72 | self.theme_light = 'Default' 73 | self.theme_dark = 'Default' 74 | 75 | @property 76 | def available(self) -> bool: 77 | return test_gnome_availability(self.command) 78 | 79 | 80 | class _Kde(DBusPlugin): 81 | name = 'GTK' 82 | 83 | def __init__(self): 84 | super().__init__() 85 | self.theme_light = 'Breeze' 86 | self.theme_dark = 'Breeze' 87 | 88 | def create_message(self, theme: str) -> QDBusMessage: 89 | message = QDBusMessage.createMethodCall( 90 | 'org.kde.GtkConfig', '/GtkConfig', 'org.kde.GtkConfig', 'setGtkTheme' 91 | ) 92 | message.setArguments([theme]) 93 | return message 94 | 95 | def set_theme(self, theme: str): 96 | if self.connection.interface().isServiceRegistered('org.kde.GtkConfig').value(): 97 | logger.debug("Detected kde-gtk-config, use it") 98 | self.call(self.create_message(theme)) 99 | return 100 | 101 | logger.warning('kde-gtk-config not available, trying xsettingsd') 102 | xsettingsd_conf_path = Path.home() / '.config/xsettingsd/xsettingsd.conf' 103 | if not xsettingsd_conf_path.exists(): 104 | logger.warning('xsettingsd not available') 105 | return 106 | 107 | with open(xsettingsd_conf_path, 'r') as f: 108 | lines = f.readlines() 109 | for i, line in enumerate(lines): 110 | if line.startswith('Net/ThemeName'): 111 | lines[i] = f'Net/ThemeName "{theme}"\n' 112 | break 113 | 114 | with open(xsettingsd_conf_path, 'w') as f: 115 | f.writelines(lines) 116 | 117 | # send signal to read new config 118 | helpers.run(['killall', '-HUP', 'xsettingsd']) 119 | 120 | # change dconf db. since dconf sending data as GVariant, use gsettings instead 121 | helpers.run(['gsettings', 'set', 'org.gnome.desktop.interface', 'gtk-theme', f'{theme}']) 122 | color_scheme = 'prefer-dark' if theme == self.theme_dark else 'prefer-light' 123 | helpers.run(['gsettings', 'set', 'org.gnome.desktop.interface', 'color-scheme', f'{color_scheme}']) 124 | 125 | 126 | class _Xfce(PluginCommandline): 127 | def __init__(self): 128 | super(_Xfce, self).__init__( 129 | ['xfconf-query', '-c', 'xsettings', '-p', '/Net/ThemeName', '-s', '{theme}'] 130 | ) 131 | self.theme_light = 'Adwaita' 132 | self.theme_dark = 'Adwaita-dark' 133 | 134 | 135 | class _Mate(PluginCommandline): 136 | def __init__(self): 137 | super().__init__( 138 | ['dconf', 'write', '/org/mate/desktop/interface/gtk-theme', '"{theme}"'] 139 | ) 140 | self.theme_light = 'Yaru' 141 | self.theme_dark = 'Yaru-dark' 142 | 143 | @property 144 | def available(self) -> bool: 145 | return self.check_command(['dconf', 'help']) 146 | 147 | 148 | class _Cinnamon(PluginCommandline): 149 | def __init__(self): 150 | super().__init__( 151 | [ 152 | 'gsettings', 153 | 'set', 154 | 'org.cinnamon.desktop.interface', 155 | 'gtk-theme', 156 | '"{theme}"', 157 | ] 158 | ) 159 | self.theme_light = 'Adwaita' 160 | self.theme_dark = 'Adwaita-dark' 161 | 162 | @property 163 | def available(self) -> bool: 164 | return test_gnome_availability(self.command) 165 | -------------------------------------------------------------------------------- /yin_yang/plugins/icons.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | from os import path, scandir 3 | from pathlib import Path 4 | 5 | from ..meta import Desktop 6 | from ._plugin import PluginCommandline, PluginDesktopDependent 7 | from .system import test_gnome_availability 8 | 9 | theme_directories = ['/usr/share/icons', f'{Path.home()}/.icons'] 10 | 11 | 12 | class Icons(PluginDesktopDependent): 13 | def __init__(self, desktop: Desktop): 14 | match desktop: 15 | case Desktop.MATE: 16 | super().__init__(_Mate()) 17 | case Desktop.CINNAMON: 18 | super().__init__(_Cinnamon()) 19 | case Desktop.BUDGIE: 20 | super().__init__(_Budgie()) 21 | case Desktop.KDE: 22 | super().__init__(_Kde()) 23 | case _: 24 | super().__init__(None) 25 | 26 | 27 | class _Mate(PluginCommandline): 28 | def __init__(self): 29 | super().__init__( 30 | ['dconf', 'write', '/org/mate/desktop/interface/icon-theme', '\"{theme}\"'] 31 | ) 32 | self.theme_light = 'Yaru' 33 | self.theme_dark = 'Yaru-dark' 34 | 35 | @property 36 | def available(self): 37 | return self.check_command(['dconf', 'help']) 38 | 39 | 40 | class _Cinnamon(PluginCommandline): 41 | def __init__(self): 42 | super().__init__( 43 | [ 44 | 'gsettings', 45 | 'set', 46 | 'org.cinnamon.desktop.interface', 47 | 'icon-theme', 48 | '\"{theme}\"', 49 | ] 50 | ) 51 | self.theme_light = 'Mint-X' 52 | self.theme_dark = 'gnome' 53 | 54 | @property 55 | def available(self) -> bool: 56 | return test_gnome_availability(self.command) 57 | 58 | 59 | class _Budgie(PluginCommandline): 60 | def __init__(self): 61 | super().__init__( 62 | [ 63 | 'gsettings', 64 | 'set', 65 | 'org.gnome.desktop.interface', 66 | 'icon-theme', 67 | '\"{theme}\"', 68 | ] 69 | ) 70 | self.theme_light = 'Default' 71 | self.theme_dark = 'Default' 72 | 73 | @property 74 | def available(self) -> bool: 75 | return test_gnome_availability(self.command) 76 | 77 | @property 78 | def available_themes(self) -> dict: 79 | themes = [] 80 | 81 | for directory in theme_directories: 82 | if not path.isdir(directory): 83 | continue 84 | 85 | with scandir(directory) as entries: 86 | themes.extend( 87 | d.name 88 | for d in entries 89 | if d.is_dir() and path.isfile(d.path + '/index.theme') 90 | ) 91 | 92 | return {t: t for t in themes} 93 | 94 | 95 | class _Kde(PluginCommandline): 96 | def __init__(self): 97 | super().__init__(["/usr/lib/plasma-changeicons", r"{theme}"]) 98 | self.theme_light = "breeze" 99 | self.theme_dark = "breeze-dark" 100 | 101 | @property 102 | def available_themes(self) -> dict: 103 | themes = [] 104 | 105 | for icon_theme in theme_directories: 106 | if not path.isdir(icon_theme): 107 | continue 108 | 109 | for icon_theme_folder in scandir(icon_theme): 110 | if not all( 111 | ( 112 | icon_theme_folder.is_dir(), 113 | path.isfile(icon_theme_folder.path + "/index.theme"), 114 | path.isfile(icon_theme_folder.path + "/icon-theme.cache"), 115 | ) 116 | ): 117 | continue 118 | 119 | theme_parser = configparser.ConfigParser(strict=False) 120 | theme_parser.read(icon_theme_folder.path + "/index.theme") 121 | theme_name = theme_parser["Icon Theme"]["Name"] 122 | 123 | # this will exclude any icon themes that aren't icons, such as cursors 124 | # https://specifications.freedesktop.org/icon-theme-spec/latest/#id-1.5.4.1 125 | if any("Size" in theme_parser[s] for s in theme_parser.sections()): 126 | themes.append((icon_theme_folder.name, theme_name)) 127 | 128 | return dict(themes) 129 | 130 | @property 131 | def available(self) -> dict: 132 | return path.isfile("/usr/lib/plasma-changeicons") 133 | -------------------------------------------------------------------------------- /yin_yang/plugins/konsole.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import re 4 | from configparser import ConfigParser 5 | from doctest import debug 6 | from itertools import chain 7 | from pathlib import Path 8 | from shutil import copyfile 9 | 10 | from PySide6.QtDBus import QDBusMessage 11 | 12 | from yin_yang import helpers 13 | from ._plugin import DBusPlugin 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class Konsole(DBusPlugin): 19 | ''' 20 | Themes are profiles. To use a color scheme, 21 | create a new profile or edit one to use the desired color scheme. 22 | This is necessary to allow live theme changes. 23 | ''' 24 | 25 | global_path = Path(helpers.get_usr() + 'share/konsole') 26 | config_path = Path.home() / '.config/konsolerc' 27 | # apps using konsole 28 | apps_have_konsole = ["org.kde.konsole", "org.kde.yakuake", "org.kde.dolphin", "org.kde.kate"] 29 | 30 | @property 31 | def user_path(self) -> Path: 32 | return Path.home() / '.local/share/konsole' 33 | 34 | def __init__(self): 35 | super().__init__() 36 | self._theme_light = 'BlackOnWhite' 37 | self._theme_dark = 'Breeze' 38 | 39 | @property 40 | def theme_light(self): 41 | return self._theme_light 42 | 43 | @theme_light.setter 44 | def theme_light(self, value): 45 | self.update_profile(False, value) 46 | self._theme_light = value 47 | 48 | @property 49 | def theme_dark(self): 50 | return self._theme_dark 51 | 52 | @theme_dark.setter 53 | def theme_dark(self, value): 54 | self.update_profile(True, value) 55 | self._theme_dark = value 56 | 57 | def set_mode(self, dark: bool) -> bool: 58 | # run checks 59 | if not super().set_mode(dark): 60 | return False 61 | 62 | profile = 'Dark' if dark else 'Light' 63 | 64 | # update default profile, if application is started afterward 65 | self.default_profile = profile + '.profile' 66 | 67 | # Find available konsole sessions, including dolphin, yakuake, kate, etc 68 | services = [str(n) for n in self.connection.interface().registeredServiceNames().value() 69 | if n.split('-')[0] in self.apps_have_konsole] 70 | for service in services: 71 | logger.debug(f'Changing profile in konsole session {service}') 72 | self.set_profile(service, profile=profile) 73 | self.set_profile(service, profile, set_default_profile=True) 74 | 75 | return True 76 | 77 | def set_theme(self, theme: str): 78 | # everything is done in set_mode (above) 79 | pass 80 | 81 | def create_message(self, theme: str) -> QDBusMessage: 82 | message = QDBusMessage.createMethodCall( 83 | 'org.kde.konsole', 'Sessions/', 'org.kde.konsole.Session', 'setProfile' 84 | ) 85 | message.setArguments([theme]) 86 | return message 87 | 88 | @property 89 | def available_themes(self) -> dict: 90 | if not self.available: 91 | return {} 92 | 93 | global_files = self.global_path.iterdir() if self.global_path.is_dir() else [] 94 | user_files = self.user_path.iterdir() if self.user_path.is_dir() else [] 95 | 96 | themes = dict( 97 | sorted( 98 | [ 99 | (p.with_suffix('').name, p) 100 | for p in chain(global_files, user_files) 101 | if p.is_file() and p.suffix == '.colorscheme' 102 | ] 103 | ) 104 | ) 105 | 106 | themes_dict = {} 107 | config_parser = ConfigParser() 108 | 109 | for theme, theme_path in themes.items(): 110 | config_parser.read(theme_path) 111 | theme_name = config_parser['General']['Description'] 112 | themes_dict[theme] = theme_name 113 | 114 | if themes_dict == {}: 115 | # sync with https://invent.kde.org/utilities/konsole/-/blob/master/data/color-schemes/BlackOnWhite.colorscheme 116 | # these are included in the binary 117 | return { 118 | 'BlackOnLightYellow': 'Black on Light Yellow', 119 | 'BlackOnRandomLight': 'Black on Random Light', 120 | 'BlackOnWhite': 'Black on White', 121 | 'BlueOnBlack': 'Blue on Black', 122 | 'Breeze': 'Breeze', 123 | 'Campbell': 'Campbell', 124 | 'DarkPastels': 'Dark Pastels', 125 | 'GreenOnBlack': 'Green on Black', 126 | 'Linux': 'Linux Colors', 127 | 'RedOnBlack': 'Red on Black', 128 | 'Solarized': 'Solarized', 129 | 'SolarizedLight': 'Solarized Light', 130 | 'WhiteOnBlack': 'White on Black' 131 | } 132 | 133 | return themes_dict 134 | 135 | @property 136 | def available(self) -> bool: 137 | return self.global_path.is_dir() or self.user_path.is_dir() 138 | 139 | @property 140 | def default_profile(self): 141 | value = None 142 | # Ensure directory exists 143 | Path(self.user_path).mkdir(parents=True, exist_ok=True) 144 | 145 | # cant use config parser because of weird file structure 146 | with self.config_path.open('r') as file: 147 | for line in file: 148 | # Search for the pattern 'DefaultProfile=*' 149 | match = re.search(r'DefaultProfile=(.*)', line) 150 | 151 | # If a match is found, return the content of the wildcard '*' 152 | if match: 153 | value = match.group(1) 154 | if not os.path.isfile(self.user_path / value): 155 | value = None 156 | 157 | if value is None: 158 | # use the first found profile 159 | for file in self.user_path.iterdir(): 160 | if file.suffix == '.profile': 161 | value = file.name 162 | break 163 | if value is not None: 164 | logger.warning(f'No default profile found, using {value} instead.') 165 | 166 | if value is None: 167 | # create a custom profile manually 168 | file_content = '''[Appearance] 169 | ColorScheme=Breeze 170 | 171 | [General] 172 | Command=/bin/bash 173 | Name=YinYang 174 | Parent=FALLBACK/ 175 | ''' 176 | 177 | with (self.user_path / 'Default.profile').open('w') as file: 178 | file.writelines(file_content) 179 | 180 | self.default_profile = 'Default.profile' 181 | 182 | return 'Default.profile' 183 | 184 | return value 185 | 186 | @default_profile.setter 187 | def default_profile(self, value: str): 188 | assert value.endswith('.profile') 189 | 190 | match: re.Match[str] | None = None 191 | with self.config_path.open('r') as file: 192 | lines = file.readlines() 193 | for i, line in enumerate(lines): 194 | # Search for the pattern 'DefaultProfile=*' 195 | match = re.search(r'DefaultProfile=(.*)', line) 196 | 197 | # If a match is found, return the content of the wildcard '*' 198 | if match: 199 | logger.debug(f'Changing default profile to {value}') 200 | lines[i] = f'DefaultProfile={value}\n' 201 | break 202 | 203 | if match is None: 204 | logger.error(f'No DefaultProfile field found in {self.config_path}') 205 | else: 206 | with self.config_path.open('w') as file: 207 | file.writelines(lines) 208 | 209 | def update_profile(self, dark: bool, theme: str): 210 | if not self.available or theme == '': 211 | # theme is empty string on super init 212 | return 213 | 214 | # update the color scheme setting in either dark or light profile 215 | logger.debug('Updating konsole profile') 216 | 217 | file_path = self.user_path / ('Dark.profile' if dark else 'Light.profile') 218 | if not file_path.exists(): 219 | self.create_profiles() 220 | 221 | profile_config = ConfigParser() 222 | profile_config.optionxform = str 223 | profile_config.read(file_path) 224 | 225 | try: 226 | profile_config['Appearance']['ColorScheme'] = theme 227 | except KeyError: 228 | profile_config.add_section('Appearance') 229 | profile_config['Appearance']['ColorScheme'] = theme 230 | 231 | with open(file_path, 'w') as file: 232 | profile_config.write(file) 233 | 234 | def create_profiles(self): 235 | logger.debug( 236 | 'Creating new profiles for live-switching between light and dark themes.' 237 | ) 238 | # copy default profile to create theme profiles 239 | light_profile = self.user_path / 'Light.profile' 240 | dark_profile = self.user_path / 'Dark.profile' 241 | # TODO there is a parent profile section in the profile file, maybe we can use that (in a later version)? 242 | copyfile(self.user_path / self.default_profile, light_profile) 243 | copyfile(self.user_path / self.default_profile, dark_profile) 244 | 245 | # Change name in file 246 | profile_config = ConfigParser() 247 | profile_config.optionxform = str 248 | 249 | profile_config.read(light_profile) 250 | profile_config['General']['Name'] = light_profile.stem 251 | 252 | with open(light_profile, 'w') as file: 253 | profile_config.write(file) 254 | 255 | profile_config.read(dark_profile) 256 | profile_config['General']['Name'] = dark_profile.stem 257 | 258 | with open(dark_profile, 'w') as file: 259 | profile_config.write(file) 260 | 261 | def set_profile(self, service: str, profile: str, set_default_profile: bool = False): 262 | if set_default_profile: 263 | path = '/Sessions' 264 | interface = 'org.kde.konsole.Session' 265 | method = 'setProfile' 266 | else: 267 | path = '/Windows' 268 | interface = 'org.kde.konsole.Window' 269 | method = 'setDefaultProfile' 270 | 271 | sessions = self.list_paths(service, path) 272 | 273 | for session in sessions: 274 | logger.debug( 275 | f'Changing {"default" if set_default_profile else ""} profile of session {session} to {profile}' 276 | ) 277 | # set profile 278 | message = QDBusMessage.createMethodCall(service, session, interface, method) 279 | message.setArguments([profile]) 280 | self.connection.call(message) 281 | -------------------------------------------------------------------------------- /yin_yang/plugins/kvantum.py: -------------------------------------------------------------------------------- 1 | from os import walk 2 | from pathlib import Path 3 | 4 | from yin_yang import helpers 5 | 6 | from ._plugin import PluginCommandline 7 | 8 | 9 | class Kvantum(PluginCommandline): 10 | def __init__(self): 11 | super().__init__(['kvantummanager', '--set', '{theme}']) 12 | self.theme_light = 'KvFlatLight' 13 | self.theme_dark = 'KvFlat' 14 | 15 | @classmethod 16 | def get_kvantum_theme_from_dir(cls, directory: Path): 17 | result = set() 18 | for _, _, filenames in walk(directory): 19 | for filename in filenames: 20 | if filename.endswith('.kvconfig'): 21 | result.add(filename[:-9]) 22 | return list(result) 23 | 24 | @property 25 | def available_themes(self) -> dict: 26 | if not self.available: 27 | return {} 28 | 29 | paths = [Path(helpers.get_usr() + 'share/Kvantum'), Path.home() / '.config/Kvantum'] 30 | themes = list() 31 | for path in paths: 32 | themes = themes + self.get_kvantum_theme_from_dir(path) 33 | assert len(themes) > 0, 'No themes were found' 34 | 35 | themes.sort() 36 | themes_dict = {t: t for t in themes} 37 | 38 | assert themes_dict != {}, 'No themes found!' 39 | return themes_dict 40 | -------------------------------------------------------------------------------- /yin_yang/plugins/notify.py: -------------------------------------------------------------------------------- 1 | from PySide6.QtDBus import QDBusMessage 2 | 3 | from ..notification_handler import create_dbus_message 4 | from ._plugin import DBusPlugin 5 | 6 | 7 | class Notification(DBusPlugin): 8 | def __init__(self): 9 | super().__init__() 10 | self.theme_light = 'Day' 11 | self.theme_dark = 'Night' 12 | 13 | def create_message(self, theme: str) -> QDBusMessage: 14 | return create_dbus_message('Theme changed', f'Set the theme to {theme}') 15 | -------------------------------------------------------------------------------- /yin_yang/plugins/okular.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | import psutil 5 | from PySide6.QtDBus import QDBusConnection, QDBusMessage 6 | 7 | from ..meta import FileFormat 8 | from ._plugin import flatpak_user, ConfigFilePlugin 9 | 10 | 11 | class Okular(ConfigFilePlugin): 12 | """Inspired by: 13 | https://gitlab.com/LADlSLAV/yabotss/-/blob/main/darkman_examples_kde_plasma/dark-mode.d/10_set_theme_okular_dark.sh 14 | """ 15 | 16 | def __init__(self): 17 | super().__init__([ 18 | Path.home() / '.config/okularpartrc', 19 | flatpak_user('org.kde.okular') / 'config/okularpartrc' 20 | ], file_format=FileFormat.CONFIG) 21 | self._theme_light = '' 22 | self._theme_dark = 'InvertLightness' 23 | 24 | def set_mode(self, dark: bool): 25 | if not self.enabled: 26 | return False 27 | 28 | process_ids = [ 29 | proc.pid for proc in psutil.process_iter(['name', 'username']) 30 | if proc.name() == 'okular' and proc.username() == os.getlogin() 31 | ] 32 | # this is if okular is running in a flatpak 33 | process_ids.append(2) 34 | 35 | connection = QDBusConnection.sessionBus() 36 | for pid in process_ids: 37 | message = QDBusMessage.createMethodCall( 38 | f'org.kde.okular-{pid}', 39 | '/okular', 40 | 'org.kde.okular', 41 | 'slotSetChangeColors' 42 | ) 43 | message.setArguments([dark]) 44 | connection.call(message) 45 | 46 | # now change the config for future starts of the app 47 | self.set_theme(self.theme_dark if dark else self.theme_light, ignore_theme_check=True) 48 | 49 | def update_config(self, config, theme: str) -> str: 50 | if theme == self.theme_dark: 51 | if not config.has_section('Document'): 52 | config.add_section('Document') 53 | config['Document']['ChangeColors'] = 'true' 54 | else: 55 | config.remove_option('Document', 'ChangeColors') 56 | if len(config.options('Document')) == 0: 57 | config.remove_section('Document') 58 | return config 59 | 60 | @property 61 | def available_themes(self) -> dict: 62 | # these are color changing modes in Okulars accessibility settings 63 | return { 64 | '': 'Invert colors', 65 | 'InvertLightness': 'Invert lightness', 66 | 'InvertLuma': 'Invert luma (sRGB linear)', 67 | 'InvertLumaSymmetric': 'Invert luma (symmetrical)' 68 | } 69 | 70 | def get_input(self, widget): 71 | inputs = super().get_input(widget) 72 | n_items = len(self.available_themes) 73 | 74 | # modify light item to make it clear that this shows the original without modifications 75 | for i in range(n_items): 76 | inputs[0].removeItem(0) 77 | inputs[0].addItem('Don\'t modify anything') 78 | 79 | return inputs 80 | 81 | @property 82 | def theme_dark(self): 83 | return self._theme_dark 84 | 85 | @theme_dark.setter 86 | def theme_dark(self, value): 87 | self._theme_dark = value 88 | 89 | for config_path in self.config_paths: 90 | if not config_path.exists(): 91 | continue 92 | 93 | config = self.open_config(config_path) 94 | 95 | # update rendering mode 96 | if value == '': 97 | if config.has_section('Document'): 98 | config.remove_option('Document', 'RenderMode') 99 | if len(config.options('Document')) == 0: 100 | config.remove_section('Document') 101 | else: 102 | if not config.has_section('Document'): 103 | config.add_section('Document') 104 | config['Document']['RenderMode'] = value 105 | 106 | self.write_config(config, config_path, space_around_delimiters=False) 107 | -------------------------------------------------------------------------------- /yin_yang/plugins/only_office.py: -------------------------------------------------------------------------------- 1 | from configparser import ConfigParser 2 | from pathlib import Path 3 | 4 | from ..meta import FileFormat 5 | from ._plugin import ConfigFilePlugin, flatpak_user 6 | 7 | 8 | class OnlyOffice(ConfigFilePlugin): 9 | def __init__(self): 10 | super().__init__([ 11 | Path.home() / '.config/onlyoffice/DesktopEditors.conf', 12 | flatpak_user('org.onlyoffice.desktopeditors') / 'config/onlyoffice/DesktopEditors.conf' 13 | ], file_format=FileFormat.CONFIG) 14 | self.theme_light = 'theme-light' 15 | self.theme_dark = 'theme-dark' 16 | 17 | def update_config(self, config: ConfigParser, theme: str): 18 | config['General']['UITheme'] = theme 19 | return config 20 | 21 | @property 22 | def available_themes(self) -> dict: 23 | return { 24 | 'theme-system': 'System', 25 | 'theme-light': 'Light', 26 | 'theme-classic-light': 'Classic light', 27 | 'theme-dark': 'Dark', 28 | 'theme-contrast-dark': 'Dark contrast' 29 | } 30 | -------------------------------------------------------------------------------- /yin_yang/plugins/system.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | import pwd 5 | from configparser import ConfigParser 6 | from pathlib import Path 7 | 8 | from PySide6.QtCore import QLocale 9 | from PySide6.QtDBus import QDBusMessage, QDBusVariant 10 | 11 | from yin_yang import helpers 12 | 13 | from ..meta import Desktop 14 | from ._plugin import ( 15 | DBusPlugin, 16 | PluginCommandline, 17 | PluginDesktopDependent, 18 | themes_from_theme_directories, 19 | ) 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | def test_gnome_availability(command) -> bool: 25 | return PluginCommandline.check_command([command[0], 'get', command[2], command[3]]) 26 | 27 | 28 | class System(PluginDesktopDependent): 29 | def __init__(self, desktop: Desktop): 30 | match desktop: 31 | case Desktop.KDE: 32 | super().__init__(_Kde()) 33 | case Desktop.GNOME: 34 | super().__init__(_Gnome()) 35 | case Desktop.MATE: 36 | super().__init__(_Mate()) 37 | case Desktop.CINNAMON: 38 | super().__init__(_Cinnamon()) 39 | case Desktop.BUDGIE: 40 | super().__init__(_Budgie()) 41 | case Desktop.XFCE: 42 | super().__init__(_Xfce()) 43 | case _: 44 | super().__init__(None) 45 | 46 | 47 | class _Gnome(PluginCommandline): 48 | name = 'System' 49 | 50 | # TODO allow using the default themes, not only user themes 51 | 52 | def __init__(self): 53 | super().__init__( 54 | [ 55 | 'gsettings', 56 | 'set', 57 | 'org.gnome.shell.extensions.user-theme', 58 | 'name', 59 | '{theme}', 60 | ] 61 | ) 62 | 63 | @property 64 | def available(self) -> bool: 65 | return test_gnome_availability(self.command) 66 | 67 | 68 | class _Budgie(PluginCommandline): 69 | name = 'System' 70 | 71 | def __init__(self): 72 | super().__init__( 73 | [ 74 | 'gsettings', 75 | 'set', 76 | 'com.solus-project.budgie-panel', 77 | 'dark-theme', 78 | '{theme}', 79 | ] 80 | ) 81 | self.theme_light = 'light' 82 | self.theme_dark = 'dark' 83 | 84 | @property 85 | def available(self) -> bool: 86 | return test_gnome_availability(self.command) 87 | 88 | # Override because budgie uses a switch for dark/light mode 89 | def insert_theme(self, theme: str) -> list: 90 | command = self.command.copy() 91 | match theme.lower(): 92 | case 'dark': 93 | theme_bool = 'true' 94 | case 'light': 95 | theme_bool = 'false' 96 | case _: 97 | raise NotImplementedError 98 | 99 | for i, arg in enumerate(command): 100 | command[i] = arg.format(theme=theme_bool) 101 | 102 | return command 103 | 104 | @property 105 | def available_themes(self) -> dict: 106 | themes: dict[str, str] = {'dark': 'Dark', 'light': 'Light'} 107 | 108 | return themes 109 | 110 | 111 | def get_readable_kde_theme_name(file) -> str: 112 | '''Searches for the long_name in the file and maps it to the found short name''' 113 | 114 | for line in file: 115 | if 'Name=' in line: 116 | name: str = '' 117 | write: bool = False 118 | for letter in line: 119 | if letter == '\n': 120 | write = False 121 | if write: 122 | name += letter 123 | if letter == '=': 124 | write = True 125 | return name 126 | 127 | 128 | def get_name_key(meta): 129 | locale = filter( 130 | lambda name: name in meta['KPlugin'], 131 | [f'Name[{QLocale().name()}]', f'Name[{QLocale().language()}]', 'Name'], 132 | ) 133 | return next(locale) 134 | 135 | 136 | class _Kde(PluginCommandline): 137 | name = 'System' 138 | translations = {} 139 | 140 | def __init__(self): 141 | super().__init__(['lookandfeeltool', '-a', '{theme}']) 142 | self.theme_light = 'org.kde.breeze.desktop' 143 | self.theme_dark = 'org.kde.breezedark.desktop' 144 | 145 | @property 146 | def available_themes(self) -> dict: 147 | if self.translations != {}: 148 | return self.translations 149 | 150 | # aliases for path to use later on 151 | user = pwd.getpwuid(os.getuid())[0] 152 | path = '/home/' + user + '/.local/share/plasma/look-and-feel/' 153 | 154 | # asks the system what themes are available 155 | # noinspection SpellCheckingInspection 156 | long_names = helpers.check_output( 157 | ['lookandfeeltool', '-l'], universal_newlines=True 158 | ) 159 | long_names = long_names.splitlines() 160 | long_names.sort() 161 | 162 | # get the actual name 163 | for long_name in long_names: 164 | # trying to get the Desktop file 165 | try: 166 | # json in newer versions 167 | with open( 168 | f'{helpers.get_usr()}share/plasma/look-and-feel/{long_name}/metadata.json', 169 | 'r', 170 | ) as file: 171 | meta = json.load(file) 172 | key = get_name_key(meta) 173 | self.translations[long_name] = meta['KPlugin'][key] 174 | except OSError: 175 | try: 176 | # load the name from the metadata.desktop file 177 | with open( 178 | f'{helpers.get_usr()}share/plasma/look-and-feel/{long_name}/metadata.desktop', 179 | 'r', 180 | ) as file: 181 | self.translations[long_name] = get_readable_kde_theme_name(file) 182 | except OSError: 183 | # check the next path if the themes exist there 184 | try: 185 | # load the name from the metadata.desktop file 186 | with open(f'{path}{long_name}/metadata.desktop', 'r') as file: 187 | # search for the name 188 | self.translations[long_name] = get_readable_kde_theme_name( 189 | file 190 | ) 191 | except OSError: 192 | # if no file exist lets just use the long name 193 | self.translations[long_name] = long_name 194 | 195 | return self.translations 196 | 197 | 198 | class _Mate(PluginCommandline): 199 | theme_directories = [ 200 | Path(helpers.get_usr() + 'share/themes'), 201 | Path.home() / '.themes', 202 | ] 203 | 204 | def __init__(self): 205 | super().__init__( 206 | ['dconf', 'write', '/org/mate/marco/general/theme', '"{theme}"'] 207 | ) 208 | self.theme_light = 'Yaru' 209 | self.theme_dark = 'Yaru-dark' 210 | 211 | @property 212 | def available_themes(self) -> dict: 213 | themes = [] 214 | 215 | for directory in self.theme_directories: 216 | if not directory.is_dir(): 217 | continue 218 | 219 | for d in directory.iterdir(): 220 | index = d / 'index.theme' 221 | if not index.is_file(): 222 | continue 223 | 224 | config = ConfigParser() 225 | config.read(index) 226 | try: 227 | theme = config['X-GNOME-Metatheme']['MetacityTheme'] 228 | themes.append(theme) 229 | except KeyError: 230 | continue 231 | 232 | return {t: t for t in themes} 233 | 234 | @property 235 | def available(self): 236 | return self.check_command(['dconf', 'help']) 237 | 238 | 239 | class _Cinnamon(PluginCommandline): 240 | def __init__(self): 241 | super().__init__( 242 | ['gsettings', 'set', 'org.cinnamon.theme', 'name', '"{theme}"'] 243 | ) 244 | self.theme_light = 'Mint-X-Teal' 245 | self.theme_dark = 'Mint-Y-Dark-Brown' 246 | 247 | @property 248 | def available(self) -> bool: 249 | return test_gnome_availability(self.command) 250 | 251 | 252 | class _Xfce(DBusPlugin): 253 | def create_message(self, theme: str) -> QDBusMessage: 254 | message = QDBusMessage.createMethodCall( 255 | 'org.xfce.Xfconf', '/org/xfce/Xfconf', 'org.xfce.Xfconf', 'SetProperty' 256 | ) 257 | theme_variant = QDBusVariant() 258 | theme_variant.setVariant(theme) 259 | message.setArguments(['xfwm4', '/general/theme', theme_variant]) 260 | return message 261 | 262 | @property 263 | def available_themes(self) -> dict: 264 | themes = themes_from_theme_directories('xfwm4') 265 | return {t: t for t in themes} 266 | -------------------------------------------------------------------------------- /yin_yang/plugins/vscode.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | from os.path import isdir, isfile 5 | from pathlib import Path 6 | 7 | from .. import helpers 8 | from ..meta import FileFormat 9 | from ._plugin import flatpak_system, flatpak_user, snap_path, ConfigFilePlugin 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | extension_paths = [ 14 | str(Path.home() / '.vscode/extensions'), 15 | str(Path.home() / '.vscode-insiders/extensions'), 16 | str(Path.home() / '.vscode-oss/extensions'), 17 | helpers.get_usr() + 'lib/code/extensions', 18 | helpers.get_usr() + 'lib/code-insiders/extensions', 19 | helpers.get_usr() + 'share/code/resources/app/extensions', 20 | helpers.get_usr() + 'share/code-insiders/resources/app/extensions', 21 | '/usr/share/vscodium/resources/app/extensions', 22 | '/usr/share/vscodium-git/resources/app/extensions', 23 | '/usr/share/vscodium-insiders/resources/app/extensions', 24 | '/usr/share/vscodium-insiders-bin/resources/app/extensions', 25 | '/opt/visual-studio-code/resources/app/extensions/', 26 | '/opt/visual-studio-code-insiders/resources/app/extensions/', 27 | '/opt/vscodium-bin/resources/app/extensions/', 28 | str(snap_path('code') / 'usr/share/code/resources/app/extensions/'), 29 | str(snap_path('code-insiders') / 'usr/share/code-insiders/resources/app/extensions/'), 30 | str(flatpak_user('com.visualstudio.code') / 'data/vscode/extensions/'), 31 | str(flatpak_user('com.visualstudio.code-oss') / 'data/vscode/extensions/'), 32 | str(flatpak_user('com.vscodium.codium') / 'data/codium/extensions/'), 33 | str(flatpak_system('com.visualstudio.code') / 'files/extra/vscode/resources/app/extensions/'), 34 | str(flatpak_system('com.visualstudio.code-oss') / 'files/main/resources/app/extensions/'), 35 | str(flatpak_system('com.vscodium.codium') / 'files/share/codium/resources/app/extensions/') 36 | ] 37 | 38 | 39 | def get_theme_name(path): 40 | if not isfile(path): 41 | return [] 42 | 43 | # open metadata 44 | manifest: dict 45 | with open(path, 'r') as file: 46 | manifest = json.load(file) 47 | 48 | if 'contributes' not in manifest: 49 | return [] 50 | 51 | # collect themes 52 | themes: list 53 | if 'themes' in manifest['contributes']: 54 | themes = manifest['contributes']['themes'] 55 | elif 'Themes' in manifest['contributes']: 56 | themes = manifest['contributes']['Themes'] 57 | else: 58 | return [] 59 | 60 | return (theme['id'] if 'id' in theme else theme['label'] for theme in themes) 61 | 62 | 63 | class Vscode(ConfigFilePlugin): 64 | name = 'VS Code' 65 | 66 | def __init__(self): 67 | possible_editors = [ 68 | "VSCodium", 69 | "Code - OSS", 70 | "Code", 71 | "Code - Insiders", 72 | ] 73 | paths = [Path.home() / f'.config/{name}/User/settings.json' for name in possible_editors] 74 | paths += [ 75 | flatpak_user('com.visualstudio.code') / 'config/Code/User/settings.json', 76 | flatpak_user('com.visualstudio.code-oss') / 'config/Code - OSS/User/settings.json', 77 | flatpak_user('com.vscodium.codium') / 'config/VSCodium/User/settings.json' 78 | ] 79 | super(Vscode, self).__init__(paths, file_format=FileFormat.JSON) 80 | self.theme_light = 'Default Light Modern' 81 | self.theme_dark = 'Default Dark Modern' 82 | 83 | def update_config(self, config: dict, theme: str): 84 | config['workbench.colorTheme'] = theme 85 | return json.dumps(config) 86 | 87 | @property 88 | def available_themes(self) -> dict: 89 | themes_dict = {} 90 | 91 | for path in filter(isdir, extension_paths): 92 | with os.scandir(path) as entries: 93 | for d in entries: 94 | # filter for a dir that doesn't seem to be an extension 95 | # since it has no manifest 96 | if not d.is_dir() or d.name == 'node_modules': 97 | continue 98 | 99 | for theme_name in get_theme_name(f'{d.path}/package.json'): 100 | themes_dict[theme_name] = theme_name 101 | 102 | return themes_dict 103 | 104 | @property 105 | def available(self) -> bool: 106 | return self.available_themes != {} 107 | 108 | def __str__(self): 109 | # for backwards compatibility 110 | return 'code' 111 | 112 | @property 113 | def default_config(self): 114 | return {'workbench.colorTheme': 'Default'} 115 | -------------------------------------------------------------------------------- /yin_yang/plugins/wallpaper.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | 4 | from PySide6.QtDBus import QDBusMessage 5 | from PySide6.QtWidgets import QDialogButtonBox, QVBoxLayout, QWidget, QLineEdit 6 | 7 | from yin_yang import helpers 8 | from ._plugin import PluginDesktopDependent, PluginCommandline, DBusPlugin 9 | from .system import test_gnome_availability 10 | from ..meta import Desktop 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class Wallpaper(PluginDesktopDependent): 16 | # themes are image file paths 17 | 18 | def __init__(self, desktop: Desktop): 19 | match desktop: 20 | case Desktop.KDE: 21 | super().__init__(_Kde()) 22 | case Desktop.GNOME: 23 | super().__init__(_Gnome()) 24 | case Desktop.XFCE: 25 | super().__init__(_Xfce()) 26 | case Desktop.CINNAMON: 27 | super().__init__(_Cinnamon()) 28 | case Desktop.BUDGIE: 29 | super().__init__(_Budgie()) 30 | case _: 31 | super().__init__(None) 32 | 33 | def get_input(self, widget): 34 | widgets = [] 35 | 36 | for is_dark in [False, True]: 37 | grp = QWidget(widget) 38 | horizontal_layout = QVBoxLayout(grp) 39 | 40 | line = QLineEdit(grp) 41 | line.setText(self.theme_dark if is_dark else self.theme_light) 42 | horizontal_layout.addWidget(line) 43 | 44 | btn = QDialogButtonBox(grp) 45 | btn.setStandardButtons(QDialogButtonBox.Open) 46 | horizontal_layout.addWidget(btn) 47 | 48 | widgets.append(grp) 49 | 50 | return widgets 51 | 52 | 53 | class _Gnome(PluginCommandline): 54 | name = 'Wallpaper' 55 | 56 | def __init__(self): 57 | super().__init__(['gsettings', 'set', 'org.gnome.desktop.background', 'picture-uri', 'file://{theme}']) 58 | 59 | @property 60 | def available(self) -> bool: 61 | return test_gnome_availability(self.command) 62 | 63 | 64 | class _Budgie(PluginCommandline): 65 | name = 'Wallpaper' 66 | 67 | def __init__(self): 68 | super().__init__(['gsettings', 'set', 'org.gnome.desktop.background', 'picture-uri', 'file://{theme}']) 69 | 70 | @property 71 | def available(self) -> bool: 72 | return test_gnome_availability(self.command) 73 | 74 | 75 | def check_theme(theme: str) -> bool: 76 | if not theme: 77 | return False 78 | file = Path(theme) 79 | if "#" in file.name: 80 | logger.error('Image files that contain a \'#\' will not work.') 81 | return False 82 | if not file.exists(): 83 | logger.error(f'Image {theme} does not exist!') 84 | return False 85 | 86 | return True 87 | 88 | 89 | class _Kde(DBusPlugin): 90 | name = 'Wallpaper' 91 | 92 | def __init__(self): 93 | super().__init__() 94 | self._theme_light = None 95 | self._theme_dark = None 96 | 97 | @property 98 | def theme_light(self) -> str: 99 | return self._theme_light 100 | 101 | @theme_light.setter 102 | def theme_light(self, value: str): 103 | check_theme(value) 104 | self._theme_light = value 105 | 106 | @property 107 | def theme_dark(self) -> str: 108 | return self._theme_dark 109 | 110 | @theme_dark.setter 111 | def theme_dark(self, value: str): 112 | check_theme(value) 113 | self._theme_dark = value 114 | 115 | @property 116 | def available(self) -> bool: 117 | return True 118 | 119 | def create_message(self, theme: str) -> QDBusMessage: 120 | message = QDBusMessage.createMethodCall( 121 | 'org.kde.plasmashell', 122 | '/PlasmaShell', 123 | 'org.kde.PlasmaShell', 124 | 'evaluateScript', 125 | ) 126 | message.setArguments([ 127 | 'string:' 128 | 'var Desktops = desktops();' 129 | 'for (let i = 0; i < Desktops.length; i++) {' 130 | ' let d = Desktops[i];' 131 | ' d.wallpaperPlugin = "org.kde.image";' 132 | ' d.currentConfigGroup = Array("Wallpaper", "org.kde.image", "General");' 133 | f' d.writeConfig("Image", "file:{theme}");' 134 | '}' 135 | ]) 136 | return message 137 | 138 | 139 | class _Xfce(PluginCommandline): 140 | def __init__(self): 141 | # first, get all monitors 142 | properties = str(helpers.check_output(['xfconf-query', '-c', 'xfce4-desktop', '-l'])) 143 | monitor = next(p for p in properties.split('\\n') if p.endswith('/workspace0/last-image')) 144 | 145 | super().__init__(['xfconf-query', '-c', 'xfce4-desktop', '-p', monitor, '-s', '{theme}']) 146 | 147 | 148 | class _Cinnamon(PluginCommandline): 149 | def __init__(self): 150 | super().__init__(['gsettings', 'set', 'org.cinnamon.desktop.background', 'picture-uri', '\"file://{theme}\"']) 151 | 152 | @property 153 | def available(self) -> bool: 154 | return test_gnome_availability(self.command) 155 | -------------------------------------------------------------------------------- /yin_yang/position.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import List 3 | 4 | import requests 5 | from PySide6.QtCore import QObject, Slot 6 | from PySide6.QtPositioning import ( 7 | QGeoPositionInfoSource, 8 | QGeoCoordinate, 9 | QGeoPositionInfo, 10 | ) 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class QTPositionReceiver(QObject): 16 | """Small handler for QT positioning service.""" 17 | 18 | def __init__(self): 19 | super().__init__() 20 | 21 | # Create the position service and hook it to us 22 | self._positionSource = QGeoPositionInfoSource.createSource( 23 | "geoclue2", {"desktopId": "Yin-Yang"}, self 24 | ) 25 | self._positionSource.positionUpdated.connect(self.handlePosition) 26 | 27 | # Start the position service. 28 | # This will only work after the app calls `exec()`. 29 | self._positionSource.startUpdates() 30 | 31 | # Get the initial last position. 32 | self._lastPosition = self._positionSource.lastKnownPosition() 33 | 34 | @Slot(QGeoPositionInfo) 35 | def handlePosition(self, position: QGeoPositionInfo): 36 | """Track the position provided by the service.""" 37 | self._lastPosition = position 38 | 39 | def lastKnownPosition(self): 40 | """Return the last known position, if valid.""" 41 | return self._lastPosition 42 | 43 | 44 | position_handler = QTPositionReceiver() 45 | 46 | 47 | def get_current_location() -> QGeoCoordinate: 48 | try: 49 | return get_qt_position() 50 | except TypeError as e: 51 | logger.warning(e) 52 | 53 | try: 54 | return get_ipinfo_position() 55 | except TypeError as e: 56 | logger.warning(e) 57 | 58 | raise TypeError("Unable to get current location") 59 | 60 | 61 | def get_qt_position() -> QGeoCoordinate: 62 | """Get the position via QT service""" 63 | # Fetch the last known position 64 | global position_handler 65 | pos: QGeoPositionInfo = position_handler.lastKnownPosition() 66 | 67 | coordinate = pos.coordinate() 68 | 69 | if not coordinate.isValid(): 70 | raise TypeError("Coordinates are not valid") 71 | 72 | return coordinate 73 | 74 | 75 | # there is a freedesktop portal for getting the location, 76 | # but it's not implemented by KDE, so I have no use for it 77 | 78 | 79 | def get_ipinfo_position() -> QGeoCoordinate: 80 | # use the old method as a fallback 81 | try: 82 | response = requests.get("https://www.ipinfo.io/loc") 83 | except Exception as e: 84 | logger.error(e) 85 | raise TypeError("Error while sending a request to get location") 86 | 87 | if not response.ok: 88 | raise TypeError("Failed to get location from ipinfo.io") 89 | 90 | loc_response = response.text.removesuffix("\n").split(",") 91 | loc: List[float] = [float(coordinate) for coordinate in loc_response] 92 | assert len(loc) == 2, "The returned location should have exactly 2 values." 93 | coordinate = QGeoCoordinate(loc[0], loc[1]) 94 | 95 | if not coordinate.isValid(): 96 | raise TypeError("Coordinates are not valid") 97 | 98 | return coordinate 99 | -------------------------------------------------------------------------------- /yin_yang/repeat_timer.py: -------------------------------------------------------------------------------- 1 | from threading import Timer 2 | 3 | 4 | class RepeatTimer(Timer): 5 | def run(self): 6 | while not self.finished.wait(self.interval): 7 | self.function(*self.args, **self.kwargs) 8 | -------------------------------------------------------------------------------- /yin_yang/theme_switcher.py: -------------------------------------------------------------------------------- 1 | """ 2 | title: yin_yang 3 | description: yin_yang provides an easy way to toggle between light and dark 4 | mode for your kde desktop. It also themes your vscode and 5 | all other qt application with it. 6 | author: oskarsh 7 | date: 21.12.2018 8 | license: MIT 9 | """ 10 | from datetime import datetime 11 | import logging 12 | import time 13 | from threading import Thread 14 | 15 | from .plugins.notify import Notification 16 | from .daemon_handler import update_times 17 | from .meta import PluginKey 18 | from .config import config, plugins 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | def should_be_dark(time_current: time, time_light: time, time_dark: time) -> bool: 24 | """Compares two times with current time""" 25 | 26 | if time_light < time_dark: 27 | return not (time_light <= time_current < time_dark) 28 | else: 29 | return time_dark <= time_current < time_light 30 | 31 | 32 | def set_mode(dark: bool, force=False): 33 | """Activates light or dark theme""" 34 | 35 | update_times() 36 | if not force and dark == config.dark_mode: 37 | return 38 | 39 | logger.info(f'Switching to {"dark" if dark else "light"} mode.') 40 | for p in plugins: 41 | if config.get_plugin_key(p.name, PluginKey.ENABLED): 42 | if force and isinstance(p, Notification): 43 | # skip sound and notify on apply settings 44 | continue 45 | try: 46 | logger.info(f'Changing theme in plugin {p.name}') 47 | p_thread = Thread(target=p.set_mode, args=[dark], name=p.name) 48 | p_thread.start() 49 | except Exception as e: 50 | logger.error('Error while changing theme in ' + p.name, exc_info=e) 51 | 52 | config.dark_mode = dark 53 | 54 | 55 | def set_desired_theme(force: bool = False): 56 | time_light, time_dark = config.times 57 | set_mode(should_be_dark( 58 | datetime.now().time(), 59 | time_light, 60 | time_dark 61 | ), force) 62 | -------------------------------------------------------------------------------- /yin_yang/ui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oskarsh/Yin-Yang/19feb5938cdf52a4cf2592ef5b4a081c4911feb8/yin_yang/ui/__init__.py -------------------------------------------------------------------------------- /yin_yang/ui/main_window_connector.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import cast 3 | 4 | from PySide6 import QtWidgets 5 | from PySide6.QtCore import QStandardPaths 6 | from PySide6.QtGui import QScreen, QColor 7 | from PySide6.QtWidgets import QFileDialog, QMessageBox, QDialogButtonBox, QColorDialog, QGroupBox 8 | 9 | from .main_window import Ui_main_window 10 | from ..theme_switcher import set_desired_theme, set_mode 11 | from ..meta import ConfigEvent, PluginKey 12 | from ..config import config, Modes, plugins, ConfigWatcher 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | class ConfigSaveNotifier(ConfigWatcher): 18 | def __init__(self): 19 | self.config_changed = False 20 | 21 | def notify(self, event: ConfigEvent, values: dict): 22 | match event: 23 | case ConfigEvent.CHANGE: 24 | self.config_changed = True 25 | logger.debug(values) 26 | case ConfigEvent.SAVE: 27 | self.config_changed = False 28 | 29 | 30 | def reverse_dict_search(dictionary, value): 31 | return next( 32 | key for key, value_dict in dictionary.items() 33 | if value_dict == value 34 | ) 35 | 36 | 37 | class MainWindow(QtWidgets.QMainWindow): 38 | def __init__(self, parent=None): 39 | super().__init__(parent) 40 | # basic setup 41 | self.setWindowTitle("Yin & Yang") 42 | self.ui = Ui_main_window() 43 | self.ui.setupUi(self) 44 | self._config_watcher = ConfigSaveNotifier() 45 | config.add_event_listener(ConfigEvent.CHANGE, self._config_watcher) 46 | config.add_event_listener(ConfigEvent.SAVE, self._config_watcher) 47 | 48 | # center the window 49 | frame_gm = self.frameGeometry() 50 | center_point = QScreen.availableGeometry(self.screen()).center() 51 | frame_gm.moveCenter(center_point) 52 | self.move(frame_gm.topLeft()) 53 | 54 | # set the config values to the elements 55 | self.load() 56 | 57 | # connects all buttons to the correct routes 58 | self.setup_config_sync() 59 | 60 | @property 61 | def config_changed(self) -> bool: 62 | return self._config_watcher.config_changed 63 | 64 | def load(self): 65 | """Sets the values from the config to the elements""" 66 | 67 | # set current version in statusbar 68 | self.ui.status_bar.showMessage(self.tr('You are using version {}', '') 69 | .format(str(config.version))) 70 | 71 | # set the correct mode 72 | mode = config.mode 73 | self.ui.btn_enable.setChecked(mode != Modes.MANUAL) 74 | self.ui.manual_buttons.setVisible(mode == Modes.MANUAL) 75 | 76 | if mode == Modes.FOLLOW_SUN: 77 | self.ui.time.setVisible(False) 78 | self.ui.btn_sun.setChecked(True) 79 | else: 80 | # fix that both settings for follow sun and scheduled showing up, when changing enabled 81 | self.ui.btn_schedule.setChecked(True) 82 | self.ui.location.setVisible(False) 83 | 84 | self.ui.toggle_notification.setChecked(config.get_plugin_key('notification', PluginKey.ENABLED)) 85 | self.ui.bootOffset.setValue(config.boot_offset) 86 | 87 | # sets the correct time based on config 88 | self.load_times() 89 | self.load_location() 90 | self.load_plugins() 91 | 92 | def load_times(self): 93 | """Loads the time from the config and sets it to the ui elements""" 94 | time_light, time_dark = config.times 95 | 96 | # giving the time widget the values of the config 97 | self.ui.inp_time_light.setTime(time_light) 98 | self.ui.inp_time_dark.setTime(time_dark) 99 | self.update_label_enabled() 100 | 101 | def load_location(self): 102 | if self.ui.btn_sun.isChecked(): 103 | config.mode = Modes.FOLLOW_SUN 104 | self.ui.btn_location.setChecked(config.update_location) 105 | self.ui.location_input.setDisabled(config.update_location) 106 | # set correct coordinates 107 | coordinates = config.location 108 | self.ui.inp_latitude.setValue(coordinates[0]) 109 | self.ui.inp_longitude.setValue(coordinates[1]) 110 | 111 | def load_plugins(self): 112 | # First, remove sample plugin 113 | sample_plugin = cast(QGroupBox, self.ui.plugins_scroll_content.findChild(QGroupBox, 'samplePluginGroupBox')) 114 | sample_plugin.hide() 115 | 116 | widget: QGroupBox 117 | for plugin in plugins: 118 | # filter out plugins for application 119 | if plugin.name.casefold() == 'notification': 120 | continue 121 | 122 | widget = cast(QGroupBox, self.ui.plugins_scroll_content.findChild(QGroupBox, 'group' + plugin.name)) 123 | if widget is None: 124 | widget = plugin.get_widget(self.ui.plugins_scroll_content) 125 | self.ui.plugins_scroll_content_layout.addWidget(widget) 126 | 127 | assert widget is not None, f'No widget for plugin {plugin.name} found' 128 | 129 | widget.toggled.connect( 130 | lambda enabled, p=plugin: 131 | config.update_plugin_key(p.name, PluginKey.ENABLED, enabled)) 132 | 133 | if plugin.available_themes: 134 | # uses combobox instead of line edit 135 | for child in widget.findChildren(QtWidgets.QComboBox): 136 | is_dark: bool = widget.findChildren(QtWidgets.QComboBox).index(child) == 1 137 | child.currentTextChanged.connect( 138 | lambda text, p=plugin, dark=is_dark: config.update_plugin_key( 139 | p.name, 140 | PluginKey.THEME_DARK if dark else PluginKey.THEME_LIGHT, 141 | reverse_dict_search(p.available_themes, text))) 142 | else: 143 | children: [QtWidgets.QLineEdit] = widget.findChildren(QtWidgets.QLineEdit) 144 | children[0].textChanged.connect( 145 | lambda text, p=plugin: config.update_plugin_key(p.name, PluginKey.THEME_LIGHT, text)) 146 | children[1].textChanged.connect( 147 | lambda text, p=plugin: config.update_plugin_key(p.name, PluginKey.THEME_DARK, text)) 148 | 149 | if plugin.name == 'Wallpaper': 150 | children: [QtWidgets.QPushButton] = widget.findChildren(QtWidgets.QDialogButtonBox) 151 | children[0].clicked.connect(lambda: self.select_wallpaper(False)) 152 | children[1].clicked.connect(lambda: self.select_wallpaper(True)) 153 | elif plugin.name == 'Brave': 154 | buttons: [QtWidgets.QPushButton] = widget.findChildren(QtWidgets.QPushButton) 155 | # this could be a loop, but it didn't work somehow 156 | color_str_0 = config.get_plugin_key(plugin.name, PluginKey.THEME_LIGHT) 157 | color_0 = QColor(color_str_0) 158 | buttons[0].clicked.connect(lambda: self.select_color(False, color_0)) 159 | 160 | color_str_1 = config.get_plugin_key(plugin.name, PluginKey.THEME_DARK) 161 | color_1 = QColor(color_str_1) 162 | buttons[1].clicked.connect(lambda: self.select_color(True, color_1)) 163 | plugin = None 164 | 165 | def update_label_enabled(self): 166 | time_light = self.ui.inp_time_light.time().toPython() 167 | time_dark = self.ui.inp_time_dark.time().toPython() 168 | self.ui.label_active.setText( 169 | self.tr('Dark mode will be active between {} and {}.') 170 | .format(time_dark.strftime("%H:%M"), time_light.strftime("%H:%M"))) 171 | 172 | def setup_config_sync(self): 173 | # set sunrise and sunset times if mode is set to followSun or coordinates changed 174 | self.ui.btn_enable.toggled.connect(self.save_mode) 175 | self.ui.btn_schedule.toggled.connect(self.save_mode) 176 | self.ui.btn_sun.toggled.connect(self.save_mode) 177 | 178 | # buttons and inputs 179 | self.ui.btn_location.stateChanged.connect(self.update_location) 180 | self.ui.inp_latitude.valueChanged.connect(self.update_location) 181 | self.ui.inp_longitude.valueChanged.connect(self.update_location) 182 | self.ui.inp_time_light.timeChanged.connect(self.update_times) 183 | self.ui.inp_time_dark.timeChanged.connect(self.update_times) 184 | 185 | # connect dialog buttons 186 | self.ui.btn_box.clicked.connect(self.save_config_to_file) 187 | 188 | self.ui.toggle_notification.toggled.connect( 189 | lambda enabled: config.update_plugin_key('notification', PluginKey.ENABLED, enabled)) 190 | 191 | self.ui.bootOffset.valueChanged.connect(self.update_boot_offset) 192 | 193 | # connect manual theme buttons 194 | self.ui.button_light.clicked.connect(lambda: set_mode(False)) 195 | self.ui.button_dark.clicked.connect(lambda: set_mode(True)) 196 | 197 | @staticmethod 198 | def update_boot_offset(value: int): 199 | config.boot_offset = value 200 | 201 | def save_mode(self): 202 | if not self.ui.btn_enable.isChecked(): 203 | config.mode = Modes.MANUAL 204 | elif self.ui.btn_schedule.isChecked(): 205 | config.mode = Modes.SCHEDULED 206 | elif self.ui.btn_sun.isChecked(): 207 | config.mode = Modes.FOLLOW_SUN 208 | 209 | self.load_times() 210 | 211 | def update_times(self): 212 | if config.mode != Modes.SCHEDULED: 213 | return 214 | 215 | # update config if time has changed 216 | time_light = self.ui.inp_time_light.time().toPython() 217 | time_dark = self.ui.inp_time_dark.time().toPython() 218 | config.times = time_light, time_dark 219 | 220 | self.update_label_enabled() 221 | 222 | def update_location(self): 223 | if config.mode != Modes.FOLLOW_SUN: 224 | return 225 | 226 | old_value = config.update_location 227 | config.update_location = self.ui.btn_location.isChecked() 228 | if config.update_location != old_value: 229 | self.load_location() 230 | elif not config.update_location: 231 | self.ui.location_input.setEnabled(True) 232 | 233 | coordinates = [ 234 | self.ui.inp_latitude.value(), 235 | self.ui.inp_longitude.value() 236 | ] 237 | config.location = coordinates 238 | # update message and times 239 | self.load_times() 240 | 241 | def select_wallpaper(self, dark: bool): 242 | message_light = self.tr('Open light wallpaper') 243 | message_dark = self.tr('Open dark wallpaper') 244 | file_name, _ = QFileDialog.getOpenFileName( 245 | self, message_dark if dark else message_light, 246 | QStandardPaths.standardLocations(QStandardPaths.PicturesLocation)[0], 247 | 'Images (*.png *.jpg *.jpeg *.JPG *.JPEG)') 248 | 249 | group_wallpaper = self.ui.plugins_scroll_content.findChild(QtWidgets.QGroupBox, 'groupWallpaper') 250 | inputs_wallpaper = group_wallpaper.findChildren(QtWidgets.QLineEdit) 251 | i = 1 if dark else 0 252 | inputs_wallpaper[i].setText(file_name) 253 | 254 | def select_color(self, dark: bool, initial_color: QColor): 255 | selected_color = QColorDialog.getColor(initial_color) 256 | group_brave = self.ui.plugins_scroll_content.findChild(QtWidgets.QGroupBox, 'groupBrave') 257 | inputs_brave = group_brave.findChildren(QtWidgets.QLineEdit) 258 | i = 1 if dark else 0 259 | inputs_brave[i].setText(selected_color.name()) 260 | inputs_brave[i].setStyleSheet(f'background-color: {selected_color.name()};' 261 | f' color: {"white" if selected_color.lightness() <= 128 else "black"}') 262 | 263 | def save_config_to_file(self, button): 264 | """Saves the config to the file or restores values""" 265 | 266 | match button: 267 | case QDialogButtonBox.Apply: 268 | success = config.save() 269 | set_desired_theme(True) 270 | return success 271 | case QDialogButtonBox.RestoreDefaults: 272 | config.reset() 273 | self.load() 274 | case QDialogButtonBox.Cancel: 275 | self.close() 276 | case QDialogButtonBox.NoButton: 277 | raise ValueError(f'Unknown button {button}') 278 | case _: 279 | button = QDialogButtonBox.standardButton(self.ui.btn_box, button) 280 | return self.save_config_to_file(button) 281 | 282 | def should_close(self) -> bool: 283 | """Returns true if the user wants to close the application""" 284 | 285 | # ask the user if he wants to save changes 286 | if self.config_changed: 287 | message = self.tr('The settings have been modified. Do you want to save them?') 288 | ret = QMessageBox.warning(self, self.tr('Unsaved changes'), 289 | message, 290 | QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel) 291 | match ret: 292 | case QMessageBox.Save: 293 | # emulate click on apply-button 294 | return self.save_config_to_file(QDialogButtonBox.Apply) 295 | case QMessageBox.Discard: 296 | return True 297 | case QMessageBox.Cancel: 298 | return False 299 | case _: 300 | logger.warning('Unexpected return value from warning dialog.') 301 | return False 302 | return True 303 | 304 | def closeEvent(self, event): 305 | """Overwrite the function that gets called when window is closed""" 306 | 307 | if self.should_close(): 308 | event.accept() 309 | else: 310 | event.ignore() 311 | --------------------------------------------------------------------------------