├── .all-contributorsrc ├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md ├── constraints.txt ├── dependabot.yml ├── pr-labeler.yml ├── release-drafter.yml ├── scripts │ └── metadata.sh └── workflows │ ├── build.yml │ ├── codeql.yml │ ├── dependency-review.yml │ ├── pr-labeler.yml │ ├── pre-commit-updater.yml │ ├── release-drafter.yml │ └── scorecards.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── .reuse └── dep5 ├── .sourcery.yaml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSES └── Apache-2.0.txt ├── NEWS.pre-3.0 ├── README.md ├── SECURITY.md ├── docs ├── Makefile ├── api.rst ├── api │ ├── connections.rst │ ├── geometry.rst │ ├── handles.rst │ ├── matrix.rst │ ├── model.rst │ ├── painters.rst │ ├── tools.rst │ ├── variable.rst │ └── view.rst ├── conf.py ├── connectors.rst ├── constraints.rst ├── diagram.rst ├── gaphas-canvas.png ├── gaphas-demo.gif ├── gaphor-canvas.gaphor ├── guide.rst ├── images │ ├── canvas.png │ ├── connections.png │ ├── gaphas-demo.gif │ ├── guides.png │ ├── painter.png │ ├── segment.png │ └── view.png ├── index.rst ├── make.bat ├── quadtree.png ├── quadtree.rst ├── segment.rst ├── solver.rst ├── table.rst ├── tools.rst └── tree.rst ├── examples ├── __init__.py ├── demo.py ├── demo_profile.py ├── exampleitems.py └── simple-box.py ├── gaphas ├── __init__.py ├── canvas.py ├── connections.py ├── connector.py ├── constraint.py ├── cursor.py ├── geometry.py ├── guide.py ├── handle.py ├── handlemove.py ├── item.py ├── matrix.py ├── model.py ├── move.py ├── painter │ ├── __init__.py │ ├── chain.py │ ├── freehand.py │ ├── handlepainter.py │ ├── itempainter.py │ └── painter.py ├── port.py ├── position.py ├── quadtree.py ├── segment.py ├── selection.py ├── solver │ ├── __init__.py │ ├── constraint.py │ ├── solver.py │ └── variable.py ├── table.py ├── tool │ ├── __init__.py │ ├── hover.py │ ├── itemtool.py │ ├── placement.py │ ├── rubberband.py │ ├── scroll.py │ ├── viewfocus.py │ └── zoom.py ├── tree.py ├── types.py └── view │ ├── __init__.py │ ├── gtkview.py │ └── scrolling.py ├── poetry.lock ├── pyproject.toml └── tests ├── conftest.py ├── test_architecture.py ├── test_canvas.py ├── test_connections.py ├── test_connector.py ├── test_constraints.py ├── test_element.py ├── test_freehand.py ├── test_geometry.py ├── test_guide.py ├── test_handle.py ├── test_handle_move.py ├── test_item.py ├── test_line.py ├── test_matrix.py ├── test_move.py ├── test_position.py ├── test_quadtree.py ├── test_segment.py ├── test_solver.py ├── test_solver_constraint.py ├── test_solver_variable.py ├── test_tool_hover.py ├── test_tool_item.py ├── test_tool_placement.py ├── test_tool_scroll.py ├── test_tool_zoom.py ├── test_tree.py ├── test_undo.py └── test_view.py /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "gaphas", 3 | "projectOwner": "gaphor", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": true, 11 | "contributors": [ 12 | { 13 | "login": "amolenaar", 14 | "name": "Arjan Molenaar", 15 | "avatar_url": "https://avatars0.githubusercontent.com/u/96249?v=4", 16 | "profile": "https://github.com/amolenaar", 17 | "contributions": [ 18 | "code", 19 | "bug", 20 | "doc", 21 | "review", 22 | "question", 23 | "plugin" 24 | ] 25 | }, 26 | { 27 | "login": "danyeaw", 28 | "name": "Dan Yeaw", 29 | "avatar_url": "https://avatars1.githubusercontent.com/u/10014976?v=4", 30 | "profile": "https://ghuser.io/danyeaw", 31 | "contributions": [ 32 | "code", 33 | "test", 34 | "review", 35 | "bug", 36 | "question", 37 | "infra", 38 | "doc" 39 | ] 40 | }, 41 | { 42 | "login": "wrobell", 43 | "name": "wrobell", 44 | "avatar_url": "https://avatars2.githubusercontent.com/u/105664?v=4", 45 | "profile": "https://github.com/wrobell", 46 | "contributions": [ 47 | "code", 48 | "test", 49 | "review" 50 | ] 51 | }, 52 | { 53 | "login": "jlstevens", 54 | "name": "Jean-Luc Stevens", 55 | "avatar_url": "https://avatars3.githubusercontent.com/u/890576?v=4", 56 | "profile": "https://github.com/jlstevens", 57 | "contributions": [ 58 | "code", 59 | "bug", 60 | "doc" 61 | ] 62 | }, 63 | { 64 | "login": "franzlst", 65 | "name": "Franz Steinmetz", 66 | "avatar_url": "https://avatars1.githubusercontent.com/u/1144966?v=4", 67 | "profile": "http://www.franework.de", 68 | "contributions": [ 69 | "code", 70 | "bug" 71 | ] 72 | }, 73 | { 74 | "login": "adrianboguszewski", 75 | "name": "Adrian Boguszewski", 76 | "avatar_url": "https://avatars3.githubusercontent.com/u/4547501?v=4", 77 | "profile": "https://github.com/adrianboguszewski", 78 | "contributions": [ 79 | "code" 80 | ] 81 | }, 82 | { 83 | "login": "Rbelder", 84 | "name": "Rico Belder", 85 | "avatar_url": "https://avatars3.githubusercontent.com/u/15119522?v=4", 86 | "profile": "https://github.com/Rbelder", 87 | "contributions": [ 88 | "bug", 89 | "review" 90 | ] 91 | }, 92 | { 93 | "login": "adamboduch", 94 | "name": "Adam Boduch", 95 | "avatar_url": "https://avatars2.githubusercontent.com/u/114619?v=4", 96 | "profile": "http://www.boduch.ca", 97 | "contributions": [ 98 | "bug" 99 | ] 100 | }, 101 | { 102 | "login": "janettech", 103 | "name": "Janet Jose", 104 | "avatar_url": "https://avatars3.githubusercontent.com/u/13398384?v=4", 105 | "profile": "https://github.com/janettech", 106 | "contributions": [ 107 | "doc" 108 | ] 109 | } 110 | ], 111 | "contributorsPerLine": 7 112 | } 113 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .venv 2 | __pycache__ 3 | dist 4 | .mypy_cache 5 | .github 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **OS** 27 | - [ ] Linux (Please put in notes the specific distro) 28 | - [ ] MacOS 29 | - [ ] Windows 30 | 31 | NOTES: 32 | 33 | **Version** 34 | Version of Gaphas: 35 | 36 | **Additional context** 37 | Add any other context about the problem here. 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ### PR Checklist 5 | Please check if your PR fulfills the following requirements: 6 | 7 | - [ ] I have read and I am following the [Contributor guide](https://github.com/gaphor/gaphas/blob/main/CONTRIBUTING.md) 8 | - [ ] I have read and I understand the [Code of Conduct](https://github.com/gaphor/gaphas/blob/main/CODE_OF_CONDUCT.md) 9 | 10 | ### PR Type 11 | What kind of change does this PR introduce? 12 | 13 | 14 | - [ ] Bugfix 15 | - [ ] Feature 16 | - [ ] Code style update (formatting, local variables) 17 | - [ ] Refactoring (no functional changes, no api changes) 18 | - [ ] Documentation content changes 19 | 20 | ### What is the current behavior? 21 | 22 | 23 | Issue Number: N/A 24 | 25 | ### What is the new behavior? 26 | 27 | ### Does this PR introduce a breaking change? 28 | - [ ] Yes 29 | - [ ] No 30 | 31 | 32 | 33 | 34 | ### Other information 35 | -------------------------------------------------------------------------------- /.github/constraints.txt: -------------------------------------------------------------------------------- 1 | poetry==2.1.1 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | groups: 6 | github-action-updates: 7 | patterns: 8 | - "*" 9 | schedule: 10 | interval: weekly 11 | labels: ["skip-changelog"] 12 | 13 | - package-ecosystem: "pip" 14 | directory: "/" 15 | groups: 16 | pip-updates: 17 | patterns: 18 | - "*" 19 | schedule: 20 | interval: weekly 21 | labels: ["skip-changelog"] 22 | -------------------------------------------------------------------------------- /.github/pr-labeler.yml: -------------------------------------------------------------------------------- 1 | feature: ['feature/*', 'feat/*'] 2 | fix: fix/* 3 | chore: chore/* 4 | skip-changelog: ['sourcery/*', 'dependabot/*', 'pre-commit-ci-*'] 5 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: 'Version $NEXT_PATCH_VERSION - Summary Here🌈' 2 | tag-template: 'v$NEXT_PATCH_VERSION' 3 | exclude-labels: 4 | - 'skip-changelog' 5 | categories: 6 | - title: '🚀 Features' 7 | labels: 8 | - 'feature' 9 | - 'enhancement' 10 | - title: '🐛 Bug Fixes' 11 | labels: 12 | - 'fix' 13 | - 'bugfix' 14 | - 'bug' 15 | - title: '🧰 Maintenance' 16 | label: 'chore' 17 | change-template: '- $TITLE @$AUTHOR (#$NUMBER)' 18 | template: | 19 | ## Changes 20 | 21 | $CHANGES 22 | 23 | Thanks again to $CONTRIBUTORS! 🎉 24 | no-changes-template: 'Changes are coming soon 😎' 25 | replacers: 26 | - search: '(?:and )?@dependabot(?:\[bot\])?,?' 27 | replace: '' 28 | - search: '(?:and )?@sourcery-ai-bot(?:\[bot\])?,?' 29 | replace: '' 30 | - search: '(?:and )?@allcontributors(?:\[bot\])?,?' 31 | replace: '' 32 | -------------------------------------------------------------------------------- /.github/scripts/metadata.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "GITHUB_REF is $GITHUB_REF" 4 | TAG="${GITHUB_REF/refs\/tags\//}" 5 | echo "TAG is $TAG" 6 | if ! [ -x "$(command -v poetry)" ]; then 7 | echo 'Poetry not found!' >&2 8 | exit 1 9 | fi 10 | VERSION="$(poetry version --no-ansi | cut -d' ' -f2)" 11 | echo "VERSION is $VERSION" 12 | 13 | if [[ "$GITHUB_REF" =~ refs\/tags\/.* && "$TAG" == "${VERSION}" ]] 14 | then 15 | REV="" 16 | RELEASE="true" 17 | else 18 | # PEP440 version scheme, different from semver 2.0 19 | REV=".dev${GITHUB_RUN_NUMBER:-0}+${GITHUB_SHA:0:8}" 20 | RELEASE="false" 21 | 22 | poetry version "${VERSION}""${REV}" 23 | fi 24 | 25 | echo "version=${VERSION}${REV}" >> "$GITHUB_OUTPUT" 26 | echo "release=${RELEASE}" >> "$GITHUB_OUTPUT" 27 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: ["main"] 9 | schedule: 10 | - cron: "0 0 * * 1" 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | analyze: 17 | name: Analyze 18 | runs-on: ubuntu-24.04 19 | permissions: 20 | actions: read 21 | contents: read 22 | security-events: write 23 | steps: 24 | - name: Harden Runner 25 | uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 26 | with: 27 | egress-policy: block 28 | allowed-endpoints: > 29 | api.github.com:443 30 | github.com:443 31 | *.githubusercontent.com:443 32 | ghcr.io 33 | uploads.github.com:443 34 | azure.archive.ubuntu.com:80 35 | esm.ubuntu.com:443 36 | motd.ubuntu.com:443 37 | packages.microsoft.com:443 38 | ppa.launchpadcontent.net:443 39 | files.pythonhosted.org:443 40 | pypi.org:443 41 | gitlab.gnome.org:443 42 | 43 | - name: Checkout repository 44 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 45 | 46 | # Initializes the CodeQL tools for scanning. 47 | - name: Initialize CodeQL 48 | uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 49 | with: 50 | languages: python 51 | 52 | - name: Perform CodeQL Analysis 53 | uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 54 | with: 55 | category: "/language:python" 56 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | # Dependency Review Action 2 | # 3 | # This Action will scan dependency manifest files that change as part of a Pull Request, 4 | # surfacing known-vulnerable versions of the packages declared or updated in the PR. 5 | # Once installed, if the workflow run is marked as required, 6 | # PRs introducing known-vulnerable packages will be blocked from merging. 7 | # 8 | # Source repository: https://github.com/actions/dependency-review-action 9 | name: 'Dependency Review' 10 | on: [pull_request] 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | dependency-review: 17 | runs-on: ubuntu-24.04 18 | steps: 19 | - name: Harden Runner 20 | uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 21 | with: 22 | egress-policy: block 23 | allowed-endpoints: > 24 | api.github.com:443 25 | api.securityscorecards.dev:443 26 | github.com:443 27 | *.githubusercontent.com:443 28 | ghcr.io 29 | 30 | - name: 'Checkout Repository' 31 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 32 | - name: 'Dependency Review' 33 | uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1 34 | -------------------------------------------------------------------------------- /.github/workflows/pr-labeler.yml: -------------------------------------------------------------------------------- 1 | name: PR Labeler 2 | on: 3 | pull_request_target: 4 | types: [opened] 5 | 6 | permissions: 7 | contents: read 8 | 9 | jobs: 10 | pr-labeler: 11 | permissions: 12 | pull-requests: write # for TimonVS/pr-labeler-action to add labels in PR 13 | runs-on: ubuntu-24.04 14 | if: "!contains(github.event.head_commit.message, 'skip ci')" 15 | steps: 16 | - name: Harden Runner 17 | uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 18 | with: 19 | egress-policy: block 20 | allowed-endpoints: > 21 | api.github.com:443 22 | *.githubusercontent.com:443 23 | ghcr.io 24 | 25 | - uses: TimonVS/pr-labeler-action@f9c084306ce8b3f488a8f3ee1ccedc6da131d1af # v5.0.0 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit-updater.yml: -------------------------------------------------------------------------------- 1 | name: Pre-commit updater 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | # min hour dom month dow 6 | - cron: '0 5 * * 3' 7 | env: 8 | python_version: '3.13' 9 | 10 | jobs: 11 | 12 | updater: 13 | name: Update 14 | runs-on: ubuntu-24.04 15 | steps: 16 | - name: Harden Runner 17 | uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 18 | with: 19 | disable-sudo: true 20 | egress-policy: block 21 | allowed-endpoints: > 22 | files.pythonhosted.org:443 23 | pypi.org:443 24 | github.com:443 25 | api.github.com:443 26 | *.githubusercontent.com:443 27 | ghcr.io 28 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 29 | with: 30 | ref: main 31 | - name: Set up Python 32 | uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 33 | with: 34 | python-version: ${{ env.python_version }} 35 | cache: pip 36 | - name: Install pre-commit 37 | run: python -m pip install pre-commit 38 | - name: Update pre-commit hooks 39 | run: pre-commit autoupdate --freeze 40 | - name: Run pre-commit hooks 41 | run: pre-commit run --all-files 42 | - name: Create GitHub App Token 43 | uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6 44 | id: generate-token 45 | with: 46 | app-id: ${{ secrets.GAPHOR_UPDATER_APP_ID }} 47 | private-key: ${{ secrets.GAPHOR_UPDATER_APP_PRIVATE_KEY }} 48 | - name: Create Pull Request 49 | uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 50 | with: 51 | token: ${{ steps.generate-token.outputs.token }} 52 | commit-message: Update pre-commit hooks 53 | branch: pre-commit-update 54 | delete-branch: true 55 | title: 'Update pre-commit hooks' 56 | body: | 57 | This PR was automatically created to make the following update: 58 | - Update pre-commit hooks 59 | labels: | 60 | skip-changelog 61 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: main 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | update-release-draft: 12 | permissions: 13 | contents: write # for release-drafter/release-drafter to create a github release 14 | pull-requests: write # for release-drafter/release-drafter to add label to PR 15 | runs-on: ubuntu-24.04 16 | if: "!contains(github.event.head_commit.message, 'skip ci')" 17 | steps: 18 | # Drafts your next Release notes as Pull Requests are merged into "main" 19 | - name: Harden Runner 20 | uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 21 | with: 22 | egress-policy: block 23 | allowed-endpoints: > 24 | api.github.com:443 25 | *.githubusercontent.com:443 26 | ghcr.io 27 | 28 | - uses: release-drafter/release-drafter@b1476f6e6eb133afa41ed8589daba6dc69b4d3f5 # v6.1.0 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | -------------------------------------------------------------------------------- /.github/workflows/scorecards.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. They are provided 2 | # by a third-party and are governed by separate terms of service, privacy 3 | # policy, and support documentation. 4 | 5 | name: Scorecard supply-chain security 6 | on: 7 | # For Branch-Protection check. Only the default branch is supported. See 8 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection 9 | branch_protection_rule: 10 | # To guarantee Maintained check is occasionally updated. See 11 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained 12 | schedule: 13 | - cron: '20 7 * * 2' 14 | push: 15 | branches: ["main"] 16 | 17 | # Declare default permissions as read only. 18 | permissions: read-all 19 | 20 | jobs: 21 | analysis: 22 | name: Scorecard analysis 23 | runs-on: ubuntu-24.04 24 | permissions: 25 | # Needed to upload the results to code-scanning dashboard. 26 | security-events: write 27 | # Needed to publish results and get a badge (see publish_results below). 28 | id-token: write 29 | contents: read 30 | actions: read 31 | 32 | steps: 33 | - name: Harden Runner 34 | uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 35 | with: 36 | egress-policy: block 37 | allowed-endpoints: > 38 | api.deps.dev:443 39 | api.github.com:443 40 | github.com:443 41 | *.githubusercontent.com:443 42 | ghcr.io 43 | api.osv.dev:443 44 | api.scorecard.dev:443 45 | api.securityscorecards.dev:443 46 | auth.docker.io:443 47 | cdn.fwupd.org:443 48 | fulcio.sigstore.dev:443 49 | index.docker.io:443 50 | oss-fuzz-build-logs.storage.googleapis.com:443 51 | rekor.sigstore.dev:443 52 | tuf-repo-cdn.sigstore.dev:443 53 | www.bestpractices.dev:443 54 | 55 | - name: "Checkout code" 56 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 57 | with: 58 | persist-credentials: false 59 | 60 | - name: "Run analysis" 61 | uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2 62 | with: 63 | results_file: results.sarif 64 | results_format: sarif 65 | # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: 66 | # - you want to enable the Branch-Protection check on a *public* repository, or 67 | # - you are installing Scorecards on a *private* repository 68 | # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat. 69 | # repo_token: ${{ secrets.SCORECARD_TOKEN }} 70 | 71 | # Public repositories: 72 | # - Publish results to OpenSSF REST API for easy access by consumers 73 | # - Allows the repository to include the Scorecard badge. 74 | # - See https://github.com/ossf/scorecard-action#publishing-results. 75 | # For private repositories: 76 | # - `publish_results` will always be set to `false`, regardless 77 | # of the value entered here. 78 | publish_results: true 79 | 80 | # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF 81 | # format to the repository Actions tab. 82 | - name: "Upload artifact" 83 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 84 | with: 85 | name: SARIF file 86 | path: results.sarif 87 | retention-days: 5 88 | 89 | # Upload the results to GitHub's code scanning dashboard. 90 | - name: "Upload to code-scanning" 91 | uses: github/codeql-action/upload-sarif@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 92 | with: 93 | sarif_file: results.sarif 94 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # python 2 | *.pyc 3 | *.egg 4 | .eggs/ 5 | *.egg-info/ 6 | build/ 7 | dist/ 8 | pip-wheel-metadata 9 | 10 | # virtual env 11 | .venv*/ 12 | .env 13 | 14 | # docs 15 | docs/_build 16 | 17 | # pyenv 18 | .python-version 19 | 20 | # tox 21 | .tox/ 22 | 23 | # coverage 24 | .coverage 25 | htmlcov/ 26 | 27 | # gaphas 28 | *.svg 29 | *.pickled 30 | 31 | # VS Code 32 | .vscode/ 33 | 34 | # mypy 35 | .mypy_cache/ 36 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/codespell-project/codespell 3 | rev: 63c8f8312b7559622c0d82815639671ae42132ac # frozen: v2.4.1 4 | hooks: 5 | - id: codespell 6 | additional_dependencies: 7 | - tomli 8 | - repo: https://github.com/pre-commit/mirrors-mypy 9 | rev: 7010b10a09f65cd60a23c207349b539aa36dbec1 # frozen: v1.16.0 10 | hooks: 11 | - id: mypy 12 | - repo: https://github.com/pre-commit/pre-commit-hooks 13 | rev: cef0300fd0fc4d2a87a85fa2093c6b283ea36f4b # frozen: v5.0.0 14 | hooks: 15 | - id: check-toml 16 | - id: check-yaml 17 | - id: end-of-file-fixer 18 | exclude: '\.gaphor' 19 | - repo: https://github.com/astral-sh/ruff-pre-commit 20 | rev: 'd19233b89771be2d89273f163f5edc5a39bbc34a' # frozen: v0.11.12 21 | hooks: 22 | - id: ruff 23 | args: [--fix] 24 | - id: ruff-format 25 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | formats: all 3 | build: 4 | os: ubuntu-24.04 5 | tools: 6 | python: "3.13" 7 | apt_packages: 8 | - libgirepository-2.0-dev 9 | jobs: 10 | pre_install: 11 | - python -m pip install --constraint=.github/constraints.txt poetry 12 | - poetry config virtualenvs.create false 13 | post_install: 14 | - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install --with docs 15 | sphinx: 16 | configuration: docs/conf.py 17 | fail_on_warning: false 18 | -------------------------------------------------------------------------------- /.reuse/dep5: -------------------------------------------------------------------------------- 1 | Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | Upstream-Name: Gaphas 3 | Upstream-Contact: Arjan Molenaar 4 | Source: https://github.com/gaphor/gaphas 5 | 6 | Files: * 7 | Copyright: 2006 Arjan Molenaar and Dan Yeaw 8 | License: Apache-2.0 9 | 10 | Files: gaphas/geometry.py 11 | Copyright: Mukesh Prasad, Arjan Molenaar, and Dan Yeaw 12 | License: Apache-2.0 13 | -------------------------------------------------------------------------------- /.sourcery.yaml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - tests/test_solver_variable.py 3 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | dan@yeaw.me. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /NEWS.pre-3.0: -------------------------------------------------------------------------------- 1 | 2.1.2 2 | ----- 3 | - Fix API compatibility: restore draw_all attribute with draw method 4 | 5 | 2.1.1 6 | ----- 7 | - Allow for custom update context for Canvas 8 | - Render (GTK) background for the view 9 | - Allow FreeHandPainter to be a child of BoundingBoxPainter 10 | 11 | 2.1.0 12 | ----- 13 | - Fix issue where port could not be updated when reconnecting two items 14 | - Documentation fixes 15 | - BoundingBoxPainter now takes an Item painter as its argument 16 | (composition over inheritance) 17 | 18 | 2.0.1 19 | ----- 20 | - Fix issue where undo events for handle movements were not recorded 21 | 22 | 2.0.0 23 | ----- 24 | - Drop support for Python 2.7 25 | 26 | 1.1.2 27 | ----- 28 | - Fix issue with placing popovers in the new 29 | 30 | 1.1.1 31 | ----- 32 | - Fix issue where views are not rendered 33 | 34 | 1.1.0 35 | ----- 36 | - Remove dependency on simplegeneric and decorator 37 | - Speed up rendering by using a back-buffer 38 | - Deprecate Handle.x and Handle.y, use Handle.pos instead 39 | 40 | 1.0.0 41 | ----- 42 | - Change license from LGPL 2.0 to Apache 2.0 43 | - Port to Python 3 with support for 3.5, 3.6, and 3.7 44 | - Python < 2.7 is no longer supported 45 | - Port from GTK+ 2 and PyGTK to GTK+ 3 with PyGObject 46 | - Migrate tests from Nose to PyTest with Tox 47 | - Reorganize project files 48 | - Overhaul the README 49 | - Add contributing guide and code of conduct 50 | - Adopt Black as code formatter 51 | - Add Continuous Integration with Travis and Read The Docs 52 | 53 | 0.7.2 54 | ----- 55 | - Fix bug in calculating bounding box for rotated text 56 | - Few minor updates 57 | 58 | 0.7.1 59 | ----- 60 | - Views no longer lose reference to canvas on unrealize() 61 | - bug fix in log message 62 | 63 | 0.7.0 64 | ----- 65 | - Painters are bound to a specific view, like tools 66 | - Introduced aspects for finding items and handles 67 | - New feature: Guides, for aligning elements 68 | - Free hand drawing style 69 | 70 | 0.6.0 71 | ----- 72 | - Handlers are no longer called recursively for observed methods/properties. 73 | - removed enable_dispatching() and disable_dispatching() calls. 74 | - Made aspect code simpler. 75 | - Moved disconnect code from tool to aspect, as stated in Aspect's docstring. 76 | - Fixed issues in connections. 77 | - Lot's of fixes and testing has been done on the undo mechanism. 78 | 79 | 0.5.0 80 | ----- 81 | - Split tools in tools and aspects, separating the _what_ from the _how_. 82 | For this, a dependency to the simplegeneric module is introduced. 83 | - Renamed VariablePoint to Position. 84 | - Handle is no longer inheriting from VariablePoint/Position. 85 | - Handle connections are no longer registered on the handle, but are 86 | maintained in the Canvas instance. This makes for much easier querying 87 | (e.g. which elements are attached to some element). 88 | Added a Table class to support this functionality. 89 | - Added a timeout property on the @async decorator. The method is invoked 90 | the amount of milliseconds after the first invocation. 91 | 92 | 0.4.1 93 | ------ 94 | - Call Item._set_canvas after matrix update 95 | - Verify if value changed before marking variable as dirty. 96 | 97 | 0.4.0 98 | ------ 99 | - allow to define connectable parts of item's (ports feature) 100 | - implemented default connection tool (thanks to ports feature) 101 | - line segment tool implemented (code taken from gaphor) 102 | - implemented Item.constraint method to simplify item's constraint 103 | creation 104 | - The canvas (-view) is no longer tied to the (0, 0) position. Scrolling can 105 | be done quite fluidly with the new PanTool implementation. 106 | - API changes 107 | - use positions instead of "x, y" pairs in all method calls 108 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | We are currently supporting the latest released version of Gaphas. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | Gaphas has GitHub's Private Security Vulnerability Reporting enabled. Please 10 | go to the Security tab to report security vulnerabilities. For more 11 | information, please see the [GitHub docs on privately reporting]( 12 | https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability). 13 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = . 8 | BUILDDIR = _build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 20 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API reference 2 | ############# 3 | 4 | 5 | .. toctree:: 6 | :caption: View 7 | :maxdepth: 1 8 | 9 | api/view 10 | api/model 11 | api/painters 12 | api/tools 13 | 14 | The central part for Gaphas is the View. That's the class that ensures stuff is displayed and can be interacted with. 15 | 16 | .. toctree:: 17 | :caption: Connections 18 | :maxdepth: 1 19 | 20 | api/handles 21 | api/connections 22 | api/variable 23 | 24 | One of Gaphas' USP is it's the way it handles connections and the constraint solver. 25 | 26 | .. toctree:: 27 | :caption: Primitives 28 | :maxdepth: 1 29 | 30 | api/matrix 31 | api/geometry 32 | 33 | Finally there are classes and modules that make up the building blocks on which Gaphas is built: 34 | -------------------------------------------------------------------------------- /docs/api/connections.rst: -------------------------------------------------------------------------------- 1 | Connections 2 | =========== 3 | 4 | The ``Connections`` class can be used to manage any type of constraint within, and between items. 5 | 6 | .. autoclass:: gaphas.connections.Connections 7 | :members: 8 | -------------------------------------------------------------------------------- /docs/api/geometry.rst: -------------------------------------------------------------------------------- 1 | Rectangle 2 | ========= 3 | 4 | .. autoclass:: gaphas.geometry.Rectangle 5 | :members: 6 | 7 | 8 | Geometry functions 9 | ================== 10 | .. autofunction:: gaphas.geometry.distance_point_point 11 | .. autofunction:: gaphas.geometry.distance_point_point_fast 12 | .. autofunction:: gaphas.geometry.distance_rectangle_point 13 | .. autofunction:: gaphas.geometry.point_on_rectangle 14 | .. autofunction:: gaphas.geometry.distance_line_point 15 | .. autofunction:: gaphas.geometry.intersect_line_line 16 | .. autofunction:: gaphas.geometry.rectangle_contains 17 | .. autofunction:: gaphas.geometry.rectangle_intersects 18 | .. autofunction:: gaphas.geometry.rectangle_clip 19 | -------------------------------------------------------------------------------- /docs/api/handles.rst: -------------------------------------------------------------------------------- 1 | Handles and Ports 2 | ================= 3 | 4 | To connect one item to another, you need something to connect, and something to connect to. 5 | These roles are fulfilled by ``Handle`` and ``Port``. 6 | 7 | The Handle is an item you normally see on screen as a small square, either green or red. 8 | Although the actual shape depends on the Painter_ used. 9 | 10 | Ports represent the receiving side. A port decides if it wants a connection with a handle. 11 | If it does, a constraint can be created and this constraint will be managed by a Connections_ instance. 12 | It is not uncommon to create special ports to suite your application's behavior, whereas Handles are rarely subtyped. 13 | 14 | Handle 15 | ------ 16 | 17 | .. autoclass:: gaphas.connector.Handle 18 | :members: 19 | 20 | Port 21 | ---- 22 | 23 | The ``Port`` class. There are two default implementations: ``LinePort`` and ``PointPort``. 24 | 25 | .. autoclass:: gaphas.connector.Port 26 | :members: 27 | 28 | .. autoclass:: gaphas.connector.LinePort 29 | :members: 30 | 31 | .. autoclass:: gaphas.connector.PointPort 32 | :members: 33 | 34 | .. _Painter: painters.html#gaphas.painter.HandlePainter 35 | .. _Connections: connections.html 36 | -------------------------------------------------------------------------------- /docs/api/matrix.rst: -------------------------------------------------------------------------------- 1 | Matrix 2 | ====== 3 | 4 | The ``Matrix`` class used to records item placement (translation), scale and skew. 5 | 6 | .. autoclass:: gaphas.matrix.Matrix 7 | :members: 8 | -------------------------------------------------------------------------------- /docs/api/model.rst: -------------------------------------------------------------------------------- 1 | 2 | Model 3 | ===== 4 | 5 | Protocols 6 | --------- 7 | 8 | Although ``gaphas.Canvas`` can be used as a default model, any class that adhere's to the Model protocol can be used as a model. 9 | 10 | .. autoclass:: gaphas.model.Model 11 | :members: 12 | 13 | An item should implement these methods, so it can be rendered by the View. Not that painters or tools can require additional methods. 14 | 15 | .. autoclass:: gaphas.item.Item 16 | :members: 17 | 18 | Default implementations 19 | ----------------------- 20 | 21 | Canvas 22 | ~~~~~~ 23 | 24 | The default implementation for a ``Model``, is a class called ``Canvas``. 25 | 26 | .. autoclass:: gaphas.canvas.Canvas 27 | :members: 28 | 29 | Items 30 | ~~~~~ 31 | 32 | Gaphas provides two default items, an box-like element and a line shape. 33 | 34 | .. autoclass:: gaphas.item.Element 35 | :members: 36 | 37 | .. autoclass:: gaphas.item.Line 38 | :members: 39 | -------------------------------------------------------------------------------- /docs/api/painters.rst: -------------------------------------------------------------------------------- 1 | Painters 2 | ======== 3 | 4 | Painters are used to draw the view. 5 | 6 | Protocols 7 | --------- 8 | 9 | Each painter adheres to the ``Painter`` protocol. 10 | 11 | .. autoclass:: gaphas.painter.Painter 12 | :members: 13 | 14 | Some painters, such as ``FreeHandPainter`` and ``BoundingBoxPainter``, require a special painter protocol: 15 | 16 | .. autoclass:: gaphas.painter.painter.ItemPainterType 17 | :members: 18 | 19 | 20 | Default implementations 21 | ----------------------- 22 | 23 | .. autoclass:: gaphas.painter.PainterChain 24 | :members: append, prepend 25 | 26 | 27 | .. autoclass:: gaphas.painter.ItemPainter 28 | 29 | .. autoclass:: gaphas.painter.HandlePainter 30 | 31 | .. autoclass:: gaphas.painter.BoundingBoxPainter 32 | 33 | .. autoclass:: gaphas.painter.FreeHandPainter 34 | 35 | 36 | Rubberband tool 37 | --------------- 38 | 39 | A special painter is used to display rubberband selection. This painter shares some state with 40 | the rubberband tool. 41 | 42 | .. autoclass:: gaphas.tool.rubberband.RubberbandPainter 43 | -------------------------------------------------------------------------------- /docs/api/tools.rst: -------------------------------------------------------------------------------- 1 | Tools 2 | ===== 3 | 4 | Tools are used to interact with the view. 5 | 6 | Each tool is basically a function that produces a `Gtk.EventController`_. 7 | The event controllers are already configured. 8 | 9 | .. autofunction:: gaphas.tool.hover_tool 10 | .. autofunction:: gaphas.tool.item_tool 11 | .. autofunction:: gaphas.tool.placement_tool 12 | .. autofunction:: gaphas.tool.rubberband_tool 13 | .. autofunction:: gaphas.tool.zoom_tool 14 | 15 | 16 | .. _Gtk.EventController: https://docs.gtk.org/gtk4/class.EventController.html 17 | -------------------------------------------------------------------------------- /docs/api/variable.rst: -------------------------------------------------------------------------------- 1 | Variables and Position 2 | ====================== 3 | 4 | The most basic class for a solvable value is ``Variable``. It acts a lot like a ``float``, which makes 5 | it easy to work with. 6 | 7 | Next to that there's Position, which is a coordinate ``(x, y)`` defined by two variables. 8 | 9 | To support connections between variables, a ``MatrixProjection`` class is available. It translates 10 | a position to a common coordinate space, based on ``Item.matrix_i2c``. Normally, it's only ``Ports`` that 11 | deal with item-to-common translation of positions. 12 | 13 | .. autoclass:: gaphas.solver.Variable 14 | :members: 15 | 16 | Variables can have different strengths. The higher the number, the stronger the variable. 17 | Variables can be ``VERY_WEAK`` (0), up to ``REQUIRED`` (100). 18 | Other constants are 19 | ``WEAK`` (10) 20 | ``NORMAL`` (20) 21 | ``STRONG`` (30), and 22 | ``VERY_STRONG`` (40). 23 | 24 | 25 | .. autofunction:: gaphas.solver.variable 26 | 27 | 28 | .. autoclass:: gaphas.position.Position 29 | 30 | 31 | .. autoclass:: gaphas.position.MatrixProjection 32 | -------------------------------------------------------------------------------- /docs/api/view.rst: -------------------------------------------------------------------------------- 1 | View 2 | ==== 3 | 4 | View is the central class in Gaphas. It shows your diagram and allows you to interact with it. 5 | 6 | .. autoclass:: gaphas.view.GtkView 7 | :members: 8 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # 2 | # Configuration file for the Sphinx documentation builder. 3 | # 4 | # This file does only contain a selection of the most common options. For a 5 | # full list see the documentation: 6 | # http://www.sphinx-doc.org/en/master/config 7 | 8 | # -- Path setup -------------------------------------------------------------- 9 | 10 | # If extensions (or modules to document with autodoc) are in another directory, 11 | # add these directories to sys.path here. If the directory is relative to the 12 | # documentation root, use os.path.abspath to make it absolute, like shown here. 13 | from __future__ import annotations 14 | 15 | # -- Project information ----------------------------------------------------- 16 | 17 | project = "Gaphas" 18 | copyright = "2006, Arjan J. Molenaar" 19 | author = "Arjan J. Molenaar" 20 | 21 | # -- General configuration --------------------------------------------------- 22 | 23 | # If your documentation needs a minimal Sphinx version, state it here. 24 | # 25 | # needs_sphinx = '1.0' 26 | 27 | # Add any Sphinx extension module names here, as strings. They can be 28 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 29 | # ones. 30 | extensions = [ 31 | "sphinx.ext.autodoc", 32 | "sphinx.ext.doctest", 33 | "sphinx.ext.coverage", 34 | "sphinx.ext.viewcode", 35 | "sphinx.ext.napoleon", 36 | ] 37 | 38 | # Add any paths that contain templates here, relative to this directory. 39 | templates_path = ["_templates"] 40 | 41 | # The suffix(es) of source filenames. 42 | # You can specify multiple suffix as a list of string: 43 | # 44 | # source_suffix = ['.rst', '.md'] 45 | source_suffix = ".rst" 46 | 47 | # The master toctree document. 48 | master_doc = "index" 49 | 50 | # The language for content autogenerated by Sphinx. Refer to documentation 51 | # for a list of supported languages. 52 | # 53 | # This is also used if you do content translation via gettext catalogs. 54 | # Usually you set "language" from the command line for these cases. 55 | language = "en" 56 | 57 | # List of patterns, relative to source directory, that match files and 58 | # directories to ignore when looking for source files. 59 | # This pattern also affects html_static_path and html_extra_path. 60 | exclude_patterns = [ 61 | "_build", 62 | "Thumbs.db", 63 | ".DS_Store", 64 | "constraints.rst", 65 | "comparison.rst", 66 | "undo_tests.rst", 67 | "requirements.txt", 68 | ] 69 | 70 | # The name of the Pygments (syntax highlighting) style to use. 71 | pygments_style = None 72 | 73 | autodoc_member_order = "bysource" 74 | autodoc_mock_imports = ["cairo", "gi.repository.Gtk", "gi.repository.Gdk"] 75 | 76 | # -- Options for HTML output ------------------------------------------------- 77 | 78 | # The theme to use for HTML and HTML Help pages. See the documentation for 79 | # a list of builtin themes. 80 | # 81 | html_theme = "furo" 82 | html_title = "Gaphas" 83 | 84 | # Theme options are theme-specific and customize the look and feel of a theme 85 | # further. For a list of options available for each theme, see the 86 | # documentation. 87 | # 88 | # html_theme_options = {} 89 | 90 | # Add any paths that contain custom static files (such as style sheets) here, 91 | # relative to this directory. They are copied after the builtin static files, 92 | # so a file named "default.css" will overwrite the builtin "default.css". 93 | # html_static_path = ["_static"] 94 | 95 | # Custom sidebar templates, must be a dictionary that maps document names 96 | # to template names. 97 | # 98 | # The default sidebars (for documents that don't match any pattern) are 99 | # defined by theme itself. Builtin themes are using these templates by 100 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 101 | # 'searchbox.html']``. 102 | # 103 | # html_sidebars = {} 104 | 105 | 106 | # -- Options for HTMLHelp output --------------------------------------------- 107 | 108 | # Output file base name for HTML help builder. 109 | htmlhelp_basename = "Gaphasdoc" 110 | 111 | 112 | # -- Options for LaTeX output ------------------------------------------------ 113 | 114 | latex_elements: dict[str, str] = { 115 | # The paper size ('letterpaper' or 'a4paper'). 116 | # 117 | # 'papersize': 'letterpaper', 118 | # The font size ('10pt', '11pt' or '12pt'). 119 | # 120 | # 'pointsize': '10pt', 121 | # Additional stuff for the LaTeX preamble. 122 | # 123 | # 'preamble': '', 124 | # Latex figure (float) alignment 125 | # 126 | # 'figure_align': 'htbp', 127 | } 128 | 129 | # Grouping the document tree into LaTeX files. List of tuples 130 | # (source start file, target name, title, 131 | # author, documentclass [howto, manual, or own class]). 132 | latex_documents = [(master_doc, "Gaphas.tex", "Gaphas Documentation", author, "manual")] 133 | 134 | 135 | # -- Options for manual page output ------------------------------------------ 136 | 137 | # One entry per manual page. List of tuples 138 | # (source start file, name, description, authors, manual section). 139 | man_pages = [(master_doc, "gaphas", "Gaphas Documentation", [author], 1)] 140 | 141 | 142 | # -- Options for Texinfo output ---------------------------------------------- 143 | 144 | # Grouping the document tree into Texinfo files. List of tuples 145 | # (source start file, target name, title, author, 146 | # dir menu entry, description, category) 147 | texinfo_documents = [ 148 | ( 149 | master_doc, 150 | "Gaphas", 151 | "Gaphas Documentation", 152 | author, 153 | "Gaphas", 154 | "One line description of project.", 155 | "Miscellaneous", 156 | ) 157 | ] 158 | 159 | 160 | # -- Options for Epub output ------------------------------------------------- 161 | 162 | # Bibliographic Dublin Core info. 163 | epub_title = project 164 | 165 | # The unique identifier of the text. This can be a ISBN number 166 | # or the project homepage. 167 | # 168 | # epub_identifier = '' 169 | 170 | # A unique identification for the text. 171 | # 172 | # epub_uid = '' 173 | 174 | # A list of files that should not be packed into the epub file. 175 | epub_exclude_files = ["search.html"] 176 | 177 | 178 | # -- Extension configuration ------------------------------------------------- 179 | -------------------------------------------------------------------------------- /docs/connectors.rst: -------------------------------------------------------------------------------- 1 | Connections 2 | =========== 3 | A Port defines a connectable part of an item. Handles can connect to ports to make connections between items. 4 | 5 | Constraints 6 | ----------- 7 | Diagram items can have internal constraints, which can be used to position 8 | item's ports within an item itself. 9 | 10 | For example, `Element` item could create constraints to position ports over 11 | its edges of rectanglular area. The result is duplication of constraints as 12 | `Element` already constraints position of handles to keep them in 13 | a rectangle. 14 | 15 | For example, a horizontal line could be implemented like:: 16 | 17 | class HorizontalLine(gaphas.item.Item): 18 | def __init__(self, connections: gaphas.connections.Connections): 19 | super(HorizontalLine, self).__init__() 20 | 21 | self.start = Handle() 22 | self.end = Handle() 23 | 24 | self.port = LinePort(self.start.pos, self.end.pos) 25 | 26 | connections.add_constraint(self, 27 | constraint(horizontal=(self.start.pos, self.end.pos))) 28 | 29 | Connections 30 | ----------- 31 | Connection between two items is established by creating a constraint 32 | between handle's position and port's positions (positions are constraint 33 | solver variables). 34 | 35 | To create a constraint between two items, the constraint needs a common 36 | coordinate space (each item has it's own origin coordinate). 37 | This can be done with the `gaphas.position.MatrixProjection` class, which 38 | translates coordinates to a common ("canvas") coordinate space where they can 39 | be used to connect two different items. 40 | 41 | Examples of ports can be found in Gaphas and Gaphor source code 42 | 43 | - `gaphas.item.Line` and `gaphas.item.Element` classes 44 | - Gaphor interface and lifeline items have own specific ports 45 | -------------------------------------------------------------------------------- /docs/constraints.rst: -------------------------------------------------------------------------------- 1 | Constraints 2 | =========== 3 | Introduction 4 | ------------ 5 | There are problems related to canvas items, which can be solved in 6 | `declarative way `_ 7 | allowing for simpler and less error prone implementation of canvas item. 8 | 9 | For example, if an item should be a rectangle, then it could be declared 10 | that 11 | 12 | - bottom-right vertex should be below and on the right side of top-left 13 | vertex 14 | - two top rectangle vertices should be always at the same y-axis 15 | - two left rectangle vertices should be always at the same x-axis 16 | - ... 17 | 18 | Above rules are constraints, which need to be applied to a rectangular 19 | item. The rules can be satisfied (constraints can be solved) using 20 | `constraint solver `_. 21 | 22 | Gaphas implements its own constraint solver (`gaphas.solver.Solver`). 23 | Items can be constrained using APIs defined in `Connections` class. 24 | 25 | Constraints API 26 | --------------- 27 | The `Connections` class' constraints API supports adding a constraint to 28 | constraint solver. Instance of a constraint has to be created and then 29 | added using `Canvas.add_constraint` method. For example, it allows to 30 | declare that two variables should be equal. 31 | 32 | The `Item` class constraint API is more abstract, it allows to constraint 33 | positions, i.e. 34 | 35 | - positions of two item handles should be on the same x-axis 36 | - position should be always on a line 37 | 38 | If this API does not provide some constraint declaration, then one can 39 | fallback to `Canvas` class constraint API. 40 | 41 | Further Reading 42 | --------------- 43 | Theory and examples related to constraint solving 44 | 45 | - http://en.wikipedia.org/wiki/Declarative_programming 46 | - http://en.wikipedia.org/wiki/Constraint_satisfaction_problem 47 | - http://norvig.com/sudoku.html 48 | 49 | There are other projects providing constraint solvers 50 | 51 | - http://adaptagrams.sourceforge.net/ 52 | - http://minion.sourceforge.net/ 53 | - http://labix.org/python-constraint 54 | - http://www.cs.washington.edu/research/constraints/cassowary/ 55 | -------------------------------------------------------------------------------- /docs/diagram.rst: -------------------------------------------------------------------------------- 1 | Class diagram 2 | ============= 3 | 4 | This class diagram describes the basic layout of Gaphas. 5 | 6 | The central class is ``GtkView``. It takes a model. 7 | A default implementation is provided by `gaphas.Canvas`. 8 | A view is rendered by Painters. Interaction is handled 9 | by Tools. 10 | 11 | .. image:: images/view.png 12 | :align: center 13 | 14 | Painting is done by painters. Each painter will paint a layer of the canvas. 15 | 16 | .. image:: images/painter.png 17 | :align: center 18 | 19 | Besides the view, there is constraint based connection management. 20 | Constraints can be used within an item, and to connect different items. 21 | 22 | .. image:: images/connections.png 23 | :align: center 24 | 25 | A default model and item implementations, a line and an element. 26 | 27 | .. image:: images/canvas.png 28 | :align: center 29 | -------------------------------------------------------------------------------- /docs/gaphas-canvas.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaphor/gaphas/d355ced2b315faec4189bce21fde5f8f75fe5561/docs/gaphas-canvas.png -------------------------------------------------------------------------------- /docs/gaphas-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaphor/gaphas/d355ced2b315faec4189bce21fde5f8f75fe5561/docs/gaphas-demo.gif -------------------------------------------------------------------------------- /docs/guide.rst: -------------------------------------------------------------------------------- 1 | Guides 2 | ###### 3 | 4 | Guides are a tool to align elements with one another. 5 | 6 | .. image:: images/guides.png 7 | :align: center 8 | 9 | Guides consist of a couple of elements: aspects that hook into the item-drag cycle, and a dedicated painter. 10 | 11 | >>> from gaphas.view import GtkView 12 | >>> from gaphas.painter import PainterChain, ItemPainter, HandlePainter 13 | >>> from gaphas.tool import item_tool, zoom_tool 14 | >>> from gaphas.guide import GuidePainter 15 | >>> view = GtkView() 16 | >>> view.painter = ( 17 | ... PainterChain() 18 | ... .append(ItemPainter(view.selection)) 19 | ... .append(HandlePainter(view)) 20 | ... .append(GuidePainter(view)) 21 | ... ) 22 | >>> view.add_controller(item_tool()) 23 | >>> view.add_controller(zoom_tool()) 24 | 25 | You need to hook up the ``GuidePainter``. The aspect are loaded as soon as the module is loaded. 26 | -------------------------------------------------------------------------------- /docs/images/canvas.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaphor/gaphas/d355ced2b315faec4189bce21fde5f8f75fe5561/docs/images/canvas.png -------------------------------------------------------------------------------- /docs/images/connections.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaphor/gaphas/d355ced2b315faec4189bce21fde5f8f75fe5561/docs/images/connections.png -------------------------------------------------------------------------------- /docs/images/gaphas-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaphor/gaphas/d355ced2b315faec4189bce21fde5f8f75fe5561/docs/images/gaphas-demo.gif -------------------------------------------------------------------------------- /docs/images/guides.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaphor/gaphas/d355ced2b315faec4189bce21fde5f8f75fe5561/docs/images/guides.png -------------------------------------------------------------------------------- /docs/images/painter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaphor/gaphas/d355ced2b315faec4189bce21fde5f8f75fe5561/docs/images/painter.png -------------------------------------------------------------------------------- /docs/images/segment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaphor/gaphas/d355ced2b315faec4189bce21fde5f8f75fe5561/docs/images/segment.png -------------------------------------------------------------------------------- /docs/images/view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaphor/gaphas/d355ced2b315faec4189bce21fde5f8f75fe5561/docs/images/view.png -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Gaphas 3 Documentation 2 | ====================== 3 | 4 | Gaphas is the diagramming widget library for Python. 5 | 6 | Gaphas has been built with extensibility in mind. It can be used for many 7 | drawing purposes, including vector drawing applications, and diagram drawing tools. 8 | 9 | The basic idea is: 10 | 11 | - Gaphas has a Model-View-Controller_ design. 12 | - A model is presented as a protocol in Gaphas. This means that it's very easy to define a class that acts as a model. 13 | - A model can be visualized by one or more Views. 14 | - A constraint solver is used to maintain item constraints and inter-item 15 | constraints. 16 | - The item (and user) should not be bothered with things like bounding-box 17 | calculations. 18 | - Very modular: The view contains the basic features. Painters and tools can be swapped out as needed. 19 | - Rendering using Cairo_. This implies the diagrams can be exported in a number 20 | of formats, including PNG and SVG. 21 | 22 | Gaphas is released under the terms of the Apache Software License, version 2.0. 23 | 24 | * Git repository: https://github.com/gaphor/gaphas 25 | * Python Package index (PyPI): https://pypi.org/project/gaphas 26 | 27 | .. toctree:: 28 | :caption: The basics 29 | :maxdepth: 1 30 | :hidden: 31 | 32 | diagram 33 | tools 34 | connectors 35 | solver 36 | 37 | .. toctree:: 38 | :caption: Advanced 39 | :maxdepth: 1 40 | :hidden: 41 | 42 | guide 43 | segment 44 | 45 | .. toctree:: 46 | :caption: API 47 | :maxdepth: 2 48 | :hidden: 49 | 50 | api 51 | 52 | .. toctree:: 53 | :caption: Internals 54 | :maxdepth: 1 55 | :hidden: 56 | 57 | quadtree 58 | table 59 | tree 60 | 61 | .. _Cairo: https://cairographics.org 62 | .. _Model-View-Controller: https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93controller 63 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/quadtree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaphor/gaphas/d355ced2b315faec4189bce21fde5f8f75fe5561/docs/quadtree.png -------------------------------------------------------------------------------- /docs/quadtree.rst: -------------------------------------------------------------------------------- 1 | Quadtree 2 | ######## 3 | 4 | In order to find items and handles fast on a 2D surface, a geometric structure is required. 5 | 6 | There are two popular variants: Quadtrees_ and R-trees_. R-trees are tough and 7 | well suited for non-moving data. Quadtrees are easier to understand and easier 8 | to maintain. 9 | 10 | Idea: 11 | 12 | * Divide the view in 4 quadrants and place each item in a quadrant. 13 | * When a quadrant has more than ''x'' elements, divide it again. 14 | * When an item overlaps more than one quadrant, it's added to the owner. 15 | 16 | Gaphas uses item bounding boxed to determine where items should be put. 17 | 18 | It is also possible to relocate or remove items to the tree. 19 | 20 | The Quadtree itself is added as part of Gaphas' View. The view is aware of 21 | item's bounding boxes as it is responsible for user interaction. The Quadtree 22 | size is defined by its contents. 23 | 24 | Interface 25 | --------- 26 | 27 | The Quadtree interface is simple and tailored towards the use cases of 28 | gaphas. 29 | 30 | Important properties: 31 | 32 | * bounds: boundaries of the canvas 33 | 34 | Methods for working with items in the quadtree: 35 | 36 | * `add(item, bounds)`: add an item to the quadtree 37 | * `remove(item)`: remove item from the tree 38 | * `update(item, new_bounds)`: replace an item in the quadtree, using it's new boundaries. 39 | * Multiple ways of finding items have been implemented: 40 | 1. Find item closest to point 41 | 2. Find all items within distance `d` of a point 42 | 3. Find all items inside a rectangle 43 | 4. Find all items inside or intersecting with a rectangle 44 | 45 | Implementation 46 | -------------- 47 | 48 | The implementation of gaphas' Quadtree can be found at https://github.com/gaphor/gaphas/blob/main/gaphas/quadtree.py. 49 | 50 | Here's an example of the Quadtree in action (Gaphas' demo app with `gaphas.view.DEBUG_DRAW_QUADTREE` enabled): 51 | 52 | .. image:: quadtree.png 53 | 54 | The screen is divided into four equal quadrants. The first quadrant has many items, therefore it has been divided again. 55 | 56 | References 57 | ~~~~~~~~~~ 58 | 59 | (!PyGame) 60 | http://www.pygame.org/wiki/QuadTree?parent=CookBook 61 | 62 | (PythonCAD) 63 | https://sourceforge.net/p/pythoncad/code/ci/master/tree/PythonCAD/Generic/quadtree.py 64 | 65 | .. _Quadtrees: http://en.wikipedia.org/wiki/Quadtree 66 | .. _R-trees: http://en.wikipedia.org/wiki/R-tree 67 | -------------------------------------------------------------------------------- /docs/segment.rst: -------------------------------------------------------------------------------- 1 | Line Segments 2 | ############# 3 | 4 | The line segment functionality is an add-on, that will allow the user to add line segments to a line, and merge them. 5 | 6 | .. image:: images/segment.png 7 | :align: center 8 | 9 | To use this behavior, import the ``gaphas.segment`` module and add ``LineSegmentPainter`` to the list of painters for the view. 10 | Splitting and merging of lines is supported by ``item_tool``, however 11 | to actually use it, the ``segment`` module needs to be imported. 12 | 13 | >>> from gaphas.view import GtkView 14 | >>> from gaphas.painter import PainterChain, ItemPainter, HandlePainter 15 | >>> from gaphas.tool import item_tool, zoom_tool 16 | >>> from gaphas.segment import LineSegmentPainter 17 | >>> view = GtkView() 18 | >>> view.painter = ( 19 | ... PainterChain() 20 | ... .append(ItemPainter(view.selection)) 21 | ... .append(HandlePainter(view)) 22 | ... .append(LineSegmentPainter(view.selection)) 23 | ... ) 24 | >>> view.add_controller(item_tool()) 25 | >>> view.add_controller(zoom_tool()) 26 | -------------------------------------------------------------------------------- /docs/solver.rst: -------------------------------------------------------------------------------- 1 | Constraint Solver 2 | ================= 3 | 4 | Gaphas' constraint solver can be consider the heart of the library. 5 | The constraint solver ('solver' for short) is used to manage constraints. Both constraint internal to an item, such as handle alignment for a box, 6 | as well as inter-item connections, for example when a line is connected to a box. The solver is called during the update of the canvas. 7 | 8 | A solver contains a set of constraints. Each constraint in itself is pretty straightforward (e.g. variable ''a'' equals variable ''b''). 9 | Did I say variable? Yes I did. Let's start at the bottom and work our way to the solver. 10 | 11 | A ``Variable`` is a simple class, contains a value. 12 | It behaves like a ``float`` in many ways. There is one typical thing about Variables: they can be added to Constraints. 13 | 14 | Constraint are basically equations. 15 | The trick is to make all constraints true. 16 | That can be pretty tricky, since a Variable can play a role in more than one Constraint. 17 | Constraint solving is overseen by the Solver (ah, there it is). 18 | 19 | Constraints are instances of Constraint class. More specific: subclasses of the Constraint class. 20 | A Constraint can perform a specific trick, e.g. centre one Variable between two other Variables or make one Variable equal to another Variable. 21 | 22 | It's the Solver's job to make sure all constraint are true in the end. 23 | In some cases this means a constraint needs to be resolved twice, 24 | but the Solver sees to it that no deadlocks occur. 25 | 26 | Variables 27 | --------- 28 | 29 | When a variable is assigned a value it marks itself __dirty__. As a result it will be resolved the next time the solver is asked to. 30 | 31 | Each variable has a specific ''strength''. Strong variables can not be changed by weak variables, but weak variables can change when a new value is assigned to a stronger variable. 32 | The Solver always tries to solve a constraint for the weakest variable. If two variables have equal strength, however, the variable that is most recently changed is considered 33 | slightly stronger than the not (or earlier) changed variable. 34 | 35 | ------ 36 | 37 | The Solver can be found at: https://github.com/gaphor/gaphas/blob/main/gaphas/solver/, along with Variable and the Constraint base class. 38 | -------------------------------------------------------------------------------- /docs/table.rst: -------------------------------------------------------------------------------- 1 | Table 2 | ##### 3 | 4 | Table is an internal structure. It can be best compared to a table in a database. On the table, indexes can be defined. 5 | 6 | Tables are used when data should be made available in different forms. 7 | 8 | Source code: https://github.com/gaphor/gaphas/blob/main/gaphas/table.py. 9 | -------------------------------------------------------------------------------- /docs/tools.rst: -------------------------------------------------------------------------------- 1 | Interacting with diagrams 2 | ========================= 3 | 4 | Tools are used to handle user actions, like moving a mouse pointer over the 5 | screen and clicking on items in the canvas. 6 | 7 | Tools are registered on the ``View``. They have some internal state (e.g. when and 8 | where a mouse button was pressed). Therefore tools can not be reused by 9 | different views [#]_. 10 | 11 | Tools are simply `Gtk.EventController`_ instances. 12 | For a certain action to happen multiple user events are used. For example a 13 | click is a combination of a button press and button release event (only talking 14 | mouse clicks now). In most cases also some movement is done. A sequence of a 15 | button press, some movement and a button release is treated as one transaction. 16 | Once a button is pressed the tool registers itself as the tool that will deal 17 | with all subsequent events (a ``grab``). 18 | 19 | 20 | Several events can happen based on user events. E.g.: 21 | 22 | - item is hovered over (motion) 23 | - item is hovered over while another item is being moved (``press``, ``motion``) 24 | - item is hovered over while dragging something else (DnD; ``press``, ``move``) 25 | - grabbed (button press on item) 26 | - handle is grabbed (button press on handle) 27 | - center of line segment is grabbed (will cause segment to split; button press on line) 28 | - ungrabbed (button release) 29 | - move (item is moved -> hover + grabbed) 30 | - key is pressed 31 | - key is released 32 | - modifier is pressed (e.g. may cause mouse pointer to change, giving a hit 33 | about what a grab event will do. 34 | 35 | There is a lot of behaviour possible and it can depend on the kind of diagrams that are created what has to be done. 36 | 37 | To organize the event sequences and keep some order in what the user is doing Tools are used. Tools define what has to happen (find a handle nearly the mouse cursor, move an item). 38 | 39 | Gaphas contains a set of default tools. Each tool is meant to deal with a special part of the view. A list of responsibilities is also defined here: 40 | 41 | :hover tool: 42 | First thing a user wants to know is if the mouse cursor is over an item. The ``HoverTool`` makes that explicit. 43 | - Find a handle or item, if found, mark it as the ``hovered_item`` 44 | :item tool: 45 | Items are the elements that are actually providing any (visual) meaning to the diagram. ItemTool deals with moving them around. The tool makes sure the right subset of selected elements are moved (e.g. you don't want to move a nested item if its parent item is already moved, this gives funny visual effects) 46 | 47 | - On click: find an item, if found become the grabbed tool and set the item as focused. If a used clicked on a handle position that is taken into account 48 | - On motion: move the selected items (only the ones that have no selected parent items) 49 | - On release: release grab and release item 50 | 51 | The item tool invokes the `Move` aspect, or the `HandleMove` aspect in case a handle is being grabbed. 52 | 53 | :rubberband tool: 54 | If no handle or item is selected a rubberband selection is started. 55 | :scroll and zoom tool: 56 | Handy tools for moving the canvas around and zooming in and out. Convenience functionality, basically. 57 | 58 | There is one more tool, that has not been mentioned yet: 59 | 60 | :placement tool: 61 | A special tool to use for placing new items on the screen. 62 | 63 | As said, tools define *what* has to happen, they don't say how. Take for example finding a handle: on a normal element (a box or something) that would mean find the handle in one of the corners. On a line, however, that may also mean a not-yet existing handle in the middle of a line segment (there is a functionality that splits the line segment). 64 | 65 | The *how* is defined by so called aspects [#]_. 66 | 67 | Separating the *What* from the *How* 68 | ------------------------------------ 69 | 70 | The *what* is decided in a tool. Based on this the *how* logic can be applied 71 | to the item at hand. For example: if an item is clicked, it should be marked as 72 | the focused item. Same for dragging: if an item is dragged it should be updated 73 | based on the event information. It may even apply this to all other selected 74 | items. 75 | 76 | The how logic depends actually on the item it is applied to. Lines have different behaviours than boxes for example. In Gaphas this has been resolved by defining a generic methods. To put it simple: a generic method is a factory that returns a specific method (or instance of a class, as we do in gaphas) based on its parameters. 77 | 78 | The advantage is that more complex behaviour can be composed. Since the 79 | decision on what should happen is done in the tool, the aspect which is then 80 | used to work on the item ensures a certain behaviour is performed. 81 | 82 | .. [#] as opposed to versions < 0.5, where tools could be shared among multiple views. 83 | .. [#] not the AOP term. The term aspect is coming from a paper by Dirk Riehe: The Tools and Materials metaphor https://wiki.c2.com/?ToolsAndMaterialsMetaphor..>. 84 | 85 | .. _Gtk.EventController: https://docs.gtk.org/gtk4/class.EventController.html 86 | -------------------------------------------------------------------------------- /docs/tree.rst: -------------------------------------------------------------------------------- 1 | Tree 2 | #### 3 | 4 | Tree is an internal structure used by the default view model implementation (``gaphas.Canvas``). A tree consists of nodes. 5 | 6 | The tree is optimized for depth-first search. 7 | 8 | Source code: https://github.com/gaphor/gaphas/blob/main/gaphas/tree.py. 9 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaphor/gaphas/d355ced2b315faec4189bce21fde5f8f75fe5561/examples/__init__.py -------------------------------------------------------------------------------- /examples/demo_profile.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from examples.demo import main 4 | 5 | if __name__ == "__main__": 6 | try: 7 | import cProfile 8 | import pstats 9 | 10 | cProfile.run("main()", "demo-gaphas.prof") 11 | p = pstats.Stats("demo-gaphas.prof") 12 | p.strip_dirs().sort_stats("time").print_stats(40) 13 | except ImportError: 14 | import hotshot 15 | import hotshot.stats 16 | 17 | prof = hotshot.Profile("demo-gaphas.prof") 18 | prof.runcall(main) 19 | prof.close() 20 | stats = hotshot.stats.load("demo-gaphas.prof") 21 | stats.strip_dirs() 22 | stats.sort_stats("time", "calls") 23 | stats.print_stats(20) 24 | -------------------------------------------------------------------------------- /examples/exampleitems.py: -------------------------------------------------------------------------------- 1 | """Simple example items. 2 | 3 | These items are used in various tests. 4 | """ 5 | 6 | from math import pi 7 | 8 | from gaphas.connector import Handle 9 | from gaphas.item import NW, Element, Matrices 10 | 11 | 12 | class Box(Element): 13 | """A Box has 4 handles (for a start): 14 | 15 | NW +---+ NE SW +---+ SE 16 | """ 17 | 18 | def __init__(self, connections, width=10, height=10): 19 | super().__init__(connections, width, height) 20 | 21 | def draw(self, context): 22 | c = context.cairo 23 | nw = self._handles[NW].pos 24 | c.rectangle(nw.x, nw.y, self.width, self.height) 25 | if context.hovered: 26 | c.set_source_rgba(0.8, 0.8, 1, 0.8) 27 | else: 28 | c.set_source_rgba(1, 1, 1, 0.8) 29 | c.fill_preserve() 30 | c.set_source_rgb(0, 0, 0.8) 31 | c.stroke() 32 | 33 | 34 | class Text(Matrices): 35 | """Simple item showing some text on the canvas.""" 36 | 37 | def __init__(self, text=None, plain=False, multiline=False, align_x=1, align_y=-1): 38 | super().__init__() 39 | self.text = "Hello" if text is None else text 40 | self.plain = plain 41 | self.multiline = multiline 42 | self.align_x = align_x 43 | self.align_y = align_y 44 | 45 | def handles(self): 46 | return [] 47 | 48 | def ports(self): 49 | return [] 50 | 51 | def point(self, x, y): 52 | return 0 53 | 54 | def draw(self, context): 55 | cr = context.cairo 56 | if self.multiline: 57 | text_multiline(cr, 0, 0, self.text) 58 | elif self.plain: 59 | cr.show_text(self.text) 60 | else: 61 | text_align(cr, 0, 0, self.text, self.align_x, self.align_y) 62 | 63 | 64 | class Circle(Matrices): 65 | def __init__(self): 66 | super().__init__() 67 | self._handles = [Handle(), Handle()] 68 | h1, h2 = self._handles 69 | h1.movable = False 70 | 71 | @property 72 | def radius(self): 73 | h1, h2 = self._handles 74 | p1, p2 = h1.pos, h2.pos 75 | return ((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2) ** 0.5 76 | 77 | @radius.setter 78 | def radius(self, r): 79 | h1, h2 = self._handles 80 | h2.pos.x = r 81 | h2.pos.y = r 82 | 83 | def handles(self): 84 | return self._handles 85 | 86 | def ports(self): 87 | return [] 88 | 89 | def point(self, x, y): 90 | h1, _ = self._handles 91 | p1 = h1.pos 92 | dist = ((x - p1.x) ** 2 + (y - p1.y) ** 2) ** 0.5 93 | return dist - self.radius 94 | 95 | def draw(self, context): 96 | cr = context.cairo 97 | path_ellipse(cr, 0, 0, 2 * self.radius, 2 * self.radius) 98 | cr.stroke() 99 | 100 | 101 | def text_align(cr, x, y, text, align_x=0, align_y=0, padding_x=0, padding_y=0): 102 | """Draw text relative to (x, y). 103 | 104 | x, y - coordinates 105 | text - text to print (utf8) 106 | align_x - -1 (top), 0 (middle), 1 (bottom) 107 | align_y - -1 (left), 0 (center), 1 (right) 108 | padding_x - padding (extra offset), always > 0 109 | padding_y - padding (extra offset), always > 0 110 | """ 111 | if not text: 112 | return 113 | 114 | x_bear, y_bear, w, h, _x_adv, _y_adv = cr.text_extents(text) 115 | if align_x == 0: 116 | x = 0.5 - (w / 2 + x_bear) + x 117 | elif align_x < 0: 118 | x = -(w + x_bear) + x - padding_x 119 | else: 120 | x = x + padding_x 121 | if align_y == 0: 122 | y = 0.5 - (h / 2 + y_bear) + y 123 | elif align_y < 0: 124 | y = -(h + y_bear) + y - padding_y 125 | else: 126 | y = -y_bear + y + padding_y 127 | cr.move_to(x, y) 128 | cr.show_text(text) 129 | 130 | 131 | def text_multiline(cr, x, y, text): 132 | """Draw a string of text with embedded newlines. 133 | 134 | cr - cairo context 135 | x - leftmost x 136 | y - topmost y 137 | text - text to draw 138 | """ 139 | if not text: 140 | return 141 | for line in text.split("\n"): 142 | _x_bear, _y_bear, _w, h, _x_adv, _y_adv = cr.text_extents(text) 143 | y += h 144 | cr.move_to(x, y) 145 | cr.show_text(line) 146 | 147 | 148 | def path_ellipse(cr, x, y, width, height, angle=0): 149 | """Draw an ellipse. 150 | 151 | x - center x 152 | y - center y 153 | width - width of ellipse (in x direction when angle=0) 154 | height - height of ellipse (in y direction when angle=0) 155 | angle - angle in radians to rotate, clockwise 156 | """ 157 | cr.save() 158 | cr.translate(x, y) 159 | cr.rotate(angle) 160 | cr.scale(width / 2.0, height / 2.0) 161 | cr.move_to(1.0, 0.0) 162 | cr.arc(0.0, 0.0, 1.0, 0.0, 2.0 * pi) 163 | cr.restore() 164 | -------------------------------------------------------------------------------- /examples/simple-box.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # ruff: noqa: E402 3 | """A simple example containing two boxes and a line.""" 4 | 5 | import gi 6 | 7 | # fmt: off 8 | gi.require_version("Gtk", "4.0") 9 | from gi.repository import Gtk 10 | 11 | from gaphas import Canvas, Line 12 | from gaphas.tool import hover_tool, item_tool, view_focus_tool, zoom_tool 13 | from gaphas.view import GtkView 14 | from examples.exampleitems import Box 15 | # fmt: on 16 | 17 | 18 | def apply_default_tool_set(view): 19 | view.remove_all_controllers() 20 | view.add_controller(item_tool()) 21 | view.add_controller(zoom_tool()) 22 | view.add_controller(view_focus_tool()) 23 | view.add_controller(hover_tool()) 24 | 25 | 26 | def create_canvas(canvas, title): 27 | # Setup drawing window 28 | view = GtkView() 29 | view.model = canvas 30 | apply_default_tool_set(view) 31 | 32 | window = Gtk.Window() 33 | window.set_title(title) 34 | window.set_default_size(400, 400) 35 | win_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) 36 | window.add(win_box) 37 | win_box.pack_start(view, True, True, 0) 38 | 39 | # Draw first gaphas box 40 | b1 = Box(canvas.connections, 60, 60) 41 | b1.matrix.translate(10, 10) 42 | canvas.add(b1) 43 | 44 | # Draw second gaphas box 45 | b2 = Box(canvas.connections, 60, 60) 46 | b2.min_width = 40 47 | b2.min_height = 50 48 | b2.matrix.translate(170, 170) 49 | canvas.add(b2) 50 | 51 | # Draw gaphas line 52 | line = Line(canvas.connections) 53 | line.matrix.translate(100, 60) 54 | canvas.add(line) 55 | line.handles()[1].pos = (30, 30) 56 | 57 | window.show_all() 58 | window.connect("destroy", Gtk.main_quit) 59 | 60 | 61 | if __name__ == "__main__": 62 | c = Canvas() 63 | create_canvas(c, "Simple Gaphas App") 64 | Gtk.main() 65 | -------------------------------------------------------------------------------- /gaphas/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Gaphas 3 | ====== 4 | 5 | Gaphor's Canvas. 6 | 7 | This module contains the application independent parts of Gaphor's 8 | Canvas. 9 | 10 | Notes 11 | ===== 12 | 13 | In py-cairo 1.8.0 (or 1.8.1, or 1.8.2) the multiplication order has 14 | been reverted. This causes bugs in Gaphas. 15 | 16 | Also a new method ``multiply()`` has been introduced. This method is 17 | used in Gaphas instead of the multiplier (``*``). In both the 18 | ``Canvas`` and ``View`` class a workaround is provided in case an 19 | older version of py-cairo is used. 20 | 21 | Copyright notice 22 | ================ 23 | 24 | Copyright 2006, Arjan Molenaar & Dan Yeaw 25 | 26 | Licensed under the Apache License, Version 2.0 (the "License"); 27 | you may not use this file except in compliance with the License. 28 | You may obtain a copy of the License at 29 | 30 | http://www.apache.org/licenses/LICENSE-2.0 31 | 32 | Unless required by applicable law or agreed to in writing, software 33 | distributed under the License is distributed on an "AS IS" BASIS, 34 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 35 | See the License for the specific language governing permissions and 36 | limitations under the License. 37 | 38 | SPDX-FileCopyrightText: 2006 Arjan Molenaar & Dan Yeaw 39 | SPDX-License-Identifer: Apache-2.0 40 | """ 41 | from gaphas.canvas import Canvas 42 | from gaphas.connector import Handle 43 | from gaphas.item import Element, Item, Line 44 | -------------------------------------------------------------------------------- /gaphas/cursor.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import singledispatch 4 | from typing import Union 5 | 6 | from gaphas.connector import Handle 7 | from gaphas.item import Element, Item, Line 8 | from gaphas.types import Pos 9 | 10 | DEFAULT_CURSOR = "default" 11 | 12 | 13 | @singledispatch 14 | def cursor(item: Union[Item, None], handle: Union[Handle, None], pos: Pos) -> str: 15 | return DEFAULT_CURSOR 16 | 17 | 18 | ELEMENT_CURSORS = ("nw-resize", "ne-resize", "se-resize", "sw-resize") 19 | 20 | 21 | @cursor.register 22 | def element_hover(item: Element, handle: Union[Handle, None], pos: Pos) -> str: 23 | if handle: 24 | index = item.handles().index(handle) 25 | return ELEMENT_CURSORS[index] if index < 4 else DEFAULT_CURSOR 26 | return DEFAULT_CURSOR 27 | 28 | 29 | LINE_CURSOR = "move" 30 | 31 | 32 | @cursor.register 33 | def line_hover(item: Line, handle: Union[Handle, None], pos: Pos) -> str: 34 | return LINE_CURSOR if handle else DEFAULT_CURSOR 35 | -------------------------------------------------------------------------------- /gaphas/handle.py: -------------------------------------------------------------------------------- 1 | """Basic connectors such as Ports and Handles.""" 2 | 3 | from __future__ import annotations 4 | 5 | from gaphas.position import Position 6 | from gaphas.solver import NORMAL 7 | from gaphas.types import Pos, SupportsFloatPos, TypedProperty 8 | 9 | 10 | class Handle: 11 | """Handles are used to support modifications of Items. 12 | 13 | If the handle is connected to an item, the ``connected_to`` 14 | property should refer to the item. A ``disconnect`` handler should 15 | be provided that handles all disconnect behaviour (e.g. clean up 16 | constraints and ``connected_to``). 17 | 18 | Note for those of you that use the Pickle module to persist a 19 | canvas: The property ``disconnect`` should contain a callable 20 | object (with __call__() method), so the pickle handler can also 21 | pickle that. Pickle is not capable of pickling ``instancemethod`` 22 | or ``function`` objects. 23 | """ 24 | 25 | def __init__( 26 | self, 27 | pos: Pos = (0, 0), 28 | strength: int = NORMAL, 29 | connectable: bool = False, 30 | movable: bool = True, 31 | ) -> None: 32 | """Create a new handle. 33 | 34 | Position is in item coordinates. 35 | """ 36 | self._pos = Position(pos[0], pos[1], strength) 37 | self._connectable = connectable 38 | self._movable = movable 39 | self._visible = True 40 | self._glued = False 41 | 42 | def _set_pos(self, pos: Position | SupportsFloatPos) -> None: 43 | """ 44 | Shortcut for ``handle.pos.pos = pos`` 45 | 46 | >>> h = Handle((10, 10)) 47 | >>> h.pos = (20, 15) 48 | >>> h.pos 49 | 50 | """ 51 | self._pos.pos = pos 52 | 53 | pos: TypedProperty[Position, Position | SupportsFloatPos] 54 | pos = property(lambda s: s._pos, _set_pos, doc="The Handle's position") 55 | 56 | @property 57 | def connectable(self) -> bool: 58 | """Can this handle actually connectect to a port?""" 59 | return self._connectable 60 | 61 | @connectable.setter 62 | def connectable(self, connectable: bool) -> None: 63 | self._connectable = connectable 64 | 65 | @property 66 | def movable(self) -> bool: 67 | """Can this handle be moved by a mouse pointer?""" 68 | return self._movable 69 | 70 | @movable.setter 71 | def movable(self, movable: bool) -> None: 72 | self._movable = movable 73 | 74 | @property 75 | def visible(self) -> bool: 76 | """Is this handle visible to the user?""" 77 | return self._visible 78 | 79 | @visible.setter 80 | def visible(self, visible: bool) -> None: 81 | self._visible = visible 82 | 83 | @property 84 | def glued(self) -> bool: 85 | """Is the handle being moved and about to be connected?""" 86 | return self._glued 87 | 88 | @glued.setter 89 | def glued(self, glued: bool) -> None: 90 | self._glued = glued 91 | 92 | def __str__(self) -> str: 93 | return f"<{self.__class__.__name__} object on ({self._pos.x}, {self._pos.y})>" 94 | 95 | __repr__ = __str__ 96 | -------------------------------------------------------------------------------- /gaphas/handlemove.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from functools import singledispatch 5 | from operator import itemgetter 6 | from typing import TYPE_CHECKING, Iterable, Sequence 7 | 8 | from gaphas.connector import ConnectionSink, ConnectionSinkType, Connector 9 | from gaphas.handle import Handle 10 | from gaphas.item import Item 11 | from gaphas.types import Pos 12 | 13 | if TYPE_CHECKING: 14 | from gaphas.view import GtkView 15 | 16 | log = logging.getLogger(__name__) 17 | 18 | 19 | class ItemHandleMove: 20 | """Move a handle (role is applied to the handle)""" 21 | 22 | GLUE_DISTANCE = 10 23 | 24 | last_x: float 25 | last_y: float 26 | 27 | def __init__(self, item: Item, handle: Handle, view: GtkView): 28 | self.item = item 29 | self.handle = handle 30 | self.view = view 31 | 32 | def start_move(self, pos: Pos) -> None: 33 | self.last_x, self.last_y = pos 34 | model = self.view.model 35 | assert model 36 | if cinfo := model.connections.get_connection(self.handle): 37 | self.handle.glued = True 38 | model.connections.solver.remove_constraint(cinfo.constraint) 39 | 40 | def move(self, pos: Pos) -> None: 41 | item = self.item 42 | view = self.view 43 | assert view.model 44 | 45 | v2i = view.get_matrix_v2i(item) 46 | 47 | x, y = v2i.transform_point(*pos) 48 | 49 | self.handle.pos = (x, y) 50 | 51 | self.handle.glued = bool(self.glue(pos)) 52 | 53 | # do not request matrix update as matrix recalculation will be 54 | # performed due to item normalization if required 55 | view.model.request_update(item) 56 | 57 | def stop_move(self, pos: Pos) -> None: 58 | self.handle.glued = False 59 | self.connect(pos) 60 | 61 | def glue( 62 | self, pos: Pos, distance: int = GLUE_DISTANCE 63 | ) -> ConnectionSinkType | None: 64 | """Glue to an item near a specific point. 65 | 66 | Returns a ConnectionSink or None. 67 | """ 68 | item = self.item 69 | handle = self.handle 70 | view = self.view 71 | model = view.model 72 | assert model 73 | connections = model.connections 74 | 75 | if not handle.connectable: 76 | return None 77 | 78 | for connectable in item_at_point(view, pos, distance=distance, exclude=(item,)): 79 | connector = Connector(self.item, self.handle, connections) 80 | sink = ConnectionSink(connectable) 81 | 82 | if connector.glue(sink): 83 | return sink 84 | 85 | return None 86 | 87 | def connect(self, pos: Pos) -> None: 88 | """Connect a handle of a item to connectable item. 89 | 90 | Connectable item is found by `ConnectHandleTool.glue` method. 91 | 92 | :Parameters: 93 | item 94 | Connecting item. 95 | handle 96 | Handle of connecting item. 97 | pos 98 | Position to connect to (or near at least) 99 | """ 100 | handle = self.handle 101 | model = self.view.model 102 | assert model 103 | connections = model.connections 104 | connector = Connector(self.item, handle, connections) 105 | 106 | if sink := self.glue(pos): 107 | connector.connect(sink) 108 | elif connections.get_connection(handle): 109 | connector.disconnect() 110 | 111 | model.request_update(self.item) 112 | 113 | 114 | HandleMove = singledispatch(ItemHandleMove) 115 | 116 | 117 | def item_distance( 118 | view: GtkView, 119 | pos: Pos, 120 | distance: float = 0.5, 121 | exclude: Sequence[Item] = (), 122 | ) -> Iterable[tuple[float, Item]]: 123 | """Return the topmost item located at ``pos`` (x, y). 124 | 125 | Parameters: 126 | - view: a view 127 | - pos: Position, a tuple ``(x, y)`` in view coordinates 128 | - selected: if False returns first non-selected item 129 | """ 130 | item: Item 131 | vx, vy = pos 132 | rect = (vx - distance, vy - distance, distance * 2, distance * 2) 133 | for item in reversed(list(view.get_items_in_rectangle(rect))): 134 | if item in exclude: 135 | continue 136 | 137 | v2i = view.get_matrix_v2i(item) 138 | ix, iy = v2i.transform_point(vx, vy) 139 | d = item.point(ix, iy) 140 | if d is None: 141 | log.warning("Item distance is None for %s", item) 142 | continue 143 | if d < distance: 144 | yield d, item 145 | 146 | 147 | def order_items(distance_items, key=itemgetter(0)): 148 | inside = [] 149 | outside = [] 150 | for e in distance_items: 151 | if key(e) > 0: 152 | outside.append(e) 153 | else: 154 | inside.append(e) 155 | 156 | inside.sort(key=key, reverse=True) 157 | outside.sort(key=key) 158 | return inside + outside 159 | 160 | 161 | def item_at_point( 162 | view: GtkView, 163 | pos: Pos, 164 | distance: float = 0.5, 165 | exclude: Sequence[Item] = (), 166 | ) -> Iterable[Item]: 167 | """Return the topmost item located at ``pos`` (x, y). 168 | 169 | Parameters: 170 | - view: a view 171 | - pos: Position, a tuple ``(x, y)`` in view coordinates 172 | - selected: if False returns first non-selected item 173 | """ 174 | return ( 175 | item for _d, item in order_items(item_distance(view, pos, distance, exclude)) 176 | ) 177 | -------------------------------------------------------------------------------- /gaphas/matrix.py: -------------------------------------------------------------------------------- 1 | """Some Gaphor specific updates to the canvas. This is done by setting the 2 | correct properties on gaphas' modules. 3 | 4 | Matrix 5 | ------ 6 | Small utility class wrapping cairo.Matrix. The `Matrix` class adds 7 | state notification capabilities. 8 | """ 9 | 10 | from __future__ import annotations 11 | 12 | from typing import Callable, SupportsFloat 13 | 14 | import cairo 15 | 16 | Matrixtuple = tuple[float, float, float, float, float, float] 17 | 18 | 19 | class Matrix: 20 | """Matrix wrapper. 21 | 22 | >>> Matrix() 23 | Matrix(1.0, 0.0, 0.0, 1.0, 0.0, 0.0) 24 | """ 25 | 26 | def __init__( 27 | self, 28 | xx: float = 1.0, 29 | yx: float = 0.0, 30 | xy: float = 0.0, 31 | yy: float = 1.0, 32 | x0: float = 0.0, 33 | y0: float = 0.0, 34 | matrix: cairo.Matrix | None = None, 35 | ) -> None: 36 | self._matrix = matrix or cairo.Matrix(xx, yx, xy, yy, x0, y0) 37 | self._handlers: set[Callable[[Matrix, Matrixtuple], None]] = set() 38 | 39 | def add_handler( 40 | self, 41 | handler: Callable[[Matrix, Matrixtuple], None], 42 | ) -> None: 43 | self._handlers.add(handler) 44 | 45 | def remove_handler( 46 | self, 47 | handler: Callable[[Matrix, Matrixtuple], None], 48 | ) -> None: 49 | self._handlers.discard(handler) 50 | 51 | def notify(self, old: Matrixtuple) -> None: 52 | for handler in self._handlers: 53 | handler(self, old) 54 | 55 | def invert(self) -> None: 56 | old: Matrixtuple = self.tuple() 57 | self._matrix.invert() 58 | self.notify(old) 59 | 60 | def rotate(self, radians: float) -> None: 61 | old: Matrixtuple = self.tuple() 62 | self._matrix.rotate(radians) 63 | self.notify(old) 64 | 65 | def scale(self, sx: float, sy: float) -> None: 66 | old = self.tuple() 67 | self._matrix.scale(sx, sy) 68 | self.notify(old) 69 | 70 | def translate(self, tx: float, ty: float) -> None: 71 | old: Matrixtuple = self.tuple() 72 | self._matrix.translate(tx, ty) 73 | self.notify(old) 74 | 75 | def set( 76 | self, 77 | xx: float | None = None, 78 | yx: float | None = None, 79 | xy: float | None = None, 80 | yy: float | None = None, 81 | x0: float | None = None, 82 | y0: float | None = None, 83 | ) -> None: 84 | updated = False 85 | m = self._matrix 86 | old = self.tuple() 87 | for name, val in ( 88 | ("xx", xx), 89 | ("yx", yx), 90 | ("xy", xy), 91 | ("yy", yy), 92 | ("x0", x0), 93 | ("y0", y0), 94 | ): 95 | if val is not None and val != getattr(m, name): 96 | setattr(m, name, val) 97 | updated = True 98 | if updated: 99 | self.notify(old) 100 | 101 | def multiply(self, m: Matrix) -> Matrix: 102 | return Matrix(matrix=self._matrix.multiply(m._matrix)) 103 | 104 | def transform_distance( 105 | self, dx: SupportsFloat, dy: SupportsFloat 106 | ) -> tuple[float, float]: 107 | return self._matrix.transform_distance(dx, dy) # type: ignore[no-any-return] 108 | 109 | def transform_point( 110 | self, x: SupportsFloat, y: SupportsFloat 111 | ) -> tuple[float, float]: 112 | return self._matrix.transform_point(x, y) # type: ignore[no-any-return] 113 | 114 | def inverse(self) -> Matrix: 115 | m = Matrix(matrix=cairo.Matrix(*self._matrix)) 116 | m.invert() 117 | return m 118 | 119 | def tuple(self) -> Matrixtuple: 120 | return tuple(self) # type: ignore[arg-type, return-value] 121 | 122 | def to_cairo(self) -> cairo.Matrix: 123 | return self._matrix 124 | 125 | def __eq__(self, other: object) -> bool: 126 | # sourcery skip: remove-unnecessary-cast 127 | return ( 128 | bool(self._matrix == other._matrix) if isinstance(other, Matrix) else False 129 | ) 130 | 131 | def __getitem__(self, val: int) -> float: 132 | return self._matrix[val] # type: ignore[no-any-return] 133 | 134 | def __mul__(self, other: Matrix) -> Matrix: 135 | return Matrix(matrix=self._matrix * other._matrix) 136 | 137 | def __imul__(self, other: Matrix) -> Matrix: 138 | old: Matrixtuple = self.tuple() 139 | self._matrix *= other._matrix 140 | self.notify(old) 141 | return self 142 | 143 | def __repr__(self) -> str: 144 | return f"Matrix{tuple(self._matrix)}" 145 | -------------------------------------------------------------------------------- /gaphas/model.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Collection, Iterable, Protocol, runtime_checkable 4 | 5 | from gaphas.connections import Connections 6 | from gaphas.item import Item 7 | 8 | 9 | @runtime_checkable 10 | class View(Protocol): 11 | def request_update( 12 | self, 13 | items: Collection[Item], 14 | matrix_only_items: Collection[Item], 15 | removed_items: Collection[Item], 16 | ) -> None: 17 | """Propagate update requests to the view. 18 | 19 | By invoking this method, the View will be made aware of state changes, 20 | that will either: 21 | 22 | 1. Cause the item to be fully updated 23 | 2. Just cause the item to move, without any further updates. 24 | """ 25 | 26 | 27 | @runtime_checkable 28 | class Model(Protocol): 29 | """Any class that adhere's to the Model protocol can be used as a model for 30 | GtkView.""" 31 | 32 | @property 33 | def connections(self) -> Connections: 34 | """The connections instance used for this model.""" 35 | 36 | def get_all_items(self) -> Iterable[Item]: 37 | """Iterate over all items in the order they need to be rendered in. 38 | 39 | Normally that will be depth-first. 40 | """ 41 | 42 | def get_parent(self, item: Item) -> Item | None: 43 | """Get the parent item of an item. 44 | 45 | Returns ``None`` if there is no parent item. 46 | """ 47 | 48 | def get_children(self, item: Item | None) -> Iterable[Item]: 49 | """Iterate all direct child items of an item.""" 50 | 51 | def sort(self, items: Collection[Item]) -> Iterable[Item]: 52 | """Sort a collection of items in the order they need to be rendered 53 | in.""" 54 | 55 | def request_update(self, item: Item) -> None: 56 | """Request update for an item. 57 | 58 | Arguments: 59 | item (Item): The item to be updated 60 | """ 61 | 62 | def update_now(self, dirty_items: Collection[Item]) -> None: 63 | """This method is called during the update process. 64 | 65 | It will allow the model to do some additional updating of it's 66 | own. 67 | """ 68 | 69 | def register_view(self, view: View) -> None: 70 | """Allow a view to be registered. 71 | 72 | Registered views should receive update requests for modified 73 | items. 74 | """ 75 | 76 | def unregister_view(self, view: View) -> None: 77 | """Unregister a previously registered view. 78 | 79 | If a view is not registered, nothing should happen. 80 | """ 81 | -------------------------------------------------------------------------------- /gaphas/move.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import singledispatch 4 | from typing import TYPE_CHECKING, Protocol 5 | 6 | from gaphas.item import Item 7 | from gaphas.types import Pos 8 | 9 | if TYPE_CHECKING: 10 | from gaphas.view import GtkView 11 | 12 | 13 | class MoveType(Protocol): 14 | item: Item 15 | 16 | def __init__(self, item: Item, view: GtkView): ... 17 | 18 | def start_move(self, pos: Pos) -> None: ... 19 | 20 | def move(self, pos: Pos) -> None: ... 21 | 22 | def stop_move(self, pos: Pos) -> None: ... 23 | 24 | 25 | class ItemMove: 26 | """Aspect for dealing with motion on an item. 27 | 28 | In this case the item is moved. 29 | """ 30 | 31 | last_x: float 32 | last_y: float 33 | 34 | def __init__(self, item: Item, view: GtkView): 35 | self.item = item 36 | self.view = view 37 | 38 | def start_move(self, pos: Pos) -> None: 39 | self.last_x, self.last_y = pos 40 | 41 | def move(self, pos: Pos) -> None: 42 | """Move the item. 43 | 44 | x and y are in view coordinates. 45 | """ 46 | item = self.item 47 | view = self.view 48 | assert view.model 49 | 50 | v2i = view.get_matrix_v2i(item) 51 | x, y = pos 52 | dx, dy = x - self.last_x, y - self.last_y 53 | dx, dy = v2i.transform_distance(dx, dy) 54 | self.last_x, self.last_y = x, y 55 | 56 | item.matrix.translate(dx, dy) 57 | view.request_update([item]) 58 | 59 | def stop_move(self, pos: Pos) -> None: 60 | pass 61 | 62 | 63 | Move = singledispatch(ItemMove) 64 | -------------------------------------------------------------------------------- /gaphas/painter/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from gaphas.painter.chain import PainterChain 6 | from gaphas.painter.freehand import FreeHandPainter 7 | from gaphas.painter.handlepainter import HandlePainter 8 | from gaphas.painter.itempainter import ItemPainter 9 | from gaphas.painter.painter import Painter 10 | 11 | if TYPE_CHECKING: 12 | from gaphas.view import GtkView 13 | 14 | 15 | def DefaultPainter(view: GtkView) -> Painter: 16 | """Default painter, containing item, handle and tool painters.""" 17 | return ( 18 | PainterChain().append(ItemPainter(view.selection)).append(HandlePainter(view)) 19 | ) 20 | -------------------------------------------------------------------------------- /gaphas/painter/chain.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Collection 4 | 5 | from cairo import Context as CairoContext 6 | 7 | from gaphas.item import Item 8 | from gaphas.painter.painter import Painter 9 | 10 | 11 | class PainterChain: 12 | """Chain up a set of painters.""" 13 | 14 | def __init__(self) -> None: 15 | self._painters: list[Painter] = [] 16 | 17 | def append(self, painter: Painter) -> PainterChain: 18 | """Add a painter to the list of painters.""" 19 | self._painters.append(painter) 20 | return self 21 | 22 | def prepend(self, painter: Painter) -> PainterChain: 23 | """Add a painter to the beginning of the list of painters.""" 24 | self._painters.insert(0, painter) 25 | return self 26 | 27 | def paint(self, items: Collection[Item], cairo: CairoContext) -> None: 28 | """See Painter.paint().""" 29 | for painter in self._painters: 30 | painter.paint(items, cairo) 31 | -------------------------------------------------------------------------------- /gaphas/painter/freehand.py: -------------------------------------------------------------------------------- 1 | """Cairo context using Steve Hanov's freehand drawing code. 2 | 3 | # Crazyline. By Steve Hanov, 2008 Released to the public domain. 4 | 5 | # The idea is to draw a curve, setting two control points at 6 | # random close to each side of the line. The longer the line, the 7 | # sloppier it's drawn. 8 | 9 | See: http://stevehanov.ca/blog/index.php?id=33 and 10 | http://stevehanov.ca/blog/index.php?id=93 11 | """ 12 | 13 | from math import sqrt 14 | from random import Random 15 | from collections.abc import Collection 16 | 17 | from cairo import Context as CairoContext 18 | 19 | from gaphas.item import Item 20 | from gaphas.painter.painter import ItemPainterType 21 | 22 | 23 | class FreeHandCairoContext: 24 | KAPPA = 0.5522847498 25 | 26 | def __init__(self, cr, sloppiness=0.5): 27 | self.cr = cr 28 | self.sloppiness = sloppiness # In range 0.0 .. 2.0 29 | 30 | def __getattr__(self, key): 31 | return getattr(self.cr, key) 32 | 33 | def line_to(self, x, y): 34 | cr = self.cr 35 | sloppiness = self.sloppiness 36 | from_x, from_y = cr.get_current_point() 37 | 38 | # calculate the length of the line. 39 | length = sqrt((x - from_x) * (x - from_x) + (y - from_y) * (y - from_y)) 40 | 41 | # This offset determines how sloppy the line is drawn. It depends on 42 | # the length, but maxes out at 20. 43 | offset = length / 10 * sloppiness 44 | offset = min(offset, 20) 45 | 46 | dev_x, dev_y = cr.user_to_device(x, y) 47 | rand = Random(from_x + from_y + dev_x + dev_y + length + offset).random 48 | 49 | # Overshoot the destination a little, as one might if drawing with a pen. 50 | to_x = x + sloppiness * rand() * offset / 4 51 | to_y = y + sloppiness * rand() * offset / 4 52 | 53 | # t1 and t2 are coordinates of a line shifted under or to the right of 54 | # our original. 55 | t1_x = from_x + offset 56 | t1_y = from_y + offset 57 | t2_x = to_x + offset 58 | t2_y = to_y + offset 59 | 60 | # create a control point at random along our shifted line. 61 | r = rand() 62 | control1_x = t1_x + r * (t2_x - t1_x) 63 | control1_y = t1_y + r * (t2_y - t1_y) 64 | 65 | # now make t1 and t2 the coordinates of our line shifted above 66 | # and to the left of the original. 67 | 68 | t1_x = from_x - offset 69 | t2_x = to_x - offset 70 | t1_y = from_y - offset 71 | t2_y = to_y - offset 72 | 73 | # create a second control point at random along the shifted line. 74 | r = rand() 75 | control2_x = t1_x + r * (t2_x - t1_x) 76 | control2_y = t1_y + r * (t2_y - t1_y) 77 | 78 | # draw the line! 79 | cr.curve_to(control1_x, control1_y, control2_x, control2_y, to_x, to_y) 80 | 81 | def rel_line_to(self, dx, dy): 82 | cr = self.cr 83 | from_x, from_y = cr.get_current_point() 84 | self.line_to(from_x + dx, from_y + dy) 85 | 86 | def curve_to(self, x1, y1, x2, y2, x3, y3): 87 | cr = self.cr 88 | from_x, from_y = cr.get_current_point() 89 | 90 | dev_x, dev_y = cr.user_to_device(x3, y3) 91 | rand = Random( 92 | from_x + from_y + dev_x + dev_y + x1 + y1 + x2 + y2 + x3 + y3 93 | ).random 94 | 95 | r = rand() 96 | c1_x = from_x + r * (x1 - from_x) 97 | c1_y = from_y + r * (y1 - from_y) 98 | 99 | r = rand() 100 | c2_x = x3 + r * (x2 - x3) 101 | c2_y = y3 + r * (y2 - y3) 102 | 103 | cr.curve_to(c1_x, c1_y, c2_x, c2_y, x3, y3) 104 | 105 | def rel_curve_to(self, dx1, dy1, dx2, dy2, dx3, dy3): 106 | cr = self.cr 107 | from_x, from_y = cr.get_current_point() 108 | self.curve_to( 109 | from_x + dx1, 110 | from_y + dy1, 111 | from_x + dx2, 112 | from_y + dy2, 113 | from_x + dx3, 114 | from_y + dy3, 115 | ) 116 | 117 | def rectangle(self, x, y, width, height): 118 | x1 = x + width 119 | y1 = y + height 120 | self.move_to(x, y) 121 | self.line_to(x1, y) 122 | self.line_to(x1, y1) 123 | self.line_to(x, y1) 124 | if self.sloppiness > 0.1: 125 | self.line_to(x, y) 126 | else: 127 | self.close_path() 128 | 129 | 130 | class FreeHandPainter: 131 | """This painter is a wrapper for an Item painter. The Cairo context is 132 | modified to allow for a sloppy, hand written drawing style. 133 | 134 | Range [0..2.0] gives acceptable results. 135 | 136 | * Draftsman: 0.0 137 | * Artist: 0.25 138 | * Cartoonist: 0.5 139 | * Child: 1.0 140 | * Drunk: 2.0 141 | """ 142 | 143 | def __init__(self, item_painter: ItemPainterType, sloppiness: float = 0.5): 144 | self.item_painter = item_painter 145 | self.sloppiness = sloppiness 146 | 147 | def paint_item(self, item: Item, cairo: CairoContext) -> None: 148 | # Bounding painter requires painting per item 149 | self.item_painter.paint_item(item, FreeHandCairoContext(cairo, self.sloppiness)) 150 | 151 | def paint(self, items: Collection[Item], cairo: CairoContext) -> None: 152 | self.item_painter.paint( 153 | items, 154 | FreeHandCairoContext(cairo, self.sloppiness), 155 | ) 156 | -------------------------------------------------------------------------------- /gaphas/painter/handlepainter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Collection 4 | 5 | from cairo import Context as CairoContext 6 | 7 | from gaphas.item import Item 8 | 9 | if TYPE_CHECKING: 10 | from gaphas.view import GtkView 11 | 12 | 13 | # Colors from the GNOME Palette 14 | RED_4 = (0.753, 0.110, 0.157) 15 | ORANGE_4 = (0.902, 0.380, 0) 16 | GREEN_4 = (0.180, 0.7608, 0.494) 17 | BLUE_4 = (0.110, 0.443, 0.847) 18 | 19 | 20 | class HandlePainter: 21 | """Draw handles of items that are marked as selected in the view.""" 22 | 23 | def __init__(self, view: GtkView) -> None: 24 | assert view 25 | self.view = view 26 | 27 | def _draw_handles( 28 | self, 29 | item: Item, 30 | cairo: CairoContext, 31 | opacity: float | None = None, 32 | ) -> None: 33 | """Draw handles for an item. 34 | 35 | The handles are drawn in non-antialiased mode for clarity. 36 | """ 37 | view = self.view 38 | model = view.model 39 | assert model 40 | cairo.save() 41 | if not opacity: 42 | opacity = 0.9 if item is view.selection.focused_item else 0.6 43 | 44 | get_connection = model.connections.get_connection 45 | for h in item.handles(): 46 | if not h.visible: 47 | continue 48 | # connected and not being moved, see HandleTool.on_button_press 49 | if get_connection(h): 50 | color = RED_4 51 | elif h.glued: 52 | color = ORANGE_4 53 | elif h.movable: 54 | color = GREEN_4 55 | else: 56 | color = BLUE_4 57 | 58 | vx, vy = cairo.user_to_device(*item.matrix_i2c.transform_point(*h.pos)) 59 | cairo.set_source_rgba(*color, opacity) 60 | 61 | draw_handle(cairo, vx, vy) 62 | 63 | cairo.restore() 64 | 65 | def paint(self, items: Collection[Item], cairo: CairoContext) -> None: 66 | view = self.view 67 | model = view.model 68 | assert model 69 | selection = view.selection 70 | # Order matters here: 71 | for item in model.sort(selection.selected_items): 72 | self._draw_handles(item, cairo) 73 | # Draw nice opaque handles when hovering an item: 74 | hovered = selection.hovered_item 75 | if hovered and hovered not in selection.selected_items: 76 | self._draw_handles(hovered, cairo, opacity=0.25) 77 | 78 | 79 | def draw_handle( 80 | cairo: CairoContext, vx: float, vy: float, size: float = 12.0, corner: float = 2.0 81 | ) -> None: 82 | """Draw a handle with rounded corners.""" 83 | radius = size / 2.0 84 | lower_right = size - corner 85 | 86 | pi_05 = 0.5 * 3.142 87 | pi = 3.142 88 | pi_15 = 1.5 * 3.142 89 | 90 | cairo.save() 91 | cairo.identity_matrix() 92 | cairo.translate(vx - radius, vy - radius) 93 | 94 | cairo.move_to(0.0, corner) 95 | cairo.arc(corner, corner, corner, pi, pi_15) 96 | cairo.line_to(lower_right, 0.0) 97 | cairo.arc(lower_right, corner, corner, pi_15, 0) 98 | cairo.line_to(size, lower_right) 99 | cairo.arc(lower_right, lower_right, corner, 0, pi_05) 100 | cairo.line_to(corner, size) 101 | cairo.arc(corner, lower_right, corner, pi_05, pi) 102 | cairo.close_path() 103 | cairo.fill() 104 | cairo.restore() 105 | -------------------------------------------------------------------------------- /gaphas/painter/itempainter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Collection 4 | 5 | from cairo import LINE_JOIN_ROUND 6 | from cairo import Context as CairoContext 7 | 8 | from gaphas.item import DrawContext, Item 9 | from gaphas.selection import Selection 10 | 11 | 12 | class ItemPainter: 13 | def __init__(self, selection: Selection | None = None) -> None: 14 | self.selection = selection or Selection() 15 | 16 | def paint_item(self, item: Item, cairo: CairoContext) -> None: 17 | cairo.save() 18 | try: 19 | cairo.set_line_join(LINE_JOIN_ROUND) 20 | cairo.transform(item.matrix_i2c.to_cairo()) 21 | 22 | selection = self.selection 23 | item.draw( 24 | DrawContext( 25 | cairo=cairo, 26 | selected=(item in selection.selected_items), 27 | focused=(item is selection.focused_item), 28 | hovered=(item is selection.hovered_item), 29 | ) 30 | ) 31 | 32 | finally: 33 | cairo.restore() 34 | 35 | def paint(self, items: Collection[Item], cairo: CairoContext) -> None: 36 | """Draw the items.""" 37 | 38 | for item in items: 39 | self.paint_item(item, cairo) 40 | -------------------------------------------------------------------------------- /gaphas/painter/painter.py: -------------------------------------------------------------------------------- 1 | """The painter module provides different painters for parts of the canvas. 2 | 3 | Painters can be swapped in and out. 4 | 5 | Each painter takes care of a layer in the canvas (such as grid, items 6 | and handles). 7 | """ 8 | 9 | from typing import Collection, Protocol 10 | 11 | from cairo import Context as CairoContext 12 | 13 | from gaphas.item import Item 14 | 15 | 16 | class Painter(Protocol): 17 | """Painter interface.""" 18 | 19 | def paint(self, items: Collection[Item], cairo: CairoContext) -> None: 20 | """Do the paint action (called from the View).""" 21 | pass 22 | 23 | 24 | class ItemPainterType(Protocol): 25 | def paint_item(self, item: Item, cairo: CairoContext) -> None: 26 | """Draw a single item.""" 27 | 28 | def paint(self, items: Collection[Item], cairo: CairoContext) -> None: 29 | """Do the paint action (called from the View).""" 30 | pass 31 | -------------------------------------------------------------------------------- /gaphas/port.py: -------------------------------------------------------------------------------- 1 | """Basic connectors such as Ports and Handles.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING 6 | 7 | from gaphas.constraint import Constraint, LineConstraint, PositionConstraint 8 | from gaphas.geometry import distance_line_point, distance_point_point 9 | from gaphas.handle import Handle 10 | from gaphas.position import MatrixProjection, Position 11 | from gaphas.solver import MultiConstraint 12 | from gaphas.types import Pos, SupportsFloatPos 13 | 14 | if TYPE_CHECKING: 15 | from gaphas.item import Item 16 | 17 | 18 | class Port: 19 | """Port connectable part of an item. 20 | 21 | The Item's handle connects to a port. 22 | """ 23 | 24 | def __init__(self) -> None: 25 | super().__init__() 26 | 27 | self._connectable = True 28 | 29 | def _set_connectable(self, connectable: bool) -> None: 30 | self._connectable = connectable 31 | 32 | connectable = property(lambda s: s._connectable, _set_connectable) 33 | 34 | def glue(self, pos: SupportsFloatPos) -> tuple[Pos, float]: 35 | """Get glue point on the port and distance to the port.""" 36 | raise NotImplementedError("Glue method not implemented") 37 | 38 | def constraint(self, item: Item, handle: Handle, glue_item: Item) -> Constraint: 39 | """Create connection constraint between item's handle and glue item.""" 40 | raise NotImplementedError("Constraint method not implemented") 41 | 42 | 43 | class LinePort(Port): 44 | """Port defined as a line between two handles.""" 45 | 46 | def __init__(self, start: Position, end: Position) -> None: 47 | super().__init__() 48 | 49 | self.start = start 50 | self.end = end 51 | 52 | def glue(self, pos: SupportsFloatPos) -> tuple[Pos, float]: 53 | """Get glue point on the port and distance to the port. 54 | 55 | >>> p1, p2 = (0.0, 0.0), (100.0, 100.0) 56 | >>> port = LinePort(p1, p2) 57 | >>> port.glue((50, 50)) 58 | ((50.0, 50.0), 0.0) 59 | >>> port.glue((0, 10)) 60 | ((5.0, 5.0), 7.0710678118654755) 61 | """ 62 | d, pl = distance_line_point( 63 | self.start.tuple(), self.end.tuple(), (float(pos[0]), float(pos[1])) 64 | ) 65 | return pl, d 66 | 67 | def constraint(self, item: Item, handle: Handle, glue_item: Item) -> Constraint: 68 | """Create connection line constraint between item's handle and the 69 | port.""" 70 | start = MatrixProjection(self.start, glue_item.matrix_i2c) 71 | end = MatrixProjection(self.end, glue_item.matrix_i2c) 72 | point = MatrixProjection(handle.pos, item.matrix_i2c) 73 | line = LineConstraint((start.pos, end.pos), point.pos) 74 | return MultiConstraint(start, end, point, line) 75 | 76 | 77 | class PointPort(Port): 78 | """Port defined as a point.""" 79 | 80 | def __init__(self, point: Position) -> None: 81 | super().__init__() 82 | self.point = point 83 | 84 | def glue(self, pos: SupportsFloatPos) -> tuple[Pos, float]: 85 | """Get glue point on the port and distance to the port. 86 | 87 | >>> h = Handle((10, 10)) 88 | >>> port = PointPort(h.pos) 89 | >>> port.glue((10, 0)) 90 | (, 10.0) 91 | """ 92 | point: tuple[float, float] = self.point.pos # type: ignore[assignment] 93 | d = distance_point_point(point, (float(pos[0]), float(pos[1]))) 94 | return point, d 95 | 96 | def constraint( 97 | self, item: Item, handle: Handle, glue_item: Item 98 | ) -> MultiConstraint: 99 | """Return connection position constraint between item's handle and the 100 | port.""" 101 | origin = MatrixProjection(self.point, glue_item.matrix_i2c) 102 | point = MatrixProjection(handle.pos, item.matrix_i2c) 103 | c = PositionConstraint(origin.pos, point.pos) 104 | return MultiConstraint(origin, point, c) 105 | -------------------------------------------------------------------------------- /gaphas/position.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Callable, SupportsFloat 4 | 5 | from gaphas.matrix import Matrix 6 | from gaphas.solver import NORMAL, BaseConstraint, Variable 7 | from gaphas.types import Pos, SupportsFloatPos, TypedProperty 8 | 9 | 10 | class Position: 11 | """A point constructed of two `Variable`'s. 12 | 13 | >>> vp = Position(3, 5) 14 | >>> vp.x, vp.y 15 | (Variable(3, 20), Variable(5, 20)) 16 | >>> vp.pos 17 | (Variable(3, 20), Variable(5, 20)) 18 | >>> vp[0], vp[1] 19 | (Variable(3, 20), Variable(5, 20)) 20 | """ 21 | 22 | def __init__(self, x, y, strength=NORMAL): 23 | self._x = Variable(x, strength) 24 | self._y = Variable(y, strength) 25 | self._handlers: set[Callable[[Position, Pos], None]] = set() 26 | self._setting_pos = 0 27 | 28 | def add_handler(self, handler: Callable[[Position, Pos], None]) -> None: 29 | if not self._handlers: 30 | self._x.add_handler(self._propagate_x) 31 | self._y.add_handler(self._propagate_y) 32 | self._handlers.add(handler) 33 | 34 | def remove_handler(self, handler: Callable[[Position, Pos], None]) -> None: 35 | self._handlers.discard(handler) 36 | if not self._handlers: 37 | self._x.remove_handler(self._propagate_x) 38 | self._y.remove_handler(self._propagate_y) 39 | 40 | def notify(self, oldpos: Pos) -> None: 41 | for handler in self._handlers: 42 | handler(self, oldpos) 43 | 44 | def _propagate_x(self, variable, oldval): 45 | if not self._setting_pos: 46 | self.notify((oldval, self._y.value)) 47 | 48 | def _propagate_y(self, variable, oldval): 49 | if not self._setting_pos: 50 | self.notify((self._x.value, oldval)) 51 | 52 | @property 53 | def strength(self) -> int: 54 | """Strength.""" 55 | return self._x.strength 56 | 57 | def _set_x(self, v: SupportsFloat) -> None: 58 | self._x.value = v 59 | 60 | x: TypedProperty[Variable, SupportsFloat] 61 | x = property(lambda s: s._x, _set_x, doc="Position.x") 62 | 63 | def _set_y(self, v: SupportsFloat) -> None: 64 | self._y.value = v 65 | 66 | y: TypedProperty[Variable, SupportsFloat] 67 | y = property(lambda s: s._y, _set_y, doc="Position.y") 68 | 69 | def _set_pos(self, pos: Position | SupportsFloatPos) -> None: 70 | """Set handle position (Item coordinates).""" 71 | oldpos = (self._x.value, self._y.value) 72 | self._setting_pos += 1 73 | try: 74 | self._x.value, self._y.value = pos 75 | finally: 76 | self._setting_pos -= 1 77 | self.notify(oldpos) 78 | 79 | pos: TypedProperty[tuple[Variable, Variable], Position | SupportsFloatPos] 80 | pos = property(lambda s: (s._x, s._y), _set_pos, doc="The position.") 81 | 82 | def tuple(self) -> tuple[float, float]: 83 | return (self._x.value, self._y.value) 84 | 85 | def __str__(self): 86 | return f"<{self.__class__.__name__} object on ({self._x}, {self._y})>" 87 | 88 | __repr__ = __str__ 89 | 90 | def __getitem__(self, index): 91 | """Shorthand for returning the x(0) or y(1) component of the point. 92 | 93 | >>> h = Position(3, 5) 94 | >>> h[0] 95 | Variable(3, 20) 96 | >>> h[1] 97 | Variable(5, 20) 98 | """ 99 | return (self._x, self._y)[index] 100 | 101 | def __iter__(self): 102 | return iter((self._x, self._y)) 103 | 104 | def __eq__(self, other): 105 | return isinstance(other, Position) and self.x == other.x and self.y == other.y 106 | 107 | 108 | class MatrixProjection(BaseConstraint): 109 | def __init__(self, pos: Position, matrix: Matrix): 110 | proj_pos = Position(0, 0, pos.strength) 111 | super().__init__(proj_pos.x, proj_pos.y, pos.x, pos.y) 112 | 113 | self._orig_pos = pos 114 | self._proj_pos = proj_pos 115 | self.matrix = matrix 116 | self.solve_for(self._proj_pos.x) 117 | 118 | def add_handler(self, handler): 119 | """Add a callback handler.""" 120 | if not self._handlers: 121 | self.matrix.add_handler(self._on_matrix_changed) 122 | super().add_handler(handler) 123 | 124 | def remove_handler(self, handler): 125 | """Remove a previously assigned handler.""" 126 | super().remove_handler(handler) 127 | if not self._handlers: 128 | self.matrix.remove_handler(self._on_matrix_changed) 129 | 130 | @property 131 | def pos(self) -> Position: 132 | """The projected position.""" 133 | return self._proj_pos 134 | 135 | def _set_x(self, x): 136 | self._proj_pos.x = x 137 | 138 | x: TypedProperty[Variable, SupportsFloat] 139 | x = property( 140 | lambda s: s._proj_pos.x, _set_x, doc="The projected position's ``x`` part." 141 | ) 142 | 143 | def _set_y(self, y): 144 | self._proj_pos.y = y 145 | 146 | y: TypedProperty[Variable, SupportsFloat] 147 | y = property( 148 | lambda s: s._proj_pos.y, _set_y, doc="The projected position's ``y`` part." 149 | ) 150 | 151 | def mark_dirty(self, var): 152 | if var is self._orig_pos.x or var is self._orig_pos.y: 153 | super().mark_dirty(self._orig_pos.x) 154 | super().mark_dirty(self._orig_pos.y) 155 | else: 156 | super().mark_dirty(self._proj_pos.x) 157 | super().mark_dirty(self._proj_pos.y) 158 | 159 | def solve_for(self, var): 160 | if var is self._orig_pos.x or var is self._orig_pos.y: 161 | self._orig_pos.x, self._orig_pos.y = self.matrix.inverse().transform_point( 162 | *self._proj_pos 163 | ) 164 | else: 165 | self._proj_pos.x, self._proj_pos.y = self.matrix.transform_point( 166 | *self._orig_pos 167 | ) 168 | 169 | def _on_matrix_changed(self, matrix, _orig): 170 | self.mark_dirty(self._orig_pos.x) 171 | self.notify() 172 | 173 | def __getitem__(self, index): 174 | return self._proj_pos[index] 175 | -------------------------------------------------------------------------------- /gaphas/selection.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Callable, Collection 4 | 5 | from gaphas.item import Item 6 | 7 | 8 | class Selection: 9 | def __init__(self): 10 | super().__init__() 11 | self._selected_items: set[Item] = set() 12 | self._focused_item: Item | None = None 13 | self._hovered_item: Item | None = None 14 | self._handlers: set[Callable[[Item | None], None]] = set() 15 | 16 | def add_handler(self, handler: Callable[[Item | None], None]) -> None: 17 | """Add a callback handler, triggered when a constraint is resolved.""" 18 | self._handlers.add(handler) 19 | 20 | def remove_handler(self, handler: Callable[[Item | None], None]) -> None: 21 | """Remove a previously assigned handler.""" 22 | self._handlers.discard(handler) 23 | 24 | def notify(self, item: Item | None) -> None: 25 | for handler in self._handlers: 26 | handler(item) 27 | 28 | def clear(self): 29 | self._selected_items.clear() 30 | self._focused_item = None 31 | self._hovered_item = None 32 | self.notify(None) 33 | 34 | @property 35 | def selected_items(self) -> Collection[Item]: 36 | return self._selected_items 37 | 38 | def select_items(self, *items: Item) -> None: 39 | for item in items: 40 | if item not in self._selected_items: 41 | self._selected_items.add(item) 42 | self.notify(item) 43 | 44 | def unselect_item(self, item: Item) -> None: 45 | """Unselect an item. 46 | 47 | If it's focused, it will be unfocused as well. 48 | """ 49 | if item is self._focused_item: 50 | self._focused_item = None 51 | if item in self._selected_items: 52 | self._selected_items.discard(item) 53 | self.notify(item) 54 | 55 | def unselect_all(self) -> None: 56 | """Clearing the selected_item also clears the focused_item.""" 57 | for item in list(self._selected_items): 58 | self.unselect_item(item) 59 | self.focused_item = None 60 | 61 | @property 62 | def focused_item(self) -> Item | None: 63 | return self._focused_item 64 | 65 | @focused_item.setter 66 | def focused_item(self, item: Item | None) -> None: 67 | if item: 68 | self.select_items(item) 69 | 70 | if item is not self._focused_item: 71 | self._focused_item = item 72 | self.notify(item) 73 | 74 | @property 75 | def hovered_item(self) -> Item | None: 76 | return self._hovered_item 77 | 78 | @hovered_item.setter 79 | def hovered_item(self, item: Item | None) -> None: 80 | if item is not self._hovered_item: 81 | self._hovered_item = item 82 | self.notify(item) 83 | -------------------------------------------------------------------------------- /gaphas/solver/__init__.py: -------------------------------------------------------------------------------- 1 | from gaphas.solver.constraint import BaseConstraint, Constraint, MultiConstraint 2 | from gaphas.solver.solver import Solver 3 | from gaphas.solver.variable import ( 4 | NORMAL, 5 | REQUIRED, 6 | STRONG, 7 | VERY_STRONG, 8 | VERY_WEAK, 9 | WEAK, 10 | Variable, 11 | variable, 12 | ) 13 | -------------------------------------------------------------------------------- /gaphas/solver/constraint.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Callable, Collection, Hashable, Protocol, runtime_checkable 4 | 5 | from gaphas.solver.variable import Variable 6 | 7 | 8 | @runtime_checkable 9 | class Constraint(Protocol, Hashable): 10 | def add_handler(self, handler: Callable[[Constraint], None]) -> None: ... 11 | 12 | def remove_handler(self, handler: Callable[[Constraint], None]) -> None: ... 13 | 14 | def solve(self) -> None: ... 15 | 16 | 17 | @runtime_checkable 18 | class ContainsConstraints(Protocol): 19 | @property 20 | def constraints(self) -> Collection[Constraint]: ... 21 | 22 | 23 | class BaseConstraint: 24 | """Constraint base class. 25 | 26 | - variables - list of all variables 27 | - weakest - list of weakest variables 28 | """ 29 | 30 | def __init__(self, *variables): 31 | """Create new constraint, register all variables, and find weakest 32 | variables. 33 | 34 | Any value can be added. It is assumed to be a variable if it has 35 | a 'strength' attribute. 36 | """ 37 | self._variables = [v for v in variables if hasattr(v, "strength")] 38 | 39 | strength = min(v.strength for v in self._variables) 40 | # manage weakest based on id, so variables are uniquely identifiable 41 | self._weakest = [(id(v), v) for v in self._variables if v.strength == strength] 42 | self._handlers: set[Callable[[Constraint], None]] = set() 43 | 44 | def variables(self): 45 | """Return an iterator which iterates over the variables that are held 46 | by this constraint.""" 47 | return self._variables 48 | 49 | def add_handler(self, handler: Callable[[Constraint], None]) -> None: 50 | if not self._handlers: 51 | for v in self._variables: 52 | v.add_handler(self._propagate) 53 | self._handlers.add(handler) 54 | 55 | def remove_handler(self, handler: Callable[[Constraint], None]) -> None: 56 | self._handlers.discard(handler) 57 | if not self._handlers: 58 | for v in self._variables: 59 | v.remove_handler(self._propagate) 60 | 61 | def notify(self): 62 | for handler in self._handlers: 63 | handler(self) 64 | 65 | def _propagate(self, variable, _old): 66 | self.mark_dirty(variable) 67 | self.notify() 68 | 69 | def weakest(self): 70 | """Return the weakest variable. 71 | 72 | The weakest variable should be always as first element of 73 | Constraint._weakest list. 74 | """ 75 | return self._weakest[0][1] 76 | 77 | def mark_dirty(self, var: Variable) -> None: 78 | """Mark variable dirty and if possible move it to the end of 79 | Constraint.weakest list to maintain weakest variable invariants (see 80 | gaphas.solver module documentation).""" 81 | 82 | if isinstance(var, Variable): 83 | key = (id(var), var) 84 | try: 85 | self._weakest.remove(key) 86 | except ValueError: 87 | return 88 | else: 89 | self._weakest.append(key) 90 | return 91 | 92 | def solve(self): 93 | """Solve the constraint. 94 | 95 | This is done by determining the weakest variable and calling 96 | solve_for() for that variable. The weakest variable is always in 97 | the set of variables with the weakest strength. The least 98 | recently changed variable is considered the weakest. 99 | """ 100 | wvar = self.weakest() 101 | self.solve_for(wvar) 102 | 103 | def solve_for(self, var): 104 | """Solve the constraint for a given variable. 105 | 106 | The variable itself is updated. 107 | """ 108 | raise NotImplementedError 109 | 110 | 111 | class MultiConstraint: 112 | """A constraint containing constraints.""" 113 | 114 | def __init__(self, *constraints: Constraint): 115 | self._constraints = constraints 116 | 117 | @property 118 | def constraints(self) -> Collection[Constraint]: 119 | return self._constraints 120 | 121 | def add_handler(self, handler: Callable[[Constraint], None]) -> None: 122 | for c in self._constraints: 123 | c.add_handler(handler) 124 | 125 | def remove_handler(self, handler: Callable[[Constraint], None]) -> None: 126 | for c in self._constraints: 127 | c.remove_handler(handler) 128 | 129 | def solve(self): 130 | for c in self._constraints: 131 | c.solve() 132 | -------------------------------------------------------------------------------- /gaphas/tool/__init__.py: -------------------------------------------------------------------------------- 1 | """Tools provide interactive behavior to a `View` by handling specific events 2 | sent by view. 3 | 4 | Some of implemented tools are 5 | 6 | `HoverTool` 7 | make the item under the mouse cursor the "hovered item" 8 | 9 | `ItemTool` 10 | handle selection and movement of items 11 | 12 | `HandleTool` 13 | handle selection and movement of handles 14 | 15 | `RubberbandTool` 16 | for rubber band selection of multiple items 17 | 18 | `PanTool` 19 | for easily moving the canvas around 20 | 21 | `PlacementTool` 22 | for placing items on the canvas 23 | 24 | The tools are chained with `ToolChain` class (it is a tool as well), 25 | which allows to combine functionality provided by different tools. 26 | 27 | Tools can handle events in different ways 28 | 29 | - event can be ignored 30 | - tool can handle the event (obviously) 31 | """ 32 | from gaphas.tool.hover import hover_tool 33 | from gaphas.tool.itemtool import item_tool 34 | from gaphas.tool.placement import placement_tool 35 | from gaphas.tool.rubberband import rubberband_tool 36 | from gaphas.tool.scroll import scroll_tool, scroll_tools 37 | from gaphas.tool.viewfocus import view_focus_tool 38 | from gaphas.tool.zoom import zoom_tool, zoom_tools 39 | -------------------------------------------------------------------------------- /gaphas/tool/hover.py: -------------------------------------------------------------------------------- 1 | from gi.repository import Gdk, Gtk 2 | 3 | from gaphas.cursor import cursor 4 | from gaphas.tool.itemtool import find_item_and_handle_at_point 5 | 6 | 7 | def hover_tool() -> Gtk.EventController: 8 | """Highlight the currently hovered item.""" 9 | ctrl = Gtk.EventControllerMotion.new() 10 | ctrl.connect("motion", on_motion) 11 | ctrl.cursor_name = "" 12 | return ctrl 13 | 14 | 15 | def on_motion(ctrl, x, y): 16 | view = ctrl.get_widget() 17 | pos = (x, y) 18 | item, handle = find_item_and_handle_at_point(view, pos) 19 | view.selection.hovered_item = item 20 | 21 | if item: 22 | v2i = view.get_matrix_v2i(item) 23 | pos = v2i.transform_point(x, y) 24 | 25 | cursor_name = cursor(item, handle, pos) 26 | if cursor_name != ctrl.cursor_name: 27 | set_cursor(view, cursor_name) 28 | ctrl.cursor_name = cursor_name 29 | 30 | 31 | def set_cursor(view, cursor_name): 32 | cursor = Gdk.Cursor.new_from_name(cursor_name) 33 | view.set_cursor(cursor) 34 | -------------------------------------------------------------------------------- /gaphas/tool/placement.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Callable 4 | 5 | from gi.repository import Gtk 6 | 7 | from gaphas.handlemove import HandleMove 8 | from gaphas.item import Item 9 | from gaphas.move import MoveType 10 | 11 | FactoryType = Callable[[], Item] 12 | 13 | 14 | def placement_tool(factory: FactoryType, handle_index: int) -> Gtk.GestureDrag: 15 | """Place a new item on the model.""" 16 | gesture = Gtk.GestureDrag.new() 17 | placement_state = PlacementState(factory, handle_index) 18 | gesture.connect("drag-begin", on_drag_begin, placement_state) 19 | gesture.connect("drag-update", on_drag_update, placement_state) 20 | gesture.connect("drag-end", on_drag_end, placement_state) 21 | return gesture 22 | 23 | 24 | class PlacementState: 25 | def __init__(self, factory: FactoryType, handle_index: int): 26 | self.factory = factory 27 | self.handle_index = handle_index 28 | self.moving: MoveType | None = None 29 | self.start_x = 0 30 | self.start_y = 0 31 | 32 | 33 | def on_drag_begin(gesture, start_x, start_y, placement_state): 34 | view = gesture.get_widget() 35 | item = placement_state.factory() 36 | x, y = view.get_matrix_v2i(item).transform_point(start_x, start_y) 37 | item.matrix.translate(x, y) 38 | view.selection.unselect_all() 39 | view.selection.focused_item = item 40 | 41 | gesture.set_state(Gtk.EventSequenceState.CLAIMED) 42 | 43 | handle = item.handles()[placement_state.handle_index] 44 | if handle.movable: 45 | placement_state.start_x = start_x 46 | placement_state.start_y = start_y 47 | placement_state.moving = HandleMove(item, handle, view) 48 | placement_state.moving.start_move((start_x, start_y)) 49 | 50 | 51 | def on_drag_update(gesture, offset_x, offset_y, placement_state): 52 | if placement_state.moving: 53 | placement_state.moving.move( 54 | (placement_state.start_x + offset_x, placement_state.start_y + offset_y) 55 | ) 56 | 57 | 58 | def on_drag_end(gesture, offset_x, offset_y, placement_state): 59 | if placement_state.moving: 60 | placement_state.moving.stop_move( 61 | (placement_state.start_x + offset_x, placement_state.start_y + offset_y) 62 | ) 63 | -------------------------------------------------------------------------------- /gaphas/tool/rubberband.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection 2 | 3 | from cairo import Context as CairoContext 4 | from gi.repository import Gtk 5 | 6 | from gaphas.item import Item 7 | 8 | 9 | class RubberbandState: 10 | def __init__(self) -> None: 11 | self.reset() 12 | 13 | def reset(self) -> None: 14 | self.x0 = self.y0 = self.x1 = self.y1 = 0 15 | 16 | 17 | class RubberbandPainter: 18 | """The rubberband painter should be used in conjunction with the rubberband 19 | tool. 20 | 21 | ``RubberbandState`` should be shared between the two. 22 | """ 23 | 24 | def __init__(self, rubberband_state: RubberbandState) -> None: 25 | self.rubberband_state = rubberband_state 26 | 27 | def paint(self, items: Collection[Item], cairo: CairoContext) -> None: 28 | data = self.rubberband_state 29 | x0, y0, x1, y1 = data.x0, data.y0, data.x1, data.y1 30 | if x0 != x1 or y0 != y1: 31 | cairo.identity_matrix() 32 | cairo.rectangle(min(x0, x1), min(y0, y1), abs(x1 - x0), abs(y1 - y0)) 33 | cairo.set_source_rgba(0.9, 0.9, 0.9, 0.3) 34 | cairo.fill_preserve() 35 | cairo.set_line_width(2.0) 36 | cairo.set_dash((7.0, 5.0)) 37 | cairo.set_source_rgba(0.5, 0.5, 0.7, 0.7) 38 | cairo.stroke() 39 | 40 | 41 | def rubberband_tool(rubberband_state): 42 | """Rubberband selection tool. 43 | 44 | Should be used in conjunction with ``RubberbandPainter``. 45 | """ 46 | gesture = Gtk.GestureDrag.new() 47 | gesture.connect("drag-begin", on_drag_begin, rubberband_state) 48 | gesture.connect("drag-update", on_drag_update, rubberband_state) 49 | gesture.connect("drag-end", on_drag_end, rubberband_state) 50 | return gesture 51 | 52 | 53 | def on_drag_begin(gesture, start_x, start_y, rubberband_state): 54 | if gesture.set_state(Gtk.EventSequenceState.CLAIMED): 55 | rubberband_state.x0 = rubberband_state.x1 = start_x 56 | rubberband_state.y0 = rubberband_state.y1 = start_y 57 | 58 | 59 | def on_drag_update(gesture, offset_x, offset_y, rubberband_state): 60 | rubberband_state.x1 = rubberband_state.x0 + offset_x 61 | rubberband_state.y1 = rubberband_state.y0 + offset_y 62 | view = gesture.get_widget() 63 | view.update_back_buffer() 64 | 65 | 66 | def on_drag_end(gesture, offset_x, offset_y, rubberband_state): 67 | view = gesture.get_widget() 68 | x0 = rubberband_state.x0 69 | y0 = rubberband_state.y0 70 | x1 = x0 + offset_x 71 | y1 = y0 + offset_y 72 | items = view.get_items_in_rectangle( 73 | (min(x0, x1), min(y0, y1), abs(x1 - x0), abs(y1 - y0)), contain=True 74 | ) 75 | view.selection.select_items(*items) 76 | rubberband_state.reset() 77 | view.update_back_buffer() 78 | -------------------------------------------------------------------------------- /gaphas/tool/scroll.py: -------------------------------------------------------------------------------- 1 | from gi.repository import Gdk, Gtk 2 | 3 | from gaphas.tool.zoom import Zoom 4 | from gaphas.tool.hover import set_cursor 5 | 6 | 7 | def scroll_tools(speed: int = 10) -> Gtk.EventControllerScroll: 8 | return scroll_tool(speed), pan_tool() 9 | 10 | 11 | def scroll_tool(speed: int = 10) -> Gtk.EventControllerScroll: 12 | """Scroll tool recognized 2 finger scroll gestures.""" 13 | ctrl = Gtk.EventControllerScroll.new(Gtk.EventControllerScrollFlags.BOTH_AXES) 14 | ctrl.connect("scroll", on_scroll, speed) 15 | return ctrl 16 | 17 | 18 | def on_scroll(controller, dx, dy, speed): 19 | view = controller.get_widget() 20 | 21 | modifiers = controller.get_current_event_state() 22 | 23 | if modifiers & Gdk.ModifierType.CONTROL_MASK: 24 | # Workaround: Gtk.EventController.get_current_event() causes SEGFAULT 25 | view = controller.get_widget() 26 | x = view.get_width() / 2 27 | y = view.get_height() / 2 28 | zoom = Zoom() 29 | zoom.begin(view.matrix, x, y) 30 | 31 | zoom_factor = 0.1 32 | d = 1 - dy * zoom_factor 33 | zoom.update(d) 34 | elif modifiers & Gdk.ModifierType.SHIFT_MASK: 35 | view.hadjustment.set_value(dy * speed - view.hadjustment.get_value()) 36 | view.vadjustment.set_value(dx * speed - view.vadjustment.get_value()) 37 | else: 38 | view.hadjustment.set_value(dx * speed - view.hadjustment.get_value()) 39 | view.vadjustment.set_value(dy * speed - view.vadjustment.get_value()) 40 | 41 | 42 | class PanState: 43 | def __init__(self): 44 | self.reset() 45 | 46 | def reset(self): 47 | self.h = 0 48 | self.v = 0 49 | 50 | 51 | def pan_tool() -> Gtk.GestureDrag: 52 | gesture = Gtk.GestureDrag.new() 53 | gesture.set_button(Gdk.BUTTON_MIDDLE) 54 | pan_state = PanState() 55 | gesture.connect("drag-begin", on_drag_begin, pan_state) 56 | gesture.connect("drag-update", on_drag_update, pan_state) 57 | gesture.connect("drag-end", on_drag_end) 58 | return gesture 59 | 60 | 61 | def on_drag_begin(gesture, start_x, start_y, pan_state): 62 | view = gesture.get_widget() 63 | pan_state.h = view.matrix[4] 64 | pan_state.v = view.matrix[5] 65 | set_cursor(view, "move") 66 | gesture.set_state(Gtk.EventSequenceState.CLAIMED) 67 | 68 | 69 | def on_drag_update(gesture, offset_x, offset_y, pan_state): 70 | view = gesture.get_widget() 71 | view.matrix.set(x0=pan_state.h + offset_x, y0=pan_state.v + offset_y) 72 | set_cursor(view, "move") 73 | 74 | 75 | def on_drag_end(gesture, _offset_x, _offset_y): 76 | view = gesture.get_widget() 77 | set_cursor(view, "default") 78 | -------------------------------------------------------------------------------- /gaphas/tool/viewfocus.py: -------------------------------------------------------------------------------- 1 | from gi.repository import Gtk 2 | 3 | 4 | def view_focus_tool(): 5 | """This little tool ensures the view grabs focus when a mouse press or 6 | touch event happens.""" 7 | gesture = Gtk.GestureSingle() 8 | gesture.connect("begin", on_begin) 9 | return gesture 10 | 11 | 12 | def on_begin(gesture, sequence): 13 | view = gesture.get_widget() 14 | if not view.is_focus(): 15 | view.grab_focus() 16 | -------------------------------------------------------------------------------- /gaphas/tool/zoom.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from gi.repository import Gdk, Gtk 4 | 5 | 6 | class Zoom: 7 | def __init__(self): 8 | self.matrix = None 9 | self.x0 = 0 10 | self.y0 = 0 11 | self.sx = 1.0 12 | self.sy = 1.0 13 | 14 | def begin(self, matrix, x0, y0): 15 | self.matrix = matrix 16 | self.x0 = x0 17 | self.y0 = y0 18 | self.sx = matrix[0] 19 | self.sy = matrix[3] 20 | 21 | def update(self, scale): 22 | assert self.matrix 23 | if self.sx * scale < 0.2: 24 | scale = 0.2 / self.sx 25 | elif self.sx * scale > 20.0: 26 | scale = 20.0 / self.sx 27 | 28 | m = self.matrix 29 | sx = m[0] 30 | sy = m[3] 31 | ox = (m[4] - self.x0) / sx 32 | oy = (m[5] - self.y0) / sy 33 | dsx = self.sx * scale / sx 34 | dsy = self.sy * scale / sy 35 | m.translate(-ox, -oy) 36 | m.scale(dsx, dsy) 37 | m.translate(+ox, +oy) 38 | 39 | 40 | def zoom_tools() -> ( 41 | tuple[Gtk.GestureZoom] | tuple[Gtk.GestureZoom, Gtk.EventControllerScroll] 42 | ): 43 | return zoom_tool(), scroll_zoom_tool() 44 | 45 | 46 | def zoom_tool() -> Gtk.GestureZoom: 47 | """Create a zoom tool as a Gtk.Gesture. 48 | 49 | Note: we need to keep a reference to this gesture, or else it will be destroyed. 50 | """ 51 | zoom = Zoom() 52 | gesture = Gtk.GestureZoom.new() 53 | gesture.set_propagation_phase(Gtk.PropagationPhase.CAPTURE) 54 | gesture.connect("begin", on_begin, zoom) 55 | gesture.connect("scale-changed", on_scale_changed, zoom) 56 | return gesture 57 | 58 | 59 | def on_begin( 60 | gesture: Gtk.GestureZoom, 61 | sequence: None, 62 | zoom: Zoom, 63 | ) -> None: 64 | view = gesture.get_widget() 65 | _, x0, y0 = gesture.get_point(sequence) 66 | zoom.begin(view.matrix, x0, y0) 67 | 68 | 69 | def on_scale_changed(_gesture: Gtk.GestureZoom, scale: float, zoom: Zoom) -> None: 70 | zoom.update(scale) 71 | 72 | 73 | def scroll_zoom_tool() -> Gtk.EventControllerScroll: 74 | """Ctrl-scroll wheel zoom.""" 75 | ctrl = Gtk.EventControllerScroll.new(Gtk.EventControllerScrollFlags.BOTH_AXES) 76 | ctrl.connect("scroll", on_scroll) 77 | return ctrl 78 | 79 | 80 | def on_scroll(controller, _dx, dy): 81 | view = controller.get_widget() 82 | 83 | modifiers = controller.get_current_event_state() 84 | 85 | if not modifiers & Gdk.ModifierType.CONTROL_MASK: 86 | return False 87 | 88 | # Workaround: Gtk.EventController.get_current_event() causes SEGFAULT 89 | view = controller.get_widget() 90 | x = view.get_width() / 2 91 | y = view.get_height() / 2 92 | zoom = Zoom() 93 | zoom.begin(view.matrix, x, y) 94 | 95 | zoom_factor = 0.1 96 | d = 1 - dy * zoom_factor 97 | zoom.update(d) 98 | return True 99 | -------------------------------------------------------------------------------- /gaphas/types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Protocol, SupportsFloat, TypeVar 4 | 5 | # A primitive position, tuple ``(x, y)`` 6 | # Pos = Tuple[Union[float, SupportsFloat], Union[float, SupportsFloat]] 7 | Pos = tuple[float, float] 8 | SupportsFloatPos = tuple[SupportsFloat, SupportsFloat] 9 | 10 | GetT = TypeVar("GetT", covariant=True) 11 | SetT = TypeVar("SetT", contravariant=True) 12 | 13 | 14 | class TypedProperty(Protocol[GetT, SetT]): 15 | def __get__(self, obj: object, type: type | None = ...) -> GetT: ... 16 | 17 | def __set__(self, obj: object, value: SetT) -> None: ... 18 | 19 | def __delete__(self, obj: object) -> None: ... 20 | -------------------------------------------------------------------------------- /gaphas/view/__init__.py: -------------------------------------------------------------------------------- 1 | """This package contains everything to display a Canvas on a screen.""" 2 | 3 | from gaphas import model 4 | from gaphas.selection import Selection 5 | from gaphas.view.gtkview import GtkView 6 | -------------------------------------------------------------------------------- /gaphas/view/scrolling.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from math import isclose 4 | from typing import Callable 5 | 6 | from gi.repository import Gtk 7 | 8 | from gaphas.geometry import Rectangle 9 | 10 | 11 | class Scrolling: 12 | """Contains Gtk.Adjustment and related code.""" 13 | 14 | def __init__( 15 | self, scrolling_updated: Callable[[float | None, float | None], None] 16 | ) -> None: 17 | self._scrolling_updated = scrolling_updated 18 | self.hadjustment: Gtk.Adjustment | None = None 19 | self.vadjustment: Gtk.Adjustment | None = None 20 | self.hscroll_policy: Gtk.ScrollablePolicy | None = None 21 | self.vscroll_policy: Gtk.ScrollablePolicy | None = None 22 | self._hadjustment_handler_id = 0 23 | self._vadjustment_handler_id = 0 24 | 25 | def get_property(self, prop): 26 | if prop.name == "hadjustment": 27 | return self.hadjustment 28 | elif prop.name == "vadjustment": 29 | return self.vadjustment 30 | elif prop.name == "hscroll-policy": 31 | return self.hscroll_policy 32 | elif prop.name == "vscroll-policy": 33 | return self.vscroll_policy 34 | else: 35 | raise AttributeError(f"Unknown property {prop.name}") 36 | 37 | def set_property(self, prop, value): 38 | if prop.name == "hadjustment": 39 | if value is not None: 40 | if self.hadjustment and self._hadjustment_handler_id: 41 | self.hadjustment.disconnect(self._hadjustment_handler_id) 42 | self.hadjustment = value 43 | self._hadjustment_handler_id = self.hadjustment.connect( 44 | "value-changed", self.on_hadjustment_changed 45 | ) 46 | elif prop.name == "vadjustment": 47 | if value is not None: 48 | if self.vadjustment and self._vadjustment_handler_id: 49 | self.vadjustment.disconnect(self._vadjustment_handler_id) 50 | self.vadjustment = value 51 | self._vadjustment_handler_id = self.vadjustment.connect( 52 | "value-changed", self.on_vadjustment_changed 53 | ) 54 | elif prop.name == "hscroll-policy": 55 | self.hscroll_policy = value 56 | elif prop.name == "vscroll-policy": 57 | self.vscroll_policy = value 58 | else: 59 | raise AttributeError(f"Unknown property {prop.name}") 60 | 61 | def update_position(self, x: float, y: float) -> None: 62 | if self.hadjustment and not isclose(self.hadjustment.get_value(), x): 63 | self.hadjustment.handler_block(self._hadjustment_handler_id) 64 | self.hadjustment.set_value(-x) 65 | self.hadjustment.handler_unblock(self._hadjustment_handler_id) 66 | 67 | if self.vadjustment and not isclose(self.vadjustment.get_value(), y): 68 | self.vadjustment.handler_block(self._vadjustment_handler_id) 69 | self.vadjustment.set_value(-y) 70 | self.vadjustment.handler_unblock(self._vadjustment_handler_id) 71 | 72 | def update_adjustments(self, width: int, height: int, bounds: Rectangle) -> None: 73 | """Update scroll bar values (adjustments in GTK). 74 | 75 | The value will change when a scroll bar is moved. 76 | """ 77 | # canvas limits (in view coordinates) 78 | c = Rectangle(*bounds) 79 | c.expand(min(width, height) / 2) 80 | u = c + Rectangle(width=width, height=height) 81 | 82 | if self.hadjustment: 83 | self.hadjustment.set_lower(u.x) 84 | self.hadjustment.set_upper(u.x1) 85 | self.hadjustment.set_step_increment(width // 10) 86 | self.hadjustment.set_page_increment(width) 87 | self.hadjustment.set_page_size(width) 88 | 89 | if self.vadjustment: 90 | self.vadjustment.set_lower(u.y) 91 | self.vadjustment.set_upper(u.y1) 92 | self.vadjustment.set_step_increment(height // 10) 93 | self.vadjustment.set_page_increment(height) 94 | self.vadjustment.set_page_size(height) 95 | 96 | def on_hadjustment_changed(self, adj): 97 | self._scrolling_updated(-adj.get_value(), None) 98 | 99 | def on_vadjustment_changed(self, adj): 100 | self._scrolling_updated(None, -adj.get_value()) 101 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "gaphas" 3 | version = "5.0.3" 4 | description="Gaphas is a GTK diagramming widget" 5 | authors = [ 6 | { name = "Arjan Molenaar", email = "gaphor@gmail.com" }, 7 | { name = "Dan Yeaw", email = "dan@yeaw.me" }, 8 | ] 9 | 10 | readme = "README.md" 11 | 12 | keywords = ["gtk", "diagram", "gaphas"] 13 | 14 | classifiers = [ 15 | "Development Status :: 5 - Production/Stable", 16 | "Environment :: X11 Applications :: GTK", 17 | "Intended Audience :: Developers", 18 | "License :: OSI Approved :: Apache Software License", 19 | "Programming Language :: Python", 20 | "Topic :: Software Development :: Libraries :: Python Modules" 21 | ] 22 | 23 | requires-python = ">=3.9,<4.0" 24 | 25 | dependencies = [ 26 | "PyGObject>=3.50", 27 | "pycairo>=1.20.0", 28 | ] 29 | 30 | [project.urls] 31 | homepage = "https://gaphas.readthedocs.io/" 32 | repository = "https://github.com/gaphor/gaphas" 33 | documentation = "https://gaphas.readthedocs.io/" 34 | 35 | [tool.poetry] 36 | requires-poetry = ">=2.0" 37 | 38 | [tool.poetry.group.dev.dependencies] 39 | pytest = ">=8.3" 40 | pytest-cov = ">=5" 41 | pytest-archon = ">=0.0.6" 42 | pytest-asyncio = ">=0.23.8" 43 | 44 | [tool.poetry.group.docs] 45 | optional=true 46 | 47 | [tool.poetry.group.docs.dependencies] 48 | sphinx = ">=4.3,<8.0" 49 | furo = ">=2022,<2025" 50 | 51 | [tool.pytest.ini_options] 52 | testpaths = ["tests"] 53 | addopts = ["--import-mode=importlib"] 54 | 55 | [tool.coverage.run] 56 | source = ["gaphas"] 57 | 58 | [tool.mypy] 59 | python_version = 3.9 60 | warn_return_any = true 61 | warn_unused_configs = true 62 | warn_redundant_casts = true 63 | check_untyped_defs = true 64 | strict_optional = true 65 | disallow_any_explicit = true 66 | show_error_codes = true 67 | ignore_missing_imports=true 68 | namespace_packages = true 69 | explicit_package_bases = true 70 | warn_unused_ignores = true 71 | 72 | [[tool.mypy.overrides]] 73 | module = [ 74 | "cairo", 75 | "gi.*", 76 | "hotshot.*", 77 | ] 78 | ignore_missing_imports = true 79 | warn_unreachable = true 80 | 81 | [[tool.mypy.overrides]] 82 | module = "gaphas.*" 83 | disallow_incomplete_defs = true 84 | 85 | [tool.ruff] 86 | exclude = [ 87 | ".venv", 88 | "dist", 89 | "__init__.py", 90 | ] 91 | line-length = 88 92 | [tool.ruff.lint] 93 | ignore = ["E501", "B905", "B019"] 94 | select = [ 95 | "B", 96 | "B9", 97 | "C", 98 | "E", 99 | "F", 100 | "W", 101 | ] 102 | 103 | [tool.ruff.lint.mccabe] 104 | max-complexity = 18 105 | 106 | [tool.codespell] 107 | skip = 'README.md, CODE_OF_CONDUCT.md' 108 | 109 | [build-system] 110 | requires = ["poetry-core>=2.0.0,<3.0.0"] 111 | build-backend = "poetry.core.masonry.api" 112 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: F402, E402 2 | 3 | import gi 4 | 5 | gi.require_version("Gdk", "4.0") 6 | gi.require_version("Gtk", "4.0") 7 | 8 | 9 | import pytest 10 | import pytest_asyncio 11 | from gi.repository import Gtk 12 | 13 | from gaphas.canvas import Canvas 14 | from gaphas.item import Element, Line 15 | from gaphas.view import GtkView 16 | 17 | 18 | class Box(Element): 19 | def draw(self, context): 20 | cr = context.cairo 21 | top_left = self.handles()[0].pos 22 | cr.rectangle(top_left.x, top_left.y, self.width, self.height) 23 | cr.stroke() 24 | 25 | 26 | @pytest.fixture 27 | def canvas(): 28 | return Canvas() 29 | 30 | 31 | @pytest.fixture 32 | def connections(canvas): 33 | return canvas.connections 34 | 35 | 36 | @pytest_asyncio.fixture 37 | async def view(canvas): 38 | view = GtkView(canvas) 39 | await view.update() 40 | return view 41 | 42 | 43 | @pytest_asyncio.fixture 44 | async def scrolled_window(view): 45 | scrolled_window = Gtk.ScrolledWindow() 46 | scrolled_window.set_child(view) 47 | await view.update() 48 | return scrolled_window 49 | 50 | 51 | @pytest.fixture 52 | def window(view): 53 | window = Gtk.Window.new() 54 | window.set_child(view) 55 | yield window 56 | window.destroy() 57 | 58 | 59 | @pytest_asyncio.fixture 60 | async def box(canvas, connections, view): 61 | box = Box(connections) 62 | canvas.add(box) 63 | await view.update() 64 | return box 65 | 66 | 67 | @pytest.fixture 68 | def line(canvas, connections): 69 | line = Line(connections) 70 | line.tail.pos = (100, 100) 71 | canvas.add(line) 72 | return line 73 | 74 | 75 | @pytest.fixture 76 | def handler(): 77 | events = [] 78 | 79 | def handler(*args): 80 | events.append(args) 81 | 82 | handler.events = events # type: ignore[attr-defined] 83 | return handler 84 | -------------------------------------------------------------------------------- /tests/test_architecture.py: -------------------------------------------------------------------------------- 1 | import gaphas 2 | 3 | 4 | def test_gtk_dependency(archrule): 5 | archrule("GTK dependency").match("gaphas*").exclude( 6 | "gaphas.tool*", "gaphas.view*" 7 | ).should_not_import("gi.repository.Gdk", "gi.repository.Gtk").check( 8 | gaphas, skip_type_checking=True 9 | ) 10 | -------------------------------------------------------------------------------- /tests/test_canvas.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from gaphas.canvas import Canvas 4 | from gaphas.connections import ConnectionError 5 | from gaphas.connector import ConnectionSink, Connector 6 | from gaphas.item import Element as Box 7 | from gaphas.item import Line 8 | from gaphas.matrix import Matrix 9 | from gaphas.model import Model 10 | 11 | 12 | def test_canvas_is_a_view_model(canvas): 13 | assert isinstance(canvas, Model) 14 | 15 | 16 | def test_update_matrices(): 17 | """Test updating of matrices.""" 18 | c = Canvas() 19 | i = Box(c.connections) 20 | ii = Box(c.connections) 21 | c.add(i) 22 | c.add(ii, i) 23 | 24 | i.matrix.translate(5.0, 0.0) 25 | ii.matrix.translate(0.0, 8.0) 26 | 27 | assert c.get_matrix_i2c(i) == Matrix(1, 0, 0, 1, 5, 0) 28 | assert c.get_matrix_i2c(ii) == Matrix(1, 0, 0, 1, 5, 8) 29 | 30 | 31 | def test_reparent(): 32 | c = Canvas() 33 | b1 = Box(c.connections) 34 | b2 = Box(c.connections) 35 | c.add(b1) 36 | c.add(b2, b1) 37 | c.reparent(b2, None) 38 | 39 | 40 | def count(i): 41 | return len(list(i)) 42 | 43 | 44 | def test_connect_item(): 45 | c = Canvas() 46 | b1 = Box(c.connections) 47 | b2 = Box(c.connections) 48 | line = Line(c.connections) 49 | c.add(b1) 50 | c.add(b2) 51 | c.add(line) 52 | 53 | c.connections.connect_item(line, line.handles()[0], b1, b1.ports()[0]) 54 | assert count(c.connections.get_connections(handle=line.handles()[0])) == 1 55 | 56 | # Add the same 57 | with pytest.raises(ConnectionError): 58 | c.connections.connect_item(line, line.handles()[0], b1, b1.ports()[0]) 59 | assert count(c.connections.get_connections(handle=line.handles()[0])) == 1 60 | 61 | 62 | def test_disconnect_item_with_callback(): 63 | c = Canvas() 64 | b1 = Box(c.connections) 65 | b2 = Box(c.connections) 66 | line = Line(c.connections) 67 | c.add(b1) 68 | c.add(b2) 69 | c.add(line) 70 | 71 | events = [] 72 | 73 | def callback(*args): 74 | events.append("called") 75 | 76 | c.connections.connect_item( 77 | line, line.handles()[0], b1, b1.ports()[0], callback=callback 78 | ) 79 | assert count(c.connections.get_connections(handle=line.handles()[0])) == 1 80 | 81 | c.connections.disconnect_item(line, line.handles()[0]) 82 | assert count(c.connections.get_connections(handle=line.handles()[0])) == 0 83 | assert events == ["called"] 84 | 85 | 86 | def test_disconnect_item_with_constraint(): 87 | c = Canvas() 88 | b1 = Box(c.connections) 89 | b2 = Box(c.connections) 90 | line = Line(c.connections) 91 | c.add(b1) 92 | c.add(b2) 93 | c.add(line) 94 | 95 | cons = b1.ports()[0].constraint(line, line.handles()[0], b1) 96 | 97 | c.connections.connect_item( 98 | line, line.handles()[0], b1, b1.ports()[0], constraint=cons 99 | ) 100 | assert count(c.connections.get_connections(handle=line.handles()[0])) == 1 101 | 102 | assert len(c.solver.constraints) == 13 103 | 104 | c.connections.disconnect_item(line, line.handles()[0]) 105 | assert count(c.connections.get_connections(handle=line.handles()[0])) == 0 106 | 107 | assert len(c.solver.constraints) == 12 108 | 109 | 110 | def test_disconnect_item_by_deleting_element(): 111 | c = Canvas() 112 | b1 = Box(c.connections) 113 | b2 = Box(c.connections) 114 | line = Line(c.connections) 115 | c.add(b1) 116 | c.add(b2) 117 | c.add(line) 118 | 119 | events = [] 120 | 121 | def callback(*args): 122 | events.append("called") 123 | 124 | c.connections.connect_item( 125 | line, line.handles()[0], b1, b1.ports()[0], callback=callback 126 | ) 127 | assert count(c.connections.get_connections(handle=line.handles()[0])) == 1 128 | 129 | c.remove(b1) 130 | 131 | assert count(c.connections.get_connections(handle=line.handles()[0])) == 0 132 | assert events == ["called"] 133 | 134 | 135 | def test_disconnect_item_with_constraint_by_deleting_element(): 136 | c = Canvas() 137 | b1 = Box(c.connections) 138 | b2 = Box(c.connections) 139 | line = Line(c.connections) 140 | c.add(b1) 141 | c.add(b2) 142 | c.add(line) 143 | 144 | cons = b1.ports()[0].constraint(line, line.handles()[0], b1) 145 | 146 | c.connections.connect_item( 147 | line, line.handles()[0], b1, b1.ports()[0], constraint=cons 148 | ) 149 | assert count(c.connections.get_connections(handle=line.handles()[0])) == 1 150 | 151 | ncons = len(c.solver.constraints) 152 | assert ncons == 13 153 | 154 | c.remove(b1) 155 | 156 | assert count(c.connections.get_connections(handle=line.handles()[0])) == 0 157 | 158 | assert 6 == len(c.solver.constraints) 159 | 160 | 161 | def test_remove_connected_item(): 162 | """Test adding canvas constraint.""" 163 | canvas = Canvas() 164 | 165 | l1 = Line(canvas.connections) 166 | canvas.add(l1) 167 | 168 | b1 = Box(canvas.connections) 169 | canvas.add(b1) 170 | 171 | number_cons1 = len(canvas.solver.constraints) 172 | 173 | b2 = Box(canvas.connections) 174 | canvas.add(b2) 175 | 176 | number_cons2 = len(canvas.solver.constraints) 177 | 178 | conn = Connector(l1, l1.handles()[0], canvas.connections) 179 | sink = ConnectionSink(b1) 180 | 181 | conn.connect(sink) 182 | 183 | assert canvas.connections.get_connection(l1.handles()[0]) 184 | 185 | conn = Connector(l1, l1.handles()[1], canvas.connections) 186 | sink = ConnectionSink(b2) 187 | 188 | conn.connect(sink) 189 | 190 | assert canvas.connections.get_connection(l1.handles()[1]) 191 | 192 | assert number_cons2 + 2 == len(canvas.solver.constraints) 193 | 194 | canvas.remove(b1) 195 | 196 | # Expecting a class + line connected at one end only 197 | assert number_cons1 + 1 == len(canvas.solver.constraints) 198 | -------------------------------------------------------------------------------- /tests/test_connections.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from gaphas import item 4 | from gaphas.connections import Connections 5 | from gaphas.constraint import EqualsConstraint 6 | from gaphas.solver import Solver 7 | 8 | 9 | @pytest.fixture 10 | def connections(): 11 | return Connections() 12 | 13 | 14 | def test_connections_with_custom_solver(): 15 | solver = Solver() 16 | connections = Connections(solver) 17 | 18 | assert connections.solver is solver 19 | 20 | 21 | def test_reconnect_item(connections): 22 | i = item.Line(connections) 23 | ii = item.Line(connections) 24 | 25 | cons1 = EqualsConstraint(i.handles()[0].pos.x, i.handles()[0].pos.x) 26 | cons2 = EqualsConstraint(i.handles()[0].pos.y, i.handles()[0].pos.y) 27 | connections.connect_item(i, i.handles()[0], ii, ii.ports()[0], cons1) 28 | 29 | assert connections.get_connection(i.handles()[0]).constraint is cons1 30 | assert cons1 in connections.solver.constraints 31 | 32 | connections.reconnect_item(i, i.handles()[0], constraint=cons2) 33 | 34 | assert connections.get_connection(i.handles()[0]).constraint is cons2 35 | assert cons1 not in connections.solver.constraints 36 | assert cons2 in connections.solver.constraints 37 | 38 | 39 | def test_add_item_constraint(connections): 40 | i = item.Line(connections) 41 | c1 = EqualsConstraint(i.handles()[0].pos.x, i.handles()[0].pos.x) 42 | 43 | connections.add_constraint(i, c1) 44 | 45 | cinfo = next(connections.get_connections(item=i)) 46 | 47 | assert cinfo.item is i 48 | assert cinfo.constraint is c1 49 | 50 | 51 | def test_remove_item_constraint(connections): 52 | i = item.Line(connections) 53 | c1 = EqualsConstraint(i.handles()[0].pos.x, i.handles()[0].pos.x) 54 | 55 | connections.add_constraint(i, c1) 56 | connections.remove_constraint(i, c1) 57 | 58 | assert list(connections.get_connections(item=i)) == [] 59 | 60 | 61 | def test_remove_item_constraint_when_item_is_disconnected(connections): 62 | i = item.Line(connections) 63 | c1 = EqualsConstraint(i.handles()[0].pos.x, i.handles()[0].pos.x) 64 | 65 | connections.add_constraint(i, c1) 66 | connections.disconnect_item(i) 67 | 68 | assert list(connections.get_connections(item=i)) == [] 69 | 70 | 71 | def test_notify_on_constraint_solved(connections): 72 | events = [] 73 | 74 | def on_notify(cinfo): 75 | events.append(cinfo) 76 | 77 | i = item.Line(connections) 78 | c = EqualsConstraint(i.handles()[0].pos.x, i.handles()[0].pos.x) 79 | connections.add_constraint(i, c) 80 | 81 | connections.add_handler(on_notify) 82 | connections.solve() 83 | 84 | assert events 85 | assert events[0].constraint is c 86 | 87 | 88 | def test_connection_remove_handler(connections): 89 | events = [] 90 | 91 | def on_notify(cinfo): 92 | events.append(cinfo) 93 | 94 | i = item.Line(connections) 95 | c = EqualsConstraint(i.handles()[0].pos.x, i.handles()[0].pos.x) 96 | connections.add_constraint(i, c) 97 | 98 | connections.add_handler(on_notify) 99 | connections.remove_handler(on_notify) 100 | connections.remove_handler(on_notify) 101 | connections.solve() 102 | 103 | assert not events 104 | -------------------------------------------------------------------------------- /tests/test_connector.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from gaphas.connector import ConnectionSink 4 | from gaphas.item import Element 5 | 6 | 7 | @pytest.fixture 8 | def element(connections): 9 | """Element at (0, 0), size 100x100.""" 10 | return Element(connections, 100, 100) 11 | 12 | 13 | @pytest.mark.parametrize( 14 | "pos,expected_glue_pos", 15 | [ 16 | [(0, 0), (0, 0)], 17 | [(50, 0), (50, 0)], 18 | [(0, 50), (0, 50)], 19 | [(50, 100), (50, 100)], 20 | [(100, 50), (100, 50)], 21 | [(-50, 0), None], 22 | [(50, 50), None], 23 | ], 24 | ) 25 | def test_element_glue_on_border(element, pos, expected_glue_pos): 26 | sink = ConnectionSink(element) 27 | glue_pos = sink.glue(pos) 28 | 29 | assert glue_pos == expected_glue_pos 30 | 31 | 32 | @pytest.mark.parametrize( 33 | "secondary_pos,expected_glue_pos", 34 | [ 35 | [(-50, 0), (0.5, 25.5)], 36 | [(0, -50), (25.5, 0.5)], 37 | [(50, 150), (50.5, 100.5)], 38 | [(150, 50), (100.5, 50.5)], 39 | ], 40 | ) 41 | def test_element_glue_on_border_with_secondary_position( 42 | element, secondary_pos, expected_glue_pos 43 | ): 44 | pos = (50, 50) 45 | sink = ConnectionSink(element) 46 | glue_pos = sink.glue(pos, secondary_pos=secondary_pos) 47 | 48 | assert glue_pos == expected_glue_pos 49 | -------------------------------------------------------------------------------- /tests/test_constraints.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from gaphas.constraint import ( 4 | EqualsConstraint, 5 | LessThanConstraint, 6 | LineAlignConstraint, 7 | LineConstraint, 8 | PositionConstraint, 9 | constraint, 10 | ) 11 | from gaphas.position import Position 12 | from gaphas.solver import Variable 13 | 14 | 15 | def test_pos_constraint(): 16 | """Test position constraint.""" 17 | x1, y1 = Variable(10), Variable(11) 18 | x2, y2 = Variable(12), Variable(13) 19 | pc = PositionConstraint(origin=(x1, y1), point=(x2, y2)) 20 | pc.solve_for() 21 | 22 | # origin shall remain the same 23 | assert 10 == x1 24 | assert 11 == y1 25 | 26 | # point shall be moved to origin 27 | assert 10 == x2 28 | assert 11 == y2 29 | 30 | # change just x of origin 31 | x1.value = 15 32 | pc.solve_for() 33 | assert 15 == x2 34 | 35 | # change just y of origin 36 | y1.value = 14 37 | pc.solve_for() 38 | assert 14 == y2 39 | 40 | 41 | def test_delta(): 42 | """Test line align constraint delta.""" 43 | line = (Variable(0), Variable(0)), (Variable(30), Variable(20)) 44 | point = (Variable(15), Variable(10)) 45 | lc = LineAlignConstraint(line=line, point=point, align=0.5, delta=5) 46 | lc.solve_for() 47 | assert round(abs(19.16 - point[0].value), 2) == 0 48 | assert round(abs(12.77 - point[1].value), 2) == 0 49 | 50 | line[1][0].value = 40 51 | line[1][1].value = 30 52 | lc.solve_for() 53 | assert round(abs(24.00 - point[0].value), 2) == 0 54 | assert round(abs(18.00 - point[1].value), 2) == 0 55 | 56 | 57 | def test_delta_below_zero(): 58 | """Test line align constraint with delta below zero.""" 59 | line = (Variable(0), Variable(0)), (Variable(30), Variable(20)) 60 | point = (Variable(15), Variable(10)) 61 | lc = LineAlignConstraint(line=line, point=point, align=0.5, delta=-5) 62 | lc.solve_for() 63 | assert round(abs(10.84 - point[0].value), 2) == 0 64 | assert round(abs(7.23 - point[1].value), 2) == 0 65 | 66 | line[1][0].value = 40 67 | line[1][1].value = 30 68 | lc.solve_for() 69 | assert round(abs(16.0 - point[0].value), 2) == 0 70 | assert round(abs(12.00 - point[1].value), 2) == 0 71 | 72 | 73 | @pytest.fixture() 74 | def pos1(): 75 | return Position(1, 2) 76 | 77 | 78 | @pytest.fixture 79 | def pos2(): 80 | return Position(3, 4) 81 | 82 | 83 | def test_line_constraint(pos1): 84 | """Test line creation constraint.""" 85 | line = (Position(3, 4), Position(5, 6)) 86 | c = constraint(line=(pos1, line)) 87 | 88 | assert isinstance(c, LineConstraint) 89 | assert Position(1, 2) == c._point 90 | assert (Position(3, 4), Position(5, 6)) == c._line 91 | 92 | 93 | def test_line_constraint_with_horizontal_line(): 94 | """Test line creation constraint.""" 95 | line = (Position(0, 0), Position(4, 0)) 96 | point = Position(1, 0) 97 | c = constraint(line=(point, line)) 98 | 99 | line[1].pos = (0, 4) 100 | c.solve() 101 | 102 | assert point.tuple() == (0.0, 1.0) 103 | 104 | 105 | def test_line_constraint_with_vertical_line(): 106 | """Test line creation constraint.""" 107 | line = (Position(0, 0), Position(0, 4)) 108 | point = Position(0, 1) 109 | c = constraint(line=(point, line)) 110 | 111 | line[1].pos = (4, 0) 112 | c.solve() 113 | 114 | assert point.tuple() == (1.0, 0.0) 115 | 116 | 117 | def test_horizontal_constraint(pos1, pos2): 118 | """Test horizontal constraint creation.""" 119 | c = constraint(horizontal=(pos1, pos2)) 120 | 121 | assert isinstance(c, EqualsConstraint) 122 | # Expect constraint on y-axis 123 | assert 2 == c.a 124 | assert 4 == c.b 125 | 126 | 127 | def test_vertical_constraint(pos1, pos2): 128 | """Test vertical constraint creation.""" 129 | c = constraint(vertical=(pos1, pos2)) 130 | 131 | assert isinstance(c, EqualsConstraint) 132 | # Expect constraint on x-axis 133 | assert 1 == c.a 134 | assert 3 == c.b 135 | 136 | 137 | def test_left_of_constraint(pos1, pos2): 138 | """Test "less than" constraint (horizontal) creation.""" 139 | c = constraint(left_of=(pos1, pos2)) 140 | 141 | assert isinstance(c, LessThanConstraint) 142 | assert 1 == c.smaller 143 | assert 3 == c.bigger 144 | 145 | 146 | def test_above_constraint(pos1, pos2): 147 | """Test "less than" constraint (vertical) creation.""" 148 | c = constraint(above=(pos1, pos2)) 149 | 150 | assert isinstance(c, LessThanConstraint) 151 | assert 2 == c.smaller 152 | assert 4 == c.bigger 153 | -------------------------------------------------------------------------------- /tests/test_element.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from gaphas.item import NE, NW, SE, SW 4 | 5 | 6 | def test_creation_with_size(canvas, box): 7 | """Test if initial size holds when added to a canvas.""" 8 | box.width = 150 9 | box.height = 153 10 | 11 | assert box.width == 150, box.width 12 | assert box.height == 153, box.height 13 | assert box.handles()[SE].pos.x == 150, box.handles()[SE].pos.x 14 | assert box.handles()[SE].pos.y == 153, box.handles()[SE].pos.y 15 | 16 | 17 | def test_box_handle_order(box): 18 | h_nw, h_ne, h_se, h_sw = box.handles() 19 | assert h_nw is box.handles()[NW] 20 | assert h_ne is box.handles()[NE] 21 | assert h_sw is box.handles()[SW] 22 | assert h_se is box.handles()[SE] 23 | 24 | 25 | @pytest.mark.parametrize("count", [1, 2, 10, 99]) 26 | @pytest.mark.asyncio 27 | async def test_resize_by_dragging_se_handle(canvas, box, count): 28 | h_nw, h_ne, h_se, h_sw = box.handles() 29 | 30 | for _ in range(count): 31 | h_se.pos.x += 100 # h.se.{x,y} = 10, now 32 | h_se.pos.y += 100 33 | canvas.update_now((box,)) 34 | 35 | assert 100 * count + 10 == h_se.pos.x 36 | assert 100 * count + 10 == float(h_se.pos.y) 37 | 38 | assert 100 * count + 10 == float(h_ne.pos.x) 39 | assert 100 * count + 10 == float(h_sw.pos.y) 40 | 41 | 42 | def test_point(box): 43 | box.handles()[SE].pos = (100, 100) 44 | 45 | assert box.point(50, 50) == -50 46 | 47 | 48 | def test_point_with_moved_nw_handle(box): 49 | box.handles()[NW].pos = (-100, -100) 50 | 51 | assert box.point(-50, -50) == -50 52 | 53 | 54 | def test_point_outside_box(box): 55 | box.handles()[SE].pos = (10, 10) 56 | 57 | assert box.point(10, 50) == 40 58 | -------------------------------------------------------------------------------- /tests/test_freehand.py: -------------------------------------------------------------------------------- 1 | import cairo 2 | 3 | from gaphas.painter.freehand import FreeHandCairoContext 4 | 5 | 6 | def test_drawing_lines(): 7 | surface = cairo.SVGSurface("freehand-drawing-lines.svg", 100, 100) 8 | cr = FreeHandCairoContext(cairo.Context(surface)) 9 | cr.set_line_width(2) 10 | cr.move_to(20, 20) 11 | cr.line_to(20, 80) 12 | cr.line_to(80, 80) 13 | cr.line_to(80, 20) 14 | cr.stroke() 15 | cr.show_page() 16 | 17 | 18 | def test_drawing_rectangle(): 19 | surface = cairo.SVGSurface("freehand-drawing-rectangle.svg", 100, 100) 20 | cr = FreeHandCairoContext(cairo.Context(surface)) 21 | cr.set_line_width(2) 22 | cr.rectangle(20, 20, 60, 60) 23 | cr.stroke() 24 | cr.show_page() 25 | 26 | 27 | DRAWING_LINES_OUTPUT = """ 28 | 29 | 30 | 31 | 32 | """ 33 | -------------------------------------------------------------------------------- /tests/test_geometry.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable 2 | 3 | import pytest 4 | 5 | from gaphas.geometry import ( 6 | Rectangle, 7 | distance_line_point, 8 | distance_rectangle_border_point, 9 | distance_rectangle_point, 10 | intersect_line_line, 11 | point_on_rectangle, 12 | ) 13 | 14 | 15 | def test_rectangle_is_iterable(): 16 | assert isinstance(Rectangle(), Iterable) 17 | 18 | 19 | def test_distance_line_point(): 20 | assert distance_line_point((0.0, 0.0), (2.0, 4.0), point=(3.0, 4.0)) == ( 21 | 1.0, 22 | (2.0, 4.0), 23 | ) 24 | assert distance_line_point((0.0, 0.0), (2.0, 4.0), point=(-1.0, 0.0)) == ( 25 | 1.0, 26 | (0.0, 0.0), 27 | ) 28 | assert distance_line_point((0.0, 0.0), (2.0, 4.0), point=(1.0, 2.0)) == ( 29 | 0.0, 30 | (1.0, 2.0), 31 | ) 32 | 33 | 34 | def test_distance_line_point_complex(): 35 | d, p = distance_line_point((0.0, 0.0), (2.0, 4.0), point=(2.0, 2.0)) 36 | 37 | assert f"{d:.3f}" == "0.894" 38 | assert f"({p[0]:.3f}, {p[1]:.3f})" == "(1.200, 2.400)" 39 | 40 | 41 | @pytest.mark.parametrize( 42 | ["line_start", "line_end", "point"], 43 | [ 44 | [(0, 0), (2, 2), (-1, 0)], 45 | [(0, 0), (0, 0), (-1, 0)], 46 | [(0, 0), (2, 2), (3, 2)], 47 | [(0, 0), (2, 3), (1, 1)], 48 | ], 49 | ) 50 | def test_distance_line_point_does_not_return_inputs(line_start, line_end, point): 51 | # Rationale: inputs can be of a different type (e.g. Position) 52 | # but the function should always return a tuple. 53 | 54 | _distance, point_on_line = distance_line_point(line_start, line_end, point) 55 | 56 | assert point_on_line is not line_start 57 | assert point_on_line is not line_end 58 | assert point_on_line is not point 59 | 60 | 61 | def test_distance_rectangle_point(): 62 | assert distance_rectangle_point((2, 0, 2, 2), (0, 0)) == 2 63 | assert distance_rectangle_point(Rectangle(0, 0, 10, 10), (11, -1)) == 2 64 | assert distance_rectangle_point((0, 0, 10, 10), (11, -1)) == 2 65 | assert distance_rectangle_point((0, 0, 10, 10), (-1, 11)) == 2 66 | 67 | 68 | def test_distance_point_in_rectangle(): 69 | assert distance_rectangle_point((0, 0, 2, 2), (1, 1)) == 0 70 | 71 | 72 | def test_distance_with_negative_numbers_in_rectangle(): 73 | assert distance_rectangle_point((-50, -100, 100, 100), (-17, -65)) == 0 74 | 75 | 76 | def test_distance_rectangle_border_point(): 77 | assert distance_rectangle_border_point((2, 0, 2, 2), (0, 0)) == 2 78 | assert distance_rectangle_border_point(Rectangle(0, 0, 10, 10), (11, -1)) == 2 79 | assert distance_rectangle_border_point((0, 0, 10, 10), (11, -1)) == 2 80 | assert distance_rectangle_border_point((0, 0, 10, 10), (3, 4)) == -3 81 | assert distance_rectangle_border_point((0, 0, 2, 2), (1, 1)) == -1 82 | 83 | 84 | def test_point_on_rectangle(): 85 | assert point_on_rectangle((2, 2, 2, 2), (0, 0)) == (2, 2) 86 | assert point_on_rectangle((2, 2, 2, 2), (3, 0)) == (3, 2) 87 | assert point_on_rectangle(Rectangle(0, 0, 10, 10), (11, -1)) == (10, 0) 88 | assert point_on_rectangle((0, 0, 10, 10), (5, 12)) == (5, 10) 89 | assert point_on_rectangle(Rectangle(0, 0, 10, 10), (12, 5)) == (10, 5) 90 | assert point_on_rectangle(Rectangle(1, 1, 10, 10), (3, 4)) == (3, 4) 91 | assert point_on_rectangle(Rectangle(1, 1, 10, 10), (0, 3)) == (1, 3) 92 | assert point_on_rectangle(Rectangle(1, 1, 10, 10), (4, 3)) == (4, 3) 93 | 94 | 95 | def test_point_on_rectangle_border(): 96 | assert point_on_rectangle(Rectangle(1, 1, 10, 10), (4, 9), border=True) == (4, 11) 97 | assert point_on_rectangle((1, 1, 10, 10), (4, 6), border=True) == (1, 6) 98 | assert point_on_rectangle(Rectangle(1, 1, 10, 10), (5, 3), border=True) == (5, 1) 99 | assert point_on_rectangle(Rectangle(1, 1, 10, 10), (8, 4), border=True) == (11, 4) 100 | assert point_on_rectangle((1, 1, 10, 100), (5, 8), border=True) == (1, 8) 101 | assert point_on_rectangle((1, 1, 10, 100), (5, 98), border=True) == (5, 101) 102 | 103 | 104 | def test_intersect_line_line(): 105 | assert intersect_line_line((3, 0), (8, 10), (0, 0), (10, 10)) == (6.5, 6.5) 106 | assert intersect_line_line((0, 0), (10, 10), (3, 0), (8, 10)) == (6.5, 6.5) 107 | assert intersect_line_line((0, 0), (10, 10), (8, 10), (3, 0)) == (6.5, 6.5) 108 | assert intersect_line_line((8, 10), (3, 0), (0, 0), (10, 10)) == (6.5, 6.5) 109 | 110 | 111 | def test_intersect_line_line_not_crossing(): 112 | assert intersect_line_line((0, 0), (0, 10), (3, 0), (8, 10)) is None 113 | assert intersect_line_line((0, 0), (0, 10), (3, 0), (3, 10)) is None 114 | -------------------------------------------------------------------------------- /tests/test_guide.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from gaphas.connections import Connections 4 | from gaphas.connector import Handle 5 | from gaphas.guide import Guide, GuidedItemMove, find_closest 6 | from gaphas.item import Element, Line 7 | from gaphas.solver import WEAK 8 | 9 | 10 | @pytest.fixture 11 | def line(canvas, connections): 12 | line = Line(connections) 13 | canvas.add(line) 14 | return line 15 | 16 | 17 | def test_find_closest(view, connections): 18 | """Test find closest method.""" 19 | set1 = [0, 10, 20] 20 | set2 = [2, 15, 30] 21 | 22 | d, closest = find_closest(set1, set2) 23 | assert 2.0 == d 24 | assert [2.0] == closest 25 | 26 | 27 | def test_element_guide(): 28 | e1 = Element(Connections()) 29 | assert 10 == e1.width 30 | assert 10 == e1.height 31 | guides = list(Guide(e1).horizontal()) 32 | assert 0.0 == guides[0] 33 | assert 5.0 == guides[1] 34 | assert 10.0 == guides[2] 35 | guides = list(Guide(e1).vertical()) 36 | assert 0.0 == guides[0] 37 | assert 5.0 == guides[1] 38 | assert 10.0 == guides[2] 39 | 40 | 41 | def test_line_guide(line, canvas): 42 | line.handles().append(Handle((20, 20), strength=WEAK)) 43 | line.handles().append(Handle((30, 30), strength=WEAK)) 44 | line.handles().append(Handle((40, 40), strength=WEAK)) 45 | line.orthogonal = True 46 | canvas.update_now((line,)) 47 | 48 | guides = list(Guide(line).horizontal()) 49 | assert len(line.handles()) == len(guides) 50 | assert 0.0 == guides[0] 51 | assert 10.0 == guides[1] 52 | assert 10.0 == guides[2] 53 | assert 40.0 == guides[3] 54 | 55 | guides = list(Guide(line).vertical()) 56 | assert len(line.handles()) == len(guides) 57 | assert 10.0 == guides[0] 58 | assert 10.0 == guides[1] 59 | assert 30.0 == guides[2] 60 | assert 30.0 == guides[3] 61 | 62 | 63 | def test_line_guide_horizontal(line, canvas): 64 | line.handles().append(Handle((20, 20))) 65 | line.handles().append(Handle((30, 30))) 66 | line.handles().append(Handle((40, 40))) 67 | line.horizontal = True 68 | line.orthogonal = True 69 | canvas.update_now((line,)) 70 | 71 | guides = list(Guide(line).horizontal()) 72 | assert len(line.handles()) == len(guides) 73 | assert 10.0 == guides[0] 74 | assert 10.0 == guides[1] 75 | assert 30.0 == guides[2] 76 | 77 | 78 | @pytest.mark.asyncio 79 | async def test_guide_item_in_motion(connections, canvas, view, window): 80 | e1 = Element(connections) 81 | e2 = Element(connections) 82 | e3 = Element(connections) 83 | canvas.add(e1) 84 | canvas.add(e2) 85 | canvas.add(e3) 86 | 87 | assert 0 == e1.matrix[4] 88 | assert 0 == e1.matrix[5] 89 | 90 | e2.matrix.translate(40, 40) 91 | canvas.request_update(e2) 92 | await view.update() 93 | 94 | assert 40 == e2.matrix[4] 95 | assert 40 == e2.matrix[5] 96 | 97 | guider = GuidedItemMove(e3, view) 98 | 99 | guider.start_move((0, 0)) 100 | assert 0 == e3.matrix[4] 101 | assert 0 == e3.matrix[5] 102 | 103 | # Moved back to guided lines: 104 | for d in range(3): 105 | guider.move((d, d)) 106 | assert 0 == e3.matrix[4] 107 | assert 0 == e3.matrix[5] 108 | 109 | guider.move((20, 20)) 110 | assert 20 == e3.matrix[4] 111 | assert 20 == e3.matrix[5] 112 | 113 | 114 | @pytest.mark.asyncio 115 | async def test_guide_item_in_motion_2(connections, canvas, view): 116 | e1 = Element(connections) 117 | e2 = Element(connections) 118 | e3 = Element(connections) 119 | canvas.add(e1) 120 | canvas.add(e2) 121 | canvas.add(e3) 122 | 123 | assert 0 == e1.matrix[4] 124 | assert 0 == e1.matrix[5] 125 | 126 | e2.matrix.translate(40, 40) 127 | canvas.request_update(e2) 128 | await view.update() 129 | 130 | assert 40 == e2.matrix[4] 131 | assert 40 == e2.matrix[5] 132 | 133 | guider = GuidedItemMove(e3, view) 134 | 135 | guider.start_move((3, 3)) 136 | assert 0 == e3.matrix[4] 137 | assert 0 == e3.matrix[5] 138 | 139 | # Moved back to guided lines: 140 | for y in range(4, 6): 141 | guider.move((3, y)) 142 | assert 0 == e3.matrix[4] 143 | assert 0 == e3.matrix[5] 144 | 145 | # Take into account initial cursor offset of (3, 3) 146 | guider.move((20, 23)) 147 | assert 17 == e3.matrix[4] 148 | assert 20 == e3.matrix[5] 149 | -------------------------------------------------------------------------------- /tests/test_handle.py: -------------------------------------------------------------------------------- 1 | from gaphas.connector import Handle 2 | 3 | 4 | def test_handle_x_y(): 5 | h = Handle() 6 | assert 0.0 == h.pos.x 7 | assert 0.0 == h.pos.y 8 | -------------------------------------------------------------------------------- /tests/test_handle_move.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from gaphas.handlemove import ItemHandleMove 4 | 5 | 6 | @pytest.mark.asyncio 7 | async def test_can_connect(line, box, connections, view): 8 | handle_move = ItemHandleMove(line, line.head, view) 9 | handle_move.connect((0, 0)) 10 | 11 | assert connections.get_connection(line.head) 12 | 13 | 14 | @pytest.mark.asyncio 15 | async def test_handle_is_connected_and_constraint_removed_when_moved( 16 | line, box, connections, view 17 | ): 18 | handle_move = ItemHandleMove(line, line.head, view) 19 | handle_move.connect((0, 0)) 20 | 21 | handle_move.start_move((0, 0)) 22 | 23 | cinfo = connections.get_connection(line.head) 24 | constraint = cinfo.constraint 25 | assert constraint not in connections.solver.constraints 26 | 27 | 28 | @pytest.mark.asyncio 29 | async def test_connected_item_can_disconnect(line, box, connections, view): 30 | handle_move = ItemHandleMove(line, line.head, view) 31 | handle_move.connect((0, 0)) 32 | 33 | cinfo = connections.get_connection(line.head) 34 | orig_constraint = cinfo.constraint 35 | 36 | handle_move.start_move((0, 0)) 37 | handle_move.stop_move((100, 100)) 38 | 39 | assert not connections.get_connection(line.head) 40 | assert orig_constraint not in connections.solver.constraints 41 | 42 | 43 | @pytest.mark.asyncio 44 | async def test_connected_item_can_reconnect(line, box, connections, view): 45 | handle_move = ItemHandleMove(line, line.head, view) 46 | handle_move.connect((0, 0)) 47 | 48 | cinfo = connections.get_connection(line.head) 49 | orig_constraint = cinfo.constraint 50 | 51 | handle_move.start_move((0, 0)) 52 | handle_move.stop_move((0, 0)) 53 | 54 | cinfo = connections.get_connection(line.head) 55 | new_constraint = cinfo.constraint 56 | 57 | assert orig_constraint is not new_constraint 58 | -------------------------------------------------------------------------------- /tests/test_item.py: -------------------------------------------------------------------------------- 1 | """Item constraint creation tests. 2 | 3 | The test check functionality of `Item.constraint` method, not 4 | constraints themselves. 5 | """ 6 | 7 | from gaphas.item import Element, Item, Line 8 | 9 | 10 | class Custom: 11 | def __init__(self, custom): 12 | self.custom = custom 13 | 14 | 15 | def test_can_pass_arbitrary_arguments_to_an_element(connections): 16 | class Test(Element, Custom): 17 | pass 18 | 19 | t = Test(connections, custom="custom") 20 | 21 | assert t.custom == "custom" 22 | 23 | 24 | def test_can_pass_arbitrary_arguments_to_a_line(connections): 25 | class Test(Line, Custom): 26 | pass 27 | 28 | t = Test(connections, custom="custom") 29 | 30 | assert t.custom == "custom" 31 | assert t._connections is connections 32 | 33 | 34 | def test_element_implements_item_protocol(connections): 35 | element = Element(connections) 36 | 37 | assert isinstance(element, Item) 38 | 39 | 40 | def test_line_implements_item_protocol(connections): 41 | line = Line(connections) 42 | 43 | assert isinstance(line, Item) 44 | -------------------------------------------------------------------------------- /tests/test_line.py: -------------------------------------------------------------------------------- 1 | """Basic item tests for lines.""" 2 | 3 | from gaphas.canvas import Canvas 4 | from gaphas.item import Line 5 | 6 | 7 | def test_initial_ports(): 8 | """Test initial ports amount.""" 9 | canvas = Canvas() 10 | line = Line(canvas.connections) 11 | assert 1 == len(line.ports()) 12 | -------------------------------------------------------------------------------- /tests/test_matrix.py: -------------------------------------------------------------------------------- 1 | from gaphas.matrix import Matrix 2 | 3 | 4 | def test_multiply_equals_should_result_in_same_matrix(): 5 | m1 = Matrix() 6 | m2 = m1 7 | m2 *= Matrix(20, 20) 8 | 9 | assert m1 is m2 10 | -------------------------------------------------------------------------------- /tests/test_move.py: -------------------------------------------------------------------------------- 1 | """Test aspects for items.""" 2 | 3 | import pytest 4 | 5 | from gaphas.item import Element 6 | from gaphas.move import Move 7 | 8 | 9 | @pytest.fixture() 10 | def item(connections): 11 | return Element(connections) 12 | 13 | 14 | @pytest.mark.asyncio 15 | async def test_selection_move(canvas, view, item): 16 | """Test the Selection role methods.""" 17 | canvas.add(item) 18 | mover = Move(item, view) 19 | assert (1, 0, 0, 1, 0, 0) == tuple(item.matrix) 20 | mover.start_move((0, 0)) 21 | mover.move((12, 26)) 22 | assert (1, 0, 0, 1, 12, 26) == tuple(item.matrix) 23 | -------------------------------------------------------------------------------- /tests/test_position.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from gaphas.matrix import Matrix 4 | from gaphas.position import MatrixProjection, Position 5 | from gaphas.solver import Solver, Variable 6 | 7 | 8 | @pytest.fixture 9 | def solver(): 10 | return Solver() 11 | 12 | 13 | @pytest.mark.parametrize("position", [(0, 0), (1, 2)]) 14 | def test_position(position): 15 | pos = Position(*position) 16 | assert position[0] == pos.x 17 | assert position[1] == pos.y 18 | 19 | 20 | def test_position_notifies_on_x_change(handler): 21 | pos = Position(3, 3) 22 | pos.add_handler(handler) 23 | 24 | pos.x = 4 25 | 26 | assert handler.events 27 | assert handler.events[0][0] is pos 28 | assert handler.events[0][1] == (3.0, 3.0) 29 | 30 | 31 | def test_position_notifies_on_y_change(handler): 32 | pos = Position(3, 3) 33 | pos.add_handler(handler) 34 | 35 | pos.y = 4 36 | 37 | assert handler.events 38 | assert handler.events[0][0] is pos 39 | assert handler.events[0][1] == (3.0, 3.0) 40 | 41 | 42 | def test_position_notifies_on_pos_change(handler): 43 | pos = Position(3, 3) 44 | pos.add_handler(handler) 45 | 46 | pos.pos = (4, 4) 47 | 48 | assert len(handler.events) == 1 49 | assert handler.events[0][0] is pos 50 | assert handler.events[0][1] == (3.0, 3.0) 51 | 52 | 53 | def test_matrix_projection_exposes_variables(): 54 | proj = MatrixProjection(Position(0, 0), Matrix()) 55 | 56 | assert isinstance(proj.x, Variable) 57 | assert isinstance(proj.y, Variable) 58 | 59 | 60 | @pytest.mark.parametrize( 61 | "position,matrix,result", 62 | [ 63 | [(0, 0), Matrix(1, 0, 0, 1, 0, 0), (0, 0)], 64 | [(2, 4), Matrix(2, 0, 0, 1, 2, 3), (6, 7)], 65 | [(2, 4), Matrix(2, 0, 0, 1, 2, 3), (6, 7)], 66 | ], 67 | ) 68 | def test_projection_updates_when_original_is_changed(solver, position, matrix, result): 69 | pos = Position(0, 0) 70 | proj = MatrixProjection(pos, matrix) 71 | solver.add_constraint(proj) 72 | solver.solve() 73 | 74 | pos.x, pos.y = position 75 | solver.solve() 76 | 77 | assert proj.x == result[0] 78 | assert proj.y == result[1] 79 | 80 | 81 | @pytest.mark.parametrize( 82 | "position,matrix,result", 83 | [ 84 | [(0, 0), Matrix(1, 0, 0, 1, 0, 0), (0, 0)], 85 | [(2, 0), Matrix(1, 0, 0, 1, 0, 0), (2, 0)], 86 | [(2, 0), Matrix(1, 0, 0, 1, 4, 3), (-2, -3)], 87 | [(1, 2), Matrix(1, 0, 0, 1, 4, 3), (-3, -1)], 88 | ], 89 | ) 90 | def test_original_updates_when_projection_is_changed(solver, position, matrix, result): 91 | pos = Position(0, 0) 92 | proj = MatrixProjection(pos, matrix) 93 | solver.add_constraint(proj) 94 | solver.solve() 95 | 96 | proj.x, proj.y = position 97 | 98 | solver.solve() 99 | 100 | assert pos.x == result[0] 101 | assert pos.y == result[1] 102 | 103 | 104 | def test_projection_updates_when_matrix_is_changed(solver): 105 | pos = Position(0, 0) 106 | matrix = Matrix() 107 | proj = MatrixProjection(pos, matrix) 108 | solver.add_constraint(proj) 109 | solver.solve() 110 | 111 | matrix.translate(2, 3) 112 | solver.solve() 113 | 114 | assert proj.x == 2 115 | assert proj.y == 3 116 | 117 | 118 | def test_matrix_projection_sets_handlers_just_in_time(): 119 | pos = Position(0, 0) 120 | matrix = Matrix() 121 | proj = MatrixProjection(pos, matrix) 122 | 123 | def handler(c): 124 | pass 125 | 126 | assert not matrix._handlers 127 | assert not pos.x._handlers 128 | assert not pos.y._handlers 129 | 130 | proj.add_handler(handler) 131 | 132 | assert matrix._handlers 133 | assert pos.x._handlers 134 | assert pos.y._handlers 135 | 136 | proj.remove_handler(handler) 137 | 138 | assert not matrix._handlers 139 | assert not pos.x._handlers 140 | assert not pos.y._handlers 141 | -------------------------------------------------------------------------------- /tests/test_quadtree.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import pytest 3 | 4 | from gaphas.quadtree import Quadtree 5 | 6 | 7 | @pytest.fixture() 8 | def qtree(): 9 | qtree: Quadtree[str, None] = Quadtree() 10 | for i, j in itertools.product(range(0, 100, 10), range(0, 100, 10)): 11 | qtree.add(item=f"{i:d}x{j:d}", bounds=(i, j, 10, 10), data=None) 12 | return qtree 13 | 14 | 15 | def test_initial_size(qtree): 16 | assert qtree.bounds == (0, 0, 100, 100) 17 | 18 | 19 | def test_lookups(qtree): 20 | for i, j in itertools.product(range(100, 10), range(100, 10)): 21 | assert qtree.find_intersect(rect=(i + 1, j + 1, 1, 1)) == [f"{i:d}x{j:d}"], ( 22 | qtree.find_intersect(rect=(i + 1, j + 1, 1, 1)) 23 | ) 24 | 25 | 26 | def test_with_rectangles(qtree): 27 | assert len(qtree._ids) == 100, len(qtree._ids) 28 | 29 | for i, j in itertools.product(range(100, 10), range(100, 10)): 30 | assert qtree.find_intersect(rect=(i + 1, j + 1, 1, 1)) == [f"{i:d}x{j:d}"], ( 31 | qtree.find_intersect(rect=(i + 1, j + 1, 1, 1)) 32 | ) 33 | 34 | 35 | def test_moving_items(qtree): 36 | qtree.capacity = 10 37 | assert len(qtree._ids) == 100, len(qtree._ids) 38 | assert qtree._bucket._buckets, qtree._bucket._buckets 39 | for i in range(4): 40 | assert qtree._bucket._buckets[i]._buckets 41 | for j in range(4): 42 | assert not qtree._bucket._buckets[i]._buckets[j]._buckets 43 | 44 | # Check contents: 45 | # First sub-level contains 9 items. second level contains 4 items 46 | # ==> 4 * (9 + (4 * 4)) = 100 47 | assert len(qtree._bucket.items) == 0, qtree._bucket.items 48 | for i in range(4): 49 | assert len(qtree._bucket._buckets[i].items) == 9 50 | for _item, bounds in qtree._bucket._buckets[i].items.items(): 51 | assert qtree._bucket.find_bucket(bounds) is qtree._bucket._buckets[i] 52 | for j in range(4): 53 | assert len(qtree._bucket._buckets[i]._buckets[j].items) == 4 54 | 55 | assert qtree.get_bounds("0x0") 56 | # Now move item '0x0' to the center of the first quadrant (20, 20) 57 | qtree.add("0x0", (20, 20, 10, 10), None) 58 | assert len(qtree._bucket.items) == 0 59 | assert len(qtree._bucket._buckets[0]._buckets[0].items) == 3, ( 60 | qtree._bucket._buckets[0]._buckets[0].items 61 | ) 62 | assert len(qtree._bucket._buckets[0].items) == 10, qtree._bucket._buckets[0].items 63 | 64 | # Now move item '0x0' to the second quadrant (70, 20) 65 | qtree.add("0x0", (70, 20, 10, 10), None) 66 | assert len(qtree._bucket.items) == 0 67 | assert len(qtree._bucket._buckets[0]._buckets[0].items) == 3, ( 68 | qtree._bucket._buckets[0]._buckets[0].items 69 | ) 70 | assert len(qtree._bucket._buckets[0].items) == 9, qtree._bucket._buckets[0].items 71 | assert len(qtree._bucket._buckets[1].items) == 10, qtree._bucket._buckets[1].items 72 | 73 | 74 | def test_get_data(qtree): 75 | """Test extra data added to a node.""" 76 | for i, j in itertools.product(range(0, 100, 10), range(0, 100, 10)): 77 | qtree.add(item=f"{i:d}x{j:d}", bounds=(i, j, 10, 10), data=i + j) 78 | 79 | for i, j in itertools.product(range(0, 100, 10), range(0, 100, 10)): 80 | assert i + j == qtree.get_data(item=f"{i:d}x{j:d}") 81 | 82 | 83 | def test_resize(qtree: Quadtree): 84 | qtree.add("0x0", (20, 130, 60, 60), None) 85 | 86 | assert "0x0" in qtree.find_inside((0, 100, 100, 200)) 87 | 88 | 89 | def test_dump_quadtree(qtree, capsys): 90 | qtree.dump() 91 | captured = capsys.readouterr() 92 | 93 | assert "gaphas.quadtree.QuadtreeBucket" in captured.out 94 | -------------------------------------------------------------------------------- /tests/test_solver.py: -------------------------------------------------------------------------------- 1 | """Test constraint solver.""" 2 | 3 | from gaphas.constraint import EqualsConstraint, LessThanConstraint 4 | from gaphas.solver import REQUIRED, MultiConstraint, Solver, Variable 5 | from gaphas.solver.constraint import Constraint, ContainsConstraints 6 | 7 | 8 | def test_solver_implements_constraint_protocol(): 9 | solver = Solver() 10 | 11 | assert isinstance(solver, Constraint) 12 | assert isinstance(solver, ContainsConstraints) 13 | 14 | 15 | def test_weakest_list_order(): 16 | solver = Solver() 17 | a = Variable(1, 30) 18 | b = Variable(2, 10) 19 | c_eq = EqualsConstraint(a, b) 20 | solver.add_constraint(c_eq) 21 | a.value = 4 22 | 23 | b.value = 5 24 | assert c_eq.weakest() == b 25 | 26 | a.value = 6 27 | assert c_eq.weakest() == b 28 | 29 | b.value = 6 30 | assert c_eq.weakest() == a 31 | 32 | 33 | def test_minimal_size_constraint(): 34 | solver = Solver() 35 | v1 = Variable(0) 36 | v2 = Variable(10) 37 | v3 = Variable(10) 38 | c1 = EqualsConstraint(a=v2, b=v3) 39 | c2 = LessThanConstraint(smaller=v1, bigger=v3, delta=10) 40 | solver.add_constraint(c1) 41 | solver.add_constraint(c2) 42 | 43 | # Check everything is ok on start 44 | solver.solve() 45 | assert 0 == v1 46 | assert 10 == v2 47 | assert 10 == v3 48 | 49 | # Change v1 to 2, after solve it should be 0 again due to LT constraint 50 | v1.value = 2 51 | solver.solve() 52 | 53 | assert 0 == v1 54 | assert 10 == v2 55 | assert 10 == v3 56 | 57 | # Change v3 to 20, after solve v2 will follow thanks to EQ constraint 58 | v3.value = 20 59 | solver.solve() 60 | 61 | assert 0 == v1 62 | assert 20 == v2 63 | assert 20 == v3 64 | 65 | # Change v3 to 0, after solve it should be 10 due to LT.delta = 10, v2 66 | # should also be 10 due to EQ constraint 67 | v3.value = 0 68 | solver.solve() 69 | 70 | assert 0 == v1 71 | assert 10 == v2 72 | assert 10 == v3 73 | 74 | 75 | def test_constraints_can_not_be_resolved(): 76 | solver = Solver() 77 | a = Variable() 78 | b = Variable() 79 | c = Variable(40, strength=REQUIRED) 80 | d = Variable(30, strength=REQUIRED) 81 | 82 | solver.add_constraint(EqualsConstraint(a, b)) 83 | solver.add_constraint(EqualsConstraint(a, c)) 84 | solver.add_constraint(EqualsConstraint(b, d)) 85 | 86 | solver.solve() 87 | 88 | assert a.value == 40.0 89 | assert b.value == 30.0 90 | 91 | 92 | def test_notify_for_nested_constraint(): 93 | events = [] 94 | solver = Solver() 95 | a = Variable() 96 | b = Variable() 97 | nested = EqualsConstraint(a, b) 98 | multi = MultiConstraint(nested) 99 | 100 | solver.add_constraint(multi) 101 | solver.solve() 102 | 103 | def handler(constraint): 104 | events.append(constraint) 105 | 106 | solver.add_handler(handler) 107 | 108 | a.value = 10 109 | 110 | solver.solve() 111 | 112 | assert multi in events 113 | assert nested not in events 114 | 115 | 116 | def test_notify_for_double_nested_constraint(): 117 | events = [] 118 | solver = Solver() 119 | a = Variable() 120 | b = Variable() 121 | nested = EqualsConstraint(a, b) 122 | multi = MultiConstraint(nested) 123 | outer = MultiConstraint(multi) 124 | 125 | solver.add_constraint(outer) 126 | solver.solve() 127 | 128 | def handler(constraint): 129 | events.append(constraint) 130 | 131 | solver.add_handler(handler) 132 | 133 | a.value = 10 134 | 135 | solver.solve() 136 | 137 | assert outer in events 138 | 139 | 140 | def test_needs_solving(): 141 | solver = Solver() 142 | a = Variable() 143 | b = Variable() 144 | eq = EqualsConstraint(a, b) 145 | 146 | solver.add_constraint(eq) 147 | assert solver.needs_solving 148 | 149 | solver.solve() 150 | assert not solver.needs_solving 151 | 152 | a.value = 3 153 | assert solver.needs_solving 154 | 155 | solver.solve() 156 | assert not solver.needs_solving 157 | -------------------------------------------------------------------------------- /tests/test_solver_constraint.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from gaphas.solver import BaseConstraint, MultiConstraint, Variable 4 | from gaphas.solver.constraint import Constraint, ContainsConstraints 5 | 6 | 7 | def test_base_constraint_implements_constraint_protocol(): 8 | c = BaseConstraint(Variable()) 9 | 10 | assert isinstance(c, Constraint) 11 | 12 | 13 | def test_multi_constraint_implements_constraint_protocol(): 14 | c = MultiConstraint() 15 | 16 | assert isinstance(c, Constraint) 17 | assert isinstance(c, ContainsConstraints) 18 | 19 | 20 | def test_constraint_propagates_variable_changed(handler): 21 | v = Variable() 22 | c = BaseConstraint(v) 23 | c.add_handler(handler) 24 | 25 | v.value = 3 26 | 27 | assert handler.events == [(c,)] 28 | 29 | 30 | def test_multi_constraint(handler): 31 | v = Variable() 32 | c = BaseConstraint(v) 33 | m = MultiConstraint(c) 34 | m.add_handler(handler) 35 | 36 | v.value = 3 37 | 38 | assert handler.events == [(c,)] 39 | 40 | 41 | def test_default_constraint_can_not_solve(): 42 | v = Variable() 43 | c = BaseConstraint(v) 44 | 45 | with pytest.raises(NotImplementedError): 46 | c.solve() 47 | 48 | 49 | def test_constraint_handlers_are_set_just_in_time(): 50 | v = Variable() 51 | c = BaseConstraint(v) 52 | 53 | def handler(c): 54 | pass 55 | 56 | assert not v._handlers 57 | 58 | c.add_handler(handler) 59 | 60 | assert v._handlers 61 | assert handler in c._handlers 62 | 63 | c.remove_handler(handler) 64 | 65 | assert not v._handlers 66 | -------------------------------------------------------------------------------- /tests/test_solver_variable.py: -------------------------------------------------------------------------------- 1 | from gaphas.solver import STRONG, Variable, variable 2 | 3 | 4 | def test_variable_decorator(): 5 | class A: 6 | x = variable(varname="sx", strength=STRONG) 7 | 8 | a = A() 9 | 10 | assert isinstance(A.x, variable) 11 | assert a.x == 0 12 | assert a.x.strength == STRONG 13 | 14 | 15 | def test_variable_decorator_value(): 16 | class A: 17 | x = variable(varname="sx", strength=STRONG) 18 | 19 | a = A() 20 | a.x = 3 21 | 22 | assert a.x == 3 23 | 24 | 25 | def test_variable_decorator_set_variable(): 26 | class A: 27 | x = variable(varname="sx", strength=STRONG) 28 | 29 | a = A() 30 | v = Variable(4) 31 | a.x = v 32 | 33 | assert a.x == 4 34 | assert a.x is not v 35 | 36 | 37 | def test_equality(): 38 | v = Variable(3) 39 | w = Variable(3) 40 | o = Variable(2) 41 | 42 | assert v == 3 43 | assert 3 == v 44 | assert v == w 45 | assert not v == o 46 | 47 | assert v != 2 48 | assert 2 != v 49 | assert not 3 != v 50 | assert v != o 51 | 52 | 53 | def test_add_to_variable(): 54 | v = Variable(3) 55 | 56 | assert v + 1 == 4 57 | assert v - 1 == 2 58 | assert 1 + v == 4 59 | assert 4 - v == 1 60 | 61 | 62 | def test_add_to_variable_with_variable(): 63 | v = Variable(3) 64 | o = Variable(1) 65 | 66 | assert v + o == 4 67 | assert v - o == 2 68 | 69 | 70 | def test_mutiplication(): 71 | v = Variable(3) 72 | 73 | assert v * 2 == 6 74 | assert v / 2 == 1.5 75 | assert v // 2 == 1 76 | 77 | assert 2 * v == 6 78 | assert 4.5 / v == 1.5 79 | assert 4 // v == 1 80 | 81 | 82 | def test_mutiplication_with_variable(): 83 | v = Variable(3) 84 | o = Variable(2) 85 | 86 | assert v * o == 6 87 | assert v / o == 1.5 88 | assert v // o == 1 89 | 90 | 91 | def test_comparison(): 92 | v = Variable(3) 93 | 94 | assert v > 2 95 | assert v < 4 96 | assert v >= 2 97 | assert v >= 3 98 | assert v <= 4 99 | assert v <= 3 100 | 101 | assert not v > 3 102 | assert not v < 3 103 | assert not v <= 2 104 | assert not v >= 4 105 | 106 | 107 | def test_inverse_comparison(): 108 | v = Variable(3) 109 | 110 | assert 4 > v 111 | assert 2 < v 112 | assert 4 >= v 113 | assert 3 >= v 114 | assert 2 <= v 115 | assert 3 <= v 116 | 117 | assert not 3 > v 118 | assert not 3 < v 119 | assert not 4 <= v 120 | assert not 2 >= v 121 | 122 | 123 | def test_power(): 124 | v = Variable(3) 125 | o = Variable(2) 126 | 127 | assert v**2 == 9 128 | assert 2**v == 8 129 | assert v**o == 9 130 | 131 | 132 | def test_modulo(): 133 | v = Variable(3) 134 | o = Variable(2) 135 | 136 | assert v % 2 == 1 137 | assert 4 % v == 1 138 | assert v % o == 1 139 | assert divmod(v, 2) == (1, 1) 140 | assert divmod(4, v) == (1, 1) 141 | assert divmod(v, o) == (1, 1) 142 | -------------------------------------------------------------------------------- /tests/test_tool_hover.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from gaphas.tool.hover import hover_tool, on_motion 4 | 5 | 6 | @pytest.mark.asyncio 7 | async def test_hovers_item(view, box): 8 | tool = hover_tool() 9 | view.add_controller(tool) 10 | 11 | on_motion(tool, 5, 5) 12 | 13 | assert view.selection.hovered_item is box 14 | 15 | 16 | def test_handles_event(view, box): 17 | tool = hover_tool() 18 | view.add_controller(tool) 19 | 20 | on_motion(tool, 100, 100) 21 | 22 | assert view.selection.hovered_item is None 23 | -------------------------------------------------------------------------------- /tests/test_tool_item.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | import pytest 4 | from gi.repository import Gtk 5 | 6 | from gaphas.handlemove import order_items 7 | from gaphas.tool.itemtool import ( 8 | DragState, 9 | handle_at_point, 10 | item_at_point, 11 | item_tool, 12 | on_drag_begin, 13 | ) 14 | from tests.conftest import Box 15 | 16 | 17 | class MockEvent: 18 | def __init__(self, modifiers=0): 19 | self._modifiers = modifiers 20 | 21 | def get_state(self): 22 | return True, self._modifiers 23 | 24 | 25 | class MockGesture: 26 | def __init__(self, view, event=None): 27 | if event is None: 28 | event = MockEvent() 29 | self._view = view 30 | self._event = event 31 | 32 | def get_widget(self): 33 | return self._view 34 | 35 | def get_last_event(self, _sequence): 36 | return self._event 37 | 38 | def get_current_event_state(self): 39 | return self._event.get_state()[1] 40 | 41 | def set_state(self, _state): 42 | pass 43 | 44 | 45 | def test_should_create_a_gesture(): 46 | tool = item_tool() 47 | 48 | assert isinstance(tool, Gtk.Gesture) 49 | 50 | 51 | @pytest.mark.asyncio 52 | async def test_select_item_on_click(view, box, window): 53 | tool = MockGesture(view) 54 | drag_state = DragState() 55 | selection = view.selection 56 | 57 | on_drag_begin(tool, 0, 0, drag_state) 58 | 59 | assert box is selection.focused_item 60 | assert box in selection.selected_items 61 | 62 | 63 | @pytest.mark.asyncio 64 | async def test_start_move_handle_on_click(view, box, window): 65 | tool = MockGesture(view) 66 | drag_state = DragState() 67 | 68 | on_drag_begin(tool, 0, 0, drag_state) 69 | 70 | assert drag_state.moving 71 | assert next(iter(drag_state.moving)).item is box 72 | assert next(iter(drag_state.moving)).handle is box.handles()[0] 73 | 74 | 75 | @pytest.mark.asyncio 76 | async def test_get_item_at_point(view, box): 77 | """Hover tool only reacts on motion-notify events.""" 78 | box.width = 50 79 | box.height = 50 80 | view.request_update((box,)) 81 | 82 | assert next(item_at_point(view, (10, 10)), None) is box # type: ignore[call-overload] 83 | assert next(item_at_point(view, (60, 10)), None) is None # type: ignore[call-overload] 84 | 85 | 86 | @pytest.mark.asyncio 87 | async def test_get_unselected_item_at_point(view, box): 88 | box.width = 50 89 | box.height = 50 90 | view.selection.select_items(box) 91 | 92 | assert next(item_at_point(view, (10, 10)), None) is box # type: ignore[call-overload] 93 | assert next(item_at_point(view, (10, 10), exclude=(box,)), None) is None # type: ignore[call-overload] 94 | 95 | 96 | @pytest.mark.asyncio 97 | async def test_get_item_at_point_overlayed_by_bigger_item(view, canvas, connections): 98 | """Hover tool only reacts on motion-notify events.""" 99 | below = Box(connections) 100 | canvas.add(below) 101 | above = Box(connections) 102 | canvas.add(above) 103 | 104 | below.width = 20 105 | below.height = 20 106 | above.matrix.translate(-40, -40) 107 | above.width = 100 108 | above.height = 100 109 | view.request_update((below, above)) 110 | await view.update() 111 | 112 | assert next(item_at_point(view, (10, 10)), None) is below # type: ignore[call-overload] 113 | assert next(item_at_point(view, (-1, -1)), None) is above # type: ignore[call-overload] 114 | 115 | 116 | def test_order_by_distance(): 117 | m = [(0, ""), (10, ""), (-1, ""), (-3, ""), (5, ""), (4, "")] 118 | 119 | assert [e[0] for e in order_items(m)] == [0, -1, -3, 4, 5, 10] 120 | 121 | 122 | @pytest.mark.asyncio 123 | async def test_get_handle_at_point(view, canvas, connections): 124 | box = Box(connections) 125 | box.min_width = 20 126 | box.min_height = 30 127 | box.matrix.translate(20, 20) 128 | box.matrix.rotate(math.pi / 1.5) 129 | canvas.add(box) 130 | await view.update() 131 | 132 | i, h = handle_at_point(view, (20, 20)) 133 | assert i is box 134 | assert h is box.handles()[0] 135 | 136 | 137 | @pytest.mark.asyncio 138 | async def test_get_handle_at_point_at_pi_div_2(view, canvas, connections): 139 | box = Box(connections) 140 | box.min_width = 20 141 | box.min_height = 30 142 | box.matrix.translate(20, 20) 143 | box.matrix.rotate(math.pi / 2) 144 | canvas.add(box) 145 | await view.update() 146 | 147 | i, h = handle_at_point(view, (20, 20)) 148 | assert i is box 149 | assert h is box.handles()[0] 150 | -------------------------------------------------------------------------------- /tests/test_tool_placement.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from gi.repository import Gtk 3 | 4 | from gaphas.item import Element 5 | from gaphas.tool.placement import PlacementState, on_drag_begin, placement_tool 6 | 7 | 8 | @pytest.fixture 9 | def tool_factory(connections): 10 | def tool_factory(): 11 | return Element(connections) 12 | 13 | return tool_factory 14 | 15 | 16 | def test_can_create_placement_tool(tool_factory): 17 | tool = placement_tool(tool_factory, 2) 18 | 19 | assert isinstance(tool, Gtk.Gesture) 20 | 21 | 22 | def test_create_new_element(view, tool_factory, window): 23 | state = PlacementState(tool_factory, 2) 24 | tool = placement_tool(tool_factory, 2) 25 | view.add_controller(tool) 26 | 27 | on_drag_begin(tool, 0, 0, state) 28 | 29 | assert state.moving 30 | -------------------------------------------------------------------------------- /tests/test_tool_scroll.py: -------------------------------------------------------------------------------- 1 | from gi.repository import Gtk 2 | 3 | from gaphas.tool.scroll import on_scroll, scroll_tool 4 | 5 | 6 | def test_scroll_tool_returns_a_controller(view): 7 | tool = scroll_tool(view) 8 | 9 | assert isinstance(tool, Gtk.EventController) 10 | 11 | 12 | def test_offset_changes(view, scrolled_window): 13 | tool = scroll_tool(view) 14 | view.add_controller(tool) 15 | view._scrolling.update_adjustments(100, 100, (0, 0, 100, 100)) 16 | 17 | assert view.hadjustment 18 | assert view.vadjustment 19 | assert view._scrolling._hadjustment_handler_id 20 | assert view._scrolling._hadjustment_handler_id 21 | 22 | on_scroll(tool, 10, 10, 1) 23 | 24 | assert view.matrix[4] == -10 25 | assert view.matrix[5] == -10 26 | -------------------------------------------------------------------------------- /tests/test_tool_zoom.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from gi.repository import Gtk 3 | 4 | from gaphas.tool.zoom import Zoom, on_begin, on_scale_changed, zoom_tool 5 | 6 | 7 | @pytest.fixture 8 | def zoom_data(view): 9 | zoom_data = Zoom() 10 | zoom_data.x0 = 0 11 | zoom_data.y0 = 0 12 | zoom_data.sx = 1 13 | zoom_data.sy = 1 14 | zoom_data.begin(view.matrix, 0, 0) 15 | return zoom_data 16 | 17 | 18 | def test_can_create_zoom_tool(view): 19 | tool = zoom_tool() 20 | view.add_controller(tool) 21 | 22 | assert isinstance(tool, Gtk.Gesture) 23 | 24 | 25 | def test_begin_state(zoom_data, view): 26 | class MockGesture: 27 | def get_widget(self): 28 | return view 29 | 30 | def get_point(self, sequence): 31 | return True, 1, 2 32 | 33 | gesture = MockGesture() 34 | sequence = None 35 | 36 | on_begin(gesture, sequence, zoom_data) 37 | 38 | assert zoom_data.x0 == 1 39 | assert zoom_data.y0 == 2 40 | assert zoom_data.sx == 1 41 | assert zoom_data.sy == 1 42 | 43 | 44 | @pytest.mark.asyncio 45 | async def test_scaling(zoom_data, view): 46 | tool = zoom_tool() 47 | view.add_controller(tool) 48 | 49 | on_scale_changed(tool, 1.2, zoom_data) 50 | 51 | assert view.matrix[0] == pytest.approx(1.2) 52 | assert view.matrix[3] == pytest.approx(1.2) 53 | 54 | 55 | @pytest.mark.asyncio 56 | async def test_multiple_scaling_events(zoom_data, view): 57 | tool = zoom_tool() 58 | view.add_controller(tool) 59 | 60 | on_scale_changed(tool, 1.1, zoom_data) 61 | on_scale_changed(tool, 1.2, zoom_data) 62 | 63 | assert view.matrix[0] == pytest.approx(1.2) 64 | assert view.matrix[3] == pytest.approx(1.2) 65 | 66 | 67 | @pytest.mark.asyncio 68 | async def test_scaling_with_unequal_scaling_factor(zoom_data, view): 69 | tool = zoom_tool() 70 | view.add_controller(tool) 71 | 72 | zoom_data.sx = 2 73 | 74 | on_scale_changed(tool, 1.2, zoom_data) 75 | 76 | assert view.matrix[0] == pytest.approx(2.4) 77 | assert view.matrix[3] == pytest.approx(1.2) 78 | 79 | 80 | @pytest.mark.asyncio 81 | async def test_zoom_should_center_around_mouse_cursor(zoom_data, view): 82 | tool = zoom_tool() 83 | view.add_controller(tool) 84 | zoom_data.x0 = 100 85 | zoom_data.y0 = 50 86 | 87 | on_scale_changed(tool, 1.2, zoom_data) 88 | 89 | assert view.matrix[4] == pytest.approx(-20.0) 90 | assert view.matrix[5] == pytest.approx(-10.0) 91 | 92 | 93 | @pytest.mark.asyncio 94 | async def test_zoom_out_should_be_limited_to_20_percent(zoom_data, view): 95 | tool = zoom_tool() 96 | view.add_controller(tool) 97 | 98 | on_scale_changed(tool, 0.0, zoom_data) 99 | 100 | assert view.matrix[0] == pytest.approx(0.2) 101 | assert view.matrix[3] == pytest.approx(0.2) 102 | 103 | 104 | @pytest.mark.asyncio 105 | async def test_zoom_in_should_be_limited_to_20_times(zoom_data, view): 106 | tool = zoom_tool() 107 | view.add_controller(tool) 108 | 109 | on_scale_changed(tool, 100.0, zoom_data) 110 | 111 | assert view.matrix[0] == pytest.approx(20) 112 | assert view.matrix[3] == pytest.approx(20) 113 | -------------------------------------------------------------------------------- /tests/test_tree.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from gaphas.tree import Tree 4 | 5 | 6 | @pytest.fixture() 7 | def tree_fixture(): 8 | node = ["n0", "n1", "n2", "n3", "n4", "n5", "n6", "n7"] 9 | return Tree(), node 10 | 11 | 12 | def test_add(tree_fixture): 13 | """Test creating node trees.""" 14 | tree = tree_fixture[0] 15 | n = tree_fixture[1] 16 | tree.add(n[1]) 17 | assert len(tree._nodes) == 1 18 | assert len(tree._children) == 2 19 | assert len(tree._children[None]) == 1 20 | assert len(tree._children[n[1]]) == 0 21 | 22 | tree.add(n[2]) 23 | tree.add(n[3], parent=n[1]) 24 | assert len(tree._nodes) == 3 25 | assert len(tree._children) == 4 26 | assert len(tree._children[None]) == 2 27 | assert len(tree._children[n[1]]) == 1 28 | assert len(tree._children[n[2]]) == 0 29 | assert len(tree._children[n[2]]) == 0 30 | assert tree._nodes == [n[1], n[3], n[2]] 31 | 32 | tree.add(n[4], parent=n[3]) 33 | assert tree._nodes == [n[1], n[3], n[4], n[2]] 34 | 35 | tree.add(n[5], parent=n[3]) 36 | assert tree._nodes == [n[1], n[3], n[4], n[5], n[2]] 37 | 38 | tree.add(n[6], parent=n[2]) 39 | assert tree._nodes == [n[1], n[3], n[4], n[5], n[2], n[6]] 40 | 41 | tree.add(n[7], parent=n[1]) 42 | assert len(tree._children) == 8 43 | assert tree._nodes == [n[1], n[3], n[4], n[5], n[7], n[2], n[6]] 44 | assert tree.get_parent(n[7]) is n[1] 45 | assert tree.get_parent(n[6]) is n[2] 46 | assert tree.get_parent(n[5]) is n[3] 47 | assert tree.get_parent(n[4]) is n[3] 48 | assert tree.get_parent(n[3]) is n[1] 49 | assert tree.get_parent(n[2]) is None 50 | assert tree.get_parent(n[1]) is None 51 | 52 | 53 | def test_add_on_index(tree_fixture): 54 | tree = tree_fixture[0] 55 | n = tree_fixture[1] 56 | tree.add(n[1]) 57 | tree.add(n[2]) 58 | tree.add(n[3], index=1) 59 | assert tree.get_children(None) == [n[1], n[3], n[2]], tree.get_children(None) 60 | assert tree.nodes == [n[1], n[3], n[2]], tree.nodes 61 | 62 | tree.add(n[4], parent=n[3]) 63 | tree.add(n[5], parent=n[3], index=0) 64 | assert tree.get_children(None) == [n[1], n[3], n[2]], tree.get_children(None) 65 | assert tree.nodes == [n[1], n[3], n[5], n[4], n[2]], tree.nodes 66 | assert tree.get_children(n[3]) == [n[5], n[4]], tree.get_children(n[3]) 67 | 68 | 69 | def test_remove(tree_fixture): 70 | """Test removal of nodes.""" 71 | tree = tree_fixture[0] 72 | n = tree_fixture[1] 73 | tree.add(n[1]) 74 | tree.add(n[2]) 75 | tree.add(n[3], parent=n[1]) 76 | tree.add(n[4], parent=n[3]) 77 | tree.add(n[5], parent=n[4]) 78 | 79 | assert tree._nodes == [n[1], n[3], n[4], n[5], n[2]] 80 | 81 | all_ch = list(tree.get_all_children(n[1])) 82 | assert all_ch == [n[3], n[4], n[5]], all_ch 83 | 84 | tree.remove(n[4]) 85 | assert tree._nodes == [n[1], n[3], n[2]] 86 | 87 | tree.remove(n[1]) 88 | assert len(tree._children) == 2 89 | assert tree._children[None] == [n[2]] 90 | assert tree._children[n[2]] == [] 91 | assert tree._nodes == [n[2]] 92 | 93 | 94 | def test_siblings(tree_fixture): 95 | tree = tree_fixture[0] 96 | n = tree_fixture[1] 97 | tree.add(n[1]) 98 | tree.add(n[2]) 99 | tree.add(n[3]) 100 | 101 | assert tree.get_next_sibling(n[1]) is n[2] 102 | assert tree.get_next_sibling(n[2]) is n[3] 103 | try: 104 | tree.get_next_sibling(n[3]) 105 | except IndexError: 106 | pass # Okay 107 | else: 108 | raise AssertionError( 109 | f"Index should be out of range, not {tree.get_next_sibling(n[3])}" 110 | ) 111 | 112 | assert tree.get_previous_sibling(n[3]) is n[2] 113 | assert tree.get_previous_sibling(n[2]) is n[1] 114 | try: 115 | tree.get_previous_sibling(n[1]) 116 | except IndexError: 117 | pass # Okay 118 | else: 119 | raise AssertionError( 120 | f"Index should be out of range, not {tree.get_previous_sibling(n[1])}" 121 | ) 122 | 123 | 124 | def test_reparent(tree_fixture): 125 | tree = tree_fixture[0] 126 | n = tree_fixture[1] 127 | tree.add(n[1]) 128 | tree.add(n[2]) 129 | tree.add(n[3]) 130 | tree.add(n[4], parent=n[2]) 131 | tree.add(n[5], parent=n[4]) 132 | 133 | assert tree.nodes == [n[1], n[2], n[4], n[5], n[3]], tree.nodes 134 | 135 | tree.move(n[4], parent=n[1], index=0) 136 | assert tree.nodes == [n[1], n[4], n[5], n[2], n[3]], tree.nodes 137 | assert tree.get_children(n[2]) == [], tree.get_children(n[2]) 138 | assert tree.get_children(n[1]) == [n[4]], tree.get_children(n[1]) 139 | assert tree.get_children(n[4]) == [n[5]], tree.get_children(n[4]) 140 | 141 | tree.move(n[4], parent=None, index=0) 142 | assert tree.nodes == [n[4], n[5], n[1], n[2], n[3]], tree.nodes 143 | -------------------------------------------------------------------------------- /tests/test_undo.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaphor/gaphas/d355ced2b315faec4189bce21fde5f8f75fe5561/tests/test_undo.py -------------------------------------------------------------------------------- /tests/test_view.py: -------------------------------------------------------------------------------- 1 | """Test cases for the View class.""" 2 | 3 | import pytest 4 | from gi.repository import Gtk 5 | 6 | from gaphas.canvas import Canvas 7 | from gaphas.selection import Selection 8 | from gaphas.view import GtkView 9 | 10 | 11 | class CustomSelection(Selection): 12 | pass 13 | 14 | 15 | def test_custom_selection(): 16 | custom_selection = CustomSelection() 17 | view = GtkView(selection=custom_selection) 18 | 19 | assert view.selection is custom_selection 20 | 21 | 22 | def test_custom_selection_setter(): 23 | custom_selection = CustomSelection() 24 | view = GtkView() 25 | 26 | view.selection = custom_selection 27 | 28 | assert view.selection is custom_selection 29 | 30 | 31 | @pytest.mark.asyncio 32 | async def test_item_removal(view, canvas, box): 33 | await view.update() 34 | assert len(list(canvas.get_all_items())) == len(view._qtree) 35 | 36 | view.selection.focused_item = box 37 | canvas.remove(box) 38 | await view.update() 39 | 40 | assert not list(canvas.get_all_items()) 41 | assert len(view._qtree) == 0 42 | 43 | 44 | @pytest.mark.asyncio 45 | async def test_view_registration(): 46 | canvas = Canvas() 47 | 48 | # GTK view does register for updates though 49 | 50 | view = GtkView(canvas) 51 | assert len(canvas._registered_views) == 1 52 | 53 | window = Gtk.Window.new() 54 | window.set_child(view) 55 | 56 | view.model = None 57 | assert len(canvas._registered_views) == 0 58 | 59 | view.model = canvas 60 | assert len(canvas._registered_views) == 1 61 | 62 | 63 | @pytest.mark.asyncio 64 | async def test_view_registration_2(view, canvas, window): 65 | """Test view registration and destroy when view is destroyed.""" 66 | window.present() 67 | 68 | assert len(canvas._registered_views) == 1 69 | assert view in canvas._registered_views 70 | 71 | window.destroy() 72 | 73 | assert len(canvas._registered_views) == 0 74 | 75 | 76 | @pytest.mark.asyncio 77 | async def test_scroll_adjustments_signal(view, scrolled_window): 78 | assert view.hadjustment 79 | assert view.vadjustment 80 | assert view.hadjustment.get_value() == 0.0 81 | assert view.hadjustment.get_lower() == 0.0 82 | assert view.hadjustment.get_upper() == 0.0 83 | assert view.hadjustment.get_step_increment() == 0.0 84 | assert view.hadjustment.get_page_increment() == 0.0 85 | assert view.hadjustment.get_page_size() == 0.0 86 | assert view.vadjustment.get_value() == 0.0 87 | assert view.vadjustment.get_lower() == 0.0 88 | assert view.vadjustment.get_upper() == 0.0 89 | assert view.vadjustment.get_step_increment() == 0.0 90 | assert view.vadjustment.get_page_increment() == 0.0 91 | assert view.vadjustment.get_page_size() == 0.0 92 | 93 | 94 | @pytest.mark.asyncio 95 | async def test_scroll_adjustments(view, scrolled_window): 96 | assert scrolled_window.get_hadjustment() is view.hadjustment 97 | assert scrolled_window.get_vadjustment() is view.vadjustment 98 | 99 | 100 | @pytest.mark.asyncio 101 | async def test_will_not_remove_lone_controller(view): 102 | ctrl = Gtk.EventControllerMotion.new() 103 | 104 | removed = view.remove_controller(ctrl) 105 | 106 | assert not removed 107 | 108 | 109 | def test_can_add_and_remove_controller(view): 110 | ctrl = Gtk.EventControllerMotion.new() 111 | view.add_controller(ctrl) 112 | 113 | removed = view.remove_controller(ctrl) 114 | 115 | assert removed 116 | assert ctrl not in view.observe_controllers() 117 | 118 | 119 | def test_can_remove_all_controllers(view): 120 | ctrl = Gtk.EventControllerMotion.new() 121 | view.add_controller(ctrl) 122 | 123 | view.remove_all_controllers() 124 | 125 | assert ctrl not in view.observe_controllers() 126 | --------------------------------------------------------------------------------