Compare commits

...

39 Commits

Author SHA1 Message Date
Talley Lambert
17fd211740 test: drop old napari test (#296) 2025-07-16 18:14:27 -04:00
pre-commit-ci[bot]
3b83a8a1e2 ci: [pre-commit.ci] autoupdate (#299)
* ci: [pre-commit.ci] autoupdate

updates:
- [github.com/astral-sh/ruff-pre-commit: v0.11.12 → v0.12.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.12...v0.12.2)
- [github.com/pre-commit/mirrors-mypy: v1.16.0 → v1.16.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.16.0...v1.16.1)

* style: [pre-commit.ci] auto fixes [...]

* pin pytestqt

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
2025-07-16 18:14:01 -04:00
Talley Lambert
13e033e4a2 chore: changelog v0.7.5 2025-06-17 20:24:57 -04:00
Grzegorz Bokota
55b66393c3 Use scientific notation for big values in labeled slider (#226)
* initial implementation

* fix formating labels

* add minimum number of decimals

* fix typo in function name

* add `decimals` method

* fix after napari src migration

* use --import-mode=importlib

* allow enforce decimals

* fix seting 0

* flexible set range for range labels

* better set range

* fix seting mode

* fix max calculation

---------

Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
2025-06-17 20:20:30 -04:00
Talley Lambert
b495c70206 chore: changelog v0.7.4 2025-06-10 10:23:25 -04:00
Lorenzo Gaifas
a9fa720577 Allow setting label position on labeled slider (#294) 2025-06-03 05:24:58 -04:00
pre-commit-ci[bot]
257d97ae0f ci: [pre-commit.ci] autoupdate (#297) 2025-06-02 18:32:50 -04:00
Gabriel Selzer
7193480796 fix: Set SliderProxy range params to Any (#290)
* fix: Set SliderProxy range params to Any

When the types are ints, this raises mypy errors when e.g. setting the
range of a QDoubleSlider to float values.

This change aligns with the parameter typing of _SliderProxy.setValue

* Update src/superqt/sliders/_labeled.py

---------

Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
2025-06-02 16:35:13 -04:00
pre-commit-ci[bot]
788d0f0325 ci: [pre-commit.ci] autoupdate (#289)
* ci: [pre-commit.ci] autoupdate

updates:
- [github.com/astral-sh/ruff-pre-commit: v0.9.9 → v0.11.8](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.9...v0.11.8)
- [github.com/abravalheri/validate-pyproject: v0.23 → v0.24.1](https://github.com/abravalheri/validate-pyproject/compare/v0.23...v0.24.1)

* style: [pre-commit.ci] auto fixes [...]

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-06-02 16:27:12 -04:00
Lorenzo Gaifas
935025eacc Fix napari test (#295)
* move napari to src

* use importlib import for new test
2025-06-02 16:25:36 -04:00
Sandro
358d041c0d Make qimage_to_array() work on big endian (#288)
* Make qimage_to_array() work on big endian

Make sure the returned ndarray is ordered the same as on little
endian systems.

Solves #287

* style: [pre-commit.ci] auto fixes [...]

* Update src/superqt/utils/_img_utils.py

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Grzegorz Bokota <bokota+github@gmail.com>
2025-04-06 23:44:07 -04:00
Talley Lambert
49a8114843 docs: document QToggleSwitch (#286)
* wip

* add toggleswitch

* style: [pre-commit.ci] auto fixes [...]

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-03-28 15:30:58 -04:00
Talley Lambert
c0c3a387bb chore: changelog v0.7.3 2025-03-28 15:18:47 -04:00
Hanjin Liu
5ce74b8198 feat: toggle switch (#284)
* implement toggle switch

* rename, inherit QCheckBox

* fix pyside6

* reimplement with QAbstractButton

* refactor methods

* fix sizeHint

* suggestions

* make sizes customizable

* parse as int

* Add doc to test function

Co-authored-by: Grzegorz Bokota <bokota+github@gmail.com>

---------

Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
Co-authored-by: Grzegorz Bokota <bokota+github@gmail.com>
2025-03-28 15:09:59 -04:00
Peter Sobolewski
0b2602b460 Enh: Adds a filterable kwarg to ColormapComboBox enabling filtering (like Catalog) (#278) 2025-03-17 19:16:44 -04:00
Talley Lambert
f9bc334228 chore: changelog v0.7.2 2025-03-17 08:53:11 -04:00
Talley Lambert
55732afa71 fix: less Slider signal renaming, make alternate signal types public (#283)
* fix: less signal renaming

* style: [pre-commit.ci] auto fixes [...]

* lint

* more renames

* style: [pre-commit.ci] auto fixes [...]

* warn napari

* lint

* add comment

* remove napari getattr

* style: [pre-commit.ci] auto fixes [...]

* add back values changed

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-03-17 08:51:50 -04:00
pre-commit-ci[bot]
22372f58a4 ci: [pre-commit.ci] autoupdate (#282)
* ci: [pre-commit.ci] autoupdate

updates:
- [github.com/astral-sh/ruff-pre-commit: v0.9.4 → v0.9.9](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.4...v0.9.9)
- [github.com/pre-commit/mirrors-mypy: v1.14.1 → v1.15.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.14.1...v1.15.0)

* style: [pre-commit.ci] auto fixes [...]

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-03-16 11:35:09 -04:00
pre-commit-ci[bot]
e990284bd1 ci: [pre-commit.ci] autoupdate (#279)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.8.6 → v0.9.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.8.6...v0.9.4)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-02-21 13:05:43 -05:00
Peter Sobolewski
7850e53b61 Update CONTRIBUTING.md to include [test] and mention Qt backend (#276)
* Update CONTRIBUTING.md to include [test] and mention Qt backend

* add superqt[test,pyqt6] to dev, mention it in contributing guide
2025-01-26 16:15:57 -05:00
Peter Sobolewski
68bafaceaa Update CONTRIBUTING.md to install .[dev] first then pre-commit (#275) 2025-01-26 14:34:22 -05:00
pre-commit-ci[bot]
0b1cd1b11a ci: [pre-commit.ci] autoupdate (#272)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.8.1 → v0.8.6](https://github.com/astral-sh/ruff-pre-commit/compare/v0.8.1...v0.8.6)
- [github.com/pre-commit/mirrors-mypy: v1.13.0 → v1.14.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.13.0...v1.14.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-01-24 13:50:58 -05:00
Talley Lambert
646cb4ea48 docs: add iconify docs 2025-01-05 17:12:37 -05:00
Talley Lambert
03978cc37a chore: changelog v0.7.1 2025-01-05 16:34:27 -05:00
Hanjin Liu
048aaa45a7 Lazy-import pyconify (#270)
* lazy-import pyconify

* change import pattern

---------

Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
2025-01-05 16:30:51 -05:00
Talley Lambert
3ff2d7ccce feat: add QFlowLayout, for variable width widgets (#271)
* feat: add QLayout

* add to docs
2025-01-05 16:17:27 -05:00
Talley Lambert
6a7a731c5d feat: Improve CodeSyntaxHighlight object (#268) 2024-12-25 07:57:13 -05:00
Talley Lambert
4da5ac262c feat: allow chaining of QIconifyIcon.addKey (#267) 2024-12-21 12:46:01 -05:00
Talley Lambert
e471031f19 fix: better warning for download error (#266) 2024-12-14 15:29:19 -05:00
Talley Lambert
34b9851b36 chore: changelog v0.7.0 2024-12-14 14:41:40 -05:00
Talley Lambert
8ede2a2f39 build: support py313 (#264)
* build: drop py38

* bump min typing ext

* add py313

* only use pyqt6

* fix ubunt
2024-12-14 12:37:26 -05:00
Hanjin Liu
df008464cc Fix KeyError in CodeSyntaxHighlight (#258)
* use dict.get

* typing

* Update src/superqt/utils/_code_syntax_highlight.py

Co-authored-by: Grzegorz Bokota <bokota+github@gmail.com>

---------

Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
Co-authored-by: Grzegorz Bokota <bokota+github@gmail.com>
2024-12-14 12:37:17 -05:00
Talley Lambert
e99adaac03 Revert "remove stylesheet on sliderLabel (#254)" (#265)
This reverts commit 7e92b81711.
2024-12-14 12:36:48 -05:00
Talley Lambert
8a40170c89 build: drop py38 (#263)
* build: drop py38

* bump min typing ext

* ignore cleanup warning from pyside6

* change minreq

* bump min

* fix for pint again
2024-12-13 09:30:27 -05:00
Gabriel Selzer
2f3113f0f6 End painter when drawing colormap (#262)
* End painter when drawing colormap

* Only end painter if we created it
2024-12-12 19:27:54 -05:00
pre-commit-ci[bot]
c9528ff85a ci: [pre-commit.ci] autoupdate (#257)
* ci: [pre-commit.ci] autoupdate

updates:
- [github.com/astral-sh/ruff-pre-commit: v0.6.9 → v0.8.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.9...v0.8.1)
- [github.com/abravalheri/validate-pyproject: v0.20.2 → v0.23](https://github.com/abravalheri/validate-pyproject/compare/v0.20.2...v0.23)
- [github.com/pre-commit/mirrors-mypy: v1.11.2 → v1.13.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.11.2...v1.13.0)

* style: [pre-commit.ci] auto fixes [...]

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-12-03 11:03:25 -05:00
Gabriel Selzer
e7a87897f5 fix: minimum size hint for QElidingLabel (#260) 2024-11-26 16:53:12 -05:00
pre-commit-ci[bot]
952ac336bf ci: [pre-commit.ci] autoupdate (#253)
* ci: [pre-commit.ci] autoupdate

updates:
- [github.com/astral-sh/ruff-pre-commit: v0.4.7 → v0.6.3](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.7...v0.6.3)
- [github.com/abravalheri/validate-pyproject: v0.18 → v0.19](https://github.com/abravalheri/validate-pyproject/compare/v0.18...v0.19)
- [github.com/pre-commit/mirrors-mypy: v1.10.0 → v1.11.2](https://github.com/pre-commit/mirrors-mypy/compare/v1.10.0...v1.11.2)

* style: [pre-commit.ci] auto fixes [...]

* fix lint

* update

* no pyside 6.8

* update pins

* quotes

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
2024-10-12 12:35:17 -04:00
Talley Lambert
7e92b81711 remove stylesheet on sliderLabel (#254) 2024-10-12 12:01:32 -04:00
63 changed files with 1770 additions and 311 deletions

View File

@@ -27,7 +27,7 @@ jobs:
fail-fast: false
matrix:
platform: [ubuntu-latest, windows-latest, macos-13]
python-version: ["3.8", "3.9", "3.10", "3.11"]
python-version: ["3.9", "3.10", "3.11", "3.12"]
backend: [pyqt5, pyside2, pyqt6]
exclude:
# Abort (core dumped) on linux pyqt6, unknown reason
@@ -36,39 +36,48 @@ jobs:
# lack of wheels for pyside2/py3.11
- python-version: "3.11"
backend: pyside2
include:
- python-version: "3.10"
platform: macos-latest
backend: pyside6
- python-version: "3.11"
platform: macos-latest
backend: pyside6
- python-version: "3.10"
platform: windows-latest
backend: pyside6
- python-version: "3.11"
platform: windows-latest
backend: pyside6
- python-version: "3.12"
backend: pyside2
- python-version: "3.12"
backend: pyqt5
include:
- python-version: "3.13"
platform: windows-latest
backend: "pyqt6"
- python-version: "3.13"
platform: ubuntu-latest
backend: "pyqt6"
- python-version: "3.10"
platform: macos-latest
backend: pyqt6
backend: "'pyside6<6.8'"
- python-version: "3.11"
platform: macos-latest
backend: "'pyside6<6.8'"
- python-version: "3.10"
platform: windows-latest
backend: "'pyside6<6.8'"
- python-version: "3.12"
platform: windows-latest
backend: "'pyside6<6.8'"
# legacy Qt
- python-version: 3.8
- python-version: 3.9
platform: ubuntu-latest
backend: "pyqt5==5.12.*"
- python-version: 3.8
- python-version: 3.9
platform: ubuntu-latest
backend: "pyqt5==5.13.*"
- python-version: 3.8
- python-version: 3.9
platform: ubuntu-latest
backend: "pyqt5==5.14.*"
test-qt-minreqs:
uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@v2
with:
python-version: "3.8"
python-version: "3.9"
qt: pyqt5
pip-post-installs: "qtpy==1.1.0 typing-extensions==3.7.4.3"
pip-post-installs: "qtpy==1.1.0 typing-extensions==4.5.0" # 4.5.0 is just for pint
pip-install-flags: -e
coverage-upload: artifact
@@ -85,14 +94,14 @@ jobs:
dependency-ref: ${{ matrix.napari-version }}
dependency-extras: "testing"
qt: ${{ matrix.qt }}
pytest-args: 'napari/_qt -k "not async and not qt_dims_2 and not qt_viewer_console_focus and not keybinding_editor"'
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"'
python-version: "3.10"
post-install-cmd: "pip install lxml_html_clean"
strategy:
fail-fast: false
matrix:
napari-version: ["", "v0.4.19.post1"]
qt: ["pyqt5", "pyside2"]
napari-version: [ "" ]
qt: [ "pyqt5", "pyside2" ]
check-manifest:
name: Check Manifest

View File

@@ -5,19 +5,19 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.7
rev: v0.12.3
hooks:
- id: ruff
args: [--fix, --unsafe-fixes]
- id: ruff-format
- repo: https://github.com/abravalheri/validate-pyproject
rev: v0.18
rev: v0.24.1
hooks:
- id: validate-pyproject
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.10.0
rev: v1.17.0
hooks:
- id: mypy
exclude: tests|examples

View File

@@ -1,5 +1,99 @@
# Changelog
## [v0.7.5](https://github.com/pyapp-kit/superqt/tree/v0.7.5) (2025-06-18)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.7.4...v0.7.5)
**Implemented enhancements:**
- feat: Use scientific notation for big values in labeled slider [\#226](https://github.com/pyapp-kit/superqt/pull/226) ([Czaki](https://github.com/Czaki))
## [v0.7.4](https://github.com/pyapp-kit/superqt/tree/v0.7.4) (2025-06-10)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.7.3...v0.7.4)
**Implemented enhancements:**
- feat: Allow setting label position on labeled slider [\#294](https://github.com/pyapp-kit/superqt/pull/294) ([brisvag](https://github.com/brisvag))
**Fixed bugs:**
- fix: Set SliderProxy range params to Any [\#290](https://github.com/pyapp-kit/superqt/pull/290) ([gselzer](https://github.com/gselzer))
- Make qimage\_to\_array\(\) work on big endian [\#288](https://github.com/pyapp-kit/superqt/pull/288) ([penguinpee](https://github.com/penguinpee))
**Documentation updates:**
- docs: document QToggleSwitch [\#286](https://github.com/pyapp-kit/superqt/pull/286) ([tlambert03](https://github.com/tlambert03))
**Tests & CI:**
- Fix napari test [\#295](https://github.com/pyapp-kit/superqt/pull/295) ([brisvag](https://github.com/brisvag))
## [v0.7.3](https://github.com/pyapp-kit/superqt/tree/v0.7.3) (2025-03-28)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.7.2...v0.7.3)
**Implemented enhancements:**
- feat: toggle switch [\#284](https://github.com/pyapp-kit/superqt/pull/284) ([hanjinliu](https://github.com/hanjinliu))
- Enh: Adds a filterable kwarg to ColormapComboBox enabling filtering \(like Catalog\) [\#278](https://github.com/pyapp-kit/superqt/pull/278) ([psobolewskiPhD](https://github.com/psobolewskiPhD))
## [v0.7.2](https://github.com/pyapp-kit/superqt/tree/v0.7.2) (2025-03-17)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.7.1...v0.7.2)
**Implemented enhancements:**
- fix: less Slider signal renaming, make alternate signal types public [\#283](https://github.com/pyapp-kit/superqt/pull/283) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- ci: \[pre-commit.ci\] autoupdate [\#282](https://github.com/pyapp-kit/superqt/pull/282) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci))
- ci: \[pre-commit.ci\] autoupdate [\#279](https://github.com/pyapp-kit/superqt/pull/279) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci))
- Update CONTRIBUTING.md to include \[test\] and mention Qt backend [\#276](https://github.com/pyapp-kit/superqt/pull/276) ([psobolewskiPhD](https://github.com/psobolewskiPhD))
- Update CONTRIBUTING.md to install .\[dev\] first then pre-commit [\#275](https://github.com/pyapp-kit/superqt/pull/275) ([psobolewskiPhD](https://github.com/psobolewskiPhD))
- ci: \[pre-commit.ci\] autoupdate [\#272](https://github.com/pyapp-kit/superqt/pull/272) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci))
## [v0.7.1](https://github.com/pyapp-kit/superqt/tree/v0.7.1) (2025-01-05)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.7.0...v0.7.1)
**Implemented enhancements:**
- feat: add QFlowLayout, for variable width widgets [\#271](https://github.com/pyapp-kit/superqt/pull/271) ([tlambert03](https://github.com/tlambert03))
- feat: Improve CodeSyntaxHighlight object [\#268](https://github.com/pyapp-kit/superqt/pull/268) ([tlambert03](https://github.com/tlambert03))
- feat: allow chaining of QIconifyIcon.addKey [\#267](https://github.com/pyapp-kit/superqt/pull/267) ([tlambert03](https://github.com/tlambert03))
**Fixed bugs:**
- fix: better warning for download error [\#266](https://github.com/pyapp-kit/superqt/pull/266) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- Lazy-import `pyconify` [\#270](https://github.com/pyapp-kit/superqt/pull/270) ([hanjinliu](https://github.com/hanjinliu))
## [v0.7.0](https://github.com/pyapp-kit/superqt/tree/v0.7.0) (2024-12-14)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.8...v0.7.0)
**Fixed bugs:**
- fix: End painter when drawing colormap [\#262](https://github.com/pyapp-kit/superqt/pull/262) ([gselzer](https://github.com/gselzer))
- fix: minimum size hint for QElidingLabel [\#260](https://github.com/pyapp-kit/superqt/pull/260) ([gselzer](https://github.com/gselzer))
- fix: KeyError in CodeSyntaxHighlight [\#258](https://github.com/pyapp-kit/superqt/pull/258) ([hanjinliu](https://github.com/hanjinliu))
**Refactors:**
- chore: Revert "remove stylesheet on sliderLabel \(\#254\)" [\#265](https://github.com/pyapp-kit/superqt/pull/265) ([tlambert03](https://github.com/tlambert03))
- refactor: remove stylesheet on sliderLabel [\#254](https://github.com/pyapp-kit/superqt/pull/254) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- build: support py313 [\#264](https://github.com/pyapp-kit/superqt/pull/264) ([tlambert03](https://github.com/tlambert03))
- build: drop py38 [\#263](https://github.com/pyapp-kit/superqt/pull/263) ([tlambert03](https://github.com/tlambert03))
- ci: \[pre-commit.ci\] autoupdate [\#257](https://github.com/pyapp-kit/superqt/pull/257) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci))
- ci: \[pre-commit.ci\] autoupdate [\#253](https://github.com/pyapp-kit/superqt/pull/253) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci))
## [v0.6.8](https://github.com/pyapp-kit/superqt/tree/v0.6.8) (2024-06-15)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.7...v0.6.8)

View File

@@ -12,12 +12,12 @@ To get started fork this repository, and clone your fork:
git clone https://github.com/<your_organization>/superqt
cd superqt
# install in editable mode (this will install PyQt6 as the Qt backend)
pip install -e .[dev]
# install pre-commit hooks
pre-commit install
# install in editable mode
pip install -e .[dev]
# run tests & make sure everything is working!
pytest
```
@@ -26,7 +26,7 @@ pytest
All widgets must be well-tested, and should work on:
- Python 3.8 and above
- Python 3.9 and above
- PyQt5 (5.11 and above) & PyQt6
- PySide2 (5.11 and above) & PySide6
- macOS, Windows, & Linux

View File

@@ -15,7 +15,7 @@ that are not provided in the native QtWidgets module.
Components are tested on:
- macOS, Windows, & Linux
- Python 3.8 and above
- Python 3.9 and above
- PyQt5 (5.11 and above) & PyQt6
- PySide2 (5.11 and above) & PySide6

View File

@@ -35,7 +35,7 @@ def define_env(env: "MacrosPlugin"):
src = src.replace(
"QApplication([])", "QApplication.instance() or QApplication([])"
)
src = src.replace("app.exec_()", "")
src = src.replace("app.exec_()", "app.processEvents()")
exec(src)
_grab(dest, width)
@@ -127,7 +127,6 @@ def define_env(env: "MacrosPlugin"):
def _grab(dest: str | Path, width) -> list[Path]:
"""Grab the top widgets of the application."""
from qtpy.QtCore import QTimer
from qtpy.QtWidgets import QApplication
w = QApplication.topLevelWidgets()[-1]
@@ -135,12 +134,3 @@ def _grab(dest: str | Path, width) -> list[Path]:
w.activateWindow()
w.setMinimumHeight(40)
w.grab().save(str(dest))
# hack to make sure the object is truly closed and deleted
while True:
QTimer.singleShot(10, w.deleteLater)
QApplication.processEvents()
try:
w.parent()
except RuntimeError:
return

View File

@@ -10,7 +10,7 @@ QtWidgets module.
Components are tested on:
- macOS, Windows, & Linux
- Python 3.8 and above
- Python 3.9 and above
- PyQt5 (5.11 and above) & PyQt6
- PySide2 (5.11 and above) & PySide6

36
docs/utilities/iconify.md Normal file
View File

@@ -0,0 +1,36 @@
# QIconifyIcon
[Iconify](https://iconify.design/) is an icon library that includes 150,000+
icons from most major icon sets including Bootstrap, FontAwesome, Material
Design, and many more; each available as individual SVGs. Unlike the
[`superqt.fonticon` module](./fonticon.md), `superqt.QIconifyIcon` does not require any additional
dependencies or font files to be installed. Icons are downloaded (and cached)
on-demand from the Iconify API, using [pyconify](https://github.com/pyapp-kit/pyconify)
Search availble icons at <https://icon-sets.iconify.design>
Once you find one you like, use the key in the format `"prefix:name"` to create an
icon: `QIconifyIcon("bi:bell")`.
## Basic Example
```python
from qtpy.QtCore import QSize
from qtpy.QtWidgets import QApplication, QPushButton
from superqt import QIconifyIcon
app = QApplication([])
btn = QPushButton()
btn.setIcon(QIconifyIcon("fluent-emoji-flat:alarm-clock"))
btn.setIconSize(QSize(60, 60))
btn.show()
app.exec()
```
{{ show_widget(225) }}
::: superqt.QIconifyIcon
options:
heading_level: 3

View File

@@ -12,6 +12,12 @@
| [`IconOpts`](./fonticon.md#superqt.fonticon.IconOpts) | Options for rendering an icon |
| [`Animation`](./fonticon.md#superqt.fonticon.Animation) | Base class for adding animations to a font-icon. |
## SVG Icons
| Object | Description |
| ----------- | --------------------- |
| [`QIconifyIcon`](./iconify.md) | QIcons backed by the [Iconify](https://iconify.design/) icon library. |
## Threading tools
| Object | Description |

View File

@@ -11,7 +11,7 @@ running in the desired thread:
`ensure_object_thread` ensures that a decorated bound method of a `QObject` runs
in the thread in which the instance lives ([see qt documentation for
details](https://doc.qt.io/qt-5/threads-qobject.html#accessing-qobject-subclasses-from-other-threads)).
details](https://doc.qt.io/qt-6/threads-qobject.html#accessing-qobject-subclasses-from-other-threads)).
## Usage

View File

@@ -27,9 +27,11 @@ The following are QWidget subclasses:
| [`QSearchableTreeWidget`](./qsearchabletreewidget.md) | `QTreeWidget` variant with search field that filters available options |
| [`QColorComboBox`](./qcolorcombobox.md) | `QComboBox` to select from a specified set of colors |
| [`QColormapComboBox`](./qcolormap.md) | `QComboBox` to select from a specified set of colormaps. |
| [`QToggleSwitch`](./qtoggleswitch.md) | `QAbstractButton` that represents a boolean value with a toggle switch. |
## Frames and containers
| Widget | Description |
| ----------- | --------------------- |
| [`QCollapsible`](./qcollapsible.md) | A collapsible widget to hide and unhide child widgets. |
| [`QFlowLayout`](./qflowlayout.md) | A layout that rearranges items based on parent width. |

View File

@@ -1,7 +1,7 @@
# QEnumComboBox
`QEnumComboBox` is a variant of
[`QComboBox`](https://doc.qt.io/qt-5/qcombobox.html) that populates the items in
[`QComboBox`](https://doc.qt.io/qt-6/qcombobox.html) that populates the items in
the combobox based on a python `Enum` class. In addition to all the methods
provided by `QComboBox`, this subclass adds the methods
`enumClass`/`setEnumClass` to get/set the current `Enum` class represented by

View File

@@ -0,0 +1,29 @@
# QFlowLayout
QLayout that rearranges items based on parent width.
```python
from qtpy.QtWidgets import QApplication, QPushButton, QWidget
from superqt import QFlowLayout
app = QApplication([])
wdg = QWidget()
layout = QFlowLayout(wdg)
layout.addWidget(QPushButton("Short"))
layout.addWidget(QPushButton("Longer"))
layout.addWidget(QPushButton("Different text"))
layout.addWidget(QPushButton("More text"))
layout.addWidget(QPushButton("Even longer button text"))
wdg.setWindowTitle("Flow Layout")
wdg.show()
app.exec()
```
{{ show_widget(350) }}
{{ show_members('superqt.QFlowLayout') }}

View File

@@ -20,7 +20,7 @@ app.exec_()
{{ show_widget() }}
- `QRangeSlider` inherits from [`QSlider`](https://doc.qt.io/qt-5/qslider.html)
- `QRangeSlider` inherits from [`QSlider`](https://doc.qt.io/qt-6/qslider.html)
and attempts to match the Qt API as closely as possible
- It uses platform-specific styles (for handle, groove, & ticks) but also supports
QSS style sheets.
@@ -28,9 +28,9 @@ app.exec_()
- Supports more than 2 handles (e.g. `slider.setValue([0, 10, 60, 80])`)
As `QRangeSlider` inherits from
[`QtWidgets.QSlider`](https://doc.qt.io/qt-5/qslider.html), you can use all of
[`QtWidgets.QSlider`](https://doc.qt.io/qt-6/qslider.html), you can use all of
the same methods available in the [QSlider
API](https://doc.qt.io/qt-5/qslider.html). The major difference is that `value()`
API](https://doc.qt.io/qt-6/qslider.html). The major difference is that `value()`
and `sliderPosition()` are reimplemented as `tuples` of `int` (where the length of
the tuple is equal to the number of handles in the slider.)

View File

@@ -1,7 +1,7 @@
# QSearchableComboBox
`QSearchableComboBox` is a variant of
[`QComboBox`](https://doc.qt.io/qt-5/qcombobox.html) that allow to filter list
[`QComboBox`](https://doc.qt.io/qt-6/qcombobox.html) that allow to filter list
of options by enter part of text. It could be drop in replacement for
`QComboBox`.

View File

@@ -1,13 +1,13 @@
# QSearchableListWidget
`QSearchableListWidget` is a variant of
[`QListWidget`](https://doc.qt.io/qt-5/qlistwidget.html) that add text entry
[`QListWidget`](https://doc.qt.io/qt-6/qlistwidget.html) that add text entry
above list widget that allow to filter list of available options.
Due to implementation details, this widget it does not inherit directly from
[`QListWidget`](https://doc.qt.io/qt-5/qlistwidget.html) but it does fully
[`QListWidget`](https://doc.qt.io/qt-6/qlistwidget.html) but it does fully
satisfy its api. The only limitation is that it cannot be used as argument of
[`QListWidgetItem`](https://doc.qt.io/qt-5/qlistwidgetitem.html) constructor.
[`QListWidgetItem`](https://doc.qt.io/qt-6/qlistwidgetitem.html) constructor.
```python
from qtpy.QtWidgets import QApplication

View File

@@ -0,0 +1,24 @@
# QToggleSwitch
`QToggleSwitch` is a
[`QAbstractButton`](https://doc.qt.io/qt-6/qabstractbutton.html) subclass
that represents a boolean value as a toggle switch. The API is similar to
[`QCheckBox`](https://doc.qt.io/qt-6/qcheckbox.html) but with a different
visual representation.
```python
from qtpy.QtWidgets import QApplication
from superqt import QToggleSwitch
app = QApplication([])
switch = QToggleSwitch()
switch.show()
app.exec_()
```
{{ show_widget(80) }}
{{ show_members('superqt.QToggleSwitch') }}

View File

@@ -6,7 +6,7 @@ from qtpy import QtWidgets as QtW
# patch for Qt 5.15 on macos >= 12
os.environ["USE_MAC_SLIDER_PATCH"] = "1"
from superqt import QRangeSlider # noqa
from superqt import QRangeSlider
QSS = """
QSlider {

19
examples/flow_layout.py Normal file
View File

@@ -0,0 +1,19 @@
from qtpy.QtWidgets import QApplication, QPushButton, QWidget
from superqt import QFlowLayout
app = QApplication([])
wdg = QWidget()
layout = QFlowLayout(wdg)
layout.addWidget(QPushButton("Short"))
layout.addWidget(QPushButton("Longer"))
layout.addWidget(QPushButton("Different text"))
layout.addWidget(QPushButton("More text"))
layout.addWidget(QPushButton("Even longer button text"))
wdg.setWindowTitle("Flow Layout")
wdg.show()
app.exec()

View File

@@ -27,11 +27,12 @@ qlds.setValue(0.5)
qlds.setSingleStep(0.1)
qlrs = QLabeledRangeSlider(ORIENTATION)
qlrs.valueChanged.connect(lambda e: print("QLabeledRangeSlider valueChanged", e))
qlrs.setValue((20, 60))
qlrs.valueChanged.connect(lambda e: print("qlrs valueChanged", e))
qlrs.setRange(0, 10**11)
qlrs.setValue((20, 60 * 10**9))
qldrs = QLabeledDoubleRangeSlider(ORIENTATION)
qldrs.valueChanged.connect(lambda e: print("qlrs valueChanged", e))
qldrs.valueChanged.connect(lambda e: print("qldrs valueChanged", e))
qldrs.setRange(0, 1)
qldrs.setSingleStep(0.01)
qldrs.setValue((0.2, 0.7))

View File

@@ -27,7 +27,7 @@ SOFTWARE.
"""
from typing import Deque
from collections import deque
from qtpy.QtCore import QRect, QSize, Qt, QTimer, Signal
from qtpy.QtGui import QPainter, QPen
@@ -65,8 +65,8 @@ class DrawSignalsWidget(QWidget):
self._scrollTimer.timeout.connect(self._scroll)
self._scrollTimer.start()
self._signalActivations: Deque[int] = Deque()
self._throttledSignalActivations: Deque[int] = Deque()
self._signalActivations: deque[int] = deque()
self._throttledSignalActivations: deque[int] = deque()
def sizeHint(self):
return QSize(400, 200)
@@ -84,7 +84,7 @@ class DrawSignalsWidget(QWidget):
self.update()
def scrollAndCut(self, v: Deque[int], cutoff: int):
def scrollAndCut(self, v: deque[int], cutoff: int):
L = len(v)
for p in range(L):
v[p] += 1
@@ -121,7 +121,7 @@ class DrawSignalsWidget(QWidget):
p.drawLine(0, h2, w, h2)
p.restore()
def _drawSignals(self, p: QPainter, v: Deque[int], color, yStart, yEnd):
def _drawSignals(self, p: QPainter, v: deque[int], color, yStart, yEnd):
p.save()
pen = QPen()
pen.setWidthF(2.0)

67
examples/toggle_switch.py Normal file
View File

@@ -0,0 +1,67 @@
from qtpy import QtCore, QtGui
from qtpy.QtWidgets import QApplication, QStyle, QVBoxLayout, QWidget
from superqt import QToggleSwitch
from superqt.switch import QStyleOptionToggleSwitch
QSS_EXAMPLE = """
QToggleSwitch {
qproperty-onColor: red;
qproperty-handleSize: 12;
qproperty-switchWidth: 30;
qproperty-switchHeight: 16;
}
"""
class QRectangleToggleSwitch(QToggleSwitch):
"""A rectangle shaped toggle switch."""
def drawGroove(
self,
painter: QtGui.QPainter,
rect: QtCore.QRectF,
option: QStyleOptionToggleSwitch,
) -> None:
"""Draw the groove of the switch."""
painter.setPen(QtCore.Qt.PenStyle.NoPen)
is_checked = option.state & QStyle.StateFlag.State_On
painter.setBrush(option.on_color if is_checked else option.off_color)
painter.setOpacity(0.8)
painter.drawRect(rect)
def drawHandle(self, painter, rect, option):
"""Draw the handle of the switch."""
painter.drawRect(rect)
class QToggleSwitchWithText(QToggleSwitch):
"""A toggle switch with text on the handle."""
def drawHandle(
self,
painter: QtGui.QPainter,
rect: QtCore.QRectF,
option: QStyleOptionToggleSwitch,
) -> None:
super().drawHandle(painter, rect, option)
text = "ON" if option.state & QStyle.StateFlag.State_On else "OFF"
painter.setPen(QtGui.QPen(QtGui.QColor("black")))
font = painter.font()
font.setPointSize(5)
painter.setFont(font)
painter.drawText(rect, QtCore.Qt.AlignmentFlag.AlignCenter, text)
app = QApplication([])
widget = QWidget()
layout = QVBoxLayout(widget)
layout.addWidget(QToggleSwitch("original"))
switch_styled = QToggleSwitch("stylesheet")
switch_styled.setStyleSheet(QSS_EXAMPLE)
layout.addWidget(switch_styled)
layout.addWidget(QRectangleToggleSwitch("rectangle"))
layout.addWidget(QToggleSwitchWithText("with text"))
widget.show()
app.exec()

View File

@@ -32,8 +32,8 @@ markdown_extensions:
- attr_list
- md_in_html
- pymdownx.emoji:
emoji_index: !!python/name:materialx.emoji.twemoji
emoji_generator: !!python/name:materialx.emoji.to_svg
emoji_index: !!python/name:material.extensions.emoji.twemoji
emoji_generator: !!python/name:material.extensions.emoji.to_svg
- toc:
permalink: "#"

View File

@@ -8,7 +8,7 @@ build-backend = "hatchling.build"
name = "superqt"
description = "Missing widgets and components for PyQt/PySide"
readme = "README.md"
requires-python = ">=3.8"
requires-python = ">=3.9"
license = { text = "BSD 3-Clause License" }
authors = [{ email = "talley.lambert@gmail.com", name = "Talley Lambert" }]
keywords = [
@@ -28,11 +28,11 @@ classifiers = [
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Desktop Environment",
"Topic :: Software Development :: User Interfaces",
"Topic :: Software Development :: Widget Sets",
@@ -41,13 +41,21 @@ dynamic = ["version"]
dependencies = [
"pygments>=2.4.0",
"qtpy>=1.1.0",
"typing-extensions >=3.7.4.3,!=3.10.0.0",
"typing-extensions >=3.7.4.3,!=3.10.0.0", # however, pint requires >4.5.0
]
# extras
# https://peps.python.org/pep-0621/#dependencies-optional-dependencies
[project.optional-dependencies]
test = ["pint", "pytest", "pytest-cov", "pytest-qt", "numpy", "cmap", "pyconify"]
test = [
"pint",
"pytest",
"pytest-cov",
"pytest-qt==4.4.0",
"numpy",
"cmap",
"pyconify",
]
dev = [
"ipython",
"ruff",
@@ -57,8 +65,15 @@ dev = [
"pydocstyle",
"rich",
"types-Pygments",
"superqt[test,pyqt6]",
]
docs = [
"mkdocs-macros-plugin ==1.3.7",
"mkdocs-material ==9.5.49",
"mkdocstrings ==0.27.0",
"mkdocstrings-python ==1.13.0",
"superqt[font-fa5, cmap, quantity]",
]
docs = ["mkdocs-macros-plugin", "mkdocs-material", "mkdocstrings[python]", "pint", "cmap"]
quantity = ["pint"]
cmap = ["cmap >=0.1.1"]
pyside2 = ["pyside2"]
@@ -66,7 +81,7 @@ pyside2 = ["pyside2"]
# https://github.com/pyapp-kit/superqt/pull/177
# https://github.com/pyapp-kit/superqt/pull/164
# https://bugreports.qt.io/browse/PYSIDE-2627
pyside6 = ["pyside6 !=6.5.0,!=6.5.1,!=6.6.2"]
pyside6 = ["pyside6 !=6.5.0,!=6.5.1,!=6.6.2,<6.8"]
pyqt5 = ["pyqt5"]
pyqt6 = ["pyqt6<6.7"]
font-fa5 = ["fonticon-fontawesome5"]
@@ -100,21 +115,30 @@ python = ["3.11"]
[[tool.hatch.envs.test.matrix]]
qt = ["pyside2", "pyqt5", "pyqt5.12"]
python = ["3.8"]
python = ["3.9"]
[tool.hatch.envs.test.overrides]
matrix.qt.extra-dependencies = [
{value = "pyside2", if = ["pyside2"]},
{value = "pyside6", if = ["pyside6"]},
{value = "pyqt5", if = ["pyqt5"]},
{value = "pyqt6", if = ["pyqt6"]},
{value = "pyqt5==5.12", if = ["pyqt5.12"]},
{ value = "pyside2", if = [
"pyside2",
] },
{ value = "pyside6", if = [
"pyside6",
] },
{ value = "pyqt5", if = [
"pyqt5",
] },
{ value = "pyqt6", if = [
"pyqt6",
] },
{ value = "pyqt5==5.12", if = [
"pyqt5.12",
] },
]
# https://github.com/charliermarsh/ruff
[tool.ruff]
line-length = 88
target-version = "py38"
target-version = "py39"
src = ["src", "tests"]
# https://docs.astral.sh/ruff/rules
@@ -132,7 +156,7 @@ select = [
"B", # flake8-bugbear
"A001", # flake8-builtins
"RUF", # ruff-specific rules
"TCH", # flake8-type-checking
"TC", # flake8-type-checking
"TID", # flake8-tidy-imports
]
ignore = [
@@ -159,6 +183,7 @@ filterwarnings = [
"ignore:QPixmapCache.find:DeprecationWarning:",
"ignore:SelectableGroups dict interface:DeprecationWarning",
"ignore:The distutils package is deprecated:DeprecationWarning",
"ignore:.*Skipping callback call set_result",
]
# https://mypy.readthedocs.io/en/stable/config_file.html

View File

@@ -11,7 +11,6 @@ except PackageNotFoundError:
from .collapsible import QCollapsible
from .combobox import QColorComboBox, QEnumComboBox, QSearchableComboBox
from .elidable import QElidingLabel, QElidingLineEdit
from .iconify import QIconifyIcon
from .selection import QSearchableListWidget, QSearchableTreeWidget
from .sliders import (
QDoubleRangeSlider,
@@ -23,11 +22,15 @@ from .sliders import (
QRangeSlider,
)
from .spinbox import QLargeIntSpinBox
from .utils import QMessageHandler, ensure_main_thread, ensure_object_thread
from .switch import QToggleSwitch
from .utils import (
QFlowLayout,
QMessageHandler,
ensure_main_thread,
ensure_object_thread,
)
__all__ = [
"ensure_main_thread",
"ensure_object_thread",
"QCollapsible",
"QColorComboBox",
"QColormapComboBox",
@@ -36,8 +39,9 @@ __all__ = [
"QElidingLabel",
"QElidingLineEdit",
"QEnumComboBox",
"QLabeledDoubleRangeSlider",
"QFlowLayout",
"QIconifyIcon",
"QLabeledDoubleRangeSlider",
"QLabeledDoubleSlider",
"QLabeledRangeSlider",
"QLabeledSlider",
@@ -48,20 +52,28 @@ __all__ = [
"QSearchableComboBox",
"QSearchableListWidget",
"QSearchableTreeWidget",
"QToggleSwitch",
"ensure_main_thread",
"ensure_object_thread",
]
if TYPE_CHECKING:
from .combobox import QColormapComboBox # noqa: TCH004
from .spinbox._quantity import QQuantity # noqa: TCH004
from .combobox import QColormapComboBox
from .iconify import QIconifyIcon
from .spinbox._quantity import QQuantity
def __getattr__(name: str) -> Any:
if name == "QQuantity":
from .spinbox._quantity import QQuantity
return QQuantity
if name == "QColormapComboBox":
from .cmap import QColormapComboBox
return QColormapComboBox
if name == "QIconifyIcon":
from .iconify import QIconifyIcon
return QIconifyIcon
if name == "QQuantity":
from .spinbox._quantity import QQuantity
return QQuantity
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View File

@@ -15,9 +15,9 @@ from ._cmap_line_edit import QColormapLineEdit
from ._cmap_utils import draw_colormap
__all__ = [
"QColormapItemDelegate",
"draw_colormap",
"QColormapLineEdit",
"CmapCatalogComboBox",
"QColormapComboBox",
"QColormapItemDelegate",
"QColormapLineEdit",
"draw_colormap",
]

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Container
from typing import TYPE_CHECKING
from cmap import Colormap
from qtpy.QtCore import Qt, Signal
@@ -11,6 +11,8 @@ from ._cmap_line_edit import QColormapLineEdit
from ._cmap_utils import try_cast_colormap
if TYPE_CHECKING:
from collections.abc import Container
from cmap._catalog import Category, Interpolation
from qtpy.QtGui import QKeyEvent

View File

@@ -1,13 +1,14 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Sequence
from typing import TYPE_CHECKING, Any
from cmap import Colormap
from qtpy.QtCore import Qt, Signal
from qtpy.QtCore import QSortFilterProxyModel, QStringListModel, Qt, Signal
from qtpy.QtWidgets import (
QButtonGroup,
QCheckBox,
QComboBox,
QCompleter,
QDialog,
QDialogButtonBox,
QSizePolicy,
@@ -23,7 +24,10 @@ from ._cmap_line_edit import QColormapLineEdit
from ._cmap_utils import try_cast_colormap
if TYPE_CHECKING:
from collections.abc import Sequence
from cmap._colormap import ColorStopsLike
from qtpy.QtGui import QKeyEvent
CMAP_ROLE = Qt.ItemDataRole.UserRole + 1
@@ -43,6 +47,9 @@ class QColormapComboBox(QComboBox):
add_colormap_text: str, optional
The text to display for the "Add Colormap..." item.
Default is "Add Colormap...".
filterable: bool, optional
Whether the user can filter colormaps by typing in the line edit.
Default is True. Can also be set with `setFilterable`.
"""
currentColormapChanged = Signal(Colormap)
@@ -53,18 +60,20 @@ class QColormapComboBox(QComboBox):
*,
allow_user_colormaps: bool = False,
add_colormap_text: str = "Add Colormap...",
filterable: bool = True,
) -> None:
# init QComboBox
super().__init__(parent)
self._add_color_text: str = add_colormap_text
self._allow_user_colors: bool = allow_user_colormaps
self._last_cmap: Colormap | None = None
self._filterable: bool = False
self.setLineEdit(_PopupColormapLineEdit(self))
self.lineEdit().setReadOnly(True)
line_edit = _PopupColormapLineEdit(self, allow_invalid=False)
self.setLineEdit(line_edit)
self.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
self.setItemDelegate(QColormapItemDelegate(self))
self.currentIndexChanged.connect(self._on_index_changed)
# there's a little bit of a potential bug here:
# if the user clicks on the "Add Colormap..." item
# then an indexChanged signal will be emitted, but it may not
@@ -73,6 +82,33 @@ class QColormapComboBox(QComboBox):
self.setUserAdditionsAllowed(allow_user_colormaps)
# Create a proxy model to handle filtering
self._proxy_model = QSortFilterProxyModel(self)
# use string list model as source model
self._proxy_model.setSourceModel(QStringListModel(self))
self._proxy_model.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
# Setup completer
self._completer = QCompleter(self)
self._completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
self._completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
self._completer.setFilterMode(Qt.MatchFlag.MatchContains)
self._completer.setModel(self._proxy_model)
# set the delegate for both the popup and the combobox
if popup := self._completer.popup():
popup.setItemDelegate(self.itemDelegate())
# Update completer model when items change
if model := self.model():
model.rowsInserted.connect(self._update_completer_model)
model.rowsRemoved.connect(self._update_completer_model)
self.setFilterable(filterable)
self.currentIndexChanged.connect(self._on_index_changed)
line_edit.editingFinished.connect(self._on_editing_finished)
def userAdditionsAllowed(self) -> bool:
"""Returns whether the user can add custom colors."""
return self._allow_user_colors
@@ -94,9 +130,26 @@ class QColormapComboBox(QComboBox):
elif not self._allow_user_colors:
self.removeItem(idx)
def setFilterable(self, filterable: bool) -> None:
"""Set whether the user can enter/filter colormaps by typing in the line edit.
If enabled, the user can select the text in the line edit and type to
filter the list of colormaps. The completer will show a list of matching
colormaps as the user types. If disabled, the user can only select from
the combo box dropdown.
"""
self._filterable = bool(filterable)
self.setCompleter(self._completer if self._filterable else None)
self.lineEdit().setReadOnly(not self._filterable)
def isFilterable(self) -> bool:
"""Returns whether the user can filter the list of colormaps."""
return self._filterable
def clear(self) -> None:
super().clear()
self.setUserAdditionsAllowed(self._allow_user_colors)
self._update_completer_model()
def itemColormap(self, index: int) -> Colormap | None:
"""Returns the color of the item at the given index."""
@@ -122,14 +175,23 @@ class QColormapComboBox(QComboBox):
# make sure the "Add Colormap..." item is last
idx = self.findData(self._add_color_text, Qt.ItemDataRole.DisplayRole)
if idx >= 0:
with signals_blocked(self):
self.removeItem(idx)
self.addItem(self._add_color_text)
self._block_completer_update = True
try:
with signals_blocked(self):
self.removeItem(idx)
self.addItem(self._add_color_text)
finally:
self._block_completer_update = False
def addColormaps(self, colors: Sequence[Any]) -> None:
"""Adds colors to the QComboBox."""
for color in colors:
self.addColormap(color)
self._block_completer_update = True
try:
for color in colors:
self.addColormap(color)
finally:
self._block_completer_update = False
self._update_completer_model()
def currentColormap(self) -> Colormap | None:
"""Returns the currently selected Colormap or None if not yet selected."""
@@ -171,6 +233,37 @@ class QColormapComboBox(QComboBox):
self.lineEdit().setColormap(colormap)
self._last_cmap = colormap
def _update_completer_model(self) -> None:
"""Update the completer's model with current items."""
if getattr(self, "_block_completer_update", False):
return
# Ensure we are updating the source model of the proxy
if isinstance(src_model := self._proxy_model.sourceModel(), QStringListModel):
words = [
txt
for i in range(self.count())
if (txt := self.itemText(i)) != self._add_color_text
]
src_model.setStringList(words)
self._proxy_model.invalidate()
def _on_editing_finished(self) -> None:
text = self.lineEdit().text()
if (cmap := try_cast_colormap(text)) is not None:
self.currentColormapChanged.emit(cmap)
# if the cmap is not in the list, add it
if self.findData(cmap, CMAP_ROLE) < 0:
self.addColormap(cmap)
def keyPressEvent(self, e: QKeyEvent | None) -> None:
if e and e.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return):
# select the first completion when pressing enter if the popup is visible
if (completer := self.completer()) and completer.completionCount():
self.lineEdit().setText(completer.currentCompletion()) # type: ignore
return super().keyPressEvent(e)
CATEGORIES = ("sequential", "diverging", "cyclic", "qualitative", "miscellaneous")
@@ -218,7 +311,9 @@ class _PopupColormapLineEdit(QColormapLineEdit):
Without this, only the down arrow will show the popup. And if mousePressEvent
is used instead, the popup will show and then immediately hide.
Also ensure that the popup is not shown when the user selects text.
"""
parent = self.parent()
if parent and hasattr(parent, "showPopup"):
parent.showPopup()
if not self.hasSelectedText():
parent = self.parent()
if parent and hasattr(parent, "showPopup"):
parent.showPopup()

View File

@@ -43,6 +43,13 @@ class QColormapLineEdit(QLineEdit):
checkerboard_size : int, optional
Size (in pixels) of the checkerboard pattern to draw behind colormaps with
transparency, by default 4. If 0, no checkerboard is drawn.
allow_invalid : bool, optional
If True, the user can enter any text, even if it does not represent a valid
colormap (and `fallback_cmap` will be shown if it's invalid). If False, the text
will be validated when editing is finished or focus is lost, and if the text is
not a valid colormap, it will be reverted to the first available valid option
from the completer, or, if that's not available, the last valid colormap.
Default is True. This is only settable at initialization.
"""
def __init__(
@@ -53,6 +60,7 @@ class QColormapLineEdit(QLineEdit):
fallback_cmap: Colormap | str | None = "gray",
missing_icon: QIcon | QStyle.StandardPixmap = MISSING,
checkerboard_size: int = 4,
allow_invalid: bool = True,
) -> None:
super().__init__(parent)
self.setFractionalColormapWidth(fractional_colormap_width)
@@ -69,6 +77,45 @@ class QColormapLineEdit(QLineEdit):
self._cmap: Colormap | None = None # current colormap
self.textChanged.connect(self.setColormap)
self._lastValidColormap: Colormap | None = None
if not allow_invalid:
self.editingFinished.connect(self._validate)
def _validate(self) -> None:
"""Called when editing is finished or focus is lost.
If the current text does not represent a valid colormap, revert to the first
available valid option from the completer, or, if that's not available, revert
to the last valid colormap.
"""
if self._cmap is None:
candidate = self._fist_completer_option()
if candidate is not None:
self.setColormap(candidate)
self.setText(candidate.name.rsplit(":", 1)[-1])
elif self._lastValidColormap is not None:
self.setColormap(self._lastValidColormap)
self.setText(self._lastValidColormap.name.rsplit(":", 1)[-1])
# Optionally, if neither is available, you might decide to clear the text.
else:
# Update the last valid value.
self._lastValidColormap = self._cmap
def _fist_completer_option(self) -> Colormap | None:
"""Return the first valid Colormap from the completer's current filtered list.
or None if no valid option is available.
"""
if (
(completer := self.completer()) is None
or (model := completer.model()) is None
or model.rowCount() == 0
):
return None
first_item = model.index(0, 0).data(Qt.ItemDataRole.DisplayRole)
return try_cast_colormap(first_item)
def setFractionalColormapWidth(self, fraction: float) -> None:
self._colormap_fraction: float = float(fraction)
align = Qt.AlignmentFlag.AlignVCenter

View File

@@ -121,6 +121,10 @@ def draw_colormap(
painter.setBrush(gradient)
painter.drawRect(rect)
# If we created a new Painter, free its resources
if isinstance(painter_or_device, QPaintDevice):
painter.end()
def _draw_checkerboard(
painter: QPainter, rect: QRect | QRectF, checker_size: int

View File

@@ -13,7 +13,7 @@ __all__ = (
if TYPE_CHECKING:
from superqt.cmap import QColormapComboBox # noqa: TCH004
from superqt.cmap import QColormapComboBox
def __getattr__(name: str) -> Any: # pragma: no cover

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import warnings
from contextlib import suppress
from enum import IntEnum, auto
from typing import Any, Literal, Sequence, cast
from typing import TYPE_CHECKING, Any, Literal, cast
from qtpy.QtCore import QModelIndex, QPersistentModelIndex, QRect, QSize, Qt, Signal
from qtpy.QtGui import QColor, QPainter
@@ -19,6 +19,9 @@ from qtpy.QtWidgets import (
from superqt.utils import signals_blocked
if TYPE_CHECKING:
from collections.abc import Sequence
_NAME_MAP = {QColor(x).name(): x for x in QColor.colorNames()}
COLOR_ROLE = Qt.ItemDataRole.BackgroundRole

View File

@@ -3,7 +3,7 @@ from enum import Enum, EnumMeta, Flag
from functools import reduce
from itertools import combinations
from operator import or_
from typing import Optional, Tuple, TypeVar
from typing import Optional, TypeVar
from qtpy.QtCore import Signal
from qtpy.QtWidgets import QComboBox
@@ -47,7 +47,7 @@ def _get_name(enum_value: Enum):
return name
def _get_name_with_value(enum_value: Enum) -> Tuple[str, Enum]:
def _get_name_with_value(enum_value: Enum) -> tuple[str, Enum]:
return _get_name(enum_value), enum_value

View File

@@ -1,5 +1,3 @@
from typing import List
from qtpy.QtCore import Qt
from qtpy.QtGui import QFont, QFontMetrics, QTextLayout
@@ -36,7 +34,7 @@ class _GenericEliding:
self._ellipses_width = width
@staticmethod
def wrapText(text, width, font=None) -> List[str]:
def wrapText(text, width, font=None) -> list[str]:
"""Returns `text`, split as it would be wrapped for `width`, given `font`.
Static method.
@@ -72,7 +70,7 @@ class _GenericEliding:
text = self._wrappedText()
last_line = fm.elidedText("".join(text[nlines:]), self._elide_mode, width)
# join them
return "".join(text[:nlines] + [last_line])
return "".join([*text[:nlines], last_line])
def _wrappedText(self) -> List[str]:
def _wrappedText(self) -> list[str]:
return _GenericEliding.wrapText(self._text, self.width(), self.font())

View File

@@ -73,3 +73,10 @@ class QElidingLabel(_GenericEliding, QLabel):
flags = int(self.alignment() | Qt.TextFlag.TextWordWrap)
r = fm.boundingRect(QRect(QPoint(0, 0), self.size()), flags, self._text)
return QSize(self.width(), r.height())
def minimumSizeHint(self) -> QSize:
# The smallest that self._elidedText can be is just the ellipsis.
fm = QFontMetrics(self.font())
flags = int(self.alignment() | Qt.TextFlag.TextWordWrap)
r = fm.boundingRect(QRect(QPoint(0, 0), self.size()), flags, "...")
return QSize(r.width(), r.height())

View File

@@ -1,16 +1,16 @@
from __future__ import annotations
__all__ = [
"addFont",
"Animation",
"ENTRY_POINT",
"font",
"icon",
"Animation",
"IconFont",
"IconFontMeta",
"IconOpts",
"pulse",
"QIconifyIcon",
"addFont",
"font",
"icon",
"pulse",
"setTextIcon",
"spin",
]

View File

@@ -1,4 +1,5 @@
from typing import Mapping, Type, Union
from collections.abc import Mapping
from typing import Union
FONTFILE_ATTR = "__font_file__"
@@ -69,7 +70,7 @@ class IconFont(metaclass=IconFontMeta):
__font_file__ = "..."
def namespace2font(namespace: Union[Mapping, Type], name: str) -> Type[IconFont]:
def namespace2font(namespace: Union[Mapping, type], name: str) -> type[IconFont]:
"""Convenience to convert a namespace (class, module, dict) into an IconFont."""
if isinstance(namespace, type):
if not isinstance(getattr(namespace, FONTFILE_ATTR), str):

View File

@@ -1,5 +1,5 @@
import contextlib
from typing import ClassVar, Dict, List, Set, Tuple
from typing import ClassVar
from ._iconfont import IconFontMeta, namespace2font
@@ -11,9 +11,9 @@ except ImportError:
class FontIconManager:
ENTRY_POINT: ClassVar[str] = "superqt.fonticon"
_PLUGINS: ClassVar[Dict[str, EntryPoint]] = {}
_LOADED: ClassVar[Dict[str, IconFontMeta]] = {}
_BLOCKED: ClassVar[Set[EntryPoint]] = set()
_PLUGINS: ClassVar[dict[str, EntryPoint]] = {}
_LOADED: ClassVar[dict[str, IconFontMeta]] = {}
_BLOCKED: ClassVar[set[EntryPoint]] = set()
def _discover_fonts(self) -> None:
self._PLUGINS.clear()
@@ -86,15 +86,15 @@ _manager = FontIconManager()
get_font_class = _manager._get_font_class
def discover() -> Tuple[str]:
def discover() -> tuple[str]:
_manager._discover_fonts()
def available() -> Tuple[str]:
def available() -> tuple[str]:
return tuple(_manager._PLUGINS)
def loaded(load_all=False) -> Dict[str, List[str]]:
def loaded(load_all=False) -> dict[str, list[str]]:
if load_all:
discover()
for x in available():

View File

@@ -2,9 +2,10 @@ from __future__ import annotations
import warnings
from collections import abc, defaultdict
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, ClassVar, DefaultDict, Sequence, Tuple, Union, cast
from typing import TYPE_CHECKING, ClassVar, Union, cast
from qtpy import QT_VERSION
from qtpy.QtCore import QObject, QPoint, QRect, QSize, Qt
@@ -47,8 +48,8 @@ ValidColor = Union[
int,
str,
Qt.GlobalColor,
Tuple[int, int, int, int],
Tuple[int, int, int],
tuple[int, int, int, int],
tuple[int, int, int],
None,
]
@@ -131,7 +132,7 @@ class IconOpts:
def dict(self) -> IconOptionDict:
# not using asdict due to pickle errors on animation
d = {k: v for k, v in vars(self).items() if v is not _Unset}
return cast(IconOptionDict, d)
return cast("IconOptionDict", d)
@dataclass
@@ -150,7 +151,7 @@ class _IconOptions:
def dict(self) -> IconOptionDict:
# not using asdict due to pickle errors on animation
return cast(IconOptionDict, vars(self))
return cast("IconOptionDict", vars(self))
class _QFontIconEngine(QIconEngine):
@@ -159,14 +160,14 @@ class _QFontIconEngine(QIconEngine):
def __init__(self, options: _IconOptions):
super().__init__()
self._opts: defaultdict[QIcon.State, dict[QIcon.Mode, _IconOptions | None]] = (
DefaultDict(dict)
defaultdict(dict)
)
self._opts[QIcon.State.Off][QIcon.Mode.Normal] = options
self.update_hash()
@property
def _default_opts(self) -> _IconOptions:
return cast(_IconOptions, self._opts[QIcon.State.Off][QIcon.Mode.Normal])
return cast("_IconOptions", self._opts[QIcon.State.Off][QIcon.Mode.Normal])
def _add_opts(self, state: QIcon.State, mode: QIcon.Mode, opts: IconOpts) -> None:
self._opts[state][mode] = self._default_opts._update(opts)
@@ -357,7 +358,7 @@ class QFontIconStore(QObject):
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent=parent)
if tuple(cast(str, QT_VERSION).split(".")) < ("6", "0"):
if tuple(cast("str", QT_VERSION).split(".")) < ("6", "0"):
# QT6 drops this
QApplication.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps)
@@ -479,7 +480,7 @@ class QFontIconStore(QObject):
# in Qt6, everything becomes a static member
QFd: QFontDatabase | type[QFontDatabase] = (
QFontDatabase()
if tuple(cast(str, QT_VERSION).split(".")) < ("6", "0")
if tuple(cast("str", QT_VERSION).split(".")) < ("6", "0")
else QFontDatabase
)

View File

@@ -7,16 +7,22 @@ from qtpy.QtCore import QSize, Qt
from qtpy.QtGui import QIcon, QPainter, QPixmap
from qtpy.QtWidgets import QApplication
try:
from pyconify import svg_path
except ModuleNotFoundError: # pragma: no cover
raise ModuleNotFoundError(
"pyconify is required to use QIconifyIcon. "
"Please install it with `pip install pyconify` or use the "
"`pip install superqt[iconify]` extra."
) from None
if TYPE_CHECKING:
from typing import Literal
Flip = Literal["horizontal", "vertical", "horizontal,vertical"]
Rotation = Literal["90", "180", "270", 90, 180, 270, "-90", 1, 2, 3]
try:
from pyconify import svg_path
except ModuleNotFoundError: # pragma: no cover
svg_path = None
__all__ = ["QIconifyIcon"]
class QIconifyIcon(QIcon):
@@ -74,14 +80,9 @@ class QIconifyIcon(QIcon):
rotate: Rotation | None = None,
dir: str | None = None,
):
if svg_path is None: # pragma: no cover
raise ModuleNotFoundError(
"pyconify is required to use QIconifyIcon. "
"Please install it with `pip install pyconify` or use the "
"`pip install superqt[iconify]` extra."
)
super().__init__()
self.addKey(*key, color=color, flip=flip, rotate=rotate, dir=dir)
if key:
self.addKey(*key, color=color, flip=flip, rotate=rotate, dir=dir)
def addKey(
self,
@@ -93,7 +94,7 @@ class QIconifyIcon(QIcon):
size: QSize | None = None,
mode: QIcon.Mode = QIcon.Mode.Normal,
state: QIcon.State = QIcon.State.Off,
) -> None:
) -> QIconifyIcon:
"""Add an icon to this QIcon.
This is a variant of `QIcon.addFile` that uses an iconify icon keys and
@@ -123,18 +124,25 @@ class QIconifyIcon(QIcon):
Mode specified for the icon, passed to `QIcon.addFile`.
state : QIcon.State, optional
State specified for the icon, passed to `QIcon.addFile`.
Returns
-------
QIconifyIcon
This QIconifyIcon instance, for chaining.
"""
try:
path = svg_path(*key, color=color, flip=flip, rotate=rotate, dir=dir)
except OSError:
except OSError as e:
warnings.warn(
f"Unable to connect to internet, and icon {key} not cached.",
f"Error fetching icon: {e}.\nIcon {key} not cached. Using fallback.",
stacklevel=2,
)
self._draw_text_fallback(key)
else:
self.addFile(str(path), size or QSize(), mode, state)
return self
def _draw_text_fallback(self, key: tuple[str, ...]) -> None:
if style := QApplication.style():
pixmap = style.standardPixmap(style.StandardPixmap.SP_MessageBoxQuestion)

View File

@@ -1,5 +1,6 @@
import logging
from typing import Any, Iterable, Mapping
from collections.abc import Iterable, Mapping
from typing import Any
from qtpy.QtCore import QRegularExpression
from qtpy.QtWidgets import QLineEdit, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
@@ -16,7 +17,7 @@ class QSearchableTreeWidget(QWidget):
into the `filter` line edit. An item is only shown if its, any of its ancestors',
or any of its descendants' keys or values match this pattern.
The regular expression follows the conventions described by the Qt docs:
https://doc.qt.io/qt-5/qregularexpression.html#details
https://doc.qt.io/qt-6/qregularexpression.html#details
Attributes
----------

View File

@@ -8,6 +8,7 @@ from ._range_style import MONTEREY_SLIDER_STYLES_FIX
from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
__all__ = [
"MONTEREY_SLIDER_STYLES_FIX",
"QDoubleRangeSlider",
"QDoubleSlider",
"QLabeledDoubleRangeSlider",
@@ -15,5 +16,4 @@ __all__ = [
"QLabeledRangeSlider",
"QLabeledSlider",
"QRangeSlider",
"MONTEREY_SLIDER_STYLES_FIX",
]

View File

@@ -1,4 +1,5 @@
from typing import List, Optional, Sequence, Tuple, TypeVar, Union
from collections.abc import Sequence
from typing import Optional, TypeVar, Union
from qtpy import QtGui
from qtpy.QtCore import Property, QEvent, QPoint, QPointF, QRect, QRectF, Qt, Signal
@@ -28,25 +29,27 @@ class _GenericRangeSlider(_GenericSlider):
"""
# Emitted when the slider value has changed, with the new slider values
_valuesChanged = Signal(tuple)
valuesChanged = Signal(tuple)
# this is just a hack to allow napari v0.4.19 tests to pass)
# since it used the presence of this private signal as a duck-typing check.
_valuesChanged = valuesChanged
# Emitted when sliderDown is true and the slider moves
# This usually happens when the user is dragging the slider
# The value is the positions of *all* handles.
_slidersMoved = Signal(tuple)
slidersMoved = Signal(tuple)
def __init__(self, *args, **kwargs):
self._style = RangeSliderStyle()
super().__init__(*args, **kwargs)
self.valueChanged = self._valuesChanged
self.sliderMoved = self._slidersMoved
# list of values
self._value: List[_T] = [20, 80]
self._value: list[_T] = [20, 80]
# list of current positions of each handle. same length as _value
# If tracking is enabled (the default) this will be identical to _value
self._position: List[_T] = [20, 80]
self._position: list[_T] = [20, 80]
# which handle is being pressed/hovered
self._pressedIndex = 0
@@ -63,6 +66,10 @@ class _GenericRangeSlider(_GenericSlider):
self.setStyleSheet("")
def _rename_signals(self) -> None:
self.valueChanged = self.valuesChanged
self.sliderMoved = self.slidersMoved
# ############### New Public API #######################
def barIsRigid(self) -> bool:
@@ -113,7 +120,7 @@ class _GenericRangeSlider(_GenericSlider):
# ############### QtOverrides #######################
def value(self) -> Tuple[_T, ...]:
def value(self) -> tuple[_T, ...]:
"""Get current value of the widget as a tuple of integers."""
return tuple(self._value)
@@ -332,7 +339,7 @@ class _GenericRangeSlider(_GenericSlider):
# NOTE: this is very much tied to mousepress... not a generic "get control"
def _getControlAtPos(
self, pos: QPoint, opt: Optional[QStyleOptionSlider] = None
) -> Tuple[QStyle.SubControl, int]:
) -> tuple[QStyle.SubControl, int]:
"""Update self._pressedControl based on ev.pos()."""
opt = opt or self._styleOption

View File

@@ -22,7 +22,7 @@ QRangeSlider.
import os
import platform
from typing import TypeVar
from typing import Any, TypeVar
from qtpy import QT_VERSION, QtGui
from qtpy.QtCore import QEvent, QPoint, QPointF, QRect, Qt, Signal
@@ -60,13 +60,13 @@ USE_MAC_SLIDER_PATCH = (
class _GenericSlider(QSlider):
_fvalueChanged = Signal(int)
_fsliderMoved = Signal(int)
_frangeChanged = Signal(int, int)
fvalueChanged = Signal(float)
fsliderMoved = Signal(float)
frangeChanged = Signal(float, float)
MAX_DISPLAY = 5000
def __init__(self, *args, **kwargs) -> None:
def __init__(self, *args: Any, **kwargs: Any) -> None:
self._minimum = 0.0
self._maximum = 99.0
self._pageStep = 10.0
@@ -90,15 +90,18 @@ class _GenericSlider(QSlider):
self._control_fraction = 0.04
super().__init__(*args, **kwargs)
self.valueChanged = self._fvalueChanged
self.sliderMoved = self._fsliderMoved
self.rangeChanged = self._frangeChanged
self._rename_signals()
self.setAttribute(Qt.WidgetAttribute.WA_Hover)
self.setStyleSheet("")
if USE_MAC_SLIDER_PATCH:
self.applyMacStylePatch()
def _rename_signals(self) -> None:
self.valueChanged = self.fvalueChanged
self.sliderMoved = self.fsliderMoved
self.rangeChanged = self.frangeChanged
def applyMacStylePatch(self) -> None:
"""Apply a QSS patch to fix sliders on macos>=12 with QT < 6.

View File

@@ -1,20 +1,18 @@
from __future__ import annotations
import contextlib
from enum import IntEnum, IntFlag, auto
from functools import partial
from typing import Any, Iterable, overload
from typing import TYPE_CHECKING, Any, overload
from qtpy import QtGui
from qtpy.QtCore import Property, QPoint, QSize, Qt, Signal
from qtpy.QtGui import QFontMetrics, QValidator
from qtpy.QtGui import QDoubleValidator, QFontMetrics, QValidator
from qtpy.QtWidgets import (
QAbstractSlider,
QBoxLayout,
QDoubleSpinBox,
QHBoxLayout,
QLineEdit,
QSlider,
QSpinBox,
QStyle,
QStyleOptionSpinBox,
QVBoxLayout,
@@ -25,6 +23,9 @@ from superqt.utils import signals_blocked
from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
if TYPE_CHECKING:
from collections.abc import Iterable
class LabelPosition(IntEnum):
NoLabel = 0
@@ -80,7 +81,7 @@ class _SliderProxy:
def setPageStep(self, step: int) -> None:
self._slider.setPageStep(step)
def setRange(self, min: int, max: int) -> None:
def setRange(self, min: float, max: float) -> None:
self._slider.setRange(min, max)
def tickInterval(self) -> int:
@@ -159,9 +160,6 @@ def _handle_overloaded_slider_sig(
class QLabeledSlider(_SliderProxy, QAbstractSlider):
editingFinished = Signal()
_ivalueChanged = Signal(int)
_isliderMoved = Signal(int)
_irangeChanged = Signal(int, int)
_slider_class = QSlider
_slider: QSlider
@@ -185,6 +183,7 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
self._slider = self._slider_class(parent=self)
self._label = SliderLabel(self._slider, connect=self._setValue, parent=self)
self._edge_label_mode: EdgeLabelMode = EdgeLabelMode.LabelIsValue
self._edge_label_position: LabelPosition = LabelPosition.LabelsRight
self._rename_signals()
self._slider.actionTriggered.connect(self.actionTriggered.emit)
@@ -205,18 +204,29 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
marg = (0, 0, 0, 0)
if orientation == Qt.Orientation.Vertical:
layout = QVBoxLayout()
layout.addWidget(self._slider, alignment=Qt.AlignmentFlag.AlignHCenter)
layout.addWidget(self._label, alignment=Qt.AlignmentFlag.AlignHCenter)
if not self._edge_label_position:
layout.addWidget(self._slider, alignment=Qt.AlignmentFlag.AlignHCenter)
elif self._edge_label_position == LabelPosition.LabelsBelow:
layout.addWidget(self._slider, alignment=Qt.AlignmentFlag.AlignHCenter)
layout.addWidget(self._label, alignment=Qt.AlignmentFlag.AlignHCenter)
else:
layout.addWidget(self._label, alignment=Qt.AlignmentFlag.AlignHCenter)
layout.addWidget(self._slider, alignment=Qt.AlignmentFlag.AlignHCenter)
self._label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.setSpacing(1)
else:
if self._edge_label_mode == EdgeLabelMode.NoLabel:
marg = (0, 0, 5, 0)
layout = QHBoxLayout() # type: ignore
layout.addWidget(self._slider)
layout.addWidget(self._label)
self._label.setAlignment(Qt.AlignmentFlag.AlignRight)
if not self._edge_label_position:
layout.addWidget(self._slider)
elif self._edge_label_position == LabelPosition.LabelsRight:
layout.addWidget(self._slider)
layout.addWidget(self._label)
self._label.setAlignment(Qt.AlignmentFlag.AlignRight)
else:
layout.addWidget(self._label)
layout.addWidget(self._slider)
self._label.setAlignment(Qt.AlignmentFlag.AlignLeft)
marg = (0, 0, 5, 0)
layout.setSpacing(6)
old_layout = self.layout()
@@ -249,27 +259,46 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
)
self._edge_label_mode = opt
self._on_slider_range_changed(self.minimum(), self.maximum())
if not self._edge_label_mode:
self._label.hide()
w = 5 if self.orientation() == Qt.Orientation.Horizontal else 0
self.layout().setContentsMargins(0, 0, w, 0)
if self._edge_label_position == LabelPosition.LabelsRight:
self.layout().setContentsMargins(0, 0, w, 0)
elif self._edge_label_position == LabelPosition.LabelsLeft:
self.layout().setContentsMargins(0, 0, 0, w)
if opt & EdgeLabelMode.LabelIsValue:
if self.isVisible():
self._label.show()
self._label.setMode(opt)
self._label.setValue(self._slider.value())
self.layout().setContentsMargins(0, 0, 0, 0)
self._on_slider_range_changed(self.minimum(), self.maximum())
def edgeLabelPosition(self) -> LabelPosition:
"""Return where/whether a label is shown at the edge of the slider."""
return self._edge_label_position
def setEdgeLabelPosition(self, opt: LabelPosition) -> None:
"""Set where/whether a label is shown at the edge of the slider."""
if opt is LabelPosition.LabelsOnHandle:
raise ValueError("position cannot be 'LabelPosition.LabelsOnHandle'")
self._edge_label_position = opt
self._label.setVisible(bool(opt))
# TODO: make double clickable to edit
self.setOrientation(self.orientation())
# putting this after labelMode methods for the sake of mypy
EdgeLabelMode = EdgeLabelMode
LabelPosition = LabelPosition
# --------------------- private api --------------------
def _on_slider_range_changed(self, min_: int, max_: int) -> None:
slash = " / " if self._edge_label_mode & EdgeLabelMode.LabelIsValue else ""
if self._edge_label_mode & EdgeLabelMode.LabelIsRange:
self._label.setSuffix(f"{slash}{max_}")
self._label.setSuffix(f" / {max_}")
else:
self._label.setSuffix("")
self.rangeChanged.emit(min_, max_)
def _on_slider_value_changed(self, v: Any) -> None:
@@ -280,18 +309,15 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
"""Convert the value from float to int before setting the slider value."""
self._slider.setValue(int(value))
def _rename_signals(self) -> None:
self.valueChanged = self._ivalueChanged
self.sliderMoved = self._isliderMoved
self.rangeChanged = self._irangeChanged
def _rename_signals(self) -> None: ...
class QLabeledDoubleSlider(QLabeledSlider):
_slider_class = QDoubleSlider
_slider: QDoubleSlider
_fvalueChanged = Signal(float)
_fsliderMoved = Signal(float)
_frangeChanged = Signal(float, float)
fvalueChanged = Signal(float)
fsliderMoved = Signal(float)
frangeChanged = Signal(float, float)
@overload
def __init__(self, parent: QWidget | None = ...) -> None: ...
@@ -310,9 +336,9 @@ class QLabeledDoubleSlider(QLabeledSlider):
self._slider.setValue(value)
def _rename_signals(self) -> None:
self.valueChanged = self._fvalueChanged
self.sliderMoved = self._fsliderMoved
self.rangeChanged = self._frangeChanged
self.valueChanged = self.fvalueChanged
self.sliderMoved = self.fsliderMoved
self.rangeChanged = self.frangeChanged
def decimals(self) -> int:
return self._label.decimals()
@@ -322,9 +348,7 @@ class QLabeledDoubleSlider(QLabeledSlider):
class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
_valueChanged = Signal(tuple)
_sliderPressed = Signal()
_sliderReleased = Signal()
valuesChanged = Signal(tuple)
editingFinished = Signal()
_slider_class = QRangeSlider
@@ -356,7 +380,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
self._slider.sliderPressed.connect(self.sliderPressed.emit)
self._slider.sliderReleased.connect(self.sliderReleased.emit)
self._slider.rangeChanged.connect(self.rangeChanged.emit)
self.sliderMoved = self._slider._slidersMoved
self.sliderMoved = self._slider.slidersMoved
self._min_label = SliderLabel(
self._slider,
@@ -489,9 +513,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
# ------------- private methods ----------------
def _rename_signals(self) -> None:
self.valueChanged = self._valueChanged
self.sliderReleased = self._sliderReleased
self.sliderPressed = self._sliderPressed
self.valueChanged = self.valuesChanged
def _reposition_labels(self) -> None:
if (
@@ -599,7 +621,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
class QLabeledDoubleRangeSlider(QLabeledRangeSlider):
_slider_class = QDoubleRangeSlider
_slider: QDoubleRangeSlider
_frangeChanged = Signal(float, float)
frangeChanged = Signal(float, float)
@overload
def __init__(self, parent: QWidget | None = ...) -> None: ...
@@ -615,7 +637,7 @@ class QLabeledDoubleRangeSlider(QLabeledRangeSlider):
def _rename_signals(self) -> None:
super()._rename_signals()
self.rangeChanged = self._frangeChanged
self.rangeChanged = self.frangeChanged
def decimals(self) -> int:
return self._min_label.decimals()
@@ -636,7 +658,7 @@ class QLabeledDoubleRangeSlider(QLabeledRangeSlider):
"""The color of the bar between the first and last handle."""
class SliderLabel(QDoubleSpinBox):
class SliderLabel(QLineEdit):
def __init__(
self,
slider: QSlider,
@@ -646,52 +668,139 @@ class SliderLabel(QDoubleSpinBox):
) -> None:
super().__init__(parent=parent)
self._slider = slider
self._prefix = ""
self._suffix = ""
self._min = slider.minimum()
self._max = slider.maximum()
self._value = self._min
self._callback = connect
self._decimals = -1
self.setFocusPolicy(Qt.FocusPolicy.ClickFocus)
self.setMode(EdgeLabelMode.LabelIsValue)
self.setDecimals(0)
self.setText(str(self._value))
validator = QDoubleValidator(self)
validator.setNotation(QDoubleValidator.Notation.ScientificNotation)
self.setValidator(validator)
self.setRange(slider.minimum(), slider.maximum())
slider.rangeChanged.connect(self._update_size)
self.setAlignment(alignment)
self.setButtonSymbols(QSpinBox.ButtonSymbols.NoButtons)
self.setStyleSheet("background:transparent; border: 0;")
if connect is not None:
self.editingFinished.connect(lambda: connect(self.value()))
self.editingFinished.connect(self._editing_finished)
self.editingFinished.connect(self._silent_clear_focus)
self._update_size()
def _editing_finished(self):
self._silent_clear_focus()
self.setValue(float(self.text()))
if self._callback:
self._callback(self.value())
def setRange(self, min_: float, max_: float) -> None:
if self._mode == EdgeLabelMode.LabelIsRange:
max_val = max(abs(min_), abs(max_))
n_digits = max(len(str(int(max_val))), 7)
upper_bound = int("9" * n_digits)
self._min = -upper_bound
self._max = upper_bound
self._update_size()
else:
max_ = max(max_, min_)
self._min = min_
self._max = max_
def setDecimals(self, prec: int) -> None:
super().setDecimals(prec)
# super().setDecimals(prec)
self._decimals = prec
self._update_size()
def decimals(self) -> int:
"""Return the number of decimals used in the label."""
return self._decimals
def value(self) -> float:
return self._value
def setValue(self, val: Any) -> None:
super().setValue(val)
if val < self._min:
val = self._min
elif val > self._max:
val = self._max
self._value = val
self.updateText()
def updateText(self) -> None:
val = float(self._value)
use_scientific = (abs(val) < 0.0001 or abs(val) > 9999999.0) and val != 0.0
font_metrics = QFontMetrics(self.font())
eight_len = _fm_width(font_metrics, "8")
available_chars = self.width() // eight_len
total, _fraction = f"{val:.<f}".split(".")
if len(total) > available_chars:
use_scientific = True
if self._decimals < 0:
if use_scientific:
mantissa, exponent = f"{val:.{available_chars}e}".split("e")
mantissa = mantissa.rstrip("0").rstrip(".")
if len(mantissa) + len(exponent) + 1 < available_chars:
text = f"{mantissa}e{exponent}"
else:
decimals = max(available_chars - len(exponent) - 3, 2)
text = f"{val:.{decimals}e}"
else:
decimals = max(available_chars - len(total) - 1, 2)
text = f"{val:.{decimals}f}"
text = text.rstrip("0").rstrip(".")
else:
if use_scientific:
mantissa, exponent = f"{val:.{self._decimals}e}".split("e")
mantissa = mantissa.rstrip("0").rstrip(".")
text = f"{mantissa}e{exponent}"
else:
text = f"{val:.{self._decimals}f}"
if text == "":
text = "0"
self.setText(text)
if self._mode == EdgeLabelMode.LabelIsRange:
self._update_size()
def setMaximum(self, max: float) -> None:
super().setMaximum(max)
if self._mode == EdgeLabelMode.LabelIsValue:
self._update_size()
def minimum(self):
return self._min
def setMinimum(self, min: float) -> None:
super().setMinimum(min)
if self._mode == EdgeLabelMode.LabelIsValue:
self._update_size()
def setMaximum(self, max_: float) -> None:
self.setRange(self._min, max_)
def maximum(self):
return self._max
def setMinimum(self, min_: float) -> None:
self.setRange(min_, self._max)
def setMode(self, opt: EdgeLabelMode) -> None:
# when the edge labels are controlling slider range,
# we want them to have a big range, but not have a huge label
self._mode = opt
if opt == EdgeLabelMode.LabelIsRange:
self.setMinimum(-9999999)
self.setMaximum(9999999)
with contextlib.suppress(Exception):
self._slider.rangeChanged.disconnect(self.setRange)
else:
self.setMinimum(self._slider.minimum())
self.setMaximum(self._slider.maximum())
self._slider.rangeChanged.connect(self.setRange)
self.setRange(self._slider.minimum(), self._slider.maximum())
self._update_size()
def prefix(self) -> str:
return self._prefix
def setPrefix(self, prefix: str) -> None:
self._prefix = prefix
self._update_size()
def suffix(self) -> str:
return self._suffix
def setSuffix(self, suffix: str) -> None:
self._suffix = suffix
self._update_size()
# --------------- private ----------------
@@ -708,21 +817,19 @@ class SliderLabel(QDoubleSpinBox):
if self._mode & EdgeLabelMode.LabelIsValue:
# determine width based on min/max/specialValue
mintext = self.textFromValue(self.minimum())[:18]
maxtext = self.textFromValue(self.maximum())[:18]
mintext = str(self.minimum())[:18]
maxtext = str(self.maximum())[:18]
w = max(0, _fm_width(fm, mintext + fixed_content))
w = max(w, _fm_width(fm, maxtext + fixed_content))
if self.specialValueText():
w = max(w, _fm_width(fm, self.specialValueText()))
if self._mode & EdgeLabelMode.LabelIsRange:
w += 8 # it seems as thought suffix() is not enough
else:
w = max(0, _fm_width(fm, self.textFromValue(self.value()))) + 3
w = max(0, _fm_width(fm, str(self.value()))) + 3
w += 3 # cursor blinking space
# get the final size hint
opt = QStyleOptionSpinBox()
self.initStyleOption(opt)
# self.initStyleOption(opt)
size = self.style().sizeFromContents(
QStyle.ContentsType.CT_SpinBox, opt, QSize(w, h), self
)

View File

@@ -10,14 +10,10 @@ class _IntMixin:
self._singleStep = 1
def _type_cast(self, value) -> int:
return int(round(value))
return round(value)
class _FloatMixin:
_fvalueChanged = Signal(float)
_fsliderMoved = Signal(float)
_frangeChanged = Signal(float, float)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._singleStep = 0.01
@@ -41,7 +37,9 @@ class QRangeSlider(_IntMixin, _GenericRangeSlider):
class QDoubleRangeSlider(_FloatMixin, QRangeSlider):
pass
def _rename_signals(self) -> None:
super()._rename_signals()
self.rangeChanged = self.frangeChanged
# QRangeSlider.__doc__ += "\n" + textwrap.indent(QSlider.__doc__, " ")

View File

@@ -0,0 +1,3 @@
from superqt.switch._toggle_switch import QStyleOptionToggleSwitch, QToggleSwitch
__all__ = ["QStyleOptionToggleSwitch", "QToggleSwitch"]

View File

@@ -0,0 +1,321 @@
from __future__ import annotations
from typing import overload
from qtpy import QtCore, QtGui
from qtpy import QtWidgets as QtW
from qtpy.QtCore import Property, Qt
class QStyleOptionToggleSwitch(QtW.QStyleOptionButton):
def __init__(self):
super().__init__()
self.on_color = QtGui.QColor("#4D79C7")
self.off_color = QtGui.QColor("#909090")
self.handle_color = QtGui.QColor("#d5d5d5")
self.switch_width = 24
self.switch_height = 12
self.handle_size = 14
# these aren't yet overrideable in QToggleSwitch
self.margin = 2
self.text_alignment = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
class QToggleSwitch(QtW.QAbstractButton):
StyleOption = QStyleOptionToggleSwitch
@overload
def __init__(self, parent: QtW.QWidget | None = ...) -> None: ...
@overload
def __init__(self, text: str | None, parent: QtW.QWidget | None = ...) -> None: ...
def __init__( # type: ignore [misc] # overload
self, text: str | None = None, parent: QtW.QWidget | None = None
) -> None:
if isinstance(text, QtW.QWidget):
if parent is not None:
raise TypeError("No overload of QToggleSwitch matches the arguments")
parent = text
text = None
# attributes for drawing the switch
self._on_color = QtGui.QColor("#4D79C7")
self._off_color = QtGui.QColor("#909090")
self._handle_color = QtGui.QColor("#d5d5d5")
self._switch_width = 24
self._switch_height = 12
self._handle_size = 14
self._offset_value = 8.0
super().__init__(parent)
self.setCheckable(True)
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.toggled.connect(self._animate_handle)
self._anim = QtCore.QPropertyAnimation(self, b"_offset", self)
self._anim.setDuration(120)
self._offset_value = self._offset_for_checkstate(False)
if text:
self.setText(text)
def initStyleOption(self, option: QStyleOptionToggleSwitch) -> None:
"""Initialize the style option for the switch."""
option.initFrom(self)
option.text = self.text()
option.icon = self.icon()
option.iconSize = self.iconSize()
option.state |= (
QtW.QStyle.StateFlag.State_On
if self.isChecked()
else QtW.QStyle.StateFlag.State_Off
)
option.on_color = self.onColor
option.off_color = self.offColor
option.handle_color = self.handleColor
option.switch_width = self.switchWidth
option.switch_height = self.switchHeight
option.handle_size = self.handleSize
def paintEvent(self, a0: QtGui.QPaintEvent | None) -> None:
p = QtGui.QPainter(self)
opt = QStyleOptionToggleSwitch()
self.initStyleOption(opt)
self.drawGroove(p, self._groove_rect(opt), opt)
p.save()
self.drawHandle(p, self._handle_rect(opt), opt)
p.restore()
self.drawText(p, self._text_rect(opt), opt)
p.end()
def minimumSizeHint(self) -> QtCore.QSize:
return self.sizeHint()
def setAnimationDuration(self, msec: int) -> None:
"""Set the duration of the animation in milliseconds.
To disable animation, set duration to 0.
"""
self._anim.setDuration(msec)
def animationDuration(self) -> int:
"""Return the duration of the animation in milliseconds."""
return self._anim.duration()
def sizeHint(self) -> QtCore.QSize:
self.ensurePolished()
opt = QStyleOptionToggleSwitch()
self.initStyleOption(opt)
fm = QtGui.QFontMetrics(self.font())
text_size = fm.size(0, self.text())
height = max(opt.switch_height, text_size.height()) + opt.margin * 2
width = opt.switch_width + text_size.width() + opt.margin * 2 + 8
return QtCore.QSize(width, height)
### Re-implementable methods for drawing the switch ###
def drawGroove(
self,
painter: QtGui.QPainter,
rect: QtCore.QRectF,
option: QStyleOptionToggleSwitch,
) -> None:
"""Draw the groove of the switch.
Parameters
----------
painter : QtGui.QPainter
The painter to use for drawing.
rect : QtCore.QRectF
The rectangle in which to draw the groove.
option : QStyleOptionToggleSwitch
The style options used for drawing.
"""
painter.setPen(Qt.PenStyle.NoPen)
is_checked = option.state & QtW.QStyle.StateFlag.State_On
is_enabled = option.state & QtW.QStyle.StateFlag.State_Enabled
# draw the groove
if is_enabled:
painter.setBrush(option.on_color if is_checked else option.off_color)
painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, True)
painter.setOpacity(0.8)
else:
painter.setBrush(option.off_color)
painter.setOpacity(0.6)
half_height = option.switch_height / 2
painter.drawRoundedRect(rect, half_height, half_height)
def drawHandle(
self,
painter: QtGui.QPainter,
rect: QtCore.QRectF,
option: QStyleOptionToggleSwitch,
) -> None:
"""Draw the handle of the switch.
Parameters
----------
painter : QtGui.QPainter
The painter to use for drawing.
rect : QtCore.QRectF
The rectangle in which to draw the handle.
option : QStyleOptionToggleSwitch
The style options used for drawing.
"""
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(option.handle_color)
painter.setOpacity(1.0)
painter.drawEllipse(rect)
def drawText(
self,
painter: QtGui.QPainter,
rect: QtCore.QRectF,
option: QStyleOptionToggleSwitch,
) -> None:
"""Draw the text next to the switch.
Parameters
----------
painter : QtGui.QPainter
The painter to use for drawing.
rect : QtCore.QRectF
The rectangle in which to draw the text.
option : QStyleOptionToggleSwitch
The style options used for drawing.
"""
# TODO:
# using self.style().drawControl(CE_PushButtonLabel ...)
# might provide a more native experience.
text_color = option.palette.color(self.foregroundRole())
pen = QtGui.QPen(text_color, 1)
painter.setPen(pen)
painter.drawText(rect, int(option.text_alignment), option.text)
### Properties ###
def _get_onColor(self) -> QtGui.QColor:
return self._on_color
def _set_onColor(self, color: QtGui.QColor | QtGui.QBrush) -> None:
self._on_color = QtGui.QColor(color)
self.update()
onColor = Property(QtGui.QColor, _get_onColor, _set_onColor)
"""Color of the switch groove when it is on."""
def _get_offColor(self) -> QtGui.QColor:
return self._off_color
def _set_offColor(self, color: QtGui.QColor | QtGui.QBrush) -> None:
self._off_color = QtGui.QColor(color)
self.update()
offColor = Property(QtGui.QColor, _get_offColor, _set_offColor)
"""Color of the switch groove when it is off."""
def _get_handleColor(self) -> QtGui.QColor:
return self._handle_color
def _set_handleColor(self, color: QtGui.QColor | QtGui.QBrush) -> None:
self._handle_color = QtGui.QColor(color)
self.update()
handleColor = Property(QtGui.QColor, _get_handleColor, _set_handleColor)
"""Color of the switch handle."""
def _get_switchWidth(self) -> int:
return self._switch_width
def _set_switchWidth(self, width: int) -> None:
self._switch_width = width
self._offset_value = self._offset_for_checkstate(self.isChecked())
self.update()
switchWidth = Property(int, _get_switchWidth, _set_switchWidth)
"""Width of the switch groove."""
def _get_switchHeight(self) -> int:
return self._switch_height
def _set_switchHeight(self, height: int) -> None:
self._switch_height = height
self._offset_value = self._offset_for_checkstate(self.isChecked())
self.update()
switchHeight = Property(int, _get_switchHeight, _set_switchHeight)
"""Height of the switch groove."""
def _get_handleSize(self) -> int:
return self._handle_size
def _set_handleSize(self, size: int) -> None:
self._handle_size = size
self.update()
handleSize = Property(int, _get_handleSize, _set_handleSize)
"""Width/height of the switch handle."""
### Other private methods ###
def _animate_handle(self, val: bool) -> None:
end = self._offset_for_checkstate(val)
if self._anim.duration():
self._anim.setStartValue(self._offset_for_checkstate(not val))
self._anim.setEndValue(end)
self._anim.start()
else:
self._set_offset(end)
def _get_offset(self) -> float:
return self._offset_value
def _set_offset(self, offset: float) -> None:
self._offset_value = offset
self.update()
_offset = Property(float, _get_offset, _set_offset)
def _offset_for_checkstate(self, val: bool) -> float:
opt = QStyleOptionToggleSwitch()
self.initStyleOption(opt)
if val:
offset = opt.margin + opt.switch_width - opt.switch_height / 2
else:
offset = opt.margin + opt.switch_height / 2
return offset
def _groove_rect(self, opt: QStyleOptionToggleSwitch) -> QtCore.QRectF:
return QtCore.QRectF(
opt.margin, self._vertical_offset(opt), opt.switch_width, opt.switch_height
)
def _handle_rect(self, opt: QStyleOptionToggleSwitch) -> QtCore.QRectF:
return QtCore.QRectF(
self._offset_value - opt.handle_size / 2,
self._vertical_offset(opt) - (opt.handle_size - opt.switch_height) / 2,
opt.handle_size,
opt.handle_size,
)
def _text_rect(self, opt: QStyleOptionToggleSwitch) -> QtCore.QRectF:
# If handle is bigger than groove, adjust the text to the right of the handle.
# If groove is bigger, adjust the text to the right of the groove.
return QtCore.QRectF(
opt.switch_width
+ max(opt.handle_size - opt.switch_height, 0) // 2
+ opt.margin * 2
+ 2,
0,
self.width() - opt.switch_width - opt.margin * 2,
self.height(),
)
def _vertical_offset(self, opt: QStyleOptionToggleSwitch) -> int:
"""Offset for the vertical centering of the switch."""
return (self.height() - opt.switch_height) // 2 + opt.margin

View File

@@ -1,32 +1,34 @@
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from superqt.cmap import draw_colormap # noqa: TCH004
from superqt.cmap import draw_colormap
__all__ = (
"CodeSyntaxHighlight",
"FunctionWorker",
"GeneratorWorker",
"QFlowLayout",
"QMessageHandler",
"QSignalDebouncer",
"QSignalThrottler",
"WorkerBase",
"create_worker",
"qimage_to_array",
"draw_colormap",
"ensure_main_thread",
"ensure_object_thread",
"exceptions_as_dialog",
"FunctionWorker",
"GeneratorWorker",
"new_worker_qthread",
"qdebounced",
"QMessageHandler",
"QSignalDebouncer",
"QSignalThrottler",
"qimage_to_array",
"qthrottled",
"signals_blocked",
"thread_worker",
"WorkerBase",
)
from ._code_syntax_highlight import CodeSyntaxHighlight
from ._ensure_thread import ensure_main_thread, ensure_object_thread
from ._errormsg_context import exceptions_as_dialog
from ._flow_layout import QFlowLayout
from ._img_utils import qimage_to_array
from ._message_handler import QMessageHandler
from ._misc import signals_blocked

View File

@@ -1,75 +1,268 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast
from pygments import highlight
from pygments.formatter import Formatter
from pygments.lexers import find_lexer_class, get_lexer_by_name
from pygments.util import ClassNotFound
from qtpy import QtGui
from qtpy.QtGui import (
QColor,
QFont,
QPalette,
QSyntaxHighlighter,
QTextCharFormat,
QTextDocument,
)
if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
from typing import Literal, TypeAlias, TypedDict, Unpack
import pygments.style
from pygments.style import _StyleDict
from pygments.token import _TokenType
from qtpy.QtCore import QObject
class SupportsDocumentAndPalette(QObject):
def document(self) -> QTextDocument | None: ...
def palette(self) -> QPalette: ...
def setPalette(self, palette: QPalette) -> None: ...
KnownStyle: TypeAlias = Literal[
"abap",
"algol",
"algol_nu",
"arduino",
"autumn",
"bw",
"borland",
"coffee",
"colorful",
"default",
"dracula",
"emacs",
"friendly_grayscale",
"friendly",
"fruity",
"github-dark",
"gruvbox-dark",
"gruvbox-light",
"igor",
"inkpot",
"lightbulb",
"lilypond",
"lovelace",
"manni",
"material",
"monokai",
"murphy",
"native",
"nord-darker",
"nord",
"one-dark",
"paraiso-dark",
"paraiso-light",
"pastie",
"perldoc",
"rainbow_dash",
"rrt",
"sas",
"solarized-dark",
"solarized-light",
"staroffice",
"stata-dark",
"stata-light",
"tango",
"trac",
"vim",
"vs",
"xcode",
"zenburn",
]
class FormatterKwargs(TypedDict, total=False):
style: KnownStyle | str
full: bool
title: str
encoding: str
outencoding: str
MONO_FAMILIES = [
"Menlo",
"Courier New",
"Courier",
"Monaco",
"Consolas",
"Andale Mono",
"Source Code Pro",
"Ubuntu Mono",
"monospace",
]
# inspired by https://github.com/Vector35/snippets/blob/master/QCodeEditor.py
# (MIT license) and
# https://pygments.org/docs/formatterdevelopment/#html-3-2-formatter
def get_text_char_format(style: _StyleDict) -> QTextCharFormat:
"""Return a QTextCharFormat object based on the given Pygments `_StyleDict`.
def get_text_char_format(style):
text_char_format = QtGui.QTextCharFormat()
if hasattr(text_char_format, "setFontFamilies"):
text_char_format.setFontFamilies(["monospace"])
else:
text_char_format.setFontFamily("monospace")
if style.get("color"):
text_char_format.setForeground(QtGui.QColor(f"#{style['color']}"))
if style.get("bgcolor"):
text_char_format.setBackground(QtGui.QColor(style["bgcolor"]))
style will likely have these keys:
- color: str | None
- bold: bool
- italic: bool
- underline: bool
- bgcolor: str | None
- border: str | None
- roman: bool | None
- sans: bool | None
- mono: bool | None
- ansicolor: str | None
- bgansicolor: str | None
"""
text_char_format = QTextCharFormat()
if style.get("mono"):
text_char_format.setFontFamilies(MONO_FAMILIES)
if color := style.get("color"):
text_char_format.setForeground(QColor(f"#{color}"))
if bgcolor := style.get("bgcolor"):
text_char_format.setBackground(QColor(f"#{bgcolor}"))
if style.get("bold"):
text_char_format.setFontWeight(QtGui.QFont.Bold)
text_char_format.setFontWeight(QFont.Weight.Bold)
if style.get("italic"):
text_char_format.setFontItalic(True)
if style.get("underline"):
text_char_format.setFontUnderline(True)
# TODO find if it is possible to support border style.
# if style.get("border"):
# ...
return text_char_format
class QFormatter(Formatter):
def __init__(self, **kwargs):
def __init__(self, **kwargs: Unpack[FormatterKwargs]) -> None:
super().__init__(**kwargs)
self.data = []
self._style = {name: get_text_char_format(style) for name, style in self.style}
self.data: list[QTextCharFormat] = []
style = cast("pygments.style.StyleMeta", self.style)
self._style: Mapping[_TokenType, QTextCharFormat]
self._style = {token: get_text_char_format(style) for token, style in style}
def format(self, tokensource, outfile):
def format(
self, tokensource: Sequence[tuple[_TokenType, str]], outfile: Any
) -> None:
"""Format the given token stream.
`outfile` is argument from parent class, but
in Qt we do not produce string output, but QTextCharFormat, so it needs to be
collected using `self.data`.
When Qt calls the highlightBlock method on a `CodeSyntaxHighlight` object,
`highlight(text, self.lexer, self.formatter)`, which trigger pygments to call
this method.
Normally, this method puts output into `outfile`, but in Qt we do not produce
string output; instead we collect QTextCharFormat objects in `self.data`, which
can be used to apply formatting in the `highlightBlock` method that triggered
this method.
"""
self.data = []
null = QTextCharFormat()
for token, value in tokensource:
self.data.extend([self._style[token]] * len(value))
# using get method to workaround not defined style for plain token
# https://github.com/pygments/pygments/issues/2149
self.data.extend([self._style.get(token, null)] * len(value))
class CodeSyntaxHighlight(QtGui.QSyntaxHighlighter):
def __init__(self, parent, lang, theme):
class CodeSyntaxHighlight(QSyntaxHighlighter):
"""A syntax highlighter for code using Pygments.
Parameters
----------
parent : QTextDocument | QObject | None
The parent object. Usually a QTextDocument. To use this class with a
QTextArea, pass in `text_area.document()`.
lang : str
The language of the code to highlight. This should be a string that
Pygments recognizes, e.g. 'python', 'pytb', 'cpp', 'java', etc.
theme : KnownStyle | str
The name of the Pygments style to use. For a complete list of available
styles, use `pygments.styles.get_all_styles()`.
Examples
--------
```python
from qtpy.QtWidgets import QTextEdit
from superqt.utils import CodeSyntaxHighlight
text_area = QTextEdit()
highlighter = CodeSyntaxHighlight(text_area.document(), "python", "monokai")
# then manually apply the background color to the text area.
palette = text_area.palette()
bgrd_color = QColor(self._highlight.background_color)
palette.setColor(QPalette.ColorRole.Base, bgrd_color)
text_area.setPalette(palette)
```
"""
def __init__(
self,
parent: SupportsDocumentAndPalette | QTextDocument | QObject | None,
lang: str,
theme: KnownStyle | str = "default",
) -> None:
self._doc_parent: SupportsDocumentAndPalette | None = None
if (
parent
and not isinstance(parent, QTextDocument)
and hasattr(parent, "document")
and callable(parent.document)
and isinstance(doc := parent.document(), QTextDocument)
):
if hasattr(parent, "palette") and hasattr(parent, "setPalette"):
self._doc_parent = cast("SupportsDocumentAndPalette", parent)
parent = doc
super().__init__(parent)
self.setLanguage(lang)
self.setTheme(theme)
def setTheme(self, theme: KnownStyle | str) -> None:
"""Set the theme for the syntax highlighting.
This should be a string that Pygments recognizes, e.g. 'monokai', 'solarized'.
Use `pygments.styles.get_all_styles()` to see a list of available styles.
"""
self.formatter = QFormatter(style=theme)
if self._doc_parent is not None:
palette = self._doc_parent.palette()
bgrd = QColor(self.background_color)
palette.setColor(QPalette.ColorRole.Base, bgrd)
self._doc_parent.setPalette(palette)
self.rehighlight()
def setLanguage(self, lang: str) -> None:
"""Set the language for the syntax highlighting.
This should be a string that Pygments recognizes, e.g. 'python', 'pytb', 'cpp',
'java', etc.
"""
try:
self.lexer = get_lexer_by_name(lang)
except ClassNotFound:
self.lexer = find_lexer_class(lang)()
except ClassNotFound as e:
if cls := find_lexer_class(lang):
self.lexer = cls()
else:
raise ValueError(f"Could not find lexer for language {lang!r}.") from e
@property
def background_color(self):
return self.formatter.style.background_color
def background_color(self) -> str:
style = cast("pygments.style.StyleMeta", self.formatter.style)
return style.background_color
def highlightBlock(self, text):
def highlightBlock(self, text: str | None) -> None:
# dirty, dirty hack
# The core problem is that pygemnts by default use string streams,
# The core problem is that pygments by default use string streams,
# that will not handle QTextCharFormat, so we need use `data` property to
# work around this.
highlight(text, self.lexer, self.formatter)
for i in range(len(text)):
self.setFormat(i, 1, self.formatter.data[i])
if text:
highlight(text, self.lexer, self.formatter)
for i in range(len(text)):
self.setFormat(i, 1, self.formatter.data[i])

View File

@@ -0,0 +1,183 @@
from __future__ import annotations
from qtpy.QtCore import QPoint, QRect, QSize, Qt
from qtpy.QtWidgets import QLayout, QLayoutItem, QSizePolicy, QStyle, QWidget
class QFlowLayout(QLayout):
"""Layout that handles different window sizes.
The widget placement changes depending on the width of the application window.
Code translated from C++ at:
<https://code.qt.io/cgit/qt/qtbase.git/tree/examples/widgets/layouts/flowlayout>
described at: <https://doc.qt.io/qt-6/qtwidgets-layouts-flowlayout-example.html>
See also: <https://doc.qt.io/qt-6/layout.html>
Parameters
----------
parent : QWidget, optional
The parent widget, by default None
"""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._item_list: list[QLayoutItem] = []
self._h_space = -1
self._v_space = -1
def __del__(self) -> None:
while item := self.takeAt(0):
del item
def addItem(self, item: QLayoutItem | None) -> None:
"""Add an item to the layout."""
if item:
self._item_list.append(item)
def setHorizontalSpacing(self, space: int | None) -> None:
"""Set the horizontal spacing.
If None or -1, the spacing is set to the default value based on the style
of the parent widget.
"""
self._h_space = -1 if space is None else space
def horizontalSpacing(self) -> int:
"""Return the horizontal spacing."""
if self._h_space >= 0:
return self._h_space
return self._smartSpacing(QStyle.PixelMetric.PM_LayoutHorizontalSpacing)
def setVerticalSpacing(self, space: int | None) -> None:
"""Set the vertical spacing.
If None or -1, the spacing is set to the default value based on the style
of the parent widget.
"""
self._v_space = -1 if space is None else space
def verticalSpacing(self) -> int:
"""Return the vertical spacing."""
if self._v_space >= 0:
return self._v_space
return self._smartSpacing(QStyle.PixelMetric.PM_LayoutVerticalSpacing)
def expandingDirections(self) -> Qt.Orientation:
"""Return the expanding directions.
These are the Qt::Orientations in which the layout can make use of more space
than its sizeHint().
"""
return Qt.Orientation.Horizontal
def hasHeightForWidth(self) -> bool:
"""Return whether the layout handles height for width."""
return True
def heightForWidth(self, width: int) -> int:
"""Return the height for a given width.
`heightForWidth()` passes the width on to _doLayout() which in turn uses the
width as an argument for the layout rect, i.e., the bounds in which the items
are laid out. This rect does not include the layout margin().
"""
return self._doLayout(QRect(0, 0, width, 0), True)
def count(self) -> int:
"""Return the number of items in the layout."""
return len(self._item_list)
def itemAt(self, index: int) -> QLayoutItem | None:
"""Return the item at the given index, or None if the index is out of range."""
try:
return self._item_list[index]
except IndexError:
return None
def minimumSize(self) -> QSize:
"""Return the minimum size of the layout."""
size = QSize()
for item in self._item_list:
size = size.expandedTo(item.minimumSize())
margins = self.contentsMargins()
size += QSize(
margins.left() + margins.right(), margins.top() + margins.bottom()
)
return size
def setGeometry(self, rect: QRect) -> None:
"""Set the geometry of the layout.
This triggers a re-layout of the items.
"""
super().setGeometry(rect)
self._doLayout(rect)
def sizeHint(self) -> QSize:
"""Return the size hint of the layout."""
return self.minimumSize()
def takeAt(self, index: int) -> QLayoutItem | None:
"""Remove and return the item at the given index. Or return None."""
if 0 <= index < len(self._item_list):
return self._item_list.pop(index)
return None
def _doLayout(self, rect: QRect, test_only: bool = False) -> int:
"""Arrange the items in the layout.
If test_only is True, the items are not actually laid out, but the height
that the layout would have with the given width is returned.
"""
left, top, right, bottom = self.getContentsMargins()
effective_rect = rect.adjusted(left, top, -right, -bottom) # type: ignore
x = effective_rect.x()
y = effective_rect.y()
line_height = 0
for item in self._item_list:
if (wid := item.widget()) and (style := wid.style()):
space_x = self.horizontalSpacing()
space_y = self.verticalSpacing()
if space_x == -1:
space_x = style.layoutSpacing(
QSizePolicy.ControlType.PushButton,
QSizePolicy.ControlType.PushButton,
Qt.Orientation.Horizontal,
)
if space_y == -1:
space_y = style.layoutSpacing(
QSizePolicy.ControlType.PushButton,
QSizePolicy.ControlType.PushButton,
Qt.Orientation.Vertical,
)
# next_x is the x-coordinate of the right edge of the item
next_x = x + item.sizeHint().width() + space_x
# if the item is not the first one in a line, add the spacing
# to the left of it
if next_x - space_x > effective_rect.right() and line_height > 0:
x = effective_rect.x()
y = y + line_height + space_y
next_x = x + item.sizeHint().width() + space_x
line_height = 0
# if this is not a test run, move the item to its proper place
if not test_only:
item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))
x = next_x
line_height = max(line_height, item.sizeHint().height())
return y + line_height - rect.y() + bottom
def _smartSpacing(self, pm: QStyle.PixelMetric) -> int:
"""Return the smart spacing based on the style of the parent widget."""
if isinstance(parent := self.parent(), QWidget) and (style := parent.style()):
return style.pixelMetric(pm, None, parent)
return -1

View File

@@ -1,3 +1,4 @@
import sys
from typing import TYPE_CHECKING
from qtpy.QtGui import QImage
@@ -37,4 +38,8 @@ def qimage_to_array(img: QImage) -> "np.ndarray":
arr = np.frombuffer(b, np.uint8).reshape(h, w, c)
# reverse channel colors for numpy
return arr.take([2, 1, 0, 3], axis=2)
# On big endian we need to specify a different order
if sys.byteorder == "big":
return arr.take([1, 2, 3, 0], axis=2) # pragma: no cover
else:
return arr.take([2, 1, 0, 3], axis=2)

View File

@@ -1,5 +1,6 @@
from collections.abc import Iterator
from contextlib import contextmanager
from typing import TYPE_CHECKING, Iterator
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from qtpy.QtCore import QObject

View File

@@ -9,9 +9,7 @@ from typing import (
Any,
Callable,
ClassVar,
Generator,
Generic,
Sequence,
TypeVar,
overload,
)
@@ -19,6 +17,8 @@ from typing import (
from qtpy.QtCore import QObject, QRunnable, QThread, QThreadPool, QTimer, Signal
if TYPE_CHECKING:
from collections.abc import Generator, Sequence
_T = TypeVar("_T")
class SigInst(Generic[_T]):
@@ -766,7 +766,7 @@ def thread_worker(
############################################################################
# This is a variant on the above pattern, it uses QThread instead of Qrunnable
# see https://doc.qt.io/qt-5/threads-technologies.html#comparison-of-solutions
# see https://doc.qt.io/qt-6/threads-technologies.html#comparison-of-solutions
# (it appears from that table that QRunnable cannot emit or receive signals,
# but we circumvent that here with our WorkerBase class that also inherits from
# QObject... providing signals/slots).
@@ -777,7 +777,7 @@ def thread_worker(
#
# However, a disadvantage is that you have no access to (and therefore less
# control over) the QThread itself. See for example all of the methods
# provided on the QThread object: https://doc.qt.io/qt-5/qthread.html
# provided on the QThread object: https://doc.qt.io/qt-6/qthread.html
if TYPE_CHECKING:
@@ -808,7 +808,7 @@ def new_worker_qthread(
standard "single-threaded" signals & slots, note that inter-thread
signals and slots (automatically) use an event-based QueuedConnection, while
intra-thread signals use a DirectConnection. See [Signals and Slots Across
Threads](https://doc.qt.io/qt-5/threads-qobject.html#signals-and-slots-across-threads>)
Threads](https://doc.qt.io/qt-6/threads-qobject.html#signals-and-slots-across-threads>)
Parameters
----------

View File

@@ -76,8 +76,9 @@ def test_catalog_combo(qtbot):
assert wdg.currentColormap() == Colormap("viridis")
def test_cmap_combo(qtbot):
wdg = QColormapComboBox(allow_user_colormaps=True)
@pytest.mark.parametrize("filterable", [False, True])
def test_cmap_combo(qtbot, filterable):
wdg = QColormapComboBox(allow_user_colormaps=True, filterable=filterable)
qtbot.addWidget(wdg)
wdg.show()
assert wdg.userAdditionsAllowed()

View File

@@ -69,3 +69,14 @@ def test_wrap_text():
assert isinstance(wrap, list)
assert all(isinstance(x, str) for x in wrap)
assert 9 <= len(wrap) <= 13
def test_minimum_size_hint():
# The hint should always just be the space needed for "..."
wdg = QElidingLabel()
size_hint = wdg.minimumSizeHint()
# Regardless of what text is contained
wdg.setText(TEXT)
new_hint = wdg.minimumSizeHint()
assert size_hint.width() == new_hint.width()
assert size_hint.height() == new_hint.height()

View File

@@ -162,7 +162,7 @@ def test_names(qapp):
signature = inspect.signature(ob.check_object_thread_return_future)
assert len(signature.parameters) == 1
assert next(iter(signature.parameters.values())).name == "a"
assert next(iter(signature.parameters.values())).annotation == int
assert next(iter(signature.parameters.values())).annotation is int
assert ob.check_main_thread_return.__name__ == "check_main_thread_return"

27
tests/test_flow_layout.py Normal file
View File

@@ -0,0 +1,27 @@
from typing import Any
from qtpy.QtWidgets import QPushButton, QWidget
from superqt import QFlowLayout
def test_flow_layout(qtbot: Any) -> None:
wdg = QWidget()
qtbot.addWidget(wdg)
layout = QFlowLayout(wdg)
layout.addWidget(QPushButton("Short"))
layout.addWidget(QPushButton("Longer"))
layout.addWidget(QPushButton("Different text"))
layout.addWidget(QPushButton("More text"))
layout.addWidget(QPushButton("Even longer button text"))
wdg.setWindowTitle("Flow Layout")
wdg.show()
assert layout.expandingDirections()
assert layout.heightForWidth(200) > layout.heightForWidth(400)
assert layout.count() == 5
assert layout.itemAt(0).widget().text() == "Short"
layout.takeAt(0)
assert layout.count() == 4

View File

@@ -1,5 +1,3 @@
from typing import List, Tuple
import pytest
from pytestqt.qtbot import QtBot
from qtpy.QtCore import Qt
@@ -30,15 +28,15 @@ def widget(qtbot: QtBot, data: dict) -> QSearchableTreeWidget:
return widget
def columns(item: QTreeWidgetItem) -> Tuple[str, str]:
def columns(item: QTreeWidgetItem) -> tuple[str, str]:
return item.text(0), item.text(1)
def all_items(tree: QTreeWidget) -> List[QTreeWidgetItem]:
def all_items(tree: QTreeWidget) -> list[QTreeWidgetItem]:
return tree.findItems("", Qt.MatchContains | Qt.MatchRecursive)
def shown_items(tree: QTreeWidget) -> List[QTreeWidgetItem]:
def shown_items(tree: QTreeWidget) -> list[QTreeWidgetItem]:
items = all_items(tree)
return [item for item in items if not item.isHidden()]

View File

@@ -125,7 +125,7 @@ def test_debouncer_method_definition(qtbot):
def test_class_with_slots(qtbot):
class A:
__slots__ = ("count", "__weakref__")
__slots__ = ("__weakref__", "count")
def __init__(self):
self.count = 0

116
tests/test_toggle_switch.py Normal file
View File

@@ -0,0 +1,116 @@
from unittest.mock import Mock
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication, QCheckBox, QVBoxLayout, QWidget
from superqt import QToggleSwitch
def test_on_and_off(qtbot):
wdg = QToggleSwitch()
qtbot.addWidget(wdg)
wdg.show()
assert not wdg.isChecked()
wdg.setChecked(True)
assert wdg.isChecked()
QApplication.processEvents()
wdg.setChecked(False)
assert not wdg.isChecked()
QApplication.processEvents()
wdg.setChecked(False)
assert not wdg.isChecked()
wdg.toggle()
assert wdg.isChecked()
wdg.toggle()
assert not wdg.isChecked()
wdg.click()
assert wdg.isChecked()
wdg.click()
assert not wdg.isChecked()
QApplication.processEvents()
def test_get_set(qtbot):
wdg = QToggleSwitch()
qtbot.addWidget(wdg)
wdg.onColor = "#ff0000"
assert wdg.onColor.name() == "#ff0000"
wdg.offColor = "#00ff00"
assert wdg.offColor.name() == "#00ff00"
wdg.handleColor = "#0000ff"
assert wdg.handleColor.name() == "#0000ff"
wdg.setText("new text")
assert wdg.text() == "new text"
wdg.switchWidth = 100
assert wdg.switchWidth == 100
wdg.switchHeight = 100
assert wdg.switchHeight == 100
wdg.handleSize = 80
assert wdg.handleSize == 80
def test_mouse_click(qtbot):
wdg = QToggleSwitch()
mock = Mock()
wdg.toggled.connect(mock)
qtbot.addWidget(wdg)
assert not wdg.isChecked()
mock.assert_not_called()
qtbot.mouseClick(wdg, Qt.MouseButton.LeftButton)
assert wdg.isChecked()
mock.assert_called_once_with(True)
qtbot.mouseClick(wdg, Qt.MouseButton.LeftButton)
assert not wdg.isChecked()
def test_signal_emission_order(qtbot):
"""Check if event emmision is same for QToggleSwitch and QCheckBox"""
wdg = QToggleSwitch()
emitted_from_toggleswitch = []
wdg.toggled.connect(lambda: emitted_from_toggleswitch.append("toggled"))
wdg.pressed.connect(lambda: emitted_from_toggleswitch.append("pressed"))
wdg.clicked.connect(lambda: emitted_from_toggleswitch.append("clicked"))
wdg.released.connect(lambda: emitted_from_toggleswitch.append("released"))
qtbot.addWidget(wdg)
checkbox = QCheckBox()
emitted_from_checkbox = []
checkbox.toggled.connect(lambda: emitted_from_checkbox.append("toggled"))
checkbox.pressed.connect(lambda: emitted_from_checkbox.append("pressed"))
checkbox.clicked.connect(lambda: emitted_from_checkbox.append("clicked"))
checkbox.released.connect(lambda: emitted_from_checkbox.append("released"))
qtbot.addWidget(checkbox)
emitted_from_toggleswitch.clear()
emitted_from_checkbox.clear()
wdg.toggle()
checkbox.toggle()
assert emitted_from_toggleswitch
assert emitted_from_toggleswitch == emitted_from_checkbox
emitted_from_toggleswitch.clear()
emitted_from_checkbox.clear()
wdg.click()
checkbox.click()
assert emitted_from_toggleswitch
assert emitted_from_toggleswitch == emitted_from_checkbox
def test_multiple_lines(qtbot):
container = QWidget()
layout = QVBoxLayout(container)
wdg0 = QToggleSwitch("line1\nline2\nline3")
wdg1 = QToggleSwitch("line1\nline2")
checkbox = QCheckBox()
layout.addWidget(wdg0)
layout.addWidget(wdg1)
layout.addWidget(checkbox)
container.show()
qtbot.addWidget(container)
assert wdg0.text() == "line1\nline2\nline3"
assert wdg1.text() == "line1\nline2"
assert wdg0.sizeHint().height() > wdg1.sizeHint().height()
assert wdg1.sizeHint().height() > checkbox.sizeHint().height()
assert wdg0.height() > wdg1.height()
assert wdg1.height() > checkbox.height()

View File

@@ -1,4 +1,5 @@
from typing import Any, Iterable
from collections.abc import Iterable
from typing import Any
from unittest.mock import Mock
import pytest

View File

@@ -1,6 +1,7 @@
import math
from collections.abc import Iterable
from itertools import product
from typing import Any, Iterable
from typing import Any
from unittest.mock import Mock
import pytest