diff --git a/.bandit.yml b/.bandit.yml new file mode 100644 index 0000000..ffb355b --- /dev/null +++ b/.bandit.yml @@ -0,0 +1,30 @@ +# Bandit configuration file for Animation Workbench security scanning +# https://bandit.readthedocs.io/en/latest/config.html + +exclude_dirs: + - ./test + - ./test/** + - animation_workbench/test + +# Skip acceptable issues for this QGIS plugin: +skips: + # B101: assert statements - legitimate in tests + - B101 + # B104: Binding to 0.0.0.0 - intentional for debug server (development only) + - B104 + # B108: Hardcoded /tmp - acceptable for temporary file handling + - B108 + # B110: Try/except/pass - acceptable for silent error handling in UI code + - B110 + # B404: subprocess import - required for ffmpeg/imagemagick integration + - B404 + # B603: subprocess without shell=True - this is the SECURE way to call processes + - B603 + # B606: Process without shell - this is intentional and secure + - B606 + # B607: Partial path - acceptable when calling known system tools (ffmpeg, convert) + - B607 + +# Note: High severity issues that MUST remain fixed: +# - B605: Shell injection - fixed by using glob/os.remove instead of os.system +# - B602: Shell injection via subprocess shell=True - not used diff --git a/.cspell.json b/.cspell.json new file mode 100644 index 0000000..ca80c74 --- /dev/null +++ b/.cspell.json @@ -0,0 +1,58 @@ +{ + "version": "0.2", + "language": "en", + "words": [ + "QGIS", + "PyQt", + "pyqt", + "qgis", + "workbench", + "kartoza", + "timlinux", + "nixpkgs", + "geospatial", + "flake", + "devshell", + "mkdocs", + "pyproject", + "toml", + "bandit", + "flake8", + "isort", + "mypy", + "pytest", + "docstring", + "docstrings", + "darglint", + "cspell", + "yamllint", + "actionlint", + "shellcheck", + "nixfmt", + "precommit", + "venv", + "virtualenv", + "ffmpeg", + "keyframe", + "keyframes", + "easing", + "interpolate", + "interpolation", + "viewport", + "renderer", + "rasterio", + "geopandas", + "numpy", + "pyqtgraph", + "debugpy" + ], + "ignorePaths": [ + "node_modules", + ".venv", + "venv", + "build", + "dist", + "*.lock", + "*.log" + ] +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..228f407 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: Tim Sutton +# SPDX-License-Identifier: MIT +# +# Environment variables for QGIS Animation Workbench development +# Copy this file to .env and adjust values as needed +# +# For Docker testing: +IMAGE=qgis/qgis +QGIS_VERSION_TAG=release-3_34 +WITH_PYTHON_PEP=true +ON_TRAVIS=false +MUTE_LOGS=false + +# For local development (these are auto-generated by scripts/vscode.sh): +# PYTHONPATH=/path/to/qgis/python +# QGIS_EXECUTABLE=/path/to/qgis +# QGIS_PREFIX_PATH=/path/to/qgis/prefix +# QT_QPA_PLATFORM=offscreen diff --git a/.envrc b/.envrc index 5876701..07f3f1e 100644 --- a/.envrc +++ b/.envrc @@ -1,2 +1,2 @@ -use nix --impure +use flake layout python3 diff --git a/.exrc b/.exrc new file mode 100644 index 0000000..46d6795 --- /dev/null +++ b/.exrc @@ -0,0 +1,31 @@ +" SPDX-FileCopyrightText: Tim Sutton +" SPDX-License-Identifier: MIT +" +" QGIS Animation Workbench - Neovim Project Configuration +" +" This file loads .nvim.lua which contains all project keybindings +" All shortcuts are under p (project commands) +" +" Usage: Requires 'exrc' option enabled in neovim: +" vim.opt.exrc = true +" +" Project Keybindings (p): +" q - QGIS (stable/LTR/master, debug/standard) +" t - Testing +" c - Code Quality (format, lint, checks) +" d - Documentation +" x - Debug (DAP breakpoints, attach to QGIS) +" p - Packaging (build zip, symlink to profile, copy install) +" r - Profiling +" u - Utilities +" g - Git + +" Load .nvim.lua if it exists and hasn't been loaded +if !exists('g:loaded_animation_workbench_project') + let g:loaded_animation_workbench_project = 1 + + " Source the Lua configuration + if filereadable(expand(':p:h') . '/.nvim.lua') + lua dofile(vim.fn.expand(':p:h') .. '/.nvim.lua') + endif +endif diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..6fc6fce --- /dev/null +++ b/.flake8 @@ -0,0 +1,34 @@ +[flake8] +max-line-length = 120 +exclude = + .git, + __pycache__, + .venv, + venv, + .eggs, + *.egg, + build, + dist, + .idea, + .vscode, + .tox, + htmlcov, + test/test_data + +# Ignore specific error codes +# E501: Line too long (handled by black) +# W503: Line break before binary operator (conflicts with black) +# E203: Whitespace before ':' (conflicts with black) +# E402: Module level import not at top (needed for optional imports) +# D-series: Docstring convention errors (handled separately) +ignore = + E501, + W503, + E203, + E402, + D + +# Docstring checking +docstring-convention = google +strictness = short +require-return-section-when-returning-nothing = no diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 8f2ceac..ce27798 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,9 +1,33 @@ +# Dependabot configuration for QGIS Animation Workbench +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates + version: 2 updates: - - package-ecosystem: github-actions - directory: / + # Python dependencies (pip) + - package-ecosystem: "pip" + directory: "/" schedule: - interval: monthly + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "python" + commit-message: + prefix: "chore(deps)" + + # GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "github-actions" + commit-message: + prefix: "chore(deps)" groups: github-actions: patterns: diff --git a/.github/workflows/BlackPythonCodeLinter.yml b/.github/workflows/BlackPythonCodeLinter.yml deleted file mode 100644 index 2f6b9dd..0000000 --- a/.github/workflows/BlackPythonCodeLinter.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: 🐍 Black python code lint -on: - push: - branches: - - main - - docs - # Paths can be used to only trigger actions when you have edited certain files, such as a file within the /docs directory - paths: - - "**.py" - # Allow manually running in the actions tab - workflow_dispatch: - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: psf/black@stable - with: - options: "--check --verbose" - src: "./animation_workbench" - # version: "21.5b1" # Fails diff --git a/.github/workflows/BuildMKDocsAndPublishToGithubPages.yml b/.github/workflows/BuildMKDocsAndPublishToGithubPages.yml deleted file mode 100644 index 8ec3e99..0000000 --- a/.github/workflows/BuildMKDocsAndPublishToGithubPages.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: 📖 Build MKDocs And Publish To Github Pages.yml -on: - push: - branches: - - main - - docs - # Paths can be used to only trigger actions when you have edited certain files, such as a file within the /docs directory - paths: - - ".github/workflows/BuildMKDocsAndPublishToGithubPages.yml" - - "**.md" - - "**.py" - - "assets/**" - # Allow manually running in the actions tab - workflow_dispatch: - -jobs: - build: - name: Deploy docs - runs-on: ubuntu-latest - steps: - - name: Install dependencies - uses: BSFishy/pip-action@v1 - with: - packages: | - mkdocs-material - qrcode - - name: Checkout main from github - uses: actions/checkout@v1 - - name: Create Mkdocs Config 🚀 - working-directory: ./docs - run: ./create-mkdocs-html-config.sh - - name: Deploy docs to github pages - # This is where we get the material theme from - #uses: mhausenblas/mkdocs-deploy-gh-pages@master - uses: timlinux/mkdocs-deploy-gh-pages@master - # Wrong - #uses: timlinux/QGISAnimationWorkbench@main - env: - # Read this carefully: - # https://github.com/marketplace/actions/deploy-mkdocs#building-with-github_token - # The token is automatically generated by the GH Action - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CONFIG_FILE: docs/mkdocs.yml - REQUIREMENTS: docs/requirements.txt diff --git a/.github/workflows/CompileMKDocsToPDF.yml b/.github/workflows/CompileMKDocsToPDF.yml index 9c9b41c..eeb7f36 100644 --- a/.github/workflows/CompileMKDocsToPDF.yml +++ b/.github/workflows/CompileMKDocsToPDF.yml @@ -1,38 +1,41 @@ -name: 📔 Compile MKDocs to PDF -# This workflow is triggered on pushes to the repository. +name: Build Documentation PDF + on: push: branches: - main - # Paths can be used to only trigger actions when you have edited certain files, such as a file within the /docs directory paths: - '.github/workflows/CompileMKDocsToPDF.yml' - - 'docs/**.md' - - 'docs/assets/**' - # Allow manually running in the actions tab + - 'docs/**' workflow_dispatch: - + jobs: - generatepdf: - name: Build PDF + build-pdf: + name: Build PDF Documentation runs-on: ubuntu-latest + steps: - - name: Checkout 🛒 - uses: actions/checkout@v2 - - name: Create Mkdocs Config 🚀 + - name: Checkout + uses: actions/checkout@v4 + + - name: Create MkDocs Config working-directory: ./docs - run: ./create-mkdocs-pdf-config.sh - - name: Build PDF 📃 + run: | + if [ -f create-mkdocs-pdf-config.sh ]; then + chmod +x create-mkdocs-pdf-config.sh + ./create-mkdocs-pdf-config.sh + fi + + - name: Build PDF uses: kartoza/mkdocs-deploy-build-pdf@master - # Uses orzih's mkdocs PDF builder - # https://github.com/orzih/mkdocs-with-pdf env: EXTRA_PACKAGES: build-base CONFIG_FILE: docs/mkdocs.yml REQUIREMENTS: docs/requirements.txt - #REQUIREMENTS: folder/requirements.txt - - name: Upload PDF Artifact ⚡ - uses: actions/upload-artifact@v3 + + - name: Upload PDF Artifact + uses: actions/upload-artifact@v4 with: - name: docs + name: documentation-pdf path: pdfs + retention-days: 30 diff --git a/.github/workflows/MakeQGISPluginZipForManualInstalls.yml b/.github/workflows/MakeQGISPluginZipForManualInstalls.yml index 08a3ba6..264b50b 100644 --- a/.github/workflows/MakeQGISPluginZipForManualInstalls.yml +++ b/.github/workflows/MakeQGISPluginZipForManualInstalls.yml @@ -1,21 +1,46 @@ -name: 📦 Make QGIS Plugin Zip for Manual Installs -# This workflow is triggered on pushes to the repository. +name: Build Plugin Package + on: push: branches: - main - # Allow manually running in the actions tab + pull_request: + branches: + - main workflow_dispatch: jobs: - build_package: - name: Build Package 🚀 + build-package: + name: Build Plugin Zip runs-on: ubuntu-latest + container: + image: qgis/qgis:release-3_34 + steps: - - uses: actions/checkout@v3 + - name: Checkout + uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/upload-artifact@v3 + + - name: Fix Python command + run: apt-get update && apt-get install -y python-is-python3 python3-pip + + - name: Install dependencies + run: pip3 install -r requirements-dev.txt + + - name: Generate plugin zip + run: python3 admin.py generate-zip + + - name: Get zip details + id: zip-details + run: | + ZIP_NAME=$(ls dist) + echo "ZIP_NAME=$ZIP_NAME" >> "$GITHUB_OUTPUT" + echo "ZIP_PATH=dist/$ZIP_NAME" >> "$GITHUB_OUTPUT" + + - name: Upload plugin artifact + uses: actions/upload-artifact@v4 with: - name: animation_workbench - path: animation_workbench + name: ${{ steps.zip-details.outputs.ZIP_NAME }} + path: ${{ steps.zip-details.outputs.ZIP_PATH }} + retention-days: 30 diff --git a/.github/workflows/MakeQGISPluginZipForReleases.yml b/.github/workflows/MakeQGISPluginZipForReleases.yml deleted file mode 100644 index a051dc0..0000000 --- a/.github/workflows/MakeQGISPluginZipForReleases.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: 📦 Make QGIS Plugin Zip for Release - -on: - push: - tags: - - '*' - # Allow manually running in the actions tab - workflow_dispatch: - -jobs: - build_release: - name: Build Release 🚀 - runs-on: ubuntu-latest - steps: - - name: Checkout 🛒 - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - name: Version 🔢 - run: echo "::set-output name=version::$(cat animation_workbench/metadata.txt | grep version | sed 's/version=//g')" - id: version - - name: Release 🔖 - uses: actions/create-release@v1 - id: create_release - with: - draft: true - prerelease: true - release_name: ${{ steps.version.outputs.version }} - tag_name: ${{ github.ref }} - body_path: CHANGELOG.md - env: - GITHUB_TOKEN: ${{ github.token }} - - name: Install Zip 🔧 - uses: montudor/action-zip@v1 - - name: PWD 📁 - run: pwd - - name: Build Package 🚀 - run: zip -qq -r ../animation_workbench.zip * - working-directory: /home/runner/work/QGISAnimationWorkbench/QGISAnimationWorkbench/animation_workbench - - name: List Files 📁 - run: ls -lah - - name: Upload Package ⚡ - # runs-on: ubuntu-latest - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ github.token }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: animation_workbench.zip - asset_name: animation_workbench.zip - asset_content_type: application/gzip diff --git a/.github/workflows/RunPythonPluginTests.yaml b/.github/workflows/RunPythonPluginTests.yaml deleted file mode 100644 index ecdb83d..0000000 --- a/.github/workflows/RunPythonPluginTests.yaml +++ /dev/null @@ -1,80 +0,0 @@ -name: 👨‍⚖️ Run Python Plugin Tests - -on: - push: - paths: - - "animation_workbench/**" - - ".github/workflows/test_plugin.yaml" - pull_request: - paths: - - "animation_workbench/**" - - ".github/workflows/test_plugin.yaml" - # Allow manually running in the actions tab - workflow_dispatch: - -env: - # plugin name/directory where the code for the plugin is stored - PLUGIN_NAME: animation_workbench - # python notation to test running inside plugin - TESTS_RUN_FUNCTION: animation_workbench.test_suite.test_package - # Docker settings - DOCKER_IMAGE: qgis/qgis - - -jobs: - - Test-plugin-animation_workbench: - - runs-on: ubuntu-latest - - strategy: - matrix: - # requires QGIS >= 3.26 - docker_tags: [latest] - - steps: - - - name: Checkout - uses: actions/checkout@v2 - - - name: Docker pull and create qgis-testing-environment - run: | - docker pull "$DOCKER_IMAGE":${{ matrix.docker_tags }} - docker run -d --name qgis-testing-environment -v "$GITHUB_WORKSPACE":/tests_directory -e DISPLAY=:99 "$DOCKER_IMAGE":${{ matrix.docker_tags }} - - - name: Docker set up QGIS - run: | - docker exec qgis-testing-environment sh -c "qgis_setup.sh $PLUGIN_NAME" - docker exec qgis-testing-environment sh -c "rm -f /root/.local/share/QGIS/QGIS3/profiles/default/python/plugins/$PLUGIN_NAME" - docker exec qgis-testing-environment sh -c "ln -s /tests_directory/$PLUGIN_NAME /root/.local/share/QGIS/QGIS3/profiles/default/python/plugins/$PLUGIN_NAME" - docker exec qgis-testing-environment sh -c "pip3 install -r /tests_directory/REQUIREMENTS_TESTING.txt" - docker exec qgis-testing-environment sh -c "apt-get update" - docker exec qgis-testing-environment sh -c "apt-get install -y python3-pyqt5.qtmultimedia ffmpeg imagemagick" - - - name: Docker run plugin tests - run: | - docker exec qgis-testing-environment sh -c "qgis_testrunner.sh $TESTS_RUN_FUNCTION" - - Check-code-quality: - runs-on: ubuntu-latest - steps: - - - name: Install Python - uses: actions/setup-python@v1 - with: - python-version: '3.8' - architecture: 'x64' - - - name: Checkout - uses: actions/checkout@v2 - - - name: Install packages - run: | - pip install -r REQUIREMENTS_TESTING.txt - pip install pylint pycodestyle - - - name: Pylint - run: make pylint - - #- name: Pycodestyle - # run: make pycodestyle diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2c7d5fe --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,106 @@ +name: Continuous Integration + +on: + workflow_dispatch: + push: + branches: + - main + pull_request: + types: + - edited + - opened + - reopened + - synchronize + branches: + - main +env: + # Global environment variable + IMAGE: qgis/qgis + WITH_PYTHON_PEP: "true" + MUTE_LOGS: "false" + +jobs: + test: + runs-on: ${{ matrix.os }} + name: Running tests on ${{ matrix.os }} using QGIS ${{ matrix.qgis_version_tag }} + + strategy: + fail-fast: false + matrix: + qgis_version_tag: + - release-3_30 + - release-3_32 + - release-3_34 + - release-3_36 + os: [ubuntu-22.04] + + steps: + + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Preparing docker-compose environment + env: + QGIS_VERSION_TAG: ${{ matrix.qgis_version_tag }} + run: | + cat << EOF > .env + QGIS_VERSION_TAG=${QGIS_VERSION_TAG} + IMAGE=${IMAGE} + ON_TRAVIS=true + MUTE_LOGS=${MUTE_LOGS} + WITH_PYTHON_PEP=${WITH_PYTHON_PEP} + EOF + - name: Install python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + cache: pip + + - name: Install plugin dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + + - name: Preparing test environment + run: | + docker pull "${IMAGE}":${{ matrix.qgis_version_tag }} + python admin.py build --tests + docker compose up -d + sleep 10 + + - name: Run test suite + run: | + docker compose exec -T qgis-testing-environment qgis_testrunner.sh test_suite.test_package + + code-quality: + runs-on: ubuntu-latest + name: Code Quality Checks + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install "black>=25.1.0,<26.0.0" flake8 isort bandit + + - name: Run Black + run: black --check . + + - name: Run Flake8 + run: flake8 animation_workbench --config=.flake8 + + - name: Run isort + run: isort --check-only --diff . + + - name: Run Bandit + run: bandit -c .bandit.yml -r animation_workbench diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml new file mode 100644 index 0000000..c694b12 --- /dev/null +++ b/.github/workflows/gh-pages.yml @@ -0,0 +1,64 @@ +name: Deploy Documentation to GitHub Pages + +on: + push: + branches: + - main + paths: + - 'docs/**' + - 'mkdocs*.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install dependencies + run: | + pip install mkdocs mkdocs-material mkdocs-autorefs mkdocstrings pymdown-extensions + + - name: Build docs + run: | + cd docs + if [ -f build-docs-html.sh ]; then + chmod +x build-docs-html.sh + ./build-docs-html.sh + else + mkdocs build -f mkdocs-html.yml -d site + fi + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: './docs/site' + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..63d750a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,129 @@ +name: Create Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + tag: + description: 'Tag to release (e.g., v1.4.0)' + required: true + type: string + +permissions: + contents: write + +jobs: + build-and-release: + name: Build and Release + runs-on: ubuntu-22.04 + container: + image: qgis/qgis:release-3_34 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fix Python command + run: apt-get update && apt-get install -y python-is-python3 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install plugin dependencies + run: pip install -r requirements-dev.txt + + - name: Get plugin metadata + id: metadata + run: | + VERSION=$(python -c "import json; f = open('config.json'); data=json.load(f); print(data['general']['version'])") + IS_EXPERIMENTAL=$(python -c "import json; f = open('config.json'); data=json.load(f); print(str(data['general']['experimental']).lower())") + PLUGIN_NAME=$(python -c "import json; f = open('config.json'); data=json.load(f); print(data['general']['name'])") + echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" + echo "IS_EXPERIMENTAL=$IS_EXPERIMENTAL" >> "$GITHUB_OUTPUT" + echo "PLUGIN_NAME=$PLUGIN_NAME" >> "$GITHUB_OUTPUT" + + - name: Generate plugin zip + run: python admin.py generate-zip + + - name: Get zip details + id: zip-details + run: | + ZIP_NAME=$(ls dist) + echo "ZIP_PATH=dist/$ZIP_NAME" >> "$GITHUB_OUTPUT" + echo "ZIP_NAME=$ZIP_NAME" >> "$GITHUB_OUTPUT" + + - name: Extract release notes from CHANGELOG + id: release-notes + run: | + # Extract the latest version section from CHANGELOG.md + if [ -f CHANGELOG.md ]; then + # Get content between first and second version headers + NOTES=$(awk '/^## \[?[0-9]/{if(found)exit; found=1; next} found{print}' CHANGELOG.md | head -50) + if [ -z "$NOTES" ]; then + NOTES="See CHANGELOG.md for details." + fi + else + NOTES="Release ${{ steps.metadata.outputs.VERSION }}" + fi + # Write to file to preserve formatting + echo "$NOTES" > release_notes.txt + + - name: Create release and upload asset + uses: softprops/action-gh-release@v2 + with: + name: ${{ steps.metadata.outputs.PLUGIN_NAME }} ${{ github.ref_name }} + tag_name: ${{ github.ref_name }} + prerelease: ${{ steps.metadata.outputs.IS_EXPERIMENTAL }} + draft: false + body_path: release_notes.txt + files: | + ${{ steps.zip-details.outputs.ZIP_PATH }} + fail_on_unmatched_files: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + update-plugin-repo: + name: Update Plugin Repository + runs-on: ubuntu-22.04 + needs: build-and-release + container: + image: qgis/qgis:release-3_34 + + steps: + - name: Checkout release branch + uses: actions/checkout@v4 + with: + ref: release + fetch-depth: 0 + + - name: Fix Python command + run: apt-get update && apt-get install -y python-is-python3 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install dependencies + run: pip install -r requirements-dev.txt + + - name: Update plugin repository XML + run: | + python admin.py --verbose generate-plugin-repo-xml + # Add newline to ensure clean git diff + echo "" >> docs/repository/plugins.xml + + - name: Commit and push + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global --add safe.directory /__w/QGISAnimationWorkbench/QGISAnimationWorkbench + git add -A + git diff --staged --quiet || git commit -m "Update plugins.xml for ${{ github.ref_name }}" + git push origin release diff --git a/.gitignore b/.gitignore index 74c5b72..7075537 100644 --- a/.gitignore +++ b/.gitignore @@ -154,4 +154,13 @@ templates/styles.scss animation_workbench_ i*.qgs~ +# Development workflow +PROMPT.log +.pip-install.log +profile.prof +profile.callgrind +pyrightconfig.json + examples/kartoza_staff_example/ +.claude +PROMPT.log diff --git a/.nvim-setup.sh b/.nvim-setup.sh new file mode 100755 index 0000000..626661b --- /dev/null +++ b/.nvim-setup.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Tim Sutton +# SPDX-License-Identifier: MIT + +# This script sets up the environment for Neovim/LSP to work with QGIS Python libraries +# Source this script before running nvim: source .nvim-setup.sh +# +# Project keybindings are under p - run :WhichKey p in neovim + +# Colors +CYAN='\033[38;2;83;161;203m' +GREEN='\033[92m' +ORANGE='\033[38;2;237;177;72m' +RESET='\033[0m' + +QGIS_BIN=$(which qgis 2>/dev/null) + +if [[ -z "$QGIS_BIN" ]]; then + echo -e "${ORANGE}Warning: QGIS binary not found in PATH${RESET}" + echo "Make sure you're in the nix develop shell first" + return 1 2>/dev/null || exit 1 +fi + +# Extract the Nix store path (removing /bin/qgis) +QGIS_PREFIX=$(dirname "$(dirname "$QGIS_BIN")") + +# Construct the correct QGIS Python path +QGIS_PYTHON_PATH="$QGIS_PREFIX/share/qgis/python" + +# Check if the Python directory exists +if [[ ! -d "$QGIS_PYTHON_PATH" ]]; then + echo -e "${ORANGE}Warning: QGIS Python path not found at $QGIS_PYTHON_PATH${RESET}" + return 1 2>/dev/null || exit 1 +fi + +# Add virtualenv if it exists +VENV_PATH="" +if [[ -d ".venv" ]]; then + VENV_PATH=$(find .venv/lib -maxdepth 1 -name "python*" -type d | head -1)/site-packages +fi + +export PYTHONPATH="$QGIS_PYTHON_PATH:$VENV_PATH:$PYTHONPATH" + +echo -e "${GREEN}Neovim environment configured for QGIS development${RESET}" +echo "" +echo -e "QGIS Python: ${CYAN}$QGIS_PYTHON_PATH${RESET}" +if [[ -n "$VENV_PATH" ]]; then + echo -e "Virtualenv: ${CYAN}$VENV_PATH${RESET}" +fi +echo "" +echo -e "Project keybindings: ${CYAN}p${RESET}" +echo -e " ${CYAN}pq${RESET} - QGIS commands" +echo -e " ${CYAN}pt${RESET} - Testing" +echo -e " ${CYAN}pc${RESET} - Code quality" +echo -e " ${CYAN}pd${RESET} - Documentation" +echo -e " ${CYAN}px${RESET} - Debugging" +echo "" +echo -e "Run ${CYAN}:WhichKey p${RESET} in neovim for full menu" +echo "" +echo -e "${ORANGE}Note:${RESET} Add this to your neovim config to auto-load .nvim.lua:" +echo -e " ${CYAN}vim.opt.exrc = true${RESET}" +echo -e " ${CYAN}-- For .nvim.lua: use neoconf.nvim or nvim-config-local plugin${RESET}" diff --git a/.nvim.lua b/.nvim.lua new file mode 100644 index 0000000..330d507 --- /dev/null +++ b/.nvim.lua @@ -0,0 +1,598 @@ +-- SPDX-FileCopyrightText: Tim Sutton +-- SPDX-License-Identifier: MIT +-- +-- QGIS Animation Workbench - Neovim Project Configuration +-- All project keybindings are under p +-- +-- This file is auto-loaded by neovim with exrc enabled or via neoconf/nvim-config-local + +local M = {} + +-- ============================================================================ +-- Helper Functions +-- ============================================================================ + +-- Run a command in a new terminal split +local function run_in_terminal(cmd, opts) + opts = opts or {} + local direction = opts.direction or "horizontal" + local size = opts.size or 15 + + if direction == "horizontal" then + vim.cmd("botright " .. size .. "split | terminal " .. cmd) + elseif direction == "vertical" then + vim.cmd("vertical " .. size .. "split | terminal " .. cmd) + elseif direction == "float" then + vim.cmd("terminal " .. cmd) + end + + -- Enter insert mode in terminal + vim.cmd("startinsert") +end + +-- Run a command in background (no terminal output) +local function run_background(cmd) + vim.fn.jobstart(cmd, { detach = true }) + vim.notify("Started: " .. cmd, vim.log.levels.INFO) +end + +-- ============================================================================ +-- Which-Key Registration +-- ============================================================================ + +M.setup_keymaps = function() + local ok, wk = pcall(require, "which-key") + if not ok then + vim.notify("which-key not found, using basic keymaps", vim.log.levels.WARN) + M.setup_basic_keymaps() + return + end + + wk.add({ + { "p", group = "Project (Animation Workbench)" }, + + -- ======================================================================== + -- QGIS + -- ======================================================================== + { "pq", group = "QGIS" }, + { + "pqs", + function() + run_background("ANIMATION_WORKBENCH_DEBUG=0 nix run .#qgis --impure") + end, + desc = "QGIS Stable", + }, + { + "pqd", + function() + run_in_terminal( + "ANIMATION_WORKBENCH_DEBUG=1 ANIMATION_WORKBENCH_LOG=$HOME/AnimationWorkbench.log nix run .#qgis --impure", + { direction = "horizontal", size = 20 } + ) + end, + desc = "QGIS Stable (Debug)", + }, + { + "pql", + function() + run_background("ANIMATION_WORKBENCH_DEBUG=0 nix run .#qgis-ltr --impure") + end, + desc = "QGIS LTR", + }, + { + "pqL", + function() + run_in_terminal( + "ANIMATION_WORKBENCH_DEBUG=1 ANIMATION_WORKBENCH_LOG=$HOME/AnimationWorkbench.log nix run .#qgis-ltr --impure", + { direction = "horizontal", size = 20 } + ) + end, + desc = "QGIS LTR (Debug)", + }, + { + "pqm", + function() + run_background("ANIMATION_WORKBENCH_DEBUG=0 nix run .#qgis-master --impure") + end, + desc = "QGIS Master", + }, + { + "pqM", + function() + run_in_terminal( + "ANIMATION_WORKBENCH_DEBUG=1 ANIMATION_WORKBENCH_LOG=$HOME/AnimationWorkbench.log nix run .#qgis-master --impure", + { direction = "horizontal", size = 20 } + ) + end, + desc = "QGIS Master (Debug)", + }, + { + "pqo", + function() + vim.cmd("edit $HOME/AnimationWorkbench.log") + end, + desc = "Open debug log", + }, + + -- ======================================================================== + -- Testing + -- ======================================================================== + { "pt", group = "Test" }, + { + "ptt", + function() + run_in_terminal("nix run .#test", { size = 20 }) + end, + desc = "Run all tests", + }, + { + "ptf", + function() + local file = vim.fn.expand("%") + run_in_terminal("pytest " .. file .. " -v", { size = 20 }) + end, + desc = "Test current file", + }, + { + "ptl", + function() + run_in_terminal("pytest --lf -v", { size = 20 }) + end, + desc = "Re-run last failed", + }, + { + "ptc", + function() + run_in_terminal("pytest --cov=animation_workbench --cov-report=html && xdg-open htmlcov/index.html", { size = 20 }) + end, + desc = "Run with coverage", + }, + + -- ======================================================================== + -- Code Quality + -- ======================================================================== + { "pc", group = "Code Quality" }, + { + "pcc", + function() + run_in_terminal("nix run .#checks", { size = 25 }) + end, + desc = "Pre-commit checks (all)", + }, + { + "pcf", + function() + run_in_terminal("nix run .#format", { size = 15 }) + end, + desc = "Format all (black + isort)", + }, + { + "pcF", + function() + local file = vim.fn.expand("%") + run_in_terminal("black " .. file .. " && isort " .. file, { size = 10 }) + end, + desc = "Format current file", + }, + { + "pcl", + function() + run_in_terminal("nix run .#lint", { size = 20 }) + end, + desc = "Lint all (flake8 + pyright)", + }, + { + "pcL", + function() + local file = vim.fn.expand("%") + run_in_terminal("flake8 " .. file .. " && pyright " .. file, { size = 15 }) + end, + desc = "Lint current file", + }, + { + "pcs", + function() + run_in_terminal("nix run .#security", { size = 20 }) + end, + desc = "Security scan (bandit)", + }, + + -- ======================================================================== + -- Documentation + -- ======================================================================== + { "pd", group = "Documentation" }, + { + "pds", + function() + run_in_terminal("nix run .#docs-serve", { size = 10 }) + end, + desc = "Serve docs locally", + }, + { + "pdb", + function() + run_in_terminal("nix run .#docs-build", { size = 15 }) + end, + desc = "Build docs", + }, + { + "pdo", + function() + run_background("xdg-open http://localhost:8000") + end, + desc = "Open docs in browser", + }, + + -- ======================================================================== + -- Debugging (DAP) + -- ======================================================================== + { "px", group = "Debug (DAP)" }, + { + "pxb", + function() + local dap_ok, dap = pcall(require, "dap") + if dap_ok then + dap.toggle_breakpoint() + else + vim.notify("DAP not available", vim.log.levels.WARN) + end + end, + desc = "Toggle breakpoint", + }, + { + "pxc", + function() + local dap_ok, dap = pcall(require, "dap") + if dap_ok then + dap.continue() + end + end, + desc = "Continue", + }, + { + "pxs", + function() + local dap_ok, dap = pcall(require, "dap") + if dap_ok then + dap.step_over() + end + end, + desc = "Step over", + }, + { + "pxi", + function() + local dap_ok, dap = pcall(require, "dap") + if dap_ok then + dap.step_into() + end + end, + desc = "Step into", + }, + { + "pxo", + function() + local dap_ok, dap = pcall(require, "dap") + if dap_ok then + dap.step_out() + end + end, + desc = "Step out", + }, + { + "pxr", + function() + local dap_ok, dap = pcall(require, "dap") + if dap_ok then + dap.repl.open() + end + end, + desc = "Open REPL", + }, + { + "pxa", + function() + local dap_ok, dap = pcall(require, "dap") + if dap_ok then + dap.run({ + type = "python", + request = "attach", + name = "Attach to QGIS", + connect = { host = "127.0.0.1", port = 5678 }, + pathMappings = { + { + localRoot = vim.fn.getcwd() .. "/animation_workbench", + remoteRoot = vim.fn.expand("~/.local/share/QGIS/QGIS3/profiles/AnimationWorkbench/python/plugins/animation_workbench"), + }, + }, + }) + end + end, + desc = "Attach to QGIS (debugpy)", + }, + + -- ======================================================================== + -- Packaging + -- ======================================================================== + { "pp", group = "Package" }, + { + "ppb", + function() + run_in_terminal("nix run .#package", { size = 10 }) + end, + desc = "Build plugin zip", + }, + { + "pps", + function() + run_in_terminal("nix run .#symlink", { size = 12 }) + end, + desc = "Symlink plugin to QGIS profile", + }, + { + "ppi", + function() + local plugin_dir = vim.fn.expand("~/.local/share/QGIS/QGIS3/profiles/AnimationWorkbench/python/plugins/") + run_in_terminal("mkdir -p " .. plugin_dir .. " && cp -r animation_workbench " .. plugin_dir, { size = 10 }) + end, + desc = "Install (copy) to QGIS profile", + }, + + -- ======================================================================== + -- Profiling + -- ======================================================================== + { "pr", group = "Profile" }, + { + "prp", + function() + local file = vim.fn.expand("%") + run_in_terminal("python -m cProfile -o profile.prof " .. file, { size = 15 }) + end, + desc = "Profile current file", + }, + { + "prv", + function() + run_in_terminal("nix run .#profile", { size = 10 }) + end, + desc = "View profile (snakeviz)", + }, + + -- ======================================================================== + -- Utilities + -- ======================================================================== + { "pu", group = "Utilities" }, + { + "puc", + function() + run_in_terminal("nix run .#clean", { size = 10 }) + end, + desc = "Clean workspace", + }, + { + "pui", + function() + run_in_terminal("pip install -r requirements-dev.txt", { size = 15 }) + end, + desc = "Install pip deps", + }, + { + "puh", + function() + run_in_terminal("pre-commit install", { size = 10 }) + end, + desc = "Install pre-commit hooks", + }, + { + "pus", + function() + run_in_terminal("./scripts/update-strings.sh", { size = 10 }) + end, + desc = "Update translation strings", + }, + { + "put", + function() + run_in_terminal("./scripts/compile-strings.sh", { size = 10 }) + end, + desc = "Compile translations", + }, + { + "pun", + function() + run_in_terminal("nix flake show", { size = 20 }) + end, + desc = "Show nix flake", + }, + { + "pue", + function() + vim.cmd("edit flake.nix") + end, + desc = "Edit flake.nix", + }, + + -- ======================================================================== + -- Git + -- ======================================================================== + { "pg", group = "Git" }, + { + "pgs", + "Git status", + desc = "Git status", + }, + { + "pgd", + "Git diff", + desc = "Git diff", + }, + { + "pgb", + "Git blame", + desc = "Git blame", + }, + { + "pgl", + "Git log --oneline -20", + desc = "Git log (20)", + }, + { + "pgp", + function() + run_in_terminal("git push", { size = 10 }) + end, + desc = "Git push", + }, + }) + + vim.notify("Animation Workbench keymaps loaded (p)", vim.log.levels.INFO) +end + +-- ============================================================================ +-- Basic Keymaps (fallback without which-key) +-- ============================================================================ + +M.setup_basic_keymaps = function() + local opts = { noremap = true, silent = true } + + -- QGIS + vim.keymap.set("n", "pqs", function() + run_background("nix run .#qgis --impure") + end, vim.tbl_extend("force", opts, { desc = "QGIS Stable" })) + + vim.keymap.set("n", "pqd", function() + run_in_terminal("ANIMATION_WORKBENCH_DEBUG=1 nix run .#qgis --impure", { size = 20 }) + end, vim.tbl_extend("force", opts, { desc = "QGIS Debug" })) + + -- Testing + vim.keymap.set("n", "ptt", function() + run_in_terminal("nix run .#test", { size = 20 }) + end, vim.tbl_extend("force", opts, { desc = "Run tests" })) + + -- Code Quality + vim.keymap.set("n", "pcc", function() + run_in_terminal("nix run .#checks", { size = 25 }) + end, vim.tbl_extend("force", opts, { desc = "Pre-commit checks" })) + + vim.keymap.set("n", "pcf", function() + run_in_terminal("nix run .#format", { size = 15 }) + end, vim.tbl_extend("force", opts, { desc = "Format code" })) + + -- Docs + vim.keymap.set("n", "pds", function() + run_in_terminal("nix run .#docs-serve", { size = 10 }) + end, vim.tbl_extend("force", opts, { desc = "Serve docs" })) +end + +-- ============================================================================ +-- LSP Configuration +-- ============================================================================ + +M.setup_lsp = function() + local lspconfig_ok, lspconfig = pcall(require, "lspconfig") + if not lspconfig_ok then + return + end + + -- Configure pyright for QGIS development + lspconfig.pyright.setup({ + settings = { + python = { + analysis = { + extraPaths = { + vim.fn.getcwd(), + vim.fn.getcwd() .. "/animation_workbench", + }, + typeCheckingMode = "basic", + autoSearchPaths = true, + useLibraryCodeForTypes = true, + diagnosticSeverityOverrides = { + reportMissingImports = "warning", + reportMissingModuleSource = "none", + }, + }, + }, + }, + }) +end + +-- ============================================================================ +-- DAP Configuration for QGIS debugging +-- ============================================================================ + +M.setup_dap = function() + local dap_ok, dap = pcall(require, "dap") + if not dap_ok then + return + end + + dap.adapters.python = { + type = "executable", + command = "python", + args = { "-m", "debugpy.adapter" }, + } + + dap.configurations.python = { + { + type = "python", + request = "attach", + name = "Attach to QGIS (debugpy on 5678)", + connect = { + host = "127.0.0.1", + port = 5678, + }, + pathMappings = { + { + localRoot = vim.fn.getcwd() .. "/animation_workbench", + remoteRoot = vim.fn.expand("~/.local/share/QGIS/QGIS3/profiles/AnimationWorkbench/python/plugins/animation_workbench"), + }, + }, + }, + { + type = "python", + request = "launch", + name = "Launch file", + program = "${file}", + }, + } +end + +-- ============================================================================ +-- Project Settings +-- ============================================================================ + +M.setup_project = function() + -- Python settings + vim.opt_local.tabstop = 4 + vim.opt_local.shiftwidth = 4 + vim.opt_local.expandtab = true + vim.opt_local.textwidth = 120 + vim.opt_local.colorcolumn = "120" + + -- File type associations + vim.filetype.add({ + extension = { + qml = "xml", + ui = "xml", + }, + pattern = { + ["metadata.txt"] = "ini", + }, + }) +end + +-- ============================================================================ +-- Initialize +-- ============================================================================ + +M.setup = function() + M.setup_project() + M.setup_keymaps() + M.setup_lsp() + M.setup_dap() +end + +-- Auto-setup +M.setup() + +return M diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..5102176 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,113 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +--- +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.3.0 + hooks: + - id: end-of-file-fixer + exclude: ^(animation_workbench/test/data/) + - id: trailing-whitespace + exclude: ^(animation_workbench/test/data/) + - id: check-yaml + - id: check-json + exclude: ^(.vscode) + - repo: https://github.com/psf/black + rev: 24.4.0 + hooks: + - id: black + name: "black" + language_version: python3 + additional_dependencies: [] + - repo: local + hooks: + - id: remove-core-file + name: "Remove core file if it exists" + entry: bash -c '[[ -f core && ! -d core ]] && rm core || exit 0' + language: system + stages: + - pre-commit + - repo: local + hooks: + - id: ensure-utf8-encoding + name: "Ensure UTF-8 encoding declaration in Python files" + entry: bash scripts/encoding_check.sh + language: system + types: [python] + stages: [pre-commit] + - repo: local + hooks: + - id: ensure-google-docstrings + name: "Ensure Google-style docstrings in Python modules" + entry: bash scripts/docstrings_check.sh + language: system + types: [python] + stages: [pre-commit] + - repo: https://github.com/PyCQA/flake8 + rev: 6.0.0 + hooks: + - id: flake8 + name: "flake8 Python Linter" + language_version: python3 + additional_dependencies: ['flake8-docstrings', 'pydocstyle'] + + - repo: https://github.com/PyCQA/isort + rev: 6.0.1 + hooks: + - id: isort + name: "isort - sort Python imports" + entry: isort + language: python + types: [python] + stages: [pre-commit] + - repo: local + hooks: + - id: nixfmt + name: "Nixfmt (RFC style)" + description: Format Nix code with nixfmt-rfc-style + entry: nixfmt + language: system + args: ["--"] + types: [nix] + - repo: local + hooks: + - id: cspell + name: "cspell - Spell checker for Markdown" + entry: cspell --config=.cspell.json --no-progress --no-summary + language: system + types: [markdown] + stages: [pre-commit] + - repo: local + hooks: + - id: yamllint + name: "yamllint - YAML linter" + entry: yamllint + language: system + types: [yaml] + stages: [pre-commit] + - repo: local + hooks: + - id: actionlint + name: "actionlint - GitHub Actions workflow linter" + entry: actionlint + language: system + types: [yaml] + files: ^\.github/workflows/.*\.ya?ml$ + stages: [pre-commit] + - repo: local + hooks: + - id: bandit-scripts + name: "Bandit - Python security analysis" + entry: bandit -c .bandit.yml -r scripts + language: system + types: [python] + stages: [pre-commit] + exclude: ^scripts/tests/ + - repo: local + hooks: + - id: shellcheck-scripts + name: "ShellCheck - scripts" + entry: shellcheck + language: system + files: ^scripts/.*\.(sh|bash|zsh)$ + pass_filenames: true diff --git a/.yamllint b/.yamllint new file mode 100644 index 0000000..74f0281 --- /dev/null +++ b/.yamllint @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: Tim Sutton +# SPDX-License-Identifier: MIT +# +# Yamllint configuration for QGIS Animation Workbench +# https://yamllint.readthedocs.io/ +--- +extends: default + +rules: + line-length: + max: 120 + level: warning + document-start: disable + truthy: + allowed-values: ['true', 'false', 'on', 'off', 'yes', 'no'] + comments: + require-starting-space: true + min-spaces-from-content: 1 + indentation: + spaces: 2 + indent-sequences: true + +ignore: | + .venv/ + .vscode/ + node_modules/ diff --git a/CODING.md b/CODING.md new file mode 100644 index 0000000..e3f8ba4 --- /dev/null +++ b/CODING.md @@ -0,0 +1,266 @@ +# Animation Workbench Coding Guide + +This guide outlines coding practices for developing Python code in the Animation Workbench project, including adherence to Python naming conventions, formatting styles, type declarations, and logging mechanisms. + +## General Guidelines + +- **Consistency**: Ensure consistent naming conventions, formatting, and structure throughout the codebase. +- **Readability**: Code should be clear and easy to read, with well-defined logical flows and separation of concerns. +- **Robustness**: Implement error handling to gracefully manage unexpected situations. + +## Naming Conventions + +Follow the standard Python naming conventions as defined in [PEP 8](https://peps.python.org/pep-0008/): + +- **Variable and Function Names**: Use `snake_case`. + + ```python + def create_animation_frame(frame_number: int) -> QImage: + ... + ``` + +- **Class Names**: Use `PascalCase`. + + ```python + class AnimationController: + ... + ``` + +- **Constants**: Use `UPPER_SNAKE_CASE`. + + ```python + DEFAULT_FRAME_RATE = 30 + ``` + +- **Private Variables and Methods**: Use a leading underscore. + + ```python + def _calculate_frame_duration(self, total_frames: int) -> float: + ... + ``` + +### Exceptions for PyQt Naming Conventions + +Follow the standard conventions for PyQt widgets and properties, even when they do not adhere to typical Python naming conventions: + +- **Signals and Slots**: Use `camelCase`. +- **PyQt Widget Properties and Methods**: Use the default `camelCase` as provided by PyQt. + +Example: + +```python +self.frame_slider.valueChanged.connect(self.on_frame_changed) +``` + +## Code Formatting + +### Black Formatter + +- **Use Black**: All Python code should be formatted with [black](https://black.readthedocs.io/en/stable/), the opinionated code formatter. +- **Configuration**: Use a line length of 120 characters as configured in `pyproject.toml`. + +To format code with Black: + +```bash +black . +``` + +### Indentation and Spacing + +- Use **4 spaces** per indentation level. +- Leave **1 blank line** between functions and class definitions. +- Leave **2 blank lines** before class definitions. + +## Type Annotations + +### Variable Declarations + +- Always declare types for variables, parameters, and return values to enhance code clarity and type safety. + +Examples: + +```python +def render_frame(frame_number: int, output_path: str) -> bool: + frame_image: QImage = self.generate_frame(frame_number) + ... +``` + +```python +layer: QgsVectorLayer = self.layer_combo.currentLayer() +``` + +### Function Signatures + +- Use type hints for all function parameters and return values. +- If a function does not return any value, use `-> None`. + +Example: + +```python +def start_animation(self) -> None: + ... +``` + +### Type Imports + +- Import types from `typing` where necessary: + - `Optional`: To indicate optional parameters. + - `List`, `Dict`, `Tuple`: For more complex types. + +Example: + +```python +from typing import List, Optional + +def export_frames(frames: List[QImage], output_dir: str) -> None: + ... +``` + +## Logging + +### Use `QgsMessageLog` for Logging + +- **Do not use `print()` statements** for debugging or outputting messages. +- Use `QgsMessageLog` for all logging to ensure messages are appropriately directed to QGIS's logging system. + +### Standardize Log Tags + +- **Tag all messages with `'AnimationWorkbench'`** to allow filtering in the QGIS log. +- Use appropriate log levels: + - **`Qgis.Info`**: For informational messages. + - **`Qgis.Warning`**: For warnings that do not interrupt the workflow. + - **`Qgis.Critical`**: For errors that need immediate attention. + +Examples: + +```python +QgsMessageLog.logMessage("Animation started.", tag="AnimationWorkbench", level=Qgis.Info) +QgsMessageLog.logMessage("Warning: Frame skipped.", tag="AnimationWorkbench", level=Qgis.Warning) +QgsMessageLog.logMessage("Error rendering frame.", tag="AnimationWorkbench", level=Qgis.Critical) +``` + +## Error Handling + +- **Graceful Error Handling**: Always catch exceptions and provide meaningful error messages through `QgsMessageLog`. +- **Use `try`/`except` Blocks**: Wrap code that may raise exceptions in `try`/`except` blocks and log the error. + +Example: + +```python +try: + self.render_animation() +except Exception as e: + QgsMessageLog.logMessage(f"Error rendering animation: {e}", tag="AnimationWorkbench", level=Qgis.Critical) +``` + +## GUI Development with PyQt5 + +- Follow the conventions required by PyQt5, using `camelCase` where necessary for properties and methods. +- Use descriptive variable names for widgets: + - **`frame_slider`** for `QSlider` + - **`output_path_edit`** for `QLineEdit` + - **`render_button`** for `QPushButton` +- Maintain a consistent naming pattern throughout the user interface code. + +## Code Structure + +### Order of Methods + +- **Class Methods Order**: + 1. **`__init__` method** + 2. **Public methods** in the order of their usage + 3. **Private (helper) methods** prefixed with `_` + +## Comments and Docstrings + +### Use Docstrings + +- Add docstrings to all functions, classes, and modules using the `"""triple quotes"""` format. +- Include a brief description, parameters, and return values where applicable. +- Use Google-style docstrings. + +Example: + +```python +def export_animation(self, output_path: str) -> bool: + """ + Exports the animation to a video file. + + Args: + output_path: The path where the video file will be saved. + + Returns: + True if the export was successful, False otherwise. + """ + ... +``` + +### Inline Comments + +- Use inline comments sparingly and only when necessary to clarify complex logic. +- Use the `#` symbol with a space to start the comment. + +Example: + +```python +# Calculate the interpolated position between keyframes +position = self._interpolate(start_pos, end_pos, t) +``` + +## Pre-commit Hooks + +This project uses pre-commit hooks to ensure code quality. Before committing, run: + +```bash +./scripts/checks.sh +``` + +Or install the hooks to run automatically: + +```bash +pre-commit install +``` + +## Development Environment + +### Using Nix Flakes + +This project uses Nix flakes for reproducible development environments. After enabling direnv: + +```bash +cd /path/to/QGISAnimationWorkbench +direnv allow +``` + +This will automatically set up your development environment with all required dependencies. + +### Launching QGIS + +Use the provided scripts to launch QGIS with the correct profile: + +```bash +./scripts/start_qgis.sh # Latest QGIS +./scripts/start_qgis_ltr.sh # LTR version +./scripts/start_qgis_master.sh # Development version +``` + +### VSCode Setup + +For VSCode development with proper Python paths and extensions: + +```bash +./scripts/vscode.sh +``` + +## Summary Checklist + +- **Naming**: Use `snake_case`, `PascalCase`, or `camelCase` as appropriate. +- **Formatting**: Use `black` for consistent code formatting. +- **Type Declarations**: Declare types for all variables and function signatures. +- **Logging**: Use `QgsMessageLog` with the tag `'AnimationWorkbench'`. +- **Error Handling**: Catch and log exceptions appropriately. +- **PyQt5**: Follow PyQt5's conventions for widget naming and handling. +- **Docstrings and Comments**: Use meaningful docstrings and comments to explain the code. +- **Pre-commit**: Run pre-commit hooks before committing code. + +Following these guidelines ensures that code within the Animation Workbench project is clear, consistent, and maintainable. diff --git a/README.md b/README.md index 3b1d2e9..a1528f0 100644 --- a/README.md +++ b/README.md @@ -1,70 +1,102 @@ - # QGIS Animation Workbench +**Bring your maps to life with stunning animations** + ![QGIS Animation Workbench](resources/img/logo/animation-workbench-logo.svg) -Welcome to the QGIS Animation Workbench (QAW). QAW is a [QGIS Plugin](https://qgis.org) that will help you bring your maps to life! Let's start with a quick overview. Click on the image below to view a 14 minute walkthrough on YouTube. +QGIS Animation Workbench (QAW) is a powerful [QGIS](https://qgis.org) plugin that transforms static maps into dynamic, cinematic animations. Create spinning globes, fly-through tours, animated symbols, and more - all without leaving QGIS. Whether you're producing educational content, storytelling with data, or showcasing geographic features, QAW provides an intuitive workbench for planning, previewing, and rendering professional map animations. + +![Animation Workbench Interface](docs/src/user/manual/img/017_AnimationPlan_1.png) + +## Badges + +| About | Status | +|-------|--------| +| [![Latest Release](https://img.shields.io/github/v/release/timlinux/QGISAnimationWorkbench.svg?include_prereleases)](https://github.com/timlinux/QGISAnimationWorkbench/releases/latest) | [![CI](https://github.com/timlinux/QGISAnimationWorkbench/actions/workflows/ci.yml/badge.svg)](https://github.com/timlinux/QGISAnimationWorkbench/actions/workflows/ci.yml) | +| [![QGIS Plugin](https://img.shields.io/badge/QGIS-Plugin-green.svg)](https://qgis.org/) | [![Lint](https://github.com/timlinux/QGISAnimationWorkbench/actions/workflows/BlackPythonCodeLinter.yml/badge.svg)](https://github.com/timlinux/QGISAnimationWorkbench/actions/workflows/BlackPythonCodeLinter.yml) | +| [![License: GPL v2](https://img.shields.io/badge/License-GPL_v2-blue.svg)](https://github.com/timlinux/QGISAnimationWorkbench/blob/master/LICENSE) | [![Docs](https://github.com/timlinux/QGISAnimationWorkbench/actions/workflows/BuildMKDocsAndPublishToGithubPages.yml/badge.svg)](https://github.com/timlinux/QGISAnimationWorkbench/actions/workflows/BuildMKDocsAndPublishToGithubPages.yml) | +| [![Python](https://img.shields.io/badge/Python-3.9+-blue.svg)](https://www.python.org/) | [![GitHub Pages](https://github.com/timlinux/QGISAnimationWorkbench/actions/workflows/pages/pages-build-deployment/badge.svg)](https://github.com/timlinux/QGISAnimationWorkbench/actions/workflows/pages/pages-build-deployment) | +| [![Open Issues](https://img.shields.io/github/issues/timlinux/QGISAnimationWorkbench)](https://github.com/timlinux/QGISAnimationWorkbench/issues) | [![Open PRs](https://img.shields.io/github/issues-pr/timlinux/QGISAnimationWorkbench)](https://github.com/timlinux/QGISAnimationWorkbench/pulls) | +| [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/timlinux/QGISAnimationWorkbench/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) | [![Dependabot](https://img.shields.io/badge/Dependabot-enabled-brightgreen.svg)](https://github.com/timlinux/QGISAnimationWorkbench/network/updates) | + +## Video Overview + +Click the image below to watch a 14-minute walkthrough on YouTube: + +[![Watch the Overview](docs/src/user/quickstart/img/QAW-IntroThumbnail.jpg)](https://youtu.be/DkS6yvnuypc) + +## Quickstart + +1. **Install the plugin** from the QGIS Plugin Manager or download from [Releases](https://github.com/timlinux/QGISAnimationWorkbench/releases) +2. **Open a QGIS project** with your map layers configured +3. **Launch Animation Workbench** from the Plugins menu +4. **Configure your animation** - choose render mode (Sphere, Planar, or Fixed Extent), set frame rate and duration +5. **Preview and render** your animation to video + +For detailed instructions, see the [Documentation](https://timlinux.github.io/QGISAnimationWorkbench/). + +## Examples -[![Overview](docs/start/img/QAW-IntroThumbnail.jpg)](https://youtu.be/DkS6yvnuypc) +**Spinning Globe:** -About | Status ---------|------------- -[![github release version](https://img.shields.io/github/v/release/timlinux/QGISAnimationWorkbench.svg?include_prereleases)](https://github.com/timlinux/QGISAnimationWorkbenchr/releases/latest) | [![Docs to PDF](https://github.com/timlinux/QGISAnimationWorkbench/actions/workflows/mkdocs-pdf.yml/badge.svg)](https://github.com/timlinux/QGISAnimationWorkbench/actions/workflows/mkdocs-pdf.yml) -[![QGIS Plugin Repository](https://img.shields.io/badge/Powered%20by-QGIS-blue.svg)](https://qgis.org/) | [![Lint](https://github.com/timlinux/QGISAnimationWorkbench/actions/workflows/black.yml/badge.svg)](https://github.com/timlinux/QGISAnimationWorkbench/actions/workflows/black.yml) -[![License: GPL v2](https://img.shields.io/badge/License-GPL_v2-blue.svg)](https://github.com/timlinux/QGISAnimationWorkbench/blob/master/LICENSE) | [![Publish docs via GitHub Pages](https://github.com/timlinux/QGISAnimationWorkbench/actions/workflows/mkdocs.yml/badge.svg)](https://github.com/timlinux/QGISAnimationWorkbench/actions/workflows/mkdocs.yml) -[![PRs welcome](https://img.shields.io/badge/PRs-welcome-ff69b4.svg)](https://github.com/timlinux/QGISAnimationWorkbench/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22)| [![pages-build-deployment](https://github.com/timlinux/QGISAnimationWorkbench/actions/workflows/pages/pages-build-deployment/badge.svg)](https://github.com/timlinux/QGISAnimationWorkbench/actions/workflows/pages/pages-build-deployment) -[![code with heart by timlinux](https://img.shields.io/badge/%3C%2F%3E%20with%20%E2%99%A5%20by-timlinux-ff1414.svg)](https://github.com/timlinux) | -[![code with heart by timlinux](https://img.shields.io/badge/%3C%2F%3E%20with%20%E2%99%A5%20by-nyalldawson-ff1414.svg)](https://github.com/nyalldawson) | +https://user-images.githubusercontent.com/178003/156930974-e6d4e76e-bfb0-4ee2-a2c5-030eba1aad8a.mp4 -## 📦 Packages +**Street Tour of Zaporizhzhia:** -| Name | Description | -| ---------------------------------------------------------------------------------------------------- | ------------------------------------ | -| [`Alpha Version 3`](https://github.com/timlinux/QGISAnimationWorkbench/archive/refs/tags/apha-3.zip) | Alpha Release (not production ready) | -| [`Alpha Version 2`](https://github.com/timlinux/QGISAnimationWorkbench/archive/refs/tags/apha-2.zip) | Alpha Release (not production ready) | -| [`Alpha Version 1`](https://github.com/timlinux/QGISAnimationWorkbench/archive/refs/tags/apha-1.zip) | Alpha Release (not production ready) | +https://user-images.githubusercontent.com/178003/156930785-d2cca084-e85d-4a67-8b6c-2dc090f08ac6.mp4 -## 📚 Documentation +*Data above © OpenStreetMap Contributors* -You can find documentation for this plugin on our [GitHub Pages Site](https://timlinux.github.io/QGISAnimationWorkbench/) and the source for this documentations is managed in the [docs](docs) folder. +**QGIS Developers Animation:** -## 🐾 Examples +https://user-images.githubusercontent.com/178003/156931066-87ce89e4-f8d7-46d9-9d30-aeba097f6d98.mp4 -Let's show you some examples! +## QGIS Compatibility -A simple spinning globe: +- Works with QGIS 3.x +- QGIS 3.26+ enables animated icon support ([PR #48060](https://github.com/qgis/QGIS/pull/48060)) +- For older versions, see the [snippets documentation](https://timlinux.github.io/QGISAnimationWorkbench/library/snippets/) - +## Documentation -A street tour of Zaporizhzhia: +- [Full Documentation](https://timlinux.github.io/QGISAnimationWorkbench/) - User guides, tutorials, and reference +- [Quickstart Guide](https://timlinux.github.io/QGISAnimationWorkbench/user/quickstart/) - Get started in minutes +- [API Reference](https://timlinux.github.io/QGISAnimationWorkbench/developer/) - For plugin developers - +## For Contributors -Data above © OpenStreetMap Contributors +We welcome contributions! Here's how to get involved: -QGIS Developers: +- [Report bugs or request features](https://github.com/timlinux/QGISAnimationWorkbench/issues) +- [Submit a Pull Request](https://github.com/timlinux/QGISAnimationWorkbench/pulls) +- [Development Setup](https://timlinux.github.io/QGISAnimationWorkbench/developer/) - +## For Developers -## 🌏 QGIS Support +```bash +# Clone the repository +git clone https://github.com/timlinux/QGISAnimationWorkbench.git +cd QGISAnimationWorkbench -Should work with and version of QGIS 3.x. If you have QGIS 3.26 or better you can benefit from the animated icon support (see @nyalldawson's most excellent patch [#48060](https://github.com/qgis/QGIS/pull/48060)). +# Enter the development environment +nix develop -For QGIS versions below 3.26, see the documentation for [QGIS Animation Workbench](https://timlinux.github.io/QGISAnimationWorkbench/library/snippets/) +# Build documentation +mkdocs serve +``` -## 🚀 Used By +## License -- [Tell Us](https://example.com) +This software is licensed under the [GPL v2](https://github.com/timlinux/QGISAnimationWorkbench/blob/master/LICENSE). -## 📜 License +## Credits -This software is licensed under the [GPL v2](https://github.com/timlinux/QGISAnimationWorkbench/blob/master/LICENSE) © [timlinux](https://github.com/timlinux). +- **Tim Sutton** - Lead developer +- **Nyall Dawson** - Core contributor +- **Mathieu Pellerin** - Contributor +- **Jeremy Prior** - Contributor +- **Thiasha Vythilingam** - Contributor -## 💛 Credits +--- -- Tim Sutton -- Nyall Dawson -- Mathieu Pellerin -- Jeremy Prior -- Thiasha Vythilingam - +Made with :heart: by [Kartoza](https://kartoza.com) | [Donate](https://github.com/sponsors/timlinux) | [GitHub](https://github.com/timlinux/QGISAnimationWorkbench) diff --git a/admin.py b/admin.py new file mode 100644 index 0000000..a83595e --- /dev/null +++ b/admin.py @@ -0,0 +1,572 @@ +# -*- coding: utf-8 -*- +"""QGIS Animation Workbench plugin admin operations""" + +import configparser +import datetime as dt +import json +import os +import shlex +import shutil +import subprocess # nosec B404 +import typing +import zipfile +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path + +import httpx +import typer + +LOCAL_ROOT_DIR = Path(__file__).parent.resolve() +SRC_NAME = "animation_workbench" +PACKAGE_NAME = SRC_NAME.replace("_", "") +TEST_FILES = ["docker-compose.yml", "scripts"] +app = typer.Typer() + + +@dataclass +class GithubRelease: + """ + Class for defining plugin releases details. + """ + + pre_release: bool + tag_name: str + url: str + published_at: dt.datetime + + +@app.callback() +def main(context: typer.Context, verbose: bool = False, qgis_profile: str = "default"): + """Performs various development-oriented tasks for this plugin + + :param context: Application context + :type context: typer.Context + + :param verbose: Boolean value to whether more details should be displayed + :type verbose: bool + + :param qgis_profile: QGIS user profile to be used when operating in + QGIS application + :type qgis_profile: str + + """ + context.obj = { + "verbose": verbose, + "qgis_profile": qgis_profile, + } + + +@app.command() +def install(context: typer.Context, build_src: bool = True): + """Deploys plugin to QGIS plugins directory + + :param context: Application context + :type context: typer.Context + + :param build_src: Whether to build plugin files from source + :type build_src: bool + """ + _log("Uninstalling...", context=context) + uninstall(context) + _log("Building...", context=context) + + built_directory = build(context, clean=True) if build_src else LOCAL_ROOT_DIR / "build" / SRC_NAME + + # For windows root dir in in AppData + if os.name == "nt": + print("User profile:") + print(os.environ["USERPROFILE"]) + plugin_path = os.path.join( + "AppData", + "Roaming", + "QGIS", + "QGIS3", + "profiles", + "default", + ) + root_directory = os.environ["USERPROFILE"] + "\\" + plugin_path + else: + root_directory = Path.home() / f".local/share/QGIS/QGIS3/profiles/" f"{context.obj['qgis_profile']}" + + base_target_directory = os.path.join(root_directory, "python/plugins", SRC_NAME) + _log(f"Copying built plugin to {base_target_directory}...", context=context) + shutil.copytree(built_directory, base_target_directory) + _log( + f"Installed {str(built_directory)!r}" f" into {str(base_target_directory)!r}", + context=context, + ) + + +@app.command() +def symlink(context: typer.Context, from_source: bool = False, build_first: bool = True): + """Create a plugin symlink to QGIS plugins directory + + :param context: Application context + :type context: typer.Context + + :param from_source: If True, symlink directly from source directory + (useful for live development). If False, symlink from build directory. + :type from_source: bool + + :param build_first: If True and not using --from-source, build the plugin + before creating the symlink. Default is True. + :type build_first: bool + """ + # Uninstall any existing plugin first + _log("Removing any existing plugin installation...", context=context) + uninstall(context) + + # Determine source path + if from_source: + source_path = LOCAL_ROOT_DIR / SRC_NAME + _log(f"Symlinking from source directory: {source_path}", context=context) + else: + if build_first: + _log("Building plugin first...", context=context) + build(context, clean=True) + source_path = LOCAL_ROOT_DIR / "build" / SRC_NAME + _log(f"Symlinking from build directory: {source_path}", context=context) + + if not source_path.exists(): + typer.echo(f"Error: Source path does not exist: {source_path}", err=True) + raise typer.Exit(code=1) + + # Determine QGIS plugins directory (platform-aware) + if os.name == "nt": + plugin_path = os.path.join( + "AppData", + "Roaming", + "QGIS", + "QGIS3", + "profiles", + context.obj["qgis_profile"], + ) + root_directory = Path(os.environ["USERPROFILE"]) / plugin_path + else: + root_directory = Path.home() / f".local/share/QGIS/QGIS3/profiles/{context.obj['qgis_profile']}" + + destination_path = root_directory / "python/plugins" / SRC_NAME + + # Ensure parent directory exists + destination_path.parent.mkdir(parents=True, exist_ok=True) + + # Create symlink + if destination_path.exists() or os.path.islink(destination_path): + _log(f"Removing existing path: {destination_path}", context=context) + if os.path.islink(destination_path): + os.unlink(destination_path) + else: + shutil.rmtree(str(destination_path), ignore_errors=True) + + os.symlink(source_path, destination_path) + _log(f"Created symlink: {destination_path} -> {source_path}", context=context) + + +@app.command() +def uninstall(context: typer.Context): + """Removes the plugin from QGIS plugins directory + + :param context: Application context + :type context: typer.Context + """ + root_directory = Path.home() / f".local/share/QGIS/QGIS3/profiles/" f"{context.obj['qgis_profile']}" + base_target_directory = root_directory / "python/plugins" / SRC_NAME + shutil.rmtree(str(base_target_directory), ignore_errors=True) + _log(f"Removed {str(base_target_directory)!r}", context=context) + + +@app.command() +def generate_zip( + context: typer.Context, + version: str = None, + output_directory: typing.Optional[Path] = LOCAL_ROOT_DIR / "dist", +): + """Generates plugin zip folder, that can be used to installed the + plugin in QGIS + + :param context: Application context + :type context: typer.Context + + :param version: Plugin version + :type version: str + + :param output_directory: Directory where the zip folder will be saved. + :type context: Path + """ + build_dir = build(context) + metadata = _get_metadata()["general"] + plugin_version = metadata["version"] if version is None else version + output_directory.mkdir(parents=True, exist_ok=True) + zip_path = output_directory / f"{SRC_NAME}.{plugin_version}.zip" + with zipfile.ZipFile(zip_path, "w") as fh: + _add_to_zip(build_dir, fh, arc_path_base=build_dir.parent) + typer.echo(f"zip generated at {str(zip_path)!r} " f"on {dt.datetime.now().strftime('%Y-%m-%d %H:%M')}") + return zip_path + + +@app.command() +def build( + context: typer.Context, + output_directory: typing.Optional[Path] = LOCAL_ROOT_DIR / "build" / SRC_NAME, + clean: bool = True, + tests: bool = False, +) -> Path: + """Builds plugin directory for use in QGIS application. + + :param context: Application context + :type context: typer.Context + + :param output_directory: Build output directory plugin where + files will be saved. + :type output_directory: Path + + :param clean: Whether current build directory files should be removed, + before writing new files. + :type clean: bool + + :param tests: Flag to indicate whether to include test related files. + :type tests: bool + + :returns: Build directory path. + :rtype: Path + """ + if clean: + shutil.rmtree(str(output_directory), ignore_errors=True) + output_directory.mkdir(parents=True, exist_ok=True) + copy_source_files(output_directory, tests=tests) + icon_path = copy_icon(output_directory) + if icon_path is None: + _log("Could not copy icon", context=context) + add_requirements_file(context, output_directory) + generate_metadata(context, output_directory) + return output_directory + + +@app.command() +def copy_icon( + output_directory: typing.Optional[Path] = LOCAL_ROOT_DIR / "build/temp", +) -> Path: + """Copies the plugin intended icon to the specified output + directory. + + :param output_directory: Output directory where the icon will be saved. + :type output_directory: Path + + :returns: Icon output directory path. + :rtype: Path + """ + + metadata = _get_metadata()["general"] + icon_path = LOCAL_ROOT_DIR / "resources" / metadata["icon"] + if icon_path.is_file(): + target_path = output_directory / icon_path.name + target_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(icon_path, target_path) + result = target_path + else: + result = None + return result + + +@app.command() +def copy_source_files( + output_directory: typing.Optional[Path] = LOCAL_ROOT_DIR / "build/temp", + tests: bool = False, +): + """Copies the plugin source files to the specified output + directory. + + :param output_directory: Output directory where the icon will be saved. + :type output_directory: Path + + :param tests: Flag to indicate whether to include test related files. + :type tests: bool + + """ + output_directory.mkdir(parents=True, exist_ok=True) + for child in (LOCAL_ROOT_DIR / SRC_NAME).iterdir(): + if child.name != "__pycache__": + target_path = output_directory / child.name + handler = shutil.copytree if child.is_dir() else shutil.copy + handler(str(child.resolve()), str(target_path)) + if tests: + for child in LOCAL_ROOT_DIR.iterdir(): + if child.name in TEST_FILES: + target_path = output_directory / child.name + handler = shutil.copytree if child.is_dir() else shutil.copy + handler(str(child.resolve()), str(target_path)) + + +@app.command() +def compile_resources( + context: typer.Context, + output_directory: typing.Optional[Path] = LOCAL_ROOT_DIR / "build/temp", +): + """Compiles plugin resources using the pyrcc package + + :param context: Application context + :type context: typer.Context + + :param output_directory: Output directory where the resources will be saved. + :type output_directory: Path + """ + resources_path = LOCAL_ROOT_DIR / "resources" / "resources.qrc" + target_path = output_directory / "resources.py" + target_path.parent.mkdir(parents=True, exist_ok=True) + _log(f"compile_resources target_path: {target_path}", context=context) + subprocess.run(shlex.split(f"pyrcc5 -o {target_path} {resources_path}")) # nosec B603 + + +@app.command() +def add_requirements_file( + context: typer.Context, + output_directory: typing.Optional[Path] = LOCAL_ROOT_DIR / "build/temp", +): + resources_path = LOCAL_ROOT_DIR / "requirements-dev.txt" + target_path = output_directory / "requirements-dev.txt" + + shutil.copy(str(resources_path.resolve()), str(target_path)) + + +@app.command() +def generate_metadata( + context: typer.Context, + output_directory: typing.Optional[Path] = LOCAL_ROOT_DIR / "build/temp", +): + """Generates plugin metadata file using settings defined in the + project configuration file config.json + + :param context: Application context + :type context: typer.Context + + :param output_directory: Output directory where the metadata.txt file will be saved. + :type output_directory: Path + """ + metadata = _get_metadata() + target_path = output_directory / "metadata.txt" + target_path.parent.mkdir(parents=True, exist_ok=True) + _log(f"generate_metadata target_path: {target_path}", context=context) + config = configparser.ConfigParser() + # do not modify case of parameters, as per + # https://docs.python.org/3/library/configparser.html#customizing-parser-behaviour + config.optionxform = lambda option: option + config["general"] = metadata["general"] + with target_path.open(mode="w") as fh: + config.write(fh) + + +@app.command() +def generate_plugin_repo_xml( + context: typer.Context, +): + """Generates the plugin repository xml file, from which users + can use to install the plugin in QGIS. + + :param context: Application context + :type context: typer.Context + """ + repo_base_dir = LOCAL_ROOT_DIR / "docs" / "repository" + repo_base_dir.mkdir(parents=True, exist_ok=True) + metadata = _get_metadata()["general"] + fragment_template = """ + + + + {version} + {qgis_minimum_version} + + {filename} + {icon} + + {download_url} + {update_date} + {experimental} + {deprecated} + + + + False + + """.strip() + contents = "\n" + all_releases = _get_existing_releases(context=context) + _log(f"Found {len(all_releases)} release(s)...", context=context) + for release in [r for r in _get_latest_releases(all_releases) if r is not None]: + tag_name = release.tag_name + _log(f"Processing release {tag_name}...", context=context) + fragment = fragment_template.format( + name=metadata.get("name"), + version=tag_name.replace("v", ""), + description=metadata.get("description"), + about=metadata.get("about"), + qgis_minimum_version=metadata.get("qgisMinimumVersion"), + homepage=metadata.get("homepage"), + filename=release.url.rpartition("/")[-1], + icon=metadata.get("icon", ""), + author=metadata.get("author"), + download_url=release.url, + update_date=release.published_at, + experimental=release.pre_release, + deprecated=metadata.get("deprecated"), + tracker=metadata.get("tracker"), + repository=metadata.get("repository"), + tags=metadata.get("tags"), + ) + contents = "\n".join((contents, fragment)) + contents = "\n".join((contents, "")) + repo_index = repo_base_dir / "plugins.xml" + repo_index.write_text(contents, encoding="utf-8") + _log(f"Plugin repo XML file saved at {repo_index}", context=context) + + return contents + + +@lru_cache() +def _get_metadata() -> typing.Dict: + """Reads the metadata properties from the + project configuration file 'config.json' + + :return: plugin metadata + :type: Dict + """ + config_path = LOCAL_ROOT_DIR / "config.json" + with config_path.open("r") as fh: + conf = json.load(fh) + general_plugin_config = conf["general"] + + general_metadata = general_plugin_config + + general_metadata.update( + { + "tags": ", ".join(general_plugin_config.get("tags", [])), + "changelog": _changelog(), + } + ) + + metadata = {"general": general_metadata} + + return metadata + + +def _changelog() -> str: + """Reads the changelog content from a config file. + + :returns: Plugin changelog + :type: str + """ + path = LOCAL_ROOT_DIR / "CHANGELOG.md" + + if path.exists(): + with path.open() as fh: + changelog_file = fh.read() + else: + changelog_file = "" + + return changelog_file + + +def _add_to_zip(directory: Path, zip_handler: zipfile.ZipFile, arc_path_base: Path): + """Adds to files inside the passed directory to the zip file. + + :param directory: Directory with files that are to be zipped. + :type directory: Path + + :param zip_handler: Plugin zip file + :type zip_handler: ZipFile + + :param arc_path_base: Parent directory of the input files directory. + :type arc_path_base: Path + """ + for item in directory.iterdir(): + if item.is_file(): + zip_handler.write(item, arcname=str(item.relative_to(arc_path_base))) + else: + _add_to_zip(item, zip_handler, arc_path_base) + + +def _log(msg, *args, context: typing.Optional[typer.Context] = None, **kwargs): + """Logs the message into the terminal. + :param msg: Directory with files that are to be zipped. + :type msg: str + + :param context: Application context + :type context: typer.Context + """ + if context is not None: + context_user_data = context.obj or {} + verbose = context_user_data.get("verbose", True) + else: + verbose = True + if verbose: + typer.echo(msg, *args, **kwargs) + + +def _get_existing_releases( + context: typing.Optional = None, +) -> typing.List[GithubRelease]: + """Gets the existing plugin releases in available in the Github repository. + + :param context: Application context + :type context: typer.Context + + :returns: List of github releases + :rtype: List[GithubRelease] + """ + base_url = "https://api.github.com/repos/" "timlinux/QGISAnimationWorkbench/releases" + response = httpx.get(base_url) + result = [] + if response.status_code == 200: + payload = response.json() + for release in payload: + for asset in release["assets"]: + if asset.get("content_type") == "application/zip": + zip_download_url = asset.get("browser_download_url") + break + else: + zip_download_url = None + _log(f"zip_download_url: {zip_download_url}", context=context) + if zip_download_url is not None: + result.append( + GithubRelease( + pre_release=release.get("prerelease", True), + tag_name=release.get("tag_name"), + url=zip_download_url, + published_at=dt.datetime.strptime(release["published_at"], "%Y-%m-%dT%H:%M:%SZ"), + ) + ) + return result + + +def _get_latest_releases( + current_releases: typing.List[GithubRelease], +) -> typing.Tuple[typing.Optional[GithubRelease], typing.Optional[GithubRelease]]: + """Searches for the latest plugin releases from the Github plugin releases. + + :param current_releases: Existing plugin releases + available in the Github repository. + :type current_releases: list + + :returns: Tuple containing the latest stable and experimental releases + :rtype: tuple + """ + latest_experimental = None + latest_stable = None + for release in current_releases: + if release.pre_release: + if latest_experimental is not None: + if release.published_at > latest_experimental.published_at: + latest_experimental = release + else: + latest_experimental = release + else: + if latest_stable is not None: + if release.published_at > latest_stable.published_at: + latest_stable = release + else: + latest_stable = release + return latest_stable, latest_experimental + + +if __name__ == "__main__": + app() diff --git a/animation_workbench/__init__.py b/animation_workbench/__init__.py index b57af0f..1d12630 100644 --- a/animation_workbench/__init__.py +++ b/animation_workbench/__init__.py @@ -21,15 +21,15 @@ import time from typing import Optional +from qgis.core import Qgis from qgis.PyQt.QtCore import Qt from qgis.PyQt.QtGui import QIcon -from qgis.PyQt.QtWidgets import QMessageBox, QPushButton, QAction -from qgis.core import Qgis +from qgis.PyQt.QtWidgets import QAction, QMessageBox, QPushButton from .animation_workbench import AnimationWorkbench from .core import RenderQueue, setting -from .utilities import resources_path from .gui import AnimationWorkbenchOptionsFactory +from .utilities import resources_path def classFactory(iface): # pylint: disable=missing-function-docstring @@ -64,9 +64,7 @@ def initGui(self): # pylint: disable=missing-function-docstring debug_mode = int(setting(key="debug_mode", default=0)) if debug_mode: debug_icon = QIcon(resources_path("icons", "animation-workbench-debug.svg")) - self.debug_action = QAction( - debug_icon, "Animation Workbench Debug Mode", self.iface.mainWindow() - ) + self.debug_action = QAction(debug_icon, "Animation Workbench Debug Mode", self.iface.mainWindow()) self.debug_action.triggered.connect(self.debug) self.iface.addToolBarIcon(self.debug_action) @@ -142,11 +140,7 @@ def display_information_message_bar( if more_details: button = QPushButton(widget) button.setText(button_text) - button.pressed.connect( - lambda: self.display_information_message_box( - title=title, message=more_details - ) - ) + button.pressed.connect(lambda: self.display_information_message_box(title=title, message=more_details)) widget.layout().addWidget(button) self.iface.messageBar().pushWidget(widget, Qgis.Info, duration) diff --git a/animation_workbench/animation_workbench.py b/animation_workbench/animation_workbench.py index 44629b7..147e871 100644 --- a/animation_workbench/animation_workbench.py +++ b/animation_workbench/animation_workbench.py @@ -12,48 +12,67 @@ # of the CRS sequentially to create a spinning globe effect import os import tempfile -from functools import partial from typing import Optional -from PyQt5.QtMultimedia import QMediaContent, QMediaPlayer -from PyQt5.QtMultimediaWidgets import QVideoWidget -from qgis.PyQt.QtCore import pyqtSlot, QUrl -from qgis.PyQt.QtGui import QIcon, QPixmap, QImage -from qgis.PyQt.QtWidgets import ( - QStyle, - QFileDialog, - QDialog, - QDialogButtonBox, - QGridLayout, - QVBoxLayout, - QPushButton, +from .core.video_player import ( + get_system_player_name, + get_video_playback_instructions, + is_multimedia_available, + open_in_system_player, ) -from qgis.PyQt.QtXml import QDomDocument + +# Import multimedia components with fallback +_multimedia_available, _multimedia_error = is_multimedia_available() +if _multimedia_available: + from PyQt5.QtMultimedia import QMediaContent, QMediaPlayer + from PyQt5.QtMultimediaWidgets import QVideoWidget +else: + QMediaContent = None + QMediaPlayer = None + QVideoWidget = None from qgis.core import ( + QgsApplication, QgsExpressionContextUtils, - QgsProject, QgsMapLayerProxyModel, - QgsReferencedRectangle, - QgsApplication, + QgsProject, QgsPropertyCollection, + QgsReferencedRectangle, QgsWkbTypes, ) from qgis.gui import QgsExtentWidget, QgsPropertyOverrideButton +from qgis.PyQt.QtCore import QUrl, pyqtSlot +from qgis.PyQt.QtGui import QIcon, QImage, QPixmap +from qgis.PyQt.QtWidgets import ( + QDialog, + QDialogButtonBox, + QFileDialog, + QGridLayout, + QMessageBox, + QPushButton, + QStyle, + QTextBrowser, + QToolButton, + QVBoxLayout, +) +from qgis.PyQt.QtXml import QDomDocument from .core import ( AnimationController, InvalidAnimationParametersException, + MapMode, MovieCreationTask, MovieFormat, set_setting, setting, - MapMode, ) +from .core.dependency_checker import DependencyChecker from .dialog_expression_context_generator import DialogExpressionContextGenerator +from .gui.kartoza_branding import KartozaFooter, apply_kartoza_styling from .utilities import get_ui_class, resources_path FORM_CLASS = get_ui_class("animation_workbench_base.ui") + # pylint: disable=too-many-public-methods class AnimationWorkbench(QDialog, FORM_CLASS): """Dialog implementation class Animation Workbench class.""" @@ -78,6 +97,11 @@ def __init__( """ QDialog.__init__(self, parent) self.setupUi(self) + + # Apply Kartoza branding and styling + apply_kartoza_styling(self) + self._setup_kartoza_footer() + self.expression_context_generator = DialogExpressionContextGenerator() self.main_tab.setCurrentIndex(0) self.extent_group_box = QgsExtentWidget(None, QgsExtentWidget.ExpandedStyle) @@ -102,10 +126,9 @@ def __init__( self.output_log_text_edit.append("Welcome to the QGIS Animation Workbench") self.output_log_text_edit.append("© Tim Sutton, Feb 2022") - ok_button = self.button_box.button(QDialogButtonBox.Ok) - # ok_button.clicked.connect(self.accept) - ok_button.setText("Run") - ok_button.setEnabled(False) + self.run_button = self.button_box.button(QDialogButtonBox.Ok) + self.run_button.setText("Run") + self.run_button.setEnabled(False) self.cancel_button = self.button_box.button(QDialogButtonBox.Cancel) self.cancel_button.clicked.connect(self.cancel_processing) @@ -121,12 +144,13 @@ def __init__( self.work_directory = tempfile.gettempdir() self.frame_filename_prefix = "animation_workbench" # place where final products are stored - output_file = setting( - key="output_file", default="", prefer_project_setting=True - ) + output_file = setting(key="output_file", default="", prefer_project_setting=True) if output_file: self.movie_file_edit.setText(output_file) - ok_button.setEnabled(True) + + # Connect output file edit to validation and update initial state + self.movie_file_edit.textChanged.connect(self._update_run_button_state) + self._update_run_button_state() self.movie_file_button.clicked.connect(self.set_output_name) @@ -134,9 +158,7 @@ def __init__( # types allowed in the QgsMapLayerSelector combo # See https://github.com/qgis/QGIS/issues/38472#issuecomment-715178025 self.layer_combo.setFilters( - QgsMapLayerProxyModel.PointLayer - | QgsMapLayerProxyModel.LineLayer - | QgsMapLayerProxyModel.PolygonLayer + QgsMapLayerProxyModel.PointLayer | QgsMapLayerProxyModel.LineLayer | QgsMapLayerProxyModel.PolygonLayer ) self.layer_combo.layerChanged.connect(self._layer_changed) @@ -146,26 +168,22 @@ def __init__( if layer: self.layer_combo.setLayer(layer) - prev_data_defined_properties_xml, _ = QgsProject.instance().readEntry( - "animation", "data_defined_properties" - ) + prev_data_defined_properties_xml, _ = QgsProject.instance().readEntry("animation", "data_defined_properties") if prev_data_defined_properties_xml: doc = QDomDocument() doc.setContent(prev_data_defined_properties_xml.encode()) elem = doc.firstChildElement("data_defined_properties") - self.data_defined_properties.readXml( - elem, AnimationController.DYNAMIC_PROPERTIES - ) + self.data_defined_properties.readXml(elem, AnimationController.DYNAMIC_PROPERTIES) self.extent_group_box.setOutputCrs(QgsProject.instance().crs()) - self.extent_group_box.setOutputExtentFromUser( - self.iface.mapCanvas().extent(), QgsProject.instance().crs() - ) + self.extent_group_box.setOutputExtentFromUser(self.iface.mapCanvas().extent(), QgsProject.instance().crs()) # self.extent_group_box.setOriginalExtnt() # Close button action (save state on close) self.button_box.button(QDialogButtonBox.Close).clicked.connect(self.close) + # Connect both accepted signal AND direct click to ensure accept() is called self.button_box.accepted.connect(self.accept) + self.run_button.clicked.connect(self.accept) self.button_box.button(QDialogButtonBox.Cancel).setEnabled(False) # Used by ffmpeg and convert to set the fps for rendered videos @@ -210,15 +228,21 @@ def __init__( == "true" ) # How many frames to render when we are in static mode - self.extent_frames_spin.setValue( - int( - setting( - key="frames_for_extent", - default="10", - prefer_project_setting=True, - ) + initial_frames = int( + setting( + key="frames_for_extent", + default="10", + prefer_project_setting=True, ) ) + self.extent_frames_spin.setValue(initial_frames) + # Connect signals that affect total frame count to update slider range + self.extent_frames_spin.valueChanged.connect(self._update_preview_frame_range) + self.framerate_spin.valueChanged.connect(self._update_preview_frame_range) + self.travel_duration_spin.valueChanged.connect(self._update_preview_frame_range) + self.hover_duration_spin.valueChanged.connect(self._update_preview_frame_range) + self.layer_combo.layerChanged.connect(self._update_preview_frame_range) + self.check_loop_features.toggled.connect(self._update_preview_frame_range) # Keep the scales the same if you dont want it to zoom in an out max_scale = float( setting( @@ -243,9 +267,7 @@ def __init__( self.setup_expression_contexts() - resolution_string = setting( - key="resolution", default="map_canvas", prefer_project_setting=True - ) + resolution_string = setting(key="resolution", default="map_canvas", prefer_project_setting=True) if resolution_string == "low_res": self.radio_low_res.setChecked(True) elif resolution_string == "medium_res": @@ -257,7 +279,11 @@ def __init__( self.setup_render_modes() + # Initialize preview frame range based on current settings + self._update_preview_frame_range() + self.current_preview_frame_render_job = None + self._preview_render_file = None # Set an initial image in the preview based on the current map self.show_preview_for_frame(0) @@ -266,9 +292,12 @@ def __init__( self.reuse_cache.setChecked(False) # Video playback stuff - see bottom of file for related methods - self.media_player = QMediaPlayer( - None, QMediaPlayer.VideoSurface # .video_preview_widget, - ) + self.current_movie_file = None + self._multimedia_available = _multimedia_available + if _multimedia_available: + self.media_player = QMediaPlayer(None, QMediaPlayer.VideoSurface) # .video_preview_widget, + else: + self.media_player = None self.setup_video_widget() # Enable options page on startup self.main_tab.setCurrentIndex(0) @@ -281,33 +310,86 @@ def __init__( self.movie_task = None self.preview_frame_spin.valueChanged.connect(self.show_preview_for_frame) - - self.register_data_defined_button( - self.scale_min_dd_btn, AnimationController.PROPERTY_MIN_SCALE - ) - self.register_data_defined_button( - self.scale_max_dd_btn, AnimationController.PROPERTY_MAX_SCALE - ) + self.preview_frame_spin.valueChanged.connect(self._sync_slider_from_spinbox) + self.preview_frame_slider.valueChanged.connect(self._sync_spinbox_from_slider) + self.preview_frame_slider.valueChanged.connect(self._update_easing_previews) + # Only render preview when slider is released (not during drag) + self.preview_frame_slider.sliderReleased.connect(self._on_slider_released) + + self.register_data_defined_button(self.scale_min_dd_btn, AnimationController.PROPERTY_MIN_SCALE) + self.register_data_defined_button(self.scale_max_dd_btn, AnimationController.PROPERTY_MAX_SCALE) + + def _setup_kartoza_footer(self): + """Add the Kartoza branding footer to the dialog above the button box.""" + main_layout = self.layout() + if main_layout and hasattr(self, "button_box"): + # Create the footer + footer = KartozaFooter(self) + # The layout is a QGridLayout - insert footer before button box + # Remove button_box, add footer at row 1, add button_box at row 2 + main_layout.removeWidget(self.button_box) + main_layout.addWidget(footer, 1, 0) + main_layout.addWidget(self.button_box, 2, 0) def setup_video_widget(self): """Set up the video widget.""" - video_widget = QVideoWidget() - # self.video_page.replaceWidget(self.video_preview_widget,video_widget) - self.play_button.setIcon(self.style().standardIcon(QStyle.SP_MediaPlay)) - self.play_button.clicked.connect(self.play) - self.media_player.setVideoOutput(video_widget) - self.media_player.stateChanged.connect(self.media_state_changed) - self.media_player.positionChanged.connect(self.position_changed) - self.media_player.durationChanged.connect(self.duration_changed) - self.media_player.error.connect(self.handle_video_error) layout = QGridLayout(self.video_preview_widget) - layout.addWidget(video_widget) + + if self._multimedia_available and QVideoWidget is not None: + # Full video player available + video_widget = QVideoWidget() + self.play_button.setIcon(self.style().standardIcon(QStyle.SP_MediaPlay)) + self.play_button.clicked.connect(self.play) + self.media_player.setVideoOutput(video_widget) + self.media_player.stateChanged.connect(self.media_state_changed) + self.media_player.positionChanged.connect(self.position_changed) + self.media_player.durationChanged.connect(self.duration_changed) + self.media_player.error.connect(self.handle_video_error) + layout.addWidget(video_widget, 0, 0) + else: + # Multimedia not available - show fallback UI + self._setup_fallback_video_ui(layout) + + # Add "Open in System Player" button next to play button + self._add_system_player_button() + + def _setup_fallback_video_ui(self, layout): + """Set up fallback UI when multimedia is not available.""" + # Create info display + info_widget = QTextBrowser() + info_widget.setOpenExternalLinks(True) + info_widget.setHtml(get_video_playback_instructions()) + layout.addWidget(info_widget, 0, 0) + + # Disable the play button and slider since they won't work + self.play_button.setEnabled(False) + self.play_button.setToolTip("Embedded player not available - use 'Open in System Player'") + self.video_slider.setEnabled(False) + + def _add_system_player_button(self): + """Add a button to open the video in the system player.""" + # Find the layout containing play_button + parent_layout = self.play_button.parent().layout() + if parent_layout is None: + return + + # Create the system player button + self.open_system_player_button = QToolButton() + self.open_system_player_button.setText("Open External") + self.open_system_player_button.setToolTip(f"Open video in {get_system_player_name()}") + self.open_system_player_button.setIcon(self.style().standardIcon(QStyle.SP_MediaPlay)) + self.open_system_player_button.setToolButtonStyle(2) # TextBesideIcon + self.open_system_player_button.clicked.connect(self._open_in_system_player) + self.open_system_player_button.setEnabled(False) + + # Insert after play button + if isinstance(parent_layout, QGridLayout): + # Find position of play button and add new button + parent_layout.addWidget(self.open_system_player_button, 2, 2) def setup_render_modes(self): """Set up the render modes.""" - mode_string = setting( - key="map_mode", default="sphere", prefer_project_setting=True - ) + mode_string = setting(key="map_mode", default="sphere", prefer_project_setting=True) if mode_string == "sphere": self.radio_sphere.setChecked(True) self.settings_stack.setCurrentIndex(0) @@ -321,6 +403,10 @@ def setup_render_modes(self): self.radio_planar.toggled.connect(self.show_non_fixed_extent_settings) self.radio_sphere.toggled.connect(self.show_non_fixed_extent_settings) self.radio_extent.toggled.connect(self.show_fixed_extent_settings) + # Update frame range when mode changes + self.radio_planar.toggled.connect(self._update_preview_frame_range) + self.radio_sphere.toggled.connect(self._update_preview_frame_range) + self.radio_extent.toggled.connect(self._update_preview_frame_range) def setup_easings(self): """Set up the easing options for the gui.""" @@ -328,9 +414,7 @@ def setup_easings(self): # custom widgets implemented in easing_preview.py # and added in designer as promoted widgets. self.pan_easing_widget.set_checkbox_label("Enable Pan Easing") - pan_easing_name = setting( - key="pan_easing", default="Linear", prefer_project_setting=True - ) + pan_easing_name = setting(key="pan_easing", default="Linear", prefer_project_setting=True) self.pan_easing_widget.set_preview_color("#ffff00") self.pan_easing_widget.set_easing_by_name(pan_easing_name) if ( @@ -348,9 +432,7 @@ def setup_easings(self): self.pan_easing_widget.enable() self.zoom_easing_widget.set_checkbox_label("Enable Zoom Easing") - zoom_easing_name = setting( - key="zoom_easing", default="Linear", prefer_project_setting=True - ) + zoom_easing_name = setting(key="zoom_easing", default="Linear", prefer_project_setting=True) self.zoom_easing_widget.set_preview_color("#0000ff") self.zoom_easing_widget.set_easing_by_name(zoom_easing_name) if ( @@ -373,38 +455,20 @@ def setup_media_widgets(self): self.outro_media.set_media_type("images") self.music_media.set_media_type("sounds") - self.intro_media.from_json( - setting(key="intro_media", default="{}", prefer_project_setting=True) - ) - self.outro_media.from_json( - setting(key="outro_media", default="{}", prefer_project_setting=True) - ) - self.music_media.from_json( - setting(key="music_media", default="{}", prefer_project_setting=True) - ) + self.intro_media.from_json(setting(key="intro_media", default="{}", prefer_project_setting=True)) + self.outro_media.from_json(setting(key="outro_media", default="{}", prefer_project_setting=True)) + self.music_media.from_json(setting(key="music_media", default="{}", prefer_project_setting=True)) def setup_expression_contexts(self): """Set up all the expression context variables.""" - QgsExpressionContextUtils.setProjectVariable( - QgsProject.instance(), "frames_per_feature", 0 - ) - QgsExpressionContextUtils.setProjectVariable( - QgsProject.instance(), "current_frame_for_feature", 0 - ) - QgsExpressionContextUtils.setProjectVariable( - QgsProject.instance(), "dwell_frames_per_feature", 0 - ) - QgsExpressionContextUtils.setProjectVariable( - QgsProject.instance(), "current_feature_id", 0 - ) + QgsExpressionContextUtils.setProjectVariable(QgsProject.instance(), "frames_per_feature", 0) + QgsExpressionContextUtils.setProjectVariable(QgsProject.instance(), "current_frame_for_feature", 0) + QgsExpressionContextUtils.setProjectVariable(QgsProject.instance(), "dwell_frames_per_feature", 0) + QgsExpressionContextUtils.setProjectVariable(QgsProject.instance(), "current_feature_id", 0) # None, Panning, Hovering - QgsExpressionContextUtils.setProjectVariable( - QgsProject.instance(), "current_animation_action", "None" - ) + QgsExpressionContextUtils.setProjectVariable(QgsProject.instance(), "current_animation_action", "None") - QgsExpressionContextUtils.setProjectVariable( - QgsProject.instance(), "total_frame_count", "None" - ) + QgsExpressionContextUtils.setProjectVariable(QgsProject.instance(), "total_frame_count", "None") def debug_button_clicked(self): """Show the different ffmpeg commands that will be run to process the images.""" @@ -424,12 +488,14 @@ def debug_button_clicked(self): def close(self): # pylint: disable=missing-function-docstring """Handler for the close button.""" - self.save_state() + try: + self.save_state() + except Exception as e: + # Don't let save_state failure prevent closing + self.output_log_text_edit.append(f"Warning: Could not save state: {e}") self.reject() - def closeEvent( - self, event - ): # pylint: disable=missing-function-docstring,unused-argument + def closeEvent(self, event): # pylint: disable=missing-function-docstring,unused-argument self.save_state() self.reject() @@ -463,9 +529,7 @@ def _update_property(self): Triggered when a property override button value is changed """ button = self.sender() - self.data_defined_properties.setProperty( - button.propertyKey(), button.toProperty() - ) + self.data_defined_properties.setProperty(button.propertyKey(), button.toProperty()) def update_data_defined_button(self, button): """ @@ -476,9 +540,7 @@ def update_data_defined_button(self, button): return button.blockSignals(True) - button.setToProperty( - self.data_defined_properties.property(button.propertyKey()) - ) + button.setToProperty(self.data_defined_properties.property(button.propertyKey())) button.blockSignals(False) def show_message(self, message: str): @@ -508,23 +570,32 @@ def show_status(self): self.active_lcd.display(self.render_queue.active_queue_size()) self.total_tasks_lcd.display(self.render_queue.total_queue_size) self.remaining_features_lcd.display( - self.render_queue.total_feature_count - - self.render_queue.completed_feature_count + self.render_queue.total_feature_count - self.render_queue.completed_feature_count ) self.completed_tasks_lcd.display(self.render_queue.total_completed) self.completed_features_lcd.display(self.render_queue.completed_feature_count) self.progress_bar.setValue(self.render_queue.total_completed) + def _update_run_button_state(self): + """Update Run button and output file field based on whether output is set.""" + output_file = self.movie_file_edit.text().strip() + has_output = bool(output_file) + + # Update Run button state and tooltip + self.run_button.setEnabled(has_output) + if has_output: + self.run_button.setToolTip("Start rendering the animation") + self.movie_file_edit.setStyleSheet("") + else: + self.run_button.setToolTip("Output file not set - click '...' to choose") + self.movie_file_edit.setStyleSheet("QLineEdit { border: 2px solid #e74c3c; background-color: #fdf2f2; }") + def set_output_name(self): """ Asks the user for the output video file path """ - # Popup a dialog to request the filename if scenario_file_path = None dialog_title = "Save video" - ok_button = self.button_box.button(QDialogButtonBox.Ok) - ok_button.setText("Run") - ok_button.setEnabled(False) output_directory = os.path.dirname(self.movie_file_edit.text()) if not output_directory: @@ -537,11 +608,8 @@ def set_output_name(self): os.path.join(output_directory, "qgis_animation.mp4"), "Video (*.mp4);;GIF (*.gif)", ) - if file_path is None or file_path == "": - ok_button.setEnabled(False) - return - ok_button.setEnabled(True) - self.movie_file_edit.setText(file_path) + if file_path: + self.movie_file_edit.setText(file_path) def choose_music_file(self): """ @@ -571,15 +639,9 @@ def save_state(self): value=self.framerate_spin.value(), store_in_project=True, ) - set_setting( - key="intro_media", value=self.intro_media.to_json(), store_in_project=True - ) - set_setting( - key="outro_media", value=self.outro_media.to_json(), store_in_project=True - ) - set_setting( - key="music_media", value=self.music_media.to_json(), store_in_project=True - ) + set_setting(key="intro_media", value=self.intro_media.to_json(), store_in_project=True) + set_setting(key="outro_media", value=self.outro_media.to_json(), store_in_project=True) + set_setting(key="music_media", value=self.music_media.to_json(), store_in_project=True) if self.radio_low_res.isChecked(): set_setting(key="resolution", value="low_res", store_in_project=True) @@ -654,28 +716,47 @@ def save_state(self): # only saved to project if self.layer_combo.currentLayer(): - QgsProject.instance().writeEntry( - "animation", "layer_id", self.layer_combo.currentLayer().id() - ) + QgsProject.instance().writeEntry("animation", "layer_id", self.layer_combo.currentLayer().id()) else: QgsProject.instance().removeEntry("animation", "layer_id") temp_doc = QDomDocument() dd_elem = temp_doc.createElement("data_defined_properties") - self.data_defined_properties.writeXml( - dd_elem, AnimationController.DYNAMIC_PROPERTIES - ) + self.data_defined_properties.writeXml(dd_elem, AnimationController.DYNAMIC_PROPERTIES) temp_doc.appendChild(dd_elem) - QgsProject.instance().writeEntry( - "animation", "data_defined_properties", temp_doc.toString() - ) + QgsProject.instance().writeEntry("animation", "data_defined_properties", temp_doc.toString()) - # Prevent the slot being called twize + # Prevent the slot being called twice @pyqtSlot() def accept(self): """Process the animation sequence. .. note:: This is called on OK click. """ + try: + # Check if output file is specified + output_file = self.movie_file_edit.text().strip() + if not output_file: + QMessageBox.warning( + self, + "Output File Required", + "Please specify an output file path before running.\n\n" + "Click the '...' button next to the output field to choose a location.", + ) + return + + # Pre-flight dependency check - verify tools are available BEFORE rendering + is_gif = self.radio_gif.isChecked() + valid, tool_path = DependencyChecker.validate_movie_export(for_gif=is_gif, parent=self) + if not valid: + self.output_log_text_edit.append( + "Export cancelled: Required tools not found. " + "Please install the missing dependencies and try again." + ) + return + except Exception as e: + QMessageBox.critical(self, "Error", f"An error occurred during pre-flight checks:\n{str(e)}") + return + # Enable progress page on accept self.main_tab.setCurrentIndex(5) # Image preview page @@ -684,7 +765,15 @@ def accept(self): # set parameter from dialog if not self.reuse_cache.isChecked(): - os.system("rm %s/%s*" % (self.work_directory, self.frame_filename_prefix)) + # Safely delete cached frame files using glob instead of shell + import glob as glob_module + + pattern = os.path.join(self.work_directory, f"{self.frame_filename_prefix}*") + for filepath in glob_module.glob(pattern): + try: + os.remove(filepath) + except OSError: + pass # Ignore errors when removing files self.save_state() @@ -698,14 +787,10 @@ def accept(self): controller.reuse_cache = self.reuse_cache.isChecked() - self.render_queue.set_annotations( - QgsProject.instance().annotationManager().annotations() - ) + self.render_queue.set_annotations(QgsProject.instance().annotationManager().annotations()) self.render_queue.set_decorations(self.iface.activeDecorations()) - self.output_log_text_edit.append( - "Generating {} frames".format(controller.total_frame_count) - ) + self.output_log_text_edit.append("Generating {} frames".format(controller.total_frame_count)) self.progress_bar.setMaximum(controller.total_frame_count) self.progress_bar.setValue(0) @@ -753,18 +838,12 @@ def create_controller(self) -> Optional[AnimationController]: if map_mode != MapMode.FIXED_EXTENT: if not self.layer_combo.currentLayer(): - self.output_log_text_edit.append( - "Cannot generate sequence without choosing a layer" - ) + self.output_log_text_edit.append("Cannot generate sequence without choosing a layer") return None - layer_type = QgsWkbTypes.displayString( - self.layer_combo.currentLayer().wkbType() - ) + layer_type = QgsWkbTypes.displayString(self.layer_combo.currentLayer().wkbType()) layer_name = self.layer_combo.currentLayer().name() - self.output_log_text_edit.append( - "Generating flight path for %s layer: %s" % (layer_type, layer_name) - ) + self.output_log_text_edit.append("Generating flight path for %s layer: %s" % (layer_type, layer_name)) if map_mode == MapMode.FIXED_EXTENT: controller = AnimationController.create_fixed_extent_controller( @@ -790,21 +869,15 @@ def create_controller(self) -> Optional[AnimationController]: min_scale=self.scale_range.minimumScale(), max_scale=self.scale_range.maximumScale(), loop=self.check_loop_features.isChecked(), - pan_easing=self.pan_easing_widget.get_easing() - if self.pan_easing_widget.is_enabled() - else None, - zoom_easing=self.zoom_easing_widget.get_easing() - if self.zoom_easing_widget.is_enabled() - else None, + pan_easing=self.pan_easing_widget.get_easing() if self.pan_easing_widget.is_enabled() else None, + zoom_easing=self.zoom_easing_widget.get_easing() if self.zoom_easing_widget.is_enabled() else None, frame_rate=self.framerate_spin.value(), ) except InvalidAnimationParametersException as e: self.output_log_text_edit.append(f"Processing halted: {e}") return None - controller.data_defined_properties = QgsPropertyCollection( - self.data_defined_properties - ) + controller.data_defined_properties = QgsPropertyCollection(self.data_defined_properties) return controller def processing_completed(self, success: bool): @@ -832,9 +905,7 @@ def processing_completed(self, success: bool): intro_command=intro_command, outro_command=outro_command, music_command=music_command, - output_format=MovieFormat.GIF - if self.radio_gif.isChecked() - else MovieFormat.MP4, + output_format=MovieFormat.GIF if self.radio_gif.isChecked() else MovieFormat.MP4, work_directory=self.work_directory, frame_filename_prefix=self.frame_filename_prefix, framerate=self.framerate_spin.value(), @@ -844,12 +915,28 @@ def log_message(message): self.output_log_text_edit.append(message) def show_movie(movie_file: str): + # Store the movie file path for system player fallback + self.current_movie_file = movie_file + # Video preview page self.main_tab.setCurrentIndex(5) self.preview_stack.setCurrentIndex(1) - self.media_player.setMedia(QMediaContent(QUrl.fromLocalFile(movie_file))) - self.play_button.setEnabled(True) - self.play() + + # Enable system player button + if hasattr(self, "open_system_player_button"): + self.open_system_player_button.setEnabled(True) + + if self._multimedia_available and self.media_player is not None: + # Try embedded player + self.media_player.setMedia(QMediaContent(QUrl.fromLocalFile(movie_file))) + self.play_button.setEnabled(True) + self.play() + else: + # Multimedia not available - offer to open in system player + self.output_log_text_edit.append(f"Video created successfully: {movie_file}") + self.output_log_text_edit.append("Embedded player not available. Click 'Open External' to view.") + # Auto-open in system player as a convenience + self._open_in_system_player() def cleanup_movie_task(): self.movie_task = None @@ -909,43 +996,152 @@ def show_preview_for_frame(self, frame: int): """ if self.radio_sphere.isChecked() or self.radio_planar.isChecked(): if not self.layer_combo.currentLayer(): - self.output_log_text_edit.append( - "Cannot generate sequence without choosing a layer" - ) + self.output_log_text_edit.append("Cannot generate sequence without choosing a layer") return if self.current_preview_frame_render_job: + # Disconnect signal before cancelling to prevent stale callbacks + try: + self.current_preview_frame_render_job.taskCompleted.disconnect(self._on_preview_render_complete) + except (TypeError, RuntimeError): + # Already disconnected or object deleted + pass self.current_preview_frame_render_job.cancel() self.current_preview_frame_render_job = None controller = self.create_controller() + if not controller: + return job = controller.create_job_for_frame(frame) if not job: return - def update_preview_image(file_name): - if not self.current_preview_frame_render_job: - return - - image = QImage(file_name) - if not image.isNull(): - pixmap = QPixmap.fromImage(image) - self.user_defined_preview.setPixmap(pixmap) - self.current_frame_preview.setPixmap(pixmap) - - self.current_preview_frame_render_job = None - job.file_name = "/tmp/tmp_image.png" + self._preview_render_file = job.file_name self.current_preview_frame_render_job = job.create_task() - self.current_preview_frame_render_job.taskCompleted.connect( - partial(update_preview_image, file_name=job.file_name) - ) - self.current_preview_frame_render_job.taskTerminated.connect( - partial(update_preview_image, file_name=job.file_name) - ) + # Use a proper method instead of nested function with partial + # to avoid closure issues when the task completes cross-thread. + # We use sender() in the callback to verify this is the current task. + self.current_preview_frame_render_job.taskCompleted.connect(self._on_preview_render_complete) + # Don't connect taskTerminated - cancelled tasks shouldn't update preview QgsApplication.taskManager().addTask(self.current_preview_frame_render_job) + def _on_preview_render_complete(self): + """Handle preview render task completion. + + Note: We cannot use sender() to verify the task because QgsTask + signals are emitted cross-thread and sender() may return None. + Instead, we just load whatever image is at the preview path. + Race conditions are mitigated by cancelling old tasks before + starting new ones. + """ + file_name = getattr(self, "_preview_render_file", None) + if file_name: + try: + image = QImage(file_name) + if not image.isNull(): + pixmap = QPixmap.fromImage(image) + self.user_defined_preview.setPixmap(pixmap) + self.current_frame_preview.setPixmap(pixmap) + except Exception: + # Silently ignore errors loading preview image + pass + + def _sync_slider_from_spinbox(self, value: int): + """Sync the slider position when spinbox value changes.""" + try: + # Use the slider's current maximum (already set by _update_preview_frame_range) + # to avoid calling _calculate_total_frames on every spinbox change + max_frames = self.preview_frame_slider.maximum() + if max_frames > 0: + self.preview_frame_slider.blockSignals(True) + self.preview_frame_slider.setValue(min(value, max_frames)) + self.preview_frame_slider.blockSignals(False) + except Exception: + # Ensure signals are unblocked even if there's an error + self.preview_frame_slider.blockSignals(False) + + def _sync_spinbox_from_slider(self, value: int): + """Sync the spinbox value when slider position changes. + + Note: This does NOT trigger a preview render - that happens + via _on_slider_released to avoid rendering during drag. + """ + try: + self.preview_frame_spin.blockSignals(True) + self.preview_frame_spin.setValue(value) + self.preview_frame_spin.blockSignals(False) + except Exception: + # Ensure signals are unblocked even if there's an error + self.preview_frame_spin.blockSignals(False) + + def _on_slider_released(self): + """Render preview when slider drag ends.""" + frame = self.preview_frame_slider.value() + self.show_preview_for_frame(frame) + + def _update_easing_previews(self, frame: int): + """Update easing preview dot positions based on current frame.""" + try: + # Use the slider's current maximum (already set by _update_preview_frame_range) + # to avoid calling _calculate_total_frames on every slider movement + max_frames = self.preview_frame_slider.maximum() + if max_frames > 0: + progress = frame / max_frames + self.pan_easing_widget.set_progress(progress) + self.zoom_easing_widget.set_progress(progress) + except Exception: + # Silently handle any errors to prevent UI freezing + pass + + def _calculate_total_frames(self) -> int: + """Calculate the total frame count based on current settings. + + For fixed extent mode: uses extent_frames_spin value. + For sphere/planar mode: matches AnimationController formula: + (feature_count * hover_frames) + (travel_segments * travel_frames) + Where travel_segments = feature_count if loop else (feature_count - 1). + This accounts for whether the animation returns to the first feature. + """ + if self.radio_extent.isChecked(): + return self.extent_frames_spin.value() + + # Sphere or planar mode + layer = self.layer_combo.currentLayer() + if not layer: + return 1 + + fps = self.framerate_spin.value() + travel_duration = self.travel_duration_spin.value() + hover_duration = self.hover_duration_spin.value() + feature_count = layer.featureCount() + loop = self.check_loop_features.isChecked() + + if feature_count == 0: + return 1 + + hover_frames = fps * hover_duration + travel_frames = fps * travel_duration + # When looping, we travel back to first feature; otherwise one less travel segment + travel_segments = feature_count if loop else max(0, feature_count - 1) + + total_frames = int((feature_count * hover_frames) + (travel_segments * travel_frames)) + return max(1, total_frames) + + def _update_preview_frame_range(self, *args): + """Update the slider, spinbox, and total frames label based on calculated frame count.""" + max_value = self._calculate_total_frames() + self.preview_frame_slider.setMaximum(max_value) + self.preview_frame_spin.setMaximum(max_value) + self.total_frames_label.setText(f"/ {max_value}") + # Also update easing previews for current position + current = self.preview_frame_slider.value() + if max_value > 0: + progress = current / max_value + self.pan_easing_widget.set_progress(progress) + self.zoom_easing_widget.set_progress(progress) + def load_image(self, name): """ Loads a preview image @@ -973,8 +1169,15 @@ def load_image(self, name): # Video Playback Methods def play(self): """ - Plays the video preview + Plays the video preview. + + Falls back to system player if embedded player is not available. """ + if not self._multimedia_available or self.media_player is None: + # Fallback to system player + self._open_in_system_player() + return + if self.media_player.state() == QMediaPlayer.PlayingState: self.media_player.pause() else: @@ -984,6 +1187,9 @@ def media_state_changed(self, state): # pylint: disable=unused-argument """ Called when the media state is changed """ + if not self._multimedia_available or self.media_player is None: + return + if self.media_player.state() == QMediaPlayer.PlayingState: self.play_button.setIcon(self.style().standardIcon(QStyle.SP_MediaPause)) else: @@ -1005,11 +1211,46 @@ def set_position(self, position): """ Sets the position of the playing video """ - self.media_player.setPosition(position) + if self._multimedia_available and self.media_player is not None: + self.media_player.setPosition(position) def handle_video_error(self): """ - Handles errors when playing videos + Handles errors when playing videos. + + When the embedded player fails, offers to open in system player. """ self.play_button.setEnabled(False) - self.output_log_text_edit.append(self.media_player.errorString()) + error_string = self.media_player.errorString() if self.media_player else "Unknown error" + self.output_log_text_edit.append(f"Video playback error: {error_string}") + self.output_log_text_edit.append("Click 'Open External' to view in your system media player.") + + # Offer to open in system player + if self.current_movie_file: + reply = QMessageBox.question( + self, + "Video Playback Error", + f"The embedded video player encountered an error:\n{error_string}\n\n" + f"Would you like to open the video in {get_system_player_name()}?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.Yes, + ) + if reply == QMessageBox.Yes: + self._open_in_system_player() + + def _open_in_system_player(self): + """Open the current movie file in the system's default media player.""" + if not self.current_movie_file: + QMessageBox.warning(self, "No Video Available", "No video file is available to open.") + return + + success, error = open_in_system_player(self.current_movie_file) + if success: + self.output_log_text_edit.append(f"Opened video in {get_system_player_name()}") + else: + QMessageBox.warning( + self, + "Could Not Open Video", + f"Failed to open video in system player:\n{error}\n\n" + f"The video file is located at:\n{self.current_movie_file}", + ) diff --git a/animation_workbench/core/__init__.py b/animation_workbench/core/__init__.py index 6867896..bdbfbdf 100644 --- a/animation_workbench/core/__init__.py +++ b/animation_workbench/core/__init__.py @@ -2,14 +2,13 @@ Core classes """ -from .constants import APPLICATION_NAME -from .settings import setting, set_setting - -from .animation_controller import ( - MapMode, +from .animation_controller import ( # noqa: F401 AnimationController, InvalidAnimationParametersException, + MapMode, ) -from .default_settings import default_settings -from .movie_creator import MovieFormat, MovieCommandGenerator, MovieCreationTask -from .render_queue import RenderJob, RenderQueue +from .constants import APPLICATION_NAME # noqa: F401 +from .default_settings import default_settings # noqa: F401 +from .movie_creator import MovieCommandGenerator, MovieCreationTask, MovieFormat # noqa: F401 +from .render_queue import RenderJob, RenderQueue # noqa: F401 +from .settings import set_setting, setting # noqa: F401 diff --git a/animation_workbench/core/animation_controller.py b/animation_workbench/core/animation_controller.py index c9ae5cc..caf8e92 100644 --- a/animation_workbench/core/animation_controller.py +++ b/animation_workbench/core/animation_controller.py @@ -9,28 +9,28 @@ import tempfile from enum import Enum from pathlib import Path -from typing import Optional, Iterator, List +from typing import Iterator, List, Optional -from qgis.PyQt.QtCore import QObject, pyqtSignal, QEasingCurve, QSize from qgis.core import ( - QgsPointXY, - QgsWkbTypes, - QgsProject, - QgsCoordinateTransform, + Qgis, QgsCoordinateReferenceSystem, - QgsReferencedRectangle, - QgsVectorLayer, - QgsMapSettings, + QgsCoordinateTransform, + QgsExpressionContext, QgsExpressionContextScope, - QgsRectangle, + QgsExpressionContextUtils, QgsFeature, QgsMapLayerUtils, - Qgis, - QgsPropertyDefinition, + QgsMapSettings, + QgsPointXY, + QgsProject, QgsPropertyCollection, - QgsExpressionContext, - QgsExpressionContextUtils, + QgsPropertyDefinition, + QgsRectangle, + QgsReferencedRectangle, + QgsVectorLayer, + QgsWkbTypes, ) +from qgis.PyQt.QtCore import QEasingCurve, QObject, QSize, pyqtSignal from .render_queue import RenderJob @@ -63,12 +63,8 @@ class AnimationController(QObject): PROPERTY_MAX_SCALE = 2 DYNAMIC_PROPERTIES = { - PROPERTY_MIN_SCALE: QgsPropertyDefinition( - "min_scale", "Minimum scale", QgsPropertyDefinition.DoublePositive - ), - PROPERTY_MAX_SCALE: QgsPropertyDefinition( - "max_scale", "Maximum scale", QgsPropertyDefinition.DoublePositive - ), + PROPERTY_MIN_SCALE: QgsPropertyDefinition("min_scale", "Minimum scale", QgsPropertyDefinition.DoublePositive), + PROPERTY_MAX_SCALE: QgsPropertyDefinition("max_scale", "Maximum scale", QgsPropertyDefinition.DoublePositive), } ACTION_HOVERING = "Hovering" @@ -97,9 +93,7 @@ def create_fixed_extent_controller( transformed_output_extent = ct.transformBoundingBox(output_extent) map_settings.setExtent(transformed_output_extent) - controller = AnimationController( - MapMode.FIXED_EXTENT, output_mode, map_settings - ) + controller = AnimationController(MapMode.FIXED_EXTENT, output_mode, map_settings) if feature_layer: controller.set_layer(feature_layer) controller.total_frame_count = total_frames @@ -139,12 +133,7 @@ def create_moving_extent_controller( controller.total_frame_count = int( ( controller.total_feature_count * hover_frames - + ( - (controller.total_feature_count - 1) - if not loop - else controller.total_feature_count - ) - * travel_frames + + ((controller.total_feature_count - 1) if not loop else controller.total_feature_count) * travel_frames ) ) # nopep8 @@ -161,20 +150,14 @@ def create_moving_extent_controller( return controller - def __init__( - self, map_mode: MapMode, output_mode: str, map_settings: QgsMapSettings - ): + def __init__(self, map_mode: MapMode, output_mode: str, map_settings: QgsMapSettings): super().__init__() self.map_settings: QgsMapSettings = map_settings self.map_mode: MapMode = map_mode self.output_mode: str = output_mode self.base_expression_context = QgsExpressionContext() - self.base_expression_context.appendScope( - QgsExpressionContextUtils.globalScope() - ) - self.base_expression_context.appendScope( - QgsExpressionContextUtils.projectScope(QgsProject.instance()) - ) + self.base_expression_context.appendScope(QgsExpressionContextUtils.globalScope()) + self.base_expression_context.appendScope(QgsExpressionContextUtils.projectScope(QgsProject.instance())) if output_mode == "1280:720": self.size = QSize(1280, 720) @@ -240,9 +223,9 @@ def create_job_for_frame(self, frame: int) -> Optional[RenderJob]: # inefficient, but we can rework later if needed! jobs = self.create_jobs() for _ in range(frame + 1): - try: # hacky fix for crash experienced by a user TODO + try: # hacky fix for crash experienced by a user TODO job = next(jobs) - except: + except StopIteration: pass return job @@ -314,23 +297,17 @@ def create_fixed_extent_job(self) -> Iterator[RenderJob]: ) scope.setVariable( "previous_feature_id", - None - if feature_idx == 0 - else self._features[feature_idx - 1].id(), + None if feature_idx == 0 else self._features[feature_idx - 1].id(), True, ) scope.setVariable( "next_feature", - None - if feature_idx == len(self._features) - 1 - else self._features[feature_idx + 1], + None if feature_idx == len(self._features) - 1 else self._features[feature_idx + 1], True, ) scope.setVariable( "next_feature_id", - None - if feature_idx == len(self._features) - 1 - else self._features[feature_idx + 1].id(), + None if feature_idx == len(self._features) - 1 else self._features[feature_idx + 1].id(), True, ) @@ -340,9 +317,7 @@ def create_fixed_extent_job(self) -> Iterator[RenderJob]: scope.setVariable("current_hover_frame", frame_for_feature) scope.setVariable("hover_frames", hover_frames) - scope.setVariable( - "current_animation_action", AnimationController.ACTION_HOVERING - ) + scope.setVariable("current_animation_action", AnimationController.ACTION_HOVERING) job = self.create_job( self.map_settings, @@ -366,9 +341,7 @@ def create_moving_extent_job(self) -> Iterator[RenderJob]: if feature_idx == 0: # first feature, need to evaluate the starting scale context = QgsExpressionContext(self.base_expression_context) - context.appendScope( - QgsExpressionContextUtils.mapSettingsScope(self.map_settings) - ) + context.appendScope(QgsExpressionContextUtils.mapSettingsScope(self.map_settings)) self._evaluated_max_scale = self.max_scale if self.data_defined_properties.hasActiveProperties(): @@ -382,9 +355,7 @@ def create_moving_extent_job(self) -> Iterator[RenderJob]: ) context = QgsExpressionContext(self.base_expression_context) - context.appendScope( - QgsExpressionContextUtils.mapSettingsScope(self.map_settings) - ) + context.appendScope(QgsExpressionContextUtils.mapSettingsScope(self.map_settings)) context.setFeature(feature) scope = QgsExpressionContextScope() @@ -418,9 +389,7 @@ def create_moving_extent_job(self) -> Iterator[RenderJob]: ) if feature_idx > 0: - for job in self.fly_feature_to_feature( - self._features[feature_idx - 1], feature - ): + for job in self.fly_feature_to_feature(self._features[feature_idx - 1], feature): yield job for job in self.hover_at_feature(feature_idx): @@ -497,9 +466,7 @@ def geometry_to_pointxy(self, feature: QgsFeature) -> Optional[QgsPointXY]: center = geom.centroid().asPoint() else: self.verbose_message.emit( - "Unsupported Feature Geometry Type: {}".format( - QgsWkbTypes.displayString(raw_geom.wkbType()) - ) + "Unsupported Feature Geometry Type: {}".format(QgsWkbTypes.displayString(raw_geom.wkbType())) ) center = None return center @@ -512,25 +479,13 @@ def hover_at_feature(self, feature_idx: int) -> Iterator[RenderJob]: """ feature = self._features[feature_idx] if not self.loop: - previous_feature = ( - None if feature_idx == 0 else self._features[feature_idx - 1] - ) - next_feature = ( - None - if feature_idx == len(self._features) - 1 - else self._features[feature_idx + 1] - ) + previous_feature = None if feature_idx == 0 else self._features[feature_idx - 1] + next_feature = None if feature_idx == len(self._features) - 1 else self._features[feature_idx + 1] else: # next and previous features must wrap around - previous_feature = ( - self._features[-1] - if feature_idx == 0 - else self._features[feature_idx - 1] - ) + previous_feature = self._features[-1] if feature_idx == 0 else self._features[feature_idx - 1] next_feature = ( - self._features[0] - if feature_idx == len(self._features) - 1 - else self._features[feature_idx + 1] + self._features[0] if feature_idx == len(self._features) - 1 else self._features[feature_idx + 1] ) center = self.geometry_to_pointxy(feature) @@ -608,9 +563,7 @@ def hover_at_feature(self, feature_idx: int) -> Iterator[RenderJob]: scope.setVariable("hover_frames", hover_frames, True) scope.setVariable("travel_frames", None, True) - scope.setVariable( - "current_animation_action", AnimationController.ACTION_HOVERING - ) + scope.setVariable("current_animation_action", AnimationController.ACTION_HOVERING) job = self.create_job(self.map_settings, file_name.as_posix(), [scope]) yield job @@ -661,9 +614,7 @@ def fly_feature_to_feature( # pylint: disable=too-many-locals,too-many-branches # Flying up # take progress from 0 -> 0.5 and scale to 0 -> 1 # before apply easing - zoom_factor = self.zoom_easing.valueForProgress( - progress_fraction * 2 - ) + zoom_factor = self.zoom_easing.valueForProgress(progress_fraction * 2) flying_up = True else: # flying down @@ -674,11 +625,7 @@ def fly_feature_to_feature( # pylint: disable=too-many-locals,too-many-branches # update max scale at the halfway point context = QgsExpressionContext(self.base_expression_context) context.setFeature(end_feature) - context.appendScope( - QgsExpressionContextUtils.mapSettingsScope( - self.map_settings - ) - ) + context.appendScope(QgsExpressionContextUtils.mapSettingsScope(self.map_settings)) scope = QgsExpressionContextScope() scope.setVariable("from_feature", start_feature, True) scope.setVariable("from_feature_id", start_feature.id(), True) @@ -705,9 +652,7 @@ def fly_feature_to_feature( # pylint: disable=too-many-locals,too-many-branches flying_up = False - zoom_factor = self.zoom_easing.valueForProgress( - (1 - progress_fraction) * 2 - ) + zoom_factor = self.zoom_easing.valueForProgress((1 - progress_fraction) * 2) zoom_factor = self.zoom_easing.valueForProgress(zoom_factor) scale = ( @@ -758,9 +703,7 @@ def fly_feature_to_feature( # pylint: disable=too-many-locals,too-many-branches scope.setVariable("hover_frames", None, True) scope.setVariable("travel_frames", travel_frames, True) - scope.setVariable( - "current_animation_action", AnimationController.ACTION_TRAVELLING - ) + scope.setVariable("current_animation_action", AnimationController.ACTION_TRAVELLING) job = self.create_job( self.map_settings, @@ -775,9 +718,7 @@ def create_job( self, map_settings: QgsMapSettings, name: str, - additional_expression_context_scopes: Optional[ - List[QgsExpressionContextScope] - ] = None, + additional_expression_context_scopes: Optional[List[QgsExpressionContextScope]] = None, ) -> RenderJob: """ Creates a render job for the given map settings diff --git a/animation_workbench/core/dependency_checker.py b/animation_workbench/core/dependency_checker.py new file mode 100644 index 0000000..89197ef --- /dev/null +++ b/animation_workbench/core/dependency_checker.py @@ -0,0 +1,443 @@ +# coding=utf-8 +"""Dependency checking and installation utilities for AnimationWorkbench.""" + +__copyright__ = "Copyright 2022, Tim Sutton" +__license__ = "GPL version 3" +__email__ = "tim@kartoza.com" +__revision__ = "$Format:%H$" + +import platform +import subprocess +import sys +from dataclasses import dataclass +from enum import Enum +from typing import List, Optional, Tuple + +from qgis.PyQt.QtGui import QFont +from qgis.PyQt.QtWidgets import ( + QDialog, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QTextEdit, + QVBoxLayout, +) + +from .utilities import CoreUtils + + +class DependencyStatus(Enum): + """Status of a dependency check.""" + + AVAILABLE = "available" + MISSING = "missing" + INSTALL_FAILED = "install_failed" + + +@dataclass +class DependencyResult: + """Result of a dependency check.""" + + name: str + status: DependencyStatus + path: Optional[str] = None + message: Optional[str] = None + + +class DependencyInstallDialog(QDialog): + """Dialog showing dependency installation instructions.""" + + def __init__(self, title: str, instructions: str, parent=None): + super().__init__(parent) + self.setWindowTitle(title) + self.setMinimumWidth(500) + self.setMinimumHeight(300) + + layout = QVBoxLayout(self) + + # Header + header = QLabel(title) + header_font = QFont() + header_font.setBold(True) + header_font.setPointSize(12) + header.setFont(header_font) + layout.addWidget(header) + + # Instructions + text_edit = QTextEdit() + text_edit.setReadOnly(True) + text_edit.setHtml(instructions) + layout.addWidget(text_edit) + + # Buttons + button_layout = QHBoxLayout() + button_layout.addStretch() + + ok_button = QPushButton("OK") + ok_button.clicked.connect(self.accept) + button_layout.addWidget(ok_button) + + layout.addLayout(button_layout) + + +class DependencyChecker: + """Checks for and helps install required dependencies.""" + + PYQTGRAPH_INSTALL_INSTRUCTIONS = """ +

