pax_global_header00006660000000000000000000000064146334001740014514gustar00rootroot0000000000000052 comment=ac4adf523442e14049a56c012eeb23d0c2c3d314 superqt-0.6.8/000077500000000000000000000000001463340017400132325ustar00rootroot00000000000000superqt-0.6.8/.github/000077500000000000000000000000001463340017400145725ustar00rootroot00000000000000superqt-0.6.8/.github/ISSUE_TEMPLATE/000077500000000000000000000000001463340017400167555ustar00rootroot00000000000000superqt-0.6.8/.github/ISSUE_TEMPLATE/bug_report.md000066400000000000000000000011631463340017400214500ustar00rootroot00000000000000--- name: Bug report about: Create a report to help us improve title: '' labels: 'bug' assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** Screenshots and GIFS are much appreciated when reporting visual bugs. **Desktop (please complete the following information):** - OS with version [e.g macOS 10.15.7] - Qt Backend [e.g PyQt5, PySide2] - Python version superqt-0.6.8/.github/ISSUE_TEMPLATE/feature.md000066400000000000000000000001511463340017400207270ustar00rootroot00000000000000--- name: Feature request about: Request a new feature title: '' labels: 'enhancement' assignees: '' --- superqt-0.6.8/.github/dependabot.yml000066400000000000000000000004241463340017400174220ustar00rootroot00000000000000# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" commit-message: prefix: "ci(dependabot):" superqt-0.6.8/.github/workflows/000077500000000000000000000000001463340017400166275ustar00rootroot00000000000000superqt-0.6.8/.github/workflows/test_and_deploy.yml000066400000000000000000000077331463340017400225410ustar00rootroot00000000000000name: Test concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true on: push: branches: [main] tags: [v*] pull_request: workflow_dispatch: schedule: - cron: "0 0 * * 0" # run weekly jobs: test: name: Test uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@v2 with: os: ${{ matrix.platform }} python-version: ${{ matrix.python-version }} qt: ${{ matrix.backend }} pip-install-pre-release: ${{ github.event_name == 'schedule' }} coverage-upload: artifact strategy: fail-fast: false matrix: platform: [ubuntu-latest, windows-latest, macos-13] python-version: ["3.8", "3.9", "3.10", "3.11"] backend: [pyqt5, pyside2, pyqt6] exclude: # Abort (core dumped) on linux pyqt6, unknown reason - platform: ubuntu-latest backend: pyqt6 # lack of wheels for pyside2/py3.11 - python-version: "3.11" backend: pyside2 include: - python-version: "3.10" platform: macos-latest backend: pyside6 - python-version: "3.11" platform: macos-latest backend: pyside6 - python-version: "3.10" platform: windows-latest backend: pyside6 - python-version: "3.11" platform: windows-latest backend: pyside6 - python-version: "3.12" platform: macos-latest backend: pyqt6 # legacy Qt - python-version: 3.8 platform: ubuntu-latest backend: "pyqt5==5.12.*" - python-version: 3.8 platform: ubuntu-latest backend: "pyqt5==5.13.*" - python-version: 3.8 platform: ubuntu-latest backend: "pyqt5==5.14.*" test-qt-minreqs: uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@v2 with: python-version: "3.8" qt: pyqt5 pip-post-installs: "qtpy==1.1.0 typing-extensions==3.7.4.3" pip-install-flags: -e coverage-upload: artifact upload_coverage: if: always() needs: [test, test-qt-minreqs] uses: pyapp-kit/workflows/.github/workflows/upload-coverage.yml@v2 secrets: inherit test_napari: uses: pyapp-kit/workflows/.github/workflows/test-dependents.yml@v2 with: dependency-repo: napari/napari dependency-ref: ${{ matrix.napari-version }} dependency-extras: "testing" qt: ${{ matrix.qt }} pytest-args: 'napari/_qt -k "not async and not qt_dims_2 and not qt_viewer_console_focus and not keybinding_editor"' python-version: "3.10" post-install-cmd: "pip install lxml_html_clean" strategy: fail-fast: false matrix: napari-version: ["", "v0.4.19.post1"] qt: ["pyqt5", "pyside2"] check-manifest: name: Check Manifest runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: pipx run check-manifest deploy: # this will run when you have tagged a commit, starting with "v*" # and requires that you have put your twine API key in your # github secrets (see readme for details) needs: [test, check-manifest] if: ${{ github.repository == 'pyapp-kit/superqt' && contains(github.ref, 'tags') }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.x" - name: Install dependencies run: | python -m pip install --upgrade pip pip install build twine - name: Build and publish env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.TWINE_API_KEY }} run: | git tag python -m build twine check dist/* twine upload dist/* - uses: softprops/action-gh-release@v2 with: generate_release_notes: true superqt-0.6.8/.github_changelog_generator000066400000000000000000000006701463340017400205750ustar00rootroot00000000000000# run this with: # export CHANGELOG_GITHUB_TOKEN=...... # github_changelog_generator --future-release vX.Y.Z user=pyapp-kit project=superqt issues=false since-tag=v0.2.0 exclude-labels=duplicate,question,invalid,wontfix,hide add-sections={"documentation":{"prefix":"**Documentation updates:**","labels":["documentation"]},"tests":{"prefix":"**Tests & CI:**","labels":["tests"]},"refactor":{"prefix":"**Refactors:**","labels":["refactor"]}} superqt-0.6.8/.gitignore000066400000000000000000000017701463340017400152270ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ .venv/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *,cover .hypothesis/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Flask instance folder instance/ # Sphinx documentation docs/_build/ # MkDocs documentation /site/ # PyBuilder target/ # IPython Notebook .ipynb_checkpoints # pyenv .python-version # OS .DS_Store # written by setuptools_scm src/superqt/_version.py .vscode/settings.json screenshots .mypy_cache docs/_auto_images/ superqt-0.6.8/.pre-commit-config.yaml000066400000000000000000000012361463340017400175150ustar00rootroot00000000000000ci: autoupdate_schedule: monthly autofix_commit_msg: "style: [pre-commit.ci] auto fixes [...]" autoupdate_commit_msg: "ci: [pre-commit.ci] autoupdate" repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.4.7 hooks: - id: ruff args: [--fix, --unsafe-fixes] - id: ruff-format - repo: https://github.com/abravalheri/validate-pyproject rev: v0.18 hooks: - id: validate-pyproject - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.10.0 hooks: - id: mypy exclude: tests|examples additional_dependencies: - types-Pygments stages: - manual superqt-0.6.8/CHANGELOG.md000066400000000000000000000616231463340017400150530ustar00rootroot00000000000000# Changelog ## [v0.6.8](https://github.com/pyapp-kit/superqt/tree/v0.6.8) (2024-06-15) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.7...v0.6.8) **Implemented enhancements:** - feat: graceful offline fallback for qiconify [\#251](https://github.com/pyapp-kit/superqt/pull/251) ([tlambert03](https://github.com/tlambert03)) ## [v0.6.7](https://github.com/pyapp-kit/superqt/tree/v0.6.7) (2024-06-07) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.6...v0.6.7) **Fixed bugs:** - fix: prevent qthrottled and qdebounced from holding strong references with bound methods [\#247](https://github.com/pyapp-kit/superqt/pull/247) ([tlambert03](https://github.com/tlambert03)) **Merged pull requests:** - Prevent computing full document content highlight per block and only compute current block content for performance [\#246](https://github.com/pyapp-kit/superqt/pull/246) ([dalthviz](https://github.com/dalthviz)) ## [v0.6.6](https://github.com/pyapp-kit/superqt/tree/v0.6.6) (2024-05-12) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.5...v0.6.6) **Refactors:** - perf: improve paint time for QColormapLineEdit [\#245](https://github.com/pyapp-kit/superqt/pull/245) ([tlambert03](https://github.com/tlambert03)) ## [v0.6.5](https://github.com/pyapp-kit/superqt/tree/v0.6.5) (2024-05-06) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.4...v0.6.5) **Implemented enhancements:** - fix: fix a number of issues with Labeled and Range Sliders, add LabelsOnHandle mode. [\#242](https://github.com/pyapp-kit/superqt/pull/242) ([tlambert03](https://github.com/tlambert03)) **Tests & CI:** - ci: trying to fix tests on various platforms [\#243](https://github.com/pyapp-kit/superqt/pull/243) ([tlambert03](https://github.com/tlambert03)) ## [v0.6.4](https://github.com/pyapp-kit/superqt/tree/v0.6.4) (2024-04-25) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.3...v0.6.4) **Fixed bugs:** - fix: fix inverted appearance [\#240](https://github.com/pyapp-kit/superqt/pull/240) ([tlambert03](https://github.com/tlambert03)) **Merged pull requests:** - ci: \[pre-commit.ci\] autoupdate [\#238](https://github.com/pyapp-kit/superqt/pull/238) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) ## [v0.6.3](https://github.com/pyapp-kit/superqt/tree/v0.6.3) (2024-03-27) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.2...v0.6.3) **Fixed bugs:** - fix: fix sliderReleased, sliderPressed signals, and setTracking [\#237](https://github.com/pyapp-kit/superqt/pull/237) ([tlambert03](https://github.com/tlambert03)) **Merged pull requests:** - ci\(dependabot\): bump softprops/action-gh-release from 1 to 2 [\#236](https://github.com/pyapp-kit/superqt/pull/236) ([dependabot[bot]](https://github.com/apps/dependabot)) ## [v0.6.2](https://github.com/pyapp-kit/superqt/tree/v0.6.2) (2024-03-06) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.1...v0.6.2) **Implemented enhancements:** - feat: make toggle button public in QCollapsible [\#232](https://github.com/pyapp-kit/superqt/pull/232) ([tlambert03](https://github.com/tlambert03)) - feat: add addKey method to QIconifyIcon [\#218](https://github.com/pyapp-kit/superqt/pull/218) ([tlambert03](https://github.com/tlambert03)) - feat: Add QIconifyIcon.name\(\) method [\#213](https://github.com/pyapp-kit/superqt/pull/213) ([tlambert03](https://github.com/tlambert03)) **Fixed bugs:** - fix: don't use AbstractContextManager for exceptions\_as\_dialog [\#234](https://github.com/pyapp-kit/superqt/pull/234) ([tlambert03](https://github.com/tlambert03)) - fix: Check min max versus current value [\#221](https://github.com/pyapp-kit/superqt/pull/221) ([psobolewskiPhD](https://github.com/psobolewskiPhD)) - fix: better default size policy for qcollapsible [\#217](https://github.com/pyapp-kit/superqt/pull/217) ([tlambert03](https://github.com/tlambert03)) **Merged pull requests:** - style: use ruff format instead of black, update pre-commit, restrict pyside6 tests [\#235](https://github.com/pyapp-kit/superqt/pull/235) ([tlambert03](https://github.com/tlambert03)) - ci: \[pre-commit.ci\] autoupdate [\#228](https://github.com/pyapp-kit/superqt/pull/228) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) - ci\(dependabot\): bump actions/setup-python from 4 to 5 [\#225](https://github.com/pyapp-kit/superqt/pull/225) ([dependabot[bot]](https://github.com/apps/dependabot)) - ci: \[pre-commit.ci\] autoupdate [\#223](https://github.com/pyapp-kit/superqt/pull/223) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) - ci: \[pre-commit.ci\] autoupdate [\#216](https://github.com/pyapp-kit/superqt/pull/216) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) - ci: use reusable test workflow [\#215](https://github.com/pyapp-kit/superqt/pull/215) ([tlambert03](https://github.com/tlambert03)) - build: remove packaging dep [\#212](https://github.com/pyapp-kit/superqt/pull/212) ([tlambert03](https://github.com/tlambert03)) ## [v0.6.1](https://github.com/pyapp-kit/superqt/tree/v0.6.1) (2023-10-10) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.0...v0.6.1) **Implemented enhancements:** - feat: add QIcon backed by iconify [\#209](https://github.com/pyapp-kit/superqt/pull/209) ([tlambert03](https://github.com/tlambert03)) **Merged pull requests:** - ci: test python 3.12 [\#181](https://github.com/pyapp-kit/superqt/pull/181) ([tlambert03](https://github.com/tlambert03)) ## [v0.6.0](https://github.com/pyapp-kit/superqt/tree/v0.6.0) (2023-09-25) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.4...v0.6.0) **Implemented enhancements:** - feat: add support for flag enum [\#207](https://github.com/pyapp-kit/superqt/pull/207) ([Czaki](https://github.com/Czaki)) - Add restart\_timer argument to GenericSignalThrottler.flush [\#206](https://github.com/pyapp-kit/superqt/pull/206) ([Czaki](https://github.com/Czaki)) - Add colormap combobox and utils [\#195](https://github.com/pyapp-kit/superqt/pull/195) ([tlambert03](https://github.com/tlambert03)) - feat: add QColorComboBox for picking single colors [\#194](https://github.com/pyapp-kit/superqt/pull/194) ([tlambert03](https://github.com/tlambert03)) **Fixed bugs:** - Fix IntEnum for python 3.11 [\#205](https://github.com/pyapp-kit/superqt/pull/205) ([Czaki](https://github.com/Czaki)) - fix: don't reuse text in qcollapsible [\#204](https://github.com/pyapp-kit/superqt/pull/204) ([tlambert03](https://github.com/tlambert03)) - fix: sliderMoved event on RangeSliders [\#200](https://github.com/pyapp-kit/superqt/pull/200) ([tlambert03](https://github.com/tlambert03)) **Documentation updates:** - docs: add colormap utils and QSearchableTreeWidget to docs [\#199](https://github.com/pyapp-kit/superqt/pull/199) ([tlambert03](https://github.com/tlambert03)) - docs: update fonticon docs [\#198](https://github.com/pyapp-kit/superqt/pull/198) ([tlambert03](https://github.com/tlambert03)) **Tests & CI:** - ci: \[pre-commit.ci\] autoupdate [\#193](https://github.com/pyapp-kit/superqt/pull/193) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) **Refactors:** - refactor: Labeled slider updates [\#197](https://github.com/pyapp-kit/superqt/pull/197) ([tlambert03](https://github.com/tlambert03)) **Merged pull requests:** - ci\(dependabot\): bump actions/checkout from 3 to 4 [\#196](https://github.com/pyapp-kit/superqt/pull/196) ([dependabot[bot]](https://github.com/apps/dependabot)) ## [v0.5.4](https://github.com/pyapp-kit/superqt/tree/v0.5.4) (2023-08-31) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.3...v0.5.4) **Fixed bugs:** - fix: fix mysterious segfault [\#192](https://github.com/pyapp-kit/superqt/pull/192) ([tlambert03](https://github.com/tlambert03)) ## [v0.5.3](https://github.com/pyapp-kit/superqt/tree/v0.5.3) (2023-08-21) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.2...v0.5.3) **Implemented enhancements:** - feat: add error `exceptions_as_dialog` context manager to catch and show Exceptions [\#191](https://github.com/pyapp-kit/superqt/pull/191) ([tlambert03](https://github.com/tlambert03)) **Fixed bugs:** - fix: remove dupes/aliases in QEnumCombo [\#190](https://github.com/pyapp-kit/superqt/pull/190) ([tlambert03](https://github.com/tlambert03)) ## [v0.5.2](https://github.com/pyapp-kit/superqt/tree/v0.5.2) (2023-08-18) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.1...v0.5.2) **Implemented enhancements:** - feat: allow throttler/debouncer as method decorator [\#188](https://github.com/pyapp-kit/superqt/pull/188) ([Czaki](https://github.com/Czaki)) **Fixed bugs:** - fix: Add descriptive exception when fail to add instance to weakref dictionary [\#189](https://github.com/pyapp-kit/superqt/pull/189) ([Czaki](https://github.com/Czaki)) ## [v0.5.1](https://github.com/pyapp-kit/superqt/tree/v0.5.1) (2023-08-17) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.0...v0.5.1) **Fixed bugs:** - fix: fix parameter inspection on ensure\_thread decorators \(alternate\) [\#185](https://github.com/pyapp-kit/superqt/pull/185) ([tlambert03](https://github.com/tlambert03)) - fix: fix callback of throttled/debounced decorated functions with mismatched args [\#184](https://github.com/pyapp-kit/superqt/pull/184) ([tlambert03](https://github.com/tlambert03)) **Documentation updates:** - docs: document signals blocked [\#186](https://github.com/pyapp-kit/superqt/pull/186) ([tlambert03](https://github.com/tlambert03)) **Merged pull requests:** - test: change wait pattern [\#187](https://github.com/pyapp-kit/superqt/pull/187) ([tlambert03](https://github.com/tlambert03)) - build: drop python3.7, misc updates to repo [\#180](https://github.com/pyapp-kit/superqt/pull/180) ([tlambert03](https://github.com/tlambert03)) ## [v0.5.0](https://github.com/pyapp-kit/superqt/tree/v0.5.0) (2023-08-06) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.4.1...v0.5.0) **Implemented enhancements:** - feat: add stepType to largeInt spinbox [\#179](https://github.com/pyapp-kit/superqt/pull/179) ([tlambert03](https://github.com/tlambert03)) - Searchable tree widget from a mapping [\#158](https://github.com/pyapp-kit/superqt/pull/158) ([andy-sweet](https://github.com/andy-sweet)) - Add `QElidingLineEdit` class for elidable `QLineEdit`s [\#154](https://github.com/pyapp-kit/superqt/pull/154) ([dalthviz](https://github.com/dalthviz)) **Fixed bugs:** - fix: focus events on QLabeledSlider [\#175](https://github.com/pyapp-kit/superqt/pull/175) ([tlambert03](https://github.com/tlambert03)) - Set parent of timer in throttler [\#171](https://github.com/pyapp-kit/superqt/pull/171) ([Czaki](https://github.com/Czaki)) - fix: fix double slider label editing [\#168](https://github.com/pyapp-kit/superqt/pull/168) ([tlambert03](https://github.com/tlambert03)) **Documentation updates:** - Fix typos [\#147](https://github.com/pyapp-kit/superqt/pull/147) ([kianmeng](https://github.com/kianmeng)) **Tests & CI:** - tests: add qtbot to test to fix windows segfault [\#165](https://github.com/pyapp-kit/superqt/pull/165) ([tlambert03](https://github.com/tlambert03)) - test: fixing tests \[wip\] [\#164](https://github.com/pyapp-kit/superqt/pull/164) ([tlambert03](https://github.com/tlambert03)) **Merged pull requests:** - build: unpin pyside6.5 [\#178](https://github.com/pyapp-kit/superqt/pull/178) ([tlambert03](https://github.com/tlambert03)) - build: pin pyside6 to \<6.5.1 [\#169](https://github.com/pyapp-kit/superqt/pull/169) ([tlambert03](https://github.com/tlambert03)) - pin pyside6\<6.5 [\#160](https://github.com/pyapp-kit/superqt/pull/160) ([tlambert03](https://github.com/tlambert03)) - ci: \[pre-commit.ci\] autoupdate [\#146](https://github.com/pyapp-kit/superqt/pull/146) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) ## [v0.4.1](https://github.com/pyapp-kit/superqt/tree/v0.4.1) (2022-12-01) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.4.0...v0.4.1) **Implemented enhancements:** - feat: Add signal to QCollapsible [\#142](https://github.com/pyapp-kit/superqt/pull/142) ([ppwadhwa](https://github.com/ppwadhwa)) - feat: Change icon used in Collapsible widget [\#140](https://github.com/pyapp-kit/superqt/pull/140) ([ppwadhwa](https://github.com/ppwadhwa)) **Fixed bugs:** - Move QCollapsible toggle signal emit [\#144](https://github.com/pyapp-kit/superqt/pull/144) ([ppwadhwa](https://github.com/ppwadhwa)) **Merged pull requests:** - build: use hatch for build backend, and use ruff for linting [\#139](https://github.com/pyapp-kit/superqt/pull/139) ([tlambert03](https://github.com/tlambert03)) - chore: rename napari org to pyapp-kit [\#137](https://github.com/pyapp-kit/superqt/pull/137) ([tlambert03](https://github.com/tlambert03)) ## [v0.4.0](https://github.com/pyapp-kit/superqt/tree/v0.4.0) (2022-11-09) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.8...v0.4.0) **Fixed bugs:** - fix: fix quantity set value and add test [\#131](https://github.com/pyapp-kit/superqt/pull/131) ([tlambert03](https://github.com/tlambert03)) **Refactors:** - refactor: update pyproject and ci, add py3.11 test [\#132](https://github.com/pyapp-kit/superqt/pull/132) ([tlambert03](https://github.com/tlambert03)) **Merged pull requests:** - chore: changelog v0.4.0 [\#136](https://github.com/pyapp-kit/superqt/pull/136) ([tlambert03](https://github.com/tlambert03)) - ci\(dependabot\): bump actions/upload-artifact from 2 to 3 [\#135](https://github.com/pyapp-kit/superqt/pull/135) ([dependabot[bot]](https://github.com/apps/dependabot)) - ci\(dependabot\): bump codecov/codecov-action from 2 to 3 [\#134](https://github.com/pyapp-kit/superqt/pull/134) ([dependabot[bot]](https://github.com/apps/dependabot)) - build: unpin pyside6 [\#133](https://github.com/pyapp-kit/superqt/pull/133) ([tlambert03](https://github.com/tlambert03)) ## [v0.3.8](https://github.com/pyapp-kit/superqt/tree/v0.3.8) (2022-10-10) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.7...v0.3.8) **Fixed bugs:** - fix: allow submodule imports [\#128](https://github.com/pyapp-kit/superqt/pull/128) ([kne42](https://github.com/kne42)) ## [v0.3.7](https://github.com/pyapp-kit/superqt/tree/v0.3.7) (2022-10-10) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.6...v0.3.7) **Implemented enhancements:** - feat: add Quantity widget \(using pint\) [\#126](https://github.com/pyapp-kit/superqt/pull/126) ([tlambert03](https://github.com/tlambert03)) ## [v0.3.6](https://github.com/pyapp-kit/superqt/tree/v0.3.6) (2022-10-05) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.6rc0...v0.3.6) **Documentation updates:** - minor fix to readme [\#125](https://github.com/pyapp-kit/superqt/pull/125) ([tlambert03](https://github.com/tlambert03)) - Docs [\#124](https://github.com/pyapp-kit/superqt/pull/124) ([tlambert03](https://github.com/tlambert03)) ## [v0.3.6rc0](https://github.com/pyapp-kit/superqt/tree/v0.3.6rc0) (2022-10-03) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.5...v0.3.6rc0) **Implemented enhancements:** - feat: add editing finished signal to LabeledSliders [\#122](https://github.com/pyapp-kit/superqt/pull/122) ([tlambert03](https://github.com/tlambert03)) **Fixed bugs:** - fix: fix missing labels after setValue [\#123](https://github.com/pyapp-kit/superqt/pull/123) ([tlambert03](https://github.com/tlambert03)) - fix: Fix TypeError on slider rangeChanged signal [\#121](https://github.com/pyapp-kit/superqt/pull/121) ([tlambert03](https://github.com/tlambert03)) - Simple workaround for pyside 6 [\#119](https://github.com/pyapp-kit/superqt/pull/119) ([Czaki](https://github.com/Czaki)) - fix: Offer patch for \(unstyled\) QSliders on macos 12 and Qt \<6 [\#117](https://github.com/pyapp-kit/superqt/pull/117) ([tlambert03](https://github.com/tlambert03)) ## [v0.3.5](https://github.com/pyapp-kit/superqt/tree/v0.3.5) (2022-08-17) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.4...v0.3.5) **Fixed bugs:** - fix range slider drag crash on PyQt6 [\#108](https://github.com/pyapp-kit/superqt/pull/108) ([sfhbarnett](https://github.com/sfhbarnett)) - Fix float value error in pyqt configuration [\#106](https://github.com/pyapp-kit/superqt/pull/106) ([mstabrin](https://github.com/mstabrin)) **Merged pull requests:** - chore: changelog v0.3.5 [\#110](https://github.com/pyapp-kit/superqt/pull/110) ([tlambert03](https://github.com/tlambert03)) ## [v0.3.4](https://github.com/pyapp-kit/superqt/tree/v0.3.4) (2022-07-24) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.3...v0.3.4) **Fixed bugs:** - fix: relax runtime typing extensions requirement [\#101](https://github.com/pyapp-kit/superqt/pull/101) ([tlambert03](https://github.com/tlambert03)) - fix: catch qpixmap deprecation [\#99](https://github.com/pyapp-kit/superqt/pull/99) ([tlambert03](https://github.com/tlambert03)) ## [v0.3.3](https://github.com/pyapp-kit/superqt/tree/v0.3.3) (2022-07-10) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.2...v0.3.3) **Implemented enhancements:** - Add code syntax highlight utils [\#88](https://github.com/pyapp-kit/superqt/pull/88) ([Czaki](https://github.com/Czaki)) **Fixed bugs:** - fix: fix deprecation warning on fonticon plugin discovery on python 3.10 [\#95](https://github.com/pyapp-kit/superqt/pull/95) ([tlambert03](https://github.com/tlambert03)) ## [v0.3.2](https://github.com/pyapp-kit/superqt/tree/v0.3.2) (2022-05-03) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.1...v0.3.2) **Implemented enhancements:** - Add QSearchableListWidget and QSearchableComboBox widgets [\#80](https://github.com/pyapp-kit/superqt/pull/80) ([Czaki](https://github.com/Czaki)) **Fixed bugs:** - Fix crazy animation loop on Qcollapsible [\#84](https://github.com/pyapp-kit/superqt/pull/84) ([tlambert03](https://github.com/tlambert03)) - Reorder label update signal [\#83](https://github.com/pyapp-kit/superqt/pull/83) ([tlambert03](https://github.com/tlambert03)) - Fix height of expanded QCollapsible when child changes size [\#72](https://github.com/pyapp-kit/superqt/pull/72) ([tlambert03](https://github.com/tlambert03)) **Tests & CI:** - Fix deprecation warnings in tests [\#82](https://github.com/pyapp-kit/superqt/pull/82) ([tlambert03](https://github.com/tlambert03)) **Merged pull requests:** - Add changelog for v0.3.2 [\#86](https://github.com/pyapp-kit/superqt/pull/86) ([tlambert03](https://github.com/tlambert03)) ## [v0.3.1](https://github.com/pyapp-kit/superqt/tree/v0.3.1) (2022-03-02) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.0...v0.3.1) **Implemented enhancements:** - Add `signals_blocked` util [\#69](https://github.com/pyapp-kit/superqt/pull/69) ([tlambert03](https://github.com/tlambert03)) **Fixed bugs:** - put SignalInstance in TYPE\_CHECKING clause, check min requirements [\#70](https://github.com/pyapp-kit/superqt/pull/70) ([tlambert03](https://github.com/tlambert03)) **Merged pull requests:** - Add changelog for v0.3.1 [\#71](https://github.com/pyapp-kit/superqt/pull/71) ([tlambert03](https://github.com/tlambert03)) ## [v0.3.0](https://github.com/pyapp-kit/superqt/tree/v0.3.0) (2022-02-16) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.5-1...v0.3.0) **Implemented enhancements:** - Qthrottler and debouncer [\#62](https://github.com/pyapp-kit/superqt/pull/62) ([tlambert03](https://github.com/tlambert03)) - add edgeLabelMode option to QLabeledSlider [\#59](https://github.com/pyapp-kit/superqt/pull/59) ([tlambert03](https://github.com/tlambert03)) **Fixed bugs:** - Fix nested threadworker not starting [\#63](https://github.com/pyapp-kit/superqt/pull/63) ([tlambert03](https://github.com/tlambert03)) - Add missing signals on proxy sliders [\#54](https://github.com/pyapp-kit/superqt/pull/54) ([tlambert03](https://github.com/tlambert03)) - Ugly but functional workaround for pyside6.2.1 breakages [\#51](https://github.com/pyapp-kit/superqt/pull/51) ([tlambert03](https://github.com/tlambert03)) **Tests & CI:** - add napari test to CI [\#67](https://github.com/pyapp-kit/superqt/pull/67) ([tlambert03](https://github.com/tlambert03)) - add gh-release action [\#65](https://github.com/pyapp-kit/superqt/pull/65) ([tlambert03](https://github.com/tlambert03)) - fix xvfb tests [\#61](https://github.com/pyapp-kit/superqt/pull/61) ([tlambert03](https://github.com/tlambert03)) **Refactors:** - Use qtpy, deprecate superqt.qtcompat, drop support for Qt \<5.12 [\#39](https://github.com/pyapp-kit/superqt/pull/39) ([tlambert03](https://github.com/tlambert03)) **Merged pull requests:** - Add changelog for v0.3.0 [\#68](https://github.com/pyapp-kit/superqt/pull/68) ([tlambert03](https://github.com/tlambert03)) ## [v0.2.5-1](https://github.com/pyapp-kit/superqt/tree/v0.2.5-1) (2021-11-23) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.5...v0.2.5-1) **Merged pull requests:** - typing-extensions version pinning [\#46](https://github.com/pyapp-kit/superqt/pull/46) ([AhmetCanSolak](https://github.com/AhmetCanSolak)) ## [v0.2.5](https://github.com/pyapp-kit/superqt/tree/v0.2.5) (2021-11-22) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.4...v0.2.5) **Implemented enhancements:** - add support for python 3.10 [\#42](https://github.com/pyapp-kit/superqt/pull/42) ([tlambert03](https://github.com/tlambert03)) - QCollapsible for Collapsible Section Control [\#37](https://github.com/pyapp-kit/superqt/pull/37) ([MosGeo](https://github.com/MosGeo)) - Threadworker [\#31](https://github.com/pyapp-kit/superqt/pull/31) ([tlambert03](https://github.com/tlambert03)) - Add font icons [\#24](https://github.com/pyapp-kit/superqt/pull/24) ([tlambert03](https://github.com/tlambert03)) **Fixed bugs:** - Fix some small linting issues. [\#41](https://github.com/pyapp-kit/superqt/pull/41) ([tlambert03](https://github.com/tlambert03)) - Use functools.wraps insterad of \_\_wraped\_\_ and manual proxing \_\_name\_\_ [\#29](https://github.com/pyapp-kit/superqt/pull/29) ([Czaki](https://github.com/Czaki)) - Propagate function name in `ensure_main_thread` and `ensure_object_thread` [\#28](https://github.com/pyapp-kit/superqt/pull/28) ([Czaki](https://github.com/Czaki)) **Tests & CI:** - reskip test\_object\_thread\_return on ci [\#43](https://github.com/pyapp-kit/superqt/pull/43) ([tlambert03](https://github.com/tlambert03)) **Refactors:** - refactoring qtcompat [\#34](https://github.com/pyapp-kit/superqt/pull/34) ([tlambert03](https://github.com/tlambert03)) **Merged pull requests:** - Fix-manifest, move font tests [\#44](https://github.com/pyapp-kit/superqt/pull/44) ([tlambert03](https://github.com/tlambert03)) - update deploy [\#33](https://github.com/pyapp-kit/superqt/pull/33) ([tlambert03](https://github.com/tlambert03)) - move to src layout [\#32](https://github.com/pyapp-kit/superqt/pull/32) ([tlambert03](https://github.com/tlambert03)) ## [v0.2.4](https://github.com/pyapp-kit/superqt/tree/v0.2.4) (2021-09-13) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.3...v0.2.4) **Implemented enhancements:** - Add type stubs for ensure\_thread decorator [\#23](https://github.com/pyapp-kit/superqt/pull/23) ([tlambert03](https://github.com/tlambert03)) - Add `ensure_main_tread` and `ensure_object_thread` [\#22](https://github.com/pyapp-kit/superqt/pull/22) ([Czaki](https://github.com/Czaki)) - Add QMessageHandler context manager [\#21](https://github.com/pyapp-kit/superqt/pull/21) ([tlambert03](https://github.com/tlambert03)) **Merged pull requests:** - add changelog for 0.2.4 [\#25](https://github.com/pyapp-kit/superqt/pull/25) ([tlambert03](https://github.com/tlambert03)) ## [v0.2.3](https://github.com/pyapp-kit/superqt/tree/v0.2.3) (2021-08-25) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.2...v0.2.3) **Fixed bugs:** - Fix warnings on eliding label for 5.12, test more qt versions [\#19](https://github.com/pyapp-kit/superqt/pull/19) ([tlambert03](https://github.com/tlambert03)) ## [v0.2.2](https://github.com/pyapp-kit/superqt/tree/v0.2.2) (2021-08-17) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.1...v0.2.2) **Implemented enhancements:** - Add QElidingLabel [\#16](https://github.com/pyapp-kit/superqt/pull/16) ([tlambert03](https://github.com/tlambert03)) - Enum ComboBox implementation [\#13](https://github.com/pyapp-kit/superqt/pull/13) ([Czaki](https://github.com/Czaki)) **Documentation updates:** - fix broken link [\#18](https://github.com/pyapp-kit/superqt/pull/18) ([haesleinhuepf](https://github.com/haesleinhuepf)) ## [v0.2.1](https://github.com/pyapp-kit/superqt/tree/v0.2.1) (2021-07-10) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.0...v0.2.1) **Fixed bugs:** - Fix QLabeledRangeSlider API \(fix slider proxy\) [\#10](https://github.com/pyapp-kit/superqt/pull/10) ([tlambert03](https://github.com/tlambert03)) - Fix range slider with negative min range [\#9](https://github.com/pyapp-kit/superqt/pull/9) ([tlambert03](https://github.com/tlambert03)) \* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* superqt-0.6.8/CONTRIBUTING.md000066400000000000000000000026061463340017400154670ustar00rootroot00000000000000# Contributing to this repository This repository seeks to accumulate Qt-based widgets for python (PyQt & PySide) that are not provided in the native QtWidgets module. ## Clone To get started fork this repository, and clone your fork: ```bash # clone your fork git clone https://github.com//superqt cd superqt # install pre-commit hooks pre-commit install # install in editable mode pip install -e .[dev] # run tests & make sure everything is working! pytest ``` ## Targeted platforms All widgets must be well-tested, and should work on: - Python 3.8 and above - PyQt5 (5.11 and above) & PyQt6 - PySide2 (5.11 and above) & PySide6 - macOS, Windows, & Linux ## Style Guide All widgets should try to match the native Qt API as much as possible: - Methods should use `camelCase` naming. - Getters/setters use the `attribute()/setAttribute()` pattern. - Private methods should use `_camelCaseNaming`. - `__init__` methods should be like Qt constructors, meaning they often don't include parameters for most of the widgets properties. - When possible, widgets should inherit from the most similar native widget available. It should strictly match the Qt API where it exists, and attempt to cover as much of the native API as possible; this includes properties, public functions, signals, and public slots. ## Testing Tests can be run in the current environment with `pytest`. superqt-0.6.8/LICENSE000066400000000000000000000027101463340017400142370ustar00rootroot00000000000000 Copyright (c) 2021, Talley Lambert All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of superqt nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. superqt-0.6.8/README.md000066400000000000000000000047101463340017400145130ustar00rootroot00000000000000# ![tiny](https://user-images.githubusercontent.com/1609449/120636353-8c3f3800-c43b-11eb-8732-a14dec578897.png) superqt! [![License](https://img.shields.io/pypi/l/superqt.svg?color=green)](https://github.com/pyapp-kit/superqt/raw/master/LICENSE) [![PyPI](https://img.shields.io/pypi/v/superqt.svg?color=green)](https://pypi.org/project/superqt) [![Python Version](https://img.shields.io/pypi/pyversions/superqt.svg?color=green)](https://python.org) [![Test](https://github.com/pyapp-kit/superqt/actions/workflows/test_and_deploy.yml/badge.svg)](https://github.com/pyapp-kit/superqt/actions/workflows/test_and_deploy.yml) [![codecov](https://codecov.io/gh/pyapp-kit/superqt/branch/main/graph/badge.svg?token=dcsjgl1sOi)](https://codecov.io/gh/pyapp-kit/superqt) ### "missing" widgets and components for PyQt/PySide This repository aims to provide high-quality community-contributed Qt widgets and components for PyQt & PySide that are not provided in the native QtWidgets module. Components are tested on: - macOS, Windows, & Linux - Python 3.8 and above - PyQt5 (5.11 and above) & PyQt6 - PySide2 (5.11 and above) & PySide6 ## Documentation Documentation is available at https://pyapp-kit.github.io/superqt/ ## Widgets superqt provides a variety of widgets that are not included in the native QtWidgets module, including multihandle (range) sliders, comboboxes, and more. See the [widgets documentation](https://pyapp-kit.github.io/superqt/widgets) for a full list of widgets. - [Range Slider](https://pyapp-kit.github.io/superqt/widgets/qrangeslider/) (multi-handle slider) range sliders range sliders range sliders ## Utilities superqt includes a number of utilities for working with Qt, including: - tools and decorators for working with threads in qt. - `superqt.fonticon` for generating icons from font files (such as [Material Design Icons](https://materialdesignicons.com/) and [Font Awesome](https://fontawesome.com/)) See the [utilities documentation](https://pyapp-kit.github.io/superqt/utilities/) for a full list of utilities. ## Contributing We welcome contributions! Please see the [Contributing Guide](CONTRIBUTING.md) superqt-0.6.8/codecov.yml000066400000000000000000000006201463340017400153750ustar00rootroot00000000000000ignore: - superqt/_version.py - '*_tests*' coverage: status: project: default: target: auto threshold: 1% # PR will fail if it drops coverage on the project by >1% patch: default: target: auto threshold: 40% # A given PR will fail if >40% is untested comment: require_changes: true # if true: only post the PR comment if coverage changes superqt-0.6.8/docs/000077500000000000000000000000001463340017400141625ustar00rootroot00000000000000superqt-0.6.8/docs/_macros.py000066400000000000000000000102241463340017400161560ustar00rootroot00000000000000import sys from enum import EnumMeta from importlib import import_module from pathlib import Path from textwrap import dedent from typing import TYPE_CHECKING from jinja2 import pass_context from qtpy.QtCore import QObject, Signal if TYPE_CHECKING: from mkdocs_macros.plugin import MacrosPlugin EXAMPLES = Path(__file__).parent.parent / "examples" IMAGES = Path(__file__).parent / "_auto_images" IMAGES.mkdir(exist_ok=True, parents=True) def define_env(env: "MacrosPlugin"): @env.macro @pass_context def show_widget(context, width: int = 500) -> list[Path]: # extract all fenced code blocks starting with "python" page = context["page"] dest = IMAGES / f"{page.title}.png" if "build" in sys.argv: dest.unlink(missing_ok=True) codeblocks = [ b[6:].strip() for b in page.markdown.split("```") if b.startswith("python") ] src = codeblocks[0].strip() src = src.replace( "QApplication([])", "QApplication.instance() or QApplication([])" ) src = src.replace("app.exec_()", "") exec(src) _grab(dest, width) return ( f"![{page.title}](../{dest.parent.name}/{dest.name})" f"{{ loading=lazy; width={width} }}\n\n" ) @env.macro def show_members(cls: str): # import class module, name = cls.rsplit(".", 1) _cls = getattr(import_module(module), name) first_q = next( ( b.__name__ for b in _cls.__mro__ if issubclass(b, QObject) and ".Qt" in b.__module__ ), None, ) inherited_members = set() for base in _cls.__mro__: if issubclass(base, QObject) and ".Qt" in base.__module__: inherited_members.update( {k for k in dir(base) if not k.startswith("_")} ) new_signals = { k for k, v in vars(_cls).items() if not k.startswith("_") and isinstance(v, Signal) } self_members = { k for k in dir(_cls) if not k.startswith("_") and k not in inherited_members | new_signals } enums = [] for m in list(self_members): if isinstance(getattr(_cls, m), EnumMeta): self_members.remove(m) enums.append(m) out = "" if first_q: url = f"https://doc.qt.io/qt-6/{first_q.lower()}.html" out += f"## Qt Class\n\n`{first_q}`\n\n" out += "" if new_signals: out += "## Signals\n\n" for sig in new_signals: out += f"### `{sig}`\n\n" if enums: out += "## Enums\n\n" for e in enums: out += f"### `{_cls.__name__}.{e}`\n\n" for m in getattr(_cls, e): out += f"- `{m.name}`\n\n" if self_members: out += dedent( f""" ## Methods ::: {cls} options: heading_level: 3 show_source: False show_inherited_members: false show_signature_annotations: True members: {sorted(self_members)} docstring_style: numpy show_bases: False show_root_toc_entry: False show_root_heading: False """ ) return out def _grab(dest: str | Path, width) -> list[Path]: """Grab the top widgets of the application.""" from qtpy.QtCore import QTimer from qtpy.QtWidgets import QApplication w = QApplication.topLevelWidgets()[-1] w.setFixedWidth(width) w.activateWindow() w.setMinimumHeight(40) w.grab().save(str(dest)) # hack to make sure the object is truly closed and deleted while True: QTimer.singleShot(10, w.deleteLater) QApplication.processEvents() try: w.parent() except RuntimeError: return superqt-0.6.8/docs/faq.md000066400000000000000000000023201463340017400152500ustar00rootroot00000000000000# FAQ ## Sliders not dragging properly on MacOS 12+ ??? details On MacOS Monterey, with Qt5, there is a bug that causes all sliders (including native Qt sliders) to not respond properly to drag events. See: - [https://bugreports.qt.io/browse/QTBUG-98093](https://bugreports.qt.io/browse/QTBUG-98093) - [https://github.com/pyapp-kit/superqt/issues/74](https://github.com/pyapp-kit/superqt/issues/74) Superqt includes a workaround for this issue, but it is not perfect, and it requires using a custom stylesheet (which may interfere with your own styles). Note that you may not see this issue if you're already using custom stylesheets. To opt in to the workaround, do any of the following: - set the environment variable `USE_MAC_SLIDER_PATCH=1` before importing superqt (note: this is safe to use even if you're targeting more than just MacOS 12, it will only be applied when needed) - call the `applyMacStylePatch()` method on any of the superqt slider subclasses (note, this will override your slider styles) - apply the stylesheet manually: ```python from superqt.sliders import MONTEREY_SLIDER_STYLES_FIX slider.setStyleSheet(MONTEREY_SLIDER_STYLES_FIX) ``` superqt-0.6.8/docs/images/000077500000000000000000000000001463340017400154275ustar00rootroot00000000000000superqt-0.6.8/docs/images/demo_darwin10.png000066400000000000000000001241031463340017400205670ustar00rootroot00000000000000PNG  IHDRXJ pHYs   IDATxyXSW7𓅐 (b٬T[ՎjE).R biZEEYTM aI y83/gy͹''7ךo)//'. x! ef,C0`X`23! ef,C0`X`23! ef,C0`X`23! ef,C0`X`23! ef,C0`X`23! ef,C0`X`23! ef,C0`X`23! ef,C0`X`23! ef,C0`X`23! e|v .r8P(T*QsOg]ZZÇz_o޼" g͚ܶvRPTvwwoŚ^rvcO>>>>nnn\n'ܻwW_}E)))_jZ;qDvvvIIJsvv>}ɓuĭ'OtgΜ9BP2dHff&!_~߿saÆZXXLYbE-#JW^M;v1c^NZ7x`Va=fqqqkY''3ge6ʚ5k֣G'&&n޼}>1!!aܹO>eƞ>}ڵmXÃN}`F۷u0$̜9w/1`F 4ciiiNNNmmǏy3gxyyؼ͛SN"L4ՕOxܸq'OW?1..nΜ9J2tЁYYYiiioիWz=z[2 ٳҿ3cԩSG⋈7FGGն@&-ZAEFFvЁyvƍ͋J/NLL477g U*\.7,,lu֭^:22"::zѢEMWXXXۼ3FTǏӱNNN,Nf@ςG"ss;w8rJyyy BGU*$99 [?YYYnsJJJn޼YYY|-cǎ2Byxx3!H=D={,''ү_?TF[lU>}Z߷E&ў7TOy?8>555==-lܬzʒ40uͺO|XT#!vvvvvvF wޥO%$$n˗ǏߵkW??nݺ >T/#im.]o5f̘YGgΜ!H[[[Ǐ755%DGG3i{z>K(FEE}ׯꡡvvv5TTT|'~~~Æ ڵ{wT=z~pk֬{wf̘2bĈ3gha1USSpB999͙3G{Tjժ7|=00mǎ28uꔝ]@@{G?seddtU 'Ohpn8___OO@#G444J\ !Yj ,;)Z1+W<~z1DroF:|oWʒJQ*tvBh)uӧOW/#޽hѢ+VhЩSI{o6UuȐ!o߾Cqppr… ˗/۷sP__T*5+?ʕ+J믿&%%iLrcї+**4iR^^s"kΙ3G=)Y벤$88p8*J,o۶֭[FFF T*ݻwɉٰaFKbbbJeSW|23*++ E3€j ,W!ڵkޮ]SǏ8qbȐ!3gl۶97##c FFFK._~}ǎ9R]]݊,++3gBD;vHHH|ޥKB͛+**4N666^xqdddLLoTYYYA޽u7Y"91ӡCnڴ)##EoQӆ h*ܹsyС1cƈsi?~7`[iu'FEE%'':u͍ѣC1žtt􂂂___BH||/B4iRRRRdd$=eIIIIIIcǎ%L6wTVVؠ ccک5|ѼyGGG?Yvw}Z TG^pҧOSZZeBȐ!CN:E:88O0ۻw^|9=FWhh9shC?~Oש={qA///SS5k֨TׯP?$**~׭>])Bټy;wJJJjjjv޽{n''#F1Lw=zo%t%:: 1cŋ߂cܸqj-0iҤp:{ݽr<11qŴoF۷ƍ??#G,Yɓ'ݻO:u*ܹsv֨f]m۶֬Y駟|||&L:* ,77w̛77$$r?3ZΝ;tg}%O>!( fW4)1h &Q>>>˗/oDEEEEE1 xwjO*#bybh?)]^reƌFҏ?>~vZ޼JIIvܩ>冇kXM1֮]rR!dҥk 988>|iʘ;v:wܼy*ccc !ݺu4Y055͌ cMГbfuݺuۺu`.]R/YSSCW"IӮvx eeeϞ={D"_(mĄ>PbD;to߾-[Ǖ+WhNMM͡C.\{i iiirss" 5:88BԇgeeI$zkԩƍ0`>}+WhFiVYYBu5d̤Dl}}}۬6 f&&&#nܸT*MLLO Ŭ8}!==v/0H;&o/:vRS:uD=ZGlFY33`?%%G˗/駟l_F0c}^\|Ϳ\s֭[W\\|L b :Oƍ[jUmm-3_~p8:1\Ì v$xxxTh3///:Zlɓ'kkkᇹs2irtBW:wLH$etoyyyFbv_}~uӧO>}fΜ9vꄄJ++f5 Q:>gϞ5<=YXXDDDܹsg߾} Jڵk׮]߿'[㣢Ο?_Bbbb!^3Sא,l0Ӱf͚躺;v[XX}􉏏ϟݣ* e2YktΨ^D}VSoN͟?O?e:Jsw+m5557oޔdOHHl9R.y<DJ>4>$//F5k;7nܠjqF>|J̼pw}W^^k.}j>}zTTT^^^rr3Ǩ4xuo޼Գgϴ3JHH`4\|)pW\yu%5Cc466 _CHԣGBH%_tx%B/_N/} 3>c<~G_____?:t(!6[nUJѣM&=yD;iAHP?x>oVXA'޷o=عsp8 &0W^͜8i$`ӦM2Lڼ8BP(lV\a^rzJZp 5kΝ;N»w._\;Mǜد_?bbb>|(33392l0ڝ8{lusvv裏!?G}D7Ьe+8qb֬YNzAqqqLL7l@۷o4seeerrg}F7(WTyyy .wȄ Ž/::ѣ999aiFq0$$X_oܸ111155uϞ=~w2nj;wC Ν;`Bׯk ʲ cvb8::9v5^.44tĉG=z(s|ڴi45kVTTTiisΝ;]fȐ!{8xĉ?0+++77WcK7>/۞͛7gff^v-%%E}:;vL\;&&feeev>4669rdp8֭[xT* L)S"hĈ/^,--]j!$<<|L:԰SN̆ {>sLRy^s:uJ}Alll]]JweU/ێ[YUO5k!*j۶m ٿ%Kל JLL Pʕ+6m?~|~y'%%1#DDDx{{3+oذaMUo3ꝋoϬ.ϟqNƹp֮]Ehjk,fsݻ˜w;zDFFFǎ f.5j?ܳgOp><<<o'ooӧOk2E+Qjĉ񾾾wQ.s\uEGGk\޽{.Xy $2Anʔ) H3o#Gwޝ9bcce˖M6 sׁB(,,kJ4|7T*577wrrj41P__BHN*,,-,,422ѣG^bcc,YB>|?:iiibW^{ĵXmmG8o "/驤޽{%%%JU=jgggoYP444hݾ} !/_fSѓPRY[[3,?ťJK\ \_pڵK9Nzz .~,\oܶm[C(++߿uuٳgn@/p5p| v |ٳyyyr+W '`R!d߾}999999fb-ФӧY^׿n4 Jڛ>>9s&8B̚СCcƌٹsÇ !:;;<L<yы-b9f6yɓ'3Iիl7^Y 777[ ef,C0`X`23! ef,C0`X`23! ef,C0`X`23nJJeCCJ"p8.n+ CRd2\.˕JR$x<'P(466p8l7`MRTRH,DN:9::B RH$B<h.3h\.H$9992sȑ{ B @ C0F֊{xxxyy=[ZZZJJKh'a@0M555$88Y'zyyyyyTTTԴZ``\>R4((e5ImB0O.bD2vgرD,jC0RT$++}eꂂ$ t@0J999UgNNT*m  BJd'///LVUUN3B [,,,kj BL&nnn[X,d[-A0B"-jDX@7`ZPQh%UKՠ9K[TޱcǿSN\.~ />ommpn Plll]]c(Jccw}V{o;=%弎HeϡjP5;woJeCCC[TR(l7 1Qhv^ *S'۶cǎ -jxՕ;:: h׹wu~m{@om=@JJ-.++2FڶI75 aoFtǏx IDATSNl7 c&sV b!Dzڢ'Oɩ-*0 !ظ-j666n BH$hj322,,,hj`"t钚ںզv @73 >oeeEIKKk:iUVVV|OgxAf_ɭUarrykU`࿌_h'''kkk## !q8333;;;."Ur\;;;333Z-0Tfљf... ̙3-̙3 LB#""|||<7--֭[]tҥ 1 #lll|H$sݻwRlkkkii)0@Off3+++Phnn^YYy7nXXX888wԉRZZZTTTXX(HLLLtbeeeii) `ٌ[[[dBFFFW\M< B RY__T*`񌌌(h3ЅOCCSMhl`zxH_m ef,C0`X`23aiKCCCCCG.РT*U*U}}Sryry<ycSTuuu2L.+JR@  _/ZT*˕J'O4 D"SSS3 JJUUUEEEbX$ɩcǎǏgggKR{{{sssHd_S(UUUyyyݺu{7E"рt"Hn߾][[۫W/KKKþ- \.H$9992sȑ{ B @ K.⌌ AyŰa*++]ijjڦdazny77777WWWN0xR<++kʔ)<[YYDЈ``jjjrss%IpppN򊉉ѣaQKP[[[VV7mڴWҷoߜRl~"<677W* T[[[ۺmJuuuQQQIIĉ_ LA00(t2D";v3vXD"՗/_UUUaaaMM ފBxJ33áR$IVVVeeeI$JkE"s8#FݖWJ999UgNNT*m TGYXXݜWPTUUU2L5%ɪihhh͵f93QWWWXX؊egaaa]]]V GTVTTdffՋb L&nnn[X,d[-B{t֍z v׍\.DmQH$ڌC}}=MeFd9$3CT*RiΝۢΝ;KRRڢ~x}]PFCРP(ڢrBaB(S"JU__okkaaFuuuu]JweU/{311裏8z @ipB*h10喕999zeee\.]"^Yfa x]BPTFFFl7=g###rE`ZZÇz_o޼" g͚:-Ƥݸq~ᇭRPTvwwoŚq\>_XXsl|enݎ;6c L3k1Cfqqq'N.))QTvvvӧO}?KJSr8e˖}m)!!aܹO>eƞ>}ڵ ײl???ƍصkW[3X|AB̙3{Ѧظ-j666ns8~il7c͛SN"L4ՕOxܸq'O>7ԩ}\WWѣǏT={TVVرN&qqqsQ*C8>77JKK{^jggT e={B`-#,,,222ZwCFFEm] A p8{{{wE???ggg1`&-ZDS٠A"##Շ nܸq޼yRtʼn5lذaذaG\hѢ∈h+J内͞=9nݺիWGFFVTTDGGG {)fD]tIMMm`ڽ{w3Ȉixׯ_ fy{~B;11<<<&"=޽{Wjʺw\.׳YYY:ʨTb}T*oS(={Cׯz*#-[ui=߅6LF{ޤR>嫪*++)___q,99N&lv_@MM͝;wbb󲴺T*]]]>^XPlݺ{ԨQ>>>6lLի+ ¨~:.Ehhv |򉳳߰aúv{6Uϭ[&Oܽ{C6eر׮](\3f1b̙YTjժ7|=00mǎ2L[h1XlgggggwҥѣGcȑzݻٳgOIHH5jo@?)S$%%1N:egg@{ї8w\FF}LWpIl||6yoPqqq9ry cnn윜Z&'';;;k0hD;wvvv~Qbb"-zeZ0̤=B7*:dBHqqQ{<O%rݻw-Z{nJemm9sN>]ZZJ+1ch|K_ϟOիW]V.+J+WܹSF"'N0U*կ:vF;^222N￧O~]+**4i^SSϛ7/22R|xxԩSo߾-˙C._@P(?Wqww޽RnLLLR433kt$C{6lΜ9̑M6,~Y[[;99EGGxmNNNs񌍍;uԭ[2ݿ ЂYVV}лwo%4N޽{ҭ[7zlΜ9 B$ر#!!˟9]-tܽ{7>>~ĉQQQɧNrss#>~;v}:tM6eddkÆ W\!xzzܹ?]pƍLMM׬YR_CI&999ѭrybbŋi0BCC̙Cz{{:t]U[l! 2ԩS; n߾{˗37o|Νݻw޽) `Ĉ#F033{#\.]ROOϠ3f\xQ; |cFE;::Ν;رcLna7СCo}nܸ;rȒ%K=~U6oGڵܹs ůJ633311޽{mm-޾{ M:u*ܹsVrv ֨f۶m?%YO?5M03\\\Ϝ93eʔTr.۳gO.r;vxO:ujk{oqnh*uJ7|VX1e__TBȠA Ť2*88vh ɣ.]àA!AҾA1y˗kTx:M>S9111O! ޽{]^reƌFҏ?>~vZ펾JIIvܩ>冇kXM߇\L*((ÃBkj';;9رcǨs͛7oDCo3Z,XpܹsiOMGnݺX#Y+VPdРA:ƦgϞZ $---""¢gϞ666-rUDD ݺuK$ }MQ2k6:Ξ=?o+ٳgϞ=H$v'P(^6 PH$tW~_hGn.]Rڷo_x۷e˖?r \9tP\\܅ t1Ͻv󬬬ԇr:~R ۬$[o5uq 0N8B?GQ_(d={Ϟ={С={lhӧ_rfftz^pp7d239Sy̪Jp8ƆpΝwӇڔRlkkkiiI7ziCͬy}\^\\|~hߠz0KOO1Ϟ=2;2G!֭+..pL&x XbJvn׭[WZZz޽5kBlllf͚xb)ƍ[jUmm-3_~p8:1F8O4YYY Bssʻw޸q¦Cvv!O>/J$.]XYYYZZ B### /s+|Px޽777oonW]]?yW^43{{{@}^߿O8::j<l0#__Ǐ/[Lw^^ިQ;v3ZҒ>kh싦8.1N]Νuh>}3s̱cVWW'$$TVVҍP53k;880AZ>wٷo_BBBEERvڵk &`kcuoj:L0aч>sLzzzCCX,޻wÇ_JLMMǏu/Bi8poD j`CBs\###ssskk뚚L)J@ʥe8<O755555566622¼2hEV433okk[UU^)(x^Tʜedddff:5r455yL&crTBB]ȑrWxtOSu|wEEEdE l߾LRtttlZtʤ>}7ڭF*r8Fk<<<>BgKJJsoӓ+3fN}aJy…<)))44t׮]MekkYUU07!EfD%K,Yٳg׮];zŋkjjV^=|p'''}*>}zTTT^^Lc]Fsox~\"50gΜ P?neeEsqDmٌe˖iԒf^Vvݸqc```PPsjynMIJJҮ?c;v,](Eft[n߿_M;Qn1.ߘrD\#/i_ۭcX?cccB:5'_p7nTVV{>~ڴi.\xj*K<<<ϟl205{iJKKoݺk׮Kfgg+>>> &&Çr<33ȑ#Æ }tg~\Lu}! 88822ϟ裏VcV\٩S'Jlٲiiiϟ|rvvMheeerrg}FVTyyy .L0A 񯢢bҤIaaaW\yǏK??~~ƨ3KK˰0'Nrss5I>/m޼ysffkRRR7СCPPбc ;99Ǐ U>Y[nR4$$$$$D 0OsϞ=&&&}]EE݈Yoܙ{M0aÇoݺu@P__OD7oV&FqUVBÙ!...>>>taNϥѣG3g*͝;ԩS~Њ njp֮]Ej>>˽"##ONgyAn?o4̠rD"ɑd#G(Hgff޻wO(XXX^3hZX|}//wsssssKKKKIIquu111y }o4c㣨ffdwrߐܨr)BG\*Rjնjh=ZP["XP (G B lM$s7{n^{ 3 3jX,+V&d2m߾-???666HpFolj^\\<vf67hYAa^aYl6[,ŋgl6,W8 MERYY9o=WVVZ,i$12 f۫Fcƪ*=2 bggf6/L&ىB g4<0!8~.1N3a`p8  p梢-**2#a`p8  eYVZ3hyEQkmm8.ܹD$A <.1}[*u`^Dw?mz0''')))4P() w. Q볮 Uٰ |vvv0dB0@?DA\y9 qMMMN" p8KNNF䔔$wiBFyWJJR0B'''[0_;(/AU@tYBBBSx4M777#rSS hBp@P2J܌|KDQK4;;;%%E9NiazV*BhV|SAm%BD@.b(*''''''܉Dg#r[Kٖ`~9ΞmF/N:5HsD=| CP]]]եV3@PZ`l؊ n` 8  Aզ6lYYYzz:q!3@P E||>~(K,^1 FpQt:ĉ;;;f w:=P@L=a4닊ˏ;VXX}znnnGGrܕӧ{xܸq èjNU*ֵPBap櫮*a=p8PE.juRRNcY8waw-//iS*J0B0ƲV FdV#0FP 0D(b% 3T<`OKK<ϣ(R*6^[04 a? R q\fff0gffr'B0 "(.+)))<*fZ8*Aa(ia "M > Mk;  r9&0JpB0_pARaJF Lt;! r9&V.]]]}'UusD\9Jq  j"a+** C D0V^VVذeee( dPE*N8.ܹD-42 FK ">>R^^Rx7}@࿛IJl[[ۄ !fyԨQ**IE42 F'z>//d2$`iiiQQ^H4o`Ĭ<χ; \.ϟlv\+h#@#Fs4Q* [n?֭[J¬Z8kmmu\F(Fjmm}"ȡ)9GFHi(CQNKMMi JJJhNMMtXZBjii w-[[[;;;'O5:z^OHHl`~B#@pllKK˙3gAXl0"lٲ邂zzm~g[X2 C2}d2U9ssKa\ˈY`,bnX.^W(K.b  c#'RXPP`06l\ 6l0 BDQڌFFF3a„'NP5j(\}1 hd@#(RT B=z&Mfɓ';|^^^RRR\\J,2ttt?^ N7~ 'hd@#C@HY||FǏꫯ CbJfnNFjj !Ŧ zhJ%2 fb _i0 O>M/Ohd@#CIMJR/j:_oDZz (gYBFFFlllllZV*X ]gggsss~~QרQ0!F`P( ( Fc0x$ĈODB(0JRz% PZWWWSSSNNNRRJJJ8V>8xE42 F!x(R"Q#h< (f555edd޴4.\@tLLL0ҋhd@#Ch0 0 轾^Yt2eۛ!+++99n6F42'fr$-ƌO1cteZ@#@plfqlV(G+,,T*f8E 42 FBa&; OƏq\gggF42 FBa&/N#++i:++t*fDC#@pl_Q^l60>M퍤l6T#c#5JJJN <ϫ뮻.YH`۵Zm0kZ.B0G42 F3yn#ѣxC hd&#yth${0Q].F FpFSZ+^jDb:Kv#\.Q آ0E5"Qv\ϟ5kV;wJUG6]! G#G.!F>~x[[RկBHHHΦ(*ܹ,DyaVWW,|zE hd qMMM|d!/omm w kii w Q^_f N_ "WcM?m=;Xڃpl@9Hdff;(/( w"^aY`0/h,((PFphd@# $/e?fa PM #G#@pl` &# h>,w0L0G42 FLF(ݴiS[[[`#mڴ)66F8hd@# +V̟?>>|WX0!F[(^xСC6π111ӧO=eV c#.k;`txz^:HsI̞={l6@-mB+!sYn]EE@/я~tAw/g=x/^LYx^'l߾-4M/[l/;C={;sرw}Wrz˖-=_c}篿~iqO|ŋ7xbQTO>={'|RRYoo-WWWWSSs饗>3;v_Bo}I~s{ٽ{~tBSO=+ǿ;{=Pe,Yа|;vܹj !_=ZN5G}t9sBƎ+aq^~寽ڡC>ciԎew/Our 3eϝ;7. 'xꩧ=hZ_z饗^z);;{ܹW\qW\ܯu1~V\7n;::O}ᇄٳggddCSSO?MSVB233gϞ}9r䥗^Ν{![nMJJ"oO> {ܞ{+zk…geey睍oV߮5\+JS>ҖYf{xi755I ג{JSO=5k,BHNNΝwqtNWWyI&I|G;::֯_o6O>-m?}?BHnn͛ì[o2/۪VիW_s5Fx'?dBUW]5cƌsΉx׿e];SNL&)0$HV K|9}=Nf̘Bƌq9s:uj޽zާ:HsO!F!999_|ō7^Ow޹ۍFo۾#Yr%!eY!#GչѣGG}Գ_q܉'ǎ*otp21,$:nŊ2Vǎۼyoɲ >`E\gJkڻ;lB;ؗF*RSSU{0aw)'OJ _wABHsssssI Y… ;wt87nܸq#0^z/J78 ҄(Ó+Yĉ?0!$11qժUsObb<%}wV: ?~mu…g}>jii2yB@BhzvNo|ӑk}޷NAOa,lӤI&MtM7-^kϞ=dʕ=t5 7PZZZ[[KJ7iוl܃_ia0rsї2P4o#3 6l8z={<Ϙ1w|(L( 3hv/Z7زeɓ'A0ӟx??MYX,~h'/۪f…)ヱh0N^;}=~xz<$7?կ~>Nxr_t ;#9lj𓮵UzJMnݺq ԣuAiEo/ץ&:˗~)=7˲2gi;BȡCz=xrSb}oEQǏ8 ͣW_ wa=eMZlqqq?6m_r|7&N(=زeK<#KCILL|=C*#>Cڋa9Noa:PsOA 0я~$=XnlMM͎;!&//O辧,..h4,>S\EzUG}sdB?s̙3g~wEg?Y߽0v6ydB7ob,[l̙iQ]]}UW]uUV񊏏*!Nt_#|.:n/^ܴiS ?UW]U\\잒Ad;7ezꩧp-/Tm%y~r^}U/N|j|>s2vmUAqo<"/~)//WX{3g 2燶>ofWW(555?jjj!_&L { V4m4 !~>X^^.-#d)'XZZo߾իWWTT`v}o}RBBkF4qK,YpC=tӧ755 y7m5|BHGG}_ڵkʕ,6 >}YYY|Z|/HqSz \]nn2hnjz뭕wqGr-O>Z+O|AB7zS+W'3vB#}[ZjӦMMMMZΞ=Nt S_}կ(̄yyy\.4b&J6bF)((㏥Nv^z^x,ӟ.DI͛'M}T*,YC%zA^y{s#>>x߾}s|R|뭷VXP(o_po}ȁ92&MiӦ/3lAA_7zvR8ɒ%Kv5sLi|eYǎf͚[z3_Գuδi6l0yd{G̊RvCI,+`7oޮ]܇@﮹wS{[ouΜ93g\zm^~!wxVcǎ;{l*?;#M/1๥WvN^l||M!x}$t47~ZnO 2QFl67h4TqVUkvuvvN8soO9pW__ޞ:m6ܹsE=ڧY:ziǽknϟW(yyy^OLڮ<tN8l6yL6`̙EUVI+/??x_bbbLL̾}JKKi&MdٳvZi+2iF$>Yv7o2@a6>HZnݺ>i-ܲ}`0;=#7|skkkky>E>hѢppz>xÝ l0111FwFiӦ;!\z[fݻU !d2s=ʆ 222l0˗/_|y藜BZZZΟ??jԨ2p@Aa #III00YD'@H!"VȀPy+p.sbo40LU@ 3't8,˲NQD'P4Cъ&Jh4jzt!\Nf,6]Z/x>{V[[+=(JPhؘ?2QD3Qv{gggCClj.NDijn>WEݞZmw!8lii3fĉM2uX,#GlqEa j,kXh\`AdgdgIO:u F3vXRB/D-efsEEŴiӼ_Y`0̛7}F1666I 3dfӧ'L`2|}QQQQQQyyc cbbB'D=RYYl2a|}{||VZ}A# 3(dZ-ˊ+|zd2L۷ookkϏ1 \SSra:ujUU E/ lj^\\<vf67Q,YOp8ATb2 H7X,ŋgl6{N_z(8 r zhX*++=V橸b4Q,Kmm-EQW\qEs (n2 h4v{BEܹsa̙N'b0(vvv:o`dr84oV]]1ypIPD Y__2hw: чSN7.DfQp梢-**2#a!pzĉiӦ3&D]`aYV#V܌0%Ue . w: @4yn#xZZnya`ć&P^^~uĄ;HK eff#xff&qѽ/ qmmm˗/GUDQ].WRRR0'%%`bFO%%%NsyZ}uׅ1wsmQ"FDP 'Te03h@QML4DܹȝV䒬!"!D!Jy^xu:;V&M}@őI! I.\SSsȑBF|hZPQQ1~0 AZJ(*##aO>d֬YyyyN*Œ¬>?I "4}f@EtԶA.))i:55U(0 hRRRh>~x׆;)u-4cF*ƻ a P(|8======111°4R(śoyxo>|xԨQ}wf[wAdΝ@Q\w 3]|LcbdgccNϷ\QGY GIKKàEQ**11QѣǏ4i҄ yɓ';|^^^RRR\\8U҆#%$$0 T*7l0w|欬>wo`Y̌ɓGvό/Wߠ3E"\=u !_ؒёޓx! 2I#4ANӧ1J5^ooo?~W_}e0ҲSRR!MMMuuu%&&&===>>>..NHS{? EBhN8gϞɓ'8^WW_}qEԠV561xEUM2g;f4-fFlՌҥupYO=F.T]Д^AdFh4440 F$ t:Q=c0H7+JZ-uzF&4=B,¬ w`&$]yVeKQEBQBu<0%]I }$ I8j^ۣ"18{ϢUTf~z$)D1c &]}Gōf PE=a쾝LSwdAdF$|v'Pm#MTf cb|tu9T*g E@AdF0T\c@LYw2܉D+*ڌ8n*2ʳ(<s03ttm~΄>}$  oFa!kf;wqp'ZZ|cz\eL/<=i~we]=k N2ʤ; 2IC>Ad| aر~~D,!w} 뫫[Ý@sTBBh"tw"=W6y?/̾4s8LkƧ~Lz "4D&i*HZZZ^^q EmaF#Z]f8{u\pwN]݅J363vIQ$iL@` -j 3BHL`@jkEML&)=/sWf.s췫L;hh"oFfw\Ad|#|[4fGc|yw:rg63vogh4祌,QZLڡR)oSL "4D&i1J?~|JJQ# 3Wo)o/ٔvBj4KJLq!v:A3B \`Z78"4D&i a1c`FeDfN{K-kݴ'۝@Dtva)"LͤŒMLL0a9sU}%δLҐO! Dt)))YYYXE5R 3IJJ7w=wa6 #i"q$[9.e.'LJJVMKK5'$%%zLz "4D&i90z>99Y)FVa斗,?JJJN-<ϫ뮻.YHÝH3ѡ30Caf( @03fa P 30Caf( @)@xWUU566Z֮.eEYoJc0$h1 8Va:Vl=je\ A;fLbRNU%%ih\rr\Q`_$ BhĬ,^@Yf+--jiiGBb 2))#>>Q%D$=APqqZAGN4)ZW~l;۟`jU\{F3BP qg'H|>_5Ke)Mb8OVW9H/tvУ1Z=ϵ,?;ʨ}ƍo߾|dCFfA|w~ޗgN$3RZrOƘhq] J)U*ݸIfG#|+ghwtDf4%Ao.\X@;fWw9Դ=#X55eYqedtQJħQvpe)eMOw{1pr9'գs]#fj@K=B䧟~ظrR*yL@k,*γªL)˲TՒ(^R[J7;ݍF)TZRyf@SI)?ٳю㯭=;lcZ7ʊ{3@f˿+Lf3TTY5Yu!]umGڮnF-wmby._>UeѶmm ed@H3^nZ\\L~ @Kk~[^^N2fdFO}}CӖe`yXľwK'!d'緄rhض)?&WrLzF>aWZԷ7o;uTՆy׮]|FyeRN2ZPId.ƅڪτIvz~۶sAi<@cݣWVV:x22$Yׯ_mScOuE>^ v- EzB@T*6˽tWW=>R(mIFFŌ4#sssb1yL@+jƍddf )w.XhNgmVQ.Ezz 0AZ.\8U(4G{̙ӇjaFպ9Z޽˗fpl[av޽͈γg@J]@sb{l]6s^n 0&$N !TO^[ݏ[gBP2;;YWÇfplUa&#qFq |X~2^9\FFR RYOZ)%e@5 ũ)̀FMѫkH,;AEA666*̶}>Je9R LH鶃ګA` =j-H}NU*ߣ777_9ddpWT*ɃZQ[frPbFf4#IJrN.몉]m`h;qqy?:;Ϟ=?f֡"Pu i G 6kxb3%I͏D[<{.R=ڶ6YU Fp'aX@j OQ,=]ObKJyW='z. .ih#3mUuwwvJ=rMk#6Fj-уAhU*q\7㺮y繞c\8TksR(4Gwtftmf3JRcZQ[faa^AP| &a&Z6Y6g}KJae/ 211 sOşdfy9c֪.+˒NmkQi\&kaWm۶+L.|?wo}vXlF"xw2dd\KR ZWfR… Aޜ<;AvEкu!&scco 4;wУ?Dg&gdHIIDATlrBpnmGryXq7~Il'^"@q:yv-G\xu]7L/O, хBի6 Je6HPMкڰ0W\vڮ32g T|KPNјpm}^-Ww'sk}@ižމ+#bX\\TJ%ouaa&t?p7g$'K,*w+/v(B66FkħhjWx&wmpljԠG"3Cbv<™3g۾h-_~8UuXOSlUs8Y !#Gtz΂l9'%'~$^gfgNJP0襥+ @d||Z]fRwyG6df0@[\q{YO4u}wdr3 #8{\:襥_|Ex'Ȩf?hQۡm 3!D.{nܸ?3'8`)Ǐ{ڱEynߓv%'/> lmCCFG'}ޑ'Zv177weH;fB)rR9Z4# AF#kk&Fk?~{} o=[1F\WnmMٶ﷕ ܣ ?o֡!1 Bܹsɏhm^N8q֭7o/2Phޞݿ. b=yn8뱱K@FyΕJ[σ@G zjcF4EAR333SSSXfB\.w_ueee}}}߷edFH3 ˩Sqѣ?߬qa&qR]g/ri k@ݶ]Rj]gدCϟ=!<53BwwS4`_ǥ0 utt,-----mnn7ї' 1O|ۥUؾJCC.h JbMOOHTJ~@;^Yܹsi[ RFa)0QWy/r/8_ ;P;naӴZjIZ/"Ƿ$]k !vhrrR!ɓ'nQ/_DjmcS ef2 3H RFa)0Q@( ef2 3H RFa)0Q@( ef2 3H RFa)0Q@( ef2 3H RFa)0Q@( ef2 3H RFa)0Q@( ef2 3H RFa)0Q@( ef2 3H RFa)0Q@( ef2 3H RFa)0Q@( ef2 3H Rv?a'IENDB`superqt-0.6.8/docs/images/demo_darwin11.png000066400000000000000000001323371463340017400206000ustar00rootroot00000000000000PNG  IHDRXJ pHYs   IDATxyXTwaAVA0 QBĐ 5LM'SJb]q+KS43Qh&E +Ⱦo̙o 0>Ù3}9y7NEE!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!9ܹsyl݀t?bx֬Y{dnSQQa:t̨(U{رcݺu|DrryBBÇ޽[RRRlmmܦO/D^x޽k׮]ruÇ4BCKK˒%K>%88ڵkQFauu!d-z7W^o!Bžy=QhۻwyNNN[1#G,Ylٚ5kYYvvŋ6l:t -ZTWWn9~ѣG8`oo\౒C3fft{{. fEEE4%캂Bhhh@УG>gCT*-//W(OB!J|>_"''j ݻ7}Ғ[PPR-[p}|1mڴBIr޽{ &DGG066vҥ*kkk{Ν似k̘1iii:'okȑ]6t_|! c Euu5áOKKFp64h䇺:0 SWWgnnNOB(//wqq1du8`~GAAA[RSS/^\\\|iӦ1@U{,455-^#GԩSmmm'Nr<22駟vuu;v8*V^mkkSUU5cƌ~O>]Ǒ=JDVVV;СCcǎ)JwׯLMMccc<(}÷Fsah b͞O?q qF=x`rrrHHH~{z/IMM7nѣ 2eʔ>~,Zĉ :::j/ ]_UUU˖-suu5jѣ,XPP؟YyDΆDү_~qss۷/}C gfwf;wgϞUx…iӦݾ}[}cKKˬYbccxYBRLHH?~|qq10vBB`F&iD.3 שqEFF޿XVV? GkjjzדR󐝝Mnk7771cBFOOOXLIHHh7,,LZa3s_ݻ_۷oo W޴iz{TUUնmVZ]VON1HKK7o^FF/\0::Z})S_2~#JsV ?aӧOkyyy ôB?#G˛avn+ݸq6ۗXYY9cƌZ@jժ~^jϯ9s&{R,YB?ロ4iRaaaM"ᄏ~:!$88￿|}^|EBȽ{vޭMMMJJʈ#6o|{ud#TFyyy읺@ '兆~… xK,?{~hmmN !v:p!gٳgOZZ޽{yBwܩOΣ311ٶmۥK.]A[~}SSݧy ø8qʕ+6mr6m"L<9==}Ϟ=%k׮MOOOOO #x -\.wʔ)mU=!d͚5.\ >>0aBTTWf[9|||tPZZn/ݻWPPr{g၁cǎԄȚBȇ~~L>>>#F8qFbyyڵk !^^^qqq=z ;^u.\Z;OΣ1112|AEEEtttee۷L9wm[fM@@!qJ211HRD~Xvvv?bĉϟ?_[[>b;!!X}nn.ͱqqq㫻/+$''wޙ辌l#""bʔ)tQ#F7ݓٳi,Ƞ[\Bl۶2_|smڴĉ tʍ,W+VhO`C {OvȜz]||I "###**W_~;ІF?C1bW\}ӦM4Qt::ƫyr]@@o'OU\p'?ޞtL&SHAamK/W[lQrwޭ~cŬ\ٳ׬Y6d2jjj4B***ڍ)UCP("@9!C ӧOKd!鰷Vw8\r%..رcJ믿ڹsy;M>}ʹ732221>eO61rSm0S׷o߹s۷h׮]o6W(+VxtgȐ!Gdك RavVoCCNqppسg3<æ7n۶MuѣGCCCzzzss3T322&Æ 0aL&!BgKR.R...ڶmd2[효m<a%G]VVƖ9s]YYy…wyg߾}څ;JuoI θgZZ`(?8$$$<<\}B FQFQ.e2g}FRH=eO\SR-Z=K0#,Y:;tz+VB/_g]t̙3Os玟ݓ}'9sfO=ĉ8`ii /h65,^899<;;{ƍtJwݞ}Y>OٳgOtttQQQvv]^~eE:fڵk/^^SS᡾!dѢE ׏3v؄wJ̨ z>gYhժUO?]n]ZZZffW_}5|Q=mqԩScbb򲲲n:o޼;w:O>9tЬYbbbrrrJJJ~Lj?7l@?陑q9s椤455I҄O?bmmM!!ٙ>8y_}FCׁCzxx<]]],X@)((>}#GrrrN:5|,bY3J"Zjʕ ì]m(prr0aBCC֭[nݪ{ 0 w=s ې2jԨ>L2zI&?~(,,O.v"lˎ2dH||)S***4v kkM4˗'OJ%b x<jȑ[ee%{yՏ6}cǎS7lp͋/fdd~tpp>|8ZYٳgB}||4999EFFC:ƻᄏrJCWޞ0`PΆ89]gΚ5kÆ 4hPKK˅ ]FJJJrqq1t5;0tCHP^^ޅ RSSry`` ; PΆ89]gb=s̙3gا:du.cֹsVXA;99iOcjjyS?߷4hϘ1cڿ2󳴴|7$BWF0rIgCN6]>          JRd啕cƌ u(LvY+++P8!mh1T0Bhhhzkhhæ2BP( uuurJCCB`-k`࿩L.KVVVr@++𜜜\f3h@v_555uКj6! IIT2 SWWW__?r-bȑuuu (Jd3V!tS/RXXX8jԨ(hԨQ4 6Ђ`}TҒ=nܸ+hܸqYYYl6ufMGcYYY@@@WXVVP(J%AB0hsT*~9ۣW^555MMMh4І`7b: A+@gGlnn)T 577|JpS.R\PBjkk\.˥_RwR&} s IDAT|Db? pV;-f f---CgCmii`)򮮏qY3^&t{{|Ly!t;JPd2ҢP(h >]{hqYcc["B>^%&BDɨZdC,_EYYzwsnHR =zD"qqqxypV;h rj1Zr"*%!p8]_}uu5f=0Luu%7KZm;]._LuMM S [aXe-,, ])00 fO0#?BQp733w^4l}q`p}wm(m1 pR$3%Q*UJe]}{316z.q^+yǙU#8D35*B( QU?UTTС5J%v%H ]#0< f,ӛ7]sMOO ;L$bLBe2X,Df7P(ӧ/r# i0C6`!tStSSSX\Y3VVVD"SSS>i?4;bD"~;;;#!t_\.I$SNu]ANH$t+ ;$nJ"%%+ JII077Gs@[/x|QYQQqa+++[[=z 7th0#v3:,--2n8PPGdIIIĄΏ`w!D p8@ U*>}xK.q8{{{333D"|>{ph STRivvٳgy<=Hihh(---***//gQ"B>O'GC!c5x<>/ %L&dJr|H$rss bX V2DTNf?hCJEgbX"( R0Jbݙ&4:>"@ @+'F}&1D2@06-`t왎=<fV0˃F- Pztۑ 8/O8}ЍlBC<3c T*U*0 B*鋨e6v]:r @z0JElnnngggkk+H!RP,BPriH !v\RRR]]=dȐqi/,-- @,**Ȱ\.w K!2LV^^^YY9fP΃dgZYYBy@2mc"Pņ`cSؘ=jԨaÆuhB044… 455e{6[#P}W kdiuhHIhfƀ2\^___PPK/=g'''333BfQYx'1vvgY BR}bt*/-vOB(˫BCC;ࡡB$n.ڢ<+ 0lD:l˥;fx<ݪ N0L]]]}}Q:#G^xٓ6ed0LKKK~~~ccc]\\D"QAejj,ئZT} [zэ5*))I an^oyyy!!!lWgDJ̙3<U􉦲.JeԸq㲲5A7+@mmm]]]hhh2u\.7$$C0x"7_tueee Χgķ*ڃgOÆ ojjSA Q4CN\WW'S@ knnfFұ EyyyWEc\.rܸjH4 :T:hqvv و`DRT2,??^d2Eve< ƘF8N~~-,,4qebDSSE[ZZy"w-~]:33' fvvv/[C p\:Kor>K3'RtiVh0svvNJJgIII4\=C0x"Pfff/̸'.V$BkkkDBP0!r"gϞ**11KNJS EϞ=E"ň0+#JDx`F8P(x^^^ax[n)}ϖiK0knnņH& ¶dO?/ݒw^?r<::ZR9ӳ* Z233w3w\:gQg?_.g͚ՉNp******NpxbM}<O(:::bHD3KΘYNN7|RPP H_hҲk݋vڕ+W*++]]]<|^{M#^eɒ%l0[tk!O=ԨQtаj*B`vo>MMM.!$,,kޯn:BȌ3$I'{577G0xл⧞zJEh S*|>6I$sssTP߿T%xaWS0..sθHϧ9m4 fJrݺu;wT,R[nݺusnڴIc#GuLlٲ5kta"))iѢEuuu얜Ǐ=z:^VDhC?3fft{{fݻw|.- ǫUUUoq\z6pG2`fڃO3a Sv;L cF߃eLR?#sȐ!vvv)))iii.///66N_ ݻ7}Ғ[PPR-[<=66vҥ@___[[;w$''_cƌIKK6W_}7^^^#GcōѣGϟ?`wnvr?pCwCs EVH$}'chʸ\}^z%UV={v֬Y2ܹsvz뭷GoIMM]xqqqM6bĈ.~]aO?TR ;wN4}J*X~裏S5f̘HwYYY{ر>xJ277ƍ2=llltRJKK322sLRy JJJ!cǎUOeDe˖={BbccS\hi)^a rڵkn~ƍ\r޽[nr7ndffwamUss[nܸҢ@&Xrbq`̒'O^ti#!55uܸq...G6l˔)SoDlZZZ.]7j('' TTThޤϟ':  ,XѱV&􅡡+|ʕSwAAAnnn'N|UUU˖-suu5jѣ,X]TJ,G'b1~ -AKdlWvоؐ~ndׯ__xݘ͛7/##M .V/+!!7޸w 03W޴iz[UUնm茈,OOOf@BBBMaaa85ra4?{믿~]vKqq/Ё Ӳ^dTlTTԔ)S/LFХRs炂RSSuGAoĔ ">>W?3!= zЃ?aӧOkTtR;w^~gϪop´in߾QP̟?ȑ#0L|||XXX;[@h0 }_Shrrr>}B!@2`Fq8ooor Էo_O>">;wܯB6nX]]q8m۶]tҥKshoґ`oɓ'ϝ;'端Үخ]8@ygٓwgyr;w{ BH^^^hh~/e; .d-Y$>>ٳ~ݻTMMMJJʈ#6o|{Bׯ_0ˉ' \i&.ظiӦG_:TUU㏓&M:zk~Cnn.^kxaÆBO8A 333#kTnrSLi3f̨V駟~UVڙ3gk֬p!d۷oOKKۿXXXIIvpNZ׭R$y̻8n&Cn|>W^PT4!AVVV?|v0۠ALLL֬YR^x☘C??۷o<}v(u֙3g=GjT^^vZBW\\}cǎ}nݺ~ }+((Xr{3|cvlȚBȇ~Ȟ%#FL8QwV0aBTTzΝ;GOŚ5kh?GG (D#J;w9/ 'NܳgN?rȈ#d2ŋ,Y:88BLMM !"HX,8qbttkkk-,,ا!>ޑyyy۷Ϙ1nӧODDD^^޾}Bmlttt⋯Jrrr';$f_/cD@'2: 5Х }ݻw)SUG1n8ې!CN8q )FAPׯ_8r@@ʨɓ'999+W!cƌaSrJ^rڴiz577Ki'vvv&M "###**W_~;c=3C՘rĈ-z S+Vt;/\ĉǏTFYx/eǏuJ ${3Rڕ*:,00PKz>>> ի-[h*cݻ5B2|P(d۷otɓ<<<ӝP(+K?J9vV \\\|EmP&ф_SSq !t6 h={ЎPo4377onnNII RRR,,,\E*BzT*uIٳvzE`u%''z[4JgdhuD-9s*Tbaa1g7n$$$|׍7o\paeeeѹ+<==8pWX$ioiiw͝;W¨lL+ZNdff}h,)5p8ӧOTH1N0AG͛7k׮M:}zM[%h8p ]W,[YY_xqԨQXą ݻX,xtN<> fMMM999mVRRB+uׯgg&ZlՐ}jʔ)Okkk cggiP[mSobbedd 2O> 9sfHHHUUUzzzKKI_N0rʕcǎeff*:**jΝ:f j ;ؖ0JKR.qGNk۶m35Qy<0lG{ҥK.KMMݿYxqpp0쩍={XXX;볆,??4ڛ6f:88)ˋ++V{NNN...n41f=z9>[***~~YZZJ$2.el7Q7k׮(ss;w~o;w[ &<bǎ8(BȯJ裏TFn;駟&$%%ie˖VwV(W԰n:///ooV fghb`H?gnn>~?˖-#(JN~RZ'%%Ec箮D6ތԩSutzV:iCX O4fVVVLKK;yd֞d'OLKK8pV`u-˄s+?}FIvZDDv6۽{w\\!w>5sNhLbLg>1R_cbbԷ+ٳg34>XreUUJZhQ `3$MbbcNJscǎ%&&ظah@2`Fٸq#;///$$yyy2Lcysss,Yg;tg/88RSSoUTT$''O6~z=:22"J?裀!C,_<77w֬Y;/_rƍSeeemݺu޼yw)))hB"111cǎMHH{T*̌ 7s111iWZE'O׭[W_͟?_}S*gFFŋ̙$J>SB5]:|'ڇ5kVLLLNNNIIɏ?B| }:;;+W_9)sssr߾}CjOmŊt˗g.]*((8sLxxӧܹGtuu]`!`G9uN!D"cvv#Gbcc:{=fUU\.UU׳n;u666ȑ#َfff"H c9ccFJLL1cFnnnnn0ut,ΫA"Zjʕ ì]6A+111Ǐ?~8I9s洴l۶9iff|y>|Xc>~?5D[>|'nBD"dߙeQ%*%!p'\=BX,ӯ*t_#l1#'%%-_\}\ :F{ٳgBiHJJ `gN_}mc>zj…W^xѣGSSSoݺmR_pvءf|~xx3gF}ffϞ>(u푑K9ZM{ !$888**wޟ9{믾+jYH$JLL\z#!aJennL>ɓ'ky{{کLG4z۷ߟDGo I&U:MLittrrӧOg?}/&$$ٵvkx"̬W^3{#g1իH$b_TԬjii.((177wrr߿ihsKd999<o߾ꩦcܹSSSrO3ghkIBB{b 333+++ܼtjJU\\\^^neem333svvؗGIIIfffii)ի rTcAׇvrrR_I7 f~͛oߖd>>>~~~^^^菝oA`255} ] 0Ns9]>      o T*-//W(Z.r9!DR)JRY]]M6665~b8*CpVՠ /6?g0 /DBWUUx

8Md2I_χ!M>|2|dYŁZFA@!02fQ ʰtCe|MMM]]݅^h2Ju֥gffL&4P\:3ҘEQ˵{¢`sTTTTXXk.%(J/=Bp!f ژ|GCI0==}ܹGmll|J"2!03RF=|Ƣp%^TTР4Ъ\1t6覊NyĉbĉNSEIФ;\tB."IRKKKEEŤI"ѤI***ZZZh{` @B!̠s1z80k֬e4k֬{<I  V 0QE>UWWO<99N2ZI.d؂ :Aﲷ644t[ vq p!fp]ӧkӫB  0tQ5t:F6Fx@XB-mQy1ch1c^/sa  0(\<_^^e a]@p$999YH>#_1pa >8e"KDe Ӵ?%%f,˲,Deјd0o1(n AhhhPT w40a?Q D,>w"KDص977~ fۉА aZ4%20Nd'YgE|22298Y xtϷh ,n3Dz,mD,6dYll`^ @dRr4.dFV8N0A[Ѓ ZOPqcf4_itA95 934'G:Q@60ZMMM7s(rmm:Bg ufʗ wog9fՊ=`0|> Rf˲\YY9d p$aaXrN:X+++f3]rP5jJhZXHf͸b DQ4{B Zkwn,cʅܼwohhh1bfÅ 8tMih`0p\v7p!jOR,~UVVj{eee~~h9e\M:Ų}v-ݾ}b' [?ڞ3ym2ybf2@lA`QALիWk5krrrL&mϡI \1tOLLX,uuuήl6'&& .b~;rl6v ^n+ßp t:#&GteYV())\F%%%j-"@/ 1&~"BP:מaÆHdaÆde;\xLEAvB._#Xev@[Z,͛7O4)YlڴIŬ,qqh.d`O>R~qM9Nee5e,}^?5 tF(2 ){&&&9s%bfw"2ZBQ^oyyyKK fsX߿?111??l6+}B`]QtIIItwy(##i~LKKZhD.dh't:fΜIfp8I֮]B BszNV)--%̚5d2V" 6,)))!!.@DBF(IRSSSsssQQQDsdYv̙~-!$55~%"6 0n(sTF#0Fl6777˲Ѓ֚A!UUm5ԛȲB CffjruMݪ# ;XM\ @op!C|S~a3t_ccѣG/PLOO;wѣG}>AlcY5ͩ,9#DARSSiAPF=Er•xQQі-[AHKK#ahdN3aL&ʕ+9>|8j\ZZZN8q!A@p@@QEt677O4)YL8qǥ&>Alkhq\BB*++EINLWQ+l6%MF}ǙL<Œ`6 = b2KN^ ݤIVZe4m6ЏQId0hjMNN!.잚~"3Anˠg@Q=pܹs#ѬYVX1jԨLQ,KRd~$I=RRR(B1`P 0Atcuuɓ#)S9ҷo_=NXD?;\N ~(fKC`[[[B\1}illӧVt݆OWMZnQ {%j8}*eH@`1N xh ٢1T8tf&)''gdf͚Ʉ "^;Ă Le2A@Cph ]p$&&Z,HgWWWg6z-|@`Fh54[znW1 "H[@ l*@Z$r8q #= = l*4KNNNIIٰaC$2ڰaCJJJrr2@qObkkkMM !$33jF8?T~8[4ޑ?cn`Xӫ6oJǒ+%#˲$>T2fLpEGKEcTx}}=0: )` j0oq)͌F#0Fl6777˲Qeų)C-Akk+Ԥ|444B CffjrcVQת,?0 Am`f0< O?tU4DQDVeYwǨnXj,˒$(A%٢P*\lIWeYc]J-Ajjj "]8Zx1!$===ez=hߚh6y<&F$~+PbhHsqh, OMMMKKgw"ra`5tjm]`RuTSXVx}Zأ*dPjx =3#.|  ݒ.y#K!Z)h,v+w\.p#BCph 3 RYY*Ecpmk4:9b0pP8-C@/Lv&eYQy(,˚L&.g@Cph 3wWZWZo4T8gP 0e9Z۶m|MFFj8+D *Ecpm*df955UbI:RARSSf3V!T8gP ] mЛt~ʕ+96lXAA 0Z!= l*zfE0 q nb0,KBBl6 ٢1T8*t8I&j|>YIlx@o$;.oCCph 3=bY<88d2)RFp@`St}@%+ p7@`whvh ٢1T81D3(C`e @!02fQ D3(C`e @!02fQ D3(C`e @!02fQ D!֚A!MMM,˲,0 !DeI$Ijhh  LuVU ժ,?0 A z/%~ 555.O|@3(C`Wff:.3转VkAA}\__ql(Q2uV-1 Ԫ,ˢ(2z @PFG#o9s1Yo˲x/eK9nPi/ͥgVXM7e+--ݵkÇfg}9\~GD?뮻\zO~_}?NW~G!OO>Ytirrn[n7ވ\.w}={!C 4iR2x9n.=cٖ,YRVVzoh7ry%%%3>o˖-[lYtO<1|+++_uBȕW^9l0>:S =䓍'OK#K[Y|qwD,12L?-ܲx⫮*###%-0z?|/ J-[`|rL:W_MOOWiӦ[oaٲeo6#D8˲?y/$IYYY'OhFC}g#6⊧~z .TmUm۶=_bquuu4+$I'N?.b/>uΝ;O<l.>psС}y@^r۷w^뫪v555wv\AVkB͛u_zV EKKˮ]x,ӯY.Ϸo߾Q_ܼ{@a޽OKdzwފ%Щ?DAA !VW^IYjU[`4\ /z뭷zY|̙CzggggggX1wߥ]fMg:t93k֬ѣG:?>|̙#F9r;#IR$Snggg/YD|}}I&M:5??gJg׮]W_}uN:mڴ̛7oǎ |뭷~qƍ9?/p>g=tЋ.p̘1K, Km۶2}t7pWꫳ͛~2b<مݚ5kfΜ9p3f 2dȑ|I?c?쳇|F9]۸qY Nzy\uUǎ{[x9SXXxE 6lذav[}}e?pvvȑ#[ZZn61mڴ#F;v˖-_ӦM5jԈ#~鎥 ?skBȱch>ggg555uLr2XT5Y.\x9=zĉ :x.*,,߿?!/f -0SE\lYǿ&''Ϟ={ٹAn*ZXX(ʕ+;rJQ^^h瞣 -[ ٳgwrȑ+ү_nӦM\sÇgz^v<?_ԩgo;bYV p VQ5+9ywQrtzΝ;d,Xo~_+ԩSs /@ TW]uw}~ (TB~sE|~)رLNCtE'sEU[p%KOiժUW^y_xxH`UEu)֭2ȉL?{)--+ؾ}+e۷o={6!k%ڵK 6BΝk6ϔ%KBƎ+.]tرz_V^yر q]wݵru?ܟM6BFϗ.[l'O]wuMMMF_>C8h47ywg̘AyG;8pĉO?t„ իW#37B/|smݺu֭'xvܹ;ed2w}{챜<(Xb׮]-bYeѢE+|ӧ/_|ǎ%\B:l_|ł ֮]~n_^YYy5o۶駟;_PG_ ۷wy\zX2{lFxgXꪫΔ{P'sʛo9uwygϞ=Ǐ':tHFP.rѮ? x[cر=أ>JY|333Ǎ7v9s_lZ ݮvW?S,\RRRRB{0h֩?τÇIIIA]tE_|Cx L&Bŋ鸻?sMaȑǏ7o^:K.%}tڠA.n0ŋB뮻>y-++{~ߩߒ҆2x/'.[{x#\p]t u2{?wߥurssIR__x.KqYggx1#u]/.\xy]z饴 ^Pet˔)Sv$uM79sN\x~QMR___UU<{Xn555 'OV2+ 1bă>x9 !E-X`Ŋ|򉲗EVXkѨ2L_~9!d40m4Wۦ|pܠ..+NxWf c.o O`_RٞDWAՔNfzfnjo;v|G[l9p(,oݺ p뮻nƍt4#4kllS篹.ި(͝ݻw=ж,_F \:-[jjPx1Fcccǵw%St!'O޽{wmm\pWΚ5kرtXZ $Isu[:b1!~Q!vYB}tV2EP$?d2khس~:qw=:KJ[ͯlFٳgW_i555YYY3~۷o믿q܄ sv5I;jćDfTp8p`Ƿ+]KKK{7oKKKKKK !W]u 7w^}/2]&۔BEG~ IOŋG3q^zґQ'sE{W{gna:k!)͛7o<!dÆ '^z駟[v;aޥ޹sѣsrr-@_=>@zGsμNY,>`Æ W_}U__/M6moB%%%1 #r^X@Х !t61tfΜk׮>?޻w$I555?^}՗_~YYbѢE< }l2rssSSSSRRh'px~ gyfvIwzJ'sO o>z=0Y 9c`F@?h,3YJY4GyW_ 0뮻ߧ z]!FZv`ϻv 68Q{j8pBȈ#֬YSUUر̯lÇ7 ϥS,N6mڴi,:t?~7ŋw\]`0ןEnJ1Q[[q!o }\^xҷoW^yeرʨŧz X~ t!/((7n\ii)HowҤI]'sxO V(WMQ@(>eYe"f$I xLh&1b~N_@ v:I輠+W~<ϛsv]:QnGY,:V7|۩Lo'%Buc0gu[wx:oW(B踓 ]/޳b*V[ѣ>lLNN3gG}_"Iܸi&sw?^=l֭BHiRzfjwS%T]U ֵ`Ȳ,Iݫy-Zd%{~wɾ>Se$eƙ(ڵk'L0a„ vᷳSiiiug>:ȑeoґT_}{ァ~^ni„ ʚ]8z̙3gΜI^*CWYT;w&40 /꺺m۶7=; ,wqǒ%Kv:߳gϿ+y퉇 4eeerss^xa߾}W_}/~ &|/bkkfS~衇V^n֬YIIIׯ_dzI[׏?~zǏ?x~q3B߿zSN{ǎ6mZ^^Ç{9͛7L___Oǎʲ<{Ȑ!Clr1ө>;sѢEFO^zH#SN]x}ںp… *ynS:t( oGyϗeyڵtO : .^y啬.iݺuO>d{%K_|_gϞk1cJ'sN lUw]]t5HɤL#z<'O644=z֬Y_o6=mʝ;wv²,˲FM/?vآEx7|3;;[y&==}ĉ[l+W^yA{?-++ / $y͛7ܹS=o߾>BT޻K].цT IDAToΘ1㷿m9,?yG}Gi8A:i$e).,ٳG$:u_'۷ojjzɰ#@7|}m6۽{뭷]~׿;v\q)78-˓O>qMsJ}}HHH(**/u8/.lٲek?cq dЩTUw]]40  :|("555uuu^xaG$77777u֥gffL&SB!<Ha裏!$%%͜9sڵwzWnve۫8JꪫKO}mK{zc0wܵkN:Uӧ PzWr5 (ma e˖]{t}%\gѭ9r|pׯŋz-WAyWu嗯]vҤIt!AX0`?'tvYqF,˾?KO2?}wqtgӂ v޽y?7nvq _5,Zzu;鄲T<^T.m6ۑ#G f@GCǍWQQю;C [>0SGe .+mKe˖Eו|Gt!|0%@M:uΜ9K,YtisF ,x嗽^>[\\|䔕}gnr7"*뙲M6mܸn_6eDe]ضmێ;L&O<@p>DQt:]'Nܼy3qʐx <tfBw=~㏯YO>?n?׿uyyԻ,p»;ŋiׯ~=-IDw;ԋ4WoiitM4iժUFfѮY3rN>K/vq 8/Ҏ;]L9rq0>nܸCW\]?~ĉhF2{sF.YfXbԨQ q< Z0 \1etƊFzU#GۗN6#q7s"so4rhmmmhh`>}466neLfTUUM>]O^UU%$IK 0ItHq:FQLFx<4wm2 $y1ch1c^/3B`dYy<77W|sssyG`A$Dȴb,˂ <}^W^tW;+%Yf)titFW &5SSS:--MEf:1V8MAc ~:bFٴf &֭n>kݎR18~}}mnjȲ,bc@ 8`0Feh!6C`$I>ߌ'Oj/D2LE[^^2x[ . f3HiD`2ͦ}6=f yNl̙,fp8$]6///%%8 2@73ʚNgQQQأ25egΜԤLcbXsss/`tǏ;v6wyn;5"0U jommm(3YyyE]e3f(//G`zD>몪*": tEFn7qZq|=*Ϸ>77W|#IJV.Ȓ@1 V5%Z>ph|fYts*qŲ'[ D|QIvf1eYد_J-󭬬ϧj/A"8$p9$aXX,۷o2۷[,Q`0[QQ[lX,<ϛL& 2ybTZPP@EMsDQӧOT˨ N8;tРA4=O$ $Lþ,I":)F ՚gBSJ[p̞=;B8>No|8j,ZZZN8q!A@˳N[z̚[_˵Fs Fo2/@=4$e%IŶ?L|>$oy-ts8:ID'C"6lYg7;0 CX,UUU7o4iRشi(YYY%2=nwUUUYYA I9}fek2@9h/q&)//b$$$fg]aX>ta1p`ִiìVsDžhwʒ$1ғf4Zy$ˆt'O":)~I1JD={>|ׯ_YPthLLL555s K6n'&&} ]8N$0dZ4$Sר_Hyv_4 \%6KJJk;EEE=N/8p`ZZjET eitpC2L$c *IRJJR7?h0{κxZ[==fY37cƈsNaYidifꡌ{R8EeYc^YLuҘI1NDX{655?BҊftRBȬY{yaÒ u@C0_$d2u:sl6w~o%SL?_.@ `fGDZ,ѐLUtzO$/^ tSSC,[ǧDtR $bD1ǏB&N.PFlnnn...ey„ 999&RUUuVarssm6(p:mĵ>'n^2%*c1mcF!9fq(x\))I~9MDtR $btȉ'?|38 _:9zuqѷo줤$B:uTEEEmm(v=//n`2Rx(@|f+Wn(--v)bYbY%)R:I# aT"0-l1b'C?KII =MJq`3<_SS9r?S& 0d2Y,H{h2ma`GDhN0JKg)*0rgh>J;m?'O&%YC)q$bD7nc }#:PY`0X,*rL te(dڋ߀e>ys)HL4.m;XLpI.I]}fO'" xTmNN:XoI=C?$z>Sq3M3KB$IaCaڇ([M_]]10IKZ?ID'&JfV\fYv)bCrrB>m+12d Cd0>Q8ama$IjH;D՝h hӇ=C?J:N%11 FU`WE1tB(i7rf$1 eI0M'$eIL%I%IieQTu rըOs\'I$&bx<XWYK 3@YFt mLYDXQU, mjfM#X'O":)F W:W,3^+3+*!H烱l‰cuvzư,q !xLPSb0u<x^^o[j))I6O{a[{޶،gtp#}R54כHDDtR@IMM =EqM>fC.@lh5*_ۦq',˲ 2EQ|[f:6;-<;=B=+֭NZ:)FӉ:I"-n=5Eq lt:}.@ p4+]+_ 88N9Վ҄a 8~{`A?<³>}222B,v<ÕN<%bD+fZCbQ\f#l9RĆm6cEI%kH;XVYDn.k  eay <Nr"bI.IbwWTCuI$s@0ZdW\UߩS׍{n__0nWs>!A IХ1Bo/݁>Иf4f8dA BũʊrPJ!9v~3Ќ 41h@33Ќ 41h@33Ќ 41 豺z֭ZV*JY)|oPP2NRWj[^޿V-n/LO Hm(0C? Ǫŭıȴ  bX('&&ZƬV}ǷnZZZ򭽞Ba9988<UIDAT{LX7EQ;(RSSC.XY..޹sB?~bf<4TھGK8N>T*B߯3HC) !ݻwڵSN9ssT ~~G;^KR%artSR:f!EmGQYTǏr337o.߽T=,-r9kS/Nݣs˲ e`ŐC AVWWWWW?;w/`ĉ͛կjiUܜ #grՔ~+\8"ˊM93tdW777+{GGQTO:~*&@\0 _~ƍK.:uMF7fq7[G&h%ȉS'ON[mm|7;)tp]88RB!7??x^9˲.]|t{R0 {{a͐4>m tsc/˛7ogݐ4 mON244j۶m'emVs VzSJvSKj8۶==;~ q[ gΜhygmѨiO>dmmRc:]6fq']0tnNFH2)mۖI,JqSJs(=<)I)L@۲!'~3PY,ў./{M-s,.. !^u1z[^x3j iWf7YBn !D+4WuapЮVeslڕwv]wyymxx!i0?7h٘ݺuիP 9kOc``ĉ)N+:,b߾$@2I+3Im)!5<nTZ^hnnG//rr``vi?1H{O&&&Z \]ؘAo!ss0mˉӖ$i1)PZm$ElyN38X|sG/.~W}bv8kQօk׮Sc$02r2]? e!v;acqvefUB!b)BZZLԱc=GaקN4A<ϻqƹsZCu[cVծ]&9>8N\>4nvr>%YOzzqRn! 8(B٣_%8VRpΟi`qӳ{Аj[/BoY֘ݹsggDCN;nY"$)Xl.ָ)%Y(@Eq")D鵍-4fA)ѿ'#Py!Y]՘-//gbؐ4v$)?oVu. #q,,+jzRRJJhyIEQcT2(rس,_jy8{zZJǞ!GECH<}Z;uUce朌,,JA!m߷hNR(,ǑRE7DQwS*"EJ9g?8{jqUc˭oq1{qҎ>1BOOAor[KKڛXq,l;BDQޅ1ũY5 D7 ì02RG/--oleiWgY[[k=8uUcVVwWL"qZM%F@{VGqazJe7p* 0TalW(T*/<rpnWtjjڞ΋ I#ĶxLIu.kO "[)yF6/ Z+sG?}R[{@cG֑n̞/2{ĶˬβmKJDz"S韴bVo+ߧD;=ukjK#xbX@I(vOY7ؖ Ϳ;Iߖ`(uTƬT*n*' )lŦTdY*,,˲8VJ ! ~}? ol׾~u3]R{隣aQ(ZuUcV,̐sy%Ξ"8V\,K)Ѭ۬SD8[ږ%( z=Qϥ]Kk;_j>0P8=zpd9dK:TW5fRHCNms@A0"Ao(RJIN X){Wwݤ{^=JTɥ\.7g]],zK[ jFFFn߾yjד-}RJ3g8y =kTrI1LJ1yޱc4fX^NM mcccŒ9i& GB81zj.uiA]t)mTJٶm[meV* DOʸz=[m*6sdv]>s9r\mK1>>z4@'Ƭhhɓ'/|!ssl5VN 8)R)y,+yGv)OJnk1Ŝz{ZvT++NjG}j!QWbv3!j+L=$f}qT$ecS2nqspQ%mJ^b9y˭^ϲח;=ܹ٭k?s8qbǯnkΞ='A'cӈc?(adH]ڶwj.܋-XKj뺮[Vr{P,w-Do8cq-AZ j1su[cV(Ο?͆ %RuL')ζCOQ iדOzc׍bnl|;}u3CGo 43ِC!oС1B,,,|;Bǝ(mGg?ivQD !(J8)l[J.;ŭ(NVݽ~dP\c&\26H>i=&suac._o!'cCe)UJ܂pV(Yk[)Pa,2#v<+?Ab)ݣӟI\1??/l=@L1==}wy!5A[ C^26nDv)L|JnqJVr/titL9؍%/NNh~o cy~ӧO'}e٘ !.\z޽}G0t=He['$s1^gf^oؽz> 9mߣ/\x'? 5@ 2>>>;;z@̲|;B-!'cCeǏ=~ZMTmL|jq>wGm?A{ g?K?9ōq7|_Y2eYccCKKO?N' jgvqAL_z$}"WqGBwy?#4sҘZ ͍² .?~իz}ڒ AZ02rq c"Rav>%G+?2@e+e=:ϽMrH$ϟ={vttcvVX[>zɓ{ӿr],199y>O? PthfR4:::11S˫8>%ۖGEV{q2vjY2i68))"̋[kiiIwT*ŕu]׭j7bHMЖ MR,.>XZZT*~%8B8BށַıJ[( z{{bxĉ!G3H){rPh}+طzya²"D=w<ݩi0 !UhKw^ZV*TL|T*%opgjjJ[>+f[Jgy=\ba%⁼iiR,֒Ky+J%{Tm]ax/V{.(9A ޞsǏTFqr\zl``q`\.qR8NE$Wu-2$VNesp 41h@33Ќ 41h@33Ќ 41h@33Ќ 41h@33Ќ 41h@33Ќ 41h@33Ќ 41h@33Ќ 41h@33Ќ 41h@33Ќ 41h@33Ќ 41h@33Ќ 41h@33Ќ 41h@33Ќ 41h@33Ќ 41h@33Ќ 41h@33Ќ 41h@33Ќ 41h@33Ќ 41h@=2_ ͝oIENDB`superqt-0.6.8/docs/images/demo_linux.png000066400000000000000000000575211463340017400203120ustar00rootroot00000000000000PNG  IHDRB*?gAMA a cHRMz&u0`:pQ<heXIfMM*(i>B* iTXtXML:com.adobe.xmp 2 1 1 2 298 1 578 @IDATx|EƟ4HBR)@轃(TEQ?)JX(  H&""]-oK..\rXn˔wdٙYEDD\HHHH (,2 hP" X- !u= N$@$@$@!:@$@$@$`(,8  Z׳$@$@$@B$@$@$@VKBj]ς P X- !u= N$@$@$@!:@$@$@$`(,8  Z׳$@$@$@B$@$@$@VKBj]ς P X- !u= N$@$@$@!:@$@$@$`(,8  Z׳$@$@$@B$@$@$@VKjK΂ @xx8.]Zj([l3Gdsq rRăK$@VJX"4 ШQ#Ql.NPNvăK$@VJ ...Kɣ[Nq8Z_[B9) A%  ((h< @AP k%_a0bXRή`Cng`mquBq+N$@صktWc*-rے/T8u#~ey]~zMzWGoI,g"-T><}[߼(Bcqf(ϐ X;0ECmɋP<偎/ qG7-ǂ1kݦpQ§ϣGQ巰m \uu> ]1{K,y   w.`.UѠq#4i?xb~Ŋ ]! ~:6D7!nPyB*gyFnoK2D2O- mu8V?"!-n5 >cGۄpݽSJ‰#1F s];kW1۝X?gYEźBRqdn9՝_'4mY< Iɹ}ge\7qⓗ^ҋi2;wnpHH: k,+LUR;a?cdjUņW!"2'`G(F߅}2{|zkGa)e {H k"zxE5j+QIoQFoqAY 2uDQlyQ/1P|li} ޭPIGHs0^=#-feNb#iь`YN2rAKZoz ăߚc֎C Qw#ѱoz^Bo׮V9B8iē0k뎎#pƦ6 ħKϡ#6a-q qL[$@$@VJ-BIǣ4ohf'){T_S{ϐJ8Bqb f=F~6gRJ)*!;,IQ؋'zeEX)d5hOdJMtk*z`&y-~Gy,qtwl=u`}mㅽ"t &ŨgnĖY;l]+:&+iL͉n& FiS^P} ,V[qOv 9s WP5f?l{%=gFâ15 h {&ܟzm(%sG FIЦ|^i3ѹ3,ZFWGlZpW9Z,+2mĖsx*&)ܿ<q~^fB~̓85l"^#uP_k1EP=6:BpHAX?85}DcM̷B!Q1 |Tz~51NP2Kw|ֳЮۖb$4< F9F㽊E9!Jk#fE>oze6|8>#?]ec'1t[4;mأ1$ NfBvgaxYvZVzc0aDK;ƴ|^!:xs &Ěq0?5jAIoo8.;saHڹb_1%AcQ1~a{t^2-s`Hqk9+_0i6̝+'`$xGH@jUJǹ7djhsg%Th4Ƥv.WW,yNc`xo/wFpY,DPXfMMiӖwD{ lڅi0/ŊY;1RsDPdٓC5jt{ͺ !$DOJ(r-z61?vAwaLC ,_b0 $@$@$`:rf,_KI !}TxHHK?,&]Ӓ/ :$@$@$@ZBZ%  :BVrHHH@KBHK$@$@$@VGB\ h y- 8.]BLL 7'HHJ. [.AJڵ˕ñcpeHHJv,4@||<"##Q^=\#""BIH[VQڸ8"E '( G$@$PLm9zvE(NRaNMKv'&&bpvv66 Ñ@ )SժU{`D !`2!pi|5ES*bb"DII`([UcG… hڴ2dN$@z LGŠk܊Mћ1$e$mLBЄCF\n) Ʋܔ|6 KdB)vssOƚp$PpIM"ځƦH!d,)+ - L NdBhLz}#NGJ@y ArI+4|Ef$ B%`2!Ա?ʅM@Nx T*-GrE.$@$@CdBzFI3[1ヤɐO2  " VQZ9|-ӹW ˋsJHH`P)K$elݻ>4;0'HHD*yʅHH `ט!2*IHJ yU*h۶#̺3&'^ۧNҼi,L~K LHH#22,w Ҋ!N:4X*P& n qqq!DEEAbiLB(HH@bb2nPYi P2   LBZE$@$@$`Bf,HHH,eV -33\3O(ru 7NNN([f'''2ȯOLcMfB,E$@$PB8::j>qرJP(]I)N> ///x\LC >150HHJ\|;v֘yzzãXkL_,%BkJx{U<9}trW~Ż]睞1"J w~`څ!3&P X l짨ЪwFo yXO5V~Ow?m=I2a5*jvĂR 䔊;.'{*Ax8 <_3>^(^_,'C$@$@Y V%\ÞOƠWZSޕB2t?kzSt F9ww{x»ru4<&|}-Nr EA+/l$l}rf}:]Ƙ֡1^7\)&RƬ?xⵁxoňtIH NtcA:43USuݴ ƬwEuC܏vB0 #nXN7쾼@邗RHŅOGoaa@$aU.Æօ5   (t߻ %$?ӏajP<׵3~݉qVu8!37k4FFкcOJ,KV7M0n5 >cG۴퀦/@41ߝ%k;0cp{Ԯ O/_TOZƑ{aڤ:*zWDH끘&4m'Hy R꾎 ENaؽO,Viۋqaj,nxoݬI9 N=u*7*y }|^.z{Vhr\(HZLչ䙂洅ܼ,vHr&uϣ3N ~v : |;65*ۻ2ju5Wvef-   `!'VFctdR8Ѩ7*֮ :R(è {"1q D:ւشm3VߍƈƁ$]Ñ_O|z|-4Qx`;_aa }74!'E:r1~5վO^zK/2Xu6ғfwNj<6[&c5XNGKG "kco/nz|`4v9Nn/b]رn>pnLS咧 _  4k enǾKZ"%%{f)?bñŮڄs{~?a5!燽1BZSzU:NG(# j&S?_Ώۀ2uР 6_<;0Tu: wpjL_u|-ҧu(nX{~@,UsX ѾuJvbׅ hsÃB A>r}z;s? jX 8s~rCG/1P|li} ޭPɘ'T xd24"|n۠ DЪrI܎mՆ4NzɁiЃ\Tޣ*dnm6afHlR.m9mYͩcl5KGq}̿MlY)y'> G’ѵa'#Wcb $@$@V@ jiV  ՞W=>}j-|^@ɸktiAUP9&m25%ÿC TTCy{Mg|;iN -YZ?V?H[iW ͟8wd &Ÿsq^Xp8JUG[l)9*7n &CF7ŽE]Ѭ(pt,L󑧢lz-pMEuO쎶k,6%tkTM/L:۸Fտw6'~ĸC$@$@0I]O#9{jry2嵚Dbl#;1\C[M-KK ׇ?ER19ȞnhcPizaTHMw^[ ݠm).Vm( @N2$0y?}zWE֏ى!^?d>6GGȸ)l^%Bkˋe8J{;埓+6[,$>jh:B@} GkFG8{>kkU KW&hյW4u'4o]BVwl)\z'{7ys12A5QM /Xu鐈Kf-j _ǻ>@.Sx#UOb!HT8mأP>ocs+4 ۊ+SQO4")<B!Q^h9%,5^JA-PT<)rөyQc2n)w#(z`X@-x`Ղ%8+{jG隮w;gZ̓$@$Pڵ+LaaaEa6a"!$F\[am;KWNY1HNeZV:6[_aŠgl-;W:k7h."R]Ԇ}.5Ա7qb\|3"mZ6:Lui ~F|7sVoމ\*VX5 t0 byyڣ{ 51/ꈖB WѯU otPo fNKǬ8%\jG !ZbGt:,~DgxU'T]\HHKmҵO0O Q(SkG7.ecZ*]b~~IŹ6TI\E 2>?vډoeUf @ȧ`81r=r,W5VFYOL"3`ˍ.} IQZ2Ŝ>g6bҀ9ETȴ%Xe2ΊVca<4yHHOLBHhNSȿ۾C8QbY |z)[-_\O6*0f)vT7L;_z:ʦh~c# n +{lbɮE37w^ǛA$@$@B4[Hh DB(':7RvñcpeH8`/^$@LPzAV֯_@q'_=HIE!)]pp$`X--4H !9.ŋ5u=,9;;kZ-ebb":6p$`R0Pc cpeʔAj^AID0p*TP" uݻyB2ߦa,@rLz`` ʖ-kQe11ٴiSSf]2J]h>&B=|h0; "(Xhrej۶mH r>u"RpBgh0YIYQ N;+tsKZѣM\g?VN8p@Y&~@K*X1&  (g$)P s(C$@$P p)BÑ CBi4HHHpPGB$@$@$P PCd   !@!T8 ,!@!T8 ?T) 'S#  |8vc <2=ضV`LHez?ر3g |cD R[XF ^O<-['7c+M]cVxH%Hoae_VDD[Xs,y oa% ϼIH෰JcK`(JSY$   ]ڵ8HJ^JAd=ӮbK0u.mFw?یO~3ưߌf$[.$`jaaaЮ_K^TY2F$@$@$P P|$@$`vّ3|Ph$@$@V@@#tYM׋Av_~~Cݺuh1ZΰƁph}Kڰ -'lO[HH H1~ C Lj>9yxxNcdc l )3xe[XIiYBB)Ӯ]\'ǮKy|G}D6٘ e L 17rի+c-!=0#1̆gH'P[ȑE~\PƳA~ڞpdZE>2̝l   Lŋ557r:6R,å&ai-eZ#ÄfkML2Vfsc$@90p*TSf9'[w޽'!$ mvnf2IY_'rG!M`` ʖ-k8a3ȦM)7fC$`ɄУG`(_˘1Bޚ6MQ^k%\0M!"`2!bJc.Ғ{>2주!$칐 = !}yDGGi)cjĭ?6Y!1By]M^*C駛_6'O,1BAQ^# = !߸q"/} T*lO9&`$/dcޥ (l东mϐ d'`2!=wrr-1F|ZJB6aȰ0!0@PQӧOJE ˫!$aMUG]@6 J|D!Sݻ>4HSdn- UNvf3$@ !$QȹCqd8K;Bf3$@(]c#`   k%@!dgIHH@!J@$@$@$`(,8 IHHصkWF2rmǃBH#  bDRď.2KI>6ƴ$K$@$@$`u(,0 IHHչ&  Ғ/ :$@$@$@ZBZ%  :BVrHHH@KBHK$@$@$@VG TD2n-UOhv~ #AqpftLdxSpul|zRsSf&:dFXI37?3;YZslq (F%ř=;pN^ wo<+gKBA#7U8u#~.o: %U f$W跾ד0xQFKPA0pcϗX$o"RǾO#ߎO")P0'[h nfƖ[3m,1;>@q\G([3=) @LUuplt톑SF=[^?C7\ns,'s;FYnoͩd ,,fLr oaxfF?|e:9N)q;VFe ;(U`KPRyxV7<er/<(U|YEtmf n-Uk6œdV]}<^X&M?w4@ E^P틘fa5]7=ZGp%oxyzjnx{7zw4 UP8lӜz2 ]TGEi=wdsp/zkK"UjwE ذoLTC7*ËsNsMDk$n~oUWrlPf;lf1Ji%$@$Pp&ihf5g=Txzkl(Q".-{E|'K9?GM cʫ#F<='ZN6|qpIicԌhY>Ę9{%kßƠ/TxjBL[ٜLaXⳘt3&`ÇQihh3;lBK_`u:]Ut)<wXq5<ӾFɸs1UIժsG1Աx7\ųZY#_OŬW_C}[1"VAj1fQ"nZ'ۺw0t=>Yq7"5(xJh;l ys ]?x8Uxx7&}uqfxT}v˧UDbX%w8UCq g7ʾȭ94sbi ܢ) ӸƉGZ> erK*,S~1:- GZe+UGH抪ڥ/ߍ_N'EC;Jq\)@ȥlK*.[lcho[dO]/NaگNp?-5kz"j\A]X2v-k՘&2||w-ĐPI1μݝO e@JD͡|h!pAOiW64AQ88Vwm[I8ܻw}<Ѻ ص~ءJcthJc_ 8\-^sÃB P#ҧPח Qw#Q[_~f@7w{*}!꺜ŏ7tnvXB,8V_6} ++8Dk?c۴p4A!N|?iO59?nKCZodMnbǫi~nA5QwsrҭޗTs۶t;N;Df6q?H}zn(ɤ#Hۑ߸ ]3r̾?\ ǻaz 3gp' n7*t}v"]AA4ÓNC7EgEGoѩo)'#v#-S7J?Tq@JDo埭APMʙ=U d6И"O/:TC0a0Mߢ Oz:DK{#Y_m' AԞHmZ Z\mk êeQ)=IQ m*L%6`= GI;R嘟Z-kw){ }nן/Ƅ`eynFE#J(E}tdLN/zcXe^_"T*)T";ֿ n(z`&y-~Gy'o1ӖKuwC֛NA;؋-UjjEj :܂-mi1m]+>.`s^NDW3sǏ a~}c2s@e[ Q~MVַKãuB(8:c/mOTFQ{I#22 C$@EL$B( ?_qPr¨'Kֳ {Ot.F%9 _voo,l?1q7N)&8wC!6U0|OOMw1%zTuBڀѩw=f5DWMm/K{S>Gl@4)DM*_ڋvb16A,_Ո1eUfƚW ]nS01͡:KTYuAbR•ޘ<;L4Gڕwp;i>qq1HIIP(頹H̕k[[[899*UBŊaccSB$HemZQݗuu!]HB`2fB"蘨` (l^h"e˖HZJGvCnF AyM_ 2ɖXL2_>*W_d%qByIWP !=} :u'_1 䇀h DfMA5බE(=yJIIARRhѴ%O3o3GiZGM Z47od2 0s!sE)ndWl ".m<4J$Pe)ӷo߅w"PPhWaĎs- !#+Vp,I~Ne5VA@M&nQ&laooF-1$oN1Cr<$ K3F /'0e ,;SHs4Ne˖5:q⢙H 'I2G`!9Y"079YlՑ;RU1CdCHN&[O fBBB617?cd8Y}KmW1crRDcǎi+-ny8}4BS!EN"i KvS$BF3}G@~1@r;~ZA bPf49\uxM݋n~ɾ8-H?7{a|2vء֘ >9xq֘]lDD9:ڊTuypLN@`'ɛF#[S|"(**BtEzG+G鿱xڸ߼qhCr_UJq8"88X\'4#G4_ʌ_!oogex$/wF>R5x)ewl"(22BH1wPf #)|}EZAu! I=yV]H.YԿmidAl-2lB$PL&xDPA.&yAg|\\SYsCvvRj &]r;%%YF)QD7bKab2, &[d}Ocwy.b!ۤԌKz K$PL"dkl~ȾoLq[ik@)7)tʣִm6Q#Z8cr2dL=a# [; \:-?0.Oq&*ɓ'E _5I I˗闔wnqvvo J͍! Y>)k[ S5I7CLıh!\Y,@*4ӄP|\\x>Yʙ\;!N||Ò IP\\cc. WV ٪#[w+v呭CR prz2$ŏ>b\pNb^)oqJR|ڡ-p~2n~+B$PL"E$/siⓟ|ēdr κ%E:r5>>NӮ|{_LYۻ6*`!Q0R 2(R 8C/}>O/BPbplAebfm=f텙ߌ73Yq65Ct BA5Eb#y-!@,QaPe˫D:#A A''E;dgl'W~Un(Q|>E^Gwҩf3?Zhhh<]HrT*խFB nlg^lutn >0R:>.@tlNWE> uliJⳝ Z)XtqEڟ@:fff.o} ʺ1B:E}*+F ooV)G^;U@m:/Б 4==mus S]__ Tqkٌxh Ҡih!SQ8 tN+VvɁo>z&WvkyM>p>!nhee@G B2jyylll4V펡o^m./lfqh91AQ0[;V®9 ־[4Xkk+*[n?gnnP~#@{ B*Nmyu[ăFȸ*haU{+KB7AN) HPFG&X T͇nhsk}͛삭ߧe]}FG5k1^C{ BҒ;̽R|i% l##CPݠi]1MT Tvㄢ !|1}!SGBݢ4+ޞl\r;Q*C'^أn`ߨ>3ޭ}Dz%}{t}Eu@GP\ 2;;; N2;ŋ1޾ۆspT41@ߧڈ7,@j @Ɵή̡}{;edѷKڄ  w?466"ReGv0}hLX#rC@XxmN@@F DW@@`B6=G@}@ T@B@V lSq@ @@ XPMO@@ D@@`B6=G@}@ T@B@V lSq@ @@ XPMO@@ D@@`B6=G@!Y ǍIENDB`superqt-0.6.8/docs/images/demo_windows.png000066400000000000000000000217151463340017400206410ustar00rootroot00000000000000PNG  IHDRD,; pHYs+ IDATxo[ׁs_hˎ-Qڊ۴2r)m`cgA3zPxZZ@w`h4,@@Ťy@I h"?2:;9KҔHuIC}?&=Kx}b@fj;^fffffffffffffffffffffff *GGGؓZm;֢v/ml۱u@z@z@zv:$^Vf]M͵tllzj6ڔau:WKݾw~ζuV.%v,zmJ$vb[3 0+'ؒ;yJicib~ {Ri =cf4y8^0_ՏWsN:) ߭J>y|A!s\MTjvt~s<+鬔^Ym~uu~Vj 9-lJ'4ˋsBܸ^]E`nr~b|nXkvpBoLQ&!+ZՍ358kvMa,,/Ml[>۷f+o^Y\6S.kNYt Qu*.3b<;n߻-.+g09bfx͝ݨ*%ύ96v%cPӳG,/L-YϵV3[W+Ga,xo:]V}(56ZU,/^ !L[*Z->ςaԲ35,/LU7ce`?n5$=klb4AX♚ّ=Br?-;O_i MO\uJ7-KvmW+sc^ n[MvАיy|I׷b>rϻ}k+ Ktnp*q<fO n{kveMx+U,Wַ5m],w\ggWonުxDpOcz%>v.`]omj:PoI1 mSVNIt-W`xg/, 7zn7ml۱uU| sZ,Y{`5{}b~v2Oc$ZU$C +g7H0H0H0H0H0H0H0H0H0H0Hsf 9o>lqDz{ =Ff ;TOsm0Yu+Hl`՘fH0H0H0Hς@B+A( ^㝕ht5g3[R:ix[ItӌY32M3aݛw,fH0H0H0H0H0H0H0H_׌'#33338@s.hBlVFn;zww>(m볧*=џ>Z@V'Bl?dxPk΍uX\t.?s_ςa,s:̒k֮klBO;ŒYXh^;:xqr`#gجߟg@,MnU n0ٚ]U^mq$[a³}g0cv\aL-;w5xqr 83UjaO_j MO\: 5$+{d yfvklb4%Y{+QmK5ZZ? Y0|`zJUU5]jo}zz{}9\,M._qێ+֮lMR ð2˯ 9 9B;@PhpxXhm/Fkי=㚽>1?';ŒIV>$lI6>< ƍ##땟gS\v @V X5#####ۏ;/۱>TRcdaaaaaaיBWYZ$\XG>\4=8hB)vF_da&} vp$~pc?;.4 3613338fZA;W{3U&ViF34v >͇VxZ~P*pґvcradry7v.۫0ig!@&Y]HEGO޹ډn($/E"CCC?;.Ivq7n=z` Nm@i_~kVz`*ґfdcQ~R= V6Omr /Vz`*% Zy&z`ڭ6Q )9 ,8f ^I3 G= A)O֜bm_6/h&KdXvMdI[liF`ٛX$]B&j 6 :2GCCC}eVz`5Yhm"EӍu_nl"@z@z@z@z@z@zro8.UB&U$ = = = = =9fZA;W{3U&ViF333333:fAnZyι갧7޷a`Ŋ0 Β>̂PiD;Z"6?d,xǞ.vҴgXfGYfsaWX#P ʬ҆ޟw%Y[-w}b^'K'Ju‚ެO:^ʤ]`?(/lUJT_mDh;*^iN ֶ\X^t_>)?޶jd rGrwnkVUzΝî]Wr߃gMp]^dlC,igq&-[3ٷg,."8s}Y&07qk]}%{[ &.X[Sa,xJ3uu3mR4^õ5bGQ-XY&nUY:|-_juVLu.!klb-tÂ0,aSκy2řʠjs],NWS]g5Q`mBJ؄XvmJu;ӠfzQ3Kύ+v[ƽ*mֶ|m%+Uf֥s tug3z c<r`U!ܾQ00qѸ*7qmvoLRC9m}u-ªvwc27 mfAy+;{ۺ]z56C8 A&v[Bx5CUM_sRj5WujY }Pծ V0^-H <`d&qa333:q6#|Py<՞o dխ IUc = = = = = =OOwO>Q, hz3ǎ;קl6Lh.(MUUu(ÇN>})MӚ<7d2omhsF.^w:_2[(s|.p88X2Vҗ)#( X,}Gpӧ.6,?>ƪ >\oSUMU5EQ( ]gP(rL&JE|۾VeP<_{ .8zea$ŋ昮;4M4UQT!DX";]y%I T[22ꌵe666TUlZUքo~- vou;]tUUP0SX,rtZ4MUH$(Ł93\/~W.?9hW4!B!T!Nn!mSzͻћwcB!!-l8aaY*o?{=MS/^WUMtC7#MJ3\^Q|>iM 9 j=-ә>쳓'O*(; ,!3gΗTU4Ut]w8]7gBBl6izeP($Ǐ?^> ۖI$_53tEwLXf~^ :̙cBEQEU;MEsB!zvJHr\G'NCسL("y]O2aI}Mt3;"(0CMUUM8iZ.fa|>el6d2Ѩ+cK h BvH2a13ק BP,łjhvEQb>3,NJa4d$t:J !?ι\.ySq.)J%d,KR;SeeZ, Xƥ7v~΂0;tHŲ{$tdBfb2 H,ǣx,xH$шalF$DfbMj"+lZ`9;avon׃x`BsB6M$X465-3HXw7*V*Z/sℕWzYfow4JӧGƼLJB|&I$x4 ƖgV8u~d̰gÇfPhpxXhg;kgY_+o{ V(?z% gt2ch:~p8T+o ֜ӟW~V{}[2 摳T2h$ [f.\hZ2˸\.e*޹6`٩ׯ?䓯Wt:5<,үdrbh$v_af<ٳgϟ?ߴnMeai?w?r=Rѣ1!2\.ɘaFol :th||M ⋦?xky~ɽo$\hmJ*u TH>DRh28xP?ujǯox**cδXFw9pqWmyw:333333333333k~>w޽{X,fm3 |СP|$x(ÇB}93|̱cǎT*OBfGa:}S4MkZ,>(WRww Nɓ-\AGɓOF>5C^΍\8td 躮M;LƲل%* B,b_~#G.\pKͲ0+}џ'0D3|rtiUTUSEbP|6 ! _wW_g=HQ_}s >\.ΦӉ#G6iCt?^pB+ʲ0۟S/=M5MUUQ,ŢGF_KS5/c|·F+lPUurriaWYfݫd6ΉuݡC4]UU!sG(JX鴮i"ΤRVY;7Z FLwlZs?_|+tA'Yf|?~b2lGFG]ipLJL\^Q|>iMGcԠz/?gn]l4}g'Ol<* lss3*b?e;p\z^f5M; !2)6 ;sf|y?DoOTL"a ̂8zt,(j~9uái(|P(RN(\.[J3g >eBa,xꝫ=nǏw;il,_,?i4MrUq汓\.fL&d"}}M{ ;vDX,܁LXa4ѭ IU DOVV[FQB!_(BX0hhvEQb>3^t:ULYo^3/vS,Oaf~mXUySq.)J%d,K& gs |KͰoYfG ûj?t:z6Z9cP(dbL&h4Eh<x,e2ڪGD"/PU_:t)'Yf###HNνz$x< cܗ[p8. k.a 8~esmÇfط,3M~}Ɇ\2ze/fR6ND<3WN+=P(~_~ի{@[uwqbb`߲ތgϞ矛e%J I*Lh4-s_60s={O^~[ovVM\.e,kCUU+yVM=y6 X,G6)bX4 pxk`W_Mۂt*NE[oŋ]aֱ2gϞ=|zfY)vGFF>lgou/N$\.ɘ;h4NO1cg 8p@c2::th||M _>s+_>xӧD"5(߭F!Ç:>6Ld2E!;6>4td=/:J *J$d"\rD./ _{O ق@y"֌Oi<6o&Jn/%NziN|m6z8gֆk f_>4{"LqD"fgE\kK-ww8 Ԍ.szߣlFT@K1ft;Irt  )v /@(q`1 jP J&T]N3n;!Ax H2, ; y@~PAP $dP*ʡjh7T @n> Co0aUX6LÑ"8΂"x\f |/Q C&⃄"H2"AV %H%R4"mH'r E^"08 Xa0( YY4cana0#oX V kuŲl.[݋==pt åvpn\?nk-P</oğ?;?! "* 7 1шJ %yč=6uqL2!"Iդ*R#<魂** >UdrYF@Gn''P(/J<%RO9KyBHSVd+W*(6+T|E%R,bj>zzRdQZTt\GiTl^%! OHNJ? |h\yڀ*NDZzHKuDMEA-ZmZI^:B7#ϳgfg8ս%Mw?k04444Z4kb45hjy~CG?1?u~<8׶og9d+NN>7夅S@SIIqe\`[5΢ lo/iݔ/'ښ<k ecu_`О7ODwފUo2)ǿ`"ÀeXIfMM*JR(iZASCIIScreenshot- pHYs%%IR$siTXtXML:com.adobe.xmp Screenshot 948 146 1 2 ї{IDATxY\ӓ=11, ⋈">x܇ " h 5.LLgĹI3WЙWO۸qc]B @JOU___) @da s @ʌ\ @ Pk׺5 @e ZP2N @@UZ@  @ @Hs&@ @@ͦ @ @HFj @ P@^*Z @$b+ @q" @ @@"M @}hZO @H @ @/ @sh @ P@cn0 @TU*: @ @DvRkĎ3 @.`㺟O @C[`  @R( @hZ)M @ 4dmO'@ @@8.M @ E @$l6S+C) @ @$N @(Q-L @ Xg @ @@iB&@ @, , @ @"Zm{9A @ @F#C[@  @ P%K9q @ @(T @@b 1$J^)[ܾ @&@L2B/@ڹsgڵkW~DR(c1u4eʔ @ -["U @7"?Ҷmr2 iҬY#(k7x]=y~h(U .mڴe.7Ft֭9cMGqo׳~o/a 0T͛7Fn7A:#cǎԾh&M;4gΜC}'O駟`;wn>}!M @N ڍ7FsANV.8|۷oO iƌiٲe dݺu)=#sT% @CO9Qm12lv*F]QgP @)SPbm{H@m,n3ŋwQgzLjvX @ +W̟%K[ou:1ONzw}.]>t .`_mذƽГ@@bH{IO?qsB;o<  @wqGZjX"曻&.f__>_tM7BZshKJ @ vbE;G,2WEƝ @f^z'W^yezk<<"{ǧ>(=sy;Uu 7P(X[$@(_M)2+;ԗ_~ @1zyXqy˽?8ovڵsb]tQz'#n˗իW<_cꉖhj.000?⃯[%#ql>٨Ngˊzw}{%9眓.5k֤tݯorBk^Jm @qq7;ڇ^Xq8B]/H?xK/ݝUqwV,kf2FӅzĺ!V9j+'q8BM U#I;wnVH'|h/-j`%]V?8'ќTbL-ՑHhx<6nQ @@/tT.\kD<?|sOZ/jb̙3Zz:WՖX"}ۣ%%v @k8Qs!PqUY!@"~q;?ؕ&ű|X+q 7|s^"FgGsЖU @ Z54ؾxj9MotA[(\2mݺ5o AuvmkYgՉj'\E&\ Nj۷t1qN#@%{ȾK[n%xc>C=}\矟x19Q+(6=-T\@_J}Uڱ Ƕ=v&pQGbao=#ٸEx婧J3H<>18?Q98?(ԑy6`WUiÖOfqMk1HjY uEj+NjժW_n?S"7|?c/;A^xqzzn!۴iSy? @)SiUkצ dW` @ꫯtKyb,WNW_}uL'pBzz9.caϳ(T m۶u=8f[!@|ڜ.X +=̎hm޼[.( P@$qz̕}Qw>,-Z}sF@aqQ EY&-]4-Q,Ysח[K.I[{ǜ=餓F4zS[@l3uJhXq8B… HHWXAted6ꪫ'|{_?z+wqq%v  @,^xD\7o/Oq=mMql>~~vob᩸J;5:T[@\үzq,; @@b7(#_mkbg9XjQ~!Ўؘ7#ٳ~N`B @  W 0@$3gLO[E":]Ψ;ǒvZX} @ YT P\f֯__|yG"Oӆ G;V @(wٶU ڵ+Dv  @ @ %B9  @ @HVe۞"{N @@H$| @*`R{N @@^*Q @ @@If3U2B @J#1V!@ @% TUe+ @)`g @)o96Ⱦ4 @j-`Zw @ @\lSn @u~m'@ @Bq&@ @@r\v @,*8~ @ @@M(TM^  @ P@ٔ݃'@ @@MF"U @ @@IyQj @ P@NhKY @ @ ؇։@ @E Hh6A @ @wk= @b"B @JJ X @ @ ̡u @ @@qc?-9vLIENDB`superqt-0.6.8/docs/images/labeled_range.png000066400000000000000000000307671463340017400207160ustar00rootroot00000000000000PNG  IHDRj'3M iCCPICC ProfileHTt" %tH =tJ Al ""XVpUD (6 ł λ?߹;3s(8V C- f3@JŬWy@- _ /%ēr3PnGsX rf'ʪ4@)8I!OD@p$)-Q=#!O`# E(px(@2##sP6EPfҟ|?"穼&+9ygid˦0FY )Z{ir%f!o,FM3W?rD./ _{O ق@y"֌Oi<6o&Jn/%NziN|m6z8gֆk f_>4{"LqD"fgE\kK-ww8 Ԍ.szߣlFT@K1ft;Irt  )v /@(q`1 jP J&T]N3n;!Ax H2, ; y@~PAP $dP*ʡjh7T @n> Co0aUX6LÑ"8΂"x\f |/Q C&⃄"H2"AV %H%R4"mH'r E^"08 Xa0( YY4cana0#oX V kuŲl.[݋==pt åvpn\?nk-P</oğ?;?! "* 7 1шJ %yč=6uqL2!"Iդ*R#<魂** >UdrYF@Gn''P(/J<%RO9KyBHSVd+W*(6+T|E%R,bj>zzRdQZTt\GiTl^%! OHNJ? |h\yڀ*NDZzHKuDMEA-ZmZI^:B7#ϳgfg8ս%Mw?k04444Z4kb45hjy~CG?1?u~<8׶og9d+NN>7夅S@SIIqe\`[5΢ lo/iݔ/'ښ<k ecu_`О7ODwފUo2)ǿ`"ÀeXIfMM*JR(iZjASCIIScreenshotn pHYs%%IR$tiTXtXML:com.adobe.xmp Screenshot 1130 156 1 2 Z>#IDATxyU8BK @Y;M Q- B*F (i "~Ř(NE4e,EhPJ7g8w۾w>\399Cs=sfȲeړ @ @`x T @ @ q# @ @*" PSP  @ @@{ @ Ptj @ @j @ @T#T @ P @ @TD@" @ @ @ @"5 @ @Ը @ @HG @ @@=@ @@ME:B5 @ @5 @ @@Ej*A @q @ @*" PSP  @ @@{ @ Ptj @ @j @ @T#T @ P @ @TD@" @ @ @ @"5 @ @Ը @ @HG @ @@=@ @@ME:B5 @ @5 @ @@Ej*A @q @ @*" PSP  @ @@{ @ Ptj @ @j @ @T#T`Xvm]UmooO֭  @@cz}% PW!0^}to|?i9眓;4nܸ4~t'[n%mܸ׼q23O|{ウ2:گfK@4իwl\_mlczo @!˖-k# Bo=wjۖzs1 sI' aÆuժUSNIOh6.yM_k=#@@=fԣ'/A$O/~饗^j}! 5'|rzᇳsMf*Z{e?EM(=Ciƌ١/=)fۍ7ޘ.̙35\hrx4zڴiiiwO{G?zQSؤM%G@ĘHGF tME&fs=z1=]wѢEECfӎ=:]y١?0]^[K:iDЙ)`FM#uM` }Y#[__Ə_#HCяf?|M_T, P``'O.~mxsc=8qb2dHq:}:CwuWZfMqsLR!@ԛ\h@M#uM` p i]wZpuץzKk̙bN;.O?Xqꫯr_L7|svJ~x4 @_K/M{o?8(M>h|czC 4X@˪AGeƌ]:{"sW*]6Kp4s̔?~tӥ0 @A+{:ן|vءH;67Wqe˖Z0}"`ᄡK>g\{iƍٱ@q.Ҕw}lMS,J?|5kV;wno @N9dA}Vؤ15}-<H SsS!4ŌdZxq7n\<Ţ6 @ ;67Wz* PSt @ @ x'@ @* PSt @ @ 4X @ @Zjj @ `+ @ P@MR @ @, P`` @ @jUJ: @ @@j x @ @@5JIG @h@MO @U@V) @ @ i0  @ @ *% @ @5 V< @ @VZ#@ @4X`XW<M&aÆqޞlȐ!iСZ9 @@UG3E@_24@d֮]֬Y֭[(~bMlψ#Spl @0 Ee 0X,[,5VOE DWN+Ŵ4n;D0&D̉`M6].BRx)U،@f&ЊlyM]c4z("ҥK/<6 @l4@M3> imI{g3fVbŊ믧קcǦ#GnU92 @'`9=YsYf{t'fKIJ >hw}SoUC|C*\rI M={vKf~c-"_SNMNl?oMЊg@5ʕ+3'Huabړ+9o ۂRqm @x=@` Z2.2 bŊM/r|q{Gs&M*;qX:!̷ֺzT^+lkbNB4s<;+d=6] VWiކθae>ft2/z @@E7nHk7}y;{GBX7osʹ \Ǝ-;dȐ(,OϟtErfiPp3fL~;+c]8AAgt^2NbzN[$7QPw4P9{v;?_n6oݠK`ȑ#RMs  @٘ a[C`ٲe L`gt~ Λ7/r!Yb}s=C}8@҇rKTvw@}*A̱{ϏT' vF?n @ xd AHC1s'xCӝwޙ8ʧzj6mZ?bOSzǜ;}|׫-2vcqxzUxg@gԔm홈txm xFyxUvo|OC⟟>;r= @`K66 f3}MS'~O)i[V1f,\0?U,k[ *_T;)ƫ7D6PXC'JlA &mmmY4۳ld_ʂ5}u#@*#i,c`M˻^j|g7n\.QL0!^qqWyNJ;};_,cAKF8XBtg%K:{CN^ rxd0v[ʏ.w1'47Du׈44ZU;63jѧ|+Yqno8:eʔb ?{6+t;YǺT @ `< $&M=tشX\8&OmV/8,'_NU4P(~zfӋ/nA?.i @`bA[Zqb[W{ @f0i^ԆV SN͚*^.͏/nm6{-Ot 'X&뮻.[9sviة7*a5g.:+HW]uU߳ ͜93Z* :D*jZ@ bxV\S]T[xv1cFv8fHw}w6S??3HŢ^nݺn֬Yiܹg;˳z衩HWߔޱO?v}_}"@,`yY~}citI/ NE]t@-Vg|ϢE?D~KͦrNW 6l͢` #K 2dz?;-w>-o[mST6YcѢE䁞Xg)VoʬұJ̨AS"XxxX) .!cFa֐ tkYf` B  @A#`<2hJE t+gL֏2FlxU<4v.S)[^ @9G_18ҥKgC8>iVD7=׈k @G:L@ QӪ=: ĔX;_/և0aBe]:cg1X >_<8USm~R @V0i^f: t@ 䃣X&-zxTi}9`,Y=J+G'֤iK  @l4@MSvFzk֬Y-2bŊ,` {E%6#G.dfb&NxVZ"@9E#Fdoz2fDN j#K@Y@aB ֫M̪KD&>ǹ7fixLyg"(*>kv @-0B0 h F kV 6lؐmʿˁ,(Sh/J%@$`rD./ _{O ق@y"֌Oi<6o&Jn/%NziN|m6z8gֆk f_>4{"LqD"fgE\kK-ww8 Ԍ.szߣlFT@K1ft;Irt  )v /@(q`1 jP J&T]N3n;!Ax H2, ; y@~PAP $dP*ʡjh7T @n> Co0aUX6LÑ"8΂"x\f |/Q C&⃄"H2"AV %H%R4"mH'r E^"08 Xa0( YY4cana0#oX V kuŲl.[݋==pt åvpn\?nk-P</oğ?;?! "* 7 1шJ %yč=6uqL2!"Iդ*R#<魂** >UdrYF@Gn''P(/J<%RO9KyBHSVd+W*(6+T|E%R,bj>zzRdQZTt\GiTl^%! OHNJ? |h\yڀ*NDZzHKuDMEA-ZmZI^:B7#ϳgfg8ս%Mw?k04444Z4kb45hjy~CG?1?u~<8׶og9d+NN>7夅S@SIIqe\`[5΢ lo/iݔ/'ښ<k ecu_`О7ODwފUo2)ǿ`"ÀeXIfMM*JR(iZLASCIIScreenshotL" pHYs%%IR$tiTXtXML:com.adobe.xmp Screenshot 1200 332 1 2 AnW@IDATxmfuwf!šH")͢^"_j[RP";n$nu:[ԟ( ]h]@R hUV NHH$ -Q$%ty~YϞ;;_{ZY0D HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD HD 8 ,t"}HI.y>Ȯ'ڑCȭ["EG=K)g|c\q=gS_2bț?]/r_S[fUcBDn{(HDEMMuJѕYZ 0NOq1%s_<#Ym_kLkm9.d-O?|pdú}͟m z[zFC$zf?,n6VrڵdRV,xڠ[9N|#GșfL;-+r$6PU֟y5.('Y7ךn N e455Kh8FR,f0g⦟a?szʻ(uwle޷ :mxXGnMud>0s܈d)ɿ9'2dS>9sGA,8W5!`8r$9˞3s#@ԍvnT:Evd拺_+//LY̬^bLL9O9g\#!ϼG3Whc=y_d~ Qݽgsabyo;Q|1쬝?59@>`r!gb:z8iFC#mz}Tn?E98:?7Is,/OippnP9g߹xv)W>Rpp #,&O᠉F =H}yJeEыג'ɿW,dS4ܘ#Yd#͏9{&gxq}.s]@S-?o[붅~ d⃒dyujȡ{FB~0ӯrJn'6PQm_=eg{c׿V5Nbc|o?Io!g̿y]"&rNQ#3Xf%ϼNdca~7P)>Σ?S^rr= u#,\.P-եD7њ.|O@_L?VkR8quƟSKS"Ǧ2JH_-%6byC^$=3J(o13ES(ϼNZ2gfeyp_ݮKM&eCv|桫{y ~k[Vgi)t {0q%NF%\)vN KcG;HkL,ڃuډCfߑ~7ѺF§W5VcoJs9cgMYfZY?9{I9yO)uy;:Ňwߪc][uko+OtNy " nx| Y'2GC/}D:DFAƧ<.rD/A!M 8X?O-?Σ̿43!d<7r N 3;I^/.?2l('Z1 z <}RI7W/)F&)ܵTL0'S|x?'_@xgZV39Lk s"篜ҜߞI/NA'uѦ?n2&M*NuR9Ɍ7m$ Mᯉq\f:%|p Jocz3Y/2ƏM'ɿy3?okLV͉X$Yȼekbbg7/kCS[U5>O_/͜ Ll7#Sd@, C4@Tv|_*>D;@|#O(u)+>%E8%ɿ̿ipM3Yg9е2篜?9Kǝ| ow-$W|:f!.j3/$yMPpBWm+u(mR}kWiW _}6MP?l[/g4>Fo?ѧޏb'2X R'јH)skZ#gK?[,K^V_%Y&:0%vjܶ#=#`BW7IR&m]NƮ'@:2}Yb}c׼2Ju+'>iO,ؐK)t$ײ:<21F݊z_ue][?G Y// ?Yd6篜<6w)!dܧs`\uk]?cf,%cGցfq5վ$ ?~׫5 $PM9z@_mԭ=0]=JS1_jх"\{uun]~ ߷jOSdMkl֟.qG%-YD7αzOm'A}v߱$:Y}P$:/̳mN%zKs%K]vR=ۘQGd}$Oŷ?5tZ~3Y0;o_"ǔ ʓ:W_wՂ:Q?$y~ mƳz:yn}ԣ}Ў$@ۃSccґվbr=|!<'-+J'o9Y>{aup]d#S^?&BO'!/{ljOƿQOtƅ!v#@0Cp %L(R=t*ևN&Z/OPkN۰]|t xU'Ф1D>>x~WO?l:v8_ǟ>~iM?3N'Oڜ_߻ke` p8ޱ~?o>B?s!.[:Oh1E>M<%$ ġnS'#z;Ac?J6eL 7uѭ:QV_]4[]xA |S/D ?h~?>oszlj/֮Yd+oO^}V?;'Y2.~2n6)!uQf`XG&^hhMX7>KIc™T&=uڰၪ$Lz?o]=շo0jУs|c-~?47GsxdٌI?Y0fe-7ͽv?ܛfb,Aꈯ?uCߎr 9$>:2uu %$7H, CB!7)M*u9dë |gV(㷦u|*r>,B:ԉƮ/Dѕ B u킟'"eel?wZ+r+i{3yq+_S3֒vtkU|۫L} | KBߕ˼M8 wBP26 Iv xS/P9<F=/NU'0 U6ǩOս|}O6dX>>_ s"7CD7f7<'da>d^~FWpWrۆR{nߠOlu.%3.(/|;bOGc?x7|'u ޿tƤlj 'CTf6mY41!NCCo/>i׮)Ndŷ7'r oZYYGQ?Yf7O頓<#{^9՟c@Q?*>箣n 6uQ㯝c^lK d%iH"_| #W}KibVU\&iWz` [m/˲wn>OYn͕?c;6y7_Y[Y]/ke?eΟӜ%qw>JS߼O.$>}{ڠwhtĥԵwOr u"//C20AV˦2utxهA}_NX}֘۾Q ]|맱nG6펋6|p NJ.1TV%:G^!dXUi9jI)im'ߔGR߼d۬|1<6SCP2|9Vef=wk ?p\1wwc_㬿Oe֍}m#-keQG[Pw#9nFZzH<~hC޷9ikr¯x2;PjYiomP->C:~W>mTGjK:ߞ'7;ȷ߼̿?YdGrss"&=+g.ObbS_ ZWnG}Ovoj7i-K%?Jy /Ǘ E#m>c/'.?ËYdcC>,>>O_u|d:o'o??~i)ߵyzeeq m{#O֔(9y1%74#k>>:'|BF|cSص><핌mTVˊD#c&IfԄESkRpx!jYMTqw{ W_p:nmPo,92>ƫQ|0czcG,J}?Oeeq/#kiK^ߜ6OΟs<79)&ĂVܿ$>?T%Omku]UǾ/SDKiG Y_ۊi7զ>ݑ%/N%^.`Y %<5QSfzR.&~hW2MƎ2t_e=7Tm?Y}/kS!>cm7C/>O;8挺Ȃ?Ŏ8HX%STje!7Te_Ub"w̔'oӞѯϼagO?a;6|pl׮'~WQ?|"߇/je۾?kS~;e<˧  o":m{[uicB |.|׾}G݊]'%GL wzMmԧ~3ǡɾ?'ﴮu?i,I'Osf9Dy3+˻*Ӹܑއ;>isLԵmե;ܝHey&ax,!Cު7L*CW||>~1n݆1 šڡ/>^Ͼ.,Ow_'sOe1\{('̿?֞?ܨ:_'Y55߬cs΅# g`W}[!(_}lkݗчWfE$ÝF0∬'eh^,<ƎdBĦQױڈ+x]`/kIDi_GA!YmU՗#?-|Ⱦ)ٗ$N-U'A\C6?|IO<e ꕲL06'ɹUc,O|0g,L3#\cʺ+ͭ쬮?=}PS9:F}lێ~Sӎ\ W@1R6Oqo!J[ Y55u5쯵>&I7珜rKGl">8A~ nůi;D@rxJS#$䋆M}؎O[Wz>mĢwcGuhG[~⫃=::S65=>jc\]S%|!O1wܴoO;)%v73O1Lta%2d7ig_S,r<oޜwt=FI; ;Lmo#xЭ:6vX}+(/vHRnߗ1b>re`ӏ~ !(Bn!A_E[M_@axC*cFo~k^O&rf}w["xġr}x.n.|ǟO# YOɿ)"!jη̿?7Oߜ?rg1suK@z!޻o~+Gj:N|*~/74~il>:7 <12Ȍ}NKO1]z(s.7"/OuSwQF,O/wMm$uG+u§]Kw} u|o u>vE*?Tt.tM_>/LݗlIzH8EhCx&(7/xl ): 2|b>_뻱k|˶>_}?_}ķCA^K1( YW| Cv>:}?czXGҦkͶW_Ư/ŧ۟*߆n\/<} S^_:uy >4A.ю!UAؠ"~>>xkO;V啉O7[߇_cOjxu{7>GP|l ݿ@_~ _~Qb~9u&eٹ$w^ɾ$~ E(yyQ_6mէ dS?&7:<mrI\Kٶ񋏽م>z*>mcw(SJ+V3bʅ̿y]?hg'OW?9?{sXv?b`(}4~ϓUB8)G>m ꇲ6vM~__چ_qz|db_U|ti3OFDرRb/'u {0)v1H6AW|l}m;Am?.;~ے6 g]K}ߊ'Nv[%Sɿy~32x1KmYf?@9]=KA_Oi>P@؈+m_᛺x->ُm֤7ŷ/o?wU|Ɓ/߸~A~x_M /JvJ%y=!{Ѓh o$6vK]cCKKG?}o^7v$du3rbmk}|gC. &Z=CǏ-4~:~-7m$s㺒7_i#?P?}r;$ [ϟ\:v|1:&;]|&2yK+c#Nc7mצ#Q߇o|1j;4I$A2xvba RRWLLv/A|KutQm-C$2 RW<o&1յxC [,ѭվm؀K]R|⨬~.UBKmЫ#7S"l^N__֟#?rs:w3+뙿w Z)_Kt?}.ݿU\쩣OtxNJP? KyZ}W|=^__kl÷ώ#QF Yk_yeRGcسOD"?/^M_te$/dR څ/tc%|؍$G|۔ao[w\_QǷznWޏl࣯jou`|z;~ol'[7:ICy6< ~}z|Q/6c7}z|}GqaG z6W7&:rO ~ ,/v_$㱍ICrA1? Ȱ<7~t*>VoW?2GQ6Գi[?6l: YzOӜiSd$%BvF2irbW{|Ů?mW_6|4(g6}ԕ DvС:OcǶGoC}>m`MF$!xln߱.㻶SߪmG? _LK9~J}چ.חu;nK1#Poi%|?ŊX$|pY֜cپЖ'k \pmqQBY7gzk(:s 5xC߸[.?_Ų܅zR6e~>W;!C}m4S._8߾wL6|C[?>[[ :,Nrk0ANk0_'>$~5>{?}D_᷍?W6f>z`Bnç?:d(:s ߾1Cu'yS7/ϴNdo29Ǽ}81y/A6C.|V|pWlh_m$^_s[/vNay\=xɾRGLħ.K`wh)g CؗPK@uerEu"[u=WJG&>21#|>zcz|QBM xYbg_>(A }]"&v_ۋil'2k~99Y 'u'ϼ]9G+-|0]ص-rPǢjo쐉ؑ:<:aWc/>>}1F%hwLb6vͫL;S*5R/vuձPJVҖ?->{L1QG}ۨ.I[7_z.=#ac_YG|8cǣ\UJ:ڻgu+fա6J ƏiJw3Ǎz}cbֿY=dLuϭ|iwwy;"aw%i?>>=w޿h7>~`W_J{ݞ,y*a)x}z4 ةSZH:N&<&r|6&UcGL96u:vMBCFǪG'}G~wmo{?K.=x{^{mrIyzp…so[D;> {g>:z|WB\}sֱGB~ȁNd`SP|WK/җMd>>R|ڔ7vvq*>2Yן>ѭeB__؇>ַ\iO|@ %@"$@"$@"$wRO|3fY CwzN(ܱPncǻzmSƃz(š??<ʪ/?}"(Wm-Y#n#UK}Aޗ@?/芯l O GNy(㶤 ~~rߜ %@"$@"$@"$wWuӟo/'>O}E[O{6wuߔU=]XOW]-ocTWZu2gY涩o%򪇬'mWODXU//QǼ36nIZj?R2Վ%;=O{˨W;K;{=ǹo?=O}绁%uڨkg[m~g@} ~oے?ծU:~%2h SBo}i& =$KvC$2}-8'~c1gCIO|?|_|Vm[}}u~ ӶUGІn^mvUO]t7KudΘ@UUGH%j^ˇ;Q֜|Ց>VUGW[QVWc^^mGY1+_u䫏zeŬ|Ց>VUGW[QVWc^^mGY1+_u䫏zeŬ|Ց>VUGW[QVWc^^mGY1+_u䫏zeŬ|Ց>VUGW[QVWc^^mGY1+_u䫏zeŬ|Ց>VUGW[QVWc V]zwۿ6;>`Gpý9H\vnlKT~BVE_lJ|×|oLL|3At,A"5`˗@l7x}>@|V5aѣUޟ>Ї ]&z@`>o&?ԉ[eN~Jަ~[u?SVަT?\9R7}ɮ:O/ge{͛ھdT'3WjMm_mNK+5Gj~^/6UI%̕#5lySۗzSzҗ>~ߺZsν7:~3M#^mJ>\§ }1*>mmOIkDã\RB`)"KE$/q#*#C_:vL|꣱#M}zo_=ޫ>SW.(u8:~8~{?mGoG/Ƿo臨-?i@IDATWT%^ֶԶ%^jsjċsVڶ룷S^mSxq[Sۖx}vʫqt/q|k[~jNy9N_9omOm[)6ѩK8mmK>z;8:~8~{?mGoG/Ƿo臨-WT%^ֶԶ%^jsjċsVڶ룷S^mSxq[Sۖx}vʫqt/q|k[~jNy9N_9omOm[)׆roi~ 돚'ܿ{OKzڏIXű_#rAAՀ _R- `}Y$2t 4%2HzAp9;vA_p}*- ʃ+>w~I'lmk6Zѵ];)VBMU6*c{ϣ,zYյFٓl˴mcLj,mSn<)X-Osթ#/3>lɿ?rü0(\3hk[m׷)O%rL/Sm9F{orQ϶Lچv1mї%rk[o#FőM Q6LچڈN/M({6lm^N]*!M/S_ֱyg;7/N!wkő ߻=%:P+vS #z_yr⣧ [b+>Y8_A /6d&PՆ햾p_PQFŸ>_VYui;H||}/)9꫇~PR Ktk E^Yy}+OSSUW^ 9_󅺤ֱKg!j.\7,C>uIycK5jțc!eUֱK%ȃ 5W1K2O]R^%Aͅ+%rxH|է.)u.^:pwnu^^{e+6r.הܷ{o>ߚF[^ȵ]JnY?}C,iTVK}Ю}Cm%p[;7PTkPf,k 42!d"P_L>V&~ElI%> <&5v_~.|,ZL1E]t-{ݾR__֟yMk|-Y3Z:MV)fCoo}̈́7wȃIw|'$E{37l5cȶvGf?Wؑh_şZn BBFKjՑtƢ-zmdT^|_>=/v_~-Cq"џO]Ǝ.^\ BÄE.FLRFd0q/|kR{Co>+|?^xpׯ|qMG <9\Å'>27c՟8ڇ?*jK>|돆/ṫ Ó{Ï<h>:)|f?OM-Zׯ:oi?=Y~_߬Y{{lsy_]7*2ś #~6aC_?xUoӶvkG_~Mnd@o9 |IpAT.<%/A/A}J{ϋELL&)+ؙ /oń'ǯ>穧u%rM~ :o}r O6/9qCoέ}4ҋ3/4\S E?^_.ZnïW埽'n{o^.%sT,KW{iG`3/gۏG$_[I]>7x+K_'}&?2|1WܺqNϘԲQ>//o:g#ڒ?xM1kLjYH>o__cx;~*wLyOGEic7 G=_#SQߺzVvŇGBާK_~<BvvM c',6ڮn- 9>$<}H^ZCSmm& -+g̗O~X.ДPuڪ];\; >&hݲ(T{po OtexQ/o>_ oy}å7ֿW+[joc}Վ~owԯ >?u7{3/wo?뱊UǯKcV-GU?˾z=fuY} wRP}zպ> BjݼzKD$2G_w=z'Oͯʓ;P/uxs5+זz#n?>`}}=Kw\4oqW]使[ԥ%?<>LJj{}Sgߕ7GL|clnّ[X|26ki㷔! 0G[:Oz>ߪ%| X70Sm'mG&۟_~gx荏6=5%Oo\ss >]t3~m_Wo>?7Ϗb~_ma?׾7>F)~mH+}kϕg{vznA8|Im=Y1C,NdgimOk֓O>ںp;rizm~?}~'6>hgq~%|_"hAB[ BB ӎ?%Ay1ccr}>:ӿO\8C-՛hދZozSKaC?r$|~_dnx77f}#&ۯ~bxc7>:Xi?^c}7ƏWozM%ײc5jO?fZ{~ꉿ#kW݊ /e$&cWyJt? FSz/_c+8 Q,=qX3\gҽ7?}ȂJ^pw|GͫKotoGJ4|㋿;\}dCJv{ ?~c!ÚqrTiwdwe#|xxRMT6b(_+f6?ʃ_ ly6?Ŀ拼a}9W͏_拼a}9W͏_拼a}9W͏_拼a}9W͏;=ůz+kOS xճ BNׯb]j^7t7ŏ]*qޞ6)ZyT}֟?NeYNwʯjt޳)IVd{kH(ѱNOH?p>z]{ILu|5XVbR*ߎtvl֗ di mbҎ~HI:z]vh+~֚v|@ T\ƿh %jgpHfǫ32=? _ C~~}ؿ`hL}dz֙..|wi݉?*W>?~oxR@lx5vt_B^sl+_Y:)~K9]j)tm&w%2)c,)R_.a詛}ՙ[daXZS\KrI7O_\swF׽s3#Oxv(m7<ت/u -}@WaE{5v_q(eU~1dE@!txLjOk [ukP(KoXb+GJphGM|A?,[1\ktᣴ^荠37x=,X IW |_w ZWغ4g#AfGƦ?|~`E|p2}\k?=oiՅ\1pcJ{ݻW22ҨڨѾ:~}OO7+WD s%ϴms5ֳf5\7Sbk.d;U"IxGB=<;zˣC;%ԭm@|:<(Q;utH|l!ذʩNP VXC A_ګb`` G OL|lG7vG5ЕA9~P!ֲGmά>p |xr@G7uPJp5h)yo6J=~ҡ0_M|BӆިͲGrqqVnI |rsoohkK}4zwM]h◍Lm7'}Y*^o^~j?q-ŧ^yq_b 쨙L0_+7|富@dzZOgL3/̷cnj~'7WR֟?C_uv ?c`\S,.gGksw-M 8ӧO;2JDC;~ ~o߱F_Q}I o,&AԑC ۫?tS__m.6|'~]>/zpQ;U9/vyjky!Sa|ޛ;n(ҶɿcvCx>wyzC7Qi/Z-m7~_,kC9Zo(Nqrbo?ƯFnYo\2dúЯUZ7o=? Kp͚(ϝNq/n=^B)q2so^2Y7B9v}`a:Xǖu5mqzC uN|⻣.m\j ?oZ-4w%|/a{ߖnGц}Ͼ`m^~2rv5nIM(N%2:ܨ9漳D6xmG$ K%sĹ49漳D6xmG$ #΍11%:k;&?o |;yqon Qt}.~C]}d< nVJ}OC_{e@ӦҖ,_1`mRڰՏCK|Qb_G.IP}=}9fmcG҆X"ѬbWw0[~Kpߪ@7ٿyCnYAĵ:M9%Wtiа[[Q\ͷALˣ}_ug,O;̿?7w,g 613WȞ9ٗ1t.:,32̑>Z}qƫĎ?iO[sޏt{߯-Wov|JS^]tz|i?DO$xu $u^>c{M4`z<(DڋW`S])iٗ:\tsv󠔧MeȺzK/N~dtiO?Ƕzh{OyU=6ȫ}&}@>Ik`7NUG~Ҫw࿕hj9]ZGMO| _};C?zՖ:Pm؟: M#sD:kOt0{f>` 1е<)W:m%KGc/8ڃCO azQ37Yb?Oy?8|/rocfj0"YcƢ(5_=ZML~/J|p-V-q oH_{ zSwkM+.w/}iwjύ}Erױ g7YW[|#?0wc y~}<'I<Υ27ZG̝-ee+o߬$lZ֥}t}(Sn)nw}0%}6"JѧCo#Aݍ_L-[46{ܶƞ.E_%2P)# HllW9/MPԾ3,^Xiz<"aŧqW|({|.e_?>^džkoy 5/<_{ݻW϶l[n4L;-§vV{ro^%~ {Kǯz}`w'%9?2(wv*%,#Ӎ4Ν8Wska (#ټH֟?7c.ܿ!njԹksϦ]Ycwq/jN.ۇ|:`Nû29C/B}J?0U{ >|YS8y|Vħ?z| Jko?Q<y=&" -M}ӺKkS~V?To6}뙯_mG>펡_{˯6\zm_kG~뉾|gg~mһO4IRD%!JE BQH!%"E*B"D @H( HD` ;v]t~\߹~kZ{]vQk~9F͵'[OVqx~>7?>w7ﭴ>8T~V}c_OɧÛ`nW{R@ͧnSwi Rpk)G㻴ņ[{)G]bí#|.m^nm>h|pk/6r4K[lr[O9ߥ-6K9­ͧnSwi Rpk)G㻴ņ[{)G]bí#|.m^nm>h|pk/6r4K[lr[O9ߥ-6K9­ͧnSwi Rpk)G㻴ņ[{)G]bí#|.m^nm>h|pk/6r4K[lr[O9ߥ-6K9­ͧnSwi Rp?ܟsxk;Hmz޺{c~Z}?->o>"_)[ZmbYI#!M4>DdWu1kN} z/jцOg#ş=>zy ,=\n|[}WثZpE,7o_k [xOVRڽo}[?X_nW/fmW.*퓇yVo{ŷ3Jko7o^?r|Osv?w_[yLw?<iXΦ_7>Zs6q&e o\=KY;Lᣯ~fMa|oڷ?tx<ZiүÇS|n[[}_ۿ6GD+MG.ŐZ}Ͻˇ?t_^nV >w/?;|ㇷ#?LimzGoj G>yx=?pxWQ[t_~>_?|ona>O~GۿdܔIKq{]5}em6{]w-\K6{]s-\K6{]s-\K6{]s-\K6{]N/|sد7qMM'cnOu;{w1/·AeӅu}bHl᯾ۙbAE1J i)+#!kNud) %-}H>\0'5hhDC+~>=~=y3'7ăxpKUZR^nWIIOv[Z2~dsz&_'?s8Zud?Yخ4skrhגy\l'NOwΓ~#f"~i;^ێ}QtR8[v{ 9e,}-8Q ? c1+_¾8Io\'Z~(YI˯oR&] ۆgB`u/D|(6~_xүy}S?r ñ3'.cIx5O|p ѥFۿ^~UWK~y[C?W7̱YuSq(._|z<>; y_ ť3v<{u%mSӯp8]/WW<tLaڡ?|°nmlMֽtD@l6ǥ[~_䧇tZ㤜k^l|_nc\Ɨ|a}PM$Dé5!&ucN{G'=$ q$]X9:%g1a_a8\|nnPm== ^|n_{o~E|Kh$&IN:Q1_ʅ,zNg.#Ys5uƄ ^Ő¢6::߄/,}}΋վ>wRJ;;x*׍\sqq~5z}G<yg?9>v?E9|:_Wgv7V7zcZ呫d]C{/ysߺG1ncy/W~$k">oit8 wlݳdsQhٺ~ʙ>|q ͿIZڨ)Ǫ<;ߞ-=3KK:ry}|Ǝ珬lqh? u˻O.r|>._u>b(YU: HbGD66ʱ_v-<-#?V|q7Y57QfC^n~CaOwfSKHoS?\sy.>'n5ryg޿χ-qe޿ԬIϐZk>|?8aMԸ;FkMhM^aQ\1df _Z9 \gGayӭ%|m>tI7/i IKb$d(lNbWÉv»Pr诿 [ߺg)+>G:ote8=竇i1nwC&l|4D?*?<.2g<O 9s~Ez_{M1?s56nFQ^zeoasy*jg>AՖ?FW\m9U=KmbW->l<0 cwl5G"8l/m咓1}ja/ֽ?Lji`N4:7y?}uF[lhkL}[}|p-ϼs}o{}HgZo\ :Xe}%㺻á#ooGn/,}[={!qńGENȷ\4o."3H{M.&4XM:R :G{ĕ]_z/og}>r'.|~ }y~G^ |}yK_7@7"}x'R5C>xq>wsGzkw\_s4]S sZHg?]ks<3y^zgYx>BRҵħf~Gzziݭ5^ukINr~Su|/#Fևo>i.rudA|ֺ%bZ?+}8Ai2ZҤ\ $"C/oMN˿~{riQ$i)'sd_Gqca`a`a`^xlob}n>Nߚv?Dia9o|ĘogT~ԧN"7MY D^ IO:Z' .+y%9o|;;/l%v溍Fa`a`a`aq n^+v X[Ѹ1b4.6>?_r\_բ/Ρ>TiɓHfȃ$%1vW f7&\G\ɾ߉b/8=Ӈg\#<9éA[Ċ'sɜa`a`a`^t~Xg[7v\[+v/?ѷWW -;I}z8Ǻ^!7˱ 0>l|O|$U>\6l1#^ENڔ/ѰHwNmWRM_-́$la`a`a`a`տښۺuߠm{[_ $ݵ{Y}~pא9l{ڰME8DGE>rd3}cG2FN1%[׹y7|q_AtpV|c~o+|981!m<2 00 00 00 Ї,hkiklM'v6b[[lmWx)>M|mЗ/a1$ z7a4Y>" d3/0-vχ҅+voOƧ9 00 00 00 M \-w5:{i.]HGnoߣ&|9?x<i!r": +섎trj7^{̽>S_ U: -j,O5va`a`a`o/nmX?К^~_vC*?Ug #zI@IDATgxf> A9ÿWߤZ*I4!DDX6揠l[_brҥ/޸>U[(acy=>U}oϯ`S|ӆuGa`a`a`aWX꭯ω7iݞAc~6ړχ~.з|ۣih폄yPYXLg//|}XD,SƖG/.T#00 00 00 0pskrk୿%[k9m||j-~o8%+>]5V,|_q|`T=ʚ'ݝZ !DB#'iV:苗{>p"|&iղڷ~i}Gv[bWNwda`a`a`.0pwZiM\couzkfΧm{ghjxu>{5G7.Z}咷zXn>{jcH&%? [#LJ4|Nړ~'Q|b/gy!{|{|:kŧ=quwAyyaa`a`a`WZ[?nn= խ1eAN-᧿?x-ōl?ĴgXϏa4u=SD\ 4֚Tg}|"D#X#%Ю>]D`O+|>ta~'2 ?lņŶ 6a`a`a`=Wdm^uOŭ}u_K/&8lΖ?=i rt髅ͧroGZ99|wU'&_amd.G [_뤅Fj_Mٵ݄vyZ v܊mn߽Ctjda`a`a`AZov'dڼzu~ճs[·m϶?KmioԲ&orHKlw16g$PKDáwA~~l |$_O6}:9>lGQN!|6-Os>a\#7чwdnzoo5ƕͫ^zx+_~.-00 00 Ϗ\}fdM[ӵ@Ϟۺ0ȶndW??l3>rXgغ+Xc˧ 3 ?O8pm'(TT:-ɮUIi[=O,67IĞ>_nm՞.pũt߼q$>]޻ , 73oqx׎W6>я~~I%o|X|Fea`<(L?om^qзϏM|p][Lg 8M+X{G _}wO%%~$mȱg&dr5{O/Gqb7|NzC~ujq|6[/\&[7s 00 0<5σxXߚ\ߚ{]gۺoo/i%ڿ('| W'݊&7}jUR ">?_.cqHk,0銣GkV{0_%u?^^͘ta^WٛyW o1ob=4Óoa`b`>Ĵ0pZ` oc'~k}MuwXZ+7\7gů|jX?Vu_B@Xkȶ/Ο>L}z'=~y 46DD c1O?F՗'|Nwb '%aFvxa}𺍥՞Pa_ݳ3a`a`xsC89G6~q-nNo} btĄ/NJ? i_—~~W=ϐcwgQ>+(;zr"i=ikNyO^#? _ى_$+z#^ܝ_R78g90|#3a`a`x5/) ?^5I|zqloou_#M}ѯ~W~uIbWYZOGԗ;OhIȡ_ڴ)V/NB1&"`+}9k/M.&bb7E|mW_#ˀKk^y43',-gN 00 /:3Zkt.^A3\l᳗c?Gqjz^^ x*,aѦt'truqz_ c%F?r뤇C[ǷK.Ӈ֤kM=lÑaf୷:~Q#00 00 <桘< ,d=}iM\ZgkxѿC[ԋ7&qҞyW|X.$l_c^O+ tų8G1{tIE\8t|ʧ5\[ú%G'[,^7/|D||G'`a`a5e`Y;[OٯnAhyݦAr R DV$]̷I#:DgߺGz-g0v|a3R}%V9_ i ;ײ8'B$= ?W;0dcx> ~O˟{100 0a~׿~+ǟw~WZsaaO?k56n;g ۚ>_2Bk~}IEjh/>:5_ /~)|ŕ/loI~4*FK|}Q&a Ųu*\ ^_I~Nh҇ _y? MlD~r|ͧv1a?l^YULAey`\~Swxo\a`a l)|yR>S-R.ab~.GjtƗ+~?Gf>ssiٳENiO+)4n2Z8Tc yٍmP׋:W1w|1WM%{/}jy^>я>+wXx 00 0 {l3~?oY0/bګoe=nݦ+Mq~te]?Ǝ\79gKwvTW *ta4 WV?'D@t =k=ּ`Ww[|5qO1^tl޴,9a,900 <ߙ@7m0z~[>WIqK/88Y~M{'`}>M: &Ym.'L9IvQ=~t-W9MwGoܧۿ@=~a`a >[?c<,u92pS1|Iۮ-f^qxd`ij"_{|476b>^"CL7ik#ޘ4Z:q֮zQrD\8O_I GW_[# ߍ:Fnd 3և>7>/o1{츉pa`>h l)|yR>S&f~k_k;~ŔWBO/oykkuiN7 ݡ+6<ʳ|m4?coCn("l7l7 z~bau'F?ŗeB5_v/yʸ3p1-7qg?\?om<{a`a`xn |o|8~sC/=WW@8i{{ _=(x^ݴ_blE_-{Ҝ~ҋ)>imV%>?';;=(t#_t2"zUJr96 5H珰?w81<62  ˇ^{O&a`a`\PLNaQ|ppG{ wh/ng؆Ǽ鴄} 8̿3-gj}{I'^WAǫ`+KgGmCZ6Gj8|O>C.A>ܷvdx_Wz|?|a`2|vgn'Wk `wMjY│UuϝlHT;MM}ވZ7;Q ;}~[FԂ<>lu<~imK﨣eEgW^yW 9Xϋa`^ s͋qgϟo/ݴ0yr'[kǐ&Ǯz!u. =7q"d#̤]$ɦat!afg_]̾Y|V-wߺ#ˀ?O}^Ct42 |#9|ӟ>뇟{f\ ̑a`a`\ЌNa`,^/kŷ`-xy?V欮ƷbxO{x~TL@OrdBĸj'_1!(2ٸrט͈rviFsx;K_ 9k{7 a`ad`>׼}fl#w+>Hbu/} 6>F)ӊ KZVt/OU&ŪUM(Z|`ҕ/bđ|l6+6<w߭{RaEf;/~A(ٟ×߾zF'00 00 <\8v6 ܝ巗,7h/n-Ϻ߯mί}|n_~1D[uS)cOSA/7Yq#u/foVhM<*:/Yc]gk.X비NˑOg/|kō=>,~4濏eA~wrd~ g|~8??~g>sKǦ>kvc0800 0p_s}a X_[s뺿q1Nۚ]%_{ Mta;}Gy|V7#~/9uTgO-MiU\GR>tҭ6'ű#$W҅꨻ Wlr᳇_}y'kG##€Mݗ^z\ڀ߬[od_W/|'?c8^00 00 <3s3v ƀuuu55wJ5ֶ GI[K—AIw {{!b0êW*_{ɟN[l"J'X[Q6}&M6ŗo?a[+ΦN'͘QN}tm_ȭժOk 𵝜cuiϫ<+X+[;E_G~7<|8|_?[}cǍ(/@ݷo7>O_ymͫGy00 00 <{sͳx> ?Ƿv|{;Ѷf.omז]l{'h? qy ܺq k>qoL˥_]lp WUM$=|6R~1tb&؊V-/O[7׊qk[l;X.3aȲyeSaS˘o1WI|c˷lVr>00 00 'd3pwl`}~;m4Y[g;w'g_c˷|ao/y}bɊ_nccݟ|ZQ|h~wjbED6I~MN?"~">9L6r-|k)K_ C[{]_l1ը%-g>1ed.1E6jmL}8Hlji ,~6V|a`|yތДIim_c[/;5&!wyQ/%|8D=~ňc[W_l>[bK!MiIvmdo+Ƅ/; ?GSo?Xo5~kuۿ(Ϧ:ZkwS[&;?h,Mr9~'+~yq:{_J~ '\#ɭ&՘/HE:6q=nllN5r}H|7|ZMa`a`a`a`o`OIm~AK[x~=y4"Zk/󴢈oSN":8ҤkmnݣЉslݳ-|ir_^f_{|zy,oqXSuu+ot5>[~߽ Fa`a`a`a>/}k#ZgKGoMz>?Xu -p܄/_>6[84֊mc}Çǧ9'5:c1u'ç_um껋O+& I&dj#@1rmE/w)b.[/6iC}o`=a`a`a`a`x/M~{O_ړx?tlB7ڪyJr( +0'L>{hCO'ȕX/F?|Howo>? VΑ;s~7#00 00 00 0pl&D{if`nA{\om [ui&o|iwo8u9<?:Upthh|ɱNR&T8O>.r_-g?G76&Fa`a`a`ng~gdZw?ȵ_^[ eK} O%k&2Qvґf1tg5ʅbo5_.t܏8ůW/vWۛ?yda`a`a`7}c;[w\ޞ@C{ ok]Cn\܄KE_Ċfa/["ӊB*б(f￙mNq剸p6ѧ|Z1=?Zy׮y{ꆟ#. _?[XF00 00 00 {mm+omGkum}mIqjMoLϧ8^]%?CAio{^zZQI6qgӚ@ն &Й\mG"jӗO{ˇ yk;`ņ_~oݟOO~600 00 00 0B3K6O?׷W6Bcَ oS㴤W>0G{a`a`a`a}Gּu˿?_շٶ飵/Mkq:ZY>!o0ÿi#>9\nݼd6цWw);V*Bhc-lY^n|HOO'Zmu:wёD8z>Ogi믿Ko׶ݗ^z镭}iq[[V^]k/ĭ|;ÿc\Ng9}pw>.]-5]{>{>|>{ƽ;ھ?????g_lWn-+/S8[[mRisg]ӷߺ\Zri埮uݴGG>=q +b|vrj"#"Dۅ҆Vb`{ƭ_\yr>_ղu'׼k_,_‰l 7+WG/omx꜃6zIE_[~S}/O!Ԯ;rًI{Q$1pm?\s13y:yz[O{w\/m?7ʡ[c%gkn{WzoF|zrӦSI_qȣ7߹`|_w<{‡],iէͧ|y8_NG绩qJl\>YW=;]Mb미`>lG&#N7 N&n`?A6D7/z"jً *>as[kw~r|r%+~T7ϗ^};~KjkW|zck^fvXU}'XL[|:F}RuϾEo̯yT_.<޺G)b)g_g#y+~e'd{py~y\?S;_wϟ8qyL>^ۺf?ﳏV ">Qqo evWkIrdÇOgOrkç/vï>+>;[q0;oO~~[~+~yĊc _nqW|翸Kr5b=$ktJ)_46qVƛя^N =q+q079_8_cW[eӯ+)|<ڵŇb<Xjɶ?y~6g-Sʳm '1vӸ+&8]}GƖcŧǁ=>'#~xoy=czV!9Snz_;]Sf?sCgۥ[=~rsW|XH54+g9Y>@{u=˷#/6X6VpmNZy Cl|ζ9+o;rZ |kIm|W;jS|z%ZQHD RkbѯOW#"5Wbm>Lm}0>;_O63&#)F5-LvR->$Le/-Kt[q [ WkϊoVى>y^O$|vZ_#k.>o?}w?=~ޘGZFCV|1?3z?L熬{7?9 egK&[0:gb$VrXu|?o^t^l5SۑoyW׾8A7v}).IW= +~||k}{_.z8Y*΁KLU|6m_/u2`:!b8+Ƙ܄UEmy˵u7mj;|{fNtkwAAF[u?6雋<(?f}cvoG]]1m=ϟ G/7W ?itªl?w7Kg9=q{ƞFu_ż;~n`OZGLo,o\-zm#vr>Y+fŧ~57%ͅO3g'09|ߜZלaӭ+\W:I6%OS]r#t_cgj0/_5i ?1/᳗o1j&aѭZCF+] V./GI1ZXW⋹H`G3Pch;r$&~VV[>?$_/V%. Z>Z|ߺ~8 UbH_kR=bx>ѮK,媥=v|{Ҟ]ي1&K[cY>Fyϗu̧g<;y5?ߗ;{ ڛ#\ӭWO-|ï^=[\:b/j(^JV|vվ/N?+>x]|;֘pYv q-?Xۺ9ֆϧ|l-~Tx{I` CI`/R#&qBH,&ڕb>ҕO<n/=浩'dwQ_u[nWsY1Ð~ѓr|I<5^}>~/&uȷ9i? wa?]z?Oc>ߞ >Ytꉟ?Z}y/=[%bg\/.j[s/.|qD\~gW[}>_yV X ]-|5>{/lkMk}זo_ Zv*t#7_G0v4rU۽EҧThEyi51d=~/82"06әlE]o+>_O|b:kc}ǚC)7ėWK)ćYn-[j,=׮_%n_NӖJ9I\ztEge?q yxc޿m]Ә:iY"gR~c1j >|k)R}_LXavaS~x2f׆Ʈbw߯ |ʯS8|~0VmxxfV:bWp-%w@4)d7ȭ{aIɯVDJf“WۿDuIu¾+~sí{7&ѵ_S_AXlb ­-_|W\ńOGV>6d7tdG%,v~=//;ahU+k/͟/o_4@IDATs-a/bv}VNHϞyw?=-Oy>[_/OIZ:Ocut~^gc_|>fh~l>'g'ZMjyIOc,W5zY5Ϧ:iIr+v~߸hيawz+q[Kk4~'r]/<7ᇑV>'~"iQLEk+1 _OX>45\<7̧KqO+>{:O>7/j}OG#-6M⩸n*v}͓\ŧo\+~t~1XϿlN?E/CLJS^VMu^cOl'vů^>VMZ)W7c\Nm1Ç~gb)Z{1kn{˞&Zy*V tZuh]t_m5_rr隷1+̵_Nߜ#qI1t۾ĿH%+>{Z~ja#|{|rjA__u1*~/6|1r-IC>Z:$L5Ò#|x}mN9IQ3Y1yg wEmts'NUK7?6o>wu=o-OV|Hծy7αym%0S:[_ͩz7S+ugo6|s7,9K?\v17No\}{M[ fr݄ztdqDǗ4}myQU]xWmZcGBOKjCzg]\M 5^ s]?+Nw^yK=59s~xoW\/s䧿gŊ&媮7Cui:|56=~~\_Ly}[5%oR.c / ]_L7&翺 t|ט{|Wն[{K;Mt?zRsmͶOCNؽm'Zkwc7_϶b4:UW>y~~Q[ws=忶lӯlyi{h^qïyk˗oN$5>gyzq7k\џX]'9qG~w޿ל{sQ17o1_%_?+>]M|[1Ň){W]kmxeȯG'lOC5o>8'|ֺäs4=~צ .ٚcm.ڪMbUNm:>mcߘ#5jQSOJvS [e?ն6~DII4Ξ\;ar$b`!S?k%|8k7~Ԙ^,7iNos-+~r9_5'꥓Ћ66Y O ~ͩ\+6< {"ߚOai"g|Wᗬs?6v?||_l>뾟{Gb*D͕4~Ϳ81өCKfw3f[md+F_ DV_>Z_A9љxGcyij_c>V[|O?_\T3>{xss'n_s]ϭv &֤V"hrR1|%c-)qɧzur×/H /G:C.DZV=cbLg?c>rßN諏h5=~񤽮9vGX$ru$FKȷh%ۙ?yz>54L;11ϟ#/=]y޿'N3N_zS]izGijO_K/o[y­ޛn\zoekY˝/os6n_ą3򰇥/ٴg޶ƙ]]}I L)\v5b @lDAM/z4|k'> ??2ەf܊/ ¯jRk h+j7XWÙqc&~m䏷6gC"lo:k_\m%aOYO/_ņqoe9_RKOIO^ѽJoHKu ;a#?Ko̮c^L=<'=~?玸_g럟8&z]ӑf˧63 O $*F>OCu:#m?Tl[ +{sNGCt'xO~|\xl?>?můG?l%6 RxtI|ڤk'30g3G7_,kR~e?ocid߿>{:}y<֝+pi/~7_;x_k̇ohN]wղ1|;^n7{^OO߼~0җoa_h >8טO%U2oT-9b%];o8360FMj$q[榉u%i'x:DEĖSc6z-Z}䊟>{>bW捣5eXW;Cu<&䇿6n>s^WyoR|Z5ss~ӊ#l?Mkxsl3|竃ҺhU!}߿gc?Z>ykOd| qz/}c^>8-Շ/aПnz9aI'6]?bl<Ϧ'6⌧^Seơ_~ɯNIO8nADŽJDJ3.t\0Ml|&&?:CO6>vH@ё/oO| ?;ѽ#5pk7WtbÙ>< Ipìϯ\ ^?>b+ʟn7pIQaiܜő|6Yj?V{=9nHϛQo~e^ws?7?w}DcKG;j9Ĺ$8IFIg'9\n286..۫a@HZ{+i8YC}f\_L8|_gr%b_-&פog?B%Ͽ˸{ǟK>BG>/inqMfipo9_úOwOzFͥ'.#Z;7|&>:q+K]cѓ\ϹOgo]%{_߽o?{{uW[޿OcG?W`cϸ̩:'tpÛ/;'˝_KlZxsỎO_1 2fasn8LXc)+Vï6aK7cvR_x LR%͵<19.xU9'+z7 ` sha_ʼn!'?ůy)0M`'K\ud}qGsG-76poz5ő 'Nxbk/>+~zq6֊~]Qyv{!ƵbVx\C&?x~ǏMn}uK{6GNBy5_6<>$~c׬q-8pqZ~xWɼ_Mj ~XӒnq~=emǧ06L73O?}|aϕpß'Fyײ1^4߿4Kfo0&ϏOD_ٻ҇#џ/,zWaY_?_cWϟ~ԮZ5?{9ٽ~>=ޝqzm5?vO{ky|79|0gSЅytT?gkk o^.cW?7pI^No/6á7&3V?~l"&]Ҥ S铊Pq`M0C?_ي *?]sqhЭz뛻ux| ?;#Uk~҇Ρ4mRWgn+?v)6>+'֏ߘO^kX6{5c~>utn?o?oU֑Fѽ_?z"x:̹ wҾWK]X}f ͳ׹6fwo.p'o>&l̓>JGk50߰|—^UoŠ>YP1l|Zx[YWþXxÌgpϙ?g'l?[I8+w|a_j=.lm{ 96|͗'W~.>Il$tl?ko坲~x/W{:r~~Zq=9kL;?GS~?Xl7u,7m{\pL[b3&񊏏A1&7ʟKf<Â7}_c&WWsqM芡wo>+髓J})0&%ٍ#|WX6Fq|W m0M^+~hG/46ͿZo?{9˽~>j q'q\Ͽ)?}}ovwuǺIgZD o7+oCLb3$?G/4g0i㏘5-^ΫWv/{tB?l}~R 8msPA1[ؿE}"d7N'|؈?!$p]/fpL} o.?}~S:[9!_ -<i֟O؅SK׊+1N~? 9zo&hŅ_E?GW{~`_ECõ_u~yA=~|Ss~jXy~Xg0.drs,:~Z:R\g+FMĚô{3_ܵa/v'>W{o|†/VKE9G=οxnMr"r}-4Y>`-k ܜ컪o8?|gNg|υ_ѽ ]95kL'?05>gb/''b&??%i]O-I()yXӧo,-[?ϐ~z.Hw?k?g}#~=Ϙׯ1|Oa9׶'X!Q|HN^H~cܸtBG5/ 6[ծ.~8oaO&]{g\퇏 t01jGHzBUo//0p_Q6͹F}:ŭ&+81paO/}gOs.г'?l>8i?ԷCawtoW^Oo>{fg#Wc)x<=󿟿gc ϿwK>D_:b{?^Q D|b /C/ۺЕ_Άtd'}"?Ɠ?y7~/?OqZ΍_˧O'>{c.ۗľSL|U"KSKWhmݛTXn~h]wx7ɯnvm?$ؚ9p! S,v+?]xG&}YӥOW?.m9gQ]z.<'Si=|ZOjR]g<~>wcU'>0}+|ͭ6{|ac~Ḟ˿xˆfCu6Rl/|Ø/= OiZ}>IqZǏ7Gm??aOfaVvrwK%3y|M ~ 4q?TOgη ;Iql.9~a%ne?w~} ?ۼċ89n-= ?W@-M ɮ^{?=o笛:Ԩw�T0 5i{goђ>xuܚ7_yoCs?}fצk^+[?\򌿜W~x7x\qj{}<}}񰹌a'+l9}ٵ"K$PT!JE/?BW"+o8~6_m18Ks嗽9{Sp]Yg9%wxO&_lŽ?tg/o87gńs+|ts,f'㯞}]W?_0טLZ>88.}ѽ"/kɯ8zD|?{j?~qӮ?6Md3>'x\&aŇ?H|xɖ`jbT,ZzmXGnSH?sto}$ٟ7|[qE̸Xb 1;ghʟ_4|=;㊡qoppŗOs_ [4,Xاm??jS}z{~~<}yߟ>{=y kc>7y*տ1_G {q?_m!Ttro"Տ+ٳg?]9OOL~X3cΟ}?N쉟g|wHPaLjG{3';[>y8'|cGF1=S+1lOđƳޏ03:7Ր?/+;i=pO_WLc'}?t+?;awx}/7xjŸ]VltNW7qdk?Ӆž˟.;_R1?|(wɜtI'a|+-q~pI7_2}_/NAʭvo^1pʧ8mPt8oO?'?{1N~9K?|/>~[{FV~xٴk]d=~_ql_=ͮ^=_ZYνܞᲯw<_co8};j0'Mu=ooLͣw^ϾV1?./6W6W)mjL??<ى> S?lՌ_pw?lbî?;~G?7zl. =&XxI }H%,`ƤK2?X*~gހ|#DfS, [PC|U|NCu_:1+pHhŬOjM~,z%߹/̚~>gّ|9k~x"zg osߟahIqp}#gC|Y`I_Mϟ/|$p? Xѽ1r^[$ AJX#|Om8ڊݛ|˗=lSN "dO7y"/~3L>IpԊך{&/ԳϹ|?qow)q0'*ütvnyʯ{=}y?UF]y|+^Ԩ|us]^>Wb`1]3cx?K'D3}!xЇ6=LwOG?R~ک!8_:7s,V&*[e&Df(ƊB賑lq7.'>IƓq]ac-|Oo??d5xk㏷F?{~^;6~|kw{~޿Zc2$یI_k~eӒjߘ}αN0?݅i>?¾o޳/.cbjI7NW{UC}oO7y~췒Xu#*B&|g!F Ǘ[7ÑM+;/,b\GosbU'i8ͯ831'c~.sds?.+S ?+9+~3WŬo]~~vR){~bеc|><-򷜿<ωw^SfO{Ắ_"]si^nbɦes'{qt]咰n~K|O /yYgӊM=[~T\ ܮIϞE)t_͍yӟm8EO68V~BBW񳍓M\]ݟlo7qkF&χ[硺鴓?~ gr󘶏np/+W/&̉7q6롵~~^:j359߽Z+{/KZ'{ \GZt_͙M-7~g x-ѽ ǜwq3}>_f|8؈x?~>3χN&} pEStR1+>?|*pȗoo`/6~gN>e'a[c|++~܄_tOO֜yY|6rS ӇUK?a$vO="~X|7YW uWО;{ϟs_FgT0/ַ s[qNG?{ib>7}76n 9$l730_sG>͙x7Lmsx\17pn53IAwpMcx3%ślb6c?a?{9\ka߿><{+DywD;7b&bъs3q_`} ]")SQj7 Wa%6|ǫoT>?ڰ'c,n3|}OO8)Ӓ?W~qD1_Xt㟸+?xI'; Wuto1Z2K^Ϻvg]}uuϒV:w> mϙJG]x'Y=C=W{9C{ }uI>cgW=[c kN?Q߄GCtb2+11OOxN)o\{ڕ\é045&7v{漏;f#a׿).bvugGByC`ٲfZÞtן3żٛ8Ÿ?wWk[$~(bVP-QC_|[.an=XŅ_L7p|͗aѳ[ py/>|źHXg~xk>>v}-ۜ?O&n}vմ|G63]Ʃ%|*3rGj^=NG?V>gMvz{o?硽}s߿o1c?zk kuj.WSl-~vb[?ޑŕ|_^{tuvgNX5xZTj\7[s #}խ_ͩi{OO?G47x|tvKC}l1ɧJOύǙ{13p-p&?i_?=1UяWt 3{m';)=lt?m7|8NOqűֿKǾmI.01O \ٵ.ГyS{Pu3V~:X=g [tϗ'_?~OL8(80Nc7'{Ϟ~gOχ!p 9>&`ŭ7}77/RǮcMXߵj?Ox&WNdz?gEXkUy\ޙwh!}苡c'SP%~-{G\yȵe!aǟX\uo_L8s~MG&6ɯ"bfH-d?HJ,DFU|اNэ77EϏ6@z:<ݸl8NW~pto"eӆƯřgꮸ\È&866u鴮lG[C}7#bk◧>_}-L}Oݡ۪?] !~|W{o]<߳^ן~cγ>~߾{3~&qȿ~c^}G;|V'5316MZ/{6?0e⧇?7~ -? 0 a䗾G` 7U-~淌Wm7B)~R;# a{?U8S_ѺZ_~t!W0È86BO>WG/oy O_lz}W8qėlMOfqԦF?O{σ&m{.k:VypsAk>i3~οk1ɿk7~? +:>ys,?^vm? sxwjs᰹À?YM/fߊ$vy+2s*}ᱳ%|㏸?*ty;[#bquǷ6o+/҉d',{_?Iv3~6IiΓo_Ivd/QGw5h_?>yg?q.{ekѽٻǓF\-=ij]}4r܋?yz?vq;pg'M| ?=?7|]?(Z0znV+T1|C%Ղ _ O=7>}|ta3[<~'՟׸ŧ'{s|\7n.|ԝ3SEu?_L~a-?oŮů<.I@IDATg>cO;;׸ڣ]`Z<өG|ך# ԞavC`l볇71W?L?u.Σ{+bz+?=_N3{l=-{צ?\~?`U*@GO, ^_)n \}ys`O, ]3'}߂s.6qes_M56bHړߜI>{+~_7k&Dy<˞RYj߿w?qyٯ|X8g1&%tIb]Diڰ٦ntOa:?q=r͇g7xz^N8j׻7η~sN.3cC*XE:g(V|k VEZ,}ZEp3/G; C L:ka ս\_y3[j7ڸ?\`1_ˮ]/gͻ9m]gѳs~>osg._n=J'?3S>q矞??WB\ K?3&+?{k8/kݛ~Jww}k俕 8nW`n+ͫpt쳻Yp'6?] 6L~]QG.Ʈ_ '?[yȟOs.>vşMĬFVr9/vEXW'o?{~og}x{ϟh?[n(}Kntb:}| \k0ݿfŦӚiZu1a4ph|3v?0gظ''1KfEZ -kVntWG=(?|N]\҅G"^<N;|?(zZډvJs?g|x3~\bH1Stɟ}O_X2w9[>^7ͷv ZͿZo?{y_]?[wuӞ~>3q#uc|~-a_.[f?'~o/gďڤaWSilsoB窿]:e~x7OO"Ϳ۽󵟿|tstR{U~=cY߿7Xf[g Ϟ?>~>۫U-`T;-[Kߘ<3IcǗ4/;sCѺO7|a\szį&+^&',4*Z7^˦ek1^h'F6tuNKoǑm-g0[H曾:ewM~+|۠'|:S4g}WD9ņO?H?OZ݅U| {c| #LNg/lf3Η4Ԟ\>/}z_;8ĺşse#C4ٌ]$L ]'GWCvlgᅬw~ [{CM'W_3z.HzmJsS]+<`\Z.HkF;>ӓɏGL9??HsKK?}sE0;z|MG SljHS~2HM*_7'yO'> $|zN|q;y߽N~|ײϹ7'=otW>\96&w O^+~ w-?uZgc?shs '^5~}l07fwnwo+?gx\a'{~e\=t=ahL7_[Ҿ_)z3suᦈTkMws)֧k'W-N>3G?qxӕ~xGZɹa#}0?lm.xŖ_O|<7d[ 82\/&ѽI+iV~qO埾bcs5տZSq57?{gx]$1X[ѴWC}O܄n!ˏ޸炿8X8g,2_bm^$~  R}c/Z||cyVXz'gW~XtXUS|?ݚW0 n?XzwǞ~Ix/{#|ﯝ?gSw7KKqgW{E|&f>㕟>ҟI78_3_ytoſ{p6O_;1K'2&Pf)G/.}:1 ]bJa ?,ɯ?rlW_)?ȟ-Z[ëFno͟-⇱ ;ņ{6|W ,|W[<ͯg-w?s}uZ[{Q~~~珿1krvuôG,=W]+k?[\Fl-_ma5ίA~Åg:+i~x}a1'>!|JAWlZ6>YKGB77Iqx_+~~4Oz>[bOZqkq<㗟+W{/ 7?O~}? tƮϏ^\udK-q(-傑X )OWjjyiο˽mz?ѽIi]+w|/~~I7z>/N;6G:W\ѽ?[&x p&{-=)N/ʟo8m#l̉  UEӛ7c.'auSG3:8a矏~c5oao[Ǜx?⯝Owoґ9xÅ^S_|&b_s,0F #tӳmj~^}8ߙϟ}o;q>l}i3X[gaI~kx~$~|j 8Ж~;w0oE򯶓?xf?BLO 놐nbE͖1[7TBnnCuO=m7-=ڲbF{Ya&[c&?[X́7E1>n??Z8]I.:⧫G~?ko?syh/yuϣf{}?K+_8z߳>>HZ˖Ĝˇ>̟U>~b'O?~}F_<W]&?eL媞??~*-槀_kQg'I˖c|gN~qu +ي!|ӻ[G3#Xor>?鋏3~crχ)<&1}s䟼<_"aޜ[<O7Gܴlɟ8U7LU]~>>u^茽~<;;O}7nnb\d__?~wʿonӧO|p?ɮ'Ug7N|8dMv}wÏcOr8r*?ZJO),f7,[6K7 VL7PE_vI~bs{oakt`b??8{Y'>]yTYL׺mP˅/&?g I~{=h?߽~>ΫϜ9q'jӓ+'-#~ӗ70[0G>1WK OKV8[p_N| KK&㕟aoO18O,>~S'O'v'YׇS.#ꟿ1lX͟mxE8Zyhwͽm{9k}ϟ3<#<_|ax~k:5G><㭅f|Z9:'?[4?mńC^2Vz>bO1=1W?|>S( XP+g?b'\Ug#c7Əyl?bk .~g?7x?`1lƉko_1ly??߰fOyy7kjg߿w<[Z6x?zWu'=ϡ1V| [{ştqӫ+Lms_ 7X|i0}'~זK팟+~ajRI`(h7"76v͛O_<n$zϖ;'ٴyx?{ŗwb=NKEئ_5f%ǟW^[W? Wk?w=sUa?x7vN]vT}RǻwϾԽW}_o{M*q0f U~pЫ{3_lXt]? ]AN_}yo9rs{-ϛ>.XYM6ȯß{_qXWq8㋟NcnIpشdg_^+**X+Z:.&?\.x=úʟͼjԖg}cL<]7_uU+u$յ^{<{y{Z_Ux:/jA?v1ֻZ9?[_﹟I+^6q%?q؍ٛ1G߿aO\}O߼kQՋ+{/H"nM/)tnM߰M> v߸owY0ݛK]Kf+9׺_G/I+1ydukՅk]7~=>G\˺"{9]u_<>[-'~u険KkL~͹_I72X?Z>+|dzQl7Tn]7"[l|I ML>Zq-iן~-|Z-^;c|9N~!K?~6Wl|a8앟vR_pa'+[ԓą1;£?/f^{ڲ'<E2TdsWyC\9w\ȣ{:$֨?ۡYc )h_?}䟱_|t?/+~Oprjc07?+~ysƿ_!?c}".7GRGΟVzgq._gKG9o?<㧇C}#a9Ho ĸfz>lٵ$|ąmф=j[섞pW)W}OV~z301?ٮ_;oOs̮%+?Պ_ڰj*7GwÙWO;6^}`?vw@Y|ySPYcp>׿qjOo/]?h|NOɟoܮW\'+-GOxI_܌Wl id7M7dB'~-x. N gәq_лi=PZ̿\KGֿgqpW_N_n^?[sk|_?ͿZ|o..}Rͩ6^s?;¹'Oړޣ~ Îy>5_;ɧo WcÞSWp,x2@/wWṕr힔8>ʏoGuRLqTfQ?aatk{ ~s cNW3C=^ҟq NQVV]aʅ߬IW˭2c9|o?{~gagBk?k?ߞ/}#Ͼfc?I'?7wϷz?Twy/U:o).3t}ʯ6a{E2`"'WtHmq3wqqZŸėoN 0.#1?</)+l|5WiMZC_{뿸ڱ^Z?{{^9w?wk?޿x4|YQ5Gb⟝#{^﹉ΧS>+c6~+_/.nD7ȂNYӷ]zai-m}1=4񡃡MFb97g||8?;̙~|'l{o~6{goGs1ζwT:J{=׃gJ9~>}y~:x-ܿ3Ѻ'Zvн9u'Ef ?8>,vo%+z&_:횿L_Xoƿ$ ҍK7su5ΞzԊ=b'ekVnaM~+~ሻ'7^+S ,,_?G}\'* \lzkp~=|?c ~Zwٚ{F{^/OtŸO?Ï/[s_ __7X܋&|{Ɵ57_yGO߂bg'tWůOp__J_l!9}'{c#>|'38Oˉ5?pg7'O|pO~6>gc)^g|?{~k?k?o9m>Fq؞}5߫xP=?֑nUg'g/~qFdIh+"Ir=_VlKHQ5& ri}:ZL?L%@FƎyG׊ } CO/h߿~vǦrO3Ć4㏹+!ZqN 3?_W@c?9Ob{韖12h/s!cx i]Ke¯??;]5k]tCX5֞2|+s7(hyvyG}>9 3 -US>ϱ:ݿMVΧ 벿ο?v }i?w3 OR[- bbd XTV~DHwJ@?u\ys0;i=_MF߻.U'!ӊ?oݾƯr>w#c6=;tfJS\}hg3O޽?h-Z6K|g "@G"3c1}8K˘MZ3tslXx?DW\l}}A;oblQ91{c¿+?Οuf=Bܿ5?tS=߈=Ͻ>'k8/ ?׾?6~?{p\kz!26Xr6;O\u 4?xˇld-9:`S6F=t!*Cddu<ޛGCDNuḾ\|{˽9_ d)]s K-9B|/ʿ9lֺ<_Gzc}8[!xYs%k|}c;߷lX3p|z dm͖s+{ο_]_>g>Ѻ)ˣds\OA֌3eE7^ʿOX@t,L:#3տ~{QėϾ}fbLs=1}6G%"|]֦ %9WGp1[˿o}G_1U=qjZbտos_?V]q!.ӇO~H{9`(/>SardId`,d007k`%ȴK\ZHi:y-Σ Wkљ{r2yQڥYz_՟οsۯ?:;u w~K%XOo?֥ v^\?kYDA×G[// |f0̠bcp$I3=ΛIk]9&sb6<G9<rMݿs\zr@G9>Oyȵ%1}?kl[1PY5{ d<{>zxutv8-9u󬓞;Xa}e>|/992f_i!PYioK^}}-]&K 1oʙޡz%SӮkAY}xcDr2L|[x!z;`]i҇ B<'Mѱe>kZ+ZC׭/JX9fk-3=/?څOՖX)ZcG?_*{M8utʿrRx E/?|̡6w>s&ɣ側Kg~:Fsؚ҇ӿ9xb Cv/I+M\|[(s$` ,v1\xS:|A@@hѾ%iGwزOq #sBcBkz<_Է+3f3|L|cfMcN>>l2vѽm~xmgyXG_E,br'? ?c ,rZY=@8g@L@  }rW߫^μH No#AZ\D[՟CBgQO5u>1޻mm1-D ^mJOh×wQ }{ A,2#s 2x:<̕wt/ct0V׿iO[Ǘ?@՟o[gB?G?߆=!Zr@ͱ95B؆ØdMcmX~ "hI *h" xӟAH޲|| ӿr}hP_G"OГVfmw ʿg?y/>/VܕT>V ?v,߷|1U[]K>ݛX_@YѷВ6.ɃI@476?s|H>րo` H?ԏ63yKFÛ+1F3簏H5]Gb~gϺ__7Ͽ+}sןW?[ܜ1üG?s]gc/1|5;?\=O]=<rՑT[9ڤub 9O]xÃ} >?\7De9׏LGLѲ6mk5N>zAߪ՟o:۩ =7kaWtwƨ1k7_XW#|gwzmх:'X{s/ǹ/u.D ySb|~ ?us:ѿځ/t@$ رgE|g=3XRWyKә?W[hOA3 ꚾ-ke%֏ XW10w[p h1~4ʿO<:Oc;cM>c>|rPws<՟oOo_'}ٿ߶Y@k1o` :؟~lȏ5Z3ѝz52Ȃ\ɱ>W[G wуhxڢ?Z!ڷǾvNw;1j,  9ZTW~qBQĵ7b,ʿO7A搵H?lP[Z1q9 ?1s՟v~M̙=/zޏA<aB n[yjS}xk]6_Bax:=YG׀oq={?}z!t!ei< K0bW/1dgL>83+%b¸(ʿy՟@E1ĉLGlą:Fo:?5k{# ϴ]Z]ѽwO#Է?[C_yޏϳI@-eq / &sŻc<bΥ>^q-_Z٣Fсh㟽߻}+WUVR#!ΟݿvpN o گ:xwqֹ|}bAV9K3?z0aLpCɤ2Ց\}ku.|ϼ9>6mC񜯞2|!~=_>vw"f >4qBJP{?DiѻT}mY LL]~w )sXT_z_q՟oyޒ'Rw?}Gw-ݿbN{ƒ?}˻?ϟ~zhN1IfO09y1!]>s!̡GǞcsrKs=LJ{}㠉5R_g@:}w5?- >cXϱz㜣A=zYf?߿cdO_F$L6ZP`^Z,A&Aҏ'y_?kq bݮ<~Zc_f>śrct.ZdWd<؃_BgoOo_蝲yWyĊ}G 19A|} O4u<ȣu I5 aĪx?3CQ6‎As,8QE&F{? _X _d+]6TcVWLttZW/ne˝KO/冀 1?fuC> }<|H/q T*,՟=o@^tt#ba{͠t[;n:@bK??1j/q %DŤG'xGX|fx͜Auc&'"?ITC"t!>-ϼ(*gb6 OYtҞ?3fʿ3 c33_g c挵VsU;~L mϿq5W@Xl|=p'CXt{ 2cdd ƴ<\ua2~ßXc{+5o;ud[9=?OsŚb1&x?guΟ?bydl]ȀSwa_ir+JS>(W-dkAm@</^ 0Ocsui nC}91ru؄ Czb :\1.DJlĴ_1dlX&csŸ+%15c;.Nʿ1bUȖEؘS՟ꏵԘ b9OW̿+xE5q\ 'S~ >O E.:رçOvMDhߤ1R)2XwlUΟݿvf_y\b8߀5jxkxg `>vc.2;-<}ȷ6&.'ɣ ^/T/F_WS Οq~JhyvP1u9&.3ʿoMX8x&1LRf;eC B HJ^RS&RXCk? xX F51ي#o!4O1^f ;5h1WW If a font library you'd like to use is unavailable as a superqt plugin, please [open a feature request](https://github.com/pyapp-kit/superqt/issues/new/choose) ### Font Awesome 6 Browse available icons at ```bash pip install fonticon-fontawesome6 ``` ### Font Awesome 5 Browse available icons at ```bash pip install fonticon-fontawesome5 ``` ### Material Design Icons 7 Browse available icons at ```bash pip install fonticon-materialdesignicons7 ``` ### Material Design Icons 6 Browse available icons at (note that the search defaults to v7, see changes from v6 in [the changelog](https://pictogrammers.com/docs/library/mdi/releases/changelog/)) ```bash pip install fonticon-materialdesignicons6 ``` ### See also - - - `superqt.fonticon` is a pluggable system, and font icon packs may use the `"superqt.fonticon"` entry point to register themselves with superqt. See [`fonticon-cookiecutter`](https://github.com/tlambert03/fonticon-cookiecutter) for a template, or look through the following repos for examples: - - - ## API ::: superqt.fonticon.icon options: heading_level: 3 ::: superqt.fonticon.setTextIcon options: heading_level: 3 ::: superqt.fonticon.font options: heading_level: 3 ::: superqt.fonticon.IconOpts options: heading_level: 3 ::: superqt.fonticon.addFont options: heading_level: 3 ## Animations the `animation` parameter to `icon()` accepts a subclass of `Animation` that will be ::: superqt.fonticon.Animation options: heading_level: 3 ::: superqt.fonticon.pulse options: heading_level: 3 ::: superqt.fonticon.spin options: heading_level: 3 superqt-0.6.8/docs/utilities/index.md000066400000000000000000000044541463340017400176350ustar00rootroot00000000000000# Utilities ## Font Icons | Object | Description | | ----------- | --------------------- | | [`addFont`](./fonticon.md#superqt.fonticon.addFont) | Add an `OTF/TTF` file at to the font registry. | | [`font`](./fonticon.md#superqt.fonticon.font) | Create `QFont` for a given font-icon font family key | | [`icon`](./fonticon.md#superqt.fonticon.icon) | Create a `QIcon` for font-con glyph key | | [`setTextIcon`](./fonticon.md#superqt.fonticon.setTextIcon) | Set text on a `QWidget` to a specific font & glyph. | | [`IconFont`](./fonticon.md#superqt.fonticon.IconFont) | Helper class that provides a standard way to create an `IconFont`. | | [`IconOpts`](./fonticon.md#superqt.fonticon.IconOpts) | Options for rendering an icon | | [`Animation`](./fonticon.md#superqt.fonticon.Animation) | Base class for adding animations to a font-icon. | ## Threading tools | Object | Description | | ----------- | --------------------- | | [`ensure_main_thread`](./thread_decorators.md#ensure_main_thread) | Decorator that ensures a function is called in the main `QApplication` thread. | | [`ensure_object_thread`](./thread_decorators.md#ensure_object_thread) | Decorator that ensures a `QObject` method is called in the object's thread. | | [`FunctionWorker`](./threading.md#superqt.utils.FunctionWorker) | `QRunnable` with signals that wraps a simple long-running function. | | [`GeneratorWorker`](./threading.md#superqt.utils.GeneratorWorker) | `QRunnable` with signals that wraps a long-running generator. | | [`create_worker`](./threading.md#superqt.utils.create_worker) | Create a worker to run a target function in another thread. | | [`thread_worker`](./threading.md#superqt.utils.thread_worker) | Decorator for `create_worker`, turn a function into a worker. | ## Miscellaneous | Object | Description | | ----------- | --------------------- | | [`QMessageHandler`](./qmessagehandler.md) | A context manager to intercept messages from Qt. | | [`CodeSyntaxHighlight`](./code_syntax_highlight.md) | A `QSyntaxHighlighter` for code syntax highlighting. | | [`draw_colormap`](./cmap.md) | Function that draws a colormap into any QPaintDevice. | superqt-0.6.8/docs/utilities/qmessagehandler.md000066400000000000000000000002751463340017400216660ustar00rootroot00000000000000# QMessageHandler ::: superqt.utils.QMessageHandler options: heading_level: 3 show_signature_annotations: True docstring_style: numpy show_bases: False superqt-0.6.8/docs/utilities/signal_utils.md000066400000000000000000000000661463340017400212160ustar00rootroot00000000000000# Signal Utilities ::: superqt.utils.signals_blocked superqt-0.6.8/docs/utilities/thread_decorators.md000066400000000000000000000047251463340017400222230ustar00rootroot00000000000000# Threading decorators `superqt` provides two decorators that help to ensure that given function is running in the desired thread: ## `ensure_main_thread` `ensure_main_thread` ensures that the decorated function/method runs in the main thread ## `ensure_object_thread` `ensure_object_thread` ensures that a decorated bound method of a `QObject` runs in the thread in which the instance lives ([see qt documentation for details](https://doc.qt.io/qt-5/threads-qobject.html#accessing-qobject-subclasses-from-other-threads)). ## Usage By default, functions are executed asynchronously (they return immediately with an instance of [`concurrent.futures.Future`](https://docs.python.org/3/library/concurrent.futures.html#concurrent.futures.Future)). To block and wait for the result, see [Synchronous mode](#synchronous-mode) ```python from qtpy.QtCore import QObject from superqt import ensure_main_thread, ensure_object_thread @ensure_main_thread def sample_function(): print("This function will run in main thread") class SampleObject(QObject): def __init__(self): super().__init__() self._value = 1 @ensure_main_thread def sample_method1(self): print("This method will run in main thread") @ensure_object_thread def sample_method3(self): import time print("sleeping") time.sleep(1) print("This method will run in object thread") @property def value(self): print("return value") return self._value @value.setter @ensure_object_thread def value(self, value): print("this setter will run in object thread") self._value = value ``` As can be seen in this example these decorators can also be used for setters. These decorators should not be used as replacement of Qt Signals but rather to interact with Qt objects from non Qt code. ## Synchronous mode If you'd like for the program to block and wait for the result of your function call, use the `await_return=True` parameter, and optionally specify a timeout. !!! important Using synchronous mode may significantly impact performance. ```python from superqt import ensure_main_thread @ensure_main_thread def sample_function1(): return 1 @ensure_main_thread(await_return=True) def sample_function2(): return 2 assert sample_function1() is None assert sample_function2() == 2 # optionally, specify a timeout @ensure_main_thread(await_return=True, timeout=10000) def sample_function(): return 1 ``` superqt-0.6.8/docs/utilities/threading.md000066400000000000000000000021741463340017400204700ustar00rootroot00000000000000# Thread workers The objects in this module provide utilities for running tasks in a separate thread. In general (with the exception of `new_worker_qthread`), everything here wraps Qt's [QRunnable API](https://doc.qt.io/qt-6/qrunnable.html). The highest level object is the [`@thread_worker`][superqt.utils.thread_worker] decorator. It was originally written for `napari`, and was later extracted into `superqt`. You may also be interested in reading the [napari documentation](https://napari.org/stable/guides/threading.html#threading-in-napari-with-thread-worker) on this feature, which provides a more in-depth/introductory usage guide. For additional control, you can create your own [`FunctionWorker`][superqt.utils.FunctionWorker] or [`GeneratorWorker`][superqt.utils.GeneratorWorker] objects. ::: superqt.utils.WorkerBase ::: superqt.utils.FunctionWorker ::: superqt.utils.GeneratorWorker ## Convenience functions ::: superqt.utils.thread_worker options: heading_level: 3 ::: superqt.utils.create_worker options: heading_level: 3 ::: superqt.utils.new_worker_qthread options: heading_level: 3 superqt-0.6.8/docs/utilities/throttling.md000066400000000000000000000025421463340017400207200ustar00rootroot00000000000000# Throttling & Debouncing These utilities allow you to throttle or debounce a function. This is useful when you have a function that is called multiple times in a short period of time, and you want to make sure it is only "actually" called once (or at least no more than a certain frequency). For background on throttling and debouncing, see: - - ::: superqt.utils.qdebounced options: show_source: false docstring_style: numpy show_root_toc_entry: True show_root_heading: True ::: superqt.utils.qthrottled options: show_source: false docstring_style: numpy show_root_toc_entry: True show_root_heading: True ::: superqt.utils.QSignalDebouncer options: show_source: false docstring_style: numpy show_root_toc_entry: True show_root_heading: True ::: superqt.utils.QSignalThrottler options: show_source: false docstring_style: numpy show_root_toc_entry: True show_root_heading: True ::: superqt.utils._throttler.GenericSignalThrottler options: show_source: false docstring_style: numpy show_root_toc_entry: True show_root_heading: True superqt-0.6.8/docs/widgets/000077500000000000000000000000001463340017400156305ustar00rootroot00000000000000superqt-0.6.8/docs/widgets/colormap_catalog.md000066400000000000000000000015311463340017400214600ustar00rootroot00000000000000# CmapCatalogComboBox Searchable `QComboBox` variant that contains the [entire cmap colormap catalog](https://cmap-docs.readthedocs.io/en/latest/catalog/) !!! note "requires cmap" This widget uses the [cmap](https://cmap-docs.readthedocs.io/) library to provide colormaps. You can install it with: ```shell # use the `cmap` extra to include colormap support pip install superqt[cmap] ``` You can limit the colormaps shown by setting the `categories` or `interpolation` keyword arguments. ```python from qtpy.QtWidgets import QApplication from superqt.cmap import CmapCatalogComboBox app = QApplication([]) catalog_combo = CmapCatalogComboBox(interpolation="linear") catalog_combo.setCurrentText("viridis") catalog_combo.show() app.exec() ``` {{ show_widget(130) }} {{ show_members('superqt.cmap.CmapCatalogComboBox') }} superqt-0.6.8/docs/widgets/index.md000066400000000000000000000045451463340017400172710ustar00rootroot00000000000000# Widgets The following are QWidget subclasses: ## Sliders and Numerical Inputs | Widget | Description | | ----------- | --------------------- | | [`QDoubleRangeSlider`](./qdoublerangeslider.md) | Multi-handle slider for float values | | [`QDoubleSlider`](./qdoubleslider.md) | Slider for float values | | [`QLabeledDoubleRangeSlider`](./qlabeleddoublerangeslider.md) | `QDoubleRangeSlider` variant with editable labels for each handle | | [`QLabeledDoubleSlider`](./qlabeleddoubleslider.md) | `QSlider` for float values with editable `QSpinBox` with the current value | | [`QLabeledRangeSlider`](./qlabeledrangeslider.md) | `QRangeSlider` variant, with editable labels for each handle | | [`QLabeledSlider`](./qlabeledslider.md) | `QSlider` with editable `QSpinBox` that shows the current value | | [`QLargeIntSpinBox`](./qlargeintspinbox.md) | `QSpinbox` that accepts arbitrarily large integers | | [`QRangeSlider`](./qrangeslider.md) | Multi-handle slider | | [`QQuantity`](./qquantity.md) | Pint-backed quantity widget (magnitude combined with unit dropdown) | ## Labels and categorical inputs | Widget | Description | | ----------- | --------------------- | | [`QElidingLabel`](./qelidinglabel.md) | A `QLabel` variant that will elide text (add `…`) to fit width. | | [`QEnumComboBox`](./qenumcombobox.md) | `QComboBox` that populates the combobox from a python `Enum` | | [`QSearchableComboBox`](./qsearchablecombobox.md) | `QComboBox` variant that filters available options based on text input | | [`QSearchableListWidget`](./qsearchablelistwidget.md) | `QListWidget` variant with search field that filters available options | | [`QSearchableTreeWidget`](./qsearchabletreewidget.md) | `QTreeWidget` variant with search field that filters available options | | [`QColorComboBox`](./qcolorcombobox.md) | `QComboBox` to select from a specified set of colors | | [`QColormapComboBox`](./qcolormap.md) | `QComboBox` to select from a specified set of colormaps. | ## Frames and containers | Widget | Description | | ----------- | --------------------- | | [`QCollapsible`](./qcollapsible.md) | A collapsible widget to hide and unhide child widgets. | superqt-0.6.8/docs/widgets/qcollapsible.md000066400000000000000000000011021463340017400206160ustar00rootroot00000000000000# QCollapsible Collapsible `QFrame` that can be expanded or collapsed by clicking on the header. ```python from qtpy.QtWidgets import QApplication, QLabel, QPushButton from superqt import QCollapsible app = QApplication([]) collapsible = QCollapsible("Advanced analysis") collapsible.addWidget(QLabel("This is the inside of the collapsible frame")) for i in range(10): collapsible.addWidget(QPushButton(f"Content button {i + 1}")) collapsible.expand(animate=False) collapsible.show() app.exec_() ``` {{ show_widget(350) }} {{ show_members('superqt.QCollapsible') }} superqt-0.6.8/docs/widgets/qcolorcombobox.md000066400000000000000000000010301463340017400211740ustar00rootroot00000000000000# QColorComboBox `QComboBox` designed to select from a specific set of colors. ```python from qtpy.QtWidgets import QApplication from superqt import QColorComboBox app = QApplication([]) colors = QColorComboBox() colors.addColors(['red', 'green', 'blue']) # show an "Add Color" item that opens a QColorDialog when clicked colors.setUserColorsAllowed(True) # emits a QColor when changed colors.currentColorChanged.connect(print) colors.show() app.exec_() ``` {{ show_widget(100) }} {{ show_members('superqt.QColorComboBox') }} superqt-0.6.8/docs/widgets/qcolormap.md000066400000000000000000000036041463340017400201520ustar00rootroot00000000000000# QColormapComboBox `QComboBox` variant to select from a specific set of colormaps. !!! note "requires cmap" This widget uses the [cmap](https://cmap-docs.readthedocs.io/) library to provide colormaps. You can install it with: ```shell # use the `cmap` extra to include colormap support pip install superqt[cmap] ``` ### ColorMapLike objects Colormaps may be specified in a variety of ways, such as by name (string), an iterable of a color/color-like objects, or as a [`cmap.Colormap`][] instance. See [cmap documentation for details on all ColormapLike types](https://cmap-docs.readthedocs.io/en/latest/colormaps/#colormaplike-objects) ### Example ```python from cmap import Colormap from qtpy.QtWidgets import QApplication from superqt import QColormapComboBox app = QApplication([]) cmap_combo = QColormapComboBox() # see note above about colormap-like objects # as names from the cmap catalog cmap_combo.addColormaps(["viridis", "plasma", "magma", "gray"]) # as a sequence of colors, linearly interpolated cmap_combo.addColormap(("#0f0", "slateblue", "#F3A003A0")) # as a `cmap.Colormap` instance with custom name: cmap_combo.addColormap(Colormap(("green", "white", "orange"), name="MyMap")) cmap_combo.show() app.exec() ``` {{ show_widget(200) }} ### Style Customization Note that both the LineEdit and the dropdown can be styled to have the colormap on the left, or fill the entire width of the widget. To make the CombBox label colormap fill the entire width of the widget: ```python from superqt.cmap import QColormapLineEdit cmap_combo.setLineEdit(QColormapLineEdit()) ``` To make the CombBox dropdown colormaps fill less than the entire width of the widget: ```python from superqt.cmap import QColormapItemDelegate delegate = QColormapItemDelegate(fractional_colormap_width=0.33) cmap_combo.setItemDelegate(delegate) ``` {{ show_members('superqt.QColormapComboBox') }} superqt-0.6.8/docs/widgets/qdoublerangeslider.md000066400000000000000000000007121463340017400220250ustar00rootroot00000000000000# QDoubleRangeSlider Float variant of [`QRangeSlider`](qrangeslider.md). (see that page for more details). ```python from qtpy.QtCore import Qt from qtpy.QtWidgets import QApplication from superqt import QDoubleRangeSlider app = QApplication([]) slider = QDoubleRangeSlider(Qt.Orientation.Horizontal) slider.setRange(0, 1) slider.setValue((0.2, 0.8)) slider.show() app.exec_() ``` {{ show_widget() }} {{ show_members('superqt.QDoubleRangeSlider') }} superqt-0.6.8/docs/widgets/qdoubleslider.md000066400000000000000000000006171463340017400210140ustar00rootroot00000000000000# QDoubleSlider `QSlider` variant that accepts floating point values. ```python from qtpy.QtCore import Qt from qtpy.QtWidgets import QApplication from superqt import QDoubleSlider app = QApplication([]) slider = QDoubleSlider(Qt.Orientation.Horizontal) slider.setRange(0, 1) slider.setValue(0.5) slider.show() app.exec_() ``` {{ show_widget() }} {{ show_members('superqt.QDoubleSlider') }} superqt-0.6.8/docs/widgets/qelidinglabel.md000066400000000000000000000010641463340017400207470ustar00rootroot00000000000000# QElidingLabel `QLabel` variant that will elide text (i.e. add an ellipsis) if it is too long to fit in the available space. ```python from qtpy.QtWidgets import QApplication from superqt import QElidingLabel app = QApplication([]) widget = QElidingLabel( "a skj skjfskfj sdlf sdfl sdlfk jsdf sdlkf jdsf dslfksdl sdlfk sdf sdl " "fjsdlf kjsdlfk laskdfsal as lsdfjdsl kfjdslf asfd dslkjfldskf sdlkfj" ) widget.setWordWrap(True) widget.resize(300, 20) widget.show() app.exec_() ``` {{ show_widget(300) }} {{ show_members('superqt.QElidingLabel') }} superqt-0.6.8/docs/widgets/qenumcombobox.md000066400000000000000000000032611463340017400210320ustar00rootroot00000000000000# QEnumComboBox `QEnumComboBox` is a variant of [`QComboBox`](https://doc.qt.io/qt-5/qcombobox.html) that populates the items in the combobox based on a python `Enum` class. In addition to all the methods provided by `QComboBox`, this subclass adds the methods `enumClass`/`setEnumClass` to get/set the current `Enum` class represented by the combobox, and `currentEnum`/`setCurrentEnum` to get/set the current `Enum` member in the combobox. There is also a new signal `currentEnumChanged(enum)` analogous to `currentIndexChanged` and `currentTextChanged`. Method like `insertItem` and `addItem` are blocked and try of its usage will end with `RuntimeError` ```python from enum import Enum from qtpy.QtWidgets import QApplication from superqt import QEnumComboBox class SampleEnum(Enum): first = 1 second = 2 third = 3 app = QApplication([]) combo = QEnumComboBox() combo.setEnumClass(SampleEnum) combo.show() app.exec_() ``` {{ show_widget() }} Another option is to use optional `enum_class` argument of constructor and change ```python # option A: combo = QEnumComboBox() combo.setEnumClass(SampleEnum) # option B: combo = QEnumComboBox(enum_class=SampleEnum) ``` ## Allow `None` `QEnumComboBox` also allows using `Optional` type annotation: ```python from enum import Enum from superqt import QEnumComboBox class SampleEnum(Enum): first = 1 second = 2 third = 3 # as usual: # you must create a QApplication before create a widget. combo = QEnumComboBox() combo.setEnumClass(SampleEnum, allow_none=True) ``` In this case there is added option `----` and the `currentEnum()` method will return `None` when it is selected. {{ show_members('superqt.QEnumComboBox') }} superqt-0.6.8/docs/widgets/qlabeleddoublerangeslider.md000066400000000000000000000007561463340017400233460ustar00rootroot00000000000000# QLabeledDoubleRangeSlider Labeled Float variant of [`QRangeSlider`](qrangeslider.md). (see that page for more details). ```python from qtpy.QtCore import Qt from qtpy.QtWidgets import QApplication from superqt import QLabeledDoubleRangeSlider app = QApplication([]) slider = QLabeledDoubleRangeSlider(Qt.Orientation.Horizontal) slider.setRange(0, 1) slider.setValue((0.2, 0.8)) slider.show() app.exec_() ``` {{ show_widget() }} {{ show_members('superqt.QLabeledDoubleRangeSlider') }} superqt-0.6.8/docs/widgets/qlabeleddoubleslider.md000066400000000000000000000007411463340017400223230ustar00rootroot00000000000000# QLabeledDoubleSlider [`QDoubleSlider`](./qdoubleslider.md) variant that shows an editable (SpinBox) label next to the slider. ```python from qtpy.QtCore import Qt from qtpy.QtWidgets import QApplication from superqt import QLabeledDoubleSlider app = QApplication([]) slider = QLabeledDoubleSlider(Qt.Orientation.Horizontal) slider.setRange(0, 2.5) slider.setValue(1.3) slider.show() app.exec_() ``` {{ show_widget() }} {{ show_members('superqt.QLabeledDoubleSlider') }} superqt-0.6.8/docs/widgets/qlabeledrangeslider.md000066400000000000000000000012301463340017400221370ustar00rootroot00000000000000# QLabeledRangeSlider Labeled variant of [`QRangeSlider`](qrangeslider.md). (see that page for more details). ```python from qtpy.QtCore import Qt from qtpy.QtWidgets import QApplication from superqt import QLabeledRangeSlider app = QApplication([]) slider = QLabeledRangeSlider(Qt.Orientation.Horizontal) slider.setValue((20, 80)) slider.show() app.exec_() ``` {{ show_widget() }} {{ show_members('superqt.QLabeledRangeSlider') }} ---- If you find that you need to fine tune the position of the handle labels: - `QLabeledRangeSlider.label_shift_x`: adjust horizontal label position - `QLabeledRangeSlider.label_shift_y`: adjust vertical label position superqt-0.6.8/docs/widgets/qlabeledslider.md000066400000000000000000000006231463340017400211270ustar00rootroot00000000000000# QLabeledSlider `QSlider` variant that shows an editable (SpinBox) label next to the slider. ```python from qtpy.QtCore import Qt from qtpy.QtWidgets import QApplication from superqt import QLabeledSlider app = QApplication([]) slider = QLabeledSlider(Qt.Orientation.Horizontal) slider.setValue(42) slider.show() app.exec_() ``` {{ show_widget() }} {{ show_members('superqt.QLabeledSlider') }} superqt-0.6.8/docs/widgets/qlargeintspinbox.md000066400000000000000000000006411463340017400215440ustar00rootroot00000000000000# QLargeIntSpinBox `QSpinBox` variant that allows to enter large integers, without overflow. ```python from qtpy.QtCore import Qt from qtpy.QtWidgets import QApplication from superqt import QLargeIntSpinBox app = QApplication([]) slider = QLargeIntSpinBox() slider.setRange(0, 4.53e8) slider.setValue(4.53e8) slider.show() app.exec_() ``` {{ show_widget(150) }} {{ show_members('superqt.QLargeIntSpinBox') }} superqt-0.6.8/docs/widgets/qquantity.md000066400000000000000000000007501463340017400202130ustar00rootroot00000000000000# QQuantity A widget that allows the user to edit a quantity (a magnitude associated with a unit). !!! note This widget requires [`pint`](https://pint.readthedocs.io): ``` pip install pint ``` or ``` pip install superqt[quantity] ``` ```python from qtpy.QtWidgets import QApplication from superqt import QQuantity app = QApplication([]) w = QQuantity("1m") w.show() app.exec() ``` {{ show_widget(150) }} {{ show_members('superqt.QQuantity') }} superqt-0.6.8/docs/widgets/qrangeslider.md000066400000000000000000000163561463340017400206450ustar00rootroot00000000000000# QRangeSlider A multi-handle slider widget than can be used to select a range of values. ```python from qtpy.QtCore import Qt from qtpy.QtWidgets import QApplication from superqt import QRangeSlider app = QApplication([]) slider = QRangeSlider(Qt.Orientation.Horizontal) slider.setValue((20, 80)) slider.show() app.exec_() ``` {{ show_widget() }} - `QRangeSlider` inherits from [`QSlider`](https://doc.qt.io/qt-5/qslider.html) and attempts to match the Qt API as closely as possible - It uses platform-specific styles (for handle, groove, & ticks) but also supports QSS style sheets. - Supports mouse wheel events - Supports more than 2 handles (e.g. `slider.setValue([0, 10, 60, 80])`) As `QRangeSlider` inherits from [`QtWidgets.QSlider`](https://doc.qt.io/qt-5/qslider.html), you can use all of the same methods available in the [QSlider API](https://doc.qt.io/qt-5/qslider.html). The major difference is that `value()` and `sliderPosition()` are reimplemented as `tuples` of `int` (where the length of the tuple is equal to the number of handles in the slider.) These options are in addition to the Qt QSlider API, and control the behavior of the bar between handles. | getter | setter | type | default | description | | -------------------- | ------------------------------------------- | ------ | ------- | ------------------------------------------------------------------------------------------------ | | `barIsVisible` | `setBarIsVisible`
`hideBar` / `showBar` | `bool` | `True` | Whether the bar between handles is visible. | | `barMovesAllHandles` | `setBarMovesAllHandles` | `bool` | `True` | Whether clicking on the bar moves all handles or just the nearest | | `barIsRigid` | `setBarIsRigid` | `bool` | `True` | Whether bar length is constant or "elastic" when dragging the bar beyond min/max. | ### Screenshots ??? title "code that generates the images below" ```python import os from qtpy import QtCore from qtpy import QtWidgets as QtW # patch for Qt 5.15 on macos >= 12 os.environ["USE_MAC_SLIDER_PATCH"] = "1" from superqt import QRangeSlider # noqa QSS = """ QSlider { min-height: 20px; } QSlider::groove:horizontal { border: 0px; background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #888, stop:1 #ddd); height: 20px; border-radius: 10px; } QSlider::handle { background: qradialgradient(cx:0, cy:0, radius: 1.2, fx:0.35, fy:0.3, stop:0 #eef, stop:1 #002); height: 20px; width: 20px; border-radius: 10px; } QSlider::sub-page:horizontal { background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #227, stop:1 #77a); border-top-left-radius: 10px; border-bottom-left-radius: 10px; } QRangeSlider { qproperty-barColor: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #227, stop:1 #77a); } """ Horizontal = QtCore.Qt.Orientation.Horizontal class DemoWidget(QtW.QWidget): def __init__(self) -> None: super().__init__() reg_hslider = QtW.QSlider(Horizontal) reg_hslider.setValue(50) range_hslider = QRangeSlider(Horizontal) range_hslider.setValue((20, 80)) multi_range_hslider = QRangeSlider(Horizontal) multi_range_hslider.setValue((11, 33, 66, 88)) multi_range_hslider.setTickPosition(QtW.QSlider.TickPosition.TicksAbove) styled_reg_hslider = QtW.QSlider(Horizontal) styled_reg_hslider.setValue(50) styled_reg_hslider.setStyleSheet(QSS) styled_range_hslider = QRangeSlider(Horizontal) styled_range_hslider.setValue((20, 80)) styled_range_hslider.setStyleSheet(QSS) reg_vslider = QtW.QSlider(QtCore.Qt.Orientation.Vertical) reg_vslider.setValue(50) range_vslider = QRangeSlider(QtCore.Qt.Orientation.Vertical) range_vslider.setValue((22, 77)) tick_vslider = QtW.QSlider(QtCore.Qt.Orientation.Vertical) tick_vslider.setValue(55) tick_vslider.setTickPosition(QtW.QSlider.TicksRight) range_tick_vslider = QRangeSlider(QtCore.Qt.Orientation.Vertical) range_tick_vslider.setValue((22, 77)) range_tick_vslider.setTickPosition(QtW.QSlider.TicksLeft) szp = QtW.QSizePolicy.Maximum left = QtW.QWidget() left.setLayout(QtW.QVBoxLayout()) left.setContentsMargins(2, 2, 2, 2) label1 = QtW.QLabel("Regular QSlider Unstyled") label2 = QtW.QLabel("QRangeSliders Unstyled") label3 = QtW.QLabel("Styled Sliders (using same stylesheet)") label1.setSizePolicy(szp, szp) label2.setSizePolicy(szp, szp) label3.setSizePolicy(szp, szp) left.layout().addWidget(label1) left.layout().addWidget(reg_hslider) left.layout().addWidget(label2) left.layout().addWidget(range_hslider) left.layout().addWidget(multi_range_hslider) left.layout().addWidget(label3) left.layout().addWidget(styled_reg_hslider) left.layout().addWidget(styled_range_hslider) right = QtW.QWidget() right.setLayout(QtW.QHBoxLayout()) right.setContentsMargins(15, 5, 5, 0) right.layout().setSpacing(30) right.layout().addWidget(reg_vslider) right.layout().addWidget(range_vslider) right.layout().addWidget(tick_vslider) right.layout().addWidget(range_tick_vslider) self.setLayout(QtW.QHBoxLayout()) self.layout().addWidget(left) self.layout().addWidget(right) self.setGeometry(600, 300, 580, 300) self.activateWindow() self.show() if __name__ == "__main__": import sys from pathlib import Path dest = Path("screenshots") dest.mkdir(exist_ok=True) app = QtW.QApplication([]) demo = DemoWidget() if "-snap" in sys.argv: import platform QtW.QApplication.processEvents() demo.grab().save(str(dest / f"demo_{platform.system().lower()}.png")) else: app.exec_() ``` #### macOS ##### Catalina ![mac10](../images/demo_darwin10.png){ width=580; } ##### Big Sur ![mac11](../images/demo_darwin11.png){ width=580; } #### Windows ![window](../images/demo_windows.png) #### Linux ![linux](../images/demo_linux.png) {{ show_members('superqt.sliders._sliders._GenericRangeSlider') }} ## Type changes Note the following changes in types compared to the `QSlider` API: ```python value() -> Tuple[int, ...] ``` ```python setValue(val: Sequence[int]) -> None ``` ```python # Signal valueChanged(Tuple[int, ...]) ``` ```python sliderPosition() -> Tuple[int, ...] ``` ```python setSliderPosition(val: Sequence[int]) -> None ``` ```python sliderMoved(Tuple[int, ...]) ``` superqt-0.6.8/docs/widgets/qsearchablecombobox.md000066400000000000000000000010351463340017400221540ustar00rootroot00000000000000# QSearchableComboBox `QSearchableComboBox` is a variant of [`QComboBox`](https://doc.qt.io/qt-5/qcombobox.html) that allow to filter list of options by enter part of text. It could be drop in replacement for `QComboBox`. ```python from qtpy.QtWidgets import QApplication from superqt import QSearchableComboBox app = QApplication([]) combo = QSearchableComboBox() combo.addItems(["foo", "bar", "baz", "foobar", "foobaz", "barbaz"]) combo.show() app.exec_() ``` {{ show_widget() }} {{ show_members('superqt.QSearchableComboBox') }} superqt-0.6.8/docs/widgets/qsearchablelistwidget.md000066400000000000000000000015131463340017400225240ustar00rootroot00000000000000# QSearchableListWidget `QSearchableListWidget` is a variant of [`QListWidget`](https://doc.qt.io/qt-5/qlistwidget.html) that add text entry above list widget that allow to filter list of available options. Due to implementation details, this widget it does not inherit directly from [`QListWidget`](https://doc.qt.io/qt-5/qlistwidget.html) but it does fully satisfy its api. The only limitation is that it cannot be used as argument of [`QListWidgetItem`](https://doc.qt.io/qt-5/qlistwidgetitem.html) constructor. ```python from qtpy.QtWidgets import QApplication from superqt import QSearchableListWidget app = QApplication([]) slider = QSearchableListWidget() slider.addItems(["foo", "bar", "baz", "foobar", "foobaz", "barbaz"]) slider.show() app.exec_() ``` {{ show_widget() }} {{ show_members('superqt.QSearchableListWidget') }} superqt-0.6.8/docs/widgets/qsearchabletreewidget.md000066400000000000000000000015101463340017400225050ustar00rootroot00000000000000# QSearchableTreeWidget `QSearchableTreeWidget` combines a [`QTreeWidget`](https://doc.qt.io/qt-6/qtreewidget.html) and a `QLineEdit` for showing a mapping that can be searched by key. This is intended to be used with a read-only mapping and be conveniently created using `QSearchableTreeWidget.fromData(data)`. If the mapping changes, the easiest way to update this is by calling `setData`. ```python from qtpy.QtWidgets import QApplication from superqt import QSearchableTreeWidget app = QApplication([]) data = { "none": None, "str": "test", "int": 42, "list": [2, 3, 5], "dict": { "float": 0.5, "tuple": (22, 99), "bool": False, }, } tree = QSearchableTreeWidget.fromData(data) tree.show() app.exec_() ``` {{ show_widget() }} {{ show_members('superqt.QSearchableTreeWidget') }} superqt-0.6.8/examples/000077500000000000000000000000001463340017400150505ustar00rootroot00000000000000superqt-0.6.8/examples/code_highlight.py000066400000000000000000000012571463340017400203700ustar00rootroot00000000000000from qtpy.QtGui import QColor, QPalette from qtpy.QtWidgets import QApplication, QTextEdit from superqt.utils import CodeSyntaxHighlight app = QApplication([]) text_area = QTextEdit() highlight = CodeSyntaxHighlight(text_area.document(), "python", "monokai") palette = text_area.palette() palette.setColor(QPalette.Base, QColor(highlight.background_color)) text_area.setPalette(palette) text_area.setText( """from argparse import ArgumentParser def main(): parser = ArgumentParser() parser.add_argument("name", help="Your name") args = parser.parse_args() print(f"Hello {args.name}") if __name__ == "__main__": main() """ ) text_area.show() app.exec_() superqt-0.6.8/examples/color_combo_box.py000066400000000000000000000012061463340017400205660ustar00rootroot00000000000000from qtpy.QtGui import QColor from qtpy.QtWidgets import QApplication from superqt import QColorComboBox app = QApplication([]) w = QColorComboBox() # adds an item "Add Color" that opens a QColorDialog when clicked w.setUserColorsAllowed(True) # colors can be any argument that can be passed to QColor # (tuples and lists will be expanded to QColor(*color) COLORS = [QColor("red"), "orange", (255, 255, 0), "green", "#00F", "indigo", "violet"] w.addColors(COLORS) # as with addColors, colors will be cast to QColor when using setColors w.setCurrentColor("indigo") w.resize(200, 50) w.show() w.currentColorChanged.connect(print) app.exec_() superqt-0.6.8/examples/colormap_combo_box.py000066400000000000000000000007661463340017400212760ustar00rootroot00000000000000from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget from superqt.cmap import CmapCatalogComboBox, QColormapComboBox app = QApplication([]) wdg = QWidget() layout = QVBoxLayout(wdg) catalog_combo = CmapCatalogComboBox(interpolation="linear") selected_cmap_combo = QColormapComboBox(allow_user_colormaps=True) selected_cmap_combo.addColormaps(["viridis", "plasma", "magma", "inferno", "turbo"]) layout.addWidget(catalog_combo) layout.addWidget(selected_cmap_combo) wdg.show() app.exec() superqt-0.6.8/examples/demo_widget.py000066400000000000000000000077311463340017400177210ustar00rootroot00000000000000import os from qtpy import QtCore from qtpy import QtWidgets as QtW # patch for Qt 5.15 on macos >= 12 os.environ["USE_MAC_SLIDER_PATCH"] = "1" from superqt import QRangeSlider # noqa QSS = """ QSlider { min-height: 20px; } QSlider::groove:horizontal { border: 0px; background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #888, stop:1 #ddd); height: 20px; border-radius: 10px; } QSlider::handle { background: qradialgradient(cx:0, cy:0, radius: 1.2, fx:0.35, fy:0.3, stop:0 #eef, stop:1 #002); height: 20px; width: 20px; border-radius: 10px; } QSlider::sub-page:horizontal { background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #227, stop:1 #77a); border-top-left-radius: 10px; border-bottom-left-radius: 10px; } QRangeSlider { qproperty-barColor: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #227, stop:1 #77a); } """ Horizontal = QtCore.Qt.Orientation.Horizontal class DemoWidget(QtW.QWidget): def __init__(self) -> None: super().__init__() reg_hslider = QtW.QSlider(Horizontal) reg_hslider.setValue(50) range_hslider = QRangeSlider(Horizontal) range_hslider.setValue((20, 80)) multi_range_hslider = QRangeSlider(Horizontal) multi_range_hslider.setValue((11, 33, 66, 88)) multi_range_hslider.setTickPosition(QtW.QSlider.TickPosition.TicksAbove) styled_reg_hslider = QtW.QSlider(Horizontal) styled_reg_hslider.setValue(50) styled_reg_hslider.setStyleSheet(QSS) styled_range_hslider = QRangeSlider(Horizontal) styled_range_hslider.setValue((20, 80)) styled_range_hslider.setStyleSheet(QSS) reg_vslider = QtW.QSlider(QtCore.Qt.Orientation.Vertical) reg_vslider.setValue(50) range_vslider = QRangeSlider(QtCore.Qt.Orientation.Vertical) range_vslider.setValue((22, 77)) tick_vslider = QtW.QSlider(QtCore.Qt.Orientation.Vertical) tick_vslider.setValue(55) tick_vslider.setTickPosition(QtW.QSlider.TicksRight) range_tick_vslider = QRangeSlider(QtCore.Qt.Orientation.Vertical) range_tick_vslider.setValue((22, 77)) range_tick_vslider.setTickPosition(QtW.QSlider.TicksLeft) szp = QtW.QSizePolicy.Maximum left = QtW.QWidget() left.setLayout(QtW.QVBoxLayout()) left.setContentsMargins(2, 2, 2, 2) label1 = QtW.QLabel("Regular QSlider Unstyled") label2 = QtW.QLabel("QRangeSliders Unstyled") label3 = QtW.QLabel("Styled Sliders (using same stylesheet)") label1.setSizePolicy(szp, szp) label2.setSizePolicy(szp, szp) label3.setSizePolicy(szp, szp) left.layout().addWidget(label1) left.layout().addWidget(reg_hslider) left.layout().addWidget(label2) left.layout().addWidget(range_hslider) left.layout().addWidget(multi_range_hslider) left.layout().addWidget(label3) left.layout().addWidget(styled_reg_hslider) left.layout().addWidget(styled_range_hslider) right = QtW.QWidget() right.setLayout(QtW.QHBoxLayout()) right.setContentsMargins(15, 5, 5, 0) right.layout().setSpacing(30) right.layout().addWidget(reg_vslider) right.layout().addWidget(range_vslider) right.layout().addWidget(tick_vslider) right.layout().addWidget(range_tick_vslider) self.setLayout(QtW.QHBoxLayout()) self.layout().addWidget(left) self.layout().addWidget(right) self.setGeometry(600, 300, 580, 300) self.activateWindow() self.show() if __name__ == "__main__": import sys from pathlib import Path dest = Path("screenshots") dest.mkdir(exist_ok=True) app = QtW.QApplication([]) demo = DemoWidget() if "-snap" in sys.argv: import platform QtW.QApplication.processEvents() demo.grab().save(str(dest / f"demo_{platform.system().lower()}.png")) else: app.exec_() superqt-0.6.8/examples/double_slider.py000066400000000000000000000004161463340017400202370ustar00rootroot00000000000000from qtpy.QtCore import Qt from qtpy.QtWidgets import QApplication from superqt import QDoubleSlider app = QApplication([]) slider = QDoubleSlider(Qt.Orientation.Horizontal) slider.setRange(0, 1) slider.setValue(0.5) slider.resize(500, 50) slider.show() app.exec_() superqt-0.6.8/examples/eliding_label.py000066400000000000000000000005111463340017400201710ustar00rootroot00000000000000from qtpy.QtWidgets import QApplication from superqt import QElidingLabel app = QApplication([]) widget = QElidingLabel( "a skj skjfskfj sdlf sdfl sdlfk jsdf sdlkf jdsf dslfksdl sdlfk sdf sdl " "fjsdlf kjsdlfk laskdfsal as lsdfjdsl kfjdslf asfd dslkjfldskf sdlkfj" ) widget.setWordWrap(True) widget.show() app.exec_() superqt-0.6.8/examples/float.py000066400000000000000000000014431463340017400165310ustar00rootroot00000000000000from qtpy.QtCore import Qt from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget from superqt import QDoubleRangeSlider, QDoubleSlider, QRangeSlider app = QApplication([]) w = QWidget() sld1 = QDoubleSlider(Qt.Orientation.Horizontal) sld2 = QDoubleRangeSlider(Qt.Orientation.Horizontal) rs = QRangeSlider(Qt.Orientation.Horizontal) sld1.valueChanged.connect(lambda e: print("doubslider valuechanged", e)) sld2.setMaximum(1) sld2.setValue((0.2, 0.8)) sld2.valueChanged.connect(lambda e: print("valueChanged", e)) sld2.sliderMoved.connect(lambda e: print("sliderMoved", e)) sld2.rangeChanged.connect(lambda e, f: print("rangeChanged", (e, f))) w.setLayout(QVBoxLayout()) w.layout().addWidget(sld1) w.layout().addWidget(sld2) w.layout().addWidget(rs) w.show() w.resize(500, 150) app.exec_() superqt-0.6.8/examples/fonticon1.py000066400000000000000000000010071463340017400173200ustar00rootroot00000000000000try: from fonticon_fa5 import FA5S except ImportError as e: raise type(e)( "This example requires the fontawesome fontpack:\n\n" "pip install git+https://github.com/tlambert03/fonticon-fontawesome5.git" ) from qtpy.QtCore import QSize from qtpy.QtWidgets import QApplication, QPushButton from superqt.fonticon import icon, pulse app = QApplication([]) btn2 = QPushButton() btn2.setIcon(icon(FA5S.spinner, animation=pulse(btn2))) btn2.setIconSize(QSize(225, 225)) btn2.show() app.exec() superqt-0.6.8/examples/fonticon2.py000066400000000000000000000007101463340017400173210ustar00rootroot00000000000000try: from fonticon_fa5 import FA5S except ImportError as e: raise type(e)( "This example requires the fontawesome fontpack:\n\n" "pip install git+https://github.com/tlambert03/fonticon-fontawesome5.git" ) from qtpy.QtWidgets import QApplication, QPushButton from superqt.fonticon import setTextIcon app = QApplication([]) btn4 = QPushButton() btn4.resize(275, 275) setTextIcon(btn4, FA5S.hamburger) btn4.show() app.exec() superqt-0.6.8/examples/fonticon3.py000066400000000000000000000016521463340017400173300ustar00rootroot00000000000000try: from fonticon_fa5 import FA5S except ImportError as e: raise type(e)( "This example requires the fontawesome fontpack:\n\n" "pip install git+https://github.com/tlambert03/fonticon-fontawesome5.git" ) from qtpy.QtCore import QSize from qtpy.QtWidgets import QApplication, QPushButton from superqt.fonticon import IconOpts, icon, pulse, spin app = QApplication([]) btn = QPushButton() btn.setIcon( icon( FA5S.smile, color="blue", states={ "active": IconOpts( glyph_key=FA5S.spinner, color="red", scale_factor=0.5, animation=pulse(btn), ), "disabled": {"color": "green", "scale_factor": 0.8, "animation": spin(btn)}, }, ) ) btn.setIconSize(QSize(256, 256)) btn.show() @btn.clicked.connect def toggle_state(): btn.setChecked(not btn.isChecked()) app.exec() superqt-0.6.8/examples/generic.py000066400000000000000000000003531463340017400170370ustar00rootroot00000000000000from qtpy.QtCore import Qt from qtpy.QtWidgets import QApplication from superqt import QDoubleSlider app = QApplication([]) sld = QDoubleSlider(Qt.Orientation.Horizontal) sld.setRange(0, 1) sld.setValue(0.5) sld.show() app.exec_() superqt-0.6.8/examples/icon_explorer.py000066400000000000000000000323471463340017400203030ustar00rootroot00000000000000from qtpy import QtCore, QtGui, QtWidgets from qtpy.QtCore import Qt from superqt.fonticon._plugins import loaded P = loaded(load_all=True) if not P: print("you have no font packs loaded!") class GlyphDelegate(QtWidgets.QItemDelegate): def createEditor(self, parent, option, index): if index.column() < 2: edit = QtWidgets.QLineEdit(parent) edit.editingFinished.connect(self.emitCommitData) return edit comboBox = QtWidgets.QComboBox(parent) if index.column() == 2: comboBox.addItem("Normal") comboBox.addItem("Active") comboBox.addItem("Disabled") comboBox.addItem("Selected") elif index.column() == 3: comboBox.addItem("Off") comboBox.addItem("On") comboBox.activated.connect(self.emitCommitData) return comboBox def setEditorData(self, editor, index): if index.column() < 2: editor.setText(index.model().data(index)) return comboBox = editor if comboBox: pos = comboBox.findText( index.model().data(index), Qt.MatchFlag.MatchExactly ) comboBox.setCurrentIndex(pos) def setModelData(self, editor, model, index): if editor: text = editor.text() if index.column() < 2 else editor.currentText() model.setData(index, text) def emitCommitData(self): self.commitData.emit(self.sender()) class IconPreviewArea(QtWidgets.QWidget): def __init__(self, parent=None): super().__init__(parent) mainLayout = QtWidgets.QGridLayout() self.setLayout(mainLayout) self.icon = QtGui.QIcon() self.size = QtCore.QSize() self.stateLabels = [] self.modeLabels = [] self.pixmapLabels = [] self.stateLabels.append(self.createHeaderLabel("Off")) self.stateLabels.append(self.createHeaderLabel("On")) self.modeLabels.append(self.createHeaderLabel("Normal")) self.modeLabels.append(self.createHeaderLabel("Active")) self.modeLabels.append(self.createHeaderLabel("Disabled")) self.modeLabels.append(self.createHeaderLabel("Selected")) for j, label in enumerate(self.stateLabels): mainLayout.addWidget(label, j + 1, 0) for i, label in enumerate(self.modeLabels): mainLayout.addWidget(label, 0, i + 1) self.pixmapLabels.append([]) for j in range(len(self.stateLabels)): self.pixmapLabels[i].append(self.createPixmapLabel()) mainLayout.addWidget(self.pixmapLabels[i][j], j + 1, i + 1) def setIcon(self, icon): self.icon = icon self.updatePixmapLabels() def setSize(self, size): if size != self.size: self.size = size self.updatePixmapLabels() def createHeaderLabel(self, text): label = QtWidgets.QLabel(f"{text}") label.setAlignment(Qt.AlignmentFlag.AlignCenter) return label def createPixmapLabel(self): label = QtWidgets.QLabel() label.setEnabled(False) label.setAlignment(Qt.AlignmentFlag.AlignCenter) label.setFrameShape(QtWidgets.QFrame.Box) label.setSizePolicy( QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding ) label.setBackgroundRole(QtGui.QPalette.Base) label.setAutoFillBackground(True) label.setMinimumSize(132, 132) return label def updatePixmapLabels(self): for i in range(len(self.modeLabels)): if i == 0: mode = QtGui.QIcon.Mode.Normal elif i == 1: mode = QtGui.QIcon.Mode.Active elif i == 2: mode = QtGui.QIcon.Mode.Disabled else: mode = QtGui.QIcon.Mode.Selected for j in range(len(self.stateLabels)): state = {True: QtGui.QIcon.State.Off, False: QtGui.QIcon.State.On}[ j == 0 ] pixmap = self.icon.pixmap(self.size, mode, state) self.pixmapLabels[i][j].setPixmap(pixmap) self.pixmapLabels[i][j].setEnabled(not pixmap.isNull()) class MainWindow(QtWidgets.QMainWindow): def __init__(self, parent=None): super().__init__(parent) self.centralWidget = QtWidgets.QWidget() self.setCentralWidget(self.centralWidget) self.createPreviewGroupBox() self.createGlyphBox() self.createIconSizeGroupBox() mainLayout = QtWidgets.QGridLayout() mainLayout.addWidget(self.previewGroupBox, 0, 0, 1, 2) mainLayout.addWidget(self.glyphGroupBox, 1, 0) mainLayout.addWidget(self.iconSizeGroupBox, 1, 1) self.centralWidget.setLayout(mainLayout) self.setWindowTitle("Icons") self.otherRadioButton.click() self.resize(self.minimumSizeHint()) def changeSize(self): if self.otherRadioButton.isChecked(): extent = self.otherSpinBox.value() else: if self.smallRadioButton.isChecked(): metric = QtWidgets.QStyle.PixelMetric.PM_SmallIconSize elif self.largeRadioButton.isChecked(): metric = QtWidgets.QStyle.PixelMetric.PM_LargeIconSize elif self.toolBarRadioButton.isChecked(): metric = QtWidgets.QStyle.PixelMetric.PM_ToolBarIconSize elif self.listViewRadioButton.isChecked(): metric = QtWidgets.QStyle.PixelMetric.PM_ListViewIconSize elif self.iconViewRadioButton.isChecked(): metric = QtWidgets.QStyle.PixelMetric.PM_IconViewIconSize else: metric = QtWidgets.QStyle.PixelMetric.PM_TabBarIconSize extent = QtWidgets.QApplication.style().pixelMetric(metric) self.previewArea.setSize(QtCore.QSize(extent, extent)) self.otherSpinBox.setEnabled(self.otherRadioButton.isChecked()) def changeIcon(self): from superqt import fonticon icon = None for row in range(self.glyphTable.rowCount()): item0 = self.glyphTable.item(row, 0) item1 = self.glyphTable.item(row, 1) item2 = self.glyphTable.item(row, 2) item3 = self.glyphTable.item(row, 3) if item0.checkState() != Qt.CheckState.Checked: continue key = item0.text() if not key: continue if item2.text() == "Normal": mode = QtGui.QIcon.Mode.Normal elif item2.text() == "Active": mode = QtGui.QIcon.Mode.Active elif item2.text() == "Disabled": mode = QtGui.QIcon.Mode.Disabled else: mode = QtGui.QIcon.Mode.Selected color = item1.text() or None state = ( QtGui.QIcon.State.On if item3.text() == "On" else QtGui.QIcon.State.Off ) try: if icon is None: icon = fonticon.icon(key, color=color) else: icon.addState(state, mode, glyph_key=key, color=color) except Exception as e: print(e) continue if icon: self.previewArea.setIcon(icon) def createPreviewGroupBox(self): self.previewGroupBox = QtWidgets.QGroupBox("Preview") self.previewArea = IconPreviewArea() layout = QtWidgets.QVBoxLayout() layout.addWidget(self.previewArea) self.previewGroupBox.setLayout(layout) def createGlyphBox(self): self.glyphGroupBox = QtWidgets.QGroupBox("Glyphs") self.glyphGroupBox.setMinimumSize(480, 200) self.glyphTable = QtWidgets.QTableWidget() self.glyphTable.setSelectionMode(QtWidgets.QAbstractItemView.NoSelection) self.glyphTable.setItemDelegate(GlyphDelegate(self)) self.glyphTable.horizontalHeader().setDefaultSectionSize(100) self.glyphTable.setColumnCount(4) self.glyphTable.setHorizontalHeaderLabels(("Glyph", "Color", "Mode", "State")) self.glyphTable.horizontalHeader().setSectionResizeMode( 0, QtWidgets.QHeaderView.Stretch ) self.glyphTable.horizontalHeader().setSectionResizeMode( 1, QtWidgets.QHeaderView.Fixed ) self.glyphTable.horizontalHeader().setSectionResizeMode( 2, QtWidgets.QHeaderView.Fixed ) self.glyphTable.horizontalHeader().setSectionResizeMode( 3, QtWidgets.QHeaderView.Fixed ) self.glyphTable.verticalHeader().hide() self.glyphTable.itemChanged.connect(self.changeIcon) layout = QtWidgets.QVBoxLayout() layout.addWidget(self.glyphTable) self.glyphGroupBox.setLayout(layout) self.changeIcon() p0 = list(P)[-1] key = f"{p0}.{list(P[p0])[1]}" for _ in range(4): row = self.glyphTable.rowCount() self.glyphTable.setRowCount(row + 1) item0 = QtWidgets.QTableWidgetItem() item1 = QtWidgets.QTableWidgetItem() if _ == 0: item0.setText(key) # item0.setFlags(item0.flags() & ~Qt.ItemFlag.ItemIsEditable) item2 = QtWidgets.QTableWidgetItem("Normal") item3 = QtWidgets.QTableWidgetItem("Off") self.glyphTable.setItem(row, 0, item0) self.glyphTable.setItem(row, 1, item1) self.glyphTable.setItem(row, 2, item2) self.glyphTable.setItem(row, 3, item3) self.glyphTable.openPersistentEditor(item2) self.glyphTable.openPersistentEditor(item3) item0.setCheckState(Qt.CheckState.Checked) def createIconSizeGroupBox(self): self.iconSizeGroupBox = QtWidgets.QGroupBox("Icon Size") self.smallRadioButton = QtWidgets.QRadioButton() self.largeRadioButton = QtWidgets.QRadioButton() self.toolBarRadioButton = QtWidgets.QRadioButton() self.listViewRadioButton = QtWidgets.QRadioButton() self.iconViewRadioButton = QtWidgets.QRadioButton() self.tabBarRadioButton = QtWidgets.QRadioButton() self.otherRadioButton = QtWidgets.QRadioButton("Other:") self.otherSpinBox = QtWidgets.QSpinBox() self.otherSpinBox.setRange(8, 128) self.otherSpinBox.setValue(64) self.smallRadioButton.toggled.connect(self.changeSize) self.largeRadioButton.toggled.connect(self.changeSize) self.toolBarRadioButton.toggled.connect(self.changeSize) self.listViewRadioButton.toggled.connect(self.changeSize) self.iconViewRadioButton.toggled.connect(self.changeSize) self.tabBarRadioButton.toggled.connect(self.changeSize) self.otherRadioButton.toggled.connect(self.changeSize) self.otherSpinBox.valueChanged.connect(self.changeSize) otherSizeLayout = QtWidgets.QHBoxLayout() otherSizeLayout.addWidget(self.otherRadioButton) otherSizeLayout.addWidget(self.otherSpinBox) otherSizeLayout.addStretch() layout = QtWidgets.QGridLayout() layout.addWidget(self.smallRadioButton, 0, 0) layout.addWidget(self.largeRadioButton, 1, 0) layout.addWidget(self.toolBarRadioButton, 2, 0) layout.addWidget(self.listViewRadioButton, 0, 1) layout.addWidget(self.iconViewRadioButton, 1, 1) layout.addWidget(self.tabBarRadioButton, 2, 1) layout.addLayout(otherSizeLayout, 3, 0, 1, 2) layout.setRowStretch(4, 1) self.iconSizeGroupBox.setLayout(layout) self.changeStyle() def changeStyle(self, style=None): style = style or QtWidgets.QApplication.style().objectName() style = QtWidgets.QStyleFactory.create(style) if not style: return QtWidgets.QApplication.setStyle(style) self.setButtonText( self.smallRadioButton, "Small (%d x %d)", style, QtWidgets.QStyle.PixelMetric.PM_SmallIconSize, ) self.setButtonText( self.largeRadioButton, "Large (%d x %d)", style, QtWidgets.QStyle.PixelMetric.PM_LargeIconSize, ) self.setButtonText( self.toolBarRadioButton, "Toolbars (%d x %d)", style, QtWidgets.QStyle.PixelMetric.PM_ToolBarIconSize, ) self.setButtonText( self.listViewRadioButton, "List views (%d x %d)", style, QtWidgets.QStyle.PixelMetric.PM_ListViewIconSize, ) self.setButtonText( self.iconViewRadioButton, "Icon views (%d x %d)", style, QtWidgets.QStyle.PixelMetric.PM_IconViewIconSize, ) self.setButtonText( self.tabBarRadioButton, "Tab bars (%d x %d)", style, QtWidgets.QStyle.PixelMetric.PM_TabBarIconSize, ) self.changeSize() @staticmethod def setButtonText(button, label, style, metric): metric_value = style.pixelMetric(metric) button.setText(label % (metric_value, metric_value)) if __name__ == "__main__": import sys app = QtWidgets.QApplication(sys.argv) mainWin = MainWindow() mainWin.show() sys.exit(app.exec_()) superqt-0.6.8/examples/iconify.py000066400000000000000000000005251463340017400170640ustar00rootroot00000000000000from qtpy.QtCore import QSize from qtpy.QtWidgets import QApplication, QPushButton from superqt import QIconifyIcon app = QApplication([]) btn = QPushButton() # search https://icon-sets.iconify.design for available icon keys btn.setIcon(QIconifyIcon("fluent-emoji-flat:alarm-clock")) btn.setIconSize(QSize(60, 60)) btn.show() app.exec() superqt-0.6.8/examples/labeled_sliders.py000066400000000000000000000024111463340017400205350ustar00rootroot00000000000000from qtpy.QtCore import Qt from qtpy.QtWidgets import QApplication, QHBoxLayout, QVBoxLayout, QWidget from superqt import ( QLabeledDoubleRangeSlider, QLabeledDoubleSlider, QLabeledRangeSlider, QLabeledSlider, ) app = QApplication([]) ORIENTATION = Qt.Orientation.Horizontal w = QWidget() qls = QLabeledSlider(ORIENTATION) qls.setEdgeLabelMode(qls.EdgeLabelMode.LabelIsRange | qls.EdgeLabelMode.LabelIsValue) qls.valueChanged.connect(lambda e: print("qls valueChanged", e)) qls.setRange(0, 500) qls.setValue(300) qlds = QLabeledDoubleSlider(ORIENTATION) qlds.valueChanged.connect(lambda e: print("qlds valueChanged", e)) qlds.setRange(0, 1) qlds.setValue(0.5) qlds.setSingleStep(0.1) qlrs = QLabeledRangeSlider(ORIENTATION) qlrs.valueChanged.connect(lambda e: print("QLabeledRangeSlider valueChanged", e)) qlrs.setValue((20, 60)) qldrs = QLabeledDoubleRangeSlider(ORIENTATION) qldrs.valueChanged.connect(lambda e: print("qlrs valueChanged", e)) qldrs.setRange(0, 1) qldrs.setSingleStep(0.01) qldrs.setValue((0.2, 0.7)) w.setLayout( QVBoxLayout() if ORIENTATION == Qt.Orientation.Horizontal else QHBoxLayout() ) w.layout().addWidget(qls) w.layout().addWidget(qlds) w.layout().addWidget(qlrs) w.layout().addWidget(qldrs) w.show() w.resize(500, 150) app.exec_() superqt-0.6.8/examples/multihandle.py000066400000000000000000000003441463340017400177310ustar00rootroot00000000000000from qtpy.QtWidgets import QApplication from superqt import QRangeSlider app = QApplication([]) slider = QRangeSlider() slider.setMinimum(0) slider.setMaximum(200) slider.setValue((0, 40, 80, 160)) slider.show() app.exec_() superqt-0.6.8/examples/qcollapsible.py000066400000000000000000000007611463340017400201000ustar00rootroot00000000000000"""Example for QCollapsible.""" from qtpy.QtWidgets import QApplication, QLabel, QPushButton from superqt import QCollapsible app = QApplication([]) collapsible = QCollapsible("Advanced analysis") collapsible.setCollapsedIcon("+") collapsible.setExpandedIcon("-") collapsible.addWidget(QLabel("This is the inside of the collapsible frame")) for i in range(10): collapsible.addWidget(QPushButton(f"Content button {i + 1}")) collapsible.expand(animate=False) collapsible.show() app.exec_() superqt-0.6.8/examples/quantity.py000066400000000000000000000002101463340017400172710ustar00rootroot00000000000000from qtpy.QtWidgets import QApplication from superqt import QQuantity app = QApplication([]) w = QQuantity("1m") w.show() app.exec() superqt-0.6.8/examples/range_slider.py000066400000000000000000000003451463340017400200620ustar00rootroot00000000000000from qtpy.QtCore import Qt from qtpy.QtWidgets import QApplication from superqt import QRangeSlider app = QApplication([]) slider = QRangeSlider(Qt.Orientation.Horizontal) slider.setValue((20, 80)) slider.show() app.exec_() superqt-0.6.8/examples/searchable_combo_box.py000066400000000000000000000003511463340017400215410ustar00rootroot00000000000000from qtpy.QtWidgets import QApplication from superqt import QSearchableComboBox app = QApplication([]) slider = QSearchableComboBox() slider.addItems(["foo", "bar", "baz", "foobar", "foobaz", "barbaz"]) slider.show() app.exec_() superqt-0.6.8/examples/searchable_list_widget.py000066400000000000000000000003551463340017400221140ustar00rootroot00000000000000from qtpy.QtWidgets import QApplication from superqt import QSearchableListWidget app = QApplication([]) slider = QSearchableListWidget() slider.addItems(["foo", "bar", "baz", "foobar", "foobaz", "barbaz"]) slider.show() app.exec_() superqt-0.6.8/examples/searchable_tree_widget.py000066400000000000000000000007551463340017400221040ustar00rootroot00000000000000import logging from qtpy.QtWidgets import QApplication from superqt import QSearchableTreeWidget logging.basicConfig( level=logging.DEBUG, format="%(asctime)s : %(levelname)s : %(filename)s : %(message)s", ) data = { "none": None, "str": "test", "int": 42, "list": [2, 3, 5], "dict": { "float": 0.5, "tuple": (22, 99), "bool": False, }, } app = QApplication([]) tree = QSearchableTreeWidget.fromData(data) tree.show() app.exec_() superqt-0.6.8/examples/throttle_mouse_event.py000066400000000000000000000013701463340017400217010ustar00rootroot00000000000000from qtpy.QtCore import Signal from qtpy.QtWidgets import QApplication, QWidget from superqt.utils import qthrottled class Demo(QWidget): positionChanged = Signal(int, int) def __init__(self) -> None: super().__init__() self.setMouseTracking(True) self.positionChanged.connect(self._show_location) @qthrottled(timeout=400) # call this no more than once every 400ms def _show_location(self, x, y): print("Throttled event at", x, y) def mouseMoveEvent(self, event): print("real move event at", event.x(), event.y()) self.positionChanged.emit(event.x(), event.y()) if __name__ == "__main__": app = QApplication([]) w = Demo() w.resize(600, 600) w.show() app.exec_() superqt-0.6.8/examples/throttler_demo.py000066400000000000000000000221661463340017400204640ustar00rootroot00000000000000"""Adapted for python from the KDToolBox. https://github.com/KDAB/KDToolBox/tree/master/qt/KDSignalThrottler MIT License Copyright (C) 2019-2022 Klarälvdalens Datakonsult AB, a KDAB Group company, info@kdab.com Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ from typing import Deque from qtpy.QtCore import QRect, QSize, Qt, QTimer, Signal from qtpy.QtGui import QPainter, QPen from qtpy.QtWidgets import ( QApplication, QCheckBox, QComboBox, QFormLayout, QGroupBox, QHBoxLayout, QLabel, QPushButton, QSizePolicy, QSpinBox, QVBoxLayout, QWidget, ) from superqt.utils._throttler import ( GenericSignalThrottler, QSignalDebouncer, QSignalThrottler, ) class DrawSignalsWidget(QWidget): def __init__(self, parent=None): super().__init__(parent) self.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding) self.setAttribute(Qt.WA_OpaquePaintEvent) self._scrollTimer = QTimer(self) self._scrollTimer.setInterval(10) self._scrollTimer.timeout.connect(self._scroll) self._scrollTimer.start() self._signalActivations: Deque[int] = Deque() self._throttledSignalActivations: Deque[int] = Deque() def sizeHint(self): return QSize(400, 200) def addSignalActivation(self): self._signalActivations.appendleft(0) def addThrottledSignalActivation(self): self._throttledSignalActivations.appendleft(0) def _scroll(self): cutoff = self.width() self.scrollAndCut(self._signalActivations, cutoff) self.scrollAndCut(self._throttledSignalActivations, cutoff) self.update() def scrollAndCut(self, v: Deque[int], cutoff: int): L = len(v) for p in range(L): v[p] += 1 if v[p] > cutoff: break # TODO: fix this... delete old ones def paintEvent(self, event): p = QPainter(self) p.fillRect(self.rect(), Qt.white) h = self.height() h2 = h // 2 w = self.width() self._drawSignals(p, self._signalActivations, Qt.red, 0, h2) self._drawSignals(p, self._throttledSignalActivations, Qt.blue, h2, h) p.drawText( QRect(0, 0, w, h2), Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, "Source signal", ) p.drawText( QRect(0, h2, w, h2), Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, "Throttled signal", ) p.save() pen = QPen() pen.setWidthF(2.0) p.drawLine(0, h2, w, h2) p.restore() def _drawSignals(self, p: QPainter, v: Deque[int], color, yStart, yEnd): p.save() pen = QPen() pen.setWidthF(2.0) pen.setColor(color) p.setPen(pen) for i in v: p.drawLine(i, yStart, i, yEnd) p.restore() class DemoWidget(QWidget): signalToBeThrottled = Signal() _throttler: GenericSignalThrottler def __init__(self, parent=None) -> None: super().__init__(parent) self._createUi() self._throttler = None self._throttlerKindComboBox.currentIndexChanged.connect(self._createThrottler) self._createThrottler() self._throttlerTimeoutSpinBox.valueChanged.connect(self.setThrottlerTimeout) self.setThrottlerTimeout() self._mainButton.clicked.connect(self.signalToBeThrottled) self._autoTriggerTimer = QTimer(self) self._autoTriggerTimer.setTimerType(Qt.TimerType.PreciseTimer) self._autoTriggerCheckBox.clicked.connect(self._startOrStopAutoTriggerTimer) self._startOrStopAutoTriggerTimer() self._autoTriggerIntervalSpinBox.valueChanged.connect( self._setAutoTriggerTimeout ) self._setAutoTriggerTimeout() self._autoTriggerTimer.timeout.connect(self.signalToBeThrottled) self.signalToBeThrottled.connect(self._drawSignalsWidget.addSignalActivation) def _createThrottler(self) -> None: if self._throttler is not None: self._throttler.deleteLater() del self._throttler if self._throttlerKindComboBox.currentIndex() < 2: cls = QSignalThrottler else: cls = QSignalDebouncer if self._throttlerKindComboBox.currentIndex() % 2: policy = QSignalThrottler.EmissionPolicy.Leading else: policy = QSignalThrottler.EmissionPolicy.Trailing self._throttler: GenericSignalThrottler = cls(policy, self) self._throttler.setTimerType(Qt.TimerType.PreciseTimer) self.signalToBeThrottled.connect(self._throttler.throttle) self._throttler.triggered.connect( self._drawSignalsWidget.addThrottledSignalActivation ) self.setThrottlerTimeout() def setThrottlerTimeout(self): self._throttler.setTimeout(self._throttlerTimeoutSpinBox.value()) def _startOrStopAutoTriggerTimer(self): shouldStart = self._autoTriggerCheckBox.isChecked() if shouldStart: self._autoTriggerTimer.start() else: self._autoTriggerTimer.stop() self._autoTriggerIntervalSpinBox.setEnabled(shouldStart) self._autoTriggerLabel.setEnabled(shouldStart) def _setAutoTriggerTimeout(self): timeout = self._autoTriggerIntervalSpinBox.value() self._autoTriggerTimer.setInterval(timeout) def _createUi(self): helpLabel = QLabel(self) helpLabel.setWordWrap(True) helpLabel.setText( "

SignalThrottler example

" "

This example demonstrates the differences between " "the different kinds of signal throttlers and debouncers." ) throttlerKindGroupBox = QGroupBox("Throttler configuration", self) self._throttlerKindComboBox = QComboBox(throttlerKindGroupBox) self._throttlerKindComboBox.addItems( ( "Throttler, trailing", "Throttler, leading", "Debouncer, trailing", "Debouncer, leading", ) ) self._throttlerTimeoutSpinBox = QSpinBox(throttlerKindGroupBox) self._throttlerTimeoutSpinBox.setRange(1, 5000) self._throttlerTimeoutSpinBox.setValue(500) self._throttlerTimeoutSpinBox.setSuffix(" ms") layout = QFormLayout(throttlerKindGroupBox) layout.addRow("Kind of throttler:", self._throttlerKindComboBox) layout.addRow("Timeout:", self._throttlerTimeoutSpinBox) throttlerKindGroupBox.setLayout(layout) buttonGroupBox = QGroupBox("Throttler activation") self._mainButton = QPushButton(("Press me!"), buttonGroupBox) self._autoTriggerCheckBox = QCheckBox("Trigger automatically") autoTriggerLayout = QHBoxLayout() self._autoTriggerLabel = QLabel("Interval", buttonGroupBox) self._autoTriggerIntervalSpinBox = QSpinBox(buttonGroupBox) self._autoTriggerIntervalSpinBox.setRange(1, 5000) self._autoTriggerIntervalSpinBox.setValue(100) self._autoTriggerIntervalSpinBox.setSuffix(" ms") autoTriggerLayout.setContentsMargins(0, 0, 0, 0) autoTriggerLayout.addWidget(self._autoTriggerLabel) autoTriggerLayout.addWidget(self._autoTriggerIntervalSpinBox) layout = QVBoxLayout(buttonGroupBox) layout.addWidget(self._mainButton) layout.addWidget(self._autoTriggerCheckBox) layout.addLayout(autoTriggerLayout) buttonGroupBox.setLayout(layout) resultGroupBox = QGroupBox("Result") self._drawSignalsWidget = DrawSignalsWidget(resultGroupBox) layout = QVBoxLayout(resultGroupBox) layout.addWidget(self._drawSignalsWidget) resultGroupBox.setLayout(layout) layout = QVBoxLayout(self) layout.addWidget(helpLabel) layout.addWidget(throttlerKindGroupBox) layout.addWidget(buttonGroupBox) layout.addWidget(resultGroupBox) self.setLayout(layout) if __name__ == "__main__": app = QApplication([__name__]) w = DemoWidget() w.resize(600, 600) w.show() app.exec_() superqt-0.6.8/mkdocs.yml000066400000000000000000000023421463340017400152360ustar00rootroot00000000000000site_name: superqt site_url: https://github.com/pyapp-kit/superqt site_description: >- missing widgets and components for PyQt/PySide # Repository repo_name: pyapp-kit/superqt repo_url: https://github.com/pyapp-kit/superqt # Copyright copyright: Copyright © 2021 - 2022 watch: - src theme: name: material features: - navigation.instant - navigation.indexes - navigation.expand # - navigation.tracking # - navigation.tabs - search.highlight - search.suggest - content.code.copy markdown_extensions: - admonition - pymdownx.details - pymdownx.superfences - tables - attr_list - md_in_html - pymdownx.emoji: emoji_index: !!python/name:materialx.emoji.twemoji emoji_generator: !!python/name:materialx.emoji.to_svg - toc: permalink: "#" plugins: - search - autorefs - macros: module_name: docs/_macros - mkdocstrings: handlers: python: import: - https://docs.python.org/3/objects.inv - https://cmap-docs.readthedocs.io/en/latest/objects.inv options: show_source: false docstring_style: numpy show_root_toc_entry: True show_root_heading: True superqt-0.6.8/pyproject.toml000066400000000000000000000131051463340017400161460ustar00rootroot00000000000000# https://peps.python.org/pep-0517/ [build-system] requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" # https://peps.python.org/pep-0621/ [project] name = "superqt" description = "Missing widgets and components for PyQt/PySide" readme = "README.md" requires-python = ">=3.8" license = { text = "BSD 3-Clause License" } authors = [{ email = "talley.lambert@gmail.com", name = "Talley Lambert" }] keywords = [ "qt", "pyqt", "pyside", "widgets", "range slider", "components", "gui", ] classifiers = [ "Development Status :: 4 - Beta", "Environment :: X11 Applications :: Qt", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Topic :: Desktop Environment", "Topic :: Software Development :: User Interfaces", "Topic :: Software Development :: Widget Sets", ] dynamic = ["version"] dependencies = [ "pygments>=2.4.0", "qtpy>=1.1.0", "typing-extensions >=3.7.4.3,!=3.10.0.0", ] # extras # https://peps.python.org/pep-0621/#dependencies-optional-dependencies [project.optional-dependencies] test = ["pint", "pytest", "pytest-cov", "pytest-qt", "numpy", "cmap", "pyconify"] dev = [ "ipython", "ruff", "mypy", "pdbpp", "pre-commit", "pydocstyle", "rich", "types-Pygments", ] docs = ["mkdocs-macros-plugin", "mkdocs-material", "mkdocstrings[python]", "pint", "cmap"] quantity = ["pint"] cmap = ["cmap >=0.1.1"] pyside2 = ["pyside2"] # see issues surrounding usage of Generics in pyside6.5.x # https://github.com/pyapp-kit/superqt/pull/177 # https://github.com/pyapp-kit/superqt/pull/164 # https://bugreports.qt.io/browse/PYSIDE-2627 pyside6 = ["pyside6 !=6.5.0,!=6.5.1,!=6.6.2"] pyqt5 = ["pyqt5"] pyqt6 = ["pyqt6<6.7"] font-fa5 = ["fonticon-fontawesome5"] font-fa6 = ["fonticon-fontawesome6"] font-mi6 = ["fonticon-materialdesignicons6"] font-mi7 = ["fonticon-materialdesignicons7"] iconify = ["pyconify >=0.1.4"] [project.urls] Documentation = "https://pyapp-kit.github.io/superqt/" Source = "https://github.com/pyapp-kit/superqt" Tracker = "https://github.com/pyapp-kit/superqt/issues" Changelog = "https://github.com/pyapp-kit/superqt/blob/main/CHANGELOG.md" [tool.hatch.version] source = "vcs" [tool.hatch.build.targets.sdist] include = ["src", "tests", "CHANGELOG.md"] # these let you run tests across all backends easily with: # hatch run test:test [tool.hatch.envs.test] [tool.hatch.envs.test.scripts] test = "pytest" [[tool.hatch.envs.test.matrix]] qt = ["pyside6", "pyqt6"] python = ["3.11"] [[tool.hatch.envs.test.matrix]] qt = ["pyside2", "pyqt5", "pyqt5.12"] python = ["3.8"] [tool.hatch.envs.test.overrides] matrix.qt.extra-dependencies = [ {value = "pyside2", if = ["pyside2"]}, {value = "pyside6", if = ["pyside6"]}, {value = "pyqt5", if = ["pyqt5"]}, {value = "pyqt6", if = ["pyqt6"]}, {value = "pyqt5==5.12", if = ["pyqt5.12"]}, ] # https://github.com/charliermarsh/ruff [tool.ruff] line-length = 88 target-version = "py38" src = ["src", "tests"] # https://docs.astral.sh/ruff/rules [tool.ruff.lint] pydocstyle = { convention = "numpy" } select = [ "E", # style errors "W", # style warnings "F", # flakes "D", # pydocstyle "D417", # Missing argument descriptions in Docstrings "I", # isort "UP", # pyupgrade "C4", # flake8-comprehensions "B", # flake8-bugbear "A001", # flake8-builtins "RUF", # ruff-specific rules "TCH", # flake8-type-checking "TID", # flake8-tidy-imports ] ignore = [ "D104", # Missing docstring in public package "D401", # First line should be in imperative mood (remove to opt in) ] [tool.ruff.lint.per-file-ignores] "tests/*.py" = ["D", "S101"] "examples/demo_widget.py" = ["E501"] "examples/*.py" = ["B", "D"] # https://docs.astral.sh/ruff/formatter/ [tool.ruff.format] docstring-code-format = true # https://docs.pytest.org/en/6.2.x/customize.html [tool.pytest.ini_options] minversion = "6.0" testpaths = ["tests"] filterwarnings = [ "error", "ignore:Failed to disconnect::pytestqt", "ignore:QPixmapCache.find:DeprecationWarning:", "ignore:SelectableGroups dict interface:DeprecationWarning", "ignore:The distutils package is deprecated:DeprecationWarning", ] # https://mypy.readthedocs.io/en/stable/config_file.html [tool.mypy] files = "src/**/*.py" strict = true disallow_untyped_defs = false disallow_untyped_calls = false disallow_any_generics = false disallow_subclassing_any = false show_error_codes = true pretty = true exclude = ['tests/**/*'] [[tool.mypy.overrides]] module = ["superqt.qtcompat.*"] ignore_missing_imports = true warn_unused_ignores = false allow_redefinition = true # https://coverage.readthedocs.io/en/6.4/config.html [tool.coverage.run] source = ["superqt"] [tool.coverage.report] show_missing = true exclude_lines = [ "pragma: no cover", "if TYPE_CHECKING:", "@overload", "except ImportError", "\\.\\.\\.", "pass", ] # https://github.com/mgedmin/check-manifest#configuration [tool.check-manifest] ignore = [ ".github_changelog_generator", ".pre-commit-config.yaml", "tests/**/*", "src/superqt/_version.py", "mkdocs.yml", "docs/**/*", "examples/**/*", "CHANGELOG.md", "CONTRIBUTING.md", "codecov.yml", ".ruff_cache/**/*", ] superqt-0.6.8/src/000077500000000000000000000000001463340017400140215ustar00rootroot00000000000000superqt-0.6.8/src/superqt/000077500000000000000000000000001463340017400155245ustar00rootroot00000000000000superqt-0.6.8/src/superqt/__init__.py000066400000000000000000000034671463340017400176470ustar00rootroot00000000000000"""superqt is a collection of Qt components for python.""" from importlib.metadata import PackageNotFoundError, version from typing import TYPE_CHECKING, Any try: __version__ = version("superqt") except PackageNotFoundError: __version__ = "unknown" from .collapsible import QCollapsible from .combobox import QColorComboBox, QEnumComboBox, QSearchableComboBox from .elidable import QElidingLabel, QElidingLineEdit from .iconify import QIconifyIcon from .selection import QSearchableListWidget, QSearchableTreeWidget from .sliders import ( QDoubleRangeSlider, QDoubleSlider, QLabeledDoubleRangeSlider, QLabeledDoubleSlider, QLabeledRangeSlider, QLabeledSlider, QRangeSlider, ) from .spinbox import QLargeIntSpinBox from .utils import QMessageHandler, ensure_main_thread, ensure_object_thread __all__ = [ "ensure_main_thread", "ensure_object_thread", "QCollapsible", "QColorComboBox", "QColormapComboBox", "QDoubleRangeSlider", "QDoubleSlider", "QElidingLabel", "QElidingLineEdit", "QEnumComboBox", "QLabeledDoubleRangeSlider", "QIconifyIcon", "QLabeledDoubleSlider", "QLabeledRangeSlider", "QLabeledSlider", "QLargeIntSpinBox", "QMessageHandler", "QQuantity", "QRangeSlider", "QSearchableComboBox", "QSearchableListWidget", "QSearchableTreeWidget", ] if TYPE_CHECKING: from .combobox import QColormapComboBox # noqa: TCH004 from .spinbox._quantity import QQuantity # noqa: TCH004 def __getattr__(name: str) -> Any: if name == "QQuantity": from .spinbox._quantity import QQuantity return QQuantity if name == "QColormapComboBox": from .cmap import QColormapComboBox return QColormapComboBox raise AttributeError(f"module {__name__!r} has no attribute {name!r}") superqt-0.6.8/src/superqt/cmap/000077500000000000000000000000001463340017400164445ustar00rootroot00000000000000superqt-0.6.8/src/superqt/cmap/__init__.py000066400000000000000000000011641463340017400205570ustar00rootroot00000000000000try: import cmap except ImportError as e: raise ImportError( "The cmap package is required to use superqt colormap utilities. " "Install it with `pip install cmap` or `pip install superqt[cmap]`." ) from e else: del cmap from ._catalog_combo import CmapCatalogComboBox from ._cmap_combo import QColormapComboBox from ._cmap_item_delegate import QColormapItemDelegate from ._cmap_line_edit import QColormapLineEdit from ._cmap_utils import draw_colormap __all__ = [ "QColormapItemDelegate", "draw_colormap", "QColormapLineEdit", "CmapCatalogComboBox", "QColormapComboBox", ] superqt-0.6.8/src/superqt/cmap/_catalog_combo.py000066400000000000000000000067341463340017400217600ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING, Container from cmap import Colormap from qtpy.QtCore import Qt, Signal from qtpy.QtWidgets import QComboBox, QCompleter, QWidget from ._cmap_item_delegate import QColormapItemDelegate from ._cmap_line_edit import QColormapLineEdit from ._cmap_utils import try_cast_colormap if TYPE_CHECKING: from cmap._catalog import Category, Interpolation from qtpy.QtGui import QKeyEvent class CmapCatalogComboBox(QComboBox): """A combo box for selecting a colormap from the entire cmap catalog. Parameters ---------- parent : QWidget, optional The parent widget. prefer_short_names : bool, optional If True (default), short names (without the namespace prefix) will be preferred over fully qualified names. In cases where the same short name is used in multiple namespaces, they will *all* be referred to by their fully qualified (namespaced) name. categories : Container[Category], optional If provided, only return names from the given categories. interpolation : Interpolation, optional If provided, only return names that have the given interpolation method. """ currentColormapChanged = Signal(Colormap) def __init__( self, parent: QWidget | None = None, *, categories: Container[Category] = (), prefer_short_names: bool = True, interpolation: Interpolation | None = None, ) -> None: super().__init__(parent) # get valid names according to preferences word_list = sorted( Colormap.catalog().unique_keys( prefer_short_names=prefer_short_names, categories=categories, interpolation=interpolation, ) ) # initialize the combobox self.addItems(word_list) self.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) self.setEditable(True) self.setDuplicatesEnabled(False) # (must come before setCompleter) self.setLineEdit(QColormapLineEdit(self)) # setup the completer completer = QCompleter(word_list) completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion) completer.setFilterMode(Qt.MatchFlag.MatchContains) completer.setModel(self.model()) self.setCompleter(completer) # set the delegate for both the popup and the combobox delegate = QColormapItemDelegate() if popup := completer.popup(): popup.setItemDelegate(delegate) self.setItemDelegate(delegate) self.currentTextChanged.connect(self._on_text_changed) def currentColormap(self) -> Colormap | None: """Returns the currently selected Colormap or None if not yet selected.""" return try_cast_colormap(self.currentText()) def keyPressEvent(self, e: QKeyEvent | None) -> None: if e and e.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return): # select the first completion when pressing enter if the popup is visible if (completer := self.completer()) and completer.completionCount(): self.lineEdit().setText(completer.currentCompletion()) # type: ignore return super().keyPressEvent(e) def _on_text_changed(self, text: str) -> None: if (cmap := try_cast_colormap(text)) is not None: self.currentColormapChanged.emit(cmap) superqt-0.6.8/src/superqt/cmap/_cmap_combo.py000066400000000000000000000177201463340017400212630ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING, Any, Sequence from cmap import Colormap from qtpy.QtCore import Qt, Signal from qtpy.QtWidgets import ( QButtonGroup, QCheckBox, QComboBox, QDialog, QDialogButtonBox, QSizePolicy, QVBoxLayout, QWidget, ) from superqt.utils import signals_blocked from ._catalog_combo import CmapCatalogComboBox from ._cmap_item_delegate import QColormapItemDelegate from ._cmap_line_edit import QColormapLineEdit from ._cmap_utils import try_cast_colormap if TYPE_CHECKING: from cmap._colormap import ColorStopsLike CMAP_ROLE = Qt.ItemDataRole.UserRole + 1 class QColormapComboBox(QComboBox): """A drop down menu for selecting colors. Parameters ---------- parent : QWidget, optional The parent widget. allow_user_colormaps : bool, optional Whether the user can add custom colormaps by clicking the "Add Colormap..." item. Default is False. Can also be set with `setUserAdditionsAllowed`. add_colormap_text: str, optional The text to display for the "Add Colormap..." item. Default is "Add Colormap...". """ currentColormapChanged = Signal(Colormap) def __init__( self, parent: QWidget | None = None, *, allow_user_colormaps: bool = False, add_colormap_text: str = "Add Colormap...", ) -> None: # init QComboBox super().__init__(parent) self._add_color_text: str = add_colormap_text self._allow_user_colors: bool = allow_user_colormaps self._last_cmap: Colormap | None = None self.setLineEdit(_PopupColormapLineEdit(self)) self.lineEdit().setReadOnly(True) self.setItemDelegate(QColormapItemDelegate(self)) self.currentIndexChanged.connect(self._on_index_changed) # there's a little bit of a potential bug here: # if the user clicks on the "Add Colormap..." item # then an indexChanged signal will be emitted, but it may not # actually represent a "true" change in the index if they dismiss the dialog self.activated.connect(self._on_activated) self.setUserAdditionsAllowed(allow_user_colormaps) def userAdditionsAllowed(self) -> bool: """Returns whether the user can add custom colors.""" return self._allow_user_colors def setUserAdditionsAllowed(self, allow: bool) -> None: """Sets whether the user can add custom colors. If enabled, an "Add Colormap..." item will be added to the end of the list. When clicked, a dialog will be shown to allow the user to select a colormap from the [cmap catalog](https://cmap-docs.readthedocs.io/en/latest/catalog/). """ self._allow_user_colors = bool(allow) idx = self.findData(self._add_color_text, Qt.ItemDataRole.DisplayRole) if idx < 0: if self._allow_user_colors: self.addItem(self._add_color_text) elif not self._allow_user_colors: self.removeItem(idx) def clear(self) -> None: super().clear() self.setUserAdditionsAllowed(self._allow_user_colors) def itemColormap(self, index: int) -> Colormap | None: """Returns the color of the item at the given index.""" return self.itemData(index, CMAP_ROLE) def addColormap(self, cmap: ColorStopsLike) -> None: """Adds the colormap to the QComboBox.""" if (_cmap := try_cast_colormap(cmap)) is None: raise ValueError(f"Invalid colormap value: {cmap!r}") for i in range(self.count()): if item := self.itemColormap(i): if item.name == _cmap.name: return # no duplicates # pragma: no cover had_items = self.count() > int(self._allow_user_colors) # add the new color and set the background color of that item self.addItem(_cmap.name.rsplit(":", 1)[-1]) self.setItemData(self.count() - 1, _cmap, CMAP_ROLE) if not had_items: # first item added self._on_index_changed(self.count() - 1) # make sure the "Add Colormap..." item is last idx = self.findData(self._add_color_text, Qt.ItemDataRole.DisplayRole) if idx >= 0: with signals_blocked(self): self.removeItem(idx) self.addItem(self._add_color_text) def addColormaps(self, colors: Sequence[Any]) -> None: """Adds colors to the QComboBox.""" for color in colors: self.addColormap(color) def currentColormap(self) -> Colormap | None: """Returns the currently selected Colormap or None if not yet selected.""" return self.currentData(CMAP_ROLE) def setCurrentColormap(self, color: Any) -> None: """Adds the color to the QComboBox and selects it.""" if not (cmap := try_cast_colormap(color)): raise ValueError(f"Invalid colormap value: {color!r}") for idx in range(self.count()): if (item := self.itemColormap(idx)) and item.name == cmap.name: self.setCurrentIndex(idx) def _on_activated(self, index: int) -> None: if self.itemText(index) != self._add_color_text: return dlg = _CmapNameDialog(self, Qt.WindowType.Sheet) if dlg.exec() and (cmap := dlg.combo.currentColormap()): # add the color and select it, without adding duplicates for i in range(self.count()): if (item := self.itemColormap(i)) and cmap.name == item.name: self.setCurrentIndex(i) return self.addColormap(cmap) self.currentIndexChanged.emit(self.currentIndex()) elif self._last_cmap is not None: # user canceled, restore previous color without emitting signal idx = self.findData(self._last_cmap, CMAP_ROLE) if idx >= 0: with signals_blocked(self): self.setCurrentIndex(idx) def _on_index_changed(self, index: int) -> None: colormap = self.itemData(index, CMAP_ROLE) if isinstance(colormap, Colormap): self.currentColormapChanged.emit(colormap) self.lineEdit().setColormap(colormap) self._last_cmap = colormap CATEGORIES = ("sequential", "diverging", "cyclic", "qualitative", "miscellaneous") class _CmapNameDialog(QDialog): def __init__(self, *args: Any) -> None: super().__init__(*args) self.combo = CmapCatalogComboBox() B = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel btns = QDialogButtonBox(B) btns.accepted.connect(self.accept) btns.rejected.connect(self.reject) layout = QVBoxLayout(self) layout.addWidget(self.combo) self._btn_group = QButtonGroup(self) self._btn_group.setExclusive(False) for cat in CATEGORIES: box = QCheckBox(cat) self._btn_group.addButton(box) box.setChecked(True) box.toggled.connect(self._on_check_toggled) layout.addWidget(box) layout.addWidget(btns) self.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Maximum) self.resize(self.sizeHint()) def _on_check_toggled(self) -> None: # get valid names according to preferences word_list = Colormap.catalog().unique_keys( prefer_short_names=True, categories={b.text() for b in self._btn_group.buttons() if b.isChecked()}, ) self.combo.clear() self.combo.addItems(sorted(word_list)) class _PopupColormapLineEdit(QColormapLineEdit): def mouseReleaseEvent(self, _: Any) -> None: """Show parent popup when clicked. Without this, only the down arrow will show the popup. And if mousePressEvent is used instead, the popup will show and then immediately hide. """ parent = self.parent() if parent and hasattr(parent, "showPopup"): parent.showPopup() superqt-0.6.8/src/superqt/cmap/_cmap_item_delegate.py000066400000000000000000000077311463340017400227550ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING, cast from qtpy.QtCore import QModelIndex, QObject, QPersistentModelIndex, QRect, QSize, Qt from qtpy.QtGui import QColor, QPainter from qtpy.QtWidgets import QStyle, QStyledItemDelegate, QStyleOptionViewItem from ._cmap_utils import CMAP_ROLE, draw_colormap, pick_font_color, try_cast_colormap if TYPE_CHECKING: from cmap import Colormap DEFAULT_SIZE = QSize(80, 22) DEFAULT_BORDER_COLOR = QColor(Qt.GlobalColor.transparent) class QColormapItemDelegate(QStyledItemDelegate): """Delegate that draws colormaps into a QAbstractItemView item. Parameters ---------- parent : QObject, optional The parent object. item_size : QSize, optional The size hint for each item, by default QSize(80, 22). fractional_colormap_width : float, optional The fraction of the widget width to use for the colormap swatch. If the colormap is full width (greater than 0.75), the swatch will be drawn behind the text. Otherwise, the swatch will be drawn to the left of the text. Default is 0.33. padding : int, optional The padding (in pixels) around the edge of the item, by default 1. checkerboard_size : int, optional Size (in pixels) of the checkerboard pattern to draw behind colormaps with transparency, by default 4. If 0, no checkerboard is drawn. """ def __init__( self, parent: QObject | None = None, *, item_size: QSize = DEFAULT_SIZE, fractional_colormap_width: float = 1, padding: int = 1, checkerboard_size: int = 4, ) -> None: super().__init__(parent) self._item_size = item_size self._colormap_fraction = fractional_colormap_width self._padding = padding self._border_color: QColor | None = DEFAULT_BORDER_COLOR self._checkerboard_size = checkerboard_size def sizeHint( self, option: QStyleOptionViewItem, index: QModelIndex | QPersistentModelIndex ) -> QSize: return super().sizeHint(option, index).expandedTo(self._item_size) def paint( self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex | QPersistentModelIndex, ) -> None: self.initStyleOption(option, index) rect = cast("QRect", option.rect) # type: ignore selected = option.state & QStyle.StateFlag.State_Selected # type: ignore text = index.data(Qt.ItemDataRole.DisplayRole) colormap: Colormap | None = index.data(CMAP_ROLE) or try_cast_colormap(text) if not colormap: # pragma: no cover return super().paint(painter, option, index) painter.save() rect.adjust(self._padding, self._padding, -self._padding, -self._padding) cmap_rect = QRect(rect) cmap_rect.setWidth(int(rect.width() * self._colormap_fraction)) lighter = 110 if selected else 100 border = self._border_color if selected else None draw_colormap( painter, colormap, cmap_rect, lighter=lighter, border_color=border, checkerboard_size=self._checkerboard_size, ) # # make new rect with the remaining space text_rect = QRect(rect) if self._colormap_fraction > 0.75: text_align = Qt.AlignmentFlag.AlignCenter alpha = 230 if selected else 140 text_color = pick_font_color(colormap, alpha=alpha) else: text_align = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter text_color = QColor(Qt.GlobalColor.black) text_rect.adjust( cmap_rect.width() + self._padding + 4, 0, -self._padding - 2, 0 ) painter.setPen(text_color) # cast to int works all the way back to Qt 5.12... # but the enum only works since Qt 5.14 painter.drawText(text_rect, int(text_align), text) painter.restore() superqt-0.6.8/src/superqt/cmap/_cmap_line_edit.py000066400000000000000000000126451463340017400221210ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING, Any from qtpy.QtCore import QRect, Qt from qtpy.QtGui import QIcon, QPainter, QPaintEvent, QPalette from qtpy.QtWidgets import QApplication, QLineEdit, QStyle, QWidget from ._cmap_utils import draw_colormap, pick_font_color, try_cast_colormap if TYPE_CHECKING: from cmap import Colormap MISSING = QStyle.StandardPixmap.SP_TitleBarContextHelpButton class QColormapLineEdit(QLineEdit): """A QLineEdit that shows a colormap swatch. When the current text is a valid colormap name from the `cmap` package, a swatch of the colormap will be shown to the left of the text (if `fractionalColormapWidth` is less than .75) or behind the text (for when the colormap fills the full width). If the current text is not a valid colormap name, a swatch of the fallback colormap will be shown instead (by default, a gray colormap) if `fractionalColormapWidth` is less than .75. Parameters ---------- parent : QWidget, optional The parent widget. fractional_colormap_width : float, optional The fraction of the widget width to use for the colormap swatch. If the colormap is full width (greater than 0.75), the swatch will be drawn behind the text. Otherwise, the swatch will be drawn to the left of the text. Default is 0.33. fallback_cmap : Colormap | str | None, optional The colormap to use when the current text is not a recognized colormap. by default "gray". missing_icon : QIcon | QStyle.StandardPixmap, optional The icon to show when the current text is not a recognized colormap and `fractionalColormapWidth` is less than .75. Default is a question mark. checkerboard_size : int, optional Size (in pixels) of the checkerboard pattern to draw behind colormaps with transparency, by default 4. If 0, no checkerboard is drawn. """ def __init__( self, parent: QWidget | None = None, *, fractional_colormap_width: float = 0.33, fallback_cmap: Colormap | str | None = "gray", missing_icon: QIcon | QStyle.StandardPixmap = MISSING, checkerboard_size: int = 4, ) -> None: super().__init__(parent) self.setFractionalColormapWidth(fractional_colormap_width) self.setMissingColormap(fallback_cmap) self._checkerboard_size = checkerboard_size if isinstance(missing_icon, QStyle.StandardPixmap): self._missing_icon: QIcon = self.style().standardIcon(missing_icon) elif isinstance(missing_icon, QIcon): self._missing_icon = missing_icon else: # pragma: no cover raise TypeError("missing_icon must be a QIcon or QStyle.StandardPixmap") self._cmap: Colormap | None = None # current colormap self.textChanged.connect(self.setColormap) def setFractionalColormapWidth(self, fraction: float) -> None: self._colormap_fraction: float = float(fraction) align = Qt.AlignmentFlag.AlignVCenter if self._cmap_is_full_width(): align |= Qt.AlignmentFlag.AlignCenter else: align |= Qt.AlignmentFlag.AlignLeft self.setAlignment(align) def fractionalColormapWidth(self) -> float: return self._colormap_fraction def setMissingColormap(self, cmap: Colormap | str | None) -> None: self._missing_cmap: Colormap | None = try_cast_colormap(cmap) def colormap(self) -> Colormap | None: return self._cmap def setColormap(self, cmap: Colormap | str | None) -> None: self._cmap = try_cast_colormap(cmap) # set self font color to contrast with the colormap if self._cmap and self._cmap_is_full_width(): text = pick_font_color(self._cmap) else: text = QApplication.palette().color(QPalette.ColorRole.Text) palette = self.palette() palette.setColor(QPalette.ColorRole.Text, text) self.setPalette(palette) def _cmap_is_full_width(self): return self._colormap_fraction >= 0.75 def _cmap_rect(self) -> QRect: cmap_rect = self.rect().adjusted(2, 0, 0, 0) cmap_rect.setWidth(int(cmap_rect.width() * self._colormap_fraction)) return cmap_rect def resizeEvent(self, e: Any) -> None: left_margin = 6 if not self._cmap_is_full_width(): # leave room for the colormap left_margin += self._cmap_rect().width() self.setTextMargins(left_margin, 2, 0, 0) super().resizeEvent(e) def paintEvent(self, e: QPaintEvent) -> None: # don't draw the background # otherwise it will cover the colormap during super().paintEvent # FIXME: this appears to need to be reset during every paint event... # otherwise something is resetting it palette = self.palette() palette.setColor(palette.ColorRole.Base, Qt.GlobalColor.transparent) self.setPalette(palette) cmap_rect = self._cmap_rect() if self._cmap: draw_colormap( self, self._cmap, cmap_rect, checkerboard_size=self._checkerboard_size ) elif not self._cmap_is_full_width(): if self._missing_cmap: draw_colormap(self, self._missing_cmap, cmap_rect) self._missing_icon.paint(QPainter(self), cmap_rect.adjusted(4, 4, 0, -4)) super().paintEvent(e) # draw text (must come after draw_colormap) superqt-0.6.8/src/superqt/cmap/_cmap_utils.py000066400000000000000000000135611463340017400213230ustar00rootroot00000000000000from __future__ import annotations from contextlib import suppress from typing import TYPE_CHECKING, Any from cmap import Colormap from qtpy.QtCore import QPointF, QRect, QRectF, Qt from qtpy.QtGui import QColor, QLinearGradient, QPaintDevice, QPainter if TYPE_CHECKING: from cmap._colormap import ColorStopsLike CMAP_ROLE = Qt.ItemDataRole.UserRole + 1 def draw_colormap( painter_or_device: QPainter | QPaintDevice, cmap: Colormap | ColorStopsLike, rect: QRect | QRectF | None = None, border_color: QColor | str | None = None, border_width: int = 1, lighter: int = 100, checkerboard_size: int = 4, ) -> None: """Draw a colormap onto a QPainter or QPaintDevice. Parameters ---------- painter_or_device : QPainter | QPaintDevice A `QPainter` instance or a `QPaintDevice` (e.g. a QWidget or QPixmap) onto which to paint the colormap. cmap : Colormap | Any `cmap.Colormap` instance, or anything that can be converted to one (such as a string name of a colormap in the `cmap` catalog). https://cmap-docs.readthedocs.io/en/latest/colormaps/#colormaplike-objects rect : QRect | QRectF | None, optional A rect onto which to draw. If `None`, the `painter.viewport()` will be used. by default `None` border_color : QColor | str | None If not `None`, a border of color `border_color` and width `border_width` is included around the edge, by default None. border_width : int, optional The width of the border to draw (provided `border_color` is not `None`), by default 2 lighter : int, optional Percentage by which to lighten (or darken) the colors. Greater than 100 lightens, less than 100 darkens, by default 100 (i.e. no change). checkerboard_size : bool, optional Size (in pixels) of the checkerboard pattern to draw, by default 5. If 0, no checkerboard is drawn. Examples -------- ```python from qtpy.QtGui import QPixmap from qtpy.QtWidgets import QWidget from superqt.utils import draw_colormap viridis = "viridis" # or cmap.Colormap('viridis') class W(QWidget): def paintEvent(self, event) -> None: draw_colormap(self, viridis, event.rect()) # or draw onto a QPixmap pm = QPixmap(200, 200) draw_colormap(pm, viridis) ``` """ if isinstance(painter_or_device, QPainter): painter = painter_or_device elif isinstance(painter_or_device, QPaintDevice): painter = QPainter(painter_or_device) else: raise TypeError( "Expected a QPainter or QPaintDevice instance, " f"got {type(painter_or_device)!r} instead." ) if (cmap_ := try_cast_colormap(cmap)) is None: raise TypeError( f"Expected a Colormap instance or something that can be " f"converted to one, got {cmap!r} instead." ) if rect is None: rect = painter.viewport() painter.setPen(Qt.PenStyle.NoPen) if border_width and border_color is not None: # draw rect, and then contract it by border_width painter.setPen(QColor(border_color)) painter.setBrush(Qt.BrushStyle.NoBrush) painter.drawRect(rect) rect = rect.adjusted(border_width, border_width, -border_width, -border_width) if checkerboard_size: _draw_checkerboard(painter, rect, checkerboard_size) if ( cmap_.interpolation == "nearest" or getattr(cmap_.color_stops, "_interpolation", "") == "nearest" ): # XXX: this is a little bit of a hack. # when the interpolation is nearest, the last stop is often at 1.0 # which means that the last color is not drawn. # to fix this, we shrink the drawing area slightly # it might not work well with unenvenly-spaced stops # (but those are uncommon for categorical colormaps) width = rect.width() - rect.width() / len(cmap_.color_stops) for stop in cmap_.color_stops: painter.setBrush(QColor(stop.color.hex).lighter(lighter)) painter.drawRect(rect.adjusted(int(stop.position * width), 0, 0, 0)) else: gradient = QLinearGradient(QPointF(rect.topLeft()), QPointF(rect.topRight())) for stop in cmap_.color_stops: gradient.setColorAt(stop.position, QColor(stop.color.hex).lighter(lighter)) painter.setBrush(gradient) painter.drawRect(rect) def _draw_checkerboard( painter: QPainter, rect: QRect | QRectF, checker_size: int ) -> None: darkgray = QColor("#969696") lightgray = QColor("#C8C8C8") sz = checker_size h, w = rect.height(), rect.width() left, top = rect.left(), rect.top() full_rows = h // sz full_cols = w // sz for row in range(int(full_rows) + 1): szh = sz if row < full_rows else int(h % sz) for col in range(int(full_cols) + 1): szw = sz if col < full_cols else int(w % sz) color = lightgray if (row + col) % 2 == 0 else darkgray painter.fillRect(int(col * sz + left), int(row * sz + top), szw, szh, color) def try_cast_colormap(val: Any) -> Colormap | None: """Try to cast `val` to a Colormap instance, return None if it fails.""" if isinstance(val, Colormap): return val with suppress(Exception): return Colormap(val) return None def pick_font_color(cmap: Colormap, at_stop: float = 0.49, alpha: int = 255) -> QColor: """Pick a font shade that contrasts with the given colormap at `at_stop`.""" if _is_dark(cmap, at_stop): return QColor(0, 0, 0, alpha) else: return QColor(255, 255, 255, alpha) def _is_dark(cmap: Colormap, at_stop: float, threshold: float = 110) -> bool: """Return True if the color at `at_stop` is dark according to `threshold`.""" color = cmap(at_stop) r, g, b, a = color.rgba8 return (r * 0.299 + g * 0.587 + b * 0.114) > threshold superqt-0.6.8/src/superqt/collapsible/000077500000000000000000000000001463340017400200155ustar00rootroot00000000000000superqt-0.6.8/src/superqt/collapsible/__init__.py000066400000000000000000000001031463340017400221200ustar00rootroot00000000000000from ._collapsible import QCollapsible __all__ = ["QCollapsible"] superqt-0.6.8/src/superqt/collapsible/_collapsible.py000066400000000000000000000173631463340017400230310ustar00rootroot00000000000000"""A collapsible widget to hide and unhide child widgets.""" from __future__ import annotations from qtpy.QtCore import ( QEasingCurve, QEvent, QMargins, QObject, QPropertyAnimation, QRect, Qt, Signal, ) from qtpy.QtGui import QIcon, QPainter, QPalette, QPixmap from qtpy.QtWidgets import QFrame, QPushButton, QSizePolicy, QVBoxLayout, QWidget class QCollapsible(QFrame): """A collapsible widget to hide and unhide child widgets. A signal is emitted when the widget is expanded (True) or collapsed (False). Based on https://stackoverflow.com/a/68141638 """ toggled = Signal(bool) def __init__( self, title: str = "", parent: QWidget | None = None, expandedIcon: QIcon | str | None = "▼", collapsedIcon: QIcon | str | None = "▲", ): super().__init__(parent) self._locked = False self._is_animating = False self._text = title self._toggle_btn = QPushButton(title) self._toggle_btn.setCheckable(True) self.setCollapsedIcon(icon=collapsedIcon) self.setExpandedIcon(icon=expandedIcon) self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum) self._toggle_btn.setStyleSheet("text-align: left; border: none; outline: none;") self._toggle_btn.toggled.connect(self._toggle) # frame layout layout = QVBoxLayout(self) layout.setAlignment(Qt.AlignmentFlag.AlignTop) layout.addWidget(self._toggle_btn) # Create animators self._animation = QPropertyAnimation(self) self._animation.setPropertyName(b"maximumHeight") self._animation.setStartValue(0) self._animation.finished.connect(self._on_animation_done) self.setDuration(300) self.setEasingCurve(QEasingCurve.Type.InOutCubic) # default content widget _content = QWidget() _content.setLayout(QVBoxLayout()) _content.setMaximumHeight(0) _content.layout().setContentsMargins(QMargins(5, 0, 0, 0)) self.setContent(_content) def toggleButton(self) -> QPushButton: """Return the toggle button.""" return self._toggle_btn def setText(self, text: str) -> None: """Set the text of the toggle button.""" self._toggle_btn.setText(text) def text(self) -> str: """Return the text of the toggle button.""" return self._toggle_btn.text() def setContent(self, content: QWidget) -> None: """Replace central widget (the widget that gets expanded/collapsed).""" self._content = content self.layout().addWidget(self._content) self._animation.setTargetObject(content) def content(self) -> QWidget: """Return the current content widget.""" return self._content def _convert_string_to_icon(self, symbol: str) -> QIcon: """Create a QIcon from a string.""" size = self._toggle_btn.font().pointSize() pixmap = QPixmap(size, size) pixmap.fill(Qt.GlobalColor.transparent) painter = QPainter(pixmap) color = self._toggle_btn.palette().color(QPalette.ColorRole.WindowText) painter.setPen(color) painter.drawText(QRect(0, 0, size, size), Qt.AlignmentFlag.AlignCenter, symbol) painter.end() return QIcon(pixmap) def expandedIcon(self) -> QIcon: """Returns the icon used when the widget is expanded.""" return self._expanded_icon def setExpandedIcon(self, icon: QIcon | str | None = None) -> None: """Set the icon on the toggle button when the widget is expanded.""" if icon and isinstance(icon, QIcon): self._expanded_icon = icon elif icon and isinstance(icon, str): self._expanded_icon = self._convert_string_to_icon(icon) if self.isExpanded(): self._toggle_btn.setIcon(self._expanded_icon) def collapsedIcon(self) -> QIcon: """Returns the icon used when the widget is collapsed.""" return self._collapsed_icon def setCollapsedIcon(self, icon: QIcon | str | None = None) -> None: """Set the icon on the toggle button when the widget is collapsed.""" if icon and isinstance(icon, QIcon): self._collapsed_icon = icon elif icon and isinstance(icon, str): self._collapsed_icon = self._convert_string_to_icon(icon) if not self.isExpanded(): self._toggle_btn.setIcon(self._collapsed_icon) def setDuration(self, msecs: int) -> None: """Set duration of the collapse/expand animation.""" self._animation.setDuration(msecs) def setEasingCurve(self, easing: QEasingCurve | QEasingCurve.Type) -> None: """Set the easing curve for the collapse/expand animation.""" self._animation.setEasingCurve(easing) def addWidget(self, widget: QWidget) -> None: """Add a widget to the central content widget's layout.""" widget.installEventFilter(self) self._content.layout().addWidget(widget) def removeWidget(self, widget: QWidget) -> None: """Remove widget from the central content widget's layout.""" self._content.layout().removeWidget(widget) widget.removeEventFilter(self) def expand(self, animate: bool = True) -> None: """Expand (show) the collapsible section.""" self._expand_collapse(QPropertyAnimation.Direction.Forward, animate) def collapse(self, animate: bool = True) -> None: """Collapse (hide) the collapsible section.""" self._expand_collapse(QPropertyAnimation.Direction.Backward, animate) def isExpanded(self) -> bool: """Return whether the collapsible section is visible.""" return self._toggle_btn.isChecked() def setLocked(self, locked: bool = True) -> None: """Set whether collapse/expand is disabled.""" self._locked = locked self._toggle_btn.setCheckable(not locked) def locked(self) -> bool: """Return True if collapse/expand is disabled.""" return self._locked def _expand_collapse( self, direction: QPropertyAnimation.Direction, animate: bool = True, emit: bool = True, ) -> None: """Set values for the widget based on whether it is expanding or collapsing. An emit flag is included so that the toggle signal is only called once (it was being emitted a few times via eventFilter when the widget was expanding previously). """ if self._locked: return forward = direction == QPropertyAnimation.Direction.Forward icon = self._expanded_icon if forward else self._collapsed_icon self._toggle_btn.setIcon(icon) self._toggle_btn.setChecked(forward) _content_height = self._content.sizeHint().height() + 10 if animate: self._animation.setDirection(direction) self._animation.setEndValue(_content_height) self._is_animating = True self._animation.start() else: self._content.setMaximumHeight(_content_height if forward else 0) if emit: self.toggled.emit(direction == QPropertyAnimation.Direction.Forward) def _toggle(self) -> None: self.expand() if self.isExpanded() else self.collapse() def eventFilter(self, a0: QObject, a1: QEvent) -> bool: """If a child widget resizes, we need to update our expanded height.""" if ( a1.type() == QEvent.Type.Resize and self.isExpanded() and not self._is_animating ): self._expand_collapse( QPropertyAnimation.Direction.Forward, animate=False, emit=False ) return False def _on_animation_done(self) -> None: self._is_animating = False superqt-0.6.8/src/superqt/combobox/000077500000000000000000000000001463340017400173345ustar00rootroot00000000000000superqt-0.6.8/src/superqt/combobox/__init__.py000066400000000000000000000011621463340017400214450ustar00rootroot00000000000000from typing import TYPE_CHECKING, Any from ._color_combobox import QColorComboBox from ._enum_combobox import QEnumComboBox from ._searchable_combo_box import QSearchableComboBox __all__ = ( "QColorComboBox", "QColormapComboBox", "QEnumComboBox", "QSearchableComboBox", ) if TYPE_CHECKING: from superqt.cmap import QColormapComboBox # noqa: TCH004 def __getattr__(name: str) -> Any: # pragma: no cover if name == "QColormapComboBox": from superqt.cmap import QColormapComboBox return QColormapComboBox raise AttributeError(f"module {__name__!r} has no attribute {name!r}") superqt-0.6.8/src/superqt/combobox/_color_combobox.py000066400000000000000000000237171463340017400230650ustar00rootroot00000000000000from __future__ import annotations import warnings from contextlib import suppress from enum import IntEnum, auto from typing import Any, Literal, Sequence, cast from qtpy.QtCore import QModelIndex, QPersistentModelIndex, QRect, QSize, Qt, Signal from qtpy.QtGui import QColor, QPainter from qtpy.QtWidgets import ( QAbstractItemDelegate, QColorDialog, QComboBox, QLineEdit, QStyle, QStyleOptionViewItem, QWidget, ) from superqt.utils import signals_blocked _NAME_MAP = {QColor(x).name(): x for x in QColor.colorNames()} COLOR_ROLE = Qt.ItemDataRole.BackgroundRole class InvalidColorPolicy(IntEnum): """Policy for handling invalid colors.""" Ignore = auto() Warn = auto() Raise = auto() class _ColorComboLineEdit(QLineEdit): """A read-only line edit that shows the parent ComboBox popup when clicked.""" def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self.setReadOnly(True) # hide any original text self.setStyleSheet("color: transparent") self.setText("") def mouseReleaseEvent(self, _: Any) -> None: """Show parent popup when clicked. Without this, only the down arrow will show the popup. And if mousePressEvent is used instead, the popup will show and then immediately hide. """ parent = self.parent() if hasattr(parent, "showPopup"): parent.showPopup() class _ColorComboItemDelegate(QAbstractItemDelegate): """Delegate that draws color squares in the ComboBox. This provides more control than simply setting various data roles on the item, and makes for a nicer appearance. Importantly, it prevents the color from being obscured on hover. """ def sizeHint( self, option: QStyleOptionViewItem, index: QModelIndex | QPersistentModelIndex ) -> QSize: return QSize(20, 20) def paint( self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex | QPersistentModelIndex, ) -> None: color: QColor | None = index.data(COLOR_ROLE) rect = cast("QRect", option.rect) # type: ignore state = cast("QStyle.StateFlag", option.state) # type: ignore selected = state & QStyle.StateFlag.State_Selected border = QColor("lightgray") if not color: # not a color square, just draw the text text_color = Qt.GlobalColor.black if selected else Qt.GlobalColor.gray painter.setPen(text_color) text = index.data(Qt.ItemDataRole.DisplayRole) painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, text) return # slightly larger border for rect pen = painter.pen() pen.setWidth(2) pen.setColor(border) painter.setPen(pen) if selected: # if hovering, give a slight highlight and draw the color name painter.setBrush(color.lighter(110)) painter.drawRect(rect) # use user friendly color name if available name = _NAME_MAP.get(color.name(), color.name()) painter.setPen(_pick_font_color(color)) painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, name) else: # not hovering painter.setBrush(color) painter.drawRect(rect) class QColorComboBox(QComboBox): """A drop down menu for selecting colors. Parameters ---------- parent : QWidget, optional The parent widget. allow_user_colors : bool, optional Whether to show an "Add Color" item that opens a QColorDialog when clicked. Whether the user can add custom colors by clicking the "Add Color" item. Default is False. Can also be set with `setUserColorsAllowed`. add_color_text: str, optional The text to display for the "Add Color" item. Default is "Add Color...". """ currentColorChanged = Signal(QColor) def __init__( self, parent: QWidget | None = None, *, allow_user_colors: bool = False, add_color_text: str = "Add Color...", ) -> None: # init QComboBox super().__init__(parent) self._invalid_policy: InvalidColorPolicy = InvalidColorPolicy.Ignore self._add_color_text: str = add_color_text self._allow_user_colors: bool = allow_user_colors self._last_color: QColor = QColor() self.setLineEdit(_ColorComboLineEdit(self)) self.setItemDelegate(_ColorComboItemDelegate()) self.currentIndexChanged.connect(self._on_index_changed) self.activated.connect(self._on_activated) self.setUserColorsAllowed(allow_user_colors) def setInvalidColorPolicy( self, policy: InvalidColorPolicy | int | Literal["Raise", "Ignore", "Warn"] ) -> None: """Sets the policy for handling invalid colors.""" if isinstance(policy, str): policy = InvalidColorPolicy[policy] elif isinstance(policy, int): policy = InvalidColorPolicy(policy) elif not isinstance(policy, InvalidColorPolicy): raise TypeError(f"Invalid policy type: {type(policy)!r}") self._invalid_policy = policy def invalidColorPolicy(self) -> InvalidColorPolicy: """Returns the policy for handling invalid colors.""" return self._invalid_policy InvalidColorPolicy = InvalidColorPolicy def userColorsAllowed(self) -> bool: """Returns whether the user can add custom colors.""" return self._allow_user_colors def setUserColorsAllowed(self, allow: bool) -> None: """Sets whether the user can add custom colors.""" self._allow_user_colors = bool(allow) idx = self.findData(self._add_color_text, Qt.ItemDataRole.DisplayRole) if idx < 0: if self._allow_user_colors: self.addItem(self._add_color_text) elif not self._allow_user_colors: self.removeItem(idx) def clear(self) -> None: """Clears the QComboBox of all entries (leaves "Add colors" if enabled).""" super().clear() self.setUserColorsAllowed(self._allow_user_colors) def addColor(self, color: Any) -> None: """Adds the color to the QComboBox.""" _color = _cast_color(color) if not _color.isValid(): if self._invalid_policy == InvalidColorPolicy.Raise: raise ValueError(f"Invalid color: {color!r}") elif self._invalid_policy == InvalidColorPolicy.Warn: warnings.warn(f"Ignoring invalid color: {color!r}", stacklevel=2) return c = self.currentColor() if self.findData(_color) > -1: # avoid duplicates return # add the new color and set the background color of that item self.addItem("", _color) self.setItemData(self.count() - 1, _color, COLOR_ROLE) if not c or not c.isValid(): self._on_index_changed(self.count() - 1) # make sure the "Add Color" item is last idx = self.findData(self._add_color_text, Qt.ItemDataRole.DisplayRole) if idx >= 0: with signals_blocked(self): self.removeItem(idx) self.addItem(self._add_color_text) def itemColor(self, index: int) -> QColor | None: """Returns the color of the item at the given index.""" return self.itemData(index, COLOR_ROLE) def addColors(self, colors: Sequence[Any]) -> None: """Adds colors to the QComboBox.""" for color in colors: self.addColor(color) def currentColor(self) -> QColor | None: """Returns the currently selected QColor or None if not yet selected.""" return self.currentData(COLOR_ROLE) def setCurrentColor(self, color: Any) -> None: """Adds the color to the QComboBox and selects it.""" idx = self.findData(_cast_color(color), COLOR_ROLE) if idx >= 0: self.setCurrentIndex(idx) def currentColorName(self) -> str | None: """Returns the name of the currently selected QColor or black if None.""" color = self.currentColor() return color.name() if color else "#000000" def _on_activated(self, index: int) -> None: if self.itemText(index) != self._add_color_text: return # show temporary text while dialog is open self.lineEdit().setStyleSheet("background-color: white; color: gray;") self.lineEdit().setText("Pick a Color ...") try: color = QColorDialog.getColor() finally: self.lineEdit().setText("") if color.isValid(): # add the color and select it self.addColor(color) elif self._last_color.isValid(): # user canceled, restore previous color without emitting signal idx = self.findData(self._last_color, COLOR_ROLE) if idx >= 0: with signals_blocked(self): self.setCurrentIndex(idx) hex_ = self._last_color.name() self.lineEdit().setStyleSheet(f"background-color: {hex_};") return def _on_index_changed(self, index: int) -> None: color = self.itemData(index, COLOR_ROLE) if isinstance(color, QColor): self.lineEdit().setStyleSheet(f"background-color: {color.name()};") self.currentColorChanged.emit(color) self._last_color = color def _cast_color(val: Any) -> QColor: with suppress(TypeError): color = QColor(val) if color.isValid(): return color if isinstance(val, (tuple, list)): with suppress(TypeError): color = QColor(*val) if color.isValid(): return color return QColor() def _pick_font_color(color: QColor) -> QColor: """Pick a font shade that contrasts with the given color.""" if (color.red() * 0.299 + color.green() * 0.587 + color.blue() * 0.114) > 80: return QColor(0, 0, 0, 128) else: return QColor(255, 255, 255, 128) superqt-0.6.8/src/superqt/combobox/_enum_combobox.py000066400000000000000000000125151463340017400227050ustar00rootroot00000000000000import sys from enum import Enum, EnumMeta, Flag from functools import reduce from itertools import combinations from operator import or_ from typing import Optional, Tuple, TypeVar from qtpy.QtCore import Signal from qtpy.QtWidgets import QComboBox EnumType = TypeVar("EnumType", bound=Enum) NONE_STRING = "----" def _get_name(enum_value: Enum): """Create human readable name if user does not implement `__str__`.""" str_module = getattr(enum_value.__str__, "__module__", "enum") if str_module != "enum" and not str_module.startswith("shibokensupport"): # check if function was overloaded name = str(enum_value) else: if enum_value.name is None: # This is hack for python bellow 3.11 if not isinstance(enum_value, Flag): raise TypeError( f"Expected Flag instance, got {enum_value}" ) # pragma: no cover if sys.version_info >= (3, 11): # There is a bug in some releases of Python 3.11 (for example 3.11.3) # that leads to wrong evaluation of or operation on Flag members # and produces numeric value without proper set name property. return f"{enum_value.value}" # Before python 3.11 there is no smart name set during # the creation of Flag members. # We needs to decompose the value to get the name. # It is under if condition because it uses private API. from enum import _decompose members, not_covered = _decompose(enum_value.__class__, enum_value.value) name = "|".join(m.name.replace("_", " ") for m in members[::-1]) else: name = enum_value.name.replace("_", " ") return name def _get_name_with_value(enum_value: Enum) -> Tuple[str, Enum]: return _get_name(enum_value), enum_value class QEnumComboBox(QComboBox): """ComboBox presenting options from a python Enum. If the Enum class does not implement `__str__` then a human readable name is created from the name of the enum member, replacing underscores with spaces. """ currentEnumChanged = Signal(object) def __init__( self, parent=None, enum_class: Optional[EnumMeta] = None, allow_none=False ): super().__init__(parent) self._enum_class = None self._allow_none = False if enum_class is not None: self.setEnumClass(enum_class, allow_none) self.currentIndexChanged.connect(self._emit_signal) def setEnumClass(self, enum: Optional[EnumMeta], allow_none=False): """Set enum class from which members value should be selected.""" self.clear() self._enum_class = enum self._allow_none = allow_none and enum is not None if allow_none: super().addItem(NONE_STRING) names_ = self._get_enum_member_list(enum) super().addItems(list(names_)) @staticmethod def _get_enum_member_list(enum: Optional[EnumMeta]): if issubclass(enum, Flag): members = list(enum.__members__.values()) comb_list = [] for i in range(len(members)): comb_list.extend(reduce(or_, x) for x in combinations(members, i + 1)) else: comb_list = list(enum.__members__.values()) return dict(map(_get_name_with_value, comb_list)) def enumClass(self) -> Optional[EnumMeta]: """Return current Enum class.""" return self._enum_class def isOptional(self) -> bool: """Return if current enum is with optional annotation.""" return self._allow_none def clear(self): self._enum_class = None self._allow_none = False super().clear() def currentEnum(self) -> Optional[EnumType]: """Current value as Enum member.""" if self._enum_class is not None: if self._allow_none: if self.currentText() == NONE_STRING: return None return self._get_enum_member_list(self._enum_class)[self.currentText()] return None def setCurrentEnum(self, value: Optional[EnumType]) -> None: """Set value with Enum.""" if self._enum_class is None: raise RuntimeError( "Uninitialized enum class. Use `setEnumClass` before `setCurrentEnum`." ) if value is None and self._allow_none: self.setCurrentIndex(0) return if not isinstance(value, self._enum_class): raise TypeError( "setValue(self, Enum): argument 1 has unexpected type " f"{type(value).__name__!r}" ) self.setCurrentText(_get_name(value)) def _emit_signal(self): if self._enum_class is not None: self.currentEnumChanged.emit(self.currentEnum()) def insertItems(self, *_, **__): raise RuntimeError("EnumComboBox does not allow to insert items") def insertItem(self, *_, **__): raise RuntimeError("EnumComboBox does not allow to insert item") def addItems(self, *_, **__): raise RuntimeError("EnumComboBox does not allow to add items") def addItem(self, *_, **__): raise RuntimeError("EnumComboBox does not allow to add item") def setInsertPolicy(self, policy): raise RuntimeError("EnumComboBox does not allow to insert item") superqt-0.6.8/src/superqt/combobox/_searchable_combo_box.py000066400000000000000000000032041463340017400241640ustar00rootroot00000000000000from typing import Optional from qtpy import QT_VERSION from qtpy.QtCore import Qt, Signal from qtpy.QtWidgets import QComboBox, QCompleter, QWidget try: is_qt_bellow_5_14 = tuple(int(x) for x in QT_VERSION.split(".")[:2]) < (5, 14) except ValueError: is_qt_bellow_5_14 = False class QSearchableComboBox(QComboBox): """ComboCox with completer for fast search in multiple options.""" if is_qt_bellow_5_14: textActivated = Signal(str) # pragma: no cover def __init__(self, parent: Optional[QWidget] = None): super().__init__(parent) self.setEditable(True) self.completer_object = QCompleter() self.completer_object.setCaseSensitivity(Qt.CaseInsensitive) self.completer_object.setCompletionMode(QCompleter.PopupCompletion) self.completer_object.setFilterMode(Qt.MatchContains) self.setCompleter(self.completer_object) self.setInsertPolicy(QComboBox.NoInsert) if is_qt_bellow_5_14: # pragma: no cover self.currentIndexChanged.connect(self._text_activated) def _text_activated(self): # pragma: no cover self.textActivated.emit(self.currentText()) def addItem(self, *args): super().addItem(*args) self.completer_object.setModel(self.model()) def addItems(self, *args): super().addItems(*args) self.completer_object.setModel(self.model()) def insertItem(self, *args) -> None: super().insertItem(*args) self.completer_object.setModel(self.model()) def insertItems(self, *args) -> None: super().insertItems(*args) self.completer_object.setModel(self.model()) superqt-0.6.8/src/superqt/elidable/000077500000000000000000000000001463340017400172655ustar00rootroot00000000000000superqt-0.6.8/src/superqt/elidable/__init__.py000066400000000000000000000002141463340017400213730ustar00rootroot00000000000000from ._eliding_label import QElidingLabel from ._eliding_line_edit import QElidingLineEdit __all__ = ["QElidingLabel", "QElidingLineEdit"] superqt-0.6.8/src/superqt/elidable/_eliding.py000066400000000000000000000051451463340017400214160ustar00rootroot00000000000000from typing import List from qtpy.QtCore import Qt from qtpy.QtGui import QFont, QFontMetrics, QTextLayout class _GenericEliding: """A mixin to provide capabilities to elide text (could add '…') to fit width.""" _elide_mode: Qt.TextElideMode = Qt.TextElideMode.ElideRight _text: str = "" # the 2 is a magic number that prevents the ellipses from going missing # in certain cases (?) _ellipses_width: int = 2 # Public methods def elideMode(self) -> Qt.TextElideMode: """The current Qt.TextElideMode.""" return self._elide_mode def setElideMode(self, mode: Qt.TextElideMode) -> None: """Set the elide mode to a Qt.TextElideMode.""" self._elide_mode = Qt.TextElideMode(mode) def full_text(self) -> str: """The current text without eliding.""" return self._text def setEllipsesWidth(self, width: int) -> None: """A width value to take into account ellipses width when eliding text. The value is deducted from the widget width when computing the elided version of the text. """ self._ellipses_width = width @staticmethod def wrapText(text, width, font=None) -> List[str]: """Returns `text`, split as it would be wrapped for `width`, given `font`. Static method. """ tl = QTextLayout(text, font or QFont()) tl.beginLayout() lines = [] while True: ln = tl.createLine() if not ln.isValid(): break ln.setLineWidth(width) start = ln.textStart() lines.append(text[start : start + ln.textLength()]) tl.endLayout() return lines # private implementation methods def _elidedText(self) -> str: """Return `self._text` elided to `width`.""" fm = QFontMetrics(self.font()) ellipses_width = 0 if self._elide_mode != Qt.TextElideMode.ElideNone: ellipses_width = self._ellipses_width width = self.width() - ellipses_width if not getattr(self, "wordWrap", None) or not self.wordWrap(): return fm.elidedText(self._text, self._elide_mode, width) # get number of lines we can fit without eliding nlines = self.height() // fm.height() - 1 # get the last line (elided) text = self._wrappedText() last_line = fm.elidedText("".join(text[nlines:]), self._elide_mode, width) # join them return "".join(text[:nlines] + [last_line]) def _wrappedText(self) -> List[str]: return _GenericEliding.wrapText(self._text, self.width(), self.font()) superqt-0.6.8/src/superqt/elidable/_eliding_label.py000066400000000000000000000047741463340017400225640ustar00rootroot00000000000000from qtpy.QtCore import QPoint, QRect, QSize, Qt from qtpy.QtGui import QFontMetrics, QResizeEvent from qtpy.QtWidgets import QLabel from ._eliding import _GenericEliding class QElidingLabel(_GenericEliding, QLabel): """ A QLabel variant that will elide text (could add '…') to fit width. QElidingLabel() QElidingLabel(parent: Optional[QWidget], f: Qt.WindowFlags = ...) QElidingLabel(text: str, parent: Optional[QWidget] = None, f: Qt.WindowFlags = ...) For a multiline eliding label, use `setWordWrap(True)`. In this case, text will wrap to fit the width, and only the last line will be elided. When `wordWrap()` is True, `sizeHint()` will return the size required to fit the full text. """ def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) if args and isinstance(args[0], str): self.setText(args[0]) # Reimplemented _GenericEliding methods def setElideMode(self, mode: Qt.TextElideMode) -> None: """Set the elide mode to a Qt.TextElideMode.""" super().setElideMode(mode) super().setText(self._elidedText()) def setEllipsesWidth(self, width: int) -> None: """A width value to take into account ellipses width when eliding text. The value is deducted from the widget width when computing the elided version of the text. """ super().setEllipsesWidth(width) super().setText(self._elidedText()) # Reimplemented QT methods def text(self) -> str: """Return the label's text. If no text has been set this will return an empty string. """ return self._text def setText(self, txt: str) -> None: """Set the label's text. Setting the text clears any previous content. NOTE: we set the QLabel private text to the elided version """ self._text = txt super().setText(self._elidedText()) def resizeEvent(self, event: QResizeEvent) -> None: event.accept() super().setText(self._elidedText()) def setWordWrap(self, wrap: bool) -> None: super().setWordWrap(wrap) super().setText(self._elidedText()) def sizeHint(self) -> QSize: if not self.wordWrap(): return super().sizeHint() fm = QFontMetrics(self.font()) flags = int(self.alignment() | Qt.TextFlag.TextWordWrap) r = fm.boundingRect(QRect(QPoint(0, 0), self.size()), flags, self._text) return QSize(self.width(), r.height()) superqt-0.6.8/src/superqt/elidable/_eliding_line_edit.py000066400000000000000000000061441463340017400234320ustar00rootroot00000000000000from qtpy.QtCore import Qt from qtpy.QtGui import QFocusEvent, QResizeEvent from qtpy.QtWidgets import QLineEdit from ._eliding import _GenericEliding class QElidingLineEdit(_GenericEliding, QLineEdit): """A QLineEdit variant that will elide text (could add '…') to fit width. QElidingLineEdit() QElidingLineEdit(parent: Optional[QWidget]) QElidingLineEdit(text: str, parent: Optional[QWidget] = None) """ def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) if args and isinstance(args[0], str): self.setText(args[0]) # The `textEdited` signal doesn't trigger the `textChanged` signal if # text is changed with `setText`, so we connect to `textEdited` to only # update _text when text is being edited by the user graphically. self.textEdited.connect(self._update_text) # Reimplemented _GenericEliding methods def setElideMode(self, mode: Qt.TextElideMode) -> None: """Set the elide mode to a Qt.TextElideMode. The text shown is updated to the elided version only if the widget is not focused. """ super().setElideMode(mode) if not self.hasFocus(): super().setText(self._elidedText()) def setEllipsesWidth(self, width: int) -> None: """A width value to take into account ellipses width when eliding text. The value is deducted from the widget width when computing the elided version of the text. The text shown is updated to the elided version only if the widget is not focused. """ super().setEllipsesWidth(width) if not self.hasFocus(): super().setText(self._elidedText()) # Reimplemented QT methods def text(self) -> str: """Return the label's text being shown. If no text has been set this will return an empty string. """ return self._text def setText(self, text) -> None: """Set the line edit's text. Setting the text clears any previous content. NOTE: we set the QLineEdit private text to the elided version """ self._text = text if not self.hasFocus(): super().setText(self._elidedText()) def focusInEvent(self, event: QFocusEvent) -> None: """Set the full text when the widget is focused.""" super().setText(self._text) super().focusInEvent(event) def focusOutEvent(self, event: QFocusEvent) -> None: """Set an elided version of the text (if needed) when the focus is out.""" super().setText(self._elidedText()) super().focusOutEvent(event) def resizeEvent(self, event: QResizeEvent) -> None: """Update elided text being shown when the widget is resized.""" if not self.hasFocus(): super().setText(self._elidedText()) super().resizeEvent(event) # private implementation methods def _update_text(self, text: str) -> None: """Update only the actual text of the widget. The actual text is the text the widget has without eliding. """ self._text = text superqt-0.6.8/src/superqt/fonticon/000077500000000000000000000000001463340017400173435ustar00rootroot00000000000000superqt-0.6.8/src/superqt/fonticon/__init__.py000066400000000000000000000173131463340017400214610ustar00rootroot00000000000000from __future__ import annotations __all__ = [ "addFont", "Animation", "ENTRY_POINT", "font", "icon", "IconFont", "IconFontMeta", "IconOpts", "pulse", "QIconifyIcon", "setTextIcon", "spin", ] from typing import TYPE_CHECKING from ._animations import Animation, pulse, spin from ._iconfont import IconFont, IconFontMeta from ._plugins import FontIconManager as _FIM from ._qfont_icon import DEFAULT_SCALING_FACTOR, IconOptionDict, IconOpts from ._qfont_icon import QFontIconStore as _QFIS if TYPE_CHECKING: from qtpy.QtGui import QFont, QTransform from qtpy.QtWidgets import QWidget from ._qfont_icon import QFontIcon, ValidColor ENTRY_POINT = _FIM.ENTRY_POINT # FIXME: currently, an Animation requires a *pre-bound* QObject. which makes it very # awkward to use animations when declaratively listing icons. It would be much better # to have a way to find the widget later, to execute the animation... short of that, I # think we should take animation off of `icon` here, and suggest that it be an # an additional convenience method after the icon has been bound to a QObject. def icon( glyph_key: str, scale_factor: float = DEFAULT_SCALING_FACTOR, color: ValidColor | None = None, opacity: float = 1, animation: Animation | None = None, transform: QTransform | None = None, states: dict[str, IconOptionDict | IconOpts] | None = None, ) -> QFontIcon: """Create a QIcon for `glyph_key`, with a number of optional settings. The `glyph_key` (e.g. 'fa5s.smile') represents a Font-family & style, and a glyph. In most cases, the key should be provided by a plugin in the environment, like: - [fonticon-fontawesome5](https://pypi.org/project/fonticon-fontawesome5/) ('fa5s' & 'fa5r' prefixes) - [fonticon-materialdesignicons6](https://pypi.org/project/fonticon-materialdesignicons6/) ('mdi6' prefix) ...but fonts can also be added manually using [`addFont`][superqt.fonticon.addFont]. Parameters ---------- glyph_key : str String encapsulating a font-family, style, and glyph. e.g. 'fa5s.smile'. scale_factor : float, optional Scale factor (fraction of widget height), When widget icon is painted on widget, it will use `font.setPixelSize(round(wdg.height() * scale_factor))`. by default 0.875. color : ValidColor, optional Color for the font, by default None. (e.g. The default `QColor`) Valid color types include `QColor`, `int`, `str`, `Qt.GlobalColor`, `tuple` (of integer: RGB[A]) (anything that can be passed to `QColor`). opacity : float, optional Opacity of icon, by default 1 animation : Animation, optional Animation for the icon. A subclass of superqt.fonticon.Animation, that provides a concrete `animate` method. (see "spin" and "pulse" for examples). by default None. transform : QTransform, optional A `QTransform` to apply when painting the icon, by default None states : dict, optional Provide additional styling for the icon in different states. `states` must be a mapping of string to dict, where: - the key represents a `QIcon.State` ("on", "off"), a `QIcon.Mode` ("normal", "active", "selected", "disabled"), or any combination of a state & mode separated by an underscore (e.g. "off_active", "selected_on", etc...). - the value is a dict with all of the same key/value meanings listed above as parameters to this function (e.g. `glyph_key`, `color`,`scale_factor`, `animation`, etc...) Missing keys in the state dicts will be taken from the default options, provided by the parameters above. Returns ------- QFontIcon A subclass of QIcon. Can be used wherever QIcons are used, such as `widget.setIcon()` Examples -------- simple example (using the string `'fa5s.smile'` assumes the `fonticon-fontawesome5` plugin is installed) >>> btn = QPushButton() >>> btn.setIcon(icon("fa5s.smile")) can also directly import from fonticon_fa5 >>> from fonticon_fa5 import FA5S >>> btn.setIcon(icon(FA5S.smile)) with animation >>> btn2 = QPushButton() >>> btn2.setIcon(icon(FA5S.spinner, animation=pulse(btn2))) complicated example >>> btn = QPushButton() >>> btn.setIcon( ... icon( ... FA5S.ambulance, ... color="blue", ... states={ ... "active": { ... "glyph": FA5S.bath, ... "color": "red", ... "scale_factor": 0.5, ... "animation": pulse(btn), ... }, ... "disabled": { ... "color": "green", ... "scale_factor": 0.8, ... "animation": spin(btn), ... }, ... }, ... ) ... ) >>> btn.setIconSize(QSize(256, 256)) >>> btn.show() """ return _QFIS.instance().icon( glyph_key, scale_factor=scale_factor, color=color, opacity=opacity, animation=animation, transform=transform, states=states or {}, ) def setTextIcon(widget: QWidget, glyph_key: str, size: float | None = None) -> None: """Set text on a widget to a specific font & glyph. This is an alternative to setting a QIcon with a pixmap. It may be easier to combine with dynamic stylesheets. Parameters ---------- widget : QWidget A widget supporting a `setText` method. glyph_key : str String encapsulating a font-family, style, and glyph. e.g. 'fa5s.smile'. size : int, optional Size for QFont. passed to `setPixelSize`, by default None """ return _QFIS.instance().setTextIcon(widget, glyph_key, size) def font(font_prefix: str, size: int | None = None) -> QFont: """Create QFont for `font_prefix`. Parameters ---------- font_prefix : str Font_prefix, such as 'fa5s' or 'mdi6', representing a font-family and style. size : int, optional Size for QFont. passed to `setPixelSize`, by default None Returns ------- QFont QFont instance that can be used to add fonticons to widgets. """ return _QFIS.instance().font(font_prefix, size) def addFont( filepath: str, prefix: str, charmap: dict[str, str] | None = None ) -> tuple[str, str] | None: """Add OTF/TTF file at `filepath` to the registry under `prefix`. If you'd like to later use a fontkey in the form of `prefix.some-name`, then `charmap` must be provided and provide a mapping for all of the glyph names to their unicode numbers. If a charmap is not provided, glyphs must be directly accessed with their unicode as something like `key.\uffff`. !!! Note in most cases, users will not need this. Instead, they should install a font plugin, like: - [fonticon-fontawesome5](https://pypi.org/project/fonticon-fontawesome5/) - [fonticon-materialdesignicons6](https://pypi.org/project/fonticon-materialdesignicons6/) Parameters ---------- filepath : str Path to an OTF or TTF file containing the fonts prefix : str A prefix that will represent this font file when used for lookup. For example, 'fa5s' for 'Font-Awesome 5 Solid'. charmap : Dict[str, str], optional optional mapping for all of the glyph names to their unicode numbers. See note above. Returns ------- Tuple[str, str], optional font-family and font-style for the file just registered, or `None` if something goes wrong. """ return _QFIS.instance().addFont(filepath, prefix, charmap) del DEFAULT_SCALING_FACTOR superqt-0.6.8/src/superqt/fonticon/_animations.py000066400000000000000000000025211463340017400222160ustar00rootroot00000000000000from abc import ABC, abstractmethod from typing import Optional from qtpy.QtCore import QRectF, QTimer from qtpy.QtGui import QPainter from qtpy.QtWidgets import QWidget class Animation(ABC): """Base icon animation class.""" def __init__(self, parent_widget: QWidget, interval: int = 10, step: int = 1): self.parent_widget = parent_widget self.timer = QTimer() self.timer.timeout.connect(self._update) # type: ignore self.timer.setInterval(interval) self._angle = 0 self._step = step def _update(self): if self.timer.isActive(): self._angle += self._step self.parent_widget.update() @abstractmethod def animate(self, painter: QPainter): """Setup and start the timer for the animation.""" class spin(Animation): """Animation that smoothly spins an icon.""" def animate(self, painter: QPainter): if not self.timer.isActive(): self.timer.start() mid = QRectF(painter.viewport()).center() painter.translate(mid) painter.rotate(self._angle % 360) painter.translate(-mid) class pulse(spin): """Animation that spins an icon in slower, discrete steps.""" def __init__(self, parent_widget: Optional[QWidget] = None): super().__init__(parent_widget, interval=200, step=45) superqt-0.6.8/src/superqt/fonticon/_iconfont.py000066400000000000000000000056041463340017400217000ustar00rootroot00000000000000from typing import Mapping, Type, Union FONTFILE_ATTR = "__font_file__" class IconFontMeta(type): """IconFont metaclass. This updates the value of all class attributes to be prefaced with the class name (lowercase), and makes sure that all values are valid characters. Examples -------- This metaclass turns the following class: class FA5S(metaclass=IconFontMeta): __font_file__ = 'path/to/font.otf' some_char = 0xfa42 into this: class FA5S: __font_file__ = path/to/font.otf' some_char = 'fa5s.\ufa42' In usage, this means that someone could use `icon(FA5S.some_char)` (provided that the FA5S class/namespace has already been registered). This makes IDE attribute checking and autocompletion easier. """ __font_file__: str def __new__(cls, name, bases, namespace, **kwargs): # make sure this class provides the __font_file__ interface ff = namespace.get(FONTFILE_ATTR) if not (ff and isinstance(ff, (str, classmethod))): raise TypeError( f"Invalid Font: must declare {FONTFILE_ATTR!r} attribute or classmethod" ) # update all values to be `key.unicode` prefix = name.lower() for k, v in list(namespace.items()): if k.startswith("__"): continue char = chr(v) if isinstance(v, int) else v if len(char) != 1: raise TypeError( "Invalid Font: All fonts values must be a single " f"unicode char. ('{name}.{char}' has length {len(char)}). " "You may use unicode representations: like '\\uf641' or '0xf641'" ) namespace[k] = f"{prefix}.{char}" return super().__new__(cls, name, bases, namespace, **kwargs) class IconFont(metaclass=IconFontMeta): """Helper class that provides a standard way to create an IconFont. Examples -------- class FA5S(IconFont): __font_file__ = '...' some_char = 0xfa42 """ __slots__ = () __font_file__ = "..." def namespace2font(namespace: Union[Mapping, Type], name: str) -> Type[IconFont]: """Convenience to convert a namespace (class, module, dict) into an IconFont.""" if isinstance(namespace, type): if not isinstance(getattr(namespace, FONTFILE_ATTR), str): raise TypeError( f"Invalid Font: must declare {FONTFILE_ATTR!r} attribute or classmethod" ) return namespace elif hasattr(namespace, "__dict__"): ns = dict(namespace.__dict__) else: raise ValueError( "namespace must be a mapping or an object with __dict__ attribute." ) if not str.isidentifier(name): raise ValueError(f"name {name!r} is not a valid identifier.") return type(name, (IconFont,), ns) superqt-0.6.8/src/superqt/fonticon/_plugins.py000066400000000000000000000062151463340017400215410ustar00rootroot00000000000000import contextlib from typing import ClassVar, Dict, List, Set, Tuple from ._iconfont import IconFontMeta, namespace2font try: from importlib.metadata import EntryPoint, entry_points except ImportError: from importlib_metadata import EntryPoint, entry_points # type: ignore class FontIconManager: ENTRY_POINT: ClassVar[str] = "superqt.fonticon" _PLUGINS: ClassVar[Dict[str, EntryPoint]] = {} _LOADED: ClassVar[Dict[str, IconFontMeta]] = {} _BLOCKED: ClassVar[Set[EntryPoint]] = set() def _discover_fonts(self) -> None: self._PLUGINS.clear() entries = entry_points() if hasattr(entries, "select"): # python>3.10 _entries = entries.select(group=self.ENTRY_POINT) # type: ignore else: _entries = entries.get(self.ENTRY_POINT, []) for ep in _entries: if ep not in self._BLOCKED: self._PLUGINS[ep.name] = ep def _get_font_class(self, key: str) -> IconFontMeta: """Get IconFont given a key. Parameters ---------- key : str font key to load. Returns ------- IconFontMeta Instance of IconFontMeta Raises ------ KeyError If no plugin provides this key ImportError If a plugin provides the key, but the entry point doesn't load TypeError If the entry point loads, but is not an IconFontMeta """ if key not in self._LOADED: # get the entrypoint if key not in self._PLUGINS: self._discover_fonts() ep = self._PLUGINS.get(key) if ep is None: raise KeyError(f"No plugin provides the key {key!r}") # load the entry point try: font = ep.load() except Exception as e: self._PLUGINS.pop(key) self._BLOCKED.add(ep) raise ImportError(f"Failed to load {ep.value}. Plugin blocked") from e # make sure it's a proper IconFont try: self._LOADED[key] = namespace2font(font, ep.name.upper()) except Exception as e: self._PLUGINS.pop(key) self._BLOCKED.add(ep) raise TypeError( f"Failed to create fonticon from {ep.value}: {e}" ) from e return self._LOADED[key] def dict(self) -> dict: return { key: sorted(filter(lambda x: not x.startswith("_"), cls.__dict__)) for key, cls in self._LOADED.items() } _manager = FontIconManager() get_font_class = _manager._get_font_class def discover() -> Tuple[str]: _manager._discover_fonts() def available() -> Tuple[str]: return tuple(_manager._PLUGINS) def loaded(load_all=False) -> Dict[str, List[str]]: if load_all: discover() for x in available(): with contextlib.suppress(Exception): _manager._get_font_class(x) return { key: sorted(filter(lambda x: not x.startswith("_"), cls.__dict__)) for key, cls in _manager._LOADED.items() } superqt-0.6.8/src/superqt/fonticon/_qfont_icon.py000066400000000000000000000472721463340017400222270ustar00rootroot00000000000000from __future__ import annotations import warnings from collections import abc, defaultdict from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, ClassVar, DefaultDict, Sequence, Tuple, Union, cast from qtpy import QT_VERSION from qtpy.QtCore import QObject, QPoint, QRect, QSize, Qt from qtpy.QtGui import ( QColor, QFont, QFontDatabase, QGuiApplication, QIcon, QIconEngine, QPainter, QPixmap, QPixmapCache, QTransform, ) from qtpy.QtWidgets import QApplication, QStyleOption, QWidget from typing_extensions import TypedDict from superqt.utils import QMessageHandler if TYPE_CHECKING: from ._animations import Animation class Unset: def __repr__(self) -> str: return "UNSET" _Unset = Unset() # A 16 pixel-high icon yields a font size of 14, which is pixel perfect # for font-awesome. 16 * 0.875 = 14 # The reason why the glyph size is smaller than the icon size is to # account for font bearing. DEFAULT_SCALING_FACTOR = 0.875 DEFAULT_OPACITY = 1 ValidColor = Union[ QColor, int, str, Qt.GlobalColor, Tuple[int, int, int, int], Tuple[int, int, int], None, ] StateOrMode = Union[QIcon.State, QIcon.Mode] StateModeKey = Union[StateOrMode, str, Sequence[StateOrMode]] _SM_MAP: dict[str, StateOrMode] = { "on": QIcon.State.On, "off": QIcon.State.Off, "normal": QIcon.Mode.Normal, "active": QIcon.Mode.Active, "selected": QIcon.Mode.Selected, "disabled": QIcon.Mode.Disabled, } def _norm_state_mode(key: StateModeKey) -> tuple[QIcon.State, QIcon.Mode]: """Return state/mode tuple given a variety of valid inputs. Input can be either a string, or a sequence of state or mode enums. Strings can be any combination of on, off, normal, active, selected, disabled, sep by underscore. """ _sm: Sequence[StateOrMode] if isinstance(key, str): try: _sm = [_SM_MAP[k.lower()] for k in key.split("_")] except KeyError as e: raise ValueError( f"{key!r} is not a valid state key, must be a combination of {{on, " "off, active, disabled, selected, normal} separated by underscore" ) from e else: _sm = key if isinstance(key, abc.Sequence) else [key] state = next((i for i in _sm if isinstance(i, QIcon.State)), QIcon.State.Off) mode = next((i for i in _sm if isinstance(i, QIcon.Mode)), QIcon.Mode.Normal) return state, mode class IconOptionDict(TypedDict, total=False): glyph_key: str scale_factor: float color: ValidColor opacity: float animation: Animation | None transform: QTransform | None # public facing, for a nicer IDE experience than a dict # The difference between IconOpts and _IconOptions is that all of IconOpts # all default to `_Unset` and are intended to extend some base/default option # IconOpts are *not* guaranteed to be fully capable of rendering an icon, whereas # IconOptions are. @dataclass class IconOpts: """Options for rendering an icon. Parameters ---------- glyph_key : str, optional The key of the glyph to use, e.g. `'fa5s.smile'`, by default `None` scale_factor : float, optional The scale factor to use, by default `None` color : ValidColor, optional The color to use, by default `None`. Colors may be specified as a string, `QColor`, `Qt.GlobalColor`, or a 3 or 4-tuple of integers. opacity : float, optional The opacity to use, by default `None` animation : Animation, optional The animation to use, by default `None` """ glyph_key: str | Unset = _Unset scale_factor: float | Unset = _Unset color: ValidColor | Unset = _Unset opacity: float | Unset = _Unset animation: Animation | Unset | None = _Unset transform: QTransform | Unset | None = _Unset def dict(self) -> IconOptionDict: # not using asdict due to pickle errors on animation d = {k: v for k, v in vars(self).items() if v is not _Unset} return cast(IconOptionDict, d) @dataclass class _IconOptions: """The set of options needed to render a font in a single State/Mode.""" glyph_key: str scale_factor: float = DEFAULT_SCALING_FACTOR color: ValidColor = None opacity: float = DEFAULT_OPACITY animation: Animation | None = None transform: QTransform | None = None def _update(self, icon_opts: IconOpts) -> _IconOptions: return _IconOptions(**{**vars(self), **icon_opts.dict()}) def dict(self) -> IconOptionDict: # not using asdict due to pickle errors on animation return cast(IconOptionDict, vars(self)) class _QFontIconEngine(QIconEngine): _opt_hash: str = "" def __init__(self, options: _IconOptions): super().__init__() self._opts: defaultdict[QIcon.State, dict[QIcon.Mode, _IconOptions | None]] = ( DefaultDict(dict) ) self._opts[QIcon.State.Off][QIcon.Mode.Normal] = options self.update_hash() @property def _default_opts(self) -> _IconOptions: return cast(_IconOptions, self._opts[QIcon.State.Off][QIcon.Mode.Normal]) def _add_opts(self, state: QIcon.State, mode: QIcon.Mode, opts: IconOpts) -> None: self._opts[state][mode] = self._default_opts._update(opts) self.update_hash() def clone(self) -> QIconEngine: # pragma: no cover ico = _QFontIconEngine(self._default_opts) ico._opts = self._opts.copy() return ico def _get_opts(self, state: QIcon.State, mode: QIcon.Mode) -> _IconOptions: opts = self._opts[state].get(mode) if opts: return opts opp_state = QIcon.State.Off if state == QIcon.State.On else QIcon.State.On if mode in (QIcon.Mode.Disabled, QIcon.Mode.Selected): opp_mode = ( QIcon.Mode.Disabled if mode == QIcon.Mode.Selected else QIcon.Mode.Selected ) for m, s in [ (QIcon.Mode.Normal, state), (QIcon.Mode.Active, state), (mode, opp_state), (QIcon.Mode.Normal, opp_state), (QIcon.Mode.Active, opp_state), (opp_mode, state), (opp_mode, opp_state), ]: opts = self._opts[s].get(m) if opts: return opts else: opp_mode = ( QIcon.Mode.Active if mode == QIcon.Mode.Normal else QIcon.Mode.Normal ) for m, s in [ (opp_mode, state), (mode, opp_state), (opp_mode, opp_state), (QIcon.Mode.Disabled, state), (QIcon.Mode.Selected, state), (QIcon.Mode.Disabled, opp_state), (QIcon.Mode.Selected, opp_state), ]: opts = self._opts[s].get(m) if opts: return opts return self._default_opts def paint( self, painter: QPainter, rect: QRect, mode: QIcon.Mode, state: QIcon.State, ) -> None: opts = self._get_opts(state, mode) char, family, style = QFontIconStore.key2glyph(opts.glyph_key) # font font = QFont() font.setFamily(family) # set separately for Qt6 font.setPixelSize(round(rect.height() * opts.scale_factor)) if style: font.setStyleName(style) # color if isinstance(opts.color, tuple): color_args = opts.color else: color_args = (opts.color,) if opts.color else () # animation if opts.animation is not None: opts.animation.animate(painter) # animation if opts.transform is not None: painter.setTransform(opts.transform, True) painter.save() painter.setPen(QColor(*color_args)) painter.setOpacity(opts.opacity) painter.setFont(font) with QMessageHandler(): # avoid "Populating font family aliases" warning painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, char) painter.restore() def pixmap(self, size: QSize, mode: QIcon.Mode, state: QIcon.State) -> QPixmap: # first look in cache pmckey = self._pmcKey(size, mode, state) with warnings.catch_warnings(): warnings.filterwarnings("ignore", "QPixmapCache.find") pm = QPixmapCache.find(pmckey) if pmckey else None if pm: return pm pixmap = QPixmap(size) if not size.isValid(): return pixmap pixmap.fill(Qt.GlobalColor.transparent) painter = QPainter(pixmap) self.paint(painter, QRect(QPoint(0, 0), size), mode, state) painter.end() # Apply palette-based styles for disabled/selected modes # unless the user has specifically set a color for this mode/state if mode != QIcon.Mode.Normal: ico_opts = self._opts[state].get(mode) if not ico_opts or not ico_opts.color: opt = QStyleOption() opt.palette = QGuiApplication.palette() generated = QApplication.style().generatedIconPixmap(mode, pixmap, opt) if not generated.isNull(): pixmap = generated if pmckey and not pixmap.isNull(): QPixmapCache.insert(pmckey, pixmap) return pixmap def _pmcKey(self, size: QSize, mode: QIcon.Mode, state: QIcon.State) -> str: # Qt6-style enums if self._get_opts(state, mode).animation: return "" if hasattr(mode, "value"): mode = mode.value if hasattr(state, "value"): state = state.value k = ((((((size.width()) << 11) | size.height()) << 11) | mode) << 4) | state return f"$superqt_{self._opt_hash}_{hex(k)}" def update_hash(self) -> None: hsh = id(self) for state, d in self._opts.items(): for mode, opts in d.items(): if not opts: continue hsh += hash( hash(opts.glyph_key) + hash(opts.color) + hash(state) + hash(mode) ) self._opt_hash = hex(hsh) class QFontIcon(QIcon): def __init__(self, options: _IconOptions) -> None: self._engine = _QFontIconEngine(options) super().__init__(self._engine) def addState( self, state: QIcon.State = QIcon.State.Off, mode: QIcon.Mode = QIcon.Mode.Normal, glyph_key: str | Unset = _Unset, scale_factor: float | Unset = _Unset, color: ValidColor | Unset = _Unset, opacity: float | Unset = _Unset, animation: Animation | Unset | None = _Unset, transform: QTransform | Unset | None = _Unset, ) -> None: """Set icon options for a specific mode/state.""" if glyph_key is not _Unset: QFontIconStore.key2glyph(glyph_key) # type: ignore _opts = IconOpts( glyph_key=glyph_key, scale_factor=scale_factor, color=color, opacity=opacity, animation=animation, transform=transform, ) self._engine._add_opts(state, mode, _opts) class QFontIconStore(QObject): # map of key -> (font_family, font_style) _LOADED_KEYS: ClassVar[dict[str, tuple[str, str]]] = {} # map of (font_family, font_style) -> character (char may include key) _CHARMAPS: ClassVar[dict[tuple[str, str | None], dict[str, str]]] = {} # singleton instance, use `instance()` to retrieve __instance: ClassVar[QFontIconStore | None] = None def __init__(self, parent: QObject | None = None) -> None: super().__init__(parent=parent) if tuple(cast(str, QT_VERSION).split(".")) < ("6", "0"): # QT6 drops this QApplication.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps) @classmethod def instance(cls) -> QFontIconStore: if cls.__instance is None: cls.__instance = cls() return cls.__instance @classmethod def clear(cls) -> None: cls._LOADED_KEYS.clear() cls._CHARMAPS.clear() QFontDatabase.removeAllApplicationFonts() @classmethod def _key2family(cls, key: str) -> tuple[str, str]: """Return (family, style) given a font `key`.""" key = key.split(".", maxsplit=1)[0] if key not in cls._LOADED_KEYS: from . import _plugins try: font_cls = _plugins.get_font_class(key) result = cls.addFont( font_cls.__font_file__, key, charmap=dict(font_cls.__dict__) ) if not result: # pragma: no cover raise Exception("Invalid font file") cls._LOADED_KEYS[key] = result except ValueError as e: raise ValueError( f"Unrecognized font key: {key!r}.\n" f"Known plugin keys include: {_plugins.available()}.\n" f"Loaded keys include: {list(cls._LOADED_KEYS)}." ) from e return cls._LOADED_KEYS[key] @classmethod def _ensure_char(cls, char: str, family: str, style: str) -> str: """Make sure that `char` is a glyph provided by `family` and `style`.""" if len(char) == 1 and ord(char) > 256: return char try: charmap = cls._CHARMAPS[(family, style)] except KeyError as e: raise KeyError( f"No charmap registered for font '{family} ({style})'" ) from e if char in charmap: # split in case the charmap includes the key return charmap[char].split(".", maxsplit=1)[-1] ident = _ensure_identifier(char) if ident in charmap: return charmap[ident].split(".", maxsplit=1)[-1] ident = f"{char!r} or {ident!r}" if char != ident else repr(ident) raise ValueError(f"Font '{family} ({style})' has no glyph with the key {ident}") @classmethod def key2glyph(cls, glyph_key: str) -> tuple[str, str, str | None]: """Return (char, family, style) given a `glyph_key`.""" if "." not in glyph_key: raise ValueError("Glyph key must contain a period") font_key, char = glyph_key.split(".", maxsplit=1) family, style = cls._key2family(font_key) char = cls._ensure_char(char, family, style) return char, family, style @classmethod def addFont( cls, filepath: str, prefix: str, charmap: dict[str, str] | None = None ) -> tuple[str, str] | None: r"""Add font at `filepath` to the registry under `key`. If you'd like to later use a fontkey in the form of `key.some-name`, then `charmap` must be provided and provide a mapping for all of the glyph names to their unicode numbers. If a charmap is not provided, glyphs must be directly accessed with their unicode as something like `key.\\uffff`. Parameters ---------- filepath : str Path to an OTF or TTF file containing the fonts prefix : str A key that will represent this font file when used for lookup. For example, 'fa5s' for 'Font-Awesome 5 Solid'. charmap : Dict[str, str], optional optional mapping for all of the glyph names to their unicode numbers. See note above. Returns ------- Tuple[str, str], optional font-family and font-style for the file just registered, or None if something goes wrong. """ if prefix in cls._LOADED_KEYS: warnings.warn(f"Prefix {prefix} already loaded", stacklevel=2) return None if not Path(filepath).exists(): raise FileNotFoundError(f"Font file doesn't exist: {filepath}") if QApplication.instance() is None: raise RuntimeError("Please create QApplication before adding a Font") fontId = QFontDatabase.addApplicationFont(str(Path(filepath).absolute())) if fontId < 0: # pragma: no cover warnings.warn(f"Cannot load font file: {filepath}", stacklevel=2) return None families = QFontDatabase.applicationFontFamilies(fontId) if not families: # pragma: no cover warnings.warn(f"Font file is empty!: {filepath}", stacklevel=2) return None family: str = families[0] # in Qt6, everything becomes a static member QFd: QFontDatabase | type[QFontDatabase] = ( QFontDatabase() if tuple(cast(str, QT_VERSION).split(".")) < ("6", "0") else QFontDatabase ) styles = QFd.styles(family) style: str = styles[-1] if styles else "" if not QFd.isSmoothlyScalable(family, style): # pragma: no cover warnings.warn( f"Registered font {family} ({style}) is not smoothly scalable. " "Icons may not look attractive.", stacklevel=2, ) cls._LOADED_KEYS[prefix] = (family, style) if charmap: cls._CHARMAPS[(family, style)] = charmap return (family, style) def icon( self, glyph_key: str, *, scale_factor: float = DEFAULT_SCALING_FACTOR, color: ValidColor | None = None, opacity: float = 1, animation: Animation | None = None, transform: QTransform | None = None, states: dict[str, IconOptionDict | IconOpts] | None = None, ) -> QFontIcon: self.key2glyph(glyph_key) # make sure it's a valid glyph_key default_opts = _IconOptions( glyph_key=glyph_key, scale_factor=scale_factor, color=color, opacity=opacity, animation=animation, transform=transform, ) icon = QFontIcon(default_opts) for kw, options in (states or {}).items(): if isinstance(options, IconOpts): options = default_opts._update(options).dict() icon.addState(*_norm_state_mode(kw), **options) return icon def setTextIcon( self, widget: QWidget, glyph_key: str, size: float | None = None ) -> None: """Sets text on a widget to a specific font & glyph. This is an alternative to setting a `QIcon` with a pixmap. It may be easier to combine with dynamic stylesheets. """ setText = getattr(widget, "setText", None) if not setText: # pragma: no cover raise TypeError(f"Object does not a setText method: {widget}") glyph = self.key2glyph(glyph_key)[0] size = size or DEFAULT_SCALING_FACTOR size = size if size > 1 else widget.height() * size widget.setFont(self.font(glyph_key, int(size))) setText(glyph) def font(self, font_prefix: str, size: int | None = None) -> QFont: """Create QFont for `font_prefix`.""" font_key, _ = font_prefix.split(".", maxsplit=1) family, style = self._key2family(font_key) font = QFont() font.setFamily(family) if style: font.setStyleName(style) if size: font.setPixelSize(int(size)) return font def _ensure_identifier(name: str) -> str: """Normalize string to valid identifier.""" import keyword if not name: return "" # add _ to beginning of names starting with numbers if name[0].isdigit(): name = f"_{name}" # add _ to end of reserved keywords if keyword.iskeyword(name): name += "_" # replace dashes and spaces with underscores name = name.replace("-", "_").replace(" ", "_") if not str.isidentifier(name): raise ValueError(f"Could not canonicalize name: {name!r}. (not an identifier)") return name superqt-0.6.8/src/superqt/iconify/000077500000000000000000000000001463340017400171645ustar00rootroot00000000000000superqt-0.6.8/src/superqt/iconify/__init__.py000066400000000000000000000130411463340017400212740ustar00rootroot00000000000000from __future__ import annotations import warnings from typing import TYPE_CHECKING from qtpy.QtCore import QSize, Qt from qtpy.QtGui import QIcon, QPainter, QPixmap from qtpy.QtWidgets import QApplication if TYPE_CHECKING: from typing import Literal Flip = Literal["horizontal", "vertical", "horizontal,vertical"] Rotation = Literal["90", "180", "270", 90, 180, 270, "-90", 1, 2, 3] try: from pyconify import svg_path except ModuleNotFoundError: # pragma: no cover svg_path = None class QIconifyIcon(QIcon): """QIcon backed by an iconify icon. Iconify includes 150,000+ icons from most major icon sets including Bootstrap, FontAwesome, Material Design, and many more. Search availble icons at https://icon-sets.iconify.design Once you find one you like, use the key in the format `"prefix:name"` to create an icon: `QIconifyIcon("bi:bell")`. This class is a thin wrapper around the [pyconify](https://github.com/pyapp-kit/pyconify) `svg_path` function. It pulls SVGs from iconify, creates a temporary SVG file and uses it as the source for a QIcon. SVGs are cached to disk, and persist across sessions (until `pyconify.clear_cache()` is called). Parameters are the same as `QIconifyIcon.addKey`, which can be used to add additional icons for various modes and states to the same QIcon. Parameters ---------- *key: str Icon set prefix and name. May be passed as a single string in the format `"prefix:name"` or as two separate strings: `'prefix', 'name'`. color : str, optional Icon color. If not provided, the icon will appear black (the icon fill color will be set to the string "currentColor"). flip : str, optional Flip icon. Must be one of "horizontal", "vertical", "horizontal,vertical" rotate : str | int, optional Rotate icon. Must be one of 0, 90, 180, 270, or 0, 1, 2, 3 (equivalent to 0, 90, 180, 270, respectively) dir : str, optional If 'dir' is not None, the file will be created in that directory, otherwise a default [directory](https://docs.python.org/3/library/tempfile.html#tempfile.mkstemp) is used. Examples -------- >>> from qtpy.QtWidgets import QPushButton >>> from superqt import QIconifyIcon >>> btn = QPushButton() >>> icon = QIconifyIcon("bi:alarm-fill", color="red", rotate=90) >>> btn.setIcon(icon) """ def __init__( self, *key: str, color: str | None = None, flip: Flip | None = None, rotate: Rotation | None = None, dir: str | None = None, ): if svg_path is None: # pragma: no cover raise ModuleNotFoundError( "pyconify is required to use QIconifyIcon. " "Please install it with `pip install pyconify` or use the " "`pip install superqt[iconify]` extra." ) super().__init__() self.addKey(*key, color=color, flip=flip, rotate=rotate, dir=dir) def addKey( self, *key: str, color: str | None = None, flip: Flip | None = None, rotate: Rotation | None = None, dir: str | None = None, size: QSize | None = None, mode: QIcon.Mode = QIcon.Mode.Normal, state: QIcon.State = QIcon.State.Off, ) -> None: """Add an icon to this QIcon. This is a variant of `QIcon.addFile` that uses an iconify icon keys and arguments instead of a file path. Parameters ---------- *key: str Icon set prefix and name. May be passed as a single string in the format `"prefix:name"` or as two separate strings: `'prefix', 'name'`. color : str, optional Icon color. If not provided, the icon will appear black (the icon fill color will be set to the string "currentColor"). flip : str, optional Flip icon. Must be one of "horizontal", "vertical", "horizontal,vertical" rotate : str | int, optional Rotate icon. Must be one of 0, 90, 180, 270, or 0, 1, 2, 3 (equivalent to 0, 90, 180, 270, respectively) dir : str, optional If 'dir' is not None, the file will be created in that directory, otherwise a default [directory](https://docs.python.org/3/library/tempfile.html#tempfile.mkstemp) is used. size : QSize, optional Size specified for the icon, passed to `QIcon.addFile`. mode : QIcon.Mode, optional Mode specified for the icon, passed to `QIcon.addFile`. state : QIcon.State, optional State specified for the icon, passed to `QIcon.addFile`. """ try: path = svg_path(*key, color=color, flip=flip, rotate=rotate, dir=dir) except OSError: warnings.warn( f"Unable to connect to internet, and icon {key} not cached.", stacklevel=2, ) self._draw_text_fallback(key) else: self.addFile(str(path), size or QSize(), mode, state) def _draw_text_fallback(self, key: tuple[str, ...]) -> None: if style := QApplication.style(): pixmap = style.standardPixmap(style.StandardPixmap.SP_MessageBoxQuestion) else: pixmap = QPixmap(18, 18) pixmap.fill(Qt.GlobalColor.transparent) painter = QPainter(pixmap) painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "?") painter.end() self.addPixmap(pixmap) superqt-0.6.8/src/superqt/py.typed000066400000000000000000000000001463340017400172110ustar00rootroot00000000000000superqt-0.6.8/src/superqt/qtcompat/000077500000000000000000000000001463340017400173545ustar00rootroot00000000000000superqt-0.6.8/src/superqt/qtcompat/__init__.py000066400000000000000000000012671463340017400214730ustar00rootroot00000000000000import sys import warnings from importlib import abc, util from qtpy import * # noqa warnings.warn( "The superqt.qtcompat module is deprecated as of v0.3.0. " "Please import from `qtpy` instead.", stacklevel=2, ) # forward any requests for superqt.qtcompat.* to qtpy.* class SuperQtImporter(abc.MetaPathFinder): """Pseudo-importer to forward superqt.qtcompat.* to qtpy.*.""" def find_spec(self, fullname: str, path, target=None): # type: ignore """Forward any requests for superqt.qtcompat.* to qtpy.*.""" if fullname.startswith(__name__): return util.find_spec(fullname.replace(__name__, "qtpy")) sys.meta_path.append(SuperQtImporter()) superqt-0.6.8/src/superqt/selection/000077500000000000000000000000001463340017400175115ustar00rootroot00000000000000superqt-0.6.8/src/superqt/selection/__init__.py000066400000000000000000000002641463340017400216240ustar00rootroot00000000000000from ._searchable_list_widget import QSearchableListWidget from ._searchable_tree_widget import QSearchableTreeWidget __all__ = ("QSearchableListWidget", "QSearchableTreeWidget") superqt-0.6.8/src/superqt/selection/_searchable_list_widget.py000066400000000000000000000027351463340017400247200ustar00rootroot00000000000000from qtpy.QtCore import Qt from qtpy.QtWidgets import QLineEdit, QListWidget, QVBoxLayout, QWidget class QSearchableListWidget(QWidget): def __init__(self, parent=None): super().__init__(parent) self.list_widget = QListWidget() self.filter_widget = QLineEdit() self.filter_widget.textChanged.connect(self.update_visible) layout = QVBoxLayout() layout.addWidget(self.filter_widget) layout.addWidget(self.list_widget) self.setLayout(layout) def __getattr__(self, item): if hasattr(self.list_widget, item): return getattr(self.list_widget, item) return super().__getattr__(item) def update_visible(self, text): items_text = [ x.text() for x in self.list_widget.findItems(text, Qt.MatchContains) ] for index in range(self.list_widget.count()): item = self.item(index) item.setHidden(item.text() not in items_text) def addItems(self, *args): self.list_widget.addItems(*args) self.update_visible(self.filter_widget.text()) def addItem(self, *args): self.list_widget.addItem(*args) self.update_visible(self.filter_widget.text()) def insertItems(self, *args): self.list_widget.insertItems(*args) self.update_visible(self.filter_widget.text()) def insertItem(self, *args): self.list_widget.insertItem(*args) self.update_visible(self.filter_widget.text()) superqt-0.6.8/src/superqt/selection/_searchable_tree_widget.py000066400000000000000000000104601463340017400246760ustar00rootroot00000000000000import logging from typing import Any, Iterable, Mapping from qtpy.QtCore import QRegularExpression from qtpy.QtWidgets import QLineEdit, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget class QSearchableTreeWidget(QWidget): """A tree widget for showing a mapping that can be searched by key. This is intended to be used with a read-only mapping and be conveniently created using `QSearchableTreeWidget.fromData(data)`. If the mapping changes, the easiest way to update this is by calling `setData`. The tree can be searched by entering a regular expression pattern into the `filter` line edit. An item is only shown if its, any of its ancestors', or any of its descendants' keys or values match this pattern. The regular expression follows the conventions described by the Qt docs: https://doc.qt.io/qt-5/qregularexpression.html#details Attributes ---------- tree : QTreeWidget Shows the mapping as a tree of items. filter : QLineEdit Used to filter items in the tree by matching their key against a regular expression. """ def __init__(self, parent=None): super().__init__(parent) self.tree: QTreeWidget = QTreeWidget(self) self.tree.setHeaderLabels(("Key", "Value")) self.filter: QLineEdit = QLineEdit(self) self.filter.setClearButtonEnabled(True) self.filter.textChanged.connect(self._updateVisibleItems) layout = QVBoxLayout(self) layout.addWidget(self.filter) layout.addWidget(self.tree) def setData(self, data: Mapping) -> None: """Update the mapping data shown by the tree.""" self.tree.clear() self.filter.clear() top_level_items = [_make_item(name=k, value=v) for k, v in data.items()] self.tree.addTopLevelItems(top_level_items) def _updateVisibleItems(self, pattern: str) -> None: """Recursively update the visibility of items based on the given pattern.""" expression = QRegularExpression(pattern) for i in range(self.tree.topLevelItemCount()): top_level_item = self.tree.topLevelItem(i) _update_visible_items(top_level_item, expression) @classmethod def fromData( cls, data: Mapping, *, parent: QWidget = None ) -> "QSearchableTreeWidget": """Make a searchable tree widget from a mapping.""" widget = cls(parent) widget.setData(data) return widget def _make_item(*, name: str, value: Any) -> QTreeWidgetItem: """Make a tree item where the name and value are two columns. Iterable values other than strings are recursively traversed to add child items and build a tree. In this case, mappings use keys as their names whereas other iterables use their enumerated index. """ if isinstance(value, Mapping): item = QTreeWidgetItem([name, type(value).__name__]) for k, v in value.items(): child = _make_item(name=k, value=v) item.addChild(child) elif isinstance(value, Iterable) and not isinstance(value, str): item = QTreeWidgetItem([name, type(value).__name__]) for i, v in enumerate(value): child = _make_item(name=str(i), value=v) item.addChild(child) else: item = QTreeWidgetItem([name, str(value)]) logging.debug("_make_item: %s, %s, %s", item.text(0), item.text(1), item.flags()) return item def _update_visible_items( item: QTreeWidgetItem, expression: QRegularExpression, ancestor_match: bool = False ) -> bool: """Recursively update the visibility of a tree item based on an expression. An item is visible if any of its, any of its ancestors', or any of its descendants' column's text matches the expression. Returns True if the item is visible, False otherwise. """ match = ancestor_match or any( expression.match(item.text(i)).hasMatch() for i in range(item.columnCount()) ) visible = match for i in range(item.childCount()): child = item.child(i) descendant_visible = _update_visible_items(child, expression, match) visible = visible or descendant_visible item.setHidden(not visible) logging.debug( "_update_visible_items: %s, %s", tuple(item.text(i) for i in range(item.columnCount())), visible, ) return visible superqt-0.6.8/src/superqt/sliders/000077500000000000000000000000001463340017400171715ustar00rootroot00000000000000superqt-0.6.8/src/superqt/sliders/__init__.py000066400000000000000000000007351463340017400213070ustar00rootroot00000000000000from ._labeled import ( QLabeledDoubleRangeSlider, QLabeledDoubleSlider, QLabeledRangeSlider, QLabeledSlider, ) from ._range_style import MONTEREY_SLIDER_STYLES_FIX from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider __all__ = [ "QDoubleRangeSlider", "QDoubleSlider", "QLabeledDoubleRangeSlider", "QLabeledDoubleSlider", "QLabeledRangeSlider", "QLabeledSlider", "QRangeSlider", "MONTEREY_SLIDER_STYLES_FIX", ] superqt-0.6.8/src/superqt/sliders/_generic_range_slider.py000066400000000000000000000341341463340017400240410ustar00rootroot00000000000000from typing import List, Optional, Sequence, Tuple, TypeVar, Union from qtpy import QtGui from qtpy.QtCore import Property, QEvent, QPoint, QPointF, QRect, QRectF, Qt, Signal from qtpy.QtWidgets import QSlider, QStyle, QStyleOptionSlider, QStylePainter from ._generic_slider import CC_SLIDER, SC_GROOVE, SC_HANDLE, SC_NONE, _GenericSlider from ._range_style import ( MONTEREY_SLIDER_STYLES_FIX, RangeSliderStyle, update_styles_from_stylesheet, ) _T = TypeVar("_T") SC_BAR = QStyle.SubControl.SC_ScrollBarSubPage class _GenericRangeSlider(_GenericSlider): """MultiHandle Range Slider widget. Same API as QSlider, but `value`, `setValue`, `sliderPosition`, and `setSliderPosition` are all sequences of integers. The `valueChanged` and `sliderMoved` signals also both emit a tuple of integers. """ # Emitted when the slider value has changed, with the new slider values _valuesChanged = Signal(tuple) # Emitted when sliderDown is true and the slider moves # This usually happens when the user is dragging the slider # The value is the positions of *all* handles. _slidersMoved = Signal(tuple) def __init__(self, *args, **kwargs): self._style = RangeSliderStyle() super().__init__(*args, **kwargs) self.valueChanged = self._valuesChanged self.sliderMoved = self._slidersMoved # list of values self._value: List[_T] = [20, 80] # list of current positions of each handle. same length as _value # If tracking is enabled (the default) this will be identical to _value self._position: List[_T] = [20, 80] # which handle is being pressed/hovered self._pressedIndex = 0 self._hoverIndex = 0 # whether bar length is constant when dragging the bar # if False, the bar can shorten when dragged beyond min/max self._bar_is_rigid = True # whether clicking on the bar moves all handles, or just the nearest handle self._bar_moves_all = True self._should_draw_bar = True # color self.setStyleSheet("") # ############### New Public API ####################### def barIsRigid(self) -> bool: """Whether bar length is constant when dragging the bar. If `False`, the bar can shorten when dragged beyond min/max. Default is `True`. """ return self._bar_is_rigid def setBarIsRigid(self, val: bool = True) -> None: """Whether bar length is constant when dragging the bar. If `False`, the bar can shorten when dragged beyond min/max. Default is `True`. """ self._bar_is_rigid = bool(val) def barMovesAllHandles(self) -> bool: """Whether clicking on the bar moves all handles, or just the nearest.""" return self._bar_moves_all def setBarMovesAllHandles(self, val: bool = True) -> None: """Whether clicking on the bar moves all handles, or just the nearest.""" self._bar_moves_all = bool(val) def barIsVisible(self) -> bool: """Whether to show the bar between the first and last handle.""" return self._should_draw_bar def setBarVisible(self, val: bool = True) -> None: """Whether to show the bar between the first and last handle.""" self._should_draw_bar = bool(val) def hideBar(self) -> None: """Hide the bar between the first and last handle.""" self.setBarVisible(False) def showBar(self) -> None: """Show the bar between the first and last handle.""" self.setBarVisible(True) def applyMacStylePatch(self) -> None: """Apply a QSS patch to fix sliders on macos>=12 with QT < 6. see [FAQ](../faq.md#sliders-not-dragging-properly-on-macos-12) for more details. """ super().applyMacStylePatch() self._style._macpatch = True # ############### QtOverrides ####################### def value(self) -> Tuple[_T, ...]: """Get current value of the widget as a tuple of integers.""" return tuple(self._value) def sliderPosition(self): """Get current value of the widget as a tuple of integers. If tracking is enabled (the default) this will be identical to value(). """ return tuple(float(i) for i in self._position) def setSliderPosition( # type: ignore self, pos: Union[float, Sequence[float]], index: Optional[int] = None, *, reversed: bool = False, ) -> None: """Set current position of the handles with a sequence of integers. Parameters ---------- pos : Union[float, Sequence[float]] The new position of the slider handle(s). If a sequence, it must have the same length as `value()`. If it is a scalar, index will be used to set the position of the handle at that index. index : int | None The index of the handle to set the position of. If None, the "pressedIndex" will be used. reversed : bool Order in which to set the positions. Can be useful when setting multiple positions, to avoid intermediate overlapping values. """ if isinstance(pos, (list, tuple)): val_len = len(self.value()) if len(pos) != val_len: msg = f"'sliderPosition' must have same length as 'value()' ({val_len})" raise ValueError(msg) pairs = list(enumerate(pos)) else: pairs = [(self._pressedIndex if index is None else index, pos)] if reversed: pairs = pairs[::-1] for idx, position in pairs: self._position[idx] = self._bound(position, idx) self._doSliderMove() def setStyleSheet(self, styleSheet: str) -> None: return super().setStyleSheet(self._patch_style(styleSheet)) def _patch_style(self, style: str): """Override to patch style options before painting.""" # sub-page styles render on top of the lower sliders and don't work here. if self._style._macpatch and not style: style = MONTEREY_SLIDER_STYLES_FIX override = f""" \n{type(self).__name__}::sub-page:horizontal {{background: none; border: none}} \n{type(self).__name__}::add-page:vertical {{background: none; border: none}} """ return style + override def event(self, ev: QEvent) -> bool: if ev.type() == QEvent.Type.StyleChange: update_styles_from_stylesheet(self) return super().event(ev) def mouseMoveEvent(self, ev: QtGui.QMouseEvent) -> None: if self._pressedControl == SC_BAR: ev.accept() delta = self._clickOffset - self._pixelPosToRangeValue( self._pick(self._event_position(ev)) ) self._offsetAllPositions(-delta, self._sldPosAtPress) else: super().mouseMoveEvent(ev) def _event_position(self, event): # API changes between PyQt5 (.pos()) and PyQt6 (.position()) return event.pos() if hasattr(event, "pos") else event.position() # ############### Implementation Details ####################### def _setPosition(self, val): self._position = list(val) def _bound(self, value, index=None): if isinstance(value, (list, tuple)): return type(value)(self._bound(v) for v in value) pos = super()._bound(value) if index is not None: pos = self._neighbor_bound(pos, index) return self._type_cast(pos) def _neighbor_bound(self, val, index): # make sure we don't go lower than any preceding index: min_dist = self.singleStep() _lst = self._position if index > 0: val = max(_lst[index - 1] + min_dist, val) # make sure we don't go higher than any following index: if index < (len(_lst) - 1): val = min(_lst[index + 1] - min_dist, val) return val def _getBarColor(self): return self._style.brush(self._styleOption) def _setBarColor(self, color): self._style.brush_active = color barColor = Property(QtGui.QBrush, _getBarColor, _setBarColor) """The color of the bar between the first and last handle.""" def _offsetAllPositions(self, offset: float, ref=None) -> None: if ref is None: ref = self._position if self._bar_is_rigid: # NOTE: This assumes monotonically increasing slider positions if offset > 0 and ref[-1] + offset > self.maximum(): offset = self.maximum() - ref[-1] elif ref[0] + offset < self.minimum(): offset = self.minimum() - ref[0] self.setSliderPosition([i + offset for i in ref], reversed=offset > 0) def _fixStyleOption(self, option): pass @property def _optSliderPositions(self): return [self._to_qinteger_space(p - self._minimum) for p in self._position] # SubControl Positions def _handleRect( self, handle_index: int, opt: Optional[QStyleOptionSlider] = None ) -> QRect: """Return the QRect for all handles.""" opt = opt or self._styleOption opt.sliderPosition = self._optSliderPositions[handle_index] return self.style().subControlRect(CC_SLIDER, opt, SC_HANDLE, self) def _barRect(self, opt: QStyleOptionSlider) -> QRect: """Return the QRect for the bar between the outer handles.""" r_groove = self.style().subControlRect(CC_SLIDER, opt, SC_GROOVE, self) r_bar = QRectF(r_groove) hdl_low, hdl_high = self._handleRect(0, opt), self._handleRect(-1, opt) thickness = self._style.thickness(opt) offset = self._style.offset(opt) if opt.orientation == Qt.Orientation.Horizontal: r_bar.setTop(r_bar.center().y() - thickness / 2 + offset) r_bar.setHeight(thickness) r_bar.setLeft(hdl_low.center().x()) r_bar.setRight(hdl_high.center().x()) else: r_bar.setLeft(r_bar.center().x() - thickness / 2 + offset) r_bar.setWidth(thickness) r_bar.setBottom(hdl_low.center().y()) r_bar.setTop(hdl_high.center().y()) return r_bar # Painting def _drawBar(self, painter: QStylePainter, opt: QStyleOptionSlider): brush = self._style.brush(opt) r_bar = self._barRect(opt) if isinstance(brush, QtGui.QGradient): brush.setStart(r_bar.topLeft()) brush.setFinalStop(r_bar.bottomRight()) painter.setPen(self._style.pen(opt)) painter.setBrush(brush) painter.drawRect(r_bar) def _draw_handle(self, painter: QStylePainter, opt: QStyleOptionSlider): if self._should_draw_bar: self._drawBar(painter, opt) opt.subControls = SC_HANDLE pidx = self._pressedIndex if self._pressedControl == SC_HANDLE else -1 hidx = self._hoverIndex if self._hoverControl == SC_HANDLE else -1 for idx, pos in enumerate(self._optSliderPositions): opt.sliderPosition = pos # make pressed handles appear sunken if idx == pidx: opt.state |= QStyle.StateFlag.State_Sunken else: opt.state = opt.state & ~QStyle.StateFlag.State_Sunken opt.activeSubControls = SC_HANDLE if idx == hidx else SC_NONE painter.drawComplexControl(CC_SLIDER, opt) def _updateHoverControl(self, pos): old_hover = self._hoverControl, self._hoverIndex self._hoverControl, self._hoverIndex = self._getControlAtPos(pos) if (self._hoverControl, self._hoverIndex) != old_hover: self.update() def _updatePressedControl(self, pos): opt = self._styleOption self._pressedControl, self._pressedIndex = self._getControlAtPos(pos, opt) def _setClickOffset(self, pos): if self._pressedControl == SC_BAR: self._clickOffset = self._pixelPosToRangeValue(self._pick(pos)) self._sldPosAtPress = tuple(self._position) elif self._pressedControl == SC_HANDLE: hr = self._handleRect(self._pressedIndex) self._clickOffset = self._pick(pos - hr.topLeft()) # NOTE: this is very much tied to mousepress... not a generic "get control" def _getControlAtPos( self, pos: QPoint, opt: Optional[QStyleOptionSlider] = None ) -> Tuple[QStyle.SubControl, int]: """Update self._pressedControl based on ev.pos().""" opt = opt or self._styleOption if isinstance(pos, QPointF): pos = pos.toPoint() for i in range(len(self._position)): if self._handleRect(i, opt).contains(pos): return (SC_HANDLE, i) click_pos = self._pixelPosToRangeValue(self._pick(pos)) for i, p in enumerate(self._position): if p > click_pos: if i > 0: # the click was in an internal segment if self._bar_moves_all: return (SC_BAR, i) avg = (self._position[i - 1] + self._position[i]) / 2 return (SC_HANDLE, i - 1 if click_pos < avg else i) # the click was below the minimum slider return (SC_HANDLE, 0) # the click was above the maximum slider return (SC_HANDLE, len(self._position) - 1) def _execute_scroll(self, steps_to_scroll, modifiers): if modifiers & Qt.KeyboardModifier.AltModifier: self._spreadAllPositions(shrink=steps_to_scroll < 0) else: self._offsetAllPositions(steps_to_scroll) self.triggerAction(QSlider.SliderAction.SliderMove) def _has_scroll_space_left(self, offset): return (offset > 0 and max(self._value) < self._maximum) or ( offset < 0 and min(self._value) < self._minimum ) def _spreadAllPositions(self, shrink=False, gain=1.1, ref=None) -> None: if ref is None: ref = self._position # if self._bar_is_rigid: # TODO if shrink: gain = 1 / gain center = abs(ref[-1] + ref[0]) / 2 self.setSliderPosition([((i - center) * gain) + center for i in ref]) superqt-0.6.8/src/superqt/sliders/_generic_slider.py000066400000000000000000000475131463340017400226720ustar00rootroot00000000000000"""Generic Sliders with internal python-based models. This module reimplements most of the logic from qslider.cpp in python: https://code.woboq.org/qt5/qtbase/src/widgets/widgets/qslider.cpp.html This probably looks like tremendous overkill at first (and it may be!), since a it's possible to achieve a very reasonable "float slider" by scaling input float values to some internal integer range for the QSlider, and converting back to float when getting `value()`. However, one still runs into overflow limitations due to the internal integer model. In order to circumvent them, one needs to reimplement more and more of the attributes from QSliderPrivate in order to have the slider behave like a native slider (with all of the proper signals and options). So that's what `_GenericSlider` is below. `_GenericRangeSlider` is a variant that expects `value()` and `sliderPosition()` to be a sequence of scalars rather than a single scalar (with one handle per item), and it forms the basis of QRangeSlider. """ import os import platform from typing import TypeVar from qtpy import QT_VERSION, QtGui from qtpy.QtCore import QEvent, QPoint, QPointF, QRect, Qt, Signal from qtpy.QtWidgets import ( QApplication, QSlider, QStyle, QStyleOptionSlider, QStylePainter, ) from ._range_style import MONTEREY_SLIDER_STYLES_FIX _T = TypeVar("_T") SC_NONE = QStyle.SubControl.SC_None SC_HANDLE = QStyle.SubControl.SC_SliderHandle SC_GROOVE = QStyle.SubControl.SC_SliderGroove SC_TICKMARKS = QStyle.SubControl.SC_SliderTickmarks CC_SLIDER = QStyle.ComplexControl.CC_Slider QOVERFLOW = 2**31 - 1 # whether to use the MONTEREY_SLIDER_STYLES_FIX QSS hack # for fixing sliders on macos>=12 with QT < 6 # https://bugreports.qt.io/browse/QTBUG-98093 # https://github.com/pyapp-kit/superqt/issues/74 USE_MAC_SLIDER_PATCH = ( QT_VERSION and int(QT_VERSION.split(".")[0]) < 6 and platform.system() == "Darwin" and int(platform.mac_ver()[0].split(".", maxsplit=1)[0]) >= 12 and os.getenv("USE_MAC_SLIDER_PATCH", "0") not in ("0", "False", "false") ) class _GenericSlider(QSlider): _fvalueChanged = Signal(int) _fsliderMoved = Signal(int) _frangeChanged = Signal(int, int) MAX_DISPLAY = 5000 def __init__(self, *args, **kwargs) -> None: self._minimum = 0.0 self._maximum = 99.0 self._pageStep = 10.0 self._value: _T = 0.0 # type: ignore self._position: _T = 0.0 self._singleStep = 1.0 self._offsetAccumulated = 0.0 self._inverted_appearance = False self._blocktracking = False self._tickInterval = 0.0 self._pressedControl = SC_NONE self._hoverControl = SC_NONE self._hoverRect = QRect() self._clickOffset = 0.0 # for keyboard nav self._repeatMultiplier = 1 # TODO # for wheel nav self._offset_accum = 0.0 # fraction of total range to scroll when holding Ctrl while scrolling self._control_fraction = 0.04 super().__init__(*args, **kwargs) self.valueChanged = self._fvalueChanged self.sliderMoved = self._fsliderMoved self.rangeChanged = self._frangeChanged self.setAttribute(Qt.WidgetAttribute.WA_Hover) self.setStyleSheet("") if USE_MAC_SLIDER_PATCH: self.applyMacStylePatch() def applyMacStylePatch(self) -> None: """Apply a QSS patch to fix sliders on macos>=12 with QT < 6. see [FAQ](../faq.md#sliders-not-dragging-properly-on-macos-12) for more details. """ self.setStyleSheet(MONTEREY_SLIDER_STYLES_FIX) # ############### QtOverrides ####################### def value(self) -> _T: # type: ignore return self._value def setValue(self, value: _T) -> None: value = self._bound(value) if self._value == value and self._position == value: return self._value = value if self._position != value: self._setPosition(value) if self.isSliderDown(): self.sliderMoved.emit(self.sliderPosition()) self.sliderChange(self.SliderChange.SliderValueChange) self.valueChanged.emit(self.value()) def sliderPosition(self) -> _T: # type: ignore return self._position def setSliderPosition(self, pos: _T) -> None: position = self._bound(pos) if position == self._position: return self._setPosition(position) self._doSliderMove() def singleStep(self) -> float: # type: ignore return self._singleStep def setSingleStep(self, step: float) -> None: if step != self._singleStep: self._setSteps(step, self._pageStep) def pageStep(self) -> float: # type: ignore return self._pageStep def setPageStep(self, step: float) -> None: if step != self._pageStep: self._setSteps(self._singleStep, step) def minimum(self) -> float: # type: ignore return self._minimum def setMinimum(self, min: float) -> None: self.setRange(min, max(self._maximum, min)) def maximum(self) -> float: # type: ignore return self._maximum def setMaximum(self, max: float) -> None: self.setRange(min(self._minimum, max), max) def setRange(self, min: float, max_: float) -> None: oldMin, self._minimum = self._minimum, self._type_cast(min) oldMax, self._maximum = self._maximum, self._type_cast(max(min, max_)) if oldMin != self._minimum or oldMax != self._maximum: self.sliderChange(self.SliderChange.SliderRangeChange) self.rangeChanged.emit(self._minimum, self._maximum) self.setValue(self._value) # re-bound def tickInterval(self) -> float: # type: ignore return self._tickInterval def setTickInterval(self, ts: float) -> None: self._tickInterval = max(0.0, ts) self.update() def invertedAppearance(self) -> bool: return self._inverted_appearance def setInvertedAppearance(self, inverted: bool) -> None: self._inverted_appearance = inverted self.update() def triggerAction(self, action: QSlider.SliderAction) -> None: self._blocktracking = True # other actions here # self.actionTriggered.emit(action) # FIXME: type not working for all Qt self._blocktracking = False self.setValue(self._position) def initStyleOption(self, option: QStyleOptionSlider) -> None: option.initFrom(self) option.subControls = SC_NONE option.activeSubControls = SC_NONE option.orientation = self.orientation() option.tickPosition = self.tickPosition() option.upsideDown = ( self.invertedAppearance() != (option.direction == Qt.LayoutDirection.RightToLeft) if self.orientation() == Qt.Orientation.Horizontal else not self.invertedAppearance() ) # we use the upsideDown option instead option.direction = Qt.LayoutDirection.LeftToRight # option.sliderValue = self._value # type: ignore # option.singleStep = self._singleStep # type: ignore if self.orientation() == Qt.Orientation.Horizontal: option.state |= QStyle.StateFlag.State_Horizontal # scale style option to integer space option.minimum = 0 option.maximum = self.MAX_DISPLAY option.tickInterval = self._to_qinteger_space(self._tickInterval) option.pageStep = self._to_qinteger_space(self._pageStep) option.singleStep = self._to_qinteger_space(self._singleStep) self._fixStyleOption(option) def event(self, ev: QEvent) -> bool: if ev.type() == QEvent.Type.WindowActivate: self.update() elif ev.type() in (QEvent.Type.HoverEnter, QEvent.Type.HoverMove): self._updateHoverControl(_event_position(ev)) elif ev.type() == QEvent.Type.HoverLeave: self._hoverControl = SC_NONE lastHoverRect, self._hoverRect = self._hoverRect, QRect() self.update(lastHoverRect) return super().event(ev) def mousePressEvent(self, ev: QtGui.QMouseEvent) -> None: if self._minimum == self._maximum or ev.buttons() ^ ev.button(): ev.ignore() return ev.accept() pos = _event_position(ev) # If the mouse button used is allowed to set the value if ev.button() in (Qt.MouseButton.LeftButton, Qt.MouseButton.MiddleButton): self._updatePressedControl(pos) if self._pressedControl == SC_HANDLE: opt = self._styleOption sr = self.style().subControlRect(CC_SLIDER, opt, SC_HANDLE, self) offset = sr.center() - sr.topLeft() new_pos = self._pixelPosToRangeValue(self._pick(pos - offset)) self.setSliderPosition(new_pos) self.triggerAction(QSlider.SliderAction.SliderMove) self.setRepeatAction(QSlider.SliderAction.SliderNoAction) self.update() # elif: deal with PageSetButtons else: ev.ignore() if self._pressedControl != SC_NONE: self.setRepeatAction(QSlider.SliderAction.SliderNoAction) self._setClickOffset(pos) self.update() self.setSliderDown(True) def mouseMoveEvent(self, ev: QtGui.QMouseEvent) -> None: # TODO: add pixelMetric(QStyle::PM_MaximumDragDistance, &opt, this); if self._pressedControl == SC_NONE: ev.ignore() return ev.accept() pos = self._pick(_event_position(ev)) newPosition = self._pixelPosToRangeValue(pos - self._clickOffset) self.setSliderPosition(newPosition) def mouseReleaseEvent(self, ev: QtGui.QMouseEvent) -> None: if self._pressedControl == SC_NONE or ev.buttons(): ev.ignore() return ev.accept() oldPressed = self._pressedControl self._pressedControl = SC_NONE self.setRepeatAction(QSlider.SliderAction.SliderNoAction) if oldPressed != SC_NONE: self.setSliderDown(False) self.update() def wheelEvent(self, e: QtGui.QWheelEvent) -> None: e.ignore() vertical = bool(e.angleDelta().y()) delta = e.angleDelta().y() if vertical else e.angleDelta().x() if e.inverted(): delta *= -1 orientation = Qt.Orientation.Vertical if vertical else Qt.Orientation.Horizontal if self._scrollByDelta(orientation, e.modifiers(), delta): e.accept() def paintEvent(self, ev: QtGui.QPaintEvent) -> None: painter = QStylePainter(self) opt = self._styleOption # draw groove and ticks opt.subControls = SC_GROOVE if opt.tickPosition != QSlider.TickPosition.NoTicks: opt.subControls |= SC_TICKMARKS painter.drawComplexControl(CC_SLIDER, opt) if ( opt.tickPosition != QSlider.TickPosition.NoTicks and "MONTEREY_SLIDER_STYLES_FIX" in self.styleSheet() ): # draw tick marks manually because they are badly behaved with style sheets interval = opt.tickInterval or int(self._pageStep) _range = self._maximum - self._minimum nticks = (_range + interval) // interval painter.setPen(QtGui.QColor("#C7C7C7")) half_height = 3 for i in range(int(nticks)): if self.orientation() == Qt.Orientation.Vertical: y = int((self.height() - 8) * i / (nticks - 1)) + 1 x = self.rect().center().x() painter.drawRect(x - half_height, y, 6, 1) else: x = int((self.width() - 3) * i / (nticks - 1)) + 1 y = self.rect().center().y() painter.drawRect(x, y - half_height, 1, 6) self._draw_handle(painter, opt) # ############### Implementation Details ####################### def _type_cast(self, val): return val def _setPosition(self, val): self._position = val def _bound(self, value: _T) -> _T: return self._type_cast(max(self._minimum, min(self._maximum, value))) def _fixStyleOption(self, option): option.sliderPosition = self._to_qinteger_space(self._position - self._minimum) option.sliderValue = self._to_qinteger_space(self._value - self._minimum) def _to_qinteger_space(self, val, _max=None): """Converts a value to the internal integer space.""" _max = _max or self.MAX_DISPLAY range_ = self._maximum - self._minimum if range_ == 0: return self._minimum return int(min(QOVERFLOW, val / range_ * _max)) def _pick(self, pt: QPoint) -> int: return pt.x() if self.orientation() == Qt.Orientation.Horizontal else pt.y() def _setSteps(self, single: float, page: float): self._singleStep = single self._pageStep = page self.sliderChange(QSlider.SliderChange.SliderStepsChange) def _doSliderMove(self): if not self.hasTracking(): self.update() if self.isSliderDown(): self.sliderMoved.emit(self.sliderPosition()) if self.hasTracking() and not self._blocktracking: self.triggerAction(QSlider.SliderAction.SliderMove) @property def _styleOption(self): opt = QStyleOptionSlider() self.initStyleOption(opt) return opt def _updateHoverControl(self, pos: QPoint) -> bool: lastHoverRect = self._hoverRect lastHoverControl = self._hoverControl doesHover = self.testAttribute(Qt.WidgetAttribute.WA_Hover) if lastHoverControl != self._newHoverControl(pos) and doesHover: self.update(lastHoverRect) self.update(self._hoverRect) return True return not doesHover def _newHoverControl(self, pos: QPoint) -> QStyle.SubControl: opt = self._styleOption opt.subControls = QStyle.SubControl.SC_All handleRect = self.style().subControlRect(CC_SLIDER, opt, SC_HANDLE, self) grooveRect = self.style().subControlRect(CC_SLIDER, opt, SC_GROOVE, self) tickmarksRect = self.style().subControlRect(CC_SLIDER, opt, SC_TICKMARKS, self) if handleRect.contains(pos): self._hoverRect = handleRect self._hoverControl = SC_HANDLE elif grooveRect.contains(pos): self._hoverRect = grooveRect self._hoverControl = SC_GROOVE elif tickmarksRect.contains(pos): self._hoverRect = tickmarksRect self._hoverControl = SC_TICKMARKS else: self._hoverRect = QRect() self._hoverControl = SC_NONE return self._hoverControl def _setClickOffset(self, pos: QPoint): hr = self.style().subControlRect(CC_SLIDER, self._styleOption, SC_HANDLE, self) self._clickOffset = self._pick(pos - hr.topLeft()) def _updatePressedControl(self, pos: QPoint): self._pressedControl = SC_HANDLE def _draw_handle(self, painter, opt): opt.subControls = SC_HANDLE if self._pressedControl: opt.activeSubControls = self._pressedControl opt.state |= QStyle.StateFlag.State_Sunken else: opt.activeSubControls = self._hoverControl painter.drawComplexControl(CC_SLIDER, opt) # from QSliderPrivate.pixelPosToRangeValue def _pixelPosToRangeValue(self, pos: int) -> float: opt = self._styleOption gr = self.style().subControlRect(CC_SLIDER, opt, SC_GROOVE, self) sr = self.style().subControlRect(CC_SLIDER, opt, SC_HANDLE, self) if self.orientation() == Qt.Orientation.Horizontal: sliderLength = sr.width() sliderMin = gr.x() sliderMax = gr.right() - sliderLength + 1 else: sliderLength = sr.height() sliderMin = gr.y() sliderMax = gr.bottom() - sliderLength + 1 return _sliderValueFromPosition( self._minimum, self._maximum, pos - sliderMin, sliderMax - sliderMin, opt.upsideDown, ) def _scrollByDelta(self, orientation, modifiers, delta: int) -> bool: steps_to_scroll = 0.0 pg_step = self._pageStep # in Qt scrolling to the right gives negative values. if orientation == Qt.Orientation.Horizontal: delta *= -1 offset = delta / 120 if modifiers & Qt.KeyboardModifier.ShiftModifier: # Scroll one page regardless of delta: steps_to_scroll = max(-pg_step, min(pg_step, offset * pg_step)) self._offset_accum = 0 elif modifiers & Qt.KeyboardModifier.ControlModifier: _range = self._maximum - self._minimum steps_to_scroll = offset * _range * self._control_fraction self._offset_accum = 0 else: # Calculate how many lines to scroll. Depending on what delta is (and # offset), we might end up with a fraction (e.g. scroll 1.3 lines). We can # only scroll whole lines, so we keep the reminder until next event. wheel_scroll_lines = QApplication.wheelScrollLines() steps_to_scrollF = wheel_scroll_lines * offset * self._effectiveSingleStep() # Check if wheel changed direction since last event: if self._offset_accum != 0 and (offset / self._offset_accum) < 0: self._offset_accum = 0 self._offset_accum += steps_to_scrollF # Don't scroll more than one page in any case: steps_to_scroll = max(-pg_step, min(pg_step, self._offset_accum)) self._offset_accum -= self._offset_accum if steps_to_scroll == 0: # We moved less than a line, but might still have accumulated partial # scroll, unless we already are at one of the ends. effective_offset = self._offset_accum if self.invertedControls(): effective_offset *= -1 if self._has_scroll_space_left(effective_offset): return True self._offset_accum = 0 return False if self.invertedControls(): steps_to_scroll *= -1 prevValue = self._value self._execute_scroll(steps_to_scroll, modifiers) if prevValue == self._value: self._offset_accum = 0 return False return True def _has_scroll_space_left(self, offset): return (offset > 0 and self._value < self._maximum) or ( offset < 0 and self._value < self._minimum ) def _execute_scroll(self, steps_to_scroll, modifiers): self._setPosition(self._bound(self._overflowSafeAdd(steps_to_scroll))) self.triggerAction(QSlider.SliderAction.SliderMove) def _effectiveSingleStep(self) -> float: return self._singleStep * self._repeatMultiplier def _overflowSafeAdd(self, add: float) -> float: newValue = self._value + add if add > 0 and newValue < self._value: newValue = self._maximum elif add < 0 and newValue > self._value: newValue = self._minimum return newValue # def keyPressEvent(self, ev: QtGui.QKeyEvent) -> None: # return # TODO def _event_position(ev: QEvent) -> QPoint: # safe for Qt6, Qt5, and hoverEvent evp = getattr(ev, "position", getattr(ev, "pos", None)) pos = evp() if evp else QPoint() if isinstance(pos, QPointF): pos = pos.toPoint() return pos def _sliderValueFromPosition( min: float, max: float, position: int, span: int, upsideDown: bool = False ) -> float: """Converts the given pixel `position` to a value.""" if span <= 0 or position <= 0: return max if upsideDown else min if position >= span: return min if upsideDown else max tmp = (max - min) * (position / span) return (max - tmp) if upsideDown else tmp + min superqt-0.6.8/src/superqt/sliders/_labeled.py000066400000000000000000000633111463340017400212760ustar00rootroot00000000000000from __future__ import annotations import contextlib from enum import IntEnum, IntFlag, auto from functools import partial from typing import Any, Iterable, overload from qtpy import QtGui from qtpy.QtCore import Property, QPoint, QSize, Qt, Signal from qtpy.QtGui import QFontMetrics, QValidator from qtpy.QtWidgets import ( QAbstractSlider, QBoxLayout, QDoubleSpinBox, QHBoxLayout, QSlider, QSpinBox, QStyle, QStyleOptionSpinBox, QVBoxLayout, QWidget, ) from superqt.utils import signals_blocked from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider class LabelPosition(IntEnum): NoLabel = 0 LabelsAbove = auto() LabelsBelow = auto() LabelsRight = LabelsAbove LabelsLeft = LabelsBelow LabelsOnHandle = auto() class EdgeLabelMode(IntFlag): NoLabel = 0 LabelIsRange = auto() LabelIsValue = auto() class _SliderProxy: _slider: QSlider def value(self) -> Any: return self._slider.value() def setValue(self, value: Any) -> None: self._slider.setValue(value) def sliderPosition(self) -> int: return self._slider.sliderPosition() def setSliderPosition(self, pos: int) -> None: self._slider.setSliderPosition(pos) def minimum(self) -> int: return self._slider.minimum() def setMinimum(self, minimum: int) -> None: self._slider.setMinimum(minimum) def maximum(self) -> int: return self._slider.maximum() def setMaximum(self, maximum: int) -> None: self._slider.setMaximum(maximum) def singleStep(self): return self._slider.singleStep() def setSingleStep(self, step: int) -> None: self._slider.setSingleStep(step) def pageStep(self) -> int: return self._slider.pageStep() def setPageStep(self, step: int) -> None: self._slider.setPageStep(step) def setRange(self, min: int, max: int) -> None: self._slider.setRange(min, max) def tickInterval(self) -> int: return self._slider.tickInterval() def setTickInterval(self, interval: int) -> None: self._slider.setTickInterval(interval) def tickPosition(self) -> QSlider.TickPosition: return self._slider.tickPosition() def setTickPosition(self, pos: QSlider.TickPosition) -> None: self._slider.setTickPosition(pos) def triggerAction(self, action: QAbstractSlider.SliderAction) -> None: return self._slider.triggerAction(action) def invertedControls(self) -> bool: return self._slider.invertedControls() def setInvertedControls(self, a0: bool) -> None: return self._slider.setInvertedControls(a0) def invertedAppearance(self) -> bool: return self._slider.invertedAppearance() def setInvertedAppearance(self, a0: bool) -> None: return self._slider.setInvertedAppearance(a0) def isSliderDown(self) -> bool: return self._slider.isSliderDown() def setSliderDown(self, a0: bool) -> None: return self._slider.setSliderDown(a0) def hasTracking(self) -> bool: return self._slider.hasTracking() def setTracking(self, enable: bool) -> None: return self._slider.setTracking(enable) def orientation(self) -> Qt.Orientation: return self._slider.orientation() def __getattr__(self, name: Any) -> Any: return getattr(self._slider, name) def _handle_overloaded_slider_sig( args: tuple, kwargs: dict ) -> tuple[QWidget | None, Qt.Orientation]: """Maintaining signature of QSlider.__init__.""" parent = None orientation = Qt.Orientation.Horizontal errmsg = ( "TypeError: arguments did not match any overloaded call:\n" " QSlider(parent: QWidget = None)\n" " QSlider(Qt.Orientation, parent: QWidget = None)" ) if len(args) > 2: raise TypeError(errmsg) elif len(args) == 2: if kwargs: raise TypeError(errmsg) orientation, parent = args elif args: if isinstance(args[0], QWidget): if kwargs: raise TypeError(errmsg) parent = args[0] else: orientation = args[0] parent = kwargs.get("parent", parent) return parent, orientation class QLabeledSlider(_SliderProxy, QAbstractSlider): editingFinished = Signal() _ivalueChanged = Signal(int) _isliderMoved = Signal(int) _irangeChanged = Signal(int, int) _slider_class = QSlider _slider: QSlider @overload def __init__(self, parent: QWidget | None = ...) -> None: ... @overload def __init__( self, orientation: Qt.Orientation, parent: QWidget | None = ... ) -> None: ... def __init__(self, *args: Any, **kwargs: Any) -> None: parent, orientation = _handle_overloaded_slider_sig(args, kwargs) super().__init__(parent) # accept focus events fp = self.style().styleHint(QStyle.StyleHint.SH_Button_FocusPolicy) self.setFocusPolicy(Qt.FocusPolicy(fp)) self._slider = self._slider_class(parent=self) self._label = SliderLabel(self._slider, connect=self._setValue, parent=self) self._edge_label_mode: EdgeLabelMode = EdgeLabelMode.LabelIsValue self._rename_signals() self._slider.actionTriggered.connect(self.actionTriggered.emit) self._slider.rangeChanged.connect(self._on_slider_range_changed) self._slider.sliderMoved.connect(self.sliderMoved.emit) self._slider.sliderPressed.connect(self.sliderPressed.emit) self._slider.sliderReleased.connect(self.sliderReleased.emit) self._slider.valueChanged.connect(self._on_slider_value_changed) self._label.editingFinished.connect(self.editingFinished) self.setOrientation(orientation) # ------------------- public API ------------------- def setOrientation(self, orientation: Qt.Orientation) -> None: """Set orientation, value will be 'horizontal' or 'vertical'.""" self._slider.setOrientation(orientation) marg = (0, 0, 0, 0) if orientation == Qt.Orientation.Vertical: layout = QVBoxLayout() layout.addWidget(self._slider, alignment=Qt.AlignmentFlag.AlignHCenter) layout.addWidget(self._label, alignment=Qt.AlignmentFlag.AlignHCenter) self._label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.setSpacing(1) else: if self._edge_label_mode == EdgeLabelMode.NoLabel: marg = (0, 0, 5, 0) layout = QHBoxLayout() # type: ignore layout.addWidget(self._slider) layout.addWidget(self._label) self._label.setAlignment(Qt.AlignmentFlag.AlignRight) layout.setSpacing(6) old_layout = self.layout() if old_layout is not None: QWidget().setLayout(old_layout) layout.setContentsMargins(*marg) self.setLayout(layout) def edgeLabelMode(self) -> EdgeLabelMode: """Return current `EdgeLabelMode`.""" return self._edge_label_mode def setEdgeLabelMode(self, opt: EdgeLabelMode) -> None: """Set the `EdgeLabelMode`. Parameters ---------- opt : EdgeLabelMode To show no label, use `EdgeLabelMode.NoLabel`. To show the value of the slider, use `EdgeLabelMode.LabelIsValue`. To show `value / maximum`, use `EdgeLabelMode.LabelIsValue | EdgeLabelMode.LabelIsRange`. """ if opt is EdgeLabelMode.LabelIsRange: raise ValueError( "mode must be one of 'EdgeLabelMode.NoLabel' or " "'EdgeLabelMode.LabelIsValue' or" "'EdgeLabelMode.LabelIsValue | EdgeLabelMode.LabelIsRange'." ) self._edge_label_mode = opt if not self._edge_label_mode: self._label.hide() w = 5 if self.orientation() == Qt.Orientation.Horizontal else 0 self.layout().setContentsMargins(0, 0, w, 0) if opt & EdgeLabelMode.LabelIsValue: if self.isVisible(): self._label.show() self._label.setMode(opt) self._label.setValue(self._slider.value()) self.layout().setContentsMargins(0, 0, 0, 0) self._on_slider_range_changed(self.minimum(), self.maximum()) # putting this after labelMode methods for the sake of mypy EdgeLabelMode = EdgeLabelMode # --------------------- private api -------------------- def _on_slider_range_changed(self, min_: int, max_: int) -> None: slash = " / " if self._edge_label_mode & EdgeLabelMode.LabelIsValue else "" if self._edge_label_mode & EdgeLabelMode.LabelIsRange: self._label.setSuffix(f"{slash}{max_}") self.rangeChanged.emit(min_, max_) def _on_slider_value_changed(self, v: Any) -> None: self._label.setValue(v) self.valueChanged.emit(v) def _setValue(self, value: float) -> None: """Convert the value from float to int before setting the slider value.""" self._slider.setValue(int(value)) def _rename_signals(self) -> None: self.valueChanged = self._ivalueChanged self.sliderMoved = self._isliderMoved self.rangeChanged = self._irangeChanged class QLabeledDoubleSlider(QLabeledSlider): _slider_class = QDoubleSlider _slider: QDoubleSlider _fvalueChanged = Signal(float) _fsliderMoved = Signal(float) _frangeChanged = Signal(float, float) @overload def __init__(self, parent: QWidget | None = ...) -> None: ... @overload def __init__( self, orientation: Qt.Orientation, parent: QWidget | None = ... ) -> None: ... def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.setDecimals(2) def _setValue(self, value: float) -> None: """Convert the value from float to int before setting the slider value.""" self._slider.setValue(value) def _rename_signals(self) -> None: self.valueChanged = self._fvalueChanged self.sliderMoved = self._fsliderMoved self.rangeChanged = self._frangeChanged def decimals(self) -> int: return self._label.decimals() def setDecimals(self, prec: int) -> None: self._label.setDecimals(prec) class QLabeledRangeSlider(_SliderProxy, QAbstractSlider): _valueChanged = Signal(tuple) _sliderPressed = Signal() _sliderReleased = Signal() editingFinished = Signal() _slider_class = QRangeSlider _slider: QRangeSlider @overload def __init__(self, parent: QWidget | None = ...) -> None: ... @overload def __init__( self, orientation: Qt.Orientation, parent: QWidget | None = ... ) -> None: ... def __init__(self, *args: Any, **kwargs: Any) -> None: parent, orientation = _handle_overloaded_slider_sig(args, kwargs) super().__init__(parent) self._rename_signals() self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating) self._handle_labels: list[SliderLabel] = [] self._handle_label_position: LabelPosition = LabelPosition.LabelsAbove # for fine tuning label position self.label_shift_x = 0 self.label_shift_y = 0 self._slider = self._slider_class() self._slider.valueChanged.connect(self.valueChanged.emit) self._slider.sliderPressed.connect(self.sliderPressed.emit) self._slider.sliderReleased.connect(self.sliderReleased.emit) self._slider.rangeChanged.connect(self.rangeChanged.emit) self.sliderMoved = self._slider._slidersMoved self._min_label = SliderLabel( self._slider, alignment=Qt.AlignmentFlag.AlignLeft, connect=self._min_label_edited, ) self._max_label = SliderLabel( self._slider, alignment=Qt.AlignmentFlag.AlignRight, connect=self._max_label_edited, ) self._min_label.editingFinished.connect(self.editingFinished) self._max_label.editingFinished.connect(self.editingFinished) self.setEdgeLabelMode(EdgeLabelMode.LabelIsRange) self._slider.valueChanged.connect(self._on_value_changed) self._slider.rangeChanged.connect(self._on_range_changed) self._on_value_changed(self._slider.value()) self._on_range_changed(self._slider.minimum(), self._slider.maximum()) self.setOrientation(orientation) # --------------------- public API ------------------- def handleLabelPosition(self) -> LabelPosition: """Return where/whether labels are shown adjacent to slider handles.""" return self._handle_label_position def setHandleLabelPosition(self, opt: LabelPosition) -> None: """Set where/whether labels are shown adjacent to slider handles.""" self._handle_label_position = opt for lbl in self._handle_labels: lbl.setVisible(bool(opt)) trans = opt == LabelPosition.LabelsOnHandle # TODO: make double clickable to edit lbl.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, trans) self.setOrientation(self.orientation()) def edgeLabelMode(self) -> EdgeLabelMode: """Return current `EdgeLabelMode`.""" return self._edge_label_mode def setEdgeLabelMode(self, opt: EdgeLabelMode) -> None: """Set `EdgeLabelMode`, controls what is shown at the min/max labels.""" self._edge_label_mode = opt if not self._edge_label_mode: self._min_label.hide() self._max_label.hide() else: if self.isVisible(): self._min_label.show() self._max_label.show() self._min_label.setMode(opt) self._max_label.setMode(opt) if opt == EdgeLabelMode.LabelIsValue: v0, *_, v1 = self._slider.value() self._min_label.setValue(v0) self._max_label.setValue(v1) elif opt == EdgeLabelMode.LabelIsRange: self._min_label.setValue(self._slider.minimum()) self._max_label.setValue(self._slider.maximum()) self._reposition_labels() def setRange(self, min: int, max: int) -> None: self._on_range_changed(min, max) def _add_labels(self, layout: QBoxLayout, inverted: bool = False) -> None: if inverted: first, second = self._max_label, self._min_label else: first, second = self._min_label, self._max_label layout.addWidget(first) layout.addWidget(self._slider) layout.addWidget(second) def setOrientation(self, orientation: Qt.Orientation) -> None: """Set orientation, value will be 'horizontal' or 'vertical'.""" self._slider.setOrientation(orientation) inverted = self._slider.invertedAppearance() marg = (0, 0, 0, 0) if orientation == Qt.Orientation.Vertical: layout: QBoxLayout = QVBoxLayout() layout.setSpacing(1) self._add_labels(layout, inverted=not inverted) # TODO: set margins based on label width if self._handle_label_position == LabelPosition.LabelsLeft: marg = (30, 0, 0, 0) elif self._handle_label_position == LabelPosition.LabelsRight: marg = (0, 0, 20, 0) layout.setAlignment(Qt.AlignmentFlag.AlignCenter) else: layout = QHBoxLayout() layout.setSpacing(7) if self._handle_label_position == LabelPosition.LabelsBelow: marg = (0, 0, 0, 25) elif self._handle_label_position == LabelPosition.LabelsAbove: marg = (0, 25, 0, 0) self._add_labels(layout, inverted=inverted) # remove old layout old_layout = self.layout() if old_layout is not None: QWidget().setLayout(old_layout) self.setLayout(layout) layout.setContentsMargins(*marg) super().setOrientation(orientation) self._reposition_labels() def setInvertedAppearance(self, a0: bool) -> None: self._slider.setInvertedAppearance(a0) self.setOrientation(self._slider.orientation()) def resizeEvent(self, a0: Any) -> None: super().resizeEvent(a0) self._reposition_labels() # putting this after methods above for the sake of mypy LabelPosition = LabelPosition EdgeLabelMode = EdgeLabelMode def _getBarColor(self) -> QtGui.QBrush: return self._slider._style.brush(self._slider._styleOption) def _setBarColor(self, color: str) -> None: self._slider._style.brush_active = color barColor = Property(QtGui.QBrush, _getBarColor, _setBarColor) """The color of the bar between the first and last handle.""" # ------------- private methods ---------------- def _rename_signals(self) -> None: self.valueChanged = self._valueChanged self.sliderReleased = self._sliderReleased self.sliderPressed = self._sliderPressed def _reposition_labels(self) -> None: if ( not self._handle_labels or self._handle_label_position == LabelPosition.NoLabel ): return horizontal = self.orientation() == Qt.Orientation.Horizontal labels_above = self._handle_label_position == LabelPosition.LabelsAbove labels_on_handle = self._handle_label_position == LabelPosition.LabelsOnHandle last_edge = None labels: Iterable[tuple[int, SliderLabel]] = enumerate(self._handle_labels) if self._slider.invertedAppearance(): labels = reversed(list(labels)) for i, label in labels: rect = self._slider._handleRect(i) dx = (-label.width() / 2) + 2 dy = -label.height() / 2 if labels_above: # or on the right if horizontal: dy *= 3 else: dx *= -1 elif labels_on_handle: if horizontal: dy += 0.5 else: dx += 0.5 else: if horizontal: dy *= -1 else: dx *= 3 pos = self._slider.mapToParent(rect.center()) pos += QPoint(int(dx + self.label_shift_x), int(dy + self.label_shift_y)) if last_edge is not None: # prevent label overlap if horizontal: pos.setX(int(max(pos.x(), last_edge.x() + label.width() / 2 + 12))) else: pos.setY(int(min(pos.y(), last_edge.y() - label.height() / 2 - 4))) label.move(pos) last_edge = pos label.clearFocus() label.raise_() label.show() self.update() def _min_label_edited(self, val: float) -> None: if self._edge_label_mode == EdgeLabelMode.LabelIsRange: self.setMinimum(val) else: v = list(self._slider.value()) v[0] = val self.setValue(v) self._reposition_labels() def _max_label_edited(self, val: float) -> None: if self._edge_label_mode == EdgeLabelMode.LabelIsRange: self.setMaximum(val) else: v = list(self._slider.value()) v[-1] = val self.setValue(v) self._reposition_labels() def _on_value_changed(self, v: tuple[int, ...]) -> None: if self._edge_label_mode == EdgeLabelMode.LabelIsValue: self._min_label.setValue(v[0]) self._max_label.setValue(v[-1]) if len(v) != len(self._handle_labels): for lbl in self._handle_labels: lbl.setParent(None) lbl.deleteLater() self._handle_labels.clear() for n, val in enumerate(self._slider.value()): _cb = partial(self._slider.setSliderPosition, index=n) s = SliderLabel(self._slider, parent=self, connect=_cb) s.editingFinished.connect(self.editingFinished) s.setValue(val) self._handle_labels.append(s) else: for val, label in zip(v, self._handle_labels): label.setValue(val) self._reposition_labels() def _on_range_changed(self, min: int, max: int) -> None: if (min, max) != (self._slider.minimum(), self._slider.maximum()): self._slider.setRange(min, max) for lbl in self._handle_labels: lbl.setRange(min, max) if self._edge_label_mode == EdgeLabelMode.LabelIsRange: self._min_label.setValue(min) self._max_label.setValue(max) self._reposition_labels() # def setValue(self, value) -> None: # super().setValue(value) # self.sliderChange(QSlider.SliderValueChange) class QLabeledDoubleRangeSlider(QLabeledRangeSlider): _slider_class = QDoubleRangeSlider _slider: QDoubleRangeSlider _frangeChanged = Signal(float, float) @overload def __init__(self, parent: QWidget | None = ...) -> None: ... @overload def __init__( self, orientation: Qt.Orientation, parent: QWidget | None = ... ) -> None: ... def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.setDecimals(2) def _rename_signals(self) -> None: super()._rename_signals() self.rangeChanged = self._frangeChanged def decimals(self) -> int: return self._min_label.decimals() def setDecimals(self, prec: int) -> None: self._min_label.setDecimals(prec) self._max_label.setDecimals(prec) for lbl in self._handle_labels: lbl.setDecimals(prec) def _getBarColor(self) -> QtGui.QBrush: return self._slider._style.brush(self._slider._styleOption) def _setBarColor(self, color: str) -> None: self._slider._style.brush_active = color barColor = Property(QtGui.QBrush, _getBarColor, _setBarColor) """The color of the bar between the first and last handle.""" class SliderLabel(QDoubleSpinBox): def __init__( self, slider: QSlider, parent=None, alignment=Qt.AlignmentFlag.AlignCenter, connect=None, ) -> None: super().__init__(parent=parent) self._slider = slider self.setFocusPolicy(Qt.FocusPolicy.ClickFocus) self.setMode(EdgeLabelMode.LabelIsValue) self.setDecimals(0) self.setRange(slider.minimum(), slider.maximum()) slider.rangeChanged.connect(self._update_size) self.setAlignment(alignment) self.setButtonSymbols(QSpinBox.ButtonSymbols.NoButtons) self.setStyleSheet("background:transparent; border: 0;") if connect is not None: self.editingFinished.connect(lambda: connect(self.value())) self.editingFinished.connect(self._silent_clear_focus) self._update_size() def setDecimals(self, prec: int) -> None: super().setDecimals(prec) self._update_size() def setValue(self, val: Any) -> None: super().setValue(val) if self._mode == EdgeLabelMode.LabelIsRange: self._update_size() def setMaximum(self, max: float) -> None: super().setMaximum(max) if self._mode == EdgeLabelMode.LabelIsValue: self._update_size() def setMinimum(self, min: float) -> None: super().setMinimum(min) if self._mode == EdgeLabelMode.LabelIsValue: self._update_size() def setMode(self, opt: EdgeLabelMode) -> None: # when the edge labels are controlling slider range, # we want them to have a big range, but not have a huge label self._mode = opt if opt == EdgeLabelMode.LabelIsRange: self.setMinimum(-9999999) self.setMaximum(9999999) with contextlib.suppress(Exception): self._slider.rangeChanged.disconnect(self.setRange) else: self.setMinimum(self._slider.minimum()) self.setMaximum(self._slider.maximum()) self._slider.rangeChanged.connect(self.setRange) self._update_size() # --------------- private ---------------- def _silent_clear_focus(self) -> None: with signals_blocked(self): self.clearFocus() def _update_size(self, *_: Any) -> None: # fontmetrics to measure the width of text fm = QFontMetrics(self.font()) h = self.sizeHint().height() fixed_content = self.prefix() + self.suffix() + " " if self._mode & EdgeLabelMode.LabelIsValue: # determine width based on min/max/specialValue mintext = self.textFromValue(self.minimum())[:18] maxtext = self.textFromValue(self.maximum())[:18] w = max(0, _fm_width(fm, mintext + fixed_content)) w = max(w, _fm_width(fm, maxtext + fixed_content)) if self.specialValueText(): w = max(w, _fm_width(fm, self.specialValueText())) if self._mode & EdgeLabelMode.LabelIsRange: w += 8 # it seems as thought suffix() is not enough else: w = max(0, _fm_width(fm, self.textFromValue(self.value()))) + 3 w += 3 # cursor blinking space # get the final size hint opt = QStyleOptionSpinBox() self.initStyleOption(opt) size = self.style().sizeFromContents( QStyle.ContentsType.CT_SpinBox, opt, QSize(w, h), self ) self.setFixedSize(size) def validate( self, input_: str | None, pos: int ) -> tuple[QValidator.State, str, int]: # fake like an integer spinbox if input_ and "." in input_ and self.decimals() < 1: return QValidator.State.Invalid, input_, len(input_) return super().validate(input_, pos) def _fm_width(fm: QFontMetrics, text: str) -> int: if hasattr(fm, "horizontalAdvance"): return fm.horizontalAdvance(text) return fm.width(text) superqt-0.6.8/src/superqt/sliders/_range_style.py000066400000000000000000000226701463340017400222250ustar00rootroot00000000000000from __future__ import annotations import platform import re from dataclasses import dataclass, replace from typing import TYPE_CHECKING from qtpy.QtCore import Qt from qtpy.QtGui import ( QBrush, QColor, QGradient, QLinearGradient, QPalette, QRadialGradient, ) from qtpy.QtWidgets import QApplication, QSlider, QStyleOptionSlider if TYPE_CHECKING: from ._generic_range_slider import _GenericRangeSlider @dataclass class RangeSliderStyle: brush_active: str | None = None brush_inactive: str | None = None brush_disabled: str | None = None pen_active: str | None = None pen_inactive: str | None = None pen_disabled: str | None = None vertical_thickness: float | None = None horizontal_thickness: float | None = None tick_offset: float | None = None tick_bar_alpha: float | None = None v_offset: float | None = None h_offset: float | None = None has_stylesheet: bool = False _macpatch: bool = False def brush(self, opt: QStyleOptionSlider) -> QBrush: cg = opt.palette.currentColorGroup() attr = { QPalette.ColorGroup.Active: "brush_active", # 0 QPalette.ColorGroup.Disabled: "brush_disabled", # 1 QPalette.ColorGroup.Inactive: "brush_inactive", # 2 }[cg] _val = getattr(self, attr) if not _val: if self.has_stylesheet: # if someone set a general style sheet but didn't specify # :active, :inactive, etc... then Qt just uses whatever they # DID specify for i in ("active", "inactive", "disabled"): _val = getattr(self, f"brush_{i}") if _val: break else: _val = getattr(SYSTEM_STYLE, attr) if _val is None: return QBrush() if isinstance(_val, str): val = QColor(_val) if not val.isValid(): val = parse_color(_val, default_attr=attr) else: val = _val if opt.tickPosition != QSlider.TickPosition.NoTicks: val.setAlphaF(self.tick_bar_alpha or SYSTEM_STYLE.tick_bar_alpha) return QBrush(val) def pen(self, opt: QStyleOptionSlider) -> Qt.PenStyle | QColor: cg = opt.palette.currentColorGroup() attr = { QPalette.ColorGroup.Active: "pen_active", # 0 QPalette.ColorGroup.Disabled: "pen_disabled", # 1 QPalette.ColorGroup.Inactive: "pen_inactive", # 2 }[cg] val = getattr(self, attr) or getattr(SYSTEM_STYLE, attr) if not val: return Qt.PenStyle.NoPen if isinstance(val, str): val = QColor(val) if opt.tickPosition != QSlider.TickPosition.NoTicks: val.setAlphaF(self.tick_bar_alpha or SYSTEM_STYLE.tick_bar_alpha) return val def offset(self, opt: QStyleOptionSlider) -> int: off = 0 if not self.has_stylesheet: tp = opt.tickPosition if opt.orientation == Qt.Orientation.Horizontal: if not self._macpatch: off += self.h_offset or SYSTEM_STYLE.h_offset or 0 else: off += self.v_offset or SYSTEM_STYLE.v_offset or 0 if tp == QSlider.TickPosition.TicksAbove: off += self.tick_offset or SYSTEM_STYLE.tick_offset elif tp == QSlider.TickPosition.TicksBelow: off -= self.tick_offset or SYSTEM_STYLE.tick_offset return off def thickness(self, opt: QStyleOptionSlider) -> float: if opt.orientation == Qt.Orientation.Horizontal: return self.horizontal_thickness or SYSTEM_STYLE.horizontal_thickness else: return self.vertical_thickness or SYSTEM_STYLE.vertical_thickness # ########## System-specific default styles ############ BASE_STYLE = RangeSliderStyle( brush_active="#3B88FD", brush_inactive="#8F8F8F", brush_disabled="#BBBBBB", pen_active=None, pen_inactive=None, pen_disabled=None, vertical_thickness=4, horizontal_thickness=4, tick_offset=0, tick_bar_alpha=0.3, v_offset=0, h_offset=0, has_stylesheet=False, ) CATALINA_STYLE = replace( BASE_STYLE, brush_active="#3B88FD", brush_inactive="#8F8F8F", brush_disabled="#D2D2D2", horizontal_thickness=3, vertical_thickness=3, tick_bar_alpha=0.3, tick_offset=4, ) # I can no longer reproduce the cases in which this was necessary # if QT_VERSION and int(QT_VERSION.split(".")[0]) == 6: # CATALINA_STYLE = replace(CATALINA_STYLE, tick_offset=2) BIG_SUR_STYLE = replace( CATALINA_STYLE, brush_active="#0A81FE", brush_inactive="#D5D5D5", brush_disabled="#E6E6E6", tick_offset=0, horizontal_thickness=4, vertical_thickness=4, h_offset=-2, tick_bar_alpha=0.2, ) # I can no longer reproduce the cases in which this was necessary # if QT_VERSION and int(QT_VERSION.split(".")[0]) == 6: # BIG_SUR_STYLE = replace(BIG_SUR_STYLE, tick_offset=-3) WINDOWS_STYLE = replace( BASE_STYLE, brush_active="#550179D7", brush_inactive="#330179D7", brush_disabled=None, ) LINUX_STYLE = replace( BASE_STYLE, brush_active="#44A0D9", brush_inactive="#44A0D9", brush_disabled="#44A0D9", pen_active="#286384", pen_inactive="#286384", pen_disabled="#286384", ) SYSTEM = platform.system() if SYSTEM == "Darwin": if int(platform.mac_ver()[0].split(".", maxsplit=1)[0]) >= 11: SYSTEM_STYLE = BIG_SUR_STYLE else: SYSTEM_STYLE = CATALINA_STYLE elif SYSTEM == "Windows": SYSTEM_STYLE = WINDOWS_STYLE elif SYSTEM == "Linux": SYSTEM_STYLE = LINUX_STYLE else: SYSTEM_STYLE = BASE_STYLE # ################ Stylesheet parsing logic ######################## qlineargrad_pattern = re.compile( r""" qlineargradient\( x1:\s*(?P\d*\.?\d+),\s* y1:\s*(?P\d*\.?\d+),\s* x2:\s*(?P\d*\.?\d+),\s* y2:\s*(?P\d*\.?\d+),\s* stop:0\s*(?P\S+),.* stop:1\s*(?P\S+) \)""", re.X, ) qradial_pattern = re.compile( r""" qradialgradient\( cx:\s*(?P\d*\.?\d+),\s* cy:\s*(?P\d*\.?\d+),\s* radius:\s*(?P\d*\.?\d+),\s* fx:\s*(?P\d*\.?\d+),\s* fy:\s*(?P\d*\.?\d+),\s* stop:0\s*(?P\S+),.* stop:1\s*(?P\S+) \)""", re.X, ) rgba_pattern = re.compile( r""" rgba?\( (?P\d+),\s* (?P\d+),\s* (?P\d+),?\s*(?P\d+)?\) """, re.X, ) def parse_color(color: str, default_attr: str) -> QColor | QGradient: qc = QColor(color) if qc.isValid(): return qc match = rgba_pattern.search(color) if match: rgba = [int(x) if x else 255 for x in match.groups()] return QColor(*rgba) # try linear gradient: match = qlineargrad_pattern.search(color) grad: QGradient if match: grad = QLinearGradient(*(float(i) for i in match.groups()[:4])) grad.setColorAt(0, QColor(match.groupdict()["stop0"])) grad.setColorAt(1, QColor(match.groupdict()["stop1"])) return grad # try linear gradient: match = qradial_pattern.search(color) if match: grad = QRadialGradient(*(float(i) for i in match.groups()[:5])) grad.setColorAt(0, QColor(match.groupdict()["stop0"])) grad.setColorAt(1, QColor(match.groupdict()["stop1"])) return grad # fallback to dark gray return QColor(getattr(SYSTEM_STYLE, default_attr)) def update_styles_from_stylesheet(obj: _GenericRangeSlider) -> None: qss: str = obj.styleSheet() parent = obj.parent() while parent and hasattr(parent, "styleSheet"): qss = parent.styleSheet() + qss parent = parent.parent() qss = QApplication.instance().styleSheet() + qss if not qss: return if MONTEREY_SLIDER_STYLES_FIX in qss: qss = qss.replace(MONTEREY_SLIDER_STYLES_FIX, "") obj._style._macpatch = True else: obj._style._macpatch = False # Find bar height/width for orient, dim in (("horizontal", "height"), ("vertical", "width")): match = re.search(rf"Slider::groove:{orient}\s*{{\s*([^}}]+)}}", qss, re.S) if match: for line in reversed(match.groups()[0].splitlines()): bgrd = re.search(rf"{dim}\s*:\s*(\d+)", line) if bgrd: thickness = float(bgrd.groups()[-1]) setattr(obj._style, f"{orient}_thickness", thickness) obj._style.has_stylesheet = True # a fix for https://bugreports.qt.io/browse/QTBUG-98093 MONTEREY_SLIDER_STYLES_FIX = """ /* MONTEREY_SLIDER_STYLES_FIX */ QSlider::groove { background: #DFDFDF; border: 1px solid #DBDBDB; border-radius: 2px; } QSlider::groove:horizontal { height: 2px; margin: 2px; } QSlider::groove:vertical { width: 2px; margin: 2px 0 6px 0; } QSlider::handle { background: white; border: 0.5px solid #DADADA; width: 19.5px; height: 19.5px; border-radius: 10.5px; } QSlider::handle:horizontal { margin: -10px -2px; } QSlider::handle:vertical { margin: -2px -10px; } QSlider::handle:pressed { background: #F0F0F0; } QSlider::sub-page:horizontal { background: #0981FE; border-radius: 2px; margin: 2px; height: 2px; } QSlider::add-page:vertical { background: #0981FE; border-radius: 2px; margin: 2px 0 6px 0; width: 2px; } """.strip() superqt-0.6.8/src/superqt/sliders/_sliders.py000066400000000000000000000020671463340017400213540ustar00rootroot00000000000000from qtpy.QtCore import Signal from ._generic_range_slider import _GenericRangeSlider from ._generic_slider import _GenericSlider class _IntMixin: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._singleStep = 1 def _type_cast(self, value) -> int: return int(round(value)) class _FloatMixin: _fvalueChanged = Signal(float) _fsliderMoved = Signal(float) _frangeChanged = Signal(float, float) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._singleStep = 0.01 self._pageStep = 0.1 def _type_cast(self, value) -> float: return float(value) class QDoubleSlider(_FloatMixin, _GenericSlider): pass class QIntSlider(_IntMixin, _GenericSlider): # mostly just an example... use QSlider instead. valueChanged = Signal(int) class QRangeSlider(_IntMixin, _GenericRangeSlider): pass class QDoubleRangeSlider(_FloatMixin, QRangeSlider): pass # QRangeSlider.__doc__ += "\n" + textwrap.indent(QSlider.__doc__, " ") superqt-0.6.8/src/superqt/spinbox/000077500000000000000000000000001463340017400172065ustar00rootroot00000000000000superqt-0.6.8/src/superqt/spinbox/__init__.py000066400000000000000000000001071463340017400213150ustar00rootroot00000000000000from ._intspin import QLargeIntSpinBox __all__ = ["QLargeIntSpinBox"] superqt-0.6.8/src/superqt/spinbox/_intspin.py000066400000000000000000000152521463340017400214100ustar00rootroot00000000000000import math from enum import Enum from qtpy.QtCore import QSize, Qt, Signal from qtpy.QtGui import QFontMetrics, QValidator from qtpy.QtWidgets import QAbstractSpinBox, QStyle, QStyleOptionSpinBox class _EmitPolicy(Enum): EmitIfChanged = 0 AlwaysEmit = 1 NeverEmit = 2 class _AnyIntValidator(QValidator): def __init__(self, parent=None) -> None: super().__init__(parent) def validate(self, input: str, pos: int): if not input.lstrip("-"): return QValidator.State.Intermediate, input, len(input) if input.lstrip("-").isnumeric(): return QValidator.State.Acceptable, input, len(input) return QValidator.State.Invalid, input, len(input) class QLargeIntSpinBox(QAbstractSpinBox): """An integer spinboxes backed by unbound python integer. Qt's built-in ``QSpinBox`` is backed by a signed 32-bit integer. This could become limiting, particularly in large dense segmentations. This class behaves like a ``QSpinBox`` backed by an unbound python int. Does not yet support "prefix", "suffix" or "specialValue" like QSpinBox. """ textChanged = Signal(str) valueChanged = Signal(object) # object instead of int for large ints def __init__(self, parent=None) -> None: super().__init__(parent) self._value: int = 0 self._minimum: int = 0 self._maximum: int = 2**64 - 1 self._single_step: int = 1 self._step_type: QAbstractSpinBox.StepType = ( QAbstractSpinBox.StepType.DefaultStepType ) self._pending_emit = False validator = _AnyIntValidator(self) self.lineEdit().setValidator(validator) self.lineEdit().textChanged.connect(self._editor_text_changed) self.setValue(0) # ############### Public Functions ####################### def value(self): return self._value def setValue(self, value): self._setValue(value, _EmitPolicy.EmitIfChanged) def minimum(self): return self._minimum def setMinimum(self, min): self._minimum = int(min) if self._minimum > self._value: self.setValue(self._minimum) def maximum(self): return self._maximum def setMaximum(self, max): self._maximum = int(max) if self._maximum < self._value: self.setValue(self._maximum) def setRange(self, minimum, maximum): if maximum < minimum: maximum = minimum self.setMinimum(minimum) self.setMaximum(maximum) def singleStep(self): return self._single_step def setSingleStep(self, step): self._single_step = int(step) def setStepType(self, stepType: QAbstractSpinBox.StepType) -> None: self._step_type = stepType def stepType(self) -> QAbstractSpinBox.StepType: return self._step_type # TODO: add prefix/suffix # ############### QtOverrides ####################### def focusOutEvent(self, e) -> None: if self._pending_emit: self._interpret(_EmitPolicy.EmitIfChanged) return super().focusOutEvent(e) def closeEvent(self, e) -> None: if self._pending_emit: self._interpret(_EmitPolicy.EmitIfChanged) return super().closeEvent(e) def keyPressEvent(self, e) -> None: if e.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return): self._interpret( _EmitPolicy.AlwaysEmit if self.keyboardTracking() else _EmitPolicy.EmitIfChanged ) return super().keyPressEvent(e) def stepBy(self, steps: int) -> None: old = self._value e = _EmitPolicy.EmitIfChanged if self._pending_emit: self._interpret(_EmitPolicy.NeverEmit) if self._value != old: e = _EmitPolicy.AlwaysEmit if self._step_type == QAbstractSpinBox.StepType.AdaptiveDecimalStepType: step = self._calculate_adaptive_decimal_step(steps) else: step = self._single_step self._setValue(self._bound(self._value + (step * steps)), e) def stepEnabled(self): flags = QAbstractSpinBox.StepEnabledFlag.StepNone if self.isReadOnly(): return flags if self._value < self._maximum: flags |= QAbstractSpinBox.StepEnabledFlag.StepUpEnabled if self._value > self._minimum: flags |= QAbstractSpinBox.StepEnabledFlag.StepDownEnabled return flags def sizeHint(self): self.ensurePolished() fm = QFontMetrics(self.font()) h = self.lineEdit().sizeHint().height() if hasattr(fm, "horizontalAdvance"): # Qt >= 5.11 w = fm.horizontalAdvance(str(self._value)) + 3 else: w = fm.width(str(self._value)) + 3 w = max(36, w) opt = QStyleOptionSpinBox() self.initStyleOption(opt) hint = QSize(w, h) return self.style().sizeFromContents( QStyle.ContentsType.CT_SpinBox, opt, hint, self ) # ############### Implementation Details ####################### def _setValue(self, value, policy): self._value, old = self._bound(int(value)), self._value self._pending_emit = False self._updateEdit() self.update() if policy is _EmitPolicy.AlwaysEmit or ( policy is _EmitPolicy.EmitIfChanged and self._value != old ): self._pending_emit = False self.textChanged.emit(self.lineEdit().displayText()) self.valueChanged.emit(self._value) def _updateEdit(self): new_text = str(self._value) if self.lineEdit().text() == new_text: return self.lineEdit().setText(new_text) def _interpret(self, policy): text = self.lineEdit().displayText() or str(self._value) v = int(text) self._setValue(v, policy) def _editor_text_changed(self, t: str) -> None: if self.keyboardTracking(): try: self._setValue(int(t), _EmitPolicy.EmitIfChanged) except ValueError: pass self.lineEdit().setFocus() self._pending_emit = False else: self._pending_emit = True def _bound(self, value): return max(self._minimum, min(self._maximum, value)) def _calculate_adaptive_decimal_step(self, steps: int) -> int: abs_value = abs(self._value) if abs_value < 100: return 1 value_negative = self._value < 0 steps_negative = steps < 0 sign_compensation = 0 if value_negative == steps_negative else 1 log = int(math.log10(abs_value - sign_compensation)) - 1 return int(math.pow(10, log)) superqt-0.6.8/src/superqt/spinbox/_quantity.py000066400000000000000000000207551463340017400216060ustar00rootroot00000000000000from typing import TYPE_CHECKING, Optional, Union try: from pint import Quantity, Unit, UnitRegistry from pint.util import UnitsContainer except ImportError as e: raise ImportError( "pint is required to use QQuantity. Install it with `pip install pint`" ) from e from qtpy.QtCore import Signal from qtpy.QtWidgets import QComboBox, QDoubleSpinBox, QHBoxLayout, QSizePolicy, QWidget from superqt.utils import signals_blocked if TYPE_CHECKING: from decimal import Decimal Number = Union[int, float, "Decimal"] UREG = UnitRegistry() NULL_OPTION = "-----" QOVERFLOW = 2**30 SI_BASES = { "[length]": "meter", "[time]": "second", "[current]": "ampere", "[luminosity]": "candela", "[mass]": "gram", "[substance]": "mole", "[temperature]": "kelvin", } DEFAULT_OPTIONS = { "[length]": ["km", "m", "mm", "µm"], "[time]": ["day", "hour", "min", "sec", "ms"], "[current]": ["A", "mA", "µA"], "[luminosity]": ["kcd", "cd", "mcd"], "[mass]": ["kg", "g", "mg", "µg"], "[substance]": ["mol", "mmol", "µmol"], "[temperature]": ["°C", "°F", "°K"], "radian": ["rad", "deg"], } class QQuantity(QWidget): """A combination QDoubleSpinBox and QComboBox for entering quantities. For this widget, `value()` returns a `pint.Quantity` object, while `setValue()` accepts either a number, `pint.Quantity`, a string that can be parsed by `pint`. Parameters ---------- value : Union[str, pint.Quantity, Number] The initial value to display. If a string, it will be parsed by `pint`. units : Union[pint.util.UnitsContainer, str, pint.Quantity], optional The units to use if `value` is a number. If a string, it will be parsed by `pint`. If a `pint.Quantity`, the units will be extracted from it. ureg : pint.UnitRegistry, optional The unit registry to use. If not provided, the registry will be extracted from `value` if it is a `pint.Quantity`, otherwise the default registry will be used. parent : QWidget, optional The parent widget, by default None """ valueChanged = Signal(Quantity) unitsChanged = Signal(Unit) dimensionalityChanged = Signal(UnitsContainer) def __init__( self, value: Union[str, Quantity, Number] = 0, units: Optional[Union[UnitsContainer, str, Quantity]] = None, ureg: Optional[UnitRegistry] = None, parent: Optional[QWidget] = None, ) -> None: super().__init__(parent=parent) if ureg is None: ureg = value._REGISTRY if isinstance(value, Quantity) else UREG else: if not isinstance(ureg, UnitRegistry): raise TypeError( f"ureg must be a pint.UnitRegistry, not {type(ureg).__name__}" ) self._ureg = ureg self._value: Quantity = self._ureg.Quantity(value, units=units) # whether to preserve quantity equality when changing units or magnitude self._preserve_quantity: bool = False self._abbreviate_units: bool = True # TODO: implement self._mag_spinbox = QDoubleSpinBox() self._mag_spinbox.setDecimals(3) self._mag_spinbox.setRange(-QOVERFLOW, QOVERFLOW - 1) self._mag_spinbox.setValue(float(self._value.magnitude)) self._mag_spinbox.valueChanged.connect(self.setMagnitude) self._units_combo = QComboBox() self._units_combo.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) self._units_combo.currentTextChanged.connect(self.setUnits) self._update_units_combo_choices() self.setLayout(QHBoxLayout()) self.layout().addWidget(self._mag_spinbox) self.layout().addWidget(self._units_combo) self.layout().setContentsMargins(6, 0, 0, 0) def unitRegistry(self) -> UnitRegistry: """Return the pint UnitRegistry used by this widget.""" return self._ureg def _update_units_combo_choices(self): if self._value.dimensionless: with signals_blocked(self._units_combo): self._units_combo.clear() self._units_combo.addItem(NULL_OPTION) self._units_combo.addItems( [self._format_units(x) for x in SI_BASES.values()] ) self._units_combo.setCurrentText(NULL_OPTION) return units = self._value.units dims, exp = next(iter(units.dimensionality.items())) if exp != 1: raise NotImplementedError("Inverse units not yet implemented") options = [ self._format_units(self._ureg.Unit(u)) for u in DEFAULT_OPTIONS.get(dims, []) ] current = self._format_units(units) with signals_blocked(self._units_combo): self._units_combo.clear() self._units_combo.addItems(options) if self._units_combo.findText(current) == -1: self._units_combo.addItem(current) self._units_combo.setCurrentText(current) def value(self) -> Quantity: """Return the current value as a `pint.Quantity`.""" return self._value def text(self) -> str: return str(self._value) def magnitude(self) -> Union[float, int]: """Return the magnitude of the current value.""" return self._value.magnitude def units(self) -> Unit: """Return the current units.""" return self._value.units def dimensionality(self) -> UnitsContainer: """Return the current dimensionality (cast to `str` for nice repr).""" return self._value.dimensionality def setDecimals(self, decimals: int) -> None: """Set the number of decimals to display in the spinbox.""" self._mag_spinbox.setDecimals(decimals) if self._value is not None: self._mag_spinbox.setValue(self._value.magnitude) def setValue( self, value: Union[str, Quantity, Number], units: Optional[Union[UnitsContainer, str, Quantity]] = None, ) -> None: """Set the current value (will cast to a pint Quantity).""" if isinstance(value, Quantity): if units is not None: raise ValueError("Cannot specify units if value is a Quantity") new_val = self._ureg.Quantity(value.magnitude, units=value.units) else: new_val = self._ureg.Quantity(value, units=units) mag_change = new_val.magnitude != self._value.magnitude units_change = new_val.units != self._value.units dims_changed = new_val.dimensionality != self._value.dimensionality self._value = new_val if mag_change: with signals_blocked(self._mag_spinbox): self._mag_spinbox.setValue(float(self._value.magnitude)) if units_change: with signals_blocked(self._units_combo): self._units_combo.setCurrentText(self._format_units(self._value.units)) self.unitsChanged.emit(self._value.units) if dims_changed: self._update_units_combo_choices() self.dimensionalityChanged.emit(self._value.dimensionality) if mag_change or units_change: self.valueChanged.emit(self._value) def setMagnitude(self, magnitude: Number) -> None: """Set the magnitude of the current value.""" self.setValue(self._ureg.Quantity(magnitude, self._value.units)) def setUnits(self, units: Union[str, Unit, Quantity]) -> None: """Set the units of the current value. If `units` is `None`, will convert to a dimensionless quantity. Otherwise, units must be compatible with the current dimensionality. """ if units is None: new_val = self._ureg.Quantity(self._value.magnitude) elif self.isDimensionless(): new_val = self._ureg.Quantity(self._value.magnitude, units) else: new_val = self._value.to(units) self.setValue(new_val) def isDimensionless(self) -> bool: """Return `True` if the current value is dimensionless.""" return self._value.dimensionless def magnitudeSpinBox(self) -> QDoubleSpinBox: """Return the `QSpinBox` widget used to edit the magnitude.""" return self._mag_spinbox def unitsComboBox(self) -> QComboBox: """Return the `QCombBox` widget used to edit the units.""" return self._units_combo def _format_units(self, u: Union[Unit, str]) -> str: if isinstance(u, str): return u return f"{u:~}" if self._abbreviate_units else f"{u:}" superqt-0.6.8/src/superqt/utils/000077500000000000000000000000001463340017400166645ustar00rootroot00000000000000superqt-0.6.8/src/superqt/utils/__init__.py000066400000000000000000000024441463340017400210010ustar00rootroot00000000000000from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from superqt.cmap import draw_colormap # noqa: TCH004 __all__ = ( "CodeSyntaxHighlight", "create_worker", "qimage_to_array", "draw_colormap", "ensure_main_thread", "ensure_object_thread", "exceptions_as_dialog", "FunctionWorker", "GeneratorWorker", "new_worker_qthread", "qdebounced", "QMessageHandler", "QSignalDebouncer", "QSignalThrottler", "qthrottled", "signals_blocked", "thread_worker", "WorkerBase", ) from ._code_syntax_highlight import CodeSyntaxHighlight from ._ensure_thread import ensure_main_thread, ensure_object_thread from ._errormsg_context import exceptions_as_dialog from ._img_utils import qimage_to_array from ._message_handler import QMessageHandler from ._misc import signals_blocked from ._qthreading import ( FunctionWorker, GeneratorWorker, WorkerBase, create_worker, new_worker_qthread, thread_worker, ) from ._throttler import QSignalDebouncer, QSignalThrottler, qdebounced, qthrottled def __getattr__(name: str) -> Any: # pragma: no cover if name == "draw_colormap": from superqt.cmap import draw_colormap return draw_colormap raise AttributeError(f"module {__name__!r} has no attribute {name!r}") superqt-0.6.8/src/superqt/utils/_code_syntax_highlight.py000066400000000000000000000050271463340017400237500ustar00rootroot00000000000000from pygments import highlight from pygments.formatter import Formatter from pygments.lexers import find_lexer_class, get_lexer_by_name from pygments.util import ClassNotFound from qtpy import QtGui # inspired by https://github.com/Vector35/snippets/blob/master/QCodeEditor.py # (MIT license) and # https://pygments.org/docs/formatterdevelopment/#html-3-2-formatter def get_text_char_format(style): text_char_format = QtGui.QTextCharFormat() if hasattr(text_char_format, "setFontFamilies"): text_char_format.setFontFamilies(["monospace"]) else: text_char_format.setFontFamily("monospace") if style.get("color"): text_char_format.setForeground(QtGui.QColor(f"#{style['color']}")) if style.get("bgcolor"): text_char_format.setBackground(QtGui.QColor(style["bgcolor"])) if style.get("bold"): text_char_format.setFontWeight(QtGui.QFont.Bold) if style.get("italic"): text_char_format.setFontItalic(True) if style.get("underline"): text_char_format.setFontUnderline(True) # TODO find if it is possible to support border style. return text_char_format class QFormatter(Formatter): def __init__(self, **kwargs): super().__init__(**kwargs) self.data = [] self._style = {name: get_text_char_format(style) for name, style in self.style} def format(self, tokensource, outfile): """Format the given token stream. `outfile` is argument from parent class, but in Qt we do not produce string output, but QTextCharFormat, so it needs to be collected using `self.data`. """ self.data = [] for token, value in tokensource: self.data.extend([self._style[token]] * len(value)) class CodeSyntaxHighlight(QtGui.QSyntaxHighlighter): def __init__(self, parent, lang, theme): super().__init__(parent) self.formatter = QFormatter(style=theme) try: self.lexer = get_lexer_by_name(lang) except ClassNotFound: self.lexer = find_lexer_class(lang)() @property def background_color(self): return self.formatter.style.background_color def highlightBlock(self, text): # dirty, dirty hack # The core problem is that pygemnts by default use string streams, # that will not handle QTextCharFormat, so we need use `data` property to # work around this. highlight(text, self.lexer, self.formatter) for i in range(len(text)): self.setFormat(i, 1, self.formatter.data[i]) superqt-0.6.8/src/superqt/utils/_ensure_thread.py000066400000000000000000000123161463340017400222300ustar00rootroot00000000000000# https://gist.github.com/FlorianRhiem/41a1ad9b694c14fb9ac3 from __future__ import annotations from concurrent.futures import Future from contextlib import suppress from functools import wraps from typing import TYPE_CHECKING, Any, Callable, ClassVar, overload from qtpy.QtCore import ( QCoreApplication, QMetaObject, QObject, Qt, QThread, Signal, Slot, ) from ._util import get_max_args if TYPE_CHECKING: from typing import TypeVar from typing_extensions import Literal, ParamSpec P = ParamSpec("P") R = TypeVar("R") class CallCallable(QObject): finished = Signal(object) instances: ClassVar[list[CallCallable]] = [] def __init__(self, callable: Callable, args: tuple, kwargs: dict): super().__init__() self._callable = callable self._args = args self._kwargs = kwargs CallCallable.instances.append(self) @Slot() def call(self): CallCallable.instances.remove(self) res = self._callable(*self._args, **self._kwargs) with suppress(RuntimeError): self.finished.emit(res) # fmt: off @overload def ensure_main_thread( await_return: Literal[True], timeout: int = 1000, ) -> Callable[[Callable[P, R]], Callable[P, R]]: ... @overload def ensure_main_thread( func: Callable[P, R], await_return: Literal[True], timeout: int = 1000, ) -> Callable[P, R]: ... @overload def ensure_main_thread( await_return: Literal[False] = False, timeout: int = 1000, ) -> Callable[[Callable[P, R]], Callable[P, Future[R]]]: ... @overload def ensure_main_thread( func: Callable[P, R], await_return: Literal[False] = False, timeout: int = 1000, ) -> Callable[P, Future[R]]: ... # fmt: on def ensure_main_thread( func: Callable | None = None, await_return: bool = False, timeout: int = 1000 ): """Decorator that ensures a function is called in the main QApplication thread. It can be applied to functions or methods. Parameters ---------- func : callable The method to decorate, must be a method on a QObject. await_return : bool, optional Whether to block and wait for the result of the function, or return immediately. by default False timeout : int, optional If `await_return` is `True`, time (in milliseconds) to wait for the result before raising a TimeoutError, by default 1000 """ def _out_func(func_): max_args = get_max_args(func_) @wraps(func_) def _func(*args, _max_args_=max_args, **kwargs): return _run_in_thread( func_, QCoreApplication.instance().thread(), await_return, timeout, args[:_max_args_], kwargs, ) return _func return _out_func if func is None else _out_func(func) # fmt: off @overload def ensure_object_thread( await_return: Literal[True], timeout: int = 1000, ) -> Callable[[Callable[P, R]], Callable[P, R]]: ... @overload def ensure_object_thread( func: Callable[P, R], await_return: Literal[True], timeout: int = 1000, ) -> Callable[P, R]: ... @overload def ensure_object_thread( await_return: Literal[False] = False, timeout: int = 1000, ) -> Callable[[Callable[P, R]], Callable[P, Future[R]]]: ... @overload def ensure_object_thread( func: Callable[P, R], await_return: Literal[False] = False, timeout: int = 1000, ) -> Callable[P, Future[R]]: ... # fmt: on def ensure_object_thread( func: Callable | None = None, await_return: bool = False, timeout: int = 1000 ): """Decorator that ensures a QObject method is called in the object's thread. It must be applied to methods of QObjects subclasses. Parameters ---------- func : callable The method to decorate, must be a method on a QObject. await_return : bool, optional Whether to block and wait for the result of the function, or return immediately. by default False timeout : int, optional If `await_return` is `True`, time (in milliseconds) to wait for the result before raising a TimeoutError, by default 1000 """ def _out_func(func_): max_args = get_max_args(func_) @wraps(func_) def _func(*args, _max_args_=max_args, **kwargs): thread = args[0].thread() # self return _run_in_thread( func_, thread, await_return, timeout, args[:_max_args_], kwargs ) return _func return _out_func if func is None else _out_func(func) def _run_in_thread( func: Callable, thread: QThread, await_return: bool, timeout: int, args: tuple, kwargs: dict, ) -> Any: future = Future() # type: ignore if thread is QThread.currentThread(): result = func(*args, **kwargs) if not await_return: future.set_result(result) return future return result f = CallCallable(func, args, kwargs) f.moveToThread(thread) f.finished.connect(future.set_result, Qt.ConnectionType.DirectConnection) QMetaObject.invokeMethod(f, "call", Qt.ConnectionType.QueuedConnection) # type: ignore return future.result(timeout=timeout / 1000) if await_return else future superqt-0.6.8/src/superqt/utils/_errormsg_context.py000066400000000000000000000142601463340017400230040ustar00rootroot00000000000000from __future__ import annotations import traceback from typing import TYPE_CHECKING, cast from qtpy.QtCore import Qt from qtpy.QtWidgets import QErrorMessage, QMessageBox, QWidget if TYPE_CHECKING: from types import TracebackType _DEFAULT_FLAGS = Qt.WindowType.Dialog | Qt.WindowType.MSWindowsFixedSizeDialogHint class exceptions_as_dialog: """Context manager that shows a dialog when an exception is raised. See examples below for common usage patterns. To determine whether an exception was raised or not, check the `exception` attribute after the context manager has exited. If `use_error_message` is `False` (the default), you can also access the `dialog` attribute to get/manipulate the `QMessageBox` instance. Parameters ---------- exceptions : type[BaseException] | tuple[type[BaseException], ...], optional The exception(s) to catch, by default `Exception` (i.e. all exceptions). icon : QMessageBox.Icon, optional The icon to show in the QMessageBox, by default `QMessageBox.Icon.Critical` title : str, optional The title of the `QMessageBox`, by default `"An error occurred"`. msg_template : str, optional The message to show in the `QMessageBox`. The message will be formatted using three variables: - `exc_value`: the exception instance - `exc_type`: the exception type - `tb`: the traceback as a string The default template is the content of the exception: `"{exc_value}"` buttons : QMessageBox.StandardButton, optional The buttons to show in the `QMessageBox`, by default `QMessageBox.StandardButton.Ok` parent : QWidget | None, optional The parent widget of the `QMessageBox`, by default `None` use_error_message : bool | QErrorMessage, optional Whether to use a `QErrorMessage` instead of a `QMessageBox`. By default `False`. `QErrorMessage` shows a checkbox that the user can check to prevent seeing the message again (based on the text of the formatted `msg_template`.) If `True`, the global `QMessageError.qtHandler()` instance is used to maintain a history of dismissed messages. You may also pass a `QErrorMessage` instance to use a specific instance. If `use_error_message` is True, or if you pass your own `QErrorMessage` instance, the `parent` argument is ignored. Attributes ---------- dialog : QMessageBox | None The `QMessageBox` instance that was created (if `use_error_message` was `False`). This can be used, among other things, to determine the result of the dialog (e.g. `dialog.result()`) or to manipulate the dialog (e.g. `dialog.setDetailedText("some text")`). exception : BaseException | None Will hold the exception instance if an exception was raised and caught. Examples -------- ```python from qtpy.QtWidgets import QApplication from superqt.utils import exceptions_as_dialog app = QApplication([]) with exceptions_as_dialog() as ctx: raise Exception("This will be caught and shown in a QMessageBox") # you can access the exception instance here assert ctx.exception is not None # with exceptions_as_dialog(ValueError): # 1 / 0 # ZeroDivisionError is not caught, so this will raise with exceptions_as_dialog(msg_template="Error: {exc_value}"): raise Exception("This message will be inserted at 'exc_value'") for _i in range(3): with exceptions_as_dialog(AssertionError, use_error_message=True): assert False, "Uncheck the checkbox to ignore this in the future" # use ctx.dialog to get the result of the dialog btns = QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel with exceptions_as_dialog(buttons=btns) as ctx: raise Exception("This will be caught and shown in a QMessageBox") print(ctx.dialog.result()) # prints which button was clicked app.exec() # needed only for the use_error_message example to show ``` """ dialog: QMessageBox | None exception: BaseException | None exec_result: int | None = None def __init__( self, exceptions: type[BaseException] | tuple[type[BaseException], ...] = Exception, icon: QMessageBox.Icon = QMessageBox.Icon.Critical, title: str = "An error occurred", msg_template: str = "{exc_value}", buttons: QMessageBox.StandardButton = QMessageBox.StandardButton.Ok, parent: QWidget | None = None, flags: Qt.WindowType = _DEFAULT_FLAGS, use_error_message: bool | QErrorMessage = False, ): self.exceptions = exceptions self.msg_template = msg_template self.exception = None self.dialog = None self._err_msg = use_error_message if not use_error_message: # the message will be overwritten in __exit__ self.dialog = QMessageBox( icon, title, "An error occurred", buttons, parent, flags ) def __enter__(self) -> exceptions_as_dialog: return self def __exit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, tb: TracebackType | None, ) -> bool: if not (exc_value is not None and isinstance(exc_value, self.exceptions)): return False # let it propagate # save the exception for later self.exception = exc_value # format the message using the context variables if "{tb}" in self.msg_template: _tb = "\n".join(traceback.format_exception(exc_type, exc_value, tb)) else: _tb = "" text = self.msg_template.format(exc_value=exc_value, exc_type=exc_type, tb=_tb) # show the dialog if self._err_msg: msg = ( self._err_msg if isinstance(self._err_msg, QErrorMessage) else QErrorMessage.qtHandler() ) cast("QErrorMessage", msg).showMessage(text) elif self.dialog is not None: # it won't be if use_error_message=False self.dialog.setText(text) self.dialog.exec() return True # swallow the exception superqt-0.6.8/src/superqt/utils/_img_utils.py000066400000000000000000000020111463340017400213630ustar00rootroot00000000000000from typing import TYPE_CHECKING from qtpy.QtGui import QImage if TYPE_CHECKING: import numpy as np def qimage_to_array(img: QImage) -> "np.ndarray": """Convert QImage to an array. Parameters ---------- img : QImage QImage to be converted. Returns ------- arr : np.ndarray Numpy array of type uint8 and shape (h, w, 4). Index [0, 0] is the upper-left corner of the rendered region. """ import numpy as np # cast to ARGB32 if necessary if img.format() != QImage.Format.Format_ARGB32: img = img.convertToFormat(QImage.Format.Format_ARGB32) h, w, c = img.height(), img.width(), 4 # pyside returns a memoryview, pyqt returns a sizeless void pointer b = img.constBits() # Returns a pointer to the first pixel data. if hasattr(b, "setsize"): b.setsize(h * w * c) # reshape to h, w, c arr = np.frombuffer(b, np.uint8).reshape(h, w, c) # reverse channel colors for numpy return arr.take([2, 1, 0, 3], axis=2) superqt-0.6.8/src/superqt/utils/_message_handler.py000066400000000000000000000060621463340017400225220ustar00rootroot00000000000000from __future__ import annotations import logging from contextlib import suppress from typing import ClassVar, NamedTuple from qtpy.QtCore import QMessageLogContext, QtMsgType, qInstallMessageHandler class Record(NamedTuple): level: int message: str ctx: dict class QMessageHandler: """A context manager to intercept messages from Qt. Parameters ---------- logger : logging.Logger, optional If provided, intercepted messages will be logged with `logger` at the corresponding python log level, by default None Attributes ---------- records: list of tuple Captured messages. This is a 3-tuple of: `(log_level: int, message: str, context: dict)` Examples -------- >>> handler = QMessageHandler() >>> handler.install() # now all Qt output will be available at mh.records >>> with QMessageHandler() as handler: # temporarily install ... ... >>> logger = logging.getLogger(__name__) >>> with QMessageHandler(logger): # re-reoute Qt messages to a python logger. ... ... """ _qt2loggertype: ClassVar[dict[QtMsgType, int]] = { QtMsgType.QtDebugMsg: logging.DEBUG, QtMsgType.QtInfoMsg: logging.INFO, QtMsgType.QtWarningMsg: logging.WARNING, QtMsgType.QtCriticalMsg: logging.ERROR, # note QtMsgType.QtFatalMsg: logging.CRITICAL, # note QtMsgType.QtSystemMsg: logging.CRITICAL, } def __init__(self, logger: logging.Logger | None = None): self.records: list[Record] = [] self._logger = logger self._previous_handler: object | None = "__uninstalled__" def install(self): """Install this handler (override the current QtMessageHandler).""" self._previous_handler = qInstallMessageHandler(self) def uninstall(self): """Uninstall this handler, restoring the previous handler.""" if self._previous_handler != "__uninstalled__": qInstallMessageHandler(self._previous_handler) def __repr__(self): n = type(self).__name__ return f"<{n} object at {hex(id(self))} with {len(self.records)} records>" def __enter__(self): """Enter a context with this handler installed.""" self.install() return self def __exit__(self, *args): self.uninstall() def __call__(self, msgtype: QtMsgType, context: QMessageLogContext, message: str): level = self._qt2loggertype[msgtype] # PyQt seems to throw an error if these are simply empty ctx = dict.fromkeys(["category", "file", "function", "line"]) with suppress(UnicodeDecodeError): ctx["category"] = context.category with suppress(UnicodeDecodeError): ctx["file"] = context.file with suppress(UnicodeDecodeError): ctx["function"] = context.function with suppress(UnicodeDecodeError): ctx["line"] = context.line self.records.append(Record(level, message, ctx)) if self._logger is not None: self._logger.log(level, message, extra=ctx) superqt-0.6.8/src/superqt/utils/_misc.py000066400000000000000000000013261463340017400203320ustar00rootroot00000000000000from contextlib import contextmanager from typing import TYPE_CHECKING, Iterator if TYPE_CHECKING: from qtpy.QtCore import QObject @contextmanager def signals_blocked(obj: "QObject") -> Iterator[None]: """Context manager to temporarily block signals emitted by QObject: `obj`. Parameters ---------- obj : QObject The QObject whose signals should be blocked. Examples -------- ```python from qtpy.QtWidgets import QSpinBox from superqt import signals_blocked spinbox = QSpinBox() with signals_blocked(spinbox): spinbox.setValue(10) ``` """ previous = obj.blockSignals(True) try: yield finally: obj.blockSignals(previous) superqt-0.6.8/src/superqt/utils/_qthreading.py000066400000000000000000000740501463340017400215310ustar00rootroot00000000000000from __future__ import annotations import inspect import time import warnings from functools import partial, wraps from typing import ( TYPE_CHECKING, Any, Callable, ClassVar, Generator, Generic, Sequence, TypeVar, overload, ) from qtpy.QtCore import QObject, QRunnable, QThread, QThreadPool, QTimer, Signal if TYPE_CHECKING: _T = TypeVar("_T") class SigInst(Generic[_T]): @staticmethod def connect(slot: Callable[[_T], Any], type: type | None = ...) -> None: ... @staticmethod def disconnect(slot: Callable[[_T], Any] = ...) -> None: ... @staticmethod def emit(*args: _T) -> None: ... from typing_extensions import Literal, ParamSpec _P = ParamSpec("_P") # maintain runtime compatibility with older typing_extensions else: try: from typing_extensions import ParamSpec _P = ParamSpec("_P") except ImportError: _P = TypeVar("_P") _Y = TypeVar("_Y") _S = TypeVar("_S") _R = TypeVar("_R") def as_generator_function( func: Callable[_P, _R], ) -> Callable[_P, Generator[None, None, _R]]: """Turns a regular function (single return) into a generator function.""" @wraps(func) def genwrapper(*args: Any, **kwargs: Any) -> Generator[None, None, _R]: yield return func(*args, **kwargs) return genwrapper class WorkerBaseSignals(QObject): started = Signal() # emitted when the work is started finished = Signal() # emitted when the work is finished _finished = Signal(object) # emitted when the work is finished to delete returned = Signal(object) # emitted with return value errored = Signal(object) # emitted with error object on Exception warned = Signal(tuple) # emitted with showwarning args on warning class WorkerBase(QRunnable, Generic[_R]): """Base class for creating a Worker that can run in another thread. Parameters ---------- SignalsClass : type, optional A QObject subclass that contains signals, by default WorkerBaseSignals Attributes ---------- signals: WorkerBaseSignals signal emitter object. To allow identify which worker thread emitted signal. """ #: A set of Workers. Add to set using `WorkerBase.start` _worker_set: ClassVar[set[WorkerBase]] = set() returned: SigInst[_R] errored: SigInst[Exception] warned: SigInst[tuple] started: SigInst[None] finished: SigInst[None] def __init__( self, func: Callable[_P, _R] | None = None, SignalsClass: type[WorkerBaseSignals] = WorkerBaseSignals, ) -> None: super().__init__() self._abort_requested = False self._running = False self.signals = SignalsClass() def __getattr__(self, name: str) -> SigInst: """Pass through attr requests to signals to simplify connection API. The goal is to enable `worker.yielded.connect` instead of `worker.signals.yielded.connect`. Because multiple inheritance of Qt classes is not well supported in PyQt, we have to use composition here (signals are provided by QObjects, and QRunnable is not a QObject). So this passthrough allows us to connect to signals on the `_signals` object. """ # the Signal object is actually a class attribute attr = getattr(self.signals.__class__, name, None) if isinstance(attr, Signal): # but what we need to connect to is the instantiated signal # (which is of type `SignalInstance` in PySide and # `pyqtBoundSignal` in PyQt) return getattr(self.signals, name) raise AttributeError( f"{self.__class__.__name__!r} object has no attribute {name!r}" ) def quit(self) -> None: """Send a request to abort the worker. !!! note It is entirely up to subclasses to honor this method by checking `self.abort_requested` periodically in their `worker.work` method, and exiting if `True`. """ self._abort_requested = True @property def abort_requested(self) -> bool: """Whether the worker has been requested to stop.""" return self._abort_requested @property def is_running(self) -> bool: """Whether the worker has been started.""" return self._running def run(self) -> None: """Start the worker. The end-user should never need to call this function. But it cannot be made private or renamed, since it is called by Qt. The order of method calls when starting a worker is: ``` calls QThreadPool.globalInstance().start(worker) | triggered by the QThreadPool.start() method | | called by worker.run | | | V V V worker.start -> worker.run -> worker.work ``` **This** is the function that actually gets called when calling `QThreadPool.start(worker)`. It simply wraps the `work()` method, and emits a few signals. Subclasses should NOT override this method (except with good reason), and instead should implement `work()`. """ self.started.emit() self._running = True try: with warnings.catch_warnings(): warnings.filterwarnings("always") warnings.showwarning = lambda *w: self.warned.emit(w) result = self.work() if isinstance(result, Exception): if isinstance(result, RuntimeError): # The Worker object has likely been deleted. # A deleted wrapped C/C++ object may result in a runtime # error that will cause segfault if we try to do much other # than simply notify the user. warnings.warn( f"RuntimeError in aborted thread: {result}", RuntimeWarning, stacklevel=2, ) return else: raise result if not self.abort_requested: self.returned.emit(result) except Exception as exc: self.errored.emit(exc) self._running = False self.finished.emit() self._finished.emit(self) def work(self) -> Exception | _R: """Main method to execute the worker. The end-user should never need to call this function. But subclasses must implement this method (See [`GeneratorFunction.work`][superqt.utils._qthreading.GeneratorWorker.work] for an example implementation). Minimally, it should check `self.abort_requested` periodically and exit if True. Examples -------- ```python class MyWorker(WorkerBase): def work(self): i = 0 while True: if self.abort_requested: self.aborted.emit() break i += 1 if i > max_iters: break time.sleep(0.5) ``` """ raise NotImplementedError( f'"{self.__class__.__name__}" failed to define work() method' ) def start(self) -> None: """Start this worker in a thread and add it to the global threadpool. The order of method calls when starting a worker is: ``` calls QThreadPool.globalInstance().start(worker) | triggered by the QThreadPool.start() method | | called by worker.run | | | V V V worker.start -> worker.run -> worker.work ``` """ if self in self._worker_set: raise RuntimeError("This worker is already started!") # This will raise a RunTimeError if the worker is already deleted repr(self) self._worker_set.add(self) self._finished.connect(self._set_discard) if QThread.currentThread().loopLevel(): # if we're in a thread with an eventloop, queue the worker to start start_ = partial(QThreadPool.globalInstance().start, self) QTimer.singleShot(1, start_) else: # otherwise start it immediately QThreadPool.globalInstance().start(self) @classmethod def _set_discard(cls, obj: WorkerBase) -> None: cls._worker_set.discard(obj) @classmethod def await_workers(cls, msecs: int | None = None) -> None: """Ask all workers to quit, and wait up to `msec` for quit. Attempts to clean up all running workers by calling `worker.quit()` method. Any workers in the `WorkerBase._worker_set` set will have this method. By default, this function will block indefinitely, until worker threads finish. If a timeout is provided, a `RuntimeError` will be raised if the workers do not gracefully exit in the time requests, but the threads will NOT be killed. It is (currently) left to the user to use their OS to force-quit rogue threads. !!! important If the user does not put any yields in their function, and the function is super long, it will just hang... For instance, there's no graceful way to kill this thread in python: ```python @thread_worker def ZZZzzz(): time.sleep(10000000) ``` This is why it's always advisable to use a generator that periodically yields for long-running computations in another thread. See [this stack-overflow post](https://stackoverflow.com/questions/323972/is-there-any-way-to-kill-a-thread) for a good discussion on the difficulty of killing a rogue python thread: Parameters ---------- msecs : int, optional Waits up to msecs milliseconds for all threads to exit and removes all threads from the thread pool. If msecs is `None` (the default), the timeout is ignored (waits for the last thread to exit). Raises ------ RuntimeError If a timeout is provided and workers do not quit successfully within the time allotted. """ for worker in cls._worker_set: worker.quit() msecs = msecs if msecs is not None else -1 if not QThreadPool.globalInstance().waitForDone(msecs): raise RuntimeError( f"Workers did not quit gracefully in the time allotted ({msecs} ms)" ) class FunctionWorker(WorkerBase[_R]): """QRunnable with signals that wraps a simple long-running function. !!! note `FunctionWorker` does not provide a way to stop a very long-running function (e.g. `time.sleep(10000)`). So whenever possible, it is better to implement your long running function as a generator that yields periodically, and use the [`GeneratorWorker`][superqt.utils.GeneratorWorker] instead. Parameters ---------- func : Callable A function to call in another thread *args will be passed to the function **kwargs will be passed to the function Raises ------ TypeError If `func` is a generator function and not a regular function. """ def __init__(self, func: Callable[_P, _R], *args, **kwargs): if inspect.isgeneratorfunction(func): raise TypeError( f"Generator function {func} cannot be used with FunctionWorker, " "use GeneratorWorker instead", ) super().__init__() self._func = func self._args = args self._kwargs = kwargs def work(self) -> _R: return self._func(*self._args, **self._kwargs) class GeneratorWorkerSignals(WorkerBaseSignals): yielded = Signal(object) # emitted with yielded values (if generator used) paused = Signal() # emitted when a running job has successfully paused resumed = Signal() # emitted when a paused job has successfully resumed aborted = Signal() # emitted when a running job is successfully aborted class GeneratorWorker(WorkerBase, Generic[_Y, _S, _R]): """QRunnable with signals that wraps a long-running generator. Provides a convenient way to run a generator function in another thread, while allowing 2-way communication between threads, using plain-python generator syntax in the original function. Parameters ---------- func : callable The function being run in another thread. May be a generator function. SignalsClass : type, optional A QObject subclass that contains signals, by default GeneratorWorkerSignals *args Will be passed to func on instantiation **kwargs Will be passed to func on instantiation """ yielded: SigInst[_Y] paused: SigInst[None] resumed: SigInst[None] aborted: SigInst[None] def __init__( self, func: Callable[_P, Generator[_Y, _S | None, _R]], *args, SignalsClass: type[WorkerBaseSignals] = GeneratorWorkerSignals, **kwargs, ): if not inspect.isgeneratorfunction(func): raise TypeError( f"Regular function {func} cannot be used with GeneratorWorker, " "use FunctionWorker instead", ) super().__init__(SignalsClass=SignalsClass) self._gen = func(*args, **kwargs) self._incoming_value: _S | None = None self._pause_requested = False self._resume_requested = False self._paused = False # polling interval: ONLY relevant if the user paused a running worker self._pause_interval = 0.01 self.pbar = None def work(self) -> _R | None | Exception: """Core event loop that calls the original function. Enters a continual loop, yielding and returning from the original function. Checks for various events (quit, pause, resume, etc...). (To clarify: we are creating a rudimentary event loop here because there IS NO Qt event loop running in the other thread to hook into) """ while True: if self.abort_requested: self.aborted.emit() break if self._paused: if self._resume_requested: self._paused = False self._resume_requested = False self.resumed.emit() else: time.sleep(self._pause_interval) continue elif self._pause_requested: self._paused = True self._pause_requested = False self.paused.emit() continue try: _input = self._next_value() output = self._gen.send(_input) self.yielded.emit(output) except StopIteration as exc: return exc.value except RuntimeError as exc: # The worker has probably been deleted. warning will be # emitted in `WorkerBase.run` return exc return None def send(self, value: _S): """Send a value into the function (if a generator was used).""" self._incoming_value = value def _next_value(self) -> _S | None: out = None if self._incoming_value is not None: out = self._incoming_value self._incoming_value = None return out @property def is_paused(self) -> bool: """Whether the worker is currently paused.""" return self._paused def toggle_pause(self) -> None: """Request to pause the worker if playing or resume if paused.""" if self.is_paused: self._resume_requested = True else: self._pause_requested = True def pause(self) -> None: """Request to pause the worker.""" if not self.is_paused: self._pause_requested = True def resume(self) -> None: """Send a request to resume the worker.""" if self.is_paused: self._resume_requested = True ############################################################################# # convenience functions for creating Worker instances @overload def create_worker( func: Callable[_P, Generator[_Y, _S, _R]], *args, _start_thread: bool | None = None, _connect: dict[str, Callable | Sequence[Callable]] | None = None, _worker_class: type[GeneratorWorker] | type[FunctionWorker] | None = None, _ignore_errors: bool = False, **kwargs, ) -> GeneratorWorker[_Y, _S, _R]: ... @overload def create_worker( func: Callable[_P, _R], *args, _start_thread: bool | None = None, _connect: dict[str, Callable | Sequence[Callable]] | None = None, _worker_class: type[GeneratorWorker] | type[FunctionWorker] | None = None, _ignore_errors: bool = False, **kwargs, ) -> FunctionWorker[_R]: ... def create_worker( func: Callable, *args, _start_thread: bool | None = None, _connect: dict[str, Callable | Sequence[Callable]] | None = None, _worker_class: type[GeneratorWorker] | type[FunctionWorker] | None = None, _ignore_errors: bool = False, **kwargs, ) -> FunctionWorker | GeneratorWorker: """Convenience function to start a function in another thread. By default, uses `FunctionWorker` for functions and `GeneratorWorker` for generators, but a custom `WorkerBase` subclass may be provided. If so, it must be a subclass of `WorkerBase`, which defines a standard set of signals and a run method. Parameters ---------- func : Callable The function to call in another thread. _start_thread : bool Whether to immediaetly start the thread. If False, the returned worker must be manually started with `worker.start()`. by default it will be `False` if the `_connect` argument is `None`, otherwise `True`. _connect : Dict[str, Union[Callable, Sequence]], optional A mapping of `"signal_name"` -> `callable` or list of `callable`: callback functions to connect to the various signals offered by the worker class. by default `None` _worker_class : type of `GeneratorWorker` or `FunctionWorker`, optional The [`WorkerBase`][superqt.utils.WorkerBase] to instantiate, by default [`FunctionWorker`][superqt.utils.FunctionWorker] will be used if `func` is a regular function, and [`GeneratorWorker`][superqt.utils.GeneratorWorker] will be used if it is a generator. _ignore_errors : bool If `False` (the default), errors raised in the other thread will be reraised in the main thread (makes debugging significantly easier). *args will be passed to `func` **kwargs will be passed to `func` Returns ------- worker : WorkerBase An instantiated worker. If `_start_thread` was `False`, the worker will have a `.start()` method that can be used to start the thread. Raises ------ TypeError If a worker_class is provided that is not a subclass of WorkerBase. TypeError If _connect is provided and is not a dict of `{str: callable}` Examples -------- ```python def long_function(duration): import time time.sleep(duration) worker = create_worker(long_function, 10) ``` """ worker: FunctionWorker | GeneratorWorker if not _worker_class: if inspect.isgeneratorfunction(func): _worker_class = GeneratorWorker else: _worker_class = FunctionWorker if not inspect.isclass(_worker_class) and issubclass(_worker_class, WorkerBase): raise TypeError(f"Worker {_worker_class} must be a subclass of WorkerBase") worker = _worker_class(func, *args, **kwargs) if _connect is not None: if not isinstance(_connect, dict): raise TypeError("The '_connect' argument must be a dict") if _start_thread is None: _start_thread = True for key, val in _connect.items(): _val = val if isinstance(val, (tuple, list)) else [val] for v in _val: if not callable(v): raise TypeError( f"_connect[{key!r}] must be a function or sequence of functions" ) getattr(worker, key).connect(v) # if the user has not provided a default connection for the "errored" # signal... and they have not explicitly set `ignore_errors=True` # Then rereaise any errors from the thread. if not _ignore_errors and not (_connect or {}).get("errored", False): def reraise(e): raise e worker.errored.connect(reraise) if _start_thread: worker.start() return worker @overload def thread_worker( function: Callable[_P, Generator[_Y, _S, _R]], start_thread: bool | None = None, connect: dict[str, Callable | Sequence[Callable]] | None = None, worker_class: type[WorkerBase] | None = None, ignore_errors: bool = False, ) -> Callable[_P, GeneratorWorker[_Y, _S, _R]]: ... @overload def thread_worker( function: Callable[_P, _R], start_thread: bool | None = None, connect: dict[str, Callable | Sequence[Callable]] | None = None, worker_class: type[WorkerBase] | None = None, ignore_errors: bool = False, ) -> Callable[_P, FunctionWorker[_R]]: ... @overload def thread_worker( function: Literal[None] = None, start_thread: bool | None = None, connect: dict[str, Callable | Sequence[Callable]] | None = None, worker_class: type[WorkerBase] | None = None, ignore_errors: bool = False, ) -> Callable[[Callable], Callable[_P, FunctionWorker | GeneratorWorker]]: ... def thread_worker( function: Callable | None = None, start_thread: bool | None = None, connect: dict[str, Callable | Sequence[Callable]] | None = None, worker_class: type[WorkerBase] | None = None, ignore_errors: bool = False, ): """Decorator that runs a function in a separate thread when called. When called, the decorated function returns a [`WorkerBase`][superqt.utils.WorkerBase]. See [`create_worker`][superqt.utils.create_worker] for additional keyword arguments that can be used when calling the function. The returned worker will have these signals: - **started**: emitted when the work is started - **finished**: emitted when the work is finished - **returned**: emitted with return value - **errored**: emitted with error object on Exception It will also have a `worker.start()` method that can be used to start execution of the function in another thread. (useful if you need to connect callbacks to signals prior to execution) If the decorated function is a generator, the returned worker will also provide these signals: - **yielded**: emitted with yielded values - **paused**: emitted when a running job has successfully paused - **resumed**: emitted when a paused job has successfully resumed - **aborted**: emitted when a running job is successfully aborted And these methods: - **quit**: ask the thread to quit - **toggle_paused**: toggle the running state of the thread. - **send**: send a value into the generator. (This requires that your decorator function uses the `value = yield` syntax) Parameters ---------- function : callable Function to call in another thread. For communication between threads may be a generator function. start_thread : bool Whether to immediaetly start the thread. If False, the returned worker must be manually started with `worker.start()`. by default it will be `False` if the `_connect` argument is `None`, otherwise `True`. connect : Dict[str, Union[Callable, Sequence]] A mapping of `"signal_name"` -> `callable` or list of `callable`: callback functions to connect to the various signals offered by the worker class. by default None worker_class : Type[WorkerBase] The [`WorkerBase`][superqt.utils.WorkerBase] to instantiate, by default [`FunctionWorker`][superqt.utils.FunctionWorker] will be used if `func` is a regular function, and [`GeneratorWorker`][superqt.utils.GeneratorWorker] will be used if it is a generator. ignore_errors : bool If `False` (the default), errors raised in the other thread will be reraised in the main thread (makes debugging significantly easier). Returns ------- callable function that creates a worker, puts it in a new thread and returns the worker instance. Examples -------- ```python @thread_worker def long_function(start, end): # do work, periodically yielding i = start while i <= end: time.sleep(0.1) yield i # do teardown return "anything" # call the function to start running in another thread. worker = long_function() # connect signals here if desired... or they may be added using the # `connect` argument in the `@thread_worker` decorator... in which # case the worker will start immediately when long_function() is called worker.start() ``` """ def _inner(func): @wraps(func) def worker_function(*args, **kwargs): # decorator kwargs can be overridden at call time by using the # underscore-prefixed version of the kwarg. kwargs["_start_thread"] = kwargs.get("_start_thread", start_thread) kwargs["_connect"] = kwargs.get("_connect", connect) kwargs["_worker_class"] = kwargs.get("_worker_class", worker_class) kwargs["_ignore_errors"] = kwargs.get("_ignore_errors", ignore_errors) return create_worker( func, *args, **kwargs, ) return worker_function return _inner if function is None else _inner(function) ############################################################################ # This is a variant on the above pattern, it uses QThread instead of Qrunnable # see https://doc.qt.io/qt-5/threads-technologies.html#comparison-of-solutions # (it appears from that table that QRunnable cannot emit or receive signals, # but we circumvent that here with our WorkerBase class that also inherits from # QObject... providing signals/slots). # # A benefit of the QRunnable pattern is that Qt manages the threads for you, # in the QThreadPool.globalInstance() ... making it easier to reuse threads, # and reduce overhead. # # However, a disadvantage is that you have no access to (and therefore less # control over) the QThread itself. See for example all of the methods # provided on the QThread object: https://doc.qt.io/qt-5/qthread.html if TYPE_CHECKING: class WorkerProtocol(QObject): finished: Signal def work(self) -> None: ... def new_worker_qthread( Worker: type[WorkerProtocol], *args, _start_thread: bool = False, _connect: dict[str, Callable] | None = None, **kwargs, ): """Convenience function to start a worker in a `QThread`. thread, not as the actual code or object that runs in that thread. The QThread object is created on the main thread and lives there. Worker objects which derive from QObject are the things that actually do the work. They can be moved to a QThread as is done here. ??? "Mostly ignorable detail" While the signals/slots syntax of the worker looks very similar to standard "single-threaded" signals & slots, note that inter-thread signals and slots (automatically) use an event-based QueuedConnection, while intra-thread signals use a DirectConnection. See [Signals and Slots Across Threads](https://doc.qt.io/qt-5/threads-qobject.html#signals-and-slots-across-threads>) Parameters ---------- Worker : QObject QObject type that implements a `work()` method. The Worker should also emit a finished signal when the work is done. _start_thread : bool If True, thread will be started immediately, otherwise, thread must be manually started with thread.start(). _connect : dict Optional dictionary of {signal: function} to connect to the new worker. for instance: _connect = {'incremented': myfunc} will result in: worker.incremented.connect(myfunc) *args will be passed to the Worker class on instantiation. **kwargs will be passed to the Worker class on instantiation. Returns ------- worker : WorkerBase The created worker. thread : QThread The thread on which the worker is running. Examples -------- Create some QObject that has a long-running work method: ```python class Worker(QObject): finished = Signal() increment = Signal(int) def __init__(self, argument): super().__init__() self.argument = argument @Slot() def work(self): # some long running task... import time for i in range(10): time.sleep(1) self.increment.emit(i) self.finished.emit() worker, thread = new_worker_qthread( Worker, "argument", _start_thread=True, _connect={"increment": print}, ) ``` """ if _connect and not isinstance(_connect, dict): raise TypeError("_connect parameter must be a dict") thread = QThread() worker = Worker(*args, **kwargs) worker.moveToThread(thread) thread.started.connect(worker.work) worker.finished.connect(thread.quit) worker.finished.connect(worker.deleteLater) thread.finished.connect(thread.deleteLater) if _connect: [getattr(worker, key).connect(val) for key, val in _connect.items()] if _start_thread: thread.start() # sometimes need to connect stuff before starting return worker, thread superqt-0.6.8/src/superqt/utils/_throttler.py000066400000000000000000000374621463340017400214400ustar00rootroot00000000000000"""Adapted for python from the KDToolBox. https://github.com/KDAB/KDToolBox/tree/master/qt/KDSignalThrottler MIT License Copyright (C) 2019-2022 Klarälvdalens Datakonsult AB, a KDAB Group company, info@kdab.com Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ from __future__ import annotations import warnings from concurrent.futures import Future from contextlib import suppress from enum import IntFlag, auto from functools import wraps from inspect import signature from types import MethodType from typing import TYPE_CHECKING, Any, Callable, Generic, TypeVar, overload from weakref import WeakKeyDictionary, WeakMethod from qtpy.QtCore import QObject, Qt, QTimer, Signal from ._util import get_max_args if TYPE_CHECKING: from typing_extensions import ParamSpec P = ParamSpec("P") # maintain runtime compatibility with older typing_extensions else: try: from typing_extensions import ParamSpec P = ParamSpec("P") except ImportError: P = TypeVar("P") R = TypeVar("R") REF_ERROR = ( "To use qthrottled or qdebounced as a method decorator, " "objects must have `__dict__` or be weak referenceable. " "Please either add `__weakref__` to `__slots__` or use" "qthrottled/qdebounced as a function (not a decorator)." ) class Kind(IntFlag): Throttler = auto() Debouncer = auto() class EmissionPolicy(IntFlag): Trailing = auto() Leading = auto() class GenericSignalThrottler(QObject): triggered = Signal() timeoutChanged = Signal(int) timerTypeChanged = Signal(Qt.TimerType) def __init__( self, kind: Kind, emissionPolicy: EmissionPolicy, parent: QObject | None = None, ) -> None: super().__init__(parent) self._kind = kind self._emissionPolicy = emissionPolicy self._hasPendingEmission = False self._timer = QTimer(parent=self) self._timer.setSingleShot(True) self._timer.setTimerType(Qt.TimerType.PreciseTimer) self._timer.timeout.connect(self._maybeEmitTriggered) def kind(self) -> Kind: """Return the kind of throttler (throttler or debouncer).""" return self._kind def emissionPolicy(self) -> EmissionPolicy: """Return the emission policy (trailing or leading).""" return self._emissionPolicy def timeout(self) -> int: """Return current timeout in milliseconds.""" return self._timer.interval() def setTimeout(self, timeout: int) -> None: """Set timeout in milliseconds.""" if self._timer.interval() != timeout: self._timer.setInterval(timeout) self.timeoutChanged.emit(timeout) def timerType(self) -> Qt.TimerType: """Return current `Qt.TimerType`.""" return self._timer.timerType() def setTimerType(self, timerType: Qt.TimerType) -> None: """Set current Qt.TimerType.""" if self._timer.timerType() != timerType: self._timer.setTimerType(timerType) self.timerTypeChanged.emit(timerType) def throttle(self) -> None: """Emit triggered if not running, then start timer.""" # public slot self._hasPendingEmission = True # Emit only if we haven't emitted already. We know if that's # the case by checking if the timer is running. if ( self._emissionPolicy is EmissionPolicy.Leading and not self._timer.isActive() ): self._emitTriggered() # The timer is started in all cases. If we got a signal, and we're Leading, # and we did emit because of that, then we don't re-emit when the timer fires # (unless we get ANOTHER signal). if self._kind is Kind.Throttler: # sourcery skip: merge-duplicate-blocks if not self._timer.isActive(): self._timer.start() # actual start, not restart elif self._kind is Kind.Debouncer: self._timer.start() # restart def cancel(self) -> None: """Cancel any pending emissions.""" self._hasPendingEmission = False def flush(self, restart_timer: bool = True) -> None: """ Force emission of any pending emissions. Parameters ---------- restart_timer : bool Whether to restart the timer after flushing. Defaults to True. """ self._maybeEmitTriggered(restart_timer=restart_timer) def _emitTriggered(self) -> None: self._hasPendingEmission = False self.triggered.emit() self._timer.start() def _maybeEmitTriggered(self, restart_timer: bool = True) -> None: if self._hasPendingEmission: self._emitTriggered() if not restart_timer: self._timer.stop() Kind = Kind EmissionPolicy = EmissionPolicy # ### Convenience classes ### class QSignalThrottler(GenericSignalThrottler): """A Signal Throttler. This object's `triggered` signal will emit at most once per timeout (set with setTimeout()). """ def __init__( self, policy: EmissionPolicy = EmissionPolicy.Leading, parent: QObject | None = None, ) -> None: super().__init__(Kind.Throttler, policy, parent) class QSignalDebouncer(GenericSignalThrottler): """A Signal Debouncer. This object's `triggered` signal will not be emitted until `self.timeout()` milliseconds have elapsed since the last time `triggered` was emitted. """ def __init__( self, policy: EmissionPolicy = EmissionPolicy.Trailing, parent: QObject | None = None, ) -> None: super().__init__(Kind.Debouncer, policy, parent) # below here part is unique to superqt (not from KD) def _weak_func(func: Callable[P, R]) -> Callable[P, R]: if isinstance(func, MethodType): # this is a bound method, we need to avoid strong references try: weak_method = WeakMethod(func) except TypeError as e: raise TypeError(REF_ERROR) from e def weak_func(*args, **kwargs): if method := weak_method(): return method(*args, **kwargs) warnings.warn( "Method has been garbage collected", RuntimeWarning, stacklevel=2 ) return weak_func return func class ThrottledCallable(GenericSignalThrottler, Generic[P, R]): def __init__( self, func: Callable[P, R], kind: Kind, emissionPolicy: EmissionPolicy, parent: QObject | None = None, ) -> None: super().__init__(kind, emissionPolicy, parent) self._future: Future[R] = Future() self._is_static_method: bool = False if isinstance(func, staticmethod): self._is_static_method = True func = func.__func__ max_args = get_max_args(func) with suppress(TypeError, ValueError): self.__signature__ = signature(func) self._func = _weak_func(func) self.__wrapped__ = self._func self._args: tuple = () self._kwargs: dict = {} self.triggered.connect(self._set_future_result) self._name = None self._obj_dkt: WeakKeyDictionary[Any, ThrottledCallable] = WeakKeyDictionary() # even if we were to compile __call__ with a signature matching that of func, # PySide wouldn't correctly inspect the signature of the ThrottledCallable # instance: https://bugreports.qt.io/browse/PYSIDE-2423 # so we do it ourselfs and limit the number of positional arguments # that we pass to func self._max_args: int | None = max_args def __call__(self, *args: P.args, **kwargs: P.kwargs) -> "Future[R]": # noqa if not self._future.done(): self._future.cancel() self._future = Future() self._args = args self._kwargs = kwargs self.throttle() return self._future def _set_future_result(self): result = self._func(*self._args[: self._max_args], **self._kwargs) self._future.set_result(result) def __set_name__(self, owner, name): if not self._is_static_method: self._name = name def _get_throttler(self, instance, owner, parent, obj, name): try: bound_method = self._func.__get__(instance, owner) except Exception as e: # pragma: no cover raise RuntimeError( f"Failed to bind function {self._func!r} to object {instance!r}" ) from e throttler = ThrottledCallable( bound_method, self._kind, self._emissionPolicy, parent=parent, ) throttler.setTimerType(self.timerType()) throttler.setTimeout(self.timeout()) try: setattr(obj, name, throttler) except AttributeError: try: self._obj_dkt[obj] = throttler except TypeError as e: raise TypeError(REF_ERROR) from e return throttler def __get__(self, instance, owner): if instance is None or not self._name: return self if instance in self._obj_dkt: return self._obj_dkt[instance] parent = self.parent() if parent is None and isinstance(instance, QObject): parent = instance return self._get_throttler(instance, owner, parent, instance, self._name) @overload def qthrottled( func: Callable[P, R], timeout: int = 100, leading: bool = True, timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer, parent: QObject | None = None, ) -> ThrottledCallable[P, R]: ... @overload def qthrottled( func: None = ..., timeout: int = 100, leading: bool = True, timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer, parent: QObject | None = None, ) -> Callable[[Callable[P, R]], ThrottledCallable[P, R]]: ... def qthrottled( func: Callable[P, R] | None = None, timeout: int = 100, leading: bool = True, timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer, parent: QObject | None = None, ) -> ThrottledCallable[P, R] | Callable[[Callable[P, R]], ThrottledCallable[P, R]]: """Creates a throttled function that invokes func at most once per timeout. The throttled function comes with a `cancel` method to cancel delayed func invocations and a `flush` method to immediately invoke them. Options to indicate whether func should be invoked on the leading and/or trailing edge of the wait timeout. The func is invoked with the last arguments provided to the throttled function. Subsequent calls to the throttled function return the result of the last func invocation. This decorator may be used with or without parameters. Parameters ---------- func : Callable A function to throttle timeout : int Timeout in milliseconds to wait before allowing another call, by default 100 leading : bool Whether to invoke the function on the leading edge of the wait timer, by default True timer_type : Qt.TimerType The timer type. by default `Qt.TimerType.PreciseTimer` One of: - `Qt.PreciseTimer`: Precise timers try to keep millisecond accuracy - `Qt.CoarseTimer`: Coarse timers try to keep accuracy within 5% of the desired interval - `Qt.VeryCoarseTimer`: Very coarse timers only keep full second accuracy parent: QObject or None Parent object for timer. If using qthrottled as function it may be usefull for cleaning data """ return _make_decorator(func, timeout, leading, timer_type, Kind.Throttler, parent) @overload def qdebounced( func: Callable[P, R], timeout: int = 100, leading: bool = False, timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer, parent: QObject | None = None, ) -> ThrottledCallable[P, R]: ... @overload def qdebounced( func: None = ..., timeout: int = 100, leading: bool = False, timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer, parent: QObject | None = None, ) -> Callable[[Callable[P, R]], ThrottledCallable[P, R]]: ... def qdebounced( func: Callable[P, R] | None = None, timeout: int = 100, leading: bool = False, timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer, parent: QObject | None = None, ) -> ThrottledCallable[P, R] | Callable[[Callable[P, R]], ThrottledCallable[P, R]]: """Creates a debounced function that delays invoking `func`. `func` will not be invoked until `timeout` ms have elapsed since the last time the debounced function was invoked. The debounced function comes with a `cancel` method to cancel delayed func invocations and a `flush` method to immediately invoke them. Options indicate whether func should be invoked on the leading and/or trailing edge of the wait timeout. The func is invoked with the *last* arguments provided to the debounced function. Subsequent calls to the debounced function return the result of the last `func` invocation. This decorator may be used with or without parameters. Parameters ---------- func : Callable A function to throttle timeout : int Timeout in milliseconds to wait before allowing another call, by default 100 leading : bool Whether to invoke the function on the leading edge of the wait timer, by default False timer_type : Qt.TimerType The timer type. by default `Qt.TimerType.PreciseTimer` One of: - `Qt.PreciseTimer`: Precise timers try to keep millisecond accuracy - `Qt.CoarseTimer`: Coarse timers try to keep accuracy within 5% of the desired interval - `Qt.VeryCoarseTimer`: Very coarse timers only keep full second accuracy parent: QObject or None Parent object for timer. If using qthrottled as function it may be usefull for cleaning data """ return _make_decorator(func, timeout, leading, timer_type, Kind.Debouncer, parent) def _make_decorator( func: Callable[P, R] | None, timeout: int, leading: bool, timer_type: Qt.TimerType, kind: Kind, parent: QObject | None = None, ) -> ThrottledCallable[P, R] | Callable[[Callable[P, R]], ThrottledCallable[P, R]]: def deco(func: Callable[P, R]) -> ThrottledCallable[P, R]: nonlocal parent instance: object | None = getattr(func, "__self__", None) if isinstance(instance, QObject) and parent is None: parent = instance policy = EmissionPolicy.Leading if leading else EmissionPolicy.Trailing obj = ThrottledCallable(func, kind, policy, parent=parent) obj.setTimerType(timer_type) obj.setTimeout(timeout) if instance is not None: # this is a bound method, we need to avoid strong references, # and functools.wraps will prevent garbage collection on bound methods return obj return wraps(func)(obj) return deco(func) if func is not None else deco superqt-0.6.8/src/superqt/utils/_util.py000066400000000000000000000012041463340017400203470ustar00rootroot00000000000000from __future__ import annotations from inspect import signature from typing import Callable def get_max_args(func: Callable) -> int | None: """Return the maximum number of positional arguments that func can accept.""" if not callable(func): raise TypeError(f"{func!r} is not callable") try: sig = signature(func) except Exception: return None max_args = 0 for param in sig.parameters.values(): if param.kind == param.VAR_POSITIONAL: return None if param.kind in {param.POSITIONAL_ONLY, param.POSITIONAL_OR_KEYWORD}: max_args += 1 return max_args superqt-0.6.8/tests/000077500000000000000000000000001463340017400143745ustar00rootroot00000000000000superqt-0.6.8/tests/test_cmap.py000066400000000000000000000121311463340017400167230ustar00rootroot00000000000000import platform from unittest.mock import patch import numpy as np import pytest from qtpy import API_NAME try: from cmap import Colormap except ImportError: pytest.skip("cmap not installed", allow_module_level=True) from qtpy.QtCore import QRect from qtpy.QtGui import QPainter, QPixmap from qtpy.QtWidgets import QStyleOptionViewItem, QWidget from superqt import QColormapComboBox from superqt.cmap import ( CmapCatalogComboBox, QColormapItemDelegate, QColormapLineEdit, _cmap_combo, draw_colormap, ) from superqt.utils import qimage_to_array def test_draw_cmap(qtbot): # draw into a QWidget wdg = QWidget() qtbot.addWidget(wdg) draw_colormap(wdg, "viridis") # draw into any QPaintDevice draw_colormap(QPixmap(), "viridis") # pass a painter an explicit colormap and a rect draw_colormap(QPainter(), Colormap(("red", "yellow", "blue")), QRect()) # test with a border draw_colormap(wdg, "viridis", border_color="red", border_width=2) with pytest.raises(TypeError, match="Expected a QPainter or QPaintDevice instance"): draw_colormap(QRect(), "viridis") # type: ignore with pytest.raises(TypeError, match="Expected a Colormap instance or something"): draw_colormap(QPainter(), "not a recognized string or cmap", QRect()) def test_cmap_draw_result(): """Test that the image drawn actually looks correct.""" # draw into any QPaintDevice w = 100 h = 20 pix = QPixmap(w, h) cmap = Colormap("viridis") draw_colormap(pix, cmap) ary1 = cmap(np.tile(np.linspace(0, 1, w), (h, 1)), bytes=True) ary2 = qimage_to_array(pix.toImage()) # there are some subtle differences between how qimage draws and how # cmap draws, so we can't assert that the arrays are exactly equal. # they are visually indistinguishable, and numbers are close within 4 (/255) values # and linux, for some reason, is a bit more different`` atol = 8 if platform.system() == "Linux" else 4 np.testing.assert_allclose(ary1, ary2, atol=atol) cmap2 = Colormap(("#230777",), name="MyMap") draw_colormap(pix, cmap2) # include transparency def test_catalog_combo(qtbot): wdg = CmapCatalogComboBox() qtbot.addWidget(wdg) wdg.show() wdg.setCurrentText("viridis") assert wdg.currentColormap() == Colormap("viridis") def test_cmap_combo(qtbot): wdg = QColormapComboBox(allow_user_colormaps=True) qtbot.addWidget(wdg) wdg.show() assert wdg.userAdditionsAllowed() with qtbot.waitSignal(wdg.currentColormapChanged): wdg.addColormaps([Colormap("viridis"), "magma", ("red", "blue", "green")]) assert wdg.currentColormap().name.split(":")[-1] == "viridis" with pytest.raises(ValueError, match="Invalid colormap"): wdg.addColormap("not a recognized string or cmap") assert wdg.currentColormap().name.split(":")[-1] == "viridis" assert wdg.currentIndex() == 0 assert wdg.count() == 4 # includes "Add Colormap..." wdg.setCurrentColormap("magma") assert wdg.count() == 4 # make sure we didn't duplicate assert wdg.currentIndex() == 1 if API_NAME == "PySide2": return # the rest fails on CI... but works locally # click the Add Colormap... item with qtbot.waitSignal(wdg.currentColormapChanged): with patch.object(_cmap_combo._CmapNameDialog, "exec", return_value=True): wdg._on_activated(wdg.count() - 1) assert wdg.count() == 5 # this could potentially fail in the future if cmap catalog changes # but mocking the return value of the dialog is also annoying assert wdg.itemColormap(3).name.split(":")[-1] == "accent" # click the Add Colormap... item, but cancel the dialog with patch.object(_cmap_combo._CmapNameDialog, "exec", return_value=False): wdg._on_activated(wdg.count() - 1) def test_cmap_item_delegate(qtbot): wdg = CmapCatalogComboBox() qtbot.addWidget(wdg) view = wdg.view() delegate = view.itemDelegate() assert isinstance(delegate, QColormapItemDelegate) # smoke tests: painter = QPainter() option = QStyleOptionViewItem() index = wdg.model().index(0, 0) delegate._colormap_fraction = 1 delegate.paint(painter, option, index) delegate._colormap_fraction = 0.33 delegate.paint(painter, option, index) assert delegate.sizeHint(option, index) == delegate._item_size def test_cmap_line_edit(qtbot, qapp): wdg = QColormapLineEdit() qtbot.addWidget(wdg) wdg.show() wdg.setColormap("viridis") assert wdg.colormap() == Colormap("viridis") wdg.setText("magma") # also works if the name is recognized assert wdg.colormap() == Colormap("magma") qapp.processEvents() qtbot.wait(10) # force the paintEvent wdg.setFractionalColormapWidth(1) assert wdg.fractionalColormapWidth() == 1 wdg.update() qapp.processEvents() qtbot.wait(10) # force the paintEvent wdg.setText("not-a-cmap") assert wdg.colormap() is None # or wdg.setFractionalColormapWidth(0.3) wdg.setColormap(None) assert wdg.colormap() is None qapp.processEvents() qtbot.wait(10) # force the paintEvent superqt-0.6.8/tests/test_code_highlight.py000066400000000000000000000012041463340017400207430ustar00rootroot00000000000000from qtpy.QtWidgets import QTextEdit from superqt.utils import CodeSyntaxHighlight def test_code_highlight(qtbot): widget = QTextEdit() qtbot.addWidget(widget) code_highlight = CodeSyntaxHighlight(widget, "python", "default") assert code_highlight.background_color == "#f8f8f8" widget.setText("from argparse import ArgumentParser") def test_code_highlight_by_name(qtbot): widget = QTextEdit() qtbot.addWidget(widget) code_highlight = CodeSyntaxHighlight(widget, "Python Traceback", "monokai") assert code_highlight.background_color == "#272822" widget.setText("from argparse import ArgumentParser") superqt-0.6.8/tests/test_collapsible.py000066400000000000000000000075651463340017400203130ustar00rootroot00000000000000"""A test module for testing collapsible""" from qtpy.QtCore import QEasingCurve, Qt from qtpy.QtGui import QIcon from qtpy.QtWidgets import QPushButton, QStyle, QWidget from superqt import QCollapsible def _get_builtin_icon(name: str) -> QIcon: """Get a built-in icon from the Qt library.""" widget = QWidget() try: pixmap = getattr(QStyle.StandardPixmap, f"SP_{name}") except AttributeError: pixmap = getattr(QStyle, f"SP_{name}") return widget.style().standardIcon(pixmap) def test_checked_initialization(qtbot): """Test simple collapsible""" wdg1 = QCollapsible("Advanced analysis") wdg1.expand(False) assert wdg1.isExpanded() assert wdg1._content.maximumHeight() > 0 wdg2 = QCollapsible("Advanced analysis") wdg1.collapse(False) assert not wdg2.isExpanded() assert wdg2._content.maximumHeight() == 0 def test_content_hide_show(qtbot): """Test collapsible with content""" # Create child component collapsible = QCollapsible("Advanced analysis") for i in range(10): collapsible.addWidget(QPushButton(f"Content button {i + 1}")) collapsible.collapse(False) assert not collapsible.isExpanded() assert collapsible._content.maximumHeight() == 0 collapsible.expand(False) assert collapsible.isExpanded() assert collapsible._content.maximumHeight() > 0 def test_locking(qtbot): """Test locking collapsible""" wdg1 = QCollapsible() assert wdg1.locked() is False wdg1.setLocked(True) assert wdg1.locked() is True assert not wdg1.isExpanded() wdg1._toggle_btn.setChecked(True) assert not wdg1.isExpanded() wdg1._toggle() assert not wdg1.isExpanded() wdg1.expand() assert not wdg1.isExpanded() wdg1._toggle_btn.setChecked(False) assert not wdg1.isExpanded() wdg1.setLocked(False) wdg1.expand() assert wdg1.isExpanded() assert wdg1._toggle_btn.isChecked() def test_changing_animation_settings(qtbot): """Quick test for changing animation settings""" wdg = QCollapsible() wdg.setDuration(600) wdg.setEasingCurve(QEasingCurve.Type.InElastic) assert wdg._animation.easingCurve() == QEasingCurve.Type.InElastic assert wdg._animation.duration() == 600 def test_changing_content(qtbot): """Test changing the content""" content = QPushButton() wdg = QCollapsible() wdg.setContent(content) assert wdg._content == content def test_changing_text(qtbot): """Test changing the content""" wdg = QCollapsible() wdg.setText("Hi new text") assert wdg.text() == "Hi new text" assert wdg._toggle_btn.text() == "Hi new text" def test_toggle_signal(qtbot): """Test that signal is emitted when widget expanded/collapsed.""" wdg = QCollapsible() with qtbot.waitSignal(wdg.toggled, timeout=500): qtbot.mouseClick(wdg._toggle_btn, Qt.LeftButton) with qtbot.waitSignal(wdg.toggled, timeout=500): wdg.expand() with qtbot.waitSignal(wdg.toggled, timeout=500): wdg.collapse() def test_getting_icon(qtbot): """Test setting string as toggle button.""" wdg = QCollapsible("test") assert isinstance(wdg.expandedIcon(), QIcon) assert isinstance(wdg.collapsedIcon(), QIcon) def test_setting_icon(qtbot): """Test setting icon for toggle button.""" icon1 = _get_builtin_icon("ArrowRight") icon2 = _get_builtin_icon("ArrowDown") wdg = QCollapsible("test", expandedIcon=icon1, collapsedIcon=icon2) assert wdg._expanded_icon == icon1 assert wdg._collapsed_icon == icon2 def test_setting_symbol_icon(qtbot): """Test setting string as toggle button.""" wdg = QCollapsible("test") icon1 = wdg._convert_string_to_icon("+") icon2 = wdg._convert_string_to_icon("-") wdg.setCollapsedIcon(icon=icon1) assert wdg._collapsed_icon == icon1 wdg.setExpandedIcon(icon=icon2) assert wdg._expanded_icon == icon2 superqt-0.6.8/tests/test_color_combo.py000066400000000000000000000050101463340017400202760ustar00rootroot00000000000000from unittest.mock import patch import pytest from qtpy import API_NAME from qtpy.QtGui import QColor, QPainter from qtpy.QtWidgets import QStyleOptionViewItem from superqt import QColorComboBox from superqt.combobox import _color_combobox def test_q_color_combobox(qtbot): wdg = QColorComboBox() qtbot.addWidget(wdg) wdg.show() wdg.setUserColorsAllowed(True) # colors can be any argument that can be passed to QColor # (tuples and lists will be expanded to QColor(*color) COLORS = [QColor("red"), "orange", (255, 255, 0), "green", "#00F", "indigo"] wdg.addColors(COLORS) colors = [wdg.itemColor(i) for i in range(wdg.count())] assert colors == [ QColor("red"), QColor("orange"), QColor("yellow"), QColor("green"), QColor("blue"), QColor("indigo"), None, # "Add Color" item ] # as with addColors, colors will be cast to QColor when using setColors wdg.setCurrentColor("indigo") assert wdg.currentColor() == QColor("indigo") assert wdg.currentColorName() == "#4b0082" wdg.clear() assert wdg.count() == 1 # "Add Color" item wdg.setUserColorsAllowed(False) assert not wdg.count() wdg.setInvalidColorPolicy(wdg.InvalidColorPolicy.Ignore) wdg.setInvalidColorPolicy(2) wdg.setInvalidColorPolicy("Raise") with pytest.raises(TypeError): wdg.setInvalidColorPolicy(1.0) # type: ignore with pytest.raises(ValueError): wdg.addColor("invalid") def test_q_color_delegate(qtbot): wdg = QColorComboBox() view = wdg.view() delegate = wdg.itemDelegate() qtbot.addWidget(wdg) wdg.show() # smoke tests: painter = QPainter() option = QStyleOptionViewItem() index = wdg.model().index(0, 0) delegate.paint(painter, option, index) wdg.addColors(["red", "orange", "yellow"]) view.selectAll() index = wdg.model().index(1, 0) delegate.paint(painter, option, index) @pytest.mark.skipif(API_NAME == "PySide2", reason="hangs on CI") def test_activated(qtbot): wdg = QColorComboBox() qtbot.addWidget(wdg) wdg.show() wdg.setUserColorsAllowed(True) with patch.object(_color_combobox.QColorDialog, "getColor", lambda: QColor("red")): wdg._on_activated(wdg.count() - 1) # "Add Color" item assert wdg.currentColor() == QColor("red") with patch.object(_color_combobox.QColorDialog, "getColor", lambda: QColor()): wdg._on_activated(wdg.count() - 1) # "Add Color" item assert wdg.currentColor() == QColor("red") superqt-0.6.8/tests/test_eliding_label.py000066400000000000000000000046021463340017400205610ustar00rootroot00000000000000import platform from qtpy.QtCore import QSize, Qt from qtpy.QtGui import QResizeEvent from superqt import QElidingLabel TEXT = ( "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do " "eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad " "minim ven iam, quis nostrud exercitation ullamco laborisnisi ut aliquip " "ex ea commodo consequat. Duis aute irure dolor inreprehenderit in voluptate " "velit esse cillum dolore eu fugiat nullapariatur." ) ELLIPSIS = "…" def test_eliding_label(qtbot): wdg = QElidingLabel(TEXT) qtbot.addWidget(wdg) assert wdg._elidedText().endswith(ELLIPSIS) oldsize = wdg.size() newsize = QSize(200, 20) wdg.resize(newsize) wdg.resizeEvent(QResizeEvent(oldsize, newsize)) # for test coverage assert wdg.text() == TEXT def test_wrapped_eliding_label(qtbot): wdg = QElidingLabel(TEXT) qtbot.addWidget(wdg) assert not wdg.wordWrap() assert 630 < wdg.sizeHint().width() < 640 assert wdg._elidedText().endswith(ELLIPSIS) wdg.resize(QSize(200, 100)) assert wdg.text() == TEXT assert wdg._elidedText().endswith(ELLIPSIS) wdg.setWordWrap(True) assert wdg.wordWrap() assert wdg.text() == TEXT assert wdg._elidedText().endswith(ELLIPSIS) # just empirically from CI ... stupid if platform.system() == "Linux": assert wdg.sizeHint() in (QSize(200, 198), QSize(200, 154)) elif platform.system() == "Windows": assert wdg.sizeHint() in (QSize(200, 160), QSize(200, 118)) elif platform.system() == "Darwin": assert wdg.sizeHint() == QSize(200, 176) # TODO: figure out how to test these on all platforms on CI wdg.resize(wdg.sizeHint()) assert wdg._elidedText() == TEXT def test_shorter_eliding_label(qtbot): short = "asd a ads sd flksdf dsf lksfj sd lsdjf sd lsdfk sdlkfj s" wdg = QElidingLabel() qtbot.addWidget(wdg) wdg.setText(short) assert not wdg._elidedText().endswith(ELLIPSIS) wdg.resize(100, 20) assert wdg._elidedText().endswith(ELLIPSIS) wdg.setElideMode(Qt.TextElideMode.ElideLeft) assert wdg._elidedText().startswith(ELLIPSIS) assert wdg.elideMode() == Qt.TextElideMode.ElideLeft def test_wrap_text(): wrap = QElidingLabel.wrapText(TEXT, 200) assert isinstance(wrap, list) assert all(isinstance(x, str) for x in wrap) assert 9 <= len(wrap) <= 13 superqt-0.6.8/tests/test_eliding_line_edit.py000066400000000000000000000034171463340017400214410ustar00rootroot00000000000000from qtpy.QtCore import QSize, Qt from qtpy.QtGui import QResizeEvent from superqt import QElidingLineEdit TEXT = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do" ELLIPSIS = "…" def test_init_text_eliding_line_edit(qtbot): wdg = QElidingLineEdit(TEXT) qtbot.addWidget(wdg) oldsize = QSize(100, 20) wdg.resize(oldsize) assert wdg._elidedText().endswith(ELLIPSIS) newsize = QSize(500, 20) wdg.resize(newsize) wdg.resizeEvent(QResizeEvent(oldsize, newsize)) # for test coverage assert wdg._elidedText() == TEXT assert wdg.text() == TEXT def test_set_text_eliding_line_edit(qtbot): wdg = QElidingLineEdit() qtbot.addWidget(wdg) wdg.resize(500, 20) wdg.setText(TEXT) assert not wdg._elidedText().endswith(ELLIPSIS) wdg.resize(100, 20) assert wdg._elidedText().endswith(ELLIPSIS) def test_set_elide_mode_eliding_line_edit(qtbot): wdg = QElidingLineEdit() qtbot.addWidget(wdg) wdg.resize(500, 20) wdg.setText(TEXT) assert not wdg._elidedText().endswith(ELLIPSIS) wdg.resize(100, 20) # ellipses should be to the right assert wdg._elidedText().endswith(ELLIPSIS) # ellipses should be to the left wdg.setElideMode(Qt.TextElideMode.ElideLeft) assert wdg._elidedText().startswith(ELLIPSIS) assert wdg.elideMode() == Qt.TextElideMode.ElideLeft # no ellipses should be shown wdg.setElideMode(Qt.TextElideMode.ElideNone) assert ELLIPSIS not in wdg._elidedText() def test_set_elipses_width_eliding_line_edit(qtbot): wdg = QElidingLineEdit() qtbot.addWidget(wdg) wdg.resize(500, 20) wdg.setText(TEXT) assert not wdg._elidedText().endswith(ELLIPSIS) wdg.setEllipsesWidth(int(wdg.width() / 2)) assert wdg._elidedText().endswith(ELLIPSIS) superqt-0.6.8/tests/test_ensure_thread.py000066400000000000000000000210171463340017400206360ustar00rootroot00000000000000import inspect import os import threading import time from concurrent.futures import Future, TimeoutError from functools import wraps from unittest.mock import Mock import pytest from qtpy.QtCore import QCoreApplication, QObject, QThread, Signal from superqt.utils import ensure_main_thread, ensure_object_thread skip_on_ci = pytest.mark.skipif(bool(os.getenv("CI")), reason="github hangs") class SampleObject(QObject): assigment_done = Signal() def __init__(self): super().__init__() self.main_thread_res = {} self.object_thread_res = {} self.main_thread_prop_val = None self.sample_thread_prop_val = None def long_wait(self): time.sleep(1) @property def sample_main_thread_property(self): return self.main_thread_prop_val @sample_main_thread_property.setter # type: ignore @ensure_main_thread() def sample_main_thread_property(self, value): if QThread.currentThread() is not QCoreApplication.instance().thread(): raise RuntimeError("Wrong thread") self.main_thread_prop_val = value self.assigment_done.emit() @property def sample_object_thread_property(self): return self.sample_thread_prop_val @sample_object_thread_property.setter # type: ignore @ensure_object_thread() def sample_object_thread_property(self, value): if QThread.currentThread() is not self.thread(): raise RuntimeError("Wrong thread") self.sample_thread_prop_val = value self.assigment_done.emit() @ensure_main_thread def check_main_thread(self, a, *, b=1): if QThread.currentThread() is not QCoreApplication.instance().thread(): raise RuntimeError("Wrong thread") self.main_thread_res = {"a": a, "b": b} self.assigment_done.emit() @ensure_object_thread def check_object_thread(self, a, *, b=1): if QThread.currentThread() is not self.thread(): raise RuntimeError("Wrong thread") self.object_thread_res = {"a": a, "b": b} self.assigment_done.emit() @ensure_object_thread(await_return=True) def check_object_thread_return(self, a): if QThread.currentThread() is not self.thread(): raise RuntimeError("Wrong thread") return a * 7 @ensure_object_thread(await_return=True, timeout=200) def check_object_thread_return_timeout(self, a): if QThread.currentThread() is not self.thread(): raise RuntimeError("Wrong thread") time.sleep(1) return a * 7 @ensure_object_thread(await_return=False) def check_object_thread_return_future(self, a: int): """sample docstring""" if QThread.currentThread() is not self.thread(): raise RuntimeError("Wrong thread") time.sleep(0.4) return a * 7 @ensure_main_thread(await_return=True) def check_main_thread_return(self, a): if QThread.currentThread() is not QCoreApplication.instance().thread(): raise RuntimeError("Wrong thread") return a * 8 class LocalThread(QThread): def __init__(self, ob): super().__init__() self.ob = ob def run(self): assert QThread.currentThread() is not QCoreApplication.instance().thread() self.ob.check_main_thread(5, b=8) self.ob.main_thread_prop_val = "text2" class LocalThread2(QThread): def __init__(self, ob): super().__init__() self.ob = ob self.executed = False def run(self): assert QThread.currentThread() is not QCoreApplication.instance().thread() assert self.ob.check_main_thread_return(5) == 40 self.executed = True def test_only_main_thread(qapp): ob = SampleObject() ob.check_main_thread(1, b=3) assert ob.main_thread_res == {"a": 1, "b": 3} ob.check_object_thread(2, b=4) assert ob.object_thread_res == {"a": 2, "b": 4} ob.sample_main_thread_property = 5 assert ob.sample_main_thread_property == 5 ob.sample_object_thread_property = 7 assert ob.sample_object_thread_property == 7 def test_main_thread(qtbot): ob = SampleObject() t = LocalThread(ob) with qtbot.waitSignal(t.finished): t.start() assert ob.main_thread_res == {"a": 5, "b": 8} assert ob.sample_main_thread_property == "text2" def test_main_thread_return(qtbot): ob = SampleObject() t = LocalThread2(ob) with qtbot.wait_signal(t.finished): t.start() assert t.executed def test_names(qapp): ob = SampleObject() assert ob.check_object_thread.__name__ == "check_object_thread" assert ob.check_object_thread_return.__name__ == "check_object_thread_return" assert ( ob.check_object_thread_return_timeout.__name__ == "check_object_thread_return_timeout" ) assert ( ob.check_object_thread_return_future.__name__ == "check_object_thread_return_future" ) assert ob.check_object_thread_return_future.__doc__ == "sample docstring" signature = inspect.signature(ob.check_object_thread_return_future) assert len(signature.parameters) == 1 assert next(iter(signature.parameters.values())).name == "a" assert next(iter(signature.parameters.values())).annotation == int assert ob.check_main_thread_return.__name__ == "check_main_thread_return" @skip_on_ci def test_object_thread_return(qtbot): ob = SampleObject() thread = QThread() thread.start() ob.moveToThread(thread) assert ob.check_object_thread_return(2) == 14 assert ob.thread() is thread with qtbot.waitSignal(thread.finished): thread.quit() @skip_on_ci def test_object_thread_return_timeout(qtbot): ob = SampleObject() thread = QThread() thread.start() ob.moveToThread(thread) with pytest.raises(TimeoutError): ob.check_object_thread_return_timeout(2) with qtbot.waitSignal(thread.finished): thread.quit() @skip_on_ci def test_object_thread_return_future(qtbot): ob = SampleObject() thread = QThread() thread.start() ob.moveToThread(thread) future = ob.check_object_thread_return_future(2) assert isinstance(future, Future) assert future.result() == 14 with qtbot.waitSignal(thread.finished): thread.quit() @skip_on_ci def test_object_thread(qtbot): ob = SampleObject() thread = QThread() thread.start() ob.moveToThread(thread) with qtbot.waitSignal(ob.assigment_done): ob.check_object_thread(2, b=4) assert ob.object_thread_res == {"a": 2, "b": 4} with qtbot.waitSignal(ob.assigment_done): ob.sample_object_thread_property = "text" assert ob.sample_object_thread_property == "text" assert ob.thread() is thread with qtbot.waitSignal(thread.finished): thread.quit() @pytest.mark.parametrize("mode", ["method", "func", "wrapped"]) @pytest.mark.parametrize("deco", [ensure_main_thread, ensure_object_thread]) def test_ensure_thread_sig_inspection(deco, mode): class Emitter(QObject): sig = Signal(int, int, int) obj = Emitter() mock = Mock() if mode == "method": class Receiver(QObject): @deco def func(self, a: int, b: int): mock(a, b) r = Receiver() obj.sig.connect(r.func) elif deco == ensure_object_thread: return # not compatible with function types elif mode == "wrapped": def wr(fun): @wraps(fun) def wr2(*args): mock(*args) return fun(*args) * 2 return wr2 @deco @wr def wrapped_func(a, b): return a + b obj.sig.connect(wrapped_func) elif mode == "func": @deco def func(a: int, b: int) -> None: mock(a, b) obj.sig.connect(func) # this is the crux of the test... # we emit 3 args, but the function only takes 2 # this should normally work fine in Qt. # testing here that the decorator doesn't break it. obj.sig.emit(1, 2, 3) mock.assert_called_once_with(1, 2) def test_main_thread_function(qtbot): """Testing decorator on a function rather than QObject method.""" mock = Mock() class Emitter(QObject): sig = Signal(int, int, int) @ensure_main_thread def func(x: int) -> None: mock(x, QThread.currentThread()) e = Emitter() e.sig.connect(func) with qtbot.waitSignal(e.sig): thread = threading.Thread(target=e.sig.emit, args=(1, 2, 3)) thread.start() thread.join() mock.assert_called_once_with(1, QCoreApplication.instance().thread()) superqt-0.6.8/tests/test_enum_comb_box.py000066400000000000000000000122201463340017400206160ustar00rootroot00000000000000import sys from enum import Enum, Flag, IntEnum, IntFlag import pytest from superqt.combobox import QEnumComboBox from superqt.combobox._enum_combobox import NONE_STRING class Enum1(Enum): a = 1 b = 2 c = 3 ALIAS = a class Enum2(Enum): d = 1 e = 2 f = 3 g = 4 class Enum3(Enum): a = 1 b = 2 c = 3 def __str__(self): return self.name + "1" class Enum4(Enum): a_1 = 1 b_2 = 2 c_3 = 3 class IntEnum1(IntEnum): a = 1 b = 2 c = 5 class IntFlag1(IntFlag): a = 1 b = 2 c = 4 class Flag1(Flag): a = 1 b = 2 c = 4 class IntFlag2(IntFlag): a = 1 b = 2 c = 3 class Flag2(IntFlag): a = 1 b = 2 c = 5 class FlagOrNum(IntFlag): a = 3 b = 5 c = 8 def test_simple_create(qtbot): enum = QEnumComboBox(enum_class=Enum1) qtbot.addWidget(enum) assert enum.count() == 3 assert [enum.itemText(i) for i in range(enum.count())] == ["a", "b", "c"] def test_simple_create2(qtbot): enum = QEnumComboBox() qtbot.addWidget(enum) assert enum.count() == 0 enum.setEnumClass(Enum1) assert enum.count() == 3 assert [enum.itemText(i) for i in range(enum.count())] == ["a", "b", "c"] def test_replace(qtbot): enum = QEnumComboBox(enum_class=Enum1) qtbot.addWidget(enum) assert enum.count() == 3 assert enum.enumClass() == Enum1 assert isinstance(enum.currentEnum(), Enum1) enum.setEnumClass(Enum2) assert enum.enumClass() == Enum2 assert isinstance(enum.currentEnum(), Enum2) assert enum.count() == 4 assert [enum.itemText(i) for i in range(enum.count())] == ["d", "e", "f", "g"] def test_str_replace(qtbot): enum = QEnumComboBox(enum_class=Enum3) qtbot.addWidget(enum) assert enum.count() == 3 assert [enum.itemText(i) for i in range(enum.count())] == ["a1", "b1", "c1"] def test_underscore_replace(qtbot): enum = QEnumComboBox(enum_class=Enum4) qtbot.addWidget(enum) assert enum.count() == 3 assert [enum.itemText(i) for i in range(enum.count())] == ["a 1", "b 2", "c 3"] def test_change_value(qtbot): enum = QEnumComboBox(enum_class=Enum1) qtbot.addWidget(enum) assert enum.currentEnum() == Enum1.a with qtbot.waitSignal( enum.currentEnumChanged, check_params_cb=lambda x: isinstance(x, Enum) ): enum.setCurrentEnum(Enum1.c) assert enum.currentEnum() == Enum1.c def test_no_enum(qtbot): enum = QEnumComboBox() assert enum.enumClass() is None qtbot.addWidget(enum) assert enum.currentEnum() is None def test_prohibited_methods(qtbot): enum = QEnumComboBox(enum_class=Enum1) qtbot.addWidget(enum) with pytest.raises(RuntimeError): enum.addItem("aaa") with pytest.raises(RuntimeError): enum.addItems(["aaa", "bbb"]) with pytest.raises(RuntimeError): enum.insertItem(0, "aaa") with pytest.raises(RuntimeError): enum.insertItems(0, ["aaa", "bbb"]) assert enum.count() == 3 def test_optional(qtbot): enum = QEnumComboBox(enum_class=Enum1, allow_none=True) qtbot.addWidget(enum) assert [enum.itemText(i) for i in range(enum.count())] == [ NONE_STRING, "a", "b", "c", ] assert enum.currentText() == NONE_STRING assert enum.currentEnum() is None enum.setCurrentEnum(Enum1.a) assert enum.currentText() == "a" assert enum.currentEnum() == Enum1.a assert enum.enumClass() is Enum1 enum.setCurrentEnum(None) assert enum.currentText() == NONE_STRING assert enum.currentEnum() is None def test_simple_create_int_enum(qtbot): enum = QEnumComboBox(enum_class=IntEnum1) qtbot.addWidget(enum) assert [enum.itemText(i) for i in range(enum.count())] == ["a", "b", "c"] @pytest.mark.parametrize("enum_class", [IntFlag1, Flag1]) def test_enum_flag_create(qtbot, enum_class): enum = QEnumComboBox(enum_class=enum_class) qtbot.addWidget(enum) assert [enum.itemText(i) for i in range(enum.count())] == [ "a", "b", "c", "a|b", "a|c", "b|c", "a|b|c", ] enum.setCurrentText("a|b") assert enum.currentEnum() == enum_class.a | enum_class.b def test_enum_flag_create_collision(qtbot): enum = QEnumComboBox(enum_class=IntFlag2) qtbot.addWidget(enum) assert [enum.itemText(i) for i in range(enum.count())] == ["a", "b", "c"] @pytest.mark.skipif( sys.version_info >= (3, 11), reason="different representation in 3.11" ) def test_enum_flag_create_collision_evaluated_to_seven(qtbot): enum = QEnumComboBox(enum_class=FlagOrNum) qtbot.addWidget(enum) assert [enum.itemText(i) for i in range(enum.count())] == [ "a", "b", "c", "a|b", "a|c", "b|c", "a|b|c", ] @pytest.mark.skipif( sys.version_info < (3, 11), reason="StrEnum is introduced in python 3.11" ) def test_create_str_enum(qtbot): from enum import StrEnum class StrEnum1(StrEnum): a = "a" b = "b" c = "c" enum = QEnumComboBox(enum_class=StrEnum1) qtbot.addWidget(enum) assert [enum.itemText(i) for i in range(enum.count())] == ["a", "b", "c"] superqt-0.6.8/tests/test_fonticon/000077500000000000000000000000001463340017400172525ustar00rootroot00000000000000superqt-0.6.8/tests/test_fonticon/fixtures/000077500000000000000000000000001463340017400211235ustar00rootroot00000000000000superqt-0.6.8/tests/test_fonticon/fixtures/fake_plugin.dist-info/000077500000000000000000000000001463340017400253025ustar00rootroot00000000000000superqt-0.6.8/tests/test_fonticon/fixtures/fake_plugin.dist-info/METADATA000066400000000000000000000000701463340017400264020ustar00rootroot00000000000000Metadata-Version: 2.1 Name: fake-plugin Version: 5.15.4 superqt-0.6.8/tests/test_fonticon/fixtures/fake_plugin.dist-info/entry_points.txt000066400000000000000000000000511463340017400305740ustar00rootroot00000000000000[superqt.fonticon] ico = fake_plugin:ICO superqt-0.6.8/tests/test_fonticon/fixtures/fake_plugin.dist-info/top_level.txt000066400000000000000000000000141463340017400300270ustar00rootroot00000000000000fake_plugin superqt-0.6.8/tests/test_fonticon/fixtures/fake_plugin/000077500000000000000000000000001463340017400234075ustar00rootroot00000000000000superqt-0.6.8/tests/test_fonticon/fixtures/fake_plugin/__init__.py000066400000000000000000000002001463340017400255100ustar00rootroot00000000000000from pathlib import Path class ICO: __font_file__ = str(Path(__file__).parent / "icontest.ttf") smiley = "ico.\ue900" superqt-0.6.8/tests/test_fonticon/fixtures/fake_plugin/icontest.ttf000066400000000000000000000021241463340017400257550ustar00rootroot00000000000000 0OS/2*`cmapV҇TgasppglyfXCxhead46hheal$hmtx loca(r maxp . namedbpost4 3 @@@ 8  797979@'+!"131!!!5!5!26514&#3#"&5!#7#53#22#V#22#VVGdVdGVV@2##2VUUV2#+#2?,,?֪Y_< cc@ ^,E-Z 9 f  L 1 ^  = 4testtestVersion 1.0Version 1.0testtesttesttestRegularRegulartesttestFont generated by IcoMoon.Font generated by IcoMoon.superqt-0.6.8/tests/test_fonticon/test_fonticon.py000066400000000000000000000100771463340017400225070ustar00rootroot00000000000000from pathlib import Path import pytest from qtpy.QtGui import QIcon, QPixmap from qtpy.QtWidgets import QPushButton from superqt.fonticon import icon, pulse, setTextIcon, spin from superqt.fonticon._qfont_icon import QFontIconStore, _ensure_identifier TEST_PREFIX = "ico" TEST_CHARNAME = "smiley" TEST_CHAR = "\ue900" TEST_GLYPHKEY = f"{TEST_PREFIX}.{TEST_CHARNAME}" FONT_FILE = Path(__file__).parent / "fixtures" / "fake_plugin" / "icontest.ttf" @pytest.fixture def store(qapp): store = QFontIconStore().instance() yield store store.clear() @pytest.fixture def full_store(store): store.addFont(str(FONT_FILE), TEST_PREFIX, {TEST_CHARNAME: TEST_CHAR}) return store def test_no_font_key(): with pytest.raises(KeyError) as err: icon(TEST_GLYPHKEY) assert "Unrecognized font key: {TEST_PREFIX!r}." in str(err) def test_no_charmap(store): store.addFont(str(FONT_FILE), TEST_PREFIX) with pytest.raises(KeyError) as err: icon(TEST_GLYPHKEY) assert "No charmap registered for" in str(err) def test_font_icon_works(full_store): icn = icon(TEST_GLYPHKEY) assert isinstance(icn, QIcon) assert isinstance(icn.pixmap(40, 40), QPixmap) icn = icon(f"{TEST_PREFIX}.{TEST_CHAR}") # also works with unicode key assert isinstance(icn, QIcon) assert isinstance(icn.pixmap(40, 40), QPixmap) with pytest.raises(ValueError) as err: icon(f"{TEST_PREFIX}.smelly") # bad name assert "Font 'test (Regular)' has no glyph with the key 'smelly'" in str(err) def test_on_button(full_store, qtbot): btn = QPushButton(None) qtbot.addWidget(btn) btn.setIcon(icon(TEST_GLYPHKEY)) def test_btn_text_icon(full_store, qtbot): btn = QPushButton(None) qtbot.addWidget(btn) setTextIcon(btn, TEST_GLYPHKEY) assert btn.text() == TEST_CHAR def test_animation(full_store, qtbot): btn = QPushButton(None) qtbot.addWidget(btn) icn = icon(TEST_GLYPHKEY, animation=pulse(btn)) btn.setIcon(icn) with qtbot.waitSignal(icn._engine._default_opts.animation.timer.timeout): icn.pixmap(40, 40) btn.update() def test_multistate(full_store, qtbot, qapp): """complicated multistate icon""" btn = QPushButton() qtbot.addWidget(btn) icn = icon( TEST_GLYPHKEY, color="blue", states={ "active": { "color": "red", "scale_factor": 0.5, "animation": pulse(btn), }, "disabled": { "color": "green", "scale_factor": 0.8, "animation": spin(btn), }, }, ) btn.setIcon(icn) btn.show() btn.setEnabled(False) active = icn._engine._opts[QIcon.State.Off][QIcon.Mode.Active].animation.timer disabled = icn._engine._opts[QIcon.State.Off][QIcon.Mode.Disabled].animation.timer with qtbot.waitSignal(active.timeout, timeout=1000): btn.setEnabled(True) # hack to get the signal emitted icn.pixmap(100, 100, QIcon.Mode.Active, QIcon.State.Off) assert active.isActive() assert not disabled.isActive() with qtbot.waitSignal(disabled.timeout): btn.setEnabled(False) assert disabled.isActive() # smoke test, paint all the states icn.pixmap(100, 100, QIcon.Mode.Active, QIcon.State.Off) icn.pixmap(100, 100, QIcon.Mode.Disabled, QIcon.State.Off) icn.pixmap(100, 100, QIcon.Mode.Selected, QIcon.State.Off) icn.pixmap(100, 100, QIcon.Mode.Normal, QIcon.State.Off) icn.pixmap(100, 100, QIcon.Mode.Active, QIcon.State.On) icn.pixmap(100, 100, QIcon.Mode.Disabled, QIcon.State.On) icn.pixmap(100, 100, QIcon.Mode.Selected, QIcon.State.On) icn.pixmap(100, 100, QIcon.Mode.Normal, QIcon.State.On) def test_ensure_identifier(): assert _ensure_identifier("") == "" assert _ensure_identifier("1a") == "_1a" assert _ensure_identifier("from") == "from_" assert _ensure_identifier("hello-world") == "hello_world" assert _ensure_identifier("hello_world") == "hello_world" assert _ensure_identifier("hello world") == "hello_world" superqt-0.6.8/tests/test_fonticon/test_plugins.py000066400000000000000000000013231463340017400223430ustar00rootroot00000000000000import sys from pathlib import Path import pytest from qtpy.QtGui import QIcon, QPixmap from superqt.fonticon import _plugins, icon from superqt.fonticon._qfont_icon import QFontIconStore FIXTURES = Path(__file__).parent / "fixtures" @pytest.fixture def plugin_store(qapp, monkeypatch): _path = [str(FIXTURES), *sys.path.copy()] store = QFontIconStore().instance() with monkeypatch.context() as m: m.setattr(sys, "path", _path) yield store store.clear() def test_plugin(plugin_store): assert not _plugins.loaded() icn = icon("ico.smiley") assert _plugins.loaded() == {"ico": ["smiley"]} assert isinstance(icn, QIcon) assert isinstance(icn.pixmap(40, 40), QPixmap) superqt-0.6.8/tests/test_iconify.py000066400000000000000000000011321463340017400174420ustar00rootroot00000000000000from typing import TYPE_CHECKING import pytest from qtpy.QtGui import QIcon from qtpy.QtWidgets import QPushButton from superqt import QIconifyIcon if TYPE_CHECKING: from pytestqt.qtbot import QtBot def test_qiconify(qtbot: "QtBot", monkeypatch: "pytest.MonkeyPatch") -> None: monkeypatch.setenv("PYCONIFY_CACHE", "0") pytest.importorskip("pyconify") icon = QIconifyIcon("bi:alarm-fill", color="red", flip="vertical") icon.addKey("bi:alarm", color="blue", rotate=90, state=QIcon.State.On) btn = QPushButton() qtbot.addWidget(btn) btn.setIcon(icon) btn.show() superqt-0.6.8/tests/test_large_int_spinbox.py000066400000000000000000000050721463340017400215170ustar00rootroot00000000000000from qtpy.QtCore import Qt from superqt.spinbox import QLargeIntSpinBox def test_large_spinbox(qtbot): sb = QLargeIntSpinBox() qtbot.addWidget(sb) for e in range(2, 100, 2): sb.setMaximum(10**e + 2) with qtbot.waitSignal(sb.valueChanged) as sgnl: sb.setValue(10**e) assert sgnl.args == [10**e] assert sb.value() == 10**e sb.setMinimum(-(10**e) - 2) with qtbot.waitSignal(sb.valueChanged) as sgnl: sb.setValue(-(10**e)) assert sgnl.args == [-(10**e)] assert sb.value() == -(10**e) def test_large_spinbox_range(qtbot): sb = QLargeIntSpinBox() qtbot.addWidget(sb) sb.setRange(-100, 100) sb.setValue(50) sb.setRange(-10, 10) assert sb.value() == 10 sb.setRange(100, 1000) assert sb.value() == 100 sb.setRange(50, 0) assert sb.minimum() == 50 assert sb.maximum() == 50 assert sb.value() == 50 def test_large_spinbox_type(qtbot): sb = QLargeIntSpinBox() qtbot.addWidget(sb) assert isinstance(sb.value(), int) sb.setValue(1.1) assert isinstance(sb.value(), int) assert sb.value() == 1 sb.setValue(1.9) assert isinstance(sb.value(), int) assert sb.value() == 1 def test_large_spinbox_signals(qtbot): sb = QLargeIntSpinBox() qtbot.addWidget(sb) with qtbot.waitSignal(sb.valueChanged) as sgnl: sb.setValue(200) assert sgnl.args == [200] with qtbot.waitSignal(sb.textChanged) as sgnl: sb.setValue(240) assert sgnl.args == ["240"] def test_keyboard_tracking(qtbot): sb = QLargeIntSpinBox() qtbot.addWidget(sb) assert sb.value() == 0 sb.setKeyboardTracking(False) with qtbot.assertNotEmitted(sb.valueChanged): sb.lineEdit().setText("20") assert sb.lineEdit().text() == "20" assert sb.value() == 0 assert sb._pending_emit is True with qtbot.waitSignal(sb.valueChanged) as sgnl: qtbot.keyPress(sb, Qt.Key.Key_Enter) assert sgnl.args == [20] assert sb._pending_emit is False sb.setKeyboardTracking(True) with qtbot.waitSignal(sb.valueChanged) as sgnl: sb.lineEdit().setText("25") assert sb._pending_emit is False assert sgnl.args == [25] def test_large_spinbox_step_type(qtbot): sb = QLargeIntSpinBox() qtbot.addWidget(sb) sb.setMaximum(1_000_000_000) sb.setStepType(sb.StepType.AdaptiveDecimalStepType) sb.setValue(1_000_000) sb.stepBy(1) assert sb.value() == 1_100_000 sb.setStepType(sb.StepType.DefaultStepType) sb.stepBy(1) assert sb.value() == 1_100_001 superqt-0.6.8/tests/test_qmessage_handler.py000066400000000000000000000021201463340017400213020ustar00rootroot00000000000000import logging from qtpy import QtCore from superqt import QMessageHandler def test_message_handler(): with QMessageHandler() as mh: QtCore.qDebug("debug") QtCore.qWarning("warning") QtCore.qCritical("critical") assert len(mh.records) == 3 assert mh.records[0].level == logging.DEBUG assert mh.records[1].level == logging.WARNING assert mh.records[2].level == logging.CRITICAL assert "3 records" in repr(mh) def test_message_handler_with_logger(caplog): logger = logging.getLogger("test_logger") caplog.set_level(logging.DEBUG, logger="test_logger") with QMessageHandler(logger): QtCore.qDebug("debug") QtCore.qWarning("warning") QtCore.qCritical("critical") assert len(caplog.records) == 3 assert caplog.records[0].message == "debug" assert caplog.records[0].levelno == logging.DEBUG assert caplog.records[1].message == "warning" assert caplog.records[1].levelno == logging.WARNING assert caplog.records[2].message == "critical" assert caplog.records[2].levelno == logging.CRITICAL superqt-0.6.8/tests/test_quantity.py000066400000000000000000000023361463340017400176670ustar00rootroot00000000000000from pint import Quantity from superqt import QQuantity def test_qquantity(qtbot): w = QQuantity(1, "m") qtbot.addWidget(w) assert w.value() == 1 * w.unitRegistry().meter assert w.magnitude() == 1 assert w.units() == w.unitRegistry().meter assert w.text() == "1 meter" w.setUnits("cm") assert w.value() == 100 * w.unitRegistry().centimeter assert w.magnitude() == 100 assert w.units() == w.unitRegistry().centimeter assert w.text() == "100.0 centimeter" w.setMagnitude(10) assert w.value() == 10 * w.unitRegistry().centimeter assert w.magnitude() == 10 assert w.units() == w.unitRegistry().centimeter assert w.text() == "10 centimeter" w.setValue(1 * w.unitRegistry().meter) assert w.value() == 1 * w.unitRegistry().meter assert w.magnitude() == 1 assert w.units() == w.unitRegistry().meter assert w.text() == "1 meter" w.setUnits(None) assert w.isDimensionless() assert w.unitsComboBox().currentText() == "-----" assert w.magnitude() == 1 def test_change_qquantity_value(qtbot): w = QQuantity() qtbot.addWidget(w) assert w.value() == Quantity(0) w.setValue(Quantity("1 meter")) assert w.value() == Quantity("1 meter") superqt-0.6.8/tests/test_searchable_combobox.py000066400000000000000000000026541463340017400217750ustar00rootroot00000000000000from superqt import QSearchableComboBox class TestSearchableComboBox: def test_constructor(self, qtbot): widget = QSearchableComboBox() qtbot.addWidget(widget) def test_add_items(self, qtbot): widget = QSearchableComboBox() qtbot.addWidget(widget) widget.addItems(["foo", "bar"]) assert widget.completer_object.model().rowCount() == 2 widget.addItem("foobar") assert widget.completer_object.model().rowCount() == 3 widget.insertItem(1, "baz") assert widget.completer_object.model().rowCount() == 4 widget.insertItems(2, ["bazbar", "foobaz"]) assert widget.completer_object.model().rowCount() == 6 assert widget.itemText(0) == "foo" assert widget.itemText(1) == "baz" assert widget.itemText(2) == "bazbar" def test_completion(self, qtbot): widget = QSearchableComboBox() qtbot.addWidget(widget) widget.addItems(["foo", "bar", "foobar", "baz", "bazbar", "foobaz"]) widget.completer_object.setCompletionPrefix("fo") assert widget.completer_object.completionCount() == 3 assert widget.completer_object.currentCompletion() == "foo" widget.completer_object.setCurrentRow(1) assert widget.completer_object.currentCompletion() == "foobar" widget.completer_object.setCurrentRow(2) assert widget.completer_object.currentCompletion() == "foobaz" superqt-0.6.8/tests/test_searchable_list.py000066400000000000000000000021741463340017400211350ustar00rootroot00000000000000from superqt import QSearchableListWidget class TestSearchableListWidget: def test_create(self, qtbot): widget = QSearchableListWidget() qtbot.addWidget(widget) widget.addItem("aaa") assert widget.count() == 1 def test_add_items(self, qtbot): widget = QSearchableListWidget() qtbot.addWidget(widget) widget.addItems(["foo", "bar"]) assert widget.count() == 2 widget.insertItems(1, ["baz", "foobaz"]) widget.insertItem(2, "foobar") assert widget.count() == 5 assert widget.item(0).text() == "foo" assert widget.item(1).text() == "baz" assert widget.item(2).text() == "foobar" def test_completion(self, qtbot): widget = QSearchableListWidget() qtbot.addWidget(widget) widget.show() widget.addItems(["foo", "bar", "foobar", "baz", "bazbar", "foobaz"]) widget.filter_widget.setText("fo") assert widget.count() == 6 for i in range(widget.count()): item = widget.item(i) assert item.isHidden() == ("fo" not in item.text()) widget.hide() superqt-0.6.8/tests/test_searchable_tree.py000066400000000000000000000106121463340017400211150ustar00rootroot00000000000000from typing import List, Tuple import pytest from pytestqt.qtbot import QtBot from qtpy.QtCore import Qt from qtpy.QtWidgets import QTreeWidget, QTreeWidgetItem from superqt import QSearchableTreeWidget @pytest.fixture def data() -> dict: return { "none": None, "str": "test", "int": 42, "list": [2, 3, 5], "dict": { "float": 0.5, "tuple": (22, 99), "bool": False, }, } @pytest.fixture def widget(qtbot: QtBot, data: dict) -> QSearchableTreeWidget: widget = QSearchableTreeWidget.fromData(data) qtbot.addWidget(widget) return widget def columns(item: QTreeWidgetItem) -> Tuple[str, str]: return item.text(0), item.text(1) def all_items(tree: QTreeWidget) -> List[QTreeWidgetItem]: return tree.findItems("", Qt.MatchContains | Qt.MatchRecursive) def shown_items(tree: QTreeWidget) -> List[QTreeWidgetItem]: items = all_items(tree) return [item for item in items if not item.isHidden()] def test_init(qtbot: QtBot): widget = QSearchableTreeWidget() qtbot.addWidget(widget) assert widget.tree.topLevelItemCount() == 0 def test_from_data(qtbot: QtBot, data: dict): widget = QSearchableTreeWidget.fromData(data) qtbot.addWidget(widget) tree = widget.tree assert tree.topLevelItemCount() == 5 none_item = tree.topLevelItem(0) assert columns(none_item) == ("none", "None") assert none_item.childCount() == 0 str_item = tree.topLevelItem(1) assert columns(str_item) == ("str", "test") assert str_item.childCount() == 0 int_item = tree.topLevelItem(2) assert columns(int_item) == ("int", "42") assert int_item.childCount() == 0 list_item = tree.topLevelItem(3) assert columns(list_item) == ("list", "list") assert list_item.childCount() == 3 assert columns(list_item.child(0)) == ("0", "2") assert columns(list_item.child(1)) == ("1", "3") assert columns(list_item.child(2)) == ("2", "5") dict_item = tree.topLevelItem(4) assert columns(dict_item) == ("dict", "dict") assert dict_item.childCount() == 3 assert columns(dict_item.child(0)) == ("float", "0.5") tuple_item = dict_item.child(1) assert columns(tuple_item) == ("tuple", "tuple") assert tuple_item.childCount() == 2 assert columns(tuple_item.child(0)) == ("0", "22") assert columns(tuple_item.child(1)) == ("1", "99") assert columns(dict_item.child(2)) == ("bool", "False") def test_set_data(widget: QSearchableTreeWidget): tree = widget.tree assert tree.topLevelItemCount() != 1 widget.setData({"test": "reset"}) assert tree.topLevelItemCount() == 1 assert columns(tree.topLevelItem(0)) == ("test", "reset") def test_search_no_match(widget: QSearchableTreeWidget): widget.filter.setText("no match here") items = shown_items(widget.tree) assert len(items) == 0 def test_search_all_match(widget: QSearchableTreeWidget): widget.filter.setText("") tree = widget.tree assert all_items(tree) == shown_items(tree) def test_search_match_one_key(widget: QSearchableTreeWidget): widget.filter.setText("int") items = shown_items(widget.tree) assert len(items) == 1 assert columns(items[0]) == ("int", "42") def test_search_match_one_value(widget: QSearchableTreeWidget): widget.filter.setText("test") items = shown_items(widget.tree) assert len(items) == 1 assert columns(items[0]) == ("str", "test") def test_search_match_many_keys(widget: QSearchableTreeWidget): widget.filter.setText("n") items = shown_items(widget.tree) assert len(items) == 2 assert columns(items[0]) == ("none", "None") assert columns(items[1]) == ("int", "42") def test_search_match_one_show_unmatched_descendants(widget: QSearchableTreeWidget): widget.filter.setText("list") items = shown_items(widget.tree) assert len(items) == 4 assert columns(items[0]) == ("list", "list") assert columns(items[1]) == ("0", "2") assert columns(items[2]) == ("1", "3") assert columns(items[3]) == ("2", "5") def test_search_match_one_show_unmatched_ancestors(widget: QSearchableTreeWidget): widget.filter.setText("tuple") items = shown_items(widget.tree) assert len(items) == 4 assert columns(items[0]) == ("dict", "dict") assert columns(items[1]) == ("tuple", "tuple") assert columns(items[2]) == ("0", "22") assert columns(items[3]) == ("1", "99") superqt-0.6.8/tests/test_threadworker.py000066400000000000000000000174441463340017400205200ustar00rootroot00000000000000import inspect import threading import time import warnings from functools import partial from operator import eq from unittest.mock import Mock import pytest import superqt.utils._qthreading as qthreading equals_1 = partial(eq, 1) equals_3 = partial(eq, 3) skip = pytest.mark.skipif(True, reason="testing") def test_as_generator_function(): """Test we can convert a regular function to a generator function.""" def func(): return assert not inspect.isgeneratorfunction(func) newfunc = qthreading.as_generator_function(func) assert inspect.isgeneratorfunction(newfunc) assert list(newfunc()) == [None] # qtbot is necessary for qthreading here. # note: pytest-cov cannot check coverage of code run in the other thread. def test_thread_worker(qtbot): """Test basic threadworker on a function""" @qthreading.thread_worker def func(): return 1 wrkr = func() assert isinstance(wrkr, qthreading.FunctionWorker) signals = [wrkr.returned, wrkr.finished] checks = [equals_1, lambda: True] with qtbot.waitSignals(signals, check_params_cbs=checks, order="strict"): wrkr.start() def test_thread_generator_worker(qtbot): """Test basic threadworker on a generator""" @qthreading.thread_worker def func(): yield 1 yield 1 return 3 wrkr = func() assert isinstance(wrkr, qthreading.GeneratorWorker) signals = [wrkr.yielded, wrkr.yielded, wrkr.returned, wrkr.finished] checks = [equals_1, equals_1, equals_3, lambda: True] with qtbot.waitSignals(signals, check_params_cbs=checks, order="strict"): wrkr.start() qtbot.wait(500) def test_thread_raises2(qtbot): handle_val = [0] def handle_raise(e): handle_val[0] = 1 assert isinstance(e, ValueError) assert str(e) == "whoops" @qthreading.thread_worker(connect={"errored": handle_raise}, start_thread=False) def func(): yield 1 yield 1 raise ValueError("whoops") wrkr = func() assert isinstance(wrkr, qthreading.GeneratorWorker) signals = [wrkr.yielded, wrkr.yielded, wrkr.errored, wrkr.finished] checks = [equals_1, equals_1, None, None] with qtbot.waitSignals(signals, check_params_cbs=checks): wrkr.start() assert handle_val[0] == 1 def test_thread_warns(qtbot): """Test warnings get returned to main thread""" def check_warning(w): return str(w) == "hey!" @qthreading.thread_worker(connect={"warned": check_warning}, start_thread=False) def func(): yield 1 warnings.warn("hey!") # noqa: B028 yield 3 warnings.warn("hey!") # noqa: B028 return 1 wrkr = func() assert isinstance(wrkr, qthreading.GeneratorWorker) signals = [wrkr.yielded, wrkr.warned, wrkr.yielded, wrkr.returned] checks = [equals_1, None, equals_3, equals_1] with qtbot.waitSignals(signals, check_params_cbs=checks): wrkr.start() def test_multiple_connections(qtbot): """Test the connect dict accepts a list of functions, and type checks""" test1_val = [0] test2_val = [0] def func(): return 1 def test1(v): test1_val[0] = 1 assert v == 1 def test2(v): test2_val[0] = 1 assert v == 1 thread_func = qthreading.thread_worker( func, connect={"returned": [test1, test2]}, start_thread=False ) worker = thread_func() assert isinstance(worker, qthreading.FunctionWorker) with qtbot.waitSignal(worker.finished): worker.start() assert test1_val[0] == 1 assert test2_val[0] == 1 # they must all be functions with pytest.raises(TypeError): qthreading.thread_worker(func, connect={"returned": ["test1", test2]})() # they must all be functions with pytest.raises(TypeError): qthreading.thread_worker(func, connect=test1)() def test_create_worker(qapp): """Test directly calling create_worker.""" def func(x, y): return x + y worker = qthreading.create_worker(func, 1, 2) assert isinstance(worker, qthreading.WorkerBase) with pytest.raises(TypeError): _ = qthreading.create_worker(func, 1, 2, _worker_class=object) # note: pytest-cov cannot check coverage of code run in the other thread. # this is just for the sake of coverage def test_thread_worker_in_main_thread(qapp): """Test basic threadworker on a function""" def func(x): return x thread_func = qthreading.thread_worker(func) worker = thread_func(2) # NOTE: you shouldn't normally call worker.work()! If you do, it will NOT # be run in a separate thread (as it would for worker.start(). # This is for the sake of testing it in the main thread. assert worker.work() == 2 # note: pytest-cov cannot check coverage of code run in the other thread. # this is just for the sake of coverage def test_thread_generator_worker_in_main_thread(qapp): """Test basic threadworker on a generator in the main thread with methods.""" def func(): i = 0 while i < 10: i += 1 incoming = yield i i = incoming if incoming is not None else i return 3 worker = qthreading.thread_worker(func, start_thread=False)() counter = 0 def handle_pause(): time.sleep(0.1) assert worker.is_paused worker.toggle_pause() def test_yield(v): nonlocal counter counter += 1 if v == 2: assert not worker.is_paused worker.pause() assert not worker.is_paused if v == 3: worker.send(7) if v == 9: worker.quit() def handle_abort(): assert counter == 5 # because we skipped a few by sending in 7 worker.paused.connect(handle_pause) assert isinstance(worker, qthreading.GeneratorWorker) worker.yielded.connect(test_yield) worker.aborted.connect(handle_abort) # NOTE: you shouldn't normally call worker.work()! If you do, it will NOT # be run in a separate thread (as it would for worker.start(). # This is for the sake of testing it in the main thread. assert worker.work() is None # because we aborted it assert not worker.is_paused assert counter == 5 worker2 = qthreading.thread_worker(func, start_thread=False)() assert worker2.work() == 3 def test_worker_base_attribute(qapp): obj = qthreading.WorkerBase() assert obj.started is not None assert obj.finished is not None assert obj.returned is not None assert obj.errored is not None with pytest.raises(AttributeError): _ = obj.aa def test_abort_does_not_return(qtbot): loop_counter = 0 def long_running_func(): nonlocal loop_counter for _ in range(5): yield loop_counter time.sleep(0.1) loop_counter += 1 abort_counter = 0 def count_abort(): nonlocal abort_counter abort_counter += 1 return_counter = 0 def returned_handler(value): nonlocal return_counter return_counter += 1 threaded_function = qthreading.thread_worker( long_running_func, connect={ "returned": returned_handler, "aborted": count_abort, }, ) worker = threaded_function() worker.quit() qtbot.wait(600) assert loop_counter < 4 assert abort_counter == 1 assert return_counter == 0 def test_nested_threads_start(qtbot): mock1 = Mock() mock2 = Mock() event = threading.Event() def call_mock(_e=event): def nested_func(): mock2() _e.set() mock1() worker2 = qthreading.create_worker(nested_func) worker2.start() worker = qthreading.create_worker(call_mock) worker.start() event.wait(timeout=2) mock1.assert_called_once() mock2.assert_called_once() superqt-0.6.8/tests/test_throttler.py000066400000000000000000000117421463340017400200410ustar00rootroot00000000000000import gc import weakref from unittest.mock import Mock import pytest from qtpy.QtCore import QObject, Signal from superqt.utils import qdebounced, qthrottled from superqt.utils._throttler import ThrottledCallable def test_debounced(qtbot): mock1 = Mock() mock2 = Mock() @qdebounced(timeout=5) def f1() -> str: mock1() def f2() -> str: mock2() for _ in range(10): f1() f2() qtbot.wait(5) mock1.assert_called_once() assert mock2.call_count == 10 @pytest.mark.usefixtures("qapp") def test_stop_timer_simple(): mock = Mock() @qdebounced(timeout=5) def f1() -> str: mock() f1() assert f1._timer.isActive() mock.assert_not_called() f1.flush(restart_timer=False) assert not f1._timer.isActive() mock.assert_called_once() @pytest.mark.usefixtures("qapp") def test_stop_timer_no_event_pending(): mock = Mock() @qdebounced(timeout=5) def f1() -> str: mock() f1() assert f1._timer.isActive() mock.assert_not_called() f1.flush() assert f1._timer.isActive() mock.assert_called_once() f1.flush(restart_timer=False) assert not f1._timer.isActive() mock.assert_called_once() def test_debouncer_method(qtbot): class A(QObject): def __init__(self): super().__init__() self.count = 0 def callback(self): self.count += 1 a = A() assert all(not isinstance(x, ThrottledCallable) for x in a.children()) b = qdebounced(a.callback, timeout=4) assert any(isinstance(x, ThrottledCallable) for x in a.children()) for _ in range(10): b() qtbot.wait(5) assert a.count == 1 def test_debouncer_method_definition(qtbot): mock1 = Mock() mock2 = Mock() class A(QObject): def __init__(self): super().__init__() self.count = 0 @qdebounced(timeout=4) def callback(self): self.count += 1 @qdebounced(timeout=4) @staticmethod def call1(): mock1() @staticmethod @qdebounced(timeout=4) def call2(): mock2() a = A() assert all(not isinstance(x, ThrottledCallable) for x in a.children()) for _ in range(10): a.callback(1) A.call1(34) a.call1(22) a.call2(22) A.call2(32) qtbot.wait(5) assert a.count == 1 mock1.assert_called_once() mock2.assert_called_once() def test_class_with_slots(qtbot): class A: __slots__ = ("count", "__weakref__") def __init__(self): self.count = 0 @qdebounced(timeout=4) def callback(self): self.count += 1 a = A() for _ in range(10): a.callback() qtbot.wait(5) assert a.count == 1 @pytest.mark.usefixtures("qapp") def test_class_with_slots_except(): class A: __slots__ = ("count",) def __init__(self): self.count = 0 @qdebounced(timeout=4) def callback(self): self.count += 1 with pytest.raises(TypeError, match="To use qthrottled or qdebounced"): A().callback() def test_throttled(qtbot): mock1 = Mock() mock2 = Mock() @qthrottled(timeout=5) def f1() -> str: mock1() def f2() -> str: mock2() for _ in range(10): f1() f2() qtbot.wait(5) assert mock1.call_count == 2 assert mock2.call_count == 10 @pytest.mark.parametrize("deco", [qthrottled, qdebounced]) def test_ensure_throttled_sig_inspection(deco, qtbot): mock = Mock() class Emitter(QObject): sig = Signal(int, int, int) @deco def func(a: int, b: int): """docstring""" mock(a, b) obj = Emitter() obj.sig.connect(func) # this is the crux of the test... # we emit 3 args, but the function only takes 2 # this should normally work fine in Qt. # testing here that the decorator doesn't break it. with qtbot.waitSignal(func.triggered, timeout=1000): obj.sig.emit(1, 2, 3) mock.assert_called_once_with(1, 2) assert func.__doc__ == "docstring" assert func.__name__ == "func" def test_qthrottled_does_not_prevent_gc(qtbot): mock = Mock() class Thing: @qdebounced(timeout=1) def dmethod(self) -> None: mock() @qthrottled(timeout=1) def tmethod(self, x: int = 1) -> None: mock() thing = Thing() thing_ref = weakref.ref(thing) assert thing_ref() is not None thing.dmethod() qtbot.waitUntil(thing.dmethod._future.done, timeout=2000) assert mock.call_count == 1 thing.tmethod() qtbot.waitUntil(thing.tmethod._future.done, timeout=2000) assert mock.call_count == 2 wm = thing.tmethod assert isinstance(wm, ThrottledCallable) del thing gc.collect() assert thing_ref() is None with pytest.warns(RuntimeWarning, match="Method has been garbage collected"): wm() wm._set_future_result() superqt-0.6.8/tests/test_utils.py000066400000000000000000000064661463340017400171610ustar00rootroot00000000000000import os import sys from unittest.mock import Mock import pytest import qtpy from qtpy.QtCore import QObject, QTimer, Signal from qtpy.QtWidgets import QApplication, QErrorMessage, QMessageBox from superqt.utils import exceptions_as_dialog, signals_blocked from superqt.utils._util import get_max_args def test_signal_blocker(qtbot): """make sure context manager signal blocker works""" class Emitter(QObject): sig = Signal() obj = Emitter() receiver = Mock() obj.sig.connect(receiver) # make sure signal works with qtbot.waitSignal(obj.sig): obj.sig.emit() receiver.assert_called_once() receiver.reset_mock() with signals_blocked(obj): obj.sig.emit() qtbot.wait(10) receiver.assert_not_called() def test_get_max_args_simple(): def fun1(): pass assert get_max_args(fun1) == 0 def fun2(a): pass assert get_max_args(fun2) == 1 def fun3(a, b=1): pass assert get_max_args(fun3) == 2 def fun4(a, *, b=2): pass assert get_max_args(fun4) == 1 def fun5(a, *b): pass assert get_max_args(fun5) is None assert get_max_args(print) is None def test_get_max_args_wrapped(): from functools import partial, wraps def fun1(a, b): pass assert get_max_args(partial(fun1, 1)) == 1 def dec(fun): @wraps(fun) def wrapper(*args, **kwargs): return fun(*args, **kwargs) return wrapper assert get_max_args(dec(fun1)) == 2 def test_get_max_args_methods(): class A: def fun1(self): pass def fun2(self, a): pass def __call__(self, a, b=1): pass assert get_max_args(A().fun1) == 0 assert get_max_args(A().fun2) == 1 assert get_max_args(A()) == 2 MAC_CI_PYSIDE6 = bool( sys.platform == "darwin" and os.getenv("CI") and qtpy.API_NAME == "PySide6" ) @pytest.mark.skipif(MAC_CI_PYSIDE6, reason="still hangs on mac ci with pyside6") def test_exception_context(qtbot, qapp: QApplication) -> None: def accept(): for wdg in qapp.topLevelWidgets(): if isinstance(wdg, QMessageBox): wdg.button(QMessageBox.StandardButton.Ok).click() with exceptions_as_dialog(): QTimer.singleShot(0, accept) raise Exception("This will be caught and shown in a QMessageBox") with pytest.raises(ZeroDivisionError), exceptions_as_dialog(ValueError): 1 / 0 # noqa with exceptions_as_dialog(msg_template="Error: {exc_value}"): QTimer.singleShot(0, accept) raise Exception("This message will be used as 'exc_value'") err = QErrorMessage() with exceptions_as_dialog(use_error_message=err): QTimer.singleShot(0, err.accept) raise AssertionError("Uncheck the checkbox to ignore this in the future") # tb formatting smoke test, and return value checking exc = ValueError("Bad Val") with exceptions_as_dialog( msg_template="{tb}", buttons=QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel, ) as ctx: qtbot.addWidget(ctx.dialog) QTimer.singleShot(100, accept) raise exc assert isinstance(ctx.dialog, QMessageBox) assert ctx.dialog.result() == QMessageBox.StandardButton.Ok assert ctx.exception is exc superqt-0.6.8/tests/zz_test_sliders/000077500000000000000000000000001463340017400176235ustar00rootroot00000000000000superqt-0.6.8/tests/zz_test_sliders/__init__.py000066400000000000000000000000001463340017400217220ustar00rootroot00000000000000superqt-0.6.8/tests/zz_test_sliders/_testutil.py000066400000000000000000000045321463340017400222150ustar00rootroot00000000000000from contextlib import suppress from platform import system import pytest from qtpy import QT_VERSION from qtpy.QtCore import QEvent, QPoint, QPointF, Qt from qtpy.QtGui import QHoverEvent, QMouseEvent, QWheelEvent QT_VERSION = tuple(int(x) for x in QT_VERSION.split(".")) SYS_DARWIN = system() == "Darwin" skip_on_linux_qt6 = pytest.mark.skipif( system() == "Linux" and QT_VERSION >= (6, 0), reason="hover events not working on linux pyqt6", ) _PointF = QPointF() def _mouse_event(pos=_PointF, type_=QEvent.Type.MouseMove): """Create a mouse event of `type_` at `pos`.""" return QMouseEvent( type_, QPointF(pos), # localPos QPointF(), # windowPos / globalPos Qt.MouseButton.LeftButton, # button Qt.MouseButton.LeftButton, # buttons Qt.KeyboardModifier.NoModifier, # modifiers ) def _wheel_event(arc): """Create a wheel event with `arc`.""" with suppress(TypeError): return QWheelEvent( QPointF(), QPointF(), QPoint(arc, arc), QPoint(arc, arc), Qt.MouseButton.NoButton, Qt.KeyboardModifier.NoModifier, Qt.ScrollPhase.ScrollBegin, False, Qt.MouseEventSource.MouseEventSynthesizedByQt, ) with suppress(TypeError): return QWheelEvent( QPointF(), QPointF(), QPoint(-arc, -arc), QPoint(-arc, -arc), 1, Qt.Orientation.Vertical, Qt.MouseButton.NoButton, Qt.KeyboardModifier.NoModifier, Qt.ScrollPhase.ScrollBegin, False, Qt.MouseEventSource.MouseEventSynthesizedByQt, ) return QWheelEvent( QPointF(), QPointF(), QPoint(arc, arc), QPoint(arc, arc), 1, Qt.Orientation.Vertical, Qt.MouseButton.NoButton, Qt.KeyboardModifier.NoModifier, ) def _hover_event(_type, position, old_position, widget=None): with suppress(TypeError): return QHoverEvent( _type, position, widget.mapToGlobal(position), old_position, ) return QHoverEvent(_type, position, old_position) def _linspace(start: int, stop: int, n: int): h = (stop - start) / (n - 1) for i in range(n): yield start + h * i superqt-0.6.8/tests/zz_test_sliders/test_float.py000066400000000000000000000067161463340017400223530ustar00rootroot00000000000000import math import os import pytest from qtpy import API_NAME from qtpy.QtWidgets import QStyleOptionSlider from superqt import ( QDoubleRangeSlider, QDoubleSlider, QLabeledDoubleRangeSlider, QLabeledDoubleSlider, ) from ._testutil import _linspace range_types = {QDoubleRangeSlider, QLabeledDoubleRangeSlider} @pytest.fixture( params=[ QDoubleSlider, QLabeledDoubleSlider, QDoubleRangeSlider, QLabeledDoubleRangeSlider, ] ) def ds(qtbot, request): # convenience fixture that converts value() and setValue() # to let us use setValue((a, b)) for both range and non-range sliders cls = request.param wdg = cls() qtbot.addWidget(wdg) def assert_val_type(): type_ = float if cls in range_types: assert all(isinstance(i, type_) for i in wdg.value()) # sourcery skip else: assert isinstance(wdg.value(), type_) def assert_val_eq(val): assert wdg.value() == val if cls is QDoubleRangeSlider else val[0] wdg.assert_val_type = assert_val_type wdg.assert_val_eq = assert_val_eq if cls not in range_types: superset = wdg.setValue def _safe_set(val): superset(val[0] if isinstance(val, tuple) else val) wdg.setValue = _safe_set return wdg def test_double_sliders(ds): ds.setMinimum(10) ds.setMaximum(99) ds.setValue((20, 40)) ds.setSingleStep(1) assert ds.minimum() == 10 assert ds.maximum() == 99 ds.assert_val_eq((20, 40)) assert ds.singleStep() == 1 ds.assert_val_eq((20, 40)) ds.assert_val_type() ds.setValue((20.23, 40.23)) ds.assert_val_eq((20.23, 40.23)) ds.assert_val_type() assert ds.minimum() == 10 assert ds.maximum() == 99 assert ds.singleStep() == 1 ds.assert_val_eq((20.23, 40.23)) ds.setValue((20.2343, 40.2342)) ds.assert_val_eq((20.2343, 40.2342)) ds.assert_val_eq((20.2343, 40.2342)) assert ds.minimum() == 10 assert ds.maximum() == 99 assert ds.singleStep() == 1 ds.assert_val_eq((20.2343, 40.2342)) assert ds.minimum() == 10 assert ds.maximum() == 99 assert ds.singleStep() == 1 def test_double_sliders_small(ds): ds.setMaximum(1) ds.setValue((0.5, 0.9)) assert ds.minimum() == 0 assert ds.maximum() == 1 ds.assert_val_eq((0.5, 0.9)) ds.setValue((0.122233, 0.72644353)) ds.assert_val_eq((0.122233, 0.72644353)) def test_double_sliders_big(ds): ds.setValue((20, 80)) ds.setMaximum(5e14) assert ds.minimum() == 0 assert ds.maximum() == 5e14 ds.setValue((1.74e9, 1.432e10)) ds.assert_val_eq((1.74e9, 1.432e10)) @pytest.mark.skipif( os.name == "nt" and API_NAME == "PyQt6", reason="Not ready for pyqt6" ) def test_signals(ds, qtbot): with qtbot.waitSignal(ds.valueChanged): ds.setValue((10, 20)) with qtbot.waitSignal(ds.rangeChanged): ds.setMinimum(0.5) with qtbot.waitSignal(ds.rangeChanged): ds.setMaximum(3.7) with qtbot.waitSignal(ds.rangeChanged): ds.setRange(1.2, 3.3) @pytest.mark.parametrize("mag", list(range(4, 37, 4)) + list(range(-4, -37, -4))) def test_slider_extremes(mag, qtbot): sld = QDoubleSlider() _mag = 10**mag with qtbot.waitSignal(sld.rangeChanged): sld.setRange(-_mag, _mag) for i in _linspace(-_mag, _mag, 10): sld.setValue(i) assert math.isclose(sld.value(), i, rel_tol=1e-8) sld.initStyleOption(QStyleOptionSlider()) superqt-0.6.8/tests/zz_test_sliders/test_generic_slider.py000066400000000000000000000136531463340017400242220ustar00rootroot00000000000000import math import platform import pytest from qtpy.QtCore import QEvent, QPoint, QPointF, Qt from qtpy.QtWidgets import QStyle, QStyleOptionSlider from superqt.sliders._generic_slider import _GenericSlider, _sliderValueFromPosition from ._testutil import _hover_event, _mouse_event, _wheel_event, skip_on_linux_qt6 @pytest.fixture(params=[Qt.Orientation.Horizontal, Qt.Orientation.Vertical]) def gslider(qtbot, request): slider = _GenericSlider(request.param) qtbot.addWidget(slider) assert slider.value() == 0 assert slider.minimum() == 0 assert slider.maximum() == 99 yield slider slider.initStyleOption(QStyleOptionSlider()) def test_change_floatslider_range(gslider: _GenericSlider, qtbot): with qtbot.waitSignals([gslider.rangeChanged, gslider.valueChanged]): gslider.setMinimum(10) assert gslider.value() == 10 == gslider.minimum() assert gslider.maximum() == 99 with qtbot.waitSignal(gslider.rangeChanged): gslider.setMaximum(90) assert gslider.value() == 10 == gslider.minimum() assert gslider.maximum() == 90 with qtbot.waitSignals([gslider.rangeChanged, gslider.valueChanged]): gslider.setRange(20, 40) assert gslider.value() == 20 == gslider.minimum() assert gslider.maximum() == 40 with qtbot.waitSignal(gslider.valueChanged): gslider.setValue(30) assert gslider.value() == 30 with qtbot.waitSignals([gslider.rangeChanged, gslider.valueChanged]): gslider.setMaximum(25) assert gslider.value() == 25 == gslider.maximum() assert gslider.minimum() == 20 def test_float_values(gslider: _GenericSlider, qtbot): with qtbot.waitSignal(gslider.rangeChanged): gslider.setRange(0.25, 0.75) assert gslider.minimum() == 0.25 assert gslider.maximum() == 0.75 with qtbot.waitSignal(gslider.valueChanged): gslider.setValue(0.55) assert gslider.value() == 0.55 with qtbot.waitSignal(gslider.valueChanged): gslider.setValue(1.55) assert gslider.value() == 0.75 == gslider.maximum() def test_ticks(gslider: _GenericSlider, qtbot): gslider.setTickInterval(0.3) assert gslider.tickInterval() == 0.3 gslider.setTickPosition(gslider.TickPosition.TicksAbove) gslider.show() def test_show(gslider, qtbot): gslider.show() @pytest.mark.skipif(platform.system() != "Darwin", reason="cross-platform is tricky") def test_press_move_release(gslider: _GenericSlider, qtbot): # this fail on vertical came with pyside6.2 ... need to debug # still works in practice, but test fails to catch signals if gslider.orientation() == Qt.Orientation.Vertical: pytest.xfail() assert gslider._pressedControl == QStyle.SubControl.SC_None opt = QStyleOptionSlider() gslider.initStyleOption(opt) style = gslider.style() hrect = style.subControlRect( QStyle.ComplexControl.CC_Slider, opt, QStyle.SubControl.SC_SliderHandle ) handle_pos = gslider.mapToGlobal(hrect.center()) with qtbot.waitSignal(gslider.sliderPressed): qtbot.mousePress(gslider, Qt.MouseButton.LeftButton, pos=handle_pos) assert gslider._pressedControl == QStyle.SubControl.SC_SliderHandle with qtbot.waitSignals([gslider.sliderMoved, gslider.valueChanged]): shift = ( QPoint(0, -8) if gslider.orientation() == Qt.Orientation.Vertical else QPoint(8, 0) ) gslider.mouseMoveEvent(_mouse_event(handle_pos + shift)) with qtbot.waitSignal(gslider.sliderReleased): qtbot.mouseRelease(gslider, Qt.MouseButton.LeftButton, pos=handle_pos) assert gslider._pressedControl == QStyle.SubControl.SC_None gslider.show() with qtbot.waitSignal(gslider.sliderPressed): qtbot.mousePress(gslider, Qt.MouseButton.LeftButton, pos=handle_pos) @skip_on_linux_qt6 def test_hover(gslider: _GenericSlider): # stub opt = QStyleOptionSlider() gslider.initStyleOption(opt) style = gslider.style() hrect = style.subControlRect( QStyle.ComplexControl.CC_Slider, opt, QStyle.SubControl.SC_SliderHandle ) handle_pos = QPointF(gslider.mapToGlobal(hrect.center())) assert gslider._hoverControl == QStyle.SubControl.SC_None gslider.event(_hover_event(QEvent.Type.HoverEnter, handle_pos, QPointF(), gslider)) assert gslider._hoverControl == QStyle.SubControl.SC_SliderHandle gslider.event( _hover_event(QEvent.Type.HoverLeave, QPointF(-1000, -1000), handle_pos, gslider) ) assert gslider._hoverControl == QStyle.SubControl.SC_None def test_wheel(gslider: _GenericSlider, qtbot): with qtbot.waitSignal(gslider.valueChanged): gslider.wheelEvent(_wheel_event(120)) gslider.wheelEvent(_wheel_event(0)) def test_position(gslider: _GenericSlider, qtbot): gslider.setSliderPosition(21.2) assert gslider.sliderPosition() == 21.2 def test_steps(gslider: _GenericSlider, qtbot): gslider.setSingleStep(0.1) assert gslider.singleStep() == 0.1 gslider.setSingleStep(1.5e20) assert gslider.singleStep() == 1.5e20 gslider.setPageStep(0.2) assert gslider.pageStep() == 0.2 gslider.setPageStep(1.5e30) assert gslider.pageStep() == 1.5e30 # args are (min: float, max: float, position: int, span: int, upsideDown: bool) @pytest.mark.parametrize( "args, result", [ # (min, max, pos, span[, inverted]), expectation # data range (1, 2) ((1, 2, 50, 100), 1.5), ((1, 2, 70, 100), 1.7), ((1, 2, 70, 100, True), 1.3), # inverted appearance ((1, 2, 170, 100), 2), ((1, 2, 100, 100), 2), ((1, 2, -30, 100), 1), # data range (-2, 2) ((-2, 2, 50, 100), 0), ((-2, 2, 75, 100), 1), ((-2, 2, 75, 100, True), -1), # inverted appearance ((-2, 2, 170, 100), 2), ((-2, 2, 100, 100), 2), ((-2, 2, -30, 100), -2), ], ) def test_slider_value_from_position(args, result): assert math.isclose(_sliderValueFromPosition(*args), result) superqt-0.6.8/tests/zz_test_sliders/test_labeled_slider.py000066400000000000000000000044731463340017400241760ustar00rootroot00000000000000from typing import Any, Iterable from unittest.mock import Mock import pytest from superqt import QLabeledDoubleSlider, QLabeledRangeSlider, QLabeledSlider def test_labeled_slider_api(qtbot): slider = QLabeledRangeSlider() qtbot.addWidget(slider) slider.hideBar() slider.showBar() slider.setBarVisible() slider.setBarMovesAllHandles() slider.setBarIsRigid() def test_slider_connect_works(qtbot): slider = QLabeledSlider() qtbot.addWidget(slider) slider._label.editingFinished.emit() def _assert_types(args: Iterable[Any], type_: type): # sourcery skip: comprehension-to-generator assert all(isinstance(v, type_) for v in args), "invalid type" @pytest.mark.parametrize("cls", [QLabeledDoubleSlider, QLabeledSlider]) def test_labeled_signals(cls, qtbot): gslider = cls() qtbot.addWidget(gslider) type_ = float if cls == QLabeledDoubleSlider else int mock = Mock() gslider.valueChanged.connect(mock) with qtbot.waitSignal(gslider.valueChanged): gslider.setValue(10) mock.assert_called_once_with(10) _assert_types(mock.call_args.args, type_) mock = Mock() gslider.rangeChanged.connect(mock) with qtbot.waitSignal(gslider.rangeChanged): gslider.setMinimum(3) mock.assert_called_once_with(3, 99) _assert_types(mock.call_args.args, type_) mock.reset_mock() with qtbot.waitSignal(gslider.rangeChanged): gslider.setMaximum(15) mock.assert_called_once_with(3, 15) _assert_types(mock.call_args.args, type_) mock.reset_mock() with qtbot.waitSignal(gslider.rangeChanged): gslider.setRange(1, 2) mock.assert_called_once_with(1, 2) _assert_types(mock.call_args.args, type_) @pytest.mark.parametrize( "cls", [QLabeledDoubleSlider, QLabeledRangeSlider, QLabeledSlider] ) def test_editing_finished_signal(cls, qtbot): mock = Mock() slider = cls() qtbot.addWidget(slider) slider.editingFinished.connect(mock) if hasattr(slider, "_label"): slider._label.editingFinished.emit() else: slider._min_label.editingFinished.emit() mock.assert_called_once() def test_editing_float(qtbot): slider = QLabeledDoubleSlider() qtbot.addWidget(slider) slider._label.setValue(0.5) slider._label.editingFinished.emit() assert slider.value() == 0.5 superqt-0.6.8/tests/zz_test_sliders/test_range_slider.py000066400000000000000000000170561463340017400237030ustar00rootroot00000000000000import math from itertools import product from typing import Any, Iterable from unittest.mock import Mock import pytest from qtpy.QtCore import QEvent, QPoint, QPointF, Qt from qtpy.QtWidgets import QStyle, QStyleOptionSlider from superqt import QDoubleRangeSlider, QLabeledRangeSlider, QRangeSlider from ._testutil import ( _hover_event, _linspace, _mouse_event, _wheel_event, skip_on_linux_qt6, ) ALL_SLIDER_COMBOS = list( product( [QDoubleRangeSlider, QRangeSlider, QLabeledRangeSlider], [Qt.Orientation.Horizontal, Qt.Orientation.Vertical], ) ) FLOAT_SLIDERS = [c for c in ALL_SLIDER_COMBOS if c[0] == QDoubleRangeSlider] @pytest.mark.parametrize("cls, orientation", ALL_SLIDER_COMBOS) def test_slider_init(qtbot, cls, orientation): slider = cls(orientation) assert slider.value() == (20, 80) assert slider.minimum() == 0 assert slider.maximum() == 99 slider.show() qtbot.addWidget(slider) @pytest.mark.parametrize("cls, orientation", ALL_SLIDER_COMBOS) def test_change_floatslider_range(cls, orientation, qtbot): sld = cls(orientation) qtbot.addWidget(sld) with qtbot.waitSignals([sld.rangeChanged, sld.valueChanged]): sld.setMinimum(30) assert sld.value()[0] == 30 == sld.minimum() assert sld.maximum() == 99 with qtbot.waitSignal(sld.rangeChanged): sld.setMaximum(70) assert sld.value()[0] == 30 == sld.minimum() assert sld.value()[1] == 70 == sld.maximum() with qtbot.waitSignals([sld.rangeChanged, sld.valueChanged]): sld.setRange(40, 60) assert sld.value()[0] == 40 == sld.minimum() assert sld.maximum() == 60 with qtbot.waitSignal(sld.valueChanged): sld.setValue([40, 50]) assert sld.value()[0] == 40 == sld.minimum() assert sld.value()[1] == 50 with qtbot.waitSignals([sld.rangeChanged, sld.valueChanged]): sld.setMaximum(45) assert sld.value()[0] == 40 == sld.minimum() assert sld.value()[1] == 45 == sld.maximum() @pytest.mark.parametrize("cls, orientation", FLOAT_SLIDERS) def test_float_values(cls, orientation, qtbot): sld = cls(orientation) qtbot.addWidget(sld) with qtbot.waitSignal(sld.rangeChanged): sld.setRange(0.1, 0.9) assert sld.minimum() == 0.1 assert sld.maximum() == 0.9 with qtbot.waitSignal(sld.valueChanged): sld.setValue([0.4, 0.6]) assert sld.value() == (0.4, 0.6) with qtbot.waitSignal(sld.valueChanged): sld.setValue([0, 1.9]) assert sld.value()[0] == 0.1 == sld.minimum() assert sld.value()[1] == 0.9 == sld.maximum() @pytest.mark.parametrize("cls, orientation", ALL_SLIDER_COMBOS) def test_position(cls, orientation, qtbot): sld = cls(orientation) qtbot.addWidget(sld) sld.setSliderPosition([10, 80]) assert sld.sliderPosition() == (10, 80) @pytest.mark.parametrize("cls, orientation", ALL_SLIDER_COMBOS) def test_steps(cls, orientation, qtbot): sld = cls(orientation) qtbot.addWidget(sld) sld.setSingleStep(0.1) assert sld.singleStep() == 0.1 sld.setSingleStep(1.5e20) assert sld.singleStep() == 1.5e20 sld.setPageStep(0.2) assert sld.pageStep() == 0.2 sld.setPageStep(1.5e30) assert sld.pageStep() == 1.5e30 @pytest.mark.parametrize("mag", list(range(4, 37, 4)) + list(range(-4, -37, -4))) @pytest.mark.parametrize("cls, orientation", FLOAT_SLIDERS) def test_slider_extremes(cls, orientation, qtbot, mag): sld = cls(orientation) qtbot.addWidget(sld) _mag = 10**mag with qtbot.waitSignal(sld.rangeChanged): sld.setRange(-_mag, _mag) for i in _linspace(-_mag, _mag, 10): sld.setValue((i, _mag)) assert math.isclose(sld.value()[0], i, rel_tol=0.0001) sld.initStyleOption(QStyleOptionSlider()) @pytest.mark.parametrize("cls, orientation", ALL_SLIDER_COMBOS) def test_ticks(cls, orientation, qtbot): sld = cls(orientation) qtbot.addWidget(sld) sld.setTickInterval(0.3) assert sld.tickInterval() == 0.3 sld.setTickPosition(sld.TickPosition.TicksAbove) sld.show() @pytest.mark.parametrize("cls, orientation", FLOAT_SLIDERS) def test_press_move_release(cls, orientation, qtbot): sld = cls(orientation) qtbot.addWidget(sld) # this fail on vertical came with pyside6.2 ... need to debug # still works in practice, but test fails to catch signals if sld.orientation() == Qt.Orientation.Vertical: pytest.xfail() assert sld._pressedControl == QStyle.SubControl.SC_None opt = QStyleOptionSlider() sld.initStyleOption(opt) style = sld.style() hrect = style.subControlRect( QStyle.ComplexControl.CC_Slider, opt, QStyle.SubControl.SC_SliderHandle ) handle_pos = sld.mapToGlobal(hrect.center()) with qtbot.waitSignal(sld.sliderPressed): qtbot.mousePress(sld, Qt.MouseButton.LeftButton, pos=handle_pos) assert sld._pressedControl == QStyle.SubControl.SC_SliderHandle with qtbot.waitSignals([sld.sliderMoved, sld.valueChanged]): shift = ( QPoint(0, -8) if sld.orientation() == Qt.Orientation.Vertical else QPoint(8, 0) ) sld.mouseMoveEvent(_mouse_event(handle_pos + shift)) with qtbot.waitSignal(sld.sliderReleased): qtbot.mouseRelease(sld, Qt.MouseButton.LeftButton, pos=handle_pos) assert sld._pressedControl == QStyle.SubControl.SC_None sld.show() with qtbot.waitSignal(sld.sliderPressed): qtbot.mousePress(sld, Qt.MouseButton.LeftButton, pos=handle_pos) @skip_on_linux_qt6 @pytest.mark.parametrize("cls, orientation", FLOAT_SLIDERS) def test_hover(cls, orientation, qtbot): sld = cls(orientation) qtbot.addWidget(sld) hrect = sld._handleRect(0) handle_pos = QPointF(sld.mapToGlobal(hrect.center())) assert sld._hoverControl == QStyle.SubControl.SC_None sld.event(_hover_event(QEvent.Type.HoverEnter, handle_pos, QPointF(), sld)) assert sld._hoverControl == QStyle.SubControl.SC_SliderHandle sld.event( _hover_event(QEvent.Type.HoverLeave, QPointF(-1000, -1000), handle_pos, sld) ) assert sld._hoverControl == QStyle.SubControl.SC_None @pytest.mark.parametrize("cls, orientation", FLOAT_SLIDERS) def test_wheel(cls, orientation, qtbot): sld = cls(orientation) qtbot.addWidget(sld) with qtbot.waitSignal(sld.valueChanged): sld.wheelEvent(_wheel_event(120)) sld.wheelEvent(_wheel_event(0)) def _assert_types(args: Iterable[Any], type_: type): # sourcery skip: comprehension-to-generator assert all(isinstance(v, type_) for v in args), "invalid type" @pytest.mark.parametrize("cls, orientation", ALL_SLIDER_COMBOS) def test_rangeslider_signals(cls, orientation, qtbot): sld = cls(orientation) qtbot.addWidget(sld) type_ = float if cls == QDoubleRangeSlider else int mock = Mock() sld.valueChanged.connect(mock) with qtbot.waitSignal(sld.valueChanged): sld.setValue((20, 40)) mock.assert_called_once_with((20, 40)) _assert_types(mock.call_args.args, tuple) _assert_types(mock.call_args.args[0], type_) mock = Mock() sld.rangeChanged.connect(mock) with qtbot.waitSignal(sld.rangeChanged): sld.setMinimum(3) mock.assert_called_once_with(3, 99) _assert_types(mock.call_args.args, type_) mock.reset_mock() with qtbot.waitSignal(sld.rangeChanged): sld.setMaximum(15) mock.assert_called_once_with(3, 15) _assert_types(mock.call_args.args, type_) mock.reset_mock() with qtbot.waitSignal(sld.rangeChanged): sld.setRange(1, 2) mock.assert_called_once_with(1, 2) _assert_types(mock.call_args.args, type_) superqt-0.6.8/tests/zz_test_sliders/test_single_value_sliders.py000066400000000000000000000167461463340017400254540ustar00rootroot00000000000000import math import platform from contextlib import suppress import pytest from qtpy.QtCore import QEvent, QPoint, QPointF, Qt from qtpy.QtWidgets import QSlider, QStyle, QStyleOptionSlider from superqt import QDoubleSlider, QLabeledDoubleSlider, QLabeledSlider from superqt.sliders._generic_slider import _GenericSlider from ._testutil import ( QT_VERSION, _hover_event, _linspace, _mouse_event, _wheel_event, skip_on_linux_qt6, ) @pytest.fixture( params=[Qt.Orientation.Horizontal, Qt.Orientation.Vertical], ids=["horizontal", "vertical"], ) def orientation(request): return request.param START_MI_MAX_VAL = (0, 99, 0) TEST_SLIDERS = [QDoubleSlider, QLabeledSlider, QLabeledDoubleSlider] def _assert_value_in_range(sld): val = sld.value() if isinstance(val, (int, float)): val = (val,) assert all(sld.minimum() <= v <= sld.maximum() for v in val) @pytest.fixture(params=TEST_SLIDERS) def sld(request, qtbot, orientation): Cls = request.param slider = Cls(orientation) slider.setRange(*START_MI_MAX_VAL[:2]) slider.setValue(START_MI_MAX_VAL[2]) qtbot.addWidget(slider) assert (slider.minimum(), slider.maximum(), slider.value()) == START_MI_MAX_VAL _assert_value_in_range(slider) yield slider _assert_value_in_range(slider) with suppress(AttributeError): slider.initStyleOption(QStyleOptionSlider()) def called_with(*expected_result): """Use in check_params_cbs to assert that a callback is called as expected. e.g. `called_with(20, 50)` returns a callback that checks that the callback is called with the arguments (20, 50) """ def check_emitted_values(*values): return values == expected_result return check_emitted_values def test_change_floatslider_range(sld: _GenericSlider, qtbot): BOTH = [sld.rangeChanged, sld.valueChanged] for signals, checks, funcname, args in [ (BOTH, [called_with(10, 99), called_with(10)], "setMinimum", (10,)), ([sld.rangeChanged], [called_with(10, 90)], "setMaximum", (90,)), (BOTH, [called_with(20, 40), called_with(20)], "setRange", (20, 40)), ([sld.valueChanged], [called_with(30)], "setValue", (30,)), (BOTH, [called_with(20, 25), called_with(25)], "setMaximum", (25,)), ([sld.valueChanged], [called_with(23)], "setValue", (23,)), ]: with qtbot.waitSignals(signals, check_params_cbs=checks, timeout=500): getattr(sld, funcname)(*args) _assert_value_in_range(sld) def test_float_values(sld: _GenericSlider, qtbot): if type(sld) is QLabeledSlider: pytest.skip() for signals, checks, funcname, args in [ (sld.rangeChanged, called_with(0.1, 0.9), "setRange", (0.1, 0.9)), (sld.valueChanged, called_with(0.4), "setValue", (0.4,)), (sld.valueChanged, called_with(0.1), "setValue", (0,)), (sld.valueChanged, called_with(0.9), "setValue", (1.9,)), ]: with qtbot.waitSignal(signals, check_params_cb=checks, timeout=400): getattr(sld, funcname)(*args) _assert_value_in_range(sld) def test_ticks(sld: _GenericSlider, qtbot): sld.setTickInterval(3) assert sld.tickInterval() == 3 sld.setTickPosition(QSlider.TickPosition.TicksAbove) sld.show() @pytest.mark.skipif(platform.system() != "Darwin", reason="cross-platform is tricky") def test_press_move_release(sld: _GenericSlider, qtbot): if hasattr(sld, "_slider") and sld._slider.orientation() == Qt.Orientation.Vertical: pytest.xfail("test failing for vertical at the moment") # this fail on vertical came with pyside6.2 ... need to debug # still works in practice, but test fails to catch signals if sld.orientation() == Qt.Orientation.Vertical: pytest.xfail() _real_sld = getattr(sld, "_slider", sld) with suppress(AttributeError): # for QSlider assert _real_sld._pressedControl == QStyle.SubControl.SC_None opt = QStyleOptionSlider() _real_sld.initStyleOption(opt) style = _real_sld.style() hrect = style.subControlRect( QStyle.ComplexControl.CC_Slider, opt, QStyle.SubControl.SC_SliderHandle ) handle_pos = _real_sld.mapToGlobal(hrect.center()) with qtbot.waitSignal(_real_sld.sliderPressed, timeout=300): qtbot.mousePress(_real_sld, Qt.MouseButton.LeftButton, pos=handle_pos) with suppress(AttributeError): assert sld._pressedControl == QStyle.SubControl.SC_SliderHandle with qtbot.waitSignals( [_real_sld.sliderMoved, _real_sld.valueChanged], timeout=300 ): shift = ( QPoint(0, -8) if _real_sld.orientation() == Qt.Orientation.Vertical else QPoint(8, 0) ) _real_sld.mouseMoveEvent(_mouse_event(handle_pos + shift)) with qtbot.waitSignal(_real_sld.sliderReleased, timeout=300): qtbot.mouseRelease(_real_sld, Qt.MouseButton.LeftButton, pos=handle_pos) with suppress(AttributeError): assert _real_sld._pressedControl == QStyle.SubControl.SC_None sld.show() with qtbot.waitSignal(_real_sld.sliderPressed, timeout=300): qtbot.mousePress(_real_sld, Qt.MouseButton.LeftButton, pos=handle_pos) @skip_on_linux_qt6 def test_hover(sld: _GenericSlider): _real_sld = getattr(sld, "_slider", sld) opt = QStyleOptionSlider() _real_sld.initStyleOption(opt) hrect = _real_sld.style().subControlRect( QStyle.ComplexControl.CC_Slider, opt, QStyle.SubControl.SC_SliderHandle ) handle_pos = QPointF(sld.mapToGlobal(hrect.center())) with suppress(AttributeError): # for QSlider assert _real_sld._hoverControl == QStyle.SubControl.SC_None _real_sld.event(_hover_event(QEvent.Type.HoverEnter, handle_pos, QPointF(), sld)) with suppress(AttributeError): # for QSlider assert _real_sld._hoverControl == QStyle.SubControl.SC_SliderHandle _real_sld.event( _hover_event(QEvent.Type.HoverLeave, QPointF(-1000, -1000), handle_pos, sld) ) with suppress(AttributeError): # for QSlider assert _real_sld._hoverControl == QStyle.SubControl.SC_None def test_wheel(sld: _GenericSlider, qtbot): if type(sld) is QLabeledSlider and QT_VERSION < (5, 12): pytest.skip() _real_sld = getattr(sld, "_slider", sld) with qtbot.waitSignal(sld.valueChanged, timeout=400): _real_sld.wheelEvent(_wheel_event(120)) _real_sld.wheelEvent(_wheel_event(0)) def test_position(sld: _GenericSlider, qtbot): sld.setSliderPosition(21) assert sld.sliderPosition() == 21 if type(sld) is not QLabeledSlider: sld.setSliderPosition(21.5) assert sld.sliderPosition() == 21.5 def test_steps(sld: _GenericSlider, qtbot): sld.setSingleStep(11) assert sld.singleStep() == 11 sld.setPageStep(16) assert sld.pageStep() == 16 if type(sld) is not QLabeledSlider: sld.setSingleStep(0.1) assert sld.singleStep() == 0.1 sld.setSingleStep(1.5e20) assert sld.singleStep() == 1.5e20 sld.setPageStep(0.2) assert sld.pageStep() == 0.2 sld.setPageStep(1.5e30) assert sld.pageStep() == 1.5e30 @pytest.mark.parametrize("mag", list(range(4, 37, 4)) + list(range(-4, -37, -4))) def test_slider_extremes(sld: _GenericSlider, mag, qtbot): if type(sld) is QLabeledSlider: pytest.skip() _mag = 10**mag with qtbot.waitSignal(sld.rangeChanged, timeout=400): sld.setRange(-_mag, _mag) for i in _linspace(-_mag, _mag, 10): sld.setValue(i) assert math.isclose(sld.value(), i, rel_tol=1e-8) superqt-0.6.8/tests/zz_test_sliders/test_slider.py000066400000000000000000000101711463340017400225160ustar00rootroot00000000000000import platform import pytest from qtpy import API_NAME from qtpy.QtCore import Qt from superqt import QRangeSlider from superqt.sliders._generic_range_slider import SC_BAR, SC_HANDLE, SC_NONE LINUX = platform.system() == "Linux" NOT_PYQT6 = API_NAME != "PyQt6" skipmouse = pytest.mark.skipif(LINUX or NOT_PYQT6, reason="mouse tests finicky") @pytest.mark.parametrize("orientation", ["Horizontal", "Vertical"]) def test_basic(qtbot, orientation): rs = QRangeSlider(getattr(Qt.Orientation, orientation)) qtbot.addWidget(rs) @pytest.mark.parametrize("orientation", ["Horizontal", "Vertical"]) def test_value(qtbot, orientation): rs = QRangeSlider(getattr(Qt.Orientation, orientation)) qtbot.addWidget(rs) rs.setValue([10, 20]) assert rs.value() == (10, 20) @pytest.mark.parametrize("orientation", ["Horizontal", "Vertical"]) def test_range(qtbot, orientation): rs = QRangeSlider(getattr(Qt.Orientation, orientation)) qtbot.addWidget(rs) rs.setValue([10, 20]) assert rs.value() == (10, 20) rs.setRange(15, 20) assert rs.value() == (15, 20) assert rs.minimum() == 15 assert rs.maximum() == 20 @skipmouse def test_drag_handles(qtbot): rs = QRangeSlider(Qt.Orientation.Horizontal) qtbot.addWidget(rs) rs.setRange(0, 99) rs.setValue((20, 80)) rs.setMouseTracking(True) rs.show() # press the left handle pos = rs._handleRect(0).center() with qtbot.waitSignal(rs.sliderPressed): qtbot.mousePress(rs, Qt.MouseButton.LeftButton, pos=pos) assert rs._pressedControl == SC_HANDLE assert rs._pressedIndex == 0 # drag the left handle with qtbot.waitSignals([rs.sliderMoved] * 13): # couple less signals for _ in range(15): pos.setX(pos.x() + 2) qtbot.mouseMove(rs, pos) with qtbot.waitSignal(rs.sliderReleased): qtbot.mouseRelease(rs, Qt.MouseButton.LeftButton) # check the values assert rs.value()[0] > 30 assert rs._pressedControl == SC_NONE # press the right handle pos = rs._handleRect(1).center() with qtbot.waitSignal(rs.sliderPressed): qtbot.mousePress(rs, Qt.MouseButton.LeftButton, pos=pos) assert rs._pressedControl == SC_HANDLE assert rs._pressedIndex == 1 # drag the right handle with qtbot.waitSignals([rs.sliderMoved] * 13): # couple less signals for _ in range(15): pos.setX(pos.x() - 2) qtbot.mouseMove(rs, pos) with qtbot.waitSignal(rs.sliderReleased): qtbot.mouseRelease(rs, Qt.MouseButton.LeftButton) # check the values assert rs.value()[1] < 70 assert rs._pressedControl == SC_NONE @skipmouse def test_drag_handles_beyond_edge(qtbot): rs = QRangeSlider(Qt.Orientation.Horizontal) qtbot.addWidget(rs) rs.setRange(0, 99) rs.setValue((20, 80)) rs.setMouseTracking(True) rs.show() # press the right handle pos = rs._handleRect(1).center() with qtbot.waitSignal(rs.sliderPressed): qtbot.mousePress(rs, Qt.MouseButton.LeftButton, pos=pos) assert rs._pressedControl == SC_HANDLE assert rs._pressedIndex == 1 # drag the handle off the right edge and make sure the value gets to the max for _ in range(7): pos.setX(pos.x() + 10) qtbot.mouseMove(rs, pos) with qtbot.waitSignal(rs.sliderReleased): qtbot.mouseRelease(rs, Qt.MouseButton.LeftButton) assert rs.value()[1] == 99 @skipmouse def test_bar_drag_beyond_edge(qtbot): rs = QRangeSlider(Qt.Orientation.Horizontal) qtbot.addWidget(rs) rs.setRange(0, 99) rs.setValue((20, 80)) rs.setMouseTracking(True) rs.show() # press the right handle pos = rs.rect().center() with qtbot.waitSignal(rs.sliderPressed): qtbot.mousePress(rs, Qt.MouseButton.LeftButton, pos=pos) assert rs._pressedControl == SC_BAR assert rs._pressedIndex == 1 # drag the handle off the right edge and make sure the value gets to the max for _ in range(15): pos.setX(pos.x() + 10) qtbot.mouseMove(rs, pos) with qtbot.waitSignal(rs.sliderReleased): qtbot.mouseRelease(rs, Qt.MouseButton.LeftButton) assert rs.value()[1] == 99