Source code for pyrobotiqgripper.joystick_visual_tool

"""Live overlay bar visualizing a joystick axis, plus optional colored
"control zones" (e.g. deadzones, force-ramp ranges).

Generic over the joystick source: anything exposing ``get_axis(axis) ->
float`` works, so the same tool visualizes a
:class:`~pyrobotiqgripper.mouse_joystick.MouseJoystick` or a real
``pygame.joystick.Joystick``.
"""

from __future__ import annotations

import threading
from typing import Dict, List, Optional, Tuple

try:
    from PySide6.QtCore import QPointF, QRectF, Qt, QTimer
    from PySide6.QtGui import QColor, QCursor, QGuiApplication, QPainter
    from PySide6.QtWidgets import QWidget
    _PYSIDE6_AVAILABLE = True
except ImportError:
    _PYSIDE6_AVAILABLE = False


class ClampedAxisJoystick:
    """Wraps a joystick, clamping :meth:`get_axis` into [0, 1].

    Some control paths (e.g. ``RobotiqGripper.realTimePositionMove``) clamp
    a signed [-1, 1] axis down to [0, 1] internally, discarding the negative
    half. Feeding :class:`JoystickVisualTool` this wrapper -- instead of the
    raw joystick -- with ``output_range="unsigned"`` keeps the marker/zones
    consistent with what that control path actually does with the signal,
    without changing how the raw axis is read anywhere else.
    """

    def __init__(self, joystick):
        self._joystick = joystick

    def get_axis(self, axis):
        return max(0.0, min(1.0, self._joystick.get_axis(axis)))