pyqtgraph is required for easing curve previews

+ +

To install pyqtgraph, open a terminal/command prompt and run:

+ +

All Platforms:

+
pip install pyqtgraph
+ +

Or if using Python 3:

+
pip3 install pyqtgraph
+ +

On Windows (from OSGeo4W Shell):

+
python -m pip install pyqtgraph
+ +

On macOS/Linux with system Python:

+
python3 -m pip install --user pyqtgraph
+ +

After installing, please restart QGIS.

+""" + + @staticmethod + def get_ffmpeg_install_instructions() -> str: + """Get platform-specific ffmpeg installation instructions.""" + system = platform.system() + + if system == "Windows": + return """ +

FFmpeg is required for video export

+ +

FFmpeg is not installed or not found in your PATH.

+ +

Option 1: Download from official website

+
    +
  1. Visit https://ffmpeg.org/download.html
  2. +
  3. Click "Windows" and download a build (e.g., from gyan.dev)
  4. +
  5. Extract the zip file to a folder (e.g., C:\\ffmpeg)
  6. +
  7. Add the bin folder to your PATH: +
      +
    • Open Start Menu, search "Environment Variables"
    • +
    • Click "Environment Variables..."
    • +
    • Under "User variables", find "Path" and click "Edit"
    • +
    • Click "New" and add C:\\ffmpeg\\bin
    • +
    • Click OK to save
    • +
    +
  8. +
  9. Restart QGIS
  10. +
