Compare commits

..

144 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
Talley Lambert
ac4adf5234 chore: changelog v0.6.8 2024-06-15 16:58:36 -04:00
Talley Lambert
5f68795a82 feat: graceful offline fallback for qiconify (#251) 2024-06-15 07:54:40 -04:00
Talley Lambert
17ad1079a8 chore: changelog v0.6.7 2024-06-07 16:39:41 -04:00
pre-commit-ci[bot]
6bb050c499 ci: [pre-commit.ci] autoupdate (#250)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.4.3 → v0.4.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.3...v0.4.7)
- [github.com/abravalheri/validate-pyproject: v0.16 → v0.18](https://github.com/abravalheri/validate-pyproject/compare/v0.16...v0.18)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-06-07 16:36:52 -04:00
Talley Lambert
1f4d9081b9 fix: prevent qthrottled and qdebounced from holding strong references with bound methods (#247)
* finish

* linting

* done

* use weakmethod, add signature

* add test for warning
2024-06-03 10:24:03 -04:00
Daniel Althviz Moré
7b1aefd119 Prevent computing full document content highlight and only parse current block content for performance (#246) 2024-05-28 07:11:57 -04:00
Talley Lambert
0ec5cd3a2f chore: changelog v0.6.6 2024-05-12 11:11:56 -04:00
Talley Lambert
8f62b0b00d perf: improve paint time for QColormapLineEdit (#245) 2024-05-12 10:32:59 -04:00
pre-commit-ci[bot]
4a0aaca2e9 ci: [pre-commit.ci] autoupdate (#244)
* ci: [pre-commit.ci] autoupdate

updates:
- [github.com/astral-sh/ruff-pre-commit: v0.3.5 → v0.4.3](https://github.com/astral-sh/ruff-pre-commit/compare/v0.3.5...v0.4.3)
- [github.com/pre-commit/mirrors-mypy: v1.9.0 → v1.10.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.9.0...v1.10.0)

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-05-06 19:18:03 -04:00
Talley Lambert
2d49e77c3d chore: changelog v0.6.5 2024-05-06 17:45:31 -04:00
Talley Lambert
ba495a5e72 fix: fix a number of issues with Labeled and Range Sliders, add LabelsOnHandle mode. (#242)
* fix: remove processEvents

* merge in fixes

* remove comment

* fix hint

* fix napari

* change pyqt6

* fix: fix range slider styles
2024-05-06 17:43:53 -04:00
Talley Lambert
12f10be8da ci: trying to fix tests on various platforms (#243)
* ci: attempt1

* add lxml_html_clean

* fix napari version name

* breakout coverage

* use main

* cump

* bump again

* bump

* bump

* skip more napari tests

* add always

* back to v1

* try editabel

* use main again

* remove editable

* editable again

* bump

* bump

* bump

* use v2
2024-05-06 15:29:43 -04:00
Talley Lambert
9ca0bbf858 chore: changelog v0.6.4 2024-04-25 15:56:57 -04:00
Talley Lambert
0ab6758972 fix: fix inverted appearance (#240)
* fix: fix inverted appearance

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

* pass codecov token

* inherit secrets

* explicitly pass token

* pin "'PyQt6<6.7'"

* pin upper pyqt6

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-04-25 15:55:02 -04:00
Talley Lambert
d2bc3d898c drop to macos-13 2024-04-25 13:54:44 -04:00
Talley Lambert
1bb1a58a73 inherit secret 2024-04-22 14:08:53 -04:00
Talley Lambert
1288250597 add secret 2024-04-22 13:51:54 -04:00
pre-commit-ci[bot]
34a776e8d0 ci: [pre-commit.ci] autoupdate (#238)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.3.0 → v0.3.5](https://github.com/astral-sh/ruff-pre-commit/compare/v0.3.0...v0.3.5)
- [github.com/pre-commit/mirrors-mypy: v1.8.0 → v1.9.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.8.0...v1.9.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-04-22 13:15:40 -04:00
Talley Lambert
146644e105 chore: changelog v0.6.3 2024-03-27 17:34:31 -04:00
Talley Lambert
e7873ad93d fix: fix sliderReleased, sliderPressed signals, and setTracking (#237) 2024-03-27 17:32:25 -04:00
dependabot[bot]
0396d465e2 ci(dependabot): bump softprops/action-gh-release from 1 to 2 (#236)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 1 to 2.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v1...v2)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-11 15:56:10 -04:00
Talley Lambert
4bf73c37f1 chore: changelog v0.6.2 2024-03-06 15:48:43 -05:00
Talley Lambert
d407af2089 fix: don't use AbstractContextManager for exceptions_as_dialog (#234)
* fix: don't use AbstractContextManager

* fix docs
2024-03-06 15:46:28 -05:00
Talley Lambert
16f9ef9d3d style: use ruff format instead of black, update pre-commit, restrict pyside6 tests (#235)
* style: use ruff format

* fix import

* disallow pyside 6.6.2

* pin in tests too

* pyside6.4 on windows

* fix greedy imports

* double quote

* run sliders last

* try 6.6.1 again
2024-03-06 15:42:51 -05:00
Talley Lambert
56f65ff123 feat: make toggle button public in QCollapsible (#232) 2024-03-06 15:27:56 -05:00
pre-commit-ci[bot]
60188de52e ci: [pre-commit.ci] autoupdate (#228)
updates:
- [github.com/psf/black: 23.11.0 → 23.12.1](https://github.com/psf/black/compare/23.11.0...23.12.1)
- [github.com/astral-sh/ruff-pre-commit: v0.1.6 → v0.1.9](https://github.com/astral-sh/ruff-pre-commit/compare/v0.1.6...v0.1.9)
- [github.com/pre-commit/mirrors-mypy: v1.7.1 → v1.8.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.7.1...v1.8.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-01-09 10:09:23 -05:00
pre-commit-ci[bot]
b4d3a4f9b7 ci: [pre-commit.ci] autoupdate (#223)
updates:
- [github.com/psf/black: 23.10.1 → 23.11.0](https://github.com/psf/black/compare/23.10.1...23.11.0)
- [github.com/astral-sh/ruff-pre-commit: v0.1.4 → v0.1.6](https://github.com/astral-sh/ruff-pre-commit/compare/v0.1.4...v0.1.6)
- [github.com/pre-commit/mirrors-mypy: v1.6.1 → v1.7.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.6.1...v1.7.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-12-28 12:03:21 -05:00
dependabot[bot]
95b1178647 ci(dependabot): bump actions/setup-python from 4 to 5 (#225)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4 to 5.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-22 17:15:47 -05:00
Peter Sobolewski
ef87685626 Bugfix: Check min max versus current value (#221)
* Check min max vs value

* add test

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

* test min too

* check that max > min per Qt

* update test

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-11-25 13:29:48 -05:00
Talley Lambert
b927159f49 feat: add addKey method to QIconifyIcon (#218)
* feat: addKey method to Iconify

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

* remove breakpoint

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-11-08 12:52:41 -05:00
Talley Lambert
61e7409b1c fix: better default size policy for qcollapsible (#217)
* fix: better default size policy for qcollapsible

* fix: fix annotations
2023-11-07 07:44:19 -05:00
Talley Lambert
c9103e3dd8 ci: use reusable test workflow (#215)
* ci: try resuable

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

* remove x

* fix cov

* update

* update

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-11-06 18:36:36 -05:00
pre-commit-ci[bot]
570c261368 ci: [pre-commit.ci] autoupdate (#216)
updates:
- [github.com/pre-commit/pre-commit-hooks: v4.4.0 → v4.5.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.4.0...v4.5.0)
- [github.com/psf/black: 23.9.1 → 23.10.1](https://github.com/psf/black/compare/23.9.1...23.10.1)
- [github.com/astral-sh/ruff-pre-commit: v0.0.292 → v0.1.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.0.292...v0.1.4)
- [github.com/abravalheri/validate-pyproject: v0.14 → v0.15](https://github.com/abravalheri/validate-pyproject/compare/v0.14...v0.15)
- [github.com/pre-commit/mirrors-mypy: v1.5.1 → v1.6.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.5.1...v1.6.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-11-06 16:36:08 -05:00
Talley Lambert
bd6899133f feat: icon.name() (#213) 2023-10-23 11:20:59 -04:00
Talley Lambert
3efafd7aa8 fix: remove old dep (#212) 2023-10-10 16:52:08 -04:00
Talley Lambert
0fd25aa665 chore: changelog v0.6.1 2023-10-10 13:27:07 -04:00
Talley Lambert
a5740f0109 feat: add QIcon backed by iconify (#209)
* feat: add iconify

* update docs

* update docs

* rearrange

* update example

* update test deps

* importorskip

* Update src/superqt/iconify/__init__.py

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

* Update src/superqt/iconify/__init__.py

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

* merge

* change test

* bump dep

* change doc

* Update src/superqt/iconify/__init__.py

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

* pragma and bump version

---------

Co-authored-by: Grzegorz Bokota <bokota+github@gmail.com>
2023-10-09 09:20:42 -04:00
Talley Lambert
65a4a6e17c ci: test python 3.12 (#181)
* ci: test 3.12

* ci: try pyqt6

* update pyproject

* test: try macos
2023-10-08 14:15:49 -04:00
pre-commit-ci[bot]
6f74c6905e ci: [pre-commit.ci] autoupdate (#210)
* ci: [pre-commit.ci] autoupdate

updates:
- [github.com/psf/black: 23.7.0 → 23.9.1](https://github.com/psf/black/compare/23.7.0...23.9.1)
- [github.com/astral-sh/ruff-pre-commit: v0.0.287 → v0.0.292](https://github.com/astral-sh/ruff-pre-commit/compare/v0.0.287...v0.0.292)

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-10-03 18:35:12 +02:00
Talley Lambert
d8211493ab chore: v0.6.0 2023-09-25 13:17:08 -04:00
Grzegorz Bokota
1c80109e92 Add support for flag enum (#207)
* add support for flag enum

* fix flag selection

* more edge cases

* remove obsolete test and add explanation
2023-09-25 13:10:10 -04:00
Talley Lambert
0b984c21e8 fix: don't reuse text in qcollapsible (#204) 2023-09-24 15:07:23 -04:00
Grzegorz Bokota
50bff8ea61 add restart_timer argument to GenericSignalThrottler.flush (#206) 2023-09-23 18:29:41 -04:00
Grzegorz Bokota
830fe38fb9 Fix IntEnum for python 3.11 (#205) 2023-09-23 18:23:39 -04:00
Talley Lambert
409d19e5c2 fix: sliderMoved event (#200) 2023-09-12 13:54:59 -04:00
Talley Lambert
df2034d5dc docs: add cmap and QSearchableTreeWidget to docs (#199) 2023-09-12 13:47:15 -04:00
Talley Lambert
bace50fbb8 docs: update fonticon docs (#198) 2023-09-12 11:24:55 -04:00
Talley Lambert
66da7113e9 refactor: Labeled slider updates (#197)
* refactor: some slider updates

* fix tests

* finish

* finish

* import future
2023-09-12 08:08:23 -04:00
Talley Lambert
717b7e3d96 ci: add hatch matrix 2023-09-11 13:12:40 -04:00
dependabot[bot]
1e3cc27686 ci(dependabot): bump actions/checkout from 3 to 4 (#196)
Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-11 08:56:46 -04:00
Talley Lambert
658995a0b4 feat: add QColorComboBox for picking single colors (#194)
* feat: add QColorCombo

* more features

* test: add some tests

* fix: import the future

* more tests

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-09-11 08:56:37 -04:00
Talley Lambert
60f442789f Add colormap combobox and utils (#195)
* feat: add colormap combobox

* working on styles

* add comment

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

* progress on combo

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

* decent styles

* move stuff around

* adding tests

* add numpy for tests

* add cmap to tests

* fix type

* fix for pyqt

* remove topointf

* better  lineedit styles

* better add colormap

* increate linux atol

* cast to int

* more tests

* tests

* try fix

* try fix test

* again

* skip pyside

* test import

* fix lineedit

* add checkerboard for transparency

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-09-10 19:59:11 -04:00
pre-commit-ci[bot]
6993c88311 ci: [pre-commit.ci] autoupdate (#193)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.0.281 → v0.0.287](https://github.com/astral-sh/ruff-pre-commit/compare/v0.0.281...v0.0.287)
- [github.com/abravalheri/validate-pyproject: v0.13 → v0.14](https://github.com/abravalheri/validate-pyproject/compare/v0.13...v0.14)
- [github.com/pre-commit/mirrors-mypy: v1.4.1 → v1.5.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.4.1...v1.5.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-09-05 09:30:56 -04:00
Talley Lambert
8525efd98c chore: changelog v0.5.4 2023-08-31 09:56:01 -04:00
Talley Lambert
f676d7e171 fix: fix mysterious segfault (#192) 2023-08-31 09:54:39 -04:00
Talley Lambert
599dff7d02 chore: changelog v0.5.3 2023-08-21 17:14:13 -04:00
Talley Lambert
ed960f4994 feat: add error exceptions_as_dialog context manager to catch and show Exceptions (#191)
* feat: add error messagebox context

* typing

* Update src/superqt/utils/_errormsg_context.py

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

* add tests

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

* docs: add docs

* test button result

* format doc

* docs: update docs

* docs

* add dialog example

* pass flags

* skip mac ci pyside6

---------

Co-authored-by: Grzegorz Bokota <bokota+github@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-08-21 17:12:39 -04:00
Talley Lambert
7fcba7a485 fix: remove dupes/aliases in QEnumCombo (#190)
* fix: remove dupes/aliases in QEnumCombo

* test: add test
2023-08-20 09:52:14 -04:00
Talley Lambert
619daae13f chore: changelog v0.5.2 2023-08-18 15:00:16 -04:00
Grzegorz Bokota
462eeada93 fix: Add descriptive exception when fail to add instance to weakref dictionary (#189)
* add weakref information and test

* more information

* Update src/superqt/utils/_throttler.py

---------

Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
2023-08-18 14:20:11 -04:00
Grzegorz Bokota
8457563f49 Implement throttling of methods (#188)
* Implement throttling of methods

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

* fix line length

* chek if object instance is Qt object

* handle `self._name` being None or empty string

* fix throttling method

* handle staticmethod

* use descriptor

* try fix staticmethods

* move descriptor to a separate class

* move __set_name__

* simplify code and restore timer information

* inspire tlamber suggestions

* clean code

* add weakref dict as fallback

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-08-18 13:30:03 -04:00
Talley Lambert
504adf8bd0 chore: changelog v0.5.1 2023-08-17 11:37:37 -04:00
Talley Lambert
64dfb43d9e fix: fix callback of throttled/debounced decorated functions with mismatched args (#184)
* fix: fix throttled inspection

* build: change typing-ext deps

* fix: use inspect.signature

* use get_max_args

* fix: fix typing
2023-08-17 11:05:02 -04:00
Talley Lambert
1da26ce7c2 test: change wait pattern (#187)
* test: change wait pattern

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-08-17 10:51:53 -04:00
Talley Lambert
41ea4e8907 docs: document signals blocked (#186) 2023-08-17 09:40:06 -04:00
Talley Lambert
39b6a0596f fix: fix parameter inspection on ensure_thread decorators (alternate) (#185)
* fix: use different approach

* test: apply fixes

* back to signature

* fix get_max_args

* IMPORT THE FUTURE

* try or return None

* check for callable

* Update test_utils.py

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

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

---------

Co-authored-by: Grzegorz Bokota <bokota+github@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-08-17 09:20:11 -04:00
Talley Lambert
9ff01e757b build: misc updates to repo (#180) 2023-08-16 12:08:13 -04:00
Talley Lambert
dd9af3bfed chore: changelog v0.5.0 2023-08-06 09:03:14 -04:00
Talley Lambert
7b964beb89 feat: add stepType to largeInt spinbox (#179) 2023-08-06 08:57:22 -04:00
Talley Lambert
0407fdc4bd build: unpin pyside6.5 (#178) 2023-08-05 19:01:25 -04:00
Daniel Althviz Moré
9119336de5 Add QElidingLineEdit class for elidable QLineEdits (#154)
* Add QElidingLineEdit class for elidable QLineEdits

* Fix QElidingLineEdit tests on Linux and MacOS

* Testing

---------

Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
2023-08-03 11:08:12 -04:00
pre-commit-ci[bot]
6318675a8c ci: [pre-commit.ci] autoupdate (#173)
* ci: [pre-commit.ci] autoupdate

updates:
- [github.com/psf/black: 23.3.0 → 23.7.0](https://github.com/psf/black/compare/23.3.0...23.7.0)
- https://github.com/charliermarsh/ruff-pre-commithttps://github.com/astral-sh/ruff-pre-commit
- [github.com/astral-sh/ruff-pre-commit: v0.0.270 → v0.0.281](https://github.com/astral-sh/ruff-pre-commit/compare/v0.0.270...v0.0.281)
- [github.com/pre-commit/mirrors-mypy: v1.3.0 → v1.4.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.3.0...v1.4.1)

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

* fix: fix precommit

* typing

---------

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>
2023-08-03 09:30:36 -04:00
Talley Lambert
efa2757111 fix: focus events on QLabeledSlider (#175)
* fix: focus events on QLabeledSlider

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-07-10 11:11:28 -04:00
Grzegorz Bokota
402d237bc4 set parent of timer (#171) 2023-06-08 21:01:08 -04:00
pre-commit-ci[bot]
dc255bdeac ci: [pre-commit.ci] autoupdate (#170) 2023-06-06 07:40:44 -04:00
Talley Lambert
ae186df2ae build: pin pyside (#169) 2023-05-30 14:14:24 -04:00
Talley Lambert
0002d5ee37 fix: fix double slider label editing (#168) 2023-05-30 13:24:36 -04:00
Talley Lambert
f990fea78c test: add qtbot to test to fix windows segfault (#165)
* test: fix windows test

* test on windows

* try ubuntu

* remove ubuntu
2023-05-20 15:53:45 -04:00
Talley Lambert
1fb46854d4 test: fixing tests [wip] (#164) 2023-05-19 20:43:52 -04:00
pre-commit-ci[bot]
ca4a1ecb20 ci: [pre-commit.ci] autoupdate (#162)
* ci: [pre-commit.ci] autoupdate

updates:
- [github.com/charliermarsh/ruff-pre-commit: v0.0.260 → v0.0.263](https://github.com/charliermarsh/ruff-pre-commit/compare/v0.0.260...v0.0.263)
- [github.com/pre-commit/mirrors-mypy: v1.1.1 → v1.2.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.1.1...v1.2.0)

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-05-16 21:24:34 -04:00
Talley Lambert
c22b7d6f07 pin pyside6 (#160) 2023-04-20 19:16:43 -04:00
Andy Sweet
bb43cd7fad Searchable tree widget from a mapping (#158)
* Crude searchable tree widget with example

* Add logging and fix hiding bug

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

* Add factory method

* Use regular expression instead

* Reduce API

* Make setData public

* Clear filter when setting data

* Visible instead of hidden

* Show item when parent is visible

* Add docs

* Empty commit to [skip ci]

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

* Empty commit to [skip ci]

* Add test coverage

* Improve readability of tests

* Use python not json names

* Simplify example

* Some optimizations

* Clean up tests

* Fix visible siblings

* Modify test to cover visible sibling

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

* fix lint

* Update src/superqt/selection/_searchable_tree_widget.py

Co-authored-by: Talley Lambert <talley.lambert@gmail.com>

* Search by value too

* Remove optimizations

* Clean up formatting

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

---------

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>
2023-04-20 19:15:26 -04:00
pre-commit-ci[bot]
09c76a0bfa ci: [pre-commit.ci] autoupdate (#156)
* ci: [pre-commit.ci] autoupdate

updates:
- [github.com/psf/black: 23.1.0 → 23.3.0](https://github.com/psf/black/compare/23.1.0...23.3.0)
- [github.com/charliermarsh/ruff-pre-commit: v0.0.254 → v0.0.260](https://github.com/charliermarsh/ruff-pre-commit/compare/v0.0.254...v0.0.260)
- [github.com/abravalheri/validate-pyproject: v0.12.1 → v0.12.2](https://github.com/abravalheri/validate-pyproject/compare/v0.12.1...v0.12.2)

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

* fix: fix precommit

---------

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>
2023-04-06 19:51:39 -04:00
Talley Lambert
183899c4e7 update pre-commit (#151) 2023-03-27 12:57:58 -04:00
Kian-Meng Ang
a39b467563 Fix typos (#147)
* Fix typos and add codespell pre-commit hook

* Update .pre-commit-config.yaml

---------

Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
2023-03-12 00:01:42 -05:00
pre-commit-ci[bot]
6ce87d44a6 ci: [pre-commit.ci] autoupdate (#146)
* ci: [pre-commit.ci] autoupdate

updates:
- [github.com/charliermarsh/ruff-pre-commit: v0.0.149 → v0.0.161](https://github.com/charliermarsh/ruff-pre-commit/compare/v0.0.149...v0.0.161)

* fix: fix linting

* style: add docstyle

* style: formatting

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>
2022-12-06 12:02:27 -05:00
Talley Lambert
2cebc868a8 chore: changelog v0.4.1 2022-12-01 08:25:36 -05:00
Talley Lambert
6abd3a21a6 build: use hatch for build backend, and use ruff for linting (#139)
* style: ruff fixes

* style: no implicit optional

* keep mypy manual

* build: add fake setup.py

* build: use hatch

* update ruff

* update ruff settings

* chore: merge

* smaller sdist

* fix: fix qfont typing

* fix types again

* add toc permalink

* ignore setup.py from sdist
2022-12-01 08:21:03 -05:00
Pam
7b2d8bfb2d Change icon used in Collapsible widget (#140)
* add ability to change icon

* fix icon setting so it will load properly on start up

* remove check on icon length.  not necessary anymore

* fix test

* reduce duplicate code.  expose _COLLAPSED and _EXPANDED to user on creation of QCollapsible widget

* add ability to set icon with string or icon.

* add tests for adding, setting icons

* fix test.

* fix test for icons

* move file

* fix test

* remove hardcoded size.  Use font size

* add test docstring

* fix test.  chnage expanded/collapsed names

* remove unnecessary strings

* update example.  add getter functions.  remove lines.  change function name

* put default string in init.  add getter tests

* update test

* cleanup typing and fix set setCollapsedIcon

Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
2022-11-30 20:45:07 -05:00
Pam
ad2f05d908 Move QCollapsible toggle signal emit (#144)
* Emit toggle signal when animate is True.

* add flag to emit signal

* add docstring
2022-11-30 17:50:01 -05:00
Pam
3df7f49706 Add signal to QCollapsible (#142)
* add signal when toggle button is clicked

* emit signal when expand/collapse are called. emit bool. add to test.

* fix signal emission

Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
2022-11-27 20:43:05 -05:00
pre-commit-ci[bot]
e98936e8d8 [pre-commit.ci] pre-commit autoupdate (#141)
updates:
- [github.com/asottile/pyupgrade: v2.34.0 → v3.2.2](https://github.com/asottile/pyupgrade/compare/v2.34.0...v3.2.2)
- [github.com/psf/black: 22.3.0 → 22.10.0](https://github.com/psf/black/compare/22.3.0...22.10.0)
- [github.com/PyCQA/flake8: 4.0.1 → 5.0.4](https://github.com/PyCQA/flake8/compare/4.0.1...5.0.4)
- [github.com/asottile/pyupgrade: v3.2.0 → v3.2.2](https://github.com/asottile/pyupgrade/compare/v3.2.0...v3.2.2)
- [github.com/pre-commit/mirrors-mypy: v0.982 → v0.991](https://github.com/pre-commit/mirrors-mypy/compare/v0.982...v0.991)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-11-24 13:34:05 -05:00
Talley Lambert
532d3bf89c chore: rename napari org to pyapp-kit (#137) 2022-11-11 08:39:22 -05:00
Talley Lambert
16b383e783 chore: changelog v0.4.0 (#136) 2022-11-09 06:58:20 -05:00
dependabot[bot]
38d15d1b3b ci(dependabot): bump codecov/codecov-action from 2 to 3 (#134)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 2 to 3.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-11-08 20:33:45 -05:00
dependabot[bot]
8f09c38074 ci(dependabot): bump actions/upload-artifact from 2 to 3 (#135)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 2 to 3.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v2...v3)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-11-08 20:33:33 -05:00
Talley Lambert
3c8b5bcf98 refactor: update pyproject and ci, add py3.11 test (#132)
* refactor: reorg repo

* fix: include pyi in manifest

* remove extra

* changes

* why no trigger

* fix needs

* include python 3.11

* remove cache

* add back license

* bump versions

* fix py37

* fix napari test

* remove timeout

* fix py37 test

* test: fix py311 tests

* change windows test
2022-11-08 20:32:47 -05:00
Talley Lambert
3ece7a27b1 build: unpin pyside6 (#133)
* build: unpin pyside6

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-11-08 18:54:00 -05:00
Talley Lambert
e0bb2ea871 Revert "fix extras"
This reverts commit 78997fe155.
2022-11-01 17:01:13 -04:00
Talley Lambert
78997fe155 fix extras 2022-11-01 16:39:08 -04:00
Talley Lambert
021f164419 fix: fix quantity set value and add test (#131)
* fix: fix quantity set value and add test

* pin pyside6

* fix: try fix screenshot
2022-11-01 14:46:29 -04:00
pre-commit-ci[bot]
7f50e69e28 [pre-commit.ci] pre-commit autoupdate (#130)
* [pre-commit.ci] pre-commit autoupdate

updates:
- [github.com/asottile/setup-cfg-fmt: v2.0.0 → v2.2.0](https://github.com/asottile/setup-cfg-fmt/compare/v2.0.0...v2.2.0)
- [github.com/PyCQA/autoflake: v1.7.1 → v1.7.7](https://github.com/PyCQA/autoflake/compare/v1.7.1...v1.7.7)
- [github.com/asottile/pyupgrade: v3.0.0 → v3.2.0](https://github.com/asottile/pyupgrade/compare/v3.0.0...v3.2.0)

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-10-31 16:24:53 -04:00
pre-commit-ci[bot]
2c747c5a4f [pre-commit.ci] pre-commit autoupdate (#127)
updates:
- [github.com/PyCQA/autoflake: v1.6.1 → v1.7.1](https://github.com/PyCQA/autoflake/compare/v1.6.1...v1.7.1)
- [github.com/asottile/pyupgrade: v2.38.2 → v3.0.0](https://github.com/asottile/pyupgrade/compare/v2.38.2...v3.0.0)
- [github.com/psf/black: 22.8.0 → 22.10.0](https://github.com/psf/black/compare/22.8.0...22.10.0)
- [github.com/pre-commit/mirrors-mypy: v0.981 → v0.982](https://github.com/pre-commit/mirrors-mypy/compare/v0.981...v0.982)

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>
2022-10-17 10:13:59 -04:00
Talley Lambert
b79c8e95b7 chore: changelog v0.3.8 2022-10-10 15:37:16 -04:00
Kira Evans
b393c6d039 fix: allow submodule imports (#128)
* fix: allow submodule imports

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-10-10 15:35:53 -04:00
124 changed files with 6781 additions and 1659 deletions

View File

@@ -2,7 +2,7 @@
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
labels: 'bug'
assignees: ''
---

7
.github/ISSUE_TEMPLATE/feature.md vendored Normal file
View File

@@ -0,0 +1,7 @@
---
name: Feature request
about: Request a new feature
title: ''
labels: 'enhancement'
assignees: ''
---

10
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,10 @@
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix: "ci(dependabot):"

View File

@@ -1,216 +1,128 @@
name: Test
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches:
- master
- main
tags:
- "v*" # Push events to matching v*, i.e. v1.0, v20.15.10
branches: [main]
tags: [v*]
pull_request:
branches:
- master
- main
workflow_dispatch:
schedule:
- cron: "0 0 * * 0" # run weekly
jobs:
test:
name: ${{ matrix.platform }} py${{ matrix.python-version }} ${{ matrix.backend }}
runs-on: ${{ matrix.platform }}
timeout-minutes: 10
name: Test
uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@v2
with:
os: ${{ matrix.platform }}
python-version: ${{ matrix.python-version }}
qt: ${{ matrix.backend }}
pip-install-pre-release: ${{ github.event_name == 'schedule' }}
coverage-upload: artifact
strategy:
fail-fast: false
matrix:
platform: [ubuntu-latest, windows-latest, macos-latest]
python-version: [3.7, 3.8, 3.9]
backend: [pyqt5, pyside2]
platform: [ubuntu-latest, windows-latest, macos-13]
python-version: ["3.9", "3.10", "3.11", "3.12"]
backend: [pyqt5, pyside2, pyqt6]
exclude:
# Abort (core dumped) on linux pyqt6, unknown reason
- platform: ubuntu-latest
backend: pyqt6
# lack of wheels for pyside2/py3.11
- python-version: "3.11"
backend: pyside2
- python-version: "3.12"
backend: pyside2
- python-version: "3.12"
backend: pyqt5
include:
# pyqt6 and pyside6 on latest platforms
- python-version: 3.9
platform: ubuntu-latest
backend: pyside6
screenshot: 1
- python-version: 3.9
- python-version: "3.13"
platform: windows-latest
backend: pyside6
screenshot: 1
- python-version: 3.9
platform: macos-11.0
backend: pyside6
screenshot: 1
- python-version: 3.9
backend: "pyqt6"
- python-version: "3.13"
platform: ubuntu-latest
backend: pyqt6
- python-version: 3.9
backend: "pyqt6"
- python-version: "3.10"
platform: macos-latest
backend: "'pyside6<6.8'"
- python-version: "3.11"
platform: macos-latest
backend: "'pyside6<6.8'"
- python-version: "3.10"
platform: windows-latest
backend: pyqt6
- python-version: 3.9
platform: macos-11.0
backend: pyqt6
# py3.10
- python-version: "3.10"
platform: ubuntu-latest
backend: pyside6
- python-version: "3.10"
platform: ubuntu-latest
backend: pyqt5
- python-version: "3.10"
platform: ubuntu-latest
backend: pyqt6
# big sur, 3.9
- python-version: 3.9
platform: macos-11.0
backend: pyside2
- python-version: 3.9
platform: macos-11.0
backend: pyqt5
# legacy OS
- python-version: 3.8
platform: ubuntu-18.04
backend: pyside2
backend: "'pyside6<6.8'"
- python-version: "3.12"
platform: windows-latest
backend: "'pyside6<6.8'"
# legacy Qt
- python-version: 3.7
- python-version: 3.9
platform: ubuntu-latest
backend: pyqt512
- python-version: 3.7
backend: "pyqt5==5.12.*"
- python-version: 3.9
platform: ubuntu-latest
backend: pyqt513
- python-version: 3.7
backend: "pyqt5==5.13.*"
- python-version: 3.9
platform: ubuntu-latest
backend: pyqt514
backend: "pyqt5==5.14.*"
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- uses: tlambert03/setup-qt-libs@v1
- name: Linux opengl
if: runner.os == 'Linux' && ( matrix.backend == 'pyside6' || matrix.backend == 'pyqt6' )
run: sudo apt-get install -y libopengl0 libegl1-mesa libxcb-xinput0
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install setuptools tox tox-gh-actions
- name: Test with tox
uses: GabrielBB/xvfb-action@v1
timeout-minutes: 3
with:
run: python -m tox
env:
PLATFORM: ${{ matrix.platform }}
BACKEND: ${{ matrix.backend }}
- name: Coverage
uses: codecov/codecov-action@v1
- name: Install for screenshots
if: matrix.screenshot
run: pip install . ${{ matrix.backend }}
- name: Screenshots (Linux)
if: runner.os == 'Linux' && matrix.screenshot
uses: GabrielBB/xvfb-action@v1
with:
run: python examples/demo_widget.py -snap
- name: Screenshots (macOS/Win)
if: runner.os != 'Linux' && matrix.screenshot
run: python examples/demo_widget.py -snap
- uses: actions/upload-artifact@v2
if: matrix.screenshot
with:
name: screenshots ${{ runner.os }}
path: screenshots
test_old_qtpy:
name: qtpy minreq
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: tlambert03/setup-qt-libs@v1
- uses: actions/setup-python@v2
with:
python-version: '3.8'
- name: install
run: |
python -m pip install -U pip
python -m pip install -e .[testing,pyqt5]
python -m pip install qtpy==1.1.0 typing-extensions==3.10.0.0
- name: Test napari magicgui
uses: GabrielBB/xvfb-action@v1
with:
run: python -m pytest --color=yes
test-qt-minreqs:
uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@v2
with:
python-version: "3.9"
qt: pyqt5
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
upload_coverage:
if: always()
needs: [test, test-qt-minreqs]
uses: pyapp-kit/workflows/.github/workflows/upload-coverage.yml@v2
secrets: inherit
test_napari:
name: napari tests
uses: pyapp-kit/workflows/.github/workflows/test-dependents.yml@v2
with:
dependency-repo: napari/napari
dependency-ref: ${{ matrix.napari-version }}
dependency-extras: "testing"
qt: ${{ matrix.qt }}
pytest-args: '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: [ "" ]
qt: [ "pyqt5", "pyside2" ]
check-manifest:
name: Check Manifest
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
path: superqt
- uses: actions/checkout@v3
with:
repository: napari/napari
path: napari-repo
fetch-depth: 2
- uses: tlambert03/setup-qt-libs@v1
- uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: install
run: |
python -m pip install -U pip
python -m pip install ./superqt
python -m pip install ./napari-repo[testing,pyqt5]
- name: Test napari
uses: GabrielBB/xvfb-action@v1
with:
working-directory: napari-repo
run: python -m pytest --color=yes napari/_qt
check_manifest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: "3.x"
- name: Check manifest
run: |
python -m pip install --upgrade pip
pip install check-manifest
check-manifest
- uses: actions/checkout@v4
- run: pipx run check-manifest
deploy:
# this will run when you have tagged a commit, starting with "v*"
# and requires that you have put your twine API key in your
# github secrets (see readme for details)
needs: [test, check_manifest]
if: ${{ github.repository == 'napari/superqt' && contains(github.ref, 'tags') }}
needs: [test, check-manifest]
if: ${{ github.repository == 'pyapp-kit/superqt' && contains(github.ref, 'tags') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v2
uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Install dependencies
@@ -227,6 +139,6 @@ jobs:
twine check dist/*
twine upload dist/*
- uses: softprops/action-gh-release@v1
- uses: softprops/action-gh-release@v2
with:
generate_release_notes: true

View File

@@ -1,7 +1,7 @@
# run this with:
# export CHANGELOG_GITHUB_TOKEN=......
# github_changelog_generator --future-release vX.Y.Z
user=napari
user=pyapp-kit
project=superqt
issues=false
since-tag=v0.2.0

1
.gitignore vendored
View File

@@ -45,7 +45,6 @@ nosetests.xml
coverage.xml
*,cover
.hypothesis/
.napari_cache
# Translations
*.mo

View File

@@ -1,41 +1,27 @@
ci:
autoupdate_schedule: monthly
autofix_commit_msg: "style: [pre-commit.ci] auto fixes [...]"
autoupdate_commit_msg: "ci: [pre-commit.ci] autoupdate"
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.3
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/asottile/setup-cfg-fmt
rev: v2.0.0
- id: ruff
args: [--fix, --unsafe-fixes]
- id: ruff-format
- repo: https://github.com/abravalheri/validate-pyproject
rev: v0.24.1
hooks:
- id: setup-cfg-fmt
args: ["--include-version-classifiers"]
- repo: https://github.com/PyCQA/flake8
rev: 5.0.4
hooks:
- id: flake8
additional_dependencies: [flake8-typing-imports==1.7.0]
exclude: examples
- repo: https://github.com/PyCQA/autoflake
rev: v1.6.1
hooks:
- id: autoflake
args: ["--in-place", "--remove-all-unused-imports"]
- repo: https://github.com/PyCQA/isort
rev: 5.10.1
hooks:
- id: isort
- repo: https://github.com/asottile/pyupgrade
rev: v2.38.2
hooks:
- id: pyupgrade
args: [--py37-plus, --keep-runtime-typing]
- repo: https://github.com/psf/black
rev: 22.8.0
hooks:
- id: black
- id: validate-pyproject
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v0.981
rev: v1.17.0
hooks:
- id: mypy
exclude: examples
stages: [manual]
exclude: tests|examples
additional_dependencies:
- types-Pygments
stages:
- manual

View File

@@ -1,224 +1,577 @@
# Changelog
## [0.3.7](https://github.com/napari/superqt/tree/0.3.7) (2022-10-10)
## [v0.7.5](https://github.com/pyapp-kit/superqt/tree/v0.7.5) (2025-06-18)
[Full Changelog](https://github.com/napari/superqt/compare/v0.3.6...0.3.7)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.7.4...v0.7.5)
**Implemented enhancements:**
- feat: add Quantity widget \(using pint\) [\#126](https://github.com/napari/superqt/pull/126) ([tlambert03](https://github.com/tlambert03))
- 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.3.6](https://github.com/napari/superqt/tree/v0.3.6) (2022-10-05)
## [v0.7.4](https://github.com/pyapp-kit/superqt/tree/v0.7.4) (2025-06-10)
[Full Changelog](https://github.com/napari/superqt/compare/v0.3.6rc0...v0.3.6)
[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:**
- minor fix to readme [\#125](https://github.com/napari/superqt/pull/125) ([tlambert03](https://github.com/tlambert03))
- Docs [\#124](https://github.com/napari/superqt/pull/124) ([tlambert03](https://github.com/tlambert03))
## [v0.3.6rc0](https://github.com/napari/superqt/tree/v0.3.6rc0) (2022-10-03)
[Full Changelog](https://github.com/napari/superqt/compare/v0.3.5...v0.3.6rc0)
**Implemented enhancements:**
- feat: add editing finished signal to LabeledSliders [\#122](https://github.com/napari/superqt/pull/122) ([tlambert03](https://github.com/tlambert03))
**Fixed bugs:**
- fix: fix missing labels after setValue [\#123](https://github.com/napari/superqt/pull/123) ([tlambert03](https://github.com/tlambert03))
- fix: Fix TypeError on slider rangeChanged signal [\#121](https://github.com/napari/superqt/pull/121) ([tlambert03](https://github.com/tlambert03))
- Simple workaround for pyside 6 [\#119](https://github.com/napari/superqt/pull/119) ([Czaki](https://github.com/Czaki))
- fix: Offer patch for \(unstyled\) QSliders on macos 12 and Qt \<6 [\#117](https://github.com/napari/superqt/pull/117) ([tlambert03](https://github.com/tlambert03))
## [v0.3.5](https://github.com/napari/superqt/tree/v0.3.5) (2022-08-17)
[Full Changelog](https://github.com/napari/superqt/compare/v0.3.4...v0.3.5)
**Fixed bugs:**
- fix range slider drag crash on PyQt6 [\#108](https://github.com/napari/superqt/pull/108) ([sfhbarnett](https://github.com/sfhbarnett))
- Fix float value error in pyqt configuration [\#106](https://github.com/napari/superqt/pull/106) ([mstabrin](https://github.com/mstabrin))
**Merged pull requests:**
- chore: changelog v0.3.5 [\#110](https://github.com/napari/superqt/pull/110) ([tlambert03](https://github.com/tlambert03))
## [v0.3.4](https://github.com/napari/superqt/tree/v0.3.4) (2022-07-24)
[Full Changelog](https://github.com/napari/superqt/compare/v0.3.3...v0.3.4)
**Fixed bugs:**
- fix: relax runtime typing extensions requirement [\#101](https://github.com/napari/superqt/pull/101) ([tlambert03](https://github.com/tlambert03))
- fix: catch qpixmap deprecation [\#99](https://github.com/napari/superqt/pull/99) ([tlambert03](https://github.com/tlambert03))
## [v0.3.3](https://github.com/napari/superqt/tree/v0.3.3) (2022-07-10)
[Full Changelog](https://github.com/napari/superqt/compare/v0.3.2...v0.3.3)
**Implemented enhancements:**
- Add code syntax highlight utils [\#88](https://github.com/napari/superqt/pull/88) ([Czaki](https://github.com/Czaki))
**Fixed bugs:**
- fix: fix deprecation warning on fonticon plugin discovery on python 3.10 [\#95](https://github.com/napari/superqt/pull/95) ([tlambert03](https://github.com/tlambert03))
## [v0.3.2](https://github.com/napari/superqt/tree/v0.3.2) (2022-05-03)
[Full Changelog](https://github.com/napari/superqt/compare/v0.3.1...v0.3.2)
**Implemented enhancements:**
- Add QSearchableListWidget and QSearchableComboBox widgets [\#80](https://github.com/napari/superqt/pull/80) ([Czaki](https://github.com/Czaki))
**Fixed bugs:**
- Fix crazy animation loop on Qcollapsible [\#84](https://github.com/napari/superqt/pull/84) ([tlambert03](https://github.com/tlambert03))
- Reorder label update signal [\#83](https://github.com/napari/superqt/pull/83) ([tlambert03](https://github.com/tlambert03))
- Fix height of expanded QCollapsible when child changes size [\#72](https://github.com/napari/superqt/pull/72) ([tlambert03](https://github.com/tlambert03))
- docs: document QToggleSwitch [\#286](https://github.com/pyapp-kit/superqt/pull/286) ([tlambert03](https://github.com/tlambert03))
**Tests & CI:**
- Fix deprecation warnings in tests [\#82](https://github.com/napari/superqt/pull/82) ([tlambert03](https://github.com/tlambert03))
- Fix napari test [\#295](https://github.com/pyapp-kit/superqt/pull/295) ([brisvag](https://github.com/brisvag))
**Merged pull requests:**
## [v0.7.3](https://github.com/pyapp-kit/superqt/tree/v0.7.3) (2025-03-28)
- Add changelog for v0.3.2 [\#86](https://github.com/napari/superqt/pull/86) ([tlambert03](https://github.com/tlambert03))
## [v0.3.1](https://github.com/napari/superqt/tree/v0.3.1) (2022-03-02)
[Full Changelog](https://github.com/napari/superqt/compare/v0.3.0...v0.3.1)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.7.2...v0.7.3)
**Implemented enhancements:**
- Add `signals_blocked` util [\#69](https://github.com/napari/superqt/pull/69) ([tlambert03](https://github.com/tlambert03))
- 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))
**Fixed bugs:**
## [v0.7.2](https://github.com/pyapp-kit/superqt/tree/v0.7.2) (2025-03-17)
- put SignalInstance in TYPE\_CHECKING clause, check min requirements [\#70](https://github.com/napari/superqt/pull/70) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- Add changelog for v0.3.1 [\#71](https://github.com/napari/superqt/pull/71) ([tlambert03](https://github.com/tlambert03))
## [v0.3.0](https://github.com/napari/superqt/tree/v0.3.0) (2022-02-16)
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.5-1...v0.3.0)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.7.1...v0.7.2)
**Implemented enhancements:**
- Qthrottler and debouncer [\#62](https://github.com/napari/superqt/pull/62) ([tlambert03](https://github.com/tlambert03))
- add edgeLabelMode option to QLabeledSlider [\#59](https://github.com/napari/superqt/pull/59) ([tlambert03](https://github.com/tlambert03))
- 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 nested threadworker not starting [\#63](https://github.com/napari/superqt/pull/63) ([tlambert03](https://github.com/tlambert03))
- Add missing signals on proxy sliders [\#54](https://github.com/napari/superqt/pull/54) ([tlambert03](https://github.com/tlambert03))
- Ugly but functional workaround for pyside6.2.1 breakages [\#51](https://github.com/napari/superqt/pull/51) ([tlambert03](https://github.com/tlambert03))
- fix: better warning for download error [\#266](https://github.com/pyapp-kit/superqt/pull/266) ([tlambert03](https://github.com/tlambert03))
**Tests & CI:**
**Merged pull requests:**
- add napari test to CI [\#67](https://github.com/napari/superqt/pull/67) ([tlambert03](https://github.com/tlambert03))
- add gh-release action [\#65](https://github.com/napari/superqt/pull/65) ([tlambert03](https://github.com/tlambert03))
- fix xvfb tests [\#61](https://github.com/napari/superqt/pull/61) ([tlambert03](https://github.com/tlambert03))
- 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:**
- Use qtpy, deprecate superqt.qtcompat, drop support for Qt \<5.12 [\#39](https://github.com/napari/superqt/pull/39) ([tlambert03](https://github.com/tlambert03))
- 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:**
- Add changelog for v0.3.0 [\#68](https://github.com/napari/superqt/pull/68) ([tlambert03](https://github.com/tlambert03))
- 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.2.5-1](https://github.com/napari/superqt/tree/v0.2.5-1) (2021-11-23)
## [v0.6.8](https://github.com/pyapp-kit/superqt/tree/v0.6.8) (2024-06-15)
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.5...v0.2.5-1)
**Merged pull requests:**
- typing-extensions version pinning [\#46](https://github.com/napari/superqt/pull/46) ([AhmetCanSolak](https://github.com/AhmetCanSolak))
## [v0.2.5](https://github.com/napari/superqt/tree/v0.2.5) (2021-11-22)
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.4...v0.2.5)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.7...v0.6.8)
**Implemented enhancements:**
- add support for python 3.10 [\#42](https://github.com/napari/superqt/pull/42) ([tlambert03](https://github.com/tlambert03))
- QCollapsible for Collapsible Section Control [\#37](https://github.com/napari/superqt/pull/37) ([MosGeo](https://github.com/MosGeo))
- Threadworker [\#31](https://github.com/napari/superqt/pull/31) ([tlambert03](https://github.com/tlambert03))
- Add font icons [\#24](https://github.com/napari/superqt/pull/24) ([tlambert03](https://github.com/tlambert03))
- feat: graceful offline fallback for qiconify [\#251](https://github.com/pyapp-kit/superqt/pull/251) ([tlambert03](https://github.com/tlambert03))
## [v0.6.7](https://github.com/pyapp-kit/superqt/tree/v0.6.7) (2024-06-07)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.6...v0.6.7)
**Fixed bugs:**
- Fix some small linting issues. [\#41](https://github.com/napari/superqt/pull/41) ([tlambert03](https://github.com/tlambert03))
- Use functools.wraps insterad of \_\_wraped\_\_ and manual proxing \_\_name\_\_ [\#29](https://github.com/napari/superqt/pull/29) ([Czaki](https://github.com/Czaki))
- Propagate function name in `ensure_main_thread` and `ensure_object_thread` [\#28](https://github.com/napari/superqt/pull/28) ([Czaki](https://github.com/Czaki))
- fix: prevent qthrottled and qdebounced from holding strong references with bound methods [\#247](https://github.com/pyapp-kit/superqt/pull/247) ([tlambert03](https://github.com/tlambert03))
**Tests & CI:**
**Merged pull requests:**
- reskip test\_object\_thread\_return on ci [\#43](https://github.com/napari/superqt/pull/43) ([tlambert03](https://github.com/tlambert03))
- Prevent computing full document content highlight per block and only compute current block content for performance [\#246](https://github.com/pyapp-kit/superqt/pull/246) ([dalthviz](https://github.com/dalthviz))
## [v0.6.6](https://github.com/pyapp-kit/superqt/tree/v0.6.6) (2024-05-12)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.5...v0.6.6)
**Refactors:**
- refactoring qtcompat [\#34](https://github.com/napari/superqt/pull/34) ([tlambert03](https://github.com/tlambert03))
- perf: improve paint time for QColormapLineEdit [\#245](https://github.com/pyapp-kit/superqt/pull/245) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
## [v0.6.5](https://github.com/pyapp-kit/superqt/tree/v0.6.5) (2024-05-06)
- Fix-manifest, move font tests [\#44](https://github.com/napari/superqt/pull/44) ([tlambert03](https://github.com/tlambert03))
- update deploy [\#33](https://github.com/napari/superqt/pull/33) ([tlambert03](https://github.com/tlambert03))
- move to src layout [\#32](https://github.com/napari/superqt/pull/32) ([tlambert03](https://github.com/tlambert03))
## [v0.2.4](https://github.com/napari/superqt/tree/v0.2.4) (2021-09-13)
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.3...v0.2.4)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.4...v0.6.5)
**Implemented enhancements:**
- Add type stubs for ensure\_thread decorator [\#23](https://github.com/napari/superqt/pull/23) ([tlambert03](https://github.com/tlambert03))
- Add `ensure_main_tread` and `ensure_object_thread` [\#22](https://github.com/napari/superqt/pull/22) ([Czaki](https://github.com/Czaki))
- Add QMessageHandler context manager [\#21](https://github.com/napari/superqt/pull/21) ([tlambert03](https://github.com/tlambert03))
- fix: fix a number of issues with Labeled and Range Sliders, add LabelsOnHandle mode. [\#242](https://github.com/pyapp-kit/superqt/pull/242) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
**Tests & CI:**
- add changelog for 0.2.4 [\#25](https://github.com/napari/superqt/pull/25) ([tlambert03](https://github.com/tlambert03))
- ci: trying to fix tests on various platforms [\#243](https://github.com/pyapp-kit/superqt/pull/243) ([tlambert03](https://github.com/tlambert03))
## [v0.2.3](https://github.com/napari/superqt/tree/v0.2.3) (2021-08-25)
## [v0.6.4](https://github.com/pyapp-kit/superqt/tree/v0.6.4) (2024-04-25)
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.2...v0.2.3)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.3...v0.6.4)
**Fixed bugs:**
- Fix warnings on eliding label for 5.12, test more qt versions [\#19](https://github.com/napari/superqt/pull/19) ([tlambert03](https://github.com/tlambert03))
- fix: fix inverted appearance [\#240](https://github.com/pyapp-kit/superqt/pull/240) ([tlambert03](https://github.com/tlambert03))
## [v0.2.2](https://github.com/napari/superqt/tree/v0.2.2) (2021-08-17)
**Merged pull requests:**
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.1...v0.2.2)
- ci: \[pre-commit.ci\] autoupdate [\#238](https://github.com/pyapp-kit/superqt/pull/238) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci))
## [v0.6.3](https://github.com/pyapp-kit/superqt/tree/v0.6.3) (2024-03-27)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.2...v0.6.3)
**Fixed bugs:**
- fix: fix sliderReleased, sliderPressed signals, and setTracking [\#237](https://github.com/pyapp-kit/superqt/pull/237) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- ci\(dependabot\): bump softprops/action-gh-release from 1 to 2 [\#236](https://github.com/pyapp-kit/superqt/pull/236) ([dependabot[bot]](https://github.com/apps/dependabot))
## [v0.6.2](https://github.com/pyapp-kit/superqt/tree/v0.6.2) (2024-03-06)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.1...v0.6.2)
**Implemented enhancements:**
- Add QElidingLabel [\#16](https://github.com/napari/superqt/pull/16) ([tlambert03](https://github.com/tlambert03))
- Enum ComboBox implementation [\#13](https://github.com/napari/superqt/pull/13) ([Czaki](https://github.com/Czaki))
- feat: make toggle button public in QCollapsible [\#232](https://github.com/pyapp-kit/superqt/pull/232) ([tlambert03](https://github.com/tlambert03))
- feat: add addKey method to QIconifyIcon [\#218](https://github.com/pyapp-kit/superqt/pull/218) ([tlambert03](https://github.com/tlambert03))
- feat: Add QIconifyIcon.name\(\) method [\#213](https://github.com/pyapp-kit/superqt/pull/213) ([tlambert03](https://github.com/tlambert03))
**Fixed bugs:**
- fix: don't use AbstractContextManager for exceptions\_as\_dialog [\#234](https://github.com/pyapp-kit/superqt/pull/234) ([tlambert03](https://github.com/tlambert03))
- fix: Check min max versus current value [\#221](https://github.com/pyapp-kit/superqt/pull/221) ([psobolewskiPhD](https://github.com/psobolewskiPhD))
- fix: better default size policy for qcollapsible [\#217](https://github.com/pyapp-kit/superqt/pull/217) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- style: use ruff format instead of black, update pre-commit, restrict pyside6 tests [\#235](https://github.com/pyapp-kit/superqt/pull/235) ([tlambert03](https://github.com/tlambert03))
- ci: \[pre-commit.ci\] autoupdate [\#228](https://github.com/pyapp-kit/superqt/pull/228) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci))
- ci\(dependabot\): bump actions/setup-python from 4 to 5 [\#225](https://github.com/pyapp-kit/superqt/pull/225) ([dependabot[bot]](https://github.com/apps/dependabot))
- ci: \[pre-commit.ci\] autoupdate [\#223](https://github.com/pyapp-kit/superqt/pull/223) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci))
- ci: \[pre-commit.ci\] autoupdate [\#216](https://github.com/pyapp-kit/superqt/pull/216) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci))
- ci: use reusable test workflow [\#215](https://github.com/pyapp-kit/superqt/pull/215) ([tlambert03](https://github.com/tlambert03))
- build: remove packaging dep [\#212](https://github.com/pyapp-kit/superqt/pull/212) ([tlambert03](https://github.com/tlambert03))
## [v0.6.1](https://github.com/pyapp-kit/superqt/tree/v0.6.1) (2023-10-10)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.0...v0.6.1)
**Implemented enhancements:**
- feat: add QIcon backed by iconify [\#209](https://github.com/pyapp-kit/superqt/pull/209) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- ci: test python 3.12 [\#181](https://github.com/pyapp-kit/superqt/pull/181) ([tlambert03](https://github.com/tlambert03))
## [v0.6.0](https://github.com/pyapp-kit/superqt/tree/v0.6.0) (2023-09-25)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.4...v0.6.0)
**Implemented enhancements:**
- feat: add support for flag enum [\#207](https://github.com/pyapp-kit/superqt/pull/207) ([Czaki](https://github.com/Czaki))
- Add restart\_timer argument to GenericSignalThrottler.flush [\#206](https://github.com/pyapp-kit/superqt/pull/206) ([Czaki](https://github.com/Czaki))
- Add colormap combobox and utils [\#195](https://github.com/pyapp-kit/superqt/pull/195) ([tlambert03](https://github.com/tlambert03))
- feat: add QColorComboBox for picking single colors [\#194](https://github.com/pyapp-kit/superqt/pull/194) ([tlambert03](https://github.com/tlambert03))
**Fixed bugs:**
- Fix IntEnum for python 3.11 [\#205](https://github.com/pyapp-kit/superqt/pull/205) ([Czaki](https://github.com/Czaki))
- fix: don't reuse text in qcollapsible [\#204](https://github.com/pyapp-kit/superqt/pull/204) ([tlambert03](https://github.com/tlambert03))
- fix: sliderMoved event on RangeSliders [\#200](https://github.com/pyapp-kit/superqt/pull/200) ([tlambert03](https://github.com/tlambert03))
**Documentation updates:**
- fix broken link [\#18](https://github.com/napari/superqt/pull/18) ([haesleinhuepf](https://github.com/haesleinhuepf))
- docs: add colormap utils and QSearchableTreeWidget to docs [\#199](https://github.com/pyapp-kit/superqt/pull/199) ([tlambert03](https://github.com/tlambert03))
- docs: update fonticon docs [\#198](https://github.com/pyapp-kit/superqt/pull/198) ([tlambert03](https://github.com/tlambert03))
## [v0.2.1](https://github.com/napari/superqt/tree/v0.2.1) (2021-07-10)
**Tests & CI:**
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.0rc0...v0.2.1)
- ci: \[pre-commit.ci\] autoupdate [\#193](https://github.com/pyapp-kit/superqt/pull/193) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci))
**Refactors:**
- refactor: Labeled slider updates [\#197](https://github.com/pyapp-kit/superqt/pull/197) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- ci\(dependabot\): bump actions/checkout from 3 to 4 [\#196](https://github.com/pyapp-kit/superqt/pull/196) ([dependabot[bot]](https://github.com/apps/dependabot))
## [v0.5.4](https://github.com/pyapp-kit/superqt/tree/v0.5.4) (2023-08-31)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.3...v0.5.4)
**Fixed bugs:**
- Fix QLabeledRangeSlider API \(fix slider proxy\) [\#10](https://github.com/napari/superqt/pull/10) ([tlambert03](https://github.com/tlambert03))
- Fix range slider with negative min range [\#9](https://github.com/napari/superqt/pull/9) ([tlambert03](https://github.com/tlambert03))
- fix: fix mysterious segfault [\#192](https://github.com/pyapp-kit/superqt/pull/192) ([tlambert03](https://github.com/tlambert03))
## [v0.2.0rc0](https://github.com/napari/superqt/tree/v0.2.0rc0) (2021-06-26)
## [v0.5.3](https://github.com/pyapp-kit/superqt/tree/v0.5.3) (2023-08-21)
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.0...v0.2.0rc0)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.2...v0.5.3)
**Implemented enhancements:**
- feat: add error `exceptions_as_dialog` context manager to catch and show Exceptions [\#191](https://github.com/pyapp-kit/superqt/pull/191) ([tlambert03](https://github.com/tlambert03))
**Fixed bugs:**
- fix: remove dupes/aliases in QEnumCombo [\#190](https://github.com/pyapp-kit/superqt/pull/190) ([tlambert03](https://github.com/tlambert03))
## [v0.5.2](https://github.com/pyapp-kit/superqt/tree/v0.5.2) (2023-08-18)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.1...v0.5.2)
**Implemented enhancements:**
- feat: allow throttler/debouncer as method decorator [\#188](https://github.com/pyapp-kit/superqt/pull/188) ([Czaki](https://github.com/Czaki))
**Fixed bugs:**
- fix: Add descriptive exception when fail to add instance to weakref dictionary [\#189](https://github.com/pyapp-kit/superqt/pull/189) ([Czaki](https://github.com/Czaki))
## [v0.5.1](https://github.com/pyapp-kit/superqt/tree/v0.5.1) (2023-08-17)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.0...v0.5.1)
**Fixed bugs:**
- fix: fix parameter inspection on ensure\_thread decorators \(alternate\) [\#185](https://github.com/pyapp-kit/superqt/pull/185) ([tlambert03](https://github.com/tlambert03))
- fix: fix callback of throttled/debounced decorated functions with mismatched args [\#184](https://github.com/pyapp-kit/superqt/pull/184) ([tlambert03](https://github.com/tlambert03))
**Documentation updates:**
- docs: document signals blocked [\#186](https://github.com/pyapp-kit/superqt/pull/186) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- test: change wait pattern [\#187](https://github.com/pyapp-kit/superqt/pull/187) ([tlambert03](https://github.com/tlambert03))
- build: drop python3.7, misc updates to repo [\#180](https://github.com/pyapp-kit/superqt/pull/180) ([tlambert03](https://github.com/tlambert03))
## [v0.5.0](https://github.com/pyapp-kit/superqt/tree/v0.5.0) (2023-08-06)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.4.1...v0.5.0)
**Implemented enhancements:**
- feat: add stepType to largeInt spinbox [\#179](https://github.com/pyapp-kit/superqt/pull/179) ([tlambert03](https://github.com/tlambert03))
- Searchable tree widget from a mapping [\#158](https://github.com/pyapp-kit/superqt/pull/158) ([andy-sweet](https://github.com/andy-sweet))
- Add `QElidingLineEdit` class for elidable `QLineEdit`s [\#154](https://github.com/pyapp-kit/superqt/pull/154) ([dalthviz](https://github.com/dalthviz))
**Fixed bugs:**
- fix: focus events on QLabeledSlider [\#175](https://github.com/pyapp-kit/superqt/pull/175) ([tlambert03](https://github.com/tlambert03))
- Set parent of timer in throttler [\#171](https://github.com/pyapp-kit/superqt/pull/171) ([Czaki](https://github.com/Czaki))
- fix: fix double slider label editing [\#168](https://github.com/pyapp-kit/superqt/pull/168) ([tlambert03](https://github.com/tlambert03))
**Documentation updates:**
- Fix typos [\#147](https://github.com/pyapp-kit/superqt/pull/147) ([kianmeng](https://github.com/kianmeng))
**Tests & CI:**
- tests: add qtbot to test to fix windows segfault [\#165](https://github.com/pyapp-kit/superqt/pull/165) ([tlambert03](https://github.com/tlambert03))
- test: fixing tests \[wip\] [\#164](https://github.com/pyapp-kit/superqt/pull/164) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- build: unpin pyside6.5 [\#178](https://github.com/pyapp-kit/superqt/pull/178) ([tlambert03](https://github.com/tlambert03))
- build: pin pyside6 to \<6.5.1 [\#169](https://github.com/pyapp-kit/superqt/pull/169) ([tlambert03](https://github.com/tlambert03))
- pin pyside6\<6.5 [\#160](https://github.com/pyapp-kit/superqt/pull/160) ([tlambert03](https://github.com/tlambert03))
- ci: \[pre-commit.ci\] autoupdate [\#146](https://github.com/pyapp-kit/superqt/pull/146) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci))
## [v0.4.1](https://github.com/pyapp-kit/superqt/tree/v0.4.1) (2022-12-01)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.4.0...v0.4.1)
**Implemented enhancements:**
- feat: Add signal to QCollapsible [\#142](https://github.com/pyapp-kit/superqt/pull/142) ([ppwadhwa](https://github.com/ppwadhwa))
- feat: Change icon used in Collapsible widget [\#140](https://github.com/pyapp-kit/superqt/pull/140) ([ppwadhwa](https://github.com/ppwadhwa))
**Fixed bugs:**
- Move QCollapsible toggle signal emit [\#144](https://github.com/pyapp-kit/superqt/pull/144) ([ppwadhwa](https://github.com/ppwadhwa))
**Merged pull requests:**
- build: use hatch for build backend, and use ruff for linting [\#139](https://github.com/pyapp-kit/superqt/pull/139) ([tlambert03](https://github.com/tlambert03))
- chore: rename napari org to pyapp-kit [\#137](https://github.com/pyapp-kit/superqt/pull/137) ([tlambert03](https://github.com/tlambert03))
## [v0.4.0](https://github.com/pyapp-kit/superqt/tree/v0.4.0) (2022-11-09)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.8...v0.4.0)
**Fixed bugs:**
- fix: fix quantity set value and add test [\#131](https://github.com/pyapp-kit/superqt/pull/131) ([tlambert03](https://github.com/tlambert03))
**Refactors:**
- refactor: update pyproject and ci, add py3.11 test [\#132](https://github.com/pyapp-kit/superqt/pull/132) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- chore: changelog v0.4.0 [\#136](https://github.com/pyapp-kit/superqt/pull/136) ([tlambert03](https://github.com/tlambert03))
- ci\(dependabot\): bump actions/upload-artifact from 2 to 3 [\#135](https://github.com/pyapp-kit/superqt/pull/135) ([dependabot[bot]](https://github.com/apps/dependabot))
- ci\(dependabot\): bump codecov/codecov-action from 2 to 3 [\#134](https://github.com/pyapp-kit/superqt/pull/134) ([dependabot[bot]](https://github.com/apps/dependabot))
- build: unpin pyside6 [\#133](https://github.com/pyapp-kit/superqt/pull/133) ([tlambert03](https://github.com/tlambert03))
## [v0.3.8](https://github.com/pyapp-kit/superqt/tree/v0.3.8) (2022-10-10)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.7...v0.3.8)
**Fixed bugs:**
- fix: allow submodule imports [\#128](https://github.com/pyapp-kit/superqt/pull/128) ([kne42](https://github.com/kne42))
## [v0.3.7](https://github.com/pyapp-kit/superqt/tree/v0.3.7) (2022-10-10)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.6...v0.3.7)
**Implemented enhancements:**
- feat: add Quantity widget \(using pint\) [\#126](https://github.com/pyapp-kit/superqt/pull/126) ([tlambert03](https://github.com/tlambert03))
## [v0.3.6](https://github.com/pyapp-kit/superqt/tree/v0.3.6) (2022-10-05)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.6rc0...v0.3.6)
**Documentation updates:**
- minor fix to readme [\#125](https://github.com/pyapp-kit/superqt/pull/125) ([tlambert03](https://github.com/tlambert03))
- Docs [\#124](https://github.com/pyapp-kit/superqt/pull/124) ([tlambert03](https://github.com/tlambert03))
## [v0.3.6rc0](https://github.com/pyapp-kit/superqt/tree/v0.3.6rc0) (2022-10-03)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.5...v0.3.6rc0)
**Implemented enhancements:**
- feat: add editing finished signal to LabeledSliders [\#122](https://github.com/pyapp-kit/superqt/pull/122) ([tlambert03](https://github.com/tlambert03))
**Fixed bugs:**
- fix: fix missing labels after setValue [\#123](https://github.com/pyapp-kit/superqt/pull/123) ([tlambert03](https://github.com/tlambert03))
- fix: Fix TypeError on slider rangeChanged signal [\#121](https://github.com/pyapp-kit/superqt/pull/121) ([tlambert03](https://github.com/tlambert03))
- Simple workaround for pyside 6 [\#119](https://github.com/pyapp-kit/superqt/pull/119) ([Czaki](https://github.com/Czaki))
- fix: Offer patch for \(unstyled\) QSliders on macos 12 and Qt \<6 [\#117](https://github.com/pyapp-kit/superqt/pull/117) ([tlambert03](https://github.com/tlambert03))
## [v0.3.5](https://github.com/pyapp-kit/superqt/tree/v0.3.5) (2022-08-17)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.4...v0.3.5)
**Fixed bugs:**
- fix range slider drag crash on PyQt6 [\#108](https://github.com/pyapp-kit/superqt/pull/108) ([sfhbarnett](https://github.com/sfhbarnett))
- Fix float value error in pyqt configuration [\#106](https://github.com/pyapp-kit/superqt/pull/106) ([mstabrin](https://github.com/mstabrin))
**Merged pull requests:**
- chore: changelog v0.3.5 [\#110](https://github.com/pyapp-kit/superqt/pull/110) ([tlambert03](https://github.com/tlambert03))
## [v0.3.4](https://github.com/pyapp-kit/superqt/tree/v0.3.4) (2022-07-24)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.3...v0.3.4)
**Fixed bugs:**
- fix: relax runtime typing extensions requirement [\#101](https://github.com/pyapp-kit/superqt/pull/101) ([tlambert03](https://github.com/tlambert03))
- fix: catch qpixmap deprecation [\#99](https://github.com/pyapp-kit/superqt/pull/99) ([tlambert03](https://github.com/tlambert03))
## [v0.3.3](https://github.com/pyapp-kit/superqt/tree/v0.3.3) (2022-07-10)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.2...v0.3.3)
**Implemented enhancements:**
- Add code syntax highlight utils [\#88](https://github.com/pyapp-kit/superqt/pull/88) ([Czaki](https://github.com/Czaki))
**Fixed bugs:**
- fix: fix deprecation warning on fonticon plugin discovery on python 3.10 [\#95](https://github.com/pyapp-kit/superqt/pull/95) ([tlambert03](https://github.com/tlambert03))
## [v0.3.2](https://github.com/pyapp-kit/superqt/tree/v0.3.2) (2022-05-03)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.1...v0.3.2)
**Implemented enhancements:**
- Add QSearchableListWidget and QSearchableComboBox widgets [\#80](https://github.com/pyapp-kit/superqt/pull/80) ([Czaki](https://github.com/Czaki))
**Fixed bugs:**
- Fix crazy animation loop on Qcollapsible [\#84](https://github.com/pyapp-kit/superqt/pull/84) ([tlambert03](https://github.com/tlambert03))
- Reorder label update signal [\#83](https://github.com/pyapp-kit/superqt/pull/83) ([tlambert03](https://github.com/tlambert03))
- Fix height of expanded QCollapsible when child changes size [\#72](https://github.com/pyapp-kit/superqt/pull/72) ([tlambert03](https://github.com/tlambert03))
**Tests & CI:**
- Fix deprecation warnings in tests [\#82](https://github.com/pyapp-kit/superqt/pull/82) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- Add changelog for v0.3.2 [\#86](https://github.com/pyapp-kit/superqt/pull/86) ([tlambert03](https://github.com/tlambert03))
## [v0.3.1](https://github.com/pyapp-kit/superqt/tree/v0.3.1) (2022-03-02)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.0...v0.3.1)
**Implemented enhancements:**
- Add `signals_blocked` util [\#69](https://github.com/pyapp-kit/superqt/pull/69) ([tlambert03](https://github.com/tlambert03))
**Fixed bugs:**
- put SignalInstance in TYPE\_CHECKING clause, check min requirements [\#70](https://github.com/pyapp-kit/superqt/pull/70) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- Add changelog for v0.3.1 [\#71](https://github.com/pyapp-kit/superqt/pull/71) ([tlambert03](https://github.com/tlambert03))
## [v0.3.0](https://github.com/pyapp-kit/superqt/tree/v0.3.0) (2022-02-16)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.5-1...v0.3.0)
**Implemented enhancements:**
- Qthrottler and debouncer [\#62](https://github.com/pyapp-kit/superqt/pull/62) ([tlambert03](https://github.com/tlambert03))
- add edgeLabelMode option to QLabeledSlider [\#59](https://github.com/pyapp-kit/superqt/pull/59) ([tlambert03](https://github.com/tlambert03))
**Fixed bugs:**
- Fix nested threadworker not starting [\#63](https://github.com/pyapp-kit/superqt/pull/63) ([tlambert03](https://github.com/tlambert03))
- Add missing signals on proxy sliders [\#54](https://github.com/pyapp-kit/superqt/pull/54) ([tlambert03](https://github.com/tlambert03))
- Ugly but functional workaround for pyside6.2.1 breakages [\#51](https://github.com/pyapp-kit/superqt/pull/51) ([tlambert03](https://github.com/tlambert03))
**Tests & CI:**
- add napari test to CI [\#67](https://github.com/pyapp-kit/superqt/pull/67) ([tlambert03](https://github.com/tlambert03))
- add gh-release action [\#65](https://github.com/pyapp-kit/superqt/pull/65) ([tlambert03](https://github.com/tlambert03))
- fix xvfb tests [\#61](https://github.com/pyapp-kit/superqt/pull/61) ([tlambert03](https://github.com/tlambert03))
**Refactors:**
- Use qtpy, deprecate superqt.qtcompat, drop support for Qt \<5.12 [\#39](https://github.com/pyapp-kit/superqt/pull/39) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- Add changelog for v0.3.0 [\#68](https://github.com/pyapp-kit/superqt/pull/68) ([tlambert03](https://github.com/tlambert03))
## [v0.2.5-1](https://github.com/pyapp-kit/superqt/tree/v0.2.5-1) (2021-11-23)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.5...v0.2.5-1)
**Merged pull requests:**
- typing-extensions version pinning [\#46](https://github.com/pyapp-kit/superqt/pull/46) ([AhmetCanSolak](https://github.com/AhmetCanSolak))
## [v0.2.5](https://github.com/pyapp-kit/superqt/tree/v0.2.5) (2021-11-22)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.4...v0.2.5)
**Implemented enhancements:**
- add support for python 3.10 [\#42](https://github.com/pyapp-kit/superqt/pull/42) ([tlambert03](https://github.com/tlambert03))
- QCollapsible for Collapsible Section Control [\#37](https://github.com/pyapp-kit/superqt/pull/37) ([MosGeo](https://github.com/MosGeo))
- Threadworker [\#31](https://github.com/pyapp-kit/superqt/pull/31) ([tlambert03](https://github.com/tlambert03))
- Add font icons [\#24](https://github.com/pyapp-kit/superqt/pull/24) ([tlambert03](https://github.com/tlambert03))
**Fixed bugs:**
- Fix some small linting issues. [\#41](https://github.com/pyapp-kit/superqt/pull/41) ([tlambert03](https://github.com/tlambert03))
- Use functools.wraps insterad of \_\_wraped\_\_ and manual proxing \_\_name\_\_ [\#29](https://github.com/pyapp-kit/superqt/pull/29) ([Czaki](https://github.com/Czaki))
- Propagate function name in `ensure_main_thread` and `ensure_object_thread` [\#28](https://github.com/pyapp-kit/superqt/pull/28) ([Czaki](https://github.com/Czaki))
**Tests & CI:**
- reskip test\_object\_thread\_return on ci [\#43](https://github.com/pyapp-kit/superqt/pull/43) ([tlambert03](https://github.com/tlambert03))
**Refactors:**
- refactoring qtcompat [\#34](https://github.com/pyapp-kit/superqt/pull/34) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- Fix-manifest, move font tests [\#44](https://github.com/pyapp-kit/superqt/pull/44) ([tlambert03](https://github.com/tlambert03))
- update deploy [\#33](https://github.com/pyapp-kit/superqt/pull/33) ([tlambert03](https://github.com/tlambert03))
- move to src layout [\#32](https://github.com/pyapp-kit/superqt/pull/32) ([tlambert03](https://github.com/tlambert03))
## [v0.2.4](https://github.com/pyapp-kit/superqt/tree/v0.2.4) (2021-09-13)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.3...v0.2.4)
**Implemented enhancements:**
- Add type stubs for ensure\_thread decorator [\#23](https://github.com/pyapp-kit/superqt/pull/23) ([tlambert03](https://github.com/tlambert03))
- Add `ensure_main_tread` and `ensure_object_thread` [\#22](https://github.com/pyapp-kit/superqt/pull/22) ([Czaki](https://github.com/Czaki))
- Add QMessageHandler context manager [\#21](https://github.com/pyapp-kit/superqt/pull/21) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- add changelog for 0.2.4 [\#25](https://github.com/pyapp-kit/superqt/pull/25) ([tlambert03](https://github.com/tlambert03))
## [v0.2.3](https://github.com/pyapp-kit/superqt/tree/v0.2.3) (2021-08-25)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.2...v0.2.3)
**Fixed bugs:**
- Fix warnings on eliding label for 5.12, test more qt versions [\#19](https://github.com/pyapp-kit/superqt/pull/19) ([tlambert03](https://github.com/tlambert03))
## [v0.2.2](https://github.com/pyapp-kit/superqt/tree/v0.2.2) (2021-08-17)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.1...v0.2.2)
**Implemented enhancements:**
- Add QElidingLabel [\#16](https://github.com/pyapp-kit/superqt/pull/16) ([tlambert03](https://github.com/tlambert03))
- Enum ComboBox implementation [\#13](https://github.com/pyapp-kit/superqt/pull/13) ([Czaki](https://github.com/Czaki))
**Documentation updates:**
- fix broken link [\#18](https://github.com/pyapp-kit/superqt/pull/18) ([haesleinhuepf](https://github.com/haesleinhuepf))
## [v0.2.1](https://github.com/pyapp-kit/superqt/tree/v0.2.1) (2021-07-10)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.0...v0.2.1)
**Fixed bugs:**
- Fix QLabeledRangeSlider API \(fix slider proxy\) [\#10](https://github.com/pyapp-kit/superqt/pull/10) ([tlambert03](https://github.com/tlambert03))
- Fix range slider with negative min range [\#9](https://github.com/pyapp-kit/superqt/pull/9) ([tlambert03](https://github.com/tlambert03))

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.7 and above
- Python 3.9 and above
- PyQt5 (5.11 and above) & PyQt6
- PySide2 (5.11 and above) & PySide6
- macOS, Windows, & Linux
@@ -48,5 +48,4 @@ All widgets should try to match the native Qt API as much as possible:
## Testing
Tests can be run in the current environment with `pytest`. Or, to run tests
against all supported python & Qt versions, run `tox`.
Tests can be run in the current environment with `pytest`.

View File

@@ -1,17 +0,0 @@
include LICENSE
include README.md
include CHANGELOG.md
include src/superqt/py.typed
recursive-include src/superqt *.py
recursive-include src/superqt *.pyi
recursive-exclude * __pycache__
recursive-exclude * *.py[co]
recursive-exclude docs *
recursive-exclude examples *
recursive-exclude tests *
exclude tox.ini
exclude CONTRIBUTING.md
exclude codecov.yml
exclude .github_changelog_generator
exclude .pre-commit-config.yaml

View File

@@ -1,11 +1,11 @@
# ![tiny](https://user-images.githubusercontent.com/1609449/120636353-8c3f3800-c43b-11eb-8732-a14dec578897.png) superqt!
[![License](https://img.shields.io/pypi/l/superqt.svg?color=green)](https://github.com/napari/superqt/raw/master/LICENSE)
[![License](https://img.shields.io/pypi/l/superqt.svg?color=green)](https://github.com/pyapp-kit/superqt/raw/master/LICENSE)
[![PyPI](https://img.shields.io/pypi/v/superqt.svg?color=green)](https://pypi.org/project/superqt)
[![Python
Version](https://img.shields.io/pypi/pyversions/superqt.svg?color=green)](https://python.org)
[![Test](https://github.com/napari/superqt/actions/workflows/test_and_deploy.yml/badge.svg)](https://github.com/napari/superqt/actions/workflows/test_and_deploy.yml)
[![codecov](https://codecov.io/gh/napari/superqt/branch/main/graph/badge.svg?token=dcsjgl1sOi)](https://codecov.io/gh/napari/superqt)
[![Test](https://github.com/pyapp-kit/superqt/actions/workflows/test_and_deploy.yml/badge.svg)](https://github.com/pyapp-kit/superqt/actions/workflows/test_and_deploy.yml)
[![codecov](https://codecov.io/gh/pyapp-kit/superqt/branch/main/graph/badge.svg?token=dcsjgl1sOi)](https://codecov.io/gh/pyapp-kit/superqt)
### "missing" widgets and components for PyQt/PySide
@@ -15,36 +15,36 @@ that are not provided in the native QtWidgets module.
Components are tested on:
- macOS, Windows, & Linux
- Python 3.7 and above
- Python 3.9 and above
- PyQt5 (5.11 and above) & PyQt6
- PySide2 (5.11 and above) & PySide6
## Documentation
Documentation is available at https://napari.org/superqt
Documentation is available at https://pyapp-kit.github.io/superqt/
## Widgets
superqt provides a variety of widgets that are not included in the native QtWidgets module, including multihandle (range) sliders, comboboxes, and more.
See the [widgets documentation](https://napari.org/superqt/widgets) for a full list of widgets.
See the [widgets documentation](https://pyapp-kit.github.io/superqt/widgets) for a full list of widgets.
- [Range Slider](https://napari.org/superqt/widgets/qrangeslider/) (multi-handle slider)
- [Range Slider](https://pyapp-kit.github.io/superqt/widgets/qrangeslider/) (multi-handle slider)
<img src="https://raw.githubusercontent.com/napari/superqt/main/docs/images/demo_darwin10.png" alt="range sliders" width=680>
<img src="https://raw.githubusercontent.com/pyapp-kit/superqt/main/docs/images/demo_darwin10.png" alt="range sliders" width=680>
<img src="https://raw.githubusercontent.com/napari/superqt/main/docs/images/labeled_qslider.png" alt="range sliders" width=680>
<img src="https://raw.githubusercontent.com/pyapp-kit/superqt/main/docs/images/labeled_qslider.png" alt="range sliders" width=680>
<img src="https://raw.githubusercontent.com/napari/superqt/main/docs/images/labeled_range.png" alt="range sliders" width=680>
<img src="https://raw.githubusercontent.com/pyapp-kit/superqt/main/docs/images/labeled_range.png" alt="range sliders" width=680>
## Utilities
superqt includes a number of utitlities for working with Qt, including:
superqt includes a number of utilities for working with Qt, including:
- tools and decorators for working with threads in qt.
- `superqt.fonticon` for generating icons from font files (such as [Material Design Icons](https://materialdesignicons.com/) and [Font Awesome](https://fontawesome.com/))
See the [utilities documentation](https://napari.org/superqt/utilities/) for a full list of utilities.
See the [utilities documentation](https://pyapp-kit.github.io/superqt/utilities/) for a full list of utilities.
## Contributing

View File

@@ -35,11 +35,14 @@ 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)
return f"![{page.title}](../{dest.parent.name}/{dest.name}){{ loading=lazy; width={width} }}\n\n"
return (
f"![{page.title}](../{dest.parent.name}/{dest.name})"
f"{{ loading=lazy; width={width} }}\n\n"
)
@env.macro
def show_members(cls: str):
@@ -101,7 +104,6 @@ def define_env(env: "MacrosPlugin"):
out += f"- `{m.name}`\n\n"
if self_members:
out += dedent(
f"""
## Methods
@@ -125,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]
@@ -133,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

@@ -7,7 +7,7 @@
(including native Qt sliders) to not respond properly to drag events. See:
- [https://bugreports.qt.io/browse/QTBUG-98093](https://bugreports.qt.io/browse/QTBUG-98093)
- [https://github.com/napari/superqt/issues/74](https://github.com/napari/superqt/issues/74)
- [https://github.com/pyapp-kit/superqt/issues/74](https://github.com/pyapp-kit/superqt/issues/74)
Superqt includes a workaround for this issue, but it is not perfect, and it requires using a custom stylesheet (which may interfere with your own styles). Note that you
may not see this issue if you're already using custom stylesheets.

View File

@@ -10,7 +10,7 @@ QtWidgets module.
Components are tested on:
- macOS, Windows, & Linux
- Python 3.7 and above
- Python 3.9 and above
- PyQt5 (5.11 and above) & PyQt6
- PySide2 (5.11 and above) & PySide6
@@ -26,4 +26,4 @@ conda install -c conda-forge superqt
## Usage
See the [Widgets](./widgets/) and [Utilities](./utilities/) pages for features offered by superqt.
See the [Widgets](./widgets/index.md) and [Utilities](./utilities/index.md) pages for features offered by superqt.

12
docs/utilities/cmap.md Normal file
View File

@@ -0,0 +1,12 @@
# Colormap utilities
See also:
- [`superqt.QColormapComboBox`](../widgets/qcolormap.md)
- [`superqt.cmap.CmapCatalogComboBox`](../widgets/colormap_catalog.md)
::: superqt.cmap.draw_colormap
::: superqt.cmap.QColormapLineEdit
::: superqt.cmap.QColormapItemDelegate

View File

@@ -0,0 +1,3 @@
# Error message context manager
::: superqt.utils.exceptions_as_dialog

View File

@@ -28,21 +28,44 @@ app.exec()
## Font Icon plugins
Ready-made fonticon packs are available as plugins:
Ready-made fonticon packs are available as plugins.
### [Font Awesome 5](https://fontawesome.com/v5/search)
A great way to search across most available icons libraries from a single
search interface is to use glyphsearch: <https://glyphsearch.com/>
```bash
pip install fonticon-fontawesome5
```
If a font library you'd like to use is unavailable as a superqt plugin,
please [open a feature request](https://github.com/pyapp-kit/superqt/issues/new/choose)
### [Font Awesome 6](https://fontawesome.com/v6/search)
### Font Awesome 6
Browse available icons at <https://fontawesome.com/v6/search>
```bash
pip install fonticon-fontawesome6
```
### [Material Design Icons](https://materialdesignicons.com/)
### Font Awesome 5
Browse available icons at <https://fontawesome.com/v5/search>
```bash
pip install fonticon-fontawesome5
```
### Material Design Icons 7
Browse available icons at <https://materialdesignicons.com/>
```bash
pip install fonticon-materialdesignicons7
```
### Material Design Icons 6
Browse available icons at <https://materialdesignicons.com/>
(note that the search defaults to v7, see changes from v6 in [the
changelog](https://pictogrammers.com/docs/library/mdi/releases/changelog/))
```bash
pip install fonticon-materialdesignicons6
@@ -55,7 +78,7 @@ pip install fonticon-materialdesignicons6
- <https://github.com/tlambert03/fonticon-feather>
`superqt.fonticon` is a pluggable system, and font icon packs may use the `"superqt.fonticon"`
entry point to register themselves with superqt. See [`fonticon-cookiecutter`](https://github.com/tlambert03/fonticon-cookiecutter) for a template, or look through the following repos for examples:
entry point to register themselves with superqt. See [`fonticon-cookiecutter`](https://github.com/tlambert03/fonticon-cookiecutter) for a template, or look through the following repos for examples:
- <https://github.com/tlambert03/fonticon-fontawesome6>
- <https://github.com/tlambert03/fonticon-fontawesome5>
@@ -64,24 +87,24 @@ entry point to register themselves with superqt. See [`fonticon-cookiecutter`](
## API
::: superqt.fonticon.icon
options:
heading_level: 3
options:
heading_level: 3
::: superqt.fonticon.setTextIcon
options:
heading_level: 3
options:
heading_level: 3
::: superqt.fonticon.font
options:
heading_level: 3
options:
heading_level: 3
::: superqt.fonticon.IconOpts
options:
heading_level: 3
options:
heading_level: 3
::: superqt.fonticon.addFont
options:
heading_level: 3
options:
heading_level: 3
## Animations
@@ -89,13 +112,13 @@ the `animation` parameter to `icon()` accepts a subclass of
`Animation` that will be
::: superqt.fonticon.Animation
options:
heading_level: 3
options:
heading_level: 3
::: superqt.fonticon.pulse
options:
heading_level: 3
options:
heading_level: 3
::: superqt.fonticon.spin
options:
heading_level: 3
options:
heading_level: 3

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 |
@@ -29,3 +35,4 @@
| ----------- | --------------------- |
| [`QMessageHandler`](./qmessagehandler.md) | A context manager to intercept messages from Qt. |
| [`CodeSyntaxHighlight`](./code_syntax_highlight.md) | A `QSyntaxHighlighter` for code syntax highlighting. |
| [`draw_colormap`](./cmap.md) | Function that draws a colormap into any QPaintDevice. |

View File

@@ -0,0 +1,3 @@
# Signal Utilities
::: superqt.utils.signals_blocked

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

@@ -0,0 +1,35 @@
# CmapCatalogComboBox
Searchable `QComboBox` variant that contains the
[entire cmap colormap catalog](https://cmap-docs.readthedocs.io/en/latest/catalog/)
!!! note "requires cmap"
This widget uses the [cmap](https://cmap-docs.readthedocs.io/) library
to provide colormaps. You can install it with:
```shell
# use the `cmap` extra to include colormap support
pip install superqt[cmap]
```
You can limit the colormaps shown by setting the `categories` or
`interpolation` keyword arguments.
```python
from qtpy.QtWidgets import QApplication
from superqt.cmap import CmapCatalogComboBox
app = QApplication([])
catalog_combo = CmapCatalogComboBox(interpolation="linear")
catalog_combo.setCurrentText("viridis")
catalog_combo.show()
app.exec()
```
{{ show_widget(130) }}
{{ show_members('superqt.cmap.CmapCatalogComboBox') }}

View File

@@ -24,9 +24,14 @@ The following are QWidget subclasses:
| [`QEnumComboBox`](./qenumcombobox.md) | `QComboBox` that populates the combobox from a python `Enum` |
| [`QSearchableComboBox`](./qsearchablecombobox.md) | `QComboBox` variant that filters available options based on text input |
| [`QSearchableListWidget`](./qsearchablelistwidget.md) | `QListWidget` variant with search field that filters available options |
| [`QSearchableTreeWidget`](./qsearchabletreewidget.md) | `QTreeWidget` variant with search field that filters available options |
| [`QColorComboBox`](./qcolorcombobox.md) | `QComboBox` to select from a specified set of colors |
| [`QColormapComboBox`](./qcolormap.md) | `QComboBox` to select from a specified set of colormaps. |
| [`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

@@ -0,0 +1,27 @@
# QColorComboBox
`QComboBox` designed to select from a specific set of colors.
```python
from qtpy.QtWidgets import QApplication
from superqt import QColorComboBox
app = QApplication([])
colors = QColorComboBox()
colors.addColors(['red', 'green', 'blue'])
# show an "Add Color" item that opens a QColorDialog when clicked
colors.setUserColorsAllowed(True)
# emits a QColor when changed
colors.currentColorChanged.connect(print)
colors.show()
app.exec_()
```
{{ show_widget(100) }}
{{ show_members('superqt.QColorComboBox') }}

67
docs/widgets/qcolormap.md Normal file
View File

@@ -0,0 +1,67 @@
# QColormapComboBox
`QComboBox` variant to select from a specific set of colormaps.
!!! note "requires cmap"
This widget uses the [cmap](https://cmap-docs.readthedocs.io/) library
to provide colormaps. You can install it with:
```shell
# use the `cmap` extra to include colormap support
pip install superqt[cmap]
```
### ColorMapLike objects
Colormaps may be specified in a variety of ways, such as by name (string), an iterable of a color/color-like objects, or as
a [`cmap.Colormap`][] instance. See [cmap documentation for details on
all ColormapLike types](https://cmap-docs.readthedocs.io/en/latest/colormaps/#colormaplike-objects)
### Example
```python
from cmap import Colormap
from qtpy.QtWidgets import QApplication
from superqt import QColormapComboBox
app = QApplication([])
cmap_combo = QColormapComboBox()
# see note above about colormap-like objects
# as names from the cmap catalog
cmap_combo.addColormaps(["viridis", "plasma", "magma", "gray"])
# as a sequence of colors, linearly interpolated
cmap_combo.addColormap(("#0f0", "slateblue", "#F3A003A0"))
# as a `cmap.Colormap` instance with custom name:
cmap_combo.addColormap(Colormap(("green", "white", "orange"), name="MyMap"))
cmap_combo.show()
app.exec()
```
{{ show_widget(200) }}
### Style Customization
Note that both the LineEdit and the dropdown can be styled to have the colormap
on the left, or fill the entire width of the widget.
To make the CombBox label colormap fill the entire width of the widget:
```python
from superqt.cmap import QColormapLineEdit
cmap_combo.setLineEdit(QColormapLineEdit())
```
To make the CombBox dropdown colormaps fill
less than the entire width of the widget:
```python
from superqt.cmap import QColormapItemDelegate
delegate = QColormapItemDelegate(fractional_colormap_width=0.33)
cmap_combo.setItemDelegate(delegate)
```
{{ show_members('superqt.QColormapComboBox') }}

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,37 @@
# QSearchableTreeWidget
`QSearchableTreeWidget` combines a
[`QTreeWidget`](https://doc.qt.io/qt-6/qtreewidget.html) and a `QLineEdit` for showing a mapping that can be searched by key.
This is intended to be used with a read-only mapping and be conveniently created
using `QSearchableTreeWidget.fromData(data)`. If the mapping changes, the
easiest way to update this is by calling `setData`.
```python
from qtpy.QtWidgets import QApplication
from superqt import QSearchableTreeWidget
app = QApplication([])
data = {
"none": None,
"str": "test",
"int": 42,
"list": [2, 3, 5],
"dict": {
"float": 0.5,
"tuple": (22, 99),
"bool": False,
},
}
tree = QSearchableTreeWidget.fromData(data)
tree.show()
app.exec_()
```
{{ show_widget() }}
{{ show_members('superqt.QSearchableTreeWidget') }}

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

@@ -1,4 +1,4 @@
from PyQt5.QtGui import QColor, QPalette
from qtpy.QtGui import QColor, QPalette
from qtpy.QtWidgets import QApplication, QTextEdit
from superqt.utils import CodeSyntaxHighlight

View File

@@ -0,0 +1,23 @@
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QApplication
from superqt import QColorComboBox
app = QApplication([])
w = QColorComboBox()
# adds an item "Add Color" that opens a QColorDialog when clicked
w.setUserColorsAllowed(True)
# colors can be any argument that can be passed to QColor
# (tuples and lists will be expanded to QColor(*color)
COLORS = [QColor("red"), "orange", (255, 255, 0), "green", "#00F", "indigo", "violet"]
w.addColors(COLORS)
# as with addColors, colors will be cast to QColor when using setColors
w.setCurrentColor("indigo")
w.resize(200, 50)
w.show()
w.currentColorChanged.connect(print)
app.exec_()

View File

@@ -0,0 +1,19 @@
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from superqt.cmap import CmapCatalogComboBox, QColormapComboBox
app = QApplication([])
wdg = QWidget()
layout = QVBoxLayout(wdg)
catalog_combo = CmapCatalogComboBox(interpolation="linear")
selected_cmap_combo = QColormapComboBox(allow_user_colormaps=True)
selected_cmap_combo.addColormaps(["viridis", "plasma", "magma", "inferno", "turbo"])
layout.addWidget(catalog_combo)
layout.addWidget(selected_cmap_combo)
wdg.show()
app.exec()

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 {
@@ -110,7 +110,6 @@ class DemoWidget(QtW.QWidget):
if __name__ == "__main__":
import sys
from pathlib import Path

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

@@ -88,7 +88,7 @@ class IconPreviewArea(QtWidgets.QWidget):
self.updatePixmapLabels()
def createHeaderLabel(self, text):
label = QtWidgets.QLabel("<b>%s</b>" % text)
label = QtWidgets.QLabel(f"<b>{text}</b>")
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
return label
@@ -219,7 +219,7 @@ class MainWindow(QtWidgets.QMainWindow):
self.previewGroupBox.setLayout(layout)
def createGlyphBox(self):
self.glyphGroupBox = QtWidgets.QGroupBox("Glpyhs")
self.glyphGroupBox = QtWidgets.QGroupBox("Glyphs")
self.glyphGroupBox.setMinimumSize(480, 200)
self.glyphTable = QtWidgets.QTableWidget()
self.glyphTable.setSelectionMode(QtWidgets.QAbstractItemView.NoSelection)
@@ -369,7 +369,6 @@ class MainWindow(QtWidgets.QMainWindow):
if __name__ == "__main__":
import sys
app = QtWidgets.QApplication(sys.argv)

14
examples/iconify.py Normal file
View File

@@ -0,0 +1,14 @@
from qtpy.QtCore import QSize
from qtpy.QtWidgets import QApplication, QPushButton
from superqt import QIconifyIcon
app = QApplication([])
btn = QPushButton()
# search https://icon-sets.iconify.design for available icon keys
btn.setIcon(QIconifyIcon("fluent-emoji-flat:alarm-clock"))
btn.setIconSize(QSize(60, 60))
btn.show()
app.exec()

View File

@@ -14,6 +14,7 @@ ORIENTATION = Qt.Orientation.Horizontal
w = QWidget()
qls = QLabeledSlider(ORIENTATION)
qls.setEdgeLabelMode(qls.EdgeLabelMode.LabelIsRange | qls.EdgeLabelMode.LabelIsValue)
qls.valueChanged.connect(lambda e: print("qls valueChanged", e))
qls.setRange(0, 500)
qls.setValue(300)
@@ -26,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

@@ -1,4 +1,5 @@
"""Example for QCollapsible"""
"""Example for QCollapsible."""
from qtpy.QtWidgets import QApplication, QLabel, QPushButton
from superqt import QCollapsible
@@ -6,6 +7,8 @@ from superqt import QCollapsible
app = QApplication([])
collapsible = QCollapsible("Advanced analysis")
collapsible.setCollapsedIcon("+")
collapsible.setExpandedIcon("-")
collapsible.addWidget(QLabel("This is the inside of the collapsible frame"))
for i in range(10):
collapsible.addWidget(QPushButton(f"Content button {i + 1}"))

View File

@@ -5,7 +5,6 @@ from superqt import QRangeSlider
app = QApplication([])
slider = QRangeSlider(Qt.Orientation.Horizontal)
slider = QRangeSlider(Qt.Orientation.Horizontal)
slider.setValue((20, 80))

View File

@@ -0,0 +1,29 @@
import logging
from qtpy.QtWidgets import QApplication
from superqt import QSearchableTreeWidget
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s : %(levelname)s : %(filename)s : %(message)s",
)
data = {
"none": None,
"str": "test",
"int": 42,
"list": [2, 3, 5],
"dict": {
"float": 0.5,
"tuple": (22, 99),
"bool": False,
},
}
app = QApplication([])
tree = QSearchableTreeWidget.fromData(data)
tree.show()
app.exec_()

View File

@@ -1,4 +1,4 @@
"""Adapted for python from the KDToolBox
"""Adapted for python from the KDToolBox.
https://github.com/KDAB/KDToolBox/tree/master/qt/KDSignalThrottler
@@ -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,13 +84,11 @@ class DrawSignalsWidget(QWidget):
self.update()
def scrollAndCut(self, v: Deque[int], cutoff: int):
x = 0
def scrollAndCut(self, v: deque[int], cutoff: int):
L = len(v)
for p in range(L):
v[p] += 1
if v[p] > cutoff:
x = p
break
# TODO: fix this... delete old ones
@@ -123,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

@@ -1,16 +1,13 @@
site_name: superqt
site_url: https://github.com/napari/superqt
site_url: https://github.com/pyapp-kit/superqt
site_description: >-
missing widgets and components for PyQt/PySide
# Repository
repo_name: napari/superqt
repo_url: https://github.com/napari/superqt
repo_name: pyapp-kit/superqt
repo_url: https://github.com/pyapp-kit/superqt
# Copyright
copyright: Copyright &copy; 2021 - 2022 Talley Lambert
extra_css:
- stylesheets/extra.css
copyright: Copyright &copy; 2021 - 2022
watch:
- src
@@ -25,6 +22,7 @@ theme:
# - navigation.tabs
- search.highlight
- search.suggest
- content.code.copy
markdown_extensions:
- admonition
@@ -34,13 +32,15 @@ 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: "#"
plugins:
- search
- autorefs
- mkdocstrings
- macros:
module_name: docs/_macros
- mkdocstrings:
@@ -48,6 +48,7 @@ plugins:
python:
import:
- https://docs.python.org/3/objects.inv
- https://cmap-docs.readthedocs.io/en/latest/objects.inv
options:
show_source: false
docstring_style: numpy

View File

@@ -1,10 +1,237 @@
# pyproject.toml
# https://peps.python.org/pep-0517/
[build-system]
requires = ["setuptools>=45", "wheel", "setuptools_scm[toml]>=6.0"]
build-backend = "setuptools.build_meta"
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"
[tool.setuptools_scm]
write_to = "src/superqt/_version.py"
# https://peps.python.org/pep-0621/
[project]
name = "superqt"
description = "Missing widgets and components for PyQt/PySide"
readme = "README.md"
requires-python = ">=3.9"
license = { text = "BSD 3-Clause License" }
authors = [{ email = "talley.lambert@gmail.com", name = "Talley Lambert" }]
keywords = [
"qt",
"pyqt",
"pyside",
"widgets",
"range slider",
"components",
"gui",
]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: X11 Applications :: Qt",
"Intended Audience :: Developers",
"License :: OSI Approved :: BSD License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.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",
]
dynamic = ["version"]
dependencies = [
"pygments>=2.4.0",
"qtpy>=1.1.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==4.4.0",
"numpy",
"cmap",
"pyconify",
]
dev = [
"ipython",
"ruff",
"mypy",
"pdbpp",
"pre-commit",
"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]",
]
quantity = ["pint"]
cmap = ["cmap >=0.1.1"]
pyside2 = ["pyside2"]
# see issues surrounding usage of Generics in pyside6.5.x
# https://github.com/pyapp-kit/superqt/pull/177
# https://github.com/pyapp-kit/superqt/pull/164
# https://bugreports.qt.io/browse/PYSIDE-2627
pyside6 = ["pyside6 !=6.5.0,!=6.5.1,!=6.6.2,<6.8"]
pyqt5 = ["pyqt5"]
pyqt6 = ["pyqt6<6.7"]
font-fa5 = ["fonticon-fontawesome5"]
font-fa6 = ["fonticon-fontawesome6"]
font-mi6 = ["fonticon-materialdesignicons6"]
font-mi7 = ["fonticon-materialdesignicons7"]
iconify = ["pyconify >=0.1.4"]
[project.urls]
Documentation = "https://pyapp-kit.github.io/superqt/"
Source = "https://github.com/pyapp-kit/superqt"
Tracker = "https://github.com/pyapp-kit/superqt/issues"
Changelog = "https://github.com/pyapp-kit/superqt/blob/main/CHANGELOG.md"
[tool.hatch.version]
source = "vcs"
[tool.hatch.build.targets.sdist]
include = ["src", "tests", "CHANGELOG.md"]
# these let you run tests across all backends easily with:
# hatch run test:test
[tool.hatch.envs.test]
[tool.hatch.envs.test.scripts]
test = "pytest"
[[tool.hatch.envs.test.matrix]]
qt = ["pyside6", "pyqt6"]
python = ["3.11"]
[[tool.hatch.envs.test.matrix]]
qt = ["pyside2", "pyqt5", "pyqt5.12"]
python = ["3.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",
] },
]
[tool.ruff]
line-length = 88
target-version = "py39"
src = ["src", "tests"]
# https://docs.astral.sh/ruff/rules
[tool.ruff.lint]
pydocstyle = { convention = "numpy" }
select = [
"E", # style errors
"W", # style warnings
"F", # flakes
"D", # pydocstyle
"D417", # Missing argument descriptions in Docstrings
"I", # isort
"UP", # pyupgrade
"C4", # flake8-comprehensions
"B", # flake8-bugbear
"A001", # flake8-builtins
"RUF", # ruff-specific rules
"TC", # flake8-type-checking
"TID", # flake8-tidy-imports
]
ignore = [
"D104", # Missing docstring in public package
"D401", # First line should be in imperative mood (remove to opt in)
]
[tool.ruff.lint.per-file-ignores]
"tests/*.py" = ["D", "S101"]
"examples/demo_widget.py" = ["E501"]
"examples/*.py" = ["B", "D"]
# https://docs.astral.sh/ruff/formatter/
[tool.ruff.format]
docstring-code-format = true
# https://docs.pytest.org/en/6.2.x/customize.html
[tool.pytest.ini_options]
minversion = "6.0"
testpaths = ["tests"]
filterwarnings = [
"error",
"ignore:Failed to disconnect::pytestqt",
"ignore:QPixmapCache.find:DeprecationWarning:",
"ignore:SelectableGroups dict interface:DeprecationWarning",
"ignore:The distutils package is deprecated:DeprecationWarning",
"ignore:.*Skipping callback call set_result",
]
# https://mypy.readthedocs.io/en/stable/config_file.html
[tool.mypy]
files = "src/**/*.py"
strict = true
disallow_untyped_defs = false
disallow_untyped_calls = false
disallow_any_generics = false
disallow_subclassing_any = false
show_error_codes = true
pretty = true
exclude = ['tests/**/*']
[[tool.mypy.overrides]]
module = ["superqt.qtcompat.*"]
ignore_missing_imports = true
warn_unused_ignores = false
allow_redefinition = true
# https://coverage.readthedocs.io/en/6.4/config.html
[tool.coverage.run]
source = ["superqt"]
[tool.coverage.report]
show_missing = true
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"@overload",
"except ImportError",
"\\.\\.\\.",
"pass",
]
# https://github.com/mgedmin/check-manifest#configuration
[tool.check-manifest]
ignore = ["src/superqt/_version.py", "mkdocs.yml"]
ignore = [
".github_changelog_generator",
".pre-commit-config.yaml",
"tests/**/*",
"src/superqt/_version.py",
"mkdocs.yml",
"docs/**/*",
"examples/**/*",
"CHANGELOG.md",
"CONTRIBUTING.md",
"codecov.yml",
".ruff_cache/**/*",
]

122
setup.cfg
View File

@@ -1,122 +0,0 @@
[metadata]
name = superqt
description = Missing widgets for PyQt/PySide
long_description = file: README.md
long_description_content_type = text/markdown
url = https://github.com/napari/superqt
author = Talley Lambert
author_email = talley.lambert@gmail.com
license = BSD-3-Clause
license_file = LICENSE
classifiers =
Development Status :: 4 - Beta
Environment :: X11 Applications :: Qt
Intended Audience :: Developers
License :: OSI Approved :: BSD License
Operating System :: OS Independent
Programming Language :: Python
Programming Language :: Python :: 3
Programming Language :: Python :: 3 :: Only
Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
Programming Language :: Python :: Implementation :: CPython
Topic :: Desktop Environment
Topic :: Software Development
Topic :: Software Development :: User Interfaces
Topic :: Software Development :: Widget Sets
keywords = qt, range slider, widget
project_urls =
Source = https://github.com/napari/superqt
Tracker = https://github.com/napari/superqt/issues
Changelog = https://github.com/napari/superqt/blob/master/CHANGELOG.md
[options]
packages = find:
install_requires =
packaging
pygments>=2.4.0
qtpy>=1.1.0
typing-extensions
python_requires = >=3.7
include_package_data = True
package_dir =
=src
setup_requires =
setuptools-scm
zip_safe = False
[options.packages.find]
where = src
[options.extras_require]
dev =
ipython
isort
jedi<0.18.0
mypy
pre-commit
pyside2
pytest
pytest-cov
pytest-qt
tox
tox-conda
docs =
mkdocs-macros-plugin
mkdocs-material
mkdocstrings[python]
font_fa5 =
fonticon-fontawesome5
font_mi5 =
fonticon-materialdesignicons5
pyqt5 =
pyqt5
pyqt6 =
pyqt6
pyside2 =
pyside2
pyside6 =
pyside6
quantity =
pint
testing =
pint
pytest
pytest-cov
pytest-qt
tox
tox-conda
[options.package_data]
superqt = py.typed
[flake8]
exclude = _version.py,.eggs,examples
docstring-convention = numpy
ignore = E203,W503,E501,C901,F403,F405,D100
[pydocstyle]
convention = numpy
add_select = D402,D415,D417
ignore = D100
[isort]
profile = black
[tool:pytest]
filterwarnings =
error
ignore:QPixmapCache.find:DeprecationWarning:
ignore:SelectableGroups dict interface:DeprecationWarning
ignore:The distutils package is deprecated:DeprecationWarning
[mypy]
strict = True
files = src/superqt
[mypy-superqt.qtcompat.*]
ignore_missing_imports = True
warn_unused_ignores = False
allow_redefinition = True

View File

@@ -1,18 +1,17 @@
"""superqt is a collection of Qt components for python."""
from typing import TYPE_CHECKING
from importlib.metadata import PackageNotFoundError, version
from typing import TYPE_CHECKING, Any
try:
from ._version import version as __version__
except ImportError:
__version__ = version("superqt")
except PackageNotFoundError:
__version__ = "unknown"
if TYPE_CHECKING:
from .spinbox._quantity import QQuantity
from ._eliding_label import QElidingLabel
from .collapsible import QCollapsible
from .combobox import QEnumComboBox, QSearchableComboBox
from .selection import QSearchableListWidget
from .combobox import QColorComboBox, QEnumComboBox, QSearchableComboBox
from .elidable import QElidingLabel, QElidingLineEdit
from .selection import QSearchableListWidget, QSearchableTreeWidget
from .sliders import (
QDoubleRangeSlider,
QDoubleSlider,
@@ -23,16 +22,25 @@ 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",
"QDoubleRangeSlider",
"QCollapsible",
"QColorComboBox",
"QColormapComboBox",
"QDoubleRangeSlider",
"QDoubleSlider",
"QElidingLabel",
"QElidingLineEdit",
"QEnumComboBox",
"QFlowLayout",
"QIconifyIcon",
"QLabeledDoubleRangeSlider",
"QLabeledDoubleSlider",
"QLabeledRangeSlider",
@@ -43,12 +51,29 @@ __all__ = [
"QRangeSlider",
"QSearchableComboBox",
"QSearchableListWidget",
"QSearchableTreeWidget",
"QToggleSwitch",
"ensure_main_thread",
"ensure_object_thread",
]
if TYPE_CHECKING:
from .combobox import QColormapComboBox
from .iconify import QIconifyIcon
from .spinbox._quantity import QQuantity
def __getattr__(name):
def __getattr__(name: str) -> Any:
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 ImportError(f"cannot import name {name!r} from {__name__!r}")
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View File

@@ -1,110 +0,0 @@
from typing import List
from qtpy.QtCore import QPoint, QRect, QSize, Qt
from qtpy.QtGui import QFont, QFontMetrics, QResizeEvent, QTextLayout
from qtpy.QtWidgets import QLabel
class QElidingLabel(QLabel):
"""A QLabel variant that will elide text (add '') to fit width.
QElidingLabel()
QElidingLabel(parent: Optional[QWidget], f: Qt.WindowFlags = ...)
QElidingLabel(text: str, parent: Optional[QWidget] = None, f: Qt.WindowFlags = ...)
For a multiline eliding label, use `setWordWrap(True)`. In this case, text
will wrap to fit the width, and only the last line will be elided.
When `wordWrap()` is True, `sizeHint()` will return the size required to fit
the full text.
"""
def __init__(self, *args, **kwargs) -> None:
self._elide_mode = Qt.TextElideMode.ElideRight
super().__init__(*args, **kwargs)
self.setText(args[0] if args and isinstance(args[0], str) else "")
# New Public methods
def elideMode(self) -> Qt.TextElideMode:
"""The current Qt.TextElideMode."""
return self._elide_mode
def setElideMode(self, mode: Qt.TextElideMode):
"""Set the elide mode to a Qt.TextElideMode."""
self._elide_mode = Qt.TextElideMode(mode)
super().setText(self._elidedText())
@staticmethod
def wrapText(text, width, font=None) -> List[str]:
"""Returns `text`, split as it would be wrapped for `width`, given `font`.
Static method.
"""
tl = QTextLayout(text, font or QFont())
tl.beginLayout()
lines = []
while True:
ln = tl.createLine()
if not ln.isValid():
break
ln.setLineWidth(width)
start = ln.textStart()
lines.append(text[start : start + ln.textLength()])
tl.endLayout()
return lines
# Reimplemented QT methods
def text(self) -> str:
"""This property holds the label's text.
If no text has been set this will return an empty string.
"""
return self._text
def setText(self, txt: str):
"""Set the label's text.
Setting the text clears any previous content.
NOTE: we set the QLabel private text to the elided version
"""
self._text = txt
super().setText(self._elidedText())
def resizeEvent(self, ev: QResizeEvent) -> None:
ev.accept()
super().setText(self._elidedText())
def setWordWrap(self, wrap: bool) -> None:
super().setWordWrap(wrap)
super().setText(self._elidedText())
def sizeHint(self) -> QSize:
if not self.wordWrap():
return super().sizeHint()
fm = QFontMetrics(self.font())
flags = int(self.alignment() | Qt.TextFlag.TextWordWrap)
r = fm.boundingRect(QRect(QPoint(0, 0), self.size()), flags, self._text)
return QSize(self.width(), r.height())
# private implementation methods
def _elidedText(self) -> str:
"""Return `self._text` elided to `width`"""
fm = QFontMetrics(self.font())
# the 2 is a magic number that prevents the ellipses from going missing
# in certain cases (?)
width = self.width() - 2
if not self.wordWrap():
return fm.elidedText(self._text, self._elide_mode, width)
# get number of lines we can fit without eliding
nlines = self.height() // fm.height() - 1
# get the last line (elided)
text = self._wrappedText()
last_line = fm.elidedText("".join(text[nlines:]), self._elide_mode, width)
# join them
return "".join(text[:nlines] + [last_line])
def _wrappedText(self) -> List[str]:
return QElidingLabel.wrapText(self._text, self.width(), self.font())

View File

@@ -0,0 +1,23 @@
try:
import cmap
except ImportError as e:
raise ImportError(
"The cmap package is required to use superqt colormap utilities. "
"Install it with `pip install cmap` or `pip install superqt[cmap]`."
) from e
else:
del cmap
from ._catalog_combo import CmapCatalogComboBox
from ._cmap_combo import QColormapComboBox
from ._cmap_item_delegate import QColormapItemDelegate
from ._cmap_line_edit import QColormapLineEdit
from ._cmap_utils import draw_colormap
__all__ = [
"CmapCatalogComboBox",
"QColormapComboBox",
"QColormapItemDelegate",
"QColormapLineEdit",
"draw_colormap",
]

View File

@@ -0,0 +1,96 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from cmap import Colormap
from qtpy.QtCore import Qt, Signal
from qtpy.QtWidgets import QComboBox, QCompleter, QWidget
from ._cmap_item_delegate import QColormapItemDelegate
from ._cmap_line_edit import QColormapLineEdit
from ._cmap_utils import try_cast_colormap
if TYPE_CHECKING:
from collections.abc import Container
from cmap._catalog import Category, Interpolation
from qtpy.QtGui import QKeyEvent
class CmapCatalogComboBox(QComboBox):
"""A combo box for selecting a colormap from the entire cmap catalog.
Parameters
----------
parent : QWidget, optional
The parent widget.
prefer_short_names : bool, optional
If True (default), short names (without the namespace prefix) will be
preferred over fully qualified names. In cases where the same short name is
used in multiple namespaces, they will *all* be referred to by their fully
qualified (namespaced) name.
categories : Container[Category], optional
If provided, only return names from the given categories.
interpolation : Interpolation, optional
If provided, only return names that have the given interpolation method.
"""
currentColormapChanged = Signal(Colormap)
def __init__(
self,
parent: QWidget | None = None,
*,
categories: Container[Category] = (),
prefer_short_names: bool = True,
interpolation: Interpolation | None = None,
) -> None:
super().__init__(parent)
# get valid names according to preferences
word_list = sorted(
Colormap.catalog().unique_keys(
prefer_short_names=prefer_short_names,
categories=categories,
interpolation=interpolation,
)
)
# initialize the combobox
self.addItems(word_list)
self.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
self.setEditable(True)
self.setDuplicatesEnabled(False)
# (must come before setCompleter)
self.setLineEdit(QColormapLineEdit(self))
# setup the completer
completer = QCompleter(word_list)
completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
completer.setFilterMode(Qt.MatchFlag.MatchContains)
completer.setModel(self.model())
self.setCompleter(completer)
# set the delegate for both the popup and the combobox
delegate = QColormapItemDelegate()
if popup := completer.popup():
popup.setItemDelegate(delegate)
self.setItemDelegate(delegate)
self.currentTextChanged.connect(self._on_text_changed)
def currentColormap(self) -> Colormap | None:
"""Returns the currently selected Colormap or None if not yet selected."""
return try_cast_colormap(self.currentText())
def keyPressEvent(self, e: QKeyEvent | None) -> None:
if e and e.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return):
# select the first completion when pressing enter if the popup is visible
if (completer := self.completer()) and completer.completionCount():
self.lineEdit().setText(completer.currentCompletion()) # type: ignore
return super().keyPressEvent(e)
def _on_text_changed(self, text: str) -> None:
if (cmap := try_cast_colormap(text)) is not None:
self.currentColormapChanged.emit(cmap)

View File

@@ -0,0 +1,319 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from cmap import Colormap
from qtpy.QtCore import QSortFilterProxyModel, QStringListModel, Qt, Signal
from qtpy.QtWidgets import (
QButtonGroup,
QCheckBox,
QComboBox,
QCompleter,
QDialog,
QDialogButtonBox,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from superqt.utils import signals_blocked
from ._catalog_combo import CmapCatalogComboBox
from ._cmap_item_delegate import QColormapItemDelegate
from ._cmap_line_edit import QColormapLineEdit
from ._cmap_utils import try_cast_colormap
if TYPE_CHECKING:
from collections.abc import Sequence
from cmap._colormap import ColorStopsLike
from qtpy.QtGui import QKeyEvent
CMAP_ROLE = Qt.ItemDataRole.UserRole + 1
class QColormapComboBox(QComboBox):
"""A drop down menu for selecting colors.
Parameters
----------
parent : QWidget, optional
The parent widget.
allow_user_colormaps : bool, optional
Whether the user can add custom colormaps by clicking the "Add
Colormap..." item. Default is False. Can also be set with
`setUserAdditionsAllowed`.
add_colormap_text: str, optional
The text to display for the "Add Colormap..." item.
Default is "Add Colormap...".
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)
def __init__(
self,
parent: QWidget | None = None,
*,
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
line_edit = _PopupColormapLineEdit(self, allow_invalid=False)
self.setLineEdit(line_edit)
self.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
self.setItemDelegate(QColormapItemDelegate(self))
# there's a little bit of a potential bug here:
# if the user clicks on the "Add Colormap..." item
# then an indexChanged signal will be emitted, but it may not
# actually represent a "true" change in the index if they dismiss the dialog
self.activated.connect(self._on_activated)
self.setUserAdditionsAllowed(allow_user_colormaps)
# 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
def setUserAdditionsAllowed(self, allow: bool) -> None:
"""Sets whether the user can add custom colors.
If enabled, an "Add Colormap..." item will be added to the end of the
list. When clicked, a dialog will be shown to allow the user to select
a colormap from the
[cmap catalog](https://cmap-docs.readthedocs.io/en/latest/catalog/).
"""
self._allow_user_colors = bool(allow)
idx = self.findData(self._add_color_text, Qt.ItemDataRole.DisplayRole)
if idx < 0:
if self._allow_user_colors:
self.addItem(self._add_color_text)
elif not self._allow_user_colors:
self.removeItem(idx)
def 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."""
return self.itemData(index, CMAP_ROLE)
def addColormap(self, cmap: ColorStopsLike) -> None:
"""Adds the colormap to the QComboBox."""
if (_cmap := try_cast_colormap(cmap)) is None:
raise ValueError(f"Invalid colormap value: {cmap!r}")
for i in range(self.count()):
if item := self.itemColormap(i):
if item.name == _cmap.name:
return # no duplicates # pragma: no cover
had_items = self.count() > int(self._allow_user_colors)
# add the new color and set the background color of that item
self.addItem(_cmap.name.rsplit(":", 1)[-1])
self.setItemData(self.count() - 1, _cmap, CMAP_ROLE)
if not had_items: # first item added
self._on_index_changed(self.count() - 1)
# make sure the "Add Colormap..." item is last
idx = self.findData(self._add_color_text, Qt.ItemDataRole.DisplayRole)
if idx >= 0:
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."""
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."""
return self.currentData(CMAP_ROLE)
def setCurrentColormap(self, color: Any) -> None:
"""Adds the color to the QComboBox and selects it."""
if not (cmap := try_cast_colormap(color)):
raise ValueError(f"Invalid colormap value: {color!r}")
for idx in range(self.count()):
if (item := self.itemColormap(idx)) and item.name == cmap.name:
self.setCurrentIndex(idx)
def _on_activated(self, index: int) -> None:
if self.itemText(index) != self._add_color_text:
return
dlg = _CmapNameDialog(self, Qt.WindowType.Sheet)
if dlg.exec() and (cmap := dlg.combo.currentColormap()):
# add the color and select it, without adding duplicates
for i in range(self.count()):
if (item := self.itemColormap(i)) and cmap.name == item.name:
self.setCurrentIndex(i)
return
self.addColormap(cmap)
self.currentIndexChanged.emit(self.currentIndex())
elif self._last_cmap is not None:
# user canceled, restore previous color without emitting signal
idx = self.findData(self._last_cmap, CMAP_ROLE)
if idx >= 0:
with signals_blocked(self):
self.setCurrentIndex(idx)
def _on_index_changed(self, index: int) -> None:
colormap = self.itemData(index, CMAP_ROLE)
if isinstance(colormap, Colormap):
self.currentColormapChanged.emit(colormap)
self.lineEdit().setColormap(colormap)
self._last_cmap = colormap
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")
class _CmapNameDialog(QDialog):
def __init__(self, *args: Any) -> None:
super().__init__(*args)
self.combo = CmapCatalogComboBox()
B = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
btns = QDialogButtonBox(B)
btns.accepted.connect(self.accept)
btns.rejected.connect(self.reject)
layout = QVBoxLayout(self)
layout.addWidget(self.combo)
self._btn_group = QButtonGroup(self)
self._btn_group.setExclusive(False)
for cat in CATEGORIES:
box = QCheckBox(cat)
self._btn_group.addButton(box)
box.setChecked(True)
box.toggled.connect(self._on_check_toggled)
layout.addWidget(box)
layout.addWidget(btns)
self.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Maximum)
self.resize(self.sizeHint())
def _on_check_toggled(self) -> None:
# get valid names according to preferences
word_list = Colormap.catalog().unique_keys(
prefer_short_names=True,
categories={b.text() for b in self._btn_group.buttons() if b.isChecked()},
)
self.combo.clear()
self.combo.addItems(sorted(word_list))
class _PopupColormapLineEdit(QColormapLineEdit):
def mouseReleaseEvent(self, _: Any) -> None:
"""Show parent popup when clicked.
Without this, only the down arrow will show the popup. And if mousePressEvent
is used instead, the popup will show and then immediately hide.
Also ensure that the popup is not shown when the user selects text.
"""
if not self.hasSelectedText():
parent = self.parent()
if parent and hasattr(parent, "showPopup"):
parent.showPopup()

View File

@@ -0,0 +1,109 @@
from __future__ import annotations
from typing import TYPE_CHECKING, cast
from qtpy.QtCore import QModelIndex, QObject, QPersistentModelIndex, QRect, QSize, Qt
from qtpy.QtGui import QColor, QPainter
from qtpy.QtWidgets import QStyle, QStyledItemDelegate, QStyleOptionViewItem
from ._cmap_utils import CMAP_ROLE, draw_colormap, pick_font_color, try_cast_colormap
if TYPE_CHECKING:
from cmap import Colormap
DEFAULT_SIZE = QSize(80, 22)
DEFAULT_BORDER_COLOR = QColor(Qt.GlobalColor.transparent)
class QColormapItemDelegate(QStyledItemDelegate):
"""Delegate that draws colormaps into a QAbstractItemView item.
Parameters
----------
parent : QObject, optional
The parent object.
item_size : QSize, optional
The size hint for each item, by default QSize(80, 22).
fractional_colormap_width : float, optional
The fraction of the widget width to use for the colormap swatch. If the
colormap is full width (greater than 0.75), the swatch will be drawn behind
the text. Otherwise, the swatch will be drawn to the left of the text.
Default is 0.33.
padding : int, optional
The padding (in pixels) around the edge of the item, by default 1.
checkerboard_size : int, optional
Size (in pixels) of the checkerboard pattern to draw behind colormaps with
transparency, by default 4. If 0, no checkerboard is drawn.
"""
def __init__(
self,
parent: QObject | None = None,
*,
item_size: QSize = DEFAULT_SIZE,
fractional_colormap_width: float = 1,
padding: int = 1,
checkerboard_size: int = 4,
) -> None:
super().__init__(parent)
self._item_size = item_size
self._colormap_fraction = fractional_colormap_width
self._padding = padding
self._border_color: QColor | None = DEFAULT_BORDER_COLOR
self._checkerboard_size = checkerboard_size
def sizeHint(
self, option: QStyleOptionViewItem, index: QModelIndex | QPersistentModelIndex
) -> QSize:
return super().sizeHint(option, index).expandedTo(self._item_size)
def paint(
self,
painter: QPainter,
option: QStyleOptionViewItem,
index: QModelIndex | QPersistentModelIndex,
) -> None:
self.initStyleOption(option, index)
rect = cast("QRect", option.rect) # type: ignore
selected = option.state & QStyle.StateFlag.State_Selected # type: ignore
text = index.data(Qt.ItemDataRole.DisplayRole)
colormap: Colormap | None = index.data(CMAP_ROLE) or try_cast_colormap(text)
if not colormap: # pragma: no cover
return super().paint(painter, option, index)
painter.save()
rect.adjust(self._padding, self._padding, -self._padding, -self._padding)
cmap_rect = QRect(rect)
cmap_rect.setWidth(int(rect.width() * self._colormap_fraction))
lighter = 110 if selected else 100
border = self._border_color if selected else None
draw_colormap(
painter,
colormap,
cmap_rect,
lighter=lighter,
border_color=border,
checkerboard_size=self._checkerboard_size,
)
# # make new rect with the remaining space
text_rect = QRect(rect)
if self._colormap_fraction > 0.75:
text_align = Qt.AlignmentFlag.AlignCenter
alpha = 230 if selected else 140
text_color = pick_font_color(colormap, alpha=alpha)
else:
text_align = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
text_color = QColor(Qt.GlobalColor.black)
text_rect.adjust(
cmap_rect.width() + self._padding + 4, 0, -self._padding - 2, 0
)
painter.setPen(text_color)
# cast to int works all the way back to Qt 5.12...
# but the enum only works since Qt 5.14
painter.drawText(text_rect, int(text_align), text)
painter.restore()

View File

@@ -0,0 +1,185 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from qtpy.QtCore import QRect, Qt
from qtpy.QtGui import QIcon, QPainter, QPaintEvent, QPalette
from qtpy.QtWidgets import QApplication, QLineEdit, QStyle, QWidget
from ._cmap_utils import draw_colormap, pick_font_color, try_cast_colormap
if TYPE_CHECKING:
from cmap import Colormap
MISSING = QStyle.StandardPixmap.SP_TitleBarContextHelpButton
class QColormapLineEdit(QLineEdit):
"""A QLineEdit that shows a colormap swatch.
When the current text is a valid colormap name from the `cmap` package, a swatch
of the colormap will be shown to the left of the text (if `fractionalColormapWidth`
is less than .75) or behind the text (for when the colormap fills the full width).
If the current text is not a valid colormap name, a swatch of the fallback colormap
will be shown instead (by default, a gray colormap) if `fractionalColormapWidth` is
less than .75.
Parameters
----------
parent : QWidget, optional
The parent widget.
fractional_colormap_width : float, optional
The fraction of the widget width to use for the colormap swatch. If the
colormap is full width (greater than 0.75), the swatch will be drawn behind
the text. Otherwise, the swatch will be drawn to the left of the text.
Default is 0.33.
fallback_cmap : Colormap | str | None, optional
The colormap to use when the current text is not a recognized colormap.
by default "gray".
missing_icon : QIcon | QStyle.StandardPixmap, optional
The icon to show when the current text is not a recognized colormap and
`fractionalColormapWidth` is less than .75. Default is a question mark.
checkerboard_size : int, optional
Size (in pixels) of the checkerboard pattern to draw behind colormaps with
transparency, by default 4. If 0, no checkerboard is drawn.
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__(
self,
parent: QWidget | None = None,
*,
fractional_colormap_width: float = 0.33,
fallback_cmap: Colormap | str | None = "gray",
missing_icon: QIcon | QStyle.StandardPixmap = MISSING,
checkerboard_size: int = 4,
allow_invalid: bool = True,
) -> None:
super().__init__(parent)
self.setFractionalColormapWidth(fractional_colormap_width)
self.setMissingColormap(fallback_cmap)
self._checkerboard_size = checkerboard_size
if isinstance(missing_icon, QStyle.StandardPixmap):
self._missing_icon: QIcon = self.style().standardIcon(missing_icon)
elif isinstance(missing_icon, QIcon):
self._missing_icon = missing_icon
else: # pragma: no cover
raise TypeError("missing_icon must be a QIcon or QStyle.StandardPixmap")
self._cmap: Colormap | None = None # current colormap
self.textChanged.connect(self.setColormap)
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
if self._cmap_is_full_width():
align |= Qt.AlignmentFlag.AlignCenter
else:
align |= Qt.AlignmentFlag.AlignLeft
self.setAlignment(align)
def fractionalColormapWidth(self) -> float:
return self._colormap_fraction
def setMissingColormap(self, cmap: Colormap | str | None) -> None:
self._missing_cmap: Colormap | None = try_cast_colormap(cmap)
def colormap(self) -> Colormap | None:
return self._cmap
def setColormap(self, cmap: Colormap | str | None) -> None:
self._cmap = try_cast_colormap(cmap)
# set self font color to contrast with the colormap
if self._cmap and self._cmap_is_full_width():
text = pick_font_color(self._cmap)
else:
text = QApplication.palette().color(QPalette.ColorRole.Text)
palette = self.palette()
palette.setColor(QPalette.ColorRole.Text, text)
self.setPalette(palette)
def _cmap_is_full_width(self):
return self._colormap_fraction >= 0.75
def _cmap_rect(self) -> QRect:
cmap_rect = self.rect().adjusted(2, 0, 0, 0)
cmap_rect.setWidth(int(cmap_rect.width() * self._colormap_fraction))
return cmap_rect
def resizeEvent(self, e: Any) -> None:
left_margin = 6
if not self._cmap_is_full_width():
# leave room for the colormap
left_margin += self._cmap_rect().width()
self.setTextMargins(left_margin, 2, 0, 0)
super().resizeEvent(e)
def paintEvent(self, e: QPaintEvent) -> None:
# don't draw the background
# otherwise it will cover the colormap during super().paintEvent
# FIXME: this appears to need to be reset during every paint event...
# otherwise something is resetting it
palette = self.palette()
palette.setColor(palette.ColorRole.Base, Qt.GlobalColor.transparent)
self.setPalette(palette)
cmap_rect = self._cmap_rect()
if self._cmap:
draw_colormap(
self, self._cmap, cmap_rect, checkerboard_size=self._checkerboard_size
)
elif not self._cmap_is_full_width():
if self._missing_cmap:
draw_colormap(self, self._missing_cmap, cmap_rect)
self._missing_icon.paint(QPainter(self), cmap_rect.adjusted(4, 4, 0, -4))
super().paintEvent(e) # draw text (must come after draw_colormap)

View File

@@ -0,0 +1,168 @@
from __future__ import annotations
from contextlib import suppress
from typing import TYPE_CHECKING, Any
from cmap import Colormap
from qtpy.QtCore import QPointF, QRect, QRectF, Qt
from qtpy.QtGui import QColor, QLinearGradient, QPaintDevice, QPainter
if TYPE_CHECKING:
from cmap._colormap import ColorStopsLike
CMAP_ROLE = Qt.ItemDataRole.UserRole + 1
def draw_colormap(
painter_or_device: QPainter | QPaintDevice,
cmap: Colormap | ColorStopsLike,
rect: QRect | QRectF | None = None,
border_color: QColor | str | None = None,
border_width: int = 1,
lighter: int = 100,
checkerboard_size: int = 4,
) -> None:
"""Draw a colormap onto a QPainter or QPaintDevice.
Parameters
----------
painter_or_device : QPainter | QPaintDevice
A `QPainter` instance or a `QPaintDevice` (e.g. a QWidget or QPixmap) onto
which to paint the colormap.
cmap : Colormap | Any
`cmap.Colormap` instance, or anything that can be converted to one (such as a
string name of a colormap in the `cmap` catalog).
https://cmap-docs.readthedocs.io/en/latest/colormaps/#colormaplike-objects
rect : QRect | QRectF | None, optional
A rect onto which to draw. If `None`, the `painter.viewport()` will be
used. by default `None`
border_color : QColor | str | None
If not `None`, a border of color `border_color` and width `border_width` is
included around the edge, by default None.
border_width : int, optional
The width of the border to draw (provided `border_color` is not `None`),
by default 2
lighter : int, optional
Percentage by which to lighten (or darken) the colors. Greater than 100
lightens, less than 100 darkens, by default 100 (i.e. no change).
checkerboard_size : bool, optional
Size (in pixels) of the checkerboard pattern to draw, by default 5.
If 0, no checkerboard is drawn.
Examples
--------
```python
from qtpy.QtGui import QPixmap
from qtpy.QtWidgets import QWidget
from superqt.utils import draw_colormap
viridis = "viridis" # or cmap.Colormap('viridis')
class W(QWidget):
def paintEvent(self, event) -> None:
draw_colormap(self, viridis, event.rect())
# or draw onto a QPixmap
pm = QPixmap(200, 200)
draw_colormap(pm, viridis)
```
"""
if isinstance(painter_or_device, QPainter):
painter = painter_or_device
elif isinstance(painter_or_device, QPaintDevice):
painter = QPainter(painter_or_device)
else:
raise TypeError(
"Expected a QPainter or QPaintDevice instance, "
f"got {type(painter_or_device)!r} instead."
)
if (cmap_ := try_cast_colormap(cmap)) is None:
raise TypeError(
f"Expected a Colormap instance or something that can be "
f"converted to one, got {cmap!r} instead."
)
if rect is None:
rect = painter.viewport()
painter.setPen(Qt.PenStyle.NoPen)
if border_width and border_color is not None:
# draw rect, and then contract it by border_width
painter.setPen(QColor(border_color))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(rect)
rect = rect.adjusted(border_width, border_width, -border_width, -border_width)
if checkerboard_size:
_draw_checkerboard(painter, rect, checkerboard_size)
if (
cmap_.interpolation == "nearest"
or getattr(cmap_.color_stops, "_interpolation", "") == "nearest"
):
# XXX: this is a little bit of a hack.
# when the interpolation is nearest, the last stop is often at 1.0
# which means that the last color is not drawn.
# to fix this, we shrink the drawing area slightly
# it might not work well with unenvenly-spaced stops
# (but those are uncommon for categorical colormaps)
width = rect.width() - rect.width() / len(cmap_.color_stops)
for stop in cmap_.color_stops:
painter.setBrush(QColor(stop.color.hex).lighter(lighter))
painter.drawRect(rect.adjusted(int(stop.position * width), 0, 0, 0))
else:
gradient = QLinearGradient(QPointF(rect.topLeft()), QPointF(rect.topRight()))
for stop in cmap_.color_stops:
gradient.setColorAt(stop.position, QColor(stop.color.hex).lighter(lighter))
painter.setBrush(gradient)
painter.drawRect(rect)
# 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
) -> None:
darkgray = QColor("#969696")
lightgray = QColor("#C8C8C8")
sz = checker_size
h, w = rect.height(), rect.width()
left, top = rect.left(), rect.top()
full_rows = h // sz
full_cols = w // sz
for row in range(int(full_rows) + 1):
szh = sz if row < full_rows else int(h % sz)
for col in range(int(full_cols) + 1):
szw = sz if col < full_cols else int(w % sz)
color = lightgray if (row + col) % 2 == 0 else darkgray
painter.fillRect(int(col * sz + left), int(row * sz + top), szw, szh, color)
def try_cast_colormap(val: Any) -> Colormap | None:
"""Try to cast `val` to a Colormap instance, return None if it fails."""
if isinstance(val, Colormap):
return val
with suppress(Exception):
return Colormap(val)
return None
def pick_font_color(cmap: Colormap, at_stop: float = 0.49, alpha: int = 255) -> QColor:
"""Pick a font shade that contrasts with the given colormap at `at_stop`."""
if _is_dark(cmap, at_stop):
return QColor(0, 0, 0, alpha)
else:
return QColor(255, 255, 255, alpha)
def _is_dark(cmap: Colormap, at_stop: float, threshold: float = 110) -> bool:
"""Return True if the color at `at_stop` is dark according to `threshold`."""
color = cmap(at_stop)
r, g, b, a = color.rgba8
return (r * 0.299 + g * 0.587 + b * 0.114) > threshold

View File

@@ -1,33 +1,56 @@
"""A collapsible widget to hide and unhide child widgets"""
from typing import Optional
"""A collapsible widget to hide and unhide child widgets."""
from qtpy.QtCore import QEasingCurve, QEvent, QMargins, QObject, QPropertyAnimation, Qt
from qtpy.QtWidgets import QFrame, QPushButton, QVBoxLayout, QWidget
from __future__ import annotations
from qtpy.QtCore import (
QEasingCurve,
QEvent,
QMargins,
QObject,
QPropertyAnimation,
QRect,
Qt,
Signal,
)
from qtpy.QtGui import QIcon, QPainter, QPalette, QPixmap
from qtpy.QtWidgets import QFrame, QPushButton, QSizePolicy, QVBoxLayout, QWidget
class QCollapsible(QFrame):
"""A collapsible widget to hide and unhide child widgets.
Based on [https://stackoverflow.com/a/68141638](https://stackoverflow.com/a/68141638)
A signal is emitted when the widget is expanded (True) or collapsed (False).
Based on https://stackoverflow.com/a/68141638
"""
_EXPANDED = ""
_COLLAPSED = ""
toggled = Signal(bool)
def __init__(self, title: str = "", parent: Optional[QWidget] = None):
def __init__(
self,
title: str = "",
parent: QWidget | None = None,
expandedIcon: QIcon | str | None = "",
collapsedIcon: QIcon | str | None = "",
):
super().__init__(parent)
self._locked = False
self._is_animating = False
self._text = title
self._toggle_btn = QPushButton(self._COLLAPSED + title)
self._toggle_btn = QPushButton(title)
self._toggle_btn.setCheckable(True)
self.setCollapsedIcon(icon=collapsedIcon)
self.setExpandedIcon(icon=expandedIcon)
self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
self._toggle_btn.setStyleSheet("text-align: left; border: none; outline: none;")
self._toggle_btn.toggled.connect(self._toggle)
# frame layout
self.setLayout(QVBoxLayout())
self.layout().setAlignment(Qt.AlignmentFlag.AlignTop)
self.layout().addWidget(self._toggle_btn)
layout = QVBoxLayout(self)
layout.setAlignment(Qt.AlignmentFlag.AlignTop)
layout.addWidget(self._toggle_btn)
# Create animators
self._animation = QPropertyAnimation(self)
@@ -44,16 +67,19 @@ class QCollapsible(QFrame):
_content.layout().setContentsMargins(QMargins(5, 0, 0, 0))
self.setContent(_content)
def setText(self, text: str):
def toggleButton(self) -> QPushButton:
"""Return the toggle button."""
return self._toggle_btn
def setText(self, text: str) -> None:
"""Set the text of the toggle button."""
current = self._toggle_btn.text()[: len(self._EXPANDED)]
self._toggle_btn.setText(current + text)
self._toggle_btn.setText(text)
def text(self) -> str:
"""Return the text of the toggle button."""
return self._toggle_btn.text()[len(self._EXPANDED) :]
return self._toggle_btn.text()
def setContent(self, content: QWidget):
def setContent(self, content: QWidget) -> None:
"""Replace central widget (the widget that gets expanded/collapsed)."""
self._content = content
self.layout().addWidget(self._content)
@@ -63,56 +89,104 @@ class QCollapsible(QFrame):
"""Return the current content widget."""
return self._content
def setDuration(self, msecs: int):
def _convert_string_to_icon(self, symbol: str) -> QIcon:
"""Create a QIcon from a string."""
size = self._toggle_btn.font().pointSize()
pixmap = QPixmap(size, size)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
color = self._toggle_btn.palette().color(QPalette.ColorRole.WindowText)
painter.setPen(color)
painter.drawText(QRect(0, 0, size, size), Qt.AlignmentFlag.AlignCenter, symbol)
painter.end()
return QIcon(pixmap)
def expandedIcon(self) -> QIcon:
"""Returns the icon used when the widget is expanded."""
return self._expanded_icon
def setExpandedIcon(self, icon: QIcon | str | None = None) -> None:
"""Set the icon on the toggle button when the widget is expanded."""
if icon and isinstance(icon, QIcon):
self._expanded_icon = icon
elif icon and isinstance(icon, str):
self._expanded_icon = self._convert_string_to_icon(icon)
if self.isExpanded():
self._toggle_btn.setIcon(self._expanded_icon)
def collapsedIcon(self) -> QIcon:
"""Returns the icon used when the widget is collapsed."""
return self._collapsed_icon
def setCollapsedIcon(self, icon: QIcon | str | None = None) -> None:
"""Set the icon on the toggle button when the widget is collapsed."""
if icon and isinstance(icon, QIcon):
self._collapsed_icon = icon
elif icon and isinstance(icon, str):
self._collapsed_icon = self._convert_string_to_icon(icon)
if not self.isExpanded():
self._toggle_btn.setIcon(self._collapsed_icon)
def setDuration(self, msecs: int) -> None:
"""Set duration of the collapse/expand animation."""
self._animation.setDuration(msecs)
def setEasingCurve(self, easing: QEasingCurve):
"""Set the easing curve for the collapse/expand animation"""
def setEasingCurve(self, easing: QEasingCurve | QEasingCurve.Type) -> None:
"""Set the easing curve for the collapse/expand animation."""
self._animation.setEasingCurve(easing)
def addWidget(self, widget: QWidget):
def addWidget(self, widget: QWidget) -> None:
"""Add a widget to the central content widget's layout."""
widget.installEventFilter(self)
self._content.layout().addWidget(widget)
def removeWidget(self, widget: QWidget):
def removeWidget(self, widget: QWidget) -> None:
"""Remove widget from the central content widget's layout."""
self._content.layout().removeWidget(widget)
widget.removeEventFilter(self)
def expand(self, animate: bool = True):
"""Expand (show) the collapsible section"""
def expand(self, animate: bool = True) -> None:
"""Expand (show) the collapsible section."""
self._expand_collapse(QPropertyAnimation.Direction.Forward, animate)
def collapse(self, animate: bool = True):
"""Collapse (hide) the collapsible section"""
def collapse(self, animate: bool = True) -> None:
"""Collapse (hide) the collapsible section."""
self._expand_collapse(QPropertyAnimation.Direction.Backward, animate)
def isExpanded(self) -> bool:
"""Return whether the collapsible section is visible"""
"""Return whether the collapsible section is visible."""
return self._toggle_btn.isChecked()
def setLocked(self, locked: bool = True):
"""Set whether collapse/expand is disabled"""
def setLocked(self, locked: bool = True) -> None:
"""Set whether collapse/expand is disabled."""
self._locked = locked
self._toggle_btn.setCheckable(not locked)
def locked(self) -> bool:
"""Return True if collapse/expand is disabled"""
"""Return True if collapse/expand is disabled."""
return self._locked
def _expand_collapse(
self, direction: QPropertyAnimation.Direction, animate: bool = True
):
self,
direction: QPropertyAnimation.Direction,
animate: bool = True,
emit: bool = True,
) -> None:
"""Set values for the widget based on whether it is expanding or collapsing.
An emit flag is included so that the toggle signal is only called once (it
was being emitted a few times via eventFilter when the widget was expanding
previously).
"""
if self._locked:
return
forward = direction == QPropertyAnimation.Direction.Forward
text = self._EXPANDED if forward else self._COLLAPSED
icon = self._expanded_icon if forward else self._collapsed_icon
self._toggle_btn.setIcon(icon)
self._toggle_btn.setChecked(forward)
self._toggle_btn.setText(text + self._toggle_btn.text()[len(self._EXPANDED) :])
_content_height = self._content.sizeHint().height() + 10
if animate:
@@ -122,8 +196,10 @@ class QCollapsible(QFrame):
self._animation.start()
else:
self._content.setMaximumHeight(_content_height if forward else 0)
if emit:
self.toggled.emit(direction == QPropertyAnimation.Direction.Forward)
def _toggle(self):
def _toggle(self) -> None:
self.expand() if self.isExpanded() else self.collapse()
def eventFilter(self, a0: QObject, a1: QEvent) -> bool:
@@ -133,8 +209,10 @@ class QCollapsible(QFrame):
and self.isExpanded()
and not self._is_animating
):
self._expand_collapse(QPropertyAnimation.Direction.Forward, animate=False)
self._expand_collapse(
QPropertyAnimation.Direction.Forward, animate=False, emit=False
)
return False
def _on_animation_done(self):
def _on_animation_done(self) -> None:
self._is_animating = False

View File

@@ -1,4 +1,24 @@
from typing import TYPE_CHECKING, Any
from ._color_combobox import QColorComboBox
from ._enum_combobox import QEnumComboBox
from ._searchable_combo_box import QSearchableComboBox
__all__ = ("QEnumComboBox", "QSearchableComboBox")
__all__ = (
"QColorComboBox",
"QColormapComboBox",
"QEnumComboBox",
"QSearchableComboBox",
)
if TYPE_CHECKING:
from superqt.cmap import QColormapComboBox
def __getattr__(name: str) -> Any: # pragma: no cover
if name == "QColormapComboBox":
from superqt.cmap import QColormapComboBox
return QColormapComboBox
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View File

@@ -0,0 +1,290 @@
from __future__ import annotations
import warnings
from contextlib import suppress
from enum import IntEnum, auto
from typing import TYPE_CHECKING, Any, Literal, cast
from qtpy.QtCore import QModelIndex, QPersistentModelIndex, QRect, QSize, Qt, Signal
from qtpy.QtGui import QColor, QPainter
from qtpy.QtWidgets import (
QAbstractItemDelegate,
QColorDialog,
QComboBox,
QLineEdit,
QStyle,
QStyleOptionViewItem,
QWidget,
)
from superqt.utils import signals_blocked
if TYPE_CHECKING:
from collections.abc import Sequence
_NAME_MAP = {QColor(x).name(): x for x in QColor.colorNames()}
COLOR_ROLE = Qt.ItemDataRole.BackgroundRole
class InvalidColorPolicy(IntEnum):
"""Policy for handling invalid colors."""
Ignore = auto()
Warn = auto()
Raise = auto()
class _ColorComboLineEdit(QLineEdit):
"""A read-only line edit that shows the parent ComboBox popup when clicked."""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setReadOnly(True)
# hide any original text
self.setStyleSheet("color: transparent")
self.setText("")
def mouseReleaseEvent(self, _: Any) -> None:
"""Show parent popup when clicked.
Without this, only the down arrow will show the popup. And if mousePressEvent
is used instead, the popup will show and then immediately hide.
"""
parent = self.parent()
if hasattr(parent, "showPopup"):
parent.showPopup()
class _ColorComboItemDelegate(QAbstractItemDelegate):
"""Delegate that draws color squares in the ComboBox.
This provides more control than simply setting various data roles on the item,
and makes for a nicer appearance. Importantly, it prevents the color from being
obscured on hover.
"""
def sizeHint(
self, option: QStyleOptionViewItem, index: QModelIndex | QPersistentModelIndex
) -> QSize:
return QSize(20, 20)
def paint(
self,
painter: QPainter,
option: QStyleOptionViewItem,
index: QModelIndex | QPersistentModelIndex,
) -> None:
color: QColor | None = index.data(COLOR_ROLE)
rect = cast("QRect", option.rect) # type: ignore
state = cast("QStyle.StateFlag", option.state) # type: ignore
selected = state & QStyle.StateFlag.State_Selected
border = QColor("lightgray")
if not color:
# not a color square, just draw the text
text_color = Qt.GlobalColor.black if selected else Qt.GlobalColor.gray
painter.setPen(text_color)
text = index.data(Qt.ItemDataRole.DisplayRole)
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, text)
return
# slightly larger border for rect
pen = painter.pen()
pen.setWidth(2)
pen.setColor(border)
painter.setPen(pen)
if selected:
# if hovering, give a slight highlight and draw the color name
painter.setBrush(color.lighter(110))
painter.drawRect(rect)
# use user friendly color name if available
name = _NAME_MAP.get(color.name(), color.name())
painter.setPen(_pick_font_color(color))
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, name)
else: # not hovering
painter.setBrush(color)
painter.drawRect(rect)
class QColorComboBox(QComboBox):
"""A drop down menu for selecting colors.
Parameters
----------
parent : QWidget, optional
The parent widget.
allow_user_colors : bool, optional
Whether to show an "Add Color" item that opens a QColorDialog when clicked.
Whether the user can add custom colors by clicking the "Add Color" item.
Default is False. Can also be set with `setUserColorsAllowed`.
add_color_text: str, optional
The text to display for the "Add Color" item. Default is "Add Color...".
"""
currentColorChanged = Signal(QColor)
def __init__(
self,
parent: QWidget | None = None,
*,
allow_user_colors: bool = False,
add_color_text: str = "Add Color...",
) -> None:
# init QComboBox
super().__init__(parent)
self._invalid_policy: InvalidColorPolicy = InvalidColorPolicy.Ignore
self._add_color_text: str = add_color_text
self._allow_user_colors: bool = allow_user_colors
self._last_color: QColor = QColor()
self.setLineEdit(_ColorComboLineEdit(self))
self.setItemDelegate(_ColorComboItemDelegate())
self.currentIndexChanged.connect(self._on_index_changed)
self.activated.connect(self._on_activated)
self.setUserColorsAllowed(allow_user_colors)
def setInvalidColorPolicy(
self, policy: InvalidColorPolicy | int | Literal["Raise", "Ignore", "Warn"]
) -> None:
"""Sets the policy for handling invalid colors."""
if isinstance(policy, str):
policy = InvalidColorPolicy[policy]
elif isinstance(policy, int):
policy = InvalidColorPolicy(policy)
elif not isinstance(policy, InvalidColorPolicy):
raise TypeError(f"Invalid policy type: {type(policy)!r}")
self._invalid_policy = policy
def invalidColorPolicy(self) -> InvalidColorPolicy:
"""Returns the policy for handling invalid colors."""
return self._invalid_policy
InvalidColorPolicy = InvalidColorPolicy
def userColorsAllowed(self) -> bool:
"""Returns whether the user can add custom colors."""
return self._allow_user_colors
def setUserColorsAllowed(self, allow: bool) -> None:
"""Sets whether the user can add custom colors."""
self._allow_user_colors = bool(allow)
idx = self.findData(self._add_color_text, Qt.ItemDataRole.DisplayRole)
if idx < 0:
if self._allow_user_colors:
self.addItem(self._add_color_text)
elif not self._allow_user_colors:
self.removeItem(idx)
def clear(self) -> None:
"""Clears the QComboBox of all entries (leaves "Add colors" if enabled)."""
super().clear()
self.setUserColorsAllowed(self._allow_user_colors)
def addColor(self, color: Any) -> None:
"""Adds the color to the QComboBox."""
_color = _cast_color(color)
if not _color.isValid():
if self._invalid_policy == InvalidColorPolicy.Raise:
raise ValueError(f"Invalid color: {color!r}")
elif self._invalid_policy == InvalidColorPolicy.Warn:
warnings.warn(f"Ignoring invalid color: {color!r}", stacklevel=2)
return
c = self.currentColor()
if self.findData(_color) > -1: # avoid duplicates
return
# add the new color and set the background color of that item
self.addItem("", _color)
self.setItemData(self.count() - 1, _color, COLOR_ROLE)
if not c or not c.isValid():
self._on_index_changed(self.count() - 1)
# make sure the "Add Color" item is last
idx = self.findData(self._add_color_text, Qt.ItemDataRole.DisplayRole)
if idx >= 0:
with signals_blocked(self):
self.removeItem(idx)
self.addItem(self._add_color_text)
def itemColor(self, index: int) -> QColor | None:
"""Returns the color of the item at the given index."""
return self.itemData(index, COLOR_ROLE)
def addColors(self, colors: Sequence[Any]) -> None:
"""Adds colors to the QComboBox."""
for color in colors:
self.addColor(color)
def currentColor(self) -> QColor | None:
"""Returns the currently selected QColor or None if not yet selected."""
return self.currentData(COLOR_ROLE)
def setCurrentColor(self, color: Any) -> None:
"""Adds the color to the QComboBox and selects it."""
idx = self.findData(_cast_color(color), COLOR_ROLE)
if idx >= 0:
self.setCurrentIndex(idx)
def currentColorName(self) -> str | None:
"""Returns the name of the currently selected QColor or black if None."""
color = self.currentColor()
return color.name() if color else "#000000"
def _on_activated(self, index: int) -> None:
if self.itemText(index) != self._add_color_text:
return
# show temporary text while dialog is open
self.lineEdit().setStyleSheet("background-color: white; color: gray;")
self.lineEdit().setText("Pick a Color ...")
try:
color = QColorDialog.getColor()
finally:
self.lineEdit().setText("")
if color.isValid():
# add the color and select it
self.addColor(color)
elif self._last_color.isValid():
# user canceled, restore previous color without emitting signal
idx = self.findData(self._last_color, COLOR_ROLE)
if idx >= 0:
with signals_blocked(self):
self.setCurrentIndex(idx)
hex_ = self._last_color.name()
self.lineEdit().setStyleSheet(f"background-color: {hex_};")
return
def _on_index_changed(self, index: int) -> None:
color = self.itemData(index, COLOR_ROLE)
if isinstance(color, QColor):
self.lineEdit().setStyleSheet(f"background-color: {color.name()};")
self.currentColorChanged.emit(color)
self._last_color = color
def _cast_color(val: Any) -> QColor:
with suppress(TypeError):
color = QColor(val)
if color.isValid():
return color
if isinstance(val, (tuple, list)):
with suppress(TypeError):
color = QColor(*val)
if color.isValid():
return color
return QColor()
def _pick_font_color(color: QColor) -> QColor:
"""Pick a font shade that contrasts with the given color."""
if (color.red() * 0.299 + color.green() * 0.587 + color.blue() * 0.114) > 80:
return QColor(0, 0, 0, 128)
else:
return QColor(255, 255, 255, 128)

View File

@@ -1,4 +1,8 @@
from enum import Enum, EnumMeta
import sys
from enum import Enum, EnumMeta, Flag
from functools import reduce
from itertools import combinations
from operator import or_
from typing import Optional, TypeVar
from qtpy.QtCore import Signal
@@ -11,21 +15,44 @@ NONE_STRING = "----"
def _get_name(enum_value: Enum):
"""Create human readable name if user does not provide own implementation of __str__"""
if (
enum_value.__str__.__module__ != "enum"
and not enum_value.__str__.__module__.startswith("shibokensupport")
):
"""Create human readable name if user does not implement `__str__`."""
str_module = getattr(enum_value.__str__, "__module__", "enum")
if str_module != "enum" and not str_module.startswith("shibokensupport"):
# check if function was overloaded
name = str(enum_value)
else:
name = enum_value.name.replace("_", " ")
if enum_value.name is None:
# This is hack for python bellow 3.11
if not isinstance(enum_value, Flag):
raise TypeError(
f"Expected Flag instance, got {enum_value}"
) # pragma: no cover
if sys.version_info >= (3, 11):
# There is a bug in some releases of Python 3.11 (for example 3.11.3)
# that leads to wrong evaluation of or operation on Flag members
# and produces numeric value without proper set name property.
return f"{enum_value.value}"
# Before python 3.11 there is no smart name set during
# the creation of Flag members.
# We needs to decompose the value to get the name.
# It is under if condition because it uses private API.
from enum import _decompose
members, not_covered = _decompose(enum_value.__class__, enum_value.value)
name = "|".join(m.name.replace("_", " ") for m in members[::-1])
else:
name = enum_value.name.replace("_", " ")
return name
def _get_name_with_value(enum_value: Enum) -> tuple[str, Enum]:
return _get_name(enum_value), enum_value
class QEnumComboBox(QComboBox):
"""
ComboBox presenting options from a python Enum.
"""ComboBox presenting options from a python Enum.
If the Enum class does not implement `__str__` then a human readable name
is created from the name of the enum member, replacing underscores with spaces.
@@ -44,22 +71,33 @@ class QEnumComboBox(QComboBox):
self.currentIndexChanged.connect(self._emit_signal)
def setEnumClass(self, enum: Optional[EnumMeta], allow_none=False):
"""
Set enum class from which members value should be selected
"""
"""Set enum class from which members value should be selected."""
self.clear()
self._enum_class = enum
self._allow_none = allow_none and enum is not None
if allow_none:
super().addItem(NONE_STRING)
super().addItems(list(map(_get_name, self._enum_class.__members__.values())))
names_ = self._get_enum_member_list(enum)
super().addItems(list(names_))
@staticmethod
def _get_enum_member_list(enum: Optional[EnumMeta]):
if issubclass(enum, Flag):
members = list(enum.__members__.values())
comb_list = []
for i in range(len(members)):
comb_list.extend(reduce(or_, x) for x in combinations(members, i + 1))
else:
comb_list = list(enum.__members__.values())
return dict(map(_get_name_with_value, comb_list))
def enumClass(self) -> Optional[EnumMeta]:
"""return current Enum class"""
"""Return current Enum class."""
return self._enum_class
def isOptional(self) -> bool:
"""return if current enum is with optional annotation"""
"""Return if current enum is with optional annotation."""
return self._allow_none
def clear(self):
@@ -68,16 +106,12 @@ class QEnumComboBox(QComboBox):
super().clear()
def currentEnum(self) -> Optional[EnumType]:
"""current value as Enum member"""
"""Current value as Enum member."""
if self._enum_class is not None:
if self._allow_none:
if self.currentText() == NONE_STRING:
return None
else:
return list(self._enum_class.__members__.values())[
self.currentIndex() - 1
]
return list(self._enum_class.__members__.values())[self.currentIndex()]
return self._get_enum_member_list(self._enum_class)[self.currentText()]
return None
def setCurrentEnum(self, value: Optional[EnumType]) -> None:
@@ -91,7 +125,8 @@ class QEnumComboBox(QComboBox):
return
if not isinstance(value, self._enum_class):
raise TypeError(
f"setValue(self, Enum): argument 1 has unexpected type {type(value).__name__!r}"
"setValue(self, Enum): argument 1 has unexpected type "
f"{type(value).__name__!r}"
)
self.setCurrentText(_get_name(value))

View File

@@ -1,6 +1,8 @@
from typing import Optional
from qtpy import QT_VERSION
from qtpy.QtCore import Qt, Signal
from qtpy.QtWidgets import QComboBox, QCompleter
from qtpy.QtWidgets import QComboBox, QCompleter, QWidget
try:
is_qt_bellow_5_14 = tuple(int(x) for x in QT_VERSION.split(".")[:2]) < (5, 14)
@@ -9,14 +11,12 @@ except ValueError:
class QSearchableComboBox(QComboBox):
"""
ComboCox with completer for fast search in multiple options
"""
"""ComboCox with completer for fast search in multiple options."""
if is_qt_bellow_5_14:
textActivated = Signal(str) # pragma: no cover
def __init__(self, parent=None):
def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent)
self.setEditable(True)
self.completer_object = QCompleter()

View File

@@ -0,0 +1,4 @@
from ._eliding_label import QElidingLabel
from ._eliding_line_edit import QElidingLineEdit
__all__ = ["QElidingLabel", "QElidingLineEdit"]

View File

@@ -0,0 +1,76 @@
from qtpy.QtCore import Qt
from qtpy.QtGui import QFont, QFontMetrics, QTextLayout
class _GenericEliding:
"""A mixin to provide capabilities to elide text (could add '') to fit width."""
_elide_mode: Qt.TextElideMode = Qt.TextElideMode.ElideRight
_text: str = ""
# the 2 is a magic number that prevents the ellipses from going missing
# in certain cases (?)
_ellipses_width: int = 2
# Public methods
def elideMode(self) -> Qt.TextElideMode:
"""The current Qt.TextElideMode."""
return self._elide_mode
def setElideMode(self, mode: Qt.TextElideMode) -> None:
"""Set the elide mode to a Qt.TextElideMode."""
self._elide_mode = Qt.TextElideMode(mode)
def full_text(self) -> str:
"""The current text without eliding."""
return self._text
def setEllipsesWidth(self, width: int) -> None:
"""A width value to take into account ellipses width when eliding text.
The value is deducted from the widget width when computing the elided version
of the text.
"""
self._ellipses_width = width
@staticmethod
def wrapText(text, width, font=None) -> list[str]:
"""Returns `text`, split as it would be wrapped for `width`, given `font`.
Static method.
"""
tl = QTextLayout(text, font or QFont())
tl.beginLayout()
lines = []
while True:
ln = tl.createLine()
if not ln.isValid():
break
ln.setLineWidth(width)
start = ln.textStart()
lines.append(text[start : start + ln.textLength()])
tl.endLayout()
return lines
# private implementation methods
def _elidedText(self) -> str:
"""Return `self._text` elided to `width`."""
fm = QFontMetrics(self.font())
ellipses_width = 0
if self._elide_mode != Qt.TextElideMode.ElideNone:
ellipses_width = self._ellipses_width
width = self.width() - ellipses_width
if not getattr(self, "wordWrap", None) or not self.wordWrap():
return fm.elidedText(self._text, self._elide_mode, width)
# get number of lines we can fit without eliding
nlines = self.height() // fm.height() - 1
# get the last line (elided)
text = self._wrappedText()
last_line = fm.elidedText("".join(text[nlines:]), self._elide_mode, width)
# join them
return "".join([*text[:nlines], last_line])
def _wrappedText(self) -> list[str]:
return _GenericEliding.wrapText(self._text, self.width(), self.font())

View File

@@ -0,0 +1,82 @@
from qtpy.QtCore import QPoint, QRect, QSize, Qt
from qtpy.QtGui import QFontMetrics, QResizeEvent
from qtpy.QtWidgets import QLabel
from ._eliding import _GenericEliding
class QElidingLabel(_GenericEliding, QLabel):
"""
A QLabel variant that will elide text (could add '') to fit width.
QElidingLabel()
QElidingLabel(parent: Optional[QWidget], f: Qt.WindowFlags = ...)
QElidingLabel(text: str, parent: Optional[QWidget] = None, f: Qt.WindowFlags = ...)
For a multiline eliding label, use `setWordWrap(True)`. In this case, text
will wrap to fit the width, and only the last line will be elided.
When `wordWrap()` is True, `sizeHint()` will return the size required to fit
the full text.
"""
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
if args and isinstance(args[0], str):
self.setText(args[0])
# Reimplemented _GenericEliding methods
def setElideMode(self, mode: Qt.TextElideMode) -> None:
"""Set the elide mode to a Qt.TextElideMode."""
super().setElideMode(mode)
super().setText(self._elidedText())
def setEllipsesWidth(self, width: int) -> None:
"""A width value to take into account ellipses width when eliding text.
The value is deducted from the widget width when computing the elided version
of the text.
"""
super().setEllipsesWidth(width)
super().setText(self._elidedText())
# Reimplemented QT methods
def text(self) -> str:
"""Return the label's text.
If no text has been set this will return an empty string.
"""
return self._text
def setText(self, txt: str) -> None:
"""Set the label's text.
Setting the text clears any previous content.
NOTE: we set the QLabel private text to the elided version
"""
self._text = txt
super().setText(self._elidedText())
def resizeEvent(self, event: QResizeEvent) -> None:
event.accept()
super().setText(self._elidedText())
def setWordWrap(self, wrap: bool) -> None:
super().setWordWrap(wrap)
super().setText(self._elidedText())
def sizeHint(self) -> QSize:
if not self.wordWrap():
return super().sizeHint()
fm = QFontMetrics(self.font())
flags = int(self.alignment() | Qt.TextFlag.TextWordWrap)
r = fm.boundingRect(QRect(QPoint(0, 0), self.size()), flags, self._text)
return QSize(self.width(), r.height())
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

@@ -0,0 +1,91 @@
from qtpy.QtCore import Qt
from qtpy.QtGui import QFocusEvent, QResizeEvent
from qtpy.QtWidgets import QLineEdit
from ._eliding import _GenericEliding
class QElidingLineEdit(_GenericEliding, QLineEdit):
"""A QLineEdit variant that will elide text (could add '') to fit width.
QElidingLineEdit()
QElidingLineEdit(parent: Optional[QWidget])
QElidingLineEdit(text: str, parent: Optional[QWidget] = None)
"""
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
if args and isinstance(args[0], str):
self.setText(args[0])
# The `textEdited` signal doesn't trigger the `textChanged` signal if
# text is changed with `setText`, so we connect to `textEdited` to only
# update _text when text is being edited by the user graphically.
self.textEdited.connect(self._update_text)
# Reimplemented _GenericEliding methods
def setElideMode(self, mode: Qt.TextElideMode) -> None:
"""Set the elide mode to a Qt.TextElideMode.
The text shown is updated to the elided version only if the widget is not
focused.
"""
super().setElideMode(mode)
if not self.hasFocus():
super().setText(self._elidedText())
def setEllipsesWidth(self, width: int) -> None:
"""A width value to take into account ellipses width when eliding text.
The value is deducted from the widget width when computing the elided version
of the text. The text shown is updated to the elided version only if the widget
is not focused.
"""
super().setEllipsesWidth(width)
if not self.hasFocus():
super().setText(self._elidedText())
# Reimplemented QT methods
def text(self) -> str:
"""Return the label's text being shown.
If no text has been set this will return an empty string.
"""
return self._text
def setText(self, text) -> None:
"""Set the line edit's text.
Setting the text clears any previous content.
NOTE: we set the QLineEdit private text to the elided version
"""
self._text = text
if not self.hasFocus():
super().setText(self._elidedText())
def focusInEvent(self, event: QFocusEvent) -> None:
"""Set the full text when the widget is focused."""
super().setText(self._text)
super().focusInEvent(event)
def focusOutEvent(self, event: QFocusEvent) -> None:
"""Set an elided version of the text (if needed) when the focus is out."""
super().setText(self._elidedText())
super().focusOutEvent(event)
def resizeEvent(self, event: QResizeEvent) -> None:
"""Update elided text being shown when the widget is resized."""
if not self.hasFocus():
super().setText(self._elidedText())
super().resizeEvent(event)
# private implementation methods
def _update_text(self, text: str) -> None:
"""Update only the actual text of the widget.
The actual text is the text the widget has without eliding.
"""
self._text = text

View File

@@ -1,20 +1,21 @@
from __future__ import annotations
__all__ = [
"addFont",
"Animation",
"ENTRY_POINT",
"font",
"icon",
"Animation",
"IconFont",
"IconFontMeta",
"IconOpts",
"QIconifyIcon",
"addFont",
"font",
"icon",
"pulse",
"setTextIcon",
"spin",
]
from typing import TYPE_CHECKING, Dict, Optional, Tuple, Union
from typing import TYPE_CHECKING
from ._animations import Animation, pulse, spin
from ._iconfont import IconFont, IconFontMeta
@@ -39,20 +40,21 @@ ENTRY_POINT = _FIM.ENTRY_POINT
def icon(
glyph_key: str,
scale_factor: float = DEFAULT_SCALING_FACTOR,
color: ValidColor = None,
color: ValidColor | None = None,
opacity: float = 1,
animation: Optional[Animation] = None,
transform: Optional[QTransform] = None,
states: Dict[str, Union[IconOptionDict, IconOpts]] = {},
animation: Animation | None = None,
transform: QTransform | None = None,
states: dict[str, IconOptionDict | IconOpts] | None = None,
) -> QFontIcon:
"""Create a QIcon for `glyph_key`, with a number of optional settings
"""Create a QIcon for `glyph_key`, with a number of optional settings.
The `glyph_key` (e.g. 'fa5s.smile') represents a Font-family & style, and a glpyh.
The `glyph_key` (e.g. 'fa5s.smile') represents a Font-family & style, and a glyph.
In most cases, the key should be provided by a plugin in the environment, like:
- [fonticon-fontawesome5](https://pypi.org/project/fonticon-fontawesome5/) ('fa5s' & 'fa5r' prefixes)
- [fonticon-materialdesignicons6](https://pypi.org/project/fonticon-materialdesignicons6/) ('mdi6' prefix)
- [fonticon-fontawesome5](https://pypi.org/project/fonticon-fontawesome5/) ('fa5s' &
'fa5r' prefixes)
- [fonticon-materialdesignicons6](https://pypi.org/project/fonticon-materialdesignicons6/)
('mdi6' prefix)
...but fonts can also be added manually using [`addFont`][superqt.fonticon.addFont].
@@ -88,7 +90,7 @@ def icon(
`animation`, etc...)
Missing keys in the state dicts will be taken from the default options, provided
by the paramters above.
by the parameters above.
Returns
-------
@@ -98,12 +100,11 @@ def icon(
Examples
--------
simple example (using the string `'fa5s.smile'` assumes the `fonticon-fontawesome5`
plugin is installed)
>>> btn = QPushButton()
>>> btn.setIcon(icon('fa5s.smile'))
>>> btn.setIcon(icon("fa5s.smile"))
can also directly import from fonticon_fa5
>>> from fonticon_fa5 import FA5S
@@ -129,7 +130,7 @@ def icon(
... "disabled": {
... "color": "green",
... "scale_factor": 0.8,
... "animation": spin(btn)
... "animation": spin(btn),
... },
... },
... )
@@ -145,11 +146,11 @@ def icon(
opacity=opacity,
animation=animation,
transform=transform,
states=states,
states=states or {},
)
def setTextIcon(widget: QWidget, glyph_key: str, size: Optional[float] = None) -> None:
def setTextIcon(widget: QWidget, glyph_key: str, size: float | None = None) -> None:
"""Set text on a widget to a specific font & glyph.
This is an alternative to setting a QIcon with a pixmap. It may be easier to
@@ -167,8 +168,8 @@ def setTextIcon(widget: QWidget, glyph_key: str, size: Optional[float] = None) -
return _QFIS.instance().setTextIcon(widget, glyph_key, size)
def font(font_prefix: str, size: Optional[int] = None) -> QFont:
"""Create QFont for `font_prefix`
def font(font_prefix: str, size: int | None = None) -> QFont:
"""Create QFont for `font_prefix`.
Parameters
----------
@@ -186,8 +187,8 @@ def font(font_prefix: str, size: Optional[int] = None) -> QFont:
def addFont(
filepath: str, prefix: str, charmap: Optional[Dict[str, str]] = None
) -> Optional[Tuple[str, str]]:
filepath: str, prefix: str, charmap: dict[str, str] | None = None
) -> tuple[str, str] | None:
"""Add OTF/TTF file at `filepath` to the registry under `prefix`.
If you'd like to later use a fontkey in the form of `prefix.some-name`, then

View File

@@ -1,4 +1,5 @@
from abc import ABC, abstractmethod
from typing import Optional
from qtpy.QtCore import QRectF, QTimer
from qtpy.QtGui import QPainter
@@ -42,5 +43,5 @@ class spin(Animation):
class pulse(spin):
"""Animation that spins an icon in slower, discrete steps."""
def __init__(self, parent_widget: QWidget = None):
def __init__(self, parent_widget: Optional[QWidget] = None):
super().__init__(parent_widget, interval=200, step=45)

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__"
@@ -60,7 +61,6 @@ class IconFont(metaclass=IconFontMeta):
Examples
--------
class FA5S(IconFont):
__font_file__ = '...'
some_char = 0xfa42
@@ -70,13 +70,14 @@ 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):
assert isinstance(
getattr(namespace, FONTFILE_ATTR), str
), "Not a valid font type"
return namespace # type: ignore
if not isinstance(getattr(namespace, FONTFILE_ATTR), str):
raise TypeError(
f"Invalid Font: must declare {FONTFILE_ATTR!r} attribute or classmethod"
)
return namespace
elif hasattr(namespace, "__dict__"):
ns = dict(namespace.__dict__)
else:

View File

@@ -1,4 +1,5 @@
from typing import Dict, List, Set, Tuple
import contextlib
from typing import ClassVar
from ._iconfont import IconFontMeta, namespace2font
@@ -9,11 +10,10 @@ except ImportError:
class FontIconManager:
ENTRY_POINT = "superqt.fonticon"
_PLUGINS: Dict[str, EntryPoint] = {}
_LOADED: Dict[str, IconFontMeta] = {}
_BLOCKED: Set[EntryPoint] = set()
ENTRY_POINT: ClassVar[str] = "superqt.fonticon"
_PLUGINS: ClassVar[dict[str, EntryPoint]] = {}
_LOADED: ClassVar[dict[str, IconFontMeta]] = {}
_BLOCKED: ClassVar[set[EntryPoint]] = set()
def _discover_fonts(self) -> None:
self._PLUGINS.clear()
@@ -86,22 +86,20 @@ _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():
try:
with contextlib.suppress(Exception):
_manager._get_font_class(x)
except Exception:
continue
return {
key: sorted(filter(lambda x: not x.startswith("_"), cls.__dict__))
for key, cls in _manager._LOADED.items()

View File

@@ -1,10 +1,11 @@
from __future__ import annotations
import warnings
from collections import abc
from collections import abc, defaultdict
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import DefaultDict, Dict, Optional, Sequence, Tuple, Type, Union, cast
from typing import TYPE_CHECKING, ClassVar, Union, cast
from qtpy import QT_VERSION
from qtpy.QtCore import QObject, QPoint, QRect, QSize, Qt
@@ -23,8 +24,10 @@ from qtpy.QtGui import (
from qtpy.QtWidgets import QApplication, QStyleOption, QWidget
from typing_extensions import TypedDict
from ..utils import QMessageHandler
from ._animations import Animation
from superqt.utils import QMessageHandler
if TYPE_CHECKING:
from ._animations import Animation
class Unset:
@@ -45,14 +48,14 @@ 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,
]
StateOrMode = Union[QIcon.State, QIcon.Mode]
StateModeKey = Union[StateOrMode, str, Sequence[StateOrMode]]
_SM_MAP: Dict[str, StateOrMode] = {
_SM_MAP: dict[str, StateOrMode] = {
"on": QIcon.State.On,
"off": QIcon.State.Off,
"normal": QIcon.Mode.Normal,
@@ -62,8 +65,8 @@ _SM_MAP: Dict[str, StateOrMode] = {
}
def _norm_state_mode(key: StateModeKey) -> Tuple[QIcon.State, QIcon.Mode]:
"""return state/mode tuple given a variety of valid inputs.
def _norm_state_mode(key: StateModeKey) -> tuple[QIcon.State, QIcon.Mode]:
"""Return state/mode tuple given a variety of valid inputs.
Input can be either a string, or a sequence of state or mode enums.
Strings can be any combination of on, off, normal, active, selected, disabled,
@@ -73,13 +76,13 @@ def _norm_state_mode(key: StateModeKey) -> Tuple[QIcon.State, QIcon.Mode]:
if isinstance(key, str):
try:
_sm = [_SM_MAP[k.lower()] for k in key.split("_")]
except KeyError:
except KeyError as e:
raise ValueError(
f"{key!r} is not a valid state key, must be a combination of {{on, "
"off, active, disabled, selected, normal} separated by underscore"
)
) from e
else:
_sm = key if isinstance(key, abc.Sequence) else [key] # type: ignore
_sm = key if isinstance(key, abc.Sequence) else [key]
state = next((i for i in _sm if isinstance(i, QIcon.State)), QIcon.State.Off)
mode = next((i for i in _sm if isinstance(i, QIcon.Mode)), QIcon.Mode.Normal)
@@ -91,8 +94,8 @@ class IconOptionDict(TypedDict, total=False):
scale_factor: float
color: ValidColor
opacity: float
animation: Optional[Animation]
transform: Optional[QTransform]
animation: Animation | None
transform: QTransform | None
# public facing, for a nicer IDE experience than a dict
@@ -119,17 +122,17 @@ class IconOpts:
The animation to use, by default `None`
"""
glyph_key: Union[str, Unset] = _Unset
scale_factor: Union[float, Unset] = _Unset
color: Union[ValidColor, Unset] = _Unset
opacity: Union[float, Unset] = _Unset
animation: Union[Animation, Unset, None] = _Unset
transform: Union[QTransform, Unset, None] = _Unset
glyph_key: str | Unset = _Unset
scale_factor: float | Unset = _Unset
color: ValidColor | Unset = _Unset
opacity: float | Unset = _Unset
animation: Animation | Unset | None = _Unset
transform: QTransform | Unset | None = _Unset
def dict(self) -> IconOptionDict:
# not using asdict due to pickle errors on animation
d = {k: v for k, v in vars(self).items() if v is not _Unset}
return cast(IconOptionDict, d)
return cast("IconOptionDict", d)
@dataclass
@@ -140,15 +143,15 @@ class _IconOptions:
scale_factor: float = DEFAULT_SCALING_FACTOR
color: ValidColor = None
opacity: float = DEFAULT_OPACITY
animation: Optional[Animation] = None
transform: Optional[QTransform] = None
animation: Animation | None = None
transform: QTransform | None = None
def _update(self, icon_opts: IconOpts) -> _IconOptions:
return _IconOptions(**{**vars(self), **icon_opts.dict()})
def dict(self) -> IconOptionDict:
# not using asdict due to pickle errors on animation
return cast(IconOptionDict, vars(self))
return cast("IconOptionDict", vars(self))
class _QFontIconEngine(QIconEngine):
@@ -156,15 +159,15 @@ class _QFontIconEngine(QIconEngine):
def __init__(self, options: _IconOptions):
super().__init__()
self._opts: DefaultDict[
QIcon.State, Dict[QIcon.Mode, Optional[_IconOptions]]
] = DefaultDict(dict)
self._opts: defaultdict[QIcon.State, dict[QIcon.Mode, _IconOptions | None]] = (
defaultdict(dict)
)
self._opts[QIcon.State.Off][QIcon.Mode.Normal] = options
self.update_hash()
@property
def _default_opts(self) -> _IconOptions:
return cast(_IconOptions, self._opts[QIcon.State.Off][QIcon.Mode.Normal])
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)
@@ -230,7 +233,7 @@ class _QFontIconEngine(QIconEngine):
# font
font = QFont()
font.setFamily(family) # set sepeartely for Qt6
font.setFamily(family) # set separately for Qt6
font.setPixelSize(round(rect.height() * opts.scale_factor))
if style:
font.setStyleName(style)
@@ -239,7 +242,7 @@ class _QFontIconEngine(QIconEngine):
if isinstance(opts.color, tuple):
color_args = opts.color
else:
color_args = (opts.color,) if opts.color else () # type: ignore
color_args = (opts.color,) if opts.color else ()
# animation
if opts.animation is not None:
@@ -321,12 +324,12 @@ class QFontIcon(QIcon):
self,
state: QIcon.State = QIcon.State.Off,
mode: QIcon.Mode = QIcon.Mode.Normal,
glyph_key: Union[str, Unset] = _Unset,
scale_factor: Union[float, Unset] = _Unset,
color: Union[ValidColor, Unset] = _Unset,
opacity: Union[float, Unset] = _Unset,
animation: Union[Animation, Unset, None] = _Unset,
transform: Union[QTransform, Unset, None] = _Unset,
glyph_key: str | Unset = _Unset,
scale_factor: float | Unset = _Unset,
color: ValidColor | Unset = _Unset,
opacity: float | Unset = _Unset,
animation: Animation | Unset | None = _Unset,
transform: QTransform | Unset | None = _Unset,
) -> None:
"""Set icon options for a specific mode/state."""
if glyph_key is not _Unset:
@@ -344,22 +347,20 @@ class QFontIcon(QIcon):
class QFontIconStore(QObject):
# map of key -> (font_family, font_style)
_LOADED_KEYS: Dict[str, Tuple[str, Optional[str]]] = dict()
_LOADED_KEYS: ClassVar[dict[str, tuple[str, str]]] = {}
# map of (font_family, font_style) -> character (char may include key)
_CHARMAPS: Dict[Tuple[str, Optional[str]], Dict[str, str]] = dict()
_CHARMAPS: ClassVar[dict[tuple[str, str | None], dict[str, str]]] = {}
# singleton instance, use `instance()` to retrieve
__instance: Optional[QFontIconStore] = None
__instance: ClassVar[QFontIconStore | None] = None
def __init__(self, parent: Optional[QObject] = None) -> None:
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent=parent)
# QT6 drops this
dpi = getattr(Qt.ApplicationAttribute, "AA_UseHighDpiPixmaps", None)
if dpi:
QApplication.setAttribute(dpi)
if tuple(cast("str", QT_VERSION).split(".")) < ("6", "0"):
# QT6 drops this
QApplication.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps)
@classmethod
def instance(cls) -> QFontIconStore:
@@ -374,8 +375,8 @@ class QFontIconStore(QObject):
QFontDatabase.removeAllApplicationFonts()
@classmethod
def _key2family(cls, key: str) -> Tuple[str, Optional[str]]:
"""Return (family, style) given a font `key`"""
def _key2family(cls, key: str) -> tuple[str, str]:
"""Return (family, style) given a font `key`."""
key = key.split(".", maxsplit=1)[0]
if key not in cls._LOADED_KEYS:
from . import _plugins
@@ -383,7 +384,7 @@ class QFontIconStore(QObject):
try:
font_cls = _plugins.get_font_class(key)
result = cls.addFont(
font_cls.__font_file__, key, charmap=font_cls.__dict__
font_cls.__font_file__, key, charmap=dict(font_cls.__dict__)
)
if not result: # pragma: no cover
raise Exception("Invalid font file")
@@ -398,13 +399,15 @@ class QFontIconStore(QObject):
@classmethod
def _ensure_char(cls, char: str, family: str, style: str) -> str:
"""make sure that `char` is a glyph provided by `family` and `style`."""
"""Make sure that `char` is a glyph provided by `family` and `style`."""
if len(char) == 1 and ord(char) > 256:
return char
try:
charmap = cls._CHARMAPS[(family, style)]
except KeyError:
raise KeyError(f"No charmap registered for font '{family} ({style})'")
except KeyError as e:
raise KeyError(
f"No charmap registered for font '{family} ({style})'"
) from e
if char in charmap:
# split in case the charmap includes the key
return charmap[char].split(".", maxsplit=1)[-1]
@@ -417,8 +420,8 @@ class QFontIconStore(QObject):
raise ValueError(f"Font '{family} ({style})' has no glyph with the key {ident}")
@classmethod
def key2glyph(cls, glyph_key: str) -> tuple[str, str, Optional[str]]:
"""Return (char, family, style) given a `glyph_key`"""
def key2glyph(cls, glyph_key: str) -> tuple[str, str, str | None]:
"""Return (char, family, style) given a `glyph_key`."""
if "." not in glyph_key:
raise ValueError("Glyph key must contain a period")
font_key, char = glyph_key.split(".", maxsplit=1)
@@ -428,9 +431,9 @@ class QFontIconStore(QObject):
@classmethod
def addFont(
cls, filepath: str, prefix: str, charmap: Optional[Dict[str, str]] = None
) -> Optional[Tuple[str, str]]:
"""Add font at `filepath` to the registry under `key`.
cls, filepath: str, prefix: str, charmap: dict[str, str] | None = None
) -> tuple[str, str] | None:
r"""Add font at `filepath` to the registry under `key`.
If you'd like to later use a fontkey in the form of `key.some-name`, then
`charmap` must be provided and provide a mapping for all of the glyph names
@@ -441,7 +444,7 @@ class QFontIconStore(QObject):
----------
filepath : str
Path to an OTF or TTF file containing the fonts
key : str
prefix : str
A key that will represent this font file when used for lookup. For example,
'fa5s' for 'Font-Awesome 5 Solid'.
charmap : Dict[str, str], optional
@@ -455,8 +458,8 @@ class QFontIconStore(QObject):
something goes wrong.
"""
if prefix in cls._LOADED_KEYS:
warnings.warn(f"Prefix {prefix} already loaded")
return
warnings.warn(f"Prefix {prefix} already loaded", stacklevel=2)
return None
if not Path(filepath).exists():
raise FileNotFoundError(f"Font file doesn't exist: {filepath}")
@@ -465,28 +468,29 @@ class QFontIconStore(QObject):
fontId = QFontDatabase.addApplicationFont(str(Path(filepath).absolute()))
if fontId < 0: # pragma: no cover
warnings.warn(f"Cannot load font file: {filepath}")
warnings.warn(f"Cannot load font file: {filepath}", stacklevel=2)
return None
families = QFontDatabase.applicationFontFamilies(fontId)
if not families: # pragma: no cover
warnings.warn(f"Font file is empty!: {filepath}")
warnings.warn(f"Font file is empty!: {filepath}", stacklevel=2)
return None
family: str = families[0]
# in Qt6, everything becomes a static member
QFd: Union[QFontDatabase, Type[QFontDatabase]] = (
QFontDatabase() # type: ignore
if tuple(QT_VERSION.split(".")) < ("6", "0")
QFd: QFontDatabase | type[QFontDatabase] = (
QFontDatabase()
if tuple(cast("str", QT_VERSION).split(".")) < ("6", "0")
else QFontDatabase
)
styles = QFd.styles(family) # type: ignore
styles = QFd.styles(family)
style: str = styles[-1] if styles else ""
if not QFd.isSmoothlyScalable(family, style): # pragma: no cover
warnings.warn(
f"Registered font {family} ({style}) is not smoothly scalable. "
"Icons may not look attractive."
"Icons may not look attractive.",
stacklevel=2,
)
cls._LOADED_KEYS[prefix] = (family, style)
@@ -499,11 +503,11 @@ class QFontIconStore(QObject):
glyph_key: str,
*,
scale_factor: float = DEFAULT_SCALING_FACTOR,
color: ValidColor = None,
color: ValidColor | None = None,
opacity: float = 1,
animation: Optional[Animation] = None,
transform: Optional[QTransform] = None,
states: Dict[str, Union[IconOptionDict, IconOpts]] = {},
animation: Animation | None = None,
transform: QTransform | None = None,
states: dict[str, IconOptionDict | IconOpts] | None = None,
) -> QFontIcon:
self.key2glyph(glyph_key) # make sure it's a valid glyph_key
default_opts = _IconOptions(
@@ -515,14 +519,14 @@ class QFontIconStore(QObject):
transform=transform,
)
icon = QFontIcon(default_opts)
for kw, options in states.items():
for kw, options in (states or {}).items():
if isinstance(options, IconOpts):
options = default_opts._update(options).dict()
icon.addState(*_norm_state_mode(kw), **options)
return icon
def setTextIcon(
self, widget: QWidget, glyph_key: str, size: Optional[float] = None
self, widget: QWidget, glyph_key: str, size: float | None = None
) -> None:
"""Sets text on a widget to a specific font & glyph.
@@ -539,8 +543,8 @@ class QFontIconStore(QObject):
widget.setFont(self.font(glyph_key, int(size)))
setText(glyph)
def font(self, font_prefix: str, size: Optional[int] = None) -> QFont:
"""Create QFont for `font_prefix`"""
def font(self, font_prefix: str, size: int | None = None) -> QFont:
"""Create QFont for `font_prefix`."""
font_key, _ = font_prefix.split(".", maxsplit=1)
family, style = self._key2family(font_key)
font = QFont()
@@ -553,7 +557,7 @@ class QFontIconStore(QObject):
def _ensure_identifier(name: str) -> str:
"""Normalize string to valid identifier"""
"""Normalize string to valid identifier."""
import keyword
if not name:
@@ -570,5 +574,6 @@ def _ensure_identifier(name: str) -> str:
# replace dashes and spaces with underscores
name = name.replace("-", "_").replace(" ", "_")
assert str.isidentifier(name), f"Could not canonicalize name: {name}"
if not str.isidentifier(name):
raise ValueError(f"Could not canonicalize name: {name!r}. (not an identifier)")
return name

View File

@@ -0,0 +1,156 @@
from __future__ import annotations
import warnings
from typing import TYPE_CHECKING
from qtpy.QtCore import QSize, Qt
from qtpy.QtGui import QIcon, QPainter, QPixmap
from qtpy.QtWidgets import QApplication
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]
__all__ = ["QIconifyIcon"]
class QIconifyIcon(QIcon):
"""QIcon backed by an iconify icon.
Iconify includes 150,000+ icons from most major icon sets including Bootstrap,
FontAwesome, Material Design, and many more.
Search availble icons at https://icon-sets.iconify.design
Once you find one you like, use the key in the format `"prefix:name"` to create an
icon: `QIconifyIcon("bi:bell")`.
This class is a thin wrapper around the
[pyconify](https://github.com/pyapp-kit/pyconify) `svg_path` function. It pulls SVGs
from iconify, creates a temporary SVG file and uses it as the source for a QIcon.
SVGs are cached to disk, and persist across sessions (until `pyconify.clear_cache()`
is called).
Parameters are the same as `QIconifyIcon.addKey`, which can be used to add
additional icons for various modes and states to the same QIcon.
Parameters
----------
*key: str
Icon set prefix and name. May be passed as a single string in the format
`"prefix:name"` or as two separate strings: `'prefix', 'name'`.
color : str, optional
Icon color. If not provided, the icon will appear black (the icon fill color
will be set to the string "currentColor").
flip : str, optional
Flip icon. Must be one of "horizontal", "vertical", "horizontal,vertical"
rotate : str | int, optional
Rotate icon. Must be one of 0, 90, 180, 270,
or 0, 1, 2, 3 (equivalent to 0, 90, 180, 270, respectively)
dir : str, optional
If 'dir' is not None, the file will be created in that directory, otherwise a
default
[directory](https://docs.python.org/3/library/tempfile.html#tempfile.mkstemp) is
used.
Examples
--------
>>> from qtpy.QtWidgets import QPushButton
>>> from superqt import QIconifyIcon
>>> btn = QPushButton()
>>> icon = QIconifyIcon("bi:alarm-fill", color="red", rotate=90)
>>> btn.setIcon(icon)
"""
def __init__(
self,
*key: str,
color: str | None = None,
flip: Flip | None = None,
rotate: Rotation | None = None,
dir: str | None = None,
):
super().__init__()
if key:
self.addKey(*key, color=color, flip=flip, rotate=rotate, dir=dir)
def addKey(
self,
*key: str,
color: str | None = None,
flip: Flip | None = None,
rotate: Rotation | None = None,
dir: str | None = None,
size: QSize | None = None,
mode: QIcon.Mode = QIcon.Mode.Normal,
state: QIcon.State = QIcon.State.Off,
) -> QIconifyIcon:
"""Add an icon to this QIcon.
This is a variant of `QIcon.addFile` that uses an iconify icon keys and
arguments instead of a file path.
Parameters
----------
*key: str
Icon set prefix and name. May be passed as a single string in the format
`"prefix:name"` or as two separate strings: `'prefix', 'name'`.
color : str, optional
Icon color. If not provided, the icon will appear black (the icon fill color
will be set to the string "currentColor").
flip : str, optional
Flip icon. Must be one of "horizontal", "vertical", "horizontal,vertical"
rotate : str | int, optional
Rotate icon. Must be one of 0, 90, 180, 270, or 0, 1, 2, 3 (equivalent to 0,
90, 180, 270, respectively)
dir : str, optional
If 'dir' is not None, the file will be created in that directory, otherwise
a default
[directory](https://docs.python.org/3/library/tempfile.html#tempfile.mkstemp)
is used.
size : QSize, optional
Size specified for the icon, passed to `QIcon.addFile`.
mode : QIcon.Mode, optional
Mode specified for the icon, passed to `QIcon.addFile`.
state : QIcon.State, optional
State specified for the icon, passed to `QIcon.addFile`.
Returns
-------
QIconifyIcon
This QIconifyIcon instance, for chaining.
"""
try:
path = svg_path(*key, color=color, flip=flip, rotate=rotate, dir=dir)
except OSError as e:
warnings.warn(
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)
else:
pixmap = QPixmap(18, 18)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "?")
painter.end()
self.addPixmap(pixmap)

View File

@@ -6,13 +6,17 @@ from qtpy import * # noqa
warnings.warn(
"The superqt.qtcompat module is deprecated as of v0.3.0. "
"Please import from `qtpy` instead."
"Please import from `qtpy` instead.",
stacklevel=2,
)
# forward any requests for superqt.qtcompat.* to qtpy.*
class SuperQtImporter(abc.MetaPathFinder):
"""Pseudo-importer to forward superqt.qtcompat.* to qtpy.*."""
def find_spec(self, fullname: str, path, target=None): # type: ignore
"""Forward any requests for superqt.qtcompat.* to qtpy.*."""
if fullname.startswith(__name__):
return util.find_spec(fullname.replace(__name__, "qtpy"))

View File

@@ -1,3 +1,4 @@
from ._searchable_list_widget import QSearchableListWidget
from ._searchable_tree_widget import QSearchableTreeWidget
__all__ = ("QSearchableListWidget",)
__all__ = ("QSearchableListWidget", "QSearchableTreeWidget")

View File

@@ -0,0 +1,115 @@
import logging
from collections.abc import Iterable, Mapping
from typing import Any
from qtpy.QtCore import QRegularExpression
from qtpy.QtWidgets import QLineEdit, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
class QSearchableTreeWidget(QWidget):
"""A tree widget for showing a mapping that can be searched by key.
This is intended to be used with a read-only mapping and be conveniently
created using `QSearchableTreeWidget.fromData(data)`.
If the mapping changes, the easiest way to update this is by calling `setData`.
The tree can be searched by entering a regular expression pattern
into the `filter` line edit. An item is only shown if its, any of its ancestors',
or any of its descendants' keys or values match this pattern.
The regular expression follows the conventions described by the Qt docs:
https://doc.qt.io/qt-6/qregularexpression.html#details
Attributes
----------
tree : QTreeWidget
Shows the mapping as a tree of items.
filter : QLineEdit
Used to filter items in the tree by matching their key against a
regular expression.
"""
def __init__(self, parent=None):
super().__init__(parent)
self.tree: QTreeWidget = QTreeWidget(self)
self.tree.setHeaderLabels(("Key", "Value"))
self.filter: QLineEdit = QLineEdit(self)
self.filter.setClearButtonEnabled(True)
self.filter.textChanged.connect(self._updateVisibleItems)
layout = QVBoxLayout(self)
layout.addWidget(self.filter)
layout.addWidget(self.tree)
def setData(self, data: Mapping) -> None:
"""Update the mapping data shown by the tree."""
self.tree.clear()
self.filter.clear()
top_level_items = [_make_item(name=k, value=v) for k, v in data.items()]
self.tree.addTopLevelItems(top_level_items)
def _updateVisibleItems(self, pattern: str) -> None:
"""Recursively update the visibility of items based on the given pattern."""
expression = QRegularExpression(pattern)
for i in range(self.tree.topLevelItemCount()):
top_level_item = self.tree.topLevelItem(i)
_update_visible_items(top_level_item, expression)
@classmethod
def fromData(
cls, data: Mapping, *, parent: QWidget = None
) -> "QSearchableTreeWidget":
"""Make a searchable tree widget from a mapping."""
widget = cls(parent)
widget.setData(data)
return widget
def _make_item(*, name: str, value: Any) -> QTreeWidgetItem:
"""Make a tree item where the name and value are two columns.
Iterable values other than strings are recursively traversed to
add child items and build a tree. In this case, mappings use keys
as their names whereas other iterables use their enumerated index.
"""
if isinstance(value, Mapping):
item = QTreeWidgetItem([name, type(value).__name__])
for k, v in value.items():
child = _make_item(name=k, value=v)
item.addChild(child)
elif isinstance(value, Iterable) and not isinstance(value, str):
item = QTreeWidgetItem([name, type(value).__name__])
for i, v in enumerate(value):
child = _make_item(name=str(i), value=v)
item.addChild(child)
else:
item = QTreeWidgetItem([name, str(value)])
logging.debug("_make_item: %s, %s, %s", item.text(0), item.text(1), item.flags())
return item
def _update_visible_items(
item: QTreeWidgetItem, expression: QRegularExpression, ancestor_match: bool = False
) -> bool:
"""Recursively update the visibility of a tree item based on an expression.
An item is visible if any of its, any of its ancestors', or any of its descendants'
column's text matches the expression.
Returns True if the item is visible, False otherwise.
"""
match = ancestor_match or any(
expression.match(item.text(i)).hasMatch() for i in range(item.columnCount())
)
visible = match
for i in range(item.childCount()):
child = item.child(i)
descendant_visible = _update_visible_items(child, expression, match)
visible = visible or descendant_visible
item.setHidden(not visible)
logging.debug(
"_update_visible_items: %s, %s",
tuple(item.text(i) for i in range(item.columnCount())),
visible,
)
return visible

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,12 +0,0 @@
from qtpy.QtWidgets import QSlider
from ._generic_range_slider import _GenericRangeSlider
from ._generic_slider import _GenericSlider
class QDoubleRangeSlider(_GenericRangeSlider): ...
class QDoubleSlider(_GenericSlider): ...
class QRangeSlider(_GenericRangeSlider): ...
class QLabeledSlider(QSlider): ...
class QLabeledDoubleSlider(QDoubleSlider): ...
class QLabeledRangeSlider(QRangeSlider): ...
class QLabeledDoubleRangeSlider(QDoubleRangeSlider): ...

View File

@@ -1,4 +1,5 @@
from typing import Generic, List, 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
@@ -17,7 +18,7 @@ _T = TypeVar("_T")
SC_BAR = QStyle.SubControl.SC_ScrollBarSubPage
class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
class _GenericRangeSlider(_GenericSlider):
"""MultiHandle Range Slider widget.
Same API as QSlider, but `value`, `setValue`, `sliderPosition`, and
@@ -28,25 +29,27 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
"""
# 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[Tuple], Generic[_T]):
self.setStyleSheet("")
def _rename_signals(self) -> None:
self.valueChanged = self.valuesChanged
self.sliderMoved = self.slidersMoved
# ############### New Public API #######################
def barIsRigid(self) -> bool:
@@ -80,11 +87,11 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
self._bar_is_rigid = bool(val)
def barMovesAllHandles(self) -> bool:
"""Whether clicking on the bar moves all handles (default), or just the nearest."""
"""Whether clicking on the bar moves all handles, or just the nearest."""
return self._bar_moves_all
def setBarMovesAllHandles(self, val: bool = True) -> None:
"""Whether clicking on the bar moves all handles (default), or just the nearest."""
"""Whether clicking on the bar moves all handles, or just the nearest."""
self._bar_moves_all = bool(val)
def barIsVisible(self) -> bool:
@@ -103,7 +110,7 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
"""Show the bar between the first and last handle."""
self.setBarVisible(True)
def applyMacStylePatch(self) -> str:
def applyMacStylePatch(self) -> None:
"""Apply a QSS patch to fix sliders on macos>=12 with QT < 6.
see [FAQ](../faq.md#sliders-not-dragging-properly-on-macos-12) for more details.
@@ -113,7 +120,7 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
# ############### 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)
@@ -124,11 +131,27 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
"""
return tuple(float(i) for i in self._position)
def setSliderPosition(self, pos: Union[float, Sequence[float]], index=None) -> None:
def setSliderPosition( # type: ignore
self,
pos: Union[float, Sequence[float]],
index: Optional[int] = None,
*,
reversed: bool = False,
) -> None:
"""Set current position of the handles with a sequence of integers.
If `pos` is a sequence, it must have the same length as `value()`.
If it is a scalar, index will be
Parameters
----------
pos : Union[float, Sequence[float]]
The new position of the slider handle(s). If a sequence, it must have the
same length as `value()`. If it is a scalar, index will be used to set the
position of the handle at that index.
index : int | None
The index of the handle to set the position of. If None, the "pressedIndex"
will be used.
reversed : bool
Order in which to set the positions. Can be useful when setting multiple
positions, to avoid intermediate overlapping values.
"""
if isinstance(pos, (list, tuple)):
val_len = len(self.value())
@@ -139,6 +162,9 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
else:
pairs = [(self._pressedIndex if index is None else index, pos)]
if reversed:
pairs = pairs[::-1]
for idx, position in pairs:
self._position[idx] = self._bound(position, idx)
@@ -222,7 +248,7 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
offset = self.maximum() - ref[-1]
elif ref[0] + offset < self.minimum():
offset = self.minimum() - ref[0]
self.setSliderPosition([i + offset for i in ref])
self.setSliderPosition([i + offset for i in ref], reversed=offset > 0)
def _fixStyleOption(self, option):
pass
@@ -233,7 +259,9 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
# SubControl Positions
def _handleRect(self, handle_index: int, opt: QStyleOptionSlider = None) -> QRect:
def _handleRect(
self, handle_index: int, opt: Optional[QStyleOptionSlider] = None
) -> QRect:
"""Return the QRect for all handles."""
opt = opt or self._styleOption
opt.sliderPosition = self._optSliderPositions[handle_index]
@@ -310,8 +338,8 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
# NOTE: this is very much tied to mousepress... not a generic "get control"
def _getControlAtPos(
self, pos: QPoint, opt: QStyleOptionSlider = None
) -> Tuple[QStyle.SubControl, int]:
self, pos: QPoint, opt: Optional[QStyleOptionSlider] = None
) -> tuple[QStyle.SubControl, int]:
"""Update self._pressedControl based on ev.pos()."""
opt = opt or self._styleOption

View File

@@ -1,10 +1,10 @@
"""Generic Sliders with internal python-based models
"""Generic Sliders with internal python-based models.
This module reimplements most of the logic from qslider.cpp in python:
https://code.woboq.org/qt5/qtbase/src/widgets/widgets/qslider.cpp.html
This probably looks like tremendous overkill at first (and it may be!),
since a it's possible to acheive a very reasonable "float slider" by
since a it's possible to achieve a very reasonable "float slider" by
scaling input float values to some internal integer range for the QSlider,
and converting back to float when getting `value()`. However, one still
runs into overflow limitations due to the internal integer model.
@@ -19,9 +19,10 @@ So that's what `_GenericSlider` is below.
scalar (with one handle per item), and it forms the basis of
QRangeSlider.
"""
import os
import platform
from typing import Generic, TypeVar
from typing import Any, TypeVar
from qtpy import QT_VERSION, QtGui
from qtpy.QtCore import QEvent, QPoint, QPointF, QRect, Qt, Signal
@@ -48,7 +49,7 @@ QOVERFLOW = 2**31 - 1
# whether to use the MONTEREY_SLIDER_STYLES_FIX QSS hack
# for fixing sliders on macos>=12 with QT < 6
# https://bugreports.qt.io/browse/QTBUG-98093
# https://github.com/napari/superqt/issues/74
# https://github.com/pyapp-kit/superqt/issues/74
USE_MAC_SLIDER_PATCH = (
QT_VERSION
and int(QT_VERSION.split(".")[0]) < 6
@@ -58,15 +59,14 @@ USE_MAC_SLIDER_PATCH = (
)
class _GenericSlider(QSlider, Generic[_T]):
_fvalueChanged = Signal(int)
_fsliderMoved = Signal(int)
_frangeChanged = Signal(int, int)
class _GenericSlider(QSlider):
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
@@ -74,6 +74,7 @@ class _GenericSlider(QSlider, Generic[_T]):
self._position: _T = 0.0
self._singleStep = 1.0
self._offsetAccumulated = 0.0
self._inverted_appearance = False
self._blocktracking = False
self._tickInterval = 0.0
self._pressedControl = SC_NONE
@@ -89,16 +90,19 @@ class _GenericSlider(QSlider, Generic[_T]):
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 applyMacStylePatch(self) -> str:
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.
see [FAQ](../faq.md#sliders-not-dragging-properly-on-macos-12) for more details.
@@ -174,6 +178,13 @@ class _GenericSlider(QSlider, Generic[_T]):
self._tickInterval = max(0.0, ts)
self.update()
def invertedAppearance(self) -> bool:
return self._inverted_appearance
def setInvertedAppearance(self, inverted: bool) -> None:
self._inverted_appearance = inverted
self.update()
def triggerAction(self, action: QSlider.SliderAction) -> None:
self._blocktracking = True
# other actions here
@@ -193,9 +204,8 @@ class _GenericSlider(QSlider, Generic[_T]):
if self.orientation() == Qt.Orientation.Horizontal
else not self.invertedAppearance()
)
option.direction = (
Qt.LayoutDirection.LeftToRight
) # we use the upsideDown option instead
# we use the upsideDown option instead
option.direction = Qt.LayoutDirection.LeftToRight
# option.sliderValue = self._value # type: ignore
# option.singleStep = self._singleStep # type: ignore
if self.orientation() == Qt.Orientation.Horizontal:
@@ -276,7 +286,6 @@ class _GenericSlider(QSlider, Generic[_T]):
self.update()
def wheelEvent(self, e: QtGui.QWheelEvent) -> None:
e.ignore()
vertical = bool(e.angleDelta().y())
delta = e.angleDelta().y() if vertical else e.angleDelta().x()
@@ -336,8 +345,12 @@ class _GenericSlider(QSlider, Generic[_T]):
option.sliderValue = self._to_qinteger_space(self._value - self._minimum)
def _to_qinteger_space(self, val, _max=None):
"""Converts a value to the internal integer space."""
_max = _max or self.MAX_DISPLAY
return int(min(QOVERFLOW, val / (self._maximum - self._minimum) * _max))
range_ = self._maximum - self._minimum
if range_ == 0:
return self._minimum
return int(min(QOVERFLOW, val / range_ * _max))
def _pick(self, pt: QPoint) -> int:
return pt.x() if self.orientation() == Qt.Orientation.Horizontal else pt.y()
@@ -522,16 +535,7 @@ def _event_position(ev: QEvent) -> QPoint:
def _sliderValueFromPosition(
min: float, max: float, position: int, span: int, upsideDown: bool = False
) -> float:
"""Converts the given pixel `position` to a value.
0 maps to the `min` parameter, `span` maps to `max` and other values are
distributed evenly in-between.
By default, this function assumes that the maximum value is on the right
for horizontal items and on the bottom for vertical items. Set the
`upsideDown` parameter to True to reverse this behavior.
"""
"""Converts the given pixel `position` to a value."""
if span <= 0 or position <= 0:
return max if upsideDown else min
if position >= span:

View File

@@ -1,101 +1,141 @@
from enum import IntEnum
from functools import partial
from typing import Any
from __future__ import annotations
from qtpy.QtCore import QPoint, QSize, Qt, Signal
from qtpy.QtGui import QFontMetrics, QValidator
from enum import IntEnum, IntFlag, auto
from functools import partial
from typing import TYPE_CHECKING, Any, overload
from qtpy import QtGui
from qtpy.QtCore import Property, QPoint, QSize, Qt, Signal
from qtpy.QtGui import QDoubleValidator, QFontMetrics, QValidator
from qtpy.QtWidgets import (
QAbstractSlider,
QApplication,
QDoubleSpinBox,
QBoxLayout,
QHBoxLayout,
QLineEdit,
QSlider,
QSpinBox,
QStyle,
QStyleOptionSpinBox,
QVBoxLayout,
QWidget,
)
from ..utils import signals_blocked
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
LabelsAbove = 1
LabelsBelow = 2
LabelsRight = 1
LabelsLeft = 2
LabelsAbove = auto()
LabelsBelow = auto()
LabelsRight = LabelsAbove
LabelsLeft = LabelsBelow
LabelsOnHandle = auto()
class EdgeLabelMode(IntEnum):
class EdgeLabelMode(IntFlag):
NoLabel = 0
LabelIsRange = 1
LabelIsValue = 2
LabelIsRange = auto()
LabelIsValue = auto()
class _SliderProxy:
_slider: QSlider
def value(self):
def value(self) -> Any:
return self._slider.value()
def setValue(self, value) -> None:
def setValue(self, value: Any) -> None:
self._slider.setValue(value)
def sliderPosition(self):
def sliderPosition(self) -> int:
return self._slider.sliderPosition()
def setSliderPosition(self, pos) -> None:
def setSliderPosition(self, pos: int) -> None:
self._slider.setSliderPosition(pos)
def minimum(self):
def minimum(self) -> int:
return self._slider.minimum()
def setMinimum(self, minimum):
def setMinimum(self, minimum: int) -> None:
self._slider.setMinimum(minimum)
def maximum(self):
def maximum(self) -> int:
return self._slider.maximum()
def setMaximum(self, maximum):
def setMaximum(self, maximum: int) -> None:
self._slider.setMaximum(maximum)
def singleStep(self):
return self._slider.singleStep()
def setSingleStep(self, step):
def setSingleStep(self, step: int) -> None:
self._slider.setSingleStep(step)
def pageStep(self):
def pageStep(self) -> int:
return self._slider.pageStep()
def setPageStep(self, step) -> None:
def setPageStep(self, step: int) -> None:
self._slider.setPageStep(step)
def setRange(self, min, max) -> None:
def setRange(self, min: float, max: float) -> None:
self._slider.setRange(min, max)
def tickInterval(self):
def tickInterval(self) -> int:
return self._slider.tickInterval()
def setTickInterval(self, interval) -> None:
def setTickInterval(self, interval: int) -> None:
self._slider.setTickInterval(interval)
def tickPosition(self):
def tickPosition(self) -> QSlider.TickPosition:
return self._slider.tickPosition()
def setTickPosition(self, pos) -> None:
def setTickPosition(self, pos: QSlider.TickPosition) -> None:
self._slider.setTickPosition(pos)
def __getattr__(self, name) -> Any:
def triggerAction(self, action: QAbstractSlider.SliderAction) -> None:
return self._slider.triggerAction(action)
def invertedControls(self) -> bool:
return self._slider.invertedControls()
def setInvertedControls(self, a0: bool) -> None:
return self._slider.setInvertedControls(a0)
def invertedAppearance(self) -> bool:
return self._slider.invertedAppearance()
def setInvertedAppearance(self, a0: bool) -> None:
return self._slider.setInvertedAppearance(a0)
def isSliderDown(self) -> bool:
return self._slider.isSliderDown()
def setSliderDown(self, a0: bool) -> None:
return self._slider.setSliderDown(a0)
def hasTracking(self) -> bool:
return self._slider.hasTracking()
def setTracking(self, enable: bool) -> None:
return self._slider.setTracking(enable)
def orientation(self) -> Qt.Orientation:
return self._slider.orientation()
def __getattr__(self, name: Any) -> Any:
return getattr(self._slider, name)
def _handle_overloaded_slider_sig(args, kwargs):
def _handle_overloaded_slider_sig(
args: tuple, kwargs: dict
) -> tuple[QWidget | None, Qt.Orientation]:
"""Maintaining signature of QSlider.__init__."""
parent = None
orientation = Qt.Orientation.Vertical
orientation = Qt.Orientation.Horizontal
errmsg = (
"TypeError: arguments did not match any overloaded call:\n"
" QSlider(parent: QWidget = None)\n"
@@ -121,60 +161,72 @@ def _handle_overloaded_slider_sig(args, kwargs):
class QLabeledSlider(_SliderProxy, QAbstractSlider):
editingFinished = Signal()
EdgeLabelMode = EdgeLabelMode
_slider_class = QSlider
_slider: QSlider
def __init__(self, *args, **kwargs) -> None:
@overload
def __init__(self, parent: QWidget | None = ...) -> None: ...
@overload
def __init__(
self, orientation: Qt.Orientation, parent: QWidget | None = ...
) -> None: ...
def __init__(self, *args: Any, **kwargs: Any) -> None:
parent, orientation = _handle_overloaded_slider_sig(args, kwargs)
super().__init__(parent)
# accept focus events
fp = self.style().styleHint(QStyle.StyleHint.SH_Button_FocusPolicy)
self.setFocusPolicy(Qt.FocusPolicy(fp))
self._slider = self._slider_class()
self._label = SliderLabel(self._slider, connect=self._setValue)
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)
self._slider.rangeChanged.connect(self.rangeChanged.emit)
self._slider.rangeChanged.connect(self._on_slider_range_changed)
self._slider.sliderMoved.connect(self.sliderMoved.emit)
self._slider.sliderPressed.connect(self.sliderPressed.emit)
self._slider.sliderReleased.connect(self.sliderReleased.emit)
self._slider.valueChanged.connect(self._label.setValue)
self._slider.valueChanged.connect(self.valueChanged.emit)
self._slider.valueChanged.connect(self._on_slider_value_changed)
self._label.editingFinished.connect(self.editingFinished)
self.setOrientation(orientation)
def _setValue(self, value: float):
"""
Convert the value from float to int before
setting the slider value
"""
self._slider.setValue(int(value))
# ------------------- public API -------------------
def _rename_signals(self):
# for subclasses
pass
def setOrientation(self, orientation):
def setOrientation(self, orientation: Qt.Orientation) -> None:
"""Set orientation, value will be 'horizontal' or 'vertical'."""
self._slider.setOrientation(orientation)
marg = (0, 0, 0, 0)
if orientation == Qt.Orientation.Vertical:
layout = QVBoxLayout()
layout.addWidget(self._slider, alignment=Qt.AlignmentFlag.AlignHCenter)
layout.addWidget(self._label, alignment=Qt.AlignmentFlag.AlignHCenter)
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:
layout = QHBoxLayout() # type: ignore
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 = QHBoxLayout()
layout.addWidget(self._slider)
layout.addWidget(self._label)
self._label.setAlignment(Qt.AlignmentFlag.AlignRight)
layout.setSpacing(6)
old_layout = self.layout()
@@ -189,67 +241,134 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
return self._edge_label_mode
def setEdgeLabelMode(self, opt: EdgeLabelMode) -> None:
"""Set the `EdgeLabelMode`."""
"""Set the `EdgeLabelMode`.
Parameters
----------
opt : EdgeLabelMode
To show no label, use `EdgeLabelMode.NoLabel`. To show the value
of the slider, use `EdgeLabelMode.LabelIsValue`. To show
`value / maximum`, use
`EdgeLabelMode.LabelIsValue | EdgeLabelMode.LabelIsRange`.
"""
if opt is EdgeLabelMode.LabelIsRange:
raise ValueError(
"mode must be one of 'EdgeLabelMode.NoLabel' or "
"'EdgeLabelMode.LabelIsValue'."
"'EdgeLabelMode.LabelIsValue' or"
"'EdgeLabelMode.LabelIsValue | EdgeLabelMode.LabelIsRange'."
)
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)
else:
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)
QApplication.processEvents()
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:
if self._edge_label_mode & EdgeLabelMode.LabelIsRange:
self._label.setSuffix(f" / {max_}")
else:
self._label.setSuffix("")
self.rangeChanged.emit(min_, max_)
def _on_slider_value_changed(self, v: Any) -> None:
self._label.setValue(v)
self.valueChanged.emit(v)
def _setValue(self, value: float) -> None:
"""Convert the value from float to int before setting the slider value."""
self._slider.setValue(int(value))
def _rename_signals(self) -> None: ...
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)
def __init__(self, *args, **kwargs) -> None:
@overload
def __init__(self, parent: QWidget | None = ...) -> None: ...
@overload
def __init__(
self, orientation: Qt.Orientation, parent: QWidget | None = ...
) -> None: ...
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.setDecimals(2)
def _rename_signals(self):
self.valueChanged = self._fvalueChanged
self.sliderMoved = self._fsliderMoved
self.rangeChanged = self._frangeChanged
def _setValue(self, value: float) -> None:
"""Convert the value from float to int before setting the slider value."""
self._slider.setValue(value)
def _rename_signals(self) -> None:
self.valueChanged = self.fvalueChanged
self.sliderMoved = self.fsliderMoved
self.rangeChanged = self.frangeChanged
def decimals(self) -> int:
return self._label.decimals()
def setDecimals(self, prec: int):
def setDecimals(self, prec: int) -> None:
self._label.setDecimals(prec)
class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
_valueChanged = Signal(tuple)
valuesChanged = Signal(tuple)
editingFinished = Signal()
LabelPosition = LabelPosition
EdgeLabelMode = EdgeLabelMode
_slider_class = QRangeSlider
_slider: QRangeSlider
def __init__(self, *args, **kwargs) -> None:
@overload
def __init__(self, parent: QWidget | None = ...) -> None: ...
@overload
def __init__(
self, orientation: Qt.Orientation, parent: QWidget | None = ...
) -> None: ...
def __init__(self, *args: Any, **kwargs: Any) -> None:
parent, orientation = _handle_overloaded_slider_sig(args, kwargs)
super().__init__(parent)
self._rename_signals()
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
self._handle_labels = []
self._handle_labels: list[SliderLabel] = []
self._handle_label_position: LabelPosition = LabelPosition.LabelsAbove
# for fine tuning label position
@@ -258,7 +377,10 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
self._slider = self._slider_class()
self._slider.valueChanged.connect(self.valueChanged.emit)
self._slider.sliderPressed.connect(self.sliderPressed.emit)
self._slider.sliderReleased.connect(self.sliderReleased.emit)
self._slider.rangeChanged.connect(self.rangeChanged.emit)
self.sliderMoved = self._slider.slidersMoved
self._min_label = SliderLabel(
self._slider,
@@ -281,28 +403,27 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
self._on_range_changed(self._slider.minimum(), self._slider.maximum())
self.setOrientation(orientation)
def _rename_signals(self):
self.valueChanged = self._valueChanged
# --------------------- public API -------------------
def handleLabelPosition(self) -> LabelPosition:
"""Return where/whether labels are shown adjacent to slider handles."""
return self._handle_label_position
def setHandleLabelPosition(self, opt: LabelPosition) -> LabelPosition:
def setHandleLabelPosition(self, opt: LabelPosition) -> None:
"""Set where/whether labels are shown adjacent to slider handles."""
self._handle_label_position = opt
for lbl in self._handle_labels:
if not opt:
lbl.hide()
else:
lbl.show()
lbl.setVisible(bool(opt))
trans = opt == LabelPosition.LabelsOnHandle
# TODO: make double clickable to edit
lbl.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, trans)
self.setOrientation(self.orientation())
def edgeLabelMode(self) -> EdgeLabelMode:
"""Return current `EdgeLabelMode`."""
return self._edge_label_mode
def setEdgeLabelMode(self, opt: EdgeLabelMode):
def setEdgeLabelMode(self, opt: EdgeLabelMode) -> None:
"""Set `EdgeLabelMode`, controls what is shown at the min/max labels."""
self._edge_label_mode = opt
if not self._edge_label_mode:
@@ -321,10 +442,80 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
elif opt == EdgeLabelMode.LabelIsRange:
self._min_label.setValue(self._slider.minimum())
self._max_label.setValue(self._slider.maximum())
QApplication.processEvents()
self._reposition_labels()
def _reposition_labels(self):
def setRange(self, min: int, max: int) -> None:
self._on_range_changed(min, max)
def _add_labels(self, layout: QBoxLayout, inverted: bool = False) -> None:
if inverted:
first, second = self._max_label, self._min_label
else:
first, second = self._min_label, self._max_label
layout.addWidget(first)
layout.addWidget(self._slider)
layout.addWidget(second)
def setOrientation(self, orientation: Qt.Orientation) -> None:
"""Set orientation, value will be 'horizontal' or 'vertical'."""
self._slider.setOrientation(orientation)
inverted = self._slider.invertedAppearance()
marg = (0, 0, 0, 0)
if orientation == Qt.Orientation.Vertical:
layout: QBoxLayout = QVBoxLayout()
layout.setSpacing(1)
self._add_labels(layout, inverted=not inverted)
# TODO: set margins based on label width
if self._handle_label_position == LabelPosition.LabelsLeft:
marg = (30, 0, 0, 0)
elif self._handle_label_position == LabelPosition.LabelsRight:
marg = (0, 0, 20, 0)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
else:
layout = QHBoxLayout()
layout.setSpacing(7)
if self._handle_label_position == LabelPosition.LabelsBelow:
marg = (0, 0, 0, 25)
elif self._handle_label_position == LabelPosition.LabelsAbove:
marg = (0, 25, 0, 0)
self._add_labels(layout, inverted=inverted)
# remove old layout
old_layout = self.layout()
if old_layout is not None:
QWidget().setLayout(old_layout)
self.setLayout(layout)
layout.setContentsMargins(*marg)
super().setOrientation(orientation)
self._reposition_labels()
def setInvertedAppearance(self, a0: bool) -> None:
self._slider.setInvertedAppearance(a0)
self.setOrientation(self._slider.orientation())
def resizeEvent(self, a0: Any) -> None:
super().resizeEvent(a0)
self._reposition_labels()
# putting this after methods above for the sake of mypy
LabelPosition = LabelPosition
EdgeLabelMode = EdgeLabelMode
def _getBarColor(self) -> QtGui.QBrush:
return self._slider._style.brush(self._slider._styleOption)
def _setBarColor(self, color: str) -> None:
self._slider._style.brush_active = color
barColor = Property(QtGui.QBrush, _getBarColor, _setBarColor)
"""The color of the bar between the first and last handle."""
# ------------- private methods ----------------
def _rename_signals(self) -> None:
self.valueChanged = self.valuesChanged
def _reposition_labels(self) -> None:
if (
not self._handle_labels
or self._handle_label_position == LabelPosition.NoLabel
@@ -333,17 +524,26 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
horizontal = self.orientation() == Qt.Orientation.Horizontal
labels_above = self._handle_label_position == LabelPosition.LabelsAbove
labels_on_handle = self._handle_label_position == LabelPosition.LabelsOnHandle
last_edge = None
for i, label in enumerate(self._handle_labels):
labels: Iterable[tuple[int, SliderLabel]] = enumerate(self._handle_labels)
if self._slider.invertedAppearance():
labels = reversed(list(labels))
for i, label in labels:
rect = self._slider._handleRect(i)
dx = -label.width() / 2
dx = (-label.width() / 2) + 2
dy = -label.height() / 2
if labels_above:
if labels_above: # or on the right
if horizontal:
dy *= 3
else:
dx *= -1
elif labels_on_handle:
if horizontal:
dy += 0.5
else:
dx += 0.5
else:
if horizontal:
dy *= -1
@@ -360,10 +560,11 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
label.move(pos)
last_edge = pos
label.clearFocus()
label.raise_()
label.show()
self.update()
def _min_label_edited(self, val):
def _min_label_edited(self, val: float) -> None:
if self._edge_label_mode == EdgeLabelMode.LabelIsRange:
self.setMinimum(val)
else:
@@ -372,7 +573,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
self.setValue(v)
self._reposition_labels()
def _max_label_edited(self, val):
def _max_label_edited(self, val: float) -> None:
if self._edge_label_mode == EdgeLabelMode.LabelIsRange:
self.setMaximum(val)
else:
@@ -381,7 +582,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
self.setValue(v)
self._reposition_labels()
def _on_value_changed(self, v):
def _on_value_changed(self, v: tuple[int, ...]) -> None:
if self._edge_label_mode == EdgeLabelMode.LabelIsValue:
self._min_label.setValue(v[0])
self._max_label.setValue(v[-1])
@@ -402,7 +603,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
label.setValue(val)
self._reposition_labels()
def _on_range_changed(self, min, max):
def _on_range_changed(self, min: int, max: int) -> None:
if (min, max) != (self._slider.minimum(), self._slider.maximum()):
self._slider.setRange(min, max)
for lbl in self._handle_labels:
@@ -416,80 +617,48 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
# super().setValue(value)
# self.sliderChange(QSlider.SliderValueChange)
def setRange(self, min, max) -> None:
self._on_range_changed(min, max)
def setOrientation(self, orientation):
"""Set orientation, value will be 'horizontal' or 'vertical'."""
self._slider.setOrientation(orientation)
if orientation == Qt.Orientation.Vertical:
layout = QVBoxLayout()
layout.setSpacing(1)
layout.addWidget(self._max_label)
layout.addWidget(self._slider)
layout.addWidget(self._min_label)
# TODO: set margins based on label width
if self._handle_label_position == LabelPosition.LabelsLeft:
marg = (30, 0, 0, 0)
elif self._handle_label_position == LabelPosition.NoLabel:
marg = (0, 0, 0, 0)
else:
marg = (0, 0, 20, 0)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
else:
layout = QHBoxLayout()
layout.setSpacing(7)
if self._handle_label_position == LabelPosition.LabelsBelow:
marg = (0, 0, 0, 25)
elif self._handle_label_position == LabelPosition.NoLabel:
marg = (0, 0, 0, 0)
else:
marg = (0, 25, 0, 0)
layout.addWidget(self._min_label)
layout.addWidget(self._slider)
layout.addWidget(self._max_label)
# remove old layout
old_layout = self.layout()
if old_layout is not None:
QWidget().setLayout(old_layout)
self.setLayout(layout)
layout.setContentsMargins(*marg)
super().setOrientation(orientation)
QApplication.processEvents()
self._reposition_labels()
def resizeEvent(self, a0) -> None:
super().resizeEvent(a0)
self._reposition_labels()
class QLabeledDoubleRangeSlider(QLabeledRangeSlider):
_slider_class = QDoubleRangeSlider
_slider: QDoubleRangeSlider
_frangeChanged = Signal(float, float)
frangeChanged = Signal(float, float)
def __init__(self, *args, **kwargs) -> None:
@overload
def __init__(self, parent: QWidget | None = ...) -> None: ...
@overload
def __init__(
self, orientation: Qt.Orientation, parent: QWidget | None = ...
) -> None: ...
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.setDecimals(2)
def _rename_signals(self):
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()
def setDecimals(self, prec: int):
def setDecimals(self, prec: int) -> None:
self._min_label.setDecimals(prec)
self._max_label.setDecimals(prec)
for lbl in self._handle_labels:
lbl.setDecimals(prec)
def _getBarColor(self) -> QtGui.QBrush:
return self._slider._style.brush(self._slider._styleOption)
class SliderLabel(QDoubleSpinBox):
def _setBarColor(self, color: str) -> None:
self._slider._style.brush_active = color
barColor = Property(QtGui.QBrush, _getBarColor, _setBarColor)
"""The color of the bar between the first and last handle."""
class SliderLabel(QLineEdit):
def __init__(
self,
slider: QSlider,
@@ -499,94 +668,183 @@ 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 _silent_clear_focus(self):
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)
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:
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 minimum(self):
return self._min
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
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 ----------------
def _silent_clear_focus(self) -> None:
with signals_blocked(self):
self.clearFocus()
def setDecimals(self, prec: int) -> None:
super().setDecimals(prec)
self._update_size()
def _update_size(self, *_):
def _update_size(self, *_: Any) -> None:
# fontmetrics to measure the width of text
fm = QFontMetrics(self.font())
h = self.sizeHint().height()
fixed_content = self.prefix() + self.suffix() + " "
if self._mode == EdgeLabelMode.LabelIsValue:
if self._mode & EdgeLabelMode.LabelIsValue:
# determine width based on min/max/specialValue
mintext = self.textFromValue(self.minimum())[:18] + fixed_content
maxtext = self.textFromValue(self.maximum())[:18] + fixed_content
w = max(0, _fm_width(fm, mintext))
w = max(w, _fm_width(fm, maxtext))
if self.specialValueText():
w = max(w, _fm_width(fm, self.specialValueText()))
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._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
)
self.setFixedSize(size)
def setValue(self, val: Any) -> None:
super().setValue(val)
if self._mode == EdgeLabelMode.LabelIsRange:
self._update_size()
def setMaximum(self, max: int) -> None:
super().setMaximum(max)
if self._mode == EdgeLabelMode.LabelIsValue:
self._update_size()
def setMinimum(self, min: int) -> None:
super().setMinimum(min)
if self._mode == EdgeLabelMode.LabelIsValue:
self._update_size()
def setMode(self, opt: EdgeLabelMode):
# 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)
try:
self._slider.rangeChanged.disconnect(self.setRange)
except Exception:
pass
else:
self.setMinimum(self._slider.minimum())
self.setMaximum(self._slider.maximum())
self._slider.rangeChanged.connect(self.setRange)
self._update_size()
def validate(self, input: str, pos: int):
def validate(
self, input_: str | None, pos: int
) -> tuple[QValidator.State, str, int]:
# fake like an integer spinbox
if "." in input and self.decimals() < 1:
return QValidator.Invalid, input, len(input)
return super().validate(input, pos)
if input_ and "." in input_ and self.decimals() < 1:
return QValidator.State.Invalid, input_, len(input_)
return super().validate(input_, pos)
def _fm_width(fm, text):
def _fm_width(fm: QFontMetrics, text: str) -> int:
if hasattr(fm, "horizontalAdvance"):
return fm.horizontalAdvance(text)
return fm.width(text)

View File

@@ -5,7 +5,6 @@ import re
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING
from qtpy import QT_VERSION
from qtpy.QtCore import Qt
from qtpy.QtGui import (
QBrush,
@@ -140,8 +139,9 @@ CATALINA_STYLE = replace(
tick_offset=4,
)
if QT_VERSION and int(QT_VERSION.split(".")[0]) == 6:
CATALINA_STYLE = replace(CATALINA_STYLE, tick_offset=2)
# I can no longer reproduce the cases in which this was necessary
# if QT_VERSION and int(QT_VERSION.split(".")[0]) == 6:
# CATALINA_STYLE = replace(CATALINA_STYLE, tick_offset=2)
BIG_SUR_STYLE = replace(
CATALINA_STYLE,
@@ -155,8 +155,9 @@ BIG_SUR_STYLE = replace(
tick_bar_alpha=0.2,
)
if QT_VERSION and int(QT_VERSION.split(".")[0]) == 6:
BIG_SUR_STYLE = replace(BIG_SUR_STYLE, tick_offset=-3)
# I can no longer reproduce the cases in which this was necessary
# if QT_VERSION and int(QT_VERSION.split(".")[0]) == 6:
# BIG_SUR_STYLE = replace(BIG_SUR_STYLE, tick_offset=-3)
WINDOWS_STYLE = replace(
BASE_STYLE,
@@ -229,7 +230,7 @@ rgba_pattern = re.compile(
)
def parse_color(color: str, default_attr) -> QColor | QGradient:
def parse_color(color: str, default_attr: str) -> QColor | QGradient:
qc = QColor(color)
if qc.isValid():
return qc
@@ -241,6 +242,7 @@ def parse_color(color: str, default_attr) -> QColor | QGradient:
# try linear gradient:
match = qlineargrad_pattern.search(color)
grad: QGradient
if match:
grad = QLinearGradient(*(float(i) for i in match.groups()[:4]))
grad.setColorAt(0, QColor(match.groupdict()["stop0"]))
@@ -259,12 +261,11 @@ def parse_color(color: str, default_attr) -> QColor | QGradient:
return QColor(getattr(SYSTEM_STYLE, default_attr))
def update_styles_from_stylesheet(obj: _GenericRangeSlider):
def update_styles_from_stylesheet(obj: _GenericRangeSlider) -> None:
qss: str = obj.styleSheet()
parent = obj.parent()
while parent is not None:
while parent and hasattr(parent, "styleSheet"):
qss = parent.styleSheet() + qss
parent = parent.parent()
qss = QApplication.instance().styleSheet() + qss

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
@@ -27,11 +23,11 @@ class _FloatMixin:
return float(value)
class QDoubleSlider(_FloatMixin, _GenericSlider[float]):
class QDoubleSlider(_FloatMixin, _GenericSlider):
pass
class QIntSlider(_IntMixin, _GenericSlider[int]):
class QIntSlider(_IntMixin, _GenericSlider):
# mostly just an example... use QSlider instead.
valueChanged = Signal(int)
@@ -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

@@ -1,3 +1,4 @@
import math
from enum import Enum
from qtpy.QtCore import QSize, Qt, Signal
@@ -24,7 +25,7 @@ class _AnyIntValidator(QValidator):
class QLargeIntSpinBox(QAbstractSpinBox):
"""An integer spinboxes backed by unbound python integer
"""An integer spinboxes backed by unbound python integer.
Qt's built-in ``QSpinBox`` is backed by a signed 32-bit integer.
This could become limiting, particularly in large dense segmentations.
@@ -42,6 +43,9 @@ class QLargeIntSpinBox(QAbstractSpinBox):
self._minimum: int = 0
self._maximum: int = 2**64 - 1
self._single_step: int = 1
self._step_type: QAbstractSpinBox.StepType = (
QAbstractSpinBox.StepType.DefaultStepType
)
self._pending_emit = False
validator = _AnyIntValidator(self)
self.lineEdit().setValidator(validator)
@@ -61,14 +65,20 @@ class QLargeIntSpinBox(QAbstractSpinBox):
def setMinimum(self, min):
self._minimum = int(min)
if self._minimum > self._value:
self.setValue(self._minimum)
def maximum(self):
return self._maximum
def setMaximum(self, max):
self._maximum = int(max)
if self._maximum < self._value:
self.setValue(self._maximum)
def setRange(self, minimum, maximum):
if maximum < minimum:
maximum = minimum
self.setMinimum(minimum)
self.setMaximum(maximum)
@@ -78,7 +88,13 @@ class QLargeIntSpinBox(QAbstractSpinBox):
def setSingleStep(self, step):
self._single_step = int(step)
# TODO: add prefix/suffix/stepType
def setStepType(self, stepType: QAbstractSpinBox.StepType) -> None:
self._step_type = stepType
def stepType(self) -> QAbstractSpinBox.StepType:
return self._step_type
# TODO: add prefix/suffix
# ############### QtOverrides #######################
@@ -102,13 +118,16 @@ class QLargeIntSpinBox(QAbstractSpinBox):
return super().keyPressEvent(e)
def stepBy(self, steps: int) -> None:
step = self._single_step
old = self._value
e = _EmitPolicy.EmitIfChanged
if self._pending_emit:
self._interpret(_EmitPolicy.NeverEmit)
if self._value != old:
e = _EmitPolicy.AlwaysEmit
if self._step_type == QAbstractSpinBox.StepType.AdaptiveDecimalStepType:
step = self._calculate_adaptive_decimal_step(steps)
else:
step = self._single_step
self._setValue(self._bound(self._value + (step * steps)), e)
def stepEnabled(self):
@@ -164,9 +183,12 @@ class QLargeIntSpinBox(QAbstractSpinBox):
v = int(text)
self._setValue(v, policy)
def _editor_text_changed(self, t):
def _editor_text_changed(self, t: str) -> None:
if self.keyboardTracking():
self._setValue(int(t), _EmitPolicy.EmitIfChanged)
try:
self._setValue(int(t), _EmitPolicy.EmitIfChanged)
except ValueError:
pass
self.lineEdit().setFocus()
self._pending_emit = False
else:
@@ -174,3 +196,15 @@ class QLargeIntSpinBox(QAbstractSpinBox):
def _bound(self, value):
return max(self._minimum, min(self._maximum, value))
def _calculate_adaptive_decimal_step(self, steps: int) -> int:
abs_value = abs(self._value)
if abs_value < 100:
return 1
value_negative = self._value < 0
steps_negative = steps < 0
sign_compensation = 0 if value_negative == steps_negative else 1
log = int(math.log10(abs_value - sign_compensation)) - 1
return int(math.pow(10, log))

View File

@@ -11,7 +11,7 @@ except ImportError as e:
from qtpy.QtCore import Signal
from qtpy.QtWidgets import QComboBox, QDoubleSpinBox, QHBoxLayout, QSizePolicy, QWidget
from ..utils import signals_blocked
from superqt.utils import signals_blocked
if TYPE_CHECKING:
from decimal import Decimal
@@ -69,8 +69,8 @@ class QQuantity(QWidget):
def __init__(
self,
value: Union[str, Quantity, Number],
units: Union[UnitsContainer, str, Quantity] = None,
value: Union[str, Quantity, Number] = 0,
units: Optional[Union[UnitsContainer, str, Quantity]] = None,
ureg: Optional[UnitRegistry] = None,
parent: Optional[QWidget] = None,
) -> None:
@@ -78,7 +78,10 @@ class QQuantity(QWidget):
if ureg is None:
ureg = value._REGISTRY if isinstance(value, Quantity) else UREG
else:
assert isinstance(ureg, UnitRegistry)
if not isinstance(ureg, UnitRegistry):
raise TypeError(
f"ureg must be a pint.UnitRegistry, not {type(ureg).__name__}"
)
self._ureg = ureg
self._value: Quantity = self._ureg.Quantity(value, units=units)
@@ -163,10 +166,15 @@ class QQuantity(QWidget):
def setValue(
self,
value: Union[str, Quantity, Number],
units: Union[UnitsContainer, str, Quantity] = None,
units: Optional[Union[UnitsContainer, str, Quantity]] = None,
) -> None:
"""Set the current value (will cast to a pint Quantity)."""
new_val = self._ureg.Quantity(value, units=units)
if isinstance(value, Quantity):
if units is not None:
raise ValueError("Cannot specify units if value is a Quantity")
new_val = self._ureg.Quantity(value.magnitude, units=value.units)
else:
new_val = self._ureg.Quantity(value, units=units)
mag_change = new_val.magnitude != self._value.magnitude
units_change = new_val.units != self._value.units

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,23 +1,35 @@
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from superqt.cmap import draw_colormap
__all__ = (
"CodeSyntaxHighlight",
"create_worker",
"ensure_main_thread",
"ensure_object_thread",
"FunctionWorker",
"GeneratorWorker",
"new_worker_qthread",
"qdebounced",
"QFlowLayout",
"QMessageHandler",
"QSignalDebouncer",
"QSignalThrottler",
"WorkerBase",
"create_worker",
"draw_colormap",
"ensure_main_thread",
"ensure_object_thread",
"exceptions_as_dialog",
"new_worker_qthread",
"qdebounced",
"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
from ._qthreading import (
@@ -29,3 +41,11 @@ from ._qthreading import (
thread_worker,
)
from ._throttler import QSignalDebouncer, QSignalThrottler, qdebounced, qthrottled
def __getattr__(name: str) -> Any: # pragma: no cover
if name == "draw_colormap":
from superqt.cmap import draw_colormap
return draw_colormap
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View File

@@ -1,93 +1,268 @@
from itertools import takewhile
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,
)
# inspired by https://github.com/Vector35/snippets/blob/master/QCodeEditor.py (MIT license) and
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):
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
"""
Return a QTextCharFormat with the given attributes.
https://pygments.org/docs/formatterdevelopment/#html-3-2-formatter
"""
text_char_format = QtGui.QTextCharFormat()
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"]))
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):
"""
`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`.
def format(
self, tokensource: Sequence[tuple[_TokenType, str]], outfile: Any
) -> None:
"""Format the given token stream.
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 highlightBlock(self, text):
cb = self.currentBlock()
p = cb.position()
text_ = self.document().toPlainText() + "\n"
highlight(text_, self.lexer, self.formatter)
enters = sum(1 for _ in takewhile(lambda x: x == "\n", text_))
# pygments lexer ignore leading empty lines, so we need to do correction
# here calculating the number of empty lines.
def background_color(self) -> str:
style = cast("pygments.style.StyleMeta", self.formatter.style)
return style.background_color
def highlightBlock(self, text: str | None) -> None:
# dirty, dirty hack
# The core problem is that pygemnts by default use string streams,
# that will not handle QTextCharFormat, so wee need use `data` property to work around this.
for i in range(len(text)):
try:
self.setFormat(i, 1, self.formatter.data[p + i - enters])
except IndexError: # pragma: no cover
pass
# 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.
if text:
highlight(text, self.lexer, self.formatter)
for i in range(len(text)):
self.setFormat(i, 1, self.formatter.data[i])

View File

@@ -1,7 +1,10 @@
# https://gist.github.com/FlorianRhiem/41a1ad9b694c14fb9ac3
from __future__ import annotations
from concurrent.futures import Future
from contextlib import suppress
from functools import wraps
from typing import Callable, List, Optional
from typing import TYPE_CHECKING, Any, Callable, ClassVar, overload
from qtpy.QtCore import (
QCoreApplication,
@@ -13,12 +16,22 @@ from qtpy.QtCore import (
Slot,
)
from ._util import get_max_args
if TYPE_CHECKING:
from typing import TypeVar
from typing_extensions import Literal, ParamSpec
P = ParamSpec("P")
R = TypeVar("R")
class CallCallable(QObject):
finished = Signal(object)
instances: List["CallCallable"] = []
instances: ClassVar[list[CallCallable]] = []
def __init__(self, callable, *args, **kwargs):
def __init__(self, callable: Callable, args: tuple, kwargs: dict):
super().__init__()
self._callable = callable
self._args = args
@@ -29,11 +42,36 @@ class CallCallable(QObject):
def call(self):
CallCallable.instances.remove(self)
res = self._callable(*self._args, **self._kwargs)
self.finished.emit(res)
with suppress(RuntimeError):
self.finished.emit(res)
# fmt: off
@overload
def ensure_main_thread(
func: Optional[Callable] = None, await_return: bool = False, timeout: int = 1000
await_return: Literal[True],
timeout: int = 1000,
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
@overload
def ensure_main_thread(
func: Callable[P, R],
await_return: Literal[True],
timeout: int = 1000,
) -> Callable[P, R]: ...
@overload
def ensure_main_thread(
await_return: Literal[False] = False,
timeout: int = 1000,
) -> Callable[[Callable[P, R]], Callable[P, Future[R]]]: ...
@overload
def ensure_main_thread(
func: Callable[P, R],
await_return: Literal[False] = False,
timeout: int = 1000,
) -> Callable[P, Future[R]]: ...
# fmt: on
def ensure_main_thread(
func: Callable | None = None, await_return: bool = False, timeout: int = 1000
):
"""Decorator that ensures a function is called in the main QApplication thread.
@@ -52,26 +90,50 @@ def ensure_main_thread(
"""
def _out_func(func_):
max_args = get_max_args(func_)
@wraps(func_)
def _func(*args, **kwargs):
def _func(*args, _max_args_=max_args, **kwargs):
return _run_in_thread(
func_,
QCoreApplication.instance().thread(),
await_return,
timeout,
*args,
**kwargs,
args[:_max_args_],
kwargs,
)
return _func
if func is None:
return _out_func
return _out_func(func)
return _out_func if func is None else _out_func(func)
# fmt: off
@overload
def ensure_object_thread(
func: Optional[Callable] = None, await_return: bool = False, timeout: int = 1000
await_return: Literal[True],
timeout: int = 1000,
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
@overload
def ensure_object_thread(
func: Callable[P, R],
await_return: Literal[True],
timeout: int = 1000,
) -> Callable[P, R]: ...
@overload
def ensure_object_thread(
await_return: Literal[False] = False,
timeout: int = 1000,
) -> Callable[[Callable[P, R]], Callable[P, Future[R]]]: ...
@overload
def ensure_object_thread(
func: Callable[P, R],
await_return: Literal[False] = False,
timeout: int = 1000,
) -> Callable[P, Future[R]]: ...
# fmt: on
def ensure_object_thread(
func: Callable | None = None, await_return: bool = False, timeout: int = 1000
):
"""Decorator that ensures a QObject method is called in the object's thread.
@@ -90,17 +152,18 @@ def ensure_object_thread(
"""
def _out_func(func_):
max_args = get_max_args(func_)
@wraps(func_)
def _func(self, *args, **kwargs):
def _func(*args, _max_args_=max_args, **kwargs):
thread = args[0].thread() # self
return _run_in_thread(
func_, self.thread(), await_return, timeout, self, *args, **kwargs
func_, thread, await_return, timeout, args[:_max_args_], kwargs
)
return _func
if func is None:
return _out_func
return _out_func(func)
return _out_func if func is None else _out_func(func)
def _run_in_thread(
@@ -108,9 +171,9 @@ def _run_in_thread(
thread: QThread,
await_return: bool,
timeout: int,
*args,
**kwargs,
):
args: tuple,
kwargs: dict,
) -> Any:
future = Future() # type: ignore
if thread is QThread.currentThread():
result = func(*args, **kwargs)
@@ -118,7 +181,8 @@ def _run_in_thread(
future.set_result(result)
return future
return result
f = CallCallable(func, *args, **kwargs)
f = CallCallable(func, args, kwargs)
f.moveToThread(thread)
f.finished.connect(future.set_result, Qt.ConnectionType.DirectConnection)
QMetaObject.invokeMethod(f, "call", Qt.ConnectionType.QueuedConnection) # type: ignore

View File

@@ -1,52 +0,0 @@
from concurrent.futures import Future
from typing import Callable, TypeVar, overload
from typing_extensions import Literal, ParamSpec
P = ParamSpec("P")
R = TypeVar("R")
@overload
def ensure_main_thread(
await_return: Literal[True],
timeout: int = 1000,
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
@overload
def ensure_main_thread(
func: Callable[P, R],
await_return: Literal[True],
timeout: int = 1000,
) -> Callable[P, R]: ...
@overload
def ensure_main_thread(
await_return: Literal[False] = False,
timeout: int = 1000,
) -> Callable[[Callable[P, R]], Callable[P, Future[R]]]: ...
@overload
def ensure_main_thread(
func: Callable[P, R],
await_return: Literal[False] = False,
timeout: int = 1000,
) -> Callable[P, Future[R]]: ...
@overload
def ensure_object_thread(
await_return: Literal[True],
timeout: int = 1000,
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
@overload
def ensure_object_thread(
func: Callable[P, R],
await_return: Literal[True],
timeout: int = 1000,
) -> Callable[P, R]: ...
@overload
def ensure_object_thread(
await_return: Literal[False] = False,
timeout: int = 1000,
) -> Callable[[Callable[P, R]], Callable[P, Future[R]]]: ...
@overload
def ensure_object_thread(
func: Callable[P, R],
await_return: Literal[False] = False,
timeout: int = 1000,
) -> Callable[P, Future[R]]: ...

View File

@@ -0,0 +1,164 @@
from __future__ import annotations
import traceback
from typing import TYPE_CHECKING, cast
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QErrorMessage, QMessageBox, QWidget
if TYPE_CHECKING:
from types import TracebackType
_DEFAULT_FLAGS = Qt.WindowType.Dialog | Qt.WindowType.MSWindowsFixedSizeDialogHint
class exceptions_as_dialog:
"""Context manager that shows a dialog when an exception is raised.
See examples below for common usage patterns.
To determine whether an exception was raised or not, check the `exception`
attribute after the context manager has exited. If `use_error_message` is `False`
(the default), you can also access the `dialog` attribute to get/manipulate the
`QMessageBox` instance.
Parameters
----------
exceptions : type[BaseException] | tuple[type[BaseException], ...], optional
The exception(s) to catch, by default `Exception` (i.e. all exceptions).
icon : QMessageBox.Icon, optional
The icon to show in the QMessageBox, by default `QMessageBox.Icon.Critical`
title : str, optional
The title of the `QMessageBox`, by default `"An error occurred"`.
msg_template : str, optional
The message to show in the `QMessageBox`. The message will be formatted
using three variables:
- `exc_value`: the exception instance
- `exc_type`: the exception type
- `tb`: the traceback as a string
The default template is the content of the exception: `"{exc_value}"`
buttons : QMessageBox.StandardButton, optional
The buttons to show in the `QMessageBox`, by default
`QMessageBox.StandardButton.Ok`
parent : QWidget | None, optional
The parent widget of the `QMessageBox`, by default `None`
use_error_message : bool | QErrorMessage, optional
Whether to use a `QErrorMessage` instead of a `QMessageBox`. By default
`False`. `QErrorMessage` shows a checkbox that the user can check to
prevent seeing the message again (based on the text of the formatted
`msg_template`.) If `True`, the global `QMessageError.qtHandler()`
instance is used to maintain a history of dismissed messages. You may also pass
a `QErrorMessage` instance to use a specific instance. If `use_error_message` is
True, or if you pass your own `QErrorMessage` instance, the `parent` argument
is ignored.
Attributes
----------
dialog : QMessageBox | None
The `QMessageBox` instance that was created (if `use_error_message` was
`False`). This can be used, among other things, to determine the result of
the dialog (e.g. `dialog.result()`) or to manipulate the dialog (e.g.
`dialog.setDetailedText("some text")`).
exception : BaseException | None
Will hold the exception instance if an exception was raised and caught.
Examples
--------
```python
from qtpy.QtWidgets import QApplication
from superqt.utils import exceptions_as_dialog
app = QApplication([])
with exceptions_as_dialog() as ctx:
raise Exception("This will be caught and shown in a QMessageBox")
# you can access the exception instance here
assert ctx.exception is not None
# with exceptions_as_dialog(ValueError):
# 1 / 0 # ZeroDivisionError is not caught, so this will raise
with exceptions_as_dialog(msg_template="Error: {exc_value}"):
raise Exception("This message will be inserted at 'exc_value'")
for _i in range(3):
with exceptions_as_dialog(AssertionError, use_error_message=True):
assert False, "Uncheck the checkbox to ignore this in the future"
# use ctx.dialog to get the result of the dialog
btns = QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel
with exceptions_as_dialog(buttons=btns) as ctx:
raise Exception("This will be caught and shown in a QMessageBox")
print(ctx.dialog.result()) # prints which button was clicked
app.exec() # needed only for the use_error_message example to show
```
"""
dialog: QMessageBox | None
exception: BaseException | None
exec_result: int | None = None
def __init__(
self,
exceptions: type[BaseException] | tuple[type[BaseException], ...] = Exception,
icon: QMessageBox.Icon = QMessageBox.Icon.Critical,
title: str = "An error occurred",
msg_template: str = "{exc_value}",
buttons: QMessageBox.StandardButton = QMessageBox.StandardButton.Ok,
parent: QWidget | None = None,
flags: Qt.WindowType = _DEFAULT_FLAGS,
use_error_message: bool | QErrorMessage = False,
):
self.exceptions = exceptions
self.msg_template = msg_template
self.exception = None
self.dialog = None
self._err_msg = use_error_message
if not use_error_message:
# the message will be overwritten in __exit__
self.dialog = QMessageBox(
icon, title, "An error occurred", buttons, parent, flags
)
def __enter__(self) -> exceptions_as_dialog:
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
tb: TracebackType | None,
) -> bool:
if not (exc_value is not None and isinstance(exc_value, self.exceptions)):
return False # let it propagate
# save the exception for later
self.exception = exc_value
# format the message using the context variables
if "{tb}" in self.msg_template:
_tb = "\n".join(traceback.format_exception(exc_type, exc_value, tb))
else:
_tb = ""
text = self.msg_template.format(exc_value=exc_value, exc_type=exc_type, tb=_tb)
# show the dialog
if self._err_msg:
msg = (
self._err_msg
if isinstance(self._err_msg, QErrorMessage)
else QErrorMessage.qtHandler()
)
cast("QErrorMessage", msg).showMessage(text)
elif self.dialog is not None: # it won't be if use_error_message=False
self.dialog.setText(text)
self.dialog.exec()
return True # swallow the exception

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

@@ -0,0 +1,45 @@
import sys
from typing import TYPE_CHECKING
from qtpy.QtGui import QImage
if TYPE_CHECKING:
import numpy as np
def qimage_to_array(img: QImage) -> "np.ndarray":
"""Convert QImage to an array.
Parameters
----------
img : QImage
QImage to be converted.
Returns
-------
arr : np.ndarray
Numpy array of type uint8 and shape (h, w, 4). Index [0, 0] is the
upper-left corner of the rendered region.
"""
import numpy as np
# cast to ARGB32 if necessary
if img.format() != QImage.Format.Format_ARGB32:
img = img.convertToFormat(QImage.Format.Format_ARGB32)
h, w, c = img.height(), img.width(), 4
# pyside returns a memoryview, pyqt returns a sizeless void pointer
b = img.constBits() # Returns a pointer to the first pixel data.
if hasattr(b, "setsize"):
b.setsize(h * w * c)
# reshape to h, w, c
arr = np.frombuffer(b, np.uint8).reshape(h, w, c)
# reverse channel colors for numpy
# 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,6 +1,8 @@
from __future__ import annotations
import logging
from contextlib import suppress
from typing import List, NamedTuple, Optional
from typing import ClassVar, NamedTuple
from qtpy.QtCore import QMessageLogContext, QtMsgType, qInstallMessageHandler
@@ -28,7 +30,6 @@ class QMessageHandler:
Examples
--------
>>> handler = QMessageHandler()
>>> handler.install() # now all Qt output will be available at mh.records
@@ -37,10 +38,10 @@ class QMessageHandler:
>>> logger = logging.getLogger(__name__)
>>> with QMessageHandler(logger): # re-reoute Qt messages to a python logger.
... ...
... ...
"""
_qt2loggertype = {
_qt2loggertype: ClassVar[dict[QtMsgType, int]] = {
QtMsgType.QtDebugMsg: logging.DEBUG,
QtMsgType.QtInfoMsg: logging.INFO,
QtMsgType.QtWarningMsg: logging.WARNING,
@@ -49,10 +50,10 @@ class QMessageHandler:
QtMsgType.QtSystemMsg: logging.CRITICAL,
}
def __init__(self, logger: Optional[logging.Logger] = None):
self.records: List[Record] = []
def __init__(self, logger: logging.Logger | None = None):
self.records: list[Record] = []
self._logger = logger
self._previous_handler: Optional[object] = "__uninstalled__"
self._previous_handler: object | None = "__uninstalled__"
def install(self):
"""Install this handler (override the current QtMessageHandler)."""
@@ -68,7 +69,7 @@ class QMessageHandler:
return f"<{n} object at {hex(id(self))} with {len(self.records)} records>"
def __enter__(self):
"""Enter a context with this handler installed"""
"""Enter a context with this handler installed."""
self.install()
return self

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
@@ -7,7 +8,24 @@ if TYPE_CHECKING:
@contextmanager
def signals_blocked(obj: "QObject") -> Iterator[None]:
"""Context manager to temporarily block signals emitted by QObject: `obj`."""
"""Context manager to temporarily block signals emitted by QObject: `obj`.
Parameters
----------
obj : QObject
The QObject whose signals should be blocked.
Examples
--------
```python
from qtpy.QtWidgets import QSpinBox
from superqt import signals_blocked
spinbox = QSpinBox()
with signals_blocked(spinbox):
spinbox.setValue(10)
```
"""
previous = obj.blockSignals(True)
try:
yield

View File

@@ -8,35 +8,28 @@ from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
Generator,
ClassVar,
Generic,
Optional,
Sequence,
Set,
Type,
TypeVar,
Union,
overload,
)
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]):
@staticmethod
def connect(slot: Callable[[_T], Any], type: Optional[type] = ...) -> None:
...
def connect(slot: Callable[[_T], Any], type: type | None = ...) -> None: ...
@staticmethod
def disconnect(slot: Callable[[_T], Any] = ...) -> None:
...
def disconnect(slot: Callable[[_T], Any] = ...) -> None: ...
@staticmethod
def emit(*args: _T) -> None:
...
def emit(*args: _T) -> None: ...
from typing_extensions import Literal, ParamSpec
@@ -56,12 +49,12 @@ _R = TypeVar("_R")
def as_generator_function(
func: Callable[_P, _R]
func: Callable[_P, _R],
) -> Callable[_P, Generator[None, None, _R]]:
"""Turns a regular function (single return) into a generator function."""
@wraps(func)
def genwrapper(*args, **kwargs) -> Generator[None, None, _R]:
def genwrapper(*args: Any, **kwargs: Any) -> Generator[None, None, _R]:
yield
return func(*args, **kwargs)
@@ -69,10 +62,9 @@ def as_generator_function(
class WorkerBaseSignals(QObject):
started = Signal() # emitted when the work is started
finished = Signal() # emitted when the work is finished
_finished = Signal(object) # emitted when the work is finished ro delete
_finished = Signal(object) # emitted when the work is finished to delete
returned = Signal(object) # emitted with return value
errored = Signal(object) # emitted with error object on Exception
warned = Signal(tuple) # emitted with showwarning args on warning
@@ -93,7 +85,7 @@ class WorkerBase(QRunnable, Generic[_R]):
"""
#: A set of Workers. Add to set using `WorkerBase.start`
_worker_set: Set[WorkerBase] = set()
_worker_set: ClassVar[set[WorkerBase]] = set()
returned: SigInst[_R]
errored: SigInst[Exception]
warned: SigInst[tuple]
@@ -102,8 +94,8 @@ class WorkerBase(QRunnable, Generic[_R]):
def __init__(
self,
func: Optional[Callable[_P, _R]] = None,
SignalsClass: Type[WorkerBaseSignals] = WorkerBaseSignals,
func: Callable[_P, _R] | None = None,
SignalsClass: type[WorkerBaseSignals] = WorkerBaseSignals,
) -> None:
super().__init__()
self._abort_requested = False
@@ -148,7 +140,7 @@ class WorkerBase(QRunnable, Generic[_R]):
@property
def is_running(self) -> bool:
"""Whether the worker has been started"""
"""Whether the worker has been started."""
return self._running
def run(self) -> None:
@@ -190,6 +182,7 @@ class WorkerBase(QRunnable, Generic[_R]):
warnings.warn(
f"RuntimeError in aborted thread: {result}",
RuntimeWarning,
stacklevel=2,
)
return
else:
@@ -202,20 +195,19 @@ class WorkerBase(QRunnable, Generic[_R]):
self.finished.emit()
self._finished.emit(self)
def work(self) -> Union[Exception, _R]:
def work(self) -> Exception | _R:
"""Main method to execute the worker.
The end-user should never need to call this function.
But subclasses must implement this method (See
[`GeneratorFunction.work`][superqt.utils._qthreading.GeneratorWorker.work] for an example implementation).
Minimally, it should check `self.abort_requested` periodically and
exit if True.
[`GeneratorFunction.work`][superqt.utils._qthreading.GeneratorWorker.work] for
an example implementation). Minimally, it should check `self.abort_requested`
periodically and exit if True.
Examples
--------
```python
class MyWorker(WorkerBase):
def work(self):
i = 0
while True:
@@ -267,7 +259,7 @@ class WorkerBase(QRunnable, Generic[_R]):
cls._worker_set.discard(obj)
@classmethod
def await_workers(cls, msecs: int = None) -> None:
def await_workers(cls, msecs: int | None = None) -> None:
"""Ask all workers to quit, and wait up to `msec` for quit.
Attempts to clean up all running workers by calling `worker.quit()`
@@ -363,7 +355,6 @@ class FunctionWorker(WorkerBase[_R]):
class GeneratorWorkerSignals(WorkerBaseSignals):
yielded = Signal(object) # emitted with yielded values (if generator used)
paused = Signal() # emitted when a running job has successfully paused
resumed = Signal() # emitted when a paused job has successfully resumed
@@ -397,9 +388,9 @@ class GeneratorWorker(WorkerBase, Generic[_Y, _S, _R]):
def __init__(
self,
func: Callable[_P, Generator[_Y, Optional[_S], _R]],
func: Callable[_P, Generator[_Y, _S | None, _R]],
*args,
SignalsClass: Type[WorkerBaseSignals] = GeneratorWorkerSignals,
SignalsClass: type[WorkerBaseSignals] = GeneratorWorkerSignals,
**kwargs,
):
if not inspect.isgeneratorfunction(func):
@@ -410,7 +401,7 @@ class GeneratorWorker(WorkerBase, Generic[_Y, _S, _R]):
super().__init__(SignalsClass=SignalsClass)
self._gen = func(*args, **kwargs)
self._incoming_value: Optional[_S] = None
self._incoming_value: _S | None = None
self._pause_requested = False
self._resume_requested = False
self._paused = False
@@ -419,7 +410,7 @@ class GeneratorWorker(WorkerBase, Generic[_Y, _S, _R]):
self._pause_interval = 0.01
self.pbar = None
def work(self) -> Union[Optional[_R], Exception]:
def work(self) -> _R | None | Exception:
"""Core event loop that calls the original function.
Enters a continual loop, yielding and returning from the original
@@ -445,8 +436,8 @@ class GeneratorWorker(WorkerBase, Generic[_Y, _S, _R]):
self.paused.emit()
continue
try:
input = self._next_value()
output = self._gen.send(input)
_input = self._next_value()
output = self._gen.send(_input)
self.yielded.emit(output)
except StopIteration as exc:
return exc.value
@@ -460,7 +451,7 @@ class GeneratorWorker(WorkerBase, Generic[_Y, _S, _R]):
"""Send a value into the function (if a generator was used)."""
self._incoming_value = value
def _next_value(self) -> Optional[_S]:
def _next_value(self) -> _S | None:
out = None
if self._incoming_value is not None:
out = self._incoming_value
@@ -499,37 +490,35 @@ class GeneratorWorker(WorkerBase, Generic[_Y, _S, _R]):
def create_worker(
func: Callable[_P, Generator[_Y, _S, _R]],
*args,
_start_thread: Optional[bool] = None,
_connect: Optional[Dict[str, Union[Callable, Sequence[Callable]]]] = None,
_worker_class: Union[Type[GeneratorWorker], Type[FunctionWorker], None] = None,
_start_thread: bool | None = None,
_connect: dict[str, Callable | Sequence[Callable]] | None = None,
_worker_class: type[GeneratorWorker] | type[FunctionWorker] | None = None,
_ignore_errors: bool = False,
**kwargs,
) -> GeneratorWorker[_Y, _S, _R]:
...
) -> GeneratorWorker[_Y, _S, _R]: ...
@overload
def create_worker(
func: Callable[_P, _R],
*args,
_start_thread: Optional[bool] = None,
_connect: Optional[Dict[str, Union[Callable, Sequence[Callable]]]] = None,
_worker_class: Union[Type[GeneratorWorker], Type[FunctionWorker], None] = None,
_start_thread: bool | None = None,
_connect: dict[str, Callable | Sequence[Callable]] | None = None,
_worker_class: type[GeneratorWorker] | type[FunctionWorker] | None = None,
_ignore_errors: bool = False,
**kwargs,
) -> FunctionWorker[_R]:
...
) -> FunctionWorker[_R]: ...
def create_worker(
func: Callable,
*args,
_start_thread: Optional[bool] = None,
_connect: Optional[Dict[str, Union[Callable, Sequence[Callable]]]] = None,
_worker_class: Union[Type[GeneratorWorker], Type[FunctionWorker], None] = None,
_start_thread: bool | None = None,
_connect: dict[str, Callable | Sequence[Callable]] | None = None,
_worker_class: type[GeneratorWorker] | type[FunctionWorker] | None = None,
_ignore_errors: bool = False,
**kwargs,
) -> Union[FunctionWorker, GeneratorWorker]:
) -> FunctionWorker | GeneratorWorker:
"""Convenience function to start a function in another thread.
By default, uses `FunctionWorker` for functions and `GeneratorWorker` for
@@ -579,12 +568,14 @@ def create_worker(
```python
def long_function(duration):
import time
time.sleep(duration)
worker = create_worker(long_function, 10)
```
"""
worker: Union[FunctionWorker, GeneratorWorker]
worker: FunctionWorker | GeneratorWorker
if not _worker_class:
if inspect.isgeneratorfunction(func):
@@ -631,47 +622,46 @@ def create_worker(
@overload
def thread_worker(
function: Callable[_P, Generator[_Y, _S, _R]],
start_thread: Optional[bool] = None,
connect: Optional[Dict[str, Union[Callable, Sequence[Callable]]]] = None,
worker_class: Optional[Type[WorkerBase]] = None,
start_thread: bool | None = None,
connect: dict[str, Callable | Sequence[Callable]] | None = None,
worker_class: type[WorkerBase] | None = None,
ignore_errors: bool = False,
) -> Callable[_P, GeneratorWorker[_Y, _S, _R]]:
...
) -> Callable[_P, GeneratorWorker[_Y, _S, _R]]: ...
@overload
def thread_worker(
function: Callable[_P, _R],
start_thread: Optional[bool] = None,
connect: Optional[Dict[str, Union[Callable, Sequence[Callable]]]] = None,
worker_class: Optional[Type[WorkerBase]] = None,
start_thread: bool | None = None,
connect: dict[str, Callable | Sequence[Callable]] | None = None,
worker_class: type[WorkerBase] | None = None,
ignore_errors: bool = False,
) -> Callable[_P, FunctionWorker[_R]]:
...
) -> Callable[_P, FunctionWorker[_R]]: ...
@overload
def thread_worker(
function: Literal[None] = None,
start_thread: Optional[bool] = None,
connect: Optional[Dict[str, Union[Callable, Sequence[Callable]]]] = None,
worker_class: Optional[Type[WorkerBase]] = None,
start_thread: bool | None = None,
connect: dict[str, Callable | Sequence[Callable]] | None = None,
worker_class: type[WorkerBase] | None = None,
ignore_errors: bool = False,
) -> Callable[[Callable], Callable[_P, Union[FunctionWorker, GeneratorWorker]]]:
...
) -> Callable[[Callable], Callable[_P, FunctionWorker | GeneratorWorker]]: ...
def thread_worker(
function: Optional[Callable] = None,
start_thread: Optional[bool] = None,
connect: Optional[Dict[str, Union[Callable, Sequence[Callable]]]] = None,
worker_class: Optional[Type[WorkerBase]] = None,
function: Callable | None = None,
start_thread: bool | None = None,
connect: dict[str, Callable | Sequence[Callable]] | None = None,
worker_class: type[WorkerBase] | None = None,
ignore_errors: bool = False,
):
"""Decorator that runs a function in a separate thread when called.
When called, the decorated function returns a [`WorkerBase`][superqt.utils.WorkerBase]. See
[`create_worker`][superqt.utils.create_worker] for additional keyword arguments that can be used
When called, the decorated function returns a
[`WorkerBase`][superqt.utils.WorkerBase]. See
[`create_worker`][superqt.utils.create_worker] for additional keyword arguments that
can be used
when calling the function.
The returned worker will have these signals:
@@ -715,8 +705,9 @@ def thread_worker(
worker class. by default None
worker_class : Type[WorkerBase]
The [`WorkerBase`][superqt.utils.WorkerBase] to instantiate, by default
[`FunctionWorker`][superqt.utils.FunctionWorker] will be used if `func` is a regular function,
and [`GeneratorWorker`][superqt.utils.GeneratorWorker] will be used if it is a generator.
[`FunctionWorker`][superqt.utils.FunctionWorker] will be used if `func` is a
regular function, and [`GeneratorWorker`][superqt.utils.GeneratorWorker] will be
used if it is a generator.
ignore_errors : bool
If `False` (the default), errors raised in the other thread will be
reraised in the main thread (makes debugging significantly easier).
@@ -739,7 +730,8 @@ def thread_worker(
yield i
# do teardown
return 'anything'
return "anything"
# call the function to start running in another thread.
worker = long_function()
@@ -774,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).
@@ -785,40 +777,25 @@ 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:
class WorkerProtocol(QObject):
finished: Signal
def work(self) -> None:
...
def work(self) -> None: ...
def new_worker_qthread(
Worker: Type[WorkerProtocol],
Worker: type[WorkerProtocol],
*args,
_start_thread: bool = False,
_connect: Dict[str, Callable] = None,
_connect: dict[str, Callable] | None = None,
**kwargs,
):
"""This is a convenience function to start a worker in a `QThread`.
"""Convenience function to start a worker in a `QThread`.
In most cases, the [thread_worker][superqt.utils.thread_worker] decorator is
sufficient and preferable. But this allows the user to completely customize the
Worker object. However, they must then maintain control over the thread and clean up
appropriately.
It follows the pattern described
[here](https://www.qt.io/blog/2010/06/17/youre-doing-it-wrong) and in the [qt thread
docs](https://doc.qt.io/qt-5/qthread.html#details)
see also:
https://mayaposch.wordpress.com/2011/11/01/how-to-really-truly-use-qthreads-the-full-explanation/
A QThread object is not a thread! It should be thought of as a class to *manage* a
thread, not as the actual code or object that runs in that
thread. The QThread object is created on the main thread and lives there.
@@ -831,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
----------
@@ -862,9 +839,7 @@ def new_worker_qthread(
Create some QObject that has a long-running work method:
```python
class Worker(QObject):
finished = Signal()
increment = Signal(int)
@@ -876,20 +851,21 @@ def new_worker_qthread(
def work(self):
# some long running task...
import time
for i in range(10):
time.sleep(1)
self.increment.emit(i)
self.finished.emit()
worker, thread = new_worker_qthread(
Worker,
'argument',
"argument",
_start_thread=True,
_connect={'increment': print},
_connect={"increment": print},
)
```
"""
if _connect and not isinstance(_connect, dict):
raise TypeError("_connect parameter must be a dict")

View File

@@ -1,4 +1,4 @@
"""Adapted for python from the KDToolBox
"""Adapted for python from the KDToolBox.
https://github.com/KDAB/KDToolBox/tree/master/qt/KDSignalThrottler
@@ -26,17 +26,25 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""
import sys
from __future__ import annotations
import warnings
from concurrent.futures import Future
from contextlib import suppress
from enum import IntFlag, auto
from functools import wraps
from typing import TYPE_CHECKING, Callable, Generic, Optional, TypeVar, Union, overload
from inspect import signature
from types import MethodType
from typing import TYPE_CHECKING, Any, Callable, Generic, TypeVar, overload
from weakref import WeakKeyDictionary, WeakMethod
from qtpy.QtCore import QObject, Qt, QTimer, Signal
from ._util import get_max_args
if TYPE_CHECKING:
from qtpy.QtCore import SignalInstance
from typing_extensions import Literal, ParamSpec
from typing_extensions import ParamSpec
P = ParamSpec("P")
# maintain runtime compatibility with older typing_extensions
@@ -49,6 +57,12 @@ else:
P = TypeVar("P")
R = TypeVar("R")
REF_ERROR = (
"To use qthrottled or qdebounced as a method decorator, "
"objects must have `__dict__` or be weak referenceable. "
"Please either add `__weakref__` to `__slots__` or use"
"qthrottled/qdebounced as a function (not a decorator)."
)
class Kind(IntFlag):
@@ -62,7 +76,6 @@ class EmissionPolicy(IntFlag):
class GenericSignalThrottler(QObject):
triggered = Signal()
timeoutChanged = Signal(int)
timerTypeChanged = Signal(Qt.TimerType)
@@ -71,7 +84,7 @@ class GenericSignalThrottler(QObject):
self,
kind: Kind,
emissionPolicy: EmissionPolicy,
parent: Optional[QObject] = None,
parent: QObject | None = None,
) -> None:
super().__init__(parent)
@@ -79,7 +92,7 @@ class GenericSignalThrottler(QObject):
self._emissionPolicy = emissionPolicy
self._hasPendingEmission = False
self._timer = QTimer()
self._timer = QTimer(parent=self)
self._timer.setSingleShot(True)
self._timer.setTimerType(Qt.TimerType.PreciseTimer)
self._timer.timeout.connect(self._maybeEmitTriggered)
@@ -94,10 +107,10 @@ class GenericSignalThrottler(QObject):
def timeout(self) -> int:
"""Return current timeout in milliseconds."""
return self._timer.interval() # type: ignore
return self._timer.interval()
def setTimeout(self, timeout: int) -> None:
"""Set timeout in milliseconds"""
"""Set timeout in milliseconds."""
if self._timer.interval() != timeout:
self._timer.setInterval(timeout)
self.timeoutChanged.emit(timeout)
@@ -133,24 +146,32 @@ class GenericSignalThrottler(QObject):
elif self._kind is Kind.Debouncer:
self._timer.start() # restart
assert self._timer.isActive()
def cancel(self) -> None:
"""Cancel any pending emissions."""
self._hasPendingEmission = False
def flush(self) -> None:
"""Force emission of any pending emissions."""
self._maybeEmitTriggered()
def flush(self, restart_timer: bool = True) -> None:
"""
Force emission of any pending emissions.
Parameters
----------
restart_timer : bool
Whether to restart the timer after flushing.
Defaults to True.
"""
self._maybeEmitTriggered(restart_timer=restart_timer)
def _emitTriggered(self) -> None:
self._hasPendingEmission = False
self.triggered.emit()
self._timer.start()
def _maybeEmitTriggered(self) -> None:
def _maybeEmitTriggered(self, restart_timer: bool = True) -> None:
if self._hasPendingEmission:
self._emitTriggered()
if not restart_timer:
self._timer.stop()
Kind = Kind
EmissionPolicy = EmissionPolicy
@@ -169,7 +190,7 @@ class QSignalThrottler(GenericSignalThrottler):
def __init__(
self,
policy: EmissionPolicy = EmissionPolicy.Leading,
parent: Optional[QObject] = None,
parent: QObject | None = None,
) -> None:
super().__init__(Kind.Throttler, policy, parent)
@@ -184,7 +205,7 @@ class QSignalDebouncer(GenericSignalThrottler):
def __init__(
self,
policy: EmissionPolicy = EmissionPolicy.Trailing,
parent: Optional[QObject] = None,
parent: QObject | None = None,
) -> None:
super().__init__(Kind.Debouncer, policy, parent)
@@ -192,30 +213,119 @@ class QSignalDebouncer(GenericSignalThrottler):
# below here part is unique to superqt (not from KD)
if TYPE_CHECKING:
from typing_extensions import Protocol
def _weak_func(func: Callable[P, R]) -> Callable[P, R]:
if isinstance(func, MethodType):
# this is a bound method, we need to avoid strong references
try:
weak_method = WeakMethod(func)
except TypeError as e:
raise TypeError(REF_ERROR) from e
class ThrottledCallable(Generic[P, R], Protocol):
triggered: "SignalInstance"
def weak_func(*args, **kwargs):
if method := weak_method():
return method(*args, **kwargs)
warnings.warn(
"Method has been garbage collected", RuntimeWarning, stacklevel=2
)
def cancel(self) -> None:
...
return weak_func
def flush(self) -> None:
...
return func
def set_timeout(self, timeout: int) -> None:
...
if sys.version_info < (3, 9):
class ThrottledCallable(GenericSignalThrottler, Generic[P, R]):
def __init__(
self,
func: Callable[P, R],
kind: Kind,
emissionPolicy: EmissionPolicy,
parent: QObject | None = None,
) -> None:
super().__init__(kind, emissionPolicy, parent)
def __call__(self, *args: "P.args", **kwargs: "P.kwargs") -> Future:
...
self._future: Future[R] = Future()
else:
self._is_static_method: bool = False
if isinstance(func, staticmethod):
self._is_static_method = True
func = func.__func__
def __call__(self, *args: "P.args", **kwargs: "P.kwargs") -> Future[R]:
...
max_args = get_max_args(func)
with suppress(TypeError, ValueError):
self.__signature__ = signature(func)
self._func = _weak_func(func)
self.__wrapped__ = self._func
self._args: tuple = ()
self._kwargs: dict = {}
self.triggered.connect(self._set_future_result)
self._name = None
self._obj_dkt: WeakKeyDictionary[Any, ThrottledCallable] = WeakKeyDictionary()
# even if we were to compile __call__ with a signature matching that of func,
# PySide wouldn't correctly inspect the signature of the ThrottledCallable
# instance: https://bugreports.qt.io/browse/PYSIDE-2423
# so we do it ourselfs and limit the number of positional arguments
# that we pass to func
self._max_args: int | None = max_args
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> "Future[R]": # noqa
if not self._future.done():
self._future.cancel()
self._future = Future()
self._args = args
self._kwargs = kwargs
self.throttle()
return self._future
def _set_future_result(self):
result = self._func(*self._args[: self._max_args], **self._kwargs)
self._future.set_result(result)
def __set_name__(self, owner, name):
if not self._is_static_method:
self._name = name
def _get_throttler(self, instance, owner, parent, obj, name):
try:
bound_method = self._func.__get__(instance, owner)
except Exception as e: # pragma: no cover
raise RuntimeError(
f"Failed to bind function {self._func!r} to object {instance!r}"
) from e
throttler = ThrottledCallable(
bound_method,
self._kind,
self._emissionPolicy,
parent=parent,
)
throttler.setTimerType(self.timerType())
throttler.setTimeout(self.timeout())
try:
setattr(obj, name, throttler)
except AttributeError:
try:
self._obj_dkt[obj] = throttler
except TypeError as e:
raise TypeError(REF_ERROR) from e
return throttler
def __get__(self, instance, owner):
if instance is None or not self._name:
return self
if instance in self._obj_dkt:
return self._obj_dkt[instance]
parent = self.parent()
if parent is None and isinstance(instance, QObject):
parent = instance
return self._get_throttler(instance, owner, parent, instance, self._name)
@overload
@@ -224,28 +334,27 @@ def qthrottled(
timeout: int = 100,
leading: bool = True,
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
) -> "ThrottledCallable[P, R]":
...
parent: QObject | None = None,
) -> ThrottledCallable[P, R]: ...
@overload
def qthrottled(
func: "Literal[None]" = None,
func: None = ...,
timeout: int = 100,
leading: bool = True,
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
) -> Callable[[Callable[P, R]], "ThrottledCallable[P, R]"]:
...
parent: QObject | None = None,
) -> Callable[[Callable[P, R]], ThrottledCallable[P, R]]: ...
def qthrottled(
func: Optional[Callable[P, R]] = None,
func: Callable[P, R] | None = None,
timeout: int = 100,
leading: bool = True,
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
) -> Union[
"ThrottledCallable[P, R]", Callable[[Callable[P, R]], "ThrottledCallable[P, R]"]
]:
parent: QObject | None = None,
) -> ThrottledCallable[P, R] | Callable[[Callable[P, R]], ThrottledCallable[P, R]]:
"""Creates a throttled function that invokes func at most once per timeout.
The throttled function comes with a `cancel` method to cancel delayed func
@@ -273,8 +382,11 @@ def qthrottled(
- `Qt.CoarseTimer`: Coarse timers try to keep accuracy within 5% of the
desired interval
- `Qt.VeryCoarseTimer`: Very coarse timers only keep full second accuracy
parent: QObject or None
Parent object for timer. If using qthrottled as function it may be usefull
for cleaning data
"""
return _make_decorator(func, timeout, leading, timer_type, Kind.Throttler)
return _make_decorator(func, timeout, leading, timer_type, Kind.Throttler, parent)
@overload
@@ -283,28 +395,27 @@ def qdebounced(
timeout: int = 100,
leading: bool = False,
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
) -> "ThrottledCallable[P, R]":
...
parent: QObject | None = None,
) -> ThrottledCallable[P, R]: ...
@overload
def qdebounced(
func: "Literal[None]" = None,
func: None = ...,
timeout: int = 100,
leading: bool = False,
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
) -> Callable[[Callable[P, R]], "ThrottledCallable[P, R]"]:
...
parent: QObject | None = None,
) -> Callable[[Callable[P, R]], ThrottledCallable[P, R]]: ...
def qdebounced(
func: Optional[Callable[P, R]] = None,
func: Callable[P, R] | None = None,
timeout: int = 100,
leading: bool = False,
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
) -> Union[
"ThrottledCallable[P, R]", Callable[[Callable[P, R]], "ThrottledCallable[P, R]"]
]:
parent: QObject | None = None,
) -> ThrottledCallable[P, R] | Callable[[Callable[P, R]], ThrottledCallable[P, R]]:
"""Creates a debounced function that delays invoking `func`.
`func` will not be invoked until `timeout` ms have elapsed since the last time
@@ -335,46 +446,36 @@ def qdebounced(
- `Qt.CoarseTimer`: Coarse timers try to keep accuracy within 5% of the
desired interval
- `Qt.VeryCoarseTimer`: Very coarse timers only keep full second accuracy
parent: QObject or None
Parent object for timer. If using qthrottled as function it may be usefull
for cleaning data
"""
return _make_decorator(func, timeout, leading, timer_type, Kind.Debouncer)
return _make_decorator(func, timeout, leading, timer_type, Kind.Debouncer, parent)
def _make_decorator(
func: Optional[Callable[P, R]],
func: Callable[P, R] | None,
timeout: int,
leading: bool,
timer_type: Qt.TimerType,
kind: Kind,
) -> Union[
"ThrottledCallable[P, R]", Callable[[Callable[P, R]], "ThrottledCallable[P, R]"]
]:
def deco(func: Callable[P, R]) -> "ThrottledCallable[P, R]":
parent: QObject | None = None,
) -> ThrottledCallable[P, R] | Callable[[Callable[P, R]], ThrottledCallable[P, R]]:
def deco(func: Callable[P, R]) -> ThrottledCallable[P, R]:
nonlocal parent
instance: object | None = getattr(func, "__self__", None)
if isinstance(instance, QObject) and parent is None:
parent = instance
policy = EmissionPolicy.Leading if leading else EmissionPolicy.Trailing
throttle = GenericSignalThrottler(kind, policy)
throttle.setTimerType(timer_type)
throttle.setTimeout(timeout)
last_f = None
future: Optional[Future] = None
obj = ThrottledCallable(func, kind, policy, parent=parent)
obj.setTimerType(timer_type)
obj.setTimeout(timeout)
@wraps(func)
def inner(*args: "P.args", **kwargs: "P.kwargs") -> Future:
nonlocal last_f
nonlocal future
if last_f is not None:
throttle.triggered.disconnect(last_f)
if future is not None and not future.done():
future.cancel()
future = Future()
last_f = lambda: future.set_result(func(*args, **kwargs)) # noqa
throttle.triggered.connect(last_f)
throttle.throttle()
return future
setattr(inner, "cancel", throttle.cancel)
setattr(inner, "flush", throttle.flush)
setattr(inner, "set_timeout", throttle.setTimeout)
setattr(inner, "triggered", throttle.triggered)
return inner # type: ignore
if instance is not None:
# this is a bound method, we need to avoid strong references,
# and functools.wraps will prevent garbage collection on bound methods
return obj
return wraps(func)(obj)
return deco(func) if func is not None else deco

View File

@@ -0,0 +1,23 @@
from __future__ import annotations
from inspect import signature
from typing import Callable
def get_max_args(func: Callable) -> int | None:
"""Return the maximum number of positional arguments that func can accept."""
if not callable(func):
raise TypeError(f"{func!r} is not callable")
try:
sig = signature(func)
except Exception:
return None
max_args = 0
for param in sig.parameters.values():
if param.kind == param.VAR_POSITIONAL:
return None
if param.kind in {param.POSITIONAL_ONLY, param.POSITIONAL_OR_KEYWORD}:
max_args += 1
return max_args

163
tests/test_cmap.py Normal file
View File

@@ -0,0 +1,163 @@
import platform
from unittest.mock import patch
import numpy as np
import pytest
from qtpy import API_NAME
try:
from cmap import Colormap
except ImportError:
pytest.skip("cmap not installed", allow_module_level=True)
from qtpy.QtCore import QRect
from qtpy.QtGui import QPainter, QPixmap
from qtpy.QtWidgets import QStyleOptionViewItem, QWidget
from superqt import QColormapComboBox
from superqt.cmap import (
CmapCatalogComboBox,
QColormapItemDelegate,
QColormapLineEdit,
_cmap_combo,
draw_colormap,
)
from superqt.utils import qimage_to_array
def test_draw_cmap(qtbot):
# draw into a QWidget
wdg = QWidget()
qtbot.addWidget(wdg)
draw_colormap(wdg, "viridis")
# draw into any QPaintDevice
draw_colormap(QPixmap(), "viridis")
# pass a painter an explicit colormap and a rect
draw_colormap(QPainter(), Colormap(("red", "yellow", "blue")), QRect())
# test with a border
draw_colormap(wdg, "viridis", border_color="red", border_width=2)
with pytest.raises(TypeError, match="Expected a QPainter or QPaintDevice instance"):
draw_colormap(QRect(), "viridis") # type: ignore
with pytest.raises(TypeError, match="Expected a Colormap instance or something"):
draw_colormap(QPainter(), "not a recognized string or cmap", QRect())
def test_cmap_draw_result():
"""Test that the image drawn actually looks correct."""
# draw into any QPaintDevice
w = 100
h = 20
pix = QPixmap(w, h)
cmap = Colormap("viridis")
draw_colormap(pix, cmap)
ary1 = cmap(np.tile(np.linspace(0, 1, w), (h, 1)), bytes=True)
ary2 = qimage_to_array(pix.toImage())
# there are some subtle differences between how qimage draws and how
# cmap draws, so we can't assert that the arrays are exactly equal.
# they are visually indistinguishable, and numbers are close within 4 (/255) values
# and linux, for some reason, is a bit more different``
atol = 8 if platform.system() == "Linux" else 4
np.testing.assert_allclose(ary1, ary2, atol=atol)
cmap2 = Colormap(("#230777",), name="MyMap")
draw_colormap(pix, cmap2) # include transparency
def test_catalog_combo(qtbot):
wdg = CmapCatalogComboBox()
qtbot.addWidget(wdg)
wdg.show()
wdg.setCurrentText("viridis")
assert wdg.currentColormap() == Colormap("viridis")
@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()
with qtbot.waitSignal(wdg.currentColormapChanged):
wdg.addColormaps([Colormap("viridis"), "magma", ("red", "blue", "green")])
assert wdg.currentColormap().name.split(":")[-1] == "viridis"
with pytest.raises(ValueError, match="Invalid colormap"):
wdg.addColormap("not a recognized string or cmap")
assert wdg.currentColormap().name.split(":")[-1] == "viridis"
assert wdg.currentIndex() == 0
assert wdg.count() == 4 # includes "Add Colormap..."
wdg.setCurrentColormap("magma")
assert wdg.count() == 4 # make sure we didn't duplicate
assert wdg.currentIndex() == 1
if API_NAME == "PySide2":
return # the rest fails on CI... but works locally
# click the Add Colormap... item
with qtbot.waitSignal(wdg.currentColormapChanged):
with patch.object(_cmap_combo._CmapNameDialog, "exec", return_value=True):
wdg._on_activated(wdg.count() - 1)
assert wdg.count() == 5
# this could potentially fail in the future if cmap catalog changes
# but mocking the return value of the dialog is also annoying
assert wdg.itemColormap(3).name.split(":")[-1] == "accent"
# click the Add Colormap... item, but cancel the dialog
with patch.object(_cmap_combo._CmapNameDialog, "exec", return_value=False):
wdg._on_activated(wdg.count() - 1)
def test_cmap_item_delegate(qtbot):
wdg = CmapCatalogComboBox()
qtbot.addWidget(wdg)
view = wdg.view()
delegate = view.itemDelegate()
assert isinstance(delegate, QColormapItemDelegate)
# smoke tests:
painter = QPainter()
option = QStyleOptionViewItem()
index = wdg.model().index(0, 0)
delegate._colormap_fraction = 1
delegate.paint(painter, option, index)
delegate._colormap_fraction = 0.33
delegate.paint(painter, option, index)
assert delegate.sizeHint(option, index) == delegate._item_size
def test_cmap_line_edit(qtbot, qapp):
wdg = QColormapLineEdit()
qtbot.addWidget(wdg)
wdg.show()
wdg.setColormap("viridis")
assert wdg.colormap() == Colormap("viridis")
wdg.setText("magma") # also works if the name is recognized
assert wdg.colormap() == Colormap("magma")
qapp.processEvents()
qtbot.wait(10) # force the paintEvent
wdg.setFractionalColormapWidth(1)
assert wdg.fractionalColormapWidth() == 1
wdg.update()
qapp.processEvents()
qtbot.wait(10) # force the paintEvent
wdg.setText("not-a-cmap")
assert wdg.colormap() is None
# or
wdg.setFractionalColormapWidth(0.3)
wdg.setColormap(None)
assert wdg.colormap() is None
qapp.processEvents()
qtbot.wait(10) # force the paintEvent

View File

@@ -1,11 +1,23 @@
"""A test module for testing collapsible"""
from qtpy.QtCore import QEasingCurve
from qtpy.QtWidgets import QPushButton
from qtpy.QtCore import QEasingCurve, Qt
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QPushButton, QStyle, QWidget
from superqt import QCollapsible
def _get_builtin_icon(name: str) -> QIcon:
"""Get a built-in icon from the Qt library."""
widget = QWidget()
try:
pixmap = getattr(QStyle.StandardPixmap, f"SP_{name}")
except AttributeError:
pixmap = getattr(QStyle, f"SP_{name}")
return widget.style().standardIcon(pixmap)
def test_checked_initialization(qtbot):
"""Test simple collapsible"""
wdg1 = QCollapsible("Advanced analysis")
@@ -84,4 +96,44 @@ def test_changing_text(qtbot):
wdg = QCollapsible()
wdg.setText("Hi new text")
assert wdg.text() == "Hi new text"
assert wdg._toggle_btn.text() == QCollapsible._COLLAPSED + "Hi new text"
assert wdg._toggle_btn.text() == "Hi new text"
def test_toggle_signal(qtbot):
"""Test that signal is emitted when widget expanded/collapsed."""
wdg = QCollapsible()
with qtbot.waitSignal(wdg.toggled, timeout=500):
qtbot.mouseClick(wdg._toggle_btn, Qt.LeftButton)
with qtbot.waitSignal(wdg.toggled, timeout=500):
wdg.expand()
with qtbot.waitSignal(wdg.toggled, timeout=500):
wdg.collapse()
def test_getting_icon(qtbot):
"""Test setting string as toggle button."""
wdg = QCollapsible("test")
assert isinstance(wdg.expandedIcon(), QIcon)
assert isinstance(wdg.collapsedIcon(), QIcon)
def test_setting_icon(qtbot):
"""Test setting icon for toggle button."""
icon1 = _get_builtin_icon("ArrowRight")
icon2 = _get_builtin_icon("ArrowDown")
wdg = QCollapsible("test", expandedIcon=icon1, collapsedIcon=icon2)
assert wdg._expanded_icon == icon1
assert wdg._collapsed_icon == icon2
def test_setting_symbol_icon(qtbot):
"""Test setting string as toggle button."""
wdg = QCollapsible("test")
icon1 = wdg._convert_string_to_icon("+")
icon2 = wdg._convert_string_to_icon("-")
wdg.setCollapsedIcon(icon=icon1)
assert wdg._collapsed_icon == icon1
wdg.setExpandedIcon(icon=icon2)
assert wdg._expanded_icon == icon2

86
tests/test_color_combo.py Normal file
View File

@@ -0,0 +1,86 @@
from unittest.mock import patch
import pytest
from qtpy import API_NAME
from qtpy.QtGui import QColor, QPainter
from qtpy.QtWidgets import QStyleOptionViewItem
from superqt import QColorComboBox
from superqt.combobox import _color_combobox
def test_q_color_combobox(qtbot):
wdg = QColorComboBox()
qtbot.addWidget(wdg)
wdg.show()
wdg.setUserColorsAllowed(True)
# colors can be any argument that can be passed to QColor
# (tuples and lists will be expanded to QColor(*color)
COLORS = [QColor("red"), "orange", (255, 255, 0), "green", "#00F", "indigo"]
wdg.addColors(COLORS)
colors = [wdg.itemColor(i) for i in range(wdg.count())]
assert colors == [
QColor("red"),
QColor("orange"),
QColor("yellow"),
QColor("green"),
QColor("blue"),
QColor("indigo"),
None, # "Add Color" item
]
# as with addColors, colors will be cast to QColor when using setColors
wdg.setCurrentColor("indigo")
assert wdg.currentColor() == QColor("indigo")
assert wdg.currentColorName() == "#4b0082"
wdg.clear()
assert wdg.count() == 1 # "Add Color" item
wdg.setUserColorsAllowed(False)
assert not wdg.count()
wdg.setInvalidColorPolicy(wdg.InvalidColorPolicy.Ignore)
wdg.setInvalidColorPolicy(2)
wdg.setInvalidColorPolicy("Raise")
with pytest.raises(TypeError):
wdg.setInvalidColorPolicy(1.0) # type: ignore
with pytest.raises(ValueError):
wdg.addColor("invalid")
def test_q_color_delegate(qtbot):
wdg = QColorComboBox()
view = wdg.view()
delegate = wdg.itemDelegate()
qtbot.addWidget(wdg)
wdg.show()
# smoke tests:
painter = QPainter()
option = QStyleOptionViewItem()
index = wdg.model().index(0, 0)
delegate.paint(painter, option, index)
wdg.addColors(["red", "orange", "yellow"])
view.selectAll()
index = wdg.model().index(1, 0)
delegate.paint(painter, option, index)
@pytest.mark.skipif(API_NAME == "PySide2", reason="hangs on CI")
def test_activated(qtbot):
wdg = QColorComboBox()
qtbot.addWidget(wdg)
wdg.show()
wdg.setUserColorsAllowed(True)
with patch.object(_color_combobox.QColorDialog, "getColor", lambda: QColor("red")):
wdg._on_activated(wdg.count() - 1) # "Add Color" item
assert wdg.currentColor() == QColor("red")
with patch.object(_color_combobox.QColorDialog, "getColor", lambda: QColor()):
wdg._on_activated(wdg.count() - 1) # "Add Color" item
assert wdg.currentColor() == QColor("red")

Some files were not shown because too many files have changed in this diff Show More