feat(gui): pin status-bar operables right, stop readout width jitter
CI / lint (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

- Fast Shutter / State / p-group / Session are addPermanentWidget now:
  QStatusBar itself pins them to the right corner, so wrapping of the
  other readouts never moves them
- passive readouts keep wrapping in the FlowHost on narrow windows
- grow-only min-width ratchet on every readout: changing number widths
  used to re-flow the row each DAQ tick and jitter every neighbour

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 09:55:09 +02:00
co-authored by Claude Fable 5
parent 1289851bb9
commit 6e5a454e89
2 changed files with 67 additions and 25 deletions
+39 -14
View File
@@ -93,16 +93,15 @@ class StatusBar(QStatusBar):
self.exp_shutter_label = ValueLabel("ExpHutch Shutter", "", self)
# Every readout lives in ONE wrapping flow host: on narrow windows
# QStatusBar used to clip/hide the labels outright, now they wrap to
# extra rows and stay readable. Fixed order: passives first, then the
# operables (shared ClickableLabel hover affordance) — Cryo | Fast
# Shutter | State | p-group | Session at the end. The old left/right
# split (addWidget vs addPermanentWidget) is gone with the wrap; no
# showMessage is ever used here, so nothing hides the host.
# 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.
# Split layout: the passive readouts wrap in a flow host (narrow
# windows used to clip them outright), while the four operables —
# Fast Shutter | State | p-group | Session — are addPermanentWidget
# so QStatusBar itself pins them to the right corner at a fixed spot
# regardless of wrapping; no showMessage is ever used here, so
# nothing hides either side.
# TODO(cryo): a cryo operation (fill/anneal menu) is planned; when it
# gets a click handler, swap to ClickableLabel and move it over to
# the permanent (right-pinned) group with the other operables.
self.info_host = FlowHost(self)
for widget in (
self.message_label,
@@ -116,14 +115,22 @@ class StatusBar(QStatusBar):
self.tell_state_label,
self.busy_label,
self.cryo_label,
self.shutter_label,
self.state_label,
self.pgroup_label,
self.session_label,
):
self.info_host.add_widget(widget)
self.addWidget(self.info_host, 1)
for widget in (self.shutter_label, self.state_label, self.pgroup_label, self.session_label):
self.addPermanentWidget(widget)
def _ratchet_widths(self, *labels: QLabel) -> None:
# ponytail: grow-only min-width ratchet — per-tick number width
# changes used to re-flow the row and jitter every neighbour; pin
# each label to the widest text it has shown. Never shrinks until
# restart, which is fine for a status bar; per-label fixed widths
# from font metrics if a pathological long value ever sticks.
for label in labels:
label.setMinimumWidth(max(label.minimumWidth(), label.sizeHint().width()))
def set_theme(self, theme: str) -> None:
"""Adopt the theme's flag colors: recolor the connection message and
re-render the DAQ-driven labels from the last status right away."""
@@ -156,6 +163,7 @@ class StatusBar(QStatusBar):
@Slot(float)
def update_sharpness(self, val: float):
self.sharpness.set_value(f"{val:.3f}")
self._ratchet_widths(self.sharpness)
@Slot(float)
def update_samcam_fps(self, fps: float):
@@ -166,6 +174,7 @@ class StatusBar(QStatusBar):
self.samcam_fps.set_value("error")
return
self.samcam_fps.set_value(f"{fps:.1f}")
self._ratchet_widths(self.samcam_fps)
@Slot(DAQStatusModel)
def update_daq_status(self, status: DAQStatusModel):
@@ -284,6 +293,21 @@ class StatusBar(QStatusBar):
html_content_session = f"""Session: {session_flag}"""
self.session_label.setText(html_content_session)
self._ratchet_widths(
self.flux,
self.transmission,
self.ring_current,
self.wvl,
self.cryo_label,
self.shutter_label,
self.exp_shutter_label,
self.tell_state_label,
self.busy_label,
self.state_label,
self.pgroup_label,
self.session_label,
)
except Exception:
logger.exception("Error updating DAQ status in status bar")
@@ -378,6 +402,7 @@ class StatusBar(QStatusBar):
text = "Session: Vacant"
self.session_label.setText(text)
self._ratchet_widths(self.session_label)
def show_session_menu(self, global_pos: QPoint | None = None):
menu = QMenu(self)
+28 -11
View File
@@ -64,22 +64,39 @@ def test_state_menu_gates_beam_location_for_non_staff(qtbot, daq_status_factory,
assert not _state_menu_entries(non_staff)["Beam location (admin mode only)"]
def test_readouts_wrap_instead_of_clipping(qtbot):
def test_readout_width_ratchets_so_numbers_do_not_jitter(qtbot, daq_status_factory):
bar = _bar(qtbot)
# A wide value pins the minimum width; a narrower one must NOT shrink it,
# otherwise every tick re-flows the row and neighbours jitter.
bar.update_samcam_fps(1234.5)
bar.update_sharpness(0.123)
wide = bar.samcam_fps.minimumWidth()
assert wide >= bar.samcam_fps.sizeHint().width()
bar.update_samcam_fps(5.0)
assert bar.samcam_fps.minimumWidth() == wide
# The DAQ tick ratchets the rest, session-display path included.
bar.update_daq_status(daq_status_factory())
bar._update_session_display()
assert bar.ring_current.minimumWidth() >= bar.ring_current.sizeHint().width()
assert bar.session_label.minimumWidth() >= bar.session_label.sizeHint().width()
def test_readouts_wrap_instead_of_clipping(qtbot, daq_status_factory):
bar = _bar(qtbot)
# Labels are empty until a DAQ tick paints them; feed one so the flow
# measures realistic widths (empty labels never trigger a wrap).
bar.update_daq_status(daq_status_factory())
host = bar.info_host
flow = host.layout()
assert isinstance(flow, FlowLayout) # narrows Optional for the checker
# Every readout is in the flow host, none clipped away by QStatusBar.
for label in (
bar.flux,
bar.busy_label,
bar.cryo_label,
bar.shutter_label,
bar.state_label,
bar.pgroup_label,
bar.session_label,
):
# Passive readouts wrap in the flow host; the four operables are
# addPermanentWidget so QStatusBar pins them to the right corner and
# they never move when the flow wraps.
for label in (bar.flux, bar.busy_label, bar.cryo_label):
assert label.parentWidget() is host
for label in (bar.shutter_label, bar.state_label, bar.pgroup_label, bar.session_label):
assert label.parentWidget() is bar
assert label.parentWidget() is not host
# Narrow width -> the flow reports a taller (multi-row) height than one
# row, and resizing the host pins its minimum height to the wrapped
# height so the bar grows instead of cutting labels off.