Source code for QInstrument.widgets.QJoystickPad

from __future__ import annotations

import sys
import logging
import numpy as np
from qtpy import QtWidgets, QtGui, QtCore

from QInstrument.widgets.QJoystick import QJoystick


logger = logging.getLogger(__name__)


[docs] class QTriangleButton(QtWidgets.QAbstractButton): '''Push-button that paints a filled equilateral-ish triangle. The triangle points in the given direction. Visual state (hover, pressed, disabled) is rendered automatically. Parameters ---------- direction : str One of ``'up'``, ``'down'``, ``'left'``, ``'right'``. ''' # Normalised (x, y) vertices for each direction, within [0, 1]^2. _VERTICES: dict[str, list[tuple[float, float]]] = { 'up': [(0.5, 0.0), (0.0, 1.0), (1.0, 1.0)], 'down': [(0.5, 1.0), (0.0, 0.0), (1.0, 0.0)], 'left': [(0.0, 0.5), (1.0, 0.0), (1.0, 1.0)], 'right': [(1.0, 0.5), (0.0, 0.0), (0.0, 1.0)], } _DISABLED_COLOR = QtGui.QColor(192, 192, 192) _DISABLED_BORDER = QtGui.QColor(144, 144, 144) def __init__(self, direction: str, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self._direction = direction self._color = QtGui.QColor('#a888c8') self.setAttribute(QtCore.Qt.WidgetAttribute.WA_Hover, True) def _getColor(self) -> QtGui.QColor: return self._color def _setColor(self, color: QtGui.QColor) -> None: self._color = color self.update() color = QtCore.Property('QColor', _getColor, _setColor)
[docs] def setColor(self, color: QtGui.QColor) -> None: '''Set the button fill color and repaint. Parameters ---------- color : QtGui.QColor Base fill color; pressed and border shades are derived automatically. ''' self._setColor(color)
[docs] def sizeHint(self) -> QtCore.QSize: '''Return the preferred button size.''' return QtCore.QSize(30, 30)
[docs] def paintEvent(self, event: QtGui.QPaintEvent) -> None: '''Paint the triangle, reflecting enabled/hover/pressed state.''' painter = QtGui.QPainter(self) painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, True) pad = 4 w = self.width() - 2 * pad h = self.height() - 2 * pad vertices = self._VERTICES[self._direction] path = QtGui.QPainterPath() x0, y0 = vertices[0] path.moveTo(pad + w * x0, pad + h * y0) for vx, vy in vertices[1:]: path.lineTo(pad + w * vx, pad + h * vy) path.closeSubpath() if not self.isEnabled(): fill = self._DISABLED_COLOR border = self._DISABLED_BORDER elif self.isDown(): fill = self._color.darker(140) border = self._color.darker(160) elif self.underMouse(): fill = self._color.lighter(120) border = self._color.darker(130) else: fill = self._color border = self._color.darker(140) painter.setPen(QtGui.QPen(border, 1.5)) painter.setBrush(QtGui.QBrush(fill)) painter.drawPath(path)
[docs] class QJoystickPad(QtWidgets.QWidget): '''QJoystick with four directional step buttons. Arranges a :class:`QJoystick` in the center of a 3×3 grid with a triangular step button on each side. The central joystick works normally. Each step button, when pressed, emits :attr:`positionChanged` once at ``stepFraction`` of the axis full-scale; releasing the button emits zero velocity. Signals ------- positionChanged(numpy.ndarray) Forwarded from the embedded joystick, and also emitted by the step buttons. Carries a two-element ``[vx, vy]`` array in the same output range as the joystick. Properties ========== stepFraction : float Fraction of full-scale used by the step buttons. Range ``(0, 1]``, default 0.25. Settable via stylesheet: ``qproperty-stepFraction: 0.5;`` padColor : QtGui.QColor Forwarded to the embedded joystick and the step buttons. Settable via stylesheet: ``qproperty-padColor: #rrggbb;`` knobColor : QtGui.QColor Forwarded to the embedded joystick. Settable via stylesheet: ``qproperty-knobColor: #rrggbb;`` ''' positionChanged = QtCore.Signal(object) stepped = QtCore.Signal(str) _BUTTON_SIZE = 24 # px; fixed size of each triangular step button _HOLD_THRESHOLD_MS = 300 # ms before a press is treated as a hold # Unit step vectors (fx, fy) in joystick fraction space [-1, 1]^2. _STEP_VECTORS: dict[str, tuple[float, float]] = { 'left': (-1., 0.), 'right': ( 1., 0.), 'up': ( 0., 1.), 'down': ( 0., -1.), } def __init__(self, *args, fullscale: float | None = None, stepFraction: float = 0.25, holdThreshold: int = _HOLD_THRESHOLD_MS, **kwargs) -> None: super().__init__(*args, **kwargs) self._stepFraction = float(stepFraction) self._holdThreshold = int(holdThreshold) self._setupUi(fullscale) def _setupUi(self, fullscale: float | None) -> None: self.joystick = QJoystick(fullscale=fullscale, parent=self) self.joystick.setSizePolicy( QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding) self.joystick.positionChanged.connect(self.positionChanged) self._isHeld: bool = False self._wasHeld: bool = False self._heldDirection: str | None = None self._holdTimer = QtCore.QTimer(self) self._holdTimer.setSingleShot(True) self._holdTimer.timeout.connect(self._onHeld) self._buttons: dict[str, QTriangleButton] = {} for direction in ('up', 'down', 'left', 'right'): btn = QTriangleButton(direction, parent=self) btn.setFixedSize(self._BUTTON_SIZE, self._BUTTON_SIZE) btn.pressed.connect( lambda d=direction: self._startHoldTimer(d)) btn.released.connect(self._onReleased) btn.clicked.connect( lambda checked=False, d=direction: self._onClicked(d)) self._buttons[direction] = btn layout = QtWidgets.QGridLayout(self) layout.setSpacing(0) layout.setContentsMargins(0, 0, 0, 0) AlignHCenter = QtCore.Qt.AlignmentFlag.AlignHCenter AlignVCenter = QtCore.Qt.AlignmentFlag.AlignVCenter layout.addWidget(self._buttons['up'], 0, 1, alignment=AlignHCenter) layout.addWidget(self._buttons['left'], 1, 0, alignment=AlignVCenter) layout.addWidget(self.joystick, 1, 1) layout.addWidget(self._buttons['right'], 1, 2, alignment=AlignVCenter) layout.addWidget(self._buttons['down'], 2, 1, alignment=AlignHCenter) layout.setRowStretch(1, 1) layout.setColumnStretch(1, 1) self.setSizePolicy( QtWidgets.QSizePolicy.Policy.MinimumExpanding, QtWidgets.QSizePolicy.Policy.MinimumExpanding) self._syncButtonColors()
[docs] def hasHeightForWidth(self) -> bool: '''Report that this widget maintains a 1:1 aspect ratio.''' return True
[docs] def heightForWidth(self, width: int) -> int: '''Return *width*, enforcing a square allocation.''' return width
[docs] def resizeEvent(self, event: QtGui.QResizeEvent) -> None: '''Centre a square sub-region by adjusting layout margins. When the allocated rectangle is not square — which can happen when ``hasHeightForWidth()`` is not propagated correctly through deeply nested layouts — this pads out the shorter axis so the inner grid always operates in a square region. ''' w, h = event.size().width(), event.size().height() side = min(w, h) dx = (w - side) // 2 dy = (h - side) // 2 self.layout().setContentsMargins(dx, dy, dx, dy) super().resizeEvent(event)
[docs] def sizeHint(self) -> QtCore.QSize: '''Return the preferred widget size with a 1:1 aspect ratio.''' return QtCore.QSize(200, 200)
[docs] def minimumSizeHint(self) -> QtCore.QSize: '''Return the minimum functional size with a 1:1 aspect ratio. Two button widths plus a minimum joystick diameter of 60 px. ''' side = 2 * self._BUTTON_SIZE + 60 return QtCore.QSize(side, side)
def _syncButtonColors(self) -> None: color = self.joystick.padColor for btn in self._buttons.values(): btn.setColor(color) # ------------------------------------------------------------------ # Step button slots # ------------------------------------------------------------------ def _startHoldTimer(self, direction: str) -> None: '''Begin hold detection for *direction*.''' self._isHeld = False self._wasHeld = False self._heldDirection = direction self._holdTimer.start(self._holdThreshold) @QtCore.Slot() def _onHeld(self) -> None: '''Hold threshold reached — begin continuous velocity motion.''' self._isHeld = True direction = self._heldDirection fx, fy = self._STEP_VECTORS[direction] fracs = np.array([fx, fy]) * self._stepFraction lo, hi = self.joystick.minimum(), self.joystick.maximum() self.joystick.setKnobFraction(fx * self._stepFraction, fy * self._stepFraction) self.positionChanged.emit(lo + (fracs + 1.) / 2. * (hi - lo)) @QtCore.Slot() def _onReleased(self) -> None: '''Handle button release for both click and hold paths.''' self._holdTimer.stop() if self._isHeld: self._isHeld = False self._wasHeld = True self._heldDirection = None self.joystick.setKnobFraction(0., 0.) lo, hi = self.joystick.minimum(), self.joystick.maximum() self.positionChanged.emit(np.full(2, (lo + hi) / 2.)) def _onClicked(self, direction: str) -> None: '''Handle click: suppress at end of hold, else emit stepped.''' if self._wasHeld: self._wasHeld = False return fx, fy = self._STEP_VECTORS[direction] self.joystick.setKnobFraction(fx * self._stepFraction, fy * self._stepFraction) QtCore.QTimer.singleShot( 200, lambda: self.joystick.setKnobFraction(0., 0.)) self.stepped.emit(direction) # ------------------------------------------------------------------ # stepFraction property # ------------------------------------------------------------------ def _getStepFraction(self) -> float: return self._stepFraction def _setStepFraction(self, value: float) -> None: self._stepFraction = float(value) stepFraction = QtCore.Property( float, _getStepFraction, _setStepFraction)
[docs] def setStepFraction(self, value: float) -> None: '''Set the step-button velocity fraction. Parameters ---------- value : float Fraction of full-scale in the range ``(0, 1]``. ''' self._setStepFraction(value)
# ------------------------------------------------------------------ # holdThreshold property # ------------------------------------------------------------------ def _getHoldThreshold(self) -> int: return self._holdThreshold def _setHoldThreshold(self, value: int) -> None: self._holdThreshold = int(value) holdThreshold = QtCore.Property(int, _getHoldThreshold, _setHoldThreshold)
[docs] def setHoldThreshold(self, value: int) -> None: '''Set the press duration that distinguishes a click from a hold. Parameters ---------- value : int Threshold in milliseconds. A press shorter than this emits :attr:`stepped`; a press longer than this starts continuous velocity motion via :attr:`positionChanged`. ''' self._setHoldThreshold(value)
# ------------------------------------------------------------------ # padColor / knobColor — forwarded to joystick and buttons # ------------------------------------------------------------------ def _getPadColor(self) -> QtGui.QColor: return self.joystick.padColor def _setPadColor(self, color: QtGui.QColor) -> None: self.joystick.padColor = color for btn in self._buttons.values(): btn.setColor(color) padColor = QtCore.Property('QColor', _getPadColor, _setPadColor)
[docs] def setPadColor(self, color: QtGui.QColor) -> None: '''Set the pad and step-button color. Parameters ---------- color : QtGui.QColor Base color forwarded to the joystick pad and all four step buttons. ''' self._setPadColor(color)
def _getKnobColor(self) -> QtGui.QColor: return self.joystick.knobColor def _setKnobColor(self, color: QtGui.QColor) -> None: self.joystick.knobColor = color knobColor = QtCore.Property('QColor', _getKnobColor, _setKnobColor)
[docs] def setKnobColor(self, color: QtGui.QColor) -> None: '''Set the joystick knob color. Parameters ---------- color : QtGui.QColor Base color forwarded to the embedded joystick knob. ''' self._setKnobColor(color)
# ------------------------------------------------------------------ # Range proxies # ------------------------------------------------------------------
[docs] def setRange(self, minimum: float, maximum: float) -> None: '''Set the joystick output range for both axes. Parameters ---------- minimum : float Output value at full negative deflection. maximum : float Output value at full positive deflection. ''' self.joystick.setRange(minimum, maximum)
[docs] def minimum(self) -> float: '''Return the output value at full negative deflection.''' return self.joystick.minimum()
[docs] def maximum(self) -> float: '''Return the output value at full positive deflection.''' return self.joystick.maximum()
def _getFullscale(self) -> float: return self.joystick.fullscale def _setFullscale(self, value: float) -> None: self.joystick.fullscale = value fullscale = QtCore.Property(float, _getFullscale, _setFullscale)
def example() -> None: from qtpy.QtWidgets import QApplication def report(xy: np.ndarray) -> None: print('velocity: ({:+.2f}, {:+.2f})'.format(*xy), end='\r') app = QApplication.instance() or QApplication(sys.argv) pad = QJoystickPad(fullscale=200.) pad.positionChanged.connect(report) pad.show() sys.exit(app.exec()) __all__ = ['QJoystickPad', 'QTriangleButton'] if __name__ == '__main__': example()