From 90d790f4e367d3231577b2fd088c39173a9f1a53 Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 17 Aug 2026 18:32:36 +0200 Subject: [PATCH] feat: status bar splits passive left / operable right Operable controls (Fast Shutter, State, p-group, Session) group right via addPermanentWidget and share one hover affordance - hand cursor + underline - now owned by ClickableLabel; ValueLabel opts out for the passive readouts, and the unwired busy label becomes a plain QLabel. Cryo sits first in the operable group but stays passive: a cryo operation is planned (TODO in status_bar.py), the placement just reserves its spot. Co-Authored-By: Claude Fable 5 --- src/aare/gui/widgets/clickable_label.py | 28 ++++++++++++++ src/aare/gui/widgets/status_bar.py | 47 +++++++++++++++-------- src/aare/gui/widgets/value_label.py | 2 + tests/unit/gui/test_status_bar.py | 50 +++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 16 deletions(-) create mode 100644 tests/unit/gui/test_status_bar.py 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_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()