mirror of
https://github.com/pyapp-kit/superqt.git
synced 2025-08-11 21:11:40 +02:00
Compare commits
3 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
c0c3a387bb | ||
|
5ce74b8198 | ||
|
0b2602b460 |
@@ -1,5 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## [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)
|
||||
|
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()
|
@@ -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
|
||||
|
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
|
@@ -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