+ +

Option 2: Using Chocolatey (if installed)

+
choco install ffmpeg
+ +

Option 3: Using winget

+
winget install ffmpeg
+ +

After installing, restart QGIS for changes to take effect.

+""" + elif system == "Darwin": # macOS + return """ +

FFmpeg is required for video export

+ +

FFmpeg is not installed or not found in your PATH.

+ +

Option 1: Using Homebrew (recommended)

+
brew install ffmpeg
+ +

Option 2: Using MacPorts

+
sudo port install ffmpeg
+ +

Option 3: Download binary

+
    +
  1. Visit https://ffmpeg.org/download.html
  2. +
  3. Click "macOS" and download a static build
  4. +
  5. Extract and move ffmpeg to /usr/local/bin/
  6. +
+ +

After installing, restart QGIS for changes to take effect.

+""" + else: # Linux + return """ +

FFmpeg is required for video export

+ +

FFmpeg is not installed or not found in your PATH.

+ +

Ubuntu/Debian:

+
sudo apt update && sudo apt install ffmpeg
+ +

Fedora:

+
sudo dnf install ffmpeg
+ +

Arch Linux:

+
sudo pacman -S ffmpeg
+ +

openSUSE:

+
sudo zypper install ffmpeg
+ +

Using Nix:

+
nix-env -iA nixpkgs.ffmpeg
+ +

