diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py
index b076b018..0a889d58 100644
--- a/src/aare/gui/main_window.py
+++ b/src/aare/gui/main_window.py
@@ -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)
diff --git a/src/aare/gui/panels/abr_tweak_panel.py b/src/aare/gui/panels/abr_tweak_panel.py
index 573a9bdf..4d70f17a 100644
--- a/src/aare/gui/panels/abr_tweak_panel.py
+++ b/src/aare/gui/panels/abr_tweak_panel.py
@@ -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)
diff --git a/src/aare/gui/panels/beam_center_panel.py b/src/aare/gui/panels/beam_center_panel.py
index c14a39a1..2f5d273b 100644
--- a/src/aare/gui/panels/beam_center_panel.py
+++ b/src/aare/gui/panels/beam_center_panel.py
@@ -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)
diff --git a/src/aare/gui/panels/beam_mark_panel.py b/src/aare/gui/panels/beam_mark_panel.py
index de1b3da0..2842f70e 100644
--- a/src/aare/gui/panels/beam_mark_panel.py
+++ b/src/aare/gui/panels/beam_mark_panel.py
@@ -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")
diff --git a/src/aare/gui/panels/beam_size_panel.py b/src/aare/gui/panels/beam_size_panel.py
index 3a9219ff..56fb765f 100644
--- a/src/aare/gui/panels/beam_size_panel.py
+++ b/src/aare/gui/panels/beam_size_panel.py
@@ -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)
diff --git a/src/aare/gui/panels/beamline_controls.py b/src/aare/gui/panels/beamline_controls.py
index 588470fd..2b7a7dad 100644
--- a/src/aare/gui/panels/beamline_controls.py
+++ b/src/aare/gui/panels/beamline_controls.py
@@ -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)
diff --git a/src/aare/gui/panels/beamline_state_panel.py b/src/aare/gui/panels/beamline_state_panel.py
index 26210c41..f9add765 100644
--- a/src/aare/gui/panels/beamline_state_panel.py
+++ b/src/aare/gui/panels/beamline_state_panel.py
@@ -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("
Beamline state
")
+ # Plain text + QSS font: 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)
diff --git a/src/aare/gui/panels/data_collection_settings.py b/src/aare/gui/panels/data_collection_settings.py
index 40a0b6b8..04d7165d 100644
--- a/src/aare/gui/panels/data_collection_settings.py
+++ b/src/aare/gui/panels/data_collection_settings.py
@@ -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)
diff --git a/src/aare/gui/panels/file_path_panel.py b/src/aare/gui/panels/file_path_panel.py
index 08f27d11..45204210 100644
--- a/src/aare/gui/panels/file_path_panel.py
+++ b/src/aare/gui/panels/file_path_panel.py
@@ -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)
diff --git a/src/aare/gui/panels/illumination_panel.py b/src/aare/gui/panels/illumination_panel.py
index f14094b7..50bac3e9 100644
--- a/src/aare/gui/panels/illumination_panel.py
+++ b/src/aare/gui/panels/illumination_panel.py
@@ -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)
diff --git a/src/aare/gui/panels/loop_centering_panel.py b/src/aare/gui/panels/loop_centering_panel.py
index 15d9a179..ee2a3a5d 100644
--- a/src/aare/gui/panels/loop_centering_panel.py
+++ b/src/aare/gui/panels/loop_centering_panel.py
@@ -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)
diff --git a/src/aare/gui/panels/manual_sample_panel.py b/src/aare/gui/panels/manual_sample_panel.py
index 9b071140..7f86a5f8 100644
--- a/src/aare/gui/panels/manual_sample_panel.py
+++ b/src/aare/gui/panels/manual_sample_panel.py
@@ -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)
diff --git a/src/aare/gui/panels/monochromator_panel.py b/src/aare/gui/panels/monochromator_panel.py
index ed637c87..2dc815a9 100644
--- a/src/aare/gui/panels/monochromator_panel.py
+++ b/src/aare/gui/panels/monochromator_panel.py
@@ -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)
diff --git a/src/aare/gui/panels/omega_panel.py b/src/aare/gui/panels/omega_panel.py
index e2585af8..b7e7a9ae 100644
--- a/src/aare/gui/panels/omega_panel.py
+++ b/src/aare/gui/panels/omega_panel.py
@@ -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 = [
diff --git a/src/aare/gui/panels/samcam_panel.py b/src/aare/gui/panels/samcam_panel.py
index b69e5c8b..83ad9777 100644
--- a/src/aare/gui/panels/samcam_panel.py
+++ b/src/aare/gui/panels/samcam_panel.py
@@ -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()
diff --git a/src/aare/gui/panels/smargon_panel.py b/src/aare/gui/panels/smargon_panel.py
index d4aa81c0..2dcaac19 100644
--- a/src/aare/gui/panels/smargon_panel.py
+++ b/src/aare/gui/panels/smargon_panel.py
@@ -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)
diff --git a/src/aare/gui/panels/zoom_panel.py b/src/aare/gui/panels/zoom_panel.py
index 27bdc8f2..6ff58f41 100644
--- a/src/aare/gui/panels/zoom_panel.py
+++ b/src/aare/gui/panels/zoom_panel.py
@@ -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 = []
diff --git a/src/aare/gui/styles.py b/src/aare/gui/styles.py
index 1dbf1f3c..527911ae 100644
--- a/src/aare/gui/styles.py
+++ b/src/aare/gui/styles.py
@@ -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 {
diff --git a/src/aare/gui/widgets/title_label.py b/src/aare/gui/widgets/title_label.py
index 44e7a55c..237df236 100644
--- a/src/aare/gui/widgets/title_label.py
+++ b/src/aare/gui/widgets/title_label.py
@@ -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"{text}
")
- self.setStyleSheet("background-color: #4B0082; color: #ffffff;")
+ # Plain text + QSS font instead of : 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)
diff --git a/tests/unit/gui/test_title_label.py b/tests/unit/gui/test_title_label.py
new file mode 100644
index 00000000..cf6d7398
--- /dev/null
+++ b/tests/unit/gui/test_title_label.py
@@ -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")