diff --git a/src/aare/gui/widgets/flow_layout.py b/src/aare/gui/widgets/flow_layout.py new file mode 100644 index 00000000..7799bb0c --- /dev/null +++ b/src/aare/gui/widgets/flow_layout.py @@ -0,0 +1,93 @@ +from PySide6.QtCore import QPoint, QRect, QSize, Qt +from PySide6.QtWidgets import QLayout, QWidget + + +class FlowLayout(QLayout): + """Left-to-right layout that wraps to new rows when the width runs out — + Qt ships no built-in for this, so this is the canonical Qt example + trimmed down. Added for the status-bar readouts: on narrow windows they + were simply clipped; now they wrap and stay readable. + """ + + def __init__(self, parent=None, hspacing: int = 10, vspacing: int = 2): + super().__init__(parent) + self._items = [] + self._hspace = hspacing + self._vspace = vspacing + + def addItem(self, item): + self._items.append(item) + + def count(self): + return len(self._items) + + def itemAt(self, index): + return self._items[index] if 0 <= index < len(self._items) else None + + def takeAt(self, index): + return self._items.pop(index) if 0 <= index < len(self._items) else None + + def expandingDirections(self): + return Qt.Orientation(0) + + def hasHeightForWidth(self): + return True + + def heightForWidth(self, width): + return self._do_layout(QRect(0, 0, width, 0), test_only=True) + + def setGeometry(self, rect): + super().setGeometry(rect) + self._do_layout(rect, test_only=False) + + def sizeHint(self): + return self.minimumSize() + + def minimumSize(self): + size = QSize() + for item in self._items: + size = size.expandedTo(item.minimumSize()) + margins = self.contentsMargins() + return size + QSize(margins.left() + margins.right(), margins.top() + margins.bottom()) + + def _do_layout(self, rect, test_only: bool) -> int: + margins = self.contentsMargins() + x = rect.x() + margins.left() + y = rect.y() + margins.top() + right = rect.right() - margins.right() + line_height = 0 + for item in self._items: + # Hidden widgets must not leave gaps in the row. + if item.isEmpty(): + continue + hint = item.sizeHint() + next_x = x + hint.width() + if next_x - 1 > right and line_height > 0: + x = rect.x() + margins.left() + y += line_height + self._vspace + next_x = x + hint.width() + line_height = 0 + if not test_only: + item.setGeometry(QRect(QPoint(x, y), hint)) + x = next_x + self._hspace + line_height = max(line_height, hint.height()) + return y + line_height + margins.bottom() - rect.y() + + +class FlowHost(QWidget): + """Widget owning a FlowLayout. Parents like QStatusBar ignore + heightForWidth, so the host pins its own minimum height to the wrapped + height on every resize — the bar then grows to fit the rows. + """ + + def __init__(self, parent=None, hspacing: int = 10, vspacing: int = 2): + super().__init__(parent) + self._flow = FlowLayout(self, hspacing=hspacing, vspacing=vspacing) + self._flow.setContentsMargins(0, 0, 0, 0) + + def add_widget(self, widget: QWidget) -> None: + self._flow.addWidget(widget) + + def resizeEvent(self, event): + super().resizeEvent(event) + self.setMinimumHeight(self._flow.heightForWidth(self.width())) diff --git a/src/aare/gui/widgets/status_bar.py b/src/aare/gui/widgets/status_bar.py index 6d939345..4b31e3f8 100644 --- a/src/aare/gui/widgets/status_bar.py +++ b/src/aare/gui/widgets/status_bar.py @@ -11,6 +11,7 @@ from aare.gui.constants import LOGGER_NAME from aare.gui.styles import THEME_SUNRISE, status_colors from aare.gui.widgets.baton_request_dialog import BatonRequestDialog from aare.gui.widgets.clickable_label import ClickableLabel +from aare.gui.widgets.flow_layout import FlowHost from aare.gui.widgets.pgroup_dialog import PGroupDialog from aare.gui.widgets.value_label import ValueLabel @@ -92,13 +93,19 @@ class StatusBar(QStatusBar): 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) + # 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. + self.info_host = FlowHost(self) for widget in ( + self.message_label, self.sharpness, self.samcam_fps, self.flux, @@ -108,19 +115,14 @@ class StatusBar(QStatusBar): 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) + self.info_host.add_widget(widget) + self.addWidget(self.info_host, 1) def set_theme(self, theme: str) -> None: """Adopt the theme's flag colors: recolor the connection message and diff --git a/tests/unit/gui/test_status_bar.py b/tests/unit/gui/test_status_bar.py index 1f5bcbde..dc4f4e7f 100644 --- a/tests/unit/gui/test_status_bar.py +++ b/tests/unit/gui/test_status_bar.py @@ -1,10 +1,12 @@ -"""Status bar layout contract: passive readouts left, operable controls -right, and every operable label wears the shared hover affordance.""" +"""Status bar layout contract: every readout lives in one wrapping flow +host (narrow windows wrap instead of clipping), 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.flow_layout import FlowLayout from aare.gui.widgets.status_bar import StatusBar @@ -62,18 +64,34 @@ 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_passives_left_operables_right(qtbot): +def test_readouts_wrap_instead_of_clipping(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() + 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, ): - assert label.isVisible() + assert label.parentWidget() is 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. + one_row = flow.heightForWidth(100_000) + wrapped = flow.heightForWidth(200) + assert wrapped > one_row + host.resize(200, wrapped) + assert host.minimumHeight() >= wrapped + # Layout bookkeeping the canonical example demands. + assert flow.count() > 0 + assert flow.itemAt(0) is not None + assert flow.itemAt(9999) is None + taken = flow.takeAt(flow.count() - 1) + assert taken is not None + assert flow.takeAt(9999) is None