mirror of
https://github.com/pyapp-kit/superqt.git
synced 2025-12-13 09:50:05 +01:00
feat: toggle switch (#284)
* implement toggle switch * rename, inherit QCheckBox * fix pyside6 * reimplement with QAbstractButton * refactor methods * fix sizeHint * suggestions * make sizes customizable * parse as int * Add doc to test function Co-authored-by: Grzegorz Bokota <bokota+github@gmail.com> --------- Co-authored-by: Talley Lambert <talley.lambert@gmail.com> Co-authored-by: Grzegorz Bokota <bokota+github@gmail.com>
This commit is contained in:
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
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
|
||||
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