diff --git a/examples/toggle_switch.py b/examples/toggle_switch.py new file mode 100644 index 0000000..92ffca3 --- /dev/null +++ b/examples/toggle_switch.py @@ -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() diff --git a/src/superqt/__init__.py b/src/superqt/__init__.py index 2bb7f9b..2ed2c2e 100644 --- a/src/superqt/__init__.py +++ b/src/superqt/__init__.py @@ -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", ] diff --git a/src/superqt/switch/__init__.py b/src/superqt/switch/__init__.py new file mode 100644 index 0000000..3514191 --- /dev/null +++ b/src/superqt/switch/__init__.py @@ -0,0 +1,3 @@ +from superqt.switch._toggle_switch import QStyleOptionToggleSwitch, QToggleSwitch + +__all__ = ["QStyleOptionToggleSwitch", "QToggleSwitch"] diff --git a/src/superqt/switch/_toggle_switch.py b/src/superqt/switch/_toggle_switch.py new file mode 100644 index 0000000..ec42ad4 --- /dev/null +++ b/src/superqt/switch/_toggle_switch.py @@ -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 diff --git a/tests/test_toggle_switch.py b/tests/test_toggle_switch.py new file mode 100644 index 0000000..2f73d9c --- /dev/null +++ b/tests/test_toggle_switch.py @@ -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()