[docs] class JoystickVisualTool: """Thin, full-screen-width overlay bar showing a live marker position and optional colored control zones. Dedicated to a joystick-like object, given at construction: the marker tracks ``joystick.get_axis(axis)`` on its own (polled on this visualizer's own timer -- a caller never pushes marker updates itself). A caller may still define colored zones behind the marker via :meth:`add_control_zone` / :meth:`clear_control_zones`. Runs entirely on the shared background Qt thread (see :mod:`pyrobotiqgripper.qt_app_host`), started and stopped explicitly with :meth:`start` and :meth:`stop`, so calling :meth:`add_control_zone` from a control loop costs at most a lock-protected list update -- nothing on that hot path ever touches Qt directly. Zones are always painted on a [0, 1] fraction of the bar, but a caller may hand :meth:`add_control_zone` bounds expressed in whichever referential ``output_range`` selects -- [0, 1] for ``"unsigned"``, [-1, 1] for ``"signed"`` -- matching the range ``joystick.get_axis(axis)`` itself returns. The bar is always click-through (clicks/hover never reach it; they pass straight to whatever's underneath), so it never blocks normal use of the screen. To stay legible despite that, it also polls the OS-level cursor position on its own redraw timer and turns 60% transparent whenever the cursor happens to be over it, so whatever's underneath remains visible. Args: joystick: Anything exposing ``get_axis(axis) -> float`` (e.g. :class:`~pyrobotiqgripper.mouse_joystick.MouseJoystick` or a ``pygame.joystick.Joystick``). Its live reading drives the marker. axis (int, optional): Axis index passed to ``joystick.get_axis``. Defaults to ``0``. output_range (str, optional): Referential ``joystick.get_axis(axis)`` reports in, and that :meth:`add_control_zone` bounds must be expressed in: ``"unsigned"`` for [0, 1] (default), or ``"signed"`` for [-1, 1]. refresh_interval_ms (int, optional): Redraw period, in milliseconds. Defaults to ``33`` (~30 FPS). bar_height (int, optional): Height of the overlay bar, in pixels. Defaults to ``48``. anchor (str, optional): Which edge of the primary screen to pin the bar to: ``"top"`` or ``"bottom"``. Positioned against the screen's *available* area (i.e. excluding the taskbar), so ``"bottom"`` sits just above the taskbar rather than under it. Defaults to ``"bottom"``, out of the way of the menu bars most applications keep at the top of their window. Warning: Requires the optional ``PySide6`` package (``pip install PySide6``). Warning: See the same PySide6/background-thread shutdown warning documented on :class:`~pyrobotiqgripper.visualizer.GripperVisualizer`: end your script with ``os._exit(0)`` after calling :meth:`stop`, rather than returning normally. Examples: >>> from pyrobotiqgripper.mouse_joystick import MouseJoystick >>> from pyrobotiqgripper.joystick_visual_tool import JoystickVisualTool >>> joystick = MouseJoystick(deadzone=0, output_range="unsigned") >>> visual_tool = JoystickVisualTool(joystick, output_range="unsigned") >>> visual_tool.start() >>> zone_id = visual_tool.add_control_zone(0.0, 0.05, "cyan") >>> visual_tool.clear_control_zones() >>> visual_tool.stop() """
[docs] def __init__(self, joystick, axis: int = 0, output_range: str = "unsigned", refresh_interval_ms: int = 33, bar_height: int = 48, anchor: str = "bottom"): if not _PYSIDE6_AVAILABLE: raise ImportError( "JoystickVisualTool requires the optional PySide6 package. " "Install it with `pip install PySide6`." ) if anchor not in ("top", "bottom"): raise ValueError(f"anchor must be 'top' or 'bottom', got {anchor!r}") if output_range not in ("signed", "unsigned"): raise ValueError( f"output_range must be 'signed' or 'unsigned', got {output_range!r}" ) self._joystick = joystick self._axis = axis self._output_range = output_range self._refresh_interval_ms = refresh_interval_ms self._bar_height = bar_height self._anchor = anchor self._lock = threading.Lock() self._next_zone_id = 0 self._zones_by_id: Dict[int, Tuple[float, float, object]] = {} self._window: Optional["_JoystickVisualToolWindow"] = None self._poll_timer: Optional["QTimer"] = None
# --- Public, thread-safe API --------------------------------------
[docs] def add_control_zone(self, start: float, end: float, color: object) -> int: """Add a colored zone drawn behind the marker. Args: start (float): Start of the zone, in this instance's ``output_range`` referential: [0, 1] if ``"unsigned"``, [-1, 1] if ``"signed"``. end (float): End of the zone, in the same referential as ``start``. color: Any Qt color spec accepted by ``QColor`` (e.g. the name ``"cyan"``, an ``(r, g, b)``/``(r, g, b, a)`` tuple, or a ``QColor`` instance). Returns: int: An opaque id, usable with :meth:`remove_control_zone`. """ with self._lock: zone_id = self._next_zone_id self._next_zone_id += 1 self._zones_by_id[zone_id] = (float(start), float(end), _to_qcolor(color)) return zone_id
[docs] def remove_control_zone(self, zone_id: int) -> None: """Remove a single zone previously added with :meth:`add_control_zone`. Does nothing if ``zone_id`` isn't currently defined. """ with self._lock: self._zones_by_id.pop(zone_id, None)
[docs] def clear_control_zones(self) -> None: """Remove every zone currently defined. Meant to be called whenever a caller's own state changes (e.g. a gripper's control mode), right before defining a fresh set of zones for the new state. """ with self._lock: self._zones_by_id.clear()
def _to_fraction(self, value: float) -> float: if self._output_range == "signed": return (value + 1) / 2 return value def _snapshot_zones(self) -> List[Tuple[float, float, object]]: with self._lock: zones = list(self._zones_by_id.values()) return [(self._to_fraction(start), self._to_fraction(end), color) for start, end, color in zones] # --- Lifecycle -------------------------------------------------------
[docs] def start(self) -> None: """Start the overlay bar on the shared Qt background thread. Does nothing if the bar is already running. """ if self._window is not None: return from .qt_app_host import get_qt_app_host def _build() -> None: screen = QGuiApplication.primaryScreen() full_geometry = screen.geometry() available_geometry = screen.availableGeometry() window = _JoystickVisualToolWindow(self._bar_height) if self._anchor == "top": y = available_geometry.y() else: y = available_geometry.y() + available_geometry.height() - self._bar_height window.setGeometry(full_geometry.x(), y, full_geometry.width(), self._bar_height) window.show() poll_timer = QTimer() poll_timer.timeout.connect(self._poll) poll_timer.start(self._refresh_interval_ms) self._window = window self._poll_timer = poll_timer get_qt_app_host().run_on_gui_thread(_build)
[docs] def stop(self) -> None: """Close the overlay bar and stop its poll timer. Does nothing if the bar is not running. Only this visualizer's own window/timer are torn down; the shared Qt event loop keeps running for any other visualizer using it (see :mod:`pyrobotiqgripper.qt_app_host`). """ if self._window is None: return from .qt_app_host import get_qt_app_host def _teardown() -> None: if self._poll_timer is not None: self._poll_timer.stop() if self._window is not None: self._window.close() self._window = None self._poll_timer = None get_qt_app_host().run_on_gui_thread(_teardown)
[docs] def is_running(self) -> bool: """Return whether the overlay bar is currently running. Returns: bool: True if the bar is currently open. """ return self._window is not None
def _poll(self) -> None: """Timer callback run on the shared Qt thread: repaint the bar, or tear down if the window was closed from the UI. Also polls the OS-level cursor position against the bar's on-screen rectangle to detect hover: the window itself is click-through and so never receives real mouse-move/enter/leave events. """ window = self._window if window is None: return if not window.isVisible(): if self._poll_timer is not None: self._poll_timer.stop() window.close() self._window = None self._poll_timer = None return marker_fraction = self._to_fraction(self._joystick.get_axis(self._axis)) zones = self._snapshot_zones() hovered = window.geometry().contains(QCursor.pos()) window.refresh(marker_fraction, zones, hovered)
if _PYSIDE6_AVAILABLE: def _to_qcolor(color: object) -> "QColor": """Normalize a caller-supplied color spec into a ``QColor``. Accepts a ``QColor`` instance, a name/hex string (anything ``QColor(x)`` understands), or an ``(r, g, b)``/``(r, g, b, a)`` tuple/list -- unlike ``QColor``'s own constructor, which doesn't accept a plain tuple/list. """ if isinstance(color, (tuple, list)): return QColor(*color) return QColor(color) class _JoystickVisualToolWindow(QWidget): """Borderless, always-on-top, click-through overlay bar. Not meant to be instantiated directly; use :class:`JoystickVisualTool`. """ _BACKGROUND_COLOR = QColor(224, 224, 224) _MARKER_COLOR = QColor(120, 120, 120) _NORMAL_OPACITY = 1.0 _HOVER_OPACITY = 0.4 # 60% transparent, so whatever's underneath stays visible def __init__(self, bar_height: int): super().__init__() self.setWindowFlags( Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool | Qt.WindowTransparentForInput ) self.setAttribute(Qt.WA_TransparentForMouseEvents, True) self.setAttribute(Qt.WA_ShowWithoutActivating, True) self.setFixedHeight(bar_height) self.setWindowOpacity(self._NORMAL_OPACITY) self._marker_fraction = 0.5 self._zones: List[Tuple[float, float, object]] = [] self._hovered = False def refresh(self, marker_fraction: float, zones: List[Tuple[float, float, object]], hovered: bool) -> None: """Update the bar's state and repaint. Args: marker_fraction (float): Marker position, in [0, 1]. zones (List[Tuple[float, float, object]]): Zones to draw, each as ``(start, end, color)``, in [0, 1]. hovered (bool): Whether the OS cursor is currently over the bar. Toggles its opacity so whatever's underneath stays visible while the cursor is there. """ self._marker_fraction = marker_fraction self._zones = zones if hovered != self._hovered: self._hovered = hovered self.setWindowOpacity(self._HOVER_OPACITY if hovered else self._NORMAL_OPACITY) self.update() def paintEvent(self, event) -> None: # noqa: N802 - Qt override width = self.width() height = self.height() painter = QPainter(self) try: painter.fillRect(0, 0, width, height, self._BACKGROUND_COLOR) for start, end, color in self._zones: x0 = max(0.0, min(1.0, start)) * width x1 = max(0.0, min(1.0, end)) * width painter.fillRect(QRectF(x0, 0, x1 - x0, height), color) painter.setRenderHint(QPainter.Antialiasing) painter.setBrush(self._MARKER_COLOR) painter.setPen(Qt.NoPen) radius = height * 0.35 marker_x = self._marker_fraction * width painter.drawEllipse(QPointF(marker_x, height / 2), radius, radius) finally: painter.end()