mirror of
https://github.com/pyapp-kit/superqt.git
synced 2025-08-17 16:00:14 +02:00
Compare commits
3 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
ba20665d57 | ||
|
939c5222af | ||
|
22beed7608 |
63
docs/combobox.md
Normal file
63
docs/combobox.md
Normal 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.
|
@@ -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
12
examples/eliding_label.py
Normal 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_()
|
@@ -5,6 +5,8 @@ except ImportError:
|
||||
__version__ = "unknown"
|
||||
|
||||
|
||||
from ._eliding_label import QElidingLabel
|
||||
from .combobox import QEnumComboBox
|
||||
from .sliders import (
|
||||
QDoubleRangeSlider,
|
||||
QDoubleSlider,
|
||||
@@ -19,10 +21,12 @@ from .spinbox import QLargeIntSpinBox
|
||||
__all__ = [
|
||||
"QDoubleRangeSlider",
|
||||
"QDoubleSlider",
|
||||
"QElidingLabel",
|
||||
"QLabeledDoubleRangeSlider",
|
||||
"QLabeledDoubleSlider",
|
||||
"QLabeledRangeSlider",
|
||||
"QLabeledSlider",
|
||||
"QLargeIntSpinBox",
|
||||
"QRangeSlider",
|
||||
"QEnumComboBox",
|
||||
]
|
||||
|
110
superqt/_eliding_label.py
Normal file
110
superqt/_eliding_label.py
Normal 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 = 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())
|
68
superqt/_tests/test_eliding_label.py
Normal file
68
superqt/_tests/test_eliding_label.py
Normal 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 wdg.sizeHint() == QSize(633, 16)
|
||||
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
|
3
superqt/combobox/__init__.py
Normal file
3
superqt/combobox/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from ._enum_combobox import QEnumComboBox
|
||||
|
||||
__all__ = ("QEnumComboBox",)
|
112
superqt/combobox/_enum_combobox.py
Normal file
112
superqt/combobox/_enum_combobox.py
Normal 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")
|
129
superqt/combobox/_tests/test_enum_comb_box.py
Normal file
129
superqt/combobox/_tests/test_enum_comb_box.py
Normal 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
|
2
tox.ini
2
tox.ini
@@ -56,4 +56,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}
|
||||
|
Reference in New Issue
Block a user