After installing, restart QGIS for changes to take effect.

+""" + + @staticmethod + def get_imagemagick_install_instructions() -> str: + """Get platform-specific ImageMagick installation instructions.""" + system = platform.system() + + if system == "Windows": + return """ +

ImageMagick is required for GIF export

+ +

ImageMagick (convert command) is not installed or not found in your PATH.

+ +

Option 1: Download installer

+
    +
  1. Visit https://imagemagick.org/script/download.php
  2. +
  3. Download the Windows installer (ImageMagick-x.x.x-Q16-HDRI-x64-dll.exe)
  4. +
  5. Important: During installation, check "Add application directory to your system path"
  6. +
  7. Complete the installation
  8. +
  9. Restart QGIS
  10. +
+ +

Option 2: Using Chocolatey

+
choco install imagemagick
+ +

After installing, restart QGIS for changes to take effect.

+""" + elif system == "Darwin": # macOS + return """ +

ImageMagick is required for GIF export

+ +

ImageMagick (convert command) is not installed or not found in your PATH.

+ +

Option 1: Using Homebrew (recommended)

+
brew install imagemagick
+ +

Option 2: Using MacPorts

+
sudo port install ImageMagick
+ +

After installing, restart QGIS for changes to take effect.

+""" + else: # Linux + return """ +

ImageMagick is required for GIF export

+ +

ImageMagick (convert command) is not installed or not found in your PATH.

+ +

Ubuntu/Debian:

+
sudo apt update && sudo apt install imagemagick
+ +

Fedora:

+
sudo dnf install ImageMagick
+ +

Arch Linux:

+
sudo pacman -S imagemagick
+ +

openSUSE:

+
sudo zypper install ImageMagick
+ +

Using Nix:

+
nix-env -iA nixpkgs.imagemagick
+ +

After installing, restart QGIS for changes to take effect.

+""" + + @classmethod + def check_pyqtgraph(cls) -> DependencyResult: + """Check if pyqtgraph is available.""" + try: + import pyqtgraph # noqa: F401 + + return DependencyResult( + name="pyqtgraph", status=DependencyStatus.AVAILABLE, message="pyqtgraph is installed" + ) + except ImportError: + return DependencyResult( + name="pyqtgraph", status=DependencyStatus.MISSING, message="pyqtgraph is not installed" + ) + + @classmethod + def install_pyqtgraph(cls) -> DependencyResult: + """Attempt to install pyqtgraph using pip.""" + try: + # Use subprocess instead of deprecated pip.main() + result = subprocess.run( + [sys.executable, "-m", "pip", "install", "pyqtgraph"], capture_output=True, text=True, timeout=120 + ) + if result.returncode == 0: + return DependencyResult( + name="pyqtgraph", status=DependencyStatus.AVAILABLE, message="pyqtgraph installed successfully" + ) + else: + return DependencyResult( + name="pyqtgraph", + status=DependencyStatus.INSTALL_FAILED, + message=f"Installation failed: {result.stderr}", + ) + except subprocess.TimeoutExpired: + return DependencyResult( + name="pyqtgraph", status=DependencyStatus.INSTALL_FAILED, message="Installation timed out" + ) + except Exception as e: + return DependencyResult( + name="pyqtgraph", status=DependencyStatus.INSTALL_FAILED, message=f"Installation error: {str(e)}" + ) + + @classmethod + def ensure_pyqtgraph(cls, parent=None, auto_install=True) -> bool: + """ + Ensure pyqtgraph is available, attempting installation if needed. + + :param parent: Parent widget for dialogs. + :param auto_install: Whether to attempt automatic installation. + :returns: True if pyqtgraph is available, False otherwise. + """ + result = cls.check_pyqtgraph() + + if result.status == DependencyStatus.AVAILABLE: + return True + + if auto_install: + # Ask user before installing + reply = QMessageBox.question( + parent, + "Install Required Dependency", + "The 'pyqtgraph' package is required for easing curve previews.\n\n" + "Would you like to install it now?\n\n" + "(This will run: pip install pyqtgraph)", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.Yes, + ) + + if reply == QMessageBox.Yes: + # Show progress + QMessageBox.information( + parent, + "Installing...", + "Installing pyqtgraph. This may take a moment.\n" "QGIS may appear unresponsive briefly.", + ) + + install_result = cls.install_pyqtgraph() + + if install_result.status == DependencyStatus.AVAILABLE: + QMessageBox.information( + parent, + "Installation Successful", + "pyqtgraph has been installed successfully.\n\n" + "Please restart QGIS to use the easing preview feature.", + ) + return False # Need restart + else: + # Show manual instructions + dialog = DependencyInstallDialog( + "Manual Installation Required", + f"

Automatic installation failed:

" + f"
{install_result.message}
" + f"{cls.PYQTGRAPH_INSTALL_INSTRUCTIONS}", + parent, + ) + dialog.exec_() + return False + else: + return False + else: + # Show manual instructions + dialog = DependencyInstallDialog( + "Missing Dependency: pyqtgraph", cls.PYQTGRAPH_INSTALL_INSTRUCTIONS, parent + ) + dialog.exec_() + return False + + @classmethod + def check_ffmpeg(cls) -> DependencyResult: + """Check if ffmpeg is available.""" + paths = CoreUtils.which("ffmpeg") + if paths: + return DependencyResult( + name="ffmpeg", status=DependencyStatus.AVAILABLE, path=paths[0], message=f"ffmpeg found at {paths[0]}" + ) + return DependencyResult(name="ffmpeg", status=DependencyStatus.MISSING, message="ffmpeg not found in PATH") + + @classmethod + def check_imagemagick(cls) -> DependencyResult: + """Check if ImageMagick convert command is available.""" + paths = CoreUtils.which("convert") + if paths: + return DependencyResult( + name="ImageMagick", + status=DependencyStatus.AVAILABLE, + path=paths[0], + message=f"convert found at {paths[0]}", + ) + return DependencyResult( + name="ImageMagick", status=DependencyStatus.MISSING, message="ImageMagick (convert) not found in PATH" + ) + + @classmethod + def check_movie_dependencies(cls, for_gif: bool = False) -> List[DependencyResult]: + """ + Check all dependencies required for movie creation. + + :param for_gif: If True, check for GIF requirements (ImageMagick). + If False, check for MP4 requirements (ffmpeg). + :returns: List of dependency check results. + """ + results = [] + + if for_gif: + results.append(cls.check_imagemagick()) + else: + results.append(cls.check_ffmpeg()) + + return results + + @classmethod + def show_missing_dependency_dialog(cls, results: List[DependencyResult], parent=None) -> bool: + """ + Show dialog for missing dependencies with installation instructions. + + :param results: List of dependency check results. + :param parent: Parent widget for dialog. + :returns: True if all dependencies are available, False otherwise. + """ + missing = [r for r in results if r.status == DependencyStatus.MISSING] + + if not missing: + return True + + instructions = "" + for result in missing: + if result.name == "ffmpeg": + instructions += cls.get_ffmpeg_install_instructions() + elif result.name == "ImageMagick": + instructions += cls.get_imagemagick_install_instructions() + + dialog = DependencyInstallDialog("Missing Dependencies", instructions, parent) + dialog.exec_() + return False + + @classmethod + def validate_movie_export(cls, for_gif: bool, parent=None) -> Tuple[bool, Optional[str]]: + """ + Validate that all dependencies for movie export are available. + + :param for_gif: Whether exporting as GIF (vs MP4). + :param parent: Parent widget for dialogs. + :returns: Tuple of (success, tool_path). If success is False, tool_path is None. + """ + results = cls.check_movie_dependencies(for_gif=for_gif) + missing = [r for r in results if r.status == DependencyStatus.MISSING] + + if missing: + cls.show_missing_dependency_dialog(results, parent) + return False, None + + # Return the path to the tool + tool_result = results[0] + return True, tool_result.path diff --git a/animation_workbench/core/movie_creator.py b/animation_workbench/core/movie_creator.py index 02aece7..0766ee1 100644 --- a/animation_workbench/core/movie_creator.py +++ b/animation_workbench/core/movie_creator.py @@ -11,8 +11,9 @@ from enum import Enum from typing import List, Optional, Tuple -from qgis.PyQt.QtCore import pyqtSignal, QProcess -from qgis.core import QgsTask, QgsBlockingProcess, QgsFeedback +from qgis.core import QgsBlockingProcess, QgsFeedback, QgsTask +from qgis.PyQt.QtCore import QProcess, pyqtSignal + from .settings import setting from .utilities import CoreUtils @@ -60,10 +61,17 @@ def as_commands(self) -> List[Tuple[str, List]]: # pylint: disable= R0915 Returns a list of commands necessary for the movie generation. :returns tuple: Returned as tuples of the command and arguments list. + :raises RuntimeError: If required tools (ffmpeg/convert) are not found. """ results = [] if self.format == MovieFormat.GIF: - convert = CoreUtils.which("convert")[0] + convert_paths = CoreUtils.which("convert") + if not convert_paths: + raise RuntimeError( + "ImageMagick 'convert' command not found. " + "Please install ImageMagick and ensure it is in your PATH." + ) + convert = convert_paths[0] # First generate the GIF. If this fails try to run the call from # the command line and check the path to convert (provided by @@ -113,7 +121,10 @@ def as_commands(self) -> List[Tuple[str, List]]: # pylint: disable= R0915 ) ) else: - ffmpeg = CoreUtils.which("ffmpeg")[0] + ffmpeg_paths = CoreUtils.which("ffmpeg") + if not ffmpeg_paths: + raise RuntimeError("FFmpeg not found. " "Please install FFmpeg and ensure it is in your PATH.") + ffmpeg = ffmpeg_paths[0] # Also, we will make a video of the scene - useful for cases where # you have a larger colour palette and gif will not hack it. # The Pad option is to deal with cases where ffmpeg complains @@ -289,9 +300,7 @@ def run_process(self, command: str, arguments: List[str]): """ Runs a process in a blocking way, reporting the stdout output to the user """ - self.message.emit( - "Generating Movie: {} {}".format(command, " ".join(arguments)) - ) + self.message.emit("Generating Movie: {} {}".format(command, " ".join(arguments))) def on_stdout(ba): val = ba.data().decode("UTF-8") @@ -345,12 +354,20 @@ def run(self): if self.format == MovieFormat.GIF: self.message.emit("Generating GIF") - convert = CoreUtils.which("convert")[0] - self.message.emit(f"convert found: {convert}") + convert_paths = CoreUtils.which("convert") + if not convert_paths: + self.message.emit( + "ERROR: ImageMagick 'convert' command not found. " "Please install ImageMagick and restart QGIS." + ) + return False + self.message.emit(f"convert found: {convert_paths[0]}") else: self.message.emit("Generating MP4 Movie") - ffmpeg = CoreUtils.which("ffmpeg")[0] - self.message.emit(f"ffmpeg found: {ffmpeg}") + ffmpeg_paths = CoreUtils.which("ffmpeg") + if not ffmpeg_paths: + self.message.emit("ERROR: FFmpeg not found. " "Please install FFmpeg and restart QGIS.") + return False + self.message.emit(f"ffmpeg found: {ffmpeg_paths[0]}") # This will create a temporary working dir & filename # that is secure and clean up after itself. @@ -373,7 +390,13 @@ def run(self): temp_dir=tmp, ) - for command, arguments in generator.as_commands(): + try: + commands = generator.as_commands() + except RuntimeError as e: + self.message.emit(f"ERROR: {str(e)}") + return False + + for command, arguments in commands: self.run_process(command, arguments) self.movie_created.emit(self.output_file) diff --git a/animation_workbench/core/render_queue.py b/animation_workbench/core/render_queue.py index 274b28c..f8e0afe 100644 --- a/animation_workbench/core/render_queue.py +++ b/animation_workbench/core/render_queue.py @@ -23,18 +23,19 @@ # DO NOT REMOVE THIS - it forces sip2 # noinspection PyUnresolvedReferences -import qgis # pylint: disable=unused-import -from qgis.PyQt.QtCore import QObject, pyqtSignal -from qgis.PyQt.QtGui import QImage -from qgis.core import QgsApplication, QgsMapRendererParallelJob +import qgis # noqa: F401 # pylint: disable=unused-import from qgis.core import ( + Qgis, + QgsApplication, + QgsFeedback, + QgsMapRendererParallelJob, QgsMapRendererTask, QgsMapSettings, QgsProxyProgressTask, - QgsFeedback, - Qgis, QgsTask, ) +from qgis.PyQt.QtCore import QObject, pyqtSignal +from qgis.PyQt.QtGui import QImage from .settings import setting @@ -135,9 +136,7 @@ def __init__(self, parent=None): # during rendering. Probably setting to the same number # of CPU cores you have would be a good conservative approach # You could probably run 100 or more on a decently specced machine - self.render_thread_pool_size = int( - setting(key="render_thread_pool_size", default=100) - ) + self.render_thread_pool_size = int(setting(key="render_thread_pool_size", default=100)) # A list of tasks that need to be rendered but # cannot be because the job queue is too full. # we pop items off this list self.render_thread_pool_size @@ -159,6 +158,7 @@ def __init__(self, parent=None): self.decorations = [] self.frames_per_feature = 0 + self._canceling = False # Flag to prevent race conditions during cancel def active_queue_size(self) -> int: """ @@ -190,25 +190,42 @@ def cancel_processing(self): """ Cancels any in-progress operation """ + self._canceling = True + self.job_queue.clear() self.total_queue_size = 0 self.total_completed = 0 self.total_feature_count = 0 self.completed_feature_count = 0 - self.proxy_feedback.cancel() + if self.proxy_feedback: + self.proxy_feedback.cancel() - for _, task in self.active_tasks.items(): - task.cancel() + # Copy the tasks list to avoid "dictionary changed size during iteration" + tasks_to_cancel = list(self.active_tasks.values()) + self.active_tasks.clear() + + for task in tasks_to_cancel: + try: + task.cancel() + except Exception: + pass # Task may already be finished if self.proxy_task: - self.proxy_task.finalize(False) + try: + self.proxy_task.finalize(False) + except Exception: + pass # May already be finalized self.proxy_task = None + self.proxy_feedback = None self.frames_per_feature = 0 self.annotations_list = [] self.decorations = [] - self.status_message.emit("Cancelling...") + + self.status_message.emit("Cancelled") + self._canceling = False + self.processing_completed.emit(False) def update_status(self): """ @@ -242,13 +259,20 @@ def process_queue(self): """ Feed the QgsTaskManager with next task """ + # Don't process if we're in the middle of canceling + if self._canceling: + return + if not self.job_queue and not self.active_tasks: # all done! self.update_status() was_canceled = self.proxy_feedback and self.proxy_feedback.isCanceled() self.processing_completed.emit(not was_canceled) if self.proxy_task: - self.proxy_task.finalize(not was_canceled) + try: + self.proxy_task.finalize(not was_canceled) + except Exception: + pass # May already be finalized self.proxy_task = None return @@ -268,12 +292,8 @@ def process_queue(self): task = job.create_task(self.annotations_list, self.decorations, hidden=True) self.active_tasks[job.file_name] = task - task.taskCompleted.connect( - partial(self.task_completed, file_name=job.file_name) - ) - task.taskTerminated.connect( - partial(self.finalize_task, file_name=job.file_name) - ) + task.taskCompleted.connect(partial(self.task_completed, file_name=job.file_name)) + task.taskTerminated.connect(partial(self.finalize_task, file_name=job.file_name)) QgsApplication.taskManager().addTask(task) self.proxy_feedback.set_remaining_steps(len(self.job_queue)) @@ -296,9 +316,7 @@ def finalize_task(self, file_name: str): self.total_completed += 1 if self.frames_per_feature: - self.completed_feature_count = int( - self.total_completed / self.frames_per_feature - ) + self.completed_feature_count = int(self.total_completed / self.frames_per_feature) self.status_changed.emit() self.process_queue() diff --git a/animation_workbench/core/settings.py b/animation_workbench/core/settings.py index a3ff235..6cf743e 100644 --- a/animation_workbench/core/settings.py +++ b/animation_workbench/core/settings.py @@ -21,9 +21,8 @@ import json from collections import OrderedDict -from qgis.PyQt.QtCore import QSettings - from qgis.core import QgsProject +from qgis.PyQt.QtCore import QSettings from .constants import APPLICATION_NAME from .default_settings import default_settings diff --git a/animation_workbench/core/utilities.py b/animation_workbench/core/utilities.py index 2da813a..771fffb 100644 --- a/animation_workbench/core/utilities.py +++ b/animation_workbench/core/utilities.py @@ -18,9 +18,9 @@ # (at your option) any later version. # --------------------------------------------------------------------- -from math import floor import os import sys +from math import floor class CoreUtils: @@ -57,18 +57,14 @@ def which(name, flags=os.X_OK): """ result = [] # pylint: disable=W0141 - extensions = [ - _f for _f in os.environ.get("PATHEXT", "").split(os.pathsep) if _f - ] + extensions = [_f for _f in os.environ.get("PATHEXT", "").split(os.pathsep) if _f] # pylint: enable=W0141 path = os.environ.get("PATH", None) # In c6c9b26 we removed this hard coding for issue #529 but I am # adding it back here in case the user's path does not include the # gdal binary dir on OSX but it is actually there. (TS) if sys.platform == "darwin": # Mac OS X - gdal_prefix = ( - "/Library/Frameworks/GDAL.framework/Versions/Current/Programs/" - ) + gdal_prefix = "/Library/Frameworks/GDAL.framework/Versions/Current/Programs/" path = "%s:%s" % (path, gdal_prefix) if path is None: diff --git a/animation_workbench/core/video_player.py b/animation_workbench/core/video_player.py new file mode 100644 index 0000000..c72c96f --- /dev/null +++ b/animation_workbench/core/video_player.py @@ -0,0 +1,156 @@ +# coding=utf-8 +"""Video player utilities for AnimationWorkbench.""" + +__copyright__ = "Copyright 2022, Tim Sutton" +__license__ = "GPL version 3" +__email__ = "tim@kartoza.com" +__revision__ = "$Format:%H$" + +import os +import platform +import subprocess +from typing import Optional, Tuple + +from qgis.PyQt.QtCore import QUrl +from qgis.PyQt.QtGui import QDesktopServices + +# Try to import multimedia components +_multimedia_available = False +_multimedia_error = None + +try: + from PyQt5.QtMultimedia import QMediaContent, QMediaPlayer # noqa: F401 + from PyQt5.QtMultimediaWidgets import QVideoWidget # noqa: F401 + + _multimedia_available = True +except ImportError as e: + _multimedia_error = str(e) + + +def is_multimedia_available() -> Tuple[bool, Optional[str]]: + """ + Check if Qt Multimedia is available. + + :returns: Tuple of (available, error_message) + """ + return _multimedia_available, _multimedia_error + + +def open_in_system_player(file_path: str) -> Tuple[bool, Optional[str]]: + """ + Open a video file in the system's default media player. + + :param file_path: Path to the video file. + :returns: Tuple of (success, error_message) + """ + if not os.path.exists(file_path): + return False, f"File not found: {file_path}" + + system = platform.system() + + try: + # First try QDesktopServices - this is the most cross-platform approach + url = QUrl.fromLocalFile(file_path) + if QDesktopServices.openUrl(url): + return True, None + + # Fallback to platform-specific commands + if system == "Windows": + os.startfile(file_path) # noqa: S606 + return True, None + elif system == "Darwin": # macOS + subprocess.run(["open", file_path], check=True) # noqa: S603, S607 + return True, None + else: # Linux and others + # Try xdg-open first (most common on Linux) + try: + subprocess.run(["xdg-open", file_path], check=True) # noqa: S603, S607 + return True, None + except (subprocess.CalledProcessError, FileNotFoundError): + # Try other common players + for player in ["vlc", "mpv", "totem", "mplayer", "smplayer"]: + try: + subprocess.Popen([player, file_path]) # noqa: S603, S607 + return True, None + except FileNotFoundError: + continue + + return False, "No suitable video player found" + + except Exception as e: + return False, str(e) + + +def get_system_player_name() -> str: + """ + Get a user-friendly name for the system player action. + + :returns: Description string for the system player. + """ + system = platform.system() + if system == "Windows": + return "Windows Media Player" + elif system == "Darwin": + return "QuickTime Player" + else: + return "System Video Player" + + +class VideoPlayerStatus: + """Status codes for video player operations.""" + + SUCCESS = "success" + MULTIMEDIA_UNAVAILABLE = "multimedia_unavailable" + CODEC_ERROR = "codec_error" + FILE_NOT_FOUND = "file_not_found" + UNKNOWN_ERROR = "unknown_error" + + +def get_video_playback_instructions() -> str: + """ + Get platform-specific instructions for fixing video playback issues. + + :returns: HTML-formatted instructions. + """ + system = platform.system() + + if system == "Windows": + return """ +

