Source code for spacr.qt.widgets.toggle

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

from PySide6.QtCore import (Property, QAbstractAnimation, QPropertyAnimation,
                            QRect, QSize, Qt)
from PySide6.QtGui import QBrush, QColor, QMouseEvent, QPainter, QPen, QPicture
from PySide6.QtWidgets import (QCheckBox, QSizePolicy, QStyle,
                                QStyleOptionFocusRect)

from ..theme import _painting_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._lent_to = None self._pictured = None 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. """ lent = self._lent_to is not None if lent and self._look() != self._pictured: self._changing() lent = False if lent and not self.text() and not self.hasFocus(): return painter = QPainter(self) try: painter.setRenderHint(QPainter.Antialiasing, True) palette = _painting_palette() if self.hasFocus(): option = QStyleOptionFocusRect() option.initFrom(self) self.style().drawPrimitive(QStyle.PE_Widget, option, painter, self) painter.setPen(QPen(QColor(palette["accent"]), 1.5)) painter.setBrush(Qt.NoBrush) painter.drawRoundedRect(self.rect().adjusted(4, 4, -4, -4), 3, 3) if not lent: self._paint_switch(painter, palette) if self.text(): self._paint_caption(painter, palette) finally: painter.end()
def _paint_switch(self, painter: QPainter, palette: dict) -> None: """Draw the track and the knob. :param painter: an antialiasing painter in the switch's coordinates. :param palette: the painting 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)) def _paint_caption(self, painter: QPainter, palette: dict) -> None: """Draw the trailing label. :param painter: the painter :meth:`paintEvent` drew the switch with. :param palette: the painting palette. """ 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(), ) def _spacr_static_picture(self): """The track and knob as a picture, for the window's field to play. Over a moving field every widget repaints on every frame, and the switch paints in Python; the field composites this picture into its frames off the GUI thread instead (:class:`spacr.qt.widgets.ambient._CardTints`). :returns: ``None`` while the knob moves or the pointer holds it. """ if (self._mouse_pressed or self._anim.state() == QAbstractAnimation.Running): return None picture = QPicture() painter = QPainter(picture) painter.setRenderHint(QPainter.Antialiasing, True) self._paint_switch(painter, _painting_palette()) painter.end() self._pictured = self._look() return picture def _look(self) -> tuple: """What the track and knob are drawn from, besides the theme and the size, which reach the field as events.""" return self.isChecked(), self.isEnabled(), self._knob_pos def _spacr_lend_static(self, give_back) -> None: """Stop drawing the track and knob; the field composites them. :param give_back: called, before the switch changes, to put them back; it must call :meth:`_spacr_take_back_static`. """ self._lent_to = give_back def _spacr_take_back_static(self) -> None: """Draw the track and knob here again.""" self._lent_to = None def _changing(self) -> None: """Hand the track and knob back before the switch changes. A state set while the signals are blocked passes no hook; the next paint finds the switch is not what was pictured, hands it back then, and draws it whole, so the field shows the old track under it for that one frame. """ give_back = self._lent_to if give_back is not None: give_back()
[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._changing() 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. """ self._changing() end_x = float( self._maximum_knob_x() if self.isChecked() else self._minimum_knob_x() ) from ..preferences import get_reduce_motion if not self.isVisible() or get_reduce_motion(): 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._changing() self._knob_pos = float(v) self.update() knobPos = Property(float, _get_knob_pos, _set_knob_pos)