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")