feat: status-bar readouts wrap on narrow windows instead of clipping
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
CI / lint (pull_request) Failing after 59s
CI / test (3.12) (pull_request) Successful in 1m4s
CI / test (3.14) (pull_request) Successful in 1m5s
CI / test (3.13) (pull_request) Successful in 1m10s
CI / test-with-beamline-plugins (pxii_bec) (pull_request) Successful in 1m12s
CI / test-with-beamline-plugins (pxi_bec) (pull_request) Successful in 1m14s
CI / test-with-beamline-plugins (pxiii_bec) (pull_request) Successful in 1m25s
CI / test-with-coverage (pull_request) Successful in 1m38s
CI / coverage-analysis (pull_request) Successful in 3s
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
CI / lint (pull_request) Failing after 59s
CI / test (3.12) (pull_request) Successful in 1m4s
CI / test (3.14) (pull_request) Successful in 1m5s
CI / test (3.13) (pull_request) Successful in 1m10s
CI / test-with-beamline-plugins (pxii_bec) (pull_request) Successful in 1m12s
CI / test-with-beamline-plugins (pxi_bec) (pull_request) Successful in 1m14s
CI / test-with-beamline-plugins (pxiii_bec) (pull_request) Successful in 1m25s
CI / test-with-coverage (pull_request) Successful in 1m38s
CI / coverage-analysis (pull_request) Successful in 3s
All readouts (sharpness ... Cryo | Fast Shutter | State | p-group | Session) now live in one FlowLayout host: when the window gets narrow the bar grows extra rows and every value stays readable, where QStatusBar used to clip the permanent group and hide the rest. FlowLayout is the canonical Qt example trimmed down (Qt ships no built-in); the host pins its minimum height to the wrapped height because QStatusBar ignores heightForWidth. The old left/right addWidget vs addPermanentWidget split is gone with the wrap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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()))
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user