././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1734303703.6661878 qtpynodeeditor-0.3.3/0000755000076500000240000000000014727657730013311 5ustar00kenstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/.codecov.yml0000644000076500000240000000026114725355310015516 0ustar00kenstaff# show coverage in CI status, not as a comment. comment: off coverage: status: project: default: target: auto patch: default: target: auto ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/.coveragerc0000644000076500000240000000022414725355310015413 0ustar00kenstaff[run] source = qtpynodeeditor [report] omit = #versioning .*version.* *_version.py #tests *test* qtpynodeeditor/tests/* ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/.flake80000644000076500000240000000172614725355310014455 0ustar00kenstaff[flake8] exclude = .git,__pycache__,build,dist,qtpynodeeditor/_version.py max-line-length = 88 select = C,E,F,W,B,B950 extend-ignore = E203, E501, E226, W503, W504 # Explanation section: # B950 # This takes into account max-line-length but only triggers when the value # has been exceeded by more than 10% (96 characters). # E203: Whitespace before ':' # This is recommended by black in relation to slice formatting. # E501: Line too long (82 > 79 characters) # Our line length limit is 88 (above 79 defined in E501). Ignore it. # E226: Missing whitespace around arithmetic operator # This is a stylistic choice which you'll find everywhere in pcdsdevices, for # example. Formulas can be easier to read when operators and operands # have no whitespace between them. # # W503: Line break occurred before a binary operator # W504: Line break occurred after a binary operator # flake8 wants us to choose one of the above two. Our choice # is to make no decision. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/.git_archival.txt0000644000076500000240000000017514725355310016552 0ustar00kenstaffnode: $Format:%H$ node-date: $Format:%cI$ describe-name: $Format:%(describe:tags=true,match=*[0-9]*)$ ref-names: $Format:%D$ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/.gitattributes0000644000076500000240000000004014725355310016161 0ustar00kenstaff.git_archival.txt export-subst ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1734303703.6556873 qtpynodeeditor-0.3.3/.github/0000755000076500000240000000000014727657730014651 5ustar00kenstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/.github/ISSUE_TEMPLATE.md0000644000076500000240000000205414725355310017342 0ustar00kenstaff ## Expected Behavior ## Current Behavior ## Possible Solution ## Steps to Reproduce (for bugs) 1. 2. 3. ## Context ## Your Environment ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/.github/PULL_REQUEST_TEMPLATE.md0000644000076500000240000000135714725355310020443 0ustar00kenstaff ## Description ## Motivation and Context ## How Has This Been Tested? ## Where Has This Been Documented? ././@PaxHeader0000000000000000000000000000003200000000000010210 xustar0026 mtime=1734303703.65591 qtpynodeeditor-0.3.3/.github/workflows/0000755000076500000240000000000014727657730016706 5ustar00kenstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734303620.0 qtpynodeeditor-0.3.3/.github/workflows/pcds.yml0000644000076500000240000000654114727657604020370 0ustar00kenstaffname: Testing on: push: pull_request: release: types: - created jobs: pre-commit: name: "pre-commit checks" uses: pcdshub/pcds-ci-helpers/.github/workflows/pre-commit.yml@master with: args: "--all-files" conda-test: strategy: fail-fast: false matrix: include: - python-version: "3.9" deploy-on-success: true - python-version: "3.10" - python-version: "3.11" - python-version: "3.12" name: "Conda" uses: pcdshub/pcds-ci-helpers/.github/workflows/python-conda-test.yml@master secrets: inherit with: package-name: "qtpynodeeditor" python-version: ${{ matrix.python-version }} experimental: ${{ matrix.experimental || false }} deploy-on-success: ${{ matrix.deploy-on-success || false }} testing-extras: "" system-packages: "" use-setuptools-scm: true pip-test-pyside6: strategy: fail-fast: false matrix: include: - python-version: "3.9" upload-artifact: true - python-version: "3.10" - python-version: "3.11" - python-version: "3.12" - python-version: "3.13" name: "Pip test (PySide6)" uses: ./.github/workflows/python-pip-test.yml secrets: inherit with: artifact-name: "PySide6" package-name: "qtpynodeeditor" python-version: ${{ matrix.python-version }} experimental: ${{ matrix.experimental || false }} deploy-on-success: ${{ matrix.deploy-on-success || false }} system-packages: "libegl1" testing-extras: "PySide6" pip-test-pyqt5: strategy: fail-fast: false matrix: include: - python-version: "3.9" - python-version: "3.10" - python-version: "3.11" - python-version: "3.12" - python-version: "3.13" name: "Pip test (pyqt5)" uses: ./.github/workflows/python-pip-test.yml secrets: inherit with: artifact-name: "PyQt5" package-name: "qtpynodeeditor" python-version: ${{ matrix.python-version }} experimental: ${{ matrix.experimental || false }} deploy-on-success: ${{ matrix.deploy-on-success || false }} system-packages: "" testing-extras: "PyQt5" # pip-test-pyqt6: # strategy: # fail-fast: false # matrix: # include: # - python-version: "3.9" # deploy-on-success: true # - python-version: "3.10" # - python-version: "3.11" # - python-version: "3.12" # - python-version: "3.13" # # name: "Pip test (pyqt6)" # uses: ./.github/workflows/python-pip-test.yml # secrets: inherit # with: # artifact-name: "PyQt6" # package-name: "qtpynodeeditor" # python-version: ${{ matrix.python-version }} # experimental: ${{ matrix.experimental || false }} # deploy-on-success: ${{ matrix.deploy-on-success || false }} # system-packages: "" # testing-extras: "PyQt6" # # pip-docs: # name: "Documentation" # uses: pcdshub/pcds-ci-helpers/.github/workflows/python-docs.yml@master # with: # package-name: "qtpynodeeditor" # python-version: "3.9" # deploy: ${{ github.repository_owner == "klauer" && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags')) }} # system-packages: "" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734303620.0 qtpynodeeditor-0.3.3/.github/workflows/python-pip-test.yml0000644000076500000240000002013014727657604022511 0ustar00kenstaff# Borrowed from https://github.com/pcdshub/pcds-ci-helpers/blob/master/.github/workflows/python-pip-test.yml name: Pip Build on: workflow_call: inputs: artifact-name: description: "Artifact name" required: true type: string package-name: description: "The Python-importable package name to be tested" required: true type: string python-version: description: "The Python version to build and test with" required: true type: string experimental: description: "Mark this version as experimental and not required to pass" required: false default: false type: boolean upload-artifact: description: "Deploy to PyPI on success (and when appropriate)" required: false default: false type: boolean deploy-on-success: description: "Deploy to PyPI on success (and when appropriate)" required: false default: false type: boolean testing-extras: default: "" description: "Extra packages to be installed for testing" required: false type: string ci-extras: default: "" description: "CI-specific packages to be installed" required: false type: string system-packages: default: "" description: "CI-specific system packages required for installation" required: false type: string outputs: {} env: MPLBACKEND: "agg" QT_QPA_PLATFORM: "offscreen" jobs: test: name: "Python ${{ inputs.python-version }}: pip" runs-on: ubuntu-20.04 continue-on-error: ${{ inputs.experimental }} defaults: run: # The following allows for each run step to utilize ~/.bash_profile # for setting up the per-step initial state. # --login: a login shell. Source ~/.bash_profile # -e: exit on first error # -o pipefail: piped processes are important; fail if they fail shell: bash --login -eo pipefail {0} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 submodules: "recursive" - name: Check version to be built run: | # NOTE: If you run CI on your own fork, you may not have the right version # number for the package. Synchronize your tags with the upstream, # otherwise cross-dependencies may result in confusing build failure. (echo "Package version: $(git describe --tags)" | tee "$GITHUB_STEP_SUMMARY") || \ echo "::warning::Git tags not found in repository. Build may fail!" - name: Check environment variables for issues run: | echo "* Package to be built: ${{ inputs.package-name }}" echo "* Pip 'extras' for CI testing: ${{ inputs.testing-extras }}" echo "* General pip packages required for CI testing: ${{ inputs.ci-extras }}" - name: Install required system packages if: inputs.system-packages != '' run: | sudo apt-get update sudo apt-get -y install ${{ inputs.system-packages }} - name: Prepare for log files run: | mkdir $HOME/logs - uses: actions/setup-python@v5 with: python-version: "${{ inputs.python-version }}" - name: Upgrade pip run: | pip install --upgrade pip - name: Build wheel and source distribution run: | python -m pip install twine build export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) echo "Source date epoch set to ${SOURCE_DATE_EPOCH} for reproducible build" # See: https://github.com/python/cpython/pull/5200 # And: https://reproducible-builds.org/docs/source-date-epoch/ # (I learned about this from DLS) python -m build --sdist --wheel --outdir ./dist - name: Check the source distribution run: | python -m venv test-source-env source test-source-env/bin/activate python -m pip install ./dist/*.gz if [ ! -z "${{ inputs.testing-extras }}" ]; then python -m pip install ${{ inputs.testing-extras }} fi python -c "import ${{ inputs.package-name }}; print('Imported ${{ inputs.package-name }} successfully')" - name: Use the wheel for testing run: | python -m venv test-wheel-env source test-wheel-env/bin/activate python -m pip install ./dist/*.whl if [ ! -z "${{ inputs.testing-extras }}" ]; then python -m pip install ${{ inputs.testing-extras }} fi python -c "import ${{ inputs.package-name }}; print('Imported ${{ inputs.package-name }} successfully')" - name: Installing CI extras and testing extras run: | # 1. escape '<' so the user doesn't have to # 2. escape '>' so the user doesn't have to # 3. allow conda/pip to use the same requirements spec; # conda expects pkg=ver but pip expects pkg==ver; using a basic # (not =<>)=(not =) to avoid incompatibility with macOS sed not supporting # '=\+' input_requirements=$( echo "${{ inputs.ci-extras }} ${{ inputs.testing-extras }}" | sed -e "s//\>/g" | sed -e 's/\([^=<>]\)=\([^=]\)/\1==\2/g' ) declare -a test_requirements=() for req in $input_requirements; do test_requirements+=( "$req" ) done set -x if [[ ${#test_requirements[@]} -gt 0 ]]; then echo "CI extras: ${{ inputs.ci-extras }}" echo "Testing extras: ${{ inputs.testing-extras }}" pip install "${test_requirements[@]}" .[test] else echo "No extras to install." pip install .[test] fi - name: Check the pip packages in the test env run: | pip list - name: Run tests run: | pytest -v \ --log-file="$HOME/logs/debug_log.txt" \ --log-format='%(asctime)s.%(msecs)03d %(module)-15s %(levelname)-8s %(threadName)-10s %(message)s' \ --log-file-date-format='%H:%M:%S' \ --log-level=DEBUG \ 2>&1 | tee "$HOME/logs/pytest_log.txt" - name: After failure if: ${{ failure() }} run: | # On failure: # * Include the pip package details # * Include the pytest log in the step summary (but not in the step output as it's available in the previous step) # * Include the debug log in the step output (but not the step summary as it's too verbose) ( echo "### Pip list" echo "
" echo "" echo '```' pip list | egrep -v -e "^#" echo '```' echo "
" echo "" echo "### Pytest log" echo '```python' cat "$HOME/logs/pytest_log.txt" || echo "# Pytest log not found?" echo '```' ) >> "$GITHUB_STEP_SUMMARY" echo "## Debug log" cat "$HOME/logs/debug_log.txt" || echo "Debug logfile not found?" # - name: Upload log file artifacts # if: ${{ always() }} # uses: actions/upload-artifact@v4 # with: # name: Python ${{ inputs.python-version }} - ${{ inputs.artifact-name }} - testing log # path: "~/logs" - name: Upload the package as an artifact if: ${{ inputs.upload-artifact }} uses: actions/upload-artifact@v4 with: name: Python ${{ inputs.python-version }} - ${{ inputs.artifact-name }} - package path: dist - name: PyPI deployment if: inputs.deploy-on-success && github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} run: | if [ -z "$TWINE_PASSWORD" ]; then echo "# No PYPI_TOKEN secret in job!" | tee -a "$GITHUB_STEP_SUMMARY" exit 1 fi twine upload --verbose dist/* ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734299658.0 qtpynodeeditor-0.3.3/.gitignore0000644000076500000240000000164514727650012015271 0ustar00kenstaff# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ venv/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *,cover .hypothesis/ # Translations *.mo *.pot # Django stuff: *.log # Sphinx documentation docs/build/ docs/source/generated/ # pytest .pytest_cache/ # PyBuilder target/ # Editor files # OSX stuff .DS_Store *~ #vim *.sw[op] #pycharm .idea/* #Ipython Notebook .ipynb_checkpoints # versioneer qtpynodeeditor/_version.py ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734299658.0 qtpynodeeditor-0.3.3/.pre-commit-config.yaml0000644000076500000240000000143114727650012017553 0ustar00kenstaff# See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks exclude: | (?x)^( qtpynodeeditor/_version.py| )$ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: no-commit-to-branch - id: trailing-whitespace - id: end-of-file-fixer - id: check-ast - id: check-case-conflict - id: check-json - id: check-merge-conflict - id: check-symlinks - id: check-xml - id: check-yaml exclude: '^(conda-recipe/meta.yaml)$' - id: debug-statements - repo: https://github.com/pycqa/flake8.git rev: 7.1.1 hooks: - id: flake8 - repo: https://github.com/timothycrosley/isort rev: 5.13.2 hooks: - id: isort ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/.pylintrc0000644000076500000240000004213614725355310015147 0ustar00kenstaff[MASTER] # A comma-separated list of package or module names from where C extensions may # be loaded. Extensions are loading into the active Python interpreter and may # run arbitrary code. extension-pkg-whitelist=qtpy,PyQt5 # Add files or directories to the blacklist. They should be base names, not # paths. ignore=CVS # Add files or directories matching the regex patterns to the blacklist. The # regex matches against base names, not paths. ignore-patterns= # Python code to execute, usually for sys.path manipulation such as # pygtk.require(). #init-hook= # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the # number of processors available to use. jobs=1 # Control the amount of potential inferred values when inferring a single # object. This can help the performance when dealing with large functions or # complex, nested conditions. limit-inference-results=100 # List of plugins (as comma separated values of python modules names) to load, # usually to register additional checkers. load-plugins= # Pickle collected data for later comparisons. persistent=yes # Specify a configuration file. #rcfile= # When enabled, pylint would attempt to guess common misconfiguration and emit # user-friendly hints instead of false-positive error messages. suggestion-mode=yes # Allow loading of arbitrary C extensions. Extensions are imported into the # active Python interpreter and may run arbitrary code. unsafe-load-any-extension=no [MESSAGES CONTROL] # Only show warnings with the listed confidence levels. Leave empty to show # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. confidence= # Disable the message, report, category or checker with the given id(s). You # can either give multiple identifiers separated by comma (,) or put this # option multiple times (only on the command line, not in the configuration # file where it should appear only once). You can also use "--disable=all" to # disable everything first and then reenable specific checks. For example, if # you want to run only the similarities checker, you can use "--disable=all # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use "--disable=all --enable=classes # --disable=W". disable=R, print-statement, parameter-unpacking, unpacking-in-except, old-raise-syntax, backtick, long-suffix, old-ne-operator, old-octal-literal, import-star-module-level, non-ascii-bytes-literal, raw-checker-failed, bad-inline-option, locally-disabled, file-ignored, suppressed-message, useless-suppression, deprecated-pragma, use-symbolic-message-instead, apply-builtin, basestring-builtin, buffer-builtin, cmp-builtin, coerce-builtin, execfile-builtin, file-builtin, long-builtin, raw_input-builtin, reduce-builtin, standarderror-builtin, unicode-builtin, xrange-builtin, coerce-method, delslice-method, getslice-method, setslice-method, no-absolute-import, old-division, dict-iter-method, dict-view-method, next-method-called, metaclass-assignment, indexing-exception, raising-string, reload-builtin, oct-method, hex-method, nonzero-method, cmp-method, input-builtin, round-builtin, intern-builtin, unichr-builtin, map-builtin-not-iterating, zip-builtin-not-iterating, range-builtin-not-iterating, filter-builtin-not-iterating, using-cmp-argument, eq-without-hash, div-method, idiv-method, rdiv-method, exception-message-attribute, invalid-str-codec, sys-max-int, bad-python3-import, deprecated-string-function, deprecated-str-translate-call, deprecated-itertools-function, deprecated-types-field, next-method-defined, dict-items-not-iterating, dict-keys-not-iterating, dict-values-not-iterating, deprecated-operator-function, deprecated-urllib-function, xreadlines-attribute, deprecated-sys-function, exception-escape, comprehension-escape # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option # multiple time (only on the command line, not in the configuration file where # it should appear only once). See also the "--disable" option for examples. enable=c-extension-no-member [REPORTS] # Python expression which should return a note less than 10 (10 is the highest # note). You have access to the variables errors warning, statement which # respectively contain the number of errors / warnings messages and the total # number of statements analyzed. This is used by the global evaluation report # (RP0004). evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) # Template used to display messages. This is a python new-style format string # used to format the message information. See doc for all details. #msg-template= # Set the output format. Available formats are text, parseable, colorized, json # and msvs (visual studio). You can also give a reporter class, e.g. # mypackage.mymodule.MyReporterClass. output-format=text # Tells whether to display a full report or only the messages. reports=no # Activate the evaluation score. score=yes [REFACTORING] # Maximum number of nested blocks for function / method body max-nested-blocks=5 # Complete name of functions that never returns. When checking for # inconsistent-return-statements if a never returning function is called then # it will be considered as an explicit return statement and no message will be # printed. never-returning-functions=sys.exit [LOGGING] # Format style used to check logging format string. `old` means using % # formatting, while `new` is for `{}` formatting. logging-format-style=old # Logging modules to check that the string format arguments are in logging # function parameter format. logging-modules=logging [SPELLING] # Limits count of emitted suggestions for spelling mistakes. max-spelling-suggestions=4 # Spelling dictionary name. Available dictionaries: none. To make it working # install python-enchant package.. spelling-dict= # List of comma separated words that should not be checked. spelling-ignore-words= # A path to a file that contains private dictionary; one word per line. spelling-private-dict-file= # Tells whether to store unknown words to indicated private dictionary in # --spelling-private-dict-file option instead of raising a message. spelling-store-unknown-words=no [MISCELLANEOUS] # List of note tags to take in consideration, separated by a comma. notes=FIXME, XXX, TODO [TYPECHECK] # List of decorators that produce context managers, such as # contextlib.contextmanager. Add to this list to register other decorators that # produce valid context managers. contextmanager-decorators=contextlib.contextmanager # List of members which are set dynamically and missed by pylint inference # system, and so shouldn't trigger E1101 when accessed. Python regular # expressions are accepted. generated-members= # Tells whether missing members accessed in mixin class should be ignored. A # mixin class is detected if its name ends with "mixin" (case insensitive). ignore-mixin-members=yes # Tells whether to warn about missing members when the owner of the attribute # is inferred to be None. ignore-none=yes # This flag controls whether pylint should warn about no-member and similar # checks whenever an opaque object is returned when inferring. The inference # can return multiple potential results while evaluating a Python object, but # some branches might not be evaluated, which results in partial inference. In # that case, it might be useful to still emit no-member and other checks for # the rest of the inferred objects. ignore-on-opaque-inference=yes # List of class names for which member attributes should not be checked (useful # for classes with dynamically set attributes). This supports the use of # qualified names. ignored-classes=optparse.Values,thread._local,_thread._local # List of module names for which member attributes should not be checked # (useful for modules/projects where namespaces are manipulated during runtime # and thus existing member attributes cannot be deduced by static analysis. It # supports qualified module names, as well as Unix pattern matching. ignored-modules= # Show a hint with possible names when a member name was not found. The aspect # of finding the hint is based on edit distance. missing-member-hint=yes # The minimum edit distance a name should have in order to be considered a # similar match for a missing member name. missing-member-hint-distance=1 # The total number of similar names that should be taken in consideration when # showing a hint for a missing member. missing-member-max-choices=1 [VARIABLES] # List of additional names supposed to be defined in builtins. Remember that # you should avoid defining new builtins when possible. additional-builtins= # Tells whether unused global variables should be treated as a violation. allow-global-unused-variables=yes # List of strings which can identify a callback function by name. A callback # name must start or end with one of those strings. callbacks=cb_, _cb # A regular expression matching the name of dummy variables (i.e. expected to # not be used). dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ # Argument names that match this expression will be ignored. Default to name # with leading underscore. ignored-argument-names=_.*|^ignored_|^unused_ # Tells whether we should check for unused import in __init__ files. init-import=no # List of qualified module names which can have objects that can redefine # builtins. redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io [FORMAT] # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. expected-line-ending-format= # Regexp for a line that is allowed to be longer than the limit. ignore-long-lines=^\s*(# )??$ # Number of spaces of indent required inside a hanging or continued line. indent-after-paren=4 # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 # tab). indent-string=' ' # Maximum number of characters on a single line. max-line-length=100 # Maximum number of lines in a module. max-module-lines=1000 # List of optional constructs for which whitespace checking is disabled. `dict- # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. # `trailing-comma` allows a space between comma and closing bracket: (a, ). # `empty-line` allows space-only lines. no-space-check=trailing-comma, dict-separator # Allow the body of a class to be on the same line as the declaration if body # contains single statement. single-line-class-stmt=no # Allow the body of an if to be on the same line as the test if there is no # else. single-line-if-stmt=no [SIMILARITIES] # Ignore comments when computing similarities. ignore-comments=yes # Ignore docstrings when computing similarities. ignore-docstrings=yes # Ignore imports when computing similarities. ignore-imports=no # Minimum lines number of a similarity. min-similarity-lines=4 [BASIC] # Naming style matching correct argument names. argument-naming-style=snake_case # Regular expression matching correct argument names. Overrides argument- # naming-style. #argument-rgx= # Naming style matching correct attribute names. attr-naming-style=snake_case # Regular expression matching correct attribute names. Overrides attr-naming- # style. #attr-rgx= # Bad variable names which should always be refused, separated by a comma. bad-names=foo, bar, baz, toto, tutu, tata # Naming style matching correct class attribute names. class-attribute-naming-style=any # Regular expression matching correct class attribute names. Overrides class- # attribute-naming-style. #class-attribute-rgx= # Naming style matching correct class names. class-naming-style=PascalCase # Regular expression matching correct class names. Overrides class-naming- # style. #class-rgx= # Naming style matching correct constant names. const-naming-style=UPPER_CASE # Regular expression matching correct constant names. Overrides const-naming- # style. #const-rgx= # Minimum line length for functions/classes that require docstrings, shorter # ones are exempt. docstring-min-length=-1 # Naming style matching correct function names. function-naming-style=snake_case # Regular expression matching correct function names. Overrides function- # naming-style. #function-rgx= # Good variable names which should always be accepted, separated by a comma. good-names=i, j, k, ex, Run, _ # Include a hint for the correct naming format with invalid-name. include-naming-hint=no # Naming style matching correct inline iteration names. inlinevar-naming-style=any # Regular expression matching correct inline iteration names. Overrides # inlinevar-naming-style. #inlinevar-rgx= # Naming style matching correct method names. method-naming-style=snake_case # Regular expression matching correct method names. Overrides method-naming- # style. #method-rgx= # Naming style matching correct module names. module-naming-style=snake_case # Regular expression matching correct module names. Overrides module-naming- # style. #module-rgx= # Colon-delimited sets of names that determine each other's naming style when # the name regexes allow several styles. name-group= # Regular expression which should only match function or class names that do # not require a docstring. no-docstring-rgx=^_ # List of decorators that produce properties, such as abc.abstractproperty. Add # to this list to register other decorators that produce valid properties. # These decorators are taken in consideration only for invalid-name. property-classes=abc.abstractproperty # Naming style matching correct variable names. variable-naming-style=snake_case # Regular expression matching correct variable names. Overrides variable- # naming-style. #variable-rgx= [STRING] # This flag controls whether the implicit-str-concat-in-sequence should # generate a warning on implicit string concatenation in sequences defined over # several lines. check-str-concat-over-line-jumps=no [IMPORTS] # Allow wildcard imports from modules that define __all__. allow-wildcard-with-all=no # Analyse import fallback blocks. This can be used to support both Python 2 and # 3 compatible code, which means that the block might have code that exists # only in one or another interpreter, leading to false positives when analysed. analyse-fallback-blocks=no # Deprecated modules which should not be used, separated by a comma. deprecated-modules=optparse,tkinter.tix # Create a graph of external dependencies in the given file (report RP0402 must # not be disabled). ext-import-graph= # Create a graph of every (i.e. internal and external) dependencies in the # given file (report RP0402 must not be disabled). import-graph= # Create a graph of internal dependencies in the given file (report RP0402 must # not be disabled). int-import-graph= # Force import order to recognize a module as part of the standard # compatibility libraries. known-standard-library= # Force import order to recognize a module as part of a third party library. known-third-party=enchant [CLASSES] # List of method names used to declare (i.e. assign) instance attributes. defining-attr-methods=__init__, __new__, setUp # List of member names, which should be excluded from the protected access # warning. exclude-protected=_asdict, _fields, _replace, _source, _make # List of valid names for the first argument in a class method. valid-classmethod-first-arg=cls # List of valid names for the first argument in a metaclass class method. valid-metaclass-classmethod-first-arg=cls [DESIGN] # Maximum number of arguments for function / method. max-args=5 # Maximum number of attributes for a class (see R0902). max-attributes=7 # Maximum number of boolean expressions in an if statement. max-bool-expr=5 # Maximum number of branch for function / method body. max-branches=12 # Maximum number of locals for function / method body. max-locals=15 # Maximum number of parents for a class (see R0901). max-parents=7 # Maximum number of public methods for a class (see R0904). max-public-methods=20 # Maximum number of return / yield for function / method body. max-returns=6 # Maximum number of statements in function / method body. max-statements=50 # Minimum number of public methods for a class (see R0903). min-public-methods=2 [EXCEPTIONS] # Exceptions that will emit a warning when being caught. Defaults to # "BaseException, Exception". overgeneral-exceptions=BaseException, Exception ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/AUTHORS.rst0000644000076500000240000000020214725355310015145 0ustar00kenstaff======= Credits ======= Maintainer ---------- * Ken Lauer @klauer Contributors ------------ Interested? See: CONTRIBUTING.rst ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/CONTRIBUTING.rst0000644000076500000240000000600314725355310015734 0ustar00kenstaff============ Contributing ============ Contributions are welcome, and they are greatly appreciated! Every little bit helps, and credit will always be given. You can contribute in many ways: Types of Contributions ---------------------- Report Bugs ~~~~~~~~~~~ Report bugs at https://github.com/klauer/qtpynodeeditor/issues. If you are reporting a bug, please include: * Any details about your local setup that might be helpful in troubleshooting. * Detailed steps to reproduce the bug. Fix Bugs ~~~~~~~~ Look through the GitHub issues for bugs. Anything tagged with "bug" is open to whoever wants to implement it. Implement Features ~~~~~~~~~~~~~~~~~~ Look through the GitHub issues for features. Anything tagged with "feature" is open to whoever wants to implement it. Write Documentation ~~~~~~~~~~~~~~~~~~~ qtpynodeeditor could always use more documentation, whether as part of the official qtpynodeeditor docs, in docstrings, or even on the web in blog posts, articles, and such. Submit Feedback ~~~~~~~~~~~~~~~ The best way to send feedback is to file an issue at https://github.com/klauer/qtpynodeeditor/issues. If you are proposing a feature: * Explain in detail how it would work. * Keep the scope as narrow as possible, to make it easier to implement. * Remember that this is a volunteer-driven project, and that contributions are welcome :) Get Started! ------------ Ready to contribute? Here's how to set up `qtpynodeeditor` for local development. 1. Fork the `qtpynodeeditor` repo on GitHub. 2. Clone your fork locally:: $ git clone git@github.com:your_name_here/qtpynodeeditor.git 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: $ mkvirtualenv qtpynodeeditor $ cd qtpynodeeditor/ $ python setup.py develop 4. Create a branch for local development:: $ git checkout -b name-of-your-bugfix-or-feature Now you can make your changes locally. 5. When you're done making changes, check that your changes pass flake8 and the tests, including testing other Python versions with tox:: $ flake8 qtpynodeeditor tests $ python setup.py test $ tox To get flake8 and tox, just pip install them into your virtualenv. 6. Commit your changes and push your branch to GitHub:: $ git add . $ git commit -m "Your detailed description of your changes." $ git push origin name-of-your-bugfix-or-feature 7. Submit a pull request through the GitHub website. Pull Request Guidelines ----------------------- Before you submit a pull request, check that it meets these guidelines: 1. The pull request should include tests. 2. If the pull request adds functionality, the docs should be updated. Put your new functionality into a function with a docstring, and add the feature to the list in README.rst. 3. The pull request should work for Python 3.6 and up. Check https://travis-ci.org/klauer/qtpynodeeditor/pull_requests and make sure that the tests pass for all supported Python versions. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/LICENSE0000644000076500000240000000315114725355310014301 0ustar00kenstaffCopyright (c) 2019, Ken Lauer All rights reserved. qtpynodeeditor is a derivative work of NodeEditor by Dmitry Pinaev. It follows in the footsteps of the original and is licensed by the BSD 3-clause license. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of copyright holder, nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734303620.0 qtpynodeeditor-0.3.3/MANIFEST.in0000644000076500000240000000042314727657604015046 0ustar00kenstaffinclude AUTHORS.rst include CONTRIBUTING.rst include LICENSE include README.rst recursive-exclude * __pycache__ recursive-exclude * *.py[co] recursive-include docs *.rst conf.py Makefile make.bat include qtpynodeeditor/_version.py include qtpynodeeditor/DefaultStyle.json ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1734303703.6659777 qtpynodeeditor-0.3.3/PKG-INFO0000644000076500000240000001116714727657730014414 0ustar00kenstaffMetadata-Version: 2.1 Name: qtpynodeeditor Version: 0.3.3 Summary: Python Qt node editor Author: Ken Lauer License: Copyright (c) 2019, Ken Lauer All rights reserved. qtpynodeeditor is a derivative work of NodeEditor by Dmitry Pinaev. It follows in the footsteps of the original and is licensed by the BSD 3-clause license. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of copyright holder, nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. Classifier: Development Status :: 2 - Pre-Alpha Classifier: Natural Language :: English Classifier: Programming Language :: Python :: 3 Requires-Python: >=3.9 Description-Content-Type: text/x-rst License-File: LICENSE License-File: AUTHORS.rst Requires-Dist: qtpy Provides-Extra: pyqt Requires-Dist: PyQt6; extra == "pyqt" Provides-Extra: pyqt5 Requires-Dist: PyQt5; extra == "pyqt5" Provides-Extra: pyqt6 Requires-Dist: PyQt6; extra == "pyqt6" Provides-Extra: pyside Requires-Dist: PySide6; extra == "pyside" Provides-Extra: pyside6 Requires-Dist: PySide6; extra == "pyside6" Provides-Extra: test Requires-Dist: pytest; extra == "test" Requires-Dist: pytest-qt; extra == "test" Requires-Dist: pytest-cov; extra == "test" Provides-Extra: doc Requires-Dist: sphinx; extra == "doc" Requires-Dist: ipython; extra == "doc" Requires-Dist: numpydoc; extra == "doc" Requires-Dist: sphinx-copybutton; extra == "doc" Requires-Dist: sphinx_rtd_theme; extra == "doc" Requires-Dist: sphinxcontrib-jquery; extra == "doc" .. image:: https://img.shields.io/travis/klauer/qtpynodeeditor.svg :target: https://travis-ci.org/klauer/qtpynodeeditor .. image:: https://img.shields.io/pypi/v/qtpynodeeditor.svg :target: https://pypi.python.org/pypi/qtpynodeeditor =============================== qtpynodeeditor =============================== Python Qt node editor Pure Python port of `NodeEditor `_, supporting PyQt5 and PySide through `qtpy `_. Requirements ------------ * Python 3.6+ * qtpy * PyQt5 / PySide Documentation ------------- `Sphinx-generated documentation `_ Screenshots ----------- `Style example `_ .. image:: https://raw.githubusercontent.com/klauer/qtpynodeeditor/assets/screenshots/style.png `Calculator example `_ .. image:: https://raw.githubusercontent.com/klauer/qtpynodeeditor/assets/screenshots/calculator.png Installation ------------ We recommend using conda to install qtpynodeeditor. :: $ conda create -n my_new_environment -c conda-forge python=3.7 qtpynodeeditor $ conda activate my_new_environment qtpynodeeditor may also be installed using pip from PyPI. :: $ python -m pip install qtpynodeeditor[pyqt5] $ python -m pip install qtpynodeeditor[pyqt6] $ python -m pip install qtpynodeeditor[pyside] Running the Tests ----------------- Tests must be run with pytest and pytest-qt. :: $ pip install .[pyqt5,test] $ pytest -v qtpynodeeditor/tests ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734303620.0 qtpynodeeditor-0.3.3/README.rst0000644000076500000240000000326014727657604015001 0ustar00kenstaff.. image:: https://img.shields.io/travis/klauer/qtpynodeeditor.svg :target: https://travis-ci.org/klauer/qtpynodeeditor .. image:: https://img.shields.io/pypi/v/qtpynodeeditor.svg :target: https://pypi.python.org/pypi/qtpynodeeditor =============================== qtpynodeeditor =============================== Python Qt node editor Pure Python port of `NodeEditor `_, supporting PyQt5 and PySide through `qtpy `_. Requirements ------------ * Python 3.6+ * qtpy * PyQt5 / PySide Documentation ------------- `Sphinx-generated documentation `_ Screenshots ----------- `Style example `_ .. image:: https://raw.githubusercontent.com/klauer/qtpynodeeditor/assets/screenshots/style.png `Calculator example `_ .. image:: https://raw.githubusercontent.com/klauer/qtpynodeeditor/assets/screenshots/calculator.png Installation ------------ We recommend using conda to install qtpynodeeditor. :: $ conda create -n my_new_environment -c conda-forge python=3.7 qtpynodeeditor $ conda activate my_new_environment qtpynodeeditor may also be installed using pip from PyPI. :: $ python -m pip install qtpynodeeditor[pyqt5] $ python -m pip install qtpynodeeditor[pyqt6] $ python -m pip install qtpynodeeditor[pyside] Running the Tests ----------------- Tests must be run with pytest and pytest-qt. :: $ pip install .[pyqt5,test] $ pytest -v qtpynodeeditor/tests ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1734303703.6560574 qtpynodeeditor-0.3.3/conda-recipe/0000755000076500000240000000000014727657730015642 5ustar00kenstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/conda-recipe/meta.yaml0000644000076500000240000000127114725355310017440 0ustar00kenstaff{% set package_name = "qtpynodeeditor" %} {% set import_name = "qtpynodeeditor" %} {% set version = load_file_regex(load_file=os.path.join(import_name, "_version.py"), regex_pattern=".*version = '(\S+)'").group(1) %} package: name: {{ package_name }} version: {{ version }} source: path: .. build: number: 0 noarch: python script: {{ PYTHON }} -m pip install . -vv requirements: build: - python >=3.6 - setuptools_scm - pip run: - python >=3.6 - pyqt >=5 - qtpy test: imports: - qtpynodeeditor requires: - pytest - pytest-qt - pytest-cov about: home: https://github.com/klauer/qtpynodeeditor license: BSD 3-clause summary: Python Qt node editor ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734303703.656496 qtpynodeeditor-0.3.3/docs/0000755000076500000240000000000014727657730014241 5ustar00kenstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/docs/Makefile0000644000076500000240000000115014725355310015661 0ustar00kenstaff# Minimal makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build SPHINXPROJ = qtpynodeeditor SOURCEDIR = source BUILDDIR = build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/docs/make.bat0000644000076500000240000000142214725355310015630 0ustar00kenstaff@ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=source set BUILDDIR=build set SPHINXPROJ=qtpynodeeditor if "%1" == "" goto help %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% :end popd ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1734303703.6578295 qtpynodeeditor-0.3.3/docs/source/0000755000076500000240000000000014727657730015541 5ustar00kenstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/docs/source/api.rst0000644000076500000240000000476214725355310017040 0ustar00kenstaff=== API === .. currentmodule:: qtpynodeeditor -------------- Scene and View -------------- FlowScene ========= .. autoclass:: FlowScene :members: FlowView ======== .. autoclass:: FlowView :members: ------ Styles ------ StyleCollection =============== .. autoclass:: StyleCollection :members: Style ===== .. autoclass:: Style :members: ConnectionStyle =============== .. autoclass:: ConnectionStyle :members: FlowViewStyle ============= .. autoclass:: FlowViewStyle :members: NodeStyle ========= .. autoclass:: NodeStyle :members: ----- Nodes ----- Node ==== .. autoclass:: Node :members: NodeData ======== .. autoclass:: NodeData :members: NodeDataModel ============= .. autoclass:: NodeDataModel :members: NodeState ========= .. autoclass:: NodeState :members: NodeDataType ============ .. autoclass:: NodeDataType :members: NodeGeometry ============ .. autoclass:: NodeGeometry :members: NodeGraphicsObject ================== .. autoclass:: NodeGraphicsObject :members: DataModelRegistry ================= .. autoclass:: DataModelRegistry :members: ----------- Connections ----------- Connection ========== .. autoclass:: Connection :members: ConnectionGeometry ================== .. autoclass:: ConnectionGeometry :members: ConnectionGraphicsObject ======================== .. autoclass:: ConnectionGraphicsObject :members: Exceptions ========== .. autoclass:: NodeConnectionFailure .. autoclass:: ConnectionCycleFailure .. autoclass:: ConnectionDataTypeFailure .. autoclass:: ConnectionPointFailure .. autoclass:: ConnectionPortNotEmptyFailure .. autoclass:: ConnectionRequiresPortFailure .. autoclass:: ConnectionSelfFailure .. autoclass:: MultipleInputConnectionError .. autoclass:: PortsAlreadyConnectedError .. autoclass:: PortsOfSameTypeError ----- Ports ----- Port ==== .. autoclass:: Port :members: PortType ======== .. autoclass:: PortType :members: ---------------- Other / Internal ---------------- ConnectionPainter ================= .. autoclass:: ConnectionPainter :members: ConnectionPolicy ================ .. autoclass:: ConnectionPolicy :members: NodeConnectionInteraction ========================= .. autoclass:: NodeConnectionInteraction :members: NodePainter =========== .. autoclass:: NodePainter :members: NodePainterDelegate =================== .. autoclass:: NodePainterDelegate :members: NodeValidationState =================== .. autoclass:: NodeValidationState :members: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/docs/source/conf.py0000644000076500000240000001242214725355310017024 0ustar00kenstaff# # Configuration file for the Sphinx documentation builder. # # This file does only contain a selection of the most common options. For a # full list see the documentation: # http://www.sphinx-doc.org/en/master/config # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # import os import sys module_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../") sys.path.insert(0, module_path) # -- Project information ----------------------------------------------------- project = "qtpynodeeditor" copyright = "2019, Ken Lauer" author = "Ken Lauer" # The short X.Y version version = "" # The full version, including alpha/beta/rc tags release = "" # -- General configuration --------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. # # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ "sphinxcontrib.jquery", "sphinx.ext.autodoc", "sphinx.ext.todo", "sphinx.ext.coverage", "sphinx.ext.mathjax", "sphinx.ext.viewcode", "sphinx.ext.githubpages", "numpydoc", ] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] autosummary_generate = True numpydoc_show_class_members = False # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] source_suffix = ".rst" # The master toctree document. master_doc = "index" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. language = "en" # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path . exclude_patterns = [] # The name of the Pygments (syntax highlighting) style to use. pygments_style = "sphinx" # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = "sphinx_rtd_theme" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # # html_theme_options = {} # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] # Custom sidebar templates, must be a dictionary that maps document names # to template names. # # The default sidebars (for documents that don't match any pattern) are # defined by theme itself. Builtin themes are using these templates by # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', # 'searchbox.html']``. # # html_sidebars = {} # -- Options for HTMLHelp output --------------------------------------------- # Output file base name for HTML help builder. htmlhelp_basename = "qtpynodeeditor" # -- Options for LaTeX output ------------------------------------------------ latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # # 'preamble': '', # Latex figure (float) alignment # # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ ( master_doc, "qtpynodeeditor.tex", "qtpynodeeditor Documentation", "Ken Lauer", "manual", ), ] # -- Options for manual page output ------------------------------------------ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ (master_doc, "qtpynodeeditor", "qtpynodeeditor Documentation", [author], 1) ] # -- Options for Texinfo output ---------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ( master_doc, "qtpynodeeditor", "qtpynodeeditor Documentation", author, "qtpynodeeditor", "Python Qt Node Editor", "Miscellaneous", ), ] # -- Extension configuration ------------------------------------------------- # -- Options for todo extension ---------------------------------------------- # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = True ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/docs/source/getting_started.rst0000644000076500000240000000274214725355310021452 0ustar00kenstaffGetting Started =============== Requirements ------------ * Python 3.6+ * qtpy * PyQt5 / PySide Installation ------------ We recommend using conda to install qtpynodeeditor. :: $ conda create -n my_new_environment -c conda-forge python=3.7 qtpynodeeditor $ conda activate my_new_environment qtpynodeeditor may also be installed using pip from PyPI. :: $ python -m pip install qtpynodeeditor pyqt5 Examples -------- 1. `Connection colors `_ :: $ python -m qtpynodeeditor.examples.connection_colors .. image:: https://raw.githubusercontent.com/klauer/qtpynodeeditor/assets/screenshots/connection_colors.png 2. `Image `_ :: $ python -m qtpynodeeditor.examples.image .. image:: https://raw.githubusercontent.com/klauer/qtpynodeeditor/assets/screenshots/image.png 3. `Style `_ :: $ python -m qtpynodeeditor.examples.style .. image:: https://raw.githubusercontent.com/klauer/qtpynodeeditor/assets/screenshots/style.png 4. `Calculator `_ :: $ python -m qtpynodeeditor.examples.calculator .. image:: https://raw.githubusercontent.com/klauer/qtpynodeeditor/assets/screenshots/calculator.png ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/docs/source/index.rst0000644000076500000240000000075614725355310017375 0ustar00kenstaff============== qtpynodeeditor ============== A pure Python port of `NodeEditor `_. Contents ======== .. toctree:: :maxdepth: 3 :caption: API Documentation :hidden: getting_started.rst api.rst release_notes.rst .. toctree:: :maxdepth: 1 :caption: Links :hidden: Github Repository Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/docs/source/release_notes.rst0000644000076500000240000000346614725355310021117 0ustar00kenstaff================= Release History ================= v0.2.0 (2020-09-02) =================== Enhancements ------------ * Verify connection compatibility with :class:`~qtpynodeeditor.NodeDataType` (`#43 `_) Fixes ----- * Do not allow for cyclic connections in the scene (`#35 `_) * :class:`~qtpynodeeditor.NodeDataModel` ``input_connection_created`` now only called once (`#27 `_) * Incorrect connections in calculator example (`#38 `_) * Fix filename globbing in open/save file dialogs. API Changes ----------- * :class:`~qtpynodeeditor.Connection` property ``output_node`` should be used in favor of the now-deprecated ``node`` property. * New connection failure exceptions: :class:`~qtpynodeeditor.ConnectionCycleFailure` and :class:`~qtpynodeeditor.ConnectionDataTypeFailure`. * Fixed deprecated ``QFontMetrics.width``. Contributors ------------ * @tfarago (`#43 `_, `#28 `_) Thanks to those who reported issues and contributed to this release. v0.1.0 (2020-03-29) =================== Now available on conda-forge:: conda install -c conda-forge qtpynodeeditor Fixes ----- * Packaging of style configuration Development ----------- * Testing and supporting pyqt5 / PySide2 * Miscellaneous cleaning and fixing, along with better continuous integration testing API Changes ----------- * New signature for ``node_context_menu`` signal: ``(node, scene_pos, screen_pos)``. v0.0.1 (2020-03-29) =================== Initial test release of qtpynodeeditor. Now available on PyPI:: pip install qtpynodeeditor ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734303620.0 qtpynodeeditor-0.3.3/pyproject.toml0000644000076500000240000000203614727657604016226 0ustar00kenstaff[build-system] build-backend = "setuptools.build_meta" requires = ["setuptools>=45", "setuptools_scm[toml]>=6.2"] [project] classifiers = [ "Development Status :: 2 - Pre-Alpha", "Natural Language :: English", "Programming Language :: Python :: 3", ] description = "Python Qt node editor" dependencies = ["qtpy"] dynamic = ["version", "readme"] keywords = [] name = "qtpynodeeditor" requires-python = ">=3.9" [[project.authors]] name = "Ken Lauer" [project.license] file = "LICENSE" [project.optional-dependencies] pyqt = ["PyQt6"] pyqt5 = ["PyQt5"] pyqt6 = ["PyQt6"] pyside = ["PySide6"] pyside6 = ["PySide6"] test = ["pytest", "pytest-qt", "pytest-cov"] doc = [ "sphinx", "ipython", "numpydoc", "sphinx-copybutton", "sphinx_rtd_theme", "sphinxcontrib-jquery", ] [options] zip_safe = false include_package_data = true [tool.setuptools_scm] write_to = "qtpynodeeditor/_version.py" [tool.setuptools.packages.find] where = ["."] include = ["qtpynodeeditor*"] namespaces = false [tool.setuptools.dynamic.readme] file = "README.rst" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1734303703.6631887 qtpynodeeditor-0.3.3/qtpynodeeditor/0000755000076500000240000000000014727657730016363 5ustar00kenstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/qtpynodeeditor/DefaultStyle.json0000644000076500000240000000202714725355310021647 0ustar00kenstaff{ "FlowViewStyle": { "BackgroundColor": [53, 53, 53], "FineGridColor": [60, 60, 60], "CoarseGridColor": [25, 25, 25] }, "NodeStyle": { "NormalBoundaryColor": [255, 255, 255], "SelectedBoundaryColor": [255, 165, 0], "GradientColor0": "gray", "GradientColor1": [80, 80, 80], "GradientColor2": [64, 64, 64], "GradientColor3": [58, 58, 58], "ShadowColor": [20, 20, 20], "FontColor" : "white", "FontColorFaded" : "gray", "ConnectionPointColor": [169, 169, 169], "FilledConnectionPointColor": "cyan", "ErrorColor": "red", "WarningColor": [128, 128, 0], "PenWidth": 1.0, "HoveredPenWidth": 1.5, "ConnectionPointDiameter": 8.0, "Opacity": 0.8 }, "ConnectionStyle": { "ConstructionColor": "gray", "NormalColor": "darkcyan", "SelectedColor": [100, 100, 100], "SelectedHaloColor": "orange", "HoveredColor": "lightcyan", "LineWidth": 3.0, "ConstructionLineWidth": 2.0, "PointDiameter": 10.0, "UseDataDefinedColors": false } } ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/qtpynodeeditor/__init__.py0000644000076500000240000000424014725355310020457 0ustar00kenstafffrom .connection import Connection from .connection_geometry import ConnectionGeometry from .connection_graphics_object import ConnectionGraphicsObject from .connection_painter import ConnectionPainter from .data_model_registry import DataModelRegistry from .enums import ConnectionPolicy, NodeValidationState, PortType from .exceptions import (ConnectionCycleFailure, ConnectionDataTypeFailure, ConnectionPointFailure, ConnectionPortNotEmptyFailure, ConnectionRequiresPortFailure, ConnectionSelfFailure, MultipleInputConnectionError, NodeConnectionFailure, PortsAlreadyConnectedError, PortsOfSameTypeError) from .flow_scene import FlowScene from .flow_view import FlowView from .node import Node, NodeDataType from .node_connection_interaction import NodeConnectionInteraction from .node_data import NodeData, NodeDataModel from .node_geometry import NodeGeometry from .node_graphics_object import NodeGraphicsObject from .node_painter import NodePainter, NodePainterDelegate from .node_state import NodeState from .port import Port, opposite_port from .style import (ConnectionStyle, FlowViewStyle, NodeStyle, Style, StyleCollection) from .version import __version__ # noqa: F401 __all__ = [ 'Connection', 'ConnectionCycleFailure', 'ConnectionDataTypeFailure', 'ConnectionGeometry', 'ConnectionGraphicsObject', 'ConnectionPainter', 'ConnectionPointFailure', 'ConnectionPolicy', 'ConnectionPortNotEmptyFailure', 'ConnectionRequiresPortFailure', 'ConnectionSelfFailure', 'ConnectionStyle', 'DataModelRegistry', 'FlowScene', 'FlowView', 'FlowViewStyle', 'MultipleInputConnectionError', 'Node', 'NodeConnectionFailure', 'NodeConnectionInteraction', 'NodeData', 'NodeDataModel', 'NodeDataType', 'NodeGeometry', 'NodeGraphicsObject', 'NodePainter', 'NodePainterDelegate', 'NodeState', 'NodeStyle', 'NodeValidationState', 'Port', 'PortType', 'PortsAlreadyConnectedError', 'PortsOfSameTypeError', 'Style', 'StyleCollection', 'opposite_port', ] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734303703.0 qtpynodeeditor-0.3.3/qtpynodeeditor/_version.py0000644000076500000240000000063314727657727020571 0ustar00kenstaff# file generated by setuptools_scm # don't change, don't track in version control TYPE_CHECKING = False if TYPE_CHECKING: from typing import Tuple, Union VERSION_TUPLE = Tuple[Union[int, str], ...] else: VERSION_TUPLE = object version: str __version__: str __version_tuple__: VERSION_TUPLE version_tuple: VERSION_TUPLE __version__ = version = '0.3.3' __version_tuple__ = version_tuple = (0, 3, 3) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/qtpynodeeditor/base.py0000644000076500000240000000052614725355310017635 0ustar00kenstaffclass Serializable: 'Interface for a serializable class' def save(self) -> dict: """ Save Returns ------- value : dict """ ... def restore(self, state: dict): """ Restore Parameters ---------- state : dict """ ... ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/qtpynodeeditor/connection.py0000644000076500000240000002627414725355310021072 0ustar00kenstaffimport typing import uuid from qtpy.QtCore import QObject, Signal from . import exceptions from .base import Serializable from .connection_geometry import ConnectionGeometry from .connection_graphics_object import ConnectionGraphicsObject from .node import Node, NodeDataType from .node_data import NodeData from .port import Port, PortType, opposite_port from .style import StyleCollection from .type_converter import TypeConverter class Connection(QObject, Serializable): connection_completed = Signal(QObject) connection_made_incomplete = Signal(QObject) updated = Signal(QObject) def __init__(self, port_a: Port, port_b: Port = None, *, style: StyleCollection, converter: TypeConverter = None): super().__init__() self._uid = str(uuid.uuid4()) if port_a is None: raise ValueError('port_a is required') elif port_a is port_b: raise ValueError('Cannot connect a port to itself') if port_a.port_type == PortType.input: in_port = port_a out_port = port_b else: in_port = port_b out_port = port_a if in_port is not None and out_port is not None: if in_port.port_type == out_port.port_type: raise exceptions.PortsOfSameTypeError( 'Cannot connect two ports of the same type') self._ports = { PortType.input: in_port, PortType.output: out_port } if in_port is not None: if in_port.connections: conn, = in_port.connections existing_in, existing_out = conn.ports if existing_in == in_port and existing_out == out_port: raise exceptions.PortsAlreadyConnectedError( 'Specified ports already connected') raise exceptions.MultipleInputConnectionError( f'Maximum one connection per input port ' f'(existing: {conn})') if in_port and out_port: self._required_port = PortType.none elif in_port: self._required_port = PortType.output else: self._required_port = PortType.input self._last_hovered_node = None self._converter = converter self._style = style self._connection_geometry = ConnectionGeometry(style) self._graphics_object = None def _cleanup(self): if self.is_complete: self.connection_made_incomplete.emit(self) self.propagate_empty_data() self.last_hovered_node = None for port_type, port in self.valid_ports.items(): if port.node.graphics_object is not None: port.node.graphics_object.update() self._ports[port] = None if self._graphics_object is not None: self._graphics_object._cleanup() self._graphics_object = None @property def style(self) -> StyleCollection: return self._style def __getstate__(self) -> dict: """ save Returns ------- value : dict """ in_port, out_port = self.ports if not in_port and not out_port: return {} connection_json = dict( in_id=in_port.node.id, in_index=in_port.index, out_id=out_port.node.id, out_index=out_port.index, ) if self._converter: def get_type_json(type: PortType): node_type = self.data_type(type) return dict( id=node_type.id, name=node_type.name ) connection_json["converter"] = { "in": get_type_json(PortType.input), "out": get_type_json(PortType.output), } return connection_json @property def id(self) -> str: """ Unique identifier (uuid) Returns ------- uuid : str """ return self._uid @property def required_port(self) -> PortType: """ Required port Returns ------- value : PortType """ return self._required_port @required_port.setter def required_port(self, dragging: PortType): """ Remembers the end being dragged. Invalidates Node address. Grabs mouse. Parameters ---------- dragging : PortType """ self._required_port = dragging try: port = self.valid_ports[dragging] except KeyError: ... else: port.remove_connection(self) @property def graphics_object(self) -> ConnectionGraphicsObject: """ Get the connection graphics object Returns ---------- graphics : ConnectionGraphicsObject """ return self._graphics_object @graphics_object.setter def graphics_object(self, graphics: ConnectionGraphicsObject): self._graphics_object = graphics # this function is only called when the ConnectionGraphicsObject is # newly created. At self moment both end coordinates are (0, 0) in # Connection G.O. coordinates. The position of the whole Connection GO # in scene coordinate system is also (0, 0). By moving the whole # object to the Node Port position we position both connection ends # correctly. if self.required_port != PortType.none: attached_port = opposite_port(self.required_port) attached_port_index = self.get_port_index(attached_port) node = self.get_node(attached_port) node_scene_transform = node.graphics_object.sceneTransform() pos = node.geometry.port_scene_position(attached_port, attached_port_index, node_scene_transform) self._graphics_object.setPos(pos) self._graphics_object.move() def connect_to(self, port: Port): """ Assigns a node to the required port. Parameters ---------- port : Port """ if self._ports[port.port_type] is not None: raise ValueError('Port already specified') was_incomplete = not self.is_complete self._ports[port.port_type] = port self.updated.emit(self) self.required_port = PortType.none if self.is_complete and was_incomplete: self.connection_completed.emit(self) def remove_from_nodes(self): for port in self._ports.values(): if port is not None: port.remove_connection(self) @property def geometry(self) -> ConnectionGeometry: """ Connection geometry Returns ------- value : ConnectionGeometry """ return self._connection_geometry def get_node(self, port_type: PortType) -> typing.Optional[Node]: """ Get node Parameters ---------- port_type : PortType Returns ------- value : Node """ port = self._ports[port_type] return port.node if port is not None else None @property def nodes(self): # TODO namedtuple; TODO order return (self.get_node(PortType.input), self.get_node(PortType.output)) @property def ports(self): # TODO namedtuple; TODO order return (self._ports[PortType.input], self._ports[PortType.output]) def get_port_index(self, port_type: PortType) -> int: """ Get port index Parameters ---------- port_type : PortType Returns ------- index : int """ return self._ports[port_type].index def clear_node(self, port_type: PortType): """ Clear node Parameters ---------- port_type : PortType """ if self.is_complete: self.connection_made_incomplete.emit(self) port = self._ports[port_type] self._ports[port_type] = None port.remove_connection(self) @property def valid_ports(self): return {port_type: port for port_type, port in self._ports.items() if port is not None } def data_type(self, port_type: PortType) -> NodeDataType: """ Data type Parameters ---------- port_type : PortType Returns ------- value : NodeDataType """ ports = self.valid_ports if not ports: raise ValueError('No ports set') try: return ports[port_type].data_type except KeyError: valid_type, = ports return ports[valid_type].data_type @property def type_converter(self) -> typing.Optional[TypeConverter]: """ The type converter used for the connection. Returns ------- converter : TypeConverter or None """ return self._converter @type_converter.setter def type_converter(self, converter: TypeConverter): self._converter = converter @property def is_complete(self) -> bool: """ Connection is complete - in/out nodes are set Returns ------- value : bool """ return all(self._ports.values()) def propagate_data(self, node_data: NodeData): """ Propagate the given data from the output port -> input port. Parameters ---------- node_data : NodeData """ in_port, out_port = self.ports if not in_port: return if node_data is not None and self._converter: node_data = self._converter(node_data) in_port.node.propagate_data(node_data, in_port) @property def input_node(self) -> Node: 'Input node' return self._ports[PortType.input].node @property def output_node(self) -> Node: 'Output node' return self._ports[PortType.output].node # For backward-compatibility: output = output_node def propagate_empty_data(self): self.propagate_data(None) @property def last_hovered_node(self) -> Node: """ Last hovered node Returns ------- value : Node """ return self._last_hovered_node @last_hovered_node.setter def last_hovered_node(self, node: Node): """ Set last hovered node Parameters ---------- node : Node """ if node is None and self._last_hovered_node: self._last_hovered_node.reset_reaction_to_connection() self._last_hovered_node = node def interact_with_node(self, node: Node): """ Interact with node Parameters ---------- node : Node """ self.last_hovered_node = node @property def requires_port(self) -> bool: """ Requires port Returns ------- value : bool """ return self._required_port != PortType.none def __repr__(self): return (f'<{self.__class__.__name__} ports={self._ports}>') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/qtpynodeeditor/connection_geometry.py0000644000076500000240000000741114725355310022775 0ustar00kenstafffrom qtpy.QtCore import QPointF, QRectF from .port import PortType class ConnectionGeometry: def __init__(self, style): # local object coordinates self._in = QPointF(0, 0) self._out = QPointF(0, 0) # self._animationPhase = 0 self._line_width = 3.0 self._hovered = False self._point_diameter = style.connection.point_diameter def get_end_point(self, port_type: PortType) -> QPointF: """ Get end point Parameters ---------- port_type : PortType Returns ------- value : QPointF """ assert port_type != PortType.none return (self._out if port_type == PortType.output else self._in ) def set_end_point(self, port_type: PortType, point: QPointF): """ Set end point Parameters ---------- port_type : PortType point : QPointF """ if port_type == PortType.output: self._out = point elif port_type == PortType.input: self._in = point else: raise ValueError(port_type) def move_end_point(self, port_type: PortType, offset: QPointF): """ Move end point Parameters ---------- port_type : PortType offset : QPointF """ if port_type == PortType.output: self._out += offset elif port_type == PortType.input: self._in += offset else: raise ValueError(port_type) @property def bounding_rect(self) -> QRectF: """ Bounding rect Returns ------- value : QRectF """ c1, c2 = self.points_c1_c2() basic_rect = QRectF(self._out, self._in).normalized() c1c2_rect = QRectF(c1, c2).normalized() common_rect = basic_rect.united(c1c2_rect) corner_offset = QPointF(self._point_diameter, self._point_diameter) common_rect.setTopLeft(common_rect.topLeft() - corner_offset) common_rect.setBottomRight(common_rect.bottomRight() + 2 * corner_offset) return common_rect def points_c1_c2(self) -> tuple: """ Connection points (c1, c2) Returns ------- c1: QPointF The first point c2: QPointF The second point """ x_distance = self._in.x() - self._out.x() default_offset = 200.0 x_offset = min((default_offset, abs(x_distance))) y_offset = 0 x_ratio = 0.5 if x_distance <= 0: y_distance = self._in.y() - self._out.y() + 20 y_direction = (-1.0 if y_distance < 0 else 1.0) y_offset = y_direction * min((default_offset, abs(y_distance))) x_ratio = 1.0 x_offset *= x_ratio return ( QPointF(self._out.x() + x_offset, self._out.y() + y_offset), QPointF(self._in.x() - x_offset, self._in.y() - y_offset) ) @property def source(self) -> QPointF: """ Source Returns ------- value : QPointF """ return self._out @property def sink(self) -> QPointF: """ Sink Returns ------- value : QPointF """ return self._in def line_width(self) -> float: """ Line width Returns ------- value : double """ return self._line_width @property def hovered(self) -> bool: """ Hovered Returns ------- value : bool """ return self._hovered @hovered.setter def hovered(self, hovered: bool): self._hovered = hovered ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/qtpynodeeditor/connection_graphics_object.py0000644000076500000240000001546614725355310024301 0ustar00kenstaffimport typing from qtpy.QtCore import QRectF from qtpy.QtGui import QPainter, QPainterPath from qtpy.QtWidgets import (QGraphicsBlurEffect, QGraphicsItem, QGraphicsObject, QGraphicsSceneHoverEvent, QGraphicsSceneMouseEvent, QStyleOptionGraphicsItem, QWidget) from .connection_painter import ConnectionPainter from .node_connection_interaction import NodeConnectionInteraction from .port import PortType, opposite_port if typing.TYPE_CHECKING: from .connection import Connection # noqa debug_drawing = False class ConnectionGraphicsObject(QGraphicsObject): def __init__(self, scene, connection): ''' connection_graphics_object Parameters ---------- scene : FlowScene connection : Connection ''' super().__init__() self._scene = scene self._connection = connection self._geometry = connection.geometry self._style = connection.style.connection self._scene.addItem(self) self.setFlag(QGraphicsItem.ItemIsMovable, True) self.setFlag(QGraphicsItem.ItemIsFocusable, True) self.setFlag(QGraphicsItem.ItemIsSelectable, True) self.setAcceptHoverEvents(True) # self.add_graphics_effect() self.setZValue(-1.0) def _cleanup(self): if self._scene is not None: self._scene.removeItem(self) self._scene = None @property def connection(self) -> 'Connection': """ Connection Returns ------- value : Connection """ return self._connection def boundingRect(self) -> QRectF: """ boundingRect Returns ------- value : QRectF """ return self._geometry.bounding_rect def shape(self) -> QPainterPath: """ Shape Returns ------- value : QPainterPath """ # TODO DEBUG_DRAWING if debug_drawing: path = QPainterPath() path.addRect(self.boundingRect()) return path return ConnectionPainter.get_painter_stroke(self._geometry) def set_geometry_changed(self): self.prepareGeometryChange() def move(self): """ Updates the position of both ends """ conn = self._connection cgo = conn.graphics_object for port_type in (PortType.input, PortType.output): node = self._connection.get_node(port_type) if node is None: continue node_graphics = node.graphics_object node_geom = node.geometry scene_pos = node_geom.port_scene_position( port_type, self._connection.get_port_index(port_type), node_graphics.sceneTransform() ) inverted, invertible = self.sceneTransform().inverted() if invertible: connection_pos = inverted.map(scene_pos) self._geometry.set_end_point(port_type, connection_pos) cgo.set_geometry_changed() cgo.update() def lock(self, locked: bool): """ Lock Parameters ---------- locked : bool """ self.setFlag(QGraphicsItem.ItemIsMovable, not locked) self.setFlag(QGraphicsItem.ItemIsFocusable, not locked) self.setFlag(QGraphicsItem.ItemIsSelectable, not locked) def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: QWidget): """ Paint Parameters ---------- painter : QPainter option : QStyleOptionGraphicsItem widget : QWidget """ painter.setClipRect(option.exposedRect) ConnectionPainter.paint(painter, self._connection, self._style) def mousePressEvent(self, event: QGraphicsSceneMouseEvent): """ mousePressEvent Parameters ---------- event : QGraphicsSceneMouseEvent """ super().mousePressEvent(event) # event.ignore() def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent): """ mouseMoveEvent Parameters ---------- event : QGraphicsSceneMouseEvent """ self.prepareGeometryChange() # view = event.widget() # TODO/BUG: widget is returning QWidget(), not QGraphicsView... view = self._scene.views()[0] node = self._scene.locate_node_at(event.scenePos(), view.transform()) self._connection.interact_with_node(node) state_required = self._connection.required_port if node: node.react_to_possible_connection( state_required, self._connection.data_type(opposite_port(state_required)), event.scenePos() ) # ------------------- offset = event.pos() - event.lastPos() required_port = self._connection.required_port if required_port != PortType.none: self._geometry.move_end_point(required_port, offset) # ------------------- self.update() event.accept() def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent): """ mouseReleaseEvent Parameters ---------- event : QGraphicsSceneMouseEvent """ self.ungrabMouse() event.accept() node = self._scene.locate_node_at(event.scenePos(), self._scene.views()[0].transform()) interaction = NodeConnectionInteraction(node, self._connection, self._scene) if node and interaction.try_connect(): node.reset_reaction_to_connection() self._scene.connection_created.emit(self._connection) if self._connection.requires_port: self._scene.delete_connection(self._connection) def hoverEnterEvent(self, event: QGraphicsSceneHoverEvent): """ hoverEnterEvent Parameters ---------- event : QGraphicsSceneHoverEvent """ self._geometry.hovered = True self.update() self._scene.connection_hovered.emit(self.connection, event.screenPos()) event.accept() def hoverLeaveEvent(self, event: QGraphicsSceneHoverEvent): """ hoverLeaveEvent Parameters ---------- event : QGraphicsSceneHoverEvent """ self._geometry.hovered = False self.update() self._scene.connection_hover_left.emit(self.connection) event.accept() def add_graphics_effect(self): effect = QGraphicsBlurEffect() effect.setBlurRadius(5) self.setGraphicsEffect(effect) # effect = QGraphicsDropShadowEffect() # effect = ConnectionBlurEffect(self) # effect.setOffset(4, 4) # effect.setColor(QColor(Qt.gray).darker(800)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/qtpynodeeditor/connection_painter.py0000644000076500000240000001313614725355310022605 0ustar00kenstaffimport typing from qtpy.QtCore import QLineF, QPointF, QSize, Qt from qtpy.QtGui import QIcon, QPainter, QPainterPath, QPainterPathStroker, QPen from .connection_geometry import ConnectionGeometry from .enums import PortType from .style import ConnectionStyle if typing.TYPE_CHECKING: from .connection import Connection # noqa use_debug_drawing = False def cubic_path(geom): source, sink = geom.source, geom.sink c1, c2 = geom.points_c1_c2() # cubic spline cubic = QPainterPath(source) cubic.cubicTo(c1, c2, sink) return cubic def debug_drawing(painter, connection): geom = connection.geometry source, sink = geom.source, geom.sink c1, c2 = geom.points_c1_c2() painter.setPen(Qt.red) painter.setBrush(Qt.red) painter.drawLine(QLineF(source, c1)) painter.drawLine(QLineF(c1, c2)) painter.drawLine(QLineF(c2, sink)) painter.drawEllipse(c1, 3, 3) painter.drawEllipse(c2, 3, 3) painter.setBrush(Qt.NoBrush) painter.drawPath(cubic_path(geom)) painter.setPen(Qt.yellow) painter.drawRect(geom.bounding_rect) def draw_sketch_line(painter, connection, style): if not connection.requires_port: return p = QPen() p.setWidthF(style.construction_line_width) p.setColor(style.construction_color) p.setStyle(Qt.DashLine) painter.setPen(p) painter.setBrush(Qt.NoBrush) geom = connection.geometry cubic = cubic_path(geom) # cubic spline painter.drawPath(cubic) def draw_hovered_or_selected(painter, connection, style): geom = connection.geometry hovered = geom.hovered graphics_object = connection.graphics_object selected = graphics_object.isSelected() # drawn as a fat background if hovered or selected: p = QPen() line_width = style.line_width p.setWidthF(2.0 * line_width) p.setColor(style.selected_halo_color if selected else style.hovered_color) painter.setPen(p) painter.setBrush(Qt.NoBrush) # cubic spline cubic = cubic_path(geom) painter.drawPath(cubic) def draw_normal_line(painter, connection, style): if connection.requires_port: return # colors normal_color_out = style.get_normal_color() normal_color_in = normal_color_out selected_color = style.selected_color gradient_color = False if style.use_data_defined_colors: data_type_out = connection.data_type(PortType.output) data_type_in = connection.data_type(PortType.input) gradient_color = data_type_out.id != data_type_in.id normal_color_out = style.get_normal_color(data_type_out.id) normal_color_in = style.get_normal_color(data_type_in.id) selected_color = normal_color_out.darker(200) # geometry geom = connection.geometry line_width = style.line_width # draw normal line p = QPen() p.setWidthF(line_width) graphics_object = connection.graphics_object selected = graphics_object.isSelected() cubic = cubic_path(geom) if gradient_color: painter.setBrush(Qt.NoBrush) c = normal_color_out if selected: c = c.darker(200) p.setColor(c) painter.setPen(p) segments = 60 for i in range(segments): ratio_prev = float(i) / segments ratio = float(i + 1) / segments if i == segments / 2: c = normal_color_in if selected: c = c.darker(200) p.setColor(c) painter.setPen(p) painter.drawLine(cubic.pointAtPercent(ratio_prev), cubic.pointAtPercent(ratio)) icon = QIcon(":convert.png") pixmap = icon.pixmap(QSize(22, 22)) painter.drawPixmap(cubic.pointAtPercent(0.50) - QPointF(pixmap.width() / 2, pixmap.height() / 2), pixmap) else: p.setColor(normal_color_out) if selected: p.setColor(selected_color) painter.setPen(p) painter.setBrush(Qt.NoBrush) painter.drawPath(cubic) class ConnectionPainter: @staticmethod def paint(painter: QPainter, connection: 'Connection', style: ConnectionStyle): """ Paint Parameters ---------- painter : QPainter connection : Connection style : ConnectionStyle """ draw_hovered_or_selected(painter, connection, style) draw_sketch_line(painter, connection, style) draw_normal_line(painter, connection, style) if use_debug_drawing: debug_drawing(painter, connection) # draw end points geom = connection.geometry source, sink = geom.source, geom.sink point_diameter = style.point_diameter painter.setPen(style.construction_color) painter.setBrush(style.construction_color) point_radius = point_diameter / 2.0 painter.drawEllipse(source, point_radius, point_radius) painter.drawEllipse(sink, point_radius, point_radius) @staticmethod def get_painter_stroke(geom: ConnectionGeometry) -> QPainterPath: """ Get painter stroke Parameters ---------- geom : ConnectionGeometry Returns ------- value : QPainterPath """ cubic = cubic_path(geom) source = geom.source result = QPainterPath(source) segments = 20 for i in range(segments): ratio = float(i + 1) / segments result.lineTo(cubic.pointAtPercent(ratio)) stroker = QPainterPathStroker() stroker.setWidth(10.0) return stroker.createStroke(result) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/qtpynodeeditor/data_model_registry.py0000644000076500000240000000736614725355310022755 0ustar00kenstaffimport logging import typing from .node_data import NodeData, NodeDataModel, NodeDataType from .type_converter import TypeConverter logger = logging.getLogger(__name__) class DataModelRegistry: def __init__(self): self.type_converters = {} self._models_category = {} self._item_creators = {} self._categories = set() def register_model(self, creator, category='', *, style=None, **init_kwargs): name = creator.name self._item_creators[name] = (creator, {'style': style, **init_kwargs}) self._categories.add(category) self._models_category[name] = category def register_type_converter(self, type_in: NodeDataType, type_out: NodeDataType, type_converter: TypeConverter): """ Register a type converter for a given data type. Parameters ---------- type_in : NodeDataType or NodeData subclass The input type. type_out : NodeDataType or NodeData subclass The output type. type_converter : TypeConverter The type converter to use for the conversion. """ # TODO typing annotation if hasattr(type_in, 'data_type'): type_in = typing.cast(NodeData, type_in).data_type if hasattr(type_out, 'data_type'): type_out = typing.cast(NodeData, type_out).data_type self.type_converters[(type_in, type_out)] = type_converter def create(self, model_name: str) -> NodeDataModel: """ Create a :class:`NodeDataModel` given its user-friendly name. Parameters ---------- model_name : str Returns ------- data_model_instance : NodeDataModel The instance of the given data model. Raises ------ ValueError If the model name is not registered. """ cls, kwargs = self.get_model_by_name(model_name) return cls(**kwargs) def get_model_by_name(self, model_name: str ) -> tuple[type[NodeDataModel], dict]: """ Get information on how to create a specific :class:`NodeDataModel` node given its user-friendly name. Parameters ---------- model_name : str Returns ------- data_model : NodeDataModel The data model class. init_kwargs : dict Default init keyword arguments. Raises ------ ValueError If the model name is not registered. """ try: return self._item_creators[model_name] except KeyError: raise ValueError(f'Unknown model: {model_name}') from None def registered_model_creators(self) -> dict: """ Registered model creators Returns ------- value : dict """ return dict(self._item_creators) def registered_models_category_association(self) -> dict: """ Registered models category association Returns ------- value : DataModelRegistry.RegisteredModelsCategoryMap """ return self._models_category def categories(self) -> set: """ Categories Returns ------- value : DataModelRegistry.CategoriesSet """ return self._categories def get_type_converter(self, d1: NodeDataType, d2: NodeDataType) -> TypeConverter: """ Get type converter Parameters ---------- d1 : NodeDataType d2 : NodeDataType Returns ------- value : TypeConverter """ return self.type_converters.get((d1, d2), None) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/qtpynodeeditor/enums.py0000644000076500000240000000060514725355310020050 0ustar00kenstafffrom enum import Enum class NodeValidationState(str, Enum): valid = 'valid' warning = 'warning' error = 'error' class PortType(str, Enum): none = 'none' input = 'input' output = 'output' class ConnectionPolicy(str, Enum): one = 'one' many = 'many' class ReactToConnectionState(str, Enum): reacting = 'reacting' not_reacting = 'not_reacting' ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1734303703.6646435 qtpynodeeditor-0.3.3/qtpynodeeditor/examples/0000755000076500000240000000000014727657730020201 5ustar00kenstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/qtpynodeeditor/examples/__init__.py0000644000076500000240000000017314725355310022276 0ustar00kenstafffrom . import calculator, connection_colors, image, style __all__ = ['calculator', 'style', 'connection_colors', 'image'] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/qtpynodeeditor/examples/calculator.py0000644000076500000240000003041114725355310022666 0ustar00kenstaffimport contextlib import logging import threading from qtpy.QtGui import QDoubleValidator from qtpy.QtWidgets import QApplication, QLabel, QLineEdit, QWidget import qtpynodeeditor as nodeeditor from qtpynodeeditor import (NodeData, NodeDataModel, NodeDataType, NodeValidationState, Port, PortType) from qtpynodeeditor.type_converter import TypeConverter class DecimalData(NodeData): 'Node data holding a decimal (floating point) number' data_type = NodeDataType("decimal", "Decimal") def __init__(self, number: float = 0.0): self._number = number self._lock = threading.RLock() @property def lock(self): return self._lock @property def number(self) -> float: 'The number data' return self._number def number_as_text(self) -> str: 'Number as a string' return '%g' % self._number class IntegerData(NodeData): 'Node data holding an integer value' data_type = NodeDataType("integer", "Integer") def __init__(self, number: int = 0): self._number = number self._lock = threading.RLock() @property def lock(self): return self._lock @property def number(self) -> int: 'The number data' return self._number def number_as_text(self) -> str: 'Number as a string' return str(self._number) class MathOperationDataModel(NodeDataModel): caption_visible = True num_ports = { 'input': 2, 'output': 1, } port_caption_visible = True data_type = DecimalData.data_type def __init__(self, style=None, parent=None): super().__init__(style=style, parent=parent) self._number1 = None self._number2 = None self._result = None self._validation_state = NodeValidationState.warning self._validation_message = 'Uninitialized' @property def caption(self): return self.name def _check_inputs(self): number1_ok = (self._number1 is not None and self._number1.data_type.id in ('decimal', 'integer')) number2_ok = (self._number2 is not None and self._number2.data_type.id in ('decimal', 'integer')) if not number1_ok or not number2_ok: self._validation_state = NodeValidationState.warning self._validation_message = "Missing or incorrect inputs" self._result = None self.data_updated.emit(0) return False self._validation_state = NodeValidationState.valid self._validation_message = '' return True @contextlib.contextmanager def _compute_lock(self): if not self._number1 or not self._number2: raise RuntimeError('inputs unset') with self._number1.lock: with self._number2.lock: yield self.data_updated.emit(0) def out_data(self, port: int) -> NodeData: ''' The output data as a result of this calculation Parameters ---------- port : int Returns ------- value : NodeData ''' return self._result def set_in_data(self, data: NodeData, port: Port): ''' New data at the input of the node Parameters ---------- data : NodeData port_index : int ''' if port.index == 0: self._number1 = data elif port.index == 1: self._number2 = data if self._check_inputs(): with self._compute_lock(): self.compute() def validation_state(self) -> NodeValidationState: return self._validation_state def validation_message(self) -> str: return self._validation_message def compute(self): ... class AdditionModel(MathOperationDataModel): name = "Addition" def compute(self): self._result = DecimalData(self._number1.number + self._number2.number) class DivisionModel(MathOperationDataModel): name = "Division" port_caption = {'input': {0: 'Dividend', 1: 'Divisor', }, 'output': {0: 'Result'}, } def compute(self): if self._number2.number == 0.0: self._validation_state = NodeValidationState.error self._validation_message = "Division by zero error" self._result = None else: self._validation_state = NodeValidationState.valid self._validation_message = '' self._result = DecimalData(self._number1.number / self._number2.number) class ModuloModel(MathOperationDataModel): name = 'Modulo' data_type = IntegerData.data_type port_caption = {'input': {0: 'Dividend', 1: 'Divisor', }, 'output': {0: 'Result'}, } def compute(self): if self._number2.number == 0.0: self._validation_state = NodeValidationState.error self._validation_message = "Division by zero error" self._result = None else: self._result = IntegerData(self._number1.number % self._number2.number) class MultiplicationModel(MathOperationDataModel): name = 'Multiplication' port_caption = {'input': {0: 'A', 1: 'B', }, 'output': {0: 'Result'}, } def compute(self): self._result = DecimalData(self._number1.number * self._number2.number) class NumberSourceDataModel(NodeDataModel): name = "NumberSource" caption_visible = False num_ports = {PortType.input: 0, PortType.output: 1, } port_caption = {'output': {0: 'Result'}} data_type = DecimalData.data_type def __init__(self, style=None, parent=None): super().__init__(style=style, parent=parent) self._number = None self._line_edit = QLineEdit() self._line_edit.setValidator(QDoubleValidator()) self._line_edit.setMaximumSize(self._line_edit.sizeHint()) self._line_edit.textChanged.connect(self.on_text_edited) self._line_edit.setText("0.0") @property def number(self): return self._number def save(self) -> dict: 'Add to the JSON dictionary to save the state of the NumberSource' doc = super().save() if self._number: doc['number'] = self._number.number return doc def restore(self, state: dict): 'Restore the number from the JSON dictionary' try: value = float(state["number"]) except Exception: ... else: self._number = DecimalData(value) self._line_edit.setText(self._number.number_as_text()) def out_data(self, port: int) -> NodeData: ''' The data output from this node Parameters ---------- port : int Returns ------- value : NodeData ''' return self._number def embedded_widget(self) -> QWidget: 'The number source has a line edit widget for the user to type in' return self._line_edit def on_text_edited(self, string: str): ''' Line edit text has changed Parameters ---------- string : str ''' try: number = float(self._line_edit.text()) except ValueError: self._data_invalidated.emit(0) else: self._number = DecimalData(number) self.data_updated.emit(0) class NumberDisplayModel(NodeDataModel): name = "NumberDisplay" data_type = DecimalData.data_type caption_visible = False num_ports = {PortType.input: 1, PortType.output: 0, } port_caption = {'input': {0: 'Number'}} def __init__(self, style=None, parent=None): super().__init__(style=style, parent=parent) self._number = None self._label = QLabel() self._label.setMargin(3) self._validation_state = NodeValidationState.warning self._validation_message = 'Uninitialized' def set_in_data(self, data: NodeData, port: Port): ''' New data propagated to the input Parameters ---------- data : NodeData int : int ''' self._number = data number_ok = (self._number is not None and self._number.data_type.id in ('decimal', 'integer')) if number_ok: self._validation_state = NodeValidationState.valid self._validation_message = '' self._label.setText(self._number.number_as_text()) else: self._validation_state = NodeValidationState.warning self._validation_message = "Missing or incorrect inputs" self._label.clear() self._label.adjustSize() def embedded_widget(self) -> QWidget: 'The number display has a label' return self._label class SubtractionModel(MathOperationDataModel): name = "Subtraction" port_caption = {'input': {0: 'Minuend', 1: 'Subtrahend' }, 'output': {0: 'Result'}, } def compute(self): self._result = DecimalData(self._number1.number - self._number2.number) def integer_to_decimal_converter(data: IntegerData) -> DecimalData: ''' integer_to_decimal_converter Parameters ---------- data : NodeData Returns ------- value : NodeData ''' return DecimalData(float(data.number)) def decimal_to_integer_converter(data: DecimalData) -> IntegerData: ''' Convert from DecimalDat to IntegerData Parameters ---------- data : DecimalData Returns ------- value : IntegerData ''' return IntegerData(int(data.number)) def main(app): registry = nodeeditor.DataModelRegistry() models = (AdditionModel, DivisionModel, ModuloModel, MultiplicationModel, NumberSourceDataModel, SubtractionModel, NumberDisplayModel) for model in models: registry.register_model(model, category='Operations', style=None) dec_converter = TypeConverter(DecimalData.data_type, IntegerData.data_type, decimal_to_integer_converter) int_converter = TypeConverter(IntegerData.data_type, DecimalData.data_type, integer_to_decimal_converter) registry.register_type_converter(DecimalData.data_type, IntegerData.data_type, dec_converter) registry.register_type_converter(IntegerData.data_type, DecimalData.data_type, int_converter) scene = nodeeditor.FlowScene(registry=registry) view = nodeeditor.FlowView(scene) view.setWindowTitle("Calculator example") view.resize(800, 600) view.show() inputs = [] node_add = scene.create_node(AdditionModel) node_sub = scene.create_node(SubtractionModel) node_mul = scene.create_node(MultiplicationModel) node_div = scene.create_node(DivisionModel) node_mod = scene.create_node(ModuloModel) for node_operation in (node_add, node_sub, node_mul, node_div, node_mod): node_a = scene.create_node(NumberSourceDataModel) node_a.model.embedded_widget().setText('1.0') inputs.append(node_a) node_b = scene.create_node(NumberSourceDataModel) node_b.model.embedded_widget().setText('2.0') inputs.append(node_b) scene.create_connection(node_a[PortType.output][0], node_operation[PortType.input][0], ) scene.create_connection(node_b[PortType.output][0], node_operation[PortType.input][1], ) node_display = scene.create_node(NumberDisplayModel) scene.create_connection(node_operation[PortType.output][0], node_display[PortType.input][0], ) try: scene.auto_arrange(nodes=inputs, layout='bipartite') except ImportError: ... return scene, view, [node_a, node_b] if __name__ == '__main__': logging.basicConfig(level='DEBUG') app = QApplication([]) scene, view, nodes = main(app) view.show() app.exec_() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/qtpynodeeditor/examples/connection_colors.py0000644000076500000240000000417014725355310024260 0ustar00kenstaffimport logging from qtpy import QtWidgets import qtpynodeeditor from qtpynodeeditor import NodeData, NodeDataModel, NodeDataType, PortType class MyNodeData(NodeData): data_type = NodeDataType(id='MyNodeData', name='My Node Data') class SimpleNodeData(NodeData): data_type = NodeDataType(id='SimpleData', name='Simple Data') class NaiveDataModel(NodeDataModel): name = 'NaiveDataModel' caption = 'Caption' caption_visible = True num_ports = {PortType.input: 2, PortType.output: 2, } data_type = { PortType.input: { 0: MyNodeData.data_type, 1: SimpleNodeData.data_type }, PortType.output: { 0: MyNodeData.data_type, 1: SimpleNodeData.data_type }, } def out_data(self, port_index): if port_index == 0: return MyNodeData() elif port_index == 1: return SimpleNodeData() def set_in_data(self, node_data, port): ... def embedded_widget(self): ... def main(app): registry = qtpynodeeditor.DataModelRegistry() registry.register_model(NaiveDataModel, category='My Category') scene = qtpynodeeditor.FlowScene(registry=registry) connection_style = scene.style_collection.connection # Configure the style collection to use colors based on data types: connection_style.use_data_defined_colors = True view = qtpynodeeditor.FlowView(scene) view.setWindowTitle("Connection (data-defined) color example") view.resize(800, 600) node_a = scene.create_node(NaiveDataModel) node_b = scene.create_node(NaiveDataModel) scene.create_connection(node_a[PortType.output][0], node_b[PortType.input][0], ) scene.create_connection(node_a[PortType.output][1], node_b[PortType.input][1], ) return scene, view, [node_a, node_b] if __name__ == '__main__': logging.basicConfig(level='DEBUG') app = QtWidgets.QApplication([]) scene, view, nodes = main(app) view.show() app.exec_() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/qtpynodeeditor/examples/image.py0000644000076500000240000001116714725355310021626 0ustar00kenstaffimport logging from qtpy import QtCore, QtGui, QtWidgets from qtpy.QtCore import Qt import qtpynodeeditor from qtpynodeeditor import NodeData, NodeDataModel, NodeDataType, PortType class PixmapData(NodeData): data_type = NodeDataType(id='Pixmap', name='PixmapData') def __init__(self, pixmap): self.pixmap = pixmap class ImageLoaderModel(NodeDataModel): caption = 'Image Source' num_ports = {PortType.input: 0, PortType.output: 1, } data_type = PixmapData def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._pixmap = None self._label = QtWidgets.QLabel('Click to load image') self._label.setAlignment(Qt.AlignVCenter | Qt.AlignCenter) font = self._label.font() font.setBold(True) font.setItalic(True) self._label.setFont(font) self._label.setFixedSize(200, 200) self._label.installEventFilter(self) def eventFilter(self, obj, event): label = getattr(self, "_label", None) if label is None or obj is not label: return False def set_pixmap(): w, h = label.width(), label.height() label.setPixmap(self._pixmap.scaled(w, h, Qt.KeepAspectRatio)) if event.type() == QtCore.QEvent.MouseButtonPress: file_name, _ = QtWidgets.QFileDialog.getOpenFileName( None, "Open Image", QtCore.QDir.homePath(), "Image files (*.png *.jpg *.bmp)") try: self._pixmap = QtGui.QPixmap(file_name) except Exception as ex: print(f'Failed to load image {file_name}: {ex}') return False set_pixmap() self.data_updated.emit(0) return True elif event.type() == QtCore.QEvent.Resize: if self._pixmap is not None: set_pixmap() return False def resizable(self): return True def out_data(self, port): return PixmapData(self._pixmap) def embedded_widget(self): return self._label class ImageShowModel(NodeDataModel): caption = 'Image Display' num_ports = {PortType.input: 1, PortType.output: 1, } data_type = PixmapData def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._node_data = None self._label = QtWidgets.QLabel('Image will appear here') self._label.setAlignment(Qt.AlignVCenter | Qt.AlignCenter) font = self._label.font() font.setBold(True) font.setItalic(True) self._label.setFont(font) self._label.setFixedSize(200, 200) self._label.installEventFilter(self) def resizable(self): return True def eventFilter(self, obj, event): if obj is self._label and event.type() == QtCore.QEvent.Resize: if (self._node_data and self._node_data.data_type == PixmapData.data_type and self._node_data.pixmap): w, h = self._label.width(), self._label.height() pixmap = self._node_data.pixmap self._label.setPixmap(pixmap.scaled(w, h, Qt.KeepAspectRatio)) return False def set_in_data(self, node_data, port): self._node_data = node_data if (self._node_data and self._node_data.data_type == PixmapData.data_type and self._node_data.pixmap): w, h = self._label.width(), self._label.height() pixmap = node_data.pixmap.scaled(w, h, Qt.KeepAspectRatio) else: pixmap = QtGui.QPixmap() self._label.setPixmap(pixmap) self.data_updated.emit(0) def out_data(self, port): return self._node_data def embedded_widget(self): return self._label def main(app): registry = qtpynodeeditor.DataModelRegistry() registry.register_model(ImageShowModel, category='My Category') registry.register_model(ImageLoaderModel, category='My Category') scene = qtpynodeeditor.FlowScene(registry=registry) view = qtpynodeeditor.FlowView(scene) view.setWindowTitle("Image example") view.resize(800, 600) node_loader = scene.create_node(ImageLoaderModel) node_show = scene.create_node(ImageShowModel) scene.create_connection( node_loader[PortType.output][0], node_show[PortType.input][0], ) return scene, view, [node_loader, node_show] if __name__ == '__main__': logging.basicConfig(level='DEBUG') app = QtWidgets.QApplication([]) scene, view, nodes = main(app) view.show() app.exec_() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/qtpynodeeditor/examples/style.py0000644000076500000240000000456614725355310021711 0ustar00kenstaffimport logging from qtpy import QtWidgets import qtpynodeeditor from qtpynodeeditor import (NodeData, NodeDataModel, NodeDataType, PortType, StyleCollection) style_json = ''' { "FlowViewStyle": { "BackgroundColor": [255, 255, 240], "FineGridColor": [245, 245, 230], "CoarseGridColor": [235, 235, 220] }, "NodeStyle": { "NormalBoundaryColor": "darkgray", "SelectedBoundaryColor": "deepskyblue", "GradientColor0": "mintcream", "GradientColor1": "mintcream", "GradientColor2": "mintcream", "GradientColor3": "mintcream", "ShadowColor": [200, 200, 200], "FontColor": [10, 10, 10], "FontColorFaded": [100, 100, 100], "ConnectionPointColor": "white", "PenWidth": 2.0, "HoveredPenWidth": 2.5, "ConnectionPointDiameter": 10.0, "Opacity": 1.0 }, "ConnectionStyle": { "ConstructionColor": "gray", "NormalColor": "black", "SelectedColor": "gray", "SelectedHaloColor": "deepskyblue", "HoveredColor": "deepskyblue", "LineWidth": 3.0, "ConstructionLineWidth": 2.0, "PointDiameter": 10.0, "UseDataDefinedColors": false } } ''' class MyNodeData(NodeData): data_type = NodeDataType(id='MyNodeData', name='My Node Data') class MyDataModel(NodeDataModel): name = 'MyDataModel' caption = 'Caption' caption_visible = True num_ports = {PortType.input: 3, PortType.output: 3, } data_type = MyNodeData.data_type def out_data(self, port): return MyNodeData() def set_in_data(self, node_data, port): ... def embedded_widget(self): return None def main(app): style = StyleCollection.from_json(style_json) registry = qtpynodeeditor.DataModelRegistry() registry.register_model(MyDataModel, category='My Category', style=style) scene = qtpynodeeditor.FlowScene(style=style, registry=registry) view = qtpynodeeditor.FlowView(scene) view.setWindowTitle("Style example") view.resize(800, 600) node = scene.create_node(MyDataModel) return scene, view, [node] if __name__ == '__main__': logging.basicConfig(level='DEBUG') app = QtWidgets.QApplication([]) scene, view, nodes = main(app) view.show() app.exec_() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/qtpynodeeditor/exceptions.py0000644000076500000240000000156314725355310021106 0ustar00kenstaffclass NodeConnectionFailure(Exception): ... class ConnectionRequiresPortFailure(NodeConnectionFailure): 'A port is required' ... class ConnectionSelfFailure(NodeConnectionFailure): 'Cannot connect a node to itself' ... class ConnectionPointFailure(NodeConnectionFailure): 'Connection point is not on top of the node port' ... class ConnectionPortNotEmptyFailure(NodeConnectionFailure): 'Port should be empty' ... class ConnectionCycleFailure(NodeConnectionFailure): 'Connection would introduce a cycle in the graph' ... class ConnectionDataTypeFailure(NodeConnectionFailure): 'Ports do not have compatible data types' ... class PortsOfSameTypeError(NodeConnectionFailure): ... class PortsAlreadyConnectedError(NodeConnectionFailure): ... class MultipleInputConnectionError(NodeConnectionFailure): ... ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733680631.0 qtpynodeeditor-0.3.3/qtpynodeeditor/flow_scene.py0000644000076500000240000005045014725356767021071 0ustar00kenstafffrom __future__ import annotations import contextlib import json import os from qtpy.QtCore import QDir, QPoint, QPointF, Qt, Signal from qtpy.QtWidgets import QFileDialog, QGraphicsScene from . import exceptions from . import style as style_module from .connection import Connection from .connection_graphics_object import ConnectionGraphicsObject from .data_model_registry import DataModelRegistry from .exceptions import ConnectionDataTypeFailure from .node import Node from .node_data import NodeDataModel, NodeDataType from .node_graphics_object import NodeGraphicsObject from .port import Port, PortType from .type_converter import TypeConverter def locate_node_at(scene_point, scene, view_transform): items = scene.items(scene_point, Qt.IntersectsItemShape, Qt.DescendingOrder, view_transform) filtered_items = [item for item in items if isinstance(item, NodeGraphicsObject)] return filtered_items[0].node if filtered_items else None class FlowSceneModel: ''' A model representing a flow scene Emits the following signals upon connection/node creation/deletion:: connection_created : Signal(Connection) connection_deleted : Signal(Connection) node_created : Signal(Node) node_deleted : Signal(Node) ''' connection_created = Signal(Connection) connection_deleted = Signal(Connection) node_created = Signal(Node) node_deleted = Signal(Node) def __init__(self, registry=None, **kwargs): super().__init__(**kwargs) self._connections = [] self._nodes = {} if registry is None: registry = DataModelRegistry() self._registry = registry # this connection should come first self.connection_created.connect(self._setup_connection_signals) self.connection_created.connect(self._send_connection_created_to_nodes) self.connection_deleted.connect(self._send_connection_deleted_to_nodes) @property def registry(self) -> DataModelRegistry: """ Registry Returns ------- value : DataModelRegistry """ return self._registry @registry.setter def registry(self, registry: DataModelRegistry): self._registry = registry @property def nodes(self) -> dict: """ All nodes in the scene Returns ------- value : dict Key: uuid Value: Node """ return dict(self._nodes) @property def connections(self) -> list: """ All connections in the scene Returns ------- conn : list of Connection """ return list(self._connections) def clear_scene(self): # Manual node cleanup. Simply clearing the holding datastructures # doesn't work, the code crashes when there are both nodes and # connections in the scene. (The data propagation internal logic tries # to propagate data through already freed connections.) for conn in list(self._connections): self.delete_connection(conn) for node in list(self._nodes.values()): self.remove_node(node) def save(self, file_name=None): if file_name is None: file_name, _ = QFileDialog.getSaveFileName( None, "Save Flow Scene", QDir.homePath(), "Flow Scene Files (*.flow)") if file_name: file_name = str(file_name) if not file_name.endswith(".flow"): file_name += ".flow" with open(file_name, 'w') as f: json.dump(self.__getstate__(), f) def load(self, file_name=None): if file_name is None: file_name, _ = QFileDialog.getOpenFileName( None, "Open Flow Scene", QDir.homePath(), "Flow Scene Files (*.flow)") if not os.path.exists(file_name): return with open(file_name) as f: doc = json.load(f) self.__setstate__(doc) def __getstate__(self) -> dict: """ Save scene state to a dictionary Returns ------- value : dict """ scene_json = {} nodes_json_array = [] connection_json_array = [] for node in self._nodes.values(): nodes_json_array.append(node.__getstate__()) scene_json["nodes"] = nodes_json_array for connection in self._connections: connection_json = connection.__getstate__() if connection_json: connection_json_array.append(connection_json) scene_json["connections"] = connection_json_array return scene_json def restore_connection(self, connection_json: dict) -> Connection: """ Restore a connection. To be overridden in a subclass. Parameters ---------- connection_json : dict Returns ------- value : Connection """ def __setstate__(self, doc: dict): """ Load scene state from a dictionary Parameters ---------- doc : dict Dictionary of settings """ self.clear_scene() for node in doc["nodes"]: self.restore_node(node) for connection in doc["connections"]: self.restore_connection(connection) def _connection_made_incomplete(self, conn: Connection): self.connection_deleted.emit(conn) def _setup_connection_signals(self, conn: Connection): """ Setup connection signals Parameters ---------- conn : Connection """ conn.connection_made_incomplete.connect( self._connection_made_incomplete, Qt.UniqueConnection) def _send_connection_created_to_nodes(self, conn: Connection): """ Send connection created to nodes Parameters ---------- conn : Connection """ input_node, output_node = conn.nodes assert input_node is not None assert output_node is not None output_node.model.output_connection_created(conn) input_node.model.input_connection_created(conn) def _send_connection_deleted_to_nodes(self, conn: Connection): """ Send connection deleted to nodes Parameters ---------- conn : Connection """ input_node, output_node = conn.nodes assert input_node is not None assert output_node is not None output_node.model.output_connection_deleted(conn) input_node.model.input_connection_deleted(conn) def iterate_over_nodes(self): """ Generator: Iterate over nodes """ yield from self._nodes.values() def iterate_over_node_data(self): """ Generator: Iterate over node data """ for node in self._nodes.values(): yield node.model def iterate_over_node_data_dependent_order(self): """ Generator: Iterate over node data dependent order """ visited_nodes = [] # A leaf node is a node with no input ports, or all possible input ports empty def is_node_leaf(node, model): for port in node[PortType.input].values(): if not port.connections: return False return True # Iterate over "leaf" nodes for node in self._nodes.values(): model = node.model if is_node_leaf(node, model): yield model visited_nodes.append(node) def are_node_inputs_visited_before(node, model): for port in node[PortType.input].values(): for conn in port.connections: other = conn.get_node(PortType.output) if visited_nodes and other == visited_nodes[-1]: return False return True # Iterate over dependent nodes while len(self._nodes) != len(visited_nodes): for node in self._nodes.values(): if node in visited_nodes and node is not visited_nodes[-1]: continue model = node.model if are_node_inputs_visited_before(node, model): yield model visited_nodes.append(node) def to_digraph(self): ''' Create a networkx digraph Returns ------- digraph : networkx.DiGraph The generated DiGraph Raises ------ ImportError If networkx is unavailable ''' import networkx graph = networkx.DiGraph() for node in self._nodes.values(): graph.add_node(node) for node in self._nodes.values(): graph.add_edges_from(conn.nodes for conn in node.state.all_connections) return graph def remove_node(self, node: Node): """ Remove node Parameters ---------- node : Node """ self.node_deleted.emit(node) for conn in list(node.state.all_connections): self.delete_connection(conn) node._cleanup() del self._nodes[node.id] def _restore_node(self, node_json: dict) -> Node: """ Restore a node from a state dictionary Parameters ---------- node_json : dict Returns ------- value : Node """ with self._new_node_context(node_json["model"]["name"]) as node: ... return node @contextlib.contextmanager def _new_node_context(self, data_model_name, *, emit_placed=False): 'Context manager: creates Node/yields it, handling necessary Signals' data_model = self._registry.create(data_model_name) node = Node(data_model) yield node self._nodes[node.id] = node if emit_placed: self.node_placed.emit(node) self.node_created.emit(node) def restore_node(self, node_json: dict) -> Node: """ Restore a node from a state dictionary Parameters ---------- node_json : dict Returns ------- value : Node """ name = node_json["model"]["name"] with self._new_node_context(name, emit_placed=True) as node: node.__setstate__(node_json) return node def delete_connection(self, connection: Connection): """ Delete connection Parameters ---------- connection : Connection """ try: self._connections.remove(connection) except ValueError: ... else: connection.remove_from_nodes() connection._cleanup() class FlowScene(FlowSceneModel, QGraphicsScene): connection_hover_left = Signal(Connection) connection_hovered = Signal(Connection, QPoint) # Node has been added to the scene. # Connect to self signal if need a correct position of node. node_placed = Signal(Node) # node_context_menu(node, scene_position, screen_position) node_context_menu = Signal(Node, QPointF, QPoint) node_double_clicked = Signal(Node) node_hover_left = Signal(Node) node_hovered = Signal(Node, QPoint) node_moved = Signal(Node, QPointF) node_dragging = Signal(bool) def __init__(self, registry=None, style=None, parent=None, allow_node_creation=True, allow_node_deletion=True): ''' Create a new flow scene Parameters ---------- registry : DataModelRegistry, optional style : StyleCollection, optional parent : QObject, optional ''' super().__init__(parent=parent) self._registry = registry or self._registry if style is None: style = style_module.default_style self._style = style self.allow_node_deletion = allow_node_creation self.allow_node_creation = allow_node_deletion self.node_dragging.connect(self._redraw_post_drag) self.setItemIndexMethod(QGraphicsScene.NoIndex) def _redraw_post_drag(self, dragging: bool) -> None: """ Redraw connections for all selected nodes after dragging is done. Parameters ---------- dragging : bool Whether dragging has started (``True``) or finished. """ if dragging: return for node in self.selected_nodes(): node.graphics_object.move_connections() def _cleanup(self): self.clear_scene() @property def allow_node_creation(self): return self._allow_node_creation @allow_node_creation.setter def allow_node_creation(self, allow): self._allow_node_creation = bool(allow) @property def allow_node_deletion(self): return self._allow_node_deletion @allow_node_deletion.setter def allow_node_deletion(self, allow): self._allow_node_deletion = bool(allow) @property def style_collection(self) -> style_module.StyleCollection: 'The style collection for the scene' return self._style def locate_node_at(self, point, transform): return locate_node_at(point, self, transform) def create_connection(self, port_a: Port, port_b: Port = None, *, converter: TypeConverter = None, check_cycles=True) -> Connection: """ Create a connection Parameters ---------- port_a : Port The first port, either input or output port_b : Port, optional The second port, opposite of the type of port_a converter : TypeConverter, optional The type converter to use for data propagation check_cycles : bool, optional Ensures that creating the connection would not introduce a cycle Returns ------- value : Connection Raises ------ NodeConnectionFailure If it is not possible to create the connection ConnectionDataTypeFailure If port data types are not compatible """ if port_a is not None and port_b is not None: in_port = port_a if port_a.port_type == PortType.input else port_b out_port = port_b if port_a.port_type == PortType.input else port_a if in_port.data_type.id != out_port.data_type.id: if not converter: # If not specified, try to get it from the registry converter = self.registry.get_type_converter(out_port.data_type, in_port.data_type) if (not converter or (converter.type_in != out_port.data_type or converter.type_out != in_port.data_type)): raise ConnectionDataTypeFailure( f'{in_port.data_type} and {out_port.data_type} are not compatible' ) connection = Connection(port_a=port_a, port_b=port_b, style=self._style, converter=converter) if port_a is not None: port_a.add_connection(connection) if port_b is not None: port_b.add_connection(connection) if port_a and port_b and check_cycles: # In the case of a fully-specified connection, ensure adding the # connection would not create a cycle in the graph. For # partially-specified connections (i.e., one port only), the # validation happens in the NodeConnectionInteraction node_a, node_b = port_a.node, port_b.node if node_a.has_connection_by_port_type(node_b, port_b.port_type): raise exceptions.ConnectionCycleFailure( f'Connecting {node_a} and {node_b} would introduce a ' f'cycle in the graph' ) cgo = ConnectionGraphicsObject(self, connection) # after self function connection points are set to node port connection.graphics_object = cgo self._connections.append(connection) if port_a and port_b: in_port, out_port = connection.ports out_port.node.on_data_updated(out_port) self.connection_created.emit(connection) return connection def create_connection_by_index( self, node_in: Node, port_index_in: int, node_out: Node, port_index_out: int, converter: TypeConverter) -> Connection: """ Create connection Parameters ---------- node_in : Node port_index_in : int node_out : Node port_index_out : int converter : TypeConverter Returns ------- value : Connection """ port_in = node_in[PortType.input][port_index_in] port_out = node_out[PortType.output][port_index_out] return self.create_connection(port_out, port_in, converter=converter) def restore_connection(self, connection_json: dict) -> Connection: """ Restore a connection. Parameters ---------- connection_json : dict Returns ------- value : Connection """ node_in_id = connection_json["in_id"] node_out_id = connection_json["out_id"] port_index_in = connection_json["in_index"] port_index_out = connection_json["out_index"] node_in = self._nodes[node_in_id] node_out = self._nodes[node_out_id] def get_converter(): converter = connection_json.get("converter", None) if converter is None: return None in_type = NodeDataType( id=converter["in"]["id"], name=converter["in"]["name"], ) out_type = NodeDataType( id=converter["out"]["id"], name=converter["out"]["name"], ) return self._registry.get_type_converter(out_type, in_type) connection = self.create_connection_by_index( node_in, port_index_in, node_out, port_index_out, converter=get_converter()) # Note: the connection_created(...) signal has already been sent by # create_connection(...) return connection def create_node(self, data_model: NodeDataModel) -> Node: """ Create a node in the scene Parameters ---------- data_model : NodeDataModel Returns ------- value : Node """ with self._new_node_context(data_model.name) as node: ngo = NodeGraphicsObject(self, node) node.graphics_object = ngo return node def restore_node(self, node_json: dict) -> Node: """ Restore a node from a state dictinoary Parameters ---------- node_json : dict Returns ------- value : Node """ # NOTE: Overrides FlowSceneModel.restore_node with self._new_node_context(node_json["model"]["name"]) as node: node.graphics_object = NodeGraphicsObject(self, node) node.__setstate__(node_json) return node def auto_arrange(self, layout='bipartite', scale=700, align='horizontal', **kwargs): ''' Automatically arrange nodes with networkx, if available Raises ------ ImportError If networkx is unavailable ''' import networkx dig = self.to_digraph() layouts = { name: getattr(networkx.layout, f'{name}_layout') for name in ('bipartite', 'circular', 'kamada_kawai', 'random', 'shell', 'spring', 'spectral') } try: layout_func = layouts[layout] except KeyError: raise ValueError(f'Unknown layout type {layout}') from None layout = layout_func(dig, **kwargs) for node, pos in layout.items(): pos_x, pos_y = pos node.position = (pos_x * scale, pos_y * scale) def selected_nodes(self) -> list[NodeGraphicsObject]: """ Selected nodes Returns ------- value : list of Node """ return [item.node for item in self.selectedItems() if isinstance(item, NodeGraphicsObject)] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/qtpynodeeditor/flow_view.py0000644000076500000240000002540714725355310020731 0ustar00kenstaffimport logging import math from qtpy.QtCore import QLineF, QPoint, QRectF, Qt from qtpy.QtGui import (QContextMenuEvent, QKeyEvent, QKeySequence, QMouseEvent, QPainter, QPen, QShowEvent, QWheelEvent) from qtpy.QtWidgets import (QAction, QGraphicsView, QLineEdit, QMenu, QTreeWidget, QTreeWidgetItem, QWidgetAction) from .connection_graphics_object import ConnectionGraphicsObject from .flow_scene import FlowScene from .node_graphics_object import NodeGraphicsObject logger = logging.getLogger(__name__) class FlowView(QGraphicsView): def __init__(self, scene, parent=None): super().__init__(parent=parent) self._clear_selection_action = None self._delete_selection_action = None self._scene = None self._click_pos = None self.setDragMode(QGraphicsView.ScrollHandDrag) self.setRenderHint(QPainter.Antialiasing) # setViewportUpdateMode(QGraphicsView.FullViewportUpdate) # setViewportUpdateMode(QGraphicsView.MinimalViewportUpdate) self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) self.setCacheMode(QGraphicsView.CacheBackground) # setViewport(new QGLWidget(QGLFormat(QGL.SampleBuffers))) if scene is not None: self.setScene(scene) self._style = self._scene.style_collection self.setBackgroundBrush(self._style.flow_view.background_color) def clear_selection_action(self) -> QAction: """ Clear selection action Returns ------- value : QAction """ return self._clear_selection_action def delete_selection_action(self) -> QAction: """ Delete selection action Returns ------- value : QAction """ return self._delete_selection_action def setScene(self, scene: FlowScene): """ setScene Parameters ---------- scene : FlowScene """ self._scene = scene super().setScene(self._scene) # setup actions del self._clear_selection_action self._clear_selection_action = QAction("Clear Selection", self) self._clear_selection_action.setShortcut(QKeySequence.Cancel) self._clear_selection_action.triggered.connect(self._scene.clearSelection) self.addAction(self._clear_selection_action) del self._delete_selection_action self._delete_selection_action = QAction("Delete Selection", self) self._delete_selection_action.setShortcut(QKeySequence.Backspace) self._delete_selection_action.setShortcut(QKeySequence.Delete) self._delete_selection_action.triggered.connect(self.delete_selected) self.addAction(self._delete_selection_action) def scale_up(self): step = 1.2 factor = step ** 1.0 t = self.transform() if t.m11() <= 2.0: self.scale(factor, factor) def scale_down(self): step = 1.2 factor = step ** -1.0 self.scale(factor, factor) def delete_selected(self): # Delete the selected connections first, ensuring that they won't be # automatically deleted when selected nodes are deleted (deleting a node # deletes some connections as well) for item in self._scene.selectedItems(): if isinstance(item, ConnectionGraphicsObject): self._scene.delete_connection(item.connection) if not self._scene.allow_node_deletion: return # Delete the nodes; self will delete many of the connections. # Selected connections were already deleted prior to self loop, otherwise # qgraphicsitem_cast(item) could be a use-after-free # when a selected connection is deleted by deleting the node. for item in self._scene.selectedItems(): if isinstance(item, NodeGraphicsObject): self._scene.remove_node(item.node) def generate_context_menu(self, pos: QPoint): """ Generate a context menu for contextMenuEvent Parameters ---------- pos : QPoint The point where the context menu was requested """ model_menu = QMenu() skip_text = "skip me" # Add filterbox to the context menu txt_box = QLineEdit(model_menu) txt_box.setPlaceholderText("Filter") txt_box.setClearButtonEnabled(True) txt_box_action = QWidgetAction(model_menu) txt_box_action.setDefaultWidget(txt_box) model_menu.addAction(txt_box_action) # Add result treeview to the context menu tree_view = QTreeWidget(model_menu) tree_view.header().close() tree_view_action = QWidgetAction(model_menu) tree_view_action.setDefaultWidget(tree_view) model_menu.addAction(tree_view_action) top_level_items = {} for cat in self._scene.registry.categories(): item = QTreeWidgetItem(tree_view) item.setText(0, cat) item.setData(0, Qt.UserRole, skip_text) top_level_items[cat] = item registry = self._scene.registry for model, category in registry.registered_models_category_association().items(): self.parent = top_level_items[category] item = QTreeWidgetItem(self.parent) item.setText(0, model) item.setData(0, Qt.UserRole, model) tree_view.expandAll() def click_handler(item): model_name = item.data(0, Qt.UserRole) if model_name == skip_text: return try: model, _ = self._scene.registry.get_model_by_name(model_name) except ValueError: logger.error("Model not found: %s", model_name) else: node = self._scene.create_node(model) pos_view = self.mapToScene(pos) node.graphics_object.setPos(pos_view) self._scene.node_placed.emit(node) model_menu.close() tree_view.itemClicked.connect(click_handler) # Setup filtering def filter_handler(text): for name, top_lvl_item in top_level_items.items(): for i in range(top_lvl_item.childCount()): child = top_lvl_item.child(i) model_name = child.data(0, Qt.UserRole) child.setHidden(text not in model_name) txt_box.textChanged.connect(filter_handler) # make sure the text box gets focus so the user doesn't have to click on it txt_box.setFocus() return model_menu def contextMenuEvent(self, event: QContextMenuEvent): """ contextMenuEvent Parameters ---------- event : QContextMenuEvent """ if self.itemAt(event.pos()): super().contextMenuEvent(event) return elif not self._scene.allow_node_creation: return menu = self.generate_context_menu(event.pos()) menu.exec_(event.globalPos()) def wheelEvent(self, event: QWheelEvent): """ wheelEvent Parameters ---------- event : QWheelEvent """ delta = event.angleDelta() if delta.y() == 0: event.ignore() return d = delta.y() / abs(delta.y()) if d > 0.0: self.scale_up() else: self.scale_down() def keyPressEvent(self, event: QKeyEvent): """ keyPressEvent Parameters ---------- event : QKeyEvent """ if event.key() == Qt.Key_Shift: self.setDragMode(QGraphicsView.RubberBandDrag) super().keyPressEvent(event) def keyReleaseEvent(self, event: QKeyEvent): """ keyReleaseEvent Parameters ---------- event : QKeyEvent """ if event.key() == Qt.Key_Shift: self.setDragMode(QGraphicsView.ScrollHandDrag) super().keyReleaseEvent(event) def mousePressEvent(self, event: QMouseEvent): """ mousePressEvent Parameters ---------- event : QMouseEvent """ super().mousePressEvent(event) if event.button() == Qt.LeftButton: self._click_pos = self.mapToScene(event.pos()) def mouseMoveEvent(self, event: QMouseEvent): """ mouseMoveEvent Parameters ---------- event : QMouseEvent """ super().mouseMoveEvent(event) if self._scene.mouseGrabberItem() is None and event.buttons() == Qt.LeftButton: # Make sure shift is not being pressed if not (event.modifiers() & Qt.ShiftModifier): difference = self._click_pos - self.mapToScene(event.pos()) self.setSceneRect(self.sceneRect().translated(difference.x(), difference.y())) def drawBackground(self, painter: QPainter, r: QRectF): """ drawBackground Parameters ---------- painter : QPainter r : QRectF """ super().drawBackground(painter, r) def draw_grid(grid_step): window_rect = self.rect() tl = self.mapToScene(window_rect.topLeft()) br = self.mapToScene(window_rect.bottomRight()) left = math.floor(tl.x() / grid_step - 0.5) right = math.floor(br.x() / grid_step + 1.0) bottom = math.floor(tl.y() / grid_step - 0.5) top = math.floor(br.y() / grid_step + 1.0) # vertical lines lines = [ QLineF(xi * grid_step, bottom * grid_step, xi * grid_step, top * grid_step) for xi in range(int(left), int(right) + 1) ] # horizontal lines lines.extend( [QLineF(left * grid_step, yi * grid_step, right * grid_step, yi * grid_step) for yi in range(int(bottom), int(top) + 1) ] ) painter.drawLines(lines) style = self._style.flow_view # brush = self.backgroundBrush() pfine = QPen(style.fine_grid_color, 1.0) painter.setPen(pfine) draw_grid(15) p = QPen(style.coarse_grid_color, 1.0) painter.setPen(p) draw_grid(150) def showEvent(self, event: QShowEvent): """ showEvent Parameters ---------- event : QShowEvent """ self._scene.setSceneRect(QRectF(self.rect())) super().showEvent(event) @property def scene(self) -> FlowScene: """ Scene Returns ------- value : FlowScene """ return self._scene ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/qtpynodeeditor/node.py0000644000076500000240000002360614725355310017654 0ustar00kenstaffimport collections import typing import uuid from typing import Optional from qtpy.QtCore import QObject, QPointF, QSizeF from .base import Serializable from .enums import ReactToConnectionState from .node_data import NodeData, NodeDataModel, NodeDataType from .node_geometry import NodeGeometry from .node_graphics_object import NodeGraphicsObject from .node_state import NodeState from .port import Port, PortType from .style import NodeStyle class Node(QObject, Serializable): _model: NodeDataModel _uid: str _style: NodeStyle _state: NodeState _geometry: NodeGeometry _graphics_obj: Optional[NodeGraphicsObject] def __init__(self, data_model: NodeDataModel): ''' A single Node in the scene Parameters ---------- data_model : NodeDataModel ''' super().__init__() self._model = data_model self._uid = str(uuid.uuid4()) self._style = data_model.node_style self._state = NodeState(self) self._geometry = NodeGeometry(self) self._graphics_obj = None self._geometry.recalculate_size() # propagate data: model => node self._model.data_updated.connect(self._on_port_index_data_updated) self._model.embedded_widget_size_updated.connect(self.on_node_size_updated) def __hash__(self): return id(self._uid) def __eq__(self, node): try: return node.id == self.id and self.model is node.model except AttributeError: return False def has_any_connection(self, node: 'Node') -> bool: """ Is this node connected to `node` through any port? Parameters ---------- node : Node The node to check connectivity Returns ------- connected : bool """ return any(self.has_connection_by_port_type(node, port_type) for port_type in PortType) def has_connection_by_port_type(self, target: 'Node', port_type: PortType) -> bool: """ Is this node connected to `target` through an input/output port? Parameters ---------- target : Node The target node to check connectivity port_type : PortType The port type (``PortType.input``, ``PortType.output``) to check Returns ------- connected : bool """ return any( path[-1] == target for path in self.walk_paths_by_port_type(port_type) ) def walk_paths_by_port_type( self, port_type: PortType ) -> typing.Generator[tuple["Node", ...], None, None]: """ Yields paths to connected nodes by port type Yields ------ node_path : tuple The path to the node """ seen: set[typing.Union[Node, None]] pending: typing.Deque[ tuple[list[Node], Node] ] seen = {None} pending = collections.deque([([], self)]) if port_type == PortType.output: def get_connection_nodes(state): for con in state.output_connections: yield con.input_node elif port_type == PortType.input: def get_connection_nodes(state): for con in state.input_connections: yield con.output_node else: raise ValueError(f'Unexpected port_type {port_type}') while pending: node_path, node = pending.popleft() seen.add(node) if node is not self: yield tuple(node_path) + (node, ) node_path = list(node_path) + [node] for node in get_connection_nodes(node.state): if node not in seen: pending.append((node_path, node)) def __getitem__(self, key): return self._state[key] def _cleanup(self): if self._graphics_obj is not None: self._graphics_obj._cleanup() self._graphics_obj = None self._geometry = None def __getstate__(self) -> dict: """ Save Returns ------- value : dict """ assert self._graphics_obj is not None return { "id": self._uid, "model": self._model.__getstate__(), "position": {"x": self._graphics_obj.pos().x(), "y": self._graphics_obj.pos().y()} } def __setstate__(self, state: dict): """ Restore Parameters ---------- state : dict """ self._uid = state["id"] if self._graphics_obj: pos = state["position"] self.position = (pos["x"], pos["y"]) self._model.__setstate__(state["model"]) @property def id(self) -> str: """ Node unique identifier (uuid) Returns ------- value : str """ return self._uid def react_to_possible_connection(self, reacting_port_type: PortType, reacting_data_type: NodeDataType, scene_point: QPointF ): """ React to possible connection Parameters ---------- port_type : PortType node_data_type : NodeDataType scene_point : QPointF """ if self._graphics_obj is None: return transform = self._graphics_obj.sceneTransform() inverted, invertible = transform.inverted() if invertible: pos = inverted.map(scene_point) self._geometry.dragging_position = pos self._graphics_obj.update() self._state.set_reaction(ReactToConnectionState.reacting, reacting_port_type, reacting_data_type) def reset_reaction_to_connection(self): self._state.set_reaction(ReactToConnectionState.not_reacting) self._graphics_obj.update() @property def graphics_object(self) -> Optional[NodeGraphicsObject]: """ Get/set the associated node graphics object. Returns ------- value : NodeGraphicsObject """ return self._graphics_obj @graphics_object.setter def graphics_object(self, graphics: NodeGraphicsObject): self._graphics_obj = graphics self._geometry.recalculate_size() @property def geometry(self) -> NodeGeometry: """ Get the node geometry. Returns ------- value : NodeGeometry """ return self._geometry @property def model(self) -> NodeDataModel: """ Get the node data model. Returns ------- value : NodeDataModel """ return self._model def propagate_data(self, node_data: NodeData, input_port: Port): """ Propagates incoming data to the underlying model. Parameters ---------- node_data : NodeData input_port : int """ if input_port.node is not self: raise ValueError('Port does not belong to this Node') elif input_port.port_type != PortType.input: raise ValueError('Port is not an input port') self._model.set_in_data(node_data, input_port) if self._graphics_obj is not None: # Recalculate the nodes visuals. A data change can result in the # node taking more space than before, so self forces a # recalculate+repaint on the affected node self._graphics_obj.set_geometry_changed() self._geometry.recalculate_size() self._graphics_obj.update() self._graphics_obj.move_connections() def _on_port_index_data_updated(self, port_index: int): """ Data has been updated on this Node's output port port_index; propagate it to any connections. Parameters ---------- index : int """ port = self[PortType.output][port_index] self.on_data_updated(port) def on_data_updated(self, port: Port): """ Fetches data from model's output port and propagates it along the connection Parameters ---------- port : Port """ node_data = port.data for conn in port.connections: conn.propagate_data(node_data) def on_node_size_updated(self): """ update the graphic part if the size of the embeddedwidget changes """ widget = self.model.embedded_widget() if widget: widget.adjustSize() self.geometry.recalculate_size() for conn in self.state.all_connections: conn.graphics_object.move() @property def size(self) -> QSizeF: """ Get the node size Parameters ---------- node : Node Returns ------- value : QSizeF """ return self._geometry.size @property def position(self) -> QPointF: """ Get the node position Parameters ---------- node : Node Returns ------- value : QPointF """ if self._graphics_obj is not None: return self._graphics_obj.pos() @position.setter def position(self, pos): if not isinstance(pos, QPointF): px, py = pos pos = QPointF(px, py) self._graphics_obj.setPos(pos) self._graphics_obj.move_connections() @property def style(self) -> NodeStyle: 'Node style' return self._style @property def state(self) -> NodeState: """ Node state Returns ------- value : NodeState """ return self._state def __repr__(self): return (f'<{self.__class__.__name__} model={self._model} ' f'uid={self._uid!r}>') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/qtpynodeeditor/node_connection_interaction.py0000644000076500000240000002267314725355310024475 0ustar00kenstaffimport logging import typing from typing import Optional from qtpy.QtCore import QPointF from .exceptions import (ConnectionCycleFailure, ConnectionDataTypeFailure, ConnectionPointFailure, ConnectionPortNotEmptyFailure, ConnectionRequiresPortFailure, ConnectionSelfFailure, NodeConnectionFailure) from .port import PortType, opposite_port from .type_converter import TypeConverter if typing.TYPE_CHECKING: from .connection import Connection # noqa from .flow_scene import FlowScene # noqa from .node import Node # noqa from .port import Port # noqa logger = logging.getLogger(__name__) class NodeConnectionInteraction: def __init__(self, node: 'Node', connection: 'Connection', scene: 'FlowScene'): ''' An interactive connection interaction to complete `connection` with the given node Parameters ---------- node : Node connection : Connection scene : FlowScene ''' self._node = node self._connection = connection self._scene = scene @property def creates_cycle(self): """Would completing the connection introduce a cycle?""" required_port = self.connection_required_port return self.connection_node.has_connection_by_port_type( self._node, required_port) def can_connect(self) -> tuple['Port', Optional[TypeConverter]]: """ Can connect when following conditions are met: 1) Connection 'requires' a port - i.e., is missing either a start node or an end node 2) Connection's vacant end is above the node port in the user interface 3) Node port is vacant 4) Connection does not introduce a cycle in the graph 5) Connection type equals node port type, or there is a registered type conversion that can translate between the two Parameters ---------- Returns ------- port : Port The port to be connected. converter : TypeConverter The data type converter to use. Raises ------ NodeConnectionFailure ConnectionDataTypeFailure If port data types are not compatible """ # 1) Connection requires a port required_port = self.connection_required_port if required_port == PortType.none: raise ConnectionRequiresPortFailure('Connection requires a port') elif required_port not in (PortType.input, PortType.output): raise ValueError(f'Invalid port specified {required_port}') # 1.5) Forbid connecting the node to itself node = self.connection_node if node == self._node: raise ConnectionSelfFailure(f'Cannot connect {node} to itself') # 2) connection point is on top of the node port connection_point = self.connection_end_scene_position(required_port) port = self.node_port_under_scene_point(required_port, connection_point) if not port: raise ConnectionPointFailure( f'Connection point {connection_point} is not on node {node}') # 3) Node port is vacant if not port.can_connect: raise ConnectionPortNotEmptyFailure( f'Port {required_port} {port} cannot connect' ) # 4) Cycle check if self.creates_cycle: raise ConnectionCycleFailure( f'Connecting {self._node} and {node} would introduce a ' f'cycle in the graph' ) # 5) Connection type equals node port type, or there is a registered # type conversion that can translate between the two connection_data_type = self._connection.data_type(opposite_port(required_port)) candidate_node_data_type = port.data_type if connection_data_type.id == candidate_node_data_type.id: return port, None registry = self._scene.registry if required_port == PortType.input: converter = registry.get_type_converter(connection_data_type, candidate_node_data_type) else: converter = registry.get_type_converter(candidate_node_data_type, connection_data_type) if not converter: raise ConnectionDataTypeFailure( f'{connection_data_type} and {candidate_node_data_type} are not compatible' ) return port, converter def try_connect(self) -> bool: """ Try to connect the nodes. Steps:: 1) Check conditions from 'can_connect' 1.5) If the connection is possible but a type conversion is needed, add a converter node to the scene, and connect it properly 2) Assign node to required port in Connection 3) Assign Connection to empty port in NodeState 4) Adjust Connection geometry 5) Poke model to initiate data transfer Returns ------- value : bool """ # 1) Check conditions from 'can_connect' try: port, converter = self.can_connect() except NodeConnectionFailure as ex: logger.debug('Cannot connect node', exc_info=ex) logger.info('Cannot connect node: %s', ex) return False # 1.5) If the connection is possible but a type conversion is needed, # assign a convertor to connection if converter: self._connection.type_converter = converter # 2) Assign node to required port in Connection port.add_connection(self._connection) # 3) Assign Connection to empty port in NodeState # The port is not longer required after this function self._connection.connect_to(port) # 4) Adjust Connection geometry self._node.graphics_object.move_connections() # 5) Poke model to intiate data transfer _, out_port = self._connection.ports if out_port: out_port.node.on_data_updated(out_port) return True def disconnect(self, port_to_disconnect: PortType): """ 1) Node and Connection should be already connected 2) If so, clear Connection entry in the NodeState 3) Propagate invalid data to IN node 4) Set Connection end to 'requiring a port' Parameters ---------- port_to_disconnect : PortType """ port_index = self._connection.get_port_index(port_to_disconnect) state = self._node.state # clear pointer to Connection in the NodeState state.erase_connection(port_to_disconnect, port_index, self._connection) # Propagate invalid data to IN node self._connection.propagate_empty_data() # clear Connection side self._connection.clear_node(port_to_disconnect) self._connection.required_port = port_to_disconnect self._connection.graphics_object.grabMouse() @property def connection_required_port(self) -> PortType: """ The required port type to complete the connection Returns ------- value : PortType """ return self._connection.required_port @property def connection_node(self): """The node already specified for the connection""" required_port = self.connection_required_port return self._connection.get_node(opposite_port(required_port)) def connection_end_scene_position(self, port_type: PortType) -> QPointF: """ Connection end scene position Parameters ---------- port_type : PortType Returns ------- value : QPointF """ go = self._connection.graphics_object geometry = self._connection.geometry end_point = geometry.get_end_point(port_type) return go.mapToScene(end_point) def node_port_scene_position(self, port_type: PortType, port_index: int) -> QPointF: """ Node port scene position Parameters ---------- port_type : PortType port_index : int Returns ------- value : QPointF """ port = self._node.state[port_type][port_index] return port.get_mapped_scene_position( self._node.graphics_object.sceneTransform()) def node_port_under_scene_point(self, port_type: PortType, scene_point: QPointF) -> Optional['Port']: """ Node port under scene point Parameters ---------- port_type : PortType p : QPointF Returns ------- port : Port """ node_geom = self._node.geometry scene_transform = self._node.graphics_object.sceneTransform() return node_geom.check_hit_scene_point(port_type, scene_point, scene_transform) def node_port_is_empty(self, port_type: PortType, port_index: int) -> bool: """ Node port is empty Parameters ---------- port_type : PortType port_index : int Returns ------- value : bool """ port = self._node.state[port_type][port_index] return port.can_connect ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/qtpynodeeditor/node_data.py0000644000076500000240000002410514725355310020640 0ustar00kenstaffimport inspect from collections import namedtuple from typing import Optional from qtpy.QtCore import QObject, Signal from qtpy.QtWidgets import QWidget from . import style as style_module from .base import Serializable from .enums import ConnectionPolicy, NodeValidationState, PortType from .port import Port NodeDataType = namedtuple('NodeDataType', ('id', 'name')) class NodeData: """ Class represents data transferred between nodes. The actual data is stored in subtypes """ data_type = NodeDataType(None, None) def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) if cls.data_type is None: raise ValueError('Subclasses must set the `data_type` attribute') def same_type(self, other) -> bool: """ Is another NodeData instance of the same type? Parameters ---------- other : NodeData Returns ------- value : bool """ return self.data_type.id == other.data_type.id class NodeDataModel(QObject, Serializable): name: Optional[str] = None caption: Optional[str] = None caption_visible = True num_ports = {PortType.input: 1, PortType.output: 1, } # data_updated and data_invalidated refer to the port index that has # changed: data_updated = Signal(int) data_invalidated = Signal(int) computing_started = Signal() computing_finished = Signal() embedded_widget_size_updated = Signal() def __init__(self, style=None, parent=None): super().__init__(parent=parent) if style is None: style = style_module.default_style self._style = style def __init_subclass__(cls, verify=True, **kwargs): super().__init_subclass__(**kwargs) # For all subclasses, if no name is defined, default to the class name if cls.name is None: cls.name = cls.__name__ if cls.caption is None and cls.caption_visible: cls.caption = cls.name num_ports = cls.num_ports if isinstance(num_ports, property): # Dynamically defined - that's OK, but we can't verify it. return if verify: cls._verify() @classmethod def _verify(cls): ''' Verify the data model won't crash in strange spots Ensure valid dictionaries: - num_ports - data_type - port_caption - port_caption_visible ''' num_ports = cls.num_ports if isinstance(num_ports, property): # Dynamically defined - that's OK, but we can't verify it. return assert set(num_ports.keys()) == {'input', 'output'} # TODO while the end result is nicer, this is ugly; refactor away... def new_dict(value): return { PortType.input: {i: value for i in range(num_ports[PortType.input]) }, PortType.output: {i: value for i in range(num_ports[PortType.output]) }, } def get_default(attr, default, valid_type): current = getattr(cls, attr, None) if current is None: # Unset - use the default return default if valid_type is not None: if isinstance(current, valid_type): # Fill in the dictionary with the user-provided value return current if attr == 'data_type' and inspect.isclass(current): if issubclass(current, NodeData): return current.data_type if inspect.ismethod(current) or inspect.isfunction(current): raise ValueError('{} should not be a function; saw: {}\n' 'Did you forget a @property decorator?' ''.format(attr, current)) try: type(default)(current) except TypeError: raise ValueError('{} is of an unexpected type: {}' ''.format(attr, current)) from None # Fill in the dictionary with the given value return current def fill_defaults(attr, default, valid_type=None): if isinstance(getattr(cls, attr, None), dict): return default = get_default(attr, default, valid_type) if default is None: raise ValueError(f'Cannot leave {attr} unspecified') setattr(cls, attr, new_dict(default)) fill_defaults('port_caption', '') fill_defaults('port_caption_visible', False) fill_defaults('data_type', None, valid_type=NodeDataType) reasons = [] for attr in ('data_type', 'port_caption', 'port_caption_visible'): try: dct = getattr(cls, attr) except AttributeError: reasons.append('{} is missing dictionary: {}' ''.format(cls.__name__, attr)) continue if isinstance(dct, property): continue for port_type in {'input', 'output'}: if port_type not in dct: if num_ports[port_type] == 0: dct[port_type] = {} else: reasons.append('Port type key {}[{!r}] missing' ''.format(attr, port_type)) continue for i in range(num_ports[port_type]): if i not in dct[port_type]: reasons.append('Port key {}[{!r}][{}] missing' ''.format(attr, port_type, i)) if reasons: reason_text = '\n'.join(f'* {reason}' for reason in reasons) raise ValueError( 'Verification of NodeDataModel class failed:\n{}' ''.format(reason_text) ) @property def style(self): 'Style collection for drawing this data model' return self._style def save(self) -> dict: """ Subclasses may implement this to save additional state for pickling/saving to JSON. Returns ------- value : dict """ return {} def restore(self, doc: dict): """ Subclasses may implement this to load additional state from pickled or saved-to-JSON data. Parameters ---------- value : dict """ return {} def __setstate__(self, doc: dict): """ Set the state of the NodeDataModel Parameters ---------- doc : dict """ self.restore(doc) return doc def __getstate__(self) -> dict: """ Get the state of the NodeDataModel for saving/pickling Returns ------- value : QJsonObject """ doc = {'name': self.name} doc.update(**self.save()) return doc @property def data_type(self): """ Data type placeholder - to be implemented by subclass. Parameters ---------- port_type : PortType port_index : int Returns ------- value : NodeDataType """ raise NotImplementedError(f'Subclass {self.__class__.__name__} must ' f'implement `data_type`') def port_out_connection_policy(self, port_index: int) -> ConnectionPolicy: """ Port out connection policy Parameters ---------- port_index : int Returns ------- value : ConnectionPolicy """ return ConnectionPolicy.many @property def node_style(self) -> style_module.NodeStyle: """ Node style Returns ------- value : NodeStyle """ return self._style.node def set_in_data(self, node_data: NodeData, port: Port): """ Triggers the algorithm; to be overridden by subclasses Parameters ---------- node_data : NodeData port : Port """ ... def out_data(self, port: int) -> NodeData: """ Out data Parameters ---------- port : int Returns ------- value : NodeData """ ... def embedded_widget(self) -> QWidget: """ Embedded widget Returns ------- value : QWidget """ ... def resizable(self) -> bool: """ Resizable Returns ------- value : bool """ return False def validation_state(self) -> NodeValidationState: """ Validation state Returns ------- value : NodeValidationState """ return NodeValidationState.valid def validation_message(self) -> str: """ Validation message Returns ------- value : str """ return "" def painter_delegate(self): """ Painter delegate Returns ------- value : NodePainterDelegate """ return None def input_connection_created(self, connection): """ Input connection created Parameters ---------- connection : Connection """ ... def input_connection_deleted(self, connection): """ Input connection deleted Parameters ---------- connection : Connection """ ... def output_connection_created(self, connection): """ Output connection created Parameters ---------- connection : Connection """ ... def output_connection_deleted(self, connection): """ Output connection deleted Parameters ---------- connection : Connection """ ... ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733680615.0 qtpynodeeditor-0.3.3/qtpynodeeditor/node_geometry.py0000644000076500000240000003166614725356747021613 0ustar00kenstaffimport math import typing from qtpy.QtCore import QPointF, QRect, QRectF, QSizeF from qtpy.QtGui import QFont, QFontMetrics, QTransform from qtpy.QtWidgets import QSizePolicy from .enums import NodeValidationState, PortType from .port import Port if typing.TYPE_CHECKING: from .node import Node # noqa class NodeGeometry: def __init__(self, node: 'Node'): super().__init__() self._node = node self._model = node.model self._dragging_pos = QPointF(-1000, -1000) self._entry_width = 0 self._entry_height = 20 self._font_metrics = QFontMetrics(QFont()) self._height = 150 self._hovered = False self._input_port_width = 70 self._output_port_width = 70 self._spacing = 20 self._style = node.style self._width = 100 f = QFont() f.setBold(True) self._bold_font_metrics = QFontMetrics(f) @property def height(self) -> int: """ Node height. Returns ------- value : int """ return self._height @height.setter def height(self, h: int): self._height = int(h) @property def width(self) -> int: """ Node width. Returns ------- value : int """ return self._width @width.setter def width(self, width: int): self._width = int(width) @property def entry_height(self) -> int: """ Entry height Returns ------- value : int """ return self._entry_height @entry_height.setter def entry_height(self, h: int): self._entry_height = int(h) @property def entry_width(self) -> int: """ Entry width Returns ------- value : int """ return self._entry_width @entry_width.setter def entry_width(self, width: int): self._entry_width = int(width) @property def spacing(self) -> int: """ Spacing Returns ------- value : int """ return self._spacing @spacing.setter def spacing(self, s: int): self._spacing = int(s) @property def hovered(self) -> bool: """ Hovered Returns ------- value : bool """ return self._hovered @hovered.setter def hovered(self, h: int): self._hovered = bool(h) @property def num_sources(self) -> int: """ Number of sources. Returns ------- value : int """ return self._model.num_ports[PortType.output] @property def num_sinks(self) -> int: """ Number of sinks. Returns ------- value : int """ return self._model.num_ports[PortType.input] @property def dragging_position(self) -> QPointF: """ Dragging pos Returns ------- value : QPointF """ return self._dragging_pos @dragging_position.setter def dragging_position(self, pos: QPointF): self._dragging_pos = QPointF(pos) # Back-compatibility dragging_pos = dragging_position def entry_bounding_rect(self, *, addon=0.0) -> QRectF: """ Entry bounding rect Returns ------- value : QRectF """ return QRectF(0 - addon, 0 - addon, self._entry_width + 2 * addon, self._entry_height + 2 * addon) @property def bounding_rect(self) -> QRectF: """ Bounding rect Returns ------- value : QRectF """ addon = 4 * self._style.connection_point_diameter return QRectF(0 - addon, 0 - addon, self._width + 2 * addon, self._height + 2 * addon) def recalculate_size(self, font: QFont = None): """ If font is unspecified, Updates size unconditionally Otherwise, Updates size if the QFontMetrics is changed """ if font is not None: font_metrics = QFontMetrics(font) bold_font = QFont(font) bold_font.setBold(True) bold_font_metrics = QFontMetrics(bold_font) if self._bold_font_metrics == bold_font_metrics: return self._font_metrics = font_metrics self._bold_font_metrics = bold_font_metrics self._entry_height = self._font_metrics.height() max_num_of_entries = max((self.num_sinks, self.num_sources)) step = self._entry_height + self._spacing height = step * max_num_of_entries widget = self._model.embedded_widget() if widget: height = max((height, widget.height())) height += self.caption_height self._input_port_width = self.port_width(PortType.input) self._output_port_width = self.port_width(PortType.output) width = self._input_port_width + self._output_port_width + 2 * self._spacing if widget: width += widget.width() width = max((width, self.caption_width)) if self._model.validation_state() != NodeValidationState.valid: width = max((width, self.validation_width)) height += self.validation_height + self._spacing self._width = width self._height = height def port_scene_position(self, port_type: PortType, index: int, t: QTransform = None) -> QPointF: """ Port scene position Parameters ---------- port_type : PortType index : int t : QTransform Returns ------- value : QPointF """ if t is None: t = QTransform() step = self._entry_height + self._spacing total_height = float(self.caption_height) + step * index # TODO_UPSTREAM: why? total_height += step / 2.0 if port_type == PortType.output: x = self._width + self._style.connection_point_diameter result = QPointF(x, total_height) elif port_type == PortType.input: x = -float(self._style.connection_point_diameter) result = QPointF(x, total_height) else: raise ValueError(port_type) return t.map(result) def check_hit_scene_point(self, port_type: PortType, scene_point: QPointF, scene_transform: QTransform) -> typing.Optional[Port]: """ Check a scene point for a specific port type. Parameters ---------- port_type : PortType The port type to check for. scene_point : QPointF The point in the scene. scene_transform : QTransform The scene transform. Returns ------- port : Port or None The nearby port, if found. """ if port_type == PortType.none: return None nearby_port = None tolerance = 2.0 * self._style.connection_point_diameter for idx, port in self._node.state[port_type].items(): pos = port.get_mapped_scene_position(scene_transform) - scene_point distance = math.sqrt(QPointF.dotProduct(pos, pos)) if distance < tolerance: nearby_port = port break return nearby_port @property def resize_rect(self) -> QRect: """ Resize rect Returns ------- value : QRect """ rect_size = 7 return QRect(self._width - rect_size, self._height - rect_size, rect_size, rect_size) @property def widget_position(self) -> QPointF: """ Returns the position of a widget on the Node surface Returns ------- value : QPointF """ widget = self._model.embedded_widget() if not widget: return QPointF() if (widget.sizePolicy().verticalPolicy() == QSizePolicy.MinimumExpanding or widget.sizePolicy().verticalPolicy() == QSizePolicy.Expanding): # If the widget wants to use as much vertical space as possible, # place it immediately after the caption. return QPointF(self._spacing + self.port_width(PortType.input), self.caption_height) if self._model.validation_state() != NodeValidationState.valid: return QPointF( self._spacing + self.port_width(PortType.input), (self.caption_height + self._height - self.validation_height - self._spacing - widget.height()) / 2.0, ) return QPointF( self._spacing + self.port_width(PortType.input), (self.caption_height + self._height - widget.height()) / 2.0 ) def equivalent_widget_height(self) -> int: ''' The maximum height a widget can be without causing the node to grow. Returns ------- value : int ''' base_height = self.height - self.caption_height if self._model.validation_state() != NodeValidationState.valid: return base_height + self.validation_height return base_height @property def validation_height(self) -> int: """ Validation height Returns ------- value : int """ msg = self._model.validation_message() return self._bold_font_metrics.boundingRect(msg).height() @property def validation_width(self) -> int: """ Validation width Returns ------- value : int """ msg = self._model.validation_message() return self._bold_font_metrics.boundingRect(msg).width() @staticmethod def calculate_node_position_between_node_ports( target_port_index: int, target_port: PortType, target_node: 'Node', source_port_index: int, source_port: PortType, source_node: 'Node', new_node: 'Node') -> QPointF: """ calculate node position between node ports Calculating the nodes position in the scene. It'll be positioned half way between the two ports that it "connects". The first line calculates the halfway point between the ports (node position + port position on the node for both nodes averaged). The second line offsets self coordinate with the size of the new node, so that the new nodes center falls on the originally calculated coordinate, instead of it's upper left corner. Parameters ---------- target_port_index : int target_port : PortType target_node : Node source_port_index : int source_port : PortType source_node : Node new_node : Node Returns ------- value : QPointF """ if (source_node.graphics_object is None or target_node.graphics_object is None): raise ValueError('Uninitialized node') converter_node_pos = ( source_node.graphics_object.pos() + source_node.geometry.port_scene_position(source_port, source_port_index) + target_node.graphics_object.pos() + target_node.geometry.port_scene_position(target_port, target_port_index) ) / 2.0 converter_node_pos.setX(converter_node_pos.x() - new_node.geometry.width / 2.0) converter_node_pos.setY(converter_node_pos.y() - new_node.geometry.height / 2.0) return converter_node_pos @property def caption_height(self) -> int: """ Caption height Returns ------- value : int """ if not self._model.caption_visible: return 0 name = self._model.caption return self._bold_font_metrics.boundingRect(name).height() @property def caption_width(self) -> int: """ Caption width Returns ------- value : int """ if not self._model.caption_visible: return 0 name = self._model.caption return self._bold_font_metrics.boundingRect(name).width() def port_width(self, port_type: PortType) -> int: """ Port width Parameters ---------- port_type : PortType Returns ------- value : int """ names = [port.display_text for port in self._node[port_type].values()] if not names: return 0 return max(self._font_metrics.horizontalAdvance(name) for name in names) @property def size(self): """ Get the node size Parameters ---------- node : Node Returns ------- value : QSizeF """ return QSizeF(self.width, self.height) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734299658.0 qtpynodeeditor-0.3.3/qtpynodeeditor/node_graphics_object.py0000644000076500000240000002523314727650012023057 0ustar00kenstaffimport typing from qtpy.QtCore import QPoint, QRectF, QSize, QSizeF, Qt from qtpy.QtGui import QCursor, QPainter from qtpy.QtWidgets import (QGraphicsDropShadowEffect, QGraphicsItem, QGraphicsObject, QGraphicsProxyWidget, QGraphicsSceneContextMenuEvent, QGraphicsSceneHoverEvent, QGraphicsSceneMouseEvent, QSizePolicy, QStyleOptionGraphicsItem, QWidget) from .enums import ConnectionPolicy from .node_connection_interaction import NodeConnectionInteraction from .port import PortType class NodeGraphicsObject(QGraphicsObject): def __init__(self, scene, node): super().__init__() self._scene = scene self._node = node self._locked = False self._proxy_widget = None self._scene.addItem(self) self.setFlag(QGraphicsItem.ItemDoesntPropagateOpacityToChildren, True) self.setFlag(QGraphicsItem.ItemIsMovable, True) self.setFlag(QGraphicsItem.ItemIsFocusable, True) self.setFlag(QGraphicsItem.ItemIsSelectable, True) self.setFlag(QGraphicsItem.ItemSendsScenePositionChanges, True) self.setCacheMode(QGraphicsItem.DeviceCoordinateCache) self._style = node.model.style node_style = self._style.node effect = QGraphicsDropShadowEffect() effect.setOffset(4, 4) effect.setBlurRadius(20) effect.setColor(node_style.shadow_color) self.setGraphicsEffect(effect) self.setOpacity(node_style.opacity) self.setAcceptHoverEvents(True) self.setZValue(0) self.embed_q_widget() # connect to the move signals to emit the move signals in FlowScene def on_move(): self._scene.node_moved.emit(self._node, self.pos()) self.xChanged.connect(on_move) self.yChanged.connect(on_move) def _cleanup(self): if self._scene is not None: self._scene.removeItem(self) self._scene = None def setPos(self, pos): super().setPos(pos) self.move_connections() @property def node(self): """ Node Returns ------- value : Node """ return self._node def boundingRect(self) -> QRectF: """ boundingRect Returns ------- value : QRectF """ return self._node.geometry.bounding_rect def set_geometry_changed(self): self.prepareGeometryChange() def move_connections(self): """ Visits all attached connections and corrects their corresponding end points. """ for conn in self._node.state.all_connections: conn.graphics_object.move() def lock(self, locked: bool): """ Lock Parameters ---------- locked : bool """ self._locked = locked self.setFlag(QGraphicsItem.ItemIsMovable, not locked) self.setFlag(QGraphicsItem.ItemIsFocusable, not locked) self.setFlag(QGraphicsItem.ItemIsSelectable, not locked) def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: QWidget): """ Paint Parameters ---------- painter : QPainter option : QStyleOptionGraphicsItem widget : QWidget """ from .node_painter import NodePainter # TODO painter.setClipRect(option.exposedRect) NodePainter.paint(painter, self._node, self._scene, node_style=self._style.node, connection_style=self._style.connection, ) def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: typing.Any) -> typing.Any: """ itemChange Parameters ---------- change : QGraphicsItem.GraphicsItemChange value : any Returns ------- value : any """ if change == QGraphicsItem.ItemPositionChange and self.scene(): self.move_connections() return super().itemChange(change, value) def mousePressEvent(self, event: QGraphicsSceneMouseEvent): """ mousePressEvent Parameters ---------- event : QGraphicsSceneMouseEvent """ if self._locked: return # deselect all other items after self one is selected if not self.isSelected() and not (event.modifiers() & Qt.ControlModifier): self._scene.clearSelection() node_geometry = self._node.geometry for port_to_check in (PortType.input, PortType.output): # TODO do not pass sceneTransform port = node_geometry.check_hit_scene_point(port_to_check, event.scenePos(), self.sceneTransform()) if not port: continue connections = port.connections # start dragging existing connection if connections and port_to_check == PortType.input: conn, = connections interaction = NodeConnectionInteraction(self._node, conn, self._scene) interaction.disconnect(port_to_check) elif port_to_check == PortType.output: # initialize new Connection out_policy = port.connection_policy if connections and out_policy == ConnectionPolicy.one: conn, = connections self._scene.delete_connection(conn) # TODO_UPSTREAM: add to FlowScene connection = self._scene.create_connection(port) connection.graphics_object.grabMouse() pos = QPoint(int(event.pos().x()), int(event.pos().y())) geom = self._node.geometry state = self._node.state if self._node.model.resizable() and geom.resize_rect.contains(pos): state.resizing = True self._scene.node_dragging.emit(True) def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent): """ mouseMoveEvent Parameters ---------- event : QGraphicsSceneMouseEvent """ geom = self._node.geometry state = self._node.state if state.resizing: diff = event.pos() - event.lastPos() w = self._node.model.embedded_widget() if w: self.prepareGeometryChange() old_size = w.size() + QSize(int(diff.x()), int(diff.y())) w.setFixedSize(old_size) old_size_f = QSizeF(old_size) self._proxy_widget.setMinimumSize(old_size_f) self._proxy_widget.setMaximumSize(old_size_f) self._proxy_widget.setPos(geom.widget_position) geom.recalculate_size() self.update() self.move_connections() event.accept() else: super().mouseMoveEvent(event) if event.lastPos() != event.pos(): self.move_connections() event.ignore() bounding = self.mapToScene(self.boundingRect()).boundingRect() r = self.scene().sceneRect().united(bounding) self.scene().setSceneRect(r) def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent): """ mouseReleaseEvent Parameters ---------- event : QGraphicsSceneMouseEvent """ self._scene.node_dragging.emit(False) self._node.state.resizing = False super().mouseReleaseEvent(event) # position connections precisely after fast node move self.move_connections() def hoverEnterEvent(self, event: QGraphicsSceneHoverEvent): """ hoverEnterEvent Parameters ---------- event : QGraphicsSceneHoverEvent """ # void # bring all the colliding nodes to background overlap_items = self.collidingItems() for item in overlap_items: if item.zValue() > 0.0: item.setZValue(0.0) # bring self node forward self.setZValue(1.0) self._node.geometry.hovered = True self.update() self._scene.node_hovered.emit(self._node, event.screenPos()) event.accept() def hoverLeaveEvent(self, event: QGraphicsSceneHoverEvent): """ hoverLeaveEvent Parameters ---------- event : QGraphicsSceneHoverEvent """ self._node.geometry.hovered = False self.update() self._scene.node_hover_left.emit(self._node) event.accept() def hoverMoveEvent(self, event: QGraphicsSceneHoverEvent): """ hoverMoveEvent Parameters ---------- q_graphics_scene_hover_event : QGraphicsSceneHoverEvent """ pos = event.pos() geom = self._node.geometry if self._node.model.resizable() and geom.resize_rect.contains( QPoint(int(pos.x()), int(pos.y())) ): self.setCursor(QCursor(Qt.SizeFDiagCursor)) else: self.setCursor(QCursor()) event.accept() def mouseDoubleClickEvent(self, event: QGraphicsSceneMouseEvent): """ mouseDoubleClickEvent Parameters ---------- event : QGraphicsSceneMouseEvent """ super().mouseDoubleClickEvent(event) self._scene.node_double_clicked.emit(self._node) def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent): """ contextMenuEvent Parameters ---------- event : QGraphicsSceneContextMenuEvent """ self._scene.node_context_menu.emit( self._node, event.scenePos(), event.screenPos()) def embed_q_widget(self): geom = self._node.geometry widget = self._node.model.embedded_widget() if widget is None: return self._proxy_widget = QGraphicsProxyWidget(self) self._proxy_widget.setWidget(widget) self._proxy_widget.setPreferredWidth(5) geom.recalculate_size() # If the widget wants to use as much vertical space as possible, set it # to have the geomtry's equivalent_widget_height. if ( widget.sizePolicy().verticalPolicy() == QSizePolicy.MinimumExpanding or widget.sizePolicy().verticalPolicy() == QSizePolicy.Expanding ): self._proxy_widget.setMinimumHeight(geom.equivalent_widget_height()) self._proxy_widget.setPos(geom.widget_position) self.update() self._proxy_widget.setOpacity(1.0) self._proxy_widget.setFlag(QGraphicsItem.ItemIgnoresParentOpacity) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/qtpynodeeditor/node_painter.py0000644000076500000240000002677114725355310021404 0ustar00kenstaffimport math import typing from qtpy.QtCore import QPointF, QRectF, Qt from qtpy.QtGui import QFontMetrics, QLinearGradient, QPainter, QPen from .enums import NodeValidationState, PortType from .node_data import NodeDataModel from .node_geometry import NodeGeometry from .node_graphics_object import NodeGraphicsObject from .node_state import NodeState from .style import ConnectionStyle, NodeStyle if typing.TYPE_CHECKING: from .connection import Connection # noqa from .flow_scene import FlowScene # noqa from .node import Node # noqa class NodePainterDelegate: def paint(self, painter: QPainter, geom: NodeGeometry, model: NodeDataModel): """ Paint Parameters ---------- painter : QPainter geom : NodeGeometry model : NodeDataModel """ ... class NodePainter: @staticmethod def paint(painter: QPainter, node: 'Node', scene: 'FlowScene', node_style: NodeStyle, connection_style: ConnectionStyle): """ Paint Parameters ---------- painter : QPainter node : Node scene : FlowScene node_style : NodeStyle connection_style : ConnectionStyle """ geom = node.geometry state = node.state graphics_object = node.graphics_object if graphics_object is None: # On CI, we might not have a graphics object return geom.recalculate_size(painter.font()) model = node.model NodePainter.draw_node_rect(painter, geom, model, graphics_object, node_style) NodePainter.draw_connection_points(painter, geom, state, model, scene, node_style, connection_style) NodePainter.draw_filled_connection_points(painter, geom, state, model, node_style, connection_style ) NodePainter.draw_model_name(painter, geom, state, model, node_style) NodePainter.draw_entry_labels(painter, geom, state, model, node_style) NodePainter.draw_resize_rect(painter, geom, model) NodePainter.draw_validation_rect(painter, geom, model, graphics_object, node_style) # call custom painter painter_delegate = model.painter_delegate() if painter_delegate: painter_delegate.paint(painter, geom, model) @staticmethod def draw_node_rect(painter: QPainter, geom: NodeGeometry, model: NodeDataModel, graphics_object: NodeGraphicsObject, node_style: NodeStyle): """ Draw node rect Parameters ---------- painter : QPainter geom : NodeGeometry model : NodeDataModel graphics_object : NodeGraphicsObject node_style : NodeStyle """ color = (node_style.selected_boundary_color if graphics_object.isSelected() else node_style.normal_boundary_color ) p = QPen(color, (node_style.hovered_pen_width if geom.hovered else node_style.pen_width)) painter.setPen(p) gradient = QLinearGradient(QPointF(0.0, 0.0), QPointF(2.0, geom.height)) for at_, color in node_style.gradient_colors: gradient.setColorAt(at_, color) painter.setBrush(gradient) diam = node_style.connection_point_diameter boundary = QRectF(-diam, -diam, 2.0 * diam + geom.width, 2.0 * diam + geom.height) radius = 3.0 painter.drawRoundedRect(boundary, radius, radius) @staticmethod def draw_model_name(painter: QPainter, geom: NodeGeometry, state: NodeState, model: NodeDataModel, node_style: NodeStyle): """ Draw model name Parameters ---------- painter : QPainter geom : NodeGeometry state : NodeState model : NodeDataModel """ if not model.caption_visible: return name = model.caption f = painter.font() f.setBold(True) metrics = QFontMetrics(f) rect = metrics.boundingRect(name) position = QPointF((geom.width - rect.width()) / 2.0, (geom.spacing + geom.entry_height) / 3.0) painter.setFont(f) painter.setPen(node_style.font_color) painter.drawText(position, name) f.setBold(False) painter.setFont(f) @staticmethod def draw_entry_labels(painter: QPainter, geom: NodeGeometry, state: NodeState, model: NodeDataModel, node_style: NodeStyle): """ Draw entry labels Parameters ---------- painter : QPainter geom : NodeGeometry state : NodeState model : NodeDataModel node_style : NodeStyle """ metrics = painter.fontMetrics() for port in state.ports: scene_pos = port.scene_position if not port.connections: painter.setPen(node_style.font_color_faded) else: painter.setPen(node_style.font_color) display_text = port.display_text rect = metrics.boundingRect(display_text) scene_pos.setY(scene_pos.y() + rect.height() / 4.0) if port.port_type == PortType.input: scene_pos.setX(5.0) elif port.port_type == PortType.output: scene_pos.setX(geom.width - 5.0 - rect.width()) painter.drawText(scene_pos, display_text) @staticmethod def draw_connection_points(painter: QPainter, geom: NodeGeometry, state: NodeState, model: NodeDataModel, scene: 'FlowScene', node_style: NodeStyle, connection_style: ConnectionStyle ): """ Draw connection points Parameters ---------- painter : QPainter geom : NodeGeometry state : NodeState model : NodeDataModel scene : FlowScene connection_style : ConnectionStyle """ diameter = node_style.connection_point_diameter reduced_diameter = diameter * 0.6 for port in state.ports: scene_pos = port.scene_position can_connect = port.can_connect port_type = port.port_type data_type = port.data_type r = 1.0 if state.is_reacting and can_connect and port_type == state.reacting_port_type: diff = geom.dragging_pos - scene_pos dist = math.sqrt(QPointF.dotProduct(diff, diff)) registry = scene.registry dtype1, dtype2 = state.reacting_data_type, data_type if port_type != PortType.input: dtype2, dtype1 = dtype1, dtype2 type_convertable = registry.get_type_converter(dtype1, dtype2) is not None if dtype1.id == dtype2.id or type_convertable: thres = 40.0 r = ((2.0 - dist / thres) if dist < thres else 1.0) else: thres = 80.0 r = ((dist / thres) if dist < thres else 1.0) if connection_style.use_data_defined_colors: brush = connection_style.get_normal_color(data_type.id) else: brush = node_style.connection_point_color painter.setBrush(brush) painter.drawEllipse(scene_pos, reduced_diameter * r, reduced_diameter * r) @staticmethod def draw_filled_connection_points(painter: QPainter, geom: NodeGeometry, state: NodeState, model: NodeDataModel, node_style: NodeStyle, connection_style: ConnectionStyle ): """ Draw filled connection points Parameters ---------- painter : QPainter geom : NodeGeometry state : NodeState model : NodeDataModel node_style : NodeStyle connection_style : ConnectionStyle """ diameter = node_style.connection_point_diameter for port in state.ports: if not port.connections: continue scene_pos = port.scene_position if connection_style.use_data_defined_colors: c = connection_style.get_normal_color(port.data_type.id) else: c = node_style.filled_connection_point_color painter.setPen(c) painter.setBrush(c) painter.drawEllipse(scene_pos, diameter * 0.4, diameter * 0.4) @staticmethod def draw_resize_rect(painter: QPainter, geom: NodeGeometry, model: NodeDataModel): """ Draw resize rect Parameters ---------- painter : QPainter geom : NodeGeometry model : NodeDataModel """ if model.resizable(): painter.setBrush(Qt.gray) painter.drawEllipse(geom.resize_rect) @staticmethod def draw_validation_rect(painter: QPainter, geom: NodeGeometry, model: NodeDataModel, graphics_object: NodeGraphicsObject, node_style: NodeStyle): """ Draw validation rect Parameters ---------- painter : QPainter geom : NodeGeometry model : NodeDataModel graphics_object : NodeGraphicsObject node_style : NodeStyle """ model_validation_state = model.validation_state() if model_validation_state == NodeValidationState.valid: return color = (node_style.selected_boundary_color if graphics_object.isSelected() else node_style.normal_boundary_color) if geom.hovered: p = QPen(color, node_style.hovered_pen_width) else: p = QPen(color, node_style.pen_width) painter.setPen(p) # Drawing the validation message background if model_validation_state == NodeValidationState.error: painter.setBrush(node_style.error_color) else: painter.setBrush(node_style.warning_color) radius = 3.0 diam = node_style.connection_point_diameter boundary = QRectF( -diam, -diam + geom.height - geom.validation_height, 2.0 * diam + geom.width, 2.0 * diam + geom.validation_height, ) painter.drawRoundedRect(boundary, radius, radius) painter.setBrush(Qt.gray) # Drawing the validation message itself error_msg = model.validation_message() f = painter.font() metrics = QFontMetrics(f) rect = metrics.boundingRect(error_msg) position = QPointF( (geom.width - rect.width()) / 2.0, geom.height - (geom.validation_height - diam) / 2.0 ) painter.setFont(f) painter.setPen(node_style.font_color) painter.drawText(position, error_msg) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/qtpynodeeditor/node_state.py0000644000076500000240000001050514725355310021046 0ustar00kenstaffimport typing from collections import OrderedDict from .enums import ReactToConnectionState from .node_data import NodeDataType from .port import Port, PortType if typing.TYPE_CHECKING: from .connection import Connection # noqa class NodeState: def __init__(self, node): ''' node_state Parameters ---------- model : NodeDataModel ''' self._ports = {PortType.input: OrderedDict(), PortType.output: OrderedDict() } model = node.model for port_type in self._ports: num_ports = model.num_ports[port_type] self._ports[port_type] = OrderedDict( (i, Port(node, port_type=port_type, index=i)) for i in range(num_ports) ) self._reaction = ReactToConnectionState.not_reacting self._reacting_port_type = PortType.none self._reacting_data_type = None self._resizing = False def __getitem__(self, key): return self._ports[key] @property def ports(self): yield from self.input_ports yield from self.output_ports @property def input_ports(self): yield from self[PortType.input].values() @property def output_ports(self): yield from self[PortType.output].values() @property def output_connections(self): """All output connections""" return [ connection for idx, port in self._ports[PortType.output].items() for connection in port.connections ] @property def input_connections(self): """All input connections""" return [ connection for idx, port in self._ports[PortType.input].items() for connection in port.connections ] @property def all_connections(self): """All input and output connections""" return self.input_connections + self.output_connections def connections(self, port_type: PortType, port_index: int) -> list: """ Connections Parameters ---------- port_type : PortType port_index : int Returns ------- value : list """ return list(self._ports[port_type][port_index].connections) def erase_connection(self, port_type: PortType, port_index: int, connection: 'Connection'): """ Erase connection Parameters ---------- port_type : PortType port_index : int connection : Connection """ self._ports[port_type][port_index].remove_connection(connection) @property def reaction(self) -> ReactToConnectionState: """ Reaction Returns ------- value : NodeState.ReactToConnectionState """ return self._reaction @property def reacting_port_type(self) -> PortType: """ Reacting port type Returns ------- value : PortType """ return self._reacting_port_type @property def reacting_data_type(self) -> NodeDataType: """ Reacting data type Returns ------- value : NodeDataType """ return self._reacting_data_type def set_reaction(self, reaction: ReactToConnectionState, reacting_port_type: PortType = PortType.none, reacting_data_type: NodeDataType = None): """ Set reaction Parameters ---------- reaction : NodeState.ReactToConnectionState reacting_port_type : PortType, optional reacting_data_type : NodeDataType """ self._reaction = ReactToConnectionState(reaction) self._reacting_port_type = reacting_port_type self._reacting_data_type = reacting_data_type @property def is_reacting(self) -> bool: """ Is the node reacting to a mouse event? Returns ------- value : bool """ return self._reaction == ReactToConnectionState.reacting @property def resizing(self) -> bool: """ Resizing Returns ------- value : bool """ return self._resizing @resizing.setter def resizing(self, resizing: bool): self._resizing = resizing ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/qtpynodeeditor/port.py0000644000076500000240000001050714725355310017707 0ustar00kenstaffimport typing from qtpy.QtCore import QObject, Signal from .enums import ConnectionPolicy, PortType if typing.TYPE_CHECKING: from .connection import Connection # noqa def opposite_port(port: PortType) -> PortType: """ Get the opposite port of `port`. Parameters ---------- port : PortType """ return {PortType.input: PortType.output, PortType.output: PortType.input}.get(port, PortType.none) class Port(QObject): """ Signals ------- connection_created : Signal(Connection) connection_deleted : Signal(Connection) data_updated : Signal(QObject) data_invalidated : Signal(QObject) """ connection_created = Signal(object) connection_deleted = Signal(object) data_updated = Signal(QObject) data_invalidated = Signal(QObject) _connections: list['Connection'] def __init__(self, node, *, port_type: PortType, index: int): super().__init__(parent=node) self.node = node self.port_type = port_type self.index = index self._connections = [] self.opposite_port = {PortType.input: PortType.output, PortType.output: PortType.input}[self.port_type] @property def connections(self): return list(self._connections) @property def model(self): 'The data model associated with the Port' return self.node.model @property def data(self): 'The NodeData associated with the Port, if an output port' if self.port_type == PortType.input: # return self.model.in_data(self.index) # TODO return else: return self.model.out_data(self.index) @property def can_connect(self): 'Can this port be connected to?' return (not self._connections or self.connection_policy == ConnectionPolicy.many) @property def caption(self): 'Data model-specified caption for the port' return self.model.port_caption[self.port_type][self.index] @property def caption_visible(self): 'Show the data model-specified caption?' return self.model.port_caption_visible[self.port_type][self.index] @property def data_type(self): 'The NodeData type associated with the Port' return self.model.data_type[self.port_type][self.index] @property def display_text(self): 'The text to show on the label caption' return (self.caption if self.caption_visible else self.data_type.name) @property def connection_policy(self): 'The connection policy (one/many) for the port' if self.port_type == PortType.input: return ConnectionPolicy.one else: return self.model.port_out_connection_policy(self.index) def add_connection(self, connection: 'Connection'): 'Add a Connection to the Port' if connection in self._connections: raise ValueError('Connection already in list') self._connections.append(connection) self.connection_created.emit(connection) def remove_connection(self, connection: 'Connection'): 'Remove a Connection from the Port' try: self._connections.remove(connection) except ValueError: # TODO: should not be reaching this ... else: self.connection_deleted.emit(connection) @property def scene_position(self): ''' The position in the scene of the Port Returns ------- value : QPointF See also -------- get_mapped_scene_position ''' return self.node.geometry.port_scene_position(self.port_type, self.index) def get_mapped_scene_position(self, transform): """ Node port scene position after a transform Parameters ---------- port_type : PortType port_index : int Returns ------- value : QPointF """ ngo = self.node.graphics_object return ngo.sceneTransform().map(self.scene_position) def __repr__(self): return (f'<{self.__class__.__name__} port_type={self.port_type} ' f'index={self.index} connections={len(self._connections)}>') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/qtpynodeeditor/style.py0000644000076500000240000001766214725355310020074 0ustar00kenstaffimport json import logging import random from qtpy.QtGui import QColor logger = logging.getLogger(__name__) def _get_qcolor(style_dict, key): if key not in style_dict: return QColor() name_or_list = style_dict[key] if isinstance(name_or_list, list): color = QColor(*name_or_list) else: color = QColor(name_or_list) logger.debug('Loaded color %s = %s -> %d %d %d %d', key, name_or_list, *color.getRgb()) return color class Style: default_style = { "FlowViewStyle": { "BackgroundColor": [53, 53, 53], "FineGridColor": [60, 60, 60], "CoarseGridColor": [25, 25, 25] }, "NodeStyle": { "NormalBoundaryColor": [255, 255, 255], "SelectedBoundaryColor": [255, 165, 0], "GradientColor0": "gray", "GradientColor1": [80, 80, 80], "GradientColor2": [64, 64, 64], "GradientColor3": [58, 58, 58], "ShadowColor": [20, 20, 20], "FontColor": "white", "FontColorFaded": "gray", "ConnectionPointColor": [169, 169, 169], "FilledConnectionPointColor": "cyan", "ErrorColor": "red", "WarningColor": [128, 128, 0], "PenWidth": 1.0, "HoveredPenWidth": 1.5, "ConnectionPointDiameter": 8.0, "Opacity": 0.8 }, "ConnectionStyle": { "ConstructionColor": "gray", "NormalColor": "darkcyan", "SelectedColor": [100, 100, 100], "SelectedHaloColor": "orange", "HoveredColor": "lightcyan", "LineWidth": 3.0, "ConstructionLineWidth": 2.0, "PointDiameter": 10.0, "UseDataDefinedColors": False } } def __init__(self, json_style=None): if json_style is None: json_style = self.default_style self.load_from_json(json_style) def load_from_json(self, json_style: str): """ Load from json Parameters ---------- json_style : str or dict """ if isinstance(json_style, dict): return json_style else: return json.loads(json_style) class FlowViewStyle(Style): def __init__(self, json_style=None): self.background_color = QColor() self.fine_grid_color = QColor() self.coarse_grid_color = QColor() super().__init__(json_style=json_style) def load_from_json(self, json_style: str): """ Load from json Parameters ---------- json_style : str or dict """ doc = super().load_from_json(json_style) style = doc["FlowViewStyle"] self.background_color = _get_qcolor(style, 'BackgroundColor') self.fine_grid_color = _get_qcolor(style, 'FineGridColor') self.coarse_grid_color = _get_qcolor(style, 'CoarseGridColor') class ConnectionStyle(Style): ''' Style for connections Attributes ---------- construction_color : QColor normal_color : QColor selected_color : QColor selected_halo_color : QColor hovered_color : QColor line_width : float construction_line_width : float point_diameter : float use_data_defined_colors : bool ''' def __init__(self, json_style=None): self.construction_color = QColor() self.normal_color = QColor() self.selected_color = QColor() self.selected_halo_color = QColor() self.hovered_color = QColor() self.line_width = 0.0 self.construction_line_width = 0.0 self.point_diameter = 0.0 self.use_data_defined_colors = True super().__init__(json_style=json_style) def load_from_json(self, json_style: str): """ Load from json Parameters ---------- json_style : str """ doc = super().load_from_json(json_style) style = doc["ConnectionStyle"] self.construction_color = _get_qcolor(style, 'ConstructionColor') self.normal_color = _get_qcolor(style, 'NormalColor') self.selected_color = _get_qcolor(style, 'SelectedColor') self.selected_halo_color = _get_qcolor(style, 'SelectedHaloColor') self.hovered_color = _get_qcolor(style, 'HoveredColor') self.line_width = float(style['LineWidth']) self.construction_line_width = float(style['ConstructionLineWidth']) self.point_diameter = float(style['PointDiameter']) self.use_data_defined_colors = bool(style['UseDataDefinedColors']) def get_normal_color(self, type_id: str = None) -> QColor: """ Normal color Parameters ---------- type_id : str Returns ------- value : QColor """ if type_id is None: return self.normal_color hue_range = 0xFF random.seed(type_id) hue = random.randint(0, hue_range) sat = 120 + id(type_id) % 129 return QColor.fromHsl(hue, sat, 160) class NodeStyle(Style): def __init__(self, json_style=None): self.normal_boundary_color = QColor() self.selected_boundary_color = QColor() self.gradient_colors = ((0, QColor()), ) self.shadow_color = QColor() self.font_color = QColor() self.font_color_faded = QColor() self.connection_point_color = QColor() self.filled_connection_point_color = QColor() self.warning_color = QColor() self.error_color = QColor() self.pen_width = 1.0 self.hovered_pen_width = 2.0 self.connection_point_diameter = 5.0 self.opacity = 1.0 super().__init__(json_style=json_style) def load_from_json(self, json_style: str): """ Load from json Parameters ---------- json_style : str """ doc = super().load_from_json(json_style) style = doc["NodeStyle"] self.normal_boundary_color = _get_qcolor(style, 'NormalBoundaryColor') self.selected_boundary_color = _get_qcolor( style, 'SelectedBoundaryColor') self.gradient_colors = ( (0.0, _get_qcolor(style, 'GradientColor0')), (0.03, _get_qcolor(style, 'GradientColor1')), (0.97, _get_qcolor(style, 'GradientColor2')), (1.0, _get_qcolor(style, 'GradientColor3')), ) self.shadow_color = _get_qcolor(style, 'ShadowColor') self.font_color = _get_qcolor(style, 'FontColor') self.font_color_faded = _get_qcolor(style, 'FontColorFaded') self.connection_point_color = _get_qcolor( style, 'ConnectionPointColor') self.filled_connection_point_color = _get_qcolor( style, 'FilledConnectionPointColor') self.warning_color = _get_qcolor(style, 'WarningColor') self.error_color = _get_qcolor(style, 'ErrorColor') self.pen_width = float(style['PenWidth']) self.hovered_pen_width = float(style['HoveredPenWidth']) self.connection_point_diameter = float( style['ConnectionPointDiameter']) self.opacity = float(style['Opacity']) class StyleCollection: 'Container for all styles' def __init__(self, *, node=None, connection=None, flow_view=None): if node is None: node = NodeStyle() self.node = node if connection is None: connection = ConnectionStyle() self.connection = connection if flow_view is None: flow_view = FlowViewStyle() self.flow_view = flow_view @classmethod def from_json(cls, json_doc): if isinstance(json_doc, dict): json_style = json_doc else: json_style = json.loads(json_doc) return StyleCollection( node=NodeStyle(json_style), connection=ConnectionStyle(json_style), flow_view=FlowViewStyle(json_style), ) default_style = StyleCollection() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1734303703.6651237 qtpynodeeditor-0.3.3/qtpynodeeditor/tests/0000755000076500000240000000000014727657730017525 5ustar00kenstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/qtpynodeeditor/tests/__init__.py0000644000076500000240000000000014725355310021607 0ustar00kenstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/qtpynodeeditor/tests/conftest.py0000644000076500000240000000010414725355310021702 0ustar00kenstaffimport pytest # noqa from pytestqt.qt_compat import qt_api # noqa ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/qtpynodeeditor/tests/test_basic.py0000644000076500000240000002773014725355310022213 0ustar00kenstaffimport unittest.mock import pytest import qtpy.QtCore import qtpynodeeditor as nodeeditor from qtpynodeeditor import PortType class MyNodeData(nodeeditor.NodeData): data_type = nodeeditor.NodeDataType('MyNodeData', 'My Node Data') class MyOtherNodeData(nodeeditor.NodeData): data_type = nodeeditor.NodeDataType('MyOtherNodeData', 'My Other Node Data') class BasicDataModel(nodeeditor.NodeDataModel): name = 'MyDataModel' caption = 'Caption' caption_visible = True num_ports = {'input': 3, 'output': 3 } data_type = MyNodeData.data_type def model(self): return 'MyDataModel' def out_data(self, port_index): return MyNodeData() def set_in_data(self, node_data, port): ... def embedded_widget(self): return None class BasicOtherDataModel(nodeeditor.NodeDataModel): name = 'MyOtherDataModel' caption = 'Caption' caption_visible = True num_ports = {'input': 1, 'output': 1 } data_type = MyOtherNodeData.data_type # @pytest.mark.parametrize("model_class", [...]) @pytest.fixture(scope='function') def model(): return BasicDataModel @pytest.fixture(scope='function') def other_model(): return BasicOtherDataModel @pytest.fixture(scope='function') def registry(model, other_model): registry = nodeeditor.DataModelRegistry() registry.register_model(model, category='My Category') registry.register_model(other_model, category='My Category') return registry @pytest.fixture(scope='function') def scene(qapp, registry): return nodeeditor.FlowScene(registry=registry) @pytest.fixture(scope='function') def view(qtbot, scene): view = nodeeditor.FlowView(scene) qtbot.addWidget(view) view.setWindowTitle("nodeeditor test suite") view.resize(800, 600) view.show() return view def test_instantiation(view): ... def test_create_node(scene, model): node = scene.create_node(model) assert node in scene.nodes.values() assert node.id in scene.nodes assert scene.allow_node_creation assert scene.allow_node_deletion def test_selected_nodes(scene, model): node = scene.create_node(model) node.graphics_object.setSelected(True) assert scene.selected_nodes() == [node] def test_create_connection(scene, view, model): node1 = scene.create_node(model) node2 = scene.create_node(model) scene.create_connection(node2[PortType.output][2], node1[PortType.input][1], ) view.update() assert len(scene.connections) == 1 all_c1 = node1.state.all_connections assert len(all_c1) == 1 all_c2 = node1.state.all_connections assert len(all_c2) == 1 assert all_c1 == all_c2 conn, = all_c1 # conn_state = conn.state in_node = conn.get_node(PortType.input) in_port = conn.get_port_index(PortType.input) out_node = conn.get_node(PortType.output) out_port = conn.get_port_index(PortType.output) assert in_node == node1 assert in_port == 1 assert out_node == node2 assert out_port == 2 scene.delete_connection(conn) assert len(scene.connections) == 0 all_c1 = node1.state.all_connections assert len(all_c1) == 0 all_c2 = node1.state.all_connections assert len(all_c2) == 0 def test_create_connection_with_converter(scene, view, model, other_model): node1 = scene.create_node(model) node2 = scene.create_node(other_model) # Converter not registerd, must raise Exception with pytest.raises(nodeeditor.ConnectionDataTypeFailure): scene.create_connection(node1[PortType.output][0], node2[PortType.input][0]) # Wrong converter, must fail converter = nodeeditor.type_converter.TypeConverter(MyOtherNodeData.data_type, MyNodeData.data_type, lambda x: None) scene.registry.register_type_converter(MyNodeData.data_type, MyOtherNodeData.data_type, converter) with pytest.raises(nodeeditor.ConnectionDataTypeFailure): scene.create_connection(node1[PortType.output][0], node2[PortType.input][0]) # Correct converter registered, must pass converter = nodeeditor.type_converter.TypeConverter(MyNodeData.data_type, MyOtherNodeData.data_type, lambda x: None) scene.registry.register_type_converter(MyNodeData.data_type, MyOtherNodeData.data_type, converter) scene.create_connection(node1[PortType.output][0], node2[PortType.input][0]) def test_clear_scene(scene, view, model): node1 = scene.create_node(model) node2 = scene.create_node(model) scene.create_connection(node2[PortType.output][2], node1[PortType.input][1], ) scene.clear_scene() assert len(scene.nodes) == 0 assert len(scene.connections) == 0 all_c1 = node1.state.all_connections assert len(all_c1) == 0 all_c2 = node1.state.all_connections assert len(all_c2) == 0 def test_get_and_set_state(scene, model): node1 = scene.create_node(model) node2 = scene.create_node(model) scene.create_connection(node2[PortType.output][2], node1[PortType.input][1], ) state = scene.__getstate__() scene.__setstate__(state) assert scene.__getstate__() == state def test_save_load(tmp_path, scene, view, model): node1 = scene.create_node(model) node2 = scene.create_node(model) created_nodes = (node1, node2) assert len(scene.nodes) == len(created_nodes) for node in created_nodes: assert node in scene.nodes.values() assert node.id in scene.nodes fname = tmp_path / 'temp.flow' scene.save(fname) scene.load(fname) assert len(scene.nodes) == len(created_nodes) for node in created_nodes: assert node not in scene.nodes.values() assert node.id in scene.nodes @pytest.mark.parametrize('reset, port_type', [(True, 'input'), (False, 'output')]) def test_smoke_reacting(scene, view, model, reset, port_type): node = scene.create_node(model) dtype = node.model.data_type[port_type][0] node.react_to_possible_connection( reacting_port_type=port_type, reacting_data_type=dtype, scene_point=qtpy.QtCore.QPointF(0, 0), ) view.update() if reset: node.reset_reaction_to_connection() def test_smoke_node_size_updated(scene, view, model): node = scene.create_node(model) node.on_node_size_updated() view.update() def test_connection_cycles(scene, view, model): node1 = scene.create_node(model) node2 = scene.create_node(model) node3 = scene.create_node(model) scene.create_connection(node1[PortType.output][0], node2[PortType.input][0]) scene.create_connection(node2[PortType.output][0], node3[PortType.input][0]) # node1 -> node2 -> node3 # Test with a fully-specified connection: try to connect node3->node1 with pytest.raises(nodeeditor.ConnectionCycleFailure): scene.create_connection(node3[PortType.output][0], node1[PortType.input][0]) # Test with a half-specified connection: start with node3 conn = scene.create_connection(node3[PortType.output][0]) # and then pretend the user attempts to connect it to node1: interaction = nodeeditor.NodeConnectionInteraction( node=node1, connection=conn, scene=scene) assert interaction.creates_cycle def test_smoke_connection_interaction(scene, view, model): node1 = scene.create_node(model) node2 = scene.create_node(model) conn = scene.create_connection(node1[PortType.output][0]) interaction = nodeeditor.NodeConnectionInteraction( node=node2, connection=conn, scene=scene) node_scene_transform = node2.graphics_object.sceneTransform() pos = node2.geometry.port_scene_position(PortType.input, 0, node_scene_transform) conn.geometry.set_end_point(PortType.input, pos) with pytest.raises(nodeeditor.ConnectionPointFailure): interaction.can_connect() conn.geometry.set_end_point(PortType.output, pos) with pytest.raises(nodeeditor.ConnectionPointFailure): interaction.can_connect() assert interaction.node_port_is_empty(PortType.input, 0) assert interaction.connection_required_port == PortType.input # TODO node still not on it? interaction.can_connect = lambda: (node1.state[PortType.input][0], None) assert interaction.try_connect() interaction.disconnect(PortType.output) interaction.connection_end_scene_position(PortType.input) interaction.node_port_scene_position(PortType.input, 0) interaction.node_port_under_scene_point(PortType.input, qtpy.QtCore.QPointF(0, 0)) def test_connection_interaction_wrong_data_type(scene, view, model, other_model): node1 = scene.create_node(model) node2 = scene.create_node(other_model) conn = scene.create_connection(node1[PortType.output][0]) interaction = nodeeditor.NodeConnectionInteraction( node=node2, connection=conn, scene=scene) node_scene_transform = node2.graphics_object.sceneTransform() pos = node2.geometry.port_scene_position(PortType.input, 0, node_scene_transform) conn.geometry.set_end_point(PortType.input, conn.graphics_object.mapFromScene(pos)) with pytest.raises(nodeeditor.ConnectionDataTypeFailure): interaction.can_connect() def test_locate_node(scene, view, model): node = scene.create_node(model) assert scene.locate_node_at(node.position, view.transform()) == node def test_view_scale(scene, view, model): node1 = scene.create_node(model) node2 = scene.create_node(model) scene.create_connection(node2[PortType.output][2], node1[PortType.input][1], ) view.scale_up() view.scale_down() def test_view_delete_selected(scene, view, model): node1 = scene.create_node(model) node2 = scene.create_node(model) conn = scene.create_connection(node2[PortType.output][2], node1[PortType.input][1]) node1.graphics_object.setSelected(True) conn.graphics_object.setSelected(True) node2.graphics_object.setSelected(True) view.delete_selected() assert node1 not in scene.nodes.values() assert node2 not in scene.nodes.values() assert conn not in scene.connections def test_smoke_view_context_menu(qtbot, view): view.generate_context_menu(qtpy.QtCore.QPoint(0, 0)) def test_smoke_repr(scene, view, model): node1 = scene.create_node(model) node2 = scene.create_node(model) print() print('node1', node1) print('node2', node2) ports = (node2[PortType.output][2], node1[PortType.input][1]) print() print('ports', ports) conn = scene.create_connection(*ports) print() print('connection', conn) def test_smoke_scene_signal_connections(scene, view, model): mock = unittest.mock.Mock() scene.connection_created.connect(mock) node1 = scene.create_node(model) node2 = scene.create_node(model) conn = scene.create_connection(node2[PortType.output][2], node1[PortType.input][1]) assert mock.call_count == 1 mock = unittest.mock.Mock() scene.connection_deleted.connect(mock) node1 = scene.create_node(model) node2 = scene.create_node(model) scene.delete_connection(conn) assert mock.call_count == 1 def test_smoke_scene_signal_nodes(scene, view, model): mock = unittest.mock.Mock() scene.node_created.connect(mock) node1 = scene.create_node(model) node2 = scene.create_node(model) assert mock.call_count == 2 mock = unittest.mock.Mock() scene.node_deleted.connect(mock) scene.remove_node(node1) scene.remove_node(node2) assert mock.call_count == 2 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/qtpynodeeditor/tests/test_examples.py0000644000076500000240000000536314725355310022746 0ustar00kenstaffimport pytest from qtpy import QtCore, QtGui from qtpynodeeditor import examples @pytest.fixture(scope='function', params=['style', 'calculator', 'connection_colors', 'image']) def example(qtbot, qapp, request): example_module = getattr(examples, request.param) scene, view, nodes = example_module.main(qapp) qtbot.addWidget(view) yield scene, view, nodes @pytest.fixture(scope='function') def scene(example): return example[0] @pytest.fixture(scope='function') def view(example): return example[1] @pytest.fixture(scope='function') def nodes(example): return example[2] def test_smoke_example(example): ... def test_iterate(scene): for node in scene.iterate_over_nodes(): print(node.size) node.position = node.position print('Node data iterator') print('------------------') for data in scene.iterate_over_node_data(): print(data, data.number if hasattr(data, 'number') else '') print('Node data dependent iterator') print('----------------------------') for data in scene.iterate_over_node_data_dependent_order(): print(data, data.number if hasattr(data, 'number') else '') def test_smoke_zero_inputs(scene, example): for node in scene.iterate_over_nodes(): widget = node.model.embedded_widget() if widget is not None: if hasattr(widget, 'setText'): widget.setText('0.0') class MySceneEvent(QtGui.QMouseEvent): last_pos = QtCore.QPoint(0, 0) scene_pos = QtCore.QPoint(0, 0) def lastPos(self): return self.last_pos def screenPos(self): return self.scene_pos def scenePos(self): return self.scene_pos def test_smoke_mouse(qtbot, nodes): for node in nodes: ngo = node.graphics_object # TODO qtbot doesn't work with QGraphicsObjects # qtbot.mouseClick(ngo) ev = MySceneEvent(QtCore.QEvent.MouseButtonPress, QtCore.QPointF(0, 0), QtCore.Qt.LeftButton, QtCore.Qt.LeftButton, QtCore.Qt.NoModifier) if node.model.num_ports['input']: pos = node.geometry.port_scene_position('input', 0) else: pos = node.geometry.port_scene_position('output', 0) ev.scene_pos = QtCore.QPoint(int(pos.x()), int(pos.y())) ev.last_pos = QtCore.QPoint(int(pos.x()), int(pos.y())) if node.model.resizable(): # Other case will try to propagate to mouseMoveEvent node.state.resizing = True ngo.mouseMoveEvent(ev) ngo.mousePressEvent(ev) ngo.hoverEnterEvent(ev) ngo.hoverMoveEvent(ev) ngo.hoverLeaveEvent(ev) def test_save_and_load(scene): scene.__setstate__(scene.__getstate__()) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/qtpynodeeditor/type_converter.py0000644000076500000240000000101214725355310021762 0ustar00kenstaffclass TypeConverterId: def __init__(self, type_in, type_out): self.type_in = type_in self.type_out = type_out class TypeConverter(TypeConverterId): def __init__(self, type_in, type_out, func): self.type_in = type_in self.type_out = type_out self.id = TypeConverterId(type_in, type_out) self.func = func def __call__(self, input): return self.func(input) def _convert(arg): return arg DefaultTypeConverter = TypeConverter(None, None, _convert) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1733679816.0 qtpynodeeditor-0.3.3/qtpynodeeditor/version.py0000644000076500000240000000355214725355310020412 0ustar00kenstafffrom collections import UserString from pathlib import Path from typing import Optional class VersionProxy(UserString): """ Version handling helper that pairs with setuptools-scm. This allows for pkg.__version__ to be dynamically retrieved on request by way of setuptools-scm. This deferred evaluation of the version until it is checked saves time on package import. This supports the following scenarios: 1. A git checkout (.git exists) 2. A git archive / a tarball release from GitHub that includes version information in .git_archival.txt. 3. An existing _version.py generated by setuptools_scm 4. A fallback in case none of the above match - resulting in a version of 0.0.unknown """ def __init__(self): self._version = None def _get_version(self) -> Optional[str]: # Checking for directory is faster than failing out of get_version repo_root = Path(__file__).resolve().parent.parent if (repo_root / ".git").exists() or (repo_root / ".git_archival.txt").exists(): try: # Git checkout from setuptools_scm import get_version return get_version(root="..", relative_to=__file__) except (ImportError, LookupError): ... # Check this second because it can exist in a git repo if we've # done a build at least once. try: from ._version import version # noqa: F401 return version except ImportError: ... return None @property def data(self) -> str: # This is accessed by UserString to allow us to lazily fill in the # information if self._version is None: self._version = self._get_version() or '0.0.unknown' return self._version __version__ = version = VersionProxy() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1734303703.6652756 qtpynodeeditor-0.3.3/qtpynodeeditor.egg-info/0000755000076500000240000000000014727657730020055 5ustar00kenstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734303703.0 qtpynodeeditor-0.3.3/qtpynodeeditor.egg-info/PKG-INFO0000644000076500000240000001116714727657727021166 0ustar00kenstaffMetadata-Version: 2.1 Name: qtpynodeeditor Version: 0.3.3 Summary: Python Qt node editor Author: Ken Lauer License: Copyright (c) 2019, Ken Lauer All rights reserved. qtpynodeeditor is a derivative work of NodeEditor by Dmitry Pinaev. It follows in the footsteps of the original and is licensed by the BSD 3-clause license. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of copyright holder, nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. Classifier: Development Status :: 2 - Pre-Alpha Classifier: Natural Language :: English Classifier: Programming Language :: Python :: 3 Requires-Python: >=3.9 Description-Content-Type: text/x-rst License-File: LICENSE License-File: AUTHORS.rst Requires-Dist: qtpy Provides-Extra: pyqt Requires-Dist: PyQt6; extra == "pyqt" Provides-Extra: pyqt5 Requires-Dist: PyQt5; extra == "pyqt5" Provides-Extra: pyqt6 Requires-Dist: PyQt6; extra == "pyqt6" Provides-Extra: pyside Requires-Dist: PySide6; extra == "pyside" Provides-Extra: pyside6 Requires-Dist: PySide6; extra == "pyside6" Provides-Extra: test Requires-Dist: pytest; extra == "test" Requires-Dist: pytest-qt; extra == "test" Requires-Dist: pytest-cov; extra == "test" Provides-Extra: doc Requires-Dist: sphinx; extra == "doc" Requires-Dist: ipython; extra == "doc" Requires-Dist: numpydoc; extra == "doc" Requires-Dist: sphinx-copybutton; extra == "doc" Requires-Dist: sphinx_rtd_theme; extra == "doc" Requires-Dist: sphinxcontrib-jquery; extra == "doc" .. image:: https://img.shields.io/travis/klauer/qtpynodeeditor.svg :target: https://travis-ci.org/klauer/qtpynodeeditor .. image:: https://img.shields.io/pypi/v/qtpynodeeditor.svg :target: https://pypi.python.org/pypi/qtpynodeeditor =============================== qtpynodeeditor =============================== Python Qt node editor Pure Python port of `NodeEditor `_, supporting PyQt5 and PySide through `qtpy `_. Requirements ------------ * Python 3.6+ * qtpy * PyQt5 / PySide Documentation ------------- `Sphinx-generated documentation `_ Screenshots ----------- `Style example `_ .. image:: https://raw.githubusercontent.com/klauer/qtpynodeeditor/assets/screenshots/style.png `Calculator example `_ .. image:: https://raw.githubusercontent.com/klauer/qtpynodeeditor/assets/screenshots/calculator.png Installation ------------ We recommend using conda to install qtpynodeeditor. :: $ conda create -n my_new_environment -c conda-forge python=3.7 qtpynodeeditor $ conda activate my_new_environment qtpynodeeditor may also be installed using pip from PyPI. :: $ python -m pip install qtpynodeeditor[pyqt5] $ python -m pip install qtpynodeeditor[pyqt6] $ python -m pip install qtpynodeeditor[pyside] Running the Tests ----------------- Tests must be run with pytest and pytest-qt. :: $ pip install .[pyqt5,test] $ pytest -v qtpynodeeditor/tests ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734303703.0 qtpynodeeditor-0.3.3/qtpynodeeditor.egg-info/SOURCES.txt0000644000076500000240000000331214727657727021746 0ustar00kenstaff.codecov.yml .coveragerc .flake8 .git_archival.txt .gitattributes .gitignore .pre-commit-config.yaml .pylintrc AUTHORS.rst CONTRIBUTING.rst LICENSE MANIFEST.in README.rst pyproject.toml .github/ISSUE_TEMPLATE.md .github/PULL_REQUEST_TEMPLATE.md .github/workflows/pcds.yml .github/workflows/python-pip-test.yml conda-recipe/meta.yaml docs/Makefile docs/make.bat docs/source/api.rst docs/source/conf.py docs/source/getting_started.rst docs/source/index.rst docs/source/release_notes.rst qtpynodeeditor/DefaultStyle.json qtpynodeeditor/__init__.py qtpynodeeditor/_version.py qtpynodeeditor/base.py qtpynodeeditor/connection.py qtpynodeeditor/connection_geometry.py qtpynodeeditor/connection_graphics_object.py qtpynodeeditor/connection_painter.py qtpynodeeditor/data_model_registry.py qtpynodeeditor/enums.py qtpynodeeditor/exceptions.py qtpynodeeditor/flow_scene.py qtpynodeeditor/flow_view.py qtpynodeeditor/node.py qtpynodeeditor/node_connection_interaction.py qtpynodeeditor/node_data.py qtpynodeeditor/node_geometry.py qtpynodeeditor/node_graphics_object.py qtpynodeeditor/node_painter.py qtpynodeeditor/node_state.py qtpynodeeditor/port.py qtpynodeeditor/style.py qtpynodeeditor/type_converter.py qtpynodeeditor/version.py qtpynodeeditor.egg-info/PKG-INFO qtpynodeeditor.egg-info/SOURCES.txt qtpynodeeditor.egg-info/dependency_links.txt qtpynodeeditor.egg-info/requires.txt qtpynodeeditor.egg-info/top_level.txt qtpynodeeditor/examples/__init__.py qtpynodeeditor/examples/calculator.py qtpynodeeditor/examples/connection_colors.py qtpynodeeditor/examples/image.py qtpynodeeditor/examples/style.py qtpynodeeditor/tests/__init__.py qtpynodeeditor/tests/conftest.py qtpynodeeditor/tests/test_basic.py qtpynodeeditor/tests/test_examples.py././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734303703.0 qtpynodeeditor-0.3.3/qtpynodeeditor.egg-info/dependency_links.txt0000644000076500000240000000000114727657727024131 0ustar00kenstaff ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734303703.0 qtpynodeeditor-0.3.3/qtpynodeeditor.egg-info/requires.txt0000644000076500000240000000032114727657727022457 0ustar00kenstaffqtpy [doc] sphinx ipython numpydoc sphinx-copybutton sphinx_rtd_theme sphinxcontrib-jquery [pyqt] PyQt6 [pyqt5] PyQt5 [pyqt6] PyQt6 [pyside] PySide6 [pyside6] PySide6 [test] pytest pytest-qt pytest-cov ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734303703.0 qtpynodeeditor-0.3.3/qtpynodeeditor.egg-info/top_level.txt0000644000076500000240000000001714727657727022613 0ustar00kenstaffqtpynodeeditor ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734303703.666226 qtpynodeeditor-0.3.3/setup.cfg0000644000076500000240000000004614727657730015132 0ustar00kenstaff[egg_info] tag_build = tag_date = 0