├── .flake8 ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── codecov.yml ├── pyproject.toml ├── requirements.dev.txt ├── requirements.txt ├── samples ├── custom_build.py ├── pngs │ ├── pointer.png │ ├── wait-001.png │ ├── wait-002.png │ ├── wait-003.png │ ├── wait-004.png │ ├── wait-005.png │ ├── wait-006.png │ ├── wait-007.png │ ├── wait-008.png │ ├── wait-009.png │ ├── wait-010.png │ ├── wait-011.png │ ├── wait-012.png │ ├── wait-013.png │ ├── wait-014.png │ ├── wait-015.png │ ├── wait-016.png │ ├── wait-017.png │ ├── wait-018.png │ ├── wait-019.png │ ├── wait-020.png │ ├── wait-021.png │ ├── wait-022.png │ ├── wait-023.png │ ├── wait-024.png │ ├── wait-025.png │ ├── wait-026.png │ ├── wait-027.png │ ├── wait-028.png │ ├── wait-029.png │ ├── wait-030.png │ ├── wait-031.png │ ├── wait-032.png │ ├── wait-033.png │ ├── wait-034.png │ ├── wait-035.png │ ├── wait-036.png │ ├── wait-037.png │ ├── wait-038.png │ ├── wait-039.png │ ├── wait-040.png │ ├── wait-041.png │ ├── wait-042.png │ ├── wait-043.png │ ├── wait-044.png │ ├── wait-045.png │ └── wait-046.png ├── sample.json ├── sample.toml ├── sample.yaml └── test.toml ├── setup.cfg ├── setup.py ├── src └── clickgen │ ├── __init__.py │ ├── __init__.pyi │ ├── configparser.py │ ├── configparser.pyi │ ├── cursors.py │ ├── cursors.pyi │ ├── libs │ ├── __init__.py │ ├── __init__.pyi │ ├── colors.py │ └── colors.pyi │ ├── packer │ ├── __init__.py │ ├── __init__.pyi │ ├── windows.py │ ├── windows.pyi │ ├── x11.py │ └── x11.pyi │ ├── parser │ ├── __init__.py │ ├── __init__.pyi │ ├── base.py │ ├── base.pyi │ ├── png.py │ └── png.pyi │ ├── py.typed │ ├── scripts │ ├── __init__.py │ ├── __init__.pyi │ ├── clickgen.py │ ├── clickgen.pyi │ ├── ctgen.py │ └── ctgen.pyi │ └── writer │ ├── __init__.py │ ├── __init__.pyi │ ├── windows.py │ ├── windows.pyi │ ├── x11.py │ └── x11.pyi ├── tests ├── conftest.py ├── packer │ ├── test_windows_packer.py │ └── test_x11_packer.py ├── parser │ ├── test_base_parser.py │ └── test_png_parser.py ├── scripts │ ├── test_clickgen_script.py │ └── test_ctgen_script.py ├── test_configparser.py ├── test_cursors.py ├── test_parser.py └── writer │ ├── test_windows_writer.py │ └── test_x11_writer.py └── tox.ini /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E501 3 | max-line-length = 120 4 | exclude = tests/* 5 | max-complexity = 10 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ful1e5 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "10:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - README.md 7 | - LICENSE 8 | 9 | pull_request: 10 | paths-ignore: 11 | - README.md 12 | - LICENSE 13 | branches: 14 | - main 15 | 16 | jobs: 17 | pytest: 18 | runs-on: ${{ matrix.os }} 19 | strategy: 20 | matrix: 21 | os: [ubuntu-latest, windows-latest] 22 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] 23 | include: 24 | - os: ubuntu-latest 25 | path: ~/.cache/pip 26 | - os: windows-latest 27 | path: ~\AppData\Local\pip\Cache 28 | 29 | steps: 30 | - uses: actions/checkout@v4 31 | - name: Set up Python ${{ matrix.python-version }} 32 | uses: actions/setup-python@v5 33 | with: 34 | python-version: ${{ matrix.python-version }} 35 | 36 | - name: Caching pip packages 37 | uses: actions/cache@v4 38 | with: 39 | path: ${{ matrix.path }} 40 | key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }} 41 | restore-keys: ${{ runner.os }}-pip- 42 | 43 | - name: Install pip dependencies 44 | run: | 45 | python -m pip install --upgrade pip 46 | python -m pip install -r requirements.txt 47 | python -m pip install tox-gh-actions 48 | continue-on-error: false 49 | 50 | - name: Test with tox 51 | run: tox 52 | continue-on-error: false 53 | 54 | build: 55 | needs: pytest 56 | runs-on: ubuntu-latest 57 | steps: 58 | - uses: actions/checkout@v4 59 | - name: Set up Python "3.x" 60 | uses: actions/setup-python@v5 61 | with: 62 | python-version: "3.x" 63 | 64 | - name: Caching pip packages 65 | uses: actions/cache@v4 66 | id: pip-cache # use this to check for `cache-hit` (`steps.pip-cache.outputs.cache-hit != 'true'`) 67 | with: 68 | path: ~/.cache/pip 69 | key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }} 70 | restore-keys: | 71 | ${{ runner.os }}-pip- 72 | 73 | - name: Install pip dependencies 74 | run: | 75 | python -m pip install --upgrade pip 76 | python -m pip install -r requirements.txt 77 | python -m pip install setuptools build 78 | python -m pip install pytest pytest-cov 79 | continue-on-error: false 80 | 81 | - name: Building clickgen 82 | run: python -m build 83 | continue-on-error: false 84 | 85 | - uses: actions/upload-artifact@master 86 | if: success() 87 | with: 88 | name: dist 89 | path: dist 90 | 91 | - name: Generating 'coverage.xml' 92 | run: | 93 | python -m pip install dist/clickgen-*.tar.gz 94 | python -m pytest --cov-report=xml --cov=src/clickgen 95 | continue-on-error: false 96 | 97 | - name: Upload coverage to Codecov 98 | uses: codecov/codecov-action@v4 99 | with: 100 | fail_ci_if_error: true 101 | token: ${{ secrets.CODECOV_TOKEN }} 102 | verbose: true 103 | 104 | - uses: actions/upload-artifact@master 105 | if: success() 106 | with: 107 | name: coverage 108 | path: coverage.xml 109 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | release: 5 | types: [released, prereleased] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Set up Python 14 | uses: actions/setup-python@v5 15 | with: 16 | python-version: "3.x" 17 | 18 | - name: Caching pip packages 19 | uses: actions/cache@v3 20 | id: pip-cache # use this to check for `cache-hit` (`steps.pip-cache.outputs.cache-hit != 'true'`) 21 | with: 22 | path: ~/.cache/pip 23 | key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }} 24 | restore-keys: | 25 | ${{ runner.os }}-pip- 26 | 27 | - name: Installing pip dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | python -m pip install -r requirements.txt 31 | python -m pip install mypy flake8 pytest 32 | python -m pip install setuptools build twine 33 | continue-on-error: false 34 | 35 | - name: Linting 36 | run: | 37 | flake8 src 38 | flake8 tests 39 | 40 | - name: Checking static types 41 | run: mypy src 42 | 43 | - name: Generating clickgen `stubfile` 44 | run: make stubgen 45 | 46 | - name: Building 47 | run: python -m build 48 | continue-on-error: false 49 | 50 | - uses: actions/upload-artifact@master 51 | if: success() 52 | with: 53 | name: dist 54 | path: dist 55 | 56 | - name: Testing 57 | run: | 58 | python -m pip install dist/clickgen-*.tar.gz 59 | python -m pytest 60 | continue-on-error: false 61 | 62 | - name: Publishing to PyPI 63 | env: 64 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 65 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 66 | run: | 67 | twine --version 68 | twine upload dist/* 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Custom 2 | env 3 | themes 4 | .vscode 5 | .vim 6 | samples/out 7 | 8 | # Byte-compiled / optimized / DLL files 9 | __pycache__/ 10 | *.py[cod] 11 | *$py.class 12 | 13 | # C extensions 14 | *.so 15 | *.o 16 | *.out 17 | 18 | # Distribution / packaging 19 | .Python 20 | build/ 21 | develop-eggs/ 22 | dist/ 23 | downloads/ 24 | eggs/ 25 | .eggs/ 26 | lib/ 27 | lib64/ 28 | parts/ 29 | sdist/ 30 | var/ 31 | wheels/ 32 | pip-wheel-metadata/ 33 | share/python-wheels/ 34 | *.egg-info/ 35 | .installed.cfg 36 | *.egg 37 | MANIFEST 38 | 39 | # PyInstaller 40 | # Usually these files are written by a python script from a template 41 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 42 | *.manifest 43 | *.spec 44 | 45 | # Installer logs 46 | pip-log.txt 47 | pip-delete-this-directory.txt 48 | 49 | # Unit test / coverage reports 50 | htmlcov/ 51 | .tox/ 52 | .nox/ 53 | .coverage 54 | .coverage.* 55 | .cache 56 | nosetests.xml 57 | coverage.xml 58 | *.cover 59 | *.py,cover 60 | .hypothesis/ 61 | .pytest_cache/ 62 | 63 | # Translations 64 | *.mo 65 | *.pot 66 | 67 | # Django stuff: 68 | *.log 69 | local_settings.py 70 | db.sqlite3 71 | db.sqlite3-journal 72 | 73 | # Flask stuff: 74 | instance/ 75 | .webassets-cache 76 | 77 | # Scrapy stuff: 78 | .scrapy 79 | 80 | # Sphinx documentation 81 | docs/_build/ 82 | 83 | # PyBuilder 84 | target/ 85 | 86 | # Jupyter Notebook 87 | .ipynb_checkpoints 88 | 89 | # IPython 90 | profile_default/ 91 | ipython_config.py 92 | 93 | # pyenv 94 | .python-version 95 | 96 | # pipenv 97 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 98 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 99 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 100 | # install all needed dependencies. 101 | #Pipfile.lock 102 | 103 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 104 | __pypackages__/ 105 | 106 | # Celery stuff 107 | celerybeat-schedule 108 | celerybeat.pid 109 | 110 | # SageMath parsed files 111 | *.sage.py 112 | 113 | # Environments 114 | .env 115 | .venv 116 | env/ 117 | venv/ 118 | ENV/ 119 | env.bak/ 120 | venv.bak/ 121 | 122 | # Spyder project settings 123 | .spyderproject 124 | .spyproject 125 | 126 | # Rope project settings 127 | .ropeproject 128 | 129 | # mkdocs documentation 130 | /site 131 | 132 | # mypy 133 | .mypy_cache/ 134 | .dmypy.json 135 | dmypy.json 136 | 137 | # Pyre type checker 138 | .pyre/ 139 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [unreleased] 9 | 10 | ## [v2.2.5] - 09 June 2024 11 | 12 | ### Issue Fixes 13 | 14 | - Fixed hotspot calculation while re-canvasing 15 | 16 | ## [v2.2.4] - 05 June 2024 17 | 18 | ### What's New? 19 | 20 | - Show detailed error for missing bitmaps in `ctgen` cursors config parsing 21 | 22 | ## [v2.2.3] - 25 May 2024 23 | 24 | ### What's New? 25 | 26 | - Clickgen now allows cursor bitmap re-canvasing by specifying size using the `cursor_size:canvas_size` format. See [changelog-05212024](https://github.com/ful1e5/clickgen/discussions/59#discussioncomment-9511166) 27 | 28 | ## [v2.2.2] - 24 April 2024 29 | 30 | ### Important Changes 31 | 32 | - Re-canvas all windows sizes to prevent blurry windows cursors f81ae4d2f4d63913f4ed60cdba351f74c5d9668b 33 | 34 | ### What's New? 35 | 36 | - Support python 3.12 37 | 38 | ### Changes 39 | 40 | - ci: exclude older python version(>3.10) on macOS runner 41 | 42 | ## [v2.2.1] - 02 March 2024 43 | 44 | ### Important Changes 45 | 46 | - aa9ea8a1102026d656c06ad28a40e9d781d9e124 Remove canvasing from lower resolution (>32px) in Windows Cursor 47 | 48 | ### Changes 49 | 50 | - ci: updated to `nodejs-20` in Github Actions 51 | 52 | ### Issue Fixes 53 | 54 | - Fixed python string template in Windows uninstallation script, reported on: https://github.com/ful1e5/clickgen/commit/4fbf21b1d04755c9a6bd2b77b5b69f9ad1c1b56b. 55 | 56 | ## [v2.2.0] - 21 December 2023 57 | 58 | ### Issues Fixes 59 | 60 | - Windows `install.inf` cursor registry in wrong order Fixed (more info https://github.com/ful1e5/Bibata_Cursor/issues/154) 61 | 62 | ## [v2.1.9] - 23 September 2023 63 | 64 | ### Issues Fixes 65 | 66 | - typo: Fixed typo in windows packaging registry `AppStarteng` -> `AppStarting` 67 | 68 | ## [v2.1.8] - 14 September 2023 69 | 70 | ### Changes 71 | 72 | - chore: `x11_name` is optional in cursor configs 73 | 74 | ### Issues Fixes 75 | 76 | - typo: Fixed typo in windows packaging registry `Unavailiable` -> `Unavailable` 77 | 78 | ## [v2.1.7] - 13 September 2023 79 | 80 | ### What's New? 81 | 82 | - Generate `install.inf` based on directory's cursor files dynamically. 83 | (Related to ful1e5/Bibata_Cursor#124, ful1e5/Bibata_Cursor#133) 84 | 85 | ### Changes 86 | 87 | - dev: include `clickgen.*` modules with `find` method in package 88 | 89 | ### Issues Fixes 90 | 91 | - Parse config files as `Path` in `configparser` module (Fixes ful1e5/Bibata_Cursor#143) 92 | 93 | ## [v2.1.6] - 01 September 2023 94 | 95 | ### Issues Fixes 96 | 97 | - Included `clickgen.libs` directory in distributing package #60 98 | 99 | ## [v2.1.5] - 31 August 2023 100 | 101 | ### Changes 102 | 103 | - ci: Use actions/checkout@v3 104 | - ci: Use `ubuntu-latest` in Linux Runner 105 | - ci: Use distributed local package from `dist/` directory for performing tests 106 | - ci: `test` operation renamed to `pytest` 107 | 108 | ### Issues Fixes 109 | 110 | - XCursor symlink generated in wrong directory bug fixed 111 | - Short parameter for `-v` added for `ctgen --version` 112 | 113 | ## [v2.1.4] - 29 August 2023 114 | 115 | ### Deprecation 116 | 117 | - In the `ctgen` configuration file, the `[config]` section no longer supports the `win_size` and `x11_sizes` options. Check [changelog-08172023](https://github.com/ful1e5/clickgen/discussions/59#discussioncomment-6747666) 118 | 119 | ### What's New? 120 | 121 | - Prettier Logs in `ctgen` CLI. 122 | - Change size of individual cursor assigning `win_sizes` and `x11_sizes` to individual cursor config in `ctgen` CLI 123 | - Support `.yaml` and `.json` manifest config files for `ctgen` 124 | - Support for `Python 3.11` has been added, along with test suites for it. 125 | 126 | ### Changes 127 | 128 | - Added 'attrs>=15.0.0' dependency for safely import `dataclass` class 129 | - Using `[build](https://pypa-build.readthedocs.io/en/stable/index.html)` instead of `wheel` for building pypi distributing packages 130 | - Updated ubuntu version in CI 131 | 132 | ## [v2.1.3] - 10 October 2022 133 | 134 | ### Changed 135 | 136 | - Fix blurry Windows Cursors ful1e5/Bibata_Cursor#119 137 | 138 | ## [v2.1.2] - 06 October 2022 139 | 140 | ### Changed 141 | 142 | - Fix distortion transparency in XCursors exports ful1e5/Bibata_Cursor#118 143 | 144 | ## [v2.1.1] - 30 August 2022 145 | 146 | ### Changed 147 | 148 | - Fix size argument type with **windows** platform in `ctgen` script 149 | 150 | ## [v2.1.0] - 19 August 2022 151 | 152 | ### Changed 153 | 154 | - Fixed sub modules error in `clickgen.*` 155 | 156 | ## [v2.0.0] - 16 August 2022 157 | 158 | > **Warning** 159 | > I removed all functionalities and modules from older versions in `v2.0.0`. 160 | 161 | > **Warning** 162 | > Docker Image support deprecated due to cross-platform compatibility. 163 | 164 | ### Added 165 | 166 | - Building logs added in `ctgen` 167 | - add: python 3.7 support 168 | - add: **Windows** and **macOS** support fixed #24 169 | - init: `cursor`, `configparser`, `packer`, `parser` and `writer` module 170 | - 'Twitter' and 'Download' links added on PYPI page 171 | - Added cursor generator cli: `clickgen -h` 172 | - Added cursor theme generator cli: `ctgen -h` (supports config file) 173 | - Uninstall script added in Windows cursors theme. 174 | 175 | ### Changed 176 | 177 | - `KeyNotFound` Exception fixed while reading cursor configuration in `configparser` module 178 | - ctgen (cli): fixed platform assignment type in '-p/--platform' argument 179 | - windows-writer: fixed slow animation in `.ani` cursors (60jifs(1000ms) -> 2 jifs(33ms)) 180 | - chore: updated template variables inside `packer.windows` 181 | - make: install all dependencies with `make install_deps` command 182 | - chore: directory renamed `examples` -> `samples` 183 | 184 | ## [v2.0.0-beta.2] - 09 July 2022 185 | 186 | ## [v2.0.0-beta.1] - 27 June 2022 187 | 188 | ## [v1.2.0] - 26 March 2022 189 | 190 | ### Added 191 | 192 | - python 3.10 support 193 | - `Makefile` at the project root added for development operations command 194 | - Generate `stubfiles` from `make stubgen` command 195 | - `make clean` command for cleaning clickgen cache 196 | - `make dev` command for development purpose 197 | - `make docs_gen` command for generating docs 198 | - Build `xcursorgen` with extra flags 199 | - `xcursorgen.c` formatted with tool **[indent](https://www.gnu.org/software/indent/)** 200 | - Linting & typing fixes inside `clickgen.builders` 201 | - `Linting`, `pip package caching`, and `stubgen` commands inside [workflows/app-publish.yml](./.github/workflows/app-publish.yml) 202 | - `ConfigFrame` typing added inside `WindowsCursor` class 203 | - `clickgen.builders` module docs init 204 | - docstring `param type` and `rtype` typing with **"or"** inside `Optional` and `Union` 205 | - `WindowsCursor` docstring init 206 | - `tests` module docstring init 207 | - use built-in typing inside `clickgen.*` 208 | - `from_bitmap` classmethod init inside `XCursor` class 209 | - `from_bitmap` classmethod init inside `WindowsCursor` class 210 | - GitHub Sponsorships added 211 | - feat: uninstall script added in `WindowsPackager` ful1e5/apple_cursor#79 212 | - feat: run `pip install` command according to make target (use for dev env setup) 213 | - chore: moved `package_data` config to `setup.cfg` 214 | - chore: removed `resample` parameter from `Bitmap.resize()` 215 | 216 | ### Changed 217 | 218 | - clean `xcursorgen` build cache automatically on `make` command 219 | - `CI` pip caching system key changed to `setup.py` 220 | - Proper typing inheritation inside `clickgen/core.pyi` 221 | - Linting & Typing fixed in `XCursor` Class `clickgen/builder.py` 222 | - `xcursorgen/makefile` renamed to `xcursorgen/Makefile` 223 | - WindowsCursor support `options` instead of `args` 224 | - clickgen pip dependencies _installation_ method changed inside [workflows/app-ci.yml](./.github/workflows/app-ci.yml) 225 | - Only `python3` syntax (removed `(object) inheritation`) 226 | - `clickgen.utils.timer` & `clickgen.utils.debug` removed 227 | - formatting inside `CHANGELOG.md` 228 | - CI: run ci on every branch push 229 | - refactor: init `setup.cfg` 230 | - lsp warning fixed in `tests` module 231 | - removed emoji from `README.md` 232 | - chore: compact `Makefile` with variables 233 | - coverage: assign default value of `data` parameter in `clickgen/util.py` 234 | - fix: updated donation link and fixed type warning in `setup.py` 235 | - refactor: source moved to `src/*` directory 236 | - chore: tox init 237 | - make-stubgen: generate type interface(.pyi) files without `MODULES` variable 238 | - refactor: `scripts` -> `src/clickgen/scripts` 239 | 240 | ## [v1.1.9] - 22 March 2021 241 | 242 | ### Added 243 | 244 | - Couple of **linting** problem fixes 245 | - **Bitmap** and **CursorsAlias** member access outside the context manager 246 | - Check `make` command in `setup.py` 247 | - Better typing experience 248 | - Configure readthedocs with `sphinx` 249 | - Added **docs** badge in `README.md` 250 | 251 | ### Changed 252 | 253 | - Fixed Pillow vulnerabilities by bumped it to `8.1.1` 254 | - python caching updated in `app-ci.yml` 255 | - `Literal` typing removed from `clickgen.util` & `clickgen.core` 256 | - Fixed #23 packaging issue of `XPackager` 257 | - Fixed #22 Inside `util.PNGProvider` 258 | 259 | ## [v1.1.8] - 24 January 2021 260 | 261 | ### Added 262 | 263 | - Code Coverage ~100% 264 | - The new CLI 265 | - New `XCursor` & `Windows Cursor` building approach 266 | - python `.pyi` static type file (stub file) init 267 | - X11 & Windows themes _packaging_ compatibility 268 | - **Semi-animated** theme supports for Windows & X11 269 | - `timer` & `debug` development utility init. 270 | 271 | ### Changed 272 | 273 | - Handle **Cursor config file** in `tmp` directory 274 | - Cursor's database in python `Dict` format 275 | - Vast changes in `clickgen` importing. 276 | - GitHub workflow with `matrix` 277 | - fixed #12 278 | 279 | ## [v1.1.7] - 5 October 2020 280 | 281 | ### Added 282 | 283 | - New Stable version **v1.1.7** 284 | - Archlinux/Manjaro installation docs 285 | - CLI usage in [README.md](./README.md) 286 | 287 | ### Changed 288 | 289 | - skip `Pillow` is already installed 290 | 291 | ## [v1.1.6] - 24 September 2020 292 | 293 | ### Changed 294 | 295 | - `vertical resize` wrong implementation fix (KDE Cursor) #13 296 | - Remove unnecessary cursors from `left_ptr` 297 | - Remove `./` from all **symbolic link** cursors 298 | - Untraced `pkginfo.in` file 299 | - Update `Pillow` to 7.2.0 300 | 301 | ### Added 302 | 303 | - clickgen **info** in [README.md](./README.md) 304 | 305 | ## [v1.1.5-beta] - 29 July 2020 306 | 307 | ### Changed 308 | 309 | - Typo fixed 310 | 311 | ## [1.1.4-beta] - 20 July 2020 312 | 313 | ### Added 314 | 315 | - **configsgen** - _a tool for automating cursor `configs` generation from images._ 316 | - **build function** - _a shortcut functions for build a `cursor theme`._ 317 | 318 | ### Changed 319 | 320 | - individual `logging` support 321 | - added more _logs_ 322 | - fixed _built-in_ **conflicts** 323 | - `import` packages manner changed 324 | 325 | ## [v1.1.3-alpha] - 24 June 2020 326 | 327 | - docker image **publishing workflow** fixed 328 | 329 | ## [v1.1.2-alpha] - 23 June 2020 330 | 331 | ### Added 332 | 333 | - Docker image available on **Github Docker Registry** 334 | - `clickgen CLI` added with the pip package 335 | 336 | ### Changed 337 | 338 | - Remove default command-line arguments in `win.py` aka **anicursorgen** 339 | - Exited with an error if `exception` occurred. 340 | - Empty cursor theme `archive` generation **fixed**. 341 | 342 | ## [v1.1.1-alpha] - 12 June 2020 343 | 344 | ### Changed 345 | 346 | - Windows cursors extension `null` to `.ani` or `.cur` in linker module. 347 | - Restructure **test** 348 | - Logo **Alignment fix** in `README.md` 349 | - CI Pipeline 350 | - GitHub workflow name changed 351 | - badges in `README.md` 352 | 353 | ## [v1.1.0-alpha] - 9 June 2020 354 | 355 | ### Added 356 | 357 | - Initial release 🎊 358 | - Logo and badges 359 | - CI/CD Pipelines 360 | - **auto-install** `pip requirements` 361 | - `xcursorgen.so` file included in the packaging 362 | - auto-generated **symlinks** based on input configs 363 | - `.tar` archive & `directory` as out **package**. 364 | 365 | [unreleased]: https://github.com/ful1e5/clickgen/compare/v2.2.5...main 366 | [v2.2.5]: https://github.com/ful1e5/clickgen/compare/v2.2.4...v2.2.5 367 | [v2.2.4]: https://github.com/ful1e5/clickgen/compare/v2.2.3...v2.2.4 368 | [v2.2.3]: https://github.com/ful1e5/clickgen/compare/v2.2.2...v2.2.3 369 | [v2.2.2]: https://github.com/ful1e5/clickgen/compare/v2.2.1...v2.2.2 370 | [v2.2.1]: https://github.com/ful1e5/clickgen/compare/v2.2.0...v2.2.1 371 | [v2.2.0]: https://github.com/ful1e5/clickgen/compare/v2.1.9...v2.2.0 372 | [v2.1.9]: https://github.com/ful1e5/clickgen/compare/v2.1.8...v2.1.9 373 | [v2.1.8]: https://github.com/ful1e5/clickgen/compare/v2.1.7...v2.1.8 374 | [v2.1.7]: https://github.com/ful1e5/clickgen/compare/v2.1.6...v2.1.7 375 | [v2.1.6]: https://github.com/ful1e5/clickgen/compare/v2.1.5...v2.1.6 376 | [v2.1.5]: https://github.com/ful1e5/clickgen/compare/v2.1.4...v2.1.5 377 | [v2.1.4]: https://github.com/ful1e5/clickgen/compare/v2.1.4...v2.1.3 378 | [v2.1.3]: https://github.com/ful1e5/clickgen/compare/v2.1.3...v2.1.2 379 | [v2.1.2]: https://github.com/ful1e5/clickgen/compare/v2.1.2...v2.1.1 380 | [v2.1.1]: https://github.com/ful1e5/clickgen/compare/v2.1.1...v2.1.0 381 | [v2.1.0]: https://github.com/ful1e5/clickgen/compare/v2.1.0...v2.0.0 382 | [v2.0.0]: https://github.com/ful1e5/clickgen/compare/v2.0.0...v2.0.0-beta.2 383 | [v2.0.0-beta.2]: https://github.com/ful1e5/clickgen/compare/v2.0.0-beta.2...v2.0.0-beta.1 384 | [v2.0.0-beta.1]: https://github.com/ful1e5/clickgen/compare/v2.0.0-beta.1...v1.1.9 385 | [v1.2.0]: https://github.com/ful1e5/clickgen/compare/v1.1.9...v1.2.0 386 | [v1.1.9]: https://github.com/ful1e5/clickgen/compare/v1.1.8...v1.1.9 387 | [v1.1.8]: https://github.com/ful1e5/clickgen/compare/v1.1.7...v1.1.8 388 | [v1.1.7]: https://github.com/ful1e5/clickgen/compare/1.1.6...v1.1.7 389 | [v1.1.6]: https://github.com/ful1e5/clickgen/compare/1.1.5-beta...1.1.6 390 | [v1.1.5-beta]: https://github.com/ful1e5/clickgen/compare/1.1.4-alpha...1.1.5-beta 391 | [v1.1.4-beta]: https://github.com/ful1e5/clickgen/compare/1.1.3-alpha...1.1.4-beta 392 | [v1.1.3-alpha]: https://github.com/ful1e5/clickgen/compare/1.1.2-alpha...1.1.3-alpha 393 | [v1.1.2-alpha]: https://github.com/ful1e5/clickgen/compare/1.1.1-alpha...1.1.2-alpha 394 | [v1.1.1-alpha]: https://github.com/ful1e5/clickgen/compare/1.1.0-alpha...1.1.1-alpha 395 | [v1.1.0-alpha]: https://github.com/ful1e5/clickgen/releases/tag/1.1.0-alpha 396 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 AbdulKaiz Khatri 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | py3 = python3 2 | 3 | clean: 4 | rm -rf .vscode .vim venv coverage.xml out samples/out 5 | rm -rf .tox build dist src/clickgen.egg-info .mypy_cache .pytest_cache .coverage htmlcov .python-version 6 | find . -name "__pycache__" -type d -exec /bin/rm -rf {} + 7 | 8 | install_deps: 9 | $(py3) -m pip install -r requirements.txt 10 | $(py3) -m pip install -r requirements.dev.txt 11 | 12 | install: 13 | $(py3) -m pip install -e . 14 | 15 | test: 16 | pytest 17 | 18 | coverage: 19 | pytest --cov=clickgen --cov-report=html 20 | 21 | build: clean 22 | $(py3) -m build 23 | 24 | stubgen: 25 | find src/clickgen/ -type f -name '*.pyi' -delete 26 | stubgen "src/clickgen" -o src 27 | 28 | tox: clean 29 | pyenv local 3.7.5 3.8.0 3.9.0 3.10.0 3.11.0 3.12.0 30 | tox 31 | pyenv local system 32 | 33 | dev: install_deps clean stubgen install coverage 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Clickgen 2 | 3 | [![ci](https://github.com/ful1e5/clickgen/actions/workflows/ci.yml/badge.svg)](https://github.com/ful1e5/clickgen/actions/workflows/ci.yml) 4 | [![code coverage](https://codecov.io/gh/ful1e5/clickgen/branch/main/graph/badge.svg)](https://codecov.io/gh/ful1e5/clickgen) 5 | 6 | Clickgen is cross-platform python library for building XCursor and Windows Cursors. 7 | 8 | Clickgen's core functionality is heavily inspired by [quantum5/win2xcur](https://github.com/quantum5/win2xcur) from _v2.0.0_ and onwards. 9 | 10 | ## Notices 11 | 12 | 14 | 15 | ![shoutout-sponsors](https://sponsor-spotlight.vercel.app/sponsor?login=ful1e5) 16 | 17 | > **Note** 18 | > The project's success depends on sponsorships. Meeting sponsorship goals for [ful1e5](https://github.com/ful1e5) GitHub Account will drive new releases and ongoing development. 19 | 20 | - **2024-05-24:** Clickgen now allows cursor bitmap re-canvasing by specifying size using the `cursor_size:canvas_size` format. See [changelog-05212024](https://github.com/ful1e5/clickgen/discussions/59#discussioncomment-9511166) 21 | - **2023-08-23:** `ctgen` CLI supports `.json` and `.yaml`/`.yml` as configuration file. 22 | - **2023-08-17:** Cursor size settings moved to `[cursors.fallback_settings]` in config. See [changelog-08172023](https://github.com/ful1e5/clickgen/discussions/59#discussioncomment-6747666) 23 | - **2022-06-15:** Docker Image support deprecated due to cross-platform compatibility. 24 | - **2022-07-09:** :warning: All the **functionality and modules are removed from older versions in `v2.0.0`**. 25 | I will be restricting any updates to the `>=v1.2.0` versions to security updates and hotfixes. 26 | Check updated documentations for [building cursors from API](#api-examples) and [CLIs](#usage) usage. 27 | 28 | ## Requirements 29 | 30 | - Python version 3.7.5 or higher 31 | - [Pillow](https://pypi.org/project/Pillow) >= 8.1.1 32 | - [PyYaml](https://pypi.org/project/PyYaml) >= 6.0.1 33 | - [attrs](https://pypi.org/project/attrs) >= 15.0.0 34 | - [numpy](https://pypi.org/project/numpy) >= 1.21.6 35 | - [toml](https://pypi.org/project/toml) >= 0.10.2 36 | 37 | ## Install 38 | 39 | ```bash 40 | pip3 install clickgen 41 | ``` 42 | 43 | > **Note** 44 | > Distributions' packages are not affiliated with clickgen developers. 45 | > If you encounter any issues with the incorrect installation, you should contact the package maintainer first. 46 | 47 | ### Arch Linux 48 | 49 | - [AUR](https://aur.archlinux.org/packages/python-clickgen) 50 | 51 | ## Usage 52 | 53 | ### `clickgen` CLI 54 | 55 | #### Linux Format (XCursor) 56 | 57 | For example, if you have to build [ponter.png](https://github.com/ful1e5/clickgen/blob/main/samples/pngs/pointer.png) 58 | file to Linux Format: 59 | 60 | ```bash 61 | clickgen samples/pngs/pointer.png -x 10 -y 10 -s 22 24 32 -p x11 62 | ``` 63 | 64 | You also **build animated Xcursor** by providing multiple png files to argument and animation delay with `-d`: 65 | 66 | ```bash 67 | clickgen samples/pngs/wait-001.png samples/pngs/wait-001.png -d 3 -x 10 -y 10 -s 22 24 32 -p x11 68 | ``` 69 | 70 | #### Windows Formats (.cur and .ani) 71 | 72 | To build [ponter.png](https://github.com/ful1e5/clickgen/blob/main/samples/pngs/pointer.png) 73 | file to Windows Format (`.cur`): 74 | 75 | > **Warning: Windows Animated Cursor only support single size.** 76 | 77 | ```bash 78 | clickgen samples/pngs/pointer.png -x 10 -y 10 -s 32 -p windows 79 | ``` 80 | 81 | You can also specify the size in the `size:canvas_size` format to enable canvasing: 82 | 83 | ```bash 84 | clickgen samples/pngs/pointer.png -x 10 -y 10 -s 20:32 -p windows 85 | ``` 86 | 87 | For **animated Windows Cursor** (`.ani`): 88 | 89 | ```bash 90 | clickgen samples/pngs/wait-001.png samples/pngs/wait-001.png -d 3 -x 10 -y 10 -s 32 -p windows 91 | ``` 92 | 93 | For more information, run `clickgen --help`. 94 | 95 | ### `ctgen` CLI 96 | 97 | This CLI allow you to generate Windows and Linux Cursor themes from config (.toml.yml,and .json) files. 98 | 99 | ```bash 100 | ctgen sample/sample.json 101 | ctgen sample/sample.toml 102 | ctgen sample/sample.yaml 103 | ``` 104 | 105 | You also provide multiple theme configuration file once as following: 106 | 107 | ```bash 108 | ctgen sample/sample.toml sample/sample.json 109 | ``` 110 | 111 | Override theme's `name` of theme with `-n` option: 112 | 113 | ```bash 114 | ctgen sample/sample.toml -n "New Theme" 115 | ``` 116 | 117 | You can run `ctgen --help` to view all available options and you also check 118 | [samples](https://github.com/ful1e5/clickgen/blob/main/samples) directory for more information. 119 | 120 | ### API Examples 121 | 122 | ### Static `XCursor` 123 | 124 | ```python 125 | from clickgen.parser import open_blob 126 | from clickgen.writer import to_x11 127 | 128 | with open("samples/pngs/pointer.png", "rb") as p: 129 | cur = open_blob([p.read()], hotspot=(50, 50)) 130 | 131 | # save X11 static cursor 132 | xresult = to_x11(cur.frames) 133 | with open("xtest", "wb") as o: 134 | o.write(xresult) 135 | ``` 136 | 137 | ### Animated `XCursor` 138 | 139 | ```python 140 | from glob import glob 141 | from typing import List 142 | 143 | from clickgen.parser import open_blob 144 | from clickgen.writer import to_x11 145 | 146 | # Get .png files from directory 147 | fnames = glob("samples/pngs/wait-*.png") 148 | pngs: List[bytes] = [] 149 | 150 | # Reading as bytes 151 | for f in sorted(fnames): 152 | with open(f, "rb") as p: 153 | pngs.append(p.read()) 154 | 155 | cur = open_blob(pngs, hotspot=(100, 100)) 156 | 157 | # save X11 animated cursor 158 | result = to_x11(cur.frames) 159 | with open("animated-xtest", "wb") as o: 160 | o.write(result) 161 | ``` 162 | 163 | ### Static `Windows Cursor` (.cur) 164 | 165 | ```python 166 | from clickgen.parser import open_blob 167 | from clickgen.writer import to_win 168 | 169 | with open("samples/pngs/pointer.png", "rb") as p: 170 | cur = open_blob([p.read()], hotspot=(50, 50)) 171 | 172 | # save Windows static cursor 173 | ext, result = to_win(cur.frames) 174 | with open(f"test{ext}", "wb") as o: 175 | o.write(result) 176 | ``` 177 | 178 | ### Animated `Windows Cursor` (.ani) 179 | 180 | ```python 181 | from glob import glob 182 | from typing import List 183 | 184 | from clickgen.parser import open_blob 185 | from clickgen.writer import to_win 186 | 187 | # Get .png files from directory 188 | fnames = glob("samples/pngs/wait-*.png") 189 | pngs: List[bytes] = [] 190 | 191 | # Reading as bytes 192 | for f in sorted(fnames): 193 | with open(f, "rb") as p: 194 | pngs.append(p.read()) 195 | 196 | cur = open_blob(pngs, hotspot=(100, 100)) 197 | 198 | # save Windows animated cursor 199 | ext, result = to_win(cur.frames) 200 | with open(f"test-ani{ext}", "wb") as o: 201 | o.write(result) 202 | ``` 203 | 204 | ### Documentation 205 | 206 | Check [wiki](https://github.com/ful1e5/clickgen/wiki) for documentation. 207 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - examples 3 | - setup.py 4 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 40.9.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.pytest.ini_options] 6 | addopts = "-vv --cache-clear" 7 | pythonpath = ["src"] 8 | testpaths = ["tests"] 9 | 10 | [tool.mypy] 11 | mypy_path = "src" 12 | check_untyped_defs = true 13 | disallow_any_generics = true 14 | ignore_missing_imports = true 15 | no_implicit_optional = true 16 | show_error_codes = true 17 | strict_equality = true 18 | warn_redundant_casts = true 19 | warn_return_any = true 20 | warn_unreachable = true 21 | warn_unused_configs = true 22 | no_implicit_reexport = true 23 | -------------------------------------------------------------------------------- /requirements.dev.txt: -------------------------------------------------------------------------------- 1 | flake8>=4.0.1 2 | mypy>=0.982 3 | pytest-cov>=3.0.0 4 | pytest>=7.1.2 5 | tox>=3.25.0 6 | build>=0.10.0 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Pillow>=8.1.1 2 | PyYaml>=6.0.1 3 | attrs>=15.0.0 4 | numpy>=1.21.6 5 | toml>=0.10.2 6 | -------------------------------------------------------------------------------- /samples/custom_build.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import sys 5 | import traceback 6 | from glob import glob 7 | from pathlib import Path 8 | from threading import Lock 9 | from typing import List 10 | 11 | from clickgen.parser import open_blob 12 | from clickgen.writer import to_win, to_x11 13 | 14 | if __name__ == "__main__": 15 | print_lock = Lock() 16 | 17 | # mkdir 'out' 18 | out = Path("out") 19 | out.mkdir(parents=True, exist_ok=True) 20 | 21 | fnames = glob("pngs/wait-*.png") 22 | pngs: List[bytes] = [] 23 | for f in sorted(fnames): 24 | with open(f, "rb") as p: 25 | pngs.append(p.read()) 26 | 27 | try: 28 | ani = open_blob(pngs, hotspot=(100, 100), sizes=["24:32", "32"]) 29 | 30 | # save Windows animated cursor 31 | aext, aresult = to_win(ani.frames) 32 | with open(out / f"test-ani{aext}", "wb") as o: 33 | o.write(aresult) 34 | 35 | # save X11 animated cursor 36 | axresult = to_x11(ani.frames) 37 | with open(out / "xtest-ani", "wb") as o: 38 | o.write(axresult) 39 | 40 | with open("pngs/pointer.png", "rb") as p: 41 | cur = open_blob([p.read()], hotspot=(50, 50)) 42 | 43 | # save Windows static cursor 44 | ext, result = to_win(cur.frames) 45 | with open(out / f"test{ext}", "wb") as o: 46 | o.write(result) 47 | 48 | # save X11 static cursor 49 | xresult = to_x11(cur.frames) 50 | with open(out / "xtest", "wb") as o: 51 | o.write(xresult) 52 | except Exception: 53 | with print_lock: 54 | print("Error occurred while processing ", file=sys.stderr) 55 | traceback.print_exc() 56 | 57 | print("Building ... DONE") 58 | -------------------------------------------------------------------------------- /samples/pngs/pointer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/pointer.png -------------------------------------------------------------------------------- /samples/pngs/wait-001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-001.png -------------------------------------------------------------------------------- /samples/pngs/wait-002.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-002.png -------------------------------------------------------------------------------- /samples/pngs/wait-003.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-003.png -------------------------------------------------------------------------------- /samples/pngs/wait-004.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-004.png -------------------------------------------------------------------------------- /samples/pngs/wait-005.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-005.png -------------------------------------------------------------------------------- /samples/pngs/wait-006.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-006.png -------------------------------------------------------------------------------- /samples/pngs/wait-007.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-007.png -------------------------------------------------------------------------------- /samples/pngs/wait-008.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-008.png -------------------------------------------------------------------------------- /samples/pngs/wait-009.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-009.png -------------------------------------------------------------------------------- /samples/pngs/wait-010.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-010.png -------------------------------------------------------------------------------- /samples/pngs/wait-011.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-011.png -------------------------------------------------------------------------------- /samples/pngs/wait-012.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-012.png -------------------------------------------------------------------------------- /samples/pngs/wait-013.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-013.png -------------------------------------------------------------------------------- /samples/pngs/wait-014.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-014.png -------------------------------------------------------------------------------- /samples/pngs/wait-015.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-015.png -------------------------------------------------------------------------------- /samples/pngs/wait-016.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-016.png -------------------------------------------------------------------------------- /samples/pngs/wait-017.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-017.png -------------------------------------------------------------------------------- /samples/pngs/wait-018.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-018.png -------------------------------------------------------------------------------- /samples/pngs/wait-019.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-019.png -------------------------------------------------------------------------------- /samples/pngs/wait-020.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-020.png -------------------------------------------------------------------------------- /samples/pngs/wait-021.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-021.png -------------------------------------------------------------------------------- /samples/pngs/wait-022.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-022.png -------------------------------------------------------------------------------- /samples/pngs/wait-023.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-023.png -------------------------------------------------------------------------------- /samples/pngs/wait-024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-024.png -------------------------------------------------------------------------------- /samples/pngs/wait-025.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-025.png -------------------------------------------------------------------------------- /samples/pngs/wait-026.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-026.png -------------------------------------------------------------------------------- /samples/pngs/wait-027.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-027.png -------------------------------------------------------------------------------- /samples/pngs/wait-028.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-028.png -------------------------------------------------------------------------------- /samples/pngs/wait-029.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-029.png -------------------------------------------------------------------------------- /samples/pngs/wait-030.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-030.png -------------------------------------------------------------------------------- /samples/pngs/wait-031.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-031.png -------------------------------------------------------------------------------- /samples/pngs/wait-032.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-032.png -------------------------------------------------------------------------------- /samples/pngs/wait-033.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-033.png -------------------------------------------------------------------------------- /samples/pngs/wait-034.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-034.png -------------------------------------------------------------------------------- /samples/pngs/wait-035.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-035.png -------------------------------------------------------------------------------- /samples/pngs/wait-036.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-036.png -------------------------------------------------------------------------------- /samples/pngs/wait-037.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-037.png -------------------------------------------------------------------------------- /samples/pngs/wait-038.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-038.png -------------------------------------------------------------------------------- /samples/pngs/wait-039.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-039.png -------------------------------------------------------------------------------- /samples/pngs/wait-040.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-040.png -------------------------------------------------------------------------------- /samples/pngs/wait-041.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-041.png -------------------------------------------------------------------------------- /samples/pngs/wait-042.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-042.png -------------------------------------------------------------------------------- /samples/pngs/wait-043.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-043.png -------------------------------------------------------------------------------- /samples/pngs/wait-044.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-044.png -------------------------------------------------------------------------------- /samples/pngs/wait-045.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-045.png -------------------------------------------------------------------------------- /samples/pngs/wait-046.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/samples/pngs/wait-046.png -------------------------------------------------------------------------------- /samples/sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "theme": { 3 | "name": "Sample", 4 | "comment": "This is sample cursor theme", 5 | "website": "https://www.example.com/" 6 | }, 7 | "config": { 8 | "bitmaps_dir": "pngs", 9 | "out_dir": "out", 10 | "platforms": ["x11", "windows"] 11 | }, 12 | "cursors": { 13 | "fallback_settings": { 14 | "x11_sizes": [16, 20, 24, 28, 32, 40, 48, 56, 64, 72, 80, 88, 96], 15 | "win_sizes": ["24:32", "32", "48", "64", "96"], 16 | "x_hotspot": 53, 17 | "y_hotspot": 36, 18 | "x11_delay": 30, 19 | "win_delay": 1 20 | }, 21 | "test_pointer1": { 22 | "png": "pointer.png", 23 | "win_name": "Default", 24 | "x11_name": "pointer1", 25 | "x11_symlinks": ["link1"] 26 | }, 27 | "test_pointer2": { 28 | "png": "pointer.png", 29 | "win_name": "Alternate", 30 | "x11_name": "pointer2", 31 | "x11_symlinks": [ 32 | "link2", 33 | "link3", 34 | "link4", 35 | "link5", 36 | "link6", 37 | "link7", 38 | "link8" 39 | ] 40 | }, 41 | "test_pointer3": { 42 | "png": "pointer.png", 43 | "win_name": "Cross", 44 | "x11_name": "pointer3" 45 | }, 46 | "test_pointer4": { 47 | "png": "pointer.png", 48 | "win_name": "Diagonal_1", 49 | "x11_name": "pointer4" 50 | }, 51 | "test_pointer5": { 52 | "png": "pointer.png", 53 | "win_name": "Diagonal_2", 54 | "x11_name": "pointer5" 55 | }, 56 | "test_pointer6": { 57 | "png": "pointer.png", 58 | "win_name": "Handwriting", 59 | "x11_name": "pointer6" 60 | }, 61 | "test_pointer7": { 62 | "png": "pointer.png", 63 | "win_name": "Help", 64 | "x11_name": "pointer7" 65 | }, 66 | "test_pointer8": { 67 | "png": "pointer.png", 68 | "win_name": "Horizontal", 69 | "x11_name": "pointer8" 70 | }, 71 | "test_pointer9": { 72 | "png": "pointer.png", 73 | "win_name": "IBeam", 74 | "x11_name": "pointer9" 75 | }, 76 | "test_pointer10": { 77 | "png": "pointer.png", 78 | "win_name": "Link", 79 | "x11_name": "pointer10" 80 | }, 81 | "test_pointer11": { 82 | "png": "pointer.png", 83 | "win_name": "Move", 84 | "x11_name": "pointer11" 85 | }, 86 | "test_pointer12": { 87 | "png": "pointer.png", 88 | "win_name": "Unavailiable", 89 | "x11_name": "pointer12" 90 | }, 91 | "test_pointer13": { 92 | "png": "pointer.png", 93 | "win_name": "Vertical", 94 | "x11_name": "pointer13" 95 | }, 96 | "test_animated_pointer1": { 97 | "png": "wait-*.png", 98 | "win_name": "Busy", 99 | "x11_name": "wait1", 100 | "x_hotspot": 100, 101 | "y_hotspot": 100, 102 | "win_sizes": [32] 103 | }, 104 | "test_animated_pointer2": { 105 | "png": "wait-*.png", 106 | "win_name": "Work", 107 | "x11_name": "wait2", 108 | "x_hotspot": 100, 109 | "y_hotspot": 100, 110 | "win_sizes": 32 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /samples/sample.toml: -------------------------------------------------------------------------------- 1 | [theme] 2 | name = 'Sample' 3 | comment = 'This is sample cursor theme' 4 | website = 'https://www.example.com/' 5 | 6 | [config] 7 | bitmaps_dir = 'pngs' 8 | out_dir = 'out' 9 | platforms = ['x11', 'windows'] 10 | 11 | [cursors] 12 | [cursors.fallback_settings] 13 | x11_sizes = [16, 20, 24, 28, 32, 40, 48, 56, 64, 72, 80, 88, 96] 14 | win_sizes = ['24:32', '32', '48', '64', '96'] 15 | x_hotspot = 53 16 | y_hotspot = 36 17 | x11_delay = 30 18 | win_delay = 1 19 | 20 | [cursors.test_pointer1] 21 | png = 'pointer.png' 22 | win_name = 'Default' 23 | x11_name = 'pointer1' 24 | x11_symlinks = ['link1'] 25 | 26 | [cursors.test_pointer2] 27 | png = 'pointer.png' 28 | win_name = 'Alternate' 29 | x11_name = 'pointer2' 30 | x11_symlinks = ['link2', 'link3', 'link4', 'link5', 'link6', 'link7', 'link8'] 31 | 32 | 33 | [cursors.test_pointer3] 34 | png = 'pointer.png' 35 | win_name = 'Cross' 36 | x11_name = 'pointer3' 37 | 38 | [cursors.test_pointer4] 39 | png = 'pointer.png' 40 | win_name = 'Diagonal_1' 41 | x11_name = 'pointer4' 42 | 43 | [cursors.test_pointer5] 44 | png = 'pointer.png' 45 | win_name = 'Diagonal_2' 46 | x11_name = 'pointer5' 47 | 48 | 49 | [cursors.test_pointer6] 50 | png = 'pointer.png' 51 | win_name = 'Handwriting' 52 | x11_name = 'pointer6' 53 | 54 | [cursors.test_pointer7] 55 | png = 'pointer.png' 56 | win_name = 'Help' 57 | x11_name = 'pointer7' 58 | 59 | [cursors.test_pointer8] 60 | png = 'pointer.png' 61 | win_name = 'Horizontal' 62 | x11_name = 'pointer8' 63 | 64 | [cursors.test_pointer9] 65 | png = 'pointer.png' 66 | win_name = 'IBeam' 67 | x11_name = 'pointer9' 68 | 69 | [cursors.test_pointer10] 70 | png = 'pointer.png' 71 | win_name = 'Link' 72 | x11_name = 'pointer10' 73 | 74 | [cursors.test_pointer11] 75 | png = 'pointer.png' 76 | win_name = 'Move' 77 | x11_name = 'pointer11' 78 | 79 | [cursors.test_pointer12] 80 | png = 'pointer.png' 81 | win_name = 'Unavailiable' 82 | x11_name = 'pointer12' 83 | 84 | [cursors.test_pointer13] 85 | png = 'pointer.png' 86 | win_name = 'Vertical' 87 | x11_name = 'pointer13' 88 | 89 | [cursors.test_animated_pointer1] 90 | png = 'wait-*.png' 91 | win_name = 'Busy' 92 | x11_name = 'wait1' 93 | x_hotspot = 100 94 | y_hotspot = 100 95 | win_sizes = [32] 96 | 97 | [cursors.test_animated_pointer2] 98 | png = 'wait-*.png' 99 | win_name = 'Work' 100 | x11_name = 'wait2' 101 | x_hotspot = 100 102 | y_hotspot = 100 103 | win_sizes = 32 104 | -------------------------------------------------------------------------------- /samples/sample.yaml: -------------------------------------------------------------------------------- 1 | theme: 2 | name: "Sample" 3 | comment: "This is sample cursor theme" 4 | website: "https://www.example.com/" 5 | 6 | config: 7 | bitmaps_dir: "pngs" 8 | out_dir: "out" 9 | platforms: ["x11", "windows"] 10 | 11 | cursors: 12 | fallback_settings: 13 | x11_sizes: [16, 20, 24, 28, 32, 40, 48, 56, 64, 72, 80, 88, 96] 14 | win_sizes: ["24", "32", "48", "64", "96"] 15 | x_hotspot: 53 16 | y_hotspot: 36 17 | x11_delay: 30 18 | win_delay: 1 19 | 20 | test_pointer1: 21 | png: "pointer.png" 22 | win_name: "Default" 23 | x11_name: "pointer1" 24 | x11_symlinks: ["link1"] 25 | 26 | test_pointer2: 27 | png: "pointer.png" 28 | win_name: "Alternate" 29 | x11_name: "pointer2" 30 | x11_symlinks: 31 | ["link2", "link3", "link4", "link5", "link6", "link7", "link8"] 32 | 33 | test_pointer3: 34 | png: "pointer.png" 35 | win_name: "Cross" 36 | x11_name: "pointer3" 37 | 38 | test_pointer4: 39 | png: "pointer.png" 40 | win_name: "Diagonal_1" 41 | x11_name: "pointer4" 42 | 43 | test_pointer5: 44 | png: "pointer.png" 45 | win_name: "Diagonal_2" 46 | x11_name: "pointer5" 47 | 48 | test_pointer6: 49 | png: "pointer.png" 50 | win_name: "Handwriting" 51 | x11_name: "pointer6" 52 | 53 | test_pointer7: 54 | png: "pointer.png" 55 | win_name: "Help" 56 | x11_name: "pointer7" 57 | 58 | test_pointer8: 59 | png: "pointer.png" 60 | win_name: "Horizontal" 61 | x11_name: "pointer8" 62 | 63 | test_pointer9: 64 | png: "pointer.png" 65 | win_name: "IBeam" 66 | x11_name: "pointer9" 67 | 68 | test_pointer10: 69 | png: "pointer.png" 70 | win_name: "Link" 71 | x11_name: "pointer10" 72 | 73 | test_pointer11: 74 | png: "pointer.png" 75 | win_name: "Move" 76 | x11_name: "pointer11" 77 | 78 | test_pointer12: 79 | png: "pointer.png" 80 | win_name: "Unavailiable" 81 | x11_name: "pointer12" 82 | 83 | test_pointer13: 84 | png: "pointer.png" 85 | win_name: "Vertical" 86 | x11_name: "pointer13" 87 | 88 | test_animated_pointer1: 89 | png: "wait-*.png" 90 | win_name: "Busy" 91 | x11_name: "wait1" 92 | x_hotspot: 100 93 | y_hotspot: 100 94 | win_sizes: [32] 95 | 96 | test_animated_pointer2: 97 | png: "wait-*.png" 98 | win_name: "Work" 99 | x11_name: "wait2" 100 | x_hotspot: 100 101 | y_hotspot: 100 102 | win_sizes: 32 103 | -------------------------------------------------------------------------------- /samples/test.toml: -------------------------------------------------------------------------------- 1 | # CAUTION: This file is used in clickgen test suit. 2 | # DON'T DELETE IT. 3 | 4 | [theme] 5 | name = 'Test' 6 | comment = 'This is test cursor config for pytest' 7 | website = 'https://www.example.com/' 8 | 9 | [config] 10 | bitmaps_dir = 'pngs' 11 | out_dir = 'out' 12 | platforms = ['x11', 'windows'] 13 | 14 | [cursors] 15 | [cursors.fallback_settings] 16 | x11_sizes = [21, 24, 28, 32, 40, 48, 56, 64, 72, 80, 88, 96] 17 | win_sizes = [24, 32, 48, 64, 96] 18 | x_hotspot = 53 19 | y_hotspot = 36 20 | x11_delay = 30 21 | win_delay = 1 22 | 23 | [cursors.test_pointer1] 24 | png = 'pointer.png' 25 | win_name = 'Default' 26 | x11_name = 'pointer1' 27 | x11_symlinks = ['link1'] 28 | 29 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = clickgen 3 | version = attr: clickgen.__version__ 4 | author = Abdulkaiz Khatri 5 | author_email = kaizmandhu@gmail.com 6 | description = The hassle-free cursor building toolbox. 7 | long_description = file: README.md 8 | long_description_content_type = text/markdown 9 | url = https://github.com/ful1e5/clickgen 10 | keywords = cursor, xcursor, windows, linux 11 | project_urls = 12 | Source = https://github.com/ful1e5/clickgen 13 | Download = https://pypi.org/project/clickgen/#files 14 | Bug Tracker = https://github.com/ful1e5/clickgen/issues 15 | Changelog = https://github.com/ful1e5/clickgen/blob/main/CHANGELOG.md 16 | Funding = https://github.com/sponsors/ful1e5 17 | Twitter = https://twitter.com/ful1e5 18 | classifiers = 19 | Development Status :: 6 - Mature 20 | License :: OSI Approved :: MIT License 21 | Programming Language :: Python :: 3 :: Only 22 | Programming Language :: Python :: 3 23 | Programming Language :: Python :: 3.7 24 | Programming Language :: Python :: 3.8 25 | Programming Language :: Python :: 3.9 26 | Programming Language :: Python :: 3.10 27 | Programming Language :: Python :: 3.11 28 | Programming Language :: Python :: 3.12 29 | Topic :: Multimedia :: Graphics 30 | Topic :: System :: Operating System 31 | Topic :: Scientific/Engineering :: Image Processing 32 | Operating System :: OS Independent 33 | Typing :: Typed 34 | 35 | [options] 36 | python_requires = >=3.7.5 37 | include_package_data = True 38 | packages = find: 39 | install_requires = 40 | Pillow>=8.1.1 41 | PyYaml>=6.0.1 42 | attrs>=15.0.0 43 | numpy>=1.21.6 44 | toml>=0.10.2 45 | package_dir = 46 | = src 47 | 48 | [options.packages.find] 49 | where = src 50 | include = clickgen* 51 | 52 | [options.package_data] 53 | * = LICENSE, README.md, *.pyi 54 | clickgen = py.typed 55 | 56 | [options.entry_points] 57 | console_scripts = 58 | clickgen = clickgen.scripts.clickgen:main 59 | ctgen = clickgen.scripts.ctgen:main 60 | 61 | [options.extras_require] 62 | test = 63 | flake8>=4.0.1 64 | mypy>=0.982 65 | pytest-cov>=3.0.0 66 | pytest>=7.0.1 67 | tox>=3.25.0 68 | build = 69 | build>=0.10.0 70 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from setuptools import setup 5 | 6 | if __name__ == "__main__": 7 | setup() 8 | -------------------------------------------------------------------------------- /src/clickgen/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | __version__ = "2.2.5" 5 | -------------------------------------------------------------------------------- /src/clickgen/__init__.pyi: -------------------------------------------------------------------------------- 1 | __version__: str 2 | -------------------------------------------------------------------------------- /src/clickgen/configparser.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import json 5 | from pathlib import Path 6 | from typing import Any, Dict, List, Optional, TypeVar, Union 7 | 8 | import toml 9 | import yaml 10 | from attr import dataclass 11 | 12 | from clickgen.libs.colors import print_warning 13 | from clickgen.parser import open_blob 14 | from clickgen.parser.png import DELAY, SIZES 15 | from clickgen.writer.windows import to_win 16 | from clickgen.writer.x11 import to_x11 17 | 18 | 19 | @dataclass 20 | class ThemeSection: 21 | name: str 22 | comment: str 23 | website: str 24 | 25 | 26 | def parse_theme_section(d: Dict[str, Any], **kwargs) -> ThemeSection: 27 | t = d["theme"] 28 | return ThemeSection( 29 | name=kwargs.get("name", t["name"]), 30 | comment=kwargs.get("comment", t["comment"]), 31 | website=kwargs.get("website", t["website"]), 32 | ) 33 | 34 | 35 | @dataclass 36 | class ConfigSection: 37 | bitmaps_dir: Path 38 | out_dir: Path 39 | platforms: List[str] 40 | 41 | 42 | def parse_config_section(fp: Path, d: Dict[str, Any], **kwargs) -> ConfigSection: 43 | c = d["config"] 44 | p = fp.parent 45 | 46 | def absolute_path(path: str) -> Path: 47 | if Path(path).is_absolute(): 48 | return Path(path) 49 | return (p / path).absolute() 50 | 51 | # Deprecation 52 | deprecated_opts = {"win_size": "win_sizes", "x11_sizes": "x11_sizes"} 53 | for opt in deprecated_opts: 54 | if c.get(opt): 55 | print_warning( 56 | f"The '{opt}' option is deprecated." 57 | f" Please use '{deprecated_opts.get(opt)}' within individual cursor settings or set it to '[cursor.fallback_settings]'." 58 | " For more information, visit: https://github.com/ful1e5/clickgen/discussions/59#discussioncomment-6747666" 59 | ) 60 | 61 | return ConfigSection( 62 | bitmaps_dir=kwargs.get("bitmaps_dir", absolute_path(c["bitmaps_dir"])), 63 | out_dir=kwargs.get("out_dir", absolute_path(c["out_dir"])), 64 | platforms=kwargs.get("platforms", c["platforms"]), 65 | ) 66 | 67 | 68 | T = TypeVar("T") 69 | 70 | 71 | @dataclass 72 | class CursorSection: 73 | x11_cursor_name: Union[str, None] 74 | x11_cursor: Union[bytes, None] 75 | x11_symlinks: List[str] 76 | win_cursor_name: Union[str, None] 77 | win_cursor: Union[bytes, None] 78 | 79 | 80 | def parse_cursors_section( 81 | d: Dict[str, Any], config: ConfigSection, **kwargs 82 | ) -> List[CursorSection]: 83 | def get_value(k: str, def_val: Optional[T] = None) -> T: 84 | return kwargs.get(k, v.get(k, fb.get(k, def_val))) 85 | 86 | def size_typing(s: Union[int, List[int]]) -> List[int]: 87 | if isinstance(s, int): 88 | return [s] 89 | elif isinstance(s, List): 90 | return s 91 | 92 | result: List[CursorSection] = [] 93 | 94 | fb = d["cursors"]["fallback_settings"] 95 | del d["cursors"]["fallback_settings"] 96 | 97 | for _, v in d["cursors"].items(): 98 | hotspot = ( 99 | get_value("x_hotspot"), 100 | get_value("y_hotspot"), 101 | ) 102 | x11_delay = get_value("x11_delay", DELAY) 103 | win_delay = get_value("win_delay", DELAY) 104 | 105 | x11_sizes = size_typing(get_value("x11_sizes", SIZES)) 106 | win_sizes = size_typing(get_value("win_sizes", SIZES)) 107 | 108 | blobs = [f.read_bytes() for f in sorted(config.bitmaps_dir.glob(v["png"]))] 109 | 110 | if not blobs: 111 | raise FileNotFoundError( 112 | f"Bitmaps not found '{v['png']}' in '{config.bitmaps_dir}'" 113 | ) 114 | 115 | x11_cursor = None 116 | x11_cursor_name = None 117 | if "x11_name" in v: 118 | x11_blob = open_blob(blobs, hotspot, x11_sizes, x11_delay) 119 | x11_cursor = to_x11(x11_blob.frames) 120 | x11_cursor_name = v["x11_name"] 121 | 122 | win_cursor = None 123 | win_cursor_name = None 124 | if "win_name" in v: 125 | win_blob = open_blob(blobs, hotspot, win_sizes, win_delay) 126 | ext, win_cursor = to_win(win_blob.frames) 127 | win_cursor_name = v["win_name"] + ext 128 | 129 | result.append( 130 | CursorSection( 131 | x11_cursor=x11_cursor, 132 | x11_cursor_name=x11_cursor_name, 133 | x11_symlinks=v.get("x11_symlinks", []), 134 | win_cursor=win_cursor, 135 | win_cursor_name=win_cursor_name, 136 | ) 137 | ) 138 | 139 | return result 140 | 141 | 142 | @dataclass 143 | class ClickgenConfig: 144 | theme: ThemeSection 145 | config: ConfigSection 146 | cursors: List[CursorSection] 147 | 148 | 149 | def parse_toml_file(fp: Path, **kwargs) -> ClickgenConfig: 150 | d: Dict[str, Any] = toml.load(fp) 151 | theme = parse_theme_section(d, **kwargs) 152 | config = parse_config_section(fp, d, **kwargs) 153 | cursors = parse_cursors_section(d, config, **kwargs) 154 | 155 | return ClickgenConfig(theme, config, cursors) 156 | 157 | 158 | def parse_yaml_file(fp: Path, **kwargs) -> ClickgenConfig: 159 | d: Dict[str, Any] = {} 160 | 161 | with open(fp, "r") as file: 162 | d = yaml.safe_load(file) 163 | 164 | theme = parse_theme_section(d, **kwargs) 165 | config = parse_config_section(fp, d, **kwargs) 166 | cursors = parse_cursors_section(d, config, **kwargs) 167 | 168 | return ClickgenConfig(theme, config, cursors) 169 | 170 | 171 | def parse_json_file(fp: Path, **kwargs) -> ClickgenConfig: 172 | d: Dict[str, Any] = {} 173 | 174 | with open(fp, "r") as file: 175 | d = json.load(file) 176 | 177 | theme = parse_theme_section(d, **kwargs) 178 | config = parse_config_section(fp, d, **kwargs) 179 | cursors = parse_cursors_section(d, config, **kwargs) 180 | 181 | return ClickgenConfig(theme, config, cursors) 182 | 183 | 184 | def parse_config_file(fp: Path, **kwargs) -> ClickgenConfig: 185 | ext = fp.suffix 186 | config: ClickgenConfig 187 | 188 | if ext == ".yml" or ext == ".yaml": 189 | config = parse_yaml_file(fp, **kwargs) 190 | 191 | elif ext == ".json": 192 | config = parse_json_file(fp, **kwargs) 193 | 194 | elif ext == ".toml": 195 | config = parse_toml_file(fp, **kwargs) 196 | 197 | else: 198 | raise IOError("Configuration File type is not supported") 199 | 200 | return config 201 | -------------------------------------------------------------------------------- /src/clickgen/configparser.pyi: -------------------------------------------------------------------------------- 1 | from clickgen.libs.colors import print_warning as print_warning 2 | from clickgen.parser import open_blob as open_blob 3 | from clickgen.parser.png import DELAY as DELAY, SIZES as SIZES 4 | from clickgen.writer.windows import to_win as to_win 5 | from clickgen.writer.x11 import to_x11 as to_x11 6 | from pathlib import Path 7 | from typing import Any, TypeVar 8 | 9 | class ThemeSection: 10 | name: str 11 | comment: str 12 | website: str 13 | def __init__(self, name, comment, website) -> None: ... 14 | def __lt__(self, other): ... 15 | def __le__(self, other): ... 16 | def __gt__(self, other): ... 17 | def __ge__(self, other): ... 18 | 19 | def parse_theme_section(d: dict[str, Any], **kwargs) -> ThemeSection: ... 20 | 21 | class ConfigSection: 22 | bitmaps_dir: Path 23 | out_dir: Path 24 | platforms: list[str] 25 | def __init__(self, bitmaps_dir, out_dir, platforms) -> None: ... 26 | def __lt__(self, other): ... 27 | def __le__(self, other): ... 28 | def __gt__(self, other): ... 29 | def __ge__(self, other): ... 30 | 31 | def parse_config_section(fp: Path, d: dict[str, Any], **kwargs) -> ConfigSection: ... 32 | T = TypeVar('T') 33 | 34 | class CursorSection: 35 | x11_cursor_name: str | None 36 | x11_cursor: bytes | None 37 | x11_symlinks: list[str] 38 | win_cursor_name: str | None 39 | win_cursor: bytes | None 40 | def __init__(self, x11_cursor_name, x11_cursor, x11_symlinks, win_cursor_name, win_cursor) -> None: ... 41 | def __lt__(self, other): ... 42 | def __le__(self, other): ... 43 | def __gt__(self, other): ... 44 | def __ge__(self, other): ... 45 | 46 | def parse_cursors_section(d: dict[str, Any], config: ConfigSection, **kwargs) -> list[CursorSection]: ... 47 | 48 | class ClickgenConfig: 49 | theme: ThemeSection 50 | config: ConfigSection 51 | cursors: list[CursorSection] 52 | def __init__(self, theme, config, cursors) -> None: ... 53 | def __lt__(self, other): ... 54 | def __le__(self, other): ... 55 | def __gt__(self, other): ... 56 | def __ge__(self, other): ... 57 | 58 | def parse_toml_file(fp: Path, **kwargs) -> ClickgenConfig: ... 59 | def parse_yaml_file(fp: Path, **kwargs) -> ClickgenConfig: ... 60 | def parse_json_file(fp: Path, **kwargs) -> ClickgenConfig: ... 61 | def parse_config_file(fp: Path, **kwargs) -> ClickgenConfig: ... 62 | -------------------------------------------------------------------------------- /src/clickgen/cursors.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | from typing import Iterator, List, Tuple 5 | 6 | from PIL.Image import Image 7 | 8 | 9 | class CursorImage: 10 | image: Image 11 | hotspot: Tuple[int, int] 12 | nominal: int 13 | re_canvas: bool 14 | 15 | def __init__( 16 | self, 17 | image: Image, 18 | hotspot: Tuple[int, int], 19 | nominal: int, 20 | re_canvas: bool = False, 21 | ) -> None: 22 | self.image = image 23 | self.hotspot = hotspot 24 | self.nominal = nominal 25 | self.re_canvas = re_canvas 26 | 27 | def __repr__(self) -> str: 28 | return f"CursorImage(image={self.image!r}, hotspot={self.hotspot!r}, nominal={self.nominal!r}, re_canvas={self.re_canvas!r})" 29 | 30 | 31 | class CursorFrame: 32 | images: List[CursorImage] 33 | delay: int 34 | 35 | def __init__(self, images: List[CursorImage], delay: int = 0) -> None: 36 | self.images = images 37 | self.delay = delay 38 | 39 | def __getitem__(self, item: int) -> CursorImage: 40 | return self.images[item] 41 | 42 | def __len__(self) -> int: 43 | return len(self.images) 44 | 45 | def __iter__(self) -> Iterator[CursorImage]: 46 | return iter(self.images) 47 | 48 | def __repr__(self) -> str: 49 | return f"CursorFrame(images={self.images!r}, delay={self.delay!r})" 50 | -------------------------------------------------------------------------------- /src/clickgen/cursors.pyi: -------------------------------------------------------------------------------- 1 | from PIL.Image import Image as Image 2 | from typing import Iterator 3 | 4 | class CursorImage: 5 | image: Image 6 | hotspot: tuple[int, int] 7 | nominal: int 8 | re_canvas: bool 9 | def __init__(self, image: Image, hotspot: tuple[int, int], nominal: int, re_canvas: bool = False) -> None: ... 10 | 11 | class CursorFrame: 12 | images: list[CursorImage] 13 | delay: int 14 | def __init__(self, images: list[CursorImage], delay: int = 0) -> None: ... 15 | def __getitem__(self, item: int) -> CursorImage: ... 16 | def __len__(self) -> int: ... 17 | def __iter__(self) -> Iterator[CursorImage]: ... 18 | -------------------------------------------------------------------------------- /src/clickgen/libs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/src/clickgen/libs/__init__.py -------------------------------------------------------------------------------- /src/clickgen/libs/__init__.pyi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/src/clickgen/libs/__init__.pyi -------------------------------------------------------------------------------- /src/clickgen/libs/colors.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | 5 | class bcolors: 6 | BOLD = "\033[1m" 7 | 8 | BLUE = "\033[94m" 9 | CYAN = "\033[96m" 10 | GREEN = "\033[92m" 11 | MAGENTA = "\033[95m" 12 | YELLOW = "\033[93m" 13 | RED = "\033[91m" 14 | 15 | NORMAL = "\033[0m" 16 | 17 | 18 | # Text Formate 19 | def bold(s: str) -> str: 20 | return f"{bcolors.BOLD}{s}{bcolors.NORMAL}" 21 | 22 | 23 | # Colors 24 | def blue(s: str) -> str: 25 | return f"{bcolors.BLUE}{s}{bcolors.NORMAL}" 26 | 27 | 28 | def cyan(s: str) -> str: 29 | return f"{bcolors.CYAN}{s}{bcolors.NORMAL}" 30 | 31 | 32 | def green(s: str) -> str: 33 | return f"{bcolors.GREEN}{s}{bcolors.NORMAL}" 34 | 35 | 36 | def magenta(s: str) -> str: 37 | return f"{bcolors.MAGENTA}{s}{bcolors.NORMAL}" 38 | 39 | 40 | def red(s: str) -> str: 41 | return f"{bcolors.RED}{s}{bcolors.NORMAL}" 42 | 43 | 44 | def yellow(s: str) -> str: 45 | return f"{bcolors.YELLOW}{s}{bcolors.NORMAL}" 46 | 47 | 48 | # Styling 49 | def print_text(s: str) -> None: 50 | print(f"{bold(blue(' -'))} {s}") 51 | 52 | 53 | def print_subtext(s: str) -> None: 54 | print(f"{bold(' ::')} {s}") 55 | 56 | 57 | def print_info(s: str) -> None: 58 | print(f"[Info] {s}") 59 | 60 | 61 | def print_done(s: str) -> None: 62 | print(f"{green('[Done]')} {s}") 63 | 64 | 65 | def print_warning(s: str) -> None: 66 | print(f"{yellow('[Warning]')} {s}") 67 | 68 | 69 | def fail(s: str) -> str: 70 | return bold(red(f"[Fail] {s}")) 71 | -------------------------------------------------------------------------------- /src/clickgen/libs/colors.pyi: -------------------------------------------------------------------------------- 1 | class bcolors: 2 | BOLD: str 3 | BLUE: str 4 | CYAN: str 5 | GREEN: str 6 | MAGENTA: str 7 | YELLOW: str 8 | RED: str 9 | NORMAL: str 10 | 11 | def bold(s: str) -> str: ... 12 | def blue(s: str) -> str: ... 13 | def cyan(s: str) -> str: ... 14 | def green(s: str) -> str: ... 15 | def magenta(s: str) -> str: ... 16 | def red(s: str) -> str: ... 17 | def yellow(s: str) -> str: ... 18 | def print_text(s: str) -> None: ... 19 | def print_subtext(s: str) -> None: ... 20 | def print_info(s: str) -> None: ... 21 | def print_done(s: str) -> None: ... 22 | def print_warning(s: str) -> None: ... 23 | def fail(s: str) -> str: ... 24 | -------------------------------------------------------------------------------- /src/clickgen/packer/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from clickgen.packer.windows import pack_win 5 | from clickgen.packer.x11 import pack_x11 6 | 7 | __all__ = ["pack_win", "pack_x11"] 8 | -------------------------------------------------------------------------------- /src/clickgen/packer/__init__.pyi: -------------------------------------------------------------------------------- 1 | from clickgen.packer.windows import pack_win as pack_win 2 | from clickgen.packer.x11 import pack_x11 as pack_x11 3 | 4 | __all__ = ['pack_win', 'pack_x11'] 5 | -------------------------------------------------------------------------------- /src/clickgen/packer/windows.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | from pathlib import Path 5 | from string import Template 6 | from typing import Dict, List, Optional 7 | 8 | FILE_TEMPLETES: Dict[str, Template] = { 9 | "install.inf": Template( 10 | """ 11 | ; =========================================================== 12 | ; Auto-Generated File 13 | ; =========================================================== 14 | ; This file has been automatically generated by a tool 15 | ; called 'clickgen'. For more information, 16 | ; visit: https://www.github.com/ful1e5/clickgen 17 | ; =========================================================== 18 | 19 | 20 | ; $theme_name 21 | ; $comment $website 22 | 23 | [Version] 24 | signature="$CHICAGO$" 25 | $comment 26 | 27 | [DefaultInstall] 28 | CopyFiles = Scheme.Cur 29 | AddReg = Scheme.Reg,Wreg 30 | 31 | [DestinationDirs] 32 | Scheme.Cur = 10,"%CUR_DIR%" 33 | 34 | [Scheme.Reg] 35 | HKCU,"Control Panel\\Cursors\\Schemes","%SCHEME_NAME%",,$scheme_reg 36 | 37 | [Wreg] 38 | HKCU,"Control Panel\\Cursors",,0x00020000,"%SCHEME_NAME%" 39 | $wreg 40 | HKLM,"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Runonce\\Setup\\","",,"rundll32.exe shell32.dll,Control_RunDLL main.cpl @0,1" 41 | 42 | [Scheme.Cur] 43 | $scheme_cur 44 | 45 | [Scheme.Txt] 46 | 47 | [Strings] 48 | CUR_DIR = "Cursors\\$theme_name" 49 | SCHEME_NAME = "$theme_name" 50 | $strings 51 | """ 52 | ), 53 | "uninstall.bat": Template( 54 | """@echo off 55 | :: =========================================================== 56 | :: Auto-Generated File 57 | :: =========================================================== 58 | :: This file has been automatically generated by a tool 59 | :: called 'clickgen'. For more information, 60 | :: visit: https://www.github.com/ful1e5/clickgen 61 | :: 62 | :: Credit: https://github.com/smit-sms 63 | :: https://github.com/ful1e5/apple_cursor/issues/79 64 | :: =========================================================== 65 | 66 | REG DELETE "HKCU\\Control Panel\\Cursors\\Schemes" /v "$theme_name" /f 67 | 68 | :: =============================================================================== 69 | :: This enables a popup message box to indicate a user for the operation complete. 70 | :: =============================================================================== 71 | echo x=msgbox("Successfully deleted the cursor!", 0+64, "Cursor") > %tmp%\\tmp.vbs 72 | wscript %tmp%\\tmp.vbs 73 | del %tmp%\\tmp.vbs 74 | """ 75 | ), 76 | } 77 | 78 | # NOTE: DO NOT CHANGE ORDER OF THIS LIST 79 | # https://github.com/ful1e5/Bibata_Cursor/issues/154#issuecomment-1783938013 80 | all_wreg = [ 81 | 'HKCU,"Control Panel\\Cursors",Arrow,0x00020000,"%10%\\%CUR_DIR%\\%pointer%"', 82 | 'HKCU,"Control Panel\\Cursors",Help,0x00020000,"%10%\\%CUR_DIR%\\%help%"', 83 | 'HKCU,"Control Panel\\Cursors",AppStarting,0x00020000,"%10%\\%CUR_DIR%\\%work%"', 84 | 'HKCU,"Control Panel\\Cursors",Wait,0x00020000,"%10%\\%CUR_DIR%\\%busy%"', 85 | 'HKCU,"Control Panel\\Cursors",crosshair,0x00020000,"%10%\\%CUR_DIR%\\%cross%"', 86 | 'HKCU,"Control Panel\\Cursors",precisionhair,0x00020000,"%10%\\%CUR_DIR%\\%cross%"', 87 | 'HKCU,"Control Panel\\Cursors",IBeam,0x00020000,"%10%\\%CUR_DIR%\\%text%"', 88 | 'HKCU,"Control Panel\\Cursors",NWPen,0x00020000,"%10%\\%CUR_DIR%\\%handwriting%"', 89 | 'HKCU,"Control Panel\\Cursors",No,0x00020000,"%10%\\%CUR_DIR%\\%unavailable%"', 90 | 'HKCU,"Control Panel\\Cursors",SizeNS,0x00020000,"%10%\\%CUR_DIR%\\%vert%"', 91 | 'HKCU,"Control Panel\\Cursors",SizeWE,0x00020000,"%10%\\%CUR_DIR%\\%horz%"', 92 | 'HKCU,"Control Panel\\Cursors",SizeNWSE,0x00020000,"%10%\\%CUR_DIR%\\%dgn1%"', 93 | 'HKCU,"Control Panel\\Cursors",SizeNESW,0x00020000,"%10%\\%CUR_DIR%\\%dgn2%"', 94 | 'HKCU,"Control Panel\\Cursors",Grab,0x00020000,"%10%\\%CUR_DIR%\\%move%"', 95 | 'HKCU,"Control Panel\\Cursors",SizeAll,0x00020000,"%10%\\%CUR_DIR%\\%move%"', 96 | 'HKCU,"Control Panel\\Cursors",UpArrow,0x00020000,"%10%\\%CUR_DIR%\\%alternate%"', 97 | 'HKCU,"Control Panel\\Cursors",Hand,0x00020000,"%10%\\%CUR_DIR%\\%link%"', 98 | 'HKCU,"Control Panel\\Cursors",Pin,0x00020000,"%10%\\%CUR_DIR%\\%pin%"', 99 | 'HKCU,"Control Panel\\Cursors",Person,0x00020000,"%10%\\%CUR_DIR%\\%person%"', 100 | 'HKCU,"Control Panel\\Cursors",Pan,0x00020000,"%10%\\%CUR_DIR%\\%pan%"', 101 | 'HKCU,"Control Panel\\Cursors",Grabbing,0x00020000,"%10%\\%CUR_DIR%\\%grabbing%"', 102 | 'HKCU,"Control Panel\\Cursors",Zoom-in,0x00020000,"%10%\\%CUR_DIR%\\%zoom-in%"', 103 | 'HKCU,"Control Panel\\Cursors",Zoom-out,0x00020000,"%10%\\%CUR_DIR%\\%zoom-out%"', 104 | ] 105 | 106 | 107 | def pack_win( 108 | dir: Path, 109 | theme_name: str, 110 | comment: str, 111 | website: Optional[str] = None, 112 | ) -> None: 113 | files: List[Path] = [] 114 | 115 | for ext in ("*.ani", "*.cur"): 116 | for i in sorted(dir.glob(ext)): 117 | files.append(i) 118 | 119 | sreg, scur, sstr, wreg_list = [], [], [], [] 120 | 121 | # Define a custom sorting key function 122 | def custom_sort_key(file_path): 123 | stem = file_path.stem.lower() 124 | for item in all_wreg: 125 | if f"%{stem}%" in item: 126 | return all_wreg.index(item) 127 | return len(all_wreg) 128 | 129 | # Sort files using the custom sorting key 130 | files = sorted(set(files), key=custom_sort_key) 131 | 132 | for f in files: 133 | stem = f.stem.lower() 134 | sreg.append(f"%10%\\%CUR_DIR%\\%{stem}%") 135 | scur.append(f.name) 136 | sstr.append(f'{stem}{" "*(20-len(stem))}= "{f.name}"') # noqa: E226 137 | wreg_list.extend([item for item in all_wreg if f"%{stem}%" in item]) 138 | 139 | scheme_reg = f'"{",".join(sreg)}"' 140 | scheme_cur = "\n".join(scur) 141 | strings = "\n".join(sstr) 142 | wreg = "\n".join(wreg_list) 143 | 144 | for fname, template in FILE_TEMPLETES.items(): 145 | data: str = template.safe_substitute( 146 | theme_name=f"{theme_name} Cursors", 147 | comment=comment, 148 | website=website, 149 | scheme_reg=scheme_reg, 150 | scheme_cur=scheme_cur, 151 | strings=strings, 152 | wreg=wreg, 153 | ) 154 | f = dir / fname 155 | f.write_text(data) 156 | -------------------------------------------------------------------------------- /src/clickgen/packer/windows.pyi: -------------------------------------------------------------------------------- 1 | from _typeshed import Incomplete 2 | from pathlib import Path 3 | from string import Template 4 | 5 | FILE_TEMPLETES: dict[str, Template] 6 | all_wreg: Incomplete 7 | 8 | def pack_win(dir: Path, theme_name: str, comment: str, website: str | None = None) -> None: ... 9 | -------------------------------------------------------------------------------- /src/clickgen/packer/x11.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | from pathlib import Path 5 | from string import Template 6 | from typing import Dict 7 | 8 | FILE_TEMPLATES: Dict[str, Template] = { 9 | "cursor.theme": Template('[Icon Theme]\nName=$theme_name\nInherits="$theme_name"'), 10 | "index.theme": Template( 11 | '[Icon Theme]\nName=$theme_name\nComment=$comment\nInherits="hicolor"' 12 | ), 13 | } 14 | 15 | 16 | def pack_x11(dir: Path, theme_name: str, comment: str) -> None: 17 | """This method generates ``cursor.theme`` & ``index.theme`` files at \ 18 | ``directory``. 19 | 20 | :param dir: Path where ``.theme`` files save. 21 | :param dir: ``pathlib.Path`` 22 | 23 | :param theme_name: Name of theme. 24 | :param theme_name: ``str`` 25 | 26 | :param comment: Extra information about theme. 27 | :param comment: ``str`` 28 | 29 | :returns: None. 30 | :rtype: ``None`` 31 | """ 32 | 33 | for fname, template in FILE_TEMPLATES.items(): 34 | data = template.safe_substitute(theme_name=theme_name, comment=comment) 35 | fp: Path = dir / fname 36 | fp.write_text(data) 37 | -------------------------------------------------------------------------------- /src/clickgen/packer/x11.pyi: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from string import Template 3 | 4 | FILE_TEMPLATES: dict[str, Template] 5 | 6 | def pack_x11(dir: Path, theme_name: str, comment: str) -> None: ... 7 | -------------------------------------------------------------------------------- /src/clickgen/parser/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from typing import List, Optional, Tuple, Type, Union 5 | 6 | from clickgen.parser.base import BaseParser 7 | from clickgen.parser.png import MultiPNGParser, SinglePNGParser 8 | 9 | __all__ = ["SinglePNGParser", "MultiPNGParser", "open_blob"] 10 | 11 | PARSERS: List[Type[BaseParser]] = [SinglePNGParser, MultiPNGParser] 12 | 13 | 14 | def open_blob( 15 | blob: Union[bytes, List[bytes]], 16 | hotspot: Tuple[int, int], 17 | sizes: Optional[List[int]] = None, 18 | delay: Optional[int] = None, 19 | ) -> BaseParser: 20 | for parser in PARSERS: 21 | if parser.can_parse(blob): 22 | return parser(blob, hotspot, sizes, delay) # type: ignore 23 | raise ValueError("Unsupported file format") 24 | -------------------------------------------------------------------------------- /src/clickgen/parser/__init__.pyi: -------------------------------------------------------------------------------- 1 | from clickgen.parser.base import BaseParser 2 | from clickgen.parser.png import MultiPNGParser as MultiPNGParser, SinglePNGParser as SinglePNGParser 3 | 4 | __all__ = ['SinglePNGParser', 'MultiPNGParser', 'open_blob'] 5 | 6 | def open_blob(blob: bytes | list[bytes], hotspot: tuple[int, int], sizes: list[int] | None = None, delay: int | None = None) -> BaseParser: ... 7 | -------------------------------------------------------------------------------- /src/clickgen/parser/base.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | from abc import ABCMeta, abstractmethod 5 | from typing import Any, List 6 | 7 | from clickgen.cursors import CursorFrame 8 | 9 | 10 | class BaseParser(metaclass=ABCMeta): 11 | blob: bytes 12 | frames: List[CursorFrame] 13 | 14 | @abstractmethod 15 | def __init__(self, blob: bytes) -> None: 16 | self.blob = blob 17 | 18 | @classmethod 19 | @abstractmethod 20 | def can_parse(cls, blob: Any) -> bool: 21 | raise NotImplementedError() 22 | -------------------------------------------------------------------------------- /src/clickgen/parser/base.pyi: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | from clickgen.cursors import CursorFrame as CursorFrame 3 | from typing import Any 4 | 5 | class BaseParser(metaclass=ABCMeta): 6 | blob: bytes 7 | frames: list[CursorFrame] 8 | @abstractmethod 9 | def __init__(self, blob: bytes): ... 10 | @classmethod 11 | @abstractmethod 12 | def can_parse(cls, blob: Any) -> bool: ... 13 | -------------------------------------------------------------------------------- /src/clickgen/parser/png.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import io 5 | from typing import List, Optional, Tuple, Union 6 | 7 | from PIL import Image 8 | 9 | from clickgen.cursors import CursorFrame, CursorImage 10 | from clickgen.parser.base import BaseParser 11 | 12 | SIZES = [16, 20, 22, 24, 28, 32, 40, 48, 56, 64, 72, 80, 88, 96] 13 | DELAY = 0 14 | 15 | 16 | class SinglePNGParser(BaseParser): 17 | MAGIC = bytes.fromhex("89504e47") 18 | 19 | @classmethod 20 | def can_parse(cls, blob: bytes) -> bool: 21 | return blob[: len(cls.MAGIC)] == cls.MAGIC 22 | 23 | def __init__( 24 | self, 25 | blob: bytes, 26 | hotspot: Tuple[int, int], 27 | sizes: Optional[List[Union[int, str]]] = None, 28 | delay: Optional[int] = None, 29 | ) -> None: 30 | super().__init__(blob) 31 | self._image = Image.open(io.BytesIO(self.blob)) 32 | 33 | # 'set' to prevent value duplication 34 | if not sizes: 35 | self.sizes = set(SIZES) 36 | else: 37 | self.sizes = set(sizes) 38 | 39 | if not delay: 40 | self.delay = DELAY 41 | else: 42 | self.delay = delay 43 | 44 | if hotspot[0] > self._image.size[0]: 45 | raise ValueError(f"Hotspot x-coordinate too large: {hotspot[0]}") 46 | if hotspot[1] > self._image.size[1]: 47 | raise ValueError(f"Hotspot x-coordinate too large: {hotspot[1]}") 48 | self.hotspot = hotspot 49 | 50 | self.frames = self._parse() 51 | 52 | def _cal_hotspot(self, res_img: Image.Image) -> Tuple[int, int]: 53 | def _dim(i: int) -> int: 54 | return int((self.hotspot[i] * (res_img.size[i] / self._image.size[i]))) 55 | 56 | return _dim(0), _dim(1) 57 | 58 | def _parse(self) -> List[CursorFrame]: 59 | images: List[CursorImage] = [] 60 | for s in sorted(self.sizes): 61 | size: int = 0 62 | canvas_size: int = 0 63 | 64 | if isinstance(s, str): 65 | try: 66 | if ":" in s: 67 | size_str, canvas_size_str = s.split(":") 68 | size = int(size_str) 69 | canvas_size = int(canvas_size_str) 70 | else: 71 | size = int(s) 72 | canvas_size = size 73 | except ValueError: 74 | raise ValueError( 75 | f"'sizes' input '{s}' must be an integer or integers separated by ':'." 76 | ) 77 | elif isinstance(s, int): 78 | size = s 79 | canvas_size = s 80 | else: 81 | raise TypeError( 82 | "Input must be 'cursor_size:canvas_size' or an integer." 83 | ) 84 | 85 | res_img = self._image.resize((size, size), 1) 86 | res_hotspot = self._cal_hotspot(res_img) 87 | 88 | if size != canvas_size: 89 | canvas = Image.new("RGBA", (canvas_size, canvas_size), (0, 0, 0, 0)) 90 | canvas.paste(res_img, (0, 0)) 91 | res_img = canvas 92 | 93 | images.append( 94 | CursorImage( 95 | image=res_img, 96 | hotspot=res_hotspot, 97 | nominal=canvas_size, 98 | re_canvas=size != canvas_size, 99 | ) 100 | ) 101 | 102 | return [CursorFrame(images, delay=self.delay)] 103 | 104 | 105 | class MultiPNGParser(BaseParser): 106 | @classmethod 107 | def can_parse(cls, blobs: List[bytes]) -> bool: 108 | checks: List[bool] = [] 109 | for blob in blobs: 110 | checks.append(SinglePNGParser.can_parse(blob)) 111 | return all(checks) 112 | 113 | def __init__( 114 | self, 115 | blobs: List[bytes], 116 | hotspot: Tuple[int, int], 117 | sizes: Optional[List[Union[int, str]]] = None, 118 | delay: Optional[int] = None, 119 | ) -> None: 120 | super().__init__(blobs[0]) 121 | self.frames = [] 122 | for blob in blobs: 123 | png = SinglePNGParser(blob, hotspot, sizes, delay) 124 | self.frames.append(png.frames[0]) 125 | -------------------------------------------------------------------------------- /src/clickgen/parser/png.pyi: -------------------------------------------------------------------------------- 1 | from _typeshed import Incomplete 2 | from clickgen.cursors import CursorFrame as CursorFrame, CursorImage as CursorImage 3 | from clickgen.parser.base import BaseParser as BaseParser 4 | 5 | SIZES: Incomplete 6 | DELAY: int 7 | 8 | class SinglePNGParser(BaseParser): 9 | MAGIC: Incomplete 10 | @classmethod 11 | def can_parse(cls, blob: bytes) -> bool: ... 12 | sizes: Incomplete 13 | delay: Incomplete 14 | hotspot: Incomplete 15 | frames: Incomplete 16 | def __init__(self, blob: bytes, hotspot: tuple[int, int], sizes: list[int | str] | None = None, delay: int | None = None) -> None: ... 17 | 18 | class MultiPNGParser(BaseParser): 19 | @classmethod 20 | def can_parse(cls, blobs: list[bytes]) -> bool: ... 21 | frames: Incomplete 22 | def __init__(self, blobs: list[bytes], hotspot: tuple[int, int], sizes: list[int | str] | None = None, delay: int | None = None) -> None: ... 23 | -------------------------------------------------------------------------------- /src/clickgen/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/src/clickgen/py.typed -------------------------------------------------------------------------------- /src/clickgen/scripts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/src/clickgen/scripts/__init__.py -------------------------------------------------------------------------------- /src/clickgen/scripts/__init__.pyi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/clickgen/ce70d07a0de82795dbedb9ebda594e79cacd4148/src/clickgen/scripts/__init__.pyi -------------------------------------------------------------------------------- /src/clickgen/scripts/clickgen.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import argparse 5 | import os 6 | import sys 7 | import traceback 8 | from pathlib import Path 9 | from threading import Lock 10 | from typing import BinaryIO, List 11 | 12 | import clickgen 13 | from clickgen.parser import open_blob 14 | from clickgen.parser.png import DELAY, SIZES 15 | from clickgen.writer.windows import to_win 16 | from clickgen.writer.x11 import to_x11 17 | 18 | 19 | def main() -> None: 20 | parser = argparse.ArgumentParser( 21 | prog="clickgen", 22 | description="The hassle-free cursor building toolbox", 23 | ) 24 | 25 | parser.add_argument( 26 | "files", 27 | type=argparse.FileType("rb"), 28 | nargs="+", 29 | help="Cursor bitmap files to generate (*.png)", 30 | ) 31 | parser.add_argument( 32 | "-o", 33 | "--output", 34 | "--output-dir", 35 | default=os.curdir, 36 | help="Directory to store generated cursor file.", 37 | ) 38 | parser.add_argument( 39 | "-p", 40 | "--platform", 41 | choices=["windows", "x11", "all"], 42 | default="all", 43 | help="Platform for generated cursor file.", 44 | ) 45 | parser.add_argument( 46 | "-x", 47 | "--hotspot-x", 48 | type=int, 49 | default=0, 50 | help="x-offset of cursor (as fraction of width)", 51 | ) 52 | parser.add_argument( 53 | "-y", 54 | "--hotspot-y", 55 | type=int, 56 | default=0, 57 | help="y-offset of cursor (as fraction of height)", 58 | ) 59 | parser.add_argument( 60 | "-s", 61 | "--sizes", 62 | dest="sizes", 63 | nargs="+", 64 | default=SIZES, 65 | type=str, 66 | help="""Specify the cursor size(s) either as a single integer value or 67 | in the 'size:canvas_size' format to resize the cursor to a specific canvas size.""", 68 | ) 69 | parser.add_argument( 70 | "-d", 71 | "--delay", 72 | default=DELAY, 73 | type=int, 74 | help="Set delay between frames of cursor.", 75 | ) 76 | parser.add_argument( 77 | "-v", 78 | "--version", 79 | action="version", 80 | version=f"%(prog)s {clickgen.__version__}", # type: ignore 81 | ) 82 | 83 | args = parser.parse_args() 84 | print_lock = Lock() 85 | files: List[BinaryIO] = args.files 86 | 87 | hotspot = (args.hotspot_x, args.hotspot_y) 88 | name = Path(files[0].name.split("-")[0]) 89 | output = Path(args.output, name.stem) 90 | blobs: List[bytes] = [f.read() for f in files] 91 | 92 | try: 93 | cursor = open_blob(blobs, hotspot, args.sizes, args.delay) 94 | except Exception: 95 | with print_lock: 96 | print(f"Error occurred while processing {name.name}:", file=sys.stderr) 97 | traceback.print_exc() 98 | else: 99 | 100 | def gen_xcursor() -> None: 101 | result = to_x11(cursor.frames) 102 | output.write_bytes(result) 103 | 104 | def gen_wincursor() -> None: 105 | ext, result = to_win(cursor.frames) 106 | win_output = output.with_suffix(ext) 107 | win_output.write_bytes(result) 108 | 109 | if args.platform == "x11": 110 | gen_xcursor() 111 | elif args.platform == "windows": 112 | gen_wincursor() 113 | else: 114 | gen_xcursor() 115 | gen_wincursor() 116 | -------------------------------------------------------------------------------- /src/clickgen/scripts/clickgen.pyi: -------------------------------------------------------------------------------- 1 | from clickgen.parser import open_blob as open_blob 2 | from clickgen.parser.png import DELAY as DELAY, SIZES as SIZES 3 | from clickgen.writer.windows import to_win as to_win 4 | from clickgen.writer.x11 import to_x11 as to_x11 5 | 6 | def main() -> None: ... 7 | -------------------------------------------------------------------------------- /src/clickgen/scripts/ctgen.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import argparse 5 | import os 6 | import sys 7 | import traceback 8 | from contextlib import contextmanager 9 | from multiprocessing import cpu_count 10 | from multiprocessing.pool import ThreadPool 11 | from pathlib import Path 12 | from threading import Lock 13 | from typing import Any, Dict, Generator, List 14 | 15 | import clickgen 16 | from clickgen.configparser import parse_config_file 17 | from clickgen.libs.colors import ( 18 | blue, 19 | bold, 20 | cyan, 21 | fail, 22 | magenta, 23 | print_done, 24 | print_info, 25 | print_subtext, 26 | print_text, 27 | ) 28 | from clickgen.packer.windows import pack_win 29 | from clickgen.packer.x11 import pack_x11 30 | 31 | 32 | def get_kwargs(args) -> Dict[str, Any]: 33 | kwargs = {} 34 | if args.name: 35 | kwargs["name"] = args.name 36 | if args.comment: 37 | kwargs["comment"] = args.comment 38 | if args.website: 39 | kwargs["website"] = args.website 40 | if args.platforms: 41 | kwargs["platforms"] = args.platforms 42 | 43 | if args.sizes: 44 | kwargs["win_sizes"] = args.sizes 45 | kwargs["x11_sizes"] = args.sizes 46 | 47 | if args.bitmaps_dir: 48 | kwargs["bitmaps_dir"] = Path(args.bitmaps_dir) 49 | if args.out_dir: 50 | kwargs["out_dir"] = Path(args.out_dir) 51 | 52 | return kwargs 53 | 54 | 55 | @contextmanager 56 | def cwd(path) -> Generator[None, None, None]: 57 | oldpwd = os.getcwd() 58 | os.chdir(path) 59 | try: 60 | yield 61 | finally: 62 | os.chdir(oldpwd) 63 | 64 | 65 | def main() -> None: # noqa: C901 66 | parser = argparse.ArgumentParser( 67 | prog="ctgen", 68 | description=f"ctgen: {bold('C')}ursor {bold('T')}heme {bold('Gen')}erator. Clickgen CLI utility for crafting a whole cursor theme from .png files with a manifest file.'", 69 | ) 70 | 71 | parser.add_argument( 72 | "files", 73 | type=argparse.FileType("rb"), 74 | nargs="+", 75 | help="Config files (.toml,.yaml,.json) for generate cursor theme", 76 | ) 77 | 78 | parser.add_argument( 79 | "-n", 80 | "--theme-name", 81 | dest="name", 82 | type=str, 83 | default=None, 84 | help="Force rename cursor theme name.", 85 | ) 86 | 87 | parser.add_argument( 88 | "-c", 89 | "--theme-comment", 90 | dest="comment", 91 | type=str, 92 | default=None, 93 | help="Force rename comment of cursor theme.", 94 | ) 95 | 96 | parser.add_argument( 97 | "-w", 98 | "--theme-website", 99 | dest="website", 100 | type=str, 101 | default=None, 102 | help="Force rename website url of cursor theme.", 103 | ) 104 | 105 | parser.add_argument( 106 | "-d", 107 | "--bitmaps-dir", 108 | type=str, 109 | help="Force bitmaps directory location (which contains .png files).", 110 | ) 111 | 112 | parser.add_argument( 113 | "-o", 114 | "--out-dir", 115 | type=str, 116 | help="Change output directory.", 117 | ) 118 | 119 | parser.add_argument( 120 | "-s", 121 | "--sizes", 122 | dest="sizes", 123 | nargs="+", 124 | default=None, 125 | type=str, 126 | help="""Specify the cursor size(s) either as a single integer value or 127 | in the 'size:canvas_size' format to resize the cursor to a specific canvas size.""", 128 | ) 129 | 130 | parser.add_argument( 131 | "-p", 132 | "--platforms", 133 | choices=["windows", "x11"], 134 | default=None, 135 | help="Change Platform for output cursors.", 136 | ) 137 | 138 | parser.add_argument( 139 | "-v", 140 | "--version", 141 | action="version", 142 | version=f"%(prog)s {clickgen.__version__}", # type: ignore 143 | ) 144 | 145 | args = parser.parse_args() 146 | kwargs = get_kwargs(args) 147 | print_lock = Lock() 148 | 149 | files: List[Path] = [] 150 | for f in args.files: 151 | files.append(Path(f.name)) 152 | 153 | def process(file: Path) -> None: 154 | try: 155 | cfg = parse_config_file(file, **kwargs) 156 | except Exception: 157 | with print_lock: 158 | print( 159 | fail(f"Error occurred while processing {file.name}:"), 160 | file=sys.stderr, 161 | ) 162 | traceback.print_exc() 163 | else: 164 | theme = cfg.theme 165 | config = cfg.config 166 | cursors = cfg.cursors 167 | 168 | # Display Theme Info 169 | print_info("Parsing Metadata:") 170 | print_text(f"Cursor Package: {bold(cyan(theme.name))}") 171 | print_text(f"Comment: {theme.comment}") 172 | print_text(f"Platform Compliblity: {config.platforms}") 173 | print_done("Metadata Parsing") 174 | 175 | # Generating XCursor 176 | if "x11" in config.platforms: 177 | print_info("Generating XCursors:") 178 | 179 | x11_out_dir = config.out_dir / theme.name / "cursors" 180 | x11_out_dir.mkdir(parents=True, exist_ok=True) 181 | 182 | for c in cursors: 183 | if c.x11_cursor and c.x11_cursor_name: 184 | print_text(f"Bitmaping '{blue(c.x11_cursor_name)}'") 185 | x_cursor = x11_out_dir / c.x11_cursor_name 186 | x_cursor.write_bytes(c.x11_cursor) 187 | 188 | # Creating symlinks 189 | with cwd(x11_out_dir): 190 | for link in c.x11_symlinks: 191 | print_subtext( 192 | f"Linking '{magenta(link)}' with '{c.x11_cursor_name}'" 193 | ) 194 | os.symlink(x_cursor.name, link) 195 | 196 | print_done("XCursors Generation") 197 | 198 | pack_x11(x11_out_dir.parent, theme.name, theme.comment) 199 | print_done("Packaging XCursors") 200 | 201 | # Generating Windows cursors 202 | if "windows" in config.platforms: 203 | print_info("Generating Windows Cursors:") 204 | 205 | win_out_dir = config.out_dir / f"{theme.name}-Windows" 206 | win_out_dir.mkdir(parents=True, exist_ok=True) 207 | 208 | for c in cursors: 209 | if c.win_cursor and c.win_cursor_name: 210 | print_text(f"Bitmaping '{magenta(c.win_cursor_name)}'") 211 | win_cursor = win_out_dir / c.win_cursor_name 212 | win_cursor.write_bytes(c.win_cursor) 213 | 214 | print_done("Windows Cursors Generation") 215 | 216 | pack_win(win_out_dir, theme.name, theme.comment, theme.website) 217 | print_done("Packaging Windows Cursors") 218 | 219 | with ThreadPool(cpu_count()) as pool: 220 | pool.map(process, files) 221 | -------------------------------------------------------------------------------- /src/clickgen/scripts/ctgen.pyi: -------------------------------------------------------------------------------- 1 | from clickgen.configparser import parse_config_file as parse_config_file 2 | from clickgen.libs.colors import blue as blue, bold as bold, cyan as cyan, fail as fail, magenta as magenta, print_done as print_done, print_info as print_info, print_subtext as print_subtext, print_text as print_text 3 | from clickgen.packer.windows import pack_win as pack_win 4 | from clickgen.packer.x11 import pack_x11 as pack_x11 5 | from typing import Any, Generator 6 | 7 | def get_kwargs(args) -> dict[str, Any]: ... 8 | def cwd(path) -> Generator[None, None, None]: ... 9 | def main() -> None: ... 10 | -------------------------------------------------------------------------------- /src/clickgen/writer/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from clickgen.writer.windows import to_cur, to_win 5 | from clickgen.writer.x11 import to_x11 6 | 7 | __all__ = ["to_x11", "to_cur", "to_win"] 8 | -------------------------------------------------------------------------------- /src/clickgen/writer/__init__.pyi: -------------------------------------------------------------------------------- 1 | from clickgen.writer.windows import to_cur as to_cur, to_win as to_win 2 | from clickgen.writer.x11 import to_x11 as to_x11 3 | 4 | __all__ = ['to_x11', 'to_cur', 'to_win'] 5 | -------------------------------------------------------------------------------- /src/clickgen/writer/windows.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import struct 5 | from io import BytesIO 6 | from itertools import chain 7 | from typing import List, Tuple 8 | 9 | from PIL import Image 10 | 11 | from clickgen.cursors import CursorFrame 12 | 13 | # .CUR FILE FORMAT 14 | MAGIC = b"\0\0\02\0" 15 | ICO_TYPE_CUR = 2 16 | ICON_DIR = struct.Struct(" bytes: 21 | header = ICON_DIR.pack(0, ICO_TYPE_CUR, len(frame)) 22 | directory: List[bytes] = [] 23 | image_data: List[bytes] = [] 24 | offset = ICON_DIR.size + len(frame) * ICON_DIR_ENTRY.size 25 | 26 | def re_canvas(size: int, img: Image.Image): 27 | canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0)) 28 | canvas.paste(img, (0, 0)) 29 | return canvas 30 | 31 | for image in frame: 32 | clone = image.image.copy() 33 | width, height = clone.size 34 | if width > 256 or height > 256: 35 | raise ValueError(f"Image too big for CUR format: {width}x{height}") 36 | 37 | # Resize cursor canvas to prevent blurriness 38 | # Bug Report: https://github.com/ful1e5/Bibata_Cursor/issues/149 39 | # 40 | # | size | Regular (× ²⁄₃) | Large (× ⁴⁄₅) | Extra-Large (× 1) | 41 | # | ---: | --------------: | ------------: | ----------------: | 42 | # | 32 | 21.333 → 22 | 25.6 → 26 | 32 | 43 | # | 48 | 32 | 38.4 → 39 | 48 | 44 | # | 64 | 42.666 → 43 | 51.2 → 52 | 64 | 45 | # | 96 | 64 | 76.8 → 77 | 96 | 46 | # | 128 | 85.333 → 86 | 102.4 → 103 | 128 | 47 | # | 256 | 170.666 → 171 | 204.8 → 205 | 256 | 48 | 49 | blob = BytesIO() 50 | if not image.re_canvas: 51 | if width <= 32 or height <= 32: 52 | re_canvas(32, clone).save(blob, "PNG") 53 | elif width <= 48 or height <= 48: 54 | re_canvas(48, clone).save(blob, "PNG") 55 | elif width <= 64 or height <= 64: 56 | re_canvas(64, clone).save(blob, "PNG") 57 | elif width <= 96 or height <= 96: 58 | re_canvas(96, clone).save(blob, "PNG") 59 | elif width <= 128 or height <= 128: 60 | re_canvas(128, clone).save(blob, "PNG") 61 | else: 62 | re_canvas(256, clone).save(blob, "PNG") 63 | else: 64 | image.image.save(blob, "PNG") 65 | 66 | blob.seek(0) 67 | image_data.append(blob.read()) 68 | x_offset, y_offset = image.hotspot 69 | directory.append( 70 | ICON_DIR_ENTRY.pack( 71 | height & 0xFF, 72 | height & 0xFF, 73 | 0, 74 | 0, 75 | x_offset, 76 | y_offset, 77 | blob.getbuffer().nbytes, 78 | offset, 79 | ) 80 | ) 81 | offset += blob.getbuffer().nbytes 82 | 83 | return b"".join(chain([header], directory, image_data)) 84 | 85 | 86 | # .ANI FILE FORMAT 87 | SIGNATURE = b"RIFF" 88 | ANI_TYPE = b"ACON" 89 | HEADER_CHUNK = b"anih" 90 | LIST_CHUNK = b"LIST" 91 | SEQ_CHUNK = b"seq " 92 | RATE_CHUNK = b"rate" 93 | FRAME_TYPE = b"fram" 94 | ICON_CHUNK = b"icon" 95 | RIFF_HEADER = struct.Struct("<4sI4s") 96 | CHUNK_HEADER = struct.Struct("<4sI") 97 | ANIH_HEADER = struct.Struct(" bytes: 104 | io = BytesIO() 105 | for frame in frames: 106 | cur_file = to_cur(frame) 107 | io.write(CHUNK_HEADER.pack(ICON_CHUNK, len(cur_file))) 108 | io.write(cur_file) 109 | if len(cur_file) & 1: 110 | io.write(b"\0") 111 | return io.getvalue() 112 | 113 | 114 | def get_ani_rate_chunk(frames: List[CursorFrame]) -> bytes: 115 | io = BytesIO() 116 | io.write(CHUNK_HEADER.pack(RATE_CHUNK, UNSIGNED.size * len(frames))) 117 | for frame in frames: 118 | io.write(UNSIGNED.pack(int(round(frame.delay * 2)))) 119 | return io.getvalue() 120 | 121 | 122 | def to_ani(frames: List[CursorFrame]) -> bytes: 123 | ani_header = ANIH_HEADER.pack( 124 | ANIH_HEADER.size, len(frames), len(frames), 0, 0, 32, 1, 1, ICON_FLAG 125 | ) 126 | 127 | cur_list = get_ani_cur_list(frames) 128 | chunks = [ 129 | CHUNK_HEADER.pack(HEADER_CHUNK, len(ani_header)), 130 | ani_header, 131 | RIFF_HEADER.pack(LIST_CHUNK, len(cur_list) + 4, FRAME_TYPE), 132 | cur_list, 133 | get_ani_rate_chunk(frames), 134 | ] 135 | body = b"".join(chunks) 136 | riff_header: bytes = RIFF_HEADER.pack(SIGNATURE, len(body) + 4, ANI_TYPE) 137 | return riff_header + body 138 | 139 | 140 | def to_win(frames: List[CursorFrame]) -> Tuple[str, bytes]: 141 | if len(frames) == 1: 142 | return ".cur", to_cur(frames[0]) 143 | else: 144 | return ".ani", to_ani(frames) 145 | -------------------------------------------------------------------------------- /src/clickgen/writer/windows.pyi: -------------------------------------------------------------------------------- 1 | from _typeshed import Incomplete 2 | from clickgen.cursors import CursorFrame as CursorFrame 3 | 4 | MAGIC: bytes 5 | ICO_TYPE_CUR: int 6 | ICON_DIR: Incomplete 7 | ICON_DIR_ENTRY: Incomplete 8 | 9 | def to_cur(frame: CursorFrame) -> bytes: ... 10 | 11 | SIGNATURE: bytes 12 | ANI_TYPE: bytes 13 | HEADER_CHUNK: bytes 14 | LIST_CHUNK: bytes 15 | SEQ_CHUNK: bytes 16 | RATE_CHUNK: bytes 17 | FRAME_TYPE: bytes 18 | ICON_CHUNK: bytes 19 | RIFF_HEADER: Incomplete 20 | CHUNK_HEADER: Incomplete 21 | ANIH_HEADER: Incomplete 22 | UNSIGNED: Incomplete 23 | SEQUENCE_FLAG: int 24 | ICON_FLAG: int 25 | 26 | def get_ani_cur_list(frames: list[CursorFrame]) -> bytes: ... 27 | def get_ani_rate_chunk(frames: list[CursorFrame]) -> bytes: ... 28 | def to_ani(frames: list[CursorFrame]) -> bytes: ... 29 | def to_win(frames: list[CursorFrame]) -> tuple[str, bytes]: ... 30 | -------------------------------------------------------------------------------- /src/clickgen/writer/x11.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import struct 5 | from itertools import chain 6 | from operator import itemgetter 7 | from typing import Any, List 8 | 9 | import numpy as np 10 | 11 | from clickgen.cursors import CursorFrame 12 | 13 | # XCURSOR FILE FORMAT 14 | MAGIC = b"Xcur" 15 | VERSION = 0x1_0000 16 | FILE_HEADER = struct.Struct("<4sIII") 17 | TOC_CHUNK = struct.Struct(" bytes: 23 | buffer: np.ndarray[Any, np.dtype[np.double]] = np.frombuffer( 24 | source, dtype=np.uint8 25 | ).astype(np.double) 26 | alpha = buffer[3::4] / 255.0 27 | buffer[0::4] *= alpha 28 | buffer[1::4] *= alpha 29 | buffer[2::4] *= alpha 30 | return buffer.astype(np.uint8).tobytes() 31 | 32 | 33 | def to_x11(frames: List[CursorFrame]) -> bytes: 34 | chunks = [] 35 | 36 | for frame in frames: 37 | for cursor in frame: 38 | hx, hy = cursor.hotspot 39 | header = IMAGE_HEADER.pack( 40 | IMAGE_HEADER.size, 41 | CHUNK_IMAGE, 42 | cursor.nominal, 43 | 1, 44 | cursor.image.width, 45 | cursor.image.height, 46 | hx, 47 | hy, 48 | int(frame.delay), 49 | ) 50 | chunks.append( 51 | ( 52 | CHUNK_IMAGE, 53 | cursor.nominal, 54 | header + premultiply_alpha(cursor.image.tobytes("raw", "BGRA")), 55 | ) 56 | ) 57 | 58 | header = FILE_HEADER.pack( 59 | MAGIC, 60 | FILE_HEADER.size, 61 | VERSION, 62 | len(chunks), 63 | ) 64 | 65 | offset = FILE_HEADER.size + len(chunks) * TOC_CHUNK.size 66 | toc = [] 67 | for chunk_type, chunk_subtype, chunk in chunks: 68 | toc.append( 69 | TOC_CHUNK.pack( 70 | chunk_type, 71 | chunk_subtype, 72 | offset, 73 | ) 74 | ) 75 | offset += len(chunk) 76 | 77 | return b"".join(chain([header], toc, map(itemgetter(2), chunks))) 78 | -------------------------------------------------------------------------------- /src/clickgen/writer/x11.pyi: -------------------------------------------------------------------------------- 1 | from _typeshed import Incomplete 2 | from clickgen.cursors import CursorFrame as CursorFrame 3 | 4 | MAGIC: bytes 5 | VERSION: int 6 | FILE_HEADER: Incomplete 7 | TOC_CHUNK: Incomplete 8 | CHUNK_IMAGE: int 9 | IMAGE_HEADER: Incomplete 10 | 11 | def premultiply_alpha(source: bytes) -> bytes: ... 12 | def to_x11(frames: list[CursorFrame]) -> bytes: ... 13 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import io 2 | from pathlib import Path 3 | from typing import List, Tuple 4 | 5 | import pytest 6 | from PIL.Image import Image, open 7 | 8 | from clickgen.cursors import CursorFrame, CursorImage 9 | 10 | 11 | @pytest.fixture 12 | def samples_dir() -> Path: 13 | return Path(__file__).parents[1] / "samples" 14 | 15 | 16 | @pytest.fixture 17 | def blob(samples_dir) -> bytes: 18 | pointer_png = samples_dir / "pngs/pointer.png" 19 | return pointer_png.read_bytes() 20 | 21 | 22 | @pytest.fixture 23 | def blobs(blob) -> List[bytes]: 24 | return [blob, blob] 25 | 26 | 27 | @pytest.fixture 28 | def dummy_blob(samples_dir) -> bytes: 29 | txt = samples_dir / "sample.toml" 30 | return txt.read_bytes() 31 | 32 | 33 | @pytest.fixture 34 | def dummy_blobs(dummy_blob) -> List[bytes]: 35 | return [dummy_blob, dummy_blob] 36 | 37 | 38 | @pytest.fixture 39 | def image(blob) -> Image: 40 | return open(io.BytesIO(blob)) 41 | 42 | 43 | @pytest.fixture 44 | def hotspot() -> Tuple[int, int]: 45 | return (100, 105) 46 | 47 | 48 | @pytest.fixture 49 | def nominal() -> int: 50 | return 24 51 | 52 | 53 | @pytest.fixture 54 | def cursor_image(image, hotspot, nominal) -> CursorImage: 55 | return CursorImage(image, hotspot, nominal) 56 | 57 | 58 | @pytest.fixture 59 | def images(cursor_image) -> List[CursorImage]: 60 | return [cursor_image, cursor_image, cursor_image] 61 | 62 | 63 | @pytest.fixture 64 | def sizes() -> List[int]: 65 | return [12, 12, 24] 66 | 67 | 68 | @pytest.fixture 69 | def delay() -> int: 70 | return 5 71 | 72 | 73 | @pytest.fixture 74 | def cursor_frame(images, delay) -> CursorFrame: 75 | return CursorFrame(images, delay) 76 | 77 | 78 | @pytest.fixture 79 | def theme_name() -> str: 80 | return "test" 81 | 82 | 83 | @pytest.fixture 84 | def comment() -> str: 85 | return "comment" 86 | 87 | 88 | @pytest.fixture 89 | def website() -> str: 90 | return "https://www.example.com" 91 | 92 | 93 | @pytest.fixture(scope="session") 94 | def x11_tmp_dir(tmpdir_factory) -> Path: 95 | return Path(tmpdir_factory.mktemp("x11_tmp")) 96 | 97 | 98 | @pytest.fixture(scope="session") 99 | def win_cur_tmp_dir(tmpdir_factory) -> Path: 100 | p = Path(tmpdir_factory.mktemp("x11_tmp")) 101 | for f in ["test1", "test2", "test3"]: 102 | cfile = p / f"{f}.cur" 103 | cfile.write_text("test win cursors") 104 | return p 105 | 106 | 107 | @pytest.fixture(scope="session") 108 | def win_ani_tmp_dir(tmpdir_factory) -> Path: 109 | p = Path(tmpdir_factory.mktemp("x11_tmp")) 110 | for f in ["test1", "test2", "test3"]: 111 | cfile = p / f"{f}.ani" 112 | cfile.write_text("test win cursors") 113 | return p 114 | -------------------------------------------------------------------------------- /tests/packer/test_windows_packer.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from clickgen.packer import pack_win 4 | 5 | 6 | def test_windows_packer_with_cur(win_cur_tmp_dir: Path, theme_name, comment, website): 7 | pack_win(win_cur_tmp_dir, theme_name, comment, website) 8 | 9 | install_inf = win_cur_tmp_dir / "install.inf" 10 | uninstall_bat = win_cur_tmp_dir / "uninstall.bat" 11 | assert install_inf.exists() 12 | assert uninstall_bat.exists() 13 | install_data = install_inf.read_text() 14 | 15 | assert theme_name in install_data 16 | assert comment in install_data 17 | assert website in install_data 18 | 19 | for f in win_cur_tmp_dir.glob("*.cur"): 20 | print(f) 21 | assert f.name in install_data 22 | 23 | 24 | def test_windows_packer_with_ani(win_ani_tmp_dir: Path, theme_name, comment, website): 25 | pack_win(win_ani_tmp_dir, theme_name, comment, website) 26 | 27 | install_inf = win_ani_tmp_dir / "install.inf" 28 | install_data = install_inf.read_text() 29 | 30 | for f in win_ani_tmp_dir.glob("*.ani"): 31 | assert f.name in install_data 32 | -------------------------------------------------------------------------------- /tests/packer/test_x11_packer.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from clickgen.packer import pack_x11 4 | 5 | 6 | def test_x11_packer(x11_tmp_dir: Path): 7 | pack_x11(x11_tmp_dir, theme_name="test", comment="test") 8 | 9 | f1 = x11_tmp_dir / "cursor.theme" 10 | f2 = x11_tmp_dir / "index.theme" 11 | 12 | assert f1.exists() 13 | assert f2.exists() 14 | 15 | assert f1.read_text() == '[Icon Theme]\nName=test\nInherits="test"' 16 | assert f2.read_text() == '[Icon Theme]\nName=test\nComment=test\nInherits="hicolor"' 17 | -------------------------------------------------------------------------------- /tests/parser/test_base_parser.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import pytest 4 | 5 | from clickgen.parser.base import BaseParser 6 | 7 | 8 | def test_base_parser(blob): 9 | p = patch.multiple(BaseParser, __abstractmethods__=set()) 10 | p.start() 11 | 12 | b = BaseParser(blob) # type: ignore 13 | 14 | assert isinstance(b.blob, bytes) 15 | assert b.blob is blob 16 | 17 | with pytest.raises(AttributeError): 18 | b.frames 19 | 20 | with pytest.raises(NotImplementedError): 21 | b.can_parse(blob) 22 | 23 | p.stop() 24 | -------------------------------------------------------------------------------- /tests/parser/test_png_parser.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from clickgen.cursors import CursorFrame, CursorImage 4 | from clickgen.parser.png import SIZES, MultiPNGParser, SinglePNGParser 5 | 6 | 7 | def test_single_png_parser(blob, hotspot, sizes, delay): 8 | p = SinglePNGParser(blob, hotspot, sizes=sizes, delay=delay) 9 | 10 | sizes = [12, 24] 11 | assert sorted(list(p.sizes)) == sizes 12 | 13 | assert p.delay == delay 14 | 15 | for i, s in enumerate(sizes): 16 | assert isinstance(p.frames[0], CursorFrame) 17 | assert isinstance(p.frames[0][i], CursorImage) 18 | assert p.frames[0][i].nominal == s 19 | assert p.frames[0][i].image.size == (s, s) 20 | 21 | assert p.frames[0][0].hotspot == (6, 6) 22 | assert p.frames[0][1].hotspot == (12, 12) 23 | 24 | with pytest.raises(IndexError): 25 | p.frames[1] 26 | 27 | 28 | def test_single_png_parser_default_args(blob, hotspot): 29 | p = SinglePNGParser(blob, hotspot) 30 | assert p.delay == 0 31 | assert sorted(list(p.sizes)) == SIZES 32 | 33 | 34 | def test_single_png_parser_can_parse(blob, dummy_blob): 35 | assert SinglePNGParser.can_parse(blob) 36 | assert not SinglePNGParser.can_parse(dummy_blob) 37 | 38 | 39 | def test_single_png_parser_raises_01(blob): 40 | with pytest.raises(ValueError): 41 | SinglePNGParser(blob, hotspot=(201, 201)) 42 | with pytest.raises(ValueError): 43 | SinglePNGParser(blob, hotspot=(100, 201)) 44 | with pytest.raises(ValueError): 45 | SinglePNGParser(blob, hotspot=(201, 100)) 46 | 47 | 48 | def test_single_png_parser_raises_size_error_for_canvasing(blob): 49 | with pytest.raises(ValueError): 50 | SinglePNGParser(blob, hotspot=(3, 3), sizes=["test"]) 51 | with pytest.raises(ValueError): 52 | SinglePNGParser(blob, hotspot=(3, 3), sizes=["20:"]) 53 | with pytest.raises(ValueError): 54 | SinglePNGParser(blob, hotspot=(3, 3), sizes=[":20"]) 55 | 56 | with pytest.raises(TypeError): 57 | SinglePNGParser(blob, hotspot=(3, 3), sizes=[(20, 20)]) 58 | 59 | 60 | def test_multi_png_parser(blobs, hotspot, sizes, delay): 61 | p = MultiPNGParser(blobs, hotspot, sizes, delay) 62 | 63 | assert p.frames[0].delay == delay 64 | assert p.frames[1].delay == delay 65 | 66 | for i in [0, 1]: 67 | for j, s in enumerate(sorted(set(sizes))): 68 | assert isinstance(p.frames[i], CursorFrame) 69 | assert isinstance(p.frames[i][j], CursorImage) 70 | assert p.frames[i][j].nominal == s 71 | assert p.frames[i][j].image.size == (s, s) 72 | 73 | 74 | def test_multi_png_parser_can_parse(blobs, dummy_blobs): 75 | assert MultiPNGParser.can_parse(blobs) 76 | assert not SinglePNGParser.can_parse(dummy_blobs) 77 | -------------------------------------------------------------------------------- /tests/scripts/test_clickgen_script.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from unittest import mock 3 | 4 | from clickgen.parser.png import DELAY, SIZES 5 | from clickgen.scripts.clickgen import main 6 | 7 | 8 | def test_clickgen_all_cursor_build(samples_dir, x11_tmp_dir, hotspot): 9 | fp = samples_dir / "pngs/pointer.png" 10 | with open(fp, "rb") as f: 11 | with mock.patch( 12 | "argparse.ArgumentParser.parse_args", 13 | return_value=argparse.Namespace( 14 | files=[f], 15 | output=x11_tmp_dir, 16 | hotspot_x=hotspot[0], 17 | hotspot_y=hotspot[1], 18 | sizes=SIZES, 19 | delay=DELAY, 20 | platform="all", 21 | ), 22 | ): 23 | main() 24 | 25 | 26 | def test_clickgen_x11_build(samples_dir, x11_tmp_dir, hotspot): 27 | fp = samples_dir / "pngs/pointer.png" 28 | with open(fp, "rb") as f: 29 | with mock.patch( 30 | "argparse.ArgumentParser.parse_args", 31 | return_value=argparse.Namespace( 32 | files=[f], 33 | output=x11_tmp_dir, 34 | hotspot_x=hotspot[0], 35 | hotspot_y=hotspot[1], 36 | sizes=SIZES, 37 | delay=DELAY, 38 | platform="x11", 39 | ), 40 | ): 41 | main() 42 | 43 | 44 | def test_clickgen_windows_build(samples_dir, x11_tmp_dir, hotspot): 45 | fp = samples_dir / "pngs/pointer.png" 46 | with open(fp, "rb") as f: 47 | with mock.patch( 48 | "argparse.ArgumentParser.parse_args", 49 | return_value=argparse.Namespace( 50 | files=[f], 51 | output=x11_tmp_dir, 52 | hotspot_x=hotspot[0], 53 | hotspot_y=hotspot[1], 54 | sizes=SIZES, 55 | delay=DELAY, 56 | platform="windows", 57 | ), 58 | ): 59 | main() 60 | 61 | 62 | def test_clickgen_raises(capsys, samples_dir, x11_tmp_dir, hotspot): 63 | fp = samples_dir / "sample.toml" 64 | with open(fp, "rb") as f: 65 | with mock.patch( 66 | "argparse.ArgumentParser.parse_args", 67 | return_value=argparse.Namespace( 68 | files=[f], 69 | output=x11_tmp_dir, 70 | hotspot_x=hotspot[0], 71 | hotspot_y=hotspot[1], 72 | sizes=SIZES, 73 | delay=DELAY, 74 | platform="all", 75 | ), 76 | ): 77 | main() 78 | captured = capsys.readouterr() 79 | assert "Error occurred while processing sample.toml" in captured.err 80 | -------------------------------------------------------------------------------- /tests/scripts/test_ctgen_script.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | from pathlib import Path 4 | from unittest import mock 5 | 6 | from clickgen.scripts.ctgen import cwd, get_kwargs, main 7 | 8 | 9 | def test_get_kwargs(): 10 | d1 = { 11 | "name": "test", 12 | "comment": "test", 13 | "website": "test", 14 | "platforms": "test", 15 | "sizes": [10, 10], 16 | "bitmaps_dir": "test", 17 | "out_dir": "test", 18 | } 19 | args1 = argparse.Namespace(**d1) 20 | res1 = get_kwargs(args1) 21 | 22 | assert isinstance(res1, dict) 23 | assert res1["name"] == d1["name"] 24 | assert isinstance(res1["bitmaps_dir"], Path) 25 | assert isinstance(res1["out_dir"], Path) 26 | assert res1["x11_sizes"] == d1["sizes"] 27 | assert res1["win_sizes"] == d1["sizes"] 28 | 29 | for k in ["name", "bitmaps_dir", "out_dir", "sizes"]: 30 | del d1[k] 31 | for i, v in d1.items(): 32 | assert res1[i] == v 33 | 34 | 35 | def test_cwd(x11_tmp_dir: Path): 36 | current_dir = os.getcwd() 37 | with cwd(x11_tmp_dir): # type: ignore 38 | assert os.getcwd() == str(x11_tmp_dir) 39 | assert os.getcwd() == current_dir 40 | 41 | 42 | def test_ctgen_file_exception(samples_dir, x11_tmp_dir, capsys): 43 | fp = samples_dir / "pngs/pointer.png" 44 | with open(fp, "rb") as f: 45 | with mock.patch( 46 | "argparse.ArgumentParser.parse_args", 47 | return_value=argparse.Namespace( 48 | files=[f], 49 | name=None, 50 | comment=None, 51 | website=None, 52 | platforms=["x11"], 53 | sizes=None, 54 | bitmaps_dir=None, 55 | out_dir=x11_tmp_dir, 56 | ), 57 | ): 58 | main() 59 | captured = capsys.readouterr() 60 | assert "Error occurred while processing pointer.png:" in captured.err 61 | 62 | 63 | def test_ctgen_with_x11_platform(samples_dir, x11_tmp_dir): 64 | fp = samples_dir / "sample.toml" 65 | with open(fp, "rb") as f: 66 | with mock.patch( 67 | "argparse.ArgumentParser.parse_args", 68 | return_value=argparse.Namespace( 69 | files=[f], 70 | name=None, 71 | comment=None, 72 | website=None, 73 | platforms=["x11"], 74 | sizes=None, 75 | bitmaps_dir=None, 76 | out_dir=x11_tmp_dir, 77 | ), 78 | ): 79 | main() 80 | 81 | 82 | def test_ctgen_with_windows_platform(samples_dir, win_cur_tmp_dir: Path): 83 | fp = samples_dir / "sample.toml" 84 | with open(fp, "rb") as f: 85 | with mock.patch( 86 | "argparse.ArgumentParser.parse_args", 87 | return_value=argparse.Namespace( 88 | files=[f], 89 | name=None, 90 | comment=None, 91 | website=None, 92 | platforms=["windows"], 93 | sizes=None, 94 | bitmaps_dir=None, 95 | out_dir=win_cur_tmp_dir, 96 | ), 97 | ): 98 | main() 99 | -------------------------------------------------------------------------------- /tests/test_configparser.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | from clickgen.configparser import ( 6 | ClickgenConfig, 7 | parse_config_file, 8 | parse_config_section, 9 | parse_cursors_section, 10 | parse_json_file, 11 | parse_theme_section, 12 | parse_toml_file, 13 | parse_yaml_file, 14 | ) 15 | 16 | td = {"theme": {"name": "test", "comment": "test", "website": "test"}} 17 | 18 | 19 | def test_parse_theme_section(): 20 | t = parse_theme_section(td) 21 | assert t.name == "test" 22 | assert t.comment == "test" 23 | assert t.website == "test" 24 | 25 | 26 | def test_parse_theme_section_with_kwargs(): 27 | kwargs = {"name": "new", "comment": "new", "website": "new"} 28 | t = parse_theme_section(td, **kwargs) 29 | assert t.name == kwargs["name"] 30 | assert t.comment == kwargs["comment"] 31 | assert t.website == kwargs["website"] 32 | 33 | 34 | dd1 = { 35 | "config": { 36 | "bitmaps_dir": "test", 37 | "out_dir": "test", 38 | "platforms": "test", 39 | "x11_sizes": 10, 40 | "win_size": 11, 41 | } 42 | } 43 | 44 | 45 | def test_win_size_deprecation_message(capsys): 46 | parse_config_section(Path(), dd1) 47 | 48 | captured = capsys.readouterr() 49 | assert ( 50 | "The 'win_size' option is deprecated." 51 | " Please use 'win_sizes' within individual cursor settings or set it to '[cursor.fallback_settings]'." 52 | " For more information, visit: https://github.com/ful1e5/clickgen/discussions/59#discussioncomment-6747666" 53 | in captured.out 54 | ) 55 | 56 | 57 | def test_x11_sizes_deprecation_message(capsys): 58 | parse_config_section(Path(), dd1) 59 | 60 | captured = capsys.readouterr() 61 | assert ( 62 | "The 'x11_sizes' option is deprecated." 63 | " Please use 'x11_sizes' within individual cursor settings or set it to '[cursor.fallback_settings]'." 64 | " For more information, visit: https://github.com/ful1e5/clickgen/discussions/59#discussioncomment-6747666" 65 | in captured.out 66 | ) 67 | 68 | 69 | dd2 = { 70 | "config": { 71 | "bitmaps_dir": "test", 72 | "out_dir": "test", 73 | "platforms": "test", 74 | } 75 | } 76 | 77 | 78 | def test_parse_config_section(): 79 | c = parse_config_section(Path(), dd2) 80 | assert isinstance(c.bitmaps_dir, Path) 81 | assert c.bitmaps_dir.name == "test" 82 | assert c.bitmaps_dir.is_absolute() 83 | assert isinstance(c.out_dir, Path) 84 | assert c.out_dir.name == "test" 85 | assert c.out_dir.is_absolute() 86 | 87 | assert c.platforms == "test" 88 | 89 | 90 | def test_parse_config_section_with_absolute_paths(): 91 | dd2["config"]["bitmaps_dir"] = str(Path("test").absolute()) 92 | dd2["config"]["out_dir"] = str(Path("test").absolute()) 93 | 94 | c = parse_config_section(Path(), dd2) 95 | 96 | assert isinstance(c.bitmaps_dir, Path) 97 | assert c.bitmaps_dir.is_absolute() 98 | assert str(c.bitmaps_dir) == dd2["config"]["bitmaps_dir"] 99 | assert isinstance(c.out_dir, Path) 100 | assert str(c.out_dir) == dd2["config"]["out_dir"] 101 | 102 | 103 | def test_parse_config_section_with_kwargs(): 104 | kwargs = { 105 | "bitmaps_dir": "new", 106 | "out_dir": "new", 107 | "platforms": "new", 108 | } 109 | 110 | c = parse_config_section(Path(), dd2, **kwargs) 111 | assert c.bitmaps_dir == kwargs["bitmaps_dir"] 112 | assert c.out_dir == kwargs["out_dir"] 113 | assert c.platforms == kwargs["platforms"] 114 | 115 | 116 | def assert_clickgen_config(c: ClickgenConfig): 117 | assert c.theme.name == "Sample" 118 | assert c.theme.comment == "This is sample cursor theme" 119 | assert c.theme.website == "https://www.example.com/" 120 | 121 | x11_list = [ 122 | "pointer1", 123 | "pointer2", 124 | "pointer3", 125 | "pointer4", 126 | "pointer5", 127 | "pointer6", 128 | "pointer7", 129 | "pointer8", 130 | "pointer9", 131 | "pointer10", 132 | "pointer11", 133 | "pointer12", 134 | "pointer13", 135 | "wait1", 136 | "wait2", 137 | ] 138 | win_list = [ 139 | "Default.cur", 140 | "Alternate.cur", 141 | "Cross.cur", 142 | "Diagonal_1.cur", 143 | "Diagonal_2.cur", 144 | "Handwriting.cur", 145 | "Help.cur", 146 | "Horizontal.cur", 147 | "IBeam.cur", 148 | "Link.cur", 149 | "Move.cur", 150 | "Unavailiable.cur", 151 | "Vertical.cur", 152 | "Busy.ani", 153 | "Work.ani", 154 | ] 155 | 156 | for cur in c.cursors: 157 | assert cur.win_cursor_name in win_list 158 | assert cur.x11_cursor_name in x11_list 159 | 160 | 161 | def test_parse_toml_file(samples_dir: Path): 162 | fp = samples_dir / "sample.toml" 163 | c: ClickgenConfig = parse_toml_file(fp) 164 | assert_clickgen_config(c) 165 | 166 | 167 | def test_parse_yaml_file(samples_dir: Path): 168 | fp = samples_dir / "sample.yaml" 169 | c: ClickgenConfig = parse_yaml_file(fp) 170 | assert_clickgen_config(c) 171 | 172 | 173 | def test_parse_json_file(samples_dir: Path): 174 | fp = samples_dir / "sample.json" 175 | c: ClickgenConfig = parse_json_file(fp) 176 | assert_clickgen_config(c) 177 | 178 | 179 | def test_parse_config_files(samples_dir: Path): 180 | for ext in ["json", "yaml", "toml"]: 181 | fp = samples_dir / f"sample.{ext}" 182 | c: ClickgenConfig = parse_config_file(fp) 183 | assert_clickgen_config(c) 184 | 185 | 186 | def test_parse_cursor_section_handles_png_not_found_exception(): 187 | exp_dd1 = dd1 188 | exp_dd1["cursors"] = { 189 | "fallback_settings": {}, 190 | "bitmap1": {"png": "test.png", "x11_name": "test", "win_name": "test"}, 191 | } 192 | 193 | c = parse_config_section(Path(), dd2) 194 | 195 | with pytest.raises(FileNotFoundError): 196 | parse_cursors_section(exp_dd1, c) 197 | -------------------------------------------------------------------------------- /tests/test_cursors.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from clickgen.cursors import CursorFrame, CursorImage 4 | 5 | 6 | def test_cursor_image(cursor_image, image, hotspot, nominal): 7 | assert isinstance(cursor_image, CursorImage) 8 | 9 | assert cursor_image.image is image 10 | assert cursor_image.hotspot is hotspot 11 | assert cursor_image.nominal is nominal 12 | 13 | assert "hotspot=(100, 105)" in repr(cursor_image) 14 | assert "nominal=24" in repr(cursor_image) 15 | 16 | 17 | def test_cursor_frame(cursor_frame, cursor_image, images, delay): 18 | assert isinstance(cursor_frame, CursorFrame) 19 | 20 | assert cursor_frame.images is images 21 | assert cursor_frame.delay is delay 22 | 23 | assert len(cursor_frame) == 3 24 | 25 | for i in range(10): 26 | if i > 2: 27 | with pytest.raises(IndexError): 28 | cursor_frame.__getitem__(i) 29 | else: 30 | item = cursor_frame.__getitem__(i) 31 | assert item == cursor_image 32 | assert isinstance(item, CursorImage) 33 | 34 | for c in iter(cursor_frame): 35 | assert isinstance(c, CursorImage) 36 | 37 | assert "delay=5" in repr(cursor_frame) 38 | -------------------------------------------------------------------------------- /tests/test_parser.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from clickgen.parser import open_blob 4 | 5 | 6 | def test_open_blob(blob, dummy_blob, blobs, dummy_blobs, hotspot): 7 | open_blob(blob, hotspot) 8 | with pytest.raises(Exception): 9 | open_blob(dummy_blob, hotspot) 10 | 11 | open_blob(blobs, hotspot) 12 | with pytest.raises(Exception): 13 | open_blob(dummy_blobs, hotspot) 14 | -------------------------------------------------------------------------------- /tests/writer/test_windows_writer.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from PIL.Image import Image 5 | 6 | from clickgen.cursors import CursorFrame, CursorImage 7 | from clickgen.writer.windows import to_ani, to_cur, to_win 8 | 9 | 10 | def test_windows_cur_writer(cursor_frame, x11_tmp_dir: Path): 11 | o = to_cur(cursor_frame) 12 | assert isinstance(o, bytes) 13 | 14 | cfile = x11_tmp_dir / "test.cur" 15 | cfile.write_bytes(o) 16 | 17 | assert cfile.exists() 18 | assert cfile.is_file() 19 | 20 | 21 | def test_windows_cur_writer_re_canvas(image: Image, hotspot, delay): 22 | def cur_frame(size: int): 23 | i = image.resize(size=(size, size), resample=3) 24 | return CursorImage(i, hotspot, nominal=31) 25 | 26 | to_cur( 27 | CursorFrame( 28 | [ 29 | cur_frame(20), 30 | cur_frame(40), 31 | cur_frame(60), 32 | cur_frame(90), 33 | cur_frame(120), 34 | cur_frame(250), 35 | ], 36 | delay, 37 | ) 38 | ) 39 | 40 | 41 | def test_windows_cur_writer_raises(image: Image, hotspot, delay): 42 | i = image.resize(size=(500, 500), resample=3) 43 | c = CursorImage(i, hotspot, nominal=i.size[0]) 44 | cf = CursorFrame([c], delay) 45 | 46 | with pytest.raises(ValueError): 47 | to_cur(cf) 48 | 49 | 50 | def test_windows_ani_writer(cursor_frame: CursorFrame, x11_tmp_dir: Path): 51 | o = to_ani([cursor_frame, cursor_frame]) 52 | assert isinstance(o, bytes) 53 | 54 | cfile = x11_tmp_dir / "test.ani" 55 | cfile.write_bytes(o) 56 | 57 | assert cfile.exists() 58 | assert cfile.is_file() 59 | 60 | 61 | def test_windows_writer(cursor_frame: CursorFrame, x11_tmp_dir: Path): 62 | ext, o = to_win([cursor_frame, cursor_frame]) 63 | assert isinstance(o, bytes) 64 | 65 | cfile = x11_tmp_dir / f"test.{ext}" 66 | cfile.write_bytes(o) 67 | 68 | assert cfile.exists() 69 | assert cfile.is_file() 70 | assert cfile.suffix == ".ani" 71 | 72 | ext1, o1 = to_win([cursor_frame]) 73 | assert isinstance(o1, bytes) 74 | 75 | cfile1 = x11_tmp_dir / f"test.{ext1}" 76 | cfile1.write_bytes(o1) 77 | 78 | assert cfile1.exists() 79 | assert cfile1.is_file() 80 | assert cfile1.suffix == ".cur" 81 | -------------------------------------------------------------------------------- /tests/writer/test_x11_writer.py: -------------------------------------------------------------------------------- 1 | import struct 2 | from typing import Any, List, Tuple 3 | 4 | from clickgen.writer.x11 import to_x11 5 | 6 | 7 | # Helpers 8 | def assert_xcursor(blob): 9 | MAGIC = b"Xcur" 10 | VERSION = 0x1_0000 11 | FILE_HEADER = struct.Struct("<4sIII") 12 | TOC_CHUNK = struct.Struct(" Tuple[Any, ...]: 17 | return struct_cls.unpack(blob[offset : offset + struct_cls.size]) 18 | 19 | magic, _, version, toc_size = _unpack(blob, FILE_HEADER, 0) 20 | 21 | assert magic == MAGIC 22 | assert version == VERSION 23 | 24 | offset = FILE_HEADER.size 25 | chunks: List[Tuple[int, int, int]] = [] 26 | for _ in range(toc_size): 27 | chunk_type, chunk_subtype, position = _unpack(blob, TOC_CHUNK, offset) 28 | chunks.append((chunk_type, chunk_subtype, position)) 29 | offset += TOC_CHUNK.size 30 | 31 | for chunk_type, chunk_subtype, position in chunks: 32 | if chunk_type != CHUNK_IMAGE: 33 | continue 34 | 35 | ( 36 | size, 37 | actual_type, 38 | nominal_size, 39 | version, 40 | width, 41 | height, 42 | x_offset, 43 | y_offset, 44 | delay, 45 | ) = _unpack(blob, IMAGE_HEADER, position) 46 | delay /= 1000 47 | 48 | assert size == IMAGE_HEADER.size 49 | 50 | assert actual_type == chunk_type 51 | 52 | assert nominal_size == chunk_subtype 53 | assert nominal_size == 24 54 | 55 | assert width < 0x7FFF 56 | assert width == 200 57 | assert height < 0x7FFF 58 | assert height == 200 59 | 60 | assert x_offset < width 61 | assert x_offset == 100 62 | 63 | assert y_offset < height 64 | assert y_offset == 105 65 | 66 | image_start = position + IMAGE_HEADER.size 67 | image_size = width * height * 4 68 | new_blob = blob[image_start : image_start + image_size] 69 | assert len(new_blob) == image_size 70 | 71 | 72 | def test_static_xcursor_file_formate(cursor_frame): 73 | blob = to_x11([cursor_frame]) 74 | 75 | assert isinstance(blob, bytes) 76 | assert_xcursor(blob) 77 | 78 | 79 | def test_animated_xcursor_file_formate(cursor_frame): 80 | blob = to_x11([cursor_frame, cursor_frame]) 81 | 82 | assert isinstance(blob, bytes) 83 | assert_xcursor(blob) 84 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | minversion = 3.8.0 3 | envlist = py{37,38,39,310,311,312}, flake8, mypy 4 | isolated_build = true 5 | 6 | [gh-actions] 7 | python = 8 | 3.7: py37 9 | 3.8: py38, flake8, mypy 10 | 3.9: py39 11 | 3.10: py310 12 | 3.11: py311 13 | 3.12: py312 14 | 15 | [testenv] 16 | setenv = 17 | PYTHONPATH = {toxinidir} 18 | deps = 19 | -r{toxinidir}/requirements.dev.txt 20 | commands = 21 | pytest --basetemp={envtmpdir} 22 | 23 | [testenv:flake8] 24 | basepython = python3.8 25 | deps = flake8 26 | commands = flake8 src tests 27 | 28 | [testenv:mypy] 29 | basepython = python3.8 30 | deps = mypy 31 | commands = mypy src 32 | --------------------------------------------------------------------------------