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 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()