diff --git a/src/aare/gui/panels/beamline_state_panel.py b/src/aare/gui/panels/beamline_state_panel.py index 0719ded7..da6854e5 100644 --- a/src/aare/gui/panels/beamline_state_panel.py +++ b/src/aare/gui/panels/beamline_state_panel.py @@ -1,9 +1,18 @@ from typing import ClassVar from aarecommon.models.models import BeamlineStateEnum, DAQStatusModel -from PySide6.QtCore import Qt, QTimer, Signal, Slot +from PySide6.QtCore import QByteArray, QPropertyAnimation, Qt, QTimer, Signal, Slot from PySide6.QtGui import QCursor, QFont, QFontMetrics -from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QMenu, QPushButton, QSizePolicy, QToolTip +from PySide6.QtWidgets import ( + QFrame, + QGraphicsOpacityEffect, + QHBoxLayout, + QLabel, + QMenu, + QPushButton, + QSizePolicy, + QToolTip, +) from aare.gui.styles import FONT_VALUE, THEME_SUNRISE, state_colors @@ -133,6 +142,7 @@ class BeamlineStatePanel(QFrame): self._hovered_state: BeamlineStateEnum | None = None self._pending_target_state: BeamlineStateEnum | None = None self._busy = False + self._breathing_button: HoverableButton | None = None # After 3 s of hovering an unavailable state, explain which states # it can be reached from. @@ -322,8 +332,31 @@ class BeamlineStatePanel(QFrame): self._style_separator(separator) self._apply_highlight() + def _sync_breathing(self, button: HoverableButton | None) -> None: + # The transition target used to sit solid blue while Moving, reading + # as "already there". Breathe it via an opacity effect instead of a + # stylesheet animation: restyling would repolish the button per frame. + if button is self._breathing_button: + return + if self._breathing_button is not None: + # Qt deletes the old effect (and the animation parented to it). + self._breathing_button.setGraphicsEffect(None) # type: ignore[arg-type] + self._breathing_button = button + if button is None: + return + effect = QGraphicsOpacityEffect(button) + button.setGraphicsEffect(effect) + animation = QPropertyAnimation(effect, QByteArray(b"opacity"), effect) + animation.setDuration(1600) + animation.setStartValue(1.0) + animation.setKeyValueAt(0.5, 0.35) + animation.setEndValue(1.0) + animation.setLoopCount(-1) + animation.start() + def _apply_highlight(self) -> None: available = self._available_targets() + pending_button: HoverableButton | None = None for state, button in self._buttons.items(): is_current = state == self._current_state is_pending = ( @@ -331,6 +364,8 @@ class BeamlineStatePanel(QFrame): and self._current_state == BeamlineStateEnum.Moving ) is_available = state in available + if is_pending: + pending_button = button # Availability drives the look: active = bold (red for # Maintenance, blue otherwise), reachable = orange, rest = grey. @@ -391,6 +426,8 @@ class BeamlineStatePanel(QFrame): if button.toolTip() != tooltip: button.setToolTip(tooltip) + self._sync_breathing(pending_button) + def set_current_state(self, state: BeamlineStateEnum | None) -> None: self._current_state = state if ( diff --git a/tests/unit/gui/test_beamline_state_panel.py b/tests/unit/gui/test_beamline_state_panel.py index 3e9bce74..0e4c7bb9 100644 --- a/tests/unit/gui/test_beamline_state_panel.py +++ b/tests/unit/gui/test_beamline_state_panel.py @@ -121,3 +121,19 @@ def test_pending_target_cleared_on_arrival(qtbot): assert panel._pending_target_state == BeamlineStateEnum.SampleExchange panel.set_current_state(BeamlineStateEnum.SampleExchange) assert panel._pending_target_state is None + + +def test_pending_target_breathes_only_while_moving(qtbot): + panel = _panel(qtbot) + panel.set_current_state(BeamlineStateEnum.Maintenance) + panel._emit_for_state(BeamlineStateEnum.SampleExchange) + target = panel._buttons[BeamlineStateEnum.SampleExchange] + assert target.graphicsEffect() is None # not Moving yet + + panel.set_current_state(BeamlineStateEnum.Moving) + assert panel._breathing_button is target + assert target.graphicsEffect() is not None + + panel.set_current_state(BeamlineStateEnum.SampleExchange) + assert panel._breathing_button is None + assert target.graphicsEffect() is None