Compare commits

..

10 Commits

Author SHA1 Message Date
Talley Lambert
5ab72a0c48 add changelog for 0.2.4 (#25) 2021-09-13 13:25:30 -04:00
Talley Lambert
06da62811b Add type stubs for ensure_thread decorator (#23)
* types

* udpate manifest

* remove unused
2021-09-11 07:59:24 -04:00
Grzegorz Bokota
bb538cda2a Add ensure_main_tread and ensure_object_thread (#22)
* initial implementation

* add tests

* add test for property

* add doc part 1

* same behavior for direct and indirect call

* allow use decorator without braces

* add documentation

* Update docs/decorators.md

* update docs

* update docs

* simplify

* remove obsolete timeout

* update docs for future

Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
2021-09-10 21:21:33 -04:00
Talley Lambert
c8a40ba051 add mesage handler (#21) 2021-09-02 22:49:55 -04:00
Talley Lambert
ac1d8403fd Fix warnings on eliding label for 5.12, test more qt versions (#19)
* more tests and eliding fix

* update tests

* tox env override

* remove ubuntu 16

* still trying to fix tests

* add backends
2021-08-25 16:05:11 -04:00
Talley Lambert
ba20665d57 Add QElidingLabel (#16)
* wip

* single class implementation

* fix init

* improve implementation

* improve sizeHint

* wrap

* update docs

* rename

* remove overloads

* review changes

* docs and reformat

* remove width from _elided text

* add tests
2021-08-17 11:03:57 -04:00
Grzegorz Bokota
939c5222af Add Enum ComboBox (#13)
* enum combobox implementation

* add enunm()

* Update superqt/combobox/_enum_combobox.py

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

* add changes from review

* updates from review

* make current enum not raise exception from currentEnum

* improve checks in setCurrentEnum

* Update superqt/combobox/_tests/test_enum_comb_box.py

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

* fix test

* fix test call

* add class to top level __init__

* fix pre-commit mmissed call

* rename

* documentation first part

* Update docs/combobox.md

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

* add possibility to use Optional[Enum]

* add information about optional annotation

* change type annotation to additional parameter

* update docs

* change to EnumMeta

* add information about signal

Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
2021-08-13 09:49:45 -04:00
Robert Haase
22beed7608 fix broken link (#18) 2021-08-06 19:02:22 -04:00
Talley Lambert
9a72d9d474 fix slider proxy (#10) 2021-07-10 15:11:17 -04:00
Talley Lambert
5202aba6a8 Fix range slider with negative min range (#9)
* fix value from position for neg numbers

* smaller diff
2021-07-10 15:11:04 -04:00
29 changed files with 1231 additions and 23 deletions

View File

@@ -60,20 +60,26 @@ jobs:
- python-version: 3.8
platform: ubuntu-18.04
backend: pyside2
- python-version: 3.6
platform: ubuntu-16.04
backend: pyqt5
- python-version: 3.6
platform: windows-2016
backend: pyqt5
# legacy Qt
- python-version: 3.7
platform: ubuntu-latest
backend: pyside511
- python-version: 3.7
platform: ubuntu-latest
backend: pyqt511
- python-version: 3.7
platform: ubuntu-latest
backend: pyside511
backend: pyqt512
- python-version: 3.7
platform: ubuntu-latest
backend: pyqt513
- python-version: 3.7
platform: ubuntu-latest
backend: pyqt514
steps:
- uses: actions/checkout@v2
@@ -83,12 +89,7 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
- name: Install Linux libraries
if: runner.os == 'Linux'
run: |
sudo apt-get install -y libdbus-1-3 libxkbcommon-x11-0 libxcb-icccm4 \
libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 \
libxcb-xinerama0 libxcb-xfixes0
- uses: tlambert03/setup-qt-libs@v1
- name: Linux opengl
if: runner.os == 'Linux' && ( matrix.backend == 'pyside6' || matrix.backend == 'pyqt6' )
@@ -100,7 +101,9 @@ jobs:
pip install setuptools tox tox-gh-actions
- name: Test with tox
run: tox
uses: GabrielBB/xvfb-action@v1
with:
run: tox
env:
PLATFORM: ${{ matrix.platform }}
BACKEND: ${{ matrix.backend }}
@@ -128,7 +131,6 @@ jobs:
name: screenshots ${{ runner.os }}
path: screenshots
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

View File

@@ -0,0 +1,8 @@
# run this with:
# export CHANGELOG_GITHUB_TOKEN=......
# github_changelog_generator --future-release vX.Y.Z
user=napari
project=superqt
issues=false
since-tag=v0.2.0
add-sections={"documentation":{"prefix":"**Documentation updates:**","labels":["documentation"]}}

2
.gitignore vendored
View File

@@ -79,3 +79,5 @@ target/
*/_version.py
.vscode/settings.json
screenshots
.mypy_cache

45
CHANGELOG.md Normal file
View File

@@ -0,0 +1,45 @@
# Changelog
## [v0.2.4](https://github.com/napari/superqt/tree/v0.2.4) (2021-09-13)
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.3...v0.2.4)
**Implemented enhancements:**
- Add type stubs for ensure\_thread decorator [\#23](https://github.com/napari/superqt/pull/23) ([tlambert03](https://github.com/tlambert03))
- Add `ensure_main_tread` and `ensure_object_thread` [\#22](https://github.com/napari/superqt/pull/22) ([Czaki](https://github.com/Czaki))
- Add QMessageHandler context manager [\#21](https://github.com/napari/superqt/pull/21) ([tlambert03](https://github.com/tlambert03))
## [v0.2.3](https://github.com/napari/superqt/tree/v0.2.3) (2021-08-25)
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.2...v0.2.3)
**Fixed bugs:**
- Fix warnings on eliding label for 5.12, test more qt versions [\#19](https://github.com/napari/superqt/pull/19) ([tlambert03](https://github.com/tlambert03))
## [v0.2.2](https://github.com/napari/superqt/tree/v0.2.2) (2021-08-17)
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.1...v0.2.2)
**Implemented enhancements:**
- Add QElidingLabel [\#16](https://github.com/napari/superqt/pull/16) ([tlambert03](https://github.com/tlambert03))
- Enum ComboBox implementation [\#13](https://github.com/napari/superqt/pull/13) ([Czaki](https://github.com/Czaki))
**Documentation updates:**
- fix broken link [\#18](https://github.com/napari/superqt/pull/18) ([haesleinhuepf](https://github.com/haesleinhuepf))
## [v0.2.1](https://github.com/napari/superqt/tree/v0.2.1) (2021-07-10)
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.0...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))
\* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)*

View File

@@ -1,5 +1,7 @@
include LICENSE
include README.md
include superqt/py.typed
recursive-include superqt *.py
recursive-include superqt *.pyi
recursive-exclude * __pycache__
recursive-exclude * *.py[co]

63
docs/combobox.md Normal file
View File

@@ -0,0 +1,63 @@
# ComboBox
## Enum Combo Box
`QEnumComboBox` is a variant of [`QComboBox`](https://doc.qt.io/qt-5/qcombobox.html)
that populates the items in the combobox based on a python `Enum` class. In addition to all
the methods provided by `QComboBox`, this subclass adds the methods
`enumClass`/`setEnumClass` to get/set the current `Enum` class represented by the combobox,
and `currentEnum`/`setCurrentEnum` to get/set the current `Enum` member in the combobox.
There is also a new signal `currentEnumChanged(enum)` analogous to `currentIndexChanged` and `currentTextChanged`.
Method like `insertItem` and `addItem` are blocked and try of its usage will end with `RuntimeError`
```python
from enum import Enum
from superqt import QEnumComboBox
class SampleEnum(Enum):
first = 1
second = 2
third = 3
# as usual:
# you must create a QApplication before create a widget.
combo = QEnumComboBox()
combo.setEnumClass(SampleEnum)
```
other option is to use optional `enum_class` argument of constructor and change
```python
combo = QEnumComboBox()
combo.setEnumClass(SampleEnum)
```
to
```python
combo = QEnumComboBox(enum_class=SampleEnum)
```
### Allow `None`
`QEnumComboBox` allow using Optional type annotation:
```python
from enum import Enum
from superqt import QEnumComboBox
class SampleEnum(Enum):
first = 1
second = 2
third = 3
# as usual:
# you must create a QApplication before create a widget.
combo = QEnumComboBox()
combo.setEnumClass(SampleEnum, allow_none=True)
```
In this case there is added option `----` and `currentEnum` will return `None` for it.

86
docs/decorators.md Normal file
View File

@@ -0,0 +1,86 @@
# Decorators
## Move to thread decorators
`superqt` provides two decorators that help to ensure that given function is
running in the desired thread:
* `ensure_main_thread` - ensures that the decorated function/method runs in the main thread
* `ensure_object_thread` - ensures that a decorated bound method of a `QObject` runs in the
thread in which the instance lives ([qt
documentation](https://doc.qt.io/qt-5/threads-qobject.html#accessing-qobject-subclasses-from-other-threads)).
By default, functions are executed asynchronously (they return immediately with
an instance of
[`concurrent.futures.Future`](https://docs.python.org/3/library/concurrent.futures.html#concurrent.futures.Future)).
To block and wait for the result, see [Synchronous mode](#synchronous-mode)
```python
from superqt.qtcompat.QtCore import QObject
from superqt import ensure_main_thread, ensure_object_thread
@ensure_main_thread
def sample_function():
print("This function will run in main thread")
class SampleObject(QObject):
def __init__(self):
super().__init__()
self._value = 1
@ensure_main_thread
def sample_method1(self):
print("This method will run in main thread")
@ensure_object_thread
def sample_method3(self):
import time
print("sleeping")
time.sleep(1)
print("This method will run in object thread")
@property
def value(self):
print("return value")
return self._value
@value.setter
@ensure_object_thread
def value(self, value):
print("this setter will run in object thread")
self._value = value
```
As can be seen in this example these decorators can also be used for setters.
These decorators should not be used as replacement of Qt Signals but rather to
interact with Qt objects from non Qt code.
### Synchronous mode
If you'd like for the program to block and wait for the result of your function
call, use the `await_return=True` parameter, and optionally specify a timeout.
> *Note: Using synchronous mode may significantly impact performance.*
```python
from superqt import ensure_main_thread
@ensure_main_thread
def sample_function1():
return 1
@ensure_main_thread(await_return=True)
def sample_function2():
return 2
assert sample_function1() is None
assert sample_function2() == 2
# optionally, specify a timeout
@ensure_main_thread(await_return=True, timeout=10000)
def sample_function():
return 1
```

View File

@@ -95,8 +95,7 @@ then you can also target it directly in your style sheet. The one "special"
property for QRangeSlider is `qproperty-barColor`, which sets the color of the
bar between the handles.
> The code for these example widgets is [here](examples/demo_widget.py)
> The code for these example widgets is [here](../examples/demo_widget.py)
<details>
<summary><em>See style sheet used for this example</em></summary>

12
examples/eliding_label.py Normal file
View File

@@ -0,0 +1,12 @@
from superqt import QElidingLabel
from superqt.qtcompat.QtWidgets import QApplication
app = QApplication([])
widget = QElidingLabel(
"a skj skjfskfj sdlf sdfl sdlfk jsdf sdlkf jdsf dslfksdl sdlfk sdf sdl "
"fjsdlf kjsdlfk laskdfsal as lsdfjdsl kfjdslf asfd dslkjfldskf sdlkfj"
)
widget.setWordWrap(True)
widget.show()
app.exec_()

View File

@@ -66,6 +66,9 @@ testing =
tox
tox-conda
[options.package_data]
superqt = py.typed
[flake8]
exclude = _version.py,.eggs,examples
docstring-convention = numpy

View File

@@ -5,6 +5,8 @@ except ImportError:
__version__ = "unknown"
from ._eliding_label import QElidingLabel
from .combobox import QEnumComboBox
from .sliders import (
QDoubleRangeSlider,
QDoubleSlider,
@@ -15,14 +17,20 @@ from .sliders import (
QRangeSlider,
)
from .spinbox import QLargeIntSpinBox
from .utils import QMessageHandler, ensure_main_thread, ensure_object_thread
__all__ = [
"ensure_main_thread",
"ensure_object_thread",
"QDoubleRangeSlider",
"QDoubleSlider",
"QElidingLabel",
"QLabeledDoubleRangeSlider",
"QLabeledDoubleSlider",
"QLabeledRangeSlider",
"QLabeledSlider",
"QLargeIntSpinBox",
"QMessageHandler",
"QRangeSlider",
"QEnumComboBox",
]

110
superqt/_eliding_label.py Normal file
View File

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

View File

@@ -0,0 +1,68 @@
from superqt import QElidingLabel
from superqt.qtcompat.QtCore import QSize, Qt
from superqt.qtcompat.QtGui import QResizeEvent
TEXT = (
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do "
"eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad "
"minim ven iam, quis nostrud exercitation ullamco laborisnisi ut aliquip "
"ex ea commodo consequat. Duis aute irure dolor inreprehenderit in voluptate "
"velit esse cillum dolore eu fugiat nullapariatur."
)
ELLIPSIS = ""
def test_eliding_label(qtbot):
wdg = QElidingLabel(TEXT)
qtbot.addWidget(wdg)
assert wdg._elidedText().endswith(ELLIPSIS)
oldsize = wdg.size()
newsize = QSize(200, 20)
wdg.resize(newsize)
wdg.resizeEvent(QResizeEvent(oldsize, newsize)) # for test coverage
assert wdg.text() == TEXT
def test_wrapped_eliding_label(qtbot):
wdg = QElidingLabel(TEXT)
qtbot.addWidget(wdg)
assert not wdg.wordWrap()
assert 630 < wdg.sizeHint().width() < 635
assert wdg._elidedText() == (
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do "
"eiusmod tempor incididunt ut labore et d…"
)
wdg.resize(QSize(200, 100))
assert wdg.text() == TEXT
assert wdg._elidedText() == "Lorem ipsum dolor sit amet, co…"
wdg.setWordWrap(True)
assert wdg.wordWrap()
assert wdg.text() == TEXT
assert wdg._elidedText() == (
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do "
"eiusmod tempor incididunt ut labore et dolore magna aliqua. "
"Ut enim ad minim ven iam, quis nostrud exercitation ullamco la…"
)
assert wdg.sizeHint() == QSize(200, 176)
wdg.resize(wdg.sizeHint())
assert wdg._elidedText() == TEXT
def test_shorter_eliding_label(qtbot):
short = "asd a ads sd flksdf dsf lksfj sd lsdjf sd lsdfk sdlkfj s"
wdg = QElidingLabel()
qtbot.addWidget(wdg)
wdg.setText(short)
assert not wdg._elidedText().endswith(ELLIPSIS)
wdg.resize(100, 20)
assert wdg._elidedText().endswith(ELLIPSIS)
wdg.setElideMode(Qt.TextElideMode.ElideLeft)
assert wdg._elidedText().startswith(ELLIPSIS)
assert wdg.elideMode() == Qt.TextElideMode.ElideLeft
def test_wrap_text():
wrap = QElidingLabel.wrapText(TEXT, 200)
assert isinstance(wrap, list)
assert all(isinstance(x, str) for x in wrap)
assert len(wrap) == 11

View File

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

View File

@@ -0,0 +1,112 @@
from enum import Enum, EnumMeta
from typing import Optional, TypeVar
from ..qtcompat.QtCore import Signal
from ..qtcompat.QtWidgets import QComboBox
EnumType = TypeVar("EnumType", bound=Enum)
NONE_STRING = "----"
def _get_name(enum_value: Enum):
"""Create human readable name if user does not provide own implementation of __str__"""
if enum_value.__str__.__module__ != "enum":
# check if function was overloaded
name = str(enum_value)
else:
name = enum_value.name.replace("_", " ")
return name
class QEnumComboBox(QComboBox):
"""
ComboBox presenting options from a python Enum.
If the Enum class does not implement `__str__` then a human readable name
is created from the name of the enum member, replacing underscores with spaces.
"""
currentEnumChanged = Signal(object)
def __init__(
self, parent=None, enum_class: Optional[EnumMeta] = None, allow_none=False
):
super().__init__(parent)
self._enum_class = None
self._allow_none = False
if enum_class is not None:
self.setEnumClass(enum_class, allow_none)
self.currentIndexChanged.connect(self._emit_signal)
def setEnumClass(self, enum: Optional[EnumMeta], allow_none=False):
"""
Set enum class from which members value should be selected
"""
self.clear()
self._enum_class = enum
self._allow_none = allow_none and enum is not None
if allow_none:
super().addItem(NONE_STRING)
super().addItems(list(map(_get_name, self._enum_class.__members__.values())))
def enumClass(self) -> Optional[EnumMeta]:
"""return current Enum class"""
return self._enum_class
def isOptional(self) -> bool:
"""return if current enum is with optional annotation"""
return self._allow_none
def clear(self):
self._enum_class = None
self._allow_none = False
super().clear()
def currentEnum(self) -> Optional[EnumType]:
"""current value as Enum member"""
if self._enum_class is not None:
if self._allow_none:
if self.currentText() == NONE_STRING:
return None
else:
return list(self._enum_class.__members__.values())[
self.currentIndex() - 1
]
return list(self._enum_class.__members__.values())[self.currentIndex()]
return None
def setCurrentEnum(self, value: Optional[EnumType]) -> None:
"""Set value with Enum."""
if self._enum_class is None:
raise RuntimeError(
"Uninitialized enum class. Use `setEnumClass` before `setCurrentEnum`."
)
if value is None and self._allow_none:
self.setCurrentIndex(0)
return
if not isinstance(value, self._enum_class):
raise TypeError(
f"setValue(self, Enum): argument 1 has unexpected type {type(value).__name__!r}"
)
self.setCurrentText(_get_name(value))
def _emit_signal(self):
if self._enum_class is not None:
self.currentEnumChanged.emit(self.currentEnum())
def insertItems(self, *_, **__):
raise RuntimeError("EnumComboBox does not allow to insert items")
def insertItem(self, *_, **__):
raise RuntimeError("EnumComboBox does not allow to insert item")
def addItems(self, *_, **__):
raise RuntimeError("EnumComboBox does not allow to add items")
def addItem(self, *_, **__):
raise RuntimeError("EnumComboBox does not allow to add item")
def setInsertPolicy(self, policy):
raise RuntimeError("EnumComboBox does not allow to insert item")

View File

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

0
superqt/py.typed Normal file
View File

View File

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

View File

@@ -483,6 +483,5 @@ def _sliderValueFromPosition(
return max if upsideDown else min
if position >= span:
return min if upsideDown else max
range = max - min
tmp = min + position * range / span
return max - tmp if upsideDown else tmp + min
tmp = (max - min) * (position / span)
return (max - tmp) if upsideDown else tmp + min

View File

@@ -1,5 +1,6 @@
from enum import IntEnum
from functools import partial
from typing import Any
from ..qtcompat.QtCore import QPoint, QSize, Qt, Signal
from ..qtcompat.QtGui import QFontMetrics, QValidator
@@ -86,6 +87,9 @@ class _SliderProxy:
def setTickPosition(self, pos) -> None:
self._slider.setTickPosition(pos)
def __getattr__(self, name) -> Any:
return getattr(self._slider, name)
def _handle_overloaded_slider_sig(args, kwargs):
parent = None

View File

@@ -5,7 +5,7 @@ import pytest
from superqt.qtcompat.QtCore import QEvent, QPoint, QPointF, Qt
from superqt.qtcompat.QtGui import QHoverEvent
from superqt.qtcompat.QtWidgets import QStyle, QStyleOptionSlider
from superqt.sliders._generic_slider import _GenericSlider
from superqt.sliders._generic_slider import _GenericSlider, _sliderValueFromPosition
from ._testutil import _linspace, _mouse_event, _wheel_event, skip_on_linux_qt6
@@ -155,3 +155,28 @@ def test_slider_extremes(gslider: _GenericSlider, mag, qtbot):
gslider.setValue(i)
assert math.isclose(gslider.value(), i, rel_tol=1e-8)
gslider.initStyleOption(QStyleOptionSlider())
# args are (min: float, max: float, position: int, span: int, upsideDown: bool)
@pytest.mark.parametrize(
"args, result",
[
# (min, max, pos, span[, inverted]), expectation
# data range (1, 2)
((1, 2, 50, 100), 1.5),
((1, 2, 70, 100), 1.7),
((1, 2, 70, 100, True), 1.3), # inverted appearance
((1, 2, 170, 100), 2),
((1, 2, 100, 100), 2),
((1, 2, -30, 100), 1),
# data range (-2, 2)
((-2, 2, 50, 100), 0),
((-2, 2, 75, 100), 1),
((-2, 2, 75, 100, True), -1), # inverted appearance
((-2, 2, 170, 100), 2),
((-2, 2, 100, 100), 2),
((-2, 2, -30, 100), -2),
],
)
def test_slider_value_from_position(args, result):
assert math.isclose(_sliderValueFromPosition(*args), result)

View File

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

View File

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

View File

@@ -0,0 +1,122 @@
# https://gist.github.com/FlorianRhiem/41a1ad9b694c14fb9ac3
from concurrent.futures import Future
from typing import Callable, List, Optional
from superqt.qtcompat.QtCore import (
QCoreApplication,
QMetaObject,
QObject,
Qt,
QThread,
Signal,
Slot,
)
class CallCallable(QObject):
finished = Signal(object)
instances: List["CallCallable"] = []
def __init__(self, callable, *args, **kwargs):
super().__init__()
self._callable = callable
self._args = args
self._kwargs = kwargs
CallCallable.instances.append(self)
@Slot()
def call(self):
CallCallable.instances.remove(self)
res = self._callable(*self._args, **self._kwargs)
self.finished.emit(res)
def ensure_main_thread(
func: Optional[Callable] = None, await_return: bool = False, timeout: int = 1000
):
"""Decorator that ensures a function is called in the main QApplication thread.
It can be applied to functions or methods.
Parameters
----------
func : callable
The method to decorate, must be a method on a QObject.
await_return : bool, optional
Whether to block and wait for the result of the function, or return immediately.
by default False
timeout : int, optional
If `await_return` is `True`, time (in milliseconds) to wait for the result
before raising a TimeoutError, by default 1000
"""
def _out_func(func):
def _func(*args, **kwargs):
return _run_in_thread(
func,
QCoreApplication.instance().thread(),
await_return,
timeout,
*args,
**kwargs,
)
return _func
if func is None:
return _out_func
return _out_func(func)
def ensure_object_thread(
func: Optional[Callable] = None, await_return: bool = False, timeout: int = 1000
):
"""Decorator that ensures a QObject method is called in the object's thread.
It must be applied to methods of QObjects subclasses.
Parameters
----------
func : callable
The method to decorate, must be a method on a QObject.
await_return : bool, optional
Whether to block and wait for the result of the function, or return immediately.
by default False
timeout : int, optional
If `await_return` is `True`, time (in milliseconds) to wait for the result
before raising a TimeoutError, by default 1000
"""
def _out_func(func):
def _func(self, *args, **kwargs):
return _run_in_thread(
func, self.thread(), await_return, timeout, self, *args, **kwargs
)
return _func
if func is None:
return _out_func
return _out_func(func)
def _run_in_thread(
func: Callable,
thread: QThread,
await_return: bool,
timeout: int,
*args,
**kwargs,
):
future = Future() # type: ignore
if thread is QThread.currentThread():
result = func(*args, **kwargs)
if not await_return:
future.set_result(result)
return future
return result
f = CallCallable(func, *args, **kwargs)
f.moveToThread(thread)
f.finished.connect(future.set_result, Qt.ConnectionType.DirectConnection)
QMetaObject.invokeMethod(f, "call", Qt.ConnectionType.QueuedConnection) # type: ignore
return future.result(timeout=timeout / 1000) if await_return else future

View File

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

View File

@@ -0,0 +1,98 @@
import logging
from contextlib import suppress
from typing import List, NamedTuple, Optional
from superqt.qtcompat.QtCore import (
QMessageLogContext,
QtMsgType,
qInstallMessageHandler,
)
class Record(NamedTuple):
level: int
message: str
ctx: dict
class QMessageHandler:
"""A context manager to intercept messages from Qt.
Parameters
----------
logger : logging.Logger, optional
If provided, intercepted messages will be logged with `logger` at the
corresponding python log level, by default None
Attributes
----------
records: list of tuple
Captured messages. This is a 3-tuple of:
`(log_level: int, message: str, context: dict)`
Examples
--------
>>> handler = QMessageHandler()
>>> handler.install() # now all Qt output will be available at mh.records
>>> with QMessageHandler() as handler: # temporarily install
... ...
>>> logger = logging.getLogger(__name__)
>>> with QMessageHandler(logger): # re-reoute Qt messages to a python logger.
... ...
"""
_qt2loggertype = {
QtMsgType.QtDebugMsg: logging.DEBUG,
QtMsgType.QtInfoMsg: logging.INFO,
QtMsgType.QtWarningMsg: logging.WARNING,
QtMsgType.QtCriticalMsg: logging.ERROR, # note
QtMsgType.QtFatalMsg: logging.CRITICAL, # note
QtMsgType.QtSystemMsg: logging.CRITICAL,
}
def __init__(self, logger: Optional[logging.Logger] = None):
self.records: List[Record] = []
self._logger = logger
self._previous_handler: Optional[object] = "__uninstalled__"
def install(self):
"""Install this handler (override the current QtMessageHandler)."""
self._previous_handler = qInstallMessageHandler(self)
def uninstall(self):
"""Uninstall this handler, restoring the previous handler."""
if self._previous_handler != "__uninstalled__":
qInstallMessageHandler(self._previous_handler)
def __repr__(self):
n = type(self).__name__
return f"<{n} object at {hex(id(self))} with {len(self.records)} records>"
def __enter__(self):
"""Enter a context with this handler installed"""
self.install()
return self
def __exit__(self, *args):
self.uninstall()
def __call__(self, msgtype: QtMsgType, context: QMessageLogContext, message: str):
level = self._qt2loggertype[msgtype]
# PyQt seems to throw an error if these are simply empty
ctx = dict.fromkeys(["category", "file", "function", "line"])
with suppress(UnicodeDecodeError):
ctx["category"] = context.category
with suppress(UnicodeDecodeError):
ctx["file"] = context.file
with suppress(UnicodeDecodeError):
ctx["function"] = context.function
with suppress(UnicodeDecodeError):
ctx["line"] = context.line
self.records.append(Record(level, message, ctx))
if self._logger is not None:
self._logger.log(level, message, extra=ctx)

View File

@@ -0,0 +1,186 @@
import time
from concurrent.futures import Future, TimeoutError
import pytest
from superqt.qtcompat.QtCore import QCoreApplication, QObject, QThread, Signal
from superqt.utils import ensure_main_thread, ensure_object_thread
class SampleObject(QObject):
assigment_done = Signal()
def __init__(self):
super().__init__()
self.main_thread_res = {}
self.object_thread_res = {}
self.main_thread_prop_val = None
self.sample_thread_prop_val = None
def long_wait(self):
time.sleep(1)
@property
def sample_main_thread_property(self):
return self.main_thread_prop_val
@sample_main_thread_property.setter # type: ignore
@ensure_main_thread()
def sample_main_thread_property(self, value):
if QThread.currentThread() is not QCoreApplication.instance().thread():
raise RuntimeError("Wrong thread")
self.main_thread_prop_val = value
self.assigment_done.emit()
@property
def sample_object_thread_property(self):
return self.sample_thread_prop_val
@sample_object_thread_property.setter # type: ignore
@ensure_object_thread()
def sample_object_thread_property(self, value):
if QThread.currentThread() is not self.thread():
raise RuntimeError("Wrong thread")
self.sample_thread_prop_val = value
self.assigment_done.emit()
@ensure_main_thread
def check_main_thread(self, a, *, b=1):
if QThread.currentThread() is not QCoreApplication.instance().thread():
raise RuntimeError("Wrong thread")
self.main_thread_res = {"a": a, "b": b}
self.assigment_done.emit()
@ensure_object_thread
def check_object_thread(self, a, *, b=1):
if QThread.currentThread() is not self.thread():
raise RuntimeError("Wrong thread")
self.object_thread_res = {"a": a, "b": b}
self.assigment_done.emit()
@ensure_object_thread(await_return=True)
def check_object_thread_return(self, a):
if QThread.currentThread() is not self.thread():
raise RuntimeError("Wrong thread")
return a * 7
@ensure_object_thread(await_return=True, timeout=200)
def check_object_thread_return_timeout(self, a):
if QThread.currentThread() is not self.thread():
raise RuntimeError("Wrong thread")
time.sleep(1)
return a * 7
@ensure_object_thread(await_return=False)
def check_object_thread_return_future(self, a):
if QThread.currentThread() is not self.thread():
raise RuntimeError("Wrong thread")
time.sleep(0.4)
return a * 7
@ensure_main_thread(await_return=True)
def check_main_thread_return(self, a):
if QThread.currentThread() is not QCoreApplication.instance().thread():
raise RuntimeError("Wrong thread")
return a * 8
class LocalThread(QThread):
def __init__(self, ob):
super().__init__()
self.ob = ob
def run(self):
assert QThread.currentThread() is not QCoreApplication.instance().thread()
self.ob.check_main_thread(5, b=8)
self.ob.main_thread_prop_val = "text2"
class LocalThread2(QThread):
def __init__(self, ob):
super().__init__()
self.ob = ob
self.executed = False
def run(self):
assert QThread.currentThread() is not QCoreApplication.instance().thread()
assert self.ob.check_main_thread_return(5) == 40
self.executed = True
def test_only_main_thread(qapp):
ob = SampleObject()
ob.check_main_thread(1, b=3)
assert ob.main_thread_res == {"a": 1, "b": 3}
ob.check_object_thread(2, b=4)
assert ob.object_thread_res == {"a": 2, "b": 4}
ob.sample_main_thread_property = 5
assert ob.sample_main_thread_property == 5
ob.sample_object_thread_property = 7
assert ob.sample_object_thread_property == 7
def test_object_thread(qtbot):
ob = SampleObject()
thread = QThread()
thread.start()
ob.moveToThread(thread)
with qtbot.waitSignal(ob.assigment_done):
ob.check_object_thread(2, b=4)
assert ob.object_thread_res == {"a": 2, "b": 4}
with qtbot.waitSignal(ob.assigment_done):
ob.sample_object_thread_property = "text"
assert ob.sample_object_thread_property == "text"
assert ob.thread() is thread
thread.exit(0)
def test_main_thread(qtbot):
ob = SampleObject()
t = LocalThread(ob)
with qtbot.waitSignal(t.finished):
t.start()
assert ob.main_thread_res == {"a": 5, "b": 8}
assert ob.sample_main_thread_property == "text2"
def test_object_thread_return(qtbot):
ob = SampleObject()
thread = QThread()
thread.start()
ob.moveToThread(thread)
assert ob.check_object_thread_return(2) == 14
assert ob.thread() is thread
thread.exit(0)
def test_object_thread_return_timeout(qtbot):
ob = SampleObject()
thread = QThread()
thread.start()
ob.moveToThread(thread)
with pytest.raises(TimeoutError):
ob.check_object_thread_return_timeout(2)
thread.exit(0)
def test_object_thread_return_future(qtbot):
ob = SampleObject()
thread = QThread()
thread.start()
ob.moveToThread(thread)
future = ob.check_object_thread_return_future(2)
assert isinstance(future, Future)
assert future.result() == 14
thread.exit(0)
def test_main_thread_return(qtbot):
ob = SampleObject()
t = LocalThread2(ob)
with qtbot.wait_signal(t.finished):
t.start()
assert t.executed

View File

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

16
tox.ini
View File

@@ -1,5 +1,5 @@
[tox]
envlist = py{37,38,39}-{linux,macos,windows}-{pyqt5,pyside2,pyqt6,pyside6},py37-{linux,macos,windows}-{pyqt511,pyside511}
envlist = py{37,38,39}-{linux,macos,windows}-{pyqt5,pyside2,pyqt6,pyside6},py37-linux-{pyqt511,pyside511,pyqt512,pyqt513,pyqt514}
toxworkdir=/tmp/.tox
[coverage:report]
@@ -35,8 +35,11 @@ BACKEND =
pyside2: pyside2
pyqt6: pyqt6
pyside6: pyside6
pyqt511: pyqt511
pyside511: pyside511
pyqt511: pyqt511
pyqt512: pyqt512
pyqt513: pyqt513
pyqt514: pyqt514
[testenv]
platform =
@@ -45,9 +48,14 @@ platform =
windows: win32
passenv = CI GITHUB_ACTIONS DISPLAY XAUTHORITY
deps =
pytest-xvfb ; sys_platform == 'linux'
pyqt511: pyqt5==5.11.*
pyside511: pyside2==5.11.*
pyqt512: pyqt5==5.12.*
pyside512: pyside2==5.12.*
pyqt513: pyqt5==5.13.*
pyside513: pyside2==5.13.*
pyqt514: pyqt5==5.14.*
pyside514: pyside2==5.14.*
extras =
testing
pyqt5: pyqt5
@@ -56,4 +64,4 @@ extras =
pyside6: pyside6
commands_pre =
pyqt6,pyside6: pip install -U pytest-qt@git+https://github.com/pytest-dev/pytest-qt.git
commands = pytest --color=yes --cov=superqt --cov-report=xml {posargs}
commands = pytest --color=yes --cov=superqt --cov-report=xml --pyargs superqt {posargs}