Compare commits

...

54 Commits

Author SHA1 Message Date
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
Talley Lambert
d93787e35a Prevent handle and label overlap (#7)
* cov changes

* mostly good

* remove cov

* use int

* fix

* prevent label overlap
2021-05-17 09:38:08 -04:00
pre-commit-ci[bot]
d04ca7a4b3 [pre-commit.ci] pre-commit autoupdate (#8)
updates:
- [github.com/asottile/pyupgrade: v2.13.0 → v2.15.0](https://github.com/asottile/pyupgrade/compare/v2.13.0...v2.15.0)
- [github.com/psf/black: 21.4b0 → 21.5b1](https://github.com/psf/black/compare/21.4b0...21.5b1)
- [github.com/PyCQA/flake8: 3.9.1 → 3.9.2](https://github.com/PyCQA/flake8/compare/3.9.1...3.9.2)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2021-05-17 09:35:54 -04:00
Talley Lambert
b6900b8b14 fix recursion (#10) 2021-05-17 09:35:45 -04:00
Talley Lambert
19779c6fb7 Update README.md 2021-05-17 09:32:42 -04:00
Talley Lambert
24b67d00e4 Fix slider signature overloads (#9)
* overload

* more sigs
2021-05-17 09:13:46 -04:00
Talley Lambert
10feb74656 remove annotation 2021-05-17 08:59:58 -04:00
Talley Lambert
96f9a5cd90 Mouse interaction tests (#5)
* tests

* skipmouse
2021-05-02 14:45:36 -04:00
Talley Lambert
f76cf6d126 FloatSlider (#4)
* decent point

* QLabeledDoubleRangeSlider

* update float add tests

* ugly but working

* fix validator

* flexible orientation

* horiz

* warnings are errors

* try convert

* fix signals

* skip signals test on windows pyqt6
2021-05-02 14:30:55 -04:00
Talley Lambert
a27b388f3e Labeled sliders (#3)
* good labels

* more options

* add to init

* reemit value changed

* remove pass

* refine positioning

* update example

* add docs
2021-04-27 21:33:45 -04:00
Talley Lambert
21523dee82 more styles 2021-04-27 17:16:12 -04:00
Talley Lambert
9471796fe5 improve barColor brush 2021-04-27 16:54:52 -04:00
pre-commit-ci[bot]
a6b0518be5 [pre-commit.ci] pre-commit autoupdate (#2)
updates:
- [github.com/asottile/pyupgrade: v2.12.0 → v2.13.0](https://github.com/asottile/pyupgrade/compare/v2.12.0...v2.13.0)
- [github.com/psf/black: 20.8b1 → 21.4b0](https://github.com/psf/black/compare/20.8b1...21.4b0)
- [github.com/PyCQA/flake8: 3.9.0 → 3.9.1](https://github.com/PyCQA/flake8/compare/3.9.0...3.9.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2021-04-26 17:24:40 -04:00
Talley Lambert
592f0d75ba option scroll adjusts gain 2021-04-26 12:53:27 -04:00
Talley Lambert
2897a18851 more style fixes 2021-04-26 12:26:20 -04:00
Talley Lambert
59c5dec044 Merge branch 'main' of https://github.com/tlambert03/PyQRangeSlider into main 2021-04-26 12:22:20 -04:00
Talley Lambert
1340bfa371 Fix scrolling bar past extremes (#1)
* ex

* add mouse drag

* more lenient values

* use tp ==

* skip mouse move windows CI

* fix dragging to edges

* fix for pyqt6

* comment out tests
2021-04-26 12:22:11 -04:00
Talley Lambert
7d0ab56d54 fix for pyqt6 2021-04-26 12:19:08 -04:00
Talley Lambert
4edcdf4941 Merge branch 'main' of https://github.com/tlambert03/PyQRangeSlider into main 2021-04-26 10:08:53 -04:00
Talley Lambert
b651e2b757 fix gradients in bar 2021-04-26 09:57:34 -04:00
Talley Lambert
7ad87f9dc6 Update issue templates 2021-04-25 17:42:41 -04:00
138 changed files with 8031 additions and 1226 deletions

29
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,29 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
Screenshots and GIFS are much appreciated when reporting visual bugs.
**Desktop (please complete the following information):**
- OS with version [e.g macOS 10.15.7]
- Qt Backend [e.g PyQt5, PySide2]
- Python version

View File

@@ -47,6 +47,16 @@ jobs:
- python-version: 3.9 - python-version: 3.9
platform: macos-11.0 platform: macos-11.0
backend: pyqt6 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 # big sur, 3.9
- python-version: 3.9 - python-version: 3.9
@@ -60,12 +70,29 @@ jobs:
- python-version: 3.8 - python-version: 3.8
platform: ubuntu-18.04 platform: ubuntu-18.04
backend: pyside2 backend: pyside2
- python-version: 3.6
platform: ubuntu-16.04
backend: pyqt5
- python-version: 3.6 - python-version: 3.6
platform: windows-2016 platform: windows-2016
backend: pyqt5 backend: pyqt5
# legacy Qt
- python-version: 3.7
platform: ubuntu-latest
backend: pyside511
- python-version: 3.7
platform: ubuntu-latest
backend: pyqt511
- 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: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
@@ -74,12 +101,8 @@ jobs:
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Install Linux libraries - uses: tlambert03/setup-qt-libs@v1
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
- name: Linux opengl - name: Linux opengl
if: runner.os == 'Linux' && ( matrix.backend == 'pyside6' || matrix.backend == 'pyqt6' ) if: runner.os == 'Linux' && ( matrix.backend == 'pyside6' || matrix.backend == 'pyqt6' )
run: sudo apt-get install -y libopengl0 libegl1-mesa libxcb-xinput0 run: sudo apt-get install -y libopengl0 libegl1-mesa libxcb-xinput0
@@ -90,7 +113,10 @@ jobs:
pip install setuptools tox tox-gh-actions pip install setuptools tox tox-gh-actions
- name: Test with tox - name: Test with tox
run: tox uses: GabrielBB/xvfb-action@v1
timeout-minutes: 3
with:
run: tox
env: env:
PLATFORM: ${{ matrix.platform }} PLATFORM: ${{ matrix.platform }}
BACKEND: ${{ matrix.backend }} BACKEND: ${{ matrix.backend }}
@@ -102,15 +128,15 @@ jobs:
if: matrix.screenshot if: matrix.screenshot
run: pip install . ${{ matrix.backend }} run: pip install . ${{ matrix.backend }}
- name: Screenshots - name: Screenshots (Linux)
if: runner.os == 'Linux' && matrix.screenshot if: runner.os == 'Linux' && matrix.screenshot
uses: GabrielBB/xvfb-action@v1 uses: GabrielBB/xvfb-action@v1
with: 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 if: runner.os != 'Linux' && matrix.screenshot
run: python examples/demo_widget.py run: python examples/demo_widget.py -snap
- uses: actions/upload-artifact@v2 - uses: actions/upload-artifact@v2
if: matrix.screenshot if: matrix.screenshot
@@ -118,14 +144,26 @@ jobs:
name: screenshots ${{ runner.os }} name: screenshots ${{ runner.os }}
path: screenshots path: screenshots
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: deploy:
# this will run when you have tagged a commit, starting with "v*" # this will run when you have tagged a commit, starting with "v*"
# and requires that you have put your twine API key in your # and requires that you have put your twine API key in your
# github secrets (see readme for details) # 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 runs-on: ubuntu-latest
if: contains(github.ref, 'tags')
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Set up Python - name: Set up Python
@@ -135,12 +173,13 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -U setuptools setuptools_scm wheel twine pip install build twine
- name: Build and publish - name: Build and publish
env: env:
TWINE_USERNAME: __token__ TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.TWINE_API_KEY }} TWINE_PASSWORD: ${{ secrets.TWINE_API_KEY }}
run: | run: |
git tag git tag
python setup.py sdist bdist_wheel python -m build
twine check dist/*
twine upload dist/* twine upload dist/*

View File

@@ -0,0 +1,8 @@
# 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
add-sections={"documentation":{"prefix":"**Documentation updates:**","labels":["documentation"]}}

5
.gitignore vendored
View File

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

View File

@@ -1,23 +1,40 @@
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v3.4.0 rev: v4.0.1
hooks: hooks:
- id: end-of-file-fixer - id: end-of-file-fixer
- id: trailing-whitespace - id: trailing-whitespace
- repo: https://github.com/asottile/setup-cfg-fmt
rev: v1.20.0
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 - repo: https://github.com/PyCQA/isort
rev: 5.8.0 rev: 5.10.1
hooks: hooks:
- id: isort - id: isort
- repo: https://github.com/asottile/pyupgrade - repo: https://github.com/asottile/pyupgrade
rev: v2.12.0 rev: v2.29.1
hooks: hooks:
- id: pyupgrade - id: pyupgrade
args: [--py37-plus, --keep-runtime-typing]
- repo: https://github.com/psf/black - repo: https://github.com/psf/black
rev: 20.8b1 rev: 21.11b1
hooks: hooks:
- id: black - id: black
- repo: https://github.com/PyCQA/flake8 - repo: https://github.com/pre-commit/mirrors-mypy
rev: 3.9.0 rev: v0.910-1
hooks: hooks:
- id: flake8 - id: mypy
pass_filenames: true exclude: examples
stages: [manual]

77
CHANGELOG.md Normal file
View File

@@ -0,0 +1,77 @@
# Changelog
## [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))
**Merged pull requests:**
- reskip test\_object\_thread\_return on ci [\#43](https://github.com/napari/superqt/pull/43) ([tlambert03](https://github.com/tlambert03))
- refactoring qtcompat [\#34](https://github.com/napari/superqt/pull/34) ([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.0rc1...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.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)*

54
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,54 @@
# 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
Until [qtpy](https://github.com/spyder-ide/qtpy) supports PyQt6/PySide6, imports
should use (and modify if necessary) `superqt.qtcompat`.
## 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 this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution. 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 contributors may be used to endorse or promote products derived from
this software without specific prior written permission. this software without specific prior written permission.

View File

@@ -1,5 +1,17 @@
include LICENSE include LICENSE
include README.md 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 * __pycache__
recursive-exclude * *.py[co] 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

210
README.md
View File

@@ -1,199 +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 [![Python
Version](https://img.shields.io/pypi/pyversions/QtRangeSlider.svg?color=green)](https://python.org) Version](https://img.shields.io/pypi/pyversions/superqt.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) [![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/tlambert03/QtRangeSlider/branch/master/graph/badge.svg)](https://codecov.io/gh/tlambert03/QtRangeSlider) [![codecov](https://codecov.io/gh/napari/superqt/branch/master/graph/badge.svg)](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 Components are tested on:
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!
- `QRangeSlider` inherits from [`QSlider`](https://doc.qt.io/qt-5/qslider.html) - macOS, Windows, & Linux
and attempts to match the Qt API as closely as possible - Python 3.7 and above
- Uses platform-specific styles (for handle, groove, & ticks) but also supports - PyQt5 (5.11 and above) & PyQt6
QSS style sheets. - PySide2 (5.11 and above) & PySide6
- 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])`)
## Installation ## Widgets
You can install `QtRangeSlider` via pip: Widgets include:
```sh - [Float Slider](docs/sliders.md#float-slider)
pip install qtrangeslider
# NOTE: you must also install a Qt Backend. - [Range Slider](docs/sliders.md#range-slider) (multi-handle slider)
# PyQt5, PySide2, PyQt6, and PySide6 are supported
# As a convenience you can install them as extras: <img src="https://raw.githubusercontent.com/napari/superqt/main/docs/images/demo_darwin10.png" alt="range sliders" width=680>
pip install qtrangeslider[pyqt5]
```
------ - [Labeled Sliders](docs/sliders.md#labeled-sliders) (sliders with linked
spinboxes)
## API <img src="https://raw.githubusercontent.com/napari/superqt/main/docs/images/labeled_qslider.png" alt="range sliders" width=680>
To create a slider: <img src="https://raw.githubusercontent.com/napari/superqt/main/docs/images/labeled_range.png" alt="range sliders" width=680>
```python - Unbound Integer SpinBox (backed by python `int`)
from qtrangeslider import QRangeSlider
# as usual: ## Contributing
# you must create a QApplication before create a widget.
range_slider = QRangeSlider()
```
As `QRangeSlider` inherits from `QtWidgets.QSlider`, you can use all of the We welcome contributions!
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, ...]` Please see the [Contributing Guide](CONTRIBUTING.md)
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> |
------
## Example
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
![mac](images/demo_darwin10.png)
##### Big Sur
![mac](images/demo_darwin11.png)
### Windows
![mac](images/demo_windows.png)
### Linux
![mac](images/demo_linux.png)
## Issues
If you encounter any problems, please [file an issue] along with a detailed
description.
[file an issue]: https://github.com/tlambert03/QtRangeSlider/issues

View File

@@ -1,14 +1,16 @@
ignore: ignore:
- qtrangeslider/_version.py - superqt/_version.py
- superqt/qtcompat/*
- '*_tests*'
coverage: coverage:
status: status:
project: project:
default: default:
target: auto 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: patch:
default: default:
target: auto 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: comment:
require_changes: true # if true: only post the PR comment if coverage changes require_changes: true # if true: only post the PR comment if coverage changes

63
docs/combobox.md Normal file
View File

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

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 superqt.qtcompat.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

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,9 +1,11 @@
from qtrangeslider import QRangeSlider from superqt import QRangeSlider
from qtrangeslider.qtcompat.QtWidgets import QApplication from superqt.qtcompat.QtCore import Qt
from superqt.qtcompat.QtWidgets import QApplication
app = QApplication([]) app = QApplication([])
slider = QRangeSlider() slider = QRangeSlider(Qt.Orientation.Horizontal)
slider = QRangeSlider(Qt.Orientation.Horizontal)
slider.setValue((20, 80)) slider.setValue((20, 80))
slider.show() slider.show()

12
examples/basic_float.py Normal file
View File

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

14
examples/collapsible.py Normal file
View File

@@ -0,0 +1,14 @@
"""Example for QCollapsible"""
from superqt import QCollapsible
from superqt.qtcompat.QtWidgets import QApplication, QLabel, QPushButton
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,6 @@
from qtrangeslider import QRangeSlider from superqt import QRangeSlider
from qtrangeslider.qtcompat import QtCore from superqt.qtcompat import QtCore
from qtrangeslider.qtcompat import QtWidgets as QtW from superqt.qtcompat import QtWidgets as QtW
QSS = """ QSS = """
QSlider { QSlider {
@@ -9,59 +9,61 @@ QSlider {
QSlider::groove:horizontal { QSlider::groove:horizontal {
border: 0px; border: 0px;
background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #777, stop:1 #aaa); background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #888, stop:1 #ddd);
height: 20px; height: 20px;
border-radius: 10px; border-radius: 10px;
} }
QSlider::handle { QSlider::handle {
background: qradialgradient(cx:0, cy:0, radius: 1.2, fx:0.5, background: qradialgradient(cx:0, cy:0, radius: 1.2, fx:0.35,
fy:0.5, stop:0 #eef, stop:1 #000); fy:0.3, stop:0 #eef, stop:1 #002);
height: 20px; height: 20px;
width: 20px; width: 20px;
border-radius: 10px; border-radius: 10px;
} }
QSlider::sub-page:horizontal { QSlider::sub-page:horizontal {
background: #447; background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #227, stop:1 #77a);
border-top-left-radius: 10px; border-top-left-radius: 10px;
border-bottom-left-radius: 10px; border-bottom-left-radius: 10px;
} }
QRangeSlider { QRangeSlider {
qproperty-barColor: #447; qproperty-barColor: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #227, stop:1 #77a);
} }
""" """
Horizontal = QtCore.Qt.Orientation.Horizontal
class DemoWidget(QtW.QWidget): class DemoWidget(QtW.QWidget):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
reg_hslider = QtW.QSlider(QtCore.Qt.Horizontal) reg_hslider = QtW.QSlider(Horizontal)
reg_hslider.setValue(50) reg_hslider.setValue(50)
range_hslider = QRangeSlider(QtCore.Qt.Horizontal) range_hslider = QRangeSlider(Horizontal)
range_hslider.setValue((20, 80)) 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.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.setValue(50)
styled_reg_hslider.setStyleSheet(QSS) 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.setValue((20, 80))
styled_range_hslider.setStyleSheet(QSS) styled_range_hslider.setStyleSheet(QSS)
reg_vslider = QtW.QSlider(QtCore.Qt.Vertical) reg_vslider = QtW.QSlider(QtCore.Qt.Orientation.Vertical)
reg_vslider.setValue(50) reg_vslider.setValue(50)
range_vslider = QRangeSlider(QtCore.Qt.Vertical) range_vslider = QRangeSlider(QtCore.Qt.Orientation.Vertical)
range_vslider.setValue((22, 77)) 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.setValue(55)
tick_vslider.setTickPosition(QtW.QSlider.TicksRight) 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.setValue((22, 77))
range_tick_vslider.setTickPosition(QtW.QSlider.TicksLeft) range_tick_vslider.setTickPosition(QtW.QSlider.TicksLeft)
@@ -106,18 +108,16 @@ if __name__ == "__main__":
import sys import sys
from pathlib import Path from pathlib import Path
QtW.QApplication.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling)
dest = Path("screenshots") dest = Path("screenshots")
dest.mkdir(exist_ok=True) dest.mkdir(exist_ok=True)
app = QtW.QApplication([]) app = QtW.QApplication([])
demo = DemoWidget() demo = DemoWidget()
if "-x" in sys.argv: if "-snap" in sys.argv:
app.exec_()
else:
import platform import platform
QtW.QApplication.processEvents() QtW.QApplication.processEvents()
demo.grab().save(str(dest / f"demo_{platform.system().lower()}.png")) demo.grab().save(str(dest / f"demo_{platform.system().lower()}.png"))
else:
app.exec_()

12
examples/eliding_label.py Normal file
View File

@@ -0,0 +1,12 @@
from superqt import QElidingLabel
from superqt.qtcompat.QtWidgets import QApplication
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_()

27
examples/float.py Normal file
View File

@@ -0,0 +1,27 @@
from superqt import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
from superqt.qtcompat.QtCore import Qt
from superqt.qtcompat.QtWidgets import QApplication, QVBoxLayout, QWidget
app = QApplication([])
w = QWidget()
sld1 = QDoubleSlider(Qt.Orientation.Horizontal)
sld2 = QDoubleRangeSlider(Qt.Orientation.Horizontal)
rs = QRangeSlider(Qt.Orientation.Horizontal)
sld1.valueChanged.connect(lambda e: print("doubslider valuechanged", e))
sld2.setMaximum(1)
sld2.setValue((0.2, 0.8))
sld2.valueChanged.connect(lambda e: print("valueChanged", e))
sld2.sliderMoved.connect(lambda e: print("sliderMoved", e))
sld2.rangeChanged.connect(lambda e, f: print("rangeChanged", (e, f)))
w.setLayout(QVBoxLayout())
w.layout().addWidget(sld1)
w.layout().addWidget(sld2)
w.layout().addWidget(rs)
w.show()
w.resize(500, 150)
app.exec_()

20
examples/fonticon1.py Normal file
View File

@@ -0,0 +1,20 @@
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 superqt.fonticon import icon, pulse
from superqt.qtcompat.QtCore import QSize
from superqt.qtcompat.QtWidgets import QApplication, QPushButton
app = QApplication([])
btn2 = QPushButton()
btn2.setIcon(icon(FA5S.spinner, animation=pulse(btn2)))
btn2.setIconSize(QSize(225, 225))
btn2.show()
app.exec()

20
examples/fonticon2.py Normal file
View File

@@ -0,0 +1,20 @@
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 superqt.fonticon import setTextIcon
from superqt.qtcompat.QtWidgets import QApplication, QPushButton
app = QApplication([])
btn4 = QPushButton()
btn4.resize(275, 275)
setTextIcon(btn4, FA5S.hamburger)
btn4.show()
app.exec()

40
examples/fonticon3.py Normal file
View File

@@ -0,0 +1,40 @@
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 superqt.fonticon import IconOpts, icon, pulse, spin
from superqt.qtcompat.QtCore import QSize
from superqt.qtcompat.QtWidgets import QApplication, QPushButton
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()

12
examples/generic.py Normal file
View File

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

377
examples/icon_explorer.py Normal file
View File

@@ -0,0 +1,377 @@
from superqt.fonticon._plugins import loaded
from superqt.qtcompat import QtCore, QtGui, QtWidgets
from superqt.qtcompat.QtCore import Qt
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_())

47
examples/labeled.py Normal file
View File

@@ -0,0 +1,47 @@
from superqt import (
QLabeledDoubleRangeSlider,
QLabeledDoubleSlider,
QLabeledRangeSlider,
QLabeledSlider,
)
from superqt.qtcompat.QtCore import Qt
from superqt.qtcompat.QtWidgets import QApplication, QHBoxLayout, QVBoxLayout, QWidget
app = QApplication([])
ORIENTATION = Qt.Orientation.Horizontal
w = QWidget()
qls = QLabeledSlider(ORIENTATION)
qls.valueChanged.connect(lambda e: print("qls valueChanged", e))
qls.setRange(0, 500)
qls.setValue(300)
qlds = QLabeledDoubleSlider(ORIENTATION)
qlds.valueChanged.connect(lambda e: print("qlds valueChanged", e))
qlds.setRange(0, 1)
qlds.setValue(0.5)
qlds.setSingleStep(0.1)
qlrs = QLabeledRangeSlider(ORIENTATION)
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.Orientation.Horizontal else QHBoxLayout()
)
w.layout().addWidget(qls)
w.layout().addWidget(qlds)
w.layout().addWidget(qlrs)
w.layout().addWidget(qldrs)
w.show()
w.resize(500, 150)
app.exec_()

View File

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

View File

@@ -1,3 +1,10 @@
# pyproject.toml # pyproject.toml
[build-system] [build-system]
requires = ["setuptools>=45", "wheel", "setuptools_scm[toml]>=6.0"] 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,8 +0,0 @@
try:
from ._version import version as __version__
except ImportError:
__version__ = "unknown"
from ._qrangeslider import QRangeSlider
__all__ = ["QRangeSlider"]

View File

@@ -1,551 +0,0 @@
import textwrap
from collections import abc
from typing import List, Sequence, Tuple
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(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):
super().__init__(*args)
# 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
# color
self._style = RangeSliderStyle()
self.setStyleSheet("")
# ############### 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(tuple(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=i == len(val) - 1)
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:
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
_new = [i - offset for i in ref]
if self._bar_is_rigid:
# FIXME: if there is an overflow ... it should still hit the edge.
if all(self.minimum() <= i <= self.maximum() for i in _new):
self.setSliderPosition(_new)
else:
self.setSliderPosition(_new)
def _getStyleOption(self) -> QStyleOptionSlider:
opt = QStyleOptionSlider()
self.initStyleOption(opt)
opt.sliderValue = 0
opt.sliderPosition = 0
return opt
def _getBarColor(self):
return self._style.brush_active or QtGui.QColor()
def _setBarColor(self, color):
self._style.brush_active = color
barColor = Property(QtGui.QColor, _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 = 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, handle_index: int = None) -> QRect:
"""Return the QRect for all handles."""
style = self.style().proxy()
if handle_index is not None: # get specific handle rect
opt.sliderPosition = self._position[handle_index]
return style.subControlRect(
QStyle.CC_Slider, opt, QStyle.SC_SliderHandle, self
)
else:
rects = []
for p in self._position:
opt.sliderPosition = 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
return QStyle.sliderValueFromPosition(
self.minimum(),
self.maximum(),
pos - sliderMin,
sliderMax - sliderMin,
opt.upsideDown,
)
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:
if index > 0:
val = max(_lst[index - 1], val)
# make sure we don't go higher than any following index:
if index < len(_lst) - 1:
val = min(_lst[index + 1], 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: Qt.KeyboardModifiers, 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.ControlModifier or 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
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()
self._offsetAllPositions(-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,10 +0,0 @@
import pytest
from qtrangeslider import QRangeSlider
from qtrangeslider.qtcompat.QtCore import Qt
@pytest.mark.parametrize("orientation", ["Horizontal", "Vertical"])
def test_basic(qtbot, orientation):
rs = QRangeSlider(getattr(Qt, orientation))
qtbot.addWidget(rs)

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):
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]

112
setup.cfg
View File

@@ -1,18 +1,13 @@
[metadata] [metadata]
name = QtRangeSlider name = superqt
url = https://github.com/tlambert03/QtRangeSlider description = Missing widgets for PyQt/PySide
license = BSD-3 long_description = file: README.md
license_file = LICENSE
description = Multi-handle range slider widget for PyQt/PySide
long_description = file: README.md, CHANGELOG.md
long_description_content_type = text/markdown long_description_content_type = text/markdown
url = https://github.com/napari/superqt
author = Talley Lambert author = Talley Lambert
author_email = talley.lambert@gmail.com author_email = talley.lambert@gmail.com
keywords = qt, range slider, widget license = BSD-3-Clause
project_urls = license_file = LICENSE
Source = https://github.com/tlambert03/QtRangeSlider
Tracker = https://github.com/tlambert03/QtRangeSlider/issues
Changelog = https://github.com/tlambert03/QtRangeSlider/blob/master/CHANGELOG.md
classifiers = classifiers =
Development Status :: 4 - Beta Development Status :: 4 - Beta
Environment :: X11 Applications :: Qt Environment :: X11 Applications :: Qt
@@ -20,46 +15,103 @@ classifiers =
License :: OSI Approved :: BSD License License :: OSI Approved :: BSD License
Operating System :: OS Independent Operating System :: OS Independent
Programming Language :: Python Programming Language :: Python
Programming Language :: Python :: 3
Programming Language :: Python :: 3 :: Only Programming Language :: Python :: 3 :: Only
Programming Language :: Python :: 3.6
Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
Programming Language :: Python :: Implementation :: CPython
Topic :: Desktop Environment Topic :: Desktop Environment
Topic :: Software Development Topic :: Software Development
Topic :: Software Development :: User Interfaces Topic :: Software Development :: User Interfaces
Topic :: Software Development :: Widget Sets 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] [options]
zip_safe = False
packages = find: packages = find:
python_requires = >=3.6 install_requires =
setup_requires = setuptools_scm 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] [options.extras_require]
pyside2 = pyside2
pyqt5 = pyqt5
pyside6 = pyside6
pyqt6 = pyqt6
testing =
tox
tox-conda
pytest
pytest-qt
pytest-cov
dev = dev =
ipython ipython
jedi<0.18.0
isort isort
jedi<0.18.0
mypy mypy
pre-commit pre-commit
%(testing)s pyside2
%(pyqt5)s 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] [flake8]
exclude = _version.py,.eggs,examples exclude = _version.py,.eggs,examples
docstring-convention = numpy docstring-convention = numpy
ignore = E203,W503,E501,C901,F403,F405 ignore = E203,W503,E501,C901,F403,F405,D100
per-file-ignores =
src/superqt/qtcompat/QtCore.py:F401
src/superqt/qtcompat/QtGui.py:F401
src/superqt/qtcompat/QtWidgets.py:F401
src/superqt/qtcompat/__init__.py:F401,F811
[pydocstyle]
convention = numpy
add_select = D402,D415,D417
ignore = D100
[isort] [isort]
profile=black profile = black
[tool:pytest]
filterwarnings =
error
ignore:QPixmapCache.find:DeprecationWarning:
ignore:SelectableGroups dict interface:DeprecationWarning
ignore:The distutils package is deprecated:DeprecationWarning
[mypy]
strict = True
files = src/superqt
[mypy-superqt.qtcompat.*]
ignore_missing_imports = True
warn_unused_ignores = False
allow_redefinition = True

View File

@@ -1,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"],
)

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

@@ -0,0 +1,38 @@
"""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
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",
"QLabeledDoubleRangeSlider",
"QLabeledDoubleSlider",
"QLabeledRangeSlider",
"QLabeledSlider",
"QLargeIntSpinBox",
"QMessageHandler",
"QRangeSlider",
"QEnumComboBox",
"QCollapsible",
]

View File

@@ -0,0 +1,110 @@
from typing import List
from superqt.qtcompat.QtCore import QPoint, QRect, QSize, Qt
from superqt.qtcompat.QtGui import QFont, QFontMetrics, QResizeEvent, QTextLayout
from superqt.qtcompat.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,128 @@
"""A collapsible widget to hide and unhide child widgets"""
from typing import Optional
from ..qtcompat.QtCore import (
QAbstractAnimation,
QEasingCurve,
QMargins,
QPropertyAnimation,
Qt,
)
from ..qtcompat.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._toggle_btn = QPushButton(self._COLLAPSED + title)
self._toggle_btn.setCheckable(True)
self._toggle_btn.setStyleSheet("text-align: left; background: transparent;")
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.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."""
self._content.layout().addWidget(widget)
def removeWidget(self, widget: QWidget):
"""Remove widget from the central content widget's layout."""
self._content.layout().removeWidget(widget)
def expand(self, animate: bool = True):
"""Expand (show) the collapsible section"""
self._expand_collapse(QAbstractAnimation.Direction.Forward, animate)
def collapse(self, animate: bool = True):
"""Collapse (hide) the collapsible section"""
self._expand_collapse(QAbstractAnimation.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: QAbstractAnimation.Direction, animate: bool = True
):
if self._locked:
return
forward = direction == QAbstractAnimation.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._animation.start()
else:
self._content.setMaximumHeight(_content_height if forward else 0)
def _toggle(self):
self.expand() if self.isExpanded() else self.collapse()

View File

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

View File

@@ -0,0 +1,112 @@
from enum import Enum, EnumMeta
from typing import Optional, TypeVar
from ..qtcompat.QtCore import Signal
from ..qtcompat.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,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 superqt.qtcompat.QtGui import QFont, QTransform
from superqt.qtcompat.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 superqt.qtcompat.QtCore import QRectF, QTimer
from superqt.qtcompat.QtGui import QPainter
from superqt.qtcompat.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 typing_extensions import TypedDict
from ..qtcompat import QT_VERSION
from ..qtcompat.QtCore import QObject, QPoint, QRect, QSize, Qt
from ..qtcompat.QtGui import (
QColor,
QFont,
QFontDatabase,
QGuiApplication,
QIcon,
QIconEngine,
QPainter,
QPixmap,
QPixmapCache,
QTransform,
)
from ..qtcompat.QtWidgets import QApplication, QStyleOption, QWidget
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,4 @@
from PyQt5.Qt3DAnimation import *
from PyQt6.Qt3DAnimation import *
from PySide2.Qt3DAnimation import *
from PySide6.Qt3DAnimation import *

View File

@@ -0,0 +1,4 @@
from PyQt5.Qt3DCore import *
from PyQt6.Qt3DCore import *
from PySide2.Qt3DCore import *
from PySide6.Qt3DCore import *

View File

@@ -0,0 +1,4 @@
from PyQt5.Qt3DExtras import *
from PyQt6.Qt3DExtras import *
from PySide2.Qt3DExtras import *
from PySide6.Qt3DExtras import *

View File

@@ -0,0 +1,4 @@
from PyQt5.Qt3DInput import *
from PyQt6.Qt3DInput import *
from PySide2.Qt3DInput import *
from PySide6.Qt3DInput import *

View File

@@ -0,0 +1,4 @@
from PyQt5.Qt3DLogic import *
from PyQt6.Qt3DLogic import *
from PySide2.Qt3DLogic import *
from PySide6.Qt3DLogic import *

View File

@@ -0,0 +1,4 @@
from PyQt5.Qt3DRender import *
from PyQt6.Qt3DRender import *
from PySide2.Qt3DRender import *
from PySide6.Qt3DRender import *

View File

@@ -0,0 +1,4 @@
from PyQt5.QtCharts import *
from PyQt6.QtCharts import *
from PySide2.QtCharts import *
from PySide6.QtCharts import *

View File

@@ -0,0 +1,4 @@
from PyQt5.QtConcurrent import *
from PyQt6.QtConcurrent import *
from PySide2.QtConcurrent import *
from PySide6.QtConcurrent import *

View File

@@ -0,0 +1,12 @@
# type: ignore
from . import API_NAME, _get_qtmodule
_QtCore = _get_qtmodule(__name__)
globals().update(_QtCore.__dict__)
if "PyQt" in API_NAME:
Property = _QtCore.pyqtProperty
Signal = _QtCore.pyqtSignal
SignalInstance = getattr(_QtCore, "pyqtBoundSignal", None)
Slot = _QtCore.pyqtSlot
__version__ = _QtCore.QT_VERSION_STR

View File

@@ -0,0 +1,10 @@
from PyQt5.QtCore import *
from PyQt6.QtCore import *
from PySide2.QtCore import *
from PySide6.QtCore import *
Property = pyqtProperty
Signal = pyqtSignal
SignalInstance = pyqtBoundSignal
Slot = pyqtSlot
__version__: str

View File

@@ -0,0 +1,4 @@
from PyQt5.QtDataVisualization import *
from PyQt6.QtDataVisualization import *
from PySide2.QtDataVisualization import *
from PySide6.QtDataVisualization import *

View File

@@ -0,0 +1,13 @@
# type: ignore
from . import API_NAME, _get_qtmodule
_QtGui = _get_qtmodule(__name__)
globals().update(_QtGui.__dict__)
if "6" in API_NAME:
def pos(self, *a):
_pos = self.position(*a)
return _pos.toPoint()
_QtGui.QMouseEvent.pos = pos

View File

@@ -0,0 +1,4 @@
from PyQt5.QtGui import *
from PyQt6.QtGui import *
from PySide2.QtGui import *
from PySide6.QtGui import *

View File

@@ -0,0 +1,4 @@
from PyQt5.QtHelp import *
from PyQt6.QtHelp import *
from PySide2.QtHelp import *
from PySide6.QtHelp import *

View File

@@ -0,0 +1,4 @@
from PyQt5.QtLocation import *
from PyQt6.QtLocation import *
from PySide2.QtLocation import *
from PySide6.QtLocation import *

View File

@@ -0,0 +1,4 @@
from PyQt5.QtMacExtras import *
from PyQt6.QtMacExtras import *
from PySide2.QtMacExtras import *
from PySide6.QtMacExtras import *

View File

@@ -0,0 +1,4 @@
from PyQt5.QtMultimedia import *
from PyQt6.QtMultimedia import *
from PySide2.QtMultimedia import *
from PySide6.QtMultimedia import *

View File

@@ -0,0 +1,4 @@
from PyQt5.QtMultimediaWidgets import *
from PyQt6.QtMultimediaWidgets import *
from PySide2.QtMultimediaWidgets import *
from PySide6.QtMultimediaWidgets import *

View File

@@ -0,0 +1,4 @@
from PyQt5.QtNetwork import *
from PyQt6.QtNetwork import *
from PySide2.QtNetwork import *
from PySide6.QtNetwork import *

View File

@@ -0,0 +1,4 @@
from PyQt5.QtOpenGL import *
from PyQt6.QtOpenGL import *
from PySide2.QtOpenGL import *
from PySide6.QtOpenGL import *

View File

@@ -0,0 +1,4 @@
from PyQt5.QtOpenGLFunctions import *
from PyQt6.QtOpenGLFunctions import *
from PySide2.QtOpenGLFunctions import *
from PySide6.QtOpenGLFunctions import *

View File

@@ -0,0 +1,4 @@
from PyQt5.QtPositioning import *
from PyQt6.QtPositioning import *
from PySide2.QtPositioning import *
from PySide6.QtPositioning import *

View File

@@ -0,0 +1,4 @@
from PyQt5.QtPrintSupport import *
from PyQt6.QtPrintSupport import *
from PySide2.QtPrintSupport import *
from PySide6.QtPrintSupport import *

View File

@@ -0,0 +1,4 @@
from PyQt5.QtQml import *
from PyQt6.QtQml import *
from PySide2.QtQml import *
from PySide6.QtQml import *

View File

@@ -0,0 +1,4 @@
from PyQt5.QtQuick import *
from PyQt6.QtQuick import *
from PySide2.QtQuick import *
from PySide6.QtQuick import *

View File

@@ -0,0 +1,4 @@
from PyQt5.QtQuickControls2 import *
from PyQt6.QtQuickControls2 import *
from PySide2.QtQuickControls2 import *
from PySide6.QtQuickControls2 import *

View File

@@ -0,0 +1,4 @@
from PyQt5.QtQuickWidgets import *
from PyQt6.QtQuickWidgets import *
from PySide2.QtQuickWidgets import *
from PySide6.QtQuickWidgets import *

View File

@@ -0,0 +1,4 @@
from PyQt5.QtRemoteObjects import *
from PyQt6.QtRemoteObjects import *
from PySide2.QtRemoteObjects import *
from PySide6.QtRemoteObjects import *

View File

@@ -0,0 +1,4 @@
from PyQt5.QtScript import *
from PyQt6.QtScript import *
from PySide2.QtScript import *
from PySide6.QtScript import *

View File

@@ -0,0 +1,4 @@
from PyQt5.QtScriptTools import *
from PyQt6.QtScriptTools import *
from PySide2.QtScriptTools import *
from PySide6.QtScriptTools import *

View File

@@ -0,0 +1,4 @@
from PyQt5.QtScxml import *
from PyQt6.QtScxml import *
from PySide2.QtScxml import *
from PySide6.QtScxml import *

View File

@@ -0,0 +1,4 @@
from PyQt5.QtSensors import *
from PyQt6.QtSensors import *
from PySide2.QtSensors import *
from PySide6.QtSensors import *

View File

@@ -0,0 +1,4 @@
from PyQt5.QtSerialPort import *
from PyQt6.QtSerialPort import *
from PySide2.QtSerialPort import *
from PySide6.QtSerialPort import *

View File

@@ -0,0 +1,4 @@
from PyQt5.QtSql import *
from PyQt6.QtSql import *
from PySide2.QtSql import *
from PySide6.QtSql import *

View File

@@ -0,0 +1,4 @@
from PyQt5.QtSvg import *
from PyQt6.QtSvg import *
from PySide2.QtSvg import *
from PySide6.QtSvg import *

View File

@@ -0,0 +1,4 @@
from PyQt5.QtTest import *
from PyQt6.QtTest import *
from PySide2.QtTest import *
from PySide6.QtTest import *

View File

@@ -0,0 +1,4 @@
from PyQt5.QtTextToSpeech import *
from PyQt6.QtTextToSpeech import *
from PySide2.QtTextToSpeech import *
from PySide6.QtTextToSpeech import *

View File

@@ -0,0 +1,4 @@
from PyQt5.QtUiTools import *
from PyQt6.QtUiTools import *
from PySide2.QtUiTools import *
from PySide6.QtUiTools import *

View File

@@ -0,0 +1,4 @@
from PyQt5.QtWebChannel import *
from PyQt6.QtWebChannel import *
from PySide2.QtWebChannel import *
from PySide6.QtWebChannel import *

View File

@@ -0,0 +1,4 @@
from PyQt5.QtWebEngine import *
from PyQt6.QtWebEngine import *
from PySide2.QtWebEngine import *
from PySide6.QtWebEngine import *

View File

@@ -0,0 +1,4 @@
from PyQt5.QtWebEngineCore import *
from PyQt6.QtWebEngineCore import *
from PySide2.QtWebEngineCore import *
from PySide6.QtWebEngineCore import *

View File

@@ -0,0 +1,4 @@
from PyQt5.QtWebEngineWidgets import *
from PyQt6.QtWebEngineWidgets import *
from PySide2.QtWebEngineWidgets import *
from PySide6.QtWebEngineWidgets import *

View File

@@ -0,0 +1,4 @@
from PyQt5.QtWebSockets import *
from PyQt6.QtWebSockets import *
from PySide2.QtWebSockets import *
from PySide6.QtWebSockets import *

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