gui: status bar operable split, no busy overlay in Sample alignment #145
Binary file not shown.
|
Before Width: | Height: | Size: 210 KiB After Width: | Height: | Size: 31 KiB |
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user