Video Playback Not Available

+

The embedded video player requires additional codecs.

+ +

To fix this:

+
    +
  1. Install K-Lite Codec Pack (Basic version is sufficient)
  2. +
  3. Or install the VLC Qt plugin
  4. +
  5. Restart QGIS after installation
  6. +
+ +

Alternatively, click "Open in System Player" to view your video in your default media player.

+""" + elif system == "Darwin": + return """ +

Video Playback Not Available

+

The embedded video player may not be available in this QGIS build.

+ +

Workaround:

+

Click "Open in System Player" to view your video in QuickTime Player or your default media application.

+ +

The video has been saved successfully and can be found at the output path you specified.

+""" + else: # Linux + return """ +

Video Playback Not Available

+

The embedded video player requires GStreamer plugins.

+ +

To fix this (Ubuntu/Debian):

+
sudo apt install gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-libav
+ +

To fix this (Fedora):

+
sudo dnf install gstreamer1-plugins-good gstreamer1-plugins-bad-free gstreamer1-plugins-ugly gstreamer1-libav
+ +

To fix this (Arch Linux):

+
sudo pacman -S gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav
+ +

Alternatively, click "Open in System Player" to view your video in VLC or your default media player.

+""" diff --git a/animation_workbench/dialog_expression_context_generator.py b/animation_workbench/dialog_expression_context_generator.py index 3cc30bf..d431244 100644 --- a/animation_workbench/dialog_expression_context_generator.py +++ b/animation_workbench/dialog_expression_context_generator.py @@ -10,10 +10,10 @@ # of the CRS sequentially to create a spinning globe effect from qgis.core import ( + QgsExpressionContext, + QgsExpressionContextGenerator, QgsExpressionContextUtils, QgsProject, - QgsExpressionContextGenerator, - QgsExpressionContext, QgsVectorLayer, ) @@ -39,9 +39,7 @@ def createExpressionContext( ) -> QgsExpressionContext: context = QgsExpressionContext() context.appendScope(QgsExpressionContextUtils.globalScope()) - context.appendScope( - QgsExpressionContextUtils.projectScope(QgsProject.instance()) - ) + context.appendScope(QgsExpressionContextUtils.projectScope(QgsProject.instance())) if self.layer: context.appendScope(self.layer.createExpressionContextScope()) return context diff --git a/animation_workbench/easing_preview.py b/animation_workbench/easing_preview.py index 6e8dbcf..51de24e 100644 --- a/animation_workbench/easing_preview.py +++ b/animation_workbench/easing_preview.py @@ -6,170 +6,272 @@ __email__ = "tim@kartoza.com" __revision__ = "$Format:%H$" -from qgis.PyQt.QtWidgets import QWidget -#from qgis.PyQt.QtGui import QPainter, QPen, QColor from qgis.PyQt.QtCore import ( QEasingCurve, - QPropertyAnimation, - QPoint, + QTimer, pyqtSignal, ) -#TODO: add a gui to prompt the user if they want to install py +from qgis.PyQt.QtGui import QPalette +from qgis.PyQt.QtWidgets import QApplication, QWidget + try: - import pyqtgraph -except ModuleNotFoundError: - import pip - pip.main(['install', 'pyqtgraph']) + import pyqtgraph as pg + from pyqtgraph import PlotWidget # pylint: disable=unused-import +except ImportError: + # Try to install pyqtgraph using subprocess (modern approach) + import subprocess + import sys + + try: + subprocess.check_call([sys.executable, "-m", "pip", "install", "pyqtgraph"]) + import pyqtgraph as pg + from pyqtgraph import PlotWidget + except Exception: + pg = None + PlotWidget = None -from pyqtgraph import PlotWidget # pylint: disable=unused-import -import pyqtgraph as pg from .utilities import get_ui_class -FORM_CLASS = get_ui_class("easing_preview_base.ui") +# Kartoza Brand Colors +KARTOZA_GREEN_DARK = "#589632" +KARTOZA_GREEN_LIGHT = "#93b023" +# Theme-specific colors +DARK_THEME = { + "background": "#2d2d2d", + "foreground": KARTOZA_GREEN_LIGHT, + "dot_color": "#ff6b6b", + "border": KARTOZA_GREEN_DARK, +} -class EasingAnimation(QPropertyAnimation): - """Animation settings for easings for natural transitions between states. +LIGHT_THEME = { + "background": "#f5f5f5", + "foreground": KARTOZA_GREEN_DARK, + "dot_color": "#e74c3c", + "border": KARTOZA_GREEN_DARK, +} - See documentation here which explains that you should - create your own subclass of QVariantAnimation - if you want to change the animation behaviour. In our - case we want to override the fact that the animation - changes both the x and y coords in each increment - so that we can show the preview as a mock chart - https://doc.qt.io/qt-6/qvariantanimation.html#endValue-prop - """ - def __init__(self, target_object, property): # pylint: disable=redefined-builtin - #parent = None - super(EasingAnimation, self).__init__() # pylint: disable=super-with-arguments - self.setTargetObject(target_object) - self.setPropertyName(property) - - def interpolated( - self, from_point: QPoint, to_point: QPoint, progress: float - ) -> QPoint: - """Linearly interpolate X and interpolate Y using the easing.""" - if not isinstance(from_point) == QPoint: - from_point = QPoint(0, 0) - x_range = to_point.x() - from_point.x() - x = (progress * x_range) + from_point.x() - y_range = to_point.y() - from_point.y() - y = to_point.y() - (y_range * self.easingCurve().valueForProgress(progress)) - return QPoint(int(x), int(y)) +# Animation settings +ANIMATION_DURATION_MS = 3000 # Duration for one animation cycle +ANIMATION_STEPS = 100 # Number of steps in the animation +DOT_SIZE = 12 # Size of the indicator dot + +FORM_CLASS = get_ui_class("easing_preview_base.ui") class EasingPreview(QWidget, FORM_CLASS): """ - A widget for setting an easing mode. + A widget for setting an easing mode with curve visualization. + + The dot position is controlled externally via set_progress() method, + allowing it to be linked to a slider or spinbox for smooth scrubbing. """ # Signal emitted when the easing is changed easing_changed_signal = pyqtSignal(QEasingCurve) - def __init__(self, color="#ff0000", parent=None): + def __init__(self, color=None, parent=None): """Constructor for easing preview. - :color: Color of the easing display - defaults to red. - :type current_easing: str + :param color: Color of the dot (unused, kept for API compatibility). + :type color: str :param parent: Parent widget of this widget. :type parent: QWidget """ QWidget.__init__(self, parent) self.setupUi(self) - self.easing = None - self.easing_preview_animation = None - self.preview_color = color + self.easing = QEasingCurve(QEasingCurve.Linear) + self.curve_data = [] + self.curve_plot = None + self.dot_plot = None + self.animation_progress = 0.0 + self.animation_direction = 1 # 1 = forward, -1 = backward + self._animation_running = False + self._animation_interval = ANIMATION_DURATION_MS // ANIMATION_STEPS + + # Animation timer - we'll use singleShot to prevent event pile-up + self.animation_timer = QTimer(self) + self.animation_timer.setSingleShot(True) + self.animation_timer.timeout.connect(self._update_animation) + self.load_combo_with_easings() - self.setup_easing_previews() + self.setup_chart() self.easing_combo.currentIndexChanged.connect(self.easing_changed) self.enable_easing.toggled.connect(self.checkbox_changed) - ## chart: Switch to using white background and black foreground - pg.setConfigOption("background", "w") - pg.setConfigOption("foreground", "k") + + def is_dark_theme(self) -> bool: + """Detect if the current application theme is dark. + + :returns: True if the theme is dark, False otherwise. + :rtype: bool + """ + palette = QApplication.instance().palette() + window_color = palette.color(QPalette.Window) + luminance = 0.299 * window_color.red() + 0.587 * window_color.green() + 0.114 * window_color.blue() + return luminance < 128 + + def get_theme(self) -> dict: + """Get the current theme colors.""" + return DARK_THEME if self.is_dark_theme() else LIGHT_THEME + + def setup_chart(self): + """Set up the chart with the easing curve and indicator dot.""" + if pg is None: + return + + theme = self.get_theme() + + # Configure chart appearance + self.chart.setBackground(theme["background"]) self.chart.hideAxis("bottom") self.chart.hideAxis("left") + self.chart.setMouseEnabled(x=False, y=False) + self.chart.setMenuEnabled(False) - def resizeEvent(self, new_size): - """Resize event handler.""" - super(EasingPreview, self).resizeEvent(new_size) # pylint: disable=super-with-arguments - width = self.easing_preview.width() - height = self.easing_preview.height() - self.easing_preview_animation.setEndValue(QPoint(width, height)) - - def checkbox_changed(self, new_state): + # Add a border around the chart + self.chart.setStyleSheet( + f""" + border: 2px solid {theme["border"]}; + border-radius: 6px; """ - Called when the enabled checkbox is toggled + ) + + # Generate initial curve data + self._generate_curve_data() + + # Plot the curve + pen = pg.mkPen(color=theme["foreground"], width=3) + self.curve_plot = self.chart.plot(self.curve_data, pen=pen) + + # Create the indicator dot as a scatter plot + self.dot_plot = pg.ScatterPlotItem(size=DOT_SIZE, brush=pg.mkBrush(theme["dot_color"]), pen=pg.mkPen(None)) + self.chart.addItem(self.dot_plot) + + # Set initial dot position + self._update_dot_position() + + # Start animation timer + self._animation_running = True + self.animation_timer.start(self._animation_interval) + + def _generate_curve_data(self): + """Generate the Y values for the easing curve.""" + self.curve_data = [] + num_points = 1000 + for i in range(num_points): + progress = i / (num_points - 1) + self.curve_data.append(self.easing.valueForProgress(progress)) + + def _update_animation(self): + """Update the animation progress and dot position.""" + try: + step = 1.0 / ANIMATION_STEPS + self.animation_progress += step * self.animation_direction + + # Bounce at the ends + if self.animation_progress >= 1.0: + self.animation_progress = 1.0 + self.animation_direction = -1 + elif self.animation_progress <= 0.0: + self.animation_progress = 0.0 + self.animation_direction = 1 + + self._update_dot_position() + except Exception: + # Prevent exceptions from stopping the animation timer + pass + finally: + # Schedule next update if animation is still running + if self._animation_running: + self.animation_timer.start(self._animation_interval) + + def set_progress(self, progress: float): + """Set the dot position based on progress (0.0 to 1.0). + + This method allows external control of the dot position, + typically linked to a slider or frame spinbox. + + :param progress: Progress value from 0.0 to 1.0. + :type progress: float """ + self.animation_progress = max(0.0, min(1.0, progress)) + self._update_dot_position() + + def _update_dot_position(self): + """Update the dot position on the chart based on animation progress.""" + try: + if self.dot_plot is None: + return + + # X position is linear (0 to 999 for 1000 data points) + x = self.animation_progress * (len(self.curve_data) - 1) + # Y position follows the easing curve + y = self.easing.valueForProgress(self.animation_progress) + + # Only update if position changed significantly to reduce overhead + if ( + not hasattr(self, "_last_dot_pos") + or abs(x - self._last_dot_pos[0]) > 0.5 + or abs(y - self._last_dot_pos[1]) > 0.01 + ): + self.dot_plot.setData([x], [y]) + self._last_dot_pos = (x, y) + except Exception: + # Prevent exceptions from affecting the animation + pass + + def checkbox_changed(self, new_state): + """Called when the enabled checkbox is toggled.""" if new_state: self.enable() else: self.disable() def disable(self): - """ - Disables the widget - """ + """Disables the widget.""" self.enable_easing.setChecked(False) - self.easing_preview_animation.stop() + self._animation_running = False + self.animation_timer.stop() def enable(self): - """ - Enables the widget - """ + """Enables the widget.""" self.enable_easing.setChecked(True) - self.easing_preview_animation.start() + self._animation_running = True + if not self.animation_timer.isActive(): + self.animation_timer.start(self._animation_interval) def is_enabled(self) -> bool: - """ - Returns True if the easing is enabled - """ + """Returns True if the easing is enabled.""" return self.enable_easing.isChecked() def set_easing_by_name(self, name: str): - """ - Sets an easing mode to show in the widget by name - """ + """Sets an easing mode to show in the widget by name.""" combo = self.easing_combo index = combo.findText(name) if index != -1: combo.setCurrentIndex(index) def easing_name(self) -> str: - """ - Returns the currently selected easing name - """ + """Returns the currently selected easing name.""" return self.easing_combo.currentText() def get_easing(self): - """ - Returns the currently selected easing type - """ + """Returns the currently selected easing type.""" easing_type = QEasingCurve.Type(self.easing_combo.currentIndex()) return QEasingCurve(easing_type) def set_preview_color(self, color: str): - """ - Sets the widget's preview color - """ - self.preview_color = color - self.easing_preview_icon.setStyleSheet( - "background-color:%s;border-radius:5px;" % self.preview_color - ) + """Sets the widget's dot color.""" + if self.dot_plot and pg: + self.dot_plot.setBrush(pg.mkBrush(color)) def set_checkbox_label(self, label: str): - """ - Sets the label for the widget - """ + """Sets the label for the widget.""" self.enable_easing.setText(label) def load_combo_with_easings(self): - """ - Populates the combobox with available easing modes - """ - # Perhaps we can softcode these items using the logic here - # https://github.com/baoboa/pyqt5/blob/master/examples/ - # animation/easing/easing.py#L159 + """Populates the combobox with available easing modes.""" combo = self.easing_combo combo.addItem("Linear", QEasingCurve.Linear) combo.addItem("InQuad", QEasingCurve.InQuad) @@ -215,55 +317,26 @@ def load_combo_with_easings(self): combo.addItem("BezierSpline", QEasingCurve.BezierSpline) combo.addItem("TCBSpline", QEasingCurve.TCBSpline) - def setup_easing_previews(self): - """ - Set up easing previews - """ - # Icon is the little dot that animates across the widget - self.easing_preview_icon = QWidget(self.easing_preview) - self.easing_preview_icon.setStyleSheet( - "background-color:%s;border-radius:5px;" % self.preview_color - ) - # this is the size of the dot - self.easing_preview_icon.resize(10, 10) - self.easing_preview_animation = EasingAnimation( - self.easing_preview_icon, b"pos" - ) - self.easing_preview_animation.setEasingCurve(QEasingCurve.InOutCubic) - self.easing_preview_animation.setStartValue(QPoint(0, 0)) - self.easing_preview_animation.setEndValue( - QPoint( - self.easing_preview.width(), - self.easing_preview.height(), - ) - ) - self.easing_preview_animation.setDuration(35000) - # loop forever ... - self.easing_preview_animation.setLoopCount(-1) - self.easing_preview_animation.start() - def easing_changed(self, index): """Handle changes to the easing type combo. - .. note:: This is called on changes to the easing combo. - - .. versionadded:: 1.0 - :param index: Index of the now selected combo item. - :type flag: int - + :type index: int """ easing_type = QEasingCurve.Type(index) - self.easing_preview_animation.stop() - self.easing_preview_animation.setEasingCurve(easing_type) self.easing = QEasingCurve(easing_type) self.easing_changed_signal.emit(self.easing) - self.easing_preview_animation.start() - self.chart.clear() - chart = [] - for i in range( - 0, - 1000, - ): - chart.append(self.easing.valueForProgress(i / 1000)) - self.chart.plot(chart) + + if pg is None: + return + + # Update the curve data + self._generate_curve_data() + + # Update existing curve plot data instead of clearing and recreating + if self.curve_plot is not None: + # Update curve data in place + self.curve_plot.setData(self.curve_data) + + # Update dot position + self._update_dot_position() diff --git a/animation_workbench/gui/__init__.py b/animation_workbench/gui/__init__.py index f948c95..ec61534 100644 --- a/animation_workbench/gui/__init__.py +++ b/animation_workbench/gui/__init__.py @@ -2,4 +2,12 @@ Gui classes """ -from .workbench_settings import AnimationWorkbenchOptionsFactory +from .kartoza_branding import ( # noqa: F401 + KARTOZA_GOLD, + KARTOZA_GREEN_DARK, + KARTOZA_GREEN_LIGHT, + KartozaFooter, + KartozaHeader, + apply_kartoza_styling, +) +from .workbench_settings import AnimationWorkbenchOptionsFactory # noqa: F401 diff --git a/animation_workbench/gui/kartoza_branding.py b/animation_workbench/gui/kartoza_branding.py new file mode 100644 index 0000000..f23b747 --- /dev/null +++ b/animation_workbench/gui/kartoza_branding.py @@ -0,0 +1,178 @@ +# coding=utf-8 +"""Kartoza branding utilities for the Animation Workbench plugin.""" + +__copyright__ = "Copyright 2024, Kartoza" +__license__ = "GPL version 3" +__email__ = "tim@kartoza.com" + +import os +from typing import Optional + +from qgis.PyQt.QtCore import Qt +from qgis.PyQt.QtGui import QFont, QPixmap +from qgis.PyQt.QtWidgets import QHBoxLayout, QLabel, QWidget + +# Kartoza Brand Colors +KARTOZA_GREEN_DARK = "#589632" +KARTOZA_GREEN_LIGHT = "#93b023" +KARTOZA_GOLD = "#E8B849" + + +def get_stylesheet_path() -> str: + """Get the path to the Kartoza stylesheet. + + Returns: + str: Absolute path to the kartoza.qss file. + """ + current_dir = os.path.dirname(os.path.abspath(__file__)) + return os.path.join(current_dir, "..", "resources", "styles", "kartoza.qss") + + +def load_stylesheet() -> str: + """Load the Kartoza stylesheet content. + + Returns: + str: The stylesheet content as a string. + """ + stylesheet_path = get_stylesheet_path() + if os.path.exists(stylesheet_path): + with open(stylesheet_path, "r", encoding="utf-8") as f: + return f.read() + return "" + + +def apply_kartoza_styling(widget: QWidget) -> None: + """Apply Kartoza branding stylesheet to a widget. + + Args: + widget: The widget to apply styling to. + """ + stylesheet = load_stylesheet() + if stylesheet: + widget.setStyleSheet(stylesheet) + + +class KartozaFooter(QWidget): + """A branded footer widget showing Kartoza attribution and links.""" + + GITHUB_REPO = "https://github.com/timlinux/QGISAnimationWorkbench" + KARTOZA_URL = "https://kartoza.com" + SPONSOR_URL = "https://github.com/sponsors/timlinux" + + def __init__(self, parent: Optional[QWidget] = None): + """Initialize the Kartoza footer widget. + + Args: + parent: Parent widget. + """ + super().__init__(parent) + self.setup_ui() + + def setup_ui(self) -> None: + """Set up the footer UI with simple hyperlinks.""" + layout = QHBoxLayout(self) + layout.setContentsMargins(8, 8, 8, 8) + layout.setSpacing(0) + + # Create a single label with HTML hyperlinks + footer_label = QLabel() + footer_label.setOpenExternalLinks(True) + footer_label.setTextFormat(Qt.RichText) + footer_label.setAlignment(Qt.AlignCenter) + + html = f""" + + Made with \u2764 by + Kartoza + | + Donate! + | + GitHub + + """ + footer_label.setText(html) + layout.addWidget(footer_label) + + +class KartozaHeader(QWidget): + """A branded header widget with logo and title.""" + + def __init__( + self, + title: str = "Animation Workbench", + subtitle: str = "", + parent: Optional[QWidget] = None, + ): + """Initialize the header widget. + + Args: + title: Main title text. + subtitle: Optional subtitle text. + parent: Parent widget. + """ + super().__init__(parent) + self.title = title + self.subtitle = subtitle + self.setup_ui() + + def setup_ui(self) -> None: + """Set up the header UI.""" + layout = QHBoxLayout(self) + layout.setContentsMargins(12, 8, 12, 8) + layout.setSpacing(12) + + # Logo + logo_label = QLabel() + logo_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "..", + "icons", + "animation-workbench.svg", + ) + if os.path.exists(logo_path): + pixmap = QPixmap(logo_path) + logo_label.setPixmap(pixmap.scaled(48, 48, Qt.KeepAspectRatio, Qt.SmoothTransformation)) + layout.addWidget(logo_label) + + # Title container + title_container = QWidget() + title_layout = QHBoxLayout(title_container) + title_layout.setContentsMargins(0, 0, 0, 0) + title_layout.setSpacing(4) + + # Title + title_label = QLabel(self.title) + title_font = QFont() + title_font.setPointSize(16) + title_font.setBold(True) + title_label.setFont(title_font) + title_label.setStyleSheet(f"color: {KARTOZA_GREEN_DARK};") + title_layout.addWidget(title_label) + + # Subtitle + if self.subtitle: + subtitle_label = QLabel(f"- {self.subtitle}") + subtitle_font = QFont() + subtitle_font.setPointSize(12) + subtitle_label.setFont(subtitle_font) + subtitle_label.setStyleSheet(f"color: {KARTOZA_GREEN_LIGHT};") + title_layout.addWidget(subtitle_label) + + title_layout.addStretch() + layout.addWidget(title_container) + layout.addStretch() + + # Set background gradient + self.setStyleSheet( + f""" + QWidget {{ + background: qlineargradient( + x1:0, y1:0, x2:1, y2:0, + stop:0 rgba(88, 150, 50, 0.1), + stop:0.5 rgba(147, 176, 35, 0.05), + stop:1 rgba(88, 150, 50, 0.1) + ); + border-bottom: 2px solid {KARTOZA_GREEN_DARK}; + }} + """ + ) diff --git a/animation_workbench/gui/workbench_settings.py b/animation_workbench/gui/workbench_settings.py index b002d5a..874e890 100644 --- a/animation_workbench/gui/workbench_settings.py +++ b/animation_workbench/gui/workbench_settings.py @@ -6,9 +6,11 @@ __email__ = "tim@kartoza.com" __revision__ = "$Format:%H$" -from qgis.PyQt.QtGui import QIcon from qgis.gui import QgsOptionsPageWidget, QgsOptionsWidgetFactory +from qgis.PyQt.QtGui import QIcon + from animation_workbench.core import set_setting, setting +from animation_workbench.gui.kartoza_branding import KartozaFooter, apply_kartoza_styling from animation_workbench.utilities import get_ui_class, resources_path FORM_CLASS = get_ui_class("workbench_settings_base.ui") @@ -27,13 +29,15 @@ def __init__(self, parent=None): QgsOptionsPageWidget.__init__(self, parent) self.setupUi(self) + # Apply Kartoza branding + apply_kartoza_styling(self) + self._setup_kartoza_footer() + # The maximum number of concurrent threads to allow # during rendering. Probably setting to the same number # of CPU cores you have would be a good conservative approach # You could probably run 100 or more on a decently specced machine - self.spin_thread_pool_size.setValue( - int(setting(key="render_thread_pool_size", default=1)) - ) + self.spin_thread_pool_size.setValue(int(setting(key="render_thread_pool_size", default=1))) # This is intended for developers to attach to the plugin using a # remote debugger so that they can step through the code. Do not # enable it if you do not have a remote debugger set up as it will @@ -51,6 +55,13 @@ def __init__(self, parent=None): else: self.verbose_mode_checkbox.setChecked(False) + def _setup_kartoza_footer(self): + """Add the Kartoza branding footer to the settings panel.""" + main_layout = self.layout() + if main_layout: + footer = KartozaFooter(self) + main_layout.addWidget(footer) + def apply(self): """Process the animation sequence. diff --git a/animation_workbench/icons/icon.png b/animation_workbench/icons/icon.png new file mode 100644 index 0000000..003a48f Binary files /dev/null and b/animation_workbench/icons/icon.png differ diff --git a/animation_workbench/media_list_widget.py b/animation_workbench/media_list_widget.py index 85701c1..1784b5b 100644 --- a/animation_workbench/media_list_widget.py +++ b/animation_workbench/media_list_widget.py @@ -9,21 +9,23 @@ import json import os from os.path import expanduser -from qgis.PyQt.QtWidgets import QWidget, QSizePolicy -from qgis.PyQt.QtCore import Qt -# from typing import Optional +from qgis.PyQt.QtCore import Qt # from PyQt5.QtMultimedia import QMediaContent, QMediaPlayer # from PyQt5.QtMultimediaWidgets import QVideoWidget # from qgis.PyQt.QtCore import pyqtSlot, QUrl -from qgis.PyQt.QtGui import QPixmap, QImage -from qgis.PyQt.QtWidgets import QFileDialog, QListWidgetItem -from .utilities import get_ui_class +from qgis.PyQt.QtGui import QImage, QPixmap +from qgis.PyQt.QtWidgets import QFileDialog, QListWidgetItem, QSizePolicy, QWidget + from .core import ( set_setting, setting, ) +from .utilities import get_ui_class + +# from typing import Optional + FORM_CLASS = get_ui_class("media_list_widget_base.ui") @@ -55,9 +57,7 @@ def __init__(self, parent=None): self.preview.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) self.images_filter = "JPEG (*.jpg);;PNG (*.png);;All files (*.*)" self.movies_filter = "MOV (*.mov);;MP4 (*.mp4);;All files (*.*)" - self.movies_and_images_filter = ( - "JPEG (*.jpg);;PNG (*.png);;MOV (*.mov);;MP4 (*.mp4);;All files (*.*)" - ) + self.movies_and_images_filter = "JPEG (*.jpg);;PNG (*.png);;MOV (*.mov);;MP4 (*.mp4);;All files (*.*)" self.sounds_filter = "MP3 (*.mp3);;WAV (*.wav);;All files (*.*)" def set_media_type(self, media_type: str): @@ -112,9 +112,7 @@ def choose_media_file(self): # Popup a dialog to request the filename for music backing track dialog_title = "Select Media File" home = expanduser("~") - directory = setting( - key="last_directory", default=home, prefer_project_setting=True - ) + directory = setting(key="last_directory", default=home, prefer_project_setting=True) # noinspection PyCallByClass,PyTypeChecker file_path, _ = QFileDialog.getOpenFileName( self, @@ -143,9 +141,7 @@ def remove_media_file(self): for item in items: self.media_list.takeItem(self.media_list.row(item)) total = self.total_duration() - self.total_duration_label.setText( - f"Total duration for all media {total} seconds" - ) + self.total_duration_label.setText(f"Total duration for all media {total} seconds") def create_item(self, file_path, duration=2): """Add an item to the list widget. @@ -162,9 +158,7 @@ def create_item(self, file_path, duration=2): self.media_list.insertItem(0, item) self.load_media(file_path) total = self.total_duration() - self.total_duration_label.setText( - f"Total duration for all media {total} seconds" - ) + self.total_duration_label.setText(f"Total duration for all media {total} seconds") def load_media(self, file_path): """Load an image, movie or sound file. diff --git a/animation_workbench/resources/styles/kartoza.qss b/animation_workbench/resources/styles/kartoza.qss new file mode 100644 index 0000000..af82421 --- /dev/null +++ b/animation_workbench/resources/styles/kartoza.qss @@ -0,0 +1,479 @@ +/* + * QGIS Animation Workbench - Kartoza Branded Stylesheet + * SPDX-FileCopyrightText: Tim Sutton + * SPDX-License-Identifier: GPL-3.0 + * + * Kartoza Brand Colors: + * - Primary Green (Dark): #589632 + * - Secondary Green (Light): #93b023 + * - Accent Gold: #E8B849 + */ + +/* ============================================================================ + * Global Styles + * ============================================================================ */ + +QDialog, QWidget { + font-family: "Segoe UI", "Ubuntu", "Noto Sans", sans-serif; +} + +/* ============================================================================ + * Tab Widget Styling + * ============================================================================ */ + +QTabWidget::pane { + border: 1px solid #c0c0c0; + border-radius: 4px; + padding: 8px; + background-color: palette(window); +} + +QTabBar::tab { + background-color: palette(button); + border: 1px solid #c0c0c0; + border-bottom: none; + border-top-left-radius: 6px; + border-top-right-radius: 6px; + padding: 8px 16px; + margin-right: 2px; + min-width: 80px; +} + +QTabBar::tab:selected { + background-color: #589632; + color: white; + font-weight: bold; +} + +QTabBar::tab:hover:!selected { + background-color: #93b023; + color: white; +} + +QTabBar::tab:!selected { + margin-top: 2px; +} + +/* ============================================================================ + * Group Box Styling + * ============================================================================ */ + +QGroupBox { + font-weight: bold; + border: 2px solid #589632; + border-radius: 8px; + margin-top: 12px; + padding-top: 8px; + background-color: palette(window); +} + +QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + left: 12px; + padding: 0 8px; + background-color: #589632; + color: white; + border-radius: 4px; +} + +/* ============================================================================ + * Button Styling + * ============================================================================ */ + +QPushButton { + background-color: #589632; + color: white; + border: none; + border-radius: 6px; + padding: 8px 20px; + font-weight: bold; + min-height: 24px; +} + +QPushButton:hover { + background-color: #93b023; +} + +QPushButton:pressed { + background-color: #4a7d2a; +} + +QPushButton:disabled { + background-color: #a0a0a0; + color: #707070; +} + +QPushButton:default { + border: 2px solid #E8B849; +} + +/* Tool Buttons */ +QToolButton { + background-color: #589632; + color: white; + border: none; + border-radius: 4px; + padding: 6px; + font-weight: bold; +} + +QToolButton:hover { + background-color: #93b023; +} + +QToolButton:pressed { + background-color: #4a7d2a; +} + +QToolButton:disabled { + background-color: #c0c0c0; + color: #808080; +} + +/* ============================================================================ + * Input Widget Styling + * ============================================================================ */ + +QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox { + border: 2px solid #c0c0c0; + border-radius: 6px; + padding: 6px 10px; + background-color: palette(base); + selection-background-color: #589632; + min-height: 20px; +} + +QLineEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus, QComboBox:focus { + border-color: #589632; +} + +QLineEdit:hover, QSpinBox:hover, QDoubleSpinBox:hover, QComboBox:hover { + border-color: #93b023; +} + +QComboBox::drop-down { + border: none; + width: 24px; +} + +QComboBox::down-arrow { + width: 12px; + height: 12px; +} + +QComboBox QAbstractItemView { + border: 2px solid #589632; + border-radius: 4px; + selection-background-color: #589632; + selection-color: white; +} + +/* Spin Box Buttons */ +QSpinBox::up-button, QDoubleSpinBox::up-button, +QSpinBox::down-button, QDoubleSpinBox::down-button { + background-color: #589632; + border: none; + width: 20px; +} + +QSpinBox::up-button:hover, QDoubleSpinBox::up-button:hover, +QSpinBox::down-button:hover, QDoubleSpinBox::down-button:hover { + background-color: #93b023; +} + +/* ============================================================================ + * Radio Button and Checkbox Styling + * ============================================================================ */ + +QRadioButton, QCheckBox { + spacing: 8px; + padding: 4px; +} + +QRadioButton::indicator, QCheckBox::indicator { + width: 18px; + height: 18px; +} + +QRadioButton::indicator:unchecked, QCheckBox::indicator:unchecked { + border: 2px solid #808080; + background-color: palette(base); +} + +QRadioButton::indicator { + border-radius: 10px; +} + +QCheckBox::indicator { + border-radius: 4px; +} + +QRadioButton::indicator:checked, QCheckBox::indicator:checked { + background-color: #589632; + border: 2px solid #589632; +} + +QRadioButton::indicator:hover, QCheckBox::indicator:hover { + border-color: #93b023; +} + +/* ============================================================================ + * Progress Bar Styling + * ============================================================================ */ + +QProgressBar { + border: 2px solid #c0c0c0; + border-radius: 8px; + text-align: center; + background-color: palette(base); + min-height: 24px; + font-weight: bold; +} + +QProgressBar::chunk { + background: qlineargradient(x1:0, y1:0, x2:1, y2:0, + stop:0 #589632, stop:0.5 #93b023, stop:1 #589632); + border-radius: 6px; +} + +/* ============================================================================ + * List Widget Styling + * ============================================================================ */ + +QListWidget { + border: 2px solid #c0c0c0; + border-radius: 6px; + background-color: palette(base); + alternate-background-color: palette(alternateBase); +} + +QListWidget:focus { + border-color: #589632; +} + +QListWidget::item { + padding: 8px; + border-radius: 4px; +} + +QListWidget::item:selected { + background-color: #589632; + color: white; +} + +QListWidget::item:hover:!selected { + background-color: rgba(147, 176, 35, 0.3); +} + +/* ============================================================================ + * Text Edit / Log Styling + * ============================================================================ */ + +QTextEdit { + border: 2px solid #589632; + border-radius: 6px; + background-color: palette(base); + color: palette(text); + font-family: "Consolas", "Monaco", "Courier New", monospace; + font-size: 11px; + padding: 8px; +} + +QTextEdit:focus { + border-color: #93b023; +} + +/* ============================================================================ + * Slider Styling + * ============================================================================ */ + +QSlider::groove:horizontal { + border: 1px solid #c0c0c0; + height: 8px; + background: palette(base); + border-radius: 4px; +} + +QSlider::handle:horizontal { + background: #589632; + border: 2px solid #4a7d2a; + width: 18px; + height: 18px; + margin: -6px 0; + border-radius: 10px; +} + +QSlider::handle:horizontal:hover { + background: #93b023; + border-color: #589632; +} + +QSlider::sub-page:horizontal { + background: qlineargradient(x1:0, y1:0, x2:1, y2:0, + stop:0 #589632, stop:1 #93b023); + border-radius: 4px; +} + +/* ============================================================================ + * LCD Number Styling + * ============================================================================ */ + +QLCDNumber { + border: 2px solid #589632; + border-radius: 6px; + background-color: palette(base); + color: #93b023; +} + +/* ============================================================================ + * Splitter Styling + * ============================================================================ */ + +QSplitter::handle { + background-color: #589632; +} + +QSplitter::handle:horizontal { + width: 4px; +} + +QSplitter::handle:vertical { + height: 4px; +} + +QSplitter::handle:hover { + background-color: #93b023; +} + +/* ============================================================================ + * Frame Styling + * ============================================================================ */ + +QFrame[frameShape="4"], /* StyledPanel */ +QFrame[frameShape="5"] { /* Box */ + border: 2px solid #c0c0c0; + border-radius: 8px; + background-color: palette(window); +} + +/* ============================================================================ + * Dialog Button Box + * ============================================================================ */ + +QDialogButtonBox { + dialogbuttonbox-buttons-have-icons: 0; +} + +/* ============================================================================ + * Tooltips + * ============================================================================ */ + +QToolTip { + background-color: palette(window); + color: palette(text); + border: 2px solid #589632; + border-radius: 6px; + padding: 8px; + font-size: 12px; +} + +/* ============================================================================ + * Scroll Bars + * ============================================================================ */ + +QScrollBar:vertical { + border: none; + background: palette(base); + width: 12px; + border-radius: 6px; +} + +QScrollBar::handle:vertical { + background: #589632; + border-radius: 6px; + min-height: 30px; +} + +QScrollBar::handle:vertical:hover { + background: #93b023; +} + +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { + height: 0; +} + +QScrollBar:horizontal { + border: none; + background: palette(base); + height: 12px; + border-radius: 6px; +} + +QScrollBar::handle:horizontal { + background: #589632; + border-radius: 6px; + min-width: 30px; +} + +QScrollBar::handle:horizontal:hover { + background: #93b023; +} + +QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { + width: 0; +} + +/* ============================================================================ + * Preview Areas + * ============================================================================ */ + +QLabel#user_defined_preview, +QLabel#current_frame_preview, +QLabel#preview { + background-color: palette(base); + border: 3px solid #589632; + border-radius: 8px; +} + +/* ============================================================================ + * Kartoza Footer + * ============================================================================ */ + +QLabel#kartoza_footer { + color: #589632; + font-size: 11px; + padding: 8px; +} + +QLabel#kartoza_footer a { + color: #93b023; + text-decoration: none; +} + +/* ============================================================================ + * Custom Classes + * ============================================================================ */ + +/* Header Labels */ +.header-label { + font-size: 16px; + font-weight: bold; + color: #589632; + padding: 8px 0; +} + +/* Section Labels */ +.section-label { + font-size: 13px; + font-weight: bold; + color: #4a7d2a; + padding: 4px 0; +} + +/* Info Labels */ +.info-label { + color: #666666; + font-style: italic; + padding: 4px; +} diff --git a/animation_workbench/test/__init__.py b/animation_workbench/test/__init__.py index 65c47b5..884794d 100644 --- a/animation_workbench/test/__init__.py +++ b/animation_workbench/test/__init__.py @@ -1,4 +1,5 @@ """ import qgis libs so that we set the correct sip api version """ + import qgis # NOQA diff --git a/animation_workbench/test/qgis_interface.py b/animation_workbench/test/qgis_interface.py index 2f4e528..e780ce2 100644 --- a/animation_workbench/test/qgis_interface.py +++ b/animation_workbench/test/qgis_interface.py @@ -25,10 +25,11 @@ import logging from typing import List -from PyQt5.QtCore import QObject, pyqtSlot, pyqtSignal, QSize -from qgis.PyQt.QtWidgets import QDockWidget -from qgis.core import QgsProject, QgsMapLayer + +from PyQt5.QtCore import QObject, QSize, pyqtSignal, pyqtSlot +from qgis.core import QgsMapLayer, QgsProject from qgis.gui import QgsMapCanvas, QgsMessageBar +from qgis.PyQt.QtWidgets import QDockWidget LOGGER = logging.getLogger("QGIS") diff --git a/animation_workbench/test/test_animation_controller.py b/animation_workbench/test/test_animation_controller.py index e8307ca..a4acc63 100644 --- a/animation_workbench/test/test_animation_controller.py +++ b/animation_workbench/test/test_animation_controller.py @@ -18,19 +18,20 @@ import unittest -from qgis.PyQt.QtCore import QSize, QEasingCurve from qgis.core import ( - QgsMapSettings, - QgsRectangle, QgsCoordinateReferenceSystem, - QgsReferencedRectangle, - QgsVectorLayer, QgsFeature, QgsGeometry, + QgsMapSettings, QgsPointXY, + QgsRectangle, + QgsReferencedRectangle, + QgsVectorLayer, ) +from qgis.PyQt.QtCore import QEasingCurve, QSize from animation_workbench.core import AnimationController, MapMode + from .utilities import get_qgis_app QGIS_APP = get_qgis_app() @@ -49,9 +50,7 @@ def test_fixed_extent(self): map_settings.setExtent(QgsRectangle(1, 2, 3, 4)) map_settings.setDestinationCrs(QgsCoordinateReferenceSystem("EPSG:4326")) map_settings.setOutputSize(QSize(400, 300)) - extent = QgsReferencedRectangle( - map_settings.extent(), map_settings.destinationCrs() - ) + extent = QgsReferencedRectangle(map_settings.extent(), map_settings.destinationCrs()) controller = AnimationController.create_fixed_extent_controller( map_settings=map_settings, output_mode="1280:720", @@ -73,15 +72,9 @@ def test_fixed_extent(self): 1122330, delta=120000, ) - self.assertEqual( - job.map_settings.expressionContext().variable("frame_number"), 0 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("frame_rate"), 10 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("total_frame_count"), 5 - ) + self.assertEqual(job.map_settings.expressionContext().variable("frame_number"), 0) + self.assertEqual(job.map_settings.expressionContext().variable("frame_rate"), 10) + self.assertEqual(job.map_settings.expressionContext().variable("total_frame_count"), 5) job = next(it) self.assertEqual(job.map_settings.extent(), map_settings.extent()) self.assertAlmostEqual(job.map_settings.scale(), 1122330, delta=120000) @@ -92,15 +85,9 @@ def test_fixed_extent(self): 1122330, delta=120000, ) - self.assertEqual( - job.map_settings.expressionContext().variable("frame_number"), 1 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("frame_rate"), 10 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("total_frame_count"), 5 - ) + self.assertEqual(job.map_settings.expressionContext().variable("frame_number"), 1) + self.assertEqual(job.map_settings.expressionContext().variable("frame_rate"), 10) + self.assertEqual(job.map_settings.expressionContext().variable("total_frame_count"), 5) job = next(it) self.assertEqual(job.map_settings.extent(), map_settings.extent()) self.assertAlmostEqual(job.map_settings.scale(), 1122330, delta=120000) @@ -111,15 +98,9 @@ def test_fixed_extent(self): 1122330, delta=1200003, ) - self.assertEqual( - job.map_settings.expressionContext().variable("frame_number"), 2 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("frame_rate"), 10 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("total_frame_count"), 5 - ) + self.assertEqual(job.map_settings.expressionContext().variable("frame_number"), 2) + self.assertEqual(job.map_settings.expressionContext().variable("frame_rate"), 10) + self.assertEqual(job.map_settings.expressionContext().variable("total_frame_count"), 5) job = next(it) self.assertEqual(job.map_settings.extent(), map_settings.extent()) self.assertAlmostEqual(job.map_settings.scale(), 1122330, delta=120000) @@ -130,15 +111,9 @@ def test_fixed_extent(self): 1122330, delta=120000, ) - self.assertEqual( - job.map_settings.expressionContext().variable("frame_number"), 3 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("frame_rate"), 10 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("total_frame_count"), 5 - ) + self.assertEqual(job.map_settings.expressionContext().variable("frame_number"), 3) + self.assertEqual(job.map_settings.expressionContext().variable("frame_rate"), 10) + self.assertEqual(job.map_settings.expressionContext().variable("total_frame_count"), 5) job = next(it) self.assertEqual(job.map_settings.extent(), map_settings.extent()) self.assertAlmostEqual(job.map_settings.scale(), 1122330, delta=120000) @@ -149,15 +124,9 @@ def test_fixed_extent(self): 1122330, delta=120000, ) - self.assertEqual( - job.map_settings.expressionContext().variable("frame_number"), 4 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("frame_rate"), 10 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("total_frame_count"), 5 - ) + self.assertEqual(job.map_settings.expressionContext().variable("frame_number"), 4) + self.assertEqual(job.map_settings.expressionContext().variable("frame_rate"), 10) + self.assertEqual(job.map_settings.expressionContext().variable("total_frame_count"), 5) with self.assertRaises(StopIteration): next(it) @@ -183,9 +152,7 @@ def test_fixed_extent_with_layer(self): map_settings.setExtent(QgsRectangle(1, 2, 3, 4)) map_settings.setDestinationCrs(QgsCoordinateReferenceSystem("EPSG:4326")) map_settings.setOutputSize(QSize(400, 300)) - extent = QgsReferencedRectangle( - map_settings.extent(), map_settings.destinationCrs() - ) + extent = QgsReferencedRectangle(map_settings.extent(), map_settings.destinationCrs()) controller = AnimationController.create_fixed_extent_controller( map_settings=map_settings, output_mode=None, # Will use map canvas dimensions @@ -207,28 +174,16 @@ def test_fixed_extent_with_layer(self): 2693593, delta=120000, ) - self.assertEqual( - job.map_settings.expressionContext().variable("frame_number"), 0 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("frame_rate"), 10 - ) + self.assertEqual(job.map_settings.expressionContext().variable("frame_number"), 0) + self.assertEqual(job.map_settings.expressionContext().variable("frame_rate"), 10) self.assertEqual( job.map_settings.expressionContext().variable("current_hover_frame"), 0, ) - self.assertIsNone( - job.map_settings.expressionContext().variable("previous_feature") - ) - self.assertIsNone( - job.map_settings.expressionContext().variable("previous_feature_id") - ) - self.assertEqual( - job.map_settings.expressionContext().variable("next_feature").id(), 2 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("next_feature_id"), 2 - ) + self.assertIsNone(job.map_settings.expressionContext().variable("previous_feature")) + self.assertIsNone(job.map_settings.expressionContext().variable("previous_feature_id")) + self.assertEqual(job.map_settings.expressionContext().variable("next_feature").id(), 2) + self.assertEqual(job.map_settings.expressionContext().variable("next_feature_id"), 2) self.assertEqual( job.map_settings.expressionContext().variable("hover_feature").id(), 1, @@ -257,28 +212,16 @@ def test_fixed_extent_with_layer(self): 2693593, delta=120000, ) - self.assertEqual( - job.map_settings.expressionContext().variable("frame_number"), 1 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("frame_rate"), 10 - ) + self.assertEqual(job.map_settings.expressionContext().variable("frame_number"), 1) + self.assertEqual(job.map_settings.expressionContext().variable("frame_rate"), 10) self.assertEqual( job.map_settings.expressionContext().variable("current_hover_frame"), 1, ) - self.assertIsNone( - job.map_settings.expressionContext().variable("previous_feature") - ) - self.assertIsNone( - job.map_settings.expressionContext().variable("previous_feature_id") - ) - self.assertEqual( - job.map_settings.expressionContext().variable("next_feature").id(), 2 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("next_feature_id"), 2 - ) + self.assertIsNone(job.map_settings.expressionContext().variable("previous_feature")) + self.assertIsNone(job.map_settings.expressionContext().variable("previous_feature_id")) + self.assertEqual(job.map_settings.expressionContext().variable("next_feature").id(), 2) + self.assertEqual(job.map_settings.expressionContext().variable("next_feature_id"), 2) self.assertEqual( job.map_settings.expressionContext().variable("hover_feature").id(), 1, @@ -307,26 +250,16 @@ def test_fixed_extent_with_layer(self): 2693593, delta=120000, ) - self.assertEqual( - job.map_settings.expressionContext().variable("frame_number"), 2 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("frame_rate"), 10 - ) + self.assertEqual(job.map_settings.expressionContext().variable("frame_number"), 2) + self.assertEqual(job.map_settings.expressionContext().variable("frame_rate"), 10) self.assertEqual( job.map_settings.expressionContext().variable("current_hover_frame"), 0, ) - self.assertEqual( - job.map_settings.expressionContext().variable("previous_feature").id(), 1 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("previous_feature_id"), 1 - ) + self.assertEqual(job.map_settings.expressionContext().variable("previous_feature").id(), 1) + self.assertEqual(job.map_settings.expressionContext().variable("previous_feature_id"), 1) self.assertIsNone(job.map_settings.expressionContext().variable("next_feature")) - self.assertIsNone( - job.map_settings.expressionContext().variable("next_feature_id") - ) + self.assertIsNone(job.map_settings.expressionContext().variable("next_feature_id")) self.assertEqual( job.map_settings.expressionContext().variable("hover_feature").id(), 2, @@ -355,26 +288,16 @@ def test_fixed_extent_with_layer(self): 2693593, delta=120000, ) - self.assertEqual( - job.map_settings.expressionContext().variable("frame_number"), 3 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("frame_rate"), 10 - ) + self.assertEqual(job.map_settings.expressionContext().variable("frame_number"), 3) + self.assertEqual(job.map_settings.expressionContext().variable("frame_rate"), 10) self.assertEqual( job.map_settings.expressionContext().variable("current_hover_frame"), 1, ) - self.assertEqual( - job.map_settings.expressionContext().variable("previous_feature").id(), 1 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("previous_feature_id"), 1 - ) + self.assertEqual(job.map_settings.expressionContext().variable("previous_feature").id(), 1) + self.assertEqual(job.map_settings.expressionContext().variable("previous_feature_id"), 1) self.assertIsNone(job.map_settings.expressionContext().variable("next_feature")) - self.assertIsNone( - job.map_settings.expressionContext().variable("next_feature_id") - ) + self.assertIsNone(job.map_settings.expressionContext().variable("next_feature_id")) self.assertEqual( job.map_settings.expressionContext().variable("hover_feature").id(), 2, @@ -445,55 +368,31 @@ def test_planar(self): 959999, delta=120000, ) - self.assertEqual( - job.map_settings.expressionContext().variable("frame_number"), 0 - ) + self.assertEqual(job.map_settings.expressionContext().variable("frame_number"), 0) self.assertEqual(job.map_settings.expressionContext().variable("frame_rate"), 2) - self.assertEqual( - job.map_settings.expressionContext().variable("hover_feature").id(), 1 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("hover_feature_id"), 1 - ) - self.assertIsNone( - job.map_settings.expressionContext().variable("previous_feature") - ) - self.assertIsNone( - job.map_settings.expressionContext().variable("previous_feature_id") - ) - self.assertEqual( - job.map_settings.expressionContext().variable("next_feature").id(), 2 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("next_feature_id"), 2 - ) + self.assertEqual(job.map_settings.expressionContext().variable("hover_feature").id(), 1) + self.assertEqual(job.map_settings.expressionContext().variable("hover_feature_id"), 1) + self.assertIsNone(job.map_settings.expressionContext().variable("previous_feature")) + self.assertIsNone(job.map_settings.expressionContext().variable("previous_feature_id")) + self.assertEqual(job.map_settings.expressionContext().variable("next_feature").id(), 2) + self.assertEqual(job.map_settings.expressionContext().variable("next_feature_id"), 2) self.assertIsNone(job.map_settings.expressionContext().variable("from_feature")) - self.assertIsNone( - job.map_settings.expressionContext().variable("from_feature_id") - ) + self.assertIsNone(job.map_settings.expressionContext().variable("from_feature_id")) self.assertIsNone(job.map_settings.expressionContext().variable("to_feature")) - self.assertIsNone( - job.map_settings.expressionContext().variable("to_feature_id") - ) + self.assertIsNone(job.map_settings.expressionContext().variable("to_feature_id")) self.assertEqual( job.map_settings.expressionContext().variable("current_hover_frame"), 0, ) - self.assertIsNone( - job.map_settings.expressionContext().variable("current_travel_frame") - ) + self.assertIsNone(job.map_settings.expressionContext().variable("current_travel_frame")) self.assertEqual( job.map_settings.expressionContext().variable("hover_frames"), 2, ) - self.assertIsNone( - job.map_settings.expressionContext().variable("travel_frames") - ) + self.assertIsNone(job.map_settings.expressionContext().variable("travel_frames")) self.assertEqual(job.map_settings.expressionContext().feature().id(), 1) - self.assertEqual( - job.map_settings.expressionContext().variable("total_frame_count"), 8 - ) + self.assertEqual(job.map_settings.expressionContext().variable("total_frame_count"), 8) self.assertEqual( job.map_settings.expressionContext().variable("current_animation_action"), "Hovering", @@ -511,55 +410,31 @@ def test_planar(self): 959999, delta=120000, ) - self.assertEqual( - job.map_settings.expressionContext().variable("frame_number"), 1 - ) + self.assertEqual(job.map_settings.expressionContext().variable("frame_number"), 1) self.assertEqual(job.map_settings.expressionContext().variable("frame_rate"), 2) - self.assertEqual( - job.map_settings.expressionContext().variable("hover_feature").id(), 1 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("hover_feature_id"), 1 - ) - self.assertIsNone( - job.map_settings.expressionContext().variable("previous_feature") - ) - self.assertIsNone( - job.map_settings.expressionContext().variable("previous_feature_id") - ) - self.assertEqual( - job.map_settings.expressionContext().variable("next_feature").id(), 2 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("next_feature_id"), 2 - ) + self.assertEqual(job.map_settings.expressionContext().variable("hover_feature").id(), 1) + self.assertEqual(job.map_settings.expressionContext().variable("hover_feature_id"), 1) + self.assertIsNone(job.map_settings.expressionContext().variable("previous_feature")) + self.assertIsNone(job.map_settings.expressionContext().variable("previous_feature_id")) + self.assertEqual(job.map_settings.expressionContext().variable("next_feature").id(), 2) + self.assertEqual(job.map_settings.expressionContext().variable("next_feature_id"), 2) self.assertIsNone(job.map_settings.expressionContext().variable("from_feature")) - self.assertIsNone( - job.map_settings.expressionContext().variable("from_feature_id") - ) + self.assertIsNone(job.map_settings.expressionContext().variable("from_feature_id")) self.assertIsNone(job.map_settings.expressionContext().variable("to_feature")) - self.assertIsNone( - job.map_settings.expressionContext().variable("to_feature_id") - ) + self.assertIsNone(job.map_settings.expressionContext().variable("to_feature_id")) self.assertEqual( job.map_settings.expressionContext().variable("current_hover_frame"), 1, ) - self.assertIsNone( - job.map_settings.expressionContext().variable("current_travel_frame") - ) + self.assertIsNone(job.map_settings.expressionContext().variable("current_travel_frame")) self.assertEqual( job.map_settings.expressionContext().variable("hover_frames"), 2, ) - self.assertIsNone( - job.map_settings.expressionContext().variable("travel_frames") - ) + self.assertIsNone(job.map_settings.expressionContext().variable("travel_frames")) self.assertEqual(job.map_settings.expressionContext().feature().id(), 1) - self.assertEqual( - job.map_settings.expressionContext().variable("total_frame_count"), 8 - ) + self.assertEqual(job.map_settings.expressionContext().variable("total_frame_count"), 8) self.assertEqual( job.map_settings.expressionContext().variable("current_animation_action"), "Hovering", @@ -578,52 +453,24 @@ def test_planar(self): 959999, delta=120000, ) - self.assertEqual( - job.map_settings.expressionContext().variable("frame_number"), 2 - ) + self.assertEqual(job.map_settings.expressionContext().variable("frame_number"), 2) self.assertEqual(job.map_settings.expressionContext().variable("frame_rate"), 2) - self.assertIsNone( - job.map_settings.expressionContext().variable("hover_feature") - ) - self.assertIsNone( - job.map_settings.expressionContext().variable("hover_feature_id") - ) - self.assertEqual( - job.map_settings.expressionContext().variable("from_feature").id(), 1 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("from_feature_id"), 1 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("to_feature").id(), 2 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("to_feature_id"), 2 - ) - self.assertIsNone( - job.map_settings.expressionContext().variable("previous_feature") - ) - self.assertIsNone( - job.map_settings.expressionContext().variable("previous_feature_id") - ) + self.assertIsNone(job.map_settings.expressionContext().variable("hover_feature")) + self.assertIsNone(job.map_settings.expressionContext().variable("hover_feature_id")) + self.assertEqual(job.map_settings.expressionContext().variable("from_feature").id(), 1) + self.assertEqual(job.map_settings.expressionContext().variable("from_feature_id"), 1) + self.assertEqual(job.map_settings.expressionContext().variable("to_feature").id(), 2) + self.assertEqual(job.map_settings.expressionContext().variable("to_feature_id"), 2) + self.assertIsNone(job.map_settings.expressionContext().variable("previous_feature")) + self.assertIsNone(job.map_settings.expressionContext().variable("previous_feature_id")) self.assertIsNone(job.map_settings.expressionContext().variable("next_feature")) - self.assertIsNone( - job.map_settings.expressionContext().variable("next_feature_id") - ) - self.assertIsNone( - job.map_settings.expressionContext().variable("current_hover_frame") - ) - self.assertEqual( - job.map_settings.expressionContext().variable("current_travel_frame"), 0 - ) + self.assertIsNone(job.map_settings.expressionContext().variable("next_feature_id")) + self.assertIsNone(job.map_settings.expressionContext().variable("current_hover_frame")) + self.assertEqual(job.map_settings.expressionContext().variable("current_travel_frame"), 0) self.assertIsNone(job.map_settings.expressionContext().variable("hover_frames")) - self.assertEqual( - job.map_settings.expressionContext().variable("travel_frames"), 4 - ) + self.assertEqual(job.map_settings.expressionContext().variable("travel_frames"), 4) self.assertEqual(job.map_settings.expressionContext().feature().id(), 2) - self.assertEqual( - job.map_settings.expressionContext().variable("total_frame_count"), 8 - ) + self.assertEqual(job.map_settings.expressionContext().variable("total_frame_count"), 8) self.assertEqual( job.map_settings.expressionContext().variable("current_animation_action"), "Travelling", @@ -640,52 +487,24 @@ def test_planar(self): 1599999, delta=120000, ) - self.assertEqual( - job.map_settings.expressionContext().variable("frame_number"), 3 - ) + self.assertEqual(job.map_settings.expressionContext().variable("frame_number"), 3) self.assertEqual(job.map_settings.expressionContext().variable("frame_rate"), 2) - self.assertIsNone( - job.map_settings.expressionContext().variable("hover_feature") - ) - self.assertIsNone( - job.map_settings.expressionContext().variable("hover_feature_id") - ) - self.assertIsNone( - job.map_settings.expressionContext().variable("previous_feature") - ) - self.assertIsNone( - job.map_settings.expressionContext().variable("previous_feature_id") - ) + self.assertIsNone(job.map_settings.expressionContext().variable("hover_feature")) + self.assertIsNone(job.map_settings.expressionContext().variable("hover_feature_id")) + self.assertIsNone(job.map_settings.expressionContext().variable("previous_feature")) + self.assertIsNone(job.map_settings.expressionContext().variable("previous_feature_id")) self.assertIsNone(job.map_settings.expressionContext().variable("next_feature")) - self.assertIsNone( - job.map_settings.expressionContext().variable("next_feature_id") - ) - self.assertEqual( - job.map_settings.expressionContext().variable("from_feature").id(), 1 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("from_feature_id"), 1 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("to_feature").id(), 2 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("to_feature_id"), 2 - ) - self.assertIsNone( - job.map_settings.expressionContext().variable("current_hover_frame") - ) - self.assertEqual( - job.map_settings.expressionContext().variable("current_travel_frame"), 1 - ) + self.assertIsNone(job.map_settings.expressionContext().variable("next_feature_id")) + self.assertEqual(job.map_settings.expressionContext().variable("from_feature").id(), 1) + self.assertEqual(job.map_settings.expressionContext().variable("from_feature_id"), 1) + self.assertEqual(job.map_settings.expressionContext().variable("to_feature").id(), 2) + self.assertEqual(job.map_settings.expressionContext().variable("to_feature_id"), 2) + self.assertIsNone(job.map_settings.expressionContext().variable("current_hover_frame")) + self.assertEqual(job.map_settings.expressionContext().variable("current_travel_frame"), 1) self.assertIsNone(job.map_settings.expressionContext().variable("hover_frames")) - self.assertEqual( - job.map_settings.expressionContext().variable("travel_frames"), 4 - ) + self.assertEqual(job.map_settings.expressionContext().variable("travel_frames"), 4) self.assertEqual(job.map_settings.expressionContext().feature().id(), 2) - self.assertEqual( - job.map_settings.expressionContext().variable("total_frame_count"), 8 - ) + self.assertEqual(job.map_settings.expressionContext().variable("total_frame_count"), 8) self.assertEqual( job.map_settings.expressionContext().variable("current_animation_action"), "Travelling", @@ -704,52 +523,24 @@ def test_planar(self): 1599999, delta=120000, ) - self.assertEqual( - job.map_settings.expressionContext().variable("frame_number"), 4 - ) + self.assertEqual(job.map_settings.expressionContext().variable("frame_number"), 4) self.assertEqual(job.map_settings.expressionContext().variable("frame_rate"), 2) - self.assertIsNone( - job.map_settings.expressionContext().variable("hover_feature") - ) - self.assertIsNone( - job.map_settings.expressionContext().variable("hover_feature_id") - ) - self.assertIsNone( - job.map_settings.expressionContext().variable("previous_feature") - ) - self.assertIsNone( - job.map_settings.expressionContext().variable("previous_feature_id") - ) + self.assertIsNone(job.map_settings.expressionContext().variable("hover_feature")) + self.assertIsNone(job.map_settings.expressionContext().variable("hover_feature_id")) + self.assertIsNone(job.map_settings.expressionContext().variable("previous_feature")) + self.assertIsNone(job.map_settings.expressionContext().variable("previous_feature_id")) self.assertIsNone(job.map_settings.expressionContext().variable("next_feature")) - self.assertIsNone( - job.map_settings.expressionContext().variable("next_feature_id") - ) - self.assertEqual( - job.map_settings.expressionContext().variable("from_feature").id(), 1 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("from_feature_id"), 1 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("to_feature").id(), 2 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("to_feature_id"), 2 - ) - self.assertIsNone( - job.map_settings.expressionContext().variable("current_hover_frame") - ) - self.assertEqual( - job.map_settings.expressionContext().variable("current_travel_frame"), 2 - ) + self.assertIsNone(job.map_settings.expressionContext().variable("next_feature_id")) + self.assertEqual(job.map_settings.expressionContext().variable("from_feature").id(), 1) + self.assertEqual(job.map_settings.expressionContext().variable("from_feature_id"), 1) + self.assertEqual(job.map_settings.expressionContext().variable("to_feature").id(), 2) + self.assertEqual(job.map_settings.expressionContext().variable("to_feature_id"), 2) + self.assertIsNone(job.map_settings.expressionContext().variable("current_hover_frame")) + self.assertEqual(job.map_settings.expressionContext().variable("current_travel_frame"), 2) self.assertIsNone(job.map_settings.expressionContext().variable("hover_frames")) - self.assertEqual( - job.map_settings.expressionContext().variable("travel_frames"), 4 - ) + self.assertEqual(job.map_settings.expressionContext().variable("travel_frames"), 4) self.assertEqual(job.map_settings.expressionContext().feature().id(), 2) - self.assertEqual( - job.map_settings.expressionContext().variable("total_frame_count"), 8 - ) + self.assertEqual(job.map_settings.expressionContext().variable("total_frame_count"), 8) self.assertEqual( job.map_settings.expressionContext().variable("current_animation_action"), "Travelling", @@ -768,52 +559,24 @@ def test_planar(self): 959999, delta=120000, ) - self.assertEqual( - job.map_settings.expressionContext().variable("frame_number"), 5 - ) + self.assertEqual(job.map_settings.expressionContext().variable("frame_number"), 5) self.assertEqual(job.map_settings.expressionContext().variable("frame_rate"), 2) - self.assertIsNone( - job.map_settings.expressionContext().variable("hover_feature") - ) - self.assertIsNone( - job.map_settings.expressionContext().variable("hover_feature_id") - ) - self.assertIsNone( - job.map_settings.expressionContext().variable("previous_feature") - ) - self.assertIsNone( - job.map_settings.expressionContext().variable("previous_feature_id") - ) + self.assertIsNone(job.map_settings.expressionContext().variable("hover_feature")) + self.assertIsNone(job.map_settings.expressionContext().variable("hover_feature_id")) + self.assertIsNone(job.map_settings.expressionContext().variable("previous_feature")) + self.assertIsNone(job.map_settings.expressionContext().variable("previous_feature_id")) self.assertIsNone(job.map_settings.expressionContext().variable("next_feature")) - self.assertIsNone( - job.map_settings.expressionContext().variable("next_feature_id") - ) - self.assertEqual( - job.map_settings.expressionContext().variable("from_feature").id(), 1 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("from_feature_id"), 1 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("to_feature").id(), 2 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("to_feature_id"), 2 - ) - self.assertIsNone( - job.map_settings.expressionContext().variable("current_hover_frame") - ) - self.assertEqual( - job.map_settings.expressionContext().variable("current_travel_frame"), 3 - ) + self.assertIsNone(job.map_settings.expressionContext().variable("next_feature_id")) + self.assertEqual(job.map_settings.expressionContext().variable("from_feature").id(), 1) + self.assertEqual(job.map_settings.expressionContext().variable("from_feature_id"), 1) + self.assertEqual(job.map_settings.expressionContext().variable("to_feature").id(), 2) + self.assertEqual(job.map_settings.expressionContext().variable("to_feature_id"), 2) + self.assertIsNone(job.map_settings.expressionContext().variable("current_hover_frame")) + self.assertEqual(job.map_settings.expressionContext().variable("current_travel_frame"), 3) self.assertIsNone(job.map_settings.expressionContext().variable("hover_frames")) - self.assertEqual( - job.map_settings.expressionContext().variable("travel_frames"), 4 - ) + self.assertEqual(job.map_settings.expressionContext().variable("travel_frames"), 4) self.assertEqual(job.map_settings.expressionContext().feature().id(), 2) - self.assertEqual( - job.map_settings.expressionContext().variable("total_frame_count"), 8 - ) + self.assertEqual(job.map_settings.expressionContext().variable("total_frame_count"), 8) self.assertEqual( job.map_settings.expressionContext().variable("current_animation_action"), "Travelling", @@ -834,53 +597,31 @@ def test_planar(self): 959999, delta=120000, ) - self.assertEqual( - job.map_settings.expressionContext().variable("frame_number"), 6 - ) + self.assertEqual(job.map_settings.expressionContext().variable("frame_number"), 6) self.assertEqual(job.map_settings.expressionContext().variable("frame_rate"), 2) - self.assertEqual( - job.map_settings.expressionContext().variable("hover_feature").id(), 2 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("hover_feature_id"), 2 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("previous_feature").id(), 1 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("previous_feature_id"), 1 - ) + self.assertEqual(job.map_settings.expressionContext().variable("hover_feature").id(), 2) + self.assertEqual(job.map_settings.expressionContext().variable("hover_feature_id"), 2) + self.assertEqual(job.map_settings.expressionContext().variable("previous_feature").id(), 1) + self.assertEqual(job.map_settings.expressionContext().variable("previous_feature_id"), 1) self.assertIsNone(job.map_settings.expressionContext().variable("next_feature")) - self.assertIsNone( - job.map_settings.expressionContext().variable("next_feature_id") - ) + self.assertIsNone(job.map_settings.expressionContext().variable("next_feature_id")) self.assertIsNone(job.map_settings.expressionContext().variable("from_feature")) - self.assertIsNone( - job.map_settings.expressionContext().variable("from_feature_id") - ) + self.assertIsNone(job.map_settings.expressionContext().variable("from_feature_id")) self.assertIsNone(job.map_settings.expressionContext().variable("to_feature")) - self.assertIsNone( - job.map_settings.expressionContext().variable("to_feature_id") - ) + self.assertIsNone(job.map_settings.expressionContext().variable("to_feature_id")) self.assertEqual( job.map_settings.expressionContext().variable("current_hover_frame"), 0, ) - self.assertIsNone( - job.map_settings.expressionContext().variable("current_travel_frame") - ) + self.assertIsNone(job.map_settings.expressionContext().variable("current_travel_frame")) self.assertEqual( job.map_settings.expressionContext().variable("hover_frames"), 2, ) - self.assertIsNone( - job.map_settings.expressionContext().variable("travel_frames") - ) + self.assertIsNone(job.map_settings.expressionContext().variable("travel_frames")) self.assertEqual(job.map_settings.expressionContext().feature().id(), 2) - self.assertEqual( - job.map_settings.expressionContext().variable("total_frame_count"), 8 - ) + self.assertEqual(job.map_settings.expressionContext().variable("total_frame_count"), 8) self.assertEqual( job.map_settings.expressionContext().variable("current_animation_action"), "Hovering", @@ -898,53 +639,31 @@ def test_planar(self): 959999, delta=120000, ) - self.assertEqual( - job.map_settings.expressionContext().variable("frame_number"), 7 - ) + self.assertEqual(job.map_settings.expressionContext().variable("frame_number"), 7) self.assertEqual(job.map_settings.expressionContext().variable("frame_rate"), 2) - self.assertEqual( - job.map_settings.expressionContext().variable("hover_feature").id(), 2 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("hover_feature_id"), 2 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("previous_feature").id(), 1 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("previous_feature_id"), 1 - ) + self.assertEqual(job.map_settings.expressionContext().variable("hover_feature").id(), 2) + self.assertEqual(job.map_settings.expressionContext().variable("hover_feature_id"), 2) + self.assertEqual(job.map_settings.expressionContext().variable("previous_feature").id(), 1) + self.assertEqual(job.map_settings.expressionContext().variable("previous_feature_id"), 1) self.assertIsNone(job.map_settings.expressionContext().variable("next_feature")) - self.assertIsNone( - job.map_settings.expressionContext().variable("next_feature_id") - ) + self.assertIsNone(job.map_settings.expressionContext().variable("next_feature_id")) self.assertIsNone(job.map_settings.expressionContext().variable("from_feature")) - self.assertIsNone( - job.map_settings.expressionContext().variable("from_feature_id") - ) + self.assertIsNone(job.map_settings.expressionContext().variable("from_feature_id")) self.assertIsNone(job.map_settings.expressionContext().variable("to_feature")) - self.assertIsNone( - job.map_settings.expressionContext().variable("to_feature_id") - ) + self.assertIsNone(job.map_settings.expressionContext().variable("to_feature_id")) self.assertEqual( job.map_settings.expressionContext().variable("current_hover_frame"), 1, ) - self.assertIsNone( - job.map_settings.expressionContext().variable("current_travel_frame") - ) + self.assertIsNone(job.map_settings.expressionContext().variable("current_travel_frame")) self.assertEqual( job.map_settings.expressionContext().variable("hover_frames"), 2, ) - self.assertIsNone( - job.map_settings.expressionContext().variable("travel_frames") - ) + self.assertIsNone(job.map_settings.expressionContext().variable("travel_frames")) self.assertEqual(job.map_settings.expressionContext().feature().id(), 2) - self.assertEqual( - job.map_settings.expressionContext().variable("total_frame_count"), 8 - ) + self.assertEqual(job.map_settings.expressionContext().variable("total_frame_count"), 8) self.assertEqual( job.map_settings.expressionContext().variable("current_animation_action"), "Hovering", @@ -1002,25 +721,13 @@ def test_planar_loop(self): 959999, delta=120000, ) - self.assertEqual( - job.map_settings.expressionContext().variable("hover_feature").id(), 1 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("hover_feature_id"), 1 - ) + self.assertEqual(job.map_settings.expressionContext().variable("hover_feature").id(), 1) + self.assertEqual(job.map_settings.expressionContext().variable("hover_feature_id"), 1) # make sure previous_feature is set to wrap around back to start - self.assertEqual( - job.map_settings.expressionContext().variable("previous_feature").id(), 2 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("previous_feature_id"), 2 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("next_feature").id(), 2 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("next_feature_id"), 2 - ) + self.assertEqual(job.map_settings.expressionContext().variable("previous_feature").id(), 2) + self.assertEqual(job.map_settings.expressionContext().variable("previous_feature_id"), 2) + self.assertEqual(job.map_settings.expressionContext().variable("next_feature").id(), 2) + self.assertEqual(job.map_settings.expressionContext().variable("next_feature_id"), 2) job = next(it) self.assertAlmostEqual(job.map_settings.extent().center().x(), 1, 2) @@ -1079,18 +786,10 @@ def test_planar_loop(self): self.assertEqual(job.map_settings.currentFrame(), 7) # make sure next_feature is set to wrap around back to start - self.assertEqual( - job.map_settings.expressionContext().variable("previous_feature").id(), 1 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("previous_feature_id"), 1 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("next_feature").id(), 1 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("next_feature_id"), 1 - ) + self.assertEqual(job.map_settings.expressionContext().variable("previous_feature").id(), 1) + self.assertEqual(job.map_settings.expressionContext().variable("previous_feature_id"), 1) + self.assertEqual(job.map_settings.expressionContext().variable("next_feature").id(), 1) + self.assertEqual(job.map_settings.expressionContext().variable("next_feature_id"), 1) # travel from last to first job = next(it) @@ -1106,42 +805,20 @@ def test_planar_loop(self): 959999, delta=120000, ) - self.assertEqual( - job.map_settings.expressionContext().variable("frame_number"), 8 - ) + self.assertEqual(job.map_settings.expressionContext().variable("frame_number"), 8) self.assertEqual(job.map_settings.expressionContext().variable("frame_rate"), 2) - self.assertIsNone( - job.map_settings.expressionContext().variable("hover_feature") - ) - self.assertIsNone( - job.map_settings.expressionContext().variable("hover_feature_id") - ) - self.assertEqual( - job.map_settings.expressionContext().variable("from_feature").id(), 2 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("from_feature_id"), 2 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("to_feature").id(), 1 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("to_feature_id"), 1 - ) - self.assertIsNone( - job.map_settings.expressionContext().variable("current_hover_frame") - ) - self.assertEqual( - job.map_settings.expressionContext().variable("current_travel_frame"), 0 - ) + self.assertIsNone(job.map_settings.expressionContext().variable("hover_feature")) + self.assertIsNone(job.map_settings.expressionContext().variable("hover_feature_id")) + self.assertEqual(job.map_settings.expressionContext().variable("from_feature").id(), 2) + self.assertEqual(job.map_settings.expressionContext().variable("from_feature_id"), 2) + self.assertEqual(job.map_settings.expressionContext().variable("to_feature").id(), 1) + self.assertEqual(job.map_settings.expressionContext().variable("to_feature_id"), 1) + self.assertIsNone(job.map_settings.expressionContext().variable("current_hover_frame")) + self.assertEqual(job.map_settings.expressionContext().variable("current_travel_frame"), 0) self.assertIsNone(job.map_settings.expressionContext().variable("hover_frames")) - self.assertEqual( - job.map_settings.expressionContext().variable("travel_frames"), 4 - ) + self.assertEqual(job.map_settings.expressionContext().variable("travel_frames"), 4) self.assertEqual(job.map_settings.expressionContext().feature().id(), 2) - self.assertEqual( - job.map_settings.expressionContext().variable("total_frame_count"), 12 - ) + self.assertEqual(job.map_settings.expressionContext().variable("total_frame_count"), 12) self.assertEqual( job.map_settings.expressionContext().variable("current_animation_action"), "Travelling", @@ -1160,42 +837,20 @@ def test_planar_loop(self): 1599999, delta=120000, ) - self.assertEqual( - job.map_settings.expressionContext().variable("frame_number"), 9 - ) + self.assertEqual(job.map_settings.expressionContext().variable("frame_number"), 9) self.assertEqual(job.map_settings.expressionContext().variable("frame_rate"), 2) - self.assertIsNone( - job.map_settings.expressionContext().variable("hover_feature") - ) - self.assertIsNone( - job.map_settings.expressionContext().variable("hover_feature_id") - ) - self.assertEqual( - job.map_settings.expressionContext().variable("from_feature").id(), 2 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("from_feature_id"), 2 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("to_feature").id(), 1 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("to_feature_id"), 1 - ) - self.assertIsNone( - job.map_settings.expressionContext().variable("current_hover_frame") - ) - self.assertEqual( - job.map_settings.expressionContext().variable("current_travel_frame"), 1 - ) + self.assertIsNone(job.map_settings.expressionContext().variable("hover_feature")) + self.assertIsNone(job.map_settings.expressionContext().variable("hover_feature_id")) + self.assertEqual(job.map_settings.expressionContext().variable("from_feature").id(), 2) + self.assertEqual(job.map_settings.expressionContext().variable("from_feature_id"), 2) + self.assertEqual(job.map_settings.expressionContext().variable("to_feature").id(), 1) + self.assertEqual(job.map_settings.expressionContext().variable("to_feature_id"), 1) + self.assertIsNone(job.map_settings.expressionContext().variable("current_hover_frame")) + self.assertEqual(job.map_settings.expressionContext().variable("current_travel_frame"), 1) self.assertIsNone(job.map_settings.expressionContext().variable("hover_frames")) - self.assertEqual( - job.map_settings.expressionContext().variable("travel_frames"), 4 - ) + self.assertEqual(job.map_settings.expressionContext().variable("travel_frames"), 4) self.assertEqual(job.map_settings.expressionContext().feature().id(), 2) - self.assertEqual( - job.map_settings.expressionContext().variable("total_frame_count"), 12 - ) + self.assertEqual(job.map_settings.expressionContext().variable("total_frame_count"), 12) self.assertEqual( job.map_settings.expressionContext().variable("current_animation_action"), "Travelling", @@ -1214,42 +869,20 @@ def test_planar_loop(self): 1599999, delta=120000, ) - self.assertEqual( - job.map_settings.expressionContext().variable("frame_number"), 10 - ) + self.assertEqual(job.map_settings.expressionContext().variable("frame_number"), 10) self.assertEqual(job.map_settings.expressionContext().variable("frame_rate"), 2) - self.assertIsNone( - job.map_settings.expressionContext().variable("hover_feature") - ) - self.assertIsNone( - job.map_settings.expressionContext().variable("hover_feature_id") - ) - self.assertEqual( - job.map_settings.expressionContext().variable("from_feature").id(), 2 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("from_feature_id"), 2 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("to_feature").id(), 1 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("to_feature_id"), 1 - ) - self.assertIsNone( - job.map_settings.expressionContext().variable("current_hover_frame") - ) - self.assertEqual( - job.map_settings.expressionContext().variable("current_travel_frame"), 2 - ) + self.assertIsNone(job.map_settings.expressionContext().variable("hover_feature")) + self.assertIsNone(job.map_settings.expressionContext().variable("hover_feature_id")) + self.assertEqual(job.map_settings.expressionContext().variable("from_feature").id(), 2) + self.assertEqual(job.map_settings.expressionContext().variable("from_feature_id"), 2) + self.assertEqual(job.map_settings.expressionContext().variable("to_feature").id(), 1) + self.assertEqual(job.map_settings.expressionContext().variable("to_feature_id"), 1) + self.assertIsNone(job.map_settings.expressionContext().variable("current_hover_frame")) + self.assertEqual(job.map_settings.expressionContext().variable("current_travel_frame"), 2) self.assertIsNone(job.map_settings.expressionContext().variable("hover_frames")) - self.assertEqual( - job.map_settings.expressionContext().variable("travel_frames"), 4 - ) + self.assertEqual(job.map_settings.expressionContext().variable("travel_frames"), 4) self.assertEqual(job.map_settings.expressionContext().feature().id(), 2) - self.assertEqual( - job.map_settings.expressionContext().variable("total_frame_count"), 12 - ) + self.assertEqual(job.map_settings.expressionContext().variable("total_frame_count"), 12) self.assertEqual( job.map_settings.expressionContext().variable("current_animation_action"), "Travelling", @@ -1266,42 +899,20 @@ def test_planar_loop(self): 959999, delta=120000, ) - self.assertEqual( - job.map_settings.expressionContext().variable("frame_number"), 11 - ) + self.assertEqual(job.map_settings.expressionContext().variable("frame_number"), 11) self.assertEqual(job.map_settings.expressionContext().variable("frame_rate"), 2) - self.assertIsNone( - job.map_settings.expressionContext().variable("hover_feature") - ) - self.assertIsNone( - job.map_settings.expressionContext().variable("hover_feature_id") - ) - self.assertEqual( - job.map_settings.expressionContext().variable("from_feature").id(), 2 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("from_feature_id"), 2 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("to_feature").id(), 1 - ) - self.assertEqual( - job.map_settings.expressionContext().variable("to_feature_id"), 1 - ) - self.assertIsNone( - job.map_settings.expressionContext().variable("current_hover_frame") - ) - self.assertEqual( - job.map_settings.expressionContext().variable("current_travel_frame"), 3 - ) + self.assertIsNone(job.map_settings.expressionContext().variable("hover_feature")) + self.assertIsNone(job.map_settings.expressionContext().variable("hover_feature_id")) + self.assertEqual(job.map_settings.expressionContext().variable("from_feature").id(), 2) + self.assertEqual(job.map_settings.expressionContext().variable("from_feature_id"), 2) + self.assertEqual(job.map_settings.expressionContext().variable("to_feature").id(), 1) + self.assertEqual(job.map_settings.expressionContext().variable("to_feature_id"), 1) + self.assertIsNone(job.map_settings.expressionContext().variable("current_hover_frame")) + self.assertEqual(job.map_settings.expressionContext().variable("current_travel_frame"), 3) self.assertIsNone(job.map_settings.expressionContext().variable("hover_frames")) - self.assertEqual( - job.map_settings.expressionContext().variable("travel_frames"), 4 - ) + self.assertEqual(job.map_settings.expressionContext().variable("travel_frames"), 4) self.assertEqual(job.map_settings.expressionContext().feature().id(), 2) - self.assertEqual( - job.map_settings.expressionContext().variable("total_frame_count"), 12 - ) + self.assertEqual(job.map_settings.expressionContext().variable("total_frame_count"), 12) self.assertEqual( job.map_settings.expressionContext().variable("current_animation_action"), "Travelling", diff --git a/animation_workbench/test/test_init.py b/animation_workbench/test/test_init.py index 358e056..fcdace0 100644 --- a/animation_workbench/test/test_init.py +++ b/animation_workbench/test/test_init.py @@ -14,11 +14,10 @@ __copyright__ = "Copyright 2018, LINZ" +import configparser +import logging import os import unittest -import logging -import configparser - LOGGER = logging.getLogger("QGIS") @@ -50,9 +49,7 @@ def test_read_init(self): "author", ] - file_path = os.path.abspath( - os.path.join(os.path.dirname(__file__), os.pardir, "metadata.txt") - ) + file_path = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, "metadata.txt")) LOGGER.info(file_path) metadata = [] parser = configparser.ConfigParser() diff --git a/animation_workbench/test/test_movie_creator.py b/animation_workbench/test/test_movie_creator.py index f9894dd..608ac1c 100644 --- a/animation_workbench/test/test_movie_creator.py +++ b/animation_workbench/test/test_movie_creator.py @@ -17,6 +17,7 @@ import unittest from animation_workbench.core import MovieCommandGenerator, MovieFormat + from .utilities import get_qgis_app QGIS_APP = get_qgis_app() @@ -143,8 +144,7 @@ def test_mp4_with_music(self): "-c", "copy", "-vf", - "pad=ceil(iw/2)*2:ceil(ih/2)*2:color=white," - "scale=1920:1080,setsar=1:1", + "pad=ceil(iw/2)*2:ceil(ih/2)*2:color=white," "scale=1920:1080,setsar=1:1", "-c:v", "libx264", "-pix_fmt", diff --git a/animation_workbench/test/test_qgis_environment.py b/animation_workbench/test/test_qgis_environment.py index c35308b..d8dd04e 100644 --- a/animation_workbench/test/test_qgis_environment.py +++ b/animation_workbench/test/test_qgis_environment.py @@ -12,9 +12,10 @@ __copyright__ = "(C) 2012, Australia Indonesia Facility for Disaster Reduction" import unittest + from qgis.core import QgsProviderRegistry -from .utilities import get_qgis_app +from .utilities import get_qgis_app QGIS_APP = get_qgis_app() diff --git a/animation_workbench/test/test_translations.py b/animation_workbench/test/test_translations.py index 9d8f5d9..e430ff7 100644 --- a/animation_workbench/test/test_translations.py +++ b/animation_workbench/test/test_translations.py @@ -12,9 +12,11 @@ __date__ = "12/10/2011" __copyright__ = "(C) 2012, Australia Indonesia Facility for Disaster Reduction" -import unittest import os +import unittest + from qgis.PyQt.QtCore import QCoreApplication, QTranslator + from .utilities import get_qgis_app QGIS_APP = get_qgis_app() diff --git a/animation_workbench/test/utilities.py b/animation_workbench/test/utilities.py index 0903bb2..34293e4 100644 --- a/animation_workbench/test/utilities.py +++ b/animation_workbench/test/utilities.py @@ -1,16 +1,16 @@ # coding=utf-8 """Common functionality used by regression tests.""" -import sys +import atexit import logging import os -import atexit +import sys from qgis.core import QgsApplication -from qgis.utils import iface from qgis.gui import QgsMapCanvas from qgis.PyQt.QtCore import QSize from qgis.PyQt.QtWidgets import QWidget +from qgis.utils import iface from .qgis_interface import QgisInterface @@ -72,9 +72,7 @@ def debug_log_message(message, tag, level): """ print("{}({}): {}".format(tag, level, message)) - QgsApplication.instance().messageLog().messageReceived.connect( - debug_log_message - ) + QgsApplication.instance().messageLog().messageReceived.connect(debug_log_message) if cleanup: @@ -83,9 +81,10 @@ def exitQgis(): # pylint: disable=unused-variable """ Gracefully closes the QgsApplication instance """ + nonlocal QGISAPP try: - QGISAPP.exitQgis() # pylint: disable=used-before-assignment - QGISAPP = None # pylint: disable=redefined-outer-name + QGISAPP.exitQgis() + QGISAPP = None # noqa: F841 except NameError: pass diff --git a/animation_workbench/test_suite.py b/animation_workbench/test_suite.py index 166eb30..6dc6ba5 100644 --- a/animation_workbench/test_suite.py +++ b/animation_workbench/test_suite.py @@ -9,12 +9,13 @@ """ -import sys import os -import unittest +import sys import tempfile +import unittest + +import qgis # noqa: F401 # pylint: disable=unused-import from osgeo import gdal -import qgis # pylint: disable=unused-import try: from pip import main as pipmain diff --git a/animation_workbench/ui/animation_workbench_base.ui b/animation_workbench/ui/animation_workbench_base.ui index e84748b..22a0df5 100644 --- a/animation_workbench/ui/animation_workbench_base.ui +++ b/animation_workbench/ui/animation_workbench_base.ui @@ -164,25 +164,82 @@ zoom to each point. - - - - 999999999 - - - - - - - 0 - 0 - + + + 6 - - Frame - - + + + + + 0 + 0 + + + + Frame + + + + + + + + 0 + 0 + + + + + 60 + 0 + + + + + 80 + 16777215 + + + + 999999999 + + + + + + + + 1 + 0 + + + + 100 + + + Qt::Horizontal + + + QSlider::NoTicks + + + + + + + + 0 + 0 + + + + / 0 + + + + diff --git a/animation_workbench/ui/easing_preview_base.ui b/animation_workbench/ui/easing_preview_base.ui index bb08916..c51cc64 100644 --- a/animation_workbench/ui/easing_preview_base.ui +++ b/animation_workbench/ui/easing_preview_base.ui @@ -6,106 +6,73 @@ 0 0 - 272 - 261 + 300 + 160 Form - - - - - Enable Easing + + + 0 + + + 0 + + + 0 + + + 0 + + + 4 + + + + + 8 - - - - - - 0 - - - - Preview - - - - 0 - - - 0 - - - 0 + + + + Enable - - 0 + + + + + + false - - 0 + + + 1 + 0 + - - - - - 0 - 0 - - - - - 250 - 150 - - - - false - - - background: lightgrey; - - - - - - - - Chart - - - - 0 + + The easing will determine the motion +characteristics of the animation. - - 0 - - - 0 - - - 0 - - - 0 - - - - - - - + + + - - - - false + + + + + 0 + 1 + - - The pan easing will determine the motion -characteristics of the camera on the Y axis -as it flies across the scene. + + + 150 + 80 + @@ -128,12 +95,12 @@ as it flies across the scene. setEnabled(bool) - 37 - 18 + 50 + 15 - 53 - 52 + 200 + 15 diff --git a/animation_workbench/utilities.py b/animation_workbench/utilities.py index c8b51be..5931d36 100644 --- a/animation_workbench/utilities.py +++ b/animation_workbench/utilities.py @@ -20,8 +20,8 @@ import os -from qgis.PyQt.QtCore import QUrl from qgis.PyQt import uic +from qgis.PyQt.QtCore import QUrl def resources_path(*args): diff --git a/config.json b/config.json new file mode 100644 index 0000000..b66fa47 --- /dev/null +++ b/config.json @@ -0,0 +1,26 @@ +{ + "general": { + "name": "QGIS Animation Workbench", + "qgisMinimumVersion": 3.0, + "qgisMaximumVersion": 3.99, + "icon": "icons/icon.png", + "experimental": false, + "deprecated": false, + "homepage": "https://timlinux.github.io/QGISAnimationWorkbench/", + "tracker": "https://github.com/timlinux/QGISAnimationWorkbench/issues", + "repository": "https://github.com/timlinux/QGISAnimationWorkbench", + "tags": ["animation", "cartography", "visualization", "temporal", "video"], + "category": [ + "plugins" + ], + "hasProcessingProvider": "no", + "about": "QGIS Animation Bench exists because we wanted to use all the awesome cartography features in QGIS and make cool, animated maps! QGIS already includes the Temporal Manager which allows you to produce animations for time-based data. But what if you want to make animations where you travel around the map, zooming in and out, and perhaps making features on the map wiggle and jiggle as the animation progresses? That is what the animation workbench tries to solve...", + "author": "Tim Sutton, Nyall Dawson, Jeremy Prior", + "email": "tim@kartoza.com", + "description": "A plugin to let you build animations in QGIS", + "version": "1.4", + "changelog": "See CHANGELOG.md for details", + "server": false, + "license": "GPLv2" + } +} diff --git a/default.nix b/default.nix deleted file mode 100644 index 81cfa59..0000000 --- a/default.nix +++ /dev/null @@ -1,51 +0,0 @@ -with import { }; - -let - pythonPackages = python3Packages; -in pkgs.mkShell rec { - name = "impurePythonEnv"; - venvDir = "./.venv"; - buildInputs = [ - # A Python interpreter including the 'venv' module is required to bootstrap - # the environment. - pythonPackages.python - pylint - black - python311Packages.future - qgis - vscode - xorg.libxcb - qgis - #qgis.override { extraPythonPackages = ps: [ ps.numpy ps.future ps.geopandas ps.rasterio ];} - qt5.full - qtcreator - python3 - python3Packages.pyqt5 - python3Packages.gdal - python3Packages.pytest - zip - - # This executes some shell code to initialize a venv in $venvDir before - # dropping into the shell - - ]; - - # Run this command, only after creating the virtual environment - postVenvCreation = '' - unset SOURCE_DATE_EPOCH - pip install -r requirements.txt - ''; - - shellHook = '' - export PYTHONPATH=$PYTHONPATH:`which qgis`/../../share/qgis/python - export QT_QPA_PLATFORM=offscreen - ''; - - # Now we can execute any commands within the virtual environment. - # This is optional and can be left out to run pip manually. - postShellHook = '' - # allow pip to install wheels - unset SOURCE_DATE_EPOCH - ''; - -} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fd187bc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +services: + qgis-testing-environment: + image: ${IMAGE}:${QGIS_VERSION_TAG} + volumes: + - ./build/animation_workbench:/tests_directory:rw + environment: + QGIS_VERSION_TAG: "${QGIS_VERSION_TAG}" + WITH_PYTHON_PEP: "${WITH_PYTHON_PEP}" + ON_TRAVIS: "${ON_TRAVIS}" + MUTE_LOGS: "${MUTE_LOGS}" + DISPLAY: ":99" + working_dir: /tests_directory + entrypoint: /tests_directory/scripts/docker/qgis-testing-entrypoint.sh + # Enable "command:" line below to immediately run unittests upon docker-compose up + # command: qgis_testrunner.sh test_suite.test_package + # Default behaviour of the container is to standby + command: tail -f /dev/null + # qgis_testrunner.sh needs tty for tee + tty: true diff --git a/docs/create-uuid.py b/docs/create-uuid.py index 7cd8a6b..fd1d2f2 100755 --- a/docs/create-uuid.py +++ b/docs/create-uuid.py @@ -1,5 +1,6 @@ #! /usr/bin/env python import shortuuid + uuid = shortuuid.uuid() -print (uuid) +print(uuid) diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..7806043 --- /dev/null +++ b/flake.lock @@ -0,0 +1,207 @@ +{ + "nodes": { + "flake-parts": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1756770412, + "narHash": "sha256-+uWLQZccFHwqpGqr2Yt5VsW/PbeJVTn9Dk6SHWhNRPw=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "4524271976b625a4a605beefd893f270620fd751", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "flake-parts_2": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib_2" + }, + "locked": { + "lastModified": 1743550720, + "narHash": "sha256-hIshGgKZCgWh6AYJpJmRgFdR3WUbkY04o82X05xqQiY=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "c621e8422220273271f52058f618c94e405bb0f5", + "type": "github" + }, + "original": { + "id": "flake-parts", + "type": "indirect" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "geospatial": { + "inputs": { + "flake-parts": "flake-parts", + "nixgl": "nixgl", + "nixpkgs": "nixpkgs" + }, + "locked": { + "lastModified": 1758188081, + "narHash": "sha256-TiHtIx08Yh3jKrqzb7RzsWX++DkIMy/9HZN/+RYZkiA=", + "owner": "imincik", + "repo": "geospatial-nix.repo", + "rev": "7c71ade6082290fe45407a5be84ba51e10bb70b0", + "type": "github" + }, + "original": { + "owner": "imincik", + "repo": "geospatial-nix.repo", + "type": "github" + } + }, + "nixgl": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": [ + "geospatial", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1752054764, + "narHash": "sha256-Ob/HuUhANoDs+nvYqyTKrkcPXf4ZgXoqMTQoCK0RFgQ=", + "owner": "nix-community", + "repo": "nixGL", + "rev": "a8e1ce7d49a149ed70df676785b07f63288f53c5", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixGL", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1758029226, + "narHash": "sha256-TjqVmbpoCqWywY9xIZLTf6ANFvDCXdctCjoYuYPYdMI=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "08b8f92ac6354983f5382124fef6006cade4a1c1", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1754788789, + "narHash": "sha256-x2rJ+Ovzq0sCMpgfgGaaqgBSwY+LST+WbZ6TytnT9Rk=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "a73b9c743612e4244d865a2fdee11865283c04e6", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "nixpkgs-lib_2": { + "locked": { + "lastModified": 1743296961, + "narHash": "sha256-b1EdN3cULCqtorQ4QeWgLMrd5ZGOjLSLemfa00heasc=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "e4822aea2a6d1cdd36653c134cacfd64c97ff4fa", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1756035328, + "narHash": "sha256-vC7SslUBCtdT3T37ZH3PLIWYmTkSeppL5BJJByUjYCM=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "6b0b1559e918d4f7d1df398ee1d33aeac586d4d6", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "qgis-upstream": { + "inputs": { + "flake-parts": "flake-parts_2", + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1772755939, + "narHash": "sha256-HP1PkgLquTOrKFx4WJJH6WuD+XQJzRb775TWxiZpt60=", + "owner": "qgis", + "repo": "qgis", + "rev": "ab125d6cb95734f62aa8fb2bce481d2748967e62", + "type": "github" + }, + "original": { + "owner": "qgis", + "repo": "qgis", + "type": "github" + } + }, + "root": { + "inputs": { + "geospatial": "geospatial", + "nixpkgs": [ + "geospatial", + "nixpkgs" + ], + "qgis-upstream": "qgis-upstream" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix index 9472848..a57b604 100644 --- a/flake.nix +++ b/flake.nix @@ -1,36 +1,410 @@ +# SPDX-FileCopyrightText: Tim Sutton +# SPDX-License-Identifier: MIT { - description = "Nix flake"; - - inputs = { - nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; - }; + description = "NixOS developer environment for QGIS Animation Workbench plugin."; + inputs.qgis-upstream.url = "github:qgis/qgis"; + inputs.geospatial.url = "github:imincik/geospatial-nix.repo"; + inputs.nixpkgs.follows = "geospatial/nixpkgs"; outputs = - { self, nixpkgs }: + { + self, + qgis-upstream, + geospatial, + nixpkgs, + }: let - supportedSystems = [ - "x86_64-linux" - "aarch64-darwin" - "aarch64-linux" + system = "x86_64-linux"; + profileName = "AnimationWorkbench"; + pkgs = import nixpkgs { + inherit system; + config = { + allowUnfree = true; + }; + }; + + extraPythonPackages = ps: [ + ps.debugpy + ps.psutil + ps.pyqtgraph ]; - forAllSystems = nixpkgs.lib.genAttrs supportedSystems; + qgisWithExtras = geospatial.packages.${system}.qgis.override { + inherit extraPythonPackages; + }; + qgisLtrWithExtras = geospatial.packages.${system}.qgis-ltr.override { + inherit extraPythonPackages; + }; + qgisMasterWithExtras = qgis-upstream.packages.${system}.qgis.override { + inherit extraPythonPackages; + }; in { - devShells = forAllSystems ( - system: - let - pkgs = import nixpkgs { inherit system; }; - in - { - default = pkgs.mkShell { - packages = with pkgs; [ - gh - jq - just - yq - ]; - }; - } - ); + packages.${system} = { + default = qgisWithExtras; + qgis = qgisWithExtras; + qgis-ltr = qgisLtrWithExtras; + qgis-master = qgisMasterWithExtras; + }; + + apps.${system} = { + qgis = { + type = "app"; + program = "${qgisWithExtras}/bin/qgis"; + args = [ + "--profile" + "${profileName}" + ]; + }; + qgis-ltr = { + type = "app"; + program = "${qgisLtrWithExtras}/bin/qgis"; + args = [ + "--profile" + "${profileName}" + ]; + }; + qgis-master = { + type = "app"; + program = "${qgisMasterWithExtras}/bin/qgis"; + args = [ + "--profile" + "${profileName}" + ]; + }; + qgis_process = { + type = "app"; + program = "${qgisWithExtras}/bin/qgis_process"; + args = [ + "--profile" + "${profileName}" + ]; + }; + + # Development convenience commands + test = { + type = "app"; + program = "${pkgs.writeShellScript "run-tests" '' + cd ${toString ./.} + source .venv/bin/activate 2>/dev/null || true + ${pkgs.python3}/bin/python -m pytest animation_workbench/test/ -v "$@" + ''}"; + }; + + format = { + type = "app"; + program = "${pkgs.writeShellScript "format-code" '' + cd ${toString ./.} + echo "Running black..." + ${pkgs.python3.withPackages (ps: [ ps.black ])}/bin/black . + echo "Running isort..." + ${pkgs.isort}/bin/isort . + echo "Formatting complete!" + ''}"; + }; + + lint = { + type = "app"; + program = "${pkgs.writeShellScript "lint-code" '' + cd ${toString ./.} + echo "Running flake8..." + source .venv/bin/activate 2>/dev/null || true + ${ + pkgs.python3.withPackages (ps: [ ps.flake8 ]) + }/bin/flake8 animation_workbench/ --max-line-length=120 + echo "Running pyright..." + ${pkgs.pyright}/bin/pyright animation_workbench/ + ''}"; + }; + + checks = { + type = "app"; + program = "${pkgs.writeShellScript "run-checks" '' + cd ${toString ./.} + ./scripts/checks.sh + ''}"; + }; + + docs-serve = { + type = "app"; + program = "${pkgs.writeShellScript "serve-docs" '' + cd ${toString ./.} + source .venv/bin/activate 2>/dev/null || true + ${ + pkgs.python3.withPackages (ps: [ + ps.mkdocs + ps.mkdocs-material + ]) + }/bin/mkdocs serve + ''}"; + }; + + docs-build = { + type = "app"; + program = "${pkgs.writeShellScript "build-docs" '' + cd ${toString ./.} + source .venv/bin/activate 2>/dev/null || true + ${ + pkgs.python3.withPackages (ps: [ + ps.mkdocs + ps.mkdocs-material + ]) + }/bin/mkdocs build + ''}"; + }; + + clean = { + type = "app"; + program = "${pkgs.writeShellScript "clean-workspace" '' + cd ${toString ./.} + ./scripts/clean.sh + ''}"; + }; + + package = { + type = "app"; + program = "${pkgs.writeShellScript "package-plugin" '' + cd ${toString ./.} + echo "Building plugin package..." + cd animation_workbench + ${pkgs.zip}/bin/zip -r ../animation_workbench.zip . \ + -x '*.pyc' \ + -x '__pycache__/*' \ + -x 'test/*' \ + -x '.pytest_cache/*' + cd .. + echo "Plugin packaged to animation_workbench.zip" + ''}"; + }; + + profile = { + type = "app"; + program = "${pkgs.writeShellScript "profile-viewer" '' + if [ -f profile.prof ]; then + ${pkgs.python3.withPackages (ps: [ ps.snakeviz ])}/bin/snakeviz profile.prof + else + echo "No profile.prof found. Run: python -m cProfile -o profile.prof your_script.py" + fi + ''}"; + }; + + security = { + type = "app"; + program = "${pkgs.writeShellScript "security-scan" '' + cd ${toString ./.} + echo "Running bandit security scanner..." + ${pkgs.bandit}/bin/bandit -r animation_workbench -c pyproject.toml + ''}"; + }; + + symlink = { + type = "app"; + program = "${pkgs.writeShellScript "symlink-plugin" '' + PLUGIN_SOURCE="$(pwd)/animation_workbench" + PLUGIN_DIR="$HOME/.local/share/QGIS/QGIS3/profiles/${profileName}/python/plugins" + PLUGIN_DEST="$PLUGIN_DIR/animation_workbench" + + echo "Creating plugin symlink..." + echo " Source: $PLUGIN_SOURCE" + echo " Destination: $PLUGIN_DEST" + + # Create plugins directory if it doesn't exist + mkdir -p "$PLUGIN_DIR" + + # Remove existing plugin (symlink or directory) + if [ -L "$PLUGIN_DEST" ]; then + echo "Removing existing symlink..." + rm "$PLUGIN_DEST" + elif [ -d "$PLUGIN_DEST" ]; then + echo "Removing existing directory..." + rm -rf "$PLUGIN_DEST" + fi + + # Create symlink + ln -s "$PLUGIN_SOURCE" "$PLUGIN_DEST" + echo "Symlink created successfully!" + echo "" + echo "The plugin is now linked. Changes to the source will be" + echo "reflected in QGIS after reloading the plugin or restarting QGIS." + ''}"; + }; + + }; + + devShells.${system}.default = pkgs.mkShell { + packages = [ + # Note: QGIS is not included here to avoid qtwebengine security issues + # Use system QGIS or run via: nix run .#qgis (with --impure flag) + pkgs.actionlint # for checking gh actions + pkgs.bandit + pkgs.chafa + pkgs.nixfmt-rfc-style + pkgs.ffmpeg + pkgs.gdb + pkgs.git + pkgs.glogg + pkgs.glow # terminal markdown viewer + pkgs.gource # Software version control visualization + pkgs.gum # UX for TUIs + pkgs.isort + pkgs.jq + # kcachegrind removed - pulls in qtwebengine via KDE deps + # Use system kcachegrind or qcachegrind instead + pkgs.markdownlint-cli + pkgs.pre-commit + pkgs.pyprof2calltree # needed to covert cprofile call trees into a format kcachegrind can read + pkgs.python3 + # Python development essentials + pkgs.pyright + # Qt5 packages removed - many pull in qtwebengine + # Use system Qt Designer if needed + pkgs.ninja # needed for building numpy + pkgs.rpl + pkgs.shellcheck + pkgs.shfmt + # vscode removed - uses electron/chromium + pkgs.yamlfmt + pkgs.yamllint + pkgs.nodePackages.cspell + (pkgs.python3.withPackages (ps: [ + # Code formatting and linting + ps.black + ps.click # needed by black + ps.flake8 + ps.isort + ps.mypy + ps.bandit + + # Testing + ps.pytest + # pytest-qt omitted - pulls in qtwebengine + + # Documentation + ps.mkdocs + ps.mkdocs-material + + # Development tools + ps.debugpy + ps.docformatter + ps.pip + ps.setuptools + ps.wheel + ps.virtualenv + ps.venvShellHook + + # Libraries + ps.gdal + ps.httpx + ps.numpy + ps.psutil + ps.rich + ps.toml + ps.typer + ps.pyyaml + ps.jinja2 + ps.requests + ps.packaging + + # Profiling + ps.snakeviz + ])) + + ]; + shellHook = '' + unset SOURCE_DATE_EPOCH + + # Create a virtual environment in .venv if it doesn't exist + if [ ! -d ".venv" ]; then + python -m venv .venv + fi + + # Activate the virtual environment + source .venv/bin/activate + + # Upgrade pip and install packages from requirements.txt if it exists + pip install --upgrade pip > /dev/null + if [ -f requirements.txt ]; then + echo "Installing Python requirements from requirements.txt..." + pip install -r requirements.txt > .pip-install.log 2>&1 + if [ $? -ne 0 ]; then + echo "Pip install failed. See .pip-install.log for details." + fi + else + echo "No requirements.txt found, skipping pip install." + fi + if [ -f requirements-dev.txt ]; then + echo "Installing Python requirements from requirements-dev.txt..." + pip install -r requirements-dev.txt > .pip-install.log 2>&1 + if [ $? -ne 0 ]; then + echo "Pip install failed. See .pip-install.log for details." + fi + else + echo "No requirements-dev.txt found, skipping pip install." + fi + + # Generate pyrightconfig.json for LSP support with QGIS + QGIS_STORE_PATH=$(nix path-info .#qgis 2>/dev/null || echo "") + VENV_SITE_PACKAGES=$(find .venv/lib -maxdepth 1 -name "python*" -type d 2>/dev/null | head -1)/site-packages + if [ -n "$QGIS_STORE_PATH" ]; then + QGIS_PYTHON_PATH="$QGIS_STORE_PATH/share/qgis/python" + cat > pyrightconfig.json << EOF +{ + "venvPath": ".", + "venv": ".venv", + "extraPaths": [ + "$QGIS_PYTHON_PATH", + "$VENV_SITE_PACKAGES" + ], + "reportMissingImports": "warning", + "reportMissingTypeStubs": "none", + "pythonVersion": "3.11", + "typeCheckingMode": "basic" +} +EOF + export PYTHONPATH="$QGIS_PYTHON_PATH:$VENV_SITE_PACKAGES:$PYTHONPATH" + fi + # Colors and styling + CYAN='\033[38;2;83;161;203m' + GREEN='\033[92m' + RED='\033[91m' + RESET='\033[0m' + ORANGE='\033[38;2;237;177;72m' + GRAY='\033[90m' + # Clear screen and show welcome banner + clear + echo -e "$RESET$ORANGE" + if [ -f animation_workbench/icons/icon.png ]; then + chafa animation_workbench/icons/icon.png --size=10x10 --colors=256 | sed 's/^/ /' + fi + # Quick tips with icons + echo -e "$RESET$ORANGE \n__________________________________________________________________\n" + echo -e " Your Dev Environment is prepared." + echo -e "" + echo -e "Quick Commands:$RESET" + echo -e " $GRAY>$RESET $CYAN./scripts/vscode.sh$RESET - VSCode preconfigured for python dev" + echo -e " $GRAY>$RESET $CYAN./scripts/checks.sh$RESET - Run pre-commit checks" + echo -e " $GRAY>$RESET $CYAN./scripts/clean.sh$RESET - Cleanup dev folder" + echo -e " $GRAY>$RESET $CYAN nix flake show$RESET - Show available configurations" + echo -e "" + echo -e "Nix Run Commands:$RESET" + echo -e " $GRAY>$RESET $CYAN nix run .#qgis$RESET - Start QGIS (stable)" + echo -e " $GRAY>$RESET $CYAN nix run .#qgis-ltr$RESET - Start QGIS LTR" + echo -e " $GRAY>$RESET $CYAN nix run .#qgis-master$RESET - Start QGIS master" + echo -e " $GRAY>$RESET $CYAN nix run .#test$RESET - Run pytest test suite" + echo -e " $GRAY>$RESET $CYAN nix run .#format$RESET - Format code (black + isort)" + echo -e " $GRAY>$RESET $CYAN nix run .#lint$RESET - Run linters (flake8 + pyright)" + echo -e " $GRAY>$RESET $CYAN nix run .#checks$RESET - Run pre-commit checks" + echo -e " $GRAY>$RESET $CYAN nix run .#docs-serve$RESET - Serve docs locally" + echo -e " $GRAY>$RESET $CYAN nix run .#docs-build$RESET - Build documentation" + echo -e " $GRAY>$RESET $CYAN nix run .#package$RESET - Build plugin zip" + echo -e " $GRAY>$RESET $CYAN nix run .#symlink$RESET - Symlink plugin to QGIS profile" + echo -e " $GRAY>$RESET $CYAN nix run .#security$RESET - Run security scan (bandit)" + echo -e " $GRAY>$RESET $CYAN nix run .#clean$RESET - Clean workspace" + echo -e " $GRAY>$RESET $CYAN nix run .#profile$RESET - View profiling data (snakeviz)" + echo -e "" + echo -e "Neovim:$RESET" + echo -e " $GRAY>$RESET $CYAN nvim$RESET - Start with LSP (PYTHONPATH auto-configured)" + echo -e " $GRAY>$RESET Project keybindings: $CYANp$RESET (run :WhichKey p)" + ''; + }; }; } diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..76b9702 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +# Bandit security scanner config is in .bandit.yml +# (pre-commit uses: bandit -c .bandit.yml) + +[tool.black] +line-length = 120 +target-version = ['py38'] +include = '\.pyi?$' +extend-exclude = ''' +/( + # directories + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | _build + | buck-out + | build + | dist +)/ +''' + +[tool.isort] +profile = "black" +multi_line_output = 3 +line_length = 120 +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = true +ensure_newline_before_comments = true +extend_skip = [".vscode-extensions", ".venv", "build", "dist"] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..03d3df2 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,25 @@ +# Requirements that are not available in nixpkgs or need specific versions +# Most packages are provided by nix - see flake.nix + +# Documentation extras (mkdocs plugins not in nixpkgs) +mkdocs-autorefs>=1.2.0 +mkdocstrings>=0.26.1 +mkdocs-material-extensions>=1.3.1 +mkdocs-get-deps>=0.2.0 +pymdown-extensions>=10.9 + +# Linting extras +darglint>=1.8.1 + +# Security +defusedxml>=0.7.1 + +# CLI and HTTP (used by admin.py) +httpx>=0.24.0 +typer>=0.9.0 + +# Visualization +pyqtgraph>=0.13.0 + +# Note: PyQt5, pytest-qt, and qtwebengine-related packages are omitted +# Use system QGIS PyQt5 instead to avoid security issues with qtwebengine diff --git a/requirements.txt b/requirements.txt index e69de29..8518af0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -0,0 +1,5 @@ +# QGIS Animation Workbench - Python Dependencies +# Note: Most dependencies are provided by QGIS itself (PyQt5, qgis.core, etc.) + +# Required for easing curve preview visualization +pyqtgraph>=0.13.0 diff --git a/scripts/checks.sh b/scripts/checks.sh new file mode 100755 index 0000000..4c72891 --- /dev/null +++ b/scripts/checks.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +# Run precommit checks +# +RESET='\033[0m' +ORANGE='\033[38;2;237;177;72m' +# Clear screen and show welcome banner +clear +echo -e "$RESET$ORANGE" +if [ -f animation_workbench/icons/icon.png ]; then + chafa animation_workbench/icons/icon.png --size=10x10 --colors=256 | sed 's/^/ /' +fi +# Quick tips with icons +echo -e "$RESET$ORANGE \n__________________________________________________________________\n" +echo "Setting up and running pre-commit hooks..." +echo -e "$RESET$ORANGE \n__________________________________________________________________\n" +pre-commit clean >/dev/null +pre-commit install --install-hooks >/dev/null +pre-commit run --all-files || true +echo -e "$RESET$ORANGE \n__________________________________________________________________\n" diff --git a/scripts/clean.sh b/scripts/clean.sh new file mode 100755 index 0000000..0156b77 --- /dev/null +++ b/scripts/clean.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +# Run precommit checks +# +RESET='\033[0m' +ORANGE='\033[38;2;237;177;72m' +# Clear screen and show welcome banner +clear +echo -e "$RESET$ORANGE" +if [ -f animation_workbench/icons/icon.png ]; then + chafa animation_workbench/icons/icon.png --size=10x10 --colors=256 | sed 's/^/ /' +fi +# Quick tips with icons +echo -e "$RESET$ORANGE \n__________________________________________________________________\n" +echo "Removing pycaches, .venv etc ..." +find . -type d -name "__pycache__" -exec rm -rf {} + +find . -type d -name ".venv" -exec rm -rf {} + +echo "Removing core dumps and other unneeded files ..." +find . -type f -name "core.*" -exec rm -f {} + +find . -type f -name "*.log" -exec rm -f {} + +find . -type f -name "*.tmp" -exec rm -f {} + +echo -e "$RESET$ORANGE \n__________________________________________________________________\n" diff --git a/scripts/docker/qgis-testing-entrypoint.sh b/scripts/docker/qgis-testing-entrypoint.sh new file mode 100755 index 0000000..aa15b4a --- /dev/null +++ b/scripts/docker/qgis-testing-entrypoint.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Tim Sutton +# SPDX-License-Identifier: MIT + +# Entry point script for QGIS testing container + +set -e + +# Start Xvfb for headless display +Xvfb :99 -screen 0 1024x768x24 & +export DISPLAY=:99 + +# Wait for Xvfb to be ready +sleep 2 + +# Install any additional Python dependencies +if [ -f /tests_directory/requirements-dev.txt ]; then + pip install -r /tests_directory/requirements-dev.txt +fi + +# Execute the command passed to docker +exec "$@" diff --git a/scripts/docstrings_check.sh b/scripts/docstrings_check.sh new file mode 100755 index 0000000..d129efd --- /dev/null +++ b/scripts/docstrings_check.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Tim Sutton +# SPDX-License-Identifier: MIT +# +# Add a check that ensures that any python modules updated +# have docstrings in google docstring format for every method, +# function and class. + +missing=0 +for file in $(git diff --cached --name-only --diff-filter=ACM | grep -E "\.py$"); do + if ! which darglint >/dev/null 2>&1; then + echo "darglint not installed. Please install it with: pip install darglint" + exit 1 + fi + if ! output=$(darglint --docstring-style=google "$file" 2>&1); then + echo "Docstring check failed for: $file" + echo "$output" + missing=1 + fi +done +exit $missing diff --git a/scripts/encoding_check.sh b/scripts/encoding_check.sh new file mode 100755 index 0000000..0d1afde --- /dev/null +++ b/scripts/encoding_check.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Tim Sutton +# SPDX-License-Identifier: MIT + +# Add a precommit hook that ensures that each python +# file is declared with the correct encoding +# -*- coding: utf-8 -*- + +add_encoding_to_file() { + local file="$1" + local temp_file + temp_file=$(mktemp) + + # Check if file starts with shebang + if head -n 1 "$file" | grep -q "^#!"; then + # Add encoding after shebang + head -n 1 "$file" >"$temp_file" + echo "# -*- coding: utf-8 -*-" >>"$temp_file" + tail -n +2 "$file" >>"$temp_file" + else + # Add encoding at the beginning + echo "# -*- coding: utf-8 -*-" >"$temp_file" + cat "$file" >>"$temp_file" + fi + + mv "$temp_file" "$file" + echo "Added UTF-8 encoding declaration to $file" +} + +for file in $(git diff --cached --name-only --diff-filter=ACM | grep -E "\.py$"); do + # check if first line contains coding declaration + # or first has interpreter then enccoding declaration on the next line + if ! grep -q "^#.*coding[:=]\s*utf-8" "$file"; then + echo "$file is missing UTF-8 encoding declaration" + # Check if running in interactive mode (TTY available) + if [ -t 0 ]; then + read -p "Do you want to add the encoding declaration to $file? (y/N): " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + add_encoding_to_file "$file" + else + echo "Skipping $file" + exit 1 + fi + else + # Non-interactive mode (CI) - auto-add the encoding + echo "Non-interactive mode: automatically adding encoding to $file" + add_encoding_to_file "$file" + fi + fi +done diff --git a/scripts/start_qgis.sh b/scripts/start_qgis.sh new file mode 100755 index 0000000..936c99b --- /dev/null +++ b/scripts/start_qgis.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Tim Sutton +# SPDX-License-Identifier: MIT + +echo "Running QGIS with the AnimationWorkbench profile:" +echo "--------------------------------" + +echo "Select run mode:" +choice=$(gum choose "Normal" "Debug Mode" "With Crash Handler (gdb)" "With Crash Handler (catchsegv)") + +developer_mode=0 +use_gdb=0 +use_catchsegv=0 + +case $choice in + "Debug Mode") + developer_mode=1 + ;; + "With Crash Handler (gdb)") + use_gdb=1 + ;; + "With Crash Handler (catchsegv)") + use_catchsegv=1 + ;; +esac + +# Running on local used to skip tests that will not work in a local dev env +ANIMATION_WORKBENCH_LOG=$HOME/AnimationWorkbench.log +ANIMATION_WORKBENCH_TEST_DIR="$(pwd)/test" +CRASH_LOG="$HOME/qgis_crash_$(date +%Y%m%d_%H%M%S).log" +rm -f "$ANIMATION_WORKBENCH_LOG" + +# Enable core dumps +ulimit -c unlimited 2>/dev/null + +# Get the QGIS package path +QGIS_PKG=$(nix build .#qgis --print-out-paths 2>/dev/null) +QGIS_WRAPPER="$QGIS_PKG/bin/qgis" +QGIS_REAL="$QGIS_PKG/bin/.qgis-wrapped_" + +export ANIMATION_WORKBENCH_LOG=${ANIMATION_WORKBENCH_LOG} +export ANIMATION_WORKBENCH_DEBUG=${developer_mode} +export ANIMATION_WORKBENCH_TEST_DIR=${ANIMATION_WORKBENCH_TEST_DIR} +export RUNNING_ON_LOCAL=1 + +if [[ $use_gdb -eq 1 ]]; then + echo "Running with gdb - stack trace will be captured on crash" + echo "Crash log will be saved to: $CRASH_LOG" + + # Source environment from wrapper and run gdb on real binary + # Extract PYTHONPATH and PATH setup from wrapper + eval "$(grep -E '^(export |PATH=|PYTHONPATH=)' "$QGIS_WRAPPER" | grep -v 'exec')" + + if [[ -x "$QGIS_REAL" ]]; then + gdb -batch \ + -ex "set pagination off" \ + -ex "run --profile AnimationWorkbench" \ + -ex "bt full" \ + -ex "info registers" \ + -ex "quit" \ + "$QGIS_REAL" 2>&1 | tee "$CRASH_LOG" + exit_code=${PIPESTATUS[0]} + else + echo "Error: Real QGIS binary not found at $QGIS_REAL" + echo "Falling back to catchsegv..." + use_catchsegv=1 + use_gdb=0 + fi +fi + +if [[ $use_catchsegv -eq 1 ]]; then + echo "Running with catchsegv - stack trace will be captured on crash" + echo "Crash log will be saved to: $CRASH_LOG" + catchsegv "$QGIS_WRAPPER" --profile AnimationWorkbench 2>&1 | tee "$CRASH_LOG" + exit_code=${PIPESTATUS[0]} +fi + +if [[ $use_gdb -eq 0 ]] && [[ $use_catchsegv -eq 0 ]]; then + # Normal run via nix + nix run .#default -- --profile AnimationWorkbench + exit_code=$? +fi + +# Check if crashed +if [[ $exit_code -eq 139 ]] || [[ $exit_code -eq 134 ]] || [[ $exit_code -eq 136 ]] || [[ $exit_code -eq 11 ]]; then + echo "" + echo "========================================" + echo "QGIS crashed with exit code: $exit_code" + echo "========================================" + if [[ -f "$CRASH_LOG" ]]; then + echo "Crash log saved to: $CRASH_LOG" + echo "" + echo "Last 50 lines of crash log:" + echo "----------------------------------------" + tail -50 "$CRASH_LOG" + else + echo "To get a stack trace, run this script again and select 'With Crash Handler'" + fi +fi diff --git a/scripts/start_qgis_ltr.sh b/scripts/start_qgis_ltr.sh new file mode 100755 index 0000000..1865d39 --- /dev/null +++ b/scripts/start_qgis_ltr.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Tim Sutton +# SPDX-License-Identifier: MIT + +echo "Running QGIS LTR with the AnimationWorkbench profile:" +echo "--------------------------------" + +echo "Select run mode:" +choice=$(gum choose "Normal" "Debug Mode" "With Crash Handler (gdb)" "With Crash Handler (catchsegv)") + +developer_mode=0 +use_gdb=0 +use_catchsegv=0 + +case $choice in + "Debug Mode") + developer_mode=1 + ;; + "With Crash Handler (gdb)") + use_gdb=1 + ;; + "With Crash Handler (catchsegv)") + use_catchsegv=1 + ;; +esac + +# Running on local used to skip tests that will not work in a local dev env +ANIMATION_WORKBENCH_LOG=$HOME/AnimationWorkbench.log +ANIMATION_WORKBENCH_TEST_DIR="$(pwd)/test" +CRASH_LOG="$HOME/qgis_crash_$(date +%Y%m%d_%H%M%S).log" +rm -f "$ANIMATION_WORKBENCH_LOG" + +# Enable core dumps +ulimit -c unlimited 2>/dev/null + +# Build the QGIS path +QGIS_PATH=$(nix build .#qgis-ltr --print-out-paths 2>/dev/null)/bin/qgis + +export ANIMATION_WORKBENCH_LOG=${ANIMATION_WORKBENCH_LOG} +export ANIMATION_WORKBENCH_DEBUG=${developer_mode} +export ANIMATION_WORKBENCH_TEST_DIR=${ANIMATION_WORKBENCH_TEST_DIR} +export RUNNING_ON_LOCAL=1 + +if [[ $use_gdb -eq 1 ]]; then + echo "Running with gdb - stack trace will be captured on crash" + echo "Crash log will be saved to: $CRASH_LOG" + gdb -batch \ + -ex "set pagination off" \ + -ex "run" \ + -ex "bt full" \ + -ex "info registers" \ + -ex "quit" \ + --args "$QGIS_PATH" --profile AnimationWorkbench 2>&1 | tee "$CRASH_LOG" + exit_code=${PIPESTATUS[0]} +elif [[ $use_catchsegv -eq 1 ]]; then + echo "Running with catchsegv - stack trace will be captured on crash" + echo "Crash log will be saved to: $CRASH_LOG" + catchsegv "$QGIS_PATH" --profile AnimationWorkbench 2>&1 | tee "$CRASH_LOG" + exit_code=${PIPESTATUS[0]} +else + # Normal run via nix + nix run .#qgis-ltr -- --profile AnimationWorkbench + exit_code=$? +fi + +# Check if crashed +if [[ $exit_code -eq 139 ]] || [[ $exit_code -eq 134 ]] || [[ $exit_code -eq 136 ]]; then + echo "" + echo "========================================" + echo "QGIS crashed with exit code: $exit_code" + echo "========================================" + if [[ -f "$CRASH_LOG" ]]; then + echo "Crash log saved to: $CRASH_LOG" + echo "" + echo "Last 50 lines of crash log:" + echo "----------------------------------------" + tail -50 "$CRASH_LOG" + else + echo "To get a stack trace, run this script again and select 'With Crash Handler'" + fi +fi diff --git a/scripts/start_qgis_master.sh b/scripts/start_qgis_master.sh new file mode 100755 index 0000000..ca118a7 --- /dev/null +++ b/scripts/start_qgis_master.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Tim Sutton +# SPDX-License-Identifier: MIT + +echo "Running QGIS Master with the AnimationWorkbench profile:" +echo "--------------------------------" + +echo "Select run mode:" +choice=$(gum choose "Normal" "Debug Mode" "With Crash Handler (gdb)" "With Crash Handler (catchsegv)") + +developer_mode=0 +use_gdb=0 +use_catchsegv=0 + +case $choice in + "Debug Mode") + developer_mode=1 + ;; + "With Crash Handler (gdb)") + use_gdb=1 + ;; + "With Crash Handler (catchsegv)") + use_catchsegv=1 + ;; +esac + +# Running on local used to skip tests that will not work in a local dev env +ANIMATION_WORKBENCH_LOG=$HOME/AnimationWorkbench.log +ANIMATION_WORKBENCH_TEST_DIR="$(pwd)/test" +CRASH_LOG="$HOME/qgis_crash_$(date +%Y%m%d_%H%M%S).log" +rm -f "$ANIMATION_WORKBENCH_LOG" + +# Enable core dumps +ulimit -c unlimited 2>/dev/null + +# Build the QGIS path +QGIS_PATH=$(nix build .#qgis-master --print-out-paths 2>/dev/null)/bin/qgis + +export ANIMATION_WORKBENCH_LOG=${ANIMATION_WORKBENCH_LOG} +export ANIMATION_WORKBENCH_DEBUG=${developer_mode} +export ANIMATION_WORKBENCH_TEST_DIR=${ANIMATION_WORKBENCH_TEST_DIR} +export RUNNING_ON_LOCAL=1 + +if [[ $use_gdb -eq 1 ]]; then + echo "Running with gdb - stack trace will be captured on crash" + echo "Crash log will be saved to: $CRASH_LOG" + gdb -batch \ + -ex "set pagination off" \ + -ex "run" \ + -ex "bt full" \ + -ex "info registers" \ + -ex "quit" \ + --args "$QGIS_PATH" --profile AnimationWorkbench 2>&1 | tee "$CRASH_LOG" + exit_code=${PIPESTATUS[0]} +elif [[ $use_catchsegv -eq 1 ]]; then + echo "Running with catchsegv - stack trace will be captured on crash" + echo "Crash log will be saved to: $CRASH_LOG" + catchsegv "$QGIS_PATH" --profile AnimationWorkbench 2>&1 | tee "$CRASH_LOG" + exit_code=${PIPESTATUS[0]} +else + # Normal run via nix + nix run .#qgis-master -- --profile AnimationWorkbench + exit_code=$? +fi + +# Check if crashed +if [[ $exit_code -eq 139 ]] || [[ $exit_code -eq 134 ]] || [[ $exit_code -eq 136 ]]; then + echo "" + echo "========================================" + echo "QGIS crashed with exit code: $exit_code" + echo "========================================" + if [[ -f "$CRASH_LOG" ]]; then + echo "Crash log saved to: $CRASH_LOG" + echo "" + echo "Last 50 lines of crash log:" + echo "----------------------------------------" + tail -50 "$CRASH_LOG" + else + echo "To get a stack trace, run this script again and select 'With Crash Handler'" + fi +fi diff --git a/scripts/vscode-extensions.txt b/scripts/vscode-extensions.txt new file mode 100644 index 0000000..be8cb56 --- /dev/null +++ b/scripts/vscode-extensions.txt @@ -0,0 +1,22 @@ +brettm12345.nixfmt-vscode@0.0.1 +DavidAnson.vscode-markdownlint@0.60.0 +donjayamanne.python-environment-manager@1.2.7 +donjayamanne.python-extension-pack@1.7.0 +foxundermoon.shell-format@7.2.5 +github.vscode-github-actions@0.27.1 +GitHub.vscode-pull-request-github@0.108.0 +hbenl.vscode-test-explorer@2.22.1 +KevinRose.vsc-python-indent@1.21.0 +mkhl.direnv@0.17.0 +ms-python.black-formatter@2025.2.0 +ms-python.debugpy@2025.8.0 +ms-python.python@2025.6.1 +ms-python.vscode-pylance@2025.4.1 +ms-vscode.test-adapter-converter@0.2.1 +naumovs.color-highlight@2.8.0 +njpwerner.autodocstring@0.6.1 +shd101wyy.markdown-preview-enhanced@0.8.18 +timonwong.shellcheck@0.37.7 +vscodevim.vim@1.32.1 +waderyan.gitblame@11.1.3 +yzhang.markdown-all-in-one@3.6.3 diff --git a/scripts/vscode.sh b/scripts/vscode.sh new file mode 100755 index 0000000..39c214f --- /dev/null +++ b/scripts/vscode.sh @@ -0,0 +1,342 @@ +#!/usr/bin/env bash + +# ---------------------------------------------- +# User-adjustable parameters +# ---------------------------------------------- + +VSCODE_PROFILE="AnimationWorkbench" +EXT_DIR=".vscode-extensions" +VSCODE_DIR=".vscode" +LOG_FILE="vscode.log" +EXT_LIST_FILE="$(dirname "$0")/vscode-extensions.txt" + +# Read extensions from file +if [[ ! -f "$EXT_LIST_FILE" ]]; then + echo "Extension list file not found: $EXT_LIST_FILE" + exit 1 +fi +mapfile -t REQUIRED_EXTENSIONS <"$EXT_LIST_FILE" + +# ---------------------------------------------- +# Functions +# ---------------------------------------------- + +launch_vscode() { + code --user-data-dir="$VSCODE_DIR" \ + --profile="${VSCODE_PROFILE}" \ + --extensions-dir="$EXT_DIR" "$@" +} + +list_installed_extensions() { + echo "Installed extensions:" + echo "" >"$EXT_LIST_FILE" + find "$EXT_DIR" -maxdepth 1 -mindepth 1 -type d | while read -r dir; do + pkg="$dir/package.json" + if [[ -f "$pkg" ]]; then + name=$(jq -r '.name' <"$pkg") + publisher=$(jq -r '.publisher' <"$pkg") + version=$(jq -r '.version' <"$pkg") + echo "${publisher}.${name}@${version}" >>"$EXT_LIST_FILE" + fi + done + # Now sort the extension list and pipe it through uniq to remove duplicates + sort -u "$EXT_LIST_FILE" -o "$EXT_LIST_FILE" + cat "$EXT_LIST_FILE" +} + +clean() { + rm -rf .vscode .vscode-extensions +} +print_help() { + cat <"$LOG_FILE" + +# Locate QGIS binary +QGIS_BIN=$(which qgis) + +if [[ -z "$QGIS_BIN" ]]; then + echo "Error: QGIS binary not found!" + exit 1 +fi + +# Extract the Nix store path (removing /bin/qgis) +QGIS_PREFIX=$(dirname "$(dirname "$QGIS_BIN")") + +# Construct the correct QGIS Python path +QGIS_PYTHON_PATH="$QGIS_PREFIX/share/qgis/python" + +# Check if the Python directory exists +if [[ ! -d "$QGIS_PYTHON_PATH" ]]; then + echo "Error: QGIS Python path not found at $QGIS_PYTHON_PATH" + exit 1 +fi + +# Create .env file for VSCode +ENV_FILE=".env" + +echo "Creating VSCode .env file..." +cat <"$ENV_FILE" +PYTHONPATH=$QGIS_PYTHON_PATH +# needed for launch.json +QGIS_EXECUTABLE=$QGIS_BIN +QGIS_PREFIX_PATH=$QGIS_PREFIX +PYQT5_PATH="$QGIS_PREFIX/share/qgis/python/PyQt" +QT_QPA_PLATFORM=offscreen +EOF + +echo ".env file created successfully!" +echo "Contents of .env:" +cat "$ENV_FILE" + +# Also set the python path in this shell in case we want to run tests etc from the command line +export PYTHONPATH=$PYTHONPATH:$QGIS_PYTHON_PATH + +echo "Checking VSCode is installed ..." +if ! command -v code &>/dev/null; then + echo " 'code' CLI not found. Please install VSCode and add 'code' to your PATH." + exit 1 +else + echo " VSCode found ok." +fi + +# Ensure .vscode directory exists +echo "Checking if VSCode has been run before..." +if [ ! -d .vscode ]; then + echo " It appears you have not run vscode in this project before." + echo " After it opens, please close vscode and then rerun this script" + echo " so that the extensions directory initialises properly." + mkdir -p .vscode + mkdir -p .vscode-extensions + # Launch VSCode with the sandboxed environment + launch_vscode . + exit 1 +else + echo " VSCode directory found from previous runs of vscode." +fi + +echo "Checking if VSCode has been run before..." +if [ ! -d "$VSCODE_DIR" ]; then + echo " First-time VSCode run detected. Opening VSCode to initialize..." + mkdir -p "$VSCODE_DIR" + mkdir -p "$EXT_DIR" + launch_vscode . + exit 1 +else + echo " VSCode directory detected." +fi + +SETTINGS_FILE="$VSCODE_DIR/settings.json" + +echo "Checking if settings.json exists..." +if [[ ! -f "$SETTINGS_FILE" ]]; then + echo "{}" >"$SETTINGS_FILE" + echo " Created new settings.json" +else + echo " settings.json exists" +fi + +echo "Updating git commit signing setting..." +jq '.["git.enableCommitSigning"] = true' "$SETTINGS_FILE" >"$SETTINGS_FILE.tmp" && mv "$SETTINGS_FILE.tmp" "$SETTINGS_FILE" +echo " git.enableCommitSigning enabled" + +echo "Ensuring markdown formatter is set..." +if ! jq -e '."[markdown]".editor.defaultFormatter' "$SETTINGS_FILE" >/dev/null; then + jq '."[markdown]" += {"editor.defaultFormatter": "DavidAnson.vscode-markdownlint"}' "$SETTINGS_FILE" >"$SETTINGS_FILE.tmp" && mv "$SETTINGS_FILE.tmp" "$SETTINGS_FILE" + echo " Markdown formatter set" +else + echo " Markdown formatter already configured" +fi + +echo "Ensuring shell script formatter and linter are set..." +if ! jq -e '."[shellscript]".editor.defaultFormatter' "$SETTINGS_FILE" >/dev/null; then + jq '."[shellscript]" += {"editor.defaultFormatter": "foxundermoon.shell-format", "editor.formatOnSave": true}' "$SETTINGS_FILE" >"$SETTINGS_FILE.tmp" && mv "$SETTINGS_FILE.tmp" "$SETTINGS_FILE" + echo " Shell script formatter set to foxundermoon.shell-format, formatOnSave enabled" +else + echo " Shell script formatter already configured" +fi + +if ! jq -e '.["shellcheck.enable"]' "$SETTINGS_FILE" >/dev/null; then + jq '. + {"shellcheck.enable": true}' "$SETTINGS_FILE" >"$SETTINGS_FILE.tmp" && mv "$SETTINGS_FILE.tmp" "$SETTINGS_FILE" + echo " ShellCheck linting enabled" +else + echo " ShellCheck linting already configured" +fi + +if ! jq -e '.["shellformat.flag"]' "$SETTINGS_FILE" >/dev/null; then + jq '. + {"shellformat.flag": "-i 4 -bn -ci"}' "$SETTINGS_FILE" >"$SETTINGS_FILE.tmp" && mv "$SETTINGS_FILE.tmp" "$SETTINGS_FILE" + echo " Shell format flags set (-i 4 -bn -ci)" +else + echo " Shell format flags already configured" +fi +echo "Ensuring global format-on-save is enabled..." +if ! jq -e '.["editor.formatOnSave"]' "$SETTINGS_FILE" >/dev/null; then + jq '. + {"editor.formatOnSave": true}' "$SETTINGS_FILE" >"$SETTINGS_FILE.tmp" && mv "$SETTINGS_FILE.tmp" "$SETTINGS_FILE" + echo " Global formatOnSave enabled" +else + echo " Global formatOnSave already configured" +fi + +# Python formatter and linter +echo "Ensuring Python formatter and linter are set..." +if ! jq -e '."[python]".editor.defaultFormatter' "$SETTINGS_FILE" >/dev/null; then + jq '."[python]" += {"editor.defaultFormatter": "ms-python.black-formatter"}' "$SETTINGS_FILE" >"$SETTINGS_FILE.tmp" && mv "$SETTINGS_FILE.tmp" "$SETTINGS_FILE" + echo " Python formatter set to Black" +else + echo " Python formatter already configured" +fi + +if ! jq -e '.["python.linting.enabled"]' "$SETTINGS_FILE" >/dev/null; then + jq '. + {"python.linting.enabled": true, "python.linting.pylintEnabled": true}' "$SETTINGS_FILE" >"$SETTINGS_FILE.tmp" && mv "$SETTINGS_FILE.tmp" "$SETTINGS_FILE" + echo " Python linting enabled (pylint)" +else + echo " Python linting already configured" +fi + +echo "Ensuring Python Testing Env is set..." +if ! jq -e '."[python]".editor.pytestArgs' "$SETTINGS_FILE" >/dev/null; then + jq '."[python]" += {"editor.pytestArgs": "test"}' "$SETTINGS_FILE" >"$SETTINGS_FILE.tmp" && mv "$SETTINGS_FILE.tmp" "$SETTINGS_FILE" + echo " Python test set up" +else + echo " Python tests already configured" +fi +if ! jq -e '."[python]".testing.unittestEnabled' "$SETTINGS_FILE" >/dev/null; then + jq '. + {"python.editor.unittestEnabled": false, "python.testing.pytestEnabled": true}' "$SETTINGS_FILE" >"$SETTINGS_FILE.tmp" && mv "$SETTINGS_FILE.tmp" "$SETTINGS_FILE" + echo " Python unit test set up" +else + echo " Python unit tests already configured" +fi +echo "Ensuring Python Env File is set..." +if ! jq -e '."[python]".envFile' "$SETTINGS_FILE" >/dev/null; then + jq '."[python]" += {"envFile": "${workspaceFolder}/.env"}' "$SETTINGS_FILE" >"$SETTINGS_FILE.tmp" && mv "$SETTINGS_FILE.tmp" "$SETTINGS_FILE" + echo " Python Env file set up" +else + echo " Python Env File already configured" +fi + +echo "Ensuring nixfmt is run on save for .nix files..." +if ! jq -e '."[nix]".editor.defaultFormatter' "$SETTINGS_FILE" >/dev/null; then + jq '."[nix]" += {"editor.defaultFormatter": "brettm12345.nixfmt", "editor.formatOnSave": true}' "$SETTINGS_FILE" >"$SETTINGS_FILE.tmp" && mv "$SETTINGS_FILE.tmp" "$SETTINGS_FILE" + echo " Nix formatter set to brettm12345.nixfmt, formatOnSave enabled" +else + echo " Nix formatter already configured" +fi + +if [[ " $* " == *" --verbose "* ]]; then + echo "Final settings.json contents:" + cat "$SETTINGS_FILE" +fi + +# Add VSCode runner configuration +# shellcheck disable=SC2154 +cat <.vscode/launch.json +{ + "version": "0.2.0", + + "configurations": [ + { + "name": "QGIS Plugin Debug", + "type": "debugpy", + "request": "launch", + + "program": "\${env:QGIS_EXECUTABLE}", + "args": ["--profile", "AnimationWorkbench"], + "console": "integratedTerminal", + "env": { + "PYTHONPATH": "\${workspaceFolder}/animation_workbench" + } + }, + { + "name": "Python: Remote Attach 9000", + "type": "debugpy", + "request": "attach", + "connect": { + "host": "localhost", + "port": 9000 + }, + "pathMappings": [ + { + "localRoot": "\${workspaceFolder}/animation_workbench", + "remoteRoot": "\${env:HOME}/.local/share/QGIS/QGIS3/profiles/AnimationWorkbench/python/plugins/animation_workbench" + } + ] + } + ] +} +EOF + +echo "Installing required extensions..." +installed_exts=$(list_installed_extensions) +for ext in "${REQUIRED_EXTENSIONS[@]}"; do + if echo "$installed_exts" | grep -q "^${ext}$"; then + echo " Extension ${ext} already installed." + else + echo " Installing ${ext}..." + # Capture both stdout and stderr to log file + if launch_vscode --install-extension "${ext}" >>"$LOG_FILE" 2>&1; then + # Refresh installed_exts after install + installed_exts=$(list_installed_extensions) + if echo "$installed_exts" | grep -q "^${ext}$"; then + echo " Successfully installed ${ext}." + else + echo " Failed to install ${ext} (not found after install)." + exit 1 + fi + else + echo " Failed to install ${ext} (error during install). Check $LOG_FILE for details." + exit 1 + fi + fi +done + +echo "Launching VSCode..." +launch_vscode .