Source code for spacr.qt.widgets.toggle

"""Toggle — QCheckBox styled as an iOS-style switch."""
from __future__ import annotations

from PySide6.QtCore import Property, QPropertyAnimation, QRect, QSize, Qt
from PySide6.QtGui import QBrush, QColor, QMouseEvent, QPainter, QPen
from PySide6.QtWidgets import QCheckBox, QSizePolicy

from ..theme import active_palette


[docs] class Toggle(QCheckBox): """A compact switch that can be clicked or dragged between states.""" def __init__(self, text: str = "", parent=None, *, word_wrap: bool = False): """Initialize the switch with an optional trailing label. :param text: the label drawn after the switch. Empty leaves the switch alone, which is what a settings row wants -- the caption beside it is the form's, not the control's. :param parent: parent widget. :param word_wrap: let a long caption wrap to the available width. False keeps the existing single-line switch layout. """ super().__init__(text, parent) self._word_wrap = bool(word_wrap) self._track_x = 2 self._track_w = 30 self._track_h = 17 self._knob_d = 12 self._label_gap = 9 self._knob_pos = float(self._minimum_knob_x()) self._mouse_pressed = False self._dragging = False self._press_x = 0.0 self._anim = QPropertyAnimation(self, b"knobPos", self) self._anim.setDuration(140) self.stateChanged.connect(self._start_anim) self.setMinimumHeight(self._track_h + 2) if self._word_wrap: policy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) policy.setHeightForWidth(True) self.setSizePolicy(policy)
[docs] def sizeHint(self) -> "QSize": """Return the default checkbox hint widened to fit the switch track.""" base = super().sizeHint() base.setWidth( self._track_x + self._track_w + self._label_gap + base.width()) if self._word_wrap: base.setWidth(min(base.width(), self.fontMetrics().averageCharWidth() * 40)) base.setHeight(self.heightForWidth(base.width())) return base
[docs] def minimumSizeHint(self) -> QSize: """Allow wrapped captions to shrink without making their text a width floor.""" if not self._word_wrap: return super().minimumSizeHint() width = self._track_x + self._track_w + self._label_gap return QSize(width + self.fontMetrics().averageCharWidth() * 8, max(self._track_h + 2, self.fontMetrics().height() + 4))
[docs] def heightForWidth(self, width: int) -> int: """Return enough height to paint the complete wrapped caption. :param width: available control width in logical pixels. :returns: wrapped height, or the native checkbox result when disabled. """ if not self._word_wrap: return super().heightForWidth(width) text_width = max(1, width - self._track_x - self._track_w - self._label_gap) bounds = self.fontMetrics().boundingRect( QRect(0, 0, text_width, 100000), Qt.TextWordWrap, self.text()) return max(self._track_h + 2, bounds.height() + 4)
def _minimum_knob_x(self) -> int: """Return the knob's left edge in the unchecked position.""" return self._track_x + (self._track_h - self._knob_d) // 2 def _maximum_knob_x(self) -> int: """Return the knob's left edge in the checked position.""" inset = (self._track_h - self._knob_d) // 2 return self._track_x + self._track_w - self._knob_d - inset
[docs] def paintEvent(self, event): """Paint the switch track, knob, and (optional) trailing label. :param event: the paint event; not read, since the whole switch is redrawn from its state every time. """ painter = QPainter(self) painter.setRenderHint(QPainter.Antialiasing, True) palette = active_palette() checked = self.isChecked() on_color = palette.get("button_accent", palette["accent"]) off_color = palette.get( "fg_dim", palette.get("border", palette["fg"])) state_color = QColor( on_color if checked else off_color) if not self.isEnabled(): state_color.setAlpha(110) on_fill = palette.get("accent_soft", palette["accent"]) track_fill = QColor( on_fill if checked else palette["surface_alt"]) if not self.isEnabled(): track_fill.setAlpha(90) painter.setBrush(QBrush(track_fill)) painter.setPen(QPen(state_color, 1.5)) track_rect = QRect(self._track_x, (self.height() - self._track_h) // 2, self._track_w, self._track_h) painter.drawRoundedRect(track_rect, self._track_h // 2, self._track_h // 2) knob_x = int(self._knob_pos) knob_y = (self.height() - self._knob_d) // 2 painter.setPen(Qt.NoPen) painter.setBrush(QBrush(state_color)) painter.drawEllipse(QRect(knob_x, knob_y, self._knob_d, self._knob_d)) if self.text(): painter.setPen(QColor(palette["fg"])) text_x = self._track_x + self._track_w + self._label_gap if self._word_wrap: painter.drawText( QRect(text_x, 0, max(1, self.width() - text_x), self.height()), Qt.AlignLeft | Qt.AlignVCenter | Qt.TextWordWrap, self.text()) else: painter.drawText( text_x, (self.height() + painter.fontMetrics().ascent()) // 2 - 2, self.text(), )
[docs] def mousePressEvent(self, event: QMouseEvent) -> None: """Begin a click or drag without delegating a second toggle to Qt. :param event: the mouse press; a left-button press on an enabled switch is consumed and its x position recorded, and any other press goes to the base class. """ if event.button() != Qt.LeftButton or not self.isEnabled(): super().mousePressEvent(event) return self._anim.stop() self._mouse_pressed = True self._dragging = False self._press_x = float(event.position().x()) event.accept()
[docs] def mouseMoveEvent(self, event: QMouseEvent) -> None: """Move the knob with the pointer while the left button is held. :param event: the mouse move; its x position drags the knob once it is 3 pixels or more from the press. """ if not self._mouse_pressed or not (event.buttons() & Qt.LeftButton): super().mouseMoveEvent(event) return x = float(event.position().x()) if abs(x - self._press_x) >= 3.0: self._dragging = True if self._dragging: left = x - self._knob_d / 2.0 left = max(self._minimum_knob_x(), min(self._maximum_knob_x(), left)) self._set_knob_pos(left) event.accept()
[docs] def mouseReleaseEvent(self, event: QMouseEvent) -> None: """Toggle on a tap, or select the side where a drag was released. :param event: the mouse release; only a left-button release ending a press on this switch is handled, anything else goes to the base class. """ if event.button() != Qt.LeftButton or not self._mouse_pressed: super().mouseReleaseEvent(event) return self._mouse_pressed = False if self._dragging: target = ( self._knob_pos + self._knob_d / 2.0 >= self._track_x + self._track_w / 2.0 ) else: target = not self.isChecked() self._dragging = False if target == self.isChecked(): self._start_anim(self.checkState()) else: self.setChecked(target) self.clicked.emit() event.accept()
def _start_anim(self, _state): """Slide the knob to whichever end the new state calls for. A toggle that is not on screen jumps rather than animating: there is nothing to see, and an animation running for a hidden widget costs frames for nobody. :param _state: the new check state; the switch is re-read, so it is not used. """ end_x = float( self._maximum_knob_x() if self.isChecked() else self._minimum_knob_x() ) if not self.isVisible(): self._anim.stop() self._set_knob_pos(end_x) return self._anim.stop() self._anim.setStartValue(self._knob_pos) self._anim.setEndValue(end_x) self._anim.start() def _get_knob_pos(self) -> float: """Return the knob's current horizontal position. :returns: the position in pixels. This is the property the animation drives. """ return self._knob_pos def _set_knob_pos(self, v: float) -> None: """Move the knob and repaint. :param v: the new horizontal position in pixels. """ self._knob_pos = float(v) self.update() knobPos = Property(float, _get_knob_pos, _set_knob_pos)