diff --git a/src/aare/gui/widgets/status_bar.py b/src/aare/gui/widgets/status_bar.py index 4b31e3f8..a89583d9 100644 --- a/src/aare/gui/widgets/status_bar.py +++ b/src/aare/gui/widgets/status_bar.py @@ -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) diff --git a/tests/unit/gui/test_status_bar.py b/tests/unit/gui/test_status_bar.py index dc4f4e7f..86312997 100644 --- a/tests/unit/gui/test_status_bar.py +++ b/tests/unit/gui/test_status_bar.py @@ -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.