mirror of
https://github.com/pyapp-kit/superqt.git
synced 2025-07-21 12:11:07 +02:00
Compare commits
15 Commits
v0.7.2
...
17fd211740
Author | SHA1 | Date | |
---|---|---|---|
|
17fd211740 | ||
|
3b83a8a1e2 | ||
|
13e033e4a2 | ||
|
55b66393c3 | ||
|
b495c70206 | ||
|
a9fa720577 | ||
|
257d97ae0f | ||
|
7193480796 | ||
|
788d0f0325 | ||
|
935025eacc | ||
|
358d041c0d | ||
|
49a8114843 | ||
|
c0c3a387bb | ||
|
5ce74b8198 | ||
|
0b2602b460 |
6
.github/workflows/test_and_deploy.yml
vendored
6
.github/workflows/test_and_deploy.yml
vendored
@@ -94,14 +94,14 @@ jobs:
|
||||
dependency-ref: ${{ matrix.napari-version }}
|
||||
dependency-extras: "testing"
|
||||
qt: ${{ matrix.qt }}
|
||||
pytest-args: 'napari/_qt -k "not async and not qt_dims_2 and not qt_viewer_console_focus and not keybinding_editor and not preferences_dialog_not_dismissed"'
|
||||
pytest-args: 'src/napari/_qt --import-mode=importlib -k "not async and not qt_dims_2 and not qt_viewer_console_focus and not keybinding_editor and not preferences_dialog_not_dismissed"'
|
||||
python-version: "3.10"
|
||||
post-install-cmd: "pip install lxml_html_clean"
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
napari-version: ["", "v0.4.19.post1"]
|
||||
qt: ["pyqt5", "pyside2"]
|
||||
napari-version: [ "" ]
|
||||
qt: [ "pyqt5", "pyside2" ]
|
||||
|
||||
check-manifest:
|
||||
name: Check Manifest
|
||||
|
@@ -5,19 +5,19 @@ ci:
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.9.9
|
||||
rev: v0.12.3
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix, --unsafe-fixes]
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/abravalheri/validate-pyproject
|
||||
rev: v0.23
|
||||
rev: v0.24.1
|
||||
hooks:
|
||||
- id: validate-pyproject
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.15.0
|
||||
rev: v1.17.0
|
||||
hooks:
|
||||
- id: mypy
|
||||
exclude: tests|examples
|
||||
|
48
CHANGELOG.md
48
CHANGELOG.md
@@ -1,5 +1,43 @@
|
||||
# Changelog
|
||||
|
||||
## [v0.7.5](https://github.com/pyapp-kit/superqt/tree/v0.7.5) (2025-06-18)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.7.4...v0.7.5)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- feat: Use scientific notation for big values in labeled slider [\#226](https://github.com/pyapp-kit/superqt/pull/226) ([Czaki](https://github.com/Czaki))
|
||||
|
||||
## [v0.7.4](https://github.com/pyapp-kit/superqt/tree/v0.7.4) (2025-06-10)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.7.3...v0.7.4)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- feat: Allow setting label position on labeled slider [\#294](https://github.com/pyapp-kit/superqt/pull/294) ([brisvag](https://github.com/brisvag))
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- fix: Set SliderProxy range params to Any [\#290](https://github.com/pyapp-kit/superqt/pull/290) ([gselzer](https://github.com/gselzer))
|
||||
- Make qimage\_to\_array\(\) work on big endian [\#288](https://github.com/pyapp-kit/superqt/pull/288) ([penguinpee](https://github.com/penguinpee))
|
||||
|
||||
**Documentation updates:**
|
||||
|
||||
- docs: document QToggleSwitch [\#286](https://github.com/pyapp-kit/superqt/pull/286) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Tests & CI:**
|
||||
|
||||
- Fix napari test [\#295](https://github.com/pyapp-kit/superqt/pull/295) ([brisvag](https://github.com/brisvag))
|
||||
|
||||
## [v0.7.3](https://github.com/pyapp-kit/superqt/tree/v0.7.3) (2025-03-28)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.7.2...v0.7.3)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- feat: toggle switch [\#284](https://github.com/pyapp-kit/superqt/pull/284) ([hanjinliu](https://github.com/hanjinliu))
|
||||
- Enh: Adds a filterable kwarg to ColormapComboBox enabling filtering \(like Catalog\) [\#278](https://github.com/pyapp-kit/superqt/pull/278) ([psobolewskiPhD](https://github.com/psobolewskiPhD))
|
||||
|
||||
## [v0.7.2](https://github.com/pyapp-kit/superqt/tree/v0.7.2) (2025-03-17)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.7.1...v0.7.2)
|
||||
@@ -528,21 +566,13 @@
|
||||
|
||||
## [v0.2.1](https://github.com/pyapp-kit/superqt/tree/v0.2.1) (2021-07-10)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.0rc1...v0.2.1)
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.0...v0.2.1)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- Fix QLabeledRangeSlider API \(fix slider proxy\) [\#10](https://github.com/pyapp-kit/superqt/pull/10) ([tlambert03](https://github.com/tlambert03))
|
||||
- Fix range slider with negative min range [\#9](https://github.com/pyapp-kit/superqt/pull/9) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.2.0rc1](https://github.com/pyapp-kit/superqt/tree/v0.2.0rc1) (2021-06-26)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.0rc0...v0.2.0rc1)
|
||||
|
||||
## [v0.2.0rc0](https://github.com/pyapp-kit/superqt/tree/v0.2.0rc0) (2021-06-26)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.0...v0.2.0rc0)
|
||||
|
||||
|
||||
|
||||
\* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)*
|
||||
|
@@ -35,7 +35,7 @@ def define_env(env: "MacrosPlugin"):
|
||||
src = src.replace(
|
||||
"QApplication([])", "QApplication.instance() or QApplication([])"
|
||||
)
|
||||
src = src.replace("app.exec_()", "")
|
||||
src = src.replace("app.exec_()", "app.processEvents()")
|
||||
|
||||
exec(src)
|
||||
_grab(dest, width)
|
||||
@@ -127,7 +127,6 @@ def define_env(env: "MacrosPlugin"):
|
||||
|
||||
def _grab(dest: str | Path, width) -> list[Path]:
|
||||
"""Grab the top widgets of the application."""
|
||||
from qtpy.QtCore import QTimer
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
w = QApplication.topLevelWidgets()[-1]
|
||||
@@ -135,12 +134,3 @@ def _grab(dest: str | Path, width) -> list[Path]:
|
||||
w.activateWindow()
|
||||
w.setMinimumHeight(40)
|
||||
w.grab().save(str(dest))
|
||||
|
||||
# hack to make sure the object is truly closed and deleted
|
||||
while True:
|
||||
QTimer.singleShot(10, w.deleteLater)
|
||||
QApplication.processEvents()
|
||||
try:
|
||||
w.parent()
|
||||
except RuntimeError:
|
||||
return
|
||||
|
@@ -11,7 +11,7 @@ running in the desired thread:
|
||||
|
||||
`ensure_object_thread` ensures that a decorated bound method of a `QObject` runs
|
||||
in the thread in which the instance lives ([see qt documentation for
|
||||
details](https://doc.qt.io/qt-5/threads-qobject.html#accessing-qobject-subclasses-from-other-threads)).
|
||||
details](https://doc.qt.io/qt-6/threads-qobject.html#accessing-qobject-subclasses-from-other-threads)).
|
||||
|
||||
## Usage
|
||||
|
||||
|
@@ -27,6 +27,7 @@ The following are QWidget subclasses:
|
||||
| [`QSearchableTreeWidget`](./qsearchabletreewidget.md) | `QTreeWidget` variant with search field that filters available options |
|
||||
| [`QColorComboBox`](./qcolorcombobox.md) | `QComboBox` to select from a specified set of colors |
|
||||
| [`QColormapComboBox`](./qcolormap.md) | `QComboBox` to select from a specified set of colormaps. |
|
||||
| [`QToggleSwitch`](./qtoggleswitch.md) | `QAbstractButton` that represents a boolean value with a toggle switch. |
|
||||
|
||||
## Frames and containers
|
||||
|
||||
|
@@ -1,7 +1,7 @@
|
||||
# QEnumComboBox
|
||||
|
||||
`QEnumComboBox` is a variant of
|
||||
[`QComboBox`](https://doc.qt.io/qt-5/qcombobox.html) that populates the items in
|
||||
[`QComboBox`](https://doc.qt.io/qt-6/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
|
||||
|
@@ -20,7 +20,7 @@ app.exec_()
|
||||
|
||||
{{ show_widget() }}
|
||||
|
||||
- `QRangeSlider` inherits from [`QSlider`](https://doc.qt.io/qt-5/qslider.html)
|
||||
- `QRangeSlider` inherits from [`QSlider`](https://doc.qt.io/qt-6/qslider.html)
|
||||
and attempts to match the Qt API as closely as possible
|
||||
- It uses platform-specific styles (for handle, groove, & ticks) but also supports
|
||||
QSS style sheets.
|
||||
@@ -28,9 +28,9 @@ app.exec_()
|
||||
- Supports more than 2 handles (e.g. `slider.setValue([0, 10, 60, 80])`)
|
||||
|
||||
As `QRangeSlider` inherits from
|
||||
[`QtWidgets.QSlider`](https://doc.qt.io/qt-5/qslider.html), you can use all of
|
||||
[`QtWidgets.QSlider`](https://doc.qt.io/qt-6/qslider.html), you can use all of
|
||||
the same methods available in the [QSlider
|
||||
API](https://doc.qt.io/qt-5/qslider.html). The major difference is that `value()`
|
||||
API](https://doc.qt.io/qt-6/qslider.html). The major difference is that `value()`
|
||||
and `sliderPosition()` are reimplemented as `tuples` of `int` (where the length of
|
||||
the tuple is equal to the number of handles in the slider.)
|
||||
|
||||
|
@@ -1,7 +1,7 @@
|
||||
# QSearchableComboBox
|
||||
|
||||
`QSearchableComboBox` is a variant of
|
||||
[`QComboBox`](https://doc.qt.io/qt-5/qcombobox.html) that allow to filter list
|
||||
[`QComboBox`](https://doc.qt.io/qt-6/qcombobox.html) that allow to filter list
|
||||
of options by enter part of text. It could be drop in replacement for
|
||||
`QComboBox`.
|
||||
|
||||
|
@@ -1,13 +1,13 @@
|
||||
# QSearchableListWidget
|
||||
|
||||
`QSearchableListWidget` is a variant of
|
||||
[`QListWidget`](https://doc.qt.io/qt-5/qlistwidget.html) that add text entry
|
||||
[`QListWidget`](https://doc.qt.io/qt-6/qlistwidget.html) that add text entry
|
||||
above list widget that allow to filter list of available options.
|
||||
|
||||
Due to implementation details, this widget it does not inherit directly from
|
||||
[`QListWidget`](https://doc.qt.io/qt-5/qlistwidget.html) but it does fully
|
||||
[`QListWidget`](https://doc.qt.io/qt-6/qlistwidget.html) but it does fully
|
||||
satisfy its api. The only limitation is that it cannot be used as argument of
|
||||
[`QListWidgetItem`](https://doc.qt.io/qt-5/qlistwidgetitem.html) constructor.
|
||||
[`QListWidgetItem`](https://doc.qt.io/qt-6/qlistwidgetitem.html) constructor.
|
||||
|
||||
```python
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
24
docs/widgets/qtoggleswitch.md
Normal file
24
docs/widgets/qtoggleswitch.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# QToggleSwitch
|
||||
|
||||
`QToggleSwitch` is a
|
||||
[`QAbstractButton`](https://doc.qt.io/qt-6/qabstractbutton.html) subclass
|
||||
that represents a boolean value as a toggle switch. The API is similar to
|
||||
[`QCheckBox`](https://doc.qt.io/qt-6/qcheckbox.html) but with a different
|
||||
visual representation.
|
||||
|
||||
```python
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from superqt import QToggleSwitch
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
switch = QToggleSwitch()
|
||||
switch.show()
|
||||
|
||||
app.exec_()
|
||||
```
|
||||
|
||||
{{ show_widget(80) }}
|
||||
|
||||
{{ show_members('superqt.QToggleSwitch') }}
|
@@ -27,11 +27,12 @@ qlds.setValue(0.5)
|
||||
qlds.setSingleStep(0.1)
|
||||
|
||||
qlrs = QLabeledRangeSlider(ORIENTATION)
|
||||
qlrs.valueChanged.connect(lambda e: print("QLabeledRangeSlider valueChanged", e))
|
||||
qlrs.setValue((20, 60))
|
||||
qlrs.valueChanged.connect(lambda e: print("qlrs valueChanged", e))
|
||||
qlrs.setRange(0, 10**11)
|
||||
qlrs.setValue((20, 60 * 10**9))
|
||||
|
||||
qldrs = QLabeledDoubleRangeSlider(ORIENTATION)
|
||||
qldrs.valueChanged.connect(lambda e: print("qlrs valueChanged", e))
|
||||
qldrs.valueChanged.connect(lambda e: print("qldrs valueChanged", e))
|
||||
qldrs.setRange(0, 1)
|
||||
qldrs.setSingleStep(0.01)
|
||||
qldrs.setValue((0.2, 0.7))
|
||||
|
67
examples/toggle_switch.py
Normal file
67
examples/toggle_switch.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from qtpy import QtCore, QtGui
|
||||
from qtpy.QtWidgets import QApplication, QStyle, QVBoxLayout, QWidget
|
||||
|
||||
from superqt import QToggleSwitch
|
||||
from superqt.switch import QStyleOptionToggleSwitch
|
||||
|
||||
QSS_EXAMPLE = """
|
||||
QToggleSwitch {
|
||||
qproperty-onColor: red;
|
||||
qproperty-handleSize: 12;
|
||||
qproperty-switchWidth: 30;
|
||||
qproperty-switchHeight: 16;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class QRectangleToggleSwitch(QToggleSwitch):
|
||||
"""A rectangle shaped toggle switch."""
|
||||
|
||||
def drawGroove(
|
||||
self,
|
||||
painter: QtGui.QPainter,
|
||||
rect: QtCore.QRectF,
|
||||
option: QStyleOptionToggleSwitch,
|
||||
) -> None:
|
||||
"""Draw the groove of the switch."""
|
||||
painter.setPen(QtCore.Qt.PenStyle.NoPen)
|
||||
is_checked = option.state & QStyle.StateFlag.State_On
|
||||
painter.setBrush(option.on_color if is_checked else option.off_color)
|
||||
painter.setOpacity(0.8)
|
||||
painter.drawRect(rect)
|
||||
|
||||
def drawHandle(self, painter, rect, option):
|
||||
"""Draw the handle of the switch."""
|
||||
painter.drawRect(rect)
|
||||
|
||||
|
||||
class QToggleSwitchWithText(QToggleSwitch):
|
||||
"""A toggle switch with text on the handle."""
|
||||
|
||||
def drawHandle(
|
||||
self,
|
||||
painter: QtGui.QPainter,
|
||||
rect: QtCore.QRectF,
|
||||
option: QStyleOptionToggleSwitch,
|
||||
) -> None:
|
||||
super().drawHandle(painter, rect, option)
|
||||
|
||||
text = "ON" if option.state & QStyle.StateFlag.State_On else "OFF"
|
||||
painter.setPen(QtGui.QPen(QtGui.QColor("black")))
|
||||
font = painter.font()
|
||||
font.setPointSize(5)
|
||||
painter.setFont(font)
|
||||
painter.drawText(rect, QtCore.Qt.AlignmentFlag.AlignCenter, text)
|
||||
|
||||
|
||||
app = QApplication([])
|
||||
widget = QWidget()
|
||||
layout = QVBoxLayout(widget)
|
||||
layout.addWidget(QToggleSwitch("original"))
|
||||
switch_styled = QToggleSwitch("stylesheet")
|
||||
switch_styled.setStyleSheet(QSS_EXAMPLE)
|
||||
layout.addWidget(switch_styled)
|
||||
layout.addWidget(QRectangleToggleSwitch("rectangle"))
|
||||
layout.addWidget(QToggleSwitchWithText("with text"))
|
||||
widget.show()
|
||||
app.exec()
|
@@ -51,7 +51,7 @@ test = [
|
||||
"pint",
|
||||
"pytest",
|
||||
"pytest-cov",
|
||||
"pytest-qt",
|
||||
"pytest-qt==4.4.0",
|
||||
"numpy",
|
||||
"cmap",
|
||||
"pyconify",
|
||||
|
@@ -22,6 +22,7 @@ from .sliders import (
|
||||
QRangeSlider,
|
||||
)
|
||||
from .spinbox import QLargeIntSpinBox
|
||||
from .switch import QToggleSwitch
|
||||
from .utils import (
|
||||
QFlowLayout,
|
||||
QMessageHandler,
|
||||
@@ -51,6 +52,7 @@ __all__ = [
|
||||
"QSearchableComboBox",
|
||||
"QSearchableListWidget",
|
||||
"QSearchableTreeWidget",
|
||||
"QToggleSwitch",
|
||||
"ensure_main_thread",
|
||||
"ensure_object_thread",
|
||||
]
|
||||
|
@@ -3,11 +3,12 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from cmap import Colormap
|
||||
from qtpy.QtCore import Qt, Signal
|
||||
from qtpy.QtCore import QSortFilterProxyModel, QStringListModel, Qt, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QButtonGroup,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QCompleter,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QSizePolicy,
|
||||
@@ -26,6 +27,7 @@ if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from cmap._colormap import ColorStopsLike
|
||||
from qtpy.QtGui import QKeyEvent
|
||||
|
||||
|
||||
CMAP_ROLE = Qt.ItemDataRole.UserRole + 1
|
||||
@@ -45,6 +47,9 @@ class QColormapComboBox(QComboBox):
|
||||
add_colormap_text: str, optional
|
||||
The text to display for the "Add Colormap..." item.
|
||||
Default is "Add Colormap...".
|
||||
filterable: bool, optional
|
||||
Whether the user can filter colormaps by typing in the line edit.
|
||||
Default is True. Can also be set with `setFilterable`.
|
||||
"""
|
||||
|
||||
currentColormapChanged = Signal(Colormap)
|
||||
@@ -55,18 +60,20 @@ class QColormapComboBox(QComboBox):
|
||||
*,
|
||||
allow_user_colormaps: bool = False,
|
||||
add_colormap_text: str = "Add Colormap...",
|
||||
filterable: bool = True,
|
||||
) -> None:
|
||||
# init QComboBox
|
||||
super().__init__(parent)
|
||||
self._add_color_text: str = add_colormap_text
|
||||
self._allow_user_colors: bool = allow_user_colormaps
|
||||
self._last_cmap: Colormap | None = None
|
||||
self._filterable: bool = False
|
||||
|
||||
self.setLineEdit(_PopupColormapLineEdit(self))
|
||||
self.lineEdit().setReadOnly(True)
|
||||
line_edit = _PopupColormapLineEdit(self, allow_invalid=False)
|
||||
self.setLineEdit(line_edit)
|
||||
self.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
|
||||
self.setItemDelegate(QColormapItemDelegate(self))
|
||||
|
||||
self.currentIndexChanged.connect(self._on_index_changed)
|
||||
# there's a little bit of a potential bug here:
|
||||
# if the user clicks on the "Add Colormap..." item
|
||||
# then an indexChanged signal will be emitted, but it may not
|
||||
@@ -75,6 +82,33 @@ class QColormapComboBox(QComboBox):
|
||||
|
||||
self.setUserAdditionsAllowed(allow_user_colormaps)
|
||||
|
||||
# Create a proxy model to handle filtering
|
||||
self._proxy_model = QSortFilterProxyModel(self)
|
||||
# use string list model as source model
|
||||
self._proxy_model.setSourceModel(QStringListModel(self))
|
||||
self._proxy_model.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
|
||||
|
||||
# Setup completer
|
||||
self._completer = QCompleter(self)
|
||||
self._completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
|
||||
self._completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
|
||||
self._completer.setFilterMode(Qt.MatchFlag.MatchContains)
|
||||
self._completer.setModel(self._proxy_model)
|
||||
|
||||
# set the delegate for both the popup and the combobox
|
||||
if popup := self._completer.popup():
|
||||
popup.setItemDelegate(self.itemDelegate())
|
||||
|
||||
# Update completer model when items change
|
||||
if model := self.model():
|
||||
model.rowsInserted.connect(self._update_completer_model)
|
||||
model.rowsRemoved.connect(self._update_completer_model)
|
||||
|
||||
self.setFilterable(filterable)
|
||||
|
||||
self.currentIndexChanged.connect(self._on_index_changed)
|
||||
line_edit.editingFinished.connect(self._on_editing_finished)
|
||||
|
||||
def userAdditionsAllowed(self) -> bool:
|
||||
"""Returns whether the user can add custom colors."""
|
||||
return self._allow_user_colors
|
||||
@@ -96,9 +130,26 @@ class QColormapComboBox(QComboBox):
|
||||
elif not self._allow_user_colors:
|
||||
self.removeItem(idx)
|
||||
|
||||
def setFilterable(self, filterable: bool) -> None:
|
||||
"""Set whether the user can enter/filter colormaps by typing in the line edit.
|
||||
|
||||
If enabled, the user can select the text in the line edit and type to
|
||||
filter the list of colormaps. The completer will show a list of matching
|
||||
colormaps as the user types. If disabled, the user can only select from
|
||||
the combo box dropdown.
|
||||
"""
|
||||
self._filterable = bool(filterable)
|
||||
self.setCompleter(self._completer if self._filterable else None)
|
||||
self.lineEdit().setReadOnly(not self._filterable)
|
||||
|
||||
def isFilterable(self) -> bool:
|
||||
"""Returns whether the user can filter the list of colormaps."""
|
||||
return self._filterable
|
||||
|
||||
def clear(self) -> None:
|
||||
super().clear()
|
||||
self.setUserAdditionsAllowed(self._allow_user_colors)
|
||||
self._update_completer_model()
|
||||
|
||||
def itemColormap(self, index: int) -> Colormap | None:
|
||||
"""Returns the color of the item at the given index."""
|
||||
@@ -124,14 +175,23 @@ class QColormapComboBox(QComboBox):
|
||||
# make sure the "Add Colormap..." item is last
|
||||
idx = self.findData(self._add_color_text, Qt.ItemDataRole.DisplayRole)
|
||||
if idx >= 0:
|
||||
with signals_blocked(self):
|
||||
self.removeItem(idx)
|
||||
self.addItem(self._add_color_text)
|
||||
self._block_completer_update = True
|
||||
try:
|
||||
with signals_blocked(self):
|
||||
self.removeItem(idx)
|
||||
self.addItem(self._add_color_text)
|
||||
finally:
|
||||
self._block_completer_update = False
|
||||
|
||||
def addColormaps(self, colors: Sequence[Any]) -> None:
|
||||
"""Adds colors to the QComboBox."""
|
||||
for color in colors:
|
||||
self.addColormap(color)
|
||||
self._block_completer_update = True
|
||||
try:
|
||||
for color in colors:
|
||||
self.addColormap(color)
|
||||
finally:
|
||||
self._block_completer_update = False
|
||||
self._update_completer_model()
|
||||
|
||||
def currentColormap(self) -> Colormap | None:
|
||||
"""Returns the currently selected Colormap or None if not yet selected."""
|
||||
@@ -173,6 +233,37 @@ class QColormapComboBox(QComboBox):
|
||||
self.lineEdit().setColormap(colormap)
|
||||
self._last_cmap = colormap
|
||||
|
||||
def _update_completer_model(self) -> None:
|
||||
"""Update the completer's model with current items."""
|
||||
if getattr(self, "_block_completer_update", False):
|
||||
return
|
||||
|
||||
# Ensure we are updating the source model of the proxy
|
||||
if isinstance(src_model := self._proxy_model.sourceModel(), QStringListModel):
|
||||
words = [
|
||||
txt
|
||||
for i in range(self.count())
|
||||
if (txt := self.itemText(i)) != self._add_color_text
|
||||
]
|
||||
src_model.setStringList(words)
|
||||
self._proxy_model.invalidate()
|
||||
|
||||
def _on_editing_finished(self) -> None:
|
||||
text = self.lineEdit().text()
|
||||
if (cmap := try_cast_colormap(text)) is not None:
|
||||
self.currentColormapChanged.emit(cmap)
|
||||
|
||||
# if the cmap is not in the list, add it
|
||||
if self.findData(cmap, CMAP_ROLE) < 0:
|
||||
self.addColormap(cmap)
|
||||
|
||||
def keyPressEvent(self, e: QKeyEvent | None) -> None:
|
||||
if e and e.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return):
|
||||
# select the first completion when pressing enter if the popup is visible
|
||||
if (completer := self.completer()) and completer.completionCount():
|
||||
self.lineEdit().setText(completer.currentCompletion()) # type: ignore
|
||||
return super().keyPressEvent(e)
|
||||
|
||||
|
||||
CATEGORIES = ("sequential", "diverging", "cyclic", "qualitative", "miscellaneous")
|
||||
|
||||
@@ -220,7 +311,9 @@ class _PopupColormapLineEdit(QColormapLineEdit):
|
||||
|
||||
Without this, only the down arrow will show the popup. And if mousePressEvent
|
||||
is used instead, the popup will show and then immediately hide.
|
||||
Also ensure that the popup is not shown when the user selects text.
|
||||
"""
|
||||
parent = self.parent()
|
||||
if parent and hasattr(parent, "showPopup"):
|
||||
parent.showPopup()
|
||||
if not self.hasSelectedText():
|
||||
parent = self.parent()
|
||||
if parent and hasattr(parent, "showPopup"):
|
||||
parent.showPopup()
|
||||
|
@@ -43,6 +43,13 @@ class QColormapLineEdit(QLineEdit):
|
||||
checkerboard_size : int, optional
|
||||
Size (in pixels) of the checkerboard pattern to draw behind colormaps with
|
||||
transparency, by default 4. If 0, no checkerboard is drawn.
|
||||
allow_invalid : bool, optional
|
||||
If True, the user can enter any text, even if it does not represent a valid
|
||||
colormap (and `fallback_cmap` will be shown if it's invalid). If False, the text
|
||||
will be validated when editing is finished or focus is lost, and if the text is
|
||||
not a valid colormap, it will be reverted to the first available valid option
|
||||
from the completer, or, if that's not available, the last valid colormap.
|
||||
Default is True. This is only settable at initialization.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -53,6 +60,7 @@ class QColormapLineEdit(QLineEdit):
|
||||
fallback_cmap: Colormap | str | None = "gray",
|
||||
missing_icon: QIcon | QStyle.StandardPixmap = MISSING,
|
||||
checkerboard_size: int = 4,
|
||||
allow_invalid: bool = True,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setFractionalColormapWidth(fractional_colormap_width)
|
||||
@@ -69,6 +77,45 @@ class QColormapLineEdit(QLineEdit):
|
||||
self._cmap: Colormap | None = None # current colormap
|
||||
self.textChanged.connect(self.setColormap)
|
||||
|
||||
self._lastValidColormap: Colormap | None = None
|
||||
if not allow_invalid:
|
||||
self.editingFinished.connect(self._validate)
|
||||
|
||||
def _validate(self) -> None:
|
||||
"""Called when editing is finished or focus is lost.
|
||||
|
||||
If the current text does not represent a valid colormap, revert to the first
|
||||
available valid option from the completer, or, if that's not available, revert
|
||||
to the last valid colormap.
|
||||
"""
|
||||
if self._cmap is None:
|
||||
candidate = self._fist_completer_option()
|
||||
if candidate is not None:
|
||||
self.setColormap(candidate)
|
||||
self.setText(candidate.name.rsplit(":", 1)[-1])
|
||||
elif self._lastValidColormap is not None:
|
||||
self.setColormap(self._lastValidColormap)
|
||||
self.setText(self._lastValidColormap.name.rsplit(":", 1)[-1])
|
||||
# Optionally, if neither is available, you might decide to clear the text.
|
||||
else:
|
||||
# Update the last valid value.
|
||||
self._lastValidColormap = self._cmap
|
||||
|
||||
def _fist_completer_option(self) -> Colormap | None:
|
||||
"""Return the first valid Colormap from the completer's current filtered list.
|
||||
|
||||
or None if no valid option is available.
|
||||
"""
|
||||
if (
|
||||
(completer := self.completer()) is None
|
||||
or (model := completer.model()) is None
|
||||
or model.rowCount() == 0
|
||||
):
|
||||
return None
|
||||
|
||||
first_item = model.index(0, 0).data(Qt.ItemDataRole.DisplayRole)
|
||||
return try_cast_colormap(first_item)
|
||||
|
||||
def setFractionalColormapWidth(self, fraction: float) -> None:
|
||||
self._colormap_fraction: float = float(fraction)
|
||||
align = Qt.AlignmentFlag.AlignVCenter
|
||||
|
@@ -70,7 +70,7 @@ class _GenericEliding:
|
||||
text = self._wrappedText()
|
||||
last_line = fm.elidedText("".join(text[nlines:]), self._elide_mode, width)
|
||||
# join them
|
||||
return "".join(text[:nlines] + [last_line])
|
||||
return "".join([*text[:nlines], last_line])
|
||||
|
||||
def _wrappedText(self) -> list[str]:
|
||||
return _GenericEliding.wrapText(self._text, self.width(), self.font())
|
||||
|
@@ -132,7 +132,7 @@ class IconOpts:
|
||||
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)
|
||||
return cast("IconOptionDict", d)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -151,7 +151,7 @@ class _IconOptions:
|
||||
|
||||
def dict(self) -> IconOptionDict:
|
||||
# not using asdict due to pickle errors on animation
|
||||
return cast(IconOptionDict, vars(self))
|
||||
return cast("IconOptionDict", vars(self))
|
||||
|
||||
|
||||
class _QFontIconEngine(QIconEngine):
|
||||
@@ -167,7 +167,7 @@ class _QFontIconEngine(QIconEngine):
|
||||
|
||||
@property
|
||||
def _default_opts(self) -> _IconOptions:
|
||||
return cast(_IconOptions, self._opts[QIcon.State.Off][QIcon.Mode.Normal])
|
||||
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)
|
||||
@@ -358,7 +358,7 @@ class QFontIconStore(QObject):
|
||||
|
||||
def __init__(self, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent=parent)
|
||||
if tuple(cast(str, QT_VERSION).split(".")) < ("6", "0"):
|
||||
if tuple(cast("str", QT_VERSION).split(".")) < ("6", "0"):
|
||||
# QT6 drops this
|
||||
QApplication.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps)
|
||||
|
||||
@@ -480,7 +480,7 @@ class QFontIconStore(QObject):
|
||||
# in Qt6, everything becomes a static member
|
||||
QFd: QFontDatabase | type[QFontDatabase] = (
|
||||
QFontDatabase()
|
||||
if tuple(cast(str, QT_VERSION).split(".")) < ("6", "0")
|
||||
if tuple(cast("str", QT_VERSION).split(".")) < ("6", "0")
|
||||
else QFontDatabase
|
||||
)
|
||||
|
||||
|
@@ -17,7 +17,7 @@ class QSearchableTreeWidget(QWidget):
|
||||
into the `filter` line edit. An item is only shown if its, any of its ancestors',
|
||||
or any of its descendants' keys or values match this pattern.
|
||||
The regular expression follows the conventions described by the Qt docs:
|
||||
https://doc.qt.io/qt-5/qregularexpression.html#details
|
||||
https://doc.qt.io/qt-6/qregularexpression.html#details
|
||||
|
||||
Attributes
|
||||
----------
|
||||
|
@@ -1,20 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from enum import IntEnum, IntFlag, auto
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING, Any, overload
|
||||
|
||||
from qtpy import QtGui
|
||||
from qtpy.QtCore import Property, QPoint, QSize, Qt, Signal
|
||||
from qtpy.QtGui import QFontMetrics, QValidator
|
||||
from qtpy.QtGui import QDoubleValidator, QFontMetrics, QValidator
|
||||
from qtpy.QtWidgets import (
|
||||
QAbstractSlider,
|
||||
QBoxLayout,
|
||||
QDoubleSpinBox,
|
||||
QHBoxLayout,
|
||||
QLineEdit,
|
||||
QSlider,
|
||||
QSpinBox,
|
||||
QStyle,
|
||||
QStyleOptionSpinBox,
|
||||
QVBoxLayout,
|
||||
@@ -83,7 +81,7 @@ class _SliderProxy:
|
||||
def setPageStep(self, step: int) -> None:
|
||||
self._slider.setPageStep(step)
|
||||
|
||||
def setRange(self, min: int, max: int) -> None:
|
||||
def setRange(self, min: float, max: float) -> None:
|
||||
self._slider.setRange(min, max)
|
||||
|
||||
def tickInterval(self) -> int:
|
||||
@@ -185,6 +183,7 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
|
||||
self._slider = self._slider_class(parent=self)
|
||||
self._label = SliderLabel(self._slider, connect=self._setValue, parent=self)
|
||||
self._edge_label_mode: EdgeLabelMode = EdgeLabelMode.LabelIsValue
|
||||
self._edge_label_position: LabelPosition = LabelPosition.LabelsRight
|
||||
|
||||
self._rename_signals()
|
||||
self._slider.actionTriggered.connect(self.actionTriggered.emit)
|
||||
@@ -205,18 +204,29 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
|
||||
marg = (0, 0, 0, 0)
|
||||
if orientation == Qt.Orientation.Vertical:
|
||||
layout = QVBoxLayout()
|
||||
layout.addWidget(self._slider, alignment=Qt.AlignmentFlag.AlignHCenter)
|
||||
layout.addWidget(self._label, alignment=Qt.AlignmentFlag.AlignHCenter)
|
||||
if not self._edge_label_position:
|
||||
layout.addWidget(self._slider, alignment=Qt.AlignmentFlag.AlignHCenter)
|
||||
elif self._edge_label_position == LabelPosition.LabelsBelow:
|
||||
layout.addWidget(self._slider, alignment=Qt.AlignmentFlag.AlignHCenter)
|
||||
layout.addWidget(self._label, alignment=Qt.AlignmentFlag.AlignHCenter)
|
||||
else:
|
||||
layout.addWidget(self._label, alignment=Qt.AlignmentFlag.AlignHCenter)
|
||||
layout.addWidget(self._slider, alignment=Qt.AlignmentFlag.AlignHCenter)
|
||||
self._label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.setSpacing(1)
|
||||
else:
|
||||
if self._edge_label_mode == EdgeLabelMode.NoLabel:
|
||||
marg = (0, 0, 5, 0)
|
||||
|
||||
layout = QHBoxLayout() # type: ignore
|
||||
layout.addWidget(self._slider)
|
||||
layout.addWidget(self._label)
|
||||
self._label.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
if not self._edge_label_position:
|
||||
layout.addWidget(self._slider)
|
||||
elif self._edge_label_position == LabelPosition.LabelsRight:
|
||||
layout.addWidget(self._slider)
|
||||
layout.addWidget(self._label)
|
||||
self._label.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
else:
|
||||
layout.addWidget(self._label)
|
||||
layout.addWidget(self._slider)
|
||||
self._label.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
marg = (0, 0, 5, 0)
|
||||
layout.setSpacing(6)
|
||||
|
||||
old_layout = self.layout()
|
||||
@@ -249,27 +259,46 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
|
||||
)
|
||||
|
||||
self._edge_label_mode = opt
|
||||
self._on_slider_range_changed(self.minimum(), self.maximum())
|
||||
if not self._edge_label_mode:
|
||||
self._label.hide()
|
||||
w = 5 if self.orientation() == Qt.Orientation.Horizontal else 0
|
||||
self.layout().setContentsMargins(0, 0, w, 0)
|
||||
if self._edge_label_position == LabelPosition.LabelsRight:
|
||||
self.layout().setContentsMargins(0, 0, w, 0)
|
||||
elif self._edge_label_position == LabelPosition.LabelsLeft:
|
||||
self.layout().setContentsMargins(0, 0, 0, w)
|
||||
if opt & EdgeLabelMode.LabelIsValue:
|
||||
if self.isVisible():
|
||||
self._label.show()
|
||||
self._label.setMode(opt)
|
||||
self._label.setValue(self._slider.value())
|
||||
self.layout().setContentsMargins(0, 0, 0, 0)
|
||||
self._on_slider_range_changed(self.minimum(), self.maximum())
|
||||
|
||||
def edgeLabelPosition(self) -> LabelPosition:
|
||||
"""Return where/whether a label is shown at the edge of the slider."""
|
||||
return self._edge_label_position
|
||||
|
||||
def setEdgeLabelPosition(self, opt: LabelPosition) -> None:
|
||||
"""Set where/whether a label is shown at the edge of the slider."""
|
||||
if opt is LabelPosition.LabelsOnHandle:
|
||||
raise ValueError("position cannot be 'LabelPosition.LabelsOnHandle'")
|
||||
|
||||
self._edge_label_position = opt
|
||||
self._label.setVisible(bool(opt))
|
||||
# TODO: make double clickable to edit
|
||||
self.setOrientation(self.orientation())
|
||||
|
||||
# putting this after labelMode methods for the sake of mypy
|
||||
EdgeLabelMode = EdgeLabelMode
|
||||
LabelPosition = LabelPosition
|
||||
|
||||
# --------------------- private api --------------------
|
||||
|
||||
def _on_slider_range_changed(self, min_: int, max_: int) -> None:
|
||||
slash = " / " if self._edge_label_mode & EdgeLabelMode.LabelIsValue else ""
|
||||
if self._edge_label_mode & EdgeLabelMode.LabelIsRange:
|
||||
self._label.setSuffix(f"{slash}{max_}")
|
||||
self._label.setSuffix(f" / {max_}")
|
||||
else:
|
||||
self._label.setSuffix("")
|
||||
self.rangeChanged.emit(min_, max_)
|
||||
|
||||
def _on_slider_value_changed(self, v: Any) -> None:
|
||||
@@ -629,7 +658,7 @@ class QLabeledDoubleRangeSlider(QLabeledRangeSlider):
|
||||
"""The color of the bar between the first and last handle."""
|
||||
|
||||
|
||||
class SliderLabel(QDoubleSpinBox):
|
||||
class SliderLabel(QLineEdit):
|
||||
def __init__(
|
||||
self,
|
||||
slider: QSlider,
|
||||
@@ -639,52 +668,139 @@ class SliderLabel(QDoubleSpinBox):
|
||||
) -> None:
|
||||
super().__init__(parent=parent)
|
||||
self._slider = slider
|
||||
self._prefix = ""
|
||||
self._suffix = ""
|
||||
self._min = slider.minimum()
|
||||
self._max = slider.maximum()
|
||||
self._value = self._min
|
||||
self._callback = connect
|
||||
self._decimals = -1
|
||||
self.setFocusPolicy(Qt.FocusPolicy.ClickFocus)
|
||||
self.setMode(EdgeLabelMode.LabelIsValue)
|
||||
self.setDecimals(0)
|
||||
self.setText(str(self._value))
|
||||
validator = QDoubleValidator(self)
|
||||
validator.setNotation(QDoubleValidator.Notation.ScientificNotation)
|
||||
self.setValidator(validator)
|
||||
|
||||
self.setRange(slider.minimum(), slider.maximum())
|
||||
slider.rangeChanged.connect(self._update_size)
|
||||
self.setAlignment(alignment)
|
||||
self.setButtonSymbols(QSpinBox.ButtonSymbols.NoButtons)
|
||||
self.setStyleSheet("background:transparent; border: 0;")
|
||||
if connect is not None:
|
||||
self.editingFinished.connect(lambda: connect(self.value()))
|
||||
self.editingFinished.connect(self._editing_finished)
|
||||
self.editingFinished.connect(self._silent_clear_focus)
|
||||
self._update_size()
|
||||
|
||||
def _editing_finished(self):
|
||||
self._silent_clear_focus()
|
||||
self.setValue(float(self.text()))
|
||||
if self._callback:
|
||||
self._callback(self.value())
|
||||
|
||||
def setRange(self, min_: float, max_: float) -> None:
|
||||
if self._mode == EdgeLabelMode.LabelIsRange:
|
||||
max_val = max(abs(min_), abs(max_))
|
||||
n_digits = max(len(str(int(max_val))), 7)
|
||||
upper_bound = int("9" * n_digits)
|
||||
self._min = -upper_bound
|
||||
self._max = upper_bound
|
||||
self._update_size()
|
||||
else:
|
||||
max_ = max(max_, min_)
|
||||
self._min = min_
|
||||
self._max = max_
|
||||
|
||||
def setDecimals(self, prec: int) -> None:
|
||||
super().setDecimals(prec)
|
||||
# super().setDecimals(prec)
|
||||
self._decimals = prec
|
||||
self._update_size()
|
||||
|
||||
def decimals(self) -> int:
|
||||
"""Return the number of decimals used in the label."""
|
||||
return self._decimals
|
||||
|
||||
def value(self) -> float:
|
||||
return self._value
|
||||
|
||||
def setValue(self, val: Any) -> None:
|
||||
super().setValue(val)
|
||||
if val < self._min:
|
||||
val = self._min
|
||||
elif val > self._max:
|
||||
val = self._max
|
||||
self._value = val
|
||||
self.updateText()
|
||||
|
||||
def updateText(self) -> None:
|
||||
val = float(self._value)
|
||||
use_scientific = (abs(val) < 0.0001 or abs(val) > 9999999.0) and val != 0.0
|
||||
font_metrics = QFontMetrics(self.font())
|
||||
eight_len = _fm_width(font_metrics, "8")
|
||||
|
||||
available_chars = self.width() // eight_len
|
||||
|
||||
total, _fraction = f"{val:.<f}".split(".")
|
||||
|
||||
if len(total) > available_chars:
|
||||
use_scientific = True
|
||||
|
||||
if self._decimals < 0:
|
||||
if use_scientific:
|
||||
mantissa, exponent = f"{val:.{available_chars}e}".split("e")
|
||||
mantissa = mantissa.rstrip("0").rstrip(".")
|
||||
if len(mantissa) + len(exponent) + 1 < available_chars:
|
||||
text = f"{mantissa}e{exponent}"
|
||||
else:
|
||||
decimals = max(available_chars - len(exponent) - 3, 2)
|
||||
text = f"{val:.{decimals}e}"
|
||||
|
||||
else:
|
||||
decimals = max(available_chars - len(total) - 1, 2)
|
||||
text = f"{val:.{decimals}f}"
|
||||
text = text.rstrip("0").rstrip(".")
|
||||
else:
|
||||
if use_scientific:
|
||||
mantissa, exponent = f"{val:.{self._decimals}e}".split("e")
|
||||
mantissa = mantissa.rstrip("0").rstrip(".")
|
||||
text = f"{mantissa}e{exponent}"
|
||||
else:
|
||||
text = f"{val:.{self._decimals}f}"
|
||||
if text == "":
|
||||
text = "0"
|
||||
self.setText(text)
|
||||
if self._mode == EdgeLabelMode.LabelIsRange:
|
||||
self._update_size()
|
||||
|
||||
def setMaximum(self, max: float) -> None:
|
||||
super().setMaximum(max)
|
||||
if self._mode == EdgeLabelMode.LabelIsValue:
|
||||
self._update_size()
|
||||
def minimum(self):
|
||||
return self._min
|
||||
|
||||
def setMinimum(self, min: float) -> None:
|
||||
super().setMinimum(min)
|
||||
if self._mode == EdgeLabelMode.LabelIsValue:
|
||||
self._update_size()
|
||||
def setMaximum(self, max_: float) -> None:
|
||||
self.setRange(self._min, max_)
|
||||
|
||||
def maximum(self):
|
||||
return self._max
|
||||
|
||||
def setMinimum(self, min_: float) -> None:
|
||||
self.setRange(min_, self._max)
|
||||
|
||||
def setMode(self, opt: EdgeLabelMode) -> None:
|
||||
# when the edge labels are controlling slider range,
|
||||
# we want them to have a big range, but not have a huge label
|
||||
self._mode = opt
|
||||
if opt == EdgeLabelMode.LabelIsRange:
|
||||
self.setMinimum(-9999999)
|
||||
self.setMaximum(9999999)
|
||||
with contextlib.suppress(Exception):
|
||||
self._slider.rangeChanged.disconnect(self.setRange)
|
||||
else:
|
||||
self.setMinimum(self._slider.minimum())
|
||||
self.setMaximum(self._slider.maximum())
|
||||
self._slider.rangeChanged.connect(self.setRange)
|
||||
self.setRange(self._slider.minimum(), self._slider.maximum())
|
||||
self._update_size()
|
||||
|
||||
def prefix(self) -> str:
|
||||
return self._prefix
|
||||
|
||||
def setPrefix(self, prefix: str) -> None:
|
||||
self._prefix = prefix
|
||||
self._update_size()
|
||||
|
||||
def suffix(self) -> str:
|
||||
return self._suffix
|
||||
|
||||
def setSuffix(self, suffix: str) -> None:
|
||||
self._suffix = suffix
|
||||
self._update_size()
|
||||
|
||||
# --------------- private ----------------
|
||||
@@ -701,21 +817,19 @@ class SliderLabel(QDoubleSpinBox):
|
||||
|
||||
if self._mode & EdgeLabelMode.LabelIsValue:
|
||||
# determine width based on min/max/specialValue
|
||||
mintext = self.textFromValue(self.minimum())[:18]
|
||||
maxtext = self.textFromValue(self.maximum())[:18]
|
||||
mintext = str(self.minimum())[:18]
|
||||
maxtext = str(self.maximum())[:18]
|
||||
w = max(0, _fm_width(fm, mintext + fixed_content))
|
||||
w = max(w, _fm_width(fm, maxtext + fixed_content))
|
||||
if self.specialValueText():
|
||||
w = max(w, _fm_width(fm, self.specialValueText()))
|
||||
if self._mode & EdgeLabelMode.LabelIsRange:
|
||||
w += 8 # it seems as thought suffix() is not enough
|
||||
else:
|
||||
w = max(0, _fm_width(fm, self.textFromValue(self.value()))) + 3
|
||||
w = max(0, _fm_width(fm, str(self.value()))) + 3
|
||||
|
||||
w += 3 # cursor blinking space
|
||||
# get the final size hint
|
||||
opt = QStyleOptionSpinBox()
|
||||
self.initStyleOption(opt)
|
||||
# self.initStyleOption(opt)
|
||||
size = self.style().sizeFromContents(
|
||||
QStyle.ContentsType.CT_SpinBox, opt, QSize(w, h), self
|
||||
)
|
||||
|
@@ -10,7 +10,7 @@ class _IntMixin:
|
||||
self._singleStep = 1
|
||||
|
||||
def _type_cast(self, value) -> int:
|
||||
return int(round(value))
|
||||
return round(value)
|
||||
|
||||
|
||||
class _FloatMixin:
|
||||
|
3
src/superqt/switch/__init__.py
Normal file
3
src/superqt/switch/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from superqt.switch._toggle_switch import QStyleOptionToggleSwitch, QToggleSwitch
|
||||
|
||||
__all__ = ["QStyleOptionToggleSwitch", "QToggleSwitch"]
|
321
src/superqt/switch/_toggle_switch.py
Normal file
321
src/superqt/switch/_toggle_switch.py
Normal file
@@ -0,0 +1,321 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import overload
|
||||
|
||||
from qtpy import QtCore, QtGui
|
||||
from qtpy import QtWidgets as QtW
|
||||
from qtpy.QtCore import Property, Qt
|
||||
|
||||
|
||||
class QStyleOptionToggleSwitch(QtW.QStyleOptionButton):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.on_color = QtGui.QColor("#4D79C7")
|
||||
self.off_color = QtGui.QColor("#909090")
|
||||
self.handle_color = QtGui.QColor("#d5d5d5")
|
||||
self.switch_width = 24
|
||||
self.switch_height = 12
|
||||
self.handle_size = 14
|
||||
|
||||
# these aren't yet overrideable in QToggleSwitch
|
||||
self.margin = 2
|
||||
self.text_alignment = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
|
||||
|
||||
|
||||
class QToggleSwitch(QtW.QAbstractButton):
|
||||
StyleOption = QStyleOptionToggleSwitch
|
||||
|
||||
@overload
|
||||
def __init__(self, parent: QtW.QWidget | None = ...) -> None: ...
|
||||
@overload
|
||||
def __init__(self, text: str | None, parent: QtW.QWidget | None = ...) -> None: ...
|
||||
|
||||
def __init__( # type: ignore [misc] # overload
|
||||
self, text: str | None = None, parent: QtW.QWidget | None = None
|
||||
) -> None:
|
||||
if isinstance(text, QtW.QWidget):
|
||||
if parent is not None:
|
||||
raise TypeError("No overload of QToggleSwitch matches the arguments")
|
||||
parent = text
|
||||
text = None
|
||||
|
||||
# attributes for drawing the switch
|
||||
self._on_color = QtGui.QColor("#4D79C7")
|
||||
self._off_color = QtGui.QColor("#909090")
|
||||
self._handle_color = QtGui.QColor("#d5d5d5")
|
||||
self._switch_width = 24
|
||||
self._switch_height = 12
|
||||
self._handle_size = 14
|
||||
self._offset_value = 8.0
|
||||
|
||||
super().__init__(parent)
|
||||
self.setCheckable(True)
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.toggled.connect(self._animate_handle)
|
||||
|
||||
self._anim = QtCore.QPropertyAnimation(self, b"_offset", self)
|
||||
self._anim.setDuration(120)
|
||||
self._offset_value = self._offset_for_checkstate(False)
|
||||
if text:
|
||||
self.setText(text)
|
||||
|
||||
def initStyleOption(self, option: QStyleOptionToggleSwitch) -> None:
|
||||
"""Initialize the style option for the switch."""
|
||||
option.initFrom(self)
|
||||
|
||||
option.text = self.text()
|
||||
option.icon = self.icon()
|
||||
option.iconSize = self.iconSize()
|
||||
option.state |= (
|
||||
QtW.QStyle.StateFlag.State_On
|
||||
if self.isChecked()
|
||||
else QtW.QStyle.StateFlag.State_Off
|
||||
)
|
||||
|
||||
option.on_color = self.onColor
|
||||
option.off_color = self.offColor
|
||||
option.handle_color = self.handleColor
|
||||
option.switch_width = self.switchWidth
|
||||
option.switch_height = self.switchHeight
|
||||
option.handle_size = self.handleSize
|
||||
|
||||
def paintEvent(self, a0: QtGui.QPaintEvent | None) -> None:
|
||||
p = QtGui.QPainter(self)
|
||||
opt = QStyleOptionToggleSwitch()
|
||||
self.initStyleOption(opt)
|
||||
self.drawGroove(p, self._groove_rect(opt), opt)
|
||||
p.save()
|
||||
self.drawHandle(p, self._handle_rect(opt), opt)
|
||||
p.restore()
|
||||
self.drawText(p, self._text_rect(opt), opt)
|
||||
p.end()
|
||||
|
||||
def minimumSizeHint(self) -> QtCore.QSize:
|
||||
return self.sizeHint()
|
||||
|
||||
def setAnimationDuration(self, msec: int) -> None:
|
||||
"""Set the duration of the animation in milliseconds.
|
||||
|
||||
To disable animation, set duration to 0.
|
||||
"""
|
||||
self._anim.setDuration(msec)
|
||||
|
||||
def animationDuration(self) -> int:
|
||||
"""Return the duration of the animation in milliseconds."""
|
||||
return self._anim.duration()
|
||||
|
||||
def sizeHint(self) -> QtCore.QSize:
|
||||
self.ensurePolished()
|
||||
opt = QStyleOptionToggleSwitch()
|
||||
self.initStyleOption(opt)
|
||||
|
||||
fm = QtGui.QFontMetrics(self.font())
|
||||
text_size = fm.size(0, self.text())
|
||||
height = max(opt.switch_height, text_size.height()) + opt.margin * 2
|
||||
width = opt.switch_width + text_size.width() + opt.margin * 2 + 8
|
||||
return QtCore.QSize(width, height)
|
||||
|
||||
### Re-implementable methods for drawing the switch ###
|
||||
|
||||
def drawGroove(
|
||||
self,
|
||||
painter: QtGui.QPainter,
|
||||
rect: QtCore.QRectF,
|
||||
option: QStyleOptionToggleSwitch,
|
||||
) -> None:
|
||||
"""Draw the groove of the switch.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
painter : QtGui.QPainter
|
||||
The painter to use for drawing.
|
||||
rect : QtCore.QRectF
|
||||
The rectangle in which to draw the groove.
|
||||
option : QStyleOptionToggleSwitch
|
||||
The style options used for drawing.
|
||||
"""
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
|
||||
is_checked = option.state & QtW.QStyle.StateFlag.State_On
|
||||
is_enabled = option.state & QtW.QStyle.StateFlag.State_Enabled
|
||||
# draw the groove
|
||||
if is_enabled:
|
||||
painter.setBrush(option.on_color if is_checked else option.off_color)
|
||||
painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, True)
|
||||
painter.setOpacity(0.8)
|
||||
else:
|
||||
painter.setBrush(option.off_color)
|
||||
painter.setOpacity(0.6)
|
||||
|
||||
half_height = option.switch_height / 2
|
||||
painter.drawRoundedRect(rect, half_height, half_height)
|
||||
|
||||
def drawHandle(
|
||||
self,
|
||||
painter: QtGui.QPainter,
|
||||
rect: QtCore.QRectF,
|
||||
option: QStyleOptionToggleSwitch,
|
||||
) -> None:
|
||||
"""Draw the handle of the switch.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
painter : QtGui.QPainter
|
||||
The painter to use for drawing.
|
||||
rect : QtCore.QRectF
|
||||
The rectangle in which to draw the handle.
|
||||
option : QStyleOptionToggleSwitch
|
||||
The style options used for drawing.
|
||||
"""
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(option.handle_color)
|
||||
painter.setOpacity(1.0)
|
||||
painter.drawEllipse(rect)
|
||||
|
||||
def drawText(
|
||||
self,
|
||||
painter: QtGui.QPainter,
|
||||
rect: QtCore.QRectF,
|
||||
option: QStyleOptionToggleSwitch,
|
||||
) -> None:
|
||||
"""Draw the text next to the switch.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
painter : QtGui.QPainter
|
||||
The painter to use for drawing.
|
||||
rect : QtCore.QRectF
|
||||
The rectangle in which to draw the text.
|
||||
option : QStyleOptionToggleSwitch
|
||||
The style options used for drawing.
|
||||
"""
|
||||
# TODO:
|
||||
# using self.style().drawControl(CE_PushButtonLabel ...)
|
||||
# might provide a more native experience.
|
||||
text_color = option.palette.color(self.foregroundRole())
|
||||
pen = QtGui.QPen(text_color, 1)
|
||||
painter.setPen(pen)
|
||||
painter.drawText(rect, int(option.text_alignment), option.text)
|
||||
|
||||
### Properties ###
|
||||
|
||||
def _get_onColor(self) -> QtGui.QColor:
|
||||
return self._on_color
|
||||
|
||||
def _set_onColor(self, color: QtGui.QColor | QtGui.QBrush) -> None:
|
||||
self._on_color = QtGui.QColor(color)
|
||||
self.update()
|
||||
|
||||
onColor = Property(QtGui.QColor, _get_onColor, _set_onColor)
|
||||
"""Color of the switch groove when it is on."""
|
||||
|
||||
def _get_offColor(self) -> QtGui.QColor:
|
||||
return self._off_color
|
||||
|
||||
def _set_offColor(self, color: QtGui.QColor | QtGui.QBrush) -> None:
|
||||
self._off_color = QtGui.QColor(color)
|
||||
self.update()
|
||||
|
||||
offColor = Property(QtGui.QColor, _get_offColor, _set_offColor)
|
||||
"""Color of the switch groove when it is off."""
|
||||
|
||||
def _get_handleColor(self) -> QtGui.QColor:
|
||||
return self._handle_color
|
||||
|
||||
def _set_handleColor(self, color: QtGui.QColor | QtGui.QBrush) -> None:
|
||||
self._handle_color = QtGui.QColor(color)
|
||||
self.update()
|
||||
|
||||
handleColor = Property(QtGui.QColor, _get_handleColor, _set_handleColor)
|
||||
"""Color of the switch handle."""
|
||||
|
||||
def _get_switchWidth(self) -> int:
|
||||
return self._switch_width
|
||||
|
||||
def _set_switchWidth(self, width: int) -> None:
|
||||
self._switch_width = width
|
||||
self._offset_value = self._offset_for_checkstate(self.isChecked())
|
||||
self.update()
|
||||
|
||||
switchWidth = Property(int, _get_switchWidth, _set_switchWidth)
|
||||
"""Width of the switch groove."""
|
||||
|
||||
def _get_switchHeight(self) -> int:
|
||||
return self._switch_height
|
||||
|
||||
def _set_switchHeight(self, height: int) -> None:
|
||||
self._switch_height = height
|
||||
self._offset_value = self._offset_for_checkstate(self.isChecked())
|
||||
self.update()
|
||||
|
||||
switchHeight = Property(int, _get_switchHeight, _set_switchHeight)
|
||||
"""Height of the switch groove."""
|
||||
|
||||
def _get_handleSize(self) -> int:
|
||||
return self._handle_size
|
||||
|
||||
def _set_handleSize(self, size: int) -> None:
|
||||
self._handle_size = size
|
||||
self.update()
|
||||
|
||||
handleSize = Property(int, _get_handleSize, _set_handleSize)
|
||||
"""Width/height of the switch handle."""
|
||||
|
||||
### Other private methods ###
|
||||
|
||||
def _animate_handle(self, val: bool) -> None:
|
||||
end = self._offset_for_checkstate(val)
|
||||
if self._anim.duration():
|
||||
self._anim.setStartValue(self._offset_for_checkstate(not val))
|
||||
self._anim.setEndValue(end)
|
||||
self._anim.start()
|
||||
else:
|
||||
self._set_offset(end)
|
||||
|
||||
def _get_offset(self) -> float:
|
||||
return self._offset_value
|
||||
|
||||
def _set_offset(self, offset: float) -> None:
|
||||
self._offset_value = offset
|
||||
self.update()
|
||||
|
||||
_offset = Property(float, _get_offset, _set_offset)
|
||||
|
||||
def _offset_for_checkstate(self, val: bool) -> float:
|
||||
opt = QStyleOptionToggleSwitch()
|
||||
self.initStyleOption(opt)
|
||||
if val:
|
||||
offset = opt.margin + opt.switch_width - opt.switch_height / 2
|
||||
else:
|
||||
offset = opt.margin + opt.switch_height / 2
|
||||
return offset
|
||||
|
||||
def _groove_rect(self, opt: QStyleOptionToggleSwitch) -> QtCore.QRectF:
|
||||
return QtCore.QRectF(
|
||||
opt.margin, self._vertical_offset(opt), opt.switch_width, opt.switch_height
|
||||
)
|
||||
|
||||
def _handle_rect(self, opt: QStyleOptionToggleSwitch) -> QtCore.QRectF:
|
||||
return QtCore.QRectF(
|
||||
self._offset_value - opt.handle_size / 2,
|
||||
self._vertical_offset(opt) - (opt.handle_size - opt.switch_height) / 2,
|
||||
opt.handle_size,
|
||||
opt.handle_size,
|
||||
)
|
||||
|
||||
def _text_rect(self, opt: QStyleOptionToggleSwitch) -> QtCore.QRectF:
|
||||
# If handle is bigger than groove, adjust the text to the right of the handle.
|
||||
# If groove is bigger, adjust the text to the right of the groove.
|
||||
return QtCore.QRectF(
|
||||
opt.switch_width
|
||||
+ max(opt.handle_size - opt.switch_height, 0) // 2
|
||||
+ opt.margin * 2
|
||||
+ 2,
|
||||
0,
|
||||
self.width() - opt.switch_width - opt.margin * 2,
|
||||
self.height(),
|
||||
)
|
||||
|
||||
def _vertical_offset(self, opt: QStyleOptionToggleSwitch) -> int:
|
||||
"""Offset for the vertical centering of the switch."""
|
||||
return (self.height() - opt.switch_height) // 2 + opt.margin
|
@@ -1,3 +1,4 @@
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from qtpy.QtGui import QImage
|
||||
@@ -37,4 +38,8 @@ def qimage_to_array(img: QImage) -> "np.ndarray":
|
||||
arr = np.frombuffer(b, np.uint8).reshape(h, w, c)
|
||||
|
||||
# reverse channel colors for numpy
|
||||
return arr.take([2, 1, 0, 3], axis=2)
|
||||
# On big endian we need to specify a different order
|
||||
if sys.byteorder == "big":
|
||||
return arr.take([1, 2, 3, 0], axis=2) # pragma: no cover
|
||||
else:
|
||||
return arr.take([2, 1, 0, 3], axis=2)
|
||||
|
@@ -766,7 +766,7 @@ def thread_worker(
|
||||
############################################################################
|
||||
|
||||
# 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
|
||||
# see https://doc.qt.io/qt-6/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).
|
||||
@@ -777,7 +777,7 @@ def thread_worker(
|
||||
#
|
||||
# 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
|
||||
# provided on the QThread object: https://doc.qt.io/qt-6/qthread.html
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -808,7 +808,7 @@ def new_worker_qthread(
|
||||
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>)
|
||||
Threads](https://doc.qt.io/qt-6/threads-qobject.html#signals-and-slots-across-threads>)
|
||||
|
||||
Parameters
|
||||
----------
|
||||
|
@@ -76,8 +76,9 @@ def test_catalog_combo(qtbot):
|
||||
assert wdg.currentColormap() == Colormap("viridis")
|
||||
|
||||
|
||||
def test_cmap_combo(qtbot):
|
||||
wdg = QColormapComboBox(allow_user_colormaps=True)
|
||||
@pytest.mark.parametrize("filterable", [False, True])
|
||||
def test_cmap_combo(qtbot, filterable):
|
||||
wdg = QColormapComboBox(allow_user_colormaps=True, filterable=filterable)
|
||||
qtbot.addWidget(wdg)
|
||||
wdg.show()
|
||||
assert wdg.userAdditionsAllowed()
|
||||
|
116
tests/test_toggle_switch.py
Normal file
116
tests/test_toggle_switch.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from unittest.mock import Mock
|
||||
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QApplication, QCheckBox, QVBoxLayout, QWidget
|
||||
|
||||
from superqt import QToggleSwitch
|
||||
|
||||
|
||||
def test_on_and_off(qtbot):
|
||||
wdg = QToggleSwitch()
|
||||
qtbot.addWidget(wdg)
|
||||
wdg.show()
|
||||
assert not wdg.isChecked()
|
||||
wdg.setChecked(True)
|
||||
assert wdg.isChecked()
|
||||
QApplication.processEvents()
|
||||
wdg.setChecked(False)
|
||||
assert not wdg.isChecked()
|
||||
QApplication.processEvents()
|
||||
wdg.setChecked(False)
|
||||
assert not wdg.isChecked()
|
||||
wdg.toggle()
|
||||
assert wdg.isChecked()
|
||||
wdg.toggle()
|
||||
assert not wdg.isChecked()
|
||||
wdg.click()
|
||||
assert wdg.isChecked()
|
||||
wdg.click()
|
||||
assert not wdg.isChecked()
|
||||
QApplication.processEvents()
|
||||
|
||||
|
||||
def test_get_set(qtbot):
|
||||
wdg = QToggleSwitch()
|
||||
qtbot.addWidget(wdg)
|
||||
wdg.onColor = "#ff0000"
|
||||
assert wdg.onColor.name() == "#ff0000"
|
||||
wdg.offColor = "#00ff00"
|
||||
assert wdg.offColor.name() == "#00ff00"
|
||||
wdg.handleColor = "#0000ff"
|
||||
assert wdg.handleColor.name() == "#0000ff"
|
||||
wdg.setText("new text")
|
||||
assert wdg.text() == "new text"
|
||||
wdg.switchWidth = 100
|
||||
assert wdg.switchWidth == 100
|
||||
wdg.switchHeight = 100
|
||||
assert wdg.switchHeight == 100
|
||||
wdg.handleSize = 80
|
||||
assert wdg.handleSize == 80
|
||||
|
||||
|
||||
def test_mouse_click(qtbot):
|
||||
wdg = QToggleSwitch()
|
||||
mock = Mock()
|
||||
wdg.toggled.connect(mock)
|
||||
qtbot.addWidget(wdg)
|
||||
assert not wdg.isChecked()
|
||||
mock.assert_not_called()
|
||||
qtbot.mouseClick(wdg, Qt.MouseButton.LeftButton)
|
||||
assert wdg.isChecked()
|
||||
mock.assert_called_once_with(True)
|
||||
qtbot.mouseClick(wdg, Qt.MouseButton.LeftButton)
|
||||
assert not wdg.isChecked()
|
||||
|
||||
|
||||
def test_signal_emission_order(qtbot):
|
||||
"""Check if event emmision is same for QToggleSwitch and QCheckBox"""
|
||||
wdg = QToggleSwitch()
|
||||
emitted_from_toggleswitch = []
|
||||
wdg.toggled.connect(lambda: emitted_from_toggleswitch.append("toggled"))
|
||||
wdg.pressed.connect(lambda: emitted_from_toggleswitch.append("pressed"))
|
||||
wdg.clicked.connect(lambda: emitted_from_toggleswitch.append("clicked"))
|
||||
wdg.released.connect(lambda: emitted_from_toggleswitch.append("released"))
|
||||
qtbot.addWidget(wdg)
|
||||
|
||||
checkbox = QCheckBox()
|
||||
emitted_from_checkbox = []
|
||||
checkbox.toggled.connect(lambda: emitted_from_checkbox.append("toggled"))
|
||||
checkbox.pressed.connect(lambda: emitted_from_checkbox.append("pressed"))
|
||||
checkbox.clicked.connect(lambda: emitted_from_checkbox.append("clicked"))
|
||||
checkbox.released.connect(lambda: emitted_from_checkbox.append("released"))
|
||||
qtbot.addWidget(checkbox)
|
||||
|
||||
emitted_from_toggleswitch.clear()
|
||||
emitted_from_checkbox.clear()
|
||||
wdg.toggle()
|
||||
checkbox.toggle()
|
||||
assert emitted_from_toggleswitch
|
||||
assert emitted_from_toggleswitch == emitted_from_checkbox
|
||||
|
||||
emitted_from_toggleswitch.clear()
|
||||
emitted_from_checkbox.clear()
|
||||
wdg.click()
|
||||
checkbox.click()
|
||||
assert emitted_from_toggleswitch
|
||||
assert emitted_from_toggleswitch == emitted_from_checkbox
|
||||
|
||||
|
||||
def test_multiple_lines(qtbot):
|
||||
container = QWidget()
|
||||
layout = QVBoxLayout(container)
|
||||
wdg0 = QToggleSwitch("line1\nline2\nline3")
|
||||
wdg1 = QToggleSwitch("line1\nline2")
|
||||
checkbox = QCheckBox()
|
||||
layout.addWidget(wdg0)
|
||||
layout.addWidget(wdg1)
|
||||
layout.addWidget(checkbox)
|
||||
container.show()
|
||||
qtbot.addWidget(container)
|
||||
|
||||
assert wdg0.text() == "line1\nline2\nline3"
|
||||
assert wdg1.text() == "line1\nline2"
|
||||
assert wdg0.sizeHint().height() > wdg1.sizeHint().height()
|
||||
assert wdg1.sizeHint().height() > checkbox.sizeHint().height()
|
||||
assert wdg0.height() > wdg1.height()
|
||||
assert wdg1.height() > checkbox.height()
|
Reference in New Issue
Block a user