feat: collapsible half-height panel banners with a universal gap
CI / lint (push) Skipped
CI / test (3.11) (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 / test (3.12) (pull_request) Successful in 48s
CI / test-with-beamline-plugins (pxiii_bec) (pull_request) Successful in 52s
CI / lint (pull_request) Failing after 1m47s
CI / test-with-beamline-plugins (pxi_bec) (pull_request) Successful in 2m27s
CI / test (3.13) (pull_request) Successful in 2m56s
CI / test-with-beamline-plugins (pxii_bec) (pull_request) Successful in 3m55s
CI / test-with-coverage (pull_request) Successful in 4m23s
CI / coverage-analysis (pull_request) Successful in 4s
CI / test (3.11) (pull_request) Successful in 7m2s

TitleLabel gains an optional collapsible mode: whole-banner click target,
bare +/- glyph (no pill background), per-title collapsed state persisted
in QSettings, and an expand() helper. Banners are halved to 25px with
plain text + QSS font (H3 margins would clip vertically).

- convert Zoom, Light, Omega, Smargon, Sample camera, Monochromator,
  ABR meas. pos., Beam mark, Beam center, Beam size, Dataset path and
  Loop centering to collapsible banners
- new collapsible Exp. Config. section wrapping the scan tabs
- move Manual sample from a bottom dock into the left column between
  Dataset path and Exp. Config.; Ctrl+M now expands it instead of
  raising the dock
- Beamline state banner restyled to match (25px, plain font, bare
  glyph, whole banner clickable)
- universal 11px banner gap via tighten_column() shared by both panel
  columns and the left column stack; ABR banner spans all grid columns
  and file path / manual sample drop side margins so widths align
- abort button sits under the tabs instead of pinned to the bottom
- unit tests for the collapse behavior

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 15:13:07 +02:00
co-authored by Claude Fable 5
parent b83ce455db
commit 87c8b7771e
20 changed files with 265 additions and 73 deletions
+12 -27
View File
@@ -50,7 +50,6 @@ from aare.gui.panels.local_contact_panel import LocalContactDialog
# panels
from aare.gui.panels.log_panel import LogDock
from aare.gui.panels.loop_centering_panel import LoopCenteringPanel
from aare.gui.panels.manual_sample_panel import ManualSamplePanel
from aare.gui.panels.portrait_mode import PortraitModePanel
from aare.gui.panels.prediction_metrics_panel import PredictionMetricsPanel
from aare.gui.panels.reference_tools_panel import ReferenceToolsPanel
@@ -86,6 +85,7 @@ from aare.gui.widgets.baton_request_dialog import BatonPendingDialog, BatonReque
from aare.gui.widgets.busy_overlay import build_busy_overlay_style
from aare.gui.widgets.camera_image import SampleCameraImageLabel
from aare.gui.widgets.message_box import precondition_check
from aare.gui.widgets.title_label import tighten_column
from aare.gui.widgets.no_wheel_scroll_area import NoWheelScrollArea
from aare.gui.widgets.status_bar import StatusBar
from aare.gui.widgets.video_image import VideoGraphicsView
@@ -235,7 +235,6 @@ class MainWindow(QMainWindow):
self.left_column = QWidget(parent=top_widget)
self.left_column_layout = QVBoxLayout(self.left_column)
self.left_column_layout.setContentsMargins(0, 0, 0, 0)
self.left_column_layout.setSpacing(8)
self.data_collection = DataCollectionSettings(
s=geom, parent=self.left_column, raster_mgr=self.raster, diffraction=diffraction
@@ -255,6 +254,8 @@ class MainWindow(QMainWindow):
else:
self.beamline_state_panel.hide()
self.left_column_layout.addStretch()
# Same universal banner gap as inside the panel columns.
tighten_column(self.left_column_layout)
top_widget_layout.addWidget(self.collection_controls_scroll)
self.collection_controls_scroll.setWidget(self.left_column)
@@ -356,6 +357,9 @@ class MainWindow(QMainWindow):
)
top_widget_layout.addWidget(self.beamline_controls_scroll)
self.beamline_controls_scroll.setWidget(self.beamline)
# Resizable so the column shrinks when panels collapse; without it the
# scrollbar keeps dead range below the collapsed panels.
self.beamline_controls_scroll.setWidgetResizable(True)
self.beamline_controls_scroll.setHorizontalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
)
@@ -400,13 +404,10 @@ class MainWindow(QMainWindow):
self.job_list_dock.setAllowedAreas(Qt.DockWidgetArea.BottomDockWidgetArea)
self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.job_list_dock)
self.manual_sample_panel = ManualSamplePanel()
self.manual_sample_dock = QDockWidget("Manual sample", self)
self.manual_sample_dock.setObjectName("manual_sample_dock")
self.manual_sample_dock.setWidget(self.manual_sample_panel)
self.manual_sample_dock.setAllowedAreas(Qt.DockWidgetArea.BottomDockWidgetArea)
self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.manual_sample_dock)
# Manual sample lives in the left column (DataCollectionSettings)
# between Dataset path and Exp. Config., collapsible like its
# neighbors — it is no longer a bottom dock.
self.manual_sample_panel = self.data_collection.manual_sample_panel
self.automation_progress_panel = AutomationProgressWidget()
self.automation_progress_dock = QDockWidget("Automation progress", self)
@@ -446,9 +447,6 @@ class MainWindow(QMainWindow):
self.tabifyDockWidget(self.automation_progress_dock, self.log_dock)
self.tabifyDockWidget(self.manual_sample_dock, self.automation_progress_dock)
self.tabifyDockWidget(self.automation_progress_dock, self.log_dock)
self.job_list_panel.samples_in_queue_changed.connect(
self.automation_progress_panel.set_samples_in_queue
)
@@ -888,10 +886,10 @@ class MainWindow(QMainWindow):
register_tutorials(self, self.tutorial_manager)
def _setup_global_shortcuts(self) -> None:
self._shortcut_manual_sample = QAction("Raise Manual Sample Dock", self)
self._shortcut_manual_sample = QAction("Expand Manual Sample", self)
self._shortcut_manual_sample.setShortcut(QKeySequence("Ctrl+M"))
self._shortcut_manual_sample.triggered.connect(
lambda: (self.manual_sample_dock.setVisible(True), self.manual_sample_dock.raise_())
lambda: self.manual_sample_panel.title.expand()
)
self.addAction(self._shortcut_manual_sample)
@@ -1114,7 +1112,6 @@ class MainWindow(QMainWindow):
self.tell_samples_dock.setVisible(False)
self.job_list_dock.setVisible(False)
self.manual_sample_dock.setVisible(False)
self.automation_progress_dock.setVisible(False)
self.face_panel_dock.setVisible(False)
self.fluor_panel_dock.setVisible(False)
@@ -1192,7 +1189,6 @@ class MainWindow(QMainWindow):
for dock_attr in (
"tell_samples_dock",
"job_list_dock",
"manual_sample_dock",
"automation_progress_dock",
"face_panel_dock",
"fluor_panel_dock",
@@ -1260,7 +1256,6 @@ class MainWindow(QMainWindow):
self.tell_samples_dock.setVisible(True)
self.job_list_dock.setVisible(True)
self.manual_sample_dock.setVisible(True)
self.automation_progress_dock.setVisible(False)
self.face_panel_dock.setVisible(False)
self.fluor_panel_dock.setVisible(False)
@@ -1462,15 +1457,6 @@ class MainWindow(QMainWindow):
self.job_list_dock.visibilityChanged.connect(show_job_list_action.setChecked)
view_menu.addAction(show_job_list_action)
show_manual_sample_action = QAction("Show manual sample", self)
show_manual_sample_action.setCheckable(True)
show_manual_sample_action.setChecked(True)
show_manual_sample_action.triggered.connect(
lambda checked: self.manual_sample_dock.setVisible(checked)
)
self.manual_sample_dock.visibilityChanged.connect(show_manual_sample_action.setChecked)
view_menu.addAction(show_manual_sample_action)
show_face_panel_action = QAction("Show face detection", self)
show_face_panel_action.setCheckable(True)
show_face_panel_action.setChecked(False)
@@ -1611,7 +1597,6 @@ class MainWindow(QMainWindow):
self.tell_samples_dock.setVisible(True)
self.job_list_dock.setVisible(True)
self.manual_sample_dock.setVisible(True)
if self._beamline_state_panel_enabled:
self.beamline_state_panel.setVisible(True)
+3 -1
View File
@@ -95,7 +95,9 @@ class AbrTweakWidget(QWidget):
grid_layout.setColumnStretch(1, 0)
grid_layout.setColumnStretch(2, 1)
grid_layout.addWidget(TitleLabel("ABR meas. pos.", self), 0, 0, 1, 3)
# Span all 4 grid columns (the ABR buttons row uses 4), otherwise the
# banner renders narrower than the neighboring panels.
grid_layout.addWidget(TitleLabel("ABR meas. pos.", self, collapsible=True), 0, 0, 1, 4)
self._abr_buttons = AbrTweakButtons(DEFAULT_ABR_STEP_UM / 1000, parent=self)
grid_layout.addWidget(self._abr_buttons, 1, 0, 1, 4)
+1 -1
View File
@@ -14,7 +14,7 @@ class BeamCenterWidget(QWidget):
grid_layout = QGridLayout(self)
grid_layout.addWidget(TitleLabel("Beam center (detector)", self), 0, 0, 1, 5)
grid_layout.addWidget(TitleLabel("Beam center (detector)", self, collapsible=True), 0, 0, 1, 5)
self.x = NumberLineEdit(-4000, 4000, 0, parent=self)
self.x.newValue.connect(self.beam_center_edited)
+1 -1
View File
@@ -13,7 +13,7 @@ class BeamMarkWidget(QWidget):
grid_layout = QGridLayout(self)
grid_layout.addWidget(TitleLabel("Beam mark (image)", self), 0, 0, 1, 5)
grid_layout.addWidget(TitleLabel("Beam mark (image)", self, collapsible=True), 0, 0, 1, 5)
self.x = QLabel("0")
self.y = QLabel("0")
+1 -1
View File
@@ -14,7 +14,7 @@ class BeamSizeWidget(QWidget):
grid_layout = QGridLayout(self)
grid_layout.addWidget(TitleLabel("Beam size", self), 0, 0, 1, 5)
grid_layout.addWidget(TitleLabel("Beam size", self, collapsible=True), 0, 0, 1, 5)
self.x = NumberLineEdit(1, 400.0, 10, parent=self)
self.x.newValue.connect(self.beam_size_edited)
+2
View File
@@ -10,6 +10,7 @@ from aare.gui.panels.omega_panel import OmegaPanel
from aare.gui.panels.samcam_panel import SamcamPanel
from aare.gui.panels.smargon_panel import SmargonPanel
from aare.gui.panels.zoom_panel import ZoomPanel
from aare.gui.widgets.title_label import tighten_column
class BeamlineControls(QFrame):
@@ -51,4 +52,5 @@ class BeamlineControls(QFrame):
self.v_layout.addWidget(self.beam_size)
self.v_layout.addStretch()
tighten_column(self.v_layout)
self.setLayout(self.v_layout)
+17 -6
View File
@@ -6,6 +6,8 @@ from PySide6.QtCore import QPoint, QRect, Qt, Signal, Slot
from PySide6.QtGui import QColor, QPainter, QPen
from PySide6.QtWidgets import QFrame, QLabel, QPushButton
from aare.gui.widgets.title_label import PANEL_VMARGIN
@dataclass(frozen=True)
class StationSpec:
@@ -57,8 +59,11 @@ class BeamlineStatePanel(QFrame):
set_width = 400
map_height = 542
title_height = 50
collapsed_height = 50
# 25 matches the halved TitleLabel banners used by every other panel;
# PANEL_VMARGIN mimics the layout margin other panels get from
# tighten_column, so the inter-banner gap stays universal.
title_height = 25
collapsed_height = title_height + 2 * PANEL_VMARGIN
station_radius = 8
def __init__(self, parent=None):
@@ -209,16 +214,22 @@ class BeamlineStatePanel(QFrame):
self.title = QLabel(self)
self.title.setObjectName("beamlineStateTitle")
self.title.setText("<H3>Beamline state</H3>")
# Plain text + QSS font: <H3> margins would clip in the 25px banner.
self.title.setText("Beamline state")
self.title.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.title.setFixedHeight(self.title_height)
self.title.setGeometry(0, 0, self.set_width, self.title_height)
self.title.setGeometry(0, PANEL_VMARGIN, self.set_width, self.title_height)
# Whole banner toggles, like TitleLabel; the +/ glyph is the indicator.
self.title.setCursor(Qt.CursorShape.PointingHandCursor)
self.title.mousePressEvent = lambda _event: self.toggle_collapsed()
self.toggle_button = QPushButton("", self)
self.toggle_button.setObjectName("beamlineStateToggleButton")
self.toggle_button.setToolTip("Minimise beamline state panel")
self.toggle_button.setFixedSize(28, 28)
self.toggle_button.move(self.set_width - 36, 11)
self.toggle_button.setFixedSize(21, 21)
self.toggle_button.move(
self.set_width - 29, PANEL_VMARGIN + (self.title_height - 21) // 2
)
self.toggle_button.clicked.connect(self.toggle_collapsed)
self.current_label = QLabel("Current: —", self)
@@ -2,9 +2,11 @@ from aarecommon.math.diffraction_geometry import DiffractionGeometry
from aarecommon.math.sample_geometry import SampleGeometryModel
from aarecommon.models.models import DAQStatusModel
from PySide6.QtCore import Signal, Slot
from PySide6.QtWidgets import QFrame, QPushButton, QTabWidget, QVBoxLayout
from PySide6.QtWidgets import QFrame, QPushButton, QTabWidget, QVBoxLayout, QWidget
from aare.gui.panels.file_path_panel import FilePathPanel
from aare.gui.panels.manual_sample_panel import ManualSamplePanel
from aare.gui.widgets.title_label import TitleLabel, tighten_column
from aare.gui.panels.fluorescence_data_collection import FluorescenceDataCollectionPanel
from aare.gui.panels.raster_data_collection import RasterDataCollectionPanel
from aare.gui.panels.rotation_data_collection import RotationDataCollectionPanel
@@ -33,6 +35,11 @@ class DataCollectionSettings(QFrame):
self.file_path_panel = FilePathPanel(self)
v_layout.addWidget(self.file_path_panel)
# Between Dataset path and Exp. Config., collapsible like both;
# main_window aliases this instead of the former bottom dock.
self.manual_sample_panel = ManualSamplePanel(self)
v_layout.addWidget(self.manual_sample_panel)
self._tab_widget = QTabWidget()
self.raster = RasterDataCollectionPanel(
@@ -49,13 +56,23 @@ class DataCollectionSettings(QFrame):
self.fluo = FluorescenceDataCollectionPanel(parent=self)
self._tab_widget.addTab(self.fluo, "XRF")
v_layout.addWidget(self._tab_widget)
v_layout.addStretch()
# Own container: TitleLabel collapse hides its siblings, so without it
# "Exp. Config." would also swallow the dataset path and abort button.
exp_config = QWidget(self)
exp_config_layout = QVBoxLayout(exp_config)
exp_config_layout.setContentsMargins(0, 0, 0, 0)
exp_config_layout.addWidget(TitleLabel("Exp. Config.", exp_config, collapsible=True))
exp_config_layout.addWidget(self._tab_widget)
v_layout.addWidget(exp_config)
abort_button = QPushButton("Abort measurement", parent=self)
abort_button.setStyleSheet("color: rgb(164, 0, 0);")
abort_button.clicked.connect(self.cancel_button_clicked)
v_layout.addWidget(abort_button)
# Stretch after the button: abort sits snug under the tabs instead of
# being pinned to the bottom of the fixed-height column.
v_layout.addStretch()
tighten_column(v_layout)
raster_mgr.update_filename(self.file_path_panel.filename)
self.screening.update_filename(self.file_path_panel.filename)
+5 -1
View File
@@ -23,6 +23,10 @@ class FilePathPanel(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
grid_layout = QGridLayout(self)
# No horizontal inset: aligns the banner and fields edge-to-edge with
# the zero-margin Exp. Config. section below.
_m = grid_layout.contentsMargins()
grid_layout.setContentsMargins(0, _m.top(), 0, _m.bottom())
self._sample_name = "sample"
self._sample_id = -1
self._dewar_pos = "None"
@@ -36,7 +40,7 @@ class FilePathPanel(QWidget):
self._formatted_date = datetime.now().strftime("%Y%m%d")
grid_layout.addWidget(TitleLabel("Dataset path", self), 0, 0, 1, 2)
grid_layout.addWidget(TitleLabel("Dataset path", self, collapsible=True), 0, 0, 1, 2)
grid_layout.addWidget(QLabel("Directory", parent=self), 1, 0)
self.directory_edit = QLineEdit("{date}/{puck}/{pos}", parent=self)
+1 -1
View File
@@ -13,7 +13,7 @@ class IlluminationPanel(QWidget):
super().__init__(parent)
grid_layout = QGridLayout(self)
grid_layout.addWidget(TitleLabel("Light", self), 0, 0, 1, 2)
grid_layout.addWidget(TitleLabel("Light", self, collapsible=True), 0, 0, 1, 2)
front_label = QLabel("Front light", parent=self)
front_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+1 -1
View File
@@ -8,7 +8,7 @@ class LoopCenteringPanel(QWidget):
super().__init__(parent)
grid_layout = QGridLayout(self)
grid_layout.addWidget(TitleLabel("Loop centering", self), 0, 0, 1, 2)
grid_layout.addWidget(TitleLabel("Loop centering", self, collapsible=True), 0, 0, 1, 2)
grid_layout.setColumnStretch(0, 1)
grid_layout.setColumnStretch(1, 1)
+7 -1
View File
@@ -18,8 +18,14 @@ class ManualSamplePanel(QWidget):
self._pgroup = "p16371"
grid_layout = QGridLayout(self)
# No horizontal inset: aligns the banner edge-to-edge with the Dataset
# path / Exp. Config. sections around it in the left column.
_m = grid_layout.contentsMargins()
grid_layout.setContentsMargins(0, _m.top(), 0, _m.bottom())
grid_layout.addWidget(TitleLabel("Manual sample", self), 0, 0, 1, 3)
# Kept as attribute: the Ctrl+M shortcut expands the panel via title.
self.title = TitleLabel("Manual sample", self, collapsible=True)
grid_layout.addWidget(self.title, 0, 0, 1, 3)
grid_layout.addWidget(QLabel("Sample"), 1, 0)
self._text_name = QLineEdit(self._sample_name)
+1 -1
View File
@@ -13,7 +13,7 @@ class MonochromatorPanel(QWidget):
super().__init__(parent)
grid_layout = QGridLayout(self)
grid_layout.addWidget(TitleLabel("Monochromator", self), 0, 0, 1, 2)
grid_layout.addWidget(TitleLabel("Monochromator", self, collapsible=True), 0, 0, 1, 2)
self.mono_pitch_scan_button = QPushButton("Mono Pitch Scan", parent=self)
self.mono_pitch_scan_button.clicked.connect(self.mono_pitch_scan.emit)
+1 -1
View File
@@ -29,7 +29,7 @@ class OmegaPanel(QWidget):
grid_layout = QGridLayout(self)
grid_layout.addWidget(TitleLabel("Omega", self), 0, 0, 1, 2)
grid_layout.addWidget(TitleLabel("Omega", self, collapsible=True), 0, 0, 1, 2)
grid_layout.setColumnStretch(0, 1)
grid_layout.setColumnStretch(1, 1)
omega_settings = [
+1 -1
View File
@@ -35,7 +35,7 @@ class SamcamPanel(QWidget):
# Create layout
layout = QVBoxLayout()
layout.addWidget(TitleLabel("Sample camera", self))
layout.addWidget(TitleLabel("Sample camera", self, collapsible=True))
# Exposure control
exposure_layout = QHBoxLayout()
+1 -1
View File
@@ -58,7 +58,7 @@ class SmargonPanel(QWidget):
grid_layout = QGridLayout(self)
grid_layout.addWidget(TitleLabel("Smargon", self), 0, 0, 1, 6)
grid_layout.addWidget(TitleLabel("Smargon", self, collapsible=True), 0, 0, 1, 6)
grid_layout.addWidget(QLabel("Chi", parent=self), 1, 0)
self.chi_enter = NumberLineEdit(-0.2, 40, decimals=1, parent=self)
+1 -1
View File
@@ -23,7 +23,7 @@ class ZoomPanel(QWidget):
{"name": "7.5x", "value": 800},
{"name": "12.5x", "value": 1000},
]
grid_layout.addWidget(TitleLabel("Zoom", self), 0, 0, 1, 2)
grid_layout.addWidget(TitleLabel("Zoom", self, collapsible=True), 0, 0, 1, 2)
i = 2
self._buttons = []
+14 -18
View File
@@ -186,19 +186,17 @@ def _original_stylesheet() -> str:
QLabel#beamlineStateTitle {
background-color: #4B0082;
color: #ffffff;
}
QPushButton#beamlineStateToggleButton {
border: none;
border-radius: 14px;
background-color: rgba(255, 255, 255, 0.20);
color: white;
font-size: 16px;
font-weight: 700;
}
QPushButton#beamlineStateToggleButton:hover {
background-color: rgba(255, 255, 255, 0.32);
/* Bare glyph to match the TitleLabel toggles: no pill background. */
QPushButton#beamlineStateToggleButton {
border: none;
background: transparent;
color: white;
font-size: 14px;
font-weight: 700;
}
QLabel#beamlineStateCurrentLabel {
@@ -430,19 +428,17 @@ def _portrait_stylesheet() -> str:
QLabel#beamlineStateTitle {
background-color: #132131;
color: #F5F7FA;
}
QPushButton#beamlineStateToggleButton {
border: none;
border-radius: 14px;
background-color: rgba(255, 255, 255, 0.10);
color: #F5F7FA;
font-size: 16px;
font-weight: 700;
}
QPushButton#beamlineStateToggleButton:hover {
background-color: rgba(255, 255, 255, 0.18);
/* Bare glyph to match the TitleLabel toggles: no pill background. */
QPushButton#beamlineStateToggleButton {
border: none;
background: transparent;
color: #F5F7FA;
font-size: 14px;
font-weight: 700;
}
QLabel#beamlineStateCurrentLabel {
+103 -6
View File
@@ -1,12 +1,109 @@
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QLabel
from PySide6.QtCore import QSettings, Qt, QTimer
from PySide6.QtWidgets import QHBoxLayout, QLabel, QLayout, QPushButton
# Universal vertical rhythm between stacked panels: each panel contributes
# PANEL_VMARGIN top and bottom, the column adds PANEL_VSPACING between them,
# so every banner-to-banner gap is 4 + 3 + 4 = 11px in every column.
PANEL_VSPACING = 3
PANEL_VMARGIN = 4
def tighten_column(layout: QLayout) -> None:
"""Apply the universal panel gap to a column layout and its child panels.
Qt's defaults (9px margins + 6px spacing) and ad-hoc per-panel margins
made the gaps uneven between the left and right columns.
"""
layout.setSpacing(PANEL_VSPACING)
for i in range(layout.count()):
w = layout.itemAt(i).widget()
if w is not None and w.layout() is not None:
m = w.layout().contentsMargins()
w.layout().setContentsMargins(m.left(), PANEL_VMARGIN, m.right(), PANEL_VMARGIN)
class TitleLabel(QLabel):
def __init__(self, text: str, parent=None):
def __init__(self, text: str, parent=None, collapsible: bool = False):
super().__init__(parent)
self.setText(f"<H3>{text}</H3>")
self.setStyleSheet("background-color: #4B0082; color: #ffffff;")
# Plain text + QSS font instead of <H3>: rich-text heading margins
# would clip vertically in the halved banner height.
self.setText(text)
# Scoped selector: an unscoped widget stylesheet propagates to child
# widgets and would paint the toggle button solid purple, overriding
# the app QSS.
self.setStyleSheet(
"TitleLabel { background-color: #4B0082; color: #ffffff;"
" font-size: 16px; font-weight: 700; }"
)
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.setFixedHeight(50)
# Half the original 50px: the full-height banner wasted vertical space.
self.setFixedHeight(25)
self._collapsible = collapsible
if not collapsible:
return
self._collapsed = False
# ponytail: settings key is the title text — unique across panels;
# renaming a title just resets that panel to expanded once.
self._settings_key = f"panel_collapsed/{text}"
self.toggle_button = QPushButton("", self)
# Bare glyph, no pill: the shared beamlineStateToggleButton QSS paints
# a translucent white background, which is unwanted on these banners.
self.toggle_button.setStyleSheet(
"QPushButton { background: transparent; border: none;"
" color: #ffffff; font-size: 14px; font-weight: 700; }"
)
self.toggle_button.setToolTip("Minimise panel")
self.toggle_button.setFixedSize(21, 21)
self.toggle_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.toggle_button.clicked.connect(self.toggle_collapsed)
button_layout = QHBoxLayout(self)
button_layout.setContentsMargins(0, 0, 8, 0)
button_layout.addStretch()
button_layout.addWidget(self.toggle_button)
self.setCursor(Qt.CursorShape.PointingHandCursor)
settings = QSettings("PSI", "AareGUI")
if settings.value(self._settings_key, False, type=bool):
self._collapsed = True
# Deferred: the panel adds its other widgets after constructing
# the TitleLabel, so siblings don't exist yet.
QTimer.singleShot(0, self._apply_collapsed)
def mousePressEvent(self, event):
if self._collapsible:
self.toggle_collapsed()
super().mousePressEvent(event)
def expand(self) -> None:
if self._collapsible and self._collapsed:
self.toggle_collapsed()
def toggle_collapsed(self) -> None:
self._collapsed = not self._collapsed
self._apply_collapsed()
QSettings("PSI", "AareGUI").setValue(self._settings_key, self._collapsed)
def _apply_collapsed(self) -> None:
parent = self.parentWidget()
if parent is None or parent.layout() is None:
return
self._set_visible(parent.layout(), not self._collapsed)
self.toggle_button.setText("+" if self._collapsed else "")
self.toggle_button.setToolTip("Restore panel" if self._collapsed else "Minimise panel")
def _set_visible(self, layout: QLayout, visible: bool) -> None:
# Recursive: panels like SamcamPanel nest sub-layouts via addLayout.
for i in range(layout.count()):
item = layout.itemAt(i)
widget = item.widget()
if widget is not None:
if widget is not self:
widget.setVisible(visible)
elif item.layout() is not None:
self._set_visible(item.layout(), visible)
+72
View File
@@ -0,0 +1,72 @@
from PySide6.QtCore import QSettings
from PySide6.QtWidgets import QGridLayout, QHBoxLayout, QPushButton, QWidget
from aare.gui.widgets.title_label import TitleLabel
# Unique title so the test never clashes with real panel settings.
TITLE = "TitleLabelTestPanel"
KEY = f"panel_collapsed/{TITLE}"
def _remove_key():
QSettings("PSI", "AareGUI").remove(KEY)
def _build_panel(qtbot):
panel = QWidget()
qtbot.addWidget(panel)
grid = QGridLayout(panel)
title = TitleLabel(TITLE, panel, collapsible=True)
grid.addWidget(title, 0, 0, 1, 2)
direct_child = QPushButton("direct", panel)
grid.addWidget(direct_child, 1, 0)
nested = QHBoxLayout()
nested_child = QPushButton("nested", panel)
nested.addWidget(nested_child)
grid.addLayout(nested, 1, 1)
return panel, title, direct_child, nested_child
def test_toggle_hides_children_and_persists(qtbot):
_remove_key()
try:
panel, title, direct_child, nested_child = _build_panel(qtbot)
expanded_height = panel.sizeHint().height()
title.toggle_collapsed()
assert direct_child.isHidden()
assert nested_child.isHidden()
assert not title.isHidden()
assert panel.sizeHint().height() < expanded_height
assert title.toggle_button.text() == "+"
assert QSettings("PSI", "AareGUI").value(KEY, False, type=bool) is True
title.toggle_collapsed()
assert not direct_child.isHidden()
assert not nested_child.isHidden()
assert title.toggle_button.text() == ""
assert QSettings("PSI", "AareGUI").value(KEY, False, type=bool) is False
finally:
_remove_key()
def test_collapsed_state_restored_on_construction(qtbot):
QSettings("PSI", "AareGUI").setValue(KEY, True)
try:
panel, title, direct_child, nested_child = _build_panel(qtbot)
# Restore is deferred with a 0 ms timer (siblings don't exist yet at
# TitleLabel construction), so let the event loop run once.
qtbot.waitUntil(lambda: direct_child.isHidden(), timeout=1000)
assert nested_child.isHidden()
assert title.toggle_button.text() == "+"
finally:
_remove_key()
def test_not_collapsible_by_default(qtbot):
panel = QWidget()
qtbot.addWidget(panel)
grid = QGridLayout(panel)
title = TitleLabel("Plain", panel)
grid.addWidget(title, 0, 0)
assert not hasattr(title, "toggle_button")