mirror of
https://github.com/pyapp-kit/superqt.git
synced 2025-07-21 12:11:07 +02:00
Compare commits
125 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
41ea4e8907 | ||
|
39b6a0596f | ||
|
9ff01e757b | ||
|
dd9af3bfed | ||
|
7b964beb89 | ||
|
0407fdc4bd | ||
|
9119336de5 | ||
|
6318675a8c | ||
|
efa2757111 | ||
|
402d237bc4 | ||
|
dc255bdeac | ||
|
ae186df2ae | ||
|
0002d5ee37 | ||
|
f990fea78c | ||
|
1fb46854d4 | ||
|
ca4a1ecb20 | ||
|
c22b7d6f07 | ||
|
bb43cd7fad | ||
|
09c76a0bfa | ||
|
183899c4e7 | ||
|
a39b467563 | ||
|
6ce87d44a6 | ||
|
2cebc868a8 | ||
|
6abd3a21a6 | ||
|
7b2d8bfb2d | ||
|
ad2f05d908 | ||
|
3df7f49706 | ||
|
e98936e8d8 | ||
|
532d3bf89c | ||
|
16b383e783 | ||
|
38d15d1b3b | ||
|
8f09c38074 | ||
|
3c8b5bcf98 | ||
|
3ece7a27b1 | ||
|
e0bb2ea871 | ||
|
78997fe155 | ||
|
021f164419 | ||
|
7f50e69e28 | ||
|
2c747c5a4f | ||
|
b79c8e95b7 | ||
|
b393c6d039 | ||
|
61b8ab30ab | ||
|
abf544cf0e | ||
|
9f9dab6f3b | ||
|
97bb814451 | ||
|
d1c056886f | ||
|
a73e56bb83 | ||
|
6f71e46914 | ||
|
fbc67a745c | ||
|
77bd737e13 | ||
|
ba626e8786 | ||
|
04efa95511 | ||
|
f401d6d59c | ||
|
a3bd0d0edf | ||
|
e7e8dfc44c | ||
|
a556f16745 | ||
|
2864058974 | ||
|
463332f4fc | ||
|
f08e2d1720 | ||
|
39c10aa238 | ||
|
d5d40a35f3 | ||
|
5b92a19b82 | ||
|
a3b0f1b115 | ||
|
b1e6d55957 | ||
|
55535b7600 | ||
|
31c834053c | ||
|
69219c846d | ||
|
2edb3c287e | ||
|
218a7b4034 | ||
|
9ab24dbcf6 | ||
|
35acbbf5e6 | ||
|
0ae3350c57 | ||
|
c7f8780900 | ||
|
cc25733ce8 | ||
|
accb87021f | ||
|
ccad397838 | ||
|
68248c920c | ||
|
f8ac85aaf6 | ||
|
bd6fba96ad | ||
|
7d31812858 | ||
|
f27377ab1b | ||
|
2052fb8310 | ||
|
40d3e20bff | ||
|
f4d9881b0c | ||
|
ba1ae92bcc | ||
|
8217a1cc71 | ||
|
96de1a261a | ||
|
d8a8328793 | ||
|
2a9f47816a | ||
|
e06ab4d081 | ||
|
13e092e381 | ||
|
b2c485bcea | ||
|
d0d67da377 | ||
|
bc98f15ba1 | ||
|
49bd078012 | ||
|
d379611491 | ||
|
329eaaa9a0 | ||
|
d25f4c1cf7 | ||
|
a07ee64f8b | ||
|
bbd60eebaf | ||
|
9c55c6c657 | ||
|
3c217026af | ||
|
0681f7138a | ||
|
1e1f38d297 | ||
|
c101b29d65 | ||
|
cb1b589768 | ||
|
b0532c31c3 | ||
|
c355f8b06d | ||
|
d7afa8824c | ||
|
789b98f892 | ||
|
8001022e18 | ||
|
e1d2edb204 | ||
|
055a4fc1a7 | ||
|
5983bd1552 | ||
|
67035a0f0b | ||
|
8d76579122 | ||
|
c5658b353a | ||
|
5ab72a0c48 | ||
|
06da62811b | ||
|
bb538cda2a | ||
|
c8a40ba051 | ||
|
ac1d8403fd | ||
|
ba20665d57 | ||
|
939c5222af | ||
|
22beed7608 |
10
.github/dependabot.yml
vendored
Normal file
10
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||||
|
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
commit-message:
|
||||||
|
prefix: "ci(dependabot):"
|
193
.github/workflows/test_and_deploy.yml
vendored
193
.github/workflows/test_and_deploy.yml
vendored
@@ -1,15 +1,17 @@
|
|||||||
name: Test
|
name: Test
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
|
||||||
- main
|
- main
|
||||||
tags:
|
tags:
|
||||||
- "v*" # Push events to matching v*, i.e. v1.0, v20.15.10
|
- "v*" # Push events to matching v*, i.e. v1.0, v20.15.10
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- master
|
|
||||||
- main
|
- main
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -17,140 +19,165 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
name: ${{ matrix.platform }} py${{ matrix.python-version }} ${{ matrix.backend }}
|
name: ${{ matrix.platform }} py${{ matrix.python-version }} ${{ matrix.backend }}
|
||||||
runs-on: ${{ matrix.platform }}
|
runs-on: ${{ matrix.platform }}
|
||||||
timeout-minutes: 10
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
platform: [ubuntu-latest, windows-latest, macos-latest]
|
platform: [ubuntu-latest, windows-latest, macos-latest]
|
||||||
python-version: [3.7, 3.8, 3.9]
|
python-version: ["3.8", "3.9", "3.10", "3.11"]
|
||||||
backend: [pyqt5, pyside2]
|
backend: [pyqt5, pyside2, pyqt6]
|
||||||
|
exclude:
|
||||||
|
# Abort (core dumped) on linux pyqt6, unknown reason
|
||||||
|
- platform: ubuntu-latest
|
||||||
|
backend: pyqt6
|
||||||
|
# lack of wheels for pyside2/py3.11
|
||||||
|
- python-version: "3.11"
|
||||||
|
backend: pyside2
|
||||||
|
|
||||||
include:
|
include:
|
||||||
# pyqt6 and pyside6 on latest platforms
|
- python-version: "3.10"
|
||||||
- python-version: 3.9
|
platform: macos-latest
|
||||||
platform: ubuntu-latest
|
|
||||||
backend: pyside6
|
backend: pyside6
|
||||||
screenshot: 1
|
- python-version: "3.11"
|
||||||
- python-version: 3.9
|
platform: macos-latest
|
||||||
|
backend: pyside6
|
||||||
|
- python-version: "3.10"
|
||||||
platform: windows-latest
|
platform: windows-latest
|
||||||
backend: pyside6
|
backend: pyside6
|
||||||
screenshot: 1
|
- python-version: "3.11"
|
||||||
- python-version: 3.9
|
|
||||||
platform: macos-11.0
|
|
||||||
backend: pyside6
|
|
||||||
screenshot: 1
|
|
||||||
- python-version: 3.9
|
|
||||||
platform: ubuntu-latest
|
|
||||||
backend: pyqt6
|
|
||||||
- python-version: 3.9
|
|
||||||
platform: windows-latest
|
platform: windows-latest
|
||||||
backend: pyqt6
|
backend: pyside6
|
||||||
- python-version: 3.9
|
|
||||||
platform: macos-11.0
|
|
||||||
backend: pyqt6
|
|
||||||
|
|
||||||
# big sur, 3.9
|
|
||||||
- python-version: 3.9
|
|
||||||
platform: macos-11.0
|
|
||||||
backend: pyside2
|
|
||||||
- python-version: 3.9
|
|
||||||
platform: macos-11.0
|
|
||||||
backend: pyqt5
|
|
||||||
|
|
||||||
# legacy OS
|
|
||||||
- python-version: 3.8
|
|
||||||
platform: ubuntu-18.04
|
|
||||||
backend: pyside2
|
|
||||||
- python-version: 3.6
|
|
||||||
platform: ubuntu-16.04
|
|
||||||
backend: pyqt5
|
|
||||||
- python-version: 3.6
|
|
||||||
platform: windows-2016
|
|
||||||
backend: pyqt5
|
|
||||||
|
|
||||||
# legacy Qt
|
# legacy Qt
|
||||||
- python-version: 3.7
|
- python-version: 3.8
|
||||||
platform: ubuntu-latest
|
platform: ubuntu-latest
|
||||||
backend: pyqt511
|
backend: "pyqt5==5.12.*"
|
||||||
- python-version: 3.7
|
- python-version: 3.8
|
||||||
platform: ubuntu-latest
|
platform: ubuntu-latest
|
||||||
backend: pyside511
|
backend: "pyqt5==5.13.*"
|
||||||
|
- python-version: 3.8
|
||||||
|
platform: ubuntu-latest
|
||||||
|
backend: "pyqt5==5.14.*"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
- name: Install Linux libraries
|
- uses: tlambert03/setup-qt-libs@v1.4
|
||||||
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' && ( startsWith(matrix.backend, 'pyside6') || startsWith(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
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install setuptools tox tox-gh-actions
|
python -m pip install -e .[test]
|
||||||
|
python -m pip install ${{ matrix.backend }}
|
||||||
|
|
||||||
- name: Test with tox
|
- name: Test
|
||||||
run: tox
|
uses: aganders3/headless-gui@v1.2
|
||||||
env:
|
with:
|
||||||
PLATFORM: ${{ matrix.platform }}
|
run: python -m pytest --color=yes --cov=superqt --cov-report=xml
|
||||||
BACKEND: ${{ matrix.backend }}
|
|
||||||
|
|
||||||
- name: Coverage
|
- name: Coverage
|
||||||
uses: codecov/codecov-action@v1
|
uses: codecov/codecov-action@v3
|
||||||
|
|
||||||
- name: Install for screenshots
|
test_old_qtpy:
|
||||||
if: matrix.screenshot
|
name: qtpy minreq
|
||||||
run: pip install . ${{ matrix.backend }}
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
- name: Screenshots (Linux)
|
- uses: actions/checkout@v3
|
||||||
if: runner.os == 'Linux' && matrix.screenshot
|
- uses: tlambert03/setup-qt-libs@v1.4
|
||||||
uses: GabrielBB/xvfb-action@v1
|
- uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
run: python examples/demo_widget.py -snap
|
python-version: "3.8"
|
||||||
|
|
||||||
- name: Screenshots (macOS/Win)
|
- name: install
|
||||||
if: runner.os != 'Linux' && matrix.screenshot
|
run: |
|
||||||
run: python examples/demo_widget.py -snap
|
python -m pip install -U pip
|
||||||
|
python -m pip install -e .[test,pyqt5]
|
||||||
|
python -m pip install qtpy==1.1.0 typing-extensions==3.10.0.0
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v2
|
- name: Test
|
||||||
if: matrix.screenshot
|
uses: aganders3/headless-gui@v1.2
|
||||||
with:
|
with:
|
||||||
name: screenshots ${{ runner.os }}
|
run: python -m pytest --color=yes
|
||||||
path: screenshots
|
|
||||||
|
|
||||||
|
test_napari:
|
||||||
|
name: napari tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
path: superqt
|
||||||
|
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
repository: napari/napari
|
||||||
|
path: napari-repo
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- uses: tlambert03/setup-qt-libs@v1
|
||||||
|
- uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: "3.10"
|
||||||
|
|
||||||
|
- name: install
|
||||||
|
run: |
|
||||||
|
python -m pip install -U pip
|
||||||
|
python -m pip install ./superqt
|
||||||
|
python -m pip install ./napari-repo[testing,pyqt5]
|
||||||
|
|
||||||
|
- name: Test napari
|
||||||
|
uses: aganders3/headless-gui@v1.2
|
||||||
|
with:
|
||||||
|
working-directory: napari-repo
|
||||||
|
run: python -m pytest --color=yes napari/_qt
|
||||||
|
|
||||||
|
check-manifest:
|
||||||
|
name: Check Manifest
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: "3.x"
|
||||||
|
- run: pip install check-manifest && check-manifest
|
||||||
|
|
||||||
deploy:
|
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') }}
|
if: ${{ github.repository == 'pyapp-kit/superqt' && contains(github.ref, 'tags') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: "3.x"
|
python-version: "3.x"
|
||||||
- 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/*
|
||||||
|
|
||||||
|
- uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
generate_release_notes: true
|
||||||
|
9
.github_changelog_generator
Normal file
9
.github_changelog_generator
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# run this with:
|
||||||
|
# export CHANGELOG_GITHUB_TOKEN=......
|
||||||
|
# github_changelog_generator --future-release vX.Y.Z
|
||||||
|
user=pyapp-kit
|
||||||
|
project=superqt
|
||||||
|
issues=false
|
||||||
|
since-tag=v0.2.0
|
||||||
|
exclude-labels=duplicate,question,invalid,wontfix,hide
|
||||||
|
add-sections={"documentation":{"prefix":"**Documentation updates:**","labels":["documentation"]},"tests":{"prefix":"**Tests & CI:**","labels":["tests"]},"refactor":{"prefix":"**Refactors:**","labels":["refactor"]}}
|
5
.gitignore
vendored
5
.gitignore
vendored
@@ -9,6 +9,7 @@ __pycache__/
|
|||||||
# Distribution / packaging
|
# Distribution / packaging
|
||||||
.Python
|
.Python
|
||||||
env/
|
env/
|
||||||
|
.venv/
|
||||||
build/
|
build/
|
||||||
develop-eggs/
|
develop-eggs/
|
||||||
dist/
|
dist/
|
||||||
@@ -44,7 +45,6 @@ nosetests.xml
|
|||||||
coverage.xml
|
coverage.xml
|
||||||
*,cover
|
*,cover
|
||||||
.hypothesis/
|
.hypothesis/
|
||||||
.napari_cache
|
|
||||||
|
|
||||||
# Translations
|
# Translations
|
||||||
*.mo
|
*.mo
|
||||||
@@ -76,8 +76,9 @@ 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
|
.mypy_cache
|
||||||
|
docs/_auto_images/
|
||||||
|
@@ -1,35 +1,38 @@
|
|||||||
|
ci:
|
||||||
|
autoupdate_schedule: monthly
|
||||||
|
autofix_commit_msg: "style: [pre-commit.ci] auto fixes [...]"
|
||||||
|
autoupdate_commit_msg: "ci: [pre-commit.ci] autoupdate"
|
||||||
|
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v4.0.1
|
rev: v4.4.0
|
||||||
hooks:
|
hooks:
|
||||||
|
- id: check-docstring-first
|
||||||
- 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.17.0
|
|
||||||
hooks:
|
|
||||||
- id: setup-cfg-fmt
|
|
||||||
- repo: https://github.com/PyCQA/flake8
|
|
||||||
rev: 3.9.2
|
|
||||||
hooks:
|
|
||||||
- id: flake8
|
|
||||||
additional_dependencies:
|
|
||||||
[flake8-typing-imports==1.7.0]
|
|
||||||
exclude: examples
|
|
||||||
- repo: https://github.com/myint/autoflake
|
|
||||||
rev: v1.4
|
|
||||||
hooks:
|
|
||||||
- id: autoflake
|
|
||||||
args: ["--in-place", "--remove-all-unused-imports"]
|
|
||||||
- repo: https://github.com/PyCQA/isort
|
|
||||||
rev: 5.8.0
|
|
||||||
hooks:
|
|
||||||
- id: isort
|
|
||||||
- repo: https://github.com/asottile/pyupgrade
|
|
||||||
rev: v2.19.1
|
|
||||||
hooks:
|
|
||||||
- id: pyupgrade
|
|
||||||
args: [--py37-plus]
|
|
||||||
- repo: https://github.com/psf/black
|
- repo: https://github.com/psf/black
|
||||||
rev: 21.5b2
|
rev: 23.7.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
|
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.0.281
|
||||||
|
hooks:
|
||||||
|
- id: ruff
|
||||||
|
args: ["--fix"]
|
||||||
|
|
||||||
|
- repo: https://github.com/abravalheri/validate-pyproject
|
||||||
|
rev: v0.13
|
||||||
|
hooks:
|
||||||
|
- id: validate-pyproject
|
||||||
|
|
||||||
|
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||||
|
rev: v1.4.1
|
||||||
|
hooks:
|
||||||
|
- id: mypy
|
||||||
|
exclude: tests|examples
|
||||||
|
additional_dependencies:
|
||||||
|
- types-Pygments
|
||||||
|
stages:
|
||||||
|
- manual
|
||||||
|
298
CHANGELOG.md
Normal file
298
CHANGELOG.md
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## [v0.5.0](https://github.com/pyapp-kit/superqt/tree/v0.5.0) (2023-08-06)
|
||||||
|
|
||||||
|
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.4.1...v0.5.0)
|
||||||
|
|
||||||
|
**Implemented enhancements:**
|
||||||
|
|
||||||
|
- feat: add stepType to largeInt spinbox [\#179](https://github.com/pyapp-kit/superqt/pull/179) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
- Searchable tree widget from a mapping [\#158](https://github.com/pyapp-kit/superqt/pull/158) ([andy-sweet](https://github.com/andy-sweet))
|
||||||
|
- Add `QElidingLineEdit` class for elidable `QLineEdit`s [\#154](https://github.com/pyapp-kit/superqt/pull/154) ([dalthviz](https://github.com/dalthviz))
|
||||||
|
|
||||||
|
**Fixed bugs:**
|
||||||
|
|
||||||
|
- fix: focus events on QLabeledSlider [\#175](https://github.com/pyapp-kit/superqt/pull/175) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
- Set parent of timer in throttler [\#171](https://github.com/pyapp-kit/superqt/pull/171) ([Czaki](https://github.com/Czaki))
|
||||||
|
- fix: fix double slider label editing [\#168](https://github.com/pyapp-kit/superqt/pull/168) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
**Documentation updates:**
|
||||||
|
|
||||||
|
- Fix typos [\#147](https://github.com/pyapp-kit/superqt/pull/147) ([kianmeng](https://github.com/kianmeng))
|
||||||
|
|
||||||
|
**Tests & CI:**
|
||||||
|
|
||||||
|
- tests: add qtbot to test to fix windows segfault [\#165](https://github.com/pyapp-kit/superqt/pull/165) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
- test: fixing tests \[wip\] [\#164](https://github.com/pyapp-kit/superqt/pull/164) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
**Merged pull requests:**
|
||||||
|
|
||||||
|
- build: unpin pyside6.5 [\#178](https://github.com/pyapp-kit/superqt/pull/178) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
- build: pin pyside6 to \<6.5.1 [\#169](https://github.com/pyapp-kit/superqt/pull/169) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
- pin pyside6\<6.5 [\#160](https://github.com/pyapp-kit/superqt/pull/160) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
- ci: \[pre-commit.ci\] autoupdate [\#146](https://github.com/pyapp-kit/superqt/pull/146) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci))
|
||||||
|
|
||||||
|
## [v0.4.1](https://github.com/pyapp-kit/superqt/tree/v0.4.1) (2022-12-01)
|
||||||
|
|
||||||
|
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.4.0...v0.4.1)
|
||||||
|
|
||||||
|
**Implemented enhancements:**
|
||||||
|
|
||||||
|
- feat: Add signal to QCollapsible [\#142](https://github.com/pyapp-kit/superqt/pull/142) ([ppwadhwa](https://github.com/ppwadhwa))
|
||||||
|
- feat: Change icon used in Collapsible widget [\#140](https://github.com/pyapp-kit/superqt/pull/140) ([ppwadhwa](https://github.com/ppwadhwa))
|
||||||
|
|
||||||
|
**Fixed bugs:**
|
||||||
|
|
||||||
|
- Move QCollapsible toggle signal emit [\#144](https://github.com/pyapp-kit/superqt/pull/144) ([ppwadhwa](https://github.com/ppwadhwa))
|
||||||
|
|
||||||
|
**Merged pull requests:**
|
||||||
|
|
||||||
|
- build: use hatch for build backend, and use ruff for linting [\#139](https://github.com/pyapp-kit/superqt/pull/139) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
- chore: rename napari org to pyapp-kit [\#137](https://github.com/pyapp-kit/superqt/pull/137) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
## [v0.4.0](https://github.com/pyapp-kit/superqt/tree/v0.4.0) (2022-11-09)
|
||||||
|
|
||||||
|
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.8...v0.4.0)
|
||||||
|
|
||||||
|
**Fixed bugs:**
|
||||||
|
|
||||||
|
- fix: fix quantity set value and add test [\#131](https://github.com/pyapp-kit/superqt/pull/131) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
**Refactors:**
|
||||||
|
|
||||||
|
- refactor: update pyproject and ci, add py3.11 test [\#132](https://github.com/pyapp-kit/superqt/pull/132) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
**Merged pull requests:**
|
||||||
|
|
||||||
|
- chore: changelog v0.4.0 [\#136](https://github.com/pyapp-kit/superqt/pull/136) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
- ci\(dependabot\): bump actions/upload-artifact from 2 to 3 [\#135](https://github.com/pyapp-kit/superqt/pull/135) ([dependabot[bot]](https://github.com/apps/dependabot))
|
||||||
|
- ci\(dependabot\): bump codecov/codecov-action from 2 to 3 [\#134](https://github.com/pyapp-kit/superqt/pull/134) ([dependabot[bot]](https://github.com/apps/dependabot))
|
||||||
|
- build: unpin pyside6 [\#133](https://github.com/pyapp-kit/superqt/pull/133) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
## [v0.3.8](https://github.com/pyapp-kit/superqt/tree/v0.3.8) (2022-10-10)
|
||||||
|
|
||||||
|
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.7...v0.3.8)
|
||||||
|
|
||||||
|
**Fixed bugs:**
|
||||||
|
|
||||||
|
- fix: allow submodule imports [\#128](https://github.com/pyapp-kit/superqt/pull/128) ([kne42](https://github.com/kne42))
|
||||||
|
|
||||||
|
## [v0.3.7](https://github.com/pyapp-kit/superqt/tree/v0.3.7) (2022-10-10)
|
||||||
|
|
||||||
|
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.6...v0.3.7)
|
||||||
|
|
||||||
|
**Implemented enhancements:**
|
||||||
|
|
||||||
|
- feat: add Quantity widget \(using pint\) [\#126](https://github.com/pyapp-kit/superqt/pull/126) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
## [v0.3.6](https://github.com/pyapp-kit/superqt/tree/v0.3.6) (2022-10-05)
|
||||||
|
|
||||||
|
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.6rc0...v0.3.6)
|
||||||
|
|
||||||
|
**Documentation updates:**
|
||||||
|
|
||||||
|
- minor fix to readme [\#125](https://github.com/pyapp-kit/superqt/pull/125) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
- Docs [\#124](https://github.com/pyapp-kit/superqt/pull/124) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
## [v0.3.6rc0](https://github.com/pyapp-kit/superqt/tree/v0.3.6rc0) (2022-10-03)
|
||||||
|
|
||||||
|
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.5...v0.3.6rc0)
|
||||||
|
|
||||||
|
**Implemented enhancements:**
|
||||||
|
|
||||||
|
- feat: add editing finished signal to LabeledSliders [\#122](https://github.com/pyapp-kit/superqt/pull/122) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
**Fixed bugs:**
|
||||||
|
|
||||||
|
- fix: fix missing labels after setValue [\#123](https://github.com/pyapp-kit/superqt/pull/123) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
- fix: Fix TypeError on slider rangeChanged signal [\#121](https://github.com/pyapp-kit/superqt/pull/121) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
- Simple workaround for pyside 6 [\#119](https://github.com/pyapp-kit/superqt/pull/119) ([Czaki](https://github.com/Czaki))
|
||||||
|
- fix: Offer patch for \(unstyled\) QSliders on macos 12 and Qt \<6 [\#117](https://github.com/pyapp-kit/superqt/pull/117) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
## [v0.3.5](https://github.com/pyapp-kit/superqt/tree/v0.3.5) (2022-08-17)
|
||||||
|
|
||||||
|
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.4...v0.3.5)
|
||||||
|
|
||||||
|
**Fixed bugs:**
|
||||||
|
|
||||||
|
- fix range slider drag crash on PyQt6 [\#108](https://github.com/pyapp-kit/superqt/pull/108) ([sfhbarnett](https://github.com/sfhbarnett))
|
||||||
|
- Fix float value error in pyqt configuration [\#106](https://github.com/pyapp-kit/superqt/pull/106) ([mstabrin](https://github.com/mstabrin))
|
||||||
|
|
||||||
|
**Merged pull requests:**
|
||||||
|
|
||||||
|
- chore: changelog v0.3.5 [\#110](https://github.com/pyapp-kit/superqt/pull/110) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
## [v0.3.4](https://github.com/pyapp-kit/superqt/tree/v0.3.4) (2022-07-24)
|
||||||
|
|
||||||
|
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.3...v0.3.4)
|
||||||
|
|
||||||
|
**Fixed bugs:**
|
||||||
|
|
||||||
|
- fix: relax runtime typing extensions requirement [\#101](https://github.com/pyapp-kit/superqt/pull/101) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
- fix: catch qpixmap deprecation [\#99](https://github.com/pyapp-kit/superqt/pull/99) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
## [v0.3.3](https://github.com/pyapp-kit/superqt/tree/v0.3.3) (2022-07-10)
|
||||||
|
|
||||||
|
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.2...v0.3.3)
|
||||||
|
|
||||||
|
**Implemented enhancements:**
|
||||||
|
|
||||||
|
- Add code syntax highlight utils [\#88](https://github.com/pyapp-kit/superqt/pull/88) ([Czaki](https://github.com/Czaki))
|
||||||
|
|
||||||
|
**Fixed bugs:**
|
||||||
|
|
||||||
|
- fix: fix deprecation warning on fonticon plugin discovery on python 3.10 [\#95](https://github.com/pyapp-kit/superqt/pull/95) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
## [v0.3.2](https://github.com/pyapp-kit/superqt/tree/v0.3.2) (2022-05-03)
|
||||||
|
|
||||||
|
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.1...v0.3.2)
|
||||||
|
|
||||||
|
**Implemented enhancements:**
|
||||||
|
|
||||||
|
- Add QSearchableListWidget and QSearchableComboBox widgets [\#80](https://github.com/pyapp-kit/superqt/pull/80) ([Czaki](https://github.com/Czaki))
|
||||||
|
|
||||||
|
**Fixed bugs:**
|
||||||
|
|
||||||
|
- Fix crazy animation loop on Qcollapsible [\#84](https://github.com/pyapp-kit/superqt/pull/84) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
- Reorder label update signal [\#83](https://github.com/pyapp-kit/superqt/pull/83) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
- Fix height of expanded QCollapsible when child changes size [\#72](https://github.com/pyapp-kit/superqt/pull/72) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
**Tests & CI:**
|
||||||
|
|
||||||
|
- Fix deprecation warnings in tests [\#82](https://github.com/pyapp-kit/superqt/pull/82) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
**Merged pull requests:**
|
||||||
|
|
||||||
|
- Add changelog for v0.3.2 [\#86](https://github.com/pyapp-kit/superqt/pull/86) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
## [v0.3.1](https://github.com/pyapp-kit/superqt/tree/v0.3.1) (2022-03-02)
|
||||||
|
|
||||||
|
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.0...v0.3.1)
|
||||||
|
|
||||||
|
**Implemented enhancements:**
|
||||||
|
|
||||||
|
- Add `signals_blocked` util [\#69](https://github.com/pyapp-kit/superqt/pull/69) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
**Fixed bugs:**
|
||||||
|
|
||||||
|
- put SignalInstance in TYPE\_CHECKING clause, check min requirements [\#70](https://github.com/pyapp-kit/superqt/pull/70) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
**Merged pull requests:**
|
||||||
|
|
||||||
|
- Add changelog for v0.3.1 [\#71](https://github.com/pyapp-kit/superqt/pull/71) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
## [v0.3.0](https://github.com/pyapp-kit/superqt/tree/v0.3.0) (2022-02-16)
|
||||||
|
|
||||||
|
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.5-1...v0.3.0)
|
||||||
|
|
||||||
|
**Implemented enhancements:**
|
||||||
|
|
||||||
|
- Qthrottler and debouncer [\#62](https://github.com/pyapp-kit/superqt/pull/62) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
- add edgeLabelMode option to QLabeledSlider [\#59](https://github.com/pyapp-kit/superqt/pull/59) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
**Fixed bugs:**
|
||||||
|
|
||||||
|
- Fix nested threadworker not starting [\#63](https://github.com/pyapp-kit/superqt/pull/63) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
- Add missing signals on proxy sliders [\#54](https://github.com/pyapp-kit/superqt/pull/54) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
- Ugly but functional workaround for pyside6.2.1 breakages [\#51](https://github.com/pyapp-kit/superqt/pull/51) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
**Tests & CI:**
|
||||||
|
|
||||||
|
- add napari test to CI [\#67](https://github.com/pyapp-kit/superqt/pull/67) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
- add gh-release action [\#65](https://github.com/pyapp-kit/superqt/pull/65) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
- fix xvfb tests [\#61](https://github.com/pyapp-kit/superqt/pull/61) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
**Refactors:**
|
||||||
|
|
||||||
|
- Use qtpy, deprecate superqt.qtcompat, drop support for Qt \<5.12 [\#39](https://github.com/pyapp-kit/superqt/pull/39) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
**Merged pull requests:**
|
||||||
|
|
||||||
|
- Add changelog for v0.3.0 [\#68](https://github.com/pyapp-kit/superqt/pull/68) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
## [v0.2.5-1](https://github.com/pyapp-kit/superqt/tree/v0.2.5-1) (2021-11-23)
|
||||||
|
|
||||||
|
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.5...v0.2.5-1)
|
||||||
|
|
||||||
|
**Merged pull requests:**
|
||||||
|
|
||||||
|
- typing-extensions version pinning [\#46](https://github.com/pyapp-kit/superqt/pull/46) ([AhmetCanSolak](https://github.com/AhmetCanSolak))
|
||||||
|
|
||||||
|
## [v0.2.5](https://github.com/pyapp-kit/superqt/tree/v0.2.5) (2021-11-22)
|
||||||
|
|
||||||
|
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.4...v0.2.5)
|
||||||
|
|
||||||
|
**Implemented enhancements:**
|
||||||
|
|
||||||
|
- add support for python 3.10 [\#42](https://github.com/pyapp-kit/superqt/pull/42) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
- QCollapsible for Collapsible Section Control [\#37](https://github.com/pyapp-kit/superqt/pull/37) ([MosGeo](https://github.com/MosGeo))
|
||||||
|
- Threadworker [\#31](https://github.com/pyapp-kit/superqt/pull/31) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
- Add font icons [\#24](https://github.com/pyapp-kit/superqt/pull/24) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
**Fixed bugs:**
|
||||||
|
|
||||||
|
- Fix some small linting issues. [\#41](https://github.com/pyapp-kit/superqt/pull/41) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
- Use functools.wraps insterad of \_\_wraped\_\_ and manual proxing \_\_name\_\_ [\#29](https://github.com/pyapp-kit/superqt/pull/29) ([Czaki](https://github.com/Czaki))
|
||||||
|
- Propagate function name in `ensure_main_thread` and `ensure_object_thread` [\#28](https://github.com/pyapp-kit/superqt/pull/28) ([Czaki](https://github.com/Czaki))
|
||||||
|
|
||||||
|
**Tests & CI:**
|
||||||
|
|
||||||
|
- reskip test\_object\_thread\_return on ci [\#43](https://github.com/pyapp-kit/superqt/pull/43) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
**Refactors:**
|
||||||
|
|
||||||
|
- refactoring qtcompat [\#34](https://github.com/pyapp-kit/superqt/pull/34) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
**Merged pull requests:**
|
||||||
|
|
||||||
|
- Fix-manifest, move font tests [\#44](https://github.com/pyapp-kit/superqt/pull/44) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
- update deploy [\#33](https://github.com/pyapp-kit/superqt/pull/33) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
- move to src layout [\#32](https://github.com/pyapp-kit/superqt/pull/32) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
## [v0.2.4](https://github.com/pyapp-kit/superqt/tree/v0.2.4) (2021-09-13)
|
||||||
|
|
||||||
|
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.3...v0.2.4)
|
||||||
|
|
||||||
|
**Implemented enhancements:**
|
||||||
|
|
||||||
|
- Add type stubs for ensure\_thread decorator [\#23](https://github.com/pyapp-kit/superqt/pull/23) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
- Add `ensure_main_tread` and `ensure_object_thread` [\#22](https://github.com/pyapp-kit/superqt/pull/22) ([Czaki](https://github.com/Czaki))
|
||||||
|
- Add QMessageHandler context manager [\#21](https://github.com/pyapp-kit/superqt/pull/21) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
**Merged pull requests:**
|
||||||
|
|
||||||
|
- add changelog for 0.2.4 [\#25](https://github.com/pyapp-kit/superqt/pull/25) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
## [v0.2.3](https://github.com/pyapp-kit/superqt/tree/v0.2.3) (2021-08-25)
|
||||||
|
|
||||||
|
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.2...v0.2.3)
|
||||||
|
|
||||||
|
**Fixed bugs:**
|
||||||
|
|
||||||
|
- Fix warnings on eliding label for 5.12, test more qt versions [\#19](https://github.com/pyapp-kit/superqt/pull/19) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
## [v0.2.2](https://github.com/pyapp-kit/superqt/tree/v0.2.2) (2021-08-17)
|
||||||
|
|
||||||
|
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.1...v0.2.2)
|
||||||
|
|
||||||
|
**Implemented enhancements:**
|
||||||
|
|
||||||
|
- Add QElidingLabel [\#16](https://github.com/pyapp-kit/superqt/pull/16) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
- Enum ComboBox implementation [\#13](https://github.com/pyapp-kit/superqt/pull/13) ([Czaki](https://github.com/Czaki))
|
||||||
|
|
||||||
|
**Documentation updates:**
|
||||||
|
|
||||||
|
- fix broken link [\#18](https://github.com/pyapp-kit/superqt/pull/18) ([haesleinhuepf](https://github.com/haesleinhuepf))
|
||||||
|
|
||||||
|
## [v0.2.1](https://github.com/pyapp-kit/superqt/tree/v0.2.1) (2021-07-10)
|
||||||
|
|
||||||
|
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.0...v0.2.1)
|
||||||
|
|
||||||
|
**Fixed bugs:**
|
||||||
|
|
||||||
|
- Fix QLabeledRangeSlider API \(fix slider proxy\) [\#10](https://github.com/pyapp-kit/superqt/pull/10) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
- Fix range slider with negative min range [\#9](https://github.com/pyapp-kit/superqt/pull/9) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
\* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)*
|
@@ -26,13 +26,11 @@ pytest
|
|||||||
|
|
||||||
All widgets must be well-tested, and should work on:
|
All widgets must be well-tested, and should work on:
|
||||||
|
|
||||||
- Python 3.7 and above
|
- Python 3.8 and above
|
||||||
- PyQt5 (5.11 and above) & PyQt6
|
- PyQt5 (5.11 and above) & PyQt6
|
||||||
- PySide2 (5.11 and above) & PySide6
|
- PySide2 (5.11 and above) & PySide6
|
||||||
- macOS, Windows, & Linux
|
- 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
|
## Style Guide
|
||||||
|
|
||||||
@@ -50,5 +48,4 @@ All widgets should try to match the native Qt API as much as possible:
|
|||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Tests can be run in the current environment with `pytest`. Or, to run tests
|
Tests can be run in the current environment with `pytest`.
|
||||||
against all supported python & Qt versions, run `tox`.
|
|
||||||
|
@@ -1,5 +0,0 @@
|
|||||||
include LICENSE
|
|
||||||
include README.md
|
|
||||||
|
|
||||||
recursive-exclude * __pycache__
|
|
||||||
recursive-exclude * *.py[co]
|
|
34
README.md
34
README.md
@@ -1,12 +1,11 @@
|
|||||||
#  superqt!
|
#  superqt!
|
||||||
|
|
||||||
|
[](https://github.com/pyapp-kit/superqt/raw/master/LICENSE)
|
||||||
[](https://github.com/napari/superqt/raw/master/LICENSE)
|
|
||||||
[](https://pypi.org/project/superqt)
|
[](https://pypi.org/project/superqt)
|
||||||
[](https://python.org)
|
Version](https://img.shields.io/pypi/pyversions/superqt.svg?color=green)](https://python.org)
|
||||||
[](https://github.com/napari/superqt/actions/workflows/test_and_deploy.yml)
|
[](https://github.com/pyapp-kit/superqt/actions/workflows/test_and_deploy.yml)
|
||||||
[](https://codecov.io/gh/napari/superqt)
|
[](https://codecov.io/gh/pyapp-kit/superqt)
|
||||||
|
|
||||||
### "missing" widgets and components for PyQt/PySide
|
### "missing" widgets and components for PyQt/PySide
|
||||||
|
|
||||||
@@ -16,29 +15,36 @@ that are not provided in the native QtWidgets module.
|
|||||||
Components are tested on:
|
Components are tested on:
|
||||||
|
|
||||||
- macOS, Windows, & Linux
|
- macOS, Windows, & Linux
|
||||||
- Python 3.7 and above
|
- Python 3.8 and above
|
||||||
- PyQt5 (5.11 and above) & PyQt6
|
- PyQt5 (5.11 and above) & PyQt6
|
||||||
- PySide2 (5.11 and above) & PySide6
|
- PySide2 (5.11 and above) & PySide6
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Documentation is available at https://pyapp-kit.github.io/superqt/
|
||||||
|
|
||||||
## Widgets
|
## Widgets
|
||||||
|
|
||||||
Widgets include:
|
superqt provides a variety of widgets that are not included in the native QtWidgets module, including multihandle (range) sliders, comboboxes, and more.
|
||||||
|
|
||||||
- [Float Slider](docs/sliders.md#float-slider)
|
See the [widgets documentation](https://pyapp-kit.github.io/superqt/widgets) for a full list of widgets.
|
||||||
|
|
||||||
- [Range Slider](docs/sliders.md#range-slider) (multi-handle slider)
|
- [Range Slider](https://pyapp-kit.github.io/superqt/widgets/qrangeslider/) (multi-handle slider)
|
||||||
|
|
||||||
<img src="https://raw.githubusercontent.com/napari/superqt/main/docs/images/demo_darwin10.png" alt="range sliders" width=680>
|
<img src="https://raw.githubusercontent.com/pyapp-kit/superqt/main/docs/images/demo_darwin10.png" alt="range sliders" width=680>
|
||||||
|
|
||||||
|
<img src="https://raw.githubusercontent.com/pyapp-kit/superqt/main/docs/images/labeled_qslider.png" alt="range sliders" width=680>
|
||||||
|
|
||||||
- [Labeled Sliders](docs/sliders.md#labeled-sliders) (sliders with linked
|
<img src="https://raw.githubusercontent.com/pyapp-kit/superqt/main/docs/images/labeled_range.png" alt="range sliders" width=680>
|
||||||
spinboxes)
|
|
||||||
|
|
||||||
<img src="https://raw.githubusercontent.com/napari/superqt/main/docs/images/labeled_qslider.png" alt="range sliders" width=680>
|
## Utilities
|
||||||
|
|
||||||
<img src="https://raw.githubusercontent.com/napari/superqt/main/docs/images/labeled_range.png" alt="range sliders" width=680>
|
superqt includes a number of utilities for working with Qt, including:
|
||||||
|
|
||||||
- Unbound Integer SpinBox (backed by python `int`)
|
- tools and decorators for working with threads in qt.
|
||||||
|
- `superqt.fonticon` for generating icons from font files (such as [Material Design Icons](https://materialdesignicons.com/) and [Font Awesome](https://fontawesome.com/))
|
||||||
|
|
||||||
|
See the [utilities documentation](https://pyapp-kit.github.io/superqt/utilities/) for a full list of utilities.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
ignore:
|
ignore:
|
||||||
- superqt/_version.py
|
- superqt/_version.py
|
||||||
- superqt/qtcompat/*
|
|
||||||
- '*_tests*'
|
- '*_tests*'
|
||||||
coverage:
|
coverage:
|
||||||
status:
|
status:
|
||||||
|
146
docs/_macros.py
Normal file
146
docs/_macros.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import sys
|
||||||
|
from enum import EnumMeta
|
||||||
|
from importlib import import_module
|
||||||
|
from pathlib import Path
|
||||||
|
from textwrap import dedent
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from jinja2 import pass_context
|
||||||
|
from qtpy.QtCore import QObject, Signal
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from mkdocs_macros.plugin import MacrosPlugin
|
||||||
|
|
||||||
|
EXAMPLES = Path(__file__).parent.parent / "examples"
|
||||||
|
IMAGES = Path(__file__).parent / "_auto_images"
|
||||||
|
IMAGES.mkdir(exist_ok=True, parents=True)
|
||||||
|
|
||||||
|
|
||||||
|
def define_env(env: "MacrosPlugin"):
|
||||||
|
@env.macro
|
||||||
|
@pass_context
|
||||||
|
def show_widget(context, width: int = 500) -> list[Path]:
|
||||||
|
# extract all fenced code blocks starting with "python"
|
||||||
|
page = context["page"]
|
||||||
|
dest = IMAGES / f"{page.title}.png"
|
||||||
|
if "build" in sys.argv:
|
||||||
|
dest.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
codeblocks = [
|
||||||
|
b[6:].strip()
|
||||||
|
for b in page.markdown.split("```")
|
||||||
|
if b.startswith("python")
|
||||||
|
]
|
||||||
|
src = codeblocks[0].strip()
|
||||||
|
src = src.replace(
|
||||||
|
"QApplication([])", "QApplication.instance() or QApplication([])"
|
||||||
|
)
|
||||||
|
src = src.replace("app.exec_()", "")
|
||||||
|
|
||||||
|
exec(src) # noqa: S102
|
||||||
|
_grab(dest, width)
|
||||||
|
return (
|
||||||
|
f""
|
||||||
|
f"{{ loading=lazy; width={width} }}\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
@env.macro
|
||||||
|
def show_members(cls: str):
|
||||||
|
# import class
|
||||||
|
module, name = cls.rsplit(".", 1)
|
||||||
|
_cls = getattr(import_module(module), name)
|
||||||
|
|
||||||
|
first_q = next(
|
||||||
|
(
|
||||||
|
b.__name__
|
||||||
|
for b in _cls.__mro__
|
||||||
|
if issubclass(b, QObject) and ".Qt" in b.__module__
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
inherited_members = set()
|
||||||
|
for base in _cls.__mro__:
|
||||||
|
if issubclass(base, QObject) and ".Qt" in base.__module__:
|
||||||
|
inherited_members.update(
|
||||||
|
{k for k in dir(base) if not k.startswith("_")}
|
||||||
|
)
|
||||||
|
|
||||||
|
new_signals = {
|
||||||
|
k
|
||||||
|
for k, v in vars(_cls).items()
|
||||||
|
if not k.startswith("_") and isinstance(v, Signal)
|
||||||
|
}
|
||||||
|
|
||||||
|
self_members = {
|
||||||
|
k
|
||||||
|
for k in dir(_cls)
|
||||||
|
if not k.startswith("_") and k not in inherited_members | new_signals
|
||||||
|
}
|
||||||
|
|
||||||
|
enums = []
|
||||||
|
for m in list(self_members):
|
||||||
|
if isinstance(getattr(_cls, m), EnumMeta):
|
||||||
|
self_members.remove(m)
|
||||||
|
enums.append(m)
|
||||||
|
|
||||||
|
out = ""
|
||||||
|
if first_q:
|
||||||
|
url = f"https://doc.qt.io/qt-6/{first_q.lower()}.html"
|
||||||
|
out += f"## Qt Class\n\n<a href='{url}'>`{first_q}`</a>\n\n"
|
||||||
|
|
||||||
|
out += ""
|
||||||
|
|
||||||
|
if new_signals:
|
||||||
|
out += "## Signals\n\n"
|
||||||
|
for sig in new_signals:
|
||||||
|
out += f"### `{sig}`\n\n"
|
||||||
|
|
||||||
|
if enums:
|
||||||
|
out += "## Enums\n\n"
|
||||||
|
for e in enums:
|
||||||
|
out += f"### `{_cls.__name__}.{e}`\n\n"
|
||||||
|
for m in getattr(_cls, e):
|
||||||
|
out += f"- `{m.name}`\n\n"
|
||||||
|
|
||||||
|
if self_members:
|
||||||
|
out += dedent(
|
||||||
|
f"""
|
||||||
|
## Methods
|
||||||
|
|
||||||
|
::: {cls}
|
||||||
|
options:
|
||||||
|
heading_level: 3
|
||||||
|
show_source: False
|
||||||
|
show_inherited_members: false
|
||||||
|
show_signature_annotations: True
|
||||||
|
members: {sorted(self_members)}
|
||||||
|
docstring_style: numpy
|
||||||
|
show_bases: False
|
||||||
|
show_root_toc_entry: False
|
||||||
|
show_root_heading: False
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _grab(dest: str | Path, width) -> list[Path]:
|
||||||
|
"""Grab the top widgets of the application."""
|
||||||
|
from qtpy.QtCore import QTimer
|
||||||
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
|
w = QApplication.topLevelWidgets()[-1]
|
||||||
|
w.setFixedWidth(width)
|
||||||
|
w.activateWindow()
|
||||||
|
w.setMinimumHeight(40)
|
||||||
|
w.grab().save(str(dest))
|
||||||
|
|
||||||
|
# hack to make sure the object is truly closed and deleted
|
||||||
|
while True:
|
||||||
|
QTimer.singleShot(10, w.deleteLater)
|
||||||
|
QApplication.processEvents()
|
||||||
|
try:
|
||||||
|
w.parent()
|
||||||
|
except RuntimeError:
|
||||||
|
return
|
26
docs/faq.md
Normal file
26
docs/faq.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# FAQ
|
||||||
|
|
||||||
|
## Sliders not dragging properly on MacOS 12+
|
||||||
|
|
||||||
|
??? details
|
||||||
|
On MacOS Monterey, with Qt5, there is a bug that causes all sliders
|
||||||
|
(including native Qt sliders) to not respond properly to drag events. See:
|
||||||
|
|
||||||
|
- [https://bugreports.qt.io/browse/QTBUG-98093](https://bugreports.qt.io/browse/QTBUG-98093)
|
||||||
|
- [https://github.com/pyapp-kit/superqt/issues/74](https://github.com/pyapp-kit/superqt/issues/74)
|
||||||
|
|
||||||
|
Superqt includes a workaround for this issue, but it is not perfect, and it requires using a custom stylesheet (which may interfere with your own styles). Note that you
|
||||||
|
may not see this issue if you're already using custom stylesheets.
|
||||||
|
|
||||||
|
To opt in to the workaround, do any of the following:
|
||||||
|
|
||||||
|
- set the environment variable `USE_MAC_SLIDER_PATCH=1` before importing superqt
|
||||||
|
(note: this is safe to use even if you're targeting more than just MacOS 12, it will only be applied when needed)
|
||||||
|
- call the `applyMacStylePatch()` method on any of the superqt slider subclasses (note, this will override your slider styles)
|
||||||
|
- apply the stylesheet manually:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from superqt.sliders import MONTEREY_SLIDER_STYLES_FIX
|
||||||
|
|
||||||
|
slider.setStyleSheet(MONTEREY_SLIDER_STYLES_FIX)
|
||||||
|
```
|
29
docs/index.md
Normal file
29
docs/index.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# superqt
|
||||||
|
|
||||||
|
##  "missing" widgets and components for PyQt/PySide
|
||||||
|
|
||||||
|
This repository aims to provide high-quality community-contributed Qt widgets
|
||||||
|
and components for [PyQt](https://riverbankcomputing.com/software/pyqt/) &
|
||||||
|
[PySide](https://www.qt.io/qt-for-python) that are not provided in the native
|
||||||
|
QtWidgets module.
|
||||||
|
|
||||||
|
Components are tested on:
|
||||||
|
|
||||||
|
- macOS, Windows, & Linux
|
||||||
|
- Python 3.8 and above
|
||||||
|
- PyQt5 (5.11 and above) & PyQt6
|
||||||
|
- PySide2 (5.11 and above) & PySide6
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install superqt
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda install -c conda-forge superqt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
See the [Widgets](./widgets/) and [Utilities](./utilities/) pages for features offered by superqt.
|
238
docs/sliders.md
238
docs/sliders.md
@@ -1,238 +0,0 @@
|
|||||||
# Sliders
|
|
||||||
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
- `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
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
##### Big Sur
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
#### Windows
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
#### Linux
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Labeled Sliders
|
|
||||||
|
|
||||||
This package also includes two "labeled" slider variants. One for `QRangeSlider`, and one for the native `QSlider`:
|
|
||||||
|
|
||||||
### `QLabeledRangeSlider`
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
```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`
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
```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
|
|
||||||
```
|
|
52
docs/utilities/code_syntax_highlight.md
Normal file
52
docs/utilities/code_syntax_highlight.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# CodeSyntaxHighlight
|
||||||
|
|
||||||
|
A code highlighter subclass of `QSyntaxHighlighter`
|
||||||
|
that can be used to highlight code in a QTextEdit.
|
||||||
|
|
||||||
|
Code lexer and available styles are from [`pygments`](https://pygments.org/) python library
|
||||||
|
|
||||||
|
List of available languages are available [here](https://pygments.org/languages/).
|
||||||
|
|
||||||
|
List of available styles are available [here](https://pygments.org/styles/).
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
from qtpy.QtGui import QColor, QPalette
|
||||||
|
from qtpy.QtWidgets import QApplication, QTextEdit
|
||||||
|
|
||||||
|
from superqt.utils import CodeSyntaxHighlight
|
||||||
|
|
||||||
|
app = QApplication([])
|
||||||
|
|
||||||
|
text_area = QTextEdit()
|
||||||
|
|
||||||
|
highlight = CodeSyntaxHighlight(text_area.document(), "python", "monokai")
|
||||||
|
|
||||||
|
palette = text_area.palette()
|
||||||
|
palette.setColor(QPalette.Base, QColor(highlight.background_color))
|
||||||
|
text_area.setPalette(palette)
|
||||||
|
text_area.setText(
|
||||||
|
"""from argparse import ArgumentParser
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = ArgumentParser()
|
||||||
|
parser.add_argument("name", help="Your name")
|
||||||
|
args = parser.parse_args()
|
||||||
|
print(f"Hello {args.name}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
text_area.show()
|
||||||
|
text_area.resize(400, 200)
|
||||||
|
|
||||||
|
app.exec_()
|
||||||
|
```
|
||||||
|
|
||||||
|
{{ show_widget() }}
|
||||||
|
|
||||||
|
{{ show_members('superqt.utils.CodeSyntaxHighlight') }}
|
101
docs/utilities/fonticon.md
Normal file
101
docs/utilities/fonticon.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Font icons
|
||||||
|
|
||||||
|
The `superqt.fonticon` module provides a set of utilities for working with font
|
||||||
|
icons such as [Font Awesome](https://fontawesome.com/) or [Material Design
|
||||||
|
Icons](https://materialdesignicons.com/).
|
||||||
|
|
||||||
|
## Basic Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fonticon_fa5 import FA5S
|
||||||
|
|
||||||
|
from qtpy.QtCore import QSize
|
||||||
|
from qtpy.QtWidgets import QApplication, QPushButton
|
||||||
|
|
||||||
|
from superqt.fonticon import icon, pulse
|
||||||
|
|
||||||
|
app = QApplication([])
|
||||||
|
|
||||||
|
btn2 = QPushButton()
|
||||||
|
btn2.setIcon(icon(FA5S.smile, color="blue"))
|
||||||
|
btn2.setIconSize(QSize(225, 225))
|
||||||
|
btn2.show()
|
||||||
|
|
||||||
|
app.exec()
|
||||||
|
```
|
||||||
|
|
||||||
|
{{ show_widget(225) }}
|
||||||
|
|
||||||
|
## Font Icon plugins
|
||||||
|
|
||||||
|
Ready-made fonticon packs are available as plugins:
|
||||||
|
|
||||||
|
### [Font Awesome 5](https://fontawesome.com/v5/search)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install fonticon-fontawesome5
|
||||||
|
```
|
||||||
|
|
||||||
|
### [Font Awesome 6](https://fontawesome.com/v6/search)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install fonticon-fontawesome6
|
||||||
|
```
|
||||||
|
|
||||||
|
### [Material Design Icons](https://materialdesignicons.com/)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install fonticon-materialdesignicons6
|
||||||
|
```
|
||||||
|
|
||||||
|
### See also
|
||||||
|
|
||||||
|
- <https://github.com/tlambert03/fonticon-bootstrapicons>
|
||||||
|
- <https://github.com/tlambert03/fonticon-linearicons>
|
||||||
|
- <https://github.com/tlambert03/fonticon-feather>
|
||||||
|
|
||||||
|
`superqt.fonticon` is a pluggable system, and font icon packs may use the `"superqt.fonticon"`
|
||||||
|
entry point to register themselves with superqt. See [`fonticon-cookiecutter`](https://github.com/tlambert03/fonticon-cookiecutter) for a template, or look through the following repos for examples:
|
||||||
|
|
||||||
|
- <https://github.com/tlambert03/fonticon-fontawesome6>
|
||||||
|
- <https://github.com/tlambert03/fonticon-fontawesome5>
|
||||||
|
- <https://github.com/tlambert03/fonticon-materialdesignicons6>
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
::: superqt.fonticon.icon
|
||||||
|
options:
|
||||||
|
heading_level: 3
|
||||||
|
|
||||||
|
::: superqt.fonticon.setTextIcon
|
||||||
|
options:
|
||||||
|
heading_level: 3
|
||||||
|
|
||||||
|
::: superqt.fonticon.font
|
||||||
|
options:
|
||||||
|
heading_level: 3
|
||||||
|
|
||||||
|
::: superqt.fonticon.IconOpts
|
||||||
|
options:
|
||||||
|
heading_level: 3
|
||||||
|
|
||||||
|
::: superqt.fonticon.addFont
|
||||||
|
options:
|
||||||
|
heading_level: 3
|
||||||
|
|
||||||
|
## Animations
|
||||||
|
|
||||||
|
the `animation` parameter to `icon()` accepts a subclass of
|
||||||
|
`Animation` that will be
|
||||||
|
|
||||||
|
::: superqt.fonticon.Animation
|
||||||
|
options:
|
||||||
|
heading_level: 3
|
||||||
|
|
||||||
|
::: superqt.fonticon.pulse
|
||||||
|
options:
|
||||||
|
heading_level: 3
|
||||||
|
|
||||||
|
::: superqt.fonticon.spin
|
||||||
|
options:
|
||||||
|
heading_level: 3
|
31
docs/utilities/index.md
Normal file
31
docs/utilities/index.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Utilities
|
||||||
|
|
||||||
|
## Font Icons
|
||||||
|
|
||||||
|
| Object | Description |
|
||||||
|
| ----------- | --------------------- |
|
||||||
|
| [`addFont`](./fonticon.md#superqt.fonticon.addFont) | Add an `OTF/TTF` file at to the font registry. |
|
||||||
|
| [`font`](./fonticon.md#superqt.fonticon.font) | Create `QFont` for a given font-icon font family key |
|
||||||
|
| [`icon`](./fonticon.md#superqt.fonticon.icon) | Create a `QIcon` for font-con glyph key |
|
||||||
|
| [`setTextIcon`](./fonticon.md#superqt.fonticon.setTextIcon) | Set text on a `QWidget` to a specific font & glyph. |
|
||||||
|
| [`IconFont`](./fonticon.md#superqt.fonticon.IconFont) | Helper class that provides a standard way to create an `IconFont`. |
|
||||||
|
| [`IconOpts`](./fonticon.md#superqt.fonticon.IconOpts) | Options for rendering an icon |
|
||||||
|
| [`Animation`](./fonticon.md#superqt.fonticon.Animation) | Base class for adding animations to a font-icon. |
|
||||||
|
|
||||||
|
## Threading tools
|
||||||
|
|
||||||
|
| Object | Description |
|
||||||
|
| ----------- | --------------------- |
|
||||||
|
| [`ensure_main_thread`](./thread_decorators.md#ensure_main_thread) | Decorator that ensures a function is called in the main `QApplication` thread. |
|
||||||
|
| [`ensure_object_thread`](./thread_decorators.md#ensure_object_thread) | Decorator that ensures a `QObject` method is called in the object's thread. |
|
||||||
|
| [`FunctionWorker`](./threading.md#superqt.utils.FunctionWorker) | `QRunnable` with signals that wraps a simple long-running function. |
|
||||||
|
| [`GeneratorWorker`](./threading.md#superqt.utils.GeneratorWorker) | `QRunnable` with signals that wraps a long-running generator. |
|
||||||
|
| [`create_worker`](./threading.md#superqt.utils.create_worker) | Create a worker to run a target function in another thread. |
|
||||||
|
| [`thread_worker`](./threading.md#superqt.utils.thread_worker) | Decorator for `create_worker`, turn a function into a worker. |
|
||||||
|
|
||||||
|
## Miscellaneous
|
||||||
|
|
||||||
|
| Object | Description |
|
||||||
|
| ----------- | --------------------- |
|
||||||
|
| [`QMessageHandler`](./qmessagehandler.md) | A context manager to intercept messages from Qt. |
|
||||||
|
| [`CodeSyntaxHighlight`](./code_syntax_highlight.md) | A `QSyntaxHighlighter` for code syntax highlighting. |
|
8
docs/utilities/qmessagehandler.md
Normal file
8
docs/utilities/qmessagehandler.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# QMessageHandler
|
||||||
|
|
||||||
|
::: superqt.utils.QMessageHandler
|
||||||
|
options:
|
||||||
|
heading_level: 3
|
||||||
|
show_signature_annotations: True
|
||||||
|
docstring_style: numpy
|
||||||
|
show_bases: False
|
3
docs/utilities/signal_utils.md
Normal file
3
docs/utilities/signal_utils.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Signal Utilities
|
||||||
|
|
||||||
|
::: superqt.utils.signals_blocked
|
94
docs/utilities/thread_decorators.md
Normal file
94
docs/utilities/thread_decorators.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Threading decorators
|
||||||
|
|
||||||
|
`superqt` provides two decorators that help to ensure that given function is
|
||||||
|
running in the desired thread:
|
||||||
|
|
||||||
|
## `ensure_main_thread`
|
||||||
|
|
||||||
|
`ensure_main_thread` ensures that the decorated function/method runs in the main thread
|
||||||
|
|
||||||
|
## `ensure_object_thread`
|
||||||
|
|
||||||
|
`ensure_object_thread` ensures that a decorated bound method of a `QObject` runs
|
||||||
|
in the thread in which the instance lives ([see qt documentation for
|
||||||
|
details](https://doc.qt.io/qt-5/threads-qobject.html#accessing-qobject-subclasses-from-other-threads)).
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
By default, functions are executed asynchronously (they return immediately with
|
||||||
|
an instance of
|
||||||
|
[`concurrent.futures.Future`](https://docs.python.org/3/library/concurrent.futures.html#concurrent.futures.Future)).
|
||||||
|
|
||||||
|
To block and wait for the result, see [Synchronous mode](#synchronous-mode)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from qtpy.QtCore import QObject
|
||||||
|
from superqt import ensure_main_thread, ensure_object_thread
|
||||||
|
|
||||||
|
@ensure_main_thread
|
||||||
|
def sample_function():
|
||||||
|
print("This function will run in main thread")
|
||||||
|
|
||||||
|
|
||||||
|
class SampleObject(QObject):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._value = 1
|
||||||
|
|
||||||
|
@ensure_main_thread
|
||||||
|
def sample_method1(self):
|
||||||
|
print("This method will run in main thread")
|
||||||
|
|
||||||
|
@ensure_object_thread
|
||||||
|
def sample_method3(self):
|
||||||
|
import time
|
||||||
|
print("sleeping")
|
||||||
|
time.sleep(1)
|
||||||
|
print("This method will run in object thread")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def value(self):
|
||||||
|
print("return value")
|
||||||
|
return self._value
|
||||||
|
|
||||||
|
@value.setter
|
||||||
|
@ensure_object_thread
|
||||||
|
def value(self, value):
|
||||||
|
print("this setter will run in object thread")
|
||||||
|
self._value = value
|
||||||
|
```
|
||||||
|
|
||||||
|
As can be seen in this example these decorators can also be used for setters.
|
||||||
|
|
||||||
|
These decorators should not be used as replacement of Qt Signals but rather to
|
||||||
|
interact with Qt objects from non Qt code.
|
||||||
|
|
||||||
|
## Synchronous mode
|
||||||
|
|
||||||
|
If you'd like for the program to block and wait for the result of your function
|
||||||
|
call, use the `await_return=True` parameter, and optionally specify a timeout.
|
||||||
|
|
||||||
|
!!! important
|
||||||
|
|
||||||
|
Using synchronous mode may significantly impact performance.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from superqt import ensure_main_thread
|
||||||
|
|
||||||
|
@ensure_main_thread
|
||||||
|
def sample_function1():
|
||||||
|
return 1
|
||||||
|
|
||||||
|
@ensure_main_thread(await_return=True)
|
||||||
|
def sample_function2():
|
||||||
|
return 2
|
||||||
|
|
||||||
|
assert sample_function1() is None
|
||||||
|
assert sample_function2() == 2
|
||||||
|
|
||||||
|
# optionally, specify a timeout
|
||||||
|
@ensure_main_thread(await_return=True, timeout=10000)
|
||||||
|
def sample_function():
|
||||||
|
return 1
|
||||||
|
|
||||||
|
```
|
36
docs/utilities/threading.md
Normal file
36
docs/utilities/threading.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Thread workers
|
||||||
|
|
||||||
|
The objects in this module provide utilities for running tasks in a separate
|
||||||
|
thread. In general (with the exception of `new_worker_qthread`), everything
|
||||||
|
here wraps Qt's [QRunnable API](https://doc.qt.io/qt-6/qrunnable.html).
|
||||||
|
|
||||||
|
The highest level object is the
|
||||||
|
[`@thread_worker`][superqt.utils.thread_worker] decorator. It was originally
|
||||||
|
written for `napari`, and was later extracted into `superqt`. You may also be
|
||||||
|
interested in reading the [napari
|
||||||
|
documentation](https://napari.org/stable/guides/threading.html#threading-in-napari-with-thread-worker) on this feature,
|
||||||
|
which provides a more in-depth/introductory usage guide.
|
||||||
|
|
||||||
|
For additional control, you can create your own
|
||||||
|
[`FunctionWorker`][superqt.utils.FunctionWorker] or
|
||||||
|
[`GeneratorWorker`][superqt.utils.GeneratorWorker] objects.
|
||||||
|
|
||||||
|
::: superqt.utils.WorkerBase
|
||||||
|
|
||||||
|
::: superqt.utils.FunctionWorker
|
||||||
|
|
||||||
|
::: superqt.utils.GeneratorWorker
|
||||||
|
|
||||||
|
## Convenience functions
|
||||||
|
|
||||||
|
::: superqt.utils.thread_worker
|
||||||
|
options:
|
||||||
|
heading_level: 3
|
||||||
|
|
||||||
|
::: superqt.utils.create_worker
|
||||||
|
options:
|
||||||
|
heading_level: 3
|
||||||
|
|
||||||
|
::: superqt.utils.new_worker_qthread
|
||||||
|
options:
|
||||||
|
heading_level: 3
|
46
docs/utilities/throttling.md
Normal file
46
docs/utilities/throttling.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Throttling & Debouncing
|
||||||
|
|
||||||
|
These utilities allow you to throttle or debounce a function. This is useful
|
||||||
|
when you have a function that is called multiple times in a short period of
|
||||||
|
time, and you want to make sure it is only "actually" called once (or at least
|
||||||
|
no more than a certain frequency).
|
||||||
|
|
||||||
|
For background on throttling and debouncing, see:
|
||||||
|
|
||||||
|
- <https://blog.openreplay.com/forever-functional-debouncing-and-throttling-for-performance>
|
||||||
|
- <https://css-tricks.com/debouncing-throttling-explained-examples/>
|
||||||
|
|
||||||
|
::: superqt.utils.qdebounced
|
||||||
|
options:
|
||||||
|
show_source: false
|
||||||
|
docstring_style: numpy
|
||||||
|
show_root_toc_entry: True
|
||||||
|
show_root_heading: True
|
||||||
|
|
||||||
|
::: superqt.utils.qthrottled
|
||||||
|
options:
|
||||||
|
show_source: false
|
||||||
|
docstring_style: numpy
|
||||||
|
show_root_toc_entry: True
|
||||||
|
show_root_heading: True
|
||||||
|
|
||||||
|
::: superqt.utils.QSignalDebouncer
|
||||||
|
options:
|
||||||
|
show_source: false
|
||||||
|
docstring_style: numpy
|
||||||
|
show_root_toc_entry: True
|
||||||
|
show_root_heading: True
|
||||||
|
|
||||||
|
::: superqt.utils.QSignalThrottler
|
||||||
|
options:
|
||||||
|
show_source: false
|
||||||
|
docstring_style: numpy
|
||||||
|
show_root_toc_entry: True
|
||||||
|
show_root_heading: True
|
||||||
|
|
||||||
|
::: superqt.utils._throttler.GenericSignalThrottler
|
||||||
|
options:
|
||||||
|
show_source: false
|
||||||
|
docstring_style: numpy
|
||||||
|
show_root_toc_entry: True
|
||||||
|
show_root_heading: True
|
32
docs/widgets/index.md
Normal file
32
docs/widgets/index.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Widgets
|
||||||
|
|
||||||
|
The following are QWidget subclasses:
|
||||||
|
|
||||||
|
## Sliders and Numerical Inputs
|
||||||
|
|
||||||
|
| Widget | Description |
|
||||||
|
| ----------- | --------------------- |
|
||||||
|
| [`QDoubleRangeSlider`](./qdoublerangeslider.md) | Multi-handle slider for float values |
|
||||||
|
| [`QDoubleSlider`](./qdoubleslider.md) | Slider for float values |
|
||||||
|
| [`QLabeledDoubleRangeSlider`](./qlabeleddoublerangeslider.md) | `QDoubleRangeSlider` variant with editable labels for each handle |
|
||||||
|
| [`QLabeledDoubleSlider`](./qlabeleddoubleslider.md) | `QSlider` for float values with editable `QSpinBox` with the current value |
|
||||||
|
| [`QLabeledRangeSlider`](./qlabeledrangeslider.md) | `QRangeSlider` variant, with editable labels for each handle |
|
||||||
|
| [`QLabeledSlider`](./qlabeledslider.md) | `QSlider` with editable `QSpinBox` that shows the current value |
|
||||||
|
| [`QLargeIntSpinBox`](./qlargeintspinbox.md) | `QSpinbox` that accepts arbitrarily large integers |
|
||||||
|
| [`QRangeSlider`](./qrangeslider.md) | Multi-handle slider |
|
||||||
|
| [`QQuantity`](./qquantity.md) | Pint-backed quantity widget (magnitude combined with unit dropdown) |
|
||||||
|
|
||||||
|
## Labels and categorical inputs
|
||||||
|
|
||||||
|
| Widget | Description |
|
||||||
|
| ----------- | --------------------- |
|
||||||
|
| [`QElidingLabel`](./qelidinglabel.md) | A `QLabel` variant that will elide text (add `…`) to fit width. |
|
||||||
|
| [`QEnumComboBox`](./qenumcombobox.md) | `QComboBox` that populates the combobox from a python `Enum` |
|
||||||
|
| [`QSearchableComboBox`](./qsearchablecombobox.md) | `QComboBox` variant that filters available options based on text input |
|
||||||
|
| [`QSearchableListWidget`](./qsearchablelistwidget.md) | `QListWidget` variant with search field that filters available options |
|
||||||
|
|
||||||
|
## Frames and containers
|
||||||
|
|
||||||
|
| Widget | Description |
|
||||||
|
| ----------- | --------------------- |
|
||||||
|
| [`QCollapsible`](./qcollapsible.md) | A collapsible widget to hide and unhide child widgets. |
|
24
docs/widgets/qcollapsible.md
Normal file
24
docs/widgets/qcollapsible.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# QCollapsible
|
||||||
|
|
||||||
|
Collapsible `QFrame` that can be expanded or collapsed by clicking on the header.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from qtpy.QtWidgets import QApplication, QLabel, QPushButton
|
||||||
|
|
||||||
|
from superqt import QCollapsible
|
||||||
|
|
||||||
|
app = QApplication([])
|
||||||
|
|
||||||
|
collapsible = QCollapsible("Advanced analysis")
|
||||||
|
collapsible.addWidget(QLabel("This is the inside of the collapsible frame"))
|
||||||
|
for i in range(10):
|
||||||
|
collapsible.addWidget(QPushButton(f"Content button {i + 1}"))
|
||||||
|
|
||||||
|
collapsible.expand(animate=False)
|
||||||
|
collapsible.show()
|
||||||
|
app.exec_()
|
||||||
|
```
|
||||||
|
|
||||||
|
{{ show_widget(350) }}
|
||||||
|
|
||||||
|
{{ show_members('superqt.QCollapsible') }}
|
23
docs/widgets/qdoublerangeslider.md
Normal file
23
docs/widgets/qdoublerangeslider.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# QDoubleRangeSlider
|
||||||
|
|
||||||
|
Float variant of [`QRangeSlider`](qrangeslider.md). (see that page for more details).
|
||||||
|
|
||||||
|
```python
|
||||||
|
from qtpy.QtCore import Qt
|
||||||
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from superqt import QDoubleRangeSlider
|
||||||
|
|
||||||
|
app = QApplication([])
|
||||||
|
|
||||||
|
slider = QDoubleRangeSlider(Qt.Orientation.Horizontal)
|
||||||
|
slider.setRange(0, 1)
|
||||||
|
slider.setValue((0.2, 0.8))
|
||||||
|
slider.show()
|
||||||
|
|
||||||
|
app.exec_()
|
||||||
|
```
|
||||||
|
|
||||||
|
{{ show_widget() }}
|
||||||
|
|
||||||
|
{{ show_members('superqt.QDoubleRangeSlider') }}
|
23
docs/widgets/qdoubleslider.md
Normal file
23
docs/widgets/qdoubleslider.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# QDoubleSlider
|
||||||
|
|
||||||
|
`QSlider` variant that accepts floating point values.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from qtpy.QtCore import Qt
|
||||||
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from superqt import QDoubleSlider
|
||||||
|
|
||||||
|
app = QApplication([])
|
||||||
|
|
||||||
|
slider = QDoubleSlider(Qt.Orientation.Horizontal)
|
||||||
|
slider.setRange(0, 1)
|
||||||
|
slider.setValue(0.5)
|
||||||
|
slider.show()
|
||||||
|
|
||||||
|
app.exec_()
|
||||||
|
```
|
||||||
|
|
||||||
|
{{ show_widget() }}
|
||||||
|
|
||||||
|
{{ show_members('superqt.QDoubleSlider') }}
|
26
docs/widgets/qelidinglabel.md
Normal file
26
docs/widgets/qelidinglabel.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# QElidingLabel
|
||||||
|
|
||||||
|
`QLabel` variant that will elide text (i.e. add an ellipsis)
|
||||||
|
if it is too long to fit in the available space.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from superqt import QElidingLabel
|
||||||
|
|
||||||
|
app = QApplication([])
|
||||||
|
|
||||||
|
widget = QElidingLabel(
|
||||||
|
"a skj skjfskfj sdlf sdfl sdlfk jsdf sdlkf jdsf dslfksdl sdlfk sdf sdl "
|
||||||
|
"fjsdlf kjsdlfk laskdfsal as lsdfjdsl kfjdslf asfd dslkjfldskf sdlkfj"
|
||||||
|
)
|
||||||
|
widget.setWordWrap(True)
|
||||||
|
widget.resize(300, 20)
|
||||||
|
widget.show()
|
||||||
|
|
||||||
|
app.exec_()
|
||||||
|
```
|
||||||
|
|
||||||
|
{{ show_widget(300) }}
|
||||||
|
|
||||||
|
{{ show_members('superqt.QElidingLabel') }}
|
72
docs/widgets/qenumcombobox.md
Normal file
72
docs/widgets/qenumcombobox.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# QEnumComboBox
|
||||||
|
|
||||||
|
`QEnumComboBox` is a variant of
|
||||||
|
[`QComboBox`](https://doc.qt.io/qt-5/qcombobox.html) that populates the items in
|
||||||
|
the combobox based on a python `Enum` class. In addition to all the methods
|
||||||
|
provided by `QComboBox`, this subclass adds the methods
|
||||||
|
`enumClass`/`setEnumClass` to get/set the current `Enum` class represented by
|
||||||
|
the combobox, and `currentEnum`/`setCurrentEnum` to get/set the current `Enum`
|
||||||
|
member in the combobox. There is also a new signal `currentEnumChanged(enum)`
|
||||||
|
analogous to `currentIndexChanged` and `currentTextChanged`.
|
||||||
|
|
||||||
|
Method like `insertItem` and `addItem` are blocked and try of its usage will end
|
||||||
|
with `RuntimeError`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from qtpy.QtWidgets import QApplication
|
||||||
|
from superqt import QEnumComboBox
|
||||||
|
|
||||||
|
|
||||||
|
class SampleEnum(Enum):
|
||||||
|
first = 1
|
||||||
|
second = 2
|
||||||
|
third = 3
|
||||||
|
|
||||||
|
app = QApplication([])
|
||||||
|
|
||||||
|
combo = QEnumComboBox()
|
||||||
|
combo.setEnumClass(SampleEnum)
|
||||||
|
combo.show()
|
||||||
|
|
||||||
|
app.exec_()
|
||||||
|
```
|
||||||
|
|
||||||
|
{{ show_widget() }}
|
||||||
|
|
||||||
|
Another option is to use optional `enum_class` argument of constructor and change
|
||||||
|
|
||||||
|
```python
|
||||||
|
# option A:
|
||||||
|
combo = QEnumComboBox()
|
||||||
|
combo.setEnumClass(SampleEnum)
|
||||||
|
# option B:
|
||||||
|
combo = QEnumComboBox(enum_class=SampleEnum)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Allow `None`
|
||||||
|
|
||||||
|
`QEnumComboBox` also allows using `Optional` type annotation:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from superqt import QEnumComboBox
|
||||||
|
|
||||||
|
class SampleEnum(Enum):
|
||||||
|
first = 1
|
||||||
|
second = 2
|
||||||
|
third = 3
|
||||||
|
|
||||||
|
# as usual:
|
||||||
|
# you must create a QApplication before create a widget.
|
||||||
|
|
||||||
|
combo = QEnumComboBox()
|
||||||
|
combo.setEnumClass(SampleEnum, allow_none=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
In this case there is added option `----` and the `currentEnum()` method will
|
||||||
|
return `None` when it is selected.
|
||||||
|
|
||||||
|
{{ show_members('superqt.QEnumComboBox') }}
|
23
docs/widgets/qlabeleddoublerangeslider.md
Normal file
23
docs/widgets/qlabeleddoublerangeslider.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# QLabeledDoubleRangeSlider
|
||||||
|
|
||||||
|
Labeled Float variant of [`QRangeSlider`](qrangeslider.md). (see that page for more details).
|
||||||
|
|
||||||
|
```python
|
||||||
|
from qtpy.QtCore import Qt
|
||||||
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from superqt import QLabeledDoubleRangeSlider
|
||||||
|
|
||||||
|
app = QApplication([])
|
||||||
|
|
||||||
|
slider = QLabeledDoubleRangeSlider(Qt.Orientation.Horizontal)
|
||||||
|
slider.setRange(0, 1)
|
||||||
|
slider.setValue((0.2, 0.8))
|
||||||
|
slider.show()
|
||||||
|
|
||||||
|
app.exec_()
|
||||||
|
```
|
||||||
|
|
||||||
|
{{ show_widget() }}
|
||||||
|
|
||||||
|
{{ show_members('superqt.QLabeledDoubleRangeSlider') }}
|
24
docs/widgets/qlabeleddoubleslider.md
Normal file
24
docs/widgets/qlabeleddoubleslider.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# QLabeledDoubleSlider
|
||||||
|
|
||||||
|
[`QDoubleSlider`](./qdoubleslider.md) variant that shows an editable (SpinBox) label next to the slider.
|
||||||
|
|
||||||
|
|
||||||
|
```python
|
||||||
|
from qtpy.QtCore import Qt
|
||||||
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from superqt import QLabeledDoubleSlider
|
||||||
|
|
||||||
|
app = QApplication([])
|
||||||
|
|
||||||
|
slider = QLabeledDoubleSlider(Qt.Orientation.Horizontal)
|
||||||
|
slider.setRange(0, 2.5)
|
||||||
|
slider.setValue(1.3)
|
||||||
|
slider.show()
|
||||||
|
|
||||||
|
app.exec_()
|
||||||
|
```
|
||||||
|
|
||||||
|
{{ show_widget() }}
|
||||||
|
|
||||||
|
{{ show_members('superqt.QLabeledDoubleSlider') }}
|
29
docs/widgets/qlabeledrangeslider.md
Normal file
29
docs/widgets/qlabeledrangeslider.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# QLabeledRangeSlider
|
||||||
|
|
||||||
|
Labeled variant of [`QRangeSlider`](qrangeslider.md). (see that page for more details).
|
||||||
|
|
||||||
|
```python
|
||||||
|
from qtpy.QtCore import Qt
|
||||||
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from superqt import QLabeledRangeSlider
|
||||||
|
|
||||||
|
app = QApplication([])
|
||||||
|
|
||||||
|
slider = QLabeledRangeSlider(Qt.Orientation.Horizontal)
|
||||||
|
slider.setValue((20, 80))
|
||||||
|
slider.show()
|
||||||
|
|
||||||
|
app.exec_()
|
||||||
|
```
|
||||||
|
|
||||||
|
{{ show_widget() }}
|
||||||
|
|
||||||
|
{{ show_members('superqt.QLabeledRangeSlider') }}
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
If you find that you need to fine tune the position of the handle labels:
|
||||||
|
|
||||||
|
- `QLabeledRangeSlider.label_shift_x`: adjust horizontal label position
|
||||||
|
- `QLabeledRangeSlider.label_shift_y`: adjust vertical label position
|
22
docs/widgets/qlabeledslider.md
Normal file
22
docs/widgets/qlabeledslider.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# QLabeledSlider
|
||||||
|
|
||||||
|
`QSlider` variant that shows an editable (SpinBox) label next to the slider.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from qtpy.QtCore import Qt
|
||||||
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from superqt import QLabeledSlider
|
||||||
|
|
||||||
|
app = QApplication([])
|
||||||
|
|
||||||
|
slider = QLabeledSlider(Qt.Orientation.Horizontal)
|
||||||
|
slider.setValue(42)
|
||||||
|
slider.show()
|
||||||
|
|
||||||
|
app.exec_()
|
||||||
|
```
|
||||||
|
|
||||||
|
{{ show_widget() }}
|
||||||
|
|
||||||
|
{{ show_members('superqt.QLabeledSlider') }}
|
23
docs/widgets/qlargeintspinbox.md
Normal file
23
docs/widgets/qlargeintspinbox.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# QLargeIntSpinBox
|
||||||
|
|
||||||
|
`QSpinBox` variant that allows to enter large integers, without overflow.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from qtpy.QtCore import Qt
|
||||||
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from superqt import QLargeIntSpinBox
|
||||||
|
|
||||||
|
app = QApplication([])
|
||||||
|
|
||||||
|
slider = QLargeIntSpinBox()
|
||||||
|
slider.setRange(0, 4.53e8)
|
||||||
|
slider.setValue(4.53e8)
|
||||||
|
slider.show()
|
||||||
|
|
||||||
|
app.exec_()
|
||||||
|
```
|
||||||
|
|
||||||
|
{{ show_widget(150) }}
|
||||||
|
|
||||||
|
{{ show_members('superqt.QLargeIntSpinBox') }}
|
33
docs/widgets/qquantity.md
Normal file
33
docs/widgets/qquantity.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# QQuantity
|
||||||
|
|
||||||
|
A widget that allows the user to edit a quantity (a magnitude associated with a unit).
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
|
||||||
|
This widget requires [`pint`](https://pint.readthedocs.io):
|
||||||
|
|
||||||
|
```
|
||||||
|
pip install pint
|
||||||
|
```
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
```
|
||||||
|
pip install superqt[quantity]
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from superqt import QQuantity
|
||||||
|
|
||||||
|
app = QApplication([])
|
||||||
|
w = QQuantity("1m")
|
||||||
|
w.show()
|
||||||
|
|
||||||
|
app.exec()
|
||||||
|
```
|
||||||
|
|
||||||
|
{{ show_widget(150) }}
|
||||||
|
|
||||||
|
{{ show_members('superqt.QQuantity') }}
|
229
docs/widgets/qrangeslider.md
Normal file
229
docs/widgets/qrangeslider.md
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
# QRangeSlider
|
||||||
|
|
||||||
|
A multi-handle slider widget than can be used to
|
||||||
|
select a range of values.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from qtpy.QtCore import Qt
|
||||||
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from superqt import QRangeSlider
|
||||||
|
|
||||||
|
app = QApplication([])
|
||||||
|
|
||||||
|
slider = QRangeSlider(Qt.Orientation.Horizontal)
|
||||||
|
slider.setValue((20, 80))
|
||||||
|
slider.show()
|
||||||
|
|
||||||
|
app.exec_()
|
||||||
|
```
|
||||||
|
|
||||||
|
{{ show_widget() }}
|
||||||
|
|
||||||
|
- `QRangeSlider` inherits from [`QSlider`](https://doc.qt.io/qt-5/qslider.html)
|
||||||
|
and attempts to match the Qt API as closely as possible
|
||||||
|
- It uses platform-specific styles (for handle, groove, & ticks) but also supports
|
||||||
|
QSS style sheets.
|
||||||
|
- Supports mouse wheel events
|
||||||
|
- Supports more than 2 handles (e.g. `slider.setValue([0, 10, 60, 80])`)
|
||||||
|
|
||||||
|
As `QRangeSlider` inherits from
|
||||||
|
[`QtWidgets.QSlider`](https://doc.qt.io/qt-5/qslider.html), you can use all of
|
||||||
|
the same methods available in the [QSlider
|
||||||
|
API](https://doc.qt.io/qt-5/qslider.html). The major difference is that `value()`
|
||||||
|
and `sliderPosition()` are reimplemented as `tuples` of `int` (where the length of
|
||||||
|
the tuple is equal to the number of handles in the slider.)
|
||||||
|
|
||||||
|
These options are in addition to the Qt QSlider API, and control the behavior of the bar between handles.
|
||||||
|
|
||||||
|
| getter | setter | type | default | description |
|
||||||
|
| -------------------- | ------------------------------------------- | ------ | ------- | ------------------------------------------------------------------------------------------------ |
|
||||||
|
| `barIsVisible` | `setBarIsVisible` <br>`hideBar` / `showBar` | `bool` | `True` | <small>Whether the bar between handles is visible.</small> |
|
||||||
|
| `barMovesAllHandles` | `setBarMovesAllHandles` | `bool` | `True` | <small>Whether clicking on the bar moves all handles or just the nearest</small> |
|
||||||
|
| `barIsRigid` | `setBarIsRigid` | `bool` | `True` | <small>Whether bar length is constant or "elastic" when dragging the bar beyond min/max.</small> |
|
||||||
|
|
||||||
|
### Screenshots
|
||||||
|
|
||||||
|
??? title "code that generates the images below"
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
|
||||||
|
from qtpy import QtCore
|
||||||
|
from qtpy import QtWidgets as QtW
|
||||||
|
|
||||||
|
# patch for Qt 5.15 on macos >= 12
|
||||||
|
os.environ["USE_MAC_SLIDER_PATCH"] = "1"
|
||||||
|
|
||||||
|
from superqt import QRangeSlider # noqa
|
||||||
|
|
||||||
|
QSS = """
|
||||||
|
QSlider {
|
||||||
|
min-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QSlider::groove:horizontal {
|
||||||
|
border: 0px;
|
||||||
|
background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #888, stop:1 #ddd);
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QSlider::handle {
|
||||||
|
background: qradialgradient(cx:0, cy:0, radius: 1.2, fx:0.35,
|
||||||
|
fy:0.3, stop:0 #eef, stop:1 #002);
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QSlider::sub-page:horizontal {
|
||||||
|
background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #227, stop:1 #77a);
|
||||||
|
border-top-left-radius: 10px;
|
||||||
|
border-bottom-left-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QRangeSlider {
|
||||||
|
qproperty-barColor: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #227, stop:1 #77a);
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
Horizontal = QtCore.Qt.Orientation.Horizontal
|
||||||
|
|
||||||
|
|
||||||
|
class DemoWidget(QtW.QWidget):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
reg_hslider = QtW.QSlider(Horizontal)
|
||||||
|
reg_hslider.setValue(50)
|
||||||
|
range_hslider = QRangeSlider(Horizontal)
|
||||||
|
range_hslider.setValue((20, 80))
|
||||||
|
multi_range_hslider = QRangeSlider(Horizontal)
|
||||||
|
multi_range_hslider.setValue((11, 33, 66, 88))
|
||||||
|
multi_range_hslider.setTickPosition(QtW.QSlider.TickPosition.TicksAbove)
|
||||||
|
|
||||||
|
styled_reg_hslider = QtW.QSlider(Horizontal)
|
||||||
|
styled_reg_hslider.setValue(50)
|
||||||
|
styled_reg_hslider.setStyleSheet(QSS)
|
||||||
|
styled_range_hslider = QRangeSlider(Horizontal)
|
||||||
|
styled_range_hslider.setValue((20, 80))
|
||||||
|
styled_range_hslider.setStyleSheet(QSS)
|
||||||
|
|
||||||
|
reg_vslider = QtW.QSlider(QtCore.Qt.Orientation.Vertical)
|
||||||
|
reg_vslider.setValue(50)
|
||||||
|
range_vslider = QRangeSlider(QtCore.Qt.Orientation.Vertical)
|
||||||
|
range_vslider.setValue((22, 77))
|
||||||
|
|
||||||
|
tick_vslider = QtW.QSlider(QtCore.Qt.Orientation.Vertical)
|
||||||
|
tick_vslider.setValue(55)
|
||||||
|
tick_vslider.setTickPosition(QtW.QSlider.TicksRight)
|
||||||
|
range_tick_vslider = QRangeSlider(QtCore.Qt.Orientation.Vertical)
|
||||||
|
range_tick_vslider.setValue((22, 77))
|
||||||
|
range_tick_vslider.setTickPosition(QtW.QSlider.TicksLeft)
|
||||||
|
|
||||||
|
szp = QtW.QSizePolicy.Maximum
|
||||||
|
left = QtW.QWidget()
|
||||||
|
left.setLayout(QtW.QVBoxLayout())
|
||||||
|
left.setContentsMargins(2, 2, 2, 2)
|
||||||
|
label1 = QtW.QLabel("Regular QSlider Unstyled")
|
||||||
|
label2 = QtW.QLabel("QRangeSliders Unstyled")
|
||||||
|
label3 = QtW.QLabel("Styled Sliders (using same stylesheet)")
|
||||||
|
label1.setSizePolicy(szp, szp)
|
||||||
|
label2.setSizePolicy(szp, szp)
|
||||||
|
label3.setSizePolicy(szp, szp)
|
||||||
|
left.layout().addWidget(label1)
|
||||||
|
left.layout().addWidget(reg_hslider)
|
||||||
|
left.layout().addWidget(label2)
|
||||||
|
left.layout().addWidget(range_hslider)
|
||||||
|
left.layout().addWidget(multi_range_hslider)
|
||||||
|
left.layout().addWidget(label3)
|
||||||
|
left.layout().addWidget(styled_reg_hslider)
|
||||||
|
left.layout().addWidget(styled_range_hslider)
|
||||||
|
|
||||||
|
right = QtW.QWidget()
|
||||||
|
right.setLayout(QtW.QHBoxLayout())
|
||||||
|
right.setContentsMargins(15, 5, 5, 0)
|
||||||
|
right.layout().setSpacing(30)
|
||||||
|
right.layout().addWidget(reg_vslider)
|
||||||
|
right.layout().addWidget(range_vslider)
|
||||||
|
right.layout().addWidget(tick_vslider)
|
||||||
|
right.layout().addWidget(range_tick_vslider)
|
||||||
|
|
||||||
|
self.setLayout(QtW.QHBoxLayout())
|
||||||
|
self.layout().addWidget(left)
|
||||||
|
self.layout().addWidget(right)
|
||||||
|
self.setGeometry(600, 300, 580, 300)
|
||||||
|
self.activateWindow()
|
||||||
|
self.show()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
dest = Path("screenshots")
|
||||||
|
dest.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
app = QtW.QApplication([])
|
||||||
|
demo = DemoWidget()
|
||||||
|
|
||||||
|
if "-snap" in sys.argv:
|
||||||
|
import platform
|
||||||
|
|
||||||
|
QtW.QApplication.processEvents()
|
||||||
|
demo.grab().save(str(dest / f"demo_{platform.system().lower()}.png"))
|
||||||
|
else:
|
||||||
|
app.exec_()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### macOS
|
||||||
|
|
||||||
|
##### Catalina
|
||||||
|
|
||||||
|
{ width=580; }
|
||||||
|
|
||||||
|
##### Big Sur
|
||||||
|
|
||||||
|
{ width=580; }
|
||||||
|
|
||||||
|
#### Windows
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### Linux
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
{{ show_members('superqt.sliders._sliders._GenericRangeSlider') }}
|
||||||
|
|
||||||
|
## Type changes
|
||||||
|
|
||||||
|
Note the following changes in types compared to the `QSlider` API:
|
||||||
|
|
||||||
|
```python
|
||||||
|
value() -> Tuple[int, ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
setValue(val: Sequence[int]) -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Signal
|
||||||
|
valueChanged(Tuple[int, ...])
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
sliderPosition() -> Tuple[int, ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
setSliderPosition(val: Sequence[int]) -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
sliderMoved(Tuple[int, ...])
|
||||||
|
```
|
25
docs/widgets/qsearchablecombobox.md
Normal file
25
docs/widgets/qsearchablecombobox.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# QSearchableComboBox
|
||||||
|
|
||||||
|
`QSearchableComboBox` is a variant of
|
||||||
|
[`QComboBox`](https://doc.qt.io/qt-5/qcombobox.html) that allow to filter list
|
||||||
|
of options by enter part of text. It could be drop in replacement for
|
||||||
|
`QComboBox`.
|
||||||
|
|
||||||
|
|
||||||
|
```python
|
||||||
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from superqt import QSearchableComboBox
|
||||||
|
|
||||||
|
app = QApplication([])
|
||||||
|
|
||||||
|
combo = QSearchableComboBox()
|
||||||
|
combo.addItems(["foo", "bar", "baz", "foobar", "foobaz", "barbaz"])
|
||||||
|
combo.show()
|
||||||
|
|
||||||
|
app.exec_()
|
||||||
|
```
|
||||||
|
|
||||||
|
{{ show_widget() }}
|
||||||
|
|
||||||
|
{{ show_members('superqt.QSearchableComboBox') }}
|
28
docs/widgets/qsearchablelistwidget.md
Normal file
28
docs/widgets/qsearchablelistwidget.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# QSearchableListWidget
|
||||||
|
|
||||||
|
`QSearchableListWidget` is a variant of
|
||||||
|
[`QListWidget`](https://doc.qt.io/qt-5/qlistwidget.html) that add text entry
|
||||||
|
above list widget that allow to filter list of available options.
|
||||||
|
|
||||||
|
Due to implementation details, this widget it does not inherit directly from
|
||||||
|
[`QListWidget`](https://doc.qt.io/qt-5/qlistwidget.html) but it does fully
|
||||||
|
satisfy its api. The only limitation is that it cannot be used as argument of
|
||||||
|
[`QListWidgetItem`](https://doc.qt.io/qt-5/qlistwidgetitem.html) constructor.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from superqt import QSearchableListWidget
|
||||||
|
|
||||||
|
app = QApplication([])
|
||||||
|
|
||||||
|
slider = QSearchableListWidget()
|
||||||
|
slider.addItems(["foo", "bar", "baz", "foobar", "foobaz", "barbaz"])
|
||||||
|
slider.show()
|
||||||
|
|
||||||
|
app.exec_()
|
||||||
|
```
|
||||||
|
|
||||||
|
{{ show_widget() }}
|
||||||
|
|
||||||
|
{{ show_members('superqt.QSearchableListWidget') }}
|
@@ -1,11 +1,12 @@
|
|||||||
|
from qtpy.QtCore import Qt
|
||||||
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
from superqt import QRangeSlider
|
from superqt import QRangeSlider
|
||||||
from superqt.qtcompat.QtCore import Qt
|
|
||||||
from superqt.qtcompat.QtWidgets import QApplication
|
|
||||||
|
|
||||||
app = QApplication([])
|
app = QApplication([])
|
||||||
|
|
||||||
slider = QRangeSlider(Qt.Horizontal)
|
slider = QRangeSlider(Qt.Orientation.Horizontal)
|
||||||
slider = QRangeSlider(Qt.Horizontal)
|
slider = QRangeSlider(Qt.Orientation.Horizontal)
|
||||||
|
|
||||||
slider.setValue((20, 80))
|
slider.setValue((20, 80))
|
||||||
slider.show()
|
slider.show()
|
||||||
|
@@ -1,12 +0,0 @@
|
|||||||
from superqt import QDoubleSlider
|
|
||||||
from superqt.qtcompat.QtCore import Qt
|
|
||||||
from superqt.qtcompat.QtWidgets import QApplication
|
|
||||||
|
|
||||||
app = QApplication([])
|
|
||||||
|
|
||||||
slider = QDoubleSlider(Qt.Horizontal)
|
|
||||||
slider.setRange(0, 1)
|
|
||||||
slider.setValue(0.5)
|
|
||||||
slider.show()
|
|
||||||
|
|
||||||
app.exec_()
|
|
32
examples/code_highlight.py
Normal file
32
examples/code_highlight.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from PyQt5.QtGui import QColor, QPalette
|
||||||
|
from qtpy.QtWidgets import QApplication, QTextEdit
|
||||||
|
|
||||||
|
from superqt.utils import CodeSyntaxHighlight
|
||||||
|
|
||||||
|
app = QApplication([])
|
||||||
|
|
||||||
|
text_area = QTextEdit()
|
||||||
|
|
||||||
|
highlight = CodeSyntaxHighlight(text_area.document(), "python", "monokai")
|
||||||
|
|
||||||
|
palette = text_area.palette()
|
||||||
|
palette.setColor(QPalette.Base, QColor(highlight.background_color))
|
||||||
|
text_area.setPalette(palette)
|
||||||
|
text_area.setText(
|
||||||
|
"""from argparse import ArgumentParser
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = ArgumentParser()
|
||||||
|
parser.add_argument("name", help="Your name")
|
||||||
|
args = parser.parse_args()
|
||||||
|
print(f"Hello {args.name}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
text_area.show()
|
||||||
|
|
||||||
|
app.exec_()
|
@@ -1,6 +1,12 @@
|
|||||||
from superqt import QRangeSlider
|
import os
|
||||||
from superqt.qtcompat import QtCore
|
|
||||||
from superqt.qtcompat import QtWidgets as QtW
|
from qtpy import QtCore
|
||||||
|
from qtpy import QtWidgets as QtW
|
||||||
|
|
||||||
|
# patch for Qt 5.15 on macos >= 12
|
||||||
|
os.environ["USE_MAC_SLIDER_PATCH"] = "1"
|
||||||
|
|
||||||
|
from superqt import QRangeSlider # noqa
|
||||||
|
|
||||||
QSS = """
|
QSS = """
|
||||||
QSlider {
|
QSlider {
|
||||||
@@ -33,35 +39,37 @@ QRangeSlider {
|
|||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
@@ -102,7 +110,6 @@ class DemoWidget(QtW.QWidget):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
14
examples/double_slider.py
Normal file
14
examples/double_slider.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from qtpy.QtCore import Qt
|
||||||
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from superqt import QDoubleSlider
|
||||||
|
|
||||||
|
app = QApplication([])
|
||||||
|
|
||||||
|
slider = QDoubleSlider(Qt.Orientation.Horizontal)
|
||||||
|
slider.setRange(0, 1)
|
||||||
|
slider.setValue(0.5)
|
||||||
|
slider.resize(500, 50)
|
||||||
|
slider.show()
|
||||||
|
|
||||||
|
app.exec_()
|
13
examples/eliding_label.py
Normal file
13
examples/eliding_label.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from superqt import QElidingLabel
|
||||||
|
|
||||||
|
app = QApplication([])
|
||||||
|
|
||||||
|
widget = QElidingLabel(
|
||||||
|
"a skj skjfskfj sdlf sdfl sdlfk jsdf sdlkf jdsf dslfksdl sdlfk sdf sdl "
|
||||||
|
"fjsdlf kjsdlfk laskdfsal as lsdfjdsl kfjdslf asfd dslkjfldskf sdlkfj"
|
||||||
|
)
|
||||||
|
widget.setWordWrap(True)
|
||||||
|
widget.show()
|
||||||
|
app.exec_()
|
@@ -1,14 +1,15 @@
|
|||||||
|
from qtpy.QtCore import Qt
|
||||||
|
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||||
|
|
||||||
from superqt import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
|
from superqt import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
|
||||||
from superqt.qtcompat.QtCore import Qt
|
|
||||||
from superqt.qtcompat.QtWidgets import QApplication, QVBoxLayout, QWidget
|
|
||||||
|
|
||||||
app = QApplication([])
|
app = QApplication([])
|
||||||
|
|
||||||
w = QWidget()
|
w = QWidget()
|
||||||
|
|
||||||
sld1 = QDoubleSlider(Qt.Horizontal)
|
sld1 = QDoubleSlider(Qt.Orientation.Horizontal)
|
||||||
sld2 = QDoubleRangeSlider(Qt.Horizontal)
|
sld2 = QDoubleRangeSlider(Qt.Orientation.Horizontal)
|
||||||
rs = QRangeSlider(Qt.Horizontal)
|
rs = QRangeSlider(Qt.Orientation.Horizontal)
|
||||||
|
|
||||||
sld1.valueChanged.connect(lambda e: print("doubslider valuechanged", e))
|
sld1.valueChanged.connect(lambda e: print("doubslider valuechanged", e))
|
||||||
|
|
||||||
|
21
examples/fonticon1.py
Normal file
21
examples/fonticon1.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
try:
|
||||||
|
from fonticon_fa5 import FA5S
|
||||||
|
except ImportError as e:
|
||||||
|
raise type(e)(
|
||||||
|
"This example requires the fontawesome fontpack:\n\n"
|
||||||
|
"pip install git+https://github.com/tlambert03/fonticon-fontawesome5.git"
|
||||||
|
)
|
||||||
|
|
||||||
|
from qtpy.QtCore import QSize
|
||||||
|
from qtpy.QtWidgets import QApplication, QPushButton
|
||||||
|
|
||||||
|
from superqt.fonticon import icon, pulse
|
||||||
|
|
||||||
|
app = QApplication([])
|
||||||
|
|
||||||
|
btn2 = QPushButton()
|
||||||
|
btn2.setIcon(icon(FA5S.spinner, animation=pulse(btn2)))
|
||||||
|
btn2.setIconSize(QSize(225, 225))
|
||||||
|
btn2.show()
|
||||||
|
|
||||||
|
app.exec()
|
21
examples/fonticon2.py
Normal file
21
examples/fonticon2.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
try:
|
||||||
|
from fonticon_fa5 import FA5S
|
||||||
|
except ImportError as e:
|
||||||
|
raise type(e)(
|
||||||
|
"This example requires the fontawesome fontpack:\n\n"
|
||||||
|
"pip install git+https://github.com/tlambert03/fonticon-fontawesome5.git"
|
||||||
|
)
|
||||||
|
|
||||||
|
from qtpy.QtWidgets import QApplication, QPushButton
|
||||||
|
|
||||||
|
from superqt.fonticon import setTextIcon
|
||||||
|
|
||||||
|
app = QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
btn4 = QPushButton()
|
||||||
|
btn4.resize(275, 275)
|
||||||
|
setTextIcon(btn4, FA5S.hamburger)
|
||||||
|
btn4.show()
|
||||||
|
|
||||||
|
app.exec()
|
41
examples/fonticon3.py
Normal file
41
examples/fonticon3.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
try:
|
||||||
|
from fonticon_fa5 import FA5S
|
||||||
|
except ImportError as e:
|
||||||
|
raise type(e)(
|
||||||
|
"This example requires the fontawesome fontpack:\n\n"
|
||||||
|
"pip install git+https://github.com/tlambert03/fonticon-fontawesome5.git"
|
||||||
|
)
|
||||||
|
|
||||||
|
from qtpy.QtCore import QSize
|
||||||
|
from qtpy.QtWidgets import QApplication, QPushButton
|
||||||
|
|
||||||
|
from superqt.fonticon import IconOpts, icon, pulse, spin
|
||||||
|
|
||||||
|
app = QApplication([])
|
||||||
|
|
||||||
|
btn = QPushButton()
|
||||||
|
btn.setIcon(
|
||||||
|
icon(
|
||||||
|
FA5S.smile,
|
||||||
|
color="blue",
|
||||||
|
states={
|
||||||
|
"active": IconOpts(
|
||||||
|
glyph_key=FA5S.spinner,
|
||||||
|
color="red",
|
||||||
|
scale_factor=0.5,
|
||||||
|
animation=pulse(btn),
|
||||||
|
),
|
||||||
|
"disabled": {"color": "green", "scale_factor": 0.8, "animation": spin(btn)},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
btn.setIconSize(QSize(256, 256))
|
||||||
|
btn.show()
|
||||||
|
|
||||||
|
|
||||||
|
@btn.clicked.connect
|
||||||
|
def toggle_state():
|
||||||
|
btn.setChecked(not btn.isChecked())
|
||||||
|
|
||||||
|
|
||||||
|
app.exec()
|
@@ -1,10 +1,11 @@
|
|||||||
|
from qtpy.QtCore import Qt
|
||||||
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
from superqt import QDoubleSlider
|
from superqt import QDoubleSlider
|
||||||
from superqt.qtcompat.QtCore import Qt
|
|
||||||
from superqt.qtcompat.QtWidgets import QApplication
|
|
||||||
|
|
||||||
app = QApplication([])
|
app = QApplication([])
|
||||||
|
|
||||||
sld = QDoubleSlider(Qt.Horizontal)
|
sld = QDoubleSlider(Qt.Orientation.Horizontal)
|
||||||
sld.setRange(0, 1)
|
sld.setRange(0, 1)
|
||||||
sld.setValue(0.5)
|
sld.setValue(0.5)
|
||||||
sld.show()
|
sld.show()
|
||||||
|
377
examples/icon_explorer.py
Normal file
377
examples/icon_explorer.py
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
from qtpy import QtCore, QtGui, QtWidgets
|
||||||
|
from qtpy.QtCore import Qt
|
||||||
|
|
||||||
|
from superqt.fonticon._plugins import loaded
|
||||||
|
|
||||||
|
P = loaded(load_all=True)
|
||||||
|
if not P:
|
||||||
|
print("you have no font packs loaded!")
|
||||||
|
|
||||||
|
|
||||||
|
class GlyphDelegate(QtWidgets.QItemDelegate):
|
||||||
|
def createEditor(self, parent, option, index):
|
||||||
|
if index.column() < 2:
|
||||||
|
edit = QtWidgets.QLineEdit(parent)
|
||||||
|
edit.editingFinished.connect(self.emitCommitData)
|
||||||
|
return edit
|
||||||
|
comboBox = QtWidgets.QComboBox(parent)
|
||||||
|
if index.column() == 2:
|
||||||
|
comboBox.addItem("Normal")
|
||||||
|
comboBox.addItem("Active")
|
||||||
|
comboBox.addItem("Disabled")
|
||||||
|
comboBox.addItem("Selected")
|
||||||
|
elif index.column() == 3:
|
||||||
|
comboBox.addItem("Off")
|
||||||
|
comboBox.addItem("On")
|
||||||
|
|
||||||
|
comboBox.activated.connect(self.emitCommitData)
|
||||||
|
return comboBox
|
||||||
|
|
||||||
|
def setEditorData(self, editor, index):
|
||||||
|
if index.column() < 2:
|
||||||
|
editor.setText(index.model().data(index))
|
||||||
|
return
|
||||||
|
comboBox = editor
|
||||||
|
if comboBox:
|
||||||
|
pos = comboBox.findText(
|
||||||
|
index.model().data(index), Qt.MatchFlag.MatchExactly
|
||||||
|
)
|
||||||
|
comboBox.setCurrentIndex(pos)
|
||||||
|
|
||||||
|
def setModelData(self, editor, model, index):
|
||||||
|
if editor:
|
||||||
|
text = editor.text() if index.column() < 2 else editor.currentText()
|
||||||
|
model.setData(index, text)
|
||||||
|
|
||||||
|
def emitCommitData(self):
|
||||||
|
self.commitData.emit(self.sender())
|
||||||
|
|
||||||
|
|
||||||
|
class IconPreviewArea(QtWidgets.QWidget):
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
mainLayout = QtWidgets.QGridLayout()
|
||||||
|
self.setLayout(mainLayout)
|
||||||
|
|
||||||
|
self.icon = QtGui.QIcon()
|
||||||
|
self.size = QtCore.QSize()
|
||||||
|
self.stateLabels = []
|
||||||
|
self.modeLabels = []
|
||||||
|
self.pixmapLabels = []
|
||||||
|
|
||||||
|
self.stateLabels.append(self.createHeaderLabel("Off"))
|
||||||
|
self.stateLabels.append(self.createHeaderLabel("On"))
|
||||||
|
self.modeLabels.append(self.createHeaderLabel("Normal"))
|
||||||
|
self.modeLabels.append(self.createHeaderLabel("Active"))
|
||||||
|
self.modeLabels.append(self.createHeaderLabel("Disabled"))
|
||||||
|
self.modeLabels.append(self.createHeaderLabel("Selected"))
|
||||||
|
|
||||||
|
for j, label in enumerate(self.stateLabels):
|
||||||
|
mainLayout.addWidget(label, j + 1, 0)
|
||||||
|
|
||||||
|
for i, label in enumerate(self.modeLabels):
|
||||||
|
mainLayout.addWidget(label, 0, i + 1)
|
||||||
|
|
||||||
|
self.pixmapLabels.append([])
|
||||||
|
for j in range(len(self.stateLabels)):
|
||||||
|
self.pixmapLabels[i].append(self.createPixmapLabel())
|
||||||
|
mainLayout.addWidget(self.pixmapLabels[i][j], j + 1, i + 1)
|
||||||
|
|
||||||
|
def setIcon(self, icon):
|
||||||
|
self.icon = icon
|
||||||
|
self.updatePixmapLabels()
|
||||||
|
|
||||||
|
def setSize(self, size):
|
||||||
|
if size != self.size:
|
||||||
|
self.size = size
|
||||||
|
self.updatePixmapLabels()
|
||||||
|
|
||||||
|
def createHeaderLabel(self, text):
|
||||||
|
label = QtWidgets.QLabel("<b>%s</b>" % text)
|
||||||
|
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
return label
|
||||||
|
|
||||||
|
def createPixmapLabel(self):
|
||||||
|
label = QtWidgets.QLabel()
|
||||||
|
label.setEnabled(False)
|
||||||
|
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
label.setFrameShape(QtWidgets.QFrame.Box)
|
||||||
|
label.setSizePolicy(
|
||||||
|
QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding
|
||||||
|
)
|
||||||
|
label.setBackgroundRole(QtGui.QPalette.Base)
|
||||||
|
label.setAutoFillBackground(True)
|
||||||
|
label.setMinimumSize(132, 132)
|
||||||
|
return label
|
||||||
|
|
||||||
|
def updatePixmapLabels(self):
|
||||||
|
for i in range(len(self.modeLabels)):
|
||||||
|
if i == 0:
|
||||||
|
mode = QtGui.QIcon.Mode.Normal
|
||||||
|
elif i == 1:
|
||||||
|
mode = QtGui.QIcon.Mode.Active
|
||||||
|
elif i == 2:
|
||||||
|
mode = QtGui.QIcon.Mode.Disabled
|
||||||
|
else:
|
||||||
|
mode = QtGui.QIcon.Mode.Selected
|
||||||
|
|
||||||
|
for j in range(len(self.stateLabels)):
|
||||||
|
state = {True: QtGui.QIcon.State.Off, False: QtGui.QIcon.State.On}[
|
||||||
|
j == 0
|
||||||
|
]
|
||||||
|
pixmap = self.icon.pixmap(self.size, mode, state)
|
||||||
|
self.pixmapLabels[i][j].setPixmap(pixmap)
|
||||||
|
self.pixmapLabels[i][j].setEnabled(not pixmap.isNull())
|
||||||
|
|
||||||
|
|
||||||
|
class MainWindow(QtWidgets.QMainWindow):
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
self.centralWidget = QtWidgets.QWidget()
|
||||||
|
self.setCentralWidget(self.centralWidget)
|
||||||
|
|
||||||
|
self.createPreviewGroupBox()
|
||||||
|
self.createGlyphBox()
|
||||||
|
self.createIconSizeGroupBox()
|
||||||
|
|
||||||
|
mainLayout = QtWidgets.QGridLayout()
|
||||||
|
mainLayout.addWidget(self.previewGroupBox, 0, 0, 1, 2)
|
||||||
|
mainLayout.addWidget(self.glyphGroupBox, 1, 0)
|
||||||
|
mainLayout.addWidget(self.iconSizeGroupBox, 1, 1)
|
||||||
|
self.centralWidget.setLayout(mainLayout)
|
||||||
|
|
||||||
|
self.setWindowTitle("Icons")
|
||||||
|
self.otherRadioButton.click()
|
||||||
|
|
||||||
|
self.resize(self.minimumSizeHint())
|
||||||
|
|
||||||
|
def changeSize(self):
|
||||||
|
if self.otherRadioButton.isChecked():
|
||||||
|
extent = self.otherSpinBox.value()
|
||||||
|
else:
|
||||||
|
if self.smallRadioButton.isChecked():
|
||||||
|
metric = QtWidgets.QStyle.PixelMetric.PM_SmallIconSize
|
||||||
|
elif self.largeRadioButton.isChecked():
|
||||||
|
metric = QtWidgets.QStyle.PixelMetric.PM_LargeIconSize
|
||||||
|
elif self.toolBarRadioButton.isChecked():
|
||||||
|
metric = QtWidgets.QStyle.PixelMetric.PM_ToolBarIconSize
|
||||||
|
elif self.listViewRadioButton.isChecked():
|
||||||
|
metric = QtWidgets.QStyle.PixelMetric.PM_ListViewIconSize
|
||||||
|
elif self.iconViewRadioButton.isChecked():
|
||||||
|
metric = QtWidgets.QStyle.PixelMetric.PM_IconViewIconSize
|
||||||
|
else:
|
||||||
|
metric = QtWidgets.QStyle.PixelMetric.PM_TabBarIconSize
|
||||||
|
|
||||||
|
extent = QtWidgets.QApplication.style().pixelMetric(metric)
|
||||||
|
|
||||||
|
self.previewArea.setSize(QtCore.QSize(extent, extent))
|
||||||
|
self.otherSpinBox.setEnabled(self.otherRadioButton.isChecked())
|
||||||
|
|
||||||
|
def changeIcon(self):
|
||||||
|
from superqt import fonticon
|
||||||
|
|
||||||
|
icon = None
|
||||||
|
for row in range(self.glyphTable.rowCount()):
|
||||||
|
item0 = self.glyphTable.item(row, 0)
|
||||||
|
item1 = self.glyphTable.item(row, 1)
|
||||||
|
item2 = self.glyphTable.item(row, 2)
|
||||||
|
item3 = self.glyphTable.item(row, 3)
|
||||||
|
|
||||||
|
if item0.checkState() != Qt.CheckState.Checked:
|
||||||
|
continue
|
||||||
|
key = item0.text()
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if item2.text() == "Normal":
|
||||||
|
mode = QtGui.QIcon.Mode.Normal
|
||||||
|
elif item2.text() == "Active":
|
||||||
|
mode = QtGui.QIcon.Mode.Active
|
||||||
|
elif item2.text() == "Disabled":
|
||||||
|
mode = QtGui.QIcon.Mode.Disabled
|
||||||
|
else:
|
||||||
|
mode = QtGui.QIcon.Mode.Selected
|
||||||
|
|
||||||
|
color = item1.text() or None
|
||||||
|
state = (
|
||||||
|
QtGui.QIcon.State.On if item3.text() == "On" else QtGui.QIcon.State.Off
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if icon is None:
|
||||||
|
icon = fonticon.icon(key, color=color)
|
||||||
|
else:
|
||||||
|
icon.addState(state, mode, glyph_key=key, color=color)
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
continue
|
||||||
|
if icon:
|
||||||
|
self.previewArea.setIcon(icon)
|
||||||
|
|
||||||
|
def createPreviewGroupBox(self):
|
||||||
|
self.previewGroupBox = QtWidgets.QGroupBox("Preview")
|
||||||
|
|
||||||
|
self.previewArea = IconPreviewArea()
|
||||||
|
|
||||||
|
layout = QtWidgets.QVBoxLayout()
|
||||||
|
layout.addWidget(self.previewArea)
|
||||||
|
self.previewGroupBox.setLayout(layout)
|
||||||
|
|
||||||
|
def createGlyphBox(self):
|
||||||
|
self.glyphGroupBox = QtWidgets.QGroupBox("Glyphs")
|
||||||
|
self.glyphGroupBox.setMinimumSize(480, 200)
|
||||||
|
self.glyphTable = QtWidgets.QTableWidget()
|
||||||
|
self.glyphTable.setSelectionMode(QtWidgets.QAbstractItemView.NoSelection)
|
||||||
|
self.glyphTable.setItemDelegate(GlyphDelegate(self))
|
||||||
|
|
||||||
|
self.glyphTable.horizontalHeader().setDefaultSectionSize(100)
|
||||||
|
self.glyphTable.setColumnCount(4)
|
||||||
|
self.glyphTable.setHorizontalHeaderLabels(("Glyph", "Color", "Mode", "State"))
|
||||||
|
self.glyphTable.horizontalHeader().setSectionResizeMode(
|
||||||
|
0, QtWidgets.QHeaderView.Stretch
|
||||||
|
)
|
||||||
|
self.glyphTable.horizontalHeader().setSectionResizeMode(
|
||||||
|
1, QtWidgets.QHeaderView.Fixed
|
||||||
|
)
|
||||||
|
self.glyphTable.horizontalHeader().setSectionResizeMode(
|
||||||
|
2, QtWidgets.QHeaderView.Fixed
|
||||||
|
)
|
||||||
|
self.glyphTable.horizontalHeader().setSectionResizeMode(
|
||||||
|
3, QtWidgets.QHeaderView.Fixed
|
||||||
|
)
|
||||||
|
self.glyphTable.verticalHeader().hide()
|
||||||
|
|
||||||
|
self.glyphTable.itemChanged.connect(self.changeIcon)
|
||||||
|
|
||||||
|
layout = QtWidgets.QVBoxLayout()
|
||||||
|
layout.addWidget(self.glyphTable)
|
||||||
|
self.glyphGroupBox.setLayout(layout)
|
||||||
|
self.changeIcon()
|
||||||
|
|
||||||
|
p0 = list(P)[-1]
|
||||||
|
key = f"{p0}.{list(P[p0])[1]}"
|
||||||
|
for _ in range(4):
|
||||||
|
row = self.glyphTable.rowCount()
|
||||||
|
self.glyphTable.setRowCount(row + 1)
|
||||||
|
|
||||||
|
item0 = QtWidgets.QTableWidgetItem()
|
||||||
|
item1 = QtWidgets.QTableWidgetItem()
|
||||||
|
|
||||||
|
if _ == 0:
|
||||||
|
item0.setText(key)
|
||||||
|
# item0.setFlags(item0.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||||||
|
|
||||||
|
item2 = QtWidgets.QTableWidgetItem("Normal")
|
||||||
|
item3 = QtWidgets.QTableWidgetItem("Off")
|
||||||
|
|
||||||
|
self.glyphTable.setItem(row, 0, item0)
|
||||||
|
self.glyphTable.setItem(row, 1, item1)
|
||||||
|
self.glyphTable.setItem(row, 2, item2)
|
||||||
|
self.glyphTable.setItem(row, 3, item3)
|
||||||
|
self.glyphTable.openPersistentEditor(item2)
|
||||||
|
self.glyphTable.openPersistentEditor(item3)
|
||||||
|
|
||||||
|
item0.setCheckState(Qt.CheckState.Checked)
|
||||||
|
|
||||||
|
def createIconSizeGroupBox(self):
|
||||||
|
self.iconSizeGroupBox = QtWidgets.QGroupBox("Icon Size")
|
||||||
|
|
||||||
|
self.smallRadioButton = QtWidgets.QRadioButton()
|
||||||
|
self.largeRadioButton = QtWidgets.QRadioButton()
|
||||||
|
self.toolBarRadioButton = QtWidgets.QRadioButton()
|
||||||
|
self.listViewRadioButton = QtWidgets.QRadioButton()
|
||||||
|
self.iconViewRadioButton = QtWidgets.QRadioButton()
|
||||||
|
self.tabBarRadioButton = QtWidgets.QRadioButton()
|
||||||
|
self.otherRadioButton = QtWidgets.QRadioButton("Other:")
|
||||||
|
|
||||||
|
self.otherSpinBox = QtWidgets.QSpinBox()
|
||||||
|
self.otherSpinBox.setRange(8, 128)
|
||||||
|
self.otherSpinBox.setValue(64)
|
||||||
|
|
||||||
|
self.smallRadioButton.toggled.connect(self.changeSize)
|
||||||
|
self.largeRadioButton.toggled.connect(self.changeSize)
|
||||||
|
self.toolBarRadioButton.toggled.connect(self.changeSize)
|
||||||
|
self.listViewRadioButton.toggled.connect(self.changeSize)
|
||||||
|
self.iconViewRadioButton.toggled.connect(self.changeSize)
|
||||||
|
self.tabBarRadioButton.toggled.connect(self.changeSize)
|
||||||
|
self.otherRadioButton.toggled.connect(self.changeSize)
|
||||||
|
self.otherSpinBox.valueChanged.connect(self.changeSize)
|
||||||
|
|
||||||
|
otherSizeLayout = QtWidgets.QHBoxLayout()
|
||||||
|
otherSizeLayout.addWidget(self.otherRadioButton)
|
||||||
|
otherSizeLayout.addWidget(self.otherSpinBox)
|
||||||
|
otherSizeLayout.addStretch()
|
||||||
|
|
||||||
|
layout = QtWidgets.QGridLayout()
|
||||||
|
layout.addWidget(self.smallRadioButton, 0, 0)
|
||||||
|
layout.addWidget(self.largeRadioButton, 1, 0)
|
||||||
|
layout.addWidget(self.toolBarRadioButton, 2, 0)
|
||||||
|
layout.addWidget(self.listViewRadioButton, 0, 1)
|
||||||
|
layout.addWidget(self.iconViewRadioButton, 1, 1)
|
||||||
|
layout.addWidget(self.tabBarRadioButton, 2, 1)
|
||||||
|
layout.addLayout(otherSizeLayout, 3, 0, 1, 2)
|
||||||
|
layout.setRowStretch(4, 1)
|
||||||
|
self.iconSizeGroupBox.setLayout(layout)
|
||||||
|
self.changeStyle()
|
||||||
|
|
||||||
|
def changeStyle(self, style=None):
|
||||||
|
style = style or QtWidgets.QApplication.style().objectName()
|
||||||
|
style = QtWidgets.QStyleFactory.create(style)
|
||||||
|
if not style:
|
||||||
|
return
|
||||||
|
|
||||||
|
QtWidgets.QApplication.setStyle(style)
|
||||||
|
|
||||||
|
self.setButtonText(
|
||||||
|
self.smallRadioButton,
|
||||||
|
"Small (%d x %d)",
|
||||||
|
style,
|
||||||
|
QtWidgets.QStyle.PixelMetric.PM_SmallIconSize,
|
||||||
|
)
|
||||||
|
self.setButtonText(
|
||||||
|
self.largeRadioButton,
|
||||||
|
"Large (%d x %d)",
|
||||||
|
style,
|
||||||
|
QtWidgets.QStyle.PixelMetric.PM_LargeIconSize,
|
||||||
|
)
|
||||||
|
self.setButtonText(
|
||||||
|
self.toolBarRadioButton,
|
||||||
|
"Toolbars (%d x %d)",
|
||||||
|
style,
|
||||||
|
QtWidgets.QStyle.PixelMetric.PM_ToolBarIconSize,
|
||||||
|
)
|
||||||
|
self.setButtonText(
|
||||||
|
self.listViewRadioButton,
|
||||||
|
"List views (%d x %d)",
|
||||||
|
style,
|
||||||
|
QtWidgets.QStyle.PixelMetric.PM_ListViewIconSize,
|
||||||
|
)
|
||||||
|
self.setButtonText(
|
||||||
|
self.iconViewRadioButton,
|
||||||
|
"Icon views (%d x %d)",
|
||||||
|
style,
|
||||||
|
QtWidgets.QStyle.PixelMetric.PM_IconViewIconSize,
|
||||||
|
)
|
||||||
|
self.setButtonText(
|
||||||
|
self.tabBarRadioButton,
|
||||||
|
"Tab bars (%d x %d)",
|
||||||
|
style,
|
||||||
|
QtWidgets.QStyle.PixelMetric.PM_TabBarIconSize,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.changeSize()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def setButtonText(button, label, style, metric):
|
||||||
|
metric_value = style.pixelMetric(metric)
|
||||||
|
button.setText(label % (metric_value, metric_value))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
app = QtWidgets.QApplication(sys.argv)
|
||||||
|
mainWin = MainWindow()
|
||||||
|
mainWin.show()
|
||||||
|
sys.exit(app.exec_())
|
@@ -1,15 +1,16 @@
|
|||||||
|
from qtpy.QtCore import Qt
|
||||||
|
from qtpy.QtWidgets import QApplication, QHBoxLayout, QVBoxLayout, QWidget
|
||||||
|
|
||||||
from superqt import (
|
from superqt import (
|
||||||
QLabeledDoubleRangeSlider,
|
QLabeledDoubleRangeSlider,
|
||||||
QLabeledDoubleSlider,
|
QLabeledDoubleSlider,
|
||||||
QLabeledRangeSlider,
|
QLabeledRangeSlider,
|
||||||
QLabeledSlider,
|
QLabeledSlider,
|
||||||
)
|
)
|
||||||
from superqt.qtcompat.QtCore import Qt
|
|
||||||
from superqt.qtcompat.QtWidgets import QApplication, QHBoxLayout, QVBoxLayout, QWidget
|
|
||||||
|
|
||||||
app = QApplication([])
|
app = QApplication([])
|
||||||
|
|
||||||
ORIENTATION = Qt.Horizontal
|
ORIENTATION = Qt.Orientation.Horizontal
|
||||||
|
|
||||||
w = QWidget()
|
w = QWidget()
|
||||||
qls = QLabeledSlider(ORIENTATION)
|
qls = QLabeledSlider(ORIENTATION)
|
||||||
@@ -35,7 +36,9 @@ qldrs.setSingleStep(0.01)
|
|||||||
qldrs.setValue((0.2, 0.7))
|
qldrs.setValue((0.2, 0.7))
|
||||||
|
|
||||||
|
|
||||||
w.setLayout(QVBoxLayout() if ORIENTATION == Qt.Horizontal else QHBoxLayout())
|
w.setLayout(
|
||||||
|
QVBoxLayout() if ORIENTATION == Qt.Orientation.Horizontal else QHBoxLayout()
|
||||||
|
)
|
||||||
w.layout().addWidget(qls)
|
w.layout().addWidget(qls)
|
||||||
w.layout().addWidget(qlds)
|
w.layout().addWidget(qlds)
|
||||||
w.layout().addWidget(qlrs)
|
w.layout().addWidget(qlrs)
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
from superqt import QRangeSlider
|
from superqt import QRangeSlider
|
||||||
from superqt.qtcompat.QtWidgets import QApplication
|
|
||||||
|
|
||||||
app = QApplication([])
|
app = QApplication([])
|
||||||
|
|
||||||
|
17
examples/qcollapsible.py
Normal file
17
examples/qcollapsible.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"""Example for QCollapsible."""
|
||||||
|
from qtpy.QtWidgets import QApplication, QLabel, QPushButton
|
||||||
|
|
||||||
|
from superqt import QCollapsible
|
||||||
|
|
||||||
|
app = QApplication([])
|
||||||
|
|
||||||
|
collapsible = QCollapsible("Advanced analysis")
|
||||||
|
collapsible.setCollapsedIcon("+")
|
||||||
|
collapsible.setExpandedIcon("-")
|
||||||
|
collapsible.addWidget(QLabel("This is the inside of the collapsible frame"))
|
||||||
|
for i in range(10):
|
||||||
|
collapsible.addWidget(QPushButton(f"Content button {i + 1}"))
|
||||||
|
|
||||||
|
collapsible.expand(animate=False)
|
||||||
|
collapsible.show()
|
||||||
|
app.exec_()
|
9
examples/quantity.py
Normal file
9
examples/quantity.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from superqt import QQuantity
|
||||||
|
|
||||||
|
app = QApplication([])
|
||||||
|
w = QQuantity("1m")
|
||||||
|
w.show()
|
||||||
|
|
||||||
|
app.exec()
|
11
examples/searchable_combo_box.py
Normal file
11
examples/searchable_combo_box.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from superqt import QSearchableComboBox
|
||||||
|
|
||||||
|
app = QApplication([])
|
||||||
|
|
||||||
|
slider = QSearchableComboBox()
|
||||||
|
slider.addItems(["foo", "bar", "baz", "foobar", "foobaz", "barbaz"])
|
||||||
|
slider.show()
|
||||||
|
|
||||||
|
app.exec_()
|
11
examples/searchable_list_widget.py
Normal file
11
examples/searchable_list_widget.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from superqt import QSearchableListWidget
|
||||||
|
|
||||||
|
app = QApplication([])
|
||||||
|
|
||||||
|
slider = QSearchableListWidget()
|
||||||
|
slider.addItems(["foo", "bar", "baz", "foobar", "foobaz", "barbaz"])
|
||||||
|
slider.show()
|
||||||
|
|
||||||
|
app.exec_()
|
29
examples/searchable_tree_widget.py
Normal file
29
examples/searchable_tree_widget.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from superqt import QSearchableTreeWidget
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG,
|
||||||
|
format="%(asctime)s : %(levelname)s : %(filename)s : %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"none": None,
|
||||||
|
"str": "test",
|
||||||
|
"int": 42,
|
||||||
|
"list": [2, 3, 5],
|
||||||
|
"dict": {
|
||||||
|
"float": 0.5,
|
||||||
|
"tuple": (22, 99),
|
||||||
|
"bool": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
app = QApplication([])
|
||||||
|
|
||||||
|
tree = QSearchableTreeWidget.fromData(data)
|
||||||
|
tree.show()
|
||||||
|
|
||||||
|
app.exec_()
|
29
examples/throttle_mouse_event.py
Normal file
29
examples/throttle_mouse_event.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from qtpy.QtCore import Signal
|
||||||
|
from qtpy.QtWidgets import QApplication, QWidget
|
||||||
|
|
||||||
|
from superqt.utils import qthrottled
|
||||||
|
|
||||||
|
|
||||||
|
class Demo(QWidget):
|
||||||
|
positionChanged = Signal(int, int)
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.setMouseTracking(True)
|
||||||
|
self.positionChanged.connect(self._show_location)
|
||||||
|
|
||||||
|
@qthrottled(timeout=400) # call this no more than once every 400ms
|
||||||
|
def _show_location(self, x, y):
|
||||||
|
print("Throttled event at", x, y)
|
||||||
|
|
||||||
|
def mouseMoveEvent(self, event):
|
||||||
|
print("real move event at", event.x(), event.y())
|
||||||
|
self.positionChanged.emit(event.x(), event.y())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = QApplication([])
|
||||||
|
w = Demo()
|
||||||
|
w.resize(600, 600)
|
||||||
|
w.show()
|
||||||
|
app.exec_()
|
280
examples/throttler_demo.py
Normal file
280
examples/throttler_demo.py
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
"""Adapted for python from the KDToolBox.
|
||||||
|
|
||||||
|
https://github.com/KDAB/KDToolBox/tree/master/qt/KDSignalThrottler
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (C) 2019-2022 Klarälvdalens Datakonsult AB, a KDAB Group company,
|
||||||
|
info@kdab.com
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Deque
|
||||||
|
|
||||||
|
from qtpy.QtCore import QRect, QSize, Qt, QTimer, Signal
|
||||||
|
from qtpy.QtGui import QPainter, QPen
|
||||||
|
from qtpy.QtWidgets import (
|
||||||
|
QApplication,
|
||||||
|
QCheckBox,
|
||||||
|
QComboBox,
|
||||||
|
QFormLayout,
|
||||||
|
QGroupBox,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QPushButton,
|
||||||
|
QSizePolicy,
|
||||||
|
QSpinBox,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from superqt.utils._throttler import (
|
||||||
|
GenericSignalThrottler,
|
||||||
|
QSignalDebouncer,
|
||||||
|
QSignalThrottler,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DrawSignalsWidget(QWidget):
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||||
|
self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
|
||||||
|
self.setAttribute(Qt.WA_OpaquePaintEvent)
|
||||||
|
|
||||||
|
self._scrollTimer = QTimer(self)
|
||||||
|
self._scrollTimer.setInterval(10)
|
||||||
|
self._scrollTimer.timeout.connect(self._scroll)
|
||||||
|
self._scrollTimer.start()
|
||||||
|
|
||||||
|
self._signalActivations: Deque[int] = Deque()
|
||||||
|
self._throttledSignalActivations: Deque[int] = Deque()
|
||||||
|
|
||||||
|
def sizeHint(self):
|
||||||
|
return QSize(400, 200)
|
||||||
|
|
||||||
|
def addSignalActivation(self):
|
||||||
|
self._signalActivations.appendleft(0)
|
||||||
|
|
||||||
|
def addThrottledSignalActivation(self):
|
||||||
|
self._throttledSignalActivations.appendleft(0)
|
||||||
|
|
||||||
|
def _scroll(self):
|
||||||
|
cutoff = self.width()
|
||||||
|
self.scrollAndCut(self._signalActivations, cutoff)
|
||||||
|
self.scrollAndCut(self._throttledSignalActivations, cutoff)
|
||||||
|
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def scrollAndCut(self, v: Deque[int], cutoff: int):
|
||||||
|
L = len(v)
|
||||||
|
for p in range(L):
|
||||||
|
v[p] += 1
|
||||||
|
if v[p] > cutoff:
|
||||||
|
break
|
||||||
|
|
||||||
|
# TODO: fix this... delete old ones
|
||||||
|
|
||||||
|
def paintEvent(self, event):
|
||||||
|
p = QPainter(self)
|
||||||
|
p.fillRect(self.rect(), Qt.white)
|
||||||
|
|
||||||
|
h = self.height()
|
||||||
|
h2 = h // 2
|
||||||
|
w = self.width()
|
||||||
|
|
||||||
|
self._drawSignals(p, self._signalActivations, Qt.red, 0, h2)
|
||||||
|
self._drawSignals(p, self._throttledSignalActivations, Qt.blue, h2, h)
|
||||||
|
|
||||||
|
p.drawText(
|
||||||
|
QRect(0, 0, w, h2),
|
||||||
|
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
|
||||||
|
"Source signal",
|
||||||
|
)
|
||||||
|
p.drawText(
|
||||||
|
QRect(0, h2, w, h2),
|
||||||
|
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
|
||||||
|
"Throttled signal",
|
||||||
|
)
|
||||||
|
|
||||||
|
p.save()
|
||||||
|
pen = QPen()
|
||||||
|
pen.setWidthF(2.0)
|
||||||
|
p.drawLine(0, h2, w, h2)
|
||||||
|
p.restore()
|
||||||
|
|
||||||
|
def _drawSignals(self, p: QPainter, v: Deque[int], color, yStart, yEnd):
|
||||||
|
p.save()
|
||||||
|
pen = QPen()
|
||||||
|
pen.setWidthF(2.0)
|
||||||
|
pen.setColor(color)
|
||||||
|
p.setPen(pen)
|
||||||
|
|
||||||
|
for i in v:
|
||||||
|
p.drawLine(i, yStart, i, yEnd)
|
||||||
|
p.restore()
|
||||||
|
|
||||||
|
|
||||||
|
class DemoWidget(QWidget):
|
||||||
|
signalToBeThrottled = Signal()
|
||||||
|
_throttler: GenericSignalThrottler
|
||||||
|
|
||||||
|
def __init__(self, parent=None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._createUi()
|
||||||
|
self._throttler = None
|
||||||
|
|
||||||
|
self._throttlerKindComboBox.currentIndexChanged.connect(self._createThrottler)
|
||||||
|
self._createThrottler()
|
||||||
|
|
||||||
|
self._throttlerTimeoutSpinBox.valueChanged.connect(self.setThrottlerTimeout)
|
||||||
|
self.setThrottlerTimeout()
|
||||||
|
|
||||||
|
self._mainButton.clicked.connect(self.signalToBeThrottled)
|
||||||
|
|
||||||
|
self._autoTriggerTimer = QTimer(self)
|
||||||
|
self._autoTriggerTimer.setTimerType(Qt.TimerType.PreciseTimer)
|
||||||
|
self._autoTriggerCheckBox.clicked.connect(self._startOrStopAutoTriggerTimer)
|
||||||
|
self._startOrStopAutoTriggerTimer()
|
||||||
|
|
||||||
|
self._autoTriggerIntervalSpinBox.valueChanged.connect(
|
||||||
|
self._setAutoTriggerTimeout
|
||||||
|
)
|
||||||
|
self._setAutoTriggerTimeout()
|
||||||
|
|
||||||
|
self._autoTriggerTimer.timeout.connect(self.signalToBeThrottled)
|
||||||
|
self.signalToBeThrottled.connect(self._drawSignalsWidget.addSignalActivation)
|
||||||
|
|
||||||
|
def _createThrottler(self) -> None:
|
||||||
|
if self._throttler is not None:
|
||||||
|
self._throttler.deleteLater()
|
||||||
|
del self._throttler
|
||||||
|
|
||||||
|
if self._throttlerKindComboBox.currentIndex() < 2:
|
||||||
|
cls = QSignalThrottler
|
||||||
|
else:
|
||||||
|
cls = QSignalDebouncer
|
||||||
|
if self._throttlerKindComboBox.currentIndex() % 2:
|
||||||
|
policy = QSignalThrottler.EmissionPolicy.Leading
|
||||||
|
else:
|
||||||
|
policy = QSignalThrottler.EmissionPolicy.Trailing
|
||||||
|
|
||||||
|
self._throttler: GenericSignalThrottler = cls(policy, self)
|
||||||
|
|
||||||
|
self._throttler.setTimerType(Qt.TimerType.PreciseTimer)
|
||||||
|
self.signalToBeThrottled.connect(self._throttler.throttle)
|
||||||
|
self._throttler.triggered.connect(
|
||||||
|
self._drawSignalsWidget.addThrottledSignalActivation
|
||||||
|
)
|
||||||
|
|
||||||
|
self.setThrottlerTimeout()
|
||||||
|
|
||||||
|
def setThrottlerTimeout(self):
|
||||||
|
self._throttler.setTimeout(self._throttlerTimeoutSpinBox.value())
|
||||||
|
|
||||||
|
def _startOrStopAutoTriggerTimer(self):
|
||||||
|
shouldStart = self._autoTriggerCheckBox.isChecked()
|
||||||
|
if shouldStart:
|
||||||
|
self._autoTriggerTimer.start()
|
||||||
|
else:
|
||||||
|
self._autoTriggerTimer.stop()
|
||||||
|
|
||||||
|
self._autoTriggerIntervalSpinBox.setEnabled(shouldStart)
|
||||||
|
self._autoTriggerLabel.setEnabled(shouldStart)
|
||||||
|
|
||||||
|
def _setAutoTriggerTimeout(self):
|
||||||
|
timeout = self._autoTriggerIntervalSpinBox.value()
|
||||||
|
self._autoTriggerTimer.setInterval(timeout)
|
||||||
|
|
||||||
|
def _createUi(self):
|
||||||
|
helpLabel = QLabel(self)
|
||||||
|
helpLabel.setWordWrap(True)
|
||||||
|
helpLabel.setText(
|
||||||
|
"<h2>SignalThrottler example</h2>"
|
||||||
|
"<p>This example demonstrates the differences between "
|
||||||
|
"the different kinds of signal throttlers and debouncers."
|
||||||
|
)
|
||||||
|
|
||||||
|
throttlerKindGroupBox = QGroupBox("Throttler configuration", self)
|
||||||
|
|
||||||
|
self._throttlerKindComboBox = QComboBox(throttlerKindGroupBox)
|
||||||
|
self._throttlerKindComboBox.addItems(
|
||||||
|
(
|
||||||
|
"Throttler, trailing",
|
||||||
|
"Throttler, leading",
|
||||||
|
"Debouncer, trailing",
|
||||||
|
"Debouncer, leading",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self._throttlerTimeoutSpinBox = QSpinBox(throttlerKindGroupBox)
|
||||||
|
self._throttlerTimeoutSpinBox.setRange(1, 5000)
|
||||||
|
self._throttlerTimeoutSpinBox.setValue(500)
|
||||||
|
self._throttlerTimeoutSpinBox.setSuffix(" ms")
|
||||||
|
|
||||||
|
layout = QFormLayout(throttlerKindGroupBox)
|
||||||
|
layout.addRow("Kind of throttler:", self._throttlerKindComboBox)
|
||||||
|
layout.addRow("Timeout:", self._throttlerTimeoutSpinBox)
|
||||||
|
throttlerKindGroupBox.setLayout(layout)
|
||||||
|
|
||||||
|
buttonGroupBox = QGroupBox("Throttler activation")
|
||||||
|
self._mainButton = QPushButton(("Press me!"), buttonGroupBox)
|
||||||
|
|
||||||
|
self._autoTriggerCheckBox = QCheckBox("Trigger automatically")
|
||||||
|
|
||||||
|
autoTriggerLayout = QHBoxLayout()
|
||||||
|
self._autoTriggerLabel = QLabel("Interval", buttonGroupBox)
|
||||||
|
self._autoTriggerIntervalSpinBox = QSpinBox(buttonGroupBox)
|
||||||
|
self._autoTriggerIntervalSpinBox.setRange(1, 5000)
|
||||||
|
self._autoTriggerIntervalSpinBox.setValue(100)
|
||||||
|
self._autoTriggerIntervalSpinBox.setSuffix(" ms")
|
||||||
|
|
||||||
|
autoTriggerLayout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
autoTriggerLayout.addWidget(self._autoTriggerLabel)
|
||||||
|
autoTriggerLayout.addWidget(self._autoTriggerIntervalSpinBox)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(buttonGroupBox)
|
||||||
|
layout.addWidget(self._mainButton)
|
||||||
|
layout.addWidget(self._autoTriggerCheckBox)
|
||||||
|
layout.addLayout(autoTriggerLayout)
|
||||||
|
buttonGroupBox.setLayout(layout)
|
||||||
|
|
||||||
|
resultGroupBox = QGroupBox("Result")
|
||||||
|
self._drawSignalsWidget = DrawSignalsWidget(resultGroupBox)
|
||||||
|
layout = QVBoxLayout(resultGroupBox)
|
||||||
|
layout.addWidget(self._drawSignalsWidget)
|
||||||
|
resultGroupBox.setLayout(layout)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.addWidget(helpLabel)
|
||||||
|
layout.addWidget(throttlerKindGroupBox)
|
||||||
|
layout.addWidget(buttonGroupBox)
|
||||||
|
layout.addWidget(resultGroupBox)
|
||||||
|
|
||||||
|
self.setLayout(layout)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = QApplication([__name__])
|
||||||
|
w = DemoWidget()
|
||||||
|
w.resize(600, 600)
|
||||||
|
w.show()
|
||||||
|
app.exec_()
|
58
mkdocs.yml
Normal file
58
mkdocs.yml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
site_name: superqt
|
||||||
|
site_url: https://github.com/pyapp-kit/superqt
|
||||||
|
site_description: >-
|
||||||
|
missing widgets and components for PyQt/PySide
|
||||||
|
# Repository
|
||||||
|
repo_name: pyapp-kit/superqt
|
||||||
|
repo_url: https://github.com/pyapp-kit/superqt
|
||||||
|
|
||||||
|
# Copyright
|
||||||
|
copyright: Copyright © 2021 - 2022 Talley Lambert
|
||||||
|
|
||||||
|
extra_css:
|
||||||
|
- stylesheets/extra.css
|
||||||
|
|
||||||
|
watch:
|
||||||
|
- src
|
||||||
|
|
||||||
|
theme:
|
||||||
|
name: material
|
||||||
|
features:
|
||||||
|
- navigation.instant
|
||||||
|
- navigation.indexes
|
||||||
|
- navigation.expand
|
||||||
|
# - navigation.tracking
|
||||||
|
# - navigation.tabs
|
||||||
|
- search.highlight
|
||||||
|
- search.suggest
|
||||||
|
|
||||||
|
markdown_extensions:
|
||||||
|
- admonition
|
||||||
|
- pymdownx.details
|
||||||
|
- pymdownx.superfences
|
||||||
|
- tables
|
||||||
|
- attr_list
|
||||||
|
- md_in_html
|
||||||
|
- pymdownx.emoji:
|
||||||
|
emoji_index: !!python/name:materialx.emoji.twemoji
|
||||||
|
emoji_generator: !!python/name:materialx.emoji.to_svg
|
||||||
|
- toc:
|
||||||
|
permalink: "#"
|
||||||
|
|
||||||
|
|
||||||
|
plugins:
|
||||||
|
- search
|
||||||
|
- autorefs
|
||||||
|
- mkdocstrings
|
||||||
|
- macros:
|
||||||
|
module_name: docs/_macros
|
||||||
|
- mkdocstrings:
|
||||||
|
handlers:
|
||||||
|
python:
|
||||||
|
import:
|
||||||
|
- https://docs.python.org/3/objects.inv
|
||||||
|
options:
|
||||||
|
show_source: false
|
||||||
|
docstring_style: numpy
|
||||||
|
show_root_toc_entry: True
|
||||||
|
show_root_heading: True
|
184
pyproject.toml
184
pyproject.toml
@@ -1,3 +1,183 @@
|
|||||||
# pyproject.toml
|
# https://peps.python.org/pep-0517/
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=45", "wheel", "setuptools_scm[toml]>=6.0"]
|
requires = ["hatchling", "hatch-vcs"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
# https://peps.python.org/pep-0621/
|
||||||
|
[project]
|
||||||
|
name = "superqt"
|
||||||
|
description = "Missing widgets and components for PyQt/PySide"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.8"
|
||||||
|
license = { text = "BSD 3-Clause License" }
|
||||||
|
authors = [{ email = "talley.lambert@gmail.com", name = "Talley Lambert" }]
|
||||||
|
keywords = [
|
||||||
|
"qt",
|
||||||
|
"pyqt",
|
||||||
|
"pyside",
|
||||||
|
"widgets",
|
||||||
|
"range slider",
|
||||||
|
"components",
|
||||||
|
"gui",
|
||||||
|
]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 4 - Beta",
|
||||||
|
"Environment :: X11 Applications :: Qt",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"License :: OSI Approved :: BSD License",
|
||||||
|
"Operating System :: OS Independent",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3 :: Only",
|
||||||
|
"Programming Language :: Python :: 3.8",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Topic :: Desktop Environment",
|
||||||
|
"Topic :: Software Development :: User Interfaces",
|
||||||
|
"Topic :: Software Development :: Widget Sets",
|
||||||
|
]
|
||||||
|
dynamic = ["version"]
|
||||||
|
dependencies = [
|
||||||
|
"packaging",
|
||||||
|
"pygments>=2.4.0",
|
||||||
|
"qtpy>=1.1.0",
|
||||||
|
"typing-extensions",
|
||||||
|
]
|
||||||
|
|
||||||
|
# extras
|
||||||
|
# https://peps.python.org/pep-0621/#dependencies-optional-dependencies
|
||||||
|
[project.optional-dependencies]
|
||||||
|
test = ["pint", "pytest", "pytest-cov", "pytest-qt"]
|
||||||
|
dev = [
|
||||||
|
"black",
|
||||||
|
"ipython",
|
||||||
|
"ruff",
|
||||||
|
"mypy",
|
||||||
|
"pdbpp",
|
||||||
|
"pre-commit",
|
||||||
|
"pydocstyle",
|
||||||
|
"rich",
|
||||||
|
"types-Pygments",
|
||||||
|
]
|
||||||
|
docs = ["mkdocs-macros-plugin", "mkdocs-material", "mkdocstrings[python]"]
|
||||||
|
quantity = ["pint"]
|
||||||
|
pyside2 = ["pyside2"]
|
||||||
|
# see issues surrounding usage of Generics in pyside6.5.x
|
||||||
|
# https://github.com/pyapp-kit/superqt/pull/177
|
||||||
|
# https://github.com/pyapp-kit/superqt/pull/164
|
||||||
|
pyside6 = ["pyside6 !=6.5.0,!=6.5.1"]
|
||||||
|
pyqt5 = ["pyqt5"]
|
||||||
|
pyqt6 = ["pyqt6"]
|
||||||
|
font-fa5 = ["fonticon-fontawesome5"]
|
||||||
|
font-fa6 = ["fonticon-fontawesome6"]
|
||||||
|
font-mi6 = ["fonticon-materialdesignicons6"]
|
||||||
|
font-mi7 = ["fonticon-materialdesignicons7"]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Source = "https://github.com/pyapp-kit/superqt"
|
||||||
|
Tracker = "https://github.com/pyapp-kit/superqt/issues"
|
||||||
|
Changelog = "https://github.com/pyapp-kit/superqt/blob/main/CHANGELOG.md"
|
||||||
|
|
||||||
|
[tool.hatch.version]
|
||||||
|
source = "vcs"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.sdist]
|
||||||
|
include = ["src", "tests", "CHANGELOG.md"]
|
||||||
|
|
||||||
|
# https://pycqa.github.io/isort/docs/configuration/options.html
|
||||||
|
[tool.isort]
|
||||||
|
profile = "black"
|
||||||
|
src_paths = ["src/superqt", "tests"]
|
||||||
|
|
||||||
|
# https://github.com/charliermarsh/ruff
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 88
|
||||||
|
target-version = "py38"
|
||||||
|
src = ["src", "tests"]
|
||||||
|
select = [
|
||||||
|
"E", # style errors
|
||||||
|
"F", # flakes
|
||||||
|
"W", # flakes
|
||||||
|
"D", # pydocstyle
|
||||||
|
"I", # isort
|
||||||
|
"UP", # pyupgrade
|
||||||
|
"S", # bandit
|
||||||
|
"C4", # flake8-comprehensions
|
||||||
|
"B", # flake8-bugbear
|
||||||
|
"A001", # flake8-builtins
|
||||||
|
"RUF", # ruff-specific rules
|
||||||
|
"TID", # tidy imports
|
||||||
|
]
|
||||||
|
ignore = [
|
||||||
|
"D100", # Missing docstring in public module
|
||||||
|
"D101", # Missing docstring in public class
|
||||||
|
"D104", # Missing docstring in public package
|
||||||
|
"D107", # Missing docstring in __init__
|
||||||
|
"D203", # 1 blank line required before class docstring
|
||||||
|
"D212", # Multi-line docstring summary should start at the first line
|
||||||
|
"D213", # Multi-line docstring summary should start at the second line
|
||||||
|
"D401", # First line should be in imperative mood
|
||||||
|
"D413", # Missing blank line after last section
|
||||||
|
"D416", # Section name should end with a colon
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
[tool.ruff.per-file-ignores]
|
||||||
|
"tests/*.py" = ["D", "S101"]
|
||||||
|
"examples/demo_widget.py" = ["E501"]
|
||||||
|
"examples/*.py" = ["B", "D"]
|
||||||
|
|
||||||
|
# https://docs.pytest.org/en/6.2.x/customize.html
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
minversion = "6.0"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
filterwarnings = [
|
||||||
|
"error",
|
||||||
|
"ignore:QPixmapCache.find:DeprecationWarning:",
|
||||||
|
"ignore:SelectableGroups dict interface:DeprecationWarning",
|
||||||
|
"ignore:The distutils package is deprecated:DeprecationWarning",
|
||||||
|
]
|
||||||
|
|
||||||
|
# https://mypy.readthedocs.io/en/stable/config_file.html
|
||||||
|
[tool.mypy]
|
||||||
|
files = "src/**/*.py"
|
||||||
|
strict = true
|
||||||
|
disallow_untyped_defs = false
|
||||||
|
disallow_untyped_calls = false
|
||||||
|
disallow_any_generics = false
|
||||||
|
disallow_subclassing_any = false
|
||||||
|
show_error_codes = true
|
||||||
|
pretty = true
|
||||||
|
exclude = ['tests/**/*']
|
||||||
|
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = ["superqt.qtcompat.*"]
|
||||||
|
ignore_missing_imports = true
|
||||||
|
warn_unused_ignores = false
|
||||||
|
allow_redefinition = true
|
||||||
|
|
||||||
|
# https://coverage.readthedocs.io/en/6.4/config.html
|
||||||
|
[tool.coverage.report]
|
||||||
|
exclude_lines = [
|
||||||
|
"pragma: no cover",
|
||||||
|
"if TYPE_CHECKING:",
|
||||||
|
"@overload",
|
||||||
|
"except ImportError",
|
||||||
|
]
|
||||||
|
|
||||||
|
# https://github.com/mgedmin/check-manifest#configuration
|
||||||
|
[tool.check-manifest]
|
||||||
|
ignore = [
|
||||||
|
".github_changelog_generator",
|
||||||
|
".pre-commit-config.yaml",
|
||||||
|
"tests/**/*",
|
||||||
|
"src/superqt/_version.py",
|
||||||
|
"mkdocs.yml",
|
||||||
|
"docs/**/*",
|
||||||
|
"examples/**/*",
|
||||||
|
"CHANGELOG.md",
|
||||||
|
"CONTRIBUTING.md",
|
||||||
|
"codecov.yml",
|
||||||
|
".ruff_cache/**/*",
|
||||||
|
]
|
||||||
|
83
setup.cfg
83
setup.cfg
@@ -1,83 +0,0 @@
|
|||||||
[metadata]
|
|
||||||
name = superqt
|
|
||||||
description = Missing widgets for PyQt/PySide
|
|
||||||
long_description = file: README.md
|
|
||||||
long_description_content_type = text/markdown
|
|
||||||
url = https://github.com/napari/superqt
|
|
||||||
author = Talley Lambert
|
|
||||||
author_email = talley.lambert@gmail.com
|
|
||||||
license = BSD-3-Clause
|
|
||||||
license_file = LICENSE
|
|
||||||
classifiers =
|
|
||||||
Development Status :: 4 - Beta
|
|
||||||
Environment :: X11 Applications :: Qt
|
|
||||||
Intended Audience :: Developers
|
|
||||||
License :: OSI Approved :: BSD License
|
|
||||||
Operating System :: OS Independent
|
|
||||||
Programming Language :: Python
|
|
||||||
Programming Language :: Python :: 3
|
|
||||||
Programming Language :: Python :: 3 :: Only
|
|
||||||
Programming Language :: Python :: 3.7
|
|
||||||
Programming Language :: Python :: 3.8
|
|
||||||
Programming Language :: Python :: 3.9
|
|
||||||
Programming Language :: Python :: Implementation :: CPython
|
|
||||||
Topic :: Desktop Environment
|
|
||||||
Topic :: Software Development
|
|
||||||
Topic :: Software Development :: User Interfaces
|
|
||||||
Topic :: Software Development :: Widget Sets
|
|
||||||
keywords = qt, range slider, widget
|
|
||||||
project_urls =
|
|
||||||
Source = https://github.com/napari/superqt
|
|
||||||
Tracker = https://github.com/napari/superqt/issues
|
|
||||||
Changelog = https://github.com/napari/superqt/blob/master/CHANGELOG.md
|
|
||||||
|
|
||||||
[options]
|
|
||||||
packages = find:
|
|
||||||
python_requires = >=3.7
|
|
||||||
setup_requires =
|
|
||||||
setuptools_scm
|
|
||||||
zip_safe = False
|
|
||||||
|
|
||||||
[options.extras_require]
|
|
||||||
dev =
|
|
||||||
ipython
|
|
||||||
isort
|
|
||||||
jedi<0.18.0
|
|
||||||
mypy
|
|
||||||
pre-commit
|
|
||||||
pyside2
|
|
||||||
pytest
|
|
||||||
pytest-cov
|
|
||||||
pytest-qt
|
|
||||||
tox
|
|
||||||
tox-conda
|
|
||||||
pyqt5 =
|
|
||||||
pyqt5
|
|
||||||
pyqt6 =
|
|
||||||
pyqt6
|
|
||||||
pyside2 =
|
|
||||||
pyside2
|
|
||||||
pyside6 =
|
|
||||||
pyside6
|
|
||||||
testing =
|
|
||||||
pytest
|
|
||||||
pytest-cov
|
|
||||||
pytest-qt
|
|
||||||
tox
|
|
||||||
tox-conda
|
|
||||||
|
|
||||||
[flake8]
|
|
||||||
exclude = _version.py,.eggs,examples
|
|
||||||
docstring-convention = numpy
|
|
||||||
ignore = E203,W503,E501,C901,F403,F405,D100
|
|
||||||
|
|
||||||
[pydocstyle]
|
|
||||||
convention = numpy
|
|
||||||
add_select = D402,D415,D417
|
|
||||||
ignore = D100
|
|
||||||
|
|
||||||
[isort]
|
|
||||||
profile = black
|
|
||||||
|
|
||||||
[tool:pytest]
|
|
||||||
addopts = -W error
|
|
6
setup.py
6
setup.py
@@ -1,6 +0,0 @@
|
|||||||
from setuptools import setup
|
|
||||||
|
|
||||||
setup(
|
|
||||||
use_scm_version={"write_to": "superqt/_version.py"},
|
|
||||||
setup_requires=["setuptools_scm"],
|
|
||||||
)
|
|
57
src/superqt/__init__.py
Normal file
57
src/superqt/__init__.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""superqt is a collection of Qt components for python."""
|
||||||
|
from importlib.metadata import PackageNotFoundError, version
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
try:
|
||||||
|
__version__ = version("superqt")
|
||||||
|
except PackageNotFoundError:
|
||||||
|
__version__ = "unknown"
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .spinbox._quantity import QQuantity
|
||||||
|
|
||||||
|
from .collapsible import QCollapsible
|
||||||
|
from .combobox import QEnumComboBox, QSearchableComboBox
|
||||||
|
from .elidable import QElidingLabel, QElidingLineEdit
|
||||||
|
from .selection import QSearchableListWidget, QSearchableTreeWidget
|
||||||
|
from .sliders import (
|
||||||
|
QDoubleRangeSlider,
|
||||||
|
QDoubleSlider,
|
||||||
|
QLabeledDoubleRangeSlider,
|
||||||
|
QLabeledDoubleSlider,
|
||||||
|
QLabeledRangeSlider,
|
||||||
|
QLabeledSlider,
|
||||||
|
QRangeSlider,
|
||||||
|
)
|
||||||
|
from .spinbox import QLargeIntSpinBox
|
||||||
|
from .utils import QMessageHandler, ensure_main_thread, ensure_object_thread
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ensure_main_thread",
|
||||||
|
"ensure_object_thread",
|
||||||
|
"QDoubleRangeSlider",
|
||||||
|
"QCollapsible",
|
||||||
|
"QDoubleSlider",
|
||||||
|
"QElidingLabel",
|
||||||
|
"QElidingLineEdit",
|
||||||
|
"QEnumComboBox",
|
||||||
|
"QLabeledDoubleRangeSlider",
|
||||||
|
"QLabeledDoubleSlider",
|
||||||
|
"QLabeledRangeSlider",
|
||||||
|
"QLabeledSlider",
|
||||||
|
"QLargeIntSpinBox",
|
||||||
|
"QMessageHandler",
|
||||||
|
"QQuantity",
|
||||||
|
"QRangeSlider",
|
||||||
|
"QSearchableComboBox",
|
||||||
|
"QSearchableListWidget",
|
||||||
|
"QSearchableTreeWidget",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str) -> Any:
|
||||||
|
if name == "QQuantity":
|
||||||
|
from .spinbox._quantity import QQuantity
|
||||||
|
|
||||||
|
return QQuantity
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
3
src/superqt/collapsible/__init__.py
Normal file
3
src/superqt/collapsible/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from ._collapsible import QCollapsible
|
||||||
|
|
||||||
|
__all__ = ["QCollapsible"]
|
212
src/superqt/collapsible/_collapsible.py
Normal file
212
src/superqt/collapsible/_collapsible.py
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
"""A collapsible widget to hide and unhide child widgets."""
|
||||||
|
from typing import Optional, Union
|
||||||
|
|
||||||
|
from qtpy.QtCore import (
|
||||||
|
QEasingCurve,
|
||||||
|
QEvent,
|
||||||
|
QMargins,
|
||||||
|
QObject,
|
||||||
|
QPropertyAnimation,
|
||||||
|
QRect,
|
||||||
|
Qt,
|
||||||
|
Signal,
|
||||||
|
)
|
||||||
|
from qtpy.QtGui import QIcon, QPainter, QPalette, QPixmap
|
||||||
|
from qtpy.QtWidgets import QFrame, QPushButton, QVBoxLayout, QWidget
|
||||||
|
|
||||||
|
|
||||||
|
class QCollapsible(QFrame):
|
||||||
|
"""A collapsible widget to hide and unhide child widgets.
|
||||||
|
|
||||||
|
A signal is emitted when the widget is expanded (True) or collapsed (False).
|
||||||
|
|
||||||
|
Based on https://stackoverflow.com/a/68141638
|
||||||
|
"""
|
||||||
|
|
||||||
|
toggled = Signal(bool)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
title: str = "",
|
||||||
|
parent: Optional[QWidget] = None,
|
||||||
|
expandedIcon: Optional[Union[QIcon, str]] = "▼",
|
||||||
|
collapsedIcon: Optional[Union[QIcon, str]] = "▲",
|
||||||
|
):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._locked = False
|
||||||
|
self._is_animating = False
|
||||||
|
self._text = title
|
||||||
|
|
||||||
|
self._toggle_btn = QPushButton(title)
|
||||||
|
self._toggle_btn.setCheckable(True)
|
||||||
|
self.setCollapsedIcon(icon=collapsedIcon)
|
||||||
|
self.setExpandedIcon(icon=expandedIcon)
|
||||||
|
self._toggle_btn.setStyleSheet("text-align: left; border: none; outline: none;")
|
||||||
|
self._toggle_btn.toggled.connect(self._toggle)
|
||||||
|
|
||||||
|
# frame layout
|
||||||
|
self.setLayout(QVBoxLayout())
|
||||||
|
self.layout().setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||||
|
self.layout().addWidget(self._toggle_btn)
|
||||||
|
|
||||||
|
# Create animators
|
||||||
|
self._animation = QPropertyAnimation(self)
|
||||||
|
self._animation.setPropertyName(b"maximumHeight")
|
||||||
|
self._animation.setStartValue(0)
|
||||||
|
self._animation.finished.connect(self._on_animation_done)
|
||||||
|
self.setDuration(300)
|
||||||
|
self.setEasingCurve(QEasingCurve.Type.InOutCubic)
|
||||||
|
|
||||||
|
# default content widget
|
||||||
|
_content = QWidget()
|
||||||
|
_content.setLayout(QVBoxLayout())
|
||||||
|
_content.setMaximumHeight(0)
|
||||||
|
_content.layout().setContentsMargins(QMargins(5, 0, 0, 0))
|
||||||
|
self.setContent(_content)
|
||||||
|
|
||||||
|
def setText(self, text: str) -> None:
|
||||||
|
"""Set the text of the toggle button."""
|
||||||
|
current = self._toggle_btn.text()
|
||||||
|
self._toggle_btn.setText(current + text)
|
||||||
|
|
||||||
|
def text(self) -> str:
|
||||||
|
"""Return the text of the toggle button."""
|
||||||
|
return self._toggle_btn.text()
|
||||||
|
|
||||||
|
def setContent(self, content: QWidget) -> None:
|
||||||
|
"""Replace central widget (the widget that gets expanded/collapsed)."""
|
||||||
|
self._content = content
|
||||||
|
self.layout().addWidget(self._content)
|
||||||
|
self._animation.setTargetObject(content)
|
||||||
|
|
||||||
|
def content(self) -> QWidget:
|
||||||
|
"""Return the current content widget."""
|
||||||
|
return self._content
|
||||||
|
|
||||||
|
def _convert_string_to_icon(self, symbol: str) -> QIcon:
|
||||||
|
"""Create a QIcon from a string."""
|
||||||
|
size = self._toggle_btn.font().pointSize()
|
||||||
|
pixmap = QPixmap(size, size)
|
||||||
|
pixmap.fill(Qt.GlobalColor.transparent)
|
||||||
|
painter = QPainter(pixmap)
|
||||||
|
color = self._toggle_btn.palette().color(QPalette.ColorRole.WindowText)
|
||||||
|
painter.setPen(color)
|
||||||
|
painter.drawText(QRect(0, 0, size, size), Qt.AlignmentFlag.AlignCenter, symbol)
|
||||||
|
painter.end()
|
||||||
|
return QIcon(pixmap)
|
||||||
|
|
||||||
|
def expandedIcon(self) -> QIcon:
|
||||||
|
"""Returns the icon used when the widget is expanded."""
|
||||||
|
return self._expanded_icon
|
||||||
|
|
||||||
|
def setExpandedIcon(self, icon: Optional[Union[QIcon, str]] = None) -> None:
|
||||||
|
"""Set the icon on the toggle button when the widget is expanded."""
|
||||||
|
if icon and isinstance(icon, QIcon):
|
||||||
|
self._expanded_icon = icon
|
||||||
|
elif icon and isinstance(icon, str):
|
||||||
|
self._expanded_icon = self._convert_string_to_icon(icon)
|
||||||
|
|
||||||
|
if self.isExpanded():
|
||||||
|
self._toggle_btn.setIcon(self._expanded_icon)
|
||||||
|
|
||||||
|
def collapsedIcon(self) -> QIcon:
|
||||||
|
"""Returns the icon used when the widget is collapsed."""
|
||||||
|
return self._collapsed_icon
|
||||||
|
|
||||||
|
def setCollapsedIcon(self, icon: Optional[Union[QIcon, str]] = None) -> None:
|
||||||
|
"""Set the icon on the toggle button when the widget is collapsed."""
|
||||||
|
if icon and isinstance(icon, QIcon):
|
||||||
|
self._collapsed_icon = icon
|
||||||
|
elif icon and isinstance(icon, str):
|
||||||
|
self._collapsed_icon = self._convert_string_to_icon(icon)
|
||||||
|
|
||||||
|
if not self.isExpanded():
|
||||||
|
self._toggle_btn.setIcon(self._collapsed_icon)
|
||||||
|
|
||||||
|
def setDuration(self, msecs: int) -> None:
|
||||||
|
"""Set duration of the collapse/expand animation."""
|
||||||
|
self._animation.setDuration(msecs)
|
||||||
|
|
||||||
|
def setEasingCurve(self, easing: QEasingCurve) -> None:
|
||||||
|
"""Set the easing curve for the collapse/expand animation."""
|
||||||
|
self._animation.setEasingCurve(easing)
|
||||||
|
|
||||||
|
def addWidget(self, widget: QWidget) -> None:
|
||||||
|
"""Add a widget to the central content widget's layout."""
|
||||||
|
widget.installEventFilter(self)
|
||||||
|
self._content.layout().addWidget(widget)
|
||||||
|
|
||||||
|
def removeWidget(self, widget: QWidget) -> None:
|
||||||
|
"""Remove widget from the central content widget's layout."""
|
||||||
|
self._content.layout().removeWidget(widget)
|
||||||
|
widget.removeEventFilter(self)
|
||||||
|
|
||||||
|
def expand(self, animate: bool = True) -> None:
|
||||||
|
"""Expand (show) the collapsible section."""
|
||||||
|
self._expand_collapse(QPropertyAnimation.Direction.Forward, animate)
|
||||||
|
|
||||||
|
def collapse(self, animate: bool = True) -> None:
|
||||||
|
"""Collapse (hide) the collapsible section."""
|
||||||
|
self._expand_collapse(QPropertyAnimation.Direction.Backward, animate)
|
||||||
|
|
||||||
|
def isExpanded(self) -> bool:
|
||||||
|
"""Return whether the collapsible section is visible."""
|
||||||
|
return self._toggle_btn.isChecked()
|
||||||
|
|
||||||
|
def setLocked(self, locked: bool = True) -> None:
|
||||||
|
"""Set whether collapse/expand is disabled."""
|
||||||
|
self._locked = locked
|
||||||
|
self._toggle_btn.setCheckable(not locked)
|
||||||
|
|
||||||
|
def locked(self) -> bool:
|
||||||
|
"""Return True if collapse/expand is disabled."""
|
||||||
|
return self._locked
|
||||||
|
|
||||||
|
def _expand_collapse(
|
||||||
|
self,
|
||||||
|
direction: QPropertyAnimation.Direction,
|
||||||
|
animate: bool = True,
|
||||||
|
emit: bool = True,
|
||||||
|
) -> None:
|
||||||
|
"""Set values for the widget based on whether it is expanding or collapsing.
|
||||||
|
|
||||||
|
An emit flag is included so that the toggle signal is only called once (it
|
||||||
|
was being emitted a few times via eventFilter when the widget was expanding
|
||||||
|
previously).
|
||||||
|
"""
|
||||||
|
if self._locked:
|
||||||
|
return
|
||||||
|
|
||||||
|
forward = direction == QPropertyAnimation.Direction.Forward
|
||||||
|
icon = self._expanded_icon if forward else self._collapsed_icon
|
||||||
|
self._toggle_btn.setIcon(icon)
|
||||||
|
self._toggle_btn.setChecked(forward)
|
||||||
|
|
||||||
|
_content_height = self._content.sizeHint().height() + 10
|
||||||
|
if animate:
|
||||||
|
self._animation.setDirection(direction)
|
||||||
|
self._animation.setEndValue(_content_height)
|
||||||
|
self._is_animating = True
|
||||||
|
self._animation.start()
|
||||||
|
else:
|
||||||
|
self._content.setMaximumHeight(_content_height if forward else 0)
|
||||||
|
if emit:
|
||||||
|
self.toggled.emit(direction == QPropertyAnimation.Direction.Forward)
|
||||||
|
|
||||||
|
def _toggle(self) -> None:
|
||||||
|
self.expand() if self.isExpanded() else self.collapse()
|
||||||
|
|
||||||
|
def eventFilter(self, a0: QObject, a1: QEvent) -> bool:
|
||||||
|
"""If a child widget resizes, we need to update our expanded height."""
|
||||||
|
if (
|
||||||
|
a1.type() == QEvent.Type.Resize
|
||||||
|
and self.isExpanded()
|
||||||
|
and not self._is_animating
|
||||||
|
):
|
||||||
|
self._expand_collapse(
|
||||||
|
QPropertyAnimation.Direction.Forward, animate=False, emit=False
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _on_animation_done(self) -> None:
|
||||||
|
self._is_animating = False
|
4
src/superqt/combobox/__init__.py
Normal file
4
src/superqt/combobox/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from ._enum_combobox import QEnumComboBox
|
||||||
|
from ._searchable_combo_box import QSearchableComboBox
|
||||||
|
|
||||||
|
__all__ = ("QEnumComboBox", "QSearchableComboBox")
|
113
src/superqt/combobox/_enum_combobox.py
Normal file
113
src/superqt/combobox/_enum_combobox.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
from enum import Enum, EnumMeta
|
||||||
|
from typing import Optional, TypeVar
|
||||||
|
|
||||||
|
from qtpy.QtCore import Signal
|
||||||
|
from qtpy.QtWidgets import QComboBox
|
||||||
|
|
||||||
|
EnumType = TypeVar("EnumType", bound=Enum)
|
||||||
|
|
||||||
|
|
||||||
|
NONE_STRING = "----"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_name(enum_value: Enum):
|
||||||
|
"""Create human readable name if user does not implement `__str__`."""
|
||||||
|
if (
|
||||||
|
enum_value.__str__.__module__ != "enum"
|
||||||
|
and not enum_value.__str__.__module__.startswith("shibokensupport")
|
||||||
|
):
|
||||||
|
# check if function was overloaded
|
||||||
|
name = str(enum_value)
|
||||||
|
else:
|
||||||
|
name = enum_value.name.replace("_", " ")
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
class QEnumComboBox(QComboBox):
|
||||||
|
"""ComboBox presenting options from a python Enum.
|
||||||
|
|
||||||
|
If the Enum class does not implement `__str__` then a human readable name
|
||||||
|
is created from the name of the enum member, replacing underscores with spaces.
|
||||||
|
"""
|
||||||
|
|
||||||
|
currentEnumChanged = Signal(object)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, parent=None, enum_class: Optional[EnumMeta] = None, allow_none=False
|
||||||
|
):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._enum_class = None
|
||||||
|
self._allow_none = False
|
||||||
|
if enum_class is not None:
|
||||||
|
self.setEnumClass(enum_class, allow_none)
|
||||||
|
self.currentIndexChanged.connect(self._emit_signal)
|
||||||
|
|
||||||
|
def setEnumClass(self, enum: Optional[EnumMeta], allow_none=False):
|
||||||
|
"""Set enum class from which members value should be selected."""
|
||||||
|
self.clear()
|
||||||
|
self._enum_class = enum
|
||||||
|
self._allow_none = allow_none and enum is not None
|
||||||
|
if allow_none:
|
||||||
|
super().addItem(NONE_STRING)
|
||||||
|
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(
|
||||||
|
"setValue(self, Enum): argument 1 has unexpected type "
|
||||||
|
f"{type(value).__name__!r}"
|
||||||
|
)
|
||||||
|
self.setCurrentText(_get_name(value))
|
||||||
|
|
||||||
|
def _emit_signal(self):
|
||||||
|
if self._enum_class is not None:
|
||||||
|
self.currentEnumChanged.emit(self.currentEnum())
|
||||||
|
|
||||||
|
def insertItems(self, *_, **__):
|
||||||
|
raise RuntimeError("EnumComboBox does not allow to insert items")
|
||||||
|
|
||||||
|
def insertItem(self, *_, **__):
|
||||||
|
raise RuntimeError("EnumComboBox does not allow to insert item")
|
||||||
|
|
||||||
|
def addItems(self, *_, **__):
|
||||||
|
raise RuntimeError("EnumComboBox does not allow to add items")
|
||||||
|
|
||||||
|
def addItem(self, *_, **__):
|
||||||
|
raise RuntimeError("EnumComboBox does not allow to add item")
|
||||||
|
|
||||||
|
def setInsertPolicy(self, policy):
|
||||||
|
raise RuntimeError("EnumComboBox does not allow to insert item")
|
48
src/superqt/combobox/_searchable_combo_box.py
Normal file
48
src/superqt/combobox/_searchable_combo_box.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from qtpy import QT_VERSION
|
||||||
|
from qtpy.QtCore import Qt, Signal
|
||||||
|
from qtpy.QtWidgets import QComboBox, QCompleter, QWidget
|
||||||
|
|
||||||
|
try:
|
||||||
|
is_qt_bellow_5_14 = tuple(int(x) for x in QT_VERSION.split(".")[:2]) < (5, 14)
|
||||||
|
except ValueError:
|
||||||
|
is_qt_bellow_5_14 = False
|
||||||
|
|
||||||
|
|
||||||
|
class QSearchableComboBox(QComboBox):
|
||||||
|
"""ComboCox with completer for fast search in multiple options."""
|
||||||
|
|
||||||
|
if is_qt_bellow_5_14:
|
||||||
|
textActivated = Signal(str) # pragma: no cover
|
||||||
|
|
||||||
|
def __init__(self, parent: Optional[QWidget] = None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setEditable(True)
|
||||||
|
self.completer_object = QCompleter()
|
||||||
|
self.completer_object.setCaseSensitivity(Qt.CaseInsensitive)
|
||||||
|
self.completer_object.setCompletionMode(QCompleter.PopupCompletion)
|
||||||
|
self.completer_object.setFilterMode(Qt.MatchContains)
|
||||||
|
self.setCompleter(self.completer_object)
|
||||||
|
self.setInsertPolicy(QComboBox.NoInsert)
|
||||||
|
if is_qt_bellow_5_14: # pragma: no cover
|
||||||
|
self.currentIndexChanged.connect(self._text_activated)
|
||||||
|
|
||||||
|
def _text_activated(self): # pragma: no cover
|
||||||
|
self.textActivated.emit(self.currentText())
|
||||||
|
|
||||||
|
def addItem(self, *args):
|
||||||
|
super().addItem(*args)
|
||||||
|
self.completer_object.setModel(self.model())
|
||||||
|
|
||||||
|
def addItems(self, *args):
|
||||||
|
super().addItems(*args)
|
||||||
|
self.completer_object.setModel(self.model())
|
||||||
|
|
||||||
|
def insertItem(self, *args) -> None:
|
||||||
|
super().insertItem(*args)
|
||||||
|
self.completer_object.setModel(self.model())
|
||||||
|
|
||||||
|
def insertItems(self, *args) -> None:
|
||||||
|
super().insertItems(*args)
|
||||||
|
self.completer_object.setModel(self.model())
|
4
src/superqt/elidable/__init__.py
Normal file
4
src/superqt/elidable/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from ._eliding_label import QElidingLabel
|
||||||
|
from ._eliding_line_edit import QElidingLineEdit
|
||||||
|
|
||||||
|
__all__ = ["QElidingLabel", "QElidingLineEdit"]
|
78
src/superqt/elidable/_eliding.py
Normal file
78
src/superqt/elidable/_eliding.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
from typing import List
|
||||||
|
|
||||||
|
from qtpy.QtCore import Qt
|
||||||
|
from qtpy.QtGui import QFont, QFontMetrics, QTextLayout
|
||||||
|
|
||||||
|
|
||||||
|
class _GenericEliding:
|
||||||
|
"""A mixin to provide capabilities to elide text (could add '…') to fit width."""
|
||||||
|
|
||||||
|
_elide_mode: Qt.TextElideMode = Qt.TextElideMode.ElideRight
|
||||||
|
_text: str = ""
|
||||||
|
# the 2 is a magic number that prevents the ellipses from going missing
|
||||||
|
# in certain cases (?)
|
||||||
|
_ellipses_width: int = 2
|
||||||
|
|
||||||
|
# Public methods
|
||||||
|
|
||||||
|
def elideMode(self) -> Qt.TextElideMode:
|
||||||
|
"""The current Qt.TextElideMode."""
|
||||||
|
return self._elide_mode
|
||||||
|
|
||||||
|
def setElideMode(self, mode: Qt.TextElideMode) -> None:
|
||||||
|
"""Set the elide mode to a Qt.TextElideMode."""
|
||||||
|
self._elide_mode = Qt.TextElideMode(mode)
|
||||||
|
|
||||||
|
def full_text(self) -> str:
|
||||||
|
"""The current text without eliding."""
|
||||||
|
return self._text
|
||||||
|
|
||||||
|
def setEllipsesWidth(self, width: int) -> None:
|
||||||
|
"""A width value to take into account ellipses width when eliding text.
|
||||||
|
|
||||||
|
The value is deducted from the widget width when computing the elided version
|
||||||
|
of the text.
|
||||||
|
"""
|
||||||
|
self._ellipses_width = width
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def wrapText(text, width, font=None) -> List[str]:
|
||||||
|
"""Returns `text`, split as it would be wrapped for `width`, given `font`.
|
||||||
|
|
||||||
|
Static method.
|
||||||
|
"""
|
||||||
|
tl = QTextLayout(text, font or QFont())
|
||||||
|
tl.beginLayout()
|
||||||
|
lines = []
|
||||||
|
while True:
|
||||||
|
ln = tl.createLine()
|
||||||
|
if not ln.isValid():
|
||||||
|
break
|
||||||
|
ln.setLineWidth(width)
|
||||||
|
start = ln.textStart()
|
||||||
|
lines.append(text[start : start + ln.textLength()])
|
||||||
|
tl.endLayout()
|
||||||
|
return lines
|
||||||
|
|
||||||
|
# private implementation methods
|
||||||
|
|
||||||
|
def _elidedText(self) -> str:
|
||||||
|
"""Return `self._text` elided to `width`."""
|
||||||
|
fm = QFontMetrics(self.font())
|
||||||
|
ellipses_width = 0
|
||||||
|
if self._elide_mode != Qt.TextElideMode.ElideNone:
|
||||||
|
ellipses_width = self._ellipses_width
|
||||||
|
width = self.width() - ellipses_width
|
||||||
|
if not getattr(self, "wordWrap", None) or not self.wordWrap():
|
||||||
|
return fm.elidedText(self._text, self._elide_mode, width)
|
||||||
|
|
||||||
|
# get number of lines we can fit without eliding
|
||||||
|
nlines = self.height() // fm.height() - 1
|
||||||
|
# get the last line (elided)
|
||||||
|
text = self._wrappedText()
|
||||||
|
last_line = fm.elidedText("".join(text[nlines:]), self._elide_mode, width)
|
||||||
|
# join them
|
||||||
|
return "".join(text[:nlines] + [last_line])
|
||||||
|
|
||||||
|
def _wrappedText(self) -> List[str]:
|
||||||
|
return _GenericEliding.wrapText(self._text, self.width(), self.font())
|
75
src/superqt/elidable/_eliding_label.py
Normal file
75
src/superqt/elidable/_eliding_label.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
from qtpy.QtCore import QPoint, QRect, QSize, Qt
|
||||||
|
from qtpy.QtGui import QFontMetrics, QResizeEvent
|
||||||
|
from qtpy.QtWidgets import QLabel
|
||||||
|
|
||||||
|
from ._eliding import _GenericEliding
|
||||||
|
|
||||||
|
|
||||||
|
class QElidingLabel(_GenericEliding, QLabel):
|
||||||
|
"""
|
||||||
|
A QLabel variant that will elide text (could add '…') to fit width.
|
||||||
|
|
||||||
|
QElidingLabel()
|
||||||
|
QElidingLabel(parent: Optional[QWidget], f: Qt.WindowFlags = ...)
|
||||||
|
QElidingLabel(text: str, parent: Optional[QWidget] = None, f: Qt.WindowFlags = ...)
|
||||||
|
|
||||||
|
For a multiline eliding label, use `setWordWrap(True)`. In this case, text
|
||||||
|
will wrap to fit the width, and only the last line will be elided.
|
||||||
|
When `wordWrap()` is True, `sizeHint()` will return the size required to fit
|
||||||
|
the full text.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
if args and isinstance(args[0], str):
|
||||||
|
self.setText(args[0])
|
||||||
|
|
||||||
|
# Reimplemented _GenericEliding methods
|
||||||
|
|
||||||
|
def setElideMode(self, mode: Qt.TextElideMode) -> None:
|
||||||
|
"""Set the elide mode to a Qt.TextElideMode."""
|
||||||
|
super().setElideMode(mode)
|
||||||
|
super().setText(self._elidedText())
|
||||||
|
|
||||||
|
def setEllipsesWidth(self, width: int) -> None:
|
||||||
|
"""A width value to take into account ellipses width when eliding text.
|
||||||
|
|
||||||
|
The value is deducted from the widget width when computing the elided version
|
||||||
|
of the text.
|
||||||
|
"""
|
||||||
|
super().setEllipsesWidth(width)
|
||||||
|
super().setText(self._elidedText())
|
||||||
|
|
||||||
|
# Reimplemented QT methods
|
||||||
|
|
||||||
|
def text(self) -> str:
|
||||||
|
"""Return the label's text.
|
||||||
|
|
||||||
|
If no text has been set this will return an empty string.
|
||||||
|
"""
|
||||||
|
return self._text
|
||||||
|
|
||||||
|
def setText(self, txt: str) -> None:
|
||||||
|
"""Set the label's text.
|
||||||
|
|
||||||
|
Setting the text clears any previous content.
|
||||||
|
NOTE: we set the QLabel private text to the elided version
|
||||||
|
"""
|
||||||
|
self._text = txt
|
||||||
|
super().setText(self._elidedText())
|
||||||
|
|
||||||
|
def resizeEvent(self, event: QResizeEvent) -> None:
|
||||||
|
event.accept()
|
||||||
|
super().setText(self._elidedText())
|
||||||
|
|
||||||
|
def setWordWrap(self, wrap: bool) -> None:
|
||||||
|
super().setWordWrap(wrap)
|
||||||
|
super().setText(self._elidedText())
|
||||||
|
|
||||||
|
def sizeHint(self) -> QSize:
|
||||||
|
if not self.wordWrap():
|
||||||
|
return super().sizeHint()
|
||||||
|
fm = QFontMetrics(self.font())
|
||||||
|
flags = int(self.alignment() | Qt.TextFlag.TextWordWrap)
|
||||||
|
r = fm.boundingRect(QRect(QPoint(0, 0), self.size()), flags, self._text)
|
||||||
|
return QSize(self.width(), r.height())
|
91
src/superqt/elidable/_eliding_line_edit.py
Normal file
91
src/superqt/elidable/_eliding_line_edit.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
from qtpy.QtCore import Qt
|
||||||
|
from qtpy.QtGui import QFocusEvent, QResizeEvent
|
||||||
|
from qtpy.QtWidgets import QLineEdit
|
||||||
|
|
||||||
|
from ._eliding import _GenericEliding
|
||||||
|
|
||||||
|
|
||||||
|
class QElidingLineEdit(_GenericEliding, QLineEdit):
|
||||||
|
"""A QLineEdit variant that will elide text (could add '…') to fit width.
|
||||||
|
|
||||||
|
QElidingLineEdit()
|
||||||
|
QElidingLineEdit(parent: Optional[QWidget])
|
||||||
|
QElidingLineEdit(text: str, parent: Optional[QWidget] = None)
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
if args and isinstance(args[0], str):
|
||||||
|
self.setText(args[0])
|
||||||
|
# The `textEdited` signal doesn't trigger the `textChanged` signal if
|
||||||
|
# text is changed with `setText`, so we connect to `textEdited` to only
|
||||||
|
# update _text when text is being edited by the user graphically.
|
||||||
|
self.textEdited.connect(self._update_text)
|
||||||
|
|
||||||
|
# Reimplemented _GenericEliding methods
|
||||||
|
|
||||||
|
def setElideMode(self, mode: Qt.TextElideMode) -> None:
|
||||||
|
"""Set the elide mode to a Qt.TextElideMode.
|
||||||
|
|
||||||
|
The text shown is updated to the elided version only if the widget is not
|
||||||
|
focused.
|
||||||
|
"""
|
||||||
|
super().setElideMode(mode)
|
||||||
|
if not self.hasFocus():
|
||||||
|
super().setText(self._elidedText())
|
||||||
|
|
||||||
|
def setEllipsesWidth(self, width: int) -> None:
|
||||||
|
"""A width value to take into account ellipses width when eliding text.
|
||||||
|
|
||||||
|
The value is deducted from the widget width when computing the elided version
|
||||||
|
of the text. The text shown is updated to the elided version only if the widget
|
||||||
|
is not focused.
|
||||||
|
"""
|
||||||
|
super().setEllipsesWidth(width)
|
||||||
|
if not self.hasFocus():
|
||||||
|
super().setText(self._elidedText())
|
||||||
|
|
||||||
|
# Reimplemented QT methods
|
||||||
|
|
||||||
|
def text(self) -> str:
|
||||||
|
"""Return the label's text being shown.
|
||||||
|
|
||||||
|
If no text has been set this will return an empty string.
|
||||||
|
"""
|
||||||
|
return self._text
|
||||||
|
|
||||||
|
def setText(self, text) -> None:
|
||||||
|
"""Set the line edit's text.
|
||||||
|
|
||||||
|
Setting the text clears any previous content.
|
||||||
|
NOTE: we set the QLineEdit private text to the elided version
|
||||||
|
"""
|
||||||
|
self._text = text
|
||||||
|
if not self.hasFocus():
|
||||||
|
super().setText(self._elidedText())
|
||||||
|
|
||||||
|
def focusInEvent(self, event: QFocusEvent) -> None:
|
||||||
|
"""Set the full text when the widget is focused."""
|
||||||
|
super().setText(self._text)
|
||||||
|
super().focusInEvent(event)
|
||||||
|
|
||||||
|
def focusOutEvent(self, event: QFocusEvent) -> None:
|
||||||
|
"""Set an elided version of the text (if needed) when the focus is out."""
|
||||||
|
super().setText(self._elidedText())
|
||||||
|
super().focusOutEvent(event)
|
||||||
|
|
||||||
|
def resizeEvent(self, event: QResizeEvent) -> None:
|
||||||
|
"""Update elided text being shown when the widget is resized."""
|
||||||
|
if not self.hasFocus():
|
||||||
|
super().setText(self._elidedText())
|
||||||
|
super().resizeEvent(event)
|
||||||
|
|
||||||
|
# private implementation methods
|
||||||
|
|
||||||
|
def _update_text(self, text: str) -> None:
|
||||||
|
"""Update only the actual text of the widget.
|
||||||
|
|
||||||
|
The actual text is the text the widget has without eliding.
|
||||||
|
"""
|
||||||
|
self._text = text
|
225
src/superqt/fonticon/__init__.py
Normal file
225
src/superqt/fonticon/__init__.py
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"addFont",
|
||||||
|
"Animation",
|
||||||
|
"ENTRY_POINT",
|
||||||
|
"font",
|
||||||
|
"icon",
|
||||||
|
"IconFont",
|
||||||
|
"IconFontMeta",
|
||||||
|
"IconOpts",
|
||||||
|
"pulse",
|
||||||
|
"setTextIcon",
|
||||||
|
"spin",
|
||||||
|
]
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from ._animations import Animation, pulse, spin
|
||||||
|
from ._iconfont import IconFont, IconFontMeta
|
||||||
|
from ._plugins import FontIconManager as _FIM
|
||||||
|
from ._qfont_icon import DEFAULT_SCALING_FACTOR, IconOptionDict, IconOpts
|
||||||
|
from ._qfont_icon import QFontIconStore as _QFIS
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from qtpy.QtGui import QFont, QTransform
|
||||||
|
from qtpy.QtWidgets import QWidget
|
||||||
|
|
||||||
|
from ._qfont_icon import QFontIcon, ValidColor
|
||||||
|
|
||||||
|
ENTRY_POINT = _FIM.ENTRY_POINT
|
||||||
|
|
||||||
|
|
||||||
|
# FIXME: currently, an Animation requires a *pre-bound* QObject. which makes it very
|
||||||
|
# awkward to use animations when declaratively listing icons. It would be much better
|
||||||
|
# to have a way to find the widget later, to execute the animation... short of that, I
|
||||||
|
# think we should take animation off of `icon` here, and suggest that it be an
|
||||||
|
# an additional convenience method after the icon has been bound to a QObject.
|
||||||
|
def icon(
|
||||||
|
glyph_key: str,
|
||||||
|
scale_factor: float = DEFAULT_SCALING_FACTOR,
|
||||||
|
color: ValidColor | None = None,
|
||||||
|
opacity: float = 1,
|
||||||
|
animation: Animation | None = None,
|
||||||
|
transform: QTransform | None = None,
|
||||||
|
states: dict[str, IconOptionDict | IconOpts] | None = None,
|
||||||
|
) -> QFontIcon:
|
||||||
|
"""Create a QIcon for `glyph_key`, with a number of optional settings.
|
||||||
|
|
||||||
|
The `glyph_key` (e.g. 'fa5s.smile') represents a Font-family & style, and a glyph.
|
||||||
|
In most cases, the key should be provided by a plugin in the environment, like:
|
||||||
|
|
||||||
|
- [fonticon-fontawesome5](https://pypi.org/project/fonticon-fontawesome5/) ('fa5s' &
|
||||||
|
'fa5r' prefixes)
|
||||||
|
- [fonticon-materialdesignicons6](https://pypi.org/project/fonticon-materialdesignicons6/)
|
||||||
|
('mdi6' prefix)
|
||||||
|
|
||||||
|
...but fonts can also be added manually using [`addFont`][superqt.fonticon.addFont].
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
glyph_key : str
|
||||||
|
String encapsulating a font-family, style, and glyph. e.g. 'fa5s.smile'.
|
||||||
|
scale_factor : float, optional
|
||||||
|
Scale factor (fraction of widget height), When widget icon is painted on widget,
|
||||||
|
it will use `font.setPixelSize(round(wdg.height() * scale_factor))`.
|
||||||
|
by default 0.875.
|
||||||
|
color : ValidColor, optional
|
||||||
|
Color for the font, by default None. (e.g. The default `QColor`)
|
||||||
|
Valid color types include `QColor`, `int`, `str`, `Qt.GlobalColor`, `tuple` (of
|
||||||
|
integer: RGB[A]) (anything that can be passed to `QColor`).
|
||||||
|
opacity : float, optional
|
||||||
|
Opacity of icon, by default 1
|
||||||
|
animation : Animation, optional
|
||||||
|
Animation for the icon. A subclass of superqt.fonticon.Animation, that provides
|
||||||
|
a concrete `animate` method. (see "spin" and "pulse" for examples).
|
||||||
|
by default None.
|
||||||
|
transform : QTransform, optional
|
||||||
|
A `QTransform` to apply when painting the icon, by default None
|
||||||
|
states : dict, optional
|
||||||
|
Provide additional styling for the icon in different states. `states` must be
|
||||||
|
a mapping of string to dict, where:
|
||||||
|
|
||||||
|
- the key represents a `QIcon.State` ("on", "off"), a `QIcon.Mode` ("normal",
|
||||||
|
"active", "selected", "disabled"), or any combination of a state & mode
|
||||||
|
separated by an underscore (e.g. "off_active", "selected_on", etc...).
|
||||||
|
- the value is a dict with all of the same key/value meanings listed above as
|
||||||
|
parameters to this function (e.g. `glyph_key`, `color`,`scale_factor`,
|
||||||
|
`animation`, etc...)
|
||||||
|
|
||||||
|
Missing keys in the state dicts will be taken from the default options, provided
|
||||||
|
by the parameters above.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
QFontIcon
|
||||||
|
A subclass of QIcon. Can be used wherever QIcons are used, such as
|
||||||
|
`widget.setIcon()`
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
simple example (using the string `'fa5s.smile'` assumes the `fonticon-fontawesome5`
|
||||||
|
plugin is installed)
|
||||||
|
|
||||||
|
>>> btn = QPushButton()
|
||||||
|
>>> btn.setIcon(icon('fa5s.smile'))
|
||||||
|
|
||||||
|
can also directly import from fonticon_fa5
|
||||||
|
>>> from fonticon_fa5 import FA5S
|
||||||
|
>>> btn.setIcon(icon(FA5S.smile))
|
||||||
|
|
||||||
|
with animation
|
||||||
|
>>> btn2 = QPushButton()
|
||||||
|
>>> btn2.setIcon(icon(FA5S.spinner, animation=pulse(btn2)))
|
||||||
|
|
||||||
|
complicated example
|
||||||
|
>>> btn = QPushButton()
|
||||||
|
>>> btn.setIcon(
|
||||||
|
... icon(
|
||||||
|
... FA5S.ambulance,
|
||||||
|
... color="blue",
|
||||||
|
... states={
|
||||||
|
... "active": {
|
||||||
|
... "glyph": FA5S.bath,
|
||||||
|
... "color": "red",
|
||||||
|
... "scale_factor": 0.5,
|
||||||
|
... "animation": pulse(btn),
|
||||||
|
... },
|
||||||
|
... "disabled": {
|
||||||
|
... "color": "green",
|
||||||
|
... "scale_factor": 0.8,
|
||||||
|
... "animation": spin(btn)
|
||||||
|
... },
|
||||||
|
... },
|
||||||
|
... )
|
||||||
|
... )
|
||||||
|
>>> btn.setIconSize(QSize(256, 256))
|
||||||
|
>>> btn.show()
|
||||||
|
|
||||||
|
"""
|
||||||
|
return _QFIS.instance().icon(
|
||||||
|
glyph_key,
|
||||||
|
scale_factor=scale_factor,
|
||||||
|
color=color,
|
||||||
|
opacity=opacity,
|
||||||
|
animation=animation,
|
||||||
|
transform=transform,
|
||||||
|
states=states or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def setTextIcon(widget: QWidget, glyph_key: str, size: float | None = None) -> None:
|
||||||
|
"""Set text on a widget to a specific font & glyph.
|
||||||
|
|
||||||
|
This is an alternative to setting a QIcon with a pixmap. It may be easier to
|
||||||
|
combine with dynamic stylesheets.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
widget : QWidget
|
||||||
|
A widget supporting a `setText` method.
|
||||||
|
glyph_key : str
|
||||||
|
String encapsulating a font-family, style, and glyph. e.g. 'fa5s.smile'.
|
||||||
|
size : int, optional
|
||||||
|
Size for QFont. passed to `setPixelSize`, by default None
|
||||||
|
"""
|
||||||
|
return _QFIS.instance().setTextIcon(widget, glyph_key, size)
|
||||||
|
|
||||||
|
|
||||||
|
def font(font_prefix: str, size: int | None = None) -> QFont:
|
||||||
|
"""Create QFont for `font_prefix`.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
font_prefix : str
|
||||||
|
Font_prefix, such as 'fa5s' or 'mdi6', representing a font-family and style.
|
||||||
|
size : int, optional
|
||||||
|
Size for QFont. passed to `setPixelSize`, by default None
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
QFont
|
||||||
|
QFont instance that can be used to add fonticons to widgets.
|
||||||
|
"""
|
||||||
|
return _QFIS.instance().font(font_prefix, size)
|
||||||
|
|
||||||
|
|
||||||
|
def addFont(
|
||||||
|
filepath: str, prefix: str, charmap: dict[str, str] | None = None
|
||||||
|
) -> tuple[str, str] | None:
|
||||||
|
"""Add OTF/TTF file at `filepath` to the registry under `prefix`.
|
||||||
|
|
||||||
|
If you'd like to later use a fontkey in the form of `prefix.some-name`, then
|
||||||
|
`charmap` must be provided and provide a mapping for all of the glyph names
|
||||||
|
to their unicode numbers. If a charmap is not provided, glyphs must be directly
|
||||||
|
accessed with their unicode as something like `key.\uffff`.
|
||||||
|
|
||||||
|
!!! Note
|
||||||
|
in most cases, users will not need this. Instead, they should install a
|
||||||
|
font plugin, like:
|
||||||
|
|
||||||
|
- [fonticon-fontawesome5](https://pypi.org/project/fonticon-fontawesome5/)
|
||||||
|
- [fonticon-materialdesignicons6](https://pypi.org/project/fonticon-materialdesignicons6/)
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
filepath : str
|
||||||
|
Path to an OTF or TTF file containing the fonts
|
||||||
|
prefix : str
|
||||||
|
A prefix that will represent this font file when used for lookup. For example,
|
||||||
|
'fa5s' for 'Font-Awesome 5 Solid'.
|
||||||
|
charmap : Dict[str, str], optional
|
||||||
|
optional mapping for all of the glyph names to their unicode numbers.
|
||||||
|
See note above.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Tuple[str, str], optional
|
||||||
|
font-family and font-style for the file just registered, or `None` if
|
||||||
|
something goes wrong.
|
||||||
|
"""
|
||||||
|
return _QFIS.instance().addFont(filepath, prefix, charmap)
|
||||||
|
|
||||||
|
|
||||||
|
del DEFAULT_SCALING_FACTOR
|
47
src/superqt/fonticon/_animations.py
Normal file
47
src/superqt/fonticon/_animations.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from qtpy.QtCore import QRectF, QTimer
|
||||||
|
from qtpy.QtGui import QPainter
|
||||||
|
from qtpy.QtWidgets import QWidget
|
||||||
|
|
||||||
|
|
||||||
|
class Animation(ABC):
|
||||||
|
"""Base icon animation class."""
|
||||||
|
|
||||||
|
def __init__(self, parent_widget: QWidget, interval: int = 10, step: int = 1):
|
||||||
|
self.parent_widget = parent_widget
|
||||||
|
self.timer = QTimer()
|
||||||
|
self.timer.timeout.connect(self._update) # type: ignore
|
||||||
|
self.timer.setInterval(interval)
|
||||||
|
self._angle = 0
|
||||||
|
self._step = step
|
||||||
|
|
||||||
|
def _update(self):
|
||||||
|
if self.timer.isActive():
|
||||||
|
self._angle += self._step
|
||||||
|
self.parent_widget.update()
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def animate(self, painter: QPainter):
|
||||||
|
"""Setup and start the timer for the animation."""
|
||||||
|
|
||||||
|
|
||||||
|
class spin(Animation):
|
||||||
|
"""Animation that smoothly spins an icon."""
|
||||||
|
|
||||||
|
def animate(self, painter: QPainter):
|
||||||
|
if not self.timer.isActive():
|
||||||
|
self.timer.start()
|
||||||
|
|
||||||
|
mid = QRectF(painter.viewport()).center()
|
||||||
|
painter.translate(mid)
|
||||||
|
painter.rotate(self._angle % 360)
|
||||||
|
painter.translate(-mid)
|
||||||
|
|
||||||
|
|
||||||
|
class pulse(spin):
|
||||||
|
"""Animation that spins an icon in slower, discrete steps."""
|
||||||
|
|
||||||
|
def __init__(self, parent_widget: Optional[QWidget] = None):
|
||||||
|
super().__init__(parent_widget, interval=200, step=45)
|
88
src/superqt/fonticon/_iconfont.py
Normal file
88
src/superqt/fonticon/_iconfont.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
from typing import Mapping, Type, Union
|
||||||
|
|
||||||
|
FONTFILE_ATTR = "__font_file__"
|
||||||
|
|
||||||
|
|
||||||
|
class IconFontMeta(type):
|
||||||
|
"""IconFont metaclass.
|
||||||
|
|
||||||
|
This updates the value of all class attributes to be prefaced with the class
|
||||||
|
name (lowercase), and makes sure that all values are valid characters.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
This metaclass turns the following class:
|
||||||
|
|
||||||
|
class FA5S(metaclass=IconFontMeta):
|
||||||
|
__font_file__ = 'path/to/font.otf'
|
||||||
|
some_char = 0xfa42
|
||||||
|
|
||||||
|
into this:
|
||||||
|
|
||||||
|
class FA5S:
|
||||||
|
__font_file__ = path/to/font.otf'
|
||||||
|
some_char = 'fa5s.\ufa42'
|
||||||
|
|
||||||
|
In usage, this means that someone could use `icon(FA5S.some_char)` (provided
|
||||||
|
that the FA5S class/namespace has already been registered). This makes
|
||||||
|
IDE attribute checking and autocompletion easier.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__font_file__: str
|
||||||
|
|
||||||
|
def __new__(cls, name, bases, namespace, **kwargs):
|
||||||
|
# make sure this class provides the __font_file__ interface
|
||||||
|
ff = namespace.get(FONTFILE_ATTR)
|
||||||
|
if not (ff and isinstance(ff, (str, classmethod))):
|
||||||
|
raise TypeError(
|
||||||
|
f"Invalid Font: must declare {FONTFILE_ATTR!r} attribute or classmethod"
|
||||||
|
)
|
||||||
|
|
||||||
|
# update all values to be `key.unicode`
|
||||||
|
prefix = name.lower()
|
||||||
|
for k, v in list(namespace.items()):
|
||||||
|
if k.startswith("__"):
|
||||||
|
continue
|
||||||
|
char = chr(v) if isinstance(v, int) else v
|
||||||
|
if len(char) != 1:
|
||||||
|
raise TypeError(
|
||||||
|
"Invalid Font: All fonts values must be a single "
|
||||||
|
f"unicode char. ('{name}.{char}' has length {len(char)}). "
|
||||||
|
"You may use unicode representations: like '\\uf641' or '0xf641'"
|
||||||
|
)
|
||||||
|
namespace[k] = f"{prefix}.{char}"
|
||||||
|
|
||||||
|
return super().__new__(cls, name, bases, namespace, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class IconFont(metaclass=IconFontMeta):
|
||||||
|
"""Helper class that provides a standard way to create an IconFont.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
class FA5S(IconFont):
|
||||||
|
__font_file__ = '...'
|
||||||
|
some_char = 0xfa42
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ()
|
||||||
|
__font_file__ = "..."
|
||||||
|
|
||||||
|
|
||||||
|
def namespace2font(namespace: Union[Mapping, Type], name: str) -> Type[IconFont]:
|
||||||
|
"""Convenience to convert a namespace (class, module, dict) into an IconFont."""
|
||||||
|
if isinstance(namespace, type):
|
||||||
|
if not isinstance(getattr(namespace, FONTFILE_ATTR), str):
|
||||||
|
raise TypeError(
|
||||||
|
f"Invalid Font: must declare {FONTFILE_ATTR!r} attribute or classmethod"
|
||||||
|
)
|
||||||
|
return namespace
|
||||||
|
elif hasattr(namespace, "__dict__"):
|
||||||
|
ns = dict(namespace.__dict__)
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
"namespace must be a mapping or an object with __dict__ attribute."
|
||||||
|
)
|
||||||
|
if not str.isidentifier(name):
|
||||||
|
raise ValueError(f"name {name!r} is not a valid identifier.")
|
||||||
|
return type(name, (IconFont,), ns)
|
106
src/superqt/fonticon/_plugins.py
Normal file
106
src/superqt/fonticon/_plugins.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import contextlib
|
||||||
|
from typing import ClassVar, Dict, List, Set, Tuple
|
||||||
|
|
||||||
|
from ._iconfont import IconFontMeta, namespace2font
|
||||||
|
|
||||||
|
try:
|
||||||
|
from importlib.metadata import EntryPoint, entry_points
|
||||||
|
except ImportError:
|
||||||
|
from importlib_metadata import EntryPoint, entry_points # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
class FontIconManager:
|
||||||
|
ENTRY_POINT: ClassVar[str] = "superqt.fonticon"
|
||||||
|
_PLUGINS: ClassVar[Dict[str, EntryPoint]] = {}
|
||||||
|
_LOADED: ClassVar[Dict[str, IconFontMeta]] = {}
|
||||||
|
_BLOCKED: ClassVar[Set[EntryPoint]] = set()
|
||||||
|
|
||||||
|
def _discover_fonts(self) -> None:
|
||||||
|
self._PLUGINS.clear()
|
||||||
|
entries = entry_points()
|
||||||
|
if hasattr(entries, "select"): # python>3.10
|
||||||
|
_entries = entries.select(group=self.ENTRY_POINT) # type: ignore
|
||||||
|
else:
|
||||||
|
_entries = entries.get(self.ENTRY_POINT, [])
|
||||||
|
for ep in _entries:
|
||||||
|
if ep not in self._BLOCKED:
|
||||||
|
self._PLUGINS[ep.name] = ep
|
||||||
|
|
||||||
|
def _get_font_class(self, key: str) -> IconFontMeta:
|
||||||
|
"""Get IconFont given a key.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
key : str
|
||||||
|
font key to load.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
IconFontMeta
|
||||||
|
Instance of IconFontMeta
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
KeyError
|
||||||
|
If no plugin provides this key
|
||||||
|
ImportError
|
||||||
|
If a plugin provides the key, but the entry point doesn't load
|
||||||
|
TypeError
|
||||||
|
If the entry point loads, but is not an IconFontMeta
|
||||||
|
"""
|
||||||
|
if key not in self._LOADED:
|
||||||
|
# get the entrypoint
|
||||||
|
if key not in self._PLUGINS:
|
||||||
|
self._discover_fonts()
|
||||||
|
ep = self._PLUGINS.get(key)
|
||||||
|
if ep is None:
|
||||||
|
raise KeyError(f"No plugin provides the key {key!r}")
|
||||||
|
|
||||||
|
# load the entry point
|
||||||
|
try:
|
||||||
|
font = ep.load()
|
||||||
|
except Exception as e:
|
||||||
|
self._PLUGINS.pop(key)
|
||||||
|
self._BLOCKED.add(ep)
|
||||||
|
raise ImportError(f"Failed to load {ep.value}. Plugin blocked") from e
|
||||||
|
|
||||||
|
# make sure it's a proper IconFont
|
||||||
|
try:
|
||||||
|
self._LOADED[key] = namespace2font(font, ep.name.upper())
|
||||||
|
except Exception as e:
|
||||||
|
self._PLUGINS.pop(key)
|
||||||
|
self._BLOCKED.add(ep)
|
||||||
|
raise TypeError(
|
||||||
|
f"Failed to create fonticon from {ep.value}: {e}"
|
||||||
|
) from e
|
||||||
|
return self._LOADED[key]
|
||||||
|
|
||||||
|
def dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
key: sorted(filter(lambda x: not x.startswith("_"), cls.__dict__))
|
||||||
|
for key, cls in self._LOADED.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_manager = FontIconManager()
|
||||||
|
get_font_class = _manager._get_font_class
|
||||||
|
|
||||||
|
|
||||||
|
def discover() -> Tuple[str]:
|
||||||
|
_manager._discover_fonts()
|
||||||
|
|
||||||
|
|
||||||
|
def available() -> Tuple[str]:
|
||||||
|
return tuple(_manager._PLUGINS)
|
||||||
|
|
||||||
|
|
||||||
|
def loaded(load_all=False) -> Dict[str, List[str]]:
|
||||||
|
if load_all:
|
||||||
|
discover()
|
||||||
|
for x in available():
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
_manager._get_font_class(x)
|
||||||
|
return {
|
||||||
|
key: sorted(filter(lambda x: not x.startswith("_"), cls.__dict__))
|
||||||
|
for key, cls in _manager._LOADED.items()
|
||||||
|
}
|
577
src/superqt/fonticon/_qfont_icon.py
Normal file
577
src/superqt/fonticon/_qfont_icon.py
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import warnings
|
||||||
|
from collections import abc, defaultdict
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import ClassVar, DefaultDict, Sequence, Tuple, Union, cast
|
||||||
|
|
||||||
|
from qtpy import QT_VERSION
|
||||||
|
from qtpy.QtCore import QObject, QPoint, QRect, QSize, Qt
|
||||||
|
from qtpy.QtGui import (
|
||||||
|
QColor,
|
||||||
|
QFont,
|
||||||
|
QFontDatabase,
|
||||||
|
QGuiApplication,
|
||||||
|
QIcon,
|
||||||
|
QIconEngine,
|
||||||
|
QPainter,
|
||||||
|
QPixmap,
|
||||||
|
QPixmapCache,
|
||||||
|
QTransform,
|
||||||
|
)
|
||||||
|
from qtpy.QtWidgets import QApplication, QStyleOption, QWidget
|
||||||
|
from typing_extensions import TypedDict
|
||||||
|
|
||||||
|
from superqt.utils import QMessageHandler
|
||||||
|
|
||||||
|
from ._animations import Animation
|
||||||
|
|
||||||
|
|
||||||
|
class Unset:
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return "UNSET"
|
||||||
|
|
||||||
|
|
||||||
|
_Unset = Unset()
|
||||||
|
|
||||||
|
# A 16 pixel-high icon yields a font size of 14, which is pixel perfect
|
||||||
|
# for font-awesome. 16 * 0.875 = 14
|
||||||
|
# The reason why the glyph size is smaller than the icon size is to
|
||||||
|
# account for font bearing.
|
||||||
|
DEFAULT_SCALING_FACTOR = 0.875
|
||||||
|
DEFAULT_OPACITY = 1
|
||||||
|
ValidColor = Union[
|
||||||
|
QColor,
|
||||||
|
int,
|
||||||
|
str,
|
||||||
|
Qt.GlobalColor,
|
||||||
|
Tuple[int, int, int, int],
|
||||||
|
Tuple[int, int, int],
|
||||||
|
None,
|
||||||
|
]
|
||||||
|
|
||||||
|
StateOrMode = Union[QIcon.State, QIcon.Mode]
|
||||||
|
StateModeKey = Union[StateOrMode, str, Sequence[StateOrMode]]
|
||||||
|
_SM_MAP: dict[str, StateOrMode] = {
|
||||||
|
"on": QIcon.State.On,
|
||||||
|
"off": QIcon.State.Off,
|
||||||
|
"normal": QIcon.Mode.Normal,
|
||||||
|
"active": QIcon.Mode.Active,
|
||||||
|
"selected": QIcon.Mode.Selected,
|
||||||
|
"disabled": QIcon.Mode.Disabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_state_mode(key: StateModeKey) -> tuple[QIcon.State, QIcon.Mode]:
|
||||||
|
"""Return state/mode tuple given a variety of valid inputs.
|
||||||
|
|
||||||
|
Input can be either a string, or a sequence of state or mode enums.
|
||||||
|
Strings can be any combination of on, off, normal, active, selected, disabled,
|
||||||
|
sep by underscore.
|
||||||
|
"""
|
||||||
|
_sm: Sequence[StateOrMode]
|
||||||
|
if isinstance(key, str):
|
||||||
|
try:
|
||||||
|
_sm = [_SM_MAP[k.lower()] for k in key.split("_")]
|
||||||
|
except KeyError as e:
|
||||||
|
raise ValueError(
|
||||||
|
f"{key!r} is not a valid state key, must be a combination of {{on, "
|
||||||
|
"off, active, disabled, selected, normal} separated by underscore"
|
||||||
|
) from e
|
||||||
|
else:
|
||||||
|
_sm = key if isinstance(key, abc.Sequence) else [key]
|
||||||
|
|
||||||
|
state = next((i for i in _sm if isinstance(i, QIcon.State)), QIcon.State.Off)
|
||||||
|
mode = next((i for i in _sm if isinstance(i, QIcon.Mode)), QIcon.Mode.Normal)
|
||||||
|
return state, mode
|
||||||
|
|
||||||
|
|
||||||
|
class IconOptionDict(TypedDict, total=False):
|
||||||
|
glyph_key: str
|
||||||
|
scale_factor: float
|
||||||
|
color: ValidColor
|
||||||
|
opacity: float
|
||||||
|
animation: Animation | None
|
||||||
|
transform: QTransform | None
|
||||||
|
|
||||||
|
|
||||||
|
# public facing, for a nicer IDE experience than a dict
|
||||||
|
# The difference between IconOpts and _IconOptions is that all of IconOpts
|
||||||
|
# all default to `_Unset` and are intended to extend some base/default option
|
||||||
|
# IconOpts are *not* guaranteed to be fully capable of rendering an icon, whereas
|
||||||
|
# IconOptions are.
|
||||||
|
@dataclass
|
||||||
|
class IconOpts:
|
||||||
|
"""Options for rendering an icon.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
glyph_key : str, optional
|
||||||
|
The key of the glyph to use, e.g. `'fa5s.smile'`, by default `None`
|
||||||
|
scale_factor : float, optional
|
||||||
|
The scale factor to use, by default `None`
|
||||||
|
color : ValidColor, optional
|
||||||
|
The color to use, by default `None`. Colors may be specified as a string,
|
||||||
|
`QColor`, `Qt.GlobalColor`, or a 3 or 4-tuple of integers.
|
||||||
|
opacity : float, optional
|
||||||
|
The opacity to use, by default `None`
|
||||||
|
animation : Animation, optional
|
||||||
|
The animation to use, by default `None`
|
||||||
|
"""
|
||||||
|
|
||||||
|
glyph_key: str | Unset = _Unset
|
||||||
|
scale_factor: float | Unset = _Unset
|
||||||
|
color: ValidColor | Unset = _Unset
|
||||||
|
opacity: float | Unset = _Unset
|
||||||
|
animation: Animation | Unset | None = _Unset
|
||||||
|
transform: QTransform | Unset | None = _Unset
|
||||||
|
|
||||||
|
def dict(self) -> IconOptionDict:
|
||||||
|
# not using asdict due to pickle errors on animation
|
||||||
|
d = {k: v for k, v in vars(self).items() if v is not _Unset}
|
||||||
|
return cast(IconOptionDict, d)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _IconOptions:
|
||||||
|
"""The set of options needed to render a font in a single State/Mode."""
|
||||||
|
|
||||||
|
glyph_key: str
|
||||||
|
scale_factor: float = DEFAULT_SCALING_FACTOR
|
||||||
|
color: ValidColor = None
|
||||||
|
opacity: float = DEFAULT_OPACITY
|
||||||
|
animation: Animation | None = None
|
||||||
|
transform: QTransform | None = None
|
||||||
|
|
||||||
|
def _update(self, icon_opts: IconOpts) -> _IconOptions:
|
||||||
|
return _IconOptions(**{**vars(self), **icon_opts.dict()})
|
||||||
|
|
||||||
|
def dict(self) -> IconOptionDict:
|
||||||
|
# not using asdict due to pickle errors on animation
|
||||||
|
return cast(IconOptionDict, vars(self))
|
||||||
|
|
||||||
|
|
||||||
|
class _QFontIconEngine(QIconEngine):
|
||||||
|
_opt_hash: str = ""
|
||||||
|
|
||||||
|
def __init__(self, options: _IconOptions):
|
||||||
|
super().__init__()
|
||||||
|
self._opts: defaultdict[
|
||||||
|
QIcon.State, dict[QIcon.Mode, _IconOptions | None]
|
||||||
|
] = DefaultDict(dict)
|
||||||
|
self._opts[QIcon.State.Off][QIcon.Mode.Normal] = options
|
||||||
|
self.update_hash()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _default_opts(self) -> _IconOptions:
|
||||||
|
return cast(_IconOptions, self._opts[QIcon.State.Off][QIcon.Mode.Normal])
|
||||||
|
|
||||||
|
def _add_opts(self, state: QIcon.State, mode: QIcon.Mode, opts: IconOpts) -> None:
|
||||||
|
self._opts[state][mode] = self._default_opts._update(opts)
|
||||||
|
self.update_hash()
|
||||||
|
|
||||||
|
def clone(self) -> QIconEngine: # pragma: no cover
|
||||||
|
ico = _QFontIconEngine(self._default_opts)
|
||||||
|
ico._opts = self._opts.copy()
|
||||||
|
return ico
|
||||||
|
|
||||||
|
def _get_opts(self, state: QIcon.State, mode: QIcon.Mode) -> _IconOptions:
|
||||||
|
opts = self._opts[state].get(mode)
|
||||||
|
if opts:
|
||||||
|
return opts
|
||||||
|
|
||||||
|
opp_state = QIcon.State.Off if state == QIcon.State.On else QIcon.State.On
|
||||||
|
if mode in (QIcon.Mode.Disabled, QIcon.Mode.Selected):
|
||||||
|
opp_mode = (
|
||||||
|
QIcon.Mode.Disabled
|
||||||
|
if mode == QIcon.Mode.Selected
|
||||||
|
else QIcon.Mode.Selected
|
||||||
|
)
|
||||||
|
for m, s in [
|
||||||
|
(QIcon.Mode.Normal, state),
|
||||||
|
(QIcon.Mode.Active, state),
|
||||||
|
(mode, opp_state),
|
||||||
|
(QIcon.Mode.Normal, opp_state),
|
||||||
|
(QIcon.Mode.Active, opp_state),
|
||||||
|
(opp_mode, state),
|
||||||
|
(opp_mode, opp_state),
|
||||||
|
]:
|
||||||
|
opts = self._opts[s].get(m)
|
||||||
|
if opts:
|
||||||
|
return opts
|
||||||
|
else:
|
||||||
|
opp_mode = (
|
||||||
|
QIcon.Mode.Active if mode == QIcon.Mode.Normal else QIcon.Mode.Normal
|
||||||
|
)
|
||||||
|
for m, s in [
|
||||||
|
(opp_mode, state),
|
||||||
|
(mode, opp_state),
|
||||||
|
(opp_mode, opp_state),
|
||||||
|
(QIcon.Mode.Disabled, state),
|
||||||
|
(QIcon.Mode.Selected, state),
|
||||||
|
(QIcon.Mode.Disabled, opp_state),
|
||||||
|
(QIcon.Mode.Selected, opp_state),
|
||||||
|
]:
|
||||||
|
opts = self._opts[s].get(m)
|
||||||
|
if opts:
|
||||||
|
return opts
|
||||||
|
return self._default_opts
|
||||||
|
|
||||||
|
def paint(
|
||||||
|
self,
|
||||||
|
painter: QPainter,
|
||||||
|
rect: QRect,
|
||||||
|
mode: QIcon.Mode,
|
||||||
|
state: QIcon.State,
|
||||||
|
) -> None:
|
||||||
|
opts = self._get_opts(state, mode)
|
||||||
|
|
||||||
|
char, family, style = QFontIconStore.key2glyph(opts.glyph_key)
|
||||||
|
|
||||||
|
# font
|
||||||
|
font = QFont()
|
||||||
|
font.setFamily(family) # set separately for Qt6
|
||||||
|
font.setPixelSize(round(rect.height() * opts.scale_factor))
|
||||||
|
if style:
|
||||||
|
font.setStyleName(style)
|
||||||
|
|
||||||
|
# color
|
||||||
|
if isinstance(opts.color, tuple):
|
||||||
|
color_args = opts.color
|
||||||
|
else:
|
||||||
|
color_args = (opts.color,) if opts.color else ()
|
||||||
|
|
||||||
|
# animation
|
||||||
|
if opts.animation is not None:
|
||||||
|
opts.animation.animate(painter)
|
||||||
|
|
||||||
|
# animation
|
||||||
|
if opts.transform is not None:
|
||||||
|
painter.setTransform(opts.transform, True)
|
||||||
|
|
||||||
|
painter.save()
|
||||||
|
painter.setPen(QColor(*color_args))
|
||||||
|
painter.setOpacity(opts.opacity)
|
||||||
|
painter.setFont(font)
|
||||||
|
with QMessageHandler(): # avoid "Populating font family aliases" warning
|
||||||
|
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, char)
|
||||||
|
painter.restore()
|
||||||
|
|
||||||
|
def pixmap(self, size: QSize, mode: QIcon.Mode, state: QIcon.State) -> QPixmap:
|
||||||
|
# first look in cache
|
||||||
|
pmckey = self._pmcKey(size, mode, state)
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.filterwarnings("ignore", "QPixmapCache.find")
|
||||||
|
pm = QPixmapCache.find(pmckey) if pmckey else None
|
||||||
|
if pm:
|
||||||
|
return pm
|
||||||
|
pixmap = QPixmap(size)
|
||||||
|
if not size.isValid():
|
||||||
|
return pixmap
|
||||||
|
pixmap.fill(Qt.GlobalColor.transparent)
|
||||||
|
painter = QPainter(pixmap)
|
||||||
|
self.paint(painter, QRect(QPoint(0, 0), size), mode, state)
|
||||||
|
painter.end()
|
||||||
|
|
||||||
|
# Apply palette-based styles for disabled/selected modes
|
||||||
|
# unless the user has specifically set a color for this mode/state
|
||||||
|
if mode != QIcon.Mode.Normal:
|
||||||
|
ico_opts = self._opts[state].get(mode)
|
||||||
|
if not ico_opts or not ico_opts.color:
|
||||||
|
opt = QStyleOption()
|
||||||
|
opt.palette = QGuiApplication.palette()
|
||||||
|
generated = QApplication.style().generatedIconPixmap(mode, pixmap, opt)
|
||||||
|
if not generated.isNull():
|
||||||
|
pixmap = generated
|
||||||
|
|
||||||
|
if pmckey and not pixmap.isNull():
|
||||||
|
QPixmapCache.insert(pmckey, pixmap)
|
||||||
|
|
||||||
|
return pixmap
|
||||||
|
|
||||||
|
def _pmcKey(self, size: QSize, mode: QIcon.Mode, state: QIcon.State) -> str:
|
||||||
|
# Qt6-style enums
|
||||||
|
if self._get_opts(state, mode).animation:
|
||||||
|
return ""
|
||||||
|
if hasattr(mode, "value"):
|
||||||
|
mode = mode.value
|
||||||
|
if hasattr(state, "value"):
|
||||||
|
state = state.value
|
||||||
|
k = ((((((size.width()) << 11) | size.height()) << 11) | mode) << 4) | state
|
||||||
|
return f"$superqt_{self._opt_hash}_{hex(k)}"
|
||||||
|
|
||||||
|
def update_hash(self) -> None:
|
||||||
|
hsh = id(self)
|
||||||
|
for state, d in self._opts.items():
|
||||||
|
for mode, opts in d.items():
|
||||||
|
if not opts:
|
||||||
|
continue
|
||||||
|
hsh += hash(
|
||||||
|
hash(opts.glyph_key) + hash(opts.color) + hash(state) + hash(mode)
|
||||||
|
)
|
||||||
|
self._opt_hash = hex(hsh)
|
||||||
|
|
||||||
|
|
||||||
|
class QFontIcon(QIcon):
|
||||||
|
def __init__(self, options: _IconOptions) -> None:
|
||||||
|
self._engine = _QFontIconEngine(options)
|
||||||
|
super().__init__(self._engine)
|
||||||
|
|
||||||
|
def addState(
|
||||||
|
self,
|
||||||
|
state: QIcon.State = QIcon.State.Off,
|
||||||
|
mode: QIcon.Mode = QIcon.Mode.Normal,
|
||||||
|
glyph_key: str | Unset = _Unset,
|
||||||
|
scale_factor: float | Unset = _Unset,
|
||||||
|
color: ValidColor | Unset = _Unset,
|
||||||
|
opacity: float | Unset = _Unset,
|
||||||
|
animation: Animation | Unset | None = _Unset,
|
||||||
|
transform: QTransform | Unset | None = _Unset,
|
||||||
|
) -> None:
|
||||||
|
"""Set icon options for a specific mode/state."""
|
||||||
|
if glyph_key is not _Unset:
|
||||||
|
QFontIconStore.key2glyph(glyph_key) # type: ignore
|
||||||
|
|
||||||
|
_opts = IconOpts(
|
||||||
|
glyph_key=glyph_key,
|
||||||
|
scale_factor=scale_factor,
|
||||||
|
color=color,
|
||||||
|
opacity=opacity,
|
||||||
|
animation=animation,
|
||||||
|
transform=transform,
|
||||||
|
)
|
||||||
|
self._engine._add_opts(state, mode, _opts)
|
||||||
|
|
||||||
|
|
||||||
|
class QFontIconStore(QObject):
|
||||||
|
# map of key -> (font_family, font_style)
|
||||||
|
_LOADED_KEYS: ClassVar[dict[str, tuple[str, str]]] = {}
|
||||||
|
|
||||||
|
# map of (font_family, font_style) -> character (char may include key)
|
||||||
|
_CHARMAPS: ClassVar[dict[tuple[str, str | None], dict[str, str]]] = {}
|
||||||
|
|
||||||
|
# singleton instance, use `instance()` to retrieve
|
||||||
|
__instance: ClassVar[QFontIconStore | None] = None
|
||||||
|
|
||||||
|
def __init__(self, parent: QObject | None = None) -> None:
|
||||||
|
super().__init__(parent=parent)
|
||||||
|
if tuple(cast(str, QT_VERSION).split(".")) < ("6", "0"):
|
||||||
|
# QT6 drops this
|
||||||
|
QApplication.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def instance(cls) -> QFontIconStore:
|
||||||
|
if cls.__instance is None:
|
||||||
|
cls.__instance = cls()
|
||||||
|
return cls.__instance
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def clear(cls) -> None:
|
||||||
|
cls._LOADED_KEYS.clear()
|
||||||
|
cls._CHARMAPS.clear()
|
||||||
|
QFontDatabase.removeAllApplicationFonts()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _key2family(cls, key: str) -> tuple[str, str]:
|
||||||
|
"""Return (family, style) given a font `key`."""
|
||||||
|
key = key.split(".", maxsplit=1)[0]
|
||||||
|
if key not in cls._LOADED_KEYS:
|
||||||
|
from . import _plugins
|
||||||
|
|
||||||
|
try:
|
||||||
|
font_cls = _plugins.get_font_class(key)
|
||||||
|
result = cls.addFont(
|
||||||
|
font_cls.__font_file__, key, charmap=dict(font_cls.__dict__)
|
||||||
|
)
|
||||||
|
if not result: # pragma: no cover
|
||||||
|
raise Exception("Invalid font file")
|
||||||
|
cls._LOADED_KEYS[key] = result
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unrecognized font key: {key!r}.\n"
|
||||||
|
f"Known plugin keys include: {_plugins.available()}.\n"
|
||||||
|
f"Loaded keys include: {list(cls._LOADED_KEYS)}."
|
||||||
|
) from e
|
||||||
|
return cls._LOADED_KEYS[key]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _ensure_char(cls, char: str, family: str, style: str) -> str:
|
||||||
|
"""Make sure that `char` is a glyph provided by `family` and `style`."""
|
||||||
|
if len(char) == 1 and ord(char) > 256:
|
||||||
|
return char
|
||||||
|
try:
|
||||||
|
charmap = cls._CHARMAPS[(family, style)]
|
||||||
|
except KeyError as e:
|
||||||
|
raise KeyError(
|
||||||
|
f"No charmap registered for font '{family} ({style})'"
|
||||||
|
) from e
|
||||||
|
if char in charmap:
|
||||||
|
# split in case the charmap includes the key
|
||||||
|
return charmap[char].split(".", maxsplit=1)[-1]
|
||||||
|
|
||||||
|
ident = _ensure_identifier(char)
|
||||||
|
if ident in charmap:
|
||||||
|
return charmap[ident].split(".", maxsplit=1)[-1]
|
||||||
|
|
||||||
|
ident = f"{char!r} or {ident!r}" if char != ident else repr(ident)
|
||||||
|
raise ValueError(f"Font '{family} ({style})' has no glyph with the key {ident}")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def key2glyph(cls, glyph_key: str) -> tuple[str, str, str | None]:
|
||||||
|
"""Return (char, family, style) given a `glyph_key`."""
|
||||||
|
if "." not in glyph_key:
|
||||||
|
raise ValueError("Glyph key must contain a period")
|
||||||
|
font_key, char = glyph_key.split(".", maxsplit=1)
|
||||||
|
family, style = cls._key2family(font_key)
|
||||||
|
char = cls._ensure_char(char, family, style)
|
||||||
|
return char, family, style
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def addFont(
|
||||||
|
cls, filepath: str, prefix: str, charmap: dict[str, str] | None = None
|
||||||
|
) -> tuple[str, str] | None:
|
||||||
|
r"""Add font at `filepath` to the registry under `key`.
|
||||||
|
|
||||||
|
If you'd like to later use a fontkey in the form of `key.some-name`, then
|
||||||
|
`charmap` must be provided and provide a mapping for all of the glyph names
|
||||||
|
to their unicode numbers. If a charmap is not provided, glyphs must be directly
|
||||||
|
accessed with their unicode as something like `key.\\uffff`.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
filepath : str
|
||||||
|
Path to an OTF or TTF file containing the fonts
|
||||||
|
prefix : str
|
||||||
|
A key that will represent this font file when used for lookup. For example,
|
||||||
|
'fa5s' for 'Font-Awesome 5 Solid'.
|
||||||
|
charmap : Dict[str, str], optional
|
||||||
|
optional mapping for all of the glyph names to their unicode numbers.
|
||||||
|
See note above.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Tuple[str, str], optional
|
||||||
|
font-family and font-style for the file just registered, or None if
|
||||||
|
something goes wrong.
|
||||||
|
"""
|
||||||
|
if prefix in cls._LOADED_KEYS:
|
||||||
|
warnings.warn(f"Prefix {prefix} already loaded", stacklevel=2)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not Path(filepath).exists():
|
||||||
|
raise FileNotFoundError(f"Font file doesn't exist: {filepath}")
|
||||||
|
if QApplication.instance() is None:
|
||||||
|
raise RuntimeError("Please create QApplication before adding a Font")
|
||||||
|
|
||||||
|
fontId = QFontDatabase.addApplicationFont(str(Path(filepath).absolute()))
|
||||||
|
if fontId < 0: # pragma: no cover
|
||||||
|
warnings.warn(f"Cannot load font file: {filepath}", stacklevel=2)
|
||||||
|
return None
|
||||||
|
|
||||||
|
families = QFontDatabase.applicationFontFamilies(fontId)
|
||||||
|
if not families: # pragma: no cover
|
||||||
|
warnings.warn(f"Font file is empty!: {filepath}", stacklevel=2)
|
||||||
|
return None
|
||||||
|
family: str = families[0]
|
||||||
|
|
||||||
|
# in Qt6, everything becomes a static member
|
||||||
|
QFd: QFontDatabase | type[QFontDatabase] = (
|
||||||
|
QFontDatabase()
|
||||||
|
if tuple(cast(str, QT_VERSION).split(".")) < ("6", "0")
|
||||||
|
else QFontDatabase
|
||||||
|
)
|
||||||
|
|
||||||
|
styles = QFd.styles(family)
|
||||||
|
style: str = styles[-1] if styles else ""
|
||||||
|
if not QFd.isSmoothlyScalable(family, style): # pragma: no cover
|
||||||
|
warnings.warn(
|
||||||
|
f"Registered font {family} ({style}) is not smoothly scalable. "
|
||||||
|
"Icons may not look attractive.",
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
cls._LOADED_KEYS[prefix] = (family, style)
|
||||||
|
if charmap:
|
||||||
|
cls._CHARMAPS[(family, style)] = charmap
|
||||||
|
return (family, style)
|
||||||
|
|
||||||
|
def icon(
|
||||||
|
self,
|
||||||
|
glyph_key: str,
|
||||||
|
*,
|
||||||
|
scale_factor: float = DEFAULT_SCALING_FACTOR,
|
||||||
|
color: ValidColor | None = None,
|
||||||
|
opacity: float = 1,
|
||||||
|
animation: Animation | None = None,
|
||||||
|
transform: QTransform | None = None,
|
||||||
|
states: dict[str, IconOptionDict | IconOpts] | None = None,
|
||||||
|
) -> QFontIcon:
|
||||||
|
self.key2glyph(glyph_key) # make sure it's a valid glyph_key
|
||||||
|
default_opts = _IconOptions(
|
||||||
|
glyph_key=glyph_key,
|
||||||
|
scale_factor=scale_factor,
|
||||||
|
color=color,
|
||||||
|
opacity=opacity,
|
||||||
|
animation=animation,
|
||||||
|
transform=transform,
|
||||||
|
)
|
||||||
|
icon = QFontIcon(default_opts)
|
||||||
|
for kw, options in (states or {}).items():
|
||||||
|
if isinstance(options, IconOpts):
|
||||||
|
options = default_opts._update(options).dict()
|
||||||
|
icon.addState(*_norm_state_mode(kw), **options)
|
||||||
|
return icon
|
||||||
|
|
||||||
|
def setTextIcon(
|
||||||
|
self, widget: QWidget, glyph_key: str, size: float | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Sets text on a widget to a specific font & glyph.
|
||||||
|
|
||||||
|
This is an alternative to setting a `QIcon` with a pixmap. It may
|
||||||
|
be easier to combine with dynamic stylesheets.
|
||||||
|
"""
|
||||||
|
setText = getattr(widget, "setText", None)
|
||||||
|
if not setText: # pragma: no cover
|
||||||
|
raise TypeError(f"Object does not a setText method: {widget}")
|
||||||
|
|
||||||
|
glyph = self.key2glyph(glyph_key)[0]
|
||||||
|
size = size or DEFAULT_SCALING_FACTOR
|
||||||
|
size = size if size > 1 else widget.height() * size
|
||||||
|
widget.setFont(self.font(glyph_key, int(size)))
|
||||||
|
setText(glyph)
|
||||||
|
|
||||||
|
def font(self, font_prefix: str, size: int | None = None) -> QFont:
|
||||||
|
"""Create QFont for `font_prefix`."""
|
||||||
|
font_key, _ = font_prefix.split(".", maxsplit=1)
|
||||||
|
family, style = self._key2family(font_key)
|
||||||
|
font = QFont()
|
||||||
|
font.setFamily(family)
|
||||||
|
if style:
|
||||||
|
font.setStyleName(style)
|
||||||
|
if size:
|
||||||
|
font.setPixelSize(int(size))
|
||||||
|
return font
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_identifier(name: str) -> str:
|
||||||
|
"""Normalize string to valid identifier."""
|
||||||
|
import keyword
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# add _ to beginning of names starting with numbers
|
||||||
|
if name[0].isdigit():
|
||||||
|
name = f"_{name}"
|
||||||
|
|
||||||
|
# add _ to end of reserved keywords
|
||||||
|
if keyword.iskeyword(name):
|
||||||
|
name += "_"
|
||||||
|
|
||||||
|
# replace dashes and spaces with underscores
|
||||||
|
name = name.replace("-", "_").replace(" ", "_")
|
||||||
|
|
||||||
|
if not str.isidentifier(name):
|
||||||
|
raise ValueError(f"Could not canonicalize name: {name!r}. (not an identifier)")
|
||||||
|
return name
|
22
src/superqt/qtcompat/__init__.py
Normal file
22
src/superqt/qtcompat/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import sys
|
||||||
|
import warnings
|
||||||
|
from importlib import abc, util
|
||||||
|
|
||||||
|
from qtpy import * # noqa
|
||||||
|
|
||||||
|
warnings.warn(
|
||||||
|
"The superqt.qtcompat module is deprecated as of v0.3.0. "
|
||||||
|
"Please import from `qtpy` instead.",
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# forward any requests for superqt.qtcompat.* to qtpy.*
|
||||||
|
class SuperQtImporter(abc.MetaPathFinder):
|
||||||
|
def find_spec(self, fullname: str, path, target=None): # type: ignore
|
||||||
|
"""Forward any requests for superqt.qtcompat.* to qtpy.*."""
|
||||||
|
if fullname.startswith(__name__):
|
||||||
|
return util.find_spec(fullname.replace(__name__, "qtpy"))
|
||||||
|
|
||||||
|
|
||||||
|
sys.meta_path.append(SuperQtImporter())
|
4
src/superqt/selection/__init__.py
Normal file
4
src/superqt/selection/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from ._searchable_list_widget import QSearchableListWidget
|
||||||
|
from ._searchable_tree_widget import QSearchableTreeWidget
|
||||||
|
|
||||||
|
__all__ = ("QSearchableListWidget", "QSearchableTreeWidget")
|
46
src/superqt/selection/_searchable_list_widget.py
Normal file
46
src/superqt/selection/_searchable_list_widget.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
from qtpy.QtCore import Qt
|
||||||
|
from qtpy.QtWidgets import QLineEdit, QListWidget, QVBoxLayout, QWidget
|
||||||
|
|
||||||
|
|
||||||
|
class QSearchableListWidget(QWidget):
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
self.list_widget = QListWidget()
|
||||||
|
|
||||||
|
self.filter_widget = QLineEdit()
|
||||||
|
self.filter_widget.textChanged.connect(self.update_visible)
|
||||||
|
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
layout.addWidget(self.filter_widget)
|
||||||
|
layout.addWidget(self.list_widget)
|
||||||
|
self.setLayout(layout)
|
||||||
|
|
||||||
|
def __getattr__(self, item):
|
||||||
|
if hasattr(self.list_widget, item):
|
||||||
|
return getattr(self.list_widget, item)
|
||||||
|
return super().__getattr__(item)
|
||||||
|
|
||||||
|
def update_visible(self, text):
|
||||||
|
items_text = [
|
||||||
|
x.text() for x in self.list_widget.findItems(text, Qt.MatchContains)
|
||||||
|
]
|
||||||
|
for index in range(self.list_widget.count()):
|
||||||
|
item = self.item(index)
|
||||||
|
item.setHidden(item.text() not in items_text)
|
||||||
|
|
||||||
|
def addItems(self, *args):
|
||||||
|
self.list_widget.addItems(*args)
|
||||||
|
self.update_visible(self.filter_widget.text())
|
||||||
|
|
||||||
|
def addItem(self, *args):
|
||||||
|
self.list_widget.addItem(*args)
|
||||||
|
self.update_visible(self.filter_widget.text())
|
||||||
|
|
||||||
|
def insertItems(self, *args):
|
||||||
|
self.list_widget.insertItems(*args)
|
||||||
|
self.update_visible(self.filter_widget.text())
|
||||||
|
|
||||||
|
def insertItem(self, *args):
|
||||||
|
self.list_widget.insertItem(*args)
|
||||||
|
self.update_visible(self.filter_widget.text())
|
114
src/superqt/selection/_searchable_tree_widget.py
Normal file
114
src/superqt/selection/_searchable_tree_widget.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Any, Iterable, Mapping
|
||||||
|
|
||||||
|
from qtpy.QtCore import QRegularExpression
|
||||||
|
from qtpy.QtWidgets import QLineEdit, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
|
||||||
|
|
||||||
|
|
||||||
|
class QSearchableTreeWidget(QWidget):
|
||||||
|
"""A tree widget for showing a mapping that can be searched by key.
|
||||||
|
|
||||||
|
This is intended to be used with a read-only mapping and be conveniently
|
||||||
|
created using `QSearchableTreeWidget.fromData(data)`.
|
||||||
|
If the mapping changes, the easiest way to update this is by calling `setData`.
|
||||||
|
|
||||||
|
The tree can be searched by entering a regular expression pattern
|
||||||
|
into the `filter` line edit. An item is only shown if its, any of its ancestors',
|
||||||
|
or any of its descendants' keys or values match this pattern.
|
||||||
|
The regular expression follows the conventions described by the Qt docs:
|
||||||
|
https://doc.qt.io/qt-5/qregularexpression.html#details
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
tree : QTreeWidget
|
||||||
|
Shows the mapping as a tree of items.
|
||||||
|
filter : QLineEdit
|
||||||
|
Used to filter items in the tree by matching their key against a
|
||||||
|
regular expression.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
self.tree: QTreeWidget = QTreeWidget(self)
|
||||||
|
self.tree.setHeaderLabels(("Key", "Value"))
|
||||||
|
|
||||||
|
self.filter: QLineEdit = QLineEdit(self)
|
||||||
|
self.filter.setClearButtonEnabled(True)
|
||||||
|
self.filter.textChanged.connect(self._updateVisibleItems)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.addWidget(self.filter)
|
||||||
|
layout.addWidget(self.tree)
|
||||||
|
|
||||||
|
def setData(self, data: Mapping) -> None:
|
||||||
|
"""Update the mapping data shown by the tree."""
|
||||||
|
self.tree.clear()
|
||||||
|
self.filter.clear()
|
||||||
|
top_level_items = [_make_item(name=k, value=v) for k, v in data.items()]
|
||||||
|
self.tree.addTopLevelItems(top_level_items)
|
||||||
|
|
||||||
|
def _updateVisibleItems(self, pattern: str) -> None:
|
||||||
|
"""Recursively update the visibility of items based on the given pattern."""
|
||||||
|
expression = QRegularExpression(pattern)
|
||||||
|
for i in range(self.tree.topLevelItemCount()):
|
||||||
|
top_level_item = self.tree.topLevelItem(i)
|
||||||
|
_update_visible_items(top_level_item, expression)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def fromData(
|
||||||
|
cls, data: Mapping, *, parent: QWidget = None
|
||||||
|
) -> "QSearchableTreeWidget":
|
||||||
|
"""Make a searchable tree widget from a mapping."""
|
||||||
|
widget = cls(parent)
|
||||||
|
widget.setData(data)
|
||||||
|
return widget
|
||||||
|
|
||||||
|
|
||||||
|
def _make_item(*, name: str, value: Any) -> QTreeWidgetItem:
|
||||||
|
"""Make a tree item where the name and value are two columns.
|
||||||
|
|
||||||
|
Iterable values other than strings are recursively traversed to
|
||||||
|
add child items and build a tree. In this case, mappings use keys
|
||||||
|
as their names whereas other iterables use their enumerated index.
|
||||||
|
"""
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
item = QTreeWidgetItem([name, type(value).__name__])
|
||||||
|
for k, v in value.items():
|
||||||
|
child = _make_item(name=k, value=v)
|
||||||
|
item.addChild(child)
|
||||||
|
elif isinstance(value, Iterable) and not isinstance(value, str):
|
||||||
|
item = QTreeWidgetItem([name, type(value).__name__])
|
||||||
|
for i, v in enumerate(value):
|
||||||
|
child = _make_item(name=str(i), value=v)
|
||||||
|
item.addChild(child)
|
||||||
|
else:
|
||||||
|
item = QTreeWidgetItem([name, str(value)])
|
||||||
|
logging.debug("_make_item: %s, %s, %s", item.text(0), item.text(1), item.flags())
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _update_visible_items(
|
||||||
|
item: QTreeWidgetItem, expression: QRegularExpression, ancestor_match: bool = False
|
||||||
|
) -> bool:
|
||||||
|
"""Recursively update the visibility of a tree item based on an expression.
|
||||||
|
|
||||||
|
An item is visible if any of its, any of its ancestors', or any of its descendants'
|
||||||
|
column's text matches the expression.
|
||||||
|
Returns True if the item is visible, False otherwise.
|
||||||
|
"""
|
||||||
|
match = ancestor_match or any(
|
||||||
|
expression.match(item.text(i)).hasMatch() for i in range(item.columnCount())
|
||||||
|
)
|
||||||
|
visible = match
|
||||||
|
for i in range(item.childCount()):
|
||||||
|
child = item.child(i)
|
||||||
|
descendant_visible = _update_visible_items(child, expression, match)
|
||||||
|
visible = visible or descendant_visible
|
||||||
|
item.setHidden(not visible)
|
||||||
|
logging.debug(
|
||||||
|
"_update_visible_items: %s, %s",
|
||||||
|
tuple(item.text(i) for i in range(item.columnCount())),
|
||||||
|
visible,
|
||||||
|
)
|
||||||
|
return visible
|
@@ -4,6 +4,7 @@ from ._labeled import (
|
|||||||
QLabeledRangeSlider,
|
QLabeledRangeSlider,
|
||||||
QLabeledSlider,
|
QLabeledSlider,
|
||||||
)
|
)
|
||||||
|
from ._range_style import MONTEREY_SLIDER_STYLES_FIX
|
||||||
from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
|
from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -14,4 +15,5 @@ __all__ = [
|
|||||||
"QLabeledRangeSlider",
|
"QLabeledRangeSlider",
|
||||||
"QLabeledSlider",
|
"QLabeledSlider",
|
||||||
"QRangeSlider",
|
"QRangeSlider",
|
||||||
|
"MONTEREY_SLIDER_STYLES_FIX",
|
||||||
]
|
]
|
@@ -1,19 +1,15 @@
|
|||||||
from typing import Generic, List, Sequence, Tuple, TypeVar, Union
|
from typing import List, Optional, Sequence, Tuple, TypeVar, Union
|
||||||
|
|
||||||
|
from qtpy import QtGui
|
||||||
|
from qtpy.QtCore import Property, QEvent, QPoint, QPointF, QRect, QRectF, Qt, Signal
|
||||||
|
from qtpy.QtWidgets import QSlider, QStyle, QStyleOptionSlider, QStylePainter
|
||||||
|
|
||||||
from ..qtcompat import QtGui
|
|
||||||
from ..qtcompat.QtCore import (
|
|
||||||
Property,
|
|
||||||
QEvent,
|
|
||||||
QPoint,
|
|
||||||
QPointF,
|
|
||||||
QRect,
|
|
||||||
QRectF,
|
|
||||||
Qt,
|
|
||||||
Signal,
|
|
||||||
)
|
|
||||||
from ..qtcompat.QtWidgets import QSlider, QStyle, QStyleOptionSlider, QStylePainter
|
|
||||||
from ._generic_slider import CC_SLIDER, SC_GROOVE, SC_HANDLE, SC_NONE, _GenericSlider
|
from ._generic_slider import CC_SLIDER, SC_GROOVE, SC_HANDLE, SC_NONE, _GenericSlider
|
||||||
from ._range_style import RangeSliderStyle, update_styles_from_stylesheet
|
from ._range_style import (
|
||||||
|
MONTEREY_SLIDER_STYLES_FIX,
|
||||||
|
RangeSliderStyle,
|
||||||
|
update_styles_from_stylesheet,
|
||||||
|
)
|
||||||
|
|
||||||
_T = TypeVar("_T")
|
_T = TypeVar("_T")
|
||||||
|
|
||||||
@@ -21,7 +17,7 @@ _T = TypeVar("_T")
|
|||||||
SC_BAR = QStyle.SubControl.SC_ScrollBarSubPage
|
SC_BAR = QStyle.SubControl.SC_ScrollBarSubPage
|
||||||
|
|
||||||
|
|
||||||
class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
|
class _GenericRangeSlider(_GenericSlider):
|
||||||
"""MultiHandle Range Slider widget.
|
"""MultiHandle Range Slider widget.
|
||||||
|
|
||||||
Same API as QSlider, but `value`, `setValue`, `sliderPosition`, and
|
Same API as QSlider, but `value`, `setValue`, `sliderPosition`, and
|
||||||
@@ -32,16 +28,19 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Emitted when the slider value has changed, with the new slider values
|
# Emitted when the slider value has changed, with the new slider values
|
||||||
valueChanged = Signal(tuple)
|
_valuesChanged = Signal(tuple)
|
||||||
|
|
||||||
# Emitted when sliderDown is true and the slider moves
|
# Emitted when sliderDown is true and the slider moves
|
||||||
# This usually happens when the user is dragging the slider
|
# This usually happens when the user is dragging the slider
|
||||||
# The value is the positions of *all* handles.
|
# The value is the positions of *all* handles.
|
||||||
sliderMoved = Signal(tuple)
|
_slidersMoved = Signal(tuple)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
self._style = RangeSliderStyle()
|
||||||
|
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.valueChanged = self._valuesChanged
|
||||||
|
self.sliderMoved = self._slidersMoved
|
||||||
# list of values
|
# list of values
|
||||||
self._value: List[_T] = [20, 80]
|
self._value: List[_T] = [20, 80]
|
||||||
|
|
||||||
@@ -62,32 +61,30 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
|
|||||||
|
|
||||||
# color
|
# color
|
||||||
|
|
||||||
self._style = RangeSliderStyle()
|
|
||||||
self.setStyleSheet("")
|
self.setStyleSheet("")
|
||||||
update_styles_from_stylesheet(self)
|
|
||||||
|
|
||||||
# ############### New Public API #######################
|
# ############### New Public API #######################
|
||||||
|
|
||||||
def barIsRigid(self) -> bool:
|
def barIsRigid(self) -> bool:
|
||||||
"""Whether bar length is constant when dragging the bar.
|
"""Whether bar length is constant when dragging the bar.
|
||||||
|
|
||||||
If False, the bar can shorten when dragged beyond min/max. Default is True.
|
If `False`, the bar can shorten when dragged beyond min/max. Default is `True`.
|
||||||
"""
|
"""
|
||||||
return self._bar_is_rigid
|
return self._bar_is_rigid
|
||||||
|
|
||||||
def setBarIsRigid(self, val: bool = True) -> None:
|
def setBarIsRigid(self, val: bool = True) -> None:
|
||||||
"""Whether bar length is constant when dragging the bar.
|
"""Whether bar length is constant when dragging the bar.
|
||||||
|
|
||||||
If False, the bar can shorten when dragged beyond min/max. Default is True.
|
If `False`, the bar can shorten when dragged beyond min/max. Default is `True`.
|
||||||
"""
|
"""
|
||||||
self._bar_is_rigid = bool(val)
|
self._bar_is_rigid = bool(val)
|
||||||
|
|
||||||
def barMovesAllHandles(self) -> bool:
|
def barMovesAllHandles(self) -> bool:
|
||||||
"""Whether clicking on the bar moves all handles (default), or just the nearest."""
|
"""Whether clicking on the bar moves all handles, or just the nearest."""
|
||||||
return self._bar_moves_all
|
return self._bar_moves_all
|
||||||
|
|
||||||
def setBarMovesAllHandles(self, val: bool = True) -> None:
|
def setBarMovesAllHandles(self, val: bool = True) -> None:
|
||||||
"""Whether clicking on the bar moves all handles (default), or just the nearest."""
|
"""Whether clicking on the bar moves all handles, or just the nearest."""
|
||||||
self._bar_moves_all = bool(val)
|
self._bar_moves_all = bool(val)
|
||||||
|
|
||||||
def barIsVisible(self) -> bool:
|
def barIsVisible(self) -> bool:
|
||||||
@@ -99,11 +96,21 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
|
|||||||
self._should_draw_bar = bool(val)
|
self._should_draw_bar = bool(val)
|
||||||
|
|
||||||
def hideBar(self) -> None:
|
def hideBar(self) -> None:
|
||||||
|
"""Hide the bar between the first and last handle."""
|
||||||
self.setBarVisible(False)
|
self.setBarVisible(False)
|
||||||
|
|
||||||
def showBar(self) -> None:
|
def showBar(self) -> None:
|
||||||
|
"""Show the bar between the first and last handle."""
|
||||||
self.setBarVisible(True)
|
self.setBarVisible(True)
|
||||||
|
|
||||||
|
def applyMacStylePatch(self) -> str:
|
||||||
|
"""Apply a QSS patch to fix sliders on macos>=12 with QT < 6.
|
||||||
|
|
||||||
|
see [FAQ](../faq.md#sliders-not-dragging-properly-on-macos-12) for more details.
|
||||||
|
"""
|
||||||
|
super().applyMacStylePatch()
|
||||||
|
self._style._macpatch = True
|
||||||
|
|
||||||
# ############### QtOverrides #######################
|
# ############### QtOverrides #######################
|
||||||
|
|
||||||
def value(self) -> Tuple[_T, ...]:
|
def value(self) -> Tuple[_T, ...]:
|
||||||
@@ -138,26 +145,41 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
|
|||||||
self._doSliderMove()
|
self._doSliderMove()
|
||||||
|
|
||||||
def setStyleSheet(self, styleSheet: str) -> None:
|
def setStyleSheet(self, styleSheet: str) -> None:
|
||||||
|
return super().setStyleSheet(self._patch_style(styleSheet))
|
||||||
|
|
||||||
|
def _patch_style(self, style: str):
|
||||||
|
"""Override to patch style options before painting."""
|
||||||
# sub-page styles render on top of the lower sliders and don't work here.
|
# sub-page styles render on top of the lower sliders and don't work here.
|
||||||
|
if self._style._macpatch and not style:
|
||||||
|
style = MONTEREY_SLIDER_STYLES_FIX
|
||||||
|
|
||||||
override = f"""
|
override = f"""
|
||||||
\n{type(self).__name__}::sub-page:horizontal {{background: none}}
|
\n{type(self).__name__}::sub-page:horizontal
|
||||||
\n{type(self).__name__}::sub-page:vertical {{background: none}}
|
{{background: none; border: none}}
|
||||||
|
\n{type(self).__name__}::add-page:vertical
|
||||||
|
{{background: none; border: none}}
|
||||||
"""
|
"""
|
||||||
return super().setStyleSheet(styleSheet + override)
|
return style + override
|
||||||
|
|
||||||
def event(self, ev: QEvent) -> bool:
|
def event(self, ev: QEvent) -> bool:
|
||||||
if ev.type() == QEvent.StyleChange:
|
if ev.type() == QEvent.Type.StyleChange:
|
||||||
update_styles_from_stylesheet(self)
|
update_styles_from_stylesheet(self)
|
||||||
return super().event(ev)
|
return super().event(ev)
|
||||||
|
|
||||||
def mouseMoveEvent(self, ev: QtGui.QMouseEvent) -> None:
|
def mouseMoveEvent(self, ev: QtGui.QMouseEvent) -> None:
|
||||||
if self._pressedControl == SC_BAR:
|
if self._pressedControl == SC_BAR:
|
||||||
ev.accept()
|
ev.accept()
|
||||||
delta = self._clickOffset - self._pixelPosToRangeValue(self._pick(ev.pos()))
|
delta = self._clickOffset - self._pixelPosToRangeValue(
|
||||||
|
self._pick(self._event_position(ev))
|
||||||
|
)
|
||||||
self._offsetAllPositions(-delta, self._sldPosAtPress)
|
self._offsetAllPositions(-delta, self._sldPosAtPress)
|
||||||
else:
|
else:
|
||||||
super().mouseMoveEvent(ev)
|
super().mouseMoveEvent(ev)
|
||||||
|
|
||||||
|
def _event_position(self, event):
|
||||||
|
# API changes between PyQt5 (.pos()) and PyQt6 (.position())
|
||||||
|
return event.pos() if hasattr(event, "pos") else event.position()
|
||||||
|
|
||||||
# ############### Implementation Details #######################
|
# ############### Implementation Details #######################
|
||||||
|
|
||||||
def _setPosition(self, val):
|
def _setPosition(self, val):
|
||||||
@@ -189,6 +211,7 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
|
|||||||
self._style.brush_active = color
|
self._style.brush_active = color
|
||||||
|
|
||||||
barColor = Property(QtGui.QBrush, _getBarColor, _setBarColor)
|
barColor = Property(QtGui.QBrush, _getBarColor, _setBarColor)
|
||||||
|
"""The color of the bar between the first and last handle."""
|
||||||
|
|
||||||
def _offsetAllPositions(self, offset: float, ref=None) -> None:
|
def _offsetAllPositions(self, offset: float, ref=None) -> None:
|
||||||
if ref is None:
|
if ref is None:
|
||||||
@@ -210,7 +233,9 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
|
|||||||
|
|
||||||
# SubControl Positions
|
# SubControl Positions
|
||||||
|
|
||||||
def _handleRect(self, handle_index: int, opt: QStyleOptionSlider = None) -> QRect:
|
def _handleRect(
|
||||||
|
self, handle_index: int, opt: Optional[QStyleOptionSlider] = None
|
||||||
|
) -> QRect:
|
||||||
"""Return the QRect for all handles."""
|
"""Return the QRect for all handles."""
|
||||||
opt = opt or self._styleOption
|
opt = opt or self._styleOption
|
||||||
opt.sliderPosition = self._optSliderPositions[handle_index]
|
opt.sliderPosition = self._optSliderPositions[handle_index]
|
||||||
@@ -225,7 +250,7 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
|
|||||||
thickness = self._style.thickness(opt)
|
thickness = self._style.thickness(opt)
|
||||||
offset = self._style.offset(opt)
|
offset = self._style.offset(opt)
|
||||||
|
|
||||||
if opt.orientation == Qt.Horizontal:
|
if opt.orientation == Qt.Orientation.Horizontal:
|
||||||
r_bar.setTop(r_bar.center().y() - thickness / 2 + offset)
|
r_bar.setTop(r_bar.center().y() - thickness / 2 + offset)
|
||||||
r_bar.setHeight(thickness)
|
r_bar.setHeight(thickness)
|
||||||
r_bar.setLeft(hdl_low.center().x())
|
r_bar.setLeft(hdl_low.center().x())
|
||||||
@@ -261,9 +286,9 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
|
|||||||
opt.sliderPosition = pos
|
opt.sliderPosition = pos
|
||||||
# make pressed handles appear sunken
|
# make pressed handles appear sunken
|
||||||
if idx == pidx:
|
if idx == pidx:
|
||||||
opt.state |= QStyle.State_Sunken
|
opt.state |= QStyle.StateFlag.State_Sunken
|
||||||
else:
|
else:
|
||||||
opt.state = opt.state & ~QStyle.State_Sunken
|
opt.state = opt.state & ~QStyle.StateFlag.State_Sunken
|
||||||
opt.activeSubControls = SC_HANDLE if idx == hidx else SC_NONE
|
opt.activeSubControls = SC_HANDLE if idx == hidx else SC_NONE
|
||||||
painter.drawComplexControl(CC_SLIDER, opt)
|
painter.drawComplexControl(CC_SLIDER, opt)
|
||||||
|
|
||||||
@@ -287,7 +312,7 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
|
|||||||
|
|
||||||
# NOTE: this is very much tied to mousepress... not a generic "get control"
|
# NOTE: this is very much tied to mousepress... not a generic "get control"
|
||||||
def _getControlAtPos(
|
def _getControlAtPos(
|
||||||
self, pos: QPoint, opt: QStyleOptionSlider = None
|
self, pos: QPoint, opt: Optional[QStyleOptionSlider] = None
|
||||||
) -> Tuple[QStyle.SubControl, int]:
|
) -> Tuple[QStyle.SubControl, int]:
|
||||||
"""Update self._pressedControl based on ev.pos()."""
|
"""Update self._pressedControl based on ev.pos()."""
|
||||||
opt = opt or self._styleOption
|
opt = opt or self._styleOption
|
||||||
@@ -314,11 +339,11 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
|
|||||||
return (SC_HANDLE, len(self._position) - 1)
|
return (SC_HANDLE, len(self._position) - 1)
|
||||||
|
|
||||||
def _execute_scroll(self, steps_to_scroll, modifiers):
|
def _execute_scroll(self, steps_to_scroll, modifiers):
|
||||||
if modifiers & Qt.AltModifier:
|
if modifiers & Qt.KeyboardModifier.AltModifier:
|
||||||
self._spreadAllPositions(shrink=steps_to_scroll < 0)
|
self._spreadAllPositions(shrink=steps_to_scroll < 0)
|
||||||
else:
|
else:
|
||||||
self._offsetAllPositions(steps_to_scroll)
|
self._offsetAllPositions(steps_to_scroll)
|
||||||
self.triggerAction(QSlider.SliderMove)
|
self.triggerAction(QSlider.SliderAction.SliderMove)
|
||||||
|
|
||||||
def _has_scroll_space_left(self, offset):
|
def _has_scroll_space_left(self, offset):
|
||||||
return (offset > 0 and max(self._value) < self._maximum) or (
|
return (offset > 0 and max(self._value) < self._maximum) or (
|
@@ -1,10 +1,10 @@
|
|||||||
"""Generic Sliders with internal python-based models
|
"""Generic Sliders with internal python-based models.
|
||||||
|
|
||||||
This module reimplements most of the logic from qslider.cpp in python:
|
This module reimplements most of the logic from qslider.cpp in python:
|
||||||
https://code.woboq.org/qt5/qtbase/src/widgets/widgets/qslider.cpp.html
|
https://code.woboq.org/qt5/qtbase/src/widgets/widgets/qslider.cpp.html
|
||||||
|
|
||||||
This probably looks like tremendous overkill at first (and it may be!),
|
This probably looks like tremendous overkill at first (and it may be!),
|
||||||
since a it's possible to acheive a very reasonable "float slider" by
|
since a it's possible to achieve a very reasonable "float slider" by
|
||||||
scaling input float values to some internal integer range for the QSlider,
|
scaling input float values to some internal integer range for the QSlider,
|
||||||
and converting back to float when getting `value()`. However, one still
|
and converting back to float when getting `value()`. However, one still
|
||||||
runs into overflow limitations due to the internal integer model.
|
runs into overflow limitations due to the internal integer model.
|
||||||
@@ -19,12 +19,13 @@ So that's what `_GenericSlider` is below.
|
|||||||
scalar (with one handle per item), and it forms the basis of
|
scalar (with one handle per item), and it forms the basis of
|
||||||
QRangeSlider.
|
QRangeSlider.
|
||||||
"""
|
"""
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
from typing import Generic, TypeVar
|
from qtpy import QT_VERSION, QtGui
|
||||||
|
from qtpy.QtCore import QEvent, QPoint, QPointF, QRect, Qt, Signal
|
||||||
from ..qtcompat import QtGui
|
from qtpy.QtWidgets import (
|
||||||
from ..qtcompat.QtCore import QEvent, QPoint, QPointF, QRect, Qt, Signal
|
|
||||||
from ..qtcompat.QtWidgets import (
|
|
||||||
QApplication,
|
QApplication,
|
||||||
QSlider,
|
QSlider,
|
||||||
QStyle,
|
QStyle,
|
||||||
@@ -32,6 +33,8 @@ from ..qtcompat.QtWidgets import (
|
|||||||
QStylePainter,
|
QStylePainter,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ._range_style import MONTEREY_SLIDER_STYLES_FIX
|
||||||
|
|
||||||
_T = TypeVar("_T")
|
_T = TypeVar("_T")
|
||||||
|
|
||||||
SC_NONE = QStyle.SubControl.SC_None
|
SC_NONE = QStyle.SubControl.SC_None
|
||||||
@@ -40,18 +43,29 @@ SC_GROOVE = QStyle.SubControl.SC_SliderGroove
|
|||||||
SC_TICKMARKS = QStyle.SubControl.SC_SliderTickmarks
|
SC_TICKMARKS = QStyle.SubControl.SC_SliderTickmarks
|
||||||
|
|
||||||
CC_SLIDER = QStyle.ComplexControl.CC_Slider
|
CC_SLIDER = QStyle.ComplexControl.CC_Slider
|
||||||
QOVERFLOW = 2 ** 31 - 1
|
QOVERFLOW = 2**31 - 1
|
||||||
|
|
||||||
|
# whether to use the MONTEREY_SLIDER_STYLES_FIX QSS hack
|
||||||
|
# for fixing sliders on macos>=12 with QT < 6
|
||||||
|
# https://bugreports.qt.io/browse/QTBUG-98093
|
||||||
|
# https://github.com/pyapp-kit/superqt/issues/74
|
||||||
|
USE_MAC_SLIDER_PATCH = (
|
||||||
|
QT_VERSION
|
||||||
|
and int(QT_VERSION.split(".")[0]) < 6
|
||||||
|
and platform.system() == "Darwin"
|
||||||
|
and int(platform.mac_ver()[0].split(".", maxsplit=1)[0]) >= 12
|
||||||
|
and os.getenv("USE_MAC_SLIDER_PATCH", "0") not in ("0", "False", "false")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _GenericSlider(QSlider, Generic[_T]):
|
class _GenericSlider(QSlider):
|
||||||
valueChanged = Signal(float)
|
_fvalueChanged = Signal(int)
|
||||||
sliderMoved = Signal(float)
|
_fsliderMoved = Signal(int)
|
||||||
rangeChanged = Signal(float, float)
|
_frangeChanged = Signal(int, int)
|
||||||
|
|
||||||
MAX_DISPLAY = 5000
|
MAX_DISPLAY = 5000
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs) -> None:
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
|
|
||||||
self._minimum = 0.0
|
self._minimum = 0.0
|
||||||
self._maximum = 99.0
|
self._maximum = 99.0
|
||||||
self._pageStep = 10.0
|
self._pageStep = 10.0
|
||||||
@@ -74,7 +88,21 @@ class _GenericSlider(QSlider, Generic[_T]):
|
|||||||
self._control_fraction = 0.04
|
self._control_fraction = 0.04
|
||||||
|
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.setAttribute(Qt.WA_Hover)
|
self.valueChanged = self._fvalueChanged
|
||||||
|
self.sliderMoved = self._fsliderMoved
|
||||||
|
self.rangeChanged = self._frangeChanged
|
||||||
|
|
||||||
|
self.setAttribute(Qt.WidgetAttribute.WA_Hover)
|
||||||
|
self.setStyleSheet("")
|
||||||
|
if USE_MAC_SLIDER_PATCH:
|
||||||
|
self.applyMacStylePatch()
|
||||||
|
|
||||||
|
def applyMacStylePatch(self) -> str:
|
||||||
|
"""Apply a QSS patch to fix sliders on macos>=12 with QT < 6.
|
||||||
|
|
||||||
|
see [FAQ](../faq.md#sliders-not-dragging-properly-on-macos-12) for more details.
|
||||||
|
"""
|
||||||
|
self.setStyleSheet(MONTEREY_SLIDER_STYLES_FIX)
|
||||||
|
|
||||||
# ############### QtOverrides #######################
|
# ############### QtOverrides #######################
|
||||||
|
|
||||||
@@ -130,11 +158,11 @@ class _GenericSlider(QSlider, Generic[_T]):
|
|||||||
self.setRange(min(self._minimum, max), max)
|
self.setRange(min(self._minimum, max), max)
|
||||||
|
|
||||||
def setRange(self, min: float, max_: float) -> None:
|
def setRange(self, min: float, max_: float) -> None:
|
||||||
oldMin, self._minimum = self._minimum, float(min)
|
oldMin, self._minimum = self._minimum, self._type_cast(min)
|
||||||
oldMax, self._maximum = self._maximum, float(max(min, max_))
|
oldMax, self._maximum = self._maximum, self._type_cast(max(min, max_))
|
||||||
|
|
||||||
if oldMin != self._minimum or oldMax != self._maximum:
|
if oldMin != self._minimum or oldMax != self._maximum:
|
||||||
self.sliderChange(self.SliderRangeChange)
|
self.sliderChange(self.SliderChange.SliderRangeChange)
|
||||||
self.rangeChanged.emit(self._minimum, self._maximum)
|
self.rangeChanged.emit(self._minimum, self._maximum)
|
||||||
self.setValue(self._value) # re-bound
|
self.setValue(self._value) # re-bound
|
||||||
|
|
||||||
@@ -159,15 +187,18 @@ class _GenericSlider(QSlider, Generic[_T]):
|
|||||||
option.orientation = self.orientation()
|
option.orientation = self.orientation()
|
||||||
option.tickPosition = self.tickPosition()
|
option.tickPosition = self.tickPosition()
|
||||||
option.upsideDown = (
|
option.upsideDown = (
|
||||||
self.invertedAppearance() != (option.direction == Qt.RightToLeft)
|
self.invertedAppearance()
|
||||||
if self.orientation() == Qt.Horizontal
|
!= (option.direction == Qt.LayoutDirection.RightToLeft)
|
||||||
|
if self.orientation() == Qt.Orientation.Horizontal
|
||||||
else not self.invertedAppearance()
|
else not self.invertedAppearance()
|
||||||
)
|
)
|
||||||
option.direction = Qt.LeftToRight # we use the upsideDown option instead
|
option.direction = (
|
||||||
|
Qt.LayoutDirection.LeftToRight
|
||||||
|
) # we use the upsideDown option instead
|
||||||
# option.sliderValue = self._value # type: ignore
|
# option.sliderValue = self._value # type: ignore
|
||||||
# option.singleStep = self._singleStep # type: ignore
|
# option.singleStep = self._singleStep # type: ignore
|
||||||
if self.orientation() == Qt.Horizontal:
|
if self.orientation() == Qt.Orientation.Horizontal:
|
||||||
option.state |= QStyle.State_Horizontal
|
option.state |= QStyle.StateFlag.State_Horizontal
|
||||||
|
|
||||||
# scale style option to integer space
|
# scale style option to integer space
|
||||||
option.minimum = 0
|
option.minimum = 0
|
||||||
@@ -178,11 +209,11 @@ class _GenericSlider(QSlider, Generic[_T]):
|
|||||||
self._fixStyleOption(option)
|
self._fixStyleOption(option)
|
||||||
|
|
||||||
def event(self, ev: QEvent) -> bool:
|
def event(self, ev: QEvent) -> bool:
|
||||||
if ev.type() == QEvent.WindowActivate:
|
if ev.type() == QEvent.Type.WindowActivate:
|
||||||
self.update()
|
self.update()
|
||||||
elif ev.type() in (QEvent.HoverEnter, QEvent.HoverMove):
|
elif ev.type() in (QEvent.Type.HoverEnter, QEvent.Type.HoverMove):
|
||||||
self._updateHoverControl(_event_position(ev))
|
self._updateHoverControl(_event_position(ev))
|
||||||
elif ev.type() == QEvent.HoverLeave:
|
elif ev.type() == QEvent.Type.HoverLeave:
|
||||||
self._hoverControl = SC_NONE
|
self._hoverControl = SC_NONE
|
||||||
lastHoverRect, self._hoverRect = self._hoverRect, QRect()
|
lastHoverRect, self._hoverRect = self._hoverRect, QRect()
|
||||||
self.update(lastHoverRect)
|
self.update(lastHoverRect)
|
||||||
@@ -198,7 +229,7 @@ class _GenericSlider(QSlider, Generic[_T]):
|
|||||||
pos = _event_position(ev)
|
pos = _event_position(ev)
|
||||||
|
|
||||||
# If the mouse button used is allowed to set the value
|
# If the mouse button used is allowed to set the value
|
||||||
if ev.button() in (Qt.LeftButton, Qt.MiddleButton):
|
if ev.button() in (Qt.MouseButton.LeftButton, Qt.MouseButton.MiddleButton):
|
||||||
self._updatePressedControl(pos)
|
self._updatePressedControl(pos)
|
||||||
if self._pressedControl == SC_HANDLE:
|
if self._pressedControl == SC_HANDLE:
|
||||||
opt = self._styleOption
|
opt = self._styleOption
|
||||||
@@ -206,8 +237,8 @@ class _GenericSlider(QSlider, Generic[_T]):
|
|||||||
offset = sr.center() - sr.topLeft()
|
offset = sr.center() - sr.topLeft()
|
||||||
new_pos = self._pixelPosToRangeValue(self._pick(pos - offset))
|
new_pos = self._pixelPosToRangeValue(self._pick(pos - offset))
|
||||||
self.setSliderPosition(new_pos)
|
self.setSliderPosition(new_pos)
|
||||||
self.triggerAction(QSlider.SliderMove)
|
self.triggerAction(QSlider.SliderAction.SliderMove)
|
||||||
self.setRepeatAction(QSlider.SliderNoAction)
|
self.setRepeatAction(QSlider.SliderAction.SliderNoAction)
|
||||||
|
|
||||||
self.update()
|
self.update()
|
||||||
# elif: deal with PageSetButtons
|
# elif: deal with PageSetButtons
|
||||||
@@ -215,7 +246,7 @@ class _GenericSlider(QSlider, Generic[_T]):
|
|||||||
ev.ignore()
|
ev.ignore()
|
||||||
|
|
||||||
if self._pressedControl != SC_NONE:
|
if self._pressedControl != SC_NONE:
|
||||||
self.setRepeatAction(QSlider.SliderNoAction)
|
self.setRepeatAction(QSlider.SliderAction.SliderNoAction)
|
||||||
self._setClickOffset(pos)
|
self._setClickOffset(pos)
|
||||||
self.update()
|
self.update()
|
||||||
self.setSliderDown(True)
|
self.setSliderDown(True)
|
||||||
@@ -238,20 +269,19 @@ class _GenericSlider(QSlider, Generic[_T]):
|
|||||||
ev.accept()
|
ev.accept()
|
||||||
oldPressed = self._pressedControl
|
oldPressed = self._pressedControl
|
||||||
self._pressedControl = SC_NONE
|
self._pressedControl = SC_NONE
|
||||||
self.setRepeatAction(QSlider.SliderNoAction)
|
self.setRepeatAction(QSlider.SliderAction.SliderNoAction)
|
||||||
if oldPressed != SC_NONE:
|
if oldPressed != SC_NONE:
|
||||||
self.setSliderDown(False)
|
self.setSliderDown(False)
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def wheelEvent(self, e: QtGui.QWheelEvent) -> None:
|
def wheelEvent(self, e: QtGui.QWheelEvent) -> None:
|
||||||
|
|
||||||
e.ignore()
|
e.ignore()
|
||||||
vertical = bool(e.angleDelta().y())
|
vertical = bool(e.angleDelta().y())
|
||||||
delta = e.angleDelta().y() if vertical else e.angleDelta().x()
|
delta = e.angleDelta().y() if vertical else e.angleDelta().x()
|
||||||
if e.inverted():
|
if e.inverted():
|
||||||
delta *= -1
|
delta *= -1
|
||||||
|
|
||||||
orientation = Qt.Vertical if vertical else Qt.Horizontal
|
orientation = Qt.Orientation.Vertical if vertical else Qt.Orientation.Horizontal
|
||||||
if self._scrollByDelta(orientation, e.modifiers(), delta):
|
if self._scrollByDelta(orientation, e.modifiers(), delta):
|
||||||
e.accept()
|
e.accept()
|
||||||
|
|
||||||
@@ -261,10 +291,31 @@ class _GenericSlider(QSlider, Generic[_T]):
|
|||||||
|
|
||||||
# draw groove and ticks
|
# draw groove and ticks
|
||||||
opt.subControls = SC_GROOVE
|
opt.subControls = SC_GROOVE
|
||||||
if opt.tickPosition != QSlider.NoTicks:
|
if opt.tickPosition != QSlider.TickPosition.NoTicks:
|
||||||
opt.subControls |= SC_TICKMARKS
|
opt.subControls |= SC_TICKMARKS
|
||||||
painter.drawComplexControl(CC_SLIDER, opt)
|
painter.drawComplexControl(CC_SLIDER, opt)
|
||||||
|
|
||||||
|
if (
|
||||||
|
opt.tickPosition != QSlider.TickPosition.NoTicks
|
||||||
|
and "MONTEREY_SLIDER_STYLES_FIX" in self.styleSheet()
|
||||||
|
):
|
||||||
|
# draw tick marks manually because they are badly behaved with style sheets
|
||||||
|
interval = opt.tickInterval or int(self._pageStep)
|
||||||
|
_range = self._maximum - self._minimum
|
||||||
|
nticks = (_range + interval) // interval
|
||||||
|
|
||||||
|
painter.setPen(QtGui.QColor("#C7C7C7"))
|
||||||
|
half_height = 3
|
||||||
|
for i in range(int(nticks)):
|
||||||
|
if self.orientation() == Qt.Orientation.Vertical:
|
||||||
|
y = int((self.height() - 8) * i / (nticks - 1)) + 1
|
||||||
|
x = self.rect().center().x()
|
||||||
|
painter.drawRect(x - half_height, y, 6, 1)
|
||||||
|
else:
|
||||||
|
x = int((self.width() - 3) * i / (nticks - 1)) + 1
|
||||||
|
y = self.rect().center().y()
|
||||||
|
painter.drawRect(x, y - half_height, 1, 6)
|
||||||
|
|
||||||
self._draw_handle(painter, opt)
|
self._draw_handle(painter, opt)
|
||||||
|
|
||||||
# ############### Implementation Details #######################
|
# ############### Implementation Details #######################
|
||||||
@@ -287,12 +338,12 @@ class _GenericSlider(QSlider, Generic[_T]):
|
|||||||
return int(min(QOVERFLOW, val / (self._maximum - self._minimum) * _max))
|
return int(min(QOVERFLOW, val / (self._maximum - self._minimum) * _max))
|
||||||
|
|
||||||
def _pick(self, pt: QPoint) -> int:
|
def _pick(self, pt: QPoint) -> int:
|
||||||
return pt.x() if self.orientation() == Qt.Horizontal else pt.y()
|
return pt.x() if self.orientation() == Qt.Orientation.Horizontal else pt.y()
|
||||||
|
|
||||||
def _setSteps(self, single: float, page: float):
|
def _setSteps(self, single: float, page: float):
|
||||||
self._singleStep = single
|
self._singleStep = single
|
||||||
self._pageStep = page
|
self._pageStep = page
|
||||||
self.sliderChange(QSlider.SliderStepsChange)
|
self.sliderChange(QSlider.SliderChange.SliderStepsChange)
|
||||||
|
|
||||||
def _doSliderMove(self):
|
def _doSliderMove(self):
|
||||||
if not self.hasTracking():
|
if not self.hasTracking():
|
||||||
@@ -300,7 +351,7 @@ class _GenericSlider(QSlider, Generic[_T]):
|
|||||||
if self.isSliderDown():
|
if self.isSliderDown():
|
||||||
self.sliderMoved.emit(self.sliderPosition())
|
self.sliderMoved.emit(self.sliderPosition())
|
||||||
if self.hasTracking() and not self._blocktracking:
|
if self.hasTracking() and not self._blocktracking:
|
||||||
self.triggerAction(QSlider.SliderMove)
|
self.triggerAction(QSlider.SliderAction.SliderMove)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _styleOption(self):
|
def _styleOption(self):
|
||||||
@@ -311,7 +362,7 @@ class _GenericSlider(QSlider, Generic[_T]):
|
|||||||
def _updateHoverControl(self, pos: QPoint) -> bool:
|
def _updateHoverControl(self, pos: QPoint) -> bool:
|
||||||
lastHoverRect = self._hoverRect
|
lastHoverRect = self._hoverRect
|
||||||
lastHoverControl = self._hoverControl
|
lastHoverControl = self._hoverControl
|
||||||
doesHover = self.testAttribute(Qt.WA_Hover)
|
doesHover = self.testAttribute(Qt.WidgetAttribute.WA_Hover)
|
||||||
if lastHoverControl != self._newHoverControl(pos) and doesHover:
|
if lastHoverControl != self._newHoverControl(pos) and doesHover:
|
||||||
self.update(lastHoverRect)
|
self.update(lastHoverRect)
|
||||||
self.update(self._hoverRect)
|
self.update(self._hoverRect)
|
||||||
@@ -351,7 +402,7 @@ class _GenericSlider(QSlider, Generic[_T]):
|
|||||||
opt.subControls = SC_HANDLE
|
opt.subControls = SC_HANDLE
|
||||||
if self._pressedControl:
|
if self._pressedControl:
|
||||||
opt.activeSubControls = self._pressedControl
|
opt.activeSubControls = self._pressedControl
|
||||||
opt.state |= QStyle.State_Sunken
|
opt.state |= QStyle.StateFlag.State_Sunken
|
||||||
else:
|
else:
|
||||||
opt.activeSubControls = self._hoverControl
|
opt.activeSubControls = self._hoverControl
|
||||||
|
|
||||||
@@ -364,7 +415,7 @@ class _GenericSlider(QSlider, Generic[_T]):
|
|||||||
gr = self.style().subControlRect(CC_SLIDER, opt, SC_GROOVE, self)
|
gr = self.style().subControlRect(CC_SLIDER, opt, SC_GROOVE, self)
|
||||||
sr = self.style().subControlRect(CC_SLIDER, opt, SC_HANDLE, self)
|
sr = self.style().subControlRect(CC_SLIDER, opt, SC_HANDLE, self)
|
||||||
|
|
||||||
if self.orientation() == Qt.Horizontal:
|
if self.orientation() == Qt.Orientation.Horizontal:
|
||||||
sliderLength = sr.width()
|
sliderLength = sr.width()
|
||||||
sliderMin = gr.x()
|
sliderMin = gr.x()
|
||||||
sliderMax = gr.right() - sliderLength + 1
|
sliderMax = gr.right() - sliderLength + 1
|
||||||
@@ -385,14 +436,14 @@ class _GenericSlider(QSlider, Generic[_T]):
|
|||||||
pg_step = self._pageStep
|
pg_step = self._pageStep
|
||||||
|
|
||||||
# in Qt scrolling to the right gives negative values.
|
# in Qt scrolling to the right gives negative values.
|
||||||
if orientation == Qt.Horizontal:
|
if orientation == Qt.Orientation.Horizontal:
|
||||||
delta *= -1
|
delta *= -1
|
||||||
offset = delta / 120
|
offset = delta / 120
|
||||||
if modifiers & Qt.ShiftModifier:
|
if modifiers & Qt.KeyboardModifier.ShiftModifier:
|
||||||
# Scroll one page regardless of delta:
|
# Scroll one page regardless of delta:
|
||||||
steps_to_scroll = max(-pg_step, min(pg_step, offset * pg_step))
|
steps_to_scroll = max(-pg_step, min(pg_step, offset * pg_step))
|
||||||
self._offset_accum = 0
|
self._offset_accum = 0
|
||||||
elif modifiers & Qt.ControlModifier:
|
elif modifiers & Qt.KeyboardModifier.ControlModifier:
|
||||||
_range = self._maximum - self._minimum
|
_range = self._maximum - self._minimum
|
||||||
steps_to_scroll = offset * _range * self._control_fraction
|
steps_to_scroll = offset * _range * self._control_fraction
|
||||||
self._offset_accum = 0
|
self._offset_accum = 0
|
||||||
@@ -440,7 +491,7 @@ class _GenericSlider(QSlider, Generic[_T]):
|
|||||||
|
|
||||||
def _execute_scroll(self, steps_to_scroll, modifiers):
|
def _execute_scroll(self, steps_to_scroll, modifiers):
|
||||||
self._setPosition(self._bound(self._overflowSafeAdd(steps_to_scroll)))
|
self._setPosition(self._bound(self._overflowSafeAdd(steps_to_scroll)))
|
||||||
self.triggerAction(QSlider.SliderMove)
|
self.triggerAction(QSlider.SliderAction.SliderMove)
|
||||||
|
|
||||||
def _effectiveSingleStep(self) -> float:
|
def _effectiveSingleStep(self) -> float:
|
||||||
return self._singleStep * self._repeatMultiplier
|
return self._singleStep * self._repeatMultiplier
|
||||||
@@ -469,16 +520,7 @@ def _event_position(ev: QEvent) -> QPoint:
|
|||||||
def _sliderValueFromPosition(
|
def _sliderValueFromPosition(
|
||||||
min: float, max: float, position: int, span: int, upsideDown: bool = False
|
min: float, max: float, position: int, span: int, upsideDown: bool = False
|
||||||
) -> float:
|
) -> float:
|
||||||
"""Converts the given pixel `position` to a value.
|
"""Converts the given pixel `position` to a value."""
|
||||||
|
|
||||||
0 maps to the `min` parameter, `span` maps to `max` and other values are
|
|
||||||
distributed evenly in-between.
|
|
||||||
|
|
||||||
By default, this function assumes that the maximum value is on the right
|
|
||||||
for horizontal items and on the bottom for vertical items. Set the
|
|
||||||
`upsideDown` parameter to True to reverse this behavior.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if span <= 0 or position <= 0:
|
if span <= 0 or position <= 0:
|
||||||
return max if upsideDown else min
|
return max if upsideDown else min
|
||||||
if position >= span:
|
if position >= span:
|
@@ -1,10 +1,11 @@
|
|||||||
|
import contextlib
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from ..qtcompat.QtCore import QPoint, QSize, Qt, Signal
|
from qtpy.QtCore import QPoint, QSize, Qt, Signal
|
||||||
from ..qtcompat.QtGui import QFontMetrics, QValidator
|
from qtpy.QtGui import QFontMetrics, QValidator
|
||||||
from ..qtcompat.QtWidgets import (
|
from qtpy.QtWidgets import (
|
||||||
QAbstractSlider,
|
QAbstractSlider,
|
||||||
QApplication,
|
QApplication,
|
||||||
QDoubleSpinBox,
|
QDoubleSpinBox,
|
||||||
@@ -16,6 +17,9 @@ from ..qtcompat.QtWidgets import (
|
|||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from superqt.utils import signals_blocked
|
||||||
|
|
||||||
from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
|
from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
|
||||||
|
|
||||||
|
|
||||||
@@ -93,7 +97,7 @@ class _SliderProxy:
|
|||||||
|
|
||||||
def _handle_overloaded_slider_sig(args, kwargs):
|
def _handle_overloaded_slider_sig(args, kwargs):
|
||||||
parent = None
|
parent = None
|
||||||
orientation = Qt.Vertical
|
orientation = Qt.Orientation.Vertical
|
||||||
errmsg = (
|
errmsg = (
|
||||||
"TypeError: arguments did not match any overloaded call:\n"
|
"TypeError: arguments did not match any overloaded call:\n"
|
||||||
" QSlider(parent: QWidget = None)\n"
|
" QSlider(parent: QWidget = None)\n"
|
||||||
@@ -117,6 +121,9 @@ def _handle_overloaded_slider_sig(args, kwargs):
|
|||||||
|
|
||||||
|
|
||||||
class QLabeledSlider(_SliderProxy, QAbstractSlider):
|
class QLabeledSlider(_SliderProxy, QAbstractSlider):
|
||||||
|
editingFinished = Signal()
|
||||||
|
|
||||||
|
EdgeLabelMode = EdgeLabelMode
|
||||||
_slider_class = QSlider
|
_slider_class = QSlider
|
||||||
_slider: QSlider
|
_slider: QSlider
|
||||||
|
|
||||||
@@ -124,50 +131,108 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
|
|||||||
parent, orientation = _handle_overloaded_slider_sig(args, kwargs)
|
parent, orientation = _handle_overloaded_slider_sig(args, kwargs)
|
||||||
|
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
# accept focus events
|
||||||
|
fp = self.style().styleHint(QStyle.StyleHint.SH_Button_FocusPolicy)
|
||||||
|
self.setFocusPolicy(Qt.FocusPolicy(fp))
|
||||||
|
|
||||||
self._slider = self._slider_class()
|
self._slider = self._slider_class()
|
||||||
self._label = SliderLabel(self._slider, connect=self._slider.setValue)
|
self._label = SliderLabel(self._slider, connect=self._setValue)
|
||||||
|
self._edge_label_mode: EdgeLabelMode = EdgeLabelMode.LabelIsValue
|
||||||
|
|
||||||
|
self._rename_signals()
|
||||||
|
self._slider.actionTriggered.connect(self.actionTriggered.emit)
|
||||||
self._slider.rangeChanged.connect(self.rangeChanged.emit)
|
self._slider.rangeChanged.connect(self.rangeChanged.emit)
|
||||||
self._slider.valueChanged.connect(self.valueChanged.emit)
|
self._slider.sliderMoved.connect(self.sliderMoved.emit)
|
||||||
|
self._slider.sliderPressed.connect(self.sliderPressed.emit)
|
||||||
|
self._slider.sliderReleased.connect(self.sliderReleased.emit)
|
||||||
self._slider.valueChanged.connect(self._label.setValue)
|
self._slider.valueChanged.connect(self._label.setValue)
|
||||||
|
self._slider.valueChanged.connect(self.valueChanged.emit)
|
||||||
|
self._label.editingFinished.connect(self.editingFinished)
|
||||||
|
|
||||||
self.setOrientation(orientation)
|
self.setOrientation(orientation)
|
||||||
|
|
||||||
|
def _setValue(self, value: float):
|
||||||
|
"""Convert the value from float to int before setting the slider value."""
|
||||||
|
self._slider.setValue(int(value))
|
||||||
|
|
||||||
|
def _rename_signals(self):
|
||||||
|
# for subclasses
|
||||||
|
pass
|
||||||
|
|
||||||
def setOrientation(self, orientation):
|
def setOrientation(self, orientation):
|
||||||
"""Set orientation, value will be 'horizontal' or 'vertical'."""
|
"""Set orientation, value will be 'horizontal' or 'vertical'."""
|
||||||
self._slider.setOrientation(orientation)
|
self._slider.setOrientation(orientation)
|
||||||
if orientation == Qt.Vertical:
|
marg = (0, 0, 0, 0)
|
||||||
|
if orientation == Qt.Orientation.Vertical:
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
layout.addWidget(self._slider, alignment=Qt.AlignHCenter)
|
layout.addWidget(self._slider, alignment=Qt.AlignmentFlag.AlignHCenter)
|
||||||
layout.addWidget(self._label, alignment=Qt.AlignHCenter)
|
layout.addWidget(self._label, alignment=Qt.AlignmentFlag.AlignHCenter)
|
||||||
self._label.setAlignment(Qt.AlignCenter)
|
self._label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
layout.setSpacing(1)
|
layout.setSpacing(1)
|
||||||
else:
|
else:
|
||||||
layout = QHBoxLayout()
|
if self._edge_label_mode == EdgeLabelMode.NoLabel:
|
||||||
|
marg = (0, 0, 5, 0)
|
||||||
|
|
||||||
|
layout = QHBoxLayout() # type: ignore
|
||||||
layout.addWidget(self._slider)
|
layout.addWidget(self._slider)
|
||||||
layout.addWidget(self._label)
|
layout.addWidget(self._label)
|
||||||
self._label.setAlignment(Qt.AlignRight)
|
self._label.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||||
layout.setSpacing(6)
|
layout.setSpacing(6)
|
||||||
|
|
||||||
old_layout = self.layout()
|
old_layout = self.layout()
|
||||||
if old_layout is not None:
|
if old_layout is not None:
|
||||||
QWidget().setLayout(old_layout)
|
QWidget().setLayout(old_layout)
|
||||||
|
|
||||||
layout.setContentsMargins(0, 0, 0, 0)
|
layout.setContentsMargins(*marg)
|
||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
|
|
||||||
|
def edgeLabelMode(self) -> EdgeLabelMode:
|
||||||
|
"""Return current `EdgeLabelMode`."""
|
||||||
|
return self._edge_label_mode
|
||||||
|
|
||||||
|
def setEdgeLabelMode(self, opt: EdgeLabelMode) -> None:
|
||||||
|
"""Set the `EdgeLabelMode`."""
|
||||||
|
if opt is EdgeLabelMode.LabelIsRange:
|
||||||
|
raise ValueError(
|
||||||
|
"mode must be one of 'EdgeLabelMode.NoLabel' or "
|
||||||
|
"'EdgeLabelMode.LabelIsValue'."
|
||||||
|
)
|
||||||
|
|
||||||
|
self._edge_label_mode = opt
|
||||||
|
if not self._edge_label_mode:
|
||||||
|
self._label.hide()
|
||||||
|
w = 5 if self.orientation() == Qt.Orientation.Horizontal else 0
|
||||||
|
self.layout().setContentsMargins(0, 0, w, 0)
|
||||||
|
else:
|
||||||
|
if self.isVisible():
|
||||||
|
self._label.show()
|
||||||
|
self._label.setMode(opt)
|
||||||
|
self._label.setValue(self._slider.value())
|
||||||
|
self.layout().setContentsMargins(0, 0, 0, 0)
|
||||||
|
|
||||||
|
QApplication.processEvents()
|
||||||
|
|
||||||
|
|
||||||
class QLabeledDoubleSlider(QLabeledSlider):
|
class QLabeledDoubleSlider(QLabeledSlider):
|
||||||
_slider_class = QDoubleSlider
|
_slider_class = QDoubleSlider
|
||||||
_slider: QDoubleSlider
|
_slider: QDoubleSlider
|
||||||
valueChanged = Signal(float)
|
_fvalueChanged = Signal(float)
|
||||||
rangeChanged = Signal(float, float)
|
_fsliderMoved = Signal(float)
|
||||||
|
_frangeChanged = Signal(float, float)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs) -> None:
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.setDecimals(2)
|
self.setDecimals(2)
|
||||||
|
|
||||||
|
def _setValue(self, value: float):
|
||||||
|
"""Convert the value from float to int before setting the slider value."""
|
||||||
|
self._slider.setValue(value)
|
||||||
|
|
||||||
|
def _rename_signals(self):
|
||||||
|
self.valueChanged = self._fvalueChanged
|
||||||
|
self.sliderMoved = self._fsliderMoved
|
||||||
|
self.rangeChanged = self._frangeChanged
|
||||||
|
|
||||||
def decimals(self) -> int:
|
def decimals(self) -> int:
|
||||||
return self._label.decimals()
|
return self._label.decimals()
|
||||||
|
|
||||||
@@ -176,7 +241,9 @@ class QLabeledDoubleSlider(QLabeledSlider):
|
|||||||
|
|
||||||
|
|
||||||
class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
||||||
valueChanged = Signal(tuple)
|
_valueChanged = Signal(tuple)
|
||||||
|
editingFinished = Signal()
|
||||||
|
|
||||||
LabelPosition = LabelPosition
|
LabelPosition = LabelPosition
|
||||||
EdgeLabelMode = EdgeLabelMode
|
EdgeLabelMode = EdgeLabelMode
|
||||||
_slider_class = QRangeSlider
|
_slider_class = QRangeSlider
|
||||||
@@ -185,7 +252,9 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
|||||||
def __init__(self, *args, **kwargs) -> None:
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
parent, orientation = _handle_overloaded_slider_sig(args, kwargs)
|
parent, orientation = _handle_overloaded_slider_sig(args, kwargs)
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setAttribute(Qt.WA_ShowWithoutActivating)
|
self._rename_signals()
|
||||||
|
|
||||||
|
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
|
||||||
self._handle_labels = []
|
self._handle_labels = []
|
||||||
self._handle_label_position: LabelPosition = LabelPosition.LabelsAbove
|
self._handle_label_position: LabelPosition = LabelPosition.LabelsAbove
|
||||||
|
|
||||||
@@ -198,11 +267,17 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
|||||||
self._slider.rangeChanged.connect(self.rangeChanged.emit)
|
self._slider.rangeChanged.connect(self.rangeChanged.emit)
|
||||||
|
|
||||||
self._min_label = SliderLabel(
|
self._min_label = SliderLabel(
|
||||||
self._slider, alignment=Qt.AlignLeft, connect=self._min_label_edited
|
self._slider,
|
||||||
|
alignment=Qt.AlignmentFlag.AlignLeft,
|
||||||
|
connect=self._min_label_edited,
|
||||||
)
|
)
|
||||||
self._max_label = SliderLabel(
|
self._max_label = SliderLabel(
|
||||||
self._slider, alignment=Qt.AlignRight, connect=self._max_label_edited
|
self._slider,
|
||||||
|
alignment=Qt.AlignmentFlag.AlignRight,
|
||||||
|
connect=self._max_label_edited,
|
||||||
)
|
)
|
||||||
|
self._min_label.editingFinished.connect(self.editingFinished)
|
||||||
|
self._max_label.editingFinished.connect(self.editingFinished)
|
||||||
self.setEdgeLabelMode(EdgeLabelMode.LabelIsRange)
|
self.setEdgeLabelMode(EdgeLabelMode.LabelIsRange)
|
||||||
|
|
||||||
self._slider.valueChanged.connect(self._on_value_changed)
|
self._slider.valueChanged.connect(self._on_value_changed)
|
||||||
@@ -212,10 +287,15 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
|||||||
self._on_range_changed(self._slider.minimum(), self._slider.maximum())
|
self._on_range_changed(self._slider.minimum(), self._slider.maximum())
|
||||||
self.setOrientation(orientation)
|
self.setOrientation(orientation)
|
||||||
|
|
||||||
|
def _rename_signals(self):
|
||||||
|
self.valueChanged = self._valueChanged
|
||||||
|
|
||||||
def handleLabelPosition(self) -> LabelPosition:
|
def handleLabelPosition(self) -> LabelPosition:
|
||||||
|
"""Return where/whether labels are shown adjacent to slider handles."""
|
||||||
return self._handle_label_position
|
return self._handle_label_position
|
||||||
|
|
||||||
def setHandleLabelPosition(self, opt: LabelPosition) -> LabelPosition:
|
def setHandleLabelPosition(self, opt: LabelPosition) -> LabelPosition:
|
||||||
|
"""Set where/whether labels are shown adjacent to slider handles."""
|
||||||
self._handle_label_position = opt
|
self._handle_label_position = opt
|
||||||
for lbl in self._handle_labels:
|
for lbl in self._handle_labels:
|
||||||
if not opt:
|
if not opt:
|
||||||
@@ -225,9 +305,11 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
|||||||
self.setOrientation(self.orientation())
|
self.setOrientation(self.orientation())
|
||||||
|
|
||||||
def edgeLabelMode(self) -> EdgeLabelMode:
|
def edgeLabelMode(self) -> EdgeLabelMode:
|
||||||
|
"""Return current `EdgeLabelMode`."""
|
||||||
return self._edge_label_mode
|
return self._edge_label_mode
|
||||||
|
|
||||||
def setEdgeLabelMode(self, opt: EdgeLabelMode):
|
def setEdgeLabelMode(self, opt: EdgeLabelMode):
|
||||||
|
"""Set `EdgeLabelMode`, controls what is shown at the min/max labels."""
|
||||||
self._edge_label_mode = opt
|
self._edge_label_mode = opt
|
||||||
if not self._edge_label_mode:
|
if not self._edge_label_mode:
|
||||||
self._min_label.hide()
|
self._min_label.hide()
|
||||||
@@ -249,10 +331,13 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
|||||||
self._reposition_labels()
|
self._reposition_labels()
|
||||||
|
|
||||||
def _reposition_labels(self):
|
def _reposition_labels(self):
|
||||||
if not self._handle_labels:
|
if (
|
||||||
|
not self._handle_labels
|
||||||
|
or self._handle_label_position == LabelPosition.NoLabel
|
||||||
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
horizontal = self.orientation() == Qt.Horizontal
|
horizontal = self.orientation() == Qt.Orientation.Horizontal
|
||||||
labels_above = self._handle_label_position == LabelPosition.LabelsAbove
|
labels_above = self._handle_label_position == LabelPosition.LabelsAbove
|
||||||
|
|
||||||
last_edge = None
|
last_edge = None
|
||||||
@@ -281,6 +366,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
|||||||
label.move(pos)
|
label.move(pos)
|
||||||
last_edge = pos
|
last_edge = pos
|
||||||
label.clearFocus()
|
label.clearFocus()
|
||||||
|
label.show()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def _min_label_edited(self, val):
|
def _min_label_edited(self, val):
|
||||||
@@ -314,6 +400,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
|||||||
for n, val in enumerate(self._slider.value()):
|
for n, val in enumerate(self._slider.value()):
|
||||||
_cb = partial(self._slider.setSliderPosition, index=n)
|
_cb = partial(self._slider.setSliderPosition, index=n)
|
||||||
s = SliderLabel(self._slider, parent=self, connect=_cb)
|
s = SliderLabel(self._slider, parent=self, connect=_cb)
|
||||||
|
s.editingFinished.connect(self.editingFinished)
|
||||||
s.setValue(val)
|
s.setValue(val)
|
||||||
self._handle_labels.append(s)
|
self._handle_labels.append(s)
|
||||||
else:
|
else:
|
||||||
@@ -340,9 +427,8 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
|||||||
|
|
||||||
def setOrientation(self, orientation):
|
def setOrientation(self, orientation):
|
||||||
"""Set orientation, value will be 'horizontal' or 'vertical'."""
|
"""Set orientation, value will be 'horizontal' or 'vertical'."""
|
||||||
|
|
||||||
self._slider.setOrientation(orientation)
|
self._slider.setOrientation(orientation)
|
||||||
if orientation == Qt.Vertical:
|
if orientation == Qt.Orientation.Vertical:
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
layout.setSpacing(1)
|
layout.setSpacing(1)
|
||||||
layout.addWidget(self._max_label)
|
layout.addWidget(self._max_label)
|
||||||
@@ -355,7 +441,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
|||||||
marg = (0, 0, 0, 0)
|
marg = (0, 0, 0, 0)
|
||||||
else:
|
else:
|
||||||
marg = (0, 0, 20, 0)
|
marg = (0, 0, 20, 0)
|
||||||
layout.setAlignment(Qt.AlignCenter)
|
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
else:
|
else:
|
||||||
layout = QHBoxLayout()
|
layout = QHBoxLayout()
|
||||||
layout.setSpacing(7)
|
layout.setSpacing(7)
|
||||||
@@ -388,12 +474,16 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
|||||||
class QLabeledDoubleRangeSlider(QLabeledRangeSlider):
|
class QLabeledDoubleRangeSlider(QLabeledRangeSlider):
|
||||||
_slider_class = QDoubleRangeSlider
|
_slider_class = QDoubleRangeSlider
|
||||||
_slider: QDoubleRangeSlider
|
_slider: QDoubleRangeSlider
|
||||||
rangeChanged = Signal(float, float)
|
_frangeChanged = Signal(float, float)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs) -> None:
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.setDecimals(2)
|
self.setDecimals(2)
|
||||||
|
|
||||||
|
def _rename_signals(self):
|
||||||
|
super()._rename_signals()
|
||||||
|
self.rangeChanged = self._frangeChanged
|
||||||
|
|
||||||
def decimals(self) -> int:
|
def decimals(self) -> int:
|
||||||
return self._min_label.decimals()
|
return self._min_label.decimals()
|
||||||
|
|
||||||
@@ -406,24 +496,32 @@ class QLabeledDoubleRangeSlider(QLabeledRangeSlider):
|
|||||||
|
|
||||||
class SliderLabel(QDoubleSpinBox):
|
class SliderLabel(QDoubleSpinBox):
|
||||||
def __init__(
|
def __init__(
|
||||||
self, slider: QSlider, parent=None, alignment=Qt.AlignCenter, connect=None
|
self,
|
||||||
|
slider: QSlider,
|
||||||
|
parent=None,
|
||||||
|
alignment=Qt.AlignmentFlag.AlignCenter,
|
||||||
|
connect=None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(parent=parent)
|
super().__init__(parent=parent)
|
||||||
self._slider = slider
|
self._slider = slider
|
||||||
self.setFocusPolicy(Qt.ClickFocus)
|
self.setFocusPolicy(Qt.FocusPolicy.ClickFocus)
|
||||||
self.setMode(EdgeLabelMode.LabelIsValue)
|
self.setMode(EdgeLabelMode.LabelIsValue)
|
||||||
self.setDecimals(0)
|
self.setDecimals(0)
|
||||||
|
|
||||||
self.setRange(slider.minimum(), slider.maximum())
|
self.setRange(slider.minimum(), slider.maximum())
|
||||||
slider.rangeChanged.connect(self._update_size)
|
slider.rangeChanged.connect(self._update_size)
|
||||||
self.setAlignment(alignment)
|
self.setAlignment(alignment)
|
||||||
self.setButtonSymbols(QSpinBox.NoButtons)
|
self.setButtonSymbols(QSpinBox.ButtonSymbols.NoButtons)
|
||||||
self.setStyleSheet("background:transparent; border: 0;")
|
self.setStyleSheet("background:transparent; border: 0;")
|
||||||
if connect is not None:
|
if connect is not None:
|
||||||
self.editingFinished.connect(lambda: connect(self.value()))
|
self.editingFinished.connect(lambda: connect(self.value()))
|
||||||
self.editingFinished.connect(self.clearFocus)
|
self.editingFinished.connect(self._silent_clear_focus)
|
||||||
self._update_size()
|
self._update_size()
|
||||||
|
|
||||||
|
def _silent_clear_focus(self):
|
||||||
|
with signals_blocked(self):
|
||||||
|
self.clearFocus()
|
||||||
|
|
||||||
def setDecimals(self, prec: int) -> None:
|
def setDecimals(self, prec: int) -> None:
|
||||||
super().setDecimals(prec)
|
super().setDecimals(prec)
|
||||||
self._update_size()
|
self._update_size()
|
||||||
@@ -449,10 +547,12 @@ class SliderLabel(QDoubleSpinBox):
|
|||||||
# get the final size hint
|
# get the final size hint
|
||||||
opt = QStyleOptionSpinBox()
|
opt = QStyleOptionSpinBox()
|
||||||
self.initStyleOption(opt)
|
self.initStyleOption(opt)
|
||||||
size = self.style().sizeFromContents(QStyle.CT_SpinBox, opt, QSize(w, h), self)
|
size = self.style().sizeFromContents(
|
||||||
|
QStyle.ContentsType.CT_SpinBox, opt, QSize(w, h), self
|
||||||
|
)
|
||||||
self.setFixedSize(size)
|
self.setFixedSize(size)
|
||||||
|
|
||||||
def setValue(self, val):
|
def setValue(self, val: Any) -> None:
|
||||||
super().setValue(val)
|
super().setValue(val)
|
||||||
if self._mode == EdgeLabelMode.LabelIsRange:
|
if self._mode == EdgeLabelMode.LabelIsRange:
|
||||||
self._update_size()
|
self._update_size()
|
||||||
@@ -474,10 +574,8 @@ class SliderLabel(QDoubleSpinBox):
|
|||||||
if opt == EdgeLabelMode.LabelIsRange:
|
if opt == EdgeLabelMode.LabelIsRange:
|
||||||
self.setMinimum(-9999999)
|
self.setMinimum(-9999999)
|
||||||
self.setMaximum(9999999)
|
self.setMaximum(9999999)
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
self._slider.rangeChanged.disconnect(self.setRange)
|
self._slider.rangeChanged.disconnect(self.setRange)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
else:
|
else:
|
||||||
self.setMinimum(self._slider.minimum())
|
self.setMinimum(self._slider.minimum())
|
||||||
self.setMaximum(self._slider.maximum())
|
self.setMaximum(self._slider.maximum())
|
@@ -5,9 +5,9 @@ import re
|
|||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from ..qtcompat import PYQT_VERSION
|
from qtpy import QT_VERSION
|
||||||
from ..qtcompat.QtCore import Qt
|
from qtpy.QtCore import Qt
|
||||||
from ..qtcompat.QtGui import (
|
from qtpy.QtGui import (
|
||||||
QBrush,
|
QBrush,
|
||||||
QColor,
|
QColor,
|
||||||
QGradient,
|
QGradient,
|
||||||
@@ -15,7 +15,7 @@ from ..qtcompat.QtGui import (
|
|||||||
QPalette,
|
QPalette,
|
||||||
QRadialGradient,
|
QRadialGradient,
|
||||||
)
|
)
|
||||||
from ..qtcompat.QtWidgets import QApplication, QSlider, QStyleOptionSlider
|
from qtpy.QtWidgets import QApplication, QSlider, QStyleOptionSlider
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ._generic_range_slider import _GenericRangeSlider
|
from ._generic_range_slider import _GenericRangeSlider
|
||||||
@@ -36,13 +36,14 @@ class RangeSliderStyle:
|
|||||||
v_offset: float | None = None
|
v_offset: float | None = None
|
||||||
h_offset: float | None = None
|
h_offset: float | None = None
|
||||||
has_stylesheet: bool = False
|
has_stylesheet: bool = False
|
||||||
|
_macpatch: bool = False
|
||||||
|
|
||||||
def brush(self, opt: QStyleOptionSlider) -> QBrush:
|
def brush(self, opt: QStyleOptionSlider) -> QBrush:
|
||||||
cg = opt.palette.currentColorGroup()
|
cg = opt.palette.currentColorGroup()
|
||||||
attr = {
|
attr = {
|
||||||
QPalette.Active: "brush_active", # 0
|
QPalette.ColorGroup.Active: "brush_active", # 0
|
||||||
QPalette.Disabled: "brush_disabled", # 1
|
QPalette.ColorGroup.Disabled: "brush_disabled", # 1
|
||||||
QPalette.Inactive: "brush_inactive", # 2
|
QPalette.ColorGroup.Inactive: "brush_inactive", # 2
|
||||||
}[cg]
|
}[cg]
|
||||||
_val = getattr(self, attr)
|
_val = getattr(self, attr)
|
||||||
if not _val:
|
if not _val:
|
||||||
@@ -67,7 +68,7 @@ class RangeSliderStyle:
|
|||||||
else:
|
else:
|
||||||
val = _val
|
val = _val
|
||||||
|
|
||||||
if opt.tickPosition != QSlider.NoTicks:
|
if opt.tickPosition != QSlider.TickPosition.NoTicks:
|
||||||
val.setAlphaF(self.tick_bar_alpha or SYSTEM_STYLE.tick_bar_alpha)
|
val.setAlphaF(self.tick_bar_alpha or SYSTEM_STYLE.tick_bar_alpha)
|
||||||
|
|
||||||
return QBrush(val)
|
return QBrush(val)
|
||||||
@@ -75,36 +76,36 @@ class RangeSliderStyle:
|
|||||||
def pen(self, opt: QStyleOptionSlider) -> Qt.PenStyle | QColor:
|
def pen(self, opt: QStyleOptionSlider) -> Qt.PenStyle | QColor:
|
||||||
cg = opt.palette.currentColorGroup()
|
cg = opt.palette.currentColorGroup()
|
||||||
attr = {
|
attr = {
|
||||||
QPalette.Active: "pen_active", # 0
|
QPalette.ColorGroup.Active: "pen_active", # 0
|
||||||
QPalette.Disabled: "pen_disabled", # 1
|
QPalette.ColorGroup.Disabled: "pen_disabled", # 1
|
||||||
QPalette.Inactive: "pen_inactive", # 2
|
QPalette.ColorGroup.Inactive: "pen_inactive", # 2
|
||||||
}[cg]
|
}[cg]
|
||||||
val = getattr(self, attr) or getattr(SYSTEM_STYLE, attr)
|
val = getattr(self, attr) or getattr(SYSTEM_STYLE, attr)
|
||||||
if not val:
|
if not val:
|
||||||
return Qt.NoPen
|
return Qt.PenStyle.NoPen
|
||||||
if isinstance(val, str):
|
if isinstance(val, str):
|
||||||
val = QColor(val)
|
val = QColor(val)
|
||||||
if opt.tickPosition != QSlider.NoTicks:
|
if opt.tickPosition != QSlider.TickPosition.NoTicks:
|
||||||
val.setAlphaF(self.tick_bar_alpha or SYSTEM_STYLE.tick_bar_alpha)
|
val.setAlphaF(self.tick_bar_alpha or SYSTEM_STYLE.tick_bar_alpha)
|
||||||
|
|
||||||
return val
|
return val
|
||||||
|
|
||||||
def offset(self, opt: QStyleOptionSlider) -> int:
|
def offset(self, opt: QStyleOptionSlider) -> int:
|
||||||
tp = opt.tickPosition
|
|
||||||
off = 0
|
off = 0
|
||||||
if not self.has_stylesheet:
|
if not self.has_stylesheet:
|
||||||
if opt.orientation == Qt.Horizontal:
|
tp = opt.tickPosition
|
||||||
off += self.h_offset or SYSTEM_STYLE.h_offset or 0
|
if opt.orientation == Qt.Orientation.Horizontal:
|
||||||
|
if not self._macpatch:
|
||||||
|
off += self.h_offset or SYSTEM_STYLE.h_offset or 0
|
||||||
else:
|
else:
|
||||||
off += self.v_offset or SYSTEM_STYLE.v_offset or 0
|
off += self.v_offset or SYSTEM_STYLE.v_offset or 0
|
||||||
if tp == QSlider.TicksAbove:
|
if tp == QSlider.TickPosition.TicksAbove:
|
||||||
off += self.tick_offset or SYSTEM_STYLE.tick_offset
|
off += self.tick_offset or SYSTEM_STYLE.tick_offset
|
||||||
elif tp == QSlider.TicksBelow:
|
elif tp == QSlider.TickPosition.TicksBelow:
|
||||||
off -= self.tick_offset or SYSTEM_STYLE.tick_offset
|
off -= self.tick_offset or SYSTEM_STYLE.tick_offset
|
||||||
return off
|
return off
|
||||||
|
|
||||||
def thickness(self, opt: QStyleOptionSlider) -> float:
|
def thickness(self, opt: QStyleOptionSlider) -> float:
|
||||||
if opt.orientation == Qt.Horizontal:
|
if opt.orientation == Qt.Orientation.Horizontal:
|
||||||
return self.horizontal_thickness or SYSTEM_STYLE.horizontal_thickness
|
return self.horizontal_thickness or SYSTEM_STYLE.horizontal_thickness
|
||||||
else:
|
else:
|
||||||
return self.vertical_thickness or SYSTEM_STYLE.vertical_thickness
|
return self.vertical_thickness or SYSTEM_STYLE.vertical_thickness
|
||||||
@@ -139,7 +140,7 @@ CATALINA_STYLE = replace(
|
|||||||
tick_offset=4,
|
tick_offset=4,
|
||||||
)
|
)
|
||||||
|
|
||||||
if PYQT_VERSION and int(PYQT_VERSION.split(".")[0]) == 6:
|
if QT_VERSION and int(QT_VERSION.split(".")[0]) == 6:
|
||||||
CATALINA_STYLE = replace(CATALINA_STYLE, tick_offset=2)
|
CATALINA_STYLE = replace(CATALINA_STYLE, tick_offset=2)
|
||||||
|
|
||||||
BIG_SUR_STYLE = replace(
|
BIG_SUR_STYLE = replace(
|
||||||
@@ -154,7 +155,7 @@ BIG_SUR_STYLE = replace(
|
|||||||
tick_bar_alpha=0.2,
|
tick_bar_alpha=0.2,
|
||||||
)
|
)
|
||||||
|
|
||||||
if PYQT_VERSION and int(PYQT_VERSION.split(".")[0]) == 6:
|
if QT_VERSION and int(QT_VERSION.split(".")[0]) == 6:
|
||||||
BIG_SUR_STYLE = replace(BIG_SUR_STYLE, tick_offset=-3)
|
BIG_SUR_STYLE = replace(BIG_SUR_STYLE, tick_offset=-3)
|
||||||
|
|
||||||
WINDOWS_STYLE = replace(
|
WINDOWS_STYLE = replace(
|
||||||
@@ -259,7 +260,7 @@ def parse_color(color: str, default_attr) -> QColor | QGradient:
|
|||||||
|
|
||||||
|
|
||||||
def update_styles_from_stylesheet(obj: _GenericRangeSlider):
|
def update_styles_from_stylesheet(obj: _GenericRangeSlider):
|
||||||
qss = obj.styleSheet()
|
qss: str = obj.styleSheet()
|
||||||
|
|
||||||
parent = obj.parent()
|
parent = obj.parent()
|
||||||
while parent is not None:
|
while parent is not None:
|
||||||
@@ -268,6 +269,11 @@ def update_styles_from_stylesheet(obj: _GenericRangeSlider):
|
|||||||
qss = QApplication.instance().styleSheet() + qss
|
qss = QApplication.instance().styleSheet() + qss
|
||||||
if not qss:
|
if not qss:
|
||||||
return
|
return
|
||||||
|
if MONTEREY_SLIDER_STYLES_FIX in qss:
|
||||||
|
qss = qss.replace(MONTEREY_SLIDER_STYLES_FIX, "")
|
||||||
|
obj._style._macpatch = True
|
||||||
|
else:
|
||||||
|
obj._style._macpatch = False
|
||||||
|
|
||||||
# Find bar height/width
|
# Find bar height/width
|
||||||
for orient, dim in (("horizontal", "height"), ("vertical", "width")):
|
for orient, dim in (("horizontal", "height"), ("vertical", "width")):
|
||||||
@@ -279,3 +285,56 @@ def update_styles_from_stylesheet(obj: _GenericRangeSlider):
|
|||||||
thickness = float(bgrd.groups()[-1])
|
thickness = float(bgrd.groups()[-1])
|
||||||
setattr(obj._style, f"{orient}_thickness", thickness)
|
setattr(obj._style, f"{orient}_thickness", thickness)
|
||||||
obj._style.has_stylesheet = True
|
obj._style.has_stylesheet = True
|
||||||
|
|
||||||
|
|
||||||
|
# a fix for https://bugreports.qt.io/browse/QTBUG-98093
|
||||||
|
|
||||||
|
MONTEREY_SLIDER_STYLES_FIX = """
|
||||||
|
/* MONTEREY_SLIDER_STYLES_FIX */
|
||||||
|
|
||||||
|
QSlider::groove {
|
||||||
|
background: #DFDFDF;
|
||||||
|
border: 1px solid #DBDBDB;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
QSlider::groove:horizontal {
|
||||||
|
height: 2px;
|
||||||
|
margin: 2px;
|
||||||
|
}
|
||||||
|
QSlider::groove:vertical {
|
||||||
|
width: 2px;
|
||||||
|
margin: 2px 0 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
QSlider::handle {
|
||||||
|
background: white;
|
||||||
|
border: 0.5px solid #DADADA;
|
||||||
|
width: 19.5px;
|
||||||
|
height: 19.5px;
|
||||||
|
border-radius: 10.5px;
|
||||||
|
}
|
||||||
|
QSlider::handle:horizontal {
|
||||||
|
margin: -10px -2px;
|
||||||
|
}
|
||||||
|
QSlider::handle:vertical {
|
||||||
|
margin: -2px -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QSlider::handle:pressed {
|
||||||
|
background: #F0F0F0;
|
||||||
|
}
|
||||||
|
|
||||||
|
QSlider::sub-page:horizontal {
|
||||||
|
background: #0981FE;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin: 2px;
|
||||||
|
height: 2px;
|
||||||
|
}
|
||||||
|
QSlider::add-page:vertical {
|
||||||
|
background: #0981FE;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin: 2px 0 6px 0;
|
||||||
|
width: 2px;
|
||||||
|
}
|
||||||
|
""".strip()
|
@@ -1,4 +1,5 @@
|
|||||||
from ..qtcompat.QtCore import Signal
|
from qtpy.QtCore import Signal
|
||||||
|
|
||||||
from ._generic_range_slider import _GenericRangeSlider
|
from ._generic_range_slider import _GenericRangeSlider
|
||||||
from ._generic_slider import _GenericSlider
|
from ._generic_slider import _GenericSlider
|
||||||
|
|
||||||
@@ -13,6 +14,10 @@ class _IntMixin:
|
|||||||
|
|
||||||
|
|
||||||
class _FloatMixin:
|
class _FloatMixin:
|
||||||
|
_fvalueChanged = Signal(float)
|
||||||
|
_fsliderMoved = Signal(float)
|
||||||
|
_frangeChanged = Signal(float, float)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self._singleStep = 0.01
|
self._singleStep = 0.01
|
||||||
@@ -22,11 +27,11 @@ class _FloatMixin:
|
|||||||
return float(value)
|
return float(value)
|
||||||
|
|
||||||
|
|
||||||
class QDoubleSlider(_FloatMixin, _GenericSlider[float]):
|
class QDoubleSlider(_FloatMixin, _GenericSlider):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class QIntSlider(_IntMixin, _GenericSlider[int]):
|
class QIntSlider(_IntMixin, _GenericSlider):
|
||||||
# mostly just an example... use QSlider instead.
|
# mostly just an example... use QSlider instead.
|
||||||
valueChanged = Signal(int)
|
valueChanged = Signal(int)
|
||||||
|
|
@@ -1,8 +1,9 @@
|
|||||||
|
import math
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
from ..qtcompat.QtCore import QSize, Qt, Signal
|
from qtpy.QtCore import QSize, Qt, Signal
|
||||||
from ..qtcompat.QtGui import QFontMetrics, QValidator
|
from qtpy.QtGui import QFontMetrics, QValidator
|
||||||
from ..qtcompat.QtWidgets import QAbstractSpinBox, QStyle, QStyleOptionSpinBox
|
from qtpy.QtWidgets import QAbstractSpinBox, QStyle, QStyleOptionSpinBox
|
||||||
|
|
||||||
|
|
||||||
class _EmitPolicy(Enum):
|
class _EmitPolicy(Enum):
|
||||||
@@ -24,7 +25,7 @@ class _AnyIntValidator(QValidator):
|
|||||||
|
|
||||||
|
|
||||||
class QLargeIntSpinBox(QAbstractSpinBox):
|
class QLargeIntSpinBox(QAbstractSpinBox):
|
||||||
"""An integer spinboxes backed by unbound python integer
|
"""An integer spinboxes backed by unbound python integer.
|
||||||
|
|
||||||
Qt's built-in ``QSpinBox`` is backed by a signed 32-bit integer.
|
Qt's built-in ``QSpinBox`` is backed by a signed 32-bit integer.
|
||||||
This could become limiting, particularly in large dense segmentations.
|
This could become limiting, particularly in large dense segmentations.
|
||||||
@@ -40,8 +41,11 @@ class QLargeIntSpinBox(QAbstractSpinBox):
|
|||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._value: int = 0
|
self._value: int = 0
|
||||||
self._minimum: int = 0
|
self._minimum: int = 0
|
||||||
self._maximum: int = 2 ** 64 - 1
|
self._maximum: int = 2**64 - 1
|
||||||
self._single_step: int = 1
|
self._single_step: int = 1
|
||||||
|
self._step_type: QAbstractSpinBox.StepType = (
|
||||||
|
QAbstractSpinBox.StepType.DefaultStepType
|
||||||
|
)
|
||||||
self._pending_emit = False
|
self._pending_emit = False
|
||||||
validator = _AnyIntValidator(self)
|
validator = _AnyIntValidator(self)
|
||||||
self.lineEdit().setValidator(validator)
|
self.lineEdit().setValidator(validator)
|
||||||
@@ -78,7 +82,13 @@ class QLargeIntSpinBox(QAbstractSpinBox):
|
|||||||
def setSingleStep(self, step):
|
def setSingleStep(self, step):
|
||||||
self._single_step = int(step)
|
self._single_step = int(step)
|
||||||
|
|
||||||
# TODO: add prefix/suffix/stepType
|
def setStepType(self, stepType: QAbstractSpinBox.StepType) -> None:
|
||||||
|
self._step_type = stepType
|
||||||
|
|
||||||
|
def stepType(self) -> QAbstractSpinBox.StepType:
|
||||||
|
return self._step_type
|
||||||
|
|
||||||
|
# TODO: add prefix/suffix
|
||||||
|
|
||||||
# ############### QtOverrides #######################
|
# ############### QtOverrides #######################
|
||||||
|
|
||||||
@@ -93,7 +103,7 @@ class QLargeIntSpinBox(QAbstractSpinBox):
|
|||||||
return super().closeEvent(e)
|
return super().closeEvent(e)
|
||||||
|
|
||||||
def keyPressEvent(self, e) -> None:
|
def keyPressEvent(self, e) -> None:
|
||||||
if e.key() in (Qt.Key_Enter, Qt.Key_Return):
|
if e.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return):
|
||||||
self._interpret(
|
self._interpret(
|
||||||
_EmitPolicy.AlwaysEmit
|
_EmitPolicy.AlwaysEmit
|
||||||
if self.keyboardTracking()
|
if self.keyboardTracking()
|
||||||
@@ -102,23 +112,26 @@ class QLargeIntSpinBox(QAbstractSpinBox):
|
|||||||
return super().keyPressEvent(e)
|
return super().keyPressEvent(e)
|
||||||
|
|
||||||
def stepBy(self, steps: int) -> None:
|
def stepBy(self, steps: int) -> None:
|
||||||
step = self._single_step
|
|
||||||
old = self._value
|
old = self._value
|
||||||
e = _EmitPolicy.EmitIfChanged
|
e = _EmitPolicy.EmitIfChanged
|
||||||
if self._pending_emit:
|
if self._pending_emit:
|
||||||
self._interpret(_EmitPolicy.NeverEmit)
|
self._interpret(_EmitPolicy.NeverEmit)
|
||||||
if self._value != old:
|
if self._value != old:
|
||||||
e = _EmitPolicy.AlwaysEmit
|
e = _EmitPolicy.AlwaysEmit
|
||||||
|
if self._step_type == QAbstractSpinBox.StepType.AdaptiveDecimalStepType:
|
||||||
|
step = self._calculate_adaptive_decimal_step(steps)
|
||||||
|
else:
|
||||||
|
step = self._single_step
|
||||||
self._setValue(self._bound(self._value + (step * steps)), e)
|
self._setValue(self._bound(self._value + (step * steps)), e)
|
||||||
|
|
||||||
def stepEnabled(self):
|
def stepEnabled(self):
|
||||||
flags = QAbstractSpinBox.StepNone
|
flags = QAbstractSpinBox.StepEnabledFlag.StepNone
|
||||||
if self.isReadOnly():
|
if self.isReadOnly():
|
||||||
return flags
|
return flags
|
||||||
if self._value < self._maximum:
|
if self._value < self._maximum:
|
||||||
flags |= QAbstractSpinBox.StepUpEnabled
|
flags |= QAbstractSpinBox.StepEnabledFlag.StepUpEnabled
|
||||||
if self._value > self._minimum:
|
if self._value > self._minimum:
|
||||||
flags |= QAbstractSpinBox.StepDownEnabled
|
flags |= QAbstractSpinBox.StepEnabledFlag.StepDownEnabled
|
||||||
return flags
|
return flags
|
||||||
|
|
||||||
def sizeHint(self):
|
def sizeHint(self):
|
||||||
@@ -134,7 +147,9 @@ class QLargeIntSpinBox(QAbstractSpinBox):
|
|||||||
opt = QStyleOptionSpinBox()
|
opt = QStyleOptionSpinBox()
|
||||||
self.initStyleOption(opt)
|
self.initStyleOption(opt)
|
||||||
hint = QSize(w, h)
|
hint = QSize(w, h)
|
||||||
return self.style().sizeFromContents(QStyle.CT_SpinBox, opt, hint, self)
|
return self.style().sizeFromContents(
|
||||||
|
QStyle.ContentsType.CT_SpinBox, opt, hint, self
|
||||||
|
)
|
||||||
|
|
||||||
# ############### Implementation Details #######################
|
# ############### Implementation Details #######################
|
||||||
|
|
||||||
@@ -162,9 +177,12 @@ class QLargeIntSpinBox(QAbstractSpinBox):
|
|||||||
v = int(text)
|
v = int(text)
|
||||||
self._setValue(v, policy)
|
self._setValue(v, policy)
|
||||||
|
|
||||||
def _editor_text_changed(self, t):
|
def _editor_text_changed(self, t: str) -> None:
|
||||||
if self.keyboardTracking():
|
if self.keyboardTracking():
|
||||||
self._setValue(int(t), _EmitPolicy.EmitIfChanged)
|
try:
|
||||||
|
self._setValue(int(t), _EmitPolicy.EmitIfChanged)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
self.lineEdit().setFocus()
|
self.lineEdit().setFocus()
|
||||||
self._pending_emit = False
|
self._pending_emit = False
|
||||||
else:
|
else:
|
||||||
@@ -172,3 +190,15 @@ class QLargeIntSpinBox(QAbstractSpinBox):
|
|||||||
|
|
||||||
def _bound(self, value):
|
def _bound(self, value):
|
||||||
return max(self._minimum, min(self._maximum, value))
|
return max(self._minimum, min(self._maximum, value))
|
||||||
|
|
||||||
|
def _calculate_adaptive_decimal_step(self, steps: int) -> int:
|
||||||
|
abs_value = abs(self._value)
|
||||||
|
if abs_value < 100:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
value_negative = self._value < 0
|
||||||
|
steps_negative = steps < 0
|
||||||
|
sign_compensation = 0 if value_negative == steps_negative else 1
|
||||||
|
|
||||||
|
log = int(math.log10(abs_value - sign_compensation)) - 1
|
||||||
|
return int(math.pow(10, log))
|
234
src/superqt/spinbox/_quantity.py
Normal file
234
src/superqt/spinbox/_quantity.py
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
from typing import TYPE_CHECKING, Optional, Union
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pint import Quantity, Unit, UnitRegistry
|
||||||
|
from pint.util import UnitsContainer
|
||||||
|
except ImportError as e:
|
||||||
|
raise ImportError(
|
||||||
|
"pint is required to use QQuantity. Install it with `pip install pint`"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
from qtpy.QtCore import Signal
|
||||||
|
from qtpy.QtWidgets import QComboBox, QDoubleSpinBox, QHBoxLayout, QSizePolicy, QWidget
|
||||||
|
|
||||||
|
from superqt.utils import signals_blocked
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
|
||||||
|
Number = Union[int, float, "Decimal"]
|
||||||
|
UREG = UnitRegistry()
|
||||||
|
NULL_OPTION = "-----"
|
||||||
|
QOVERFLOW = 2**30
|
||||||
|
SI_BASES = {
|
||||||
|
"[length]": "meter",
|
||||||
|
"[time]": "second",
|
||||||
|
"[current]": "ampere",
|
||||||
|
"[luminosity]": "candela",
|
||||||
|
"[mass]": "gram",
|
||||||
|
"[substance]": "mole",
|
||||||
|
"[temperature]": "kelvin",
|
||||||
|
}
|
||||||
|
DEFAULT_OPTIONS = {
|
||||||
|
"[length]": ["km", "m", "mm", "µm"],
|
||||||
|
"[time]": ["day", "hour", "min", "sec", "ms"],
|
||||||
|
"[current]": ["A", "mA", "µA"],
|
||||||
|
"[luminosity]": ["kcd", "cd", "mcd"],
|
||||||
|
"[mass]": ["kg", "g", "mg", "µg"],
|
||||||
|
"[substance]": ["mol", "mmol", "µmol"],
|
||||||
|
"[temperature]": ["°C", "°F", "°K"],
|
||||||
|
"radian": ["rad", "deg"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class QQuantity(QWidget):
|
||||||
|
"""A combination QDoubleSpinBox and QComboBox for entering quantities.
|
||||||
|
|
||||||
|
For this widget, `value()` returns a `pint.Quantity` object, while `setValue()`
|
||||||
|
accepts either a number, `pint.Quantity`, a string that can be parsed by `pint`.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
value : Union[str, pint.Quantity, Number]
|
||||||
|
The initial value to display. If a string, it will be parsed by `pint`.
|
||||||
|
units : Union[pint.util.UnitsContainer, str, pint.Quantity], optional
|
||||||
|
The units to use if `value` is a number. If a string, it will be parsed by
|
||||||
|
`pint`. If a `pint.Quantity`, the units will be extracted from it.
|
||||||
|
ureg : pint.UnitRegistry, optional
|
||||||
|
The unit registry to use. If not provided, the registry will be extracted
|
||||||
|
from `value` if it is a `pint.Quantity`, otherwise the default registry will
|
||||||
|
be used.
|
||||||
|
parent : QWidget, optional
|
||||||
|
The parent widget, by default None
|
||||||
|
"""
|
||||||
|
|
||||||
|
valueChanged = Signal(Quantity)
|
||||||
|
unitsChanged = Signal(Unit)
|
||||||
|
dimensionalityChanged = Signal(UnitsContainer)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
value: Union[str, Quantity, Number] = 0,
|
||||||
|
units: Optional[Union[UnitsContainer, str, Quantity]] = None,
|
||||||
|
ureg: Optional[UnitRegistry] = None,
|
||||||
|
parent: Optional[QWidget] = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent=parent)
|
||||||
|
if ureg is None:
|
||||||
|
ureg = value._REGISTRY if isinstance(value, Quantity) else UREG
|
||||||
|
else:
|
||||||
|
if not isinstance(ureg, UnitRegistry):
|
||||||
|
raise TypeError(
|
||||||
|
f"ureg must be a pint.UnitRegistry, not {type(ureg).__name__}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._ureg = ureg
|
||||||
|
self._value: Quantity = self._ureg.Quantity(value, units=units)
|
||||||
|
|
||||||
|
# whether to preserve quantity equality when changing units or magnitude
|
||||||
|
self._preserve_quantity: bool = False
|
||||||
|
self._abbreviate_units: bool = True # TODO: implement
|
||||||
|
|
||||||
|
self._mag_spinbox = QDoubleSpinBox()
|
||||||
|
self._mag_spinbox.setDecimals(3)
|
||||||
|
self._mag_spinbox.setRange(-QOVERFLOW, QOVERFLOW - 1)
|
||||||
|
self._mag_spinbox.setValue(float(self._value.magnitude))
|
||||||
|
self._mag_spinbox.valueChanged.connect(self.setMagnitude)
|
||||||
|
|
||||||
|
self._units_combo = QComboBox()
|
||||||
|
self._units_combo.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||||
|
self._units_combo.currentTextChanged.connect(self.setUnits)
|
||||||
|
self._update_units_combo_choices()
|
||||||
|
|
||||||
|
self.setLayout(QHBoxLayout())
|
||||||
|
self.layout().addWidget(self._mag_spinbox)
|
||||||
|
self.layout().addWidget(self._units_combo)
|
||||||
|
self.layout().setContentsMargins(6, 0, 0, 0)
|
||||||
|
|
||||||
|
def unitRegistry(self) -> UnitRegistry:
|
||||||
|
"""Return the pint UnitRegistry used by this widget."""
|
||||||
|
return self._ureg
|
||||||
|
|
||||||
|
def _update_units_combo_choices(self):
|
||||||
|
if self._value.dimensionless:
|
||||||
|
with signals_blocked(self._units_combo):
|
||||||
|
self._units_combo.clear()
|
||||||
|
self._units_combo.addItem(NULL_OPTION)
|
||||||
|
self._units_combo.addItems(
|
||||||
|
[self._format_units(x) for x in SI_BASES.values()]
|
||||||
|
)
|
||||||
|
self._units_combo.setCurrentText(NULL_OPTION)
|
||||||
|
return
|
||||||
|
|
||||||
|
units = self._value.units
|
||||||
|
dims, exp = next(iter(units.dimensionality.items()))
|
||||||
|
if exp != 1:
|
||||||
|
raise NotImplementedError("Inverse units not yet implemented")
|
||||||
|
options = [
|
||||||
|
self._format_units(self._ureg.Unit(u))
|
||||||
|
for u in DEFAULT_OPTIONS.get(dims, [])
|
||||||
|
]
|
||||||
|
current = self._format_units(units)
|
||||||
|
with signals_blocked(self._units_combo):
|
||||||
|
self._units_combo.clear()
|
||||||
|
self._units_combo.addItems(options)
|
||||||
|
if self._units_combo.findText(current) == -1:
|
||||||
|
self._units_combo.addItem(current)
|
||||||
|
|
||||||
|
self._units_combo.setCurrentText(current)
|
||||||
|
|
||||||
|
def value(self) -> Quantity:
|
||||||
|
"""Return the current value as a `pint.Quantity`."""
|
||||||
|
return self._value
|
||||||
|
|
||||||
|
def text(self) -> str:
|
||||||
|
return str(self._value)
|
||||||
|
|
||||||
|
def magnitude(self) -> Union[float, int]:
|
||||||
|
"""Return the magnitude of the current value."""
|
||||||
|
return self._value.magnitude
|
||||||
|
|
||||||
|
def units(self) -> Unit:
|
||||||
|
"""Return the current units."""
|
||||||
|
return self._value.units
|
||||||
|
|
||||||
|
def dimensionality(self) -> UnitsContainer:
|
||||||
|
"""Return the current dimensionality (cast to `str` for nice repr)."""
|
||||||
|
return self._value.dimensionality
|
||||||
|
|
||||||
|
def setDecimals(self, decimals: int) -> None:
|
||||||
|
"""Set the number of decimals to display in the spinbox."""
|
||||||
|
self._mag_spinbox.setDecimals(decimals)
|
||||||
|
if self._value is not None:
|
||||||
|
self._mag_spinbox.setValue(self._value.magnitude)
|
||||||
|
|
||||||
|
def setValue(
|
||||||
|
self,
|
||||||
|
value: Union[str, Quantity, Number],
|
||||||
|
units: Optional[Union[UnitsContainer, str, Quantity]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Set the current value (will cast to a pint Quantity)."""
|
||||||
|
if isinstance(value, Quantity):
|
||||||
|
if units is not None:
|
||||||
|
raise ValueError("Cannot specify units if value is a Quantity")
|
||||||
|
new_val = self._ureg.Quantity(value.magnitude, units=value.units)
|
||||||
|
else:
|
||||||
|
new_val = self._ureg.Quantity(value, units=units)
|
||||||
|
|
||||||
|
mag_change = new_val.magnitude != self._value.magnitude
|
||||||
|
units_change = new_val.units != self._value.units
|
||||||
|
dims_changed = new_val.dimensionality != self._value.dimensionality
|
||||||
|
|
||||||
|
self._value = new_val
|
||||||
|
|
||||||
|
if mag_change:
|
||||||
|
with signals_blocked(self._mag_spinbox):
|
||||||
|
self._mag_spinbox.setValue(float(self._value.magnitude))
|
||||||
|
|
||||||
|
if units_change:
|
||||||
|
with signals_blocked(self._units_combo):
|
||||||
|
self._units_combo.setCurrentText(self._format_units(self._value.units))
|
||||||
|
self.unitsChanged.emit(self._value.units)
|
||||||
|
|
||||||
|
if dims_changed:
|
||||||
|
self._update_units_combo_choices()
|
||||||
|
self.dimensionalityChanged.emit(self._value.dimensionality)
|
||||||
|
|
||||||
|
if mag_change or units_change:
|
||||||
|
self.valueChanged.emit(self._value)
|
||||||
|
|
||||||
|
def setMagnitude(self, magnitude: Number) -> None:
|
||||||
|
"""Set the magnitude of the current value."""
|
||||||
|
self.setValue(self._ureg.Quantity(magnitude, self._value.units))
|
||||||
|
|
||||||
|
def setUnits(self, units: Union[str, Unit, Quantity]) -> None:
|
||||||
|
"""Set the units of the current value.
|
||||||
|
|
||||||
|
If `units` is `None`, will convert to a dimensionless quantity.
|
||||||
|
Otherwise, units must be compatible with the current dimensionality.
|
||||||
|
"""
|
||||||
|
if units is None:
|
||||||
|
new_val = self._ureg.Quantity(self._value.magnitude)
|
||||||
|
elif self.isDimensionless():
|
||||||
|
new_val = self._ureg.Quantity(self._value.magnitude, units)
|
||||||
|
else:
|
||||||
|
new_val = self._value.to(units)
|
||||||
|
self.setValue(new_val)
|
||||||
|
|
||||||
|
def isDimensionless(self) -> bool:
|
||||||
|
"""Return `True` if the current value is dimensionless."""
|
||||||
|
return self._value.dimensionless
|
||||||
|
|
||||||
|
def magnitudeSpinBox(self) -> QDoubleSpinBox:
|
||||||
|
"""Return the `QSpinBox` widget used to edit the magnitude."""
|
||||||
|
return self._mag_spinbox
|
||||||
|
|
||||||
|
def unitsComboBox(self) -> QComboBox:
|
||||||
|
"""Return the `QCombBox` widget used to edit the units."""
|
||||||
|
return self._units_combo
|
||||||
|
|
||||||
|
def _format_units(self, u: Union[Unit, str]) -> str:
|
||||||
|
if isinstance(u, str):
|
||||||
|
return u
|
||||||
|
return f"{u:~}" if self._abbreviate_units else f"{u:}"
|
31
src/superqt/utils/__init__.py
Normal file
31
src/superqt/utils/__init__.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
__all__ = (
|
||||||
|
"CodeSyntaxHighlight",
|
||||||
|
"create_worker",
|
||||||
|
"ensure_main_thread",
|
||||||
|
"ensure_object_thread",
|
||||||
|
"FunctionWorker",
|
||||||
|
"GeneratorWorker",
|
||||||
|
"new_worker_qthread",
|
||||||
|
"qdebounced",
|
||||||
|
"QMessageHandler",
|
||||||
|
"QSignalDebouncer",
|
||||||
|
"QSignalThrottler",
|
||||||
|
"qthrottled",
|
||||||
|
"signals_blocked",
|
||||||
|
"thread_worker",
|
||||||
|
"WorkerBase",
|
||||||
|
)
|
||||||
|
|
||||||
|
from ._code_syntax_highlight import CodeSyntaxHighlight
|
||||||
|
from ._ensure_thread import ensure_main_thread, ensure_object_thread
|
||||||
|
from ._message_handler import QMessageHandler
|
||||||
|
from ._misc import signals_blocked
|
||||||
|
from ._qthreading import (
|
||||||
|
FunctionWorker,
|
||||||
|
GeneratorWorker,
|
||||||
|
WorkerBase,
|
||||||
|
create_worker,
|
||||||
|
new_worker_qthread,
|
||||||
|
thread_worker,
|
||||||
|
)
|
||||||
|
from ._throttler import QSignalDebouncer, QSignalThrottler, qdebounced, qthrottled
|
88
src/superqt/utils/_code_syntax_highlight.py
Normal file
88
src/superqt/utils/_code_syntax_highlight.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
from itertools import takewhile
|
||||||
|
|
||||||
|
from pygments import highlight
|
||||||
|
from pygments.formatter import Formatter
|
||||||
|
from pygments.lexers import find_lexer_class, get_lexer_by_name
|
||||||
|
from pygments.util import ClassNotFound
|
||||||
|
from qtpy import QtGui
|
||||||
|
|
||||||
|
# inspired by https://github.com/Vector35/snippets/blob/master/QCodeEditor.py
|
||||||
|
# (MIT license) and
|
||||||
|
# https://pygments.org/docs/formatterdevelopment/#html-3-2-formatter
|
||||||
|
|
||||||
|
|
||||||
|
def get_text_char_format(style):
|
||||||
|
text_char_format = QtGui.QTextCharFormat()
|
||||||
|
if hasattr(text_char_format, "setFontFamilies"):
|
||||||
|
text_char_format.setFontFamilies(["monospace"])
|
||||||
|
else:
|
||||||
|
text_char_format.setFontFamily("monospace")
|
||||||
|
if style.get("color"):
|
||||||
|
text_char_format.setForeground(QtGui.QColor(f"#{style['color']}"))
|
||||||
|
|
||||||
|
if style.get("bgcolor"):
|
||||||
|
text_char_format.setBackground(QtGui.QColor(style["bgcolor"]))
|
||||||
|
|
||||||
|
if style.get("bold"):
|
||||||
|
text_char_format.setFontWeight(QtGui.QFont.Bold)
|
||||||
|
if style.get("italic"):
|
||||||
|
text_char_format.setFontItalic(True)
|
||||||
|
if style.get("underline"):
|
||||||
|
text_char_format.setFontUnderline(True)
|
||||||
|
|
||||||
|
# TODO find if it is possible to support border style.
|
||||||
|
|
||||||
|
return text_char_format
|
||||||
|
|
||||||
|
|
||||||
|
class QFormatter(Formatter):
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.data = []
|
||||||
|
self._style = {name: get_text_char_format(style) for name, style in self.style}
|
||||||
|
|
||||||
|
def format(self, tokensource, outfile):
|
||||||
|
"""Format the given token stream.
|
||||||
|
|
||||||
|
`outfile` is argument from parent class, but
|
||||||
|
in Qt we do not produce string output, but QTextCharFormat, so it needs to be
|
||||||
|
collected using `self.data`.
|
||||||
|
"""
|
||||||
|
self.data = []
|
||||||
|
|
||||||
|
for token, value in tokensource:
|
||||||
|
self.data.extend([self._style[token]] * len(value))
|
||||||
|
|
||||||
|
|
||||||
|
class CodeSyntaxHighlight(QtGui.QSyntaxHighlighter):
|
||||||
|
def __init__(self, parent, lang, theme):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.formatter = QFormatter(style=theme)
|
||||||
|
try:
|
||||||
|
self.lexer = get_lexer_by_name(lang)
|
||||||
|
except ClassNotFound:
|
||||||
|
self.lexer = find_lexer_class(lang)()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def background_color(self):
|
||||||
|
return self.formatter.style.background_color
|
||||||
|
|
||||||
|
def highlightBlock(self, text):
|
||||||
|
cb = self.currentBlock()
|
||||||
|
p = cb.position()
|
||||||
|
text_ = self.document().toPlainText() + "\n"
|
||||||
|
highlight(text_, self.lexer, self.formatter)
|
||||||
|
|
||||||
|
enters = sum(1 for _ in takewhile(lambda x: x == "\n", text_))
|
||||||
|
# pygments lexer ignore leading empty lines, so we need to do correction
|
||||||
|
# here calculating the number of empty lines.
|
||||||
|
|
||||||
|
# dirty, dirty hack
|
||||||
|
# The core problem is that pygemnts by default use string streams,
|
||||||
|
# that will not handle QTextCharFormat, so we need use `data` property to
|
||||||
|
# work around this.
|
||||||
|
for i in range(len(text)):
|
||||||
|
try:
|
||||||
|
self.setFormat(i, 1, self.formatter.data[p + i - enters])
|
||||||
|
except IndexError: # pragma: no cover
|
||||||
|
pass
|
191
src/superqt/utils/_ensure_thread.py
Normal file
191
src/superqt/utils/_ensure_thread.py
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
# https://gist.github.com/FlorianRhiem/41a1ad9b694c14fb9ac3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from concurrent.futures import Future
|
||||||
|
from functools import wraps
|
||||||
|
from typing import TYPE_CHECKING, Any, Callable, ClassVar, overload
|
||||||
|
|
||||||
|
from qtpy.QtCore import (
|
||||||
|
QCoreApplication,
|
||||||
|
QMetaObject,
|
||||||
|
QObject,
|
||||||
|
Qt,
|
||||||
|
QThread,
|
||||||
|
Signal,
|
||||||
|
Slot,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ._util import get_max_args
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
|
from typing_extensions import Literal, ParamSpec
|
||||||
|
|
||||||
|
P = ParamSpec("P")
|
||||||
|
R = TypeVar("R")
|
||||||
|
|
||||||
|
|
||||||
|
class CallCallable(QObject):
|
||||||
|
finished = Signal(object)
|
||||||
|
instances: ClassVar[list[CallCallable]] = []
|
||||||
|
|
||||||
|
def __init__(self, callable: Callable, args: tuple, kwargs: dict):
|
||||||
|
super().__init__()
|
||||||
|
self._callable = callable
|
||||||
|
self._args = args
|
||||||
|
self._kwargs = kwargs
|
||||||
|
CallCallable.instances.append(self)
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def call(self):
|
||||||
|
CallCallable.instances.remove(self)
|
||||||
|
res = self._callable(*self._args, **self._kwargs)
|
||||||
|
self.finished.emit(res)
|
||||||
|
|
||||||
|
|
||||||
|
# fmt: off
|
||||||
|
@overload
|
||||||
|
def ensure_main_thread(
|
||||||
|
await_return: Literal[True],
|
||||||
|
timeout: int = 1000,
|
||||||
|
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
|
||||||
|
@overload
|
||||||
|
def ensure_main_thread(
|
||||||
|
func: Callable[P, R],
|
||||||
|
await_return: Literal[True],
|
||||||
|
timeout: int = 1000,
|
||||||
|
) -> Callable[P, R]: ...
|
||||||
|
@overload
|
||||||
|
def ensure_main_thread(
|
||||||
|
await_return: Literal[False] = False,
|
||||||
|
timeout: int = 1000,
|
||||||
|
) -> Callable[[Callable[P, R]], Callable[P, Future[R]]]: ...
|
||||||
|
@overload
|
||||||
|
def ensure_main_thread(
|
||||||
|
func: Callable[P, R],
|
||||||
|
await_return: Literal[False] = False,
|
||||||
|
timeout: int = 1000,
|
||||||
|
) -> Callable[P, Future[R]]: ...
|
||||||
|
# fmt: on
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_main_thread(
|
||||||
|
func: Callable | None = None, await_return: bool = False, timeout: int = 1000
|
||||||
|
):
|
||||||
|
"""Decorator that ensures a function is called in the main QApplication thread.
|
||||||
|
|
||||||
|
It can be applied to functions or methods.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
func : callable
|
||||||
|
The method to decorate, must be a method on a QObject.
|
||||||
|
await_return : bool, optional
|
||||||
|
Whether to block and wait for the result of the function, or return immediately.
|
||||||
|
by default False
|
||||||
|
timeout : int, optional
|
||||||
|
If `await_return` is `True`, time (in milliseconds) to wait for the result
|
||||||
|
before raising a TimeoutError, by default 1000
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _out_func(func_):
|
||||||
|
max_args = get_max_args(func_)
|
||||||
|
|
||||||
|
@wraps(func_)
|
||||||
|
def _func(*args, _max_args_=max_args, **kwargs):
|
||||||
|
return _run_in_thread(
|
||||||
|
func_,
|
||||||
|
QCoreApplication.instance().thread(),
|
||||||
|
await_return,
|
||||||
|
timeout,
|
||||||
|
args[:_max_args_],
|
||||||
|
kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _func
|
||||||
|
|
||||||
|
return _out_func if func is None else _out_func(func)
|
||||||
|
|
||||||
|
|
||||||
|
# fmt: off
|
||||||
|
@overload
|
||||||
|
def ensure_object_thread(
|
||||||
|
await_return: Literal[True],
|
||||||
|
timeout: int = 1000,
|
||||||
|
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
|
||||||
|
@overload
|
||||||
|
def ensure_object_thread(
|
||||||
|
func: Callable[P, R],
|
||||||
|
await_return: Literal[True],
|
||||||
|
timeout: int = 1000,
|
||||||
|
) -> Callable[P, R]: ...
|
||||||
|
@overload
|
||||||
|
def ensure_object_thread(
|
||||||
|
await_return: Literal[False] = False,
|
||||||
|
timeout: int = 1000,
|
||||||
|
) -> Callable[[Callable[P, R]], Callable[P, Future[R]]]: ...
|
||||||
|
@overload
|
||||||
|
def ensure_object_thread(
|
||||||
|
func: Callable[P, R],
|
||||||
|
await_return: Literal[False] = False,
|
||||||
|
timeout: int = 1000,
|
||||||
|
) -> Callable[P, Future[R]]: ...
|
||||||
|
# fmt: on
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_object_thread(
|
||||||
|
func: Callable | None = None, await_return: bool = False, timeout: int = 1000
|
||||||
|
):
|
||||||
|
"""Decorator that ensures a QObject method is called in the object's thread.
|
||||||
|
|
||||||
|
It must be applied to methods of QObjects subclasses.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
func : callable
|
||||||
|
The method to decorate, must be a method on a QObject.
|
||||||
|
await_return : bool, optional
|
||||||
|
Whether to block and wait for the result of the function, or return immediately.
|
||||||
|
by default False
|
||||||
|
timeout : int, optional
|
||||||
|
If `await_return` is `True`, time (in milliseconds) to wait for the result
|
||||||
|
before raising a TimeoutError, by default 1000
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _out_func(func_):
|
||||||
|
max_args = get_max_args(func_)
|
||||||
|
|
||||||
|
@wraps(func_)
|
||||||
|
def _func(*args, _max_args_=max_args, **kwargs):
|
||||||
|
thread = args[0].thread() # self
|
||||||
|
return _run_in_thread(
|
||||||
|
func_, thread, await_return, timeout, args[:_max_args_], kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
return _func
|
||||||
|
|
||||||
|
return _out_func if func is None else _out_func(func)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_in_thread(
|
||||||
|
func: Callable,
|
||||||
|
thread: QThread,
|
||||||
|
await_return: bool,
|
||||||
|
timeout: int,
|
||||||
|
args: tuple,
|
||||||
|
kwargs: dict,
|
||||||
|
) -> Any:
|
||||||
|
future = Future() # type: ignore
|
||||||
|
if thread is QThread.currentThread():
|
||||||
|
result = func(*args, **kwargs)
|
||||||
|
if not await_return:
|
||||||
|
future.set_result(result)
|
||||||
|
return future
|
||||||
|
return result
|
||||||
|
|
||||||
|
f = CallCallable(func, args, kwargs)
|
||||||
|
f.moveToThread(thread)
|
||||||
|
f.finished.connect(future.set_result, Qt.ConnectionType.DirectConnection)
|
||||||
|
QMetaObject.invokeMethod(f, "call", Qt.ConnectionType.QueuedConnection) # type: ignore # noqa
|
||||||
|
return future.result(timeout=timeout / 1000) if await_return else future
|
95
src/superqt/utils/_message_handler.py
Normal file
95
src/superqt/utils/_message_handler.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from contextlib import suppress
|
||||||
|
from typing import ClassVar, NamedTuple
|
||||||
|
|
||||||
|
from qtpy.QtCore import QMessageLogContext, QtMsgType, qInstallMessageHandler
|
||||||
|
|
||||||
|
|
||||||
|
class Record(NamedTuple):
|
||||||
|
level: int
|
||||||
|
message: str
|
||||||
|
ctx: dict
|
||||||
|
|
||||||
|
|
||||||
|
class QMessageHandler:
|
||||||
|
"""A context manager to intercept messages from Qt.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
logger : logging.Logger, optional
|
||||||
|
If provided, intercepted messages will be logged with `logger` at the
|
||||||
|
corresponding python log level, by default None
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
records: list of tuple
|
||||||
|
Captured messages. This is a 3-tuple of:
|
||||||
|
`(log_level: int, message: str, context: dict)`
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
>>> handler = QMessageHandler()
|
||||||
|
>>> handler.install() # now all Qt output will be available at mh.records
|
||||||
|
|
||||||
|
>>> with QMessageHandler() as handler: # temporarily install
|
||||||
|
... ...
|
||||||
|
|
||||||
|
>>> logger = logging.getLogger(__name__)
|
||||||
|
>>> with QMessageHandler(logger): # re-reoute Qt messages to a python logger.
|
||||||
|
... ...
|
||||||
|
"""
|
||||||
|
|
||||||
|
_qt2loggertype: ClassVar[dict[QtMsgType, int]] = {
|
||||||
|
QtMsgType.QtDebugMsg: logging.DEBUG,
|
||||||
|
QtMsgType.QtInfoMsg: logging.INFO,
|
||||||
|
QtMsgType.QtWarningMsg: logging.WARNING,
|
||||||
|
QtMsgType.QtCriticalMsg: logging.ERROR, # note
|
||||||
|
QtMsgType.QtFatalMsg: logging.CRITICAL, # note
|
||||||
|
QtMsgType.QtSystemMsg: logging.CRITICAL,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, logger: logging.Logger | None = None):
|
||||||
|
self.records: list[Record] = []
|
||||||
|
self._logger = logger
|
||||||
|
self._previous_handler: object | None = "__uninstalled__"
|
||||||
|
|
||||||
|
def install(self):
|
||||||
|
"""Install this handler (override the current QtMessageHandler)."""
|
||||||
|
self._previous_handler = qInstallMessageHandler(self)
|
||||||
|
|
||||||
|
def uninstall(self):
|
||||||
|
"""Uninstall this handler, restoring the previous handler."""
|
||||||
|
if self._previous_handler != "__uninstalled__":
|
||||||
|
qInstallMessageHandler(self._previous_handler)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
n = type(self).__name__
|
||||||
|
return f"<{n} object at {hex(id(self))} with {len(self.records)} records>"
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
"""Enter a context with this handler installed."""
|
||||||
|
self.install()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
self.uninstall()
|
||||||
|
|
||||||
|
def __call__(self, msgtype: QtMsgType, context: QMessageLogContext, message: str):
|
||||||
|
level = self._qt2loggertype[msgtype]
|
||||||
|
|
||||||
|
# PyQt seems to throw an error if these are simply empty
|
||||||
|
ctx = dict.fromkeys(["category", "file", "function", "line"])
|
||||||
|
with suppress(UnicodeDecodeError):
|
||||||
|
ctx["category"] = context.category
|
||||||
|
with suppress(UnicodeDecodeError):
|
||||||
|
ctx["file"] = context.file
|
||||||
|
with suppress(UnicodeDecodeError):
|
||||||
|
ctx["function"] = context.function
|
||||||
|
with suppress(UnicodeDecodeError):
|
||||||
|
ctx["line"] = context.line
|
||||||
|
|
||||||
|
self.records.append(Record(level, message, ctx))
|
||||||
|
if self._logger is not None:
|
||||||
|
self._logger.log(level, message, extra=ctx)
|
32
src/superqt/utils/_misc.py
Normal file
32
src/superqt/utils/_misc.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import TYPE_CHECKING, Iterator
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from qtpy.QtCore import QObject
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def signals_blocked(obj: "QObject") -> Iterator[None]:
|
||||||
|
"""Context manager to temporarily block signals emitted by QObject: `obj`.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
obj : QObject
|
||||||
|
The QObject whose signals should be blocked.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```python
|
||||||
|
from qtpy.QtWidgets import QSpinBox
|
||||||
|
from superqt import signals_blocked
|
||||||
|
|
||||||
|
spinbox = QSpinBox()
|
||||||
|
with signals_blocked(spinbox):
|
||||||
|
spinbox.setValue(10)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
previous = obj.blockSignals(True)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
obj.blockSignals(previous)
|
892
src/superqt/utils/_qthreading.py
Normal file
892
src/superqt/utils/_qthreading.py
Normal file
@@ -0,0 +1,892 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import time
|
||||||
|
import warnings
|
||||||
|
from functools import partial, wraps
|
||||||
|
from typing import (
|
||||||
|
TYPE_CHECKING,
|
||||||
|
Any,
|
||||||
|
Callable,
|
||||||
|
ClassVar,
|
||||||
|
Generator,
|
||||||
|
Generic,
|
||||||
|
Sequence,
|
||||||
|
TypeVar,
|
||||||
|
overload,
|
||||||
|
)
|
||||||
|
|
||||||
|
from qtpy.QtCore import QObject, QRunnable, QThread, QThreadPool, QTimer, Signal
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
_T = TypeVar("_T")
|
||||||
|
|
||||||
|
class SigInst(Generic[_T]):
|
||||||
|
@staticmethod
|
||||||
|
def connect(slot: Callable[[_T], Any], type: type | None = ...) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def disconnect(slot: Callable[[_T], Any] = ...) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def emit(*args: _T) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
from typing_extensions import Literal, ParamSpec
|
||||||
|
|
||||||
|
_P = ParamSpec("_P")
|
||||||
|
# maintain runtime compatibility with older typing_extensions
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
from typing_extensions import ParamSpec
|
||||||
|
|
||||||
|
_P = ParamSpec("_P")
|
||||||
|
except ImportError:
|
||||||
|
_P = TypeVar("_P")
|
||||||
|
|
||||||
|
_Y = TypeVar("_Y")
|
||||||
|
_S = TypeVar("_S")
|
||||||
|
_R = TypeVar("_R")
|
||||||
|
|
||||||
|
|
||||||
|
def as_generator_function(
|
||||||
|
func: Callable[_P, _R]
|
||||||
|
) -> Callable[_P, Generator[None, None, _R]]:
|
||||||
|
"""Turns a regular function (single return) into a generator function."""
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
def genwrapper(*args: Any, **kwargs: Any) -> Generator[None, None, _R]:
|
||||||
|
yield
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
|
return genwrapper
|
||||||
|
|
||||||
|
|
||||||
|
class WorkerBaseSignals(QObject):
|
||||||
|
started = Signal() # emitted when the work is started
|
||||||
|
finished = Signal() # emitted when the work is finished
|
||||||
|
_finished = Signal(object) # emitted when the work is finished to delete
|
||||||
|
returned = Signal(object) # emitted with return value
|
||||||
|
errored = Signal(object) # emitted with error object on Exception
|
||||||
|
warned = Signal(tuple) # emitted with showwarning args on warning
|
||||||
|
|
||||||
|
|
||||||
|
class WorkerBase(QRunnable, Generic[_R]):
|
||||||
|
"""Base class for creating a Worker that can run in another thread.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
SignalsClass : type, optional
|
||||||
|
A QObject subclass that contains signals, by default WorkerBaseSignals
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
signals: WorkerBaseSignals
|
||||||
|
signal emitter object. To allow identify which worker thread emitted signal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: A set of Workers. Add to set using `WorkerBase.start`
|
||||||
|
_worker_set: ClassVar[set[WorkerBase]] = set()
|
||||||
|
returned: SigInst[_R]
|
||||||
|
errored: SigInst[Exception]
|
||||||
|
warned: SigInst[tuple]
|
||||||
|
started: SigInst[None]
|
||||||
|
finished: SigInst[None]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
func: Callable[_P, _R] | None = None,
|
||||||
|
SignalsClass: type[WorkerBaseSignals] = WorkerBaseSignals,
|
||||||
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._abort_requested = False
|
||||||
|
self._running = False
|
||||||
|
self.signals = SignalsClass()
|
||||||
|
|
||||||
|
def __getattr__(self, name: str) -> SigInst:
|
||||||
|
"""Pass through attr requests to signals to simplify connection API.
|
||||||
|
|
||||||
|
The goal is to enable `worker.yielded.connect` instead of
|
||||||
|
`worker.signals.yielded.connect`. Because multiple inheritance of Qt
|
||||||
|
classes is not well supported in PyQt, we have to use composition here
|
||||||
|
(signals are provided by QObjects, and QRunnable is not a QObject). So
|
||||||
|
this passthrough allows us to connect to signals on the `_signals`
|
||||||
|
object.
|
||||||
|
"""
|
||||||
|
# the Signal object is actually a class attribute
|
||||||
|
attr = getattr(self.signals.__class__, name, None)
|
||||||
|
if isinstance(attr, Signal):
|
||||||
|
# but what we need to connect to is the instantiated signal
|
||||||
|
# (which is of type `SignalInstance` in PySide and
|
||||||
|
# `pyqtBoundSignal` in PyQt)
|
||||||
|
return getattr(self.signals, name)
|
||||||
|
raise AttributeError(
|
||||||
|
f"{self.__class__.__name__!r} object has no attribute {name!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def quit(self) -> None:
|
||||||
|
"""Send a request to abort the worker.
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
It is entirely up to subclasses to honor this method by checking
|
||||||
|
`self.abort_requested` periodically in their `worker.work`
|
||||||
|
method, and exiting if `True`.
|
||||||
|
"""
|
||||||
|
self._abort_requested = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def abort_requested(self) -> bool:
|
||||||
|
"""Whether the worker has been requested to stop."""
|
||||||
|
return self._abort_requested
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
"""Whether the worker has been started."""
|
||||||
|
return self._running
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
"""Start the worker.
|
||||||
|
|
||||||
|
The end-user should never need to call this function.
|
||||||
|
But it cannot be made private or renamed, since it is called by Qt.
|
||||||
|
|
||||||
|
The order of method calls when starting a worker is:
|
||||||
|
|
||||||
|
```
|
||||||
|
calls QThreadPool.globalInstance().start(worker)
|
||||||
|
| triggered by the QThreadPool.start() method
|
||||||
|
| | called by worker.run
|
||||||
|
| | |
|
||||||
|
V V V
|
||||||
|
worker.start -> worker.run -> worker.work
|
||||||
|
```
|
||||||
|
|
||||||
|
**This** is the function that actually gets called when calling
|
||||||
|
`QThreadPool.start(worker)`. It simply wraps the `work()`
|
||||||
|
method, and emits a few signals. Subclasses should NOT override this
|
||||||
|
method (except with good reason), and instead should implement
|
||||||
|
`work()`.
|
||||||
|
"""
|
||||||
|
self.started.emit()
|
||||||
|
self._running = True
|
||||||
|
try:
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.filterwarnings("always")
|
||||||
|
warnings.showwarning = lambda *w: self.warned.emit(w)
|
||||||
|
result = self.work()
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
if isinstance(result, RuntimeError):
|
||||||
|
# The Worker object has likely been deleted.
|
||||||
|
# A deleted wrapped C/C++ object may result in a runtime
|
||||||
|
# error that will cause segfault if we try to do much other
|
||||||
|
# than simply notify the user.
|
||||||
|
warnings.warn(
|
||||||
|
f"RuntimeError in aborted thread: {result}",
|
||||||
|
RuntimeWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
raise result
|
||||||
|
if not self.abort_requested:
|
||||||
|
self.returned.emit(result)
|
||||||
|
except Exception as exc:
|
||||||
|
self.errored.emit(exc)
|
||||||
|
self._running = False
|
||||||
|
self.finished.emit()
|
||||||
|
self._finished.emit(self)
|
||||||
|
|
||||||
|
def work(self) -> Exception | _R:
|
||||||
|
"""Main method to execute the worker.
|
||||||
|
|
||||||
|
The end-user should never need to call this function.
|
||||||
|
But subclasses must implement this method (See
|
||||||
|
[`GeneratorFunction.work`][superqt.utils._qthreading.GeneratorWorker.work] for
|
||||||
|
an example implementation). Minimally, it should check `self.abort_requested`
|
||||||
|
periodically and exit if True.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```python
|
||||||
|
class MyWorker(WorkerBase):
|
||||||
|
|
||||||
|
def work(self):
|
||||||
|
i = 0
|
||||||
|
while True:
|
||||||
|
if self.abort_requested:
|
||||||
|
self.aborted.emit()
|
||||||
|
break
|
||||||
|
i += 1
|
||||||
|
if i > max_iters:
|
||||||
|
break
|
||||||
|
time.sleep(0.5)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(
|
||||||
|
f'"{self.__class__.__name__}" failed to define work() method'
|
||||||
|
)
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Start this worker in a thread and add it to the global threadpool.
|
||||||
|
|
||||||
|
The order of method calls when starting a worker is:
|
||||||
|
|
||||||
|
```
|
||||||
|
calls QThreadPool.globalInstance().start(worker)
|
||||||
|
| triggered by the QThreadPool.start() method
|
||||||
|
| | called by worker.run
|
||||||
|
| | |
|
||||||
|
V V V
|
||||||
|
worker.start -> worker.run -> worker.work
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
if self in self._worker_set:
|
||||||
|
raise RuntimeError("This worker is already started!")
|
||||||
|
|
||||||
|
# This will raise a RunTimeError if the worker is already deleted
|
||||||
|
repr(self)
|
||||||
|
|
||||||
|
self._worker_set.add(self)
|
||||||
|
self._finished.connect(self._set_discard)
|
||||||
|
if QThread.currentThread().loopLevel():
|
||||||
|
# if we're in a thread with an eventloop, queue the worker to start
|
||||||
|
start_ = partial(QThreadPool.globalInstance().start, self)
|
||||||
|
QTimer.singleShot(1, start_)
|
||||||
|
else:
|
||||||
|
# otherwise start it immediately
|
||||||
|
QThreadPool.globalInstance().start(self)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _set_discard(cls, obj: WorkerBase) -> None:
|
||||||
|
cls._worker_set.discard(obj)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def await_workers(cls, msecs: int | None = None) -> None:
|
||||||
|
"""Ask all workers to quit, and wait up to `msec` for quit.
|
||||||
|
|
||||||
|
Attempts to clean up all running workers by calling `worker.quit()`
|
||||||
|
method. Any workers in the `WorkerBase._worker_set` set will have this
|
||||||
|
method.
|
||||||
|
|
||||||
|
By default, this function will block indefinitely, until worker threads
|
||||||
|
finish. If a timeout is provided, a `RuntimeError` will be raised if
|
||||||
|
the workers do not gracefully exit in the time requests, but the threads
|
||||||
|
will NOT be killed. It is (currently) left to the user to use their OS
|
||||||
|
to force-quit rogue threads.
|
||||||
|
|
||||||
|
!!! important
|
||||||
|
|
||||||
|
If the user does not put any yields in their function, and the function
|
||||||
|
is super long, it will just hang... For instance, there's no graceful
|
||||||
|
way to kill this thread in python:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@thread_worker
|
||||||
|
def ZZZzzz():
|
||||||
|
time.sleep(10000000)
|
||||||
|
```
|
||||||
|
|
||||||
|
This is why it's always advisable to use a generator that periodically
|
||||||
|
yields for long-running computations in another thread.
|
||||||
|
|
||||||
|
See [this stack-overflow
|
||||||
|
post](https://stackoverflow.com/questions/323972/is-there-any-way-to-kill-a-thread)
|
||||||
|
for a good discussion on the difficulty of killing a rogue python thread:
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
msecs : int, optional
|
||||||
|
Waits up to msecs milliseconds for all threads to exit and removes all
|
||||||
|
threads from the thread pool. If msecs is `None` (the default), the
|
||||||
|
timeout is ignored (waits for the last thread to exit).
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
RuntimeError
|
||||||
|
If a timeout is provided and workers do not quit successfully within
|
||||||
|
the time allotted.
|
||||||
|
"""
|
||||||
|
for worker in cls._worker_set:
|
||||||
|
worker.quit()
|
||||||
|
|
||||||
|
msecs = msecs if msecs is not None else -1
|
||||||
|
if not QThreadPool.globalInstance().waitForDone(msecs):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Workers did not quit gracefully in the time allotted ({msecs} ms)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FunctionWorker(WorkerBase[_R]):
|
||||||
|
"""QRunnable with signals that wraps a simple long-running function.
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
`FunctionWorker` does not provide a way to stop a very long-running
|
||||||
|
function (e.g. `time.sleep(10000)`). So whenever possible, it is better to
|
||||||
|
implement your long running function as a generator that yields periodically,
|
||||||
|
and use the [`GeneratorWorker`][superqt.utils.GeneratorWorker] instead.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
func : Callable
|
||||||
|
A function to call in another thread
|
||||||
|
*args
|
||||||
|
will be passed to the function
|
||||||
|
**kwargs
|
||||||
|
will be passed to the function
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
TypeError
|
||||||
|
If `func` is a generator function and not a regular function.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, func: Callable[_P, _R], *args, **kwargs):
|
||||||
|
if inspect.isgeneratorfunction(func):
|
||||||
|
raise TypeError(
|
||||||
|
f"Generator function {func} cannot be used with FunctionWorker, "
|
||||||
|
"use GeneratorWorker instead",
|
||||||
|
)
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self._func = func
|
||||||
|
self._args = args
|
||||||
|
self._kwargs = kwargs
|
||||||
|
|
||||||
|
def work(self) -> _R:
|
||||||
|
return self._func(*self._args, **self._kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class GeneratorWorkerSignals(WorkerBaseSignals):
|
||||||
|
yielded = Signal(object) # emitted with yielded values (if generator used)
|
||||||
|
paused = Signal() # emitted when a running job has successfully paused
|
||||||
|
resumed = Signal() # emitted when a paused job has successfully resumed
|
||||||
|
aborted = Signal() # emitted when a running job is successfully aborted
|
||||||
|
|
||||||
|
|
||||||
|
class GeneratorWorker(WorkerBase, Generic[_Y, _S, _R]):
|
||||||
|
"""QRunnable with signals that wraps a long-running generator.
|
||||||
|
|
||||||
|
Provides a convenient way to run a generator function in another thread,
|
||||||
|
while allowing 2-way communication between threads, using plain-python
|
||||||
|
generator syntax in the original function.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
func : callable
|
||||||
|
The function being run in another thread. May be a generator function.
|
||||||
|
SignalsClass : type, optional
|
||||||
|
A QObject subclass that contains signals, by default
|
||||||
|
GeneratorWorkerSignals
|
||||||
|
*args
|
||||||
|
Will be passed to func on instantiation
|
||||||
|
**kwargs
|
||||||
|
Will be passed to func on instantiation
|
||||||
|
"""
|
||||||
|
|
||||||
|
yielded: SigInst[_Y]
|
||||||
|
paused: SigInst[None]
|
||||||
|
resumed: SigInst[None]
|
||||||
|
aborted: SigInst[None]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
func: Callable[_P, Generator[_Y, _S | None, _R]],
|
||||||
|
*args,
|
||||||
|
SignalsClass: type[WorkerBaseSignals] = GeneratorWorkerSignals,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
if not inspect.isgeneratorfunction(func):
|
||||||
|
raise TypeError(
|
||||||
|
f"Regular function {func} cannot be used with GeneratorWorker, "
|
||||||
|
"use FunctionWorker instead",
|
||||||
|
)
|
||||||
|
super().__init__(SignalsClass=SignalsClass)
|
||||||
|
|
||||||
|
self._gen = func(*args, **kwargs)
|
||||||
|
self._incoming_value: _S | None = None
|
||||||
|
self._pause_requested = False
|
||||||
|
self._resume_requested = False
|
||||||
|
self._paused = False
|
||||||
|
|
||||||
|
# polling interval: ONLY relevant if the user paused a running worker
|
||||||
|
self._pause_interval = 0.01
|
||||||
|
self.pbar = None
|
||||||
|
|
||||||
|
def work(self) -> _R | None | Exception:
|
||||||
|
"""Core event loop that calls the original function.
|
||||||
|
|
||||||
|
Enters a continual loop, yielding and returning from the original
|
||||||
|
function. Checks for various events (quit, pause, resume, etc...).
|
||||||
|
(To clarify: we are creating a rudimentary event loop here because
|
||||||
|
there IS NO Qt event loop running in the other thread to hook into)
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
if self.abort_requested:
|
||||||
|
self.aborted.emit()
|
||||||
|
break
|
||||||
|
if self._paused:
|
||||||
|
if self._resume_requested:
|
||||||
|
self._paused = False
|
||||||
|
self._resume_requested = False
|
||||||
|
self.resumed.emit()
|
||||||
|
else:
|
||||||
|
time.sleep(self._pause_interval)
|
||||||
|
continue
|
||||||
|
elif self._pause_requested:
|
||||||
|
self._paused = True
|
||||||
|
self._pause_requested = False
|
||||||
|
self.paused.emit()
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
_input = self._next_value()
|
||||||
|
output = self._gen.send(_input)
|
||||||
|
self.yielded.emit(output)
|
||||||
|
except StopIteration as exc:
|
||||||
|
return exc.value
|
||||||
|
except RuntimeError as exc:
|
||||||
|
# The worker has probably been deleted. warning will be
|
||||||
|
# emitted in `WorkerBase.run`
|
||||||
|
return exc
|
||||||
|
return None
|
||||||
|
|
||||||
|
def send(self, value: _S):
|
||||||
|
"""Send a value into the function (if a generator was used)."""
|
||||||
|
self._incoming_value = value
|
||||||
|
|
||||||
|
def _next_value(self) -> _S | None:
|
||||||
|
out = None
|
||||||
|
if self._incoming_value is not None:
|
||||||
|
out = self._incoming_value
|
||||||
|
self._incoming_value = None
|
||||||
|
return out
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_paused(self) -> bool:
|
||||||
|
"""Whether the worker is currently paused."""
|
||||||
|
return self._paused
|
||||||
|
|
||||||
|
def toggle_pause(self) -> None:
|
||||||
|
"""Request to pause the worker if playing or resume if paused."""
|
||||||
|
if self.is_paused:
|
||||||
|
self._resume_requested = True
|
||||||
|
else:
|
||||||
|
self._pause_requested = True
|
||||||
|
|
||||||
|
def pause(self) -> None:
|
||||||
|
"""Request to pause the worker."""
|
||||||
|
if not self.is_paused:
|
||||||
|
self._pause_requested = True
|
||||||
|
|
||||||
|
def resume(self) -> None:
|
||||||
|
"""Send a request to resume the worker."""
|
||||||
|
if self.is_paused:
|
||||||
|
self._resume_requested = True
|
||||||
|
|
||||||
|
|
||||||
|
#############################################################################
|
||||||
|
|
||||||
|
# convenience functions for creating Worker instances
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def create_worker(
|
||||||
|
func: Callable[_P, Generator[_Y, _S, _R]],
|
||||||
|
*args,
|
||||||
|
_start_thread: bool | None = None,
|
||||||
|
_connect: dict[str, Callable | Sequence[Callable]] | None = None,
|
||||||
|
_worker_class: type[GeneratorWorker] | type[FunctionWorker] | None = None,
|
||||||
|
_ignore_errors: bool = False,
|
||||||
|
**kwargs,
|
||||||
|
) -> GeneratorWorker[_Y, _S, _R]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def create_worker(
|
||||||
|
func: Callable[_P, _R],
|
||||||
|
*args,
|
||||||
|
_start_thread: bool | None = None,
|
||||||
|
_connect: dict[str, Callable | Sequence[Callable]] | None = None,
|
||||||
|
_worker_class: type[GeneratorWorker] | type[FunctionWorker] | None = None,
|
||||||
|
_ignore_errors: bool = False,
|
||||||
|
**kwargs,
|
||||||
|
) -> FunctionWorker[_R]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def create_worker(
|
||||||
|
func: Callable,
|
||||||
|
*args,
|
||||||
|
_start_thread: bool | None = None,
|
||||||
|
_connect: dict[str, Callable | Sequence[Callable]] | None = None,
|
||||||
|
_worker_class: type[GeneratorWorker] | type[FunctionWorker] | None = None,
|
||||||
|
_ignore_errors: bool = False,
|
||||||
|
**kwargs,
|
||||||
|
) -> FunctionWorker | GeneratorWorker:
|
||||||
|
"""Convenience function to start a function in another thread.
|
||||||
|
|
||||||
|
By default, uses `FunctionWorker` for functions and `GeneratorWorker` for
|
||||||
|
generators, but a custom `WorkerBase` subclass may be provided. If so, it must be a
|
||||||
|
subclass of `WorkerBase`, which defines a standard set of signals and a run method.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
func : Callable
|
||||||
|
The function to call in another thread.
|
||||||
|
_start_thread : bool
|
||||||
|
Whether to immediaetly start the thread. If False, the returned worker
|
||||||
|
must be manually started with `worker.start()`. by default it will be
|
||||||
|
`False` if the `_connect` argument is `None`, otherwise `True`.
|
||||||
|
_connect : Dict[str, Union[Callable, Sequence]], optional
|
||||||
|
A mapping of `"signal_name"` -> `callable` or list of `callable`:
|
||||||
|
callback functions to connect to the various signals offered by the
|
||||||
|
worker class. by default `None`
|
||||||
|
_worker_class : type of `GeneratorWorker` or `FunctionWorker`, optional
|
||||||
|
The [`WorkerBase`][superqt.utils.WorkerBase] to instantiate, by default
|
||||||
|
[`FunctionWorker`][superqt.utils.FunctionWorker] will be used if `func` is a
|
||||||
|
regular function, and [`GeneratorWorker`][superqt.utils.GeneratorWorker] will be
|
||||||
|
used if it is a generator.
|
||||||
|
_ignore_errors : bool
|
||||||
|
If `False` (the default), errors raised in the other thread will be
|
||||||
|
reraised in the main thread (makes debugging significantly easier).
|
||||||
|
*args
|
||||||
|
will be passed to `func`
|
||||||
|
**kwargs
|
||||||
|
will be passed to `func`
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
worker : WorkerBase
|
||||||
|
An instantiated worker. If `_start_thread` was `False`, the worker
|
||||||
|
will have a `.start()` method that can be used to start the thread.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
TypeError
|
||||||
|
If a worker_class is provided that is not a subclass of WorkerBase.
|
||||||
|
TypeError
|
||||||
|
If _connect is provided and is not a dict of `{str: callable}`
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```python
|
||||||
|
def long_function(duration):
|
||||||
|
import time
|
||||||
|
time.sleep(duration)
|
||||||
|
|
||||||
|
worker = create_worker(long_function, 10)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
worker: FunctionWorker | GeneratorWorker
|
||||||
|
|
||||||
|
if not _worker_class:
|
||||||
|
if inspect.isgeneratorfunction(func):
|
||||||
|
_worker_class = GeneratorWorker
|
||||||
|
else:
|
||||||
|
_worker_class = FunctionWorker
|
||||||
|
|
||||||
|
if not inspect.isclass(_worker_class) and issubclass(_worker_class, WorkerBase):
|
||||||
|
raise TypeError(f"Worker {_worker_class} must be a subclass of WorkerBase")
|
||||||
|
|
||||||
|
worker = _worker_class(func, *args, **kwargs)
|
||||||
|
|
||||||
|
if _connect is not None:
|
||||||
|
if not isinstance(_connect, dict):
|
||||||
|
raise TypeError("The '_connect' argument must be a dict")
|
||||||
|
|
||||||
|
if _start_thread is None:
|
||||||
|
_start_thread = True
|
||||||
|
|
||||||
|
for key, val in _connect.items():
|
||||||
|
_val = val if isinstance(val, (tuple, list)) else [val]
|
||||||
|
for v in _val:
|
||||||
|
if not callable(v):
|
||||||
|
raise TypeError(
|
||||||
|
f"_connect[{key!r}] must be a function or sequence of functions"
|
||||||
|
)
|
||||||
|
getattr(worker, key).connect(v)
|
||||||
|
|
||||||
|
# if the user has not provided a default connection for the "errored"
|
||||||
|
# signal... and they have not explicitly set `ignore_errors=True`
|
||||||
|
# Then rereaise any errors from the thread.
|
||||||
|
if not _ignore_errors and not (_connect or {}).get("errored", False):
|
||||||
|
|
||||||
|
def reraise(e):
|
||||||
|
raise e
|
||||||
|
|
||||||
|
worker.errored.connect(reraise)
|
||||||
|
|
||||||
|
if _start_thread:
|
||||||
|
worker.start()
|
||||||
|
return worker
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def thread_worker(
|
||||||
|
function: Callable[_P, Generator[_Y, _S, _R]],
|
||||||
|
start_thread: bool | None = None,
|
||||||
|
connect: dict[str, Callable | Sequence[Callable]] | None = None,
|
||||||
|
worker_class: type[WorkerBase] | None = None,
|
||||||
|
ignore_errors: bool = False,
|
||||||
|
) -> Callable[_P, GeneratorWorker[_Y, _S, _R]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def thread_worker(
|
||||||
|
function: Callable[_P, _R],
|
||||||
|
start_thread: bool | None = None,
|
||||||
|
connect: dict[str, Callable | Sequence[Callable]] | None = None,
|
||||||
|
worker_class: type[WorkerBase] | None = None,
|
||||||
|
ignore_errors: bool = False,
|
||||||
|
) -> Callable[_P, FunctionWorker[_R]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def thread_worker(
|
||||||
|
function: Literal[None] = None,
|
||||||
|
start_thread: bool | None = None,
|
||||||
|
connect: dict[str, Callable | Sequence[Callable]] | None = None,
|
||||||
|
worker_class: type[WorkerBase] | None = None,
|
||||||
|
ignore_errors: bool = False,
|
||||||
|
) -> Callable[[Callable], Callable[_P, FunctionWorker | GeneratorWorker]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def thread_worker(
|
||||||
|
function: Callable | None = None,
|
||||||
|
start_thread: bool | None = None,
|
||||||
|
connect: dict[str, Callable | Sequence[Callable]] | None = None,
|
||||||
|
worker_class: type[WorkerBase] | None = None,
|
||||||
|
ignore_errors: bool = False,
|
||||||
|
):
|
||||||
|
"""Decorator that runs a function in a separate thread when called.
|
||||||
|
|
||||||
|
When called, the decorated function returns a
|
||||||
|
[`WorkerBase`][superqt.utils.WorkerBase]. See
|
||||||
|
[`create_worker`][superqt.utils.create_worker] for additional keyword arguments that
|
||||||
|
can be used
|
||||||
|
when calling the function.
|
||||||
|
|
||||||
|
The returned worker will have these signals:
|
||||||
|
|
||||||
|
- **started**: emitted when the work is started
|
||||||
|
- **finished**: emitted when the work is finished
|
||||||
|
- **returned**: emitted with return value
|
||||||
|
- **errored**: emitted with error object on Exception
|
||||||
|
|
||||||
|
It will also have a `worker.start()` method that can be used to start
|
||||||
|
execution of the function in another thread. (useful if you need to connect
|
||||||
|
callbacks to signals prior to execution)
|
||||||
|
|
||||||
|
If the decorated function is a generator, the returned worker will also
|
||||||
|
provide these signals:
|
||||||
|
|
||||||
|
- **yielded**: emitted with yielded values
|
||||||
|
- **paused**: emitted when a running job has successfully paused
|
||||||
|
- **resumed**: emitted when a paused job has successfully resumed
|
||||||
|
- **aborted**: emitted when a running job is successfully aborted
|
||||||
|
|
||||||
|
And these methods:
|
||||||
|
|
||||||
|
- **quit**: ask the thread to quit
|
||||||
|
- **toggle_paused**: toggle the running state of the thread.
|
||||||
|
- **send**: send a value into the generator. (This requires that your
|
||||||
|
decorator function uses the `value = yield` syntax)
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
function : callable
|
||||||
|
Function to call in another thread. For communication between threads
|
||||||
|
may be a generator function.
|
||||||
|
start_thread : bool
|
||||||
|
Whether to immediaetly start the thread. If False, the returned worker
|
||||||
|
must be manually started with `worker.start()`. by default it will be
|
||||||
|
`False` if the `_connect` argument is `None`, otherwise `True`.
|
||||||
|
connect : Dict[str, Union[Callable, Sequence]]
|
||||||
|
A mapping of `"signal_name"` -> `callable` or list of `callable`:
|
||||||
|
callback functions to connect to the various signals offered by the
|
||||||
|
worker class. by default None
|
||||||
|
worker_class : Type[WorkerBase]
|
||||||
|
The [`WorkerBase`][superqt.utils.WorkerBase] to instantiate, by default
|
||||||
|
[`FunctionWorker`][superqt.utils.FunctionWorker] will be used if `func` is a
|
||||||
|
regular function, and [`GeneratorWorker`][superqt.utils.GeneratorWorker] will be
|
||||||
|
used if it is a generator.
|
||||||
|
ignore_errors : bool
|
||||||
|
If `False` (the default), errors raised in the other thread will be
|
||||||
|
reraised in the main thread (makes debugging significantly easier).
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
callable
|
||||||
|
function that creates a worker, puts it in a new thread and returns
|
||||||
|
the worker instance.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```python
|
||||||
|
@thread_worker
|
||||||
|
def long_function(start, end):
|
||||||
|
# do work, periodically yielding
|
||||||
|
i = start
|
||||||
|
while i <= end:
|
||||||
|
time.sleep(0.1)
|
||||||
|
yield i
|
||||||
|
|
||||||
|
# do teardown
|
||||||
|
return 'anything'
|
||||||
|
|
||||||
|
# call the function to start running in another thread.
|
||||||
|
worker = long_function()
|
||||||
|
|
||||||
|
# connect signals here if desired... or they may be added using the
|
||||||
|
# `connect` argument in the `@thread_worker` decorator... in which
|
||||||
|
# case the worker will start immediately when long_function() is called
|
||||||
|
worker.start()
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _inner(func):
|
||||||
|
@wraps(func)
|
||||||
|
def worker_function(*args, **kwargs):
|
||||||
|
# decorator kwargs can be overridden at call time by using the
|
||||||
|
# underscore-prefixed version of the kwarg.
|
||||||
|
kwargs["_start_thread"] = kwargs.get("_start_thread", start_thread)
|
||||||
|
kwargs["_connect"] = kwargs.get("_connect", connect)
|
||||||
|
kwargs["_worker_class"] = kwargs.get("_worker_class", worker_class)
|
||||||
|
kwargs["_ignore_errors"] = kwargs.get("_ignore_errors", ignore_errors)
|
||||||
|
return create_worker(
|
||||||
|
func,
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
return worker_function
|
||||||
|
|
||||||
|
return _inner if function is None else _inner(function)
|
||||||
|
|
||||||
|
|
||||||
|
############################################################################
|
||||||
|
|
||||||
|
# This is a variant on the above pattern, it uses QThread instead of Qrunnable
|
||||||
|
# see https://doc.qt.io/qt-5/threads-technologies.html#comparison-of-solutions
|
||||||
|
# (it appears from that table that QRunnable cannot emit or receive signals,
|
||||||
|
# but we circumvent that here with our WorkerBase class that also inherits from
|
||||||
|
# QObject... providing signals/slots).
|
||||||
|
#
|
||||||
|
# A benefit of the QRunnable pattern is that Qt manages the threads for you,
|
||||||
|
# in the QThreadPool.globalInstance() ... making it easier to reuse threads,
|
||||||
|
# and reduce overhead.
|
||||||
|
#
|
||||||
|
# However, a disadvantage is that you have no access to (and therefore less
|
||||||
|
# control over) the QThread itself. See for example all of the methods
|
||||||
|
# provided on the QThread object: https://doc.qt.io/qt-5/qthread.html
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
|
||||||
|
class WorkerProtocol(QObject):
|
||||||
|
finished: Signal
|
||||||
|
|
||||||
|
def work(self) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def new_worker_qthread(
|
||||||
|
Worker: type[WorkerProtocol],
|
||||||
|
*args,
|
||||||
|
_start_thread: bool = False,
|
||||||
|
_connect: dict[str, Callable] | None = None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
"""Convenience function to start a worker in a `QThread`.
|
||||||
|
|
||||||
|
thread, not as the actual code or object that runs in that
|
||||||
|
thread. The QThread object is created on the main thread and lives there.
|
||||||
|
|
||||||
|
Worker objects which derive from QObject are the things that actually do
|
||||||
|
the work. They can be moved to a QThread as is done here.
|
||||||
|
|
||||||
|
??? "Mostly ignorable detail"
|
||||||
|
|
||||||
|
While the signals/slots syntax of the worker looks very similar to
|
||||||
|
standard "single-threaded" signals & slots, note that inter-thread
|
||||||
|
signals and slots (automatically) use an event-based QueuedConnection, while
|
||||||
|
intra-thread signals use a DirectConnection. See [Signals and Slots Across
|
||||||
|
Threads](https://doc.qt.io/qt-5/threads-qobject.html#signals-and-slots-across-threads>)
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
Worker : QObject
|
||||||
|
QObject type that implements a `work()` method. The Worker should also
|
||||||
|
emit a finished signal when the work is done.
|
||||||
|
_start_thread : bool
|
||||||
|
If True, thread will be started immediately, otherwise, thread must
|
||||||
|
be manually started with thread.start().
|
||||||
|
_connect : dict
|
||||||
|
Optional dictionary of {signal: function} to connect to the new worker.
|
||||||
|
for instance: _connect = {'incremented': myfunc} will result in:
|
||||||
|
worker.incremented.connect(myfunc)
|
||||||
|
*args
|
||||||
|
will be passed to the Worker class on instantiation.
|
||||||
|
**kwargs
|
||||||
|
will be passed to the Worker class on instantiation.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
worker : WorkerBase
|
||||||
|
The created worker.
|
||||||
|
thread : QThread
|
||||||
|
The thread on which the worker is running.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
Create some QObject that has a long-running work method:
|
||||||
|
|
||||||
|
```python
|
||||||
|
|
||||||
|
class Worker(QObject):
|
||||||
|
|
||||||
|
finished = Signal()
|
||||||
|
increment = Signal(int)
|
||||||
|
|
||||||
|
def __init__(self, argument):
|
||||||
|
super().__init__()
|
||||||
|
self.argument = argument
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def work(self):
|
||||||
|
# some long running task...
|
||||||
|
import time
|
||||||
|
for i in range(10):
|
||||||
|
time.sleep(1)
|
||||||
|
self.increment.emit(i)
|
||||||
|
self.finished.emit()
|
||||||
|
|
||||||
|
worker, thread = new_worker_qthread(
|
||||||
|
Worker,
|
||||||
|
'argument',
|
||||||
|
_start_thread=True,
|
||||||
|
_connect={'increment': print},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
if _connect and not isinstance(_connect, dict):
|
||||||
|
raise TypeError("_connect parameter must be a dict")
|
||||||
|
|
||||||
|
thread = QThread()
|
||||||
|
worker = Worker(*args, **kwargs)
|
||||||
|
worker.moveToThread(thread)
|
||||||
|
thread.started.connect(worker.work)
|
||||||
|
worker.finished.connect(thread.quit)
|
||||||
|
worker.finished.connect(worker.deleteLater)
|
||||||
|
thread.finished.connect(thread.deleteLater)
|
||||||
|
|
||||||
|
if _connect:
|
||||||
|
[getattr(worker, key).connect(val) for key, val in _connect.items()]
|
||||||
|
|
||||||
|
if _start_thread:
|
||||||
|
thread.start() # sometimes need to connect stuff before starting
|
||||||
|
return worker, thread
|
377
src/superqt/utils/_throttler.py
Normal file
377
src/superqt/utils/_throttler.py
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
"""Adapted for python from the KDToolBox.
|
||||||
|
|
||||||
|
https://github.com/KDAB/KDToolBox/tree/master/qt/KDSignalThrottler
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (C) 2019-2022 Klarälvdalens Datakonsult AB, a KDAB Group company,
|
||||||
|
info@kdab.com
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from concurrent.futures import Future
|
||||||
|
from enum import IntFlag, auto
|
||||||
|
from functools import wraps
|
||||||
|
from typing import TYPE_CHECKING, Callable, Generic, Optional, TypeVar, Union, overload
|
||||||
|
|
||||||
|
from qtpy.QtCore import QObject, Qt, QTimer, Signal
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from qtpy.QtCore import SignalInstance
|
||||||
|
from typing_extensions import Literal, ParamSpec
|
||||||
|
|
||||||
|
P = ParamSpec("P")
|
||||||
|
# maintain runtime compatibility with older typing_extensions
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
from typing_extensions import ParamSpec
|
||||||
|
|
||||||
|
P = ParamSpec("P")
|
||||||
|
except ImportError:
|
||||||
|
P = TypeVar("P")
|
||||||
|
|
||||||
|
R = TypeVar("R")
|
||||||
|
|
||||||
|
|
||||||
|
class Kind(IntFlag):
|
||||||
|
Throttler = auto()
|
||||||
|
Debouncer = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class EmissionPolicy(IntFlag):
|
||||||
|
Trailing = auto()
|
||||||
|
Leading = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class GenericSignalThrottler(QObject):
|
||||||
|
triggered = Signal()
|
||||||
|
timeoutChanged = Signal(int)
|
||||||
|
timerTypeChanged = Signal(Qt.TimerType)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
kind: Kind,
|
||||||
|
emissionPolicy: EmissionPolicy,
|
||||||
|
parent: Optional[QObject] = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
self._kind = kind
|
||||||
|
self._emissionPolicy = emissionPolicy
|
||||||
|
self._hasPendingEmission = False
|
||||||
|
|
||||||
|
self._timer = QTimer(parent=self)
|
||||||
|
self._timer.setSingleShot(True)
|
||||||
|
self._timer.setTimerType(Qt.TimerType.PreciseTimer)
|
||||||
|
self._timer.timeout.connect(self._maybeEmitTriggered)
|
||||||
|
|
||||||
|
def kind(self) -> Kind:
|
||||||
|
"""Return the kind of throttler (throttler or debouncer)."""
|
||||||
|
return self._kind
|
||||||
|
|
||||||
|
def emissionPolicy(self) -> EmissionPolicy:
|
||||||
|
"""Return the emission policy (trailing or leading)."""
|
||||||
|
return self._emissionPolicy
|
||||||
|
|
||||||
|
def timeout(self) -> int:
|
||||||
|
"""Return current timeout in milliseconds."""
|
||||||
|
return self._timer.interval()
|
||||||
|
|
||||||
|
def setTimeout(self, timeout: int) -> None:
|
||||||
|
"""Set timeout in milliseconds."""
|
||||||
|
if self._timer.interval() != timeout:
|
||||||
|
self._timer.setInterval(timeout)
|
||||||
|
self.timeoutChanged.emit(timeout)
|
||||||
|
|
||||||
|
def timerType(self) -> Qt.TimerType:
|
||||||
|
"""Return current `Qt.TimerType`."""
|
||||||
|
return self._timer.timerType()
|
||||||
|
|
||||||
|
def setTimerType(self, timerType: Qt.TimerType) -> None:
|
||||||
|
"""Set current Qt.TimerType."""
|
||||||
|
if self._timer.timerType() != timerType:
|
||||||
|
self._timer.setTimerType(timerType)
|
||||||
|
self.timerTypeChanged.emit(timerType)
|
||||||
|
|
||||||
|
def throttle(self) -> None:
|
||||||
|
"""Emit triggered if not running, then start timer."""
|
||||||
|
# public slot
|
||||||
|
self._hasPendingEmission = True
|
||||||
|
# Emit only if we haven't emitted already. We know if that's
|
||||||
|
# the case by checking if the timer is running.
|
||||||
|
if (
|
||||||
|
self._emissionPolicy is EmissionPolicy.Leading
|
||||||
|
and not self._timer.isActive()
|
||||||
|
):
|
||||||
|
self._emitTriggered()
|
||||||
|
|
||||||
|
# The timer is started in all cases. If we got a signal, and we're Leading,
|
||||||
|
# and we did emit because of that, then we don't re-emit when the timer fires
|
||||||
|
# (unless we get ANOTHER signal).
|
||||||
|
if self._kind is Kind.Throttler: # sourcery skip: merge-duplicate-blocks
|
||||||
|
if not self._timer.isActive():
|
||||||
|
self._timer.start() # actual start, not restart
|
||||||
|
elif self._kind is Kind.Debouncer:
|
||||||
|
self._timer.start() # restart
|
||||||
|
|
||||||
|
def cancel(self) -> None:
|
||||||
|
"""Cancel any pending emissions."""
|
||||||
|
self._hasPendingEmission = False
|
||||||
|
|
||||||
|
def flush(self) -> None:
|
||||||
|
"""Force emission of any pending emissions."""
|
||||||
|
self._maybeEmitTriggered()
|
||||||
|
|
||||||
|
def _emitTriggered(self) -> None:
|
||||||
|
self._hasPendingEmission = False
|
||||||
|
self.triggered.emit()
|
||||||
|
self._timer.start()
|
||||||
|
|
||||||
|
def _maybeEmitTriggered(self) -> None:
|
||||||
|
if self._hasPendingEmission:
|
||||||
|
self._emitTriggered()
|
||||||
|
|
||||||
|
Kind = Kind
|
||||||
|
EmissionPolicy = EmissionPolicy
|
||||||
|
|
||||||
|
|
||||||
|
# ### Convenience classes ###
|
||||||
|
|
||||||
|
|
||||||
|
class QSignalThrottler(GenericSignalThrottler):
|
||||||
|
"""A Signal Throttler.
|
||||||
|
|
||||||
|
This object's `triggered` signal will emit at most once per timeout
|
||||||
|
(set with setTimeout()).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
policy: EmissionPolicy = EmissionPolicy.Leading,
|
||||||
|
parent: Optional[QObject] = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(Kind.Throttler, policy, parent)
|
||||||
|
|
||||||
|
|
||||||
|
class QSignalDebouncer(GenericSignalThrottler):
|
||||||
|
"""A Signal Debouncer.
|
||||||
|
|
||||||
|
This object's `triggered` signal will not be emitted until `self.timeout()`
|
||||||
|
milliseconds have elapsed since the last time `triggered` was emitted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
policy: EmissionPolicy = EmissionPolicy.Trailing,
|
||||||
|
parent: Optional[QObject] = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(Kind.Debouncer, policy, parent)
|
||||||
|
|
||||||
|
|
||||||
|
# below here part is unique to superqt (not from KD)
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from typing_extensions import Protocol
|
||||||
|
|
||||||
|
class ThrottledCallable(Generic[P, R], Protocol):
|
||||||
|
triggered: "SignalInstance"
|
||||||
|
|
||||||
|
def cancel(self) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def flush(self) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def set_timeout(self, timeout: int) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
if sys.version_info < (3, 9):
|
||||||
|
|
||||||
|
def __call__(self, *args: "P.args", **kwargs: "P.kwargs") -> Future:
|
||||||
|
...
|
||||||
|
|
||||||
|
else:
|
||||||
|
|
||||||
|
def __call__(self, *args: "P.args", **kwargs: "P.kwargs") -> Future[R]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def qthrottled(
|
||||||
|
func: Callable[P, R],
|
||||||
|
timeout: int = 100,
|
||||||
|
leading: bool = True,
|
||||||
|
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
|
||||||
|
) -> "ThrottledCallable[P, R]":
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def qthrottled(
|
||||||
|
func: Optional["Literal[None]"] = None,
|
||||||
|
timeout: int = 100,
|
||||||
|
leading: bool = True,
|
||||||
|
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
|
||||||
|
) -> Callable[[Callable[P, R]], "ThrottledCallable[P, R]"]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def qthrottled(
|
||||||
|
func: Optional[Callable[P, R]] = None,
|
||||||
|
timeout: int = 100,
|
||||||
|
leading: bool = True,
|
||||||
|
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
|
||||||
|
) -> Union[
|
||||||
|
"ThrottledCallable[P, R]", Callable[[Callable[P, R]], "ThrottledCallable[P, R]"]
|
||||||
|
]:
|
||||||
|
"""Creates a throttled function that invokes func at most once per timeout.
|
||||||
|
|
||||||
|
The throttled function comes with a `cancel` method to cancel delayed func
|
||||||
|
invocations and a `flush` method to immediately invoke them. Options
|
||||||
|
to indicate whether func should be invoked on the leading and/or trailing
|
||||||
|
edge of the wait timeout. The func is invoked with the last arguments provided
|
||||||
|
to the throttled function. Subsequent calls to the throttled function return
|
||||||
|
the result of the last func invocation.
|
||||||
|
|
||||||
|
This decorator may be used with or without parameters.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
func : Callable
|
||||||
|
A function to throttle
|
||||||
|
timeout : int
|
||||||
|
Timeout in milliseconds to wait before allowing another call, by default 100
|
||||||
|
leading : bool
|
||||||
|
Whether to invoke the function on the leading edge of the wait timer,
|
||||||
|
by default True
|
||||||
|
timer_type : Qt.TimerType
|
||||||
|
The timer type. by default `Qt.TimerType.PreciseTimer`
|
||||||
|
One of:
|
||||||
|
- `Qt.PreciseTimer`: Precise timers try to keep millisecond accuracy
|
||||||
|
- `Qt.CoarseTimer`: Coarse timers try to keep accuracy within 5% of the
|
||||||
|
desired interval
|
||||||
|
- `Qt.VeryCoarseTimer`: Very coarse timers only keep full second accuracy
|
||||||
|
"""
|
||||||
|
return _make_decorator(func, timeout, leading, timer_type, Kind.Throttler)
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def qdebounced(
|
||||||
|
func: Callable[P, R],
|
||||||
|
timeout: int = 100,
|
||||||
|
leading: bool = False,
|
||||||
|
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
|
||||||
|
) -> "ThrottledCallable[P, R]":
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def qdebounced(
|
||||||
|
func: Optional["Literal[None]"] = None,
|
||||||
|
timeout: int = 100,
|
||||||
|
leading: bool = False,
|
||||||
|
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
|
||||||
|
) -> Callable[[Callable[P, R]], "ThrottledCallable[P, R]"]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def qdebounced(
|
||||||
|
func: Optional[Callable[P, R]] = None,
|
||||||
|
timeout: int = 100,
|
||||||
|
leading: bool = False,
|
||||||
|
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
|
||||||
|
) -> Union[
|
||||||
|
"ThrottledCallable[P, R]", Callable[[Callable[P, R]], "ThrottledCallable[P, R]"]
|
||||||
|
]:
|
||||||
|
"""Creates a debounced function that delays invoking `func`.
|
||||||
|
|
||||||
|
`func` will not be invoked until `timeout` ms have elapsed since the last time
|
||||||
|
the debounced function was invoked.
|
||||||
|
|
||||||
|
The debounced function comes with a `cancel` method to cancel delayed func
|
||||||
|
invocations and a `flush` method to immediately invoke them. Options
|
||||||
|
indicate whether func should be invoked on the leading and/or trailing edge
|
||||||
|
of the wait timeout. The func is invoked with the *last* arguments provided to
|
||||||
|
the debounced function. Subsequent calls to the debounced function return the
|
||||||
|
result of the last `func` invocation.
|
||||||
|
|
||||||
|
This decorator may be used with or without parameters.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
func : Callable
|
||||||
|
A function to throttle
|
||||||
|
timeout : int
|
||||||
|
Timeout in milliseconds to wait before allowing another call, by default 100
|
||||||
|
leading : bool
|
||||||
|
Whether to invoke the function on the leading edge of the wait timer,
|
||||||
|
by default False
|
||||||
|
timer_type : Qt.TimerType
|
||||||
|
The timer type. by default `Qt.TimerType.PreciseTimer`
|
||||||
|
One of:
|
||||||
|
- `Qt.PreciseTimer`: Precise timers try to keep millisecond accuracy
|
||||||
|
- `Qt.CoarseTimer`: Coarse timers try to keep accuracy within 5% of the
|
||||||
|
desired interval
|
||||||
|
- `Qt.VeryCoarseTimer`: Very coarse timers only keep full second accuracy
|
||||||
|
"""
|
||||||
|
return _make_decorator(func, timeout, leading, timer_type, Kind.Debouncer)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_decorator(
|
||||||
|
func: Optional[Callable[P, R]],
|
||||||
|
timeout: int,
|
||||||
|
leading: bool,
|
||||||
|
timer_type: Qt.TimerType,
|
||||||
|
kind: Kind,
|
||||||
|
) -> Union[
|
||||||
|
"ThrottledCallable[P, R]", Callable[[Callable[P, R]], "ThrottledCallable[P, R]"]
|
||||||
|
]:
|
||||||
|
def deco(func: Callable[P, R]) -> "ThrottledCallable[P, R]":
|
||||||
|
policy = EmissionPolicy.Leading if leading else EmissionPolicy.Trailing
|
||||||
|
throttle = GenericSignalThrottler(kind, policy)
|
||||||
|
throttle.setTimerType(timer_type)
|
||||||
|
throttle.setTimeout(timeout)
|
||||||
|
last_f = None
|
||||||
|
future: Optional[Future] = None
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
def inner(*args: "P.args", **kwargs: "P.kwargs") -> Future:
|
||||||
|
nonlocal last_f
|
||||||
|
nonlocal future
|
||||||
|
if last_f is not None:
|
||||||
|
throttle.triggered.disconnect(last_f)
|
||||||
|
if future is not None and not future.done():
|
||||||
|
future.cancel()
|
||||||
|
|
||||||
|
future = Future()
|
||||||
|
last_f = lambda: future.set_result(func(*args, **kwargs)) # noqa
|
||||||
|
throttle.triggered.connect(last_f)
|
||||||
|
throttle.throttle()
|
||||||
|
return future
|
||||||
|
|
||||||
|
inner.cancel = throttle.cancel
|
||||||
|
inner.flush = throttle.flush
|
||||||
|
inner.set_timeout = throttle.setTimeout
|
||||||
|
inner.triggered = throttle.triggered
|
||||||
|
return inner # type: ignore
|
||||||
|
|
||||||
|
return deco(func) if func is not None else deco
|
23
src/superqt/utils/_util.py
Normal file
23
src/superqt/utils/_util.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from inspect import signature
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
|
||||||
|
def get_max_args(func: Callable) -> int | None:
|
||||||
|
"""Return the maximum number of positional arguments that func can accept."""
|
||||||
|
if not callable(func):
|
||||||
|
raise TypeError(f"{func!r} is not callable")
|
||||||
|
|
||||||
|
try:
|
||||||
|
sig = signature(func)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
max_args = 0
|
||||||
|
for param in sig.parameters.values():
|
||||||
|
if param.kind == param.VAR_POSITIONAL:
|
||||||
|
return None
|
||||||
|
if param.kind in {param.POSITIONAL_ONLY, param.POSITIONAL_OR_KEYWORD}:
|
||||||
|
max_args += 1
|
||||||
|
return max_args
|
@@ -1,28 +0,0 @@
|
|||||||
"""superqt is a collection of QtWidgets for python."""
|
|
||||||
try:
|
|
||||||
from ._version import version as __version__
|
|
||||||
except ImportError:
|
|
||||||
__version__ = "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
from .sliders import (
|
|
||||||
QDoubleRangeSlider,
|
|
||||||
QDoubleSlider,
|
|
||||||
QLabeledDoubleRangeSlider,
|
|
||||||
QLabeledDoubleSlider,
|
|
||||||
QLabeledRangeSlider,
|
|
||||||
QLabeledSlider,
|
|
||||||
QRangeSlider,
|
|
||||||
)
|
|
||||||
from .spinbox import QLargeIntSpinBox
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"QDoubleRangeSlider",
|
|
||||||
"QDoubleSlider",
|
|
||||||
"QLabeledDoubleRangeSlider",
|
|
||||||
"QLabeledDoubleSlider",
|
|
||||||
"QLabeledRangeSlider",
|
|
||||||
"QLabeledSlider",
|
|
||||||
"QLargeIntSpinBox",
|
|
||||||
"QRangeSlider",
|
|
||||||
]
|
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user