Compare commits

...

15 Commits

Author SHA1 Message Date
Ahmet Can Solak
0681f7138a typing-extensions version pinning (#46) 2021-11-22 20:44:50 -05:00
Talley Lambert
1e1f38d297 Fix-manifest, move font tests (#44)
* remove arrow

* move mypy

* add files

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

* remove print

* 4 min timeout

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

* fix tox

* move

* ignore deprecation

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

* add tests

* update versions

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

* wip

* wip

* Working animation

* WIP Implement tests

* All tests are passing

* convert to camalCase

* Change function name to match functionality

* convert pyside to qtcompat

* move animation utils to main module

* remove seperators

* protect util functions

* add example

* remove seperators from test file

* suggestions

* Passing tests and ability to initialize expansion

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

* replace quick functions with parameters

* Update src/superqt/collapsible/_collapsible.py

Fix initial text

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

* Update src/superqt/collapsible/_collapsible.py

Remote WindowFlags to prevent compatiblity issue.

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

* merge internal expand and collapse into one function

* Update src/superqt/collapsible/_collapsible.py

* Update tests/test_collapsible.py

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

* rearrange

* add back init

* change entrypoints

* add mi4

* example

* more improvements

* add animations

* more changes

* add feather, improve seticon

* update example

* refactor

* broken wip

* use iconfontmeta instead of enum

* mostly working

* misc

* more tweaks

* docs

* adding tests

* remove napari example

* more docs

* more docs

* update examples

* more docs

* typing

* working on icon options

* updates

* update

* update example

* update tests

* add comment

* docs

* fix annotation

* try set false first

* fix py37

* more test fixes

* fix qt6 test

* ignore old deprecation warning

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

* add qtsvg

* more changes for qt6 support

* add qaction

* more enum namespacing

* more ns fixes

* updating qtcompat

* more minimal

* wip

* update typing

* fix one more namespace

* update types

* update exports

* add stubs

* fix

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

* add type

* update typing

* keep runtime types

* update

* remove slot

* remove order

* remove signalinstance hint

* fix old import error

* remove unneeded order

* try something

* comment

* timeout

* add qapp to everything

* verbose

* also add -s

* print lots

* move to bottom

* use sigint after time

* use wraper for future object

* remove temporary stuff

* undo move

* move again

* delete reference after return result

* add back sigint after time

* add print

* change scope

* add more prints

* change f string

* timtout

* no sigint again

* print more

* bump

* try without object thread tests

* just skip

* modify skips

* undo ensure thread changes

* verbose

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

* fix manifest and version

* fix test structure

* undo

* undo

* undo change

* remove pyargs

* waitsignal

* update label test

* soften eliding test

* another fix

* update again

* more fixes

* more skips

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

* add __wrapped__ information for inspect module
2021-10-04 09:22:56 -04:00
118 changed files with 3873 additions and 556 deletions

View File

@@ -47,6 +47,16 @@ jobs:
- python-version: 3.9
platform: macos-11.0
backend: pyqt6
# py3.10
- python-version: "3.10"
platform: ubuntu-latest
backend: pyside6
- python-version: "3.10"
platform: ubuntu-latest
backend: pyqt5
- python-version: "3.10"
platform: ubuntu-latest
backend: pyqt6
# big sur, 3.9
- python-version: 3.9
@@ -81,6 +91,8 @@ jobs:
platform: ubuntu-latest
backend: pyqt514
steps:
- uses: actions/checkout@v2
@@ -102,6 +114,7 @@ jobs:
- name: Test with tox
uses: GabrielBB/xvfb-action@v1
timeout-minutes: 3
with:
run: tox
env:
@@ -131,11 +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:
@@ -147,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/*

3
.gitignore vendored
View File

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

View File

@@ -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]

View File

@@ -1,5 +1,29 @@
# 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)
@@ -10,6 +34,10 @@
- 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)
@@ -33,13 +61,17 @@
## [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.0...v0.2.1)
[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)*

View File

@@ -1,7 +1,17 @@
include LICENSE
include README.md
include superqt/py.typed
recursive-include superqt *.py
recursive-include superqt *.pyi
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

View File

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

View File

@@ -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
View File

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

View File

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

View File

@@ -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
View File

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

20
examples/fonticon2.py Normal file
View File

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

40
examples/fonticon3.py Normal file
View File

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

View File

@@ -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
View File

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

View File

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

View File

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

View File

@@ -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 =
@@ -73,6 +86,11 @@ superqt = py.typed
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
@@ -83,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

View File

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

View File

@@ -6,6 +6,7 @@ except ImportError:
from ._eliding_label import QElidingLabel
from .collapsible import QCollapsible
from .combobox import QEnumComboBox
from .sliders import (
QDoubleRangeSlider,
@@ -33,4 +34,5 @@ __all__ = [
"QMessageHandler",
"QRangeSlider",
"QEnumComboBox",
"QCollapsible",
]

View File

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

View File

@@ -0,0 +1,128 @@
"""A collapsible widget to hide and unhide child widgets"""
from typing import Optional
from ..qtcompat.QtCore import (
QAbstractAnimation,
QEasingCurve,
QMargins,
QPropertyAnimation,
Qt,
)
from ..qtcompat.QtWidgets import QFrame, QPushButton, QVBoxLayout, QWidget
class QCollapsible(QFrame):
"""A collapsible widget to hide and unhide child widgets.
Based on https://stackoverflow.com/a/68141638
"""
_EXPANDED = ""
_COLLAPSED = ""
def __init__(self, title: str = "", parent: Optional[QWidget] = None):
super().__init__(parent)
self._locked = False
self._toggle_btn = QPushButton(self._COLLAPSED + title)
self._toggle_btn.setCheckable(True)
self._toggle_btn.setStyleSheet("text-align: left; background: transparent;")
self._toggle_btn.toggled.connect(self._toggle)
# frame layout
self.setLayout(QVBoxLayout())
self.layout().setAlignment(Qt.AlignmentFlag.AlignTop)
self.layout().addWidget(self._toggle_btn)
# Create animators
self._animation = QPropertyAnimation(self)
self._animation.setPropertyName(b"maximumHeight")
self._animation.setStartValue(0)
self.setDuration(300)
self.setEasingCurve(QEasingCurve.Type.InOutCubic)
# default content widget
_content = QWidget()
_content.setLayout(QVBoxLayout())
_content.setMaximumHeight(0)
_content.layout().setContentsMargins(QMargins(5, 0, 0, 0))
self.setContent(_content)
def setText(self, text: str):
"""Set the text of the toggle button."""
current = self._toggle_btn.text()[: len(self._EXPANDED)]
self._toggle_btn.setText(current + text)
def text(self) -> str:
"""Return the text of the toggle button."""
return self._toggle_btn.text()[len(self._EXPANDED) :]
def setContent(self, content: QWidget):
"""Replace central widget (the widget that gets expanded/collapsed)."""
self._content = content
self.layout().addWidget(self._content)
self._animation.setTargetObject(content)
def content(self) -> QWidget:
"""Return the current content widget."""
return self._content
def setDuration(self, msecs: int):
"""Set duration of the collapse/expand animation."""
self._animation.setDuration(msecs)
def setEasingCurve(self, easing: QEasingCurve):
"""Set the easing curve for the collapse/expand animation"""
self._animation.setEasingCurve(easing)
def addWidget(self, widget: QWidget):
"""Add a widget to the central content widget's layout."""
self._content.layout().addWidget(widget)
def removeWidget(self, widget: QWidget):
"""Remove widget from the central content widget's layout."""
self._content.layout().removeWidget(widget)
def expand(self, animate: bool = True):
"""Expand (show) the collapsible section"""
self._expand_collapse(QAbstractAnimation.Direction.Forward, animate)
def collapse(self, animate: bool = True):
"""Collapse (hide) the collapsible section"""
self._expand_collapse(QAbstractAnimation.Direction.Backward, animate)
def isExpanded(self) -> bool:
"""Return whether the collapsible section is visible"""
return self._toggle_btn.isChecked()
def setLocked(self, locked: bool = True):
"""Set whether collapse/expand is disabled"""
self._locked = locked
self._toggle_btn.setCheckable(not locked)
def locked(self) -> bool:
"""Return True if collapse/expand is disabled"""
return self._locked
def _expand_collapse(
self, direction: QAbstractAnimation.Direction, animate: bool = True
):
if self._locked:
return
forward = direction == QAbstractAnimation.Direction.Forward
text = self._EXPANDED if forward else self._COLLAPSED
self._toggle_btn.setChecked(forward)
self._toggle_btn.setText(text + self._toggle_btn.text()[len(self._EXPANDED) :])
_content_height = self._content.sizeHint().height() + 10
if animate:
self._animation.setDirection(direction)
self._animation.setEndValue(_content_height)
self._animation.start()
else:
self._content.setMaximumHeight(_content_height if forward else 0)
def _toggle(self):
self.expand() if self.isExpanded() else self.collapse()

View File

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

View File

@@ -0,0 +1,40 @@
from abc import ABC, abstractmethod
from superqt.qtcompat.QtCore import QRectF, QTimer
from superqt.qtcompat.QtGui import QPainter
from superqt.qtcompat.QtWidgets import QWidget
class Animation(ABC):
def __init__(self, parent_widget: QWidget, interval: int = 10, step: int = 1):
self.parent_widget = parent_widget
self.timer = QTimer()
self.timer.timeout.connect(self._update) # type: ignore
self.timer.setInterval(interval)
self._angle = 0
self._step = step
def _update(self):
if self.timer.isActive():
self._angle += self._step
self.parent_widget.update()
@abstractmethod
def animate(self, painter: QPainter):
"""Setup and start the timer for the animation."""
class spin(Animation):
def animate(self, painter: QPainter):
if not self.timer.isActive():
self.timer.start()
mid = QRectF(painter.viewport()).center()
painter.translate(mid)
painter.rotate(self._angle % 360)
painter.translate(-mid)
class pulse(spin):
def __init__(self, parent_widget: QWidget = None):
super().__init__(parent_widget, interval=200, step=45)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View 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

View File

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

View File

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

View 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())

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 #######################

View 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,
)

View File

@@ -1,5 +1,6 @@
# 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 (
@@ -50,10 +51,11 @@ def ensure_main_thread(
before raising a TimeoutError, by default 1000
"""
def _out_func(func):
def _out_func(func_):
@wraps(func_)
def _func(*args, **kwargs):
return _run_in_thread(
func,
func_,
QCoreApplication.instance().thread(),
await_return,
timeout,
@@ -87,10 +89,11 @@ def ensure_object_thread(
before raising a TimeoutError, by default 1000
"""
def _out_func(func):
def _out_func(func_):
@wraps(func_)
def _func(self, *args, **kwargs):
return _run_in_thread(
func, self.thread(), await_return, timeout, self, *args, **kwargs
func_, self.thread(), await_return, timeout, self, *args, **kwargs
)
return _func

View 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

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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]

View File

@@ -1,4 +0,0 @@
__all__ = ("QMessageHandler", "ensure_object_thread", "ensure_main_thread")
from ._ensure_thread import ensure_main_thread, ensure_object_thread
from ._message_handler import QMessageHandler

86
tests/test_collapsible.py Normal file
View File

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

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