feat: status bar splits passive left / operable right
CI / lint (push) Skipped
CI / test (3.11) (push) Skipped
CI / test (3.12) (push) Skipped
CI / test (3.13) (push) Skipped
CI / test-with-beamline-plugins (pxi_bec) (push) Skipped
CI / test-with-beamline-plugins (pxii_bec) (push) Skipped
CI / test-with-beamline-plugins (pxiii_bec) (push) Skipped

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 18:32:36 +02:00
co-authored by Claude Fable 5
parent 07c4112191
commit 90d790f4e3
4 changed files with 111 additions and 16 deletions
+28
View File
@@ -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()
+31 -16
View File
@@ -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
+2
View File
@@ -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)
+50
View File
@@ -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()