├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature.md ├── dependabot.yml └── workflows │ └── test_and_deploy.yml ├── .github_changelog_generator ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── codecov.yml ├── docs ├── _macros.py ├── faq.md ├── images │ ├── demo_darwin10.png │ ├── demo_darwin11.png │ ├── demo_linux.png │ ├── demo_windows.png │ ├── labeled_qslider.png │ ├── labeled_range.png │ └── slider.png ├── index.md ├── utilities │ ├── cmap.md │ ├── code_syntax_highlight.md │ ├── error_dialog_contexts.md │ ├── fonticon.md │ ├── iconify.md │ ├── index.md │ ├── qmessagehandler.md │ ├── signal_utils.md │ ├── thread_decorators.md │ ├── threading.md │ └── throttling.md └── widgets │ ├── colormap_catalog.md │ ├── index.md │ ├── qcollapsible.md │ ├── qcolorcombobox.md │ ├── qcolormap.md │ ├── qdoublerangeslider.md │ ├── qdoubleslider.md │ ├── qelidinglabel.md │ ├── qenumcombobox.md │ ├── qflowlayout.md │ ├── qlabeleddoublerangeslider.md │ ├── qlabeleddoubleslider.md │ ├── qlabeledrangeslider.md │ ├── qlabeledslider.md │ ├── qlargeintspinbox.md │ ├── qquantity.md │ ├── qrangeslider.md │ ├── qsearchablecombobox.md │ ├── qsearchablelistwidget.md │ ├── qsearchabletreewidget.md │ └── qtoggleswitch.md ├── examples ├── code_highlight.py ├── color_combo_box.py ├── colormap_combo_box.py ├── demo_widget.py ├── double_slider.py ├── eliding_label.py ├── float.py ├── flow_layout.py ├── fonticon1.py ├── fonticon2.py ├── fonticon3.py ├── generic.py ├── icon_explorer.py ├── iconify.py ├── labeled_sliders.py ├── multihandle.py ├── qcollapsible.py ├── quantity.py ├── range_slider.py ├── searchable_combo_box.py ├── searchable_list_widget.py ├── searchable_tree_widget.py ├── throttle_mouse_event.py ├── throttler_demo.py └── toggle_switch.py ├── mkdocs.yml ├── pyproject.toml ├── src └── superqt │ ├── __init__.py │ ├── cmap │ ├── __init__.py │ ├── _catalog_combo.py │ ├── _cmap_combo.py │ ├── _cmap_item_delegate.py │ ├── _cmap_line_edit.py │ └── _cmap_utils.py │ ├── collapsible │ ├── __init__.py │ └── _collapsible.py │ ├── combobox │ ├── __init__.py │ ├── _color_combobox.py │ ├── _enum_combobox.py │ └── _searchable_combo_box.py │ ├── elidable │ ├── __init__.py │ ├── _eliding.py │ ├── _eliding_label.py │ └── _eliding_line_edit.py │ ├── fonticon │ ├── __init__.py │ ├── _animations.py │ ├── _iconfont.py │ ├── _plugins.py │ └── _qfont_icon.py │ ├── iconify │ └── __init__.py │ ├── py.typed │ ├── qtcompat │ └── __init__.py │ ├── selection │ ├── __init__.py │ ├── _searchable_list_widget.py │ └── _searchable_tree_widget.py │ ├── sliders │ ├── __init__.py │ ├── _generic_range_slider.py │ ├── _generic_slider.py │ ├── _labeled.py │ ├── _range_style.py │ └── _sliders.py │ ├── spinbox │ ├── __init__.py │ ├── _intspin.py │ └── _quantity.py │ ├── switch │ ├── __init__.py │ └── _toggle_switch.py │ └── utils │ ├── __init__.py │ ├── _code_syntax_highlight.py │ ├── _ensure_thread.py │ ├── _errormsg_context.py │ ├── _flow_layout.py │ ├── _img_utils.py │ ├── _message_handler.py │ ├── _misc.py │ ├── _qthreading.py │ ├── _throttler.py │ └── _util.py └── tests ├── test_cmap.py ├── test_code_highlight.py ├── test_collapsible.py ├── test_color_combo.py ├── test_eliding_label.py ├── test_eliding_line_edit.py ├── test_ensure_thread.py ├── test_enum_comb_box.py ├── test_flow_layout.py ├── test_fonticon ├── fixtures │ ├── fake_plugin.dist-info │ │ ├── METADATA │ │ ├── entry_points.txt │ │ └── top_level.txt │ └── fake_plugin │ │ ├── __init__.py │ │ └── icontest.ttf ├── test_fonticon.py └── test_plugins.py ├── test_iconify.py ├── test_large_int_spinbox.py ├── test_qmessage_handler.py ├── test_quantity.py ├── test_searchable_combobox.py ├── test_searchable_list.py ├── test_searchable_tree.py ├── test_threadworker.py ├── test_throttler.py ├── test_toggle_switch.py ├── test_utils.py └── zz_test_sliders ├── __init__.py ├── _testutil.py ├── test_float.py ├── test_generic_slider.py ├── test_labeled_slider.py ├── test_range_slider.py ├── test_single_value_sliders.py └── test_slider.py /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: 'bug' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | Screenshots and GIFS are much appreciated when reporting visual bugs. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS with version [e.g macOS 10.15.7] 28 | - Qt Backend [e.g PyQt5, PySide2] 29 | - Python version 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Request a new feature 4 | title: '' 5 | labels: 'enhancement' 6 | assignees: '' 7 | --- 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 2 | 3 | version: 2 4 | updates: 5 | - package-ecosystem: "github-actions" 6 | directory: "/" 7 | schedule: 8 | interval: "weekly" 9 | commit-message: 10 | prefix: "ci(dependabot):" 11 | -------------------------------------------------------------------------------- /.github/workflows/test_and_deploy.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | concurrency: 4 | group: ${{ github.workflow }}-${{ github.ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | push: 9 | branches: [main] 10 | tags: [v*] 11 | pull_request: 12 | workflow_dispatch: 13 | schedule: 14 | - cron: "0 0 * * 0" # run weekly 15 | 16 | jobs: 17 | test: 18 | name: Test 19 | uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@v2 20 | with: 21 | os: ${{ matrix.platform }} 22 | python-version: ${{ matrix.python-version }} 23 | qt: ${{ matrix.backend }} 24 | pip-install-pre-release: ${{ github.event_name == 'schedule' }} 25 | coverage-upload: artifact 26 | strategy: 27 | fail-fast: false 28 | matrix: 29 | platform: [ubuntu-latest, windows-latest, macos-13] 30 | python-version: ["3.9", "3.10", "3.11", "3.12"] 31 | backend: [pyqt5, pyside2, pyqt6] 32 | exclude: 33 | # Abort (core dumped) on linux pyqt6, unknown reason 34 | - platform: ubuntu-latest 35 | backend: pyqt6 36 | # lack of wheels for pyside2/py3.11 37 | - python-version: "3.11" 38 | backend: pyside2 39 | - python-version: "3.12" 40 | backend: pyside2 41 | - python-version: "3.12" 42 | backend: pyqt5 43 | include: 44 | - python-version: "3.13" 45 | platform: windows-latest 46 | backend: "pyqt6" 47 | - python-version: "3.13" 48 | platform: ubuntu-latest 49 | backend: "pyqt6" 50 | 51 | - python-version: "3.10" 52 | platform: macos-latest 53 | backend: "'pyside6<6.8'" 54 | - python-version: "3.11" 55 | platform: macos-latest 56 | backend: "'pyside6<6.8'" 57 | - python-version: "3.10" 58 | platform: windows-latest 59 | backend: "'pyside6<6.8'" 60 | - python-version: "3.12" 61 | platform: windows-latest 62 | backend: "'pyside6<6.8'" 63 | 64 | # legacy Qt 65 | - python-version: 3.9 66 | platform: ubuntu-latest 67 | backend: "pyqt5==5.12.*" 68 | - python-version: 3.9 69 | platform: ubuntu-latest 70 | backend: "pyqt5==5.13.*" 71 | - python-version: 3.9 72 | platform: ubuntu-latest 73 | backend: "pyqt5==5.14.*" 74 | 75 | test-qt-minreqs: 76 | uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@v2 77 | with: 78 | python-version: "3.9" 79 | qt: pyqt5 80 | pip-post-installs: "qtpy==1.1.0 typing-extensions==4.5.0" # 4.5.0 is just for pint 81 | pip-install-flags: -e 82 | coverage-upload: artifact 83 | 84 | upload_coverage: 85 | if: always() 86 | needs: [test, test-qt-minreqs] 87 | uses: pyapp-kit/workflows/.github/workflows/upload-coverage.yml@v2 88 | secrets: inherit 89 | 90 | test_napari_old: 91 | uses: pyapp-kit/workflows/.github/workflows/test-dependents.yml@v2 92 | with: 93 | dependency-repo: napari/napari 94 | dependency-ref: ${{ matrix.napari-version }} 95 | dependency-extras: "testing" 96 | qt: ${{ matrix.qt }} 97 | pytest-args: 'napari/_qt -k "not async and not qt_dims_2 and not qt_viewer_console_focus and not keybinding_editor and not preferences_dialog_not_dismissed"' 98 | python-version: "3.10" 99 | post-install-cmd: "pip install lxml_html_clean" 100 | strategy: 101 | fail-fast: false 102 | matrix: 103 | napari-version: ["v0.4.19.post1"] 104 | qt: ["pyqt5", "pyside2"] 105 | 106 | test_napari: 107 | uses: pyapp-kit/workflows/.github/workflows/test-dependents.yml@v2 108 | with: 109 | dependency-repo: napari/napari 110 | dependency-ref: ${{ matrix.napari-version }} 111 | dependency-extras: "testing" 112 | qt: ${{ matrix.qt }} 113 | pytest-args: 'src/napari/_qt --import-mode=importlib -k "not async and not qt_dims_2 and not qt_viewer_console_focus and not keybinding_editor and not preferences_dialog_not_dismissed"' 114 | python-version: "3.10" 115 | post-install-cmd: "pip install lxml_html_clean" 116 | strategy: 117 | fail-fast: false 118 | matrix: 119 | napari-version: [ "" ] 120 | qt: [ "pyqt5", "pyside2" ] 121 | 122 | check-manifest: 123 | name: Check Manifest 124 | runs-on: ubuntu-latest 125 | steps: 126 | - uses: actions/checkout@v4 127 | - run: pipx run check-manifest 128 | 129 | deploy: 130 | # this will run when you have tagged a commit, starting with "v*" 131 | # and requires that you have put your twine API key in your 132 | # github secrets (see readme for details) 133 | needs: [test, check-manifest] 134 | if: ${{ github.repository == 'pyapp-kit/superqt' && contains(github.ref, 'tags') }} 135 | runs-on: ubuntu-latest 136 | steps: 137 | - uses: actions/checkout@v4 138 | with: 139 | fetch-depth: 0 140 | - name: Set up Python 141 | uses: actions/setup-python@v5 142 | with: 143 | python-version: "3.x" 144 | - name: Install dependencies 145 | run: | 146 | python -m pip install --upgrade pip 147 | pip install build twine 148 | - name: Build and publish 149 | env: 150 | TWINE_USERNAME: __token__ 151 | TWINE_PASSWORD: ${{ secrets.TWINE_API_KEY }} 152 | run: | 153 | git tag 154 | python -m build 155 | twine check dist/* 156 | twine upload dist/* 157 | 158 | - uses: softprops/action-gh-release@v2 159 | with: 160 | generate_release_notes: true 161 | -------------------------------------------------------------------------------- /.github_changelog_generator: -------------------------------------------------------------------------------- 1 | # run this with: 2 | # export CHANGELOG_GITHUB_TOKEN=...... 3 | # github_changelog_generator --future-release vX.Y.Z 4 | user=pyapp-kit 5 | project=superqt 6 | issues=false 7 | since-tag=v0.2.0 8 | exclude-labels=duplicate,question,invalid,wontfix,hide 9 | add-sections={"documentation":{"prefix":"**Documentation updates:**","labels":["documentation"]},"tests":{"prefix":"**Tests & CI:**","labels":["tests"]},"refactor":{"prefix":"**Refactors:**","labels":["refactor"]}} 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | .venv/ 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask instance folder 58 | instance/ 59 | 60 | # Sphinx documentation 61 | docs/_build/ 62 | 63 | # MkDocs documentation 64 | /site/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # OS 76 | .DS_Store 77 | 78 | # written by setuptools_scm 79 | src/superqt/_version.py 80 | .vscode/settings.json 81 | screenshots 82 | 83 | .mypy_cache 84 | docs/_auto_images/ 85 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autoupdate_schedule: monthly 3 | autofix_commit_msg: "style: [pre-commit.ci] auto fixes [...]" 4 | autoupdate_commit_msg: "ci: [pre-commit.ci] autoupdate" 5 | 6 | repos: 7 | - repo: https://github.com/astral-sh/ruff-pre-commit 8 | rev: v0.11.12 9 | hooks: 10 | - id: ruff 11 | args: [--fix, --unsafe-fixes] 12 | - id: ruff-format 13 | 14 | - repo: https://github.com/abravalheri/validate-pyproject 15 | rev: v0.24.1 16 | hooks: 17 | - id: validate-pyproject 18 | 19 | - repo: https://github.com/pre-commit/mirrors-mypy 20 | rev: v1.16.0 21 | hooks: 22 | - id: mypy 23 | exclude: tests|examples 24 | additional_dependencies: 25 | - types-Pygments 26 | stages: 27 | - manual 28 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to this repository 2 | 3 | This repository seeks to accumulate Qt-based widgets for python (PyQt & PySide) 4 | that are not provided in the native QtWidgets module. 5 | 6 | ## Clone 7 | 8 | To get started fork this repository, and clone your fork: 9 | 10 | ```bash 11 | # clone your fork 12 | git clone https://github.com//superqt 13 | cd superqt 14 | 15 | # install in editable mode (this will install PyQt6 as the Qt backend) 16 | pip install -e .[dev] 17 | 18 | # install pre-commit hooks 19 | pre-commit install 20 | 21 | # run tests & make sure everything is working! 22 | pytest 23 | ``` 24 | 25 | ## Targeted platforms 26 | 27 | All widgets must be well-tested, and should work on: 28 | 29 | - Python 3.9 and above 30 | - PyQt5 (5.11 and above) & PyQt6 31 | - PySide2 (5.11 and above) & PySide6 32 | - macOS, Windows, & Linux 33 | 34 | 35 | ## Style Guide 36 | 37 | All widgets should try to match the native Qt API as much as possible: 38 | 39 | - Methods should use `camelCase` naming. 40 | - Getters/setters use the `attribute()/setAttribute()` pattern. 41 | - Private methods should use `_camelCaseNaming`. 42 | - `__init__` methods should be like Qt constructors, meaning they often don't 43 | include parameters for most of the widgets properties. 44 | - When possible, widgets should inherit from the most similar native widget 45 | available. It should strictly match the Qt API where it exists, and attempt to 46 | cover as much of the native API as possible; this includes properties, public 47 | functions, signals, and public slots. 48 | 49 | ## Testing 50 | 51 | Tests can be run in the current environment with `pytest`. 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Copyright (c) 2021, Talley Lambert 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | * Neither the name of superqt nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![tiny](https://user-images.githubusercontent.com/1609449/120636353-8c3f3800-c43b-11eb-8732-a14dec578897.png) superqt! 2 | 3 | [![License](https://img.shields.io/pypi/l/superqt.svg?color=green)](https://github.com/pyapp-kit/superqt/raw/master/LICENSE) 4 | [![PyPI](https://img.shields.io/pypi/v/superqt.svg?color=green)](https://pypi.org/project/superqt) 5 | [![Python 6 | Version](https://img.shields.io/pypi/pyversions/superqt.svg?color=green)](https://python.org) 7 | [![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) 8 | [![codecov](https://codecov.io/gh/pyapp-kit/superqt/branch/main/graph/badge.svg?token=dcsjgl1sOi)](https://codecov.io/gh/pyapp-kit/superqt) 9 | 10 | ### "missing" widgets and components for PyQt/PySide 11 | 12 | This repository aims to provide high-quality community-contributed Qt widgets and components for PyQt & PySide 13 | that are not provided in the native QtWidgets module. 14 | 15 | Components are tested on: 16 | 17 | - macOS, Windows, & Linux 18 | - Python 3.9 and above 19 | - PyQt5 (5.11 and above) & PyQt6 20 | - PySide2 (5.11 and above) & PySide6 21 | 22 | ## Documentation 23 | 24 | Documentation is available at https://pyapp-kit.github.io/superqt/ 25 | 26 | ## Widgets 27 | 28 | superqt provides a variety of widgets that are not included in the native QtWidgets module, including multihandle (range) sliders, comboboxes, and more. 29 | 30 | See the [widgets documentation](https://pyapp-kit.github.io/superqt/widgets) for a full list of widgets. 31 | 32 | - [Range Slider](https://pyapp-kit.github.io/superqt/widgets/qrangeslider/) (multi-handle slider) 33 | 34 | range sliders 35 | 36 | range sliders 37 | 38 | range sliders 39 | 40 | ## Utilities 41 | 42 | superqt includes a number of utilities for working with Qt, including: 43 | 44 | - tools and decorators for working with threads in qt. 45 | - `superqt.fonticon` for generating icons from font files (such as [Material Design Icons](https://materialdesignicons.com/) and [Font Awesome](https://fontawesome.com/)) 46 | 47 | See the [utilities documentation](https://pyapp-kit.github.io/superqt/utilities/) for a full list of utilities. 48 | 49 | ## Contributing 50 | 51 | We welcome contributions! 52 | 53 | Please see the [Contributing Guide](CONTRIBUTING.md) 54 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - superqt/_version.py 3 | - '*_tests*' 4 | coverage: 5 | status: 6 | project: 7 | default: 8 | target: auto 9 | threshold: 1% # PR will fail if it drops coverage on the project by >1% 10 | patch: 11 | default: 12 | target: auto 13 | threshold: 40% # A given PR will fail if >40% is untested 14 | comment: 15 | require_changes: true # if true: only post the PR comment if coverage changes 16 | -------------------------------------------------------------------------------- /docs/_macros.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from enum import EnumMeta 3 | from importlib import import_module 4 | from pathlib import Path 5 | from textwrap import dedent 6 | from typing import TYPE_CHECKING 7 | 8 | from jinja2 import pass_context 9 | from qtpy.QtCore import QObject, Signal 10 | 11 | if TYPE_CHECKING: 12 | from mkdocs_macros.plugin import MacrosPlugin 13 | 14 | EXAMPLES = Path(__file__).parent.parent / "examples" 15 | IMAGES = Path(__file__).parent / "_auto_images" 16 | IMAGES.mkdir(exist_ok=True, parents=True) 17 | 18 | 19 | def define_env(env: "MacrosPlugin"): 20 | @env.macro 21 | @pass_context 22 | def show_widget(context, width: int = 500) -> list[Path]: 23 | # extract all fenced code blocks starting with "python" 24 | page = context["page"] 25 | dest = IMAGES / f"{page.title}.png" 26 | if "build" in sys.argv: 27 | dest.unlink(missing_ok=True) 28 | 29 | codeblocks = [ 30 | b[6:].strip() 31 | for b in page.markdown.split("```") 32 | if b.startswith("python") 33 | ] 34 | src = codeblocks[0].strip() 35 | src = src.replace( 36 | "QApplication([])", "QApplication.instance() or QApplication([])" 37 | ) 38 | src = src.replace("app.exec_()", "app.processEvents()") 39 | 40 | exec(src) 41 | _grab(dest, width) 42 | return ( 43 | f"![{page.title}](../{dest.parent.name}/{dest.name})" 44 | f"{{ loading=lazy; width={width} }}\n\n" 45 | ) 46 | 47 | @env.macro 48 | def show_members(cls: str): 49 | # import class 50 | module, name = cls.rsplit(".", 1) 51 | _cls = getattr(import_module(module), name) 52 | 53 | first_q = next( 54 | ( 55 | b.__name__ 56 | for b in _cls.__mro__ 57 | if issubclass(b, QObject) and ".Qt" in b.__module__ 58 | ), 59 | None, 60 | ) 61 | 62 | inherited_members = set() 63 | for base in _cls.__mro__: 64 | if issubclass(base, QObject) and ".Qt" in base.__module__: 65 | inherited_members.update( 66 | {k for k in dir(base) if not k.startswith("_")} 67 | ) 68 | 69 | new_signals = { 70 | k 71 | for k, v in vars(_cls).items() 72 | if not k.startswith("_") and isinstance(v, Signal) 73 | } 74 | 75 | self_members = { 76 | k 77 | for k in dir(_cls) 78 | if not k.startswith("_") and k not in inherited_members | new_signals 79 | } 80 | 81 | enums = [] 82 | for m in list(self_members): 83 | if isinstance(getattr(_cls, m), EnumMeta): 84 | self_members.remove(m) 85 | enums.append(m) 86 | 87 | out = "" 88 | if first_q: 89 | url = f"https://doc.qt.io/qt-6/{first_q.lower()}.html" 90 | out += f"## Qt Class\n\n`{first_q}`\n\n" 91 | 92 | out += "" 93 | 94 | if new_signals: 95 | out += "## Signals\n\n" 96 | for sig in new_signals: 97 | out += f"### `{sig}`\n\n" 98 | 99 | if enums: 100 | out += "## Enums\n\n" 101 | for e in enums: 102 | out += f"### `{_cls.__name__}.{e}`\n\n" 103 | for m in getattr(_cls, e): 104 | out += f"- `{m.name}`\n\n" 105 | 106 | if self_members: 107 | out += dedent( 108 | f""" 109 | ## Methods 110 | 111 | ::: {cls} 112 | options: 113 | heading_level: 3 114 | show_source: False 115 | show_inherited_members: false 116 | show_signature_annotations: True 117 | members: {sorted(self_members)} 118 | docstring_style: numpy 119 | show_bases: False 120 | show_root_toc_entry: False 121 | show_root_heading: False 122 | """ 123 | ) 124 | 125 | return out 126 | 127 | 128 | def _grab(dest: str | Path, width) -> list[Path]: 129 | """Grab the top widgets of the application.""" 130 | from qtpy.QtWidgets import QApplication 131 | 132 | w = QApplication.topLevelWidgets()[-1] 133 | w.setFixedWidth(width) 134 | w.activateWindow() 135 | w.setMinimumHeight(40) 136 | w.grab().save(str(dest)) 137 | -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | ## Sliders not dragging properly on MacOS 12+ 4 | 5 | ??? details 6 | On MacOS Monterey, with Qt5, there is a bug that causes all sliders 7 | (including native Qt sliders) to not respond properly to drag events. See: 8 | 9 | - [https://bugreports.qt.io/browse/QTBUG-98093](https://bugreports.qt.io/browse/QTBUG-98093) 10 | - [https://github.com/pyapp-kit/superqt/issues/74](https://github.com/pyapp-kit/superqt/issues/74) 11 | 12 | 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 13 | may not see this issue if you're already using custom stylesheets. 14 | 15 | To opt in to the workaround, do any of the following: 16 | 17 | - set the environment variable `USE_MAC_SLIDER_PATCH=1` before importing superqt 18 | (note: this is safe to use even if you're targeting more than just MacOS 12, it will only be applied when needed) 19 | - call the `applyMacStylePatch()` method on any of the superqt slider subclasses (note, this will override your slider styles) 20 | - apply the stylesheet manually: 21 | 22 | ```python 23 | from superqt.sliders import MONTEREY_SLIDER_STYLES_FIX 24 | 25 | slider.setStyleSheet(MONTEREY_SLIDER_STYLES_FIX) 26 | ``` 27 | -------------------------------------------------------------------------------- /docs/images/demo_darwin10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyapp-kit/superqt/a9fa7205770a8721887e1810461c5a918fd8190d/docs/images/demo_darwin10.png -------------------------------------------------------------------------------- /docs/images/demo_darwin11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyapp-kit/superqt/a9fa7205770a8721887e1810461c5a918fd8190d/docs/images/demo_darwin11.png -------------------------------------------------------------------------------- /docs/images/demo_linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyapp-kit/superqt/a9fa7205770a8721887e1810461c5a918fd8190d/docs/images/demo_linux.png -------------------------------------------------------------------------------- /docs/images/demo_windows.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyapp-kit/superqt/a9fa7205770a8721887e1810461c5a918fd8190d/docs/images/demo_windows.png -------------------------------------------------------------------------------- /docs/images/labeled_qslider.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyapp-kit/superqt/a9fa7205770a8721887e1810461c5a918fd8190d/docs/images/labeled_qslider.png -------------------------------------------------------------------------------- /docs/images/labeled_range.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyapp-kit/superqt/a9fa7205770a8721887e1810461c5a918fd8190d/docs/images/labeled_range.png -------------------------------------------------------------------------------- /docs/images/slider.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyapp-kit/superqt/a9fa7205770a8721887e1810461c5a918fd8190d/docs/images/slider.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # superqt 2 | 3 | ## ![tiny](https://user-images.githubusercontent.com/1609449/120636353-8c3f3800-c43b-11eb-8732-a14dec578897.png) "missing" widgets and components for PyQt/PySide 4 | 5 | This repository aims to provide high-quality community-contributed Qt widgets 6 | and components for [PyQt](https://riverbankcomputing.com/software/pyqt/) & 7 | [PySide](https://www.qt.io/qt-for-python) that are not provided in the native 8 | QtWidgets module. 9 | 10 | Components are tested on: 11 | 12 | - macOS, Windows, & Linux 13 | - Python 3.9 and above 14 | - PyQt5 (5.11 and above) & PyQt6 15 | - PySide2 (5.11 and above) & PySide6 16 | 17 | ## Installation 18 | 19 | ```bash 20 | pip install superqt 21 | ``` 22 | 23 | ```bash 24 | conda install -c conda-forge superqt 25 | ``` 26 | 27 | ## Usage 28 | 29 | See the [Widgets](./widgets/index.md) and [Utilities](./utilities/index.md) pages for features offered by superqt. 30 | -------------------------------------------------------------------------------- /docs/utilities/cmap.md: -------------------------------------------------------------------------------- 1 | # Colormap utilities 2 | 3 | See also: 4 | 5 | - [`superqt.QColormapComboBox`](../widgets/qcolormap.md) 6 | - [`superqt.cmap.CmapCatalogComboBox`](../widgets/colormap_catalog.md) 7 | 8 | ::: superqt.cmap.draw_colormap 9 | 10 | ::: superqt.cmap.QColormapLineEdit 11 | 12 | ::: superqt.cmap.QColormapItemDelegate 13 | -------------------------------------------------------------------------------- /docs/utilities/code_syntax_highlight.md: -------------------------------------------------------------------------------- 1 | # CodeSyntaxHighlight 2 | 3 | A code highlighter subclass of `QSyntaxHighlighter` 4 | that can be used to highlight code in a QTextEdit. 5 | 6 | Code lexer and available styles are from [`pygments`](https://pygments.org/) python library 7 | 8 | List of available languages are available [here](https://pygments.org/languages/). 9 | 10 | List of available styles are available [here](https://pygments.org/styles/). 11 | 12 | ## Example 13 | 14 | ```python 15 | from qtpy.QtGui import QColor, QPalette 16 | from qtpy.QtWidgets import QApplication, QTextEdit 17 | 18 | from superqt.utils import CodeSyntaxHighlight 19 | 20 | app = QApplication([]) 21 | 22 | text_area = QTextEdit() 23 | 24 | highlight = CodeSyntaxHighlight(text_area.document(), "python", "monokai") 25 | 26 | palette = text_area.palette() 27 | palette.setColor(QPalette.Base, QColor(highlight.background_color)) 28 | text_area.setPalette(palette) 29 | text_area.setText( 30 | """from argparse import ArgumentParser 31 | 32 | def main(): 33 | parser = ArgumentParser() 34 | parser.add_argument("name", help="Your name") 35 | args = parser.parse_args() 36 | print(f"Hello {args.name}") 37 | 38 | 39 | if __name__ == "__main__": 40 | main() 41 | """ 42 | ) 43 | 44 | text_area.show() 45 | text_area.resize(400, 200) 46 | 47 | app.exec_() 48 | ``` 49 | 50 | {{ show_widget() }} 51 | 52 | {{ show_members('superqt.utils.CodeSyntaxHighlight') }} 53 | -------------------------------------------------------------------------------- /docs/utilities/error_dialog_contexts.md: -------------------------------------------------------------------------------- 1 | # Error message context manager 2 | 3 | ::: superqt.utils.exceptions_as_dialog 4 | -------------------------------------------------------------------------------- /docs/utilities/fonticon.md: -------------------------------------------------------------------------------- 1 | # Font icons 2 | 3 | The `superqt.fonticon` module provides a set of utilities for working with font 4 | icons such as [Font Awesome](https://fontawesome.com/) or [Material Design 5 | Icons](https://materialdesignicons.com/). 6 | 7 | ## Basic Example 8 | 9 | ```python 10 | from fonticon_fa5 import FA5S 11 | 12 | from qtpy.QtCore import QSize 13 | from qtpy.QtWidgets import QApplication, QPushButton 14 | 15 | from superqt.fonticon import icon, pulse 16 | 17 | app = QApplication([]) 18 | 19 | btn2 = QPushButton() 20 | btn2.setIcon(icon(FA5S.smile, color="blue")) 21 | btn2.setIconSize(QSize(225, 225)) 22 | btn2.show() 23 | 24 | app.exec() 25 | ``` 26 | 27 | {{ show_widget(225) }} 28 | 29 | ## Font Icon plugins 30 | 31 | Ready-made fonticon packs are available as plugins. 32 | 33 | A great way to search across most available icons libraries from a single 34 | search interface is to use glyphsearch: 35 | 36 | If a font library you'd like to use is unavailable as a superqt plugin, 37 | please [open a feature request](https://github.com/pyapp-kit/superqt/issues/new/choose) 38 | 39 | 40 | ### Font Awesome 6 41 | 42 | Browse available icons at 43 | 44 | ```bash 45 | pip install fonticon-fontawesome6 46 | ``` 47 | 48 | ### Font Awesome 5 49 | 50 | Browse available icons at 51 | 52 | ```bash 53 | pip install fonticon-fontawesome5 54 | ``` 55 | 56 | ### Material Design Icons 7 57 | 58 | Browse available icons at 59 | 60 | ```bash 61 | pip install fonticon-materialdesignicons7 62 | ``` 63 | 64 | ### Material Design Icons 6 65 | 66 | Browse available icons at 67 | (note that the search defaults to v7, see changes from v6 in [the 68 | changelog](https://pictogrammers.com/docs/library/mdi/releases/changelog/)) 69 | 70 | ```bash 71 | pip install fonticon-materialdesignicons6 72 | ``` 73 | 74 | ### See also 75 | 76 | - 77 | - 78 | - 79 | 80 | `superqt.fonticon` is a pluggable system, and font icon packs may use the `"superqt.fonticon"` 81 | 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: 82 | 83 | - 84 | - 85 | - 86 | 87 | ## API 88 | 89 | ::: superqt.fonticon.icon 90 | options: 91 | heading_level: 3 92 | 93 | ::: superqt.fonticon.setTextIcon 94 | options: 95 | heading_level: 3 96 | 97 | ::: superqt.fonticon.font 98 | options: 99 | heading_level: 3 100 | 101 | ::: superqt.fonticon.IconOpts 102 | options: 103 | heading_level: 3 104 | 105 | ::: superqt.fonticon.addFont 106 | options: 107 | heading_level: 3 108 | 109 | ## Animations 110 | 111 | the `animation` parameter to `icon()` accepts a subclass of 112 | `Animation` that will be 113 | 114 | ::: superqt.fonticon.Animation 115 | options: 116 | heading_level: 3 117 | 118 | ::: superqt.fonticon.pulse 119 | options: 120 | heading_level: 3 121 | 122 | ::: superqt.fonticon.spin 123 | options: 124 | heading_level: 3 125 | -------------------------------------------------------------------------------- /docs/utilities/iconify.md: -------------------------------------------------------------------------------- 1 | # QIconifyIcon 2 | 3 | [Iconify](https://iconify.design/) is an icon library that includes 150,000+ 4 | icons from most major icon sets including Bootstrap, FontAwesome, Material 5 | Design, and many more; each available as individual SVGs. Unlike the 6 | [`superqt.fonticon` module](./fonticon.md), `superqt.QIconifyIcon` does not require any additional 7 | dependencies or font files to be installed. Icons are downloaded (and cached) 8 | on-demand from the Iconify API, using [pyconify](https://github.com/pyapp-kit/pyconify) 9 | 10 | Search availble icons at 11 | Once you find one you like, use the key in the format `"prefix:name"` to create an 12 | icon: `QIconifyIcon("bi:bell")`. 13 | 14 | ## Basic Example 15 | 16 | ```python 17 | from qtpy.QtCore import QSize 18 | from qtpy.QtWidgets import QApplication, QPushButton 19 | 20 | from superqt import QIconifyIcon 21 | 22 | app = QApplication([]) 23 | 24 | btn = QPushButton() 25 | btn.setIcon(QIconifyIcon("fluent-emoji-flat:alarm-clock")) 26 | btn.setIconSize(QSize(60, 60)) 27 | btn.show() 28 | 29 | app.exec() 30 | ``` 31 | 32 | {{ show_widget(225) }} 33 | 34 | ::: superqt.QIconifyIcon 35 | options: 36 | heading_level: 3 37 | -------------------------------------------------------------------------------- /docs/utilities/index.md: -------------------------------------------------------------------------------- 1 | # Utilities 2 | 3 | ## Font Icons 4 | 5 | | Object | Description | 6 | | ----------- | --------------------- | 7 | | [`addFont`](./fonticon.md#superqt.fonticon.addFont) | Add an `OTF/TTF` file at to the font registry. | 8 | | [`font`](./fonticon.md#superqt.fonticon.font) | Create `QFont` for a given font-icon font family key | 9 | | [`icon`](./fonticon.md#superqt.fonticon.icon) | Create a `QIcon` for font-con glyph key | 10 | | [`setTextIcon`](./fonticon.md#superqt.fonticon.setTextIcon) | Set text on a `QWidget` to a specific font & glyph. | 11 | | [`IconFont`](./fonticon.md#superqt.fonticon.IconFont) | Helper class that provides a standard way to create an `IconFont`. | 12 | | [`IconOpts`](./fonticon.md#superqt.fonticon.IconOpts) | Options for rendering an icon | 13 | | [`Animation`](./fonticon.md#superqt.fonticon.Animation) | Base class for adding animations to a font-icon. | 14 | 15 | ## SVG Icons 16 | 17 | | Object | Description | 18 | | ----------- | --------------------- | 19 | | [`QIconifyIcon`](./iconify.md) | QIcons backed by the [Iconify](https://iconify.design/) icon library. | 20 | 21 | ## Threading tools 22 | 23 | | Object | Description | 24 | | ----------- | --------------------- | 25 | | [`ensure_main_thread`](./thread_decorators.md#ensure_main_thread) | Decorator that ensures a function is called in the main `QApplication` thread. | 26 | | [`ensure_object_thread`](./thread_decorators.md#ensure_object_thread) | Decorator that ensures a `QObject` method is called in the object's thread. | 27 | | [`FunctionWorker`](./threading.md#superqt.utils.FunctionWorker) | `QRunnable` with signals that wraps a simple long-running function. | 28 | | [`GeneratorWorker`](./threading.md#superqt.utils.GeneratorWorker) | `QRunnable` with signals that wraps a long-running generator. | 29 | | [`create_worker`](./threading.md#superqt.utils.create_worker) | Create a worker to run a target function in another thread. | 30 | | [`thread_worker`](./threading.md#superqt.utils.thread_worker) | Decorator for `create_worker`, turn a function into a worker. | 31 | 32 | ## Miscellaneous 33 | 34 | | Object | Description | 35 | | ----------- | --------------------- | 36 | | [`QMessageHandler`](./qmessagehandler.md) | A context manager to intercept messages from Qt. | 37 | | [`CodeSyntaxHighlight`](./code_syntax_highlight.md) | A `QSyntaxHighlighter` for code syntax highlighting. | 38 | | [`draw_colormap`](./cmap.md) | Function that draws a colormap into any QPaintDevice. | 39 | -------------------------------------------------------------------------------- /docs/utilities/qmessagehandler.md: -------------------------------------------------------------------------------- 1 | # QMessageHandler 2 | 3 | ::: superqt.utils.QMessageHandler 4 | options: 5 | heading_level: 3 6 | show_signature_annotations: True 7 | docstring_style: numpy 8 | show_bases: False 9 | -------------------------------------------------------------------------------- /docs/utilities/signal_utils.md: -------------------------------------------------------------------------------- 1 | # Signal Utilities 2 | 3 | ::: superqt.utils.signals_blocked 4 | -------------------------------------------------------------------------------- /docs/utilities/thread_decorators.md: -------------------------------------------------------------------------------- 1 | # Threading decorators 2 | 3 | `superqt` provides two decorators that help to ensure that given function is 4 | running in the desired thread: 5 | 6 | ## `ensure_main_thread` 7 | 8 | `ensure_main_thread` ensures that the decorated function/method runs in the main thread 9 | 10 | ## `ensure_object_thread` 11 | 12 | `ensure_object_thread` ensures that a decorated bound method of a `QObject` runs 13 | in the thread in which the instance lives ([see qt documentation for 14 | details](https://doc.qt.io/qt-6/threads-qobject.html#accessing-qobject-subclasses-from-other-threads)). 15 | 16 | ## Usage 17 | 18 | By default, functions are executed asynchronously (they return immediately with 19 | an instance of 20 | [`concurrent.futures.Future`](https://docs.python.org/3/library/concurrent.futures.html#concurrent.futures.Future)). 21 | 22 | To block and wait for the result, see [Synchronous mode](#synchronous-mode) 23 | 24 | ```python 25 | from qtpy.QtCore import QObject 26 | from superqt import ensure_main_thread, ensure_object_thread 27 | 28 | @ensure_main_thread 29 | def sample_function(): 30 | print("This function will run in main thread") 31 | 32 | 33 | class SampleObject(QObject): 34 | def __init__(self): 35 | super().__init__() 36 | self._value = 1 37 | 38 | @ensure_main_thread 39 | def sample_method1(self): 40 | print("This method will run in main thread") 41 | 42 | @ensure_object_thread 43 | def sample_method3(self): 44 | import time 45 | print("sleeping") 46 | time.sleep(1) 47 | print("This method will run in object thread") 48 | 49 | @property 50 | def value(self): 51 | print("return value") 52 | return self._value 53 | 54 | @value.setter 55 | @ensure_object_thread 56 | def value(self, value): 57 | print("this setter will run in object thread") 58 | self._value = value 59 | ``` 60 | 61 | As can be seen in this example these decorators can also be used for setters. 62 | 63 | These decorators should not be used as replacement of Qt Signals but rather to 64 | interact with Qt objects from non Qt code. 65 | 66 | ## Synchronous mode 67 | 68 | If you'd like for the program to block and wait for the result of your function 69 | call, use the `await_return=True` parameter, and optionally specify a timeout. 70 | 71 | !!! important 72 | 73 | Using synchronous mode may significantly impact performance. 74 | 75 | ```python 76 | from superqt import ensure_main_thread 77 | 78 | @ensure_main_thread 79 | def sample_function1(): 80 | return 1 81 | 82 | @ensure_main_thread(await_return=True) 83 | def sample_function2(): 84 | return 2 85 | 86 | assert sample_function1() is None 87 | assert sample_function2() == 2 88 | 89 | # optionally, specify a timeout 90 | @ensure_main_thread(await_return=True, timeout=10000) 91 | def sample_function(): 92 | return 1 93 | 94 | ``` 95 | -------------------------------------------------------------------------------- /docs/utilities/threading.md: -------------------------------------------------------------------------------- 1 | # Thread workers 2 | 3 | The objects in this module provide utilities for running tasks in a separate 4 | thread. In general (with the exception of `new_worker_qthread`), everything 5 | here wraps Qt's [QRunnable API](https://doc.qt.io/qt-6/qrunnable.html). 6 | 7 | The highest level object is the 8 | [`@thread_worker`][superqt.utils.thread_worker] decorator. It was originally 9 | written for `napari`, and was later extracted into `superqt`. You may also be 10 | interested in reading the [napari 11 | documentation](https://napari.org/stable/guides/threading.html#threading-in-napari-with-thread-worker) on this feature, 12 | which provides a more in-depth/introductory usage guide. 13 | 14 | For additional control, you can create your own 15 | [`FunctionWorker`][superqt.utils.FunctionWorker] or 16 | [`GeneratorWorker`][superqt.utils.GeneratorWorker] objects. 17 | 18 | ::: superqt.utils.WorkerBase 19 | 20 | ::: superqt.utils.FunctionWorker 21 | 22 | ::: superqt.utils.GeneratorWorker 23 | 24 | ## Convenience functions 25 | 26 | ::: superqt.utils.thread_worker 27 | options: 28 | heading_level: 3 29 | 30 | ::: superqt.utils.create_worker 31 | options: 32 | heading_level: 3 33 | 34 | ::: superqt.utils.new_worker_qthread 35 | options: 36 | heading_level: 3 37 | -------------------------------------------------------------------------------- /docs/utilities/throttling.md: -------------------------------------------------------------------------------- 1 | # Throttling & Debouncing 2 | 3 | These utilities allow you to throttle or debounce a function. This is useful 4 | when you have a function that is called multiple times in a short period of 5 | time, and you want to make sure it is only "actually" called once (or at least 6 | no more than a certain frequency). 7 | 8 | For background on throttling and debouncing, see: 9 | 10 | - 11 | - 12 | 13 | ::: superqt.utils.qdebounced 14 | options: 15 | show_source: false 16 | docstring_style: numpy 17 | show_root_toc_entry: True 18 | show_root_heading: True 19 | 20 | ::: superqt.utils.qthrottled 21 | options: 22 | show_source: false 23 | docstring_style: numpy 24 | show_root_toc_entry: True 25 | show_root_heading: True 26 | 27 | ::: superqt.utils.QSignalDebouncer 28 | options: 29 | show_source: false 30 | docstring_style: numpy 31 | show_root_toc_entry: True 32 | show_root_heading: True 33 | 34 | ::: superqt.utils.QSignalThrottler 35 | options: 36 | show_source: false 37 | docstring_style: numpy 38 | show_root_toc_entry: True 39 | show_root_heading: True 40 | 41 | ::: superqt.utils._throttler.GenericSignalThrottler 42 | options: 43 | show_source: false 44 | docstring_style: numpy 45 | show_root_toc_entry: True 46 | show_root_heading: True 47 | -------------------------------------------------------------------------------- /docs/widgets/colormap_catalog.md: -------------------------------------------------------------------------------- 1 | # CmapCatalogComboBox 2 | 3 | Searchable `QComboBox` variant that contains the 4 | [entire cmap colormap catalog](https://cmap-docs.readthedocs.io/en/latest/catalog/) 5 | 6 | !!! note "requires cmap" 7 | 8 | This widget uses the [cmap](https://cmap-docs.readthedocs.io/) library 9 | to provide colormaps. You can install it with: 10 | 11 | ```shell 12 | # use the `cmap` extra to include colormap support 13 | pip install superqt[cmap] 14 | ``` 15 | 16 | You can limit the colormaps shown by setting the `categories` or 17 | `interpolation` keyword arguments. 18 | 19 | ```python 20 | from qtpy.QtWidgets import QApplication 21 | 22 | from superqt.cmap import CmapCatalogComboBox 23 | 24 | app = QApplication([]) 25 | 26 | catalog_combo = CmapCatalogComboBox(interpolation="linear") 27 | catalog_combo.setCurrentText("viridis") 28 | catalog_combo.show() 29 | 30 | app.exec() 31 | ``` 32 | 33 | {{ show_widget(130) }} 34 | 35 | {{ show_members('superqt.cmap.CmapCatalogComboBox') }} 36 | -------------------------------------------------------------------------------- /docs/widgets/index.md: -------------------------------------------------------------------------------- 1 | # Widgets 2 | 3 | The following are QWidget subclasses: 4 | 5 | ## Sliders and Numerical Inputs 6 | 7 | | Widget | Description | 8 | | ----------- | --------------------- | 9 | | [`QDoubleRangeSlider`](./qdoublerangeslider.md) | Multi-handle slider for float values | 10 | | [`QDoubleSlider`](./qdoubleslider.md) | Slider for float values | 11 | | [`QLabeledDoubleRangeSlider`](./qlabeleddoublerangeslider.md) | `QDoubleRangeSlider` variant with editable labels for each handle | 12 | | [`QLabeledDoubleSlider`](./qlabeleddoubleslider.md) | `QSlider` for float values with editable `QSpinBox` with the current value | 13 | | [`QLabeledRangeSlider`](./qlabeledrangeslider.md) | `QRangeSlider` variant, with editable labels for each handle | 14 | | [`QLabeledSlider`](./qlabeledslider.md) | `QSlider` with editable `QSpinBox` that shows the current value | 15 | | [`QLargeIntSpinBox`](./qlargeintspinbox.md) | `QSpinbox` that accepts arbitrarily large integers | 16 | | [`QRangeSlider`](./qrangeslider.md) | Multi-handle slider | 17 | | [`QQuantity`](./qquantity.md) | Pint-backed quantity widget (magnitude combined with unit dropdown) | 18 | 19 | ## Labels and categorical inputs 20 | 21 | | Widget | Description | 22 | | ----------- | --------------------- | 23 | | [`QElidingLabel`](./qelidinglabel.md) | A `QLabel` variant that will elide text (add `…`) to fit width. | 24 | | [`QEnumComboBox`](./qenumcombobox.md) | `QComboBox` that populates the combobox from a python `Enum` | 25 | | [`QSearchableComboBox`](./qsearchablecombobox.md) | `QComboBox` variant that filters available options based on text input | 26 | | [`QSearchableListWidget`](./qsearchablelistwidget.md) | `QListWidget` variant with search field that filters available options | 27 | | [`QSearchableTreeWidget`](./qsearchabletreewidget.md) | `QTreeWidget` variant with search field that filters available options | 28 | | [`QColorComboBox`](./qcolorcombobox.md) | `QComboBox` to select from a specified set of colors | 29 | | [`QColormapComboBox`](./qcolormap.md) | `QComboBox` to select from a specified set of colormaps. | 30 | | [`QToggleSwitch`](./qtoggleswitch.md) | `QAbstractButton` that represents a boolean value with a toggle switch. | 31 | 32 | ## Frames and containers 33 | 34 | | Widget | Description | 35 | | ----------- | --------------------- | 36 | | [`QCollapsible`](./qcollapsible.md) | A collapsible widget to hide and unhide child widgets. | 37 | | [`QFlowLayout`](./qflowlayout.md) | A layout that rearranges items based on parent width. | 38 | -------------------------------------------------------------------------------- /docs/widgets/qcollapsible.md: -------------------------------------------------------------------------------- 1 | # QCollapsible 2 | 3 | Collapsible `QFrame` that can be expanded or collapsed by clicking on the header. 4 | 5 | ```python 6 | from qtpy.QtWidgets import QApplication, QLabel, QPushButton 7 | 8 | from superqt import QCollapsible 9 | 10 | app = QApplication([]) 11 | 12 | collapsible = QCollapsible("Advanced analysis") 13 | collapsible.addWidget(QLabel("This is the inside of the collapsible frame")) 14 | for i in range(10): 15 | collapsible.addWidget(QPushButton(f"Content button {i + 1}")) 16 | 17 | collapsible.expand(animate=False) 18 | collapsible.show() 19 | app.exec_() 20 | ``` 21 | 22 | {{ show_widget(350) }} 23 | 24 | {{ show_members('superqt.QCollapsible') }} 25 | -------------------------------------------------------------------------------- /docs/widgets/qcolorcombobox.md: -------------------------------------------------------------------------------- 1 | # QColorComboBox 2 | 3 | `QComboBox` designed to select from a specific set of colors. 4 | 5 | ```python 6 | from qtpy.QtWidgets import QApplication 7 | 8 | from superqt import QColorComboBox 9 | 10 | app = QApplication([]) 11 | 12 | colors = QColorComboBox() 13 | colors.addColors(['red', 'green', 'blue']) 14 | 15 | # show an "Add Color" item that opens a QColorDialog when clicked 16 | colors.setUserColorsAllowed(True) 17 | 18 | # emits a QColor when changed 19 | colors.currentColorChanged.connect(print) 20 | colors.show() 21 | 22 | app.exec_() 23 | ``` 24 | 25 | {{ show_widget(100) }} 26 | 27 | {{ show_members('superqt.QColorComboBox') }} 28 | -------------------------------------------------------------------------------- /docs/widgets/qcolormap.md: -------------------------------------------------------------------------------- 1 | # QColormapComboBox 2 | 3 | `QComboBox` variant to select from a specific set of colormaps. 4 | 5 | !!! note "requires cmap" 6 | 7 | This widget uses the [cmap](https://cmap-docs.readthedocs.io/) library 8 | to provide colormaps. You can install it with: 9 | 10 | ```shell 11 | # use the `cmap` extra to include colormap support 12 | pip install superqt[cmap] 13 | ``` 14 | 15 | ### ColorMapLike objects 16 | 17 | Colormaps may be specified in a variety of ways, such as by name (string), an iterable of a color/color-like objects, or as 18 | a [`cmap.Colormap`][] instance. See [cmap documentation for details on 19 | all ColormapLike types](https://cmap-docs.readthedocs.io/en/latest/colormaps/#colormaplike-objects) 20 | 21 | ### Example 22 | 23 | ```python 24 | from cmap import Colormap 25 | from qtpy.QtWidgets import QApplication 26 | 27 | from superqt import QColormapComboBox 28 | 29 | app = QApplication([]) 30 | 31 | cmap_combo = QColormapComboBox() 32 | # see note above about colormap-like objects 33 | # as names from the cmap catalog 34 | cmap_combo.addColormaps(["viridis", "plasma", "magma", "gray"]) 35 | # as a sequence of colors, linearly interpolated 36 | cmap_combo.addColormap(("#0f0", "slateblue", "#F3A003A0")) 37 | # as a `cmap.Colormap` instance with custom name: 38 | cmap_combo.addColormap(Colormap(("green", "white", "orange"), name="MyMap")) 39 | 40 | cmap_combo.show() 41 | app.exec() 42 | ``` 43 | 44 | {{ show_widget(200) }} 45 | 46 | ### Style Customization 47 | 48 | Note that both the LineEdit and the dropdown can be styled to have the colormap 49 | on the left, or fill the entire width of the widget. 50 | 51 | To make the CombBox label colormap fill the entire width of the widget: 52 | 53 | ```python 54 | from superqt.cmap import QColormapLineEdit 55 | cmap_combo.setLineEdit(QColormapLineEdit()) 56 | ``` 57 | 58 | To make the CombBox dropdown colormaps fill 59 | less than the entire width of the widget: 60 | 61 | ```python 62 | from superqt.cmap import QColormapItemDelegate 63 | delegate = QColormapItemDelegate(fractional_colormap_width=0.33) 64 | cmap_combo.setItemDelegate(delegate) 65 | ``` 66 | 67 | {{ show_members('superqt.QColormapComboBox') }} 68 | -------------------------------------------------------------------------------- /docs/widgets/qdoublerangeslider.md: -------------------------------------------------------------------------------- 1 | # QDoubleRangeSlider 2 | 3 | Float variant of [`QRangeSlider`](qrangeslider.md). (see that page for more details). 4 | 5 | ```python 6 | from qtpy.QtCore import Qt 7 | from qtpy.QtWidgets import QApplication 8 | 9 | from superqt import QDoubleRangeSlider 10 | 11 | app = QApplication([]) 12 | 13 | slider = QDoubleRangeSlider(Qt.Orientation.Horizontal) 14 | slider.setRange(0, 1) 15 | slider.setValue((0.2, 0.8)) 16 | slider.show() 17 | 18 | app.exec_() 19 | ``` 20 | 21 | {{ show_widget() }} 22 | 23 | {{ show_members('superqt.QDoubleRangeSlider') }} 24 | -------------------------------------------------------------------------------- /docs/widgets/qdoubleslider.md: -------------------------------------------------------------------------------- 1 | # QDoubleSlider 2 | 3 | `QSlider` variant that accepts floating point values. 4 | 5 | ```python 6 | from qtpy.QtCore import Qt 7 | from qtpy.QtWidgets import QApplication 8 | 9 | from superqt import QDoubleSlider 10 | 11 | app = QApplication([]) 12 | 13 | slider = QDoubleSlider(Qt.Orientation.Horizontal) 14 | slider.setRange(0, 1) 15 | slider.setValue(0.5) 16 | slider.show() 17 | 18 | app.exec_() 19 | ``` 20 | 21 | {{ show_widget() }} 22 | 23 | {{ show_members('superqt.QDoubleSlider') }} 24 | -------------------------------------------------------------------------------- /docs/widgets/qelidinglabel.md: -------------------------------------------------------------------------------- 1 | # QElidingLabel 2 | 3 | `QLabel` variant that will elide text (i.e. add an ellipsis) 4 | if it is too long to fit in the available space. 5 | 6 | ```python 7 | from qtpy.QtWidgets import QApplication 8 | 9 | from superqt import QElidingLabel 10 | 11 | app = QApplication([]) 12 | 13 | widget = QElidingLabel( 14 | "a skj skjfskfj sdlf sdfl sdlfk jsdf sdlkf jdsf dslfksdl sdlfk sdf sdl " 15 | "fjsdlf kjsdlfk laskdfsal as lsdfjdsl kfjdslf asfd dslkjfldskf sdlkfj" 16 | ) 17 | widget.setWordWrap(True) 18 | widget.resize(300, 20) 19 | widget.show() 20 | 21 | app.exec_() 22 | ``` 23 | 24 | {{ show_widget(300) }} 25 | 26 | {{ show_members('superqt.QElidingLabel') }} 27 | -------------------------------------------------------------------------------- /docs/widgets/qenumcombobox.md: -------------------------------------------------------------------------------- 1 | # QEnumComboBox 2 | 3 | `QEnumComboBox` is a variant of 4 | [`QComboBox`](https://doc.qt.io/qt-6/qcombobox.html) that populates the items in 5 | the combobox based on a python `Enum` class. In addition to all the methods 6 | provided by `QComboBox`, this subclass adds the methods 7 | `enumClass`/`setEnumClass` to get/set the current `Enum` class represented by 8 | the combobox, and `currentEnum`/`setCurrentEnum` to get/set the current `Enum` 9 | member in the combobox. There is also a new signal `currentEnumChanged(enum)` 10 | analogous to `currentIndexChanged` and `currentTextChanged`. 11 | 12 | Method like `insertItem` and `addItem` are blocked and try of its usage will end 13 | with `RuntimeError` 14 | 15 | ```python 16 | from enum import Enum 17 | 18 | from qtpy.QtWidgets import QApplication 19 | from superqt import QEnumComboBox 20 | 21 | 22 | class SampleEnum(Enum): 23 | first = 1 24 | second = 2 25 | third = 3 26 | 27 | app = QApplication([]) 28 | 29 | combo = QEnumComboBox() 30 | combo.setEnumClass(SampleEnum) 31 | combo.show() 32 | 33 | app.exec_() 34 | ``` 35 | 36 | {{ show_widget() }} 37 | 38 | Another option is to use optional `enum_class` argument of constructor and change 39 | 40 | ```python 41 | # option A: 42 | combo = QEnumComboBox() 43 | combo.setEnumClass(SampleEnum) 44 | # option B: 45 | combo = QEnumComboBox(enum_class=SampleEnum) 46 | ``` 47 | 48 | ## Allow `None` 49 | 50 | `QEnumComboBox` also allows using `Optional` type annotation: 51 | 52 | ```python 53 | from enum import Enum 54 | 55 | from superqt import QEnumComboBox 56 | 57 | class SampleEnum(Enum): 58 | first = 1 59 | second = 2 60 | third = 3 61 | 62 | # as usual: 63 | # you must create a QApplication before create a widget. 64 | 65 | combo = QEnumComboBox() 66 | combo.setEnumClass(SampleEnum, allow_none=True) 67 | ``` 68 | 69 | In this case there is added option `----` and the `currentEnum()` method will 70 | return `None` when it is selected. 71 | 72 | {{ show_members('superqt.QEnumComboBox') }} 73 | -------------------------------------------------------------------------------- /docs/widgets/qflowlayout.md: -------------------------------------------------------------------------------- 1 | # QFlowLayout 2 | 3 | QLayout that rearranges items based on parent width. 4 | 5 | ```python 6 | from qtpy.QtWidgets import QApplication, QPushButton, QWidget 7 | 8 | from superqt import QFlowLayout 9 | 10 | app = QApplication([]) 11 | 12 | wdg = QWidget() 13 | 14 | layout = QFlowLayout(wdg) 15 | layout.addWidget(QPushButton("Short")) 16 | layout.addWidget(QPushButton("Longer")) 17 | layout.addWidget(QPushButton("Different text")) 18 | layout.addWidget(QPushButton("More text")) 19 | layout.addWidget(QPushButton("Even longer button text")) 20 | 21 | wdg.setWindowTitle("Flow Layout") 22 | wdg.show() 23 | 24 | app.exec() 25 | ``` 26 | 27 | {{ show_widget(350) }} 28 | 29 | {{ show_members('superqt.QFlowLayout') }} 30 | -------------------------------------------------------------------------------- /docs/widgets/qlabeleddoublerangeslider.md: -------------------------------------------------------------------------------- 1 | # QLabeledDoubleRangeSlider 2 | 3 | Labeled Float variant of [`QRangeSlider`](qrangeslider.md). (see that page for more details). 4 | 5 | ```python 6 | from qtpy.QtCore import Qt 7 | from qtpy.QtWidgets import QApplication 8 | 9 | from superqt import QLabeledDoubleRangeSlider 10 | 11 | app = QApplication([]) 12 | 13 | slider = QLabeledDoubleRangeSlider(Qt.Orientation.Horizontal) 14 | slider.setRange(0, 1) 15 | slider.setValue((0.2, 0.8)) 16 | slider.show() 17 | 18 | app.exec_() 19 | ``` 20 | 21 | {{ show_widget() }} 22 | 23 | {{ show_members('superqt.QLabeledDoubleRangeSlider') }} 24 | -------------------------------------------------------------------------------- /docs/widgets/qlabeleddoubleslider.md: -------------------------------------------------------------------------------- 1 | # QLabeledDoubleSlider 2 | 3 | [`QDoubleSlider`](./qdoubleslider.md) variant that shows an editable (SpinBox) label next to the slider. 4 | 5 | 6 | ```python 7 | from qtpy.QtCore import Qt 8 | from qtpy.QtWidgets import QApplication 9 | 10 | from superqt import QLabeledDoubleSlider 11 | 12 | app = QApplication([]) 13 | 14 | slider = QLabeledDoubleSlider(Qt.Orientation.Horizontal) 15 | slider.setRange(0, 2.5) 16 | slider.setValue(1.3) 17 | slider.show() 18 | 19 | app.exec_() 20 | ``` 21 | 22 | {{ show_widget() }} 23 | 24 | {{ show_members('superqt.QLabeledDoubleSlider') }} 25 | -------------------------------------------------------------------------------- /docs/widgets/qlabeledrangeslider.md: -------------------------------------------------------------------------------- 1 | # QLabeledRangeSlider 2 | 3 | Labeled variant of [`QRangeSlider`](qrangeslider.md). (see that page for more details). 4 | 5 | ```python 6 | from qtpy.QtCore import Qt 7 | from qtpy.QtWidgets import QApplication 8 | 9 | from superqt import QLabeledRangeSlider 10 | 11 | app = QApplication([]) 12 | 13 | slider = QLabeledRangeSlider(Qt.Orientation.Horizontal) 14 | slider.setValue((20, 80)) 15 | slider.show() 16 | 17 | app.exec_() 18 | ``` 19 | 20 | {{ show_widget() }} 21 | 22 | {{ show_members('superqt.QLabeledRangeSlider') }} 23 | 24 | ---- 25 | 26 | If you find that you need to fine tune the position of the handle labels: 27 | 28 | - `QLabeledRangeSlider.label_shift_x`: adjust horizontal label position 29 | - `QLabeledRangeSlider.label_shift_y`: adjust vertical label position 30 | -------------------------------------------------------------------------------- /docs/widgets/qlabeledslider.md: -------------------------------------------------------------------------------- 1 | # QLabeledSlider 2 | 3 | `QSlider` variant that shows an editable (SpinBox) label next to the slider. 4 | 5 | ```python 6 | from qtpy.QtCore import Qt 7 | from qtpy.QtWidgets import QApplication 8 | 9 | from superqt import QLabeledSlider 10 | 11 | app = QApplication([]) 12 | 13 | slider = QLabeledSlider(Qt.Orientation.Horizontal) 14 | slider.setValue(42) 15 | slider.show() 16 | 17 | app.exec_() 18 | ``` 19 | 20 | {{ show_widget() }} 21 | 22 | {{ show_members('superqt.QLabeledSlider') }} 23 | -------------------------------------------------------------------------------- /docs/widgets/qlargeintspinbox.md: -------------------------------------------------------------------------------- 1 | # QLargeIntSpinBox 2 | 3 | `QSpinBox` variant that allows to enter large integers, without overflow. 4 | 5 | ```python 6 | from qtpy.QtCore import Qt 7 | from qtpy.QtWidgets import QApplication 8 | 9 | from superqt import QLargeIntSpinBox 10 | 11 | app = QApplication([]) 12 | 13 | slider = QLargeIntSpinBox() 14 | slider.setRange(0, 4.53e8) 15 | slider.setValue(4.53e8) 16 | slider.show() 17 | 18 | app.exec_() 19 | ``` 20 | 21 | {{ show_widget(150) }} 22 | 23 | {{ show_members('superqt.QLargeIntSpinBox') }} 24 | -------------------------------------------------------------------------------- /docs/widgets/qquantity.md: -------------------------------------------------------------------------------- 1 | # QQuantity 2 | 3 | A widget that allows the user to edit a quantity (a magnitude associated with a unit). 4 | 5 | !!! note 6 | 7 | This widget requires [`pint`](https://pint.readthedocs.io): 8 | 9 | ``` 10 | pip install pint 11 | ``` 12 | 13 | or 14 | 15 | ``` 16 | pip install superqt[quantity] 17 | ``` 18 | 19 | ```python 20 | from qtpy.QtWidgets import QApplication 21 | 22 | from superqt import QQuantity 23 | 24 | app = QApplication([]) 25 | w = QQuantity("1m") 26 | w.show() 27 | 28 | app.exec() 29 | ``` 30 | 31 | {{ show_widget(150) }} 32 | 33 | {{ show_members('superqt.QQuantity') }} 34 | -------------------------------------------------------------------------------- /docs/widgets/qsearchablecombobox.md: -------------------------------------------------------------------------------- 1 | # QSearchableComboBox 2 | 3 | `QSearchableComboBox` is a variant of 4 | [`QComboBox`](https://doc.qt.io/qt-6/qcombobox.html) that allow to filter list 5 | of options by enter part of text. It could be drop in replacement for 6 | `QComboBox`. 7 | 8 | 9 | ```python 10 | from qtpy.QtWidgets import QApplication 11 | 12 | from superqt import QSearchableComboBox 13 | 14 | app = QApplication([]) 15 | 16 | combo = QSearchableComboBox() 17 | combo.addItems(["foo", "bar", "baz", "foobar", "foobaz", "barbaz"]) 18 | combo.show() 19 | 20 | app.exec_() 21 | ``` 22 | 23 | {{ show_widget() }} 24 | 25 | {{ show_members('superqt.QSearchableComboBox') }} 26 | -------------------------------------------------------------------------------- /docs/widgets/qsearchablelistwidget.md: -------------------------------------------------------------------------------- 1 | # QSearchableListWidget 2 | 3 | `QSearchableListWidget` is a variant of 4 | [`QListWidget`](https://doc.qt.io/qt-6/qlistwidget.html) that add text entry 5 | above list widget that allow to filter list of available options. 6 | 7 | Due to implementation details, this widget it does not inherit directly from 8 | [`QListWidget`](https://doc.qt.io/qt-6/qlistwidget.html) but it does fully 9 | satisfy its api. The only limitation is that it cannot be used as argument of 10 | [`QListWidgetItem`](https://doc.qt.io/qt-6/qlistwidgetitem.html) constructor. 11 | 12 | ```python 13 | from qtpy.QtWidgets import QApplication 14 | 15 | from superqt import QSearchableListWidget 16 | 17 | app = QApplication([]) 18 | 19 | slider = QSearchableListWidget() 20 | slider.addItems(["foo", "bar", "baz", "foobar", "foobaz", "barbaz"]) 21 | slider.show() 22 | 23 | app.exec_() 24 | ``` 25 | 26 | {{ show_widget() }} 27 | 28 | {{ show_members('superqt.QSearchableListWidget') }} 29 | -------------------------------------------------------------------------------- /docs/widgets/qsearchabletreewidget.md: -------------------------------------------------------------------------------- 1 | # QSearchableTreeWidget 2 | 3 | `QSearchableTreeWidget` combines a 4 | [`QTreeWidget`](https://doc.qt.io/qt-6/qtreewidget.html) and a `QLineEdit` for showing a mapping that can be searched by key. 5 | 6 | This is intended to be used with a read-only mapping and be conveniently created 7 | using `QSearchableTreeWidget.fromData(data)`. If the mapping changes, the 8 | easiest way to update this is by calling `setData`. 9 | 10 | 11 | ```python 12 | from qtpy.QtWidgets import QApplication 13 | 14 | from superqt import QSearchableTreeWidget 15 | 16 | app = QApplication([]) 17 | 18 | data = { 19 | "none": None, 20 | "str": "test", 21 | "int": 42, 22 | "list": [2, 3, 5], 23 | "dict": { 24 | "float": 0.5, 25 | "tuple": (22, 99), 26 | "bool": False, 27 | }, 28 | } 29 | tree = QSearchableTreeWidget.fromData(data) 30 | tree.show() 31 | 32 | app.exec_() 33 | ``` 34 | 35 | {{ show_widget() }} 36 | 37 | {{ show_members('superqt.QSearchableTreeWidget') }} 38 | -------------------------------------------------------------------------------- /docs/widgets/qtoggleswitch.md: -------------------------------------------------------------------------------- 1 | # QToggleSwitch 2 | 3 | `QToggleSwitch` is a 4 | [`QAbstractButton`](https://doc.qt.io/qt-6/qabstractbutton.html) subclass 5 | that represents a boolean value as a toggle switch. The API is similar to 6 | [`QCheckBox`](https://doc.qt.io/qt-6/qcheckbox.html) but with a different 7 | visual representation. 8 | 9 | ```python 10 | from qtpy.QtWidgets import QApplication 11 | 12 | from superqt import QToggleSwitch 13 | 14 | app = QApplication([]) 15 | 16 | switch = QToggleSwitch() 17 | switch.show() 18 | 19 | app.exec_() 20 | ``` 21 | 22 | {{ show_widget(80) }} 23 | 24 | {{ show_members('superqt.QToggleSwitch') }} 25 | -------------------------------------------------------------------------------- /examples/code_highlight.py: -------------------------------------------------------------------------------- 1 | from qtpy.QtGui import QColor, QPalette 2 | from qtpy.QtWidgets import QApplication, QTextEdit 3 | 4 | from superqt.utils import CodeSyntaxHighlight 5 | 6 | app = QApplication([]) 7 | 8 | text_area = QTextEdit() 9 | 10 | highlight = CodeSyntaxHighlight(text_area.document(), "python", "monokai") 11 | 12 | palette = text_area.palette() 13 | palette.setColor(QPalette.Base, QColor(highlight.background_color)) 14 | text_area.setPalette(palette) 15 | text_area.setText( 16 | """from argparse import ArgumentParser 17 | 18 | def main(): 19 | parser = ArgumentParser() 20 | parser.add_argument("name", help="Your name") 21 | args = parser.parse_args() 22 | print(f"Hello {args.name}") 23 | 24 | 25 | if __name__ == "__main__": 26 | main() 27 | """ 28 | ) 29 | 30 | text_area.show() 31 | 32 | app.exec_() 33 | -------------------------------------------------------------------------------- /examples/color_combo_box.py: -------------------------------------------------------------------------------- 1 | from qtpy.QtGui import QColor 2 | from qtpy.QtWidgets import QApplication 3 | 4 | from superqt import QColorComboBox 5 | 6 | app = QApplication([]) 7 | w = QColorComboBox() 8 | # adds an item "Add Color" that opens a QColorDialog when clicked 9 | w.setUserColorsAllowed(True) 10 | 11 | # colors can be any argument that can be passed to QColor 12 | # (tuples and lists will be expanded to QColor(*color) 13 | COLORS = [QColor("red"), "orange", (255, 255, 0), "green", "#00F", "indigo", "violet"] 14 | w.addColors(COLORS) 15 | 16 | # as with addColors, colors will be cast to QColor when using setColors 17 | w.setCurrentColor("indigo") 18 | 19 | w.resize(200, 50) 20 | w.show() 21 | 22 | w.currentColorChanged.connect(print) 23 | app.exec_() 24 | -------------------------------------------------------------------------------- /examples/colormap_combo_box.py: -------------------------------------------------------------------------------- 1 | from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget 2 | 3 | from superqt.cmap import CmapCatalogComboBox, QColormapComboBox 4 | 5 | app = QApplication([]) 6 | 7 | wdg = QWidget() 8 | layout = QVBoxLayout(wdg) 9 | 10 | catalog_combo = CmapCatalogComboBox(interpolation="linear") 11 | 12 | selected_cmap_combo = QColormapComboBox(allow_user_colormaps=True) 13 | selected_cmap_combo.addColormaps(["viridis", "plasma", "magma", "inferno", "turbo"]) 14 | 15 | layout.addWidget(catalog_combo) 16 | layout.addWidget(selected_cmap_combo) 17 | 18 | wdg.show() 19 | app.exec() 20 | -------------------------------------------------------------------------------- /examples/demo_widget.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from qtpy import QtCore 4 | from qtpy import QtWidgets as QtW 5 | 6 | # patch for Qt 5.15 on macos >= 12 7 | os.environ["USE_MAC_SLIDER_PATCH"] = "1" 8 | 9 | from superqt import QRangeSlider 10 | 11 | QSS = """ 12 | QSlider { 13 | min-height: 20px; 14 | } 15 | 16 | QSlider::groove:horizontal { 17 | border: 0px; 18 | background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #888, stop:1 #ddd); 19 | height: 20px; 20 | border-radius: 10px; 21 | } 22 | 23 | QSlider::handle { 24 | background: qradialgradient(cx:0, cy:0, radius: 1.2, fx:0.35, 25 | fy:0.3, stop:0 #eef, stop:1 #002); 26 | height: 20px; 27 | width: 20px; 28 | border-radius: 10px; 29 | } 30 | 31 | QSlider::sub-page:horizontal { 32 | background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #227, stop:1 #77a); 33 | border-top-left-radius: 10px; 34 | border-bottom-left-radius: 10px; 35 | } 36 | 37 | QRangeSlider { 38 | qproperty-barColor: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #227, stop:1 #77a); 39 | } 40 | """ 41 | 42 | Horizontal = QtCore.Qt.Orientation.Horizontal 43 | 44 | 45 | class DemoWidget(QtW.QWidget): 46 | def __init__(self) -> None: 47 | super().__init__() 48 | 49 | reg_hslider = QtW.QSlider(Horizontal) 50 | reg_hslider.setValue(50) 51 | range_hslider = QRangeSlider(Horizontal) 52 | range_hslider.setValue((20, 80)) 53 | multi_range_hslider = QRangeSlider(Horizontal) 54 | multi_range_hslider.setValue((11, 33, 66, 88)) 55 | multi_range_hslider.setTickPosition(QtW.QSlider.TickPosition.TicksAbove) 56 | 57 | styled_reg_hslider = QtW.QSlider(Horizontal) 58 | styled_reg_hslider.setValue(50) 59 | styled_reg_hslider.setStyleSheet(QSS) 60 | styled_range_hslider = QRangeSlider(Horizontal) 61 | styled_range_hslider.setValue((20, 80)) 62 | styled_range_hslider.setStyleSheet(QSS) 63 | 64 | reg_vslider = QtW.QSlider(QtCore.Qt.Orientation.Vertical) 65 | reg_vslider.setValue(50) 66 | range_vslider = QRangeSlider(QtCore.Qt.Orientation.Vertical) 67 | range_vslider.setValue((22, 77)) 68 | 69 | tick_vslider = QtW.QSlider(QtCore.Qt.Orientation.Vertical) 70 | tick_vslider.setValue(55) 71 | tick_vslider.setTickPosition(QtW.QSlider.TicksRight) 72 | range_tick_vslider = QRangeSlider(QtCore.Qt.Orientation.Vertical) 73 | range_tick_vslider.setValue((22, 77)) 74 | range_tick_vslider.setTickPosition(QtW.QSlider.TicksLeft) 75 | 76 | szp = QtW.QSizePolicy.Maximum 77 | left = QtW.QWidget() 78 | left.setLayout(QtW.QVBoxLayout()) 79 | left.setContentsMargins(2, 2, 2, 2) 80 | label1 = QtW.QLabel("Regular QSlider Unstyled") 81 | label2 = QtW.QLabel("QRangeSliders Unstyled") 82 | label3 = QtW.QLabel("Styled Sliders (using same stylesheet)") 83 | label1.setSizePolicy(szp, szp) 84 | label2.setSizePolicy(szp, szp) 85 | label3.setSizePolicy(szp, szp) 86 | left.layout().addWidget(label1) 87 | left.layout().addWidget(reg_hslider) 88 | left.layout().addWidget(label2) 89 | left.layout().addWidget(range_hslider) 90 | left.layout().addWidget(multi_range_hslider) 91 | left.layout().addWidget(label3) 92 | left.layout().addWidget(styled_reg_hslider) 93 | left.layout().addWidget(styled_range_hslider) 94 | 95 | right = QtW.QWidget() 96 | right.setLayout(QtW.QHBoxLayout()) 97 | right.setContentsMargins(15, 5, 5, 0) 98 | right.layout().setSpacing(30) 99 | right.layout().addWidget(reg_vslider) 100 | right.layout().addWidget(range_vslider) 101 | right.layout().addWidget(tick_vslider) 102 | right.layout().addWidget(range_tick_vslider) 103 | 104 | self.setLayout(QtW.QHBoxLayout()) 105 | self.layout().addWidget(left) 106 | self.layout().addWidget(right) 107 | self.setGeometry(600, 300, 580, 300) 108 | self.activateWindow() 109 | self.show() 110 | 111 | 112 | if __name__ == "__main__": 113 | import sys 114 | from pathlib import Path 115 | 116 | dest = Path("screenshots") 117 | dest.mkdir(exist_ok=True) 118 | 119 | app = QtW.QApplication([]) 120 | demo = DemoWidget() 121 | 122 | if "-snap" in sys.argv: 123 | import platform 124 | 125 | QtW.QApplication.processEvents() 126 | demo.grab().save(str(dest / f"demo_{platform.system().lower()}.png")) 127 | else: 128 | app.exec_() 129 | -------------------------------------------------------------------------------- /examples/double_slider.py: -------------------------------------------------------------------------------- 1 | from qtpy.QtCore import Qt 2 | from qtpy.QtWidgets import QApplication 3 | 4 | from superqt import QDoubleSlider 5 | 6 | app = QApplication([]) 7 | 8 | slider = QDoubleSlider(Qt.Orientation.Horizontal) 9 | slider.setRange(0, 1) 10 | slider.setValue(0.5) 11 | slider.resize(500, 50) 12 | slider.show() 13 | 14 | app.exec_() 15 | -------------------------------------------------------------------------------- /examples/eliding_label.py: -------------------------------------------------------------------------------- 1 | from qtpy.QtWidgets import QApplication 2 | 3 | from superqt import QElidingLabel 4 | 5 | app = QApplication([]) 6 | 7 | widget = QElidingLabel( 8 | "a skj skjfskfj sdlf sdfl sdlfk jsdf sdlkf jdsf dslfksdl sdlfk sdf sdl " 9 | "fjsdlf kjsdlfk laskdfsal as lsdfjdsl kfjdslf asfd dslkjfldskf sdlkfj" 10 | ) 11 | widget.setWordWrap(True) 12 | widget.show() 13 | app.exec_() 14 | -------------------------------------------------------------------------------- /examples/float.py: -------------------------------------------------------------------------------- 1 | from qtpy.QtCore import Qt 2 | from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget 3 | 4 | from superqt import QDoubleRangeSlider, QDoubleSlider, QRangeSlider 5 | 6 | app = QApplication([]) 7 | 8 | w = QWidget() 9 | 10 | sld1 = QDoubleSlider(Qt.Orientation.Horizontal) 11 | sld2 = QDoubleRangeSlider(Qt.Orientation.Horizontal) 12 | rs = QRangeSlider(Qt.Orientation.Horizontal) 13 | 14 | sld1.valueChanged.connect(lambda e: print("doubslider valuechanged", e)) 15 | 16 | sld2.setMaximum(1) 17 | sld2.setValue((0.2, 0.8)) 18 | sld2.valueChanged.connect(lambda e: print("valueChanged", e)) 19 | sld2.sliderMoved.connect(lambda e: print("sliderMoved", e)) 20 | sld2.rangeChanged.connect(lambda e, f: print("rangeChanged", (e, f))) 21 | 22 | w.setLayout(QVBoxLayout()) 23 | w.layout().addWidget(sld1) 24 | w.layout().addWidget(sld2) 25 | w.layout().addWidget(rs) 26 | w.show() 27 | w.resize(500, 150) 28 | app.exec_() 29 | -------------------------------------------------------------------------------- /examples/flow_layout.py: -------------------------------------------------------------------------------- 1 | from qtpy.QtWidgets import QApplication, QPushButton, QWidget 2 | 3 | from superqt import QFlowLayout 4 | 5 | app = QApplication([]) 6 | 7 | wdg = QWidget() 8 | 9 | layout = QFlowLayout(wdg) 10 | layout.addWidget(QPushButton("Short")) 11 | layout.addWidget(QPushButton("Longer")) 12 | layout.addWidget(QPushButton("Different text")) 13 | layout.addWidget(QPushButton("More text")) 14 | layout.addWidget(QPushButton("Even longer button text")) 15 | 16 | wdg.setWindowTitle("Flow Layout") 17 | wdg.show() 18 | 19 | app.exec() 20 | -------------------------------------------------------------------------------- /examples/fonticon1.py: -------------------------------------------------------------------------------- 1 | try: 2 | from fonticon_fa5 import FA5S 3 | except ImportError as e: 4 | raise type(e)( 5 | "This example requires the fontawesome fontpack:\n\n" 6 | "pip install git+https://github.com/tlambert03/fonticon-fontawesome5.git" 7 | ) 8 | 9 | from qtpy.QtCore import QSize 10 | from qtpy.QtWidgets import QApplication, QPushButton 11 | 12 | from superqt.fonticon import icon, pulse 13 | 14 | app = QApplication([]) 15 | 16 | btn2 = QPushButton() 17 | btn2.setIcon(icon(FA5S.spinner, animation=pulse(btn2))) 18 | btn2.setIconSize(QSize(225, 225)) 19 | btn2.show() 20 | 21 | app.exec() 22 | -------------------------------------------------------------------------------- /examples/fonticon2.py: -------------------------------------------------------------------------------- 1 | try: 2 | from fonticon_fa5 import FA5S 3 | except ImportError as e: 4 | raise type(e)( 5 | "This example requires the fontawesome fontpack:\n\n" 6 | "pip install git+https://github.com/tlambert03/fonticon-fontawesome5.git" 7 | ) 8 | 9 | from qtpy.QtWidgets import QApplication, QPushButton 10 | 11 | from superqt.fonticon import setTextIcon 12 | 13 | app = QApplication([]) 14 | 15 | 16 | btn4 = QPushButton() 17 | btn4.resize(275, 275) 18 | setTextIcon(btn4, FA5S.hamburger) 19 | btn4.show() 20 | 21 | app.exec() 22 | -------------------------------------------------------------------------------- /examples/fonticon3.py: -------------------------------------------------------------------------------- 1 | try: 2 | from fonticon_fa5 import FA5S 3 | except ImportError as e: 4 | raise type(e)( 5 | "This example requires the fontawesome fontpack:\n\n" 6 | "pip install git+https://github.com/tlambert03/fonticon-fontawesome5.git" 7 | ) 8 | 9 | from qtpy.QtCore import QSize 10 | from qtpy.QtWidgets import QApplication, QPushButton 11 | 12 | from superqt.fonticon import IconOpts, icon, pulse, spin 13 | 14 | app = QApplication([]) 15 | 16 | btn = QPushButton() 17 | btn.setIcon( 18 | icon( 19 | FA5S.smile, 20 | color="blue", 21 | states={ 22 | "active": IconOpts( 23 | glyph_key=FA5S.spinner, 24 | color="red", 25 | scale_factor=0.5, 26 | animation=pulse(btn), 27 | ), 28 | "disabled": {"color": "green", "scale_factor": 0.8, "animation": spin(btn)}, 29 | }, 30 | ) 31 | ) 32 | btn.setIconSize(QSize(256, 256)) 33 | btn.show() 34 | 35 | 36 | @btn.clicked.connect 37 | def toggle_state(): 38 | btn.setChecked(not btn.isChecked()) 39 | 40 | 41 | app.exec() 42 | -------------------------------------------------------------------------------- /examples/generic.py: -------------------------------------------------------------------------------- 1 | from qtpy.QtCore import Qt 2 | from qtpy.QtWidgets import QApplication 3 | 4 | from superqt import QDoubleSlider 5 | 6 | app = QApplication([]) 7 | 8 | sld = QDoubleSlider(Qt.Orientation.Horizontal) 9 | sld.setRange(0, 1) 10 | sld.setValue(0.5) 11 | sld.show() 12 | 13 | app.exec_() 14 | -------------------------------------------------------------------------------- /examples/iconify.py: -------------------------------------------------------------------------------- 1 | from qtpy.QtCore import QSize 2 | from qtpy.QtWidgets import QApplication, QPushButton 3 | 4 | from superqt import QIconifyIcon 5 | 6 | app = QApplication([]) 7 | 8 | btn = QPushButton() 9 | # search https://icon-sets.iconify.design for available icon keys 10 | btn.setIcon(QIconifyIcon("fluent-emoji-flat:alarm-clock")) 11 | btn.setIconSize(QSize(60, 60)) 12 | btn.show() 13 | 14 | app.exec() 15 | -------------------------------------------------------------------------------- /examples/labeled_sliders.py: -------------------------------------------------------------------------------- 1 | from qtpy.QtCore import Qt 2 | from qtpy.QtWidgets import QApplication, QHBoxLayout, QVBoxLayout, QWidget 3 | 4 | from superqt import ( 5 | QLabeledDoubleRangeSlider, 6 | QLabeledDoubleSlider, 7 | QLabeledRangeSlider, 8 | QLabeledSlider, 9 | ) 10 | 11 | app = QApplication([]) 12 | 13 | ORIENTATION = Qt.Orientation.Horizontal 14 | 15 | w = QWidget() 16 | qls = QLabeledSlider(ORIENTATION) 17 | qls.setEdgeLabelMode(qls.EdgeLabelMode.LabelIsRange | qls.EdgeLabelMode.LabelIsValue) 18 | qls.valueChanged.connect(lambda e: print("qls valueChanged", e)) 19 | qls.setRange(0, 500) 20 | qls.setValue(300) 21 | 22 | 23 | qlds = QLabeledDoubleSlider(ORIENTATION) 24 | qlds.valueChanged.connect(lambda e: print("qlds valueChanged", e)) 25 | qlds.setRange(0, 1) 26 | qlds.setValue(0.5) 27 | qlds.setSingleStep(0.1) 28 | 29 | qlrs = QLabeledRangeSlider(ORIENTATION) 30 | qlrs.valueChanged.connect(lambda e: print("QLabeledRangeSlider valueChanged", e)) 31 | qlrs.setValue((20, 60)) 32 | 33 | qldrs = QLabeledDoubleRangeSlider(ORIENTATION) 34 | qldrs.valueChanged.connect(lambda e: print("qlrs valueChanged", e)) 35 | qldrs.setRange(0, 1) 36 | qldrs.setSingleStep(0.01) 37 | qldrs.setValue((0.2, 0.7)) 38 | 39 | 40 | w.setLayout( 41 | QVBoxLayout() if ORIENTATION == Qt.Orientation.Horizontal else QHBoxLayout() 42 | ) 43 | w.layout().addWidget(qls) 44 | w.layout().addWidget(qlds) 45 | w.layout().addWidget(qlrs) 46 | w.layout().addWidget(qldrs) 47 | w.show() 48 | w.resize(500, 150) 49 | app.exec_() 50 | -------------------------------------------------------------------------------- /examples/multihandle.py: -------------------------------------------------------------------------------- 1 | from qtpy.QtWidgets import QApplication 2 | 3 | from superqt import QRangeSlider 4 | 5 | app = QApplication([]) 6 | 7 | slider = QRangeSlider() 8 | slider.setMinimum(0) 9 | slider.setMaximum(200) 10 | slider.setValue((0, 40, 80, 160)) 11 | slider.show() 12 | 13 | app.exec_() 14 | -------------------------------------------------------------------------------- /examples/qcollapsible.py: -------------------------------------------------------------------------------- 1 | """Example for QCollapsible.""" 2 | 3 | from qtpy.QtWidgets import QApplication, QLabel, QPushButton 4 | 5 | from superqt import QCollapsible 6 | 7 | app = QApplication([]) 8 | 9 | collapsible = QCollapsible("Advanced analysis") 10 | collapsible.setCollapsedIcon("+") 11 | collapsible.setExpandedIcon("-") 12 | collapsible.addWidget(QLabel("This is the inside of the collapsible frame")) 13 | for i in range(10): 14 | collapsible.addWidget(QPushButton(f"Content button {i + 1}")) 15 | 16 | collapsible.expand(animate=False) 17 | collapsible.show() 18 | app.exec_() 19 | -------------------------------------------------------------------------------- /examples/quantity.py: -------------------------------------------------------------------------------- 1 | from qtpy.QtWidgets import QApplication 2 | 3 | from superqt import QQuantity 4 | 5 | app = QApplication([]) 6 | w = QQuantity("1m") 7 | w.show() 8 | 9 | app.exec() 10 | -------------------------------------------------------------------------------- /examples/range_slider.py: -------------------------------------------------------------------------------- 1 | from qtpy.QtCore import Qt 2 | from qtpy.QtWidgets import QApplication 3 | 4 | from superqt import QRangeSlider 5 | 6 | app = QApplication([]) 7 | 8 | slider = QRangeSlider(Qt.Orientation.Horizontal) 9 | 10 | slider.setValue((20, 80)) 11 | slider.show() 12 | 13 | app.exec_() 14 | -------------------------------------------------------------------------------- /examples/searchable_combo_box.py: -------------------------------------------------------------------------------- 1 | from qtpy.QtWidgets import QApplication 2 | 3 | from superqt import QSearchableComboBox 4 | 5 | app = QApplication([]) 6 | 7 | slider = QSearchableComboBox() 8 | slider.addItems(["foo", "bar", "baz", "foobar", "foobaz", "barbaz"]) 9 | slider.show() 10 | 11 | app.exec_() 12 | -------------------------------------------------------------------------------- /examples/searchable_list_widget.py: -------------------------------------------------------------------------------- 1 | from qtpy.QtWidgets import QApplication 2 | 3 | from superqt import QSearchableListWidget 4 | 5 | app = QApplication([]) 6 | 7 | slider = QSearchableListWidget() 8 | slider.addItems(["foo", "bar", "baz", "foobar", "foobaz", "barbaz"]) 9 | slider.show() 10 | 11 | app.exec_() 12 | -------------------------------------------------------------------------------- /examples/searchable_tree_widget.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from qtpy.QtWidgets import QApplication 4 | 5 | from superqt import QSearchableTreeWidget 6 | 7 | logging.basicConfig( 8 | level=logging.DEBUG, 9 | format="%(asctime)s : %(levelname)s : %(filename)s : %(message)s", 10 | ) 11 | 12 | data = { 13 | "none": None, 14 | "str": "test", 15 | "int": 42, 16 | "list": [2, 3, 5], 17 | "dict": { 18 | "float": 0.5, 19 | "tuple": (22, 99), 20 | "bool": False, 21 | }, 22 | } 23 | 24 | app = QApplication([]) 25 | 26 | tree = QSearchableTreeWidget.fromData(data) 27 | tree.show() 28 | 29 | app.exec_() 30 | -------------------------------------------------------------------------------- /examples/throttle_mouse_event.py: -------------------------------------------------------------------------------- 1 | from qtpy.QtCore import Signal 2 | from qtpy.QtWidgets import QApplication, QWidget 3 | 4 | from superqt.utils import qthrottled 5 | 6 | 7 | class Demo(QWidget): 8 | positionChanged = Signal(int, int) 9 | 10 | def __init__(self) -> None: 11 | super().__init__() 12 | self.setMouseTracking(True) 13 | self.positionChanged.connect(self._show_location) 14 | 15 | @qthrottled(timeout=400) # call this no more than once every 400ms 16 | def _show_location(self, x, y): 17 | print("Throttled event at", x, y) 18 | 19 | def mouseMoveEvent(self, event): 20 | print("real move event at", event.x(), event.y()) 21 | self.positionChanged.emit(event.x(), event.y()) 22 | 23 | 24 | if __name__ == "__main__": 25 | app = QApplication([]) 26 | w = Demo() 27 | w.resize(600, 600) 28 | w.show() 29 | app.exec_() 30 | -------------------------------------------------------------------------------- /examples/toggle_switch.py: -------------------------------------------------------------------------------- 1 | from qtpy import QtCore, QtGui 2 | from qtpy.QtWidgets import QApplication, QStyle, QVBoxLayout, QWidget 3 | 4 | from superqt import QToggleSwitch 5 | from superqt.switch import QStyleOptionToggleSwitch 6 | 7 | QSS_EXAMPLE = """ 8 | QToggleSwitch { 9 | qproperty-onColor: red; 10 | qproperty-handleSize: 12; 11 | qproperty-switchWidth: 30; 12 | qproperty-switchHeight: 16; 13 | } 14 | """ 15 | 16 | 17 | class QRectangleToggleSwitch(QToggleSwitch): 18 | """A rectangle shaped toggle switch.""" 19 | 20 | def drawGroove( 21 | self, 22 | painter: QtGui.QPainter, 23 | rect: QtCore.QRectF, 24 | option: QStyleOptionToggleSwitch, 25 | ) -> None: 26 | """Draw the groove of the switch.""" 27 | painter.setPen(QtCore.Qt.PenStyle.NoPen) 28 | is_checked = option.state & QStyle.StateFlag.State_On 29 | painter.setBrush(option.on_color if is_checked else option.off_color) 30 | painter.setOpacity(0.8) 31 | painter.drawRect(rect) 32 | 33 | def drawHandle(self, painter, rect, option): 34 | """Draw the handle of the switch.""" 35 | painter.drawRect(rect) 36 | 37 | 38 | class QToggleSwitchWithText(QToggleSwitch): 39 | """A toggle switch with text on the handle.""" 40 | 41 | def drawHandle( 42 | self, 43 | painter: QtGui.QPainter, 44 | rect: QtCore.QRectF, 45 | option: QStyleOptionToggleSwitch, 46 | ) -> None: 47 | super().drawHandle(painter, rect, option) 48 | 49 | text = "ON" if option.state & QStyle.StateFlag.State_On else "OFF" 50 | painter.setPen(QtGui.QPen(QtGui.QColor("black"))) 51 | font = painter.font() 52 | font.setPointSize(5) 53 | painter.setFont(font) 54 | painter.drawText(rect, QtCore.Qt.AlignmentFlag.AlignCenter, text) 55 | 56 | 57 | app = QApplication([]) 58 | widget = QWidget() 59 | layout = QVBoxLayout(widget) 60 | layout.addWidget(QToggleSwitch("original")) 61 | switch_styled = QToggleSwitch("stylesheet") 62 | switch_styled.setStyleSheet(QSS_EXAMPLE) 63 | layout.addWidget(switch_styled) 64 | layout.addWidget(QRectangleToggleSwitch("rectangle")) 65 | layout.addWidget(QToggleSwitchWithText("with text")) 66 | widget.show() 67 | app.exec() 68 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: superqt 2 | site_url: https://github.com/pyapp-kit/superqt 3 | site_description: >- 4 | missing widgets and components for PyQt/PySide 5 | # Repository 6 | repo_name: pyapp-kit/superqt 7 | repo_url: https://github.com/pyapp-kit/superqt 8 | 9 | # Copyright 10 | copyright: Copyright © 2021 - 2022 11 | 12 | watch: 13 | - src 14 | 15 | theme: 16 | name: material 17 | features: 18 | - navigation.instant 19 | - navigation.indexes 20 | - navigation.expand 21 | # - navigation.tracking 22 | # - navigation.tabs 23 | - search.highlight 24 | - search.suggest 25 | - content.code.copy 26 | 27 | markdown_extensions: 28 | - admonition 29 | - pymdownx.details 30 | - pymdownx.superfences 31 | - tables 32 | - attr_list 33 | - md_in_html 34 | - pymdownx.emoji: 35 | emoji_index: !!python/name:material.extensions.emoji.twemoji 36 | emoji_generator: !!python/name:material.extensions.emoji.to_svg 37 | - toc: 38 | permalink: "#" 39 | 40 | 41 | plugins: 42 | - search 43 | - autorefs 44 | - macros: 45 | module_name: docs/_macros 46 | - mkdocstrings: 47 | handlers: 48 | python: 49 | import: 50 | - https://docs.python.org/3/objects.inv 51 | - https://cmap-docs.readthedocs.io/en/latest/objects.inv 52 | options: 53 | show_source: false 54 | docstring_style: numpy 55 | show_root_toc_entry: True 56 | show_root_heading: True 57 | -------------------------------------------------------------------------------- /src/superqt/__init__.py: -------------------------------------------------------------------------------- 1 | """superqt is a collection of Qt components for python.""" 2 | 3 | from importlib.metadata import PackageNotFoundError, version 4 | from typing import TYPE_CHECKING, Any 5 | 6 | try: 7 | __version__ = version("superqt") 8 | except PackageNotFoundError: 9 | __version__ = "unknown" 10 | 11 | from .collapsible import QCollapsible 12 | from .combobox import QColorComboBox, QEnumComboBox, QSearchableComboBox 13 | from .elidable import QElidingLabel, QElidingLineEdit 14 | from .selection import QSearchableListWidget, QSearchableTreeWidget 15 | from .sliders import ( 16 | QDoubleRangeSlider, 17 | QDoubleSlider, 18 | QLabeledDoubleRangeSlider, 19 | QLabeledDoubleSlider, 20 | QLabeledRangeSlider, 21 | QLabeledSlider, 22 | QRangeSlider, 23 | ) 24 | from .spinbox import QLargeIntSpinBox 25 | from .switch import QToggleSwitch 26 | from .utils import ( 27 | QFlowLayout, 28 | QMessageHandler, 29 | ensure_main_thread, 30 | ensure_object_thread, 31 | ) 32 | 33 | __all__ = [ 34 | "QCollapsible", 35 | "QColorComboBox", 36 | "QColormapComboBox", 37 | "QDoubleRangeSlider", 38 | "QDoubleSlider", 39 | "QElidingLabel", 40 | "QElidingLineEdit", 41 | "QEnumComboBox", 42 | "QFlowLayout", 43 | "QIconifyIcon", 44 | "QLabeledDoubleRangeSlider", 45 | "QLabeledDoubleSlider", 46 | "QLabeledRangeSlider", 47 | "QLabeledSlider", 48 | "QLargeIntSpinBox", 49 | "QMessageHandler", 50 | "QQuantity", 51 | "QRangeSlider", 52 | "QSearchableComboBox", 53 | "QSearchableListWidget", 54 | "QSearchableTreeWidget", 55 | "QToggleSwitch", 56 | "ensure_main_thread", 57 | "ensure_object_thread", 58 | ] 59 | 60 | if TYPE_CHECKING: 61 | from .combobox import QColormapComboBox 62 | from .iconify import QIconifyIcon 63 | from .spinbox._quantity import QQuantity 64 | 65 | 66 | def __getattr__(name: str) -> Any: 67 | if name == "QColormapComboBox": 68 | from .cmap import QColormapComboBox 69 | 70 | return QColormapComboBox 71 | if name == "QIconifyIcon": 72 | from .iconify import QIconifyIcon 73 | 74 | return QIconifyIcon 75 | if name == "QQuantity": 76 | from .spinbox._quantity import QQuantity 77 | 78 | return QQuantity 79 | raise AttributeError(f"module {__name__!r} has no attribute {name!r}") 80 | -------------------------------------------------------------------------------- /src/superqt/cmap/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | import cmap 3 | except ImportError as e: 4 | raise ImportError( 5 | "The cmap package is required to use superqt colormap utilities. " 6 | "Install it with `pip install cmap` or `pip install superqt[cmap]`." 7 | ) from e 8 | else: 9 | del cmap 10 | 11 | from ._catalog_combo import CmapCatalogComboBox 12 | from ._cmap_combo import QColormapComboBox 13 | from ._cmap_item_delegate import QColormapItemDelegate 14 | from ._cmap_line_edit import QColormapLineEdit 15 | from ._cmap_utils import draw_colormap 16 | 17 | __all__ = [ 18 | "CmapCatalogComboBox", 19 | "QColormapComboBox", 20 | "QColormapItemDelegate", 21 | "QColormapLineEdit", 22 | "draw_colormap", 23 | ] 24 | -------------------------------------------------------------------------------- /src/superqt/cmap/_catalog_combo.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from cmap import Colormap 6 | from qtpy.QtCore import Qt, Signal 7 | from qtpy.QtWidgets import QComboBox, QCompleter, QWidget 8 | 9 | from ._cmap_item_delegate import QColormapItemDelegate 10 | from ._cmap_line_edit import QColormapLineEdit 11 | from ._cmap_utils import try_cast_colormap 12 | 13 | if TYPE_CHECKING: 14 | from collections.abc import Container 15 | 16 | from cmap._catalog import Category, Interpolation 17 | from qtpy.QtGui import QKeyEvent 18 | 19 | 20 | class CmapCatalogComboBox(QComboBox): 21 | """A combo box for selecting a colormap from the entire cmap catalog. 22 | 23 | Parameters 24 | ---------- 25 | parent : QWidget, optional 26 | The parent widget. 27 | prefer_short_names : bool, optional 28 | If True (default), short names (without the namespace prefix) will be 29 | preferred over fully qualified names. In cases where the same short name is 30 | used in multiple namespaces, they will *all* be referred to by their fully 31 | qualified (namespaced) name. 32 | categories : Container[Category], optional 33 | If provided, only return names from the given categories. 34 | interpolation : Interpolation, optional 35 | If provided, only return names that have the given interpolation method. 36 | """ 37 | 38 | currentColormapChanged = Signal(Colormap) 39 | 40 | def __init__( 41 | self, 42 | parent: QWidget | None = None, 43 | *, 44 | categories: Container[Category] = (), 45 | prefer_short_names: bool = True, 46 | interpolation: Interpolation | None = None, 47 | ) -> None: 48 | super().__init__(parent) 49 | 50 | # get valid names according to preferences 51 | word_list = sorted( 52 | Colormap.catalog().unique_keys( 53 | prefer_short_names=prefer_short_names, 54 | categories=categories, 55 | interpolation=interpolation, 56 | ) 57 | ) 58 | 59 | # initialize the combobox 60 | self.addItems(word_list) 61 | self.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) 62 | self.setEditable(True) 63 | self.setDuplicatesEnabled(False) 64 | # (must come before setCompleter) 65 | self.setLineEdit(QColormapLineEdit(self)) 66 | 67 | # setup the completer 68 | completer = QCompleter(word_list) 69 | completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) 70 | completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion) 71 | completer.setFilterMode(Qt.MatchFlag.MatchContains) 72 | completer.setModel(self.model()) 73 | self.setCompleter(completer) 74 | 75 | # set the delegate for both the popup and the combobox 76 | delegate = QColormapItemDelegate() 77 | if popup := completer.popup(): 78 | popup.setItemDelegate(delegate) 79 | self.setItemDelegate(delegate) 80 | 81 | self.currentTextChanged.connect(self._on_text_changed) 82 | 83 | def currentColormap(self) -> Colormap | None: 84 | """Returns the currently selected Colormap or None if not yet selected.""" 85 | return try_cast_colormap(self.currentText()) 86 | 87 | def keyPressEvent(self, e: QKeyEvent | None) -> None: 88 | if e and e.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return): 89 | # select the first completion when pressing enter if the popup is visible 90 | if (completer := self.completer()) and completer.completionCount(): 91 | self.lineEdit().setText(completer.currentCompletion()) # type: ignore 92 | return super().keyPressEvent(e) 93 | 94 | def _on_text_changed(self, text: str) -> None: 95 | if (cmap := try_cast_colormap(text)) is not None: 96 | self.currentColormapChanged.emit(cmap) 97 | -------------------------------------------------------------------------------- /src/superqt/cmap/_cmap_item_delegate.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, cast 4 | 5 | from qtpy.QtCore import QModelIndex, QObject, QPersistentModelIndex, QRect, QSize, Qt 6 | from qtpy.QtGui import QColor, QPainter 7 | from qtpy.QtWidgets import QStyle, QStyledItemDelegate, QStyleOptionViewItem 8 | 9 | from ._cmap_utils import CMAP_ROLE, draw_colormap, pick_font_color, try_cast_colormap 10 | 11 | if TYPE_CHECKING: 12 | from cmap import Colormap 13 | 14 | DEFAULT_SIZE = QSize(80, 22) 15 | DEFAULT_BORDER_COLOR = QColor(Qt.GlobalColor.transparent) 16 | 17 | 18 | class QColormapItemDelegate(QStyledItemDelegate): 19 | """Delegate that draws colormaps into a QAbstractItemView item. 20 | 21 | Parameters 22 | ---------- 23 | parent : QObject, optional 24 | The parent object. 25 | item_size : QSize, optional 26 | The size hint for each item, by default QSize(80, 22). 27 | fractional_colormap_width : float, optional 28 | The fraction of the widget width to use for the colormap swatch. If the 29 | colormap is full width (greater than 0.75), the swatch will be drawn behind 30 | the text. Otherwise, the swatch will be drawn to the left of the text. 31 | Default is 0.33. 32 | padding : int, optional 33 | The padding (in pixels) around the edge of the item, by default 1. 34 | checkerboard_size : int, optional 35 | Size (in pixels) of the checkerboard pattern to draw behind colormaps with 36 | transparency, by default 4. If 0, no checkerboard is drawn. 37 | """ 38 | 39 | def __init__( 40 | self, 41 | parent: QObject | None = None, 42 | *, 43 | item_size: QSize = DEFAULT_SIZE, 44 | fractional_colormap_width: float = 1, 45 | padding: int = 1, 46 | checkerboard_size: int = 4, 47 | ) -> None: 48 | super().__init__(parent) 49 | self._item_size = item_size 50 | self._colormap_fraction = fractional_colormap_width 51 | self._padding = padding 52 | self._border_color: QColor | None = DEFAULT_BORDER_COLOR 53 | self._checkerboard_size = checkerboard_size 54 | 55 | def sizeHint( 56 | self, option: QStyleOptionViewItem, index: QModelIndex | QPersistentModelIndex 57 | ) -> QSize: 58 | return super().sizeHint(option, index).expandedTo(self._item_size) 59 | 60 | def paint( 61 | self, 62 | painter: QPainter, 63 | option: QStyleOptionViewItem, 64 | index: QModelIndex | QPersistentModelIndex, 65 | ) -> None: 66 | self.initStyleOption(option, index) 67 | rect = cast("QRect", option.rect) # type: ignore 68 | selected = option.state & QStyle.StateFlag.State_Selected # type: ignore 69 | text = index.data(Qt.ItemDataRole.DisplayRole) 70 | colormap: Colormap | None = index.data(CMAP_ROLE) or try_cast_colormap(text) 71 | 72 | if not colormap: # pragma: no cover 73 | return super().paint(painter, option, index) 74 | 75 | painter.save() 76 | rect.adjust(self._padding, self._padding, -self._padding, -self._padding) 77 | cmap_rect = QRect(rect) 78 | cmap_rect.setWidth(int(rect.width() * self._colormap_fraction)) 79 | 80 | lighter = 110 if selected else 100 81 | border = self._border_color if selected else None 82 | draw_colormap( 83 | painter, 84 | colormap, 85 | cmap_rect, 86 | lighter=lighter, 87 | border_color=border, 88 | checkerboard_size=self._checkerboard_size, 89 | ) 90 | 91 | # # make new rect with the remaining space 92 | text_rect = QRect(rect) 93 | 94 | if self._colormap_fraction > 0.75: 95 | text_align = Qt.AlignmentFlag.AlignCenter 96 | alpha = 230 if selected else 140 97 | text_color = pick_font_color(colormap, alpha=alpha) 98 | else: 99 | text_align = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter 100 | text_color = QColor(Qt.GlobalColor.black) 101 | text_rect.adjust( 102 | cmap_rect.width() + self._padding + 4, 0, -self._padding - 2, 0 103 | ) 104 | 105 | painter.setPen(text_color) 106 | # cast to int works all the way back to Qt 5.12... 107 | # but the enum only works since Qt 5.14 108 | painter.drawText(text_rect, int(text_align), text) 109 | painter.restore() 110 | -------------------------------------------------------------------------------- /src/superqt/collapsible/__init__.py: -------------------------------------------------------------------------------- 1 | from ._collapsible import QCollapsible 2 | 3 | __all__ = ["QCollapsible"] 4 | -------------------------------------------------------------------------------- /src/superqt/combobox/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Any 2 | 3 | from ._color_combobox import QColorComboBox 4 | from ._enum_combobox import QEnumComboBox 5 | from ._searchable_combo_box import QSearchableComboBox 6 | 7 | __all__ = ( 8 | "QColorComboBox", 9 | "QColormapComboBox", 10 | "QEnumComboBox", 11 | "QSearchableComboBox", 12 | ) 13 | 14 | 15 | if TYPE_CHECKING: 16 | from superqt.cmap import QColormapComboBox 17 | 18 | 19 | def __getattr__(name: str) -> Any: # pragma: no cover 20 | if name == "QColormapComboBox": 21 | from superqt.cmap import QColormapComboBox 22 | 23 | return QColormapComboBox 24 | raise AttributeError(f"module {__name__!r} has no attribute {name!r}") 25 | -------------------------------------------------------------------------------- /src/superqt/combobox/_enum_combobox.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from enum import Enum, EnumMeta, Flag 3 | from functools import reduce 4 | from itertools import combinations 5 | from operator import or_ 6 | from typing import Optional, TypeVar 7 | 8 | from qtpy.QtCore import Signal 9 | from qtpy.QtWidgets import QComboBox 10 | 11 | EnumType = TypeVar("EnumType", bound=Enum) 12 | 13 | 14 | NONE_STRING = "----" 15 | 16 | 17 | def _get_name(enum_value: Enum): 18 | """Create human readable name if user does not implement `__str__`.""" 19 | str_module = getattr(enum_value.__str__, "__module__", "enum") 20 | if str_module != "enum" and not str_module.startswith("shibokensupport"): 21 | # check if function was overloaded 22 | name = str(enum_value) 23 | else: 24 | if enum_value.name is None: 25 | # This is hack for python bellow 3.11 26 | if not isinstance(enum_value, Flag): 27 | raise TypeError( 28 | f"Expected Flag instance, got {enum_value}" 29 | ) # pragma: no cover 30 | if sys.version_info >= (3, 11): 31 | # There is a bug in some releases of Python 3.11 (for example 3.11.3) 32 | # that leads to wrong evaluation of or operation on Flag members 33 | # and produces numeric value without proper set name property. 34 | return f"{enum_value.value}" 35 | 36 | # Before python 3.11 there is no smart name set during 37 | # the creation of Flag members. 38 | # We needs to decompose the value to get the name. 39 | # It is under if condition because it uses private API. 40 | 41 | from enum import _decompose 42 | 43 | members, not_covered = _decompose(enum_value.__class__, enum_value.value) 44 | name = "|".join(m.name.replace("_", " ") for m in members[::-1]) 45 | else: 46 | name = enum_value.name.replace("_", " ") 47 | return name 48 | 49 | 50 | def _get_name_with_value(enum_value: Enum) -> tuple[str, Enum]: 51 | return _get_name(enum_value), enum_value 52 | 53 | 54 | class QEnumComboBox(QComboBox): 55 | """ComboBox presenting options from a python Enum. 56 | 57 | If the Enum class does not implement `__str__` then a human readable name 58 | is created from the name of the enum member, replacing underscores with spaces. 59 | """ 60 | 61 | currentEnumChanged = Signal(object) 62 | 63 | def __init__( 64 | self, parent=None, enum_class: Optional[EnumMeta] = None, allow_none=False 65 | ): 66 | super().__init__(parent) 67 | self._enum_class = None 68 | self._allow_none = False 69 | if enum_class is not None: 70 | self.setEnumClass(enum_class, allow_none) 71 | self.currentIndexChanged.connect(self._emit_signal) 72 | 73 | def setEnumClass(self, enum: Optional[EnumMeta], allow_none=False): 74 | """Set enum class from which members value should be selected.""" 75 | self.clear() 76 | self._enum_class = enum 77 | self._allow_none = allow_none and enum is not None 78 | if allow_none: 79 | super().addItem(NONE_STRING) 80 | names_ = self._get_enum_member_list(enum) 81 | super().addItems(list(names_)) 82 | 83 | @staticmethod 84 | def _get_enum_member_list(enum: Optional[EnumMeta]): 85 | if issubclass(enum, Flag): 86 | members = list(enum.__members__.values()) 87 | comb_list = [] 88 | for i in range(len(members)): 89 | comb_list.extend(reduce(or_, x) for x in combinations(members, i + 1)) 90 | 91 | else: 92 | comb_list = list(enum.__members__.values()) 93 | return dict(map(_get_name_with_value, comb_list)) 94 | 95 | def enumClass(self) -> Optional[EnumMeta]: 96 | """Return current Enum class.""" 97 | return self._enum_class 98 | 99 | def isOptional(self) -> bool: 100 | """Return if current enum is with optional annotation.""" 101 | return self._allow_none 102 | 103 | def clear(self): 104 | self._enum_class = None 105 | self._allow_none = False 106 | super().clear() 107 | 108 | def currentEnum(self) -> Optional[EnumType]: 109 | """Current value as Enum member.""" 110 | if self._enum_class is not None: 111 | if self._allow_none: 112 | if self.currentText() == NONE_STRING: 113 | return None 114 | return self._get_enum_member_list(self._enum_class)[self.currentText()] 115 | return None 116 | 117 | def setCurrentEnum(self, value: Optional[EnumType]) -> None: 118 | """Set value with Enum.""" 119 | if self._enum_class is None: 120 | raise RuntimeError( 121 | "Uninitialized enum class. Use `setEnumClass` before `setCurrentEnum`." 122 | ) 123 | if value is None and self._allow_none: 124 | self.setCurrentIndex(0) 125 | return 126 | if not isinstance(value, self._enum_class): 127 | raise TypeError( 128 | "setValue(self, Enum): argument 1 has unexpected type " 129 | f"{type(value).__name__!r}" 130 | ) 131 | self.setCurrentText(_get_name(value)) 132 | 133 | def _emit_signal(self): 134 | if self._enum_class is not None: 135 | self.currentEnumChanged.emit(self.currentEnum()) 136 | 137 | def insertItems(self, *_, **__): 138 | raise RuntimeError("EnumComboBox does not allow to insert items") 139 | 140 | def insertItem(self, *_, **__): 141 | raise RuntimeError("EnumComboBox does not allow to insert item") 142 | 143 | def addItems(self, *_, **__): 144 | raise RuntimeError("EnumComboBox does not allow to add items") 145 | 146 | def addItem(self, *_, **__): 147 | raise RuntimeError("EnumComboBox does not allow to add item") 148 | 149 | def setInsertPolicy(self, policy): 150 | raise RuntimeError("EnumComboBox does not allow to insert item") 151 | -------------------------------------------------------------------------------- /src/superqt/combobox/_searchable_combo_box.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from qtpy import QT_VERSION 4 | from qtpy.QtCore import Qt, Signal 5 | from qtpy.QtWidgets import QComboBox, QCompleter, QWidget 6 | 7 | try: 8 | is_qt_bellow_5_14 = tuple(int(x) for x in QT_VERSION.split(".")[:2]) < (5, 14) 9 | except ValueError: 10 | is_qt_bellow_5_14 = False 11 | 12 | 13 | class QSearchableComboBox(QComboBox): 14 | """ComboCox with completer for fast search in multiple options.""" 15 | 16 | if is_qt_bellow_5_14: 17 | textActivated = Signal(str) # pragma: no cover 18 | 19 | def __init__(self, parent: Optional[QWidget] = None): 20 | super().__init__(parent) 21 | self.setEditable(True) 22 | self.completer_object = QCompleter() 23 | self.completer_object.setCaseSensitivity(Qt.CaseInsensitive) 24 | self.completer_object.setCompletionMode(QCompleter.PopupCompletion) 25 | self.completer_object.setFilterMode(Qt.MatchContains) 26 | self.setCompleter(self.completer_object) 27 | self.setInsertPolicy(QComboBox.NoInsert) 28 | if is_qt_bellow_5_14: # pragma: no cover 29 | self.currentIndexChanged.connect(self._text_activated) 30 | 31 | def _text_activated(self): # pragma: no cover 32 | self.textActivated.emit(self.currentText()) 33 | 34 | def addItem(self, *args): 35 | super().addItem(*args) 36 | self.completer_object.setModel(self.model()) 37 | 38 | def addItems(self, *args): 39 | super().addItems(*args) 40 | self.completer_object.setModel(self.model()) 41 | 42 | def insertItem(self, *args) -> None: 43 | super().insertItem(*args) 44 | self.completer_object.setModel(self.model()) 45 | 46 | def insertItems(self, *args) -> None: 47 | super().insertItems(*args) 48 | self.completer_object.setModel(self.model()) 49 | -------------------------------------------------------------------------------- /src/superqt/elidable/__init__.py: -------------------------------------------------------------------------------- 1 | from ._eliding_label import QElidingLabel 2 | from ._eliding_line_edit import QElidingLineEdit 3 | 4 | __all__ = ["QElidingLabel", "QElidingLineEdit"] 5 | -------------------------------------------------------------------------------- /src/superqt/elidable/_eliding.py: -------------------------------------------------------------------------------- 1 | from qtpy.QtCore import Qt 2 | from qtpy.QtGui import QFont, QFontMetrics, QTextLayout 3 | 4 | 5 | class _GenericEliding: 6 | """A mixin to provide capabilities to elide text (could add '…') to fit width.""" 7 | 8 | _elide_mode: Qt.TextElideMode = Qt.TextElideMode.ElideRight 9 | _text: str = "" 10 | # the 2 is a magic number that prevents the ellipses from going missing 11 | # in certain cases (?) 12 | _ellipses_width: int = 2 13 | 14 | # Public methods 15 | 16 | def elideMode(self) -> Qt.TextElideMode: 17 | """The current Qt.TextElideMode.""" 18 | return self._elide_mode 19 | 20 | def setElideMode(self, mode: Qt.TextElideMode) -> None: 21 | """Set the elide mode to a Qt.TextElideMode.""" 22 | self._elide_mode = Qt.TextElideMode(mode) 23 | 24 | def full_text(self) -> str: 25 | """The current text without eliding.""" 26 | return self._text 27 | 28 | def setEllipsesWidth(self, width: int) -> None: 29 | """A width value to take into account ellipses width when eliding text. 30 | 31 | The value is deducted from the widget width when computing the elided version 32 | of the text. 33 | """ 34 | self._ellipses_width = width 35 | 36 | @staticmethod 37 | def wrapText(text, width, font=None) -> list[str]: 38 | """Returns `text`, split as it would be wrapped for `width`, given `font`. 39 | 40 | Static method. 41 | """ 42 | tl = QTextLayout(text, font or QFont()) 43 | tl.beginLayout() 44 | lines = [] 45 | while True: 46 | ln = tl.createLine() 47 | if not ln.isValid(): 48 | break 49 | ln.setLineWidth(width) 50 | start = ln.textStart() 51 | lines.append(text[start : start + ln.textLength()]) 52 | tl.endLayout() 53 | return lines 54 | 55 | # private implementation methods 56 | 57 | def _elidedText(self) -> str: 58 | """Return `self._text` elided to `width`.""" 59 | fm = QFontMetrics(self.font()) 60 | ellipses_width = 0 61 | if self._elide_mode != Qt.TextElideMode.ElideNone: 62 | ellipses_width = self._ellipses_width 63 | width = self.width() - ellipses_width 64 | if not getattr(self, "wordWrap", None) or not self.wordWrap(): 65 | return fm.elidedText(self._text, self._elide_mode, width) 66 | 67 | # get number of lines we can fit without eliding 68 | nlines = self.height() // fm.height() - 1 69 | # get the last line (elided) 70 | text = self._wrappedText() 71 | last_line = fm.elidedText("".join(text[nlines:]), self._elide_mode, width) 72 | # join them 73 | return "".join(text[:nlines] + [last_line]) 74 | 75 | def _wrappedText(self) -> list[str]: 76 | return _GenericEliding.wrapText(self._text, self.width(), self.font()) 77 | -------------------------------------------------------------------------------- /src/superqt/elidable/_eliding_label.py: -------------------------------------------------------------------------------- 1 | from qtpy.QtCore import QPoint, QRect, QSize, Qt 2 | from qtpy.QtGui import QFontMetrics, QResizeEvent 3 | from qtpy.QtWidgets import QLabel 4 | 5 | from ._eliding import _GenericEliding 6 | 7 | 8 | class QElidingLabel(_GenericEliding, QLabel): 9 | """ 10 | A QLabel variant that will elide text (could add '…') to fit width. 11 | 12 | QElidingLabel() 13 | QElidingLabel(parent: Optional[QWidget], f: Qt.WindowFlags = ...) 14 | QElidingLabel(text: str, parent: Optional[QWidget] = None, f: Qt.WindowFlags = ...) 15 | 16 | For a multiline eliding label, use `setWordWrap(True)`. In this case, text 17 | will wrap to fit the width, and only the last line will be elided. 18 | When `wordWrap()` is True, `sizeHint()` will return the size required to fit 19 | the full text. 20 | """ 21 | 22 | def __init__(self, *args, **kwargs) -> None: 23 | super().__init__(*args, **kwargs) 24 | if args and isinstance(args[0], str): 25 | self.setText(args[0]) 26 | 27 | # Reimplemented _GenericEliding methods 28 | 29 | def setElideMode(self, mode: Qt.TextElideMode) -> None: 30 | """Set the elide mode to a Qt.TextElideMode.""" 31 | super().setElideMode(mode) 32 | super().setText(self._elidedText()) 33 | 34 | def setEllipsesWidth(self, width: int) -> None: 35 | """A width value to take into account ellipses width when eliding text. 36 | 37 | The value is deducted from the widget width when computing the elided version 38 | of the text. 39 | """ 40 | super().setEllipsesWidth(width) 41 | super().setText(self._elidedText()) 42 | 43 | # Reimplemented QT methods 44 | 45 | def text(self) -> str: 46 | """Return the label's text. 47 | 48 | If no text has been set this will return an empty string. 49 | """ 50 | return self._text 51 | 52 | def setText(self, txt: str) -> None: 53 | """Set the label's text. 54 | 55 | Setting the text clears any previous content. 56 | NOTE: we set the QLabel private text to the elided version 57 | """ 58 | self._text = txt 59 | super().setText(self._elidedText()) 60 | 61 | def resizeEvent(self, event: QResizeEvent) -> None: 62 | event.accept() 63 | super().setText(self._elidedText()) 64 | 65 | def setWordWrap(self, wrap: bool) -> None: 66 | super().setWordWrap(wrap) 67 | super().setText(self._elidedText()) 68 | 69 | def sizeHint(self) -> QSize: 70 | if not self.wordWrap(): 71 | return super().sizeHint() 72 | fm = QFontMetrics(self.font()) 73 | flags = int(self.alignment() | Qt.TextFlag.TextWordWrap) 74 | r = fm.boundingRect(QRect(QPoint(0, 0), self.size()), flags, self._text) 75 | return QSize(self.width(), r.height()) 76 | 77 | def minimumSizeHint(self) -> QSize: 78 | # The smallest that self._elidedText can be is just the ellipsis. 79 | fm = QFontMetrics(self.font()) 80 | flags = int(self.alignment() | Qt.TextFlag.TextWordWrap) 81 | r = fm.boundingRect(QRect(QPoint(0, 0), self.size()), flags, "...") 82 | return QSize(r.width(), r.height()) 83 | -------------------------------------------------------------------------------- /src/superqt/elidable/_eliding_line_edit.py: -------------------------------------------------------------------------------- 1 | from qtpy.QtCore import Qt 2 | from qtpy.QtGui import QFocusEvent, QResizeEvent 3 | from qtpy.QtWidgets import QLineEdit 4 | 5 | from ._eliding import _GenericEliding 6 | 7 | 8 | class QElidingLineEdit(_GenericEliding, QLineEdit): 9 | """A QLineEdit variant that will elide text (could add '…') to fit width. 10 | 11 | QElidingLineEdit() 12 | QElidingLineEdit(parent: Optional[QWidget]) 13 | QElidingLineEdit(text: str, parent: Optional[QWidget] = None) 14 | 15 | """ 16 | 17 | def __init__(self, *args, **kwargs) -> None: 18 | super().__init__(*args, **kwargs) 19 | if args and isinstance(args[0], str): 20 | self.setText(args[0]) 21 | # The `textEdited` signal doesn't trigger the `textChanged` signal if 22 | # text is changed with `setText`, so we connect to `textEdited` to only 23 | # update _text when text is being edited by the user graphically. 24 | self.textEdited.connect(self._update_text) 25 | 26 | # Reimplemented _GenericEliding methods 27 | 28 | def setElideMode(self, mode: Qt.TextElideMode) -> None: 29 | """Set the elide mode to a Qt.TextElideMode. 30 | 31 | The text shown is updated to the elided version only if the widget is not 32 | focused. 33 | """ 34 | super().setElideMode(mode) 35 | if not self.hasFocus(): 36 | super().setText(self._elidedText()) 37 | 38 | def setEllipsesWidth(self, width: int) -> None: 39 | """A width value to take into account ellipses width when eliding text. 40 | 41 | The value is deducted from the widget width when computing the elided version 42 | of the text. The text shown is updated to the elided version only if the widget 43 | is not focused. 44 | """ 45 | super().setEllipsesWidth(width) 46 | if not self.hasFocus(): 47 | super().setText(self._elidedText()) 48 | 49 | # Reimplemented QT methods 50 | 51 | def text(self) -> str: 52 | """Return the label's text being shown. 53 | 54 | If no text has been set this will return an empty string. 55 | """ 56 | return self._text 57 | 58 | def setText(self, text) -> None: 59 | """Set the line edit's text. 60 | 61 | Setting the text clears any previous content. 62 | NOTE: we set the QLineEdit private text to the elided version 63 | """ 64 | self._text = text 65 | if not self.hasFocus(): 66 | super().setText(self._elidedText()) 67 | 68 | def focusInEvent(self, event: QFocusEvent) -> None: 69 | """Set the full text when the widget is focused.""" 70 | super().setText(self._text) 71 | super().focusInEvent(event) 72 | 73 | def focusOutEvent(self, event: QFocusEvent) -> None: 74 | """Set an elided version of the text (if needed) when the focus is out.""" 75 | super().setText(self._elidedText()) 76 | super().focusOutEvent(event) 77 | 78 | def resizeEvent(self, event: QResizeEvent) -> None: 79 | """Update elided text being shown when the widget is resized.""" 80 | if not self.hasFocus(): 81 | super().setText(self._elidedText()) 82 | super().resizeEvent(event) 83 | 84 | # private implementation methods 85 | 86 | def _update_text(self, text: str) -> None: 87 | """Update only the actual text of the widget. 88 | 89 | The actual text is the text the widget has without eliding. 90 | """ 91 | self._text = text 92 | -------------------------------------------------------------------------------- /src/superqt/fonticon/_animations.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Optional 3 | 4 | from qtpy.QtCore import QRectF, QTimer 5 | from qtpy.QtGui import QPainter 6 | from qtpy.QtWidgets import QWidget 7 | 8 | 9 | class Animation(ABC): 10 | """Base icon animation class.""" 11 | 12 | def __init__(self, parent_widget: QWidget, interval: int = 10, step: int = 1): 13 | self.parent_widget = parent_widget 14 | self.timer = QTimer() 15 | self.timer.timeout.connect(self._update) # type: ignore 16 | self.timer.setInterval(interval) 17 | self._angle = 0 18 | self._step = step 19 | 20 | def _update(self): 21 | if self.timer.isActive(): 22 | self._angle += self._step 23 | self.parent_widget.update() 24 | 25 | @abstractmethod 26 | def animate(self, painter: QPainter): 27 | """Setup and start the timer for the animation.""" 28 | 29 | 30 | class spin(Animation): 31 | """Animation that smoothly spins an icon.""" 32 | 33 | def animate(self, painter: QPainter): 34 | if not self.timer.isActive(): 35 | self.timer.start() 36 | 37 | mid = QRectF(painter.viewport()).center() 38 | painter.translate(mid) 39 | painter.rotate(self._angle % 360) 40 | painter.translate(-mid) 41 | 42 | 43 | class pulse(spin): 44 | """Animation that spins an icon in slower, discrete steps.""" 45 | 46 | def __init__(self, parent_widget: Optional[QWidget] = None): 47 | super().__init__(parent_widget, interval=200, step=45) 48 | -------------------------------------------------------------------------------- /src/superqt/fonticon/_iconfont.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Mapping 2 | from typing import Union 3 | 4 | FONTFILE_ATTR = "__font_file__" 5 | 6 | 7 | class IconFontMeta(type): 8 | """IconFont metaclass. 9 | 10 | This updates the value of all class attributes to be prefaced with the class 11 | name (lowercase), and makes sure that all values are valid characters. 12 | 13 | Examples 14 | -------- 15 | This metaclass turns the following class: 16 | 17 | class FA5S(metaclass=IconFontMeta): 18 | __font_file__ = 'path/to/font.otf' 19 | some_char = 0xfa42 20 | 21 | into this: 22 | 23 | class FA5S: 24 | __font_file__ = path/to/font.otf' 25 | some_char = 'fa5s.\ufa42' 26 | 27 | In usage, this means that someone could use `icon(FA5S.some_char)` (provided 28 | that the FA5S class/namespace has already been registered). This makes 29 | IDE attribute checking and autocompletion easier. 30 | """ 31 | 32 | __font_file__: str 33 | 34 | def __new__(cls, name, bases, namespace, **kwargs): 35 | # make sure this class provides the __font_file__ interface 36 | ff = namespace.get(FONTFILE_ATTR) 37 | if not (ff and isinstance(ff, (str, classmethod))): 38 | raise TypeError( 39 | f"Invalid Font: must declare {FONTFILE_ATTR!r} attribute or classmethod" 40 | ) 41 | 42 | # update all values to be `key.unicode` 43 | prefix = name.lower() 44 | for k, v in list(namespace.items()): 45 | if k.startswith("__"): 46 | continue 47 | char = chr(v) if isinstance(v, int) else v 48 | if len(char) != 1: 49 | raise TypeError( 50 | "Invalid Font: All fonts values must be a single " 51 | f"unicode char. ('{name}.{char}' has length {len(char)}). " 52 | "You may use unicode representations: like '\\uf641' or '0xf641'" 53 | ) 54 | namespace[k] = f"{prefix}.{char}" 55 | 56 | return super().__new__(cls, name, bases, namespace, **kwargs) 57 | 58 | 59 | class IconFont(metaclass=IconFontMeta): 60 | """Helper class that provides a standard way to create an IconFont. 61 | 62 | Examples 63 | -------- 64 | class FA5S(IconFont): 65 | __font_file__ = '...' 66 | some_char = 0xfa42 67 | """ 68 | 69 | __slots__ = () 70 | __font_file__ = "..." 71 | 72 | 73 | def namespace2font(namespace: Union[Mapping, type], name: str) -> type[IconFont]: 74 | """Convenience to convert a namespace (class, module, dict) into an IconFont.""" 75 | if isinstance(namespace, type): 76 | if not isinstance(getattr(namespace, FONTFILE_ATTR), str): 77 | raise TypeError( 78 | f"Invalid Font: must declare {FONTFILE_ATTR!r} attribute or classmethod" 79 | ) 80 | return namespace 81 | elif hasattr(namespace, "__dict__"): 82 | ns = dict(namespace.__dict__) 83 | else: 84 | raise ValueError( 85 | "namespace must be a mapping or an object with __dict__ attribute." 86 | ) 87 | if not str.isidentifier(name): 88 | raise ValueError(f"name {name!r} is not a valid identifier.") 89 | return type(name, (IconFont,), ns) 90 | -------------------------------------------------------------------------------- /src/superqt/fonticon/_plugins.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | from typing import ClassVar 3 | 4 | from ._iconfont import IconFontMeta, namespace2font 5 | 6 | try: 7 | from importlib.metadata import EntryPoint, entry_points 8 | except ImportError: 9 | from importlib_metadata import EntryPoint, entry_points # type: ignore 10 | 11 | 12 | class FontIconManager: 13 | ENTRY_POINT: ClassVar[str] = "superqt.fonticon" 14 | _PLUGINS: ClassVar[dict[str, EntryPoint]] = {} 15 | _LOADED: ClassVar[dict[str, IconFontMeta]] = {} 16 | _BLOCKED: ClassVar[set[EntryPoint]] = set() 17 | 18 | def _discover_fonts(self) -> None: 19 | self._PLUGINS.clear() 20 | entries = entry_points() 21 | if hasattr(entries, "select"): # python>3.10 22 | _entries = entries.select(group=self.ENTRY_POINT) # type: ignore 23 | else: 24 | _entries = entries.get(self.ENTRY_POINT, []) 25 | for ep in _entries: 26 | if ep not in self._BLOCKED: 27 | self._PLUGINS[ep.name] = ep 28 | 29 | def _get_font_class(self, key: str) -> IconFontMeta: 30 | """Get IconFont given a key. 31 | 32 | Parameters 33 | ---------- 34 | key : str 35 | font key to load. 36 | 37 | Returns 38 | ------- 39 | IconFontMeta 40 | Instance of IconFontMeta 41 | 42 | Raises 43 | ------ 44 | KeyError 45 | If no plugin provides this key 46 | ImportError 47 | If a plugin provides the key, but the entry point doesn't load 48 | TypeError 49 | If the entry point loads, but is not an IconFontMeta 50 | """ 51 | if key not in self._LOADED: 52 | # get the entrypoint 53 | if key not in self._PLUGINS: 54 | self._discover_fonts() 55 | ep = self._PLUGINS.get(key) 56 | if ep is None: 57 | raise KeyError(f"No plugin provides the key {key!r}") 58 | 59 | # load the entry point 60 | try: 61 | font = ep.load() 62 | except Exception as e: 63 | self._PLUGINS.pop(key) 64 | self._BLOCKED.add(ep) 65 | raise ImportError(f"Failed to load {ep.value}. Plugin blocked") from e 66 | 67 | # make sure it's a proper IconFont 68 | try: 69 | self._LOADED[key] = namespace2font(font, ep.name.upper()) 70 | except Exception as e: 71 | self._PLUGINS.pop(key) 72 | self._BLOCKED.add(ep) 73 | raise TypeError( 74 | f"Failed to create fonticon from {ep.value}: {e}" 75 | ) from e 76 | return self._LOADED[key] 77 | 78 | def dict(self) -> dict: 79 | return { 80 | key: sorted(filter(lambda x: not x.startswith("_"), cls.__dict__)) 81 | for key, cls in self._LOADED.items() 82 | } 83 | 84 | 85 | _manager = FontIconManager() 86 | get_font_class = _manager._get_font_class 87 | 88 | 89 | def discover() -> tuple[str]: 90 | _manager._discover_fonts() 91 | 92 | 93 | def available() -> tuple[str]: 94 | return tuple(_manager._PLUGINS) 95 | 96 | 97 | def loaded(load_all=False) -> dict[str, list[str]]: 98 | if load_all: 99 | discover() 100 | for x in available(): 101 | with contextlib.suppress(Exception): 102 | _manager._get_font_class(x) 103 | return { 104 | key: sorted(filter(lambda x: not x.startswith("_"), cls.__dict__)) 105 | for key, cls in _manager._LOADED.items() 106 | } 107 | -------------------------------------------------------------------------------- /src/superqt/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyapp-kit/superqt/a9fa7205770a8721887e1810461c5a918fd8190d/src/superqt/py.typed -------------------------------------------------------------------------------- /src/superqt/qtcompat/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import warnings 3 | from importlib import abc, util 4 | 5 | from qtpy import * # noqa 6 | 7 | warnings.warn( 8 | "The superqt.qtcompat module is deprecated as of v0.3.0. " 9 | "Please import from `qtpy` instead.", 10 | stacklevel=2, 11 | ) 12 | 13 | 14 | # forward any requests for superqt.qtcompat.* to qtpy.* 15 | class SuperQtImporter(abc.MetaPathFinder): 16 | """Pseudo-importer to forward superqt.qtcompat.* to qtpy.*.""" 17 | 18 | def find_spec(self, fullname: str, path, target=None): # type: ignore 19 | """Forward any requests for superqt.qtcompat.* to qtpy.*.""" 20 | if fullname.startswith(__name__): 21 | return util.find_spec(fullname.replace(__name__, "qtpy")) 22 | 23 | 24 | sys.meta_path.append(SuperQtImporter()) 25 | -------------------------------------------------------------------------------- /src/superqt/selection/__init__.py: -------------------------------------------------------------------------------- 1 | from ._searchable_list_widget import QSearchableListWidget 2 | from ._searchable_tree_widget import QSearchableTreeWidget 3 | 4 | __all__ = ("QSearchableListWidget", "QSearchableTreeWidget") 5 | -------------------------------------------------------------------------------- /src/superqt/selection/_searchable_list_widget.py: -------------------------------------------------------------------------------- 1 | from qtpy.QtCore import Qt 2 | from qtpy.QtWidgets import QLineEdit, QListWidget, QVBoxLayout, QWidget 3 | 4 | 5 | class QSearchableListWidget(QWidget): 6 | def __init__(self, parent=None): 7 | super().__init__(parent) 8 | 9 | self.list_widget = QListWidget() 10 | 11 | self.filter_widget = QLineEdit() 12 | self.filter_widget.textChanged.connect(self.update_visible) 13 | 14 | layout = QVBoxLayout() 15 | layout.addWidget(self.filter_widget) 16 | layout.addWidget(self.list_widget) 17 | self.setLayout(layout) 18 | 19 | def __getattr__(self, item): 20 | if hasattr(self.list_widget, item): 21 | return getattr(self.list_widget, item) 22 | return super().__getattr__(item) 23 | 24 | def update_visible(self, text): 25 | items_text = [ 26 | x.text() for x in self.list_widget.findItems(text, Qt.MatchContains) 27 | ] 28 | for index in range(self.list_widget.count()): 29 | item = self.item(index) 30 | item.setHidden(item.text() not in items_text) 31 | 32 | def addItems(self, *args): 33 | self.list_widget.addItems(*args) 34 | self.update_visible(self.filter_widget.text()) 35 | 36 | def addItem(self, *args): 37 | self.list_widget.addItem(*args) 38 | self.update_visible(self.filter_widget.text()) 39 | 40 | def insertItems(self, *args): 41 | self.list_widget.insertItems(*args) 42 | self.update_visible(self.filter_widget.text()) 43 | 44 | def insertItem(self, *args): 45 | self.list_widget.insertItem(*args) 46 | self.update_visible(self.filter_widget.text()) 47 | -------------------------------------------------------------------------------- /src/superqt/selection/_searchable_tree_widget.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from collections.abc import Iterable, Mapping 3 | from typing import Any 4 | 5 | from qtpy.QtCore import QRegularExpression 6 | from qtpy.QtWidgets import QLineEdit, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget 7 | 8 | 9 | class QSearchableTreeWidget(QWidget): 10 | """A tree widget for showing a mapping that can be searched by key. 11 | 12 | This is intended to be used with a read-only mapping and be conveniently 13 | created using `QSearchableTreeWidget.fromData(data)`. 14 | If the mapping changes, the easiest way to update this is by calling `setData`. 15 | 16 | The tree can be searched by entering a regular expression pattern 17 | into the `filter` line edit. An item is only shown if its, any of its ancestors', 18 | or any of its descendants' keys or values match this pattern. 19 | The regular expression follows the conventions described by the Qt docs: 20 | https://doc.qt.io/qt-6/qregularexpression.html#details 21 | 22 | Attributes 23 | ---------- 24 | tree : QTreeWidget 25 | Shows the mapping as a tree of items. 26 | filter : QLineEdit 27 | Used to filter items in the tree by matching their key against a 28 | regular expression. 29 | """ 30 | 31 | def __init__(self, parent=None): 32 | super().__init__(parent) 33 | 34 | self.tree: QTreeWidget = QTreeWidget(self) 35 | self.tree.setHeaderLabels(("Key", "Value")) 36 | 37 | self.filter: QLineEdit = QLineEdit(self) 38 | self.filter.setClearButtonEnabled(True) 39 | self.filter.textChanged.connect(self._updateVisibleItems) 40 | 41 | layout = QVBoxLayout(self) 42 | layout.addWidget(self.filter) 43 | layout.addWidget(self.tree) 44 | 45 | def setData(self, data: Mapping) -> None: 46 | """Update the mapping data shown by the tree.""" 47 | self.tree.clear() 48 | self.filter.clear() 49 | top_level_items = [_make_item(name=k, value=v) for k, v in data.items()] 50 | self.tree.addTopLevelItems(top_level_items) 51 | 52 | def _updateVisibleItems(self, pattern: str) -> None: 53 | """Recursively update the visibility of items based on the given pattern.""" 54 | expression = QRegularExpression(pattern) 55 | for i in range(self.tree.topLevelItemCount()): 56 | top_level_item = self.tree.topLevelItem(i) 57 | _update_visible_items(top_level_item, expression) 58 | 59 | @classmethod 60 | def fromData( 61 | cls, data: Mapping, *, parent: QWidget = None 62 | ) -> "QSearchableTreeWidget": 63 | """Make a searchable tree widget from a mapping.""" 64 | widget = cls(parent) 65 | widget.setData(data) 66 | return widget 67 | 68 | 69 | def _make_item(*, name: str, value: Any) -> QTreeWidgetItem: 70 | """Make a tree item where the name and value are two columns. 71 | 72 | Iterable values other than strings are recursively traversed to 73 | add child items and build a tree. In this case, mappings use keys 74 | as their names whereas other iterables use their enumerated index. 75 | """ 76 | if isinstance(value, Mapping): 77 | item = QTreeWidgetItem([name, type(value).__name__]) 78 | for k, v in value.items(): 79 | child = _make_item(name=k, value=v) 80 | item.addChild(child) 81 | elif isinstance(value, Iterable) and not isinstance(value, str): 82 | item = QTreeWidgetItem([name, type(value).__name__]) 83 | for i, v in enumerate(value): 84 | child = _make_item(name=str(i), value=v) 85 | item.addChild(child) 86 | else: 87 | item = QTreeWidgetItem([name, str(value)]) 88 | logging.debug("_make_item: %s, %s, %s", item.text(0), item.text(1), item.flags()) 89 | return item 90 | 91 | 92 | def _update_visible_items( 93 | item: QTreeWidgetItem, expression: QRegularExpression, ancestor_match: bool = False 94 | ) -> bool: 95 | """Recursively update the visibility of a tree item based on an expression. 96 | 97 | An item is visible if any of its, any of its ancestors', or any of its descendants' 98 | column's text matches the expression. 99 | Returns True if the item is visible, False otherwise. 100 | """ 101 | match = ancestor_match or any( 102 | expression.match(item.text(i)).hasMatch() for i in range(item.columnCount()) 103 | ) 104 | visible = match 105 | for i in range(item.childCount()): 106 | child = item.child(i) 107 | descendant_visible = _update_visible_items(child, expression, match) 108 | visible = visible or descendant_visible 109 | item.setHidden(not visible) 110 | logging.debug( 111 | "_update_visible_items: %s, %s", 112 | tuple(item.text(i) for i in range(item.columnCount())), 113 | visible, 114 | ) 115 | return visible 116 | -------------------------------------------------------------------------------- /src/superqt/sliders/__init__.py: -------------------------------------------------------------------------------- 1 | from ._labeled import ( 2 | QLabeledDoubleRangeSlider, 3 | QLabeledDoubleSlider, 4 | QLabeledRangeSlider, 5 | QLabeledSlider, 6 | ) 7 | from ._range_style import MONTEREY_SLIDER_STYLES_FIX 8 | from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider 9 | 10 | __all__ = [ 11 | "MONTEREY_SLIDER_STYLES_FIX", 12 | "QDoubleRangeSlider", 13 | "QDoubleSlider", 14 | "QLabeledDoubleRangeSlider", 15 | "QLabeledDoubleSlider", 16 | "QLabeledRangeSlider", 17 | "QLabeledSlider", 18 | "QRangeSlider", 19 | ] 20 | -------------------------------------------------------------------------------- /src/superqt/sliders/_sliders.py: -------------------------------------------------------------------------------- 1 | from qtpy.QtCore import Signal 2 | 3 | from ._generic_range_slider import _GenericRangeSlider 4 | from ._generic_slider import _GenericSlider 5 | 6 | 7 | class _IntMixin: 8 | def __init__(self, *args, **kwargs): 9 | super().__init__(*args, **kwargs) 10 | self._singleStep = 1 11 | 12 | def _type_cast(self, value) -> int: 13 | return round(value) 14 | 15 | 16 | class _FloatMixin: 17 | def __init__(self, *args, **kwargs): 18 | super().__init__(*args, **kwargs) 19 | self._singleStep = 0.01 20 | self._pageStep = 0.1 21 | 22 | def _type_cast(self, value) -> float: 23 | return float(value) 24 | 25 | 26 | class QDoubleSlider(_FloatMixin, _GenericSlider): 27 | pass 28 | 29 | 30 | class QIntSlider(_IntMixin, _GenericSlider): 31 | # mostly just an example... use QSlider instead. 32 | valueChanged = Signal(int) 33 | 34 | 35 | class QRangeSlider(_IntMixin, _GenericRangeSlider): 36 | pass 37 | 38 | 39 | class QDoubleRangeSlider(_FloatMixin, QRangeSlider): 40 | def _rename_signals(self) -> None: 41 | super()._rename_signals() 42 | self.rangeChanged = self.frangeChanged 43 | 44 | 45 | # QRangeSlider.__doc__ += "\n" + textwrap.indent(QSlider.__doc__, " ") 46 | -------------------------------------------------------------------------------- /src/superqt/spinbox/__init__.py: -------------------------------------------------------------------------------- 1 | from ._intspin import QLargeIntSpinBox 2 | 3 | __all__ = ["QLargeIntSpinBox"] 4 | -------------------------------------------------------------------------------- /src/superqt/switch/__init__.py: -------------------------------------------------------------------------------- 1 | from superqt.switch._toggle_switch import QStyleOptionToggleSwitch, QToggleSwitch 2 | 3 | __all__ = ["QStyleOptionToggleSwitch", "QToggleSwitch"] 4 | -------------------------------------------------------------------------------- /src/superqt/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Any 2 | 3 | if TYPE_CHECKING: 4 | from superqt.cmap import draw_colormap 5 | 6 | __all__ = ( 7 | "CodeSyntaxHighlight", 8 | "FunctionWorker", 9 | "GeneratorWorker", 10 | "QFlowLayout", 11 | "QMessageHandler", 12 | "QSignalDebouncer", 13 | "QSignalThrottler", 14 | "WorkerBase", 15 | "create_worker", 16 | "draw_colormap", 17 | "ensure_main_thread", 18 | "ensure_object_thread", 19 | "exceptions_as_dialog", 20 | "new_worker_qthread", 21 | "qdebounced", 22 | "qimage_to_array", 23 | "qthrottled", 24 | "signals_blocked", 25 | "thread_worker", 26 | ) 27 | 28 | from ._code_syntax_highlight import CodeSyntaxHighlight 29 | from ._ensure_thread import ensure_main_thread, ensure_object_thread 30 | from ._errormsg_context import exceptions_as_dialog 31 | from ._flow_layout import QFlowLayout 32 | from ._img_utils import qimage_to_array 33 | from ._message_handler import QMessageHandler 34 | from ._misc import signals_blocked 35 | from ._qthreading import ( 36 | FunctionWorker, 37 | GeneratorWorker, 38 | WorkerBase, 39 | create_worker, 40 | new_worker_qthread, 41 | thread_worker, 42 | ) 43 | from ._throttler import QSignalDebouncer, QSignalThrottler, qdebounced, qthrottled 44 | 45 | 46 | def __getattr__(name: str) -> Any: # pragma: no cover 47 | if name == "draw_colormap": 48 | from superqt.cmap import draw_colormap 49 | 50 | return draw_colormap 51 | raise AttributeError(f"module {__name__!r} has no attribute {name!r}") 52 | -------------------------------------------------------------------------------- /src/superqt/utils/_ensure_thread.py: -------------------------------------------------------------------------------- 1 | # https://gist.github.com/FlorianRhiem/41a1ad9b694c14fb9ac3 2 | from __future__ import annotations 3 | 4 | from concurrent.futures import Future 5 | from contextlib import suppress 6 | from functools import wraps 7 | from typing import TYPE_CHECKING, Any, Callable, ClassVar, overload 8 | 9 | from qtpy.QtCore import ( 10 | QCoreApplication, 11 | QMetaObject, 12 | QObject, 13 | Qt, 14 | QThread, 15 | Signal, 16 | Slot, 17 | ) 18 | 19 | from ._util import get_max_args 20 | 21 | if TYPE_CHECKING: 22 | from typing import TypeVar 23 | 24 | from typing_extensions import Literal, ParamSpec 25 | 26 | P = ParamSpec("P") 27 | R = TypeVar("R") 28 | 29 | 30 | class CallCallable(QObject): 31 | finished = Signal(object) 32 | instances: ClassVar[list[CallCallable]] = [] 33 | 34 | def __init__(self, callable: Callable, args: tuple, kwargs: dict): 35 | super().__init__() 36 | self._callable = callable 37 | self._args = args 38 | self._kwargs = kwargs 39 | CallCallable.instances.append(self) 40 | 41 | @Slot() 42 | def call(self): 43 | CallCallable.instances.remove(self) 44 | res = self._callable(*self._args, **self._kwargs) 45 | with suppress(RuntimeError): 46 | self.finished.emit(res) 47 | 48 | 49 | # fmt: off 50 | @overload 51 | def ensure_main_thread( 52 | await_return: Literal[True], 53 | timeout: int = 1000, 54 | ) -> Callable[[Callable[P, R]], Callable[P, R]]: ... 55 | @overload 56 | def ensure_main_thread( 57 | func: Callable[P, R], 58 | await_return: Literal[True], 59 | timeout: int = 1000, 60 | ) -> Callable[P, R]: ... 61 | @overload 62 | def ensure_main_thread( 63 | await_return: Literal[False] = False, 64 | timeout: int = 1000, 65 | ) -> Callable[[Callable[P, R]], Callable[P, Future[R]]]: ... 66 | @overload 67 | def ensure_main_thread( 68 | func: Callable[P, R], 69 | await_return: Literal[False] = False, 70 | timeout: int = 1000, 71 | ) -> Callable[P, Future[R]]: ... 72 | # fmt: on 73 | def ensure_main_thread( 74 | func: Callable | None = None, await_return: bool = False, timeout: int = 1000 75 | ): 76 | """Decorator that ensures a function is called in the main QApplication thread. 77 | 78 | It can be applied to functions or methods. 79 | 80 | Parameters 81 | ---------- 82 | func : callable 83 | The method to decorate, must be a method on a QObject. 84 | await_return : bool, optional 85 | Whether to block and wait for the result of the function, or return immediately. 86 | by default False 87 | timeout : int, optional 88 | If `await_return` is `True`, time (in milliseconds) to wait for the result 89 | before raising a TimeoutError, by default 1000 90 | """ 91 | 92 | def _out_func(func_): 93 | max_args = get_max_args(func_) 94 | 95 | @wraps(func_) 96 | def _func(*args, _max_args_=max_args, **kwargs): 97 | return _run_in_thread( 98 | func_, 99 | QCoreApplication.instance().thread(), 100 | await_return, 101 | timeout, 102 | args[:_max_args_], 103 | kwargs, 104 | ) 105 | 106 | return _func 107 | 108 | return _out_func if func is None else _out_func(func) 109 | 110 | 111 | # fmt: off 112 | @overload 113 | def ensure_object_thread( 114 | await_return: Literal[True], 115 | timeout: int = 1000, 116 | ) -> Callable[[Callable[P, R]], Callable[P, R]]: ... 117 | @overload 118 | def ensure_object_thread( 119 | func: Callable[P, R], 120 | await_return: Literal[True], 121 | timeout: int = 1000, 122 | ) -> Callable[P, R]: ... 123 | @overload 124 | def ensure_object_thread( 125 | await_return: Literal[False] = False, 126 | timeout: int = 1000, 127 | ) -> Callable[[Callable[P, R]], Callable[P, Future[R]]]: ... 128 | @overload 129 | def ensure_object_thread( 130 | func: Callable[P, R], 131 | await_return: Literal[False] = False, 132 | timeout: int = 1000, 133 | ) -> Callable[P, Future[R]]: ... 134 | # fmt: on 135 | def ensure_object_thread( 136 | func: Callable | None = None, await_return: bool = False, timeout: int = 1000 137 | ): 138 | """Decorator that ensures a QObject method is called in the object's thread. 139 | 140 | It must be applied to methods of QObjects subclasses. 141 | 142 | Parameters 143 | ---------- 144 | func : callable 145 | The method to decorate, must be a method on a QObject. 146 | await_return : bool, optional 147 | Whether to block and wait for the result of the function, or return immediately. 148 | by default False 149 | timeout : int, optional 150 | If `await_return` is `True`, time (in milliseconds) to wait for the result 151 | before raising a TimeoutError, by default 1000 152 | """ 153 | 154 | def _out_func(func_): 155 | max_args = get_max_args(func_) 156 | 157 | @wraps(func_) 158 | def _func(*args, _max_args_=max_args, **kwargs): 159 | thread = args[0].thread() # self 160 | return _run_in_thread( 161 | func_, thread, await_return, timeout, args[:_max_args_], kwargs 162 | ) 163 | 164 | return _func 165 | 166 | return _out_func if func is None else _out_func(func) 167 | 168 | 169 | def _run_in_thread( 170 | func: Callable, 171 | thread: QThread, 172 | await_return: bool, 173 | timeout: int, 174 | args: tuple, 175 | kwargs: dict, 176 | ) -> Any: 177 | future = Future() # type: ignore 178 | if thread is QThread.currentThread(): 179 | result = func(*args, **kwargs) 180 | if not await_return: 181 | future.set_result(result) 182 | return future 183 | return result 184 | 185 | f = CallCallable(func, args, kwargs) 186 | f.moveToThread(thread) 187 | f.finished.connect(future.set_result, Qt.ConnectionType.DirectConnection) 188 | QMetaObject.invokeMethod(f, "call", Qt.ConnectionType.QueuedConnection) # type: ignore 189 | return future.result(timeout=timeout / 1000) if await_return else future 190 | -------------------------------------------------------------------------------- /src/superqt/utils/_img_utils.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import TYPE_CHECKING 3 | 4 | from qtpy.QtGui import QImage 5 | 6 | if TYPE_CHECKING: 7 | import numpy as np 8 | 9 | 10 | def qimage_to_array(img: QImage) -> "np.ndarray": 11 | """Convert QImage to an array. 12 | 13 | Parameters 14 | ---------- 15 | img : QImage 16 | QImage to be converted. 17 | 18 | Returns 19 | ------- 20 | arr : np.ndarray 21 | Numpy array of type uint8 and shape (h, w, 4). Index [0, 0] is the 22 | upper-left corner of the rendered region. 23 | """ 24 | import numpy as np 25 | 26 | # cast to ARGB32 if necessary 27 | if img.format() != QImage.Format.Format_ARGB32: 28 | img = img.convertToFormat(QImage.Format.Format_ARGB32) 29 | 30 | h, w, c = img.height(), img.width(), 4 31 | 32 | # pyside returns a memoryview, pyqt returns a sizeless void pointer 33 | b = img.constBits() # Returns a pointer to the first pixel data. 34 | if hasattr(b, "setsize"): 35 | b.setsize(h * w * c) 36 | 37 | # reshape to h, w, c 38 | arr = np.frombuffer(b, np.uint8).reshape(h, w, c) 39 | 40 | # reverse channel colors for numpy 41 | # On big endian we need to specify a different order 42 | if sys.byteorder == "big": 43 | return arr.take([1, 2, 3, 0], axis=2) # pragma: no cover 44 | else: 45 | return arr.take([2, 1, 0, 3], axis=2) 46 | -------------------------------------------------------------------------------- /src/superqt/utils/_message_handler.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from contextlib import suppress 5 | from typing import ClassVar, NamedTuple 6 | 7 | from qtpy.QtCore import QMessageLogContext, QtMsgType, qInstallMessageHandler 8 | 9 | 10 | class Record(NamedTuple): 11 | level: int 12 | message: str 13 | ctx: dict 14 | 15 | 16 | class QMessageHandler: 17 | """A context manager to intercept messages from Qt. 18 | 19 | Parameters 20 | ---------- 21 | logger : logging.Logger, optional 22 | If provided, intercepted messages will be logged with `logger` at the 23 | corresponding python log level, by default None 24 | 25 | Attributes 26 | ---------- 27 | records: list of tuple 28 | Captured messages. This is a 3-tuple of: 29 | `(log_level: int, message: str, context: dict)` 30 | 31 | Examples 32 | -------- 33 | >>> handler = QMessageHandler() 34 | >>> handler.install() # now all Qt output will be available at mh.records 35 | 36 | >>> with QMessageHandler() as handler: # temporarily install 37 | ... ... 38 | 39 | >>> logger = logging.getLogger(__name__) 40 | >>> with QMessageHandler(logger): # re-reoute Qt messages to a python logger. 41 | ... ... 42 | """ 43 | 44 | _qt2loggertype: ClassVar[dict[QtMsgType, int]] = { 45 | QtMsgType.QtDebugMsg: logging.DEBUG, 46 | QtMsgType.QtInfoMsg: logging.INFO, 47 | QtMsgType.QtWarningMsg: logging.WARNING, 48 | QtMsgType.QtCriticalMsg: logging.ERROR, # note 49 | QtMsgType.QtFatalMsg: logging.CRITICAL, # note 50 | QtMsgType.QtSystemMsg: logging.CRITICAL, 51 | } 52 | 53 | def __init__(self, logger: logging.Logger | None = None): 54 | self.records: list[Record] = [] 55 | self._logger = logger 56 | self._previous_handler: object | None = "__uninstalled__" 57 | 58 | def install(self): 59 | """Install this handler (override the current QtMessageHandler).""" 60 | self._previous_handler = qInstallMessageHandler(self) 61 | 62 | def uninstall(self): 63 | """Uninstall this handler, restoring the previous handler.""" 64 | if self._previous_handler != "__uninstalled__": 65 | qInstallMessageHandler(self._previous_handler) 66 | 67 | def __repr__(self): 68 | n = type(self).__name__ 69 | return f"<{n} object at {hex(id(self))} with {len(self.records)} records>" 70 | 71 | def __enter__(self): 72 | """Enter a context with this handler installed.""" 73 | self.install() 74 | return self 75 | 76 | def __exit__(self, *args): 77 | self.uninstall() 78 | 79 | def __call__(self, msgtype: QtMsgType, context: QMessageLogContext, message: str): 80 | level = self._qt2loggertype[msgtype] 81 | 82 | # PyQt seems to throw an error if these are simply empty 83 | ctx = dict.fromkeys(["category", "file", "function", "line"]) 84 | with suppress(UnicodeDecodeError): 85 | ctx["category"] = context.category 86 | with suppress(UnicodeDecodeError): 87 | ctx["file"] = context.file 88 | with suppress(UnicodeDecodeError): 89 | ctx["function"] = context.function 90 | with suppress(UnicodeDecodeError): 91 | ctx["line"] = context.line 92 | 93 | self.records.append(Record(level, message, ctx)) 94 | if self._logger is not None: 95 | self._logger.log(level, message, extra=ctx) 96 | -------------------------------------------------------------------------------- /src/superqt/utils/_misc.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterator 2 | from contextlib import contextmanager 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from qtpy.QtCore import QObject 7 | 8 | 9 | @contextmanager 10 | def signals_blocked(obj: "QObject") -> Iterator[None]: 11 | """Context manager to temporarily block signals emitted by QObject: `obj`. 12 | 13 | Parameters 14 | ---------- 15 | obj : QObject 16 | The QObject whose signals should be blocked. 17 | 18 | Examples 19 | -------- 20 | ```python 21 | from qtpy.QtWidgets import QSpinBox 22 | from superqt import signals_blocked 23 | 24 | spinbox = QSpinBox() 25 | with signals_blocked(spinbox): 26 | spinbox.setValue(10) 27 | ``` 28 | """ 29 | previous = obj.blockSignals(True) 30 | try: 31 | yield 32 | finally: 33 | obj.blockSignals(previous) 34 | -------------------------------------------------------------------------------- /src/superqt/utils/_util.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from inspect import signature 4 | from typing import Callable 5 | 6 | 7 | def get_max_args(func: Callable) -> int | None: 8 | """Return the maximum number of positional arguments that func can accept.""" 9 | if not callable(func): 10 | raise TypeError(f"{func!r} is not callable") 11 | 12 | try: 13 | sig = signature(func) 14 | except Exception: 15 | return None 16 | 17 | max_args = 0 18 | for param in sig.parameters.values(): 19 | if param.kind == param.VAR_POSITIONAL: 20 | return None 21 | if param.kind in {param.POSITIONAL_ONLY, param.POSITIONAL_OR_KEYWORD}: 22 | max_args += 1 23 | return max_args 24 | -------------------------------------------------------------------------------- /tests/test_cmap.py: -------------------------------------------------------------------------------- 1 | import platform 2 | from unittest.mock import patch 3 | 4 | import numpy as np 5 | import pytest 6 | from qtpy import API_NAME 7 | 8 | try: 9 | from cmap import Colormap 10 | except ImportError: 11 | pytest.skip("cmap not installed", allow_module_level=True) 12 | 13 | from qtpy.QtCore import QRect 14 | from qtpy.QtGui import QPainter, QPixmap 15 | from qtpy.QtWidgets import QStyleOptionViewItem, QWidget 16 | 17 | from superqt import QColormapComboBox 18 | from superqt.cmap import ( 19 | CmapCatalogComboBox, 20 | QColormapItemDelegate, 21 | QColormapLineEdit, 22 | _cmap_combo, 23 | draw_colormap, 24 | ) 25 | from superqt.utils import qimage_to_array 26 | 27 | 28 | def test_draw_cmap(qtbot): 29 | # draw into a QWidget 30 | wdg = QWidget() 31 | qtbot.addWidget(wdg) 32 | draw_colormap(wdg, "viridis") 33 | # draw into any QPaintDevice 34 | draw_colormap(QPixmap(), "viridis") 35 | # pass a painter an explicit colormap and a rect 36 | draw_colormap(QPainter(), Colormap(("red", "yellow", "blue")), QRect()) 37 | # test with a border 38 | draw_colormap(wdg, "viridis", border_color="red", border_width=2) 39 | 40 | with pytest.raises(TypeError, match="Expected a QPainter or QPaintDevice instance"): 41 | draw_colormap(QRect(), "viridis") # type: ignore 42 | 43 | with pytest.raises(TypeError, match="Expected a Colormap instance or something"): 44 | draw_colormap(QPainter(), "not a recognized string or cmap", QRect()) 45 | 46 | 47 | def test_cmap_draw_result(): 48 | """Test that the image drawn actually looks correct.""" 49 | # draw into any QPaintDevice 50 | w = 100 51 | h = 20 52 | pix = QPixmap(w, h) 53 | cmap = Colormap("viridis") 54 | draw_colormap(pix, cmap) 55 | 56 | ary1 = cmap(np.tile(np.linspace(0, 1, w), (h, 1)), bytes=True) 57 | ary2 = qimage_to_array(pix.toImage()) 58 | 59 | # there are some subtle differences between how qimage draws and how 60 | # cmap draws, so we can't assert that the arrays are exactly equal. 61 | # they are visually indistinguishable, and numbers are close within 4 (/255) values 62 | # and linux, for some reason, is a bit more different`` 63 | atol = 8 if platform.system() == "Linux" else 4 64 | np.testing.assert_allclose(ary1, ary2, atol=atol) 65 | 66 | cmap2 = Colormap(("#230777",), name="MyMap") 67 | draw_colormap(pix, cmap2) # include transparency 68 | 69 | 70 | def test_catalog_combo(qtbot): 71 | wdg = CmapCatalogComboBox() 72 | qtbot.addWidget(wdg) 73 | wdg.show() 74 | 75 | wdg.setCurrentText("viridis") 76 | assert wdg.currentColormap() == Colormap("viridis") 77 | 78 | 79 | @pytest.mark.parametrize("filterable", [False, True]) 80 | def test_cmap_combo(qtbot, filterable): 81 | wdg = QColormapComboBox(allow_user_colormaps=True, filterable=filterable) 82 | qtbot.addWidget(wdg) 83 | wdg.show() 84 | assert wdg.userAdditionsAllowed() 85 | 86 | with qtbot.waitSignal(wdg.currentColormapChanged): 87 | wdg.addColormaps([Colormap("viridis"), "magma", ("red", "blue", "green")]) 88 | assert wdg.currentColormap().name.split(":")[-1] == "viridis" 89 | 90 | with pytest.raises(ValueError, match="Invalid colormap"): 91 | wdg.addColormap("not a recognized string or cmap") 92 | 93 | assert wdg.currentColormap().name.split(":")[-1] == "viridis" 94 | assert wdg.currentIndex() == 0 95 | assert wdg.count() == 4 # includes "Add Colormap..." 96 | wdg.setCurrentColormap("magma") 97 | assert wdg.count() == 4 # make sure we didn't duplicate 98 | assert wdg.currentIndex() == 1 99 | 100 | if API_NAME == "PySide2": 101 | return # the rest fails on CI... but works locally 102 | 103 | # click the Add Colormap... item 104 | with qtbot.waitSignal(wdg.currentColormapChanged): 105 | with patch.object(_cmap_combo._CmapNameDialog, "exec", return_value=True): 106 | wdg._on_activated(wdg.count() - 1) 107 | 108 | assert wdg.count() == 5 109 | # this could potentially fail in the future if cmap catalog changes 110 | # but mocking the return value of the dialog is also annoying 111 | assert wdg.itemColormap(3).name.split(":")[-1] == "accent" 112 | 113 | # click the Add Colormap... item, but cancel the dialog 114 | with patch.object(_cmap_combo._CmapNameDialog, "exec", return_value=False): 115 | wdg._on_activated(wdg.count() - 1) 116 | 117 | 118 | def test_cmap_item_delegate(qtbot): 119 | wdg = CmapCatalogComboBox() 120 | qtbot.addWidget(wdg) 121 | view = wdg.view() 122 | delegate = view.itemDelegate() 123 | assert isinstance(delegate, QColormapItemDelegate) 124 | 125 | # smoke tests: 126 | painter = QPainter() 127 | option = QStyleOptionViewItem() 128 | index = wdg.model().index(0, 0) 129 | delegate._colormap_fraction = 1 130 | delegate.paint(painter, option, index) 131 | delegate._colormap_fraction = 0.33 132 | delegate.paint(painter, option, index) 133 | 134 | assert delegate.sizeHint(option, index) == delegate._item_size 135 | 136 | 137 | def test_cmap_line_edit(qtbot, qapp): 138 | wdg = QColormapLineEdit() 139 | qtbot.addWidget(wdg) 140 | wdg.show() 141 | 142 | wdg.setColormap("viridis") 143 | assert wdg.colormap() == Colormap("viridis") 144 | wdg.setText("magma") # also works if the name is recognized 145 | assert wdg.colormap() == Colormap("magma") 146 | qapp.processEvents() 147 | qtbot.wait(10) # force the paintEvent 148 | 149 | wdg.setFractionalColormapWidth(1) 150 | assert wdg.fractionalColormapWidth() == 1 151 | wdg.update() 152 | qapp.processEvents() 153 | qtbot.wait(10) # force the paintEvent 154 | 155 | wdg.setText("not-a-cmap") 156 | assert wdg.colormap() is None 157 | # or 158 | 159 | wdg.setFractionalColormapWidth(0.3) 160 | wdg.setColormap(None) 161 | assert wdg.colormap() is None 162 | qapp.processEvents() 163 | qtbot.wait(10) # force the paintEvent 164 | -------------------------------------------------------------------------------- /tests/test_code_highlight.py: -------------------------------------------------------------------------------- 1 | from qtpy.QtWidgets import QTextEdit 2 | 3 | from superqt.utils import CodeSyntaxHighlight 4 | 5 | 6 | def test_code_highlight(qtbot): 7 | widget = QTextEdit() 8 | qtbot.addWidget(widget) 9 | code_highlight = CodeSyntaxHighlight(widget, "python", "default") 10 | assert code_highlight.background_color == "#f8f8f8" 11 | widget.setText("from argparse import ArgumentParser") 12 | 13 | 14 | def test_code_highlight_by_name(qtbot): 15 | widget = QTextEdit() 16 | qtbot.addWidget(widget) 17 | code_highlight = CodeSyntaxHighlight(widget, "Python Traceback", "monokai") 18 | assert code_highlight.background_color == "#272822" 19 | widget.setText("from argparse import ArgumentParser") 20 | -------------------------------------------------------------------------------- /tests/test_collapsible.py: -------------------------------------------------------------------------------- 1 | """A test module for testing collapsible""" 2 | 3 | from qtpy.QtCore import QEasingCurve, Qt 4 | from qtpy.QtGui import QIcon 5 | from qtpy.QtWidgets import QPushButton, QStyle, QWidget 6 | 7 | from superqt import QCollapsible 8 | 9 | 10 | def _get_builtin_icon(name: str) -> QIcon: 11 | """Get a built-in icon from the Qt library.""" 12 | widget = QWidget() 13 | try: 14 | pixmap = getattr(QStyle.StandardPixmap, f"SP_{name}") 15 | except AttributeError: 16 | pixmap = getattr(QStyle, f"SP_{name}") 17 | 18 | return widget.style().standardIcon(pixmap) 19 | 20 | 21 | def test_checked_initialization(qtbot): 22 | """Test simple collapsible""" 23 | wdg1 = QCollapsible("Advanced analysis") 24 | wdg1.expand(False) 25 | assert wdg1.isExpanded() 26 | assert wdg1._content.maximumHeight() > 0 27 | 28 | wdg2 = QCollapsible("Advanced analysis") 29 | wdg1.collapse(False) 30 | assert not wdg2.isExpanded() 31 | assert wdg2._content.maximumHeight() == 0 32 | 33 | 34 | def test_content_hide_show(qtbot): 35 | """Test collapsible with content""" 36 | 37 | # Create child component 38 | collapsible = QCollapsible("Advanced analysis") 39 | for i in range(10): 40 | collapsible.addWidget(QPushButton(f"Content button {i + 1}")) 41 | 42 | collapsible.collapse(False) 43 | assert not collapsible.isExpanded() 44 | assert collapsible._content.maximumHeight() == 0 45 | 46 | collapsible.expand(False) 47 | assert collapsible.isExpanded() 48 | assert collapsible._content.maximumHeight() > 0 49 | 50 | 51 | def test_locking(qtbot): 52 | """Test locking collapsible""" 53 | wdg1 = QCollapsible() 54 | assert wdg1.locked() is False 55 | wdg1.setLocked(True) 56 | assert wdg1.locked() is True 57 | assert not wdg1.isExpanded() 58 | 59 | wdg1._toggle_btn.setChecked(True) 60 | assert not wdg1.isExpanded() 61 | 62 | wdg1._toggle() 63 | assert not wdg1.isExpanded() 64 | 65 | wdg1.expand() 66 | assert not wdg1.isExpanded() 67 | 68 | wdg1._toggle_btn.setChecked(False) 69 | assert not wdg1.isExpanded() 70 | 71 | wdg1.setLocked(False) 72 | wdg1.expand() 73 | assert wdg1.isExpanded() 74 | assert wdg1._toggle_btn.isChecked() 75 | 76 | 77 | def test_changing_animation_settings(qtbot): 78 | """Quick test for changing animation settings""" 79 | wdg = QCollapsible() 80 | wdg.setDuration(600) 81 | wdg.setEasingCurve(QEasingCurve.Type.InElastic) 82 | assert wdg._animation.easingCurve() == QEasingCurve.Type.InElastic 83 | assert wdg._animation.duration() == 600 84 | 85 | 86 | def test_changing_content(qtbot): 87 | """Test changing the content""" 88 | content = QPushButton() 89 | wdg = QCollapsible() 90 | wdg.setContent(content) 91 | assert wdg._content == content 92 | 93 | 94 | def test_changing_text(qtbot): 95 | """Test changing the content""" 96 | wdg = QCollapsible() 97 | wdg.setText("Hi new text") 98 | assert wdg.text() == "Hi new text" 99 | assert wdg._toggle_btn.text() == "Hi new text" 100 | 101 | 102 | def test_toggle_signal(qtbot): 103 | """Test that signal is emitted when widget expanded/collapsed.""" 104 | wdg = QCollapsible() 105 | with qtbot.waitSignal(wdg.toggled, timeout=500): 106 | qtbot.mouseClick(wdg._toggle_btn, Qt.LeftButton) 107 | 108 | with qtbot.waitSignal(wdg.toggled, timeout=500): 109 | wdg.expand() 110 | 111 | with qtbot.waitSignal(wdg.toggled, timeout=500): 112 | wdg.collapse() 113 | 114 | 115 | def test_getting_icon(qtbot): 116 | """Test setting string as toggle button.""" 117 | wdg = QCollapsible("test") 118 | assert isinstance(wdg.expandedIcon(), QIcon) 119 | assert isinstance(wdg.collapsedIcon(), QIcon) 120 | 121 | 122 | def test_setting_icon(qtbot): 123 | """Test setting icon for toggle button.""" 124 | icon1 = _get_builtin_icon("ArrowRight") 125 | icon2 = _get_builtin_icon("ArrowDown") 126 | wdg = QCollapsible("test", expandedIcon=icon1, collapsedIcon=icon2) 127 | assert wdg._expanded_icon == icon1 128 | assert wdg._collapsed_icon == icon2 129 | 130 | 131 | def test_setting_symbol_icon(qtbot): 132 | """Test setting string as toggle button.""" 133 | wdg = QCollapsible("test") 134 | icon1 = wdg._convert_string_to_icon("+") 135 | icon2 = wdg._convert_string_to_icon("-") 136 | wdg.setCollapsedIcon(icon=icon1) 137 | assert wdg._collapsed_icon == icon1 138 | wdg.setExpandedIcon(icon=icon2) 139 | assert wdg._expanded_icon == icon2 140 | -------------------------------------------------------------------------------- /tests/test_color_combo.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import pytest 4 | from qtpy import API_NAME 5 | from qtpy.QtGui import QColor, QPainter 6 | from qtpy.QtWidgets import QStyleOptionViewItem 7 | 8 | from superqt import QColorComboBox 9 | from superqt.combobox import _color_combobox 10 | 11 | 12 | def test_q_color_combobox(qtbot): 13 | wdg = QColorComboBox() 14 | qtbot.addWidget(wdg) 15 | wdg.show() 16 | wdg.setUserColorsAllowed(True) 17 | 18 | # colors can be any argument that can be passed to QColor 19 | # (tuples and lists will be expanded to QColor(*color) 20 | COLORS = [QColor("red"), "orange", (255, 255, 0), "green", "#00F", "indigo"] 21 | wdg.addColors(COLORS) 22 | 23 | colors = [wdg.itemColor(i) for i in range(wdg.count())] 24 | assert colors == [ 25 | QColor("red"), 26 | QColor("orange"), 27 | QColor("yellow"), 28 | QColor("green"), 29 | QColor("blue"), 30 | QColor("indigo"), 31 | None, # "Add Color" item 32 | ] 33 | 34 | # as with addColors, colors will be cast to QColor when using setColors 35 | wdg.setCurrentColor("indigo") 36 | assert wdg.currentColor() == QColor("indigo") 37 | assert wdg.currentColorName() == "#4b0082" 38 | 39 | wdg.clear() 40 | assert wdg.count() == 1 # "Add Color" item 41 | wdg.setUserColorsAllowed(False) 42 | assert not wdg.count() 43 | 44 | wdg.setInvalidColorPolicy(wdg.InvalidColorPolicy.Ignore) 45 | wdg.setInvalidColorPolicy(2) 46 | wdg.setInvalidColorPolicy("Raise") 47 | with pytest.raises(TypeError): 48 | wdg.setInvalidColorPolicy(1.0) # type: ignore 49 | 50 | with pytest.raises(ValueError): 51 | wdg.addColor("invalid") 52 | 53 | 54 | def test_q_color_delegate(qtbot): 55 | wdg = QColorComboBox() 56 | view = wdg.view() 57 | delegate = wdg.itemDelegate() 58 | qtbot.addWidget(wdg) 59 | wdg.show() 60 | 61 | # smoke tests: 62 | painter = QPainter() 63 | option = QStyleOptionViewItem() 64 | index = wdg.model().index(0, 0) 65 | delegate.paint(painter, option, index) 66 | 67 | wdg.addColors(["red", "orange", "yellow"]) 68 | view.selectAll() 69 | index = wdg.model().index(1, 0) 70 | delegate.paint(painter, option, index) 71 | 72 | 73 | @pytest.mark.skipif(API_NAME == "PySide2", reason="hangs on CI") 74 | def test_activated(qtbot): 75 | wdg = QColorComboBox() 76 | qtbot.addWidget(wdg) 77 | wdg.show() 78 | wdg.setUserColorsAllowed(True) 79 | 80 | with patch.object(_color_combobox.QColorDialog, "getColor", lambda: QColor("red")): 81 | wdg._on_activated(wdg.count() - 1) # "Add Color" item 82 | assert wdg.currentColor() == QColor("red") 83 | 84 | with patch.object(_color_combobox.QColorDialog, "getColor", lambda: QColor()): 85 | wdg._on_activated(wdg.count() - 1) # "Add Color" item 86 | assert wdg.currentColor() == QColor("red") 87 | -------------------------------------------------------------------------------- /tests/test_eliding_label.py: -------------------------------------------------------------------------------- 1 | import platform 2 | 3 | from qtpy.QtCore import QSize, Qt 4 | from qtpy.QtGui import QResizeEvent 5 | 6 | from superqt import QElidingLabel 7 | 8 | TEXT = ( 9 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do " 10 | "eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad " 11 | "minim ven iam, quis nostrud exercitation ullamco laborisnisi ut aliquip " 12 | "ex ea commodo consequat. Duis aute irure dolor inreprehenderit in voluptate " 13 | "velit esse cillum dolore eu fugiat nullapariatur." 14 | ) 15 | ELLIPSIS = "…" 16 | 17 | 18 | def test_eliding_label(qtbot): 19 | wdg = QElidingLabel(TEXT) 20 | qtbot.addWidget(wdg) 21 | assert wdg._elidedText().endswith(ELLIPSIS) 22 | oldsize = wdg.size() 23 | newsize = QSize(200, 20) 24 | wdg.resize(newsize) 25 | wdg.resizeEvent(QResizeEvent(oldsize, newsize)) # for test coverage 26 | assert wdg.text() == TEXT 27 | 28 | 29 | def test_wrapped_eliding_label(qtbot): 30 | wdg = QElidingLabel(TEXT) 31 | qtbot.addWidget(wdg) 32 | assert not wdg.wordWrap() 33 | assert 630 < wdg.sizeHint().width() < 640 34 | assert wdg._elidedText().endswith(ELLIPSIS) 35 | wdg.resize(QSize(200, 100)) 36 | assert wdg.text() == TEXT 37 | assert wdg._elidedText().endswith(ELLIPSIS) 38 | wdg.setWordWrap(True) 39 | assert wdg.wordWrap() 40 | assert wdg.text() == TEXT 41 | assert wdg._elidedText().endswith(ELLIPSIS) 42 | # just empirically from CI ... stupid 43 | if platform.system() == "Linux": 44 | assert wdg.sizeHint() in (QSize(200, 198), QSize(200, 154)) 45 | elif platform.system() == "Windows": 46 | assert wdg.sizeHint() in (QSize(200, 160), QSize(200, 118)) 47 | elif platform.system() == "Darwin": 48 | assert wdg.sizeHint() == QSize(200, 176) 49 | # TODO: figure out how to test these on all platforms on CI 50 | wdg.resize(wdg.sizeHint()) 51 | assert wdg._elidedText() == TEXT 52 | 53 | 54 | def test_shorter_eliding_label(qtbot): 55 | short = "asd a ads sd flksdf dsf lksfj sd lsdjf sd lsdfk sdlkfj s" 56 | wdg = QElidingLabel() 57 | qtbot.addWidget(wdg) 58 | wdg.setText(short) 59 | assert not wdg._elidedText().endswith(ELLIPSIS) 60 | wdg.resize(100, 20) 61 | assert wdg._elidedText().endswith(ELLIPSIS) 62 | wdg.setElideMode(Qt.TextElideMode.ElideLeft) 63 | assert wdg._elidedText().startswith(ELLIPSIS) 64 | assert wdg.elideMode() == Qt.TextElideMode.ElideLeft 65 | 66 | 67 | def test_wrap_text(): 68 | wrap = QElidingLabel.wrapText(TEXT, 200) 69 | assert isinstance(wrap, list) 70 | assert all(isinstance(x, str) for x in wrap) 71 | assert 9 <= len(wrap) <= 13 72 | 73 | 74 | def test_minimum_size_hint(): 75 | # The hint should always just be the space needed for "..." 76 | wdg = QElidingLabel() 77 | size_hint = wdg.minimumSizeHint() 78 | # Regardless of what text is contained 79 | wdg.setText(TEXT) 80 | new_hint = wdg.minimumSizeHint() 81 | assert size_hint.width() == new_hint.width() 82 | assert size_hint.height() == new_hint.height() 83 | -------------------------------------------------------------------------------- /tests/test_eliding_line_edit.py: -------------------------------------------------------------------------------- 1 | from qtpy.QtCore import QSize, Qt 2 | from qtpy.QtGui import QResizeEvent 3 | 4 | from superqt import QElidingLineEdit 5 | 6 | TEXT = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do" 7 | ELLIPSIS = "…" 8 | 9 | 10 | def test_init_text_eliding_line_edit(qtbot): 11 | wdg = QElidingLineEdit(TEXT) 12 | qtbot.addWidget(wdg) 13 | oldsize = QSize(100, 20) 14 | wdg.resize(oldsize) 15 | assert wdg._elidedText().endswith(ELLIPSIS) 16 | newsize = QSize(500, 20) 17 | wdg.resize(newsize) 18 | wdg.resizeEvent(QResizeEvent(oldsize, newsize)) # for test coverage 19 | assert wdg._elidedText() == TEXT 20 | assert wdg.text() == TEXT 21 | 22 | 23 | def test_set_text_eliding_line_edit(qtbot): 24 | wdg = QElidingLineEdit() 25 | qtbot.addWidget(wdg) 26 | wdg.resize(500, 20) 27 | wdg.setText(TEXT) 28 | assert not wdg._elidedText().endswith(ELLIPSIS) 29 | wdg.resize(100, 20) 30 | assert wdg._elidedText().endswith(ELLIPSIS) 31 | 32 | 33 | def test_set_elide_mode_eliding_line_edit(qtbot): 34 | wdg = QElidingLineEdit() 35 | qtbot.addWidget(wdg) 36 | wdg.resize(500, 20) 37 | wdg.setText(TEXT) 38 | assert not wdg._elidedText().endswith(ELLIPSIS) 39 | wdg.resize(100, 20) 40 | # ellipses should be to the right 41 | assert wdg._elidedText().endswith(ELLIPSIS) 42 | 43 | # ellipses should be to the left 44 | wdg.setElideMode(Qt.TextElideMode.ElideLeft) 45 | assert wdg._elidedText().startswith(ELLIPSIS) 46 | assert wdg.elideMode() == Qt.TextElideMode.ElideLeft 47 | 48 | # no ellipses should be shown 49 | wdg.setElideMode(Qt.TextElideMode.ElideNone) 50 | assert ELLIPSIS not in wdg._elidedText() 51 | 52 | 53 | def test_set_elipses_width_eliding_line_edit(qtbot): 54 | wdg = QElidingLineEdit() 55 | qtbot.addWidget(wdg) 56 | wdg.resize(500, 20) 57 | wdg.setText(TEXT) 58 | assert not wdg._elidedText().endswith(ELLIPSIS) 59 | wdg.setEllipsesWidth(int(wdg.width() / 2)) 60 | assert wdg._elidedText().endswith(ELLIPSIS) 61 | -------------------------------------------------------------------------------- /tests/test_flow_layout.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from qtpy.QtWidgets import QPushButton, QWidget 4 | 5 | from superqt import QFlowLayout 6 | 7 | 8 | def test_flow_layout(qtbot: Any) -> None: 9 | wdg = QWidget() 10 | qtbot.addWidget(wdg) 11 | 12 | layout = QFlowLayout(wdg) 13 | layout.addWidget(QPushButton("Short")) 14 | layout.addWidget(QPushButton("Longer")) 15 | layout.addWidget(QPushButton("Different text")) 16 | layout.addWidget(QPushButton("More text")) 17 | layout.addWidget(QPushButton("Even longer button text")) 18 | 19 | wdg.setWindowTitle("Flow Layout") 20 | wdg.show() 21 | 22 | assert layout.expandingDirections() 23 | assert layout.heightForWidth(200) > layout.heightForWidth(400) 24 | assert layout.count() == 5 25 | assert layout.itemAt(0).widget().text() == "Short" 26 | layout.takeAt(0) 27 | assert layout.count() == 4 28 | -------------------------------------------------------------------------------- /tests/test_fonticon/fixtures/fake_plugin.dist-info/METADATA: -------------------------------------------------------------------------------- 1 | Metadata-Version: 2.1 2 | Name: fake-plugin 3 | Version: 5.15.4 4 | -------------------------------------------------------------------------------- /tests/test_fonticon/fixtures/fake_plugin.dist-info/entry_points.txt: -------------------------------------------------------------------------------- 1 | [superqt.fonticon] 2 | ico = fake_plugin:ICO 3 | -------------------------------------------------------------------------------- /tests/test_fonticon/fixtures/fake_plugin.dist-info/top_level.txt: -------------------------------------------------------------------------------- 1 | fake_plugin 2 | -------------------------------------------------------------------------------- /tests/test_fonticon/fixtures/fake_plugin/__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | 4 | class ICO: 5 | __font_file__ = str(Path(__file__).parent / "icontest.ttf") 6 | smiley = "ico.\ue900" 7 | -------------------------------------------------------------------------------- /tests/test_fonticon/fixtures/fake_plugin/icontest.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyapp-kit/superqt/a9fa7205770a8721887e1810461c5a918fd8190d/tests/test_fonticon/fixtures/fake_plugin/icontest.ttf -------------------------------------------------------------------------------- /tests/test_fonticon/test_fonticon.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from qtpy.QtGui import QIcon, QPixmap 5 | from qtpy.QtWidgets import QPushButton 6 | 7 | from superqt.fonticon import icon, pulse, setTextIcon, spin 8 | from superqt.fonticon._qfont_icon import QFontIconStore, _ensure_identifier 9 | 10 | TEST_PREFIX = "ico" 11 | TEST_CHARNAME = "smiley" 12 | TEST_CHAR = "\ue900" 13 | TEST_GLYPHKEY = f"{TEST_PREFIX}.{TEST_CHARNAME}" 14 | FONT_FILE = Path(__file__).parent / "fixtures" / "fake_plugin" / "icontest.ttf" 15 | 16 | 17 | @pytest.fixture 18 | def store(qapp): 19 | store = QFontIconStore().instance() 20 | yield store 21 | store.clear() 22 | 23 | 24 | @pytest.fixture 25 | def full_store(store): 26 | store.addFont(str(FONT_FILE), TEST_PREFIX, {TEST_CHARNAME: TEST_CHAR}) 27 | return store 28 | 29 | 30 | def test_no_font_key(): 31 | with pytest.raises(KeyError) as err: 32 | icon(TEST_GLYPHKEY) 33 | assert "Unrecognized font key: {TEST_PREFIX!r}." in str(err) 34 | 35 | 36 | def test_no_charmap(store): 37 | store.addFont(str(FONT_FILE), TEST_PREFIX) 38 | with pytest.raises(KeyError) as err: 39 | icon(TEST_GLYPHKEY) 40 | assert "No charmap registered for" in str(err) 41 | 42 | 43 | def test_font_icon_works(full_store): 44 | icn = icon(TEST_GLYPHKEY) 45 | assert isinstance(icn, QIcon) 46 | assert isinstance(icn.pixmap(40, 40), QPixmap) 47 | 48 | icn = icon(f"{TEST_PREFIX}.{TEST_CHAR}") # also works with unicode key 49 | assert isinstance(icn, QIcon) 50 | assert isinstance(icn.pixmap(40, 40), QPixmap) 51 | 52 | with pytest.raises(ValueError) as err: 53 | icon(f"{TEST_PREFIX}.smelly") # bad name 54 | assert "Font 'test (Regular)' has no glyph with the key 'smelly'" in str(err) 55 | 56 | 57 | def test_on_button(full_store, qtbot): 58 | btn = QPushButton(None) 59 | qtbot.addWidget(btn) 60 | btn.setIcon(icon(TEST_GLYPHKEY)) 61 | 62 | 63 | def test_btn_text_icon(full_store, qtbot): 64 | btn = QPushButton(None) 65 | qtbot.addWidget(btn) 66 | setTextIcon(btn, TEST_GLYPHKEY) 67 | assert btn.text() == TEST_CHAR 68 | 69 | 70 | def test_animation(full_store, qtbot): 71 | btn = QPushButton(None) 72 | qtbot.addWidget(btn) 73 | icn = icon(TEST_GLYPHKEY, animation=pulse(btn)) 74 | btn.setIcon(icn) 75 | with qtbot.waitSignal(icn._engine._default_opts.animation.timer.timeout): 76 | icn.pixmap(40, 40) 77 | btn.update() 78 | 79 | 80 | def test_multistate(full_store, qtbot, qapp): 81 | """complicated multistate icon""" 82 | btn = QPushButton() 83 | qtbot.addWidget(btn) 84 | icn = icon( 85 | TEST_GLYPHKEY, 86 | color="blue", 87 | states={ 88 | "active": { 89 | "color": "red", 90 | "scale_factor": 0.5, 91 | "animation": pulse(btn), 92 | }, 93 | "disabled": { 94 | "color": "green", 95 | "scale_factor": 0.8, 96 | "animation": spin(btn), 97 | }, 98 | }, 99 | ) 100 | btn.setIcon(icn) 101 | btn.show() 102 | 103 | btn.setEnabled(False) 104 | active = icn._engine._opts[QIcon.State.Off][QIcon.Mode.Active].animation.timer 105 | disabled = icn._engine._opts[QIcon.State.Off][QIcon.Mode.Disabled].animation.timer 106 | 107 | with qtbot.waitSignal(active.timeout, timeout=1000): 108 | btn.setEnabled(True) 109 | # hack to get the signal emitted 110 | icn.pixmap(100, 100, QIcon.Mode.Active, QIcon.State.Off) 111 | 112 | assert active.isActive() 113 | assert not disabled.isActive() 114 | with qtbot.waitSignal(disabled.timeout): 115 | btn.setEnabled(False) 116 | assert disabled.isActive() 117 | 118 | # smoke test, paint all the states 119 | icn.pixmap(100, 100, QIcon.Mode.Active, QIcon.State.Off) 120 | icn.pixmap(100, 100, QIcon.Mode.Disabled, QIcon.State.Off) 121 | icn.pixmap(100, 100, QIcon.Mode.Selected, QIcon.State.Off) 122 | icn.pixmap(100, 100, QIcon.Mode.Normal, QIcon.State.Off) 123 | icn.pixmap(100, 100, QIcon.Mode.Active, QIcon.State.On) 124 | icn.pixmap(100, 100, QIcon.Mode.Disabled, QIcon.State.On) 125 | icn.pixmap(100, 100, QIcon.Mode.Selected, QIcon.State.On) 126 | icn.pixmap(100, 100, QIcon.Mode.Normal, QIcon.State.On) 127 | 128 | 129 | def test_ensure_identifier(): 130 | assert _ensure_identifier("") == "" 131 | assert _ensure_identifier("1a") == "_1a" 132 | assert _ensure_identifier("from") == "from_" 133 | assert _ensure_identifier("hello-world") == "hello_world" 134 | assert _ensure_identifier("hello_world") == "hello_world" 135 | assert _ensure_identifier("hello world") == "hello_world" 136 | -------------------------------------------------------------------------------- /tests/test_fonticon/test_plugins.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | 4 | import pytest 5 | from qtpy.QtGui import QIcon, QPixmap 6 | 7 | from superqt.fonticon import _plugins, icon 8 | from superqt.fonticon._qfont_icon import QFontIconStore 9 | 10 | FIXTURES = Path(__file__).parent / "fixtures" 11 | 12 | 13 | @pytest.fixture 14 | def plugin_store(qapp, monkeypatch): 15 | _path = [str(FIXTURES), *sys.path.copy()] 16 | store = QFontIconStore().instance() 17 | with monkeypatch.context() as m: 18 | m.setattr(sys, "path", _path) 19 | yield store 20 | store.clear() 21 | 22 | 23 | def test_plugin(plugin_store): 24 | assert not _plugins.loaded() 25 | icn = icon("ico.smiley") 26 | assert _plugins.loaded() == {"ico": ["smiley"]} 27 | assert isinstance(icn, QIcon) 28 | assert isinstance(icn.pixmap(40, 40), QPixmap) 29 | -------------------------------------------------------------------------------- /tests/test_iconify.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | import pytest 4 | from qtpy.QtGui import QIcon 5 | from qtpy.QtWidgets import QPushButton 6 | 7 | from superqt import QIconifyIcon 8 | 9 | if TYPE_CHECKING: 10 | from pytestqt.qtbot import QtBot 11 | 12 | 13 | def test_qiconify(qtbot: "QtBot", monkeypatch: "pytest.MonkeyPatch") -> None: 14 | monkeypatch.setenv("PYCONIFY_CACHE", "0") 15 | pytest.importorskip("pyconify") 16 | 17 | icon = QIconifyIcon("bi:alarm-fill", color="red", flip="vertical") 18 | icon.addKey("bi:alarm", color="blue", rotate=90, state=QIcon.State.On) 19 | 20 | btn = QPushButton() 21 | qtbot.addWidget(btn) 22 | btn.setIcon(icon) 23 | btn.show() 24 | -------------------------------------------------------------------------------- /tests/test_large_int_spinbox.py: -------------------------------------------------------------------------------- 1 | from qtpy.QtCore import Qt 2 | 3 | from superqt.spinbox import QLargeIntSpinBox 4 | 5 | 6 | def test_large_spinbox(qtbot): 7 | sb = QLargeIntSpinBox() 8 | qtbot.addWidget(sb) 9 | 10 | for e in range(2, 100, 2): 11 | sb.setMaximum(10**e + 2) 12 | with qtbot.waitSignal(sb.valueChanged) as sgnl: 13 | sb.setValue(10**e) 14 | assert sgnl.args == [10**e] 15 | assert sb.value() == 10**e 16 | 17 | sb.setMinimum(-(10**e) - 2) 18 | 19 | with qtbot.waitSignal(sb.valueChanged) as sgnl: 20 | sb.setValue(-(10**e)) 21 | assert sgnl.args == [-(10**e)] 22 | assert sb.value() == -(10**e) 23 | 24 | 25 | def test_large_spinbox_range(qtbot): 26 | sb = QLargeIntSpinBox() 27 | qtbot.addWidget(sb) 28 | sb.setRange(-100, 100) 29 | sb.setValue(50) 30 | 31 | sb.setRange(-10, 10) 32 | assert sb.value() == 10 33 | 34 | sb.setRange(100, 1000) 35 | assert sb.value() == 100 36 | 37 | sb.setRange(50, 0) 38 | assert sb.minimum() == 50 39 | assert sb.maximum() == 50 40 | assert sb.value() == 50 41 | 42 | 43 | def test_large_spinbox_type(qtbot): 44 | sb = QLargeIntSpinBox() 45 | qtbot.addWidget(sb) 46 | 47 | assert isinstance(sb.value(), int) 48 | 49 | sb.setValue(1.1) 50 | assert isinstance(sb.value(), int) 51 | assert sb.value() == 1 52 | 53 | sb.setValue(1.9) 54 | assert isinstance(sb.value(), int) 55 | assert sb.value() == 1 56 | 57 | 58 | def test_large_spinbox_signals(qtbot): 59 | sb = QLargeIntSpinBox() 60 | qtbot.addWidget(sb) 61 | 62 | with qtbot.waitSignal(sb.valueChanged) as sgnl: 63 | sb.setValue(200) 64 | assert sgnl.args == [200] 65 | 66 | with qtbot.waitSignal(sb.textChanged) as sgnl: 67 | sb.setValue(240) 68 | assert sgnl.args == ["240"] 69 | 70 | 71 | def test_keyboard_tracking(qtbot): 72 | sb = QLargeIntSpinBox() 73 | qtbot.addWidget(sb) 74 | 75 | assert sb.value() == 0 76 | sb.setKeyboardTracking(False) 77 | with qtbot.assertNotEmitted(sb.valueChanged): 78 | sb.lineEdit().setText("20") 79 | assert sb.lineEdit().text() == "20" 80 | assert sb.value() == 0 81 | assert sb._pending_emit is True 82 | 83 | with qtbot.waitSignal(sb.valueChanged) as sgnl: 84 | qtbot.keyPress(sb, Qt.Key.Key_Enter) 85 | assert sgnl.args == [20] 86 | assert sb._pending_emit is False 87 | 88 | sb.setKeyboardTracking(True) 89 | with qtbot.waitSignal(sb.valueChanged) as sgnl: 90 | sb.lineEdit().setText("25") 91 | assert sb._pending_emit is False 92 | assert sgnl.args == [25] 93 | 94 | 95 | def test_large_spinbox_step_type(qtbot): 96 | sb = QLargeIntSpinBox() 97 | qtbot.addWidget(sb) 98 | sb.setMaximum(1_000_000_000) 99 | sb.setStepType(sb.StepType.AdaptiveDecimalStepType) 100 | sb.setValue(1_000_000) 101 | sb.stepBy(1) 102 | assert sb.value() == 1_100_000 103 | sb.setStepType(sb.StepType.DefaultStepType) 104 | sb.stepBy(1) 105 | assert sb.value() == 1_100_001 106 | -------------------------------------------------------------------------------- /tests/test_qmessage_handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from qtpy import QtCore 4 | 5 | from superqt import QMessageHandler 6 | 7 | 8 | def test_message_handler(): 9 | with QMessageHandler() as mh: 10 | QtCore.qDebug("debug") 11 | QtCore.qWarning("warning") 12 | QtCore.qCritical("critical") 13 | 14 | assert len(mh.records) == 3 15 | assert mh.records[0].level == logging.DEBUG 16 | assert mh.records[1].level == logging.WARNING 17 | assert mh.records[2].level == logging.CRITICAL 18 | 19 | assert "3 records" in repr(mh) 20 | 21 | 22 | def test_message_handler_with_logger(caplog): 23 | logger = logging.getLogger("test_logger") 24 | caplog.set_level(logging.DEBUG, logger="test_logger") 25 | with QMessageHandler(logger): 26 | QtCore.qDebug("debug") 27 | QtCore.qWarning("warning") 28 | QtCore.qCritical("critical") 29 | 30 | assert len(caplog.records) == 3 31 | assert caplog.records[0].message == "debug" 32 | assert caplog.records[0].levelno == logging.DEBUG 33 | assert caplog.records[1].message == "warning" 34 | assert caplog.records[1].levelno == logging.WARNING 35 | assert caplog.records[2].message == "critical" 36 | assert caplog.records[2].levelno == logging.CRITICAL 37 | -------------------------------------------------------------------------------- /tests/test_quantity.py: -------------------------------------------------------------------------------- 1 | from pint import Quantity 2 | 3 | from superqt import QQuantity 4 | 5 | 6 | def test_qquantity(qtbot): 7 | w = QQuantity(1, "m") 8 | qtbot.addWidget(w) 9 | 10 | assert w.value() == 1 * w.unitRegistry().meter 11 | assert w.magnitude() == 1 12 | assert w.units() == w.unitRegistry().meter 13 | assert w.text() == "1 meter" 14 | w.setUnits("cm") 15 | assert w.value() == 100 * w.unitRegistry().centimeter 16 | assert w.magnitude() == 100 17 | assert w.units() == w.unitRegistry().centimeter 18 | assert w.text() == "100.0 centimeter" 19 | w.setMagnitude(10) 20 | assert w.value() == 10 * w.unitRegistry().centimeter 21 | assert w.magnitude() == 10 22 | assert w.units() == w.unitRegistry().centimeter 23 | assert w.text() == "10 centimeter" 24 | w.setValue(1 * w.unitRegistry().meter) 25 | assert w.value() == 1 * w.unitRegistry().meter 26 | assert w.magnitude() == 1 27 | assert w.units() == w.unitRegistry().meter 28 | assert w.text() == "1 meter" 29 | 30 | w.setUnits(None) 31 | assert w.isDimensionless() 32 | assert w.unitsComboBox().currentText() == "-----" 33 | assert w.magnitude() == 1 34 | 35 | 36 | def test_change_qquantity_value(qtbot): 37 | w = QQuantity() 38 | qtbot.addWidget(w) 39 | assert w.value() == Quantity(0) 40 | w.setValue(Quantity("1 meter")) 41 | assert w.value() == Quantity("1 meter") 42 | -------------------------------------------------------------------------------- /tests/test_searchable_combobox.py: -------------------------------------------------------------------------------- 1 | from superqt import QSearchableComboBox 2 | 3 | 4 | class TestSearchableComboBox: 5 | def test_constructor(self, qtbot): 6 | widget = QSearchableComboBox() 7 | qtbot.addWidget(widget) 8 | 9 | def test_add_items(self, qtbot): 10 | widget = QSearchableComboBox() 11 | qtbot.addWidget(widget) 12 | widget.addItems(["foo", "bar"]) 13 | assert widget.completer_object.model().rowCount() == 2 14 | widget.addItem("foobar") 15 | assert widget.completer_object.model().rowCount() == 3 16 | widget.insertItem(1, "baz") 17 | assert widget.completer_object.model().rowCount() == 4 18 | widget.insertItems(2, ["bazbar", "foobaz"]) 19 | assert widget.completer_object.model().rowCount() == 6 20 | assert widget.itemText(0) == "foo" 21 | assert widget.itemText(1) == "baz" 22 | assert widget.itemText(2) == "bazbar" 23 | 24 | def test_completion(self, qtbot): 25 | widget = QSearchableComboBox() 26 | qtbot.addWidget(widget) 27 | widget.addItems(["foo", "bar", "foobar", "baz", "bazbar", "foobaz"]) 28 | 29 | widget.completer_object.setCompletionPrefix("fo") 30 | assert widget.completer_object.completionCount() == 3 31 | assert widget.completer_object.currentCompletion() == "foo" 32 | widget.completer_object.setCurrentRow(1) 33 | assert widget.completer_object.currentCompletion() == "foobar" 34 | widget.completer_object.setCurrentRow(2) 35 | assert widget.completer_object.currentCompletion() == "foobaz" 36 | -------------------------------------------------------------------------------- /tests/test_searchable_list.py: -------------------------------------------------------------------------------- 1 | from superqt import QSearchableListWidget 2 | 3 | 4 | class TestSearchableListWidget: 5 | def test_create(self, qtbot): 6 | widget = QSearchableListWidget() 7 | qtbot.addWidget(widget) 8 | widget.addItem("aaa") 9 | assert widget.count() == 1 10 | 11 | def test_add_items(self, qtbot): 12 | widget = QSearchableListWidget() 13 | qtbot.addWidget(widget) 14 | widget.addItems(["foo", "bar"]) 15 | assert widget.count() == 2 16 | widget.insertItems(1, ["baz", "foobaz"]) 17 | widget.insertItem(2, "foobar") 18 | assert widget.count() == 5 19 | assert widget.item(0).text() == "foo" 20 | assert widget.item(1).text() == "baz" 21 | assert widget.item(2).text() == "foobar" 22 | 23 | def test_completion(self, qtbot): 24 | widget = QSearchableListWidget() 25 | qtbot.addWidget(widget) 26 | widget.show() 27 | widget.addItems(["foo", "bar", "foobar", "baz", "bazbar", "foobaz"]) 28 | widget.filter_widget.setText("fo") 29 | assert widget.count() == 6 30 | for i in range(widget.count()): 31 | item = widget.item(i) 32 | assert item.isHidden() == ("fo" not in item.text()) 33 | 34 | widget.hide() 35 | -------------------------------------------------------------------------------- /tests/test_searchable_tree.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pytestqt.qtbot import QtBot 3 | from qtpy.QtCore import Qt 4 | from qtpy.QtWidgets import QTreeWidget, QTreeWidgetItem 5 | 6 | from superqt import QSearchableTreeWidget 7 | 8 | 9 | @pytest.fixture 10 | def data() -> dict: 11 | return { 12 | "none": None, 13 | "str": "test", 14 | "int": 42, 15 | "list": [2, 3, 5], 16 | "dict": { 17 | "float": 0.5, 18 | "tuple": (22, 99), 19 | "bool": False, 20 | }, 21 | } 22 | 23 | 24 | @pytest.fixture 25 | def widget(qtbot: QtBot, data: dict) -> QSearchableTreeWidget: 26 | widget = QSearchableTreeWidget.fromData(data) 27 | qtbot.addWidget(widget) 28 | return widget 29 | 30 | 31 | def columns(item: QTreeWidgetItem) -> tuple[str, str]: 32 | return item.text(0), item.text(1) 33 | 34 | 35 | def all_items(tree: QTreeWidget) -> list[QTreeWidgetItem]: 36 | return tree.findItems("", Qt.MatchContains | Qt.MatchRecursive) 37 | 38 | 39 | def shown_items(tree: QTreeWidget) -> list[QTreeWidgetItem]: 40 | items = all_items(tree) 41 | return [item for item in items if not item.isHidden()] 42 | 43 | 44 | def test_init(qtbot: QtBot): 45 | widget = QSearchableTreeWidget() 46 | qtbot.addWidget(widget) 47 | assert widget.tree.topLevelItemCount() == 0 48 | 49 | 50 | def test_from_data(qtbot: QtBot, data: dict): 51 | widget = QSearchableTreeWidget.fromData(data) 52 | qtbot.addWidget(widget) 53 | tree = widget.tree 54 | 55 | assert tree.topLevelItemCount() == 5 56 | 57 | none_item = tree.topLevelItem(0) 58 | assert columns(none_item) == ("none", "None") 59 | assert none_item.childCount() == 0 60 | 61 | str_item = tree.topLevelItem(1) 62 | assert columns(str_item) == ("str", "test") 63 | assert str_item.childCount() == 0 64 | 65 | int_item = tree.topLevelItem(2) 66 | assert columns(int_item) == ("int", "42") 67 | assert int_item.childCount() == 0 68 | 69 | list_item = tree.topLevelItem(3) 70 | assert columns(list_item) == ("list", "list") 71 | assert list_item.childCount() == 3 72 | assert columns(list_item.child(0)) == ("0", "2") 73 | assert columns(list_item.child(1)) == ("1", "3") 74 | assert columns(list_item.child(2)) == ("2", "5") 75 | 76 | dict_item = tree.topLevelItem(4) 77 | assert columns(dict_item) == ("dict", "dict") 78 | assert dict_item.childCount() == 3 79 | assert columns(dict_item.child(0)) == ("float", "0.5") 80 | tuple_item = dict_item.child(1) 81 | assert columns(tuple_item) == ("tuple", "tuple") 82 | assert tuple_item.childCount() == 2 83 | assert columns(tuple_item.child(0)) == ("0", "22") 84 | assert columns(tuple_item.child(1)) == ("1", "99") 85 | assert columns(dict_item.child(2)) == ("bool", "False") 86 | 87 | 88 | def test_set_data(widget: QSearchableTreeWidget): 89 | tree = widget.tree 90 | assert tree.topLevelItemCount() != 1 91 | 92 | widget.setData({"test": "reset"}) 93 | 94 | assert tree.topLevelItemCount() == 1 95 | assert columns(tree.topLevelItem(0)) == ("test", "reset") 96 | 97 | 98 | def test_search_no_match(widget: QSearchableTreeWidget): 99 | widget.filter.setText("no match here") 100 | items = shown_items(widget.tree) 101 | assert len(items) == 0 102 | 103 | 104 | def test_search_all_match(widget: QSearchableTreeWidget): 105 | widget.filter.setText("") 106 | tree = widget.tree 107 | assert all_items(tree) == shown_items(tree) 108 | 109 | 110 | def test_search_match_one_key(widget: QSearchableTreeWidget): 111 | widget.filter.setText("int") 112 | items = shown_items(widget.tree) 113 | assert len(items) == 1 114 | assert columns(items[0]) == ("int", "42") 115 | 116 | 117 | def test_search_match_one_value(widget: QSearchableTreeWidget): 118 | widget.filter.setText("test") 119 | items = shown_items(widget.tree) 120 | assert len(items) == 1 121 | assert columns(items[0]) == ("str", "test") 122 | 123 | 124 | def test_search_match_many_keys(widget: QSearchableTreeWidget): 125 | widget.filter.setText("n") 126 | items = shown_items(widget.tree) 127 | assert len(items) == 2 128 | assert columns(items[0]) == ("none", "None") 129 | assert columns(items[1]) == ("int", "42") 130 | 131 | 132 | def test_search_match_one_show_unmatched_descendants(widget: QSearchableTreeWidget): 133 | widget.filter.setText("list") 134 | items = shown_items(widget.tree) 135 | assert len(items) == 4 136 | assert columns(items[0]) == ("list", "list") 137 | assert columns(items[1]) == ("0", "2") 138 | assert columns(items[2]) == ("1", "3") 139 | assert columns(items[3]) == ("2", "5") 140 | 141 | 142 | def test_search_match_one_show_unmatched_ancestors(widget: QSearchableTreeWidget): 143 | widget.filter.setText("tuple") 144 | items = shown_items(widget.tree) 145 | assert len(items) == 4 146 | assert columns(items[0]) == ("dict", "dict") 147 | assert columns(items[1]) == ("tuple", "tuple") 148 | assert columns(items[2]) == ("0", "22") 149 | assert columns(items[3]) == ("1", "99") 150 | -------------------------------------------------------------------------------- /tests/test_throttler.py: -------------------------------------------------------------------------------- 1 | import gc 2 | import weakref 3 | from unittest.mock import Mock 4 | 5 | import pytest 6 | from qtpy.QtCore import QObject, Signal 7 | 8 | from superqt.utils import qdebounced, qthrottled 9 | from superqt.utils._throttler import ThrottledCallable 10 | 11 | 12 | def test_debounced(qtbot): 13 | mock1 = Mock() 14 | mock2 = Mock() 15 | 16 | @qdebounced(timeout=5) 17 | def f1() -> str: 18 | mock1() 19 | 20 | def f2() -> str: 21 | mock2() 22 | 23 | for _ in range(10): 24 | f1() 25 | f2() 26 | 27 | qtbot.wait(5) 28 | mock1.assert_called_once() 29 | assert mock2.call_count == 10 30 | 31 | 32 | @pytest.mark.usefixtures("qapp") 33 | def test_stop_timer_simple(): 34 | mock = Mock() 35 | 36 | @qdebounced(timeout=5) 37 | def f1() -> str: 38 | mock() 39 | 40 | f1() 41 | assert f1._timer.isActive() 42 | mock.assert_not_called() 43 | f1.flush(restart_timer=False) 44 | assert not f1._timer.isActive() 45 | mock.assert_called_once() 46 | 47 | 48 | @pytest.mark.usefixtures("qapp") 49 | def test_stop_timer_no_event_pending(): 50 | mock = Mock() 51 | 52 | @qdebounced(timeout=5) 53 | def f1() -> str: 54 | mock() 55 | 56 | f1() 57 | assert f1._timer.isActive() 58 | mock.assert_not_called() 59 | f1.flush() 60 | assert f1._timer.isActive() 61 | mock.assert_called_once() 62 | f1.flush(restart_timer=False) 63 | assert not f1._timer.isActive() 64 | mock.assert_called_once() 65 | 66 | 67 | def test_debouncer_method(qtbot): 68 | class A(QObject): 69 | def __init__(self): 70 | super().__init__() 71 | self.count = 0 72 | 73 | def callback(self): 74 | self.count += 1 75 | 76 | a = A() 77 | assert all(not isinstance(x, ThrottledCallable) for x in a.children()) 78 | b = qdebounced(a.callback, timeout=4) 79 | assert any(isinstance(x, ThrottledCallable) for x in a.children()) 80 | for _ in range(10): 81 | b() 82 | 83 | qtbot.wait(5) 84 | 85 | assert a.count == 1 86 | 87 | 88 | def test_debouncer_method_definition(qtbot): 89 | mock1 = Mock() 90 | mock2 = Mock() 91 | 92 | class A(QObject): 93 | def __init__(self): 94 | super().__init__() 95 | self.count = 0 96 | 97 | @qdebounced(timeout=4) 98 | def callback(self): 99 | self.count += 1 100 | 101 | @qdebounced(timeout=4) 102 | @staticmethod 103 | def call1(): 104 | mock1() 105 | 106 | @staticmethod 107 | @qdebounced(timeout=4) 108 | def call2(): 109 | mock2() 110 | 111 | a = A() 112 | assert all(not isinstance(x, ThrottledCallable) for x in a.children()) 113 | for _ in range(10): 114 | a.callback(1) 115 | A.call1(34) 116 | a.call1(22) 117 | a.call2(22) 118 | A.call2(32) 119 | 120 | qtbot.wait(5) 121 | assert a.count == 1 122 | mock1.assert_called_once() 123 | mock2.assert_called_once() 124 | 125 | 126 | def test_class_with_slots(qtbot): 127 | class A: 128 | __slots__ = ("__weakref__", "count") 129 | 130 | def __init__(self): 131 | self.count = 0 132 | 133 | @qdebounced(timeout=4) 134 | def callback(self): 135 | self.count += 1 136 | 137 | a = A() 138 | for _ in range(10): 139 | a.callback() 140 | 141 | qtbot.wait(5) 142 | assert a.count == 1 143 | 144 | 145 | @pytest.mark.usefixtures("qapp") 146 | def test_class_with_slots_except(): 147 | class A: 148 | __slots__ = ("count",) 149 | 150 | def __init__(self): 151 | self.count = 0 152 | 153 | @qdebounced(timeout=4) 154 | def callback(self): 155 | self.count += 1 156 | 157 | with pytest.raises(TypeError, match="To use qthrottled or qdebounced"): 158 | A().callback() 159 | 160 | 161 | def test_throttled(qtbot): 162 | mock1 = Mock() 163 | mock2 = Mock() 164 | 165 | @qthrottled(timeout=5) 166 | def f1() -> str: 167 | mock1() 168 | 169 | def f2() -> str: 170 | mock2() 171 | 172 | for _ in range(10): 173 | f1() 174 | f2() 175 | 176 | qtbot.wait(5) 177 | assert mock1.call_count == 2 178 | assert mock2.call_count == 10 179 | 180 | 181 | @pytest.mark.parametrize("deco", [qthrottled, qdebounced]) 182 | def test_ensure_throttled_sig_inspection(deco, qtbot): 183 | mock = Mock() 184 | 185 | class Emitter(QObject): 186 | sig = Signal(int, int, int) 187 | 188 | @deco 189 | def func(a: int, b: int): 190 | """docstring""" 191 | mock(a, b) 192 | 193 | obj = Emitter() 194 | obj.sig.connect(func) 195 | 196 | # this is the crux of the test... 197 | # we emit 3 args, but the function only takes 2 198 | # this should normally work fine in Qt. 199 | # testing here that the decorator doesn't break it. 200 | with qtbot.waitSignal(func.triggered, timeout=1000): 201 | obj.sig.emit(1, 2, 3) 202 | mock.assert_called_once_with(1, 2) 203 | assert func.__doc__ == "docstring" 204 | assert func.__name__ == "func" 205 | 206 | 207 | def test_qthrottled_does_not_prevent_gc(qtbot): 208 | mock = Mock() 209 | 210 | class Thing: 211 | @qdebounced(timeout=1) 212 | def dmethod(self) -> None: 213 | mock() 214 | 215 | @qthrottled(timeout=1) 216 | def tmethod(self, x: int = 1) -> None: 217 | mock() 218 | 219 | thing = Thing() 220 | thing_ref = weakref.ref(thing) 221 | assert thing_ref() is not None 222 | thing.dmethod() 223 | qtbot.waitUntil(thing.dmethod._future.done, timeout=2000) 224 | assert mock.call_count == 1 225 | thing.tmethod() 226 | qtbot.waitUntil(thing.tmethod._future.done, timeout=2000) 227 | assert mock.call_count == 2 228 | 229 | wm = thing.tmethod 230 | assert isinstance(wm, ThrottledCallable) 231 | del thing 232 | gc.collect() 233 | assert thing_ref() is None 234 | 235 | with pytest.warns(RuntimeWarning, match="Method has been garbage collected"): 236 | wm() 237 | wm._set_future_result() 238 | -------------------------------------------------------------------------------- /tests/test_toggle_switch.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | from qtpy.QtCore import Qt 4 | from qtpy.QtWidgets import QApplication, QCheckBox, QVBoxLayout, QWidget 5 | 6 | from superqt import QToggleSwitch 7 | 8 | 9 | def test_on_and_off(qtbot): 10 | wdg = QToggleSwitch() 11 | qtbot.addWidget(wdg) 12 | wdg.show() 13 | assert not wdg.isChecked() 14 | wdg.setChecked(True) 15 | assert wdg.isChecked() 16 | QApplication.processEvents() 17 | wdg.setChecked(False) 18 | assert not wdg.isChecked() 19 | QApplication.processEvents() 20 | wdg.setChecked(False) 21 | assert not wdg.isChecked() 22 | wdg.toggle() 23 | assert wdg.isChecked() 24 | wdg.toggle() 25 | assert not wdg.isChecked() 26 | wdg.click() 27 | assert wdg.isChecked() 28 | wdg.click() 29 | assert not wdg.isChecked() 30 | QApplication.processEvents() 31 | 32 | 33 | def test_get_set(qtbot): 34 | wdg = QToggleSwitch() 35 | qtbot.addWidget(wdg) 36 | wdg.onColor = "#ff0000" 37 | assert wdg.onColor.name() == "#ff0000" 38 | wdg.offColor = "#00ff00" 39 | assert wdg.offColor.name() == "#00ff00" 40 | wdg.handleColor = "#0000ff" 41 | assert wdg.handleColor.name() == "#0000ff" 42 | wdg.setText("new text") 43 | assert wdg.text() == "new text" 44 | wdg.switchWidth = 100 45 | assert wdg.switchWidth == 100 46 | wdg.switchHeight = 100 47 | assert wdg.switchHeight == 100 48 | wdg.handleSize = 80 49 | assert wdg.handleSize == 80 50 | 51 | 52 | def test_mouse_click(qtbot): 53 | wdg = QToggleSwitch() 54 | mock = Mock() 55 | wdg.toggled.connect(mock) 56 | qtbot.addWidget(wdg) 57 | assert not wdg.isChecked() 58 | mock.assert_not_called() 59 | qtbot.mouseClick(wdg, Qt.MouseButton.LeftButton) 60 | assert wdg.isChecked() 61 | mock.assert_called_once_with(True) 62 | qtbot.mouseClick(wdg, Qt.MouseButton.LeftButton) 63 | assert not wdg.isChecked() 64 | 65 | 66 | def test_signal_emission_order(qtbot): 67 | """Check if event emmision is same for QToggleSwitch and QCheckBox""" 68 | wdg = QToggleSwitch() 69 | emitted_from_toggleswitch = [] 70 | wdg.toggled.connect(lambda: emitted_from_toggleswitch.append("toggled")) 71 | wdg.pressed.connect(lambda: emitted_from_toggleswitch.append("pressed")) 72 | wdg.clicked.connect(lambda: emitted_from_toggleswitch.append("clicked")) 73 | wdg.released.connect(lambda: emitted_from_toggleswitch.append("released")) 74 | qtbot.addWidget(wdg) 75 | 76 | checkbox = QCheckBox() 77 | emitted_from_checkbox = [] 78 | checkbox.toggled.connect(lambda: emitted_from_checkbox.append("toggled")) 79 | checkbox.pressed.connect(lambda: emitted_from_checkbox.append("pressed")) 80 | checkbox.clicked.connect(lambda: emitted_from_checkbox.append("clicked")) 81 | checkbox.released.connect(lambda: emitted_from_checkbox.append("released")) 82 | qtbot.addWidget(checkbox) 83 | 84 | emitted_from_toggleswitch.clear() 85 | emitted_from_checkbox.clear() 86 | wdg.toggle() 87 | checkbox.toggle() 88 | assert emitted_from_toggleswitch 89 | assert emitted_from_toggleswitch == emitted_from_checkbox 90 | 91 | emitted_from_toggleswitch.clear() 92 | emitted_from_checkbox.clear() 93 | wdg.click() 94 | checkbox.click() 95 | assert emitted_from_toggleswitch 96 | assert emitted_from_toggleswitch == emitted_from_checkbox 97 | 98 | 99 | def test_multiple_lines(qtbot): 100 | container = QWidget() 101 | layout = QVBoxLayout(container) 102 | wdg0 = QToggleSwitch("line1\nline2\nline3") 103 | wdg1 = QToggleSwitch("line1\nline2") 104 | checkbox = QCheckBox() 105 | layout.addWidget(wdg0) 106 | layout.addWidget(wdg1) 107 | layout.addWidget(checkbox) 108 | container.show() 109 | qtbot.addWidget(container) 110 | 111 | assert wdg0.text() == "line1\nline2\nline3" 112 | assert wdg1.text() == "line1\nline2" 113 | assert wdg0.sizeHint().height() > wdg1.sizeHint().height() 114 | assert wdg1.sizeHint().height() > checkbox.sizeHint().height() 115 | assert wdg0.height() > wdg1.height() 116 | assert wdg1.height() > checkbox.height() 117 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from unittest.mock import Mock 4 | 5 | import pytest 6 | import qtpy 7 | from qtpy.QtCore import QObject, QTimer, Signal 8 | from qtpy.QtWidgets import QApplication, QErrorMessage, QMessageBox 9 | 10 | from superqt.utils import exceptions_as_dialog, signals_blocked 11 | from superqt.utils._util import get_max_args 12 | 13 | 14 | def test_signal_blocker(qtbot): 15 | """make sure context manager signal blocker works""" 16 | 17 | class Emitter(QObject): 18 | sig = Signal() 19 | 20 | obj = Emitter() 21 | receiver = Mock() 22 | obj.sig.connect(receiver) 23 | 24 | # make sure signal works 25 | with qtbot.waitSignal(obj.sig): 26 | obj.sig.emit() 27 | 28 | receiver.assert_called_once() 29 | receiver.reset_mock() 30 | 31 | with signals_blocked(obj): 32 | obj.sig.emit() 33 | qtbot.wait(10) 34 | 35 | receiver.assert_not_called() 36 | 37 | 38 | def test_get_max_args_simple(): 39 | def fun1(): 40 | pass 41 | 42 | assert get_max_args(fun1) == 0 43 | 44 | def fun2(a): 45 | pass 46 | 47 | assert get_max_args(fun2) == 1 48 | 49 | def fun3(a, b=1): 50 | pass 51 | 52 | assert get_max_args(fun3) == 2 53 | 54 | def fun4(a, *, b=2): 55 | pass 56 | 57 | assert get_max_args(fun4) == 1 58 | 59 | def fun5(a, *b): 60 | pass 61 | 62 | assert get_max_args(fun5) is None 63 | 64 | assert get_max_args(print) is None 65 | 66 | 67 | def test_get_max_args_wrapped(): 68 | from functools import partial, wraps 69 | 70 | def fun1(a, b): 71 | pass 72 | 73 | assert get_max_args(partial(fun1, 1)) == 1 74 | 75 | def dec(fun): 76 | @wraps(fun) 77 | def wrapper(*args, **kwargs): 78 | return fun(*args, **kwargs) 79 | 80 | return wrapper 81 | 82 | assert get_max_args(dec(fun1)) == 2 83 | 84 | 85 | def test_get_max_args_methods(): 86 | class A: 87 | def fun1(self): 88 | pass 89 | 90 | def fun2(self, a): 91 | pass 92 | 93 | def __call__(self, a, b=1): 94 | pass 95 | 96 | assert get_max_args(A().fun1) == 0 97 | assert get_max_args(A().fun2) == 1 98 | assert get_max_args(A()) == 2 99 | 100 | 101 | MAC_CI_PYSIDE6 = bool( 102 | sys.platform == "darwin" and os.getenv("CI") and qtpy.API_NAME == "PySide6" 103 | ) 104 | 105 | 106 | @pytest.mark.skipif(MAC_CI_PYSIDE6, reason="still hangs on mac ci with pyside6") 107 | def test_exception_context(qtbot, qapp: QApplication) -> None: 108 | def accept(): 109 | for wdg in qapp.topLevelWidgets(): 110 | if isinstance(wdg, QMessageBox): 111 | wdg.button(QMessageBox.StandardButton.Ok).click() 112 | 113 | with exceptions_as_dialog(): 114 | QTimer.singleShot(0, accept) 115 | raise Exception("This will be caught and shown in a QMessageBox") 116 | 117 | with pytest.raises(ZeroDivisionError), exceptions_as_dialog(ValueError): 118 | 1 / 0 # noqa 119 | 120 | with exceptions_as_dialog(msg_template="Error: {exc_value}"): 121 | QTimer.singleShot(0, accept) 122 | raise Exception("This message will be used as 'exc_value'") 123 | 124 | err = QErrorMessage() 125 | with exceptions_as_dialog(use_error_message=err): 126 | QTimer.singleShot(0, err.accept) 127 | raise AssertionError("Uncheck the checkbox to ignore this in the future") 128 | 129 | # tb formatting smoke test, and return value checking 130 | exc = ValueError("Bad Val") 131 | with exceptions_as_dialog( 132 | msg_template="{tb}", 133 | buttons=QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel, 134 | ) as ctx: 135 | qtbot.addWidget(ctx.dialog) 136 | QTimer.singleShot(100, accept) 137 | raise exc 138 | 139 | assert isinstance(ctx.dialog, QMessageBox) 140 | assert ctx.dialog.result() == QMessageBox.StandardButton.Ok 141 | assert ctx.exception is exc 142 | -------------------------------------------------------------------------------- /tests/zz_test_sliders/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyapp-kit/superqt/a9fa7205770a8721887e1810461c5a918fd8190d/tests/zz_test_sliders/__init__.py -------------------------------------------------------------------------------- /tests/zz_test_sliders/_testutil.py: -------------------------------------------------------------------------------- 1 | from contextlib import suppress 2 | from platform import system 3 | 4 | import pytest 5 | from qtpy import QT_VERSION 6 | from qtpy.QtCore import QEvent, QPoint, QPointF, Qt 7 | from qtpy.QtGui import QHoverEvent, QMouseEvent, QWheelEvent 8 | 9 | QT_VERSION = tuple(int(x) for x in QT_VERSION.split(".")) 10 | 11 | SYS_DARWIN = system() == "Darwin" 12 | 13 | skip_on_linux_qt6 = pytest.mark.skipif( 14 | system() == "Linux" and QT_VERSION >= (6, 0), 15 | reason="hover events not working on linux pyqt6", 16 | ) 17 | 18 | _PointF = QPointF() 19 | 20 | 21 | def _mouse_event(pos=_PointF, type_=QEvent.Type.MouseMove): 22 | """Create a mouse event of `type_` at `pos`.""" 23 | return QMouseEvent( 24 | type_, 25 | QPointF(pos), # localPos 26 | QPointF(), # windowPos / globalPos 27 | Qt.MouseButton.LeftButton, # button 28 | Qt.MouseButton.LeftButton, # buttons 29 | Qt.KeyboardModifier.NoModifier, # modifiers 30 | ) 31 | 32 | 33 | def _wheel_event(arc): 34 | """Create a wheel event with `arc`.""" 35 | with suppress(TypeError): 36 | return QWheelEvent( 37 | QPointF(), 38 | QPointF(), 39 | QPoint(arc, arc), 40 | QPoint(arc, arc), 41 | Qt.MouseButton.NoButton, 42 | Qt.KeyboardModifier.NoModifier, 43 | Qt.ScrollPhase.ScrollBegin, 44 | False, 45 | Qt.MouseEventSource.MouseEventSynthesizedByQt, 46 | ) 47 | with suppress(TypeError): 48 | return QWheelEvent( 49 | QPointF(), 50 | QPointF(), 51 | QPoint(-arc, -arc), 52 | QPoint(-arc, -arc), 53 | 1, 54 | Qt.Orientation.Vertical, 55 | Qt.MouseButton.NoButton, 56 | Qt.KeyboardModifier.NoModifier, 57 | Qt.ScrollPhase.ScrollBegin, 58 | False, 59 | Qt.MouseEventSource.MouseEventSynthesizedByQt, 60 | ) 61 | 62 | return QWheelEvent( 63 | QPointF(), 64 | QPointF(), 65 | QPoint(arc, arc), 66 | QPoint(arc, arc), 67 | 1, 68 | Qt.Orientation.Vertical, 69 | Qt.MouseButton.NoButton, 70 | Qt.KeyboardModifier.NoModifier, 71 | ) 72 | 73 | 74 | def _hover_event(_type, position, old_position, widget=None): 75 | with suppress(TypeError): 76 | return QHoverEvent( 77 | _type, 78 | position, 79 | widget.mapToGlobal(position), 80 | old_position, 81 | ) 82 | return QHoverEvent(_type, position, old_position) 83 | 84 | 85 | def _linspace(start: int, stop: int, n: int): 86 | h = (stop - start) / (n - 1) 87 | for i in range(n): 88 | yield start + h * i 89 | -------------------------------------------------------------------------------- /tests/zz_test_sliders/test_float.py: -------------------------------------------------------------------------------- 1 | import math 2 | import os 3 | 4 | import pytest 5 | from qtpy import API_NAME 6 | from qtpy.QtWidgets import QStyleOptionSlider 7 | 8 | from superqt import ( 9 | QDoubleRangeSlider, 10 | QDoubleSlider, 11 | QLabeledDoubleRangeSlider, 12 | QLabeledDoubleSlider, 13 | ) 14 | 15 | from ._testutil import _linspace 16 | 17 | range_types = {QDoubleRangeSlider, QLabeledDoubleRangeSlider} 18 | 19 | 20 | @pytest.fixture( 21 | params=[ 22 | QDoubleSlider, 23 | QLabeledDoubleSlider, 24 | QDoubleRangeSlider, 25 | QLabeledDoubleRangeSlider, 26 | ] 27 | ) 28 | def ds(qtbot, request): 29 | # convenience fixture that converts value() and setValue() 30 | # to let us use setValue((a, b)) for both range and non-range sliders 31 | cls = request.param 32 | wdg = cls() 33 | qtbot.addWidget(wdg) 34 | 35 | def assert_val_type(): 36 | type_ = float 37 | if cls in range_types: 38 | assert all(isinstance(i, type_) for i in wdg.value()) # sourcery skip 39 | else: 40 | assert isinstance(wdg.value(), type_) 41 | 42 | def assert_val_eq(val): 43 | assert wdg.value() == val if cls is QDoubleRangeSlider else val[0] 44 | 45 | wdg.assert_val_type = assert_val_type 46 | wdg.assert_val_eq = assert_val_eq 47 | 48 | if cls not in range_types: 49 | superset = wdg.setValue 50 | 51 | def _safe_set(val): 52 | superset(val[0] if isinstance(val, tuple) else val) 53 | 54 | wdg.setValue = _safe_set 55 | 56 | return wdg 57 | 58 | 59 | def test_double_sliders(ds): 60 | ds.setMinimum(10) 61 | ds.setMaximum(99) 62 | ds.setValue((20, 40)) 63 | ds.setSingleStep(1) 64 | assert ds.minimum() == 10 65 | assert ds.maximum() == 99 66 | ds.assert_val_eq((20, 40)) 67 | assert ds.singleStep() == 1 68 | 69 | ds.assert_val_eq((20, 40)) 70 | ds.assert_val_type() 71 | 72 | ds.setValue((20.23, 40.23)) 73 | ds.assert_val_eq((20.23, 40.23)) 74 | ds.assert_val_type() 75 | 76 | assert ds.minimum() == 10 77 | assert ds.maximum() == 99 78 | assert ds.singleStep() == 1 79 | ds.assert_val_eq((20.23, 40.23)) 80 | ds.setValue((20.2343, 40.2342)) 81 | ds.assert_val_eq((20.2343, 40.2342)) 82 | 83 | ds.assert_val_eq((20.2343, 40.2342)) 84 | assert ds.minimum() == 10 85 | assert ds.maximum() == 99 86 | assert ds.singleStep() == 1 87 | 88 | ds.assert_val_eq((20.2343, 40.2342)) 89 | assert ds.minimum() == 10 90 | assert ds.maximum() == 99 91 | assert ds.singleStep() == 1 92 | 93 | 94 | def test_double_sliders_small(ds): 95 | ds.setMaximum(1) 96 | ds.setValue((0.5, 0.9)) 97 | assert ds.minimum() == 0 98 | assert ds.maximum() == 1 99 | ds.assert_val_eq((0.5, 0.9)) 100 | 101 | ds.setValue((0.122233, 0.72644353)) 102 | ds.assert_val_eq((0.122233, 0.72644353)) 103 | 104 | 105 | def test_double_sliders_big(ds): 106 | ds.setValue((20, 80)) 107 | ds.setMaximum(5e14) 108 | assert ds.minimum() == 0 109 | assert ds.maximum() == 5e14 110 | ds.setValue((1.74e9, 1.432e10)) 111 | ds.assert_val_eq((1.74e9, 1.432e10)) 112 | 113 | 114 | @pytest.mark.skipif( 115 | os.name == "nt" and API_NAME == "PyQt6", reason="Not ready for pyqt6" 116 | ) 117 | def test_signals(ds, qtbot): 118 | with qtbot.waitSignal(ds.valueChanged): 119 | ds.setValue((10, 20)) 120 | 121 | with qtbot.waitSignal(ds.rangeChanged): 122 | ds.setMinimum(0.5) 123 | 124 | with qtbot.waitSignal(ds.rangeChanged): 125 | ds.setMaximum(3.7) 126 | 127 | with qtbot.waitSignal(ds.rangeChanged): 128 | ds.setRange(1.2, 3.3) 129 | 130 | 131 | @pytest.mark.parametrize("mag", list(range(4, 37, 4)) + list(range(-4, -37, -4))) 132 | def test_slider_extremes(mag, qtbot): 133 | sld = QDoubleSlider() 134 | _mag = 10**mag 135 | with qtbot.waitSignal(sld.rangeChanged): 136 | sld.setRange(-_mag, _mag) 137 | for i in _linspace(-_mag, _mag, 10): 138 | sld.setValue(i) 139 | assert math.isclose(sld.value(), i, rel_tol=1e-8) 140 | sld.initStyleOption(QStyleOptionSlider()) 141 | -------------------------------------------------------------------------------- /tests/zz_test_sliders/test_labeled_slider.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable 2 | from typing import Any 3 | from unittest.mock import Mock 4 | 5 | import pytest 6 | 7 | from superqt import QLabeledDoubleSlider, QLabeledRangeSlider, QLabeledSlider 8 | 9 | 10 | def test_labeled_slider_api(qtbot): 11 | slider = QLabeledRangeSlider() 12 | qtbot.addWidget(slider) 13 | slider.hideBar() 14 | slider.showBar() 15 | slider.setBarVisible() 16 | slider.setBarMovesAllHandles() 17 | slider.setBarIsRigid() 18 | 19 | 20 | def test_slider_connect_works(qtbot): 21 | slider = QLabeledSlider() 22 | qtbot.addWidget(slider) 23 | 24 | slider._label.editingFinished.emit() 25 | 26 | 27 | def _assert_types(args: Iterable[Any], type_: type): 28 | # sourcery skip: comprehension-to-generator 29 | assert all(isinstance(v, type_) for v in args), "invalid type" 30 | 31 | 32 | @pytest.mark.parametrize("cls", [QLabeledDoubleSlider, QLabeledSlider]) 33 | def test_labeled_signals(cls, qtbot): 34 | gslider = cls() 35 | qtbot.addWidget(gslider) 36 | 37 | type_ = float if cls == QLabeledDoubleSlider else int 38 | 39 | mock = Mock() 40 | gslider.valueChanged.connect(mock) 41 | with qtbot.waitSignal(gslider.valueChanged): 42 | gslider.setValue(10) 43 | mock.assert_called_once_with(10) 44 | _assert_types(mock.call_args.args, type_) 45 | 46 | mock = Mock() 47 | gslider.rangeChanged.connect(mock) 48 | with qtbot.waitSignal(gslider.rangeChanged): 49 | gslider.setMinimum(3) 50 | mock.assert_called_once_with(3, 99) 51 | _assert_types(mock.call_args.args, type_) 52 | 53 | mock.reset_mock() 54 | with qtbot.waitSignal(gslider.rangeChanged): 55 | gslider.setMaximum(15) 56 | mock.assert_called_once_with(3, 15) 57 | _assert_types(mock.call_args.args, type_) 58 | 59 | mock.reset_mock() 60 | with qtbot.waitSignal(gslider.rangeChanged): 61 | gslider.setRange(1, 2) 62 | mock.assert_called_once_with(1, 2) 63 | _assert_types(mock.call_args.args, type_) 64 | 65 | 66 | @pytest.mark.parametrize( 67 | "cls", [QLabeledDoubleSlider, QLabeledRangeSlider, QLabeledSlider] 68 | ) 69 | def test_editing_finished_signal(cls, qtbot): 70 | mock = Mock() 71 | slider = cls() 72 | qtbot.addWidget(slider) 73 | slider.editingFinished.connect(mock) 74 | if hasattr(slider, "_label"): 75 | slider._label.editingFinished.emit() 76 | else: 77 | slider._min_label.editingFinished.emit() 78 | mock.assert_called_once() 79 | 80 | 81 | def test_editing_float(qtbot): 82 | slider = QLabeledDoubleSlider() 83 | qtbot.addWidget(slider) 84 | slider._label.setValue(0.5) 85 | slider._label.editingFinished.emit() 86 | assert slider.value() == 0.5 87 | -------------------------------------------------------------------------------- /tests/zz_test_sliders/test_slider.py: -------------------------------------------------------------------------------- 1 | import platform 2 | 3 | import pytest 4 | from qtpy import API_NAME 5 | from qtpy.QtCore import Qt 6 | 7 | from superqt import QRangeSlider 8 | from superqt.sliders._generic_range_slider import SC_BAR, SC_HANDLE, SC_NONE 9 | 10 | LINUX = platform.system() == "Linux" 11 | NOT_PYQT6 = API_NAME != "PyQt6" 12 | 13 | skipmouse = pytest.mark.skipif(LINUX or NOT_PYQT6, reason="mouse tests finicky") 14 | 15 | 16 | @pytest.mark.parametrize("orientation", ["Horizontal", "Vertical"]) 17 | def test_basic(qtbot, orientation): 18 | rs = QRangeSlider(getattr(Qt.Orientation, orientation)) 19 | qtbot.addWidget(rs) 20 | 21 | 22 | @pytest.mark.parametrize("orientation", ["Horizontal", "Vertical"]) 23 | def test_value(qtbot, orientation): 24 | rs = QRangeSlider(getattr(Qt.Orientation, orientation)) 25 | qtbot.addWidget(rs) 26 | rs.setValue([10, 20]) 27 | assert rs.value() == (10, 20) 28 | 29 | 30 | @pytest.mark.parametrize("orientation", ["Horizontal", "Vertical"]) 31 | def test_range(qtbot, orientation): 32 | rs = QRangeSlider(getattr(Qt.Orientation, orientation)) 33 | qtbot.addWidget(rs) 34 | rs.setValue([10, 20]) 35 | assert rs.value() == (10, 20) 36 | rs.setRange(15, 20) 37 | assert rs.value() == (15, 20) 38 | assert rs.minimum() == 15 39 | assert rs.maximum() == 20 40 | 41 | 42 | @skipmouse 43 | def test_drag_handles(qtbot): 44 | rs = QRangeSlider(Qt.Orientation.Horizontal) 45 | qtbot.addWidget(rs) 46 | rs.setRange(0, 99) 47 | rs.setValue((20, 80)) 48 | rs.setMouseTracking(True) 49 | rs.show() 50 | 51 | # press the left handle 52 | pos = rs._handleRect(0).center() 53 | with qtbot.waitSignal(rs.sliderPressed): 54 | qtbot.mousePress(rs, Qt.MouseButton.LeftButton, pos=pos) 55 | assert rs._pressedControl == SC_HANDLE 56 | assert rs._pressedIndex == 0 57 | 58 | # drag the left handle 59 | with qtbot.waitSignals([rs.sliderMoved] * 13): # couple less signals 60 | for _ in range(15): 61 | pos.setX(pos.x() + 2) 62 | qtbot.mouseMove(rs, pos) 63 | 64 | with qtbot.waitSignal(rs.sliderReleased): 65 | qtbot.mouseRelease(rs, Qt.MouseButton.LeftButton) 66 | 67 | # check the values 68 | assert rs.value()[0] > 30 69 | assert rs._pressedControl == SC_NONE 70 | 71 | # press the right handle 72 | pos = rs._handleRect(1).center() 73 | with qtbot.waitSignal(rs.sliderPressed): 74 | qtbot.mousePress(rs, Qt.MouseButton.LeftButton, pos=pos) 75 | assert rs._pressedControl == SC_HANDLE 76 | assert rs._pressedIndex == 1 77 | 78 | # drag the right handle 79 | with qtbot.waitSignals([rs.sliderMoved] * 13): # couple less signals 80 | for _ in range(15): 81 | pos.setX(pos.x() - 2) 82 | qtbot.mouseMove(rs, pos) 83 | with qtbot.waitSignal(rs.sliderReleased): 84 | qtbot.mouseRelease(rs, Qt.MouseButton.LeftButton) 85 | 86 | # check the values 87 | assert rs.value()[1] < 70 88 | assert rs._pressedControl == SC_NONE 89 | 90 | 91 | @skipmouse 92 | def test_drag_handles_beyond_edge(qtbot): 93 | rs = QRangeSlider(Qt.Orientation.Horizontal) 94 | qtbot.addWidget(rs) 95 | rs.setRange(0, 99) 96 | rs.setValue((20, 80)) 97 | rs.setMouseTracking(True) 98 | rs.show() 99 | 100 | # press the right handle 101 | pos = rs._handleRect(1).center() 102 | with qtbot.waitSignal(rs.sliderPressed): 103 | qtbot.mousePress(rs, Qt.MouseButton.LeftButton, pos=pos) 104 | assert rs._pressedControl == SC_HANDLE 105 | assert rs._pressedIndex == 1 106 | 107 | # drag the handle off the right edge and make sure the value gets to the max 108 | for _ in range(7): 109 | pos.setX(pos.x() + 10) 110 | qtbot.mouseMove(rs, pos) 111 | 112 | with qtbot.waitSignal(rs.sliderReleased): 113 | qtbot.mouseRelease(rs, Qt.MouseButton.LeftButton) 114 | 115 | assert rs.value()[1] == 99 116 | 117 | 118 | @skipmouse 119 | def test_bar_drag_beyond_edge(qtbot): 120 | rs = QRangeSlider(Qt.Orientation.Horizontal) 121 | qtbot.addWidget(rs) 122 | rs.setRange(0, 99) 123 | rs.setValue((20, 80)) 124 | rs.setMouseTracking(True) 125 | rs.show() 126 | 127 | # press the right handle 128 | pos = rs.rect().center() 129 | with qtbot.waitSignal(rs.sliderPressed): 130 | qtbot.mousePress(rs, Qt.MouseButton.LeftButton, pos=pos) 131 | assert rs._pressedControl == SC_BAR 132 | assert rs._pressedIndex == 1 133 | 134 | # drag the handle off the right edge and make sure the value gets to the max 135 | for _ in range(15): 136 | pos.setX(pos.x() + 10) 137 | qtbot.mouseMove(rs, pos) 138 | 139 | with qtbot.waitSignal(rs.sliderReleased): 140 | qtbot.mouseRelease(rs, Qt.MouseButton.LeftButton) 141 | 142 | assert rs.value()[1] == 99 143 | --------------------------------------------------------------------------------