Compare commits

...

63 Commits

Author SHA1 Message Date
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
Talley Lambert
7e64be7d9d rename to superqt (#3) 2021-06-26 16:29:59 -04:00
Talley Lambert
eeb4413678 Update README.md 2021-06-03 07:31:02 -04:00
Talley Lambert
f1cfe11c1a Merge pull request #1 from tlambert-forks/intspin 2021-06-02 22:21:55 -04:00
Talley Lambert
5a55a74670 add spin 2021-06-02 21:11:42 -04:00
Talley Lambert
27bcfc4c8e update readme 2021-06-02 20:36:23 -04:00
Talley Lambert
40b34213fb Move to to qwidgets
commit 466fc7c19ace1343d23739e4058758cd21328511
Author: Talley Lambert <talley.lambert@gmail.com>
Date:   Wed Jun 2 20:22:38 2021 -0400

    add deploy cond

commit e9965e71490689935b61099225acc7f3bf5c2d48
Author: Talley Lambert <talley.lambert@gmail.com>
Date:   Wed Jun 2 20:20:45 2021 -0400

    more precommit

commit b39150b16d7d64a5530ec9a0e29e673e2b6ed0a4
Author: Talley Lambert <talley.lambert@gmail.com>
Date:   Wed Jun 2 19:52:42 2021 -0400

    updating precommit

commit d5018b38e7bc59f81cc161cca06fae829e493e3c
Author: Talley Lambert <talley.lambert@gmail.com>
Date:   Wed Jun 2 19:42:32 2021 -0400

    big reorg
2021-06-02 20:25:40 -04:00
Talley Lambert
297838e895 cov changes (#6)
* cov changes

* update yml

* undo test slider
2021-06-02 17:44:12 -04:00
Talley Lambert
15e3af4985 Generic slider (#14)
* good coverage

* merged classes

* working cross platform

* range slider tests working too

* many more fixes and unification

* type

* reorg

* working labels, better typing

* tests

* legacy compat

* update envlist

* skip mouse press not on mac

* fix getStyleOption

* fix again

* skip hover

* remove print

* add module docstring
2021-06-02 17:23:05 -04:00
pre-commit-ci[bot]
b12e5471a0 [pre-commit.ci] pre-commit autoupdate (#11)
updates:
- [github.com/pre-commit/pre-commit-hooks: v3.4.0 → v4.0.1](https://github.com/pre-commit/pre-commit-hooks/compare/v3.4.0...v4.0.1)
- [github.com/asottile/pyupgrade: v2.15.0 → v2.19.0](https://github.com/asottile/pyupgrade/compare/v2.15.0...v2.19.0)
- [github.com/psf/black: 21.5b1 → 21.5b2](https://github.com/psf/black/compare/21.5b1...21.5b2)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2021-05-31 13:37:01 -04:00
104 changed files with 8153 additions and 1524 deletions

View File

@@ -47,6 +47,16 @@ jobs:
- python-version: 3.9
platform: macos-11.0
backend: pyqt6
# py3.10
- python-version: "3.10"
platform: ubuntu-latest
backend: pyside6
- python-version: "3.10"
platform: ubuntu-latest
backend: pyqt5
- python-version: "3.10"
platform: ubuntu-latest
backend: pyqt6
# big sur, 3.9
- python-version: 3.9
@@ -60,12 +70,18 @@ jobs:
- 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
# legacy Qt
- python-version: 3.7
platform: ubuntu-latest
backend: pyqt512
- python-version: 3.7
platform: ubuntu-latest
backend: pyqt513
- python-version: 3.7
platform: ubuntu-latest
backend: pyqt514
steps:
- uses: actions/checkout@v2
@@ -74,12 +90,8 @@ jobs:
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
- name: Linux opengl
if: runner.os == 'Linux' && ( matrix.backend == 'pyside6' || matrix.backend == 'pyqt6' )
run: sudo apt-get install -y libopengl0 libegl1-mesa libxcb-xinput0
@@ -87,10 +99,13 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install setuptools tox tox-gh-actions
python -m pip install setuptools tox tox-gh-actions
- name: Test with tox
run: tox
uses: GabrielBB/xvfb-action@v1
timeout-minutes: 3
with:
run: python -m tox
env:
PLATFORM: ${{ matrix.platform }}
BACKEND: ${{ matrix.backend }}
@@ -102,15 +117,15 @@ jobs:
if: matrix.screenshot
run: pip install . ${{ matrix.backend }}
- name: Screenshots
- name: Screenshots (Linux)
if: runner.os == 'Linux' && matrix.screenshot
uses: GabrielBB/xvfb-action@v1
with:
run: python examples/demo_widget.py
run: python examples/demo_widget.py -snap
- name: Screenshots
- name: Screenshots (macOS/Win)
if: runner.os != 'Linux' && matrix.screenshot
run: python examples/demo_widget.py
run: python examples/demo_widget.py -snap
- uses: actions/upload-artifact@v2
if: matrix.screenshot
@@ -118,14 +133,79 @@ jobs:
name: screenshots ${{ runner.os }}
path: screenshots
test_old_qtpy:
name: qtpy minreq
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: tlambert03/setup-qt-libs@v1
- uses: actions/setup-python@v2
with:
python-version: '3.8'
- name: install
run: |
python -m pip install -U pip
python -m pip install -e .[testing,pyqt5]
python -m pip install qtpy==1.1.0 typing-extensions==3.10.0.0
- name: Test napari magicgui
uses: GabrielBB/xvfb-action@v1
with:
run: python -m pytest --color=yes
test_napari:
name: napari tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 0
path: superqt
- uses: actions/checkout@v2
with:
repository: napari/napari
path: napari
fetch-depth: 2
- uses: tlambert03/setup-qt-libs@v1
- uses: actions/setup-python@v2
with:
python-version: '3.9'
- name: install
run: |
python -m pip install -U pip
python -m pip install -e ./napari[testing,pyqt5]
python -m pip install -e ./superqt
- name: Test napari magicgui
uses: GabrielBB/xvfb-action@v1
with:
run: python -m pytest --color=yes napari/napari/_qt
check_manifest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: "3.x"
- name: Check manifest
run: |
python -m pip install --upgrade pip
pip install check-manifest
check-manifest
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]
needs: [test, check_manifest]
if: ${{ github.repository == 'napari/superqt' && contains(github.ref, 'tags') }}
runs-on: ubuntu-latest
if: contains(github.ref, 'tags')
steps:
- uses: actions/checkout@v2
- name: Set up Python
@@ -135,12 +215,17 @@ jobs:
- 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=napari
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"]}}

5
.gitignore vendored
View File

@@ -9,6 +9,7 @@ __pycache__/
# Distribution / packaging
.Python
env/
.venv/
build/
develop-eggs/
dist/
@@ -76,6 +77,8 @@ target/
.DS_Store
# written by setuptools_scm
*/_version.py
src/superqt/_version.py
.vscode/settings.json
screenshots
.mypy_cache

View File

@@ -1,23 +1,40 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v3.4.0
rev: v4.2.0
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/asottile/setup-cfg-fmt
rev: v1.20.1
hooks:
- id: setup-cfg-fmt
- repo: https://github.com/PyCQA/flake8
rev: 4.0.1
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
rev: 5.10.1
hooks:
- id: isort
- repo: https://github.com/asottile/pyupgrade
rev: v2.15.0
rev: v2.32.0
hooks:
- id: pyupgrade
args: [--py37-plus, --keep-runtime-typing]
- repo: https://github.com/psf/black
rev: 21.5b1
rev: 22.3.0
hooks:
- id: black
- repo: https://github.com/PyCQA/flake8
rev: 3.9.2
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v0.950
hooks:
- id: flake8
pass_filenames: true
- id: mypy
exclude: examples
stages: [manual]

159
CHANGELOG.md Normal file
View File

@@ -0,0 +1,159 @@
# Changelog
## [0.3.2](https://github.com/napari/superqt/tree/0.3.2) (2022-05-02)
[Full Changelog](https://github.com/napari/superqt/compare/v0.3.1...0.3.2)
**Implemented enhancements:**
- Add QSearchableListWidget and QSearchableComboBox widgets [\#80](https://github.com/napari/superqt/pull/80) ([Czaki](https://github.com/Czaki))
**Fixed bugs:**
- Fix crazy animation loop on Qcollapsible [\#84](https://github.com/napari/superqt/pull/84) ([tlambert03](https://github.com/tlambert03))
- Reorder label update signal [\#83](https://github.com/napari/superqt/pull/83) ([tlambert03](https://github.com/tlambert03))
- Fix height of expanded QCollapsible when child changes size [\#72](https://github.com/napari/superqt/pull/72) ([tlambert03](https://github.com/tlambert03))
**Tests & CI:**
- Fix deprecation warnings in tests [\#82](https://github.com/napari/superqt/pull/82) ([tlambert03](https://github.com/tlambert03))
## [v0.3.1](https://github.com/napari/superqt/tree/v0.3.1) (2022-03-02)
[Full Changelog](https://github.com/napari/superqt/compare/v0.3.0...v0.3.1)
**Implemented enhancements:**
- Add `signals_blocked` util [\#69](https://github.com/napari/superqt/pull/69) ([tlambert03](https://github.com/tlambert03))
**Fixed bugs:**
- put SignalInstance in TYPE\_CHECKING clause, check min requirements [\#70](https://github.com/napari/superqt/pull/70) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- Add changelog for v0.3.1 [\#71](https://github.com/napari/superqt/pull/71) ([tlambert03](https://github.com/tlambert03))
## [v0.3.0](https://github.com/napari/superqt/tree/v0.3.0) (2022-02-16)
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.5-1...v0.3.0)
**Implemented enhancements:**
- Qthrottler and debouncer [\#62](https://github.com/napari/superqt/pull/62) ([tlambert03](https://github.com/tlambert03))
- add edgeLabelMode option to QLabeledSlider [\#59](https://github.com/napari/superqt/pull/59) ([tlambert03](https://github.com/tlambert03))
**Fixed bugs:**
- Fix nested threadworker not starting [\#63](https://github.com/napari/superqt/pull/63) ([tlambert03](https://github.com/tlambert03))
- Add missing signals on proxy sliders [\#54](https://github.com/napari/superqt/pull/54) ([tlambert03](https://github.com/tlambert03))
- Ugly but functional workaround for pyside6.2.1 breakages [\#51](https://github.com/napari/superqt/pull/51) ([tlambert03](https://github.com/tlambert03))
**Tests & CI:**
- add napari test to CI [\#67](https://github.com/napari/superqt/pull/67) ([tlambert03](https://github.com/tlambert03))
- add gh-release action [\#65](https://github.com/napari/superqt/pull/65) ([tlambert03](https://github.com/tlambert03))
- fix xvfb tests [\#61](https://github.com/napari/superqt/pull/61) ([tlambert03](https://github.com/tlambert03))
**Refactors:**
- Use qtpy, deprecate superqt.qtcompat, drop support for Qt \<5.12 [\#39](https://github.com/napari/superqt/pull/39) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- Add changelog for v0.3.0 [\#68](https://github.com/napari/superqt/pull/68) ([tlambert03](https://github.com/tlambert03))
## [v0.2.5-1](https://github.com/napari/superqt/tree/v0.2.5-1) (2021-11-23)
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.5...v0.2.5-1)
**Merged pull requests:**
- typing-extensions version pinning [\#46](https://github.com/napari/superqt/pull/46) ([AhmetCanSolak](https://github.com/AhmetCanSolak))
## [v0.2.5](https://github.com/napari/superqt/tree/v0.2.5) (2021-11-22)
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.4...v0.2.5)
**Implemented enhancements:**
- add support for python 3.10 [\#42](https://github.com/napari/superqt/pull/42) ([tlambert03](https://github.com/tlambert03))
- QCollapsible for Collapsible Section Control [\#37](https://github.com/napari/superqt/pull/37) ([MosGeo](https://github.com/MosGeo))
- Threadworker [\#31](https://github.com/napari/superqt/pull/31) ([tlambert03](https://github.com/tlambert03))
- Add font icons [\#24](https://github.com/napari/superqt/pull/24) ([tlambert03](https://github.com/tlambert03))
**Fixed bugs:**
- Fix some small linting issues. [\#41](https://github.com/napari/superqt/pull/41) ([tlambert03](https://github.com/tlambert03))
- Use functools.wraps insterad of \_\_wraped\_\_ and manual proxing \_\_name\_\_ [\#29](https://github.com/napari/superqt/pull/29) ([Czaki](https://github.com/Czaki))
- Propagate function name in `ensure_main_thread` and `ensure_object_thread` [\#28](https://github.com/napari/superqt/pull/28) ([Czaki](https://github.com/Czaki))
**Tests & CI:**
- reskip test\_object\_thread\_return on ci [\#43](https://github.com/napari/superqt/pull/43) ([tlambert03](https://github.com/tlambert03))
**Refactors:**
- refactoring qtcompat [\#34](https://github.com/napari/superqt/pull/34) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- Fix-manifest, move font tests [\#44](https://github.com/napari/superqt/pull/44) ([tlambert03](https://github.com/tlambert03))
- update deploy [\#33](https://github.com/napari/superqt/pull/33) ([tlambert03](https://github.com/tlambert03))
- move to src layout [\#32](https://github.com/napari/superqt/pull/32) ([tlambert03](https://github.com/tlambert03))
## [v0.2.4](https://github.com/napari/superqt/tree/v0.2.4) (2021-09-13)
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.3...v0.2.4)
**Implemented enhancements:**
- Add type stubs for ensure\_thread decorator [\#23](https://github.com/napari/superqt/pull/23) ([tlambert03](https://github.com/tlambert03))
- Add `ensure_main_tread` and `ensure_object_thread` [\#22](https://github.com/napari/superqt/pull/22) ([Czaki](https://github.com/Czaki))
- Add QMessageHandler context manager [\#21](https://github.com/napari/superqt/pull/21) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- add changelog for 0.2.4 [\#25](https://github.com/napari/superqt/pull/25) ([tlambert03](https://github.com/tlambert03))
## [v0.2.3](https://github.com/napari/superqt/tree/v0.2.3) (2021-08-25)
[Full Changelog](https://github.com/napari/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/napari/superqt/pull/19) ([tlambert03](https://github.com/tlambert03))
## [v0.2.2](https://github.com/napari/superqt/tree/v0.2.2) (2021-08-17)
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.1...v0.2.2)
**Implemented enhancements:**
- Add QElidingLabel [\#16](https://github.com/napari/superqt/pull/16) ([tlambert03](https://github.com/tlambert03))
- Enum ComboBox implementation [\#13](https://github.com/napari/superqt/pull/13) ([Czaki](https://github.com/Czaki))
**Documentation updates:**
- fix broken link [\#18](https://github.com/napari/superqt/pull/18) ([haesleinhuepf](https://github.com/haesleinhuepf))
## [v0.2.1](https://github.com/napari/superqt/tree/v0.2.1) (2021-07-10)
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.0rc0...v0.2.1)
**Fixed bugs:**
- Fix QLabeledRangeSlider API \(fix slider proxy\) [\#10](https://github.com/napari/superqt/pull/10) ([tlambert03](https://github.com/tlambert03))
- Fix range slider with negative min range [\#9](https://github.com/napari/superqt/pull/9) ([tlambert03](https://github.com/tlambert03))
## [v0.2.0rc0](https://github.com/napari/superqt/tree/v0.2.0rc0) (2021-06-26)
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.0rc1...v0.2.0rc0)
## [v0.2.0rc1](https://github.com/napari/superqt/tree/v0.2.0rc1) (2021-06-26)
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.0...v0.2.0rc1)
\* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)*

52
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,52 @@
# Contributing to this repository
This repository seeks to accumulate Qt-based widgets for python (PyQt & PySide)
that are not provided in the native QtWidgets module.
## Clone
To get started fork this repository, and clone your fork:
```bash
# clone your fork
git clone https://github.com/<your_organization>/superqt
cd superqt
# install pre-commit hooks
pre-commit install
# install in editable mode
pip install -e .[dev]
# run tests & make sure everything is working!
pytest
```
## Targeted platforms
All widgets must be well-tested, and should work on:
- Python 3.7 and above
- PyQt5 (5.11 and above) & PyQt6
- PySide2 (5.11 and above) & PySide6
- macOS, Windows, & Linux
## Style Guide
All widgets should try to match the native Qt API as much as possible:
- Methods should use `camelCase` naming.
- Getters/setters use the `attribute()/setAttribute()` pattern.
- Private methods should use `_camelCaseNaming`.
- `__init__` methods should be like Qt constructors, meaning they often don't
include parameters for most of the widgets properties.
- When possible, widgets should inherit from the most similar native widget
available. It should strictly match the Qt API where it exists, and attempt to
cover as much of the native API as possible; this includes properties, public
functions, signals, and public slots.
## Testing
Tests can be run in the current environment with `pytest`. Or, to run tests
against all supported python & Qt versions, run `tox`.

View File

@@ -12,7 +12,7 @@ modification, are permitted provided that the following conditions are met:
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of QtRangeSlider nor the names of its
* Neither the name of superqt nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.

View File

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

271
README.md
View File

@@ -1,262 +1,47 @@
# QtRangeSlider
# ![tiny](https://user-images.githubusercontent.com/1609449/120636353-8c3f3800-c43b-11eb-8732-a14dec578897.png) superqt!
[![License](https://img.shields.io/pypi/l/QtRangeSlider.svg?color=green)](https://github.com/tlambert03/QtRangeSlider/raw/master/LICENSE)
[![PyPI](https://img.shields.io/pypi/v/QtRangeSlider.svg?color=green)](https://pypi.org/project/QtRangeSlider)
[![License](https://img.shields.io/pypi/l/superqt.svg?color=green)](https://github.com/napari/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/QtRangeSlider.svg?color=green)](https://python.org)
[![Test](https://github.com/tlambert03/QtRangeSlider/actions/workflows/test_and_deploy.yml/badge.svg)](https://github.com/tlambert03/QtRangeSlider/actions/workflows/test_and_deploy.yml)
[![codecov](https://codecov.io/gh/tlambert03/QtRangeSlider/branch/master/graph/badge.svg)](https://codecov.io/gh/tlambert03/QtRangeSlider)
Version](https://img.shields.io/pypi/pyversions/superqt.svg?color=green)](https://python.org)
[![Test](https://github.com/napari/superqt/actions/workflows/test_and_deploy.yml/badge.svg)](https://github.com/napari/superqt/actions/workflows/test_and_deploy.yml)
[![codecov](https://codecov.io/gh/napari/superqt/branch/main/graph/badge.svg?token=dcsjgl1sOi)](https://codecov.io/gh/napari/superqt)
**The missing multi-handle range slider widget for PyQt & PySide**
### "missing" widgets and components for PyQt/PySide
![slider](images/slider.png)
This repository aims to provide high-quality community-contributed Qt widgets and components for PyQt & PySide
that are not provided in the native QtWidgets module.
The goal of this package is to provide a Range Slider (a slider with 2 or more
handles) that feels as "native" as possible. Styles should match the OS by
default, and the slider should behave like a standard
[`QSlider`](https://doc.qt.io/qt-5/qslider.html)... but with multiple handles!
Components are tested on:
- `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 PyQt5, PyQt6, PySide2 and PySide6
- Supports more than 2 handles (e.g. `slider.setValue([0, 10, 60, 80])`)
- macOS, Windows, & Linux
- Python 3.7 and above
- PyQt5 (5.11 and above) & PyQt6
- PySide2 (5.11 and above) & PySide6
## Installation
## Widgets
You can install `QtRangeSlider` via pip:
Widgets include:
```sh
pip install qtrangeslider
- [Float Slider](docs/sliders.md#float-slider)
# NOTE: you must also install a Qt Backend.
# PyQt5, PySide2, PyQt6, and PySide6 are supported
# As a convenience you can install them as extras:
pip install qtrangeslider[pyqt5]
```
- [Range Slider](docs/sliders.md#range-slider) (multi-handle slider)
<img src="https://raw.githubusercontent.com/napari/superqt/main/docs/images/demo_darwin10.png" alt="range sliders" width=680>
------
## API
- [Labeled Sliders](docs/sliders.md#labeled-sliders) (sliders with linked
spinboxes)
To create a slider:
<img src="https://raw.githubusercontent.com/napari/superqt/main/docs/images/labeled_qslider.png" alt="range sliders" width=680>
```python
from qtrangeslider import QRangeSlider
<img src="https://raw.githubusercontent.com/napari/superqt/main/docs/images/labeled_range.png" alt="range sliders" width=680>
# as usual:
# you must create a QApplication before create a widget.
range_slider = QRangeSlider()
```
- Unbound Integer SpinBox (backed by python `int`)
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.)
## Contributing
### `value: Tuple[int, ...]`
We welcome contributions!
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 qtrangeslider 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 qtrangeslider 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/tlambert03/QtRangeSlider/issues
Please see the [Contributing Guide](CONTRIBUTING.md)

View File

@@ -1,14 +1,15 @@
ignore:
- qtrangeslider/_version.py
- superqt/_version.py
- '*_tests*'
coverage:
status:
project:
default:
target: auto
threshold: 1% # coverage can drop by up to 1% while still posting success
threshold: 1% # PR will fail if it drops coverage on the project by >1%
patch:
default:
target: auto
threshold: 40% # coverage can drop by up to 40% while still posting success
threshold: 40% # A given PR will fail if >40% is untested
comment:
require_changes: true # if true: only post the PR comment if coverage changes

68
docs/combobox.md Normal file
View File

@@ -0,0 +1,68 @@
# ComboBox
## Enum Combo Box
`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 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)
```
other option is to use optional `enum_class` argument of constructor and change
```python
combo = QEnumComboBox()
combo.setEnumClass(SampleEnum)
```
to
```python
combo = QEnumComboBox(enum_class=SampleEnum)
```
### Allow `None`
`QEnumComboBox` allow 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 `currentEnum` will return `None` for it.
## 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`.

86
docs/decorators.md Normal file
View File

@@ -0,0 +1,86 @@
# Decorators
## Move to thread decorators
`superqt` provides two decorators that help to ensure that given function is
running in the desired thread:
* `ensure_main_thread` - ensures that the decorated function/method runs in the main thread
* `ensure_object_thread` - ensures that a decorated bound method of a `QObject` runs in the
thread in which the instance lives ([qt
documentation](https://doc.qt.io/qt-5/threads-qobject.html#accessing-qobject-subclasses-from-other-threads)).
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.
> *Note: 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
```

0
docs/fonticon.md Normal file
View File

View File

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View File

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

View File

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

8
docs/listwidgets.md Normal file
View File

@@ -0,0 +1,8 @@
# ListWidget
## 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.
Because of implementation it does not inherit directly from `QListWidget` but satisfy it all api. The only limitation is that it cannot be used as argument of `QListWidgetItem` constructor.

237
docs/sliders.md Normal file
View File

@@ -0,0 +1,237 @@
# 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

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

13
examples/basic_float.py Normal file
View File

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

15
examples/collapsible.py Normal file
View File

@@ -0,0 +1,15 @@
"""Example for QCollapsible"""
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_()

View File

@@ -1,6 +1,7 @@
from qtrangeslider import QRangeSlider
from qtrangeslider.qtcompat import QtCore
from qtrangeslider.qtcompat import QtWidgets as QtW
from qtpy import QtCore
from qtpy import QtWidgets as QtW
from superqt import QRangeSlider
QSS = """
QSlider {
@@ -33,35 +34,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)
@@ -112,10 +115,10 @@ if __name__ == "__main__":
app = QtW.QApplication([])
demo = DemoWidget()
if "-x" in sys.argv:
app.exec_()
else:
if "-snap" in sys.argv:
import platform
QtW.QApplication.processEvents()
demo.grab().save(str(dest / f"demo_{platform.system().lower()}.png"))
else:
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,15 +1,15 @@
from qtrangeslider import QRangeSlider
from qtrangeslider._float_slider import QDoubleRangeSlider, QDoubleSlider
from qtrangeslider.qtcompat.QtCore import Qt
from qtrangeslider.qtcompat.QtWidgets import QApplication, QVBoxLayout, QWidget
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from superqt import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
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()

13
examples/generic.py Normal file
View File

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

378
examples/icon_explorer.py Normal file
View File

@@ -0,0 +1,378 @@
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("Glpyhs")
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,20 +1,16 @@
from qtrangeslider._labeled import (
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication, QHBoxLayout, QVBoxLayout, QWidget
from superqt import (
QLabeledDoubleRangeSlider,
QLabeledDoubleSlider,
QLabeledRangeSlider,
QLabeledSlider,
)
from qtrangeslider.qtcompat.QtCore import Qt
from qtrangeslider.qtcompat.QtWidgets import (
QApplication,
QHBoxLayout,
QVBoxLayout,
QWidget,
)
app = QApplication([])
ORIENTATION = Qt.Horizontal
ORIENTATION = Qt.Orientation.Horizontal
w = QWidget()
qls = QLabeledSlider(ORIENTATION)
@@ -30,16 +26,19 @@ qlds.setValue(0.5)
qlds.setSingleStep(0.1)
qlrs = QLabeledRangeSlider(ORIENTATION)
qlrs.valueChanged.connect(lambda e: print("qlrs valueChanged", e))
qlrs.valueChanged.connect(lambda e: print("QLabeledRangeSlider valueChanged", e))
qlrs.setValue((20, 60))
qldrs = QLabeledDoubleRangeSlider(ORIENTATION)
qldrs.valueChanged.connect(lambda e: print("qlrs valueChanged", e))
qldrs.setRange(0, 1)
qldrs.setSingleStep(0.01)
qldrs.setValue((0.2, 0.7))
w.setLayout(QVBoxLayout() if ORIENTATION == Qt.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 qtrangeslider import QRangeSlider
from qtrangeslider.qtcompat.QtWidgets import QApplication
from qtpy.QtWidgets import QApplication
from superqt import QRangeSlider
app = QApplication([])

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 @@
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_()

282
examples/throttler_demo.py Normal file
View File

@@ -0,0 +1,282 @@
"""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):
x = 0
L = len(v)
for p in range(L):
v[p] += 1
if v[p] > cutoff:
x = p
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_()

View File

@@ -1,3 +1,10 @@
# pyproject.toml
[build-system]
requires = ["setuptools>=45", "wheel", "setuptools_scm[toml]>=6.0"]
build-backend = "setuptools.build_meta"
[tool.setuptools_scm]
write_to = "src/superqt/_version.py"
[tool.check-manifest]
ignore = ["src/superqt/_version.py"]

View File

@@ -1,96 +0,0 @@
import math
from typing import Tuple
from ._hooked import _HookedSlider
from ._qrangeslider import QRangeSlider
from .qtcompat.QtCore import Signal
class QDoubleSlider(_HookedSlider):
valueChanged = Signal(float)
rangeChanged = Signal(float, float)
sliderMoved = Signal(float)
_multiplier = 1
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._multiplier = 10 ** 2
self.setMinimum(0)
self.setMaximum(99)
self.setSingleStep(1)
self.setPageStep(10)
super().sliderMoved.connect(
lambda e: self.sliderMoved.emit(self._post_get_hook(e))
)
def decimals(self) -> int:
"""This property holds the precision of the slider, in decimals."""
return int(math.log10(self._multiplier))
def setDecimals(self, prec: int):
"""This property holds the precision of the slider, in decimals
Sets how many decimals the slider uses for displaying and interpreting doubles.
"""
previous = self._multiplier
self._multiplier = 10 ** int(prec)
ratio = self._multiplier / previous
if ratio != 1:
self.blockSignals(True)
try:
newmin = self.minimum() * ratio
newmax = self.maximum() * ratio
newval = self._scale_value(ratio)
newstep = self.singleStep() * ratio
newpage = self.pageStep() * ratio
self.setRange(newmin, newmax)
self.setValue(newval)
self.setSingleStep(newstep)
self.setPageStep(newpage)
except OverflowError as err:
self._multiplier = previous
raise OverflowError(
f"Cannot use {prec} decimals with a range of {newmin}-"
f"{newmax}. If you need this feature, please open a feature"
" request at github."
) from err
self.blockSignals(False)
def _scale_value(self, p):
# for subclasses
return self.value() * p
def _post_get_hook(self, value: int) -> float:
return float(value / self._multiplier)
def _pre_set_hook(self, value: float) -> int:
return int(value * self._multiplier)
def sliderChange(self, change) -> None:
if change == self.SliderValueChange:
self.valueChanged.emit(self.value())
if change == self.SliderRangeChange:
self.rangeChanged.emit(self.minimum(), self.maximum())
return super().sliderChange(self.SliderChange(change))
class QDoubleRangeSlider(QRangeSlider, QDoubleSlider):
rangeChanged = Signal(float, float)
def value(self) -> Tuple[float, ...]:
"""Get current value of the widget as a tuple of integers."""
return tuple(float(i) for i in self._value)
def _min_max_bound(self, val: int) -> float:
return round(super()._min_max_bound(val), self.decimals())
def _scale_value(self, p):
# This function is called during setDecimals...
# but because QRangeSlider has a private nonQt `_value`
# we don't actually need to scale
return self._value
def setDecimals(self, prec: int):
return super().setDecimals(prec)

View File

@@ -1,42 +0,0 @@
from .qtcompat.QtWidgets import QSlider
class _HookedSlider(QSlider):
def _post_get_hook(self, value):
return value
def _pre_set_hook(self, value):
return value
def value(self):
return self._post_get_hook(super().value())
def setValue(self, value) -> None:
super().setValue(self._pre_set_hook(value))
def minimum(self):
return self._post_get_hook(super().minimum())
def setMinimum(self, minimum):
super().setMinimum(self._pre_set_hook(minimum))
def maximum(self):
return self._post_get_hook(super().maximum())
def setMaximum(self, maximum):
super().setMaximum(self._pre_set_hook(maximum))
def singleStep(self):
return self._post_get_hook(super().singleStep())
def setSingleStep(self, step):
super().setSingleStep(self._pre_set_hook(step))
def pageStep(self):
return self._post_get_hook(super().pageStep())
def setPageStep(self, step) -> None:
super().setPageStep(self._pre_set_hook(step))
def setRange(self, min: float, max: float) -> None:
super().setRange(self._pre_set_hook(min), self._pre_set_hook(max))

View File

@@ -1,585 +0,0 @@
import textwrap
from collections import abc
from typing import List, Sequence, Tuple
from ._hooked import _HookedSlider
from ._style import RangeSliderStyle, update_styles_from_stylesheet
from .qtcompat import QtGui
from .qtcompat.QtCore import (
Property,
QEvent,
QPoint,
QPointF,
QRect,
QRectF,
Qt,
Signal,
)
from .qtcompat.QtWidgets import (
QApplication,
QSlider,
QStyle,
QStyleOptionSlider,
QStylePainter,
)
ControlType = Tuple[str, int]
class QRangeSlider(_HookedSlider, QSlider):
"""MultiHandle Range Slider widget.
Same API as QSlider, but `value`, `setValue`, `sliderPosition`, and
`setSliderPosition` are all sequences of integers.
The `valueChanged` and `sliderMoved` signals also both emit a tuple of
integers.
"""
# Emitted when the slider value has changed, with the new slider values
valueChanged = 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)
_NULL_CTRL = ("None", -1)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# list of values
self._value: List[int] = [20, 80]
# list of current positions of each handle. same length as _value
# If tracking is enabled (the default) this will be identical to _value
self._position: List[int] = [20, 80]
self._pressedControl: ControlType = self._NULL_CTRL
self._hoverControl: ControlType = self._NULL_CTRL
# whether bar length is constant when dragging the bar
# if False, the bar can shorten when dragged beyond min/max
self._bar_is_rigid = True
# whether clicking on the bar moves all handles, or just the nearest handle
self._bar_moves_all = True
self._should_draw_bar = True
# for keyboard nav
self._repeatMultiplier = 1 # TODO
# for wheel nav
self._offset_accum = 0
# fraction of total range to scroll when holding Ctrl while scrolling
self._control_fraction = 0.04
# color
self._style = RangeSliderStyle()
self.setStyleSheet("")
update_styles_from_stylesheet(self)
# ############### Public API #######################
def setStyleSheet(self, styleSheet: str) -> None:
# sub-page styles render on top of the lower sliders and don't work here.
override = f"""
\n{type(self).__name__}::sub-page:horizontal {{background: none}}
\n{type(self).__name__}::sub-page:vertical {{background: none}}
"""
return super().setStyleSheet(styleSheet + override)
def value(self) -> Tuple[int, ...]:
"""Get current value of the widget as a tuple of integers."""
return tuple(self._value)
def setValue(self, val: Sequence[int]) -> None:
"""Set current value of the widget with a sequence of integers.
The number of handles will be equal to the length of the sequence
"""
if not (isinstance(val, abc.Sequence) and len(val) >= 2):
raise ValueError("value must be iterable of len >= 2")
val = [self._min_max_bound(v) for v in val]
if self._value == val and self._position == val:
return
self._value[:] = val[:]
if self._position != val:
self._position = val
if self.isSliderDown():
self.sliderMoved.emit(tuple(self._position))
self.sliderChange(QSlider.SliderValueChange)
self.valueChanged.emit(self.value())
def sliderPosition(self) -> Tuple[int, ...]:
"""Get current value of the widget as a tuple of integers.
If tracking is enabled (the default) this will be identical to value().
"""
return tuple(self._position)
def setSliderPosition(self, val: Sequence[int]) -> None:
"""Set current position of the handles with a sequence of integers.
The sequence must have the same length as `value()`.
"""
if len(val) != len(self.value()):
raise ValueError(
f"'sliderPosition' must have length of 'value()' ({len(self.value())})"
)
for i, v in enumerate(val):
self._setSliderPositionAt(i, v, _update=False)
self._updateSliderMove()
def barIsRigid(self) -> bool:
"""Whether bar length is constant when dragging the bar.
If False, the bar can shorten when dragged beyond min/max. Default is True.
"""
return self._bar_is_rigid
def setBarIsRigid(self, val: bool = True) -> None:
"""Whether bar length is constant when dragging the bar.
If False, the bar can shorten when dragged beyond min/max. Default is True.
"""
self._bar_is_rigid = bool(val)
def barMovesAllHandles(self) -> bool:
"""Whether clicking on the bar moves all handles (default), 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."""
self._bar_moves_all = bool(val)
def barIsVisible(self) -> bool:
"""Whether to show the bar between the first and last handle."""
return self._should_draw_bar
def setBarVisible(self, val: bool = True) -> None:
"""Whether to show the bar between the first and last handle."""
self._should_draw_bar = bool(val)
def hideBar(self) -> None:
self.setBarVisible(False)
def showBar(self) -> None:
self.setBarVisible(True)
# ############### Implementation Details #######################
def _setSliderPositionAt(self, index: int, pos: int, _update=True) -> None:
pos = self._min_max_bound(pos)
# prevent sliders from moving beyond their neighbors
pos = self._neighbor_bound(pos, index, self._position)
if pos == self._position[index]:
return
self._position[index] = pos
if _update:
self._updateSliderMove()
def _updateSliderMove(self):
if not self.hasTracking():
self.update()
if self.isSliderDown():
self.sliderMoved.emit(tuple(self._position))
if self.hasTracking():
self.triggerAction(QSlider.SliderMove)
def _offsetAllPositions(self, offset: int, ref=None) -> None:
if ref is None:
ref = self._position
if self._bar_is_rigid:
# NOTE: This assumes monotonically increasing slider positions
if offset > 0 and ref[-1] + offset > self.maximum():
offset = self.maximum() - ref[-1]
elif ref[0] + offset < self.minimum():
offset = self.minimum() - ref[0]
self.setSliderPosition([i + offset for i in ref])
def _spreadAllPositions(self, shrink=False, gain=1.1, ref=None) -> None:
if ref is None:
ref = self._position
# if self._bar_is_rigid: # TODO
if shrink:
gain = 1 / gain
center = abs(ref[-1] + ref[0]) / 2
self.setSliderPosition([((i - center) * gain) + center for i in ref])
def _getStyleOption(self) -> QStyleOptionSlider:
opt = QStyleOptionSlider()
self.initStyleOption(opt)
opt.sliderValue = 0
opt.sliderPosition = 0
return opt
def _getBarColor(self):
return self._style.brush(self._getStyleOption())
def _setBarColor(self, color):
self._style.brush_active = color
barColor = Property(QtGui.QBrush, _getBarColor, _setBarColor)
def _drawBar(self, painter: QStylePainter, opt: QStyleOptionSlider):
brush = self._style.brush(opt)
r_bar = self._barRect(opt)
if isinstance(brush, QtGui.QGradient):
brush.setStart(r_bar.topLeft())
brush.setFinalStop(r_bar.bottomRight())
painter.setPen(self._style.pen(opt))
painter.setBrush(brush)
painter.drawRect(r_bar)
def paintEvent(self, a0: QtGui.QPaintEvent) -> None:
"""Paint the slider."""
# initialize painter and options
painter = QStylePainter(self)
opt = self._getStyleOption()
# draw groove and ticks
opt.subControls = QStyle.SC_SliderGroove | QStyle.SC_SliderTickmarks
painter.drawComplexControl(QStyle.CC_Slider, opt)
if self._should_draw_bar:
self._drawBar(painter, opt)
# draw handles
opt.subControls = QStyle.SC_SliderHandle
hidx = -1
pidx = -1
if self._pressedControl[0] == "handle":
pidx = self._pressedControl[1]
elif self._hoverControl[0] == "handle":
hidx = self._hoverControl[1]
for idx, pos in enumerate(self._position):
opt.sliderPosition = self._pre_set_hook(pos)
if idx == pidx: # make pressed handles appear sunken
opt.state |= QStyle.State_Sunken
else:
opt.state = opt.state & ~QStyle.State_Sunken
if idx == hidx:
opt.activeSubControls = QStyle.SC_SliderHandle
else:
opt.activeSubControls = QStyle.SC_None
painter.drawComplexControl(QStyle.CC_Slider, opt)
def event(self, ev: QEvent) -> bool:
if ev.type() == QEvent.WindowActivate:
self.update()
if ev.type() == QEvent.StyleChange:
update_styles_from_stylesheet(self)
if ev.type() in (QEvent.HoverEnter, QEvent.HoverLeave, QEvent.HoverMove):
old_hover = self._hoverControl
self._hoverControl = self._getControlAtPos(ev.pos())
if self._hoverControl != old_hover:
self.update() # TODO: restrict to the rect of old_hover
return super().event(ev)
def mousePressEvent(self, ev: QtGui.QMouseEvent) -> None:
if self.minimum() == self.maximum() or ev.buttons() ^ ev.button():
ev.ignore()
return
ev.accept()
# FIXME: why not working on other styles?
# set_buttons = self.style().styleHint(QStyle.SH_Slider_AbsoluteSetButtons)
set_buttons = Qt.LeftButton | Qt.MiddleButton
# If the mouse button used is allowed to set the value
if ev.buttons() & set_buttons == ev.button():
opt = self._getStyleOption()
self._pressedControl = self._getControlAtPos(ev.pos(), opt, True)
if self._pressedControl[0] == "handle":
offset = self._handle_offset(opt)
new_pos = self._pixelPosToRangeValue(self._pick(ev.pos() - offset))
self._setSliderPositionAt(self._pressedControl[1], new_pos)
self.triggerAction(QSlider.SliderMove)
self.setRepeatAction(QSlider.SliderNoAction)
self.update()
if self._pressedControl[0] == "handle":
self.setRepeatAction(QSlider.SliderNoAction) # why again?
sr = self._handleRects(opt, self._pressedControl[1])
self._clickOffset = self._pick(ev.pos() - sr.topLeft())
self.update()
self.setSliderDown(True)
elif self._pressedControl[0] == "bar":
self.setRepeatAction(QSlider.SliderNoAction) # why again?
self._clickOffset = self._pixelPosToRangeValue(self._pick(ev.pos()))
self._sldPosAtPress = tuple(self._position)
self.update()
self.setSliderDown(True)
def mouseMoveEvent(self, ev: QtGui.QMouseEvent) -> None:
# TODO: add pixelMetric(QStyle::PM_MaximumDragDistance, &opt, this);
if self._pressedControl[0] == "handle":
ev.accept()
new = self._pixelPosToRangeValue(self._pick(ev.pos()) - self._clickOffset)
self._setSliderPositionAt(self._pressedControl[1], new)
elif self._pressedControl[0] == "bar":
ev.accept()
delta = self._clickOffset - self._pixelPosToRangeValue(self._pick(ev.pos()))
self._offsetAllPositions(-delta, self._sldPosAtPress)
else:
ev.ignore()
return
def mouseReleaseEvent(self, ev: QtGui.QMouseEvent) -> None:
if self._pressedControl[0] == "None" or ev.buttons():
ev.ignore()
return
ev.accept()
old_pressed = self._pressedControl
self._pressedControl = self._NULL_CTRL
self.setRepeatAction(QSlider.SliderNoAction)
if old_pressed[0] in ("handle", "bar"):
self.setSliderDown(False)
self.update() # TODO: restrict to the rect of old_pressed
def triggerAction(self, action: QSlider.SliderAction) -> None:
super().triggerAction(action) # TODO: probably need to override.
self.setValue(self._position)
def setRange(self, min: int, max: int) -> None:
super().setRange(min, max)
self.setValue(self._value) # re-bound
def _handleRects(
self, opt: QStyleOptionSlider = None, handle_index: int = None
) -> QRect:
"""Return the QRect for all handles."""
if opt is None:
opt = self._getStyleOption()
style = self.style().proxy()
if handle_index is not None: # get specific handle rect
opt.sliderPosition = self._pre_set_hook(self._position[handle_index])
return style.subControlRect(
QStyle.CC_Slider, opt, QStyle.SC_SliderHandle, self
)
else:
rects = []
for p in self._position:
opt.sliderPosition = self._pre_set_hook(p)
r = style.subControlRect(
QStyle.CC_Slider, opt, QStyle.SC_SliderHandle, self
)
rects.append(r)
return rects
def _grooveRect(self, opt: QStyleOptionSlider) -> QRect:
"""Return the QRect for the slider groove."""
style = self.style().proxy()
return style.subControlRect(QStyle.CC_Slider, opt, QStyle.SC_SliderGroove, self)
def _barRect(self, opt: QStyleOptionSlider, r_groove: QRect = None) -> QRect:
"""Return the QRect for the bar between the outer handles."""
if r_groove is None:
r_groove = self._grooveRect(opt)
r_bar = QRectF(r_groove)
hdl_low, *_, hdl_high = self._handleRects(opt)
thickness = self._style.thickness(opt)
offset = self._style.offset(opt)
if opt.orientation == Qt.Horizontal:
r_bar.setTop(r_bar.center().y() - thickness / 2 + offset)
r_bar.setHeight(thickness)
r_bar.setLeft(hdl_low.center().x())
r_bar.setRight(hdl_high.center().x())
else:
r_bar.setLeft(r_bar.center().x() - thickness / 2 + offset)
r_bar.setWidth(thickness)
r_bar.setBottom(hdl_low.center().y())
r_bar.setTop(hdl_high.center().y())
return r_bar
def _getControlAtPos(
self, pos: QPoint, opt: QStyleOptionSlider = None, closest_handle=False
) -> ControlType:
"""Update self._pressedControl based on ev.pos()."""
if not opt:
opt = self._getStyleOption()
event_position = self._pick(pos)
bar_idx = 0
hdl_idx = 0
dist = float("inf")
if isinstance(pos, QPointF):
pos = QPoint(pos.x(), pos.y())
# TODO: this should be reversed, to prefer higher value handles
for i, hdl in enumerate(self._handleRects(opt)):
if hdl.contains(pos):
return ("handle", i) # TODO: use enum for 'handle'
hdl_center = self._pick(hdl.center())
abs_dist = abs(event_position - hdl_center)
if abs_dist < dist:
dist = abs_dist
hdl_idx = i
if event_position > hdl_center:
bar_idx += 1
else:
if closest_handle:
if bar_idx == 0:
# the click was below the minimum slider
return ("handle", 0)
elif bar_idx == len(self._position):
# the click was above the maximum slider
return ("handle", len(self._position) - 1)
if self._bar_moves_all:
# the click was in an internal segment
return ("bar", bar_idx)
elif closest_handle:
return ("handle", hdl_idx)
return self._NULL_CTRL
def _handle_offset(self, opt: QStyleOptionSlider) -> QPoint:
# to take half of the slider off for the setSliderPosition call we use the
# center - topLeft
handle_rect = self._handleRects(opt, 0)
return handle_rect.center() - handle_rect.topLeft()
# from QSliderPrivate::pixelPosToRangeValue
def _pixelPosToRangeValue(self, pos: int, opt: QStyleOptionSlider = None) -> int:
if not opt:
opt = self._getStyleOption()
groove_rect = self._grooveRect(opt)
handle_rect = self._handleRects(opt, 0)
if self.orientation() == Qt.Horizontal:
sliderLength = handle_rect.width()
sliderMin = groove_rect.x()
sliderMax = groove_rect.right() - sliderLength + 1
else:
sliderLength = handle_rect.height()
sliderMin = groove_rect.y()
sliderMax = groove_rect.bottom() - sliderLength + 1
v = QStyle.sliderValueFromPosition(
opt.minimum,
opt.maximum,
pos - sliderMin,
sliderMax - sliderMin,
opt.upsideDown,
)
return self._post_get_hook(v)
def _pick(self, pt: QPoint) -> int:
return pt.x() if self.orientation() == Qt.Horizontal else pt.y()
def _min_max_bound(self, val: int) -> int:
return _bound(self.minimum(), self.maximum(), val)
def _neighbor_bound(self, val: int, index: int, _lst: List[int]) -> int:
# make sure we don't go lower than any preceding index:
min_dist = self._post_get_hook(self.singleStep())
if index > 0:
val = max(_lst[index - 1] + min_dist, val)
# make sure we don't go higher than any following index:
if index < (len(_lst) - 1):
val = min(_lst[index + 1] - min_dist, val)
return val
def 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
if self._scrollByDelta(orientation, e.modifiers(), delta):
e.accept()
def _scrollByDelta(self, orientation, modifiers, delta: int) -> bool:
steps_to_scroll = 0
pg_step = self.pageStep()
# in Qt scrolling to the right gives negative values.
if orientation == Qt.Horizontal:
delta *= -1
offset = delta / 120
if modifiers & Qt.ShiftModifier:
# Scroll one page regardless of delta:
steps_to_scroll = _bound(-pg_step, pg_step, int(offset * pg_step))
self._offset_accum = 0
elif modifiers & Qt.ControlModifier:
# Scroll one page regardless of delta:
_range = self._pre_set_hook(self.maximum()) - self._pre_set_hook(
self.minimum()
)
steps_to_scroll = offset * _range * self._control_fraction
self._offset_accum = 0
else:
# Calculate how many lines to scroll. Depending on what delta is (and
# offset), we might end up with a fraction (e.g. scroll 1.3 lines). We can
# only scroll whole lines, so we keep the reminder until next event.
wheel_scroll_lines = QApplication.wheelScrollLines()
steps_to_scrollF = wheel_scroll_lines * offset * self._effectiveSingleStep()
# Check if wheel changed direction since last event:
if self._offset_accum != 0 and (offset / self._offset_accum) < 0:
self._offset_accum = 0
self._offset_accum += steps_to_scrollF
# Don't scroll more than one page in any case:
steps_to_scroll = _bound(-pg_step, pg_step, int(self._offset_accum))
self._offset_accum -= int(self._offset_accum)
if steps_to_scroll == 0:
# We moved less than a line, but might still have accumulated partial
# scroll, unless we already are at one of the ends.
effective_offset = self._offset_accum
if self.invertedControls():
effective_offset *= -1
if effective_offset > 0 and max(self._value) < self.maximum():
return True
if effective_offset < 0 and min(self._value) < self.minimum():
return True
self._offset_accum = 0
return False
if self.invertedControls():
steps_to_scroll *= -1
_prev_value = self.value()
if modifiers & Qt.AltModifier:
self._spreadAllPositions(shrink=steps_to_scroll < 0)
else:
self._offsetAllPositions(self._post_get_hook(steps_to_scroll))
self.triggerAction(QSlider.SliderMove)
if _prev_value == self.value():
self._offset_accum = 0
return False
return True
def _effectiveSingleStep(self) -> int:
return self.singleStep() * self._repeatMultiplier
def keyPressEvent(self, ev: QtGui.QKeyEvent) -> None:
return # TODO
def _bound(min_: int, max_: int, value: int) -> int:
"""Return value bounded by min_ and max_."""
return max(min_, min(max_, value))
QRangeSlider.__doc__ += "\n" + textwrap.indent(QSlider.__doc__, " ")

View File

@@ -1,57 +0,0 @@
# -*- coding: utf-8 -*-
#
# Copyright © 2014-2015 Colin Duquesnoy
# Copyright © 2009- The Spyder Development Team
#
# Licensed under the terms of the MIT License
# (see LICENSE.txt for details)
"""
Modified from qtpy.QtCore.
Provides QtCore classes and functions.
"""
from . import PYQT5, PYQT6, PYSIDE2, PYSIDE6, PythonQtError
if PYQT5:
from PyQt5.QtCore import QT_VERSION_STR as __version__
from PyQt5.QtCore import *
from PyQt5.QtCore import pyqtProperty as Property # noqa
from PyQt5.QtCore import pyqtSignal as Signal # noqa
from PyQt5.QtCore import pyqtSlot as Slot # noqa
# Those are imported from `import *`
del pyqtSignal, pyqtBoundSignal, pyqtSlot, pyqtProperty, QT_VERSION_STR
elif PYQT6:
from PyQt6.QtCore import QT_VERSION_STR as __version__
from PyQt6.QtCore import *
from PyQt6.QtCore import pyqtProperty as Property # noqa
from PyQt6.QtCore import pyqtSignal as Signal # noqa
from PyQt6.QtCore import pyqtSlot as Slot # noqa
# backwards compat with PyQt5
# namespace moves:
for cls in (QEvent, Qt):
for attr in dir(cls):
if not attr[0].isupper():
continue
ns = getattr(cls, attr)
for name, val in vars(ns).items():
if not name.startswith("_"):
setattr(cls, name, val)
# Those are imported from `import *`
del pyqtSignal, pyqtBoundSignal, pyqtSlot, pyqtProperty, QT_VERSION_STR
elif PYSIDE2:
import PySide2.QtCore
from PySide2.QtCore import * # noqa
__version__ = PySide2.QtCore.__version__
elif PYSIDE6:
import PySide6.QtCore
from PySide6.QtCore import * # noqa
__version__ = PySide6.QtCore.__version__
else:
raise PythonQtError("No Qt bindings could be found")

View File

@@ -1,43 +0,0 @@
# -*- coding: utf-8 -*-
#
# Copyright © 2014-2015 Colin Duquesnoy
# Copyright © 2009- The Spyder Development Team
#
# Licensed under the terms of the MIT License
# (see LICENSE.txt for details)
"""
Modified from qtpy.QtGui
Provides QtGui classes and functions.
"""
from . import PYQT5, PYQT6, PYSIDE2, PYSIDE6, PythonQtError
if PYQT5:
from PyQt5.QtGui import *
elif PYSIDE2:
from PySide2.QtGui import *
elif PYQT6:
from PyQt6.QtGui import *
# backwards compat with PyQt5
# namespace moves:
for cls in (QPalette,):
for attr in dir(cls):
if not attr[0].isupper():
continue
ns = getattr(cls, attr)
for name, val in vars(ns).items():
if not name.startswith("_"):
setattr(cls, name, val)
def pos(self, *a):
_pos = self.position(*a)
return _pos.toPoint()
QMouseEvent.pos = pos
elif PYSIDE6:
from PySide6.QtGui import * # noqa
else:
raise PythonQtError("No Qt bindings could be found")

View File

@@ -1,43 +0,0 @@
# -*- coding: utf-8 -*-
#
# Copyright © 2014-2015 Colin Duquesnoy
# Copyright © 2009- The Spyder Developmet Team
#
# Licensed under the terms of the MIT License
# (see LICENSE.txt for details)
"""
Modified from qtpy.QtWidgets
Provides widget classes and functions.
"""
from . import PYQT5, PYQT6, PYSIDE2, PYSIDE6, PythonQtError
if PYQT5:
from PyQt5.QtWidgets import *
elif PYSIDE2:
from PySide2.QtWidgets import *
elif PYQT6:
from PyQt6.QtWidgets import *
# backwards compat with PyQt5
# namespace moves:
for cls in (QStyle, QSlider, QSizePolicy, QSpinBox):
for attr in dir(cls):
if not attr[0].isupper():
continue
ns = getattr(cls, attr)
for name, val in vars(ns).items():
if not name.startswith("_"):
setattr(cls, name, val)
def exec_(self):
self.exec()
QApplication.exec_ = exec_
elif PYSIDE6:
from PySide6.QtWidgets import * # noqa
else:
raise PythonQtError("No Qt bindings could be found")

View File

@@ -1,167 +0,0 @@
# -*- coding: utf-8 -*-
#
# Copyright © 2009- The Spyder Development Team
# Copyright © 2014-2015 Colin Duquesnoy
#
# Licensed under the terms of the MIT License
# (see LICENSE.txt for details)
"""
This file is borrowed from qtpy and modified to support PySide6/PyQt6 (drops PyQt4)
"""
import os
import platform
import sys
import warnings
from distutils.version import LooseVersion
class PythonQtError(RuntimeError):
"""Error raise if no bindings could be selected."""
class PythonQtWarning(Warning):
"""Warning if some features are not implemented in a binding."""
# Qt API environment variable name
QT_API = "QT_API"
# Names of the expected PyQt5 api
PYQT5_API = ["pyqt5"]
# Names of the expected PyQt6 api
PYQT6_API = ["pyqt6"]
# Names of the expected PySide2 api
PYSIDE2_API = ["pyside2"]
# Names of the expected PySide6 api
PYSIDE6_API = ["pyside6"]
# Detecting if a binding was specified by the user
binding_specified = QT_API in os.environ
# Setting a default value for QT_API
os.environ.setdefault(QT_API, "pyqt5")
API = os.environ[QT_API].lower()
initial_api = API
assert API in (PYQT5_API + PYQT6_API + PYSIDE2_API + PYSIDE6_API)
PYQT5 = True
PYSIDE2 = PYQT6 = PYSIDE6 = False
# When `FORCE_QT_API` is set, we disregard
# any previously imported python bindings.
if os.environ.get("FORCE_QT_API") is not None:
if "PyQt5" in sys.modules:
API = initial_api if initial_api in PYQT5_API else "pyqt5"
elif "PySide2" in sys.modules:
API = initial_api if initial_api in PYSIDE2_API else "pyside2"
elif "PyQt6" in sys.modules:
API = initial_api if initial_api in PYQT6_API else "pyqt6"
elif "PySide6" in sys.modules:
API = initial_api if initial_api in PYSIDE6_API else "pyside6"
if API in PYQT5_API:
try:
from PyQt5.QtCore import PYQT_VERSION_STR as PYQT_VERSION # noqa
from PyQt5.QtCore import QT_VERSION_STR as QT_VERSION # noqa
PYSIDE_VERSION = None # noqa
if sys.platform == "darwin":
macos_version = LooseVersion(platform.mac_ver()[0])
if macos_version < LooseVersion("10.10"):
if LooseVersion(QT_VERSION) >= LooseVersion("5.9"):
raise PythonQtError(
"Qt 5.9 or higher only works in "
"macOS 10.10 or higher. Your "
"program will fail in this "
"system."
)
elif macos_version < LooseVersion("10.11"):
if LooseVersion(QT_VERSION) >= LooseVersion("5.11"):
raise PythonQtError(
"Qt 5.11 or higher only works in "
"macOS 10.11 or higher. Your "
"program will fail in this "
"system."
)
del macos_version
except ImportError:
API = os.environ["QT_API"] = "pyside2"
if API in PYSIDE2_API:
try:
from PySide2 import __version__ as PYSIDE_VERSION # noqa
from PySide2.QtCore import __version__ as QT_VERSION # noqa
PYQT_VERSION = None # noqa
PYQT5 = False
PYSIDE2 = True
if sys.platform == "darwin":
macos_version = LooseVersion(platform.mac_ver()[0])
if macos_version < LooseVersion("10.11"):
if LooseVersion(QT_VERSION) >= LooseVersion("5.11"):
raise PythonQtError(
"Qt 5.11 or higher only works in "
"macOS 10.11 or higher. Your "
"program will fail in this "
"system."
)
del macos_version
except ImportError:
API = os.environ["QT_API"] = "pyqt6"
if API in PYQT6_API:
try:
from PyQt6.QtCore import PYQT_VERSION_STR as PYQT_VERSION # noqa
from PyQt6.QtCore import QT_VERSION_STR as QT_VERSION # noqa
PYSIDE_VERSION = None # noqa
PYQT5 = False
PYQT6 = True
except ImportError:
API = os.environ["QT_API"] = "pyside6"
if API in PYSIDE6_API:
try:
from PySide6 import __version__ as PYSIDE_VERSION # noqa
from PySide6.QtCore import __version__ as QT_VERSION # noqa
PYQT_VERSION = None # noqa
PYQT5 = False
PYSIDE6 = True
except ImportError:
API = None
if API is None:
raise PythonQtError(
"No Qt bindings could be found.\nYou must install one of the following packages "
"to use QtRangeSlider: PyQt5, PyQt6, PySide2, or PySide6"
)
# If a correct API name is passed to QT_API and it could not be found,
# switches to another and informs through the warning
if API != initial_api and binding_specified:
warnings.warn(
'Selected binding "{}" could not be found, '
'using "{}"'.format(initial_api, API),
RuntimeWarning,
)
API_NAME = {
"pyqt5": "PyQt5",
"pyqt6": "PyQt6",
"pyside2": "PySide2",
"pyside6": "PySide6",
}[API]

108
setup.cfg
View File

@@ -1,18 +1,13 @@
[metadata]
name = QtRangeSlider
url = https://github.com/tlambert03/QtRangeSlider
license = BSD-3
license_file = LICENSE
description = Multi-handle range slider widget for PyQt/PySide
long_description = file: README.md, CHANGELOG.md
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
keywords = qt, range slider, widget
project_urls =
Source = https://github.com/tlambert03/QtRangeSlider
Tracker = https://github.com/tlambert03/QtRangeSlider/issues
Changelog = https://github.com/tlambert03/QtRangeSlider/blob/master/CHANGELOG.md
license = BSD-3-Clause
license_file = LICENSE
classifiers =
Development Status :: 4 - Beta
Environment :: X11 Applications :: Qt
@@ -20,49 +15,100 @@ classifiers =
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.6
Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
Programming Language :: Python :: Implementation :: CPython
Topic :: Desktop Environment
Topic :: Software Development
Topic :: Software Development :: User Interfaces
Topic :: Software Development :: Widget Sets
keywords = qt, range slider, widget
project_urls =
Source = https://github.com/napari/superqt
Tracker = https://github.com/napari/superqt/issues
Changelog = https://github.com/napari/superqt/blob/master/CHANGELOG.md
[options]
zip_safe = False
packages = find:
python_requires = >=3.6
setup_requires = setuptools_scm
install_requires =
packaging
qtpy>=1.1.0
typing-extensions>=3.10.0.0
python_requires = >=3.7
include_package_data = True
package_dir =
=src
setup_requires =
setuptools-scm
zip_safe = False
[options.packages.find]
where = src
[options.extras_require]
pyside2 = pyside2
pyqt5 = pyqt5
pyside6 = pyside6
pyqt6 = pyqt6
testing =
tox
tox-conda
pytest
pytest-qt
pytest-cov
dev =
ipython
jedi<0.18.0
isort
jedi<0.18.0
mypy
pre-commit
%(testing)s
%(pyqt5)s
pyside2
pytest
pytest-cov
pytest-qt
tox
tox-conda
font_fa5 =
fonticon-fontawesome5
font_mi5 =
fonticon-materialdesignicons5
pyqt5 =
pyqt5
pyqt6 =
pyqt6
pyside2 =
pyside2
pyside6 =
pyside6
testing =
pytest
pytest-cov
pytest-qt
tox
tox-conda
[options.package_data]
superqt = py.typed
[flake8]
exclude = _version.py,.eggs,examples
docstring-convention = numpy
ignore = E203,W503,E501,C901,F403,F405
ignore = E203,W503,E501,C901,F403,F405,D100
[pydocstyle]
convention = numpy
add_select = D402,D415,D417
ignore = D100
[isort]
profile=black
profile = black
[tool:pytest]
addopts = -W error
filterwarnings =
error
ignore:QPixmapCache.find:DeprecationWarning:
ignore:SelectableGroups dict interface:DeprecationWarning
ignore:The distutils package is deprecated:DeprecationWarning
[mypy]
strict = True
files = src/superqt
[mypy-superqt.qtcompat.*]
ignore_missing_imports = True
warn_unused_ignores = False
allow_redefinition = True

View File

@@ -1,10 +0,0 @@
"""
PEP 517 doesnt support editable installs
so this file is currently here to support "pip install -e ."
"""
from setuptools import setup
setup(
use_scm_version={"write_to": "qtrangeslider/_version.py"},
setup_requires=["setuptools_scm"],
)

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

@@ -0,0 +1,41 @@
"""superqt is a collection of QtWidgets for python."""
try:
from ._version import version as __version__
except ImportError:
__version__ = "unknown"
from ._eliding_label import QElidingLabel
from .collapsible import QCollapsible
from .combobox import QEnumComboBox, QSearchableComboBox
from .selection import QSearchableListWidget
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",
"QDoubleSlider",
"QElidingLabel",
"QEnumComboBox",
"QLabeledDoubleRangeSlider",
"QLabeledDoubleSlider",
"QLabeledRangeSlider",
"QLabeledSlider",
"QLargeIntSpinBox",
"QMessageHandler",
"QSearchableComboBox",
"QSearchableListWidget",
"QRangeSlider",
"QCollapsible",
]

View File

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

View File

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

View File

@@ -0,0 +1,140 @@
"""A collapsible widget to hide and unhide child widgets"""
from typing import Optional
from qtpy.QtCore import QEasingCurve, QEvent, QMargins, QObject, QPropertyAnimation, Qt
from qtpy.QtWidgets import QFrame, QPushButton, QVBoxLayout, QWidget
class QCollapsible(QFrame):
"""A collapsible widget to hide and unhide child widgets.
Based on https://stackoverflow.com/a/68141638
"""
_EXPANDED = ""
_COLLAPSED = ""
def __init__(self, title: str = "", parent: Optional[QWidget] = None):
super().__init__(parent)
self._locked = False
self._is_animating = False
self._toggle_btn = QPushButton(self._COLLAPSED + title)
self._toggle_btn.setCheckable(True)
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):
"""Set the text of the toggle button."""
current = self._toggle_btn.text()[: len(self._EXPANDED)]
self._toggle_btn.setText(current + text)
def text(self) -> str:
"""Return the text of the toggle button."""
return self._toggle_btn.text()[len(self._EXPANDED) :]
def setContent(self, content: QWidget):
"""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 setDuration(self, msecs: int):
"""Set duration of the collapse/expand animation."""
self._animation.setDuration(msecs)
def setEasingCurve(self, easing: QEasingCurve):
"""Set the easing curve for the collapse/expand animation"""
self._animation.setEasingCurve(easing)
def addWidget(self, widget: QWidget):
"""Add a widget to the central content widget's layout."""
widget.installEventFilter(self)
self._content.layout().addWidget(widget)
def removeWidget(self, widget: QWidget):
"""Remove widget from the central content widget's layout."""
self._content.layout().removeWidget(widget)
widget.removeEventFilter(self)
def expand(self, animate: bool = True):
"""Expand (show) the collapsible section"""
self._expand_collapse(QPropertyAnimation.Direction.Forward, animate)
def collapse(self, animate: bool = True):
"""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):
"""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
):
if self._locked:
return
forward = direction == QPropertyAnimation.Direction.Forward
text = self._EXPANDED if forward else self._COLLAPSED
self._toggle_btn.setChecked(forward)
self._toggle_btn.setText(text + self._toggle_btn.text()[len(self._EXPANDED) :])
_content_height = self._content.sizeHint().height() + 10
if animate:
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)
def _toggle(self):
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)
return False
def _on_animation_done(self):
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,112 @@
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 provide own implementation of __str__"""
if enum_value.__str__.__module__ != "enum":
# 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)
super().addItems(list(map(_get_name, self._enum_class.__members__.values())))
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(
f"setValue(self, Enum): argument 1 has unexpected type {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 qtpy import QT_VERSION
from qtpy.QtCore import Qt, Signal
from qtpy.QtWidgets import QComboBox, QCompleter
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=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,218 @@
from __future__ import annotations
__all__ = [
"addFont",
"ENTRY_POINT",
"font",
"icon",
"IconFont",
"IconFontMeta",
"IconOpts",
"Animation",
"pulse",
"spin",
]
from typing import TYPE_CHECKING, Dict, Optional, Tuple, Union
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,
opacity: float = 1,
animation: Optional[Animation] = None,
transform: Optional[QTransform] = None,
states: Dict[str, Union[IconOptionDict, IconOpts]] = {},
) -> 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 glpyh.
In most cases, the key should be provided by a plugin in the environment, like:
https://github.com/tlambert03/fonticon-fontawesome5 ('fa5s' & 'fa5r' prefixes)
https://github.com/tlambert03/fonticon-materialdesignicons6 ('mdi6' prefix)
...but fonts can also be added manually using :func:`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 paramters above.
Returns
-------
QFontIcon
A subclass of QIcon. Can be used wherever QIcons are used, such as
`widget.setIcon()`
Examples
--------
# simple example (assumes the font-awesome5 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,
)
def setTextIcon(widget: QWidget, glyph_key: str, size: Optional[float] = 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
----------
wdg : 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: Optional[int] = 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: Optional[Dict[str, str]] = None
) -> Optional[Tuple[str, str]]:
"""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:
https://github.com/tlambert03/fonticon-fontawesome5
https://github.com/tlambert03/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,40 @@
from abc import ABC, abstractmethod
from qtpy.QtCore import QRectF, QTimer
from qtpy.QtGui import QPainter
from qtpy.QtWidgets import QWidget
class Animation(ABC):
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):
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):
def __init__(self, parent_widget: 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):
assert isinstance(
getattr(namespace, FONTFILE_ATTR), str
), "Not a valid font type"
return namespace # type: ignore
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,103 @@
from typing import 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 = "superqt.fonticon"
_PLUGINS: Dict[str, EntryPoint] = {}
_LOADED: Dict[str, IconFontMeta] = {}
_BLOCKED: Set[EntryPoint] = set()
def _discover_fonts(self) -> None:
self._PLUGINS.clear()
for ep in entry_points().get(self.ENTRY_POINT, {}):
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():
try:
_manager._get_font_class(x)
except Exception:
continue
return {
key: sorted(filter(lambda x: not x.startswith("_"), cls.__dict__))
for key, cls in _manager._LOADED.items()
}

View File

@@ -0,0 +1,555 @@
from __future__ import annotations
import warnings
from collections import abc
from dataclasses import dataclass
from pathlib import Path
from typing import DefaultDict, Dict, Optional, Sequence, Tuple, Type, 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 ..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:
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"
)
else:
_sm = key if isinstance(key, abc.Sequence) else [key] # type: ignore
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: Optional[Animation]
transform: Optional[QTransform]
# 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:
glyph_key: Union[str, Unset] = _Unset
scale_factor: Union[float, Unset] = _Unset
color: Union[ValidColor, Unset] = _Unset
opacity: Union[float, Unset] = _Unset
animation: Union[Animation, Unset, None] = _Unset
transform: Union[QTransform, Unset, None] = _Unset
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: Optional[Animation] = None
transform: Optional[QTransform] = 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, Optional[_IconOptions]]
] = 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 sepeartely 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 () # type: ignore
# 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)
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: Union[str, Unset] = _Unset,
scale_factor: Union[float, Unset] = _Unset,
color: Union[ValidColor, Unset] = _Unset,
opacity: Union[float, Unset] = _Unset,
animation: Union[Animation, Unset, None] = _Unset,
transform: Union[QTransform, Unset, None] = _Unset,
) -> 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: Dict[str, Tuple[str, Optional[str]]] = dict()
# map of (font_family, font_style) -> character (char may include key)
_CHARMAPS: Dict[Tuple[str, Optional[str]], Dict[str, str]] = dict()
# singleton instance, use `instance()` to retrieve
__instance: Optional[QFontIconStore] = None
def __init__(self, parent: Optional[QObject] = None) -> None:
super().__init__(parent=parent)
# QT6 drops this
dpi = getattr(Qt.ApplicationAttribute, "AA_UseHighDpiPixmaps", None)
if dpi:
QApplication.setAttribute(dpi)
@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, Optional[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=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:
raise KeyError(f"No charmap registered for font '{family} ({style})'")
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, Optional[str]]:
"""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: Optional[Dict[str, str]] = None
) -> Optional[Tuple[str, str]]:
"""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
key : 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")
return
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}")
return None
families = QFontDatabase.applicationFontFamilies(fontId)
if not families: # pragma: no cover
warnings.warn(f"Font file is empty!: {filepath}")
return None
family: str = families[0]
# in Qt6, everything becomes a static member
QFd: Union[QFontDatabase, Type[QFontDatabase]] = (
QFontDatabase() # type: ignore
if tuple(QT_VERSION.split(".")) < ("6", "0")
else QFontDatabase
)
styles = QFd.styles(family) # type: ignore
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."
)
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,
opacity: float = 1,
animation: Optional[Animation] = None,
transform: Optional[QTransform] = None,
states: Dict[str, Union[IconOptionDict, IconOpts]] = {},
) -> 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.items():
if isinstance(options, IconOpts):
options = default_opts._update(options).dict()
icon.addState(*_norm_state_mode(kw), **options)
return icon
def setTextIcon(
self, widget: QWidget, glyph_key: str, size: Optional[float] = None
) -> 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: Optional[int] = 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(" ", "_")
assert str.isidentifier(name), f"Could not canonicalize name: {name}"
return name

0
src/superqt/py.typed Normal file
View File

View File

@@ -0,0 +1,20 @@
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."
)
# forward any requests for superqt.qtcompat.* to qtpy.*
class SuperQtImporter(abc.MetaPathFinder):
def find_spec(self, fullname: str, path, target=None): # type: ignore
if fullname.startswith(__name__):
return util.find_spec(fullname.replace(__name__, "qtpy"))
sys.meta_path.append(SuperQtImporter())

View File

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

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

@@ -1,16 +1,10 @@
try:
from ._version import version as __version__
except ImportError:
__version__ = "unknown"
from ._float_slider import QDoubleRangeSlider, QDoubleSlider
from ._labeled import (
QLabeledDoubleRangeSlider,
QLabeledDoubleSlider,
QLabeledRangeSlider,
QLabeledSlider,
)
from ._qrangeslider import QRangeSlider
from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
__all__ = [
"QDoubleRangeSlider",

View File

@@ -0,0 +1,12 @@
from qtpy.QtWidgets import QSlider
from ._generic_range_slider import _GenericRangeSlider
from ._generic_slider import _GenericSlider
class QDoubleRangeSlider(_GenericRangeSlider): ...
class QDoubleSlider(_GenericSlider): ...
class QRangeSlider(_GenericRangeSlider): ...
class QLabeledSlider(QSlider): ...
class QLabeledDoubleSlider(QDoubleSlider): ...
class QLabeledRangeSlider(QRangeSlider): ...
class QLabeledDoubleRangeSlider(QDoubleRangeSlider): ...

View File

@@ -0,0 +1,329 @@
from typing import Generic, List, Sequence, Tuple, TypeVar, Union
from qtpy import QtGui
from qtpy.QtCore import Property, QEvent, QPoint, QPointF, QRect, QRectF, Qt, Signal
from qtpy.QtWidgets import QSlider, QStyle, QStyleOptionSlider, QStylePainter
from ._generic_slider import CC_SLIDER, SC_GROOVE, SC_HANDLE, SC_NONE, _GenericSlider
from ._range_style import RangeSliderStyle, update_styles_from_stylesheet
_T = TypeVar("_T")
SC_BAR = QStyle.SubControl.SC_ScrollBarSubPage
class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
"""MultiHandle Range Slider widget.
Same API as QSlider, but `value`, `setValue`, `sliderPosition`, and
`setSliderPosition` are all sequences of integers.
The `valueChanged` and `sliderMoved` signals also both emit a tuple of
integers.
"""
# Emitted when the slider value has changed, with the new slider values
_valuesChanged = Signal(tuple)
# Emitted when sliderDown is true and the slider moves
# This usually happens when the user is dragging the slider
# The value is the positions of *all* handles.
_slidersMoved = Signal(tuple)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.valueChanged = self._valuesChanged
self.sliderMoved = self._slidersMoved
# list of values
self._value: List[_T] = [20, 80]
# list of current positions of each handle. same length as _value
# If tracking is enabled (the default) this will be identical to _value
self._position: List[_T] = [20, 80]
# which handle is being pressed/hovered
self._pressedIndex = 0
self._hoverIndex = 0
# whether bar length is constant when dragging the bar
# if False, the bar can shorten when dragged beyond min/max
self._bar_is_rigid = True
# whether clicking on the bar moves all handles, or just the nearest handle
self._bar_moves_all = True
self._should_draw_bar = True
# color
self._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.
"""
return self._bar_is_rigid
def setBarIsRigid(self, val: bool = True) -> None:
"""Whether bar length is constant when dragging the bar.
If False, the bar can shorten when dragged beyond min/max. Default is True.
"""
self._bar_is_rigid = bool(val)
def barMovesAllHandles(self) -> bool:
"""Whether clicking on the bar moves all handles (default), 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."""
self._bar_moves_all = bool(val)
def barIsVisible(self) -> bool:
"""Whether to show the bar between the first and last handle."""
return self._should_draw_bar
def setBarVisible(self, val: bool = True) -> None:
"""Whether to show the bar between the first and last handle."""
self._should_draw_bar = bool(val)
def hideBar(self) -> None:
self.setBarVisible(False)
def showBar(self) -> None:
self.setBarVisible(True)
# ############### QtOverrides #######################
def value(self) -> Tuple[_T, ...]:
"""Get current value of the widget as a tuple of integers."""
return tuple(self._value)
def sliderPosition(self):
"""Get current value of the widget as a tuple of integers.
If tracking is enabled (the default) this will be identical to value().
"""
return tuple(float(i) for i in self._position)
def setSliderPosition(self, pos: Union[float, Sequence[float]], index=None) -> None:
"""Set current position of the handles with a sequence of integers.
If `pos` is a sequence, it must have the same length as `value()`.
If it is a scalar, index will be
"""
if isinstance(pos, (list, tuple)):
val_len = len(self.value())
if len(pos) != val_len:
msg = f"'sliderPosition' must have same length as 'value()' ({val_len})"
raise ValueError(msg)
pairs = list(enumerate(pos))
else:
pairs = [(self._pressedIndex if index is None else index, pos)]
for idx, position in pairs:
self._position[idx] = self._bound(position, idx)
self._doSliderMove()
def setStyleSheet(self, styleSheet: str) -> None:
# sub-page styles render on top of the lower sliders and don't work here.
override = f"""
\n{type(self).__name__}::sub-page:horizontal {{background: none}}
\n{type(self).__name__}::sub-page:vertical {{background: none}}
"""
return super().setStyleSheet(styleSheet + override)
def event(self, ev: QEvent) -> bool:
if ev.type() == QEvent.Type.StyleChange:
update_styles_from_stylesheet(self)
return super().event(ev)
def mouseMoveEvent(self, ev: QtGui.QMouseEvent) -> None:
if self._pressedControl == SC_BAR:
ev.accept()
delta = self._clickOffset - self._pixelPosToRangeValue(self._pick(ev.pos()))
self._offsetAllPositions(-delta, self._sldPosAtPress)
else:
super().mouseMoveEvent(ev)
# ############### Implementation Details #######################
def _setPosition(self, val):
self._position = list(val)
def _bound(self, value, index=None):
if isinstance(value, (list, tuple)):
return type(value)(self._bound(v) for v in value)
pos = super()._bound(value)
if index is not None:
pos = self._neighbor_bound(pos, index)
return self._type_cast(pos)
def _neighbor_bound(self, val, index):
# make sure we don't go lower than any preceding index:
min_dist = self.singleStep()
_lst = self._position
if index > 0:
val = max(_lst[index - 1] + min_dist, val)
# make sure we don't go higher than any following index:
if index < (len(_lst) - 1):
val = min(_lst[index + 1] - min_dist, val)
return val
def _getBarColor(self):
return self._style.brush(self._styleOption)
def _setBarColor(self, color):
self._style.brush_active = color
barColor = Property(QtGui.QBrush, _getBarColor, _setBarColor)
def _offsetAllPositions(self, offset: float, ref=None) -> None:
if ref is None:
ref = self._position
if self._bar_is_rigid:
# NOTE: This assumes monotonically increasing slider positions
if offset > 0 and ref[-1] + offset > self.maximum():
offset = self.maximum() - ref[-1]
elif ref[0] + offset < self.minimum():
offset = self.minimum() - ref[0]
self.setSliderPosition([i + offset for i in ref])
def _fixStyleOption(self, option):
pass
@property
def _optSliderPositions(self):
return [self._to_qinteger_space(p - self._minimum) for p in self._position]
# SubControl Positions
def _handleRect(self, handle_index: int, opt: QStyleOptionSlider = None) -> QRect:
"""Return the QRect for all handles."""
opt = opt or self._styleOption
opt.sliderPosition = self._optSliderPositions[handle_index]
return self.style().subControlRect(CC_SLIDER, opt, SC_HANDLE, self)
def _barRect(self, opt: QStyleOptionSlider) -> QRect:
"""Return the QRect for the bar between the outer handles."""
r_groove = self.style().subControlRect(CC_SLIDER, opt, SC_GROOVE, self)
r_bar = QRectF(r_groove)
hdl_low, hdl_high = self._handleRect(0, opt), self._handleRect(-1, opt)
thickness = self._style.thickness(opt)
offset = self._style.offset(opt)
if opt.orientation == Qt.Orientation.Horizontal:
r_bar.setTop(r_bar.center().y() - thickness / 2 + offset)
r_bar.setHeight(thickness)
r_bar.setLeft(hdl_low.center().x())
r_bar.setRight(hdl_high.center().x())
else:
r_bar.setLeft(r_bar.center().x() - thickness / 2 + offset)
r_bar.setWidth(thickness)
r_bar.setBottom(hdl_low.center().y())
r_bar.setTop(hdl_high.center().y())
return r_bar
# Painting
def _drawBar(self, painter: QStylePainter, opt: QStyleOptionSlider):
brush = self._style.brush(opt)
r_bar = self._barRect(opt)
if isinstance(brush, QtGui.QGradient):
brush.setStart(r_bar.topLeft())
brush.setFinalStop(r_bar.bottomRight())
painter.setPen(self._style.pen(opt))
painter.setBrush(brush)
painter.drawRect(r_bar)
def _draw_handle(self, painter: QStylePainter, opt: QStyleOptionSlider):
if self._should_draw_bar:
self._drawBar(painter, opt)
opt.subControls = SC_HANDLE
pidx = self._pressedIndex if self._pressedControl == SC_HANDLE else -1
hidx = self._hoverIndex if self._hoverControl == SC_HANDLE else -1
for idx, pos in enumerate(self._optSliderPositions):
opt.sliderPosition = pos
# make pressed handles appear sunken
if idx == pidx:
opt.state |= QStyle.StateFlag.State_Sunken
else:
opt.state = opt.state & ~QStyle.StateFlag.State_Sunken
opt.activeSubControls = SC_HANDLE if idx == hidx else SC_NONE
painter.drawComplexControl(CC_SLIDER, opt)
def _updateHoverControl(self, pos):
old_hover = self._hoverControl, self._hoverIndex
self._hoverControl, self._hoverIndex = self._getControlAtPos(pos)
if (self._hoverControl, self._hoverIndex) != old_hover:
self.update()
def _updatePressedControl(self, pos):
opt = self._styleOption
self._pressedControl, self._pressedIndex = self._getControlAtPos(pos, opt)
def _setClickOffset(self, pos):
if self._pressedControl == SC_BAR:
self._clickOffset = self._pixelPosToRangeValue(self._pick(pos))
self._sldPosAtPress = tuple(self._position)
elif self._pressedControl == SC_HANDLE:
hr = self._handleRect(self._pressedIndex)
self._clickOffset = self._pick(pos - hr.topLeft())
# NOTE: this is very much tied to mousepress... not a generic "get control"
def _getControlAtPos(
self, pos: QPoint, opt: QStyleOptionSlider = None
) -> Tuple[QStyle.SubControl, int]:
"""Update self._pressedControl based on ev.pos()."""
opt = opt or self._styleOption
if isinstance(pos, QPointF):
pos = pos.toPoint()
for i in range(len(self._position)):
if self._handleRect(i, opt).contains(pos):
return (SC_HANDLE, i)
click_pos = self._pixelPosToRangeValue(self._pick(pos))
for i, p in enumerate(self._position):
if p > click_pos:
if i > 0:
# the click was in an internal segment
if self._bar_moves_all:
return (SC_BAR, i)
avg = (self._position[i - 1] + self._position[i]) / 2
return (SC_HANDLE, i - 1 if click_pos < avg else i)
# the click was below the minimum slider
return (SC_HANDLE, 0)
# the click was above the maximum slider
return (SC_HANDLE, len(self._position) - 1)
def _execute_scroll(self, steps_to_scroll, modifiers):
if modifiers & Qt.KeyboardModifier.AltModifier:
self._spreadAllPositions(shrink=steps_to_scroll < 0)
else:
self._offsetAllPositions(steps_to_scroll)
self.triggerAction(QSlider.SliderAction.SliderMove)
def _has_scroll_space_left(self, offset):
return (offset > 0 and max(self._value) < self._maximum) or (
offset < 0 and min(self._value) < self._minimum
)
def _spreadAllPositions(self, shrink=False, gain=1.1, ref=None) -> None:
if ref is None:
ref = self._position
# if self._bar_is_rigid: # TODO
if shrink:
gain = 1 / gain
center = abs(ref[-1] + ref[0]) / 2
self.setSliderPosition([((i - center) * gain) + center for i in ref])

View File

@@ -0,0 +1,494 @@
"""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
scaling input float values to some internal integer range for the QSlider,
and converting back to float when getting `value()`. However, one still
runs into overflow limitations due to the internal integer model.
In order to circumvent them, one needs to reimplement more and more of
the attributes from QSliderPrivate in order to have the slider behave
like a native slider (with all of the proper signals and options).
So that's what `_GenericSlider` is below.
`_GenericRangeSlider` is a variant that expects `value()` and
`sliderPosition()` to be a sequence of scalars rather than a single
scalar (with one handle per item), and it forms the basis of
QRangeSlider.
"""
from typing import Generic, TypeVar
from qtpy import QtGui
from qtpy.QtCore import QEvent, QPoint, QPointF, QRect, Qt, Signal
from qtpy.QtWidgets import (
QApplication,
QSlider,
QStyle,
QStyleOptionSlider,
QStylePainter,
)
_T = TypeVar("_T")
SC_NONE = QStyle.SubControl.SC_None
SC_HANDLE = QStyle.SubControl.SC_SliderHandle
SC_GROOVE = QStyle.SubControl.SC_SliderGroove
SC_TICKMARKS = QStyle.SubControl.SC_SliderTickmarks
CC_SLIDER = QStyle.ComplexControl.CC_Slider
QOVERFLOW = 2**31 - 1
class _GenericSlider(QSlider, Generic[_T]):
_fvalueChanged = Signal(float)
_fsliderMoved = Signal(float)
_frangeChanged = Signal(float, float)
MAX_DISPLAY = 5000
def __init__(self, *args, **kwargs) -> None:
self._minimum = 0.0
self._maximum = 99.0
self._pageStep = 10.0
self._value: _T = 0.0 # type: ignore
self._position: _T = 0.0
self._singleStep = 1.0
self._offsetAccumulated = 0.0
self._blocktracking = False
self._tickInterval = 0.0
self._pressedControl = SC_NONE
self._hoverControl = SC_NONE
self._hoverRect = QRect()
self._clickOffset = 0.0
# for keyboard nav
self._repeatMultiplier = 1 # TODO
# for wheel nav
self._offset_accum = 0.0
# fraction of total range to scroll when holding Ctrl while scrolling
self._control_fraction = 0.04
super().__init__(*args, **kwargs)
self.valueChanged = self._fvalueChanged
self.sliderMoved = self._fsliderMoved
self.rangeChanged = self._frangeChanged
self.setAttribute(Qt.WidgetAttribute.WA_Hover)
# ############### QtOverrides #######################
def value(self) -> _T: # type: ignore
return self._value
def setValue(self, value: _T) -> None:
value = self._bound(value)
if self._value == value and self._position == value:
return
self._value = value
if self._position != value:
self._setPosition(value)
if self.isSliderDown():
self.sliderMoved.emit(self.sliderPosition())
self.sliderChange(self.SliderChange.SliderValueChange)
self.valueChanged.emit(self.value())
def sliderPosition(self) -> _T: # type: ignore
return self._position
def setSliderPosition(self, pos: _T) -> None:
position = self._bound(pos)
if position == self._position:
return
self._setPosition(position)
self._doSliderMove()
def singleStep(self) -> float: # type: ignore
return self._singleStep
def setSingleStep(self, step: float) -> None:
if step != self._singleStep:
self._setSteps(step, self._pageStep)
def pageStep(self) -> float: # type: ignore
return self._pageStep
def setPageStep(self, step: float) -> None:
if step != self._pageStep:
self._setSteps(self._singleStep, step)
def minimum(self) -> float: # type: ignore
return self._minimum
def setMinimum(self, min: float) -> None:
self.setRange(min, max(self._maximum, min))
def maximum(self) -> float: # type: ignore
return self._maximum
def setMaximum(self, max: float) -> None:
self.setRange(min(self._minimum, max), max)
def setRange(self, min: float, max_: float) -> None:
oldMin, self._minimum = self._minimum, float(min)
oldMax, self._maximum = self._maximum, float(max(min, max_))
if oldMin != self._minimum or oldMax != self._maximum:
self.sliderChange(self.SliderChange.SliderRangeChange)
self.rangeChanged.emit(self._minimum, self._maximum)
self.setValue(self._value) # re-bound
def tickInterval(self) -> float: # type: ignore
return self._tickInterval
def setTickInterval(self, ts: float) -> None:
self._tickInterval = max(0.0, ts)
self.update()
def triggerAction(self, action: QSlider.SliderAction) -> None:
self._blocktracking = True
# other actions here
# self.actionTriggered.emit(action) # FIXME: type not working for all Qt
self._blocktracking = False
self.setValue(self._position)
def initStyleOption(self, option: QStyleOptionSlider) -> None:
option.initFrom(self)
option.subControls = SC_NONE
option.activeSubControls = SC_NONE
option.orientation = self.orientation()
option.tickPosition = self.tickPosition()
option.upsideDown = (
self.invertedAppearance()
!= (option.direction == Qt.LayoutDirection.RightToLeft)
if self.orientation() == Qt.Orientation.Horizontal
else not self.invertedAppearance()
)
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.Orientation.Horizontal:
option.state |= QStyle.StateFlag.State_Horizontal
# scale style option to integer space
option.minimum = 0
option.maximum = self.MAX_DISPLAY
option.tickInterval = self._to_qinteger_space(self._tickInterval)
option.pageStep = self._to_qinteger_space(self._pageStep)
option.singleStep = self._to_qinteger_space(self._singleStep)
self._fixStyleOption(option)
def event(self, ev: QEvent) -> bool:
if ev.type() == QEvent.Type.WindowActivate:
self.update()
elif ev.type() in (QEvent.Type.HoverEnter, QEvent.Type.HoverMove):
self._updateHoverControl(_event_position(ev))
elif ev.type() == QEvent.Type.HoverLeave:
self._hoverControl = SC_NONE
lastHoverRect, self._hoverRect = self._hoverRect, QRect()
self.update(lastHoverRect)
return super().event(ev)
def mousePressEvent(self, ev: QtGui.QMouseEvent) -> None:
if self._minimum == self._maximum or ev.buttons() ^ ev.button():
ev.ignore()
return
ev.accept()
pos = _event_position(ev)
# If the mouse button used is allowed to set the value
if ev.button() in (Qt.MouseButton.LeftButton, Qt.MouseButton.MiddleButton):
self._updatePressedControl(pos)
if self._pressedControl == SC_HANDLE:
opt = self._styleOption
sr = self.style().subControlRect(CC_SLIDER, opt, SC_HANDLE, self)
offset = sr.center() - sr.topLeft()
new_pos = self._pixelPosToRangeValue(self._pick(pos - offset))
self.setSliderPosition(new_pos)
self.triggerAction(QSlider.SliderAction.SliderMove)
self.setRepeatAction(QSlider.SliderAction.SliderNoAction)
self.update()
# elif: deal with PageSetButtons
else:
ev.ignore()
if self._pressedControl != SC_NONE:
self.setRepeatAction(QSlider.SliderAction.SliderNoAction)
self._setClickOffset(pos)
self.update()
self.setSliderDown(True)
def mouseMoveEvent(self, ev: QtGui.QMouseEvent) -> None:
# TODO: add pixelMetric(QStyle::PM_MaximumDragDistance, &opt, this);
if self._pressedControl == SC_NONE:
ev.ignore()
return
ev.accept()
pos = self._pick(_event_position(ev))
newPosition = self._pixelPosToRangeValue(pos - self._clickOffset)
self.setSliderPosition(newPosition)
def mouseReleaseEvent(self, ev: QtGui.QMouseEvent) -> None:
if self._pressedControl == SC_NONE or ev.buttons():
ev.ignore()
return
ev.accept()
oldPressed = self._pressedControl
self._pressedControl = SC_NONE
self.setRepeatAction(QSlider.SliderAction.SliderNoAction)
if oldPressed != SC_NONE:
self.setSliderDown(False)
self.update()
def wheelEvent(self, e: QtGui.QWheelEvent) -> None:
e.ignore()
vertical = bool(e.angleDelta().y())
delta = e.angleDelta().y() if vertical else e.angleDelta().x()
if e.inverted():
delta *= -1
orientation = Qt.Orientation.Vertical if vertical else Qt.Orientation.Horizontal
if self._scrollByDelta(orientation, e.modifiers(), delta):
e.accept()
def paintEvent(self, ev: QtGui.QPaintEvent) -> None:
painter = QStylePainter(self)
opt = self._styleOption
# draw groove and ticks
opt.subControls = SC_GROOVE
if opt.tickPosition != QSlider.TickPosition.NoTicks:
opt.subControls |= SC_TICKMARKS
painter.drawComplexControl(CC_SLIDER, opt)
self._draw_handle(painter, opt)
# ############### Implementation Details #######################
def _type_cast(self, val):
return val
def _setPosition(self, val):
self._position = val
def _bound(self, value: _T) -> _T:
return self._type_cast(max(self._minimum, min(self._maximum, value)))
def _fixStyleOption(self, option):
option.sliderPosition = self._to_qinteger_space(self._position - self._minimum)
option.sliderValue = self._to_qinteger_space(self._value - self._minimum)
def _to_qinteger_space(self, val, _max=None):
_max = _max or self.MAX_DISPLAY
return int(min(QOVERFLOW, val / (self._maximum - self._minimum) * _max))
def _pick(self, pt: QPoint) -> int:
return pt.x() if self.orientation() == Qt.Orientation.Horizontal else pt.y()
def _setSteps(self, single: float, page: float):
self._singleStep = single
self._pageStep = page
self.sliderChange(QSlider.SliderChange.SliderStepsChange)
def _doSliderMove(self):
if not self.hasTracking():
self.update()
if self.isSliderDown():
self.sliderMoved.emit(self.sliderPosition())
if self.hasTracking() and not self._blocktracking:
self.triggerAction(QSlider.SliderAction.SliderMove)
@property
def _styleOption(self):
opt = QStyleOptionSlider()
self.initStyleOption(opt)
return opt
def _updateHoverControl(self, pos: QPoint) -> bool:
lastHoverRect = self._hoverRect
lastHoverControl = self._hoverControl
doesHover = self.testAttribute(Qt.WidgetAttribute.WA_Hover)
if lastHoverControl != self._newHoverControl(pos) and doesHover:
self.update(lastHoverRect)
self.update(self._hoverRect)
return True
return not doesHover
def _newHoverControl(self, pos: QPoint) -> QStyle.SubControl:
opt = self._styleOption
opt.subControls = QStyle.SubControl.SC_All
handleRect = self.style().subControlRect(CC_SLIDER, opt, SC_HANDLE, self)
grooveRect = self.style().subControlRect(CC_SLIDER, opt, SC_GROOVE, self)
tickmarksRect = self.style().subControlRect(CC_SLIDER, opt, SC_TICKMARKS, self)
if handleRect.contains(pos):
self._hoverRect = handleRect
self._hoverControl = SC_HANDLE
elif grooveRect.contains(pos):
self._hoverRect = grooveRect
self._hoverControl = SC_GROOVE
elif tickmarksRect.contains(pos):
self._hoverRect = tickmarksRect
self._hoverControl = SC_TICKMARKS
else:
self._hoverRect = QRect()
self._hoverControl = SC_NONE
return self._hoverControl
def _setClickOffset(self, pos: QPoint):
hr = self.style().subControlRect(CC_SLIDER, self._styleOption, SC_HANDLE, self)
self._clickOffset = self._pick(pos - hr.topLeft())
def _updatePressedControl(self, pos: QPoint):
self._pressedControl = SC_HANDLE
def _draw_handle(self, painter, opt):
opt.subControls = SC_HANDLE
if self._pressedControl:
opt.activeSubControls = self._pressedControl
opt.state |= QStyle.StateFlag.State_Sunken
else:
opt.activeSubControls = self._hoverControl
painter.drawComplexControl(CC_SLIDER, opt)
# from QSliderPrivate.pixelPosToRangeValue
def _pixelPosToRangeValue(self, pos: int) -> float:
opt = self._styleOption
gr = self.style().subControlRect(CC_SLIDER, opt, SC_GROOVE, self)
sr = self.style().subControlRect(CC_SLIDER, opt, SC_HANDLE, self)
if self.orientation() == Qt.Orientation.Horizontal:
sliderLength = sr.width()
sliderMin = gr.x()
sliderMax = gr.right() - sliderLength + 1
else:
sliderLength = sr.height()
sliderMin = gr.y()
sliderMax = gr.bottom() - sliderLength + 1
return _sliderValueFromPosition(
self._minimum,
self._maximum,
pos - sliderMin,
sliderMax - sliderMin,
opt.upsideDown,
)
def _scrollByDelta(self, orientation, modifiers, delta: int) -> bool:
steps_to_scroll = 0.0
pg_step = self._pageStep
# in Qt scrolling to the right gives negative values.
if orientation == Qt.Orientation.Horizontal:
delta *= -1
offset = delta / 120
if modifiers & Qt.KeyboardModifier.ShiftModifier:
# Scroll one page regardless of delta:
steps_to_scroll = max(-pg_step, min(pg_step, offset * pg_step))
self._offset_accum = 0
elif modifiers & Qt.KeyboardModifier.ControlModifier:
_range = self._maximum - self._minimum
steps_to_scroll = offset * _range * self._control_fraction
self._offset_accum = 0
else:
# Calculate how many lines to scroll. Depending on what delta is (and
# offset), we might end up with a fraction (e.g. scroll 1.3 lines). We can
# only scroll whole lines, so we keep the reminder until next event.
wheel_scroll_lines = QApplication.wheelScrollLines()
steps_to_scrollF = wheel_scroll_lines * offset * self._effectiveSingleStep()
# Check if wheel changed direction since last event:
if self._offset_accum != 0 and (offset / self._offset_accum) < 0:
self._offset_accum = 0
self._offset_accum += steps_to_scrollF
# Don't scroll more than one page in any case:
steps_to_scroll = max(-pg_step, min(pg_step, self._offset_accum))
self._offset_accum -= self._offset_accum
if steps_to_scroll == 0:
# We moved less than a line, but might still have accumulated partial
# scroll, unless we already are at one of the ends.
effective_offset = self._offset_accum
if self.invertedControls():
effective_offset *= -1
if self._has_scroll_space_left(effective_offset):
return True
self._offset_accum = 0
return False
if self.invertedControls():
steps_to_scroll *= -1
prevValue = self._value
self._execute_scroll(steps_to_scroll, modifiers)
if prevValue == self._value:
self._offset_accum = 0
return False
return True
def _has_scroll_space_left(self, offset):
return (offset > 0 and self._value < self._maximum) or (
offset < 0 and self._value < self._minimum
)
def _execute_scroll(self, steps_to_scroll, modifiers):
self._setPosition(self._bound(self._overflowSafeAdd(steps_to_scroll)))
self.triggerAction(QSlider.SliderAction.SliderMove)
def _effectiveSingleStep(self) -> float:
return self._singleStep * self._repeatMultiplier
def _overflowSafeAdd(self, add: float) -> float:
newValue = self._value + add
if add > 0 and newValue < self._value:
newValue = self._maximum
elif add < 0 and newValue > self._value:
newValue = self._minimum
return newValue
# def keyPressEvent(self, ev: QtGui.QKeyEvent) -> None:
# return # TODO
def _event_position(ev: QEvent) -> QPoint:
# safe for Qt6, Qt5, and hoverEvent
evp = getattr(ev, "position", getattr(ev, "pos", None))
pos = evp() if evp else QPoint()
if isinstance(pos, QPointF):
pos = pos.toPoint()
return pos
def _sliderValueFromPosition(
min: float, max: float, position: int, span: int, upsideDown: bool = False
) -> float:
"""Converts the given pixel `position` to a value.
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.
"""
if span <= 0 or position <= 0:
return max if upsideDown else min
if position >= span:
return min if upsideDown else max
tmp = (max - min) * (position / span)
return (max - tmp) if upsideDown else tmp + min

View File

@@ -1,11 +1,10 @@
from enum import IntEnum
from functools import partial
from typing import Any
from ._float_slider import QDoubleRangeSlider, QDoubleSlider
from ._qrangeslider import QRangeSlider
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,
@@ -18,6 +17,8 @@ from .qtcompat.QtWidgets import (
QWidget,
)
from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
class LabelPosition(IntEnum):
NoLabel = 0
@@ -33,8 +34,8 @@ class EdgeLabelMode(IntEnum):
LabelIsValue = 2
class SliderProxy:
_slider: QAbstractSlider
class _SliderProxy:
_slider: QSlider
def value(self):
return self._slider.value()
@@ -42,6 +43,12 @@ class SliderProxy:
def setValue(self, value) -> None:
self._slider.setValue(value)
def sliderPosition(self):
return self._slider.sliderPosition()
def setSliderPosition(self, pos) -> None:
self._slider.setSliderPosition(pos)
def minimum(self):
return self._slider.minimum()
@@ -69,10 +76,25 @@ class SliderProxy:
def setRange(self, min, max) -> None:
self._slider.setRange(min, max)
def tickInterval(self):
return self._slider.tickInterval()
def setTickInterval(self, interval) -> None:
self._slider.setTickInterval(interval)
def tickPosition(self):
return self._slider.tickPosition()
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"
@@ -95,7 +117,8 @@ def _handle_overloaded_slider_sig(args, kwargs):
return parent, orientation
class QLabeledSlider(SliderProxy, QAbstractSlider):
class QLabeledSlider(_SliderProxy, QAbstractSlider):
EdgeLabelMode = EdgeLabelMode
_slider_class = QSlider
_slider: QSlider
@@ -106,57 +129,100 @@ class QLabeledSlider(SliderProxy, QAbstractSlider):
self._slider = self._slider_class()
self._label = SliderLabel(self._slider, connect=self._slider.setValue)
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.sliderMoved.connect(self.sliderMoved.emit)
self._slider.sliderPressed.connect(self.sliderPressed.emit)
self._slider.sliderReleased.connect(self.sliderReleased.emit)
self._slider.valueChanged.connect(self._label.setValue)
self._slider.valueChanged.connect(self.valueChanged.emit)
self.setOrientation(orientation)
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:
if self._edge_label_mode == EdgeLabelMode.NoLabel:
marg = (0, 0, 5, 0)
layout = QHBoxLayout()
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 self._edge_label_mode
def setEdgeLabelMode(self, opt: EdgeLabelMode) -> None:
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 _rename_signals(self):
self.valueChanged = self._fvalueChanged
self.sliderMoved = self._fsliderMoved
self.rangeChanged = self._frangeChanged
def decimals(self) -> int:
return self._slider.decimals()
return self._label.decimals()
def setDecimals(self, prec: int):
self._slider.setDecimals(prec)
self._label.setDecimals(prec)
class QLabeledRangeSlider(SliderProxy, QAbstractSlider):
valueChanged = Signal(tuple)
class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
_valueChanged = Signal(tuple)
LabelPosition = LabelPosition
EdgeLabelMode = EdgeLabelMode
_slider_class = QRangeSlider
@@ -165,7 +231,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
@@ -178,10 +246,14 @@ 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.setEdgeLabelMode(EdgeLabelMode.LabelIsRange)
@@ -192,6 +264,9 @@ 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 self._handle_label_position
@@ -232,11 +307,12 @@ class QLabeledRangeSlider(SliderProxy, QAbstractSlider):
if not self._handle_labels:
return
horizontal = self.orientation() == Qt.Horizontal
horizontal = self.orientation() == Qt.Orientation.Horizontal
labels_above = self._handle_label_position == LabelPosition.LabelsAbove
last_edge = None
for label, rect in zip(self._handle_labels, self._slider._handleRects()):
for i, label in enumerate(self._handle_labels):
rect = self._slider._handleRect(i)
dx = -label.width() / 2
dy = -label.height() / 2
if labels_above:
@@ -260,6 +336,7 @@ class QLabeledRangeSlider(SliderProxy, QAbstractSlider):
label.move(pos)
last_edge = pos
label.clearFocus()
self.update()
def _min_label_edited(self, val):
if self._edge_label_mode == EdgeLabelMode.LabelIsRange:
@@ -290,7 +367,7 @@ class QLabeledRangeSlider(SliderProxy, QAbstractSlider):
lbl.deleteLater()
self._handle_labels.clear()
for n, val in enumerate(self._slider.value()):
_cb = partial(self._slider._setSliderPositionAt, n)
_cb = partial(self._slider.setSliderPosition, index=n)
s = SliderLabel(self._slider, parent=self, connect=_cb)
s.setValue(val)
self._handle_labels.append(s)
@@ -300,7 +377,8 @@ class QLabeledRangeSlider(SliderProxy, QAbstractSlider):
self._reposition_labels()
def _on_range_changed(self, min, max):
self._slider.setRange(min, max)
if (min, max) != (self._slider.minimum(), self._slider.maximum()):
self._slider.setRange(min, max)
for lbl in self._handle_labels:
lbl.setRange(min, max)
if self._edge_label_mode == EdgeLabelMode.LabelIsRange:
@@ -319,7 +397,7 @@ class QLabeledRangeSlider(SliderProxy, QAbstractSlider):
"""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)
@@ -332,7 +410,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)
@@ -365,17 +443,20 @@ 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._slider.decimals()
return self._min_label.decimals()
def setDecimals(self, prec: int):
self._slider.setDecimals(prec)
self._min_label.setDecimals(prec)
self._max_label.setDecimals(prec)
for lbl in self._handle_labels:
@@ -384,18 +465,22 @@ 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()))
@@ -406,7 +491,7 @@ class SliderLabel(QDoubleSpinBox):
super().setDecimals(prec)
self._update_size()
def _update_size(self):
def _update_size(self, *_):
# fontmetrics to measure the width of text
fm = QFontMetrics(self.font())
h = self.sizeHint().height()
@@ -427,10 +512,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()

View File

@@ -1,11 +1,13 @@
from __future__ import annotations
import platform
import re
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING, Union
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,
@@ -13,34 +15,34 @@ from .qtcompat.QtGui import (
QPalette,
QRadialGradient,
)
from .qtcompat.QtWidgets import QApplication, QSlider, QStyleOptionSlider
from qtpy.QtWidgets import QApplication, QSlider, QStyleOptionSlider
if TYPE_CHECKING:
from ._qrangeslider import QRangeSlider
from ._generic_range_slider import _GenericRangeSlider
@dataclass
class RangeSliderStyle:
brush_active: str = None
brush_inactive: str = None
brush_disabled: str = None
pen_active: str = None
pen_inactive: str = None
pen_disabled: str = None
vertical_thickness: float = None
horizontal_thickness: float = None
tick_offset: float = None
tick_bar_alpha: float = None
v_offset: float = None
h_offset: float = None
brush_active: str | None = None
brush_inactive: str | None = None
brush_disabled: str | None = None
pen_active: str | None = None
pen_inactive: str | None = None
pen_disabled: str | None = None
vertical_thickness: float | None = None
horizontal_thickness: float | None = None
tick_offset: float | None = None
tick_bar_alpha: float | None = None
v_offset: float | None = None
h_offset: float | None = None
has_stylesheet: bool = False
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:
@@ -65,24 +67,24 @@ 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)
def pen(self, opt: QStyleOptionSlider) -> Union[Qt.PenStyle, QColor]:
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
@@ -91,18 +93,18 @@ class RangeSliderStyle:
tp = opt.tickPosition
off = 0
if not self.has_stylesheet:
if opt.orientation == Qt.Horizontal:
if opt.orientation == Qt.Orientation.Horizontal:
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
@@ -137,7 +139,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(
@@ -152,7 +154,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(
@@ -226,7 +228,7 @@ rgba_pattern = re.compile(
)
def parse_color(color: str, default_attr) -> Union[QColor, QGradient]:
def parse_color(color: str, default_attr) -> QColor | QGradient:
qc = QColor(color)
if qc.isValid():
return qc
@@ -239,7 +241,7 @@ def parse_color(color: str, default_attr) -> Union[QColor, QGradient]:
# try linear gradient:
match = qlineargrad_pattern.search(color)
if match:
grad = QLinearGradient(*[float(i) for i in match.groups()[:4]])
grad = QLinearGradient(*(float(i) for i in match.groups()[:4]))
grad.setColorAt(0, QColor(match.groupdict()["stop0"]))
grad.setColorAt(1, QColor(match.groupdict()["stop1"]))
return grad
@@ -247,7 +249,7 @@ def parse_color(color: str, default_attr) -> Union[QColor, QGradient]:
# try linear gradient:
match = qradial_pattern.search(color)
if match:
grad = QRadialGradient(*[float(i) for i in match.groups()[:5]])
grad = QRadialGradient(*(float(i) for i in match.groups()[:5]))
grad.setColorAt(0, QColor(match.groupdict()["stop0"]))
grad.setColorAt(1, QColor(match.groupdict()["stop1"]))
return grad
@@ -256,7 +258,7 @@ def parse_color(color: str, default_attr) -> Union[QColor, QGradient]:
return QColor(getattr(SYSTEM_STYLE, default_attr))
def update_styles_from_stylesheet(obj: "QRangeSlider"):
def update_styles_from_stylesheet(obj: _GenericRangeSlider):
qss = obj.styleSheet()
parent = obj.parent()

View File

@@ -0,0 +1,43 @@
from qtpy.QtCore import Signal
from ._generic_range_slider import _GenericRangeSlider
from ._generic_slider import _GenericSlider
class _IntMixin:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._singleStep = 1
def _type_cast(self, value) -> int:
return int(round(value))
class _FloatMixin:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._singleStep = 0.01
self._pageStep = 0.1
def _type_cast(self, value) -> float:
return float(value)
class QDoubleSlider(_FloatMixin, _GenericSlider[float]):
pass
class QIntSlider(_IntMixin, _GenericSlider[int]):
# mostly just an example... use QSlider instead.
valueChanged = Signal(int)
class QRangeSlider(_IntMixin, _GenericRangeSlider):
pass
class QDoubleRangeSlider(_FloatMixin, QRangeSlider):
pass
# QRangeSlider.__doc__ += "\n" + textwrap.indent(QSlider.__doc__, " ")

View File

@@ -0,0 +1,3 @@
from ._intspin import QLargeIntSpinBox
__all__ = ["QLargeIntSpinBox"]

View File

@@ -0,0 +1,176 @@
from enum import Enum
from qtpy.QtCore import QSize, Qt, Signal
from qtpy.QtGui import QFontMetrics, QValidator
from qtpy.QtWidgets import QAbstractSpinBox, QStyle, QStyleOptionSpinBox
class _EmitPolicy(Enum):
EmitIfChanged = 0
AlwaysEmit = 1
NeverEmit = 2
class _AnyIntValidator(QValidator):
def __init__(self, parent=None) -> None:
super().__init__(parent)
def validate(self, input: str, pos: int):
if not input.lstrip("-"):
return QValidator.State.Intermediate, input, len(input)
if input.lstrip("-").isnumeric():
return QValidator.State.Acceptable, input, len(input)
return QValidator.State.Invalid, input, len(input)
class QLargeIntSpinBox(QAbstractSpinBox):
"""An integer spinboxes backed by unbound python integer
Qt's built-in ``QSpinBox`` is backed by a signed 32-bit integer.
This could become limiting, particularly in large dense segmentations.
This class behaves like a ``QSpinBox`` backed by an unbound python int.
Does not yet support "prefix", "suffix" or "specialValue" like QSpinBox.
"""
textChanged = Signal(str)
valueChanged = Signal(object) # object instead of int for large ints
def __init__(self, parent=None) -> None:
super().__init__(parent)
self._value: int = 0
self._minimum: int = 0
self._maximum: int = 2**64 - 1
self._single_step: int = 1
self._pending_emit = False
validator = _AnyIntValidator(self)
self.lineEdit().setValidator(validator)
self.lineEdit().textChanged.connect(self._editor_text_changed)
self.setValue(0)
# ############### Public Functions #######################
def value(self):
return self._value
def setValue(self, value):
self._setValue(value, _EmitPolicy.EmitIfChanged)
def minimum(self):
return self._minimum
def setMinimum(self, min):
self._minimum = int(min)
def maximum(self):
return self._maximum
def setMaximum(self, max):
self._maximum = int(max)
def setRange(self, minimum, maximum):
self.setMinimum(minimum)
self.setMaximum(maximum)
def singleStep(self):
return self._single_step
def setSingleStep(self, step):
self._single_step = int(step)
# TODO: add prefix/suffix/stepType
# ############### QtOverrides #######################
def focusOutEvent(self, e) -> None:
if self._pending_emit:
self._interpret(_EmitPolicy.EmitIfChanged)
return super().focusOutEvent(e)
def closeEvent(self, e) -> None:
if self._pending_emit:
self._interpret(_EmitPolicy.EmitIfChanged)
return super().closeEvent(e)
def keyPressEvent(self, e) -> None:
if e.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return):
self._interpret(
_EmitPolicy.AlwaysEmit
if self.keyboardTracking()
else _EmitPolicy.EmitIfChanged
)
return super().keyPressEvent(e)
def stepBy(self, steps: int) -> None:
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
self._setValue(self._bound(self._value + (step * steps)), e)
def stepEnabled(self):
flags = QAbstractSpinBox.StepEnabledFlag.StepNone
if self.isReadOnly():
return flags
if self._value < self._maximum:
flags |= QAbstractSpinBox.StepEnabledFlag.StepUpEnabled
if self._value > self._minimum:
flags |= QAbstractSpinBox.StepEnabledFlag.StepDownEnabled
return flags
def sizeHint(self):
self.ensurePolished()
fm = QFontMetrics(self.font())
h = self.lineEdit().sizeHint().height()
if hasattr(fm, "horizontalAdvance"):
# Qt >= 5.11
w = fm.horizontalAdvance(str(self._value)) + 3
else:
w = fm.width(str(self._value)) + 3
w = max(36, w)
opt = QStyleOptionSpinBox()
self.initStyleOption(opt)
hint = QSize(w, h)
return self.style().sizeFromContents(
QStyle.ContentsType.CT_SpinBox, opt, hint, self
)
# ############### Implementation Details #######################
def _setValue(self, value, policy):
self._value, old = self._bound(int(value)), self._value
self._pending_emit = False
self._updateEdit()
self.update()
if policy is _EmitPolicy.AlwaysEmit or (
policy is _EmitPolicy.EmitIfChanged and self._value != old
):
self._pending_emit = False
self.textChanged.emit(self.lineEdit().displayText())
self.valueChanged.emit(self._value)
def _updateEdit(self):
new_text = str(self._value)
if self.lineEdit().text() == new_text:
return
self.lineEdit().setText(new_text)
def _interpret(self, policy):
text = self.lineEdit().displayText() or str(self._value)
v = int(text)
self._setValue(v, policy)
def _editor_text_changed(self, t):
if self.keyboardTracking():
self._setValue(int(t), _EmitPolicy.EmitIfChanged)
self.lineEdit().setFocus()
self._pending_emit = False
else:
self._pending_emit = True
def _bound(self, value):
return max(self._minimum, min(self._maximum, value))

View File

@@ -0,0 +1,30 @@
__all__ = (
"create_worker",
"ensure_main_thread",
"ensure_object_thread",
"FunctionWorker",
"GeneratorWorker",
"new_worker_qthread",
"qdebounced",
"QMessageHandler",
"QSignalDebouncer",
"QSignalThrottler",
"qthrottled",
"signals_blocked",
"thread_worker",
"WorkerBase",
)
from ._ensure_thread import ensure_main_thread, ensure_object_thread
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,125 @@
# https://gist.github.com/FlorianRhiem/41a1ad9b694c14fb9ac3
from concurrent.futures import Future
from functools import wraps
from typing import Callable, List, Optional
from qtpy.QtCore import (
QCoreApplication,
QMetaObject,
QObject,
Qt,
QThread,
Signal,
Slot,
)
class CallCallable(QObject):
finished = Signal(object)
instances: List["CallCallable"] = []
def __init__(self, callable, *args, **kwargs):
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)
def ensure_main_thread(
func: Optional[Callable] = 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_):
@wraps(func_)
def _func(*args, **kwargs):
return _run_in_thread(
func_,
QCoreApplication.instance().thread(),
await_return,
timeout,
*args,
**kwargs,
)
return _func
if func is None:
return _out_func
return _out_func(func)
def ensure_object_thread(
func: Optional[Callable] = 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_):
@wraps(func_)
def _func(self, *args, **kwargs):
return _run_in_thread(
func_, self.thread(), await_return, timeout, self, *args, **kwargs
)
return _func
if func is None:
return _out_func
return _out_func(func)
def _run_in_thread(
func: Callable,
thread: QThread,
await_return: bool,
timeout: int,
*args,
**kwargs,
):
future = Future() # type: ignore
if thread is QThread.currentThread():
result = func(*args, **kwargs)
if not await_return:
future.set_result(result)
return future
return result
f = CallCallable(func, *args, **kwargs)
f.moveToThread(thread)
f.finished.connect(future.set_result, Qt.ConnectionType.DirectConnection)
QMetaObject.invokeMethod(f, "call", Qt.ConnectionType.QueuedConnection) # type: ignore
return future.result(timeout=timeout / 1000) if await_return else future

View File

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

View File

@@ -0,0 +1,94 @@
import logging
from contextlib import suppress
from typing import List, NamedTuple, Optional
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 = {
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: Optional[logging.Logger] = None):
self.records: List[Record] = []
self._logger = logger
self._previous_handler: Optional[object] = "__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,15 @@
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`."""
previous = obj.blockSignals(True)
try:
yield
finally:
obj.blockSignals(previous)

View File

@@ -0,0 +1,903 @@
from __future__ import annotations
import inspect
import time
import warnings
from functools import partial, wraps
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
Generator,
Generic,
Optional,
Sequence,
Set,
Type,
TypeVar,
Union,
overload,
)
from qtpy.QtCore import QObject, QRunnable, QThread, QThreadPool, QTimer, Signal
from typing_extensions import Literal, ParamSpec
if TYPE_CHECKING:
_T = TypeVar("_T")
class SigInst(Generic[_T]):
@staticmethod
def connect(slot: Callable[[_T], Any], type: Optional[type] = ...) -> None:
...
@staticmethod
def disconnect(slot: Callable[[_T], Any] = ...) -> None:
...
@staticmethod
def emit(*args: _T) -> None:
...
_Y = TypeVar("_Y")
_S = TypeVar("_S")
_R = TypeVar("_R")
_P = ParamSpec("_P")
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, **kwargs) -> 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 ro 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 :meth:`WorkerBase.start`
_worker_set: Set[WorkerBase] = set()
returned: SigInst[_R]
errored: SigInst[Exception]
warned: SigInst[tuple]
started: SigInst[None]
finished: SigInst[None]
def __init__(
self,
func: Optional[Callable[_P, _R]] = 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:
.. code-block:: none
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
:func:`QThreadPool.start(worker)`. It simply wraps the :meth:`work`
method, and emits a few signals. Subclasses should NOT override this
method (except with good reason), and instead should implement
:meth:`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,
)
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) -> Union[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
:meth:`GeneratorFunction.work` for an example implementation).
Minimally, it should check ``self.abort_requested`` periodically and
exit if True.
Examples
--------
.. code-block:: 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:
.. code-block:: none
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:
"""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:
.. code-block:: 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 :class:`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, Optional[_S], _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: Optional[_S] = 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) -> Union[Optional[_R], 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) -> Optional[_S]:
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: Optional[bool] = None,
_connect: Optional[Dict[str, Union[Callable, Sequence[Callable]]]] = None,
_worker_class: Union[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: Optional[bool] = None,
_connect: Optional[Dict[str, Union[Callable, Sequence[Callable]]]] = None,
_worker_class: Union[Type[GeneratorWorker], Type[FunctionWorker], None] = None,
_ignore_errors: bool = False,
**kwargs,
) -> FunctionWorker[_R]:
...
def create_worker(
func: Callable,
*args,
_start_thread: Optional[bool] = None,
_connect: Optional[Dict[str, Union[Callable, Sequence[Callable]]]] = None,
_worker_class: Union[Type[GeneratorWorker], Type[FunctionWorker], None] = None,
_ignore_errors: bool = False,
**kwargs,
) -> Union[FunctionWorker, GeneratorWorker]:
"""Convenience function to start a function in another thread.
By default, uses :class:`Worker`, but a custom ``WorkerBase`` subclass may
be provided. If so, it must be a subclass of :class:`Worker`, which
defines a standard set of signals and a run method.
Parameters
----------
func : Callable
The function to call in another thread.
_start_thread : bool, optional
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 :class`WorkerBase` to instantiate, by default
:class:`FunctionWorker` will be used if ``func`` is a regular function,
and :class:`GeneratorWorker` will be used if it is a generator.
_ignore_errors : bool, optional
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
--------
.. code-block:: python
def long_function(duration):
import time
time.sleep(duration)
worker = create_worker(long_function, 10)
"""
worker: Union[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: Optional[bool] = None,
connect: Optional[Dict[str, Union[Callable, Sequence[Callable]]]] = None,
worker_class: Optional[Type[WorkerBase]] = None,
ignore_errors: bool = False,
) -> Callable[_P, GeneratorWorker[_Y, _S, _R]]:
...
@overload
def thread_worker(
function: Callable[_P, _R],
start_thread: Optional[bool] = None,
connect: Optional[Dict[str, Union[Callable, Sequence[Callable]]]] = None,
worker_class: Optional[Type[WorkerBase]] = None,
ignore_errors: bool = False,
) -> Callable[_P, FunctionWorker[_R]]:
...
@overload
def thread_worker(
function: Literal[None] = None,
start_thread: Optional[bool] = None,
connect: Optional[Dict[str, Union[Callable, Sequence[Callable]]]] = None,
worker_class: Optional[Type[WorkerBase]] = None,
ignore_errors: bool = False,
) -> Callable[[Callable], Callable[_P, Union[FunctionWorker, GeneratorWorker]]]:
...
def thread_worker(
function: Optional[Callable] = None,
start_thread: Optional[bool] = None,
connect: Optional[Dict[str, Union[Callable, Sequence[Callable]]]] = None,
worker_class: Optional[Type[WorkerBase]] = None,
ignore_errors: bool = False,
):
"""Decorator that runs a function in a separate thread when called.
When called, the decorated function returns a :class:`WorkerBase`. See
:func:`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, optional
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[WorkerBase], optional
The :class`WorkerBase` to instantiate, by default
:class:`FunctionWorker` will be used if ``func`` is a regular function,
and :class:`GeneratorWorker` will be used if it is a generator.
ignore_errors : bool, optional
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
--------
.. code-block:: 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,
**kwargs,
):
"""This is a convenience function to start a worker in a Qthread.
In most cases, the @thread_worker decorator is sufficient and preferable.
But this allows the user to completely customize the Worker object.
However, they must then maintain control over the thread and clean up
appropriately.
It follows the pattern described here:
https://www.qt.io/blog/2010/06/17/youre-doing-it-wrong
and
https://doc.qt.io/qt-5/qthread.html#details
see also:
https://mayaposch.wordpress.com/2011/11/01/how-to-really-truly-use-qthreads-the-full-explanation/
A QThread object is not a thread! It should be thought of as a class to
*manage* a thread, not as the actual code or object that runs in that
thread. The QThread object is created on the main thread and lives there.
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.
.. note:: 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
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:
.. code-block:: 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,370 @@
"""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.
"""
import sys
from concurrent.futures import Future
from enum import IntFlag, auto
from functools import wraps
from typing import TYPE_CHECKING, Callable, Generic, Optional, TypeVar, Union, overload
from qtpy.QtCore import QObject, Qt, QTimer, Signal
from typing_extensions import Literal, ParamSpec
if TYPE_CHECKING:
from qtpy.QtCore import SignalInstance
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: Optional[QObject] = None,
) -> None:
super().__init__(parent)
self._kind = kind
self._emissionPolicy = emissionPolicy
self._hasPendingEmission = False
self._timer = QTimer()
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() # type: ignore
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
assert self._timer.isActive()
def cancel(self) -> None:
""" "Cancel and 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: Optional[QObject] = 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: Optional[QObject] = None,
) -> None:
super().__init__(Kind.Debouncer, policy, parent)
# below here part is unique to superqt (not from KD)
P = ParamSpec("P")
R = TypeVar("R")
if TYPE_CHECKING:
from typing_extensions import Protocol
class ThrottledCallable(Generic[P, R], Protocol):
triggered: "SignalInstance"
def cancel(self) -> None:
...
def flush(self) -> None:
...
def set_timeout(self, timeout: int) -> None:
...
if sys.version_info < (3, 9):
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Future:
...
else:
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Future[R]:
...
@overload
def qthrottled(
func: Callable[P, R],
timeout: int = 100,
leading: bool = True,
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
) -> "ThrottledCallable[P, R]":
...
@overload
def qthrottled(
func: Literal[None] = None,
timeout: int = 100,
leading: bool = True,
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
) -> Callable[[Callable[P, R]], "ThrottledCallable[P, R]"]:
...
def qthrottled(
func: Optional[Callable[P, R]] = None,
timeout: int = 100,
leading: bool = True,
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
) -> Union[
"ThrottledCallable[P, R]", Callable[[Callable[P, R]], "ThrottledCallable[P, R]"]
]:
"""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
"""
return _make_decorator(func, timeout, leading, timer_type, Kind.Throttler)
@overload
def qdebounced(
func: Callable[P, R],
timeout: int = 100,
leading: bool = False,
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
) -> "ThrottledCallable[P, R]":
...
@overload
def qdebounced(
func: Literal[None] = None,
timeout: int = 100,
leading: bool = False,
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
) -> Callable[[Callable[P, R]], "ThrottledCallable[P, R]"]:
...
def qdebounced(
func: Optional[Callable[P, R]] = None,
timeout: int = 100,
leading: bool = False,
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
) -> Union[
"ThrottledCallable[P, R]", Callable[[Callable[P, R]], "ThrottledCallable[P, R]"]
]:
"""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
"""
return _make_decorator(func, timeout, leading, timer_type, Kind.Debouncer)
def _make_decorator(
func: Optional[Callable[P, R]],
timeout: int,
leading: bool,
timer_type: Qt.TimerType,
kind: Kind,
) -> Union[
"ThrottledCallable[P, R]", Callable[[Callable[P, R]], "ThrottledCallable[P, R]"]
]:
def deco(func: Callable[P, R]) -> "ThrottledCallable[P, R]":
policy = EmissionPolicy.Leading if leading else EmissionPolicy.Trailing
throttle = GenericSignalThrottler(kind, policy)
throttle.setTimerType(timer_type)
throttle.setTimeout(timeout)
last_f = None
future: Optional[Future] = None
@wraps(func)
def inner(*args: P.args, **kwargs: P.kwargs) -> Future:
nonlocal last_f
nonlocal future
if last_f is not None:
throttle.triggered.disconnect(last_f)
if future is not None and not future.done():
future.cancel()
future = Future()
last_f = lambda: future.set_result(func(*args, **kwargs)) # noqa
throttle.triggered.connect(last_f)
throttle.throttle()
return future
setattr(inner, "cancel", throttle.cancel)
setattr(inner, "flush", throttle.flush)
setattr(inner, "set_timeout", throttle.setTimeout)
setattr(inner, "triggered", throttle.triggered)
return inner # type: ignore
return deco(func) if func is not None else deco

87
tests/test_collapsible.py Normal file
View File

@@ -0,0 +1,87 @@
"""A test module for testing collapsible"""
from qtpy.QtCore import QEasingCurve
from qtpy.QtWidgets import QPushButton
from superqt import QCollapsible
def test_checked_initialization(qtbot):
"""Test simple collapsible"""
wdg1 = QCollapsible("Advanced analysis")
wdg1.expand(False)
assert wdg1.isExpanded()
assert wdg1._content.maximumHeight() > 0
wdg2 = QCollapsible("Advanced analysis")
wdg1.collapse(False)
assert not wdg2.isExpanded()
assert wdg2._content.maximumHeight() == 0
def test_content_hide_show(qtbot):
"""Test collapsible with content"""
# Create child component
collapsible = QCollapsible("Advanced analysis")
for i in range(10):
collapsible.addWidget(QPushButton(f"Content button {i + 1}"))
collapsible.collapse(False)
assert not collapsible.isExpanded()
assert collapsible._content.maximumHeight() == 0
collapsible.expand(False)
assert collapsible.isExpanded()
assert collapsible._content.maximumHeight() > 0
def test_locking(qtbot):
"""Test locking collapsible"""
wdg1 = QCollapsible()
assert wdg1.locked() is False
wdg1.setLocked(True)
assert wdg1.locked() is True
assert not wdg1.isExpanded()
wdg1._toggle_btn.setChecked(True)
assert not wdg1.isExpanded()
wdg1._toggle()
assert not wdg1.isExpanded()
wdg1.expand()
assert not wdg1.isExpanded()
wdg1._toggle_btn.setChecked(False)
assert not wdg1.isExpanded()
wdg1.setLocked(False)
wdg1.expand()
assert wdg1.isExpanded()
assert wdg1._toggle_btn.isChecked()
def test_changing_animation_settings(qtbot):
"""Quick test for changing animation settings"""
wdg = QCollapsible()
wdg.setDuration(600)
wdg.setEasingCurve(QEasingCurve.Type.InElastic)
assert wdg._animation.easingCurve() == QEasingCurve.Type.InElastic
assert wdg._animation.duration() == 600
def test_changing_content(qtbot):
"""Test changing the content"""
content = QPushButton()
wdg = QCollapsible()
wdg.setContent(content)
assert wdg._content == content
def test_changing_text(qtbot):
"""Test changing the content"""
wdg = QCollapsible()
wdg.setText("Hi new text")
assert wdg.text() == "Hi new text"
assert wdg._toggle_btn.text() == QCollapsible._COLLAPSED + "Hi new text"

View File

@@ -0,0 +1,71 @@
import platform
from qtpy.QtCore import QSize, Qt
from qtpy.QtGui import QResizeEvent
from superqt import QElidingLabel
TEXT = (
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do "
"eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad "
"minim ven iam, quis nostrud exercitation ullamco laborisnisi ut aliquip "
"ex ea commodo consequat. Duis aute irure dolor inreprehenderit in voluptate "
"velit esse cillum dolore eu fugiat nullapariatur."
)
ELLIPSIS = ""
def test_eliding_label(qtbot):
wdg = QElidingLabel(TEXT)
qtbot.addWidget(wdg)
assert wdg._elidedText().endswith(ELLIPSIS)
oldsize = wdg.size()
newsize = QSize(200, 20)
wdg.resize(newsize)
wdg.resizeEvent(QResizeEvent(oldsize, newsize)) # for test coverage
assert wdg.text() == TEXT
def test_wrapped_eliding_label(qtbot):
wdg = QElidingLabel(TEXT)
qtbot.addWidget(wdg)
assert not wdg.wordWrap()
assert 630 < wdg.sizeHint().width() < 640
assert wdg._elidedText().endswith("")
wdg.resize(QSize(200, 100))
assert wdg.text() == TEXT
assert wdg._elidedText().endswith("")
wdg.setWordWrap(True)
assert wdg.wordWrap()
assert wdg.text() == TEXT
assert wdg._elidedText().endswith("")
# just empirically from CI ... stupid
if platform.system() == "Linux":
assert wdg.sizeHint() in (QSize(200, 198), QSize(200, 154))
elif platform.system() == "Windows":
assert wdg.sizeHint() in (QSize(200, 160), QSize(200, 118))
elif platform.system() == "Darwin":
assert wdg.sizeHint() == QSize(200, 176)
# TODO: figure out how to test these on all platforms on CI
wdg.resize(wdg.sizeHint())
assert wdg._elidedText() == TEXT
def test_shorter_eliding_label(qtbot):
short = "asd a ads sd flksdf dsf lksfj sd lsdjf sd lsdfk sdlkfj s"
wdg = QElidingLabel()
qtbot.addWidget(wdg)
wdg.setText(short)
assert not wdg._elidedText().endswith(ELLIPSIS)
wdg.resize(100, 20)
assert wdg._elidedText().endswith(ELLIPSIS)
wdg.setElideMode(Qt.TextElideMode.ElideLeft)
assert wdg._elidedText().startswith(ELLIPSIS)
assert wdg.elideMode() == Qt.TextElideMode.ElideLeft
def test_wrap_text():
wrap = QElidingLabel.wrapText(TEXT, 200)
assert isinstance(wrap, list)
assert all(isinstance(x, str) for x in wrap)
assert 9 <= len(wrap) <= 13

219
tests/test_ensure_thread.py Normal file
View File

@@ -0,0 +1,219 @@
import inspect
import os
import time
from concurrent.futures import Future, TimeoutError
import pytest
from qtpy.QtCore import QCoreApplication, QObject, QThread, Signal
from superqt.utils import ensure_main_thread, ensure_object_thread
skip_on_ci = pytest.mark.skipif(bool(os.getenv("CI")), reason="github hangs")
class SampleObject(QObject):
assigment_done = Signal()
def __init__(self):
super().__init__()
self.main_thread_res = {}
self.object_thread_res = {}
self.main_thread_prop_val = None
self.sample_thread_prop_val = None
def long_wait(self):
time.sleep(1)
@property
def sample_main_thread_property(self):
return self.main_thread_prop_val
@sample_main_thread_property.setter # type: ignore
@ensure_main_thread()
def sample_main_thread_property(self, value):
if QThread.currentThread() is not QCoreApplication.instance().thread():
raise RuntimeError("Wrong thread")
self.main_thread_prop_val = value
self.assigment_done.emit()
@property
def sample_object_thread_property(self):
return self.sample_thread_prop_val
@sample_object_thread_property.setter # type: ignore
@ensure_object_thread()
def sample_object_thread_property(self, value):
if QThread.currentThread() is not self.thread():
raise RuntimeError("Wrong thread")
self.sample_thread_prop_val = value
self.assigment_done.emit()
@ensure_main_thread
def check_main_thread(self, a, *, b=1):
if QThread.currentThread() is not QCoreApplication.instance().thread():
raise RuntimeError("Wrong thread")
self.main_thread_res = {"a": a, "b": b}
self.assigment_done.emit()
@ensure_object_thread
def check_object_thread(self, a, *, b=1):
if QThread.currentThread() is not self.thread():
raise RuntimeError("Wrong thread")
self.object_thread_res = {"a": a, "b": b}
self.assigment_done.emit()
@ensure_object_thread(await_return=True)
def check_object_thread_return(self, a):
if QThread.currentThread() is not self.thread():
raise RuntimeError("Wrong thread")
return a * 7
@ensure_object_thread(await_return=True, timeout=200)
def check_object_thread_return_timeout(self, a):
if QThread.currentThread() is not self.thread():
raise RuntimeError("Wrong thread")
time.sleep(1)
return a * 7
@ensure_object_thread(await_return=False)
def check_object_thread_return_future(self, a: int):
"""sample docstring"""
if QThread.currentThread() is not self.thread():
raise RuntimeError("Wrong thread")
time.sleep(0.4)
return a * 7
@ensure_main_thread(await_return=True)
def check_main_thread_return(self, a):
if QThread.currentThread() is not QCoreApplication.instance().thread():
raise RuntimeError("Wrong thread")
return a * 8
class LocalThread(QThread):
def __init__(self, ob):
super().__init__()
self.ob = ob
def run(self):
assert QThread.currentThread() is not QCoreApplication.instance().thread()
self.ob.check_main_thread(5, b=8)
self.ob.main_thread_prop_val = "text2"
class LocalThread2(QThread):
def __init__(self, ob):
super().__init__()
self.ob = ob
self.executed = False
def run(self):
assert QThread.currentThread() is not QCoreApplication.instance().thread()
assert self.ob.check_main_thread_return(5) == 40
self.executed = True
def test_only_main_thread(qapp):
ob = SampleObject()
ob.check_main_thread(1, b=3)
assert ob.main_thread_res == {"a": 1, "b": 3}
ob.check_object_thread(2, b=4)
assert ob.object_thread_res == {"a": 2, "b": 4}
ob.sample_main_thread_property = 5
assert ob.sample_main_thread_property == 5
ob.sample_object_thread_property = 7
assert ob.sample_object_thread_property == 7
def test_main_thread(qtbot):
ob = SampleObject()
t = LocalThread(ob)
with qtbot.waitSignal(t.finished):
t.start()
assert ob.main_thread_res == {"a": 5, "b": 8}
assert ob.sample_main_thread_property == "text2"
def test_main_thread_return(qtbot):
ob = SampleObject()
t = LocalThread2(ob)
with qtbot.wait_signal(t.finished):
t.start()
assert t.executed
def test_names(qapp):
ob = SampleObject()
assert ob.check_object_thread.__name__ == "check_object_thread"
assert ob.check_object_thread_return.__name__ == "check_object_thread_return"
assert (
ob.check_object_thread_return_timeout.__name__
== "check_object_thread_return_timeout"
)
assert (
ob.check_object_thread_return_future.__name__
== "check_object_thread_return_future"
)
assert ob.check_object_thread_return_future.__doc__ == "sample docstring"
signature = inspect.signature(ob.check_object_thread_return_future)
assert len(signature.parameters) == 1
assert list(signature.parameters.values())[0].name == "a"
assert list(signature.parameters.values())[0].annotation == int
assert ob.check_main_thread_return.__name__ == "check_main_thread_return"
@skip_on_ci
def test_object_thread_return(qtbot):
ob = SampleObject()
thread = QThread()
thread.start()
ob.moveToThread(thread)
assert ob.check_object_thread_return(2) == 14
assert ob.thread() is thread
with qtbot.waitSignal(thread.finished):
thread.quit()
@skip_on_ci
def test_object_thread_return_timeout(qtbot):
ob = SampleObject()
thread = QThread()
thread.start()
ob.moveToThread(thread)
with pytest.raises(TimeoutError):
ob.check_object_thread_return_timeout(2)
with qtbot.waitSignal(thread.finished):
thread.quit()
@skip_on_ci
def test_object_thread_return_future(qtbot):
ob = SampleObject()
thread = QThread()
thread.start()
ob.moveToThread(thread)
future = ob.check_object_thread_return_future(2)
assert isinstance(future, Future)
assert future.result() == 14
with qtbot.waitSignal(thread.finished):
thread.quit()
@skip_on_ci
def test_object_thread(qtbot):
ob = SampleObject()
thread = QThread()
thread.start()
ob.moveToThread(thread)
with qtbot.waitSignal(ob.assigment_done):
ob.check_object_thread(2, b=4)
assert ob.object_thread_res == {"a": 2, "b": 4}
with qtbot.waitSignal(ob.assigment_done):
ob.sample_object_thread_property = "text"
assert ob.sample_object_thread_property == "text"
assert ob.thread() is thread
with qtbot.waitSignal(thread.finished):
thread.quit()

129
tests/test_enum_comb_box.py Normal file
View File

@@ -0,0 +1,129 @@
from enum import Enum
import pytest
from superqt.combobox import QEnumComboBox
from superqt.combobox._enum_combobox import NONE_STRING
class Enum1(Enum):
a = 1
b = 2
c = 3
class Enum2(Enum):
d = 1
e = 2
f = 3
g = 4
class Enum3(Enum):
a = 1
b = 2
c = 3
def __str__(self):
return self.name + "1"
class Enum4(Enum):
a_1 = 1
b_2 = 2
c_3 = 3
def test_simple_create(qtbot):
enum = QEnumComboBox(enum_class=Enum1)
qtbot.addWidget(enum)
assert enum.count() == 3
assert [enum.itemText(i) for i in range(enum.count())] == ["a", "b", "c"]
def test_simple_create2(qtbot):
enum = QEnumComboBox()
qtbot.addWidget(enum)
assert enum.count() == 0
enum.setEnumClass(Enum1)
assert enum.count() == 3
assert [enum.itemText(i) for i in range(enum.count())] == ["a", "b", "c"]
def test_replace(qtbot):
enum = QEnumComboBox(enum_class=Enum1)
qtbot.addWidget(enum)
assert enum.count() == 3
assert enum.enumClass() == Enum1
assert isinstance(enum.currentEnum(), Enum1)
enum.setEnumClass(Enum2)
assert enum.enumClass() == Enum2
assert isinstance(enum.currentEnum(), Enum2)
assert enum.count() == 4
assert [enum.itemText(i) for i in range(enum.count())] == ["d", "e", "f", "g"]
def test_str_replace(qtbot):
enum = QEnumComboBox(enum_class=Enum3)
qtbot.addWidget(enum)
assert enum.count() == 3
assert [enum.itemText(i) for i in range(enum.count())] == ["a1", "b1", "c1"]
def test_underscore_replace(qtbot):
enum = QEnumComboBox(enum_class=Enum4)
qtbot.addWidget(enum)
assert enum.count() == 3
assert [enum.itemText(i) for i in range(enum.count())] == ["a 1", "b 2", "c 3"]
def test_change_value(qtbot):
enum = QEnumComboBox(enum_class=Enum1)
qtbot.addWidget(enum)
assert enum.currentEnum() == Enum1.a
with qtbot.waitSignal(
enum.currentEnumChanged, check_params_cb=lambda x: isinstance(x, Enum)
):
enum.setCurrentEnum(Enum1.c)
assert enum.currentEnum() == Enum1.c
def test_no_enum(qtbot):
enum = QEnumComboBox()
assert enum.enumClass() is None
qtbot.addWidget(enum)
assert enum.currentEnum() is None
def test_prohibited_methods(qtbot):
enum = QEnumComboBox(enum_class=Enum1)
qtbot.addWidget(enum)
with pytest.raises(RuntimeError):
enum.addItem("aaa")
with pytest.raises(RuntimeError):
enum.addItems(["aaa", "bbb"])
with pytest.raises(RuntimeError):
enum.insertItem(0, "aaa")
with pytest.raises(RuntimeError):
enum.insertItems(0, ["aaa", "bbb"])
assert enum.count() == 3
def test_optional(qtbot):
enum = QEnumComboBox(enum_class=Enum1, allow_none=True)
qtbot.addWidget(enum)
assert [enum.itemText(i) for i in range(enum.count())] == [
NONE_STRING,
"a",
"b",
"c",
]
assert enum.currentText() == NONE_STRING
assert enum.currentEnum() is None
enum.setCurrentEnum(Enum1.a)
assert enum.currentText() == "a"
assert enum.currentEnum() == Enum1.a
assert enum.enumClass() is Enum1
enum.setCurrentEnum(None)
assert enum.currentText() == NONE_STRING
assert enum.currentEnum() is None

Binary file not shown.

View File

@@ -0,0 +1,135 @@
from pathlib import Path
import pytest
from qtpy.QtGui import QIcon, QPixmap
from qtpy.QtWidgets import QPushButton
from superqt.fonticon import icon, pulse, setTextIcon, spin
from superqt.fonticon._qfont_icon import QFontIconStore, _ensure_identifier
TEST_PREFIX = "ico"
TEST_CHARNAME = "smiley"
TEST_CHAR = "\ue900"
TEST_GLYPHKEY = f"{TEST_PREFIX}.{TEST_CHARNAME}"
FONT_FILE = Path(__file__).parent / "icontest.ttf"
@pytest.fixture
def store(qapp):
store = QFontIconStore().instance()
yield store
store.clear()
@pytest.fixture
def full_store(store):
store.addFont(str(FONT_FILE), TEST_PREFIX, {TEST_CHARNAME: TEST_CHAR})
return store
def test_no_font_key():
with pytest.raises(KeyError) as err:
icon(TEST_GLYPHKEY)
assert "Unrecognized font key: {TEST_PREFIX!r}." in str(err)
def test_no_charmap(store):
store.addFont(str(FONT_FILE), TEST_PREFIX)
with pytest.raises(KeyError) as err:
icon(TEST_GLYPHKEY)
assert "No charmap registered for" in str(err)
def test_font_icon_works(full_store):
icn = icon(TEST_GLYPHKEY)
assert isinstance(icn, QIcon)
assert isinstance(icn.pixmap(40, 40), QPixmap)
icn = icon(f"{TEST_PREFIX}.{TEST_CHAR}") # also works with unicode key
assert isinstance(icn, QIcon)
assert isinstance(icn.pixmap(40, 40), QPixmap)
with pytest.raises(ValueError) as err:
icon(f"{TEST_PREFIX}.smelly") # bad name
assert "Font 'test (Regular)' has no glyph with the key 'smelly'" in str(err)
def test_on_button(full_store, qtbot):
btn = QPushButton(None)
qtbot.addWidget(btn)
btn.setIcon(icon(TEST_GLYPHKEY))
def test_btn_text_icon(full_store, qtbot):
btn = QPushButton(None)
qtbot.addWidget(btn)
setTextIcon(btn, TEST_GLYPHKEY)
assert btn.text() == TEST_CHAR
def test_animation(full_store, qtbot):
btn = QPushButton(None)
qtbot.addWidget(btn)
icn = icon(TEST_GLYPHKEY, animation=pulse(btn))
btn.setIcon(icn)
with qtbot.waitSignal(icn._engine._default_opts.animation.timer.timeout):
icn.pixmap(40, 40)
btn.update()
def test_multistate(full_store, qtbot, qapp):
"""complicated multistate icon"""
btn = QPushButton()
qtbot.addWidget(btn)
icn = icon(
TEST_GLYPHKEY,
color="blue",
states={
"active": {
"color": "red",
"scale_factor": 0.5,
"animation": pulse(btn),
},
"disabled": {
"color": "green",
"scale_factor": 0.8,
"animation": spin(btn),
},
},
)
btn.setIcon(icn)
btn.show()
btn.setEnabled(False)
active = icn._engine._opts[QIcon.State.Off][QIcon.Mode.Active].animation.timer
disabled = icn._engine._opts[QIcon.State.Off][QIcon.Mode.Disabled].animation.timer
with qtbot.waitSignal(active.timeout, timeout=1000):
btn.setEnabled(True)
# hack to get the signal emitted
icn.pixmap(100, 100, QIcon.Mode.Active, QIcon.State.Off)
assert active.isActive()
assert not disabled.isActive()
with qtbot.waitSignal(disabled.timeout):
btn.setEnabled(False)
assert disabled.isActive()
# smoke test, paint all the states
icn.pixmap(100, 100, QIcon.Mode.Active, QIcon.State.Off)
icn.pixmap(100, 100, QIcon.Mode.Disabled, QIcon.State.Off)
icn.pixmap(100, 100, QIcon.Mode.Selected, QIcon.State.Off)
icn.pixmap(100, 100, QIcon.Mode.Normal, QIcon.State.Off)
icn.pixmap(100, 100, QIcon.Mode.Active, QIcon.State.On)
icn.pixmap(100, 100, QIcon.Mode.Disabled, QIcon.State.On)
icn.pixmap(100, 100, QIcon.Mode.Selected, QIcon.State.On)
icn.pixmap(100, 100, QIcon.Mode.Normal, QIcon.State.On)
def test_ensure_identifier():
assert _ensure_identifier("") == ""
assert _ensure_identifier("1a") == "_1a"
assert _ensure_identifier("from") == "from_"
assert _ensure_identifier("hello-world") == "hello_world"
assert _ensure_identifier("hello_world") == "hello_world"
assert _ensure_identifier("hello world") == "hello_world"

View File

@@ -0,0 +1,54 @@
import sys
from pathlib import Path
import pytest
from qtpy.QtGui import QIcon, QPixmap
from superqt.fonticon import _plugins, icon
from superqt.fonticon._qfont_icon import QFontIconStore
try:
from importlib.metadata import Distribution
except ImportError:
from importlib_metadata import Distribution # type: ignore
class ICO:
__font_file__ = str(Path(__file__).parent / "icontest.ttf")
smiley = "ico.\ue900"
@pytest.fixture
def plugin_store(qapp, monkeypatch):
class MockEntryPoint:
name = "ico"
group = _plugins.FontIconManager.ENTRY_POINT
value = "fake_plugin.ICO"
def load(self):
return ICO
class MockFinder:
def find_distributions(self, *a):
class D(Distribution):
name = "mock"
@property
def entry_points(self):
return [MockEntryPoint()]
return [D()]
store = QFontIconStore().instance()
with monkeypatch.context() as m:
m.setattr(sys, "meta_path", [MockFinder()])
yield store
store.clear()
def test_plugin(plugin_store):
assert not _plugins.loaded()
icn = icon("ico.smiley")
assert _plugins.loaded() == {"ico": ["smiley"]}
assert isinstance(icn, QIcon)
assert isinstance(icn.pixmap(40, 40), QPixmap)

View File

@@ -0,0 +1,74 @@
from qtpy.QtCore import Qt
from superqt.spinbox import QLargeIntSpinBox
def test_large_spinbox(qtbot):
sb = QLargeIntSpinBox()
qtbot.addWidget(sb)
for e in range(2, 100, 2):
sb.setMaximum(10**e + 2)
with qtbot.waitSignal(sb.valueChanged) as sgnl:
sb.setValue(10**e)
assert sgnl.args == [10**e]
assert sb.value() == 10**e
sb.setMinimum(-(10**e) - 2)
with qtbot.waitSignal(sb.valueChanged) as sgnl:
sb.setValue(-(10**e))
assert sgnl.args == [-(10**e)]
assert sb.value() == -(10**e)
def test_large_spinbox_type(qtbot):
sb = QLargeIntSpinBox()
qtbot.addWidget(sb)
assert isinstance(sb.value(), int)
sb.setValue(1.1)
assert isinstance(sb.value(), int)
assert sb.value() == 1
sb.setValue(1.9)
assert isinstance(sb.value(), int)
assert sb.value() == 1
def test_large_spinbox_signals(qtbot):
sb = QLargeIntSpinBox()
qtbot.addWidget(sb)
with qtbot.waitSignal(sb.valueChanged) as sgnl:
sb.setValue(200)
assert sgnl.args == [200]
with qtbot.waitSignal(sb.textChanged) as sgnl:
sb.setValue(240)
assert sgnl.args == ["240"]
def test_keyboard_tracking(qtbot):
sb = QLargeIntSpinBox()
qtbot.addWidget(sb)
assert sb.value() == 0
sb.setKeyboardTracking(False)
with qtbot.assertNotEmitted(sb.valueChanged):
sb.lineEdit().setText("20")
assert sb.lineEdit().text() == "20"
assert sb.value() == 0
assert sb._pending_emit is True
with qtbot.waitSignal(sb.valueChanged) as sgnl:
qtbot.keyPress(sb, Qt.Key.Key_Enter)
assert sgnl.args == [20]
assert sb._pending_emit is False
sb.setKeyboardTracking(True)
with qtbot.waitSignal(sb.valueChanged) as sgnl:
sb.lineEdit().setText("25")
assert sb._pending_emit is False
assert sgnl.args == [25]

View File

@@ -0,0 +1,36 @@
import logging
from qtpy import QtCore
from superqt import QMessageHandler
def test_message_handler():
with QMessageHandler() as mh:
QtCore.qDebug("debug")
QtCore.qWarning("warning")
QtCore.qCritical("critical")
assert len(mh.records) == 3
assert mh.records[0].level == logging.DEBUG
assert mh.records[1].level == logging.WARNING
assert mh.records[2].level == logging.CRITICAL
assert "3 records" in repr(mh)
def test_message_handler_with_logger(caplog):
logger = logging.getLogger("test_logger")
caplog.set_level(logging.DEBUG, logger="test_logger")
with QMessageHandler(logger):
QtCore.qDebug("debug")
QtCore.qWarning("warning")
QtCore.qCritical("critical")
assert len(caplog.records) == 3
caplog.records[0].message == "debug"
caplog.records[0].levelno == logging.DEBUG
caplog.records[1].message == "warning"
caplog.records[1].levelno == logging.WARNING
caplog.records[2].message == "critical"
caplog.records[2].levelno == logging.CRITICAL

View File

@@ -0,0 +1,35 @@
from superqt import QSearchableComboBox
class TestSearchableComboBox:
def test_constructor(self, qtbot):
widget = QSearchableComboBox()
qtbot.addWidget(widget)
def test_add_items(self, qtbot):
widget = QSearchableComboBox()
qtbot.addWidget(widget)
widget.addItems(["foo", "bar"])
assert widget.completer_object.model().rowCount() == 2
widget.addItem("foobar")
assert widget.completer_object.model().rowCount() == 3
widget.insertItem(1, "baz")
assert widget.completer_object.model().rowCount() == 4
widget.insertItems(2, ["bazbar", "foobaz"])
assert widget.completer_object.model().rowCount() == 6
assert widget.itemText(0) == "foo"
assert widget.itemText(1) == "baz"
assert widget.itemText(2) == "bazbar"
def test_completion(self, qtbot):
widget = QSearchableComboBox()
qtbot.addWidget(widget)
widget.addItems(["foo", "bar", "foobar", "baz", "bazbar", "foobaz"])
widget.completer_object.setCompletionPrefix("fo")
assert widget.completer_object.completionCount() == 3
assert widget.completer_object.currentCompletion() == "foo"
widget.completer_object.setCurrentRow(1)
assert widget.completer_object.currentCompletion() == "foobar"
widget.completer_object.setCurrentRow(2)
assert widget.completer_object.currentCompletion() == "foobaz"

View File

@@ -0,0 +1,34 @@
from superqt import QSearchableListWidget
class TestSearchableListWidget:
def test_create(self, qtbot):
widget = QSearchableListWidget()
qtbot.addWidget(widget)
widget.addItem("aaa")
assert widget.count() == 1
def test_add_items(self, qtbot):
widget = QSearchableListWidget()
qtbot.addWidget(widget)
widget.addItems(["foo", "bar"])
assert widget.count() == 2
widget.insertItems(1, ["baz", "foobaz"])
widget.insertItem(2, "foobar")
assert widget.count() == 5
assert widget.item(0).text() == "foo"
assert widget.item(1).text() == "baz"
assert widget.item(2).text() == "foobar"
def test_completion(self, qtbot):
widget = QSearchableListWidget()
qtbot.addWidget(widget)
widget.show()
widget.addItems(["foo", "bar", "foobar", "baz", "bazbar", "foobaz"])
widget.filter_widget.setText("fo")
assert widget.count() == 6
for i in range(widget.count()):
item = widget.item(i)
assert item.isHidden() == ("fo" not in item.text())
widget.hide()

View File

View File

@@ -0,0 +1,85 @@
from contextlib import suppress
from platform import system
import pytest
from qtpy import QT_VERSION
from qtpy.QtCore import QEvent, QPoint, QPointF, Qt
from qtpy.QtGui import QHoverEvent, QMouseEvent, QWheelEvent
QT_VERSION = tuple(int(x) for x in QT_VERSION.split("."))
SYS_DARWIN = system() == "Darwin"
skip_on_linux_qt6 = pytest.mark.skipif(
system() == "Linux" and QT_VERSION >= (6, 0),
reason="hover events not working on linux pyqt6",
)
def _mouse_event(pos=QPointF(), type_=QEvent.Type.MouseMove):
"""Create a mouse event of `type_` at `pos`."""
return QMouseEvent(
type_,
QPointF(pos),
Qt.MouseButton.LeftButton,
Qt.MouseButton.LeftButton,
Qt.KeyboardModifier.NoModifier,
)
def _wheel_event(arc):
"""Create a wheel event with `arc`."""
with suppress(TypeError):
return QWheelEvent(
QPointF(),
QPointF(),
QPoint(arc, arc),
QPoint(arc, arc),
Qt.MouseButton.NoButton,
Qt.KeyboardModifier.NoModifier,
Qt.ScrollPhase.ScrollBegin,
False,
Qt.MouseEventSource.MouseEventSynthesizedByQt,
)
with suppress(TypeError):
return QWheelEvent(
QPointF(),
QPointF(),
QPoint(-arc, -arc),
QPoint(-arc, -arc),
1,
Qt.Orientation.Vertical,
Qt.MouseButton.NoButton,
Qt.KeyboardModifier.NoModifier,
Qt.ScrollPhase.ScrollBegin,
False,
Qt.MouseEventSource.MouseEventSynthesizedByQt,
)
return QWheelEvent(
QPointF(),
QPointF(),
QPoint(arc, arc),
QPoint(arc, arc),
1,
Qt.Orientation.Vertical,
Qt.MouseButton.NoButton,
Qt.KeyboardModifier.NoModifier,
)
def _hover_event(_type, position, old_position, widget=None):
with suppress(TypeError):
return QHoverEvent(
_type,
position,
widget.mapToGlobal(position),
old_position,
)
return QHoverEvent(_type, position, old_position)
def _linspace(start, stop, n):
h = (stop - start) / (n - 1)
for i in range(n):
yield start + h * i

View File

@@ -1,14 +1,14 @@
import os
import pytest
from qtpy import API_NAME
from qtrangeslider import (
from superqt import (
QDoubleRangeSlider,
QDoubleSlider,
QLabeledDoubleRangeSlider,
QLabeledDoubleSlider,
)
from qtrangeslider.qtcompat import API_NAME
range_types = {QDoubleRangeSlider, QLabeledDoubleRangeSlider}
@@ -62,15 +62,13 @@ def test_double_sliders(ds):
ds.assert_val_eq((20, 40))
assert ds.singleStep() == 1
ds.setDecimals(2)
ds.assert_val_eq((20, 40))
ds.assert_val_type()
ds.setValue((20.23435, 40.2342))
ds.assert_val_eq((20.23, 40.23)) # because of decimals
ds.setValue((20.23, 40.23))
ds.assert_val_eq((20.23, 40.23))
ds.assert_val_type()
ds.setDecimals(4)
assert ds.minimum() == 10
assert ds.maximum() == 99
assert ds.singleStep() == 1
@@ -78,16 +76,11 @@ def test_double_sliders(ds):
ds.setValue((20.2343, 40.2342))
ds.assert_val_eq((20.2343, 40.2342))
ds.setDecimals(6)
ds.assert_val_eq((20.2343, 40.2342))
assert ds.minimum() == 10
assert ds.maximum() == 99
assert ds.singleStep() == 1
with pytest.raises(OverflowError) as err:
ds.setDecimals(8)
assert "open a feature request" in str(err)
ds.assert_val_eq((20.2343, 40.2342))
assert ds.minimum() == 10
assert ds.maximum() == 99
@@ -96,7 +89,6 @@ def test_double_sliders(ds):
def test_double_sliders_small(ds):
ds.setMaximum(1)
ds.setDecimals(8)
ds.setValue((0.5, 0.9))
assert ds.minimum() == 0
assert ds.maximum() == 1
@@ -108,8 +100,6 @@ def test_double_sliders_small(ds):
def test_double_sliders_big(ds):
ds.setValue((20, 80))
ds.setDecimals(-6)
assert ds.decimals() == -6
ds.setMaximum(5e14)
assert ds.minimum() == 0
assert ds.maximum() == 5e14

View File

@@ -0,0 +1,205 @@
import math
import platform
import pytest
from qtpy.QtCore import QEvent, QPoint, QPointF, Qt
from qtpy.QtWidgets import QStyle, QStyleOptionSlider
from superqt.sliders._generic_slider import _GenericSlider, _sliderValueFromPosition
from ._testutil import (
_hover_event,
_linspace,
_mouse_event,
_wheel_event,
skip_on_linux_qt6,
)
@pytest.fixture(params=[Qt.Orientation.Horizontal, Qt.Orientation.Vertical])
def gslider(qtbot, request):
slider = _GenericSlider(request.param)
qtbot.addWidget(slider)
assert slider.value() == 0
assert slider.minimum() == 0
assert slider.maximum() == 99
yield slider
slider.initStyleOption(QStyleOptionSlider())
def test_change_floatslider_range(gslider: _GenericSlider, qtbot):
with qtbot.waitSignals([gslider.rangeChanged, gslider.valueChanged]):
gslider.setMinimum(10)
assert gslider.value() == 10 == gslider.minimum()
assert gslider.maximum() == 99
with qtbot.waitSignal(gslider.rangeChanged):
gslider.setMaximum(90)
assert gslider.value() == 10 == gslider.minimum()
assert gslider.maximum() == 90
with qtbot.waitSignals([gslider.rangeChanged, gslider.valueChanged]):
gslider.setRange(20, 40)
assert gslider.value() == 20 == gslider.minimum()
assert gslider.maximum() == 40
with qtbot.waitSignal(gslider.valueChanged):
gslider.setValue(30)
assert gslider.value() == 30
with qtbot.waitSignals([gslider.rangeChanged, gslider.valueChanged]):
gslider.setMaximum(25)
assert gslider.value() == 25 == gslider.maximum()
assert gslider.minimum() == 20
def test_float_values(gslider: _GenericSlider, qtbot):
with qtbot.waitSignal(gslider.rangeChanged):
gslider.setRange(0.25, 0.75)
assert gslider.minimum() == 0.25
assert gslider.maximum() == 0.75
with qtbot.waitSignal(gslider.valueChanged):
gslider.setValue(0.55)
assert gslider.value() == 0.55
with qtbot.waitSignal(gslider.valueChanged):
gslider.setValue(1.55)
assert gslider.value() == 0.75 == gslider.maximum()
def test_ticks(gslider: _GenericSlider, qtbot):
gslider.setTickInterval(0.3)
assert gslider.tickInterval() == 0.3
gslider.setTickPosition(gslider.TickPosition.TicksAbove)
gslider.show()
def test_show(gslider, qtbot):
gslider.show()
@pytest.mark.skipif(platform.system() != "Darwin", reason="cross-platform is tricky")
def test_press_move_release(gslider: _GenericSlider, qtbot):
# this fail on vertical came with pyside6.2 ... need to debug
# still works in practice, but test fails to catch signals
if gslider.orientation() == Qt.Orientation.Vertical:
pytest.xfail()
assert gslider._pressedControl == QStyle.SubControl.SC_None
opt = QStyleOptionSlider()
gslider.initStyleOption(opt)
style = gslider.style()
hrect = style.subControlRect(
QStyle.ComplexControl.CC_Slider, opt, QStyle.SubControl.SC_SliderHandle
)
handle_pos = gslider.mapToGlobal(hrect.center())
with qtbot.waitSignal(gslider.sliderPressed):
qtbot.mousePress(gslider, Qt.MouseButton.LeftButton, pos=handle_pos)
assert gslider._pressedControl == QStyle.SubControl.SC_SliderHandle
with qtbot.waitSignals([gslider.sliderMoved, gslider.valueChanged]):
shift = (
QPoint(0, -8)
if gslider.orientation() == Qt.Orientation.Vertical
else QPoint(8, 0)
)
gslider.mouseMoveEvent(_mouse_event(handle_pos + shift))
with qtbot.waitSignal(gslider.sliderReleased):
qtbot.mouseRelease(gslider, Qt.MouseButton.LeftButton, pos=handle_pos)
assert gslider._pressedControl == QStyle.SubControl.SC_None
gslider.show()
with qtbot.waitSignal(gslider.sliderPressed):
qtbot.mousePress(gslider, Qt.MouseButton.LeftButton, pos=handle_pos)
@skip_on_linux_qt6
def test_hover(gslider: _GenericSlider):
# stub
opt = QStyleOptionSlider()
gslider.initStyleOption(opt)
style = gslider.style()
hrect = style.subControlRect(
QStyle.ComplexControl.CC_Slider, opt, QStyle.SubControl.SC_SliderHandle
)
handle_pos = QPointF(gslider.mapToGlobal(hrect.center()))
assert gslider._hoverControl == QStyle.SubControl.SC_None
gslider.event(_hover_event(QEvent.Type.HoverEnter, handle_pos, QPointF(), gslider))
assert gslider._hoverControl == QStyle.SubControl.SC_SliderHandle
gslider.event(
_hover_event(QEvent.Type.HoverLeave, QPointF(-1000, -1000), handle_pos, gslider)
)
assert gslider._hoverControl == QStyle.SubControl.SC_None
def test_wheel(gslider: _GenericSlider, qtbot):
with qtbot.waitSignal(gslider.valueChanged):
gslider.wheelEvent(_wheel_event(120))
gslider.wheelEvent(_wheel_event(0))
def test_position(gslider: _GenericSlider, qtbot):
gslider.setSliderPosition(21.2)
assert gslider.sliderPosition() == 21.2
def test_steps(gslider: _GenericSlider, qtbot):
gslider.setSingleStep(0.1)
assert gslider.singleStep() == 0.1
gslider.setSingleStep(1.5e20)
assert gslider.singleStep() == 1.5e20
gslider.setPageStep(0.2)
assert gslider.pageStep() == 0.2
gslider.setPageStep(1.5e30)
assert gslider.pageStep() == 1.5e30
@pytest.mark.parametrize("mag", list(range(4, 37, 4)) + list(range(-4, -37, -4)))
def test_slider_extremes(gslider: _GenericSlider, mag, qtbot):
_mag = 10**mag
with qtbot.waitSignal(gslider.rangeChanged):
gslider.setRange(-_mag, _mag)
for i in _linspace(-_mag, _mag, 10):
gslider.setValue(i)
assert math.isclose(gslider.value(), i, rel_tol=1e-8)
gslider.initStyleOption(QStyleOptionSlider())
# args are (min: float, max: float, position: int, span: int, upsideDown: bool)
@pytest.mark.parametrize(
"args, result",
[
# (min, max, pos, span[, inverted]), expectation
# data range (1, 2)
((1, 2, 50, 100), 1.5),
((1, 2, 70, 100), 1.7),
((1, 2, 70, 100, True), 1.3), # inverted appearance
((1, 2, 170, 100), 2),
((1, 2, 100, 100), 2),
((1, 2, -30, 100), 1),
# data range (-2, 2)
((-2, 2, 50, 100), 0),
((-2, 2, 75, 100), 1),
((-2, 2, 75, 100, True), -1), # inverted appearance
((-2, 2, 170, 100), 2),
((-2, 2, 100, 100), 2),
((-2, 2, -30, 100), -2),
],
)
def test_slider_value_from_position(args, result):
assert math.isclose(_sliderValueFromPosition(*args), result)

View File

@@ -0,0 +1,11 @@
from superqt import QLabeledRangeSlider
def test_labeled_slider_api(qtbot):
slider = QLabeledRangeSlider()
qtbot.addWidget(slider)
slider.hideBar()
slider.showBar()
slider.setBarVisible()
slider.setBarMovesAllHandles()
slider.setBarIsRigid()

View File

@@ -0,0 +1,174 @@
import math
import pytest
from qtpy.QtCore import QEvent, QPoint, QPointF, Qt
from qtpy.QtWidgets import QStyle, QStyleOptionSlider
from superqt import QDoubleRangeSlider, QRangeSlider
from ._testutil import (
_hover_event,
_linspace,
_mouse_event,
_wheel_event,
skip_on_linux_qt6,
)
@pytest.fixture(params=[Qt.Orientation.Horizontal, Qt.Orientation.Vertical])
def gslider(qtbot, request):
slider = QDoubleRangeSlider(request.param)
qtbot.addWidget(slider)
assert slider.value() == (20, 80)
assert slider.minimum() == 0
assert slider.maximum() == 99
yield slider
slider.initStyleOption(QStyleOptionSlider())
def test_change_floatslider_range(gslider: QRangeSlider, qtbot):
with qtbot.waitSignals([gslider.rangeChanged, gslider.valueChanged]):
gslider.setMinimum(30)
assert gslider.value()[0] == 30 == gslider.minimum()
assert gslider.maximum() == 99
with qtbot.waitSignal(gslider.rangeChanged):
gslider.setMaximum(70)
assert gslider.value()[0] == 30 == gslider.minimum()
assert gslider.value()[1] == 70 == gslider.maximum()
with qtbot.waitSignals([gslider.rangeChanged, gslider.valueChanged]):
gslider.setRange(40, 60)
assert gslider.value()[0] == 40 == gslider.minimum()
assert gslider.maximum() == 60
with qtbot.waitSignal(gslider.valueChanged):
gslider.setValue([40, 50])
assert gslider.value()[0] == 40 == gslider.minimum()
assert gslider.value()[1] == 50
with qtbot.waitSignals([gslider.rangeChanged, gslider.valueChanged]):
gslider.setMaximum(45)
assert gslider.value()[0] == 40 == gslider.minimum()
assert gslider.value()[1] == 45 == gslider.maximum()
def test_float_values(gslider: QRangeSlider, qtbot):
with qtbot.waitSignal(gslider.rangeChanged):
gslider.setRange(0.1, 0.9)
assert gslider.minimum() == 0.1
assert gslider.maximum() == 0.9
with qtbot.waitSignal(gslider.valueChanged):
gslider.setValue([0.4, 0.6])
assert gslider.value() == (0.4, 0.6)
with qtbot.waitSignal(gslider.valueChanged):
gslider.setValue([0, 1.9])
assert gslider.value()[0] == 0.1 == gslider.minimum()
assert gslider.value()[1] == 0.9 == gslider.maximum()
def test_position(gslider: QRangeSlider, qtbot):
gslider.setSliderPosition([10, 80])
assert gslider.sliderPosition() == (10, 80)
def test_steps(gslider: QRangeSlider, qtbot):
gslider.setSingleStep(0.1)
assert gslider.singleStep() == 0.1
gslider.setSingleStep(1.5e20)
assert gslider.singleStep() == 1.5e20
gslider.setPageStep(0.2)
assert gslider.pageStep() == 0.2
gslider.setPageStep(1.5e30)
assert gslider.pageStep() == 1.5e30
@pytest.mark.parametrize("mag", list(range(4, 37, 4)) + list(range(-4, -37, -4)))
def test_slider_extremes(gslider: QRangeSlider, mag, qtbot):
_mag = 10**mag
with qtbot.waitSignal(gslider.rangeChanged):
gslider.setRange(-_mag, _mag)
for i in _linspace(-_mag, _mag, 10):
gslider.setValue((i, _mag))
assert math.isclose(gslider.value()[0], i, rel_tol=1e-8)
gslider.initStyleOption(QStyleOptionSlider())
def test_ticks(gslider: QRangeSlider, qtbot):
gslider.setTickInterval(0.3)
assert gslider.tickInterval() == 0.3
gslider.setTickPosition(gslider.TickPosition.TicksAbove)
gslider.show()
def test_show(gslider, qtbot):
gslider.show()
def test_press_move_release(gslider: QRangeSlider, qtbot):
# this fail on vertical came with pyside6.2 ... need to debug
# still works in practice, but test fails to catch signals
if gslider.orientation() == Qt.Orientation.Vertical:
pytest.xfail()
assert gslider._pressedControl == QStyle.SubControl.SC_None
opt = QStyleOptionSlider()
gslider.initStyleOption(opt)
style = gslider.style()
hrect = style.subControlRect(
QStyle.ComplexControl.CC_Slider, opt, QStyle.SubControl.SC_SliderHandle
)
handle_pos = gslider.mapToGlobal(hrect.center())
with qtbot.waitSignal(gslider.sliderPressed):
qtbot.mousePress(gslider, Qt.MouseButton.LeftButton, pos=handle_pos)
assert gslider._pressedControl == QStyle.SubControl.SC_SliderHandle
with qtbot.waitSignals([gslider.sliderMoved, gslider.valueChanged]):
shift = (
QPoint(0, -8)
if gslider.orientation() == Qt.Orientation.Vertical
else QPoint(8, 0)
)
gslider.mouseMoveEvent(_mouse_event(handle_pos + shift))
with qtbot.waitSignal(gslider.sliderReleased):
qtbot.mouseRelease(gslider, Qt.MouseButton.LeftButton, pos=handle_pos)
assert gslider._pressedControl == QStyle.SubControl.SC_None
gslider.show()
with qtbot.waitSignal(gslider.sliderPressed):
qtbot.mousePress(gslider, Qt.MouseButton.LeftButton, pos=handle_pos)
@skip_on_linux_qt6
def test_hover(gslider: QRangeSlider):
hrect = gslider._handleRect(0)
handle_pos = QPointF(gslider.mapToGlobal(hrect.center()))
assert gslider._hoverControl == QStyle.SubControl.SC_None
gslider.event(_hover_event(QEvent.Type.HoverEnter, handle_pos, QPointF(), gslider))
assert gslider._hoverControl == QStyle.SubControl.SC_SliderHandle
gslider.event(
_hover_event(QEvent.Type.HoverLeave, QPointF(-1000, -1000), handle_pos, gslider)
)
assert gslider._hoverControl == QStyle.SubControl.SC_None
def test_wheel(gslider: QRangeSlider, qtbot):
with qtbot.waitSignal(gslider.valueChanged):
gslider.wheelEvent(_wheel_event(120))
gslider.wheelEvent(_wheel_event(0))

View File

@@ -0,0 +1,235 @@
import math
import platform
from contextlib import suppress
import pytest
from qtpy.QtCore import QEvent, QPoint, QPointF, Qt
from qtpy.QtWidgets import QSlider, QStyle, QStyleOptionSlider
from superqt import QDoubleSlider, QLabeledDoubleSlider, QLabeledSlider
from superqt.sliders._generic_slider import _GenericSlider
from ._testutil import (
QT_VERSION,
_hover_event,
_linspace,
_mouse_event,
_wheel_event,
skip_on_linux_qt6,
)
@pytest.fixture(
params=[Qt.Orientation.Horizontal, Qt.Orientation.Vertical],
ids=["horizontal", "vertical"],
)
def orientation(request):
return request.param
START_MI_MAX_VAL = (0, 99, 0)
TEST_SLIDERS = [QDoubleSlider, QLabeledSlider, QLabeledDoubleSlider]
def _assert_value_in_range(sld):
val = sld.value()
if isinstance(val, (int, float)):
val = (val,)
assert all(sld.minimum() <= v <= sld.maximum() for v in val)
@pytest.fixture(params=TEST_SLIDERS)
def sld(request, qtbot, orientation):
Cls = request.param
slider = Cls(orientation)
slider.setRange(*START_MI_MAX_VAL[:2])
slider.setValue(START_MI_MAX_VAL[2])
qtbot.addWidget(slider)
assert (slider.minimum(), slider.maximum(), slider.value()) == START_MI_MAX_VAL
_assert_value_in_range(slider)
yield slider
_assert_value_in_range(slider)
with suppress(AttributeError):
slider.initStyleOption(QStyleOptionSlider())
def called_with(*expected_result):
"""Use in check_params_cbs to assert that a callback is called as expected.
e.g. `called_with(20, 50)` returns a callback that checks that the callback
is called with the arguments (20, 50)
"""
def check_emitted_values(*values):
return values == expected_result
return check_emitted_values
def test_change_floatslider_range(sld: _GenericSlider, qtbot):
BOTH = [sld.rangeChanged, sld.valueChanged]
for signals, checks, funcname, args in [
(BOTH, [called_with(10, 99), called_with(10)], "setMinimum", (10,)),
([sld.rangeChanged], [called_with(10, 90)], "setMaximum", (90,)),
(BOTH, [called_with(20, 40), called_with(20)], "setRange", (20, 40)),
([sld.valueChanged], [called_with(30)], "setValue", (30,)),
(BOTH, [called_with(20, 25), called_with(25)], "setMaximum", (25,)),
([sld.valueChanged], [called_with(23)], "setValue", (23,)),
]:
with qtbot.waitSignals(signals, check_params_cbs=checks, timeout=500):
getattr(sld, funcname)(*args)
_assert_value_in_range(sld)
def test_float_values(sld: _GenericSlider, qtbot):
if type(sld) is QLabeledSlider:
pytest.skip()
for signals, checks, funcname, args in [
(sld.rangeChanged, called_with(0.1, 0.9), "setRange", (0.1, 0.9)),
(sld.valueChanged, called_with(0.4), "setValue", (0.4,)),
(sld.valueChanged, called_with(0.1), "setValue", (0,)),
(sld.valueChanged, called_with(0.9), "setValue", (1.9,)),
]:
with qtbot.waitSignal(signals, check_params_cb=checks, timeout=400):
getattr(sld, funcname)(*args)
_assert_value_in_range(sld)
def test_ticks(sld: _GenericSlider, qtbot):
sld.setTickInterval(3)
assert sld.tickInterval() == 3
sld.setTickPosition(QSlider.TickPosition.TicksAbove)
sld.show()
@pytest.mark.skipif(platform.system() != "Darwin", reason="cross-platform is tricky")
def test_press_move_release(sld: _GenericSlider, qtbot):
if hasattr(sld, "_slider") and sld._slider.orientation() == Qt.Orientation.Vertical:
pytest.xfail("test failing for vertical at the moment")
# this fail on vertical came with pyside6.2 ... need to debug
# still works in practice, but test fails to catch signals
if sld.orientation() == Qt.Orientation.Vertical:
pytest.xfail()
_real_sld = getattr(sld, "_slider", sld)
with suppress(AttributeError): # for QSlider
assert _real_sld._pressedControl == QStyle.SubControl.SC_None
opt = QStyleOptionSlider()
_real_sld.initStyleOption(opt)
style = _real_sld.style()
hrect = style.subControlRect(
QStyle.ComplexControl.CC_Slider, opt, QStyle.SubControl.SC_SliderHandle
)
handle_pos = _real_sld.mapToGlobal(hrect.center())
with qtbot.waitSignal(_real_sld.sliderPressed, timeout=300):
qtbot.mousePress(_real_sld, Qt.MouseButton.LeftButton, pos=handle_pos)
with suppress(AttributeError):
assert sld._pressedControl == QStyle.SubControl.SC_SliderHandle
with qtbot.waitSignals(
[_real_sld.sliderMoved, _real_sld.valueChanged], timeout=300
):
shift = (
QPoint(0, -8)
if _real_sld.orientation() == Qt.Orientation.Vertical
else QPoint(8, 0)
)
_real_sld.mouseMoveEvent(_mouse_event(handle_pos + shift))
with qtbot.waitSignal(_real_sld.sliderReleased, timeout=300):
qtbot.mouseRelease(_real_sld, Qt.MouseButton.LeftButton, pos=handle_pos)
with suppress(AttributeError):
assert _real_sld._pressedControl == QStyle.SubControl.SC_None
sld.show()
with qtbot.waitSignal(_real_sld.sliderPressed, timeout=300):
qtbot.mousePress(_real_sld, Qt.MouseButton.LeftButton, pos=handle_pos)
@skip_on_linux_qt6
def test_hover(sld: _GenericSlider):
_real_sld = getattr(sld, "_slider", sld)
opt = QStyleOptionSlider()
_real_sld.initStyleOption(opt)
hrect = _real_sld.style().subControlRect(
QStyle.ComplexControl.CC_Slider, opt, QStyle.SubControl.SC_SliderHandle
)
handle_pos = QPointF(sld.mapToGlobal(hrect.center()))
with suppress(AttributeError): # for QSlider
assert _real_sld._hoverControl == QStyle.SubControl.SC_None
_real_sld.event(_hover_event(QEvent.Type.HoverEnter, handle_pos, QPointF(), sld))
with suppress(AttributeError): # for QSlider
assert _real_sld._hoverControl == QStyle.SubControl.SC_SliderHandle
_real_sld.event(
_hover_event(QEvent.Type.HoverLeave, QPointF(-1000, -1000), handle_pos, sld)
)
with suppress(AttributeError): # for QSlider
assert _real_sld._hoverControl == QStyle.SubControl.SC_None
def test_wheel(sld: _GenericSlider, qtbot):
if type(sld) is QLabeledSlider and QT_VERSION < (5, 12):
pytest.skip()
_real_sld = getattr(sld, "_slider", sld)
with qtbot.waitSignal(sld.valueChanged, timeout=400):
_real_sld.wheelEvent(_wheel_event(120))
_real_sld.wheelEvent(_wheel_event(0))
def test_position(sld: _GenericSlider, qtbot):
sld.setSliderPosition(21)
assert sld.sliderPosition() == 21
if type(sld) is not QLabeledSlider:
sld.setSliderPosition(21.5)
assert sld.sliderPosition() == 21.5
def test_steps(sld: _GenericSlider, qtbot):
sld.setSingleStep(11)
assert sld.singleStep() == 11
sld.setPageStep(16)
assert sld.pageStep() == 16
if type(sld) is not QLabeledSlider:
sld.setSingleStep(0.1)
assert sld.singleStep() == 0.1
sld.setSingleStep(1.5e20)
assert sld.singleStep() == 1.5e20
sld.setPageStep(0.2)
assert sld.pageStep() == 0.2
sld.setPageStep(1.5e30)
assert sld.pageStep() == 1.5e30
@pytest.mark.parametrize("mag", list(range(4, 37, 4)) + list(range(-4, -37, -4)))
def test_slider_extremes(sld: _GenericSlider, mag, qtbot):
if type(sld) is QLabeledSlider:
pytest.skip()
_mag = 10**mag
with qtbot.waitSignal(sld.rangeChanged, timeout=400):
sld.setRange(-_mag, _mag)
for i in _linspace(-_mag, _mag, 10):
sld.setValue(i)
assert math.isclose(sld.value(), i, rel_tol=1e-8)

View File

@@ -1,10 +1,11 @@
import platform
import pytest
from qtpy import API_NAME
from qtpy.QtCore import Qt
from qtrangeslider import QRangeSlider
from qtrangeslider.qtcompat import API_NAME
from qtrangeslider.qtcompat.QtCore import Qt
from superqt import QRangeSlider
from superqt.sliders._generic_range_slider import SC_BAR, SC_HANDLE, SC_NONE
NOT_LINUX = platform.system() != "Linux"
NOT_PYSIDE2 = API_NAME != "PySide2"
@@ -14,13 +15,33 @@ skipmouse = pytest.mark.skipif(NOT_LINUX or NOT_PYSIDE2, reason="mouse tests fin
@pytest.mark.parametrize("orientation", ["Horizontal", "Vertical"])
def test_basic(qtbot, orientation):
rs = QRangeSlider(getattr(Qt, orientation))
rs = QRangeSlider(getattr(Qt.Orientation, orientation))
qtbot.addWidget(rs)
@pytest.mark.parametrize("orientation", ["Horizontal", "Vertical"])
def test_value(qtbot, orientation):
rs = QRangeSlider(getattr(Qt.Orientation, orientation))
qtbot.addWidget(rs)
rs.setValue([10, 20])
assert rs.value() == (10, 20)
@pytest.mark.parametrize("orientation", ["Horizontal", "Vertical"])
def test_range(qtbot, orientation):
rs = QRangeSlider(getattr(Qt.Orientation, orientation))
qtbot.addWidget(rs)
rs.setValue([10, 20])
assert rs.value() == (10, 20)
rs.setRange(15, 20)
assert rs.value() == (15, 20)
assert rs.minimum() == 15
assert rs.maximum() == 20
@skipmouse
def test_drag_handles(qtbot):
rs = QRangeSlider(Qt.Horizontal)
rs = QRangeSlider(Qt.Orientation.Horizontal)
qtbot.addWidget(rs)
rs.setRange(0, 99)
rs.setValue((20, 80))
@@ -28,11 +49,11 @@ def test_drag_handles(qtbot):
rs.show()
# press the left handle
opt = rs._getStyleOption()
pos = rs._handleRects(opt, 0).center()
pos = rs._handleRect(0).center()
with qtbot.waitSignal(rs.sliderPressed):
qtbot.mousePress(rs, Qt.LeftButton, pos=pos)
assert rs._pressedControl == ("handle", 0)
qtbot.mousePress(rs, Qt.MouseButton.LeftButton, pos=pos)
assert rs._pressedControl == SC_HANDLE
assert rs._pressedIndex == 0
# drag the left handle
with qtbot.waitSignals([rs.sliderMoved] * 13): # couple less signals
@@ -41,17 +62,18 @@ def test_drag_handles(qtbot):
qtbot.mouseMove(rs, pos)
with qtbot.waitSignal(rs.sliderReleased):
qtbot.mouseRelease(rs, Qt.LeftButton)
qtbot.mouseRelease(rs, Qt.MouseButton.LeftButton)
# check the values
assert rs.value()[0] > 30
assert rs._pressedControl == rs._NULL_CTRL
assert rs._pressedControl == SC_NONE
# press the right handle
pos = rs._handleRects(opt, 1).center()
pos = rs._handleRect(1).center()
with qtbot.waitSignal(rs.sliderPressed):
qtbot.mousePress(rs, Qt.LeftButton, pos=pos)
assert rs._pressedControl == ("handle", 1)
qtbot.mousePress(rs, Qt.MouseButton.LeftButton, pos=pos)
assert rs._pressedControl == SC_HANDLE
assert rs._pressedIndex == 1
# drag the right handle
with qtbot.waitSignals([rs.sliderMoved] * 13): # couple less signals
@@ -59,16 +81,16 @@ def test_drag_handles(qtbot):
pos.setX(pos.x() - 2)
qtbot.mouseMove(rs, pos)
with qtbot.waitSignal(rs.sliderReleased):
qtbot.mouseRelease(rs, Qt.LeftButton)
qtbot.mouseRelease(rs, Qt.MouseButton.LeftButton)
# check the values
assert rs.value()[1] < 70
assert rs._pressedControl == rs._NULL_CTRL
assert rs._pressedControl == SC_NONE
@skipmouse
def test_drag_handles_beyond_edge(qtbot):
rs = QRangeSlider(Qt.Horizontal)
rs = QRangeSlider(Qt.Orientation.Horizontal)
qtbot.addWidget(rs)
rs.setRange(0, 99)
rs.setValue((20, 80))
@@ -76,26 +98,26 @@ def test_drag_handles_beyond_edge(qtbot):
rs.show()
# press the right handle
opt = rs._getStyleOption()
pos = rs._handleRects(opt, 1).center()
pos = rs._handleRect(1).center()
with qtbot.waitSignal(rs.sliderPressed):
qtbot.mousePress(rs, Qt.LeftButton, pos=pos)
assert rs._pressedControl == ("handle", 1)
qtbot.mousePress(rs, Qt.MouseButton.LeftButton, pos=pos)
assert rs._pressedControl == SC_HANDLE
assert rs._pressedIndex == 1
# drag the handle off the right edge and make sure the value gets to the max
for _ in range(5):
pos.setX(pos.x() + 20)
for _ in range(7):
pos.setX(pos.x() + 10)
qtbot.mouseMove(rs, pos)
with qtbot.waitSignal(rs.sliderReleased):
qtbot.mouseRelease(rs, Qt.LeftButton)
qtbot.mouseRelease(rs, Qt.MouseButton.LeftButton)
assert rs.value()[1] == 99
@skipmouse
def test_bar_drag_beyond_edge(qtbot):
rs = QRangeSlider(Qt.Horizontal)
rs = QRangeSlider(Qt.Orientation.Horizontal)
qtbot.addWidget(rs)
rs.setRange(0, 99)
rs.setValue((20, 80))
@@ -105,8 +127,9 @@ def test_bar_drag_beyond_edge(qtbot):
# press the right handle
pos = rs.rect().center()
with qtbot.waitSignal(rs.sliderPressed):
qtbot.mousePress(rs, Qt.LeftButton, pos=pos)
assert rs._pressedControl == ("bar", 1)
qtbot.mousePress(rs, Qt.MouseButton.LeftButton, pos=pos)
assert rs._pressedControl == SC_BAR
assert rs._pressedIndex == 1
# drag the handle off the right edge and make sure the value gets to the max
for _ in range(15):
@@ -114,6 +137,6 @@ def test_bar_drag_beyond_edge(qtbot):
qtbot.mouseMove(rs, pos)
with qtbot.waitSignal(rs.sliderReleased):
qtbot.mouseRelease(rs, Qt.LeftButton)
qtbot.mouseRelease(rs, Qt.MouseButton.LeftButton)
assert rs.value()[1] == 99

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