diff --git a/src/aare/gui/graphics/aare_banner.png b/src/aare/gui/graphics/aare_banner.png index f9f4e829..759a2b2f 100644 Binary files a/src/aare/gui/graphics/aare_banner.png and b/src/aare/gui/graphics/aare_banner.png differ diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 115c080c..3282a738 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -2734,8 +2734,10 @@ class MainWindow(QMainWindow): if hasattr(self, "gonio_camera_thread") and self.gonio_camera_thread is not None: self.gonio_camera_thread.set_busy(s.busy) + # Sample alignment busy is the alignment itself (same exception as + # the motion watch below) — no BEAMLINE BUSY curtain on any view. busy_style = build_busy_overlay_style( - is_busy=bool(s.busy), + is_busy=bool(s.busy) and s.state != BeamlineStateEnum.SampleAlignment, tell_state=s.tell_state, session_state=getattr(getattr(s, "session", None), "session", None), ) diff --git a/src/aare/gui/widgets/camera_image.py b/src/aare/gui/widgets/camera_image.py index 423a3bb5..b8e4171d 100644 --- a/src/aare/gui/widgets/camera_image.py +++ b/src/aare/gui/widgets/camera_image.py @@ -804,8 +804,14 @@ class SampleCameraImageLabel(QGraphicsView): self._session_state = new_session_state self.update() + # Same exception as the main window's motion watch: busy during + # Sample alignment IS the alignment, and the sample motion is watched + # right here — no BEAMLINE BUSY curtain over it. The "In viewing + # mode" badge is session-driven and unaffected by the gate. new_busy_style = build_busy_overlay_style( - is_busy=bool(s.busy), tell_state=s.tell_state, session_state=self._session_state + is_busy=bool(s.busy) and s.state != BeamlineStateEnum.SampleAlignment, + tell_state=s.tell_state, + session_state=self._session_state, ) if new_busy_style != self._busy_overlay_style: self._busy_overlay_style = new_busy_style diff --git a/src/aare/gui/widgets/clickable_label.py b/src/aare/gui/widgets/clickable_label.py index eb3b2aac..a12aad26 100644 --- a/src/aare/gui/widgets/clickable_label.py +++ b/src/aare/gui/widgets/clickable_label.py @@ -5,6 +5,34 @@ from PySide6.QtWidgets import QLabel class ClickableLabel(QLabel): clicked = Signal() + # Hover affordance (hand cursor + underline) marks a label as operable — + # the one shared look for every clickable status-bar entry. ValueLabel + # flips it off for the passive readouts it renders. + _hover_affordance = True + + def __init__(self, parent=None): + super().__init__(parent) + if self._hover_affordance: + self.setCursor(Qt.CursorShape.PointingHandCursor) + + def _set_underline(self, on: bool) -> None: + # Font toggle, not QSS :hover — these labels carry rich text (colored + # spans), and the widget font reliably underlines it; stylesheet + # text-decoration does not reach rich-text content. + font = self.font() + font.setUnderline(on) + self.setFont(font) + + def enterEvent(self, event): + if self._hover_affordance: + self._set_underline(True) + super().enterEvent(event) + + def leaveEvent(self, event): + if self._hover_affordance: + self._set_underline(False) + super().leaveEvent(event) + def mousePressEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: self.clicked.emit() diff --git a/src/aare/gui/widgets/status_bar.py b/src/aare/gui/widgets/status_bar.py index c19a53be..485a47f5 100644 --- a/src/aare/gui/widgets/status_bar.py +++ b/src/aare/gui/widgets/status_bar.py @@ -81,7 +81,8 @@ class StatusBar(QStatusBar): self.tell_state_label = QLabel("Tell: —", self) - self.busy_label = ClickableLabel(parent=self) + # Plain QLabel: "Beamline: Busy/Idle" is a passive readout, no click. + self.busy_label = QLabel(parent=self) self.session_label = ClickableLabel(parent=self) self.session_label.clicked.connect(self.show_session_menu) @@ -90,22 +91,36 @@ class StatusBar(QStatusBar): self.shutter_label.clicked.connect(self.show_shutter_menu) self.exp_shutter_label = ValueLabel("ExpHutch Shutter", "", self) + + # Passive readouts group left (addWidget), operable controls group + # right (addPermanentWidget) wearing the shared ClickableLabel hover + # affordance — fixed order Cryo | Fast Shutter | State | p-group | + # Session. No showMessage is ever used here, so the left section is + # never hidden by temporary messages. self.addWidget(self.message_label) - self.addPermanentWidget(self.sharpness) - self.addPermanentWidget(self.samcam_fps) - self.addPermanentWidget(self.flux) - self.addPermanentWidget(self.transmission) - self.addPermanentWidget(self.ring_current) - self.addPermanentWidget(self.wvl) - self.addPermanentWidget(self.cryo_label) - # Beam-path order: the hutch shutter sits upstream of the fast shutter. - self.addPermanentWidget(self.exp_shutter_label) - self.addPermanentWidget(self.shutter_label) - self.addPermanentWidget(self.pgroup_label) - self.addPermanentWidget(self.state_label) - self.addPermanentWidget(self.tell_state_label) - self.addPermanentWidget(self.busy_label) - self.addPermanentWidget(self.session_label) + for widget in ( + self.sharpness, + self.samcam_fps, + self.flux, + self.transmission, + self.ring_current, + self.wvl, + self.exp_shutter_label, + self.tell_state_label, + self.busy_label, + ): + self.addWidget(widget) + # TODO(cryo): passive for now, but placed with the operables because a + # cryo operation (fill/anneal menu) is planned; when it gets a click + # handler, swap it to a ClickableLabel so it inherits the affordance. + for widget in ( + self.cryo_label, + self.shutter_label, + self.state_label, + self.pgroup_label, + self.session_label, + ): + self.addPermanentWidget(widget) def set_theme(self, theme: str) -> None: """Adopt the theme's flag colors: recolor the connection message and diff --git a/src/aare/gui/widgets/value_label.py b/src/aare/gui/widgets/value_label.py index ef0f90e5..f286d7dc 100644 --- a/src/aare/gui/widgets/value_label.py +++ b/src/aare/gui/widgets/value_label.py @@ -4,6 +4,8 @@ from aare.gui.widgets.clickable_label import ClickableLabel class ValueLabel(ClickableLabel): # clicked signal + left-click mousePressEvent inherited from # ClickableLabel — this class only adds the "descr: value unit" text. + # Passive readout: no hand cursor / hover underline. + _hover_affordance = False def __init__(self, text: str, unit: str = "", parent=None): super().__init__(parent) diff --git a/tests/unit/gui/test_camera_image.py b/tests/unit/gui/test_camera_image.py index 4730fb6e..2451310b 100644 --- a/tests/unit/gui/test_camera_image.py +++ b/tests/unit/gui/test_camera_image.py @@ -31,7 +31,12 @@ def _geom() -> SampleGeometryModel: ) -def _status(*, busy: bool, session: SessionsStateEnum) -> DAQStatusModel: +def _status( + *, + busy: bool, + session: SessionsStateEnum, + state: BeamlineStateEnum = BeamlineStateEnum.SampleAlignment, +) -> DAQStatusModel: return DAQStatusModel( geom=_geom(), diffraction=DiffractionGeometry( @@ -61,7 +66,7 @@ def _status(*, busy: bool, session: SessionsStateEnum) -> DAQStatusModel: dtz_min=120.0, dtz_max=1600.0, ), - state=BeamlineStateEnum.SampleAlignment, + state=state, busy=busy, session=SessionStatus(session=session, current_pgroup="p123", staff=True), crystal_size=CrystalSize(x=0, y=0, z=0), @@ -122,13 +127,26 @@ def test_camera_error_message_rewords_and_draws(camera): def test_busy_warning_is_not_a_click_target(camera): - camera.update_daq_status(_status(busy=True, session=SessionsStateEnum.OwnedByYou)) + camera.update_daq_status( + _status( + busy=True, session=SessionsStateEnum.OwnedByYou, state=BeamlineStateEnum.DataCollection + ) + ) style = camera._busy_overlay_style assert style is not None assert style.text == "BEAMLINE BUSY" camera.grab() assert camera._session_badge_rect is None + # Sample alignment is the exception: its busy moves ARE the alignment, + # watched in this very view — no BEAMLINE BUSY curtain over it. + camera.update_daq_status( + _status( + busy=True, session=SessionsStateEnum.OwnedByYou, state=BeamlineStateEnum.SampleAlignment + ) + ) + assert camera._busy_overlay_style is None + def test_vacant_badge_hover_click_and_theme(camera, qtbot): camera.update_daq_status(_status(busy=False, session=SessionsStateEnum.Vacant)) diff --git a/tests/unit/gui/test_status_bar.py b/tests/unit/gui/test_status_bar.py new file mode 100644 index 00000000..291db130 --- /dev/null +++ b/tests/unit/gui/test_status_bar.py @@ -0,0 +1,50 @@ +"""Status bar layout contract: passive readouts left, operable controls +right, and every operable label wears the shared hover affordance.""" + +from aarecommon.models.models import TokenData +from PySide6.QtCore import QEvent, QPointF, Qt +from PySide6.QtGui import QEnterEvent + +from aare.gui.widgets.status_bar import StatusBar + + +def _bar(qtbot): + bar = StatusBar(token=TokenData(sub="u", staff=True, pgroups=["p1"], session=1)) + qtbot.addWidget(bar) + bar.show() + return bar + + +def test_operables_share_the_hover_affordance(qtbot): + bar = _bar(qtbot) + for label in (bar.shutter_label, bar.state_label, bar.pgroup_label, bar.session_label): + assert label.cursor().shape() == Qt.CursorShape.PointingHandCursor + + # Cryo is grouped with the operables but stays passive for now (TODO in + # status_bar.py); the plain readouts are passive too. + for label in (bar.cryo_label, bar.flux, bar.busy_label): + assert label.cursor().shape() != Qt.CursorShape.PointingHandCursor + + # Hover underlines, leaving restores. + enter = QEnterEvent(QPointF(1, 1), QPointF(1, 1), QPointF(1, 1)) + bar.state_label.enterEvent(enter) + assert bar.state_label.font().underline() + bar.state_label.leaveEvent(QEvent(QEvent.Type.Leave)) + assert not bar.state_label.font().underline() + + +def test_passives_left_operables_right(qtbot): + bar = _bar(qtbot) + # QStatusBar hides only the non-permanent (left) section behind a + # temporary message — probe the grouping through that behavior. + bar.showMessage("probe") + assert not bar.flux.isVisible() + assert not bar.busy_label.isVisible() + for label in ( + bar.cryo_label, + bar.shutter_label, + bar.state_label, + bar.pgroup_label, + bar.session_label, + ): + assert label.isVisible()