mirror of
https://github.com/pyapp-kit/superqt.git
synced 2025-07-21 04:01:07 +02:00
Compare commits
20 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
0681f7138a | ||
|
1e1f38d297 | ||
|
c101b29d65 | ||
|
cb1b589768 | ||
|
b0532c31c3 | ||
|
c355f8b06d | ||
|
d7afa8824c | ||
|
789b98f892 | ||
|
8001022e18 | ||
|
e1d2edb204 | ||
|
055a4fc1a7 | ||
|
5983bd1552 | ||
|
67035a0f0b | ||
|
8d76579122 | ||
|
c5658b353a | ||
|
5ab72a0c48 | ||
|
06da62811b | ||
|
bb538cda2a | ||
|
c8a40ba051 | ||
|
ac1d8403fd |
57
.github/workflows/test_and_deploy.yml
vendored
57
.github/workflows/test_and_deploy.yml
vendored
@@ -47,6 +47,16 @@ jobs:
|
||||
- python-version: 3.9
|
||||
platform: macos-11.0
|
||||
backend: pyqt6
|
||||
# py3.10
|
||||
- python-version: "3.10"
|
||||
platform: ubuntu-latest
|
||||
backend: pyside6
|
||||
- python-version: "3.10"
|
||||
platform: ubuntu-latest
|
||||
backend: pyqt5
|
||||
- python-version: "3.10"
|
||||
platform: ubuntu-latest
|
||||
backend: pyqt6
|
||||
|
||||
# big sur, 3.9
|
||||
- python-version: 3.9
|
||||
@@ -60,20 +70,28 @@ jobs:
|
||||
- python-version: 3.8
|
||||
platform: ubuntu-18.04
|
||||
backend: pyside2
|
||||
- python-version: 3.6
|
||||
platform: ubuntu-16.04
|
||||
backend: pyqt5
|
||||
- python-version: 3.6
|
||||
platform: windows-2016
|
||||
backend: pyqt5
|
||||
|
||||
# legacy Qt
|
||||
- python-version: 3.7
|
||||
platform: ubuntu-latest
|
||||
backend: pyside511
|
||||
- python-version: 3.7
|
||||
platform: ubuntu-latest
|
||||
backend: pyqt511
|
||||
- python-version: 3.7
|
||||
platform: ubuntu-latest
|
||||
backend: pyside511
|
||||
backend: pyqt512
|
||||
- python-version: 3.7
|
||||
platform: ubuntu-latest
|
||||
backend: pyqt513
|
||||
- python-version: 3.7
|
||||
platform: ubuntu-latest
|
||||
backend: pyqt514
|
||||
|
||||
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
@@ -83,12 +101,7 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install Linux libraries
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get install -y libdbus-1-3 libxkbcommon-x11-0 libxcb-icccm4 \
|
||||
libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 \
|
||||
libxcb-xinerama0 libxcb-xfixes0
|
||||
- uses: tlambert03/setup-qt-libs@v1
|
||||
|
||||
- name: Linux opengl
|
||||
if: runner.os == 'Linux' && ( matrix.backend == 'pyside6' || matrix.backend == 'pyqt6' )
|
||||
@@ -100,7 +113,10 @@ jobs:
|
||||
pip install setuptools tox tox-gh-actions
|
||||
|
||||
- name: Test with tox
|
||||
run: tox
|
||||
uses: GabrielBB/xvfb-action@v1
|
||||
timeout-minutes: 3
|
||||
with:
|
||||
run: tox
|
||||
env:
|
||||
PLATFORM: ${{ matrix.platform }}
|
||||
BACKEND: ${{ matrix.backend }}
|
||||
@@ -128,12 +144,24 @@ jobs:
|
||||
name: screenshots ${{ runner.os }}
|
||||
path: screenshots
|
||||
|
||||
check_manifest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: "3.x"
|
||||
- name: Check manifest
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install check-manifest
|
||||
check-manifest
|
||||
|
||||
deploy:
|
||||
# this will run when you have tagged a commit, starting with "v*"
|
||||
# and requires that you have put your twine API key in your
|
||||
# github secrets (see readme for details)
|
||||
needs: [test]
|
||||
needs: [test, check_manifest]
|
||||
if: ${{ github.repository == 'napari/superqt' && contains(github.ref, 'tags') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -145,12 +173,13 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -U setuptools setuptools_scm wheel twine
|
||||
pip install build twine
|
||||
- name: Build and publish
|
||||
env:
|
||||
TWINE_USERNAME: __token__
|
||||
TWINE_PASSWORD: ${{ secrets.TWINE_API_KEY }}
|
||||
run: |
|
||||
git tag
|
||||
python setup.py sdist bdist_wheel
|
||||
python -m build
|
||||
twine check dist/*
|
||||
twine upload dist/*
|
||||
|
8
.github_changelog_generator
Normal file
8
.github_changelog_generator
Normal file
@@ -0,0 +1,8 @@
|
||||
# run this with:
|
||||
# export CHANGELOG_GITHUB_TOKEN=......
|
||||
# github_changelog_generator --future-release vX.Y.Z
|
||||
user=napari
|
||||
project=superqt
|
||||
issues=false
|
||||
since-tag=v0.2.0
|
||||
add-sections={"documentation":{"prefix":"**Documentation updates:**","labels":["documentation"]}}
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -9,6 +9,7 @@ __pycache__/
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
env/
|
||||
.venv/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
@@ -76,7 +77,7 @@ target/
|
||||
.DS_Store
|
||||
|
||||
# written by setuptools_scm
|
||||
*/_version.py
|
||||
src/superqt/_version.py
|
||||
.vscode/settings.json
|
||||
screenshots
|
||||
|
||||
|
@@ -5,15 +5,14 @@ repos:
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
- repo: https://github.com/asottile/setup-cfg-fmt
|
||||
rev: v1.17.0
|
||||
rev: v1.20.0
|
||||
hooks:
|
||||
- id: setup-cfg-fmt
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: 3.9.2
|
||||
rev: 4.0.1
|
||||
hooks:
|
||||
- id: flake8
|
||||
additional_dependencies:
|
||||
[flake8-typing-imports==1.7.0]
|
||||
additional_dependencies: [flake8-typing-imports==1.7.0]
|
||||
exclude: examples
|
||||
- repo: https://github.com/myint/autoflake
|
||||
rev: v1.4
|
||||
@@ -21,15 +20,21 @@ repos:
|
||||
- id: autoflake
|
||||
args: ["--in-place", "--remove-all-unused-imports"]
|
||||
- repo: https://github.com/PyCQA/isort
|
||||
rev: 5.8.0
|
||||
rev: 5.10.1
|
||||
hooks:
|
||||
- id: isort
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v2.19.1
|
||||
rev: v2.29.1
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py37-plus]
|
||||
args: [--py37-plus, --keep-runtime-typing]
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 21.5b2
|
||||
rev: 21.11b1
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v0.910-1
|
||||
hooks:
|
||||
- id: mypy
|
||||
exclude: examples
|
||||
stages: [manual]
|
||||
|
77
CHANGELOG.md
Normal file
77
CHANGELOG.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Changelog
|
||||
|
||||
## [v0.2.5](https://github.com/napari/superqt/tree/v0.2.5) (2021-11-22)
|
||||
|
||||
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.4...v0.2.5)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- add support for python 3.10 [\#42](https://github.com/napari/superqt/pull/42) ([tlambert03](https://github.com/tlambert03))
|
||||
- QCollapsible for Collapsible Section Control [\#37](https://github.com/napari/superqt/pull/37) ([MosGeo](https://github.com/MosGeo))
|
||||
- Threadworker [\#31](https://github.com/napari/superqt/pull/31) ([tlambert03](https://github.com/tlambert03))
|
||||
- Add font icons [\#24](https://github.com/napari/superqt/pull/24) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- Fix some small linting issues. [\#41](https://github.com/napari/superqt/pull/41) ([tlambert03](https://github.com/tlambert03))
|
||||
- Use functools.wraps insterad of \_\_wraped\_\_ and manual proxing \_\_name\_\_ [\#29](https://github.com/napari/superqt/pull/29) ([Czaki](https://github.com/Czaki))
|
||||
- Propagate function name in `ensure_main_thread` and `ensure_object_thread` [\#28](https://github.com/napari/superqt/pull/28) ([Czaki](https://github.com/Czaki))
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- reskip test\_object\_thread\_return on ci [\#43](https://github.com/napari/superqt/pull/43) ([tlambert03](https://github.com/tlambert03))
|
||||
- refactoring qtcompat [\#34](https://github.com/napari/superqt/pull/34) ([tlambert03](https://github.com/tlambert03))
|
||||
- update deploy [\#33](https://github.com/napari/superqt/pull/33) ([tlambert03](https://github.com/tlambert03))
|
||||
- move to src layout [\#32](https://github.com/napari/superqt/pull/32) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.2.4](https://github.com/napari/superqt/tree/v0.2.4) (2021-09-13)
|
||||
|
||||
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.3...v0.2.4)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- Add type stubs for ensure\_thread decorator [\#23](https://github.com/napari/superqt/pull/23) ([tlambert03](https://github.com/tlambert03))
|
||||
- Add `ensure_main_tread` and `ensure_object_thread` [\#22](https://github.com/napari/superqt/pull/22) ([Czaki](https://github.com/Czaki))
|
||||
- Add QMessageHandler context manager [\#21](https://github.com/napari/superqt/pull/21) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- add changelog for 0.2.4 [\#25](https://github.com/napari/superqt/pull/25) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.2.3](https://github.com/napari/superqt/tree/v0.2.3) (2021-08-25)
|
||||
|
||||
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.2...v0.2.3)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- Fix warnings on eliding label for 5.12, test more qt versions [\#19](https://github.com/napari/superqt/pull/19) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.2.2](https://github.com/napari/superqt/tree/v0.2.2) (2021-08-17)
|
||||
|
||||
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.1...v0.2.2)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- Add QElidingLabel [\#16](https://github.com/napari/superqt/pull/16) ([tlambert03](https://github.com/tlambert03))
|
||||
- Enum ComboBox implementation [\#13](https://github.com/napari/superqt/pull/13) ([Czaki](https://github.com/Czaki))
|
||||
|
||||
**Documentation updates:**
|
||||
|
||||
- fix broken link [\#18](https://github.com/napari/superqt/pull/18) ([haesleinhuepf](https://github.com/haesleinhuepf))
|
||||
|
||||
## [v0.2.1](https://github.com/napari/superqt/tree/v0.2.1) (2021-07-10)
|
||||
|
||||
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.0rc1...v0.2.1)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- Fix QLabeledRangeSlider API \(fix slider proxy\) [\#10](https://github.com/napari/superqt/pull/10) ([tlambert03](https://github.com/tlambert03))
|
||||
- Fix range slider with negative min range [\#9](https://github.com/napari/superqt/pull/9) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.2.0rc1](https://github.com/napari/superqt/tree/v0.2.0rc1) (2021-06-26)
|
||||
|
||||
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.0...v0.2.0rc1)
|
||||
|
||||
|
||||
|
||||
\* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)*
|
12
MANIFEST.in
12
MANIFEST.in
@@ -1,5 +1,17 @@
|
||||
include LICENSE
|
||||
include README.md
|
||||
include CHANGELOG.md
|
||||
include src/superqt/py.typed
|
||||
recursive-include src/superqt *.py
|
||||
recursive-include src/superqt *.pyi
|
||||
|
||||
recursive-exclude * __pycache__
|
||||
recursive-exclude * *.py[co]
|
||||
recursive-exclude docs *
|
||||
recursive-exclude examples *
|
||||
recursive-exclude tests *
|
||||
exclude tox.ini
|
||||
exclude CONTRIBUTING.md
|
||||
exclude codecov.yml
|
||||
exclude .github_changelog_generator
|
||||
exclude .pre-commit-config.yaml
|
||||
|
86
docs/decorators.md
Normal file
86
docs/decorators.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Decorators
|
||||
|
||||
## Move to thread decorators
|
||||
|
||||
`superqt` provides two decorators that help to ensure that given function is
|
||||
running in the desired thread:
|
||||
|
||||
* `ensure_main_thread` - ensures that the decorated function/method runs in the main thread
|
||||
* `ensure_object_thread` - ensures that a decorated bound method of a `QObject` runs in the
|
||||
thread in which the instance lives ([qt
|
||||
documentation](https://doc.qt.io/qt-5/threads-qobject.html#accessing-qobject-subclasses-from-other-threads)).
|
||||
|
||||
By default, functions are executed asynchronously (they return immediately with
|
||||
an instance of
|
||||
[`concurrent.futures.Future`](https://docs.python.org/3/library/concurrent.futures.html#concurrent.futures.Future)).
|
||||
To block and wait for the result, see [Synchronous mode](#synchronous-mode)
|
||||
|
||||
```python
|
||||
from superqt.qtcompat.QtCore import QObject
|
||||
from superqt import ensure_main_thread, ensure_object_thread
|
||||
|
||||
@ensure_main_thread
|
||||
def sample_function():
|
||||
print("This function will run in main thread")
|
||||
|
||||
|
||||
class SampleObject(QObject):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._value = 1
|
||||
|
||||
@ensure_main_thread
|
||||
def sample_method1(self):
|
||||
print("This method will run in main thread")
|
||||
|
||||
@ensure_object_thread
|
||||
def sample_method3(self):
|
||||
import time
|
||||
print("sleeping")
|
||||
time.sleep(1)
|
||||
print("This method will run in object thread")
|
||||
|
||||
@property
|
||||
def value(self):
|
||||
print("return value")
|
||||
return self._value
|
||||
|
||||
@value.setter
|
||||
@ensure_object_thread
|
||||
def value(self, value):
|
||||
print("this setter will run in object thread")
|
||||
self._value = value
|
||||
```
|
||||
|
||||
As can be seen in this example these decorators can also be used for setters.
|
||||
|
||||
These decorators should not be used as replacement of Qt Signals but rather to
|
||||
interact with Qt objects from non Qt code.
|
||||
|
||||
### Synchronous mode
|
||||
|
||||
If you'd like for the program to block and wait for the result of your function
|
||||
call, use the `await_return=True` parameter, and optionally specify a timeout.
|
||||
|
||||
> *Note: Using synchronous mode may significantly impact performance.*
|
||||
|
||||
```python
|
||||
from superqt import ensure_main_thread
|
||||
|
||||
@ensure_main_thread
|
||||
def sample_function1():
|
||||
return 1
|
||||
|
||||
@ensure_main_thread(await_return=True)
|
||||
def sample_function2():
|
||||
return 2
|
||||
|
||||
assert sample_function1() is None
|
||||
assert sample_function2() == 2
|
||||
|
||||
# optionally, specify a timeout
|
||||
@ensure_main_thread(await_return=True, timeout=10000)
|
||||
def sample_function():
|
||||
return 1
|
||||
|
||||
```
|
@@ -4,8 +4,8 @@ from superqt.qtcompat.QtWidgets import QApplication
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
slider = QRangeSlider(Qt.Horizontal)
|
||||
slider = QRangeSlider(Qt.Horizontal)
|
||||
slider = QRangeSlider(Qt.Orientation.Horizontal)
|
||||
slider = QRangeSlider(Qt.Orientation.Horizontal)
|
||||
|
||||
slider.setValue((20, 80))
|
||||
slider.show()
|
||||
|
@@ -4,7 +4,7 @@ from superqt.qtcompat.QtWidgets import QApplication
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
slider = QDoubleSlider(Qt.Horizontal)
|
||||
slider = QDoubleSlider(Qt.Orientation.Horizontal)
|
||||
slider.setRange(0, 1)
|
||||
slider.setValue(0.5)
|
||||
slider.show()
|
||||
|
14
examples/collapsible.py
Normal file
14
examples/collapsible.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Example for QCollapsible"""
|
||||
from superqt import QCollapsible
|
||||
from superqt.qtcompat.QtWidgets import QApplication, QLabel, QPushButton
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
collapsible = QCollapsible("Advanced analysis")
|
||||
collapsible.addWidget(QLabel("This is the inside of the collapsible frame"))
|
||||
for i in range(10):
|
||||
collapsible.addWidget(QPushButton(f"Content button {i + 1}"))
|
||||
|
||||
collapsible.expand(animate=False)
|
||||
collapsible.show()
|
||||
app.exec_()
|
@@ -33,35 +33,37 @@ QRangeSlider {
|
||||
}
|
||||
"""
|
||||
|
||||
Horizontal = QtCore.Qt.Orientation.Horizontal
|
||||
|
||||
|
||||
class DemoWidget(QtW.QWidget):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
reg_hslider = QtW.QSlider(QtCore.Qt.Horizontal)
|
||||
reg_hslider = QtW.QSlider(Horizontal)
|
||||
reg_hslider.setValue(50)
|
||||
range_hslider = QRangeSlider(QtCore.Qt.Horizontal)
|
||||
range_hslider = QRangeSlider(Horizontal)
|
||||
range_hslider.setValue((20, 80))
|
||||
multi_range_hslider = QRangeSlider(QtCore.Qt.Horizontal)
|
||||
multi_range_hslider = QRangeSlider(Horizontal)
|
||||
multi_range_hslider.setValue((11, 33, 66, 88))
|
||||
multi_range_hslider.setTickPosition(QtW.QSlider.TicksAbove)
|
||||
multi_range_hslider.setTickPosition(QtW.QSlider.TickPosition.TicksAbove)
|
||||
|
||||
styled_reg_hslider = QtW.QSlider(QtCore.Qt.Horizontal)
|
||||
styled_reg_hslider = QtW.QSlider(Horizontal)
|
||||
styled_reg_hslider.setValue(50)
|
||||
styled_reg_hslider.setStyleSheet(QSS)
|
||||
styled_range_hslider = QRangeSlider(QtCore.Qt.Horizontal)
|
||||
styled_range_hslider = QRangeSlider(Horizontal)
|
||||
styled_range_hslider.setValue((20, 80))
|
||||
styled_range_hslider.setStyleSheet(QSS)
|
||||
|
||||
reg_vslider = QtW.QSlider(QtCore.Qt.Vertical)
|
||||
reg_vslider = QtW.QSlider(QtCore.Qt.Orientation.Vertical)
|
||||
reg_vslider.setValue(50)
|
||||
range_vslider = QRangeSlider(QtCore.Qt.Vertical)
|
||||
range_vslider = QRangeSlider(QtCore.Qt.Orientation.Vertical)
|
||||
range_vslider.setValue((22, 77))
|
||||
|
||||
tick_vslider = QtW.QSlider(QtCore.Qt.Vertical)
|
||||
tick_vslider = QtW.QSlider(QtCore.Qt.Orientation.Vertical)
|
||||
tick_vslider.setValue(55)
|
||||
tick_vslider.setTickPosition(QtW.QSlider.TicksRight)
|
||||
range_tick_vslider = QRangeSlider(QtCore.Qt.Vertical)
|
||||
range_tick_vslider = QRangeSlider(QtCore.Qt.Orientation.Vertical)
|
||||
range_tick_vslider.setValue((22, 77))
|
||||
range_tick_vslider.setTickPosition(QtW.QSlider.TicksLeft)
|
||||
|
||||
|
@@ -6,9 +6,9 @@ app = QApplication([])
|
||||
|
||||
w = QWidget()
|
||||
|
||||
sld1 = QDoubleSlider(Qt.Horizontal)
|
||||
sld2 = QDoubleRangeSlider(Qt.Horizontal)
|
||||
rs = QRangeSlider(Qt.Horizontal)
|
||||
sld1 = QDoubleSlider(Qt.Orientation.Horizontal)
|
||||
sld2 = QDoubleRangeSlider(Qt.Orientation.Horizontal)
|
||||
rs = QRangeSlider(Qt.Orientation.Horizontal)
|
||||
|
||||
sld1.valueChanged.connect(lambda e: print("doubslider valuechanged", e))
|
||||
|
||||
|
20
examples/fonticon1.py
Normal file
20
examples/fonticon1.py
Normal file
@@ -0,0 +1,20 @@
|
||||
try:
|
||||
from fonticon_fa5 import FA5S
|
||||
except ImportError as e:
|
||||
raise type(e)(
|
||||
"This example requires the fontawesome fontpack:\n\n"
|
||||
"pip install git+https://github.com/tlambert03/fonticon-fontawesome5.git"
|
||||
)
|
||||
|
||||
from superqt.fonticon import icon, pulse
|
||||
from superqt.qtcompat.QtCore import QSize
|
||||
from superqt.qtcompat.QtWidgets import QApplication, QPushButton
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
btn2 = QPushButton()
|
||||
btn2.setIcon(icon(FA5S.spinner, animation=pulse(btn2)))
|
||||
btn2.setIconSize(QSize(225, 225))
|
||||
btn2.show()
|
||||
|
||||
app.exec()
|
20
examples/fonticon2.py
Normal file
20
examples/fonticon2.py
Normal file
@@ -0,0 +1,20 @@
|
||||
try:
|
||||
from fonticon_fa5 import FA5S
|
||||
except ImportError as e:
|
||||
raise type(e)(
|
||||
"This example requires the fontawesome fontpack:\n\n"
|
||||
"pip install git+https://github.com/tlambert03/fonticon-fontawesome5.git"
|
||||
)
|
||||
|
||||
from superqt.fonticon import setTextIcon
|
||||
from superqt.qtcompat.QtWidgets import QApplication, QPushButton
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
|
||||
btn4 = QPushButton()
|
||||
btn4.resize(275, 275)
|
||||
setTextIcon(btn4, FA5S.hamburger)
|
||||
btn4.show()
|
||||
|
||||
app.exec()
|
40
examples/fonticon3.py
Normal file
40
examples/fonticon3.py
Normal file
@@ -0,0 +1,40 @@
|
||||
try:
|
||||
from fonticon_fa5 import FA5S
|
||||
except ImportError as e:
|
||||
raise type(e)(
|
||||
"This example requires the fontawesome fontpack:\n\n"
|
||||
"pip install git+https://github.com/tlambert03/fonticon-fontawesome5.git"
|
||||
)
|
||||
|
||||
from superqt.fonticon import IconOpts, icon, pulse, spin
|
||||
from superqt.qtcompat.QtCore import QSize
|
||||
from superqt.qtcompat.QtWidgets import QApplication, QPushButton
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
btn = QPushButton()
|
||||
btn.setIcon(
|
||||
icon(
|
||||
FA5S.smile,
|
||||
color="blue",
|
||||
states={
|
||||
"active": IconOpts(
|
||||
glyph_key=FA5S.spinner,
|
||||
color="red",
|
||||
scale_factor=0.5,
|
||||
animation=pulse(btn),
|
||||
),
|
||||
"disabled": {"color": "green", "scale_factor": 0.8, "animation": spin(btn)},
|
||||
},
|
||||
)
|
||||
)
|
||||
btn.setIconSize(QSize(256, 256))
|
||||
btn.show()
|
||||
|
||||
|
||||
@btn.clicked.connect
|
||||
def toggle_state():
|
||||
btn.setChecked(not btn.isChecked())
|
||||
|
||||
|
||||
app.exec()
|
@@ -4,7 +4,7 @@ from superqt.qtcompat.QtWidgets import QApplication
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
sld = QDoubleSlider(Qt.Horizontal)
|
||||
sld = QDoubleSlider(Qt.Orientation.Horizontal)
|
||||
sld.setRange(0, 1)
|
||||
sld.setValue(0.5)
|
||||
sld.show()
|
||||
|
377
examples/icon_explorer.py
Normal file
377
examples/icon_explorer.py
Normal file
@@ -0,0 +1,377 @@
|
||||
from superqt.fonticon._plugins import loaded
|
||||
from superqt.qtcompat import QtCore, QtGui, QtWidgets
|
||||
from superqt.qtcompat.QtCore import Qt
|
||||
|
||||
P = loaded(load_all=True)
|
||||
if not P:
|
||||
print("you have no font packs loaded!")
|
||||
|
||||
|
||||
class GlyphDelegate(QtWidgets.QItemDelegate):
|
||||
def createEditor(self, parent, option, index):
|
||||
if index.column() < 2:
|
||||
edit = QtWidgets.QLineEdit(parent)
|
||||
edit.editingFinished.connect(self.emitCommitData)
|
||||
return edit
|
||||
comboBox = QtWidgets.QComboBox(parent)
|
||||
if index.column() == 2:
|
||||
comboBox.addItem("Normal")
|
||||
comboBox.addItem("Active")
|
||||
comboBox.addItem("Disabled")
|
||||
comboBox.addItem("Selected")
|
||||
elif index.column() == 3:
|
||||
comboBox.addItem("Off")
|
||||
comboBox.addItem("On")
|
||||
|
||||
comboBox.activated.connect(self.emitCommitData)
|
||||
return comboBox
|
||||
|
||||
def setEditorData(self, editor, index):
|
||||
if index.column() < 2:
|
||||
editor.setText(index.model().data(index))
|
||||
return
|
||||
comboBox = editor
|
||||
if comboBox:
|
||||
pos = comboBox.findText(
|
||||
index.model().data(index), Qt.MatchFlag.MatchExactly
|
||||
)
|
||||
comboBox.setCurrentIndex(pos)
|
||||
|
||||
def setModelData(self, editor, model, index):
|
||||
if editor:
|
||||
text = editor.text() if index.column() < 2 else editor.currentText()
|
||||
model.setData(index, text)
|
||||
|
||||
def emitCommitData(self):
|
||||
self.commitData.emit(self.sender())
|
||||
|
||||
|
||||
class IconPreviewArea(QtWidgets.QWidget):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
mainLayout = QtWidgets.QGridLayout()
|
||||
self.setLayout(mainLayout)
|
||||
|
||||
self.icon = QtGui.QIcon()
|
||||
self.size = QtCore.QSize()
|
||||
self.stateLabels = []
|
||||
self.modeLabels = []
|
||||
self.pixmapLabels = []
|
||||
|
||||
self.stateLabels.append(self.createHeaderLabel("Off"))
|
||||
self.stateLabels.append(self.createHeaderLabel("On"))
|
||||
self.modeLabels.append(self.createHeaderLabel("Normal"))
|
||||
self.modeLabels.append(self.createHeaderLabel("Active"))
|
||||
self.modeLabels.append(self.createHeaderLabel("Disabled"))
|
||||
self.modeLabels.append(self.createHeaderLabel("Selected"))
|
||||
|
||||
for j, label in enumerate(self.stateLabels):
|
||||
mainLayout.addWidget(label, j + 1, 0)
|
||||
|
||||
for i, label in enumerate(self.modeLabels):
|
||||
mainLayout.addWidget(label, 0, i + 1)
|
||||
|
||||
self.pixmapLabels.append([])
|
||||
for j in range(len(self.stateLabels)):
|
||||
self.pixmapLabels[i].append(self.createPixmapLabel())
|
||||
mainLayout.addWidget(self.pixmapLabels[i][j], j + 1, i + 1)
|
||||
|
||||
def setIcon(self, icon):
|
||||
self.icon = icon
|
||||
self.updatePixmapLabels()
|
||||
|
||||
def setSize(self, size):
|
||||
if size != self.size:
|
||||
self.size = size
|
||||
self.updatePixmapLabels()
|
||||
|
||||
def createHeaderLabel(self, text):
|
||||
label = QtWidgets.QLabel("<b>%s</b>" % text)
|
||||
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
return label
|
||||
|
||||
def createPixmapLabel(self):
|
||||
label = QtWidgets.QLabel()
|
||||
label.setEnabled(False)
|
||||
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
label.setFrameShape(QtWidgets.QFrame.Box)
|
||||
label.setSizePolicy(
|
||||
QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding
|
||||
)
|
||||
label.setBackgroundRole(QtGui.QPalette.Base)
|
||||
label.setAutoFillBackground(True)
|
||||
label.setMinimumSize(132, 132)
|
||||
return label
|
||||
|
||||
def updatePixmapLabels(self):
|
||||
for i in range(len(self.modeLabels)):
|
||||
if i == 0:
|
||||
mode = QtGui.QIcon.Mode.Normal
|
||||
elif i == 1:
|
||||
mode = QtGui.QIcon.Mode.Active
|
||||
elif i == 2:
|
||||
mode = QtGui.QIcon.Mode.Disabled
|
||||
else:
|
||||
mode = QtGui.QIcon.Mode.Selected
|
||||
|
||||
for j in range(len(self.stateLabels)):
|
||||
state = {True: QtGui.QIcon.State.Off, False: QtGui.QIcon.State.On}[
|
||||
j == 0
|
||||
]
|
||||
pixmap = self.icon.pixmap(self.size, mode, state)
|
||||
self.pixmapLabels[i][j].setPixmap(pixmap)
|
||||
self.pixmapLabels[i][j].setEnabled(not pixmap.isNull())
|
||||
|
||||
|
||||
class MainWindow(QtWidgets.QMainWindow):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.centralWidget = QtWidgets.QWidget()
|
||||
self.setCentralWidget(self.centralWidget)
|
||||
|
||||
self.createPreviewGroupBox()
|
||||
self.createGlyphBox()
|
||||
self.createIconSizeGroupBox()
|
||||
|
||||
mainLayout = QtWidgets.QGridLayout()
|
||||
mainLayout.addWidget(self.previewGroupBox, 0, 0, 1, 2)
|
||||
mainLayout.addWidget(self.glyphGroupBox, 1, 0)
|
||||
mainLayout.addWidget(self.iconSizeGroupBox, 1, 1)
|
||||
self.centralWidget.setLayout(mainLayout)
|
||||
|
||||
self.setWindowTitle("Icons")
|
||||
self.otherRadioButton.click()
|
||||
|
||||
self.resize(self.minimumSizeHint())
|
||||
|
||||
def changeSize(self):
|
||||
if self.otherRadioButton.isChecked():
|
||||
extent = self.otherSpinBox.value()
|
||||
else:
|
||||
if self.smallRadioButton.isChecked():
|
||||
metric = QtWidgets.QStyle.PixelMetric.PM_SmallIconSize
|
||||
elif self.largeRadioButton.isChecked():
|
||||
metric = QtWidgets.QStyle.PixelMetric.PM_LargeIconSize
|
||||
elif self.toolBarRadioButton.isChecked():
|
||||
metric = QtWidgets.QStyle.PixelMetric.PM_ToolBarIconSize
|
||||
elif self.listViewRadioButton.isChecked():
|
||||
metric = QtWidgets.QStyle.PixelMetric.PM_ListViewIconSize
|
||||
elif self.iconViewRadioButton.isChecked():
|
||||
metric = QtWidgets.QStyle.PixelMetric.PM_IconViewIconSize
|
||||
else:
|
||||
metric = QtWidgets.QStyle.PixelMetric.PM_TabBarIconSize
|
||||
|
||||
extent = QtWidgets.QApplication.style().pixelMetric(metric)
|
||||
|
||||
self.previewArea.setSize(QtCore.QSize(extent, extent))
|
||||
self.otherSpinBox.setEnabled(self.otherRadioButton.isChecked())
|
||||
|
||||
def changeIcon(self):
|
||||
from superqt import fonticon
|
||||
|
||||
icon = None
|
||||
for row in range(self.glyphTable.rowCount()):
|
||||
item0 = self.glyphTable.item(row, 0)
|
||||
item1 = self.glyphTable.item(row, 1)
|
||||
item2 = self.glyphTable.item(row, 2)
|
||||
item3 = self.glyphTable.item(row, 3)
|
||||
|
||||
if item0.checkState() != Qt.CheckState.Checked:
|
||||
continue
|
||||
key = item0.text()
|
||||
if not key:
|
||||
continue
|
||||
|
||||
if item2.text() == "Normal":
|
||||
mode = QtGui.QIcon.Mode.Normal
|
||||
elif item2.text() == "Active":
|
||||
mode = QtGui.QIcon.Mode.Active
|
||||
elif item2.text() == "Disabled":
|
||||
mode = QtGui.QIcon.Mode.Disabled
|
||||
else:
|
||||
mode = QtGui.QIcon.Mode.Selected
|
||||
|
||||
color = item1.text() or None
|
||||
state = (
|
||||
QtGui.QIcon.State.On if item3.text() == "On" else QtGui.QIcon.State.Off
|
||||
)
|
||||
try:
|
||||
if icon is None:
|
||||
icon = fonticon.icon(key, color=color)
|
||||
else:
|
||||
icon.addState(state, mode, glyph_key=key, color=color)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
continue
|
||||
if icon:
|
||||
self.previewArea.setIcon(icon)
|
||||
|
||||
def createPreviewGroupBox(self):
|
||||
self.previewGroupBox = QtWidgets.QGroupBox("Preview")
|
||||
|
||||
self.previewArea = IconPreviewArea()
|
||||
|
||||
layout = QtWidgets.QVBoxLayout()
|
||||
layout.addWidget(self.previewArea)
|
||||
self.previewGroupBox.setLayout(layout)
|
||||
|
||||
def createGlyphBox(self):
|
||||
self.glyphGroupBox = QtWidgets.QGroupBox("Glpyhs")
|
||||
self.glyphGroupBox.setMinimumSize(480, 200)
|
||||
self.glyphTable = QtWidgets.QTableWidget()
|
||||
self.glyphTable.setSelectionMode(QtWidgets.QAbstractItemView.NoSelection)
|
||||
self.glyphTable.setItemDelegate(GlyphDelegate(self))
|
||||
|
||||
self.glyphTable.horizontalHeader().setDefaultSectionSize(100)
|
||||
self.glyphTable.setColumnCount(4)
|
||||
self.glyphTable.setHorizontalHeaderLabels(("Glyph", "Color", "Mode", "State"))
|
||||
self.glyphTable.horizontalHeader().setSectionResizeMode(
|
||||
0, QtWidgets.QHeaderView.Stretch
|
||||
)
|
||||
self.glyphTable.horizontalHeader().setSectionResizeMode(
|
||||
1, QtWidgets.QHeaderView.Fixed
|
||||
)
|
||||
self.glyphTable.horizontalHeader().setSectionResizeMode(
|
||||
2, QtWidgets.QHeaderView.Fixed
|
||||
)
|
||||
self.glyphTable.horizontalHeader().setSectionResizeMode(
|
||||
3, QtWidgets.QHeaderView.Fixed
|
||||
)
|
||||
self.glyphTable.verticalHeader().hide()
|
||||
|
||||
self.glyphTable.itemChanged.connect(self.changeIcon)
|
||||
|
||||
layout = QtWidgets.QVBoxLayout()
|
||||
layout.addWidget(self.glyphTable)
|
||||
self.glyphGroupBox.setLayout(layout)
|
||||
self.changeIcon()
|
||||
|
||||
p0 = list(P)[-1]
|
||||
key = f"{p0}.{list(P[p0])[1]}"
|
||||
for _ in range(4):
|
||||
row = self.glyphTable.rowCount()
|
||||
self.glyphTable.setRowCount(row + 1)
|
||||
|
||||
item0 = QtWidgets.QTableWidgetItem()
|
||||
item1 = QtWidgets.QTableWidgetItem()
|
||||
|
||||
if _ == 0:
|
||||
item0.setText(key)
|
||||
# item0.setFlags(item0.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||||
|
||||
item2 = QtWidgets.QTableWidgetItem("Normal")
|
||||
item3 = QtWidgets.QTableWidgetItem("Off")
|
||||
|
||||
self.glyphTable.setItem(row, 0, item0)
|
||||
self.glyphTable.setItem(row, 1, item1)
|
||||
self.glyphTable.setItem(row, 2, item2)
|
||||
self.glyphTable.setItem(row, 3, item3)
|
||||
self.glyphTable.openPersistentEditor(item2)
|
||||
self.glyphTable.openPersistentEditor(item3)
|
||||
|
||||
item0.setCheckState(Qt.CheckState.Checked)
|
||||
|
||||
def createIconSizeGroupBox(self):
|
||||
self.iconSizeGroupBox = QtWidgets.QGroupBox("Icon Size")
|
||||
|
||||
self.smallRadioButton = QtWidgets.QRadioButton()
|
||||
self.largeRadioButton = QtWidgets.QRadioButton()
|
||||
self.toolBarRadioButton = QtWidgets.QRadioButton()
|
||||
self.listViewRadioButton = QtWidgets.QRadioButton()
|
||||
self.iconViewRadioButton = QtWidgets.QRadioButton()
|
||||
self.tabBarRadioButton = QtWidgets.QRadioButton()
|
||||
self.otherRadioButton = QtWidgets.QRadioButton("Other:")
|
||||
|
||||
self.otherSpinBox = QtWidgets.QSpinBox()
|
||||
self.otherSpinBox.setRange(8, 128)
|
||||
self.otherSpinBox.setValue(64)
|
||||
|
||||
self.smallRadioButton.toggled.connect(self.changeSize)
|
||||
self.largeRadioButton.toggled.connect(self.changeSize)
|
||||
self.toolBarRadioButton.toggled.connect(self.changeSize)
|
||||
self.listViewRadioButton.toggled.connect(self.changeSize)
|
||||
self.iconViewRadioButton.toggled.connect(self.changeSize)
|
||||
self.tabBarRadioButton.toggled.connect(self.changeSize)
|
||||
self.otherRadioButton.toggled.connect(self.changeSize)
|
||||
self.otherSpinBox.valueChanged.connect(self.changeSize)
|
||||
|
||||
otherSizeLayout = QtWidgets.QHBoxLayout()
|
||||
otherSizeLayout.addWidget(self.otherRadioButton)
|
||||
otherSizeLayout.addWidget(self.otherSpinBox)
|
||||
otherSizeLayout.addStretch()
|
||||
|
||||
layout = QtWidgets.QGridLayout()
|
||||
layout.addWidget(self.smallRadioButton, 0, 0)
|
||||
layout.addWidget(self.largeRadioButton, 1, 0)
|
||||
layout.addWidget(self.toolBarRadioButton, 2, 0)
|
||||
layout.addWidget(self.listViewRadioButton, 0, 1)
|
||||
layout.addWidget(self.iconViewRadioButton, 1, 1)
|
||||
layout.addWidget(self.tabBarRadioButton, 2, 1)
|
||||
layout.addLayout(otherSizeLayout, 3, 0, 1, 2)
|
||||
layout.setRowStretch(4, 1)
|
||||
self.iconSizeGroupBox.setLayout(layout)
|
||||
self.changeStyle()
|
||||
|
||||
def changeStyle(self, style=None):
|
||||
style = style or QtWidgets.QApplication.style().objectName()
|
||||
style = QtWidgets.QStyleFactory.create(style)
|
||||
if not style:
|
||||
return
|
||||
|
||||
QtWidgets.QApplication.setStyle(style)
|
||||
|
||||
self.setButtonText(
|
||||
self.smallRadioButton,
|
||||
"Small (%d x %d)",
|
||||
style,
|
||||
QtWidgets.QStyle.PixelMetric.PM_SmallIconSize,
|
||||
)
|
||||
self.setButtonText(
|
||||
self.largeRadioButton,
|
||||
"Large (%d x %d)",
|
||||
style,
|
||||
QtWidgets.QStyle.PixelMetric.PM_LargeIconSize,
|
||||
)
|
||||
self.setButtonText(
|
||||
self.toolBarRadioButton,
|
||||
"Toolbars (%d x %d)",
|
||||
style,
|
||||
QtWidgets.QStyle.PixelMetric.PM_ToolBarIconSize,
|
||||
)
|
||||
self.setButtonText(
|
||||
self.listViewRadioButton,
|
||||
"List views (%d x %d)",
|
||||
style,
|
||||
QtWidgets.QStyle.PixelMetric.PM_ListViewIconSize,
|
||||
)
|
||||
self.setButtonText(
|
||||
self.iconViewRadioButton,
|
||||
"Icon views (%d x %d)",
|
||||
style,
|
||||
QtWidgets.QStyle.PixelMetric.PM_IconViewIconSize,
|
||||
)
|
||||
self.setButtonText(
|
||||
self.tabBarRadioButton,
|
||||
"Tab bars (%d x %d)",
|
||||
style,
|
||||
QtWidgets.QStyle.PixelMetric.PM_TabBarIconSize,
|
||||
)
|
||||
|
||||
self.changeSize()
|
||||
|
||||
@staticmethod
|
||||
def setButtonText(button, label, style, metric):
|
||||
metric_value = style.pixelMetric(metric)
|
||||
button.setText(label % (metric_value, metric_value))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
import sys
|
||||
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
mainWin = MainWindow()
|
||||
mainWin.show()
|
||||
sys.exit(app.exec_())
|
@@ -9,7 +9,7 @@ from superqt.qtcompat.QtWidgets import QApplication, QHBoxLayout, QVBoxLayout, Q
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
ORIENTATION = Qt.Horizontal
|
||||
ORIENTATION = Qt.Orientation.Horizontal
|
||||
|
||||
w = QWidget()
|
||||
qls = QLabeledSlider(ORIENTATION)
|
||||
@@ -35,7 +35,9 @@ qldrs.setSingleStep(0.01)
|
||||
qldrs.setValue((0.2, 0.7))
|
||||
|
||||
|
||||
w.setLayout(QVBoxLayout() if ORIENTATION == Qt.Horizontal else QHBoxLayout())
|
||||
w.setLayout(
|
||||
QVBoxLayout() if ORIENTATION == Qt.Orientation.Horizontal else QHBoxLayout()
|
||||
)
|
||||
w.layout().addWidget(qls)
|
||||
w.layout().addWidget(qlds)
|
||||
w.layout().addWidget(qlrs)
|
||||
|
@@ -1,3 +1,10 @@
|
||||
# pyproject.toml
|
||||
[build-system]
|
||||
requires = ["setuptools>=45", "wheel", "setuptools_scm[toml]>=6.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools_scm]
|
||||
write_to = "src/superqt/_version.py"
|
||||
|
||||
[tool.check-manifest]
|
||||
ignore = ["src/superqt/_version.py"]
|
||||
|
38
setup.cfg
38
setup.cfg
@@ -20,6 +20,7 @@ classifiers =
|
||||
Programming Language :: Python :: 3.7
|
||||
Programming Language :: Python :: 3.8
|
||||
Programming Language :: Python :: 3.9
|
||||
Programming Language :: Python :: 3.10
|
||||
Programming Language :: Python :: Implementation :: CPython
|
||||
Topic :: Desktop Environment
|
||||
Topic :: Software Development
|
||||
@@ -33,11 +34,19 @@ project_urls =
|
||||
|
||||
[options]
|
||||
packages = find:
|
||||
install_requires =
|
||||
typing-extensions>=3.10.0.0
|
||||
python_requires = >=3.7
|
||||
include_package_data = True
|
||||
package_dir =
|
||||
=src
|
||||
setup_requires =
|
||||
setuptools_scm
|
||||
setuptools-scm
|
||||
zip_safe = False
|
||||
|
||||
[options.packages.find]
|
||||
where = src
|
||||
|
||||
[options.extras_require]
|
||||
dev =
|
||||
ipython
|
||||
@@ -51,6 +60,10 @@ dev =
|
||||
pytest-qt
|
||||
tox
|
||||
tox-conda
|
||||
font_fa5 =
|
||||
fonticon-fontawesome5
|
||||
font_mi5 =
|
||||
fonticon-materialdesignicons5
|
||||
pyqt5 =
|
||||
pyqt5
|
||||
pyqt6 =
|
||||
@@ -66,10 +79,18 @@ testing =
|
||||
tox
|
||||
tox-conda
|
||||
|
||||
[options.package_data]
|
||||
superqt = py.typed
|
||||
|
||||
[flake8]
|
||||
exclude = _version.py,.eggs,examples
|
||||
docstring-convention = numpy
|
||||
ignore = E203,W503,E501,C901,F403,F405,D100
|
||||
per-file-ignores =
|
||||
src/superqt/qtcompat/QtCore.py:F401
|
||||
src/superqt/qtcompat/QtGui.py:F401
|
||||
src/superqt/qtcompat/QtWidgets.py:F401
|
||||
src/superqt/qtcompat/__init__.py:F401,F811
|
||||
|
||||
[pydocstyle]
|
||||
convention = numpy
|
||||
@@ -80,4 +101,17 @@ ignore = D100
|
||||
profile = black
|
||||
|
||||
[tool:pytest]
|
||||
addopts = -W error
|
||||
filterwarnings =
|
||||
error
|
||||
ignore:QPixmapCache.find:DeprecationWarning:
|
||||
ignore:SelectableGroups dict interface:DeprecationWarning
|
||||
ignore:The distutils package is deprecated:DeprecationWarning
|
||||
|
||||
[mypy]
|
||||
strict = True
|
||||
files = src/superqt
|
||||
|
||||
[mypy-superqt.qtcompat.*]
|
||||
ignore_missing_imports = True
|
||||
warn_unused_ignores = False
|
||||
allow_redefinition = True
|
||||
|
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"],
|
||||
)
|
@@ -6,6 +6,7 @@ except ImportError:
|
||||
|
||||
|
||||
from ._eliding_label import QElidingLabel
|
||||
from .collapsible import QCollapsible
|
||||
from .combobox import QEnumComboBox
|
||||
from .sliders import (
|
||||
QDoubleRangeSlider,
|
||||
@@ -17,8 +18,11 @@ from .sliders import (
|
||||
QRangeSlider,
|
||||
)
|
||||
from .spinbox import QLargeIntSpinBox
|
||||
from .utils import QMessageHandler, ensure_main_thread, ensure_object_thread
|
||||
|
||||
__all__ = [
|
||||
"ensure_main_thread",
|
||||
"ensure_object_thread",
|
||||
"QDoubleRangeSlider",
|
||||
"QDoubleSlider",
|
||||
"QElidingLabel",
|
||||
@@ -27,6 +31,8 @@ __all__ = [
|
||||
"QLabeledRangeSlider",
|
||||
"QLabeledSlider",
|
||||
"QLargeIntSpinBox",
|
||||
"QMessageHandler",
|
||||
"QRangeSlider",
|
||||
"QEnumComboBox",
|
||||
"QCollapsible",
|
||||
]
|
@@ -83,7 +83,7 @@ class QElidingLabel(QLabel):
|
||||
if not self.wordWrap():
|
||||
return super().sizeHint()
|
||||
fm = QFontMetrics(self.font())
|
||||
flags = self.alignment() | Qt.TextFlag.TextWordWrap
|
||||
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())
|
||||
|
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"]
|
128
src/superqt/collapsible/_collapsible.py
Normal file
128
src/superqt/collapsible/_collapsible.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""A collapsible widget to hide and unhide child widgets"""
|
||||
from typing import Optional
|
||||
|
||||
from ..qtcompat.QtCore import (
|
||||
QAbstractAnimation,
|
||||
QEasingCurve,
|
||||
QMargins,
|
||||
QPropertyAnimation,
|
||||
Qt,
|
||||
)
|
||||
from ..qtcompat.QtWidgets import QFrame, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
|
||||
class QCollapsible(QFrame):
|
||||
"""A collapsible widget to hide and unhide child widgets.
|
||||
|
||||
Based on https://stackoverflow.com/a/68141638
|
||||
"""
|
||||
|
||||
_EXPANDED = "▼ "
|
||||
_COLLAPSED = "▲ "
|
||||
|
||||
def __init__(self, title: str = "", parent: Optional[QWidget] = None):
|
||||
super().__init__(parent)
|
||||
self._locked = False
|
||||
|
||||
self._toggle_btn = QPushButton(self._COLLAPSED + title)
|
||||
self._toggle_btn.setCheckable(True)
|
||||
self._toggle_btn.setStyleSheet("text-align: left; background: transparent;")
|
||||
self._toggle_btn.toggled.connect(self._toggle)
|
||||
|
||||
# frame layout
|
||||
self.setLayout(QVBoxLayout())
|
||||
self.layout().setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
self.layout().addWidget(self._toggle_btn)
|
||||
|
||||
# Create animators
|
||||
self._animation = QPropertyAnimation(self)
|
||||
self._animation.setPropertyName(b"maximumHeight")
|
||||
self._animation.setStartValue(0)
|
||||
self.setDuration(300)
|
||||
self.setEasingCurve(QEasingCurve.Type.InOutCubic)
|
||||
|
||||
# default content widget
|
||||
_content = QWidget()
|
||||
_content.setLayout(QVBoxLayout())
|
||||
_content.setMaximumHeight(0)
|
||||
_content.layout().setContentsMargins(QMargins(5, 0, 0, 0))
|
||||
self.setContent(_content)
|
||||
|
||||
def setText(self, text: str):
|
||||
"""Set the text of the toggle button."""
|
||||
current = self._toggle_btn.text()[: len(self._EXPANDED)]
|
||||
self._toggle_btn.setText(current + text)
|
||||
|
||||
def text(self) -> str:
|
||||
"""Return the text of the toggle button."""
|
||||
return self._toggle_btn.text()[len(self._EXPANDED) :]
|
||||
|
||||
def setContent(self, content: QWidget):
|
||||
"""Replace central widget (the widget that gets expanded/collapsed)."""
|
||||
self._content = content
|
||||
self.layout().addWidget(self._content)
|
||||
self._animation.setTargetObject(content)
|
||||
|
||||
def content(self) -> QWidget:
|
||||
"""Return the current content widget."""
|
||||
return self._content
|
||||
|
||||
def setDuration(self, msecs: int):
|
||||
"""Set duration of the collapse/expand animation."""
|
||||
self._animation.setDuration(msecs)
|
||||
|
||||
def setEasingCurve(self, easing: QEasingCurve):
|
||||
"""Set the easing curve for the collapse/expand animation"""
|
||||
self._animation.setEasingCurve(easing)
|
||||
|
||||
def addWidget(self, widget: QWidget):
|
||||
"""Add a widget to the central content widget's layout."""
|
||||
self._content.layout().addWidget(widget)
|
||||
|
||||
def removeWidget(self, widget: QWidget):
|
||||
"""Remove widget from the central content widget's layout."""
|
||||
self._content.layout().removeWidget(widget)
|
||||
|
||||
def expand(self, animate: bool = True):
|
||||
"""Expand (show) the collapsible section"""
|
||||
self._expand_collapse(QAbstractAnimation.Direction.Forward, animate)
|
||||
|
||||
def collapse(self, animate: bool = True):
|
||||
"""Collapse (hide) the collapsible section"""
|
||||
self._expand_collapse(QAbstractAnimation.Direction.Backward, animate)
|
||||
|
||||
def isExpanded(self) -> bool:
|
||||
"""Return whether the collapsible section is visible"""
|
||||
return self._toggle_btn.isChecked()
|
||||
|
||||
def setLocked(self, locked: bool = True):
|
||||
"""Set whether collapse/expand is disabled"""
|
||||
self._locked = locked
|
||||
self._toggle_btn.setCheckable(not locked)
|
||||
|
||||
def locked(self) -> bool:
|
||||
"""Return True if collapse/expand is disabled"""
|
||||
return self._locked
|
||||
|
||||
def _expand_collapse(
|
||||
self, direction: QAbstractAnimation.Direction, animate: bool = True
|
||||
):
|
||||
if self._locked:
|
||||
return
|
||||
|
||||
forward = direction == QAbstractAnimation.Direction.Forward
|
||||
text = self._EXPANDED if forward else self._COLLAPSED
|
||||
|
||||
self._toggle_btn.setChecked(forward)
|
||||
self._toggle_btn.setText(text + self._toggle_btn.text()[len(self._EXPANDED) :])
|
||||
|
||||
_content_height = self._content.sizeHint().height() + 10
|
||||
if animate:
|
||||
self._animation.setDirection(direction)
|
||||
self._animation.setEndValue(_content_height)
|
||||
self._animation.start()
|
||||
else:
|
||||
self._content.setMaximumHeight(_content_height if forward else 0)
|
||||
|
||||
def _toggle(self):
|
||||
self.expand() if self.isExpanded() else self.collapse()
|
218
src/superqt/fonticon/__init__.py
Normal file
218
src/superqt/fonticon/__init__.py
Normal file
@@ -0,0 +1,218 @@
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = [
|
||||
"addFont",
|
||||
"ENTRY_POINT",
|
||||
"font",
|
||||
"icon",
|
||||
"IconFont",
|
||||
"IconFontMeta",
|
||||
"IconOpts",
|
||||
"Animation",
|
||||
"pulse",
|
||||
"spin",
|
||||
]
|
||||
|
||||
from typing import TYPE_CHECKING, Dict, Optional, Tuple, Union
|
||||
|
||||
from ._animations import Animation, pulse, spin
|
||||
from ._iconfont import IconFont, IconFontMeta
|
||||
from ._plugins import FontIconManager as _FIM
|
||||
from ._qfont_icon import DEFAULT_SCALING_FACTOR, IconOptionDict, IconOpts
|
||||
from ._qfont_icon import QFontIconStore as _QFIS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superqt.qtcompat.QtGui import QFont, QTransform
|
||||
from superqt.qtcompat.QtWidgets import QWidget
|
||||
|
||||
from ._qfont_icon import QFontIcon, ValidColor
|
||||
|
||||
ENTRY_POINT = _FIM.ENTRY_POINT
|
||||
|
||||
|
||||
# FIXME: currently, an Animation requires a *pre-bound* QObject. which makes it very
|
||||
# awkward to use animations when declaratively listing icons. It would be much better
|
||||
# to have a way to find the widget later, to execute the animation... short of that, I
|
||||
# think we should take animation off of `icon` here, and suggest that it be an
|
||||
# an additional convenience method after the icon has been bound to a QObject.
|
||||
def icon(
|
||||
glyph_key: str,
|
||||
scale_factor: float = DEFAULT_SCALING_FACTOR,
|
||||
color: ValidColor = None,
|
||||
opacity: float = 1,
|
||||
animation: Optional[Animation] = None,
|
||||
transform: Optional[QTransform] = None,
|
||||
states: Dict[str, Union[IconOptionDict, IconOpts]] = {},
|
||||
) -> QFontIcon:
|
||||
"""Create a QIcon for `glyph_key`, with a number of optional settings
|
||||
|
||||
The `glyph_key` (e.g. 'fa5s.smile') represents a Font-family & style, and a glpyh.
|
||||
In most cases, the key should be provided by a plugin in the environment, like:
|
||||
|
||||
https://github.com/tlambert03/fonticon-fontawesome5 ('fa5s' & 'fa5r' prefixes)
|
||||
https://github.com/tlambert03/fonticon-materialdesignicons6 ('mdi6' prefix)
|
||||
|
||||
...but fonts can also be added manually using :func:`addFont`.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
glyph_key : str
|
||||
String encapsulating a font-family, style, and glyph. e.g. 'fa5s.smile'.
|
||||
scale_factor : float, optional
|
||||
Scale factor (fraction of widget height), When widget icon is painted on widget,
|
||||
it will use `font.setPixelSize(round(wdg.height() * scale_factor))`.
|
||||
by default 0.875.
|
||||
color : ValidColor, optional
|
||||
Color for the font, by default None. (e.g. The default `QColor`)
|
||||
Valid color types include `QColor`, `int`, `str`, `Qt.GlobalColor`, `tuple` (of
|
||||
integer: RGB[A]) (anything that can be passed to `QColor`).
|
||||
opacity : float, optional
|
||||
Opacity of icon, by default 1
|
||||
animation : Animation, optional
|
||||
Animation for the icon. A subclass of superqt.fonticon.Animation, that provides
|
||||
a concrete `animate` method. (see "spin" and "pulse" for examples).
|
||||
by default None.
|
||||
transform : QTransform, optional
|
||||
A `QTransform` to apply when painting the icon, by default None
|
||||
states : dict, optional
|
||||
Provide additional styling for the icon in different states. `states` must be
|
||||
a mapping of string to dict, where:
|
||||
|
||||
- the key represents a `QIcon.State` ("on", "off"), a `QIcon.Mode` ("normal",
|
||||
"active", "selected", "disabled"), or any combination of a state & mode
|
||||
separated by an underscore (e.g. "off_active", "selected_on", etc...).
|
||||
- the value is a dict with all of the same key/value meanings listed above as
|
||||
parameters to this function (e.g. `glyph_key`, `color`,`scale_factor`,
|
||||
`animation`, etc...)
|
||||
|
||||
Missing keys in the state dicts will be taken from the default options, provided
|
||||
by the paramters above.
|
||||
|
||||
Returns
|
||||
-------
|
||||
QFontIcon
|
||||
A subclass of QIcon. Can be used wherever QIcons are used, such as
|
||||
`widget.setIcon()`
|
||||
|
||||
Examples
|
||||
--------
|
||||
# simple example (assumes the font-awesome5 plugin is installed)
|
||||
>>> btn = QPushButton()
|
||||
>>> btn.setIcon(icon('fa5s.smile'))
|
||||
|
||||
# can also directly import from fonticon_fa5
|
||||
>>> from fonticon_fa5 import FA5S
|
||||
>>> btn.setIcon(icon(FA5S.smile))
|
||||
|
||||
# with animation
|
||||
>>> btn2 = QPushButton()
|
||||
>>> btn2.setIcon(icon(FA5S.spinner, animation=pulse(btn2)))
|
||||
|
||||
# complicated example
|
||||
>>> btn = QPushButton()
|
||||
>>> btn.setIcon(
|
||||
... icon(
|
||||
... FA5S.ambulance,
|
||||
... color="blue",
|
||||
... states={
|
||||
... "active": {
|
||||
... "glyph": FA5S.bath,
|
||||
... "color": "red",
|
||||
... "scale_factor": 0.5,
|
||||
... "animation": pulse(btn),
|
||||
... },
|
||||
... "disabled": {
|
||||
... "color": "green",
|
||||
... "scale_factor": 0.8,
|
||||
... "animation": spin(btn)
|
||||
... },
|
||||
... },
|
||||
... )
|
||||
... )
|
||||
>>> btn.setIconSize(QSize(256, 256))
|
||||
>>> btn.show()
|
||||
|
||||
"""
|
||||
return _QFIS.instance().icon(
|
||||
glyph_key,
|
||||
scale_factor=scale_factor,
|
||||
color=color,
|
||||
opacity=opacity,
|
||||
animation=animation,
|
||||
transform=transform,
|
||||
states=states,
|
||||
)
|
||||
|
||||
|
||||
def setTextIcon(widget: QWidget, glyph_key: str, size: Optional[float] = None) -> None:
|
||||
"""Set text on a widget to a specific font & glyph.
|
||||
|
||||
This is an alternative to setting a QIcon with a pixmap. It may be easier to
|
||||
combine with dynamic stylesheets.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
wdg : QWidget
|
||||
A widget supporting a `setText` method.
|
||||
glyph_key : str
|
||||
String encapsulating a font-family, style, and glyph. e.g. 'fa5s.smile'.
|
||||
size : int, optional
|
||||
Size for QFont. passed to `setPixelSize`, by default None
|
||||
"""
|
||||
return _QFIS.instance().setTextIcon(widget, glyph_key, size)
|
||||
|
||||
|
||||
def font(font_prefix: str, size: Optional[int] = None) -> QFont:
|
||||
"""Create QFont for `font_prefix`
|
||||
|
||||
Parameters
|
||||
----------
|
||||
font_prefix : str
|
||||
Font_prefix, such as 'fa5s' or 'mdi6', representing a font-family and style.
|
||||
size : int, optional
|
||||
Size for QFont. passed to `setPixelSize`, by default None
|
||||
|
||||
Returns
|
||||
-------
|
||||
QFont
|
||||
QFont instance that can be used to add fonticons to widgets.
|
||||
"""
|
||||
return _QFIS.instance().font(font_prefix, size)
|
||||
|
||||
|
||||
def addFont(
|
||||
filepath: str, prefix: str, charmap: Optional[Dict[str, str]] = None
|
||||
) -> Optional[Tuple[str, str]]:
|
||||
"""Add OTF/TTF file at `filepath` to the registry under `prefix`.
|
||||
|
||||
If you'd like to later use a fontkey in the form of `prefix.some-name`, then
|
||||
`charmap` must be provided and provide a mapping for all of the glyph names
|
||||
to their unicode numbers. If a charmap is not provided, glyphs must be directly
|
||||
accessed with their unicode as something like `key.\uffff`.
|
||||
|
||||
NOTE: in most cases, users will not need this.
|
||||
Instead, they should install a font plugin, like:
|
||||
https://github.com/tlambert03/fonticon-fontawesome5
|
||||
https://github.com/tlambert03/fonticon-materialdesignicons6
|
||||
|
||||
Parameters
|
||||
----------
|
||||
filepath : str
|
||||
Path to an OTF or TTF file containing the fonts
|
||||
prefix : str
|
||||
A prefix that will represent this font file when used for lookup. For example,
|
||||
'fa5s' for 'Font-Awesome 5 Solid'.
|
||||
charmap : Dict[str, str], optional
|
||||
optional mapping for all of the glyph names to their unicode numbers.
|
||||
See note above.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Tuple[str, str], optional
|
||||
font-family and font-style for the file just registered, or `None` if
|
||||
something goes wrong.
|
||||
"""
|
||||
return _QFIS.instance().addFont(filepath, prefix, charmap)
|
||||
|
||||
|
||||
del DEFAULT_SCALING_FACTOR
|
40
src/superqt/fonticon/_animations.py
Normal file
40
src/superqt/fonticon/_animations.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from superqt.qtcompat.QtCore import QRectF, QTimer
|
||||
from superqt.qtcompat.QtGui import QPainter
|
||||
from superqt.qtcompat.QtWidgets import QWidget
|
||||
|
||||
|
||||
class Animation(ABC):
|
||||
def __init__(self, parent_widget: QWidget, interval: int = 10, step: int = 1):
|
||||
self.parent_widget = parent_widget
|
||||
self.timer = QTimer()
|
||||
self.timer.timeout.connect(self._update) # type: ignore
|
||||
self.timer.setInterval(interval)
|
||||
self._angle = 0
|
||||
self._step = step
|
||||
|
||||
def _update(self):
|
||||
if self.timer.isActive():
|
||||
self._angle += self._step
|
||||
self.parent_widget.update()
|
||||
|
||||
@abstractmethod
|
||||
def animate(self, painter: QPainter):
|
||||
"""Setup and start the timer for the animation."""
|
||||
|
||||
|
||||
class spin(Animation):
|
||||
def animate(self, painter: QPainter):
|
||||
if not self.timer.isActive():
|
||||
self.timer.start()
|
||||
|
||||
mid = QRectF(painter.viewport()).center()
|
||||
painter.translate(mid)
|
||||
painter.rotate(self._angle % 360)
|
||||
painter.translate(-mid)
|
||||
|
||||
|
||||
class pulse(spin):
|
||||
def __init__(self, parent_widget: QWidget = None):
|
||||
super().__init__(parent_widget, interval=200, step=45)
|
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):
|
||||
assert isinstance(
|
||||
getattr(namespace, FONTFILE_ATTR), str
|
||||
), "Not a valid font type"
|
||||
return namespace # type: ignore
|
||||
elif hasattr(namespace, "__dict__"):
|
||||
ns = dict(namespace.__dict__)
|
||||
else:
|
||||
raise ValueError(
|
||||
"namespace must be a mapping or an object with __dict__ attribute."
|
||||
)
|
||||
if not str.isidentifier(name):
|
||||
raise ValueError(f"name {name!r} is not a valid identifier.")
|
||||
return type(name, (IconFont,), ns)
|
103
src/superqt/fonticon/_plugins.py
Normal file
103
src/superqt/fonticon/_plugins.py
Normal file
@@ -0,0 +1,103 @@
|
||||
from typing import Dict, List, Set, Tuple
|
||||
|
||||
from ._iconfont import IconFontMeta, namespace2font
|
||||
|
||||
try:
|
||||
from importlib.metadata import EntryPoint, entry_points
|
||||
except ImportError:
|
||||
from importlib_metadata import EntryPoint, entry_points # type: ignore
|
||||
|
||||
|
||||
class FontIconManager:
|
||||
|
||||
ENTRY_POINT = "superqt.fonticon"
|
||||
_PLUGINS: Dict[str, EntryPoint] = {}
|
||||
_LOADED: Dict[str, IconFontMeta] = {}
|
||||
_BLOCKED: Set[EntryPoint] = set()
|
||||
|
||||
def _discover_fonts(self) -> None:
|
||||
self._PLUGINS.clear()
|
||||
for ep in entry_points().get(self.ENTRY_POINT, {}):
|
||||
if ep not in self._BLOCKED:
|
||||
self._PLUGINS[ep.name] = ep
|
||||
|
||||
def _get_font_class(self, key: str) -> IconFontMeta:
|
||||
"""Get IconFont given a key.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
key : str
|
||||
font key to load.
|
||||
|
||||
Returns
|
||||
-------
|
||||
IconFontMeta
|
||||
Instance of IconFontMeta
|
||||
|
||||
Raises
|
||||
------
|
||||
KeyError
|
||||
If no plugin provides this key
|
||||
ImportError
|
||||
If a plugin provides the key, but the entry point doesn't load
|
||||
TypeError
|
||||
If the entry point loads, but is not an IconFontMeta
|
||||
"""
|
||||
if key not in self._LOADED:
|
||||
# get the entrypoint
|
||||
if key not in self._PLUGINS:
|
||||
self._discover_fonts()
|
||||
ep = self._PLUGINS.get(key)
|
||||
if ep is None:
|
||||
raise KeyError(f"No plugin provides the key {key!r}")
|
||||
|
||||
# load the entry point
|
||||
try:
|
||||
font = ep.load()
|
||||
except Exception as e:
|
||||
self._PLUGINS.pop(key)
|
||||
self._BLOCKED.add(ep)
|
||||
raise ImportError(f"Failed to load {ep.value}. Plugin blocked") from e
|
||||
|
||||
# make sure it's a proper IconFont
|
||||
try:
|
||||
self._LOADED[key] = namespace2font(font, ep.name.upper())
|
||||
except Exception as e:
|
||||
self._PLUGINS.pop(key)
|
||||
self._BLOCKED.add(ep)
|
||||
raise TypeError(
|
||||
f"Failed to create fonticon from {ep.value}: {e}"
|
||||
) from e
|
||||
return self._LOADED[key]
|
||||
|
||||
def dict(self) -> dict:
|
||||
return {
|
||||
key: sorted(filter(lambda x: not x.startswith("_"), cls.__dict__))
|
||||
for key, cls in self._LOADED.items()
|
||||
}
|
||||
|
||||
|
||||
_manager = FontIconManager()
|
||||
get_font_class = _manager._get_font_class
|
||||
|
||||
|
||||
def discover() -> Tuple[str]:
|
||||
_manager._discover_fonts()
|
||||
|
||||
|
||||
def available() -> Tuple[str]:
|
||||
return tuple(_manager._PLUGINS)
|
||||
|
||||
|
||||
def loaded(load_all=False) -> Dict[str, List[str]]:
|
||||
if load_all:
|
||||
discover()
|
||||
for x in available():
|
||||
try:
|
||||
_manager._get_font_class(x)
|
||||
except Exception:
|
||||
continue
|
||||
return {
|
||||
key: sorted(filter(lambda x: not x.startswith("_"), cls.__dict__))
|
||||
for key, cls in _manager._LOADED.items()
|
||||
}
|
555
src/superqt/fonticon/_qfont_icon.py
Normal file
555
src/superqt/fonticon/_qfont_icon.py
Normal file
@@ -0,0 +1,555 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from collections import abc
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import DefaultDict, Dict, Optional, Sequence, Tuple, Type, Union, cast
|
||||
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from ..qtcompat import QT_VERSION
|
||||
from ..qtcompat.QtCore import QObject, QPoint, QRect, QSize, Qt
|
||||
from ..qtcompat.QtGui import (
|
||||
QColor,
|
||||
QFont,
|
||||
QFontDatabase,
|
||||
QGuiApplication,
|
||||
QIcon,
|
||||
QIconEngine,
|
||||
QPainter,
|
||||
QPixmap,
|
||||
QPixmapCache,
|
||||
QTransform,
|
||||
)
|
||||
from ..qtcompat.QtWidgets import QApplication, QStyleOption, QWidget
|
||||
from ..utils import QMessageHandler
|
||||
from ._animations import Animation
|
||||
|
||||
|
||||
class Unset:
|
||||
def __repr__(self) -> str:
|
||||
return "UNSET"
|
||||
|
||||
|
||||
_Unset = Unset()
|
||||
|
||||
# A 16 pixel-high icon yields a font size of 14, which is pixel perfect
|
||||
# for font-awesome. 16 * 0.875 = 14
|
||||
# The reason why the glyph size is smaller than the icon size is to
|
||||
# account for font bearing.
|
||||
DEFAULT_SCALING_FACTOR = 0.875
|
||||
DEFAULT_OPACITY = 1
|
||||
ValidColor = Union[
|
||||
QColor,
|
||||
int,
|
||||
str,
|
||||
Qt.GlobalColor,
|
||||
Tuple[int, int, int, int],
|
||||
Tuple[int, int, int],
|
||||
None,
|
||||
]
|
||||
|
||||
StateOrMode = Union[QIcon.State, QIcon.Mode]
|
||||
StateModeKey = Union[StateOrMode, str, Sequence[StateOrMode]]
|
||||
_SM_MAP: Dict[str, StateOrMode] = {
|
||||
"on": QIcon.State.On,
|
||||
"off": QIcon.State.Off,
|
||||
"normal": QIcon.Mode.Normal,
|
||||
"active": QIcon.Mode.Active,
|
||||
"selected": QIcon.Mode.Selected,
|
||||
"disabled": QIcon.Mode.Disabled,
|
||||
}
|
||||
|
||||
|
||||
def _norm_state_mode(key: StateModeKey) -> Tuple[QIcon.State, QIcon.Mode]:
|
||||
"""return state/mode tuple given a variety of valid inputs.
|
||||
|
||||
Input can be either a string, or a sequence of state or mode enums.
|
||||
Strings can be any combination of on, off, normal, active, selected, disabled,
|
||||
sep by underscore.
|
||||
"""
|
||||
_sm: Sequence[StateOrMode]
|
||||
if isinstance(key, str):
|
||||
try:
|
||||
_sm = [_SM_MAP[k.lower()] for k in key.split("_")]
|
||||
except KeyError:
|
||||
raise ValueError(
|
||||
f"{key!r} is not a valid state key, must be a combination of {{on, "
|
||||
"off, active, disabled, selected, normal} separated by underscore"
|
||||
)
|
||||
else:
|
||||
_sm = key if isinstance(key, abc.Sequence) else [key] # type: ignore
|
||||
|
||||
state = next((i for i in _sm if isinstance(i, QIcon.State)), QIcon.State.Off)
|
||||
mode = next((i for i in _sm if isinstance(i, QIcon.Mode)), QIcon.Mode.Normal)
|
||||
return state, mode
|
||||
|
||||
|
||||
class IconOptionDict(TypedDict, total=False):
|
||||
glyph_key: str
|
||||
scale_factor: float
|
||||
color: ValidColor
|
||||
opacity: float
|
||||
animation: Optional[Animation]
|
||||
transform: Optional[QTransform]
|
||||
|
||||
|
||||
# public facing, for a nicer IDE experience than a dict
|
||||
# The difference between IconOpts and _IconOptions is that all of IconOpts
|
||||
# all default to `_Unset` and are intended to extend some base/default option
|
||||
# IconOpts are *not* guaranteed to be fully capable of rendering an icon, whereas
|
||||
# IconOptions are.
|
||||
@dataclass
|
||||
class IconOpts:
|
||||
glyph_key: Union[str, Unset] = _Unset
|
||||
scale_factor: Union[float, Unset] = _Unset
|
||||
color: Union[ValidColor, Unset] = _Unset
|
||||
opacity: Union[float, Unset] = _Unset
|
||||
animation: Union[Animation, Unset, None] = _Unset
|
||||
transform: Union[QTransform, Unset, None] = _Unset
|
||||
|
||||
def dict(self) -> IconOptionDict:
|
||||
# not using asdict due to pickle errors on animation
|
||||
d = {k: v for k, v in vars(self).items() if v is not _Unset}
|
||||
return cast(IconOptionDict, d)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _IconOptions:
|
||||
"""The set of options needed to render a font in a single State/Mode."""
|
||||
|
||||
glyph_key: str
|
||||
scale_factor: float = DEFAULT_SCALING_FACTOR
|
||||
color: ValidColor = None
|
||||
opacity: float = DEFAULT_OPACITY
|
||||
animation: Optional[Animation] = None
|
||||
transform: Optional[QTransform] = None
|
||||
|
||||
def _update(self, icon_opts: IconOpts) -> _IconOptions:
|
||||
return _IconOptions(**{**vars(self), **icon_opts.dict()})
|
||||
|
||||
def dict(self) -> IconOptionDict:
|
||||
# not using asdict due to pickle errors on animation
|
||||
return cast(IconOptionDict, vars(self))
|
||||
|
||||
|
||||
class _QFontIconEngine(QIconEngine):
|
||||
_opt_hash: str = ""
|
||||
|
||||
def __init__(self, options: _IconOptions):
|
||||
super().__init__()
|
||||
self._opts: DefaultDict[
|
||||
QIcon.State, Dict[QIcon.Mode, Optional[_IconOptions]]
|
||||
] = DefaultDict(dict)
|
||||
self._opts[QIcon.State.Off][QIcon.Mode.Normal] = options
|
||||
self.update_hash()
|
||||
|
||||
@property
|
||||
def _default_opts(self) -> _IconOptions:
|
||||
return cast(_IconOptions, self._opts[QIcon.State.Off][QIcon.Mode.Normal])
|
||||
|
||||
def _add_opts(self, state: QIcon.State, mode: QIcon.Mode, opts: IconOpts) -> None:
|
||||
self._opts[state][mode] = self._default_opts._update(opts)
|
||||
self.update_hash()
|
||||
|
||||
def clone(self) -> QIconEngine: # pragma: no cover
|
||||
ico = _QFontIconEngine(self._default_opts)
|
||||
ico._opts = self._opts.copy()
|
||||
return ico
|
||||
|
||||
def _get_opts(self, state: QIcon.State, mode: QIcon.Mode) -> _IconOptions:
|
||||
opts = self._opts[state].get(mode)
|
||||
if opts:
|
||||
return opts
|
||||
|
||||
opp_state = QIcon.State.Off if state == QIcon.State.On else QIcon.State.On
|
||||
if mode in (QIcon.Mode.Disabled, QIcon.Mode.Selected):
|
||||
opp_mode = (
|
||||
QIcon.Mode.Disabled
|
||||
if mode == QIcon.Mode.Selected
|
||||
else QIcon.Mode.Selected
|
||||
)
|
||||
for m, s in [
|
||||
(QIcon.Mode.Normal, state),
|
||||
(QIcon.Mode.Active, state),
|
||||
(mode, opp_state),
|
||||
(QIcon.Mode.Normal, opp_state),
|
||||
(QIcon.Mode.Active, opp_state),
|
||||
(opp_mode, state),
|
||||
(opp_mode, opp_state),
|
||||
]:
|
||||
opts = self._opts[s].get(m)
|
||||
if opts:
|
||||
return opts
|
||||
else:
|
||||
opp_mode = (
|
||||
QIcon.Mode.Active if mode == QIcon.Mode.Normal else QIcon.Mode.Normal
|
||||
)
|
||||
for m, s in [
|
||||
(opp_mode, state),
|
||||
(mode, opp_state),
|
||||
(opp_mode, opp_state),
|
||||
(QIcon.Mode.Disabled, state),
|
||||
(QIcon.Mode.Selected, state),
|
||||
(QIcon.Mode.Disabled, opp_state),
|
||||
(QIcon.Mode.Selected, opp_state),
|
||||
]:
|
||||
opts = self._opts[s].get(m)
|
||||
if opts:
|
||||
return opts
|
||||
return self._default_opts
|
||||
|
||||
def paint(
|
||||
self,
|
||||
painter: QPainter,
|
||||
rect: QRect,
|
||||
mode: QIcon.Mode,
|
||||
state: QIcon.State,
|
||||
) -> None:
|
||||
opts = self._get_opts(state, mode)
|
||||
|
||||
char, family, style = QFontIconStore.key2glyph(opts.glyph_key)
|
||||
|
||||
# font
|
||||
font = QFont()
|
||||
font.setFamily(family) # set sepeartely for Qt6
|
||||
font.setPixelSize(round(rect.height() * opts.scale_factor))
|
||||
if style:
|
||||
font.setStyleName(style)
|
||||
|
||||
# color
|
||||
if isinstance(opts.color, tuple):
|
||||
color_args = opts.color
|
||||
else:
|
||||
color_args = (opts.color,) if opts.color else () # type: ignore
|
||||
|
||||
# animation
|
||||
if opts.animation is not None:
|
||||
opts.animation.animate(painter)
|
||||
|
||||
# animation
|
||||
if opts.transform is not None:
|
||||
painter.setTransform(opts.transform, True)
|
||||
|
||||
painter.save()
|
||||
painter.setPen(QColor(*color_args))
|
||||
painter.setOpacity(opts.opacity)
|
||||
painter.setFont(font)
|
||||
with QMessageHandler(): # avoid "Populating font family aliases" warning
|
||||
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, char)
|
||||
painter.restore()
|
||||
|
||||
def pixmap(self, size: QSize, mode: QIcon.Mode, state: QIcon.State) -> QPixmap:
|
||||
# first look in cache
|
||||
pmckey = self._pmcKey(size, mode, state)
|
||||
pm = QPixmapCache.find(pmckey) if pmckey else None
|
||||
if pm:
|
||||
return pm
|
||||
pixmap = QPixmap(size)
|
||||
if not size.isValid():
|
||||
return pixmap
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
painter = QPainter(pixmap)
|
||||
self.paint(painter, QRect(QPoint(0, 0), size), mode, state)
|
||||
painter.end()
|
||||
|
||||
# Apply palette-based styles for disabled/selected modes
|
||||
# unless the user has specifically set a color for this mode/state
|
||||
if mode != QIcon.Mode.Normal:
|
||||
ico_opts = self._opts[state].get(mode)
|
||||
if not ico_opts or not ico_opts.color:
|
||||
opt = QStyleOption()
|
||||
opt.palette = QGuiApplication.palette()
|
||||
generated = QApplication.style().generatedIconPixmap(mode, pixmap, opt)
|
||||
if not generated.isNull():
|
||||
pixmap = generated
|
||||
|
||||
if pmckey and not pixmap.isNull():
|
||||
QPixmapCache.insert(pmckey, pixmap)
|
||||
|
||||
return pixmap
|
||||
|
||||
def _pmcKey(self, size: QSize, mode: QIcon.Mode, state: QIcon.State) -> str:
|
||||
# Qt6-style enums
|
||||
if self._get_opts(state, mode).animation:
|
||||
return ""
|
||||
if hasattr(mode, "value"):
|
||||
mode = mode.value
|
||||
if hasattr(state, "value"):
|
||||
state = state.value
|
||||
k = ((((((size.width()) << 11) | size.height()) << 11) | mode) << 4) | state
|
||||
return f"$superqt_{self._opt_hash}_{hex(k)}"
|
||||
|
||||
def update_hash(self) -> None:
|
||||
hsh = id(self)
|
||||
for state, d in self._opts.items():
|
||||
for mode, opts in d.items():
|
||||
if not opts:
|
||||
continue
|
||||
hsh += hash(
|
||||
hash(opts.glyph_key) + hash(opts.color) + hash(state) + hash(mode)
|
||||
)
|
||||
self._opt_hash = hex(hsh)
|
||||
|
||||
|
||||
class QFontIcon(QIcon):
|
||||
def __init__(self, options: _IconOptions) -> None:
|
||||
self._engine = _QFontIconEngine(options)
|
||||
super().__init__(self._engine)
|
||||
|
||||
def addState(
|
||||
self,
|
||||
state: QIcon.State = QIcon.State.Off,
|
||||
mode: QIcon.Mode = QIcon.Mode.Normal,
|
||||
glyph_key: Union[str, Unset] = _Unset,
|
||||
scale_factor: Union[float, Unset] = _Unset,
|
||||
color: Union[ValidColor, Unset] = _Unset,
|
||||
opacity: Union[float, Unset] = _Unset,
|
||||
animation: Union[Animation, Unset, None] = _Unset,
|
||||
transform: Union[QTransform, Unset, None] = _Unset,
|
||||
) -> None:
|
||||
"""Set icon options for a specific mode/state."""
|
||||
if glyph_key is not _Unset:
|
||||
QFontIconStore.key2glyph(glyph_key) # type: ignore
|
||||
|
||||
_opts = IconOpts(
|
||||
glyph_key=glyph_key,
|
||||
scale_factor=scale_factor,
|
||||
color=color,
|
||||
opacity=opacity,
|
||||
animation=animation,
|
||||
transform=transform,
|
||||
)
|
||||
self._engine._add_opts(state, mode, _opts)
|
||||
|
||||
|
||||
class QFontIconStore(QObject):
|
||||
|
||||
# map of key -> (font_family, font_style)
|
||||
_LOADED_KEYS: Dict[str, Tuple[str, Optional[str]]] = dict()
|
||||
|
||||
# map of (font_family, font_style) -> character (char may include key)
|
||||
_CHARMAPS: Dict[Tuple[str, Optional[str]], Dict[str, str]] = dict()
|
||||
|
||||
# singleton instance, use `instance()` to retrieve
|
||||
__instance: Optional[QFontIconStore] = None
|
||||
|
||||
def __init__(self, parent: Optional[QObject] = None) -> None:
|
||||
super().__init__(parent=parent)
|
||||
# QT6 drops this
|
||||
dpi = getattr(Qt.ApplicationAttribute, "AA_UseHighDpiPixmaps", None)
|
||||
if dpi:
|
||||
QApplication.setAttribute(dpi)
|
||||
|
||||
@classmethod
|
||||
def instance(cls) -> QFontIconStore:
|
||||
if cls.__instance is None:
|
||||
cls.__instance = cls()
|
||||
return cls.__instance
|
||||
|
||||
@classmethod
|
||||
def clear(cls) -> None:
|
||||
cls._LOADED_KEYS.clear()
|
||||
cls._CHARMAPS.clear()
|
||||
QFontDatabase.removeAllApplicationFonts()
|
||||
|
||||
@classmethod
|
||||
def _key2family(cls, key: str) -> Tuple[str, Optional[str]]:
|
||||
"""Return (family, style) given a font `key`"""
|
||||
key = key.split(".", maxsplit=1)[0]
|
||||
if key not in cls._LOADED_KEYS:
|
||||
from . import _plugins
|
||||
|
||||
try:
|
||||
font_cls = _plugins.get_font_class(key)
|
||||
result = cls.addFont(
|
||||
font_cls.__font_file__, key, charmap=font_cls.__dict__
|
||||
)
|
||||
if not result: # pragma: no cover
|
||||
raise Exception("Invalid font file")
|
||||
cls._LOADED_KEYS[key] = result
|
||||
except ValueError as e:
|
||||
raise ValueError(
|
||||
f"Unrecognized font key: {key!r}.\n"
|
||||
f"Known plugin keys include: {_plugins.available()}.\n"
|
||||
f"Loaded keys include: {list(cls._LOADED_KEYS)}."
|
||||
) from e
|
||||
return cls._LOADED_KEYS[key]
|
||||
|
||||
@classmethod
|
||||
def _ensure_char(cls, char: str, family: str, style: str) -> str:
|
||||
"""make sure that `char` is a glyph provided by `family` and `style`."""
|
||||
if len(char) == 1 and ord(char) > 256:
|
||||
return char
|
||||
try:
|
||||
charmap = cls._CHARMAPS[(family, style)]
|
||||
except KeyError:
|
||||
raise KeyError(f"No charmap registered for font '{family} ({style})'")
|
||||
if char in charmap:
|
||||
# split in case the charmap includes the key
|
||||
return charmap[char].split(".", maxsplit=1)[-1]
|
||||
|
||||
ident = _ensure_identifier(char)
|
||||
if ident in charmap:
|
||||
return charmap[ident].split(".", maxsplit=1)[-1]
|
||||
|
||||
ident = f"{char!r} or {ident!r}" if char != ident else repr(ident)
|
||||
raise ValueError(f"Font '{family} ({style})' has no glyph with the key {ident}")
|
||||
|
||||
@classmethod
|
||||
def key2glyph(cls, glyph_key: str) -> tuple[str, str, Optional[str]]:
|
||||
"""Return (char, family, style) given a `glyph_key`"""
|
||||
if "." not in glyph_key:
|
||||
raise ValueError("Glyph key must contain a period")
|
||||
font_key, char = glyph_key.split(".", maxsplit=1)
|
||||
family, style = cls._key2family(font_key)
|
||||
char = cls._ensure_char(char, family, style)
|
||||
return char, family, style
|
||||
|
||||
@classmethod
|
||||
def addFont(
|
||||
cls, filepath: str, prefix: str, charmap: Optional[Dict[str, str]] = None
|
||||
) -> Optional[Tuple[str, str]]:
|
||||
"""Add font at `filepath` to the registry under `key`.
|
||||
|
||||
If you'd like to later use a fontkey in the form of `key.some-name`, then
|
||||
`charmap` must be provided and provide a mapping for all of the glyph names
|
||||
to their unicode numbers. If a charmap is not provided, glyphs must be directly
|
||||
accessed with their unicode as something like `key.\uffff`.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
filepath : str
|
||||
Path to an OTF or TTF file containing the fonts
|
||||
key : str
|
||||
A key that will represent this font file when used for lookup. For example,
|
||||
'fa5s' for 'Font-Awesome 5 Solid'.
|
||||
charmap : Dict[str, str], optional
|
||||
optional mapping for all of the glyph names to their unicode numbers.
|
||||
See note above.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Tuple[str, str], optional
|
||||
font-family and font-style for the file just registered, or None if
|
||||
something goes wrong.
|
||||
"""
|
||||
if prefix in cls._LOADED_KEYS:
|
||||
warnings.warn(f"Prefix {prefix} already loaded")
|
||||
return
|
||||
|
||||
if not Path(filepath).exists():
|
||||
raise FileNotFoundError(f"Font file doesn't exist: {filepath}")
|
||||
if QApplication.instance() is None:
|
||||
raise RuntimeError("Please create QApplication before adding a Font")
|
||||
|
||||
fontId = QFontDatabase.addApplicationFont(str(Path(filepath).absolute()))
|
||||
if fontId < 0: # pragma: no cover
|
||||
warnings.warn(f"Cannot load font file: {filepath}")
|
||||
return None
|
||||
|
||||
families = QFontDatabase.applicationFontFamilies(fontId)
|
||||
if not families: # pragma: no cover
|
||||
warnings.warn(f"Font file is empty!: {filepath}")
|
||||
return None
|
||||
family: str = families[0]
|
||||
|
||||
# in Qt6, everything becomes a static member
|
||||
QFd: Union[QFontDatabase, Type[QFontDatabase]] = (
|
||||
QFontDatabase() # type: ignore
|
||||
if tuple(QT_VERSION.split(".")) < ("6", "0")
|
||||
else QFontDatabase
|
||||
)
|
||||
|
||||
styles = QFd.styles(family) # type: ignore
|
||||
style: str = styles[-1] if styles else ""
|
||||
if not QFd.isSmoothlyScalable(family, style): # pragma: no cover
|
||||
warnings.warn(
|
||||
f"Registered font {family} ({style}) is not smoothly scalable. "
|
||||
"Icons may not look attractive."
|
||||
)
|
||||
|
||||
cls._LOADED_KEYS[prefix] = (family, style)
|
||||
if charmap:
|
||||
cls._CHARMAPS[(family, style)] = charmap
|
||||
return (family, style)
|
||||
|
||||
def icon(
|
||||
self,
|
||||
glyph_key: str,
|
||||
*,
|
||||
scale_factor: float = DEFAULT_SCALING_FACTOR,
|
||||
color: ValidColor = None,
|
||||
opacity: float = 1,
|
||||
animation: Optional[Animation] = None,
|
||||
transform: Optional[QTransform] = None,
|
||||
states: Dict[str, Union[IconOptionDict, IconOpts]] = {},
|
||||
) -> QFontIcon:
|
||||
self.key2glyph(glyph_key) # make sure it's a valid glyph_key
|
||||
default_opts = _IconOptions(
|
||||
glyph_key=glyph_key,
|
||||
scale_factor=scale_factor,
|
||||
color=color,
|
||||
opacity=opacity,
|
||||
animation=animation,
|
||||
transform=transform,
|
||||
)
|
||||
icon = QFontIcon(default_opts)
|
||||
for kw, options in states.items():
|
||||
if isinstance(options, IconOpts):
|
||||
options = default_opts._update(options).dict()
|
||||
icon.addState(*_norm_state_mode(kw), **options)
|
||||
return icon
|
||||
|
||||
def setTextIcon(
|
||||
self, widget: QWidget, glyph_key: str, size: Optional[float] = None
|
||||
) -> None:
|
||||
"""Sets text on a widget to a specific font & glyph.
|
||||
|
||||
This is an alternative to setting a QIcon with a pixmap. It may
|
||||
be easier to combine with dynamic stylesheets.
|
||||
"""
|
||||
setText = getattr(widget, "setText", None)
|
||||
if not setText: # pragma: no cover
|
||||
raise TypeError(f"Object does not a setText method: {widget}")
|
||||
|
||||
glyph = self.key2glyph(glyph_key)[0]
|
||||
size = size or DEFAULT_SCALING_FACTOR
|
||||
size = size if size > 1 else widget.height() * size
|
||||
widget.setFont(self.font(glyph_key, int(size)))
|
||||
setText(glyph)
|
||||
|
||||
def font(self, font_prefix: str, size: Optional[int] = None) -> QFont:
|
||||
"""Create QFont for `font_prefix`"""
|
||||
font_key, _ = font_prefix.split(".", maxsplit=1)
|
||||
family, style = self._key2family(font_key)
|
||||
font = QFont()
|
||||
font.setFamily(family)
|
||||
if style:
|
||||
font.setStyleName(style)
|
||||
if size:
|
||||
font.setPixelSize(int(size))
|
||||
return font
|
||||
|
||||
|
||||
def _ensure_identifier(name: str) -> str:
|
||||
"""Normalize string to valid identifier"""
|
||||
import keyword
|
||||
|
||||
if not name:
|
||||
return ""
|
||||
|
||||
# add _ to beginning of names starting with numbers
|
||||
if name[0].isdigit():
|
||||
name = f"_{name}"
|
||||
|
||||
# add _ to end of reserved keywords
|
||||
if keyword.iskeyword(name):
|
||||
name += "_"
|
||||
|
||||
# replace dashes and spaces with underscores
|
||||
name = name.replace("-", "_").replace(" ", "_")
|
||||
|
||||
assert str.isidentifier(name), f"Could not canonicalize name: {name}"
|
||||
return name
|
0
src/superqt/py.typed
Normal file
0
src/superqt/py.typed
Normal file
4
src/superqt/qtcompat/Qt3DAnimation.pyi
Normal file
4
src/superqt/qtcompat/Qt3DAnimation.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.Qt3DAnimation import *
|
||||
from PyQt6.Qt3DAnimation import *
|
||||
from PySide2.Qt3DAnimation import *
|
||||
from PySide6.Qt3DAnimation import *
|
4
src/superqt/qtcompat/Qt3DCore.pyi
Normal file
4
src/superqt/qtcompat/Qt3DCore.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.Qt3DCore import *
|
||||
from PyQt6.Qt3DCore import *
|
||||
from PySide2.Qt3DCore import *
|
||||
from PySide6.Qt3DCore import *
|
4
src/superqt/qtcompat/Qt3DExtras.pyi
Normal file
4
src/superqt/qtcompat/Qt3DExtras.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.Qt3DExtras import *
|
||||
from PyQt6.Qt3DExtras import *
|
||||
from PySide2.Qt3DExtras import *
|
||||
from PySide6.Qt3DExtras import *
|
4
src/superqt/qtcompat/Qt3DInput.pyi
Normal file
4
src/superqt/qtcompat/Qt3DInput.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.Qt3DInput import *
|
||||
from PyQt6.Qt3DInput import *
|
||||
from PySide2.Qt3DInput import *
|
||||
from PySide6.Qt3DInput import *
|
4
src/superqt/qtcompat/Qt3DLogic.pyi
Normal file
4
src/superqt/qtcompat/Qt3DLogic.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.Qt3DLogic import *
|
||||
from PyQt6.Qt3DLogic import *
|
||||
from PySide2.Qt3DLogic import *
|
||||
from PySide6.Qt3DLogic import *
|
4
src/superqt/qtcompat/Qt3DRender.pyi
Normal file
4
src/superqt/qtcompat/Qt3DRender.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.Qt3DRender import *
|
||||
from PyQt6.Qt3DRender import *
|
||||
from PySide2.Qt3DRender import *
|
||||
from PySide6.Qt3DRender import *
|
4
src/superqt/qtcompat/QtCharts.pyi
Normal file
4
src/superqt/qtcompat/QtCharts.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtCharts import *
|
||||
from PyQt6.QtCharts import *
|
||||
from PySide2.QtCharts import *
|
||||
from PySide6.QtCharts import *
|
4
src/superqt/qtcompat/QtConcurrent.pyi
Normal file
4
src/superqt/qtcompat/QtConcurrent.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtConcurrent import *
|
||||
from PyQt6.QtConcurrent import *
|
||||
from PySide2.QtConcurrent import *
|
||||
from PySide6.QtConcurrent import *
|
12
src/superqt/qtcompat/QtCore.py
Normal file
12
src/superqt/qtcompat/QtCore.py
Normal file
@@ -0,0 +1,12 @@
|
||||
# type: ignore
|
||||
from . import API_NAME, _get_qtmodule
|
||||
|
||||
_QtCore = _get_qtmodule(__name__)
|
||||
globals().update(_QtCore.__dict__)
|
||||
|
||||
if "PyQt" in API_NAME:
|
||||
Property = _QtCore.pyqtProperty
|
||||
Signal = _QtCore.pyqtSignal
|
||||
SignalInstance = getattr(_QtCore, "pyqtBoundSignal", None)
|
||||
Slot = _QtCore.pyqtSlot
|
||||
__version__ = _QtCore.QT_VERSION_STR
|
10
src/superqt/qtcompat/QtCore.pyi
Normal file
10
src/superqt/qtcompat/QtCore.pyi
Normal file
@@ -0,0 +1,10 @@
|
||||
from PyQt5.QtCore import *
|
||||
from PyQt6.QtCore import *
|
||||
from PySide2.QtCore import *
|
||||
from PySide6.QtCore import *
|
||||
|
||||
Property = pyqtProperty
|
||||
Signal = pyqtSignal
|
||||
SignalInstance = pyqtBoundSignal
|
||||
Slot = pyqtSlot
|
||||
__version__: str
|
4
src/superqt/qtcompat/QtDataVisualization.pyi
Normal file
4
src/superqt/qtcompat/QtDataVisualization.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtDataVisualization import *
|
||||
from PyQt6.QtDataVisualization import *
|
||||
from PySide2.QtDataVisualization import *
|
||||
from PySide6.QtDataVisualization import *
|
13
src/superqt/qtcompat/QtGui.py
Normal file
13
src/superqt/qtcompat/QtGui.py
Normal file
@@ -0,0 +1,13 @@
|
||||
# type: ignore
|
||||
from . import API_NAME, _get_qtmodule
|
||||
|
||||
_QtGui = _get_qtmodule(__name__)
|
||||
globals().update(_QtGui.__dict__)
|
||||
|
||||
if "6" in API_NAME:
|
||||
|
||||
def pos(self, *a):
|
||||
_pos = self.position(*a)
|
||||
return _pos.toPoint()
|
||||
|
||||
_QtGui.QMouseEvent.pos = pos
|
4
src/superqt/qtcompat/QtGui.pyi
Normal file
4
src/superqt/qtcompat/QtGui.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtGui import *
|
||||
from PyQt6.QtGui import *
|
||||
from PySide2.QtGui import *
|
||||
from PySide6.QtGui import *
|
4
src/superqt/qtcompat/QtHelp.pyi
Normal file
4
src/superqt/qtcompat/QtHelp.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtHelp import *
|
||||
from PyQt6.QtHelp import *
|
||||
from PySide2.QtHelp import *
|
||||
from PySide6.QtHelp import *
|
4
src/superqt/qtcompat/QtLocation.pyi
Normal file
4
src/superqt/qtcompat/QtLocation.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtLocation import *
|
||||
from PyQt6.QtLocation import *
|
||||
from PySide2.QtLocation import *
|
||||
from PySide6.QtLocation import *
|
4
src/superqt/qtcompat/QtMacExtras.pyi
Normal file
4
src/superqt/qtcompat/QtMacExtras.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtMacExtras import *
|
||||
from PyQt6.QtMacExtras import *
|
||||
from PySide2.QtMacExtras import *
|
||||
from PySide6.QtMacExtras import *
|
4
src/superqt/qtcompat/QtMultimedia.pyi
Normal file
4
src/superqt/qtcompat/QtMultimedia.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtMultimedia import *
|
||||
from PyQt6.QtMultimedia import *
|
||||
from PySide2.QtMultimedia import *
|
||||
from PySide6.QtMultimedia import *
|
4
src/superqt/qtcompat/QtMultimediaWidgets.pyi
Normal file
4
src/superqt/qtcompat/QtMultimediaWidgets.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtMultimediaWidgets import *
|
||||
from PyQt6.QtMultimediaWidgets import *
|
||||
from PySide2.QtMultimediaWidgets import *
|
||||
from PySide6.QtMultimediaWidgets import *
|
4
src/superqt/qtcompat/QtNetwork.pyi
Normal file
4
src/superqt/qtcompat/QtNetwork.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtNetwork import *
|
||||
from PyQt6.QtNetwork import *
|
||||
from PySide2.QtNetwork import *
|
||||
from PySide6.QtNetwork import *
|
4
src/superqt/qtcompat/QtOpenGL.pyi
Normal file
4
src/superqt/qtcompat/QtOpenGL.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtOpenGL import *
|
||||
from PyQt6.QtOpenGL import *
|
||||
from PySide2.QtOpenGL import *
|
||||
from PySide6.QtOpenGL import *
|
4
src/superqt/qtcompat/QtOpenGLFunctions.pyi
Normal file
4
src/superqt/qtcompat/QtOpenGLFunctions.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtOpenGLFunctions import *
|
||||
from PyQt6.QtOpenGLFunctions import *
|
||||
from PySide2.QtOpenGLFunctions import *
|
||||
from PySide6.QtOpenGLFunctions import *
|
4
src/superqt/qtcompat/QtPositioning.pyi
Normal file
4
src/superqt/qtcompat/QtPositioning.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtPositioning import *
|
||||
from PyQt6.QtPositioning import *
|
||||
from PySide2.QtPositioning import *
|
||||
from PySide6.QtPositioning import *
|
4
src/superqt/qtcompat/QtPrintSupport.pyi
Normal file
4
src/superqt/qtcompat/QtPrintSupport.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtPrintSupport import *
|
||||
from PyQt6.QtPrintSupport import *
|
||||
from PySide2.QtPrintSupport import *
|
||||
from PySide6.QtPrintSupport import *
|
4
src/superqt/qtcompat/QtQml.pyi
Normal file
4
src/superqt/qtcompat/QtQml.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtQml import *
|
||||
from PyQt6.QtQml import *
|
||||
from PySide2.QtQml import *
|
||||
from PySide6.QtQml import *
|
4
src/superqt/qtcompat/QtQuick.pyi
Normal file
4
src/superqt/qtcompat/QtQuick.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtQuick import *
|
||||
from PyQt6.QtQuick import *
|
||||
from PySide2.QtQuick import *
|
||||
from PySide6.QtQuick import *
|
4
src/superqt/qtcompat/QtQuickControls2.pyi
Normal file
4
src/superqt/qtcompat/QtQuickControls2.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtQuickControls2 import *
|
||||
from PyQt6.QtQuickControls2 import *
|
||||
from PySide2.QtQuickControls2 import *
|
||||
from PySide6.QtQuickControls2 import *
|
4
src/superqt/qtcompat/QtQuickWidgets.pyi
Normal file
4
src/superqt/qtcompat/QtQuickWidgets.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtQuickWidgets import *
|
||||
from PyQt6.QtQuickWidgets import *
|
||||
from PySide2.QtQuickWidgets import *
|
||||
from PySide6.QtQuickWidgets import *
|
4
src/superqt/qtcompat/QtRemoteObjects.pyi
Normal file
4
src/superqt/qtcompat/QtRemoteObjects.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtRemoteObjects import *
|
||||
from PyQt6.QtRemoteObjects import *
|
||||
from PySide2.QtRemoteObjects import *
|
||||
from PySide6.QtRemoteObjects import *
|
4
src/superqt/qtcompat/QtScript.pyi
Normal file
4
src/superqt/qtcompat/QtScript.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtScript import *
|
||||
from PyQt6.QtScript import *
|
||||
from PySide2.QtScript import *
|
||||
from PySide6.QtScript import *
|
4
src/superqt/qtcompat/QtScriptTools.pyi
Normal file
4
src/superqt/qtcompat/QtScriptTools.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtScriptTools import *
|
||||
from PyQt6.QtScriptTools import *
|
||||
from PySide2.QtScriptTools import *
|
||||
from PySide6.QtScriptTools import *
|
4
src/superqt/qtcompat/QtScxml.pyi
Normal file
4
src/superqt/qtcompat/QtScxml.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtScxml import *
|
||||
from PyQt6.QtScxml import *
|
||||
from PySide2.QtScxml import *
|
||||
from PySide6.QtScxml import *
|
4
src/superqt/qtcompat/QtSensors.pyi
Normal file
4
src/superqt/qtcompat/QtSensors.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtSensors import *
|
||||
from PyQt6.QtSensors import *
|
||||
from PySide2.QtSensors import *
|
||||
from PySide6.QtSensors import *
|
4
src/superqt/qtcompat/QtSerialPort.pyi
Normal file
4
src/superqt/qtcompat/QtSerialPort.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtSerialPort import *
|
||||
from PyQt6.QtSerialPort import *
|
||||
from PySide2.QtSerialPort import *
|
||||
from PySide6.QtSerialPort import *
|
4
src/superqt/qtcompat/QtSql.pyi
Normal file
4
src/superqt/qtcompat/QtSql.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtSql import *
|
||||
from PyQt6.QtSql import *
|
||||
from PySide2.QtSql import *
|
||||
from PySide6.QtSql import *
|
4
src/superqt/qtcompat/QtSvg.pyi
Normal file
4
src/superqt/qtcompat/QtSvg.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtSvg import *
|
||||
from PyQt6.QtSvg import *
|
||||
from PySide2.QtSvg import *
|
||||
from PySide6.QtSvg import *
|
4
src/superqt/qtcompat/QtTest.pyi
Normal file
4
src/superqt/qtcompat/QtTest.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtTest import *
|
||||
from PyQt6.QtTest import *
|
||||
from PySide2.QtTest import *
|
||||
from PySide6.QtTest import *
|
4
src/superqt/qtcompat/QtTextToSpeech.pyi
Normal file
4
src/superqt/qtcompat/QtTextToSpeech.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtTextToSpeech import *
|
||||
from PyQt6.QtTextToSpeech import *
|
||||
from PySide2.QtTextToSpeech import *
|
||||
from PySide6.QtTextToSpeech import *
|
4
src/superqt/qtcompat/QtUiTools.pyi
Normal file
4
src/superqt/qtcompat/QtUiTools.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtUiTools import *
|
||||
from PyQt6.QtUiTools import *
|
||||
from PySide2.QtUiTools import *
|
||||
from PySide6.QtUiTools import *
|
4
src/superqt/qtcompat/QtWebChannel.pyi
Normal file
4
src/superqt/qtcompat/QtWebChannel.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtWebChannel import *
|
||||
from PyQt6.QtWebChannel import *
|
||||
from PySide2.QtWebChannel import *
|
||||
from PySide6.QtWebChannel import *
|
4
src/superqt/qtcompat/QtWebEngine.pyi
Normal file
4
src/superqt/qtcompat/QtWebEngine.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtWebEngine import *
|
||||
from PyQt6.QtWebEngine import *
|
||||
from PySide2.QtWebEngine import *
|
||||
from PySide6.QtWebEngine import *
|
4
src/superqt/qtcompat/QtWebEngineCore.pyi
Normal file
4
src/superqt/qtcompat/QtWebEngineCore.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtWebEngineCore import *
|
||||
from PyQt6.QtWebEngineCore import *
|
||||
from PySide2.QtWebEngineCore import *
|
||||
from PySide6.QtWebEngineCore import *
|
4
src/superqt/qtcompat/QtWebEngineWidgets.pyi
Normal file
4
src/superqt/qtcompat/QtWebEngineWidgets.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtWebEngineWidgets import *
|
||||
from PyQt6.QtWebEngineWidgets import *
|
||||
from PySide2.QtWebEngineWidgets import *
|
||||
from PySide6.QtWebEngineWidgets import *
|
4
src/superqt/qtcompat/QtWebSockets.pyi
Normal file
4
src/superqt/qtcompat/QtWebSockets.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtWebSockets import *
|
||||
from PyQt6.QtWebSockets import *
|
||||
from PySide2.QtWebSockets import *
|
||||
from PySide6.QtWebSockets import *
|
16
src/superqt/qtcompat/QtWidgets.py
Normal file
16
src/superqt/qtcompat/QtWidgets.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# type: ignore
|
||||
from . import API_NAME, _get_qtmodule
|
||||
|
||||
_QtWidgets = _get_qtmodule(__name__)
|
||||
globals().update(_QtWidgets.__dict__)
|
||||
|
||||
|
||||
QApplication = _QtWidgets.QApplication
|
||||
if not hasattr(QApplication, "exec"):
|
||||
QApplication.exec = _QtWidgets.QApplication.exec_
|
||||
|
||||
# backwargs compat with qt5
|
||||
if "6" in API_NAME:
|
||||
_QtGui = _get_qtmodule("QtGui")
|
||||
QAction = _QtGui.QAction
|
||||
QShortcut = _QtGui.QShortcut
|
12
src/superqt/qtcompat/QtWidgets.pyi
Normal file
12
src/superqt/qtcompat/QtWidgets.pyi
Normal file
@@ -0,0 +1,12 @@
|
||||
from PyQt5.QtWidgets import *
|
||||
from PyQt6.QtWidgets import *
|
||||
from PySide2.QtWidgets import *
|
||||
from PySide6.QtWidgets import *
|
||||
|
||||
QApplication.exec_ = QApplication.exec
|
||||
|
||||
from PyQt6 import QtGui
|
||||
from PySide6 import QtGui
|
||||
|
||||
QAction = QtGui.QAction
|
||||
QShortcut = QtGui.QShortcut
|
4
src/superqt/qtcompat/QtXml.pyi
Normal file
4
src/superqt/qtcompat/QtXml.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtXml import *
|
||||
from PyQt6.QtXml import *
|
||||
from PySide2.QtXml import *
|
||||
from PySide6.QtXml import *
|
4
src/superqt/qtcompat/QtXmlPatterns.pyi
Normal file
4
src/superqt/qtcompat/QtXmlPatterns.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from PyQt5.QtXmlPatterns import *
|
||||
from PyQt6.QtXmlPatterns import *
|
||||
from PySide2.QtXmlPatterns import *
|
||||
from PySide6.QtXmlPatterns import *
|
114
src/superqt/qtcompat/__init__.py
Normal file
114
src/superqt/qtcompat/__init__.py
Normal file
@@ -0,0 +1,114 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
from importlib import abc, import_module, util
|
||||
from typing import TYPE_CHECKING, Optional, Sequence, Union
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from importlib.machinery import ModuleSpec
|
||||
from types import ModuleType
|
||||
|
||||
|
||||
class QtMissingError(ImportError):
|
||||
"""Error raise if no bindings could be selected."""
|
||||
|
||||
|
||||
VALID_APIS = {
|
||||
"pyqt5": "PyQt5",
|
||||
"pyqt6": "PyQt6",
|
||||
"pyside2": "PySide2",
|
||||
"pyside6": "PySide6",
|
||||
}
|
||||
|
||||
# Detecting if a binding was specified by the user
|
||||
_requested_api = os.getenv("QT_API", "").lower()
|
||||
_forced_api = os.getenv("FORCE_QT_API")
|
||||
|
||||
# warn if an invalid API has been requested
|
||||
if _requested_api and _requested_api not in VALID_APIS:
|
||||
warnings.warn(
|
||||
f"invalid QT_API specified: {_requested_api}. "
|
||||
f"Valid values include {set(VALID_APIS)}"
|
||||
)
|
||||
_forced_api = None
|
||||
_requested_api = ""
|
||||
|
||||
# TODO: FORCE_QT_API requires also using QT_API ... does that make sense?
|
||||
|
||||
# now we'll try to import QtCore
|
||||
_QtCore: Optional[ModuleType] = None
|
||||
|
||||
# If `FORCE_QT_API` is not set, we first look for previously imported bindings
|
||||
if not _forced_api:
|
||||
for api_name, module_name in VALID_APIS.items():
|
||||
if module_name in sys.modules:
|
||||
_QtCore = import_module(f"{module_name}.QtCore")
|
||||
break
|
||||
|
||||
if _QtCore is None:
|
||||
# try the requested API first, and if _forced_api is True,
|
||||
# raise an ImportError if it doesn't work.
|
||||
# Otherwise go through the list of Valid APIs until something imports
|
||||
requested = VALID_APIS.get(_requested_api)
|
||||
for module_name in sorted(VALID_APIS.values(), key=lambda x: x != requested):
|
||||
try:
|
||||
_QtCore = import_module(f"{module_name}.QtCore")
|
||||
break
|
||||
except ImportError:
|
||||
if _forced_api:
|
||||
ImportError(
|
||||
"FORCE_QT_API set and unable to import requested QT_API: {e}"
|
||||
)
|
||||
|
||||
# didn't find one... not going to work
|
||||
if _QtCore is None:
|
||||
raise QtMissingError(f"No QtCore could be found. Tried: {VALID_APIS.values()}")
|
||||
|
||||
# load variables based on what we found.
|
||||
if not _QtCore.__package__:
|
||||
raise RuntimeError("QtCore does not declare __package__?")
|
||||
|
||||
API_NAME = _QtCore.__package__
|
||||
PYSIDE2 = API_NAME == "PySide2"
|
||||
PYSIDE6 = API_NAME == "PySide6"
|
||||
PYQT5 = API_NAME == "PyQt5"
|
||||
PYQT6 = API_NAME == "PyQt6"
|
||||
QT_VERSION = getattr(_QtCore, "QT_VERSION_STR", "") or getattr(_QtCore, "__version__")
|
||||
|
||||
# lastly, emit a warning if we ended up with an API other than the one requested
|
||||
if _requested_api and API_NAME != VALID_APIS[_requested_api]:
|
||||
warnings.warn(
|
||||
f"Selected binding {_requested_api!r} could not be found, using {API_NAME!r}"
|
||||
)
|
||||
|
||||
|
||||
# Setup the meta path finder that lets us import anything using `superqt.qtcompat.Mod`
|
||||
class SuperQtImporter(abc.MetaPathFinder):
|
||||
def find_spec(
|
||||
self,
|
||||
fullname: str,
|
||||
path: Optional[Sequence[Union[bytes, str]]],
|
||||
target: Optional[ModuleType] = None,
|
||||
) -> Optional[ModuleSpec]:
|
||||
"""Find a spec for the specified module.
|
||||
|
||||
If fullname is superqt.X or superqt.qtcompat.Xx ...
|
||||
it will look for API_NAME.X instead...
|
||||
|
||||
See https://docs.python.org/3/reference/import.html#the-meta-path
|
||||
"""
|
||||
if fullname.startswith(__name__):
|
||||
spec = fullname.replace(__name__, API_NAME)
|
||||
return util.find_spec(spec)
|
||||
return None
|
||||
|
||||
|
||||
def _get_qtmodule(mod_name: str) -> ModuleType:
|
||||
"""Convenience to get a submodule from the current QT_API"""
|
||||
_mod_name = mod_name.rsplit(".", maxsplit=1)[-1]
|
||||
return import_module(f"{API_NAME}.{_mod_name}")
|
||||
|
||||
|
||||
sys.meta_path.append(SuperQtImporter())
|
@@ -1,8 +1,7 @@
|
||||
from PyQt5.QtWidgets import QSlider
|
||||
from superqt.qtcompat.QtWidgets import QSlider
|
||||
|
||||
from ._generic_range_slider import _GenericRangeSlider
|
||||
from ._generic_slider import _GenericSlider
|
||||
from .qtcompat.QtWidgets import QSlider
|
||||
|
||||
class QDoubleRangeSlider(_GenericRangeSlider): ...
|
||||
class QDoubleSlider(_GenericSlider): ...
|
@@ -146,7 +146,7 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
|
||||
return super().setStyleSheet(styleSheet + override)
|
||||
|
||||
def event(self, ev: QEvent) -> bool:
|
||||
if ev.type() == QEvent.StyleChange:
|
||||
if ev.type() == QEvent.Type.StyleChange:
|
||||
update_styles_from_stylesheet(self)
|
||||
return super().event(ev)
|
||||
|
||||
@@ -225,7 +225,7 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
|
||||
thickness = self._style.thickness(opt)
|
||||
offset = self._style.offset(opt)
|
||||
|
||||
if opt.orientation == Qt.Horizontal:
|
||||
if opt.orientation == Qt.Orientation.Horizontal:
|
||||
r_bar.setTop(r_bar.center().y() - thickness / 2 + offset)
|
||||
r_bar.setHeight(thickness)
|
||||
r_bar.setLeft(hdl_low.center().x())
|
||||
@@ -261,9 +261,9 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
|
||||
opt.sliderPosition = pos
|
||||
# make pressed handles appear sunken
|
||||
if idx == pidx:
|
||||
opt.state |= QStyle.State_Sunken
|
||||
opt.state |= QStyle.StateFlag.State_Sunken
|
||||
else:
|
||||
opt.state = opt.state & ~QStyle.State_Sunken
|
||||
opt.state = opt.state & ~QStyle.StateFlag.State_Sunken
|
||||
opt.activeSubControls = SC_HANDLE if idx == hidx else SC_NONE
|
||||
painter.drawComplexControl(CC_SLIDER, opt)
|
||||
|
||||
@@ -314,11 +314,11 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
|
||||
return (SC_HANDLE, len(self._position) - 1)
|
||||
|
||||
def _execute_scroll(self, steps_to_scroll, modifiers):
|
||||
if modifiers & Qt.AltModifier:
|
||||
if modifiers & Qt.KeyboardModifier.AltModifier:
|
||||
self._spreadAllPositions(shrink=steps_to_scroll < 0)
|
||||
else:
|
||||
self._offsetAllPositions(steps_to_scroll)
|
||||
self.triggerAction(QSlider.SliderMove)
|
||||
self.triggerAction(QSlider.SliderAction.SliderMove)
|
||||
|
||||
def _has_scroll_space_left(self, offset):
|
||||
return (offset > 0 and max(self._value) < self._maximum) or (
|
@@ -74,7 +74,7 @@ class _GenericSlider(QSlider, Generic[_T]):
|
||||
self._control_fraction = 0.04
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
self.setAttribute(Qt.WA_Hover)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_Hover)
|
||||
|
||||
# ############### QtOverrides #######################
|
||||
|
||||
@@ -134,7 +134,7 @@ class _GenericSlider(QSlider, Generic[_T]):
|
||||
oldMax, self._maximum = self._maximum, float(max(min, max_))
|
||||
|
||||
if oldMin != self._minimum or oldMax != self._maximum:
|
||||
self.sliderChange(self.SliderRangeChange)
|
||||
self.sliderChange(self.SliderChange.SliderRangeChange)
|
||||
self.rangeChanged.emit(self._minimum, self._maximum)
|
||||
self.setValue(self._value) # re-bound
|
||||
|
||||
@@ -159,15 +159,18 @@ class _GenericSlider(QSlider, Generic[_T]):
|
||||
option.orientation = self.orientation()
|
||||
option.tickPosition = self.tickPosition()
|
||||
option.upsideDown = (
|
||||
self.invertedAppearance() != (option.direction == Qt.RightToLeft)
|
||||
if self.orientation() == Qt.Horizontal
|
||||
self.invertedAppearance()
|
||||
!= (option.direction == Qt.LayoutDirection.RightToLeft)
|
||||
if self.orientation() == Qt.Orientation.Horizontal
|
||||
else not self.invertedAppearance()
|
||||
)
|
||||
option.direction = Qt.LeftToRight # we use the upsideDown option instead
|
||||
option.direction = (
|
||||
Qt.LayoutDirection.LeftToRight
|
||||
) # we use the upsideDown option instead
|
||||
# option.sliderValue = self._value # type: ignore
|
||||
# option.singleStep = self._singleStep # type: ignore
|
||||
if self.orientation() == Qt.Horizontal:
|
||||
option.state |= QStyle.State_Horizontal
|
||||
if self.orientation() == Qt.Orientation.Horizontal:
|
||||
option.state |= QStyle.StateFlag.State_Horizontal
|
||||
|
||||
# scale style option to integer space
|
||||
option.minimum = 0
|
||||
@@ -178,11 +181,11 @@ class _GenericSlider(QSlider, Generic[_T]):
|
||||
self._fixStyleOption(option)
|
||||
|
||||
def event(self, ev: QEvent) -> bool:
|
||||
if ev.type() == QEvent.WindowActivate:
|
||||
if ev.type() == QEvent.Type.WindowActivate:
|
||||
self.update()
|
||||
elif ev.type() in (QEvent.HoverEnter, QEvent.HoverMove):
|
||||
elif ev.type() in (QEvent.Type.HoverEnter, QEvent.Type.HoverMove):
|
||||
self._updateHoverControl(_event_position(ev))
|
||||
elif ev.type() == QEvent.HoverLeave:
|
||||
elif ev.type() == QEvent.Type.HoverLeave:
|
||||
self._hoverControl = SC_NONE
|
||||
lastHoverRect, self._hoverRect = self._hoverRect, QRect()
|
||||
self.update(lastHoverRect)
|
||||
@@ -198,7 +201,7 @@ class _GenericSlider(QSlider, Generic[_T]):
|
||||
pos = _event_position(ev)
|
||||
|
||||
# If the mouse button used is allowed to set the value
|
||||
if ev.button() in (Qt.LeftButton, Qt.MiddleButton):
|
||||
if ev.button() in (Qt.MouseButton.LeftButton, Qt.MouseButton.MiddleButton):
|
||||
self._updatePressedControl(pos)
|
||||
if self._pressedControl == SC_HANDLE:
|
||||
opt = self._styleOption
|
||||
@@ -206,8 +209,8 @@ class _GenericSlider(QSlider, Generic[_T]):
|
||||
offset = sr.center() - sr.topLeft()
|
||||
new_pos = self._pixelPosToRangeValue(self._pick(pos - offset))
|
||||
self.setSliderPosition(new_pos)
|
||||
self.triggerAction(QSlider.SliderMove)
|
||||
self.setRepeatAction(QSlider.SliderNoAction)
|
||||
self.triggerAction(QSlider.SliderAction.SliderMove)
|
||||
self.setRepeatAction(QSlider.SliderAction.SliderNoAction)
|
||||
|
||||
self.update()
|
||||
# elif: deal with PageSetButtons
|
||||
@@ -215,7 +218,7 @@ class _GenericSlider(QSlider, Generic[_T]):
|
||||
ev.ignore()
|
||||
|
||||
if self._pressedControl != SC_NONE:
|
||||
self.setRepeatAction(QSlider.SliderNoAction)
|
||||
self.setRepeatAction(QSlider.SliderAction.SliderNoAction)
|
||||
self._setClickOffset(pos)
|
||||
self.update()
|
||||
self.setSliderDown(True)
|
||||
@@ -238,7 +241,7 @@ class _GenericSlider(QSlider, Generic[_T]):
|
||||
ev.accept()
|
||||
oldPressed = self._pressedControl
|
||||
self._pressedControl = SC_NONE
|
||||
self.setRepeatAction(QSlider.SliderNoAction)
|
||||
self.setRepeatAction(QSlider.SliderAction.SliderNoAction)
|
||||
if oldPressed != SC_NONE:
|
||||
self.setSliderDown(False)
|
||||
self.update()
|
||||
@@ -251,7 +254,7 @@ class _GenericSlider(QSlider, Generic[_T]):
|
||||
if e.inverted():
|
||||
delta *= -1
|
||||
|
||||
orientation = Qt.Vertical if vertical else Qt.Horizontal
|
||||
orientation = Qt.Orientation.Vertical if vertical else Qt.Orientation.Horizontal
|
||||
if self._scrollByDelta(orientation, e.modifiers(), delta):
|
||||
e.accept()
|
||||
|
||||
@@ -261,7 +264,7 @@ class _GenericSlider(QSlider, Generic[_T]):
|
||||
|
||||
# draw groove and ticks
|
||||
opt.subControls = SC_GROOVE
|
||||
if opt.tickPosition != QSlider.NoTicks:
|
||||
if opt.tickPosition != QSlider.TickPosition.NoTicks:
|
||||
opt.subControls |= SC_TICKMARKS
|
||||
painter.drawComplexControl(CC_SLIDER, opt)
|
||||
|
||||
@@ -287,12 +290,12 @@ class _GenericSlider(QSlider, Generic[_T]):
|
||||
return int(min(QOVERFLOW, val / (self._maximum - self._minimum) * _max))
|
||||
|
||||
def _pick(self, pt: QPoint) -> int:
|
||||
return pt.x() if self.orientation() == Qt.Horizontal else pt.y()
|
||||
return pt.x() if self.orientation() == Qt.Orientation.Horizontal else pt.y()
|
||||
|
||||
def _setSteps(self, single: float, page: float):
|
||||
self._singleStep = single
|
||||
self._pageStep = page
|
||||
self.sliderChange(QSlider.SliderStepsChange)
|
||||
self.sliderChange(QSlider.SliderChange.SliderStepsChange)
|
||||
|
||||
def _doSliderMove(self):
|
||||
if not self.hasTracking():
|
||||
@@ -300,7 +303,7 @@ class _GenericSlider(QSlider, Generic[_T]):
|
||||
if self.isSliderDown():
|
||||
self.sliderMoved.emit(self.sliderPosition())
|
||||
if self.hasTracking() and not self._blocktracking:
|
||||
self.triggerAction(QSlider.SliderMove)
|
||||
self.triggerAction(QSlider.SliderAction.SliderMove)
|
||||
|
||||
@property
|
||||
def _styleOption(self):
|
||||
@@ -311,7 +314,7 @@ class _GenericSlider(QSlider, Generic[_T]):
|
||||
def _updateHoverControl(self, pos: QPoint) -> bool:
|
||||
lastHoverRect = self._hoverRect
|
||||
lastHoverControl = self._hoverControl
|
||||
doesHover = self.testAttribute(Qt.WA_Hover)
|
||||
doesHover = self.testAttribute(Qt.WidgetAttribute.WA_Hover)
|
||||
if lastHoverControl != self._newHoverControl(pos) and doesHover:
|
||||
self.update(lastHoverRect)
|
||||
self.update(self._hoverRect)
|
||||
@@ -351,7 +354,7 @@ class _GenericSlider(QSlider, Generic[_T]):
|
||||
opt.subControls = SC_HANDLE
|
||||
if self._pressedControl:
|
||||
opt.activeSubControls = self._pressedControl
|
||||
opt.state |= QStyle.State_Sunken
|
||||
opt.state |= QStyle.StateFlag.State_Sunken
|
||||
else:
|
||||
opt.activeSubControls = self._hoverControl
|
||||
|
||||
@@ -364,7 +367,7 @@ class _GenericSlider(QSlider, Generic[_T]):
|
||||
gr = self.style().subControlRect(CC_SLIDER, opt, SC_GROOVE, self)
|
||||
sr = self.style().subControlRect(CC_SLIDER, opt, SC_HANDLE, self)
|
||||
|
||||
if self.orientation() == Qt.Horizontal:
|
||||
if self.orientation() == Qt.Orientation.Horizontal:
|
||||
sliderLength = sr.width()
|
||||
sliderMin = gr.x()
|
||||
sliderMax = gr.right() - sliderLength + 1
|
||||
@@ -385,14 +388,14 @@ class _GenericSlider(QSlider, Generic[_T]):
|
||||
pg_step = self._pageStep
|
||||
|
||||
# in Qt scrolling to the right gives negative values.
|
||||
if orientation == Qt.Horizontal:
|
||||
if orientation == Qt.Orientation.Horizontal:
|
||||
delta *= -1
|
||||
offset = delta / 120
|
||||
if modifiers & Qt.ShiftModifier:
|
||||
if modifiers & Qt.KeyboardModifier.ShiftModifier:
|
||||
# Scroll one page regardless of delta:
|
||||
steps_to_scroll = max(-pg_step, min(pg_step, offset * pg_step))
|
||||
self._offset_accum = 0
|
||||
elif modifiers & Qt.ControlModifier:
|
||||
elif modifiers & Qt.KeyboardModifier.ControlModifier:
|
||||
_range = self._maximum - self._minimum
|
||||
steps_to_scroll = offset * _range * self._control_fraction
|
||||
self._offset_accum = 0
|
||||
@@ -440,7 +443,7 @@ class _GenericSlider(QSlider, Generic[_T]):
|
||||
|
||||
def _execute_scroll(self, steps_to_scroll, modifiers):
|
||||
self._setPosition(self._bound(self._overflowSafeAdd(steps_to_scroll)))
|
||||
self.triggerAction(QSlider.SliderMove)
|
||||
self.triggerAction(QSlider.SliderAction.SliderMove)
|
||||
|
||||
def _effectiveSingleStep(self) -> float:
|
||||
return self._singleStep * self._repeatMultiplier
|
@@ -93,7 +93,7 @@ class _SliderProxy:
|
||||
|
||||
def _handle_overloaded_slider_sig(args, kwargs):
|
||||
parent = None
|
||||
orientation = Qt.Vertical
|
||||
orientation = Qt.Orientation.Vertical
|
||||
errmsg = (
|
||||
"TypeError: arguments did not match any overloaded call:\n"
|
||||
" QSlider(parent: QWidget = None)\n"
|
||||
@@ -137,17 +137,17 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
|
||||
def setOrientation(self, orientation):
|
||||
"""Set orientation, value will be 'horizontal' or 'vertical'."""
|
||||
self._slider.setOrientation(orientation)
|
||||
if orientation == Qt.Vertical:
|
||||
if orientation == Qt.Orientation.Vertical:
|
||||
layout = QVBoxLayout()
|
||||
layout.addWidget(self._slider, alignment=Qt.AlignHCenter)
|
||||
layout.addWidget(self._label, alignment=Qt.AlignHCenter)
|
||||
self._label.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(self._slider, alignment=Qt.AlignmentFlag.AlignHCenter)
|
||||
layout.addWidget(self._label, alignment=Qt.AlignmentFlag.AlignHCenter)
|
||||
self._label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.setSpacing(1)
|
||||
else:
|
||||
layout = QHBoxLayout()
|
||||
layout.addWidget(self._slider)
|
||||
layout.addWidget(self._label)
|
||||
self._label.setAlignment(Qt.AlignRight)
|
||||
self._label.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
layout.setSpacing(6)
|
||||
|
||||
old_layout = self.layout()
|
||||
@@ -185,7 +185,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
parent, orientation = _handle_overloaded_slider_sig(args, kwargs)
|
||||
super().__init__(parent)
|
||||
self.setAttribute(Qt.WA_ShowWithoutActivating)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
|
||||
self._handle_labels = []
|
||||
self._handle_label_position: LabelPosition = LabelPosition.LabelsAbove
|
||||
|
||||
@@ -198,10 +198,14 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
||||
self._slider.rangeChanged.connect(self.rangeChanged.emit)
|
||||
|
||||
self._min_label = SliderLabel(
|
||||
self._slider, alignment=Qt.AlignLeft, connect=self._min_label_edited
|
||||
self._slider,
|
||||
alignment=Qt.AlignmentFlag.AlignLeft,
|
||||
connect=self._min_label_edited,
|
||||
)
|
||||
self._max_label = SliderLabel(
|
||||
self._slider, alignment=Qt.AlignRight, connect=self._max_label_edited
|
||||
self._slider,
|
||||
alignment=Qt.AlignmentFlag.AlignRight,
|
||||
connect=self._max_label_edited,
|
||||
)
|
||||
self.setEdgeLabelMode(EdgeLabelMode.LabelIsRange)
|
||||
|
||||
@@ -252,7 +256,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
||||
if not self._handle_labels:
|
||||
return
|
||||
|
||||
horizontal = self.orientation() == Qt.Horizontal
|
||||
horizontal = self.orientation() == Qt.Orientation.Horizontal
|
||||
labels_above = self._handle_label_position == LabelPosition.LabelsAbove
|
||||
|
||||
last_edge = None
|
||||
@@ -342,7 +346,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
||||
"""Set orientation, value will be 'horizontal' or 'vertical'."""
|
||||
|
||||
self._slider.setOrientation(orientation)
|
||||
if orientation == Qt.Vertical:
|
||||
if orientation == Qt.Orientation.Vertical:
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(1)
|
||||
layout.addWidget(self._max_label)
|
||||
@@ -355,7 +359,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
||||
marg = (0, 0, 0, 0)
|
||||
else:
|
||||
marg = (0, 0, 20, 0)
|
||||
layout.setAlignment(Qt.AlignCenter)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
else:
|
||||
layout = QHBoxLayout()
|
||||
layout.setSpacing(7)
|
||||
@@ -406,18 +410,22 @@ class QLabeledDoubleRangeSlider(QLabeledRangeSlider):
|
||||
|
||||
class SliderLabel(QDoubleSpinBox):
|
||||
def __init__(
|
||||
self, slider: QSlider, parent=None, alignment=Qt.AlignCenter, connect=None
|
||||
self,
|
||||
slider: QSlider,
|
||||
parent=None,
|
||||
alignment=Qt.AlignmentFlag.AlignCenter,
|
||||
connect=None,
|
||||
) -> None:
|
||||
super().__init__(parent=parent)
|
||||
self._slider = slider
|
||||
self.setFocusPolicy(Qt.ClickFocus)
|
||||
self.setFocusPolicy(Qt.FocusPolicy.ClickFocus)
|
||||
self.setMode(EdgeLabelMode.LabelIsValue)
|
||||
self.setDecimals(0)
|
||||
|
||||
self.setRange(slider.minimum(), slider.maximum())
|
||||
slider.rangeChanged.connect(self._update_size)
|
||||
self.setAlignment(alignment)
|
||||
self.setButtonSymbols(QSpinBox.NoButtons)
|
||||
self.setButtonSymbols(QSpinBox.ButtonSymbols.NoButtons)
|
||||
self.setStyleSheet("background:transparent; border: 0;")
|
||||
if connect is not None:
|
||||
self.editingFinished.connect(lambda: connect(self.value()))
|
||||
@@ -449,7 +457,9 @@ class SliderLabel(QDoubleSpinBox):
|
||||
# get the final size hint
|
||||
opt = QStyleOptionSpinBox()
|
||||
self.initStyleOption(opt)
|
||||
size = self.style().sizeFromContents(QStyle.CT_SpinBox, opt, QSize(w, h), self)
|
||||
size = self.style().sizeFromContents(
|
||||
QStyle.ContentsType.CT_SpinBox, opt, QSize(w, h), self
|
||||
)
|
||||
self.setFixedSize(size)
|
||||
|
||||
def setValue(self, val):
|
@@ -5,7 +5,7 @@ import re
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..qtcompat import PYQT_VERSION
|
||||
from ..qtcompat import QT_VERSION
|
||||
from ..qtcompat.QtCore import Qt
|
||||
from ..qtcompat.QtGui import (
|
||||
QBrush,
|
||||
@@ -40,9 +40,9 @@ class RangeSliderStyle:
|
||||
def brush(self, opt: QStyleOptionSlider) -> QBrush:
|
||||
cg = opt.palette.currentColorGroup()
|
||||
attr = {
|
||||
QPalette.Active: "brush_active", # 0
|
||||
QPalette.Disabled: "brush_disabled", # 1
|
||||
QPalette.Inactive: "brush_inactive", # 2
|
||||
QPalette.ColorGroup.Active: "brush_active", # 0
|
||||
QPalette.ColorGroup.Disabled: "brush_disabled", # 1
|
||||
QPalette.ColorGroup.Inactive: "brush_inactive", # 2
|
||||
}[cg]
|
||||
_val = getattr(self, attr)
|
||||
if not _val:
|
||||
@@ -67,7 +67,7 @@ class RangeSliderStyle:
|
||||
else:
|
||||
val = _val
|
||||
|
||||
if opt.tickPosition != QSlider.NoTicks:
|
||||
if opt.tickPosition != QSlider.TickPosition.NoTicks:
|
||||
val.setAlphaF(self.tick_bar_alpha or SYSTEM_STYLE.tick_bar_alpha)
|
||||
|
||||
return QBrush(val)
|
||||
@@ -75,16 +75,16 @@ class RangeSliderStyle:
|
||||
def pen(self, opt: QStyleOptionSlider) -> Qt.PenStyle | QColor:
|
||||
cg = opt.palette.currentColorGroup()
|
||||
attr = {
|
||||
QPalette.Active: "pen_active", # 0
|
||||
QPalette.Disabled: "pen_disabled", # 1
|
||||
QPalette.Inactive: "pen_inactive", # 2
|
||||
QPalette.ColorGroup.Active: "pen_active", # 0
|
||||
QPalette.ColorGroup.Disabled: "pen_disabled", # 1
|
||||
QPalette.ColorGroup.Inactive: "pen_inactive", # 2
|
||||
}[cg]
|
||||
val = getattr(self, attr) or getattr(SYSTEM_STYLE, attr)
|
||||
if not val:
|
||||
return Qt.NoPen
|
||||
return Qt.PenStyle.NoPen
|
||||
if isinstance(val, str):
|
||||
val = QColor(val)
|
||||
if opt.tickPosition != QSlider.NoTicks:
|
||||
if opt.tickPosition != QSlider.TickPosition.NoTicks:
|
||||
val.setAlphaF(self.tick_bar_alpha or SYSTEM_STYLE.tick_bar_alpha)
|
||||
|
||||
return val
|
||||
@@ -93,18 +93,18 @@ class RangeSliderStyle:
|
||||
tp = opt.tickPosition
|
||||
off = 0
|
||||
if not self.has_stylesheet:
|
||||
if opt.orientation == Qt.Horizontal:
|
||||
if opt.orientation == Qt.Orientation.Horizontal:
|
||||
off += self.h_offset or SYSTEM_STYLE.h_offset or 0
|
||||
else:
|
||||
off += self.v_offset or SYSTEM_STYLE.v_offset or 0
|
||||
if tp == QSlider.TicksAbove:
|
||||
if tp == QSlider.TickPosition.TicksAbove:
|
||||
off += self.tick_offset or SYSTEM_STYLE.tick_offset
|
||||
elif tp == QSlider.TicksBelow:
|
||||
elif tp == QSlider.TickPosition.TicksBelow:
|
||||
off -= self.tick_offset or SYSTEM_STYLE.tick_offset
|
||||
return off
|
||||
|
||||
def thickness(self, opt: QStyleOptionSlider) -> float:
|
||||
if opt.orientation == Qt.Horizontal:
|
||||
if opt.orientation == Qt.Orientation.Horizontal:
|
||||
return self.horizontal_thickness or SYSTEM_STYLE.horizontal_thickness
|
||||
else:
|
||||
return self.vertical_thickness or SYSTEM_STYLE.vertical_thickness
|
||||
@@ -139,7 +139,7 @@ CATALINA_STYLE = replace(
|
||||
tick_offset=4,
|
||||
)
|
||||
|
||||
if PYQT_VERSION and int(PYQT_VERSION.split(".")[0]) == 6:
|
||||
if QT_VERSION and int(QT_VERSION.split(".")[0]) == 6:
|
||||
CATALINA_STYLE = replace(CATALINA_STYLE, tick_offset=2)
|
||||
|
||||
BIG_SUR_STYLE = replace(
|
||||
@@ -154,7 +154,7 @@ BIG_SUR_STYLE = replace(
|
||||
tick_bar_alpha=0.2,
|
||||
)
|
||||
|
||||
if PYQT_VERSION and int(PYQT_VERSION.split(".")[0]) == 6:
|
||||
if QT_VERSION and int(QT_VERSION.split(".")[0]) == 6:
|
||||
BIG_SUR_STYLE = replace(BIG_SUR_STYLE, tick_offset=-3)
|
||||
|
||||
WINDOWS_STYLE = replace(
|
@@ -93,7 +93,7 @@ class QLargeIntSpinBox(QAbstractSpinBox):
|
||||
return super().closeEvent(e)
|
||||
|
||||
def keyPressEvent(self, e) -> None:
|
||||
if e.key() in (Qt.Key_Enter, Qt.Key_Return):
|
||||
if e.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return):
|
||||
self._interpret(
|
||||
_EmitPolicy.AlwaysEmit
|
||||
if self.keyboardTracking()
|
||||
@@ -112,13 +112,13 @@ class QLargeIntSpinBox(QAbstractSpinBox):
|
||||
self._setValue(self._bound(self._value + (step * steps)), e)
|
||||
|
||||
def stepEnabled(self):
|
||||
flags = QAbstractSpinBox.StepNone
|
||||
flags = QAbstractSpinBox.StepEnabledFlag.StepNone
|
||||
if self.isReadOnly():
|
||||
return flags
|
||||
if self._value < self._maximum:
|
||||
flags |= QAbstractSpinBox.StepUpEnabled
|
||||
flags |= QAbstractSpinBox.StepEnabledFlag.StepUpEnabled
|
||||
if self._value > self._minimum:
|
||||
flags |= QAbstractSpinBox.StepDownEnabled
|
||||
flags |= QAbstractSpinBox.StepEnabledFlag.StepDownEnabled
|
||||
return flags
|
||||
|
||||
def sizeHint(self):
|
||||
@@ -134,7 +134,9 @@ class QLargeIntSpinBox(QAbstractSpinBox):
|
||||
opt = QStyleOptionSpinBox()
|
||||
self.initStyleOption(opt)
|
||||
hint = QSize(w, h)
|
||||
return self.style().sizeFromContents(QStyle.CT_SpinBox, opt, hint, self)
|
||||
return self.style().sizeFromContents(
|
||||
QStyle.ContentsType.CT_SpinBox, opt, hint, self
|
||||
)
|
||||
|
||||
# ############### Implementation Details #######################
|
||||
|
22
src/superqt/utils/__init__.py
Normal file
22
src/superqt/utils/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
__all__ = (
|
||||
"create_worker",
|
||||
"ensure_main_thread",
|
||||
"ensure_object_thread",
|
||||
"FunctionWorker",
|
||||
"GeneratorWorker",
|
||||
"new_worker_qthread",
|
||||
"QMessageHandler",
|
||||
"thread_worker",
|
||||
"WorkerBase",
|
||||
)
|
||||
|
||||
from ._ensure_thread import ensure_main_thread, ensure_object_thread
|
||||
from ._message_handler import QMessageHandler
|
||||
from ._qthreading import (
|
||||
FunctionWorker,
|
||||
GeneratorWorker,
|
||||
WorkerBase,
|
||||
create_worker,
|
||||
new_worker_qthread,
|
||||
thread_worker,
|
||||
)
|
125
src/superqt/utils/_ensure_thread.py
Normal file
125
src/superqt/utils/_ensure_thread.py
Normal file
@@ -0,0 +1,125 @@
|
||||
# https://gist.github.com/FlorianRhiem/41a1ad9b694c14fb9ac3
|
||||
from concurrent.futures import Future
|
||||
from functools import wraps
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
from superqt.qtcompat.QtCore import (
|
||||
QCoreApplication,
|
||||
QMetaObject,
|
||||
QObject,
|
||||
Qt,
|
||||
QThread,
|
||||
Signal,
|
||||
Slot,
|
||||
)
|
||||
|
||||
|
||||
class CallCallable(QObject):
|
||||
finished = Signal(object)
|
||||
instances: List["CallCallable"] = []
|
||||
|
||||
def __init__(self, callable, *args, **kwargs):
|
||||
super().__init__()
|
||||
self._callable = callable
|
||||
self._args = args
|
||||
self._kwargs = kwargs
|
||||
CallCallable.instances.append(self)
|
||||
|
||||
@Slot()
|
||||
def call(self):
|
||||
CallCallable.instances.remove(self)
|
||||
res = self._callable(*self._args, **self._kwargs)
|
||||
self.finished.emit(res)
|
||||
|
||||
|
||||
def ensure_main_thread(
|
||||
func: Optional[Callable] = None, await_return: bool = False, timeout: int = 1000
|
||||
):
|
||||
"""Decorator that ensures a function is called in the main QApplication thread.
|
||||
|
||||
It can be applied to functions or methods.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
func : callable
|
||||
The method to decorate, must be a method on a QObject.
|
||||
await_return : bool, optional
|
||||
Whether to block and wait for the result of the function, or return immediately.
|
||||
by default False
|
||||
timeout : int, optional
|
||||
If `await_return` is `True`, time (in milliseconds) to wait for the result
|
||||
before raising a TimeoutError, by default 1000
|
||||
"""
|
||||
|
||||
def _out_func(func_):
|
||||
@wraps(func_)
|
||||
def _func(*args, **kwargs):
|
||||
return _run_in_thread(
|
||||
func_,
|
||||
QCoreApplication.instance().thread(),
|
||||
await_return,
|
||||
timeout,
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
return _func
|
||||
|
||||
if func is None:
|
||||
return _out_func
|
||||
return _out_func(func)
|
||||
|
||||
|
||||
def ensure_object_thread(
|
||||
func: Optional[Callable] = None, await_return: bool = False, timeout: int = 1000
|
||||
):
|
||||
"""Decorator that ensures a QObject method is called in the object's thread.
|
||||
|
||||
It must be applied to methods of QObjects subclasses.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
func : callable
|
||||
The method to decorate, must be a method on a QObject.
|
||||
await_return : bool, optional
|
||||
Whether to block and wait for the result of the function, or return immediately.
|
||||
by default False
|
||||
timeout : int, optional
|
||||
If `await_return` is `True`, time (in milliseconds) to wait for the result
|
||||
before raising a TimeoutError, by default 1000
|
||||
"""
|
||||
|
||||
def _out_func(func_):
|
||||
@wraps(func_)
|
||||
def _func(self, *args, **kwargs):
|
||||
return _run_in_thread(
|
||||
func_, self.thread(), await_return, timeout, self, *args, **kwargs
|
||||
)
|
||||
|
||||
return _func
|
||||
|
||||
if func is None:
|
||||
return _out_func
|
||||
return _out_func(func)
|
||||
|
||||
|
||||
def _run_in_thread(
|
||||
func: Callable,
|
||||
thread: QThread,
|
||||
await_return: bool,
|
||||
timeout: int,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
future = Future() # type: ignore
|
||||
if thread is QThread.currentThread():
|
||||
result = func(*args, **kwargs)
|
||||
if not await_return:
|
||||
future.set_result(result)
|
||||
return future
|
||||
return result
|
||||
f = CallCallable(func, *args, **kwargs)
|
||||
f.moveToThread(thread)
|
||||
f.finished.connect(future.set_result, Qt.ConnectionType.DirectConnection)
|
||||
QMetaObject.invokeMethod(f, "call", Qt.ConnectionType.QueuedConnection) # type: ignore
|
||||
return future.result(timeout=timeout / 1000) if await_return else future
|
52
src/superqt/utils/_ensure_thread.pyi
Normal file
52
src/superqt/utils/_ensure_thread.pyi
Normal file
@@ -0,0 +1,52 @@
|
||||
from concurrent.futures import Future
|
||||
from typing import Callable, TypeVar, overload
|
||||
|
||||
from typing_extensions import Literal, ParamSpec
|
||||
|
||||
P = ParamSpec("P")
|
||||
R = TypeVar("R")
|
||||
|
||||
@overload
|
||||
def ensure_main_thread(
|
||||
await_return: Literal[True],
|
||||
timeout: int = 1000,
|
||||
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
|
||||
@overload
|
||||
def ensure_main_thread(
|
||||
func: Callable[P, R],
|
||||
await_return: Literal[True],
|
||||
timeout: int = 1000,
|
||||
) -> Callable[P, R]: ...
|
||||
@overload
|
||||
def ensure_main_thread(
|
||||
await_return: Literal[False] = False,
|
||||
timeout: int = 1000,
|
||||
) -> Callable[[Callable[P, R]], Callable[P, Future[R]]]: ...
|
||||
@overload
|
||||
def ensure_main_thread(
|
||||
func: Callable[P, R],
|
||||
await_return: Literal[False] = False,
|
||||
timeout: int = 1000,
|
||||
) -> Callable[P, Future[R]]: ...
|
||||
@overload
|
||||
def ensure_object_thread(
|
||||
await_return: Literal[True],
|
||||
timeout: int = 1000,
|
||||
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
|
||||
@overload
|
||||
def ensure_object_thread(
|
||||
func: Callable[P, R],
|
||||
await_return: Literal[True],
|
||||
timeout: int = 1000,
|
||||
) -> Callable[P, R]: ...
|
||||
@overload
|
||||
def ensure_object_thread(
|
||||
await_return: Literal[False] = False,
|
||||
timeout: int = 1000,
|
||||
) -> Callable[[Callable[P, R]], Callable[P, Future[R]]]: ...
|
||||
@overload
|
||||
def ensure_object_thread(
|
||||
func: Callable[P, R],
|
||||
await_return: Literal[False] = False,
|
||||
timeout: int = 1000,
|
||||
) -> Callable[P, Future[R]]: ...
|
98
src/superqt/utils/_message_handler.py
Normal file
98
src/superqt/utils/_message_handler.py
Normal file
@@ -0,0 +1,98 @@
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
from typing import List, NamedTuple, Optional
|
||||
|
||||
from superqt.qtcompat.QtCore import (
|
||||
QMessageLogContext,
|
||||
QtMsgType,
|
||||
qInstallMessageHandler,
|
||||
)
|
||||
|
||||
|
||||
class Record(NamedTuple):
|
||||
level: int
|
||||
message: str
|
||||
ctx: dict
|
||||
|
||||
|
||||
class QMessageHandler:
|
||||
"""A context manager to intercept messages from Qt.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
logger : logging.Logger, optional
|
||||
If provided, intercepted messages will be logged with `logger` at the
|
||||
corresponding python log level, by default None
|
||||
|
||||
Attributes
|
||||
----------
|
||||
records: list of tuple
|
||||
Captured messages. This is a 3-tuple of:
|
||||
`(log_level: int, message: str, context: dict)`
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
>>> handler = QMessageHandler()
|
||||
>>> handler.install() # now all Qt output will be available at mh.records
|
||||
|
||||
>>> with QMessageHandler() as handler: # temporarily install
|
||||
... ...
|
||||
|
||||
>>> logger = logging.getLogger(__name__)
|
||||
>>> with QMessageHandler(logger): # re-reoute Qt messages to a python logger.
|
||||
... ...
|
||||
"""
|
||||
|
||||
_qt2loggertype = {
|
||||
QtMsgType.QtDebugMsg: logging.DEBUG,
|
||||
QtMsgType.QtInfoMsg: logging.INFO,
|
||||
QtMsgType.QtWarningMsg: logging.WARNING,
|
||||
QtMsgType.QtCriticalMsg: logging.ERROR, # note
|
||||
QtMsgType.QtFatalMsg: logging.CRITICAL, # note
|
||||
QtMsgType.QtSystemMsg: logging.CRITICAL,
|
||||
}
|
||||
|
||||
def __init__(self, logger: Optional[logging.Logger] = None):
|
||||
self.records: List[Record] = []
|
||||
self._logger = logger
|
||||
self._previous_handler: Optional[object] = "__uninstalled__"
|
||||
|
||||
def install(self):
|
||||
"""Install this handler (override the current QtMessageHandler)."""
|
||||
self._previous_handler = qInstallMessageHandler(self)
|
||||
|
||||
def uninstall(self):
|
||||
"""Uninstall this handler, restoring the previous handler."""
|
||||
if self._previous_handler != "__uninstalled__":
|
||||
qInstallMessageHandler(self._previous_handler)
|
||||
|
||||
def __repr__(self):
|
||||
n = type(self).__name__
|
||||
return f"<{n} object at {hex(id(self))} with {len(self.records)} records>"
|
||||
|
||||
def __enter__(self):
|
||||
"""Enter a context with this handler installed"""
|
||||
self.install()
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.uninstall()
|
||||
|
||||
def __call__(self, msgtype: QtMsgType, context: QMessageLogContext, message: str):
|
||||
level = self._qt2loggertype[msgtype]
|
||||
|
||||
# PyQt seems to throw an error if these are simply empty
|
||||
ctx = dict.fromkeys(["category", "file", "function", "line"])
|
||||
with suppress(UnicodeDecodeError):
|
||||
ctx["category"] = context.category
|
||||
with suppress(UnicodeDecodeError):
|
||||
ctx["file"] = context.file
|
||||
with suppress(UnicodeDecodeError):
|
||||
ctx["function"] = context.function
|
||||
with suppress(UnicodeDecodeError):
|
||||
ctx["line"] = context.line
|
||||
|
||||
self.records.append(Record(level, message, ctx))
|
||||
if self._logger is not None:
|
||||
self._logger.log(level, message, extra=ctx)
|
899
src/superqt/utils/_qthreading.py
Normal file
899
src/superqt/utils/_qthreading.py
Normal file
@@ -0,0 +1,899 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import time
|
||||
import warnings
|
||||
from functools import partial, wraps
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
Generator,
|
||||
Generic,
|
||||
Optional,
|
||||
Sequence,
|
||||
Set,
|
||||
Type,
|
||||
TypeVar,
|
||||
Union,
|
||||
overload,
|
||||
)
|
||||
|
||||
from typing_extensions import Literal, ParamSpec
|
||||
|
||||
from ..qtcompat.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: Optional[type] = ...) -> None:
|
||||
...
|
||||
|
||||
@staticmethod
|
||||
def disconnect(slot: Callable[[_T], Any] = ...) -> None:
|
||||
...
|
||||
|
||||
@staticmethod
|
||||
def emit(*args: _T) -> None:
|
||||
...
|
||||
|
||||
|
||||
_Y = TypeVar("_Y")
|
||||
_S = TypeVar("_S")
|
||||
_R = TypeVar("_R")
|
||||
_P = ParamSpec("_P")
|
||||
|
||||
|
||||
def as_generator_function(
|
||||
func: Callable[_P, _R]
|
||||
) -> Callable[_P, Generator[None, None, _R]]:
|
||||
"""Turns a regular function (single return) into a generator function."""
|
||||
|
||||
@wraps(func)
|
||||
def genwrapper(*args, **kwargs) -> Generator[None, None, _R]:
|
||||
yield
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return genwrapper
|
||||
|
||||
|
||||
class WorkerBaseSignals(QObject):
|
||||
|
||||
started = Signal() # emitted when the work is started
|
||||
finished = Signal() # emitted when the work is finished
|
||||
_finished = Signal(object) # emitted when the work is finished ro delete
|
||||
returned = Signal(object) # emitted with return value
|
||||
errored = Signal(object) # emitted with error object on Exception
|
||||
warned = Signal(tuple) # emitted with showwarning args on warning
|
||||
|
||||
|
||||
class WorkerBase(QRunnable, Generic[_R]):
|
||||
"""Base class for creating a Worker that can run in another thread.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
SignalsClass : type, optional
|
||||
A QObject subclass that contains signals, by default WorkerBaseSignals
|
||||
|
||||
Attributes
|
||||
----------
|
||||
signals: WorkerBaseSignals
|
||||
signal emitter object. To allow identify which worker thread emitted signal.
|
||||
"""
|
||||
|
||||
#: A set of Workers. Add to set using :meth:`WorkerBase.start`
|
||||
_worker_set: Set[WorkerBase] = set()
|
||||
returned: SigInst[_R]
|
||||
errored: SigInst[Exception]
|
||||
warned: SigInst[tuple]
|
||||
started: SigInst[None]
|
||||
finished: SigInst[None]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
func: Optional[Callable[_P, _R]] = None,
|
||||
SignalsClass: Type[WorkerBaseSignals] = WorkerBaseSignals,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._abort_requested = False
|
||||
self._running = False
|
||||
self.signals = SignalsClass()
|
||||
|
||||
def __getattr__(self, name: str) -> SigInst:
|
||||
"""Pass through attr requests to signals to simplify connection API.
|
||||
|
||||
The goal is to enable ``worker.yielded.connect`` instead of
|
||||
``worker.signals.yielded.connect``. Because multiple inheritance of Qt
|
||||
classes is not well supported in PyQt, we have to use composition here
|
||||
(signals are provided by QObjects, and QRunnable is not a QObject). So
|
||||
this passthrough allows us to connect to signals on the ``_signals``
|
||||
object.
|
||||
"""
|
||||
# the Signal object is actually a class attribute
|
||||
attr = getattr(self.signals.__class__, name, None)
|
||||
if isinstance(attr, Signal):
|
||||
# but what we need to connect to is the instantiated signal
|
||||
# (which is of type `SignalInstance` in PySide and
|
||||
# `pyqtBoundSignal` in PyQt)
|
||||
return getattr(self.signals, name)
|
||||
raise AttributeError(
|
||||
f"{self.__class__.__name__!r} object has no attribute {name!r}"
|
||||
)
|
||||
|
||||
def quit(self) -> None:
|
||||
"""Send a request to abort the worker.
|
||||
|
||||
.. note::
|
||||
|
||||
It is entirely up to subclasses to honor this method by checking
|
||||
``self.abort_requested`` periodically in their ``worker.work``
|
||||
method, and exiting if ``True``.
|
||||
"""
|
||||
self._abort_requested = True
|
||||
|
||||
@property
|
||||
def abort_requested(self) -> bool:
|
||||
"""Whether the worker has been requested to stop."""
|
||||
return self._abort_requested
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Whether the worker has been started"""
|
||||
return self._running
|
||||
|
||||
def run(self) -> None:
|
||||
"""Start the worker.
|
||||
|
||||
The end-user should never need to call this function.
|
||||
But it cannot be made private or renamed, since it is called by Qt.
|
||||
|
||||
The order of method calls when starting a worker is:
|
||||
|
||||
.. code-block:: none
|
||||
|
||||
calls QThreadPool.globalInstance().start(worker)
|
||||
| triggered by the QThreadPool.start() method
|
||||
| | called by worker.run
|
||||
| | |
|
||||
V V V
|
||||
worker.start -> worker.run -> worker.work
|
||||
|
||||
**This** is the function that actually gets called when calling
|
||||
:func:`QThreadPool.start(worker)`. It simply wraps the :meth:`work`
|
||||
method, and emits a few signals. Subclasses should NOT override this
|
||||
method (except with good reason), and instead should implement
|
||||
:meth:`work`.
|
||||
"""
|
||||
self.started.emit()
|
||||
self._running = True
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings("always")
|
||||
warnings.showwarning = lambda *w: self.warned.emit(w)
|
||||
result = self.work()
|
||||
if isinstance(result, Exception):
|
||||
if isinstance(result, RuntimeError):
|
||||
# The Worker object has likely been deleted.
|
||||
# A deleted wrapped C/C++ object may result in a runtime
|
||||
# error that will cause segfault if we try to do much other
|
||||
# than simply notify the user.
|
||||
warnings.warn(
|
||||
f"RuntimeError in aborted thread: {result}",
|
||||
RuntimeWarning,
|
||||
)
|
||||
return
|
||||
else:
|
||||
raise result
|
||||
if not self.abort_requested:
|
||||
self.returned.emit(result)
|
||||
except Exception as exc:
|
||||
self.errored.emit(exc)
|
||||
self._running = False
|
||||
self.finished.emit()
|
||||
self._finished.emit(self)
|
||||
|
||||
def work(self) -> Union[Exception, _R]:
|
||||
"""Main method to execute the worker.
|
||||
|
||||
The end-user should never need to call this function.
|
||||
But subclasses must implement this method (See
|
||||
:meth:`GeneratorFunction.work` for an example implementation).
|
||||
Minimally, it should check ``self.abort_requested`` periodically and
|
||||
exit if True.
|
||||
|
||||
Examples
|
||||
--------
|
||||
.. code-block:: python
|
||||
|
||||
class MyWorker(WorkerBase):
|
||||
|
||||
def work(self):
|
||||
i = 0
|
||||
while True:
|
||||
if self.abort_requested:
|
||||
self.aborted.emit()
|
||||
break
|
||||
i += 1
|
||||
if i > max_iters:
|
||||
break
|
||||
time.sleep(0.5)
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f'"{self.__class__.__name__}" failed to define work() method'
|
||||
)
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start this worker in a thread and add it to the global threadpool.
|
||||
|
||||
The order of method calls when starting a worker is:
|
||||
|
||||
.. code-block:: none
|
||||
|
||||
calls QThreadPool.globalInstance().start(worker)
|
||||
| triggered by the QThreadPool.start() method
|
||||
| | called by worker.run
|
||||
| | |
|
||||
V V V
|
||||
worker.start -> worker.run -> worker.work
|
||||
"""
|
||||
if self in self._worker_set:
|
||||
raise RuntimeError("This worker is already started!")
|
||||
|
||||
# This will raise a RunTimeError if the worker is already deleted
|
||||
repr(self)
|
||||
|
||||
self._worker_set.add(self)
|
||||
self._finished.connect(self._set_discard)
|
||||
start_ = partial(QThreadPool.globalInstance().start, self)
|
||||
QTimer.singleShot(10, start_)
|
||||
|
||||
@classmethod
|
||||
def _set_discard(cls, obj: WorkerBase) -> None:
|
||||
cls._worker_set.discard(obj)
|
||||
|
||||
@classmethod
|
||||
def await_workers(cls, msecs: int = None) -> None:
|
||||
"""Ask all workers to quit, and wait up to `msec` for quit.
|
||||
|
||||
Attempts to clean up all running workers by calling ``worker.quit()``
|
||||
method. Any workers in the ``WorkerBase._worker_set`` set will have this
|
||||
method.
|
||||
|
||||
By default, this function will block indefinitely, until worker threads
|
||||
finish. If a timeout is provided, a ``RuntimeError`` will be raised if
|
||||
the workers do not gracefully exit in the time requests, but the threads
|
||||
will NOT be killed. It is (currently) left to the user to use their OS
|
||||
to force-quit rogue threads.
|
||||
|
||||
.. important::
|
||||
|
||||
If the user does not put any yields in their function, and the function
|
||||
is super long, it will just hang... For instance, there's no graceful
|
||||
way to kill this thread in python:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
@thread_worker
|
||||
def ZZZzzz():
|
||||
time.sleep(10000000)
|
||||
|
||||
This is why it's always advisable to use a generator that periodically
|
||||
yields for long-running computations in another thread.
|
||||
|
||||
See `this stack-overflow post
|
||||
<https://stackoverflow.com/questions/323972/is-there-any-way-to-kill-a-thread>`_
|
||||
for a good discussion on the difficulty of killing a rogue python thread:
|
||||
|
||||
Parameters
|
||||
----------
|
||||
msecs : int, optional
|
||||
Waits up to msecs milliseconds for all threads to exit and removes all
|
||||
threads from the thread pool. If msecs is `None` (the default), the
|
||||
timeout is ignored (waits for the last thread to exit).
|
||||
|
||||
Raises
|
||||
------
|
||||
RuntimeError
|
||||
If a timeout is provided and workers do not quit successfully within
|
||||
the time allotted.
|
||||
"""
|
||||
for worker in cls._worker_set:
|
||||
worker.quit()
|
||||
|
||||
msecs = msecs if msecs is not None else -1
|
||||
if not QThreadPool.globalInstance().waitForDone(msecs):
|
||||
raise RuntimeError(
|
||||
f"Workers did not quit gracefully in the time allotted ({msecs} ms)"
|
||||
)
|
||||
|
||||
|
||||
class FunctionWorker(WorkerBase[_R]):
|
||||
"""QRunnable with signals that wraps a simple long-running function.
|
||||
|
||||
.. note::
|
||||
|
||||
``FunctionWorker`` does not provide a way to stop a very long-running
|
||||
function (e.g. ``time.sleep(10000)``). So whenever possible, it is
|
||||
better to implement your long running function as a generator that
|
||||
yields periodically, and use the :class:`GeneratorWorker` instead.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
func : Callable
|
||||
A function to call in another thread
|
||||
*args
|
||||
will be passed to the function
|
||||
**kwargs
|
||||
will be passed to the function
|
||||
|
||||
Raises
|
||||
------
|
||||
TypeError
|
||||
If ``func`` is a generator function and not a regular function.
|
||||
"""
|
||||
|
||||
def __init__(self, func: Callable[_P, _R], *args, **kwargs):
|
||||
if inspect.isgeneratorfunction(func):
|
||||
raise TypeError(
|
||||
f"Generator function {func} cannot be used with FunctionWorker, "
|
||||
"use GeneratorWorker instead",
|
||||
)
|
||||
super().__init__()
|
||||
|
||||
self._func = func
|
||||
self._args = args
|
||||
self._kwargs = kwargs
|
||||
|
||||
def work(self) -> _R:
|
||||
return self._func(*self._args, **self._kwargs)
|
||||
|
||||
|
||||
class GeneratorWorkerSignals(WorkerBaseSignals):
|
||||
|
||||
yielded = Signal(object) # emitted with yielded values (if generator used)
|
||||
paused = Signal() # emitted when a running job has successfully paused
|
||||
resumed = Signal() # emitted when a paused job has successfully resumed
|
||||
aborted = Signal() # emitted when a running job is successfully aborted
|
||||
|
||||
|
||||
class GeneratorWorker(WorkerBase, Generic[_Y, _S, _R]):
|
||||
"""QRunnable with signals that wraps a long-running generator.
|
||||
|
||||
Provides a convenient way to run a generator function in another thread,
|
||||
while allowing 2-way communication between threads, using plain-python
|
||||
generator syntax in the original function.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
func : callable
|
||||
The function being run in another thread. May be a generator function.
|
||||
SignalsClass : type, optional
|
||||
A QObject subclass that contains signals, by default
|
||||
GeneratorWorkerSignals
|
||||
*args
|
||||
Will be passed to func on instantiation
|
||||
**kwargs
|
||||
Will be passed to func on instantiation
|
||||
"""
|
||||
|
||||
yielded: SigInst[_Y]
|
||||
paused: SigInst[None]
|
||||
resumed: SigInst[None]
|
||||
aborted: SigInst[None]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
func: Callable[_P, Generator[_Y, Optional[_S], _R]],
|
||||
*args,
|
||||
SignalsClass: Type[WorkerBaseSignals] = GeneratorWorkerSignals,
|
||||
**kwargs,
|
||||
):
|
||||
if not inspect.isgeneratorfunction(func):
|
||||
raise TypeError(
|
||||
f"Regular function {func} cannot be used with GeneratorWorker, "
|
||||
"use FunctionWorker instead",
|
||||
)
|
||||
super().__init__(SignalsClass=SignalsClass)
|
||||
|
||||
self._gen = func(*args, **kwargs)
|
||||
self._incoming_value: Optional[_S] = None
|
||||
self._pause_requested = False
|
||||
self._resume_requested = False
|
||||
self._paused = False
|
||||
|
||||
# polling interval: ONLY relevant if the user paused a running worker
|
||||
self._pause_interval = 0.01
|
||||
self.pbar = None
|
||||
|
||||
def work(self) -> Union[Optional[_R], Exception]:
|
||||
"""Core event loop that calls the original function.
|
||||
|
||||
Enters a continual loop, yielding and returning from the original
|
||||
function. Checks for various events (quit, pause, resume, etc...).
|
||||
(To clarify: we are creating a rudimentary event loop here because
|
||||
there IS NO Qt event loop running in the other thread to hook into)
|
||||
"""
|
||||
while True:
|
||||
if self.abort_requested:
|
||||
self.aborted.emit()
|
||||
break
|
||||
if self._paused:
|
||||
if self._resume_requested:
|
||||
self._paused = False
|
||||
self._resume_requested = False
|
||||
self.resumed.emit()
|
||||
else:
|
||||
time.sleep(self._pause_interval)
|
||||
continue
|
||||
elif self._pause_requested:
|
||||
self._paused = True
|
||||
self._pause_requested = False
|
||||
self.paused.emit()
|
||||
continue
|
||||
try:
|
||||
input = self._next_value()
|
||||
output = self._gen.send(input)
|
||||
self.yielded.emit(output)
|
||||
except StopIteration as exc:
|
||||
return exc.value
|
||||
except RuntimeError as exc:
|
||||
# The worker has probably been deleted. warning will be
|
||||
# emitted in ``WorkerBase.run``
|
||||
return exc
|
||||
return None
|
||||
|
||||
def send(self, value: _S):
|
||||
"""Send a value into the function (if a generator was used)."""
|
||||
self._incoming_value = value
|
||||
|
||||
def _next_value(self) -> Optional[_S]:
|
||||
out = None
|
||||
if self._incoming_value is not None:
|
||||
out = self._incoming_value
|
||||
self._incoming_value = None
|
||||
return out
|
||||
|
||||
@property
|
||||
def is_paused(self) -> bool:
|
||||
"""Whether the worker is currently paused."""
|
||||
return self._paused
|
||||
|
||||
def toggle_pause(self) -> None:
|
||||
"""Request to pause the worker if playing or resume if paused."""
|
||||
if self.is_paused:
|
||||
self._resume_requested = True
|
||||
else:
|
||||
self._pause_requested = True
|
||||
|
||||
def pause(self) -> None:
|
||||
"""Request to pause the worker."""
|
||||
if not self.is_paused:
|
||||
self._pause_requested = True
|
||||
|
||||
def resume(self) -> None:
|
||||
"""Send a request to resume the worker."""
|
||||
if self.is_paused:
|
||||
self._resume_requested = True
|
||||
|
||||
|
||||
#############################################################################
|
||||
|
||||
# convenience functions for creating Worker instances
|
||||
|
||||
|
||||
@overload
|
||||
def create_worker(
|
||||
func: Callable[_P, Generator[_Y, _S, _R]],
|
||||
*args,
|
||||
_start_thread: Optional[bool] = None,
|
||||
_connect: Optional[Dict[str, Union[Callable, Sequence[Callable]]]] = None,
|
||||
_worker_class: Union[Type[GeneratorWorker], Type[FunctionWorker], None] = None,
|
||||
_ignore_errors: bool = False,
|
||||
**kwargs,
|
||||
) -> GeneratorWorker[_Y, _S, _R]:
|
||||
...
|
||||
|
||||
|
||||
@overload
|
||||
def create_worker(
|
||||
func: Callable[_P, _R],
|
||||
*args,
|
||||
_start_thread: Optional[bool] = None,
|
||||
_connect: Optional[Dict[str, Union[Callable, Sequence[Callable]]]] = None,
|
||||
_worker_class: Union[Type[GeneratorWorker], Type[FunctionWorker], None] = None,
|
||||
_ignore_errors: bool = False,
|
||||
**kwargs,
|
||||
) -> FunctionWorker[_R]:
|
||||
...
|
||||
|
||||
|
||||
def create_worker(
|
||||
func: Callable,
|
||||
*args,
|
||||
_start_thread: Optional[bool] = None,
|
||||
_connect: Optional[Dict[str, Union[Callable, Sequence[Callable]]]] = None,
|
||||
_worker_class: Union[Type[GeneratorWorker], Type[FunctionWorker], None] = None,
|
||||
_ignore_errors: bool = False,
|
||||
**kwargs,
|
||||
) -> Union[FunctionWorker, GeneratorWorker]:
|
||||
"""Convenience function to start a function in another thread.
|
||||
|
||||
By default, uses :class:`Worker`, but a custom ``WorkerBase`` subclass may
|
||||
be provided. If so, it must be a subclass of :class:`Worker`, which
|
||||
defines a standard set of signals and a run method.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
func : Callable
|
||||
The function to call in another thread.
|
||||
_start_thread : bool, optional
|
||||
Whether to immediaetly start the thread. If False, the returned worker
|
||||
must be manually started with ``worker.start()``. by default it will be
|
||||
``False`` if the ``_connect`` argument is ``None``, otherwise ``True``.
|
||||
_connect : Dict[str, Union[Callable, Sequence]], optional
|
||||
A mapping of ``"signal_name"`` -> ``callable`` or list of ``callable``:
|
||||
callback functions to connect to the various signals offered by the
|
||||
worker class. by default None
|
||||
_worker_class : type of GeneratorWorker or FunctionWorker, optional
|
||||
The :class`WorkerBase` to instantiate, by default
|
||||
:class:`FunctionWorker` will be used if ``func`` is a regular function,
|
||||
and :class:`GeneratorWorker` will be used if it is a generator.
|
||||
_ignore_errors : bool, optional
|
||||
If ``False`` (the default), errors raised in the other thread will be
|
||||
reraised in the main thread (makes debugging significantly easier).
|
||||
*args
|
||||
will be passed to ``func``
|
||||
**kwargs
|
||||
will be passed to ``func``
|
||||
|
||||
Returns
|
||||
-------
|
||||
worker : WorkerBase
|
||||
An instantiated worker. If ``_start_thread`` was ``False``, the worker
|
||||
will have a `.start()` method that can be used to start the thread.
|
||||
|
||||
Raises
|
||||
------
|
||||
TypeError
|
||||
If a worker_class is provided that is not a subclass of WorkerBase.
|
||||
TypeError
|
||||
If _connect is provided and is not a dict of ``{str: callable}``
|
||||
|
||||
Examples
|
||||
--------
|
||||
.. code-block:: python
|
||||
|
||||
def long_function(duration):
|
||||
import time
|
||||
time.sleep(duration)
|
||||
|
||||
worker = create_worker(long_function, 10)
|
||||
|
||||
"""
|
||||
worker: Union[FunctionWorker, GeneratorWorker]
|
||||
|
||||
if not _worker_class:
|
||||
if inspect.isgeneratorfunction(func):
|
||||
_worker_class = GeneratorWorker
|
||||
else:
|
||||
_worker_class = FunctionWorker
|
||||
|
||||
if not inspect.isclass(_worker_class) and issubclass(_worker_class, WorkerBase):
|
||||
raise TypeError(f"Worker {_worker_class} must be a subclass of WorkerBase")
|
||||
|
||||
worker = _worker_class(func, *args, **kwargs)
|
||||
|
||||
if _connect is not None:
|
||||
if not isinstance(_connect, dict):
|
||||
raise TypeError("The '_connect' argument must be a dict")
|
||||
|
||||
if _start_thread is None:
|
||||
_start_thread = True
|
||||
|
||||
for key, val in _connect.items():
|
||||
_val = val if isinstance(val, (tuple, list)) else [val]
|
||||
for v in _val:
|
||||
if not callable(v):
|
||||
raise TypeError(
|
||||
f"_connect[{key!r}] must be a function or sequence of functions"
|
||||
)
|
||||
getattr(worker, key).connect(v)
|
||||
|
||||
# if the user has not provided a default connection for the "errored"
|
||||
# signal... and they have not explicitly set ``ignore_errors=True``
|
||||
# Then rereaise any errors from the thread.
|
||||
if not _ignore_errors and not (_connect or {}).get("errored", False):
|
||||
|
||||
def reraise(e):
|
||||
raise e
|
||||
|
||||
worker.errored.connect(reraise)
|
||||
|
||||
if _start_thread:
|
||||
worker.start()
|
||||
return worker
|
||||
|
||||
|
||||
@overload
|
||||
def thread_worker(
|
||||
function: Callable[_P, Generator[_Y, _S, _R]],
|
||||
start_thread: Optional[bool] = None,
|
||||
connect: Optional[Dict[str, Union[Callable, Sequence[Callable]]]] = None,
|
||||
worker_class: Optional[Type[WorkerBase]] = None,
|
||||
ignore_errors: bool = False,
|
||||
) -> Callable[_P, GeneratorWorker[_Y, _S, _R]]:
|
||||
...
|
||||
|
||||
|
||||
@overload
|
||||
def thread_worker(
|
||||
function: Callable[_P, _R],
|
||||
start_thread: Optional[bool] = None,
|
||||
connect: Optional[Dict[str, Union[Callable, Sequence[Callable]]]] = None,
|
||||
worker_class: Optional[Type[WorkerBase]] = None,
|
||||
ignore_errors: bool = False,
|
||||
) -> Callable[_P, FunctionWorker[_R]]:
|
||||
...
|
||||
|
||||
|
||||
@overload
|
||||
def thread_worker(
|
||||
function: Literal[None] = None,
|
||||
start_thread: Optional[bool] = None,
|
||||
connect: Optional[Dict[str, Union[Callable, Sequence[Callable]]]] = None,
|
||||
worker_class: Optional[Type[WorkerBase]] = None,
|
||||
ignore_errors: bool = False,
|
||||
) -> Callable[[Callable], Callable[_P, Union[FunctionWorker, GeneratorWorker]]]:
|
||||
...
|
||||
|
||||
|
||||
def thread_worker(
|
||||
function: Optional[Callable] = None,
|
||||
start_thread: Optional[bool] = None,
|
||||
connect: Optional[Dict[str, Union[Callable, Sequence[Callable]]]] = None,
|
||||
worker_class: Optional[Type[WorkerBase]] = None,
|
||||
ignore_errors: bool = False,
|
||||
):
|
||||
"""Decorator that runs a function in a separate thread when called.
|
||||
|
||||
When called, the decorated function returns a :class:`WorkerBase`. See
|
||||
:func:`create_worker` for additional keyword arguments that can be used
|
||||
when calling the function.
|
||||
|
||||
The returned worker will have these signals:
|
||||
|
||||
- *started*: emitted when the work is started
|
||||
- *finished*: emitted when the work is finished
|
||||
- *returned*: emitted with return value
|
||||
- *errored*: emitted with error object on Exception
|
||||
|
||||
It will also have a ``worker.start()`` method that can be used to start
|
||||
execution of the function in another thread. (useful if you need to connect
|
||||
callbacks to signals prior to execution)
|
||||
|
||||
If the decorated function is a generator, the returned worker will also
|
||||
provide these signals:
|
||||
|
||||
- *yielded*: emitted with yielded values
|
||||
- *paused*: emitted when a running job has successfully paused
|
||||
- *resumed*: emitted when a paused job has successfully resumed
|
||||
- *aborted*: emitted when a running job is successfully aborted
|
||||
|
||||
And these methods:
|
||||
|
||||
- *quit*: ask the thread to quit
|
||||
- *toggle_paused*: toggle the running state of the thread.
|
||||
- *send*: send a value into the generator. (This requires that your
|
||||
decorator function uses the ``value = yield`` syntax)
|
||||
|
||||
Parameters
|
||||
----------
|
||||
function : callable
|
||||
Function to call in another thread. For communication between threads
|
||||
may be a generator function.
|
||||
start_thread : bool, optional
|
||||
Whether to immediaetly start the thread. If False, the returned worker
|
||||
must be manually started with ``worker.start()``. by default it will be
|
||||
``False`` if the ``_connect`` argument is ``None``, otherwise ``True``.
|
||||
connect : Dict[str, Union[Callable, Sequence]], optional
|
||||
A mapping of ``"signal_name"`` -> ``callable`` or list of ``callable``:
|
||||
callback functions to connect to the various signals offered by the
|
||||
worker class. by default None
|
||||
worker_class : Type[WorkerBase], optional
|
||||
The :class`WorkerBase` to instantiate, by default
|
||||
:class:`FunctionWorker` will be used if ``func`` is a regular function,
|
||||
and :class:`GeneratorWorker` will be used if it is a generator.
|
||||
ignore_errors : bool, optional
|
||||
If ``False`` (the default), errors raised in the other thread will be
|
||||
reraised in the main thread (makes debugging significantly easier).
|
||||
|
||||
Returns
|
||||
-------
|
||||
callable
|
||||
function that creates a worker, puts it in a new thread and returns
|
||||
the worker instance.
|
||||
|
||||
Examples
|
||||
--------
|
||||
.. code-block:: python
|
||||
|
||||
@thread_worker
|
||||
def long_function(start, end):
|
||||
# do work, periodically yielding
|
||||
i = start
|
||||
while i <= end:
|
||||
time.sleep(0.1)
|
||||
yield i
|
||||
|
||||
# do teardown
|
||||
return 'anything'
|
||||
|
||||
# call the function to start running in another thread.
|
||||
worker = long_function()
|
||||
# connect signals here if desired... or they may be added using the
|
||||
# `connect` argument in the `@thread_worker` decorator... in which
|
||||
# case the worker will start immediately when long_function() is called
|
||||
worker.start()
|
||||
"""
|
||||
|
||||
def _inner(func):
|
||||
@wraps(func)
|
||||
def worker_function(*args, **kwargs):
|
||||
# decorator kwargs can be overridden at call time by using the
|
||||
# underscore-prefixed version of the kwarg.
|
||||
kwargs["_start_thread"] = kwargs.get("_start_thread", start_thread)
|
||||
kwargs["_connect"] = kwargs.get("_connect", connect)
|
||||
kwargs["_worker_class"] = kwargs.get("_worker_class", worker_class)
|
||||
kwargs["_ignore_errors"] = kwargs.get("_ignore_errors", ignore_errors)
|
||||
return create_worker(
|
||||
func,
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
return worker_function
|
||||
|
||||
return _inner if function is None else _inner(function)
|
||||
|
||||
|
||||
############################################################################
|
||||
|
||||
# This is a variant on the above pattern, it uses QThread instead of Qrunnable
|
||||
# see https://doc.qt.io/qt-5/threads-technologies.html#comparison-of-solutions
|
||||
# (it appears from that table that QRunnable cannot emit or receive signals,
|
||||
# but we circumvent that here with our WorkerBase class that also inherits from
|
||||
# QObject... providing signals/slots).
|
||||
#
|
||||
# A benefit of the QRunnable pattern is that Qt manages the threads for you,
|
||||
# in the QThreadPool.globalInstance() ... making it easier to reuse threads,
|
||||
# and reduce overhead.
|
||||
#
|
||||
# However, a disadvantage is that you have no access to (and therefore less
|
||||
# control over) the QThread itself. See for example all of the methods
|
||||
# provided on the QThread object: https://doc.qt.io/qt-5/qthread.html
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
class WorkerProtocol(QObject):
|
||||
finished: Signal
|
||||
|
||||
def work(self) -> None:
|
||||
...
|
||||
|
||||
|
||||
def new_worker_qthread(
|
||||
Worker: Type[WorkerProtocol],
|
||||
*args,
|
||||
_start_thread: bool = False,
|
||||
_connect: Dict[str, Callable] = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""This is a convenience function to start a worker in a Qthread.
|
||||
|
||||
In most cases, the @thread_worker decorator is sufficient and preferable.
|
||||
But this allows the user to completely customize the Worker object.
|
||||
However, they must then maintain control over the thread and clean up
|
||||
appropriately.
|
||||
|
||||
It follows the pattern described here:
|
||||
https://www.qt.io/blog/2010/06/17/youre-doing-it-wrong
|
||||
and
|
||||
https://doc.qt.io/qt-5/qthread.html#details
|
||||
|
||||
see also:
|
||||
https://mayaposch.wordpress.com/2011/11/01/how-to-really-truly-use-qthreads-the-full-explanation/
|
||||
|
||||
A QThread object is not a thread! It should be thought of as a class to
|
||||
*manage* a thread, not as the actual code or object that runs in that
|
||||
thread. The QThread object is created on the main thread and lives there.
|
||||
|
||||
Worker objects which derive from QObject are the things that actually do
|
||||
the work. They can be moved to a QThread as is done here.
|
||||
|
||||
.. note:: Mostly ignorable detail
|
||||
|
||||
While the signals/slots syntax of the worker looks very similar to
|
||||
standard "single-threaded" signals & slots, note that inter-thread
|
||||
signals and slots (automatically) use an event-based QueuedConnection,
|
||||
while intra-thread signals use a DirectConnection. See `Signals and
|
||||
Slots Across Threads
|
||||
<https://doc.qt.io/qt-5/threads-qobject.html#signals-and-slots-across-threads>`_
|
||||
|
||||
Parameters
|
||||
----------
|
||||
Worker : QObject
|
||||
QObject type that implements a `work()` method. The Worker should also
|
||||
emit a finished signal when the work is done.
|
||||
_start_thread : bool
|
||||
If True, thread will be started immediately, otherwise, thread must
|
||||
be manually started with thread.start().
|
||||
_connect : dict, optional
|
||||
Optional dictionary of {signal: function} to connect to the new worker.
|
||||
for instance: _connect = {'incremented': myfunc} will result in:
|
||||
worker.incremented.connect(myfunc)
|
||||
*args
|
||||
will be passed to the Worker class on instantiation.
|
||||
**kwargs
|
||||
will be passed to the Worker class on instantiation.
|
||||
|
||||
Returns
|
||||
-------
|
||||
worker : WorkerBase
|
||||
The created worker.
|
||||
thread : QThread
|
||||
The thread on which the worker is running.
|
||||
|
||||
Examples
|
||||
--------
|
||||
Create some QObject that has a long-running work method:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class Worker(QObject):
|
||||
|
||||
finished = Signal()
|
||||
increment = Signal(int)
|
||||
|
||||
def __init__(self, argument):
|
||||
super().__init__()
|
||||
self.argument = argument
|
||||
|
||||
@Slot()
|
||||
def work(self):
|
||||
# some long running task...
|
||||
import time
|
||||
for i in range(10):
|
||||
time.sleep(1)
|
||||
self.increment.emit(i)
|
||||
self.finished.emit()
|
||||
|
||||
worker, thread = new_worker_qthread(
|
||||
Worker,
|
||||
'argument',
|
||||
_start_thread=True,
|
||||
_connect={'increment': print},
|
||||
)
|
||||
|
||||
"""
|
||||
|
||||
if _connect and not isinstance(_connect, dict):
|
||||
raise TypeError("_connect parameter must be a dict")
|
||||
|
||||
thread = QThread()
|
||||
worker = Worker(*args, **kwargs)
|
||||
worker.moveToThread(thread)
|
||||
thread.started.connect(worker.work)
|
||||
worker.finished.connect(thread.quit)
|
||||
worker.finished.connect(worker.deleteLater)
|
||||
thread.finished.connect(thread.deleteLater)
|
||||
|
||||
if _connect:
|
||||
[getattr(worker, key).connect(val) for key, val in _connect.items()]
|
||||
|
||||
if _start_thread:
|
||||
thread.start() # sometimes need to connect stuff before starting
|
||||
return worker, thread
|
@@ -1,56 +0,0 @@
|
||||
#
|
||||
# Copyright © 2014-2015 Colin Duquesnoy
|
||||
# Copyright © 2009- The Spyder Development Team
|
||||
#
|
||||
# Licensed under the terms of the MIT License
|
||||
# (see LICENSE.txt for details)
|
||||
|
||||
"""
|
||||
Modified from qtpy.QtCore.
|
||||
Provides QtCore classes and functions.
|
||||
"""
|
||||
|
||||
from . import PYQT5, PYQT6, PYSIDE2, PYSIDE6, PythonQtError
|
||||
|
||||
if PYQT5:
|
||||
from PyQt5.QtCore import QT_VERSION_STR as __version__
|
||||
from PyQt5.QtCore import *
|
||||
from PyQt5.QtCore import pyqtProperty as Property # noqa
|
||||
from PyQt5.QtCore import pyqtSignal as Signal # noqa
|
||||
from PyQt5.QtCore import pyqtSlot as Slot # noqa
|
||||
|
||||
# Those are imported from `import *`
|
||||
del pyqtSignal, pyqtBoundSignal, pyqtSlot, pyqtProperty, QT_VERSION_STR
|
||||
elif PYQT6:
|
||||
from PyQt6.QtCore import QT_VERSION_STR as __version__
|
||||
from PyQt6.QtCore import *
|
||||
from PyQt6.QtCore import pyqtProperty as Property # noqa
|
||||
from PyQt6.QtCore import pyqtSignal as Signal # noqa
|
||||
from PyQt6.QtCore import pyqtSlot as Slot # noqa
|
||||
|
||||
# backwards compat with PyQt5
|
||||
# namespace moves:
|
||||
for cls in (QEvent, Qt):
|
||||
for attr in dir(cls):
|
||||
if not attr[0].isupper():
|
||||
continue
|
||||
ns = getattr(cls, attr)
|
||||
for name, val in vars(ns).items():
|
||||
if not name.startswith("_"):
|
||||
setattr(cls, name, val)
|
||||
|
||||
# Those are imported from `import *`
|
||||
del pyqtSignal, pyqtBoundSignal, pyqtSlot, pyqtProperty, QT_VERSION_STR
|
||||
elif PYSIDE2:
|
||||
import PySide2.QtCore
|
||||
from PySide2.QtCore import * # noqa
|
||||
|
||||
__version__ = PySide2.QtCore.__version__
|
||||
elif PYSIDE6:
|
||||
import PySide6.QtCore
|
||||
from PySide6.QtCore import * # noqa
|
||||
|
||||
__version__ = PySide6.QtCore.__version__
|
||||
|
||||
else:
|
||||
raise PythonQtError("No Qt bindings could be found")
|
@@ -1,42 +0,0 @@
|
||||
#
|
||||
# Copyright © 2014-2015 Colin Duquesnoy
|
||||
# Copyright © 2009- The Spyder Development Team
|
||||
#
|
||||
# Licensed under the terms of the MIT License
|
||||
# (see LICENSE.txt for details)
|
||||
|
||||
"""
|
||||
Modified from qtpy.QtGui
|
||||
Provides QtGui classes and functions.
|
||||
"""
|
||||
|
||||
from . import PYQT5, PYQT6, PYSIDE2, PYSIDE6, PythonQtError
|
||||
|
||||
if PYQT5:
|
||||
from PyQt5.QtGui import *
|
||||
elif PYSIDE2:
|
||||
from PySide2.QtGui import *
|
||||
elif PYQT6:
|
||||
from PyQt6.QtGui import *
|
||||
|
||||
# backwards compat with PyQt5
|
||||
# namespace moves:
|
||||
for cls in (QPalette,):
|
||||
for attr in dir(cls):
|
||||
if not attr[0].isupper():
|
||||
continue
|
||||
ns = getattr(cls, attr)
|
||||
for name, val in vars(ns).items():
|
||||
if not name.startswith("_"):
|
||||
setattr(cls, name, val)
|
||||
|
||||
def pos(self, *a):
|
||||
_pos = self.position(*a)
|
||||
return _pos.toPoint()
|
||||
|
||||
QMouseEvent.pos = pos
|
||||
|
||||
elif PYSIDE6:
|
||||
from PySide6.QtGui import * # noqa
|
||||
else:
|
||||
raise PythonQtError("No Qt bindings could be found")
|
@@ -1,42 +0,0 @@
|
||||
#
|
||||
# Copyright © 2014-2015 Colin Duquesnoy
|
||||
# Copyright © 2009- The Spyder Developmet Team
|
||||
#
|
||||
# Licensed under the terms of the MIT License
|
||||
# (see LICENSE.txt for details)
|
||||
|
||||
"""
|
||||
Modified from qtpy.QtWidgets
|
||||
Provides widget classes and functions.
|
||||
"""
|
||||
|
||||
from . import PYQT5, PYQT6, PYSIDE2, PYSIDE6, PythonQtError
|
||||
|
||||
if PYQT5:
|
||||
from PyQt5.QtWidgets import *
|
||||
elif PYSIDE2:
|
||||
from PySide2.QtWidgets import *
|
||||
elif PYQT6:
|
||||
from PyQt6.QtWidgets import *
|
||||
|
||||
# backwards compat with PyQt5
|
||||
# namespace moves:
|
||||
for cls in (QStyle, QSlider, QSizePolicy, QSpinBox):
|
||||
for attr in dir(cls):
|
||||
if not attr[0].isupper():
|
||||
continue
|
||||
ns = getattr(cls, attr)
|
||||
for name, val in vars(ns).items():
|
||||
if not name.startswith("_"):
|
||||
setattr(cls, name, val)
|
||||
|
||||
def exec_(self):
|
||||
self.exec()
|
||||
|
||||
QApplication.exec_ = exec_
|
||||
|
||||
elif PYSIDE6:
|
||||
from PySide6.QtWidgets import * # noqa
|
||||
|
||||
else:
|
||||
raise PythonQtError("No Qt bindings could be found")
|
@@ -1,166 +0,0 @@
|
||||
#
|
||||
# Copyright © 2009- The Spyder Development Team
|
||||
# Copyright © 2014-2015 Colin Duquesnoy
|
||||
#
|
||||
# Licensed under the terms of the MIT License
|
||||
# (see LICENSE.txt for details)
|
||||
|
||||
"""
|
||||
This file is borrowed from qtpy and modified to support PySide6/PyQt6 (drops PyQt4)
|
||||
"""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
import warnings
|
||||
from distutils.version import LooseVersion
|
||||
|
||||
|
||||
class PythonQtError(RuntimeError):
|
||||
"""Error raise if no bindings could be selected."""
|
||||
|
||||
|
||||
class PythonQtWarning(Warning):
|
||||
"""Warning if some features are not implemented in a binding."""
|
||||
|
||||
|
||||
# Qt API environment variable name
|
||||
QT_API = "QT_API"
|
||||
|
||||
# Names of the expected PyQt5 api
|
||||
PYQT5_API = ["pyqt5"]
|
||||
|
||||
# Names of the expected PyQt6 api
|
||||
PYQT6_API = ["pyqt6"]
|
||||
|
||||
# Names of the expected PySide2 api
|
||||
PYSIDE2_API = ["pyside2"]
|
||||
|
||||
# Names of the expected PySide6 api
|
||||
PYSIDE6_API = ["pyside6"]
|
||||
|
||||
# Detecting if a binding was specified by the user
|
||||
binding_specified = QT_API in os.environ
|
||||
|
||||
# Setting a default value for QT_API
|
||||
os.environ.setdefault(QT_API, "pyqt5")
|
||||
|
||||
API = os.environ[QT_API].lower()
|
||||
initial_api = API
|
||||
assert API in (PYQT5_API + PYQT6_API + PYSIDE2_API + PYSIDE6_API)
|
||||
|
||||
PYQT5 = True
|
||||
PYSIDE2 = PYQT6 = PYSIDE6 = False
|
||||
|
||||
# When `FORCE_QT_API` is set, we disregard
|
||||
# any previously imported python bindings.
|
||||
if os.environ.get("FORCE_QT_API") is not None:
|
||||
if "PyQt5" in sys.modules:
|
||||
API = initial_api if initial_api in PYQT5_API else "pyqt5"
|
||||
elif "PySide2" in sys.modules:
|
||||
API = initial_api if initial_api in PYSIDE2_API else "pyside2"
|
||||
elif "PyQt6" in sys.modules:
|
||||
API = initial_api if initial_api in PYQT6_API else "pyqt6"
|
||||
elif "PySide6" in sys.modules:
|
||||
API = initial_api if initial_api in PYSIDE6_API else "pyside6"
|
||||
|
||||
|
||||
if API in PYQT5_API:
|
||||
try:
|
||||
from PyQt5.QtCore import PYQT_VERSION_STR as PYQT_VERSION # noqa
|
||||
from PyQt5.QtCore import QT_VERSION_STR as QT_VERSION # noqa
|
||||
|
||||
PYSIDE_VERSION = None # noqa
|
||||
|
||||
if sys.platform == "darwin":
|
||||
macos_version = LooseVersion(platform.mac_ver()[0])
|
||||
if macos_version < LooseVersion("10.10"):
|
||||
if LooseVersion(QT_VERSION) >= LooseVersion("5.9"):
|
||||
raise PythonQtError(
|
||||
"Qt 5.9 or higher only works in "
|
||||
"macOS 10.10 or higher. Your "
|
||||
"program will fail in this "
|
||||
"system."
|
||||
)
|
||||
elif macos_version < LooseVersion("10.11"):
|
||||
if LooseVersion(QT_VERSION) >= LooseVersion("5.11"):
|
||||
raise PythonQtError(
|
||||
"Qt 5.11 or higher only works in "
|
||||
"macOS 10.11 or higher. Your "
|
||||
"program will fail in this "
|
||||
"system."
|
||||
)
|
||||
|
||||
del macos_version
|
||||
except ImportError:
|
||||
API = os.environ["QT_API"] = "pyside2"
|
||||
|
||||
if API in PYSIDE2_API:
|
||||
try:
|
||||
from PySide2 import __version__ as PYSIDE_VERSION # noqa
|
||||
from PySide2.QtCore import __version__ as QT_VERSION # noqa
|
||||
|
||||
PYQT_VERSION = None # noqa
|
||||
PYQT5 = False
|
||||
PYSIDE2 = True
|
||||
|
||||
if sys.platform == "darwin":
|
||||
macos_version = LooseVersion(platform.mac_ver()[0])
|
||||
if macos_version < LooseVersion("10.11"):
|
||||
if LooseVersion(QT_VERSION) >= LooseVersion("5.11"):
|
||||
raise PythonQtError(
|
||||
"Qt 5.11 or higher only works in "
|
||||
"macOS 10.11 or higher. Your "
|
||||
"program will fail in this "
|
||||
"system."
|
||||
)
|
||||
|
||||
del macos_version
|
||||
except ImportError:
|
||||
API = os.environ["QT_API"] = "pyqt6"
|
||||
|
||||
if API in PYQT6_API:
|
||||
try:
|
||||
from PyQt6.QtCore import PYQT_VERSION_STR as PYQT_VERSION # noqa
|
||||
from PyQt6.QtCore import QT_VERSION_STR as QT_VERSION # noqa
|
||||
|
||||
PYSIDE_VERSION = None # noqa
|
||||
PYQT5 = False
|
||||
PYQT6 = True
|
||||
|
||||
except ImportError:
|
||||
API = os.environ["QT_API"] = "pyside6"
|
||||
|
||||
if API in PYSIDE6_API:
|
||||
try:
|
||||
from PySide6 import __version__ as PYSIDE_VERSION # noqa
|
||||
from PySide6.QtCore import __version__ as QT_VERSION # noqa
|
||||
|
||||
PYQT_VERSION = None # noqa
|
||||
PYQT5 = False
|
||||
PYSIDE6 = True
|
||||
|
||||
except ImportError:
|
||||
API = None
|
||||
|
||||
if API is None:
|
||||
raise PythonQtError(
|
||||
"No Qt bindings could be found.\nYou must install one of the following packages "
|
||||
"to use superqt: PyQt5, PyQt6, PySide2, or PySide6"
|
||||
)
|
||||
|
||||
# If a correct API name is passed to QT_API and it could not be found,
|
||||
# switches to another and informs through the warning
|
||||
if API != initial_api and binding_specified:
|
||||
warnings.warn(
|
||||
'Selected binding "{}" could not be found, '
|
||||
'using "{}"'.format(initial_api, API),
|
||||
RuntimeWarning,
|
||||
)
|
||||
|
||||
API_NAME = {
|
||||
"pyqt5": "PyQt5",
|
||||
"pyqt6": "PyQt6",
|
||||
"pyside2": "PySide2",
|
||||
"pyside6": "PySide6",
|
||||
}[API]
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user