Compare commits

...

138 Commits

Author SHA1 Message Date
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
Talley Lambert
61b8ab30ab chore: changelog v0.3.7 2022-10-10 08:27:33 -04:00
Talley Lambert
abf544cf0e feat: add Quantity widget (using pint) (#126)
* wip

* simplified quantity widget

* fix example

* more docs

* add test

* update docs

* try to avoid overflow

* reduce again
2022-10-10 08:22:52 -04:00
Talley Lambert
9f9dab6f3b fix readme (#125) 2022-10-05 11:16:55 -04:00
Talley Lambert
97bb814451 Docs (#124)
* wip

* wip

* more wip

* progress

* more docs

* more changes

* add link

* more examples and improvements

* fix check-manifest

* sort members

* remove autogen images

* remove _images

* add font docs

* add link to utils
2022-10-05 08:59:27 -04:00
Talley Lambert
d1c056886f chore: changelog v0.3.6 2022-10-03 17:04:14 -04:00
Talley Lambert
a73e56bb83 fix: fix missing labels after setValue (#123) 2022-10-03 17:00:53 -04:00
Talley Lambert
6f71e46914 feat: add editing finished signal to LabeledSliders (#122)
* feat: add editing finished signal to LabeledSliders

* remove extra file
2022-10-03 17:00:29 -04:00
Talley Lambert
fbc67a745c fix: Offer patch for (unstyled) QSliders on macos 12 and Qt <6 (#117)
* wip

* opt-in patch

* finishes

* add patch to demo

* remove demo 2

* extend docs
2022-10-03 15:49:34 -04:00
Talley Lambert
77bd737e13 fix: Fix TypeError on slider rangeChanged signal (#121)
* fix: fix sliders type signals

* test: more tests

* fix for pyside2

* skip type check on py37
2022-10-03 15:10:40 -04:00
pre-commit-ci[bot]
ba626e8786 ci(pre-commit.ci): pre-commit autoupdate (#120)
updates:
- [github.com/pre-commit/mirrors-mypy: v0.971 → v0.981](https://github.com/pre-commit/mirrors-mypy/compare/v0.971...v0.981)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-10-03 15:09:47 -04:00
Grzegorz Bokota
04efa95511 dummy fix (#119) 2022-10-03 08:24:45 -04:00
pre-commit-ci[bot]
f401d6d59c [pre-commit.ci] pre-commit autoupdate (#116)
updates:
- https://github.com/myint/autoflakehttps://github.com/PyCQA/autoflake
- [github.com/PyCQA/autoflake: v1.5.3 → v1.6.1](https://github.com/PyCQA/autoflake/compare/v1.5.3...v1.6.1)
- [github.com/asottile/pyupgrade: v2.37.3 → v2.38.2](https://github.com/asottile/pyupgrade/compare/v2.37.3...v2.38.2)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-10-02 19:20:26 -04:00
pre-commit-ci[bot]
a3bd0d0edf [pre-commit.ci] pre-commit autoupdate (#114) 2022-09-05 16:06:28 -04:00
pre-commit-ci[bot]
e7e8dfc44c [pre-commit.ci] pre-commit autoupdate (#109)
updates:
- [github.com/PyCQA/flake8: 5.0.2 → 5.0.4](https://github.com/PyCQA/flake8/compare/5.0.2...5.0.4)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-08-17 12:55:20 -04:00
Talley Lambert
a556f16745 chore: changelog v0.3.5 (#110)
* chore: changelog v0.3.5

* try fix napari test

* again

* try another

* again

* fix again
2022-08-17 12:54:27 -04:00
sfhbarnett
2864058974 fix range slider drag crash on PyQt6 (#108)
* fix range slider drag crash on PyQt6

* [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-08-04 11:01:19 -04:00
pre-commit-ci[bot]
463332f4fc [pre-commit.ci] pre-commit autoupdate (#104)
* [pre-commit.ci] pre-commit autoupdate

updates:
- [github.com/asottile/setup-cfg-fmt: v1.20.2 → v2.0.0](https://github.com/asottile/setup-cfg-fmt/compare/v1.20.2...v2.0.0)
- [github.com/PyCQA/flake8: 4.0.1 → 5.0.2](https://github.com/PyCQA/flake8/compare/4.0.1...5.0.2)
- [github.com/asottile/pyupgrade: v2.37.2 → v2.37.3](https://github.com/asottile/pyupgrade/compare/v2.37.2...v2.37.3)

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

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

* version specs

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-08-03 09:36:12 -04:00
Markus Stabrin
f08e2d1720 Fix float value error in pyqt configuration (#106)
Co-authored-by: Markus Stabrin <markus.stabrin@gmail.com>
2022-08-03 09:35:56 -04:00
pre-commit-ci[bot]
39c10aa238 [pre-commit.ci] pre-commit autoupdate (#102)
updates:
- [github.com/asottile/setup-cfg-fmt: v1.20.1 → v1.20.2](https://github.com/asottile/setup-cfg-fmt/compare/v1.20.1...v1.20.2)
- [github.com/asottile/pyupgrade: v2.37.1 → v2.37.2](https://github.com/asottile/pyupgrade/compare/v2.37.1...v2.37.2)
- [github.com/pre-commit/mirrors-mypy: v0.961 → v0.971](https://github.com/pre-commit/mirrors-mypy/compare/v0.961...v0.971)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-07-31 11:46:56 -04:00
Talley Lambert
d5d40a35f3 changelog 0.3.4 2022-07-24 11:10:01 -04:00
Talley Lambert
5b92a19b82 fix: relax runtime typing extensions requirement (#101) 2022-07-24 11:08:00 -04:00
pre-commit-ci[bot]
a3b0f1b115 [pre-commit.ci] pre-commit autoupdate (#97)
updates:
- [github.com/asottile/pyupgrade: v2.34.0 → v2.37.1](https://github.com/asottile/pyupgrade/compare/v2.34.0...v2.37.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-07-13 11:57:21 -04:00
Talley Lambert
b1e6d55957 fix: catch qpixmap deprecation (#99) 2022-07-13 11:57:01 -04:00
Talley Lambert
55535b7600 chore: changelog v0.3.3 2022-07-10 10:15:33 -04:00
pre-commit-ci[bot]
31c834053c [pre-commit.ci] pre-commit autoupdate (#96)
updates:
- [github.com/psf/black: 22.3.0 → 22.6.0](https://github.com/psf/black/compare/22.3.0...22.6.0)

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-07-07 17:08:52 -04:00
Talley Lambert
69219c846d Revert "update typing and namespace"
This reverts commit 2edb3c287e.
2022-07-07 16:49:26 -04:00
Talley Lambert
2edb3c287e update typing and namespace 2022-07-07 16:47:04 -04:00
Talley Lambert
218a7b4034 fix: fix deprecation warning on fonticon plugin discovery on python 3.10 (#95)
* fix: fix fonticon

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

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

* fix entry points API

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-07-01 11:23:00 -04:00
pre-commit-ci[bot]
9ab24dbcf6 [pre-commit.ci] pre-commit autoupdate (#93)
updates:
- [github.com/pre-commit/pre-commit-hooks: v4.2.0 → v4.3.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.2.0...v4.3.0)
- [github.com/asottile/pyupgrade: v2.32.1 → v2.34.0](https://github.com/asottile/pyupgrade/compare/v2.32.1...v2.34.0)
- [github.com/pre-commit/mirrors-mypy: v0.960 → v0.961](https://github.com/pre-commit/mirrors-mypy/compare/v0.960...v0.961)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-07-01 11:13:41 -04:00
pre-commit-ci[bot]
35acbbf5e6 [pre-commit.ci] pre-commit autoupdate (#90)
updates:
- [github.com/pre-commit/mirrors-mypy: v0.950 → v0.960](https://github.com/pre-commit/mirrors-mypy/compare/v0.950...v0.960)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-06-11 14:54:37 -04:00
Grzegorz Bokota
0ae3350c57 Add code syntax highlight utils (#88)
* add code syntax highlight code

* add example

* add documentation and fix example

* add tests

* add information about napari theme usage

* clean napari mention
2022-05-18 16:50:51 -04:00
pre-commit-ci[bot]
c7f8780900 [pre-commit.ci] pre-commit autoupdate (#87)
updates:
- [github.com/asottile/pyupgrade: v2.32.0 → v2.32.1](https://github.com/asottile/pyupgrade/compare/v2.32.0...v2.32.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-05-18 10:05:00 -04:00
Talley Lambert
cc25733ce8 Add changelog for v0.3.2 (#86)
* Add changelog for v0.3.2

* caps
2022-05-03 10:14:15 -04:00
pre-commit-ci[bot]
accb87021f [pre-commit.ci] pre-commit autoupdate (#85)
updates:
- [github.com/pre-commit/mirrors-mypy: v0.942 → v0.950](https://github.com/pre-commit/mirrors-mypy/compare/v0.942...v0.950)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-05-02 14:05:19 -04:00
Talley Lambert
ccad397838 fix crazy animation loop on collapsible (#84) 2022-05-02 14:01:17 -04:00
Talley Lambert
68248c920c reorder label update signal (#83) 2022-04-28 13:31:16 -04:00
Grzegorz Bokota
f8ac85aaf6 feat: Add QSearchableListWidget and QSearchableComboBox widgets (#80)
* implement widgets

* add basic documentation

* Add examples

* try version without packaging

Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
2022-04-25 14:03:24 -04:00
Talley Lambert
bd6fba96ad fix deprecation warnings in tests (#82)
* stub

* update tests

* use util func

* add fallback for older versions

* don't test 3.6
2022-04-24 11:04:50 -04:00
Nekyo
7d31812858 Fix CSS for Collapsible (#79)
The button used for the Collapsible previously showed a black on
archlinux.
This fixes it to display properly.
Closes https://github.com/napari/superqt/issues/78
2022-04-17 10:58:05 -04:00
pre-commit-ci[bot]
f27377ab1b [pre-commit.ci] pre-commit autoupdate (#76)
updates:
- [github.com/pre-commit/pre-commit-hooks: v4.1.0 → v4.2.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.1.0...v4.2.0)
- [github.com/asottile/setup-cfg-fmt: v1.20.0 → v1.20.1](https://github.com/asottile/setup-cfg-fmt/compare/v1.20.0...v1.20.1)
- [github.com/asottile/pyupgrade: v2.31.1 → v2.32.0](https://github.com/asottile/pyupgrade/compare/v2.31.1...v2.32.0)
- [github.com/psf/black: 22.1.0 → 22.3.0](https://github.com/psf/black/compare/22.1.0...22.3.0)
- [github.com/pre-commit/mirrors-mypy: v0.941 → v0.942](https://github.com/pre-commit/mirrors-mypy/compare/v0.941...v0.942)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-04-17 10:54:09 -04:00
pre-commit-ci[bot]
2052fb8310 [pre-commit.ci] pre-commit autoupdate (#75)
updates:
- [github.com/pre-commit/mirrors-mypy: v0.940 → v0.941](https://github.com/pre-commit/mirrors-mypy/compare/v0.940...v0.941)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-03-22 10:38:06 -04:00
pre-commit-ci[bot]
40d3e20bff [pre-commit.ci] pre-commit autoupdate (#73)
updates:
- [github.com/asottile/pyupgrade: v2.31.0 → v2.31.1](https://github.com/asottile/pyupgrade/compare/v2.31.0...v2.31.1)
- [github.com/pre-commit/mirrors-mypy: v0.931 → v0.940](https://github.com/pre-commit/mirrors-mypy/compare/v0.931...v0.940)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-03-14 16:43:55 -04:00
Talley Lambert
f4d9881b0c Fix height of expanded QCollapsible when child changes size (#72)
* update height when child changes

* return false
2022-03-11 14:16:03 -05:00
Talley Lambert
ba1ae92bcc changelog (#71) 2022-03-02 08:26:05 -05:00
Talley Lambert
8217a1cc71 check min requirements (#70) 2022-03-02 07:54:03 -05:00
Talley Lambert
96de1a261a add signals_blocked util (#69) 2022-02-20 11:25:10 -05:00
Talley Lambert
d8a8328793 add 0.3.0 changelog (#68) 2022-02-16 16:53:51 -05:00
Talley Lambert
2a9f47816a add napari test to CI (#67)
* add napari test

* fix test
2022-02-16 16:39:29 -05:00
Talley Lambert
e06ab4d081 Qthrottler and debouncer (#62)
* initial working throttler

* complete typing and docs

* basic test

* fix future subscript

* touch ups

* Update src/superqt/utils/_throttler.py

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

* Update src/superqt/utils/_throttler.py

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

* Update src/superqt/utils/_throttler.py

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

Co-authored-by: Grzegorz Bokota <bokota+github@gmail.com>
2022-02-16 16:20:21 -05:00
Talley Lambert
13e092e381 add release action (#65) 2022-02-14 14:24:29 -05:00
Talley Lambert
b2c485bcea fix nested calls (#63) 2022-02-14 14:09:18 -05:00
Talley Lambert
d0d67da377 fix xvfb tests (#61) 2022-02-03 17:06:07 -05:00
pre-commit-ci[bot]
bc98f15ba1 [pre-commit.ci] pre-commit autoupdate (#60)
* [pre-commit.ci] pre-commit autoupdate

updates:
- [github.com/psf/black: 21.12b0 → 22.1.0](https://github.com/psf/black/compare/21.12b0...22.1.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-02-03 10:10:05 -05:00
Talley Lambert
49bd078012 add edgeLabelMode option to QLabeledSlider (#59) 2022-01-31 13:07:20 -05:00
pre-commit-ci[bot]
d379611491 [pre-commit.ci] pre-commit autoupdate (#55)
updates:
- [github.com/pre-commit/mirrors-mypy: v0.930 → v0.931](https://github.com/pre-commit/mirrors-mypy/compare/v0.930...v0.931)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-01-20 07:31:24 -05:00
Talley Lambert
329eaaa9a0 more proxy signals (#54) 2022-01-07 13:17:12 -05:00
Talley Lambert
d25f4c1cf7 Use qtpy, deprecate superqt.qtcompat, drop support for Qt <5.12 (#39)
* remove qtcompat

* change imports

* change codecov

* add dep

* run tests against dalthviz branch

* update

* more replace

* pin pyside6 for sake of test

* add deprecation

* drop qt 5.11

* unpin pyside6
2022-01-07 12:19:59 -05:00
pre-commit-ci[bot]
a07ee64f8b [pre-commit.ci] pre-commit autoupdate (#52) 2022-01-03 13:21:40 -05:00
Talley Lambert
bbd60eebaf Ugly but functional workaround for pyside6.2.1 breakages (#51)
* working but ugly

* remove signalinstsance type annotation

* change method name

* move line
2022-01-03 10:17:07 -05:00
pre-commit-ci[bot]
9c55c6c657 [pre-commit.ci] pre-commit autoupdate (#47)
updates:
- [github.com/pre-commit/pre-commit-hooks: v4.0.1 → v4.1.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.0.1...v4.1.0)
- [github.com/psf/black: 21.11b1 → 21.12b0](https://github.com/psf/black/compare/21.11b1...21.12b0)
- [github.com/pre-commit/mirrors-mypy: v0.910-1 → v0.930](https://github.com/pre-commit/mirrors-mypy/compare/v0.910-1...v0.930)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-01-01 17:39:14 -05:00
Talley Lambert
3c217026af fix codecov badge 2021-11-27 10:12:01 -05:00
Ahmet Can Solak
0681f7138a typing-extensions version pinning (#46) 2021-11-22 20:44:50 -05:00
Talley Lambert
1e1f38d297 Fix-manifest, move font tests (#44)
* remove arrow

* move mypy

* add files

* move font tests
2021-11-22 11:38:23 -05:00
Talley Lambert
c101b29d65 update changelog 2021-11-22 10:26:33 -05:00
Talley Lambert
cb1b589768 reskip test_object_thread_return on ci (#43)
* skip on ci

* remove print

* 4 min timeout

* 3 min
2021-11-22 10:23:56 -05:00
Talley Lambert
b0532c31c3 add changelog 2021-11-21 20:23:24 -05:00
Talley Lambert
c355f8b06d add support for python 3.10 (#42)
* add support for 3.10

* fix tox

* move

* ignore deprecation

* add timeout
2021-11-21 19:24:46 -05:00
Talley Lambert
d7afa8824c Fix some small linting issues. (#41)
* fix some linting

* add tests

* update versions

* update setup
2021-11-21 19:09:34 -05:00
Mustafa Al Ibrahim
789b98f892 QCollapsible for Collapsible Section Control (#37)
* Update changelog to ingnore virtual environment

* wip

* wip

* Working animation

* WIP Implement tests

* All tests are passing

* convert to camalCase

* Change function name to match functionality

* convert pyside to qtcompat

* move animation utils to main module

* remove seperators

* protect util functions

* add example

* remove seperators from test file

* suggestions

* Passing tests and ability to initialize expansion

* Ensure that the test will be passed in any screen resolution

* replace quick functions with parameters

* Update src/superqt/collapsible/_collapsible.py

Fix initial text

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

* Update src/superqt/collapsible/_collapsible.py

Remote WindowFlags to prevent compatiblity issue.

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

* merge internal expand and collapse into one function

* Update src/superqt/collapsible/_collapsible.py

* Update tests/test_collapsible.py

Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
2021-11-20 09:27:26 -05:00
Talley Lambert
8001022e18 Add font icons (#24)
* working kinda well

* rearrange

* add back init

* change entrypoints

* add mi4

* example

* more improvements

* add animations

* more changes

* add feather, improve seticon

* update example

* refactor

* broken wip

* use iconfontmeta instead of enum

* mostly working

* misc

* more tweaks

* docs

* adding tests

* remove napari example

* more docs

* more docs

* update examples

* more docs

* typing

* working on icon options

* updates

* update

* update example

* update tests

* add comment

* docs

* fix annotation

* try set false first

* fix py37

* more test fixes

* fix qt6 test

* ignore old deprecation warning

* extend test
2021-11-15 10:55:46 -05:00
Talley Lambert
e1d2edb204 refactoring qtcompat (#34)
* add other modules

* add qtsvg

* more changes for qt6 support

* add qaction

* more enum namespacing

* more ns fixes

* updating qtcompat

* more minimal

* wip

* update typing

* fix one more namespace

* update types

* update exports

* add stubs

* fix

* fix exec
2021-11-02 11:13:52 -04:00
Talley Lambert
055a4fc1a7 update deploy (#33) 2021-10-15 12:59:59 -04:00
Talley Lambert
5983bd1552 Threadworker (#31)
* add threadworker and tests

* add type

* update typing

* keep runtime types

* update

* remove slot

* remove order

* remove signalinstance hint

* fix old import error

* remove unneeded order

* try something

* comment

* timeout

* add qapp to everything

* verbose

* also add -s

* print lots

* move to bottom

* use sigint after time

* use wraper for future object

* remove temporary stuff

* undo move

* move again

* delete reference after return result

* add back sigint after time

* add print

* change scope

* add more prints

* change f string

* timtout

* no sigint again

* print more

* bump

* try without object thread tests

* just skip

* modify skips

* undo ensure thread changes

* verbose

Co-authored-by: Grzegorz Bokota <bokota+github@gmail.com>
2021-10-15 12:05:44 -04:00
Talley Lambert
67035a0f0b move to src layout (#32)
* move to src layout

* fix manifest and version

* fix test structure

* undo

* undo

* undo change

* remove pyargs

* waitsignal

* update label test

* soften eliding test

* another fix

* update again

* more fixes

* more skips

* stupid fixes
2021-10-13 09:33:46 -04:00
Grzegorz Bokota
8d76579122 use functools.wraps to expose more parameters of wraped functon (#29) 2021-10-08 15:14:35 -04:00
Grzegorz Bokota
c5658b353a Propagate function name in ensure_main_thread and ensure_object_thread (#28)
* propagate function name in decorators

* add __wrapped__ information for inspect module
2021-10-04 09:22:56 -04:00
Talley Lambert
5ab72a0c48 add changelog for 0.2.4 (#25) 2021-09-13 13:25:30 -04:00
Talley Lambert
06da62811b Add type stubs for ensure_thread decorator (#23)
* types

* udpate manifest

* remove unused
2021-09-11 07:59:24 -04:00
Grzegorz Bokota
bb538cda2a Add ensure_main_tread and ensure_object_thread (#22)
* initial implementation

* add tests

* add test for property

* add doc part 1

* same behavior for direct and indirect call

* allow use decorator without braces

* add documentation

* Update docs/decorators.md

* update docs

* update docs

* simplify

* remove obsolete timeout

* update docs for future

Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
2021-09-10 21:21:33 -04:00
Talley Lambert
c8a40ba051 add mesage handler (#21) 2021-09-02 22:49:55 -04:00
Talley Lambert
ac1d8403fd Fix warnings on eliding label for 5.12, test more qt versions (#19)
* more tests and eliding fix

* update tests

* tox env override

* remove ubuntu 16

* still trying to fix tests

* add backends
2021-08-25 16:05:11 -04:00
Talley Lambert
ba20665d57 Add QElidingLabel (#16)
* wip

* single class implementation

* fix init

* improve implementation

* improve sizeHint

* wrap

* update docs

* rename

* remove overloads

* review changes

* docs and reformat

* remove width from _elided text

* add tests
2021-08-17 11:03:57 -04:00
Grzegorz Bokota
939c5222af Add Enum ComboBox (#13)
* enum combobox implementation

* add enunm()

* Update superqt/combobox/_enum_combobox.py

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

* add changes from review

* updates from review

* make current enum not raise exception from currentEnum

* improve checks in setCurrentEnum

* Update superqt/combobox/_tests/test_enum_comb_box.py

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

* fix test

* fix test call

* add class to top level __init__

* fix pre-commit mmissed call

* rename

* documentation first part

* Update docs/combobox.md

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

* add possibility to use Optional[Enum]

* add information about optional annotation

* change type annotation to additional parameter

* update docs

* change to EnumMeta

* add information about signal

Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
2021-08-13 09:49:45 -04:00
Robert Haase
22beed7608 fix broken link (#18) 2021-08-06 19:02:22 -04:00
Talley Lambert
9a72d9d474 fix slider proxy (#10) 2021-07-10 15:11:17 -04:00
Talley Lambert
5202aba6a8 Fix range slider with negative min range (#9)
* fix value from position for neg numbers

* smaller diff
2021-07-10 15:11:04 -04:00
139 changed files with 9852 additions and 1375 deletions

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,15 +1,17 @@
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
pull_request:
branches:
- master
- main
workflow_dispatch:
@@ -17,140 +19,165 @@ jobs:
test:
name: ${{ matrix.platform }} py${{ matrix.python-version }} ${{ matrix.backend }}
runs-on: ${{ matrix.platform }}
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
platform: [ubuntu-latest, windows-latest, macos-latest]
python-version: [3.7, 3.8, 3.9]
backend: [pyqt5, pyside2]
python-version: ["3.8", "3.9", "3.10", "3.11"]
backend: [pyqt5, pyside2, pyqt6]
exclude:
# Abort (core dumped) on linux pyqt6, unknown reason
- platform: ubuntu-latest
backend: pyqt6
# lack of wheels for pyside2/py3.11
- python-version: "3.11"
backend: pyside2
include:
# pyqt6 and pyside6 on latest platforms
- python-version: 3.9
platform: ubuntu-latest
- python-version: "3.10"
platform: macos-latest
backend: pyside6
screenshot: 1
- python-version: 3.9
- python-version: "3.11"
platform: macos-latest
backend: pyside6
- python-version: "3.10"
platform: windows-latest
backend: pyside6
screenshot: 1
- python-version: 3.9
platform: macos-11.0
backend: pyside6
screenshot: 1
- python-version: 3.9
platform: ubuntu-latest
backend: pyqt6
- python-version: 3.9
- python-version: "3.11"
platform: windows-latest
backend: pyqt6
- python-version: 3.9
platform: macos-11.0
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
- python-version: 3.6
platform: ubuntu-16.04
backend: pyqt5
- python-version: 3.6
platform: windows-2016
backend: pyqt5
backend: pyside6
# legacy Qt
- python-version: 3.7
- python-version: 3.8
platform: ubuntu-latest
backend: pyqt511
- python-version: 3.7
backend: "pyqt5==5.12.*"
- python-version: 3.8
platform: ubuntu-latest
backend: pyside511
backend: "pyqt5==5.13.*"
- python-version: 3.8
platform: ubuntu-latest
backend: "pyqt5==5.14.*"
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install Linux libraries
if: runner.os == 'Linux'
run: |
sudo apt-get install -y libdbus-1-3 libxkbcommon-x11-0 libxcb-icccm4 \
libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 \
libxcb-xinerama0 libxcb-xfixes0
- uses: tlambert03/setup-qt-libs@v1.4
- name: Linux opengl
if: runner.os == 'Linux' && ( matrix.backend == 'pyside6' || matrix.backend == 'pyqt6' )
if: runner.os == 'Linux' && ( startsWith(matrix.backend, 'pyside6') || startsWith(matrix.backend, 'pyqt6') )
run: sudo apt-get install -y libopengl0 libegl1-mesa libxcb-xinput0
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install setuptools tox tox-gh-actions
python -m pip install -e .[test]
python -m pip install ${{ matrix.backend }}
- name: Test with tox
run: tox
env:
PLATFORM: ${{ matrix.platform }}
BACKEND: ${{ matrix.backend }}
- name: Test
uses: aganders3/headless-gui@v1.2
with:
run: python -m pytest --color=yes --cov=superqt --cov-report=xml
- name: Coverage
uses: codecov/codecov-action@v1
uses: codecov/codecov-action@v3
- 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
test_old_qtpy:
name: qtpy minreq
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: tlambert03/setup-qt-libs@v1.4
- uses: actions/setup-python@v4
with:
run: python examples/demo_widget.py -snap
python-version: "3.8"
- name: Screenshots (macOS/Win)
if: runner.os != 'Linux' && matrix.screenshot
run: python examples/demo_widget.py -snap
- name: install
run: |
python -m pip install -U pip
python -m pip install -e .[test,pyqt5]
python -m pip install qtpy==1.1.0 typing-extensions==3.7.4.3
- uses: actions/upload-artifact@v2
if: matrix.screenshot
- name: Test
uses: aganders3/headless-gui@v1.2
with:
name: screenshots ${{ runner.os }}
path: screenshots
run: python -m pytest --color=yes
test_napari:
name: napari tests
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: aganders3/headless-gui@v1.2
with:
working-directory: napari-repo
run: python -m pytest --color=yes napari/_qt
check-manifest:
name: Check Manifest
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: "3.x"
- run: pip install check-manifest && 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]
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@v3
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v2
uses: actions/setup-python@v4
with:
python-version: "3.x"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -U setuptools setuptools_scm wheel twine
pip install build twine
- name: Build and publish
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.TWINE_API_KEY }}
run: |
git tag
python setup.py sdist bdist_wheel
python -m build
twine check dist/*
twine upload dist/*
- uses: softprops/action-gh-release@v1
with:
generate_release_notes: true

View File

@@ -0,0 +1,9 @@
# run this with:
# export CHANGELOG_GITHUB_TOKEN=......
# github_changelog_generator --future-release vX.Y.Z
user=pyapp-kit
project=superqt
issues=false
since-tag=v0.2.0
exclude-labels=duplicate,question,invalid,wontfix,hide
add-sections={"documentation":{"prefix":"**Documentation updates:**","labels":["documentation"]},"tests":{"prefix":"**Tests & CI:**","labels":["tests"]},"refactor":{"prefix":"**Refactors:**","labels":["refactor"]}}

7
.gitignore vendored
View File

@@ -9,6 +9,7 @@ __pycache__/
# Distribution / packaging
.Python
env/
.venv/
build/
develop-eggs/
dist/
@@ -44,7 +45,6 @@ nosetests.xml
coverage.xml
*,cover
.hypothesis/
.napari_cache
# Translations
*.mo
@@ -76,6 +76,9 @@ target/
.DS_Store
# written by setuptools_scm
*/_version.py
src/superqt/_version.py
.vscode/settings.json
screenshots
.mypy_cache
docs/_auto_images/

View File

@@ -1,35 +1,38 @@
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.0.1
rev: v4.4.0
hooks:
- id: check-docstring-first
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/asottile/setup-cfg-fmt
rev: v1.17.0
hooks:
- id: setup-cfg-fmt
- repo: https://github.com/PyCQA/flake8
rev: 3.9.2
hooks:
- id: flake8
additional_dependencies:
[flake8-typing-imports==1.7.0]
exclude: examples
- repo: https://github.com/myint/autoflake
rev: v1.4
hooks:
- id: autoflake
args: ["--in-place", "--remove-all-unused-imports"]
- repo: https://github.com/PyCQA/isort
rev: 5.8.0
hooks:
- id: isort
- repo: https://github.com/asottile/pyupgrade
rev: v2.19.1
hooks:
- id: pyupgrade
args: [--py37-plus]
- repo: https://github.com/psf/black
rev: 21.5b2
rev: 23.7.0
hooks:
- id: black
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.0.281
hooks:
- id: ruff
args: ["--fix"]
- repo: https://github.com/abravalheri/validate-pyproject
rev: v0.13
hooks:
- id: validate-pyproject
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.4.1
hooks:
- id: mypy
exclude: tests|examples
additional_dependencies:
- types-Pygments
stages:
- manual

348
CHANGELOG.md Normal file
View File

@@ -0,0 +1,348 @@
# Changelog
## [v0.5.4](https://github.com/pyapp-kit/superqt/tree/v0.5.4) (2023-08-31)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.3...v0.5.4)
**Fixed bugs:**
- fix: fix mysterious segfault [\#192](https://github.com/pyapp-kit/superqt/pull/192) ([tlambert03](https://github.com/tlambert03))
## [v0.5.3](https://github.com/pyapp-kit/superqt/tree/v0.5.3) (2023-08-21)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.2...v0.5.3)
**Implemented enhancements:**
- feat: add error `exceptions_as_dialog` context manager to catch and show Exceptions [\#191](https://github.com/pyapp-kit/superqt/pull/191) ([tlambert03](https://github.com/tlambert03))
**Fixed bugs:**
- fix: remove dupes/aliases in QEnumCombo [\#190](https://github.com/pyapp-kit/superqt/pull/190) ([tlambert03](https://github.com/tlambert03))
## [v0.5.2](https://github.com/pyapp-kit/superqt/tree/v0.5.2) (2023-08-18)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.1...v0.5.2)
**Implemented enhancements:**
- feat: allow throttler/debouncer as method decorator [\#188](https://github.com/pyapp-kit/superqt/pull/188) ([Czaki](https://github.com/Czaki))
**Fixed bugs:**
- fix: Add descriptive exception when fail to add instance to weakref dictionary [\#189](https://github.com/pyapp-kit/superqt/pull/189) ([Czaki](https://github.com/Czaki))
## [v0.5.1](https://github.com/pyapp-kit/superqt/tree/v0.5.1) (2023-08-17)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.0...v0.5.1)
**Fixed bugs:**
- fix: fix parameter inspection on ensure\_thread decorators \(alternate\) [\#185](https://github.com/pyapp-kit/superqt/pull/185) ([tlambert03](https://github.com/tlambert03))
- fix: fix callback of throttled/debounced decorated functions with mismatched args [\#184](https://github.com/pyapp-kit/superqt/pull/184) ([tlambert03](https://github.com/tlambert03))
**Documentation updates:**
- docs: document signals blocked [\#186](https://github.com/pyapp-kit/superqt/pull/186) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- test: change wait pattern [\#187](https://github.com/pyapp-kit/superqt/pull/187) ([tlambert03](https://github.com/tlambert03))
- build: drop python3.7, misc updates to repo [\#180](https://github.com/pyapp-kit/superqt/pull/180) ([tlambert03](https://github.com/tlambert03))
## [v0.5.0](https://github.com/pyapp-kit/superqt/tree/v0.5.0) (2023-08-06)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.4.1...v0.5.0)
**Implemented enhancements:**
- feat: add stepType to largeInt spinbox [\#179](https://github.com/pyapp-kit/superqt/pull/179) ([tlambert03](https://github.com/tlambert03))
- Searchable tree widget from a mapping [\#158](https://github.com/pyapp-kit/superqt/pull/158) ([andy-sweet](https://github.com/andy-sweet))
- Add `QElidingLineEdit` class for elidable `QLineEdit`s [\#154](https://github.com/pyapp-kit/superqt/pull/154) ([dalthviz](https://github.com/dalthviz))
**Fixed bugs:**
- fix: focus events on QLabeledSlider [\#175](https://github.com/pyapp-kit/superqt/pull/175) ([tlambert03](https://github.com/tlambert03))
- Set parent of timer in throttler [\#171](https://github.com/pyapp-kit/superqt/pull/171) ([Czaki](https://github.com/Czaki))
- fix: fix double slider label editing [\#168](https://github.com/pyapp-kit/superqt/pull/168) ([tlambert03](https://github.com/tlambert03))
**Documentation updates:**
- Fix typos [\#147](https://github.com/pyapp-kit/superqt/pull/147) ([kianmeng](https://github.com/kianmeng))
**Tests & CI:**
- tests: add qtbot to test to fix windows segfault [\#165](https://github.com/pyapp-kit/superqt/pull/165) ([tlambert03](https://github.com/tlambert03))
- test: fixing tests \[wip\] [\#164](https://github.com/pyapp-kit/superqt/pull/164) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- build: unpin pyside6.5 [\#178](https://github.com/pyapp-kit/superqt/pull/178) ([tlambert03](https://github.com/tlambert03))
- build: pin pyside6 to \<6.5.1 [\#169](https://github.com/pyapp-kit/superqt/pull/169) ([tlambert03](https://github.com/tlambert03))
- pin pyside6\<6.5 [\#160](https://github.com/pyapp-kit/superqt/pull/160) ([tlambert03](https://github.com/tlambert03))
- ci: \[pre-commit.ci\] autoupdate [\#146](https://github.com/pyapp-kit/superqt/pull/146) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci))
## [v0.4.1](https://github.com/pyapp-kit/superqt/tree/v0.4.1) (2022-12-01)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.4.0...v0.4.1)
**Implemented enhancements:**
- feat: Add signal to QCollapsible [\#142](https://github.com/pyapp-kit/superqt/pull/142) ([ppwadhwa](https://github.com/ppwadhwa))
- feat: Change icon used in Collapsible widget [\#140](https://github.com/pyapp-kit/superqt/pull/140) ([ppwadhwa](https://github.com/ppwadhwa))
**Fixed bugs:**
- Move QCollapsible toggle signal emit [\#144](https://github.com/pyapp-kit/superqt/pull/144) ([ppwadhwa](https://github.com/ppwadhwa))
**Merged pull requests:**
- build: use hatch for build backend, and use ruff for linting [\#139](https://github.com/pyapp-kit/superqt/pull/139) ([tlambert03](https://github.com/tlambert03))
- chore: rename napari org to pyapp-kit [\#137](https://github.com/pyapp-kit/superqt/pull/137) ([tlambert03](https://github.com/tlambert03))
## [v0.4.0](https://github.com/pyapp-kit/superqt/tree/v0.4.0) (2022-11-09)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.8...v0.4.0)
**Fixed bugs:**
- fix: fix quantity set value and add test [\#131](https://github.com/pyapp-kit/superqt/pull/131) ([tlambert03](https://github.com/tlambert03))
**Refactors:**
- refactor: update pyproject and ci, add py3.11 test [\#132](https://github.com/pyapp-kit/superqt/pull/132) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- chore: changelog v0.4.0 [\#136](https://github.com/pyapp-kit/superqt/pull/136) ([tlambert03](https://github.com/tlambert03))
- ci\(dependabot\): bump actions/upload-artifact from 2 to 3 [\#135](https://github.com/pyapp-kit/superqt/pull/135) ([dependabot[bot]](https://github.com/apps/dependabot))
- ci\(dependabot\): bump codecov/codecov-action from 2 to 3 [\#134](https://github.com/pyapp-kit/superqt/pull/134) ([dependabot[bot]](https://github.com/apps/dependabot))
- build: unpin pyside6 [\#133](https://github.com/pyapp-kit/superqt/pull/133) ([tlambert03](https://github.com/tlambert03))
## [v0.3.8](https://github.com/pyapp-kit/superqt/tree/v0.3.8) (2022-10-10)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.7...v0.3.8)
**Fixed bugs:**
- fix: allow submodule imports [\#128](https://github.com/pyapp-kit/superqt/pull/128) ([kne42](https://github.com/kne42))
## [v0.3.7](https://github.com/pyapp-kit/superqt/tree/v0.3.7) (2022-10-10)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.6...v0.3.7)
**Implemented enhancements:**
- feat: add Quantity widget \(using pint\) [\#126](https://github.com/pyapp-kit/superqt/pull/126) ([tlambert03](https://github.com/tlambert03))
## [v0.3.6](https://github.com/pyapp-kit/superqt/tree/v0.3.6) (2022-10-05)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.6rc0...v0.3.6)
**Documentation updates:**
- minor fix to readme [\#125](https://github.com/pyapp-kit/superqt/pull/125) ([tlambert03](https://github.com/tlambert03))
- Docs [\#124](https://github.com/pyapp-kit/superqt/pull/124) ([tlambert03](https://github.com/tlambert03))
## [v0.3.6rc0](https://github.com/pyapp-kit/superqt/tree/v0.3.6rc0) (2022-10-03)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.5...v0.3.6rc0)
**Implemented enhancements:**
- feat: add editing finished signal to LabeledSliders [\#122](https://github.com/pyapp-kit/superqt/pull/122) ([tlambert03](https://github.com/tlambert03))
**Fixed bugs:**
- fix: fix missing labels after setValue [\#123](https://github.com/pyapp-kit/superqt/pull/123) ([tlambert03](https://github.com/tlambert03))
- fix: Fix TypeError on slider rangeChanged signal [\#121](https://github.com/pyapp-kit/superqt/pull/121) ([tlambert03](https://github.com/tlambert03))
- Simple workaround for pyside 6 [\#119](https://github.com/pyapp-kit/superqt/pull/119) ([Czaki](https://github.com/Czaki))
- fix: Offer patch for \(unstyled\) QSliders on macos 12 and Qt \<6 [\#117](https://github.com/pyapp-kit/superqt/pull/117) ([tlambert03](https://github.com/tlambert03))
## [v0.3.5](https://github.com/pyapp-kit/superqt/tree/v0.3.5) (2022-08-17)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.4...v0.3.5)
**Fixed bugs:**
- fix range slider drag crash on PyQt6 [\#108](https://github.com/pyapp-kit/superqt/pull/108) ([sfhbarnett](https://github.com/sfhbarnett))
- Fix float value error in pyqt configuration [\#106](https://github.com/pyapp-kit/superqt/pull/106) ([mstabrin](https://github.com/mstabrin))
**Merged pull requests:**
- chore: changelog v0.3.5 [\#110](https://github.com/pyapp-kit/superqt/pull/110) ([tlambert03](https://github.com/tlambert03))
## [v0.3.4](https://github.com/pyapp-kit/superqt/tree/v0.3.4) (2022-07-24)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.3...v0.3.4)
**Fixed bugs:**
- fix: relax runtime typing extensions requirement [\#101](https://github.com/pyapp-kit/superqt/pull/101) ([tlambert03](https://github.com/tlambert03))
- fix: catch qpixmap deprecation [\#99](https://github.com/pyapp-kit/superqt/pull/99) ([tlambert03](https://github.com/tlambert03))
## [v0.3.3](https://github.com/pyapp-kit/superqt/tree/v0.3.3) (2022-07-10)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.2...v0.3.3)
**Implemented enhancements:**
- Add code syntax highlight utils [\#88](https://github.com/pyapp-kit/superqt/pull/88) ([Czaki](https://github.com/Czaki))
**Fixed bugs:**
- fix: fix deprecation warning on fonticon plugin discovery on python 3.10 [\#95](https://github.com/pyapp-kit/superqt/pull/95) ([tlambert03](https://github.com/tlambert03))
## [v0.3.2](https://github.com/pyapp-kit/superqt/tree/v0.3.2) (2022-05-03)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.1...v0.3.2)
**Implemented enhancements:**
- Add QSearchableListWidget and QSearchableComboBox widgets [\#80](https://github.com/pyapp-kit/superqt/pull/80) ([Czaki](https://github.com/Czaki))
**Fixed bugs:**
- Fix crazy animation loop on Qcollapsible [\#84](https://github.com/pyapp-kit/superqt/pull/84) ([tlambert03](https://github.com/tlambert03))
- Reorder label update signal [\#83](https://github.com/pyapp-kit/superqt/pull/83) ([tlambert03](https://github.com/tlambert03))
- Fix height of expanded QCollapsible when child changes size [\#72](https://github.com/pyapp-kit/superqt/pull/72) ([tlambert03](https://github.com/tlambert03))
**Tests & CI:**
- Fix deprecation warnings in tests [\#82](https://github.com/pyapp-kit/superqt/pull/82) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- Add changelog for v0.3.2 [\#86](https://github.com/pyapp-kit/superqt/pull/86) ([tlambert03](https://github.com/tlambert03))
## [v0.3.1](https://github.com/pyapp-kit/superqt/tree/v0.3.1) (2022-03-02)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.0...v0.3.1)
**Implemented enhancements:**
- Add `signals_blocked` util [\#69](https://github.com/pyapp-kit/superqt/pull/69) ([tlambert03](https://github.com/tlambert03))
**Fixed bugs:**
- put SignalInstance in TYPE\_CHECKING clause, check min requirements [\#70](https://github.com/pyapp-kit/superqt/pull/70) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- Add changelog for v0.3.1 [\#71](https://github.com/pyapp-kit/superqt/pull/71) ([tlambert03](https://github.com/tlambert03))
## [v0.3.0](https://github.com/pyapp-kit/superqt/tree/v0.3.0) (2022-02-16)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.5-1...v0.3.0)
**Implemented enhancements:**
- Qthrottler and debouncer [\#62](https://github.com/pyapp-kit/superqt/pull/62) ([tlambert03](https://github.com/tlambert03))
- add edgeLabelMode option to QLabeledSlider [\#59](https://github.com/pyapp-kit/superqt/pull/59) ([tlambert03](https://github.com/tlambert03))
**Fixed bugs:**
- Fix nested threadworker not starting [\#63](https://github.com/pyapp-kit/superqt/pull/63) ([tlambert03](https://github.com/tlambert03))
- Add missing signals on proxy sliders [\#54](https://github.com/pyapp-kit/superqt/pull/54) ([tlambert03](https://github.com/tlambert03))
- Ugly but functional workaround for pyside6.2.1 breakages [\#51](https://github.com/pyapp-kit/superqt/pull/51) ([tlambert03](https://github.com/tlambert03))
**Tests & CI:**
- add napari test to CI [\#67](https://github.com/pyapp-kit/superqt/pull/67) ([tlambert03](https://github.com/tlambert03))
- add gh-release action [\#65](https://github.com/pyapp-kit/superqt/pull/65) ([tlambert03](https://github.com/tlambert03))
- fix xvfb tests [\#61](https://github.com/pyapp-kit/superqt/pull/61) ([tlambert03](https://github.com/tlambert03))
**Refactors:**
- Use qtpy, deprecate superqt.qtcompat, drop support for Qt \<5.12 [\#39](https://github.com/pyapp-kit/superqt/pull/39) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- Add changelog for v0.3.0 [\#68](https://github.com/pyapp-kit/superqt/pull/68) ([tlambert03](https://github.com/tlambert03))
## [v0.2.5-1](https://github.com/pyapp-kit/superqt/tree/v0.2.5-1) (2021-11-23)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.5...v0.2.5-1)
**Merged pull requests:**
- typing-extensions version pinning [\#46](https://github.com/pyapp-kit/superqt/pull/46) ([AhmetCanSolak](https://github.com/AhmetCanSolak))
## [v0.2.5](https://github.com/pyapp-kit/superqt/tree/v0.2.5) (2021-11-22)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.4...v0.2.5)
**Implemented enhancements:**
- add support for python 3.10 [\#42](https://github.com/pyapp-kit/superqt/pull/42) ([tlambert03](https://github.com/tlambert03))
- QCollapsible for Collapsible Section Control [\#37](https://github.com/pyapp-kit/superqt/pull/37) ([MosGeo](https://github.com/MosGeo))
- Threadworker [\#31](https://github.com/pyapp-kit/superqt/pull/31) ([tlambert03](https://github.com/tlambert03))
- Add font icons [\#24](https://github.com/pyapp-kit/superqt/pull/24) ([tlambert03](https://github.com/tlambert03))
**Fixed bugs:**
- Fix some small linting issues. [\#41](https://github.com/pyapp-kit/superqt/pull/41) ([tlambert03](https://github.com/tlambert03))
- Use functools.wraps insterad of \_\_wraped\_\_ and manual proxing \_\_name\_\_ [\#29](https://github.com/pyapp-kit/superqt/pull/29) ([Czaki](https://github.com/Czaki))
- Propagate function name in `ensure_main_thread` and `ensure_object_thread` [\#28](https://github.com/pyapp-kit/superqt/pull/28) ([Czaki](https://github.com/Czaki))
**Tests & CI:**
- reskip test\_object\_thread\_return on ci [\#43](https://github.com/pyapp-kit/superqt/pull/43) ([tlambert03](https://github.com/tlambert03))
**Refactors:**
- refactoring qtcompat [\#34](https://github.com/pyapp-kit/superqt/pull/34) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- Fix-manifest, move font tests [\#44](https://github.com/pyapp-kit/superqt/pull/44) ([tlambert03](https://github.com/tlambert03))
- update deploy [\#33](https://github.com/pyapp-kit/superqt/pull/33) ([tlambert03](https://github.com/tlambert03))
- move to src layout [\#32](https://github.com/pyapp-kit/superqt/pull/32) ([tlambert03](https://github.com/tlambert03))
## [v0.2.4](https://github.com/pyapp-kit/superqt/tree/v0.2.4) (2021-09-13)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.3...v0.2.4)
**Implemented enhancements:**
- Add type stubs for ensure\_thread decorator [\#23](https://github.com/pyapp-kit/superqt/pull/23) ([tlambert03](https://github.com/tlambert03))
- Add `ensure_main_tread` and `ensure_object_thread` [\#22](https://github.com/pyapp-kit/superqt/pull/22) ([Czaki](https://github.com/Czaki))
- Add QMessageHandler context manager [\#21](https://github.com/pyapp-kit/superqt/pull/21) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- add changelog for 0.2.4 [\#25](https://github.com/pyapp-kit/superqt/pull/25) ([tlambert03](https://github.com/tlambert03))
## [v0.2.3](https://github.com/pyapp-kit/superqt/tree/v0.2.3) (2021-08-25)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.2...v0.2.3)
**Fixed bugs:**
- Fix warnings on eliding label for 5.12, test more qt versions [\#19](https://github.com/pyapp-kit/superqt/pull/19) ([tlambert03](https://github.com/tlambert03))
## [v0.2.2](https://github.com/pyapp-kit/superqt/tree/v0.2.2) (2021-08-17)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.1...v0.2.2)
**Implemented enhancements:**
- Add QElidingLabel [\#16](https://github.com/pyapp-kit/superqt/pull/16) ([tlambert03](https://github.com/tlambert03))
- Enum ComboBox implementation [\#13](https://github.com/pyapp-kit/superqt/pull/13) ([Czaki](https://github.com/Czaki))
**Documentation updates:**
- fix broken link [\#18](https://github.com/pyapp-kit/superqt/pull/18) ([haesleinhuepf](https://github.com/haesleinhuepf))
## [v0.2.1](https://github.com/pyapp-kit/superqt/tree/v0.2.1) (2021-07-10)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.0...v0.2.1)
**Fixed bugs:**
- Fix QLabeledRangeSlider API \(fix slider proxy\) [\#10](https://github.com/pyapp-kit/superqt/pull/10) ([tlambert03](https://github.com/tlambert03))
- Fix range slider with negative min range [\#9](https://github.com/pyapp-kit/superqt/pull/9) ([tlambert03](https://github.com/tlambert03))
\* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)*

View File

@@ -26,13 +26,11 @@ pytest
All widgets must be well-tested, and should work on:
- Python 3.7 and above
- Python 3.8 and above
- PyQt5 (5.11 and above) & PyQt6
- PySide2 (5.11 and above) & PySide6
- macOS, Windows, & Linux
Until [qtpy](https://github.com/spyder-ide/qtpy) supports PyQt6/PySide6, imports
should use (and modify if necessary) `superqt.qtcompat`.
## Style Guide
@@ -50,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,5 +0,0 @@
include LICENSE
include README.md
recursive-exclude * __pycache__
recursive-exclude * *.py[co]

View File

@@ -1,12 +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/master/graph/badge.svg)](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
@@ -16,29 +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.8 and above
- PyQt5 (5.11 and above) & PyQt6
- PySide2 (5.11 and above) & PySide6
## Documentation
Documentation is available at https://pyapp-kit.github.io/superqt/
## Widgets
Widgets include:
superqt provides a variety of widgets that are not included in the native QtWidgets module, including multihandle (range) sliders, comboboxes, and more.
- [Float Slider](docs/sliders.md#float-slider)
See the [widgets documentation](https://pyapp-kit.github.io/superqt/widgets) for a full list of widgets.
- [Range Slider](docs/sliders.md#range-slider) (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/pyapp-kit/superqt/main/docs/images/labeled_qslider.png" alt="range sliders" width=680>
- [Labeled Sliders](docs/sliders.md#labeled-sliders) (sliders with linked
spinboxes)
<img src="https://raw.githubusercontent.com/pyapp-kit/superqt/main/docs/images/labeled_range.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>
## Utilities
<img src="https://raw.githubusercontent.com/napari/superqt/main/docs/images/labeled_range.png" alt="range sliders" width=680>
superqt includes a number of utilities for working with Qt, including:
- Unbound Integer SpinBox (backed by python `int`)
- tools and decorators for working with threads in qt.
- `superqt.fonticon` for generating icons from font files (such as [Material Design Icons](https://materialdesignicons.com/) and [Font Awesome](https://fontawesome.com/))
See the [utilities documentation](https://pyapp-kit.github.io/superqt/utilities/) for a full list of utilities.
## Contributing

View File

@@ -1,6 +1,5 @@
ignore:
- superqt/_version.py
- superqt/qtcompat/*
- '*_tests*'
coverage:
status:

146
docs/_macros.py Normal file
View File

@@ -0,0 +1,146 @@
import sys
from enum import EnumMeta
from importlib import import_module
from pathlib import Path
from textwrap import dedent
from typing import TYPE_CHECKING
from jinja2 import pass_context
from qtpy.QtCore import QObject, Signal
if TYPE_CHECKING:
from mkdocs_macros.plugin import MacrosPlugin
EXAMPLES = Path(__file__).parent.parent / "examples"
IMAGES = Path(__file__).parent / "_auto_images"
IMAGES.mkdir(exist_ok=True, parents=True)
def define_env(env: "MacrosPlugin"):
@env.macro
@pass_context
def show_widget(context, width: int = 500) -> list[Path]:
# extract all fenced code blocks starting with "python"
page = context["page"]
dest = IMAGES / f"{page.title}.png"
if "build" in sys.argv:
dest.unlink(missing_ok=True)
codeblocks = [
b[6:].strip()
for b in page.markdown.split("```")
if b.startswith("python")
]
src = codeblocks[0].strip()
src = src.replace(
"QApplication([])", "QApplication.instance() or QApplication([])"
)
src = src.replace("app.exec_()", "")
exec(src) # noqa: S102
_grab(dest, width)
return (
f"![{page.title}](../{dest.parent.name}/{dest.name})"
f"{{ loading=lazy; width={width} }}\n\n"
)
@env.macro
def show_members(cls: str):
# import class
module, name = cls.rsplit(".", 1)
_cls = getattr(import_module(module), name)
first_q = next(
(
b.__name__
for b in _cls.__mro__
if issubclass(b, QObject) and ".Qt" in b.__module__
),
None,
)
inherited_members = set()
for base in _cls.__mro__:
if issubclass(base, QObject) and ".Qt" in base.__module__:
inherited_members.update(
{k for k in dir(base) if not k.startswith("_")}
)
new_signals = {
k
for k, v in vars(_cls).items()
if not k.startswith("_") and isinstance(v, Signal)
}
self_members = {
k
for k in dir(_cls)
if not k.startswith("_") and k not in inherited_members | new_signals
}
enums = []
for m in list(self_members):
if isinstance(getattr(_cls, m), EnumMeta):
self_members.remove(m)
enums.append(m)
out = ""
if first_q:
url = f"https://doc.qt.io/qt-6/{first_q.lower()}.html"
out += f"## Qt Class\n\n<a href='{url}'>`{first_q}`</a>\n\n"
out += ""
if new_signals:
out += "## Signals\n\n"
for sig in new_signals:
out += f"### `{sig}`\n\n"
if enums:
out += "## Enums\n\n"
for e in enums:
out += f"### `{_cls.__name__}.{e}`\n\n"
for m in getattr(_cls, e):
out += f"- `{m.name}`\n\n"
if self_members:
out += dedent(
f"""
## Methods
::: {cls}
options:
heading_level: 3
show_source: False
show_inherited_members: false
show_signature_annotations: True
members: {sorted(self_members)}
docstring_style: numpy
show_bases: False
show_root_toc_entry: False
show_root_heading: False
"""
)
return out
def _grab(dest: str | Path, width) -> list[Path]:
"""Grab the top widgets of the application."""
from qtpy.QtCore import QTimer
from qtpy.QtWidgets import QApplication
w = QApplication.topLevelWidgets()[-1]
w.setFixedWidth(width)
w.activateWindow()
w.setMinimumHeight(40)
w.grab().save(str(dest))
# hack to make sure the object is truly closed and deleted
while True:
QTimer.singleShot(10, w.deleteLater)
QApplication.processEvents()
try:
w.parent()
except RuntimeError:
return

26
docs/faq.md Normal file
View File

@@ -0,0 +1,26 @@
# FAQ
## Sliders not dragging properly on MacOS 12+
??? details
On MacOS Monterey, with Qt5, there is a bug that causes all sliders
(including native Qt sliders) to not respond properly to drag events. See:
- [https://bugreports.qt.io/browse/QTBUG-98093](https://bugreports.qt.io/browse/QTBUG-98093)
- [https://github.com/pyapp-kit/superqt/issues/74](https://github.com/pyapp-kit/superqt/issues/74)
Superqt includes a workaround for this issue, but it is not perfect, and it requires using a custom stylesheet (which may interfere with your own styles). Note that you
may not see this issue if you're already using custom stylesheets.
To opt in to the workaround, do any of the following:
- set the environment variable `USE_MAC_SLIDER_PATCH=1` before importing superqt
(note: this is safe to use even if you're targeting more than just MacOS 12, it will only be applied when needed)
- call the `applyMacStylePatch()` method on any of the superqt slider subclasses (note, this will override your slider styles)
- apply the stylesheet manually:
```python
from superqt.sliders import MONTEREY_SLIDER_STYLES_FIX
slider.setStyleSheet(MONTEREY_SLIDER_STYLES_FIX)
```

29
docs/index.md Normal file
View File

@@ -0,0 +1,29 @@
# superqt
## ![tiny](https://user-images.githubusercontent.com/1609449/120636353-8c3f3800-c43b-11eb-8732-a14dec578897.png) "missing" widgets and components for PyQt/PySide
This repository aims to provide high-quality community-contributed Qt widgets
and components for [PyQt](https://riverbankcomputing.com/software/pyqt/) &
[PySide](https://www.qt.io/qt-for-python) that are not provided in the native
QtWidgets module.
Components are tested on:
- macOS, Windows, & Linux
- Python 3.8 and above
- PyQt5 (5.11 and above) & PyQt6
- PySide2 (5.11 and above) & PySide6
## Installation
```bash
pip install superqt
```
```bash
conda install -c conda-forge superqt
```
## Usage
See the [Widgets](./widgets/) and [Utilities](./utilities/) pages for features offered by superqt.

View File

@@ -1,238 +0,0 @@
# Sliders
![slider](images/slider.png)
- `QRangeSlider` inherits from [`QSlider`](https://doc.qt.io/qt-5/qslider.html)
and attempts to match the Qt API as closely as possible
- Uses platform-specific styles (for handle, groove, & ticks) but also supports
QSS style sheets.
- Supports mouse wheel and keypress (soon) events
- Supports more than 2 handles (e.g. `slider.setValue([0, 10, 60, 80])`)
------
## Range Slider
```python
from superqt import QRangeSlider
# as usual:
# you must create a QApplication before create a widget.
range_slider = QRangeSlider()
```
As `QRangeSlider` inherits from `QtWidgets.QSlider`, you can use all of the
same methods available in the [QSlider API](https://doc.qt.io/qt-5/qslider.html). The major difference is that `value` and `sliderPosition` are reimplemented as `tuples` of `int` (where the length of the tuple is equal to the number of handles in the slider.)
### `value: Tuple[int, ...]`
This property holds the current value of all handles in the slider.
The slider forces all values to be within the legal range:
`minimum <= value <= maximum`.
Changing the value also changes the sliderPosition.
##### Access Functions:
```python
range_slider.value() -> Tuple[int, ...]
```
```python
range_slider.setValue(val: Sequence[int]) -> None
```
##### Notifier Signal:
```python
valueChanged(Tuple[int, ...])
```
### `sliderPosition: Tuple[int, ...]`
This property holds the current slider positions. It is a `tuple` with length equal to the number of handles.
If [tracking](https://doc.qt.io/qt-5/qabstractslider.html#tracking-prop) is enabled (the default), this is identical to [`value`](#value--tupleint-).
##### Access Functions:
```python
range_slider.sliderPosition() -> Tuple[int, ...]
```
```python
range_slider.setSliderPosition(val: Sequence[int]) -> None
```
##### Notifier Signal:
```python
sliderMoved(Tuple[int, ...])
```
### Additional properties
These options are in addition to the Qt QSlider API, and control the behavior of the bar between handles.
| getter | setter | type | default | description |
| -------------------- | ------------------------------------------- | ------ | ------- | ------------------------------------------------------------------------------------------------ |
| `barIsVisible` | `setBarIsVisible` <br>`hideBar` / `showBar` | `bool` | `True` | <small>Whether the bar between handles is visible.</small> |
| `barMovesAllHandles` | `setBarMovesAllHandles` | `bool` | `True` | <small>Whether clicking on the bar moves all handles or just the nearest</small> |
| `barIsRigid` | `setBarIsRigid` | `bool` | `True` | <small>Whether bar length is constant or "elastic" when dragging the bar beyond min/max.</small> |
------
### Examples
These screenshots show `QRangeSlider` (multiple handles) next to the native `QSlider`
(single handle). With no styles applied, `QRangeSlider` will match the native OS
style of `QSlider` with or without tick marks. When styles have been applied
using [Qt Style Sheets](https://doc.qt.io/qt-5/stylesheet-reference.html), then
`QRangeSlider` will inherit any styles applied to `QSlider` (since it inherits
from QSlider). If you'd like to style `QRangeSlider` differently than `QSlider`,
then you can also target it directly in your style sheet. The one "special"
property for QRangeSlider is `qproperty-barColor`, which sets the color of the
bar between the handles.
> The code for these example widgets is [here](examples/demo_widget.py)
<details>
<summary><em>See style sheet used for this example</em></summary>
```css
/*
Because QRangeSlider inherits from QSlider, it will also inherit styles
*/
QSlider {
min-height: 20px;
}
QSlider::groove:horizontal {
border: 0px;
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
stop:0 #777, stop:1 #aaa);
height: 20px;
border-radius: 10px;
}
QSlider::handle {
background: qradialgradient(cx:0, cy:0, radius: 1.2, fx:0.5,
fy:0.5, stop:0 #eef, stop:1 #000);
height: 20px;
width: 20px;
border-radius: 10px;
}
/*
"QSlider::sub-page" is the one exception ...
(it styles the area to the left of the QSlider handle)
*/
QSlider::sub-page:horizontal {
background: #447;
border-top-left-radius: 10px;
border-bottom-left-radius: 10px;
}
/*
for QRangeSlider: use "qproperty-barColor". "sub-page" will not work.
*/
QRangeSlider {
qproperty-barColor: #447;
}
```
</details>
#### macOS
##### Catalina
![mac10](images/demo_darwin10.png)
##### Big Sur
![mac11](images/demo_darwin11.png)
#### Windows
![window](images/demo_windows.png)
#### Linux
![linux](images/demo_linux.png)
## Labeled Sliders
This package also includes two "labeled" slider variants. One for `QRangeSlider`, and one for the native `QSlider`:
### `QLabeledRangeSlider`
![labeled_range](images/labeled_range.png)
```python
from superqt import QLabeledRangeSlider
```
This has the same API as `QRangeSlider` with the following additional options:
#### `handleLabelPosition`/`setHandleLabelPosition`
Where/whether labels are shown adjacent to slider handles.
**type:** `QLabeledRangeSlider.LabelPosition`
**default:** `LabelPosition.LabelsAbove`
*options:*
- `LabelPosition.NoLabel` (no labels shown adjacent to handles)
- `LabelPosition.LabelsAbove`
- `LabelPosition.LabelsBelow`
- `LabelPosition.LabelsRight` (alias for `LabelPosition.LabelsAbove`)
- `LabelPosition.LabelsLeft` (alias for `LabelPosition.LabelsBelow`)
#### `edgeLabelMode`/`setEdgeLabelMode`
**type:** `QLabeledRangeSlider.EdgeLabelMode`
**default:** `EdgeLabelMode.LabelIsRange`
*options:*
- `EdgeLabelMode.NoLabel`: no labels shown at slider extremes
- `EdgeLabelMode.LabelIsRange`: edge labels shown the min/max values
- `EdgeLabelMode.LabelIsValue`: edge labels shown the slider range
#### fine tuning position of labels:
If you find that you need to fine tune the position of the handle labels:
- `QLabeledRangeSlider.label_shift_x`: adjust horizontal label position
- `QLabeledRangeSlider.label_shift_y`: adjust vertical label position
### `QLabeledSlider`
![labeled_range](images/labeled_qslider.png)
```python
from superqt import QLabeledSlider
```
(no additional options at this point)
## Issues
If you encounter any problems, please [file an issue] along with a detailed
description.
[file an issue]: https://github.com/napari/superqt/issues
## Float Slider
just like QSlider, but supports float values
```python
from superqt import QDoubleSlider
```

View File

@@ -0,0 +1,52 @@
# CodeSyntaxHighlight
A code highlighter subclass of `QSyntaxHighlighter`
that can be used to highlight code in a QTextEdit.
Code lexer and available styles are from [`pygments`](https://pygments.org/) python library
List of available languages are available [here](https://pygments.org/languages/).
List of available styles are available [here](https://pygments.org/styles/).
## Example
```python
from qtpy.QtGui import QColor, QPalette
from qtpy.QtWidgets import QApplication, QTextEdit
from superqt.utils import CodeSyntaxHighlight
app = QApplication([])
text_area = QTextEdit()
highlight = CodeSyntaxHighlight(text_area.document(), "python", "monokai")
palette = text_area.palette()
palette.setColor(QPalette.Base, QColor(highlight.background_color))
text_area.setPalette(palette)
text_area.setText(
"""from argparse import ArgumentParser
def main():
parser = ArgumentParser()
parser.add_argument("name", help="Your name")
args = parser.parse_args()
print(f"Hello {args.name}")
if __name__ == "__main__":
main()
"""
)
text_area.show()
text_area.resize(400, 200)
app.exec_()
```
{{ show_widget() }}
{{ show_members('superqt.utils.CodeSyntaxHighlight') }}

View File

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

101
docs/utilities/fonticon.md Normal file
View File

@@ -0,0 +1,101 @@
# Font icons
The `superqt.fonticon` module provides a set of utilities for working with font
icons such as [Font Awesome](https://fontawesome.com/) or [Material Design
Icons](https://materialdesignicons.com/).
## Basic Example
```python
from fonticon_fa5 import FA5S
from qtpy.QtCore import QSize
from qtpy.QtWidgets import QApplication, QPushButton
from superqt.fonticon import icon, pulse
app = QApplication([])
btn2 = QPushButton()
btn2.setIcon(icon(FA5S.smile, color="blue"))
btn2.setIconSize(QSize(225, 225))
btn2.show()
app.exec()
```
{{ show_widget(225) }}
## Font Icon plugins
Ready-made fonticon packs are available as plugins:
### [Font Awesome 5](https://fontawesome.com/v5/search)
```bash
pip install fonticon-fontawesome5
```
### [Font Awesome 6](https://fontawesome.com/v6/search)
```bash
pip install fonticon-fontawesome6
```
### [Material Design Icons](https://materialdesignicons.com/)
```bash
pip install fonticon-materialdesignicons6
```
### See also
- <https://github.com/tlambert03/fonticon-bootstrapicons>
- <https://github.com/tlambert03/fonticon-linearicons>
- <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:
- <https://github.com/tlambert03/fonticon-fontawesome6>
- <https://github.com/tlambert03/fonticon-fontawesome5>
- <https://github.com/tlambert03/fonticon-materialdesignicons6>
## API
::: superqt.fonticon.icon
options:
heading_level: 3
::: superqt.fonticon.setTextIcon
options:
heading_level: 3
::: superqt.fonticon.font
options:
heading_level: 3
::: superqt.fonticon.IconOpts
options:
heading_level: 3
::: superqt.fonticon.addFont
options:
heading_level: 3
## Animations
the `animation` parameter to `icon()` accepts a subclass of
`Animation` that will be
::: superqt.fonticon.Animation
options:
heading_level: 3
::: superqt.fonticon.pulse
options:
heading_level: 3
::: superqt.fonticon.spin
options:
heading_level: 3

31
docs/utilities/index.md Normal file
View File

@@ -0,0 +1,31 @@
# Utilities
## Font Icons
| Object | Description |
| ----------- | --------------------- |
| [`addFont`](./fonticon.md#superqt.fonticon.addFont) | Add an `OTF/TTF` file at to the font registry. |
| [`font`](./fonticon.md#superqt.fonticon.font) | Create `QFont` for a given font-icon font family key |
| [`icon`](./fonticon.md#superqt.fonticon.icon) | Create a `QIcon` for font-con glyph key |
| [`setTextIcon`](./fonticon.md#superqt.fonticon.setTextIcon) | Set text on a `QWidget` to a specific font & glyph. |
| [`IconFont`](./fonticon.md#superqt.fonticon.IconFont) | Helper class that provides a standard way to create an `IconFont`. |
| [`IconOpts`](./fonticon.md#superqt.fonticon.IconOpts) | Options for rendering an icon |
| [`Animation`](./fonticon.md#superqt.fonticon.Animation) | Base class for adding animations to a font-icon. |
## Threading tools
| Object | Description |
| ----------- | --------------------- |
| [`ensure_main_thread`](./thread_decorators.md#ensure_main_thread) | Decorator that ensures a function is called in the main `QApplication` thread. |
| [`ensure_object_thread`](./thread_decorators.md#ensure_object_thread) | Decorator that ensures a `QObject` method is called in the object's thread. |
| [`FunctionWorker`](./threading.md#superqt.utils.FunctionWorker) | `QRunnable` with signals that wraps a simple long-running function. |
| [`GeneratorWorker`](./threading.md#superqt.utils.GeneratorWorker) | `QRunnable` with signals that wraps a long-running generator. |
| [`create_worker`](./threading.md#superqt.utils.create_worker) | Create a worker to run a target function in another thread. |
| [`thread_worker`](./threading.md#superqt.utils.thread_worker) | Decorator for `create_worker`, turn a function into a worker. |
## Miscellaneous
| Object | Description |
| ----------- | --------------------- |
| [`QMessageHandler`](./qmessagehandler.md) | A context manager to intercept messages from Qt. |
| [`CodeSyntaxHighlight`](./code_syntax_highlight.md) | A `QSyntaxHighlighter` for code syntax highlighting. |

View File

@@ -0,0 +1,8 @@
# QMessageHandler
::: superqt.utils.QMessageHandler
options:
heading_level: 3
show_signature_annotations: True
docstring_style: numpy
show_bases: False

View File

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

View File

@@ -0,0 +1,94 @@
# Threading decorators
`superqt` provides two decorators that help to ensure that given function is
running in the desired thread:
## `ensure_main_thread`
`ensure_main_thread` ensures that the decorated function/method runs in the main thread
## `ensure_object_thread`
`ensure_object_thread` ensures that a decorated bound method of a `QObject` runs
in the thread in which the instance lives ([see qt documentation for
details](https://doc.qt.io/qt-5/threads-qobject.html#accessing-qobject-subclasses-from-other-threads)).
## Usage
By default, functions are executed asynchronously (they return immediately with
an instance of
[`concurrent.futures.Future`](https://docs.python.org/3/library/concurrent.futures.html#concurrent.futures.Future)).
To block and wait for the result, see [Synchronous mode](#synchronous-mode)
```python
from qtpy.QtCore import QObject
from superqt import ensure_main_thread, ensure_object_thread
@ensure_main_thread
def sample_function():
print("This function will run in main thread")
class SampleObject(QObject):
def __init__(self):
super().__init__()
self._value = 1
@ensure_main_thread
def sample_method1(self):
print("This method will run in main thread")
@ensure_object_thread
def sample_method3(self):
import time
print("sleeping")
time.sleep(1)
print("This method will run in object thread")
@property
def value(self):
print("return value")
return self._value
@value.setter
@ensure_object_thread
def value(self, value):
print("this setter will run in object thread")
self._value = value
```
As can be seen in this example these decorators can also be used for setters.
These decorators should not be used as replacement of Qt Signals but rather to
interact with Qt objects from non Qt code.
## Synchronous mode
If you'd like for the program to block and wait for the result of your function
call, use the `await_return=True` parameter, and optionally specify a timeout.
!!! important
Using synchronous mode may significantly impact performance.
```python
from superqt import ensure_main_thread
@ensure_main_thread
def sample_function1():
return 1
@ensure_main_thread(await_return=True)
def sample_function2():
return 2
assert sample_function1() is None
assert sample_function2() == 2
# optionally, specify a timeout
@ensure_main_thread(await_return=True, timeout=10000)
def sample_function():
return 1
```

View File

@@ -0,0 +1,36 @@
# Thread workers
The objects in this module provide utilities for running tasks in a separate
thread. In general (with the exception of `new_worker_qthread`), everything
here wraps Qt's [QRunnable API](https://doc.qt.io/qt-6/qrunnable.html).
The highest level object is the
[`@thread_worker`][superqt.utils.thread_worker] decorator. It was originally
written for `napari`, and was later extracted into `superqt`. You may also be
interested in reading the [napari
documentation](https://napari.org/stable/guides/threading.html#threading-in-napari-with-thread-worker) on this feature,
which provides a more in-depth/introductory usage guide.
For additional control, you can create your own
[`FunctionWorker`][superqt.utils.FunctionWorker] or
[`GeneratorWorker`][superqt.utils.GeneratorWorker] objects.
::: superqt.utils.WorkerBase
::: superqt.utils.FunctionWorker
::: superqt.utils.GeneratorWorker
## Convenience functions
::: superqt.utils.thread_worker
options:
heading_level: 3
::: superqt.utils.create_worker
options:
heading_level: 3
::: superqt.utils.new_worker_qthread
options:
heading_level: 3

View File

@@ -0,0 +1,46 @@
# Throttling & Debouncing
These utilities allow you to throttle or debounce a function. This is useful
when you have a function that is called multiple times in a short period of
time, and you want to make sure it is only "actually" called once (or at least
no more than a certain frequency).
For background on throttling and debouncing, see:
- <https://blog.openreplay.com/forever-functional-debouncing-and-throttling-for-performance>
- <https://css-tricks.com/debouncing-throttling-explained-examples/>
::: superqt.utils.qdebounced
options:
show_source: false
docstring_style: numpy
show_root_toc_entry: True
show_root_heading: True
::: superqt.utils.qthrottled
options:
show_source: false
docstring_style: numpy
show_root_toc_entry: True
show_root_heading: True
::: superqt.utils.QSignalDebouncer
options:
show_source: false
docstring_style: numpy
show_root_toc_entry: True
show_root_heading: True
::: superqt.utils.QSignalThrottler
options:
show_source: false
docstring_style: numpy
show_root_toc_entry: True
show_root_heading: True
::: superqt.utils._throttler.GenericSignalThrottler
options:
show_source: false
docstring_style: numpy
show_root_toc_entry: True
show_root_heading: True

32
docs/widgets/index.md Normal file
View File

@@ -0,0 +1,32 @@
# Widgets
The following are QWidget subclasses:
## Sliders and Numerical Inputs
| Widget | Description |
| ----------- | --------------------- |
| [`QDoubleRangeSlider`](./qdoublerangeslider.md) | Multi-handle slider for float values |
| [`QDoubleSlider`](./qdoubleslider.md) | Slider for float values |
| [`QLabeledDoubleRangeSlider`](./qlabeleddoublerangeslider.md) | `QDoubleRangeSlider` variant with editable labels for each handle |
| [`QLabeledDoubleSlider`](./qlabeleddoubleslider.md) | `QSlider` for float values with editable `QSpinBox` with the current value |
| [`QLabeledRangeSlider`](./qlabeledrangeslider.md) | `QRangeSlider` variant, with editable labels for each handle |
| [`QLabeledSlider`](./qlabeledslider.md) | `QSlider` with editable `QSpinBox` that shows the current value |
| [`QLargeIntSpinBox`](./qlargeintspinbox.md) | `QSpinbox` that accepts arbitrarily large integers |
| [`QRangeSlider`](./qrangeslider.md) | Multi-handle slider |
| [`QQuantity`](./qquantity.md) | Pint-backed quantity widget (magnitude combined with unit dropdown) |
## Labels and categorical inputs
| Widget | Description |
| ----------- | --------------------- |
| [`QElidingLabel`](./qelidinglabel.md) | A `QLabel` variant that will elide text (add `…`) to fit width. |
| [`QEnumComboBox`](./qenumcombobox.md) | `QComboBox` that populates the combobox from a python `Enum` |
| [`QSearchableComboBox`](./qsearchablecombobox.md) | `QComboBox` variant that filters available options based on text input |
| [`QSearchableListWidget`](./qsearchablelistwidget.md) | `QListWidget` variant with search field that filters available options |
## Frames and containers
| Widget | Description |
| ----------- | --------------------- |
| [`QCollapsible`](./qcollapsible.md) | A collapsible widget to hide and unhide child widgets. |

View File

@@ -0,0 +1,24 @@
# QCollapsible
Collapsible `QFrame` that can be expanded or collapsed by clicking on the header.
```python
from qtpy.QtWidgets import QApplication, QLabel, QPushButton
from superqt import QCollapsible
app = QApplication([])
collapsible = QCollapsible("Advanced analysis")
collapsible.addWidget(QLabel("This is the inside of the collapsible frame"))
for i in range(10):
collapsible.addWidget(QPushButton(f"Content button {i + 1}"))
collapsible.expand(animate=False)
collapsible.show()
app.exec_()
```
{{ show_widget(350) }}
{{ show_members('superqt.QCollapsible') }}

View File

@@ -0,0 +1,23 @@
# QDoubleRangeSlider
Float variant of [`QRangeSlider`](qrangeslider.md). (see that page for more details).
```python
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication
from superqt import QDoubleRangeSlider
app = QApplication([])
slider = QDoubleRangeSlider(Qt.Orientation.Horizontal)
slider.setRange(0, 1)
slider.setValue((0.2, 0.8))
slider.show()
app.exec_()
```
{{ show_widget() }}
{{ show_members('superqt.QDoubleRangeSlider') }}

View File

@@ -0,0 +1,23 @@
# QDoubleSlider
`QSlider` variant that accepts floating point values.
```python
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication
from superqt import QDoubleSlider
app = QApplication([])
slider = QDoubleSlider(Qt.Orientation.Horizontal)
slider.setRange(0, 1)
slider.setValue(0.5)
slider.show()
app.exec_()
```
{{ show_widget() }}
{{ show_members('superqt.QDoubleSlider') }}

View File

@@ -0,0 +1,26 @@
# QElidingLabel
`QLabel` variant that will elide text (i.e. add an ellipsis)
if it is too long to fit in the available space.
```python
from qtpy.QtWidgets import QApplication
from superqt import QElidingLabel
app = QApplication([])
widget = QElidingLabel(
"a skj skjfskfj sdlf sdfl sdlfk jsdf sdlkf jdsf dslfksdl sdlfk sdf sdl "
"fjsdlf kjsdlfk laskdfsal as lsdfjdsl kfjdslf asfd dslkjfldskf sdlkfj"
)
widget.setWordWrap(True)
widget.resize(300, 20)
widget.show()
app.exec_()
```
{{ show_widget(300) }}
{{ show_members('superqt.QElidingLabel') }}

View File

@@ -0,0 +1,72 @@
# QEnumComboBox
`QEnumComboBox` is a variant of
[`QComboBox`](https://doc.qt.io/qt-5/qcombobox.html) that populates the items in
the combobox based on a python `Enum` class. In addition to all the methods
provided by `QComboBox`, this subclass adds the methods
`enumClass`/`setEnumClass` to get/set the current `Enum` class represented by
the combobox, and `currentEnum`/`setCurrentEnum` to get/set the current `Enum`
member in the combobox. There is also a new signal `currentEnumChanged(enum)`
analogous to `currentIndexChanged` and `currentTextChanged`.
Method like `insertItem` and `addItem` are blocked and try of its usage will end
with `RuntimeError`
```python
from enum import Enum
from qtpy.QtWidgets import QApplication
from superqt import QEnumComboBox
class SampleEnum(Enum):
first = 1
second = 2
third = 3
app = QApplication([])
combo = QEnumComboBox()
combo.setEnumClass(SampleEnum)
combo.show()
app.exec_()
```
{{ show_widget() }}
Another option is to use optional `enum_class` argument of constructor and change
```python
# option A:
combo = QEnumComboBox()
combo.setEnumClass(SampleEnum)
# option B:
combo = QEnumComboBox(enum_class=SampleEnum)
```
## Allow `None`
`QEnumComboBox` also allows using `Optional` type annotation:
```python
from enum import Enum
from superqt import QEnumComboBox
class SampleEnum(Enum):
first = 1
second = 2
third = 3
# as usual:
# you must create a QApplication before create a widget.
combo = QEnumComboBox()
combo.setEnumClass(SampleEnum, allow_none=True)
```
In this case there is added option `----` and the `currentEnum()` method will
return `None` when it is selected.
{{ show_members('superqt.QEnumComboBox') }}

View File

@@ -0,0 +1,23 @@
# QLabeledDoubleRangeSlider
Labeled Float variant of [`QRangeSlider`](qrangeslider.md). (see that page for more details).
```python
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication
from superqt import QLabeledDoubleRangeSlider
app = QApplication([])
slider = QLabeledDoubleRangeSlider(Qt.Orientation.Horizontal)
slider.setRange(0, 1)
slider.setValue((0.2, 0.8))
slider.show()
app.exec_()
```
{{ show_widget() }}
{{ show_members('superqt.QLabeledDoubleRangeSlider') }}

View File

@@ -0,0 +1,24 @@
# QLabeledDoubleSlider
[`QDoubleSlider`](./qdoubleslider.md) variant that shows an editable (SpinBox) label next to the slider.
```python
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication
from superqt import QLabeledDoubleSlider
app = QApplication([])
slider = QLabeledDoubleSlider(Qt.Orientation.Horizontal)
slider.setRange(0, 2.5)
slider.setValue(1.3)
slider.show()
app.exec_()
```
{{ show_widget() }}
{{ show_members('superqt.QLabeledDoubleSlider') }}

View File

@@ -0,0 +1,29 @@
# QLabeledRangeSlider
Labeled variant of [`QRangeSlider`](qrangeslider.md). (see that page for more details).
```python
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication
from superqt import QLabeledRangeSlider
app = QApplication([])
slider = QLabeledRangeSlider(Qt.Orientation.Horizontal)
slider.setValue((20, 80))
slider.show()
app.exec_()
```
{{ show_widget() }}
{{ show_members('superqt.QLabeledRangeSlider') }}
----
If you find that you need to fine tune the position of the handle labels:
- `QLabeledRangeSlider.label_shift_x`: adjust horizontal label position
- `QLabeledRangeSlider.label_shift_y`: adjust vertical label position

View File

@@ -0,0 +1,22 @@
# QLabeledSlider
`QSlider` variant that shows an editable (SpinBox) label next to the slider.
```python
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication
from superqt import QLabeledSlider
app = QApplication([])
slider = QLabeledSlider(Qt.Orientation.Horizontal)
slider.setValue(42)
slider.show()
app.exec_()
```
{{ show_widget() }}
{{ show_members('superqt.QLabeledSlider') }}

View File

@@ -0,0 +1,23 @@
# QLargeIntSpinBox
`QSpinBox` variant that allows to enter large integers, without overflow.
```python
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication
from superqt import QLargeIntSpinBox
app = QApplication([])
slider = QLargeIntSpinBox()
slider.setRange(0, 4.53e8)
slider.setValue(4.53e8)
slider.show()
app.exec_()
```
{{ show_widget(150) }}
{{ show_members('superqt.QLargeIntSpinBox') }}

33
docs/widgets/qquantity.md Normal file
View File

@@ -0,0 +1,33 @@
# QQuantity
A widget that allows the user to edit a quantity (a magnitude associated with a unit).
!!! note
This widget requires [`pint`](https://pint.readthedocs.io):
```
pip install pint
```
or
```
pip install superqt[quantity]
```
```python
from qtpy.QtWidgets import QApplication
from superqt import QQuantity
app = QApplication([])
w = QQuantity("1m")
w.show()
app.exec()
```
{{ show_widget(150) }}
{{ show_members('superqt.QQuantity') }}

View File

@@ -0,0 +1,229 @@
# QRangeSlider
A multi-handle slider widget than can be used to
select a range of values.
```python
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication
from superqt import QRangeSlider
app = QApplication([])
slider = QRangeSlider(Qt.Orientation.Horizontal)
slider.setValue((20, 80))
slider.show()
app.exec_()
```
{{ show_widget() }}
- `QRangeSlider` inherits from [`QSlider`](https://doc.qt.io/qt-5/qslider.html)
and attempts to match the Qt API as closely as possible
- It uses platform-specific styles (for handle, groove, & ticks) but also supports
QSS style sheets.
- Supports mouse wheel events
- Supports more than 2 handles (e.g. `slider.setValue([0, 10, 60, 80])`)
As `QRangeSlider` inherits from
[`QtWidgets.QSlider`](https://doc.qt.io/qt-5/qslider.html), you can use all of
the same methods available in the [QSlider
API](https://doc.qt.io/qt-5/qslider.html). The major difference is that `value()`
and `sliderPosition()` are reimplemented as `tuples` of `int` (where the length of
the tuple is equal to the number of handles in the slider.)
These options are in addition to the Qt QSlider API, and control the behavior of the bar between handles.
| getter | setter | type | default | description |
| -------------------- | ------------------------------------------- | ------ | ------- | ------------------------------------------------------------------------------------------------ |
| `barIsVisible` | `setBarIsVisible` <br>`hideBar` / `showBar` | `bool` | `True` | <small>Whether the bar between handles is visible.</small> |
| `barMovesAllHandles` | `setBarMovesAllHandles` | `bool` | `True` | <small>Whether clicking on the bar moves all handles or just the nearest</small> |
| `barIsRigid` | `setBarIsRigid` | `bool` | `True` | <small>Whether bar length is constant or "elastic" when dragging the bar beyond min/max.</small> |
### Screenshots
??? title "code that generates the images below"
```python
import os
from qtpy import QtCore
from qtpy import QtWidgets as QtW
# patch for Qt 5.15 on macos >= 12
os.environ["USE_MAC_SLIDER_PATCH"] = "1"
from superqt import QRangeSlider # noqa
QSS = """
QSlider {
min-height: 20px;
}
QSlider::groove:horizontal {
border: 0px;
background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #888, stop:1 #ddd);
height: 20px;
border-radius: 10px;
}
QSlider::handle {
background: qradialgradient(cx:0, cy:0, radius: 1.2, fx:0.35,
fy:0.3, stop:0 #eef, stop:1 #002);
height: 20px;
width: 20px;
border-radius: 10px;
}
QSlider::sub-page:horizontal {
background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #227, stop:1 #77a);
border-top-left-radius: 10px;
border-bottom-left-radius: 10px;
}
QRangeSlider {
qproperty-barColor: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #227, stop:1 #77a);
}
"""
Horizontal = QtCore.Qt.Orientation.Horizontal
class DemoWidget(QtW.QWidget):
def __init__(self) -> None:
super().__init__()
reg_hslider = QtW.QSlider(Horizontal)
reg_hslider.setValue(50)
range_hslider = QRangeSlider(Horizontal)
range_hslider.setValue((20, 80))
multi_range_hslider = QRangeSlider(Horizontal)
multi_range_hslider.setValue((11, 33, 66, 88))
multi_range_hslider.setTickPosition(QtW.QSlider.TickPosition.TicksAbove)
styled_reg_hslider = QtW.QSlider(Horizontal)
styled_reg_hslider.setValue(50)
styled_reg_hslider.setStyleSheet(QSS)
styled_range_hslider = QRangeSlider(Horizontal)
styled_range_hslider.setValue((20, 80))
styled_range_hslider.setStyleSheet(QSS)
reg_vslider = QtW.QSlider(QtCore.Qt.Orientation.Vertical)
reg_vslider.setValue(50)
range_vslider = QRangeSlider(QtCore.Qt.Orientation.Vertical)
range_vslider.setValue((22, 77))
tick_vslider = QtW.QSlider(QtCore.Qt.Orientation.Vertical)
tick_vslider.setValue(55)
tick_vslider.setTickPosition(QtW.QSlider.TicksRight)
range_tick_vslider = QRangeSlider(QtCore.Qt.Orientation.Vertical)
range_tick_vslider.setValue((22, 77))
range_tick_vslider.setTickPosition(QtW.QSlider.TicksLeft)
szp = QtW.QSizePolicy.Maximum
left = QtW.QWidget()
left.setLayout(QtW.QVBoxLayout())
left.setContentsMargins(2, 2, 2, 2)
label1 = QtW.QLabel("Regular QSlider Unstyled")
label2 = QtW.QLabel("QRangeSliders Unstyled")
label3 = QtW.QLabel("Styled Sliders (using same stylesheet)")
label1.setSizePolicy(szp, szp)
label2.setSizePolicy(szp, szp)
label3.setSizePolicy(szp, szp)
left.layout().addWidget(label1)
left.layout().addWidget(reg_hslider)
left.layout().addWidget(label2)
left.layout().addWidget(range_hslider)
left.layout().addWidget(multi_range_hslider)
left.layout().addWidget(label3)
left.layout().addWidget(styled_reg_hslider)
left.layout().addWidget(styled_range_hslider)
right = QtW.QWidget()
right.setLayout(QtW.QHBoxLayout())
right.setContentsMargins(15, 5, 5, 0)
right.layout().setSpacing(30)
right.layout().addWidget(reg_vslider)
right.layout().addWidget(range_vslider)
right.layout().addWidget(tick_vslider)
right.layout().addWidget(range_tick_vslider)
self.setLayout(QtW.QHBoxLayout())
self.layout().addWidget(left)
self.layout().addWidget(right)
self.setGeometry(600, 300, 580, 300)
self.activateWindow()
self.show()
if __name__ == "__main__":
import sys
from pathlib import Path
dest = Path("screenshots")
dest.mkdir(exist_ok=True)
app = QtW.QApplication([])
demo = DemoWidget()
if "-snap" in sys.argv:
import platform
QtW.QApplication.processEvents()
demo.grab().save(str(dest / f"demo_{platform.system().lower()}.png"))
else:
app.exec_()
```
#### macOS
##### Catalina
![mac10](../images/demo_darwin10.png){ width=580; }
##### Big Sur
![mac11](../images/demo_darwin11.png){ width=580; }
#### Windows
![window](../images/demo_windows.png)
#### Linux
![linux](../images/demo_linux.png)
{{ show_members('superqt.sliders._sliders._GenericRangeSlider') }}
## Type changes
Note the following changes in types compared to the `QSlider` API:
```python
value() -> Tuple[int, ...]
```
```python
setValue(val: Sequence[int]) -> None
```
```python
# Signal
valueChanged(Tuple[int, ...])
```
```python
sliderPosition() -> Tuple[int, ...]
```
```python
setSliderPosition(val: Sequence[int]) -> None
```
```python
sliderMoved(Tuple[int, ...])
```

View File

@@ -0,0 +1,25 @@
# QSearchableComboBox
`QSearchableComboBox` is a variant of
[`QComboBox`](https://doc.qt.io/qt-5/qcombobox.html) that allow to filter list
of options by enter part of text. It could be drop in replacement for
`QComboBox`.
```python
from qtpy.QtWidgets import QApplication
from superqt import QSearchableComboBox
app = QApplication([])
combo = QSearchableComboBox()
combo.addItems(["foo", "bar", "baz", "foobar", "foobaz", "barbaz"])
combo.show()
app.exec_()
```
{{ show_widget() }}
{{ show_members('superqt.QSearchableComboBox') }}

View File

@@ -0,0 +1,28 @@
# QSearchableListWidget
`QSearchableListWidget` is a variant of
[`QListWidget`](https://doc.qt.io/qt-5/qlistwidget.html) that add text entry
above list widget that allow to filter list of available options.
Due to implementation details, this widget it does not inherit directly from
[`QListWidget`](https://doc.qt.io/qt-5/qlistwidget.html) but it does fully
satisfy its api. The only limitation is that it cannot be used as argument of
[`QListWidgetItem`](https://doc.qt.io/qt-5/qlistwidgetitem.html) constructor.
```python
from qtpy.QtWidgets import QApplication
from superqt import QSearchableListWidget
app = QApplication([])
slider = QSearchableListWidget()
slider.addItems(["foo", "bar", "baz", "foobar", "foobaz", "barbaz"])
slider.show()
app.exec_()
```
{{ show_widget() }}
{{ show_members('superqt.QSearchableListWidget') }}

View File

@@ -1,11 +1,12 @@
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication
from superqt import QRangeSlider
from superqt.qtcompat.QtCore import Qt
from superqt.qtcompat.QtWidgets import QApplication
app = QApplication([])
slider = QRangeSlider(Qt.Horizontal)
slider = QRangeSlider(Qt.Horizontal)
slider = QRangeSlider(Qt.Orientation.Horizontal)
slider = QRangeSlider(Qt.Orientation.Horizontal)
slider.setValue((20, 80))
slider.show()

View File

@@ -1,12 +0,0 @@
from superqt import QDoubleSlider
from superqt.qtcompat.QtCore import Qt
from superqt.qtcompat.QtWidgets import QApplication
app = QApplication([])
slider = QDoubleSlider(Qt.Horizontal)
slider.setRange(0, 1)
slider.setValue(0.5)
slider.show()
app.exec_()

View File

@@ -0,0 +1,32 @@
from PyQt5.QtGui import QColor, QPalette
from qtpy.QtWidgets import QApplication, QTextEdit
from superqt.utils import CodeSyntaxHighlight
app = QApplication([])
text_area = QTextEdit()
highlight = CodeSyntaxHighlight(text_area.document(), "python", "monokai")
palette = text_area.palette()
palette.setColor(QPalette.Base, QColor(highlight.background_color))
text_area.setPalette(palette)
text_area.setText(
"""from argparse import ArgumentParser
def main():
parser = ArgumentParser()
parser.add_argument("name", help="Your name")
args = parser.parse_args()
print(f"Hello {args.name}")
if __name__ == "__main__":
main()
"""
)
text_area.show()
app.exec_()

View File

@@ -1,6 +1,12 @@
from superqt import QRangeSlider
from superqt.qtcompat import QtCore
from superqt.qtcompat import QtWidgets as QtW
import os
from qtpy import QtCore
from qtpy import QtWidgets as QtW
# patch for Qt 5.15 on macos >= 12
os.environ["USE_MAC_SLIDER_PATCH"] = "1"
from superqt import QRangeSlider # noqa
QSS = """
QSlider {
@@ -33,35 +39,37 @@ QRangeSlider {
}
"""
Horizontal = QtCore.Qt.Orientation.Horizontal
class DemoWidget(QtW.QWidget):
def __init__(self) -> None:
super().__init__()
reg_hslider = QtW.QSlider(QtCore.Qt.Horizontal)
reg_hslider = QtW.QSlider(Horizontal)
reg_hslider.setValue(50)
range_hslider = QRangeSlider(QtCore.Qt.Horizontal)
range_hslider = QRangeSlider(Horizontal)
range_hslider.setValue((20, 80))
multi_range_hslider = QRangeSlider(QtCore.Qt.Horizontal)
multi_range_hslider = QRangeSlider(Horizontal)
multi_range_hslider.setValue((11, 33, 66, 88))
multi_range_hslider.setTickPosition(QtW.QSlider.TicksAbove)
multi_range_hslider.setTickPosition(QtW.QSlider.TickPosition.TicksAbove)
styled_reg_hslider = QtW.QSlider(QtCore.Qt.Horizontal)
styled_reg_hslider = QtW.QSlider(Horizontal)
styled_reg_hslider.setValue(50)
styled_reg_hslider.setStyleSheet(QSS)
styled_range_hslider = QRangeSlider(QtCore.Qt.Horizontal)
styled_range_hslider = QRangeSlider(Horizontal)
styled_range_hslider.setValue((20, 80))
styled_range_hslider.setStyleSheet(QSS)
reg_vslider = QtW.QSlider(QtCore.Qt.Vertical)
reg_vslider = QtW.QSlider(QtCore.Qt.Orientation.Vertical)
reg_vslider.setValue(50)
range_vslider = QRangeSlider(QtCore.Qt.Vertical)
range_vslider = QRangeSlider(QtCore.Qt.Orientation.Vertical)
range_vslider.setValue((22, 77))
tick_vslider = QtW.QSlider(QtCore.Qt.Vertical)
tick_vslider = QtW.QSlider(QtCore.Qt.Orientation.Vertical)
tick_vslider.setValue(55)
tick_vslider.setTickPosition(QtW.QSlider.TicksRight)
range_tick_vslider = QRangeSlider(QtCore.Qt.Vertical)
range_tick_vslider = QRangeSlider(QtCore.Qt.Orientation.Vertical)
range_tick_vslider.setValue((22, 77))
range_tick_vslider.setTickPosition(QtW.QSlider.TicksLeft)
@@ -102,7 +110,6 @@ class DemoWidget(QtW.QWidget):
if __name__ == "__main__":
import sys
from pathlib import Path

14
examples/double_slider.py Normal file
View File

@@ -0,0 +1,14 @@
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication
from superqt import QDoubleSlider
app = QApplication([])
slider = QDoubleSlider(Qt.Orientation.Horizontal)
slider.setRange(0, 1)
slider.setValue(0.5)
slider.resize(500, 50)
slider.show()
app.exec_()

13
examples/eliding_label.py Normal file
View File

@@ -0,0 +1,13 @@
from qtpy.QtWidgets import QApplication
from superqt import QElidingLabel
app = QApplication([])
widget = QElidingLabel(
"a skj skjfskfj sdlf sdfl sdlfk jsdf sdlkf jdsf dslfksdl sdlfk sdf sdl "
"fjsdlf kjsdlfk laskdfsal as lsdfjdsl kfjdslf asfd dslkjfldskf sdlkfj"
)
widget.setWordWrap(True)
widget.show()
app.exec_()

View File

@@ -1,14 +1,15 @@
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from superqt import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
from superqt.qtcompat.QtCore import Qt
from superqt.qtcompat.QtWidgets import QApplication, QVBoxLayout, QWidget
app = QApplication([])
w = QWidget()
sld1 = QDoubleSlider(Qt.Horizontal)
sld2 = QDoubleRangeSlider(Qt.Horizontal)
rs = QRangeSlider(Qt.Horizontal)
sld1 = QDoubleSlider(Qt.Orientation.Horizontal)
sld2 = QDoubleRangeSlider(Qt.Orientation.Horizontal)
rs = QRangeSlider(Qt.Orientation.Horizontal)
sld1.valueChanged.connect(lambda e: print("doubslider valuechanged", e))

21
examples/fonticon1.py Normal file
View File

@@ -0,0 +1,21 @@
try:
from fonticon_fa5 import FA5S
except ImportError as e:
raise type(e)(
"This example requires the fontawesome fontpack:\n\n"
"pip install git+https://github.com/tlambert03/fonticon-fontawesome5.git"
)
from qtpy.QtCore import QSize
from qtpy.QtWidgets import QApplication, QPushButton
from superqt.fonticon import icon, pulse
app = QApplication([])
btn2 = QPushButton()
btn2.setIcon(icon(FA5S.spinner, animation=pulse(btn2)))
btn2.setIconSize(QSize(225, 225))
btn2.show()
app.exec()

21
examples/fonticon2.py Normal file
View File

@@ -0,0 +1,21 @@
try:
from fonticon_fa5 import FA5S
except ImportError as e:
raise type(e)(
"This example requires the fontawesome fontpack:\n\n"
"pip install git+https://github.com/tlambert03/fonticon-fontawesome5.git"
)
from qtpy.QtWidgets import QApplication, QPushButton
from superqt.fonticon import setTextIcon
app = QApplication([])
btn4 = QPushButton()
btn4.resize(275, 275)
setTextIcon(btn4, FA5S.hamburger)
btn4.show()
app.exec()

41
examples/fonticon3.py Normal file
View File

@@ -0,0 +1,41 @@
try:
from fonticon_fa5 import FA5S
except ImportError as e:
raise type(e)(
"This example requires the fontawesome fontpack:\n\n"
"pip install git+https://github.com/tlambert03/fonticon-fontawesome5.git"
)
from qtpy.QtCore import QSize
from qtpy.QtWidgets import QApplication, QPushButton
from superqt.fonticon import IconOpts, icon, pulse, spin
app = QApplication([])
btn = QPushButton()
btn.setIcon(
icon(
FA5S.smile,
color="blue",
states={
"active": IconOpts(
glyph_key=FA5S.spinner,
color="red",
scale_factor=0.5,
animation=pulse(btn),
),
"disabled": {"color": "green", "scale_factor": 0.8, "animation": spin(btn)},
},
)
)
btn.setIconSize(QSize(256, 256))
btn.show()
@btn.clicked.connect
def toggle_state():
btn.setChecked(not btn.isChecked())
app.exec()

View File

@@ -1,10 +1,11 @@
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication
from superqt import QDoubleSlider
from superqt.qtcompat.QtCore import Qt
from superqt.qtcompat.QtWidgets import QApplication
app = QApplication([])
sld = QDoubleSlider(Qt.Horizontal)
sld = QDoubleSlider(Qt.Orientation.Horizontal)
sld.setRange(0, 1)
sld.setValue(0.5)
sld.show()

377
examples/icon_explorer.py Normal file
View File

@@ -0,0 +1,377 @@
from qtpy import QtCore, QtGui, QtWidgets
from qtpy.QtCore import Qt
from superqt.fonticon._plugins import loaded
P = loaded(load_all=True)
if not P:
print("you have no font packs loaded!")
class GlyphDelegate(QtWidgets.QItemDelegate):
def createEditor(self, parent, option, index):
if index.column() < 2:
edit = QtWidgets.QLineEdit(parent)
edit.editingFinished.connect(self.emitCommitData)
return edit
comboBox = QtWidgets.QComboBox(parent)
if index.column() == 2:
comboBox.addItem("Normal")
comboBox.addItem("Active")
comboBox.addItem("Disabled")
comboBox.addItem("Selected")
elif index.column() == 3:
comboBox.addItem("Off")
comboBox.addItem("On")
comboBox.activated.connect(self.emitCommitData)
return comboBox
def setEditorData(self, editor, index):
if index.column() < 2:
editor.setText(index.model().data(index))
return
comboBox = editor
if comboBox:
pos = comboBox.findText(
index.model().data(index), Qt.MatchFlag.MatchExactly
)
comboBox.setCurrentIndex(pos)
def setModelData(self, editor, model, index):
if editor:
text = editor.text() if index.column() < 2 else editor.currentText()
model.setData(index, text)
def emitCommitData(self):
self.commitData.emit(self.sender())
class IconPreviewArea(QtWidgets.QWidget):
def __init__(self, parent=None):
super().__init__(parent)
mainLayout = QtWidgets.QGridLayout()
self.setLayout(mainLayout)
self.icon = QtGui.QIcon()
self.size = QtCore.QSize()
self.stateLabels = []
self.modeLabels = []
self.pixmapLabels = []
self.stateLabels.append(self.createHeaderLabel("Off"))
self.stateLabels.append(self.createHeaderLabel("On"))
self.modeLabels.append(self.createHeaderLabel("Normal"))
self.modeLabels.append(self.createHeaderLabel("Active"))
self.modeLabels.append(self.createHeaderLabel("Disabled"))
self.modeLabels.append(self.createHeaderLabel("Selected"))
for j, label in enumerate(self.stateLabels):
mainLayout.addWidget(label, j + 1, 0)
for i, label in enumerate(self.modeLabels):
mainLayout.addWidget(label, 0, i + 1)
self.pixmapLabels.append([])
for j in range(len(self.stateLabels)):
self.pixmapLabels[i].append(self.createPixmapLabel())
mainLayout.addWidget(self.pixmapLabels[i][j], j + 1, i + 1)
def setIcon(self, icon):
self.icon = icon
self.updatePixmapLabels()
def setSize(self, size):
if size != self.size:
self.size = size
self.updatePixmapLabels()
def createHeaderLabel(self, text):
label = QtWidgets.QLabel("<b>%s</b>" % text)
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
return label
def createPixmapLabel(self):
label = QtWidgets.QLabel()
label.setEnabled(False)
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
label.setFrameShape(QtWidgets.QFrame.Box)
label.setSizePolicy(
QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding
)
label.setBackgroundRole(QtGui.QPalette.Base)
label.setAutoFillBackground(True)
label.setMinimumSize(132, 132)
return label
def updatePixmapLabels(self):
for i in range(len(self.modeLabels)):
if i == 0:
mode = QtGui.QIcon.Mode.Normal
elif i == 1:
mode = QtGui.QIcon.Mode.Active
elif i == 2:
mode = QtGui.QIcon.Mode.Disabled
else:
mode = QtGui.QIcon.Mode.Selected
for j in range(len(self.stateLabels)):
state = {True: QtGui.QIcon.State.Off, False: QtGui.QIcon.State.On}[
j == 0
]
pixmap = self.icon.pixmap(self.size, mode, state)
self.pixmapLabels[i][j].setPixmap(pixmap)
self.pixmapLabels[i][j].setEnabled(not pixmap.isNull())
class MainWindow(QtWidgets.QMainWindow):
def __init__(self, parent=None):
super().__init__(parent)
self.centralWidget = QtWidgets.QWidget()
self.setCentralWidget(self.centralWidget)
self.createPreviewGroupBox()
self.createGlyphBox()
self.createIconSizeGroupBox()
mainLayout = QtWidgets.QGridLayout()
mainLayout.addWidget(self.previewGroupBox, 0, 0, 1, 2)
mainLayout.addWidget(self.glyphGroupBox, 1, 0)
mainLayout.addWidget(self.iconSizeGroupBox, 1, 1)
self.centralWidget.setLayout(mainLayout)
self.setWindowTitle("Icons")
self.otherRadioButton.click()
self.resize(self.minimumSizeHint())
def changeSize(self):
if self.otherRadioButton.isChecked():
extent = self.otherSpinBox.value()
else:
if self.smallRadioButton.isChecked():
metric = QtWidgets.QStyle.PixelMetric.PM_SmallIconSize
elif self.largeRadioButton.isChecked():
metric = QtWidgets.QStyle.PixelMetric.PM_LargeIconSize
elif self.toolBarRadioButton.isChecked():
metric = QtWidgets.QStyle.PixelMetric.PM_ToolBarIconSize
elif self.listViewRadioButton.isChecked():
metric = QtWidgets.QStyle.PixelMetric.PM_ListViewIconSize
elif self.iconViewRadioButton.isChecked():
metric = QtWidgets.QStyle.PixelMetric.PM_IconViewIconSize
else:
metric = QtWidgets.QStyle.PixelMetric.PM_TabBarIconSize
extent = QtWidgets.QApplication.style().pixelMetric(metric)
self.previewArea.setSize(QtCore.QSize(extent, extent))
self.otherSpinBox.setEnabled(self.otherRadioButton.isChecked())
def changeIcon(self):
from superqt import fonticon
icon = None
for row in range(self.glyphTable.rowCount()):
item0 = self.glyphTable.item(row, 0)
item1 = self.glyphTable.item(row, 1)
item2 = self.glyphTable.item(row, 2)
item3 = self.glyphTable.item(row, 3)
if item0.checkState() != Qt.CheckState.Checked:
continue
key = item0.text()
if not key:
continue
if item2.text() == "Normal":
mode = QtGui.QIcon.Mode.Normal
elif item2.text() == "Active":
mode = QtGui.QIcon.Mode.Active
elif item2.text() == "Disabled":
mode = QtGui.QIcon.Mode.Disabled
else:
mode = QtGui.QIcon.Mode.Selected
color = item1.text() or None
state = (
QtGui.QIcon.State.On if item3.text() == "On" else QtGui.QIcon.State.Off
)
try:
if icon is None:
icon = fonticon.icon(key, color=color)
else:
icon.addState(state, mode, glyph_key=key, color=color)
except Exception as e:
print(e)
continue
if icon:
self.previewArea.setIcon(icon)
def createPreviewGroupBox(self):
self.previewGroupBox = QtWidgets.QGroupBox("Preview")
self.previewArea = IconPreviewArea()
layout = QtWidgets.QVBoxLayout()
layout.addWidget(self.previewArea)
self.previewGroupBox.setLayout(layout)
def createGlyphBox(self):
self.glyphGroupBox = QtWidgets.QGroupBox("Glyphs")
self.glyphGroupBox.setMinimumSize(480, 200)
self.glyphTable = QtWidgets.QTableWidget()
self.glyphTable.setSelectionMode(QtWidgets.QAbstractItemView.NoSelection)
self.glyphTable.setItemDelegate(GlyphDelegate(self))
self.glyphTable.horizontalHeader().setDefaultSectionSize(100)
self.glyphTable.setColumnCount(4)
self.glyphTable.setHorizontalHeaderLabels(("Glyph", "Color", "Mode", "State"))
self.glyphTable.horizontalHeader().setSectionResizeMode(
0, QtWidgets.QHeaderView.Stretch
)
self.glyphTable.horizontalHeader().setSectionResizeMode(
1, QtWidgets.QHeaderView.Fixed
)
self.glyphTable.horizontalHeader().setSectionResizeMode(
2, QtWidgets.QHeaderView.Fixed
)
self.glyphTable.horizontalHeader().setSectionResizeMode(
3, QtWidgets.QHeaderView.Fixed
)
self.glyphTable.verticalHeader().hide()
self.glyphTable.itemChanged.connect(self.changeIcon)
layout = QtWidgets.QVBoxLayout()
layout.addWidget(self.glyphTable)
self.glyphGroupBox.setLayout(layout)
self.changeIcon()
p0 = list(P)[-1]
key = f"{p0}.{list(P[p0])[1]}"
for _ in range(4):
row = self.glyphTable.rowCount()
self.glyphTable.setRowCount(row + 1)
item0 = QtWidgets.QTableWidgetItem()
item1 = QtWidgets.QTableWidgetItem()
if _ == 0:
item0.setText(key)
# item0.setFlags(item0.flags() & ~Qt.ItemFlag.ItemIsEditable)
item2 = QtWidgets.QTableWidgetItem("Normal")
item3 = QtWidgets.QTableWidgetItem("Off")
self.glyphTable.setItem(row, 0, item0)
self.glyphTable.setItem(row, 1, item1)
self.glyphTable.setItem(row, 2, item2)
self.glyphTable.setItem(row, 3, item3)
self.glyphTable.openPersistentEditor(item2)
self.glyphTable.openPersistentEditor(item3)
item0.setCheckState(Qt.CheckState.Checked)
def createIconSizeGroupBox(self):
self.iconSizeGroupBox = QtWidgets.QGroupBox("Icon Size")
self.smallRadioButton = QtWidgets.QRadioButton()
self.largeRadioButton = QtWidgets.QRadioButton()
self.toolBarRadioButton = QtWidgets.QRadioButton()
self.listViewRadioButton = QtWidgets.QRadioButton()
self.iconViewRadioButton = QtWidgets.QRadioButton()
self.tabBarRadioButton = QtWidgets.QRadioButton()
self.otherRadioButton = QtWidgets.QRadioButton("Other:")
self.otherSpinBox = QtWidgets.QSpinBox()
self.otherSpinBox.setRange(8, 128)
self.otherSpinBox.setValue(64)
self.smallRadioButton.toggled.connect(self.changeSize)
self.largeRadioButton.toggled.connect(self.changeSize)
self.toolBarRadioButton.toggled.connect(self.changeSize)
self.listViewRadioButton.toggled.connect(self.changeSize)
self.iconViewRadioButton.toggled.connect(self.changeSize)
self.tabBarRadioButton.toggled.connect(self.changeSize)
self.otherRadioButton.toggled.connect(self.changeSize)
self.otherSpinBox.valueChanged.connect(self.changeSize)
otherSizeLayout = QtWidgets.QHBoxLayout()
otherSizeLayout.addWidget(self.otherRadioButton)
otherSizeLayout.addWidget(self.otherSpinBox)
otherSizeLayout.addStretch()
layout = QtWidgets.QGridLayout()
layout.addWidget(self.smallRadioButton, 0, 0)
layout.addWidget(self.largeRadioButton, 1, 0)
layout.addWidget(self.toolBarRadioButton, 2, 0)
layout.addWidget(self.listViewRadioButton, 0, 1)
layout.addWidget(self.iconViewRadioButton, 1, 1)
layout.addWidget(self.tabBarRadioButton, 2, 1)
layout.addLayout(otherSizeLayout, 3, 0, 1, 2)
layout.setRowStretch(4, 1)
self.iconSizeGroupBox.setLayout(layout)
self.changeStyle()
def changeStyle(self, style=None):
style = style or QtWidgets.QApplication.style().objectName()
style = QtWidgets.QStyleFactory.create(style)
if not style:
return
QtWidgets.QApplication.setStyle(style)
self.setButtonText(
self.smallRadioButton,
"Small (%d x %d)",
style,
QtWidgets.QStyle.PixelMetric.PM_SmallIconSize,
)
self.setButtonText(
self.largeRadioButton,
"Large (%d x %d)",
style,
QtWidgets.QStyle.PixelMetric.PM_LargeIconSize,
)
self.setButtonText(
self.toolBarRadioButton,
"Toolbars (%d x %d)",
style,
QtWidgets.QStyle.PixelMetric.PM_ToolBarIconSize,
)
self.setButtonText(
self.listViewRadioButton,
"List views (%d x %d)",
style,
QtWidgets.QStyle.PixelMetric.PM_ListViewIconSize,
)
self.setButtonText(
self.iconViewRadioButton,
"Icon views (%d x %d)",
style,
QtWidgets.QStyle.PixelMetric.PM_IconViewIconSize,
)
self.setButtonText(
self.tabBarRadioButton,
"Tab bars (%d x %d)",
style,
QtWidgets.QStyle.PixelMetric.PM_TabBarIconSize,
)
self.changeSize()
@staticmethod
def setButtonText(button, label, style, metric):
metric_value = style.pixelMetric(metric)
button.setText(label % (metric_value, metric_value))
if __name__ == "__main__":
import sys
app = QtWidgets.QApplication(sys.argv)
mainWin = MainWindow()
mainWin.show()
sys.exit(app.exec_())

View File

@@ -1,15 +1,16 @@
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication, QHBoxLayout, QVBoxLayout, QWidget
from superqt import (
QLabeledDoubleRangeSlider,
QLabeledDoubleSlider,
QLabeledRangeSlider,
QLabeledSlider,
)
from superqt.qtcompat.QtCore import Qt
from superqt.qtcompat.QtWidgets import QApplication, QHBoxLayout, QVBoxLayout, QWidget
app = QApplication([])
ORIENTATION = Qt.Horizontal
ORIENTATION = Qt.Orientation.Horizontal
w = QWidget()
qls = QLabeledSlider(ORIENTATION)
@@ -35,7 +36,9 @@ qldrs.setSingleStep(0.01)
qldrs.setValue((0.2, 0.7))
w.setLayout(QVBoxLayout() if ORIENTATION == Qt.Horizontal else QHBoxLayout())
w.setLayout(
QVBoxLayout() if ORIENTATION == Qt.Orientation.Horizontal else QHBoxLayout()
)
w.layout().addWidget(qls)
w.layout().addWidget(qlds)
w.layout().addWidget(qlrs)

View File

@@ -1,5 +1,6 @@
from qtpy.QtWidgets import QApplication
from superqt import QRangeSlider
from superqt.qtcompat.QtWidgets import QApplication
app = QApplication([])

17
examples/qcollapsible.py Normal file
View File

@@ -0,0 +1,17 @@
"""Example for QCollapsible."""
from qtpy.QtWidgets import QApplication, QLabel, QPushButton
from superqt import QCollapsible
app = QApplication([])
collapsible = QCollapsible("Advanced analysis")
collapsible.setCollapsedIcon("+")
collapsible.setExpandedIcon("-")
collapsible.addWidget(QLabel("This is the inside of the collapsible frame"))
for i in range(10):
collapsible.addWidget(QPushButton(f"Content button {i + 1}"))
collapsible.expand(animate=False)
collapsible.show()
app.exec_()

9
examples/quantity.py Normal file
View File

@@ -0,0 +1,9 @@
from qtpy.QtWidgets import QApplication
from superqt import QQuantity
app = QApplication([])
w = QQuantity("1m")
w.show()
app.exec()

View File

@@ -0,0 +1,11 @@
from qtpy.QtWidgets import QApplication
from superqt import QSearchableComboBox
app = QApplication([])
slider = QSearchableComboBox()
slider.addItems(["foo", "bar", "baz", "foobar", "foobaz", "barbaz"])
slider.show()
app.exec_()

View File

@@ -0,0 +1,11 @@
from qtpy.QtWidgets import QApplication
from superqt import QSearchableListWidget
app = QApplication([])
slider = QSearchableListWidget()
slider.addItems(["foo", "bar", "baz", "foobar", "foobaz", "barbaz"])
slider.show()
app.exec_()

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

@@ -0,0 +1,29 @@
from qtpy.QtCore import Signal
from qtpy.QtWidgets import QApplication, QWidget
from superqt.utils import qthrottled
class Demo(QWidget):
positionChanged = Signal(int, int)
def __init__(self) -> None:
super().__init__()
self.setMouseTracking(True)
self.positionChanged.connect(self._show_location)
@qthrottled(timeout=400) # call this no more than once every 400ms
def _show_location(self, x, y):
print("Throttled event at", x, y)
def mouseMoveEvent(self, event):
print("real move event at", event.x(), event.y())
self.positionChanged.emit(event.x(), event.y())
if __name__ == "__main__":
app = QApplication([])
w = Demo()
w.resize(600, 600)
w.show()
app.exec_()

280
examples/throttler_demo.py Normal file
View File

@@ -0,0 +1,280 @@
"""Adapted for python from the KDToolBox.
https://github.com/KDAB/KDToolBox/tree/master/qt/KDSignalThrottler
MIT License
Copyright (C) 2019-2022 Klarälvdalens Datakonsult AB, a KDAB Group company,
info@kdab.com
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""
from typing import Deque
from qtpy.QtCore import QRect, QSize, Qt, QTimer, Signal
from qtpy.QtGui import QPainter, QPen
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
QComboBox,
QFormLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QPushButton,
QSizePolicy,
QSpinBox,
QVBoxLayout,
QWidget,
)
from superqt.utils._throttler import (
GenericSignalThrottler,
QSignalDebouncer,
QSignalThrottler,
)
class DrawSignalsWidget(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
self.setAttribute(Qt.WA_OpaquePaintEvent)
self._scrollTimer = QTimer(self)
self._scrollTimer.setInterval(10)
self._scrollTimer.timeout.connect(self._scroll)
self._scrollTimer.start()
self._signalActivations: Deque[int] = Deque()
self._throttledSignalActivations: Deque[int] = Deque()
def sizeHint(self):
return QSize(400, 200)
def addSignalActivation(self):
self._signalActivations.appendleft(0)
def addThrottledSignalActivation(self):
self._throttledSignalActivations.appendleft(0)
def _scroll(self):
cutoff = self.width()
self.scrollAndCut(self._signalActivations, cutoff)
self.scrollAndCut(self._throttledSignalActivations, cutoff)
self.update()
def scrollAndCut(self, v: Deque[int], cutoff: int):
L = len(v)
for p in range(L):
v[p] += 1
if v[p] > cutoff:
break
# TODO: fix this... delete old ones
def paintEvent(self, event):
p = QPainter(self)
p.fillRect(self.rect(), Qt.white)
h = self.height()
h2 = h // 2
w = self.width()
self._drawSignals(p, self._signalActivations, Qt.red, 0, h2)
self._drawSignals(p, self._throttledSignalActivations, Qt.blue, h2, h)
p.drawText(
QRect(0, 0, w, h2),
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
"Source signal",
)
p.drawText(
QRect(0, h2, w, h2),
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
"Throttled signal",
)
p.save()
pen = QPen()
pen.setWidthF(2.0)
p.drawLine(0, h2, w, h2)
p.restore()
def _drawSignals(self, p: QPainter, v: Deque[int], color, yStart, yEnd):
p.save()
pen = QPen()
pen.setWidthF(2.0)
pen.setColor(color)
p.setPen(pen)
for i in v:
p.drawLine(i, yStart, i, yEnd)
p.restore()
class DemoWidget(QWidget):
signalToBeThrottled = Signal()
_throttler: GenericSignalThrottler
def __init__(self, parent=None) -> None:
super().__init__(parent)
self._createUi()
self._throttler = None
self._throttlerKindComboBox.currentIndexChanged.connect(self._createThrottler)
self._createThrottler()
self._throttlerTimeoutSpinBox.valueChanged.connect(self.setThrottlerTimeout)
self.setThrottlerTimeout()
self._mainButton.clicked.connect(self.signalToBeThrottled)
self._autoTriggerTimer = QTimer(self)
self._autoTriggerTimer.setTimerType(Qt.TimerType.PreciseTimer)
self._autoTriggerCheckBox.clicked.connect(self._startOrStopAutoTriggerTimer)
self._startOrStopAutoTriggerTimer()
self._autoTriggerIntervalSpinBox.valueChanged.connect(
self._setAutoTriggerTimeout
)
self._setAutoTriggerTimeout()
self._autoTriggerTimer.timeout.connect(self.signalToBeThrottled)
self.signalToBeThrottled.connect(self._drawSignalsWidget.addSignalActivation)
def _createThrottler(self) -> None:
if self._throttler is not None:
self._throttler.deleteLater()
del self._throttler
if self._throttlerKindComboBox.currentIndex() < 2:
cls = QSignalThrottler
else:
cls = QSignalDebouncer
if self._throttlerKindComboBox.currentIndex() % 2:
policy = QSignalThrottler.EmissionPolicy.Leading
else:
policy = QSignalThrottler.EmissionPolicy.Trailing
self._throttler: GenericSignalThrottler = cls(policy, self)
self._throttler.setTimerType(Qt.TimerType.PreciseTimer)
self.signalToBeThrottled.connect(self._throttler.throttle)
self._throttler.triggered.connect(
self._drawSignalsWidget.addThrottledSignalActivation
)
self.setThrottlerTimeout()
def setThrottlerTimeout(self):
self._throttler.setTimeout(self._throttlerTimeoutSpinBox.value())
def _startOrStopAutoTriggerTimer(self):
shouldStart = self._autoTriggerCheckBox.isChecked()
if shouldStart:
self._autoTriggerTimer.start()
else:
self._autoTriggerTimer.stop()
self._autoTriggerIntervalSpinBox.setEnabled(shouldStart)
self._autoTriggerLabel.setEnabled(shouldStart)
def _setAutoTriggerTimeout(self):
timeout = self._autoTriggerIntervalSpinBox.value()
self._autoTriggerTimer.setInterval(timeout)
def _createUi(self):
helpLabel = QLabel(self)
helpLabel.setWordWrap(True)
helpLabel.setText(
"<h2>SignalThrottler example</h2>"
"<p>This example demonstrates the differences between "
"the different kinds of signal throttlers and debouncers."
)
throttlerKindGroupBox = QGroupBox("Throttler configuration", self)
self._throttlerKindComboBox = QComboBox(throttlerKindGroupBox)
self._throttlerKindComboBox.addItems(
(
"Throttler, trailing",
"Throttler, leading",
"Debouncer, trailing",
"Debouncer, leading",
)
)
self._throttlerTimeoutSpinBox = QSpinBox(throttlerKindGroupBox)
self._throttlerTimeoutSpinBox.setRange(1, 5000)
self._throttlerTimeoutSpinBox.setValue(500)
self._throttlerTimeoutSpinBox.setSuffix(" ms")
layout = QFormLayout(throttlerKindGroupBox)
layout.addRow("Kind of throttler:", self._throttlerKindComboBox)
layout.addRow("Timeout:", self._throttlerTimeoutSpinBox)
throttlerKindGroupBox.setLayout(layout)
buttonGroupBox = QGroupBox("Throttler activation")
self._mainButton = QPushButton(("Press me!"), buttonGroupBox)
self._autoTriggerCheckBox = QCheckBox("Trigger automatically")
autoTriggerLayout = QHBoxLayout()
self._autoTriggerLabel = QLabel("Interval", buttonGroupBox)
self._autoTriggerIntervalSpinBox = QSpinBox(buttonGroupBox)
self._autoTriggerIntervalSpinBox.setRange(1, 5000)
self._autoTriggerIntervalSpinBox.setValue(100)
self._autoTriggerIntervalSpinBox.setSuffix(" ms")
autoTriggerLayout.setContentsMargins(0, 0, 0, 0)
autoTriggerLayout.addWidget(self._autoTriggerLabel)
autoTriggerLayout.addWidget(self._autoTriggerIntervalSpinBox)
layout = QVBoxLayout(buttonGroupBox)
layout.addWidget(self._mainButton)
layout.addWidget(self._autoTriggerCheckBox)
layout.addLayout(autoTriggerLayout)
buttonGroupBox.setLayout(layout)
resultGroupBox = QGroupBox("Result")
self._drawSignalsWidget = DrawSignalsWidget(resultGroupBox)
layout = QVBoxLayout(resultGroupBox)
layout.addWidget(self._drawSignalsWidget)
resultGroupBox.setLayout(layout)
layout = QVBoxLayout(self)
layout.addWidget(helpLabel)
layout.addWidget(throttlerKindGroupBox)
layout.addWidget(buttonGroupBox)
layout.addWidget(resultGroupBox)
self.setLayout(layout)
if __name__ == "__main__":
app = QApplication([__name__])
w = DemoWidget()
w.resize(600, 600)
w.show()
app.exec_()

59
mkdocs.yml Normal file
View File

@@ -0,0 +1,59 @@
site_name: superqt
site_url: https://github.com/pyapp-kit/superqt
site_description: >-
missing widgets and components for PyQt/PySide
# Repository
repo_name: pyapp-kit/superqt
repo_url: https://github.com/pyapp-kit/superqt
# Copyright
copyright: Copyright &copy; 2021 - 2022 Talley Lambert
extra_css:
- stylesheets/extra.css
watch:
- src
theme:
name: material
features:
- navigation.instant
- navigation.indexes
- navigation.expand
# - navigation.tracking
# - navigation.tabs
- search.highlight
- search.suggest
- content.code.copy
markdown_extensions:
- admonition
- pymdownx.details
- pymdownx.superfences
- tables
- attr_list
- md_in_html
- pymdownx.emoji:
emoji_index: !!python/name:materialx.emoji.twemoji
emoji_generator: !!python/name:materialx.emoji.to_svg
- toc:
permalink: "#"
plugins:
- search
- autorefs
- mkdocstrings
- macros:
module_name: docs/_macros
- mkdocstrings:
handlers:
python:
import:
- https://docs.python.org/3/objects.inv
options:
show_source: false
docstring_style: numpy
show_root_toc_entry: True
show_root_heading: True

View File

@@ -1,3 +1,183 @@
# pyproject.toml
# https://peps.python.org/pep-0517/
[build-system]
requires = ["setuptools>=45", "wheel", "setuptools_scm[toml]>=6.0"]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"
# https://peps.python.org/pep-0621/
[project]
name = "superqt"
description = "Missing widgets and components for PyQt/PySide"
readme = "README.md"
requires-python = ">=3.8"
license = { text = "BSD 3-Clause License" }
authors = [{ email = "talley.lambert@gmail.com", name = "Talley Lambert" }]
keywords = [
"qt",
"pyqt",
"pyside",
"widgets",
"range slider",
"components",
"gui",
]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: X11 Applications :: Qt",
"Intended Audience :: Developers",
"License :: OSI Approved :: BSD License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Topic :: Desktop Environment",
"Topic :: Software Development :: User Interfaces",
"Topic :: Software Development :: Widget Sets",
]
dynamic = ["version"]
dependencies = [
"packaging",
"pygments>=2.4.0",
"qtpy>=1.1.0",
"typing-extensions >=3.7.4.3,!=3.10.0.0",
]
# extras
# https://peps.python.org/pep-0621/#dependencies-optional-dependencies
[project.optional-dependencies]
test = ["pint", "pytest", "pytest-cov", "pytest-qt"]
dev = [
"black",
"ipython",
"ruff",
"mypy",
"pdbpp",
"pre-commit",
"pydocstyle",
"rich",
"types-Pygments",
]
docs = ["mkdocs-macros-plugin", "mkdocs-material", "mkdocstrings[python]"]
quantity = ["pint"]
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
pyside6 = ["pyside6 !=6.5.0,!=6.5.1"]
pyqt5 = ["pyqt5"]
pyqt6 = ["pyqt6"]
font-fa5 = ["fonticon-fontawesome5"]
font-fa6 = ["fonticon-fontawesome6"]
font-mi6 = ["fonticon-materialdesignicons6"]
font-mi7 = ["fonticon-materialdesignicons7"]
[project.urls]
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"]
# https://pycqa.github.io/isort/docs/configuration/options.html
[tool.isort]
profile = "black"
src_paths = ["src/superqt", "tests"]
# https://github.com/charliermarsh/ruff
[tool.ruff]
line-length = 88
target-version = "py38"
src = ["src", "tests"]
select = [
"E", # style errors
"F", # flakes
"W", # flakes
"D", # pydocstyle
"I", # isort
"UP", # pyupgrade
"S", # bandit
"C4", # flake8-comprehensions
"B", # flake8-bugbear
"A001", # flake8-builtins
"RUF", # ruff-specific rules
"TID", # tidy imports
]
ignore = [
"D100", # Missing docstring in public module
"D101", # Missing docstring in public class
"D104", # Missing docstring in public package
"D107", # Missing docstring in __init__
"D203", # 1 blank line required before class docstring
"D212", # Multi-line docstring summary should start at the first line
"D213", # Multi-line docstring summary should start at the second line
"D401", # First line should be in imperative mood
"D413", # Missing blank line after last section
"D416", # Section name should end with a colon
]
[tool.ruff.per-file-ignores]
"tests/*.py" = ["D", "S101"]
"examples/demo_widget.py" = ["E501"]
"examples/*.py" = ["B", "D"]
# https://docs.pytest.org/en/6.2.x/customize.html
[tool.pytest.ini_options]
minversion = "6.0"
testpaths = ["tests"]
filterwarnings = [
"error",
"ignore:QPixmapCache.find:DeprecationWarning:",
"ignore:SelectableGroups dict interface:DeprecationWarning",
"ignore:The distutils package is deprecated:DeprecationWarning",
]
# https://mypy.readthedocs.io/en/stable/config_file.html
[tool.mypy]
files = "src/**/*.py"
strict = true
disallow_untyped_defs = false
disallow_untyped_calls = false
disallow_any_generics = false
disallow_subclassing_any = false
show_error_codes = true
pretty = true
exclude = ['tests/**/*']
[[tool.mypy.overrides]]
module = ["superqt.qtcompat.*"]
ignore_missing_imports = true
warn_unused_ignores = false
allow_redefinition = true
# https://coverage.readthedocs.io/en/6.4/config.html
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"@overload",
"except ImportError",
]
# https://github.com/mgedmin/check-manifest#configuration
[tool.check-manifest]
ignore = [
".github_changelog_generator",
".pre-commit-config.yaml",
"tests/**/*",
"src/superqt/_version.py",
"mkdocs.yml",
"docs/**/*",
"examples/**/*",
"CHANGELOG.md",
"CONTRIBUTING.md",
"codecov.yml",
".ruff_cache/**/*",
]

View File

@@ -1,83 +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 :: 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:
python_requires = >=3.7
setup_requires =
setuptools_scm
zip_safe = False
[options.extras_require]
dev =
ipython
isort
jedi<0.18.0
mypy
pre-commit
pyside2
pytest
pytest-cov
pytest-qt
tox
tox-conda
pyqt5 =
pyqt5
pyqt6 =
pyqt6
pyside2 =
pyside2
pyside6 =
pyside6
testing =
pytest
pytest-cov
pytest-qt
tox
tox-conda
[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]
addopts = -W error

View File

@@ -1,6 +0,0 @@
from setuptools import setup
setup(
use_scm_version={"write_to": "superqt/_version.py"},
setup_requires=["setuptools_scm"],
)

57
src/superqt/__init__.py Normal file
View File

@@ -0,0 +1,57 @@
"""superqt is a collection of Qt components for python."""
from importlib.metadata import PackageNotFoundError, version
from typing import TYPE_CHECKING, Any
try:
__version__ = version("superqt")
except PackageNotFoundError:
__version__ = "unknown"
if TYPE_CHECKING:
from .spinbox._quantity import QQuantity
from .collapsible import QCollapsible
from .combobox import QEnumComboBox, QSearchableComboBox
from .elidable import QElidingLabel, QElidingLineEdit
from .selection import QSearchableListWidget, QSearchableTreeWidget
from .sliders import (
QDoubleRangeSlider,
QDoubleSlider,
QLabeledDoubleRangeSlider,
QLabeledDoubleSlider,
QLabeledRangeSlider,
QLabeledSlider,
QRangeSlider,
)
from .spinbox import QLargeIntSpinBox
from .utils import QMessageHandler, ensure_main_thread, ensure_object_thread
__all__ = [
"ensure_main_thread",
"ensure_object_thread",
"QDoubleRangeSlider",
"QCollapsible",
"QDoubleSlider",
"QElidingLabel",
"QElidingLineEdit",
"QEnumComboBox",
"QLabeledDoubleRangeSlider",
"QLabeledDoubleSlider",
"QLabeledRangeSlider",
"QLabeledSlider",
"QLargeIntSpinBox",
"QMessageHandler",
"QQuantity",
"QRangeSlider",
"QSearchableComboBox",
"QSearchableListWidget",
"QSearchableTreeWidget",
]
def __getattr__(name: str) -> Any:
if name == "QQuantity":
from .spinbox._quantity import QQuantity
return QQuantity
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View File

@@ -0,0 +1,3 @@
from ._collapsible import QCollapsible
__all__ = ["QCollapsible"]

View File

@@ -0,0 +1,212 @@
"""A collapsible widget to hide and unhide child widgets."""
from typing import Optional, Union
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, QVBoxLayout, QWidget
class QCollapsible(QFrame):
"""A collapsible widget to hide and unhide child widgets.
A signal is emitted when the widget is expanded (True) or collapsed (False).
Based on https://stackoverflow.com/a/68141638
"""
toggled = Signal(bool)
def __init__(
self,
title: str = "",
parent: Optional[QWidget] = None,
expandedIcon: Optional[Union[QIcon, str]] = "",
collapsedIcon: Optional[Union[QIcon, str]] = "",
):
super().__init__(parent)
self._locked = False
self._is_animating = False
self._text = title
self._toggle_btn = QPushButton(title)
self._toggle_btn.setCheckable(True)
self.setCollapsedIcon(icon=collapsedIcon)
self.setExpandedIcon(icon=expandedIcon)
self._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)
# Create animators
self._animation = QPropertyAnimation(self)
self._animation.setPropertyName(b"maximumHeight")
self._animation.setStartValue(0)
self._animation.finished.connect(self._on_animation_done)
self.setDuration(300)
self.setEasingCurve(QEasingCurve.Type.InOutCubic)
# default content widget
_content = QWidget()
_content.setLayout(QVBoxLayout())
_content.setMaximumHeight(0)
_content.layout().setContentsMargins(QMargins(5, 0, 0, 0))
self.setContent(_content)
def setText(self, text: str) -> None:
"""Set the text of the toggle button."""
current = self._toggle_btn.text()
self._toggle_btn.setText(current + text)
def text(self) -> str:
"""Return the text of the toggle button."""
return self._toggle_btn.text()
def setContent(self, content: QWidget) -> None:
"""Replace central widget (the widget that gets expanded/collapsed)."""
self._content = content
self.layout().addWidget(self._content)
self._animation.setTargetObject(content)
def content(self) -> QWidget:
"""Return the current content widget."""
return self._content
def _convert_string_to_icon(self, symbol: str) -> QIcon:
"""Create a QIcon from a string."""
size = self._toggle_btn.font().pointSize()
pixmap = QPixmap(size, size)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
color = self._toggle_btn.palette().color(QPalette.ColorRole.WindowText)
painter.setPen(color)
painter.drawText(QRect(0, 0, size, size), Qt.AlignmentFlag.AlignCenter, symbol)
painter.end()
return QIcon(pixmap)
def expandedIcon(self) -> QIcon:
"""Returns the icon used when the widget is expanded."""
return self._expanded_icon
def setExpandedIcon(self, icon: Optional[Union[QIcon, str]] = 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: Optional[Union[QIcon, str]] = 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) -> None:
"""Set the easing curve for the collapse/expand animation."""
self._animation.setEasingCurve(easing)
def addWidget(self, widget: QWidget) -> None:
"""Add a widget to the central content widget's layout."""
widget.installEventFilter(self)
self._content.layout().addWidget(widget)
def removeWidget(self, widget: QWidget) -> None:
"""Remove widget from the central content widget's layout."""
self._content.layout().removeWidget(widget)
widget.removeEventFilter(self)
def expand(self, animate: bool = True) -> None:
"""Expand (show) the collapsible section."""
self._expand_collapse(QPropertyAnimation.Direction.Forward, animate)
def collapse(self, animate: bool = True) -> None:
"""Collapse (hide) the collapsible section."""
self._expand_collapse(QPropertyAnimation.Direction.Backward, animate)
def isExpanded(self) -> bool:
"""Return whether the collapsible section is visible."""
return self._toggle_btn.isChecked()
def setLocked(self, locked: bool = True) -> None:
"""Set whether collapse/expand is disabled."""
self._locked = locked
self._toggle_btn.setCheckable(not locked)
def locked(self) -> bool:
"""Return True if collapse/expand is disabled."""
return self._locked
def _expand_collapse(
self,
direction: QPropertyAnimation.Direction,
animate: bool = True,
emit: bool = True,
) -> None:
"""Set values for the widget based on whether it is expanding or collapsing.
An emit flag is included so that the toggle signal is only called once (it
was being emitted a few times via eventFilter when the widget was expanding
previously).
"""
if self._locked:
return
forward = direction == QPropertyAnimation.Direction.Forward
icon = self._expanded_icon if forward else self._collapsed_icon
self._toggle_btn.setIcon(icon)
self._toggle_btn.setChecked(forward)
_content_height = self._content.sizeHint().height() + 10
if animate:
self._animation.setDirection(direction)
self._animation.setEndValue(_content_height)
self._is_animating = True
self._animation.start()
else:
self._content.setMaximumHeight(_content_height if forward else 0)
if emit:
self.toggled.emit(direction == QPropertyAnimation.Direction.Forward)
def _toggle(self) -> None:
self.expand() if self.isExpanded() else self.collapse()
def eventFilter(self, a0: QObject, a1: QEvent) -> bool:
"""If a child widget resizes, we need to update our expanded height."""
if (
a1.type() == QEvent.Type.Resize
and self.isExpanded()
and not self._is_animating
):
self._expand_collapse(
QPropertyAnimation.Direction.Forward, animate=False, emit=False
)
return False
def _on_animation_done(self) -> None:
self._is_animating = False

View File

@@ -0,0 +1,4 @@
from ._enum_combobox import QEnumComboBox
from ._searchable_combo_box import QSearchableComboBox
__all__ = ("QEnumComboBox", "QSearchableComboBox")

View File

@@ -0,0 +1,115 @@
from enum import Enum, EnumMeta
from typing import Optional, TypeVar
from qtpy.QtCore import Signal
from qtpy.QtWidgets import QComboBox
EnumType = TypeVar("EnumType", bound=Enum)
NONE_STRING = "----"
def _get_name(enum_value: Enum):
"""Create human readable name if user does not implement `__str__`."""
if (
enum_value.__str__.__module__ != "enum"
and not enum_value.__str__.__module__.startswith("shibokensupport")
):
# check if function was overloaded
name = str(enum_value)
else:
name = enum_value.name.replace("_", " ")
return name
class QEnumComboBox(QComboBox):
"""ComboBox presenting options from a python Enum.
If the Enum class does not implement `__str__` then a human readable name
is created from the name of the enum member, replacing underscores with spaces.
"""
currentEnumChanged = Signal(object)
def __init__(
self, parent=None, enum_class: Optional[EnumMeta] = None, allow_none=False
):
super().__init__(parent)
self._enum_class = None
self._allow_none = False
if enum_class is not None:
self.setEnumClass(enum_class, allow_none)
self.currentIndexChanged.connect(self._emit_signal)
def setEnumClass(self, enum: Optional[EnumMeta], allow_none=False):
"""Set enum class from which members value should be selected."""
self.clear()
self._enum_class = enum
self._allow_none = allow_none and enum is not None
if allow_none:
super().addItem(NONE_STRING)
names = map(_get_name, self._enum_class.__members__.values())
_names = dict.fromkeys(names) # remove duplicates/aliases, keep order
super().addItems(list(_names))
def enumClass(self) -> Optional[EnumMeta]:
"""Return current Enum class."""
return self._enum_class
def isOptional(self) -> bool:
"""Return if current enum is with optional annotation."""
return self._allow_none
def clear(self):
self._enum_class = None
self._allow_none = False
super().clear()
def currentEnum(self) -> Optional[EnumType]:
"""Current value as Enum member."""
if self._enum_class is not None:
if self._allow_none:
if self.currentText() == NONE_STRING:
return None
else:
return list(self._enum_class.__members__.values())[
self.currentIndex() - 1
]
return list(self._enum_class.__members__.values())[self.currentIndex()]
return None
def setCurrentEnum(self, value: Optional[EnumType]) -> None:
"""Set value with Enum."""
if self._enum_class is None:
raise RuntimeError(
"Uninitialized enum class. Use `setEnumClass` before `setCurrentEnum`."
)
if value is None and self._allow_none:
self.setCurrentIndex(0)
return
if not isinstance(value, self._enum_class):
raise TypeError(
"setValue(self, Enum): argument 1 has unexpected type "
f"{type(value).__name__!r}"
)
self.setCurrentText(_get_name(value))
def _emit_signal(self):
if self._enum_class is not None:
self.currentEnumChanged.emit(self.currentEnum())
def insertItems(self, *_, **__):
raise RuntimeError("EnumComboBox does not allow to insert items")
def insertItem(self, *_, **__):
raise RuntimeError("EnumComboBox does not allow to insert item")
def addItems(self, *_, **__):
raise RuntimeError("EnumComboBox does not allow to add items")
def addItem(self, *_, **__):
raise RuntimeError("EnumComboBox does not allow to add item")
def setInsertPolicy(self, policy):
raise RuntimeError("EnumComboBox does not allow to insert item")

View File

@@ -0,0 +1,48 @@
from typing import Optional
from qtpy import QT_VERSION
from qtpy.QtCore import Qt, Signal
from qtpy.QtWidgets import QComboBox, QCompleter, QWidget
try:
is_qt_bellow_5_14 = tuple(int(x) for x in QT_VERSION.split(".")[:2]) < (5, 14)
except ValueError:
is_qt_bellow_5_14 = False
class QSearchableComboBox(QComboBox):
"""ComboCox with completer for fast search in multiple options."""
if is_qt_bellow_5_14:
textActivated = Signal(str) # pragma: no cover
def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent)
self.setEditable(True)
self.completer_object = QCompleter()
self.completer_object.setCaseSensitivity(Qt.CaseInsensitive)
self.completer_object.setCompletionMode(QCompleter.PopupCompletion)
self.completer_object.setFilterMode(Qt.MatchContains)
self.setCompleter(self.completer_object)
self.setInsertPolicy(QComboBox.NoInsert)
if is_qt_bellow_5_14: # pragma: no cover
self.currentIndexChanged.connect(self._text_activated)
def _text_activated(self): # pragma: no cover
self.textActivated.emit(self.currentText())
def addItem(self, *args):
super().addItem(*args)
self.completer_object.setModel(self.model())
def addItems(self, *args):
super().addItems(*args)
self.completer_object.setModel(self.model())
def insertItem(self, *args) -> None:
super().insertItem(*args)
self.completer_object.setModel(self.model())
def insertItems(self, *args) -> None:
super().insertItems(*args)
self.completer_object.setModel(self.model())

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

View File

@@ -0,0 +1,75 @@
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())

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

@@ -0,0 +1,225 @@
from __future__ import annotations
__all__ = [
"addFont",
"Animation",
"ENTRY_POINT",
"font",
"icon",
"IconFont",
"IconFontMeta",
"IconOpts",
"pulse",
"setTextIcon",
"spin",
]
from typing import TYPE_CHECKING
from ._animations import Animation, pulse, spin
from ._iconfont import IconFont, IconFontMeta
from ._plugins import FontIconManager as _FIM
from ._qfont_icon import DEFAULT_SCALING_FACTOR, IconOptionDict, IconOpts
from ._qfont_icon import QFontIconStore as _QFIS
if TYPE_CHECKING:
from qtpy.QtGui import QFont, QTransform
from qtpy.QtWidgets import QWidget
from ._qfont_icon import QFontIcon, ValidColor
ENTRY_POINT = _FIM.ENTRY_POINT
# FIXME: currently, an Animation requires a *pre-bound* QObject. which makes it very
# awkward to use animations when declaratively listing icons. It would be much better
# to have a way to find the widget later, to execute the animation... short of that, I
# think we should take animation off of `icon` here, and suggest that it be an
# an additional convenience method after the icon has been bound to a QObject.
def icon(
glyph_key: str,
scale_factor: float = DEFAULT_SCALING_FACTOR,
color: ValidColor | None = None,
opacity: float = 1,
animation: Animation | None = None,
transform: QTransform | None = None,
states: dict[str, IconOptionDict | IconOpts] | None = None,
) -> QFontIcon:
"""Create a QIcon for `glyph_key`, with a number of optional settings.
The `glyph_key` (e.g. 'fa5s.smile') represents a Font-family & style, and a glyph.
In most cases, the key should be provided by a plugin in the environment, like:
- [fonticon-fontawesome5](https://pypi.org/project/fonticon-fontawesome5/) ('fa5s' &
'fa5r' prefixes)
- [fonticon-materialdesignicons6](https://pypi.org/project/fonticon-materialdesignicons6/)
('mdi6' prefix)
...but fonts can also be added manually using [`addFont`][superqt.fonticon.addFont].
Parameters
----------
glyph_key : str
String encapsulating a font-family, style, and glyph. e.g. 'fa5s.smile'.
scale_factor : float, optional
Scale factor (fraction of widget height), When widget icon is painted on widget,
it will use `font.setPixelSize(round(wdg.height() * scale_factor))`.
by default 0.875.
color : ValidColor, optional
Color for the font, by default None. (e.g. The default `QColor`)
Valid color types include `QColor`, `int`, `str`, `Qt.GlobalColor`, `tuple` (of
integer: RGB[A]) (anything that can be passed to `QColor`).
opacity : float, optional
Opacity of icon, by default 1
animation : Animation, optional
Animation for the icon. A subclass of superqt.fonticon.Animation, that provides
a concrete `animate` method. (see "spin" and "pulse" for examples).
by default None.
transform : QTransform, optional
A `QTransform` to apply when painting the icon, by default None
states : dict, optional
Provide additional styling for the icon in different states. `states` must be
a mapping of string to dict, where:
- the key represents a `QIcon.State` ("on", "off"), a `QIcon.Mode` ("normal",
"active", "selected", "disabled"), or any combination of a state & mode
separated by an underscore (e.g. "off_active", "selected_on", etc...).
- the value is a dict with all of the same key/value meanings listed above as
parameters to this function (e.g. `glyph_key`, `color`,`scale_factor`,
`animation`, etc...)
Missing keys in the state dicts will be taken from the default options, provided
by the parameters above.
Returns
-------
QFontIcon
A subclass of QIcon. Can be used wherever QIcons are used, such as
`widget.setIcon()`
Examples
--------
simple example (using the string `'fa5s.smile'` assumes the `fonticon-fontawesome5`
plugin is installed)
>>> btn = QPushButton()
>>> btn.setIcon(icon('fa5s.smile'))
can also directly import from fonticon_fa5
>>> from fonticon_fa5 import FA5S
>>> btn.setIcon(icon(FA5S.smile))
with animation
>>> btn2 = QPushButton()
>>> btn2.setIcon(icon(FA5S.spinner, animation=pulse(btn2)))
complicated example
>>> btn = QPushButton()
>>> btn.setIcon(
... icon(
... FA5S.ambulance,
... color="blue",
... states={
... "active": {
... "glyph": FA5S.bath,
... "color": "red",
... "scale_factor": 0.5,
... "animation": pulse(btn),
... },
... "disabled": {
... "color": "green",
... "scale_factor": 0.8,
... "animation": spin(btn)
... },
... },
... )
... )
>>> btn.setIconSize(QSize(256, 256))
>>> btn.show()
"""
return _QFIS.instance().icon(
glyph_key,
scale_factor=scale_factor,
color=color,
opacity=opacity,
animation=animation,
transform=transform,
states=states or {},
)
def setTextIcon(widget: QWidget, glyph_key: str, size: float | None = None) -> None:
"""Set text on a widget to a specific font & glyph.
This is an alternative to setting a QIcon with a pixmap. It may be easier to
combine with dynamic stylesheets.
Parameters
----------
widget : QWidget
A widget supporting a `setText` method.
glyph_key : str
String encapsulating a font-family, style, and glyph. e.g. 'fa5s.smile'.
size : int, optional
Size for QFont. passed to `setPixelSize`, by default None
"""
return _QFIS.instance().setTextIcon(widget, glyph_key, size)
def font(font_prefix: str, size: int | None = None) -> QFont:
"""Create QFont for `font_prefix`.
Parameters
----------
font_prefix : str
Font_prefix, such as 'fa5s' or 'mdi6', representing a font-family and style.
size : int, optional
Size for QFont. passed to `setPixelSize`, by default None
Returns
-------
QFont
QFont instance that can be used to add fonticons to widgets.
"""
return _QFIS.instance().font(font_prefix, size)
def addFont(
filepath: str, prefix: str, charmap: dict[str, str] | None = None
) -> tuple[str, str] | None:
"""Add OTF/TTF file at `filepath` to the registry under `prefix`.
If you'd like to later use a fontkey in the form of `prefix.some-name`, then
`charmap` must be provided and provide a mapping for all of the glyph names
to their unicode numbers. If a charmap is not provided, glyphs must be directly
accessed with their unicode as something like `key.\uffff`.
!!! Note
in most cases, users will not need this. Instead, they should install a
font plugin, like:
- [fonticon-fontawesome5](https://pypi.org/project/fonticon-fontawesome5/)
- [fonticon-materialdesignicons6](https://pypi.org/project/fonticon-materialdesignicons6/)
Parameters
----------
filepath : str
Path to an OTF or TTF file containing the fonts
prefix : str
A prefix that will represent this font file when used for lookup. For example,
'fa5s' for 'Font-Awesome 5 Solid'.
charmap : Dict[str, str], optional
optional mapping for all of the glyph names to their unicode numbers.
See note above.
Returns
-------
Tuple[str, str], optional
font-family and font-style for the file just registered, or `None` if
something goes wrong.
"""
return _QFIS.instance().addFont(filepath, prefix, charmap)
del DEFAULT_SCALING_FACTOR

View File

@@ -0,0 +1,47 @@
from abc import ABC, abstractmethod
from typing import Optional
from qtpy.QtCore import QRectF, QTimer
from qtpy.QtGui import QPainter
from qtpy.QtWidgets import QWidget
class Animation(ABC):
"""Base icon animation class."""
def __init__(self, parent_widget: QWidget, interval: int = 10, step: int = 1):
self.parent_widget = parent_widget
self.timer = QTimer()
self.timer.timeout.connect(self._update) # type: ignore
self.timer.setInterval(interval)
self._angle = 0
self._step = step
def _update(self):
if self.timer.isActive():
self._angle += self._step
self.parent_widget.update()
@abstractmethod
def animate(self, painter: QPainter):
"""Setup and start the timer for the animation."""
class spin(Animation):
"""Animation that smoothly spins an icon."""
def animate(self, painter: QPainter):
if not self.timer.isActive():
self.timer.start()
mid = QRectF(painter.viewport()).center()
painter.translate(mid)
painter.rotate(self._angle % 360)
painter.translate(-mid)
class pulse(spin):
"""Animation that spins an icon in slower, discrete steps."""
def __init__(self, parent_widget: Optional[QWidget] = None):
super().__init__(parent_widget, interval=200, step=45)

View File

@@ -0,0 +1,88 @@
from typing import Mapping, Type, Union
FONTFILE_ATTR = "__font_file__"
class IconFontMeta(type):
"""IconFont metaclass.
This updates the value of all class attributes to be prefaced with the class
name (lowercase), and makes sure that all values are valid characters.
Examples
--------
This metaclass turns the following class:
class FA5S(metaclass=IconFontMeta):
__font_file__ = 'path/to/font.otf'
some_char = 0xfa42
into this:
class FA5S:
__font_file__ = path/to/font.otf'
some_char = 'fa5s.\ufa42'
In usage, this means that someone could use `icon(FA5S.some_char)` (provided
that the FA5S class/namespace has already been registered). This makes
IDE attribute checking and autocompletion easier.
"""
__font_file__: str
def __new__(cls, name, bases, namespace, **kwargs):
# make sure this class provides the __font_file__ interface
ff = namespace.get(FONTFILE_ATTR)
if not (ff and isinstance(ff, (str, classmethod))):
raise TypeError(
f"Invalid Font: must declare {FONTFILE_ATTR!r} attribute or classmethod"
)
# update all values to be `key.unicode`
prefix = name.lower()
for k, v in list(namespace.items()):
if k.startswith("__"):
continue
char = chr(v) if isinstance(v, int) else v
if len(char) != 1:
raise TypeError(
"Invalid Font: All fonts values must be a single "
f"unicode char. ('{name}.{char}' has length {len(char)}). "
"You may use unicode representations: like '\\uf641' or '0xf641'"
)
namespace[k] = f"{prefix}.{char}"
return super().__new__(cls, name, bases, namespace, **kwargs)
class IconFont(metaclass=IconFontMeta):
"""Helper class that provides a standard way to create an IconFont.
Examples
--------
class FA5S(IconFont):
__font_file__ = '...'
some_char = 0xfa42
"""
__slots__ = ()
__font_file__ = "..."
def namespace2font(namespace: Union[Mapping, Type], name: str) -> Type[IconFont]:
"""Convenience to convert a namespace (class, module, dict) into an IconFont."""
if isinstance(namespace, type):
if not isinstance(getattr(namespace, FONTFILE_ATTR), str):
raise TypeError(
f"Invalid Font: must declare {FONTFILE_ATTR!r} attribute or classmethod"
)
return namespace
elif hasattr(namespace, "__dict__"):
ns = dict(namespace.__dict__)
else:
raise ValueError(
"namespace must be a mapping or an object with __dict__ attribute."
)
if not str.isidentifier(name):
raise ValueError(f"name {name!r} is not a valid identifier.")
return type(name, (IconFont,), ns)

View File

@@ -0,0 +1,106 @@
import contextlib
from typing import ClassVar, Dict, List, Set, Tuple
from ._iconfont import IconFontMeta, namespace2font
try:
from importlib.metadata import EntryPoint, entry_points
except ImportError:
from importlib_metadata import EntryPoint, entry_points # type: ignore
class FontIconManager:
ENTRY_POINT: ClassVar[str] = "superqt.fonticon"
_PLUGINS: ClassVar[Dict[str, EntryPoint]] = {}
_LOADED: ClassVar[Dict[str, IconFontMeta]] = {}
_BLOCKED: ClassVar[Set[EntryPoint]] = set()
def _discover_fonts(self) -> None:
self._PLUGINS.clear()
entries = entry_points()
if hasattr(entries, "select"): # python>3.10
_entries = entries.select(group=self.ENTRY_POINT) # type: ignore
else:
_entries = entries.get(self.ENTRY_POINT, [])
for ep in _entries:
if ep not in self._BLOCKED:
self._PLUGINS[ep.name] = ep
def _get_font_class(self, key: str) -> IconFontMeta:
"""Get IconFont given a key.
Parameters
----------
key : str
font key to load.
Returns
-------
IconFontMeta
Instance of IconFontMeta
Raises
------
KeyError
If no plugin provides this key
ImportError
If a plugin provides the key, but the entry point doesn't load
TypeError
If the entry point loads, but is not an IconFontMeta
"""
if key not in self._LOADED:
# get the entrypoint
if key not in self._PLUGINS:
self._discover_fonts()
ep = self._PLUGINS.get(key)
if ep is None:
raise KeyError(f"No plugin provides the key {key!r}")
# load the entry point
try:
font = ep.load()
except Exception as e:
self._PLUGINS.pop(key)
self._BLOCKED.add(ep)
raise ImportError(f"Failed to load {ep.value}. Plugin blocked") from e
# make sure it's a proper IconFont
try:
self._LOADED[key] = namespace2font(font, ep.name.upper())
except Exception as e:
self._PLUGINS.pop(key)
self._BLOCKED.add(ep)
raise TypeError(
f"Failed to create fonticon from {ep.value}: {e}"
) from e
return self._LOADED[key]
def dict(self) -> dict:
return {
key: sorted(filter(lambda x: not x.startswith("_"), cls.__dict__))
for key, cls in self._LOADED.items()
}
_manager = FontIconManager()
get_font_class = _manager._get_font_class
def discover() -> Tuple[str]:
_manager._discover_fonts()
def available() -> Tuple[str]:
return tuple(_manager._PLUGINS)
def loaded(load_all=False) -> Dict[str, List[str]]:
if load_all:
discover()
for x in available():
with contextlib.suppress(Exception):
_manager._get_font_class(x)
return {
key: sorted(filter(lambda x: not x.startswith("_"), cls.__dict__))
for key, cls in _manager._LOADED.items()
}

View File

@@ -0,0 +1,577 @@
from __future__ import annotations
import warnings
from collections import abc, defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import ClassVar, DefaultDict, Sequence, Tuple, Union, cast
from qtpy import QT_VERSION
from qtpy.QtCore import QObject, QPoint, QRect, QSize, Qt
from qtpy.QtGui import (
QColor,
QFont,
QFontDatabase,
QGuiApplication,
QIcon,
QIconEngine,
QPainter,
QPixmap,
QPixmapCache,
QTransform,
)
from qtpy.QtWidgets import QApplication, QStyleOption, QWidget
from typing_extensions import TypedDict
from superqt.utils import QMessageHandler
from ._animations import Animation
class Unset:
def __repr__(self) -> str:
return "UNSET"
_Unset = Unset()
# A 16 pixel-high icon yields a font size of 14, which is pixel perfect
# for font-awesome. 16 * 0.875 = 14
# The reason why the glyph size is smaller than the icon size is to
# account for font bearing.
DEFAULT_SCALING_FACTOR = 0.875
DEFAULT_OPACITY = 1
ValidColor = Union[
QColor,
int,
str,
Qt.GlobalColor,
Tuple[int, int, int, int],
Tuple[int, int, int],
None,
]
StateOrMode = Union[QIcon.State, QIcon.Mode]
StateModeKey = Union[StateOrMode, str, Sequence[StateOrMode]]
_SM_MAP: dict[str, StateOrMode] = {
"on": QIcon.State.On,
"off": QIcon.State.Off,
"normal": QIcon.Mode.Normal,
"active": QIcon.Mode.Active,
"selected": QIcon.Mode.Selected,
"disabled": QIcon.Mode.Disabled,
}
def _norm_state_mode(key: StateModeKey) -> tuple[QIcon.State, QIcon.Mode]:
"""Return state/mode tuple given a variety of valid inputs.
Input can be either a string, or a sequence of state or mode enums.
Strings can be any combination of on, off, normal, active, selected, disabled,
sep by underscore.
"""
_sm: Sequence[StateOrMode]
if isinstance(key, str):
try:
_sm = [_SM_MAP[k.lower()] for k in key.split("_")]
except KeyError as e:
raise ValueError(
f"{key!r} is not a valid state key, must be a combination of {{on, "
"off, active, disabled, selected, normal} separated by underscore"
) from e
else:
_sm = key if isinstance(key, abc.Sequence) else [key]
state = next((i for i in _sm if isinstance(i, QIcon.State)), QIcon.State.Off)
mode = next((i for i in _sm if isinstance(i, QIcon.Mode)), QIcon.Mode.Normal)
return state, mode
class IconOptionDict(TypedDict, total=False):
glyph_key: str
scale_factor: float
color: ValidColor
opacity: float
animation: Animation | None
transform: QTransform | None
# public facing, for a nicer IDE experience than a dict
# The difference between IconOpts and _IconOptions is that all of IconOpts
# all default to `_Unset` and are intended to extend some base/default option
# IconOpts are *not* guaranteed to be fully capable of rendering an icon, whereas
# IconOptions are.
@dataclass
class IconOpts:
"""Options for rendering an icon.
Parameters
----------
glyph_key : str, optional
The key of the glyph to use, e.g. `'fa5s.smile'`, by default `None`
scale_factor : float, optional
The scale factor to use, by default `None`
color : ValidColor, optional
The color to use, by default `None`. Colors may be specified as a string,
`QColor`, `Qt.GlobalColor`, or a 3 or 4-tuple of integers.
opacity : float, optional
The opacity to use, by default `None`
animation : Animation, optional
The animation to use, by default `None`
"""
glyph_key: str | Unset = _Unset
scale_factor: float | Unset = _Unset
color: ValidColor | Unset = _Unset
opacity: float | Unset = _Unset
animation: Animation | Unset | None = _Unset
transform: QTransform | Unset | None = _Unset
def dict(self) -> IconOptionDict:
# not using asdict due to pickle errors on animation
d = {k: v for k, v in vars(self).items() if v is not _Unset}
return cast(IconOptionDict, d)
@dataclass
class _IconOptions:
"""The set of options needed to render a font in a single State/Mode."""
glyph_key: str
scale_factor: float = DEFAULT_SCALING_FACTOR
color: ValidColor = None
opacity: float = DEFAULT_OPACITY
animation: Animation | None = None
transform: QTransform | None = None
def _update(self, icon_opts: IconOpts) -> _IconOptions:
return _IconOptions(**{**vars(self), **icon_opts.dict()})
def dict(self) -> IconOptionDict:
# not using asdict due to pickle errors on animation
return cast(IconOptionDict, vars(self))
class _QFontIconEngine(QIconEngine):
_opt_hash: str = ""
def __init__(self, options: _IconOptions):
super().__init__()
self._opts: defaultdict[
QIcon.State, dict[QIcon.Mode, _IconOptions | None]
] = DefaultDict(dict)
self._opts[QIcon.State.Off][QIcon.Mode.Normal] = options
self.update_hash()
@property
def _default_opts(self) -> _IconOptions:
return cast(_IconOptions, self._opts[QIcon.State.Off][QIcon.Mode.Normal])
def _add_opts(self, state: QIcon.State, mode: QIcon.Mode, opts: IconOpts) -> None:
self._opts[state][mode] = self._default_opts._update(opts)
self.update_hash()
def clone(self) -> QIconEngine: # pragma: no cover
ico = _QFontIconEngine(self._default_opts)
ico._opts = self._opts.copy()
return ico
def _get_opts(self, state: QIcon.State, mode: QIcon.Mode) -> _IconOptions:
opts = self._opts[state].get(mode)
if opts:
return opts
opp_state = QIcon.State.Off if state == QIcon.State.On else QIcon.State.On
if mode in (QIcon.Mode.Disabled, QIcon.Mode.Selected):
opp_mode = (
QIcon.Mode.Disabled
if mode == QIcon.Mode.Selected
else QIcon.Mode.Selected
)
for m, s in [
(QIcon.Mode.Normal, state),
(QIcon.Mode.Active, state),
(mode, opp_state),
(QIcon.Mode.Normal, opp_state),
(QIcon.Mode.Active, opp_state),
(opp_mode, state),
(opp_mode, opp_state),
]:
opts = self._opts[s].get(m)
if opts:
return opts
else:
opp_mode = (
QIcon.Mode.Active if mode == QIcon.Mode.Normal else QIcon.Mode.Normal
)
for m, s in [
(opp_mode, state),
(mode, opp_state),
(opp_mode, opp_state),
(QIcon.Mode.Disabled, state),
(QIcon.Mode.Selected, state),
(QIcon.Mode.Disabled, opp_state),
(QIcon.Mode.Selected, opp_state),
]:
opts = self._opts[s].get(m)
if opts:
return opts
return self._default_opts
def paint(
self,
painter: QPainter,
rect: QRect,
mode: QIcon.Mode,
state: QIcon.State,
) -> None:
opts = self._get_opts(state, mode)
char, family, style = QFontIconStore.key2glyph(opts.glyph_key)
# font
font = QFont()
font.setFamily(family) # set separately for Qt6
font.setPixelSize(round(rect.height() * opts.scale_factor))
if style:
font.setStyleName(style)
# color
if isinstance(opts.color, tuple):
color_args = opts.color
else:
color_args = (opts.color,) if opts.color else ()
# animation
if opts.animation is not None:
opts.animation.animate(painter)
# animation
if opts.transform is not None:
painter.setTransform(opts.transform, True)
painter.save()
painter.setPen(QColor(*color_args))
painter.setOpacity(opts.opacity)
painter.setFont(font)
with QMessageHandler(): # avoid "Populating font family aliases" warning
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, char)
painter.restore()
def pixmap(self, size: QSize, mode: QIcon.Mode, state: QIcon.State) -> QPixmap:
# first look in cache
pmckey = self._pmcKey(size, mode, state)
with warnings.catch_warnings():
warnings.filterwarnings("ignore", "QPixmapCache.find")
pm = QPixmapCache.find(pmckey) if pmckey else None
if pm:
return pm
pixmap = QPixmap(size)
if not size.isValid():
return pixmap
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
self.paint(painter, QRect(QPoint(0, 0), size), mode, state)
painter.end()
# Apply palette-based styles for disabled/selected modes
# unless the user has specifically set a color for this mode/state
if mode != QIcon.Mode.Normal:
ico_opts = self._opts[state].get(mode)
if not ico_opts or not ico_opts.color:
opt = QStyleOption()
opt.palette = QGuiApplication.palette()
generated = QApplication.style().generatedIconPixmap(mode, pixmap, opt)
if not generated.isNull():
pixmap = generated
if pmckey and not pixmap.isNull():
QPixmapCache.insert(pmckey, pixmap)
return pixmap
def _pmcKey(self, size: QSize, mode: QIcon.Mode, state: QIcon.State) -> str:
# Qt6-style enums
if self._get_opts(state, mode).animation:
return ""
if hasattr(mode, "value"):
mode = mode.value
if hasattr(state, "value"):
state = state.value
k = ((((((size.width()) << 11) | size.height()) << 11) | mode) << 4) | state
return f"$superqt_{self._opt_hash}_{hex(k)}"
def update_hash(self) -> None:
hsh = id(self)
for state, d in self._opts.items():
for mode, opts in d.items():
if not opts:
continue
hsh += hash(
hash(opts.glyph_key) + hash(opts.color) + hash(state) + hash(mode)
)
self._opt_hash = hex(hsh)
class QFontIcon(QIcon):
def __init__(self, options: _IconOptions) -> None:
self._engine = _QFontIconEngine(options)
super().__init__(self._engine)
def addState(
self,
state: QIcon.State = QIcon.State.Off,
mode: QIcon.Mode = QIcon.Mode.Normal,
glyph_key: str | Unset = _Unset,
scale_factor: float | Unset = _Unset,
color: ValidColor | Unset = _Unset,
opacity: float | Unset = _Unset,
animation: Animation | Unset | None = _Unset,
transform: QTransform | Unset | None = _Unset,
) -> None:
"""Set icon options for a specific mode/state."""
if glyph_key is not _Unset:
QFontIconStore.key2glyph(glyph_key) # type: ignore
_opts = IconOpts(
glyph_key=glyph_key,
scale_factor=scale_factor,
color=color,
opacity=opacity,
animation=animation,
transform=transform,
)
self._engine._add_opts(state, mode, _opts)
class QFontIconStore(QObject):
# map of key -> (font_family, font_style)
_LOADED_KEYS: ClassVar[dict[str, tuple[str, str]]] = {}
# map of (font_family, font_style) -> character (char may include key)
_CHARMAPS: ClassVar[dict[tuple[str, str | None], dict[str, str]]] = {}
# singleton instance, use `instance()` to retrieve
__instance: ClassVar[QFontIconStore | None] = None
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent=parent)
if tuple(cast(str, QT_VERSION).split(".")) < ("6", "0"):
# QT6 drops this
QApplication.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps)
@classmethod
def instance(cls) -> QFontIconStore:
if cls.__instance is None:
cls.__instance = cls()
return cls.__instance
@classmethod
def clear(cls) -> None:
cls._LOADED_KEYS.clear()
cls._CHARMAPS.clear()
QFontDatabase.removeAllApplicationFonts()
@classmethod
def _key2family(cls, key: str) -> tuple[str, str]:
"""Return (family, style) given a font `key`."""
key = key.split(".", maxsplit=1)[0]
if key not in cls._LOADED_KEYS:
from . import _plugins
try:
font_cls = _plugins.get_font_class(key)
result = cls.addFont(
font_cls.__font_file__, key, charmap=dict(font_cls.__dict__)
)
if not result: # pragma: no cover
raise Exception("Invalid font file")
cls._LOADED_KEYS[key] = result
except ValueError as e:
raise ValueError(
f"Unrecognized font key: {key!r}.\n"
f"Known plugin keys include: {_plugins.available()}.\n"
f"Loaded keys include: {list(cls._LOADED_KEYS)}."
) from e
return cls._LOADED_KEYS[key]
@classmethod
def _ensure_char(cls, char: str, family: str, style: str) -> str:
"""Make sure that `char` is a glyph provided by `family` and `style`."""
if len(char) == 1 and ord(char) > 256:
return char
try:
charmap = cls._CHARMAPS[(family, style)]
except KeyError as e:
raise KeyError(
f"No charmap registered for font '{family} ({style})'"
) from e
if char in charmap:
# split in case the charmap includes the key
return charmap[char].split(".", maxsplit=1)[-1]
ident = _ensure_identifier(char)
if ident in charmap:
return charmap[ident].split(".", maxsplit=1)[-1]
ident = f"{char!r} or {ident!r}" if char != ident else repr(ident)
raise ValueError(f"Font '{family} ({style})' has no glyph with the key {ident}")
@classmethod
def key2glyph(cls, glyph_key: str) -> tuple[str, str, str | None]:
"""Return (char, family, style) given a `glyph_key`."""
if "." not in glyph_key:
raise ValueError("Glyph key must contain a period")
font_key, char = glyph_key.split(".", maxsplit=1)
family, style = cls._key2family(font_key)
char = cls._ensure_char(char, family, style)
return char, family, style
@classmethod
def addFont(
cls, filepath: str, prefix: str, charmap: dict[str, str] | None = None
) -> tuple[str, str] | None:
r"""Add font at `filepath` to the registry under `key`.
If you'd like to later use a fontkey in the form of `key.some-name`, then
`charmap` must be provided and provide a mapping for all of the glyph names
to their unicode numbers. If a charmap is not provided, glyphs must be directly
accessed with their unicode as something like `key.\\uffff`.
Parameters
----------
filepath : str
Path to an OTF or TTF file containing the fonts
prefix : str
A key that will represent this font file when used for lookup. For example,
'fa5s' for 'Font-Awesome 5 Solid'.
charmap : Dict[str, str], optional
optional mapping for all of the glyph names to their unicode numbers.
See note above.
Returns
-------
Tuple[str, str], optional
font-family and font-style for the file just registered, or None if
something goes wrong.
"""
if prefix in cls._LOADED_KEYS:
warnings.warn(f"Prefix {prefix} already loaded", stacklevel=2)
return None
if not Path(filepath).exists():
raise FileNotFoundError(f"Font file doesn't exist: {filepath}")
if QApplication.instance() is None:
raise RuntimeError("Please create QApplication before adding a Font")
fontId = QFontDatabase.addApplicationFont(str(Path(filepath).absolute()))
if fontId < 0: # pragma: no cover
warnings.warn(f"Cannot load font file: {filepath}", stacklevel=2)
return None
families = QFontDatabase.applicationFontFamilies(fontId)
if not families: # pragma: no cover
warnings.warn(f"Font file is empty!: {filepath}", stacklevel=2)
return None
family: str = families[0]
# in Qt6, everything becomes a static member
QFd: QFontDatabase | type[QFontDatabase] = (
QFontDatabase()
if tuple(cast(str, QT_VERSION).split(".")) < ("6", "0")
else QFontDatabase
)
styles = QFd.styles(family)
style: str = styles[-1] if styles else ""
if not QFd.isSmoothlyScalable(family, style): # pragma: no cover
warnings.warn(
f"Registered font {family} ({style}) is not smoothly scalable. "
"Icons may not look attractive.",
stacklevel=2,
)
cls._LOADED_KEYS[prefix] = (family, style)
if charmap:
cls._CHARMAPS[(family, style)] = charmap
return (family, style)
def icon(
self,
glyph_key: str,
*,
scale_factor: float = DEFAULT_SCALING_FACTOR,
color: ValidColor | None = None,
opacity: float = 1,
animation: Animation | None = None,
transform: QTransform | None = None,
states: dict[str, IconOptionDict | IconOpts] | None = None,
) -> QFontIcon:
self.key2glyph(glyph_key) # make sure it's a valid glyph_key
default_opts = _IconOptions(
glyph_key=glyph_key,
scale_factor=scale_factor,
color=color,
opacity=opacity,
animation=animation,
transform=transform,
)
icon = QFontIcon(default_opts)
for kw, options in (states or {}).items():
if isinstance(options, IconOpts):
options = default_opts._update(options).dict()
icon.addState(*_norm_state_mode(kw), **options)
return icon
def setTextIcon(
self, widget: QWidget, glyph_key: str, size: float | None = None
) -> None:
"""Sets text on a widget to a specific font & glyph.
This is an alternative to setting a `QIcon` with a pixmap. It may
be easier to combine with dynamic stylesheets.
"""
setText = getattr(widget, "setText", None)
if not setText: # pragma: no cover
raise TypeError(f"Object does not a setText method: {widget}")
glyph = self.key2glyph(glyph_key)[0]
size = size or DEFAULT_SCALING_FACTOR
size = size if size > 1 else widget.height() * size
widget.setFont(self.font(glyph_key, int(size)))
setText(glyph)
def font(self, font_prefix: str, size: int | None = None) -> QFont:
"""Create QFont for `font_prefix`."""
font_key, _ = font_prefix.split(".", maxsplit=1)
family, style = self._key2family(font_key)
font = QFont()
font.setFamily(family)
if style:
font.setStyleName(style)
if size:
font.setPixelSize(int(size))
return font
def _ensure_identifier(name: str) -> str:
"""Normalize string to valid identifier."""
import keyword
if not name:
return ""
# add _ to beginning of names starting with numbers
if name[0].isdigit():
name = f"_{name}"
# add _ to end of reserved keywords
if keyword.iskeyword(name):
name += "_"
# replace dashes and spaces with underscores
name = name.replace("-", "_").replace(" ", "_")
if not str.isidentifier(name):
raise ValueError(f"Could not canonicalize name: {name!r}. (not an identifier)")
return name

View File

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

View File

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

View File

@@ -0,0 +1,46 @@
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QLineEdit, QListWidget, QVBoxLayout, QWidget
class QSearchableListWidget(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.list_widget = QListWidget()
self.filter_widget = QLineEdit()
self.filter_widget.textChanged.connect(self.update_visible)
layout = QVBoxLayout()
layout.addWidget(self.filter_widget)
layout.addWidget(self.list_widget)
self.setLayout(layout)
def __getattr__(self, item):
if hasattr(self.list_widget, item):
return getattr(self.list_widget, item)
return super().__getattr__(item)
def update_visible(self, text):
items_text = [
x.text() for x in self.list_widget.findItems(text, Qt.MatchContains)
]
for index in range(self.list_widget.count()):
item = self.item(index)
item.setHidden(item.text() not in items_text)
def addItems(self, *args):
self.list_widget.addItems(*args)
self.update_visible(self.filter_widget.text())
def addItem(self, *args):
self.list_widget.addItem(*args)
self.update_visible(self.filter_widget.text())
def insertItems(self, *args):
self.list_widget.insertItems(*args)
self.update_visible(self.filter_widget.text())
def insertItem(self, *args):
self.list_widget.insertItem(*args)
self.update_visible(self.filter_widget.text())

View File

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

View File

@@ -4,6 +4,7 @@ from ._labeled import (
QLabeledRangeSlider,
QLabeledSlider,
)
from ._range_style import MONTEREY_SLIDER_STYLES_FIX
from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
__all__ = [
@@ -14,4 +15,5 @@ __all__ = [
"QLabeledRangeSlider",
"QLabeledSlider",
"QRangeSlider",
"MONTEREY_SLIDER_STYLES_FIX",
]

View File

@@ -1,19 +1,15 @@
from typing import Generic, List, Sequence, Tuple, TypeVar, Union
from typing import List, Optional, Sequence, Tuple, TypeVar, Union
from qtpy import QtGui
from qtpy.QtCore import Property, QEvent, QPoint, QPointF, QRect, QRectF, Qt, Signal
from qtpy.QtWidgets import QSlider, QStyle, QStyleOptionSlider, QStylePainter
from ..qtcompat import QtGui
from ..qtcompat.QtCore import (
Property,
QEvent,
QPoint,
QPointF,
QRect,
QRectF,
Qt,
Signal,
)
from ..qtcompat.QtWidgets import QSlider, QStyle, QStyleOptionSlider, QStylePainter
from ._generic_slider import CC_SLIDER, SC_GROOVE, SC_HANDLE, SC_NONE, _GenericSlider
from ._range_style import RangeSliderStyle, update_styles_from_stylesheet
from ._range_style import (
MONTEREY_SLIDER_STYLES_FIX,
RangeSliderStyle,
update_styles_from_stylesheet,
)
_T = TypeVar("_T")
@@ -21,7 +17,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
@@ -32,16 +28,19 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
"""
# Emitted when the slider value has changed, with the new slider values
valueChanged = Signal(tuple)
_valuesChanged = Signal(tuple)
# Emitted when sliderDown is true and the slider moves
# This usually happens when the user is dragging the slider
# The value is the positions of *all* handles.
sliderMoved = Signal(tuple)
_slidersMoved = Signal(tuple)
def __init__(self, *args, **kwargs):
super().__init__(*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]
@@ -62,32 +61,30 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
# color
self._style = RangeSliderStyle()
self.setStyleSheet("")
update_styles_from_stylesheet(self)
# ############### New Public API #######################
def barIsRigid(self) -> bool:
"""Whether bar length is constant when dragging the bar.
If False, the bar can shorten when dragged beyond min/max. Default is True.
If `False`, the bar can shorten when dragged beyond min/max. Default is `True`.
"""
return self._bar_is_rigid
def setBarIsRigid(self, val: bool = True) -> None:
"""Whether bar length is constant when dragging the bar.
If False, the bar can shorten when dragged beyond min/max. Default is True.
If `False`, the bar can shorten when dragged beyond min/max. Default is `True`.
"""
self._bar_is_rigid = bool(val)
def barMovesAllHandles(self) -> bool:
"""Whether clicking on the bar moves all handles (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:
@@ -99,11 +96,21 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
self._should_draw_bar = bool(val)
def hideBar(self) -> None:
"""Hide the bar between the first and last handle."""
self.setBarVisible(False)
def showBar(self) -> None:
"""Show the bar between the first and last handle."""
self.setBarVisible(True)
def applyMacStylePatch(self) -> str:
"""Apply a QSS patch to fix sliders on macos>=12 with QT < 6.
see [FAQ](../faq.md#sliders-not-dragging-properly-on-macos-12) for more details.
"""
super().applyMacStylePatch()
self._style._macpatch = True
# ############### QtOverrides #######################
def value(self) -> Tuple[_T, ...]:
@@ -138,26 +145,41 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
self._doSliderMove()
def setStyleSheet(self, styleSheet: str) -> None:
return super().setStyleSheet(self._patch_style(styleSheet))
def _patch_style(self, style: str):
"""Override to patch style options before painting."""
# sub-page styles render on top of the lower sliders and don't work here.
if self._style._macpatch and not style:
style = MONTEREY_SLIDER_STYLES_FIX
override = f"""
\n{type(self).__name__}::sub-page:horizontal {{background: none}}
\n{type(self).__name__}::sub-page:vertical {{background: none}}
\n{type(self).__name__}::sub-page:horizontal
{{background: none; border: none}}
\n{type(self).__name__}::add-page:vertical
{{background: none; border: none}}
"""
return super().setStyleSheet(styleSheet + override)
return style + override
def event(self, ev: QEvent) -> bool:
if ev.type() == QEvent.StyleChange:
if ev.type() == QEvent.Type.StyleChange:
update_styles_from_stylesheet(self)
return super().event(ev)
def mouseMoveEvent(self, ev: QtGui.QMouseEvent) -> None:
if self._pressedControl == SC_BAR:
ev.accept()
delta = self._clickOffset - self._pixelPosToRangeValue(self._pick(ev.pos()))
delta = self._clickOffset - self._pixelPosToRangeValue(
self._pick(self._event_position(ev))
)
self._offsetAllPositions(-delta, self._sldPosAtPress)
else:
super().mouseMoveEvent(ev)
def _event_position(self, event):
# API changes between PyQt5 (.pos()) and PyQt6 (.position())
return event.pos() if hasattr(event, "pos") else event.position()
# ############### Implementation Details #######################
def _setPosition(self, val):
@@ -189,6 +211,7 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
self._style.brush_active = color
barColor = Property(QtGui.QBrush, _getBarColor, _setBarColor)
"""The color of the bar between the first and last handle."""
def _offsetAllPositions(self, offset: float, ref=None) -> None:
if ref is None:
@@ -210,7 +233,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]
@@ -225,7 +250,7 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
thickness = self._style.thickness(opt)
offset = self._style.offset(opt)
if opt.orientation == Qt.Horizontal:
if opt.orientation == Qt.Orientation.Horizontal:
r_bar.setTop(r_bar.center().y() - thickness / 2 + offset)
r_bar.setHeight(thickness)
r_bar.setLeft(hdl_low.center().x())
@@ -261,9 +286,9 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
opt.sliderPosition = pos
# make pressed handles appear sunken
if idx == pidx:
opt.state |= QStyle.State_Sunken
opt.state |= QStyle.StateFlag.State_Sunken
else:
opt.state = opt.state & ~QStyle.State_Sunken
opt.state = opt.state & ~QStyle.StateFlag.State_Sunken
opt.activeSubControls = SC_HANDLE if idx == hidx else SC_NONE
painter.drawComplexControl(CC_SLIDER, opt)
@@ -287,7 +312,7 @@ 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
self, pos: QPoint, opt: Optional[QStyleOptionSlider] = None
) -> Tuple[QStyle.SubControl, int]:
"""Update self._pressedControl based on ev.pos()."""
opt = opt or self._styleOption
@@ -314,11 +339,11 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
return (SC_HANDLE, len(self._position) - 1)
def _execute_scroll(self, steps_to_scroll, modifiers):
if modifiers & Qt.AltModifier:
if modifiers & Qt.KeyboardModifier.AltModifier:
self._spreadAllPositions(shrink=steps_to_scroll < 0)
else:
self._offsetAllPositions(steps_to_scroll)
self.triggerAction(QSlider.SliderMove)
self.triggerAction(QSlider.SliderAction.SliderMove)
def _has_scroll_space_left(self, offset):
return (offset > 0 and max(self._value) < self._maximum) or (

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,12 +19,13 @@ 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 TypeVar
from typing import Generic, TypeVar
from ..qtcompat import QtGui
from ..qtcompat.QtCore import QEvent, QPoint, QPointF, QRect, Qt, Signal
from ..qtcompat.QtWidgets import (
from qtpy import QT_VERSION, QtGui
from qtpy.QtCore import QEvent, QPoint, QPointF, QRect, Qt, Signal
from qtpy.QtWidgets import (
QApplication,
QSlider,
QStyle,
@@ -32,6 +33,8 @@ from ..qtcompat.QtWidgets import (
QStylePainter,
)
from ._range_style import MONTEREY_SLIDER_STYLES_FIX
_T = TypeVar("_T")
SC_NONE = QStyle.SubControl.SC_None
@@ -40,18 +43,29 @@ SC_GROOVE = QStyle.SubControl.SC_SliderGroove
SC_TICKMARKS = QStyle.SubControl.SC_SliderTickmarks
CC_SLIDER = QStyle.ComplexControl.CC_Slider
QOVERFLOW = 2 ** 31 - 1
QOVERFLOW = 2**31 - 1
# whether to use the MONTEREY_SLIDER_STYLES_FIX QSS hack
# for fixing sliders on macos>=12 with QT < 6
# https://bugreports.qt.io/browse/QTBUG-98093
# https://github.com/pyapp-kit/superqt/issues/74
USE_MAC_SLIDER_PATCH = (
QT_VERSION
and int(QT_VERSION.split(".")[0]) < 6
and platform.system() == "Darwin"
and int(platform.mac_ver()[0].split(".", maxsplit=1)[0]) >= 12
and os.getenv("USE_MAC_SLIDER_PATCH", "0") not in ("0", "False", "false")
)
class _GenericSlider(QSlider, Generic[_T]):
valueChanged = Signal(float)
sliderMoved = Signal(float)
rangeChanged = Signal(float, float)
class _GenericSlider(QSlider):
_fvalueChanged = Signal(int)
_fsliderMoved = Signal(int)
_frangeChanged = Signal(int, int)
MAX_DISPLAY = 5000
def __init__(self, *args, **kwargs) -> None:
self._minimum = 0.0
self._maximum = 99.0
self._pageStep = 10.0
@@ -74,7 +88,21 @@ class _GenericSlider(QSlider, Generic[_T]):
self._control_fraction = 0.04
super().__init__(*args, **kwargs)
self.setAttribute(Qt.WA_Hover)
self.valueChanged = self._fvalueChanged
self.sliderMoved = self._fsliderMoved
self.rangeChanged = self._frangeChanged
self.setAttribute(Qt.WidgetAttribute.WA_Hover)
self.setStyleSheet("")
if USE_MAC_SLIDER_PATCH:
self.applyMacStylePatch()
def applyMacStylePatch(self) -> str:
"""Apply a QSS patch to fix sliders on macos>=12 with QT < 6.
see [FAQ](../faq.md#sliders-not-dragging-properly-on-macos-12) for more details.
"""
self.setStyleSheet(MONTEREY_SLIDER_STYLES_FIX)
# ############### QtOverrides #######################
@@ -130,11 +158,11 @@ class _GenericSlider(QSlider, Generic[_T]):
self.setRange(min(self._minimum, max), max)
def setRange(self, min: float, max_: float) -> None:
oldMin, self._minimum = self._minimum, float(min)
oldMax, self._maximum = self._maximum, float(max(min, max_))
oldMin, self._minimum = self._minimum, self._type_cast(min)
oldMax, self._maximum = self._maximum, self._type_cast(max(min, max_))
if oldMin != self._minimum or oldMax != self._maximum:
self.sliderChange(self.SliderRangeChange)
self.sliderChange(self.SliderChange.SliderRangeChange)
self.rangeChanged.emit(self._minimum, self._maximum)
self.setValue(self._value) # re-bound
@@ -159,15 +187,18 @@ class _GenericSlider(QSlider, Generic[_T]):
option.orientation = self.orientation()
option.tickPosition = self.tickPosition()
option.upsideDown = (
self.invertedAppearance() != (option.direction == Qt.RightToLeft)
if self.orientation() == Qt.Horizontal
self.invertedAppearance()
!= (option.direction == Qt.LayoutDirection.RightToLeft)
if self.orientation() == Qt.Orientation.Horizontal
else not self.invertedAppearance()
)
option.direction = Qt.LeftToRight # we use the upsideDown option instead
option.direction = (
Qt.LayoutDirection.LeftToRight
) # we use the upsideDown option instead
# option.sliderValue = self._value # type: ignore
# option.singleStep = self._singleStep # type: ignore
if self.orientation() == Qt.Horizontal:
option.state |= QStyle.State_Horizontal
if self.orientation() == Qt.Orientation.Horizontal:
option.state |= QStyle.StateFlag.State_Horizontal
# scale style option to integer space
option.minimum = 0
@@ -178,11 +209,11 @@ class _GenericSlider(QSlider, Generic[_T]):
self._fixStyleOption(option)
def event(self, ev: QEvent) -> bool:
if ev.type() == QEvent.WindowActivate:
if ev.type() == QEvent.Type.WindowActivate:
self.update()
elif ev.type() in (QEvent.HoverEnter, QEvent.HoverMove):
elif ev.type() in (QEvent.Type.HoverEnter, QEvent.Type.HoverMove):
self._updateHoverControl(_event_position(ev))
elif ev.type() == QEvent.HoverLeave:
elif ev.type() == QEvent.Type.HoverLeave:
self._hoverControl = SC_NONE
lastHoverRect, self._hoverRect = self._hoverRect, QRect()
self.update(lastHoverRect)
@@ -198,7 +229,7 @@ class _GenericSlider(QSlider, Generic[_T]):
pos = _event_position(ev)
# If the mouse button used is allowed to set the value
if ev.button() in (Qt.LeftButton, Qt.MiddleButton):
if ev.button() in (Qt.MouseButton.LeftButton, Qt.MouseButton.MiddleButton):
self._updatePressedControl(pos)
if self._pressedControl == SC_HANDLE:
opt = self._styleOption
@@ -206,8 +237,8 @@ class _GenericSlider(QSlider, Generic[_T]):
offset = sr.center() - sr.topLeft()
new_pos = self._pixelPosToRangeValue(self._pick(pos - offset))
self.setSliderPosition(new_pos)
self.triggerAction(QSlider.SliderMove)
self.setRepeatAction(QSlider.SliderNoAction)
self.triggerAction(QSlider.SliderAction.SliderMove)
self.setRepeatAction(QSlider.SliderAction.SliderNoAction)
self.update()
# elif: deal with PageSetButtons
@@ -215,7 +246,7 @@ class _GenericSlider(QSlider, Generic[_T]):
ev.ignore()
if self._pressedControl != SC_NONE:
self.setRepeatAction(QSlider.SliderNoAction)
self.setRepeatAction(QSlider.SliderAction.SliderNoAction)
self._setClickOffset(pos)
self.update()
self.setSliderDown(True)
@@ -238,20 +269,19 @@ class _GenericSlider(QSlider, Generic[_T]):
ev.accept()
oldPressed = self._pressedControl
self._pressedControl = SC_NONE
self.setRepeatAction(QSlider.SliderNoAction)
self.setRepeatAction(QSlider.SliderAction.SliderNoAction)
if oldPressed != SC_NONE:
self.setSliderDown(False)
self.update()
def wheelEvent(self, e: QtGui.QWheelEvent) -> None:
e.ignore()
vertical = bool(e.angleDelta().y())
delta = e.angleDelta().y() if vertical else e.angleDelta().x()
if e.inverted():
delta *= -1
orientation = Qt.Vertical if vertical else Qt.Horizontal
orientation = Qt.Orientation.Vertical if vertical else Qt.Orientation.Horizontal
if self._scrollByDelta(orientation, e.modifiers(), delta):
e.accept()
@@ -261,10 +291,31 @@ class _GenericSlider(QSlider, Generic[_T]):
# draw groove and ticks
opt.subControls = SC_GROOVE
if opt.tickPosition != QSlider.NoTicks:
if opt.tickPosition != QSlider.TickPosition.NoTicks:
opt.subControls |= SC_TICKMARKS
painter.drawComplexControl(CC_SLIDER, opt)
if (
opt.tickPosition != QSlider.TickPosition.NoTicks
and "MONTEREY_SLIDER_STYLES_FIX" in self.styleSheet()
):
# draw tick marks manually because they are badly behaved with style sheets
interval = opt.tickInterval or int(self._pageStep)
_range = self._maximum - self._minimum
nticks = (_range + interval) // interval
painter.setPen(QtGui.QColor("#C7C7C7"))
half_height = 3
for i in range(int(nticks)):
if self.orientation() == Qt.Orientation.Vertical:
y = int((self.height() - 8) * i / (nticks - 1)) + 1
x = self.rect().center().x()
painter.drawRect(x - half_height, y, 6, 1)
else:
x = int((self.width() - 3) * i / (nticks - 1)) + 1
y = self.rect().center().y()
painter.drawRect(x, y - half_height, 1, 6)
self._draw_handle(painter, opt)
# ############### Implementation Details #######################
@@ -287,12 +338,12 @@ class _GenericSlider(QSlider, Generic[_T]):
return int(min(QOVERFLOW, val / (self._maximum - self._minimum) * _max))
def _pick(self, pt: QPoint) -> int:
return pt.x() if self.orientation() == Qt.Horizontal else pt.y()
return pt.x() if self.orientation() == Qt.Orientation.Horizontal else pt.y()
def _setSteps(self, single: float, page: float):
self._singleStep = single
self._pageStep = page
self.sliderChange(QSlider.SliderStepsChange)
self.sliderChange(QSlider.SliderChange.SliderStepsChange)
def _doSliderMove(self):
if not self.hasTracking():
@@ -300,7 +351,7 @@ class _GenericSlider(QSlider, Generic[_T]):
if self.isSliderDown():
self.sliderMoved.emit(self.sliderPosition())
if self.hasTracking() and not self._blocktracking:
self.triggerAction(QSlider.SliderMove)
self.triggerAction(QSlider.SliderAction.SliderMove)
@property
def _styleOption(self):
@@ -311,7 +362,7 @@ class _GenericSlider(QSlider, Generic[_T]):
def _updateHoverControl(self, pos: QPoint) -> bool:
lastHoverRect = self._hoverRect
lastHoverControl = self._hoverControl
doesHover = self.testAttribute(Qt.WA_Hover)
doesHover = self.testAttribute(Qt.WidgetAttribute.WA_Hover)
if lastHoverControl != self._newHoverControl(pos) and doesHover:
self.update(lastHoverRect)
self.update(self._hoverRect)
@@ -351,7 +402,7 @@ class _GenericSlider(QSlider, Generic[_T]):
opt.subControls = SC_HANDLE
if self._pressedControl:
opt.activeSubControls = self._pressedControl
opt.state |= QStyle.State_Sunken
opt.state |= QStyle.StateFlag.State_Sunken
else:
opt.activeSubControls = self._hoverControl
@@ -364,7 +415,7 @@ class _GenericSlider(QSlider, Generic[_T]):
gr = self.style().subControlRect(CC_SLIDER, opt, SC_GROOVE, self)
sr = self.style().subControlRect(CC_SLIDER, opt, SC_HANDLE, self)
if self.orientation() == Qt.Horizontal:
if self.orientation() == Qt.Orientation.Horizontal:
sliderLength = sr.width()
sliderMin = gr.x()
sliderMax = gr.right() - sliderLength + 1
@@ -385,14 +436,14 @@ class _GenericSlider(QSlider, Generic[_T]):
pg_step = self._pageStep
# in Qt scrolling to the right gives negative values.
if orientation == Qt.Horizontal:
if orientation == Qt.Orientation.Horizontal:
delta *= -1
offset = delta / 120
if modifiers & Qt.ShiftModifier:
if modifiers & Qt.KeyboardModifier.ShiftModifier:
# Scroll one page regardless of delta:
steps_to_scroll = max(-pg_step, min(pg_step, offset * pg_step))
self._offset_accum = 0
elif modifiers & Qt.ControlModifier:
elif modifiers & Qt.KeyboardModifier.ControlModifier:
_range = self._maximum - self._minimum
steps_to_scroll = offset * _range * self._control_fraction
self._offset_accum = 0
@@ -440,7 +491,7 @@ class _GenericSlider(QSlider, Generic[_T]):
def _execute_scroll(self, steps_to_scroll, modifiers):
self._setPosition(self._bound(self._overflowSafeAdd(steps_to_scroll)))
self.triggerAction(QSlider.SliderMove)
self.triggerAction(QSlider.SliderAction.SliderMove)
def _effectiveSingleStep(self) -> float:
return self._singleStep * self._repeatMultiplier
@@ -469,20 +520,10 @@ 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:
return min if upsideDown else max
range = max - min
tmp = min + position * range / span
return max - tmp if upsideDown else tmp + min
tmp = (max - min) * (position / span)
return (max - tmp) if upsideDown else tmp + min

View File

@@ -1,9 +1,11 @@
import contextlib
from enum import IntEnum
from functools import partial
from typing import Any
from ..qtcompat.QtCore import QPoint, QSize, Qt, Signal
from ..qtcompat.QtGui import QFontMetrics, QValidator
from ..qtcompat.QtWidgets import (
from qtpy.QtCore import QPoint, QSize, Qt, Signal
from qtpy.QtGui import QFontMetrics, QValidator
from qtpy.QtWidgets import (
QAbstractSlider,
QApplication,
QDoubleSpinBox,
@@ -15,6 +17,9 @@ from ..qtcompat.QtWidgets import (
QVBoxLayout,
QWidget,
)
from superqt.utils import signals_blocked
from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
@@ -86,10 +91,13 @@ class _SliderProxy:
def setTickPosition(self, pos) -> None:
self._slider.setTickPosition(pos)
def __getattr__(self, name) -> Any:
return getattr(self._slider, name)
def _handle_overloaded_slider_sig(args, kwargs):
parent = None
orientation = Qt.Vertical
orientation = Qt.Orientation.Vertical
errmsg = (
"TypeError: arguments did not match any overloaded call:\n"
" QSlider(parent: QWidget = None)\n"
@@ -113,6 +121,9 @@ def _handle_overloaded_slider_sig(args, kwargs):
class QLabeledSlider(_SliderProxy, QAbstractSlider):
editingFinished = Signal()
EdgeLabelMode = EdgeLabelMode
_slider_class = QSlider
_slider: QSlider
@@ -120,50 +131,111 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
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._slider.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._rename_signals()
self._slider.actionTriggered.connect(self.actionTriggered.emit)
self._slider.rangeChanged.connect(self.rangeChanged.emit)
self._slider.valueChanged.connect(self.valueChanged.emit)
self._slider.valueChanged.connect(self._label.setValue)
self._slider.sliderMoved.connect(self.sliderMoved.emit)
self._slider.sliderPressed.connect(self.sliderPressed.emit)
self._slider.sliderReleased.connect(self.sliderReleased.emit)
self._slider.valueChanged.connect(self._on_slider_value_changed)
self._label.editingFinished.connect(self.editingFinished)
self.setOrientation(orientation)
def _on_slider_value_changed(self, v):
self._label.setValue(v)
self.valueChanged.emit(v)
def _setValue(self, value: float):
"""Convert the value from float to int before setting the slider value."""
self._slider.setValue(int(value))
def _rename_signals(self):
# for subclasses
pass
def setOrientation(self, orientation):
"""Set orientation, value will be 'horizontal' or 'vertical'."""
self._slider.setOrientation(orientation)
if orientation == Qt.Vertical:
marg = (0, 0, 0, 0)
if orientation == Qt.Orientation.Vertical:
layout = QVBoxLayout()
layout.addWidget(self._slider, alignment=Qt.AlignHCenter)
layout.addWidget(self._label, alignment=Qt.AlignHCenter)
self._label.setAlignment(Qt.AlignCenter)
layout.addWidget(self._slider, alignment=Qt.AlignmentFlag.AlignHCenter)
layout.addWidget(self._label, alignment=Qt.AlignmentFlag.AlignHCenter)
self._label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.setSpacing(1)
else:
layout = QHBoxLayout()
if self._edge_label_mode == EdgeLabelMode.NoLabel:
marg = (0, 0, 5, 0)
layout = QHBoxLayout() # type: ignore
layout.addWidget(self._slider)
layout.addWidget(self._label)
self._label.setAlignment(Qt.AlignRight)
self._label.setAlignment(Qt.AlignmentFlag.AlignRight)
layout.setSpacing(6)
old_layout = self.layout()
if old_layout is not None:
QWidget().setLayout(old_layout)
layout.setContentsMargins(0, 0, 0, 0)
layout.setContentsMargins(*marg)
self.setLayout(layout)
def edgeLabelMode(self) -> EdgeLabelMode:
"""Return current `EdgeLabelMode`."""
return self._edge_label_mode
def setEdgeLabelMode(self, opt: EdgeLabelMode) -> None:
"""Set the `EdgeLabelMode`."""
if opt is EdgeLabelMode.LabelIsRange:
raise ValueError(
"mode must be one of 'EdgeLabelMode.NoLabel' or "
"'EdgeLabelMode.LabelIsValue'."
)
self._edge_label_mode = opt
if not self._edge_label_mode:
self._label.hide()
w = 5 if self.orientation() == Qt.Orientation.Horizontal else 0
self.layout().setContentsMargins(0, 0, w, 0)
else:
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()
class QLabeledDoubleSlider(QLabeledSlider):
_slider_class = QDoubleSlider
_slider: QDoubleSlider
valueChanged = Signal(float)
rangeChanged = Signal(float, float)
_fvalueChanged = Signal(float)
_fsliderMoved = Signal(float)
_frangeChanged = Signal(float, float)
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.setDecimals(2)
def _setValue(self, value: float):
"""Convert the value from float to int before setting the slider value."""
self._slider.setValue(value)
def _rename_signals(self):
self.valueChanged = self._fvalueChanged
self.sliderMoved = self._fsliderMoved
self.rangeChanged = self._frangeChanged
def decimals(self) -> int:
return self._label.decimals()
@@ -172,7 +244,9 @@ class QLabeledDoubleSlider(QLabeledSlider):
class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
valueChanged = Signal(tuple)
_valueChanged = Signal(tuple)
editingFinished = Signal()
LabelPosition = LabelPosition
EdgeLabelMode = EdgeLabelMode
_slider_class = QRangeSlider
@@ -181,7 +255,9 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
def __init__(self, *args, **kwargs) -> None:
parent, orientation = _handle_overloaded_slider_sig(args, kwargs)
super().__init__(parent)
self.setAttribute(Qt.WA_ShowWithoutActivating)
self._rename_signals()
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
self._handle_labels = []
self._handle_label_position: LabelPosition = LabelPosition.LabelsAbove
@@ -194,11 +270,17 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
self._slider.rangeChanged.connect(self.rangeChanged.emit)
self._min_label = SliderLabel(
self._slider, alignment=Qt.AlignLeft, connect=self._min_label_edited
self._slider,
alignment=Qt.AlignmentFlag.AlignLeft,
connect=self._min_label_edited,
)
self._max_label = SliderLabel(
self._slider, alignment=Qt.AlignRight, connect=self._max_label_edited
self._slider,
alignment=Qt.AlignmentFlag.AlignRight,
connect=self._max_label_edited,
)
self._min_label.editingFinished.connect(self.editingFinished)
self._max_label.editingFinished.connect(self.editingFinished)
self.setEdgeLabelMode(EdgeLabelMode.LabelIsRange)
self._slider.valueChanged.connect(self._on_value_changed)
@@ -208,10 +290,15 @@ 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
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:
"""Set where/whether labels are shown adjacent to slider handles."""
self._handle_label_position = opt
for lbl in self._handle_labels:
if not opt:
@@ -221,9 +308,11 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
self.setOrientation(self.orientation())
def edgeLabelMode(self) -> EdgeLabelMode:
"""Return current `EdgeLabelMode`."""
return self._edge_label_mode
def setEdgeLabelMode(self, opt: EdgeLabelMode):
"""Set `EdgeLabelMode`, controls what is shown at the min/max labels."""
self._edge_label_mode = opt
if not self._edge_label_mode:
self._min_label.hide()
@@ -245,10 +334,13 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
self._reposition_labels()
def _reposition_labels(self):
if not self._handle_labels:
if (
not self._handle_labels
or self._handle_label_position == LabelPosition.NoLabel
):
return
horizontal = self.orientation() == Qt.Horizontal
horizontal = self.orientation() == Qt.Orientation.Horizontal
labels_above = self._handle_label_position == LabelPosition.LabelsAbove
last_edge = None
@@ -277,6 +369,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
label.move(pos)
last_edge = pos
label.clearFocus()
label.show()
self.update()
def _min_label_edited(self, val):
@@ -310,6 +403,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
for n, val in enumerate(self._slider.value()):
_cb = partial(self._slider.setSliderPosition, index=n)
s = SliderLabel(self._slider, parent=self, connect=_cb)
s.editingFinished.connect(self.editingFinished)
s.setValue(val)
self._handle_labels.append(s)
else:
@@ -336,9 +430,8 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
def setOrientation(self, orientation):
"""Set orientation, value will be 'horizontal' or 'vertical'."""
self._slider.setOrientation(orientation)
if orientation == Qt.Vertical:
if orientation == Qt.Orientation.Vertical:
layout = QVBoxLayout()
layout.setSpacing(1)
layout.addWidget(self._max_label)
@@ -351,7 +444,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
marg = (0, 0, 0, 0)
else:
marg = (0, 0, 20, 0)
layout.setAlignment(Qt.AlignCenter)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
else:
layout = QHBoxLayout()
layout.setSpacing(7)
@@ -384,12 +477,16 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
class QLabeledDoubleRangeSlider(QLabeledRangeSlider):
_slider_class = QDoubleRangeSlider
_slider: QDoubleRangeSlider
rangeChanged = Signal(float, float)
_frangeChanged = Signal(float, float)
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.setDecimals(2)
def _rename_signals(self):
super()._rename_signals()
self.rangeChanged = self._frangeChanged
def decimals(self) -> int:
return self._min_label.decimals()
@@ -402,24 +499,32 @@ class QLabeledDoubleRangeSlider(QLabeledRangeSlider):
class SliderLabel(QDoubleSpinBox):
def __init__(
self, slider: QSlider, parent=None, alignment=Qt.AlignCenter, connect=None
self,
slider: QSlider,
parent=None,
alignment=Qt.AlignmentFlag.AlignCenter,
connect=None,
) -> None:
super().__init__(parent=parent)
self._slider = slider
self.setFocusPolicy(Qt.ClickFocus)
self.setFocusPolicy(Qt.FocusPolicy.ClickFocus)
self.setMode(EdgeLabelMode.LabelIsValue)
self.setDecimals(0)
self.setRange(slider.minimum(), slider.maximum())
slider.rangeChanged.connect(self._update_size)
self.setAlignment(alignment)
self.setButtonSymbols(QSpinBox.NoButtons)
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.clearFocus)
self.editingFinished.connect(self._silent_clear_focus)
self._update_size()
def _silent_clear_focus(self):
with signals_blocked(self):
self.clearFocus()
def setDecimals(self, prec: int) -> None:
super().setDecimals(prec)
self._update_size()
@@ -445,10 +550,12 @@ class SliderLabel(QDoubleSpinBox):
# get the final size hint
opt = QStyleOptionSpinBox()
self.initStyleOption(opt)
size = self.style().sizeFromContents(QStyle.CT_SpinBox, opt, QSize(w, h), self)
size = self.style().sizeFromContents(
QStyle.ContentsType.CT_SpinBox, opt, QSize(w, h), self
)
self.setFixedSize(size)
def setValue(self, val):
def setValue(self, val: Any) -> None:
super().setValue(val)
if self._mode == EdgeLabelMode.LabelIsRange:
self._update_size()
@@ -470,10 +577,8 @@ class SliderLabel(QDoubleSpinBox):
if opt == EdgeLabelMode.LabelIsRange:
self.setMinimum(-9999999)
self.setMaximum(9999999)
try:
with contextlib.suppress(Exception):
self._slider.rangeChanged.disconnect(self.setRange)
except Exception:
pass
else:
self.setMinimum(self._slider.minimum())
self.setMaximum(self._slider.maximum())

View File

@@ -5,9 +5,9 @@ import re
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING
from ..qtcompat import PYQT_VERSION
from ..qtcompat.QtCore import Qt
from ..qtcompat.QtGui import (
from qtpy import QT_VERSION
from qtpy.QtCore import Qt
from qtpy.QtGui import (
QBrush,
QColor,
QGradient,
@@ -15,7 +15,7 @@ from ..qtcompat.QtGui import (
QPalette,
QRadialGradient,
)
from ..qtcompat.QtWidgets import QApplication, QSlider, QStyleOptionSlider
from qtpy.QtWidgets import QApplication, QSlider, QStyleOptionSlider
if TYPE_CHECKING:
from ._generic_range_slider import _GenericRangeSlider
@@ -36,13 +36,14 @@ class RangeSliderStyle:
v_offset: float | None = None
h_offset: float | None = None
has_stylesheet: bool = False
_macpatch: bool = False
def brush(self, opt: QStyleOptionSlider) -> QBrush:
cg = opt.palette.currentColorGroup()
attr = {
QPalette.Active: "brush_active", # 0
QPalette.Disabled: "brush_disabled", # 1
QPalette.Inactive: "brush_inactive", # 2
QPalette.ColorGroup.Active: "brush_active", # 0
QPalette.ColorGroup.Disabled: "brush_disabled", # 1
QPalette.ColorGroup.Inactive: "brush_inactive", # 2
}[cg]
_val = getattr(self, attr)
if not _val:
@@ -67,7 +68,7 @@ class RangeSliderStyle:
else:
val = _val
if opt.tickPosition != QSlider.NoTicks:
if opt.tickPosition != QSlider.TickPosition.NoTicks:
val.setAlphaF(self.tick_bar_alpha or SYSTEM_STYLE.tick_bar_alpha)
return QBrush(val)
@@ -75,36 +76,36 @@ class RangeSliderStyle:
def pen(self, opt: QStyleOptionSlider) -> Qt.PenStyle | QColor:
cg = opt.palette.currentColorGroup()
attr = {
QPalette.Active: "pen_active", # 0
QPalette.Disabled: "pen_disabled", # 1
QPalette.Inactive: "pen_inactive", # 2
QPalette.ColorGroup.Active: "pen_active", # 0
QPalette.ColorGroup.Disabled: "pen_disabled", # 1
QPalette.ColorGroup.Inactive: "pen_inactive", # 2
}[cg]
val = getattr(self, attr) or getattr(SYSTEM_STYLE, attr)
if not val:
return Qt.NoPen
return Qt.PenStyle.NoPen
if isinstance(val, str):
val = QColor(val)
if opt.tickPosition != QSlider.NoTicks:
if opt.tickPosition != QSlider.TickPosition.NoTicks:
val.setAlphaF(self.tick_bar_alpha or SYSTEM_STYLE.tick_bar_alpha)
return val
def offset(self, opt: QStyleOptionSlider) -> int:
tp = opt.tickPosition
off = 0
if not self.has_stylesheet:
if opt.orientation == Qt.Horizontal:
off += self.h_offset or SYSTEM_STYLE.h_offset or 0
tp = opt.tickPosition
if opt.orientation == Qt.Orientation.Horizontal:
if not self._macpatch:
off += self.h_offset or SYSTEM_STYLE.h_offset or 0
else:
off += self.v_offset or SYSTEM_STYLE.v_offset or 0
if tp == QSlider.TicksAbove:
if tp == QSlider.TickPosition.TicksAbove:
off += self.tick_offset or SYSTEM_STYLE.tick_offset
elif tp == QSlider.TicksBelow:
elif tp == QSlider.TickPosition.TicksBelow:
off -= self.tick_offset or SYSTEM_STYLE.tick_offset
return off
def thickness(self, opt: QStyleOptionSlider) -> float:
if opt.orientation == Qt.Horizontal:
if opt.orientation == Qt.Orientation.Horizontal:
return self.horizontal_thickness or SYSTEM_STYLE.horizontal_thickness
else:
return self.vertical_thickness or SYSTEM_STYLE.vertical_thickness
@@ -139,7 +140,7 @@ CATALINA_STYLE = replace(
tick_offset=4,
)
if PYQT_VERSION and int(PYQT_VERSION.split(".")[0]) == 6:
if QT_VERSION and int(QT_VERSION.split(".")[0]) == 6:
CATALINA_STYLE = replace(CATALINA_STYLE, tick_offset=2)
BIG_SUR_STYLE = replace(
@@ -154,7 +155,7 @@ BIG_SUR_STYLE = replace(
tick_bar_alpha=0.2,
)
if PYQT_VERSION and int(PYQT_VERSION.split(".")[0]) == 6:
if QT_VERSION and int(QT_VERSION.split(".")[0]) == 6:
BIG_SUR_STYLE = replace(BIG_SUR_STYLE, tick_offset=-3)
WINDOWS_STYLE = replace(
@@ -259,7 +260,7 @@ def parse_color(color: str, default_attr) -> QColor | QGradient:
def update_styles_from_stylesheet(obj: _GenericRangeSlider):
qss = obj.styleSheet()
qss: str = obj.styleSheet()
parent = obj.parent()
while parent is not None:
@@ -268,6 +269,11 @@ def update_styles_from_stylesheet(obj: _GenericRangeSlider):
qss = QApplication.instance().styleSheet() + qss
if not qss:
return
if MONTEREY_SLIDER_STYLES_FIX in qss:
qss = qss.replace(MONTEREY_SLIDER_STYLES_FIX, "")
obj._style._macpatch = True
else:
obj._style._macpatch = False
# Find bar height/width
for orient, dim in (("horizontal", "height"), ("vertical", "width")):
@@ -279,3 +285,56 @@ def update_styles_from_stylesheet(obj: _GenericRangeSlider):
thickness = float(bgrd.groups()[-1])
setattr(obj._style, f"{orient}_thickness", thickness)
obj._style.has_stylesheet = True
# a fix for https://bugreports.qt.io/browse/QTBUG-98093
MONTEREY_SLIDER_STYLES_FIX = """
/* MONTEREY_SLIDER_STYLES_FIX */
QSlider::groove {
background: #DFDFDF;
border: 1px solid #DBDBDB;
border-radius: 2px;
}
QSlider::groove:horizontal {
height: 2px;
margin: 2px;
}
QSlider::groove:vertical {
width: 2px;
margin: 2px 0 6px 0;
}
QSlider::handle {
background: white;
border: 0.5px solid #DADADA;
width: 19.5px;
height: 19.5px;
border-radius: 10.5px;
}
QSlider::handle:horizontal {
margin: -10px -2px;
}
QSlider::handle:vertical {
margin: -2px -10px;
}
QSlider::handle:pressed {
background: #F0F0F0;
}
QSlider::sub-page:horizontal {
background: #0981FE;
border-radius: 2px;
margin: 2px;
height: 2px;
}
QSlider::add-page:vertical {
background: #0981FE;
border-radius: 2px;
margin: 2px 0 6px 0;
width: 2px;
}
""".strip()

View File

@@ -1,4 +1,5 @@
from ..qtcompat.QtCore import Signal
from qtpy.QtCore import Signal
from ._generic_range_slider import _GenericRangeSlider
from ._generic_slider import _GenericSlider
@@ -13,6 +14,10 @@ class _IntMixin:
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
@@ -22,11 +27,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)

View File

@@ -1,8 +1,9 @@
import math
from enum import Enum
from ..qtcompat.QtCore import QSize, Qt, Signal
from ..qtcompat.QtGui import QFontMetrics, QValidator
from ..qtcompat.QtWidgets import QAbstractSpinBox, QStyle, QStyleOptionSpinBox
from qtpy.QtCore import QSize, Qt, Signal
from qtpy.QtGui import QFontMetrics, QValidator
from qtpy.QtWidgets import QAbstractSpinBox, QStyle, QStyleOptionSpinBox
class _EmitPolicy(Enum):
@@ -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.
@@ -40,8 +41,11 @@ class QLargeIntSpinBox(QAbstractSpinBox):
super().__init__(parent)
self._value: int = 0
self._minimum: int = 0
self._maximum: int = 2 ** 64 - 1
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)
@@ -78,7 +82,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 #######################
@@ -93,7 +103,7 @@ class QLargeIntSpinBox(QAbstractSpinBox):
return super().closeEvent(e)
def keyPressEvent(self, e) -> None:
if e.key() in (Qt.Key_Enter, Qt.Key_Return):
if e.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return):
self._interpret(
_EmitPolicy.AlwaysEmit
if self.keyboardTracking()
@@ -102,23 +112,26 @@ 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):
flags = QAbstractSpinBox.StepNone
flags = QAbstractSpinBox.StepEnabledFlag.StepNone
if self.isReadOnly():
return flags
if self._value < self._maximum:
flags |= QAbstractSpinBox.StepUpEnabled
flags |= QAbstractSpinBox.StepEnabledFlag.StepUpEnabled
if self._value > self._minimum:
flags |= QAbstractSpinBox.StepDownEnabled
flags |= QAbstractSpinBox.StepEnabledFlag.StepDownEnabled
return flags
def sizeHint(self):
@@ -134,7 +147,9 @@ class QLargeIntSpinBox(QAbstractSpinBox):
opt = QStyleOptionSpinBox()
self.initStyleOption(opt)
hint = QSize(w, h)
return self.style().sizeFromContents(QStyle.CT_SpinBox, opt, hint, self)
return self.style().sizeFromContents(
QStyle.ContentsType.CT_SpinBox, opt, hint, self
)
# ############### Implementation Details #######################
@@ -162,9 +177,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:
@@ -172,3 +190,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

@@ -0,0 +1,234 @@
from typing import TYPE_CHECKING, Optional, Union
try:
from pint import Quantity, Unit, UnitRegistry
from pint.util import UnitsContainer
except ImportError as e:
raise ImportError(
"pint is required to use QQuantity. Install it with `pip install pint`"
) from e
from qtpy.QtCore import Signal
from qtpy.QtWidgets import QComboBox, QDoubleSpinBox, QHBoxLayout, QSizePolicy, QWidget
from superqt.utils import signals_blocked
if TYPE_CHECKING:
from decimal import Decimal
Number = Union[int, float, "Decimal"]
UREG = UnitRegistry()
NULL_OPTION = "-----"
QOVERFLOW = 2**30
SI_BASES = {
"[length]": "meter",
"[time]": "second",
"[current]": "ampere",
"[luminosity]": "candela",
"[mass]": "gram",
"[substance]": "mole",
"[temperature]": "kelvin",
}
DEFAULT_OPTIONS = {
"[length]": ["km", "m", "mm", "µm"],
"[time]": ["day", "hour", "min", "sec", "ms"],
"[current]": ["A", "mA", "µA"],
"[luminosity]": ["kcd", "cd", "mcd"],
"[mass]": ["kg", "g", "mg", "µg"],
"[substance]": ["mol", "mmol", "µmol"],
"[temperature]": ["°C", "°F", "°K"],
"radian": ["rad", "deg"],
}
class QQuantity(QWidget):
"""A combination QDoubleSpinBox and QComboBox for entering quantities.
For this widget, `value()` returns a `pint.Quantity` object, while `setValue()`
accepts either a number, `pint.Quantity`, a string that can be parsed by `pint`.
Parameters
----------
value : Union[str, pint.Quantity, Number]
The initial value to display. If a string, it will be parsed by `pint`.
units : Union[pint.util.UnitsContainer, str, pint.Quantity], optional
The units to use if `value` is a number. If a string, it will be parsed by
`pint`. If a `pint.Quantity`, the units will be extracted from it.
ureg : pint.UnitRegistry, optional
The unit registry to use. If not provided, the registry will be extracted
from `value` if it is a `pint.Quantity`, otherwise the default registry will
be used.
parent : QWidget, optional
The parent widget, by default None
"""
valueChanged = Signal(Quantity)
unitsChanged = Signal(Unit)
dimensionalityChanged = Signal(UnitsContainer)
def __init__(
self,
value: Union[str, Quantity, Number] = 0,
units: Optional[Union[UnitsContainer, str, Quantity]] = None,
ureg: Optional[UnitRegistry] = None,
parent: Optional[QWidget] = None,
) -> None:
super().__init__(parent=parent)
if ureg is None:
ureg = value._REGISTRY if isinstance(value, Quantity) else UREG
else:
if not isinstance(ureg, UnitRegistry):
raise TypeError(
f"ureg must be a pint.UnitRegistry, not {type(ureg).__name__}"
)
self._ureg = ureg
self._value: Quantity = self._ureg.Quantity(value, units=units)
# whether to preserve quantity equality when changing units or magnitude
self._preserve_quantity: bool = False
self._abbreviate_units: bool = True # TODO: implement
self._mag_spinbox = QDoubleSpinBox()
self._mag_spinbox.setDecimals(3)
self._mag_spinbox.setRange(-QOVERFLOW, QOVERFLOW - 1)
self._mag_spinbox.setValue(float(self._value.magnitude))
self._mag_spinbox.valueChanged.connect(self.setMagnitude)
self._units_combo = QComboBox()
self._units_combo.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self._units_combo.currentTextChanged.connect(self.setUnits)
self._update_units_combo_choices()
self.setLayout(QHBoxLayout())
self.layout().addWidget(self._mag_spinbox)
self.layout().addWidget(self._units_combo)
self.layout().setContentsMargins(6, 0, 0, 0)
def unitRegistry(self) -> UnitRegistry:
"""Return the pint UnitRegistry used by this widget."""
return self._ureg
def _update_units_combo_choices(self):
if self._value.dimensionless:
with signals_blocked(self._units_combo):
self._units_combo.clear()
self._units_combo.addItem(NULL_OPTION)
self._units_combo.addItems(
[self._format_units(x) for x in SI_BASES.values()]
)
self._units_combo.setCurrentText(NULL_OPTION)
return
units = self._value.units
dims, exp = next(iter(units.dimensionality.items()))
if exp != 1:
raise NotImplementedError("Inverse units not yet implemented")
options = [
self._format_units(self._ureg.Unit(u))
for u in DEFAULT_OPTIONS.get(dims, [])
]
current = self._format_units(units)
with signals_blocked(self._units_combo):
self._units_combo.clear()
self._units_combo.addItems(options)
if self._units_combo.findText(current) == -1:
self._units_combo.addItem(current)
self._units_combo.setCurrentText(current)
def value(self) -> Quantity:
"""Return the current value as a `pint.Quantity`."""
return self._value
def text(self) -> str:
return str(self._value)
def magnitude(self) -> Union[float, int]:
"""Return the magnitude of the current value."""
return self._value.magnitude
def units(self) -> Unit:
"""Return the current units."""
return self._value.units
def dimensionality(self) -> UnitsContainer:
"""Return the current dimensionality (cast to `str` for nice repr)."""
return self._value.dimensionality
def setDecimals(self, decimals: int) -> None:
"""Set the number of decimals to display in the spinbox."""
self._mag_spinbox.setDecimals(decimals)
if self._value is not None:
self._mag_spinbox.setValue(self._value.magnitude)
def setValue(
self,
value: Union[str, Quantity, Number],
units: Optional[Union[UnitsContainer, str, Quantity]] = None,
) -> None:
"""Set the current value (will cast to a pint Quantity)."""
if isinstance(value, Quantity):
if units is not None:
raise ValueError("Cannot specify units if value is a Quantity")
new_val = self._ureg.Quantity(value.magnitude, units=value.units)
else:
new_val = self._ureg.Quantity(value, units=units)
mag_change = new_val.magnitude != self._value.magnitude
units_change = new_val.units != self._value.units
dims_changed = new_val.dimensionality != self._value.dimensionality
self._value = new_val
if mag_change:
with signals_blocked(self._mag_spinbox):
self._mag_spinbox.setValue(float(self._value.magnitude))
if units_change:
with signals_blocked(self._units_combo):
self._units_combo.setCurrentText(self._format_units(self._value.units))
self.unitsChanged.emit(self._value.units)
if dims_changed:
self._update_units_combo_choices()
self.dimensionalityChanged.emit(self._value.dimensionality)
if mag_change or units_change:
self.valueChanged.emit(self._value)
def setMagnitude(self, magnitude: Number) -> None:
"""Set the magnitude of the current value."""
self.setValue(self._ureg.Quantity(magnitude, self._value.units))
def setUnits(self, units: Union[str, Unit, Quantity]) -> None:
"""Set the units of the current value.
If `units` is `None`, will convert to a dimensionless quantity.
Otherwise, units must be compatible with the current dimensionality.
"""
if units is None:
new_val = self._ureg.Quantity(self._value.magnitude)
elif self.isDimensionless():
new_val = self._ureg.Quantity(self._value.magnitude, units)
else:
new_val = self._value.to(units)
self.setValue(new_val)
def isDimensionless(self) -> bool:
"""Return `True` if the current value is dimensionless."""
return self._value.dimensionless
def magnitudeSpinBox(self) -> QDoubleSpinBox:
"""Return the `QSpinBox` widget used to edit the magnitude."""
return self._mag_spinbox
def unitsComboBox(self) -> QComboBox:
"""Return the `QCombBox` widget used to edit the units."""
return self._units_combo
def _format_units(self, u: Union[Unit, str]) -> str:
if isinstance(u, str):
return u
return f"{u:~}" if self._abbreviate_units else f"{u:}"

View File

@@ -0,0 +1,33 @@
__all__ = (
"CodeSyntaxHighlight",
"create_worker",
"ensure_main_thread",
"ensure_object_thread",
"FunctionWorker",
"GeneratorWorker",
"new_worker_qthread",
"qdebounced",
"QMessageHandler",
"QSignalDebouncer",
"QSignalThrottler",
"qthrottled",
"signals_blocked",
"thread_worker",
"WorkerBase",
"exceptions_as_dialog",
)
from ._code_syntax_highlight import CodeSyntaxHighlight
from ._ensure_thread import ensure_main_thread, ensure_object_thread
from ._errormsg_context import exceptions_as_dialog
from ._message_handler import QMessageHandler
from ._misc import signals_blocked
from ._qthreading import (
FunctionWorker,
GeneratorWorker,
WorkerBase,
create_worker,
new_worker_qthread,
thread_worker,
)
from ._throttler import QSignalDebouncer, QSignalThrottler, qdebounced, qthrottled

View File

@@ -0,0 +1,88 @@
from itertools import takewhile
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
# inspired by https://github.com/Vector35/snippets/blob/master/QCodeEditor.py
# (MIT license) and
# https://pygments.org/docs/formatterdevelopment/#html-3-2-formatter
def get_text_char_format(style):
text_char_format = QtGui.QTextCharFormat()
if hasattr(text_char_format, "setFontFamilies"):
text_char_format.setFontFamilies(["monospace"])
else:
text_char_format.setFontFamily("monospace")
if style.get("color"):
text_char_format.setForeground(QtGui.QColor(f"#{style['color']}"))
if style.get("bgcolor"):
text_char_format.setBackground(QtGui.QColor(style["bgcolor"]))
if style.get("bold"):
text_char_format.setFontWeight(QtGui.QFont.Bold)
if style.get("italic"):
text_char_format.setFontItalic(True)
if style.get("underline"):
text_char_format.setFontUnderline(True)
# TODO find if it is possible to support border style.
return text_char_format
class QFormatter(Formatter):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.data = []
self._style = {name: get_text_char_format(style) for name, style in self.style}
def format(self, tokensource, outfile):
"""Format the given token stream.
`outfile` is argument from parent class, but
in Qt we do not produce string output, but QTextCharFormat, so it needs to be
collected using `self.data`.
"""
self.data = []
for token, value in tokensource:
self.data.extend([self._style[token]] * len(value))
class CodeSyntaxHighlight(QtGui.QSyntaxHighlighter):
def __init__(self, parent, lang, theme):
super().__init__(parent)
self.formatter = QFormatter(style=theme)
try:
self.lexer = get_lexer_by_name(lang)
except ClassNotFound:
self.lexer = find_lexer_class(lang)()
@property
def background_color(self):
return self.formatter.style.background_color
def highlightBlock(self, text):
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.
# dirty, dirty hack
# The core problem is that pygemnts by default use string streams,
# that will not handle QTextCharFormat, so we need use `data` property to
# work around this.
for i in range(len(text)):
try:
self.setFormat(i, 1, self.formatter.data[p + i - enters])
except IndexError: # pragma: no cover
pass

View File

@@ -0,0 +1,191 @@
# https://gist.github.com/FlorianRhiem/41a1ad9b694c14fb9ac3
from __future__ import annotations
from concurrent.futures import Future
from functools import wraps
from typing import TYPE_CHECKING, Any, Callable, ClassVar, overload
from qtpy.QtCore import (
QCoreApplication,
QMetaObject,
QObject,
Qt,
QThread,
Signal,
Slot,
)
from ._util import get_max_args
if TYPE_CHECKING:
from typing import TypeVar
from typing_extensions import Literal, ParamSpec
P = ParamSpec("P")
R = TypeVar("R")
class CallCallable(QObject):
finished = Signal(object)
instances: ClassVar[list[CallCallable]] = []
def __init__(self, callable: Callable, args: tuple, kwargs: dict):
super().__init__()
self._callable = callable
self._args = args
self._kwargs = kwargs
CallCallable.instances.append(self)
@Slot()
def call(self):
CallCallable.instances.remove(self)
res = self._callable(*self._args, **self._kwargs)
self.finished.emit(res)
# fmt: off
@overload
def ensure_main_thread(
await_return: Literal[True],
timeout: int = 1000,
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
@overload
def ensure_main_thread(
func: Callable[P, R],
await_return: Literal[True],
timeout: int = 1000,
) -> Callable[P, R]: ...
@overload
def ensure_main_thread(
await_return: Literal[False] = False,
timeout: int = 1000,
) -> Callable[[Callable[P, R]], Callable[P, Future[R]]]: ...
@overload
def ensure_main_thread(
func: Callable[P, R],
await_return: Literal[False] = False,
timeout: int = 1000,
) -> Callable[P, Future[R]]: ...
# fmt: on
def ensure_main_thread(
func: Callable | None = None, await_return: bool = False, timeout: int = 1000
):
"""Decorator that ensures a function is called in the main QApplication thread.
It can be applied to functions or methods.
Parameters
----------
func : callable
The method to decorate, must be a method on a QObject.
await_return : bool, optional
Whether to block and wait for the result of the function, or return immediately.
by default False
timeout : int, optional
If `await_return` is `True`, time (in milliseconds) to wait for the result
before raising a TimeoutError, by default 1000
"""
def _out_func(func_):
max_args = get_max_args(func_)
@wraps(func_)
def _func(*args, _max_args_=max_args, **kwargs):
return _run_in_thread(
func_,
QCoreApplication.instance().thread(),
await_return,
timeout,
args[:_max_args_],
kwargs,
)
return _func
return _out_func if func is None else _out_func(func)
# fmt: off
@overload
def ensure_object_thread(
await_return: Literal[True],
timeout: int = 1000,
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
@overload
def ensure_object_thread(
func: Callable[P, R],
await_return: Literal[True],
timeout: int = 1000,
) -> Callable[P, R]: ...
@overload
def ensure_object_thread(
await_return: Literal[False] = False,
timeout: int = 1000,
) -> Callable[[Callable[P, R]], Callable[P, Future[R]]]: ...
@overload
def ensure_object_thread(
func: Callable[P, R],
await_return: Literal[False] = False,
timeout: int = 1000,
) -> Callable[P, Future[R]]: ...
# fmt: on
def ensure_object_thread(
func: Callable | None = None, await_return: bool = False, timeout: int = 1000
):
"""Decorator that ensures a QObject method is called in the object's thread.
It must be applied to methods of QObjects subclasses.
Parameters
----------
func : callable
The method to decorate, must be a method on a QObject.
await_return : bool, optional
Whether to block and wait for the result of the function, or return immediately.
by default False
timeout : int, optional
If `await_return` is `True`, time (in milliseconds) to wait for the result
before raising a TimeoutError, by default 1000
"""
def _out_func(func_):
max_args = get_max_args(func_)
@wraps(func_)
def _func(*args, _max_args_=max_args, **kwargs):
thread = args[0].thread() # self
return _run_in_thread(
func_, thread, await_return, timeout, args[:_max_args_], kwargs
)
return _func
return _out_func if func is None else _out_func(func)
def _run_in_thread(
func: Callable,
thread: QThread,
await_return: bool,
timeout: int,
args: tuple,
kwargs: dict,
) -> Any:
future = Future() # type: ignore
if thread is QThread.currentThread():
result = func(*args, **kwargs)
if not await_return:
future.set_result(result)
return future
return result
f = CallCallable(func, args, kwargs)
f.moveToThread(thread)
f.finished.connect(future.set_result, Qt.ConnectionType.DirectConnection)
QMetaObject.invokeMethod(f, "call", Qt.ConnectionType.QueuedConnection) # type: ignore # noqa
return future.result(timeout=timeout / 1000) if await_return else future

View File

@@ -0,0 +1,165 @@
from __future__ import annotations
import traceback
from contextlib import AbstractContextManager
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(AbstractContextManager):
"""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.
Examplez
-------
```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,95 @@
from __future__ import annotations
import logging
from contextlib import suppress
from typing import ClassVar, NamedTuple
from qtpy.QtCore import QMessageLogContext, QtMsgType, qInstallMessageHandler
class Record(NamedTuple):
level: int
message: str
ctx: dict
class QMessageHandler:
"""A context manager to intercept messages from Qt.
Parameters
----------
logger : logging.Logger, optional
If provided, intercepted messages will be logged with `logger` at the
corresponding python log level, by default None
Attributes
----------
records: list of tuple
Captured messages. This is a 3-tuple of:
`(log_level: int, message: str, context: dict)`
Examples
--------
>>> handler = QMessageHandler()
>>> handler.install() # now all Qt output will be available at mh.records
>>> with QMessageHandler() as handler: # temporarily install
... ...
>>> logger = logging.getLogger(__name__)
>>> with QMessageHandler(logger): # re-reoute Qt messages to a python logger.
... ...
"""
_qt2loggertype: ClassVar[dict[QtMsgType, int]] = {
QtMsgType.QtDebugMsg: logging.DEBUG,
QtMsgType.QtInfoMsg: logging.INFO,
QtMsgType.QtWarningMsg: logging.WARNING,
QtMsgType.QtCriticalMsg: logging.ERROR, # note
QtMsgType.QtFatalMsg: logging.CRITICAL, # note
QtMsgType.QtSystemMsg: logging.CRITICAL,
}
def __init__(self, logger: logging.Logger | None = None):
self.records: list[Record] = []
self._logger = logger
self._previous_handler: object | None = "__uninstalled__"
def install(self):
"""Install this handler (override the current QtMessageHandler)."""
self._previous_handler = qInstallMessageHandler(self)
def uninstall(self):
"""Uninstall this handler, restoring the previous handler."""
if self._previous_handler != "__uninstalled__":
qInstallMessageHandler(self._previous_handler)
def __repr__(self):
n = type(self).__name__
return f"<{n} object at {hex(id(self))} with {len(self.records)} records>"
def __enter__(self):
"""Enter a context with this handler installed."""
self.install()
return self
def __exit__(self, *args):
self.uninstall()
def __call__(self, msgtype: QtMsgType, context: QMessageLogContext, message: str):
level = self._qt2loggertype[msgtype]
# PyQt seems to throw an error if these are simply empty
ctx = dict.fromkeys(["category", "file", "function", "line"])
with suppress(UnicodeDecodeError):
ctx["category"] = context.category
with suppress(UnicodeDecodeError):
ctx["file"] = context.file
with suppress(UnicodeDecodeError):
ctx["function"] = context.function
with suppress(UnicodeDecodeError):
ctx["line"] = context.line
self.records.append(Record(level, message, ctx))
if self._logger is not None:
self._logger.log(level, message, extra=ctx)

View File

@@ -0,0 +1,32 @@
from contextlib import contextmanager
from typing import TYPE_CHECKING, Iterator
if TYPE_CHECKING:
from qtpy.QtCore import QObject
@contextmanager
def signals_blocked(obj: "QObject") -> Iterator[None]:
"""Context manager to temporarily block signals emitted by QObject: `obj`.
Parameters
----------
obj : QObject
The QObject whose signals should be blocked.
Examples
--------
```python
from qtpy.QtWidgets import QSpinBox
from superqt import signals_blocked
spinbox = QSpinBox()
with signals_blocked(spinbox):
spinbox.setValue(10)
```
"""
previous = obj.blockSignals(True)
try:
yield
finally:
obj.blockSignals(previous)

View File

@@ -0,0 +1,892 @@
from __future__ import annotations
import inspect
import time
import warnings
from functools import partial, wraps
from typing import (
TYPE_CHECKING,
Any,
Callable,
ClassVar,
Generator,
Generic,
Sequence,
TypeVar,
overload,
)
from qtpy.QtCore import QObject, QRunnable, QThread, QThreadPool, QTimer, Signal
if TYPE_CHECKING:
_T = TypeVar("_T")
class SigInst(Generic[_T]):
@staticmethod
def connect(slot: Callable[[_T], Any], type: type | None = ...) -> None:
...
@staticmethod
def disconnect(slot: Callable[[_T], Any] = ...) -> None:
...
@staticmethod
def emit(*args: _T) -> None:
...
from typing_extensions import Literal, ParamSpec
_P = ParamSpec("_P")
# maintain runtime compatibility with older typing_extensions
else:
try:
from typing_extensions import ParamSpec
_P = ParamSpec("_P")
except ImportError:
_P = TypeVar("_P")
_Y = TypeVar("_Y")
_S = TypeVar("_S")
_R = TypeVar("_R")
def as_generator_function(
func: Callable[_P, _R]
) -> Callable[_P, Generator[None, None, _R]]:
"""Turns a regular function (single return) into a generator function."""
@wraps(func)
def genwrapper(*args: Any, **kwargs: Any) -> Generator[None, None, _R]:
yield
return func(*args, **kwargs)
return genwrapper
class WorkerBaseSignals(QObject):
started = Signal() # emitted when the work is started
finished = Signal() # emitted when the work is finished
_finished = Signal(object) # emitted when the work is finished to delete
returned = Signal(object) # emitted with return value
errored = Signal(object) # emitted with error object on Exception
warned = Signal(tuple) # emitted with showwarning args on warning
class WorkerBase(QRunnable, Generic[_R]):
"""Base class for creating a Worker that can run in another thread.
Parameters
----------
SignalsClass : type, optional
A QObject subclass that contains signals, by default WorkerBaseSignals
Attributes
----------
signals: WorkerBaseSignals
signal emitter object. To allow identify which worker thread emitted signal.
"""
#: A set of Workers. Add to set using `WorkerBase.start`
_worker_set: ClassVar[set[WorkerBase]] = set()
returned: SigInst[_R]
errored: SigInst[Exception]
warned: SigInst[tuple]
started: SigInst[None]
finished: SigInst[None]
def __init__(
self,
func: Callable[_P, _R] | None = None,
SignalsClass: type[WorkerBaseSignals] = WorkerBaseSignals,
) -> None:
super().__init__()
self._abort_requested = False
self._running = False
self.signals = SignalsClass()
def __getattr__(self, name: str) -> SigInst:
"""Pass through attr requests to signals to simplify connection API.
The goal is to enable `worker.yielded.connect` instead of
`worker.signals.yielded.connect`. Because multiple inheritance of Qt
classes is not well supported in PyQt, we have to use composition here
(signals are provided by QObjects, and QRunnable is not a QObject). So
this passthrough allows us to connect to signals on the `_signals`
object.
"""
# the Signal object is actually a class attribute
attr = getattr(self.signals.__class__, name, None)
if isinstance(attr, Signal):
# but what we need to connect to is the instantiated signal
# (which is of type `SignalInstance` in PySide and
# `pyqtBoundSignal` in PyQt)
return getattr(self.signals, name)
raise AttributeError(
f"{self.__class__.__name__!r} object has no attribute {name!r}"
)
def quit(self) -> None:
"""Send a request to abort the worker.
!!! note
It is entirely up to subclasses to honor this method by checking
`self.abort_requested` periodically in their `worker.work`
method, and exiting if `True`.
"""
self._abort_requested = True
@property
def abort_requested(self) -> bool:
"""Whether the worker has been requested to stop."""
return self._abort_requested
@property
def is_running(self) -> bool:
"""Whether the worker has been started."""
return self._running
def run(self) -> None:
"""Start the worker.
The end-user should never need to call this function.
But it cannot be made private or renamed, since it is called by Qt.
The order of method calls when starting a worker is:
```
calls QThreadPool.globalInstance().start(worker)
| triggered by the QThreadPool.start() method
| | called by worker.run
| | |
V V V
worker.start -> worker.run -> worker.work
```
**This** is the function that actually gets called when calling
`QThreadPool.start(worker)`. It simply wraps the `work()`
method, and emits a few signals. Subclasses should NOT override this
method (except with good reason), and instead should implement
`work()`.
"""
self.started.emit()
self._running = True
try:
with warnings.catch_warnings():
warnings.filterwarnings("always")
warnings.showwarning = lambda *w: self.warned.emit(w)
result = self.work()
if isinstance(result, Exception):
if isinstance(result, RuntimeError):
# The Worker object has likely been deleted.
# A deleted wrapped C/C++ object may result in a runtime
# error that will cause segfault if we try to do much other
# than simply notify the user.
warnings.warn(
f"RuntimeError in aborted thread: {result}",
RuntimeWarning,
stacklevel=2,
)
return
else:
raise result
if not self.abort_requested:
self.returned.emit(result)
except Exception as exc:
self.errored.emit(exc)
self._running = False
self.finished.emit()
self._finished.emit(self)
def work(self) -> Exception | _R:
"""Main method to execute the worker.
The end-user should never need to call this function.
But subclasses must implement this method (See
[`GeneratorFunction.work`][superqt.utils._qthreading.GeneratorWorker.work] for
an example implementation). Minimally, it should check `self.abort_requested`
periodically and exit if True.
Examples
--------
```python
class MyWorker(WorkerBase):
def work(self):
i = 0
while True:
if self.abort_requested:
self.aborted.emit()
break
i += 1
if i > max_iters:
break
time.sleep(0.5)
```
"""
raise NotImplementedError(
f'"{self.__class__.__name__}" failed to define work() method'
)
def start(self) -> None:
"""Start this worker in a thread and add it to the global threadpool.
The order of method calls when starting a worker is:
```
calls QThreadPool.globalInstance().start(worker)
| triggered by the QThreadPool.start() method
| | called by worker.run
| | |
V V V
worker.start -> worker.run -> worker.work
```
"""
if self in self._worker_set:
raise RuntimeError("This worker is already started!")
# This will raise a RunTimeError if the worker is already deleted
repr(self)
self._worker_set.add(self)
self._finished.connect(self._set_discard)
if QThread.currentThread().loopLevel():
# if we're in a thread with an eventloop, queue the worker to start
start_ = partial(QThreadPool.globalInstance().start, self)
QTimer.singleShot(1, start_)
else:
# otherwise start it immediately
QThreadPool.globalInstance().start(self)
@classmethod
def _set_discard(cls, obj: WorkerBase) -> None:
cls._worker_set.discard(obj)
@classmethod
def await_workers(cls, msecs: int | None = None) -> None:
"""Ask all workers to quit, and wait up to `msec` for quit.
Attempts to clean up all running workers by calling `worker.quit()`
method. Any workers in the `WorkerBase._worker_set` set will have this
method.
By default, this function will block indefinitely, until worker threads
finish. If a timeout is provided, a `RuntimeError` will be raised if
the workers do not gracefully exit in the time requests, but the threads
will NOT be killed. It is (currently) left to the user to use their OS
to force-quit rogue threads.
!!! important
If the user does not put any yields in their function, and the function
is super long, it will just hang... For instance, there's no graceful
way to kill this thread in python:
```python
@thread_worker
def ZZZzzz():
time.sleep(10000000)
```
This is why it's always advisable to use a generator that periodically
yields for long-running computations in another thread.
See [this stack-overflow
post](https://stackoverflow.com/questions/323972/is-there-any-way-to-kill-a-thread)
for a good discussion on the difficulty of killing a rogue python thread:
Parameters
----------
msecs : int, optional
Waits up to msecs milliseconds for all threads to exit and removes all
threads from the thread pool. If msecs is `None` (the default), the
timeout is ignored (waits for the last thread to exit).
Raises
------
RuntimeError
If a timeout is provided and workers do not quit successfully within
the time allotted.
"""
for worker in cls._worker_set:
worker.quit()
msecs = msecs if msecs is not None else -1
if not QThreadPool.globalInstance().waitForDone(msecs):
raise RuntimeError(
f"Workers did not quit gracefully in the time allotted ({msecs} ms)"
)
class FunctionWorker(WorkerBase[_R]):
"""QRunnable with signals that wraps a simple long-running function.
!!! note
`FunctionWorker` does not provide a way to stop a very long-running
function (e.g. `time.sleep(10000)`). So whenever possible, it is better to
implement your long running function as a generator that yields periodically,
and use the [`GeneratorWorker`][superqt.utils.GeneratorWorker] instead.
Parameters
----------
func : Callable
A function to call in another thread
*args
will be passed to the function
**kwargs
will be passed to the function
Raises
------
TypeError
If `func` is a generator function and not a regular function.
"""
def __init__(self, func: Callable[_P, _R], *args, **kwargs):
if inspect.isgeneratorfunction(func):
raise TypeError(
f"Generator function {func} cannot be used with FunctionWorker, "
"use GeneratorWorker instead",
)
super().__init__()
self._func = func
self._args = args
self._kwargs = kwargs
def work(self) -> _R:
return self._func(*self._args, **self._kwargs)
class GeneratorWorkerSignals(WorkerBaseSignals):
yielded = Signal(object) # emitted with yielded values (if generator used)
paused = Signal() # emitted when a running job has successfully paused
resumed = Signal() # emitted when a paused job has successfully resumed
aborted = Signal() # emitted when a running job is successfully aborted
class GeneratorWorker(WorkerBase, Generic[_Y, _S, _R]):
"""QRunnable with signals that wraps a long-running generator.
Provides a convenient way to run a generator function in another thread,
while allowing 2-way communication between threads, using plain-python
generator syntax in the original function.
Parameters
----------
func : callable
The function being run in another thread. May be a generator function.
SignalsClass : type, optional
A QObject subclass that contains signals, by default
GeneratorWorkerSignals
*args
Will be passed to func on instantiation
**kwargs
Will be passed to func on instantiation
"""
yielded: SigInst[_Y]
paused: SigInst[None]
resumed: SigInst[None]
aborted: SigInst[None]
def __init__(
self,
func: Callable[_P, Generator[_Y, _S | None, _R]],
*args,
SignalsClass: type[WorkerBaseSignals] = GeneratorWorkerSignals,
**kwargs,
):
if not inspect.isgeneratorfunction(func):
raise TypeError(
f"Regular function {func} cannot be used with GeneratorWorker, "
"use FunctionWorker instead",
)
super().__init__(SignalsClass=SignalsClass)
self._gen = func(*args, **kwargs)
self._incoming_value: _S | None = None
self._pause_requested = False
self._resume_requested = False
self._paused = False
# polling interval: ONLY relevant if the user paused a running worker
self._pause_interval = 0.01
self.pbar = None
def work(self) -> _R | None | Exception:
"""Core event loop that calls the original function.
Enters a continual loop, yielding and returning from the original
function. Checks for various events (quit, pause, resume, etc...).
(To clarify: we are creating a rudimentary event loop here because
there IS NO Qt event loop running in the other thread to hook into)
"""
while True:
if self.abort_requested:
self.aborted.emit()
break
if self._paused:
if self._resume_requested:
self._paused = False
self._resume_requested = False
self.resumed.emit()
else:
time.sleep(self._pause_interval)
continue
elif self._pause_requested:
self._paused = True
self._pause_requested = False
self.paused.emit()
continue
try:
_input = self._next_value()
output = self._gen.send(_input)
self.yielded.emit(output)
except StopIteration as exc:
return exc.value
except RuntimeError as exc:
# The worker has probably been deleted. warning will be
# emitted in `WorkerBase.run`
return exc
return None
def send(self, value: _S):
"""Send a value into the function (if a generator was used)."""
self._incoming_value = value
def _next_value(self) -> _S | None:
out = None
if self._incoming_value is not None:
out = self._incoming_value
self._incoming_value = None
return out
@property
def is_paused(self) -> bool:
"""Whether the worker is currently paused."""
return self._paused
def toggle_pause(self) -> None:
"""Request to pause the worker if playing or resume if paused."""
if self.is_paused:
self._resume_requested = True
else:
self._pause_requested = True
def pause(self) -> None:
"""Request to pause the worker."""
if not self.is_paused:
self._pause_requested = True
def resume(self) -> None:
"""Send a request to resume the worker."""
if self.is_paused:
self._resume_requested = True
#############################################################################
# convenience functions for creating Worker instances
@overload
def create_worker(
func: Callable[_P, Generator[_Y, _S, _R]],
*args,
_start_thread: bool | None = None,
_connect: dict[str, Callable | Sequence[Callable]] | None = None,
_worker_class: type[GeneratorWorker] | type[FunctionWorker] | None = None,
_ignore_errors: bool = False,
**kwargs,
) -> GeneratorWorker[_Y, _S, _R]:
...
@overload
def create_worker(
func: Callable[_P, _R],
*args,
_start_thread: bool | None = None,
_connect: dict[str, Callable | Sequence[Callable]] | None = None,
_worker_class: type[GeneratorWorker] | type[FunctionWorker] | None = None,
_ignore_errors: bool = False,
**kwargs,
) -> FunctionWorker[_R]:
...
def create_worker(
func: Callable,
*args,
_start_thread: bool | None = None,
_connect: dict[str, Callable | Sequence[Callable]] | None = None,
_worker_class: type[GeneratorWorker] | type[FunctionWorker] | None = None,
_ignore_errors: bool = False,
**kwargs,
) -> FunctionWorker | GeneratorWorker:
"""Convenience function to start a function in another thread.
By default, uses `FunctionWorker` for functions and `GeneratorWorker` for
generators, but a custom `WorkerBase` subclass may be provided. If so, it must be a
subclass of `WorkerBase`, which defines a standard set of signals and a run method.
Parameters
----------
func : Callable
The function to call in another thread.
_start_thread : bool
Whether to immediaetly start the thread. If False, the returned worker
must be manually started with `worker.start()`. by default it will be
`False` if the `_connect` argument is `None`, otherwise `True`.
_connect : Dict[str, Union[Callable, Sequence]], optional
A mapping of `"signal_name"` -> `callable` or list of `callable`:
callback functions to connect to the various signals offered by the
worker class. by default `None`
_worker_class : type of `GeneratorWorker` or `FunctionWorker`, optional
The [`WorkerBase`][superqt.utils.WorkerBase] to instantiate, by default
[`FunctionWorker`][superqt.utils.FunctionWorker] will be used if `func` is a
regular function, and [`GeneratorWorker`][superqt.utils.GeneratorWorker] will be
used if it is a generator.
_ignore_errors : bool
If `False` (the default), errors raised in the other thread will be
reraised in the main thread (makes debugging significantly easier).
*args
will be passed to `func`
**kwargs
will be passed to `func`
Returns
-------
worker : WorkerBase
An instantiated worker. If `_start_thread` was `False`, the worker
will have a `.start()` method that can be used to start the thread.
Raises
------
TypeError
If a worker_class is provided that is not a subclass of WorkerBase.
TypeError
If _connect is provided and is not a dict of `{str: callable}`
Examples
--------
```python
def long_function(duration):
import time
time.sleep(duration)
worker = create_worker(long_function, 10)
```
"""
worker: FunctionWorker | GeneratorWorker
if not _worker_class:
if inspect.isgeneratorfunction(func):
_worker_class = GeneratorWorker
else:
_worker_class = FunctionWorker
if not inspect.isclass(_worker_class) and issubclass(_worker_class, WorkerBase):
raise TypeError(f"Worker {_worker_class} must be a subclass of WorkerBase")
worker = _worker_class(func, *args, **kwargs)
if _connect is not None:
if not isinstance(_connect, dict):
raise TypeError("The '_connect' argument must be a dict")
if _start_thread is None:
_start_thread = True
for key, val in _connect.items():
_val = val if isinstance(val, (tuple, list)) else [val]
for v in _val:
if not callable(v):
raise TypeError(
f"_connect[{key!r}] must be a function or sequence of functions"
)
getattr(worker, key).connect(v)
# if the user has not provided a default connection for the "errored"
# signal... and they have not explicitly set `ignore_errors=True`
# Then rereaise any errors from the thread.
if not _ignore_errors and not (_connect or {}).get("errored", False):
def reraise(e):
raise e
worker.errored.connect(reraise)
if _start_thread:
worker.start()
return worker
@overload
def thread_worker(
function: Callable[_P, Generator[_Y, _S, _R]],
start_thread: bool | None = None,
connect: dict[str, Callable | Sequence[Callable]] | None = None,
worker_class: type[WorkerBase] | None = None,
ignore_errors: bool = False,
) -> Callable[_P, GeneratorWorker[_Y, _S, _R]]:
...
@overload
def thread_worker(
function: Callable[_P, _R],
start_thread: bool | None = None,
connect: dict[str, Callable | Sequence[Callable]] | None = None,
worker_class: type[WorkerBase] | None = None,
ignore_errors: bool = False,
) -> Callable[_P, FunctionWorker[_R]]:
...
@overload
def thread_worker(
function: Literal[None] = None,
start_thread: bool | None = None,
connect: dict[str, Callable | Sequence[Callable]] | None = None,
worker_class: type[WorkerBase] | None = None,
ignore_errors: bool = False,
) -> Callable[[Callable], Callable[_P, FunctionWorker | GeneratorWorker]]:
...
def thread_worker(
function: Callable | None = None,
start_thread: bool | None = None,
connect: dict[str, Callable | Sequence[Callable]] | None = None,
worker_class: type[WorkerBase] | None = None,
ignore_errors: bool = False,
):
"""Decorator that runs a function in a separate thread when called.
When called, the decorated function returns a
[`WorkerBase`][superqt.utils.WorkerBase]. See
[`create_worker`][superqt.utils.create_worker] for additional keyword arguments that
can be used
when calling the function.
The returned worker will have these signals:
- **started**: emitted when the work is started
- **finished**: emitted when the work is finished
- **returned**: emitted with return value
- **errored**: emitted with error object on Exception
It will also have a `worker.start()` method that can be used to start
execution of the function in another thread. (useful if you need to connect
callbacks to signals prior to execution)
If the decorated function is a generator, the returned worker will also
provide these signals:
- **yielded**: emitted with yielded values
- **paused**: emitted when a running job has successfully paused
- **resumed**: emitted when a paused job has successfully resumed
- **aborted**: emitted when a running job is successfully aborted
And these methods:
- **quit**: ask the thread to quit
- **toggle_paused**: toggle the running state of the thread.
- **send**: send a value into the generator. (This requires that your
decorator function uses the `value = yield` syntax)
Parameters
----------
function : callable
Function to call in another thread. For communication between threads
may be a generator function.
start_thread : bool
Whether to immediaetly start the thread. If False, the returned worker
must be manually started with `worker.start()`. by default it will be
`False` if the `_connect` argument is `None`, otherwise `True`.
connect : Dict[str, Union[Callable, Sequence]]
A mapping of `"signal_name"` -> `callable` or list of `callable`:
callback functions to connect to the various signals offered by the
worker class. by default None
worker_class : Type[WorkerBase]
The [`WorkerBase`][superqt.utils.WorkerBase] to instantiate, by default
[`FunctionWorker`][superqt.utils.FunctionWorker] will be used if `func` is a
regular function, and [`GeneratorWorker`][superqt.utils.GeneratorWorker] will be
used if it is a generator.
ignore_errors : bool
If `False` (the default), errors raised in the other thread will be
reraised in the main thread (makes debugging significantly easier).
Returns
-------
callable
function that creates a worker, puts it in a new thread and returns
the worker instance.
Examples
--------
```python
@thread_worker
def long_function(start, end):
# do work, periodically yielding
i = start
while i <= end:
time.sleep(0.1)
yield i
# do teardown
return 'anything'
# call the function to start running in another thread.
worker = long_function()
# connect signals here if desired... or they may be added using the
# `connect` argument in the `@thread_worker` decorator... in which
# case the worker will start immediately when long_function() is called
worker.start()
```
"""
def _inner(func):
@wraps(func)
def worker_function(*args, **kwargs):
# decorator kwargs can be overridden at call time by using the
# underscore-prefixed version of the kwarg.
kwargs["_start_thread"] = kwargs.get("_start_thread", start_thread)
kwargs["_connect"] = kwargs.get("_connect", connect)
kwargs["_worker_class"] = kwargs.get("_worker_class", worker_class)
kwargs["_ignore_errors"] = kwargs.get("_ignore_errors", ignore_errors)
return create_worker(
func,
*args,
**kwargs,
)
return worker_function
return _inner if function is None else _inner(function)
############################################################################
# This is a variant on the above pattern, it uses QThread instead of Qrunnable
# see https://doc.qt.io/qt-5/threads-technologies.html#comparison-of-solutions
# (it appears from that table that QRunnable cannot emit or receive signals,
# but we circumvent that here with our WorkerBase class that also inherits from
# QObject... providing signals/slots).
#
# A benefit of the QRunnable pattern is that Qt manages the threads for you,
# in the QThreadPool.globalInstance() ... making it easier to reuse threads,
# and reduce overhead.
#
# However, a disadvantage is that you have no access to (and therefore less
# control over) the QThread itself. See for example all of the methods
# provided on the QThread object: https://doc.qt.io/qt-5/qthread.html
if TYPE_CHECKING:
class WorkerProtocol(QObject):
finished: Signal
def work(self) -> None:
...
def new_worker_qthread(
Worker: type[WorkerProtocol],
*args,
_start_thread: bool = False,
_connect: dict[str, Callable] | None = None,
**kwargs,
):
"""Convenience function to start a worker in a `QThread`.
thread, not as the actual code or object that runs in that
thread. The QThread object is created on the main thread and lives there.
Worker objects which derive from QObject are the things that actually do
the work. They can be moved to a QThread as is done here.
??? "Mostly ignorable detail"
While the signals/slots syntax of the worker looks very similar to
standard "single-threaded" signals & slots, note that inter-thread
signals and slots (automatically) use an event-based QueuedConnection, while
intra-thread signals use a DirectConnection. See [Signals and Slots Across
Threads](https://doc.qt.io/qt-5/threads-qobject.html#signals-and-slots-across-threads>)
Parameters
----------
Worker : QObject
QObject type that implements a `work()` method. The Worker should also
emit a finished signal when the work is done.
_start_thread : bool
If True, thread will be started immediately, otherwise, thread must
be manually started with thread.start().
_connect : dict
Optional dictionary of {signal: function} to connect to the new worker.
for instance: _connect = {'incremented': myfunc} will result in:
worker.incremented.connect(myfunc)
*args
will be passed to the Worker class on instantiation.
**kwargs
will be passed to the Worker class on instantiation.
Returns
-------
worker : WorkerBase
The created worker.
thread : QThread
The thread on which the worker is running.
Examples
--------
Create some QObject that has a long-running work method:
```python
class Worker(QObject):
finished = Signal()
increment = Signal(int)
def __init__(self, argument):
super().__init__()
self.argument = argument
@Slot()
def work(self):
# some long running task...
import time
for i in range(10):
time.sleep(1)
self.increment.emit(i)
self.finished.emit()
worker, thread = new_worker_qthread(
Worker,
'argument',
_start_thread=True,
_connect={'increment': print},
)
```
"""
if _connect and not isinstance(_connect, dict):
raise TypeError("_connect parameter must be a dict")
thread = QThread()
worker = Worker(*args, **kwargs)
worker.moveToThread(thread)
thread.started.connect(worker.work)
worker.finished.connect(thread.quit)
worker.finished.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
if _connect:
[getattr(worker, key).connect(val) for key, val in _connect.items()]
if _start_thread:
thread.start() # sometimes need to connect stuff before starting
return worker, thread

View File

@@ -0,0 +1,436 @@
"""Adapted for python from the KDToolBox.
https://github.com/KDAB/KDToolBox/tree/master/qt/KDSignalThrottler
MIT License
Copyright (C) 2019-2022 Klarälvdalens Datakonsult AB, a KDAB Group company,
info@kdab.com
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""
from __future__ import annotations
from concurrent.futures import Future
from enum import IntFlag, auto
from functools import wraps
from typing import TYPE_CHECKING, Callable, Generic, TypeVar, overload
from weakref import WeakKeyDictionary
from qtpy.QtCore import QObject, Qt, QTimer, Signal
from ._util import get_max_args
if TYPE_CHECKING:
from typing_extensions import ParamSpec
P = ParamSpec("P")
# maintain runtime compatibility with older typing_extensions
else:
try:
from typing_extensions import ParamSpec
P = ParamSpec("P")
except ImportError:
P = TypeVar("P")
R = TypeVar("R")
class Kind(IntFlag):
Throttler = auto()
Debouncer = auto()
class EmissionPolicy(IntFlag):
Trailing = auto()
Leading = auto()
class GenericSignalThrottler(QObject):
triggered = Signal()
timeoutChanged = Signal(int)
timerTypeChanged = Signal(Qt.TimerType)
def __init__(
self,
kind: Kind,
emissionPolicy: EmissionPolicy,
parent: QObject | None = None,
) -> None:
super().__init__(parent)
self._kind = kind
self._emissionPolicy = emissionPolicy
self._hasPendingEmission = False
self._timer = QTimer(parent=self)
self._timer.setSingleShot(True)
self._timer.setTimerType(Qt.TimerType.PreciseTimer)
self._timer.timeout.connect(self._maybeEmitTriggered)
def kind(self) -> Kind:
"""Return the kind of throttler (throttler or debouncer)."""
return self._kind
def emissionPolicy(self) -> EmissionPolicy:
"""Return the emission policy (trailing or leading)."""
return self._emissionPolicy
def timeout(self) -> int:
"""Return current timeout in milliseconds."""
return self._timer.interval()
def setTimeout(self, timeout: int) -> None:
"""Set timeout in milliseconds."""
if self._timer.interval() != timeout:
self._timer.setInterval(timeout)
self.timeoutChanged.emit(timeout)
def timerType(self) -> Qt.TimerType:
"""Return current `Qt.TimerType`."""
return self._timer.timerType()
def setTimerType(self, timerType: Qt.TimerType) -> None:
"""Set current Qt.TimerType."""
if self._timer.timerType() != timerType:
self._timer.setTimerType(timerType)
self.timerTypeChanged.emit(timerType)
def throttle(self) -> None:
"""Emit triggered if not running, then start timer."""
# public slot
self._hasPendingEmission = True
# Emit only if we haven't emitted already. We know if that's
# the case by checking if the timer is running.
if (
self._emissionPolicy is EmissionPolicy.Leading
and not self._timer.isActive()
):
self._emitTriggered()
# The timer is started in all cases. If we got a signal, and we're Leading,
# and we did emit because of that, then we don't re-emit when the timer fires
# (unless we get ANOTHER signal).
if self._kind is Kind.Throttler: # sourcery skip: merge-duplicate-blocks
if not self._timer.isActive():
self._timer.start() # actual start, not restart
elif self._kind is Kind.Debouncer:
self._timer.start() # restart
def cancel(self) -> None:
"""Cancel any pending emissions."""
self._hasPendingEmission = False
def flush(self) -> None:
"""Force emission of any pending emissions."""
self._maybeEmitTriggered()
def _emitTriggered(self) -> None:
self._hasPendingEmission = False
self.triggered.emit()
self._timer.start()
def _maybeEmitTriggered(self) -> None:
if self._hasPendingEmission:
self._emitTriggered()
Kind = Kind
EmissionPolicy = EmissionPolicy
# ### Convenience classes ###
class QSignalThrottler(GenericSignalThrottler):
"""A Signal Throttler.
This object's `triggered` signal will emit at most once per timeout
(set with setTimeout()).
"""
def __init__(
self,
policy: EmissionPolicy = EmissionPolicy.Leading,
parent: QObject | None = None,
) -> None:
super().__init__(Kind.Throttler, policy, parent)
class QSignalDebouncer(GenericSignalThrottler):
"""A Signal Debouncer.
This object's `triggered` signal will not be emitted until `self.timeout()`
milliseconds have elapsed since the last time `triggered` was emitted.
"""
def __init__(
self,
policy: EmissionPolicy = EmissionPolicy.Trailing,
parent: QObject | None = None,
) -> None:
super().__init__(Kind.Debouncer, policy, parent)
# below here part is unique to superqt (not from KD)
class ThrottledCallable(GenericSignalThrottler, Generic[P, R]):
def __init__(
self,
func: Callable[P, R],
kind: Kind,
emissionPolicy: EmissionPolicy,
parent: QObject | None = None,
) -> None:
super().__init__(kind, emissionPolicy, parent)
self._future: Future[R] = Future()
if isinstance(func, staticmethod):
self._func = func.__func__
else:
self._func = func
self.__wrapped__ = func
self._args: tuple = ()
self._kwargs: dict = {}
self.triggered.connect(self._set_future_result)
self._name = None
self._obj_dkt = 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 = get_max_args(self._func)
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 isinstance(self.__wrapped__, staticmethod):
self._name = name
def _get_throttler(self, instance, owner, parent, obj):
throttler = ThrottledCallable(
self.__wrapped__.__get__(instance, owner),
self._kind,
self._emissionPolicy,
parent=parent,
)
throttler.setTimerType(self.timerType())
throttler.setTimeout(self.timeout())
try:
setattr(
obj,
self._name,
throttler,
)
except AttributeError:
try:
self._obj_dkt[obj] = throttler
except TypeError as e:
raise TypeError(
"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)."
) 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)
@overload
def qthrottled(
func: Callable[P, R],
timeout: int = 100,
leading: bool = True,
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
parent: QObject | None = None,
) -> ThrottledCallable[P, R]:
...
@overload
def qthrottled(
func: None = ...,
timeout: int = 100,
leading: bool = True,
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
parent: QObject | None = None,
) -> Callable[[Callable[P, R]], ThrottledCallable[P, R]]:
...
def qthrottled(
func: Callable[P, R] | None = None,
timeout: int = 100,
leading: bool = True,
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
parent: QObject | None = None,
) -> ThrottledCallable[P, R] | Callable[[Callable[P, R]], ThrottledCallable[P, R]]:
"""Creates a throttled function that invokes func at most once per timeout.
The throttled function comes with a `cancel` method to cancel delayed func
invocations and a `flush` method to immediately invoke them. Options
to indicate whether func should be invoked on the leading and/or trailing
edge of the wait timeout. The func is invoked with the last arguments provided
to the throttled function. Subsequent calls to the throttled function return
the result of the last func invocation.
This decorator may be used with or without parameters.
Parameters
----------
func : Callable
A function to throttle
timeout : int
Timeout in milliseconds to wait before allowing another call, by default 100
leading : bool
Whether to invoke the function on the leading edge of the wait timer,
by default True
timer_type : Qt.TimerType
The timer type. by default `Qt.TimerType.PreciseTimer`
One of:
- `Qt.PreciseTimer`: Precise timers try to keep millisecond accuracy
- `Qt.CoarseTimer`: Coarse timers try to keep accuracy within 5% of the
desired interval
- `Qt.VeryCoarseTimer`: Very coarse timers only keep full second accuracy
parent: QObject or None
Parent object for timer. If using qthrottled as function it may be usefull
for cleaning data
"""
return _make_decorator(func, timeout, leading, timer_type, Kind.Throttler, parent)
@overload
def qdebounced(
func: Callable[P, R],
timeout: int = 100,
leading: bool = False,
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
parent: QObject | None = None,
) -> ThrottledCallable[P, R]:
...
@overload
def qdebounced(
func: None = ...,
timeout: int = 100,
leading: bool = False,
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
parent: QObject | None = None,
) -> Callable[[Callable[P, R]], ThrottledCallable[P, R]]:
...
def qdebounced(
func: Callable[P, R] | None = None,
timeout: int = 100,
leading: bool = False,
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
parent: QObject | None = None,
) -> ThrottledCallable[P, R] | Callable[[Callable[P, R]], ThrottledCallable[P, R]]:
"""Creates a debounced function that delays invoking `func`.
`func` will not be invoked until `timeout` ms have elapsed since the last time
the debounced function was invoked.
The debounced function comes with a `cancel` method to cancel delayed func
invocations and a `flush` method to immediately invoke them. Options
indicate whether func should be invoked on the leading and/or trailing edge
of the wait timeout. The func is invoked with the *last* arguments provided to
the debounced function. Subsequent calls to the debounced function return the
result of the last `func` invocation.
This decorator may be used with or without parameters.
Parameters
----------
func : Callable
A function to throttle
timeout : int
Timeout in milliseconds to wait before allowing another call, by default 100
leading : bool
Whether to invoke the function on the leading edge of the wait timer,
by default False
timer_type : Qt.TimerType
The timer type. by default `Qt.TimerType.PreciseTimer`
One of:
- `Qt.PreciseTimer`: Precise timers try to keep millisecond accuracy
- `Qt.CoarseTimer`: Coarse timers try to keep accuracy within 5% of the
desired interval
- `Qt.VeryCoarseTimer`: Very coarse timers only keep full second accuracy
parent: QObject or None
Parent object for timer. If using qthrottled as function it may be usefull
for cleaning data
"""
return _make_decorator(func, timeout, leading, timer_type, Kind.Debouncer, parent)
def _make_decorator(
func: Callable[P, R] | None,
timeout: int,
leading: bool,
timer_type: Qt.TimerType,
kind: Kind,
parent: QObject | None = None,
) -> ThrottledCallable[P, R] | Callable[[Callable[P, R]], ThrottledCallable[P, R]]:
def deco(func: Callable[P, R]) -> ThrottledCallable[P, R]:
nonlocal parent
instance: object | None = getattr(func, "__self__", None)
if isinstance(instance, QObject) and parent is None:
parent = instance
policy = EmissionPolicy.Leading if leading else EmissionPolicy.Trailing
obj = ThrottledCallable(func, kind, policy, parent=parent)
obj.setTimerType(timer_type)
obj.setTimeout(timeout)
return wraps(func)(obj)
return deco(func) if func is not None else deco

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