gui: status bar operable split, no busy overlay in Sample alignment #145

Merged
duan_j merged 4 commits from feat/gui-dynamic-signals-and-sample-tools into main 2026-08-18 09:27:42 +02:00
8 changed files with 142 additions and 21 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

After

Width:  |  Height:  |  Size: 31 KiB

+3 -1
View File
@@ -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),
)
+7 -1
View File
@@ -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
+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)
+21 -3
View File
@@ -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))
+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()