diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c9fb66e350..72a5e26b20 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,14 +1,18 @@ -# Set update schedule for GitHub Actions - version: 2 updates: - - package-ecosystem: "github-actions" directory: "/" schedule: # Check for updates to GitHub Actions every weekday interval: "daily" - - package-ecosystem: pip - directory: /doc + - package-ecosystem: "pip" + directory: "/doc" schedule: - interval: daily + interval: "daily" + - package-ecosystem: "pip" + directory: ".github/workflows/etc" + ignore: + - dependency-name: "numpy" + # NEP-29 governs supported versions of NumPy + schedule: + interval: "daily" diff --git a/.github/workflows/etc/requirements.txt b/.github/workflows/etc/requirements.txt new file mode 100644 index 0000000000..b48d2b6484 --- /dev/null +++ b/.github/workflows/etc/requirements.txt @@ -0,0 +1,22 @@ +# numpy based on python version and NEP-29 requirements +numpy; python_version == '3.10' +numpy; python_version == '3.11' +numpy~=1.22.0; python_version == '3.9' + +# image testing +scipy==1.11.2 + +# optional high performance paths +numba==0.57.1; python_version == '3.9' + +# optional 3D +pyopengl==3.1.7 + +# supplimental tools +matplotlib==3.7.2 +h5py==3.9.0 + +# testing +pytest==7.4.0 +pytest-xdist==3.3.1 +pytest-xvfb==3.0.0; sys_platform == 'linux' diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9c7f71c254..8c37e84893 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -23,29 +23,35 @@ jobs: matrix: os: [ubuntu-latest, windows-latest, macos-latest] qt-lib: [pyqt, pyside] - python-version: ["3.8", "3.9", "3.10"] - exclude: - - qt-lib: pyside - python-version: "3.8" + python-version: ["3.9", "3.10", "3.11"] include: - - python-version: "3.8" - qt-lib: "pyqt" - qt-version: "PyQt5~=5.12.0" - python-version: "3.9" qt-lib: "pyqt" qt-version: "PyQt5~=5.15.0" - python-version: "3.9" qt-lib: "pyside" qt-version: "PySide2~=5.15.0" + - python-version: "3.10" + qt-lib: "pyqt" + qt-version: "PyQt6~=6.2.0 PyQt6-Qt6~=6.2.0" + - python-version: "3.10" + qt-lib: "pyside" + qt-version: "PySide6~=6.2.0" - python-version: "3.10" qt-lib: "pyqt" qt-version: "PyQt6" - python-version: "3.10" qt-lib: "pyside" qt-version: "PySide6-Essentials" + - python-version: "3.11" + qt-lib: "pyqt" + qt-version: "PyQt6" + - python-version: "3.11" + qt-lib: "pyside" + qt-version: "PySide6-Essentials" steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: @@ -55,7 +61,8 @@ jobs: - name: Get pip cache dir id: pip-cache run: | - echo "::set-output name=dir::$(pip cache dir)" + echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT + shell: bash - name: pip cache uses: actions/cache@v3 with: @@ -82,7 +89,7 @@ jobs: shell: cmd - name: Install Dependencies run: | - python -m pip install -r .github/workflows/requirements.txt ${{ matrix.qt-version }} . + python -m pip install -r .github/workflows/etc/requirements.txt ${{ matrix.qt-version }} . - name: "Install Linux VirtualDisplay" if: runner.os == 'Linux' run: | @@ -98,7 +105,8 @@ jobs: libxcb-randr0 \ libxcb-render-util0 \ libxcb-xinerama0 \ - libopengl0 + libopengl0 \ + libxcb-cursor0 - name: 'Debug Info' run: | echo python location: `which python` @@ -154,14 +162,15 @@ jobs: environment-file: .github/workflows/etc/environment-pyside.yml steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - uses: conda-incubator/setup-miniconda@v2 with: miniforge-version: latest miniforge-variant: Mambaforge environment-file: ${{ matrix.environment-file }} auto-update-conda: false - python-version: "3.8" + python-version: "3.10" + use-mamba: true - name: "Install Test Framework" run: pip install pytest pytest-xdist - name: "Install Windows-Mesa OpenGL DLL" @@ -196,7 +205,8 @@ jobs: libxcb-randr0 \ libxcb-render-util0 \ libxcb-xinerama0 \ - libopengl0 + libopengl0 \ + libxcb-cursor0 pip install pytest-xvfb - name: 'Debug Info' run: | @@ -232,11 +242,11 @@ jobs: name: build wheel runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Setup Python 3.9 + - uses: actions/checkout@v4 + - name: Setup Python uses: actions/setup-python@v4 with: - python-version: '3.9' + python-version: '3.11' - name: Build Wheel run: | python -m pip install setuptools wheel @@ -247,7 +257,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: # We must fetch at least the immediate parents so that if this is # a pull request then we can checkout the head. @@ -255,7 +265,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v4 with: - python-version: '3.9' + python-version: '3.11' - name: Install Dependencies run: | python -m pip install PyQt5 numpy scipy diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index f564c120c6..312b2ec77c 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -5,7 +5,7 @@ name: Upload Python Package on: release: - types: [created] + types: [published] jobs: deploy: @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 with: diff --git a/.github/workflows/requirements.txt b/.github/workflows/requirements.txt deleted file mode 100644 index de7ad47a3e..0000000000 --- a/.github/workflows/requirements.txt +++ /dev/null @@ -1,22 +0,0 @@ -# numpy based on python version and NEP-29 requirements -numpy; python_version == '3.10' -numpy~=1.21.0; python_version == '3.9' -numpy~=1.20.0; python_version == '3.8' - -# image testing -scipy==1.8.1 - -# optional high performance paths -numba==0.55.2; python_version == '3.9' - -# optional 3D -pyopengl==3.1.6 - -# supplimental tools -matplotlib==3.5.2 -h5py==3.7.0 - -# testing -pytest==7.1.2 -pytest-xdist==2.5.0 -pytest-xvfb==2.0.0; sys_platform == 'linux' diff --git a/.gitignore b/.gitignore index e3bd193185..19b29428e2 100644 --- a/.gitignore +++ b/.gitignore @@ -77,6 +77,8 @@ coverage.xml *.am *.tiff *.tif +*.png +!doc/source/**/*.png *.dat *.DAT @@ -110,3 +112,5 @@ asv.conf.json # jupyter notebooks .ipynb_checkpoints + +.venv diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ab28aeb656..1253d644d2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.2.0 + rev: v4.4.0 hooks: - id: check-added-large-files args: ['--maxkb=100'] @@ -15,6 +15,6 @@ repos: - id: debug-statements - id: check-ast - repo: https://github.com/pycqa/isort - rev: 5.10.1 + rev: 5.12.0 hooks: - id: isort diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 4746100396..d5ba108e30 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -7,6 +7,8 @@ build: os: ubuntu-22.04 tools: python: "3" + apt_packages: + - libopengl0 sphinx: fail_on_warning: true diff --git a/CHANGELOG b/CHANGELOG index 356f5e6c6d..d323da6031 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,295 @@ +pyqtgraph-0.13.3 + +## What's Changed + +### Highlights + +* PySide6 6.5 Compatability + +### Bug Fixes + +* Return float values from QColor in getByIndex by @nickdimitroff in https://github.com/pyqtgraph/pyqtgraph/pull/2648 +* GLViewWidget: don't assume mouse tracking is disabled by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2653 +* Tolerate an empty BarGraphItem by @jmakovicka in https://github.com/pyqtgraph/pyqtgraph/pull/2658 +* Only apply nan mask workaround for cp version below 10.0. by @koenstrien in https://github.com/pyqtgraph/pyqtgraph/pull/2689 + +### Misc + +* unify ndarray_from_qpolygonf implementation by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2654 +* re-enable tests taking gui thread on PySide6 by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2657 +* inherit GraphicsWidgetAnchor on the left-hand-side by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2662 +* Prepare support for PySide6 drawLines and friends by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2596 +* Avoid changing background colors of text and rows for group parameter… by @ntjess in https://github.com/pyqtgraph/pyqtgraph/pull/2683 + +### Testing + +* Allow macOS to have fudge factor in test_polyROI by @j9ac9k in https://github.com/pyqtgraph/pyqtgraph/pull/2687 + +### Docs +* Update pydata-sphinx-theme and fix warnings by @j9ac9k in https://github.com/pyqtgraph/pyqtgraph/pull/2643 +* Bump sphinx-design from 0.3.0 to 0.4.1 in /doc by @dependabot in https://github.com/pyqtgraph/pyqtgraph/pull/2686 +* Bump sphinx from 5.3.0 to 6.1.3 in /doc by @dependabot in https://github.com/pyqtgraph/pyqtgraph/pull/2585 + +## New Contributors +* @nickdimitroff made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2648 +* @koenstrien made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2689 + +**Full Changelog**: https://github.com/pyqtgraph/pyqtgraph/compare/pyqtgraph-0.13.2...pyqtgraph-0.13.3 + +pyqtgraph-0.13.2 + +## What's Changed + +### Highlights + +* Fix InfiniteLine bounding rect calculation by @ixjlyons in https://github.com/pyqtgraph/pyqtgraph/pull/2407 +* Allow plotting multiple data items at once by @ntjess in https://github.com/pyqtgraph/pyqtgraph/pull/2461 +* Migrate to PyData Sphinx Theme - Restructure Docs by @j9ac9k in https://github.com/pyqtgraph/pyqtgraph/pull/2449 + +### API/Behavior Changes + +* re-enable hmac authentication for win32 by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2465 +* Add keyword argument in PColorMeshItem to enable consistent colormap scaling during animation by @SimenZhor in https://github.com/pyqtgraph/pyqtgraph/pull/2463 +* fix: use connect='finite' if finite-ness of data is unknown by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2471 +* Fix `ViewBox.autoRange()` for case of data with constant y-value by @bbc131 in https://github.com/pyqtgraph/pyqtgraph/pull/2489 +* Make `ActionGroup` compatible with existing Parameter conventions by @ntjess in https://github.com/pyqtgraph/pyqtgraph/pull/2505 +* Update `PenParameter` by @ntjess in https://github.com/pyqtgraph/pyqtgraph/pull/2536 +* remove resizeEvent on screen change by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2546 +* BarGraphItem: implement dataBounds and pixelPadding by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2565 +* Maintain argument propagation for GLGraphicsItems to super class by @koutoftimer in https://github.com/pyqtgraph/pyqtgraph/pull/2516 +* Accept custom ROI objects for ImageView by @ktahar in https://github.com/pyqtgraph/pyqtgraph/pull/2581 +* PColorMeshItem colorbar support by @SimenZhor in https://github.com/pyqtgraph/pyqtgraph/pull/2477 +* feat(PlotItem) define context menu action visibility by @jmkerloch in https://github.com/pyqtgraph/pyqtgraph/pull/2584 +* Allow plotting multiple data items at once by @ntjess in https://github.com/pyqtgraph/pyqtgraph/pull/2461 + +### Bug Fixes + +* Fix renderView to not use mremap on FreeBSD by @yurivict in https://github.com/pyqtgraph/pyqtgraph/pull/2445 +* Fix action parameter button that is briefly made visible before getting a parent by @ntjess in https://github.com/pyqtgraph/pyqtgraph/pull/2451 +* Fix InfiniteLine bounding rect calculation by @ixjlyons in https://github.com/pyqtgraph/pyqtgraph/pull/2407 +* Fix disconnect of signal proxy by @dgoeries in https://github.com/pyqtgraph/pyqtgraph/pull/2453 +* Fix ButtonItem hover event by @bbc131 in https://github.com/pyqtgraph/pyqtgraph/pull/2473 +* test and fix for ChecklistParameter.show bug by @outofculture in https://github.com/pyqtgraph/pyqtgraph/pull/2480 +* fix segmented line mode with no segments by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2481 +* Prevent flickering `ActionGroup` when switching parameter trees by @ntjess in https://github.com/pyqtgraph/pyqtgraph/pull/2484 +* fix: ndarray_from_qimage does not hold a reference to qimage by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2492 +* Fix exportDialog drawn off screen by @aksy2512 in https://github.com/pyqtgraph/pyqtgraph/pull/2510 +* partial fix for Qmenu leak by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2518 +* Fix last QMenu leak and its associated segfaults by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2522 +* fix setMaximumHeight(1e6) in SpinBox.py by @sem-geologist in https://github.com/pyqtgraph/pyqtgraph/pull/2519 +* Use base_prefix to detect virtual env by @eendebakpt in https://github.com/pyqtgraph/pyqtgraph/pull/2566 +* typo: dataRange -> dataBounds by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2589 +* NonUniformImage: implement floating point boundingRect by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2587 +* PColorMeshItem: implement dataBounds and pixelPadding by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2586 +* BarGraphItem: calculate boundingRect without drawing by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2599 +* Fix bounds handling when data is int16 or similar formats by @NilsNemitz in https://github.com/pyqtgraph/pyqtgraph/pull/2515 +* ImageView: make .nframes() to use .axis['t'] instead of .shape[0] by @sem-geologist in https://github.com/pyqtgraph/pyqtgraph/pull/2623 +* Fix GraphicsScene ValueError in mouseReleaseEvent by @j9ac9k in https://github.com/pyqtgraph/pyqtgraph/pull/2605 +* PlotCurveItem error with stepMode="center", autoRange and autoVisible by @djdt in https://github.com/pyqtgraph/pyqtgraph/pull/2595 + +### Examples + +* Fix #2482 argparse inputs were ignored by @ntjess in https://github.com/pyqtgraph/pyqtgraph/pull/2483 +* PlotSpeedTest: reflect initial use_opengl state by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2487 +* draw example histogram using BarGraphItem by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2556 + +### Tests + +* ROI: Add test with mouseDrag event and check snapping by @dgoeries in https://github.com/pyqtgraph/pyqtgraph/pull/2476 +* fix wrong logic for assert_alldead by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2503 +* fix: instantiate QApplication for test_Parameter.py by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2539 +* don't use pg.plot() in tests by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2625 + +### Docs + +* Migrate to PyData Sphinx Theme - Restructure Docs by @j9ac9k in https://github.com/pyqtgraph/pyqtgraph/pull/2449 +* Fix Qt crash course example by @Jaime02 in https://github.com/pyqtgraph/pyqtgraph/pull/2470 + +### Other + +* Remove remaining templates by @j9ac9k in https://github.com/pyqtgraph/pyqtgraph/pull/2448 +* Have canvas deprecation warning by @j9ac9k in https://github.com/pyqtgraph/pyqtgraph/pull/2446 +* unify win32 and unix mmap codepaths by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2457 +* Update setup.py, import distutils after setuptools by @LocutusOfBorg in https://github.com/pyqtgraph/pyqtgraph/pull/2459 +* Raise appropriate Exceptions in place of generic exceptions by @Nibba2018 in https://github.com/pyqtgraph/pyqtgraph/pull/2474 +* Remove old unused mains by @Jaime02 in https://github.com/pyqtgraph/pyqtgraph/pull/2490 +* make DockDrop be a non-mixin by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2450 +* Use non-deprecated QMouseEvent signatures by @j9ac9k in https://github.com/pyqtgraph/pyqtgraph/pull/2509 +* Remove STRTransform main by @Jaime02 in https://github.com/pyqtgraph/pyqtgraph/pull/2466 +* Minor improvements to `InteractiveFunction` ecosystem by @ntjess in https://github.com/pyqtgraph/pyqtgraph/pull/2521 +* Improve `ChecklistParameter.setValue` logic. by @ntjess in https://github.com/pyqtgraph/pyqtgraph/pull/2544 +* Remove antiquated Qt crash prevention by @NeilGirdhar in https://github.com/pyqtgraph/pyqtgraph/pull/2573 +* create internals.PrimitiveArray by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2591 +* rename "method" to "use_array" and make it keyword only by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2609 + +## New Contributors + +* @yurivict made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2445 +* @Jaime02 made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2468 +* @SimenZhor made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2463 +* @Nibba2018 made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2474 +* @rookiepeng made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2491 +* @aksy2512 made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2510 +* @noonchen made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2553 +* @ZeitgeberH made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2559 +* @NeilGirdhar made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2573 +* @koutoftimer made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2516 +* @ktahar made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2581 +* @bilaljo made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2577 +* @djdt made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2595 +* @jmkerloch made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2584 + +**Full Changelog**: https://github.com/pyqtgraph/pyqtgraph/compare/pyqtgraph-0.13.1...pyqtgraph-0.13.2 + +pyqtgraph-0.13.1 + +## What's Changed + +Bug Fixes + +* Refactor examples using `Interactor` to run on changing function parameters by @ntjess in https://github.com/pyqtgraph/pyqtgraph/pull/2437 + +API Change + +* deprecate GraphicsObject::parentChanged method by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2420 + +Other + +* Move Console to generic template and make font Courier New by @j9ac9k in https://github.com/pyqtgraph/pyqtgraph/pull/2435 + + + +pyqtgraph-0.13.0 + +## What's Changed + +Highlights + +* With PyQt6 6.3.2+ PyQtGraph uses sip.array, which leads to significantly faster draw performance by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2314 +* Introducing "interactive" parameter trees by @ntjess in https://github.com/pyqtgraph/pyqtgraph/pull/2318 +* Minimum Qt version now 5.15 for Qt5 and 6.2+ for Qt6 by @j9ac9k in https://github.com/pyqtgraph/pyqtgraph/pull/2403 +* with `enableExperimental` pyqtgraph accesses QPainterPathPrivate for faster QPainterPath generation by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2324 + +New Features + +* Interactive params fixup by @ntjess in https://github.com/pyqtgraph/pyqtgraph/pull/2318 +* Added possibility to use custom dock labels by @ardiloot in https://github.com/pyqtgraph/pyqtgraph/pull/2274 +* Introduce API option to control whether lines are drawn as segmented lines by @swvanbuuren in https://github.com/pyqtgraph/pyqtgraph/pull/2185 +* access QPainterPathPrivate for faster arrayToQPath by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2324 +* Update LabelItem to allow transparency in the text by @ElpadoCan in https://github.com/pyqtgraph/pyqtgraph/pull/2300 +* Make parameter tree read-only values selectable and copiable by @ardiloot in https://github.com/pyqtgraph/pyqtgraph/pull/2311 +* Have CSV exporter export error bar information by @j9ac9k in https://github.com/pyqtgraph/pyqtgraph/pull/2405 +* map pyqtgraph symbols to a matplotlib equivalent by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2395 + +Performance Improvements + +* Improve performance of PlotCurveItem with QOpenGLWidget by @bbc131 in https://github.com/pyqtgraph/pyqtgraph/pull/2264 +* ScatterPlotItem: use Format_ARGB32_Premultiplied by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2317 +* access QPainterPathPrivate for faster arrayToQPath by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2324 +* make use of PyQt sip.array by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2314 + +Bug Fixes + +* Fix GLImageItem regression by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2232 +* Fixed the app crash on right clicked by @Cosmicoppai in https://github.com/pyqtgraph/pyqtgraph/pull/2236 +* Fix Regression in in ViewBox.updateScaleBox Caused by #2034 by @campagnola in https://github.com/pyqtgraph/pyqtgraph/pull/2241 +* Fix UFuncTypeError when plotting integer data on windows by @campagnola in https://github.com/pyqtgraph/pyqtgraph/pull/2249 +* Fixed division by zero when no pixmap is loaded by @StSav012 in https://github.com/pyqtgraph/pyqtgraph/pull/2275 +* Ensure in PlotCurveItem lookup occurs in tuple, not str by @j9ac9k in https://github.com/pyqtgraph/pyqtgraph/pull/2294 +* Fixed a crash when `step` option is missing by @StSav012 in https://github.com/pyqtgraph/pyqtgraph/pull/2261 +* Invalidate cached properties on geometryChanged signal by @j9ac9k in https://github.com/pyqtgraph/pyqtgraph/pull/2357 +* Bugfix: Handle example search failure due to bad regex by @ntjess in https://github.com/pyqtgraph/pyqtgraph/pull/2121 +* Address #2303 unapplied pen parameter constructor options by @ntjess in https://github.com/pyqtgraph/pyqtgraph/pull/2305 +* Issue #2203 Potential Fix: Disabled FlowchartCtrlWidget.nodeRenamed o… by @HallowedDust5 in https://github.com/pyqtgraph/pyqtgraph/pull/2301 +* Fix #2289 unwanted growing in scene context menu by @ntjess in https://github.com/pyqtgraph/pyqtgraph/pull/2306 +* #2283 delete limitation by rectangle width ROI by @sasha-sem in https://github.com/pyqtgraph/pyqtgraph/pull/2285 +* Update exception handling to catch exceptions in threads (py3 change) by @campagnola in https://github.com/pyqtgraph/pyqtgraph/pull/2309 +* Fix PlotCurveItem errors when pen=None by @campagnola in https://github.com/pyqtgraph/pyqtgraph/pull/2315 +* ScatterPlotItem point masking fix by @ardiloot in https://github.com/pyqtgraph/pyqtgraph/pull/2310 +* Use property to lazily declare rectangle by @j9ac9k in https://github.com/pyqtgraph/pyqtgraph/pull/2356 +* Fix missing import in Flowchart.py by @Puff-Machine in https://github.com/pyqtgraph/pyqtgraph/pull/2421 +* Fix doubling labels in DateAxisItem by @bbc131 in https://github.com/pyqtgraph/pyqtgraph/pull/2413 +* GridItem: Fix pen for usage of dash-pattern by @bbc131 in https://github.com/pyqtgraph/pyqtgraph/pull/2304 +* Update PColorMeshItem.py by @LarsVoxen in https://github.com/pyqtgraph/pyqtgraph/pull/2327 +* Fix infinite loop within DateAxisItem by @bbc131 in https://github.com/pyqtgraph/pyqtgraph/pull/2365 +* Fix GraphicsScene.itemsNearEvent and setClickRadius by @bbc131 in https://github.com/pyqtgraph/pyqtgraph/pull/2383 +* Modify MatplotlibWidget to accept QWidget super constructor parameters. by @Dolphindalt in https://github.com/pyqtgraph/pyqtgraph/pull/2366 +* Fix flickering, when panning/scrolling in a fully zoomed-out view by @bbc131 in https://github.com/pyqtgraph/pyqtgraph/pull/2387 +* Make auto downsample factor calculation more robust by @StSav012 in https://github.com/pyqtgraph/pyqtgraph/pull/2253 +* Fix : QPoint() no longer accepts float arguments by @campagnola in https://github.com/pyqtgraph/pyqtgraph/pull/2260 +* avoid double __init__ of DockDrop by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2286 +* Add a few ImageView improvements by @outofculture in https://github.com/pyqtgraph/pyqtgraph/pull/1828 + +API/Behavior Changes + +* remove border QGraphicsRectItem from scene by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2225 +* Introduce API option to control whether lines are drawn as segmented lines by @swvanbuuren in https://github.com/pyqtgraph/pyqtgraph/pull/2185 +* Modify CSV exporter to output original data without log mapping by @NilsNemitz in https://github.com/pyqtgraph/pyqtgraph/pull/2297 +* Expose useCache ScatterPlotItem option from plot method by @ibrewster in https://github.com/pyqtgraph/pyqtgraph/pull/2258 +* remove legend items manually from scene by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2368 +* `getHistogramRange` for `HistogramLUTItem` by @kremeyer in https://github.com/pyqtgraph/pyqtgraph/pull/2397 +* Axis pen improvements by @ibrewster in https://github.com/pyqtgraph/pyqtgraph/pull/2398 +* remove MatplotlibWidget from pg namespace by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2400 +* change the libOrder to favor Qt6 by @Wubbzi in https://github.com/pyqtgraph/pyqtgraph/pull/2157 + +Examples + +* Added Jupyter console widget and Example by @jonmatthis in https://github.com/pyqtgraph/pyqtgraph/pull/2353 +* Add glow example by @edumur in https://github.com/pyqtgraph/pyqtgraph/pull/2242 +* update multiplePlotSpeedTest.py to use PlotCurveItem instead of QGraphicsPathItem by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2426 + +Docs + +* Add GLTextItem to docs by @jebguth in https://github.com/pyqtgraph/pyqtgraph/pull/2419 +* Add logo to docs by @ixjlyons in https://github.com/pyqtgraph/pyqtgraph/pull/2384 +* Enable nit-picky mode in documentation and fix associated warnings by @j9ac9k in https://github.com/pyqtgraph/pyqtgraph/pull/1753 +* Added UML class diagram to give overview of the most important classes by @titusjan in https://github.com/pyqtgraph/pyqtgraph/pull/1631 + +Other + +* Remove old Qt workarounds by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2224 +* Track when ScatterPlotItem clears the tooltip, only clear when needed by @ixjlyons in https://github.com/pyqtgraph/pyqtgraph/pull/2235 +* Avoid import error in HDF5 exporter by @campagnola in https://github.com/pyqtgraph/pyqtgraph/pull/2259 +* test enum using : "enums & enum" by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2250 +* add support for PySide6 6.3.0 QOverrideCursorGuard by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2263 +* Used the power of `blockIfUnchanged` decorator by @StSav012 in https://github.com/pyqtgraph/pyqtgraph/pull/2181 +* Handle/remove unused variables by @ksunden in https://github.com/pyqtgraph/pyqtgraph/pull/2094 +* BusyCursor and QPainter fixes for PyPy by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2349 +* Add a pyi stub file to import best-guess pyqt type hints by @outofculture in https://github.com/pyqtgraph/pyqtgraph/pull/2358 +* test_PlotCurveItem: unset skipFiniteCheck for next test by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2313 +* lazy create the rectangle selection item by @danielhrisca in https://github.com/pyqtgraph/pyqtgraph/pull/2168 +* fix: super().__init__ does not need self by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2359 +* Promote interactive `Run` action to group level by @ntjess in https://github.com/pyqtgraph/pyqtgraph/pull/2414 +* Enhance testing for creating parameters from saved states by @ntjess in https://github.com/pyqtgraph/pyqtgraph/pull/2319 +* add support for PySide6's usage of Python Enums by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2329 +* remove QFileDialog PyQt4 compatibility code by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2394 +* Bugfix: `interact` on decorated method that uses `self`. by @ntjess in https://github.com/pyqtgraph/pyqtgraph/pull/2408 +* use single generic template for all bindings by @pijyoi in https://github.com/pyqtgraph/pyqtgraph/pull/2226 +* `interact()` defaults to `ON_ACTION` behavior and accepts `runActionTemplate` argument by @ntjess in https://github.com/pyqtgraph/pyqtgraph/pull/2432 + +## New Contributors +* @andriyor made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2212 +* @keziah55 made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2191 +* @Cosmicoppai made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2236 +* @bbc131 made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2264 +* @StSav012 made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2181 +* @ardiloot made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2274 +* @sasha-sem made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2285 +* @swvanbuuren made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2185 +* @Anatoly1010 made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2330 +* @LarsVoxen made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2327 +* @HallowedDust5 made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2301 +* @ElpadoCan made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2300 +* @dependabot made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2342 +* @jaj42 made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2389 +* @Dolphindalt made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2366 +* @kremeyer made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2397 +* @jonmatthis made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2353 +* @jebguth made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2419 +* @Puff-Machine made their first contribution in https://github.com/pyqtgraph/pyqtgraph/pull/2421 + +**Full Changelog**: https://github.com/pyqtgraph/pyqtgraph/compare/pyqtgraph-0.12.4...pyqtgraph-0.13.0 + pyqtgraph-0.12.4 Highlights: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8a117f9d9d..9595808c70 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,6 +43,10 @@ PyQtGraph has adopted [NEP-29](https://numpy.org/neps/nep-0029-deprecation_polic * Documentation is generated with sphinx, and usage of [numpy-docstyle](https://numpydoc.readthedocs.io/en/latest/format.html) is encouraged (many places in the library do not use this docstring style at present, it's a gradual process to migrate). * The docs built for this PR can be previewed by clicking on the "Details" link for the read-the-docs entry in the checks section of the PR conversation page. +## Templates + +PyQtGraph makes use of `.ui` files where are compiled using `uic`. These files are identified by ending with `_generic.py` and have a `.ui` file next to them in the same directory. In past versions of PyQtGraph, there was a file for each binding. These are generated using tools/rebuildUi.py. Upon completion, the compiled files need to be modified such that they do not inherit from PyQt6 but from pyqtgraph's Qt abstraction layer. + ## Style guidelines ### Formatting ~~Rules~~ Suggestions @@ -55,6 +59,7 @@ PyQtGraph has adopted [NEP-29](https://numpy.org/neps/nep-0029-deprecation_polic PyQtGraph developers are highly encouraged to (but not required) to use [`pre-commit`](https://pre-commit.com/). `pre-commit` does a number of checks when attempting to commit the code to being committed, such as ensuring no large files are accidentally added, address mixed-line-endings types and so on. Check the [pre-commit documentation](https://pre-commit.com) on how to setup. + ## Testing ### Basic Setup diff --git a/README.md b/README.md index 69bc6fc947..d058bcd030 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,10 @@ PyQtGraph [![Build Status](https://github.com/pyqtgraph/pyqtgraph/workflows/main/badge.svg)](https://github.com/pyqtgraph/pyqtgraph/actions/?query=workflow%3Amain) [![CodeQL Status](https://github.com/pyqtgraph/pyqtgraph/workflows/codeql/badge.svg)](https://github.com/pyqtgraph/pyqtgraph/actions/?query=workflow%3Acodeql) [![Documentation Status](https://readthedocs.org/projects/pyqtgraph/badge/?version=latest)](https://pyqtgraph.readthedocs.io/en/latest/?badge=latest) -[![Total alerts](https://img.shields.io/lgtm/alerts/g/pyqtgraph/pyqtgraph.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/pyqtgraph/pyqtgraph/alerts/) -[![Language grade: Python](https://img.shields.io/lgtm/grade/python/g/pyqtgraph/pyqtgraph.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/pyqtgraph/pyqtgraph/context:python) -[![Discord](https://img.shields.io/discord/946624673200893953.svg?label=PyQtGraph&logo=discord)](https://discord.gg/3Qxjz5BF) +[![Discord](https://img.shields.io/discord/946624673200893953.svg?label=PyQtGraph&logo=discord)](https://discord.gg/ufTVNNreAZ) A pure-Python graphics library for PyQt5/PyQt6/PySide2/PySide6 -Copyright 2020 Luke Campagnola, University of North Carolina at Chapel Hill +Copyright 2023 PyQtGraph developers @@ -29,17 +27,17 @@ This project supports: * All minor versions of Python released 42 months prior to the project, and at minimum the two latest minor versions. * All minor versions of numpy released in the 24 months prior to the project, and at minimum the last three minor versions. -* All Qt5 versions from 5.12-5.15, and Qt6 6.1+ +* Qt5 5.15, and Qt6 6.2+ Currently this means: -* Python 3.8+ -* Qt 5.12-5.15, 6.1+ +* Python 3.9+ +* Qt 5.15, 6.2+ * [PyQt5](https://www.riverbankcomputing.com/software/pyqt/), [PyQt6](https://www.riverbankcomputing.com/software/pyqt/), [PySide2](https://wiki.qt.io/Qt_for_Python), or [PySide6](https://wiki.qt.io/Qt_for_Python) -* [`numpy`](https://github.com/numpy/numpy) 1.20+ +* [`numpy`](https://github.com/numpy/numpy) 1.22+ ### Optional added functionalities @@ -54,7 +52,7 @@ Through 3rd part libraries, additional functionality may be added to PyQtGraph, | [`matplotlib`] | | | [`cupy`] | | | [`numba`] | | -| [`jupyter_rfb`]| | +| [`jupyter_rfb`]| | [`scipy`]: https://github.com/scipy/scipy [`ndimage`]: https://docs.scipy.org/doc/scipy/reference/ndimage.html @@ -67,30 +65,11 @@ Through 3rd part libraries, additional functionality may be added to PyQtGraph, [`cupy`]: https://docs.cupy.dev/en/stable/install.html [`jupyter_rfb`]: https://github.com/vispy/jupyter_rfb -Qt Bindings Test Matrix ------------------------ - -The following table represents the python environments we test in our CI system. Our CI system uses Ubuntu 20.04, Windows Server 2019, and macOS 10.15 base images. - -| Qt-Bindings |Python 3.8 | Python 3.9 | Python 3.10 | -| :------------- |:---------------------: | :---------------------: | :---------------------: | -| PySide2-5.12 |:eight_spoked_asterisk: | :eight_spoked_asterisk: | :eight_spoked_asterisk: | -| PyQt5-5.12 |:white_check_mark: | :x: | :x: | -| PySide2-5.15 |:white_check_mark: | :white_check_mark: | | -| PyQt5-5.15 |:white_check_mark: | :white_check_mark: | | -| PySide6-6.3 | | | :white_check_mark: | -| PyQt6-6.3 | | | :white_check_mark: | - -* :x: - Not compatible -* :white_check_mark: - Tested -* :eight_spoked_asterisk: - only available with `conda-forge` package -* No icon means supported configuration but we do not explicitely test it - Support ------- * Report issues on the [GitHub issue tracker](https://github.com/pyqtgraph/pyqtgraph/issues) -* Post questions to +* Post questions to * [mailing list / forum](https://groups.google.com/forum/?fromgroups#!forum/pyqtgraph) * [StackOverflow](https://stackoverflow.com/questions/tagged/pyqtgraph) * [GitHub Discussions](https://github.com/pyqtgraph/pyqtgraph/discussions) @@ -122,20 +101,25 @@ Used By Here is a partial listing of some of the applications that make use of PyQtGraph! * [ACQ4](https://github.com/acq4/acq4) -* [Orange3](https://orangedatamining.com/) -* [neurotic](https://neurotic.readthedocs.io) -* [ephyviewer](https://ephyviewer.readthedocs.io) -* [Joulescope](https://www.joulescope.com/) -* [rapidtide](https://rapidtide.readthedocs.io/en/latest/) +* [Antenna Array Analysis](https://github.com/rookiepeng/antenna-array-analysis) * [argos](https://github.com/titusjan/argos) -* [PySpectra](http://hasyweb.desy.de/services/computing/Spock/node138.html) -* [Semi-Supervised Semantic Annotator](https://gitlab.com/ficsresearch/s3ah) -* [PyMeasure](https://github.com/pymeasure/pymeasure) -* [Exo-Striker](https://github.com/3fon3fonov/exostriker) -* [HussariX](https://github.com/sem-geologist/HussariX) +* [Atomize](https://github.com/Anatoly1010/Atomize) * [EnMAP-Box](https://enmap-box.readthedocs.io) * [EO Time Series Viewer](https://eo-time-series-viewer.readthedocs.io) -* [Atomize](https://github.com/Anatoly1010/Atomize) +* [ephyviewer](https://ephyviewer.readthedocs.io) +* [Exo-Striker](https://github.com/3fon3fonov/exostriker) * [GraPhysio](https://github.com/jaj42/GraPhysio) +* [HussariX](https://github.com/sem-geologist/HussariX) +* [Joulescope](https://www.joulescope.com/) +* [MaD GUI](https://github.com/mad-lab-fau/mad-gui) +* [neurotic](https://neurotic.readthedocs.io) +* [Orange3](https://orangedatamining.com/) +* [PatchView](https://github.com/ZeitgeberH/patchview) +* [pyplotter](https://github.com/pyplotter/pyplotter) +* [PyMeasure](https://github.com/pymeasure/pymeasure) +* [PySpectra](http://hasyweb.desy.de/services/computing/Spock/node138.html) +* [rapidtide](https://rapidtide.readthedocs.io/en/latest/) +* [Semi-Supervised Semantic Annotator](https://gitlab.com/s3a/s3a) +* [STDF-Viewer](https://github.com/noonchen/STDF-Viewer) Do you use PyQtGraph in your own project, and want to add it to the list? Submit a pull request to update this listing! diff --git a/doc/Makefile b/doc/Makefile index 15b77d380e..e4eb7f8b56 100644 --- a/doc/Makefile +++ b/doc/Makefile @@ -2,17 +2,18 @@ # # You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build +SPHINXOPTS ?= -n +SPHINXBUILD ?= sphinx-build PAPER = BUILDDIR = build +SOURCEDIR = source # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) $(SOURCEDIR) -.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest livehtml dash help: @echo "Please use \`make ' where is one of" @@ -32,15 +33,35 @@ help: @echo " changes to make an overview of all changed/added/deprecated items" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" + @echo " livehtml to create livehtml of docs that watches for changes" clean: -rm -rf $(BUILDDIR)/* +livehtml: + sphinx-autobuild -a "$(SOURCEDIR)" "$(BUILDDIR)/html" --watch doc/source/_static --open-browser + html: + # this will not detect changes in theme files, static files and + # source code used with auto-doc $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dash: clean + # this will create a docset of the documentation, used in documentation + # viewers such as dash or zeal, requires doc2dash to be installed + export BUILD_DASH_DOCSET=1;\ + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/dash + doc2dash -n pyqtgraph\ + --online-redirect-url https://pyqtgraph.readthedocs.io/en/latest\ + --icon $(SOURCEDIR)/_static/peegee_03_square_no_bg_32_cleaned.png\ + --force\ + -A\ + $(BUILDDIR)/dash + @echo + dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo diff --git a/doc/listmissing.py b/doc/listmissing.py index 6268d81ee8..4acb1f52dd 100644 --- a/doc/listmissing.py +++ b/doc/listmissing.py @@ -7,8 +7,8 @@ path = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..') for a, b in dirs: - rst = [os.path.splitext(x)[0].lower() for x in os.listdir(os.path.join(path, 'documentation', 'source', a))] - py = [os.path.splitext(x)[0].lower() for x in os.listdir(os.path.join(path, b))] + rst = [os.path.splitext(x)[0].lower() for x in os.listdir(os.path.join(path, 'doc', 'source', a))] + py = [os.path.splitext(x)[0].lower() for x in os.listdir(os.path.join(path, "pyqtgraph", b))] print(a) for x in set(py) - set(rst): print( " ", x) diff --git a/doc/requirements.txt b/doc/requirements.txt index 3571388ed0..a960cf9f6f 100644 --- a/doc/requirements.txt +++ b/doc/requirements.txt @@ -1,5 +1,11 @@ -sphinx==5.1.1 -PyQt6==6.3.1 -sphinx_rtd_theme +PyQt6==6.5.2 +sphinx==7.2.5 +pydata-sphinx-theme==0.13.3 +sphinx-design==0.5.0 +sphinxcontrib-images==0.9.4 +sphinx-favicon==1.0.1 +sphinx-autodoc-typehints +sphinx-qt-documentation +sphinxext-rediraffe numpy pyopengl diff --git a/doc/source/3dgraphics/glviewwidget.rst b/doc/source/3dgraphics/glviewwidget.rst deleted file mode 100644 index 6f89213ec6..0000000000 --- a/doc/source/3dgraphics/glviewwidget.rst +++ /dev/null @@ -1,7 +0,0 @@ -GLViewWidget -============ - -.. autoclass:: pyqtgraph.opengl.GLViewWidget - :members: - - .. automethod:: pyqtgraph.opengl.GLViewWidget.__init__ diff --git a/doc/source/_static/custom.css b/doc/source/_static/custom.css index 6046173833..4a7219c7ae 100644 --- a/doc/source/_static/custom.css +++ b/doc/source/_static/custom.css @@ -1,18 +1,162 @@ -/* Customizations to the theme */ +/* Template Inherited from SciPy and modified for PyQtGraph purposes + https://github.com/scipy/scipy/blob/9ae8fd0f4341d7d8785777d460cca4f7b6a93edd/doc/source/_static/scipy.css + SPDX-License-Identifier: BSD-3-Clause */ -/* override table width restrictions */ -/* https://github.com/readthedocs/sphinx_rtd_theme/issues/117 */ -@media screen and (min-width: 768px) { - .wy-table-responsive table td, .wy-table-responsive table th { - white-space: normal !important; - } +/* Remove parenthesis around module using fictive font and add them back. + This is needed for better wrapping in the sidebar. */ +.bd-sidebar .nav li > a > code { + white-space: nowrap; +} + +.bd-sidebar .nav li > a > code:before { + content:'('; +} + +.bd-sidebar .nav li > a > code:after { + content:')'; +} + +.bd-sidebar .nav li > a { + font-family: "no-parens", sans-serif; +} + +/* Retrieved from https://codepen.io/jonneal/pen/bXLEdB (MIT) + It replaces (, ) with a zero-width font. This version is lighter than + the original font from Adobe. +*/ +@font-face { + font-family: no-parens; + src: url("data:application/x-font-woff;base64,d09GRk9UVE8AABuoAAoAAAAASrAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABDRkYgAAANJAAADlwAABk8NN4INERTSUcAABugAAAACAAAAAgAAAABT1MvMgAAAVAAAABRAAAAYABfsZtjbWFwAAAEQAAACM0AABnoJENu0WhlYWQAAAD0AAAAMwAAADYFl9tDaGhlYQAAASgAAAAeAAAAJAdaA+9obXR4AAAbgAAAAB8AABAGA+gAfG1heHAAAAFIAAAABgAAAAYIAVAAbmFtZQAAAaQAAAKbAAAF6yBNB5Jwb3N0AAANEAAAABMAAAAg/7gAMnjaY2BkYGBg5G6tPXx8azy/zVcGZuYXQBGGiz6un+F0zf8O5hzmAiCXmYEJJAoAkoQNcAB42mNgZGBgLvjfASRfMNQw1DDnMABFUAATAHAaBFEAAAAAUAAIAQAAeNpjYGZ+wTiBgZWBgamLKYKBgcEbQjPGMRgx3GFAAt//r/v/+/7///wPGOxBfEcXJ38GBwaG//+ZC/53MDAwFzBUJOgz/kfSosDAAAAMpBWaAAAAeNqdU9tu00AQPU6TcqmoRIV46YvFE5Vgm7ZOVDVPSS8iIkqquBTxhJzEuSiOHWwnwH8g/oHfgW9A/AZnx5smQZWg2MrumZ0z47MzEwCP8R0W9GNhS1b95HCPVoY3sIsdg/MrnAJO8NLgTTzEgEwr/4DWF3ww2MJTq2BwDtvWrsEbKFt7BudXOAWk1nuDN/HE+mHwfTjWL4O34OQWeR7lvuZaBm/Dyf+s9qKOb9cCLxy3/cEs8OIDVXRKlepZrVURp/hot2rn136cjKLQziiXrgHDKO1G4Vxb6viwMvHGfpT2VTDqHKqSKh85xfIyE04RYYrPiDFiCYZIYeMbf4co4gBHeHGDS0RV9MjvwCd2GZWQ72PC3UYdIbr0xsynV098PXqeS96U5yfY5/tRXkXGIpuSyAl9e8SrX6khIC/EGG3aA8zEjqlHUZVDVRXyz8hrCVpELuMyf4sn57imJ6baEVkhs69mueSN1k+GZKWiLMT8xqdwzIpUqNZjdl84fZ4GzNqhRzFWoczaOWSXb9X0P3X89xqmzDjlyT6uGDWSrBdyi1S+F1FvymhdR60gY2j9XdohraxvM+KeVMwmf2jU1tHg3pIvhGuZG2sZ9OTcVm/9s++krCd7KjPaoarFXGU5PVmfsaauVM8l1nNTFa2u6HhLdIVXVP2Gu7arnKc21ybtOifDlTu1uZ5yb3Ji6uLROPNdyPw38Y77a3o0R+f2qSqrTizWJ1ZGq09EeySnI/ZlKhXWypXc1Zcb3r2uNmsUrfUkkZguWX1h2mbO9L/F45r1YioKJ1LLRUcSU7+e6f9E7qInbukfEM0lNuSpzmpzviLmjmVGMk26c5miv3VV/THJCRXrzk55ltCrtQXc9R0H9OvKN34D31P2fwB42i3YLfAsS2GG8X9Pf3dP97QjqOBAUAUOHDhwxAUHLnHgwIEDBw4cOHDgEgeOuIsjLnHgAMU1tw7PnvNs1fT7zlfV7q9rd2bn7e0tv729RZYvsySWb76Ft9fr82wN77fHt/F+e3m73+8J74/8zPsxvdbqu3fvXjsYg2e/P/LTP33f367PfMj67sPZjXjsh/iU/V+If7W/Tvms/XPEF+xfJL5kf73lr9i/SnzN/nXiG/Z/I/7d/k3iW/ZvE/9h/0/iO/bvEt+zf5/4gf2HxI/sPyZ+Yn99xJ/Zf078wv5L4lf2XxO/sf+W+C/7fxO/s/+e+IP9f4iP7H8k/mT/f+LP9r8Qf7X/jfiH/WPik48+9E/Y8e4Tpvjv72cl6B/wD/oH/IP+Af+gf8A/6B/wD/oH/IP+Af+gf8A/6B/wD/oH/IP+Af+gf8A/6B/wD/oH/IP+Af+gf8A/6B/wD/oH/IP+Af+gf8A/6B/wD/oH/IP+Af+gf8A/6B/wD/oH/IP+4X8Z/8/OXATnIjAXwbkIkAfnIjAX4eVPv15fA/0v/C/9L/wv/S/8L/1fX5lL/wv/S/8L/0v/C/9L/wv/S/8L/0v/C/9L/wv/S/8L/0v/C/9L/wv/S/8L/0v/C/9L/wv/S/8L/0v/C/9L/wv/S/8L/0v/C/9L/wv/S/8L/0v/C/9L/wv/S/8L/0v/C/9L/wv/S/8L/0v/C/9L/9cvXNQ/4h/1j/hH/SP+Uf+If9Q/4h/1j/hH/SP+Uf+If9Q/4h/1j/hH/SP+Uf+If9Q/4h/1j/hH/SP+Uf+If9Q/4h/1j/hH/SP+Uf+If9Q/4h/1j/hH/SP+Uf+If9Q/4h/1j/hH/SP+Uf+If9Q/4h/1j/hH/SP+Uf/XlSXpn/BP+if8k/4J/6R/wj/pn/BP+if8k/4J/6R/wj/pn/BP+if8k/4J/6R/wj/pn/BP+if8k/4J/6R/wj/pn/BP+if8k/4J/6R/wj/pn/BP+if8k/4J/6R/wj/pn/BP+if8k/4J/6R/wj/pn/BP+if8k/4J/6T/6yqf9c/4Z/0z/ln/jH/WP+Of9c/4Z/0z/ln/jH/WP+Of9c/4Z/0z/ln/jH/WP+Of9c/4Z/0z/ln/jH/WP+Of9c/4Z/0z/ln/jH/WP+Of9c/4Z/0z/ln/jH/WP+Of9c/4Z/0z/ln/jH/WP+Of9c/4Z/0z/ln/jH/WvzAW/Qv+Rf+Cf9G/4F/0L/gX/Qv+Rf+Cf9G/4F/0L/gX/Qv+Rf+Cf9G/4F/0L/gX/Qv+Rf+Cf9G/4F/0L/gX/Qv+Rf+Cf9G/4F/0L/gX/Qv+Rf+Cf9G/4F/0L/gX/Qv+Rf+Cf9G/4F/0L/gX/Qv+Rf+Cf9G/4F/0r6/bT/0r/lX/in/Vv+Jf9a/4V/0r/lX/in/Vv+Jf9a/4V/0r/lX/in/Vv+Jf9a/4V/0r/lX/in/Vv+Jf9a/4V/0r/lX/in/Vv+Jf9a/4V/0r/lX/in/Vv+Jf9a/4V/0r/lX/in/Vv+Jf9a/4V/0r/lX/in/Vv378uuX/4P+65W/6N1aa/g3/pn/Dv+nf8G/6N/yb/g3/pn/Dv+nf8G/6N/yb/g3/pn/Dv+nf8G/6N/yb/g3/pn/Dv+nf8G/6N/yb/g3/pn/Dv+nf8G/6N/yb/g3/pn/Dv+nf8G/6N/yb/g3/pn/Dv+nf8G/6N/yb/g3/pn/Dv+nfGbv+Hf+uf8e/69/x7/p3/Lv+Hf+uf8e/69/x7/p3/Lv+Hf+uf8e/69/x7/p3/Lv+Hf+uf8e/69/x7/p3/Lv+Hf+uf8e/69/x7/p3/Lv+Hf+uf8e/69/x7/p3/Lv+Hf+uf8e/69/x7/p3/Lv+Hf+uf8e/69/x7/q//kEP/Qf+Q/+B/9B/4D/0H/gP/Qf+Q/+B/9B/4D/0H/gP/Qf+Q/+B/9B/4D/0H/gP/Qf+Q/+B/9B/4D/0H/gP/Qf+Q/+B/9B/4D/0H/gP/Qf+Q/+B/9B/4D/0H/gP/Qf+Q/+B/9B/4D/0H/gP/Qf+Q/+B/9B/4D/0n4xT/4n/1H/iP/Wf+E/9J/5T/4n/1H/iP/Wf+E/9J/5T/4n/1H/iP/Wf+E/9J/5T/4n/1H/iP/Wf+E/9J/5T/4n/1H/iP/Wf+E/9J/5T/4n/1H/iP/Wf+E/9J/5T/4n/1H/iP/Wf+E/9J/5T/4n/1H/iP/Wf+E/9X8+Dbv1v/G/9b/xv/W/8b/1v/G/9b/xv/W/8b/1v/G/9b/xv/W/8b/1v/G/9b/xv/W/8b/1v/G/9b/xv/W/8b/1v/G/9b/xv/W/8b/1v/G/9b/xv/W/8b/1v/G/9b/xv/W/8b/1v/G/9b/xv/W/8b/1v/G/9b/xv/W/8b/1v/G/9F+PSf+G/9F/4L/0X/kv/hf/Sf+G/9F/4L/0X/kv/hf/Sf+G/9F/4L/0X/kv/hf/Sf+G/9F/4L/0X/kv/hf/Sf+G/9F/4L/0X/kv/hf/Sf+G/9F/4L/0X/kv/hf/Sf+G/9F/4L/0X/kv/hf/Sf+G/9F/4L/0X/kv/zbj13/hv/Tf+W/+N/9Z/47/13/hv/Tf+W/+N/9Z/47/13/hv/Tf+W/+N/9Z/47/13/hv/Tf+W/+N/9Z/47/13/hv/Tf+W/+N/9Z/47/13/hv/Tf+W/+N/9Z/47/13/hv/Tf+W/+N/9Z/47/13/hv/Tf+W/+N/9b/eT1y1v/B/9H/wf/R/8H/0f/B/9H/wf/R/8H/0f/B/9H/wf/R/8H/0f/B/9H/wf/R/8H/0f/B/9H/wf/R/8H/0f/B/9H/wf/R/8H/0f/B/9H/wf/R/8H/0f/B/9H/wf/R/8H/0f/B/9H/wf/R/8H/0f/B/9H/wf/R/8H/0f/5+PWY/4P/6zH/0f/gf/Q/7Dj6H/yP/gf/o//B/+h/8D/6H/yP/gf/o//B/+h/8D/6H/yP/gf/o//B/+h/8D/6H/yP/gf/o//B/+h/8D/6H/yP/gf/o//B/+h/8D/6H/yP/gf/o//B/+h/8D/6H/yP/gf/o//B/+h/8D/6H/zPB/9/AsqUaXgAAAB42mNgZgCD/1sZjBiwAAAswgHqAHja7ZhVc5BNkIWn/QWCEzRAcHd3d3eX4J4Awd0luLu7e3B3d3d3h4RgC99e7I9YnoupOjXdXaempqamGxyjA4AoxVoENmtZvENAp/Z/ZdbwROF+IT5JwhNDeBIM+e4T4SJYkiTkJj5J/TzwSR5WK3pYs5hh9X1S+SVI6pPSCYBGqx0Q9F+Zci1adgpuG9yrRGBQry5tW7cJ9s+eNVuOjH/XXP7/RfjX6NU1uGXHrv7lOjUP7BIU2CUguGUL/7RtgoOD8mfJ0qNHj8wBf8MyNw/smCVd5v9N+c/c/9nMlD1rznzO/XFvv8mBc84DD/5IV8FVdJVcZVfFVXXVXHVXw9V0tVxtV8fVdfVcfdfANXSNXGPXxDV1Aa6Za+5auJaulWvt2ri2rp1r7zq4jq6TC3RBrrPr4rq6YNfNdXc9XE/Xy/V2fVxf18/1dwPcQDfIDXZD3FA3zA13I9xIN8qNdiFujBvrxrnxboKb6Ca5yW6Km+qmueluhpvpZrnZbo6b6+a5+W6BW+gWucVuiVvqlrnlboVb6Va51W6NW+vWufVug9voNrnNbovb6ra5ULfd7XA73S632+1xe90+t98dcAfdIXfYHXFH3TF33J1wJ90pd9qdcWfdOXfeXXAX3SV32V1xV901d93dcDfdLXfb3XF33T133z1wD90j99g9cU/dM/fcvXAv3Sv32r1xb9079959cB/dJ/fZfXFfXZgLd99chPvufrif7pf7DX+vCgIBg4CC/Tn/SBAZooAPRIVoEB1iQEyIBbEhDvhCXIgH8SEBJIRE4AeJIQkkBX9IBskhBaSEVJAa0kBaSAfpIQNkhEyQGbJAVsgG2SEH5IRckBvyQF7IB/mhABSEQlAYikBRKAbFoQSUhFJQGspAWSgH5aECVIRKUBmqQFWoBtWhBtSEWlAb6kBdqAf1oQE0hEbQGJpAUwiAZtAcWkBLaAWtoQ20hXbQHjpAR+gEgRAEnaELdIVg6AbdoQf0hF7QG/pAX+gH/WEADIRBMBiGwFAYBsNhBIyEUTAaQmAMjIVxMB4mwESYBJNhCkyFaTAdZsBMmAWzYQ7MhXkwHxbAQlgEi2EJLIVlsBxWwEpYBathDayFdbAeNsBG2ASbYQtshW0QCtthB+yEXbAb9sBe2Af74QAchENwGI7AUTgGx+EEnIRTcBrOwFk4B+fhAlyES3AZrsBVuAbX4QbchFtwG+7AXbgH9+EBPIRH8BiewFN4Bs/hBbyEV/Aa3sBbeAfv4QN8hE/wGb7AVwiDcPgGEfAdfsBP+AW/0SEgIiGjoKKhh5EwMkZBH4yK0TA6xsCYGAtjYxz0xbgYD+NjAkyIidAPE2MSTIr+mAyTYwpMiakwNabBtJgO02MGzIiZMDNmwayYDbNjDsyJuTA35sG8mA/zYwEsiIWwMBbBolgMi2MJLImlsDSWwbJYDstjBayIlbAyVsGqWA2rYw2sibWwNtbBulgP62MDbIiNsDE2waYYgM2wObbAltgKW2MbbIvtsD12wI7YCQMxCDtjF+yKwdgNu2MP7Im9sDf2wb7YD/vjAByIg3AwDsGhOAyH4wgciaNwNIbgGByL43A8TsCJOAkn4xScitNwOs7AmTgLZ+McnIvzcD4uwIW4CBfjElyKy3A5rsCVuApX4xpci+twPW7AjbgJN+MW3IrbMBS34w7cibtwN+7BvbgP9+MBPIiH8DAewaN4DI/jCTyJp/A0nsGzeA7P4wW8iJfwMl7Bq3gNr+MNvIm38Dbewbt4D+/jA3yIj/AxPsGn+Ayf4wt8ia/wNb7Bt/gO3+MH/Iif8DN+wa8YhuH4DSPwO/7An/gL/zy7BIRExCSkZORRJIpMUciHolI0ik4xKCbFotgUh3wpLsWj+JSAElIi8qPElISSkj8lo+SUglJSKkpNaSgtpaP0lIEyUibKTFkoK2Wj7JSDclIuyk15KC/lo/xUgApSISpMRagoFaPiVIJKUikqTWWoLJWj8lSBKlIlqkxVqCpVo+pUg2pSLapNdagu1aP61IAaUiNqTE2oKQVQM2pOLagltaLW1IbaUjtqTx2oI3WiQAqiztSFulIwdaPu1IN6Ui/qTX2oL/Wj/jSABtIgGkxDaCgNo+E0gkbSKBpNITSGxtI4Gk8TaCJNosk0habSNJpOM2gmzaLZNIfm0jyaTwtoIS2ixbSEltIyWk4raCWtotW0htbSOlpPG2gjbaLNtIW20jYKpe20g3bSLtpNe2gv7aP9dIAO0iE6TEfoKB2j43SCTtIpOk1n6Cydo/N0gS7SJbpMV+gqXaPrdINu0i26TXfoLt2j+/SAHtIjekxP6Ck9o+f0gl7SK3pNb+gtvaP39IE+0if6TF/oK4VROH2jCPpOP+gn/aLf7BgYmZhZWNnY40gcmaOwD0flaBydY3BMjsWxOQ77clyOx/E5ASfkROzHiTkJJ2V/TsbJOQWn5FScmtNwWk7H6TkDZ+RMnJmzcFbOxtk5B+fkXJyb83Bezsf5uQAX5EJcmItwUS7GxbkEl+RSXJrLcFkux+W5AlfkSlyZq3BVrsbVuQbX5Fpcm+twXa7H9bkBN+RG3JibcFMO4GbcnFtwS27FrbkNt+V23J47cEfuxIEcxJ25C3flYO7G3bkH9+Re3Jv7cF/ux/15AA/kQTyYh/BQHsbDeQSP5FE8mkN4DI/lcTyeJ/BEnsSTeQpP5Wk8nWfwTJ7Fs3kOz+V5PJ8X8EJexIt5CS/lZbycV/BKXsWreQ2v5XW8njfwRt7Em3kLb+VtHMrbeQfv5F28m/fwXt7H+/kAH+RDfJiP8FE+xsf5BJ/kU3yaz/BZPsfn+QJf5Et8ma/wVb7G1/kG3+RbfJvv8F2+x/f5AT/kR/yYn/BTfsbP+QW/5Ff8mt/wW37H7/kDf+RP/Jm/8FcO43D+xhH8nX/wT/7Fv+XPt09QSFhEVEw8iSSRJYr4SFSJJtElhsSUWBJb4oivxJV4El8SSEJJJH6SWJJIUvGXZJJcUkhKSSWpJY2klXSSXjJIRskkmSWLZJVskl1ySE7JJbklj+SVfJJfCkhBKSSFpYgUlWJSXEpISSklpaWMlJVyUl4qSEWpJJWlilSValJdakhNqSW1pY7UlXpSXxpIQ2kkjaWJNJUAaSbNpYW0lFbSWtpIW2kn7aWDdJROEihB0lm6SFcJlm7SXXpIT+klvaWP9JV+0l8GyEAZJINliAyVYTJcRshIGSWjJUTGyFgZJ+NlgkyUSTJZpshUmSbTZYbMlFkyW+bIXJkn82WBLJRFsliWyFJZJstlhayUVbJa1shaWSfrZYNslE2yWbbIVtkmobJddshO2SW7ZY/slX2yXw7IQTkkh+WIHJVjclxOyEk5JafljJyVc3JeLshFuSSX5YpclWtyXW7ITbklt+WO3JV7cl8eyEN5JI/liTyVZ/JcXshLeSWv5Y28lXfyXj7IR/kkn+WLfJUwCZdvEiHf5Yf8lF/yW52CopKyiqqaehpJI2sU9dGoGk2jawyNqbE0tsZRX42r8TS+JtCEmkj9NLEm0aTqr8k0uabQlJpKU2saTavpNL1m0IyaSTNrFs2q2TS75tCcmktzax7Nq/k0vxbQglpIC2sRLarFtLiW0JJaSktrGS2r5bS8VtCKWkkraxWtqtW0utbQmlpLa2sdrav1tL420IbaSBtrE22qAdpMm2sLbamttLW20bbaTttrB+2onTRQg7SzdtGuGqzdtLv20J7aS3trH+2r/bS/DtCBOkgH6xAdqsN0uI7QkTpKR2uIjtGxOk7H6wSdqJN0sk7RqTpNp+sMnamzdLbO0bk6T+frAl2oi3SxLtGlukyX6wpdqat0ta7RtbpO1+sG3aibdLNu0a26TUN1u+7QnbpLd+se3av7dL8e0IN6SA/rET2qx/S4ntCTekpP6xk9q+f0vF7Qi3pJL+sVvarX9Lre0Jt6S2/rHb2r9/S+PtCH+kgf6xN9qs/0ub7Ql/pKX+sbfavv9L1+0I/6ST/rF/2qYRqu3zRCv+sP/am/9Lc5A0MjYxNTM/MskkW2KOZjUS2aRbcYFtNiWWyLY74W1+JZfEtgCS2R+VliS2JJzd+SWXJLYSktlaW2NJbW0ll6y2AZLZNltiyW1bJZdsthOS2X5bY8ltfyWX4rYAWtkBW2IlbUillxK2ElrZSVtjJW1spZeatgFa2SVbYqVtWqWXWrYTWtltW2OlbX6ll9a2ANrZE1tibW1AKsmTW3FtbSWllra2NtrZ21tw7W0TpZoAVZZ+tiXS3Yull362E9rZf1tj7W1/pZfxtgA22QDbYhNtSG2XAbYSNtlI22EBtjY22cjbcJNtEm2WSbYlNtmk23GTbTZtlsm2NzbZ7NtwW20BbZYltiS22ZLbcVttJW2WpbY2ttna23DbbRNtlm22JbbZuF2nbbYTttl+22PbbX9tl+O2AH7ZAdtiN21I7ZcTthJ+2UnbYzdtbO2Xm7YBftkl22K3bVrtl1u2E37Zbdtjt21+7ZfXtgD+2RPbYn9tSe2XN7YS/tlb22N/bW3tl7+2Af7ZN9ti/21cIs3L5ZhH23H/bTftlv72/LjR557ImnnnmeF8mL7EXxfLyoXjQvuhfDi+nF8mJ7cTxfL64Xz4vvJfASeok8Py+xl8RL6vl7ybzkXgovpZfKS+2l8dJ66bz0XgYvo5fJy+xl8bJ62bzsXg4vp5fLy+3l8fJ6+bz8XgGvoFfIK+wV8Yp6xbziXgmvpFfKK+2V8cp65bzyXgX/7z6hESlDISxG6LeMoRQWI4J9f/X9NjSir/2s+yuN77eLFnbkRw5ZtsH3+5HwPBL+VZc18/150f6oHBLUyvfPbh758VWj/eMf//jHP/7xj/9//B1wRw5P6pN6ll+CTLG+jwvxk9IhuifynigRz3z/B+I69cx42u3BAQ0AAAgDoG/WNvBjGERgmg0AAADwwAGHXgFoAAAAAAEAAAAA"); + unicode-range: U+0028, U+0029; +} + +/* Colors from: + +Wong, B. Points of view: Color blindness. +Nat Methods 8, 441 (2011). https://doi.org/10.1038/nmeth.1618 +*/ + +/* If the active version has the name "dev", style it orange */ +#version_switcher_button[data-active-version-name*="dev"] { + background-color: #E69F00; + border-color: #E69F00; + color: white; +} + +/* green for `stable` */ +#version_switcher_button[data-active-version-name*="stable"] { + background-color: #009E73; + border-color: #009E73; + color: white; +} + +/* red for `old` */ +#version_switcher_button:not([data-active-version-name*="stable"]):not([data-active-version-name*="dev"]):not([data-active-version-name*="pull"]) { + background-color: #980F0F; + border-color: #980F0F; + color: white; +} + +/* Main index page overview cards */ + +.sd-card { + background: #fff; + border-radius: 0; + padding: 30px 10px 20px 10px; + margin: 10px 0px; +} + +.sd-card .sd-card-header { + text-align: center; +} + +.sd-card .sd-card-header .sd-card-text { + margin: 0px; +} + +.sd-card .sd-card-img-top { + height: 52px; + width: 52px; + margin-left: auto; + margin-right: auto; +} + +.sd-card .sd-card-header { + border: none; + background-color:white; + color: #150458 !important; + font-size: var(--pst-font-size-h5); + font-weight: bold; + padding: 2.5rem 0rem 0.5rem 0rem; + border-bottom: none !important; +} + +.sd-card .sd-card-footer { + border: none; + background-color:white; + border-top: none !important; +} + +.sd-card .sd-card-footer .sd-card-text{ + max-width: 220px; + margin-left: auto; + margin-right: auto; +} + +.custom-button { + background-color:#DCDCDC; + border: none; + color: #484848; + text-align: center; + text-decoration: none; + display: inline-block; + font-size: 0.9rem; + border-radius: 0.5rem; + max-width: 120px; + padding: 0.5rem 0rem; +} + +.custom-button a { + color: #484848; +} + +.custom-button p { + margin-top: 0; + margin-bottom: 0rem; + color: #484848; +} + +/* Dark theme tweaking + +Matplotlib images are in png and inverted while other output +types are assumed to be normal images. + +*/ +html[data-theme=dark] img[src*='.svg']:not(.only-dark):not(.dark-light) { + filter: brightness(0.8) invert(0.82) contrast(1.2); + background: unset +} + +html[data-theme=dark] .MathJax_SVG * { + fill: var(--pst-color-text-base); +} + +/* Main index page overview cards */ + +html[data-theme=dark] .sd-card { + background-color:var(--pst-color-background); + border: none +} + +html[data-theme=dark] .sd-shadow-sm { + box-shadow: 0 .1rem 0.5rem rgba(250, 250, 250, .2) !important +} - .wy-table-responsive { - overflow: visible !important; - max-width: 100%; - } +html[data-theme=dark] .sd-card .sd-card-header { + background-color:var(--pst-color-background); + color: #150458 !important; } -.wy-side-nav-search, .wy-nav-top { - background: #444466; +html[data-theme=dark] .sd-card .sd-card-footer { + background-color:var(--pst-color-background); } diff --git a/doc/source/_static/dash.css b/doc/source/_static/dash.css new file mode 100644 index 0000000000..d85c613b32 --- /dev/null +++ b/doc/source/_static/dash.css @@ -0,0 +1,29 @@ +/* Slight Modification When Building Dash Docset + +Dash windows can be of a variety of sizes, no need to restrict to fixed +width. To be used with sphinx-pydata-theme + +*/ + +.bd-header { + display: none; +} + +.bd-sidebar-primary { + display: none; +} + +.bd-main .bd-content .bd-article-container { + flex-grow: 1; + max-width: 100%; +} + +.bd-container .bd-container__inner { + flex-grow: 1; + max-width: 100%; +} + +/* patching issue from pydata-sphinx-theme */ +div.viewcode-block:target { + display: block; +} diff --git a/doc/source/_static/peegee_03_square_no_bg_32_cleaned.ico b/doc/source/_static/peegee_03_square_no_bg_32_cleaned.ico new file mode 100644 index 0000000000..2e084f0220 Binary files /dev/null and b/doc/source/_static/peegee_03_square_no_bg_32_cleaned.ico differ diff --git a/doc/source/_static/peegee_03_square_no_bg_32_cleaned.png b/doc/source/_static/peegee_03_square_no_bg_32_cleaned.png new file mode 100644 index 0000000000..0683368e08 Binary files /dev/null and b/doc/source/_static/peegee_03_square_no_bg_32_cleaned.png differ diff --git a/doc/source/_static/peegee_04_square_no_bg_180_cleaned.png b/doc/source/_static/peegee_04_square_no_bg_180_cleaned.png new file mode 100644 index 0000000000..7a70426cb6 Binary files /dev/null and b/doc/source/_static/peegee_04_square_no_bg_180_cleaned.png differ diff --git a/doc/source/3dgraphics/glaxisitem.rst b/doc/source/api_reference/3dgraphics/glaxisitem.rst similarity index 100% rename from doc/source/3dgraphics/glaxisitem.rst rename to doc/source/api_reference/3dgraphics/glaxisitem.rst diff --git a/doc/source/3dgraphics/glgraphicsitem.rst b/doc/source/api_reference/3dgraphics/glgraphicsitem.rst similarity index 100% rename from doc/source/3dgraphics/glgraphicsitem.rst rename to doc/source/api_reference/3dgraphics/glgraphicsitem.rst diff --git a/doc/source/3dgraphics/glgraphitem.rst b/doc/source/api_reference/3dgraphics/glgraphitem.rst similarity index 100% rename from doc/source/3dgraphics/glgraphitem.rst rename to doc/source/api_reference/3dgraphics/glgraphitem.rst diff --git a/doc/source/3dgraphics/glgriditem.rst b/doc/source/api_reference/3dgraphics/glgriditem.rst similarity index 100% rename from doc/source/3dgraphics/glgriditem.rst rename to doc/source/api_reference/3dgraphics/glgriditem.rst diff --git a/doc/source/3dgraphics/glimageitem.rst b/doc/source/api_reference/3dgraphics/glimageitem.rst similarity index 100% rename from doc/source/3dgraphics/glimageitem.rst rename to doc/source/api_reference/3dgraphics/glimageitem.rst diff --git a/doc/source/3dgraphics/gllineplotitem.rst b/doc/source/api_reference/3dgraphics/gllineplotitem.rst similarity index 100% rename from doc/source/3dgraphics/gllineplotitem.rst rename to doc/source/api_reference/3dgraphics/gllineplotitem.rst diff --git a/doc/source/3dgraphics/glmeshitem.rst b/doc/source/api_reference/3dgraphics/glmeshitem.rst similarity index 100% rename from doc/source/3dgraphics/glmeshitem.rst rename to doc/source/api_reference/3dgraphics/glmeshitem.rst diff --git a/doc/source/3dgraphics/glscatterplotitem.rst b/doc/source/api_reference/3dgraphics/glscatterplotitem.rst similarity index 100% rename from doc/source/3dgraphics/glscatterplotitem.rst rename to doc/source/api_reference/3dgraphics/glscatterplotitem.rst diff --git a/doc/source/3dgraphics/glsurfaceplotitem.rst b/doc/source/api_reference/3dgraphics/glsurfaceplotitem.rst similarity index 100% rename from doc/source/3dgraphics/glsurfaceplotitem.rst rename to doc/source/api_reference/3dgraphics/glsurfaceplotitem.rst diff --git a/doc/source/api_reference/3dgraphics/gltextitem.rst b/doc/source/api_reference/3dgraphics/gltextitem.rst new file mode 100644 index 0000000000..2ffe6c3640 --- /dev/null +++ b/doc/source/api_reference/3dgraphics/gltextitem.rst @@ -0,0 +1,7 @@ +GLTextItem +========== + +.. autoclass:: pyqtgraph.opengl.GLTextItem + :members: + + .. automethod:: pyqtgraph.opengl.GLTextItem.__init__ diff --git a/doc/source/api_reference/3dgraphics/glviewwidget.rst b/doc/source/api_reference/3dgraphics/glviewwidget.rst new file mode 100644 index 0000000000..d7dd009819 --- /dev/null +++ b/doc/source/api_reference/3dgraphics/glviewwidget.rst @@ -0,0 +1,21 @@ +GLViewWidget +============ + +.. autoclass:: pyqtgraph.opengl.GLViewWidget + :members: + :show-inheritance: + :inherited-members: QOpenGLWidget + + .. automethod:: pyqtgraph.opengl.GLViewWidget.__init__ + + +.. autoclass:: pyqtgraph.opengl.GLViewWidget::GLViewMixin + + .. warning:: The intention of this class is to provide users who want to use + ``QOpenGLWindow`` instead of ``QOpenGLWidget`` but retain the benefits of + ``GLViewWidget``. Usage of this class should be considered experimental + for the time being as it may change without warning in future releases. + + :members: + + .. automethod:: pyqtgraph.opengl.GLViewWidget::GLViewMixin.__init__ diff --git a/doc/source/3dgraphics/glvolumeitem.rst b/doc/source/api_reference/3dgraphics/glvolumeitem.rst similarity index 100% rename from doc/source/3dgraphics/glvolumeitem.rst rename to doc/source/api_reference/3dgraphics/glvolumeitem.rst diff --git a/doc/source/3dgraphics/index.rst b/doc/source/api_reference/3dgraphics/index.rst similarity index 92% rename from doc/source/3dgraphics/index.rst rename to doc/source/api_reference/3dgraphics/index.rst index 06bf048176..15d23b382f 100644 --- a/doc/source/3dgraphics/index.rst +++ b/doc/source/api_reference/3dgraphics/index.rst @@ -2,7 +2,7 @@ PyQtGraph's 3D Graphics System ============================== The 3D graphics system in pyqtgraph is composed of a :class:`view widget ` and -several graphics items (all subclasses of :class:`GLGraphicsItem `) which +several graphics items (all subclasses of :class:`GLGraphicsItem `) which can be added to a view widget. **Note 1:** pyqtgraph.opengl is based on the deprecated OpenGL fixed-function pipeline. Although it is @@ -28,4 +28,5 @@ Contents: glaxisitem glgraphicsitem glscatterplotitem + gltextitem meshdata diff --git a/doc/source/3dgraphics/meshdata.rst b/doc/source/api_reference/3dgraphics/meshdata.rst similarity index 100% rename from doc/source/3dgraphics/meshdata.rst rename to doc/source/api_reference/3dgraphics/meshdata.rst diff --git a/doc/source/colormap.rst b/doc/source/api_reference/colormap.rst similarity index 90% rename from doc/source/colormap.rst rename to doc/source/api_reference/colormap.rst index c3c3b85b5a..5a91e6fbb2 100644 --- a/doc/source/colormap.rst +++ b/doc/source/api_reference/colormap.rst @@ -45,25 +45,27 @@ Examples False color display of a 2D data set. Display levels are controlled by a :class:`ColorBarItem `: -.. literalinclude:: images/gen_example_false_color_image.py +.. literalinclude:: /images/gen_example_false_color_image.py :lines: 18-28 :dedent: 8 Using QtGui.QPen and QtGui.QBrush to color plots according to the plotted value: -.. literalinclude:: images/gen_example_gradient_plot.py +.. literalinclude:: /images/gen_example_gradient_plot.py :lines: 16-33 :dedent: 8 -.. image:: - images/example_false_color_image.png +.. thumbnail:: + /images/example_false_color_image.png :width: 49% :alt: Example of a false color image + :title: False color image -.. image:: - images/example_gradient_plot.png +.. thumbnail:: + /images/example_gradient_plot.png :width: 49% :alt: Example of drawing and filling plots with gradients + :title: Drawing and filling plots with gradients The use of color maps is also demonstrated in the `ImageView`, `Color Gradient Plots` and `ColorBarItem` :ref:`examples`. diff --git a/doc/source/config_options.rst b/doc/source/api_reference/config_options.rst similarity index 98% rename from doc/source/config_options.rst rename to doc/source/api_reference/config_options.rst index c14743ffc0..6893995848 100644 --- a/doc/source/config_options.rst +++ b/doc/source/api_reference/config_options.rst @@ -49,4 +49,6 @@ segmentedLineMode str 'auto' For 'on', lines are al .. autofunction:: pyqtgraph.setConfigOptions +.. autofunction:: pyqtgraph.setConfigOption + .. autofunction:: pyqtgraph.getConfigOption diff --git a/doc/source/dockarea.rst b/doc/source/api_reference/dockarea.rst similarity index 100% rename from doc/source/dockarea.rst rename to doc/source/api_reference/dockarea.rst diff --git a/doc/source/api_reference/exporters/Exporter.rst b/doc/source/api_reference/exporters/Exporter.rst new file mode 100644 index 0000000000..c84f856aae --- /dev/null +++ b/doc/source/api_reference/exporters/Exporter.rst @@ -0,0 +1,5 @@ +Exporter +============ + +.. autoclass:: pyqtgraph.exporters.Exporter + :members: diff --git a/doc/source/api_reference/exporters/SVGExporter.rst b/doc/source/api_reference/exporters/SVGExporter.rst new file mode 100644 index 0000000000..fd9fb962e3 --- /dev/null +++ b/doc/source/api_reference/exporters/SVGExporter.rst @@ -0,0 +1,9 @@ +SVGExporter +============ + +.. autoclass:: pyqtgraph.exporters.SVGExporter + :members: + +.. autofunction:: pyqtgraph.exporters.SVGExporter.generateSvg + +.. autofunction:: pyqtgraph.exporters.SVGExporter._generateItemSvg diff --git a/doc/source/api_reference/exporters/index.rst b/doc/source/api_reference/exporters/index.rst new file mode 100644 index 0000000000..8d6c5a043c --- /dev/null +++ b/doc/source/api_reference/exporters/index.rst @@ -0,0 +1,13 @@ +.. _exporter: + + +API Reference +============= + +Contents: + +.. toctree:: + :maxdepth: 2 + + Exporter + SVGExporter diff --git a/doc/source/flowchart/flowchart.rst b/doc/source/api_reference/flowchart/flowchart.rst similarity index 100% rename from doc/source/flowchart/flowchart.rst rename to doc/source/api_reference/flowchart/flowchart.rst diff --git a/doc/source/api_reference/flowchart/index.rst b/doc/source/api_reference/flowchart/index.rst new file mode 100644 index 0000000000..8f95ef8109 --- /dev/null +++ b/doc/source/api_reference/flowchart/index.rst @@ -0,0 +1,219 @@ +.. _flowchart: + +Visual Programming with Flowcharts +================================== + +PyQtGraph's flowcharts provide a visual programming environment similar in concept to +LabView--functional modules are added to a flowchart and connected by wires to define +a more complex and arbitrarily configurable algorithm. A small number of predefined +modules (called Nodes) are included with pyqtgraph, but most flowchart developers will +want to define their own library of Nodes. At their core, the Nodes are little more +than 1) a python function 2) a list of input/output terminals, and 3) an optional +widget providing a control panel for the Node. Nodes may transmit/receive any type of +Python object via their terminals. + +One major limitation of flowcharts is that there is no mechanism for looping within a +flowchart. (however individual Nodes may contain loops (they may contain any Python +code at all), and an entire flowchart may be executed from within a loop). + +There are two distinct modes of executing the code in a flowchart: + +1. Provide data to the input terminals of the flowchart. This method is slower and +will provide a graphical representation of the data as it passes through the +flowchart. This is useful for debugging as it allows the user to inspect the data +at each terminal and see where exceptions occurred within the flowchart. +2. Call :func:`Flowchart.process() `. This +method does not update the displayed state of the flowchart and only retains the +state of each terminal as long as it is needed. Additionally, Nodes which do not +contribute to the output values of the flowchart (such as plotting nodes) are +ignored. This mode allows for faster processing of large data sets and avoids memory +issues which can occur if too much data is present in the flowchart at once (e.g., +when processing image data through several stages). + +See the flowchart example for more information. + +API Reference: + +.. toctree:: + :maxdepth: 2 + + flowchart + node + terminal + +Basic Use +--------- + +Flowcharts are most useful in situations where you have a processing stage in your +application that you would like to be arbitrarily configurable by the user. Rather +than giving a pre-defined algorithm with parameters for the user to tweak, you supply +a set of pre-defined functions and allow the user to arrange and connect these +functions how they like. A very common example is the use of filter networks in +audio / video processing applications. + +To begin, you must decide what the input and output variables will be for your +flowchart. Create a flowchart with one terminal defined for each variable:: + + ## This example creates just a single input and a single output. + ## Flowcharts may define any number of terminals, though. + from pyqtgraph.flowchart import Flowchart + fc = Flowchart(terminals={ + 'nameOfInputTerminal': {'io': 'in'}, + 'nameOfOutputTerminal': {'io': 'out'} + }) + +In the example above, each terminal is defined by a dictionary of options which define +the behavior of that terminal (see +:func:`Terminal.__init__() ` for more +information and options). Note that Terminals are not typed; any python object may be +passed from one Terminal to another. + +Once the flowchart is created, add its control widget to your application:: + + ctrl = fc.widget() + myLayout.addWidget(ctrl) ## read Qt docs on QWidget and layouts for more information + +The control widget provides several features: + +* Displays a list of all nodes in the flowchart containing the control widget for + each node. +* Provides access to the flowchart design window via the 'flowchart' button +* Interface for saving / restoring flowcharts to disk. + +At this point your user has the ability to generate flowcharts based on the built-in +node library. It is recommended to provide a default set of flowcharts for your users +to build from. + +All that remains is to process data through the flowchart. As noted above, there are +two ways to do this: + +.. _processing methods: + +1. Set the values of input terminals with + :func:`Flowchart.setInput() `, then read + the values of output terminals with + :func:`Flowchart.output() `:: + + fc.setInput(nameOfInputTerminal=newValue) + output = fc.output() # returns {terminalName:value} + + This method updates all of the values displayed in the flowchart design window, + allowing the user to inspect values at all terminals in the flowchart and + indicating the location of errors that occurred during processing. +2. Call :func:`Flowchart.process() `:: + + output = fc.process(nameOfInputTerminal=newValue) + + This method processes data without updating any of the displayed terminal values. + Additionally, all :func:`Node.process() ` methods + are called with display=False to request that they not invoke any custom display + code. This allows data to be processed both more quickly and with a smaller memory + footprint, but errors that occur during Flowchart.process() will be more difficult + for the user to diagnose. It is thus recommended to use this method for batch + processing through flowcharts that have already been tested and debugged with + method 1. + +Implementing Custom Nodes +------------------------- + +PyQtGraph includes a small library of built-in flowchart nodes. This library is +intended to cover some of the most commonly-used functions as well as provide examples +for some more exotic Node types. Most applications that use the flowchart system will +find the built-in library insufficient and will thus need to implement custom Node +classes. + +A node subclass implements at least: + +1) A list of input / output terminals and their properties +2) A :func:`process() ` function which takes the + names of input terminals as keyword arguments and returns a dict with the names of + output terminals as keys. + +Optionally, a Node subclass can implement the +:func:`ctrlWidget() ` method, which must return +a QWidget (usually containing other widgets) that will be displayed in the flowchart +control panel. A minimal Node subclass looks like:: + + class SpecialFunctionNode(Node): + """SpecialFunction: short description + + This description will appear in the flowchart design window when the user + selects a node of this type. + """ + nodeName = 'SpecialFunction' # Node type name that will appear to the user. + + def __init__(self, name): # all Nodes are provided a unique name when they + # are created. + Node.__init__(self, name, terminals={ # Initialize with a dict + # describing the I/O terminals + # on this Node. + 'inputTerminalName': {'io': 'in'}, + 'anotherInputTerminal': {'io': 'in'}, + 'outputTerminalName': {'io': 'out'}, + }) + + def process(self, **kwds): + # kwds will have one keyword argument per input terminal. + + return {'outputTerminalName': result} + + def ctrlWidget(self): # this method is optional + return someQWidget + +Some nodes implement fairly complex control widgets, but most nodes follow a simple +form-like pattern: a list of parameter names and a single value (represented as spin +box, check box, etc..) for each parameter. To make this easier, the +``pyqtgraph.flowchart.library.common.CtrlNode`` subclass allows you +to instead define a simple data structure that CtrlNode will use to automatically +generate the control widget. This is used in many of the built-in library nodes +(especially the filters). + +There are many other optional parameters for nodes and terminals -- whether the user +is allowed to add/remove/rename terminals, whether one terminal may be connected to +many others or just one, etc. See the documentation on the +:class:`~pyqtgraph.flowchart.Node` and :class:`~pyqtgraph.flowchart.Terminal` +classes for more details. + +After implementing a new Node subclass, you will most likely want to register the +class so that it appears in the menu of Nodes the user can select from:: + + import pyqtgraph.flowchart.library as fclib + fclib.registerNodeType(SpecialFunctionNode, [('Category', 'Sub-Category')]) + +The second argument to registerNodeType is a list of tuples, with each tuple +describing a menu location in which SpecialFunctionNode should appear. + +See the FlowchartCustomNode example for more information. + + +Debugging Custom Nodes +^^^^^^^^^^^^^^^^^^^^^^ + +When designing flowcharts or custom Nodes, it is important to set the input of the +flowchart with data that at least has the same types and structure as the data you +intend to process (see `processing methods`_ #1 above). When you use +:func:`Flowchart.setInput() `, the flowchart +displays visual feedback in its design window that can tell you what data is present +at any terminal and whether there were errors in processing. Nodes that generated +errors are displayed with a red border. If you select a Node, its input and output +values will be displayed as well as the exception that occurred while the node was +processing, if any. + + +Using Nodes Without Flowcharts +------------------------------ + +Flowchart Nodes implement a very useful generalization in data processing by combining +a function with a GUI for configuring that function. This generalization is useful +even outside the context of a flowchart. For example:: + + ## We defined a useful filter Node for use in flowcharts, but would like to + ## re-use its processing code and GUI without having a flowchart present. + filterNode = MyFilterNode("filterNodeName") + + ## get the Node's control widget and place it inside the main window + filterCtrl = filterNode.ctrlWidget() + someLayout.addWidget(filterCtrl) + + ## later on, process data through the node + filteredData = filterNode.process(inputTerminal=rawData) diff --git a/doc/source/flowchart/node.rst b/doc/source/api_reference/flowchart/node.rst similarity index 100% rename from doc/source/flowchart/node.rst rename to doc/source/api_reference/flowchart/node.rst diff --git a/doc/source/flowchart/terminal.rst b/doc/source/api_reference/flowchart/terminal.rst similarity index 100% rename from doc/source/flowchart/terminal.rst rename to doc/source/api_reference/flowchart/terminal.rst diff --git a/doc/source/functions.rst b/doc/source/api_reference/functions.rst similarity index 100% rename from doc/source/functions.rst rename to doc/source/api_reference/functions.rst diff --git a/doc/source/graphicsItems/arrowitem.rst b/doc/source/api_reference/graphicsItems/arrowitem.rst similarity index 100% rename from doc/source/graphicsItems/arrowitem.rst rename to doc/source/api_reference/graphicsItems/arrowitem.rst diff --git a/doc/source/graphicsItems/axisitem.rst b/doc/source/api_reference/graphicsItems/axisitem.rst similarity index 100% rename from doc/source/graphicsItems/axisitem.rst rename to doc/source/api_reference/graphicsItems/axisitem.rst diff --git a/doc/source/graphicsItems/bargraphitem.rst b/doc/source/api_reference/graphicsItems/bargraphitem.rst similarity index 100% rename from doc/source/graphicsItems/bargraphitem.rst rename to doc/source/api_reference/graphicsItems/bargraphitem.rst diff --git a/doc/source/graphicsItems/buttonitem.rst b/doc/source/api_reference/graphicsItems/buttonitem.rst similarity index 100% rename from doc/source/graphicsItems/buttonitem.rst rename to doc/source/api_reference/graphicsItems/buttonitem.rst diff --git a/doc/source/graphicsItems/colorbaritem.rst b/doc/source/api_reference/graphicsItems/colorbaritem.rst similarity index 100% rename from doc/source/graphicsItems/colorbaritem.rst rename to doc/source/api_reference/graphicsItems/colorbaritem.rst diff --git a/doc/source/graphicsItems/curvearrow.rst b/doc/source/api_reference/graphicsItems/curvearrow.rst similarity index 100% rename from doc/source/graphicsItems/curvearrow.rst rename to doc/source/api_reference/graphicsItems/curvearrow.rst diff --git a/doc/source/graphicsItems/curvepoint.rst b/doc/source/api_reference/graphicsItems/curvepoint.rst similarity index 100% rename from doc/source/graphicsItems/curvepoint.rst rename to doc/source/api_reference/graphicsItems/curvepoint.rst diff --git a/doc/source/graphicsItems/dateaxisitem.rst b/doc/source/api_reference/graphicsItems/dateaxisitem.rst similarity index 100% rename from doc/source/graphicsItems/dateaxisitem.rst rename to doc/source/api_reference/graphicsItems/dateaxisitem.rst diff --git a/doc/source/graphicsItems/errorbaritem.rst b/doc/source/api_reference/graphicsItems/errorbaritem.rst similarity index 100% rename from doc/source/graphicsItems/errorbaritem.rst rename to doc/source/api_reference/graphicsItems/errorbaritem.rst diff --git a/doc/source/graphicsItems/fillbetweenitem.rst b/doc/source/api_reference/graphicsItems/fillbetweenitem.rst similarity index 100% rename from doc/source/graphicsItems/fillbetweenitem.rst rename to doc/source/api_reference/graphicsItems/fillbetweenitem.rst diff --git a/doc/source/graphicsItems/gradienteditoritem.rst b/doc/source/api_reference/graphicsItems/gradienteditoritem.rst similarity index 100% rename from doc/source/graphicsItems/gradienteditoritem.rst rename to doc/source/api_reference/graphicsItems/gradienteditoritem.rst diff --git a/doc/source/graphicsItems/gradientlegend.rst b/doc/source/api_reference/graphicsItems/gradientlegend.rst similarity index 100% rename from doc/source/graphicsItems/gradientlegend.rst rename to doc/source/api_reference/graphicsItems/gradientlegend.rst diff --git a/doc/source/graphicsItems/graphicsitem.rst b/doc/source/api_reference/graphicsItems/graphicsitem.rst similarity index 100% rename from doc/source/graphicsItems/graphicsitem.rst rename to doc/source/api_reference/graphicsItems/graphicsitem.rst diff --git a/doc/source/graphicsItems/graphicslayout.rst b/doc/source/api_reference/graphicsItems/graphicslayout.rst similarity index 100% rename from doc/source/graphicsItems/graphicslayout.rst rename to doc/source/api_reference/graphicsItems/graphicslayout.rst diff --git a/doc/source/graphicsItems/graphicsobject.rst b/doc/source/api_reference/graphicsItems/graphicsobject.rst similarity index 100% rename from doc/source/graphicsItems/graphicsobject.rst rename to doc/source/api_reference/graphicsItems/graphicsobject.rst diff --git a/doc/source/graphicsItems/graphicswidget.rst b/doc/source/api_reference/graphicsItems/graphicswidget.rst similarity index 100% rename from doc/source/graphicsItems/graphicswidget.rst rename to doc/source/api_reference/graphicsItems/graphicswidget.rst diff --git a/doc/source/graphicsItems/graphicswidgetanchor.rst b/doc/source/api_reference/graphicsItems/graphicswidgetanchor.rst similarity index 100% rename from doc/source/graphicsItems/graphicswidgetanchor.rst rename to doc/source/api_reference/graphicsItems/graphicswidgetanchor.rst diff --git a/doc/source/graphicsItems/graphitem.rst b/doc/source/api_reference/graphicsItems/graphitem.rst similarity index 100% rename from doc/source/graphicsItems/graphitem.rst rename to doc/source/api_reference/graphicsItems/graphitem.rst diff --git a/doc/source/graphicsItems/griditem.rst b/doc/source/api_reference/graphicsItems/griditem.rst similarity index 100% rename from doc/source/graphicsItems/griditem.rst rename to doc/source/api_reference/graphicsItems/griditem.rst diff --git a/doc/source/graphicsItems/histogramlutitem.rst b/doc/source/api_reference/graphicsItems/histogramlutitem.rst similarity index 100% rename from doc/source/graphicsItems/histogramlutitem.rst rename to doc/source/api_reference/graphicsItems/histogramlutitem.rst diff --git a/doc/source/graphicsItems/imageitem.rst b/doc/source/api_reference/graphicsItems/imageitem.rst similarity index 94% rename from doc/source/graphicsItems/imageitem.rst rename to doc/source/api_reference/graphicsItems/imageitem.rst index c590f80f0a..1d16c1e653 100644 --- a/doc/source/graphicsItems/imageitem.rst +++ b/doc/source/api_reference/graphicsItems/imageitem.rst @@ -50,15 +50,15 @@ If performance is critial, the following points may be worth investigating: Examples -------- -.. literalinclude:: ../images/gen_example_imageitem_transform.py +.. literalinclude:: /images/gen_example_imageitem_transform.py :lines: 19-28 :dedent: 8 -.. image:: - ../images/example_imageitem_transform.png +.. thumbnail:: + /images/example_imageitem_transform.png :width: 49% :alt: Example of transformed image display - + :title: Transformed Image Display .. autoclass:: pyqtgraph.ImageItem diff --git a/doc/source/graphicsItems/index.rst b/doc/source/api_reference/graphicsItems/index.rst similarity index 100% rename from doc/source/graphicsItems/index.rst rename to doc/source/api_reference/graphicsItems/index.rst diff --git a/doc/source/graphicsItems/infiniteline.rst b/doc/source/api_reference/graphicsItems/infiniteline.rst similarity index 55% rename from doc/source/graphicsItems/infiniteline.rst rename to doc/source/api_reference/graphicsItems/infiniteline.rst index e438d54ea1..e96ac24530 100644 --- a/doc/source/graphicsItems/infiniteline.rst +++ b/doc/source/api_reference/graphicsItems/infiniteline.rst @@ -5,3 +5,8 @@ InfiniteLine :members: .. automethod:: pyqtgraph.InfiniteLine.__init__ + +.. autoclass:: pyqtgraph.InfLineLabel + :members: + + .. automethod:: pyqtgraph.InfLineLabel.__init__ diff --git a/doc/source/graphicsItems/isocurveitem.rst b/doc/source/api_reference/graphicsItems/isocurveitem.rst similarity index 100% rename from doc/source/graphicsItems/isocurveitem.rst rename to doc/source/api_reference/graphicsItems/isocurveitem.rst diff --git a/doc/source/graphicsItems/labelitem.rst b/doc/source/api_reference/graphicsItems/labelitem.rst similarity index 100% rename from doc/source/graphicsItems/labelitem.rst rename to doc/source/api_reference/graphicsItems/labelitem.rst diff --git a/doc/source/graphicsItems/legenditem.rst b/doc/source/api_reference/graphicsItems/legenditem.rst similarity index 100% rename from doc/source/graphicsItems/legenditem.rst rename to doc/source/api_reference/graphicsItems/legenditem.rst diff --git a/doc/source/graphicsItems/linearregionitem.rst b/doc/source/api_reference/graphicsItems/linearregionitem.rst similarity index 100% rename from doc/source/graphicsItems/linearregionitem.rst rename to doc/source/api_reference/graphicsItems/linearregionitem.rst diff --git a/doc/source/graphicsItems/multiplotitem.rst b/doc/source/api_reference/graphicsItems/multiplotitem.rst similarity index 100% rename from doc/source/graphicsItems/multiplotitem.rst rename to doc/source/api_reference/graphicsItems/multiplotitem.rst diff --git a/doc/source/graphicsItems/pcolormeshitem.rst b/doc/source/api_reference/graphicsItems/pcolormeshitem.rst similarity index 100% rename from doc/source/graphicsItems/pcolormeshitem.rst rename to doc/source/api_reference/graphicsItems/pcolormeshitem.rst diff --git a/doc/source/graphicsItems/plotcurveitem.rst b/doc/source/api_reference/graphicsItems/plotcurveitem.rst similarity index 100% rename from doc/source/graphicsItems/plotcurveitem.rst rename to doc/source/api_reference/graphicsItems/plotcurveitem.rst diff --git a/doc/source/graphicsItems/plotdataitem.rst b/doc/source/api_reference/graphicsItems/plotdataitem.rst similarity index 100% rename from doc/source/graphicsItems/plotdataitem.rst rename to doc/source/api_reference/graphicsItems/plotdataitem.rst diff --git a/doc/source/graphicsItems/plotitem.rst b/doc/source/api_reference/graphicsItems/plotitem.rst similarity index 100% rename from doc/source/graphicsItems/plotitem.rst rename to doc/source/api_reference/graphicsItems/plotitem.rst diff --git a/doc/source/graphicsItems/roi.rst b/doc/source/api_reference/graphicsItems/roi.rst similarity index 100% rename from doc/source/graphicsItems/roi.rst rename to doc/source/api_reference/graphicsItems/roi.rst diff --git a/doc/source/graphicsItems/scalebar.rst b/doc/source/api_reference/graphicsItems/scalebar.rst similarity index 100% rename from doc/source/graphicsItems/scalebar.rst rename to doc/source/api_reference/graphicsItems/scalebar.rst diff --git a/doc/source/graphicsItems/scatterplotitem.rst b/doc/source/api_reference/graphicsItems/scatterplotitem.rst similarity index 100% rename from doc/source/graphicsItems/scatterplotitem.rst rename to doc/source/api_reference/graphicsItems/scatterplotitem.rst diff --git a/doc/source/graphicsItems/targetitem.rst b/doc/source/api_reference/graphicsItems/targetitem.rst similarity index 100% rename from doc/source/graphicsItems/targetitem.rst rename to doc/source/api_reference/graphicsItems/targetitem.rst diff --git a/doc/source/graphicsItems/textitem.rst b/doc/source/api_reference/graphicsItems/textitem.rst similarity index 100% rename from doc/source/graphicsItems/textitem.rst rename to doc/source/api_reference/graphicsItems/textitem.rst diff --git a/doc/source/graphicsItems/uigraphicsitem.rst b/doc/source/api_reference/graphicsItems/uigraphicsitem.rst similarity index 100% rename from doc/source/graphicsItems/uigraphicsitem.rst rename to doc/source/api_reference/graphicsItems/uigraphicsitem.rst diff --git a/doc/source/graphicsItems/viewbox.rst b/doc/source/api_reference/graphicsItems/viewbox.rst similarity index 100% rename from doc/source/graphicsItems/viewbox.rst rename to doc/source/api_reference/graphicsItems/viewbox.rst diff --git a/doc/source/graphicsItems/vtickgroup.rst b/doc/source/api_reference/graphicsItems/vtickgroup.rst similarity index 100% rename from doc/source/graphicsItems/vtickgroup.rst rename to doc/source/api_reference/graphicsItems/vtickgroup.rst diff --git a/doc/source/graphicsscene/graphicsscene.rst b/doc/source/api_reference/graphicsscene/graphicsscene.rst similarity index 100% rename from doc/source/graphicsscene/graphicsscene.rst rename to doc/source/api_reference/graphicsscene/graphicsscene.rst diff --git a/doc/source/graphicsscene/hoverevent.rst b/doc/source/api_reference/graphicsscene/hoverevent.rst similarity index 100% rename from doc/source/graphicsscene/hoverevent.rst rename to doc/source/api_reference/graphicsscene/hoverevent.rst diff --git a/doc/source/graphicsscene/index.rst b/doc/source/api_reference/graphicsscene/index.rst similarity index 100% rename from doc/source/graphicsscene/index.rst rename to doc/source/api_reference/graphicsscene/index.rst diff --git a/doc/source/graphicsscene/mouseclickevent.rst b/doc/source/api_reference/graphicsscene/mouseclickevent.rst similarity index 100% rename from doc/source/graphicsscene/mouseclickevent.rst rename to doc/source/api_reference/graphicsscene/mouseclickevent.rst diff --git a/doc/source/graphicsscene/mousedragevent.rst b/doc/source/api_reference/graphicsscene/mousedragevent.rst similarity index 100% rename from doc/source/graphicsscene/mousedragevent.rst rename to doc/source/api_reference/graphicsscene/mousedragevent.rst diff --git a/doc/source/apireference.rst b/doc/source/api_reference/index.rst similarity index 68% rename from doc/source/apireference.rst rename to doc/source/api_reference/index.rst index 2392a65ebc..3b097eba50 100644 --- a/doc/source/apireference.rst +++ b/doc/source/api_reference/index.rst @@ -1,3 +1,5 @@ +.. _pyqtgraph_api_ref: + API Reference ============= @@ -12,8 +14,11 @@ Contents: widgets/index 3dgraphics/index colormap - parametertree/apiref + parametertree/index dockarea graphicsscene/index + exporters/index flowchart/index - graphicswindow + point + transform3d + uml_overview diff --git a/doc/source/parametertree/apiref.rst b/doc/source/api_reference/parametertree/apiref.rst similarity index 100% rename from doc/source/parametertree/apiref.rst rename to doc/source/api_reference/parametertree/apiref.rst diff --git a/doc/source/parametertree/index.rst b/doc/source/api_reference/parametertree/index.rst similarity index 100% rename from doc/source/parametertree/index.rst rename to doc/source/api_reference/parametertree/index.rst diff --git a/doc/source/parametertree/interactiveparameters.rst b/doc/source/api_reference/parametertree/interactiveparameters.rst similarity index 86% rename from doc/source/parametertree/interactiveparameters.rst rename to doc/source/api_reference/parametertree/interactiveparameters.rst index 9a5d2bda77..b338cf1fed 100644 --- a/doc/source/parametertree/interactiveparameters.rst +++ b/doc/source/api_reference/parametertree/interactiveparameters.rst @@ -73,24 +73,24 @@ code below is functionally equivalent to above): There are several caveats, but this is one of the most common scenarios for function interaction. -``runOpts`` -^^^^^^^^^^^ +``runOptions`` +^^^^^^^^^^^^^^ Often, an ``interact``-ed function shouldn't run until multiple parameter values are changed. Or, the function should be run every time a value is *changing*, not just changed. In these cases, modify the -``runOpts`` parameter. +``runOptions`` parameter. .. code:: python - from pyqtgraph.parametertree import interact, RunOpts + from pyqtgraph.parametertree import interact, RunOptions # Will add a button named "Run". When clicked, the function will run - params = interact(a, runOpts=RunOpts.ON_ACTION) + params = interact(a, runOptions=RunOptions.ON_ACTION) # Will run on any `sigValueChanging` signal - params = interact(a, runOpts=RunOpts.ON_CHANGING) + params = interact(a, runOptions=RunOptions.ON_CHANGING) # Runs on `sigValueChanged` or when "Run" is pressed - params = interact(a, runOpts=[RunOpts.ON_CHANGED, RunOpts.ON_ACTION]) + params = interact(a, runOptions=[RunOptions.ON_CHANGED, RunOptions.ON_ACTION]) # Any combination of RUN_* options can be used The default run behavior can also be modified. If several functions are @@ -100,13 +100,13 @@ use the provided context manager: .. code:: python from pyqtgraph.parametertree import interact - # `runOpts` can be set to any combination of options as demonstrated above, too - with interact.optsContext(runOpts=RunOpts.ON_ACTION): - # All will have `runOpts` set to ON_ACTION + # `runOptions` can be set to any combination of options as demonstrated above, too + with interact.optsContext(runOptions=RunOptions.ON_ACTION): + # All will have `runOptions` set to ON_ACTION p1 = interact(aFunc) p2 = interact(bFunc) p3 = interact(cFunc) - # After the context, `runOpts` is back to the previous default + # After the context, `runOptions` is back to the previous default If the default for all interaction should be changed, you can directly call ``interactDefaults.setOpts`` (but be warned - anyone who imports your @@ -119,9 +119,9 @@ resetting afterward: from pyqtgraph.parametertree import Interactor myInteractor = Interactor() - oldOpts = myInteractor.setOpts(runOpts=RunOpts.ON_ACTION) + oldOpts = myInteractor.setOpts(runOptions=RunOptions.ON_ACTION) # Can also directly create interactor with these opts: - # myInteractor = Interactor(runOpts=RunOpts.ON_ACTION) + # myInteractor = Interactor(runOptions=RunOptions.ON_ACTION) # ... do some things... # Unset option @@ -220,6 +220,30 @@ should be directly inside the parent, use ``nest=False``: # directly as children of `parent` params = interact(a, nest=False) +``runActionTemplate`` +^^^^^^^^^^^^^^^^^^^^^ +When the ``runOptions`` argument is set to (or contains) ``RunOptions.ON_ACTION``, a +button will be added next to the parameter group which can be clicked to run the +function with the current parameter values. The button's options can be customized +through passing a dictionary to ``runActionTemplate``. The dictionary can contain +any key accepted as an ``action`` parameter option. For instance, to run a function +either by pressing the button or a shortcut, you can interact like so: + +.. code:: python + + def a(x=5, y=6): + return x + y + + # The button will be labeled "Run" and will run the function when clicked or when + # the shortcut "Ctrl+R" is pressed + params = interact(a, runActionTemplate={'shortcut': 'Ctrl+R'}) + + # Alternatively, add an icon to the button + params = interact(a, runActionTemplate={'icon': 'run.png'}) + + # Why not both? + params = interact(a, runActionTemplate={'icon': 'run.png', 'shortcut': 'Ctrl+R'}) + ``existOk`` ^^^^^^^^^^^ @@ -340,7 +364,7 @@ Title Formatting ---------------- If functions should have formatted titles, specify this in the -``title`` parameter: +``titleFormat`` parameter: .. code:: python @@ -351,7 +375,7 @@ If functions should have formatted titles, specify this in the return name.replace('_', ' ').title() # The title in the parameter tree will be "My Snake Case Function" - params = interact(my_snake_case_function, title=titleFormat) + params = interact(my_snake_case_function, titleFormat=titleFormat) Using ``InteractiveFunction`` ----------------------------- @@ -367,13 +391,13 @@ and ``reconnect()`` methods, and object accessors to ``closures`` arguments. .. code:: python - from pyqtgraph.parametertree import InteractiveFunction, interact, Parameter, RunOpts + from pyqtgraph.parametertree import InteractiveFunction, interact, Parameter, RunOptions def myfunc(a=5): print(a) useFunc = InteractiveFunction(myfunc) - param = interact(useFunc, runOpts=RunOpts.ON_CHANGED) + param = interact(useFunc, runOptions=RunOptions.ON_CHANGED) param['a'] = 6 # Will print 6 useFunc.disconnect() @@ -388,7 +412,7 @@ can use ``InteractiveFunction`` like a decorator: .. code:: python - from pyqtgraph.parametertree import InteractiveFunction, interact, Parameter, RunOpts + from pyqtgraph.parametertree import InteractiveFunction, interact, Parameter, RunOptions @InteractiveFunction def myfunc(a=5): @@ -396,7 +420,7 @@ can use ``InteractiveFunction`` like a decorator: # myfunc is now an InteractiveFunction that can be used as above # Also, calling `myfunc` will preserve parameter arguments - param = interact(myfunc, RunOpts.ON_ACTION) + param = interact(myfunc, RunOptions.ON_ACTION) param['a'] = 6 myfunc() diff --git a/doc/source/parametertree/parameter.rst b/doc/source/api_reference/parametertree/parameter.rst similarity index 100% rename from doc/source/parametertree/parameter.rst rename to doc/source/api_reference/parametertree/parameter.rst diff --git a/doc/source/parametertree/parameteritem.rst b/doc/source/api_reference/parametertree/parameteritem.rst similarity index 100% rename from doc/source/parametertree/parameteritem.rst rename to doc/source/api_reference/parametertree/parameteritem.rst diff --git a/doc/source/parametertree/parametertree.rst b/doc/source/api_reference/parametertree/parametertree.rst similarity index 100% rename from doc/source/parametertree/parametertree.rst rename to doc/source/api_reference/parametertree/parametertree.rst diff --git a/doc/source/parametertree/parametertypes.rst b/doc/source/api_reference/parametertree/parametertypes.rst similarity index 97% rename from doc/source/parametertree/parametertypes.rst rename to doc/source/api_reference/parametertree/parametertypes.rst index ef524440ae..f77b4187cc 100644 --- a/doc/source/parametertree/parametertypes.rst +++ b/doc/source/api_reference/parametertree/parametertypes.rst @@ -102,3 +102,6 @@ ParameterItems .. autoclass:: TextParameterItem :members: + +.. autoclass:: WidgetParameterItem + :members: diff --git a/doc/source/api_reference/point.rst b/doc/source/api_reference/point.rst new file mode 100644 index 0000000000..a28c03facb --- /dev/null +++ b/doc/source/api_reference/point.rst @@ -0,0 +1,5 @@ +Point +===== + +.. automodule:: pyqtgraph.Point + :members: diff --git a/doc/source/api_reference/transform3d.rst b/doc/source/api_reference/transform3d.rst new file mode 100644 index 0000000000..44f0d5d52d --- /dev/null +++ b/doc/source/api_reference/transform3d.rst @@ -0,0 +1,5 @@ +Transform3D +=========== + +.. automodule:: pyqtgraph.Transform3D + :members: diff --git a/doc/source/api_reference/uml_overview.rst b/doc/source/api_reference/uml_overview.rst new file mode 100644 index 0000000000..40dd14e956 --- /dev/null +++ b/doc/source/api_reference/uml_overview.rst @@ -0,0 +1,38 @@ +:html_theme.sidebar_secondary.remove: + +UML class diagram +================= + +.. _uml_diagram: + +The UML class diagram below gives an overview of the most important classes and their relations. + +The green boxes represent Qt classes, the purple boxes are PyQtGraph classes. + +The black arrows indicate inheritance between two classes (with the parent class always above the child classes.) + +The gray lines with the diamonds indicate an aggregation relation. For example the :class:`PlotDataItem ` class has a ``curve`` attribute that is a reference to a :class:`PlotCurveItem ` object. + + +.. If it's stupid, and it works, it's not stupid +.. Inlining SVG code, not using tags so nodes can act as links and be clicked + +.. raw:: html + +
+ +.. raw:: html + :file: ../images/overview_uml-dark_mode.svg + +.. raw:: html + +
+ +.. raw:: html + :file: ../images/overview_uml-light_mode.svg + +.. raw:: html + +
+ +.. end of not stupid stupidity diff --git a/doc/source/widgets/busycursor.rst b/doc/source/api_reference/widgets/busycursor.rst similarity index 100% rename from doc/source/widgets/busycursor.rst rename to doc/source/api_reference/widgets/busycursor.rst diff --git a/doc/source/widgets/checktable.rst b/doc/source/api_reference/widgets/checktable.rst similarity index 100% rename from doc/source/widgets/checktable.rst rename to doc/source/api_reference/widgets/checktable.rst diff --git a/doc/source/widgets/colorbutton.rst b/doc/source/api_reference/widgets/colorbutton.rst similarity index 100% rename from doc/source/widgets/colorbutton.rst rename to doc/source/api_reference/widgets/colorbutton.rst diff --git a/doc/source/api_reference/widgets/colormapwidget.rst b/doc/source/api_reference/widgets/colormapwidget.rst new file mode 100644 index 0000000000..deebec5978 --- /dev/null +++ b/doc/source/api_reference/widgets/colormapwidget.rst @@ -0,0 +1,14 @@ +ColorMapWidget +============== + +.. autoclass:: pyqtgraph.ColorMapWidget + :members: + + .. automethod:: pyqtgraph.ColorMapWidget.__init__ + + .. automethod:: pyqtgraph.ColorMapParameter.setFields + + .. automethod:: pyqtgraph.ColorMapParameter.map + +.. autoclass:: pyqtgraph.widgets.ColorMapWidget.ColorMapParameter + :members: diff --git a/doc/source/widgets/combobox.rst b/doc/source/api_reference/widgets/combobox.rst similarity index 100% rename from doc/source/widgets/combobox.rst rename to doc/source/api_reference/widgets/combobox.rst diff --git a/doc/source/widgets/consolewidget.rst b/doc/source/api_reference/widgets/consolewidget.rst similarity index 60% rename from doc/source/widgets/consolewidget.rst rename to doc/source/api_reference/widgets/consolewidget.rst index 580dae3eec..961a5a056f 100644 --- a/doc/source/widgets/consolewidget.rst +++ b/doc/source/api_reference/widgets/consolewidget.rst @@ -4,3 +4,4 @@ ConsoleWidget .. autoclass:: pyqtgraph.console.ConsoleWidget :members: + .. automethod:: pyqtgraph.console.ConsoleWidget.__init__ diff --git a/doc/source/widgets/datatreewidget.rst b/doc/source/api_reference/widgets/datatreewidget.rst similarity index 100% rename from doc/source/widgets/datatreewidget.rst rename to doc/source/api_reference/widgets/datatreewidget.rst diff --git a/doc/source/widgets/feedbackbutton.rst b/doc/source/api_reference/widgets/feedbackbutton.rst similarity index 100% rename from doc/source/widgets/feedbackbutton.rst rename to doc/source/api_reference/widgets/feedbackbutton.rst diff --git a/doc/source/widgets/filedialog.rst b/doc/source/api_reference/widgets/filedialog.rst similarity index 100% rename from doc/source/widgets/filedialog.rst rename to doc/source/api_reference/widgets/filedialog.rst diff --git a/doc/source/widgets/gradientwidget.rst b/doc/source/api_reference/widgets/gradientwidget.rst similarity index 100% rename from doc/source/widgets/gradientwidget.rst rename to doc/source/api_reference/widgets/gradientwidget.rst diff --git a/doc/source/widgets/graphicslayoutwidget.rst b/doc/source/api_reference/widgets/graphicslayoutwidget.rst similarity index 100% rename from doc/source/widgets/graphicslayoutwidget.rst rename to doc/source/api_reference/widgets/graphicslayoutwidget.rst diff --git a/doc/source/widgets/graphicsview.rst b/doc/source/api_reference/widgets/graphicsview.rst similarity index 100% rename from doc/source/widgets/graphicsview.rst rename to doc/source/api_reference/widgets/graphicsview.rst diff --git a/doc/source/widgets/histogramlutwidget.rst b/doc/source/api_reference/widgets/histogramlutwidget.rst similarity index 100% rename from doc/source/widgets/histogramlutwidget.rst rename to doc/source/api_reference/widgets/histogramlutwidget.rst diff --git a/doc/source/widgets/imageview.rst b/doc/source/api_reference/widgets/imageview.rst similarity index 100% rename from doc/source/widgets/imageview.rst rename to doc/source/api_reference/widgets/imageview.rst diff --git a/doc/source/widgets/index.rst b/doc/source/api_reference/widgets/index.rst similarity index 97% rename from doc/source/widgets/index.rst rename to doc/source/api_reference/widgets/index.rst index e5acb7f05d..06d87759eb 100644 --- a/doc/source/widgets/index.rst +++ b/doc/source/api_reference/widgets/index.rst @@ -38,3 +38,4 @@ Contents: pathbutton valuelabel busycursor + rawimagewidget diff --git a/doc/source/widgets/joystickbutton.rst b/doc/source/api_reference/widgets/joystickbutton.rst similarity index 100% rename from doc/source/widgets/joystickbutton.rst rename to doc/source/api_reference/widgets/joystickbutton.rst diff --git a/doc/source/widgets/layoutwidget.rst b/doc/source/api_reference/widgets/layoutwidget.rst similarity index 100% rename from doc/source/widgets/layoutwidget.rst rename to doc/source/api_reference/widgets/layoutwidget.rst diff --git a/doc/source/widgets/matplotlibwidget.rst b/doc/source/api_reference/widgets/matplotlibwidget.rst similarity index 100% rename from doc/source/widgets/matplotlibwidget.rst rename to doc/source/api_reference/widgets/matplotlibwidget.rst diff --git a/doc/source/widgets/multiplotwidget.rst b/doc/source/api_reference/widgets/multiplotwidget.rst similarity index 100% rename from doc/source/widgets/multiplotwidget.rst rename to doc/source/api_reference/widgets/multiplotwidget.rst diff --git a/doc/source/widgets/pathbutton.rst b/doc/source/api_reference/widgets/pathbutton.rst similarity index 100% rename from doc/source/widgets/pathbutton.rst rename to doc/source/api_reference/widgets/pathbutton.rst diff --git a/doc/source/widgets/plotwidget.rst b/doc/source/api_reference/widgets/plotwidget.rst similarity index 100% rename from doc/source/widgets/plotwidget.rst rename to doc/source/api_reference/widgets/plotwidget.rst diff --git a/doc/source/widgets/progressdialog.rst b/doc/source/api_reference/widgets/progressdialog.rst similarity index 100% rename from doc/source/widgets/progressdialog.rst rename to doc/source/api_reference/widgets/progressdialog.rst diff --git a/doc/source/api_reference/widgets/rawimagewidget.rst b/doc/source/api_reference/widgets/rawimagewidget.rst new file mode 100644 index 0000000000..db6dc7f031 --- /dev/null +++ b/doc/source/api_reference/widgets/rawimagewidget.rst @@ -0,0 +1,9 @@ +RawImageWidget +============== + +.. autoclass:: pyqtgraph.RawImageWidget + :members: + + .. automethod:: pyqtgraph.widgets.RawImageWidget.RawImageWidget.__init__ + + .. automethod:: pyqtgraph.widgets.RawImageWidget.RawImageWidget.setImage diff --git a/doc/source/widgets/remotegraphicsview.rst b/doc/source/api_reference/widgets/remotegraphicsview.rst similarity index 100% rename from doc/source/widgets/remotegraphicsview.rst rename to doc/source/api_reference/widgets/remotegraphicsview.rst diff --git a/doc/source/widgets/scatterplotwidget.rst b/doc/source/api_reference/widgets/scatterplotwidget.rst similarity index 100% rename from doc/source/widgets/scatterplotwidget.rst rename to doc/source/api_reference/widgets/scatterplotwidget.rst diff --git a/doc/source/widgets/spinbox.rst b/doc/source/api_reference/widgets/spinbox.rst similarity index 100% rename from doc/source/widgets/spinbox.rst rename to doc/source/api_reference/widgets/spinbox.rst diff --git a/doc/source/widgets/tablewidget.rst b/doc/source/api_reference/widgets/tablewidget.rst similarity index 100% rename from doc/source/widgets/tablewidget.rst rename to doc/source/api_reference/widgets/tablewidget.rst diff --git a/doc/source/widgets/treewidget.rst b/doc/source/api_reference/widgets/treewidget.rst similarity index 100% rename from doc/source/widgets/treewidget.rst rename to doc/source/api_reference/widgets/treewidget.rst diff --git a/doc/source/widgets/valuelabel.rst b/doc/source/api_reference/widgets/valuelabel.rst similarity index 100% rename from doc/source/widgets/valuelabel.rst rename to doc/source/api_reference/widgets/valuelabel.rst diff --git a/doc/source/widgets/verticallabel.rst b/doc/source/api_reference/widgets/verticallabel.rst similarity index 100% rename from doc/source/widgets/verticallabel.rst rename to doc/source/api_reference/widgets/verticallabel.rst diff --git a/doc/source/conf.py b/doc/source/conf.py index d241c81516..7d26cc9c16 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -10,11 +10,13 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import time -import sys import os +import sys +import time from datetime import datetime +from sphinx.application import Sphinx + # 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. @@ -30,7 +32,18 @@ # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode', 'sphinx.ext.napoleon'] +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.viewcode", + "sphinx.ext.napoleon", + "sphinx.ext.intersphinx", + "sphinx_qt_documentation", + "sphinx_design", + "sphinx_favicon", + "sphinxext.rediraffe", + "sphinxcontrib.images", + "sphinx_autodoc_typehints" +] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -38,6 +51,26 @@ # The suffix of source filenames. source_suffix = '.rst' +# Set Qt Documentation Variable +qt_documentation = "Qt6" + +intersphinx_mapping = { + 'python': ('https://docs.python.org/3', None), + 'numpy': ('https://numpy.org/doc/stable/', None) +} + +nitpick_ignore_regex = [ + ("py:class", r"re\.Pattern"), # doesn't seem to be a good ref in python docs +] + +napoleon_preprocess_types = True +napoleon_type_aliases = { + "callable": ":class:`collections.abc.Callable`", + "np.ndarray": ":class:`numpy.ndarray`", + 'array_like': ':term:`array_like`', + 'color_like': ':func:`pyqtgraph.mkColor`' +} + # The encoding of source files. #source_encoding = 'utf-8-sig' @@ -49,7 +82,7 @@ now = datetime.utcfromtimestamp( int(os.environ.get('SOURCE_DATE_EPOCH', time.time())) ) -copyright = '2011 - {}, Luke Campagnola'.format(now.year) +copyright = '2011 - {}, PyQtGraph developers'.format(now.year) # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -101,16 +134,39 @@ "matplotlib", ] + # -- 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' +html_theme = 'pydata_sphinx_theme' + +# favicons +favicons = [ + "peegee_03_square_no_bg_32_cleaned.png", + "peegee_04_square_no_bg_180_cleaned.png", + "peegee_03_square_no_bg_32_cleaned.ico" +] # 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 = {} +html_theme_options = { + "github_url": "https://github.com/pyqtgraph/pyqtgraph", + "navbar_end": ["theme-switcher", "navbar-icon-links"], + "twitter_url": "https://twitter.com/pyqtgraph", + "use_edit_page_button": False, + "secondary_sidebar_items": ["page-toc"] +} + + +if os.getenv("BUILD_DASH_DOCSET"): + html_theme_options |= { + 'secondary_sidebar_items': [], + "show_prev_next": False, + "collapse_navigation": True, + } + # Add any paths that contain custom themes here, relative to this directory. #html_theme_path = [] @@ -129,7 +185,7 @@ # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = "_static/peegee_03_square_no_bg_32_cleaned.ico" # 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, @@ -137,8 +193,137 @@ html_static_path = ['_static'] # add the theme customizations -def setup(app): - app.add_css_file("custom.css") +html_css_files = [ + "custom.css", +] + +if os.getenv("BUILD_DASH_DOCSET"): + html_css_files.append("dash.css") + +# Redirects for pages that were moved to new locations +rediraffe_redirects = { + "3dgraphics/glaxisitem.rst": "api_reference/3dgraphics/glaxisitem.rst", + "3dgraphics/glgraphicsitem.rst": "api_reference/3dgraphics/glgraphicsitem.rst", + "3dgraphics/glgraphitem.rst": "api_reference/3dgraphics/glgraphitem.rst", + "3dgraphics/glgriditem.rst": "api_reference/3dgraphics/glgriditem.rst", + "3dgraphics/glimageitem.rst": "api_reference/3dgraphics/glimageitem.rst", + "3dgraphics/gllineplotitem.rst": "api_reference/3dgraphics/gllineplotitem.rst", + "3dgraphics/glmeshitem.rst": "api_reference/3dgraphics/glmeshitem.rst", + "3dgraphics/glscatterplotitem.rst": "api_reference/3dgraphics/glscatterplotitem.rst", + "3dgraphics/glsurfaceplotitem.rst": "api_reference/3dgraphics/glsurfaceplotitem.rst", + "3dgraphics/gltextitem.rst": "api_reference/3dgraphics/gltextitem.rst", + "3dgraphics/glviewwidget.rst": "api_reference/3dgraphics/glviewwidget.rst", + "3dgraphics/glvolumeitem.rst": "api_reference/3dgraphics/glvolumeitem.rst", + "3dgraphics/index.rst": "api_reference/3dgraphics/index.rst", + "3dgraphics/meshdata.rst": "api_reference/3dgraphics/meshdata.rst", + "colormap.rst": "api_reference/colormap.rst", + "config_options.rst": "api_reference/config_options.rst", + "dockarea.rst": "api_reference/dockarea.rst", + "flowchart/flowchart.rst": "api_reference/flowchart/flowchart.rst", + "flowchart/index.rst": "api_reference/flowchart/index.rst", + "flowchart/node.rst": "api_reference/flowchart/node.rst", + "flowchart/terminal.rst": "api_reference/flowchart/terminal.rst", + "functions.rst": "api_reference/functions.rst", + "graphicsItems/arrowitem.rst": "api_reference/graphicsItems/arrowitem.rst", + "graphicsItems/axisitem.rst": "api_reference/graphicsItems/axisitem.rst", + "graphicsItems/bargraphitem.rst": "api_reference/graphicsItems/bargraphitem.rst", + "graphicsItems/buttonitem.rst": "api_reference/graphicsItems/buttonitem.rst", + "graphicsItems/colorbaritem.rst": "api_reference/graphicsItems/colorbaritem.rst", + "graphicsItems/curvearrow.rst": "api_reference/graphicsItems/curvearrow.rst", + "graphicsItems/curvepoint.rst": "api_reference/graphicsItems/curvepoint.rst", + "graphicsItems/dateaxisitem.rst": "api_reference/graphicsItems/dateaxisitem.rst", + "graphicsItems/errorbaritem.rst": "api_reference/graphicsItems/errorbaritem.rst", + "graphicsItems/fillbetweenitem.rst": "api_reference/graphicsItems/fillbetweenitem.rst", + "graphicsItems/gradienteditoritem.rst": "api_reference/graphicsItems/gradienteditoritem.rst", + "graphicsItems/gradientlegend.rst": "api_reference/graphicsItems/gradientlegend.rst", + "graphicsItems/graphicsitem.rst": "api_reference/graphicsItems/graphicsitem.rst", + "graphicsItems/graphicslayout.rst": "api_reference/graphicsItems/graphicslayout.rst", + "graphicsItems/graphicsobject.rst": "api_reference/graphicsItems/graphicsobject.rst", + "graphicsItems/graphicswidget.rst": "api_reference/graphicsItems/graphicswidget.rst", + "graphicsItems/graphicswidgetanchor.rst": "api_reference/graphicsItems/graphicswidgetanchor.rst", + "graphicsItems/graphitem.rst": "api_reference/graphicsItems/graphitem.rst", + "graphicsItems/griditem.rst": "api_reference/graphicsItems/griditem.rst", + "graphicsItems/histogramlutitem.rst": "api_reference/graphicsItems/histogramlutitem.rst", + "graphicsItems/imageitem.rst": "api_reference/graphicsItems/imageitem.rst", + "graphicsItems/index.rst": "api_reference/graphicsItems/index.rst", + "graphicsItems/infiniteline.rst": "api_reference/graphicsItems/infiniteline.rst", + "graphicsItems/isocurveitem.rst": "api_reference/graphicsItems/isocurveitem.rst", + "graphicsItems/labelitem.rst": "api_reference/graphicsItems/labelitem.rst", + "graphicsItems/legenditem.rst": "api_reference/graphicsItems/legenditem.rst", + "graphicsItems/linearregionitem.rst": "api_reference/graphicsItems/linearregionitem.rst", + "graphicsItems/multiplotitem.rst": "api_reference/graphicsItems/multiplotitem.rst", + "graphicsItems/pcolormeshitem.rst": "api_reference/graphicsItems/pcolormeshitem.rst", + "graphicsItems/plotcurveitem.rst": "api_reference/graphicsItems/plotcurveitem.rst", + "graphicsItems/plotdataitem.rst": "api_reference/graphicsItems/plotdataitem.rst", + "graphicsItems/plotitem.rst": "api_reference/graphicsItems/plotitem.rst", + "graphicsItems/roi.rst": "api_reference/graphicsItems/roi.rst", + "graphicsItems/scalebar.rst": "api_reference/graphicsItems/scalebar.rst", + "graphicsItems/scatterplotitem.rst": "api_reference/graphicsItems/scatterplotitem.rst", + "graphicsItems/targetitem.rst": "api_reference/graphicsItems/targetitem.rst", + "graphicsItems/textitem.rst": "api_reference/graphicsItems/textitem.rst", + "graphicsItems/uigraphicsitem.rst": "api_reference/graphicsItems/uigraphicsitem.rst", + "graphicsItems/viewbox.rst": "api_reference/graphicsItems/viewbox.rst", + "graphicsItems/vtickgroup.rst": "api_reference/graphicsItems/vtickgroup.rst", + "graphicsscene/graphicsscene.rst": "api_reference/graphicsscene/graphicsscene.rst", + "graphicsscene/hoverevent.rst": "api_reference/graphicsscene/hoverevent.rst", + "graphicsscene/index.rst": "api_reference/graphicsscene/index.rst", + "graphicsscene/mouseclickevent.rst": "api_reference/graphicsscene/mouseclickevent.rst", + "graphicsscene/mousedragevent.rst": "api_reference/graphicsscene/mousedragevent.rst", + "apireference.rst": "api_reference/index.rst", + "parametertree/apiref.rst": "api_reference/parametertree/apiref.rst", + "parametertree/index.rst": "api_reference/parametertree/index.rst", + "parametertree/interactiveparameters.rst": "api_reference/parametertree/interactiveparameters.rst", + "parametertree/parameter.rst": "api_reference/parametertree/parameter.rst", + "parametertree/parameteritem.rst": "api_reference/parametertree/parameteritem.rst", + "parametertree/parametertree.rst": "api_reference/parametertree/parametertree.rst", + "parametertree/parametertypes.rst": "api_reference/parametertree/parametertypes.rst", + "point.rst": "api_reference/point.rst", + "transform3d.rst": "api_reference/transform3d.rst", + "uml_overview.rst": "api_reference/uml_overview.rst", + "widgets/busycursor.rst": "api_reference/widgets/busycursor.rst", + "widgets/checktable.rst": "api_reference/widgets/checktable.rst", + "widgets/colorbutton.rst": "api_reference/widgets/colorbutton.rst", + "widgets/colormapwidget.rst": "api_reference/widgets/colormapwidget.rst", + "widgets/combobox.rst": "api_reference/widgets/combobox.rst", + "widgets/consolewidget.rst": "api_reference/widgets/consolewidget.rst", + "widgets/datatreewidget.rst": "api_reference/widgets/datatreewidget.rst", + "widgets/feedbackbutton.rst": "api_reference/widgets/feedbackbutton.rst", + "widgets/filedialog.rst": "api_reference/widgets/filedialog.rst", + "widgets/gradientwidget.rst": "api_reference/widgets/gradientwidget.rst", + "widgets/graphicslayoutwidget.rst": "api_reference/widgets/graphicslayoutwidget.rst", + "widgets/graphicsview.rst": "api_reference/widgets/graphicsview.rst", + "widgets/histogramlutwidget.rst": "api_reference/widgets/histogramlutwidget.rst", + "widgets/imageview.rst": "api_reference/widgets/imageview.rst", + "widgets/index.rst": "api_reference/widgets/index.rst", + "widgets/joystickbutton.rst": "api_reference/widgets/joystickbutton.rst", + "widgets/layoutwidget.rst": "api_reference/widgets/layoutwidget.rst", + "widgets/matplotlibwidget.rst": "api_reference/widgets/matplotlibwidget.rst", + "widgets/multiplotwidget.rst": "api_reference/widgets/multiplotwidget.rst", + "widgets/pathbutton.rst": "api_reference/widgets/pathbutton.rst", + "widgets/plotwidget.rst": "api_reference/widgets/plotwidget.rst", + "widgets/progressdialog.rst": "api_reference/widgets/progressdialog.rst", + "widgets/rawimagewidget.rst": "api_reference/widgets/rawimagewidget.rst", + "widgets/remotegraphicsview.rst": "api_reference/widgets/remotegraphicsview.rst", + "widgets/scatterplotwidget.rst": "api_reference/widgets/scatterplotwidget.rst", + "widgets/spinbox.rst": "api_reference/widgets/spinbox.rst", + "widgets/tablewidget.rst": "api_reference/widgets/tablewidget.rst", + "widgets/treewidget.rst": "api_reference/widgets/treewidget.rst", + "widgets/valuelabel.rst": "api_reference/widgets/valuelabel.rst", + "widgets/verticallabel.rst": "api_reference/widgets/verticallabel.rst", + "internals.rst": "developer_guide/internals.rst", + "3dgraphics.rst": "getting_started/3dgraphics.rst", + "how_to_use.rst": "getting_started/how_to_use.rst", + "images.rst": "getting_started/images.rst", + "installation.rst": "getting_started/installation.rst", + "introduction.rst": "getting_started/introduction.rst", + "plotting.rst": "getting_started/plotting.rst", + "prototyping.rst": "getting_started/prototyping.rst", + "qtcrashcourse.rst": "getting_started/qtcrashcourse.rst", + "exporting.rst": "user_guide/exporting.rst", + "mouse_interaction.rst": "user_guide/mouse_interaction.rst", + "region_of_interest.rst": "user_guide/region_of_interest.rst", + "style.rst": "user_guide/style.rst" +} # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. @@ -149,7 +334,15 @@ def setup(app): #html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +if os.getenv("BUILD_DASH_DOCSET"): # used for building dash docsets + html_sidebars = { + "**": [] + } +else: + html_sidebars = { + "**": ["sidebar-nav-bs.html"], + 'index': [] # don't show sidebar on main landing page + } # Additional templates that should be rendered to pages, maps page names to # template names. @@ -165,7 +358,7 @@ def setup(app): #html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +html_show_sourcelink = False # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. #html_show_sphinx = True @@ -232,3 +425,12 @@ def setup(app): ('index', 'pyqtgraph', 'pyqtgraph Documentation', ['Luke Campagnola'], 1) ] + + +def html_page_context(app, pagename, templatename, context, doctree): + # Disable edit button for docstring generated pages + if "generated" in pagename: + context["theme_use_edit_page_button"] = False + +def setup(app: Sphinx): + app.connect("html-page-context", html_page_context) diff --git a/doc/source/developer_guide/index.rst b/doc/source/developer_guide/index.rst new file mode 100644 index 0000000000..1ad4947872 --- /dev/null +++ b/doc/source/developer_guide/index.rst @@ -0,0 +1,11 @@ +.. _developer_guide_ref: + +Developer Guide +=============== + +The PyQtGraph library welcomes contributions from the community. +This guide is intended to help you get started with contributing to the project. + +.. toctree:: + + internals diff --git a/doc/source/internals.rst b/doc/source/developer_guide/internals.rst similarity index 100% rename from doc/source/internals.rst rename to doc/source/developer_guide/internals.rst diff --git a/doc/source/flowchart/index.rst b/doc/source/flowchart/index.rst deleted file mode 100644 index 98f5680b38..0000000000 --- a/doc/source/flowchart/index.rst +++ /dev/null @@ -1,143 +0,0 @@ -Visual Programming with Flowcharts -================================== - -PyQtGraph's flowcharts provide a visual programming environment similar in concept to LabView--functional modules are added to a flowchart and connected by wires to define a more complex and arbitrarily configurable algorithm. A small number of predefined modules (called Nodes) are included with pyqtgraph, but most flowchart developers will want to define their own library of Nodes. At their core, the Nodes are little more than 1) a python function 2) a list of input/output terminals, and 3) an optional widget providing a control panel for the Node. Nodes may transmit/receive any type of Python object via their terminals. - -One major limitation of flowcharts is that there is no mechanism for looping within a flowchart. (however individual Nodes may contain loops (they may contain any Python code at all), and an entire flowchart may be executed from within a loop). - -There are two distinct modes of executing the code in a flowchart: - -1. Provide data to the input terminals of the flowchart. This method is slower and will provide a graphical representation of the data as it passes through the flowchart. This is useful for debugging as it allows the user to inspect the data at each terminal and see where exceptions occurred within the flowchart. -2. Call :func:`Flowchart.process() `. This method does not update the displayed state of the flowchart and only retains the state of each terminal as long as it is needed. Additionally, Nodes which do not contribute to the output values of the flowchart (such as plotting nodes) are ignored. This mode allows for faster processing of large data sets and avoids memory issues which can occur if too much data is present in the flowchart at once (e.g., when processing image data through several stages). - -See the flowchart example for more information. - -API Reference: - -.. toctree:: - :maxdepth: 2 - - flowchart - node - terminal - -Basic Use ---------- - -Flowcharts are most useful in situations where you have a processing stage in your application that you would like to be arbitrarily configurable by the user. Rather than giving a pre-defined algorithm with parameters for the user to tweak, you supply a set of pre-defined functions and allow the user to arrange and connect these functions how they like. A very common example is the use of filter networks in audio / video processing applications. - -To begin, you must decide what the input and output variables will be for your flowchart. Create a flowchart with one terminal defined for each variable:: - - ## This example creates just a single input and a single output. - ## Flowcharts may define any number of terminals, though. - from pyqtgraph.flowchart import Flowchart - fc = Flowchart(terminals={ - 'nameOfInputTerminal': {'io': 'in'}, - 'nameOfOutputTerminal': {'io': 'out'} - }) - -In the example above, each terminal is defined by a dictionary of options which define the behavior of that terminal (see :func:`Terminal.__init__() ` for more information and options). Note that Terminals are not typed; any python object may be passed from one Terminal to another. - -Once the flowchart is created, add its control widget to your application:: - - ctrl = fc.widget() - myLayout.addWidget(ctrl) ## read Qt docs on QWidget and layouts for more information - -The control widget provides several features: - -* Displays a list of all nodes in the flowchart containing the control widget for - each node. -* Provides access to the flowchart design window via the 'flowchart' button -* Interface for saving / restoring flowcharts to disk. - -At this point your user has the ability to generate flowcharts based on the built-in node library. It is recommended to provide a default set of flowcharts for your users to build from. - -All that remains is to process data through the flowchart. As noted above, there are two ways to do this: - -.. _processing methods: - -1. Set the values of input terminals with :func:`Flowchart.setInput() `, then read the values of output terminals with :func:`Flowchart.output() `:: - - fc.setInput(nameOfInputTerminal=newValue) - output = fc.output() # returns {terminalName:value} - - This method updates all of the values displayed in the flowchart design window, allowing the user to inspect values at all terminals in the flowchart and indicating the location of errors that occurred during processing. -2. Call :func:`Flowchart.process() `:: - - output = fc.process(nameOfInputTerminal=newValue) - - This method processes data without updating any of the displayed terminal values. Additionally, all :func:`Node.process() ` methods are called with display=False to request that they not invoke any custom display code. This allows data to be processed both more quickly and with a smaller memory footprint, but errors that occur during Flowchart.process() will be more difficult for the user to diagnose. It is thus recommended to use this method for batch processing through flowcharts that have already been tested and debugged with method 1. - -Implementing Custom Nodes -------------------------- - -PyQtGraph includes a small library of built-in flowchart nodes. This library is intended to cover some of the most commonly-used functions as well as provide examples for some more exotic Node types. Most applications that use the flowchart system will find the built-in library insufficient and will thus need to implement custom Node classes. - -A node subclass implements at least: - -1) A list of input / output terminals and their properties -2) A :func:`process() ` function which takes the names of input terminals as keyword arguments and returns a dict with the names of output terminals as keys. - -Optionally, a Node subclass can implement the :func:`ctrlWidget() ` method, which must return a QWidget (usually containing other widgets) that will be displayed in the flowchart control panel. A minimal Node subclass looks like:: - - class SpecialFunctionNode(Node): - """SpecialFunction: short description - - This description will appear in the flowchart design window when the user - selects a node of this type. - """ - nodeName = 'SpecialFunction' # Node type name that will appear to the user. - - def __init__(self, name): # all Nodes are provided a unique name when they - # are created. - Node.__init__(self, name, terminals={ # Initialize with a dict - # describing the I/O terminals - # on this Node. - 'inputTerminalName': {'io': 'in'}, - 'anotherInputTerminal': {'io': 'in'}, - 'outputTerminalName': {'io': 'out'}, - }) - - def process(self, **kwds): - # kwds will have one keyword argument per input terminal. - - return {'outputTerminalName': result} - - def ctrlWidget(self): # this method is optional - return someQWidget - -Some nodes implement fairly complex control widgets, but most nodes follow a simple form-like pattern: a list of parameter names and a single value (represented as spin box, check box, etc..) for each parameter. To make this easier, the :class:`~pyqtgraph.flowchart.library.common.CtrlNode` subclass allows you to instead define a simple data structure that CtrlNode will use to automatically generate the control widget. This is used in many of the built-in library nodes (especially the filters). - -There are many other optional parameters for nodes and terminals -- whether the user is allowed to add/remove/rename terminals, whether one terminal may be connected to many others or just one, etc. See the documentation on the :class:`~pyqtgraph.flowchart.Node` and :class:`~pyqtgraph.flowchart.Terminal` classes for more details. - -After implementing a new Node subclass, you will most likely want to register the class so that it appears in the menu of Nodes the user can select from:: - - import pyqtgraph.flowchart.library as fclib - fclib.registerNodeType(SpecialFunctionNode, [('Category', 'Sub-Category')]) - -The second argument to registerNodeType is a list of tuples, with each tuple describing a menu location in which SpecialFunctionNode should appear. - -See the FlowchartCustomNode example for more information. - - -Debugging Custom Nodes -^^^^^^^^^^^^^^^^^^^^^^ - -When designing flowcharts or custom Nodes, it is important to set the input of the flowchart with data that at least has the same types and structure as the data you intend to process (see `processing methods`_ #1 above). When you use :func:`Flowchart.setInput() `, the flowchart displays visual feedback in its design window that can tell you what data is present at any terminal and whether there were errors in processing. Nodes that generated errors are displayed with a red border. If you select a Node, its input and output values will be displayed as well as the exception that occurred while the node was processing, if any. - - -Using Nodes Without Flowcharts ------------------------------- - -Flowchart Nodes implement a very useful generalization in data processing by combining a function with a GUI for configuring that function. This generalization is useful even outside the context of a flowchart. For example:: - - ## We defined a useful filter Node for use in flowcharts, but would like to - ## re-use its processing code and GUI without having a flowchart present. - filterNode = MyFilterNode("filterNodeName") - - ## get the Node's control widget and place it inside the main window - filterCtrl = filterNode.ctrlWidget() - someLayout.addWidget(filterCtrl) - - ## later on, process data through the node - filteredData = filterNode.process(inputTerminal=rawData) diff --git a/doc/source/3dgraphics.rst b/doc/source/getting_started/3dgraphics.rst similarity index 88% rename from doc/source/3dgraphics.rst rename to doc/source/getting_started/3dgraphics.rst index eed8eb69db..819b8cde1b 100644 --- a/doc/source/3dgraphics.rst +++ b/doc/source/getting_started/3dgraphics.rst @@ -11,7 +11,7 @@ Current capabilities include: * Volumetric rendering item * Grid/axis items -See the :doc:`API Reference ` and the Volumetric (GLVolumeItem.py) and Isosurface (GLMeshItem.py) examples for more information. +See the :doc:`API Reference <../api_reference/3dgraphics/index>` and the Volumetric (GLVolumeItem.py) and Isosurface (GLMeshItem.py) examples for more information. Basic usage example:: diff --git a/doc/source/how_to_use.rst b/doc/source/getting_started/how_to_use.rst similarity index 95% rename from doc/source/how_to_use.rst rename to doc/source/getting_started/how_to_use.rst index ca2a9f7d18..84a0c2935e 100644 --- a/doc/source/how_to_use.rst +++ b/doc/source/getting_started/how_to_use.rst @@ -24,7 +24,7 @@ Further examples:: pw = pg.plot(xVals, yVals, pen='r') # plot x vs y in red pw.plot(xVals, yVals2, pen='b') - win = pg.GraphicsWindow() # Automatically generates grids with multiple items + win = pg.GraphicsLayoutWidget() # Automatically generates grids with multiple items win.addPlot(data1, row=0, col=0) win.addPlot(data2, row=0, col=1) win.addPlot(data3, row=1, col=0, colspan=2) @@ -81,18 +81,19 @@ PyQt and PySide PyQtGraph supports two popular python wrappers for the Qt library: PyQt and PySide. Both packages provide nearly identical APIs and functionality, but for various reasons (discussed elsewhere) you may prefer to use one package or the other. When -pyqtgraph is first imported, it automatically determines which library to use by making the fillowing checks: +pyqtgraph is first imported, if the environment variable ``PYQTGRAPH_QT_LIB`` is not set, it automatically determines which +library to use by making the following checks: -#. If PyQt5 is already imported, use that -#. Else, if PySide2 is already imported, use that +#. If PyQt6 is already imported, use that #. Else, if PySide6 is already imported, use that -#. Else, if PyQt6 is already imported, use that -#. Else, attempt to import PyQt5, PySide2, PySide6, PyQt6, in that order. +#. Else, if PyQt5 is already imported, use that +#. Else, if PySide2 is already imported, use that +#. Else, attempt to import PyQt6, PySide6, PyQt5, PySide2, in that order. If you have both libraries installed on your system and you wish to force pyqtgraph to use one or the other, simply make sure it is imported before pyqtgraph:: - import PySide2 ## this will force pyqtgraph to use PySide2 instead of PyQt5 + import PySide2 ## this will force pyqtgraph to use PySide2 instead of PyQt6 import pyqtgraph as pg diff --git a/doc/source/images.rst b/doc/source/getting_started/images.rst similarity index 100% rename from doc/source/images.rst rename to doc/source/getting_started/images.rst diff --git a/doc/source/getting_started/index.rst b/doc/source/getting_started/index.rst new file mode 100644 index 0000000000..e64c39b94f --- /dev/null +++ b/doc/source/getting_started/index.rst @@ -0,0 +1,18 @@ +.. _getting_started_ref: + +Getting Started +=============== + +This section will guide you through the process of installing PyQtGraph and some common use cases, such as plotting data, displaying video and images, and building graphical user interfaces. + +.. toctree:: + :maxdepth: 1 + + introduction + installation + how_to_use + plotting + images + 3dgraphics + prototyping + qtcrashcourse diff --git a/doc/source/installation.rst b/doc/source/getting_started/installation.rst similarity index 100% rename from doc/source/installation.rst rename to doc/source/getting_started/installation.rst diff --git a/doc/source/introduction.rst b/doc/source/getting_started/introduction.rst similarity index 99% rename from doc/source/introduction.rst rename to doc/source/getting_started/introduction.rst index 21ced2b6e3..8f61ae28b0 100644 --- a/doc/source/introduction.rst +++ b/doc/source/getting_started/introduction.rst @@ -78,6 +78,3 @@ How does it compare to... (My experience with these libraries is somewhat outdated; please correct me if I am wrong here) - - -.. rubric:: Footnotes diff --git a/doc/source/plotting.rst b/doc/source/getting_started/plotting.rst similarity index 91% rename from doc/source/plotting.rst rename to doc/source/getting_started/plotting.rst index 956f5b97ee..4e1ca89aa1 100644 --- a/doc/source/plotting.rst +++ b/doc/source/getting_started/plotting.rst @@ -3,12 +3,12 @@ Plotting in pyqtgraph There are a few basic ways to plot data in pyqtgraph: -=================================================================== ================================================== +=================================================================== ===================================================== :func:`pyqtgraph.plot` Create a new plot window showing your data -:func:`PlotWidget.plot() ` Add a new set of data to an existing plot widget :func:`PlotItem.plot() ` Add a new set of data to an existing plot widget +``PlotWidget.plot()`` Calls :func:`PlotItem.plot ` :func:`GraphicsLayout.addPlot() ` Add a new plot to a grid of plots -=================================================================== ================================================== +=================================================================== ===================================================== All of these will accept the same basic arguments which control how the plot data is interpreted and displayed: @@ -43,7 +43,11 @@ There are several classes invloved in displaying plot data. Most of these classe * :class:`PlotWidget ` - A subclass of GraphicsView with a single PlotItem displayed. Most of the methods provided by PlotItem are also available through PlotWidget. * :class:`GraphicsLayoutWidget ` - QWidget subclass displaying a single :class:`~pyqtgraph.GraphicsLayout`. Most of the methods provided by :class:`~pyqtgraph.GraphicsLayout` are also available through GraphicsLayoutWidget. -.. image:: images/plottingClasses.png +.. thumbnail:: + /images/plottingClasses.png + :title: Elements of PlotClasses + +See the :ref:`UML class diagram ` page for a more detailed figure of the most important classes and their relations. Examples diff --git a/doc/source/prototyping.rst b/doc/source/getting_started/prototyping.rst similarity index 93% rename from doc/source/prototyping.rst rename to doc/source/getting_started/prototyping.rst index a392f20e6c..98f59d5d76 100644 --- a/doc/source/prototyping.rst +++ b/doc/source/getting_started/prototyping.rst @@ -10,7 +10,7 @@ Parameter Trees The parameter tree system provides a widget displaying a tree of modifiable values similar to those used in most GUI editor applications. This allows a large number of variables to be controlled by the user with relatively little programming effort. The system also provides separation between the data being controlled and the user interface controlling it (model/view architecture). Parameters may be grouped/nested to any depth and custom parameter types can be built by subclassing from Parameter and ParameterItem. -See the `parametertree documentation `_ for more information. +See the :ref:`parametertree` documentation for more information. Visual Programming Flowcharts @@ -18,9 +18,11 @@ Visual Programming Flowcharts PyQtGraph's flowcharts provide a visual programming environment similar in concept to LabView--functional modules are added to a flowchart and connected by wires to define a more complex and arbitrarily configurable algorithm. A small number of predefined modules (called Nodes) are included with pyqtgraph, but most flowchart developers will want to define their own library of Nodes. At their core, the Nodes are little more than 1) a Python function 2) a list of input/output terminals, and 3) an optional widget providing a control panel for the Node. Nodes may transmit/receive any type of Python object via their terminals. -See the `flowchart documentation `_ and the flowchart examples for more information. +See the :ref:`flowchart` documentation and the flowchart examples for more information. +.. _Canvas: + Graphical Canvas ---------------- diff --git a/doc/source/qtcrashcourse.rst b/doc/source/getting_started/qtcrashcourse.rst similarity index 87% rename from doc/source/qtcrashcourse.rst rename to doc/source/getting_started/qtcrashcourse.rst index bc993974c9..66b63717e8 100644 --- a/doc/source/qtcrashcourse.rst +++ b/doc/source/getting_started/qtcrashcourse.rst @@ -16,37 +16,38 @@ PyQtGraph fits into this scheme by providing its own QWidget subclasses to be in Example:: - - from PyQt5 import QtGui # (the example applies equally well to PySide2) + + from PyQt6 import QtWidgets # Should work with PyQt5 / PySide2 / PySide6 as well import pyqtgraph as pg - + ## Always start by initializing Qt (only once per application) - app = QtGui.QApplication([]) + app = QtWidgets.QApplication([]) ## Define a top-level widget to hold everything - w = QtGui.QWidget() + w = QtWidgets.QWidget() + w.setWindowTitle('PyQtGraph example') ## Create some widgets to be placed inside - btn = QtGui.QPushButton('press me') - text = QtGui.QLineEdit('enter text') - listw = QtGui.QListWidget() + btn = QtWidgets.QPushButton('press me') + text = QtWidgets.QLineEdit('enter text') + listw = QtWidgets.QListWidget() plot = pg.PlotWidget() ## Create a grid layout to manage the widgets size and position - layout = QtGui.QGridLayout() + layout = QtWidgets.QGridLayout() w.setLayout(layout) ## Add widgets to the layout in their proper positions - layout.addWidget(btn, 0, 0) # button goes in upper-left - layout.addWidget(text, 1, 0) # text edit goes in middle-left + layout.addWidget(btn, 0, 0) # button goes in upper-left + layout.addWidget(text, 1, 0) # text edit goes in middle-left layout.addWidget(listw, 2, 0) # list widget goes in bottom-left layout.addWidget(plot, 0, 1, 3, 1) # plot goes on right side, spanning 3 rows - ## Display the widget as a new window w.show() ## Start the Qt event loop - app.exec_() + app.exec() # or app.exec_() for PyQt5 / PySide2 + More complex interfaces may be designed graphically using Qt Designer, which allows you to simply drag widgets into your window to define its appearance. diff --git a/doc/source/graphicsItems/make b/doc/source/graphicsItems/make deleted file mode 100644 index 14e62118e4..0000000000 --- a/doc/source/graphicsItems/make +++ /dev/null @@ -1,39 +0,0 @@ -files = """ArrowItem -AxisItem -ButtonItem -CurvePoint -DateAxisItem -GradientEditorItem -GradientLegend -GraphicsLayout -GraphicsObject -GraphicsWidget -GridItem -HistogramLUTItem -ImageItem -PColorMeshItem -InfiniteLine -LabelItem -LinearRegionItem -PlotCurveItem -PlotDataItem -ROI -ScaleBar -ScatterPlotItem -UIGraphicsItem -ViewBox -VTickGroup""".split('\n') - -for f in files: - print(f) - fh = open(f.lower()+'.rst', 'w') - fh.write( -"""%s -%s - -.. autoclass:: pyqtgraph.%s - :members: - - .. automethod:: pyqtgraph.%s.__init__ - -""" % (f, '='*len(f), f, f)) diff --git a/doc/source/graphicswindow.rst b/doc/source/graphicswindow.rst deleted file mode 100644 index 0602ae7ed2..0000000000 --- a/doc/source/graphicswindow.rst +++ /dev/null @@ -1,16 +0,0 @@ -Deprecated Window Classes -========================= - -.. automodule:: pyqtgraph.graphicsWindows - -.. autoclass:: pyqtgraph.GraphicsWindow - :members: - -.. autoclass:: pyqtgraph.TabWindow - :members: - -.. autoclass:: pyqtgraph.PlotWindow - :members: - -.. autoclass:: pyqtgraph.ImageWindow - :members: diff --git a/doc/source/images/index_api.svg b/doc/source/images/index_api.svg new file mode 100644 index 0000000000..87013d24ce --- /dev/null +++ b/doc/source/images/index_api.svg @@ -0,0 +1,97 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/doc/source/images/index_contribute.svg b/doc/source/images/index_contribute.svg new file mode 100644 index 0000000000..399f1d7630 --- /dev/null +++ b/doc/source/images/index_contribute.svg @@ -0,0 +1,76 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/doc/source/images/index_getting_started.svg b/doc/source/images/index_getting_started.svg new file mode 100644 index 0000000000..d1c7b08a2a --- /dev/null +++ b/doc/source/images/index_getting_started.svg @@ -0,0 +1,66 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/doc/source/images/index_user_guide.svg b/doc/source/images/index_user_guide.svg new file mode 100644 index 0000000000..bff2482423 --- /dev/null +++ b/doc/source/images/index_user_guide.svg @@ -0,0 +1,67 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/doc/source/images/overview_uml-dark_mode.graphml b/doc/source/images/overview_uml-dark_mode.graphml new file mode 100644 index 0000000000..5aa011f52a --- /dev/null +++ b/doc/source/images/overview_uml-dark_mode.graphml @@ -0,0 +1,1396 @@ + + + + + + + + + + + + + + + + + + + + + + + + + ImageView + + graphicsView +imageItem +view + + + + + + + + + + + + + + + QGraphicsView + + + scene() + + + + + + + + + + + + + QGraphicsScene + + + items() +views() + + + + + + + + + + + + + QGraphicsItem + + + scene() + + + + + + + + + + + + + QGraphicsObject + + + + + + + + + + + + + + + + GraphicsView + + sceneObj + + + + + + + + + + + + + + GraphicsObject + + graphicsView +imageItem +scene +view + + + + + + + + + + + + + + + ScatterPlotItem + + graphicsView +imageItem +scene +view + + + + + + + + + + + + + + + PlotCurveItem + + graphicsView +imageItem +scene +view + + + + + + + + + + + + + + + ImageItem + + graphicsView +imageItem +scene +view + + + + + + + + + + + + + + + PlotDataItem + + curve +scatter + + + + + + + + + + + + + + GraphicsLayoutWidget + + graphicsLayout + + + + + + + + + + + + + + GraphicsItem + + + + + + + + + + + + + + + + QGraphicsLayoutItem + + + + + + + + + + + + + + + + QGraphicsWidget + + + + + + + + + + + + + + + + QGraphicsLayout + + + + + + + + + + + + + + + + QGraphicsGridLayout + + + + + + + + + + + + + + + + PlotWidget + + plotItem + + + + + + + + + + + + + + GraphicsScene + + graphicsView +imageItem +scene +view + + + + + + + + + + + + + + + GraphicsWidget + + graphicsView +imageItem +scene +view + + + + + + + + + + + + + + + ViewBox + + + addItem(item) + + + + + + + + + + + + + GraphicsLayout + + layout + + + + + + + + + + + + + + PlotItem + + items +layout +vb + addItem(item) + + + + + + + + + + + + + QPaintDevice + + + + + + + + + + + + + + + + QWidget + + + + + + + + + + + + + + + + QObject + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/doc/source/images/overview_uml-dark_mode.svg b/doc/source/images/overview_uml-dark_mode.svg new file mode 100644 index 0000000000..79eae99823 --- /dev/null +++ b/doc/source/images/overview_uml-dark_mode.svg @@ -0,0 +1,1226 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ImageView + + + + + graphicsView + imageItem + view + + + + + + + + + + + + + + + QGraphicsView + + + + + + scene() + + + + + + + + + + + + + + QGraphicsScene + + + + + + items() + views() + + + + + + + + + + + + + + QGraphicsItem + + + + + + scene() + + + + + + + + + + + + + + QGraphicsObject + + + + + + + + + + + + + + + GraphicsView + + + + + sceneObj + + + + + + + + + + + + + + + GraphicsObject + + + + + + + + + + + + + + + ScatterPlotItem + + + + + + + + + + + + + + + PlotCurveItem + + + + + + + + + + + + + + + ImageItem + + + + + + + + + + + + + + + PlotDataItem + + + + + curve + scatter + + + + + + + + + + + + + + + GraphicsLayoutWidget + + + + + graphicsLayout + + + + + + + + + + + + + + + GraphicsItem + + + + + + + + + + + + + + + QGraphicsLayoutItem + + + + + + + + + + + + + + + QGraphicsWidget + + + + + + + + + + + + + + + QGraphicsLayout + + + + + + + + + + + + + + + QGraphicsGridLayout + + + + + + + + + + + + + + + PlotWidget + + + + + plotItem + + + + + + + + + + + + + + + GraphicsScene + + + + + + + + + + + + + + + GraphicsWidget + + + + + + + + + + + + + + + ViewBox + + + + + + addItem(item) + + + + + + + + + + + + + + GraphicsLayout + + + + + layout + + + + + + + + + + + + + + + PlotItem + + + + + items + layout + vb + + addItem(item) + + + + + + + + + + + + + + QPaintDevice + + + + + + + + + + + + + + + QWidget + + + + + + + + + + + + + + + QObject + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Widget used for display and analysis of image data. + + + + + + The QGraphicsView class provides a widget for + + displaying the contents of a QGraphicsScene. + + + + + + The QGraphicsScene class provides a surface for + + managing a large number of 2D graphical items. + + + + + + The QGraphicsItem class is the base class for all + + graphical items in a QGraphicsScene. + + + + + + The QGraphicsObject class provides a base class for all + + graphics items that require signals, slots and properties. + + + + + + Re-implementation of QGraphicsView that removes scrollbars and allows + + unambiguous control of the viewed coordinate range. + + + Also automatically creates a GraphicsScene and a central QGraphicsWidget + + that is automatically scaled to the full view geometry. + + + + + + Extension of QGraphicsObject with some useful + + methods (provided by GraphicsItem) + + + + + + Displays a set of x/y points. + + + Instances of this class are created automatically as part of + + PlotDataItem; these rarely need to be instantiated directly. + + + + + + Class representing a single plot curve. + + + Instances of this class are created automatically as part of + + PlotDataItem; these rarely need to be instantiated directly. + + + + + + GraphicsObject displaying an image. + + + + + + GraphicsItem for displaying plot curves, scatter plots, or both. + + + While it is possible to use PlotCurveItem or ScatterPlotItem + + individually, this class provides a unified interface to both. + + + + + + Convenience class consisting of a GraphicsView with a + + single GraphicsLayout as its central item. + + + Most of the methods provided by GraphicsLayout are + + also available through GraphicsLayoutWidget. + + + + + + Abstract class providing useful methods to GraphicsObject + + and GraphicsWidget. (This is required because we cannot + + have multiple inheritance with QObject subclasses.) + + + + + + The QGraphicsLayoutItem class can + + be inherited to allow your custom + + items to be managed by layouts. + + + + + + The QGraphicsWidget class is the base class for + + all widget items in a QGraphicsScene. + + + + + + The QGraphicsLayout class provides + + the base class for all layouts in + + Graphics View. + + + + + + The QGraphicsGridLayout class provides + + a grid layout for managing widgets in + + Graphics View. + + + + + + A subclass of GraphicsView with a single PlotItem displayed. + + + Most of the methods provided by PlotItem are also available + + through PlotWidget. + + + + + + Extension of QGraphicsScene that implements a + + complete, parallel mouse event system. + + + + + + Extends QGraphicsWidget with several helpful methods and + + workarounds for PyQt bugs. + + + Most of the extra functionality is inherited from GraphicsItem. + + + + + + Box that allows internal scaling/panning of children by mouse drag. + + + addItem() will add a graphics item (e.g. a PlotDataItem) to the scene. + + + This class is usually created automatically as part of a PlotItem or + + Canvas or with GraphicsLayout.addViewBox(). + + + + + + Used for laying out GraphicsWidgets + + in a grid. + + + This is usually created automatically + + as part of a GraphicsWindow or + + GraphicsLayoutWidget. + + + + + + GraphicsWidget implementing a standard 2D plotting + + area with axes. + + + addItem() will call ViewBox.addItem(), which will add + + the graphics item to the scene. + + + + + + The QPaintDevice class is the base class of objects that + + can be painted on with QPainter. + + + + + + The QWidget class is the base class of all user interface objects. + + + + + + The QObject class is the base class of all Qt objects. + + + + diff --git a/doc/source/images/overview_uml-light_mode.graphml b/doc/source/images/overview_uml-light_mode.graphml new file mode 100644 index 0000000000..98c7280635 --- /dev/null +++ b/doc/source/images/overview_uml-light_mode.graphml @@ -0,0 +1,1396 @@ + + + + + + + + + + + + + + + + + + + + + + + + + ImageView + + graphicsView +imageItem +view + + + + + + + + + + + + + + + QGraphicsView + + + scene() + + + + + + + + + + + + + QGraphicsScene + + + items() +views() + + + + + + + + + + + + + QGraphicsItem + + + scene() + + + + + + + + + + + + + QGraphicsObject + + + + + + + + + + + + + + + + GraphicsView + + sceneObj + + + + + + + + + + + + + + GraphicsObject + + graphicsView +imageItem +scene +view + + + + + + + + + + + + + + + ScatterPlotItem + + graphicsView +imageItem +scene +view + + + + + + + + + + + + + + + PlotCurveItem + + graphicsView +imageItem +scene +view + + + + + + + + + + + + + + + ImageItem + + graphicsView +imageItem +scene +view + + + + + + + + + + + + + + + PlotDataItem + + curve +scatter + + + + + + + + + + + + + + GraphicsLayoutWidget + + graphicsLayout + + + + + + + + + + + + + + GraphicsItem + + + + + + + + + + + + + + + + QGraphicsLayoutItem + + + + + + + + + + + + + + + + QGraphicsWidget + + + + + + + + + + + + + + + + QGraphicsLayout + + + + + + + + + + + + + + + + QGraphicsGridLayout + + + + + + + + + + + + + + + + PlotWidget + + plotItem + + + + + + + + + + + + + + GraphicsScene + + graphicsView +imageItem +scene +view + + + + + + + + + + + + + + + GraphicsWidget + + graphicsView +imageItem +scene +view + + + + + + + + + + + + + + + ViewBox + + + addItem(item) + + + + + + + + + + + + + GraphicsLayout + + layout + + + + + + + + + + + + + + PlotItem + + items +layout +vb + addItem(item) + + + + + + + + + + + + + QPaintDevice + + + + + + + + + + + + + + + + QWidget + + + + + + + + + + + + + + + + QObject + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/doc/source/images/overview_uml-light_mode.svg b/doc/source/images/overview_uml-light_mode.svg new file mode 100644 index 0000000000..d60f07ab62 --- /dev/null +++ b/doc/source/images/overview_uml-light_mode.svg @@ -0,0 +1,1226 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ImageView + + + + + graphicsView + imageItem + view + + + + + + + + + + + + + + + QGraphicsView + + + + + + scene() + + + + + + + + + + + + + + QGraphicsScene + + + + + + items() + views() + + + + + + + + + + + + + + QGraphicsItem + + + + + + scene() + + + + + + + + + + + + + + QGraphicsObject + + + + + + + + + + + + + + + GraphicsView + + + + + sceneObj + + + + + + + + + + + + + + + GraphicsObject + + + + + + + + + + + + + + + ScatterPlotItem + + + + + + + + + + + + + + + PlotCurveItem + + + + + + + + + + + + + + + ImageItem + + + + + + + + + + + + + + + PlotDataItem + + + + + curve + scatter + + + + + + + + + + + + + + + GraphicsLayoutWidget + + + + + graphicsLayout + + + + + + + + + + + + + + + GraphicsItem + + + + + + + + + + + + + + + QGraphicsLayoutItem + + + + + + + + + + + + + + + QGraphicsWidget + + + + + + + + + + + + + + + QGraphicsLayout + + + + + + + + + + + + + + + QGraphicsGridLayout + + + + + + + + + + + + + + + PlotWidget + + + + + plotItem + + + + + + + + + + + + + + + GraphicsScene + + + + + + + + + + + + + + + GraphicsWidget + + + + + + + + + + + + + + + ViewBox + + + + + + addItem(item) + + + + + + + + + + + + + + GraphicsLayout + + + + + layout + + + + + + + + + + + + + + + PlotItem + + + + + items + layout + vb + + addItem(item) + + + + + + + + + + + + + + QPaintDevice + + + + + + + + + + + + + + + QWidget + + + + + + + + + + + + + + + QObject + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Widget used for display and analysis of image data. + + + + + + The QGraphicsView class provides a widget for + + displaying the contents of a QGraphicsScene. + + + + + + The QGraphicsScene class provides a surface for + + managing a large number of 2D graphical items. + + + + + + The QGraphicsItem class is the base class for all + + graphical items in a QGraphicsScene. + + + + + + The QGraphicsObject class provides a base class for all + + graphics items that require signals, slots and properties. + + + + + + Re-implementation of QGraphicsView that removes scrollbars and allows + + unambiguous control of the viewed coordinate range. + + + Also automatically creates a GraphicsScene and a central QGraphicsWidget + + that is automatically scaled to the full view geometry. + + + + + + Extension of QGraphicsObject with some useful + + methods (provided by GraphicsItem) + + + + + + Displays a set of x/y points. + + + Instances of this class are created automatically as part of + + PlotDataItem; these rarely need to be instantiated directly. + + + + + + Class representing a single plot curve. + + + Instances of this class are created automatically as part of + + PlotDataItem; these rarely need to be instantiated directly. + + + + + + GraphicsObject displaying an image. + + + + + + GraphicsItem for displaying plot curves, scatter plots, or both. + + + While it is possible to use PlotCurveItem or ScatterPlotItem + + individually, this class provides a unified interface to both. + + + + + + Convenience class consisting of a GraphicsView with a + + single GraphicsLayout as its central item. + + + Most of the methods provided by GraphicsLayout are + + also available through GraphicsLayoutWidget. + + + + + + Abstract class providing useful methods to GraphicsObject + + and GraphicsWidget. (This is required because we cannot + + have multiple inheritance with QObject subclasses.) + + + + + + The QGraphicsLayoutItem class can + + be inherited to allow your custom + + items to be managed by layouts. + + + + + + The QGraphicsWidget class is the base class for + + all widget items in a QGraphicsScene. + + + + + + The QGraphicsLayout class provides + + the base class for all layouts in + + Graphics View. + + + + + + The QGraphicsGridLayout class provides + + a grid layout for managing widgets in + + Graphics View. + + + + + + A subclass of GraphicsView with a single PlotItem displayed. + + + Most of the methods provided by PlotItem are also available + + through PlotWidget. + + + + + + Extension of QGraphicsScene that implements a + + complete, parallel mouse event system. + + + + + + Extends QGraphicsWidget with several helpful methods and + + workarounds for PyQt bugs. + + + Most of the extra functionality is inherited from GraphicsItem. + + + + + + Box that allows internal scaling/panning of children by mouse drag. + + + addItem() will add a graphics item (e.g. a PlotDataItem) to the scene. + + + This class is usually created automatically as part of a PlotItem or + + Canvas or with GraphicsLayout.addViewBox(). + + + + + + Used for laying out GraphicsWidgets + + in a grid. + + + This is usually created automatically + + as part of a GraphicsWindow or + + GraphicsLayoutWidget. + + + + + + GraphicsWidget implementing a standard 2D plotting + + area with axes. + + + addItem() will call ViewBox.addItem(), which will add + + the graphics item to the scene. + + + + + + The QPaintDevice class is the base class of objects that + + can be painted on with QPainter. + + + + + + The QWidget class is the base class of all user interface objects. + + + + + + The QObject class is the base class of all Qt objects. + + + + diff --git a/doc/source/index.rst b/doc/source/index.rst index c24b55f41a..6d2ece9a94 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -1,37 +1,87 @@ -.. pyqtgraph documentation master file, created by - sphinx-quickstart on Fri Nov 18 19:33:12 2011. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. +:html_theme.sidebar_secondary.remove: -Welcome to the documentation for pyqtgraph -========================================== +PyQtGraph +========= -Contents: +A pure-python graphics and GUI library built on PyQt / PySide and numpy for use in mathematics / scientific / engineering applications. .. toctree:: - :maxdepth: 2 - - introduction - mouse_interaction - how_to_use - installation - qtcrashcourse - plotting - images - 3dgraphics - style - region_of_interest - exporting - prototyping - parametertree/index - flowchart/index - internals - apireference - - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` + :hidden: + :maxdepth: 1 + + getting_started/index + user_guide/index + developer_guide/index + api_reference/index + +.. grid:: 2 + + .. grid-item-card:: + :img-top: /images/index_getting_started.svg + + Getting started + ^^^^^^^^^^^^^^^ + + New to PyQtGraph? Check out the getting started guides. Content here includes introductions to + PyQtGraph concepts and capabilities, as well as basic tutorials. + + +++ + + .. button-ref:: getting_started_ref + :expand: + :color: success + :click-parent: + + To the getting started guides + + .. grid-item-card:: + :img-top: /images/index_user_guide.svg + + User guide + ^^^^^^^^^^ + + The user guide provides in-depth information on the key concepts of PyQtGraph. More complicated examples are presented and greater detail of the capabilities of the library are highlighted. Performance related considerations are detailed here. + + +++ + + .. button-ref:: user_guide_ref + :expand: + :color: success + :click-parent: + + To the user guide + + .. grid-item-card:: + :img-top: /images/index_api.svg + + API Reference + ^^^^^^^^^^^^^ + + The reference guide contains a detailed description of the PyQtGraph API. + + +++ + + .. button-ref:: pyqtgraph_api_ref + :expand: + :color: success + :click-parent: + + To the reference guide + + + .. grid-item-card:: + :img-top: /images/index_contribute.svg + + Developer guide + ^^^^^^^^^^^^^^^ + + Guide to contributing to PyQtGraph. Provides guidance on how to contribute to PyQtGraph and factors to be careful of when trying to remain compatible with all the Qt bindings that are supported. + + +++ + + .. button-ref:: developer_guide_ref + :expand: + :color: success + :click-parent: + + To the development guide diff --git a/doc/source/exporting.rst b/doc/source/user_guide/exporting.rst similarity index 100% rename from doc/source/exporting.rst rename to doc/source/user_guide/exporting.rst diff --git a/doc/source/user_guide/index.rst b/doc/source/user_guide/index.rst new file mode 100644 index 0000000000..c14ec716ed --- /dev/null +++ b/doc/source/user_guide/index.rst @@ -0,0 +1,15 @@ +.. _user_guide_ref: + +User Guide +========== + +This user guide provides an in-depth description of PyQtGraph features and how they apply to common scientific visualization tasks. +It is intended to be a reference for users who are already familiar with the basics of PyQtGraph. + +.. toctree:: + :maxdepth: 1 + + exporting + mouse_interaction + region_of_interest + style diff --git a/doc/source/mouse_interaction.rst b/doc/source/user_guide/mouse_interaction.rst similarity index 100% rename from doc/source/mouse_interaction.rst rename to doc/source/user_guide/mouse_interaction.rst diff --git a/doc/source/region_of_interest.rst b/doc/source/user_guide/region_of_interest.rst similarity index 100% rename from doc/source/region_of_interest.rst rename to doc/source/user_guide/region_of_interest.rst diff --git a/doc/source/style.rst b/doc/source/user_guide/style.rst similarity index 100% rename from doc/source/style.rst rename to doc/source/user_guide/style.rst diff --git a/doc/source/widgets/colormapwidget.rst b/doc/source/widgets/colormapwidget.rst deleted file mode 100644 index e6b3bb0bcb..0000000000 --- a/doc/source/widgets/colormapwidget.rst +++ /dev/null @@ -1,12 +0,0 @@ -ColorMapWidget -============== - -.. autoclass:: pyqtgraph.ColorMapWidget - :members: - - .. automethod:: pyqtgraph.ColorMapWidget.__init__ - - .. automethod:: pyqtgraph.widgets.ColorMapWidget.ColorMapParameter.setFields - - .. automethod:: pyqtgraph.widgets.ColorMapWidget.ColorMapParameter.map - diff --git a/doc/source/widgets/make b/doc/source/widgets/make deleted file mode 100644 index 1c7d379e7f..0000000000 --- a/doc/source/widgets/make +++ /dev/null @@ -1,31 +0,0 @@ -files = """CheckTable -ColorButton -DataTreeWidget -FileDialog -GradientWidget -GraphicsLayoutWidget -GraphicsView -HistogramLUTWidget -JoystickButton -MultiPlotWidget -PlotWidget -ProgressDialog -RawImageWidget -SpinBox -TableWidget -TreeWidget -VerticalLabel""".split('\n') - -for f in files: - print(f) - fh = open(f.lower()+'.rst', 'w') - fh.write( -"""%s -%s - -.. autoclass:: pyqtgraph.%s - :members: - - .. automethod:: pyqtgraph.%s.__init__ - -""" % (f, '='*len(f), f, f)) diff --git a/doc/uml_overview.txt b/doc/uml_overview.txt new file mode 100644 index 0000000000..b3e01caadd --- /dev/null +++ b/doc/uml_overview.txt @@ -0,0 +1,153 @@ +' Made in PlantUml. You can try it out here: http://plantuml.com/plantuml +' Based on commit eb7a60fcf83cd4e7a41ae5955e57935e39928fbd + +@startuml +hide empty members +hide circles +hide stereotypes + +skinparam class { + BorderColor Black + ArrowColor Black + + ' Using stereotypes to define the background colors + BackgroundColor<> #e5f2da + BackgroundColor<> #e5e5f4 +} +skinparam shadowing false + + +'----------- Qt package ----------` + + +class QGraphicsGridLayout <> + +class QGraphicsItem <> + +class QGraphicsLayout <> + +class QGraphicsLayoutItem <> + +class QGraphicsObject <> + +class QGraphicsScene <> { + items +} + +class QGraphicsWidget <> + +class QGraphicsView <> { + scene +} + +class QObject <> + +class QPaintDevice <> + +class QWidget <> + + +'----------- PyQtGraph package ----------` + + +class GraphicsItem <> + +class GraphicsLayout <> { + layout +} + +class GraphicsLayoutWidget <> { + graphicsLayout +} + +class GraphicsObject <> + +class GraphicsView <> + +class GraphicsWidget <> + +class ImageItem <> + +class ImageView <> { + graphicsView + imageItem + scene + view +} + +class PlotCurveItem <> + +class PlotDataItem <> { + curve + scatter +} + +class PlotItem <> { + layout + vb +} + +class PlotWidget <> { + plotItem +} + +class ScatterPlotItem <> + +class ViewBox <> + + +'---------- Inheritance within Qt ----------' +QObject <|-- QGraphicsObject +QGraphicsItem <|-- QGraphicsObject +QGraphicsObject <|-- QGraphicsWidget +QGraphicsLayoutItem <|-- QGraphicsWidget +QGraphicsLayoutItem <|-- QGraphicsLayout +QGraphicsLayout <|-- QGraphicsGridLayout +QPaintDevice <|-- QWidget +QObject <|-- QWidget +QObject <|-- QGraphicsScene +QWidget <|-- QGraphicsView + + +'---------- Inheritance from Qt to PyQtGraph ----------' +QGraphicsWidget <|-- GraphicsWidget +QGraphicsObject <|-- GraphicsObject +QGraphicsView <|-- GraphicsView +QWidget <|-- ImageView + + +'---------- Inheritance within PyQtGraph ----------' +GraphicsItem <|-- GraphicsObject +GraphicsItem <|-- GraphicsWidget +GraphicsWidget <|-- GraphicsLayout +GraphicsWidget <|-- PlotItem +GraphicsWidget <|-- ViewBox +GraphicsObject <|-- ScatterPlotItem +GraphicsObject <|-- PlotCurveItem +GraphicsObject <|-- ImageItem +GraphicsObject <|-- PlotDataItem +GraphicsView <|-- PlotWidget +GraphicsView <|-- GraphicsLayoutWidget + + +'---------- Aggregation ----------' + +' Shorter arrow so at same height in the diagram +QGraphicsScene::items o- QGraphicsItem #b8b8b8 +QGraphicsView::scene o- QGraphicsScene #b8b8b8 + +' Longer (regular) arrows +PlotWidget::plotItem o-- PlotItem #b8b8b8 +GraphicsLayoutWidget::graphicsLayout o-- GraphicsLayout #b8b8b8 +PlotDataItem::curve o-- PlotCurveItem #b8b8b8 +PlotDataItem::scatter o-- ScatterPlotItem #b8b8b8 +PlotItem::vb o-- ViewBox #b8b8b8 +PlotItem::layout o-- QGraphicsGridLayout #b8b8b8 +GraphicsLayout::layout o-- QGraphicsGridLayout #b8b8b8 +ImageView::graphicsView o-- GraphicsView #b8b8b8 +ImageView::imageItem o-- ImageItem #b8b8b8 +ImageView::scene o-- QGraphicsScene #b8b8b8 +ImageView::view o-- ViewBox #b8b8b8 + + +@enduml diff --git a/pyqtgraph/GraphicsScene/GraphicsScene.py b/pyqtgraph/GraphicsScene/GraphicsScene.py index 5378476fd4..e3344b5d18 100644 --- a/pyqtgraph/GraphicsScene/GraphicsScene.py +++ b/pyqtgraph/GraphicsScene/GraphicsScene.py @@ -35,7 +35,7 @@ class GraphicsScene(QtWidgets.QGraphicsScene): sigMouseClicked(event) Emitted when the mouse is clicked over the scene. Use ev.pos() to get the click position relative to the item that was clicked on, or ev.scenePos() to get the click position in scene coordinates. - See :class:`pyqtgraph.GraphicsScene.MouseClickEvent`. + See :class:`pyqtgraph.GraphicsScene.mouseEvents.MouseClickEvent`. sigMouseMoved(pos) Emitted when the mouse cursor moves over the scene. The position is given in scene coordinates. sigMouseHover(items) Emitted when the mouse is moved over the scene. Items is a list @@ -83,13 +83,6 @@ class GraphicsScene(QtWidgets.QGraphicsScene): _addressCache = weakref.WeakValueDictionary() ExportDirectory = None - - @classmethod - def registerObject(cls, obj): - warnings.warn( - "'registerObject' is deprecated and does nothing.", - DeprecationWarning, stacklevel=2 - ) def __init__(self, clickRadius=2, moveDistance=5, parent=None): QtWidgets.QGraphicsScene.__init__(self, parent) @@ -228,10 +221,18 @@ def mouseReleaseEvent(self, ev): cev = [e for e in self.clickEvents if e.button() == ev.button()] if cev: if self.sendClickEvent(cev[0]): - #print "sent click event" ev.accept() - self.clickEvents.remove(cev[0]) - + try: + self.clickEvents.remove(cev[0]) + except ValueError: + warnings.warn( + ("A ValueError can occur here with errant " + "QApplication.processEvent() calls, see " + "https://github.com/pyqtgraph/pyqtgraph/pull/2580 " + "for more information."), + RuntimeWarning, + stacklevel=2 + ) if not ev.buttons(): self.dragItem = None self.dragButtons = [] @@ -396,66 +397,77 @@ def removeItem(self, item): ret = QtWidgets.QGraphicsScene.removeItem(self, item) self.sigItemRemoved.emit(item) return ret - - def itemsNearEvent(self, event, selMode=QtCore.Qt.ItemSelectionMode.IntersectsItemShape, sortOrder=QtCore.Qt.SortOrder.DescendingOrder, hoverable=False): + + def itemsNearEvent( + self, + event, + selMode=QtCore.Qt.ItemSelectionMode.IntersectsItemShape, + sortOrder=QtCore.Qt.SortOrder.DescendingOrder, + hoverable=False, + ): """ Return an iterator that iterates first through the items that directly intersect point (in Z order) followed by any other items that are within the scene's click radius. """ - #tr = self.getViewWidget(event.widget()).transform() view = self.views()[0] tr = view.viewportTransform() - - if hasattr(event, 'buttonDownScenePos'): + + if hasattr(event, "buttonDownScenePos"): point = event.buttonDownScenePos() else: point = event.scenePos() - items = self.items(point, selMode, sortOrder, tr) - - ## remove items whose shape does not contain point (scene.items() apparently sucks at this) - items2 = [] - for item in items: - if hoverable and not hasattr(item, 'hoverEvent'): - continue - if item.scene() is not self: - continue - shape = item.shape() # Note: default shape() returns boundingRect() - if shape is None: - continue - if shape.contains(item.mapFromScene(point)): - items2.append(item) - ## Sort by descending Z-order (don't trust scene.itms() to do this either) ## use 'absolute' z value, which is the sum of all item/parent ZValues def absZValue(item): if item is None: return 0 return item.zValue() + absZValue(item.parentItem()) - - items2.sort(key=absZValue, reverse=True) - - return items2 - - #seen = set() - #r = self._clickRadius - #rect = view.mapToScene(QtCore.QRect(0, 0, 2*r, 2*r)).boundingRect() - #w = rect.width() - #h = rect.height() - #rgn = QtCore.QRectF(point.x()-w, point.y()-h, 2*w, 2*h) - #self.searchRect.setRect(rgn) - - #for item in items: - ##seen.add(item) - - #shape = item.mapToScene(item.shape()) - #if not shape.contains(point): - #continue - #yield item - #for item in self.items(rgn, selMode, sortOrder, tr): - ##if item not in seen: - #yield item - + + ## Get items, which directly are at the given point (sorted by z-value) + items_at_point = self.items(point, selMode, sortOrder, tr) + items_at_point.sort(key=absZValue, reverse=True) + + ## Get items, which are within the click radius around the given point (sorted by z-value) + r = self._clickRadius + items_within_radius = [] + rgn = None + if r > 0.0: + rect = view.mapToScene(QtCore.QRect(0, 0, 2 * r, 2 * r)).boundingRect() + w = rect.width() + h = rect.height() + rgn = QtCore.QRectF(point.x() - w / 2, point.y() - h / 2, w, h) + items_within_radius = self.items(rgn, selMode, sortOrder, tr) + items_within_radius.sort(key=absZValue, reverse=True) + # Remove items, which are already in the other list + for item in items_at_point: + if item in items_within_radius: + items_within_radius.remove(item) + + ## Put both groups of items together, but in the correct order + ## The items directly at the given point shall have higher priority + all_items = items_at_point + items_within_radius + + ## Remove items, which we don't want, due to several reasons + selected_items = [] + for item in all_items: + if hoverable and not hasattr(item, "hoverEvent"): + continue + if item.scene() is not self: + continue + shape = item.shape() # Note: default shape() returns boundingRect() + if shape is None: + continue + # Remove items whose shape does not contain point or region + # (scene.items() apparently sucks at this) + if ( + rgn is not None + and shape.intersects(item.mapFromScene(rgn).boundingRect()) + ) or shape.contains(item.mapFromScene(point)): + selected_items.append(item) + + return selected_items + def getViewWidget(self): return self.views()[0] diff --git a/pyqtgraph/GraphicsScene/exportDialog.py b/pyqtgraph/GraphicsScene/exportDialog.py index 870123334b..c39a6d760b 100644 --- a/pyqtgraph/GraphicsScene/exportDialog.py +++ b/pyqtgraph/GraphicsScene/exportDialog.py @@ -49,13 +49,12 @@ def show(self, item=None): self.activateWindow() self.raise_() self.selectBox.setVisible(True) - if not self.shown: self.shown = True vcenter = self.scene.getViewWidget().geometry().center() - self.setGeometry(int(vcenter.x() - self.width() / 2), - int(vcenter.y() - self.height() / 2), - self.width(), self.height()) + x = max(0, int(vcenter.x() - self.width() / 2)) + y = max(0, int(vcenter.y() - self.height() / 2)) + self.move(x, y) def updateItemList(self, select=None): self.ui.itemTree.clear() @@ -105,7 +104,7 @@ def updateFormatList(self): for exp in exporters.listExporters(): item = FormatExportListWidgetItem(exp, QtCore.QCoreApplication.translate('Exporter', exp.Name)) self.ui.formatList.addItem(item) - if item == current: + if item is current: self.ui.formatList.setCurrentRow(self.ui.formatList.count() - 1) gotCurrent = True diff --git a/pyqtgraph/GraphicsScene/exportDialogTemplate_generic.py b/pyqtgraph/GraphicsScene/exportDialogTemplate_generic.py index 48db23583f..a7b1806102 100644 --- a/pyqtgraph/GraphicsScene/exportDialogTemplate_generic.py +++ b/pyqtgraph/GraphicsScene/exportDialogTemplate_generic.py @@ -5,7 +5,6 @@ # WARNING: Any manual changes made to this file will be lost when pyuic6 is # run again. Do not edit this file unless you know what you are doing. - from ..Qt import QtCore, QtGui, QtWidgets diff --git a/pyqtgraph/Point.py b/pyqtgraph/Point.py index c90487168f..ac02198763 100644 --- a/pyqtgraph/Point.py +++ b/pyqtgraph/Point.py @@ -134,6 +134,7 @@ def dot(self, a): return Point.dotProduct(self, a) def cross(self, a): + """Returns the cross product of a and this Point""" if not isinstance(a, QtCore.QPointF): a = Point(a) return self.x() * a.y() - self.y() * a.x() diff --git a/pyqtgraph/Qt/__init__.py b/pyqtgraph/Qt/__init__.py index 040718d384..ebc60d969f 100644 --- a/pyqtgraph/Qt/__init__.py +++ b/pyqtgraph/Qt/__init__.py @@ -5,7 +5,6 @@ * Allow you to import QtCore/QtGui from pyqtgraph.Qt without specifying which Qt wrapper you want to use. """ - import os import re import subprocess @@ -34,7 +33,7 @@ ## is already imported. If not, then attempt to import in the order ## specified in libOrder. if QT_LIB is None: - libOrder = [PYQT5, PYSIDE2, PYSIDE6, PYQT6] + libOrder = [PYQT6, PYSIDE6, PYQT5, PYSIDE2] for lib in libOrder: if lib in sys.modules: @@ -43,8 +42,9 @@ if QT_LIB is None: for lib in libOrder: + qt = lib + '.QtCore' try: - __import__(lib) + __import__(qt) QT_LIB = lib break except ImportError: @@ -105,7 +105,7 @@ def _loadUiType(uiFile): if pyside2uic is None: pyside2version = tuple(map(int, PySide2.__version__.split("."))) if (5, 14) <= pyside2version < (5, 14, 2, 2): - warnings.warn('For UI compilation, it is recommended to upgrade to PySide >= 5.15') + warnings.warn('For UI compilation, it is recommended to upgrade to PySide >= 5.15', RuntimeWarning, stacklevel=2) # get class names from ui file import xml.etree.ElementTree as xml @@ -251,97 +251,6 @@ def _copy_attrs(src, dst): raise ValueError("Invalid Qt lib '%s'" % QT_LIB) -# common to PyQt5, PyQt6, PySide2 and PySide6 -if QT_LIB in [PYQT5, PYQT6, PYSIDE2, PYSIDE6]: - # We're using Qt5 which has a different structure so we're going to use a shim to - # recreate the Qt4 structure - - if QT_LIB in [PYQT5, PYSIDE2]: - __QGraphicsItem_scale = QtWidgets.QGraphicsItem.scale - - def scale(self, *args): - warnings.warn( - "Deprecated Qt API, will be removed in 0.13.0.", - DeprecationWarning, stacklevel=2 - ) - if args: - sx, sy = args - tr = self.transform() - tr.scale(sx, sy) - self.setTransform(tr) - else: - return __QGraphicsItem_scale(self) - QtWidgets.QGraphicsItem.scale = scale - - def rotate(self, angle): - warnings.warn( - "Deprecated Qt API, will be removed in 0.13.0.", - DeprecationWarning, stacklevel=2 - ) - tr = self.transform() - tr.rotate(angle) - self.setTransform(tr) - QtWidgets.QGraphicsItem.rotate = rotate - - def translate(self, dx, dy): - warnings.warn( - "Deprecated Qt API, will be removed in 0.13.0.", - DeprecationWarning, stacklevel=2 - ) - tr = self.transform() - tr.translate(dx, dy) - self.setTransform(tr) - QtWidgets.QGraphicsItem.translate = translate - - def setMargin(self, i): - warnings.warn( - "Deprecated Qt API, will be removed in 0.13.0.", - DeprecationWarning, stacklevel=2 - ) - self.setContentsMargins(i, i, i, i) - QtWidgets.QGridLayout.setMargin = setMargin - - def setResizeMode(self, *args): - warnings.warn( - "Deprecated Qt API, will be removed in 0.13.0.", - DeprecationWarning, stacklevel=2 - ) - self.setSectionResizeMode(*args) - QtWidgets.QHeaderView.setResizeMode = setResizeMode - - # Import all QtWidgets objects into QtGui - _fallbacks = dir(QtWidgets) - - def lazyGetattr(name): - if not (name in _fallbacks and name.startswith('Q')): - raise AttributeError(f"module 'QtGui' has no attribute '{name}'") - # This whitelist is attrs which are not shared between PyQt6.QtGui and PyQt5.QtGui, but which can be found on - # one of the QtWidgets. - whitelist = [ - "QAction", - "QActionGroup", - "QFileSystemModel", - "QPagedPaintDevice", - "QPaintEvent", - "QShortcut", - "QUndoCommand", - "QUndoGroup", - "QUndoStack", - ] - if name not in whitelist: - warnings.warn( - "Accessing pyqtgraph.QtWidgets through QtGui is deprecated and will be removed sometime" - f" after May 2022. Use QtWidgets.{name} instead.", - DeprecationWarning, stacklevel=2 - ) - attr = getattr(QtWidgets, name) - setattr(QtGui, name, attr) - return attr - - QtGui.__getattr__ = lazyGetattr - - QtWidgets.QApplication.setGraphicsSystem = None - if QT_LIB in [PYQT6, PYSIDE6]: # We're using Qt6 which has a different structure so we're going to use a shim to @@ -350,6 +259,26 @@ def lazyGetattr(name): if not isinstance(QtOpenGLWidgets, FailedImport): QtWidgets.QOpenGLWidget = QtOpenGLWidgets.QOpenGLWidget + # PySide6 incorrectly placed QFileSystemModel inside QtWidgets + if QT_LIB == PYSIDE6 and hasattr(QtWidgets, 'QFileSystemModel'): + module = getattr(QtWidgets, "QFileSystemModel") + setattr(QtGui, "QFileSystemModel", module) + +else: + # Shim Qt5 namespace to match Qt6 + module_whitelist = [ + "QAction", + "QActionGroup", + "QFileSystemModel", + "QShortcut", + "QUndoCommand", + "QUndoGroup", + "QUndoStack", + ] + for module in module_whitelist: + attr = getattr(QtWidgets, module) + setattr(QtGui, module, attr) + # Common to PySide2 and PySide6 if QT_LIB in [PYSIDE2, PYSIDE6]: @@ -398,17 +327,16 @@ def isQObjectAlive(obj): from . import internals -# USE_XXX variables are deprecated -USE_PYSIDE = QT_LIB == PYSIDE -USE_PYQT4 = QT_LIB == PYQT4 -USE_PYQT5 = QT_LIB == PYQT5 - -## Make sure we have Qt >= 5.12 -versionReq = [5, 12] +# Alert user if using Qt < 5.15, but do not raise exception +versionReq = [5, 15] m = re.match(r'(\d+)\.(\d+).*', QtVersion) if m is not None and list(map(int, m.groups())) < versionReq: - print(list(map(int, m.groups()))) - raise Exception('pyqtgraph requires Qt version >= %d.%d (your version is %s)' % (versionReq[0], versionReq[1], QtVersion)) + warnings.warn( + f"PyQtGraph supports Qt version >= {versionReq[0]}.{versionReq[1]}," + f" but {QtVersion} detected.", + RuntimeWarning, + stacklevel=2 + ) App = QtWidgets.QApplication # subclassing QApplication causes segfaults on PySide{2, 6} / Python 3.8.7+ diff --git a/pyqtgraph/Qt/internals.py b/pyqtgraph/Qt/internals.py index 3ef44c86c4..02623a7711 100644 --- a/pyqtgraph/Qt/internals.py +++ b/pyqtgraph/Qt/internals.py @@ -1,10 +1,19 @@ import ctypes +import itertools + import numpy as np -from . import QT_LIB -from . import compat + +from . import QT_LIB, QtCore, QtGui, compat __all__ = ["get_qpainterpath_element_array"] +if QT_LIB.startswith('PyQt'): + from . import sip +elif QT_LIB == 'PySide2': + from PySide2 import __version_info__ as pyside_version_info +elif QT_LIB == 'PySide6': + from PySide6 import __version_info__ as pyside_version_info + class QArrayDataQt5(ctypes.Structure): _fields_ = [ ("ref", ctypes.c_int), @@ -69,3 +78,154 @@ def get_qpainterpath_element_array(qpath, nelems=None): vp = compat.voidptr(eptr, itemsize*nelems, writable) return np.frombuffer(vp, dtype=dtype) + +class PrimitiveArray: + # Note: This class is an internal implementation detail and is not part + # of the public API. + # + # QPainter has a C++ native API that takes an array of objects: + # drawPrimitives(const Primitive *array, int count, ...) + # where "Primitive" is one of QPointF, QLineF, QRectF, PixmapFragment + # + # PySide (with the exception of drawPixmapFragments) and older PyQt + # require a Python list of "Primitive" instances to be provided to + # the respective "drawPrimitives" method. + # + # This is inefficient because: + # 1) constructing the Python list involves calling wrapinstance multiple times. + # - this is mitigated here by reusing the instance pointers + # 2) The binding will anyway have to repack the instances into a contiguous array, + # in order to call the underlying C++ native API. + # + # Newer PyQt provides sip.array, which is more efficient. + # + # PySide's drawPixmapFragments() takes an instance to the first item of a + # C array of PixmapFragment(s) _and_ the length of the array. + # There is no overload that takes a Python list of PixmapFragment(s). + + def __init__(self, Klass, nfields, *, use_array=None): + self._Klass = Klass + self._nfields = nfields + self._capa = -1 + + self.use_sip_array = False + self.use_ptr_to_array = False + + if QT_LIB.startswith('PyQt'): + if use_array is None: + use_array = ( + hasattr(sip, 'array') and + ( + (0x60301 <= QtCore.PYQT_VERSION) or + (0x50f07 <= QtCore.PYQT_VERSION < 0x60000) + ) + ) + self.use_sip_array = use_array + + if QT_LIB.startswith('PySide'): + if use_array is None: + use_array = ( + Klass is QtGui.QPainter.PixmapFragment + or pyside_version_info >= (6, 4, 3) + ) + self.use_ptr_to_array = use_array + + self.resize(0) + + def resize(self, size): + if self.use_sip_array: + # For reference, SIP_VERSION 6.7.8 first arrived + # in PyQt5_sip 12.11.2 and PyQt6_sip 13.4.2 + if sip.SIP_VERSION >= 0x60708: + if size <= self._capa: + self._size = size + return + else: + # sip.array prior to SIP_VERSION 6.7.8 had a + # buggy slicing implementation. + # so trigger a reallocate for any different size + if size == self._capa: + return + + self._siparray = sip.array(self._Klass, size) + + else: + if size <= self._capa: + self._size = size + return + self._ndarray = np.empty((size, self._nfields), dtype=np.float64) + + if self.use_ptr_to_array: + # defer creation + self._objs = None + else: + self._objs = self._wrap_instances(self._ndarray) + + self._capa = size + self._size = size + + def _wrap_instances(self, array): + return list(map(compat.wrapinstance, + itertools.count(array.ctypes.data, array.strides[0]), + itertools.repeat(self._Klass, array.shape[0]))) + + def __len__(self): + return self._size + + def ndarray(self): + # ndarray views are cheap to recreate each time + if self.use_sip_array: + if ( + sip.SIP_VERSION >= 0x60708 and + np.__version__ != "1.22.4" # TODO: remove me after numpy 1.23+ + ): # workaround for numpy/sip compatability issue + # see https://github.com/numpy/numpy/issues/21612 + mv = self._siparray + else: + # sip.array prior to SIP_VERSION 6.7.8 had a buggy buffer protocol + # that set the wrong size. + # workaround it by going through a sip.voidptr + mv = sip.voidptr(self._siparray, self._capa*self._nfields*8) + # note that we perform the slicing by using only _size rows + nd = np.frombuffer(mv, dtype=np.float64, count=self._size*self._nfields) + return nd.reshape((-1, self._nfields)) + else: + return self._ndarray[:self._size] + + def instances(self): + # this returns an iterable container of Klass instances. + # for "use_ptr_to_array" mode, such a container may not + # be required at all, so its creation is deferred + if self.use_sip_array: + if self._size == self._capa: + # avoiding slicing when it's not necessary + # handles the case where sip.array had a buggy + # slicing implementation + return self._siparray + else: + # this is a view + return self._siparray[:self._size] + + if self._objs is None: + self._objs = self._wrap_instances(self._ndarray) + + if self._size == self._capa: + return self._objs + else: + # this is a shallow copy + return self._objs[:self._size] + + def drawargs(self): + # returns arguments to apply to the respective drawPrimitives() functions + if self.use_ptr_to_array: + if self._capa > 0: + # wrap memory only if it is safe to do so + ptr = compat.wrapinstance(self._ndarray.ctypes.data, self._Klass) + else: + # shiboken translates None <--> nullptr + # alternatively, we could instantiate a dummy _Klass() + ptr = None + return ptr, self._size + + else: + return self.instances(), diff --git a/pyqtgraph/SRTTransform.py b/pyqtgraph/SRTTransform.py index 6170a92ba2..3d0aac7ea2 100644 --- a/pyqtgraph/SRTTransform.py +++ b/pyqtgraph/SRTTransform.py @@ -1,10 +1,10 @@ -import warnings from math import atan2, degrees import numpy as np from .Point import Point -from .Qt import QtCore, QtGui, QtWidgets +from .Qt import QtGui +from . import SRTTransform3D class SRTTransform(QtGui.QTransform): @@ -37,14 +37,6 @@ def __init__(self, init=None): def getScale(self): return self._state['scale'] - def getAngle(self): - warnings.warn( - 'SRTTransform.getAngle() is deprecated, use SRTTransform.getRotation() instead' - 'will be removed in 0.13', - DeprecationWarning, stacklevel=2 - ) - return self.getRotation() - def getRotation(self): return self._state['angle'] @@ -83,7 +75,7 @@ def setFromQTransform(self, tr): self.update() def setFromMatrix4x4(self, m): - m = SRTTransform3D(m) + m = SRTTransform3D.SRTTransform3D(m) angle, axis = m.getRotation() if angle != 0 and (axis[0] != 0 or axis[1] != 0 or axis[2] != 1): print("angle: %s axis: %s" % (str(angle), str(axis))) @@ -172,96 +164,3 @@ def __repr__(self): def matrix(self): return np.array([[self.m11(), self.m12(), self.m13()],[self.m21(), self.m22(), self.m23()],[self.m31(), self.m32(), self.m33()]]) - - -if __name__ == '__main__': - import GraphicsView - - from . import widgets - from .functions import * - app = pg.mkQApp() # noqa: qapp stored to avoid gc - win = QtWidgets.QMainWindow() - win.show() - cw = GraphicsView.GraphicsView() - #cw.enableMouse() - win.setCentralWidget(cw) - s = QtWidgets.QGraphicsScene() - cw.setScene(s) - win.resize(600,600) - cw.enableMouse() - cw.setRange(QtCore.QRectF(-100., -100., 200., 200.)) - - class Item(QtWidgets.QGraphicsItem): - def __init__(self): - QtWidgets.QGraphicsItem.__init__(self) - self.b = QtWidgets.QGraphicsRectItem(20, 20, 20, 20, self) - self.b.setPen(QtGui.QPen(mkPen('y'))) - self.t1 = QtWidgets.QGraphicsTextItem(self) - self.t1.setHtml('R') - self.t1.translate(20, 20) - self.l1 = QtWidgets.QGraphicsLineItem(10, 0, -10, 0, self) - self.l2 = QtWidgets.QGraphicsLineItem(0, 10, 0, -10, self) - self.l1.setPen(QtGui.QPen(mkPen('y'))) - self.l2.setPen(QtGui.QPen(mkPen('y'))) - def boundingRect(self): - return QtCore.QRectF() - def paint(self, *args): - pass - - #s.addItem(b) - #s.addItem(t1) - item = Item() - s.addItem(item) - l1 = QtWidgets.QGraphicsLineItem(10, 0, -10, 0) - l2 = QtWidgets.QGraphicsLineItem(0, 10, 0, -10) - l1.setPen(QtGui.QPen(mkPen('r'))) - l2.setPen(QtGui.QPen(mkPen('r'))) - s.addItem(l1) - s.addItem(l2) - - tr1 = SRTTransform() - tr2 = SRTTransform() - tr3 = QtGui.QTransform() - tr3.translate(20, 0) - tr3.rotate(45) - print("QTransform -> Transform:", SRTTransform(tr3)) - - print("tr1:", tr1) - - tr2.translate(20, 0) - tr2.rotate(45) - print("tr2:", tr2) - - dt = tr2/tr1 - print("tr2 / tr1 = ", dt) - - print("tr2 * tr1 = ", tr2*tr1) - - tr4 = SRTTransform() - tr4.scale(-1, 1) - tr4.rotate(30) - print("tr1 * tr4 = ", tr1*tr4) - - w1 = widgets.TestROI((19,19), (22, 22), invertible=True) - #w2 = widgets.TestROI((0,0), (150, 150)) - w1.setZValue(10) - s.addItem(w1) - #s.addItem(w2) - w1Base = w1.getState() - #w2Base = w2.getState() - def update(): - tr1 = w1.getGlobalTransform(w1Base) - #tr2 = w2.getGlobalTransform(w2Base) - item.setTransform(tr1) - - #def update2(): - #tr1 = w1.getGlobalTransform(w1Base) - #tr2 = w2.getGlobalTransform(w2Base) - #t1.setTransform(tr1) - #w1.setState(w1Base) - #w1.applyGlobalTransform(tr2) - - w1.sigRegionChanged.connect(update) - #w2.sigRegionChanged.connect(update2) - -from .SRTTransform3D import SRTTransform3D diff --git a/pyqtgraph/SRTTransform3D.py b/pyqtgraph/SRTTransform3D.py index 0dadaa488b..59dd78429c 100644 --- a/pyqtgraph/SRTTransform3D.py +++ b/pyqtgraph/SRTTransform3D.py @@ -2,9 +2,10 @@ import numpy as np -from .Qt import QtCore, QtGui, QtWidgets +from .Qt import QtGui from .Transform3D import Transform3D from .Vector import Vector +from . import SRTTransform class SRTTransform3D(Transform3D): @@ -17,7 +18,7 @@ def __init__(self, init=None): if init is None: return if init.__class__ is QtGui.QTransform: - init = SRTTransform(init) + init = SRTTransform.SRTTransform(init) if isinstance(init, dict): self.restoreState(init) @@ -29,7 +30,7 @@ def __init__(self, init=None): 'axis': Vector(init._state['axis']), } self.update() - elif isinstance(init, SRTTransform): + elif isinstance(init, SRTTransform.SRTTransform): self._state = { 'pos': Vector(init._state['pos']), 'scale': Vector(init._state['scale']), @@ -172,15 +173,15 @@ def setFromMatrix(self, m): def as2D(self): """Return a QTransform representing the x,y portion of this transform (if possible)""" - return SRTTransform(self) + return SRTTransform.SRTTransform(self) #def __div__(self, t): #"""A / B == B^-1 * A""" #dt = t.inverted()[0] * self - #return SRTTransform(dt) + #return SRTTransform.SRTTransform(dt) #def __mul__(self, t): - #return SRTTransform(QtGui.QTransform.__mul__(self, t)) + #return SRTTransform.SRTTransform(QtGui.QTransform.__mul__(self, t)) def saveState(self): p = self._state['pos'] @@ -224,95 +225,3 @@ def matrix(self, nd=3): return m[:3,:3] else: raise Exception("Argument 'nd' must be 2 or 3") - -if __name__ == '__main__': - import GraphicsView - - from . import widgets - from .functions import * - app = pg.mkQApp() # noqa: qapp must be stored to avoid gc - win = QtWidgets.QMainWindow() - win.show() - cw = GraphicsView.GraphicsView() - #cw.enableMouse() - win.setCentralWidget(cw) - s = QtWidgets.QGraphicsScene() - cw.setScene(s) - win.resize(600,600) - cw.enableMouse() - cw.setRange(QtCore.QRectF(-100., -100., 200., 200.)) - - class Item(QtWidgets.QGraphicsItem): - def __init__(self): - QtWidgets.QGraphicsItem.__init__(self) - self.b = QtWidgets.QGraphicsRectItem(20, 20, 20, 20, self) - self.b.setPen(QtGui.QPen(mkPen('y'))) - self.t1 = QtWidgets.QGraphicsTextItem(self) - self.t1.setHtml('R') - self.t1.translate(20, 20) - self.l1 = QtWidgets.QGraphicsLineItem(10, 0, -10, 0, self) - self.l2 = QtWidgets.QGraphicsLineItem(0, 10, 0, -10, self) - self.l1.setPen(QtGui.QPen(mkPen('y'))) - self.l2.setPen(QtGui.QPen(mkPen('y'))) - def boundingRect(self): - return QtCore.QRectF() - def paint(self, *args): - pass - - #s.addItem(b) - #s.addItem(t1) - item = Item() - s.addItem(item) - l1 = QtWidgets.QGraphicsLineItem(10, 0, -10, 0) - l2 = QtWidgets.QGraphicsLineItem(0, 10, 0, -10) - l1.setPen(QtGui.QPen(mkPen('r'))) - l2.setPen(QtGui.QPen(mkPen('r'))) - s.addItem(l1) - s.addItem(l2) - - tr1 = SRTTransform() - tr2 = SRTTransform() - tr3 = QtGui.QTransform() - tr3.translate(20, 0) - tr3.rotate(45) - print("QTransform -> Transform: %s" % str(SRTTransform(tr3))) - - print("tr1: %s" % str(tr1)) - - tr2.translate(20, 0) - tr2.rotate(45) - print("tr2: %s" % str(tr2)) - - dt = tr2/tr1 - print("tr2 / tr1 = %s" % str(dt)) - - print("tr2 * tr1 = %s" % str(tr2*tr1)) - - tr4 = SRTTransform() - tr4.scale(-1, 1) - tr4.rotate(30) - print("tr1 * tr4 = %s" % str(tr1*tr4)) - - w1 = widgets.TestROI((19,19), (22, 22), invertible=True) - #w2 = widgets.TestROI((0,0), (150, 150)) - w1.setZValue(10) - s.addItem(w1) - #s.addItem(w2) - w1Base = w1.getState() - #w2Base = w2.getState() - def update(): - tr1 = w1.getGlobalTransform(w1Base) - #tr2 = w2.getGlobalTransform(w2Base) - item.setTransform(tr1) - - #def update2(): - #tr1 = w1.getGlobalTransform(w1Base) - #tr2 = w2.getGlobalTransform(w2Base) - #t1.setTransform(tr1) - #w1.setState(w1Base) - #w1.applyGlobalTransform(tr2) - - w1.sigRegionChanged.connect(update) - #w2.sigRegionChanged.connect(update2) - -from .SRTTransform import SRTTransform diff --git a/pyqtgraph/SignalProxy.py b/pyqtgraph/SignalProxy.py index 1c6012f3dd..5f31b25cc1 100644 --- a/pyqtgraph/SignalProxy.py +++ b/pyqtgraph/SignalProxy.py @@ -1,39 +1,42 @@ import weakref from time import perf_counter -from . import ThreadsafeTimer from .functions import SignalBlock from .Qt import QtCore +from .ThreadsafeTimer import ThreadsafeTimer __all__ = ['SignalProxy'] class SignalProxy(QtCore.QObject): """Object which collects rapid-fire signals and condenses them - into a single signal or a rate-limited stream of signals. - Used, for example, to prevent a SpinBox from generating multiple + into a single signal or a rate-limited stream of signals. + Used, for example, to prevent a SpinBox from generating multiple signals when the mouse wheel is rolled over it. - + Emits sigDelayed after input signals have stopped for a certain period of time. """ sigDelayed = QtCore.Signal(object) - def __init__(self, signal, delay=0.3, rateLimit=0, slot=None): + def __init__(self, signal, delay=0.3, rateLimit=0, slot=None, *, threadSafe=True): """Initialization arguments: signal - a bound Signal or pyqtSignal instance delay - Time (in seconds) to wait for signals to stop before emitting (default 0.3s) slot - Optional function to connect sigDelayed to. - rateLimit - (signals/sec) if greater than 0, this allows signals to stream out at a + rateLimit - (signals/sec) if greater than 0, this allows signals to stream out at a steady rate while they are being received. + threadSafe - Specify if thread-safety is required. For backwards compatibility, it + defaults to True. """ QtCore.QObject.__init__(self) self.delay = delay self.rateLimit = rateLimit self.args = None - self.timer = ThreadsafeTimer.ThreadsafeTimer() + Timer = ThreadsafeTimer if threadSafe else QtCore.QTimer + self.timer = Timer() self.timer.timeout.connect(self.flush) self.lastFlushTime = None self.signal = signal @@ -86,9 +89,9 @@ def disconnect(self): except: pass try: - # XXX: This is a weakref, however segfaulting on PySide and - # Python 2. We come back later - self.sigDelayed.disconnect(self.slot) + slot = self.slot() + if slot is not None: + self.sigDelayed.disconnect(slot) except: pass finally: diff --git a/pyqtgraph/WidgetGroup.py b/pyqtgraph/WidgetGroup.py index 51677cc41e..8a1f0f3755 100644 --- a/pyqtgraph/WidgetGroup.py +++ b/pyqtgraph/WidgetGroup.py @@ -172,7 +172,7 @@ def addWidget(self, w, name=None, scale=None): if signal is not None: if inspect.isfunction(signal) or inspect.ismethod(signal): signal = signal(w) - signal.connect(self.mkChangeCallback(w)) + signal.connect(self.widgetChanged) else: self.uncachedWidgets[w] = None @@ -217,10 +217,8 @@ def setScale(self, widget, scale): self.scales[widget] = scale self.setWidget(widget, val) - def mkChangeCallback(self, w): - return lambda *args: self.widgetChanged(w, *args) - - def widgetChanged(self, w, *args): + def widgetChanged(self, *args): + w = self.sender() n = self.widgetList[w] v1 = self.cache[n] v2 = self.readWidget(w) diff --git a/pyqtgraph/__init__.py b/pyqtgraph/__init__.py index 52f289a241..112cbe4c9c 100644 --- a/pyqtgraph/__init__.py +++ b/pyqtgraph/__init__.py @@ -3,13 +3,13 @@ www.pyqtgraph.org """ -__version__ = '0.12.4.dev0.qps' +__version__ = '0.13.4.dev0' ### import all the goodies and add some helper functions for easy CLI use +import importlib import os import sys -import importlib import numpy # # pyqtgraph requires numpy @@ -35,8 +35,8 @@ elif 'darwin' in sys.platform: ## openGL can have a major impact on mac, but also has serious bugs useOpenGL = False else: - useOpenGL = False ## on windows there's a more even performance / bugginess tradeoff. - + useOpenGL = False ## on windows there's a more even performance / bugginess tradeoff. + CONFIG_OPTIONS = { 'useOpenGL': useOpenGL, ## by default, this is platform-dependent (see widgets/GraphicsView). Set to True or False to explicitly enable/disable opengl. 'leftButtonPan': True, ## if false, left button drags a rubber band for zooming in viewbox @@ -72,9 +72,9 @@ def setConfigOption(opt, value): CONFIG_OPTIONS[opt] = value def setConfigOptions(**opts): - """Set global configuration options. - - Each keyword argument sets one global option. + """Set global configuration options. + + Each keyword argument sets one global option. """ for k,v in opts.items(): setConfigOption(k, v) @@ -90,7 +90,7 @@ def systemInfo(): print("sys.version: %s" % sys.version) from .Qt import VERSION_INFO print("qt bindings: %s" % VERSION_INFO) - + global __version__ rev = None if __version__ is None: ## this code was probably checked out from bzr; look up the last-revision file @@ -98,7 +98,7 @@ def systemInfo(): if os.path.exists(lastRevFile): with open(lastRevFile, 'r') as fd: rev = fd.read().strip() - + print("pyqtgraph: %s; %s" % (__version__, rev)) print("config:") import pprint @@ -106,16 +106,16 @@ def systemInfo(): ## Rename orphaned .pyc files. This is *probably* safe :) ## We only do this if __version__ is None, indicating the code was probably pulled -## from the repository. +## from the repository. def renamePyc(startDir): ### Used to rename orphaned .pyc files ### When a python file changes its location in the repository, usually the .pyc file - ### is left behind, possibly causing mysterious and difficult to track bugs. + ### is left behind, possibly causing mysterious and difficult to track bugs. ### Note that this is no longer necessary for python 3.2; from PEP 3147: - ### "If the py source file is missing, the pyc file inside __pycache__ will be ignored. + ### "If the py source file is missing, the pyc file inside __pycache__ will be ignored. ### This eliminates the problem of accidental stale pyc file imports." - + printed = False startDir = os.path.abspath(startDir) for path, dirs, files in os.walk(startDir): @@ -138,7 +138,7 @@ def renamePyc(startDir): print(" " + fileName + " ==>") print(" " + name2) os.rename(fileName, name2) - + path = os.path.split(__file__)[0] ## Import almost everything to make it available from a single namespace @@ -147,8 +147,8 @@ def renamePyc(startDir): #from . import frozenSupport #def importModules(path, globals, locals, excludes=()): #"""Import all modules residing within *path*, return a dict of name: module pairs. - - #Note that *path* MUST be relative to the module doing the import. + + #Note that *path* MUST be relative to the module doing the import. #""" #d = os.path.join(os.path.split(globals['__file__'])[0], path) #files = set() @@ -159,7 +159,7 @@ def renamePyc(startDir): #files.add(f[:-3]) #elif f[-4:] == '.pyc' and f != '__init__.pyc': #files.add(f[:-4]) - + #mods = {} #path = path.replace(os.sep, '.') #for modName in files: @@ -176,7 +176,7 @@ def renamePyc(startDir): #traceback.print_stack() #sys.excepthook(*sys.exc_info()) #print("[Error importing module: %s]" % modName) - + #return mods #def importAll(path, globals, locals, excludes=()): @@ -243,14 +243,11 @@ def renamePyc(startDir): # indirect imports used within library from .GraphicsScene import GraphicsScene -from .graphicsWindows import * from .imageview import * # indirect imports known to be used outside of the library from .metaarray import MetaArray -from .ordereddict import OrderedDict from .Point import Point -from .ptime import time from .Qt import isQObjectAlive from .SignalProxy import * from .SRTTransform import SRTTransform @@ -281,6 +278,7 @@ def renamePyc(startDir): from .widgets.PathButton import * from .widgets.PlotWidget import * from .widgets.ProgressDialog import * +from .widgets.RawImageWidget import * from .widgets.RemoteGraphicsView import RemoteGraphicsView from .widgets.ScatterPlotWidget import * from .widgets.SpinBox import * @@ -289,9 +287,8 @@ def renamePyc(startDir): from .widgets.ValueLabel import * from .widgets.VerticalLabel import * - ############################################################## -## PyQt and PySide both are prone to crashing on exit. +## PyQt and PySide both are prone to crashing on exit. ## There are two general approaches to dealing with this: ## 1. Install atexit handlers that assist in tearing down to avoid crashes. ## This helps, but is never perfect. @@ -303,34 +300,12 @@ def cleanup(): global _cleanupCalled if _cleanupCalled: return - + if not getConfigOption('exitCleanup'): return - + ViewBox.quit() ## tell ViewBox that it doesn't need to deregister views anymore. - - ## Workaround for Qt exit crash: - ## ALL QGraphicsItems must have a scene before they are deleted. - ## This is potentially very expensive, but preferred over crashing. - ## Note: this appears to be fixed in PySide as of 2012.12, but it should be left in for a while longer.. - app = QtWidgets.QApplication.instance() - if app is None or not isinstance(app, QtWidgets.QApplication): - # app was never constructed is already deleted or is an - # QCoreApplication/QGuiApplication and not a full QApplication - return - import gc - s = QtWidgets.QGraphicsScene() - for o in gc.get_objects(): - try: - if isinstance(o, QtWidgets.QGraphicsItem) and isQObjectAlive(o) and o.scene() is None: - if getConfigOption('crashWarning'): - sys.stderr.write('Error: graphics item without scene. ' - 'Make sure ViewBox.close() and GraphicsView.close() ' - 'are properly called before app shutdown (%s)\n' % (o,)) - - s.addItem(o) - except (RuntimeError, ReferenceError): ## occurs if a python wrapper no longer has its underlying C++ object - continue + _cleanupCalled = True atexit.register(cleanup) @@ -352,28 +327,28 @@ def _connectCleanup(): def exit(): """ Causes python to exit without garbage-collecting any objects, and thus avoids - calling object destructor methods. This is a sledgehammer workaround for + calling object destructor methods. This is a sledgehammer workaround for a variety of bugs in PyQt and Pyside that cause crashes on exit. - + This function does the following in an attempt to 'safely' terminate the process: - + * Invoke atexit callbacks * Close all open file handles * os._exit() - + Note: there is some potential for causing damage with this function if you are using objects that _require_ their destructors to be called (for example, to properly terminate log files, disconnect from devices, etc). Situations like this are probably quite rare, but use at your own risk. """ - + ## first disable our own cleanup function; won't be needing it. setConfigOptions(exitCleanup=False) - + ## invoke atexit callbacks atexit._run_exitfuncs() - + ## close file handles if sys.platform == 'darwin': for fd in range(3, 4096): @@ -387,7 +362,7 @@ def exit(): os.closerange(3, 4096) ## just guessing on the maximum descriptor count.. os._exit(0) - + ## Convenience functions for command-line use plots = [] @@ -396,7 +371,7 @@ def exit(): def plot(*args, **kargs): """ - Create and return a :class:`PlotWidget ` + Create and return a :class:`PlotWidget ` Accepts a *title* argument to set the title of the window. All other arguments are used to plot data. (see :func:`PlotItem.plot() `) """ @@ -420,7 +395,7 @@ def plot(*args, **kargs): def image(*args, **kargs): """ - Create and return an :class:`ImageView ` + Create and return an :class:`ImageView ` Will show 2D or 3D image data. Accepts a *title* argument to set the title of the window. All other arguments are used to show data. (see :func:`ImageView.setImage() `) @@ -439,7 +414,7 @@ def image(*args, **kargs): def dbg(*args, **kwds): """ Create a console window and begin watching for exceptions. - + All arguments are passed to :func:`ConsoleWidget.__init__() `. """ mkQApp() @@ -458,7 +433,7 @@ def dbg(*args, **kwds): def stack(*args, **kwds): """ Create a console window and show the current stack trace. - + All arguments are passed to :func:`ConsoleWidget.__init__() `. """ mkQApp() diff --git a/pyqtgraph/canvas/Canvas.py b/pyqtgraph/canvas/Canvas.py index a6f1632bde..8b7a4fcc1c 100644 --- a/pyqtgraph/canvas/Canvas.py +++ b/pyqtgraph/canvas/Canvas.py @@ -1,18 +1,15 @@ __all__ = ["Canvas"] +import gc import importlib +import weakref +import warnings from ..graphicsItems.GridItem import GridItem from ..graphicsItems.ROI import ROI from ..graphicsItems.ViewBox import ViewBox from ..Qt import QT_LIB, QtCore, QtGui, QtWidgets - -ui_template = importlib.import_module( - f'.CanvasTemplate_{QT_LIB.lower()}', package=__package__) - -import gc -import weakref - +from . import CanvasTemplate_generic as ui_template from .CanvasItem import CanvasItem, GroupCanvasItem from .CanvasManager import CanvasManager @@ -26,6 +23,12 @@ class Canvas(QtWidgets.QWidget): def __init__(self, parent=None, allowTransforms=True, hideCtrl=False, name=None): QtWidgets.QWidget.__init__(self, parent) + warnings.warn( + 'pyqtgrapoh.cavas will be deprecated in pyqtgraph and migrate to ' + 'acq4. Removal will occur after September, 2023.', + DeprecationWarning, stacklevel=2 + ) + self.ui = ui_template.Ui_Form() self.ui.setupUi(self) self.view = ViewBox() diff --git a/pyqtgraph/canvas/CanvasItem.py b/pyqtgraph/canvas/CanvasItem.py index f67e6d3e01..4601d9e0ff 100644 --- a/pyqtgraph/canvas/CanvasItem.py +++ b/pyqtgraph/canvas/CanvasItem.py @@ -2,15 +2,11 @@ import importlib -from .. import ItemGroup, SRTTransform +from .. import ItemGroup, SRTTransform, debug from .. import functions as fn from ..graphicsItems.ROI import ROI from ..Qt import QT_LIB, QtCore, QtWidgets - -ui_template = importlib.import_module( - f'.TransformGuiTemplate_{QT_LIB.lower()}', package=__package__) - -from .. import debug +from . import TransformGuiTemplate_generic as ui_template translate = QtCore.QCoreApplication.translate diff --git a/pyqtgraph/canvas/CanvasTemplate_pyqt6.py b/pyqtgraph/canvas/CanvasTemplate_generic.py similarity index 99% rename from pyqtgraph/canvas/CanvasTemplate_pyqt6.py rename to pyqtgraph/canvas/CanvasTemplate_generic.py index 5603e54fa1..add2b5852f 100644 --- a/pyqtgraph/canvas/CanvasTemplate_pyqt6.py +++ b/pyqtgraph/canvas/CanvasTemplate_generic.py @@ -6,7 +6,7 @@ # run again. Do not edit this file unless you know what you are doing. -from PyQt6 import QtCore, QtGui, QtWidgets +from ..Qt import QtCore, QtGui, QtWidgets class Ui_Form(object): diff --git a/pyqtgraph/canvas/CanvasTemplate_pyqt5.py b/pyqtgraph/canvas/CanvasTemplate_pyqt5.py deleted file mode 100644 index 0a341c6f38..0000000000 --- a/pyqtgraph/canvas/CanvasTemplate_pyqt5.py +++ /dev/null @@ -1,91 +0,0 @@ - -# Form implementation generated from reading ui file 'CanvasTemplate.ui' -# -# Created by: PyQt5 UI code generator 5.7.1 -# -# WARNING! All changes made in this file will be lost! - -from PyQt5 import QtCore, QtGui, QtWidgets - -class Ui_Form(object): - def setupUi(self, Form): - Form.setObjectName("Form") - Form.resize(821, 578) - self.gridLayout_2 = QtWidgets.QGridLayout(Form) - self.gridLayout_2.setContentsMargins(0, 0, 0, 0) - self.gridLayout_2.setSpacing(0) - self.gridLayout_2.setObjectName("gridLayout_2") - self.splitter = QtWidgets.QSplitter(Form) - self.splitter.setOrientation(QtCore.Qt.Horizontal) - self.splitter.setObjectName("splitter") - self.view = GraphicsView(self.splitter) - self.view.setObjectName("view") - self.vsplitter = QtWidgets.QSplitter(self.splitter) - self.vsplitter.setOrientation(QtCore.Qt.Vertical) - self.vsplitter.setObjectName("vsplitter") - self.canvasCtrlWidget = QtWidgets.QWidget(self.vsplitter) - self.canvasCtrlWidget.setObjectName("canvasCtrlWidget") - self.gridLayout = QtWidgets.QGridLayout(self.canvasCtrlWidget) - self.gridLayout.setContentsMargins(0, 0, 0, 0) - self.gridLayout.setObjectName("gridLayout") - self.autoRangeBtn = QtWidgets.QPushButton(self.canvasCtrlWidget) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(1) - sizePolicy.setHeightForWidth(self.autoRangeBtn.sizePolicy().hasHeightForWidth()) - self.autoRangeBtn.setSizePolicy(sizePolicy) - self.autoRangeBtn.setObjectName("autoRangeBtn") - self.gridLayout.addWidget(self.autoRangeBtn, 0, 0, 1, 2) - self.horizontalLayout = QtWidgets.QHBoxLayout() - self.horizontalLayout.setSpacing(0) - self.horizontalLayout.setObjectName("horizontalLayout") - self.redirectCheck = QtWidgets.QCheckBox(self.canvasCtrlWidget) - self.redirectCheck.setObjectName("redirectCheck") - self.horizontalLayout.addWidget(self.redirectCheck) - self.redirectCombo = CanvasCombo(self.canvasCtrlWidget) - self.redirectCombo.setObjectName("redirectCombo") - self.horizontalLayout.addWidget(self.redirectCombo) - self.gridLayout.addLayout(self.horizontalLayout, 1, 0, 1, 2) - self.itemList = TreeWidget(self.canvasCtrlWidget) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(100) - sizePolicy.setHeightForWidth(self.itemList.sizePolicy().hasHeightForWidth()) - self.itemList.setSizePolicy(sizePolicy) - self.itemList.setHeaderHidden(True) - self.itemList.setObjectName("itemList") - self.itemList.headerItem().setText(0, "1") - self.gridLayout.addWidget(self.itemList, 2, 0, 1, 2) - self.resetTransformsBtn = QtWidgets.QPushButton(self.canvasCtrlWidget) - self.resetTransformsBtn.setObjectName("resetTransformsBtn") - self.gridLayout.addWidget(self.resetTransformsBtn, 3, 0, 1, 2) - self.mirrorSelectionBtn = QtWidgets.QPushButton(self.canvasCtrlWidget) - self.mirrorSelectionBtn.setObjectName("mirrorSelectionBtn") - self.gridLayout.addWidget(self.mirrorSelectionBtn, 4, 0, 1, 1) - self.reflectSelectionBtn = QtWidgets.QPushButton(self.canvasCtrlWidget) - self.reflectSelectionBtn.setObjectName("reflectSelectionBtn") - self.gridLayout.addWidget(self.reflectSelectionBtn, 4, 1, 1, 1) - self.canvasItemCtrl = QtWidgets.QWidget(self.vsplitter) - self.canvasItemCtrl.setObjectName("canvasItemCtrl") - self.ctrlLayout = QtWidgets.QGridLayout(self.canvasItemCtrl) - self.ctrlLayout.setContentsMargins(0, 0, 0, 0) - self.ctrlLayout.setSpacing(0) - self.ctrlLayout.setObjectName("ctrlLayout") - self.gridLayout_2.addWidget(self.splitter, 0, 0, 1, 1) - - self.retranslateUi(Form) - QtCore.QMetaObject.connectSlotsByName(Form) - - def retranslateUi(self, Form): - _translate = QtCore.QCoreApplication.translate - Form.setWindowTitle(_translate("Form", "PyQtGraph")) - self.autoRangeBtn.setText(_translate("Form", "Auto Range")) - self.redirectCheck.setToolTip(_translate("Form", "Check to display all local items in a remote canvas.")) - self.redirectCheck.setText(_translate("Form", "Redirect")) - self.resetTransformsBtn.setText(_translate("Form", "Reset Transforms")) - self.mirrorSelectionBtn.setText(_translate("Form", "Mirror Selection")) - self.reflectSelectionBtn.setText(_translate("Form", "MirrorXY")) - -from ..widgets.GraphicsView import GraphicsView -from ..widgets.TreeWidget import TreeWidget -from .CanvasManager import CanvasCombo diff --git a/pyqtgraph/canvas/CanvasTemplate_pyside2.py b/pyqtgraph/canvas/CanvasTemplate_pyside2.py deleted file mode 100644 index 42891bed90..0000000000 --- a/pyqtgraph/canvas/CanvasTemplate_pyside2.py +++ /dev/null @@ -1,86 +0,0 @@ - -# Form implementation generated from reading ui file 'CanvasTemplate.ui' -# -# Created: Sun Sep 18 19:18:22 2016 -# by: pyside2-uic running on PySide2 2.0.0~alpha0 -# -# WARNING! All changes made in this file will be lost! - -from PySide2 import QtCore, QtGui, QtWidgets - -class Ui_Form(object): - def setupUi(self, Form): - Form.setObjectName("Form") - Form.resize(490, 414) - self.gridLayout = QtWidgets.QGridLayout(Form) - self.gridLayout.setContentsMargins(0, 0, 0, 0) - self.gridLayout.setSpacing(0) - self.gridLayout.setObjectName("gridLayout") - self.splitter = QtWidgets.QSplitter(Form) - self.splitter.setOrientation(QtCore.Qt.Horizontal) - self.splitter.setObjectName("splitter") - self.view = GraphicsView(self.splitter) - self.view.setObjectName("view") - self.layoutWidget = QtWidgets.QWidget(self.splitter) - self.layoutWidget.setObjectName("layoutWidget") - self.gridLayout_2 = QtWidgets.QGridLayout(self.layoutWidget) - self.gridLayout_2.setContentsMargins(0, 0, 0, 0) - self.gridLayout_2.setObjectName("gridLayout_2") - self.autoRangeBtn = QtWidgets.QPushButton(self.layoutWidget) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(1) - sizePolicy.setHeightForWidth(self.autoRangeBtn.sizePolicy().hasHeightForWidth()) - self.autoRangeBtn.setSizePolicy(sizePolicy) - self.autoRangeBtn.setObjectName("autoRangeBtn") - self.gridLayout_2.addWidget(self.autoRangeBtn, 2, 0, 1, 2) - self.horizontalLayout = QtWidgets.QHBoxLayout() - self.horizontalLayout.setSpacing(0) - self.horizontalLayout.setObjectName("horizontalLayout") - self.redirectCheck = QtWidgets.QCheckBox(self.layoutWidget) - self.redirectCheck.setObjectName("redirectCheck") - self.horizontalLayout.addWidget(self.redirectCheck) - self.redirectCombo = CanvasCombo(self.layoutWidget) - self.redirectCombo.setObjectName("redirectCombo") - self.horizontalLayout.addWidget(self.redirectCombo) - self.gridLayout_2.addLayout(self.horizontalLayout, 5, 0, 1, 2) - self.itemList = TreeWidget(self.layoutWidget) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(100) - sizePolicy.setHeightForWidth(self.itemList.sizePolicy().hasHeightForWidth()) - self.itemList.setSizePolicy(sizePolicy) - self.itemList.setHeaderHidden(True) - self.itemList.setObjectName("itemList") - self.itemList.headerItem().setText(0, "1") - self.gridLayout_2.addWidget(self.itemList, 6, 0, 1, 2) - self.ctrlLayout = QtWidgets.QGridLayout() - self.ctrlLayout.setSpacing(0) - self.ctrlLayout.setObjectName("ctrlLayout") - self.gridLayout_2.addLayout(self.ctrlLayout, 10, 0, 1, 2) - self.resetTransformsBtn = QtWidgets.QPushButton(self.layoutWidget) - self.resetTransformsBtn.setObjectName("resetTransformsBtn") - self.gridLayout_2.addWidget(self.resetTransformsBtn, 7, 0, 1, 1) - self.mirrorSelectionBtn = QtWidgets.QPushButton(self.layoutWidget) - self.mirrorSelectionBtn.setObjectName("mirrorSelectionBtn") - self.gridLayout_2.addWidget(self.mirrorSelectionBtn, 3, 0, 1, 1) - self.reflectSelectionBtn = QtWidgets.QPushButton(self.layoutWidget) - self.reflectSelectionBtn.setObjectName("reflectSelectionBtn") - self.gridLayout_2.addWidget(self.reflectSelectionBtn, 3, 1, 1, 1) - self.gridLayout.addWidget(self.splitter, 0, 0, 1, 1) - - self.retranslateUi(Form) - QtCore.QMetaObject.connectSlotsByName(Form) - - def retranslateUi(self, Form): - Form.setWindowTitle(QtWidgets.QApplication.translate("Form", "Form", None, -1)) - self.autoRangeBtn.setText(QtWidgets.QApplication.translate("Form", "Auto Range", None, -1)) - self.redirectCheck.setToolTip(QtWidgets.QApplication.translate("Form", "Check to display all local items in a remote canvas.", None, -1)) - self.redirectCheck.setText(QtWidgets.QApplication.translate("Form", "Redirect", None, -1)) - self.resetTransformsBtn.setText(QtWidgets.QApplication.translate("Form", "Reset Transforms", None, -1)) - self.mirrorSelectionBtn.setText(QtWidgets.QApplication.translate("Form", "Mirror Selection", None, -1)) - self.reflectSelectionBtn.setText(QtWidgets.QApplication.translate("Form", "MirrorXY", None, -1)) - -from ..widgets.TreeWidget import TreeWidget -from CanvasManager import CanvasCombo -from ..widgets.GraphicsView import GraphicsView diff --git a/pyqtgraph/canvas/CanvasTemplate_pyside6.py b/pyqtgraph/canvas/CanvasTemplate_pyside6.py deleted file mode 100644 index 540692de28..0000000000 --- a/pyqtgraph/canvas/CanvasTemplate_pyside6.py +++ /dev/null @@ -1,124 +0,0 @@ - -################################################################################ -## Form generated from reading UI file 'CanvasTemplate.ui' -## -## Created by: Qt User Interface Compiler version 6.1.0 -## -## WARNING! All changes made in this file will be lost when recompiling UI file! -################################################################################ - -from PySide6.QtCore import * -from PySide6.QtGui import * -from PySide6.QtWidgets import * - -from ..widgets.TreeWidget import TreeWidget -from ..widgets.GraphicsView import GraphicsView -from .CanvasManager import CanvasCombo - - -class Ui_Form(object): - def setupUi(self, Form): - if not Form.objectName(): - Form.setObjectName(u"Form") - Form.resize(821, 578) - self.gridLayout_2 = QGridLayout(Form) - self.gridLayout_2.setSpacing(0) - self.gridLayout_2.setContentsMargins(0, 0, 0, 0) - self.gridLayout_2.setObjectName(u"gridLayout_2") - self.splitter = QSplitter(Form) - self.splitter.setObjectName(u"splitter") - self.splitter.setOrientation(Qt.Horizontal) - self.view = GraphicsView(self.splitter) - self.view.setObjectName(u"view") - self.splitter.addWidget(self.view) - self.vsplitter = QSplitter(self.splitter) - self.vsplitter.setObjectName(u"vsplitter") - self.vsplitter.setOrientation(Qt.Vertical) - self.canvasCtrlWidget = QWidget(self.vsplitter) - self.canvasCtrlWidget.setObjectName(u"canvasCtrlWidget") - self.gridLayout = QGridLayout(self.canvasCtrlWidget) - self.gridLayout.setObjectName(u"gridLayout") - self.autoRangeBtn = QPushButton(self.canvasCtrlWidget) - self.autoRangeBtn.setObjectName(u"autoRangeBtn") - sizePolicy = QSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(1) - sizePolicy.setHeightForWidth(self.autoRangeBtn.sizePolicy().hasHeightForWidth()) - self.autoRangeBtn.setSizePolicy(sizePolicy) - - self.gridLayout.addWidget(self.autoRangeBtn, 0, 0, 1, 2) - - self.horizontalLayout = QHBoxLayout() - self.horizontalLayout.setSpacing(0) - self.horizontalLayout.setObjectName(u"horizontalLayout") - self.redirectCheck = QCheckBox(self.canvasCtrlWidget) - self.redirectCheck.setObjectName(u"redirectCheck") - - self.horizontalLayout.addWidget(self.redirectCheck) - - self.redirectCombo = CanvasCombo(self.canvasCtrlWidget) - self.redirectCombo.setObjectName(u"redirectCombo") - - self.horizontalLayout.addWidget(self.redirectCombo) - - - self.gridLayout.addLayout(self.horizontalLayout, 1, 0, 1, 2) - - self.itemList = TreeWidget(self.canvasCtrlWidget) - __qtreewidgetitem = QTreeWidgetItem() - __qtreewidgetitem.setText(0, u"1"); - self.itemList.setHeaderItem(__qtreewidgetitem) - self.itemList.setObjectName(u"itemList") - sizePolicy1 = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - sizePolicy1.setHorizontalStretch(0) - sizePolicy1.setVerticalStretch(100) - sizePolicy1.setHeightForWidth(self.itemList.sizePolicy().hasHeightForWidth()) - self.itemList.setSizePolicy(sizePolicy1) - self.itemList.setHeaderHidden(True) - - self.gridLayout.addWidget(self.itemList, 2, 0, 1, 2) - - self.resetTransformsBtn = QPushButton(self.canvasCtrlWidget) - self.resetTransformsBtn.setObjectName(u"resetTransformsBtn") - - self.gridLayout.addWidget(self.resetTransformsBtn, 3, 0, 1, 2) - - self.mirrorSelectionBtn = QPushButton(self.canvasCtrlWidget) - self.mirrorSelectionBtn.setObjectName(u"mirrorSelectionBtn") - - self.gridLayout.addWidget(self.mirrorSelectionBtn, 4, 0, 1, 1) - - self.reflectSelectionBtn = QPushButton(self.canvasCtrlWidget) - self.reflectSelectionBtn.setObjectName(u"reflectSelectionBtn") - - self.gridLayout.addWidget(self.reflectSelectionBtn, 4, 1, 1, 1) - - self.vsplitter.addWidget(self.canvasCtrlWidget) - self.canvasItemCtrl = QWidget(self.vsplitter) - self.canvasItemCtrl.setObjectName(u"canvasItemCtrl") - self.ctrlLayout = QGridLayout(self.canvasItemCtrl) - self.ctrlLayout.setSpacing(0) - self.ctrlLayout.setContentsMargins(0, 0, 0, 0) - self.ctrlLayout.setObjectName(u"ctrlLayout") - self.vsplitter.addWidget(self.canvasItemCtrl) - self.splitter.addWidget(self.vsplitter) - - self.gridLayout_2.addWidget(self.splitter, 0, 0, 1, 1) - - - self.retranslateUi(Form) - - QMetaObject.connectSlotsByName(Form) - # setupUi - - def retranslateUi(self, Form): - Form.setWindowTitle(QCoreApplication.translate("Form", u"PyQtGraph", None)) - self.autoRangeBtn.setText(QCoreApplication.translate("Form", u"Auto Range", None)) -#if QT_CONFIG(tooltip) - self.redirectCheck.setToolTip(QCoreApplication.translate("Form", u"Check to display all local items in a remote canvas.", None)) -#endif // QT_CONFIG(tooltip) - self.redirectCheck.setText(QCoreApplication.translate("Form", u"Redirect", None)) - self.resetTransformsBtn.setText(QCoreApplication.translate("Form", u"Reset Transforms", None)) - self.mirrorSelectionBtn.setText(QCoreApplication.translate("Form", u"Mirror Selection", None)) - self.reflectSelectionBtn.setText(QCoreApplication.translate("Form", u"MirrorXY", None)) - # retranslateUi diff --git a/pyqtgraph/canvas/TransformGuiTemplate_pyqt6.py b/pyqtgraph/canvas/TransformGuiTemplate_generic.py similarity index 98% rename from pyqtgraph/canvas/TransformGuiTemplate_pyqt6.py rename to pyqtgraph/canvas/TransformGuiTemplate_generic.py index 71d72ac879..f167efa7e7 100644 --- a/pyqtgraph/canvas/TransformGuiTemplate_pyqt6.py +++ b/pyqtgraph/canvas/TransformGuiTemplate_generic.py @@ -6,7 +6,7 @@ # run again. Do not edit this file unless you know what you are doing. -from PyQt6 import QtCore, QtGui, QtWidgets +from ..Qt import QtCore, QtGui, QtWidgets class Ui_Form(object): diff --git a/pyqtgraph/canvas/TransformGuiTemplate_pyqt5.py b/pyqtgraph/canvas/TransformGuiTemplate_pyqt5.py deleted file mode 100644 index be09f66dc9..0000000000 --- a/pyqtgraph/canvas/TransformGuiTemplate_pyqt5.py +++ /dev/null @@ -1,53 +0,0 @@ - -# Form implementation generated from reading ui file 'pyqtgraph/canvas/TransformGuiTemplate.ui' -# -# Created by: PyQt5 UI code generator 5.5.1 -# -# WARNING! All changes made in this file will be lost! - -from PyQt5 import QtCore, QtGui, QtWidgets - -class Ui_Form(object): - def setupUi(self, Form): - Form.setObjectName("Form") - Form.resize(224, 117) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(Form.sizePolicy().hasHeightForWidth()) - Form.setSizePolicy(sizePolicy) - self.verticalLayout = QtWidgets.QVBoxLayout(Form) - self.verticalLayout.setContentsMargins(0, 0, 0, 0) - self.verticalLayout.setSpacing(1) - self.verticalLayout.setObjectName("verticalLayout") - self.translateLabel = QtWidgets.QLabel(Form) - self.translateLabel.setObjectName("translateLabel") - self.verticalLayout.addWidget(self.translateLabel) - self.rotateLabel = QtWidgets.QLabel(Form) - self.rotateLabel.setObjectName("rotateLabel") - self.verticalLayout.addWidget(self.rotateLabel) - self.scaleLabel = QtWidgets.QLabel(Form) - self.scaleLabel.setObjectName("scaleLabel") - self.verticalLayout.addWidget(self.scaleLabel) - self.horizontalLayout = QtWidgets.QHBoxLayout() - self.horizontalLayout.setObjectName("horizontalLayout") - self.mirrorImageBtn = QtWidgets.QPushButton(Form) - self.mirrorImageBtn.setToolTip("") - self.mirrorImageBtn.setObjectName("mirrorImageBtn") - self.horizontalLayout.addWidget(self.mirrorImageBtn) - self.reflectImageBtn = QtWidgets.QPushButton(Form) - self.reflectImageBtn.setObjectName("reflectImageBtn") - self.horizontalLayout.addWidget(self.reflectImageBtn) - self.verticalLayout.addLayout(self.horizontalLayout) - - self.retranslateUi(Form) - QtCore.QMetaObject.connectSlotsByName(Form) - - def retranslateUi(self, Form): - _translate = QtCore.QCoreApplication.translate - Form.setWindowTitle(_translate("Form", "PyQtGraph")) - self.translateLabel.setText(_translate("Form", "Translate:")) - self.rotateLabel.setText(_translate("Form", "Rotate:")) - self.scaleLabel.setText(_translate("Form", "Scale:")) - self.mirrorImageBtn.setText(_translate("Form", "Mirror")) - self.reflectImageBtn.setText(_translate("Form", "Reflect")) diff --git a/pyqtgraph/canvas/TransformGuiTemplate_pyside2.py b/pyqtgraph/canvas/TransformGuiTemplate_pyside2.py deleted file mode 100644 index b85e519c55..0000000000 --- a/pyqtgraph/canvas/TransformGuiTemplate_pyside2.py +++ /dev/null @@ -1,53 +0,0 @@ - -# Form implementation generated from reading ui file 'TransformGuiTemplate.ui' -# -# Created: Sun Sep 18 19:18:41 2016 -# by: pyside2-uic running on PySide2 2.0.0~alpha0 -# -# WARNING! All changes made in this file will be lost! - -from PySide2 import QtCore, QtGui, QtWidgets - -class Ui_Form(object): - def setupUi(self, Form): - Form.setObjectName("Form") - Form.resize(224, 117) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(Form.sizePolicy().hasHeightForWidth()) - Form.setSizePolicy(sizePolicy) - self.verticalLayout = QtWidgets.QVBoxLayout(Form) - self.verticalLayout.setSpacing(1) - self.verticalLayout.setContentsMargins(0, 0, 0, 0) - self.verticalLayout.setObjectName("verticalLayout") - self.translateLabel = QtWidgets.QLabel(Form) - self.translateLabel.setObjectName("translateLabel") - self.verticalLayout.addWidget(self.translateLabel) - self.rotateLabel = QtWidgets.QLabel(Form) - self.rotateLabel.setObjectName("rotateLabel") - self.verticalLayout.addWidget(self.rotateLabel) - self.scaleLabel = QtWidgets.QLabel(Form) - self.scaleLabel.setObjectName("scaleLabel") - self.verticalLayout.addWidget(self.scaleLabel) - self.horizontalLayout = QtWidgets.QHBoxLayout() - self.horizontalLayout.setObjectName("horizontalLayout") - self.mirrorImageBtn = QtWidgets.QPushButton(Form) - self.mirrorImageBtn.setToolTip("") - self.mirrorImageBtn.setObjectName("mirrorImageBtn") - self.horizontalLayout.addWidget(self.mirrorImageBtn) - self.reflectImageBtn = QtWidgets.QPushButton(Form) - self.reflectImageBtn.setObjectName("reflectImageBtn") - self.horizontalLayout.addWidget(self.reflectImageBtn) - self.verticalLayout.addLayout(self.horizontalLayout) - - self.retranslateUi(Form) - QtCore.QMetaObject.connectSlotsByName(Form) - - def retranslateUi(self, Form): - Form.setWindowTitle(QtWidgets.QApplication.translate("Form", "Form", None, -1)) - self.translateLabel.setText(QtWidgets.QApplication.translate("Form", "Translate:", None, -1)) - self.rotateLabel.setText(QtWidgets.QApplication.translate("Form", "Rotate:", None, -1)) - self.scaleLabel.setText(QtWidgets.QApplication.translate("Form", "Scale:", None, -1)) - self.mirrorImageBtn.setText(QtWidgets.QApplication.translate("Form", "Mirror", None, -1)) - self.reflectImageBtn.setText(QtWidgets.QApplication.translate("Form", "Reflect", None, -1)) diff --git a/pyqtgraph/canvas/TransformGuiTemplate_pyside6.py b/pyqtgraph/canvas/TransformGuiTemplate_pyside6.py deleted file mode 100644 index 4d085f6ebd..0000000000 --- a/pyqtgraph/canvas/TransformGuiTemplate_pyside6.py +++ /dev/null @@ -1,75 +0,0 @@ - -################################################################################ -## Form generated from reading UI file 'TransformGuiTemplate.ui' -## -## Created by: Qt User Interface Compiler version 6.1.0 -## -## WARNING! All changes made in this file will be lost when recompiling UI file! -################################################################################ - -from PySide6.QtCore import * -from PySide6.QtGui import * -from PySide6.QtWidgets import * - - -class Ui_Form(object): - def setupUi(self, Form): - if not Form.objectName(): - Form.setObjectName(u"Form") - Form.resize(224, 117) - sizePolicy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(Form.sizePolicy().hasHeightForWidth()) - Form.setSizePolicy(sizePolicy) - self.verticalLayout = QVBoxLayout(Form) - self.verticalLayout.setSpacing(1) - self.verticalLayout.setContentsMargins(0, 0, 0, 0) - self.verticalLayout.setObjectName(u"verticalLayout") - self.translateLabel = QLabel(Form) - self.translateLabel.setObjectName(u"translateLabel") - - self.verticalLayout.addWidget(self.translateLabel) - - self.rotateLabel = QLabel(Form) - self.rotateLabel.setObjectName(u"rotateLabel") - - self.verticalLayout.addWidget(self.rotateLabel) - - self.scaleLabel = QLabel(Form) - self.scaleLabel.setObjectName(u"scaleLabel") - - self.verticalLayout.addWidget(self.scaleLabel) - - self.horizontalLayout = QHBoxLayout() - self.horizontalLayout.setObjectName(u"horizontalLayout") - self.mirrorImageBtn = QPushButton(Form) - self.mirrorImageBtn.setObjectName(u"mirrorImageBtn") - - self.horizontalLayout.addWidget(self.mirrorImageBtn) - - self.reflectImageBtn = QPushButton(Form) - self.reflectImageBtn.setObjectName(u"reflectImageBtn") - - self.horizontalLayout.addWidget(self.reflectImageBtn) - - - self.verticalLayout.addLayout(self.horizontalLayout) - - - self.retranslateUi(Form) - - QMetaObject.connectSlotsByName(Form) - # setupUi - - def retranslateUi(self, Form): - Form.setWindowTitle(QCoreApplication.translate("Form", u"PyQtGraph", None)) - self.translateLabel.setText(QCoreApplication.translate("Form", u"Translate:", None)) - self.rotateLabel.setText(QCoreApplication.translate("Form", u"Rotate:", None)) - self.scaleLabel.setText(QCoreApplication.translate("Form", u"Scale:", None)) -#if QT_CONFIG(tooltip) - self.mirrorImageBtn.setToolTip("") -#endif // QT_CONFIG(tooltip) - self.mirrorImageBtn.setText(QCoreApplication.translate("Form", u"Mirror", None)) - self.reflectImageBtn.setText(QCoreApplication.translate("Form", u"Reflect", None)) - # retranslateUi diff --git a/pyqtgraph/colormap.py b/pyqtgraph/colormap.py index eb69cb5379..5a2b8106b8 100644 --- a/pyqtgraph/colormap.py +++ b/pyqtgraph/colormap.py @@ -1,4 +1,3 @@ -import warnings from collections.abc import Callable, Sequence from os import listdir, path @@ -379,9 +378,10 @@ def __init__(self, pos, color, mapping=CLIP, mode=None, linearize=False, name='' Parameters ---------- - pos: array_like of float in range 0 to 1, or None + pos: array_like of float, optional Assigned positions of specified colors. `None` sets equal spacing. - color: array_like of colors + Values need to be in range 0.0-1.0. + color: array_like of color_like List of colors, interpreted via :func:`mkColor() `. mapping: str or int, optional Controls how values outside the 0 to 1 range are mapped to colors. @@ -391,11 +391,6 @@ def __init__(self, pos, color, mapping=CLIP, mode=None, linearize=False, name='' the colors assigned to 0 and 1 for all values below or above this range, respectively. """ self.name = name # storing a name helps identify ColorMaps sampled by Palette - if mode is not None: - warnings.warn( - "'mode' argument is deprecated and does nothing.", - DeprecationWarning, stacklevel=2 - ) if pos is None: order = range(len(color)) self.pos = np.linspace(0.0, 1.0, num=len(color)) @@ -487,16 +482,18 @@ def getSubset(self, start, span): Parameters ---------- - start : float (0.0 to 1.0) + start : float Starting value that defines the 0.0 value of the new color map. - span : float (-1.0 to 1.0) - span of the extracted region. The orignal color map will be trated as cyclical - if the extracted interval exceeds the 0.0 to 1.0 range. + Possible value between 0.0 to 1.0 + span : float + Span of the extracted region. The original color map will be + treated as cyclical if the extracted interval exceeds the + 0.0 to 1.0 range. Possible values between -1.0 to 1.0. """ pos, col = self.getStops( mode=ColorMap.FLOAT ) start = clip_scalar(start, 0.0, 1.0) span = clip_scalar(span, -1.0, 1.0) - + if span == 0.0: raise ValueError("'length' needs to be non-zero") stop = (start + span) @@ -567,14 +564,14 @@ def map(self, data, mode=BYTE): Returns ------- - array of color.dtype + np.ndarray of {``ColorMap.BYTE``, ``ColorMap.FLOAT``, QColor} for `ColorMap.BYTE` or `ColorMap.FLOAT`: RGB values for each `data` value, arranged in the same shape as `data`. - list of QColor objects + list of QColor for `ColorMap.QCOLOR`: - Colors for each `data` value as Qcolor objects. + Colors for each `data` value as QColor objects. """ if isinstance(mode, str): mode = self.enumMap[mode.lower()] @@ -625,12 +622,12 @@ def mapToFloat(self, data): def getByIndex(self, idx): """Retrieve a QColor by the index of the stop it is assigned to.""" - return QtGui.QColor( *self.color[idx] ) + return QtGui.QColor.fromRgbF( *self.color[idx] ) def getGradient(self, p1=None, p2=None): """ Returns a QtGui.QLinearGradient corresponding to this ColorMap. - The span and orientiation is given by two points in plot coordinates. + The span and orientation is given by two points in plot coordinates. When no parameters are given for `p1` and `p2`, the gradient is mapped to the `y` coordinates 0 to 1, unless the color map is defined for a more limited range. @@ -640,11 +637,11 @@ def getGradient(self, p1=None, p2=None): Parameters ---------- - p1: QtCore.QPointF, default (0,0) - Starting point (value 0) of the gradient. - p2: QtCore.QPointF, default (dy,0) + p1: QtCore.QPointF, optional + Starting point (value 0) of the gradient. Default value is QPointF(0., 0.) + p2: QtCore.QPointF, optional End point (value 1) of the gradient. Default parameter `dy` is the span of ``max(pos) - min(pos)`` - over which the color map is defined, typically `dy=1`. + over which the color map is defined, typically `dy=1`. Default is QPointF(dy, 0.) """ if p1 is None: p1 = QtCore.QPointF(0,0) @@ -675,14 +672,16 @@ def getBrush(self, span=(0.,1.), orientation='vertical'): Parameters ---------- - span : tuple (min, max), default (0.0, 1.0) + span : tuple of float, optional Span of data values covered by the gradient: - Color map value 0.0 will appear at `min`, - Color map value 1.0 will appear at `max`. + + Default value is (0., 1.) orientation : str, default 'vertical' - Orientiation of the gradient: + Orientation of the gradient: - 'vertical': `span` corresponds to the `y` coordinate. - 'horizontal': `span` corresponds to the `x` coordinate. @@ -704,17 +703,18 @@ def getPen(self, span=(0.,1.), orientation='vertical', width=1.0): Parameters ---------- - span : tuple (min, max), default (0.0, 1.0) + span : tuple of float Span of the data values covered by the gradient: - Color map value 0.0 will appear at `min`. - Color map value 1.0 will appear at `max`. + Default is (0., 1.) orientation : str, default 'vertical' - Orientiation of the gradient: + Orientation of the gradient: - 'vertical' creates a vertical gradient, where `span` corresponds to the `y` coordinate. - - 'horizontal' creates a horizontal gradient, where `span` correspnds to the `x` coordinate. + - 'horizontal' creates a horizontal gradient, where `span` corresponds to the `x` coordinate. width : int or float Width of the pen in pixels on screen. @@ -781,23 +781,24 @@ def getLookupTable(self, start=0.0, stop=1.0, nPts=512, alpha=None, mode=BYTE): The starting value in the lookup table stop: float, default=1.0 The final value in the lookup table - nPts: int, default is 512 + nPts: int, default=512 The number of points in the returned lookup table. - alpha: True, False, or None + alpha: bool, optional Specifies whether or not alpha values are included in the table. If alpha is None, it will be automatically determined. - mode: int or str, default is `ColorMap.BYTE` + mode: int or str, default='byte' Determines return type as described in :func:`map() `, can be either `ColorMap.BYTE` (0 to 255), `ColorMap.FLOAT` (0.0 to 1.0) or `ColorMap.QColor`. Returns ------- - array of color.dtype + np.ndarray of {``ColorMap.BYTE``, ``ColorMap.FLOAT``} for `ColorMap.BYTE` or `ColorMap.FLOAT`: RGB values for each `data` value, arranged in the same shape as `data`. If alpha values are included the array has shape (`nPts`, 4), otherwise (`nPts`, 3). - list of QColor objects + + list of QColor for `ColorMap.QCOLOR`: Colors for each `data` value as QColor objects. diff --git a/pyqtgraph/colors/maps/turbo.csv b/pyqtgraph/colors/maps/turbo.csv new file mode 100644 index 0000000000..aa70108497 --- /dev/null +++ b/pyqtgraph/colors/maps/turbo.csv @@ -0,0 +1,260 @@ +; turbo colormap, an improved rainbow colormap enabling high contrast and smooth visualization of data +; Published by Anton Mikhailov et. al. https://ai.googleblog.com/2019/08/turbo-improved-rainbow-colormap-for.html +; SPDX-License-Identifier: Apache-2.0 +; +0.18995,0.07176,0.23217 +0.19483,0.08339,0.26149 +0.19956,0.09498,0.29024 +0.20415,0.10652,0.31844 +0.20860,0.11802,0.34607 +0.21291,0.12947,0.37314 +0.21708,0.14087,0.39964 +0.22111,0.15223,0.42558 +0.22500,0.16354,0.45096 +0.22875,0.17481,0.47578 +0.23236,0.18603,0.50004 +0.23582,0.19720,0.52373 +0.23915,0.20833,0.54686 +0.24234,0.21941,0.56942 +0.24539,0.23044,0.59142 +0.24830,0.24143,0.61286 +0.25107,0.25237,0.63374 +0.25369,0.26327,0.65406 +0.25618,0.27412,0.67381 +0.25853,0.28492,0.69300 +0.26074,0.29568,0.71162 +0.26280,0.30639,0.72968 +0.26473,0.31706,0.74718 +0.26652,0.32768,0.76412 +0.26816,0.33825,0.78050 +0.26967,0.34878,0.79631 +0.27103,0.35926,0.81156 +0.27226,0.36970,0.82624 +0.27334,0.38008,0.84037 +0.27429,0.39043,0.85393 +0.27509,0.40072,0.86692 +0.27576,0.41097,0.87936 +0.27628,0.42118,0.89123 +0.27667,0.43134,0.90254 +0.27691,0.44145,0.91328 +0.27701,0.45152,0.92347 +0.27698,0.46153,0.93309 +0.27680,0.47151,0.94214 +0.27648,0.48144,0.95064 +0.27603,0.49132,0.95857 +0.27543,0.50115,0.96594 +0.27469,0.51094,0.97275 +0.27381,0.52069,0.97899 +0.27273,0.53040,0.98461 +0.27106,0.54015,0.98930 +0.26878,0.54995,0.99303 +0.26592,0.55979,0.99583 +0.26252,0.56967,0.99773 +0.25862,0.57958,0.99876 +0.25425,0.58950,0.99896 +0.24946,0.59943,0.99835 +0.24427,0.60937,0.99697 +0.23874,0.61931,0.99485 +0.23288,0.62923,0.99202 +0.22676,0.63913,0.98851 +0.22039,0.64901,0.98436 +0.21382,0.65886,0.97959 +0.20708,0.66866,0.97423 +0.20021,0.67842,0.96833 +0.19326,0.68812,0.96190 +0.18625,0.69775,0.95498 +0.17923,0.70732,0.94761 +0.17223,0.71680,0.93981 +0.16529,0.72620,0.93161 +0.15844,0.73551,0.92305 +0.15173,0.74472,0.91416 +0.14519,0.75381,0.90496 +0.13886,0.76279,0.89550 +0.13278,0.77165,0.88580 +0.12698,0.78037,0.87590 +0.12151,0.78896,0.86581 +0.11639,0.79740,0.85559 +0.11167,0.80569,0.84525 +0.10738,0.81381,0.83484 +0.10357,0.82177,0.82437 +0.10026,0.82955,0.81389 +0.09750,0.83714,0.80342 +0.09532,0.84455,0.79299 +0.09377,0.85175,0.78264 +0.09287,0.85875,0.77240 +0.09267,0.86554,0.76230 +0.09320,0.87211,0.75237 +0.09451,0.87844,0.74265 +0.09662,0.88454,0.73316 +0.09958,0.89040,0.72393 +0.10342,0.89600,0.71500 +0.10815,0.90142,0.70599 +0.11374,0.90673,0.69651 +0.12014,0.91193,0.68660 +0.12733,0.91701,0.67627 +0.13526,0.92197,0.66556 +0.14391,0.92680,0.65448 +0.15323,0.93151,0.64308 +0.16319,0.93609,0.63137 +0.17377,0.94053,0.61938 +0.18491,0.94484,0.60713 +0.19659,0.94901,0.59466 +0.20877,0.95304,0.58199 +0.22142,0.95692,0.56914 +0.23449,0.96065,0.55614 +0.24797,0.96423,0.54303 +0.26180,0.96765,0.52981 +0.27597,0.97092,0.51653 +0.29042,0.97403,0.50321 +0.30513,0.97697,0.48987 +0.32006,0.97974,0.47654 +0.33517,0.98234,0.46325 +0.35043,0.98477,0.45002 +0.36581,0.98702,0.43688 +0.38127,0.98909,0.42386 +0.39678,0.99098,0.41098 +0.41229,0.99268,0.39826 +0.42778,0.99419,0.38575 +0.44321,0.99551,0.37345 +0.45854,0.99663,0.36140 +0.47375,0.99755,0.34963 +0.48879,0.99828,0.33816 +0.50362,0.99879,0.32701 +0.51822,0.99910,0.31622 +0.53255,0.99919,0.30581 +0.54658,0.99907,0.29581 +0.56026,0.99873,0.28623 +0.57357,0.99817,0.27712 +0.58646,0.99739,0.26849 +0.59891,0.99638,0.26038 +0.61088,0.99514,0.25280 +0.62233,0.99366,0.24579 +0.63323,0.99195,0.23937 +0.64362,0.98999,0.23356 +0.65394,0.98775,0.22835 +0.66428,0.98524,0.22370 +0.67462,0.98246,0.21960 +0.68494,0.97941,0.21602 +0.69525,0.97610,0.21294 +0.70553,0.97255,0.21032 +0.71577,0.96875,0.20815 +0.72596,0.96470,0.20640 +0.73610,0.96043,0.20504 +0.74617,0.95593,0.20406 +0.75617,0.95121,0.20343 +0.76608,0.94627,0.20311 +0.77591,0.94113,0.20310 +0.78563,0.93579,0.20336 +0.79524,0.93025,0.20386 +0.80473,0.92452,0.20459 +0.81410,0.91861,0.20552 +0.82333,0.91253,0.20663 +0.83241,0.90627,0.20788 +0.84133,0.89986,0.20926 +0.85010,0.89328,0.21074 +0.85868,0.88655,0.21230 +0.86709,0.87968,0.21391 +0.87530,0.87267,0.21555 +0.88331,0.86553,0.21719 +0.89112,0.85826,0.21880 +0.89870,0.85087,0.22038 +0.90605,0.84337,0.22188 +0.91317,0.83576,0.22328 +0.92004,0.82806,0.22456 +0.92666,0.82025,0.22570 +0.93301,0.81236,0.22667 +0.93909,0.80439,0.22744 +0.94489,0.79634,0.22800 +0.95039,0.78823,0.22831 +0.95560,0.78005,0.22836 +0.96049,0.77181,0.22811 +0.96507,0.76352,0.22754 +0.96931,0.75519,0.22663 +0.97323,0.74682,0.22536 +0.97679,0.73842,0.22369 +0.98000,0.73000,0.22161 +0.98289,0.72140,0.21918 +0.98549,0.71250,0.21650 +0.98781,0.70330,0.21358 +0.98986,0.69382,0.21043 +0.99163,0.68408,0.20706 +0.99314,0.67408,0.20348 +0.99438,0.66386,0.19971 +0.99535,0.65341,0.19577 +0.99607,0.64277,0.19165 +0.99654,0.63193,0.18738 +0.99675,0.62093,0.18297 +0.99672,0.60977,0.17842 +0.99644,0.59846,0.17376 +0.99593,0.58703,0.16899 +0.99517,0.57549,0.16412 +0.99419,0.56386,0.15918 +0.99297,0.55214,0.15417 +0.99153,0.54036,0.14910 +0.98987,0.52854,0.14398 +0.98799,0.51667,0.13883 +0.98590,0.50479,0.13367 +0.98360,0.49291,0.12849 +0.98108,0.48104,0.12332 +0.97837,0.46920,0.11817 +0.97545,0.45740,0.11305 +0.97234,0.44565,0.10797 +0.96904,0.43399,0.10294 +0.96555,0.42241,0.09798 +0.96187,0.41093,0.09310 +0.95801,0.39958,0.08831 +0.95398,0.38836,0.08362 +0.94977,0.37729,0.07905 +0.94538,0.36638,0.07461 +0.94084,0.35566,0.07031 +0.93612,0.34513,0.06616 +0.93125,0.33482,0.06218 +0.92623,0.32473,0.05837 +0.92105,0.31489,0.05475 +0.91572,0.30530,0.05134 +0.91024,0.29599,0.04814 +0.90463,0.28696,0.04516 +0.89888,0.27824,0.04243 +0.89298,0.26981,0.03993 +0.88691,0.26152,0.03753 +0.88066,0.25334,0.03521 +0.87422,0.24526,0.03297 +0.86760,0.23730,0.03082 +0.86079,0.22945,0.02875 +0.85380,0.22170,0.02677 +0.84662,0.21407,0.02487 +0.83926,0.20654,0.02305 +0.83172,0.19912,0.02131 +0.82399,0.19182,0.01966 +0.81608,0.18462,0.01809 +0.80799,0.17753,0.01660 +0.79971,0.17055,0.01520 +0.79125,0.16368,0.01387 +0.78260,0.15693,0.01264 +0.77377,0.15028,0.01148 +0.76476,0.14374,0.01041 +0.75556,0.13731,0.00942 +0.74617,0.13098,0.00851 +0.73661,0.12477,0.00769 +0.72686,0.11867,0.00695 +0.71692,0.11268,0.00629 +0.70680,0.10680,0.00571 +0.69650,0.10102,0.00522 +0.68602,0.09536,0.00481 +0.67535,0.08980,0.00449 +0.66449,0.08436,0.00424 +0.65345,0.07902,0.00408 +0.64223,0.07380,0.00401 +0.63082,0.06868,0.00401 +0.61923,0.06367,0.00410 +0.60746,0.05878,0.00427 +0.59550,0.05399,0.00453 +0.58336,0.04931,0.00486 +0.57103,0.04474,0.00529 +0.55852,0.04028,0.00579 +0.54583,0.03593,0.00638 +0.53295,0.03169,0.00705 +0.51989,0.02756,0.00780 +0.50664,0.02354,0.00863 +0.49321,0.01963,0.00955 +0.47960,0.01583,0.01055 diff --git a/pyqtgraph/configfile.py b/pyqtgraph/configfile.py index 89004d9e3e..9f33d42b88 100644 --- a/pyqtgraph/configfile.py +++ b/pyqtgraph/configfile.py @@ -12,7 +12,6 @@ import os import re import sys -import tempfile from collections import OrderedDict import numpy @@ -193,24 +192,3 @@ def measureIndent(s): while n < len(s) and s[n] == ' ': n += 1 return n - -if __name__ == '__main__': - cf = """ -key: 'value' -key2: ##comment - ##comment - key21: 'value' ## comment - ##comment - key22: [1,2,3] - key23: 234 #comment - """ - with tempfile.NamedTemporaryFile(encoding="utf-8") as tf: - tf.write(cf.encode("utf-8")) - print("=== Test:===") - for num, line in enumerate(cf.split('\n'), start=1): - print("%02d %s" % (num, line)) - print(cf) - print("============") - data = readConfigFile(tf.name) - print(data) - diff --git a/pyqtgraph/console/CmdInput.py b/pyqtgraph/console/CmdInput.py index 2ef99476e7..c41f5763ee 100644 --- a/pyqtgraph/console/CmdInput.py +++ b/pyqtgraph/console/CmdInput.py @@ -7,9 +7,18 @@ class CmdInput(QtWidgets.QLineEdit): def __init__(self, parent): QtWidgets.QLineEdit.__init__(self, parent) + self.ps1 = ">>> " + self.ps2 = "... " self.history = [""] self.ptr = 0 + self.setMultiline(False) + def setMultiline(self, ml): + if ml: + self.setPlaceholderText(self.ps2) + else: + self.setPlaceholderText(self.ps1) + def keyPressEvent(self, ev): if ev.key() == QtCore.Qt.Key.Key_Up: if self.ptr < len(self.history) - 1: @@ -21,7 +30,7 @@ def keyPressEvent(self, ev): self.setHistory(self.ptr-1) ev.accept() return - elif ev.key() == QtCore.Qt.Key.Key_Return: + elif ev.key() in (QtCore.Qt.Key.Key_Return, QtCore.Qt.Key.Key_Enter): self.execCmd() else: super().keyPressEvent(ev) diff --git a/pyqtgraph/console/Console.py b/pyqtgraph/console/Console.py index b8f275f1ec..104947210c 100644 --- a/pyqtgraph/console/Console.py +++ b/pyqtgraph/console/Console.py @@ -1,17 +1,12 @@ -import importlib +import os +import sys import pickle -import re import subprocess -import sys -import traceback -from .. import exceptionHandling as exceptionHandling from .. import getConfigOption -from ..functions import SignalBlock -from ..Qt import QT_LIB, QtCore, QtGui, QtWidgets - -ui_template = importlib.import_module( - f'.template_{QT_LIB.lower()}', package=__package__) +from ..Qt import QtCore, QtWidgets +from .repl_widget import ReplWidget +from .exception_widget import ExceptionHandlerWidget class ConsoleWidget(QtWidgets.QWidget): @@ -32,11 +27,9 @@ class ConsoleWidget(QtWidgets.QWidget): - ability to add extra features like exception stack introspection - ability to have multiple interactive prompts, including for spawned sub-processes """ - _threadException = QtCore.Signal(object) - def __init__(self, parent=None, namespace=None, historyFile=None, text=None, editor=None): """ - ============== ============================================================================ + ============== ============================================================================= **Arguments:** namespace dictionary containing the initial variables present in the default namespace historyFile optional file for storing command history @@ -48,19 +41,18 @@ def __init__(self, parent=None, namespace=None, historyFile=None, text=None, edi ============== ============================================================================= """ QtWidgets.QWidget.__init__(self, parent) + + self._setupUi() + if namespace is None: namespace = {} namespace['__console__'] = self + self.localNamespace = namespace self.editor = editor - self.multiline = None - self.inCmd = False - self.frames = [] # stack frames to access when an item in the stack list is selected - self.ui = ui_template.Ui_Form() - self.ui.setupUi(self) - self.output = self.ui.output - self.input = self.ui.input + self.output = self.repl.output + self.input = self.repl.input self.input.setFocus() if text is not None: @@ -68,34 +60,70 @@ def __init__(self, parent=None, namespace=None, historyFile=None, text=None, edi self.historyFile = historyFile - history = self.loadHistory() + try: + history = self.loadHistory() + except Exception as exc: + sys.excepthook(*sys.exc_info()) + history = None if history is not None: self.input.history = [""] + history - self.ui.historyList.addItems(history[::-1]) - self.ui.historyList.hide() - self.ui.exceptionGroup.hide() - - self.input.sigExecuteCmd.connect(self.runCmd) - self.ui.historyBtn.toggled.connect(self.ui.historyList.setVisible) - self.ui.historyList.itemClicked.connect(self.cmdSelected) - self.ui.historyList.itemDoubleClicked.connect(self.cmdDblClicked) - self.ui.exceptionBtn.toggled.connect(self.ui.exceptionGroup.setVisible) - - self.ui.catchAllExceptionsBtn.toggled.connect(self.catchAllExceptions) - self.ui.catchNextExceptionBtn.toggled.connect(self.catchNextException) - self.ui.clearExceptionBtn.clicked.connect(self.clearExceptionClicked) - self.ui.exceptionStackList.itemClicked.connect(self.stackItemClicked) - self.ui.exceptionStackList.itemDoubleClicked.connect(self.stackItemDblClicked) - self.ui.onlyUncaughtCheck.toggled.connect(self.updateSysTrace) - + self.historyList.addItems(history[::-1]) + self.currentTraceback = None - # send exceptions raised in non-gui threads back to the main thread by signal. - self._threadException.connect(self._threadExceptionHandler) - + def _setupUi(self): + self.layout = QtWidgets.QGridLayout(self) + self.setLayout(self.layout) + self.layout.setContentsMargins(0, 0, 0, 0) + self.layout.setSpacing(0) + + self.splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical, self) + self.layout.addWidget(self.splitter, 0, 0) + + self.repl = ReplWidget(self.globals, self.locals, self) + self.splitter.addWidget(self.repl) + + self.historyList = QtWidgets.QListWidget(self) + self.historyList.hide() + self.splitter.addWidget(self.historyList) + + self.historyBtn = QtWidgets.QPushButton('History', self) + self.historyBtn.setCheckable(True) + self.repl.inputLayout.addWidget(self.historyBtn) + + self.repl.sigCommandEntered.connect(self._commandEntered) + self.repl.sigCommandRaisedException.connect(self._commandRaisedException) + + self.excHandler = ExceptionHandlerWidget(self) + self.excHandler.hide() + self.splitter.addWidget(self.excHandler) + + self.exceptionBtn = QtWidgets.QPushButton("Exceptions..", self) + self.exceptionBtn.setCheckable(True) + self.repl.inputLayout.addWidget(self.exceptionBtn) + + self.excHandler.sigStackItemDblClicked.connect(self._stackItemDblClicked) + self.exceptionBtn.toggled.connect(self.excHandler.setVisible) + self.historyBtn.toggled.connect(self.historyList.setVisible) + self.historyList.itemClicked.connect(self.cmdSelected) + self.historyList.itemDoubleClicked.connect(self.cmdDblClicked) + + def catchAllExceptions(self, catch=True): + if catch: + self.exceptionBtn.setChecked(True) + self.excHandler.catchAllExceptions(catch) + + def catchNextException(self, catch=True): + if catch: + self.exceptionBtn.setChecked(True) + self.excHandler.catchNextException(catch) + + def setStack(self, frame=None): + self.excHandler.setStack(frame) + def loadHistory(self): """Return the list of previously-invoked command strings (or None).""" - if self.historyFile is not None: + if self.historyFile is not None and os.path.exists(self.historyFile): with open(self.historyFile, 'rb') as pf: return pickle.load(pf) @@ -103,395 +131,48 @@ def saveHistory(self, history): """Store the list of previously-invoked command strings.""" if self.historyFile is not None: with open(self.historyFile, 'wb') as pf: - pickle.dump(pf, history) + pickle.dump(history, pf) - def runCmd(self, cmd): - #cmd = str(self.input.lastCmd) - - orig_stdout = sys.stdout - orig_stderr = sys.stderr - encCmd = re.sub(r'>', '>', re.sub(r'<', '<', cmd)) - encCmd = re.sub(r' ', ' ', encCmd) - - self.ui.historyList.addItem(cmd) + def _commandEntered(self, repl, cmd): + self.historyList.addItem(cmd) self.saveHistory(self.input.history[1:100]) - - try: - sys.stdout = self - sys.stderr = self - if self.multiline is not None: - self.write("
%s\n"%encCmd, html=True, scrollToBottom=True) - self.execMulti(cmd) - else: - self.write("
%s\n"%encCmd, html=True, scrollToBottom=True) - self.inCmd = True - self.execSingle(cmd) - - if not self.inCmd: - self.write("
\n", html=True, scrollToBottom=True) - - finally: - sys.stdout = orig_stdout - sys.stderr = orig_stderr - - sb = self.ui.historyList.verticalScrollBar() - sb.setValue(sb.maximum()) - + sb = self.historyList.verticalScrollBar() + sb.setValue(sb.maximum()) + + def _commandRaisedException(self, repl, exc): + self.excHandler.exceptionHandler(exc) + def globals(self): - frame = self.currentFrame() - if frame is not None and self.ui.runSelectedFrameCheck.isChecked(): - return self.currentFrame().f_globals + frame = self.excHandler.selectedFrame() + if frame is not None and self.excHandler.runSelectedFrameCheck.isChecked(): + return frame.f_globals else: return self.localNamespace def locals(self): - frame = self.currentFrame() - if frame is not None and self.ui.runSelectedFrameCheck.isChecked(): - return self.currentFrame().f_locals + frame = self.excHandler.selectedFrame() + if frame is not None and self.excHandler.runSelectedFrameCheck.isChecked(): + return frame.f_locals else: return self.localNamespace - - def currentFrame(self): - ## Return the currently selected exception stack frame (or None if there is no exception) - index = self.ui.exceptionStackList.currentRow() - if index >= 0 and index < len(self.frames): - return self.frames[index] - else: - return None - - def execSingle(self, cmd): - try: - output = eval(cmd, self.globals(), self.locals()) - self.write(repr(output) + '\n') - return - except SyntaxError: - pass - except: - self.displayException() - return - - # eval failed with syntax error; try exec instead - try: - exec(cmd, self.globals(), self.locals()) - except SyntaxError as exc: - if 'unexpected EOF' in exc.msg: - self.multiline = cmd - else: - self.displayException() - except: - self.displayException() - - def execMulti(self, nextLine): - if nextLine.strip() != '': - self.multiline += "\n" + nextLine - return - else: - cmd = self.multiline - - try: - output = eval(cmd, self.globals(), self.locals()) - self.write(str(output) + '\n') - self.multiline = None - return - except SyntaxError: - pass - except: - self.displayException() - self.multiline = None - return - - # eval failed with syntax error; try exec instead - try: - exec(cmd, self.globals(), self.locals()) - self.multiline = None - except SyntaxError as exc: - if 'unexpected EOF' in exc.msg: - self.multiline = cmd - else: - self.displayException() - self.multiline = None - except: - self.displayException() - self.multiline = None - - - def write(self, strn, html=False, scrollToBottom='auto'): - """Write a string into the console. - - If scrollToBottom is 'auto', then the console is automatically scrolled - to fit the new text only if it was already at the bottom. - """ - isGuiThread = QtCore.QThread.currentThread() == QtCore.QCoreApplication.instance().thread() - if not isGuiThread: - sys.__stdout__.write(strn) - return - - sb = self.output.verticalScrollBar() - scroll = sb.value() - if scrollToBottom == 'auto': - atBottom = scroll == sb.maximum() - scrollToBottom = atBottom - - self.output.moveCursor(QtGui.QTextCursor.MoveOperation.End) - if html: - self.output.textCursor().insertHtml(strn) - else: - if self.inCmd: - self.inCmd = False - self.output.textCursor().insertHtml("
") - self.output.insertPlainText(strn) - - if scrollToBottom: - sb.setValue(sb.maximum()) - else: - sb.setValue(scroll) - - - def fileno(self): - # Need to implement this since we temporarily occlude sys.stdout, and someone may be looking for it (faulthandler, for example) - return 1 - def displayException(self): - """ - Display the current exception and stack. - """ - tb = traceback.format_exc() - lines = [] - indent = 4 - prefix = '' - for l in tb.split('\n'): - lines.append(" "*indent + prefix + l) - self.write('\n'.join(lines)) - self.exceptionHandler(*sys.exc_info()) - def cmdSelected(self, item): - index = -(self.ui.historyList.row(item)+1) + index = -(self.historyList.row(item)+1) self.input.setHistory(index) self.input.setFocus() def cmdDblClicked(self, item): - index = -(self.ui.historyList.row(item)+1) + index = -(self.historyList.row(item)+1) self.input.setHistory(index) self.input.execCmd() - def flush(self): - pass - - def catchAllExceptions(self, catch=True): - """ - If True, the console will catch all unhandled exceptions and display the stack - trace. Each exception caught clears the last. - """ - with SignalBlock(self.ui.catchAllExceptionsBtn.toggled, self.catchAllExceptions): - self.ui.catchAllExceptionsBtn.setChecked(catch) - - if catch: - with SignalBlock(self.ui.catchNextExceptionBtn.toggled, self.catchNextException): - self.ui.catchNextExceptionBtn.setChecked(False) - self.enableExceptionHandling() - self.ui.exceptionBtn.setChecked(True) - else: - self.disableExceptionHandling() - - def catchNextException(self, catch=True): - """ - If True, the console will catch the next unhandled exception and display the stack - trace. - """ - with SignalBlock(self.ui.catchNextExceptionBtn.toggled, self.catchNextException): - self.ui.catchNextExceptionBtn.setChecked(catch) - if catch: - with SignalBlock(self.ui.catchAllExceptionsBtn.toggled, self.catchAllExceptions): - self.ui.catchAllExceptionsBtn.setChecked(False) - self.enableExceptionHandling() - self.ui.exceptionBtn.setChecked(True) - else: - self.disableExceptionHandling() - - def enableExceptionHandling(self): - exceptionHandling.register(self.exceptionHandler) - self.updateSysTrace() - - def disableExceptionHandling(self): - exceptionHandling.unregister(self.exceptionHandler) - self.updateSysTrace() - - def clearExceptionClicked(self): - self.currentTraceback = None - self.frames = [] - self.ui.exceptionInfoLabel.setText("[No current exception]") - self.ui.exceptionStackList.clear() - self.ui.clearExceptionBtn.setEnabled(False) - - def stackItemClicked(self, item): - pass - - def stackItemDblClicked(self, item): + def _stackItemDblClicked(self, handler, item): editor = self.editor if editor is None: editor = getConfigOption('editorCommand') if editor is None: return - tb = self.currentFrame() + tb = self.excHandler.selectedFrame() lineNum = tb.f_lineno fileName = tb.f_code.co_filename subprocess.Popen(self.editor.format(fileName=fileName, lineNum=lineNum), shell=True) - - def updateSysTrace(self): - ## Install or uninstall sys.settrace handler - - if not self.ui.catchNextExceptionBtn.isChecked() and not self.ui.catchAllExceptionsBtn.isChecked(): - if sys.gettrace() == self.systrace: - sys.settrace(None) - return - - if self.ui.onlyUncaughtCheck.isChecked(): - if sys.gettrace() == self.systrace: - sys.settrace(None) - else: - if sys.gettrace() is not None and sys.gettrace() != self.systrace: - self.ui.onlyUncaughtCheck.setChecked(False) - raise Exception("sys.settrace is in use; cannot monitor for caught exceptions.") - else: - sys.settrace(self.systrace) - - def exceptionHandler(self, excType, exc, tb, systrace=False, frame=None): - if frame is None: - frame = sys._getframe() - - # exceptions raised in non-gui threads must be handled separately - isGuiThread = QtCore.QThread.currentThread() == QtCore.QCoreApplication.instance().thread() - if not isGuiThread: - # sending a frame from one thread to another.. probably not safe, but better than just - # dropping the exception? - self._threadException.emit((excType, exc, tb, systrace, frame.f_back)) - return - - if self.ui.catchNextExceptionBtn.isChecked(): - self.ui.catchNextExceptionBtn.setChecked(False) - elif not self.ui.catchAllExceptionsBtn.isChecked(): - return - - self.currentTraceback = tb - - excMessage = ''.join(traceback.format_exception_only(excType, exc)) - self.ui.exceptionInfoLabel.setText(excMessage) - - if systrace: - # exceptions caught using systrace don't need the usual - # call stack + traceback handling - self.setStack(frame.f_back.f_back) - else: - self.setStack(frame=frame.f_back, tb=tb) - - def _threadExceptionHandler(self, args): - self.exceptionHandler(*args) - - def setStack(self, frame=None, tb=None): - """Display a call stack and exception traceback. - - This allows the user to probe the contents of any frame in the given stack. - - *frame* may either be a Frame instance or None, in which case the current - frame is retrieved from ``sys._getframe()``. - - If *tb* is provided then the frames in the traceback will be appended to - the end of the stack list. If *tb* is None, then sys.exc_info() will - be checked instead. - """ - self.ui.clearExceptionBtn.setEnabled(True) - - if frame is None: - frame = sys._getframe().f_back - - if tb is None: - tb = sys.exc_info()[2] - - self.ui.exceptionStackList.clear() - self.frames = [] - - # Build stack up to this point - for index, line in enumerate(traceback.extract_stack(frame)): - # extract_stack return value changed in python 3.5 - if 'FrameSummary' in str(type(line)): - line = (line.filename, line.lineno, line.name, line._line) - - self.ui.exceptionStackList.addItem('File "%s", line %s, in %s()\n %s' % line) - while frame is not None: - self.frames.insert(0, frame) - frame = frame.f_back - - if tb is None: - return - - self.ui.exceptionStackList.addItem('-- exception caught here: --') - item = self.ui.exceptionStackList.item(self.ui.exceptionStackList.count()-1) - item.setBackground(QtGui.QBrush(QtGui.QColor(200, 200, 200))) - item.setForeground(QtGui.QBrush(QtGui.QColor(50, 50, 50))) - self.frames.append(None) - - # And finish the rest of the stack up to the exception - for index, line in enumerate(traceback.extract_tb(tb)): - # extract_stack return value changed in python 3.5 - if 'FrameSummary' in str(type(line)): - line = (line.filename, line.lineno, line.name, line._line) - - self.ui.exceptionStackList.addItem('File "%s", line %s, in %s()\n %s' % line) - while tb is not None: - self.frames.append(tb.tb_frame) - tb = tb.tb_next - - def systrace(self, frame, event, arg): - if event == 'exception' and self.checkException(*arg): - self.exceptionHandler(*arg, systrace=True) - return self.systrace - - def checkException(self, excType, exc, tb): - ## Return True if the exception is interesting; False if it should be ignored. - - filename = tb.tb_frame.f_code.co_filename - function = tb.tb_frame.f_code.co_name - - filterStr = str(self.ui.filterText.text()) - if filterStr != '': - if isinstance(exc, Exception): - msg = traceback.format_exception_only(type(exc), exc) - elif isinstance(exc, str): - msg = exc - else: - msg = repr(exc) - match = re.search(filterStr, "%s:%s:%s" % (filename, function, msg)) - return match is not None - - ## Go through a list of common exception points we like to ignore: - if excType is GeneratorExit or excType is StopIteration: - return False - if excType is KeyError: - if filename.endswith('python2.7/weakref.py') and function in ('__contains__', 'get'): - return False - if filename.endswith('python2.7/copy.py') and function == '_keep_alive': - return False - if excType is AttributeError: - if filename.endswith('python2.7/collections.py') and function == '__init__': - return False - if filename.endswith('numpy/core/fromnumeric.py') and function in ('all', '_wrapit', 'transpose', 'sum'): - return False - if filename.endswith('numpy/core/arrayprint.py') and function in ('_array2string'): - return False - if filename.endswith('MetaArray.py') and function == '__getattr__': - for name in ('__array_interface__', '__array_struct__', '__array__'): ## numpy looks for these when converting objects to array - if name in exc: - return False - if filename.endswith('flowchart/eq.py'): - return False - if filename.endswith('pyqtgraph/functions.py') and function == 'makeQImage': - return False - if excType is TypeError: - if filename.endswith('numpy/lib/function_base.py') and function == 'iterable': - return False - if excType is ZeroDivisionError: - if filename.endswith('python2.7/traceback.py'): - return False - - return True - diff --git a/pyqtgraph/console/exception_widget.py b/pyqtgraph/console/exception_widget.py new file mode 100644 index 0000000000..73e4990840 --- /dev/null +++ b/pyqtgraph/console/exception_widget.py @@ -0,0 +1,243 @@ +import sys, re, traceback, threading +from .. import exceptionHandling as exceptionHandling +from ..Qt import QtWidgets, QtCore +from ..functions import SignalBlock +from .stackwidget import StackWidget + + +class ExceptionHandlerWidget(QtWidgets.QGroupBox): + sigStackItemClicked = QtCore.Signal(object, object) # self, item + sigStackItemDblClicked = QtCore.Signal(object, object) # self, item + _threadException = QtCore.Signal(object) + + def __init__(self, parent=None): + super().__init__(parent) + self._setupUi() + + self.filterString = '' + self._inSystrace = False + + # send exceptions raised in non-gui threads back to the main thread by signal. + self._threadException.connect(self._threadExceptionHandler) + + def _setupUi(self): + self.setTitle("Exception Handling") + + self.layout = QtWidgets.QGridLayout(self) + self.layout.setContentsMargins(0, 0, 0, 0) + self.layout.setHorizontalSpacing(2) + self.layout.setVerticalSpacing(0) + + self.clearExceptionBtn = QtWidgets.QPushButton("Clear Stack", self) + self.clearExceptionBtn.setEnabled(False) + self.layout.addWidget(self.clearExceptionBtn, 0, 6, 1, 1) + + self.catchAllExceptionsBtn = QtWidgets.QPushButton("Show All Exceptions", self) + self.catchAllExceptionsBtn.setCheckable(True) + self.layout.addWidget(self.catchAllExceptionsBtn, 0, 1, 1, 1) + + self.catchNextExceptionBtn = QtWidgets.QPushButton("Show Next Exception", self) + self.catchNextExceptionBtn.setCheckable(True) + self.layout.addWidget(self.catchNextExceptionBtn, 0, 0, 1, 1) + + self.onlyUncaughtCheck = QtWidgets.QCheckBox("Only Uncaught Exceptions", self) + self.onlyUncaughtCheck.setChecked(True) + self.layout.addWidget(self.onlyUncaughtCheck, 0, 4, 1, 1) + + self.stackTree = StackWidget(self) + self.layout.addWidget(self.stackTree, 2, 0, 1, 7) + + self.runSelectedFrameCheck = QtWidgets.QCheckBox("Run commands in selected stack frame", self) + self.runSelectedFrameCheck.setChecked(True) + self.layout.addWidget(self.runSelectedFrameCheck, 3, 0, 1, 7) + + spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + self.layout.addItem(spacerItem, 0, 5, 1, 1) + + self.filterLabel = QtWidgets.QLabel("Filter (regex):", self) + self.layout.addWidget(self.filterLabel, 0, 2, 1, 1) + + self.filterText = QtWidgets.QLineEdit(self) + self.layout.addWidget(self.filterText, 0, 3, 1, 1) + + self.catchAllExceptionsBtn.toggled.connect(self.catchAllExceptions) + self.catchNextExceptionBtn.toggled.connect(self.catchNextException) + self.clearExceptionBtn.clicked.connect(self.clearExceptionClicked) + self.stackTree.itemClicked.connect(self.stackItemClicked) + self.stackTree.itemDoubleClicked.connect(self.stackItemDblClicked) + self.onlyUncaughtCheck.toggled.connect(self.updateSysTrace) + self.filterText.textChanged.connect(self._filterTextChanged) + + def setStack(self, frame=None): + self.clearExceptionBtn.setEnabled(True) + self.stackTree.setStack(frame) + + def setException(self, exc=None, lastFrame=None): + self.clearExceptionBtn.setEnabled(True) + self.stackTree.setException(exc, lastFrame=lastFrame) + + def selectedFrame(self): + return self.stackTree.selectedFrame() + + def catchAllExceptions(self, catch=True): + """ + If True, the console will catch all unhandled exceptions and display the stack + trace. Each exception caught clears the last. + """ + with SignalBlock(self.catchAllExceptionsBtn.toggled, self.catchAllExceptions): + self.catchAllExceptionsBtn.setChecked(catch) + + if catch: + with SignalBlock(self.catchNextExceptionBtn.toggled, self.catchNextException): + self.catchNextExceptionBtn.setChecked(False) + self.enableExceptionHandling() + else: + self.disableExceptionHandling() + + def catchNextException(self, catch=True): + """ + If True, the console will catch the next unhandled exception and display the stack + trace. + """ + with SignalBlock(self.catchNextExceptionBtn.toggled, self.catchNextException): + self.catchNextExceptionBtn.setChecked(catch) + if catch: + with SignalBlock(self.catchAllExceptionsBtn.toggled, self.catchAllExceptions): + self.catchAllExceptionsBtn.setChecked(False) + self.enableExceptionHandling() + else: + self.disableExceptionHandling() + + def enableExceptionHandling(self): + exceptionHandling.registerCallback(self.exceptionHandler) + self.updateSysTrace() + + def disableExceptionHandling(self): + exceptionHandling.unregisterCallback(self.exceptionHandler) + self.updateSysTrace() + + def clearExceptionClicked(self): + self.stackTree.clear() + self.clearExceptionBtn.setEnabled(False) + + def updateSysTrace(self): + ## Install or uninstall sys.settrace handler + + if not self.catchNextExceptionBtn.isChecked() and not self.catchAllExceptionsBtn.isChecked(): + if sys.gettrace() == self.systrace: + self._disableSysTrace() + return + + if self.onlyUncaughtCheck.isChecked(): + if sys.gettrace() == self.systrace: + self._disableSysTrace() + else: + if sys.gettrace() not in (None, self.systrace): + self.onlyUncaughtCheck.setChecked(False) + raise Exception("sys.settrace is in use (are you using another debugger?); cannot monitor for caught exceptions.") + else: + self._enableSysTrace() + + def _enableSysTrace(self): + # set global trace function + # note: this has no effect on pre-existing frames or threads + # until settrace_all_threads arrives in python 3.12. + sys.settrace(self.systrace) # affects current thread only + threading.settrace(self.systrace) # affects new threads only + if hasattr(threading, 'settrace_all_threads'): + threading.settrace_all_threads(self.systrace) + + def _disableSysTrace(self): + sys.settrace(None) + threading.settrace(None) + if hasattr(threading, 'settrace_all_threads'): + threading.settrace_all_threads(None) + + def exceptionHandler(self, excInfo, lastFrame=None): + if isinstance(excInfo, Exception): + exc = excInfo + else: + exc = excInfo.exc_value + + # exceptions raised in non-gui threads must be sent to the gui thread by signal + isGuiThread = QtCore.QThread.currentThread() == QtCore.QCoreApplication.instance().thread() + if not isGuiThread: + # note: we are giving the user the ability to modify a frame owned by another thread.. + # expect trouble :) + self._threadException.emit((excInfo, lastFrame)) + return + + if self.catchNextExceptionBtn.isChecked(): + self.catchNextExceptionBtn.setChecked(False) + elif not self.catchAllExceptionsBtn.isChecked(): + return + + self.setException(exc, lastFrame=lastFrame) + + def _threadExceptionHandler(self, args): + self.exceptionHandler(*args) + + def systrace(self, frame, event, arg): + if event != 'exception': + return self.systrace + + if self._inSystrace: + # prevent recursve calling + return self.systrace + self._inSystrace = True + try: + if self.checkException(*arg): + # note: the exception has no __traceback__ at this point! + self.exceptionHandler(arg[1], lastFrame=frame) + except Exception as exc: + print("Exception in systrace:") + traceback.print_exc() + finally: + self.inSystrace = False + return self.systrace + + def checkException(self, excType, exc, tb): + ## Return True if the exception is interesting; False if it should be ignored. + + filename = tb.tb_frame.f_code.co_filename + function = tb.tb_frame.f_code.co_name + + filterStr = self.filterString + if filterStr != '': + if isinstance(exc, Exception): + msg = traceback.format_exception_only(type(exc), exc) + elif isinstance(exc, str): + msg = exc + else: + msg = repr(exc) + match = re.search(filterStr, "%s:%s:%s" % (filename, function, msg)) + return match is not None + + ## Go through a list of common exception points we like to ignore: + if excType is GeneratorExit or excType is StopIteration: + return False + if excType is AttributeError: + if filename.endswith('numpy/core/fromnumeric.py') and function in ('all', '_wrapit', 'transpose', 'sum'): + return False + if filename.endswith('numpy/core/arrayprint.py') and function in ('_array2string'): + return False + if filename.endswith('MetaArray.py') and function == '__getattr__': + for name in ('__array_interface__', '__array_struct__', '__array__'): ## numpy looks for these when converting objects to array + if name in exc: + return False + if filename.endswith('flowchart/eq.py'): + return False + if excType is TypeError: + if filename.endswith('numpy/lib/function_base.py') and function == 'iterable': + return False + + return True + + def stackItemClicked(self, item): + self.sigStackItemClicked.emit(self, item) + + def stackItemDblClicked(self, item): + self.sigStackItemDblClicked.emit(self, item) + + def _filterTextChanged(self, value): + self.filterString = str(value) diff --git a/pyqtgraph/console/repl_widget.py b/pyqtgraph/console/repl_widget.py new file mode 100644 index 0000000000..ec095f689e --- /dev/null +++ b/pyqtgraph/console/repl_widget.py @@ -0,0 +1,219 @@ +import code, sys, traceback +from ..Qt import QtWidgets, QtGui, QtCore +from ..functions import mkBrush +from .CmdInput import CmdInput + + +class ReplWidget(QtWidgets.QWidget): + sigCommandEntered = QtCore.Signal(object, object) # self, command + sigCommandRaisedException = QtCore.Signal(object, object) # self, exc + + def __init__(self, globals, locals, parent=None): + self.globals = globals + self.locals = locals + self._lastCommandRow = None + self._commandBuffer = [] # buffer to hold multiple lines of input + self.stdoutInterceptor = StdoutInterceptor(self.write) + self.ps1 = ">>> " + self.ps2 = "... " + + QtWidgets.QWidget.__init__(self, parent=parent) + + self._setupUi() + + # define text styles + isDark = self.output.palette().color(QtGui.QPalette.ColorRole.Base).value() < 128 + outputBlockFormat = QtGui.QTextBlockFormat() + outputFirstLineBlockFormat = QtGui.QTextBlockFormat(outputBlockFormat) + outputFirstLineBlockFormat.setTopMargin(5) + outputCharFormat = QtGui.QTextCharFormat() + outputCharFormat.setFontWeight(QtGui.QFont.Weight.Normal) + cmdBlockFormat = QtGui.QTextBlockFormat() + cmdBlockFormat.setBackground(mkBrush("#335" if isDark else "#CCF")) + cmdCharFormat = QtGui.QTextCharFormat() + cmdCharFormat.setFontWeight(QtGui.QFont.Weight.Bold) + self.textStyles = { + 'command': (cmdCharFormat, cmdBlockFormat), + 'output': (outputCharFormat, outputBlockFormat), + 'output_first_line': (outputCharFormat, outputFirstLineBlockFormat), + } + + self.input.ps1 = self.ps1 + self.input.ps2 = self.ps2 + + def _setupUi(self): + self.layout = QtWidgets.QVBoxLayout(self) + self.layout.setContentsMargins(0, 0, 0, 0) + self.layout.setSpacing(0) + self.setLayout(self.layout) + + self.output = QtWidgets.QTextEdit(self) + font = QtGui.QFont() + font.setFamily("Courier New") + font.setStyleStrategy(QtGui.QFont.StyleStrategy.PreferAntialias) + self.output.setFont(font) + self.output.setReadOnly(True) + self.layout.addWidget(self.output) + + # put input box in a horizontal layout so we can easily place buttons at the end + self.inputWidget = QtWidgets.QWidget(self) + self.layout.addWidget(self.inputWidget) + self.inputLayout = QtWidgets.QHBoxLayout() + self.inputWidget.setLayout(self.inputLayout) + self.inputLayout.setContentsMargins(0, 0, 0, 0) + + self.input = CmdInput(parent=self) + self.inputLayout.addWidget(self.input) + + self.input.sigExecuteCmd.connect(self.runCmd) + + def runCmd(self, cmd): + if '\n' in cmd: + for line in cmd.split('\n'): + self.runCmd(line) + return + + if len(self._commandBuffer) == 0: + self.write(f"{self.ps1}{cmd}\n", style='command') + else: + self.write(f"{self.ps2}{cmd}\n", style='command') + + self.sigCommandEntered.emit(self, cmd) + self._commandBuffer.append(cmd) + + fullcmd = '\n'.join(self._commandBuffer) + try: + cmdCode = code.compile_command(fullcmd) + self.input.setMultiline(False) + except Exception: + # cannot continue processing this command; reset and print exception + self._commandBuffer = [] + self.displayException() + self.input.setMultiline(False) + else: + if cmdCode is None: + # incomplete input; wait for next line + self.input.setMultiline(True) + return + + self._commandBuffer = [] + + # run command + try: + with self.stdoutInterceptor: + exec(cmdCode, self.globals(), self.locals()) + except Exception as exc: + self.displayException() + self.sigCommandRaisedException.emit(self, exc) + + # Add a newline if the output did not + cursor = self.output.textCursor() + if cursor.columnNumber() > 0: + self.write('\n') + + def write(self, strn, style='output', scrollToBottom='auto'): + """Write a string into the console. + + If scrollToBottom is 'auto', then the console is automatically scrolled + to fit the new text only if it was already at the bottom. + """ + isGuiThread = QtCore.QThread.currentThread() == QtCore.QCoreApplication.instance().thread() + if not isGuiThread: + sys.__stdout__.write(strn) + return + + cursor = self.output.textCursor() + cursor.movePosition(QtGui.QTextCursor.MoveOperation.End) + self.output.setTextCursor(cursor) + + sb = self.output.verticalScrollBar() + scroll = sb.value() + if scrollToBottom == 'auto': + atBottom = scroll == sb.maximum() + scrollToBottom = atBottom + + row = cursor.blockNumber() + if style == 'command': + self._lastCommandRow = row + + if style == 'output' and row == self._lastCommandRow + 1: + # adjust style for first line of output + firstLine, endl, strn = strn.partition('\n') + self._setTextStyle('output_first_line') + self.output.insertPlainText(firstLine + endl) + + if len(strn) > 0: + self._setTextStyle(style) + self.output.insertPlainText(strn) + # return to output style immediately to avoid seeing an extra line of command style + if style != 'output': + self._setTextStyle('output') + + if scrollToBottom: + sb.setValue(sb.maximum()) + else: + sb.setValue(scroll) + + def displayException(self): + """ + Display the current exception and stack. + """ + tb = traceback.format_exc() + lines = [] + indent = 4 + prefix = '' + for l in tb.split('\n'): + lines.append(" "*indent + prefix + l) + self.write('\n'.join(lines)) + + def _setTextStyle(self, style): + charFormat, blockFormat = self.textStyles[style] + cursor = self.output.textCursor() + cursor.setBlockFormat(blockFormat) + self.output.setCurrentCharFormat(charFormat) + + +class StdoutInterceptor: + """Used to temporarily redirect writes meant for sys.stdout and sys.stderr to a new location + """ + def __init__(self, writeFn): + self._orig_stdout = None + self._orig_stderr = None + self.writeFn = writeFn + + def realOutputFiles(self): + """Return the real sys.stdout and stderr (which are sometimes masked while running commands) + """ + return ( + self._orig_stdout or sys.stdout, + self._orig_stderr or sys.stderr + ) + + def print(self, *args): + """Print to real stdout (for debugging) + """ + self.realOutputFiles()[0].write(' '.join(map(str, args)) + "\n") + + def flush(self): + # Need to implement this since we temporarily occlude sys.stdout + pass + + def fileno(self): + # Need to implement this since we temporarily occlude sys.stdout, and someone may be looking for it (faulthandler, for example) + return 1 + + def write(self, strn): + self.writeFn(strn) + + def __enter__(self): + self._orig_stdout = sys.stdout + self._orig_stderr = sys.stderr + sys.stdout = self + sys.stderr = self + + def __exit__(self, exc_type, exc_val, exc_tb): + sys.stdout = self._orig_stdout + sys.stderr = self._orig_stderr + self._orig_stdout = None + self._orig_stderr = None + diff --git a/pyqtgraph/console/stackwidget.py b/pyqtgraph/console/stackwidget.py new file mode 100644 index 0000000000..265b83b9e5 --- /dev/null +++ b/pyqtgraph/console/stackwidget.py @@ -0,0 +1,157 @@ +import sys, traceback +from ..Qt import QtWidgets, QtGui + + +class StackWidget(QtWidgets.QTreeWidget): + def __init__(self, parent=None): + QtWidgets.QTreeWidget.__init__(self, parent) + self.setAlternatingRowColors(True) + self.setHeaderHidden(True) + + def selectedFrame(self): + """Return the currently selected stack frame (or None if there is no selection) + """ + sel = self.selectedItems() + if len(sel) == 0: + return None + else: + return sel[0].frame + + def clear(self): + QtWidgets.QTreeWidget.clear(self) + self.frames = [] + + def setException(self, exc=None, lastFrame=None): + """Display an exception chain with its tracebacks and call stack. + """ + if exc is None: + exc = sys.exc_info()[1] + + self.clear() + + exceptions = exceptionChain(exc) + for ex, cause in exceptions: + stackFrames, tbFrames = stacksFromTraceback(ex.__traceback__, lastFrame=lastFrame) + catchMsg = textItem("Exception caught here") + excStr = ''.join(traceback.format_exception_only(type(ex), ex)).strip() + items = makeItemTree(stackFrames + [catchMsg] + tbFrames, excStr) + self.addTopLevelItem(items[0]) + if cause is not None: + if cause == 'cause': + causeItem = textItem("The above exception was the direct cause of the following exception:") + elif cause == 'context': + causeItem = textItem("During handling of the above exception, another exception occurred:") + self.addTopLevelItem(causeItem) + + items[0].setExpanded(True) + + def setStack(self, frame=None, expand=True, lastFrame=None): + """Display a call stack and exception traceback. + + This allows the user to probe the contents of any frame in the given stack. + + *frame* may either be a Frame instance or None, in which case the current + frame is retrieved from ``sys._getframe()``. + + If *tb* is provided then the frames in the traceback will be appended to + the end of the stack list. If *tb* is None, then sys.exc_info() will + be checked instead. + """ + if frame is None: + frame = sys._getframe().f_back + + self.clear() + + stack = stackFromFrame(frame, lastFrame=lastFrame) + items = makeItemTree(stack, "Call stack") + self.addTopLevelItem(items[0]) + if expand: + items[0].setExpanded(True) + + +def stackFromFrame(frame, lastFrame=None): + """Return (text, stack_frame) for the entire stack ending at *frame* + + If *lastFrame* is given and present in the stack, then the stack is truncated + at that frame. + """ + lines = traceback.format_stack(frame) + frames = [] + while frame is not None: + frames.insert(0, frame) + frame = frame.f_back + if lastFrame is not None and lastFrame in frames: + frames = frames[:frames.index(lastFrame)+1] + + return list(zip(lines[:len(frames)], frames)) + + +def stacksFromTraceback(tb, lastFrame=None): + """Return (text, stack_frame) for a traceback and the stack preceding it + + If *lastFrame* is given and present in the stack, then the stack is truncated + at that frame. + """ + # get stack before tb + stack = stackFromFrame(tb.tb_frame.f_back if tb is not None else lastFrame) + if tb is None: + return stack, [] + + # walk to last frame of traceback + lines = traceback.format_tb(tb) + frames = [] + while True: + frames.append(tb.tb_frame) + if tb.tb_next is None or tb.tb_frame is lastFrame: + break + tb = tb.tb_next + + return stack, list(zip(lines[:len(frames)], frames)) + + +def makeItemTree(stack, title): + topItem = QtWidgets.QTreeWidgetItem([title]) + topItem.frame = None + font = topItem.font(0) + font.setWeight(font.Weight.Bold) + topItem.setFont(0, font) + items = [topItem] + for entry in stack: + if isinstance(entry, QtWidgets.QTreeWidgetItem): + item = entry + else: + text, frame = entry + item = QtWidgets.QTreeWidgetItem([text.rstrip()]) + item.frame = frame + topItem.addChild(item) + items.append(item) + return items + + +def exceptionChain(exc): + """Return a list of (exception, 'cause'|'context') pairs for exceptions + leading up to *exc* + """ + exceptions = [(exc, None)] + while True: + # walk through chained exceptions + if exc.__cause__ is not None: + exc = exc.__cause__ + exceptions.insert(0, (exc, 'cause')) + elif exc.__context__ is not None and exc.__suppress_context__ is False: + exc = exc.__context__ + exceptions.insert(0, (exc, 'context')) + else: + break + return exceptions + + +def textItem(text): + """Return a tree item with no associated stack frame and a darker background color + """ + item = QtWidgets.QTreeWidgetItem([text]) + item.frame = None + item.setBackground(0, QtGui.QBrush(QtGui.QColor(220, 220, 220))) + item.setForeground(0, QtGui.QBrush(QtGui.QColor(0, 0, 0))) + item.setChildIndicatorPolicy(item.ChildIndicatorPolicy.DontShowIndicator) + return item diff --git a/pyqtgraph/console/template.ui b/pyqtgraph/console/template.ui deleted file mode 100644 index 1237b5f3b1..0000000000 --- a/pyqtgraph/console/template.ui +++ /dev/null @@ -1,200 +0,0 @@ - - - Form - - - - 0 - 0 - 739 - 497 - - - - Console - - - - 0 - - - 0 - - - - - Qt::Vertical - - - - - - - - Monospace - - - - true - - - - - - - - - - - - History.. - - - true - - - - - - - Exceptions.. - - - true - - - - - - - - - - - Monospace - - - - - - Exception Handling - - - - 0 - - - 0 - - - 2 - - - 0 - - - - - false - - - Clear Stack - - - - - - - Show All Exceptions - - - true - - - - - - - Show Next Exception - - - true - - - - - - - Only Uncaught Exceptions - - - true - - - - - - - true - - - - - - - Run commands in selected stack frame - - - true - - - - - - - Stack Trace - - - true - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Filter (regex): - - - - - - - - - - - - - - - CmdInput - QLineEdit -
.CmdInput
-
-
- - -
diff --git a/pyqtgraph/console/template_pyqt5.py b/pyqtgraph/console/template_pyqt5.py deleted file mode 100644 index f72ff241d9..0000000000 --- a/pyqtgraph/console/template_pyqt5.py +++ /dev/null @@ -1,114 +0,0 @@ - -# Form implementation generated from reading ui file 'pyqtgraph/console/template.ui' -# -# Created by: PyQt5 UI code generator 5.5.1 -# -# WARNING! All changes made in this file will be lost! - -from PyQt5 import QtCore, QtGui, QtWidgets - - -class Ui_Form(object): - def setupUi(self, Form): - Form.setObjectName("Form") - Form.resize(739, 497) - self.gridLayout = QtWidgets.QGridLayout(Form) - self.gridLayout.setContentsMargins(0, 0, 0, 0) - self.gridLayout.setSpacing(0) - self.gridLayout.setObjectName("gridLayout") - self.splitter = QtWidgets.QSplitter(Form) - self.splitter.setOrientation(QtCore.Qt.Vertical) - self.splitter.setObjectName("splitter") - self.layoutWidget = QtWidgets.QWidget(self.splitter) - self.layoutWidget.setObjectName("layoutWidget") - self.verticalLayout = QtWidgets.QVBoxLayout(self.layoutWidget) - self.verticalLayout.setObjectName("verticalLayout") - self.output = QtWidgets.QPlainTextEdit(self.layoutWidget) - font = QtGui.QFont() - font.setFamily("Monospace") - self.output.setFont(font) - self.output.setReadOnly(True) - self.output.setObjectName("output") - self.verticalLayout.addWidget(self.output) - self.horizontalLayout = QtWidgets.QHBoxLayout() - self.horizontalLayout.setObjectName("horizontalLayout") - self.input = CmdInput(self.layoutWidget) - self.input.setObjectName("input") - self.horizontalLayout.addWidget(self.input) - self.historyBtn = QtWidgets.QPushButton(self.layoutWidget) - self.historyBtn.setCheckable(True) - self.historyBtn.setObjectName("historyBtn") - self.horizontalLayout.addWidget(self.historyBtn) - self.exceptionBtn = QtWidgets.QPushButton(self.layoutWidget) - self.exceptionBtn.setCheckable(True) - self.exceptionBtn.setObjectName("exceptionBtn") - self.horizontalLayout.addWidget(self.exceptionBtn) - self.verticalLayout.addLayout(self.horizontalLayout) - self.historyList = QtWidgets.QListWidget(self.splitter) - font = QtGui.QFont() - font.setFamily("Monospace") - self.historyList.setFont(font) - self.historyList.setObjectName("historyList") - self.exceptionGroup = QtWidgets.QGroupBox(self.splitter) - self.exceptionGroup.setObjectName("exceptionGroup") - self.gridLayout_2 = QtWidgets.QGridLayout(self.exceptionGroup) - self.gridLayout_2.setContentsMargins(-1, 0, -1, 0) - self.gridLayout_2.setHorizontalSpacing(2) - self.gridLayout_2.setVerticalSpacing(0) - self.gridLayout_2.setObjectName("gridLayout_2") - self.clearExceptionBtn = QtWidgets.QPushButton(self.exceptionGroup) - self.clearExceptionBtn.setEnabled(False) - self.clearExceptionBtn.setObjectName("clearExceptionBtn") - self.gridLayout_2.addWidget(self.clearExceptionBtn, 0, 6, 1, 1) - self.catchAllExceptionsBtn = QtWidgets.QPushButton(self.exceptionGroup) - self.catchAllExceptionsBtn.setCheckable(True) - self.catchAllExceptionsBtn.setObjectName("catchAllExceptionsBtn") - self.gridLayout_2.addWidget(self.catchAllExceptionsBtn, 0, 1, 1, 1) - self.catchNextExceptionBtn = QtWidgets.QPushButton(self.exceptionGroup) - self.catchNextExceptionBtn.setCheckable(True) - self.catchNextExceptionBtn.setObjectName("catchNextExceptionBtn") - self.gridLayout_2.addWidget(self.catchNextExceptionBtn, 0, 0, 1, 1) - self.onlyUncaughtCheck = QtWidgets.QCheckBox(self.exceptionGroup) - self.onlyUncaughtCheck.setChecked(True) - self.onlyUncaughtCheck.setObjectName("onlyUncaughtCheck") - self.gridLayout_2.addWidget(self.onlyUncaughtCheck, 0, 4, 1, 1) - self.exceptionStackList = QtWidgets.QListWidget(self.exceptionGroup) - self.exceptionStackList.setAlternatingRowColors(True) - self.exceptionStackList.setObjectName("exceptionStackList") - self.gridLayout_2.addWidget(self.exceptionStackList, 2, 0, 1, 7) - self.runSelectedFrameCheck = QtWidgets.QCheckBox(self.exceptionGroup) - self.runSelectedFrameCheck.setChecked(True) - self.runSelectedFrameCheck.setObjectName("runSelectedFrameCheck") - self.gridLayout_2.addWidget(self.runSelectedFrameCheck, 3, 0, 1, 7) - self.exceptionInfoLabel = QtWidgets.QLabel(self.exceptionGroup) - self.exceptionInfoLabel.setWordWrap(True) - self.exceptionInfoLabel.setObjectName("exceptionInfoLabel") - self.gridLayout_2.addWidget(self.exceptionInfoLabel, 1, 0, 1, 7) - spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) - self.gridLayout_2.addItem(spacerItem, 0, 5, 1, 1) - self.label = QtWidgets.QLabel(self.exceptionGroup) - self.label.setObjectName("label") - self.gridLayout_2.addWidget(self.label, 0, 2, 1, 1) - self.filterText = QtWidgets.QLineEdit(self.exceptionGroup) - self.filterText.setObjectName("filterText") - self.gridLayout_2.addWidget(self.filterText, 0, 3, 1, 1) - self.gridLayout.addWidget(self.splitter, 0, 0, 1, 1) - - self.retranslateUi(Form) - QtCore.QMetaObject.connectSlotsByName(Form) - - def retranslateUi(self, Form): - _translate = QtCore.QCoreApplication.translate - Form.setWindowTitle(_translate("Form", "Console")) - self.historyBtn.setText(_translate("Form", "History..")) - self.exceptionBtn.setText(_translate("Form", "Exceptions..")) - self.exceptionGroup.setTitle(_translate("Form", "Exception Handling")) - self.clearExceptionBtn.setText(_translate("Form", "Clear Stack")) - self.catchAllExceptionsBtn.setText(_translate("Form", "Show All Exceptions")) - self.catchNextExceptionBtn.setText(_translate("Form", "Show Next Exception")) - self.onlyUncaughtCheck.setText(_translate("Form", "Only Uncaught Exceptions")) - self.runSelectedFrameCheck.setText(_translate("Form", "Run commands in selected stack frame")) - self.exceptionInfoLabel.setText(_translate("Form", "Stack Trace")) - self.label.setText(_translate("Form", "Filter (regex):")) - -from .CmdInput import CmdInput diff --git a/pyqtgraph/console/template_pyqt6.py b/pyqtgraph/console/template_pyqt6.py deleted file mode 100644 index 03a1a98d3c..0000000000 --- a/pyqtgraph/console/template_pyqt6.py +++ /dev/null @@ -1,115 +0,0 @@ -# Form implementation generated from reading ui file '../pyqtgraph/console/template.ui' -# -# Created by: PyQt6 UI code generator 6.1.0 -# -# WARNING: Any manual changes made to this file will be lost when pyuic6 is -# run again. Do not edit this file unless you know what you are doing. - - -from PyQt6 import QtCore, QtGui, QtWidgets - - -class Ui_Form(object): - def setupUi(self, Form): - Form.setObjectName("Form") - Form.resize(739, 497) - self.gridLayout = QtWidgets.QGridLayout(Form) - self.gridLayout.setContentsMargins(0, 0, 0, 0) - self.gridLayout.setSpacing(0) - self.gridLayout.setObjectName("gridLayout") - self.splitter = QtWidgets.QSplitter(Form) - self.splitter.setOrientation(QtCore.Qt.Orientation.Vertical) - self.splitter.setObjectName("splitter") - self.layoutWidget = QtWidgets.QWidget(self.splitter) - self.layoutWidget.setObjectName("layoutWidget") - self.verticalLayout = QtWidgets.QVBoxLayout(self.layoutWidget) - self.verticalLayout.setContentsMargins(0, 0, 0, 0) - self.verticalLayout.setObjectName("verticalLayout") - self.output = QtWidgets.QPlainTextEdit(self.layoutWidget) - font = QtGui.QFont() - font.setFamily("Monospace") - self.output.setFont(font) - self.output.setReadOnly(True) - self.output.setObjectName("output") - self.verticalLayout.addWidget(self.output) - self.horizontalLayout = QtWidgets.QHBoxLayout() - self.horizontalLayout.setObjectName("horizontalLayout") - self.input = CmdInput(self.layoutWidget) - self.input.setObjectName("input") - self.horizontalLayout.addWidget(self.input) - self.historyBtn = QtWidgets.QPushButton(self.layoutWidget) - self.historyBtn.setCheckable(True) - self.historyBtn.setObjectName("historyBtn") - self.horizontalLayout.addWidget(self.historyBtn) - self.exceptionBtn = QtWidgets.QPushButton(self.layoutWidget) - self.exceptionBtn.setCheckable(True) - self.exceptionBtn.setObjectName("exceptionBtn") - self.horizontalLayout.addWidget(self.exceptionBtn) - self.verticalLayout.addLayout(self.horizontalLayout) - self.historyList = QtWidgets.QListWidget(self.splitter) - font = QtGui.QFont() - font.setFamily("Monospace") - self.historyList.setFont(font) - self.historyList.setObjectName("historyList") - self.exceptionGroup = QtWidgets.QGroupBox(self.splitter) - self.exceptionGroup.setObjectName("exceptionGroup") - self.gridLayout_2 = QtWidgets.QGridLayout(self.exceptionGroup) - self.gridLayout_2.setContentsMargins(-1, 0, -1, 0) - self.gridLayout_2.setHorizontalSpacing(2) - self.gridLayout_2.setVerticalSpacing(0) - self.gridLayout_2.setObjectName("gridLayout_2") - self.clearExceptionBtn = QtWidgets.QPushButton(self.exceptionGroup) - self.clearExceptionBtn.setEnabled(False) - self.clearExceptionBtn.setObjectName("clearExceptionBtn") - self.gridLayout_2.addWidget(self.clearExceptionBtn, 0, 6, 1, 1) - self.catchAllExceptionsBtn = QtWidgets.QPushButton(self.exceptionGroup) - self.catchAllExceptionsBtn.setCheckable(True) - self.catchAllExceptionsBtn.setObjectName("catchAllExceptionsBtn") - self.gridLayout_2.addWidget(self.catchAllExceptionsBtn, 0, 1, 1, 1) - self.catchNextExceptionBtn = QtWidgets.QPushButton(self.exceptionGroup) - self.catchNextExceptionBtn.setCheckable(True) - self.catchNextExceptionBtn.setObjectName("catchNextExceptionBtn") - self.gridLayout_2.addWidget(self.catchNextExceptionBtn, 0, 0, 1, 1) - self.onlyUncaughtCheck = QtWidgets.QCheckBox(self.exceptionGroup) - self.onlyUncaughtCheck.setChecked(True) - self.onlyUncaughtCheck.setObjectName("onlyUncaughtCheck") - self.gridLayout_2.addWidget(self.onlyUncaughtCheck, 0, 4, 1, 1) - self.exceptionStackList = QtWidgets.QListWidget(self.exceptionGroup) - self.exceptionStackList.setAlternatingRowColors(True) - self.exceptionStackList.setObjectName("exceptionStackList") - self.gridLayout_2.addWidget(self.exceptionStackList, 2, 0, 1, 7) - self.runSelectedFrameCheck = QtWidgets.QCheckBox(self.exceptionGroup) - self.runSelectedFrameCheck.setChecked(True) - self.runSelectedFrameCheck.setObjectName("runSelectedFrameCheck") - self.gridLayout_2.addWidget(self.runSelectedFrameCheck, 3, 0, 1, 7) - self.exceptionInfoLabel = QtWidgets.QLabel(self.exceptionGroup) - self.exceptionInfoLabel.setWordWrap(True) - self.exceptionInfoLabel.setObjectName("exceptionInfoLabel") - self.gridLayout_2.addWidget(self.exceptionInfoLabel, 1, 0, 1, 7) - spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) - self.gridLayout_2.addItem(spacerItem, 0, 5, 1, 1) - self.label = QtWidgets.QLabel(self.exceptionGroup) - self.label.setObjectName("label") - self.gridLayout_2.addWidget(self.label, 0, 2, 1, 1) - self.filterText = QtWidgets.QLineEdit(self.exceptionGroup) - self.filterText.setObjectName("filterText") - self.gridLayout_2.addWidget(self.filterText, 0, 3, 1, 1) - self.gridLayout.addWidget(self.splitter, 0, 0, 1, 1) - - self.retranslateUi(Form) - QtCore.QMetaObject.connectSlotsByName(Form) - - def retranslateUi(self, Form): - _translate = QtCore.QCoreApplication.translate - Form.setWindowTitle(_translate("Form", "Console")) - self.historyBtn.setText(_translate("Form", "History..")) - self.exceptionBtn.setText(_translate("Form", "Exceptions..")) - self.exceptionGroup.setTitle(_translate("Form", "Exception Handling")) - self.clearExceptionBtn.setText(_translate("Form", "Clear Stack")) - self.catchAllExceptionsBtn.setText(_translate("Form", "Show All Exceptions")) - self.catchNextExceptionBtn.setText(_translate("Form", "Show Next Exception")) - self.onlyUncaughtCheck.setText(_translate("Form", "Only Uncaught Exceptions")) - self.runSelectedFrameCheck.setText(_translate("Form", "Run commands in selected stack frame")) - self.exceptionInfoLabel.setText(_translate("Form", "Stack Trace")) - self.label.setText(_translate("Form", "Filter (regex):")) -from .CmdInput import CmdInput diff --git a/pyqtgraph/console/template_pyside2.py b/pyqtgraph/console/template_pyside2.py deleted file mode 100644 index 4b26a3b886..0000000000 --- a/pyqtgraph/console/template_pyside2.py +++ /dev/null @@ -1,113 +0,0 @@ - -# Form implementation generated from reading ui file 'template.ui' -# -# Created: Sun Sep 18 19:19:10 2016 -# by: pyside2-uic running on PySide2 2.0.0~alpha0 -# -# WARNING! All changes made in this file will be lost! - -from PySide2 import QtCore, QtGui, QtWidgets - - -class Ui_Form(object): - def setupUi(self, Form): - Form.setObjectName("Form") - Form.resize(694, 497) - self.gridLayout = QtWidgets.QGridLayout(Form) - self.gridLayout.setContentsMargins(0, 0, 0, 0) - self.gridLayout.setSpacing(0) - self.gridLayout.setObjectName("gridLayout") - self.splitter = QtWidgets.QSplitter(Form) - self.splitter.setOrientation(QtCore.Qt.Vertical) - self.splitter.setObjectName("splitter") - self.layoutWidget = QtWidgets.QWidget(self.splitter) - self.layoutWidget.setObjectName("layoutWidget") - self.verticalLayout = QtWidgets.QVBoxLayout(self.layoutWidget) - self.verticalLayout.setContentsMargins(0, 0, 0, 0) - self.verticalLayout.setObjectName("verticalLayout") - self.output = QtWidgets.QPlainTextEdit(self.layoutWidget) - font = QtGui.QFont() - font.setFamily("Monospace") - self.output.setFont(font) - self.output.setReadOnly(True) - self.output.setObjectName("output") - self.verticalLayout.addWidget(self.output) - self.horizontalLayout = QtWidgets.QHBoxLayout() - self.horizontalLayout.setObjectName("horizontalLayout") - self.input = CmdInput(self.layoutWidget) - self.input.setObjectName("input") - self.horizontalLayout.addWidget(self.input) - self.historyBtn = QtWidgets.QPushButton(self.layoutWidget) - self.historyBtn.setCheckable(True) - self.historyBtn.setObjectName("historyBtn") - self.horizontalLayout.addWidget(self.historyBtn) - self.exceptionBtn = QtWidgets.QPushButton(self.layoutWidget) - self.exceptionBtn.setCheckable(True) - self.exceptionBtn.setObjectName("exceptionBtn") - self.horizontalLayout.addWidget(self.exceptionBtn) - self.verticalLayout.addLayout(self.horizontalLayout) - self.historyList = QtWidgets.QListWidget(self.splitter) - font = QtGui.QFont() - font.setFamily("Monospace") - self.historyList.setFont(font) - self.historyList.setObjectName("historyList") - self.exceptionGroup = QtWidgets.QGroupBox(self.splitter) - self.exceptionGroup.setObjectName("exceptionGroup") - self.gridLayout_2 = QtWidgets.QGridLayout(self.exceptionGroup) - self.gridLayout_2.setSpacing(0) - self.gridLayout_2.setContentsMargins(-1, 0, -1, 0) - self.gridLayout_2.setObjectName("gridLayout_2") - self.clearExceptionBtn = QtWidgets.QPushButton(self.exceptionGroup) - self.clearExceptionBtn.setEnabled(False) - self.clearExceptionBtn.setObjectName("clearExceptionBtn") - self.gridLayout_2.addWidget(self.clearExceptionBtn, 0, 6, 1, 1) - self.catchAllExceptionsBtn = QtWidgets.QPushButton(self.exceptionGroup) - self.catchAllExceptionsBtn.setCheckable(True) - self.catchAllExceptionsBtn.setObjectName("catchAllExceptionsBtn") - self.gridLayout_2.addWidget(self.catchAllExceptionsBtn, 0, 1, 1, 1) - self.catchNextExceptionBtn = QtWidgets.QPushButton(self.exceptionGroup) - self.catchNextExceptionBtn.setCheckable(True) - self.catchNextExceptionBtn.setObjectName("catchNextExceptionBtn") - self.gridLayout_2.addWidget(self.catchNextExceptionBtn, 0, 0, 1, 1) - self.onlyUncaughtCheck = QtWidgets.QCheckBox(self.exceptionGroup) - self.onlyUncaughtCheck.setChecked(True) - self.onlyUncaughtCheck.setObjectName("onlyUncaughtCheck") - self.gridLayout_2.addWidget(self.onlyUncaughtCheck, 0, 4, 1, 1) - self.exceptionStackList = QtWidgets.QListWidget(self.exceptionGroup) - self.exceptionStackList.setAlternatingRowColors(True) - self.exceptionStackList.setObjectName("exceptionStackList") - self.gridLayout_2.addWidget(self.exceptionStackList, 2, 0, 1, 7) - self.runSelectedFrameCheck = QtWidgets.QCheckBox(self.exceptionGroup) - self.runSelectedFrameCheck.setChecked(True) - self.runSelectedFrameCheck.setObjectName("runSelectedFrameCheck") - self.gridLayout_2.addWidget(self.runSelectedFrameCheck, 3, 0, 1, 7) - self.exceptionInfoLabel = QtWidgets.QLabel(self.exceptionGroup) - self.exceptionInfoLabel.setObjectName("exceptionInfoLabel") - self.gridLayout_2.addWidget(self.exceptionInfoLabel, 1, 0, 1, 7) - spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) - self.gridLayout_2.addItem(spacerItem, 0, 5, 1, 1) - self.label = QtWidgets.QLabel(self.exceptionGroup) - self.label.setObjectName("label") - self.gridLayout_2.addWidget(self.label, 0, 2, 1, 1) - self.filterText = QtWidgets.QLineEdit(self.exceptionGroup) - self.filterText.setObjectName("filterText") - self.gridLayout_2.addWidget(self.filterText, 0, 3, 1, 1) - self.gridLayout.addWidget(self.splitter, 0, 0, 1, 1) - - self.retranslateUi(Form) - QtCore.QMetaObject.connectSlotsByName(Form) - - def retranslateUi(self, Form): - Form.setWindowTitle(QtWidgets.QApplication.translate("Form", "Console", None, -1)) - self.historyBtn.setText(QtWidgets.QApplication.translate("Form", "History..", None, -1)) - self.exceptionBtn.setText(QtWidgets.QApplication.translate("Form", "Exceptions..", None, -1)) - self.exceptionGroup.setTitle(QtWidgets.QApplication.translate("Form", "Exception Handling", None, -1)) - self.clearExceptionBtn.setText(QtWidgets.QApplication.translate("Form", "Clear Exception", None, -1)) - self.catchAllExceptionsBtn.setText(QtWidgets.QApplication.translate("Form", "Show All Exceptions", None, -1)) - self.catchNextExceptionBtn.setText(QtWidgets.QApplication.translate("Form", "Show Next Exception", None, -1)) - self.onlyUncaughtCheck.setText(QtWidgets.QApplication.translate("Form", "Only Uncaught Exceptions", None, -1)) - self.runSelectedFrameCheck.setText(QtWidgets.QApplication.translate("Form", "Run commands in selected stack frame", None, -1)) - self.exceptionInfoLabel.setText(QtWidgets.QApplication.translate("Form", "Exception Info", None, -1)) - self.label.setText(QtWidgets.QApplication.translate("Form", "Filter (regex):", None, -1)) - -from .CmdInput import CmdInput diff --git a/pyqtgraph/console/template_pyside6.py b/pyqtgraph/console/template_pyside6.py deleted file mode 100644 index 2f07c2c0ce..0000000000 --- a/pyqtgraph/console/template_pyside6.py +++ /dev/null @@ -1,155 +0,0 @@ - -################################################################################ -## Form generated from reading UI file 'template.ui' -## -## Created by: Qt User Interface Compiler version 6.1.0 -## -## WARNING! All changes made in this file will be lost when recompiling UI file! -################################################################################ - -from PySide6.QtCore import * -from PySide6.QtGui import * -from PySide6.QtWidgets import * - -from .CmdInput import CmdInput - - -class Ui_Form(object): - def setupUi(self, Form): - if not Form.objectName(): - Form.setObjectName(u"Form") - Form.resize(739, 497) - self.gridLayout = QGridLayout(Form) - self.gridLayout.setSpacing(0) - self.gridLayout.setContentsMargins(0, 0, 0, 0) - self.gridLayout.setObjectName(u"gridLayout") - self.splitter = QSplitter(Form) - self.splitter.setObjectName(u"splitter") - self.splitter.setOrientation(Qt.Vertical) - self.layoutWidget = QWidget(self.splitter) - self.layoutWidget.setObjectName(u"layoutWidget") - self.verticalLayout = QVBoxLayout(self.layoutWidget) - self.verticalLayout.setObjectName(u"verticalLayout") - self.verticalLayout.setContentsMargins(0, 0, 0, 0) - self.output = QPlainTextEdit(self.layoutWidget) - self.output.setObjectName(u"output") - font = QFont() - font.setFamilies([u"Monospace"]) - self.output.setFont(font) - self.output.setReadOnly(True) - - self.verticalLayout.addWidget(self.output) - - self.horizontalLayout = QHBoxLayout() - self.horizontalLayout.setObjectName(u"horizontalLayout") - self.input = CmdInput(self.layoutWidget) - self.input.setObjectName(u"input") - - self.horizontalLayout.addWidget(self.input) - - self.historyBtn = QPushButton(self.layoutWidget) - self.historyBtn.setObjectName(u"historyBtn") - self.historyBtn.setCheckable(True) - - self.horizontalLayout.addWidget(self.historyBtn) - - self.exceptionBtn = QPushButton(self.layoutWidget) - self.exceptionBtn.setObjectName(u"exceptionBtn") - self.exceptionBtn.setCheckable(True) - - self.horizontalLayout.addWidget(self.exceptionBtn) - - - self.verticalLayout.addLayout(self.horizontalLayout) - - self.splitter.addWidget(self.layoutWidget) - self.historyList = QListWidget(self.splitter) - self.historyList.setObjectName(u"historyList") - self.historyList.setFont(font) - self.splitter.addWidget(self.historyList) - self.exceptionGroup = QGroupBox(self.splitter) - self.exceptionGroup.setObjectName(u"exceptionGroup") - self.gridLayout_2 = QGridLayout(self.exceptionGroup) - self.gridLayout_2.setObjectName(u"gridLayout_2") - self.gridLayout_2.setHorizontalSpacing(2) - self.gridLayout_2.setVerticalSpacing(0) - self.gridLayout_2.setContentsMargins(-1, 0, -1, 0) - self.clearExceptionBtn = QPushButton(self.exceptionGroup) - self.clearExceptionBtn.setObjectName(u"clearExceptionBtn") - self.clearExceptionBtn.setEnabled(False) - - self.gridLayout_2.addWidget(self.clearExceptionBtn, 0, 6, 1, 1) - - self.catchAllExceptionsBtn = QPushButton(self.exceptionGroup) - self.catchAllExceptionsBtn.setObjectName(u"catchAllExceptionsBtn") - self.catchAllExceptionsBtn.setCheckable(True) - - self.gridLayout_2.addWidget(self.catchAllExceptionsBtn, 0, 1, 1, 1) - - self.catchNextExceptionBtn = QPushButton(self.exceptionGroup) - self.catchNextExceptionBtn.setObjectName(u"catchNextExceptionBtn") - self.catchNextExceptionBtn.setCheckable(True) - - self.gridLayout_2.addWidget(self.catchNextExceptionBtn, 0, 0, 1, 1) - - self.onlyUncaughtCheck = QCheckBox(self.exceptionGroup) - self.onlyUncaughtCheck.setObjectName(u"onlyUncaughtCheck") - self.onlyUncaughtCheck.setChecked(True) - - self.gridLayout_2.addWidget(self.onlyUncaughtCheck, 0, 4, 1, 1) - - self.exceptionStackList = QListWidget(self.exceptionGroup) - self.exceptionStackList.setObjectName(u"exceptionStackList") - self.exceptionStackList.setAlternatingRowColors(True) - - self.gridLayout_2.addWidget(self.exceptionStackList, 2, 0, 1, 7) - - self.runSelectedFrameCheck = QCheckBox(self.exceptionGroup) - self.runSelectedFrameCheck.setObjectName(u"runSelectedFrameCheck") - self.runSelectedFrameCheck.setChecked(True) - - self.gridLayout_2.addWidget(self.runSelectedFrameCheck, 3, 0, 1, 7) - - self.exceptionInfoLabel = QLabel(self.exceptionGroup) - self.exceptionInfoLabel.setObjectName(u"exceptionInfoLabel") - self.exceptionInfoLabel.setWordWrap(True) - - self.gridLayout_2.addWidget(self.exceptionInfoLabel, 1, 0, 1, 7) - - self.horizontalSpacer = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum) - - self.gridLayout_2.addItem(self.horizontalSpacer, 0, 5, 1, 1) - - self.label = QLabel(self.exceptionGroup) - self.label.setObjectName(u"label") - - self.gridLayout_2.addWidget(self.label, 0, 2, 1, 1) - - self.filterText = QLineEdit(self.exceptionGroup) - self.filterText.setObjectName(u"filterText") - - self.gridLayout_2.addWidget(self.filterText, 0, 3, 1, 1) - - self.splitter.addWidget(self.exceptionGroup) - - self.gridLayout.addWidget(self.splitter, 0, 0, 1, 1) - - - self.retranslateUi(Form) - - QMetaObject.connectSlotsByName(Form) - # setupUi - - def retranslateUi(self, Form): - Form.setWindowTitle(QCoreApplication.translate("Form", u"Console", None)) - self.historyBtn.setText(QCoreApplication.translate("Form", u"History..", None)) - self.exceptionBtn.setText(QCoreApplication.translate("Form", u"Exceptions..", None)) - self.exceptionGroup.setTitle(QCoreApplication.translate("Form", u"Exception Handling", None)) - self.clearExceptionBtn.setText(QCoreApplication.translate("Form", u"Clear Stack", None)) - self.catchAllExceptionsBtn.setText(QCoreApplication.translate("Form", u"Show All Exceptions", None)) - self.catchNextExceptionBtn.setText(QCoreApplication.translate("Form", u"Show Next Exception", None)) - self.onlyUncaughtCheck.setText(QCoreApplication.translate("Form", u"Only Uncaught Exceptions", None)) - self.runSelectedFrameCheck.setText(QCoreApplication.translate("Form", u"Run commands in selected stack frame", None)) - self.exceptionInfoLabel.setText(QCoreApplication.translate("Form", u"Stack Trace", None)) - self.label.setText(QCoreApplication.translate("Form", u"Filter (regex):", None)) - # retranslateUi diff --git a/pyqtgraph/debug.py b/pyqtgraph/debug.py index a080302b83..36d15522c7 100644 --- a/pyqtgraph/debug.py +++ b/pyqtgraph/debug.py @@ -26,15 +26,6 @@ from .Qt import QT_LIB, QtCore from .util import cprint - -if sys.version.startswith("3.8") and QT_LIB == "PySide2": - from .Qt import PySide2 - if tuple(map(int, PySide2.__version__.split("."))) < (5, 14) \ - and PySide2.__version__.startswith(QtCore.__version__): - warnings.warn( - "Due to PYSIDE-1140, ThreadChase and ThreadColor will not work" + - " on pip-installed PySide2 bindings < 5.14" - ) from .util.mutex import Mutex @@ -1192,19 +1183,7 @@ def run(self): if id == threading.current_thread().ident: continue - # try to determine a thread name - try: - name = threading._active.get(id, None) - except: - name = None - if name is None: - try: - # QThread._names must be manually set by thread creators. - name = QtCore.QThread._names.get(id) - except: - name = None - if name is None: - name = "???" + name = threadName() printFile.write("<< thread %d \"%s\" >>\n" % (id, name)) tb = str(''.join(traceback.format_stack(frame))) @@ -1217,6 +1196,46 @@ def run(self): time.sleep(self.interval) +def threadName(threadId=None): + """Return a string name for a thread id. + + If *threadId* is None, then the current thread's id is used. + + This attempts to look up thread names either from `threading._active`, or from + QThread._names. However, note that the latter does not exist by default; rather + you must manually add id:name pairs to a dictionary there:: + + # for python threads: + t1 = threading.Thread(name="mythread") + + # for Qt threads: + class Thread(Qt.QThread): + def __init__(self, name): + self._threadname = name + if not hasattr(Qt.QThread, '_names'): + Qt.QThread._names = {} + Qt.QThread.__init__(self, *args, **kwds) + def run(self): + Qt.QThread._names[threading.current_thread().ident] = self._threadname + """ + if threadId is None: + threadId = threading.current_thread().ident + + try: + name = threading._active.get(threadId, None) + except Exception: + name = None + if name is None: + try: + # QThread._names must be manually set by thread creators. + name = QtCore.QThread._names.get(threadId) + except Exception: + name = None + if name is None: + name = "???" + return name + + class ThreadColor(object): """ Wrapper on stdout/stderr that colors text by the current thread ID. diff --git a/pyqtgraph/dockarea/Dock.py b/pyqtgraph/dockarea/Dock.py index 783c51ce8d..99449e1b71 100644 --- a/pyqtgraph/dockarea/Dock.py +++ b/pyqtgraph/dockarea/Dock.py @@ -1,22 +1,18 @@ import warnings -from ..Qt import QT_LIB, QtCore, QtGui, QtWidgets +from ..Qt import QtCore, QtGui, QtWidgets from ..widgets.VerticalLabel import VerticalLabel from .DockDrop import DockDrop -class Dock(QtWidgets.QWidget, DockDrop): +class Dock(QtWidgets.QWidget): sigStretchChanged = QtCore.Signal() sigClosed = QtCore.Signal(object) def __init__(self, name, area=None, size=(10, 10), widget=None, hideTitle=False, autoOrientation=True, label=None, **kargs): - allowedAreas = None - if QT_LIB.startswith('PyQt'): - QtWidgets.QWidget.__init__(self, allowedAreas=allowedAreas) - else: - QtWidgets.QWidget.__init__(self) - DockDrop.__init__(self, allowedAreas=allowedAreas) + QtWidgets.QWidget.__init__(self) + self.dockdrop = DockDrop(self) self._container = None self._name = name self.area = area @@ -46,7 +42,7 @@ def __init__(self, name, area=None, size=(10, 10), widget=None, hideTitle=False, self.widgets = [] self.currentRow = 0 #self.titlePos = 'top' - self.raiseOverlay() + self.dockdrop.raiseOverlay() self.hStyle = """ Dock > QWidget { border: 1px solid #000; @@ -113,8 +109,7 @@ def hideTitleBar(self): """ self.label.hide() self.labelHidden = True - if 'center' in self.allowedAreas: - self.allowedAreas.remove('center') + self.dockdrop.removeAllowedArea('center') self.updateStyle() def showTitleBar(self): @@ -123,7 +118,7 @@ def showTitleBar(self): """ self.label.show() self.labelHidden = False - self.allowedAreas.add('center') + self.dockdrop.addAllowedArea('center') self.updateStyle() def title(self): @@ -180,7 +175,7 @@ def updateStyle(self): def resizeEvent(self, ev): self.setOrientation() - self.resizeOverlay(self.size()) + self.dockdrop.resizeOverlay(self.size()) def name(self): return self._name @@ -195,7 +190,7 @@ def addWidget(self, widget, row=None, col=0, rowspan=1, colspan=1): self.currentRow = max(row+1, self.currentRow) self.widgets.append(widget) self.layout.addWidget(widget, row, col, rowspan, colspan) - self.raiseOverlay() + self.dockdrop.raiseOverlay() def startDrag(self): self.drag = QtGui.QDrag(self) @@ -249,19 +244,17 @@ def close(self): def __repr__(self): return "" % (self.name(), self.stretch()) - ## PySide bug: We need to explicitly redefine these methods - ## or else drag/drop events will not be delivered. def dragEnterEvent(self, *args): - DockDrop.dragEnterEvent(self, *args) + self.dockdrop.dragEnterEvent(*args) def dragMoveEvent(self, *args): - DockDrop.dragMoveEvent(self, *args) + self.dockdrop.dragMoveEvent(*args) def dragLeaveEvent(self, *args): - DockDrop.dragLeaveEvent(self, *args) + self.dockdrop.dragLeaveEvent(*args) def dropEvent(self, *args): - DockDrop.dropEvent(self, *args) + self.dockdrop.dropEvent(*args) class DockLabel(VerticalLabel): diff --git a/pyqtgraph/dockarea/DockArea.py b/pyqtgraph/dockarea/DockArea.py index e97ce048de..ea98d2c88a 100644 --- a/pyqtgraph/dockarea/DockArea.py +++ b/pyqtgraph/dockarea/DockArea.py @@ -1,27 +1,24 @@ import weakref -from ..Qt import QT_LIB, QtWidgets +from ..Qt import QtWidgets from .Container import Container, HContainer, TContainer, VContainer from .Dock import Dock from .DockDrop import DockDrop -class DockArea(Container, QtWidgets.QWidget, DockDrop): +class DockArea(Container, QtWidgets.QWidget): def __init__(self, parent=None, temporary=False, home=None): Container.__init__(self, self) - allowedAreas=['left', 'right', 'top', 'bottom'] - if QT_LIB.startswith('PyQt'): - QtWidgets.QWidget.__init__(self, parent=parent, allowedAreas=allowedAreas) - else: - QtWidgets.QWidget.__init__(self, parent=parent) - DockDrop.__init__(self, allowedAreas=allowedAreas) + QtWidgets.QWidget.__init__(self, parent=parent) + self.dockdrop = DockDrop(self) + self.dockdrop.removeAllowedArea('center') self.layout = QtWidgets.QVBoxLayout() self.layout.setContentsMargins(0,0,0,0) self.layout.setSpacing(0) self.setLayout(self.layout) self.docks = weakref.WeakValueDictionary() self.topContainer = None - self.raiseOverlay() + self.dockdrop.raiseOverlay() self.temporary = temporary self.tempAreas = [] self.home = home @@ -146,7 +143,7 @@ def addContainer(self, typ, obj): #print "Add container:", new, " -> ", container if obj is not None: new.insert(obj) - self.raiseOverlay() + self.dockdrop.raiseOverlay() return new def insert(self, new, pos=None, neighbor=None): @@ -157,7 +154,7 @@ def insert(self, new, pos=None, neighbor=None): self.layout.addWidget(new) new.containerChanged(self) self.topContainer = new - self.raiseOverlay() + self.dockdrop.raiseOverlay() def count(self): if self.topContainer is None: @@ -165,7 +162,7 @@ def count(self): return 1 def resizeEvent(self, ev): - self.resizeOverlay(self.size()) + self.dockdrop.resizeOverlay(self.size()) def addTempArea(self): if self.home is None: @@ -330,19 +327,17 @@ def clear(self): for dock in docks.values(): dock.close() - ## PySide bug: We need to explicitly redefine these methods - ## or else drag/drop events will not be delivered. def dragEnterEvent(self, *args): - DockDrop.dragEnterEvent(self, *args) + self.dockdrop.dragEnterEvent(*args) def dragMoveEvent(self, *args): - DockDrop.dragMoveEvent(self, *args) + self.dockdrop.dragMoveEvent(*args) def dragLeaveEvent(self, *args): - DockDrop.dragLeaveEvent(self, *args) + self.dockdrop.dragLeaveEvent(*args) def dropEvent(self, *args): - DockDrop.dropEvent(self, *args) + self.dockdrop.dropEvent(*args) def printState(self, state=None, name='Main'): # for debugging diff --git a/pyqtgraph/dockarea/DockDrop.py b/pyqtgraph/dockarea/DockDrop.py index f93822f15b..74cd786855 100644 --- a/pyqtgraph/dockarea/DockDrop.py +++ b/pyqtgraph/dockarea/DockDrop.py @@ -3,18 +3,22 @@ from ..Qt import QtCore, QtGui, QtWidgets -class DockDrop(object): +class DockDrop: """Provides dock-dropping methods""" - def __init__(self, allowedAreas=None): - object.__init__(self) - if allowedAreas is None: - allowedAreas = ['center', 'right', 'left', 'top', 'bottom'] - self.allowedAreas = set(allowedAreas) - self.setAcceptDrops(True) + def __init__(self, dndWidget): + self.dndWidget = dndWidget + self.allowedAreas = {'center', 'right', 'left', 'top', 'bottom'} + self.dndWidget.setAcceptDrops(True) self.dropArea = None - self.overlay = DropAreaOverlay(self) + self.overlay = DropAreaOverlay(dndWidget) self.overlay.raise_() - + + def addAllowedArea(self, area): + self.allowedAreas.update(area) + + def removeAllowedArea(self, area): + self.allowedAreas.discard(area) + def resizeOverlay(self, size): self.overlay.resize(size) @@ -34,18 +38,19 @@ def dragMoveEvent(self, ev): #print "drag move" # QDragMoveEvent inherits QDropEvent which provides posF() # PyQt6 provides only position() + width, height = self.dndWidget.width(), self.dndWidget.height() posF = ev.posF() if hasattr(ev, 'posF') else ev.position() ld = posF.x() - rd = self.width() - ld + rd = width - ld td = posF.y() - bd = self.height() - td + bd = height - td mn = min(ld, rd, td, bd) if mn > 30: self.dropArea = "center" - elif (ld == mn or td == mn) and mn > self.height()/3.: + elif (ld == mn or td == mn) and mn > height/3: self.dropArea = "center" - elif (rd == mn or ld == mn) and mn > self.width()/3.: + elif (rd == mn or ld == mn) and mn > width/3: self.dropArea = "center" elif rd == mn: @@ -57,7 +62,7 @@ def dragMoveEvent(self, ev): elif bd == mn: self.dropArea = "bottom" - if ev.source() is self and self.dropArea == 'center': + if ev.source() is self.dndWidget and self.dropArea == 'center': #print " no self-center" self.dropArea = None ev.ignore() @@ -80,7 +85,7 @@ def dropEvent(self, ev): return if area == 'center': area = 'above' - self.area.moveDock(ev.source(), area, self) + self.dndWidget.area.moveDock(ev.source(), area, self.dndWidget) self.dropArea = None self.overlay.setDropArea(self.dropArea) diff --git a/pyqtgraph/examples/ConsoleWidget.py b/pyqtgraph/examples/ConsoleWidget.py index 9b3875dddf..c90847c6ec 100644 --- a/pyqtgraph/examples/ConsoleWidget.py +++ b/pyqtgraph/examples/ConsoleWidget.py @@ -24,6 +24,7 @@ Go, play. """ c = pyqtgraph.console.ConsoleWidget(namespace=namespace, text=text) +c.resize(800, 400) c.show() c.setWindowTitle('pyqtgraph example: ConsoleWidget') diff --git a/pyqtgraph/examples/ExampleApp.py b/pyqtgraph/examples/ExampleApp.py index badd703311..5151eadcbd 100644 --- a/pyqtgraph/examples/ExampleApp.py +++ b/pyqtgraph/examples/ExampleApp.py @@ -1,5 +1,6 @@ import keyword import os +import pkgutil import re import subprocess import sys @@ -8,7 +9,7 @@ from functools import lru_cache import pyqtgraph as pg -from pyqtgraph.Qt import QtCore, QtGui, QtWidgets +from pyqtgraph.Qt import QT_LIB, QtCore, QtGui, QtWidgets app = pg.mkQApp() @@ -16,10 +17,8 @@ path = os.path.abspath(os.path.dirname(__file__)) sys.path.insert(0, path) -import utils - import exampleLoaderTemplate_generic as ui_template - +import utils # based on https://github.com/art1415926535/PyQt5-syntax-highlighting @@ -299,6 +298,10 @@ def unnestedDict(exDict): class ExampleLoader(QtWidgets.QMainWindow): + # update qtLibCombo item order to match bindings in the UI file and recreate + # the templates files if you change bindings. + bindings = {'PyQt6': 0, 'PySide6': 1, 'PyQt5': 2, 'PySide2': 3} + modules = tuple(m.name for m in pkgutil.iter_modules()) def __init__(self): QtWidgets.QMainWindow.__init__(self) self.ui = ui_template.Ui_Form() @@ -320,11 +323,17 @@ def __init__(self): textFil = self.ui.exampleFilter self.curListener = None self.ui.exampleFilter.setFocus() + self.ui.qtLibCombo.addItems(self.bindings.keys()) + self.ui.qtLibCombo.setCurrentIndex(self.bindings[QT_LIB]) + def onComboChanged(searchType): if self.curListener is not None: self.curListener.disconnect() self.curListener = textFil.textChanged + # In case the regex was invalid before switching to title search, + # ensure the "invalid" color is reset + self.ui.exampleFilter.setStyleSheet('') if searchType == 'Content Search': self.curListener.connect(self.filterByContent) else: @@ -350,6 +359,27 @@ def onComboChanged(searchType): self.ui.exampleTree.itemDoubleClicked.connect(self.loadFile) self.ui.codeView.textChanged.connect(self.onTextChange) self.codeBtn.clicked.connect(self.runEditedCode) + self.updateCodeViewTabWidth(self.ui.codeView.font()) + + def updateCodeViewTabWidth(self,font): + """ + Change the codeView tabStopDistance to 4 spaces based on the size of the current font + """ + fm = QtGui.QFontMetrics(font) + tabWidth = fm.horizontalAdvance(' ' * 4) + # the default value is 80 pixels! that's more than 2x what we want. + self.ui.codeView.setTabStopDistance(tabWidth) + + def showEvent(self, event) -> None: + super(ExampleLoader, self).showEvent(event) + disabledColor = QColor(QtCore.Qt.GlobalColor.red) + for name, idx in self.bindings.items(): + disableBinding = name not in self.modules + if disableBinding: + item = self.ui.qtLibCombo.model().item(idx) + item.setData(disabledColor, QtCore.Qt.ItemDataRole.ForegroundRole) + item.setEnabled(False) + item.setToolTip(f'{item.text()} is not installed') def onTextChange(self): """ @@ -367,7 +397,21 @@ def filterByTitle(self, text): self.hl.setDocument(self.ui.codeView.document()) def filterByContent(self, text=None): - # Don't filter very short strings + # If the new text isn't valid regex, fail early and highlight the search filter red to indicate a problem + # to the user + validRegex = True + try: + re.compile(text) + self.ui.exampleFilter.setStyleSheet('') + except re.error: + colors = DarkThemeColors if app.property('darkMode') else LightThemeColors + errorColor = pg.mkColor(colors.Red) + validRegex = False + errorColor.setAlpha(100) + # Tuple prints nicely :) + self.ui.exampleFilter.setStyleSheet(f'background: rgba{errorColor.getRgb()}') + if not validRegex: + return checkDict = unnestedDict(utils.examples_) self.hl.searchText = text # Need to reapply to current document @@ -466,15 +510,11 @@ def currentFile(self): return os.path.join(path, item.file) return None - def loadFile(self, edited=False): - - qtLib = str(self.ui.qtLibCombo.currentText()) - - env = None - if qtLib != 'default': - env = dict(os.environ, PYQTGRAPH_QT_LIB=qtLib) - else: - env = dict(os.environ) + def loadFile(self, *, edited=False): + # make *edited* keyword-only so it is not confused for extra arguments + # sent by ui signals + qtLib = self.ui.qtLibCombo.currentText() + env = dict(os.environ, PYQTGRAPH_QT_LIB=qtLib) example_path = os.path.abspath(os.path.dirname(__file__)) path = os.path.dirname(os.path.dirname(example_path)) env['PYTHONPATH'] = f'{path}' @@ -539,6 +579,7 @@ def keyPressEvent(self, event): # Reset to original size font.setPointSize(10) self.ui.codeView.setFont(font) + self.updateCodeViewTabWidth(font) event.accept() def main(): diff --git a/pyqtgraph/examples/GraphicsScene.py b/pyqtgraph/examples/GraphicsScene.py index 7b74f757b1..17330f8ae8 100644 --- a/pyqtgraph/examples/GraphicsScene.py +++ b/pyqtgraph/examples/GraphicsScene.py @@ -1,5 +1,4 @@ import pyqtgraph as pg -from pyqtgraph.GraphicsScene import GraphicsScene from pyqtgraph.Qt import QtCore, QtWidgets app = pg.mkQApp("GraphicsScene Example") @@ -10,7 +9,6 @@ class Obj(QtWidgets.QGraphicsObject): def __init__(self): QtWidgets.QGraphicsObject.__init__(self) - GraphicsScene.registerObject(self) def paint(self, p, *args): p.setPen(pg.mkPen(200,200,200)) diff --git a/pyqtgraph/examples/ImageView.py b/pyqtgraph/examples/ImageView.py index c9fc49f01d..f8f4d85c90 100644 --- a/pyqtgraph/examples/ImageView.py +++ b/pyqtgraph/examples/ImageView.py @@ -24,10 +24,11 @@ ## Create window with ImageView widget win = QtWidgets.QMainWindow() win.resize(800,800) -imv = pg.ImageView() +imv = pg.ImageView(discreteTimeLine=True) win.setCentralWidget(imv) win.show() win.setWindowTitle('pyqtgraph example: ImageView') +imv.setHistogramLabel("Histogram label goes here") ## Create random 3D data set with time varying signals dataRed = np.ones((100, 200, 200)) * np.linspace(90, 150, 100)[:, np.newaxis, np.newaxis] @@ -42,8 +43,9 @@ ) -## Display the data and assign each frame a time value from 1.0 to 3.0 +# Display the data and assign each frame a time value from 1.0 to 3.0 imv.setImage(data, xvals=np.linspace(1., 3., data.shape[0])) +imv.play(10) ## Set a custom color map colors = [ diff --git a/pyqtgraph/examples/InteractiveParameter.py b/pyqtgraph/examples/InteractiveParameter.py index d5caf70f90..e8955f1b95 100644 --- a/pyqtgraph/examples/InteractiveParameter.py +++ b/pyqtgraph/examples/InteractiveParameter.py @@ -5,7 +5,7 @@ from pyqtgraph.parametertree import ( Parameter, ParameterTree, - RunOpts, + RunOptions, InteractiveFunction, Interactor, ) @@ -33,7 +33,7 @@ def wrapper(*args, **kwargs): host = Parameter.create(name="Interactive Parameter Use", type="group") -interactor = Interactor(parent=host) +interactor = Interactor(parent=host, runOptions=RunOptions.ON_CHANGED) @interactor.decorate() @@ -60,7 +60,7 @@ def ignoredAParam(a=10, b=20): return a * b -@interactor.decorate(runOpts=RunOpts.ON_ACTION) +@interactor.decorate(runOptions=RunOptions.ON_ACTION) @printResult def runOnButton(a=10, b=20): return a + b @@ -82,7 +82,7 @@ def accessVarInDifferentScope(x, y=10): interactor(func_interactive) -with interactor.optsContext(title=str.upper): +with interactor.optsContext(titleFormat=str.upper): @interactor.decorate() @printResult @@ -91,7 +91,7 @@ def capslocknames(a=5): @interactor.decorate( - runOpts=(RunOpts.ON_CHANGED, RunOpts.ON_ACTION), + runOptions=(RunOptions.ON_CHANGED, RunOptions.ON_ACTION), a={"type": "list", "limits": [5, 10, 20]}, ) @printResult diff --git a/pyqtgraph/examples/MultiDataPlot.py b/pyqtgraph/examples/MultiDataPlot.py new file mode 100644 index 0000000000..c8db14621e --- /dev/null +++ b/pyqtgraph/examples/MultiDataPlot.py @@ -0,0 +1,83 @@ +import traceback +import numpy as np + +import pyqtgraph as pg +from pyqtgraph.graphicsItems.ScatterPlotItem import name_list +from pyqtgraph.Qt import QtWidgets, QtCore +from pyqtgraph.parametertree import interact, ParameterTree, Parameter +import random + +pg.mkQApp() + +rng = np.random.default_rng(10) +random.seed(10) + + +def sortedRandint(low, high, size): + return np.sort(rng.integers(low, high, size)) + + +def isNoneOrScalar(value): + return value is None or np.isscalar(value[0]) + + +values = { + # Convention 1 + "None (replaced by integer indices)": None, + # Convention 2 + "Single curve values": sortedRandint(0, 20, 15), + # Convention 3 list form + "container of (optionally) mixed-size curve values": [ + sortedRandint(0, 20, 15), + *[sortedRandint(0, 20, 15) for _ in range(4)], + ], + # Convention 3 array form + "2D matrix": np.row_stack([sortedRandint(20, 40, 15) for _ in range(6)]), +} + + +def next_plot(xtype="random", ytype="random", symbol="o", symbolBrush="#f00"): + constKwargs = locals() + x = y = None + if xtype == "random": + xtype = random.choice(list(values)) + if ytype == "random": + ytype = random.choice(list(values)) + x = values[xtype] + y = values[ytype] + textbox.setValue(f"x={xtype}\ny={ytype}") + pltItem.clear() + try: + pltItem.multiDataPlot( + x=x, y=y, pen=cmap.getLookupTable(nPts=6), constKwargs=constKwargs + ) + except Exception as e: + QtWidgets.QMessageBox.critical(widget, "Error", traceback.format_exc()) + + +cmap = pg.colormap.get("viridis") +widget = pg.PlotWidget() +pltItem: pg.PlotItem = widget.plotItem + +xytype = dict(type="list", values=list(values)) +topParam = interact( + next_plot, + symbolBrush=dict(type="color"), + symbol=dict(type="list", values=name_list), + xtype=xytype, + ytype=xytype, +) +tree = ParameterTree() +tree.setMinimumWidth(150) +tree.addParameters(topParam, showTop=True) + +textbox = Parameter.create(name="text", type="text", readonly=True) +tree.addParameters(textbox) + +win = QtWidgets.QWidget() +win.setLayout(lay := QtWidgets.QHBoxLayout()) +lay.addWidget(widget) +lay.addWidget(tree) +if __name__ == "__main__": + win.show() + pg.exec() diff --git a/pyqtgraph/examples/MultiPlotSpeedTest.py b/pyqtgraph/examples/MultiPlotSpeedTest.py index eddf8deeb4..7f03ebecf3 100644 --- a/pyqtgraph/examples/MultiPlotSpeedTest.py +++ b/pyqtgraph/examples/MultiPlotSpeedTest.py @@ -2,14 +2,22 @@ """ Test the speed of rapidly updating multiple plot curves """ - -from time import perf_counter +import argparse +import itertools import numpy as np +from utils import FrameCounter import pyqtgraph as pg from pyqtgraph.Qt import QtCore +parser = argparse.ArgumentParser() +parser.add_argument('--iterations', default=float('inf'), type=float, + help="Number of iterations to run before exiting" +) +args = parser.parse_args() +iterations_counter = itertools.count() + # pg.setConfigOptions(useOpenGL=True) app = pg.mkQApp("MultiPlot Speed Test") @@ -36,30 +44,24 @@ data = np.random.normal(size=(nPlots*23,nSamples)) ptr = 0 -lastTime = perf_counter() -fps = None -count = 0 def update(): - global curve, data, ptr, plot, lastTime, fps, nPlots, count - count += 1 - + global ptr + if next(iterations_counter) > args.iterations: + timer.stop() + app.quit() + return None for i in range(nPlots): curves[i].setData(data[(ptr+i)%data.shape[0]]) ptr += nPlots - now = perf_counter() - dt = now - lastTime - lastTime = now - if fps is None: - fps = 1.0/dt - else: - s = np.clip(dt*3., 0, 1) - fps = fps * (1-s) + (1.0/dt) * s - plot.setTitle('%0.2f fps' % fps) - #app.processEvents() ## force complete redraw for every plot + framecnt.update() + timer = QtCore.QTimer() timer.timeout.connect(update) timer.start(0) +framecnt = FrameCounter() +framecnt.sigFpsUpdate.connect(lambda fps: plot.setTitle(f'{fps:.1f} fps')) + if __name__ == '__main__': pg.exec() diff --git a/pyqtgraph/examples/MultiPlotWidget.py b/pyqtgraph/examples/MultiPlotWidget.py deleted file mode 100644 index 84802288e8..0000000000 --- a/pyqtgraph/examples/MultiPlotWidget.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/python - -import numpy as np -from numpy import linspace - -import pyqtgraph as pg -from pyqtgraph import MultiPlotWidget -from pyqtgraph.Qt import QtWidgets - -try: - from pyqtgraph.metaarray import * -except: - print("MultiPlot is only used with MetaArray for now (and you do not have the metaarray package)") - exit() - -app = pg.mkQApp("MultiPlot Widget Example") -mw = QtWidgets.QMainWindow() -mw.resize(800,800) -pw = MultiPlotWidget() -mw.setCentralWidget(pw) -mw.show() - -data = np.random.normal(size=(3, 1000)) * np.array([[0.1], [1e-5], [1]]) -ma = MetaArray(data, info=[ - {'name': 'Signal', 'cols': [ - {'name': 'Col1', 'units': 'V'}, - {'name': 'Col2', 'units': 'A'}, - {'name': 'Col3'}, - ]}, - {'name': 'Time', 'values': linspace(0., 1., 1000), 'units': 's'} - ]) -pw.plot(ma, pen='y') - -if __name__ == '__main__': - pg.exec() diff --git a/pyqtgraph/examples/NonUniformImage.py b/pyqtgraph/examples/NonUniformImage.py index d0d74abeba..aa62e8127e 100644 --- a/pyqtgraph/examples/NonUniformImage.py +++ b/pyqtgraph/examples/NonUniformImage.py @@ -7,9 +7,7 @@ import numpy as np import pyqtgraph as pg -from pyqtgraph.graphicsItems.GradientEditorItem import Gradients from pyqtgraph.graphicsItems.NonUniformImage import NonUniformImage -from pyqtgraph.Qt import QtWidgets RPM2RADS = 2 * np.pi / 60 RADS2RPM = 1 / RPM2RADS @@ -35,37 +33,26 @@ P_mech = TAU * W P_loss[P_mech > 1.5e5] = np.NaN -# green - orange - red -Gradients['gor'] = {'ticks': [(0.0, (74, 158, 71)), (0.5, (255, 230, 0)), (1, (191, 79, 76))], 'mode': 'rgb'} - app = pg.mkQApp("NonUniform Image Example") -win = QtWidgets.QMainWindow() -cw = pg.GraphicsLayoutWidget() +win = pg.PlotWidget() win.show() win.resize(600, 400) -win.setCentralWidget(cw) win.setWindowTitle('pyqtgraph example: Non-uniform Image') -p = cw.addPlot(title="Power Losses [W]", row=0, col=0) - -lut = pg.HistogramLUTItem(orientation="horizontal") +p = win.getPlotItem() +p.setTitle("Power Losses [W]") p.setMouseEnabled(x=False, y=False) -cw.nextRow() -cw.addItem(lut) - -# load the gradient -lut.gradient.loadPreset('gor') - image = NonUniformImage(w * RADS2RPM, tau, P_loss.T) -image.setLookupTable(lut, autoLevel=True) image.setZValue(-1) p.addItem(image) -h = image.getHistogram() -lut.plot.setData(*h) +# green - orange - red +cmap = pg.ColorMap([0.0, 0.5, 1.0], [(74, 158, 71), (255, 230, 0), (191, 79, 76)]) +bar = pg.ColorBarItem(colorMap=cmap, orientation='h') +bar.setImageItem(image, insert_in=p) p.showGrid(x=True, y=True) diff --git a/pyqtgraph/examples/PColorMeshItem.py b/pyqtgraph/examples/PColorMeshItem.py index cf64158664..bf23d7a62c 100644 --- a/pyqtgraph/examples/PColorMeshItem.py +++ b/pyqtgraph/examples/PColorMeshItem.py @@ -2,12 +2,11 @@ Demonstrates very basic use of PColorMeshItem """ -import time - import numpy as np import pyqtgraph as pg from pyqtgraph.Qt import QtCore +from utils import FrameCounter app = pg.mkQApp("PColorMesh Example") @@ -15,7 +14,8 @@ win = pg.GraphicsLayoutWidget() win.show() ## show widget alone in its own window win.setWindowTitle('pyqtgraph example: pColorMeshItem') -view = win.addViewBox() +view_auto_scale = win.addPlot(0,0,1,1, title="Auto-scaling colorscheme", enableMenu=False) +view_consistent_scale = win.addPlot(1,0,1,1, title="Consistent colorscheme", enableMenu=False) ## Create data @@ -41,29 +41,53 @@ # z being the color of the polygons its shape must be decreased by one in each dimension z = np.exp(-(x*xn)**2/1000)[:-1,:-1] -## Create image item +## Create autoscaling image item edgecolors = None antialiasing = False -# edgecolors = {'color':'w', 'width':2} # May be uncommened to see edgecolor effect -# antialiasing = True # May be uncommened to see antialiasing effect -pcmi = pg.PColorMeshItem(edgecolors=edgecolors, antialiasing=antialiasing) -view.addItem(pcmi) +cmap = pg.colormap.get('viridis') +levels = (-2,2) # Will be overwritten unless enableAutoLevels is set to False +# edgecolors = {'color':'w', 'width':2} # May be uncommented to see edgecolor effect +# antialiasing = True # May be uncommented to see antialiasing effect +# cmap = pg.colormap.get('plasma') # May be uncommented to see a different colormap than the default 'viridis' +pcmi_auto = pg.PColorMeshItem(edgecolors=edgecolors, antialiasing=antialiasing, colorMap=cmap, levels=levels, enableAutoLevels=True) +view_auto_scale.addItem(pcmi_auto) + +# Add colorbar +bar = pg.ColorBarItem( + label = "Z value [arbitrary unit]", + interactive=False, # Setting `interactive=True` would override `enableAutoLevels=True` of pcmi_auto (resulting in consistent colors) + rounding=0.1) +bar.setImageItem( [pcmi_auto] ) +win.addItem(bar, 0,1,1,1) + +# Create image item with consistent colors and an interactive colorbar +pcmi_consistent = pg.PColorMeshItem(edgecolors=edgecolors, antialiasing=antialiasing, colorMap=cmap, levels=levels, enableAutoLevels=False) +view_consistent_scale.addItem(pcmi_consistent) + +# Add colorbar +bar_static = pg.ColorBarItem( + label = "Z value [arbitrary unit]", + interactive=True, + rounding=0.1) +bar_static.setImageItem( [pcmi_consistent] ) +win.addItem(bar_static,1,1,1,1) + +# Add timing label to the autoscaling view textitem = pg.TextItem(anchor=(1, 0)) -view.addItem(textitem) - - -## Set the animation -fps = 25 # Frame per second of the animation +view_auto_scale.addItem(textitem) # Wave parameters wave_amplitude = 3 wave_speed = 0.3 wave_length = 10 color_speed = 0.3 +color_noise_freq = 0.05 -timer = QtCore.QTimer() -timer.setSingleShot(True) -# not using QTimer.singleShot() because of persistence on PyQt. see PR #1605 +# display info in top-right corner +miny = np.min(y) - wave_amplitude +maxy = np.max(y) + wave_amplitude +view_auto_scale.setYRange(miny, maxy) +textitem.setPos(np.max(x), maxy) textpos = None i=0 @@ -72,30 +96,26 @@ def updateData(): global textpos ## Display the new data set - t0 = time.perf_counter() + color_noise = np.sin(i * 2*np.pi*color_noise_freq) new_x = x new_y = y+wave_amplitude*np.cos(x/wave_length+i) - new_z = np.exp(-(x-np.cos(i*color_speed)*xn)**2/1000)[:-1,:-1] - t1 = time.perf_counter() - pcmi.setData(new_x, + new_z = np.exp(-(x-np.cos(i*color_speed)*xn)**2/1000)[:-1,:-1] + color_noise + pcmi_auto.setData(new_x, + new_y, + new_z) + pcmi_consistent.setData(new_x, new_y, new_z) - t2 = time.perf_counter() i += wave_speed + framecnt.update() - # display info in top-right corner - textitem.setText(f'{(t2 - t1)*1000:.1f} ms') - if textpos is None: - textpos = pcmi.width(), pcmi.height() - textitem.setPos(*textpos) - - # cap update rate at fps - delay = max(1000/fps - (t2 - t0), 0) - timer.start(int(delay)) - +timer = QtCore.QTimer() timer.timeout.connect(updateData) -updateData() +timer.start() + +framecnt = FrameCounter() +framecnt.sigFpsUpdate.connect(lambda fps: textitem.setText(f'{fps:.1f} fps')) if __name__ == '__main__': pg.exec() diff --git a/pyqtgraph/examples/PlotSpeedTest.py b/pyqtgraph/examples/PlotSpeedTest.py index f8f4e290c7..e077870938 100644 --- a/pyqtgraph/examples/PlotSpeedTest.py +++ b/pyqtgraph/examples/PlotSpeedTest.py @@ -4,10 +4,10 @@ """ import argparse -from collections import deque -from time import perf_counter +import itertools import numpy as np +from utils import FrameCounter import pyqtgraph as pg import pyqtgraph.functions as fn @@ -30,11 +30,15 @@ parser.add_argument('--allow-opengl-toggle', action='store_true', help="""Allow on-the-fly change of OpenGL setting. This may cause unwanted side effects. """) +parser.add_argument('--iterations', default=float('inf'), type=float, + help="Number of iterations to run before exiting" +) args = parser.parse_args() if args.use_opengl is not None: pg.setConfigOption('useOpenGL', args.use_opengl) pg.setConfigOption('enableExperimental', args.use_opengl) +use_opengl = pg.getConfigOption('useOpenGL') # don't limit frame rate to vsync sfmt = QtGui.QSurfaceFormat() @@ -73,18 +77,15 @@ def paint(self, painter, opt, widget): splitter.addWidget(pw) splitter.show() -interactor = ptree.Interactor(parent=params, nest=False) +interactor = ptree.Interactor( + parent=params, nest=False, runOptions=ptree.RunOptions.ON_CHANGED +) pw.setWindowTitle('pyqtgraph example: PlotSpeedTest') pw.setLabel('bottom', 'Index', units='B') curve = MonkeyCurveItem(pen=default_pen, brush='b') pw.addItem(curve) - -rollingAverageSize = 1000 -elapsed = deque(maxlen=rollingAverageSize) - -def resetTimings(*args): - elapsed.clear() +iterations_counter = itertools.count() @interactor.decorate( nest=True, @@ -93,7 +94,14 @@ def resetTimings(*args): fsample={'units': 'Hz'}, frequency={'units': 'Hz'} ) -def makeData(noise=True, nsamples=5000, frames=50, fsample=1000.0, frequency=0.0, amplitude=5.0): +def makeData( + noise=args.noise, + nsamples=args.nsamples, + frames=args.frames, + fsample=args.fsample, + frequency=args.frequency, + amplitude=args.amplitude, +): global data, connect_array, ptr ttt = np.arange(frames * nsamples, dtype=np.float64) / fsample data = amplitude*np.sin(2*np.pi*frequency*ttt).reshape((frames, nsamples)) @@ -105,6 +113,7 @@ def makeData(noise=True, nsamples=5000, frames=50, fsample=1000.0, frequency=0.0 params.child('makeData').setOpts(title='Plot Options') + @interactor.decorate( connect={'type': 'list', 'limits': ['all', 'pairs', 'finite', 'array']} ) @@ -113,25 +122,26 @@ def update( connect='all', skipFiniteCheck=False ): - global curve, data, ptr, elapsed, fpsLastUpdate + global ptr + + if next(iterations_counter) > args.iterations: + # cleanly close down benchmark + timer.stop() + app.quit() + return None if connect == 'array': connect = connect_array - # Measure - t_start = perf_counter() - curve.setData(data[ptr], antialias=antialias, connect=connect, skipFiniteCheck=skipFiniteCheck) - app.processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents) - t_end = perf_counter() - elapsed.append(t_end - t_start) + curve.setData( + data[ptr], + antialias=antialias, + connect=connect, + skipFiniteCheck=skipFiniteCheck + ) ptr = (ptr + 1) % data.shape[0] + framecnt.update() - # update fps at most once every 0.2 secs - if t_end - fpsLastUpdate > 0.2: - fpsLastUpdate = t_end - average = np.mean(elapsed) - fps = 1 / average - pw.setTitle('%0.2f fps - %0.1f ms avg' % (fps, average * 1_000)) @interactor.decorate( useOpenGL={'readonly': not args.allow_opengl_toggle}, @@ -142,24 +152,27 @@ def updateOptions( curvePen=pg.mkPen(), plotMethod='pyqtgraph', fillLevel=False, - enableExperimental=False, - useOpenGL=False, + enableExperimental=use_opengl, + useOpenGL=use_opengl, ): pg.setConfigOption('enableExperimental', enableExperimental) - pg.setConfigOption('useOpenGL', useOpenGL) + pw.useOpenGL(useOpenGL) curve.setPen(curvePen) curve.setFillLevel(0.0 if fillLevel else None) curve.setMethod(plotMethod) -params.sigTreeStateChanged.connect(resetTimings) makeData() -fpsLastUpdate = perf_counter() - timer = QtCore.QTimer() timer.timeout.connect(update) timer.start(0) +framecnt = FrameCounter() +framecnt.sigFpsUpdate.connect(lambda fps: pw.setTitle(f'{fps:.1f} fps')) + if __name__ == '__main__': + # Splitter by default gives too small of a width to the parameter tree, + # so fix that right before the event loop + pt.setMinimumSize(225,0) pg.exec() diff --git a/pyqtgraph/examples/RemoteSpeedTest.py b/pyqtgraph/examples/RemoteSpeedTest.py index 4addeb395a..f369aedde3 100644 --- a/pyqtgraph/examples/RemoteSpeedTest.py +++ b/pyqtgraph/examples/RemoteSpeedTest.py @@ -10,13 +10,22 @@ remote case is much faster. """ -from time import perf_counter +import argparse +import itertools import numpy as np +from utils import FrameCounter import pyqtgraph as pg from pyqtgraph.Qt import QtCore, QtWidgets +parser = argparse.ArgumentParser() +parser.add_argument('--iterations', default=float('inf'), type=float, + help="Number of iterations to run before exiting" +) +args = parser.parse_args() +iterations_counter = itertools.count() + app = pg.mkQApp() view = pg.widgets.RemoteGraphicsView.RemoteGraphicsView() @@ -45,11 +54,12 @@ rplt._setProxyOptions(deferGetattr=True) ## speeds up access to rplt.plot view.setCentralItem(rplt) -lastUpdate = perf_counter() -avgFps = 0.0 - def update(): - global check, label, plt, lastUpdate, avgFps, rpltfunc + if next(iterations_counter) > args.iterations: + timer.stop() + app.quit() + return None + data = np.random.normal(size=(10000,50)).sum(axis=1) data += 5 * np.sin(np.linspace(0, 10, data.shape[0])) @@ -61,16 +71,15 @@ def update(): ## process. if lcheck.isChecked(): lplt.plot(data, clear=True) - - now = perf_counter() - fps = 1.0 / (now - lastUpdate) - lastUpdate = now - avgFps = avgFps * 0.8 + fps * 0.2 - label.setText("Generating %0.2f fps" % avgFps) + + framecnt.update() timer = QtCore.QTimer() timer.timeout.connect(update) timer.start(0) +framecnt = FrameCounter() +framecnt.sigFpsUpdate.connect(lambda fps : label.setText(f"Generating {fps:.1f}")) + if __name__ == '__main__': pg.exec() diff --git a/pyqtgraph/examples/ScatterPlotSpeedTest.py b/pyqtgraph/examples/ScatterPlotSpeedTest.py index 8b11ff4916..f5d50d0cf5 100644 --- a/pyqtgraph/examples/ScatterPlotSpeedTest.py +++ b/pyqtgraph/examples/ScatterPlotSpeedTest.py @@ -5,15 +5,26 @@ (Scatter plots are still rather slow to draw; expect about 20fps) """ +import argparse +import itertools +import re + import numpy as np +from utils import FrameCounter + import pyqtgraph as pg -from pyqtgraph.Qt import QtCore, QtWidgets import pyqtgraph.parametertree as ptree -from time import perf_counter -import re +from pyqtgraph.Qt import QtCore, QtWidgets translate = QtCore.QCoreApplication.translate +parser = argparse.ArgumentParser() +parser.add_argument('--iterations', default=float('inf'), type=float, + help="Number of iterations to run before exiting" +) +args = parser.parse_args() +iterations_counter = itertools.count() + app = pg.mkQApp() pt = ptree.ParameterTree(showHeader=False) @@ -28,21 +39,21 @@ data = {} item = pg.ScatterPlotItem() + hoverBrush = pg.mkBrush("y") ptr = 0 -lastTime = perf_counter() -fps = None timer = QtCore.QTimer() - def fmt(name): replace = r"\1 \2" name = re.sub(r"(\w)([A-Z])", replace, name) name = name.replace("_", " ") - return translate("ScatterPlot", name.title().strip() + ": ") + return translate("ScatterPlot", f"{name.title().strip()}: ") -interactor = ptree.Interactor(title=fmt, nest=False, parent=param) +interactor = ptree.Interactor( + titleFormat=fmt, nest=False, parent=param, runOptions=ptree.RunOptions.ON_CHANGED +) @interactor.decorate( @@ -50,7 +61,7 @@ def fmt(name): size=dict(limits=[1, None]), ) def mkDataAndItem(count=500, size=10): - global data, fps + global data scale = 100 data = { "pos": np.random.normal(size=(50, count), scale=scale), @@ -95,7 +106,12 @@ def getData(randomize=False): ), ) def update(mode="Reuse Item"): - global ptr, lastTime, fps + global ptr + + if next(iterations_counter) > args.iterations: + timer.stop() + app.quit() + return None if mode == "New Item": mkItem() elif mode == "Reuse Item": @@ -110,19 +126,8 @@ def update(mode="Reuse Item"): item.pointsAt(new.pos()) old.resetBrush() # reset old's brush before setting new's to better simulate hovering new.setBrush(hoverBrush) - ptr += 1 - now = perf_counter() - dt = now - lastTime - lastTime = now - if fps is None: - fps = 1.0 / dt - else: - s = np.clip(dt * 3.0, 0, 1) - fps = fps * (1 - s) + (1.0 / dt) * s - p.setTitle("%0.2f fps" % fps) - p.repaint() - # app.processEvents() # force complete redraw for every plot + framecnt.update() @interactor.decorate() @@ -132,9 +137,12 @@ def pausePlot(paused=False): else: timer.start() - mkDataAndItem() timer.timeout.connect(update) timer.start(0) + +framecnt = FrameCounter() +framecnt.sigFpsUpdate.connect(lambda fps : p.setTitle(f'{fps:.1f} fps')) + if __name__ == "__main__": pg.exec() diff --git a/pyqtgraph/examples/VideoSpeedTest.py b/pyqtgraph/examples/VideoSpeedTest.py index 992332e7b4..949bcede7c 100644 --- a/pyqtgraph/examples/VideoSpeedTest.py +++ b/pyqtgraph/examples/VideoSpeedTest.py @@ -6,10 +6,11 @@ """ import argparse +import itertools import sys -from time import perf_counter import numpy as np +from utils import FrameCounter import pyqtgraph as pg from pyqtgraph.Qt import QtCore, QtGui, QtWidgets @@ -47,7 +48,11 @@ parser.add_argument('--lut', default=False, action='store_true', help="Use color lookup table") parser.add_argument('--lut-alpha', default=False, action='store_true', help="Use alpha color lookup table", dest='lut_alpha') parser.add_argument('--size', default='512x512', type=lambda s: tuple([int(x) for x in s.split('x')]), help="WxH image dimensions default='512x512'") +parser.add_argument('--iterations', default=float('inf'), type=float, + help="Number of iterations to run before exiting" +) args = parser.parse_args(sys.argv[1:]) +iterations_counter = itertools.count() if RawImageGLWidget is not None: # don't limit frame rate to vsync @@ -240,12 +245,15 @@ def noticeNumbaCheck(): ui.cudaCheck.toggled.connect(noticeCudaCheck) ui.numbaCheck.toggled.connect(noticeNumbaCheck) - ptr = 0 -lastTime = perf_counter() -fps = None def update(): - global ui, ptr, lastTime, fps, LUT, img + global ptr + if next(iterations_counter) > args.iterations: + # cleanly close down benchmark + timer.stop() + app.quit() + return None + if ui.lutCheck.isChecked(): useLut = LUT else: @@ -276,19 +284,14 @@ def update(): #img.setImage(data[ptr%data.shape[0]], autoRange=False) ptr += 1 - now = perf_counter() - dt = now - lastTime - lastTime = now - if fps is None: - fps = 1.0/dt - else: - s = np.clip(dt*3., 0, 1) - fps = fps * (1-s) + (1.0/dt) * s - ui.fpsLabel.setText('%0.2f fps' % fps) - app.processEvents() ## force complete redraw for every plot + framecnt.update() + timer = QtCore.QTimer() timer.timeout.connect(update) timer.start(0) +framecnt = FrameCounter() +framecnt.sigFpsUpdate.connect(lambda fps: ui.fpsLabel.setText(f'{fps:.1f} fps')) + if __name__ == '__main__': pg.exec() diff --git a/pyqtgraph/examples/_buildParamTypes.py b/pyqtgraph/examples/_buildParamTypes.py index 8e0433c134..3744e49a39 100644 --- a/pyqtgraph/examples/_buildParamTypes.py +++ b/pyqtgraph/examples/_buildParamTypes.py @@ -88,7 +88,11 @@ def activate(action): btn = Parameter.create(name=f'{name} All', type='action') btn.sigActivated.connect(activate) params.insertChild(0, btn) - missing = set(PARAM_TYPES).difference(_encounteredTypes) + missing = [ + typ + for typ in set(PARAM_TYPES).difference(_encounteredTypes) + if not typ.startswith("_") + ] if missing: raise RuntimeError(f'{missing} parameters are not represented') return params diff --git a/pyqtgraph/examples/_paramtreecfg.py b/pyqtgraph/examples/_paramtreecfg.py index 4a178b19c4..161b24a136 100644 --- a/pyqtgraph/examples/_paramtreecfg.py +++ b/pyqtgraph/examples/_paramtreecfg.py @@ -132,6 +132,18 @@ } }, + 'action': { + 'shortcut': { + 'type': 'str', + 'value': "Ctrl+Shift+P", + }, + 'icon': { + 'type': 'file', + 'value': None, + 'nameFilter': "Images (*.png *.jpg *.bmp *.jpeg *.svg)", + }, + }, + 'calendar': { 'format': { 'type': 'str', @@ -186,7 +198,6 @@ 'bool': False, 'colormap': None, 'progress': 50, - 'action': None, 'font': 'Inter', } } diff --git a/pyqtgraph/examples/console_exception_inspection.py b/pyqtgraph/examples/console_exception_inspection.py new file mode 100644 index 0000000000..aac62f419e --- /dev/null +++ b/pyqtgraph/examples/console_exception_inspection.py @@ -0,0 +1,199 @@ +""" +Using ConsoleWidget to interactively inspect exception backtraces + + +TODO + - fix uncaught exceptions in threads (python 3.12) + - allow using qtconsole + - provide thread info for stacks + - add thread browser? + - add object browser? + - clicking on a stack frame populates list of locals? + - optional merged exception stacks + +""" + +import sys +import queue +import functools +import threading +import pyqtgraph as pg +import pyqtgraph.console +from pyqtgraph.Qt import QtWidgets +from pyqtgraph.debug import threadName + + +def raiseException(): + """Raise an exception + """ + x = "inside raiseException()" + raise Exception(f"Raised an exception {x} in {threadName()}") + + +def raiseNested(): + """Raise an exception while handling another + """ + x = "inside raiseNested()" + try: + raiseException() + except Exception: + raise Exception(f"Raised during exception handling {x} in {threadName()}") + + +def raiseFrom(): + """Raise an exception from another + """ + x = "inside raiseFrom()" + try: + raiseException() + except Exception as exc: + raise Exception(f"Raised-from during exception handling {x} in {threadName()}") from exc + + +def raiseCaughtException(): + """Raise and catch an exception + """ + x = "inside raiseCaughtException()" + try: + raise Exception(f"Raised an exception {x} in {threadName()}") + except Exception: + print(f"Raised and caught exception {x} in {threadName()} trace: {sys._getframe().f_trace}") + + +def captureStack(): + """Inspect the curent call stack + """ + x = "inside captureStack()" + global console + console.setStack() + return x + + +# Background thread for running functions +threadRunQueue = queue.Queue() +def threadRunner(): + global threadRunQueue + # This is necessary to allow installing trace functions in the thread later on + sys.settrace(lambda *args: None) + while True: + func, args = threadRunQueue.get() + try: + print(f"running {func} from thread, trace: {sys._getframe().f_trace}") + func(*args) + except Exception: + sys.excepthook(*sys.exc_info()) +thread = threading.Thread(target=threadRunner, name="background_thread", daemon=True) +thread.start() + + +# functions used to generate a stack a few items deep +def runInStack(func): + x = "inside runInStack(func)" + runInStack2(func) + return x + +def runInStack2(func): + x = "inside runInStack2(func)" + runInStack3(func) + return x + +def runInStack3(func): + x = "inside runInStack3(func)" + runInStack4(func) + return x + +def runInStack4(func): + x = "inside runInStack4(func)" + func() + return x + + +class SignalEmitter(pg.QtCore.QObject): + signal = pg.QtCore.Signal(object, object) + def __init__(self, queued): + pg.QtCore.QObject.__init__(self) + if queued: + self.signal.connect(self.run, pg.QtCore.Qt.ConnectionType.QueuedConnection) + else: + self.signal.connect(self.run) + def run(self, func, args): + func(*args) +signalEmitter = SignalEmitter(queued=False) +queuedSignalEmitter = SignalEmitter(queued=True) + + + +def runFunc(func): + if signalCheck.isChecked(): + if queuedSignalCheck.isChecked(): + func = functools.partial(queuedSignalEmitter.signal.emit, runInStack, (func,)) + else: + func = functools.partial(signalEmitter.signal.emit, runInStack, (func,)) + + if threadCheck.isChecked(): + threadRunQueue.put((runInStack, (func,))) + else: + runInStack(func) + + + +funcs = [ + raiseException, + raiseNested, + raiseFrom, + raiseCaughtException, + captureStack, +] + +app = pg.mkQApp() + +win = pg.QtWidgets.QSplitter(pg.QtCore.Qt.Orientation.Horizontal) + +ctrl = QtWidgets.QWidget() +ctrlLayout = QtWidgets.QVBoxLayout() +ctrl.setLayout(ctrlLayout) +win.addWidget(ctrl) + +btns = [] +for func in funcs: + btn = QtWidgets.QPushButton(func.__doc__) + btn.clicked.connect(functools.partial(runFunc, func)) + btns.append(btn) + ctrlLayout.addWidget(btn) + +threadCheck = QtWidgets.QCheckBox('Run in thread') +ctrlLayout.addWidget(threadCheck) + +signalCheck = QtWidgets.QCheckBox('Run from Qt signal') +ctrlLayout.addWidget(signalCheck) + +queuedSignalCheck = QtWidgets.QCheckBox('Use queued Qt signal') +ctrlLayout.addWidget(queuedSignalCheck) + +ctrlLayout.addStretch() + +console = pyqtgraph.console.ConsoleWidget(text=""" +Use ConsoleWidget to interactively inspect exception tracebacks and call stacks! + +- Enable "Show next exception" and the next unhandled exception will be displayed below. +- Click any of the buttons to the left to generate an exception. +- When an exception traceback is shown, you can select any of the stack frames and then run commands from that context, + allowing you to inspect variables along the stack. (hint: most of the functions called by the buttons to the left + have a variable named "x" in their local scope) +- Note that this is not like a typical debugger--the program is not paused when an exception is caught; we simply keep + a reference to the stack frames and continue on. +- By default, we only catch unhandled exceptions. If you need to inspect a handled exception (one that is caught by + a try:except block), then uncheck the "Only handled exceptions" box. Note, however that this incurs a performance + penalty and will interfere with other debuggers. + + +""") +console.catchNextException() +win.addWidget(console) + +win.resize(1400, 800) +win.setSizes([300, 1100]) +win.show() + +if __name__ == '__main__': + pg.exec() diff --git a/pyqtgraph/examples/crosshair.py b/pyqtgraph/examples/crosshair.py index 596aba85a2..ba35272964 100644 --- a/pyqtgraph/examples/crosshair.py +++ b/pyqtgraph/examples/crosshair.py @@ -67,7 +67,7 @@ def updateRegion(window, viewRange): vb = p1.vb def mouseMoved(evt): - pos = evt[0] ## using signal proxy turns original arguments into a tuple + pos = evt if p1.sceneBoundingRect().contains(pos): mousePoint = vb.mapSceneToView(pos) index = int(mousePoint.x()) @@ -78,8 +78,7 @@ def mouseMoved(evt): -proxy = pg.SignalProxy(p1.scene().sigMouseMoved, rateLimit=60, slot=mouseMoved) -#p1.scene().sigMouseMoved.connect(mouseMoved) +p1.scene().sigMouseMoved.connect(mouseMoved) if __name__ == '__main__': diff --git a/pyqtgraph/examples/exampleLoaderTemplate.ui b/pyqtgraph/examples/exampleLoaderTemplate.ui index 22a213e680..a462b633e4 100644 --- a/pyqtgraph/examples/exampleLoaderTemplate.ui +++ b/pyqtgraph/examples/exampleLoaderTemplate.ui @@ -22,33 +22,7 @@ - - - - default - - - - - PyQt5 - - - - - PySide2 - - - - - PySide6 - - - - - PyQt6 - - - + diff --git a/pyqtgraph/examples/exampleLoaderTemplate_generic.py b/pyqtgraph/examples/exampleLoaderTemplate_generic.py index bf87b31f94..5219ba2b63 100644 --- a/pyqtgraph/examples/exampleLoaderTemplate_generic.py +++ b/pyqtgraph/examples/exampleLoaderTemplate_generic.py @@ -1,6 +1,6 @@ -# Form implementation generated from reading ui file 'examples/exampleLoaderTemplate.ui' +# Form implementation generated from reading ui file '../pyqtgraph/examples/exampleLoaderTemplate.ui' # -# Created by: PyQt6 UI code generator 6.1.1 +# Created by: PyQt6 UI code generator 6.2.2 # # WARNING: Any manual changes made to this file will be lost when pyuic6 is # run again. Do not edit this file unless you know what you are doing. @@ -25,11 +25,6 @@ def setupUi(self, Form): self.gridLayout.setObjectName("gridLayout") self.qtLibCombo = QtWidgets.QComboBox(self.layoutWidget) self.qtLibCombo.setObjectName("qtLibCombo") - self.qtLibCombo.addItem("") - self.qtLibCombo.addItem("") - self.qtLibCombo.addItem("") - self.qtLibCombo.addItem("") - self.qtLibCombo.addItem("") self.gridLayout.addWidget(self.qtLibCombo, 4, 1, 1, 1) self.loadBtn = QtWidgets.QPushButton(self.layoutWidget) self.loadBtn.setObjectName("loadBtn") @@ -77,11 +72,6 @@ def setupUi(self, Form): def retranslateUi(self, Form): _translate = QtCore.QCoreApplication.translate Form.setWindowTitle(_translate("Form", "PyQtGraph")) - self.qtLibCombo.setItemText(0, _translate("Form", "default")) - self.qtLibCombo.setItemText(1, _translate("Form", "PyQt5")) - self.qtLibCombo.setItemText(2, _translate("Form", "PySide2")) - self.qtLibCombo.setItemText(3, _translate("Form", "PySide6")) - self.qtLibCombo.setItemText(4, _translate("Form", "PyQt6")) self.loadBtn.setText(_translate("Form", "Run Example")) self.label.setText(_translate("Form", "Qt Library:")) self.exampleFilter.setPlaceholderText(_translate("Form", "Type to filter...")) diff --git a/pyqtgraph/examples/histogram.py b/pyqtgraph/examples/histogram.py index f09361bb28..9e46b4e0b7 100644 --- a/pyqtgraph/examples/histogram.py +++ b/pyqtgraph/examples/histogram.py @@ -7,7 +7,7 @@ import pyqtgraph as pg win = pg.GraphicsLayoutWidget(show=True) -win.resize(800,350) +win.resize(800,480) win.setWindowTitle('pyqtgraph example: Histogram') plt1 = win.addPlot() plt2 = win.addPlot() @@ -23,9 +23,14 @@ plt1.plot(x, y, stepMode="center", fillLevel=0, fillOutline=True, brush=(0,0,255,150)) ## Now draw all points as a nicely-spaced scatter plot -y = pg.pseudoScatter(vals, spacing=0.15) -#plt2.plot(vals, y, pen=None, symbol='o', symbolSize=5) -plt2.plot(vals, y, pen=None, symbol='o', symbolSize=5, symbolPen=(255,255,255,200), symbolBrush=(0,0,255,150)) +psy = pg.pseudoScatter(vals, spacing=0.15) +plt2.plot(vals, psy, pen=None, symbol='o', symbolSize=5, symbolPen=(255,255,255,200), symbolBrush=(0,0,255,150)) + +# draw histogram using BarGraphItem +win.nextRow() +plt3 = win.addPlot() +bgi = pg.BarGraphItem(x0=x[:-1], x1=x[1:], height=y, pen='w', brush=(0,0,255,150)) +plt3.addItem(bgi) if __name__ == '__main__': pg.exec() diff --git a/pyqtgraph/examples/infiniteline_performance.py b/pyqtgraph/examples/infiniteline_performance.py index 5425505edb..b42fb02caa 100644 --- a/pyqtgraph/examples/infiniteline_performance.py +++ b/pyqtgraph/examples/infiniteline_performance.py @@ -1,11 +1,10 @@ #!/usr/bin/python -from time import perf_counter - import numpy as np import pyqtgraph as pg from pyqtgraph.Qt import QtCore +from utils import FrameCounter app = pg.mkQApp("Infinite Line Performance") @@ -22,29 +21,20 @@ data = np.random.normal(size=(50, 5000)) ptr = 0 -lastTime = perf_counter() -fps = None - def update(): - global curve, data, ptr, p, lastTime, fps + global ptr curve.setData(data[ptr % 10]) ptr += 1 - now = perf_counter() - dt = now - lastTime - lastTime = now - if fps is None: - fps = 1.0/dt - else: - s = np.clip(dt*3., 0, 1) - fps = fps * (1-s) + (1.0/dt) * s - p.setTitle('%0.2f fps' % fps) - app.processEvents() # force complete redraw for every plot + framecnt.update() timer = QtCore.QTimer() timer.timeout.connect(update) timer.start(0) +framecnt = FrameCounter() +framecnt.sigFpsUpdate.connect(lambda fps: p.setTitle(f'{fps:.1f} fps')) + if __name__ == '__main__': pg.exec() diff --git a/pyqtgraph/examples/jupyter_console_example.py b/pyqtgraph/examples/jupyter_console_example.py new file mode 100644 index 0000000000..3fe5639a6e --- /dev/null +++ b/pyqtgraph/examples/jupyter_console_example.py @@ -0,0 +1,97 @@ +""" +This example show how to create a Rich Jupyter Widget, and places it in a MainWindow alongside a PlotWidget. + +The widgets are implemented as `Docks` so they may be moved around within the Main Window + +The `__main__` function shows an example that inputs the commands to plot simple `sine` and cosine` waves, equivalent to creating such plots by entering the commands manually in the console + +Also shows the use of `whos`, which returns a list of the variables defined within the `ipython` kernel + +This method for creating a Jupyter console is based on the example(s) here: +https://github.com/jupyter/qtconsole/tree/b4e08f763ef1334d3560d8dac1d7f9095859545a/examples +especially- +https://github.com/jupyter/qtconsole/blob/b4e08f763ef1334d3560d8dac1d7f9095859545a/examples/embed_qtconsole.py#L19 + +""" + + +import numpy as np +import pyqtgraph as pg +from pyqtgraph.Qt import QtWidgets +from pyqtgraph.dockarea.Dock import Dock +from pyqtgraph.dockarea.DockArea import DockArea + +try: + from qtconsole import inprocess +except (ImportError, NameError): + print( + "The example in `jupyter_console_example.py` requires `qtconsole` to run. Install with `pip install qtconsole` or equivalent." + ) + + +class JupyterConsoleWidget(inprocess.QtInProcessRichJupyterWidget): + def __init__(self): + super().__init__() + + self.kernel_manager = inprocess.QtInProcessKernelManager() + self.kernel_manager.start_kernel() + self.kernel_client = self.kernel_manager.client() + self.kernel_client.start_channels() + + def shutdown_kernel(self): + self.kernel_client.stop_channels() + self.kernel_manager.shutdown_kernel() + + +class MainWindow(QtWidgets.QMainWindow): + def __init__(self, dark_mode=True): + super().__init__() + central_dock_area = DockArea() + + # create plot widget (and dock) + self.plot_widget = pg.PlotWidget() + plot_dock = Dock(name="Plot Widget Dock", closable=True) + plot_dock.addWidget(self.plot_widget) + central_dock_area.addDock(plot_dock) + + # create jupyter console widget (and dock) + self.jupyter_console_widget = JupyterConsoleWidget() + jupyter_console_dock = Dock("Jupyter Console Dock") + jupyter_console_dock.addWidget(self.jupyter_console_widget) + central_dock_area.addDock(jupyter_console_dock) + self.setCentralWidget(central_dock_area) + + app = QtWidgets.QApplication.instance() + app.aboutToQuit.connect(self.jupyter_console_widget.shutdown_kernel) + + kernel = self.jupyter_console_widget.kernel_manager.kernel + kernel.shell.push(dict(np=np, pw=self.plot_widget)) + + # set dark mode + if dark_mode: + # Set Dark bg color via this relatively roundabout method + self.jupyter_console_widget.set_default_style( + "linux" + ) + +if __name__ == "__main__": + pg.mkQApp() + main = MainWindow(dark_mode=True) + main.show() + main.jupyter_console_widget.execute('print("hello world :D ")') + + # plot a sine/cosine waves by printing to console + # this is equivalent to typing the commands into the console manually + main.jupyter_console_widget.execute("x = np.arange(0, 3 * np.pi, .1)") + main.jupyter_console_widget.execute("pw.plotItem.plot(np.sin(x), pen='r')") + main.jupyter_console_widget.execute( + "pw.plotItem.plot(np.cos(x),\ + pen='cyan',\ + symbol='o',\ + symbolPen='m',\ + symbolBrush=(0,0,255))" + ) + main.jupyter_console_widget.execute("whos") + main.jupyter_console_widget.execute("") + + pg.exec() diff --git a/pyqtgraph/examples/multiplePlotSpeedTest.py b/pyqtgraph/examples/multiplePlotSpeedTest.py index 804706f790..8cbe1cf789 100644 --- a/pyqtgraph/examples/multiplePlotSpeedTest.py +++ b/pyqtgraph/examples/multiplePlotSpeedTest.py @@ -3,7 +3,6 @@ import numpy as np import pyqtgraph as pg -from pyqtgraph.Qt import QtWidgets app = pg.mkQApp() plt = pg.PlotWidget() @@ -30,19 +29,13 @@ def plot(): ## we need here. This overhead adds up quickly and makes a big ## difference in speed. - #plt.plot(x=x+i, y=y+j) plt.addItem(pg.PlotCurveItem(x=x+i, y=y+j)) - #path = pg.arrayToQPath(x+i, y+j) - #item = QtWidgets.QGraphicsPathItem(path) - #item.setPen(pg.mkPen('w')) - #plt.addItem(item) - dt = perf_counter() - start - print(f"Create plots tooks {dt * 1000:.3f} ms") + print(f"Create plots took: {dt * 1000:.3f} ms") ## Plot and clear 5 times, printing the time it took -for i in range(5): +for _ in range(5): plt.clear() plot() app.processEvents() @@ -54,21 +47,21 @@ def plot(): def fastPlot(): ## Different approach: generate a single item with all data points. - ## This runs about 20x faster. + ## This runs many times faster. start = perf_counter() n = 15 pts = 100 x = np.linspace(0, 0.8, pts) y = np.random.random(size=pts)*0.8 - xdata = np.empty((n, n, pts)) - xdata[:] = x.reshape(1,1,pts) + np.arange(n).reshape(n,1,1) - ydata = np.empty((n, n, pts)) - ydata[:] = y.reshape(1,1,pts) + np.arange(n).reshape(1,n,1) - conn = np.ones((n*n,pts)) - conn[:,-1] = False # make sure plots are disconnected - path = pg.arrayToQPath(xdata.flatten(), ydata.flatten(), conn.flatten()) - item = QtWidgets.QGraphicsPathItem(path) - item.setPen(pg.mkPen('w')) + shape = (n, n, pts) + xdata = np.empty(shape) + xdata[:] = x + np.arange(shape[1]).reshape((1,-1,1)) + ydata = np.empty(shape) + ydata[:] = y + np.arange(shape[0]).reshape((-1,1,1)) + conn = np.ones(shape, dtype=bool) + conn[...,-1] = False # make sure plots are disconnected + item = pg.PlotCurveItem() + item.setData(xdata.ravel(), ydata.ravel(), connect=conn.ravel()) plt.addItem(item) dt = perf_counter() - start @@ -76,15 +69,11 @@ def fastPlot(): ## Plot and clear 5 times, printing the time it took -if hasattr(pg, 'arrayToQPath'): - for i in range(5): - plt.clear() - fastPlot() - app.processEvents() -else: - print("Skipping fast tests--arrayToQPath function is missing.") - -plt.autoRange() +for _ in range(5): + plt.clear() + fastPlot() + app.processEvents() + plt.autoRange() if __name__ == '__main__': pg.exec() diff --git a/pyqtgraph/examples/optics_demos.py b/pyqtgraph/examples/optics_demos.py index 2b80ca0bac..f551ec02e8 100644 --- a/pyqtgraph/examples/optics_demos.py +++ b/pyqtgraph/examples/optics_demos.py @@ -3,10 +3,11 @@ """ import numpy as np -from optics import * +from optics import Mirror, Ray, Tracer, Lens import pyqtgraph as pg from pyqtgraph import Point +from pyqtgraph.Qt import QtCore app = pg.mkQApp("Optics Demo") diff --git a/pyqtgraph/examples/test_examples.py b/pyqtgraph/examples/test_examples.py index ad0c029705..53bbefdc70 100644 --- a/pyqtgraph/examples/test_examples.py +++ b/pyqtgraph/examples/test_examples.py @@ -70,6 +70,10 @@ def buildFileList(examples, files=None): False, reason="Example requires user interaction" ), + "jupyter_console_example.py": exceptionCondition( + importlib.util.find_spec("qtconsole") is not None, + reason="No need to test with qtconsole not being installed" + ), "RemoteSpeedTest.py": exceptionCondition( False, reason="Test is being problematic on CI machines" diff --git a/pyqtgraph/examples/utils.py b/pyqtgraph/examples/utils.py index ac300de300..0ce23ae75c 100644 --- a/pyqtgraph/examples/utils.py +++ b/pyqtgraph/examples/utils.py @@ -1,22 +1,27 @@ from argparse import Namespace from collections import OrderedDict +from time import perf_counter +from pyqtgraph.Qt import QtCore # Avoid clash with module name examples_ = OrderedDict([ ('Command-line usage', 'CLIexample.py'), ('Basic Plotting', Namespace(filename='Plotting.py', recommended=True)), - ('ImageView', 'ImageView.py'), - ('ParameterTree', 'parametertree.py'), + ('ImageView', Namespace(filename='ImageView.py', recommended=True)), + ('ParameterTree', Namespace(filename='parametertree.py', recommended=True)), + ('Plotting Datasets', 'MultiDataPlot.py'), ('Parameter-Function Interaction', 'InteractiveParameter.py'), ('Crosshair / Mouse interaction', 'crosshair.py'), ('Data Slicing', 'DataSlicing.py'), ('Plot Customization', 'customPlot.py'), ('Timestamps on x axis', 'DateAxisItem.py'), - ('Image Analysis', 'imageAnalysis.py'), + ('Image Analysis', Namespace(filename='imageAnalysis.py', recommended=True)), ('Matrix Display', 'MatrixDisplayExample.py'), ('ViewBox Features', Namespace(filename='ViewBoxFeatures.py', recommended=True)), ('Dock widgets', 'dockarea.py'), ('Console', 'ConsoleWidget.py'), + ('Console - Exception inspection', 'console_exception_inspection.py'), + ('Rich Jupyter Console', 'jupyter_console_example.py'), ('Histograms', 'histogram.py'), ('Beeswarm plot', 'beeswarm.py'), ('Symbols', 'Symbols.py'), @@ -112,7 +117,6 @@ ('GradientEditor', 'GradientEditor.py'), ('GLViewWidget', 'GLViewWidget.py'), ('DiffTreeWidget', 'DiffTreeWidget.py'), - ('MultiPlotWidget', 'MultiPlotWidget.py'), ('RemoteGraphicsView', 'RemoteGraphicsView.py'), ('contextMenu', 'contextMenu.py'), ('designerExample', 'designerExample.py'), @@ -133,3 +137,28 @@ skiptest = dict([ ('ProgressDialog', 'ProgressDialog.py'), # modal dialog ]) + + +class FrameCounter(QtCore.QObject): + sigFpsUpdate = QtCore.Signal(object) + + def __init__(self, interval=1000): + super().__init__() + self.count = 0 + self.last_update = 0 + self.interval = interval + + def update(self): + self.count += 1 + + if self.last_update == 0: + self.last_update = perf_counter() + self.startTimer(self.interval) + + def timerEvent(self, evt): + now = perf_counter() + elapsed = now - self.last_update + fps = self.count / elapsed + self.last_update = now + self.count = 0 + self.sigFpsUpdate.emit(fps) diff --git a/pyqtgraph/exporters/CSVExporter.py b/pyqtgraph/exporters/CSVExporter.py index 32b5047ebc..d918b52dd8 100644 --- a/pyqtgraph/exporters/CSVExporter.py +++ b/pyqtgraph/exporters/CSVExporter.py @@ -1,4 +1,9 @@ -from .. import PlotItem +import csv +import itertools + +import numpy as np + +from .. import ErrorBarItem, PlotItem from ..parametertree import Parameter from ..Qt import QtCore from .Exporter import Exporter @@ -16,71 +21,107 @@ def __init__(self, item): self.params = Parameter(name='params', type='group', children=[ {'name': 'separator', 'title': translate("Exporter", 'separator'), 'type': 'list', 'value': 'comma', 'limits': ['comma', 'tab']}, {'name': 'precision', 'title': translate("Exporter", 'precision'), 'type': 'int', 'value': 10, 'limits': [0, None]}, - {'name': 'columnMode', 'title': translate("Exporter", 'columnMode'), 'type': 'list', 'limits': ['(x,y) per plot', '(x,y,y,y) for all plots']} + { + 'name': 'columnMode', + 'title': translate("Exporter", 'columnMode'), + 'type': 'list', + 'limits': ['(x,y) per plot', '(x,y,y,y) for all plots'] + } ]) - + + self.index_counter = itertools.count(start=0) + self.header = [] + self.data = [] + def parameters(self): return self.params - + + def _exportErrorBarItem(self, errorBarItem: ErrorBarItem) -> None: + error_data = [] + index = next(self.index_counter) + + # make sure the plot actually has data: + if errorBarItem.opts['x'] is None or errorBarItem.opts['y'] is None: + return None + + header_naming_map = { + "left": "x_min_error", + "right": "x_max_error", + "bottom": "y_min_error", + "top": "y_max_error" + } + + # grab the base-points + self.header.extend([f'x{index:04}_error', f'y{index:04}_error']) + error_data.extend([errorBarItem.opts['x'], errorBarItem.opts['y']]) + + # grab the error bars + for error_direction, header_label in header_naming_map.items(): + if (error := errorBarItem.opts[error_direction]) is not None: + self.header.extend([f'{header_label}_{index:04}']) + error_data.append(error) + + self.data.append(tuple(error_data)) + return None + + def _exportPlotDataItem(self, plotDataItem) -> None: + if hasattr(plotDataItem, 'getOriginalDataset'): + # try to access unmapped, unprocessed data + cd = plotDataItem.getOriginalDataset() + else: + # fall back to earlier access method + cd = plotDataItem.getData() + if cd[0] is None: + # no data found, break out... + return None + self.data.append(cd) + + index = next(self.index_counter) + if plotDataItem.name() is not None: + name = plotDataItem.name().replace('"', '""') + '_' + xName = f"{name}x" + yName = f"{name}y" + else: + xName = f'x{index:04}' + yName = f'y{index:04}' + appendAllX = self.params['columnMode'] == '(x,y) per plot' + if appendAllX or index == 0: + self.header.extend([xName, yName]) + else: + self.header.extend([yName]) + return None + def export(self, fileName=None): - if not isinstance(self.item, PlotItem): - raise Exception("Must have a PlotItem selected for CSV export.") - + raise TypeError("Must have a PlotItem selected for CSV export.") + if fileName is None: self.fileSaveDialog(filter=["*.csv", "*.tsv"]) return - data = [] - header = [] + for item in self.item.items: + if isinstance(item, ErrorBarItem): + self._exportErrorBarItem(item) + elif hasattr(item, 'implements') and item.implements('plotData'): + self._exportPlotDataItem(item) - appendAllX = self.params['columnMode'] == '(x,y) per plot' + sep = "," if self.params['separator'] == 'comma' else "\t" + # we want to flatten the nested arrays of data into columns + columns = [column for dataset in self.data for column in dataset] + with open(fileName, 'w', newline='') as csvfile: + writer = csv.writer(csvfile, delimiter=sep, quoting=csv.QUOTE_MINIMAL) + writer.writerow(self.header) + for row in itertools.zip_longest(*columns, fillvalue=""): + row_to_write = [ + item if isinstance(item, str) + else np.format_float_positional( + item, precision=self.params['precision'] + ) + for item in row + ] + writer.writerow(row_to_write) - for i, c in enumerate(self.item.curves): - if hasattr(c, 'getOriginalDataset'): # try to access unmapped, unprocessed data - cd = c.getOriginalDataset() - else: - cd = c.getData() # fall back to earlier access method - if cd[0] is None: - continue - data.append(cd) - if hasattr(c, 'implements') and c.implements('plotData') and c.name() is not None: - name = c.name().replace('"', '""') + '_' - xName, yName = '"'+name+'x"', '"'+name+'y"' - else: - xName = 'x%04d' % i - yName = 'y%04d' % i - if appendAllX or i == 0: - header.extend([xName, yName]) - else: - header.extend([yName]) - - if self.params['separator'] == 'comma': - sep = ',' - else: - sep = '\t' - - with open(fileName, 'w') as fd: - fd.write(sep.join(map(str, header)) + '\n') - i = 0 - numFormat = '%%0.%dg' % self.params['precision'] - numRows = max([len(d[0]) for d in data]) - for i in range(numRows): - for j, d in enumerate(data): - # write x value if this is the first column, or if we want - # x for all rows - if appendAllX or j == 0: - if d is not None and i < len(d[0]): - fd.write(numFormat % d[0][i] + sep) - else: - fd.write(' %s' % sep) - - # write y value - if d is not None and i < len(d[1]): - fd.write(numFormat % d[1][i] + sep) - else: - fd.write(' %s' % sep) - fd.write('\n') - - -CSVExporter.register() + self.header.clear() + self.data.clear() + +CSVExporter.register() diff --git a/pyqtgraph/exporters/SVGExporter.py b/pyqtgraph/exporters/SVGExporter.py index e95e19e2c0..575c228fb3 100644 --- a/pyqtgraph/exporters/SVGExporter.py +++ b/pyqtgraph/exporters/SVGExporter.py @@ -1,9 +1,12 @@ +__all__ = ['SVGExporter'] + +import contextlib import re import xml.dom.minidom as xml import numpy as np -from .. import debug +from .. import PlotCurveItem, ScatterPlotItem, debug from .. import functions as fn from ..parametertree import Parameter from ..Qt import QtCore, QtGui, QtSvg, QtWidgets @@ -11,8 +14,6 @@ translate = QtCore.QCoreApplication.translate -__all__ = ['SVGExporter'] - class SVGExporter(Exporter): Name = "Scalable Vector Graphics (SVG)" allowCopy=True @@ -21,10 +22,7 @@ def __init__(self, item): Exporter.__init__(self, item) tr = self.getTargetRect() - if isinstance(item, QtWidgets.QGraphicsItem): - scene = item.scene() - else: - scene = item + scene = item.scene() if isinstance(item, QtWidgets.QGraphicsItem) else item bgbrush = scene.views()[0].backgroundBrush() bg = bgbrush.color() if bgbrush.style() == QtCore.Qt.BrushStyle.NoBrush: @@ -38,8 +36,15 @@ def __init__(self, item): 'limits': (0, None)}, #{'name': 'viewbox clipping', 'type': 'bool', 'value': True}, #{'name': 'normalize coordinates', 'type': 'bool', 'value': True}, - {'name': 'scaling stroke', 'title': translate("Exporter", 'scaling stroke'), 'type': 'bool', 'value': False, 'tip': "If False, strokes are non-scaling, " - "which means that they appear the same width on screen regardless of how they are scaled or how the view is zoomed."}, + { + 'name': 'scaling stroke', + 'title': translate("Exporter", 'scaling stroke'), + 'type': 'bool', + 'value': False, + 'tip': "If False, strokes are non-scaling, which means that " + "they appear the same width on screen regardless of " + "how they are scaled or how the view is zoomed." + }, ]) self.params.param('width').sigValueChanged.connect(self.widthChanged) self.params.param('height').sigValueChanged.connect(self.heightChanged) @@ -70,7 +75,6 @@ def export(self, fileName=None, toBytes=False, copy=False): options['width'] = self.params['width'] options['height'] = self.params['height'] xml = generateSvg(self.item, options) - if toBytes: return xml.encode('UTF-8') elif copy: @@ -79,7 +83,7 @@ def export(self, fileName=None, toBytes=False, copy=False): QtWidgets.QApplication.clipboard().setMimeData(md) else: with open(fileName, 'wb') as fh: - fh.write(str(xml).encode('utf-8')) + fh.write(xml.encode('utf-8')) # Includes space for extra attributes xmlHeader = """\ @@ -96,7 +100,9 @@ def export(self, fileName=None, toBytes=False, copy=False): """ -def generateSvg(item, options={}): +def generateSvg(item, options=None): + if options is None: + options = {} global xmlHeader try: node, defs = _generateItemSvg(item, options=options) @@ -111,67 +117,95 @@ def generateSvg(item, options={}): for i in items: if hasattr(i, 'setExportMode'): i.setExportMode(False) - cleanXml(node) defsXml = "\n" for d in defs: defsXml += d.toprettyxml(indent=' ') defsXml += "\n" - svgAttributes = ' viewBox ="0 0 %f %f"' % (options["width"], options["height"]) + svgAttributes = f' viewBox ="0 0 {int(options["width"])} {int(options["height"])}"' c = options['background'] - backgroundtag = '\n' % (c.red(), c.green(), c.blue(), c.alphaF()) + backgroundtag = f'\n' return (xmlHeader % svgAttributes) + backgroundtag + defsXml + node.toprettyxml(indent=' ') + "\n\n" +def _generateItemSvg(item, nodes=None, root=None, options=None): + """This function is intended to work around some issues with Qt's SVG generator + and SVG in general. + + .. warning:: + This function, while documented, is not considered part of the public + API. The reason for its documentation is for ease of referencing by + :func:`~pyqtgraph.GraphicsItem.generateSvg`. There should be no need + to call this function explicitly. -def _generateItemSvg(item, nodes=None, root=None, options={}): - ## This function is intended to work around some issues with Qt's SVG generator - ## and SVG in general. - ## 1) Qt SVG does not implement clipping paths. This is absurd. - ## The solution is to let Qt generate SVG for each item independently, - ## then glue them together manually with clipping. - ## - ## The format Qt generates for all items looks like this: - ## - ## - ## - ## one or more of: or or - ## - ## - ## one or more of: or or - ## - ## . . . - ## - ## - ## 2) There seems to be wide disagreement over whether path strokes - ## should be scaled anisotropically. - ## see: http://web.mit.edu/jonas/www/anisotropy/ - ## Given that both inkscape and illustrator seem to prefer isotropic - ## scaling, we will optimize for those cases. - ## - ## 3) Qt generates paths using non-scaling-stroke from SVG 1.2, but - ## inkscape only supports 1.1. - ## - ## Both 2 and 3 can be addressed by drawing all items in world coordinates. + 1. Qt SVG does not implement clipping paths. This is absurd. + The solution is to let Qt generate SVG for each item independently, + then glue them together manually with clipping. The format Qt generates + for all items looks like this: + + .. code-block:: xml + + + one or more of: or or + + + one or more of: or or + + . . . + + + 2. There seems to be wide disagreement over whether path strokes + should be scaled anisotropically. Given that both inkscape and + illustrator seem to prefer isotropic scaling, we will optimize for + those cases. + + .. note:: + + see: http://web.mit.edu/jonas/www/anisotropy/ + + 3. Qt generates paths using non-scaling-stroke from SVG 1.2, but + inkscape only supports 1.1. + + Both 2 and 3 can be addressed by drawing all items in world coordinates. + + Parameters + ---------- + item : :class:`~pyqtgraph.GraphicsItem` + GraphicsItem to generate SVG of + nodes : dict of str, optional + dictionary keyed on graphics item names, values contains the + XML elements, by default None + root : :class:`~pyqtgraph.GraphicsItem`, optional + root GraphicsItem, if none, assigns to `item`, by default None + options : dict of str, optional + Options to be applied to the generated XML, by default None + + Returns + ------- + tuple + tuple where first element is XML element, second element is + a list of child GraphicItems XML elements + """ + profiler = debug.Profiler() - + if options is None: + options = {} + if nodes is None: ## nodes maps all node IDs to their XML element. ## this allows us to ensure all elements receive unique names. nodes = {} - + if root is None: root = item - + ## Skip hidden items if hasattr(item, 'isVisible') and not item.isVisible(): return None - - ## If this item defines its own SVG generator, use that. - if hasattr(item, 'generateSvg'): - return item.generateSvg(nodes) - + with contextlib.suppress(NotImplementedError, AttributeError): + # If this item defines its own SVG generator, use that. + return item.generateSvg(nodes) ## Generate SVG text for just this item (exclude its children; we'll handle them later) if isinstance(item, QtWidgets.QGraphicsScene): xmlStr = "\n\n" @@ -183,16 +217,51 @@ def _generateItemSvg(item, nodes=None, root=None, options={}): childs = item.childItems() else: childs = item.childItems() - tr = itemTransform(item, item.scene()) + + dx = dy = 0. + sx = sy = 1. + + if isinstance(item, PlotCurveItem): + # Workaround for lack of precision in SVG numbers + # We shift curves to be centered about (0, 0) and scaled such that + # they fit within the region of float32 values that we can + # distinguish between similar but different values + rect = item.viewRect() + x_range = rect.right() - rect.left() + dx = rect.left() + (x_range / 2) + + y_range = rect.top() - rect.bottom() + dy = rect.bottom() + y_range / 2 + + sx = 1 / abs(x_range) + sy = 1 / abs(y_range) + + item.blockSignals(True) + + xDataOriginal = item.xData + yDataOriginal = item.yData + # use deepcopy of data to not mess with other references... + item.setData( + (xDataOriginal.copy() - dx) * sx, (yDataOriginal.copy() - dy) * sy + ) + item.blockSignals(False) - ## offset to corner of root item + manipulate = QtGui.QTransform(1 / sx, 0, 0, 1 / sy, dx, dy) + tr = itemTransform(item, item.scene()) + # offset to corner of root item if isinstance(root, QtWidgets.QGraphicsScene): rootPos = QtCore.QPoint(0,0) else: rootPos = root.scenePos() - tr2 = QtGui.QTransform() - tr2.translate(-rootPos.x(), -rootPos.y()) - tr = tr * tr2 + + # handle rescaling from the export dialog + if hasattr(root, 'boundingRect'): + resize_x = options["width"] / root.boundingRect().width() + resize_y = options["height"] / root.boundingRect().height() + else: + resize_x = resize_y = 1 + tr2 = QtGui.QTransform(resize_x, 0, 0, resize_y, -rootPos.x(), -rootPos.y()) + tr = manipulate * tr * tr2 arr = QtCore.QByteArray() buf = QtCore.QBuffer(arr) @@ -200,7 +269,6 @@ def _generateItemSvg(item, nodes=None, root=None, options={}): svg.setOutputDevice(buf) dpi = QtGui.QGuiApplication.primaryScreen().logicalDotsPerInchX() svg.setResolution(int(dpi)) - p = QtGui.QPainter() p.begin(svg) if hasattr(item, 'setExportMode'): @@ -215,28 +283,25 @@ def _generateItemSvg(item, nodes=None, root=None, options={}): p.end() ## Can't do this here--we need to wait until all children have painted as well. ## this is taken care of in generateSvg instead. - #if hasattr(item, 'setExportMode'): - #item.setExportMode(False) + # if hasattr(item, 'setExportMode'): + # item.setExportMode(False) doc = xml.parseString(arr.data()) - + try: ## Get top-level group for this item g1 = doc.getElementsByTagName('g')[0] - defs = doc.getElementsByTagName('defs') if len(defs) > 0: defs = [n for n in defs[0].childNodes if isinstance(n, xml.Element)] except: print(doc.toxml()) raise - profiler('render') - ## Get rid of group transformation matrices by applying ## transformation to inner coordinates correctCoordinates(g1, defs, item, options) profiler('correct') - + ## decide on a name for this item baseName = item.__class__.__name__ i = 1 @@ -247,33 +312,34 @@ def _generateItemSvg(item, nodes=None, root=None, options={}): i += 1 nodes[name] = g1 g1.setAttribute('id', name) - + ## If this item clips its children, we need to take care of that. childGroup = g1 ## add children directly to this node unless we are clipping - if not isinstance(item, QtWidgets.QGraphicsScene): - ## See if this item clips its children - if item.flags() & item.GraphicsItemFlag.ItemClipsChildrenToShape: - ## Generate svg for just the path - path = QtWidgets.QGraphicsPathItem(item.mapToScene(item.shape())) - item.scene().addItem(path) - try: - pathNode = _generateItemSvg(path, root=root, options=options)[0].getElementsByTagName('path')[0] - # assume for this path is empty.. possibly problematic. - finally: - item.scene().removeItem(path) - + if ( + not isinstance(item, QtWidgets.QGraphicsScene) and + item.flags() & item.GraphicsItemFlag.ItemClipsChildrenToShape + ): + ## Generate svg for just the path + path = QtWidgets.QGraphicsPathItem(item.mapToScene(item.shape())) + item.scene().addItem(path) + try: + pathNode = _generateItemSvg(path, root=root, options=options)[0].getElementsByTagName('path')[0] + # assume for this path is empty.. possibly problematic. + finally: + item.scene().removeItem(path) + ## and for the clipPath element - clip = name + '_clip' - clipNode = g1.ownerDocument.createElement('clipPath') - clipNode.setAttribute('id', clip) - clipNode.appendChild(pathNode) - g1.appendChild(clipNode) - - childGroup = g1.ownerDocument.createElement('g') - childGroup.setAttribute('clip-path', 'url(#%s)' % clip) - g1.appendChild(childGroup) + clip = f'{name}_clip' + clipNode = g1.ownerDocument.createElement('clipPath') + clipNode.setAttribute('id', clip) + clipNode.appendChild(pathNode) + g1.appendChild(clipNode) + + childGroup = g1.ownerDocument.createElement('g') + childGroup.setAttribute('clip-path', f'url(#{clip})') + g1.appendChild(childGroup) profiler('clipping') - + ## Add all child items as sub-elements. childs.sort(key=lambda c: c.zValue()) for ch in childs: @@ -288,14 +354,40 @@ def _generateItemSvg(item, nodes=None, root=None, options={}): return g1, defs -def correctCoordinates(node, defs, item, options): - # TODO: correct gradient coordinates inside defs - +def correctCoordinates(node, defs, item, options): + # correct the defs in the linearGradient + for d in defs: + if d.tagName == "linearGradient": + # reset "gradientUnits" attribute to SVG default value + d.removeAttribute("gradientUnits") + + # replace with percentages + for coord in ("x1", "x2", "y1", "y2"): + if coord.startswith("x"): + denominator = item.boundingRect().width() + else: + denominator = item.boundingRect().height() + percentage = round(float(d.getAttribute(coord)) * 100 / denominator) + d.setAttribute(coord, f"{percentage}%") + + # replace stops with percentages + for child in filter( + lambda e: isinstance(e, xml.Element) and e.tagName == "stop", + d.childNodes + ): + offset = child.getAttribute("offset") + try: + child.setAttribute("offset", f"{round(float(offset) * 100)}%") + except ValueError: + # offset attribute could not be converted to float + # must be one of the other SVG accepted formats + continue + ## Remove transformation matrices from tags by applying matrix to coordinates inside. ## Each item is represented by a single top-level group with one or more groups inside. ## Each inner group contains one or more drawing primitives, possibly of different types. groups = node.getElementsByTagName('g') - + ## Since we leave text unchanged, groups which combine text and non-text primitives must be split apart. ## (if at some point we start correcting text transforms as well, then it should be safe to remove this) groups2 = [] @@ -320,8 +412,7 @@ def correctCoordinates(node, defs, item, options): node.insertBefore(sg, grp) node.removeChild(grp) groups = groups2 - - + for grp in groups: matrix = grp.getAttribute('transform') match = re.match(r'matrix\((.*)\)', matrix) @@ -330,7 +421,7 @@ def correctCoordinates(node, defs, item, options): else: vals = [float(a) for a in match.groups()[0].split(',')] tr = np.array([[vals[0], vals[2], vals[4]], [vals[1], vals[3], vals[5]]]) - + removeTransform = False for ch in grp.childNodes: if not isinstance(ch, xml.Element): @@ -358,22 +449,22 @@ def correctCoordinates(node, defs, item, options): # If coords start with L instead of M, then the entire path will not be rendered. # (This can happen if the first point had nan values in it--Qt will skip it on export) if newCoords[0] != 'M': - newCoords = 'M' + newCoords[1:] + newCoords = f'M{newCoords[1:]}' ch.setAttribute('d', newCoords) elif ch.tagName == 'text': removeTransform = False ## leave text alone for now. Might need this later to correctly render text with outline. - #c = np.array([ - #[float(ch.getAttribute('x')), float(ch.getAttribute('y'))], - #[float(ch.getAttribute('font-size')), 0], - #[0,0]]) - #c = fn.transformCoordinates(tr, c, transpose=True) - #ch.setAttribute('x', str(c[0,0])) - #ch.setAttribute('y', str(c[0,1])) - #fs = c[1]-c[2] - #fs = (fs**2).sum()**0.5 - #ch.setAttribute('font-size', str(fs)) - + # c = np.array([ + # [float(ch.getAttribute('x')), float(ch.getAttribute('y'))], + # [float(ch.getAttribute('font-size')), 0], + # [0,0]]) + # c = fn.transformCoordinates(tr, c, transpose=True) + # ch.setAttribute('x', str(c[0,0])) + # ch.setAttribute('y', str(c[0,1])) + # fs = c[1]-c[2] + # fs = (fs**2).sum()**0.5 + # ch.setAttribute('font-size', str(fs)) + ## Correct some font information families = ch.getAttribute('font-family').split(',') if len(families) == 1: @@ -385,14 +476,14 @@ def correctCoordinates(node, defs, item, options): elif font.styleHint() == font.StyleHint.Courier: families.append('monospace') ch.setAttribute('font-family', ', '.join([f if ' ' not in f else '"%s"'%f for f in families])) - + ## correct line widths if needed if removeTransform and ch.getAttribute('vector-effect') != 'non-scaling-stroke' and grp.getAttribute('stroke-width') != '': w = float(grp.getAttribute('stroke-width')) s = fn.transformCoordinates(tr, np.array([[w,0], [0,0]]), transpose=True) w = ((s[0]-s[1])**2).sum()**0.5 ch.setAttribute('stroke-width', str(w)) - + # Remove non-scaling-stroke if requested if options.get('scaling stroke') is True and ch.getAttribute('vector-effect') == 'non-scaling-stroke': ch.removeAttribute('vector-effect') @@ -407,14 +498,13 @@ def correctCoordinates(node, defs, item, options): def itemTransform(item, root): ## Return the transformation mapping item to root ## (actually to parent coordinate system of root) - + if item is root: tr = QtGui.QTransform() tr.translate(*item.pos()) tr = tr * item.transform() return tr - - + if item.flags() & item.GraphicsItemFlag.ItemIgnoresTransformations: pos = item.pos() parent = item.parentItem() diff --git a/pyqtgraph/flowchart/Flowchart.py b/pyqtgraph/flowchart/Flowchart.py index 53bddae8e4..ac6ee95bf8 100644 --- a/pyqtgraph/flowchart/Flowchart.py +++ b/pyqtgraph/flowchart/Flowchart.py @@ -1,24 +1,22 @@ __init__ = ["Flowchart", "FlowchartGraphicsItem", "FlowchartNode"] import importlib +import os from collections import OrderedDict -from .. import DataTreeWidget, FileDialog -from ..Qt import QT_LIB, QtCore, QtWidgets -from .Node import Node - -FlowchartCtrlTemplate = importlib.import_module( - f'.FlowchartCtrlTemplate_{QT_LIB.lower()}', package=__package__) - from numpy import ndarray +from .. import DataTreeWidget, FileDialog from .. import configfile as configfile from .. import dockarea as dockarea from .. import functions as fn from ..debug import printExc from ..graphicsItems.GraphicsObject import GraphicsObject +from ..Qt import QtCore, QtWidgets +from . import FlowchartCtrlTemplate_generic as FlowchartCtrlTemplate from . import FlowchartGraphicsView from .library import LIBRARY +from .Node import Node from .Terminal import Terminal @@ -26,8 +24,6 @@ def strDict(d): return dict([(str(k), v) for k, v in d.items()]) - - class Flowchart(Node): sigFileLoaded = QtCore.Signal(object) sigFileSaved = QtCore.Signal(object) @@ -763,10 +759,7 @@ def __init__(self, chart, ctrl): self.hoverItem = None #self.setMinimumWidth(250) #self.setSizePolicy(QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Expanding)) - - #self.ui = FlowchartTemplate.Ui_Form() - #self.ui.setupUi(self) - + ## build user interface (it was easier to do it here than via developer) self.view = FlowchartGraphicsView.FlowchartGraphicsView(self) self.viewDock = dockarea.Dock('view', size=(1000,600)) diff --git a/pyqtgraph/flowchart/FlowchartCtrlTemplate_pyqt6.py b/pyqtgraph/flowchart/FlowchartCtrlTemplate_generic.py similarity index 98% rename from pyqtgraph/flowchart/FlowchartCtrlTemplate_pyqt6.py rename to pyqtgraph/flowchart/FlowchartCtrlTemplate_generic.py index 86ee801d18..a188e825b9 100644 --- a/pyqtgraph/flowchart/FlowchartCtrlTemplate_pyqt6.py +++ b/pyqtgraph/flowchart/FlowchartCtrlTemplate_generic.py @@ -6,7 +6,7 @@ # run again. Do not edit this file unless you know what you are doing. -from PyQt6 import QtCore, QtGui, QtWidgets +from ..Qt import QtCore, QtGui, QtWidgets class Ui_Form(object): diff --git a/pyqtgraph/flowchart/FlowchartCtrlTemplate_pyqt5.py b/pyqtgraph/flowchart/FlowchartCtrlTemplate_pyqt5.py deleted file mode 100644 index b3499bc095..0000000000 --- a/pyqtgraph/flowchart/FlowchartCtrlTemplate_pyqt5.py +++ /dev/null @@ -1,66 +0,0 @@ - -# Form implementation generated from reading ui file './pyqtgraph/flowchart/FlowchartCtrlTemplate.ui' -# -# Created: Wed Mar 26 15:09:28 2014 -# by: PyQt5 UI code generator 5.0.1 -# -# WARNING! All changes made in this file will be lost! - -from PyQt5 import QtCore, QtGui, QtWidgets - -class Ui_Form(object): - def setupUi(self, Form): - Form.setObjectName("Form") - Form.resize(217, 499) - self.gridLayout = QtWidgets.QGridLayout(Form) - self.gridLayout.setContentsMargins(0, 0, 0, 0) - self.gridLayout.setVerticalSpacing(0) - self.gridLayout.setObjectName("gridLayout") - self.loadBtn = QtWidgets.QPushButton(Form) - self.loadBtn.setObjectName("loadBtn") - self.gridLayout.addWidget(self.loadBtn, 1, 0, 1, 1) - self.saveBtn = FeedbackButton(Form) - self.saveBtn.setObjectName("saveBtn") - self.gridLayout.addWidget(self.saveBtn, 1, 1, 1, 2) - self.saveAsBtn = FeedbackButton(Form) - self.saveAsBtn.setObjectName("saveAsBtn") - self.gridLayout.addWidget(self.saveAsBtn, 1, 3, 1, 1) - self.reloadBtn = FeedbackButton(Form) - self.reloadBtn.setCheckable(False) - self.reloadBtn.setFlat(False) - self.reloadBtn.setObjectName("reloadBtn") - self.gridLayout.addWidget(self.reloadBtn, 4, 0, 1, 2) - self.showChartBtn = QtWidgets.QPushButton(Form) - self.showChartBtn.setCheckable(True) - self.showChartBtn.setObjectName("showChartBtn") - self.gridLayout.addWidget(self.showChartBtn, 4, 2, 1, 2) - self.ctrlList = TreeWidget(Form) - self.ctrlList.setObjectName("ctrlList") - self.ctrlList.headerItem().setText(0, "1") - self.ctrlList.header().setVisible(False) - self.ctrlList.header().setStretchLastSection(False) - self.gridLayout.addWidget(self.ctrlList, 3, 0, 1, 4) - self.fileNameLabel = QtWidgets.QLabel(Form) - font = QtGui.QFont() - font.setBold(True) - font.setWeight(75) - self.fileNameLabel.setFont(font) - self.fileNameLabel.setText("") - self.fileNameLabel.setAlignment(QtCore.Qt.AlignCenter) - self.fileNameLabel.setObjectName("fileNameLabel") - self.gridLayout.addWidget(self.fileNameLabel, 0, 1, 1, 1) - - self.retranslateUi(Form) - QtCore.QMetaObject.connectSlotsByName(Form) - - def retranslateUi(self, Form): - _translate = QtCore.QCoreApplication.translate - Form.setWindowTitle(_translate("Form", "PyQtGraph")) - self.loadBtn.setText(_translate("Form", "Load..")) - self.saveBtn.setText(_translate("Form", "Save")) - self.saveAsBtn.setText(_translate("Form", "As..")) - self.reloadBtn.setText(_translate("Form", "Reload Libs")) - self.showChartBtn.setText(_translate("Form", "Flowchart")) - -from ..widgets.FeedbackButton import FeedbackButton -from ..widgets.TreeWidget import TreeWidget diff --git a/pyqtgraph/flowchart/FlowchartCtrlTemplate_pyside2.py b/pyqtgraph/flowchart/FlowchartCtrlTemplate_pyside2.py deleted file mode 100644 index 201571f2ac..0000000000 --- a/pyqtgraph/flowchart/FlowchartCtrlTemplate_pyside2.py +++ /dev/null @@ -1,65 +0,0 @@ - -# Form implementation generated from reading ui file 'FlowchartCtrlTemplate.ui' -# -# Created: Sun Sep 18 19:16:46 2016 -# by: pyside2-uic running on PySide2 2.0.0~alpha0 -# -# WARNING! All changes made in this file will be lost! - -from PySide2 import QtCore, QtGui, QtWidgets - -class Ui_Form(object): - def setupUi(self, Form): - Form.setObjectName("Form") - Form.resize(217, 499) - self.gridLayout = QtWidgets.QGridLayout(Form) - self.gridLayout.setContentsMargins(0, 0, 0, 0) - self.gridLayout.setVerticalSpacing(0) - self.gridLayout.setObjectName("gridLayout") - self.loadBtn = QtWidgets.QPushButton(Form) - self.loadBtn.setObjectName("loadBtn") - self.gridLayout.addWidget(self.loadBtn, 1, 0, 1, 1) - self.saveBtn = FeedbackButton(Form) - self.saveBtn.setObjectName("saveBtn") - self.gridLayout.addWidget(self.saveBtn, 1, 1, 1, 2) - self.saveAsBtn = FeedbackButton(Form) - self.saveAsBtn.setObjectName("saveAsBtn") - self.gridLayout.addWidget(self.saveAsBtn, 1, 3, 1, 1) - self.reloadBtn = FeedbackButton(Form) - self.reloadBtn.setCheckable(False) - self.reloadBtn.setFlat(False) - self.reloadBtn.setObjectName("reloadBtn") - self.gridLayout.addWidget(self.reloadBtn, 4, 0, 1, 2) - self.showChartBtn = QtWidgets.QPushButton(Form) - self.showChartBtn.setCheckable(True) - self.showChartBtn.setObjectName("showChartBtn") - self.gridLayout.addWidget(self.showChartBtn, 4, 2, 1, 2) - self.ctrlList = TreeWidget(Form) - self.ctrlList.setObjectName("ctrlList") - self.ctrlList.headerItem().setText(0, "1") - self.ctrlList.header().setVisible(False) - self.ctrlList.header().setStretchLastSection(False) - self.gridLayout.addWidget(self.ctrlList, 3, 0, 1, 4) - self.fileNameLabel = QtWidgets.QLabel(Form) - font = QtGui.QFont() - font.setWeight(75) - font.setBold(True) - self.fileNameLabel.setFont(font) - self.fileNameLabel.setText("") - self.fileNameLabel.setAlignment(QtCore.Qt.AlignCenter) - self.fileNameLabel.setObjectName("fileNameLabel") - self.gridLayout.addWidget(self.fileNameLabel, 0, 1, 1, 1) - - self.retranslateUi(Form) - QtCore.QMetaObject.connectSlotsByName(Form) - - def retranslateUi(self, Form): - Form.setWindowTitle(QtWidgets.QApplication.translate("Form", "Form", None, -1)) - self.loadBtn.setText(QtWidgets.QApplication.translate("Form", "Load..", None, -1)) - self.saveBtn.setText(QtWidgets.QApplication.translate("Form", "Save", None, -1)) - self.saveAsBtn.setText(QtWidgets.QApplication.translate("Form", "As..", None, -1)) - self.reloadBtn.setText(QtWidgets.QApplication.translate("Form", "Reload Libs", None, -1)) - self.showChartBtn.setText(QtWidgets.QApplication.translate("Form", "Flowchart", None, -1)) - -from ..widgets.FeedbackButton import FeedbackButton -from ..widgets.TreeWidget import TreeWidget diff --git a/pyqtgraph/flowchart/FlowchartCtrlTemplate_pyside6.py b/pyqtgraph/flowchart/FlowchartCtrlTemplate_pyside6.py deleted file mode 100644 index 030f354f70..0000000000 --- a/pyqtgraph/flowchart/FlowchartCtrlTemplate_pyside6.py +++ /dev/null @@ -1,88 +0,0 @@ - -################################################################################ -## Form generated from reading UI file 'FlowchartCtrlTemplate.ui' -## -## Created by: Qt User Interface Compiler version 6.1.0 -## -## WARNING! All changes made in this file will be lost when recompiling UI file! -################################################################################ - -from PySide6.QtCore import * -from PySide6.QtGui import * -from PySide6.QtWidgets import * - -from ..widgets.TreeWidget import TreeWidget -from ..widgets.FeedbackButton import FeedbackButton - - -class Ui_Form(object): - def setupUi(self, Form): - if not Form.objectName(): - Form.setObjectName(u"Form") - Form.resize(217, 499) - self.gridLayout = QGridLayout(Form) - self.gridLayout.setContentsMargins(0, 0, 0, 0) - self.gridLayout.setObjectName(u"gridLayout") - self.gridLayout.setVerticalSpacing(0) - self.loadBtn = QPushButton(Form) - self.loadBtn.setObjectName(u"loadBtn") - - self.gridLayout.addWidget(self.loadBtn, 1, 0, 1, 1) - - self.saveBtn = FeedbackButton(Form) - self.saveBtn.setObjectName(u"saveBtn") - - self.gridLayout.addWidget(self.saveBtn, 1, 1, 1, 2) - - self.saveAsBtn = FeedbackButton(Form) - self.saveAsBtn.setObjectName(u"saveAsBtn") - - self.gridLayout.addWidget(self.saveAsBtn, 1, 3, 1, 1) - - self.reloadBtn = FeedbackButton(Form) - self.reloadBtn.setObjectName(u"reloadBtn") - self.reloadBtn.setCheckable(False) - self.reloadBtn.setFlat(False) - - self.gridLayout.addWidget(self.reloadBtn, 4, 0, 1, 2) - - self.showChartBtn = QPushButton(Form) - self.showChartBtn.setObjectName(u"showChartBtn") - self.showChartBtn.setCheckable(True) - - self.gridLayout.addWidget(self.showChartBtn, 4, 2, 1, 2) - - self.ctrlList = TreeWidget(Form) - __qtreewidgetitem = QTreeWidgetItem() - __qtreewidgetitem.setText(0, u"1"); - self.ctrlList.setHeaderItem(__qtreewidgetitem) - self.ctrlList.setObjectName(u"ctrlList") - self.ctrlList.header().setVisible(False) - self.ctrlList.header().setStretchLastSection(False) - - self.gridLayout.addWidget(self.ctrlList, 3, 0, 1, 4) - - self.fileNameLabel = QLabel(Form) - self.fileNameLabel.setObjectName(u"fileNameLabel") - font = QFont() - font.setBold(True) - self.fileNameLabel.setFont(font) - self.fileNameLabel.setAlignment(Qt.AlignCenter) - - self.gridLayout.addWidget(self.fileNameLabel, 0, 1, 1, 1) - - - self.retranslateUi(Form) - - QMetaObject.connectSlotsByName(Form) - # setupUi - - def retranslateUi(self, Form): - Form.setWindowTitle(QCoreApplication.translate("Form", u"PyQtGraph", None)) - self.loadBtn.setText(QCoreApplication.translate("Form", u"Load..", None)) - self.saveBtn.setText(QCoreApplication.translate("Form", u"Save", None)) - self.saveAsBtn.setText(QCoreApplication.translate("Form", u"As..", None)) - self.reloadBtn.setText(QCoreApplication.translate("Form", u"Reload Libs", None)) - self.showChartBtn.setText(QCoreApplication.translate("Form", u"Flowchart", None)) - self.fileNameLabel.setText("") - # retranslateUi diff --git a/pyqtgraph/flowchart/FlowchartTemplate.ui b/pyqtgraph/flowchart/FlowchartTemplate.ui deleted file mode 100644 index 8b4ef814d5..0000000000 --- a/pyqtgraph/flowchart/FlowchartTemplate.ui +++ /dev/null @@ -1,97 +0,0 @@ - - - Form - - - - 0 - 0 - 529 - 329 - - - - PyQtGraph - - - - - 260 - 10 - 264 - 222 - - - - - - - - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop - - - true - - - - - - - - true - - - - - - - - - - - - 1 - - - - - - - - - - 0 - 240 - 521 - 81 - - - - - - - 0 - 0 - 256 - 192 - - - - - - - DataTreeWidget - QTreeWidget -
..widgets.DataTreeWidget
-
- - FlowchartGraphicsView - QGraphicsView -
..flowchart.FlowchartGraphicsView
-
-
- - -
diff --git a/pyqtgraph/flowchart/FlowchartTemplate_pyqt5.py b/pyqtgraph/flowchart/FlowchartTemplate_pyqt5.py deleted file mode 100644 index f3c3ccf03e..0000000000 --- a/pyqtgraph/flowchart/FlowchartTemplate_pyqt5.py +++ /dev/null @@ -1,54 +0,0 @@ - -# Form implementation generated from reading ui file './pyqtgraph/flowchart/FlowchartTemplate.ui' -# -# Created: Wed Mar 26 15:09:28 2014 -# by: PyQt5 UI code generator 5.0.1 -# -# WARNING! All changes made in this file will be lost! - -from PyQt5 import QtCore, QtGui, QtWidgets - -class Ui_Form(object): - def setupUi(self, Form): - Form.setObjectName("Form") - Form.resize(529, 329) - self.selInfoWidget = QtWidgets.QWidget(Form) - self.selInfoWidget.setGeometry(QtCore.QRect(260, 10, 264, 222)) - self.selInfoWidget.setObjectName("selInfoWidget") - self.gridLayout = QtWidgets.QGridLayout(self.selInfoWidget) - self.gridLayout.setContentsMargins(0, 0, 0, 0) - self.gridLayout.setObjectName("gridLayout") - self.selDescLabel = QtWidgets.QLabel(self.selInfoWidget) - self.selDescLabel.setText("") - self.selDescLabel.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop) - self.selDescLabel.setWordWrap(True) - self.selDescLabel.setObjectName("selDescLabel") - self.gridLayout.addWidget(self.selDescLabel, 0, 0, 1, 1) - self.selNameLabel = QtWidgets.QLabel(self.selInfoWidget) - font = QtGui.QFont() - font.setBold(True) - font.setWeight(75) - self.selNameLabel.setFont(font) - self.selNameLabel.setText("") - self.selNameLabel.setObjectName("selNameLabel") - self.gridLayout.addWidget(self.selNameLabel, 0, 1, 1, 1) - self.selectedTree = DataTreeWidget(self.selInfoWidget) - self.selectedTree.setObjectName("selectedTree") - self.selectedTree.headerItem().setText(0, "1") - self.gridLayout.addWidget(self.selectedTree, 1, 0, 1, 2) - self.hoverText = QtWidgets.QTextEdit(Form) - self.hoverText.setGeometry(QtCore.QRect(0, 240, 521, 81)) - self.hoverText.setObjectName("hoverText") - self.view = FlowchartGraphicsView(Form) - self.view.setGeometry(QtCore.QRect(0, 0, 256, 192)) - self.view.setObjectName("view") - - self.retranslateUi(Form) - QtCore.QMetaObject.connectSlotsByName(Form) - - def retranslateUi(self, Form): - _translate = QtCore.QCoreApplication.translate - Form.setWindowTitle(_translate("Form", "PyQtGraph")) - -from ..widgets.DataTreeWidget import DataTreeWidget -from ..flowchart.FlowchartGraphicsView import FlowchartGraphicsView diff --git a/pyqtgraph/flowchart/FlowchartTemplate_pyqt6.py b/pyqtgraph/flowchart/FlowchartTemplate_pyqt6.py deleted file mode 100644 index d7f077ae25..0000000000 --- a/pyqtgraph/flowchart/FlowchartTemplate_pyqt6.py +++ /dev/null @@ -1,53 +0,0 @@ -# Form implementation generated from reading ui file '../pyqtgraph/flowchart/FlowchartTemplate.ui' -# -# Created by: PyQt6 UI code generator 6.1.0 -# -# WARNING: Any manual changes made to this file will be lost when pyuic6 is -# run again. Do not edit this file unless you know what you are doing. - - -from PyQt6 import QtCore, QtGui, QtWidgets - - -class Ui_Form(object): - def setupUi(self, Form): - Form.setObjectName("Form") - Form.resize(529, 329) - self.selInfoWidget = QtWidgets.QWidget(Form) - self.selInfoWidget.setGeometry(QtCore.QRect(260, 10, 264, 222)) - self.selInfoWidget.setObjectName("selInfoWidget") - self.gridLayout = QtWidgets.QGridLayout(self.selInfoWidget) - self.gridLayout.setContentsMargins(0, 0, 0, 0) - self.gridLayout.setObjectName("gridLayout") - self.selDescLabel = QtWidgets.QLabel(self.selInfoWidget) - self.selDescLabel.setText("") - self.selDescLabel.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeading|QtCore.Qt.AlignmentFlag.AlignLeft|QtCore.Qt.AlignmentFlag.AlignTop) - self.selDescLabel.setWordWrap(True) - self.selDescLabel.setObjectName("selDescLabel") - self.gridLayout.addWidget(self.selDescLabel, 0, 0, 1, 1) - self.selNameLabel = QtWidgets.QLabel(self.selInfoWidget) - font = QtGui.QFont() - font.setBold(True) - self.selNameLabel.setFont(font) - self.selNameLabel.setText("") - self.selNameLabel.setObjectName("selNameLabel") - self.gridLayout.addWidget(self.selNameLabel, 0, 1, 1, 1) - self.selectedTree = DataTreeWidget(self.selInfoWidget) - self.selectedTree.setObjectName("selectedTree") - self.selectedTree.headerItem().setText(0, "1") - self.gridLayout.addWidget(self.selectedTree, 1, 0, 1, 2) - self.hoverText = QtWidgets.QTextEdit(Form) - self.hoverText.setGeometry(QtCore.QRect(0, 240, 521, 81)) - self.hoverText.setObjectName("hoverText") - self.view = FlowchartGraphicsView(Form) - self.view.setGeometry(QtCore.QRect(0, 0, 256, 192)) - self.view.setObjectName("view") - - self.retranslateUi(Form) - QtCore.QMetaObject.connectSlotsByName(Form) - - def retranslateUi(self, Form): - _translate = QtCore.QCoreApplication.translate - Form.setWindowTitle(_translate("Form", "PyQtGraph")) -from ..flowchart.FlowchartGraphicsView import FlowchartGraphicsView -from ..widgets.DataTreeWidget import DataTreeWidget diff --git a/pyqtgraph/flowchart/FlowchartTemplate_pyside2.py b/pyqtgraph/flowchart/FlowchartTemplate_pyside2.py deleted file mode 100644 index ce2d23fe65..0000000000 --- a/pyqtgraph/flowchart/FlowchartTemplate_pyside2.py +++ /dev/null @@ -1,53 +0,0 @@ - -# Form implementation generated from reading ui file 'FlowchartTemplate.ui' -# -# Created: Sun Sep 18 19:16:03 2016 -# by: pyside2-uic running on PySide2 2.0.0~alpha0 -# -# WARNING! All changes made in this file will be lost! - -from PySide2 import QtCore, QtGui, QtWidgets - -class Ui_Form(object): - def setupUi(self, Form): - Form.setObjectName("Form") - Form.resize(529, 329) - self.selInfoWidget = QtWidgets.QWidget(Form) - self.selInfoWidget.setGeometry(QtCore.QRect(260, 10, 264, 222)) - self.selInfoWidget.setObjectName("selInfoWidget") - self.gridLayout = QtWidgets.QGridLayout(self.selInfoWidget) - self.gridLayout.setContentsMargins(0, 0, 0, 0) - self.gridLayout.setObjectName("gridLayout") - self.selDescLabel = QtWidgets.QLabel(self.selInfoWidget) - self.selDescLabel.setText("") - self.selDescLabel.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop) - self.selDescLabel.setWordWrap(True) - self.selDescLabel.setObjectName("selDescLabel") - self.gridLayout.addWidget(self.selDescLabel, 0, 0, 1, 1) - self.selNameLabel = QtWidgets.QLabel(self.selInfoWidget) - font = QtGui.QFont() - font.setWeight(75) - font.setBold(True) - self.selNameLabel.setFont(font) - self.selNameLabel.setText("") - self.selNameLabel.setObjectName("selNameLabel") - self.gridLayout.addWidget(self.selNameLabel, 0, 1, 1, 1) - self.selectedTree = DataTreeWidget(self.selInfoWidget) - self.selectedTree.setObjectName("selectedTree") - self.selectedTree.headerItem().setText(0, "1") - self.gridLayout.addWidget(self.selectedTree, 1, 0, 1, 2) - self.hoverText = QtWidgets.QTextEdit(Form) - self.hoverText.setGeometry(QtCore.QRect(0, 240, 521, 81)) - self.hoverText.setObjectName("hoverText") - self.view = FlowchartGraphicsView(Form) - self.view.setGeometry(QtCore.QRect(0, 0, 256, 192)) - self.view.setObjectName("view") - - self.retranslateUi(Form) - QtCore.QMetaObject.connectSlotsByName(Form) - - def retranslateUi(self, Form): - Form.setWindowTitle(QtWidgets.QApplication.translate("Form", "Form", None, -1)) - -from ..flowchart.FlowchartGraphicsView import FlowchartGraphicsView -from ..widgets.DataTreeWidget import DataTreeWidget diff --git a/pyqtgraph/flowchart/FlowchartTemplate_pyside6.py b/pyqtgraph/flowchart/FlowchartTemplate_pyside6.py deleted file mode 100644 index c117c500c8..0000000000 --- a/pyqtgraph/flowchart/FlowchartTemplate_pyside6.py +++ /dev/null @@ -1,67 +0,0 @@ - -################################################################################ -## Form generated from reading UI file 'FlowchartTemplate.ui' -## -## Created by: Qt User Interface Compiler version 6.1.0 -## -## WARNING! All changes made in this file will be lost when recompiling UI file! -################################################################################ - -from PySide6.QtCore import * -from PySide6.QtGui import * -from PySide6.QtWidgets import * - -from ..widgets.DataTreeWidget import DataTreeWidget -from ..flowchart.FlowchartGraphicsView import FlowchartGraphicsView - - -class Ui_Form(object): - def setupUi(self, Form): - if not Form.objectName(): - Form.setObjectName(u"Form") - Form.resize(529, 329) - self.selInfoWidget = QWidget(Form) - self.selInfoWidget.setObjectName(u"selInfoWidget") - self.selInfoWidget.setGeometry(QRect(260, 10, 264, 222)) - self.gridLayout = QGridLayout(self.selInfoWidget) - self.gridLayout.setObjectName(u"gridLayout") - self.selDescLabel = QLabel(self.selInfoWidget) - self.selDescLabel.setObjectName(u"selDescLabel") - self.selDescLabel.setAlignment(Qt.AlignLeading|Qt.AlignLeft|Qt.AlignTop) - self.selDescLabel.setWordWrap(True) - - self.gridLayout.addWidget(self.selDescLabel, 0, 0, 1, 1) - - self.selNameLabel = QLabel(self.selInfoWidget) - self.selNameLabel.setObjectName(u"selNameLabel") - font = QFont() - font.setBold(True) - self.selNameLabel.setFont(font) - - self.gridLayout.addWidget(self.selNameLabel, 0, 1, 1, 1) - - self.selectedTree = DataTreeWidget(self.selInfoWidget) - __qtreewidgetitem = QTreeWidgetItem() - __qtreewidgetitem.setText(0, u"1"); - self.selectedTree.setHeaderItem(__qtreewidgetitem) - self.selectedTree.setObjectName(u"selectedTree") - - self.gridLayout.addWidget(self.selectedTree, 1, 0, 1, 2) - - self.hoverText = QTextEdit(Form) - self.hoverText.setObjectName(u"hoverText") - self.hoverText.setGeometry(QRect(0, 240, 521, 81)) - self.view = FlowchartGraphicsView(Form) - self.view.setObjectName(u"view") - self.view.setGeometry(QRect(0, 0, 256, 192)) - - self.retranslateUi(Form) - - QMetaObject.connectSlotsByName(Form) - # setupUi - - def retranslateUi(self, Form): - Form.setWindowTitle(QCoreApplication.translate("Form", u"PyQtGraph", None)) - self.selDescLabel.setText("") - self.selNameLabel.setText("") - # retranslateUi diff --git a/pyqtgraph/flowchart/Node.py b/pyqtgraph/flowchart/Node.py index dc6c582a73..7fdfbd1626 100644 --- a/pyqtgraph/flowchart/Node.py +++ b/pyqtgraph/flowchart/Node.py @@ -1,7 +1,6 @@ __all__ = ["Node", "NodeGraphicsItem"] import sys -import warnings from collections import OrderedDict from .. import functions as fn @@ -190,22 +189,6 @@ def graphicsItem(self): self._graphicsItem = NodeGraphicsItem(self) return self._graphicsItem - ## this is just bad planning. Causes too many bugs. - def __getattr__(self, attr): - """Return the terminal with the given name""" - warnings.warn( - "Use of note.terminalName is deprecated, use node['terminalName'] instead" - "Will be removed from 0.13.0", - DeprecationWarning, stacklevel=2 - ) - - if attr not in self.terminals: - raise AttributeError(attr) - else: - import traceback - traceback.print_stack() - print("Warning: use of node.terminalName is deprecated; use node['terminalName'] instead.") - return self.terminals[attr] def __getitem__(self, item): #return getattr(self, item) diff --git a/pyqtgraph/flowchart/library/Display.py b/pyqtgraph/flowchart/library/Display.py index 779bfed2c1..d54d2f589f 100644 --- a/pyqtgraph/flowchart/library/Display.py +++ b/pyqtgraph/flowchart/library/Display.py @@ -4,7 +4,7 @@ from ...graphicsItems.ScatterPlotItem import ScatterPlotItem from ...Qt import QtCore, QtGui, QtWidgets from ..Node import Node -from .common import * +from .common import CtrlNode class PlotWidgetNode(Node): diff --git a/pyqtgraph/functions.py b/pyqtgraph/functions.py index 31880c9408..2f3476e9be 100644 --- a/pyqtgraph/functions.py +++ b/pyqtgraph/functions.py @@ -16,8 +16,7 @@ import numpy as np -from . import Qt, debug, reload -from . import getConfigOption +from . import Qt, debug, getConfigOption, reload from .metaarray import MetaArray from .Qt import QT_LIB, QtCore, QtGui from .util.cupy_helper import getCupy @@ -241,9 +240,9 @@ def mkColor(*args): float greyscale, 0.0-1.0 int see :func:`intColor() ` (int, hues) see :func:`intColor() ` - "#RGB" hexadecimal strings prefixed with '#' - "#RGBA" previously allowed use without prefix is deprecated and - "#RRGGBB" will be removed in 0.13 + "#RGB" + "#RGBA" + "#RRGGBB" "#RRGGBBAA" QColor QColor instance; makes a copy. ================ ================================================ @@ -270,11 +269,7 @@ def mkColor(*args): if c[0] == '#': c = c[1:] else: - warnings.warn( - "Parsing of hex strings that do not start with '#' is" - "deprecated and support will be removed in 0.13", - DeprecationWarning, stacklevel=2 - ) + raise ValueError(f"Unable to convert {c} to QColor") if len(c) == 3: r = int(c[0]*2, 16) g = int(c[1]*2, 16) @@ -505,7 +500,7 @@ def colorCIELab(qcol): Returns ------- - NumPy array + np.ndarray Color coordinates `[L, a, b]`. """ srgb = qcol.getRgbF()[:3] # get sRGB values from QColor @@ -540,7 +535,7 @@ def colorDistance(colors, metric='CIE76'): ---------- colors: list of QColor Two or more colors to calculate the distances between. - metric: string, optional + metric: str, optional Metric used to determined the difference. Only 'CIE76' is supported at this time, where a distance of 2.3 is considered a "just noticeable difference". The default may change as more metrics become available. @@ -551,7 +546,7 @@ def colorDistance(colors, metric='CIE76'): The `N-1` sequential distances between `N` colors. """ metric = metric.upper() - if len(colors) < 1: return np.array([], dtype=np.float) + if len(colors) < 1: return np.array([], dtype=float) if metric == 'CIE76': dist = [] lab1 = None @@ -999,7 +994,7 @@ def interpolateArray(data, x, default=0.0, order=1): sax = f1 * dx[...,ax] + (1-f1) * (1-dx[...,ax]) sax = sax.reshape(sax.shape + (1,) * (s.ndim-1-sax.ndim)) s[ax] = sax - s = np.product(s, axis=0) + s = np.prod(s, axis=0) result = fieldData * s for i in range(md): result = result.sum(axis=0) @@ -1197,12 +1192,6 @@ def clip_scalar(val, vmin, vmax): """ convenience function to avoid using np.clip for scalar values """ return vmin if val < vmin else vmax if val > vmax else val -# umath.clip was slower than umath.maximum(umath.minimum). -# See https://github.com/numpy/numpy/pull/20134 for details. -_win32_clip_workaround_needed = ( - sys.platform == 'win32' and - tuple(map(int, np.__version__.split(".")[:2])) < (1, 22) -) def clip_array(arr, vmin, vmax, out=None): # replacement for np.clip due to regression in @@ -1217,12 +1206,6 @@ def clip_array(arr, vmin, vmax, out=None): return np.core.umath.minimum(arr, vmax, out=out) elif vmax is None: return np.core.umath.maximum(arr, vmin, out=out) - elif _win32_clip_workaround_needed: - if out is None: - out = np.empty(arr.shape, dtype=np.find_common_type([arr.dtype], [type(vmax)])) - out = np.core.umath.minimum(arr, vmax, out=out) - return np.core.umath.maximum(out, vmin, out=out) - else: return np.core.umath.clip(arr, vmin, vmax, out=out) @@ -1333,8 +1316,8 @@ def applyLookupTable(data, lut): Parameters ---------- - data : ndarray - lut : ndarray + data : np.ndarray + lut : np.ndarray Either cupy or numpy arrays are accepted, though this function has only consistently behaved correctly on windows with cuda toolkit version >= 11.1. """ @@ -1536,8 +1519,8 @@ def makeARGB(data, lut=None, levels=None, scale=None, useRGBA=False, maskNans=Tr # apply nan mask through alpha channel if nanMask is not None: alpha = True - # Workaround for https://github.com/cupy/cupy/issues/4693 - if xp == cp: + # Workaround for https://github.com/cupy/cupy/issues/4693, fixed in cupy 10.0.0 + if xp == cp and tuple(map(int, cp.__version__.split("."))) < (10, 0): imgData[nanMask, :, dst_order[3]] = 0 else: imgData[nanMask, dst_order[3]] = 0 @@ -2054,17 +2037,17 @@ def arrayToQPath(x, y, connect='all', finiteCheck=True): Parameters ---------- - x : (N,) ndarray - x-values to be plotted - y : (N,) ndarray - y-values to be plotted, must be same length as `x` + x : np.ndarray + x-values to be plotted of shape (N,) + y : np.ndarray + y-values to be plotted, must be same length as `x` of shape (N,) connect : {'all', 'pairs', 'finite', (N,) ndarray}, optional Argument detailing how to connect the points in the path. `all` will have sequential points being connected. `pairs` generates lines between every other point. `finite` only connects points that are finite. If an ndarray is passed, containing int32 values of 0 or 1, only values with 1 will connect to the previous point. Def - finiteCheck : bool, default Ture + finiteCheck : bool, default True When false, the check for finite values will be skipped, which can improve performance. If nonfinite values are present in `x` or `y`, an empty QPainterPath will be generated. @@ -2186,26 +2169,18 @@ def arrayToQPath(x, y, connect='all', finiteCheck=True): return path def ndarray_from_qpolygonf(polyline): - nbytes = 2 * len(polyline) * 8 - if QT_LIB.startswith('PyQt'): - buffer = polyline.data() - if buffer is None: - buffer = Qt.sip.voidptr(0) - buffer.setsize(nbytes) - else: - ptr = polyline.data() - if ptr is None: - ptr = 0 - buffer = Qt.shiboken.VoidPtr(ptr, nbytes, True) - memory = np.frombuffer(buffer, np.double).reshape((-1, 2)) - return memory + # polyline.data() will be None if the pointer was null. + # voidptr(None) is the same as voidptr(0). + vp = Qt.compat.voidptr(polyline.data(), len(polyline)*2*8, True) + return np.frombuffer(vp, dtype=np.float64).reshape((-1, 2)) def create_qpolygonf(size): polyline = QtGui.QPolygonF() - if QT_LIB.startswith('PyQt'): - polyline.fill(QtCore.QPointF(), size) - else: + if hasattr(polyline, 'resize'): + # (PySide) and (PyQt6 >= 6.3.1) polyline.resize(size) + else: + polyline.fill(QtCore.QPointF(), size) return polyline def arrayToQPolygonF(x, y): diff --git a/pyqtgraph/graphicsItems/AxisItem.py b/pyqtgraph/graphicsItems/AxisItem.py index 278572c509..7fafc69151 100644 --- a/pyqtgraph/graphicsItems/AxisItem.py +++ b/pyqtgraph/graphicsItems/AxisItem.py @@ -1,4 +1,3 @@ -import warnings import weakref from math import ceil, floor, isfinite, log, log10 @@ -60,6 +59,7 @@ def __init__(self, orientation, pen=None, textPen=None, tickPen = None, linkView 'tickTextHeight': 18, 'autoExpandTextSpace': True, ## automatically expand text space if needed 'autoReduceTextSpace': True, + 'hideOverlappingLabels': True, 'tickFont': None, 'stopAxisAtTick': (False, False), ## whether axis is drawn to edge of box or to last tick 'textFillLimits': [ ## how much of the axis to fill up with tick text, maximally. @@ -126,46 +126,49 @@ def setStyle(self, **kwds): """ Set various style options. - =================== ======================================================= + ===================== ======================================================= Keyword Arguments: - tickLength (int) The maximum length of ticks in pixels. - Positive values point toward the text; negative - values point away. - tickTextOffset (int) reserved spacing between text and axis in px - tickTextWidth (int) Horizontal space reserved for tick text in px - tickTextHeight (int) Vertical space reserved for tick text in px - autoExpandTextSpace (bool) Automatically expand text space if the tick - strings become too long. - autoReduceTextSpace (bool) Automatically shrink the axis if necessary - tickFont (QFont or None) Determines the font used for tick - values. Use None for the default font. - stopAxisAtTick (tuple: (bool min, bool max)) If True, the axis - line is drawn only as far as the last tick. - Otherwise, the line is drawn to the edge of the - AxisItem boundary. - textFillLimits (list of (tick #, % fill) tuples). This structure - determines how the AxisItem decides how many ticks - should have text appear next to them. Each tuple in - the list specifies what fraction of the axis length - may be occupied by text, given the number of ticks - that already have text displayed. For example:: - - [(0, 0.8), # Never fill more than 80% of the axis - (2, 0.6), # If we already have 2 ticks with text, - # fill no more than 60% of the axis - (4, 0.4), # If we already have 4 ticks with text, - # fill no more than 40% of the axis - (6, 0.2)] # If we already have 6 ticks with text, - # fill no more than 20% of the axis - - showValues (bool) indicates whether text is displayed adjacent - to ticks. - tickAlpha (float or int or None) If None, pyqtgraph will draw the - ticks with the alpha it deems appropriate. Otherwise, - the alpha will be fixed at the value passed. With int, - accepted values are [0..255]. With value of type - float, accepted values are from [0..1]. - =================== ======================================================= + tickLength (int) The maximum length of ticks in pixels. + Positive values point toward the text; negative + values point away. + tickTextOffset (int) reserved spacing between text and axis in px + tickTextWidth (int) Horizontal space reserved for tick text in px + tickTextHeight (int) Vertical space reserved for tick text in px + autoExpandTextSpace (bool) Automatically expand text space if the tick + strings become too long. + autoReduceTextSpace (bool) Automatically shrink the axis if necessary + hideOverlappingLabels (bool) Hide tick labels which overlap the AxisItems' + geometry rectangle. If False, labels might be drawn + overlapping with tick labels from neighboring plots. + tickFont (QFont or None) Determines the font used for tick + values. Use None for the default font. + stopAxisAtTick (tuple: (bool min, bool max)) If True, the axis + line is drawn only as far as the last tick. + Otherwise, the line is drawn to the edge of the + AxisItem boundary. + textFillLimits (list of (tick #, % fill) tuples). This structure + determines how the AxisItem decides how many ticks + should have text appear next to them. Each tuple in + the list specifies what fraction of the axis length + may be occupied by text, given the number of ticks + that already have text displayed. For example:: + + [(0, 0.8), # Never fill more than 80% of the axis + (2, 0.6), # If we already have 2 ticks with text, + # fill no more than 60% of the axis + (4, 0.4), # If we already have 4 ticks with text, + # fill no more than 40% of the axis + (6, 0.2)] # If we already have 6 ticks with text, + # fill no more than 20% of the axis + + showValues (bool) indicates whether text is displayed adjacent + to ticks. + tickAlpha (float or int or None) If None, pyqtgraph will draw the + ticks with the alpha it deems appropriate. Otherwise, + the alpha will be fixed at the value passed. With int, + accepted values are [0..255]. With value of type + float, accepted values are from [0..1]. + ===================== ======================================================= Added in version 0.9.9 """ @@ -515,17 +518,6 @@ def setScale(self, scale=None): the view coordinate system were scaled. By default, the axis scaling is 1.0. """ - # Deprecated usage, kept for backward compatibility - if scale is None: - warnings.warn( - 'AxisItem.setScale(None) is deprecated, will be removed in 0.13.0' - 'instead use AxisItem.enableAutoSIPrefix(bool) to enable/disable' - 'SI prefix scaling', - DeprecationWarning, stacklevel=2 - ) - scale = 1.0 - self.enableAutoSIPrefix(True) - if scale != self.scale: self.scale = scale self._updateLabel() @@ -632,6 +624,7 @@ def linkedViewChanged(self, view, newRange=None): self.setRange(*newRange) def boundingRect(self): + m = 0 if self.style['hideOverlappingLabels'] else 15 linkedView = self.linkedView() if linkedView is None or self.grid is False: rect = self.mapRectFromParent(self.geometry()) @@ -639,13 +632,13 @@ def boundingRect(self): ## also extend to account for text that flows past the edges tl = self.style['tickLength'] if self.orientation == 'left': - rect = rect.adjusted(0, -15, -min(0,tl), 15) + rect = rect.adjusted(0, -m, -min(0,tl), m) elif self.orientation == 'right': - rect = rect.adjusted(min(0,tl), -15, 0, 15) + rect = rect.adjusted(min(0,tl), -m, 0, m) elif self.orientation == 'top': rect = rect.adjusted(-15, 0, 15, -min(0,tl)) elif self.orientation == 'bottom': - rect = rect.adjusted(-15, min(0,tl), 15, 0) + rect = rect.adjusted(-m, min(0,tl), m, 0) return rect else: return self.mapRectFromParent(self.geometry()) | linkedView.mapRectToItem(self, linkedView.boundingRect()) @@ -827,9 +820,13 @@ def tickValues(self, minVal, maxVal, size): ## remove any ticks that were present in higher levels ## we assume here that if the difference between a tick value and a previously seen tick value ## is less than spacing/100, then they are 'equal' and we can ignore the new tick. - values = list(filter(lambda x: np.all(np.abs(allValues-x) > spacing/self.scale*0.01), values)) + close = np.any( + np.isclose(allValues, values[:, np.newaxis], rtol=0, atol=spacing/self.scale*0.01) + , axis=-1 + ) + values = values[~close] allValues = np.concatenate([allValues, values]) - ticks.append((spacing/self.scale, values)) + ticks.append((spacing/self.scale, values.tolist())) if self.logMode: return self.logTickValues(minVal, maxVal, size, ticks) @@ -939,26 +936,34 @@ def generateDrawSpecs(self, p): else: tickBounds = linkedView.mapRectToItem(self, linkedView.boundingRect()) + left_offset = -1.0 + right_offset = 1.0 + top_offset = -1.0 + bottom_offset = 1.0 if self.orientation == 'left': - span = (bounds.topRight(), bounds.bottomRight()) + span = (bounds.topRight() + Point(left_offset, top_offset), + bounds.bottomRight() + Point(left_offset, bottom_offset)) tickStart = tickBounds.right() tickStop = bounds.right() tickDir = -1 axis = 0 elif self.orientation == 'right': - span = (bounds.topLeft(), bounds.bottomLeft()) + span = (bounds.topLeft() + Point(right_offset, top_offset), + bounds.bottomLeft() + Point(right_offset, bottom_offset)) tickStart = tickBounds.left() tickStop = bounds.left() tickDir = 1 axis = 0 elif self.orientation == 'top': - span = (bounds.bottomLeft(), bounds.bottomRight()) + span = (bounds.bottomLeft() + Point(left_offset, top_offset), + bounds.bottomRight() + Point(right_offset, top_offset)) tickStart = tickBounds.bottom() tickStop = bounds.bottom() tickDir = -1 axis = 1 elif self.orientation == 'bottom': - span = (bounds.topLeft(), bounds.topRight()) + span = (bounds.topLeft() + Point(left_offset, bottom_offset), + bounds.topRight() + Point(right_offset, bottom_offset)) tickStart = tickBounds.top() tickStop = bounds.top() tickDir = 1 @@ -1168,6 +1173,7 @@ def generateDrawSpecs(self, p): #self.textHeight = height offset = max(0,self.style['tickLength']) + textOffset + rect = QtCore.QRectF() if self.orientation == 'left': alignFlags = QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignVCenter rect = QtCore.QRectF(tickStop-offset-width, x-(height/2), width, height) @@ -1184,6 +1190,11 @@ def generateDrawSpecs(self, p): textFlags = alignFlags | QtCore.Qt.TextFlag.TextDontClip #p.setPen(self.pen()) #p.drawText(rect, textFlags, vstr) + + br = self.boundingRect() + if not br.contains(rect): + continue + textSpecs.append((rect, textFlags, vstr)) profiler('compute text') diff --git a/pyqtgraph/graphicsItems/BarGraphItem.py b/pyqtgraph/graphicsItems/BarGraphItem.py index 40bf5c2823..459751a7c6 100644 --- a/pyqtgraph/graphicsItems/BarGraphItem.py +++ b/pyqtgraph/graphicsItems/BarGraphItem.py @@ -2,6 +2,7 @@ from .. import functions as fn from .. import getConfigOption +from .. import Qt from ..Qt import QtCore, QtGui from .GraphicsObject import GraphicsObject @@ -48,6 +49,14 @@ def __init__(self, **opts): pens=None, brushes=None, ) + + if 'pen' not in opts: + opts['pen'] = getConfigOption('foreground') + if 'brush' not in opts: + opts['brush'] = (128, 128, 128) + # the first call to _updateColors() will thus always be an update + + self._rectarray = Qt.internals.PrimitiveArray(QtCore.QRectF, 4) self._shape = None self.picture = None self.setOpts(**opts) @@ -56,31 +65,74 @@ def setOpts(self, **opts): self.opts.update(opts) self.picture = None self._shape = None + self._prepareData() + self._updateColors(opts) + self.prepareGeometryChange() self.update() self.informViewBoundsChanged() - - def drawPicture(self): - self.picture = QtGui.QPicture() - self._shape = QtGui.QPainterPath() - p = QtGui.QPainter(self.picture) - - pen = self.opts['pen'] - pens = self.opts['pens'] - - if pen is None and pens is None: - pen = getConfigOption('foreground') - - brush = self.opts['brush'] - brushes = self.opts['brushes'] - if brush is None and brushes is None: - brush = (128, 128, 128) - + + def _updatePenWidth(self, pen): + no_pen = pen is None or pen.style() == QtCore.Qt.PenStyle.NoPen + if no_pen: + return + + idx = pen.isCosmetic() + self._penWidth[idx] = max(self._penWidth[idx], pen.widthF()) + + def _updateColors(self, opts): + # the logic here is to permit the user to update only data + # without updating pens/brushes + + # update only if fresh pen/pens supplied + if 'pen' in opts or 'pens' in opts: + self._penWidth = [0, 0] + + if self.opts['pens'] is None: + # pens not configured, use single pen + pen = self.opts['pen'] + pen = fn.mkPen(pen) + self._updatePenWidth(pen) + self._sharedPen = pen + self._pens = None + else: + # pens configured, ignore single pen (if any) + pens = [] + for pen in self.opts['pens']: + if not isinstance(pen, QtGui.QPen): + pen = fn.mkPen(pen) + pens.append(pen) + self._updatePenWidth(pen) + self._sharedPen = None + self._pens = pens + + # update only if fresh brush/brushes supplied + if 'brush' in opts or 'brushes' in opts: + if self.opts['brushes'] is None: + # brushes not configured, use single brush + brush = self.opts['brush'] + self._sharedBrush = fn.mkBrush(brush) + self._brushes = None + else: + # brushes configured, ignore single brush (if any) + brushes = [] + for brush in self.opts['brushes']: + if not isinstance(brush, QtGui.QBrush): + brush = fn.mkBrush(brush) + brushes.append(brush) + self._sharedBrush = None + self._brushes = brushes + + self._singleColor = ( + self._sharedPen is not None and + self._sharedBrush is not None + ) + + def _getNormalizedCoords(self): def asarray(x): if x is None or np.isscalar(x) or isinstance(x, np.ndarray): return x return np.array(x) - x = asarray(self.opts.get('x')) x0 = asarray(self.opts.get('x0')) x1 = asarray(self.opts.get('x1')) @@ -118,54 +170,88 @@ def asarray(x): if y1 is None: raise Exception('must specify either y1 or height') height = y1 - y0 - - p.setPen(fn.mkPen(pen)) - p.setBrush(fn.mkBrush(brush)) - for i in range(len(x0 if not np.isscalar(x0) else y0)): - if pens is not None: - p.setPen(fn.mkPen(pens[i])) - if brushes is not None: - p.setBrush(fn.mkBrush(brushes[i])) - - if np.isscalar(x0): - x = x0 - else: - x = x0[i] - if np.isscalar(y0): - y = y0 - else: - y = y0[i] - if np.isscalar(width): - w = width - else: - w = width[i] - if np.isscalar(height): - h = height - else: - h = height[i] - - - rect = QtCore.QRectF(x, y, w, h) - p.drawRect(rect) - self._shape.addRect(rect) - - p.end() - self.prepareGeometryChange() - - + + # ensure x0 < x1 and y0 < y1 + t0, t1 = x0, x0 + width + x0 = np.minimum(t0, t1, dtype=np.float64) + x1 = np.maximum(t0, t1, dtype=np.float64) + t0, t1 = y0, y0 + height + y0 = np.minimum(t0, t1, dtype=np.float64) + y1 = np.maximum(t0, t1, dtype=np.float64) + + # here, all of x0, y0, x1, y1 are numpy objects, + # BUT could possibly be numpy scalars + return x0, y0, x1, y1 + + def _prepareData(self): + x0, y0, x1, y1 = self._getNormalizedCoords() + if x0.size == 0 or y0.size == 0: + self._dataBounds = (None, None), (None, None) + self._rectarray.resize(0) + return + + xmn, xmx = np.min(x0), np.max(x1) + ymn, ymx = np.min(y0), np.max(y1) + self._dataBounds = (xmn, xmx), (ymn, ymx) + + self._rectarray.resize(max(x0.size, y0.size)) + memory = self._rectarray.ndarray() + memory[:, 0] = x0 + memory[:, 1] = y0 + memory[:, 2] = x1 - x0 + memory[:, 3] = y1 - y0 + + def _render(self, painter): + multi_pen = self._pens is not None + multi_brush = self._brushes is not None + no_pen = ( + not multi_pen + and self._sharedPen.style() == QtCore.Qt.PenStyle.NoPen + ) + + rects = self._rectarray.instances() + + if no_pen and multi_brush: + for idx, rect in enumerate(rects): + painter.fillRect(rect, self._brushes[idx]) + else: + if not multi_pen: + painter.setPen(self._sharedPen) + if not multi_brush: + painter.setBrush(self._sharedBrush) + + for idx, rect in enumerate(rects): + if multi_pen: + painter.setPen(self._pens[idx]) + if multi_brush: + painter.setBrush(self._brushes[idx]) + + painter.drawRect(rect) + + def drawPicture(self): + self.picture = QtGui.QPicture() + painter = QtGui.QPainter(self.picture) + self._render(painter) + painter.end() + def paint(self, p, *args): - if self.picture is None: - self.drawPicture() - self.picture.play(p) + if self._singleColor: + p.setPen(self._sharedPen) + p.setBrush(self._sharedBrush) + drawargs = self._rectarray.drawargs() + p.drawRects(*drawargs) + else: + if self.picture is None: + self.drawPicture() + self.picture.play(p) - def boundingRect(self): - if self.picture is None: - self.drawPicture() - return QtCore.QRectF(self.picture.boundingRect()) - def shape(self): - if self.picture is None: - self.drawPicture() + if self._shape is None: + shape = QtGui.QPainterPath() + rects = self._rectarray.instances() + for rect in rects: + shape.addRect(rect) + self._shape = shape return self._shape def implements(self, interface=None): @@ -179,3 +265,39 @@ def name(self): def getData(self): return self.opts.get('x'), self.opts.get('height') + + def dataBounds(self, ax, frac=1.0, orthoRange=None): + # _penWidth is available after _updateColors() + pw = self._penWidth[0] * 0.5 + # _dataBounds is available after _prepareData() + bounds = self._dataBounds[ax] + if bounds[0] is None or bounds[1] is None: + return None, None + + return (bounds[0] - pw, bounds[1] + pw) + + def pixelPadding(self): + # _penWidth is available after _updateColors() + pw = (self._penWidth[1] or 1) * 0.5 + return pw + + def boundingRect(self): + xmn, xmx = self.dataBounds(ax=0) + if xmn is None or xmx is None: + return QtCore.QRectF() + ymn, ymx = self.dataBounds(ax=1) + if ymn is None or ymx is None: + return QtCore.QRectF() + + px = py = 0 + pxPad = self.pixelPadding() + if pxPad > 0: + # determine length of pixel in local x, y directions + px, py = self.pixelVectors() + px = 0 if px is None else px.length() + py = 0 if py is None else py.length() + # return bounds expanded by pixel size + px *= pxPad + py *= pxPad + + return QtCore.QRectF(xmn-px, ymn-py, (2*px)+xmx-xmn, (2*py)+ymx-ymn) diff --git a/pyqtgraph/graphicsItems/ButtonItem.py b/pyqtgraph/graphicsItems/ButtonItem.py index be38f7ccec..acacdde922 100644 --- a/pyqtgraph/graphicsItems/ButtonItem.py +++ b/pyqtgraph/graphicsItems/ButtonItem.py @@ -33,12 +33,12 @@ def mouseClickEvent(self, ev): if self.enabled: self.clicked.emit(self) - def mouseHoverEvent(self, ev): + def hoverEvent(self, ev): if not self.enabled: return if ev.isEnter(): self.setOpacity(1.0) - else: + elif ev.isExit(): self.setOpacity(0.7) def disable(self): diff --git a/pyqtgraph/graphicsItems/ColorBarItem.py b/pyqtgraph/graphicsItems/ColorBarItem.py index 9ab841b91a..34df8972bc 100644 --- a/pyqtgraph/graphicsItems/ColorBarItem.py +++ b/pyqtgraph/graphicsItems/ColorBarItem.py @@ -1,15 +1,14 @@ import math -import warnings import weakref import numpy as np +from .. import colormap from .. import functions as fn -from ..Qt import QtCore -from .ImageItem import ImageItem +from ..Qt import QtCore, QtGui, QtWidgets from .LinearRegionItem import LinearRegionItem +from .PColorMeshItem import PColorMeshItem from .PlotItem import PlotItem -from .. import colormap __all__ = ['ColorBarItem'] @@ -26,9 +25,10 @@ class ColorBarItem(PlotItem): A labeled axis is displayed directly next to the gradient to help identify values. Handles included in the color bar allow for interactive adjustment. - A ColorBarItem can be assigned one or more :class:`~pyqtgraph.ImageItem`s that will be displayed - according to the selected color map and levels. The ColorBarItem can be used as a separate - element in a :class:`~pyqtgraph.GraphicsLayout` or added to the layout of a + A ColorBarItem can be assigned one or more :class:`~pyqtgraph.ImageItem` s + that will be displayed according to the selected color map and levels. The + ColorBarItem can be used as a separate element in a + :class:`~pyqtgraph.GraphicsLayout` or added to the layout of a :class:`~pyqtgraph.PlotItem` used to display image data with coordinate axes. ============================= ============================================= @@ -40,7 +40,7 @@ class ColorBarItem(PlotItem): sigLevelsChanged = QtCore.Signal(object) sigLevelsChangeFinished = QtCore.Signal(object) - def __init__(self, values=(0,1), width=25, colorMap=None, label=None, + def __init__(self, values=None, width=25, colorMap=None, label=None, interactive=True, limits=None, rounding=1, orientation='vertical', pen='w', hoverPen='r', hoverBrush='#FF000080', cmap=None ): """ @@ -50,36 +50,39 @@ def __init__(self, values=(0,1), width=25, colorMap=None, label=None, ---------- colorMap: `str` or :class:`~pyqtgraph.ColorMap` Determines the color map displayed and applied to assigned ImageItem(s). - values: tuple of float - The range of image levels covered by the color bar, as ``(min, max)``. - width: float, default=25 + values: tuple of float, optional + The range of values that will be represented by the color bar, as ``(min, max)``. + If no values are supplied, the default is to use user-specified values from + an assigned image. If that does not exist, values will default to (0,1). + width: float, default=25.0 The width of the displayed color bar. label: str, optional Label applied to the color bar axis. interactive: bool, default=True If `True`, handles are displayed to interactively adjust the level range. - limits: `None` or `tuple of float` + limits: `tuple of float`, optional Limits the adjustment range to `(low, high)`, `None` disables the limit. rounding: float, default=1 Adjusted range values are rounded to multiples of this value. orientation: str, default 'vertical' 'horizontal' or 'h' gives a horizontal color bar instead of the default vertical bar - pen: :class:`Qpen` or argument to :func:`~pyqtgraph.mkPen` + pen: :class:`QPen` or color_like Sets the color of adjustment handles in interactive mode. - hoverPen: :class:`QPen` or argument to :func:`~pyqtgraph.mkPen` - Sets the color of adjustement handles when hovered over. - hoverBrush: :class:`QBrush` or argument to :func:`~pyqtgraph.mkBrush` + hoverPen: :class:`QPen` or color_like + Sets the color of adjustment handles when hovered over. + hoverBrush: :class:`QBrush` or color_like Sets the color of movable center region when hovered over. """ super().__init__() - if cmap is not None: - warnings.warn( - "The parameter 'cmap' has been renamed to 'colorMap' for clarity. " - "The old name will no longer be available in any version of PyQtGraph released after July 2022.", - DeprecationWarning, stacklevel=2 - ) - colorMap = cmap self.img_list = [] # list of controlled ImageItems + self._actively_adjusted_values = False + if values is None: + # Use default values + # NOTE: User-specified values from the assigned item will be preferred over the default values of ColorBarItem + values = (0,1) + else: + # The user explicitly entered values, prefer these over values from assigned image. + self._actively_adjusted_values = True self.values = values self._colorMap = None self.rounding = rounding @@ -125,16 +128,16 @@ def __init__(self, values=(0,1), width=25, colorMap=None, label=None, self.axis.unlinkFromView() self.axis.setRange( self.values[0], self.values[1] ) - self.bar = ImageItem(axisOrder='col-major') if self.horizontal: - self.bar.setImage( np.linspace(0, 1, 256).reshape( (-1,1) ) ) if label is not None: self.getAxis('bottom').setLabel(label) else: - self.bar.setImage( np.linspace(0, 1, 256).reshape( (1,-1) ) ) if label is not None: self.getAxis('left').setLabel(label) + self.bar = QtWidgets.QGraphicsPixmapItem() + self.bar.setShapeMode(self.bar.ShapeMode.BoundingRectShape) self.addItem(self.bar) if colorMap is not None: self.setColorMap(colorMap) + self.interactive = interactive if interactive: if self.horizontal: align = 'vertical' @@ -158,16 +161,23 @@ def __init__(self, values=(0,1), width=25, colorMap=None, label=None, def setImageItem(self, img, insert_in=None): """ - Assigns an ImageItem or list of ImageItems to be represented and controlled + Assigns an item or list of items to be represented and controlled. + Supported "image items": class:`~pyqtgraph.ImageItem`, class:`~pyqtgraph.PColorMeshItem` Parameters ---------- - image: :class:`~pyqtgraph.ImageItem` or list of `[ImageItem, ImageItem, ...]` - Assigns one or more ImageItems to this ColorBarItem. + image: :class:`~pyqtgraph.ImageItem` or list of :class:`~pyqtgraph.ImageItem` + Assigns one or more image items to this ColorBarItem. If a :class:`~pyqtgraph.ColorMap` is defined for ColorBarItem, this will be assigned to the - ImageItems. Otherwise, the ColorBarItem will attempt to retrieve a color map from the ImageItems. - In interactive mode, ColorBarItem will control the levels of the assigned ImageItems, + ImageItems. Otherwise, the ColorBarItem will attempt to retrieve a color map from the image items. + In interactive mode, ColorBarItem will control the levels of the assigned image items, simultaneously if there is more than one. + If the ColorBarItem was initialized without a specified ``values`` parameter, it will attempt + to retrieve a set of user-defined ``levels`` from one of the image items. If this fails, + the default values of ColorBarItem will be used as the (min, max) levels of the colorbar. + Note that, for non-interactive ColorBarItems, levels may be overridden by image items with + auto-scaling colors (defined by ``enableAutoLevels``). When using an interactive ColorBarItem + in an animated plot, auto-scaling for its assigned image items should be *manually* disabled. insert_in: :class:`~pyqtgraph.PlotItem`, optional If a PlotItem is given, the color bar is inserted on the right or bottom of the plot, depending on the specified orientation. @@ -176,14 +186,28 @@ def setImageItem(self, img, insert_in=None): self.img_list = [ weakref.ref(item) for item in img ] except TypeError: # failed to iterate, make a single-item list self.img_list = [ weakref.ref( img ) ] - if self._colorMap is None: # check if one of the assigned images has a defined color map - for img_weakref in self.img_list: - img = img_weakref() - if img is not None: + colormap_is_undefined = self._colorMap is None + for img_weakref in self.img_list: + img = img_weakref() + if img is not None: + if hasattr(img, "sigLevelsChanged"): + img.sigLevelsChanged.connect(self._levelsChangedHandler) + + if colormap_is_undefined and hasattr(img, 'getColorMap'): # check if one of the assigned images has a defined color map img_cm = img.getColorMap() if img_cm is not None: self._colorMap = img_cm - break + colormap_is_undefined = False + + if not self._actively_adjusted_values: + # check if one of the assigned images has a non-default set of levels + if hasattr(img, 'getLevels'): + img_levels = img.getLevels() + + if img_levels is not None: + self.setLevels(img_levels, update_items=False) + + if insert_in is not None: if self.horizontal: insert_in.layout.addItem( self, 5, 1 ) # insert in layout below bottom axis @@ -193,15 +217,6 @@ def setImageItem(self, img, insert_in=None): insert_in.layout.setColumnFixedWidth(4, 5) # enforce some space to axis on the left self._update_items( update_cmap = True ) - # Maintain compatibility for old name of color bar setting method. - def setCmap(self, cmap): - warnings.warn( - "The method 'setCmap' has been renamed to 'setColorMap' for clarity. " - "The old name will no longer be available in any version of PyQtGraph released after July 2022.", - DeprecationWarning, stacklevel=2 - ) - self.setColorMap(cmap) - def setColorMap(self, colorMap): """ Sets a color map to determine the ColorBarItem's look-up table. The same @@ -220,16 +235,8 @@ def colorMap(self): Returns the assigned ColorMap object. """ return self._colorMap - - @property - def cmap(self): - warnings.warn( - "Direct access to ColorMap.cmap is deprecated and will no longer be available in any " - "version of PyQtGraph released after July 2022. Please use 'ColorMap.colorMap()' instead.", - DeprecationWarning, stacklevel=2) - return self._colorMap - def setLevels(self, values=None, low=None, high=None ): + def setLevels(self, values=None, low=None, high=None, update_items=True): """ Sets the displayed range of image levels. @@ -256,7 +263,11 @@ def setLevels(self, values=None, low=None, high=None ): if self.lo_lim is not None and lo_new < self.lo_lim: lo_new = self.lo_lim if self.hi_lim is not None and hi_new > self.hi_lim: hi_new = self.hi_lim self.values = self.lo_prv, self.hi_prv = (lo_new, hi_new) - self._update_items() + if update_items: + self._update_items() + else: + # update color bar only: + self.axis.setRange( self.values[0], self.values[1] ) def levels(self): """ Returns the currently set levels as the tuple ``(low, high)``. """ @@ -267,14 +278,26 @@ def _update_items(self, update_cmap=False): # update color bar: self.axis.setRange( self.values[0], self.values[1] ) if update_cmap and self._colorMap is not None: - self.bar.setLookupTable( self._colorMap.getLookupTable(nPts=256) ) + lut = self._colorMap.getLookupTable(nPts=256, alpha=True) + lut = np.expand_dims(lut, axis=0 if self.horizontal else 1) + qimg = fn.ndarray_to_qimage(lut, QtGui.QImage.Format.Format_RGBA8888) + self.bar.setPixmap(QtGui.QPixmap.fromImage(qimg)) # update assigned ImageItems, too: for img_weakref in self.img_list: img = img_weakref() if img is None: continue # dereference weakref img.setLevels( self.values ) # (min,max) tuple if update_cmap and self._colorMap is not None: - img.setLookupTable( self._colorMap.getLookupTable(nPts=256) ) + if isinstance(img, PColorMeshItem): + img.setLookupTable( self._colorMap.getLookupTable(nPts=256, mode=self._colorMap.QCOLOR) ) + else: + img.setLookupTable( self._colorMap.getLookupTable(nPts=256) ) + + def _levelsChangedHandler(self, levels): + """ internal: called when child item for some reason decides to update its levels without using ColorBarItem. + Will update colormap for the bar based on child items new levels """ + if levels != self.values: + self.setLevels(levels, update_items=False) def _regionChanged(self): """ internal: snap adjusters back to default positions on release """ diff --git a/pyqtgraph/graphicsItems/DateAxisItem.py b/pyqtgraph/graphicsItems/DateAxisItem.py index 0b74ca5dbe..f424fae9b8 100644 --- a/pyqtgraph/graphicsItems/DateAxisItem.py +++ b/pyqtgraph/graphicsItems/DateAxisItem.py @@ -157,7 +157,7 @@ def tickValues(self, minVal, maxVal, minSpc): # minSpc indicates the minimum spacing (in seconds) between two ticks # to fullfill the maxTicksPerPt constraint of the DateAxisItem at the # current zoom level. This is used for auto skipping ticks. - allTicks = [] + allTicks = np.array([]) valueSpecs = [] # back-project (minVal maxVal) to UTC, compute ticks then offset to # back to local time again @@ -168,9 +168,15 @@ def tickValues(self, minVal, maxVal, minSpc): # reposition tick labels to local time coordinates ticks += self.utcOffset # remove any ticks that were present in higher levels - tick_list = [x for x in ticks.tolist() if x not in allTicks] - allTicks.extend(tick_list) - valueSpecs.append((spec.spacing, tick_list)) + # we assume here that if the difference between a tick value and a previously seen tick value + # is less than min-spacing/100, then they are 'equal' and we can ignore the new tick. + close = np.any( + np.isclose(allTicks, ticks[:, np.newaxis], rtol=0, atol=minSpc * 0.01), + axis=-1, + ) + ticks = ticks[~close] + allTicks = np.concatenate([allTicks, ticks]) + valueSpecs.append((spec.spacing, ticks.tolist())) # if we're skipping ticks on the current level there's no point in # producing lower level ticks if skipFactor > 1: diff --git a/pyqtgraph/graphicsItems/GradientEditorItem.py b/pyqtgraph/graphicsItems/GradientEditorItem.py index bdf80ac66b..7c67803d27 100644 --- a/pyqtgraph/graphicsItems/GradientEditorItem.py +++ b/pyqtgraph/graphicsItems/GradientEditorItem.py @@ -12,7 +12,7 @@ translate = QtCore.QCoreApplication.translate -__all__ = ['TickSliderItem', 'GradientEditorItem'] +__all__ = ['TickSliderItem', 'GradientEditorItem', 'addGradientListToDocstring'] Gradients = OrderedDict([ ('thermal', {'ticks': [(0.3333, (185, 0, 0, 255)), (0.6666, (255, 220, 0, 255)), (1, (255, 255, 255, 255)), (0, (0, 0, 0, 255))], 'mode': 'rgb'}), @@ -28,6 +28,9 @@ ('inferno', {'ticks': [(0.0, (0, 0, 3, 255)), (0.25, (87, 15, 109, 255)), (0.5, (187, 55, 84, 255)), (0.75, (249, 142, 8, 255)), (1.0, (252, 254, 164, 255))], 'mode': 'rgb'}), ('plasma', {'ticks': [(0.0, (12, 7, 134, 255)), (0.25, (126, 3, 167, 255)), (0.5, (203, 71, 119, 255)), (0.75, (248, 149, 64, 255)), (1.0, (239, 248, 33, 255))], 'mode': 'rgb'}), ('magma', {'ticks': [(0.0, (0, 0, 3, 255)), (0.25, (80, 18, 123, 255)), (0.5, (182, 54, 121, 255)), (0.75, (251, 136, 97, 255)), (1.0, (251, 252, 191, 255))], 'mode': 'rgb'}), + # turbo from https://ai.googleblog.com/2019/08/turbo-improved-rainbow-colormap-for.html + ('turbo', {'ticks': [(0.0, (51, 27, 61, 255)), (0.125, (77, 110, 223, 255)), (0.25, (61, 185, 233, 255)), (0.375, (68, 238, 154, 255)), (0.5, (164, 250, 80, 255)), + (0.625, (235, 206, 76, 255)), (0.75, (247, 129, 55, 255)), (0.875, (206, 58, 32, 255)), (1.0, (119, 21, 19, 255))], 'mode': 'rgb'}), ]) def addGradientListToDocstring(): @@ -494,7 +497,7 @@ def __init__(self, *args, **kargs): self.linkedGradients = {} self.sigTicksChanged.connect(self._updateGradientIgnoreArgs) - self.sigTicksChangeFinished.connect(self.sigGradientChangeFinished.emit) + self.sigTicksChangeFinished.connect(self.sigGradientChangeFinished) def showTicks(self, show=True): for tick in self.ticks.keys(): diff --git a/pyqtgraph/graphicsItems/GradientLegend.py b/pyqtgraph/graphicsItems/GradientLegend.py index 9d65c3175c..50343ed9a6 100644 --- a/pyqtgraph/graphicsItems/GradientLegend.py +++ b/pyqtgraph/graphicsItems/GradientLegend.py @@ -1,6 +1,6 @@ from .. import functions as fn from ..Qt import QtCore, QtGui -from .UIGraphicsItem import * +from .UIGraphicsItem import UIGraphicsItem __all__ = ['GradientLegend'] diff --git a/pyqtgraph/graphicsItems/GraphItem.py b/pyqtgraph/graphicsItems/GraphItem.py index 65793dc031..a01098a74b 100644 --- a/pyqtgraph/graphicsItems/GraphItem.py +++ b/pyqtgraph/graphicsItems/GraphItem.py @@ -111,7 +111,7 @@ def generatePicture(self): lastPen = None for i in range(pts.shape[0]): pen = self.pen[i] - if np.any(pen != lastPen): + if lastPen is None or np.any(pen != lastPen): lastPen = pen if pen.dtype.fields is None: p.setPen(fn.mkPen(color=(pen[0], pen[1], pen[2], pen[3]), width=1)) diff --git a/pyqtgraph/graphicsItems/GraphicsItem.py b/pyqtgraph/graphicsItems/GraphicsItem.py index 61ae1f144c..854004534a 100644 --- a/pyqtgraph/graphicsItems/GraphicsItem.py +++ b/pyqtgraph/graphicsItems/GraphicsItem.py @@ -1,16 +1,18 @@ +__all__ = ['GraphicsItem'] + import operator -import warnings import weakref from collections import OrderedDict from functools import reduce from math import hypot +from typing import Optional +from xml.etree.ElementTree import Element from .. import functions as fn from ..GraphicsScene import GraphicsScene from ..Point import Point from ..Qt import QtCore, QtWidgets, isQObjectAlive -__all__ = ['GraphicsItem'] # Recipe from https://docs.python.org/3.8/library/collections.html#collections.OrderedDict # slightly adapted for Python 3.7 compatibility @@ -48,7 +50,7 @@ class GraphicsItem(object): """ _pixelVectorGlobalCache = LRU(100) - def __init__(self, register=None): + def __init__(self): if not hasattr(self, '_qtBaseClass'): for b in self.__class__.__bases__: if issubclass(b, QtWidgets.QGraphicsItem): @@ -63,11 +65,7 @@ def __init__(self, register=None): self._connectedView = None self._exportOpts = False ## If False, not currently exporting. Otherwise, contains dict of export options. self._cachedView = None - if register is not None and register: - warnings.warn( - "'register' argument is deprecated and does nothing, will be removed in 0.13", - DeprecationWarning, stacklevel=2 - ) + def getViewWidget(self): """ @@ -157,8 +155,6 @@ def viewTransform(self): return self.sceneTransform() #return self.deviceTransform(view.viewportTransform()) - - def getBoundingParents(self): """Return a list of parents to this item that have child clipping enabled.""" p = self @@ -284,7 +280,7 @@ def pixelVectors(self, direction=None): #pv = Point(dti.map(normView)-dti.map(Point(0,0))), Point(dti.map(normOrtho)-dti.map(Point(0,0))) pv = Point(dti.map(normView).p2()), Point(dti.map(normOrtho).p2()) self._pixelVectorCache[1] = pv - self._pixelVectorCache[0] = dt + self._pixelVectorCache[0] = key self._pixelVectorGlobalCache[key] = pv return self._pixelVectorCache[1] @@ -457,13 +453,16 @@ def transformAngle(self, relativeItem=None): #print " --> ", ch2.scene() #self.setChildScene(ch2) - def parentChanged(self): + def changeParent(self): """Called when the item's parent has changed. This method handles connecting / disconnecting from ViewBox signals to make sure viewRangeChanged works properly. It should generally be extended, not overridden.""" self._updateView() - + + def parentChanged(self): + # deprecated version of changeParent() + GraphicsItem.changeParent(self) def _updateView(self): ## called to see whether this item has a new view to connect to @@ -608,3 +607,38 @@ def setExportMode(self, export, opts=None): def getContextMenus(self, event): return [self.getMenu()] if hasattr(self, "getMenu") else [] + + def generateSvg( + self, + nodes: dict[str, Element] + ) -> Optional[tuple[Element, list[Element]]]: + """Method to override to manually specify the SVG writer mechanism. + + Parameters + ---------- + nodes + Dictionary keyed by the name of graphics items and the XML + representation of the the item that can be written as valid + SVG. + + Returns + ------- + tuple + First element is the top level group for this item. The + second element is a list of xml Elements corresponding to the + child nodes of the item. + None + Return None if no XML is needed for rendering + + Raises + ------ + NotImplementedError + override method to implement in subclasses of GraphicsItem + + See Also + -------- + pyqtgraph.exporters.SVGExporter._generateItemSvg + The generic and default implementation + + """ + raise NotImplementedError diff --git a/pyqtgraph/graphicsItems/GraphicsLayout.py b/pyqtgraph/graphicsItems/GraphicsLayout.py index 0cb8658b3f..f76041c084 100644 --- a/pyqtgraph/graphicsItems/GraphicsLayout.py +++ b/pyqtgraph/graphicsItems/GraphicsLayout.py @@ -11,7 +11,7 @@ class GraphicsLayout(GraphicsWidget): """ Used for laying out GraphicsWidgets in a grid. - This is usually created automatically as part of a :class:`GraphicsWindow ` or :class:`GraphicsLayoutWidget `. + This is usually created automatically as part of a :class:`GraphicsLayoutWidget `. """ def __init__(self, parent=None, border=None): @@ -149,10 +149,27 @@ def boundingRect(self): return self.rect() def itemIndex(self, item): + """Return the numerical index of GraphicsItem object passed in + + Parameters + ---------- + item : QGraphicsLayoutItem + Item to query the index position of + + Returns + ------- + int + Index of the item within the graphics layout + + Raises + ------ + ValueError + Raised if item could not be found inside the GraphicsLayout instance. + """ for i in range(self.layout.count()): if self.layout.itemAt(i).graphicsItem() is item: return i - raise Exception("Could not determine index of item " + str(item)) + raise ValueError(f"Could not determine index of item {item}") def removeItem(self, item): """Remove *item* from the layout.""" @@ -171,6 +188,8 @@ def removeItem(self, item): self.update() def clear(self): + """Remove all items from the layout and set the current row and column to 0 + """ for i in list(self.items.keys()): self.removeItem(i) self.currentRow = 0 diff --git a/pyqtgraph/graphicsItems/GraphicsObject.py b/pyqtgraph/graphicsItems/GraphicsObject.py index 15bcae21c4..f672672f55 100644 --- a/pyqtgraph/graphicsItems/GraphicsObject.py +++ b/pyqtgraph/graphicsItems/GraphicsObject.py @@ -1,13 +1,13 @@ -from ..Qt import QtCore, QtWidgets, QT_LIB - +import warnings +from ..Qt import QT_LIB, QtCore, QtWidgets from .GraphicsItem import GraphicsItem __all__ = ['GraphicsObject'] class GraphicsObject(GraphicsItem, QtWidgets.QGraphicsObject): """ - **Bases:** :class:`GraphicsItem `, :class:`QtWidgets.QGraphicsObject` + **Bases:** :class:`GraphicsItem `, :class:`QtWidgets.QGraphicsObject` - Extension of QGraphicsObject with some useful methods (provided by :class:`GraphicsItem `) + Extension of QGraphicsObject with some useful methods (provided by :class:`GraphicsItem `) """ _qtBaseClass = QtWidgets.QGraphicsObject def __init__(self, *args): @@ -22,12 +22,21 @@ def itemChange(self, change, value): except TypeError: return None if change in [self.GraphicsItemChange.ItemParentHasChanged, self.GraphicsItemChange.ItemSceneHasChanged]: - if QT_LIB == 'PySide6' and QtCore.__version_info__ == (6, 2, 2): - # workaround PySide6 6.2.2 issue https://bugreports.qt.io/browse/PYSIDE-1730 - # note that the bug exists also in PySide6 6.2.2.1 / Qt 6.2.2 - getattr(self.__class__, 'parentChanged')(self) + if self.__class__.__dict__.get('parentChanged') is not None: + # user's GraphicsObject subclass has a parentChanged() method + warnings.warn( + "parentChanged() is deprecated and will be removed in the future. " + "Use changeParent() instead.", + DeprecationWarning, stacklevel=2 + ) + if QT_LIB == 'PySide6' and QtCore.__version_info__ == (6, 2, 2): + # workaround PySide6 6.2.2 issue https://bugreports.qt.io/browse/PYSIDE-1730 + # note that the bug exists also in PySide6 6.2.2.1 / Qt 6.2.2 + getattr(self.__class__, 'parentChanged')(self) + else: + self.parentChanged() else: - self.parentChanged() + self.changeParent() try: inform_view_on_change = self.__inform_view_on_changes except AttributeError: diff --git a/pyqtgraph/graphicsItems/GraphicsWidget.py b/pyqtgraph/graphicsItems/GraphicsWidget.py index d86b6d1fc9..57f83f21ca 100644 --- a/pyqtgraph/graphicsItems/GraphicsWidget.py +++ b/pyqtgraph/graphicsItems/GraphicsWidget.py @@ -1,4 +1,4 @@ -from ..Qt import QtGui, QtWidgets +from ..Qt import QtCore, QtGui, QtWidgets from .GraphicsItem import GraphicsItem __all__ = ['GraphicsWidget'] @@ -64,7 +64,7 @@ def boundingRect(self): self._previousGeometry = geometry else: br = self._boundingRectCache - return br + return QtCore.QRectF(br) def shape(self): p = self._painterPathCache diff --git a/pyqtgraph/graphicsItems/GridItem.py b/pyqtgraph/graphicsItems/GridItem.py index 5e72ea6b7d..95715557a2 100644 --- a/pyqtgraph/graphicsItems/GridItem.py +++ b/pyqtgraph/graphicsItems/GridItem.py @@ -4,7 +4,7 @@ from .. import getConfigOption from ..Point import Point from ..Qt import QtCore, QtGui -from .UIGraphicsItem import * +from .UIGraphicsItem import UIGraphicsItem __all__ = ['GridItem'] class GridItem(UIGraphicsItem): diff --git a/pyqtgraph/graphicsItems/HistogramLUTItem.py b/pyqtgraph/graphicsItems/HistogramLUTItem.py index 58c8f72262..3bff15b06a 100644 --- a/pyqtgraph/graphicsItems/HistogramLUTItem.py +++ b/pyqtgraph/graphicsItems/HistogramLUTItem.py @@ -12,12 +12,12 @@ from .. import functions as fn from ..Point import Point from ..Qt import QtCore, QtGui, QtWidgets -from .AxisItem import * -from .GradientEditorItem import * +from .AxisItem import AxisItem +from .GradientEditorItem import GradientEditorItem from .GraphicsWidget import GraphicsWidget -from .LinearRegionItem import * -from .PlotCurveItem import * -from .ViewBox import * +from .LinearRegionItem import LinearRegionItem +from .PlotCurveItem import PlotCurveItem +from .ViewBox import ViewBox __all__ = ['HistogramLUTItem'] @@ -64,11 +64,11 @@ class HistogramLUTItem(GraphicsWidget): Attributes ---------- - sigLookupTableChanged : signal + sigLookupTableChanged : QtCore.Signal Emits the HistogramLUTItem itself when the gradient changes - sigLevelsChanged : signal + sigLevelsChanged : QtCore.Signal Emits the HistogramLUTItem itself while the movable region is changing - sigLevelChangeFinished : signal + sigLevelChangeFinished : QtCore.Signal Emits the HistogramLUTItem itself when the movable region is finished changing See Also @@ -195,7 +195,7 @@ def fillHistogram(self, fill=True, level=0.0, color=(100, 100, 200)): level : float, optional Set the fill level. See :meth:`PlotCurveItem.setFillLevel `. Only used if ``fill`` is True. - color : color, optional + color : color_like, optional Color to use for the fill when the histogram ``levelMode == "mono"``. See :meth:`PlotCurveItem.setBrush `. """ @@ -282,7 +282,8 @@ def setImageItem(self, img): HistogramLUTItem. """ self.imageItem = weakref.ref(img) - img.sigImageChanged.connect(self.imageChanged) + if hasattr(img, 'sigImageChanged'): + img.sigImageChanged.connect(self.imageChanged) self._setImageLookupTable() self.regionChanged() self.imageChanged(autoLevel=True) @@ -351,7 +352,7 @@ def imageChanged(self, autoLevel=False, autoRange=False): self.region.setRegion([mn, mx]) profiler('set region') else: - mn, mx = self.imageItem().levels + mn, mx = self.imageItem().getLevels() self.region.setRegion([mn, mx]) else: # plot one histogram for each channel @@ -437,7 +438,8 @@ def setLevelMode(self, mode): # force this because calling self.setLevels might not set the imageItem # levels if there was no change to the region item - self.imageItem().setLevels(self.getLevels()) + if self.imageItem() is not None: + self.imageItem().setLevels(self.getLevels()) self.imageChanged() self.update() diff --git a/pyqtgraph/graphicsItems/ImageItem.py b/pyqtgraph/graphicsItems/ImageItem.py index 35f2b70b82..9a90ceccdd 100644 --- a/pyqtgraph/graphicsItems/ImageItem.py +++ b/pyqtgraph/graphicsItems/ImageItem.py @@ -3,6 +3,7 @@ import numpy +from .. import colormap from .. import debug as debug from .. import functions as fn from .. import getConfigOption @@ -10,7 +11,6 @@ from ..Qt import QtCore, QtGui, QtWidgets from ..util.cupy_helper import getCupy from .GraphicsObject import GraphicsObject -from .. import colormap translate = QtCore.QCoreApplication.translate @@ -32,7 +32,7 @@ def __init__(self, image=None, **kargs): Parameters ---------- - image: array + image: np.ndarray, optional Image data """ GraphicsObject.__init__(self) @@ -52,6 +52,7 @@ def __init__(self, image=None, **kargs): self._unrenderable = False self._xp = None # either numpy or cupy, to match the image data self._defferedLevels = None + self._imageHasNans = None # None : not yet known self.axisOrder = getConfigOption('imageAxisOrder') self._dataTransform = self._inverseDataTransform = None @@ -104,7 +105,7 @@ def setCompositionMode(self, mode): def setBorder(self, b): """ Defines the border drawn around the image. Accepts all arguments supported by - :func:`~pyqtgraph.functions.mkPen`. + :func:`~pyqtgraph.mkPen`. """ self.border = fn.mkPen(b) self.update() @@ -138,7 +139,7 @@ def setLevels(self, levels, update=True): Parameters ---------- - levels: list_like + levels: array_like - ``[blackLevel, whiteLevel]`` sets black and white levels for monochrome data and can be used with a lookup table. - ``[[minR, maxR], [minG, maxG], [minB, maxB]]`` @@ -253,18 +254,21 @@ def setOpts(self, update=True, **kargs): See :func:`~pyqtgraph.ImageItem.setCompositionMode` colorMap: :class:`~pyqtgraph.ColorMap` or `str` Sets a color map. A string will be passed to :func:`colormap.get() ` - lut: array + lut: array_like Sets a color lookup table to use when displaying the image. See :func:`~pyqtgraph.ImageItem.setLookupTable`. - levels: list_like, usally [`min`, `max`] - Sets minimum and maximum values to use when rescaling the image data. By default, these will be set to - the estimated minimum and maximum values in the image. If the image array has dtype uint8, no rescaling - is necessary. See :func:`~pyqtgraph.ImageItem.setLevels`. - opacity: float, 0.0-1.0 - Overall opacity for an RGB image. - rect: :class:`QRectF`, :class:`QRect` or array_like of floats (`x`,`y`,`w`,`h`) - Displays the current image within the specified rectangle in plot coordinates. - See :func:`~pyqtgraph.ImageItem.setRect`. + levels: array_like + Shape of (min, max). Sets minimum and maximum values to use when + rescaling the image data. By default, these will be set to the + estimated minimum and maximum values in the image. If the image array + has dtype uint8, no rescaling is necessary. See + :func:`~pyqtgraph.ImageItem.setLevels`. + opacity: float + Overall opacity for an RGB image. Between 0.0-1.0. + rect: :class:`QRectF`, :class:`QRect` or array_like + Displays the current image within the specified rectangle in plot + coordinates. If ``array_like``, should be of the of ``floats + (`x`,`y`,`w`,`h`)`` . See :func:`~pyqtgraph.ImageItem.setRect`. update : bool, optional Controls if image immediately updates to reflect the new options. """ @@ -351,22 +355,21 @@ def setImage(self, image=None, autoLevels=None, **kargs): imageitem.setImage(imagedata.T) or the interpretation of the data can be changed locally through the ``axisOrder`` keyword or by changing the - `imageAxisOrder` :ref:`global configuration option `. + `imageAxisOrder` :ref:`global configuration option ` All keywords supported by :func:`~pyqtgraph.ImageItem.setOpts` are also allowed here. Parameters ---------- - image: array + image: np.ndarray, optional Image data given as NumPy array with an integer or floating point dtype of any bit depth. A 2-dimensional array describes single-valued (monochromatic) data. A 3-dimensional array is used to give individual color components. The third dimension must be of length 3 (RGB) or 4 (RGBA). - - rect: QRectF, QRect or list_like of floats ``[x, y, w, h]``, optional - If given, sets translation and scaling to display the image within the specified rectangle. See - :func:`~pyqtgraph.ImageItem.setRect`. - + rect: QRectF or QRect or array_like, optional + If given, sets translation and scaling to display the image within the + specified rectangle. If ``array_like`` should be the form of floats + ``[x, y, w, h]`` See :func:`~pyqtgraph.ImageItem.setRect` autoLevels: bool, optional If `True`, ImageItem will automatically select levels based on the maximum and minimum values encountered in the data. For performance reasons, this search subsamples the images and may miss individual bright or @@ -375,12 +378,10 @@ def setImage(self, image=None, autoLevels=None, **kargs): If `False`, the search will be omitted. The default is `False` if a ``levels`` keyword argument is given, and `True` otherwise. - levelSamples: int, default 65536 When determining minimum and maximum values, ImageItem only inspects a subset of pixels no larger than this number. Setting this larger than the total number of pixels considers all values. - """ profile = debug.Profiler() @@ -401,6 +402,7 @@ def setImage(self, image=None, autoLevels=None, **kargs): if self.image is None or image.dtype != self.image.dtype: self._effectiveLut = None self.image = image + self._imageHasNans = None if self.image.shape[0] > 2**15-1 or self.image.shape[1] > 2**15-1: if 'autoDownsample' not in kargs: kargs['autoDownsample'] = True @@ -524,7 +526,7 @@ def render(self): if self.image.ndim == 2 or self.image.shape[2] == 1: self.lut = self._ensure_proper_substrate(self.lut, self._xp) if isinstance(self.lut, Callable): - lut = self._ensure_proper_substrate(self.lut(self.image), self._xp) + lut = self._ensure_proper_substrate(self.lut(self.image, 256), self._xp) else: lut = self.lut else: @@ -613,7 +615,10 @@ def _try_rescale_float(self, image, levels, lut): break # awkward, but fastest numpy native nan evaluation - if xp.isnan(image.min()): + if self._imageHasNans is None: + self._imageHasNans = xp.isnan(image.min()) + + if self._imageHasNans: # don't handle images with nans # this should be an uncommon case break @@ -898,7 +903,7 @@ def getHistogram(self, bins='auto', step='auto', perChannel=False, targetImageSi dimensions approximating `targetImageSize` for each axis. The `bins` argument and any extra keyword arguments are passed to - :func:`self.xp.histogram()`. If `bins` is `auto`, a bin number is automatically + :func:`numpy.histogram()`. If `bins` is `auto`, a bin number is automatically chosen based on the image characteristics: * Integer images will have approximately `targetHistogramSize` bins, diff --git a/pyqtgraph/graphicsItems/InfiniteLine.py b/pyqtgraph/graphicsItems/InfiniteLine.py index 75098c44cb..3e73e870d6 100644 --- a/pyqtgraph/graphicsItems/InfiniteLine.py +++ b/pyqtgraph/graphicsItems/InfiniteLine.py @@ -58,7 +58,7 @@ def __init__(self, pos=None, angle=90, pen=None, movable=False, bounds=None, None to show no label (default is None). May optionally include formatting strings to display the line value. labelOpts A dict of keyword arguments to use when constructing the - text label. See :class:`InfLineLabel`. + text label. See :class:`InfLineLabel `. span Optional tuple (min, max) giving the range over the view to draw the line. For example, with a vertical line, use span=(0.5, 1) to draw only on the top half of the view. @@ -300,15 +300,14 @@ def _computeBoundingRect(self): vr = self.viewRect() # bounds of containing ViewBox mapped to local coords. if vr is None: return QtCore.QRectF() - - ## add a 4-pixel radius around the line for mouse interaction. - - px = self.pixelLength(direction=Point(1,0), ortho=True) ## get pixel length orthogonal to the line - if px is None: - px = 0 + + # compute the pixel size orthogonal to the line + # this is more complicated than it seems, maybe it can be simplified + _, ortho = self.pixelVectors(direction=Point(1, 0)) + px = 0 if ortho is None else ortho.y() + pw = max(self.pen.width() / 2, self.hoverPen.width() / 2) - w = max(4, self._maxMarkerSize + pw) + 1 - w = w * px + w = (self._maxMarkerSize + pw + 1) * px br = QtCore.QRectF(vr) br.setBottom(-w) br.setTop(w) @@ -338,7 +337,8 @@ def boundingRect(self): return self._boundingRect def paint(self, p, *args): - p.setRenderHint(p.RenderHint.Antialiasing) + if self.angle % 180 not in (0, 90): + p.setRenderHint(p.RenderHint.Antialiasing) left, right = self._endPoints pen = self.currentPen diff --git a/pyqtgraph/graphicsItems/IsocurveItem.py b/pyqtgraph/graphicsItems/IsocurveItem.py index ce9aa64124..0dec0430c5 100644 --- a/pyqtgraph/graphicsItems/IsocurveItem.py +++ b/pyqtgraph/graphicsItems/IsocurveItem.py @@ -1,7 +1,7 @@ from .. import functions as fn from .. import getConfigOption from ..Qt import QtCore, QtGui -from .GraphicsObject import * +from .GraphicsObject import GraphicsObject __all__ = ['IsocurveItem'] diff --git a/pyqtgraph/graphicsItems/LabelItem.py b/pyqtgraph/graphicsItems/LabelItem.py index 79f3280d14..85a6d88075 100644 --- a/pyqtgraph/graphicsItems/LabelItem.py +++ b/pyqtgraph/graphicsItems/LabelItem.py @@ -6,7 +6,7 @@ __all__ = ['LabelItem'] -class LabelItem(GraphicsWidget, GraphicsWidgetAnchor): +class LabelItem(GraphicsWidgetAnchor, GraphicsWidget): """ GraphicsWidget displaying text. Used mainly as axis labels, titles, etc. diff --git a/pyqtgraph/graphicsItems/LegendItem.py b/pyqtgraph/graphicsItems/LegendItem.py index 92b512d5d6..024a140ef6 100644 --- a/pyqtgraph/graphicsItems/LegendItem.py +++ b/pyqtgraph/graphicsItems/LegendItem.py @@ -14,7 +14,7 @@ __all__ = ['LegendItem', 'ItemSample'] -class LegendItem(GraphicsWidget, GraphicsWidgetAnchor): +class LegendItem(GraphicsWidgetAnchor, GraphicsWidget): """ Displays a legend used for describing the contents of a plot. diff --git a/pyqtgraph/graphicsItems/NonUniformImage.py b/pyqtgraph/graphicsItems/NonUniformImage.py index b148fac876..065e0effef 100644 --- a/pyqtgraph/graphicsItems/NonUniformImage.py +++ b/pyqtgraph/graphicsItems/NonUniformImage.py @@ -1,12 +1,12 @@ -import math - +import warnings import numpy as np from .. import functions as fn -from .. import mkBrush, mkPen from ..colormap import ColorMap +from .. import Qt from ..Qt import QtCore, QtGui from .GraphicsObject import GraphicsObject +from .HistogramLUTItem import HistogramLUTItem __all__ = ['NonUniformImage'] @@ -18,7 +18,6 @@ class NonUniformImage(GraphicsObject): commonly used to display 2-d or slices of higher dimensional data that have a regular but non-uniform grid e.g. measurements or simulation results. """ - def __init__(self, x, y, z, border=None): GraphicsObject.__init__(self) @@ -38,28 +37,37 @@ def __init__(self, x, y, z, border=None): raise Exception("The length of x and y must match the shape of z.") # default colormap (black - white) - self.cmap = ColorMap(pos=[0.0, 1.0], color=[(0.0, 0.0, 0.0, 1.0), (1.0, 1.0, 1.0, 1.0)]) + self.cmap = ColorMap(pos=[0.0, 1.0], color=[(0, 0, 0), (255, 255, 255)]) self.data = (x, y, z) + self.levels = None self.lut = None self.border = border - self.generatePicture() + self.picture = None - def setLookupTable(self, lut, autoLevel=False): - lut.sigLevelsChanged.connect(self.generatePicture) - lut.gradient.sigGradientChanged.connect(self.generatePicture) - self.lut = lut + self.update() - if autoLevel: - _, _, z = self.data - f = z[np.isfinite(z)] - lut.setLevels(f.min(), f.max()) + def setLookupTable(self, lut, update=True, **kwargs): + # backwards compatibility hack + if isinstance(lut, HistogramLUTItem): + warnings.warn( + "NonUniformImage::setLookupTable(HistogramLUTItem) is deprecated " + "and will be removed in a future version of PyQtGraph. " + "use HistogramLUTItem::setImageItem(NonUniformImage) instead", + DeprecationWarning, stacklevel=2 + ) + lut.setImageItem(self) + return - self.generatePicture() + self.lut = lut + self.picture = None + if update: + self.update() def setColorMap(self, cmap): self.cmap = cmap - self.generatePicture() + self.picture = None + self.update() def getHistogram(self, **kwds): """Returns x and y arrays containing the histogram values for the current image. @@ -72,63 +80,97 @@ def getHistogram(self, **kwds): return hist[1][:-1], hist[0] - def generatePicture(self): - - x, y, z = self.data - - self.picture = QtGui.QPicture() - p = QtGui.QPainter(self.picture) - p.setPen(mkPen(None)) - - # normalize - if self.lut is not None: - mn, mx = self.lut.getLevels() - else: - f = z[np.isfinite(z)] - mn = f.min() - mx = f.max() + def setLevels(self, levels): + self.levels = levels + self.picture = None + self.update() - # draw the tiles - for i in range(x.size): - for j in range(y.size): + def getLevels(self): + if self.levels is None: + z = self.data[2] + z = z[np.isfinite(z)] + self.levels = z.min(), z.max() + return self.levels - value = z[i, j] + def generatePicture(self): + x, y, z = self.data - if np.isneginf(value): - value = 0.0 - elif np.isposinf(value): - value = 1.0 - elif math.isnan(value): - continue # ignore NaN - else: - value = (value - mn) / (mx - mn) # normalize + # pad x and y so that we don't need to special-case the edges + x = np.pad(x, 1, mode='edge') + y = np.pad(y, 1, mode='edge') - if self.lut: - color = self.lut.gradient.getColor(value) - else: - color = self.cmap.mapToQColor(value) + x = (x[:-1] + x[1:]) / 2 + y = (y[:-1] + y[1:]) / 2 - p.setBrush(mkBrush(color)) + X, Y = np.meshgrid(x[:-1], y[:-1], indexing='ij') + W, H = np.meshgrid(np.diff(x), np.diff(y), indexing='ij') + Z = z - # left, right, bottom, top - l = x[0] if i == 0 else (x[i - 1] + x[i]) / 2 - r = (x[i] + x[i + 1]) / 2 if i < x.size - 1 else x[-1] - b = y[0] if j == 0 else (y[j - 1] + y[j]) / 2 - t = (y[j] + y[j + 1]) / 2 if j < y.size - 1 else y[-1] + # get colormap, lut has precedence over cmap + if self.lut is None: + lut = self.cmap.getLookupTable(nPts=256) + elif callable(self.lut): + lut = self.lut(z) + else: + lut = self.lut + + # normalize and quantize + mn, mx = self.getLevels() + rng = mx - mn + if rng == 0: + rng = 1 + scale = len(lut) / rng + Z = fn.rescaleData(Z, scale, mn, dtype=int, clip=(0, len(lut)-1)) + + # replace nans positions with invalid lut index + invalid_coloridx = len(lut) + Z[np.isnan(z)] = invalid_coloridx + + # pre-allocate to the largest array needed + color_indices, counts = np.unique(Z, return_counts=True) + rectarray = Qt.internals.PrimitiveArray(QtCore.QRectF, 4) + rectarray.resize(counts.max()) + + # sorted_indices effectively groups together the + # (flattened) indices of the same coloridx together. + sorted_indices = np.argsort(Z, axis=None) + for arr in X, Y, W, H: + arr.shape = -1 # in-place unravel - p.drawRect(QtCore.QRectF(l, t, r - l, b - t)) + self.picture = QtGui.QPicture() + painter = QtGui.QPainter(self.picture) + painter.setPen(fn.mkPen(None)) + + # draw the tiles grouped by coloridx + offset = 0 + for coloridx, cnt in zip(color_indices, counts): + if coloridx == invalid_coloridx: + continue + indices = sorted_indices[offset:offset+cnt] + offset += cnt + rectarray.resize(cnt) + memory = rectarray.ndarray() + memory[:,0] = X[indices] + memory[:,1] = Y[indices] + memory[:,2] = W[indices] + memory[:,3] = H[indices] + + brush = fn.mkBrush(lut[coloridx]) + painter.setBrush(brush) + painter.drawRects(*rectarray.drawargs()) if self.border is not None: - p.setPen(self.border) - p.setBrush(fn.mkBrush(None)) - p.drawRect(self.boundingRect()) + painter.setPen(self.border) + painter.setBrush(fn.mkBrush(None)) + painter.drawRect(self.boundingRect()) - p.end() - - self.update() + painter.end() def paint(self, p, *args): + if self.picture is None: + self.generatePicture() p.drawPicture(0, 0, self.picture) def boundingRect(self): - return QtCore.QRectF(self.picture.boundingRect()) + x, y, _ = self.data + return QtCore.QRectF(x[0], y[0], x[-1]-x[0], y[-1]-y[0]) diff --git a/pyqtgraph/graphicsItems/PColorMeshItem.py b/pyqtgraph/graphicsItems/PColorMeshItem.py index 0be9c7b342..ce10b50fe5 100644 --- a/pyqtgraph/graphicsItems/PColorMeshItem.py +++ b/pyqtgraph/graphicsItems/PColorMeshItem.py @@ -1,51 +1,49 @@ -import itertools -import warnings - import numpy as np from .. import Qt, colormap from .. import functions as fn from ..Qt import QtCore, QtGui -from .GradientEditorItem import Gradients # List of colormaps from .GraphicsObject import GraphicsObject __all__ = ['PColorMeshItem'] -if Qt.QT_LIB.startswith('PyQt'): - wrapinstance = Qt.sip.wrapinstance -else: - wrapinstance = Qt.shiboken.wrapInstance - - class QuadInstances: def __init__(self): - self.polys = [] - - def alloc(self, size): - self.polys.clear() + self.nrows = -1 + self.ncols = -1 + self.pointsarray = Qt.internals.PrimitiveArray(QtCore.QPointF, 2) + self.resize(0, 0) - # 2 * (size + 1) vertices, (x, y) - arr = np.empty((2 * (size + 1), 2), dtype=np.float64) - ptrs = list(map(wrapinstance, - itertools.count(arr.ctypes.data, arr.strides[0]), - itertools.repeat(QtCore.QPointF, arr.shape[0]))) - - # arrange into 2 rows, (size + 1) vertices - points = [ptrs[:len(ptrs)//2], ptrs[len(ptrs)//2:]] - self.arr = arr.reshape((2, -1, 2)) - - # pre-create quads from those 2 rows of QPointF(s) - for j in range(size): - bl, tl = points[0][j:j+2] - br, tr = points[1][j:j+2] - poly = (bl, br, tr, tl) - self.polys.append(poly) + def resize(self, nrows, ncols): + if nrows == self.nrows and ncols == self.ncols: + return - def array(self, size): - if size != len(self.polys): - self.alloc(size) - return self.arr + self.nrows = nrows + self.ncols = ncols + + # (nrows + 1) * (ncols + 1) vertices, (x, y) + self.pointsarray.resize((nrows+1)*(ncols+1)) + points = self.pointsarray.instances() + # points is a flattened list of a 2d array of + # QPointF(s) of shape (nrows+1, ncols+1) + + # pre-create quads from those instances of QPointF(s). + # store the quads as a flattened list of a 2d array + # of polygons of shape (nrows, ncols) + polys = [] + for r in range(nrows): + for c in range(ncols): + bl = points[(r+0)*(ncols+1)+(c+0)] + tl = points[(r+0)*(ncols+1)+(c+1)] + br = points[(r+1)*(ncols+1)+(c+0)] + tr = points[(r+1)*(ncols+1)+(c+1)] + poly = (bl, br, tr, tl) + polys.append(poly) + self.polys = polys + + def ndarray(self): + return self.pointsarray.ndarray() def instances(self): return self.polys @@ -56,6 +54,7 @@ class PColorMeshItem(GraphicsObject): **Bases:** :class:`GraphicsObject ` """ + sigLevelsChanged = QtCore.Signal(object) # emits tuple with levels (low,high) when color levels are changed. def __init__(self, *args, **kwargs): """ @@ -84,18 +83,27 @@ def __init__(self, *args, **kwargs): +---------+ (x[i, j], y[i, j]) (x[i, j+1], y[i, j+1]) - "ASCII from: ". - colorMap : pg.ColorMap, default pg.colormap.get('viridis') + "ASCII from: ". + colorMap : pyqtgraph.ColorMap Colormap used to map the z value to colors. - edgecolors : dict, default None + default ``pyqtgraph.colormap.get('viridis')`` + levels: tuple, optional, default None + Sets the minimum and maximum values to be represented by the colormap (min, max). + Values outside this range will be clipped to the colors representing min or max. + ``None`` disables the limits, meaning that the colormap will autoscale + each time ``setData()`` is called - unless ``enableAutoLevels=False``. + enableAutoLevels: bool, optional, default True + Causes the colormap levels to autoscale whenever ``setData()`` is called. + When enableAutoLevels is set to True, it is still possible to disable autoscaling + on a per-change-basis by using ``autoLevels=False`` when calling ``setData()``. + If ``enableAutoLevels==False`` and ``levels==None``, autoscaling will be + performed once when the first z data is supplied. + edgecolors : dict, optional The color of the edges of the polygons. Default None means no edges. + Only cosmetic pens are supported. The dict may contains any arguments accepted by :func:`mkColor() `. - Example: - - ``mkPen(color='w', width=2)`` - + Example: ``mkPen(color='w', width=2)`` antialiasing : bool, default False Whether to draw edgelines with antialiasing. Note that if edgecolors is None, antialiasing is always False. @@ -107,33 +115,27 @@ def __init__(self, *args, **kwargs): self.x = None self.y = None self.z = None - + self._dataBounds = None + self.edgecolors = kwargs.get('edgecolors', None) + if self.edgecolors is not None: + self.edgecolors = fn.mkPen(self.edgecolors) + # force the pen to be cosmetic. see discussion in + # https://github.com/pyqtgraph/pyqtgraph/pull/2586 + self.edgecolors.setCosmetic(True) self.antialiasing = kwargs.get('antialiasing', False) + self.levels = kwargs.get('levels', None) + self.enableautolevels = kwargs.get('enableAutoLevels', True) if 'colorMap' in kwargs: cmap = kwargs.get('colorMap') if not isinstance(cmap, colormap.ColorMap): raise ValueError('colorMap argument must be a ColorMap instance') self.cmap = cmap - elif 'cmap' in kwargs: - # legacy unadvertised argument for backwards compatibility. - # this will only use colormaps from Gradients. - # Note that the colors will be wrong for the hsv colormaps. - warnings.warn( - "The parameter 'cmap' will be removed in a version of PyQtGraph released after Nov 2022.", - DeprecationWarning, stacklevel=2 - ) - cmap = kwargs.get('cmap') - if not isinstance(cmap, str) or cmap not in Gradients: - raise NameError('Undefined colormap, should be one of the following: '+', '.join(['"'+i+'"' for i in Gradients.keys()])+'.') - pos, color = zip(*Gradients[cmap]['ticks']) - self.cmap = colormap.ColorMap(pos, color) else: self.cmap = colormap.get('viridis') - lut_qcolor = self.cmap.getLookupTable(nPts=256, mode=self.cmap.QCOLOR) - self.lut_qbrush = [QtGui.QBrush(x) for x in lut_qcolor] + self.lut_qcolor = self.cmap.getLookupTable(nPts=256, mode=self.cmap.QCOLOR) self.quads = QuadInstances() @@ -154,6 +156,8 @@ def _prepareData(self, args): self.x = None self.y = None self.z = None + + self._dataBounds = None # User only specified z elif len(args)==1: @@ -163,6 +167,8 @@ def _prepareData(self, args): self.x, self.y = np.meshgrid(x, y, indexing='ij') self.z = args[0] + self._dataBounds = ((x[0], x[-1]), (y[0], y[-1])) + # User specified x, y, z elif len(args)==3: @@ -177,11 +183,15 @@ def _prepareData(self, args): self.y = args[1] self.z = args[2] + xmn, xmx = np.min(self.x), np.max(self.x) + ymn, ymx = np.min(self.y), np.max(self.y) + self._dataBounds = ((xmn, xmx), (ymn, ymx)) + else: - ValueError('Data must been sent as (z) or (x, y, z)') + raise ValueError('Data must been sent as (z) or (x, y, z)') - def setData(self, *args): + def setData(self, *args, **kwargs): """ Set the data to be drawn. @@ -203,7 +213,13 @@ def setData(self, *args): "ASCII from: ". + autoLevels: bool, optional, default True + When set to True, PColorMeshItem will automatically select levels + based on the minimum and maximum values encountered in the data along the z axis. + The minimum and maximum levels are mapped to the lowest and highest colors + in the colormap. The autoLevels parameter is ignored if ``enableAutoLevels is False`` """ + autoLevels = kwargs.get('autoLevels', True) # Has the view bounds changed shapeChanged = False @@ -216,8 +232,15 @@ def setData(self, *args): if np.any(self.x != args[0]) or np.any(self.y != args[1]): shapeChanged = True - # Prepare data - self._prepareData(args) + if len(args)==0: + # No data was received. + if self.z is None: + # No data is currently displayed, + # so other settings (like colormap) can not be updated + return + else: + # Got new data. Prepare it for plotting + self._prepareData(args) self.qpicture = QtGui.QPicture() @@ -226,18 +249,26 @@ def setData(self, *args): if self.edgecolors is None: painter.setPen(QtCore.Qt.PenStyle.NoPen) else: - painter.setPen(fn.mkPen(self.edgecolors)) + painter.setPen(self.edgecolors) if self.antialiasing: painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing) ## Prepare colormap # First we get the LookupTable - lut = self.lut_qbrush + lut = self.lut_qcolor # Second we associate each z value, that we normalize, to the lut scale = len(lut) - 1 - z_min = self.z.min() - z_max = self.z.max() + # Decide whether to autoscale the colormap or use the same levels as before + if (self.levels is None) or (self.enableautolevels and autoLevels): + # Autoscale colormap + z_min = self.z.min() + z_max = self.z.max() + self.setLevels( (z_min, z_max), update=False) + else: + # Use consistent colormap scaling + z_min = self.levels[0] + z_max = self.levels[1] rng = z_max - z_min if rng == 0: rng = 1 @@ -249,20 +280,23 @@ def setData(self, *args): else: drawConvexPolygon = painter.drawConvexPolygon - memory = self.quads.array(self.z.shape[1]) + self.quads.resize(self.z.shape[0], self.z.shape[1]) + memory = self.quads.ndarray() + memory[..., 0] = self.x.ravel() + memory[..., 1] = self.y.ravel() polys = self.quads.instances() - # Go through all the data and draw the polygons accordingly - for i in range(self.z.shape[0]): - # populate 2 rows of values into points - memory[..., 0] = self.x[i:i+2, :] - memory[..., 1] = self.y[i:i+2, :] - - brushes = [lut[z] for z in norm[i].tolist()] + # group indices of same coloridx together + color_indices, counts = np.unique(norm, return_counts=True) + sorted_indices = np.argsort(norm, axis=None) - for brush, poly in zip(brushes, polys): - painter.setBrush(brush) - drawConvexPolygon(poly) + offset = 0 + for coloridx, cnt in zip(color_indices, counts): + indices = sorted_indices[offset:offset+cnt] + offset += cnt + painter.setBrush(lut[coloridx]) + for idx in indices: + drawConvexPolygon(polys[idx]) painter.end() self.update() @@ -273,6 +307,68 @@ def setData(self, *args): + def _updateDisplayWithCurrentState(self, *args, **kargs): + ## Used for re-rendering mesh from self.z. + ## For example when a new colormap is applied, or the levels are adjusted + + defaults = { + 'autoLevels': False, + } + defaults.update(kargs) + return self.setData(*args, **defaults) + + + + def setLevels(self, levels, update=True): + """ + Sets color-scaling levels for the mesh. + + Parameters + ---------- + levels: tuple + ``(low, high)`` + sets the range for which values can be represented in the colormap. + update: bool, optional + Controls if mesh immediately updates to reflect the new color levels. + """ + self.levels = levels + self.sigLevelsChanged.emit(levels) + if update: + self._updateDisplayWithCurrentState() + + + + def getLevels(self): + """ + Returns a tuple containing the current level settings. See :func:`~setLevels`. + The format is ``(low, high)``. + """ + return self.levels + + + + def setLookupTable(self, lut, update=True): + self.lut_qcolor = lut[:] + if update: + self._updateDisplayWithCurrentState() + + + + def getColorMap(self): + return self.cmap + + + + def enableAutoLevels(self): + self.enableautolevels = True + + + + def disableAutoLevels(self): + self.enableautolevels = False + + + def paint(self, p, *args): if self.z is None: return @@ -280,7 +376,6 @@ def paint(self, p, *args): p.drawPicture(0, 0, self.qpicture) - def setBorder(self, b): self.border = fn.mkPen(b) self.update() @@ -288,21 +383,45 @@ def setBorder(self, b): def width(self): - if self.x is None: - return None - return np.max(self.x) - - + if self._dataBounds is None: + return 0 + bounds = self._dataBounds[0] + return bounds[1]-bounds[0] def height(self): - if self.y is None: - return None - return np.max(self.y) - - - + if self._dataBounds is None: + return 0 + bounds = self._dataBounds[1] + return bounds[1]-bounds[0] + + def dataBounds(self, ax, frac=1.0, orthoRange=None): + if self._dataBounds is None: + return (None, None) + return self._dataBounds[ax] + + def pixelPadding(self): + # pen is known to be cosmetic + pen = self.edgecolors + no_pen = (pen is None) or (pen.style() == QtCore.Qt.PenStyle.NoPen) + return 0 if no_pen else (pen.widthF() or 1) * 0.5 def boundingRect(self): - if self.qpicture is None: - return QtCore.QRectF(0., 0., 0., 0.) - return QtCore.QRectF(self.qpicture.boundingRect()) + xmn, xmx = self.dataBounds(ax=0) + if xmn is None or xmx is None: + return QtCore.QRectF() + ymn, ymx = self.dataBounds(ax=1) + if ymn is None or ymx is None: + return QtCore.QRectF() + + px = py = 0 + pxPad = self.pixelPadding() + if pxPad > 0: + # determine length of pixel in local x, y directions + px, py = self.pixelVectors() + px = 0 if px is None else px.length() + py = 0 if py is None else py.length() + # return bounds expanded by pixel size + px *= pxPad + py *= pxPad + + return QtCore.QRectF(xmn-px, ymn-py, (2*px)+xmx-xmn, (2*py)+ymx-ymn) diff --git a/pyqtgraph/graphicsItems/PlotCurveItem.py b/pyqtgraph/graphicsItems/PlotCurveItem.py index a762cd02fb..0e4b1276cb 100644 --- a/pyqtgraph/graphicsItems/PlotCurveItem.py +++ b/pyqtgraph/graphicsItems/PlotCurveItem.py @@ -1,7 +1,6 @@ from ..Qt import QtCore, QtGui, QtWidgets HAVE_OPENGL = hasattr(QtWidgets, 'QOpenGLWidget') -import itertools import math import sys import warnings @@ -16,132 +15,82 @@ __all__ = ['PlotCurveItem'] -def have_native_drawlines_array(): - size = 10 - line = QtCore.QLineF(0, 0, size, size) - qimg = QtGui.QImage(size, size, QtGui.QImage.Format.Format_RGB32) - qimg.fill(QtCore.Qt.GlobalColor.transparent) - painter = QtGui.QPainter(qimg) - painter.setPen(QtCore.Qt.GlobalColor.white) +def arrayToLineSegments(x, y, connect, finiteCheck, out=None): + if out is None: + out = Qt.internals.PrimitiveArray(QtCore.QLineF, 4) - try: - painter.drawLines(line, 1) - except TypeError: - success = False - else: - success = True - finally: - painter.end() + # analogue of arrayToQPath taking the same parameters + if len(x) < 2: + out.resize(0) + return out - return success + connect_array = None + if isinstance(connect, np.ndarray): + # the last element is not used + connect_array, connect = np.asarray(connect[:-1], dtype=bool), 'array' -_have_native_drawlines_array = Qt.QT_LIB.startswith('PySide') and have_native_drawlines_array() + all_finite = True + if finiteCheck or connect == 'finite': + mask = np.isfinite(x) & np.isfinite(y) + all_finite = np.all(mask) + if connect == 'all': + if not all_finite: + # remove non-finite points, if any + x = x[mask] + y = y[mask] -class LineSegments: - def __init__(self): - self.use_sip_array = ( - Qt.QT_LIB.startswith('PyQt') and - hasattr(Qt.sip, 'array') and - ( - (0x60301 <= QtCore.PYQT_VERSION) or - (0x50f07 <= QtCore.PYQT_VERSION < 0x60000) - ) - ) - self.use_native_drawlines = Qt.QT_LIB.startswith('PySide') and _have_native_drawlines_array - self.alloc(0) - - def alloc(self, size): - if self.use_sip_array: - self.objs = Qt.sip.array(QtCore.QLineF, size) - vp = Qt.sip.voidptr(self.objs, len(self.objs)*4*8) - self.arr = np.frombuffer(vp, dtype=np.float64).reshape((-1, 4)) - elif self.use_native_drawlines: - self.arr = np.empty((size, 4), dtype=np.float64) - self.objs = Qt.compat.wrapinstance(self.arr.ctypes.data, QtCore.QLineF) + elif connect == 'finite': + if all_finite: + connect = 'all' else: - self.arr = np.empty((size, 4), dtype=np.float64) - self.objs = list(map(Qt.compat.wrapinstance, - itertools.count(self.arr.ctypes.data, self.arr.strides[0]), - itertools.repeat(QtCore.QLineF, self.arr.shape[0]))) - - def get(self, size): - if size != self.arr.shape[0]: - self.alloc(size) - return self.objs, self.arr - - def arrayToLineSegments(self, x, y, connect, finiteCheck): - # analogue of arrayToQPath taking the same parameters - if len(x) < 2: - return [] + # each non-finite point affects the segment before and after + connect_array = mask[:-1] & mask[1:] + + elif connect in ['pairs', 'array']: + if not all_finite: + # replicate the behavior of arrayToQPath + backfill_idx = fn._compute_backfill_indices(mask) + x = x[backfill_idx] + y = y[backfill_idx] + + if connect == 'all': + nsegs = len(x) - 1 + out.resize(nsegs) + if nsegs: + memory = out.ndarray() + memory[:, 0] = x[:-1] + memory[:, 2] = x[1:] + memory[:, 1] = y[:-1] + memory[:, 3] = y[1:] + + elif connect == 'pairs': + nsegs = len(x) // 2 + out.resize(nsegs) + if nsegs: + memory = out.ndarray() + memory = memory.reshape((-1, 2)) + memory[:, 0] = x[:nsegs * 2] + memory[:, 1] = y[:nsegs * 2] + + elif connect_array is not None: + # the following are handled here + # - 'array' + # - 'finite' with non-finite elements + nsegs = np.count_nonzero(connect_array) + out.resize(nsegs) + if nsegs: + memory = out.ndarray() + memory[:, 0] = x[:-1][connect_array] + memory[:, 2] = x[1:][connect_array] + memory[:, 1] = y[:-1][connect_array] + memory[:, 3] = y[1:][connect_array] - connect_array = None - if isinstance(connect, np.ndarray): - # the last element is not used - connect_array, connect = np.asarray(connect[:-1], dtype=bool), 'array' - - all_finite = True - if finiteCheck or connect == 'finite': - mask = np.isfinite(x) & np.isfinite(y) - all_finite = np.all(mask) - - if connect == 'all': - if not all_finite: - # remove non-finite points, if any - x = x[mask] - y = y[mask] - - elif connect == 'finite': - if all_finite: - connect = 'all' - else: - # each non-finite point affects the segment before and after - connect_array = mask[:-1] & mask[1:] - - elif connect in ['pairs', 'array']: - if not all_finite: - # replicate the behavior of arrayToQPath - backfill_idx = fn._compute_backfill_indices(mask) - x = x[backfill_idx] - y = y[backfill_idx] - - segs = [] + else: nsegs = 0 + out.resize(nsegs) - if connect == 'all': - nsegs = len(x) - 1 - if nsegs: - segs, memory = self.get(nsegs) - memory[:, 0] = x[:-1] - memory[:, 2] = x[1:] - memory[:, 1] = y[:-1] - memory[:, 3] = y[1:] - - elif connect == 'pairs': - nsegs = len(x) // 2 - if nsegs: - segs, memory = self.get(nsegs) - memory = memory.reshape((-1, 2)) - memory[:, 0] = x[:nsegs * 2] - memory[:, 1] = y[:nsegs * 2] - - elif connect_array is not None: - # the following are handled here - # - 'array' - # - 'finite' with non-finite elements - nsegs = np.count_nonzero(connect_array) - if nsegs: - segs, memory = self.get(nsegs) - memory[:, 0] = x[:-1][connect_array] - memory[:, 2] = x[1:][connect_array] - memory[:, 1] = y[:-1][connect_array] - memory[:, 3] = y[1:][connect_array] - - if nsegs and self.use_native_drawlines: - return segs, nsegs - else: - return segs, - + return out class PlotCurveItem(GraphicsObject): """ @@ -282,6 +231,8 @@ def dataBounds(self, ax, frac=1.0, orthoRange=None): ## If an orthogonal range is specified, mask the data now if orthoRange is not None: mask = (d2 >= orthoRange[0]) * (d2 <= orthoRange[1]) + if self.opts.get("stepMode", None) == "center": + mask = mask[:-1] # len(y) == len(x) - 1 when stepMode is center d = d[mask] #d2 = d2[mask] @@ -295,13 +246,13 @@ def dataBounds(self, ax, frac=1.0, orthoRange=None): with warnings.catch_warnings(): # All-NaN data is acceptable; Explicit numpy warning is not needed. warnings.simplefilter("ignore") - b = (np.nanmin(d), np.nanmax(d)) + b = ( float(np.nanmin(d)), float(np.nanmax(d)) ) # enforce float format for bounds, even if data format is different if math.isinf(b[0]) or math.isinf(b[1]): mask = np.isfinite(d) d = d[mask] if len(d) == 0: return (None, None) - b = (d.min(), d.max()) + b = ( float(d.min()), float(d.max()) ) # enforce float format for bounds, even if data format is different elif frac <= 0.0: raise Exception("Value for parameter 'frac' must be > 0. (got %s)" % str(frac)) @@ -311,11 +262,14 @@ def dataBounds(self, ax, frac=1.0, orthoRange=None): d = d[mask] if len(d) == 0: return (None, None) - b = np.percentile(d, [50 * (1 - frac), 50 * (1 + frac)]) + b = np.percentile(d, [50 * (1 - frac), 50 * (1 + frac)]) # percentile result is always float64 or larger ## adjust for fill level if ax == 1 and self.opts['fillLevel'] not in [None, 'enclosed']: - b = (min(b[0], self.opts['fillLevel']), max(b[1], self.opts['fillLevel'])) + b = ( + float( min(b[0], self.opts['fillLevel']) ), + float( max(b[1], self.opts['fillLevel']) ) + ) # enforce float format for bounds, even if data format is different ## Add pen width only if it is non-cosmetic. pen = self.opts['pen'] @@ -584,7 +538,7 @@ def updateData(self, *args, **kargs): self.fillPath = None self._fillPathList = None self._mouseShape = None - self._renderSegmentList = None + self._lineSegmentsRendered = False if 'name' in kargs: self.opts['name'] = kargs['name'] @@ -714,10 +668,7 @@ def _shouldUseDrawLineSegments(self, pen): ) def _getLineSegments(self): - if not hasattr(self, '_lineSegments'): - self._lineSegments = LineSegments() - - if self._renderSegmentList is None: + if not self._lineSegmentsRendered: x, y = self.getData() if self.opts['stepMode']: x, y = self._generateStepModeData( @@ -727,14 +678,17 @@ def _getLineSegments(self): baseline=self.opts['fillLevel'] ) - self._renderSegmentList = self._lineSegments.arrayToLineSegments( + self._lineSegments = arrayToLineSegments( x, y, connect=self.opts['connect'], - finiteCheck=not self.opts['skipFiniteCheck'] + finiteCheck=not self.opts['skipFiniteCheck'], + out=self._lineSegments ) - return self._renderSegmentList + self._lineSegmentsRendered = True + + return self._lineSegments.drawargs() def _getClosingSegments(self): # this is only used for fillOutline @@ -999,7 +953,8 @@ def paintGL(self, p, opt, widget): def clear(self): self.xData = None ## raw values self.yData = None - self._renderSegmentList = None + self._lineSegments = None + self._lineSegmentsRendered = False self.path = None self.fillPath = None self._fillPathList = None diff --git a/pyqtgraph/graphicsItems/PlotDataItem.py b/pyqtgraph/graphicsItems/PlotDataItem.py index 15e87b12ee..47b9ebc7a2 100644 --- a/pyqtgraph/graphicsItems/PlotDataItem.py +++ b/pyqtgraph/graphicsItems/PlotDataItem.py @@ -1,4 +1,6 @@ +import math import warnings +import bisect import numpy as np @@ -26,7 +28,7 @@ class PlotDataset(object): For internal use in :class:`PlotDataItem `, this class should not be instantiated when no data is available. """ - def __init__(self, x, y): + def __init__(self, x, y, xAllFinite=None, yAllFinite=None): """ Parameters ---------- @@ -38,9 +40,22 @@ def __init__(self, x, y): super().__init__() self.x = x self.y = y + self.xAllFinite = xAllFinite + self.yAllFinite = yAllFinite self._dataRect = None - self.containsNonfinite = None - + + if isinstance(x, np.ndarray) and x.dtype.kind in 'iu': + self.xAllFinite = True + if isinstance(y, np.ndarray) and y.dtype.kind in 'iu': + self.yAllFinite = True + + @property + def containsNonfinite(self): + if self.xAllFinite is None or self.yAllFinite is None: + # don't know for sure yet + return None + return not (self.xAllFinite and self.yAllFinite) + def _updateDataRect(self): """ Finds bounds of plotable data and stores them as ``dataset._dataRect``, @@ -48,31 +63,27 @@ def _updateDataRect(self): """ if self.y is None or self.x is None: return None - if self.containsNonfinite is False: # all points are directly usable. - ymin = np.min( self.y ) # find minimum of all values - ymax = np.max( self.y ) # find maximum of all values - xmin = np.min( self.x ) # find minimum of all values - xmax = np.max( self.x ) # find maximum of all values - else: # This may contain NaN values and infinites. - selection = np.isfinite(self.y) # We are looking for the bounds of the plottable data set. Infinite and Nan are ignored. - all_y_are_finite = selection.all() # False if all values are finite, True if there are any non-finites - try: - ymin = np.min( self.y[selection] ) # find minimum of all finite values - ymax = np.max( self.y[selection] ) # find maximum of all finite values - except ValueError: # is raised when there are no finite values - ymin = np.nan - ymax = np.nan - selection = np.isfinite(self.x) # We are looking for the bounds of the plottable data set. Infinite and Nan are ignored. - all_x_are_finite = selection.all() # False if all values are finite, True if there are any non-finites - try: - xmin = np.min( self.x[selection] ) # find minimum of all finite values - xmax = np.max( self.x[selection] ) # find maximum of all finite values - except ValueError: # is raised when there are no finite values - xmin = np.nan - xmax = np.nan - self.containsNonfinite = not (all_x_are_finite and all_y_are_finite) # This always yields a definite True/False answer + xmin, xmax, self.xAllFinite = self._getArrayBounds(self.x, self.xAllFinite) + ymin, ymax, self.yAllFinite = self._getArrayBounds(self.y, self.yAllFinite) self._dataRect = QtCore.QRectF( QtCore.QPointF(xmin,ymin), QtCore.QPointF(xmax,ymax) ) - + + def _getArrayBounds(self, arr, all_finite): + # here all_finite could be [None, False, True] + if not all_finite: # This may contain NaN values and infinites. + selection = np.isfinite(arr) # We are looking for the bounds of the plottable data set. Infinite and Nan are ignored. + all_finite = selection.all() # True if all values are finite, False if there are any non-finites + if not all_finite: + arr = arr[selection] + # here all_finite could be [False, True] + + try: + amin = np.min( arr ) # find minimum of all finite values + amax = np.max( arr ) # find maximum of all finite values + except ValueError: # is raised when there are no finite values + amin = np.nan + amax = np.nan + return amin, amax, all_finite + def dataRect(self): """ Returns a bounding rectangle (as :class:`QtCore.QRectF`) for the finite subset of data. @@ -94,7 +105,6 @@ def applyLogMapping(self, logMode): logmode: tuple or list of two bool A `True` value requests log-scale mapping for the x and y axis (in this order). """ - all_x_finite = False if logMode[0]: with warnings.catch_warnings(): warnings.simplefilter("ignore", RuntimeWarning) @@ -102,10 +112,11 @@ def applyLogMapping(self, logMode): nonfinites = ~np.isfinite( self.x ) if nonfinites.any(): self.x[nonfinites] = np.nan # set all non-finite values to NaN - self.containsNonfinite = True + all_x_finite = False else: all_x_finite = True - all_y_finite = False + self.xAllFinite = all_x_finite + if logMode[1]: with warnings.catch_warnings(): warnings.simplefilter("ignore", RuntimeWarning) @@ -113,11 +124,10 @@ def applyLogMapping(self, logMode): nonfinites = ~np.isfinite( self.y ) if nonfinites.any(): self.y[nonfinites] = np.nan # set all non-finite values to NaN - self.containsNonfinite = True + all_y_finite = False else: all_y_finite = True - if all_x_finite and all_y_finite: - self.containsNonfinite = False # mark as False only if both axes were checked. + self.yAllFinite = all_y_finite class PlotDataItem(GraphicsObject): """ @@ -270,8 +280,8 @@ def __init__(self, *args, **kargs): values exist, unpredictable behavior will occur. The data may not be displayed or the plot may take a significant performance hit. - In the default 'auto' connect mode, `PlotDataItem` will apply this - setting automatically. + In the default 'auto' connect mode, `PlotDataItem` will automatically + override this setting. ================= ======================================================================= **Meta-info keyword arguments:** @@ -437,6 +447,7 @@ def setLogMode(self, xState, yState): self.opts['logMode'] = [xState, yState] self._datasetMapped = None # invalidate mapped data self._datasetDisplay = None # invalidate display data + self._adsLastValue = 1 # reset auto-downsample value self.updateItems(styleUpdate=False) self.informViewBoundsChanged() @@ -451,6 +462,7 @@ def setDerivativeMode(self, state): self.opts['derivativeMode'] = state self._datasetMapped = None # invalidate mapped data self._datasetDisplay = None # invalidate display data + self._adsLastValue = 1 # reset auto-downsample value self.updateItems(styleUpdate=False) self.informViewBoundsChanged() @@ -466,20 +478,10 @@ def setPhasemapMode(self, state): self.opts['phasemapMode'] = state self._datasetMapped = None # invalidate mapped data self._datasetDisplay = None # invalidate display data + self._adsLastValue = 1 # reset auto-downsample value self.updateItems(styleUpdate=False) self.informViewBoundsChanged() - def setPointMode(self, state): - # This does not seem to do anything, but PlotItem still seems to call it. - # warnings.warn( - # 'setPointMode has been deprecated, and has no effect. It will be removed from the library in the first release following April, 2022.', - # DeprecationWarning, stacklevel=2 - # ) - if self.opts['pointMode'] == state: - return - self.opts['pointMode'] = state - self.update() - def setPen(self, *args, **kargs): """ Sets the pen used to draw lines between points. @@ -515,7 +517,7 @@ def setShadowPen(self, *args, **kargs): def setFillBrush(self, *args, **kargs): """ Sets the :class:`QtGui.QBrush` used to fill the area under the curve. - See :func:`mkBrush() `) for arguments. + See :func:`mkBrush() `) for arguments. """ if args[0] is None: brush = None @@ -528,7 +530,7 @@ def setFillBrush(self, *args, **kargs): def setBrush(self, *args, **kargs): """ - See :func:`setFillBrush() `) for arguments. + See :func:`mkPen() `) for arguments. """ pen = fn.mkPen(*args, **kargs) if self.opts['symbolPen'] == pen: @@ -568,7 +570,7 @@ def setSymbolPen(self, *args, **kargs): def setSymbolBrush(self, *args, **kargs): """ Sets the :class:`QtGui.QBrush` used to fill symbols. - See :func:`mkBrush() `) for arguments. + See :func:`mkBrush() `) for arguments. """ brush = fn.mkBrush(*args, **kargs) if self.opts['symbolBrush'] == brush: @@ -623,6 +625,7 @@ def setDownsampling(self, ds=None, auto=None, method=None): if changed: self._datasetMapped = None # invalidata mapped data self._datasetDisplay = None # invalidate display data + self._adsLastValue = 1 # reset auto-downsample value self.updateItems(styleUpdate=False) def setClipToView(self, state): @@ -717,7 +720,7 @@ def setData(self, *args, **kargs): x = np.array(data['x']) if 'y' in data: y = np.array(data['y']) - elif dt == 'listOfDicts': + elif dt == 'listOfDicts': if 'x' in data[0]: x = np.array([d.get('x',None) for d in data]) if 'y' in data[0]: @@ -729,13 +732,13 @@ def setData(self, *args, **kargs): y = data.view(np.ndarray) x = data.xvals(0).view(np.ndarray) else: - raise Exception('Invalid data type %s' % type(data)) + raise TypeError('Invalid data type %s' % type(data)) elif len(args) == 2: seq = ('listOfValues', 'MetaArray', 'empty') dtyp = dataType(args[0]), dataType(args[1]) if dtyp[0] not in seq or dtyp[1] not in seq: - raise Exception('When passing two unnamed arguments, both must be a list or array of values. (got %s, %s)' % (str(type(args[0])), str(type(args[1])))) + raise TypeError('When passing two unnamed arguments, both must be a list or array of values. (got %s, %s)' % (str(type(args[0])), str(type(args[1])))) if not isinstance(args[0], np.ndarray): #x = np.array(args[0]) if dtyp[0] == 'MetaArray': @@ -821,8 +824,9 @@ def setData(self, *args, **kargs): self._dataset = None else: self._dataset = PlotDataset( xData, yData ) - self._datasetMapped = None # invalidata mapped data , will be generated in getData() / getDisplayDataset() - self._datasetDisplay = None # invalidate display data, will be generated in getData() / getDisplayDataset() + self._datasetMapped = None # invalidata mapped data , will be generated in getData() / _getDisplayDataset() + self._datasetDisplay = None # invalidate display data, will be generated in getData() / _getDisplayDataset() + self._adsLastValue = 1 # reset auto-downsample value profiler('set data') @@ -872,7 +876,7 @@ def updateItems(self, styleUpdate=True): if k in self.opts: scatterArgs[v] = self.opts[k] - dataset = self.getDisplayDataset() + dataset = self._getDisplayDataset() if dataset is None: # then we have nothing to show self.curve.hide() self.scatter.hide() @@ -887,14 +891,17 @@ def updateItems(self, styleUpdate=True): ): # draw if visible... # auto-switch to indicate non-finite values as interruptions in the curve: if isinstance(curveArgs['connect'], str) and curveArgs['connect'] == 'auto': # connect can also take a boolean array - if dataset.containsNonfinite is None: - curveArgs['connect'] = 'all' # this is faster, but silently connects the curve across any non-finite values - else: - if dataset.containsNonfinite: - curveArgs['connect'] = 'finite' - else: - curveArgs['connect'] = 'all' # all points can be connected, and no further check is needed. - curveArgs['skipFiniteCheck'] = True + if dataset.containsNonfinite is False: + # all points can be connected, and no further check is needed. + curveArgs['connect'] = 'all' + curveArgs['skipFiniteCheck'] = True + else: # True or None + # True: (we checked and found non-finites) + # don't connect non-finites + # None: (we haven't performed a check for non-finites yet) + # use connect='finite' in case there are non-finites. + curveArgs['connect'] = 'finite' + curveArgs['skipFiniteCheck'] = False self.curve.setData(x=x, y=y, **curveArgs) self.curve.show() else: # ...hide if not. @@ -918,9 +925,9 @@ def getOriginalDataset(self): return (None, None) return dataset.x, dataset.y - def getDisplayDataset(self): + def _getDisplayDataset(self): """ - Returns a :class:`PlotDataset ` object that contains data suitable for display + Returns a :class:`~.PlotDataset` object that contains data suitable for display (after mapping and data reduction) as ``dataset.x`` and ``dataset.y``. Intended for internal use. """ @@ -957,8 +964,7 @@ def getDisplayDataset(self): x = self._dataset.y[:-1] y = np.diff(self._dataset.y)/np.diff(self._dataset.x) - dataset = PlotDataset(x,y) - dataset.containsNonfinite = self._dataset.containsNonfinite + dataset = PlotDataset(x, y, self._dataset.xAllFinite, self._dataset.yAllFinite) if True in self.opts['logMode']: dataset.applyLogMapping( self.opts['logMode'] ) # Apply log scaling for x and/or y axis @@ -968,7 +974,8 @@ def getDisplayDataset(self): # apply processing that affects the on-screen display of data: x = self._datasetMapped.x y = self._datasetMapped.y - containsNonfinite = self._datasetMapped.containsNonfinite + xAllFinite = self._datasetMapped.xAllFinite + yAllFinite = self._datasetMapped.yAllFinite view = self.getViewBox() if view is None: @@ -984,16 +991,30 @@ def getDisplayDataset(self): if self.opts['autoDownsample']: # this option presumes that x-values have uniform spacing - if view_range is not None and len(x) > 1: - dx = float(x[-1]-x[0]) / (len(x)-1) + + if xAllFinite: + finite_x = x + else: + # False: (we checked and found non-finites) + # None : (we haven't performed a check for non-finites yet) + finite_x = x[np.isfinite(x)] # ignore infinite and nan values + + if view_range is not None and len(finite_x) > 1: + dx = float(finite_x[-1]-finite_x[0]) / (len(finite_x)-1) if dx != 0.0: - x0 = (view_range.left()-x[0]) / dx - x1 = (view_range.right()-x[0]) / dx width = self.getViewBox().width() - if width != 0.0: - ds = int(max(1, int((x1-x0) / (width*self.opts['autoDownsampleFactor'])))) + if width != 0.0: # autoDownsampleFactor _should_ be > 1.0 + ds_float = max(1.0, abs(view_range.width() / dx / (width * self.opts['autoDownsampleFactor']))) + if math.isfinite(ds_float): + ds = int(ds_float) ## downsampling is expensive; delay until after clipping. + # use the last computed value if our new value is not too different. + # this guards against an infinite cycle where the plot never stabilizes. + if math.isclose(ds, self._adsLastValue, rel_tol=0.01): + ds = self._adsLastValue + self._adsLastValue = ds + if self.opts['clipToView']: if view is None or view.autoRangeEnabled()[0]: pass # no ViewBox to clip to, or view will autoscale to data range. @@ -1003,12 +1024,14 @@ def getDisplayDataset(self): # find first in-view value (left edge) and first out-of-view value (right edge) # since we want the curve to go to the edge of the screen, we need to preserve # one down-sampled point on the left and one of the right, so we extend the interval - x0 = np.searchsorted(x, view_range.left()) - ds + x0 = bisect.bisect_left(x, view_range.left()) - ds x0 = fn.clip_scalar(x0, 0, len(x)) # workaround + # x0 = np.searchsorted(x, view_range.left()) - ds # x0 = np.clip(x0, 0, len(x)) - x1 = np.searchsorted(x, view_range.right()) + ds + x1 = bisect.bisect_left(x, view_range.right()) + ds x1 = fn.clip_scalar(x1, x0, len(x)) + # x1 = np.searchsorted(x, view_range.right()) + ds # x1 = np.clip(x1, 0, len(x)) x = x[x0:x1] y = y[x0:x1] @@ -1069,8 +1092,7 @@ def getDisplayDataset(self): # y = np.clip(y, a_min=min_val, a_max=max_val) y = fn.clip_array(y, min_val, max_val) self._drlLastClip = (min_val, max_val) - self._datasetDisplay = PlotDataset( x,y ) - self._datasetDisplay.containsNonfinite = containsNonfinite + self._datasetDisplay = PlotDataset(x, y, xAllFinite, yAllFinite) self.setProperty('xViewRangeWasChanged', False) self.setProperty('yViewRangeWasChanged', False) @@ -1080,7 +1102,7 @@ def getData(self): """ Returns the displayed data as the tuple (`xData`, `yData`) after mapping and data reduction. """ - dataset = self.getDisplayDataset() + dataset = self._getDisplayDataset() if dataset is None: return (None, None) return dataset.x, dataset.y @@ -1088,7 +1110,7 @@ def getData(self): # compatbility method for access to dataRect for full dataset: def dataRect(self): """ - Returns a bounding rectangle (as :class:`QtGui.QRectF`) for the full set of data. + Returns a bounding rectangle (as :class:`QtCore.QRectF`) for the full set of data. Will return `None` if there is no data or if all values (x or y) are NaN. """ if self._dataset is None: @@ -1222,7 +1244,7 @@ def dataType(obj): elif obj.ndim == 2 and obj.dtype.names is None and obj.shape[1] == 2: return 'Nx2array' else: - raise Exception('array shape must be (N,) or (N,2); got %s instead' % str(obj.shape)) + raise ValueError('array shape must be (N,) or (N,2); got %s instead' % str(obj.shape)) elif isinstance(first, dict): return 'listOfDicts' else: diff --git a/pyqtgraph/graphicsItems/PlotItem/PlotItem.py b/pyqtgraph/graphicsItems/PlotItem/PlotItem.py index 5c641812ba..5c9decb523 100644 --- a/pyqtgraph/graphicsItems/PlotItem/PlotItem.py +++ b/pyqtgraph/graphicsItems/PlotItem/PlotItem.py @@ -21,7 +21,6 @@ from ..ScatterPlotItem import ScatterPlotItem from ..ViewBox import ViewBox - translate = QtCore.QCoreApplication.translate from . import plotConfigTemplate_generic as ui_template @@ -36,7 +35,7 @@ class PlotItem(GraphicsWidget): This class provides the ViewBox-plus-axes that appear when using :func:`pg.plot() `, :class:`PlotWidget `, - and :func:`GraphicsLayoutWidget.addPlot() `. + and :func:`GraphicsLayout.addPlot() `. It's main functionality is: @@ -197,17 +196,13 @@ def __init__(self, parent=None, name=None, labels=None, title=None, viewBox=None ] - self.ctrlMenu = QtWidgets.QMenu() - - self.ctrlMenu.setTitle(translate("PlotItem", 'Plot Options')) - self.subMenus = [] + self.ctrlMenu = QtWidgets.QMenu(translate("PlotItem", 'Plot Options')) + for name, grp in menuItems: - sm = QtWidgets.QMenu(name) + sm = self.ctrlMenu.addMenu(name) act = QtWidgets.QWidgetAction(self) act.setDefaultWidget(grp) sm.addAction(act) - self.subMenus.append(sm) - self.ctrlMenu.addMenu(sm) self.stateGroup = WidgetGroup() for name, w in menuItems: @@ -502,23 +497,12 @@ def autoBtnClicked(self): def viewStateChanged(self): self.updateButtons() - - def enableAutoScale(self): - """ - Enable auto-scaling. The plot will continuously scale to fit the boundaries of its data. - """ - warnings.warn( - 'PlotItem.enableAutoScale is deprecated, and will be removed in 0.13' - 'Use PlotItem.enableAutoRange(axis, enable) instead', - DeprecationWarning, stacklevel=2 - ) - self.vb.enableAutoRange(self.vb.XYAxes) def addItem(self, item, *args, **kargs): """ Add a graphics item to the view box. - If the item has plot data (:class:`~pyqtgrpah.PlotDataItem`, - :class:`~pyqtgraph.PlotCurveItem`, :class:`~pyqtgraph.ScatterPlotItem`), + If the item has plot data (:class:`PlotDataItem ` , + :class:`~pyqtgraph.PlotCurveItem` , :class:`~pyqtgraph.ScatterPlotItem` ), it may be included in analysis performed by the PlotItem. """ if item in self.items: @@ -551,7 +535,6 @@ def addItem(self, item, *args, **kargs): item.setFftMode(self.ctrl.fftCheck.isChecked()) item.setDownsampling(*self.downsampleMode()) item.setClipToView(self.clipToViewMode()) - item.setPointMode(self.pointMode()) ## Hide older plots if needed self.updateDecimation() @@ -568,27 +551,11 @@ def addItem(self, item, *args, **kargs): if name is not None and hasattr(self, 'legend') and self.legend is not None: self.legend.addItem(item, name=name) - def addDataItem(self, item, *args): - warnings.warn( - 'PlotItem.addDataItem is deprecated and will be removed in 0.13. ' - 'Use PlotItem.addItem instead', - DeprecationWarning, stacklevel=2 - ) - self.addItem(item, *args) - def listDataItems(self): - """Return a list of all data items (:class:`~pyqtgrpah.PlotDataItem`, - :class:`~pyqtgraph.PlotCurveItem`, :class:`~pyqtgraph.ScatterPlotItem`, etc) + """Return a list of all data items (:class:`PlotDataItem `, + :class:`~pyqtgraph.PlotCurveItem` , :class:`~pyqtgraph.ScatterPlotItem` , etc) contained in this PlotItem.""" return self.dataItems[:] - - def addCurve(self, c, params=None): - warnings.warn( - 'PlotItem.addCurve is deprecated and will be removed in 0.13. ' - 'Use PlotItem.addItem instead.', - DeprecationWarning, stacklevel=2 - ) - self.addItem(c, params) def addLine(self, x=None, y=None, z=None, **kwds): """ @@ -674,7 +641,7 @@ def addLegend(self, offset=(30, 30), **kwargs): :class:`~pyqtgraph.ViewBox`. Plots added after this will be automatically displayed in the legend if they are created with a 'name' argument. - If a :class:`~pyqtGraph.LegendItem` has already been created using this method, + If a :class:`~pyqtgraph.LegendItem` has already been created using this method, that item will be returned rather than creating a new one. Accepts the same arguments as :func:`~pyqtgraph.LegendItem.__init__`. @@ -693,11 +660,77 @@ def addColorBar(self, image, **kargs): A call like `plot.addColorBar(img, colorMap='viridis')` is a convenient method to assign and show a color map. """ - from ..ColorBarItem import ColorBarItem # avoid circular import + from ..ColorBarItem import ColorBarItem # avoid circular import bar = ColorBarItem(**kargs) bar.setImageItem( image, insert_in=self ) return bar + def multiDataPlot(self, *, x=None, y=None, constKwargs=None, **kwargs): + """ + Allow plotting multiple curves on the same plot, changing some kwargs + per curve. + + Parameters + ---------- + x, y: array_like + can be in the following formats: + - {x or y} = [n1, n2, n3, ...]: The named argument iterates through + ``n`` curves, while the unspecified argument is range(len(n)) for + each curve. + - x, [y1, y2, y3, ...] + - [x1, x2, x3, ...], [y1, y2, y3, ...] + - [x1, x2, x3, ...], y + + where ``x_n`` and ``y_n`` are ``ndarray`` data for each curve. Since + ``x`` and ``y`` values are matched using ``zip``, unequal lengths mean + the longer array will be truncated. Note that 2D matrices for either x + or y are considered lists of curve + data. + constKwargs: dict, optional + A dict of {str: value} passed to each curve during ``plot()``. + kwargs: dict, optional + A dict of {str: iterable} where the str is the name of a kwarg and the + iterable is a list of values, one for each plotted curve. + """ + if (x is not None and not len(x)) or (y is not None and not len(y)): + # Nothing to plot -- either x or y array will bail out early from + # zip() below. + return [] + def scalarOrNone(val): + return val is None or (len(val) and np.isscalar(val[0])) + + if scalarOrNone(x) and scalarOrNone(y): + raise ValueError( + "If both `x` and `y` represent single curves, use `plot` instead " + "of `multiPlot`." + ) + curves = [] + constKwargs = constKwargs or {} + xy: 'dict[str, list | None]' = dict(x=x, y=y) + for key, oppositeVal in zip(('x', 'y'), [y, x]): + oppositeVal: 'Iterable | None' + val = xy[key] + if val is None: + # Other curve has all data, make range that supports longest chain + val = range(max(len(curveN) for curveN in oppositeVal)) + if np.isscalar(val[0]): + # x, [y1, y2, y3, ...] or [x1, x2, x3, ...], y + # Repeat the single curve to match length of opposite list + val = [val] * len(oppositeVal) + xy[key] = val + for ii, (xi, yi) in enumerate(zip(xy['x'], xy['y'])): + for kk in kwargs: + if len(kwargs[kk]) <= ii: + raise ValueError( + f"Not enough values for kwarg `{kk}`. " + f"Expected {ii + 1:d} (number of curves to plot), got" + f" {len(kwargs[kk]):d}" + ) + kwargsi = {kk: kwargs[kk][ii] for kk in kwargs} + constKwargs.update(kwargsi) + curves.append(self.plot(xi, yi, **constKwargs)) + return curves + def scatterPlot(self, *args, **kargs): if 'pen' in kargs: kargs['symbolPen'] = kargs['pen'] @@ -738,88 +771,6 @@ def updateParamList(self): self.paramList[p] = (i.checkState() == QtCore.Qt.CheckState.Checked) - def writeSvgCurves(self, fileName=None): - if fileName is None: - self._chooseFilenameDialog(handler=self.writeSvg) - return - - if isinstance(fileName, tuple): - raise Exception("Not implemented yet..") - fileName = str(fileName) - PlotItem.lastFileDir = os.path.dirname(fileName) - - rect = self.vb.viewRect() - xRange = rect.left(), rect.right() - - dx = max(rect.right(),0) - min(rect.left(),0) - ymn = min(rect.top(), rect.bottom()) - ymx = max(rect.top(), rect.bottom()) - dy = max(ymx,0) - min(ymn,0) - sx = 1. - sy = 1. - while dx*sx < 10: - sx *= 1000 - while dy*sy < 10: - sy *= 1000 - sy *= -1 - - with open(fileName, 'w') as fh: - # fh.write('\n' % (rect.left() * sx, - # rect.top() * sx, - # rect.width() * sy, - # rect.height()*sy)) - fh.write('\n') - fh.write('\n' % ( - rect.left() * sx, rect.right() * sx)) - fh.write('\n' % ( - rect.top() * sy, rect.bottom() * sy)) - - for item in self.curves: - if isinstance(item, PlotCurveItem): - color = item.pen.color() - hrrggbb, opacity = color.name(), color.alphaF() - x, y = item.getData() - mask = (x > xRange[0]) * (x < xRange[1]) - mask[:-1] += mask[1:] - m2 = mask.copy() - mask[1:] += m2[:-1] - x = x[mask] - y = y[mask] - - x *= sx - y *= sy - - # fh.write('\n' % ( - # color, )) - fh.write('') - # fh.write("") - - for item in self.dataItems: - if isinstance(item, ScatterPlotItem): - for point in item.points(): - pos = point.pos() - if not rect.contains(pos): - continue - color = point.brush.color() - hrrggbb, opacity = color.name(), color.alphaF() - x = pos.x() * sx - y = pos.y() * sy - - fh.write('\n' % ( - x, y, hrrggbb, opacity)) - - fh.write("\n") - def writeSvg(self, fileName=None): if fileName is None: self._chooseFilenameDialog(handler=self.writeSvg) @@ -1015,7 +966,7 @@ def downsampleMode(self): return ds, auto, method def setClipToView(self, clip): - """Set the default clip-to-view mode for all :class:`~pyqtgraph.PlotDataItem`s managed by this plot. + """Set the default clip-to-view mode for all :class:`~pyqtgraph.PlotDataItem` s managed by this plot. If *clip* is `True`, then PlotDataItems will attempt to draw only points within the visible range of the ViewBox.""" self.ctrl.clipToViewCheck.setChecked(clip) @@ -1116,6 +1067,26 @@ def setMenuEnabled(self, enableMenu=True, enableViewBoxMenu='same'): def menuEnabled(self): return self._menuEnabled + + def setContextMenuActionVisible(self, name : str, visible : bool) -> None: + """ + Changes the context menu action visibility + + Parameters + ---------- + name: str + Action name + must be one of 'Transforms', 'Downsample', 'Average','Alpha', 'Grid', or 'Points' + visible: bool + Determines if action will be display + True action is visible + False action is invisible. + """ + translated_name = translate("PlotItem", name) + for action in self.ctrlMenu.actions(): + if action.text() == translated_name: + action.setVisible(visible) + break def hoverEvent(self, ev): if ev.enter: @@ -1215,21 +1186,24 @@ def showAxes(self, selection, showValues=True, size=False): Parameters ---------- - selection: boolean or tuple of booleans (left, top, right, bottom) + selection: bool or tuple of bool Determines which AxisItems will be displayed. + If in tuple form, order is (left, top, right, bottom) A single boolean value will set all axes, so that ``showAxes(True)`` configures the axes to draw a frame. - showValues: optional, boolean or tuple of booleans (left, top, right, bottom) + showValues: bool or tuple of bool, optional Determines if values will be displayed for the ticks of each axis. True value shows values for left and bottom axis (default). False shows no values. + If in tuple form, order is (left, top, right, bottom) None leaves settings unchanged. If not specified, left and bottom axes will be drawn with values. - size: optional, float or tuple of floats (width, height) + size: float or tuple of float, optional Reserves as fixed amount of space (width for vertical axis, height for horizontal axis) for each axis where tick values are enabled. If only a single float value is given, it will be applied for both width and height. If `None` is given instead of a float value, the axis reverts to automatic allocation of space. + If in tuple form, order is (width, height) """ if selection is True: # shortcut: enable all axes, creating a frame selection = (True, True, True, True) @@ -1264,15 +1238,7 @@ def showAxes(self, selection, showValues=True, size=False): elif axis_key in ('top', 'bottom'): if show_value: ax.setHeight(size[1]) else : ax.setHeight( None ) - - def showScale(self, *args, **kargs): - warnings.warn( - 'PlotItem.showScale has been deprecated and will be removed in 0.13. ' - 'Use PlotItem.showAxis() instead', - DeprecationWarning, stacklevel=2 - ) - return self.showAxis(*args, **kargs) - + def hideButtons(self): """Causes auto-scale button ('A' in lower-left corner) to be hidden for this PlotItem""" #self.ctrlBtn.hide() diff --git a/pyqtgraph/graphicsItems/ROI.py b/pyqtgraph/graphicsItems/ROI.py index 76bb40c466..39fb143298 100644 --- a/pyqtgraph/graphicsItems/ROI.py +++ b/pyqtgraph/graphicsItems/ROI.py @@ -12,7 +12,6 @@ """ import sys -import warnings from math import atan2, cos, degrees, hypot, sin import numpy as np @@ -30,7 +29,7 @@ __all__ = [ 'ROI', - 'TestROI', 'RectROI', 'EllipseROI', 'CircleROI', 'PolygonROI', + 'TestROI', 'RectROI', 'EllipseROI', 'CircleROI', 'LineROI', 'MultiLineROI', 'MultiRectROI', 'LineSegmentROI', 'PolyLineROI', 'CrosshairROI','TriangleROI' ] @@ -1543,7 +1542,13 @@ def mouseDragEvent(self, ev): if ev.isStart(): if ev.button() == QtCore.Qt.MouseButton.LeftButton: roi.setSelected(True) - mods = ev.modifiers() & ~self.snapModifier + mods = ev.modifiers() + try: + mods &= ~self.snapModifier + except ValueError: + # workaround bug in Python 3.11.4 that affects PyQt + if mods & self.snapModifier: + mods ^= self.snapModifier if roi.translatable and mods == self.translateModifier: self.dragMode = 'translate' elif roi.rotatable and mods == self.rotateModifier: @@ -1935,54 +1940,6 @@ def _addHandles(self): self.addScaleHandle([0.5*2.**-0.5 + 0.5, 0.5*2.**-0.5 + 0.5], [0.5, 0.5]) -class PolygonROI(ROI): - - def __init__(self, positions, pos=None, **args): - warnings.warn( - 'PolygonROI has been deprecated, will be removed in 0.13' - 'use PolyLineROI instead', - DeprecationWarning, stacklevel=2 - ) - - if pos is None: - pos = [0,0] - ROI.__init__(self, pos, [1,1], **args) - for p in positions: - self.addFreeHandle(p) - self.setZValue(1000) - - def listPoints(self): - return [p['item'].pos() for p in self.handles] - - def paint(self, p, *args): - p.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing) - p.setPen(self.currentPen) - for i in range(len(self.handles)): - h1 = self.handles[i]['item'].pos() - h2 = self.handles[i-1]['item'].pos() - p.drawLine(h1, h2) - - def boundingRect(self): - r = QtCore.QRectF() - for h in self.handles: - r |= self.mapFromItem(h['item'], h['item'].boundingRect()).boundingRect() ## |= gives the union of the two QRectFs - return r - - def shape(self): - p = QtGui.QPainterPath() - p.moveTo(self.handles[0]['item'].pos()) - for i in range(len(self.handles)): - p.lineTo(self.handles[i]['item'].pos()) - return p - - def stateCopy(self): - sc = {} - sc['pos'] = Point(self.state['pos']) - sc['size'] = Point(self.state['size']) - sc['angle'] = self.state['angle'] - return sc - - class PolyLineROI(ROI): r""" Container class for multiple connected LineSegmentROIs. diff --git a/pyqtgraph/graphicsItems/ScaleBar.py b/pyqtgraph/graphicsItems/ScaleBar.py index 3aa953e6c8..eb940962f0 100644 --- a/pyqtgraph/graphicsItems/ScaleBar.py +++ b/pyqtgraph/graphicsItems/ScaleBar.py @@ -2,13 +2,13 @@ from .. import getConfigOption from ..Point import Point from ..Qt import QtCore, QtWidgets -from .GraphicsObject import * -from .GraphicsWidgetAnchor import * +from .GraphicsObject import GraphicsObject +from .GraphicsWidgetAnchor import GraphicsWidgetAnchor from .TextItem import TextItem __all__ = ['ScaleBar'] -class ScaleBar(GraphicsObject, GraphicsWidgetAnchor): +class ScaleBar(GraphicsWidgetAnchor, GraphicsObject): """ Displays a rectangular bar to indicate the relative scale of objects on the view. """ @@ -36,7 +36,7 @@ def __init__(self, size, width=5, brush=None, pen=None, suffix='m', offset=None) self.text = TextItem(text=fn.siFormat(size, suffix=suffix), anchor=(0.5,1)) self.text.setParentItem(self) - def parentChanged(self): + def changeParent(self): view = self.parentItem() if view is None: return diff --git a/pyqtgraph/graphicsItems/ScatterPlotItem.py b/pyqtgraph/graphicsItems/ScatterPlotItem.py index 0168a2fb44..0a19882b5b 100644 --- a/pyqtgraph/graphicsItems/ScatterPlotItem.py +++ b/pyqtgraph/graphicsItems/ScatterPlotItem.py @@ -1,6 +1,5 @@ import itertools import math -import warnings import weakref from collections import OrderedDict @@ -10,7 +9,7 @@ from .. import functions as fn from .. import getConfigOption from ..Point import Point -from ..Qt import QT_LIB, QtCore, QtGui +from ..Qt import QtCore, QtGui from .GraphicsObject import GraphicsObject __all__ = ['ScatterPlotItem', 'SpotItem'] @@ -109,15 +108,6 @@ def renderSymbol(symbol, size, pen, brush, device=None): p.end() return device -def makeSymbolPixmap(size, pen, brush, symbol): - warnings.warn( - "This is an internal function that is no longer being used. " - "Will be removed in 0.13", - DeprecationWarning, stacklevel=2 - ) - img = renderSymbol(symbol, size, pen, brush) - return QtGui.QPixmap(img) - def _mkPen(*args, **kwargs): """ @@ -143,57 +133,6 @@ def _mkBrush(*args, **kwargs): return fn.mkBrush(*args, **kwargs) -class PixmapFragments: - def __init__(self): - self.use_sip_array = ( - Qt.QT_LIB.startswith('PyQt') and - hasattr(Qt.sip, 'array') and - ( - (0x60301 <= QtCore.PYQT_VERSION) or - (0x50f07 <= QtCore.PYQT_VERSION < 0x60000) - ) - ) - self.alloc(0) - - def alloc(self, size): - # The C++ native API is: - # drawPixmapFragments(const PixmapFragment *fragments, int fragmentCount, - # const QPixmap &pixmap) - # - # PySide exposes this API whereas PyQt wraps it to be more Pythonic. - # In PyQt, a Python list of PixmapFragment instances needs to be provided. - # This is inefficient because: - # 1) constructing the Python list involves calling sip.wrapinstance multiple times. - # - this is mitigated here by reusing the instance pointers - # 2) PyQt will anyway deconstruct the Python list and repack the PixmapFragment - # instances into a contiguous array, in order to call the underlying C++ native API. - if self.use_sip_array: - self.objs = Qt.sip.array(QtGui.QPainter.PixmapFragment, size) - vp = Qt.sip.voidptr(self.objs, len(self.objs)*10*8) - self.arr = np.frombuffer(vp, dtype=np.float64).reshape((-1, 10)) - else: - self.arr = np.empty((size, 10), dtype=np.float64) - if QT_LIB.startswith('PyQt'): - self.objs = list(map(Qt.sip.wrapinstance, - itertools.count(self.arr.ctypes.data, self.arr.strides[0]), - itertools.repeat(QtGui.QPainter.PixmapFragment, self.arr.shape[0]))) - else: - self.objs = Qt.shiboken.wrapInstance(self.arr.ctypes.data, QtGui.QPainter.PixmapFragment) - - def array(self, size): - if size != self.arr.shape[0]: - self.alloc(size) - return self.arr - - def draw(self, painter, pixmap): - if not len(self.arr): - return - if QT_LIB.startswith('PyQt'): - painter.drawPixmapFragments(self.objs, pixmap) - else: - painter.drawPixmapFragments(self.objs, len(self.arr), pixmap) - - class SymbolAtlas(object): """ Used to efficiently construct a single QPixmap containing all rendered symbols @@ -437,7 +376,7 @@ def __init__(self, *args, **kargs): self.bounds = [None, None] ## caches data bounds self._maxSpotWidth = 0 ## maximum size of the scale-variant portion of all spots self._maxSpotPxWidth = 0 ## maximum size of the scale-invariant portion of all spots - self._pixmapFragments = PixmapFragments() + self._pixmapFragments = Qt.internals.PrimitiveArray(QtGui.QPainter.PixmapFragment, 10) self.opts = { 'pxMode': True, 'useCache': True, ## If useCache is False, symbols are re-drawn on every paint. @@ -512,12 +451,6 @@ def setData(self, *args, **kargs): generating LegendItem entries and by some exporters. ====================== =============================================================================================== """ - if 'identical' in kargs: - warnings.warn( - "The *identical* functionality is handled automatically now. " - "Will be removed in 0.13.", - DeprecationWarning, stacklevel=2 - ) oldData = self.data ## this causes cached pixmaps to be preserved while new data is registered. self.clear() ## clear out all old data self.addPoints(*args, **kargs) @@ -651,14 +584,6 @@ def invalidate(self): def getData(self): return self.data['x'], self.data['y'] - def setPoints(self, *args, **kargs): - warnings.warn( - "ScatterPlotItem.setPoints is deprecated, use ScatterPlotItem.setData " - "instead. Will be removed in 0.13", - DeprecationWarning, stacklevel=2 - ) - return self.setData(*args, **kargs) - def implements(self, interface=None): ints = ['plotData'] if interface is None: @@ -909,58 +834,6 @@ def _measureSpotSizes(self, **kwargs): else: yield size + pen.widthF(), 0 - def getSpotOpts(self, recs, scale=1.0): - warnings.warn( - "This is an internal method that is no longer being used. Will be " - "removed in 0.13", - DeprecationWarning, stacklevel=2 - ) - if recs.ndim == 0: - rec = recs - symbol = rec['symbol'] - if symbol is None: - symbol = self.opts['symbol'] - size = rec['size'] - if size < 0: - size = self.opts['size'] - pen = rec['pen'] - if pen is None: - pen = self.opts['pen'] - brush = rec['brush'] - if brush is None: - brush = self.opts['brush'] - return (symbol, size*scale, fn.mkPen(pen), fn.mkBrush(brush)) - else: - recs = recs.copy() - recs['symbol'][np.equal(recs['symbol'], None)] = self.opts['symbol'] - recs['size'][np.equal(recs['size'], -1)] = self.opts['size'] - recs['size'] *= scale - recs['pen'][np.equal(recs['pen'], None)] = fn.mkPen(self.opts['pen']) - recs['brush'][np.equal(recs['brush'], None)] = fn.mkBrush(self.opts['brush']) - return recs - - def measureSpotSizes(self, dataSet): - warnings.warn( - "This is an internal method that is no longer being used. " - "Will be removed in 0.13.", - DeprecationWarning, stacklevel=2 - ) - for size, pen in zip(*self._style(['size', 'pen'], data=dataSet)): - ## keep track of the maximum spot size and pixel size - width = 0 - pxWidth = 0 - if self.opts['pxMode']: - pxWidth = size + pen.widthF() - else: - width = size - if pen.isCosmetic(): - pxWidth += pen.widthF() - else: - width += pen.widthF() - self._maxSpotWidth = max(self._maxSpotWidth, width) - self._maxSpotPxWidth = max(self._maxSpotPxWidth, pxWidth) - self.bounds = [None, None] - def clear(self): """Remove all spots from the scatter plot""" #self.clearItems() @@ -1045,44 +918,6 @@ def setExportMode(self, *args, **kwds): GraphicsObject.setExportMode(self, *args, **kwds) self.invalidate() - def mapPointsToDevice(self, pts): - warnings.warn( - "This is an internal method that is no longer being used. " - "Will be removed in 0.13", - DeprecationWarning, stacklevel=2 - ) - # Map point locations to device - tr = self.deviceTransform() - if tr is None: - return None - - pts = fn.transformCoordinates(tr, pts) - pts -= self.data['sourceRect']['w'] / 2 - pts = np.clip(pts, -2**30, 2**30) ## prevent Qt segmentation fault. - - return pts - - def getViewMask(self, pts): - warnings.warn( - "This is an internal method that is no longer being used. " - "Will be removed in 0.13", - DeprecationWarning, stacklevel=2 - ) - # Return bool mask indicating all points that are within viewbox - # pts is expressed in *device coordiantes* - vb = self.getViewBox() - if vb is None: - return None - viewBounds = vb.mapRectToDevice(vb.boundingRect()) - w = self.data['sourceRect']['w'] / 2 - mask = ((pts[0] + w > viewBounds.left()) & - (pts[0] - w < viewBounds.right()) & - (pts[1] + w > viewBounds.top()) & - (pts[1] - w < viewBounds.bottom())) ## remove out of view points - - mask &= self.data['visible'] - return mask - @debug.warnOnException ## raising an exception here causes crash def paint(self, p, *args): profiler = debug.Profiler() @@ -1116,13 +951,15 @@ def paint(self, p, *args): xy = pts[:, viewMask].T sr = self.data['sourceRect'][viewMask] - frags = self._pixmapFragments.array(sr.size) + self._pixmapFragments.resize(sr.size) + frags = self._pixmapFragments.ndarray() frags[:, 0:2] = xy frags[:, 2:6] = np.frombuffer(sr, dtype=int).reshape((-1, 4)) # sx, sy, sw, sh frags[:, 6:10] = [1.0, 1.0, 0.0, 1.0] # scaleX, scaleY, rotation, opacity profiler('prep') - self._pixmapFragments.draw(p, self.fragmentAtlas.pixmap) + drawargs = self._pixmapFragments.drawargs() + p.drawPixmapFragments(*drawargs, self.fragmentAtlas.pixmap) profiler('draw') else: # render each symbol individually diff --git a/pyqtgraph/graphicsItems/TargetItem.py b/pyqtgraph/graphicsItems/TargetItem.py index c78c93c67d..3f74e2eb0e 100644 --- a/pyqtgraph/graphicsItems/TargetItem.py +++ b/pyqtgraph/graphicsItems/TargetItem.py @@ -1,12 +1,11 @@ import string -import warnings from math import atan2 from .. import functions as fn from ..Point import Point from ..Qt import QtCore, QtGui from .GraphicsObject import GraphicsObject -from .ScatterPlotItem import Symbols, makeCrosshair +from .ScatterPlotItem import Symbols from .TextItem import TextItem from .UIGraphicsItem import UIGraphicsItem from .ViewBox import ViewBox @@ -27,7 +26,6 @@ def __init__( self, pos=None, size=10, - radii=None, symbol="crosshair", pen=None, hoverPen=None, @@ -44,8 +42,6 @@ def __init__( Initial position of the symbol. Default is (0, 0) size : int Size of the symbol in pixels. Default is 10. - radii : tuple of int - Deprecated. Gives size of crosshair in screen pixels. pen : QPen, tuple, list or str Pen to use when drawing line. Can be any arguments that are valid for :func:`~pyqtgraph.mkPen`. Default pen is transparent yellow. @@ -84,16 +80,6 @@ def __init__( self._label = None self.mouseHovering = False - if radii is not None: - warnings.warn( - "'radii' is now deprecated, and will be removed in 0.13.0. Use 'size' " - "parameter instead", - DeprecationWarning, - stacklevel=2, - ) - symbol = makeCrosshair(*radii) - size = 1 - if pen is None: pen = (255, 255, 0) self.setPen(pen) @@ -134,29 +120,19 @@ def __init__( self.setPath(self._path) self.setLabel(label, labelOpts) - @property - def sigDragged(self): - warnings.warn( - "'sigDragged' has been deprecated and will be removed in 0.13.0. Use " - "`sigPositionChangeFinished` instead", - DeprecationWarning, - stacklevel=2, - ) - return self.sigPositionChangeFinished - def setPos(self, *args): """Method to set the position to ``(x, y)`` within the plot view Parameters ---------- - args : tuple, list, QPointF, QPoint, pg.Point, or two floats + args : tuple or list or QtCore.QPointF or QtCore.QPoint or Point or float Two float values or a container that specifies ``(x, y)`` position where the TargetItem should be placed Raises ------ TypeError - If args cannot be used to instantiate a pg.Point + If args cannot be used to instantiate a Point """ try: newPos = Point(*args) @@ -327,7 +303,7 @@ def setLabel(self, text=None, labelOpts=None): displayed If a non-formatted string, then the text label will display ``text``, by default None - labelOpts : dictionary, optional + labelOpts : dict, optional These arguments are passed on to :class:`~pyqtgraph.TextItem` """ if not text: @@ -346,17 +322,6 @@ def setLabel(self, text=None, labelOpts=None): self._label.scene().removeItem(self._label) self._label = TargetLabel(self, text=text, **labelOpts) - def setLabelAngle(self, angle): - warnings.warn( - "TargetItem.setLabelAngle is deprecated and will be removed in 0.13.0." - "Use TargetItem.label().setAngle() instead", - DeprecationWarning, - stacklevel=2, - ) - if self.label() is not None and angle != self.label().angle: - self.label().setAngle(angle) - return None - class TargetLabel(TextItem): """A TextItem that attaches itself to a TargetItem. @@ -377,10 +342,11 @@ class TargetLabel(TextItem): offset : tuple or list or QPointF or QPoint Position to set the anchor of the TargetLabel away from the center of the target in pixels, by default it is (20, 0). - anchor : tuple, list, QPointF or QPoint + anchor : tuple or list or QPointF or QPoint Position to rotate the TargetLabel about, and position to set the offset value to see :class:`~pyqtgraph.TextItem` for more information. - kwargs : dict of arguments that are passed on to + kwargs : dict + kwargs contains arguments that are passed onto :class:`~pyqtgraph.TextItem` constructor, excluding text parameter """ diff --git a/pyqtgraph/graphicsItems/TextItem.py b/pyqtgraph/graphicsItems/TextItem.py index ce43721b6c..f59039e995 100644 --- a/pyqtgraph/graphicsItems/TextItem.py +++ b/pyqtgraph/graphicsItems/TextItem.py @@ -12,26 +12,29 @@ class TextItem(GraphicsObject): GraphicsItem displaying unscaled text (the text will always appear normal even inside a scaled ViewBox). """ def __init__(self, text='', color=(200,200,200), html=None, anchor=(0,0), - border=None, fill=None, angle=0, rotateAxis=None): + border=None, fill=None, angle=0, rotateAxis=None, ensureInBounds=False): """ - ============== ================================================================================= + ================ ================================================================================= **Arguments:** - *text* The text to display - *color* The color of the text (any format accepted by pg.mkColor) - *html* If specified, this overrides both *text* and *color* - *anchor* A QPointF or (x,y) sequence indicating what region of the text box will - be anchored to the item's position. A value of (0,0) sets the upper-left corner - of the text box to be at the position specified by setPos(), while a value of (1,1) - sets the lower-right corner. - *border* A pen to use when drawing the border - *fill* A brush to use when filling within the border - *angle* Angle in degrees to rotate text. Default is 0; text will be displayed upright. - *rotateAxis* If None, then a text angle of 0 always points along the +x axis of the scene. - If a QPointF or (x,y) sequence is given, then it represents a vector direction - in the parent's coordinate system that the 0-degree line will be aligned to. This - Allows text to follow both the position and orientation of its parent while still - discarding any scale and shear factors. - ============== ================================================================================= + *text* The text to display + *color* The color of the text (any format accepted by pg.mkColor) + *html* If specified, this overrides both *text* and *color* + *anchor* A QPointF or (x,y) sequence indicating what region of the text box will + be anchored to the item's position. A value of (0,0) sets the upper-left corner + of the text box to be at the position specified by setPos(), while a value of (1,1) + sets the lower-right corner. + *border* A pen to use when drawing the border + *fill* A brush to use when filling within the border + *angle* Angle in degrees to rotate text. Default is 0; text will be displayed upright. + *rotateAxis* If None, then a text angle of 0 always points along the +x axis of the scene. + If a QPointF or (x,y) sequence is given, then it represents a vector direction + in the parent's coordinate system that the 0-degree line will be aligned to. This + Allows text to follow both the position and orientation of its parent while still + discarding any scale and shear factors. + *ensureInBounds* Ensures that the entire TextItem will be visible when using autorange, but may + produce runaway scaling in certain circumstances (See issue #2642). Setting to + "True" retains legacy behavior. + ================ ================================================================================= The effects of the `rotateAxis` and `angle` arguments are added independently. So for example: @@ -51,6 +54,13 @@ def __init__(self, text='', color=(200,200,200), html=None, anchor=(0,0), self.textItem.setParentItem(self) self._lastTransform = None self._lastScene = None + + # Note: The following is pretty scuffed; ideally there would likely be + # some inheritance changes, But this is the least-intrusive thing that + # works for now + if ensureInBounds: + self.dataBounds = None + self._bounds = QtCore.QRectF() if html is None: self.setColor(color) @@ -150,6 +160,18 @@ def updateTextPos(self): offset = (br - tl) * self.anchor self.textItem.setPos(-offset) + def dataBounds(self, ax, frac=1.0, orthoRange=None): + """ + Returns only the anchor point for when calulating view ranges. + + Sacrifices some visual polish for fixing issue #2642. + """ + if orthoRange: + range_min, range_max = orthoRange[0], orthoRange[1] + if not range_min <= self.anchor[ax] <= range_max: + return [None, None] + + return [self.anchor[ax], self.anchor[ax]] def boundingRect(self): return self.textItem.mapRectToParent(self.textItem.boundingRect()) @@ -202,7 +224,7 @@ def updateTransform(self, force=False): if not force and pt == self._lastTransform: return - t = pt.inverted()[0] + t = fn.invertQTransform(pt) # reset translation t.setMatrix(t.m11(), t.m12(), t.m13(), t.m21(), t.m22(), t.m23(), 0, 0, t.m33()) diff --git a/pyqtgraph/graphicsItems/ViewBox/ViewBox.py b/pyqtgraph/graphicsItems/ViewBox/ViewBox.py index 7ec7808462..6ea6e431d8 100644 --- a/pyqtgraph/graphicsItems/ViewBox/ViewBox.py +++ b/pyqtgraph/graphicsItems/ViewBox/ViewBox.py @@ -76,7 +76,7 @@ class ViewBox(GraphicsWidget): **Bases:** :class:`GraphicsWidget ` Box that allows internal scaling/panning of children by mouse drag. - This class is usually created automatically as part of a :class:`PlotItem ` or :class:`Canvas ` or with :func:`GraphicsLayout.addViewBox() `. + This class is usually created automatically as part of a :class:`PlotItem ` or :ref:`Canvas ` or with :func:`GraphicsLayout.addViewBox() `. Features: @@ -475,6 +475,10 @@ def resizeEvent(self, ev): self.sigStateChanged.emit(self) self.sigResized.emit(self) + def boundingRect(self): + br = super().boundingRect() + return br.adjusted(0, 0, +0.5, +0.5) + def viewRange(self): """Return a the view's visible range as a list: [[xmin, xmax], [ymin, ymax]]""" return [x[:] for x in self.state['viewRange']] ## return copy @@ -593,7 +597,9 @@ def setRange(self, rect=None, xRange=None, yRange=None, padding=None, update=Tru # If we requested 0 range, try to preserve previous scale. # Otherwise just pick an arbitrary scale. + preserve = False if mn == mx: + preserve = True dy = self.state['viewRange'][ax][1] - self.state['viewRange'][ax][0] if dy == 0: dy = 1 @@ -613,13 +619,14 @@ def setRange(self, rect=None, xRange=None, yRange=None, padding=None, update=Tru raise Exception("Cannot set range [%s, %s]" % (str(mn), str(mx))) # Apply padding - if padding is None: - xpad = self.suggestPadding(ax) - else: - xpad = padding - p = (mx-mn) * xpad - mn -= p - mx += p + if not preserve: + if padding is None: + xpad = self.suggestPadding(ax) + else: + xpad = padding + p = (mx-mn) * xpad + mn -= p + mx += p # max range cannot be larger than bounds, if they are given if limits[ax][0] is not None and limits[ax][1] is not None: @@ -841,7 +848,7 @@ def enableAutoRange(self, axis=None, enable=True, x=None, y=None): (if *axis* is omitted, both axes will be changed). When enabled, the axis will automatically rescale when items are added/removed or change their shape. The argument *enable* may optionally be a float (0.0-1.0) which indicates the fraction of the data that should - be visible (this only works with items implementing a dataRange method, such as PlotDataItem). + be visible (this only works with items implementing a dataBounds method, such as PlotDataItem). """ # support simpler interface: if x is not None or y is not None: @@ -1438,7 +1445,7 @@ def childrenBounds(self, frac=None, orthoRange=(None,None), items=None): useX = True useY = True - if hasattr(item, 'dataBounds'): + if hasattr(item, 'dataBounds') and item.dataBounds is not None: if frac is None: frac = (1.0, 1.0) xr = item.dataBounds(0, frac=frac[0], orthoRange=orthoRange[0]) diff --git a/pyqtgraph/graphicsItems/ViewBox/ViewBoxMenu.py b/pyqtgraph/graphicsItems/ViewBox/ViewBoxMenu.py index a0804d4b26..617ad78054 100644 --- a/pyqtgraph/graphicsItems/ViewBox/ViewBoxMenu.py +++ b/pyqtgraph/graphicsItems/ViewBox/ViewBoxMenu.py @@ -19,21 +19,17 @@ def __init__(self, view): self.viewAll.triggered.connect(self.autoRange) self.addAction(self.viewAll) - self.axes = [] self.ctrl = [] self.widgetGroups = [] self.dv = QtGui.QDoubleValidator(self) for axis in 'XY': - m = QtWidgets.QMenu() - m.setTitle(f"{axis} {translate('ViewBox', 'axis')}") + m = self.addMenu(f"{axis} {translate('ViewBox', 'axis')}") w = QtWidgets.QWidget() ui = ui_template.Ui_Form() ui.setupUi(w) a = QtWidgets.QWidgetAction(self) a.setDefaultWidget(w) m.addAction(a) - self.addMenu(m) - self.axes.append(m) self.ctrl.append(ui) wg = WidgetGroup(w) self.widgetGroups.append(wg) @@ -55,43 +51,24 @@ def __init__(self, view): self.ctrl[0].invertCheck.toggled.connect(self.xInvertToggled) self.ctrl[1].invertCheck.toggled.connect(self.yInvertToggled) - ## exporting is handled by GraphicsScene now - #self.export = QtWidgets.QMenu("Export") - #self.setExportMethods(view.exportMethods) - #self.addMenu(self.export) - self.leftMenu = QtWidgets.QMenu(translate("ViewBox", "Mouse Mode")) + leftMenu = self.addMenu(translate("ViewBox", "Mouse Mode")) + group = QtGui.QActionGroup(self) - - # This does not work! QAction _must_ be initialized with a permanent - # object as the parent or else it may be collected prematurely. - #pan = self.leftMenu.addAction("3 button", self.set3ButtonMode) - #zoom = self.leftMenu.addAction("1 button", self.set1ButtonMode) - pan = QtGui.QAction(translate("ViewBox", "3 button"), self.leftMenu) - zoom = QtGui.QAction(translate("ViewBox", "1 button"), self.leftMenu) - self.leftMenu.addAction(pan) - self.leftMenu.addAction(zoom) - pan.triggered.connect(self.set3ButtonMode) - zoom.triggered.connect(self.set1ButtonMode) - + group.triggered.connect(self.setMouseMode) + pan = QtGui.QAction(translate("ViewBox", "3 button"), group) + zoom = QtGui.QAction(translate("ViewBox", "1 button"), group) pan.setCheckable(True) zoom.setCheckable(True) - pan.setActionGroup(group) - zoom.setActionGroup(group) + + leftMenu.addActions(group.actions()) + self.mouseModes = [pan, zoom] - self.addMenu(self.leftMenu) self.view().sigStateChanged.connect(self.viewStateChanged) self.updateState() - def setExportMethods(self, methods): - self.exportMethods = methods - self.export.clear() - for opt, fn in methods.items(): - self.export.addAction(opt, self.exportMethod) - - def viewStateChanged(self): self.valid = False if self.ctrl[0].minText.isVisible() or self.ctrl[1].minText.isVisible(): @@ -210,15 +187,14 @@ def yInvertToggled(self, b): def xInvertToggled(self, b): self.view().invertX(b) - def exportMethod(self): - act = self.sender() - self.exportMethods[str(act.text())]() - - def set3ButtonMode(self): - self.view().setLeftButtonAction('pan') - - def set1ButtonMode(self): - self.view().setLeftButtonAction('rect') + def setMouseMode(self, action): + mode = None + if action == self.mouseModes[0]: + mode = 'pan' + elif action == self.mouseModes[1]: + mode = 'rect' + if mode is not None: + self.view().setLeftButtonAction(mode) def setViewList(self, views): names = [''] diff --git a/pyqtgraph/graphicsWindows.py b/pyqtgraph/graphicsWindows.py deleted file mode 100644 index 7aa57d4cde..0000000000 --- a/pyqtgraph/graphicsWindows.py +++ /dev/null @@ -1,117 +0,0 @@ -""" -DEPRECATED: The classes below are convenience classes that create a new window -containting a single, specific widget. These classes are now unnecessary because -it is possible to place any widget into its own window by simply calling its -show() method. -""" - -__all__ = ['GraphicsWindow', 'TabWindow', 'PlotWindow', 'ImageWindow'] - -import warnings - -from .imageview import * -from .Qt import QtCore, QtWidgets, mkQApp -from .widgets.GraphicsLayoutWidget import GraphicsLayoutWidget -from .widgets.PlotWidget import * - - -class GraphicsWindow(GraphicsLayoutWidget): - """ - (deprecated; use :class:`~pyqtgraph.GraphicsLayoutWidget` instead) - - Convenience subclass of :class:`~pyqtgraph.GraphicsLayoutWidget`. This class - is intended for use from the interactive python prompt. - """ - def __init__(self, title=None, size=(800,600), **kargs): - warnings.warn( - 'GraphicsWindow is deprecated, use GraphicsLayoutWidget instead,' - 'will be removed in 0.13', - DeprecationWarning, stacklevel=2 - ) - mkQApp() - GraphicsLayoutWidget.__init__(self, **kargs) - self.resize(*size) - if title is not None: - self.setWindowTitle(title) - self.show() - - -class TabWindow(QtWidgets.QMainWindow): - """ - (deprecated) - """ - def __init__(self, title=None, size=(800,600)): - warnings.warn( - 'TabWindow is deprecated, will be removed in 0.13', - DeprecationWarning, stacklevel=2 - ) - mkQApp() - QtWidgets.QMainWindow.__init__(self) - self.resize(*size) - self.cw = QtWidgets.QTabWidget() - self.setCentralWidget(self.cw) - if title is not None: - self.setWindowTitle(title) - self.show() - - def __getattr__(self, attr): - return getattr(self.cw, attr) - - -class PlotWindow(PlotWidget): - sigClosed = QtCore.Signal(object) - - """ - (deprecated; use :class:`~pyqtgraph.PlotWidget` instead) - """ - def __init__(self, title=None, **kargs): - warnings.warn( - 'PlotWindow is deprecated, use PlotWidget instead,' - 'will be removed in 0.13', - DeprecationWarning, stacklevel=2 - ) - mkQApp() - self.win = QtWidgets.QMainWindow() - PlotWidget.__init__(self, **kargs) - self.win.setCentralWidget(self) - for m in ['resize']: - setattr(self, m, getattr(self.win, m)) - if title is not None: - self.win.setWindowTitle(title) - self.win.show() - - def closeEvent(self, event): - PlotWidget.closeEvent(self, event) - self.sigClosed.emit(self) - - -class ImageWindow(ImageView): - sigClosed = QtCore.Signal(object) - - """ - (deprecated; use :class:`~pyqtgraph.ImageView` instead) - """ - def __init__(self, *args, **kargs): - warnings.warn( - 'ImageWindow is deprecated, use ImageView instead' - 'will be removed in 0.13', - DeprecationWarning, stacklevel=2 - ) - mkQApp() - self.win = QtWidgets.QMainWindow() - self.win.resize(800,600) - if 'title' in kargs: - self.win.setWindowTitle(kargs['title']) - del kargs['title'] - ImageView.__init__(self, self.win) - if len(args) > 0 or len(kargs) > 0: - self.setImage(*args, **kargs) - - self.win.setCentralWidget(self) - for m in ['resize']: - setattr(self, m, getattr(self.win, m)) - self.win.show() - - def closeEvent(self, event): - ImageView.closeEvent(self, event) - self.sigClosed.emit(self) diff --git a/pyqtgraph/icons/__init__.py b/pyqtgraph/icons/__init__.py index 418a0c3241..229237f0ae 100644 --- a/pyqtgraph/icons/__init__.py +++ b/pyqtgraph/icons/__init__.py @@ -59,18 +59,6 @@ def getGraphPixmap(name, size=(20, 20)): return icon.pixmap(*size) -def getPixmap(name, size=(20, 20)): - """Historic `getPixmap` function - - (eg. getPixmap('auto') loads pyqtgraph/icons/auto.png) - """ - warnings.warn( - "'getPixmap' is deprecated and will be removed soon, " - "please use `getGraphPixmap` in the future", - DeprecationWarning, stacklevel=2) - return getGraphPixmap(name, size=size) - - # Note: List all graph icons here ... auto = GraphIcon("auto.png") ctrl = GraphIcon("ctrl.png") diff --git a/pyqtgraph/icons/invisibleEye.svg b/pyqtgraph/icons/invisibleEye.svg index 5a67832861..dbb1475af4 100644 --- a/pyqtgraph/icons/invisibleEye.svg +++ b/pyqtgraph/icons/invisibleEye.svg @@ -1,35 +1,68 @@ + xmlns:svg="http://www.w3.org/2000/svg"> + + - - - - + transform="matrix(1.3011758,0,0,1.3175188,37.063439,44.322322)" + id="g5" /> + x="53.386093" + y="39.182629" /> + + + + + diff --git a/pyqtgraph/imageview/ImageView.py b/pyqtgraph/imageview/ImageView.py index 9d559fbcb0..d6169e0815 100644 --- a/pyqtgraph/imageview/ImageView.py +++ b/pyqtgraph/imageview/ImageView.py @@ -17,21 +17,19 @@ import numpy as np -from .. import functions as fn -from ..Qt import QtCore, QtGui, QtWidgets - -from . import ImageViewTemplate_generic as ui_template - from .. import debug as debug +from .. import functions as fn from .. import getConfigOption from ..graphicsItems.GradientEditorItem import addGradientListToDocstring -from ..graphicsItems.ImageItem import * -from ..graphicsItems.InfiniteLine import * -from ..graphicsItems.LinearRegionItem import * -from ..graphicsItems.ROI import * -from ..graphicsItems.ViewBox import * +from ..graphicsItems.ImageItem import ImageItem +from ..graphicsItems.InfiniteLine import InfiniteLine +from ..graphicsItems.LinearRegionItem import LinearRegionItem +from ..graphicsItems.ROI import ROI +from ..graphicsItems.ViewBox import ViewBox from ..graphicsItems.VTickGroup import VTickGroup +from ..Qt import QtCore, QtGui, QtWidgets from ..SignalProxy import SignalProxy +from . import ImageViewTemplate_generic as ui_template try: from bottleneck import nanmax, nanmin @@ -40,6 +38,7 @@ translate = QtCore.QCoreApplication.translate + class PlotROI(ROI): def __init__(self, size): ROI.__init__(self, pos=[0,0], size=size) #, scaleSnap=True, translateSnap=True) @@ -81,44 +80,54 @@ class ImageView(QtWidgets.QWidget): sigTimeChanged = QtCore.Signal(object, object) sigProcessingChanged = QtCore.Signal(object) - def __init__(self, parent=None, name="ImageView", view=None, imageItem=None, - levelMode='mono', *args): + def __init__( + self, + parent=None, + name="ImageView", + view=None, + imageItem=None, + levelMode='mono', + discreteTimeLine=False, + roi=None, + normRoi=None, + *args, + ): """ By default, this class creates an :class:`ImageItem ` to display image data - and a :class:`ViewBox ` to contain the ImageItem. - - ============= ========================================================= - **Arguments** - parent (QWidget) Specifies the parent widget to which - this ImageView will belong. If None, then the ImageView - is created with no parent. - name (str) The name used to register both the internal ViewBox - and the PlotItem used to display ROI data. See the *name* - argument to :func:`ViewBox.__init__() - `. - view (ViewBox or PlotItem) If specified, this will be used - as the display area that contains the displayed image. - Any :class:`ViewBox `, - :class:`PlotItem `, or other - compatible object is acceptable. - imageItem (ImageItem) If specified, this object will be used to - display the image. Must be an instance of ImageItem - or other compatible object. - levelMode See the *levelMode* argument to - :func:`HistogramLUTItem.__init__() - ` - ============= ========================================================= - - Note: to display axis ticks inside the ImageView, instantiate it - with a PlotItem instance as its view:: - - pg.ImageView(view=pg.PlotItem()) + and a :class:`ViewBox ` to contain the ImageItem. + + Parameters + ---------- + parent : QWidget + Specifies the parent widget to which this ImageView will belong. If None, then the ImageView is created with + no parent. + name : str + The name used to register both the internal ViewBox and the PlotItem used to display ROI data. See the + *name* argument to :func:`ViewBox.__init__() `. + view : ViewBox or PlotItem + If specified, this will be used as the display area that contains the displayed image. Any + :class:`ViewBox `, :class:`PlotItem `, or other compatible object is + acceptable. Note: to display axis ticks inside the ImageView, instantiate it with a PlotItem instance as its + view:: + + pg.ImageView(view=pg.PlotItem()) + imageItem : ImageItem + If specified, this object will be used to display the image. Must be an instance of ImageItem or other + compatible object. + levelMode : str + See the *levelMode* argument to :func:`HistogramLUTItem.__init__() ` + discreteTimeLine : bool + Whether to snap to xvals / frame numbers when interacting with the timeline position. + roi : ROI + If specified, this object is used as ROI for the plot feature. Must be an instance of ROI. + normRoi : ROI + If specified, this object is used as ROI for the normalization feature. Must be an instance of ROI. """ QtWidgets.QWidget.__init__(self, parent, *args) self._imageLevels = None # [(min, max), ...] per channel image metrics self.levelMin = None # min / max levels across all channels self.levelMax = None - + self.name = name self.image = None self.axes = {} @@ -126,9 +135,10 @@ def __init__(self, parent=None, name="ImageView", view=None, imageItem=None, self.ui = ui_template.Ui_Form() self.ui.setupUi(self) self.scene = self.ui.graphicsView.scene() - - self.ignorePlaying = False - + self.discreteTimeLine = discreteTimeLine + self.ui.histogram.setLevelMode(levelMode) + self.ignoreTimeLine = False + if view is None: self.view = ViewBox() else: @@ -141,12 +151,18 @@ def __init__(self, parent=None, name="ImageView", view=None, imageItem=None, self.ui.normGroup.hide() - self.roi = PlotROI(10) + if roi is None: + self.roi = PlotROI(10) + else: + self.roi = roi self.roi.setZValue(20) self.view.addItem(self.roi) self.roi.hide() - self.normRoi = PlotROI(10) - self.normRoi.setPen('y') + if normRoi is None: + self.normRoi = PlotROI(10) + self.normRoi.setPen('y') + else: + self.normRoi = normRoi self.normRoi.setZValue(20) self.view.addItem(self.normRoi) self.normRoi.hide() @@ -185,7 +201,8 @@ def __init__(self, parent=None, name="ImageView", view=None, imageItem=None, self.keysPressed = {} self.playTimer = QtCore.QTimer() self.playRate = 0 - self.fps = 1 # 1 Hz by default + self._pausedPlayRate = None + self.fps = 1 # 1 Hz by default self.lastPlayTime = 0 self.normRgn = LinearRegionItem() @@ -214,49 +231,77 @@ def __init__(self, parent=None, name="ImageView", view=None, imageItem=None, self.ui.normTimeRangeCheck.clicked.connect(self.updateNorm) self.playTimer.timeout.connect(self.timeout) - self.normProxy = SignalProxy(self.normRgn.sigRegionChanged, slot=self.updateNorm) + self.normProxy = SignalProxy( + self.normRgn.sigRegionChanged, + slot=self.updateNorm, + threadSafe=False, + ) self.normRoi.sigRegionChangeFinished.connect(self.updateNorm) self.ui.roiPlot.registerPlot(self.name + '_ROI') self.view.register(self.name) - self.noRepeatKeys = [QtCore.Qt.Key.Key_Right, QtCore.Qt.Key.Key_Left, QtCore.Qt.Key.Key_Up, QtCore.Qt.Key.Key_Down, QtCore.Qt.Key.Key_PageUp, QtCore.Qt.Key.Key_PageDown] + self.noRepeatKeys = [ + QtCore.Qt.Key.Key_Right, + QtCore.Qt.Key.Key_Left, + QtCore.Qt.Key.Key_Up, + QtCore.Qt.Key.Key_Down, + QtCore.Qt.Key.Key_PageUp, + QtCore.Qt.Key.Key_PageDown, + ] self.roiClicked() ## initialize roi plot to correct shape / visibility - def setImage(self, img, autoRange=True, autoLevels=True, levels=None, axes=None, xvals=None, pos=None, scale=None, transform=None, autoHistogramRange=True, levelMode=None): + def setImage( + self, + img, + autoRange=True, + autoLevels=True, + levels=None, + axes=None, + xvals=None, + pos=None, + scale=None, + transform=None, + autoHistogramRange=True, + levelMode=None, + ): """ Set the image to be displayed in the widget. - - ================== =========================================================================== - **Arguments:** - img (numpy array) the image to be displayed. See :func:`ImageItem.setImage` and - *notes* below. - xvals (numpy array) 1D array of z-axis values corresponding to the first axis - in a 3D image. For video, this array should contain the time of each - frame. - autoRange (bool) whether to scale/pan the view to fit the image. - autoLevels (bool) whether to update the white/black levels to fit the image. - levels (min, max); the white and black level values to use. - axes Dictionary indicating the interpretation for each axis. - This is only needed to override the default guess. Format is:: - - {'t':0, 'x':1, 'y':2, 'c':3}; - - pos Change the position of the displayed image - scale Change the scale of the displayed image - transform Set the transform of the displayed image. This option overrides *pos* - and *scale*. - autoHistogramRange If True, the histogram y-range is automatically scaled to fit the - image data. - levelMode If specified, this sets the user interaction mode for setting image - levels. Options are 'mono', which provides a single level control for - all image channels, and 'rgb' or 'rgba', which provide individual - controls for each channel. - ================== =========================================================================== - - **Notes:** - + + Parameters + ---------- + img : np.ndarray + The image to be displayed. See :func:`ImageItem.setImage` and *notes* below. + autoRange : bool + Whether to scale/pan the view to fit the image. + autoLevels : bool + Whether to update the white/black levels to fit the image. + levels : tuple + (min, max) white and black level values to use. + axes : dict + Dictionary indicating the interpretation for each axis. This is only needed to override the default guess. + Format is:: + + {'t':0, 'x':1, 'y':2, 'c':3}; + xvals : np.ndarray + 1D array of values corresponding to the first axis in a 3D image. For video, this array should contain + the time of each frame. + pos + Change the position of the displayed image + scale + Change the scale of the displayed image + transform + Set the transform of the displayed image. This option overrides *pos* and *scale*. + autoHistogramRange : bool + If True, the histogram y-range is automatically scaled to fit the image data. + levelMode : str + If specified, this sets the user interaction mode for setting image levels. Options are 'mono', + which provides a single level control for all image channels, and 'rgb' or 'rgba', which provide + individual controls for each channel. + + Notes + ----- For backward compatibility, image data is assumed to be in column-major order (column, row). However, most image data is stored in row-major order (row, column) and will need to be transposed before calling setImage():: @@ -265,30 +310,29 @@ def setImage(self, img, autoRange=True, autoLevels=True, levels=None, axes=None, This requirement can be changed by the ``imageAxisOrder`` :ref:`global configuration option `. - """ profiler = debug.Profiler() - + if hasattr(img, 'implements') and img.implements('MetaArray'): img = img.asarray() - + if not isinstance(img, np.ndarray): required = ['dtype', 'max', 'min', 'ndim', 'shape', 'size'] if not all(hasattr(img, attr) for attr in required): raise TypeError("Image must be NumPy array or any object " "that provides compatible attributes/methods:\n" " %s" % str(required)) - + self.image = img self.imageDisp = None if levelMode is not None: self.ui.histogram.setLevelMode(levelMode) - + profiler() - + if axes is None: x,y = (0, 1) if self.imageItem.axisOrder == 'col-major' else (1, 0) - + if img.ndim == 2: self.axes = {'t': None, 'x': x, 'y': y, 'c': None} elif img.ndim == 3: @@ -383,9 +427,10 @@ def clear(self): def play(self, rate=None): """Begin automatically stepping frames forward at the given rate (in fps). This can also be accessed by pressing the spacebar.""" - #print "play:", rate - if rate is None: - rate = self.fps + if rate is None: + rate = self._pausedPlayRate or self.fps + if rate == 0 and self.playRate not in (None, 0): + self._pausedPlayRate = self.playRate self.playRate = rate if rate == 0: @@ -394,8 +439,44 @@ def play(self, rate=None): self.lastPlayTime = perf_counter() if not self.playTimer.isActive(): - self.playTimer.start(16) - + self.playTimer.start(abs(int(1000/rate))) + + def togglePause(self): + if self.playTimer.isActive(): + self.play(0) + elif self.playRate == 0: + if self._pausedPlayRate is not None: + fps = self._pausedPlayRate + else: + fps = (self.nframes() - 1) / (self.tVals[-1] - self.tVals[0]) + self.play(fps) + else: + self.play(self.playRate) + + def setHistogramLabel(self, text=None, **kwargs): + """ + Set the label text of the histogram axis similar to + :func:`AxisItem.setLabel() ` + """ + a = self.ui.histogram.axis + a.setLabel(text, **kwargs) + if text == '': + a.showLabel(False) + self.ui.histogram.setMinimumWidth(135) + + def nframes(self): + """ + Returns + ------- + int + The number of frames in the image data. + """ + if self.image is None: + return 0 + elif self.axes['t'] is not None: + return self.image.shape[self.axes['t']] + return 1 + def autoLevels(self): """Set the min/max intensity levels automatically to match the image data.""" self.setLevels(rgba=self._imageLevels) @@ -438,17 +519,14 @@ def keyPressEvent(self, ev): return if ev.key() == QtCore.Qt.Key.Key_Space: - if self.playRate == 0: - self.play() - else: - self.play(0) + self.togglePause() ev.accept() elif ev.key() == QtCore.Qt.Key.Key_Home: self.setCurrentIndex(0) self.play(0) ev.accept() elif ev.key() == QtCore.Qt.Key.Key_End: - self.setCurrentIndex(self.getProcessedImage().shape[0]-1) + self.setCurrentIndex(self.nframes()-1) self.play(0) ev.accept() elif ev.key() in self.noRepeatKeys: @@ -516,11 +594,13 @@ def timeout(self): def setCurrentIndex(self, ind): """Set the currently displayed frame index.""" - index = fn.clip_scalar(ind, 0, self.getProcessedImage().shape[self.axes['t']]-1) - self.ignorePlaying = True + index = fn.clip_scalar(ind, 0, self.nframes()-1) + self.currentIndex = index + self.updateImage() + self.ignoreTimeLine = True # Implicitly call timeLineChanged self.timeLine.setValue(self.tVals[index]) - self.ignorePlaying = False + self.ignoreTimeLine = False def jumpFrames(self, n): """Move video frame ahead n frames (may be negative)""" @@ -728,22 +808,28 @@ def normalize(self, image): return norm def timeLineChanged(self): - if not self.ignorePlaying: + if not self.ignoreTimeLine: self.play(0) (ind, time) = self.timeIndex(self.timeLine) if ind != self.currentIndex: self.currentIndex = ind self.updateImage() + if self.discreteTimeLine: + with fn.SignalBlock(self.timeLine.sigPositionChanged, self.timeLineChanged): + if self.tVals is not None: + self.timeLine.setPos(self.tVals[ind]) + else: + self.timeLine.setPos(ind) + self.sigTimeChanged.emit(ind, time) def updateImage(self, autoHistogramRange=True): ## Redraw image on screen if self.image is None: return - + image = self.getProcessedImage() - if autoHistogramRange: self.ui.histogram.setHistogramRange(self.levelMin, self.levelMax) @@ -761,13 +847,19 @@ def updateImage(self, autoHistogramRange=True): image = image[self.currentIndex] self.imageItem.updateImage(image) - - + def timeIndex(self, slider): - ## Return the time and frame index indicated by a slider + """ + Returns + ------- + int + The index of the frame closest to the timeline slider. + float + The time value of the slider. + """ if not self.hasTimeAxis(): - return (0,0) - + return 0, 0.0 + t = slider.value() xv = self.tVals @@ -775,11 +867,11 @@ def timeIndex(self, slider): ind = int(t) else: if len(xv) < 2: - return (0,0) + return 0, 0.0 inds = np.argwhere(xv <= t) if len(inds) < 1: - return (0,t) - ind = inds[-1,0] + return 0, t + ind = inds[-1, 0] return ind, t def getView(self): @@ -840,17 +932,16 @@ def menuClicked(self): def setColorMap(self, colormap): """Set the color map. - ============= ========================================================= - **Arguments** - colormap (A ColorMap() instance) The ColorMap to use for coloring - images. - ============= ========================================================= + Parameters + ---------- + colormap : ColorMap + The ColorMap to use for coloring images. """ self.ui.histogram.gradient.setColorMap(colormap) @addGradientListToDocstring() def setPredefinedGradient(self, name): - """Set one of the gradients defined in :class:`GradientEditorItem `. + """Set one of the gradients defined in :class:`GradientEditorItem`. Currently available gradients are: """ self.ui.histogram.gradient.loadPreset(name) diff --git a/pyqtgraph/metaarray/MetaArray.py b/pyqtgraph/metaarray/MetaArray.py index 480256b1f8..fd6a0bcd11 100644 --- a/pyqtgraph/metaarray/MetaArray.py +++ b/pyqtgraph/metaarray/MetaArray.py @@ -323,17 +323,6 @@ def __array__(self, dtype=None): return self.asarray() else: return self.asarray().astype(dtype) - - def view(self, typ): - warnings.warn( - 'MetaArray.view is deprecated and will be removed in 0.13. ' - 'Use MetaArray.asarray() instead.', - DeprecationWarning, stacklevel=2 - ) - if typ is np.ndarray: - return self.asarray() - else: - raise Exception('invalid view type: %s' % str(typ)) def axisValues(self, axis): """Return the list of values for an axis""" @@ -1210,169 +1199,3 @@ def writeCsv(self, fileName=None): file.close() else: return ret - - -if __name__ == '__main__': - ## Create an array with every option possible - - arr = np.zeros((2, 5, 3, 5), dtype=int) - for i in range(arr.shape[0]): - for j in range(arr.shape[1]): - for k in range(arr.shape[2]): - for l in range(arr.shape[3]): - arr[i,j,k,l] = (i+1)*1000 + (j+1)*100 + (k+1)*10 + (l+1) - - info = [ - axis('Axis1'), - axis('Axis2', values=[1,2,3,4,5]), - axis('Axis3', cols=[ - ('Ax3Col1'), - ('Ax3Col2', 'mV', 'Axis3 Column2'), - (('Ax3','Col3'), 'A', 'Axis3 Column3')]), - {'name': 'Axis4', 'values': np.array([1.1, 1.2, 1.3, 1.4, 1.5]), 'units': 's'}, - {'extra': 'info'} - ] - - ma = MetaArray(arr, info=info) - - print("==== Original Array =======") - print(ma) - print("\n\n") - - #### Tests follow: - - - #### Index/slice tests: check that all values and meta info are correct after slice - print("\n -- normal integer indexing\n") - - print("\n ma[1]") - print(ma[1]) - - print("\n ma[1, 2:4]") - print(ma[1, 2:4]) - - print("\n ma[1, 1:5:2]") - print(ma[1, 1:5:2]) - - print("\n -- named axis indexing\n") - - print("\n ma['Axis2':3]") - print(ma['Axis2':3]) - - print("\n ma['Axis2':3:5]") - print(ma['Axis2':3:5]) - - print("\n ma[1, 'Axis2':3]") - print(ma[1, 'Axis2':3]) - - print("\n ma[:, 'Axis2':3]") - print(ma[:, 'Axis2':3]) - - print("\n ma['Axis2':3, 'Axis4':0:2]") - print(ma['Axis2':3, 'Axis4':0:2]) - - - print("\n -- column name indexing\n") - - print("\n ma['Axis3':'Ax3Col1']") - print(ma['Axis3':'Ax3Col1']) - - print("\n ma['Axis3':('Ax3','Col3')]") - print(ma['Axis3':('Ax3','Col3')]) - - print("\n ma[:, :, 'Ax3Col2']") - print(ma[:, :, 'Ax3Col2']) - - print("\n ma[:, :, ('Ax3','Col3')]") - print(ma[:, :, ('Ax3','Col3')]) - - - print("\n -- axis value range indexing\n") - - print("\n ma['Axis2':1.5:4.5]") - print(ma['Axis2':1.5:4.5]) - - print("\n ma['Axis4':1.15:1.45]") - print(ma['Axis4':1.15:1.45]) - - print("\n ma['Axis4':1.15:1.25]") - print(ma['Axis4':1.15:1.25]) - - - - print("\n -- list indexing\n") - - print("\n ma[:, [0,2,4]]") - print(ma[:, [0,2,4]]) - - print("\n ma['Axis4':[0,2,4]]") - print(ma['Axis4':[0,2,4]]) - - print("\n ma['Axis3':[0, ('Ax3','Col3')]]") - print(ma['Axis3':[0, ('Ax3','Col3')]]) - - - - print("\n -- boolean indexing\n") - - print("\n ma[:, array([True, True, False, True, False])]") - print(ma[:, np.array([True, True, False, True, False])]) - - print("\n ma['Axis4':array([True, False, False, False])]") - print(ma['Axis4':np.array([True, False, False, False])]) - - - - - - #### Array operations - # - Concatenate - # - Append - # - Extend - # - Rowsort - - - - - #### File I/O tests - - print("\n================ File I/O Tests ===================\n") - tf = 'test.ma' - # write whole array - - print("\n -- write/read test") - ma.write(tf) - ma2 = MetaArray(file=tf) - - #print ma2 - print("\nArrays are equivalent:", (ma == ma2).all()) - #print "Meta info is equivalent:", ma.infoCopy() == ma2.infoCopy() - os.remove(tf) - - # CSV write - - # append mode - - - print("\n================append test (%s)===============" % tf) - ma['Axis2':0:2].write(tf, appendAxis='Axis2') - for i in range(2,ma.shape[1]): - ma['Axis2':[i]].write(tf, appendAxis='Axis2') - - ma2 = MetaArray(file=tf) - - #print ma2 - print("\nArrays are equivalent:", (ma == ma2).all()) - #print "Meta info is equivalent:", ma.infoCopy() == ma2.infoCopy() - - os.remove(tf) - - - - ## memmap test - print("\n==========Memmap test============") - ma.write(tf, mappable=True) - ma2 = MetaArray(file=tf, mmap=True) - print("\nArrays are equivalent:", (ma == ma2).all()) - os.remove(tf) - diff --git a/pyqtgraph/multiprocess/processes.py b/pyqtgraph/multiprocess/processes.py index 520f31f011..06a2f1e44f 100644 --- a/pyqtgraph/multiprocess/processes.py +++ b/pyqtgraph/multiprocess/processes.py @@ -85,11 +85,6 @@ def __init__(self, name=None, target=None, executable=None, copySysPath=True, de ## random authentication key authkey = os.urandom(20) - ## Windows seems to have a hard time with hmac - if sys.platform.startswith('win'): - authkey = None - - #print "key:", ' '.join([str(ord(x)) for x in authkey]) ## Listen for connection from remote process (and find free port number) l = multiprocessing.connection.Listener(('localhost', 0), authkey=authkey) port = l.address[1] @@ -141,7 +136,10 @@ def __init__(self, name=None, target=None, executable=None, copySysPath=True, de # the multiprocessing connection. Technically, only the launched subprocess needs to # send its pid back. Practically, we hijack the ppid parameter to indicate to the # subprocess that pid exchange is needed. - xchg_pids = sys.platform == 'win32' and os.getenv('VIRTUAL_ENV') is not None + # + # We detect a virtual environment using sys.base_prefix, see https://docs.python.org/3/library/sys.html#sys.base_prefix + # See https://github.com/pyqtgraph/pyqtgraph/pull/2566 and https://github.com/spyder-ide/spyder/issues/20273 + xchg_pids = sys.platform == 'win32' and sys.prefix != sys.base_prefix ## Send everything the remote process needs to start correctly data = dict( diff --git a/pyqtgraph/opengl/GLGraphicsItem.py b/pyqtgraph/opengl/GLGraphicsItem.py index df9d4153c5..e8efa6dc2c 100644 --- a/pyqtgraph/opengl/GLGraphicsItem.py +++ b/pyqtgraph/opengl/GLGraphicsItem.py @@ -31,14 +31,14 @@ class GLGraphicsItem(QtCore.QObject): _nextId = 0 - def __init__(self, parentItem=None): + def __init__(self, parentItem: 'GLGraphicsItem' = None): super().__init__() self._id = GLGraphicsItem._nextId GLGraphicsItem._nextId += 1 - self.__parent = None + self.__parent: GLGraphicsItem | None = None self.__view = None - self.__children = set() + self.__children: set[GLGraphicsItem] = set() self.__transform = Transform3D() self.__visible = True self.__initialized = False @@ -135,9 +135,12 @@ def depthValue(self): def setTransform(self, tr): """Set the local transform for this object. - Must be a :class:`Transform3D ` instance. This transform - determines how the local coordinate system of the item is mapped to the coordinate - system of its parent.""" + + Parameters + ---------- + tr : pyqtgraph.Transform3D + Tranformation from the local coordinate system to the parent's. + """ self.__transform = Transform3D(tr) self.update() diff --git a/pyqtgraph/opengl/GLViewWidget.py b/pyqtgraph/opengl/GLViewWidget.py index 282145e051..9d9e27b14d 100644 --- a/pyqtgraph/opengl/GLViewWidget.py +++ b/pyqtgraph/opengl/GLViewWidget.py @@ -1,6 +1,5 @@ from OpenGL.GL import * # noqa import OpenGL.GL.framebufferobjects as glfbo # noqa -import warnings from math import cos, radians, sin, tan import numpy as np @@ -10,31 +9,18 @@ from .. import getConfigOption from ..Qt import QtCore, QtGui, QtWidgets -##Vector = QtGui.QVector3D - - -class GLViewWidget(QtWidgets.QOpenGLWidget): - - def __init__(self, parent=None, devicePixelRatio=None, rotationMethod='euler'): - """ - Basic widget for displaying 3D data - - Rotation/scale controls - - Axis/grid display - - Export options +class GLViewMixin: + def __init__(self, *args, rotationMethod='euler', **kwargs): + """ + Mixin class providing functionality for GLViewWidget ================ ============================================================== **Arguments:** - parent (QObject, optional): Parent QObject. Defaults to None. - devicePixelRatio No longer in use. High-DPI displays should automatically - detect the correct resolution. - rotationMethod (str): Mechanimsm to drive the rotation method, options are + rotationMethod (str): Mechanism to drive the rotation method, options are 'euler' and 'quaternion'. Defaults to 'euler'. ================ ============================================================== """ - - QtWidgets.QOpenGLWidget.__init__(self, parent) - - self.setFocusPolicy(QtCore.Qt.FocusPolicy.ClickFocus) + super().__init__(*args, **kwargs) if rotationMethod not in ["euler", "quaternion"]: raise ValueError("Rotation method should be either 'euler' or 'quaternion'") @@ -59,21 +45,6 @@ def __init__(self, parent=None, devicePixelRatio=None, rotationMethod='euler'): self.keyTimer = QtCore.QTimer() self.keyTimer.timeout.connect(self.evalKeyState) - def _updateScreen(self, screen): - self._updatePixelRatio() - if screen is not None: - screen.physicalDotsPerInchChanged.connect(self._updatePixelRatio) - screen.logicalDotsPerInchChanged.connect(self._updatePixelRatio) - - def _updatePixelRatio(self): - event = QtGui.QResizeEvent(self.size(), self.size()) - self.resizeEvent(event) - - def showEvent(self, event): - window = self.window().windowHandle() - window.screenChanged.connect(self._updateScreen) - self._updateScreen(window.screen()) - def deviceWidth(self): dpr = self.devicePixelRatioF() return int(self.width() * dpr) @@ -377,18 +348,7 @@ def pan(self, dx, dy, dz, relative='global'): Distances are scaled roughly such that a value of 1.0 moves by one pixel on screen. - - Prior to version 0.11, *relative* was expected to be either True (x-aligned) or - False (global). These values are deprecated but still recognized. """ - # for backward compatibility: - if isinstance(relative, bool): - warnings.warn( - "'relative' as a boolean is deprecated, and will not be recognized in 0.13. " - "Acceptable values are 'global', 'view', or 'view-upright'", - DeprecationWarning, stacklevel=2 - ) - relative = {True: "view-upright", False: "global"}.get(relative, relative) if relative == 'global': self.opts['center'] += QtGui.QVector3D(dx, dy, dz) elif relative == 'view-upright': @@ -444,13 +404,15 @@ def pixelSize(self, pos): dist = (pos-cam).length() xDist = dist * 2. * tan(0.5 * radians(self.opts['fov'])) return xDist / self.width() - + def mousePressEvent(self, ev): lpos = ev.position() if hasattr(ev, 'position') else ev.localPos() self.mousePos = lpos - + def mouseMoveEvent(self, ev): lpos = ev.position() if hasattr(ev, 'position') else ev.localPos() + if not hasattr(self, 'mousePos'): + self.mousePos = lpos diff = lpos - self.mousePos self.mousePos = lpos @@ -597,3 +559,24 @@ def renderToArray(self, size, format=GL_BGRA, type=GL_UNSIGNED_BYTE, textureSize glDeleteRenderbuffers(1, [depth_buf]) return output + + +class GLViewWidget(GLViewMixin, QtWidgets.QOpenGLWidget): + def __init__(self, *args, devicePixelRatio=None, **kwargs): + """ + Basic widget for displaying 3D data + - Rotation/scale controls + - Axis/grid display + - Export options + + ================ ============================================================== + **Arguments:** + parent (QObject, optional): Parent QObject. Defaults to None. + devicePixelRatio No longer in use. High-DPI displays should automatically + detect the correct resolution. + rotationMethod (str): Mechanism to drive the rotation method, options are + 'euler' and 'quaternion'. Defaults to 'euler'. + ================ ============================================================== + """ + super().__init__(*args, **kwargs) + self.setFocusPolicy(QtCore.Qt.FocusPolicy.ClickFocus) diff --git a/pyqtgraph/opengl/items/GLAxisItem.py b/pyqtgraph/opengl/items/GLAxisItem.py index 98331dfa84..a4a5ce8e9b 100644 --- a/pyqtgraph/opengl/items/GLAxisItem.py +++ b/pyqtgraph/opengl/items/GLAxisItem.py @@ -6,14 +6,14 @@ class GLAxisItem(GLGraphicsItem): """ - **Bases:** :class:`GLGraphicsItem ` + **Bases:** :class:`GLGraphicsItem ` Displays three lines indicating origin and orientation of local coordinate system. """ - def __init__(self, size=None, antialias=True, glOptions='translucent'): - GLGraphicsItem.__init__(self) + def __init__(self, size=None, antialias=True, glOptions='translucent', parentItem=None): + super().__init__(parentItem=parentItem) if size is None: size = QtGui.QVector3D(1,1,1) self.antialias = antialias diff --git a/pyqtgraph/opengl/items/GLBarGraphItem.py b/pyqtgraph/opengl/items/GLBarGraphItem.py index 9760403dd4..98c1dab04c 100644 --- a/pyqtgraph/opengl/items/GLBarGraphItem.py +++ b/pyqtgraph/opengl/items/GLBarGraphItem.py @@ -7,7 +7,7 @@ class GLBarGraphItem(GLMeshItem): - def __init__(self, pos, size): + def __init__(self, pos, size, parentItem=None): """ pos is (...,3) array of the bar positions (the corner of each bar) size is (...,3) array of the sizes of each bar @@ -27,4 +27,4 @@ def __init__(self, pos, size): faces = cubeFaces + (np.arange(nCubes) * 8).reshape(nCubes,1,1) md = MeshData(verts.reshape(nCubes*8,3), faces.reshape(nCubes*12,3)) - GLMeshItem.__init__(self, meshdata=md, shader='shaded', smooth=False) + super().__init__(meshdata=md, shader='shaded', smooth=False, parentItem=parentItem) diff --git a/pyqtgraph/opengl/items/GLBoxItem.py b/pyqtgraph/opengl/items/GLBoxItem.py index 9c743d4f4d..2f8f68a9ad 100644 --- a/pyqtgraph/opengl/items/GLBoxItem.py +++ b/pyqtgraph/opengl/items/GLBoxItem.py @@ -11,8 +11,8 @@ class GLBoxItem(GLGraphicsItem): Displays a wire-frame box. """ - def __init__(self, size=None, color=None, glOptions='translucent'): - GLGraphicsItem.__init__(self) + def __init__(self, size=None, color=None, glOptions='translucent', parentItem=None): + super().__init__(parentItem=parentItem) if size is None: size = QtGui.QVector3D(1,1,1) self.setSize(size=size) diff --git a/pyqtgraph/opengl/items/GLGradientLegendItem.py b/pyqtgraph/opengl/items/GLGradientLegendItem.py index 9453221eec..35285f1997 100644 --- a/pyqtgraph/opengl/items/GLGradientLegendItem.py +++ b/pyqtgraph/opengl/items/GLGradientLegendItem.py @@ -10,7 +10,7 @@ class GLGradientLegendItem(GLGraphicsItem): Displays legend colorbar on the screen. """ - def __init__(self, **kwds): + def __init__(self, parentItem=None, **kwds): """ Arguments: pos: position of the colorbar on the screen, from the top left corner, in pixels @@ -24,7 +24,7 @@ def __init__(self, **kwds): size as percentage legend title """ - GLGraphicsItem.__init__(self) + super().__init__(parentItem=parentItem) glopts = kwds.pop("glOptions", "additive") self.setGLOptions(glopts) self.pos = (10, 10) diff --git a/pyqtgraph/opengl/items/GLGraphItem.py b/pyqtgraph/opengl/items/GLGraphItem.py index 9738620cf7..987a41eb01 100644 --- a/pyqtgraph/opengl/items/GLGraphItem.py +++ b/pyqtgraph/opengl/items/GLGraphItem.py @@ -1,5 +1,6 @@ from OpenGL.GL import * # noqa import numpy as np + from ... import functions as fn from ...Qt import QtCore, QtGui from ..GLGraphicsItem import GLGraphicsItem @@ -13,8 +14,8 @@ class GLGraphItem(GLGraphicsItem): Useful for drawing networks, trees, etc. """ - def __init__(self, **kwds): - GLGraphicsItem.__init__(self) + def __init__(self, parentItem=None, **kwds): + super().__init__(parentItem=parentItem) self.edges = None self.edgeColor = QtGui.QColor(QtCore.Qt.GlobalColor.white) @@ -34,21 +35,21 @@ def setData(self, **kwds): 2D array of shape (M, 2) of connection data, each row contains indexes of two nodes that are connected. Dtype must be integer or unsigned. - edgeColor: QColor, array-like, optional. + edgeColor: color_like, optional The color to draw edges. Accepts the same arguments as :func:`~pyqtgraph.mkColor()`. If None, no edges will be drawn. Default is (1.0, 1.0, 1.0, 0.5). - edgeWidth: float, optional. + edgeWidth: float, optional Value specifying edge width. Default is 1.0 nodePositions : np.ndarray 2D array of shape (N, 3), where each row represents the x, y, z coordinates for each node - nodeColor : np.ndarray, QColor, str or array like + nodeColor : np.ndarray or float or color_like, optional 2D array of shape (N, 4) of dtype float32, where each row represents - the R, G, B, A vakues in range of 0-1, or for the same color for all + the R, G, B, A values in range of 0-1, or for the same color for all nodes, provide either QColor type or input for :func:`~pyqtgraph.mkColor()` - nodeSize : np.ndarray, float or int + nodeSize : np.ndarray or float or int Either 2D numpy array of shape (N, 1) where each row represents the size of each node, or if a scalar, apply the same size to all nodes **kwds diff --git a/pyqtgraph/opengl/items/GLGridItem.py b/pyqtgraph/opengl/items/GLGridItem.py index 9cde7dec71..49e1ba9e28 100644 --- a/pyqtgraph/opengl/items/GLGridItem.py +++ b/pyqtgraph/opengl/items/GLGridItem.py @@ -9,13 +9,13 @@ class GLGridItem(GLGraphicsItem): """ - **Bases:** :class:`GLGraphicsItem ` + **Bases:** :class:`GLGraphicsItem ` Displays a wire-frame grid. """ - def __init__(self, size=None, color=(255, 255, 255, 76.5), antialias=True, glOptions='translucent'): - GLGraphicsItem.__init__(self) + def __init__(self, size=None, color=(255, 255, 255, 76.5), antialias=True, glOptions='translucent', parentItem=None): + super().__init__(parentItem=parentItem) self.setGLOptions(glOptions) self.antialias = antialias if size is None: diff --git a/pyqtgraph/opengl/items/GLImageItem.py b/pyqtgraph/opengl/items/GLImageItem.py index 19109bc41c..0dcecc872c 100644 --- a/pyqtgraph/opengl/items/GLImageItem.py +++ b/pyqtgraph/opengl/items/GLImageItem.py @@ -1,18 +1,19 @@ from OpenGL.GL import * # noqa import numpy as np + from ..GLGraphicsItem import GLGraphicsItem __all__ = ['GLImageItem'] class GLImageItem(GLGraphicsItem): """ - **Bases:** :class:`GLGraphicsItem ` + **Bases:** :class:`GLGraphicsItem ` Displays image data as a textured quad. """ - def __init__(self, data, smooth=False, glOptions='translucent'): + def __init__(self, data, smooth=False, glOptions='translucent', parentItem=None): """ ============== ======================================================================================= @@ -25,7 +26,7 @@ def __init__(self, data, smooth=False, glOptions='translucent'): self.smooth = smooth self._needUpdate = False - GLGraphicsItem.__init__(self) + super().__init__(parentItem=parentItem) self.setData(data) self.setGLOptions(glOptions) self.texture = None diff --git a/pyqtgraph/opengl/items/GLLinePlotItem.py b/pyqtgraph/opengl/items/GLLinePlotItem.py index b1c5b364aa..c3e626ed6f 100644 --- a/pyqtgraph/opengl/items/GLLinePlotItem.py +++ b/pyqtgraph/opengl/items/GLLinePlotItem.py @@ -10,9 +10,9 @@ class GLLinePlotItem(GLGraphicsItem): """Draws line plots in 3D.""" - def __init__(self, **kwds): + def __init__(self, parentItem=None, **kwds): """All keyword arguments are passed to setData()""" - GLGraphicsItem.__init__(self) + super().__init__(parentItem=parentItem) glopts = kwds.pop('glOptions', 'additive') self.setGLOptions(glopts) self.pos = None diff --git a/pyqtgraph/opengl/items/GLMeshItem.py b/pyqtgraph/opengl/items/GLMeshItem.py index 883b3ee5b8..d5b605b105 100644 --- a/pyqtgraph/opengl/items/GLMeshItem.py +++ b/pyqtgraph/opengl/items/GLMeshItem.py @@ -10,11 +10,11 @@ class GLMeshItem(GLGraphicsItem): """ - **Bases:** :class:`GLGraphicsItem ` + **Bases:** :class:`GLGraphicsItem ` Displays a 3D triangle mesh. """ - def __init__(self, **kwds): + def __init__(self, parentItem=None, **kwds): """ ============== ===================================================== **Arguments:** @@ -47,7 +47,7 @@ def __init__(self, **kwds): 'computeNormals': True, } - GLGraphicsItem.__init__(self) + super().__init__(parentItem=parentItem) glopts = kwds.pop('glOptions', 'opaque') self.setGLOptions(glopts) shader = kwds.pop('shader', None) @@ -192,7 +192,7 @@ def paint(self): glNormalPointerf(norms) if faces is None: - glDrawArrays(GL_TRIANGLES, 0, np.product(verts.shape[:-1])) + glDrawArrays(GL_TRIANGLES, 0, np.prod(verts.shape[:-1])) else: faces = faces.astype(np.uint32).flatten() glDrawElements(GL_TRIANGLES, faces.shape[0], GL_UNSIGNED_INT, faces) diff --git a/pyqtgraph/opengl/items/GLScatterPlotItem.py b/pyqtgraph/opengl/items/GLScatterPlotItem.py index eb61d86510..3fb384592b 100644 --- a/pyqtgraph/opengl/items/GLScatterPlotItem.py +++ b/pyqtgraph/opengl/items/GLScatterPlotItem.py @@ -11,8 +11,8 @@ class GLScatterPlotItem(GLGraphicsItem): """Draws points at a list of 3D positions.""" - def __init__(self, **kwds): - super().__init__() + def __init__(self, parentItem=None, **kwds): + super().__init__(parentItem=parentItem) glopts = kwds.pop('glOptions', 'additive') self.setGLOptions(glopts) self.pos = None diff --git a/pyqtgraph/opengl/items/GLSurfacePlotItem.py b/pyqtgraph/opengl/items/GLSurfacePlotItem.py index 655cf15b51..aa40ca0db7 100644 --- a/pyqtgraph/opengl/items/GLSurfacePlotItem.py +++ b/pyqtgraph/opengl/items/GLSurfacePlotItem.py @@ -12,7 +12,7 @@ class GLSurfacePlotItem(GLMeshItem): Displays a surface plot on a regular x,y grid """ - def __init__(self, x=None, y=None, z=None, colors=None, **kwds): + def __init__(self, x=None, y=None, z=None, colors=None, parentItem=None, **kwds): """ The x, y, z, and colors arguments are passed to setData(). All other keyword arguments are passed to GLMeshItem.__init__(). @@ -24,7 +24,7 @@ def __init__(self, x=None, y=None, z=None, colors=None, **kwds): self._color = None self._vertexes = None self._meshdata = MeshData() - GLMeshItem.__init__(self, meshdata=self._meshdata, **kwds) + super().__init__(parentItem=parentItem, meshdata=self._meshdata, **kwds) self.setData(x, y, z, colors) diff --git a/pyqtgraph/opengl/items/GLTextItem.py b/pyqtgraph/opengl/items/GLTextItem.py index 1cb6d0d2b3..4c7a2555af 100644 --- a/pyqtgraph/opengl/items/GLTextItem.py +++ b/pyqtgraph/opengl/items/GLTextItem.py @@ -10,9 +10,9 @@ class GLTextItem(GLGraphicsItem): """Draws text in 3D.""" - def __init__(self, **kwds): + def __init__(self, parentItem=None, **kwds): """All keyword arguments are passed to setData()""" - GLGraphicsItem.__init__(self) + super().__init__(parentItem=parentItem) glopts = kwds.pop('glOptions', 'additive') self.setGLOptions(glopts) diff --git a/pyqtgraph/opengl/items/GLVolumeItem.py b/pyqtgraph/opengl/items/GLVolumeItem.py index 6dfb96e589..a1c28d3afb 100644 --- a/pyqtgraph/opengl/items/GLVolumeItem.py +++ b/pyqtgraph/opengl/items/GLVolumeItem.py @@ -8,13 +8,13 @@ class GLVolumeItem(GLGraphicsItem): """ - **Bases:** :class:`GLGraphicsItem ` + **Bases:** :class:`GLGraphicsItem ` Displays volumetric data. """ - def __init__(self, data, sliceDensity=1, smooth=True, glOptions='translucent'): + def __init__(self, data, sliceDensity=1, smooth=True, glOptions='translucent', parentItem=None): """ ============== ======================================================================================= **Arguments:** @@ -29,7 +29,7 @@ def __init__(self, data, sliceDensity=1, smooth=True, glOptions='translucent'): self.data = None self._needUpload = False self.texture = None - GLGraphicsItem.__init__(self) + super().__init__(parentItem=parentItem) self.setGLOptions(glOptions) self.setData(data) diff --git a/pyqtgraph/ordereddict.py b/pyqtgraph/ordereddict.py deleted file mode 100644 index 4c99a631ac..0000000000 --- a/pyqtgraph/ordereddict.py +++ /dev/null @@ -1,12 +0,0 @@ -import collections -import warnings - - -class OrderedDict(collections.OrderedDict): - def __init__(self, *args, **kwds): - warnings.warn( - "OrderedDict is in the standard library for supported versions of Python. Will be removed in 0.13", - DeprecationWarning, - stacklevel=2, - ) - super().__init__(*args, **kwds) diff --git a/pyqtgraph/parametertree/Parameter.py b/pyqtgraph/parametertree/Parameter.py index cd2a53e65c..6b7581780e 100644 --- a/pyqtgraph/parametertree/Parameter.py +++ b/pyqtgraph/parametertree/Parameter.py @@ -1,5 +1,4 @@ import re -import warnings import weakref from collections import OrderedDict @@ -755,24 +754,6 @@ def param(self, *names): def __repr__(self): return "<%s '%s' at 0x%x>" % (self.__class__.__name__, self.name(), id(self)) - def __getattr__(self, attr): - ## Leaving this undocumented because I might like to remove it in the future.. - #print type(self), attr - warnings.warn( - 'Use of Parameter.subParam is deprecated and will be removed in 0.13 ' - 'Use Parameter.param(name) instead.', - DeprecationWarning, stacklevel=2 - ) - if 'names' not in self.__dict__: - raise AttributeError(attr) - if attr in self.names: - import traceback - traceback.print_stack() - print("Warning: Use of Parameter.subParam is deprecated. Use Parameter.param(name) instead.") - return self.param(attr) - else: - raise AttributeError(attr) - def _renameChild(self, child, name): ## Only to be called from Parameter.rename if name in self.names: diff --git a/pyqtgraph/parametertree/__init__.py b/pyqtgraph/parametertree/__init__.py index 417637925b..44b758d04b 100644 --- a/pyqtgraph/parametertree/__init__.py +++ b/pyqtgraph/parametertree/__init__.py @@ -3,4 +3,4 @@ from .ParameterItem import ParameterItem from .ParameterSystem import ParameterSystem, SystemSolver from .ParameterTree import ParameterTree -from .interactive import RunOpts, interact, InteractiveFunction, Interactor +from .interactive import RunOptions, interact, InteractiveFunction, Interactor diff --git a/pyqtgraph/parametertree/interactive.py b/pyqtgraph/parametertree/interactive.py index 64cc33febb..05d3eb5939 100644 --- a/pyqtgraph/parametertree/interactive.py +++ b/pyqtgraph/parametertree/interactive.py @@ -4,12 +4,15 @@ import pydoc from . import Parameter +from .parameterTypes import ActionGroupParameter from .. import functions as fn -class RunOpts: - class PARAM_UNSET: - """Sentinel value for detecting parameters with unset values""" +class PARAM_UNSET: + """Sentinel value for detecting parameters with unset values""" + + +class RunOptions: ON_ACTION = "action" """ @@ -36,6 +39,10 @@ class InteractiveFunction: can provide an external scope for accessing the hooked up parameter signals. """ + # Attributes below are populated by `update_wrapper` but aren't detected by linters + __name__: str + __qualname__: str + def __init__(self, function, *, closures=None, **extra): """ Wraps a callable function in a way that forwards Parameter arguments as keywords @@ -61,9 +68,9 @@ def __init__(self, function, *, closures=None, **extra): self.parametersNeedRunKwargs = False self.parameterCache = {} - self.__name__ = function.__name__ - self.__doc__ = function.__doc__ - functools.update_wrapper(self, function) + # No need for wrapper __dict__ to function as function.__dict__, since + # Only __doc__, __name__, etc. attributes are required + functools.update_wrapper(self, function, updated=()) def __call__(self, **kwargs): """ @@ -185,26 +192,33 @@ def reconnect(self): return oldDisconnect def __str__(self): - return f"InteractiveFunction(`<{self.function.__name__}>`) at {hex(id(self))}" + return f"{type(self).__name__}(`<{self.function.__name__}>`) at {hex(id(self))}" def __repr__(self): return ( str(self) + " with keys:\n" - f"params={list(self.parameters)}, " + f"parameters={list(self.parameters)}, " f"extra={list(self.extra)}, " f"closures={list(self.closures)}" ) class Interactor: - runOpts = RunOpts.ON_CHANGED + runOptions = RunOptions.ON_ACTION parent = None - title = None + titleFormat = None nest = True existOk = True runActionTemplate = dict(type="action", defaultName="Run") - _optNames = ["runOpts", "parent", "title", "nest", "existOk", "runActionTemplate"] + _optionNames = [ + "runOptions", + "parent", + "titleFormat", + "nest", + "existOk", + "runActionTemplate", + ] def __init__(self, **kwargs): """ @@ -265,11 +279,12 @@ def interact( function, *, ignores=None, - runOpts=RunOpts.PARAM_UNSET, - parent=RunOpts.PARAM_UNSET, - title=RunOpts.PARAM_UNSET, - nest=RunOpts.PARAM_UNSET, - existOk=RunOpts.PARAM_UNSET, + runOptions=PARAM_UNSET, + parent=PARAM_UNSET, + titleFormat=PARAM_UNSET, + nest=PARAM_UNSET, + runActionTemplate=PARAM_UNSET, + existOk=PARAM_UNSET, **overrides, ): """ @@ -287,7 +302,7 @@ def interact( function: Callable function with which to interact. Can also be a :class:`InteractiveFunction`, if a reference to the bound signals is required. - runOpts: ``GroupParameter.`` value + runOptions: ``GroupParameter.`` value How the function should be run, i.e. when pressing an action, on sigValueChanged, and/or on sigValueChanging ignores: Sequence @@ -295,8 +310,8 @@ def interact( parent: GroupParameter Parent in which to add argument Parameters. If *None*, a new group parameter is created. - title: str or Callable - Title of the group sub-parameter if one must be created (see ``nest`` + titleFormat: str or Callable + title of the group sub-parameter if one must be created (see ``nest`` behavior). If a function is supplied, it must be of the form (str) -> str and will be passed the function name as an input nest: bool @@ -304,6 +319,13 @@ def interact( and arguments to that function are 'nested' inside as its children. If *False*, function arguments are directly added to this parameter instead of being placed inside a child GroupParameter + runActionTemplate: dict + Template for the action parameter which runs the function, used + if ``runOptions`` is set to ``GroupParameter.RUN_ACTION``. Note that + if keys like "name" or "type" are not included, they are inferred + from the previous / default ``runActionTemplate``. This allows + items that should only be set per-function to exist here, like + a ``shortcut`` or ``icon``. existOk: bool Whether it is OK for existing parameter names to bind to this function. See behavior during 'Parameter.insertChild' @@ -314,23 +336,22 @@ def interact( override can be a value (e.g. 5) or a dict specification of a parameter (e.g. dict(type='list', limits=[0, 10, 20])) """ + # Special case: runActionTemplate can be overridden to specify action + if runActionTemplate is not PARAM_UNSET: + runActionTemplate = {**self.runActionTemplate, **runActionTemplate} # Get every overridden default locs = locals() # Everything until action template - opts = { - kk: locs[kk] - for kk in self._optNames[:-1] - if locs[kk] is not RunOpts.PARAM_UNSET - } + opts = {kk: locs[kk] for kk in self._optionNames if locs[kk] is not PARAM_UNSET} oldOpts = self.setOpts(**opts) # Delete explicitly since correct values are now ``self`` attributes - del runOpts, title, nest, existOk, parent + del runOptions, titleFormat, nest, existOk, parent, runActionTemplate - funcDict = self.functionToParameterDict(function, **overrides) + function = self._toInteractiveFunction(function) + funcDict = self.functionToParameterDict(function.function, **overrides) children = funcDict.pop("children", []) # type: list[dict] chNames = [ch["name"] for ch in children] - funcGroup = self._resolveFunctionGroup(funcDict) - function = self._toInteractiveFunction(function) + funcGroup = self._resolveFunctionGroup(funcDict, function) # Values can't come both from closures and overrides/params, so ensure they don't # get created @@ -342,17 +363,21 @@ def interact( recycleNames = set(ignores) & set(chNames) for name in recycleNames: value = children[chNames.index(name)]["value"] - if name not in function.extra and value is not RunOpts.PARAM_UNSET: + if name not in function.extra and value is not PARAM_UNSET: function.extra[name] = value missingChildren = [ ch["name"] for ch in children - if ch["value"] is RunOpts.PARAM_UNSET + if ch["value"] is PARAM_UNSET and ch["name"] not in function.closures and ch["name"] not in function.extra ] if missingChildren: + # setOpts will not be called due to the value error, so reset here. + # This only matters to restore Interactor state in an outer try-except + # block + self.setOpts(**oldOpts) raise ValueError( f"Cannot interact with `{function}` since it has required parameters " f"{missingChildren} with no default or closure values provided." @@ -363,22 +388,20 @@ def interact( for name in checkNames: childOpts = children[chNames.index(name)] child = self.resolveAndHookupParameterChild(funcGroup, childOpts, function) - useParams.append(child) + if child is not None: + useParams.append(child) function.hookupParameters(useParams) - # If no top-level parent and no nesting, return the list of child parameters - ret = funcGroup or useParams - if RunOpts.ON_ACTION in self.runOpts: + if RunOptions.ON_ACTION in self.runOptions: # Add an extra action child which can activate the function - action = self._makeRunAction(self.nest, funcDict.get("tip"), function) - # Return just the action if no other params were allowed - if not useParams: - ret = action - if funcGroup: - funcGroup.addChild(action, existOk=self.existOk) - + action = self._resolveRunAction(function, funcGroup, funcDict.get("tip")) + if action: + useParams.append(action) + retValue = funcGroup if self.nest else useParams self.setOpts(**oldOpts) - return ret + # Return either the parent which contains all added options, or the list + # of created children (if no parent was created) + return retValue @functools.wraps(interact) def __call__(self, function, **kwargs): @@ -402,40 +425,41 @@ def decorator(function): return decorator - def _nameToTitle(self, name, forwardStrTitle=False): + def _nameToTitle(self, name, forwardStringTitle=False): """ - Converts a function name to a title based on ``self.title``. + Converts a function name to a title based on ``self.titleFormat``. Parameters ---------- name: str Name of the function - forwardStrTitle: bool - If ``self.title`` is a string and ``forwardStrTitle`` is True, - ``self.title`` will be used as the title. Otherwise, if ``self.title`` is - *None*, the name will be returned unchanged. Finally, if ``self.title`` is - a callable, it will be called with the name as an input and the output will - be returned - """ - titleFormat = self.title + forwardStringTitle: bool + If ``self.titleFormat`` is a string and ``forwardStrTitle`` is True, + ``self.titleFormat`` will be used as the title. Otherwise, if + ``self.titleFormat`` is *None*, the name will be returned unchanged. + Finally, if ``self.titleFormat`` is a callable, it will be called with + the name as an input and the output will be returned + """ + titleFormat = self.titleFormat isString = isinstance(titleFormat, str) - if titleFormat is None or (isString and not forwardStrTitle): + if titleFormat is None or (isString and not forwardStringTitle): return name elif isString: return titleFormat # else: titleFormat should be callable return titleFormat(name) - def _resolveFunctionGroup(self, parentOpts): + def _resolveFunctionGroup(self, functionDict, interactiveFunction): """ Returns parent parameter that holds function children. May be ``None`` if no top parent is provided and nesting is disabled. """ funcGroup = self.parent if self.nest: - funcGroup = Parameter.create(**parentOpts) - if self.parent and self.nest: - self.parent.addChild(funcGroup, existOk=self.existOk) + funcGroup = Parameter.create(**functionDict) + if self.parent: + funcGroup = self.parent.addChild(funcGroup, existOk=self.existOk) + funcGroup.sigActivated.connect(interactiveFunction.runFromAction) return funcGroup @staticmethod @@ -455,38 +479,54 @@ def _toInteractiveFunction(function): refOwner.interactiveRefs = [interactive] return interactive - def resolveAndHookupParameterChild(self, funcGroup, childOpts, interactiveFunc): - if not funcGroup: + def resolveAndHookupParameterChild( + self, functionGroup, childOpts, interactiveFunction + ): + if not functionGroup: child = Parameter.create(**childOpts) else: - child = funcGroup.addChild(childOpts, existOk=self.existOk) - if RunOpts.ON_CHANGED in self.runOpts: - child.sigValueChanged.connect(interactiveFunc.runFromChangedOrChanging) - if RunOpts.ON_CHANGING in self.runOpts: - child.sigValueChanging.connect(interactiveFunc.runFromChangedOrChanging) + child = functionGroup.addChild(childOpts, existOk=self.existOk) + if RunOptions.ON_CHANGED in self.runOptions: + child.sigValueChanged.connect(interactiveFunction.runFromChangedOrChanging) + if RunOptions.ON_CHANGING in self.runOptions: + child.sigValueChanging.connect(interactiveFunction.runFromChangedOrChanging) return child - def _makeRunAction(self, nest, tip, interactiveFunc): - # Add an extra action child which can activate the function + def _resolveRunAction(self, interactiveFunction, functionGroup, functionTip): + if isinstance(functionGroup, ActionGroupParameter): + functionGroup.setButtonOpts(visible=True) + child = None + else: + # Add an extra action child which can activate the function + createOpts = self._makePopulatedActionTemplate( + interactiveFunction.__name__, functionTip + ) + child = Parameter.create(**createOpts) + child.sigActivated.connect(interactiveFunction.runFromAction) + if functionGroup: + functionGroup.addChild(child, existOk=self.existOk) + return child + + def _makePopulatedActionTemplate(self, functionName="", functionTip=None): createOpts = self.runActionTemplate.copy() defaultName = createOpts.get("defaultName", "Run") - name = defaultName if nest else interactiveFunc.function.__name__ + name = defaultName if self.nest else functionName createOpts.setdefault("name", name) - if tip: - createOpts["tip"] = tip - child = Parameter.create(**createOpts) - child.sigActivated.connect(interactiveFunc.runFromAction) - return child + if functionTip: + createOpts.setdefault("tip", functionTip) + return createOpts def functionToParameterDict(self, function, **overrides): """ Converts a function into a list of child parameter dicts """ children = [] - out = dict(name=function.__name__, type="group", children=children) - if self.title is not None: - out["title"] = self._nameToTitle(function.__name__, forwardStrTitle=True) + name = function.__name__ + btnOpts = dict(**self._makePopulatedActionTemplate(name), visible=False) + out = dict(name=name, type="_actiongroup", children=children, button=btnOpts) + if self.titleFormat is not None: + out["title"] = self._nameToTitle(name, forwardStringTitle=True) funcParams = inspect.signature(function).parameters if function.__doc__: @@ -494,20 +534,28 @@ def functionToParameterDict(self, function, **overrides): synopsis, _ = pydoc.splitdoc(function.__doc__) if synopsis: out.setdefault("tip", synopsis) + out["button"].setdefault("tip", synopsis) # Make pyqtgraph parameter dicts from each parameter # Use list instead of funcParams.items() so kwargs can add to the iterable checkNames = list(funcParams) - isKwarg = [p.kind is p.VAR_KEYWORD for p in funcParams.values()] - if any(isKwarg): + parameterKinds = [p.kind for p in funcParams.values()] + _positional = inspect.Parameter.VAR_POSITIONAL + _keyword = inspect.Parameter.VAR_KEYWORD + if _keyword in parameterKinds: # Function accepts kwargs, so any overrides not already present as a function # parameter should be accepted # Remove the keyword parameter since it can't be parsed properly - # Only one kwarg can be in the signature, so there will be only one - # "True" index - del checkNames[isKwarg.index(True)] + # Kwargs must appear at the end of the parameter list + del checkNames[-1] notInSignature = [n for n in overrides if n not in checkNames] checkNames.extend(notInSignature) + if _positional in parameterKinds: + # *args is also difficult to handle for key-value paradigm + # and will mess with the logic for detecting whether any parameter + # is left unfilled + del checkNames[parameterKinds.index(_positional)] + for name in checkNames: # May be none if this is an override name after function accepted kwargs param = funcParams.get(name) @@ -556,10 +604,10 @@ def createFunctionParameter(self, name, signatureParameter, overridesInfo): pgDict["name"] = name # Required function arguments with any override specifications can still be # unfilled at this point - pgDict.setdefault("value", RunOpts.PARAM_UNSET) + pgDict.setdefault("value", PARAM_UNSET) # Anywhere a title is specified should take precedence over the default factory - if self.title is not None: + if self.titleFormat is not None: pgDict.setdefault("title", self._nameToTitle(name)) pgDict.setdefault("type", type(pgDict["value"]).__name__) return pgDict @@ -571,7 +619,7 @@ def __repr__(self): return str(self) def getOpts(self): - return {attr: getattr(self, attr) for attr in self._optNames} + return {attr: getattr(self, attr) for attr in self._optionNames} interact = Interactor() diff --git a/pyqtgraph/parametertree/parameterTypes/__init__.py b/pyqtgraph/parametertree/parameterTypes/__init__.py index 57bdd5838f..2fe0336e04 100644 --- a/pyqtgraph/parametertree/parameterTypes/__init__.py +++ b/pyqtgraph/parametertree/parameterTypes/__init__.py @@ -1,5 +1,6 @@ from ..Parameter import registerParameterItemType, registerParameterType from .action import ActionParameter, ActionParameterItem +from .actiongroup import ActionGroup, ActionGroupParameter, ActionGroupParameterItem from .basetypes import ( GroupParameter, GroupParameterItem, @@ -27,7 +28,9 @@ registerParameterItemType('int', NumericParameterItem, SimpleParameter, override=True) registerParameterItemType('str', StrParameterItem, SimpleParameter, override=True) -registerParameterType('group', GroupParameter, override=True) +registerParameterType('group', GroupParameter, override=True) +# Keep actiongroup private for now, mainly useful for Interactor but not externally +registerParameterType('_actiongroup', ActionGroupParameter, override=True) registerParameterType('action', ActionParameter, override=True) registerParameterType('calendar', CalendarParameter, override=True) diff --git a/pyqtgraph/parametertree/parameterTypes/action.py b/pyqtgraph/parametertree/parameterTypes/action.py index 58f8a1f151..ff69d09422 100644 --- a/pyqtgraph/parametertree/parameterTypes/action.py +++ b/pyqtgraph/parametertree/parameterTypes/action.py @@ -1,8 +1,49 @@ -from ...Qt import QtCore, QtWidgets +from ...Qt import QtCore, QtWidgets, QtGui from ..Parameter import Parameter from ..ParameterItem import ParameterItem +class ParameterControlledButton(QtWidgets.QPushButton): + settableAttributes = { + "title", "tip", "icon", "shortcut", "enabled", "visible" + } + + def __init__(self, parameter=None, parent=None): + super().__init__(parent) + if not parameter: + return + parameter.sigNameChanged.connect(self.onNameChange) + parameter.sigOptionsChanged.connect(self.updateOpts) + self.clicked.connect(parameter.activate) + self.updateOpts(parameter, parameter.opts) + + def updateOpts(self, param, opts): + # Of the attributes that can be set on a QPushButton, only the text + # and tooltip attributes are different from standard pushbutton names + nameMap = dict(title="text", tip="toolTip") + # Special case: "title" could be none, in which case make it something + # readable by the simple copy-paste logic later + opts = opts.copy() + if "name" in opts: + opts.setdefault("title", opts["name"]) + if "title" in opts and opts["title"] is None: + opts["title"] = param.title() + + # Another special case: icons should be loaded from data before + # being passed to the button + if "icon" in opts: + opts["icon"] = QtGui.QIcon(opts["icon"]) + + for attr in self.settableAttributes.intersection(opts): + buttonAttr = nameMap.get(attr, attr) + capitalized = buttonAttr[0].upper() + buttonAttr[1:] + setter = getattr(self, f"set{capitalized}") + setter(opts[attr]) + + def onNameChange(self, param, name): + self.updateOpts(param, dict(title=param.title())) + + class ActionParameterItem(ParameterItem): """ParameterItem displaying a clickable button.""" def __init__(self, param, depth): @@ -11,13 +52,11 @@ def __init__(self, param, depth): self.layout = QtWidgets.QHBoxLayout() self.layout.setContentsMargins(0, 0, 0, 0) self.layoutWidget.setLayout(self.layout) - self.button = QtWidgets.QPushButton() + self.button = ParameterControlledButton(param, self.layoutWidget) #self.layout.addSpacing(100) self.layout.addWidget(self.button) self.layout.addStretch() - self.button.clicked.connect(self.buttonClicked) self.titleChanged() - self.optsChanged(self.param, self.param.opts) def treeWidgetChanged(self): ParameterItem.treeWidgetChanged(self) @@ -29,26 +68,25 @@ def treeWidgetChanged(self): tree.setItemWidget(self, 0, self.layoutWidget) def titleChanged(self): - self.button.setText(self.param.title()) self.setSizeHint(0, self.button.sizeHint()) - def optsChanged(self, param, opts): - ParameterItem.optsChanged(self, param, opts) - - if 'enabled' in opts: - self.button.setEnabled(opts['enabled']) - - if 'tip' in opts: - self.button.setToolTip(opts['tip']) - - def buttonClicked(self): - self.param.activate() - class ActionParameter(Parameter): - """Used for displaying a button within the tree. + """ + Used for displaying a button within the tree. ``sigActivated(self)`` is emitted when the button is clicked. + + Parameters + ---------- + icon: str + Icon to display in the button. Can be any argument accepted + by :class:`QIcon `. + shortcut: str + Key sequence to use as a shortcut for the button. Note that this shortcut is + associated with spawned parameters, i.e. the shortcut will only work when this + parameter has an item in a tree that is visible. Can be set to any string + accepted by :class:`QKeySequence `. """ itemClass = ActionParameterItem sigActivated = QtCore.Signal(object) diff --git a/pyqtgraph/parametertree/parameterTypes/actiongroup.py b/pyqtgraph/parametertree/parameterTypes/actiongroup.py new file mode 100644 index 0000000000..b69ab96943 --- /dev/null +++ b/pyqtgraph/parametertree/parameterTypes/actiongroup.py @@ -0,0 +1,78 @@ +import warnings + +from ...Qt import QtCore +from .action import ParameterControlledButton +from .basetypes import GroupParameter, GroupParameterItem +from ..ParameterItem import ParameterItem +from ...Qt import QtCore, QtWidgets + + +class ActionGroupParameterItem(GroupParameterItem): + """ + Wraps a :class:`GroupParameterItem` to manage function parameters created by + an interactor. Provies a button widget which mimics an ``action`` parameter. + """ + + def __init__(self, param, depth): + self.itemWidget = QtWidgets.QWidget() + self.button = ParameterControlledButton(parent=self.itemWidget) + self.button.clicked.connect(param.activate) + + self.itemWidget.setLayout(layout := QtWidgets.QHBoxLayout()) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.button) + + super().__init__(param, depth) + + def treeWidgetChanged(self): + ParameterItem.treeWidgetChanged(self) + tw = self.treeWidget() + if tw is None: + return + tw.setItemWidget(self, 1, self.itemWidget) + + def optsChanged(self, param, opts): + if "button" in opts: + buttonOpts = opts["button"] or dict(visible=False) + self.button.updateOpts(param, buttonOpts) + super().optsChanged(param, opts) + + +class ActionGroupParameter(GroupParameter): + itemClass = ActionGroupParameterItem + + sigActivated = QtCore.Signal(object) + + def __init__(self, **opts): + opts.setdefault("button", {}) + super().__init__(**opts) + + def activate(self): + self.sigActivated.emit(self) + self.emitStateChanged('activated', None) + + def setButtonOpts(self, **opts): + """ + Update individual button options without replacing the entire + button definition. + """ + buttonOpts = self.opts.get("button", {}).copy() + buttonOpts.update(opts) + self.setOpts(button=buttonOpts) + + +class ActionGroup(ActionGroupParameter): + sigActivated = QtCore.Signal() + + def __init__(self, **opts): + warnings.warn( + "`ActionGroup` is deprecated and will be removed in the first release after " + "January 2023. Use `ActionGroupParameter` instead. See " + "https://github.com/pyqtgraph/pyqtgraph/pull/2505 for details.", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(**opts) + + def activate(self): + self.sigActivated.emit() diff --git a/pyqtgraph/parametertree/parameterTypes/basetypes.py b/pyqtgraph/parametertree/parameterTypes/basetypes.py index 7992f015f2..ba9a27c921 100644 --- a/pyqtgraph/parametertree/parameterTypes/basetypes.py +++ b/pyqtgraph/parametertree/parameterTypes/basetypes.py @@ -1,10 +1,10 @@ import builtins -from ..Parameter import Parameter -from ..ParameterItem import ParameterItem from ... import functions as fn from ... import icons from ...Qt import QtCore, QtGui, QtWidgets, mkQApp +from ..Parameter import Parameter +from ..ParameterItem import ParameterItem class WidgetParameterItem(ParameterItem): @@ -258,8 +258,8 @@ class SimpleParameter(Parameter): """ Parameter representing a single value. - This parameter is backed by :class:`WidgetParameterItem` to represent the - following parameter names through various subclasses: + This parameter is backed by :class:`~pyqtgraph.parametertree.parameterTypes.basetypes.WidgetParameterItem` + to represent the following parameter names through various subclasses: - 'int' - 'float' @@ -274,7 +274,7 @@ def __init__(self, *args, **kargs): Initialize the parameter. This is normally called implicitly through :meth:`Parameter.create`. - The keyword arguments avaialble to :meth:`Parameter.__init__` are + The keyword arguments available to :meth:`Parameter.__init__` are applicable. """ Parameter.__init__(self, *args, **kargs) @@ -337,22 +337,11 @@ def updateDepth(self, depth): """ Change set the item font to bold and increase the font size on outermost groups. """ - app = mkQApp() - palette = app.palette() - background = palette.base().color() - h, s, l, a = background.getHslF() - lightness = 0.5 + (l - 0.5) * .8 - altBackground = QtGui.QColor.fromHslF(h, s, lightness, a) - for c in [0, 1]: font = self.font(c) font.setBold(True) if depth == 0: font.setPointSize(self.pointSize() + 1) - self.setBackground(c, background) - else: - self.setBackground(c, altBackground) - self.setForeground(c, palette.text().color()) self.setFont(c, font) self.titleChanged() # sets the size hint for column 0 which is based on the new font diff --git a/pyqtgraph/parametertree/parameterTypes/calendar.py b/pyqtgraph/parametertree/parameterTypes/calendar.py index 5c61792c4e..48f1fac1c1 100644 --- a/pyqtgraph/parametertree/parameterTypes/calendar.py +++ b/pyqtgraph/parametertree/parameterTypes/calendar.py @@ -51,5 +51,6 @@ def _interpretValue(self, v): def saveState(self, filter=None): state = super().saveState(filter) fmt = self._interpretFormat() - state['value'] = state['value'].toString(fmt) + if state['value'] is not None: + state['value'] = state['value'].toString(fmt) return state diff --git a/pyqtgraph/parametertree/parameterTypes/checklist.py b/pyqtgraph/parametertree/parameterTypes/checklist.py index 67425072f7..8cf4179737 100644 --- a/pyqtgraph/parametertree/parameterTypes/checklist.py +++ b/pyqtgraph/parametertree/parameterTypes/checklist.py @@ -31,12 +31,9 @@ def _constructMetaBtns(self): self.metaBtnLayout.addWidget(btn) btn.clicked.connect(getattr(self, f'{title.lower()}AllClicked')) - self.metaBtns['default'] = WidgetParameterItem.makeDefaultButton(self) + self.metaBtns['default'] = self.makeDefaultButton() self.metaBtnLayout.addWidget(self.metaBtns['default']) - def defaultClicked(self): - self.param.setToDefault() - def treeWidgetChanged(self): ParameterItem.treeWidgetChanged(self) tw = self.treeWidget() @@ -45,9 +42,13 @@ def treeWidgetChanged(self): tw.setItemWidget(self, 1, self.metaBtnWidget) def selectAllClicked(self): + # timer stop: see explanation on param.setToDefault() + self.param.valChangingProxy.timer.stop() self.param.setValue(self.param.reverse[0]) def clearAllClicked(self): + # timer stop: see explanation on param.setToDefault() + self.param.valChangingProxy.timer.stop() self.param.setValue([]) def insertChild(self, pos, item): @@ -65,19 +66,38 @@ def takeChild(self, i): self.btnGrp.removeButton(child.widget) def optsChanged(self, param, opts): + super().optsChanged(param, opts) if 'expanded' in opts: for btn in self.metaBtns.values(): btn.setVisible(opts['expanded']) exclusive = opts.get('exclusive', param.opts['exclusive']) enabled = opts.get('enabled', param.opts['enabled']) - for btn in self.metaBtns.values(): - btn.setDisabled(exclusive or (not enabled)) + for name, btn in self.metaBtns.items(): + if name != 'default': + btn.setDisabled(exclusive or (not enabled)) self.btnGrp.setExclusive(exclusive) + # "Limits" will force update anyway, no need to duplicate if it's present + if 'limits' not in opts and ('enabled' in opts or 'readonly' in opts): + self.updateDefaultBtn() def expandedChangedEvent(self, expanded): for btn in self.metaBtns.values(): btn.setVisible(expanded) + def valueChanged(self, param, val): + self.updateDefaultBtn() + + def updateDefaultBtn(self): + self.metaBtns["default"].setEnabled( + not self.param.valueIsDefault() + and self.param.opts["enabled"] + and self.param.writable() + ) + return + + makeDefaultButton = WidgetParameterItem.makeDefaultButton + defaultClicked = WidgetParameterItem.defaultClicked + class RadioParameterItem(BoolParameterItem): """ Allows radio buttons to function as booleans when `exclusive` is *True* @@ -135,6 +155,11 @@ class ChecklistParameter(GroupParameter): itemClass = ChecklistParameterItem def __init__(self, **opts): + # Child options are populated through values, not explicit "children" + if 'children' in opts: + raise ValueError( + "Cannot pass 'children' to ChecklistParameter. Pass a 'value' key only." + ) self.targetValue = None limits = opts.setdefault('limits', []) self.forward, self.reverse = ListParameter.mapping(limits) @@ -150,7 +175,12 @@ def __init__(self, **opts): # Also, value calculation will be incorrect until children are added, so make sure to recompute self.setValue(value) - self.valChangingProxy = SignalProxy(self.sigValueChanging, delay=opts.get('delay', 1.0), slot=self._finishChildChanges) + self.valChangingProxy = SignalProxy( + self.sigValueChanging, + delay=opts.get('delay', 1.0), + slot=self._finishChildChanges, + threadSafe=False, + ) def childrenValue(self): vals = [self.forward[p.name()] for p in self.children() if p.value()] @@ -162,8 +192,13 @@ def childrenValue(self): else: return vals - def _onChildChanging(self, _ch, _val): - self.sigValueChanging.emit(self, self.childrenValue()) + def _onChildChanging(self, child, value): + # When exclusive, ensure only this value is True + if self.opts['exclusive'] and value: + value = self.forward[child.name()] + else: + value = self.childrenValue() + self.sigValueChanging.emit(self, value) def updateLimits(self, _param, limits): oldOpts = self.names @@ -191,11 +226,8 @@ def updateLimits(self, _param, limits): def _finishChildChanges(self, paramAndValue): param, value = paramAndValue - if self.opts['exclusive']: - val = self.reverse[0][self.reverse[1].index(param.name())] - return self.setValue(val) # Interpret value, fire sigValueChanged - return self.setValue(self.childrenValue()) + return self.setValue(value) def optsChanged(self, param, opts): if 'exclusive' in opts: @@ -204,23 +236,75 @@ def optsChanged(self, param, opts): self.updateLimits(None, self.opts.get('limits', [])) if 'delay' in opts: self.valChangingProxy.setDelay(opts['delay']) - + def setValue(self, value, blockSignal=None): self.targetValue = value - exclusive = self.opts['exclusive'] - # Will emit at the end, so no problem discarding existing changes - cmpVals = value if isinstance(value, list) else [value] - for ii in range(len(cmpVals)-1, -1, -1): - exists = any(fn.eq(cmpVals[ii], lim) for lim in self.reverse[0]) - if not exists: - del cmpVals[ii] - names = [self.reverse[1][self.reverse[0].index(val)] for val in cmpVals] - if exclusive and len(names) > 1: - names = [names[0]] - elif exclusive and not len(names) and len(self.forward): - # An option is required during exclusivity - names = [self.reverse[1][0]] + if not isinstance(value, list): + value = [value] + names, values = self._intersectionWithLimits(value) + valueToSet = values + + if self.opts['exclusive']: + if len(self.forward): + # Exclusive means at least one entry must exist, grab from limits + # if they exist + names.append(self.reverse[1][0]) + if len(names) > 1: + names = names[:1] + if not len(names): + valueToSet = None + else: + valueToSet = self.forward[names[0]] + for chParam in self: checked = chParam.name() in names + # Will emit at the end, so no problem discarding existing changes chParam.setValue(checked, self._onChildChanging) - super().setValue(self.childrenValue(), blockSignal) + super().setValue(valueToSet, blockSignal) + + def _intersectionWithLimits(self, values: list): + """ + Returns the (names, values) from limits that intersect with ``values``. + """ + allowedNames = [] + allowedValues = [] + # Could be replaced by "value in self.reverse[0]" and "reverse[0].index", + # but this allows for using pg.eq to cover more diverse value options + for val in values: + for limitName, limitValue in zip(*self.reverse): + if fn.eq(limitValue, val): + allowedNames.append(limitName) + allowedValues.append(val) + break + return allowedNames, allowedValues + + def setToDefault(self): + # Since changing values are covered by a proxy, this method must be overridden + # to flush changes. Otherwise, setting to default while waiting for changes + # to finalize will override the request to take default values + self.valChangingProxy.timer.stop() + super().setToDefault() + + def saveState(self, filter=None): + # Unlike the normal GroupParameter, child states shouldn't be separately + # preserved + state = super().saveState(filter) + state.pop("children", None) + return state + + def restoreState( + self, + state, + recursive=True, + addChildren=True, + removeChildren=True, + blockSignals=True + ): + # Child management shouldn't happen through state + return super().restoreState( + state, + recursive, + addChildren=False, + removeChildren=False, + blockSignals=blockSignals + ) diff --git a/pyqtgraph/parametertree/parameterTypes/pen.py b/pyqtgraph/parametertree/parameterTypes/pen.py index 87628f60ed..91d2ddad3c 100644 --- a/pyqtgraph/parametertree/parameterTypes/pen.py +++ b/pyqtgraph/parametertree/parameterTypes/pen.py @@ -1,26 +1,52 @@ import re from contextlib import ExitStack -from . import GroupParameterItem +from . import GroupParameterItem, WidgetParameterItem from .basetypes import GroupParameter, Parameter, ParameterItem from .qtenum import QtEnumParameter from ... import functions as fn -from ...Qt import QtCore +from ...Qt import QtCore, QtWidgets from ...SignalProxy import SignalProxy from ...widgets.PenPreviewLabel import PenPreviewLabel class PenParameterItem(GroupParameterItem): def __init__(self, param, depth): + self.defaultBtn = self.makeDefaultButton() super().__init__(param, depth) + self.itemWidget = QtWidgets.QWidget() + layout = QtWidgets.QHBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(2) + self.penLabel = PenPreviewLabel(param) + for child in self.penLabel, self.defaultBtn: + layout.addWidget(child) + self.itemWidget.setLayout(layout) + + def optsChanged(self, param, opts): + if "enabled" in opts or "readonly" in opts: + self.updateDefaultBtn() def treeWidgetChanged(self): ParameterItem.treeWidgetChanged(self) tw = self.treeWidget() if tw is None: return - tw.setItemWidget(self, 1, self.penLabel - ) + tw.setItemWidget(self, 1, self.itemWidget) + + defaultClicked = WidgetParameterItem.defaultClicked + makeDefaultButton = WidgetParameterItem.makeDefaultButton + + def valueChanged(self, param, val): + self.updateDefaultBtn() + + def updateDefaultBtn(self): + self.defaultBtn.setEnabled( + not self.param.valueIsDefault() + and self.param.opts["enabled"] + and self.param.writable() + ) + class PenParameter(GroupParameter): """ @@ -46,10 +72,29 @@ def __init__(self, **opts): if 'children' in opts: raise KeyError('Cannot set "children" argument in Pen Parameter opts') super().__init__(**opts, children=list(children)) - self.valChangingProxy = SignalProxy(self.sigValueChanging, delay=1.0, slot=self._childrenFinishedChanging) + self.valChangingProxy = SignalProxy( + self.sigValueChanging, + delay=1.0, + slot=self._childrenFinishedChanging, + threadSafe=False, + ) def _childrenFinishedChanging(self, paramAndValue): - self.sigValueChanged.emit(*paramAndValue) + self.setValue(self.pen) + + def setDefault(self, val): + pen = self._interpretValue(val) + with self.treeChangeBlocker(): + # Block changes until all are finalized + for opt in self.names: + # Booleans have different naming convention + if isinstance(self[opt], bool): + attrName = f'is{opt.title()}' + else: + attrName = opt + self.child(opt).setDefault(getattr(pen, attrName)()) + out = super().setDefault(val) + return out def saveState(self, filter=None): state = super().saveState(filter) @@ -129,20 +174,13 @@ def _makeChildren(self, boundPen=None): name = name.title().strip() p.setOpts(title=name, default=default) - def penPropertyWrapper(propertySetter): - def tiePenPropToParam(_, value): - propertySetter(value) - self.sigValueChanging.emit(self, self.pen) - - return tiePenPropToParam - if boundPen is not None: self.updateFromPen(param, boundPen) for p in param: - setter, setName = self._setterForParam(p.name(), boundPen, returnName=True) + setName = f'set{p.name().capitalize()}' # Instead, set the parameter which will signal the old setter setattr(boundPen, setName, p.setValue) - newSetter = penPropertyWrapper(setter) + newSetter = self.penPropertySetter # Edge case: color picker uses a dialog with user interaction, so wait until full change there if p.type() != 'color': p.sigValueChanging.connect(newSetter) @@ -153,13 +191,13 @@ def tiePenPropToParam(_, value): return param - @staticmethod - def _setterForParam(paramName, obj, returnName=False): - formatted = paramName[0].upper() + paramName[1:] - setter = getattr(obj, f'set{formatted}') - if returnName: - return setter, formatted - return setter + def penPropertySetter(self, p, value): + boundPen = self.pen + setName = f'set{p.name().capitalize()}' + # boundPen.setName has been monkey-patched + # so we get the original setter from the class + getattr(boundPen.__class__, setName)(boundPen, value) + self.sigValueChanging.emit(self, boundPen) @staticmethod def updateFromPen(param, pen): diff --git a/pyqtgraph/parametertree/parameterTypes/slider.py b/pyqtgraph/parametertree/parameterTypes/slider.py index a15e4fb0cc..1e27c04988 100644 --- a/pyqtgraph/parametertree/parameterTypes/slider.py +++ b/pyqtgraph/parametertree/parameterTypes/slider.py @@ -20,6 +20,7 @@ def __init__(self, param, depth): def updateDisplayLabel(self, value=None): if value is None: value = self.param.value() + self.sliderLabel.setText(self.prettyTextValue(self.slider.value())) value = str(value) if self._suffix is None: suffixTxt = '' @@ -27,9 +28,13 @@ def updateDisplayLabel(self, value=None): suffixTxt = f' {self._suffix}' self.displayLabel.setText(value + suffixTxt) + def setSuffix(self, suffix): self._suffix = suffix - self._updateLabel(self.slider.value()) + # This may be called during widget construction in which case there is no + # displayLabel yet + if hasattr(self, 'displayLabel'): + self.updateDisplayLabel(self.slider.value()) def makeWidget(self): param = self.param @@ -39,7 +44,7 @@ def makeWidget(self): self.slider = QtWidgets.QSlider() self.slider.setOrientation(QtCore.Qt.Orientation.Horizontal) - lbl = QtWidgets.QLabel() + lbl = self.sliderLabel = QtWidgets.QLabel() lbl.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeft) w = QtWidgets.QWidget() @@ -54,10 +59,7 @@ def setValue(v): def getValue(): return self.span[self.slider.value()].item() - def vChanged(v): - lbl.setText(self.prettyTextValue(v)) - - self.slider.valueChanged.connect(vChanged) + self.slider.valueChanged.connect(self.updateDisplayLabel) def onMove(pos): self.sigChanging.emit(self, self.span[pos].item()) @@ -109,7 +111,6 @@ def optsChanged(self, param, opts): w.setMaximum(len(span) - 1) if 'suffix' in opts: self.setSuffix(opts['suffix']) - self.slider.valueChanged.emit(self.slider.value()) def limitsChanged(self, param, limits): self.optsChanged(param, dict(limits=limits)) diff --git a/pyqtgraph/pgcollections.py b/pyqtgraph/pgcollections.py deleted file mode 100644 index b88d34f600..0000000000 --- a/pyqtgraph/pgcollections.py +++ /dev/null @@ -1,484 +0,0 @@ -""" -advancedTypes.py - Basic data structures not included with python -Copyright 2010 Luke Campagnola -Distributed under MIT/X11 license. See license.txt for more information. - -Includes: - - OrderedDict - Dictionary which preserves the order of its elements - - BiDict, ReverseDict - Bi-directional dictionaries - - ThreadsafeDict, ThreadsafeList - Self-mutexed data structures -""" - -import warnings - -warnings.warn( - "None of these are used in pyqtgraph. Will be removed in 0.13", - DeprecationWarning, stacklevel=2 -) - -import copy -import threading -from collections import OrderedDict - -try: - from collections.abc import Sequence -except ImportError: - # fallback for python < 3.3 - from collections import Sequence - - -class ReverseDict(dict): - """extends dict so that reverse lookups are possible by requesting the key as a list of length 1: - d = BiDict({'x': 1, 'y': 2}) - d['x'] - 1 - d[[2]] - 'y' - """ - def __init__(self, data=None): - if data is None: - data = {} - self.reverse = {} - for k in data: - self.reverse[data[k]] = k - dict.__init__(self, data) - - def __getitem__(self, item): - if type(item) is list: - return self.reverse[item[0]] - else: - return dict.__getitem__(self, item) - - def __setitem__(self, item, value): - self.reverse[value] = item - dict.__setitem__(self, item, value) - - def __deepcopy__(self, memo): - raise Exception("deepcopy not implemented") - - -class BiDict(dict): - """extends dict so that reverse lookups are possible by adding each reverse combination to the dict. - This only works if all values and keys are unique.""" - def __init__(self, data=None): - if data is None: - data = {} - dict.__init__(self) - for k in data: - self[data[k]] = k - - def __setitem__(self, item, value): - dict.__setitem__(self, item, value) - dict.__setitem__(self, value, item) - - def __deepcopy__(self, memo): - raise Exception("deepcopy not implemented") - -class ThreadsafeDict(dict): - """Extends dict so that getitem, setitem, and contains are all thread-safe. - Also adds lock/unlock functions for extended exclusive operations - Converts all sub-dicts and lists to threadsafe as well. - """ - - def __init__(self, *args, **kwargs): - self.mutex = threading.RLock() - dict.__init__(self, *args, **kwargs) - for k in self: - if type(self[k]) is dict: - self[k] = ThreadsafeDict(self[k]) - - def __getitem__(self, attr): - self.lock() - try: - val = dict.__getitem__(self, attr) - finally: - self.unlock() - return val - - def __setitem__(self, attr, val): - if type(val) is dict: - val = ThreadsafeDict(val) - self.lock() - try: - dict.__setitem__(self, attr, val) - finally: - self.unlock() - - def __contains__(self, attr): - self.lock() - try: - val = dict.__contains__(self, attr) - finally: - self.unlock() - return val - - def __len__(self): - self.lock() - try: - val = dict.__len__(self) - finally: - self.unlock() - return val - - def clear(self): - self.lock() - try: - dict.clear(self) - finally: - self.unlock() - - def lock(self): - self.mutex.acquire() - - def unlock(self): - self.mutex.release() - - def __deepcopy__(self, memo): - raise Exception("deepcopy not implemented") - -class ThreadsafeList(list): - """Extends list so that getitem, setitem, and contains are all thread-safe. - Also adds lock/unlock functions for extended exclusive operations - Converts all sub-lists and dicts to threadsafe as well. - """ - - def __init__(self, *args, **kwargs): - self.mutex = threading.RLock() - list.__init__(self, *args, **kwargs) - for k in self: - self[k] = mkThreadsafe(self[k]) - - def __getitem__(self, attr): - self.lock() - try: - val = list.__getitem__(self, attr) - finally: - self.unlock() - return val - - def __setitem__(self, attr, val): - val = makeThreadsafe(val) - self.lock() - try: - list.__setitem__(self, attr, val) - finally: - self.unlock() - - def __contains__(self, attr): - self.lock() - try: - val = list.__contains__(self, attr) - finally: - self.unlock() - return val - - def __len__(self): - self.lock() - try: - val = list.__len__(self) - finally: - self.unlock() - return val - - def lock(self): - self.mutex.acquire() - - def unlock(self): - self.mutex.release() - - def __deepcopy__(self, memo): - raise Exception("deepcopy not implemented") - - -def makeThreadsafe(obj): - if type(obj) is dict: - return ThreadsafeDict(obj) - elif type(obj) is list: - return ThreadsafeList(obj) - elif type(obj) in [str, int, float, bool, tuple]: - return obj - else: - raise Exception("Not sure how to make object of type %s thread-safe" % str(type(obj))) - - -class Locker(object): - def __init__(self, lock): - self.lock = lock - self.lock.acquire() - def __del__(self): - try: - self.lock.release() - except: - pass - -class CaselessDict(OrderedDict): - """Case-insensitive dict. Values can be set and retrieved using keys of any case. - Note that when iterating, the original case is returned for each key.""" - def __init__(self, *args): - OrderedDict.__init__(self, {}) ## requirement for the empty {} here seems to be a python bug? - self.keyMap = OrderedDict([(k.lower(), k) for k in OrderedDict.keys(self)]) - if len(args) == 0: - return - elif len(args) == 1 and isinstance(args[0], dict): - for k in args[0]: - self[k] = args[0][k] - else: - raise Exception("CaselessDict may only be instantiated with a single dict.") - - #def keys(self): - #return self.keyMap.values() - - def __setitem__(self, key, val): - kl = key.lower() - if kl in self.keyMap: - OrderedDict.__setitem__(self, self.keyMap[kl], val) - else: - OrderedDict.__setitem__(self, key, val) - self.keyMap[kl] = key - - def __getitem__(self, key): - kl = key.lower() - if kl not in self.keyMap: - raise KeyError(key) - return OrderedDict.__getitem__(self, self.keyMap[kl]) - - def __contains__(self, key): - return key.lower() in self.keyMap - - def update(self, d): - for k, v in d.items(): - self[k] = v - - def copy(self): - return CaselessDict(OrderedDict.copy(self)) - - def __delitem__(self, key): - kl = key.lower() - if kl not in self.keyMap: - raise KeyError(key) - OrderedDict.__delitem__(self, self.keyMap[kl]) - del self.keyMap[kl] - - def __deepcopy__(self, memo): - raise Exception("deepcopy not implemented") - - def clear(self): - OrderedDict.clear(self) - self.keyMap.clear() - - - -class ProtectedDict(dict): - """ - A class allowing read-only 'view' of a dict. - The object can be treated like a normal dict, but will never modify the original dict it points to. - Any values accessed from the dict will also be read-only. - """ - def __init__(self, data): - self._data_ = data - - ## List of methods to directly wrap from _data_ - wrapMethods = ['_cmp_', '__contains__', '__eq__', '__format__', '__ge__', '__gt__', '__le__', '__len__', '__lt__', '__ne__', '__reduce__', '__reduce_ex__', '__repr__', '__str__', 'count', 'has_key', 'iterkeys', 'keys', ] - - ## List of methods which wrap from _data_ but return protected results - protectMethods = ['__getitem__', '__iter__', 'get', 'items', 'values'] - - ## List of methods to disable - disableMethods = ['__delitem__', '__setitem__', 'clear', 'pop', 'popitem', 'setdefault', 'update'] - - - ## Template methods - def wrapMethod(methodName): - return lambda self, *a, **k: getattr(self._data_, methodName)(*a, **k) - - def protectMethod(methodName): - return lambda self, *a, **k: protect(getattr(self._data_, methodName)(*a, **k)) - - def error(self, *args, **kargs): - raise Exception("Can not modify read-only list.") - - - ## Directly (and explicitly) wrap some methods from _data_ - ## Many of these methods can not be intercepted using __getattribute__, so they - ## must be implemented explicitly - for methodName in wrapMethods: - locals()[methodName] = wrapMethod(methodName) - - ## Wrap some methods from _data_ with the results converted to protected objects - for methodName in protectMethods: - locals()[methodName] = protectMethod(methodName) - - ## Disable any methods that could change data in the list - for methodName in disableMethods: - locals()[methodName] = error - - - ## Add a few extra methods. - def copy(self): - raise Exception("It is not safe to copy protected dicts! (instead try deepcopy, but be careful.)") - - def itervalues(self): - for v in self._data_.values(): - yield protect(v) - - def iteritems(self): - for k, v in self._data_.items(): - yield (k, protect(v)) - - def deepcopy(self): - return copy.deepcopy(self._data_) - - def __deepcopy__(self, memo): - return copy.deepcopy(self._data_, memo) - - - -class ProtectedList(Sequence): - """ - A class allowing read-only 'view' of a list or dict. - The object can be treated like a normal list, but will never modify the original list it points to. - Any values accessed from the list will also be read-only. - - Note: It would be nice if we could inherit from list or tuple so that isinstance checks would work. - However, doing this causes tuple(obj) to return unprotected results (importantly, this means - unpacking into function arguments will also fail) - """ - def __init__(self, data): - self._data_ = data - #self.__mro__ = (ProtectedList, object) - - ## List of methods to directly wrap from _data_ - wrapMethods = ['__contains__', '__eq__', '__format__', '__ge__', '__gt__', '__le__', '__len__', '__lt__', '__ne__', '__reduce__', '__reduce_ex__', '__repr__', '__str__', 'count', 'index'] - - ## List of methods which wrap from _data_ but return protected results - protectMethods = ['__getitem__', '__getslice__', '__mul__', '__reversed__', '__rmul__'] - - ## List of methods to disable - disableMethods = ['__delitem__', '__delslice__', '__iadd__', '__imul__', '__setitem__', '__setslice__', 'append', 'extend', 'insert', 'pop', 'remove', 'reverse', 'sort'] - - - ## Template methods - def wrapMethod(methodName): - return lambda self, *a, **k: getattr(self._data_, methodName)(*a, **k) - - def protectMethod(methodName): - return lambda self, *a, **k: protect(getattr(self._data_, methodName)(*a, **k)) - - def error(self, *args, **kargs): - raise Exception("Can not modify read-only list.") - - - ## Directly (and explicitly) wrap some methods from _data_ - ## Many of these methods can not be intercepted using __getattribute__, so they - ## must be implemented explicitly - for methodName in wrapMethods: - locals()[methodName] = wrapMethod(methodName) - - ## Wrap some methods from _data_ with the results converted to protected objects - for methodName in protectMethods: - locals()[methodName] = protectMethod(methodName) - - ## Disable any methods that could change data in the list - for methodName in disableMethods: - locals()[methodName] = error - - - ## Add a few extra methods. - def __iter__(self): - for item in self._data_: - yield protect(item) - - - def __add__(self, op): - if isinstance(op, ProtectedList): - return protect(self._data_.__add__(op._data_)) - elif isinstance(op, list): - return protect(self._data_.__add__(op)) - else: - raise TypeError("Argument must be a list.") - - def __radd__(self, op): - if isinstance(op, ProtectedList): - return protect(op._data_.__add__(self._data_)) - elif isinstance(op, list): - return protect(op.__add__(self._data_)) - else: - raise TypeError("Argument must be a list.") - - def deepcopy(self): - return copy.deepcopy(self._data_) - - def __deepcopy__(self, memo): - return copy.deepcopy(self._data_, memo) - - def poop(self): - raise Exception("This is a list. It does not poop.") - - -class ProtectedTuple(Sequence): - """ - A class allowing read-only 'view' of a tuple. - The object can be treated like a normal tuple, but its contents will be returned as protected objects. - - Note: It would be nice if we could inherit from list or tuple so that isinstance checks would work. - However, doing this causes tuple(obj) to return unprotected results (importantly, this means - unpacking into function arguments will also fail) - """ - def __init__(self, data): - self._data_ = data - - ## List of methods to directly wrap from _data_ - wrapMethods = ['__contains__', '__eq__', '__format__', '__ge__', '__getnewargs__', '__gt__', '__hash__', '__le__', '__len__', '__lt__', '__ne__', '__reduce__', '__reduce_ex__', '__repr__', '__str__', 'count', 'index'] - - ## List of methods which wrap from _data_ but return protected results - protectMethods = ['__getitem__', '__getslice__', '__iter__', '__add__', '__mul__', '__reversed__', '__rmul__'] - - - ## Template methods - def wrapMethod(methodName): - return lambda self, *a, **k: getattr(self._data_, methodName)(*a, **k) - - def protectMethod(methodName): - return lambda self, *a, **k: protect(getattr(self._data_, methodName)(*a, **k)) - - - ## Directly (and explicitly) wrap some methods from _data_ - ## Many of these methods can not be intercepted using __getattribute__, so they - ## must be implemented explicitly - for methodName in wrapMethods: - locals()[methodName] = wrapMethod(methodName) - - ## Wrap some methods from _data_ with the results converted to protected objects - for methodName in protectMethods: - locals()[methodName] = protectMethod(methodName) - - - ## Add a few extra methods. - def deepcopy(self): - return copy.deepcopy(self._data_) - - def __deepcopy__(self, memo): - return copy.deepcopy(self._data_, memo) - - - -def protect(obj): - if isinstance(obj, dict): - return ProtectedDict(obj) - elif isinstance(obj, list): - return ProtectedList(obj) - elif isinstance(obj, tuple): - return ProtectedTuple(obj) - else: - return obj - - -if __name__ == '__main__': - d = {'x': 1, 'y': [1,2], 'z': ({'a': 2, 'b': [3,4], 'c': (5,6)}, 1, 2)} - dp = protect(d) - - l = [1, 'x', ['a', 'b'], ('c', 'd'), {'x': 1, 'y': 2}] - lp = protect(l) - - t = (1, 'x', ['a', 'b'], ('c', 'd'), {'x': 1, 'y': 2}) - tp = protect(t) diff --git a/pyqtgraph/ptime.py b/pyqtgraph/ptime.py deleted file mode 100644 index a806349b14..0000000000 --- a/pyqtgraph/ptime.py +++ /dev/null @@ -1,39 +0,0 @@ -""" -ptime.py - Precision time function made os-independent (should have been taken care of by python) -Copyright 2010 Luke Campagnola -Distributed under MIT/X11 license. See license.txt for more information. -""" - - -import sys -import warnings -from time import perf_counter as clock -from time import time as system_time - -START_TIME = None -time = None - -def winTime(): - """Return the current time in seconds with high precision (windows version, use Manager.time() to stay platform independent).""" - warnings.warn( - "'pg.time' will be removed from the library in the first release following January, 2022.", - DeprecationWarning, stacklevel=2 - ) - return clock() + START_TIME - -def unixTime(): - """Return the current time in seconds with high precision (unix version, use Manager.time() to stay platform independent).""" - warnings.warn( - "'pg.time' will be removed from the library in the first release following January, 2022.", - DeprecationWarning, stacklevel=2 - ) - return system_time() - - -if sys.platform.startswith('win'): - cstart = clock() ### Required to start the clock in windows - START_TIME = system_time() - cstart - - time = winTime -else: - time = unixTime diff --git a/pyqtgraph/util/lru_cache.py b/pyqtgraph/util/lru_cache.py deleted file mode 100644 index 6c5817ead7..0000000000 --- a/pyqtgraph/util/lru_cache.py +++ /dev/null @@ -1,93 +0,0 @@ -import warnings - -warnings.warn( - "No longer used in pyqtgraph. Will be removed in 0.13", - DeprecationWarning, stacklevel=2 -) - -import itertools -import operator - - -class LRUCache(object): - ''' - This LRU cache should be reasonable for short collections (until around 100 items), as it does a - sort on the items if the collection would become too big (so, it is very fast for getting and - setting but when its size would become higher than the max size it does one sort based on the - internal time to decide which items should be removed -- which should be Ok if the resizeTo - isn't too close to the maxSize so that it becomes an operation that doesn't happen all the - time). - ''' - - def __init__(self, maxSize=100, resizeTo=70): - ''' - ============== ========================================================= - **Arguments:** - maxSize (int) This is the maximum size of the cache. When some - item is added and the cache would become bigger than - this, it's resized to the value passed on resizeTo. - resizeTo (int) When a resize operation happens, this is the size - of the final cache. - ============== ========================================================= - ''' - assert resizeTo < maxSize - self.maxSize = maxSize - self.resizeTo = resizeTo - self._counter = 0 - self._dict = {} - self._nextTime = itertools.count(0).__next__ - - def __getitem__(self, key): - item = self._dict[key] - item[2] = self._nextTime() - return item[1] - - def __len__(self): - return len(self._dict) - - def __setitem__(self, key, value): - item = self._dict.get(key) - if item is None: - if len(self._dict) + 1 > self.maxSize: - self._resizeTo() - - item = [key, value, self._nextTime()] - self._dict[key] = item - else: - item[1] = value - item[2] = self._nextTime() - - def __delitem__(self, key): - del self._dict[key] - - def get(self, key, default=None): - try: - return self[key] - except KeyError: - return default - - def clear(self): - self._dict.clear() - - def values(self): - return [i[1] for i in self._dict.values()] - - def keys(self): - return [x[0] for x in self._dict.values()] - - def _resizeTo(self): - ordered = sorted(self._dict.values(), key=operator.itemgetter(2))[:self.resizeTo] - for i in ordered: - del self._dict[i[0]] - - def items(self, accessTime=False): - ''' - :param bool accessTime: - If True sorts the returned items by the internal access time. - ''' - if accessTime: - for x in sorted(self._dict.values(), key=operator.itemgetter(2)): - yield x[0], x[1] - else: - for x in self._dict.items(): - yield x[0], x[1] diff --git a/pyqtgraph/util/pil_fix.py b/pyqtgraph/util/pil_fix.py deleted file mode 100644 index 7746162023..0000000000 --- a/pyqtgraph/util/pil_fix.py +++ /dev/null @@ -1,69 +0,0 @@ -""" -Importing this module installs support for 16-bit images in PIL. -This works by patching objects in the PIL namespace; no files are -modified. -""" - -import warnings - -warnings.warn( - "Not used in pyqtgraph. Will be removed in 0.13", - DeprecationWarning, stacklevel=2 -) - -from PIL import Image - -if Image.VERSION == '1.1.7': - Image._MODE_CONV["I;16"] = ('%su2' % Image._ENDIAN, None) - Image._fromarray_typemap[((1, 1), " ndmax: - raise ValueError("Too many dimensions.") - - size = shape[:2][::-1] - if strides is not None: - obj = obj.tostring() - - return frombuffer(mode, size, obj, "raw", mode, 0, 1) - - Image.fromarray=fromarray diff --git a/pyqtgraph/widgets/ColorMapWidget.py b/pyqtgraph/widgets/ColorMapWidget.py index 8fbcb78fe6..e31bdf4261 100644 --- a/pyqtgraph/widgets/ColorMapWidget.py +++ b/pyqtgraph/widgets/ColorMapWidget.py @@ -6,7 +6,7 @@ from .. import parametertree as ptree from ..Qt import QtCore -__all__ = ['ColorMapWidget'] +__all__ = ['ColorMapWidget', 'ColorMapParameter'] class ColorMapWidget(ptree.ParameterTree): """ diff --git a/pyqtgraph/widgets/FeedbackButton.py b/pyqtgraph/widgets/FeedbackButton.py index 7e100f7edd..9e7e6d2aa8 100644 --- a/pyqtgraph/widgets/FeedbackButton.py +++ b/pyqtgraph/widgets/FeedbackButton.py @@ -139,24 +139,3 @@ def setStyleSheet(self, style=None, temporary=False): QtWidgets.QPushButton.setStyleSheet(self, style) if not temporary: self.origStyle = style - - -if __name__ == '__main__': - import time - app = QtWidgets.QApplication([]) # noqa: qapp stored to avoid gc - win = QtWidgets.QMainWindow() - btn = FeedbackButton("Button") - fail = True - def click(): - btn.processing("Hold on..") - time.sleep(2.0) - - global fail - fail = not fail - if fail: - btn.failure(message="FAIL.", tip="There was a failure. Get over it.") - else: - btn.success(message="Bueno!") - btn.clicked.connect(click) - win.setCentralWidget(btn) - win.show() diff --git a/pyqtgraph/widgets/JoystickButton.py b/pyqtgraph/widgets/JoystickButton.py index 3ae78770ce..5f79f8bdce 100644 --- a/pyqtgraph/widgets/JoystickButton.py +++ b/pyqtgraph/widgets/JoystickButton.py @@ -1,6 +1,6 @@ from math import hypot -from ..Qt import QtCore, QtGui, QtWidgets, mkQApp +from ..Qt import QtCore, QtGui, QtWidgets __all__ = ['JoystickButton'] @@ -84,20 +84,3 @@ def paintEvent(self, ev): def resizeEvent(self, ev): self.setState(*self.state) super().resizeEvent(ev) - - - -if __name__ == '__main__': - app = mkQApp() - w = QtWidgets.QMainWindow() - b = JoystickButton() - w.setCentralWidget(b) - w.show() - w.resize(100, 100) - - def fn(b, s): - print("state changed:", s) - - b.sigStateChanged.connect(fn) - - app.exec() if hasattr(app, 'exec') else app.exec_() diff --git a/pyqtgraph/widgets/MatplotlibWidget.py b/pyqtgraph/widgets/MatplotlibWidget.py index 49553179d5..643ec5e51c 100644 --- a/pyqtgraph/widgets/MatplotlibWidget.py +++ b/pyqtgraph/widgets/MatplotlibWidget.py @@ -1,5 +1,5 @@ -from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas -from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar +from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.backends.backend_qtagg import NavigationToolbar2QT as NavigationToolbar from matplotlib.figure import Figure from ..Qt import QtWidgets diff --git a/pyqtgraph/widgets/PlotWidget.py b/pyqtgraph/widgets/PlotWidget.py index ad3c25681a..932a30fdef 100644 --- a/pyqtgraph/widgets/PlotWidget.py +++ b/pyqtgraph/widgets/PlotWidget.py @@ -4,9 +4,9 @@ Distributed under MIT/X11 license. See license.txt for more information. """ -from ..graphicsItems.PlotItem import * +from ..graphicsItems.PlotItem import PlotItem from ..Qt import QtCore, QtWidgets -from .GraphicsView import * +from .GraphicsView import GraphicsView __all__ = ['PlotWidget'] class PlotWidget(GraphicsView): diff --git a/pyqtgraph/widgets/RemoteGraphicsView.py b/pyqtgraph/widgets/RemoteGraphicsView.py index dfb60d957b..6c4bbbdbc0 100644 --- a/pyqtgraph/widgets/RemoteGraphicsView.py +++ b/pyqtgraph/widgets/RemoteGraphicsView.py @@ -4,7 +4,6 @@ import enum import mmap import os -import random import sys import tempfile @@ -169,10 +168,11 @@ def __init__(self, parent=None, *args, **kwds): self.setMouseTracking(True) self.shm = None shmFileName = self._view.shmFileName() - if sys.platform.startswith('win'): - self.shmtag = shmFileName + if sys.platform == 'win32': + opener = lambda path, flags: os.open(path, flags | os.O_TEMPORARY) else: - self.shmFile = open(shmFileName, 'r') + opener = None + self.shmFile = open(shmFileName, 'rb', opener=opener) self._view.sceneRendered.connect(mp.proxy(self.remoteSceneChanged)) #, callSync='off')) ## Note: we need synchronous signals @@ -192,16 +192,11 @@ def sizeHint(self): return QtCore.QSize(*self._sizeHint) def remoteSceneChanged(self, data): - w, h, size, newfile = data - #self._sizeHint = (whint, hhint) + w, h, size = data if self.shm is None or self.shm.size != size: if self.shm is not None: self.shm.close() - if sys.platform.startswith('win'): - self.shmtag = newfile ## on windows, we create a new tag for every resize - self.shm = mmap.mmap(-1, size, self.shmtag) ## can't use tmpfile on windows because the file can only be opened once. - else: - self.shm = mmap.mmap(self.shmFile.fileno(), size, mmap.MAP_SHARED, mmap.PROT_READ) + self.shm = mmap.mmap(self.shmFile.fileno(), size, access=mmap.ACCESS_READ) self._img = QtGui.QImage(self.shm, w, h, QtGui.QImage.Format.Format_RGB32).copy() self.update() @@ -257,16 +252,11 @@ class Renderer(GraphicsView): def __init__(self, *args, **kwds): ## Create shared memory for rendered image - #pg.dbg(namespace={'r': self}) - if sys.platform.startswith('win'): - self.shmtag = "pyqtgraph_shmem_" + ''.join([chr((random.getrandbits(20)%25) + 97) for i in range(20)]) - self.shm = mmap.mmap(-1, mmap.PAGESIZE, self.shmtag) # use anonymous mmap on windows - else: - self.shmFile = tempfile.NamedTemporaryFile(prefix='pyqtgraph_shmem_') - self.shmFile.write(b'\x00' * (mmap.PAGESIZE+1)) - self.shmFile.flush() - fd = self.shmFile.fileno() - self.shm = mmap.mmap(fd, mmap.PAGESIZE, mmap.MAP_SHARED, mmap.PROT_WRITE) + self.shmFile = tempfile.NamedTemporaryFile(prefix='pyqtgraph_shmem_') + size = mmap.PAGESIZE + self.shmFile.write(b'\x00' * size) + self.shmFile.flush() + self.shm = mmap.mmap(self.shmFile.fileno(), size, access=mmap.ACCESS_WRITE) atexit.register(self.close) GraphicsView.__init__(self, *args, **kwds) @@ -278,14 +268,10 @@ def __init__(self, *args, **kwds): def close(self): self.shm.close() - if not sys.platform.startswith('win'): - self.shmFile.close() + self.shmFile.close() def shmFileName(self): - if sys.platform.startswith('win'): - return self.shmtag - else: - return self.shmFile.name + return self.shmFile.name def update(self): self.img = None @@ -307,19 +293,15 @@ def renderView(self): iheight = int(self.height() * dpr) size = iwidth * iheight * 4 if size > self.shm.size(): - if sys.platform.startswith('win'): - ## windows says "WindowsError: [Error 87] the parameter is incorrect" if we try to resize the mmap - self.shm.close() - ## it also says (sometimes) 'access is denied' if we try to reuse the tag. - self.shmtag = "pyqtgraph_shmem_" + ''.join([chr((random.getrandbits(20)%25) + 97) for i in range(20)]) - self.shm = mmap.mmap(-1, size, self.shmtag) - elif sys.platform == 'darwin': + try: + self.shm.resize(size) + except SystemError: + # actually, the platforms on which resize() _does_ work + # can also take this codepath self.shm.close() fd = self.shmFile.fileno() - os.ftruncate(fd, size + 1) - self.shm = mmap.mmap(fd, size, mmap.MAP_SHARED, mmap.PROT_WRITE) - else: - self.shm.resize(size) + os.ftruncate(fd, size) + self.shm = mmap.mmap(fd, size, access=mmap.ACCESS_WRITE) ## render the scene directly to shared memory @@ -338,4 +320,4 @@ def renderView(self): p = QtGui.QPainter(self.img) self.render(p, self.viewRect(), self.rect()) p.end() - self.sceneRendered.emit((iwidth, iheight, self.shm.size(), self.shmFileName())) + self.sceneRendered.emit((iwidth, iheight, self.shm.size())) diff --git a/pyqtgraph/widgets/ScatterPlotWidget.py b/pyqtgraph/widgets/ScatterPlotWidget.py index 5411e4fdb9..5dc7ab68bd 100644 --- a/pyqtgraph/widgets/ScatterPlotWidget.py +++ b/pyqtgraph/widgets/ScatterPlotWidget.py @@ -82,7 +82,7 @@ def setFields(self, fields, mouseOverField=None): Set the list of field names/units to be processed. The format of *fields* is the same as used by - :func:`ColorMapWidget.setFields ` + :meth:`~pyqtgraph.widgets.ColorMapWidget.ColorMapParameter.setFields` """ self.fields = OrderedDict(fields) self.mouseOverField = mouseOverField diff --git a/pyqtgraph/widgets/SpinBox.py b/pyqtgraph/widgets/SpinBox.py index 76722fca89..e893d0d8de 100644 --- a/pyqtgraph/widgets/SpinBox.py +++ b/pyqtgraph/widgets/SpinBox.py @@ -104,7 +104,12 @@ def __init__(self, parent=None, value=0.0, **kwargs): self.skipValidate = False self.setCorrectionMode(self.CorrectionMode.CorrectToPreviousValue) self.setKeyboardTracking(False) - self.proxy = SignalProxy(self.sigValueChanging, slot=self.delayedChange, delay=self.opts['delay']) + self.proxy = SignalProxy( + self.sigValueChanging, + delay=self.opts['delay'], + slot=self.delayedChange, + threadSafe=False, + ) self.setOpts(**kwargs) self._updateHeight() @@ -572,7 +577,7 @@ def _updateHeight(self): # SpinBox has very large margins on some platforms; this is a hack to remove those # margins and allow more compact packing of controls. if not self.opts['compactHeight']: - self.setMaximumHeight(1e6) + self.setMaximumHeight(1000000) return h = QtGui.QFontMetrics(self.font()).height() if self._lastFontHeight != h: diff --git a/pyqtgraph/widgets/VerticalLabel.py b/pyqtgraph/widgets/VerticalLabel.py index c1ac9110cf..9ac41dee8b 100644 --- a/pyqtgraph/widgets/VerticalLabel.py +++ b/pyqtgraph/widgets/VerticalLabel.py @@ -80,22 +80,3 @@ def sizeHint(self): return QtCore.QSize(self.hint.width(), self.hint.height()) else: return QtCore.QSize(50, 19) - - -if __name__ == '__main__': - app = QtWidgets.QApplication([]) # noqa: qapplication must be stored to variable to avoid gc - win = QtWidgets.QMainWindow() - w = QtWidgets.QWidget() - l = QtWidgets.QGridLayout() - w.setLayout(l) - - l1 = VerticalLabel("text 1", orientation='horizontal') - l2 = VerticalLabel("text 2") - l3 = VerticalLabel("text 3") - l4 = VerticalLabel("text 4", orientation='horizontal') - l.addWidget(l1, 0, 0) - l.addWidget(l2, 1, 1) - l.addWidget(l3, 2, 2) - l.addWidget(l4, 3, 3) - win.setCentralWidget(w) - win.show() diff --git a/setup.py b/setup.py index de875f6ee0..d8d6907ba1 100644 --- a/setup.py +++ b/setup.py @@ -122,7 +122,7 @@ def run(self): 'style': helpers.StyleCommand }, packages=find_namespace_packages(include=['pyqtgraph', 'pyqtgraph.*']), - python_requires=">=3.8", + python_requires=">=3.9", package_dir={"pyqtgraph": "pyqtgraph"}, package_data={ 'pyqtgraph.examples': ['optics/*.gz', 'relativity/presets/*.cfg'], @@ -134,7 +134,7 @@ def run(self): ], }, install_requires = [ - 'numpy>=1.20.0', + 'numpy>=1.22.0', ], **setupOpts ) diff --git a/tests/exporters/test_csv.py b/tests/exporters/test_csv.py index 44bf580eef..05a5a96f3d 100644 --- a/tests/exporters/test_csv.py +++ b/tests/exporters/test_csv.py @@ -1,12 +1,11 @@ """ CSV export test """ -from __future__ import absolute_import, division, print_function - import csv import tempfile import numpy as np +import pytest import pyqtgraph as pg @@ -18,7 +17,8 @@ def approxeq(a, b): def test_CSVExporter(): - plt = pg.plot() + plt = pg.PlotWidget() + plt.show() y1 = [1,3,2,3,1,6,9,8,4,2] plt.plot(y=y1, name='myPlot') @@ -54,3 +54,32 @@ def test_CSVExporter(): assert (i >= len(x3) and vals[4] == '') or approxeq(float(vals[4]), x3[i]) assert (i >= len(y3) and vals[5] == '') or approxeq(float(vals[5]), y3[i]) + +def test_CSVExporter_with_ErrorBarItem(): + plt = pg.PlotWidget() + plt.show() + x=np.arange(5) + y=np.array([1, 2, 3, 2, 1]) + top_error = np.array([2, 3, 3, 3, 2]) + bottom_error = np.array([-2.5, -2.5, -2.5, -2.5, -1.5]) + + err = pg.ErrorBarItem( + x=x, + y=y, + top=top_error, + bottom=bottom_error + ) + plt.addItem(err) + ex = pg.exporters.CSVExporter(plt.plotItem) + with tempfile.NamedTemporaryFile(mode="w+t", suffix='.csv', encoding="utf-8", delete=False) as tf: + ex.export(fileName=tf.name) + lines = [line for line in csv.reader(tf)] + + header = lines.pop(0) + + assert header == ['x0000_error', 'y0000_error', 'y_min_error_0000', 'y_max_error_0000'] + for i, values in enumerate(lines): + assert pytest.approx(float(values[0])) == x[i] + assert pytest.approx(float(values[1])) == y[i] + assert pytest.approx(float(values[2])) == bottom_error[i] + assert pytest.approx(float(values[3])) == top_error[i] diff --git a/tests/exporters/test_hdf5.py b/tests/exporters/test_hdf5.py index ed427e73a4..9ca603a2d1 100644 --- a/tests/exporters/test_hdf5.py +++ b/tests/exporters/test_hdf5.py @@ -21,7 +21,8 @@ def test_HDF5Exporter(tmp_h5, combine): y1 = np.sin(x) y2 = np.cos(x) - plt = pg.plot() + plt = pg.PlotWidget() + plt.show() plt.plot(x=x, y=y1) plt.plot(x=x, y=y2) @@ -51,7 +52,8 @@ def test_HDF5Exporter_unequal_lengths(tmp_h5): x2 = np.linspace(0, 1, 100) y2 = np.cos(x2) - plt = pg.plot() + plt = pg.PlotWidget() + plt.show() plt.plot(x=x1, y=y1, name='plot0') plt.plot(x=x2, y=y2) diff --git a/tests/exporters/test_image.py b/tests/exporters/test_image.py index aff6bed311..8ef12a26bd 100644 --- a/tests/exporters/test_image.py +++ b/tests/exporters/test_image.py @@ -11,13 +11,15 @@ def test_ImageExporter_filename_dialog(): """Tests ImageExporter code path that opens a file dialog. Regression test for pull request 1133.""" - p = pg.plot() + p = pg.PlotWidget() + p.show() exp = ImageExporter(p.getPlotItem()) exp.export() def test_ImageExporter_toBytes(): - p = pg.plot() + p = pg.PlotWidget() + p.show() p.hideAxis('bottom') p.hideAxis('left') exp = ImageExporter(p.getPlotItem()) diff --git a/tests/exporters/test_matplotlib.py b/tests/exporters/test_matplotlib.py index c734fb01f3..8d082e3024 100644 --- a/tests/exporters/test_matplotlib.py +++ b/tests/exporters/test_matplotlib.py @@ -1,3 +1,5 @@ +from importlib.metadata import version + import pytest import pyqtgraph as pg @@ -17,10 +19,22 @@ ) ) +# see https://github.com/matplotlib/matplotlib/pull/24172 +if ( + pg.Qt.QT_LIB == "PySide6" + and tuple(map(int, pg.Qt.PySide6.__version__.split("."))) > (6, 4) + and tuple(map(int, version("matplotlib").split("."))) < (3, 6, 2) +): + pytest.skip( + "matplotlib + PySide6 6.4 bug", + allow_module_level=True + ) + @skip_qt6 def test_MatplotlibExporter(): - plt = pg.plot() + plt = pg.PlotWidget() + plt.show() # curve item plt.plot([0, 1, 2], [0, 1, 2]) @@ -35,7 +49,8 @@ def test_MatplotlibExporter(): @skip_qt6 def test_MatplotlibExporter_nonplotitem(): # attempting to export something other than a PlotItem raises an exception - plt = pg.plot() + plt = pg.PlotWidget() + plt.show() plt.plot([0, 1, 2], [2, 3, 4]) exp = MatplotlibExporter(plt.getPlotItem().getViewBox()) with pytest.raises(Exception): @@ -46,7 +61,9 @@ def test_MatplotlibExporter_nonplotitem(): def test_MatplotlibExporter_siscale(scale): # coarse test to verify that plot data is scaled before export when # autoSIPrefix is in effect (so mpl doesn't add its own multiplier label) - plt = pg.plot([0, 1, 2], [(i+1)*scale for i in range(3)]) + plt = pg.PlotWidget() + plt.show() + plt.plot([0, 1, 2], [(i+1)*scale for i in range(3)]) # set the label so autoSIPrefix works plt.setLabel('left', 'magnitude') exp = MatplotlibExporter(plt.getPlotItem()) diff --git a/tests/graphicsItems/PlotItem/test_PlotItem.py b/tests/graphicsItems/PlotItem/test_PlotItem.py index 9727deab59..f0c52d0301 100644 --- a/tests/graphicsItems/PlotItem/test_PlotItem.py +++ b/tests/graphicsItems/PlotItem/test_PlotItem.py @@ -4,9 +4,29 @@ import pyqtgraph as pg app = pg.mkQApp() +rng = np.random.default_rng(1001) -@pytest.mark.parametrize('orientation', ['left', 'right', 'top', 'bottom']) +def sorted_randint(low, high, size): + return np.sort(rng.integers(low, high, size)) + + +def is_none_or_scalar(value): + return value is None or np.isscalar(value[0]) + + +multi_data_plot_values = [ + None, + sorted_randint(0, 20, 15), + [ + sorted_randint(0, 20, 15), + *[sorted_randint(0, 20, 15) for _ in range(4)], + ], + np.row_stack([sorted_randint(20, 40, 15) for _ in range(6)]), +] + + +@pytest.mark.parametrize("orientation", ["left", "right", "top", "bottom"]) def test_PlotItem_shared_axis_items(orientation): """Adding an AxisItem to multiple plots raises RuntimeError""" ax1 = pg.AxisItem(orientation) @@ -53,6 +73,31 @@ def test_PlotItem_maxTraces(): assert curve1 not in item.curves, "curve1 should not be in the item's curves" +@pytest.mark.parametrize("xvalues", multi_data_plot_values) +@pytest.mark.parametrize("yvalues", multi_data_plot_values) +def test_PlotItem_multi_data_plot(xvalues, yvalues): + item = pg.PlotItem() + if is_none_or_scalar(xvalues) and is_none_or_scalar(yvalues): + with pytest.raises(ValueError): + item.multiDataPlot(x=xvalues, y=yvalues) + return + else: + curves = item.multiDataPlot(x=xvalues, y=yvalues, constKwargs={"pen": "r"}) + check_idx = None + if xvalues is None: + check_idx = 0 + elif yvalues is None: + check_idx = 1 + if check_idx is not None: + for curve in curves: + data = curve.getData() + opposite_idx = 1 - check_idx + assert np.array_equal( + data[check_idx], np.arange(len(data[opposite_idx])) + ) + assert curve.opts["pen"] == "r" + + def test_PlotItem_preserve_external_visibility_control(): item = pg.PlotItem() curve1 = pg.PlotDataItem(np.random.normal(size=10)) diff --git a/tests/graphicsItems/test_ImageItem.py b/tests/graphicsItems/test_ImageItem.py index 99e165c344..f3e245e302 100644 --- a/tests/graphicsItems/test_ImageItem.py +++ b/tests/graphicsItems/test_ImageItem.py @@ -230,11 +230,18 @@ def assert_equal_transforms(tr1, tr2): def test_dividebyzero(): - im = pg.image(np.random.normal(size=(100,100))) - im.imageItem.setAutoDownsample(True) - im.view.setRange(xRange=[-5+25, 5e+25],yRange=[-5e+25, 5e+25]) - app.processEvents() - QtTest.QTest.qWait(1000) - # must manually call im.imageItem.render here or the exception + # test that the calculation of downsample factors + # does not result in a division by zero + plt = pg.PlotWidget() + plt.show() + plt.setAspectLocked(True) + imgitem = pg.ImageItem(np.random.normal(size=(100,100))) + imgitem.setAutoDownsample(True) + plt.addItem(imgitem) + + plt.setRange(xRange=[-5e+25, 5e+25],yRange=[-5e+25, 5e+25]) + QtTest.QTest.qWaitForWindowExposed(plt) + QtTest.QTest.qWait(100) + # must manually call imgitem.render here or the exception # will only exist on the Qt event loop - im.imageItem.render() + imgitem.render() diff --git a/tests/graphicsItems/test_InfiniteLine.py b/tests/graphicsItems/test_InfiniteLine.py index 584552e6e4..2d0690490c 100644 --- a/tests/graphicsItems/test_InfiniteLine.py +++ b/tests/graphicsItems/test_InfiniteLine.py @@ -10,7 +10,8 @@ def test_InfiniteLine(): pg.setConfigOption('mouseRateLimit', -1) # Test basic InfiniteLine API - plt = pg.plot() + plt = pg.PlotWidget() + plt.show() plt.setXRange(-10, 10) plt.setYRange(-10, 10) plt.resize(600, 600) @@ -46,15 +47,16 @@ def test_InfiniteLine(): pos = oline.mapToScene(pg.Point(2, 0)) assert br.containsPoint(pos, QtCore.Qt.FillRule.OddEvenFill) px = pg.Point(-0.5, -1.0 / 3**0.5) - assert br.containsPoint(pos + 5 * px, QtCore.Qt.FillRule.OddEvenFill) - assert not br.containsPoint(pos + 7 * px, QtCore.Qt.FillRule.OddEvenFill) + assert br.containsPoint(pos + 1 * px, QtCore.Qt.FillRule.OddEvenFill) + assert not br.containsPoint(pos + 3 * px, QtCore.Qt.FillRule.OddEvenFill) plt.close() def test_mouseInteraction(): # disable delay of mouse move events because events is called immediately in test pg.setConfigOption('mouseRateLimit', -1) - plt = pg.plot() + plt = pg.PlotWidget() + plt.show() plt.scene().minDragTime = 0 # let us simulate mouse drags very quickly. vline = plt.addLine(x=0, movable=True) hline = plt.addLine(y=0, movable=True) diff --git a/tests/graphicsItems/test_PlotCurveItem.py b/tests/graphicsItems/test_PlotCurveItem.py index d88887cb20..77873164ac 100644 --- a/tests/graphicsItems/test_PlotCurveItem.py +++ b/tests/graphicsItems/test_PlotCurveItem.py @@ -1,6 +1,7 @@ import numpy as np import pyqtgraph as pg +from pyqtgraph.graphicsItems.PlotCurveItem import arrayToLineSegments from tests.image_testing import assertImageApproved @@ -12,6 +13,7 @@ def test_PlotCurveItem(): v = p.addViewBox() data = np.array([1,4,2,3,np.inf,5,7,6,-np.inf,8,10,9,np.nan,-1,-2,0]) c = pg.PlotCurveItem(data) + c.setSegmentedLineMode('off') # test images assume non-segmented-line-mode v.addItem(c) v.autoRange() @@ -35,3 +37,15 @@ def test_PlotCurveItem(): assertImageApproved(p, 'plotcurveitem/connectarray', "Plot curve with connection array.") p.close() + + +def test_arrayToLineSegments(): + # test the boundary case where the dataset consists of a single point + xy = np.array([0.]) + parray = arrayToLineSegments(xy, xy, connect='all', finiteCheck=True) + segs = parray.drawargs() + assert isinstance(segs, tuple) and len(segs) in [1, 2] + if len(segs) == 1: + assert len(segs[0]) == 0 + elif len(segs) == 2: + assert segs[1] == 0 diff --git a/tests/graphicsItems/test_PlotDataItem.py b/tests/graphicsItems/test_PlotDataItem.py index 083c1d14cb..1cd9f32d6b 100644 --- a/tests/graphicsItems/test_PlotDataItem.py +++ b/tests/graphicsItems/test_PlotDataItem.py @@ -11,12 +11,20 @@ def test_bool(): truths = np.random.randint(0, 2, size=(100,)).astype(bool) pdi = pg.PlotDataItem(truths) - bounds = pdi.dataBounds(1) - assert isinstance(bounds[0], np.uint8) - assert isinstance(bounds[1], np.uint8) xdata, ydata = pdi.getData() assert ydata.dtype == np.uint8 +def test_bound_formats(): + for datatype in (bool, np.uint8, np.int16, float): + truths = np.random.randint(0, 2, size=(100,)).astype(datatype) + pdi_scatter = pg.PlotDataItem(truths, symbol='o', pen=None) + pdi_line = pg.PlotDataItem(truths) + bounds = pdi_scatter.dataBounds(1) + assert isinstance(bounds[0], float), 'bound 0 is not float for scatter plot of '+str(datatype) + assert isinstance(bounds[0], float), 'bound 1 is not float for scatter plot of '+str(datatype) + bounds = pdi_line.dataBounds(1) + assert isinstance(bounds[0], float), 'bound 0 is not float for line plot of '+str(datatype) + assert isinstance(bounds[0], float), 'bound 1 is not float for line plot of '+str(datatype) def test_fft(): f = 20. @@ -83,7 +91,7 @@ def _assert_equal_arrays(a1, a2): x = np.array([-np.inf, 0.0, 1.0, 2.0 , np.nan, 4.0 , np.inf]) y = np.array([ 1.0, 0.0,-1.0, np.inf, 2.0 , np.nan, 0.0 ]) pdi = pg.PlotDataItem(x, y) - dataset = pdi.getDisplayDataset() + dataset = pdi._getDisplayDataset() _assert_equal_arrays( dataset.x, x ) _assert_equal_arrays( dataset.y, y ) @@ -95,7 +103,7 @@ def _assert_equal_arrays(a1, a2): y_log[ ~np.isfinite(y_log) ] = np.nan pdi.setLogMode(True, True) - dataset = pdi.getDisplayDataset() + dataset = pdi._getDisplayDataset() _assert_equal_arrays( dataset.x, x_log ) _assert_equal_arrays( dataset.y, y_log ) diff --git a/tests/graphicsItems/test_ROI.py b/tests/graphicsItems/test_ROI.py index 2889220f03..89a4f82bcb 100644 --- a/tests/graphicsItems/test_ROI.py +++ b/tests/graphicsItems/test_ROI.py @@ -3,6 +3,7 @@ import numpy as np import pytest +from packaging.version import Version, parse import pyqtgraph as pg from pyqtgraph.Qt import QtCore, QtGui, QtTest @@ -12,6 +13,7 @@ app = pg.mkQApp() pg.setConfigOption("mouseRateLimit", 0) + def test_getArrayRegion(transpose=False): pr = pg.PolyLineROI([[0, 0], [27, 0], [0, 28]], closed=True) pr.setPos(1, 1) @@ -24,38 +26,40 @@ def test_getArrayRegion(transpose=False): for roi, name in rois: # For some ROIs, resize should not be used. testResize = not isinstance(roi, pg.PolyLineROI) - + origMode = pg.getConfigOption('imageAxisOrder') try: if transpose: pg.setConfigOptions(imageAxisOrder='row-major') - check_getArrayRegion(roi, 'roi/'+name, testResize, transpose=True) + check_getArrayRegion(roi, 'roi/' + name, testResize, + transpose=True) else: pg.setConfigOptions(imageAxisOrder='col-major') - check_getArrayRegion(roi, 'roi/'+name, testResize) + check_getArrayRegion(roi, 'roi/' + name, testResize) finally: pg.setConfigOptions(imageAxisOrder=origMode) - + + def test_getArrayRegion_axisorder(): test_getArrayRegion(transpose=True) - + def check_getArrayRegion(roi, name, testResize=True, transpose=False): - # on windows, edges corner pixels seem to be slightly different from other platforms - # giving a pxCount=2 for a fudge factor - if isinstance(roi, (pg.ROI, pg.RectROI)) and platform.system() == "Windows": + # on windows, edges corner pixels seem to be slightly different from + # other platforms giving a pxCount=2 for a fudge factor + if (isinstance(roi, (pg.ROI, pg.RectROI)) + and platform.system() == "Windows"): pxCount = 2 else: - pxCount=-1 - + pxCount = -1 initState = roi.getState() - + win = pg.GraphicsView() win.show() resizeWindow(win, 200, 400) - # Don't use Qt's layouts for testing--these generate unpredictable results. - # Instead, place the viewboxes manually + # Don't use Qt's layouts for testing--these generate unpredictable results. + # Instead, place the viewboxes manually vb1 = pg.ViewBox() win.scene().addItem(vb1) vb1.setPos(6, 6) @@ -65,44 +69,47 @@ def check_getArrayRegion(roi, name, testResize=True, transpose=False): win.scene().addItem(vb2) vb2.setPos(6, 203) vb2.resize(188, 191) - + img1 = pg.ImageItem(border='w') img2 = pg.ImageItem(border='w') vb1.addItem(img1) vb2.addItem(img2) - + np.random.seed(0) data = np.random.normal(size=(7, 30, 31, 5)) data[0, :, :, :] += 10 data[:, 1, :, :] += 10 data[:, :, 2, :] += 10 data[:, :, :, 3] += 10 - + if transpose: data = data.transpose(0, 2, 1, 3) - + img1.setImage(data[0, ..., 0]) vb1.setAspectLocked() vb1.enableAutoRange(True, True) - + roi.setZValue(10) vb1.addItem(roi) if isinstance(roi, pg.RectROI): if transpose: - assert roi.getAffineSliceParams(data, img1, axes=(1, 2)) == ([28.0, 27.0], ((1.0, 0.0), (0.0, 1.0)), (1.0, 1.0)) + assert roi.getAffineSliceParams(data, img1, axes=(1, 2)) == ( + [28.0, 27.0], ((1.0, 0.0), (0.0, 1.0)), (1.0, 1.0)) else: - assert roi.getAffineSliceParams(data, img1, axes=(1, 2)) == ([27.0, 28.0], ((1.0, 0.0), (0.0, 1.0)), (1.0, 1.0)) + assert roi.getAffineSliceParams(data, img1, axes=(1, 2)) == ( + [27.0, 28.0], ((1.0, 0.0), (0.0, 1.0)), (1.0, 1.0)) rgn = roi.getArrayRegion(data, img1, axes=(1, 2)) - #assert np.all((rgn == data[:, 1:-2, 1:-2, :]) | (rgn == 0)) + # assert np.all((rgn == data[:, 1:-2, 1:-2, :]) | (rgn == 0)) img2.setImage(rgn[0, ..., 0]) vb2.setAspectLocked() vb2.enableAutoRange(True, True) - + app.processEvents() - assertImageApproved(win, name+'/roi_getarrayregion', 'Simple ROI region selection.', pxCount=pxCount) + assertImageApproved(win, name + '/roi_getarrayregion', + 'Simple ROI region selection.', pxCount=pxCount) with pytest.raises(TypeError): roi.setPos(0, False) @@ -111,34 +118,44 @@ def check_getArrayRegion(roi, name, testResize=True, transpose=False): rgn = roi.getArrayRegion(data, img1, axes=(1, 2)) img2.setImage(rgn[0, ..., 0]) app.processEvents() - assertImageApproved(win, name+'/roi_getarrayregion_halfpx', 'Simple ROI region selection, 0.5 pixel shift.', pxCount=pxCount) + assertImageApproved(win, name + '/roi_getarrayregion_halfpx', + 'Simple ROI region selection, 0.5 pixel shift.', + pxCount=pxCount) roi.setAngle(45) roi.setPos([3, 0]) rgn = roi.getArrayRegion(data, img1, axes=(1, 2)) img2.setImage(rgn[0, ..., 0]) app.processEvents() - assertImageApproved(win, name+'/roi_getarrayregion_rotate', 'Simple ROI region selection, rotation.', pxCount=pxCount) + assertImageApproved(win, name + '/roi_getarrayregion_rotate', + 'Simple ROI region selection, rotation.', + pxCount=pxCount) if testResize: roi.setSize([60, 60]) rgn = roi.getArrayRegion(data, img1, axes=(1, 2)) img2.setImage(rgn[0, ..., 0]) app.processEvents() - assertImageApproved(win, name+'/roi_getarrayregion_resize', 'Simple ROI region selection, resized.', pxCount=pxCount) + assertImageApproved(win, name + '/roi_getarrayregion_resize', + 'Simple ROI region selection, resized.', + pxCount=pxCount) img1.setPos(0, img1.height()) img1.setTransform(QtGui.QTransform().scale(1, -1).rotate(20), True) rgn = roi.getArrayRegion(data, img1, axes=(1, 2)) img2.setImage(rgn[0, ..., 0]) app.processEvents() - assertImageApproved(win, name+'/roi_getarrayregion_img_trans', 'Simple ROI region selection, image transformed.', pxCount=pxCount) + assertImageApproved(win, name + '/roi_getarrayregion_img_trans', + 'Simple ROI region selection, image transformed.', + pxCount=pxCount) vb1.invertY() rgn = roi.getArrayRegion(data, img1, axes=(1, 2)) img2.setImage(rgn[0, ..., 0]) app.processEvents() - assertImageApproved(win, name+'/roi_getarrayregion_inverty', 'Simple ROI region selection, view inverted.', pxCount=pxCount) + assertImageApproved(win, name + '/roi_getarrayregion_inverty', + 'Simple ROI region selection, view inverted.', + pxCount=pxCount) roi.setState(initState) img1.setPos(0, 0) @@ -146,8 +163,11 @@ def check_getArrayRegion(roi, name, testResize=True, transpose=False): rgn = roi.getArrayRegion(data, img1, axes=(1, 2)) img2.setImage(rgn[0, ..., 0]) app.processEvents() - assertImageApproved(win, name+'/roi_getarrayregion_anisotropic', 'Simple ROI region selection, image scaled anisotropically.', pxCount=pxCount) - + assertImageApproved( + win, name + '/roi_getarrayregion_anisotropic', + 'Simple ROI region selection, image scaled anisotropically.', + pxCount=pxCount) + # allow the roi to be re-used roi.scene().removeItem(roi) @@ -168,90 +188,159 @@ def test_mouseClickEvent(): vb.addItem(roi) app.processEvents() - mouseClick(plt, roi.mapToScene(pg.Point(2, 2)), QtCore.Qt.MouseButton.LeftButton) + mouseClick(plt, roi.mapToScene(pg.Point(2, 2)), + QtCore.Qt.MouseButton.LeftButton) + + +def test_mouseDragEventSnap(): + plt = pg.GraphicsView() + plt.show() + resizeWindow(plt, 200, 200) + vb = pg.ViewBox() + plt.scene().addItem(vb) + vb.resize(200, 200) + QtTest.QTest.qWaitForWindowExposed(plt) + QtTest.QTest.qWait(100) + + # A Rectangular roi with scaleSnap enabled + initial_x = 20 + initial_y = 20 + roi = pg.RectROI((initial_x, initial_y), (20, 20), scaleSnap=True, + translateSnap=True, snapSize=1.0, movable=True) + vb.addItem(roi) + app.processEvents() + + # Snap size roundtrip + assert roi.snapSize == 1.0 + roi.snapSize = 0.2 + assert roi.snapSize == 0.2 + roi.snapSize = 1.0 + assert roi.snapSize == 1.0 + + # Snap position check + snapped = roi.getSnapPosition(pg.Point(2.5, 3.5), snap=True) + assert snapped == pg.Point(2.0, 4.0) + + # Only drag in y direction + roi_position = roi.mapToView(pg.Point(initial_x, initial_y)) + mouseDrag(plt, roi_position, roi_position + pg.Point(0, 10), + QtCore.Qt.MouseButton.LeftButton) + assert roi.pos() == pg.Point(initial_x, 19) + + mouseDrag(plt, roi_position, roi_position + pg.Point(0, 10), + QtCore.Qt.MouseButton.LeftButton) + assert roi.pos() == pg.Point(initial_x, 18) + + # Only drag in x direction + mouseDrag(plt, roi_position, roi_position + pg.Point(10, 0), + QtCore.Qt.MouseButton.LeftButton) + assert roi.pos() == pg.Point(21, 18) def test_PolyLineROI(): rois = [ - (pg.PolyLineROI([[0, 0], [10, 0], [0, 15]], closed=True, pen=0.3), 'closed'), - (pg.PolyLineROI([[0, 0], [10, 0], [0, 15]], closed=False, pen=0.3), 'open') + (pg.PolyLineROI([[0, 0], [10, 0], [0, 15]], closed=True, pen=0.3), + 'closed'), + (pg.PolyLineROI([[0, 0], [10, 0], [0, 15]], closed=False, pen=0.3), + 'open') ] - - #plt = pg.plot() + plt = pg.GraphicsView() plt.show() resizeWindow(plt, 200, 200) vb = pg.ViewBox() plt.scene().addItem(vb) vb.resize(200, 200) - #plt.plotItem = pg.PlotItem() - #plt.scene().addItem(plt.plotItem) - #plt.plotItem.resize(200, 200) - + # plt.plotItem = pg.PlotItem() + # plt.scene().addItem(plt.plotItem) + # plt.plotItem.resize(200, 200) plt.scene().minDragTime = 0 # let us simulate mouse drags very quickly. - # seemingly arbitrary requirements; might need longer wait time for some platforms.. + # seemingly arbitrary requirements; might need longer wait time for some + # platforms.. QtTest.QTest.qWaitForWindowExposed(plt) QtTest.QTest.qWait(100) - + for r, name in rois: vb.clear() vb.addItem(r) vb.autoRange() app.processEvents() - - assertImageApproved(plt, 'roi/polylineroi/'+name+'_init', 'Init %s polyline.' % name) + + assertImageApproved(plt, 'roi/polylineroi/' + name + '_init', + 'Init %s polyline.' % name) initState = r.getState() assert len(r.getState()['points']) == 3 - + # hover over center center = r.mapToScene(pg.Point(3, 3)) mouseMove(plt, center) - assertImageApproved(plt, 'roi/polylineroi/'+name+'_hover_roi', 'Hover mouse over center of ROI.') - + assertImageApproved(plt, 'roi/polylineroi/' + name + '_hover_roi', + 'Hover mouse over center of ROI.') + # drag ROI - mouseDrag(plt, center, center + pg.Point(10, -10), QtCore.Qt.MouseButton.LeftButton) - assertImageApproved(plt, 'roi/polylineroi/'+name+'_drag_roi', 'Drag mouse over center of ROI.') - + mouseDrag(plt, center, center + pg.Point(10, -10), + QtCore.Qt.MouseButton.LeftButton) + assertImageApproved(plt, 'roi/polylineroi/' + name + '_drag_roi', + 'Drag mouse over center of ROI.') + # hover over handle pt = r.mapToScene(pg.Point(r.getState()['points'][2])) mouseMove(plt, pt) - assertImageApproved(plt, 'roi/polylineroi/'+name+'_hover_handle', 'Hover mouse over handle.') - + assertImageApproved(plt, 'roi/polylineroi/' + name + '_hover_handle', + 'Hover mouse over handle.') + # drag handle - mouseDrag(plt, pt, pt + pg.Point(5, 20), QtCore.Qt.MouseButton.LeftButton) - assertImageApproved(plt, 'roi/polylineroi/'+name+'_drag_handle', 'Drag mouse over handle.') - - # hover over segment - pt = r.mapToScene((pg.Point(r.getState()['points'][2]) + pg.Point(r.getState()['points'][1])) * 0.5) - mouseMove(plt, pt+pg.Point(0, 2)) - assertImageApproved(plt, 'roi/polylineroi/'+name+'_hover_segment', 'Hover mouse over diagonal segment.') - + mouseDrag(plt, pt, pt + pg.Point(5, 20), + QtCore.Qt.MouseButton.LeftButton) + assertImageApproved(plt, 'roi/polylineroi/' + name + '_drag_handle', + 'Drag mouse over handle.') + + # hover over segment + pt = r.mapToScene((pg.Point(r.getState()['points'][2]) + pg.Point( + r.getState()['points'][1])) * 0.5) + mouseMove(plt, pt + pg.Point(0, 2)) + assertImageApproved(plt, 'roi/polylineroi/' + name + '_hover_segment', + 'Hover mouse over diagonal segment.') + # click segment mouseClick(plt, pt, QtCore.Qt.MouseButton.LeftButton) - assertImageApproved(plt, 'roi/polylineroi/'+name+'_click_segment', 'Click mouse over segment.') + assertImageApproved(plt, 'roi/polylineroi/' + name + '_click_segment', + 'Click mouse over segment.') # drag new handle - mouseMove(plt, pt+pg.Point(10, -10)) # pg bug: have to move the mouse off/on again to register hover - mouseDrag(plt, pt, pt + pg.Point(10, -10), QtCore.Qt.MouseButton.LeftButton) - assertImageApproved(plt, 'roi/polylineroi/'+name+'_drag_new_handle', 'Drag mouse over created handle.') - + mouseMove(plt, pt + pg.Point(10, + -10)) + # pg bug: have to move the mouse off/on again to register hover + mouseDrag(plt, pt, pt + pg.Point(10, -10), + QtCore.Qt.MouseButton.LeftButton) + assertImageApproved(plt, + 'roi/polylineroi/' + name + '_drag_new_handle', + 'Drag mouse over created handle.') + # clear all points r.clearPoints() - assertImageApproved(plt, 'roi/polylineroi/'+name+'_clear', 'All points cleared.') + assertImageApproved(plt, 'roi/polylineroi/' + name + '_clear', + 'All points cleared.') assert len(r.getState()['points']) == 0 - + # call setPoints r.setPoints(initState['points']) - assertImageApproved(plt, 'roi/polylineroi/'+name+'_setpoints', 'Reset points to initial state.') + assertImageApproved( + plt, + f'roi/polylineroi/{name}_setpoints', + 'Reset points to initial state.', + pxCount=1 if platform.system() == "Darwin" and parse(platform.mac_ver()[0]) >= Version("13.0") else 0 + ) assert len(r.getState()['points']) == 3 - + # call setState r.setState(initState) - assertImageApproved(plt, 'roi/polylineroi/'+name+'_setstate', 'Reset ROI to initial state.') + assertImageApproved(plt, 'roi/polylineroi/' + name + '_setstate', + 'Reset ROI to initial state.') assert len(r.getState()['points']) == 3 - + plt.hide() @@ -262,13 +351,15 @@ def test_PolyLineROI(): ((-2, 1), (-4, -8)), ]) def test_LineROI_coords(p1, p2): - pw = pg.plot() + pw = pg.PlotWidget() + pw.show() lineroi = pg.LineROI(p1, p2, width=0.5, pen="r") pw.addItem(lineroi) # first two handles are the scale-rotate handles positioned by pos1, pos2 - for expected, (name, scenepos) in zip([p1, p2], lineroi.getSceneHandlePositions()): + for expected, (name, scenepos) in zip([p1, p2], + lineroi.getSceneHandlePositions()): got = lineroi.mapSceneToParent(scenepos) assert math.isclose(got.x(), expected[0]) assert math.isclose(got.y(), expected[1]) diff --git a/tests/graphicsItems/test_TextItem.py b/tests/graphicsItems/test_TextItem.py index c6143b6f57..55c99196d2 100644 --- a/tests/graphicsItems/test_TextItem.py +++ b/tests/graphicsItems/test_TextItem.py @@ -4,7 +4,8 @@ def test_TextItem_setAngle(): - plt = pg.plot() + plt = pg.PlotWidget() + plt.show() plt.setXRange(-10, 10) plt.setYRange(-20, 20) item = pg.TextItem(text="test") diff --git a/tests/image_testing.py b/tests/image_testing.py index 3a07e6077c..2db9b4b67f 100644 --- a/tests/image_testing.py +++ b/tests/image_testing.py @@ -20,7 +20,6 @@ import os import sys import time -import warnings from pathlib import Path import numpy as np @@ -387,16 +386,6 @@ def failTest(self): self.lastKey = 'f' -def getTestDataRepo(): - warnings.warn( - "Test data data repo has been merged with the main repo" - "use getTestDataDirectory() instead, this method will be removed" - "in a future version of pyqtgraph", - DeprecationWarning, stacklevel=2 - ) - return getTestDataDirectory() - - def getTestDataDirectory(): dataPath = Path(__file__).absolute().parent / "images" return dataPath.as_posix() diff --git a/tests/imageview/test_imageview.py b/tests/imageview/test_imageview.py index 885993e778..84043d2003 100644 --- a/tests/imageview/test_imageview.py +++ b/tests/imageview/test_imageview.py @@ -4,13 +4,41 @@ app = pg.mkQApp() + def test_nan_image(): img = np.ones((10,10)) img[0,0] = np.nan - v = pg.image(img) - v.imageItem.getHistogram() + iv = pg.ImageView() + iv.setImage(img) + iv.show() + iv.getImageItem().getHistogram() app.processEvents() - v.window().close() + iv.window().close() + + +def test_timeslide_snap(): + count = 31 + frames = np.ones((count, 10, 10)) + iv = pg.ImageView(discreteTimeLine=True) + assert iv.nframes() == 0 + iv.setImage(frames, xvals=(np.linspace(0., 1., count))) + iv.show() + assert iv.nframes() == count + speed = count / 2 + iv.play(speed) + assert iv.playRate == speed + iv.timeLine.setPos(0.51) # side effect: also pauses playback + assert iv.playRate == 0 + ind, val = iv.timeIndex(iv.timeLine) + assert ind == count // 2 + assert val == 0.5 + iv.togglePause() # restarts playback + assert iv.playRate == speed + iv.togglePause() # pauses playback + assert iv.playRate == 0 + iv.play() + assert iv.playRate == speed + def test_init_with_mode_and_imageitem(): data = np.random.randint(256, size=(256, 256, 3)) diff --git a/tests/opengl/items/common.py b/tests/opengl/items/common.py new file mode 100644 index 0000000000..3e6e078753 --- /dev/null +++ b/tests/opengl/items/common.py @@ -0,0 +1,6 @@ +from pyqtgraph.opengl.GLGraphicsItem import GLGraphicsItem + + +def ensure_parentItem(parent: GLGraphicsItem, child: GLGraphicsItem): + assert child in parent.childItems() + assert parent is child.parentItem() diff --git a/tests/opengl/items/test_GLAxisItem.py b/tests/opengl/items/test_GLAxisItem.py new file mode 100644 index 0000000000..695c042045 --- /dev/null +++ b/tests/opengl/items/test_GLAxisItem.py @@ -0,0 +1,13 @@ +import pytest +pytest.importorskip('OpenGL') + +from pyqtgraph.opengl.GLGraphicsItem import GLGraphicsItem +from pyqtgraph.opengl import GLAxisItem + +from common import ensure_parentItem + + +def test_parentItem(): + parent = GLGraphicsItem() + child = GLAxisItem(parentItem=parent) + ensure_parentItem(parent, child) diff --git a/tests/opengl/items/test_GLBarGraphItem.py b/tests/opengl/items/test_GLBarGraphItem.py new file mode 100644 index 0000000000..a9478b3357 --- /dev/null +++ b/tests/opengl/items/test_GLBarGraphItem.py @@ -0,0 +1,15 @@ +import pytest +pytest.importorskip('OpenGL') + +import numpy as np + +from pyqtgraph.opengl.GLGraphicsItem import GLGraphicsItem +from pyqtgraph.opengl import GLBarGraphItem + +from common import ensure_parentItem + + +def test_parentItem(): + parent = GLGraphicsItem() + child = GLBarGraphItem(np.ndarray([0,0,0]), np.ndarray([0,0,0]), parentItem=parent) + ensure_parentItem(parent, child) diff --git a/tests/opengl/items/test_GLBoxItem.py b/tests/opengl/items/test_GLBoxItem.py new file mode 100644 index 0000000000..2ff53ee4df --- /dev/null +++ b/tests/opengl/items/test_GLBoxItem.py @@ -0,0 +1,13 @@ +import pytest +pytest.importorskip('OpenGL') + +from pyqtgraph.opengl.GLGraphicsItem import GLGraphicsItem +from pyqtgraph.opengl import GLBoxItem + +from common import ensure_parentItem + + +def test_parentItem(): + parent = GLGraphicsItem() + child = GLBoxItem(parentItem=parent) + ensure_parentItem(parent, child) diff --git a/tests/opengl/items/test_GLGradientLegendItem.py b/tests/opengl/items/test_GLGradientLegendItem.py new file mode 100644 index 0000000000..e9b31833d4 --- /dev/null +++ b/tests/opengl/items/test_GLGradientLegendItem.py @@ -0,0 +1,13 @@ +import pytest +pytest.importorskip('OpenGL') + +from pyqtgraph.opengl.GLGraphicsItem import GLGraphicsItem +from pyqtgraph.opengl import GLGradientLegendItem + +from common import ensure_parentItem + + +def test_parentItem(): + parent = GLGraphicsItem() + child = GLGradientLegendItem(parentItem=parent) + ensure_parentItem(parent, child) diff --git a/tests/opengl/items/test_GLGraphItem.py b/tests/opengl/items/test_GLGraphItem.py new file mode 100644 index 0000000000..fd66629393 --- /dev/null +++ b/tests/opengl/items/test_GLGraphItem.py @@ -0,0 +1,13 @@ +import pytest +pytest.importorskip('OpenGL') + +from pyqtgraph.opengl.GLGraphicsItem import GLGraphicsItem +from pyqtgraph.opengl import GLGraphItem + +from common import ensure_parentItem + + +def test_parentItem(): + parent = GLGraphicsItem() + child = GLGraphItem(parentItem=parent) + ensure_parentItem(parent, child) diff --git a/tests/opengl/items/test_GLGridItem.py b/tests/opengl/items/test_GLGridItem.py new file mode 100644 index 0000000000..d42d6a2306 --- /dev/null +++ b/tests/opengl/items/test_GLGridItem.py @@ -0,0 +1,13 @@ +import pytest +pytest.importorskip('OpenGL') + +from pyqtgraph.opengl.GLGraphicsItem import GLGraphicsItem +from pyqtgraph.opengl import GLGridItem + +from common import ensure_parentItem + + +def test_parentItem(): + parent = GLGraphicsItem() + child = GLGridItem(parentItem=parent) + ensure_parentItem(parent, child) diff --git a/tests/opengl/items/test_GLImageItem.py b/tests/opengl/items/test_GLImageItem.py new file mode 100644 index 0000000000..695c042045 --- /dev/null +++ b/tests/opengl/items/test_GLImageItem.py @@ -0,0 +1,13 @@ +import pytest +pytest.importorskip('OpenGL') + +from pyqtgraph.opengl.GLGraphicsItem import GLGraphicsItem +from pyqtgraph.opengl import GLAxisItem + +from common import ensure_parentItem + + +def test_parentItem(): + parent = GLGraphicsItem() + child = GLAxisItem(parentItem=parent) + ensure_parentItem(parent, child) diff --git a/tests/opengl/items/test_GLLinePlotItem.py b/tests/opengl/items/test_GLLinePlotItem.py new file mode 100644 index 0000000000..5db6410bd2 --- /dev/null +++ b/tests/opengl/items/test_GLLinePlotItem.py @@ -0,0 +1,13 @@ +import pytest +pytest.importorskip('OpenGL') + +from pyqtgraph.opengl.GLGraphicsItem import GLGraphicsItem +from pyqtgraph.opengl import GLLinePlotItem + +from common import ensure_parentItem + + +def test_parentItem(): + parent = GLGraphicsItem() + child = GLLinePlotItem(parentItem=parent) + ensure_parentItem(parent, child) diff --git a/tests/opengl/items/test_GLMeshItem.py b/tests/opengl/items/test_GLMeshItem.py new file mode 100644 index 0000000000..3b8e69856b --- /dev/null +++ b/tests/opengl/items/test_GLMeshItem.py @@ -0,0 +1,13 @@ +import pytest +pytest.importorskip('OpenGL') + +from pyqtgraph.opengl.GLGraphicsItem import GLGraphicsItem +from pyqtgraph.opengl import GLMeshItem + +from common import ensure_parentItem + + +def test_parentItem(): + parent = GLGraphicsItem() + child = GLMeshItem(parentItem=parent) + ensure_parentItem(parent, child) diff --git a/tests/opengl/items/test_GLScatterPlotItem.py b/tests/opengl/items/test_GLScatterPlotItem.py new file mode 100644 index 0000000000..d7d10d71c2 --- /dev/null +++ b/tests/opengl/items/test_GLScatterPlotItem.py @@ -0,0 +1,13 @@ +import pytest +pytest.importorskip('OpenGL') + +from pyqtgraph.opengl.GLGraphicsItem import GLGraphicsItem +from pyqtgraph.opengl import GLScatterPlotItem + +from common import ensure_parentItem + + +def test_parentItem(): + parent = GLGraphicsItem() + child = GLScatterPlotItem(parentItem=parent) + ensure_parentItem(parent, child) diff --git a/tests/opengl/items/test_GLSurfacePlotItem.py b/tests/opengl/items/test_GLSurfacePlotItem.py new file mode 100644 index 0000000000..3b98f285b3 --- /dev/null +++ b/tests/opengl/items/test_GLSurfacePlotItem.py @@ -0,0 +1,13 @@ +import pytest +pytest.importorskip('OpenGL') + +from pyqtgraph.opengl.GLGraphicsItem import GLGraphicsItem +from pyqtgraph.opengl import GLSurfacePlotItem + +from common import ensure_parentItem + + +def test_parentItem(): + parent = GLGraphicsItem() + child = GLSurfacePlotItem(parentItem=parent) + ensure_parentItem(parent, child) diff --git a/tests/opengl/items/test_GLTextItem.py b/tests/opengl/items/test_GLTextItem.py new file mode 100644 index 0000000000..fc74b5ea2b --- /dev/null +++ b/tests/opengl/items/test_GLTextItem.py @@ -0,0 +1,13 @@ +import pytest +pytest.importorskip('OpenGL') + +from pyqtgraph.opengl.GLGraphicsItem import GLGraphicsItem +from pyqtgraph.opengl import GLTextItem + +from common import ensure_parentItem + + +def test_parentItem(): + parent = GLGraphicsItem() + child = GLTextItem(parentItem=parent) + ensure_parentItem(parent, child) diff --git a/tests/opengl/items/test_GLVolumeItem.py b/tests/opengl/items/test_GLVolumeItem.py new file mode 100644 index 0000000000..104abeb4c6 --- /dev/null +++ b/tests/opengl/items/test_GLVolumeItem.py @@ -0,0 +1,13 @@ +import pytest +pytest.importorskip('OpenGL') + +from pyqtgraph.opengl.GLGraphicsItem import GLGraphicsItem +from pyqtgraph.opengl import GLVolumeItem + +from common import ensure_parentItem + + +def test_parentItem(): + parent = GLGraphicsItem() + child = GLVolumeItem(None, parentItem=parent) + ensure_parentItem(parent, child) diff --git a/tests/parametertree/test_Parameter.py b/tests/parametertree/test_Parameter.py index 28ac14d09a..4ede448cdc 100644 --- a/tests/parametertree/test_Parameter.py +++ b/tests/parametertree/test_Parameter.py @@ -1,9 +1,22 @@ -import pytest from functools import wraps + +import numpy as np +import pyqtgraph as pg +import pytest + +from pyqtgraph import functions as fn +from pyqtgraph.parametertree import ( + InteractiveFunction, + Interactor, + interact, + RunOptions, +) from pyqtgraph.parametertree import Parameter +from pyqtgraph.parametertree.Parameter import PARAM_TYPES from pyqtgraph.parametertree.parameterTypes import GroupParameter as GP -from pyqtgraph.parametertree import RunOpts, InteractiveFunction, Interactor, interact +from pyqtgraph.Qt import QtGui +pg.mkQApp() def test_parameter_hasdefault(): opts = {"name": "param", "type": int, "value": 1} @@ -65,7 +78,7 @@ def test_unpack_parameter(): def test_interact(): - interactor = Interactor(runOpts=RunOpts.ON_ACTION) + interactor = Interactor(runOptions=RunOptions.ON_ACTION) value = None def retain(func): @@ -99,14 +112,18 @@ def a(x, y=5): a_interact = InteractiveFunction(a, closures=dict(x=lambda: myval)) host = interactor(a_interact) assert "x" not in host.names - host.child("Run").activate() + host.activate() assert value == (5, 5) myval = 10 - host.child("Run").activate() + host.activate() assert value == (10, 5) host = interactor( - a, x=10, y=50, ignores=["x"], runOpts=(RunOpts.ON_CHANGED, RunOpts.ON_CHANGING) + a, + x=10, + y=50, + ignores=["x"], + runOptions=(RunOptions.ON_CHANGED, RunOptions.ON_CHANGING), ) for child in "x", "Run": assert child not in host.names @@ -116,18 +133,18 @@ def a(x, y=5): host.child("y").sigValueChanging.emit(host.child("y"), 100) assert value == (10, 100) - with interactor.optsContext(title=str.upper): + with interactor.optsContext(titleFormat=str.upper): host = interactor(a, x={"title": "different", "value": 5}) titles = [p.title() for p in host] for ch in "different", "Y": assert ch in titles - with interactor.optsContext(title="Group only"): + with interactor.optsContext(titleFormat="Group only"): host = interactor(a, x=1) assert host.title() == "Group only" assert [p.title() is None for p in host] - with interactor.optsContext(runOpts=RunOpts.ON_CHANGED): + with interactor.optsContext(runOptions=RunOptions.ON_CHANGED): host = interactor(a, x=5) host["y"] = 20 assert value == (5, 20) @@ -140,7 +157,7 @@ def kwargTest(a, b=5, **c): host = interactor(kwargTest, a=10, test=3) for ch in "a", "b", "test": assert ch in host.names - host.child("Run").activate() + host.activate() assert value == 12 host = GP.create(name="test deco", type="group") @@ -153,10 +170,10 @@ def a(x=5): assert "a" in host.names assert "x" in host.child("a").names - host.child("a", "Run").activate() + host.child("a").activate() assert value == 5 - @interactor.decorate(nest=False, runOpts=RunOpts.ON_CHANGED) + @interactor.decorate(nest=False, runOptions=RunOptions.ON_CHANGED) @retain def b(y=6): return y @@ -173,7 +190,7 @@ def raw(x=5): def override(**kwargs): return raw(**kwargs) - host = interactor(wraps(raw)(override), runOpts=RunOpts.ON_CHANGED) + host = interactor(wraps(raw)(override), runOptions=RunOptions.ON_CHANGED) assert "x" in host.names host["x"] = 100 assert value == 100 @@ -183,23 +200,28 @@ def test_run(): def a(): """""" - interactor = Interactor(runOpts=RunOpts.ON_ACTION) + interactor = Interactor(runOptions=RunOptions.ON_ACTION) defaultRunBtn = Parameter.create(**interactor.runActionTemplate, name="Run") - btn = interactor(a) - assert btn.type() == defaultRunBtn.type() + group = interactor(a) + assert group.makeTreeItem(0).button.text() == defaultRunBtn.name() template = dict(defaultName="Test", type="action") with interactor.optsContext(runActionTemplate=template): x = interactor(a) - assert x.name() == "Test" + assert x.makeTreeItem(0).button.text() == "Test" parent = Parameter.create(name="parent", type="group") test2 = interactor(a, parent=parent, nest=False) - assert test2.parent() is parent + assert ( + len(test2) == 1 + and test2[0].name() == a.__name__ + and test2[0].parent() is parent + ) test2 = interactor(a, nest=False) - assert not test2.parent() + assert len(test2) == 1 and not test2[0].parent() + def test_no_func_group(): def inner(a=5, b=6): @@ -215,22 +237,19 @@ def a(): interactor = Interactor() - btn = interactor(a, runOpts=RunOpts.ON_ACTION) - assert btn.opts["tip"] == a.__doc__ + group = interactor(a, runOptions=RunOptions.ON_ACTION) + assert group.opts["tip"] == a.__doc__ and group.type() == "_actiongroup" - def a2(x=5): - """a simple tip""" + params = interactor(a, runOptions=RunOptions.ON_ACTION, nest=False) + assert len(params) == 1 and params[0].opts["tip"] == a.__doc__ - def a3(x=5): + def a2(x=5): """ A long docstring with a newline followed by more text won't result in a tooltip """ - param = interactor(a2, runOpts=RunOpts.ON_ACTION) - assert param.opts["tip"] == a2.__doc__ and param.type() == "group" - - param = interactor(a3) + param = interactor(a2) assert "tip" not in param.opts @@ -243,7 +262,7 @@ def myfunc(a=5): return a interactive = InteractiveFunction(myfunc) - host = interact(interactive, runOpts=[]) + host = interact(interactive, runOptions=[]) host["a"] = 7 assert interactive.runFromAction() == 7 @@ -259,6 +278,11 @@ def myfunc(a=5): assert not interactive.setDisconnected(True) assert interactive.setDisconnected(False) + host = interact(interactive, runOptions=RunOptions.ON_CHANGED) + interactive.disconnect() + host["a"] = 20 + assert value == 10 + def test_badOptsContext(): with pytest.raises(KeyError): @@ -296,7 +320,7 @@ class RetainVal: def inner(a=4): RetainVal.a = a - host = interact(inner) + host = interact(inner, runOptions=RunOptions.ON_CHANGED) host["a"] = 5 assert RetainVal.a == 5 @@ -354,6 +378,7 @@ class RetainVal: def a(x=3, **kwargs): RetainVal.a = sum(kwargs.values()) + x return RetainVal.a + a.parametersNeedRunKwargs = True host = interact(a) @@ -372,10 +397,12 @@ def a(x=3, **kwargs): # But the cache should still be up-to-date assert a() == 5 + def test_hookup_extra_params(): @InteractiveFunction def a(x=5, **kwargs): return x + sum(kwargs.values()) + interact(a) p2 = Parameter.create(name="p2", type="int", value=3) @@ -388,6 +415,13 @@ def test_class_interact(): parent = Parameter.create(name="parent", type="group") interactor = Interactor(parent=parent, nest=False) + def outside_class_deco(func): + @wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return wrapper + class A: def a(self, x=5): return x @@ -396,9 +430,80 @@ def a(self, x=5): def b(cls, y=5): return y + @outside_class_deco + def c(self, z=5): + return z + a = A() ai = interactor.decorate()(a.a) assert ai() == a.a() bi = interactor.decorate()(A.b) assert bi() == A.b() + + ci = interactor.decorate()(a.c) + assert ci() == a.c() + + +def test_args_interact(): + @interact.decorate() + def a(*args): + """""" + + assert not (a.parameters or a.extra) + a() + + +def test_interact_with_icon(): + randomPixmap = QtGui.QPixmap(64, 64) + randomPixmap.fill(QtGui.QColor("red")) + + parent = Parameter.create(name="parent", type="group") + + @interact.decorate( + runActionTemplate=dict(icon=randomPixmap), + parent=parent, + runOptions=RunOptions.ON_ACTION, + ) + def a(): + """""" + + groupItem = parent.child("a").itemClass(parent.child("a"), 1) + buttonPixmap = groupItem.button.icon().pixmap(randomPixmap.size()) + + # hold references to the QImages + images = [ pix.toImage() for pix in (randomPixmap, buttonPixmap) ] + + imageBytes = [ fn.ndarray_from_qimage(img) for img in images ] + assert np.array_equal(*imageBytes) + + +def test_interact_ignore_none_child(): + class InteractorSubclass(Interactor): + def resolveAndHookupParameterChild( + self, functionGroup, childOpts, interactiveFunction + ): + if childOpts["type"] not in PARAM_TYPES: + # Optionally add to `extra` instead + return None + return super().resolveAndHookupParameterChild( + functionGroup, childOpts, interactiveFunction + ) + + interactor = InteractorSubclass() + out = interactor(lambda a=None: a, runOptions=[]) + assert "a" not in out.names + + +def test_interact_existing_parent(): + lastValue = None + + def a(): + nonlocal lastValue + lastValue = 5 + + parent = Parameter.create(name="parent", type="group") + outParam = interact(a, parent=parent) + assert outParam in parent.names.values() + outParam.activate() + assert lastValue == 5 diff --git a/tests/parametertree/test_parametertypes.py b/tests/parametertree/test_parametertypes.py index 749504a3db..382ac2e913 100644 --- a/tests/parametertree/test_parametertypes.py +++ b/tests/parametertree/test_parametertypes.py @@ -1,10 +1,11 @@ -import sys +from unittest.mock import MagicMock import numpy as np import pyqtgraph as pg import pyqtgraph.parametertree as pt from pyqtgraph.functions import eq +from pyqtgraph.parametertree.parameterTypes import ChecklistParameterItem from pyqtgraph.Qt import QtCore, QtGui app = pg.mkQApp() @@ -163,6 +164,19 @@ def override(): pi.widget.setValue(2) assert p.value() == pi.widget.value() == 1 + +def test_checklist_show_hide(): + p = pt.Parameter.create(name='checklist', type='checklist', limits=["a", "b", "c"]) + pi = ChecklistParameterItem(p, 0) + pi.setHidden = MagicMock() + p.hide() + pi.setHidden.assert_called_with(True) + assert not p.opts["visible"] + p.show() + pi.setHidden.assert_called_with(False) + assert p.opts["visible"] + + def test_pen_settings(): # Option from constructor p = pt.Parameter.create(name='test', type='pen', width=5, additionalname='test') @@ -173,3 +187,11 @@ def test_pen_settings(): # Opts from changing child p["width"] = 10 assert p.pen.width() == 10 + + +def test_recreate_from_savestate(): + from pyqtgraph.examples import _buildParamTypes + created = _buildParamTypes.makeAllParamTypes() + state = created.saveState() + created2 = pt.Parameter.create(**state) + assert pg.eq(state, created2.saveState()) diff --git a/tests/test_colormap.py b/tests/test_colormap.py index 0926302c65..16372ca6f5 100644 --- a/tests/test_colormap.py +++ b/tests/test_colormap.py @@ -74,3 +74,8 @@ def test_ColorMap_getColors(color_list): colors = cm.getColors('qcolor') for actual, good in zip(colors, qcols): assert actual.getRgbF() == good.getRgbF() + +def test_ColorMap_getByIndex(): + cm = pg.ColorMap([0.0, 1.0], [(0,0,0), (255,0,0)]) + assert cm.getByIndex(0) == QtGui.QColor.fromRgbF(0.0, 0.0, 0.0, 1.0) + assert cm.getByIndex(1) == QtGui.QColor.fromRgbF(1.0, 0.0, 0.0, 1.0) diff --git a/tests/test_functions.py b/tests/test_functions.py index c327b423c4..426d757348 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -118,10 +118,15 @@ def test_subArray(): def test_rescaleData(): + rng = np.random.default_rng(12345) dtypes = map(np.dtype, ('ubyte', 'uint16', 'byte', 'int16', 'int', 'float')) for dtype1 in dtypes: for dtype2 in dtypes: - data = (np.random.random(size=10) * 2**32 - 2**31).astype(dtype1) + if dtype1.kind in 'iu': + lim = np.iinfo(dtype1) + data = rng.integers(lim.min, lim.max, size=10, dtype=dtype1, endpoint=True) + else: + data = (rng.random(size=10) * 2**32 - 2**31).astype(dtype1) for scale, offset in [(10, 0), (10., 0.), (1, -50), (0.2, 0.5), (0.001, 0)]: if dtype2.kind in 'iu': lim = np.iinfo(dtype2) @@ -287,7 +292,7 @@ def _handle_underflow(dtype, *elements): "xs, ys, connect, expected", [ *( ( - np.arange(6, dtype=dtype), np.arange(0, -6, step=-1, dtype=dtype), 'all', + np.arange(6, dtype=dtype), np.arange(0, -6, step=-1).astype(dtype), 'all', _handle_underflow(dtype, (MoveToElement, 0.0, 0.0), (LineToElement, 1.0, -1.0), @@ -300,7 +305,7 @@ def _handle_underflow(dtype, *elements): ), *( ( - np.arange(6, dtype=dtype), np.arange(0, -6, step=-1, dtype=dtype), 'pairs', + np.arange(6, dtype=dtype), np.arange(0, -6, step=-1).astype(dtype), 'pairs', _handle_underflow(dtype, (MoveToElement, 0.0, 0.0), (LineToElement, 1.0, -1.0), @@ -313,7 +318,7 @@ def _handle_underflow(dtype, *elements): ), *( ( - np.arange(5, dtype=dtype), np.arange(0, -5, step=-1, dtype=dtype), 'pairs', + np.arange(5, dtype=dtype), np.arange(0, -5, step=-1).astype(dtype), 'pairs', _handle_underflow(dtype, (MoveToElement, 0.0, 0.0), (LineToElement, 1.0, -1.0), @@ -344,7 +349,7 @@ def _handle_underflow(dtype, *elements): ), *( ( - np.arange(5, dtype=dtype), np.arange(0, -5, step=-1, dtype=dtype), np.array([0, 1, 0, 1, 0]), + np.arange(5, dtype=dtype), np.arange(0, -5, step=-1).astype(dtype), np.array([0, 1, 0, 1, 0]), _handle_underflow(dtype, (MoveToElement, 0.0, 0.0), (MoveToElement, 1.0, -1.0), @@ -400,3 +405,7 @@ def test_ndarray_from_qimage(): qimg.fill(0) arr = pg.functions.ndarray_from_qimage(qimg) assert arr.shape == (h, w) + +def test_colorDistance(): + pg.colorDistance([pg.Qt.QtGui.QColor(0,0,0), pg.Qt.QtGui.QColor(255,0,0)]) + pg.colorDistance([]) diff --git a/tests/test_qmenu_leak_workaround.py b/tests/test_qmenu_leak_workaround.py new file mode 100644 index 0000000000..71bec3074f --- /dev/null +++ b/tests/test_qmenu_leak_workaround.py @@ -0,0 +1,25 @@ +import sys +import pyqtgraph as pg +from pyqtgraph.Qt import QtWidgets + +def test_qmenu_leak_workaround(): + # refer to https://github.com/pyqtgraph/pyqtgraph/pull/2518 + pg.mkQApp() + topmenu = QtWidgets.QMenu() + submenu = QtWidgets.QMenu() + + refcnt1 = sys.getrefcount(submenu) + + # check that after the workaround, + # submenu has no change in refcnt + topmenu.addMenu(submenu) + submenu.setParent(None) # this is the workaround for PySide{2,6}, + # and should have no effect on bindings + # where it is not needed. + refcnt2 = sys.getrefcount(submenu) + assert refcnt2 == refcnt1 + + # check that topmenu is not a C++ parent of submenu. + # i.e. deleting topmenu leaves submenu alive + del topmenu + assert pg.Qt.isQObjectAlive(submenu) \ No newline at end of file diff --git a/tests/test_qt.py b/tests/test_qt.py index 7790c9e320..94dc6307f3 100644 --- a/tests/test_qt.py +++ b/tests/test_qt.py @@ -13,11 +13,6 @@ def test_isQObjectAlive(): del o1 assert not pg.Qt.isQObjectAlive(o2) -@pytest.mark.skipif( - pg.Qt.QT_LIB == 'PySide2' - and not pg.Qt.PySide2.__version__ .startswith(pg.Qt.QtCore.__version__), - reason='test fails on conda distributions' -) @pytest.mark.skipif( pg.Qt.QT_LIB == "PySide2" and tuple(map(int, pg.Qt.PySide2.__version__.split("."))) >= (5, 14) diff --git a/tests/test_ref_cycles.py b/tests/test_ref_cycles.py index 391709f745..83d15a961c 100644 --- a/tests/test_ref_cycles.py +++ b/tests/test_ref_cycles.py @@ -48,22 +48,11 @@ def mkobjs(*args, **kwds): app.focusChanged.connect(w.plotItem.vb.invertY) # return weakrefs to a bunch of objects that should die when the scope exits. - return mkrefs(w, c, data, w.plotItem, w.plotItem.vb, w.plotItem.getMenu(), w.plotItem.getAxis('left')) + return mkrefs(w, c, data, w.plotItem, w.plotItem.vb, w.plotItem.getAxis('left')) for _ in range(5): assert_alldead(mkobjs()) -def test_GraphicsWindow(): - def mkobjs(): - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - w = pg.GraphicsWindow() - p1 = w.addPlot() - v1 = w.addViewBox() - return mkrefs(w, p1, v1) - - for _ in range(5): - assert_alldead(mkobjs()) def test_ImageView(): def mkobjs(): diff --git a/tests/test_signalproxy.py b/tests/test_signalproxy.py index 68df12f661..b0bb2764d4 100644 --- a/tests/test_signalproxy.py +++ b/tests/test_signalproxy.py @@ -35,7 +35,7 @@ def test_signal_proxy_slot(qapp): sender = Sender(parent=qapp) receiver = Receiver(parent=qapp) proxy = SignalProxy(sender.signalSend, delay=0.0, rateLimit=0.6, - slot=receiver.slotReceive) + slot=receiver.slotReceive, threadSafe=False) assert proxy.blockSignal is False assert proxy is not None @@ -54,7 +54,7 @@ def test_signal_proxy_disconnect_slot(qapp): sender = Sender(parent=qapp) receiver = Receiver(parent=qapp) proxy = SignalProxy(sender.signalSend, delay=0.0, rateLimit=0.6, - slot=receiver.slotReceive) + slot=receiver.slotReceive, threadSafe=False) assert proxy.blockSignal is False assert proxy is not None @@ -77,7 +77,8 @@ def test_signal_proxy_no_slot_start(qapp): """Test the connect mode of SignalProxy without slot at start`""" sender = Sender(parent=qapp) receiver = Receiver(parent=qapp) - proxy = SignalProxy(sender.signalSend, delay=0.0, rateLimit=0.6) + proxy = SignalProxy(sender.signalSend, delay=0.0, rateLimit=0.6, + threadSafe=False) assert proxy.blockSignal is True assert proxy is not None @@ -107,7 +108,7 @@ def test_signal_proxy_slot_block(qapp): sender = Sender(parent=qapp) receiver = Receiver(parent=qapp) proxy = SignalProxy(sender.signalSend, delay=0.0, rateLimit=0.6, - slot=receiver.slotReceive) + slot=receiver.slotReceive, threadSafe=False) assert proxy.blockSignal is False assert proxy is not None diff --git a/tests/ui_testing.py b/tests/ui_testing.py index 75cc51c65b..b56141a609 100644 --- a/tests/ui_testing.py +++ b/tests/ui_testing.py @@ -31,29 +31,54 @@ def resizeWindow(win, w, h, timeout=2.0): def mousePress(widget, pos, button, modifier=None): if isinstance(widget, QtWidgets.QGraphicsView): widget = widget.viewport() + global_pos = QtCore.QPointF(widget.mapToGlobal(pos.toPoint())) if modifier is None: modifier = QtCore.Qt.KeyboardModifier.NoModifier - event = QtGui.QMouseEvent(QtCore.QEvent.Type.MouseButtonPress, pos, button, QtCore.Qt.MouseButton.NoButton, modifier) + event = QtGui.QMouseEvent( + QtCore.QEvent.Type.MouseButtonPress, + pos, + global_pos, + button, + QtCore.Qt.MouseButton.NoButton, + modifier + ) QtWidgets.QApplication.sendEvent(widget, event) def mouseRelease(widget, pos, button, modifier=None): if isinstance(widget, QtWidgets.QGraphicsView): widget = widget.viewport() + global_pos = QtCore.QPointF(widget.mapToGlobal(pos.toPoint())) if modifier is None: modifier = QtCore.Qt.KeyboardModifier.NoModifier - event = QtGui.QMouseEvent(QtCore.QEvent.Type.MouseButtonRelease, pos, button, QtCore.Qt.MouseButton.NoButton, modifier) + event = QtGui.QMouseEvent( + QtCore.QEvent.Type.MouseButtonRelease, + pos, + global_pos, + button, + QtCore.Qt.MouseButton.NoButton, + modifier + ) QtWidgets.QApplication.sendEvent(widget, event) def mouseMove(widget, pos, buttons=None, modifier=None): if isinstance(widget, QtWidgets.QGraphicsView): widget = widget.viewport() + + global_pos = QtCore.QPointF(widget.mapToGlobal(pos.toPoint())) if modifier is None: modifier = QtCore.Qt.KeyboardModifier.NoModifier if buttons is None: buttons = QtCore.Qt.MouseButton.NoButton - event = QtGui.QMouseEvent(QtCore.QEvent.Type.MouseMove, pos, QtCore.Qt.MouseButton.NoButton, buttons, modifier) + event = QtGui.QMouseEvent( + QtCore.QEvent.Type.MouseMove, + pos, + global_pos, + QtCore.Qt.MouseButton.NoButton, + buttons, + modifier + ) QtWidgets.QApplication.sendEvent(widget, event) diff --git a/tests/util/test_lru_cache.py b/tests/util/test_lru_cache.py deleted file mode 100644 index be1eb5d956..0000000000 --- a/tests/util/test_lru_cache.py +++ /dev/null @@ -1,51 +0,0 @@ -import warnings - -with warnings.catch_warnings(): - warnings.simplefilter('ignore') - from pyqtgraph.util.lru_cache import LRUCache - - -def testLRU(): - lru = LRUCache(2, 1) - # check twice - checkLru(lru) - checkLru(lru) - -def checkLru(lru): - lru[1] = 1 - lru[2] = 2 - lru[3] = 3 - - assert len(lru) == 2 - assert set([2, 3]) == set(lru.keys()) - assert set([2, 3]) == set(lru.values()) - - lru[2] = 2 - assert set([2, 3]) == set(lru.values()) - - lru[1] = 1 - set([2, 1]) == set(lru.values()) - - #Iterates from the used in the last access to others based on access time. - assert [(2, 2), (1, 1)] == list(lru.items(accessTime=True)) - lru[2] = 2 - assert [(1, 1), (2, 2)] == list(lru.items(accessTime=True)) - - del lru[2] - assert [(1, 1), ] == list(lru.items(accessTime=True)) - - lru[2] = 2 - assert [(1, 1), (2, 2)] == list(lru.items(accessTime=True)) - - _ = lru[1] - assert [(2, 2), (1, 1)] == list(lru.items(accessTime=True)) - - _ = lru[2] - assert [(1, 1), (2, 2)] == list(lru.items(accessTime=True)) - - assert lru.get(2) == 2 - assert lru.get(3) is None - assert [(1, 1), (2, 2)] == list(lru.items(accessTime=True)) - - lru.clear() - assert [] == list(lru.items()) diff --git a/tests/widgets/test_busycursor.py b/tests/widgets/test_busycursor.py index ef7de10349..2647d99163 100644 --- a/tests/widgets/test_busycursor.py +++ b/tests/widgets/test_busycursor.py @@ -1,8 +1,16 @@ +import pytest +import sys + import pyqtgraph as pg pg.mkQApp() - +@pytest.mark.skipif( + sys.platform.startswith("linux") + and pg.Qt.QT_LIB == "PySide6" + and (6, 0) < pg.Qt.PySide6.__version_info__ < (6, 4, 3), + reason="taking gui thread causes segfault" +) def test_nested_busy_cursors_clear_after_all_exit(): with pg.BusyCursor(): wait_cursor = pg.Qt.QtCore.Qt.CursorShape.WaitCursor diff --git a/tests/widgets/test_matplotlibwidget.py b/tests/widgets/test_matplotlibwidget.py index 31e66f62ff..31ca2f62b6 100644 --- a/tests/widgets/test_matplotlibwidget.py +++ b/tests/widgets/test_matplotlibwidget.py @@ -4,13 +4,27 @@ Tests the creation of a MatplotlibWidget. """ +from importlib.metadata import version + +import numpy as np import pytest + import pyqtgraph as pg from pyqtgraph.Qt import QtWidgets -import numpy as np pytest.importorskip("matplotlib") +# see https://github.com/matplotlib/matplotlib/pull/24172 +if ( + pg.Qt.QT_LIB == "PySide6" + and tuple(map(int, pg.Qt.PySide6.__version__.split("."))) > (6, 4) + and tuple(map(int, version("matplotlib").split("."))) < (3, 6, 2) +): + pytest.skip( + "matplotlib + PySide6 6.4 bug", + allow_module_level=True + ) + from pyqtgraph.widgets.MatplotlibWidget import MatplotlibWidget pg.mkQApp() diff --git a/tests/widgets/test_progressdialog.py b/tests/widgets/test_progressdialog.py index 44673d974d..2eb3f8a4ea 100644 --- a/tests/widgets/test_progressdialog.py +++ b/tests/widgets/test_progressdialog.py @@ -1,8 +1,16 @@ -from pyqtgraph import ProgressDialog, mkQApp +import pytest +import sys -mkQApp() +import pyqtgraph as pg +pg.mkQApp() +@pytest.mark.skipif( + sys.platform.startswith("linux") + and pg.Qt.QT_LIB == "PySide6" + and (6, 0) < pg.Qt.PySide6.__version_info__ < (6, 4, 3), + reason="taking gui thread causes segfault" +) def test_progress_dialog(): - with ProgressDialog("test", 0, 1) as dlg: + with pg.ProgressDialog("test", 0, 1) as dlg: dlg += 1 diff --git a/tools/rebuildUi.py b/tools/rebuildUi.py index 8c64c07040..07f8897141 100644 --- a/tools/rebuildUi.py +++ b/tools/rebuildUi.py @@ -1,18 +1,16 @@ #!/usr/bin/python """ Script for compiling Qt Designer .ui files to .py +""" +import os +import subprocess +import sys -""" -import os, sys, subprocess, tempfile - -pyqt5uic = 'pyuic5' pyqt6uic = 'pyuic6' -pyside2uic = 'pyside2-uic' -pyside6uic = 'pyside6-uic' -usage = """Compile .ui files to .py for all supported pyqt/pyside versions. +usage = """Compile .ui files to .py for PyQt6 Usage: python rebuildUi.py [--force] [.ui files|search paths] @@ -31,7 +29,6 @@ print(usage) sys.exit(-1) - uifiles = [] for arg in args: if os.path.isfile(arg) and arg.endswith('.ui'): @@ -39,26 +36,25 @@ elif os.path.isdir(arg): # recursively search for ui files in this directory for path, sd, files in os.walk(arg): - for f in files: - if not f.endswith('.ui'): - continue - uifiles.append(os.path.join(path, f)) + uifiles.extend(os.path.join(path, f) for f in files if f.endswith('.ui')) else: print('Argument "%s" is not a directory or .ui file.' % arg) sys.exit(-1) +compiler = pyqt6uic +extension = '_generic.py' # rebuild all requested ui files for ui in uifiles: base, _ = os.path.splitext(ui) - for compiler, ext in [(pyqt5uic, '_pyqt5.py'), (pyside2uic, '_pyside2.py'), - (pyqt6uic, '_pyqt6.py'), (pyside6uic, '_pyside6.py')]: - py = base + ext - if not force and os.path.exists(py) and os.stat(ui).st_mtime <= os.stat(py).st_mtime: - print("Skipping %s; already compiled." % py) + py = base + ext + if not force and os.path.exists(py) and os.stat(ui).st_mtime <= os.stat(py).st_mtime: + print(f"Skipping {py}; already compiled.") + else: + cmd = f'{compiler} {ui} > {py}' + print(cmd) + try: + subprocess.check_call(cmd, shell=True) + except subprocess.CalledProcessError: + os.remove(py) else: - cmd = '%s %s > %s' % (compiler, ui, py) - print(cmd) - try: - subprocess.check_call(cmd, shell=True) - except subprocess.CalledProcessError: - os.remove(py) + print(f"{py} created, modify import to import from pyqtgraph.Qt not PyQt6") diff --git a/tox.ini b/tox.ini index cfad5eb066..a8b1314d39 100644 --- a/tox.ini +++ b/tox.ini @@ -1,16 +1,15 @@ [tox] -envlist = +envlist = ; qt 5.15.x - py{38,39,310}-{pyqt5,pyside2}_515 - - ; py38-pyside2_512 doesn't work due to PYSIDE-1140 - py38-pyqt5_512 + py{39,310,311}-pyqt5_515 + py{39,310}-pyside2_515 ; qt 6.2 - py{38,39,310}-{pyqt6,pyside6}_62 + py{39,310,311}-pyqt6_62 + py{39,310}-pyside6_62 ; qt 6-newest - py{38,39,310}-{pyqt6,pyside6} + py{39,310,311}-{pyqt6,pyside6} [base] deps = @@ -22,18 +21,17 @@ deps = h5py [testenv] -passenv = DISPLAY XAUTHORITY, PYTHON_VERSION +passenv = DISPLAY,XAUTHORITY,PYTHON_VERSION setenv = PYTHONWARNINGS=ignore:DEPRECATION::pip._internal.cli.base_command deps= {[base]deps} - pyqt5_512: pyqt5~=5.12.0 pyside2_515: pyside2 pyqt5_515: pyqt5 pyqt6_62: pyqt6~=6.2.0 pyqt6_62: PyQt6-Qt6~=6.2.0 pyside6_62: pyside6~=6.2.0 pyqt6: pyqt6 - pyside6: pyside6 + pyside6: PySide6-Essentials commands= python -c "import pyqtgraph as pg; pg.systemInfo()"