Source code for QInstrument.widgets.QJoystick

from __future__ import annotations

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


logger = logging.getLogger(__name__)


[docs] class QJoystick(QtWidgets.QWidget): '''Mouse-controlled joystick widget. Renders a circular pad with a draggable knob. Press and drag the knob to set a position; release to return it to center. Signals ------- positionChanged(numpy.ndarray) Emitted when the knob moves beyond the dead-band. Carries a two-element array ``[x, y]`` mapped linearly from the knob fraction ``[-1, 1]`` to ``[minimum, maximum]``. ``x`` is positive to the right; ``y`` is positive upward. Properties ========== fullscale : float Symmetric output limit: equivalent to ``setRange(-v, v)``. Default: 1.0. padColor : QtGui.QColor Base color of the pad. Gradient stops and border are derived from it via ``lighter()`` / ``darker()``. Default: ``QColor('#a888c8')`` (medium lavender). Settable via stylesheet: ``qproperty-padColor: #rrggbb;`` knobColor : QtGui.QColor Base color of the knob. Gradient stops are derived from it. Default: ``QColor('#3848b8')`` (medium blue). Settable via stylesheet: ``qproperty-knobColor: #rrggbb;`` tolerance : float Fractional dead-band; position changes smaller than this fraction of the full pad radius are suppressed. Default: 0.05. ''' positionChanged = QtCore.Signal(object) _DISABLED_PAD_LIGHT = QtGui.QColor(244, 244, 244) _DISABLED_PAD_DARK = QtGui.QColor(192, 192, 192) _DISABLED_PAD_BORDER = QtGui.QColor(144, 144, 144) _DISABLED_CROSS = QtGui.QColor(160, 160, 160, 80) _DISABLED_RIM = QtGui.QColor(216, 216, 216, 180) _DISABLED_KNOB_LIGHT = QtGui.QColor(224, 224, 232) _DISABLED_KNOB_DARK = QtGui.QColor(112, 112, 128) def __init__(self, *args, fullscale: float | None = None, **kwargs) -> None: super().__init__(*args, **kwargs) self._setupUi(fullscale) def _setupUi(self, fullscale: float | None) -> None: self.sizePolicy().setHeightForWidth(True) self.padding = 0.1 self.knobSize = 0.3 self.setRange(-(fullscale or 1.), fullscale or 1.) self.tolerance = 0.05 self._padColor = QtGui.QColor('#a888c8') self._knobColor = QtGui.QColor('#3848b8') self.position = QtCore.QPointF(0, 0) self._values = np.zeros(2) self.active = False self.radius = 0. self.limit = 0.
[docs] def setRange(self, minimum: float, maximum: float) -> None: '''Set the output range for both axes. The knob fraction ``[-1, 1]`` maps linearly to ``[minimum, maximum]``, so the center position emits ``(minimum + maximum) / 2``. Parameters ---------- minimum : float Output value at full negative deflection. maximum : float Output value at full positive deflection. ''' self._minimum = minimum self._maximum = maximum
[docs] def minimum(self) -> float: '''Return the output value at full negative deflection.''' return self._minimum
[docs] def maximum(self) -> float: '''Return the output value at full positive deflection.''' return self._maximum
@property def fullscale(self) -> float: '''Symmetric output limit; equivalent to ``setRange(-v, v)``.''' return self._maximum @fullscale.setter def fullscale(self, value: float) -> None: self.setRange(-value, value) def _getPadColor(self) -> QtGui.QColor: return self._padColor def _setPadColor(self, color: QtGui.QColor) -> None: self._padColor = color self.update() padColor = QtCore.Property('QColor', _getPadColor, _setPadColor)
[docs] def setPadColor(self, color: QtGui.QColor) -> None: '''Set the base pad color and repaint. Gradient stops and border are derived automatically via ``lighter()`` / ``darker()``. May also be set via stylesheet with ``qproperty-padColor: #rrggbb;``. Parameters ---------- color : QtGui.QColor Base color for the pad. ''' self._setPadColor(color)
def _getKnobColor(self) -> QtGui.QColor: return self._knobColor def _setKnobColor(self, color: QtGui.QColor) -> None: self._knobColor = color self.update() knobColor = QtCore.Property('QColor', _getKnobColor, _setKnobColor)
[docs] def setKnobColor(self, color: QtGui.QColor) -> None: '''Set the base knob color and repaint. Gradient stops are derived automatically via ``lighter()`` / ``darker()``. May also be set via stylesheet with ``qproperty-knobColor: #rrggbb;``. Parameters ---------- color : QtGui.QColor Base color for the knob. ''' self._setKnobColor(color)
[docs] def sizeHint(self) -> QtCore.QSize: '''Return the preferred widget size.''' return QtCore.QSize(120, 120)
[docs] def resizeEvent(self, event: QtGui.QResizeEvent) -> None: '''Recompute the pad radius and knob travel limit on resize.''' self.radius = min(self.size().width(), self.size().height()) / 2 self.radius *= (1. - self.padding) self.limit = (1. - self.knobSize) * self.radius
[docs] def setKnobFraction(self, fx: float, fy: float) -> None: '''Position the knob at a given fraction of the travel range. Intended for external control (e.g. step buttons on a :class:`QJoystickPad`). Does not emit :attr:`positionChanged`. Parameters ---------- fx : float Horizontal fraction in ``[-1, 1]``. Positive is right. fy : float Vertical fraction in ``[-1, 1]``. Positive is upward. ''' if fx == 0. and fy == 0.: self.active = False self.position = QtCore.QPointF(0., 0.) else: c = self._center() self.position = QtCore.QPointF(c.x() + fx * self.limit, c.y() - fy * self.limit) self.active = True self.update()
[docs] def paintEvent(self, event: QtGui.QPaintEvent) -> None: painter = QtGui.QPainter(self) painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, True) enabled = self.isEnabled() self._drawPad(painter, enabled) self._drawCrosshair(painter, enabled) self._drawRimHighlight(painter, enabled) self._drawKnobShadow(painter) self._drawKnob(painter, enabled)
def _drawPad(self, painter: QtGui.QPainter, enabled: bool) -> None: '''Fill the pad with a radial gradient and draw its border.''' rect = self._limitRect() c = rect.center() r = rect.width() / 2. grad = QtGui.QRadialGradient(c.x(), c.y(), r) if enabled: grad.setColorAt(0., self._padColor.lighter(155)) grad.setColorAt(1., self._padColor.darker(108)) border = self._padColor.darker(130) else: grad.setColorAt(0., self._DISABLED_PAD_LIGHT) grad.setColorAt(1., self._DISABLED_PAD_DARK) border = self._DISABLED_PAD_BORDER painter.setPen(QtGui.QPen(border, 1.5)) painter.setBrush(QtGui.QBrush(grad)) painter.drawEllipse(rect) def _drawCrosshair(self, painter: QtGui.QPainter, enabled: bool) -> None: '''Draw faint axis lines through the pad center.''' c = self._center() r = self.radius if enabled: color = self._padColor.darker(160) color.setAlpha(80) else: color = self._DISABLED_CROSS painter.setPen(QtGui.QPen(color, 1.0, QtCore.Qt.PenStyle.DashLine)) painter.drawLine(QtCore.QPointF(c.x() - r, c.y()), QtCore.QPointF(c.x() + r, c.y())) painter.drawLine(QtCore.QPointF(c.x(), c.y() - r), QtCore.QPointF(c.x(), c.y() + r)) def _drawRimHighlight(self, painter: QtGui.QPainter, enabled: bool) -> None: '''Draw a bright arc across the upper-left rim to suggest a bevel.''' rect = self._limitRect() if enabled: color = self._padColor.lighter(165) color.setAlpha(180) else: color = self._DISABLED_RIM painter.setPen(QtGui.QPen(color, 2.0)) painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) painter.drawArc(rect, 45 * 16, 135 * 16) def _drawKnobShadow(self, painter: QtGui.QPainter) -> None: '''Draw a soft drop shadow behind the knob.''' rect = self._knobRect() offset = rect.width() * 0.12 shadow = rect.translated(offset, offset) shadow.adjust(-offset * 0.2, -offset * 0.2, offset * 0.2, offset * 0.2) painter.setPen(QtCore.Qt.PenStyle.NoPen) painter.setBrush(QtGui.QColor(0, 0, 0, 55)) painter.drawEllipse(shadow) def _drawKnob(self, painter: QtGui.QPainter, enabled: bool) -> None: '''Draw the knob as a sphere.''' rect = self._knobRect() c = rect.center() r = rect.width() / 2. fx, fy = c.x() - 0.3 * r, c.y() - 0.3 * r grad = QtGui.QRadialGradient(c.x(), c.y(), r, fx, fy) if enabled: grad.setColorAt(0., self._knobColor.lighter(230)) grad.setColorAt(1., self._knobColor.darker(155)) else: grad.setColorAt(0., self._DISABLED_KNOB_LIGHT) grad.setColorAt(1., self._DISABLED_KNOB_DARK) painter.setPen(QtCore.Qt.PenStyle.NoPen) painter.setBrush(QtGui.QBrush(grad)) painter.drawEllipse(rect) # Specular highlight sr = r * 0.22 spec = QtCore.QRectF(fx - 0.7 * sr - sr, fy - 0.5 * sr - sr, sr * 2, sr * 2) painter.setBrush(QtGui.QColor(255, 255, 255, 190)) painter.drawEllipse(spec) def _limitRect(self) -> QtCore.QRectF: '''Return the bounding rectangle of the outer pad circle.''' rect = np.array([-1, -1, 2, 2]) * self.radius return QtCore.QRectF(*rect).translated(self._center()) def _knobRect(self) -> QtCore.QRectF: '''Return the bounding rectangle of the knob circle. When inactive the knob is drawn at the center; when active it follows :attr:`position`. ''' size = self.radius * self.knobSize rect = np.array([-1, -1, 2, 2]) * size pos = self.position if self.active else self._center() return QtCore.QRectF(*rect).translated(pos) def _center(self) -> QtCore.QPointF: '''Return the widget center point.''' return QtCore.QPointF(self.width() / 2, self.height() / 2) def _limited(self, point: QtCore.QPointF) -> QtCore.QPointF: '''Clamp ``point`` to within the knob travel radius. Parameters ---------- point : QtCore.QPointF Unclamped cursor position in widget coordinates. Returns ------- QtCore.QPointF Nearest point on or inside the travel circle. ''' limit_line = QtCore.QLineF(self._center(), point) if limit_line.length() > self.limit: limit_line.setLength(self.limit) return limit_line.p2()
[docs] def mousePressEvent(self, event: QtGui.QMouseEvent) -> None: '''Activate dragging when the press lands inside the knob.''' self.active = self._knobRect().contains(QtCore.QPointF(event.pos())) super().mousePressEvent(event)
[docs] def mouseReleaseEvent(self, event: QtGui.QMouseEvent) -> None: '''Deactivate dragging and return the knob to center.''' self.active = False self.position = QtCore.QPointF(0, 0) self.update() self._emitSignal()
[docs] def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None: '''Update knob position while dragging.''' self.position = self._limited(QtCore.QPointF(event.pos())) self.update() self._emitSignal()
def _fractions(self) -> np.ndarray: '''Return the current fractional knob displacement. Returns ------- numpy.ndarray Two-element array ``[fx, fy]`` in the range ``[-1, 1]``. ``fy`` is negated so that upward motion gives a positive value. Returns ``[0., 0.]`` when inactive. ''' if self.active: displacement = QtCore.QLineF(self._center(), self.position) fx = max(-1., min(1., displacement.dx() / self.limit)) fy = max(-1., min(1., -displacement.dy() / self.limit)) else: fx, fy = 0., 0. return np.array([fx, fy]) def _emitSignal(self) -> None: '''Emit :attr:`positionChanged` if the position changed. Suppresses emission when the change from the last emitted value is within :attr:`tolerance` on both axes. ''' values = self._fractions() if np.allclose(values, self._values, self.tolerance): return self._values = values lo, hi = self._minimum, self._maximum values = lo + (values + 1.) / 2. * (hi - lo) self.positionChanged.emit(values) logger.debug('{:.2f} {:.2f}'.format(*values))
def example() -> None: from qtpy.QtWidgets import QApplication def report(xy: np.ndarray) -> None: print('position: ({:+.2f}, {:+.2f})'.format(*xy), end='\r') app = QApplication.instance() or QApplication(sys.argv) joystick = QJoystick(fullscale=2.) joystick.positionChanged.connect(report) joystick.show() sys.exit(app.exec()) __all__ = ['QJoystick'] if __name__ == '__main__': example()