From f4f3c5ec3d7d734dc34cf6c2c72d053deea4a200 Mon Sep 17 00:00:00 2001 From: Dawn Date: Thu, 6 Aug 2026 10:53:37 +0200 Subject: [PATCH 01/57] fix: adapt main window to monitor size instead of overflowing The window's minimum was 1400x1207 - taller than a 1920x1200 console - so the status bar was pushed off-screen on every monitor. Three causes: - the standard page's combined panel minimums: now wrapped in a QScrollArea so scrollbars appear instead of clamping the window - hidden portrait/compact stack pages inflating the QStackedWidget minimum (portrait alone demands 720px height): only the visible page contributes now - launching at the natural size hint (1577x1612): launch maximized so the window takes whatever the monitor offers Minimum drops to 1400x555; verified on a 1920x1200 X display that the status bar is visible and panels scroll when space runs out. Co-Authored-By: Claude Fable 5 --- src/aare/gui/gui.py | 4 +++- src/aare/gui/main_window.py | 35 ++++++++++++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/aare/gui/gui.py b/src/aare/gui/gui.py index e5387451..93d9646c 100644 --- a/src/aare/gui/gui.py +++ b/src/aare/gui/gui.py @@ -189,7 +189,9 @@ def main(): splash.set_progress(100, "Ready") splash.finish(win) - win.show() + # Maximized so the window adapts to the monitor instead of its size hint, + # which is taller than a 1920x1200 console. + win.showMaximized() sys.exit(app.exec()) except Exception as e: diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 061f11df..b076b018 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -18,9 +18,12 @@ from PySide6.QtCore import QEvent, QSettings, Qt, QTimer, Signal, Slot from PySide6.QtGui import QAction, QActionGroup, QGuiApplication, QKeySequence from PySide6.QtWidgets import ( QDockWidget, + QFrame, QHBoxLayout, QMainWindow, QMessageBox, + QScrollArea, + QSizePolicy, QStackedWidget, QTabWidget, QVBoxLayout, @@ -520,11 +523,37 @@ class MainWindow(QMainWindow): self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.prediction_metrics_dock) self.prediction_metrics_dock.hide() - self.content_stack.addWidget(top_widget) + # The standard page's combined panel minimum (~1400x1200) exceeds many + # monitors, which pushed the status bar off-screen. Scrolling instead of + # clamping lets the window shrink to any screen; scrollbars only appear + # when the monitor is actually smaller than the panels. + standard_page_scroll = QScrollArea(parent=root_widget) + standard_page_scroll.setWidgetResizable(True) + standard_page_scroll.setFrameShape(QFrame.Shape.NoFrame) + standard_page_scroll.setWidget(top_widget) + + self.content_stack.addWidget(standard_page_scroll) self.content_stack.addWidget(self.compact_automation_page) self.content_stack.addWidget(self.portrait_mode_page) - self.content_stack.setCurrentWidget(top_widget) - self._standard_main_page = top_widget + self.content_stack.setCurrentWidget(standard_page_scroll) + self._standard_main_page = standard_page_scroll + + # QStackedWidget's minimum size is the max over ALL pages, so the hidden + # portrait/compact pages inflated the window minimum past small monitors + # (portrait alone demands 720px height). Only the visible page should + # count — the mode-switch code resizes the window explicitly anyway. + def _only_current_page_counts(index: int) -> None: + for i in range(self.content_stack.count()): + page = self.content_stack.widget(i) + policy = ( + QSizePolicy.Policy.Preferred + if i == index + else QSizePolicy.Policy.Ignored + ) + page.setSizePolicy(policy, policy) + + self.content_stack.currentChanged.connect(_only_current_page_counts) + _only_current_page_counts(self.content_stack.currentIndex()) self.setCentralWidget(root_widget) -- 2.54.0 From a9137b3221fff7364d0a3c98d9d6a4199eeb95b9 Mon Sep 17 00:00:00 2001 From: Dawn Date: Thu, 6 Aug 2026 15:13:07 +0200 Subject: [PATCH 02/57] feat: collapsible half-height panel banners with a universal gap 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 --- src/aare/gui/main_window.py | 39 ++----- src/aare/gui/panels/abr_tweak_panel.py | 4 +- src/aare/gui/panels/beam_center_panel.py | 2 +- src/aare/gui/panels/beam_mark_panel.py | 2 +- src/aare/gui/panels/beam_size_panel.py | 2 +- src/aare/gui/panels/beamline_controls.py | 2 + src/aare/gui/panels/beamline_state_panel.py | 23 +++- .../gui/panels/data_collection_settings.py | 23 +++- src/aare/gui/panels/file_path_panel.py | 6 +- src/aare/gui/panels/illumination_panel.py | 2 +- src/aare/gui/panels/loop_centering_panel.py | 2 +- src/aare/gui/panels/manual_sample_panel.py | 8 +- src/aare/gui/panels/monochromator_panel.py | 2 +- src/aare/gui/panels/omega_panel.py | 2 +- src/aare/gui/panels/samcam_panel.py | 2 +- src/aare/gui/panels/smargon_panel.py | 2 +- src/aare/gui/panels/zoom_panel.py | 2 +- src/aare/gui/styles.py | 32 +++-- src/aare/gui/widgets/title_label.py | 109 +++++++++++++++++- tests/unit/gui/test_title_label.py | 72 ++++++++++++ 20 files changed, 265 insertions(+), 73 deletions(-) create mode 100644 tests/unit/gui/test_title_label.py 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") -- 2.54.0 From ce0ed20d5a1d4d2f33e662eb8be7bd58fbe2d368 Mon Sep 17 00:00:00 2001 From: Dawn Date: Thu, 6 Aug 2026 15:28:14 +0200 Subject: [PATCH 03/57] style: fix lint for the banner/adaptive changes - ruff format and import ordering (main_window, data_collection_settings, beam_center_panel, beamline_state_panel) - rename unused unpacked variable in test_title_label - replace the mousePressEvent monkeypatch on the beamline state title with a proper eventFilter so basedpyright accepts the diff Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 8 ++------ src/aare/gui/panels/beam_center_panel.py | 4 +++- src/aare/gui/panels/beamline_state_panel.py | 17 +++++++++++------ src/aare/gui/panels/data_collection_settings.py | 4 ++-- tests/unit/gui/test_title_label.py | 2 +- 5 files changed, 19 insertions(+), 16 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 0a889d58..f8405460 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -85,9 +85,9 @@ 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.title_label import tighten_column from aare.gui.widgets.video_image import VideoGraphicsView logger = setup_logger(LOGGER_NAME) @@ -543,11 +543,7 @@ class MainWindow(QMainWindow): def _only_current_page_counts(index: int) -> None: for i in range(self.content_stack.count()): page = self.content_stack.widget(i) - policy = ( - QSizePolicy.Policy.Preferred - if i == index - else QSizePolicy.Policy.Ignored - ) + policy = QSizePolicy.Policy.Preferred if i == index else QSizePolicy.Policy.Ignored page.setSizePolicy(policy, policy) self.content_stack.currentChanged.connect(_only_current_page_counts) diff --git a/src/aare/gui/panels/beam_center_panel.py b/src/aare/gui/panels/beam_center_panel.py index 2f5d273b..4140bc9f 100644 --- a/src/aare/gui/panels/beam_center_panel.py +++ b/src/aare/gui/panels/beam_center_panel.py @@ -14,7 +14,9 @@ class BeamCenterWidget(QWidget): grid_layout = QGridLayout(self) - grid_layout.addWidget(TitleLabel("Beam center (detector)", self, collapsible=True), 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/beamline_state_panel.py b/src/aare/gui/panels/beamline_state_panel.py index f9add765..e38b6b93 100644 --- a/src/aare/gui/panels/beamline_state_panel.py +++ b/src/aare/gui/panels/beamline_state_panel.py @@ -2,7 +2,7 @@ from collections import deque from dataclasses import dataclass from aarecommon.models.models import BeamlineStateEnum, DAQStatusModel -from PySide6.QtCore import QPoint, QRect, Qt, Signal, Slot +from PySide6.QtCore import QEvent, QObject, QPoint, QRect, Qt, Signal, Slot from PySide6.QtGui import QColor, QPainter, QPen from PySide6.QtWidgets import QFrame, QLabel, QPushButton @@ -219,17 +219,16 @@ class BeamlineStatePanel(QFrame): self.title.setAlignment(Qt.AlignmentFlag.AlignCenter) self.title.setFixedHeight(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. + # Whole banner toggles via eventFilter, like TitleLabel; the +/- glyph + # is only the indicator. self.title.setCursor(Qt.CursorShape.PointingHandCursor) - self.title.mousePressEvent = lambda _event: self.toggle_collapsed() + self.title.installEventFilter(self) self.toggle_button = QPushButton("−", self) self.toggle_button.setObjectName("beamlineStateToggleButton") self.toggle_button.setToolTip("Minimise beamline state panel") self.toggle_button.setFixedSize(21, 21) - self.toggle_button.move( - self.set_width - 29, PANEL_VMARGIN + (self.title_height - 21) // 2 - ) + 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) @@ -355,6 +354,12 @@ class BeamlineStatePanel(QFrame): self._apply_station_highlight() + def eventFilter(self, watched: QObject, event: QEvent) -> bool: + if watched is self.title and event.type() == QEvent.Type.MouseButtonPress: + self.toggle_collapsed() + return True + return super().eventFilter(watched, event) + def toggle_collapsed(self) -> None: self._is_collapsed = not self._is_collapsed self._update_collapsed_state() diff --git a/src/aare/gui/panels/data_collection_settings.py b/src/aare/gui/panels/data_collection_settings.py index 04d7165d..c8cad827 100644 --- a/src/aare/gui/panels/data_collection_settings.py +++ b/src/aare/gui/panels/data_collection_settings.py @@ -5,13 +5,13 @@ from PySide6.QtCore import Signal, Slot 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.manual_sample_panel import ManualSamplePanel from aare.gui.panels.raster_data_collection import RasterDataCollectionPanel from aare.gui.panels.rotation_data_collection import RotationDataCollectionPanel from aare.gui.panels.smart_rotation_panel import SimpleRotationSettingsPanel from aare.gui.scan_logic.raster_grid_manager import RasterGridManager +from aare.gui.widgets.title_label import TitleLabel, tighten_column class DataCollectionSettings(QFrame): diff --git a/tests/unit/gui/test_title_label.py b/tests/unit/gui/test_title_label.py index cf6d7398..4cad9306 100644 --- a/tests/unit/gui/test_title_label.py +++ b/tests/unit/gui/test_title_label.py @@ -53,7 +53,7 @@ def test_toggle_hides_children_and_persists(qtbot): def test_collapsed_state_restored_on_construction(qtbot): QSettings("PSI", "AareGUI").setValue(KEY, True) try: - panel, title, direct_child, nested_child = _build_panel(qtbot) + _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) -- 2.54.0 From ee7a8356a6d46de5e97a010886eb6e7cb1a4f051 Mon Sep 17 00:00:00 2001 From: Dawn Date: Thu, 6 Aug 2026 15:47:06 +0200 Subject: [PATCH 04/57] style: narrow Optionals for basedpyright diff gate QLayout.itemAt/QWidget.layout return Optionals; pyright cannot narrow across repeated method calls, so hold them in locals before use in tighten_column, _apply_collapsed and _set_visible. Co-Authored-By: Claude Fable 5 --- src/aare/gui/widgets/title_label.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/aare/gui/widgets/title_label.py b/src/aare/gui/widgets/title_label.py index 237df236..2d128825 100644 --- a/src/aare/gui/widgets/title_label.py +++ b/src/aare/gui/widgets/title_label.py @@ -16,10 +16,12 @@ def tighten_column(layout: QLayout) -> None: """ 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) + item = layout.itemAt(i) + widget = item.widget() if item is not None else None + child_layout = widget.layout() if widget is not None else None + if child_layout is not None: + m = child_layout.contentsMargins() + child_layout.setContentsMargins(m.left(), PANEL_VMARGIN, m.right(), PANEL_VMARGIN) class TitleLabel(QLabel): @@ -91,9 +93,10 @@ class TitleLabel(QLabel): def _apply_collapsed(self) -> None: parent = self.parentWidget() - if parent is None or parent.layout() is None: + parent_layout = parent.layout() if parent is not None else None + if parent_layout is None: return - self._set_visible(parent.layout(), not self._collapsed) + 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") @@ -101,9 +104,12 @@ class TitleLabel(QLabel): # Recursive: panels like SamcamPanel nest sub-layouts via addLayout. for i in range(layout.count()): item = layout.itemAt(i) + if item is None: + continue widget = item.widget() + child_layout = item.layout() 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) + elif child_layout is not None: + self._set_visible(child_layout, visible) -- 2.54.0 From f84bee66ee726598dd38b75f5a8978a29bcc7806 Mon Sep 17 00:00:00 2001 From: Dawn Date: Thu, 6 Aug 2026 16:54:44 +0200 Subject: [PATCH 05/57] feat: start all collapsible panels collapsed by default A fresh GUI shows only the banners; the Beamline state panel keeps its own expanded default. A user's saved per-title states still override. Co-Authored-By: Claude Fable 5 --- src/aare/gui/widgets/title_label.py | 5 ++++- tests/unit/gui/test_title_label.py | 35 ++++++++++++++++------------- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/aare/gui/widgets/title_label.py b/src/aare/gui/widgets/title_label.py index 2d128825..ac9eef1f 100644 --- a/src/aare/gui/widgets/title_label.py +++ b/src/aare/gui/widgets/title_label.py @@ -71,7 +71,10 @@ class TitleLabel(QLabel): self.setCursor(Qt.CursorShape.PointingHandCursor) settings = QSettings("PSI", "AareGUI") - if settings.value(self._settings_key, False, type=bool): + # Default collapsed: a fresh GUI shows only banners (plus the expanded + # Beamline state panel, which manages its own default) until the user + # opens what they need; their choice is then persisted per title. + if settings.value(self._settings_key, True, type=bool): self._collapsed = True # Deferred: the panel adds its other widgets after constructing # the TitleLabel, so siblings don't exist yet. diff --git a/tests/unit/gui/test_title_label.py b/tests/unit/gui/test_title_label.py index 4cad9306..168dc31d 100644 --- a/tests/unit/gui/test_title_label.py +++ b/tests/unit/gui/test_title_label.py @@ -27,38 +27,41 @@ def _build_panel(qtbot): return panel, title, direct_child, nested_child -def test_toggle_hides_children_and_persists(qtbot): +def test_starts_collapsed_by_default_and_toggle_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() + _panel, title, direct_child, nested_child = _build_panel(qtbot) + # Default is collapsed; applied deferred with a 0 ms timer (siblings + # don't exist yet at TitleLabel construction). + qtbot.waitUntil(lambda: direct_child.isHidden(), timeout=1000) 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 + assert QSettings("PSI", "AareGUI").value(KEY, True, type=bool) is False + + title.toggle_collapsed() + assert direct_child.isHidden() + assert title.toggle_button.text() == "+" + assert QSettings("PSI", "AareGUI").value(KEY, False, type=bool) is True finally: _remove_key() -def test_collapsed_state_restored_on_construction(qtbot): - QSettings("PSI", "AareGUI").setValue(KEY, True) +def test_saved_expanded_state_restored_on_construction(qtbot): + QSettings("PSI", "AareGUI").setValue(KEY, False) 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() == "+" + # A saved expanded state must override the collapsed default; give the + # (absent) deferred collapse a chance to run before asserting. + qtbot.wait(100) + assert not direct_child.isHidden() + assert not nested_child.isHidden() + assert title.toggle_button.text() == "−" finally: _remove_key() -- 2.54.0 From c14d93ffcf0a9f8c3f3b98318ec772cd153789a8 Mon Sep 17 00:00:00 2001 From: Dawn Date: Thu, 6 Aug 2026 16:54:44 +0200 Subject: [PATCH 06/57] fix: declutter the Local Contact view - drop the redundant Local Contact banner (the window title carries it) - remove the three parent=self config spinboxes that painted themselves over the top-left corner; parentless placeholders are replaced in _build_config_tab - remove all rounded corners (status card, value badges, group boxes, transfer-error frame, recovery panels) - tighten status rows to 2px spacing with a per-row minimum height so word-wrapped value labels can never compress rows until text clips (previously visible on the Hardware tab) Co-Authored-By: Claude Fable 5 --- .../gui/panels/beamline_recovery_panel.py | 8 ------- src/aare/gui/panels/local_contact_panel.py | 21 +++++++------------ .../widgets/local_contact_status_widget.py | 10 ++++++--- 3 files changed, 15 insertions(+), 24 deletions(-) diff --git a/src/aare/gui/panels/beamline_recovery_panel.py b/src/aare/gui/panels/beamline_recovery_panel.py index 17933205..882f021f 100644 --- a/src/aare/gui/panels/beamline_recovery_panel.py +++ b/src/aare/gui/panels/beamline_recovery_panel.py @@ -40,7 +40,6 @@ class RecoveryPanel(QWidget): " background: #fff3cd;" " color: #7a4b00;" " border: 1px solid #f0c36d;" - " border-radius: 8px;" " padding: 10px;" " font-weight: 600;" "}" @@ -57,7 +56,6 @@ class RecoveryPanel(QWidget): " background: #fdeaea;" " color: #8b1e1e;" " border: 1px solid #e6a8a8;" - " border-radius: 8px;" " padding: 10px;" " font-weight: 600;" "}" @@ -71,7 +69,6 @@ class RecoveryPanel(QWidget): " background: #eef6ff;" " color: #12406a;" " border: 1px solid #a8c7e6;" - " border-radius: 8px;" " padding: 10px;" " font-weight: 600;" "}" @@ -83,7 +80,6 @@ class RecoveryPanel(QWidget): "QPushButton {" " background: #fff7db;" " border: 1px solid #e7cb73;" - " border-radius: 8px;" " padding: 10px;" " font-weight: 600;" "}" @@ -96,7 +92,6 @@ class RecoveryPanel(QWidget): "QPushButton {" " background: #fff7db;" " border: 1px solid #e7cb73;" - " border-radius: 8px;" " padding: 10px;" " font-weight: 600;" "}" @@ -110,7 +105,6 @@ class RecoveryPanel(QWidget): " background: #fdeaea;" " color: #8b1e1e;" " border: 1px solid #e6a8a8;" - " border-radius: 8px;" " padding: 10px;" " font-weight: 700;" "}" @@ -124,7 +118,6 @@ class RecoveryPanel(QWidget): " background: #fdeaea;" " color: #8b1e1e;" " border: 1px solid #e6a8a8;" - " border-radius: 8px;" " padding: 10px;" " font-weight: 700;" "}" @@ -138,7 +131,6 @@ class RecoveryPanel(QWidget): " background: #eef6ff;" " color: #12406a;" " border: 1px solid #a8c7e6;" - " border-radius: 8px;" " padding: 10px;" " font-weight: 600;" "}" diff --git a/src/aare/gui/panels/local_contact_panel.py b/src/aare/gui/panels/local_contact_panel.py index 7b6630c4..409e76f0 100644 --- a/src/aare/gui/panels/local_contact_panel.py +++ b/src/aare/gui/panels/local_contact_panel.py @@ -31,7 +31,6 @@ from aare.gui.panels.beamline_recovery_panel import RecoveryPanel from aare.gui.threads.daq_worker import DAQWorker from aare.gui.widgets.local_contact_status_widget import LocalContactStatusWidget from aare.gui.widgets.text_list_dialog import TextListDialog -from aare.gui.widgets.title_label import TitleLabel logger = setup_logger(LOGGER_NAME) @@ -65,9 +64,12 @@ class LocalContactPanel(QFrame): self._bec_macros_dialog: TextListDialog | None = None self._bec_devices_dialog: TextListDialog | None = None self._local_contact_config_payload: dict = {} - self._mount_to_center_sleep_s = QDoubleSpinBox(self) - self._line_scan_loop_face_y_padding_fraction_each_side = QDoubleSpinBox(self) - self._line_scan_loop_all_y_padding_fraction_each_side = QDoubleSpinBox(self) + # Recreated with proper parents in _build_config_tab; parentless here + # so no orphan widget floats over the panel (the old parent=self + # copies painted themselves over the top-left corner). + self._mount_to_center_sleep_s = QDoubleSpinBox() + self._line_scan_loop_face_y_padding_fraction_each_side = QDoubleSpinBox() + self._line_scan_loop_all_y_padding_fraction_each_side = QDoubleSpinBox() self.setFrameShape(QFrame.Shape.StyledPanel) self.setFrameShadow(QFrame.Shadow.Raised) @@ -76,7 +78,6 @@ class LocalContactPanel(QFrame): QGroupBox { background-color: white; border: 1px solid #c7d4e5; - border-radius: 5px; margin-top: 15px; padding-top: 15px; font-weight: 700; @@ -96,8 +97,7 @@ class LocalContactPanel(QFrame): layout.setContentsMargins(8, 8, 8, 8) layout.setSpacing(8) - layout.addWidget(TitleLabel("Local Contact", parent=self)) - + # No TitleLabel banner: the dialog window title already says it. self._info_label = QLabel( "Staff tools for beamline recovery and local-contact operations.", self ) @@ -107,12 +107,7 @@ class LocalContactPanel(QFrame): self._transfer_error_frame = QFrame(self) self._transfer_error_frame.setVisible(False) self._transfer_error_frame.setStyleSheet( - "QFrame {" - " background: #fdeaea;" - " color: #8b1e1e;" - " border: 1px solid #e6a8a8;" - " border-radius: 8px;" - "}" + "QFrame { background: #fdeaea; color: #8b1e1e; border: 1px solid #e6a8a8;}" ) transfer_error_layout = QVBoxLayout(self._transfer_error_frame) transfer_error_layout.setContentsMargins(10, 10, 10, 10) diff --git a/src/aare/gui/widgets/local_contact_status_widget.py b/src/aare/gui/widgets/local_contact_status_widget.py index 328e9157..4d8d45fc 100644 --- a/src/aare/gui/widgets/local_contact_status_widget.py +++ b/src/aare/gui/widgets/local_contact_status_widget.py @@ -83,7 +83,6 @@ class LocalContactStatusWidget(QFrame): QFrame#localContactStatusCard { background: #f8fbff; border: 1px solid #c7d4e5; - border-radius: 10px; } """ ) @@ -103,7 +102,8 @@ class LocalContactStatusWidget(QFrame): self._grid = QGridLayout() self._grid.setContentsMargins(0, 0, 0, 0) self._grid.setHorizontalSpacing(14) - self._grid.setVerticalSpacing(5) + # Tight rows; the badge keeps 2px vertical padding so text never clips. + self._grid.setVerticalSpacing(2) layout.addLayout(self._grid) self._rebuild_rows() @@ -139,6 +139,10 @@ class LocalContactStatusWidget(QFrame): self._row_widgets[key] = (title, value) self._grid.addWidget(title, row, 0, alignment=Qt.AlignmentFlag.AlignTop) self._grid.addWidget(value, row, 1) + # Word-wrapped value labels report a near-zero minimum height, so + # cramped tabs (e.g. Hardware) could compress rows until the text + # clipped; guarantee one full text line + badge padding per row. + self._grid.setRowMinimumHeight(row, self.fontMetrics().height() + 6) def _badge(self, text: str, *, tone: str = "neutral") -> str: palette = { @@ -151,7 +155,7 @@ class LocalContactStatusWidget(QFrame): background, foreground = palette.get(tone, palette["neutral"]) return ( f"{text}" + f"padding:2px 6px;'>{text}" ) def _format_bool( -- 2.54.0 From 9ef4032d06274fb1448907f21b53b53b2ac34400 Mon Sep 17 00:00:00 2001 From: Dawn Date: Thu, 6 Aug 2026 18:32:07 +0200 Subject: [PATCH 07/57] feat: horizontal beamline state bar above the status bar Replaces the vertical station map that consumed left-column space with a slim always-visible strip in the bottom toolbar area, directly above the status bar: - states in physical beamline order, 18px text, one line when the bar is wide enough and wrapped at the last space otherwise - availability coloring from the route graph plus the status-bar shortcut transitions: bold blue = active (red for Maintenance), orange = reachable in one step, grey = not reachable; no backgrounds or rounded corners - transitions only via right-click 'Go to '; left click shows a reminder tip for available states and the reachability explanation for grey ones; hovering a grey state for 3 s shows the same explanation - whole entry is the hover/click region; tips anchor to the entry and disappear when the mouse leaves - always visible: no View-menu toggle, not hidden by portrait or compact modes Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 50 +- src/aare/gui/panels/beamline_state_panel.py | 824 +++++++------------- src/aare/gui/styles.py | 6 +- 3 files changed, 282 insertions(+), 598 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index f8405460..6f955dd4 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -26,6 +26,7 @@ from PySide6.QtWidgets import ( QSizePolicy, QStackedWidget, QTabWidget, + QToolBar, QVBoxLayout, QWidget, ) @@ -142,8 +143,6 @@ class MainWindow(QMainWindow): self.beamline_camera_thread = None self.gonio_camera_thread = None - self._show_beamline_state_panel_for_users = True - self._waiting_for_baton_response: bool = False self._baton_request_dialog: BatonRequestDialog | None = None self._baton_pending_dialog: BatonPendingDialog | None = None @@ -242,17 +241,12 @@ class MainWindow(QMainWindow): self.loop_centering = LoopCenteringPanel(parent=self.left_column) - self.beamline_state_panel = BeamlineStatePanel(parent=self.left_column) - self._beamline_state_panel_enabled = bool( - self._decoded_token.staff or self._show_beamline_state_panel_for_users - ) + # The beamline state strip lives in a bottom toolbar row (created + # after the docks), not in the left column. Always visible. + self.beamline_state_panel = BeamlineStatePanel(parent=self) self.left_column_layout.addWidget(self.data_collection) self.left_column_layout.addWidget(self.loop_centering) - if self._beamline_state_panel_enabled: - self.left_column_layout.addWidget(self.beamline_state_panel) - 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) @@ -264,12 +258,7 @@ class MainWindow(QMainWindow): ) self.collection_controls_scroll.setWidgetResizable(True) self.collection_controls_scroll.setFixedWidth( - max( - self.data_collection.set_width, - self.loop_centering.sizeHint().width(), - self.beamline_state_panel.set_width, - ) - + 10 + max(self.data_collection.set_width, self.loop_centering.sizeHint().width()) + 10 ) self.video_tab = QTabWidget(parent=top_widget) @@ -521,6 +510,20 @@ class MainWindow(QMainWindow): self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.prediction_metrics_dock) self.prediction_metrics_dock.hide() + # Full-width state strip directly above the status bar: the bottom + # toolbar area is guaranteed to sit below the bottom dock area (a + # bottom dock would land beside the existing docks instead). + self.beamline_state_toolbar = QToolBar("Beamline state", self) + self.beamline_state_toolbar.setObjectName("beamline_state_toolbar") + self.beamline_state_toolbar.setMovable(False) + self.beamline_state_toolbar.setFloatable(False) + self.beamline_state_toolbar.setAllowedAreas(Qt.ToolBarArea.BottomToolBarArea) + self.beamline_state_panel.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred + ) + self.beamline_state_toolbar.addWidget(self.beamline_state_panel) + self.addToolBar(Qt.ToolBarArea.BottomToolBarArea, self.beamline_state_toolbar) + # The standard page's combined panel minimum (~1400x1200) exceeds many # monitors, which pushed the status bar off-screen. Scrolling instead of # clamping lets the window shrink to any screen; scrollbars only appear @@ -1416,15 +1419,6 @@ class MainWindow(QMainWindow): view_menu.addAction(self._use_portrait_theme_action) view_menu.addSeparator() - if self._beamline_state_panel_enabled: - self._show_beamline_state_action = QAction("Show Beamline State Panel", self) - self._show_beamline_state_action.setCheckable(True) - self._show_beamline_state_action.setChecked(self.beamline_state_panel.isVisible()) - self._show_beamline_state_action.triggered.connect( - lambda checked: self.beamline_state_panel.setVisible(checked) - ) - view_menu.addAction(self._show_beamline_state_action) - show_samples_action = QAction("Show Sample List", self) show_samples_action.setCheckable(True) show_samples_action.setChecked(True) @@ -1594,12 +1588,6 @@ class MainWindow(QMainWindow): self.tell_samples_dock.setVisible(True) self.job_list_dock.setVisible(True) - if self._beamline_state_panel_enabled: - self.beamline_state_panel.setVisible(True) - self.beamline_state_panel.set_collapsed(False) - if hasattr(self, "_show_beamline_state_action"): - self._show_beamline_state_action.setChecked(True) - self.face_panel_dock.setVisible(False) self.fluor_panel_dock.setVisible(False) self.smargon_trace_dock.setVisible(False) diff --git a/src/aare/gui/panels/beamline_state_panel.py b/src/aare/gui/panels/beamline_state_panel.py index e38b6b93..e5006faa 100644 --- a/src/aare/gui/panels/beamline_state_panel.py +++ b/src/aare/gui/panels/beamline_state_panel.py @@ -1,35 +1,62 @@ -from collections import deque -from dataclasses import dataclass +from typing import ClassVar from aarecommon.models.models import BeamlineStateEnum, DAQStatusModel -from PySide6.QtCore import QEvent, QObject, QPoint, QRect, Qt, Signal, Slot -from PySide6.QtGui import QColor, QPainter, QPen -from PySide6.QtWidgets import QFrame, QLabel, QPushButton +from PySide6.QtCore import Qt, QTimer, Signal, Slot +from PySide6.QtGui import QCursor, QFont, QFontMetrics +from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QMenu, QPushButton, QSizePolicy, QToolTip -from aare.gui.widgets.title_label import PANEL_VMARGIN +# Shortcut transitions from the "Available transitions" menu in +# widgets/status_bar.py show_state_menu — these come ON TOP of the one-hop +# routes between states (_GRAPH), e.g. Manual sample exchange -> Sample +# alignment skipping the robot station, and Maintenance which sits outside +# the route graph. Keep in sync until the server exposes transitions. +_TO_SAMPLE_ALIGNMENT = frozenset({BeamlineStateEnum.SampleAlignment}) +MENU_TRANSITIONS: dict[BeamlineStateEnum, frozenset[BeamlineStateEnum]] = { + BeamlineStateEnum.RobotSampleExchange: _TO_SAMPLE_ALIGNMENT, + BeamlineStateEnum.SampleExchange: _TO_SAMPLE_ALIGNMENT, + BeamlineStateEnum.DewarTransfer: _TO_SAMPLE_ALIGNMENT, + BeamlineStateEnum.BeamLocation: _TO_SAMPLE_ALIGNMENT, + BeamlineStateEnum.DataCollection: _TO_SAMPLE_ALIGNMENT, + BeamlineStateEnum.XrayFluorescence: _TO_SAMPLE_ALIGNMENT, + BeamlineStateEnum.XtalSnapshot: _TO_SAMPLE_ALIGNMENT, + BeamlineStateEnum.SampleAlignment: frozenset( + { + BeamlineStateEnum.SampleExchange, + BeamlineStateEnum.DewarTransfer, + BeamlineStateEnum.BeamLocation, + } + ), + BeamlineStateEnum.Maintenance: frozenset({BeamlineStateEnum.SampleExchange}), +} + +# One-hop routes between states (the former map's segments). +_SEGMENTS = [ + (BeamlineStateEnum.DewarTransfer, BeamlineStateEnum.SampleExchange), + (BeamlineStateEnum.SampleExchange, BeamlineStateEnum.RobotSampleExchange), + (BeamlineStateEnum.RobotSampleExchange, BeamlineStateEnum.SampleAlignment), + (BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.BeamLocation), + (BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.BeamstopAlignment), + (BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.FluxMeasurement), + (BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.DataCollection), + (BeamlineStateEnum.BeamLocation, BeamlineStateEnum.BeamstopAlignment), + (BeamlineStateEnum.BeamLocation, BeamlineStateEnum.FluxMeasurement), + (BeamlineStateEnum.BeamstopAlignment, BeamlineStateEnum.FluxMeasurement), + (BeamlineStateEnum.DataCollection, BeamlineStateEnum.XtalSnapshot), + (BeamlineStateEnum.DataCollection, BeamlineStateEnum.XrayFluorescence), +] -@dataclass(frozen=True) -class StationSpec: - state: BeamlineStateEnum - label: str - x: int - y: int - clickable: bool = False - tooltip: str = "" +def _build_graph( + segments: list[tuple[BeamlineStateEnum, BeamlineStateEnum]], +) -> dict[BeamlineStateEnum, set[BeamlineStateEnum]]: + graph: dict[BeamlineStateEnum, set[BeamlineStateEnum]] = {} + for a, b in segments: + graph.setdefault(a, set()).add(b) + graph.setdefault(b, set()).add(a) + return graph -class HoverableLabel(QLabel): - hovered = Signal(object) - unhovered = Signal() - - def enterEvent(self, event) -> None: - self.hovered.emit(getattr(self, "_beamline_state", None)) - super().enterEvent(event) - - def leaveEvent(self, event) -> None: - self.unhovered.emit() - super().leaveEvent(event) +_GRAPH = _build_graph(_SEGMENTS) class HoverableButton(QPushButton): @@ -46,6 +73,13 @@ class HoverableButton(QPushButton): class BeamlineStatePanel(QFrame): + """Slim horizontal state strip shown directly above the status bar. + + Replaces the former vertical station map: two-line entries in beamline + order, colored by availability (blue/red = active, orange = reachable in + one transition, grey = not reachable). + """ + sample_exchange = Signal() sample_alignment = Signal() dewar_exchange = Signal() @@ -57,346 +91,183 @@ class BeamlineStatePanel(QFrame): beamstop_alignment = Signal() flux_measurement = Signal() - set_width = 400 - map_height = 542 - # 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 + # Physical beamline order. Labels are one line when the bar is wide + # enough and wrap at the last space to two lines when it is not. + _ENTRIES: tuple[tuple[BeamlineStateEnum, str], ...] = ( + (BeamlineStateEnum.Maintenance, "Maintenance mode"), + (BeamlineStateEnum.BeamLocation, "Beam location"), + (BeamlineStateEnum.BeamstopAlignment, "Beamstop alignment"), + (BeamlineStateEnum.FluxMeasurement, "Flux measurement"), + (BeamlineStateEnum.SampleAlignment, "Sample alignment"), + (BeamlineStateEnum.SampleExchange, "Manual sample exchange"), + (BeamlineStateEnum.RobotSampleExchange, "Robot sample exchange"), + (BeamlineStateEnum.DewarTransfer, "Dewar transfer"), + (BeamlineStateEnum.DataCollection, "Data collection"), + (BeamlineStateEnum.XtalSnapshot, "Crystal snapshot"), + (BeamlineStateEnum.XrayFluorescence, "X-ray fluorescence"), + ) + + _TOOLTIPS: ClassVar[dict[BeamlineStateEnum, str]] = { + BeamlineStateEnum.DewarTransfer: "Dewar transfer mode", + BeamlineStateEnum.SampleExchange: "Manual sample exchange mode", + BeamlineStateEnum.RobotSampleExchange: "Robot-assisted sample exchange", + BeamlineStateEnum.SampleAlignment: "Sample centring and alignment mode", + BeamlineStateEnum.BeamLocation: "Beam location mode", + BeamlineStateEnum.BeamstopAlignment: "Beamstop alignment mode", + BeamlineStateEnum.FluxMeasurement: "Flux measurement mode", + BeamlineStateEnum.DataCollection: "Measurement / collection mode", + BeamlineStateEnum.XtalSnapshot: "Crystal snapshot mode", + BeamlineStateEnum.XrayFluorescence: "X-ray fluorescence mode", + } def __init__(self, parent=None): super().__init__(parent) self.setObjectName("beamlineStatePanel") - self.setFrameShape(QFrame.Shape.StyledPanel) - self.setFrameShadow(QFrame.Shadow.Raised) - self.setFixedWidth(self.set_width) - self._is_collapsed = False - self.setMinimumHeight(self.map_height) - self.setMaximumHeight(self.map_height) self._current_state: BeamlineStateEnum | None = None self._hovered_state: BeamlineStateEnum | None = None self._pending_target_state: BeamlineStateEnum | None = None - self._last_stable_state: BeamlineStateEnum | None = None - self._line_color = QColor(111, 129, 160) - self._line_current = QColor(0, 126, 229) - self._line_hover = QColor(244, 196, 48) + # After 3 s of hovering an unavailable state, explain which states + # it can be reached from. + self._hover_hint_timer = QTimer(self) + self._hover_hint_timer.setSingleShot(True) + self._hover_hint_timer.setInterval(3000) + self._hover_hint_timer.timeout.connect(self._show_hover_hint) - self._station_current = QColor(0, 126, 229) - self._station_current_ring = QColor(120, 195, 255) - self._station_hover = QColor(244, 196, 48) - self._label_current_bg = "rgba(0, 126, 229, 0.12)" - self._label_hover_bg = "rgba(244, 196, 48, 0.22)" + self._available_color = "rgb(237, 137, 54)" + self._unavailable_color = "rgb(140, 150, 165)" - self._group_colors: dict[BeamlineStateEnum, QColor] = { - BeamlineStateEnum.DewarTransfer: QColor(128, 90, 213), - BeamlineStateEnum.SampleExchange: QColor(237, 137, 54), - BeamlineStateEnum.RobotSampleExchange: QColor(237, 137, 54), - BeamlineStateEnum.SampleAlignment: QColor(72, 187, 120), - BeamlineStateEnum.BeamLocation: QColor(72, 187, 120), - BeamlineStateEnum.BeamstopAlignment: QColor(72, 187, 120), - BeamlineStateEnum.FluxMeasurement: QColor(72, 187, 120), - BeamlineStateEnum.DataCollection: QColor(236, 72, 153), - BeamlineStateEnum.XtalSnapshot: QColor(236, 72, 153), - BeamlineStateEnum.XrayFluorescence: QColor(236, 72, 153), - } + layout = QHBoxLayout(self) + layout.setContentsMargins(10, 2, 10, 2) + layout.setSpacing(2) + layout.addStretch(1) - self._group_label_colors: dict[BeamlineStateEnum, str] = { - state: color.name() for state, color in self._group_colors.items() - } - - self._group_label_backgrounds: dict[BeamlineStateEnum, str] = { - BeamlineStateEnum.DewarTransfer: "rgba(128, 90, 213, 0.14)", - BeamlineStateEnum.SampleExchange: "rgba(237, 137, 54, 0.16)", - BeamlineStateEnum.RobotSampleExchange: "rgba(237, 137, 54, 0.16)", - BeamlineStateEnum.SampleAlignment: "rgba(72, 187, 120, 0.16)", - BeamlineStateEnum.BeamLocation: "rgba(72, 187, 120, 0.16)", - BeamlineStateEnum.BeamstopAlignment: "rgba(72, 187, 120, 0.16)", - BeamlineStateEnum.FluxMeasurement: "rgba(72, 187, 120, 0.16)", - BeamlineStateEnum.DataCollection: "rgba(236, 72, 153, 0.14)", - BeamlineStateEnum.XtalSnapshot: "rgba(236, 72, 153, 0.14)", - BeamlineStateEnum.XrayFluorescence: "rgba(236, 72, 153, 0.14)", - } - - self._stations = [ - StationSpec( - BeamlineStateEnum.DewarTransfer, - "Dewar transfer", - 54, - 140, - True, - "Dewar transfer mode", - ), - StationSpec( - BeamlineStateEnum.SampleExchange, - "Manual sample exchange", - 54, - 176, - True, - "Manual sample exchange mode", - ), - StationSpec( - BeamlineStateEnum.RobotSampleExchange, - "Robot sample exchange", - 54, - 212, - True, - "Robot-assisted sample exchange", - ), - StationSpec( - BeamlineStateEnum.SampleAlignment, - "Sample alignment", - 54, - 248, - True, - "Sample centring and alignment mode", - ), - StationSpec( - BeamlineStateEnum.BeamLocation, "Beam location", 54, 284, True, "Beam location mode" - ), - StationSpec( - BeamlineStateEnum.BeamstopAlignment, - "Beamstop alignment", - 54, - 320, - True, - "Beamstop alignment mode", - ), - StationSpec( - BeamlineStateEnum.FluxMeasurement, - "Flux measurement", - 54, - 356, - True, - "Flux measurement mode", - ), - StationSpec( - BeamlineStateEnum.DataCollection, - "Data collection", - 54, - 392, - True, - "Measurement / collection mode", - ), - StationSpec( - BeamlineStateEnum.XtalSnapshot, - "Crystal snapshot", - 54, - 428, - True, - "Crystal snapshot mode", - ), - StationSpec( - BeamlineStateEnum.XrayFluorescence, "XRF", 54, 464, True, "X-ray fluorescence mode" - ), - ] - - self._segments = [ - (BeamlineStateEnum.DewarTransfer, BeamlineStateEnum.SampleExchange), - (BeamlineStateEnum.SampleExchange, BeamlineStateEnum.RobotSampleExchange), - (BeamlineStateEnum.RobotSampleExchange, BeamlineStateEnum.SampleAlignment), - (BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.BeamLocation), - (BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.BeamstopAlignment), - (BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.FluxMeasurement), - (BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.DataCollection), - (BeamlineStateEnum.BeamLocation, BeamlineStateEnum.BeamstopAlignment), - (BeamlineStateEnum.BeamLocation, BeamlineStateEnum.FluxMeasurement), - (BeamlineStateEnum.BeamstopAlignment, BeamlineStateEnum.FluxMeasurement), - (BeamlineStateEnum.DataCollection, BeamlineStateEnum.XtalSnapshot), - (BeamlineStateEnum.DataCollection, BeamlineStateEnum.XrayFluorescence), - ] - - self._graph = self._build_graph(self._segments) - self._station_widgets: dict[BeamlineStateEnum, QLabel | QPushButton] = {} - - self.title = QLabel(self) - self.title.setObjectName("beamlineStateTitle") - # 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, PANEL_VMARGIN, self.set_width, self.title_height) - # Whole banner toggles via eventFilter, like TitleLabel; the +/- glyph - # is only the indicator. - self.title.setCursor(Qt.CursorShape.PointingHandCursor) - self.title.installEventFilter(self) - - self.toggle_button = QPushButton("−", self) - self.toggle_button.setObjectName("beamlineStateToggleButton") - self.toggle_button.setToolTip("Minimise beamline state panel") - 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) - self.current_label.setObjectName("beamlineStateCurrentLabel") - self.current_label.move(14, 58) - self.current_label.adjustSize() - - self.tell_label = QLabel("Tell: —", self) - self.tell_label.setObjectName("beamlineStateTellLabel") - self.tell_label.move(14, 86) - self.tell_label.adjustSize() - - self._build_station_widgets() - self._position_station_widgets() - self._update_collapsed_state() - - @staticmethod - def _canon_segment( - a: BeamlineStateEnum, b: BeamlineStateEnum - ) -> tuple[BeamlineStateEnum, BeamlineStateEnum]: - return tuple(sorted((a, b), key=lambda state: state.value)) - - def _build_graph( - self, segments: list[tuple[BeamlineStateEnum, BeamlineStateEnum]] - ) -> dict[BeamlineStateEnum, set[BeamlineStateEnum]]: - graph: dict[BeamlineStateEnum, set[BeamlineStateEnum]] = {} - for a, b in segments: - graph.setdefault(a, set()).add(b) - graph.setdefault(b, set()).add(a) - return graph - - def _station_map(self) -> dict[BeamlineStateEnum, StationSpec]: - return {station.state: station for station in self._stations} - - def _path_segments_between( - self, start: BeamlineStateEnum | None, end: BeamlineStateEnum | None - ) -> set[tuple[BeamlineStateEnum, BeamlineStateEnum]]: - if start is None or end is None: - return set() - - if start == BeamlineStateEnum.Moving or end == BeamlineStateEnum.Moving: - return set() - - if start == end: - return set() - - queue = deque([start]) - previous: dict[BeamlineStateEnum, BeamlineStateEnum | None] = {start: None} - - while queue: - node = queue.popleft() - if node == end: - break - - for neighbour in self._graph.get(node, set()): - if neighbour in previous: - continue - previous[neighbour] = node - queue.append(neighbour) - - if end not in previous: - return set() - - path_segments: set[tuple[BeamlineStateEnum, BeamlineStateEnum]] = set() - cursor = end - while previous[cursor] is not None: - parent = previous[cursor] - path_segments.add(self._canon_segment(cursor, parent)) - cursor = parent - - return path_segments - - def _active_hover_route(self) -> set[tuple[BeamlineStateEnum, BeamlineStateEnum]]: - if self._hovered_state is not None: - route_source = ( - self._last_stable_state - if self._current_state == BeamlineStateEnum.Moving - else self._current_state - ) - return self._path_segments_between(route_source, self._hovered_state) - - if ( - self._current_state == BeamlineStateEnum.Moving - and self._pending_target_state is not None - ): - return self._path_segments_between(self._last_stable_state, self._pending_target_state) - - return set() - - def _build_station_widgets(self) -> None: - for station in self._stations: - if station.clickable: - widget: QLabel | QPushButton = HoverableButton(station.label, self) - widget.setFlat(True) - widget.setCursor(Qt.CursorShape.PointingHandCursor) - widget.clicked.connect( - lambda _checked=False, state=station.state: self._emit_for_state(state) + self._buttons: dict[BeamlineStateEnum, HoverableButton] = {} + self._single_line = True + for index, (state, label) in enumerate(self._ENTRIES): + if index: + separator = QLabel("–", self) + separator.setStyleSheet( + "color: rgb(140, 150, 165); background: transparent; border: none; font-size: 18px;" ) - else: - widget = HoverableLabel(station.label, self) + layout.addWidget(separator) + button = HoverableButton(label, self) + button.setFlat(True) + # Fill the bar height so the hover region is the whole entry, + # not just the text line. + button.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding) + button._beamline_state = state + # Transitions only via right-click -> "Go to "; a plain + # left click must not move the beamline. + button.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + button.customContextMenuRequested.connect( + lambda pos, s=state, b=button: self._show_state_menu(s, b, pos) + ) + button.clicked.connect(lambda _checked=False, s=state: self._on_left_click(s)) + button.hovered.connect(self._set_hovered_state) + button.unhovered.connect(self._clear_hovered_state) + self._buttons[state] = button + layout.addWidget(button) - widget._beamline_state = station.state - widget.hovered.connect(self._set_hovered_state) - widget.unhovered.connect(self._clear_hovered_state) - widget.setToolTip(station.tooltip or station.label) - self._station_widgets[station.state] = widget + layout.addStretch(1) + self._apply_highlight() - self._apply_station_highlight() + def resizeEvent(self, event) -> None: + super().resizeEvent(event) + self._update_label_mode() - def _position_station_widgets(self) -> None: - station_map = self._station_map() - - for state, widget in self._station_widgets.items(): - station = station_map[state] - widget.setText(station.label) - widget.adjustSize() - - label_x = station.x + 20 - label_y = station.y - 12 - - widget.move(label_x, label_y) - widget.show() - - self._apply_station_highlight() - - def eventFilter(self, watched: QObject, event: QEvent) -> bool: - if watched is self.title and event.type() == QEvent.Type.MouseButtonPress: - self.toggle_collapsed() - return True - return super().eventFilter(watched, event) - - def toggle_collapsed(self) -> None: - self._is_collapsed = not self._is_collapsed - self._update_collapsed_state() - - def set_collapsed(self, collapsed: bool) -> None: - if self._is_collapsed == collapsed: + def _update_label_mode(self) -> None: + # One line while it fits, wrapped at the last space otherwise. + # Measured with bold so the mode does not flap when the active + # state (the only bold entry) changes. + font = QFont(self.font()) + font.setPixelSize(18) + font.setWeight(QFont.Weight.Bold) + fm = QFontMetrics(font) + needed = 20 # layout margins + for index, (_state, label) in enumerate(self._ENTRIES): + if index: + needed += fm.horizontalAdvance("–") + 4 + needed += fm.horizontalAdvance(label) + 20 # padding + frame + single_line = needed <= self.width() + if single_line == self._single_line: return - self._is_collapsed = collapsed - self._update_collapsed_state() + self._single_line = single_line + for state, label in self._ENTRIES: + text = label if single_line else "\n".join(label.rsplit(" ", 1)) + self._buttons[state].setText(text) - def _update_collapsed_state(self) -> None: - show_content = not self._is_collapsed + def _show_state_menu(self, state: BeamlineStateEnum, button: QPushButton, pos) -> None: + if state not in self._available_targets(): + return + menu = QMenu(button) + go_action = menu.addAction(f"Go to {state.display_name()}") + go_action.triggered.connect(lambda: self._emit_for_state(state)) + menu.exec(button.mapToGlobal(pos)) - self.current_label.setVisible(show_content) - self.tell_label.setVisible(show_content) - - for widget in self._station_widgets.values(): - widget.setVisible(show_content) - - if self._is_collapsed: - self.setMinimumHeight(self.collapsed_height) - self.setMaximumHeight(self.collapsed_height) - self.toggle_button.setText("+") - self.toggle_button.setToolTip("Restore beamline state panel") + def _on_left_click(self, state: BeamlineStateEnum) -> None: + # Left click never moves the beamline: remind about right-click for + # available states, explain unreachability for the rest. + if state == self._current_state: + return + if state in self._available_targets(): + button = self._buttons[state] + QToolTip.showText( + QCursor.pos(), + f"Right-click to go to {state.display_name()}.", + button, + button.rect(), + ) else: - self.setMinimumHeight(self.map_height) - self.setMaximumHeight(self.map_height) - self.toggle_button.setText("−") - self.toggle_button.setToolTip("Minimise beamline state panel") + self._show_unavailable_hint(state) - self.updateGeometry() - self.update() + def _available_targets(self) -> frozenset[BeamlineStateEnum]: + current = self._current_state + if current is None or current == BeamlineStateEnum.Moving: + return frozenset() + # Reachable in one step: the route graph plus the status-bar + # shortcut transitions. + return frozenset(_GRAPH.get(current, set())) | MENU_TRANSITIONS.get(current, frozenset()) + + def _set_hovered_state(self, state: BeamlineStateEnum | None) -> None: + self._hovered_state = state + self._hover_hint_timer.start() + + @Slot() + def _clear_hovered_state(self) -> None: + self._hovered_state = None + self._hover_hint_timer.stop() + QToolTip.hideText() + + def _show_hover_hint(self) -> None: + state = self._hovered_state + if state is None or state == self._current_state or state in self._available_targets(): + return + self._show_unavailable_hint(state) + + def _show_unavailable_hint(self, state: BeamlineStateEnum) -> None: + sources = set(_GRAPH.get(state, set())) + sources |= {s for s, targets in MENU_TRANSITIONS.items() if state in targets} + sources.discard(state) + if not sources: + return + reachable = ", ".join(sorted(s.display_name() for s in sources)) + button = self._buttons[state] + # Anchoring to the button rect makes Qt drop the tip as soon as the + # mouse leaves the entry, instead of letting it linger. + QToolTip.showText( + QCursor.pos(), + f"You can only go to {state.display_name()} by being in: {reachable}.", + button, + button.rect(), + ) def _emit_for_state(self, state: BeamlineStateEnum) -> None: + # Unavailable transitions are not clickable (grey + forbidden cursor). + if state not in self._available_targets(): + return self._pending_target_state = state - self._hovered_state = None - self.update() if state == BeamlineStateEnum.SampleExchange: self.sample_exchange.emit() @@ -419,236 +290,63 @@ class BeamlineStatePanel(QFrame): elif state == BeamlineStateEnum.XrayFluorescence: self.xray_fluorescence.emit() - @Slot(object) - def _set_hovered_state(self, state: BeamlineStateEnum | None) -> None: - self._hovered_state = state - self._apply_station_highlight() - - @Slot() - def _clear_hovered_state(self) -> None: - self._hovered_state = None - self._apply_station_highlight() - - def _apply_station_highlight(self) -> None: - for station in self._stations: - widget = self._station_widgets[station.state] - is_current = station.state == self._current_state - is_hovered = station.state == self._hovered_state + def _apply_highlight(self) -> None: + available = self._available_targets() + for state, button in self._buttons.items(): + is_current = state == self._current_state is_pending = ( - station.state == self._pending_target_state + state == self._pending_target_state and self._current_state == BeamlineStateEnum.Moving ) + is_available = state in available - label_color = self._group_label_colors.get(station.state, "rgb(55, 67, 87)") - label_bg = self._group_label_backgrounds.get(station.state, "transparent") - - if isinstance(widget, QPushButton): - if is_current: - widget.setStyleSheet(f""" - QPushButton {{ - border: none; - border-radius: 10px; - background: {self._label_current_bg}; - color: rgb(0, 92, 170); - font-size: 14px; - font-weight: 700; - text-align: left; - padding: 2px 6px 2px 8px; - }} - QPushButton:hover {{ - color: rgb(0, 92, 170); - }} - """) - elif is_hovered or is_pending: - widget.setStyleSheet(f""" - QPushButton {{ - border: none; - border-radius: 10px; - background: {self._label_hover_bg}; - color: rgb(115, 88, 0); - font-size: 14px; - font-weight: 700; - text-align: left; - padding: 2px 6px 2px 8px; - }} - QPushButton:hover {{ - color: rgb(115, 88, 0); - }} - """) - else: - widget.setStyleSheet(f""" - QPushButton {{ - border: none; - border-radius: 10px; - background: {label_bg}; - color: {label_color}; - font-size: 14px; - font-weight: 600; - text-align: left; - padding: 2px 6px 2px 8px; - }} - QPushButton:hover {{ - color: {label_color}; - }} - """) - else: - if is_current: - widget.setStyleSheet(f""" - QLabel {{ - border-radius: 10px; - background: {self._label_current_bg}; - color: rgb(0, 92, 170); - font-size: 14px; - font-weight: 700; - padding: 2px 6px 2px 8px; - }} - """) - elif is_hovered or is_pending: - widget.setStyleSheet(f""" - QLabel {{ - border-radius: 10px; - background: {self._label_hover_bg}; - color: rgb(115, 88, 0); - font-size: 14px; - font-weight: 700; - padding: 2px 6px 2px 8px; - }} - """) - else: - widget.setStyleSheet(f""" - QLabel {{ - border-radius: 10px; - background: {label_bg}; - color: {label_color}; - font-size: 14px; - font-weight: 600; - padding: 2px 6px 2px 8px; - }} - """) - - widget.adjustSize() - - self.update() - - def _station_center(self, state: BeamlineStateEnum) -> QPoint: - station = self._station_map()[state] - return QPoint(station.x, station.y) - - def _segment_color(self, a: BeamlineStateEnum, b: BeamlineStateEnum) -> QColor: - segment = self._canon_segment(a, b) - - active_hover_route = self._active_hover_route() - if segment in active_hover_route: - return self._line_hover - - current_path = self._path_segments_between( - BeamlineStateEnum.DewarTransfer, self._last_stable_state or self._current_state - ) - if segment in current_path: - return self._line_current - - return self._line_color - - def _draw_segment(self, painter: QPainter, start: QPoint, end: QPoint, color: QColor) -> None: - pen = QPen(color, 3) - pen.setCapStyle(Qt.PenCapStyle.RoundCap) - painter.setPen(pen) - painter.drawLine(start, end) - - def _station_base_color(self, state: BeamlineStateEnum) -> QColor: - return self._group_colors.get(state, QColor(180, 190, 210)) - - def _draw_station(self, painter: QPainter, station: StationSpec) -> None: - center = self._station_center(station.state) - rect = QRect( - center.x() - self.station_radius, - center.y() - self.station_radius, - self.station_radius * 2, - self.station_radius * 2, - ) - - if station.state == self._hovered_state or ( - self._current_state == BeamlineStateEnum.Moving - and station.state == self._pending_target_state - ): - painter.setPen(QPen(self._station_hover, 3)) - painter.setBrush(self._station_hover) - elif station.state == self._current_state: - painter.setPen(QPen(self._station_current_ring, 3)) - painter.setBrush(Qt.BrushStyle.NoBrush) - painter.drawEllipse( - QRect( - center.x() - self.station_radius - 3, - center.y() - self.station_radius - 3, - (self.station_radius + 3) * 2, - (self.station_radius + 3) * 2, + # Availability drives the look: active = bold (red for + # Maintenance, blue otherwise), reachable = orange, rest = grey. + # No backgrounds, no rounded corners. + if is_current or is_pending: + color = ( + "rgb(200, 30, 30)" + if state == BeamlineStateEnum.Maintenance + else "rgb(0, 92, 170)" ) - ) - painter.setPen(QPen(self._station_current, 2)) - painter.setBrush(self._station_current) - else: - base_color = self._station_base_color(station.state) - painter.setPen(QPen(base_color.darker(125), 2)) - painter.setBrush(base_color) - - painter.drawEllipse(rect) - - def paintEvent(self, event) -> None: - super().paintEvent(event) - - if self._is_collapsed: - return - - painter = QPainter(self) - painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) - - for start_state, end_state in self._segments: - start = self._station_center(start_state) - end = self._station_center(end_state) - color = self._segment_color(start_state, end_state) - self._draw_segment(painter, start, end, color) - - for station in self._stations: - self._draw_station(painter, station) - - @Slot(DAQStatusModel) - def update_daq_status(self, status: DAQStatusModel) -> None: - self.set_current_state(status.state) - - tell_text = "Tell: —" - tell_color = "rgb(55, 67, 87)" - if status.tell_state is not None: - tell_state = status.tell_state - tell_text = f"Tell: {tell_state.activity.display_name()}" - - tell_phase = tell_state.phase.display_name() if tell_state.phase is not None else "" - tell_message = (tell_state.message or "").strip() - - if tell_phase: - tell_text = f"{tell_text} ({tell_phase})" - elif tell_message: - tell_text = f"{tell_text} ({tell_message})" - - if tell_state.activity.value == "error": - tell_color = "red" - elif tell_state.activity.value in {"mounting", "unmounting", "drying", "cooling"}: - tell_color = "orange" + bold = True + elif is_available: + color = self._available_color + bold = False else: - tell_color = "green" + color = self._unavailable_color + bold = False - self.tell_label.setText(tell_text) - self.tell_label.setStyleSheet(f"color: {tell_color};") - self.tell_label.adjustSize() + # Font set in code (not QSS) so _update_label_mode can measure + # the real metrics when deciding one- vs two-line labels. + font = QFont(self.font()) + font.setPixelSize(18) + font.setWeight(QFont.Weight.Bold if bold else QFont.Weight.Normal) + button.setFont(font) + button.setStyleSheet( + f"QPushButton {{ border: none; background: transparent; color: {color};" + f" padding: 1px 8px; }}" + f" QPushButton:hover {{ color: {color}; }}" + ) + + # Clickability follows availability; unavailable states get the + # forbidden cursor and only the deferred 3 s explanation tooltip. + if is_available: + button.setCursor(Qt.CursorShape.PointingHandCursor) + button.setToolTip(self._TOOLTIPS.get(state, state.display_name())) + else: + button.setCursor(Qt.CursorShape.ForbiddenCursor) + button.setToolTip("") def set_current_state(self, state: BeamlineStateEnum | None) -> None: self._current_state = state + if ( + state is not None + and state != BeamlineStateEnum.Moving + and self._pending_target_state == state + ): + self._pending_target_state = None + self._apply_highlight() - if state is not None and state != BeamlineStateEnum.Moving: - self._last_stable_state = state - if self._pending_target_state == state: - self._pending_target_state = None - - label = state.display_name() if state is not None else "—" - self.current_label.setText(f"Current: {label}") - self.current_label.adjustSize() - self._apply_station_highlight() + def update_daq_status(self, status: DAQStatusModel) -> None: + self.set_current_state(status.state) diff --git a/src/aare/gui/styles.py b/src/aare/gui/styles.py index 527911ae..4628e432 100644 --- a/src/aare/gui/styles.py +++ b/src/aare/gui/styles.py @@ -179,8 +179,7 @@ def _original_stylesheet() -> str: QFrame#beamlineStatePanel { background: rgb(216, 228, 253); - border: 1px solid rgb(185, 204, 238); - border-radius: 12px; + border-top: 1px solid rgb(185, 204, 238); } QLabel#beamlineStateTitle { @@ -421,8 +420,7 @@ def _portrait_stylesheet() -> str: QFrame#beamlineStatePanel { background: #0E1A26; - border: 1px solid #1A3A36; - border-radius: 12px; + border-top: 1px solid #1A3A36; } QLabel#beamlineStateTitle { -- 2.54.0 From 7a57d85282888bf967e6c6e6b756a610dfa4ebf0 Mon Sep 17 00:00:00 2001 From: Dawn Date: Thu, 6 Aug 2026 18:32:07 +0200 Subject: [PATCH 08/57] fix: scroll Local Contact tabs so rows keep text height The dialog's explicit minimum size is smaller than some tabs' content; without a scroll area Qt squeezes rows below text height (clipped labels on the Hardware tab). Wrap every tab in a QScrollArea. Co-Authored-By: Claude Fable 5 --- src/aare/gui/panels/local_contact_panel.py | 26 ++++++++++++++++------ 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/aare/gui/panels/local_contact_panel.py b/src/aare/gui/panels/local_contact_panel.py index 409e76f0..83a79717 100644 --- a/src/aare/gui/panels/local_contact_panel.py +++ b/src/aare/gui/panels/local_contact_panel.py @@ -20,6 +20,7 @@ from PySide6.QtWidgets import ( QLabel, QMessageBox, QPushButton, + QScrollArea, QTabWidget, QTextEdit, QVBoxLayout, @@ -128,13 +129,16 @@ class LocalContactPanel(QFrame): self._tabs = QTabWidget(self) layout.addWidget(self._tabs, 1) - self._tabs.addTab(self._build_status_tab(), self.TAB_STATUS) - self._tabs.addTab(self._build_recovery_tab(), self.TAB_RECOVERY) - self._tabs.addTab(self._build_tell_tab(), self.TAB_TELL) - self._tabs.addTab(self._build_bec_tab(), self.TAB_BEC) - self._tabs.addTab(self._build_hardware_tab(), self.TAB_HARDWARE) - self._tabs.addTab(self._build_detector_tab(), self.TAB_DETECTOR) - self._tabs.addTab(self._build_config_tab(), self.TAB_CONFIG) + # Every tab scrolls: the dialog's explicit minimum size is smaller + # than some tabs' content, and without a scroll area Qt squeezes the + # rows below text height (clipped labels on the Hardware tab). + self._tabs.addTab(self._scrolled(self._build_status_tab()), self.TAB_STATUS) + self._tabs.addTab(self._scrolled(self._build_recovery_tab()), self.TAB_RECOVERY) + self._tabs.addTab(self._scrolled(self._build_tell_tab()), self.TAB_TELL) + self._tabs.addTab(self._scrolled(self._build_bec_tab()), self.TAB_BEC) + self._tabs.addTab(self._scrolled(self._build_hardware_tab()), self.TAB_HARDWARE) + self._tabs.addTab(self._scrolled(self._build_detector_tab()), self.TAB_DETECTOR) + self._tabs.addTab(self._scrolled(self._build_config_tab()), self.TAB_CONFIG) self._daq.local_contact_simulation_state_loaded.connect(self._apply_simulation_state) self._daq.local_contact_device_state_loaded.connect(self._apply_device_state) @@ -174,6 +178,14 @@ class LocalContactPanel(QFrame): ) return self._register_status_widget(widget) + @staticmethod + def _scrolled(widget: QWidget) -> QScrollArea: + area = QScrollArea() + area.setWidgetResizable(True) + area.setFrameShape(QFrame.Shape.NoFrame) + area.setWidget(widget) + return area + def _build_status_tab(self) -> QWidget: tab = QWidget(self) layout = QVBoxLayout(tab) -- 2.54.0 From 3175e437a4ebf4c749d4f6c841d67f35a506fdeb Mon Sep 17 00:00:00 2001 From: Dawn Date: Thu, 6 Aug 2026 18:39:27 +0200 Subject: [PATCH 09/57] test: cover the beamline state bar Availability sets (routes + menu shortcuts, Moving/unknown), signal gating, palette and cursors per availability, red/blue active states, adaptive label wrapping, left-click hint paths, hover-hint timer lifecycle, and pending-target clearing. Lifts the PR diff coverage back over the 80 percent gate (measured 88 percent locally). Co-Authored-By: Claude Fable 5 --- tests/unit/gui/test_beamline_state_panel.py | 122 ++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 tests/unit/gui/test_beamline_state_panel.py diff --git a/tests/unit/gui/test_beamline_state_panel.py b/tests/unit/gui/test_beamline_state_panel.py new file mode 100644 index 00000000..1c42d411 --- /dev/null +++ b/tests/unit/gui/test_beamline_state_panel.py @@ -0,0 +1,122 @@ +from aarecommon.models.models import BeamlineStateEnum +from PySide6.QtCore import Qt + +from aare.gui.panels.beamline_state_panel import BeamlineStatePanel + + +def _panel(qtbot): + panel = BeamlineStatePanel() + qtbot.addWidget(panel) + return panel + + +def test_availability_from_maintenance(qtbot): + panel = _panel(qtbot) + panel.set_current_state(BeamlineStateEnum.Maintenance) + assert panel._available_targets() == frozenset({BeamlineStateEnum.SampleExchange}) + + +def test_availability_is_union_of_routes_and_menu_shortcuts(qtbot): + panel = _panel(qtbot) + panel.set_current_state(BeamlineStateEnum.SampleAlignment) + targets = panel._available_targets() + assert BeamlineStateEnum.FluxMeasurement in targets # one-hop route + assert BeamlineStateEnum.SampleExchange in targets # status-bar shortcut + assert BeamlineStateEnum.XtalSnapshot not in targets # two hops away + + +def test_no_targets_while_moving_or_unknown(qtbot): + panel = _panel(qtbot) + panel.set_current_state(None) + assert panel._available_targets() == frozenset() + panel.set_current_state(BeamlineStateEnum.Moving) + assert panel._available_targets() == frozenset() + + +def test_emit_gated_by_availability(qtbot): + panel = _panel(qtbot) + panel.set_current_state(BeamlineStateEnum.Maintenance) + with qtbot.waitSignal(panel.sample_exchange, timeout=1000): + panel._emit_for_state(BeamlineStateEnum.SampleExchange) + with qtbot.assertNotEmitted(panel.flux_measurement): + panel._emit_for_state(BeamlineStateEnum.FluxMeasurement) + + +def test_active_maintenance_is_red_and_bold(qtbot): + panel = _panel(qtbot) + panel.set_current_state(BeamlineStateEnum.Maintenance) + maintenance = panel._buttons[BeamlineStateEnum.Maintenance] + assert maintenance.font().bold() + assert "rgb(200, 30, 30)" in maintenance.styleSheet() + + +def test_availability_palette_and_cursors(qtbot): + panel = _panel(qtbot) + panel.set_current_state(BeamlineStateEnum.Maintenance) + available = panel._buttons[BeamlineStateEnum.SampleExchange] + grey = panel._buttons[BeamlineStateEnum.FluxMeasurement] + assert "rgb(237, 137, 54)" in available.styleSheet() + assert available.cursor().shape() == Qt.CursorShape.PointingHandCursor + assert not available.font().bold() + assert "rgb(140, 150, 165)" in grey.styleSheet() + assert grey.cursor().shape() == Qt.CursorShape.ForbiddenCursor + assert grey.toolTip() == "" + assert available.toolTip() != "" + + +def test_active_non_maintenance_is_blue(qtbot): + panel = _panel(qtbot) + panel.set_current_state(BeamlineStateEnum.SampleAlignment) + active = panel._buttons[BeamlineStateEnum.SampleAlignment] + assert active.font().bold() + assert "rgb(0, 92, 170)" in active.styleSheet() + + +def test_labels_wrap_when_narrow_and_unwrap_when_wide(qtbot): + panel = _panel(qtbot) + sample_exchange = panel._buttons[BeamlineStateEnum.SampleExchange] + + panel.resize(600, 60) + panel._update_label_mode() + assert not panel._single_line + assert sample_exchange.text() == "Manual sample\nexchange" + + panel.resize(4000, 60) + panel._update_label_mode() + assert panel._single_line + assert sample_exchange.text() == "Manual sample exchange" + + +def test_left_click_never_transitions_but_hints(qtbot): + panel = _panel(qtbot) + panel.set_current_state(BeamlineStateEnum.Maintenance) + with qtbot.assertNotEmitted(panel.sample_exchange): + panel._on_left_click(BeamlineStateEnum.SampleExchange) # reminder tip + panel._on_left_click(BeamlineStateEnum.FluxMeasurement) # reachability hint + panel._on_left_click(BeamlineStateEnum.Maintenance) # current: no-op + + +def test_hover_hint_timer_lifecycle(qtbot): + panel = _panel(qtbot) + panel.set_current_state(BeamlineStateEnum.Maintenance) + panel._set_hovered_state(BeamlineStateEnum.FluxMeasurement) + assert panel._hover_hint_timer.isActive() + panel._show_hover_hint() # runs the unavailable-hint path + panel._clear_hovered_state() + assert not panel._hover_hint_timer.isActive() + assert panel._hovered_state is None + + +def test_update_daq_status_sets_state(qtbot, daq_status_factory): + panel = _panel(qtbot) + panel.update_daq_status(daq_status_factory(state=BeamlineStateEnum.SampleAlignment)) + assert panel._current_state == BeamlineStateEnum.SampleAlignment + + +def test_pending_target_cleared_on_arrival(qtbot): + panel = _panel(qtbot) + panel.set_current_state(BeamlineStateEnum.Maintenance) + panel._emit_for_state(BeamlineStateEnum.SampleExchange) + assert panel._pending_target_state == BeamlineStateEnum.SampleExchange + panel.set_current_state(BeamlineStateEnum.SampleExchange) + assert panel._pending_target_state is None -- 2.54.0 From e818682abdcdc5e4c1db04c48180af7f4218ee3e Mon Sep 17 00:00:00 2001 From: Dawn Date: Thu, 6 Aug 2026 18:45:37 +0200 Subject: [PATCH 10/57] style: declare HoverableButton state attribute for the pyright gate The dynamic _beamline_state assignment was a pre-existing pattern, but the panel rewrite turned its line into a changed line, so the basedpyright diff gate now counts it. Declare the attribute instead. Co-Authored-By: Claude Fable 5 --- src/aare/gui/panels/beamline_state_panel.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/aare/gui/panels/beamline_state_panel.py b/src/aare/gui/panels/beamline_state_panel.py index e5006faa..2fb895ed 100644 --- a/src/aare/gui/panels/beamline_state_panel.py +++ b/src/aare/gui/panels/beamline_state_panel.py @@ -63,8 +63,11 @@ class HoverableButton(QPushButton): hovered = Signal(object) unhovered = Signal() + # Set by the panel right after construction; carried in hover signals. + _beamline_state: BeamlineStateEnum | None = None + def enterEvent(self, event) -> None: - self.hovered.emit(getattr(self, "_beamline_state", None)) + self.hovered.emit(self._beamline_state) super().enterEvent(event) def leaveEvent(self, event) -> None: -- 2.54.0 From 271ca54d9ad3a2001dddaa5c4f960c8929f13d3c Mon Sep 17 00:00:00 2001 From: Dawn Date: Fri, 7 Aug 2026 10:04:51 +0200 Subject: [PATCH 11/57] style: centralize the GUI color palette in styles.py Every color literal in the GUI moves into one palette module: ~200 grouped constants, a qcolor() helper (hue from the theme, alpha at the call site), and both QSS stylesheets become string.Templates fed by _palette() so a renamed constant fails loudly. Also retints: light background #d8e4fd -> #e2e7ee, banner indigo -> #a2b7e4, and the portrait dark theme adopts Catppuccin Macchiato. Co-Authored-By: Claude Fable 5 --- src/aare/gui/styles.py | 512 +++++++++++++++++++++++++++++++++-------- 1 file changed, 419 insertions(+), 93 deletions(-) diff --git a/src/aare/gui/styles.py b/src/aare/gui/styles.py index 4628e432..83b3adb1 100644 --- a/src/aare/gui/styles.py +++ b/src/aare/gui/styles.py @@ -1,8 +1,312 @@ from __future__ import annotations +from string import Template + THEME_ORIGINAL = "original" THEME_PORTRAIT = "portrait" +# --------------------------------------------------------------------------- +# Color palette. Change values HERE to try a different look — the QSS below +# and the widgets that import these use them everywhere. Every UPPERCASE +# constant is exposed to the QSS templates as $lowercase_name (see _palette), +# so no color literals live inside the stylesheets themselves. +# Any value can be "transparent" (e.g. to hide a border or a fill). +# string.Template ($name) instead of f-strings so the QSS braces stay literal. + +# -- Light theme ------------------------------------------------------------ +BACKGROUND = "#e2e7ee" +BANNER = "#a2b7e4" +BANNER_TEXT = "#263043" # must contrast with BANNER +TEXT = "#263043" +SURFACE = "#f2f2f2" # input fields, cards, group boxes + +# Borders (all can be "transparent" to hide the line): +BORDER = "transparent" # main dividers, e.g. the beamline state bar top line +CARD_BORDER = "transparent" # cards / group boxes (Local Contact, automation) +COMPACT_BORDER = "transparent" # compact-automation cards and buttons + +# Compact-automation page: +COMPACT_CARD_BG = "#e6eefc" +COMPACT_TITLE = "#17324d" # section titles + menu/secondary button text +COMPACT_HINT = "#51657d" # secondary text: hints, queue titles +COMPACT_VALUE = "#10263a" +COMPACT_MENU_BG = "#cbdcf8" +COMPACT_MENU_BG_HOVER = "#bfd4f6" +PRIMARY = "#2563eb" # main action button +PRIMARY_HOVER = "#1d4ed8" +PRIMARY_TEXT = "white" +SECONDARY_BG = "#dfe9fb" +SECONDARY_BG_HOVER = "#d3e1f8" + +# Alert banners (alertKind: error / success / waiting=warning): +ERROR_BG = "#fbe4e6" +ERROR_BORDER = "#d97a84" +ERROR_TEXT = "#8f1d2c" +SUCCESS_BG = "#e7f6ea" +SUCCESS_BORDER = "#7bbf8e" +SUCCESS_TEXT = "#1f6a3a" +WARNING_BG = "#fff8e1" +WARNING_BORDER = "#ffb300" +WARNING_TEXT = "#e65100" + +# Axis video status + beamline state bar: +STATUS_IDLE_BG = "#d9e2f2" +STATUS_LABEL_TEXT = "#2f3b52" +STATE_TOGGLE_TEXT = "white" # collapse glyph sitting on the BANNER strip +STATE_CURRENT_TEXT = "#1e293b" # same slate as HEADING_TEXT, separate knob +STATE_TELL_TEXT = "#374357" # also the status-bar TELL line + +# -- Portrait (dark) theme: Catppuccin Macchiato ---------------------------- +# https://catppuccin.com/palette — token names in comments. DARK_BG was set to +# Macchiato mantle by hand, so the rest follows that flavor. +DARK_BG = "#1e2030" # mantle +DARK_TEXT = "#cad3f5" # text +DARK_SURFACE = "#24273a" # base — cards, state panel, scrollbar track +DARK_ELEVATED = "#363a4f" # surface0 — buttons, title strip, idle status pill +DARK_BORDER = "#494d64" # surface1 — card borders, scrollbar handle, button hover fill +DARK_ACCENT = "#8bd5ca" # teal +DARK_ACCENT_HOVER = "#a2ddd5" # teal +20% white — palette has no lighter teal step +DARK_MUTED = "#a5adcb" # subtext0 — secondary text +# Alert banners: full-strength color for border/text, 25%-over-DARK_BG tint +# for bg (Catppuccin defines no alert backgrounds, so these are blends). +DARK_ERROR_BG = "#523a4a" # red 25% over mantle +DARK_ERROR_BORDER = "#ed8796" # red +DARK_ERROR_TEXT = "#ed8796" # red +DARK_SUCCESS_BG = "#404e49" # green 25% over mantle +DARK_SUCCESS_BORDER = "#a6da95" # green +DARK_SUCCESS_TEXT = "#a6da95" # green +DARK_WARNING_BG = "#524d4c" # yellow 25% over mantle +DARK_WARNING_BORDER = "#eed49f" # yellow +DARK_WARNING_TEXT = "#eed49f" # yellow + +# -- Shared chrome (light-theme widgets) ------------------------------------ +# Extracted from per-widget literals so the whole app is themeable from this +# file. The same hex may appear under two names when the roles differ — +# separate knobs on purpose. +WHITE = "#ffffff" +DEFAULT_TEXT = "#000000" # labels that reset to plain black +NOTE_TEXT = "#555555" # tutorial hints, TELL sample details +DIM_TEXT = "#666666" # baton dialog timers +HINT_TEXT = "#999999" # baton dialog fine print +HEADING_TEXT = "#1e293b" # card headings (slate-800) +SUBTLE_TEXT = "#334155" # card body text (slate-700) +MUTED_TEXT = "#475569" # neutral chip / idle step text (slate-600) +FAINT_TEXT = "#64748b" # pending/skipped step text (slate-500) +SHADOW = "#000000" # drop shadows & tutorial scrim; alpha stays at call site + +# -- Semantic action colors ------------------------------------------------- +GO_TEXT = "#4e9a06" # green start/run/measure button text +ABORT_TEXT = "#a40000" # abort button text +ALERT_TEXT = "#ff0000" # out-of-range motor labels +PATH_WARN_TEXT = "#c80000" # file-exists warning in path panel +DANGER_ACCENT = "#d9534f" # invalid p-group border + message text + +# -- Status chips (local contact status) — "good" reuses SUCCESS_BG/TEXT ---- +CHIP_WARN_BG = "#fff3cd" +CHIP_WARN_TEXT = "#7a4b00" +CHIP_BAD_BG = "#fdeaea" +CHIP_BAD_TEXT = "#8b1e1e" +CHIP_NEUTRAL_BG = "#e9eef5" # text uses MUTED_TEXT +CHIP_INFO_BG = "#e8f1ff" +CHIP_INFO_TEXT = "#12406a" + +# -- Status cards (beamline recovery, local contact error frame) ------------ +WARN_CARD_BORDER = "#f0c36d" +BAD_CARD_BORDER = "#e6a8a8" +INFO_CARD_BG = "#eef6ff" +INFO_CARD_BORDER = "#a8c7e6" +PENDING_CARD_BG = "#fff7db" +PENDING_CARD_BORDER = "#e7cb73" + +# -- Log panel -------------------------------------------------------------- +LOG_BORDER = "#8a8a8a" +LOG_PANEL_BG = "#fff4f4" +LOG_ERROR_BG = "#fff1f1" +LOG_ERROR_BORDER = "#dd6666" +LOG_WARN_BG = "#fff8e8" +LOG_WARN_BORDER = "#d7aa42" +LOG_SUCCESS_BG = "#eefaf0" +LOG_SUCCESS_BORDER = "#6cb37a" +LOG_INFO_BG = "#eef5ff" +LOG_INFO_BORDER = "#6b9bd6" + +# -- Automation panel + progress steps -------------------------------------- +AUTOMATION_TITLE_TEXT = "#1f2937" +AUTOMATION_HINT_TEXT = "#374151" +STEP_RUNNING_TEXT = "#2563eb" # same blue as PRIMARY, separate knob +STEP_SUCCESS_TEXT = "#15803d" +STEP_FAILED_TEXT = "#b91c1c" +STEP_PAUSED_TEXT = "#c2410c" +STEP_DONE_BG = "#ecfdf3" +STEP_DONE_TEXT = "#166534" +STEP_DONE_BORDER = "#a7f3d0" +STEP_ACTIVE_BG = "#eff6ff" +STEP_ACTIVE_TEXT = "#1d4ed8" +STEP_ACTIVE_BORDER = "#bfdbfe" +STEP_FAILED_BG = "#fef2f2" +STEP_FAILED_BORDER = "#fecaca" +STEP_PAUSED_BG = "#fff7ed" +STEP_PAUSED_BORDER = "#fed7aa" +STEP_IDLE_BG = "#f8fafc" +STEP_IDLE_BORDER = "#e2e8f0" + +# -- Baton request dialog --------------------------------------------------- +BATON_OK_BG = "#4caf50" +BATON_OK_HOVER = "#45a049" +BATON_OK_PRESSED = "#3d8b40" +BATON_DANGER_BG = "#f44336" +BATON_DANGER_HOVER = "#da190b" +BATON_DANGER_PRESSED = "#c41000" +BATON_WARN = "#ff9800" +BATON_INFO = "#2196f3" +LIGHT_BORDER = "#cccccc" +PROGRESS_TRACK_BG = "#f0f0f0" + +# -- Splash screen ---------------------------------------------------------- +SPLASH_BG = "#222222" +SPLASH_BORDER = "#444444" +SPLASH_ACCENT = "#0078d7" + +# -- Numeric inputs --------------------------------------------------------- +INPUT_BG = "#ffffff" +INPUT_INVALID_BG = "#ffd5d5" +INPUT_DISABLED_BG = "#f0f0f0" +INPUT_DISABLED_INVALID_BG = "#f0e1e1" + +# -- Status bar flags (hex equivalents of the old CSS named colors) --------- +STATUS_OK = "#008000" # closed / idle / owned / tell ok (was "green") +STATUS_ALERT = "#ff0000" # open / busy / other-owner / hot cryo (was "red") +STATUS_WARN = "#ffa500" # baton waiting / warming cryo / tell busy (was "orange") +STATUS_INFO = "#0000ff" # cold cryo (was "blue") +STATUS_VACANT = "#ffff00" # baton vacant (was "yellow") +STATUS_REQUEST = "#00ffff" # baton request (was "cyan") + +# -- Beamline state panel --------------------------------------------------- +STATE_AVAILABLE = "#ed8936" +STATE_UNAVAILABLE = "#8c96a5" +STATE_MSG_ERROR = "#c81e1e" +STATE_MSG_INFO = "#005caa" + +# -- Sample tables + raster grid -------------------------------------------- +SAMPLE_ROW_ACTIVE_BG = "#ff6600" +SAMPLE_ROW_QUEUED_BG = "#729fcf" +SAMPLE_ROW_HIGHLIGHT_BG = "#d8e4fd" +RASTER_GRID_LINE = "#729fcf" +TABLE_SHADE_BG = "#e0e0e0" + +# -- Camera / video overlay (painter colors, alpha at call site) ------------ +BEAM_OPEN = "#00ff00" # beam marker: shutter open +BEAM_IDLE = "#f57900" # beam marker: idle +BEAM_BUSY = "#ff0000" # beam marker: busy +BEAM_MARKING = "#663399" # beam marker: marking mode +MARKER_GREEN = "#32cd32" # loop-centering click marker +PATH_START = "#008000" # raster path gradient start + start circle +PATH_END = "#ff0000" # raster path gradient end + end circle +LEGEND_BG = "#141414" +LEGEND_TEXT = "#f0f0f0" +TOOLTIP_TEXT = "#e6e6e6" +MARK_TOOLTIP_GOLD = "#ffd700" +MARK_TOOLTIP_ORANGE = "#ffa500" +MARK_TOOLTIP_RED = "#ff0000" +MARK_BADGE_BG = "#b43c00" + +# Prediction class overlay colors. The chart variant historically used pure +# green (#00ff00) while the overlay used CSS green (#008000) — both kept. +CLASS_COLORS = { + "pin": "#ff0000", + "loop_all": "#008000", + "loop_face": "#ffff00", + "crystal": "#0000ff", + "needle": "#ff00ff", + "ice": "#00ffff", +} +CHART_CLASS_COLORS = { + "Pin": "#ff0000", + "Loop_all": "#00ff00", + "Loop_face": "#ffff00", + "Crystal": "#0000ff", + "Needle": "#ff00ff", + "Ice": "#00ffff", +} +TARGET_COLORS = {"Cyan": "#00ffff", "Dark Blue": "#0046a0", "Dark Red": "#8c1919"} +BOOKMARK_COLORS = { + "red": "#ff0000", + "green": "#008000", + "blue": "#0000ff", + "indigo": "#4b0082", + "lime": "#00ff00", +} + +# -- Busy overlay (per-source color coding) --------------------------------- +BUSY_YELLOW = "#f1c40f" +BUSY_YELLOW_BORDER = "#fff8d2" +BUSY_YELLOW_DOT = "#fff6bf" +BUSY_YELLOW_TEXT_DARK = "#3b2f00" +BUSY_PURPLE = "#8e44ad" +BUSY_PURPLE_BORDER = "#ebdcf5" +BUSY_PURPLE_DOT = "#f0dfff" +BUSY_RED_BADGE = "#d64545" +BUSY_RED_FILL = "#be2828" +BUSY_RED_BORDER = "#ffdcdc" +BUSY_RED_DOT = "#ffdddd" +BUSY_ORANGE = "#e67e22" +BUSY_ORANGE_BORDER = "#ffead6" +BUSY_ORANGE_DOT = "#fff0db" +BUSY_BLUE = "#3498db" +BUSY_BLUE_BORDER = "#dcf0ff" +BUSY_BLUE_DOT = "#dff2ff" +BUSY_PSI_RED = "#e04f39" +BUSY_PSI_RED_BORDER = "#ffe1dc" +BUSY_PSI_RED_DOT = "#ffd8d1" + +# -- Charts (prediction metrics, target stability, fluorescence) ------------ +CHART_BLUE = "#1f77b4" +CHART_BLUE_LIGHT = "#6baed6" +CHART_BLUE_PALE = "#9ecae1" +CHART_RED = "#d62728" +CHART_RED_LIGHT = "#ff9896" +CHART_RED_DARK = "#c43c39" +CHART_ORANGE = "#ff7f0e" +CHART_ORANGE_PALE = "#ffbb78" +CHART_GREEN = "#2ca02c" +CHART_GREEN_PALE = "#98df8a" +CHART_CYAN = "#17becf" +CHART_PURPLE = "#9467bd" +CHART_MUTED = "#888888" +CONFIDENCE_BIN_COLORS = [CHART_RED, CHART_ORANGE, CHART_ORANGE_PALE, CHART_GREEN_PALE, CHART_GREEN] +SPECTRUM_LINE = "#cc0000" + +# -- Generic panels (developer help, raster table) -------------------------- +PANEL_BG_SOFT = "#f6f6f6" +PANEL_BG_FAINT = "#fafafa" +PANEL_BORDER = "#d0d0d0" +PANEL_BORDER_DARK = "#bdbdbd" +PANEL_BORDER_LIGHT = "#e0e0e0" +TUTORIAL_BORDER = "#555555" +TUTORIAL_HIGHLIGHT = "#ffff00" # widget spotlight pen (was Qt.yellow) +# --------------------------------------------------------------------------- + + +def _palette() -> dict[str, str]: + # Why: one substitution mapping instead of ~40 kwargs per stylesheet, and + # a renamed/missing constant fails loudly (KeyError) instead of silently. + return {k.lower(): v for k, v in globals().items() if k.isupper() and isinstance(v, str)} + + +def qcolor(color: str, alpha: int | None = None): + """QColor from a palette hex string, optional 0-255 alpha. + + Hue lives here (theme), alpha stays at the call site (behavior). Lazy Qt + import so this module — and its self-check — stay importable without + PySide6. + """ + from PySide6.QtGui import QColor + + c = QColor(color) + if alpha is not None: + c.setAlpha(alpha) + return c + def build_app_stylesheet(theme: str) -> str: if theme == THEME_PORTRAIT: @@ -11,24 +315,24 @@ def build_app_stylesheet(theme: str) -> str: def _original_stylesheet() -> str: - return """ + return Template(""" QMainWindow, QWidget { - background-color: rgb(216, 228, 253); - color: rgb(30, 41, 59); + background-color: $background; + color: $text; } QWidget#mainContentRoot, QWidget#standardMainPage, QWidget#compactAutomationPage { - background-color: rgb(216, 228, 253); + background-color: $background; } QWidget#portraitModePage { - background-color: #071018; + background-color: $dark_bg; } QFrame#compactAutomationPanel { - background: #d8e4fd; + background: $background; border: none; border-radius: 18px; } @@ -38,42 +342,42 @@ def _original_stylesheet() -> str: QFrame#compactProgressCard, QFrame#compactQueueCard, QFrame#compactQueueItem { - background: #e6eefc; - border: 1px solid #b9ccee; + background: $compact_card_bg; + border: 1px solid $compact_border; border-radius: 16px; } QLabel#compactSectionTitle { background: transparent; - color: #17324d; + color: $compact_title; font-size: 14px; font-weight: 700; } QLabel#compactSectionHint { background: transparent; - color: #51657d; + color: $compact_hint; font-size: 12px; } QLabel#compactQueueTitle { background: transparent; - color: #51657d; + color: $compact_hint; font-size: 11px; font-weight: 700; } QLabel#compactQueueValue { background: transparent; - color: #10263a; + color: $compact_value; font-size: 14px; font-weight: 700; } QToolButton#compactMenuButton { - background: #cbdcf8; - color: #17324d; - border: 1px solid #9fb9e5; + background: $compact_menu_bg; + color: $compact_title; + border: 1px solid $compact_border; border-radius: 14px; padding: 10px 14px; font-size: 18px; @@ -81,12 +385,12 @@ def _original_stylesheet() -> str: } QToolButton#compactMenuButton:hover { - background: #bfd4f6; + background: $compact_menu_bg_hover; } QPushButton#compactPrimaryButton { - background: #2563eb; - color: white; + background: $primary; + color: $primary_text; border: none; border-radius: 14px; padding: 14px 18px; @@ -95,14 +399,14 @@ def _original_stylesheet() -> str: } QPushButton#compactPrimaryButton:hover { - background: #1d4ed8; + background: $primary_hover; } QPushButton#compactSecondaryButton, QToolButton#compactSecondaryButton { - background: #dfe9fb; - color: #17324d; - border: 1px solid #b2c7eb; + background: $secondary_bg; + color: $compact_title; + border: 1px solid $compact_border; border-radius: 14px; padding: 14px 18px; font-size: 14px; @@ -111,26 +415,26 @@ def _original_stylesheet() -> str: QPushButton#compactSecondaryButton:hover, QToolButton#compactSecondaryButton:hover { - background: #d3e1f8; + background: $secondary_bg_hover; } QFrame#alertBanner[alertKind="error"] { - background-color: #fbe4e6; - border: 2px solid #d97a84; + background-color: $error_bg; + border: 2px solid $error_border; border-radius: 12px; margin: 8px 12px 8px 12px; } QFrame#alertBanner[alertKind="success"] { - background-color: #e7f6ea; - border: 2px solid #7bbf8e; + background-color: $success_bg; + border: 2px solid $success_border; border-radius: 12px; margin: 8px 12px 8px 12px; } QFrame#alertBanner[alertKind="waiting"] { - background-color: #fff8e1; - border: 2px solid #ffb300; + background-color: $warning_bg; + border: 2px solid $warning_border; border-radius: 12px; margin: 8px 12px 8px 12px; } @@ -142,20 +446,20 @@ def _original_stylesheet() -> str: } QFrame#alertBanner[alertKind="error"] QLabel { - color: #8f1d2c; + color: $error_text; } QFrame#alertBanner[alertKind="success"] QLabel { - color: #1f6a3a; + color: $success_text; } QFrame#alertBanner[alertKind="waiting"] QLabel { - color: #e65100; + color: $warning_text; } QWidget#axisVideoStatusContainer[busyState="idle"] { border-radius: 12px; - background-color: #d9e2f2; + background-color: $status_idle_bg; } QWidget#axisVideoStatusContainer[busyState="active"] { @@ -173,18 +477,33 @@ def _original_stylesheet() -> str: QLabel#axisVideoStatusLabel { background-color: transparent; - color: #2f3b52; + color: $status_label_text; font-weight: bold; } + QTabWidget::pane { + border: 1px solid $border; + } + + QMainWindow::separator { + background: $border; + width: 4px; + height: 4px; + } + + QFrame#beamlineControls, + QFrame#dataCollectionSettings { + border: 1px solid $border; + } + QFrame#beamlineStatePanel { - background: rgb(216, 228, 253); - border-top: 1px solid rgb(185, 204, 238); + background: $background; + border-top: 1px solid $border; } QLabel#beamlineStateTitle { - background-color: #4B0082; - color: #ffffff; + background-color: $banner; + color: $banner_text; font-size: 16px; font-weight: 700; } @@ -193,13 +512,13 @@ def _original_stylesheet() -> str: QPushButton#beamlineStateToggleButton { border: none; background: transparent; - color: white; + color: $state_toggle_text; font-size: 14px; font-weight: 700; } QLabel#beamlineStateCurrentLabel { - color: rgb(30, 41, 59); + color: $state_current_text; font-size: 18px; font-weight: 700; padding-left: 4px; @@ -207,7 +526,7 @@ def _original_stylesheet() -> str: } QLabel#beamlineStateTellLabel { - color: rgb(55, 67, 87); + color: $state_tell_text; font-size: 15px; font-weight: 600; padding-left: 4px; @@ -216,8 +535,8 @@ def _original_stylesheet() -> str: QWidget#portraitRoot, QWidget#portraitRoot QWidget { - background: #071018; - color: #F5F7FA; + background: $dark_bg; + color: $dark_text; font-family: 'Inter', 'SF Pro Display', Arial, sans-serif; font-size: 14px; } @@ -228,13 +547,13 @@ def _original_stylesheet() -> str: } QWidget#portraitRoot QScrollBar:vertical { - background: #0E1A26; + background: $dark_surface; width: 4px; border-radius: 2px; } QWidget#portraitRoot QScrollBar::handle:vertical { - background: #1A3A36; + background: $dark_border; border-radius: 2px; min-height: 20px; } @@ -243,33 +562,33 @@ def _original_stylesheet() -> str: QWidget#portraitRoot QScrollBar::sub-line:vertical { height: 0px; } - """ + """).substitute(_palette()) def _portrait_stylesheet() -> str: - return """ + return Template(""" QMainWindow, QWidget { - background: #071018; - color: #F5F7FA; + background: $dark_bg; + color: $dark_text; } QWidget#mainContentRoot, QWidget#standardMainPage, QWidget#compactAutomationPage, QWidget#portraitModePage { - background: #071018; + background: $dark_bg; } QTabWidget::pane, QScrollArea, QDockWidget, QDockWidget > QWidget { - background: #071018; - color: #F5F7FA; + background: $dark_bg; + color: $dark_text; } QFrame#compactAutomationPanel { - background: #071018; + background: $dark_bg; border: none; border-radius: 18px; } @@ -279,42 +598,42 @@ def _portrait_stylesheet() -> str: QFrame#compactProgressCard, QFrame#compactQueueCard, QFrame#compactQueueItem { - background: #0E1A26; - border: 1px solid #1A3A36; + background: $dark_surface; + border: 1px solid $dark_border; border-radius: 16px; } QLabel#compactSectionTitle { background: transparent; - color: #62D8C8; + color: $dark_accent; font-size: 14px; font-weight: 700; } QLabel#compactSectionHint { background: transparent; - color: #8A9BB0; + color: $dark_muted; font-size: 12px; } QLabel#compactQueueTitle { background: transparent; - color: #8A9BB0; + color: $dark_muted; font-size: 11px; font-weight: 700; } QLabel#compactQueueValue { background: transparent; - color: #F5F7FA; + color: $dark_text; font-size: 14px; font-weight: 700; } QToolButton#compactMenuButton { - background: #132131; - color: #62D8C8; - border: 1px solid #1A3A36; + background: $dark_elevated; + color: $dark_accent; + border: 1px solid $dark_border; border-radius: 14px; padding: 10px 14px; font-size: 18px; @@ -322,12 +641,12 @@ def _portrait_stylesheet() -> str: } QToolButton#compactMenuButton:hover { - background: #1A3A36; + background: $dark_border; } QPushButton#compactPrimaryButton { - background: #62D8C8; - color: #071018; + background: $dark_accent; + color: $dark_bg; border: none; border-radius: 14px; padding: 14px 18px; @@ -336,14 +655,14 @@ def _portrait_stylesheet() -> str: } QPushButton#compactPrimaryButton:hover { - background: #7ce6d8; + background: $dark_accent_hover; } QPushButton#compactSecondaryButton, QToolButton#compactSecondaryButton { - background: #132131; - color: #F5F7FA; - border: 1px solid #1A3A36; + background: $dark_elevated; + color: $dark_text; + border: 1px solid $dark_border; border-radius: 14px; padding: 14px 18px; font-size: 14px; @@ -352,26 +671,26 @@ def _portrait_stylesheet() -> str: QPushButton#compactSecondaryButton:hover, QToolButton#compactSecondaryButton:hover { - background: #1A3A36; + background: $dark_border; } QFrame#alertBanner[alertKind="error"] { - background: #1A0E0E; - border: 2px solid #8f1d2c; + background: $dark_error_bg; + border: 2px solid $dark_error_border; border-radius: 12px; margin: 8px 12px 8px 12px; } QFrame#alertBanner[alertKind="success"] { - background: #0E1A12; - border: 2px solid #2a7a44; + background: $dark_success_bg; + border: 2px solid $dark_success_border; border-radius: 12px; margin: 8px 12px 8px 12px; } QFrame#alertBanner[alertKind="waiting"] { - background: #2B2208; - border: 2px solid #ffb300; + background: $dark_warning_bg; + border: 2px solid $dark_warning_border; border-radius: 12px; margin: 8px 12px 8px 12px; } @@ -383,20 +702,20 @@ def _portrait_stylesheet() -> str: } QFrame#alertBanner[alertKind="error"] QLabel { - color: #ffb3bc; + color: $dark_error_text; } QFrame#alertBanner[alertKind="success"] QLabel { - color: #a8f0c0; + color: $dark_success_text; } QFrame#alertBanner[alertKind="waiting"] QLabel { - color: #ffd166; + color: $dark_warning_text; } QWidget#axisVideoStatusContainer[busyState="idle"] { border-radius: 12px; - background-color: #132131; + background-color: $dark_elevated; } QWidget#axisVideoStatusContainer[busyState="active"] { @@ -414,18 +733,18 @@ def _portrait_stylesheet() -> str: QLabel#axisVideoStatusLabel { background-color: transparent; - color: #8A9BB0; + color: $dark_muted; font-weight: bold; } QFrame#beamlineStatePanel { - background: #0E1A26; - border-top: 1px solid #1A3A36; + background: $dark_surface; + border-top: 1px solid $dark_border; } QLabel#beamlineStateTitle { - background-color: #132131; - color: #F5F7FA; + background-color: $dark_elevated; + color: $dark_text; font-size: 16px; font-weight: 700; } @@ -434,13 +753,13 @@ def _portrait_stylesheet() -> str: QPushButton#beamlineStateToggleButton { border: none; background: transparent; - color: #F5F7FA; + color: $dark_text; font-size: 14px; font-weight: 700; } QLabel#beamlineStateCurrentLabel { - color: #F5F7FA; + color: $dark_text; font-size: 18px; font-weight: 700; padding-left: 4px; @@ -448,7 +767,7 @@ def _portrait_stylesheet() -> str: } QLabel#beamlineStateTellLabel { - color: #8A9BB0; + color: $dark_muted; font-size: 15px; font-weight: 600; padding-left: 4px; @@ -457,8 +776,8 @@ def _portrait_stylesheet() -> str: QWidget#portraitRoot, QWidget#portraitRoot QWidget { - background: #071018; - color: #F5F7FA; + background: $dark_bg; + color: $dark_text; font-family: 'Inter', 'SF Pro Display', Arial, sans-serif; font-size: 14px; } @@ -469,13 +788,13 @@ def _portrait_stylesheet() -> str: } QWidget#portraitRoot QScrollBar:vertical { - background: #0E1A26; + background: $dark_surface; width: 4px; border-radius: 2px; } QWidget#portraitRoot QScrollBar::handle:vertical { - background: #1A3A36; + background: $dark_border; border-radius: 2px; min-height: 20px; } @@ -484,4 +803,11 @@ def _portrait_stylesheet() -> str: QWidget#portraitRoot QScrollBar::sub-line:vertical { height: 0px; } - """ + """).substitute(_palette()) + + +if __name__ == "__main__": + # ponytail: smallest check that fails if a $name has no matching constant + for _theme in (THEME_ORIGINAL, THEME_PORTRAIT): + assert "$" not in build_app_stylesheet(_theme) + print("gude") -- 2.54.0 From b7d6610261840ad45ad91b9663c37c52f5915d89 Mon Sep 17 00:00:00 2001 From: Dawn Date: Fri, 7 Aug 2026 10:04:57 +0200 Subject: [PATCH 12/57] style: move panel and model colors to the shared palette Mechanical swap of hardcoded color literals for styles.py constants and qcolor() across panels, models, and scan_logic. portrait_mode.py drops its private palette block; beamline_controls and data_collection_settings gain object names so the new $border QSS rules can target them. The status-panel test asserts the palette hex instead of 'red'. Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 4 +- src/aare/gui/models/bookmark.py | 10 +--- src/aare/gui/models/sample_queue_model.py | 9 +-- src/aare/gui/models/user_sample_model.py | 9 +-- src/aare/gui/panels/abr_tweak_panel.py | 13 +++-- src/aare/gui/panels/automation_panel.py | 49 +++++++++++++--- src/aare/gui/panels/beamline_controls.py | 1 + .../gui/panels/beamline_recovery_panel.py | 57 +++++++++++------- src/aare/gui/panels/beamline_state_panel.py | 12 ++-- .../gui/panels/data_collection_settings.py | 4 +- src/aare/gui/panels/developer_help_dialog.py | 23 +++++--- src/aare/gui/panels/face_detection_panel.py | 3 +- src/aare/gui/panels/file_path_panel.py | 9 +-- .../panels/fluorescence_data_collection.py | 3 +- src/aare/gui/panels/fluorescence_panel.py | 5 +- src/aare/gui/panels/local_contact_panel.py | 32 +++++++--- src/aare/gui/panels/log_panel.py | 58 +++++++++++-------- src/aare/gui/panels/manual_sample_panel.py | 3 +- src/aare/gui/panels/portrait_mode.py | 57 ++++++++++-------- .../gui/panels/prediction_metrics_panel.py | 43 +++++++------- src/aare/gui/panels/raster_data_collection.py | 5 +- src/aare/gui/panels/reference_tools_panel.py | 7 ++- .../gui/panels/rotation_data_collection.py | 5 +- src/aare/gui/panels/samcam_panel.py | 11 ++-- src/aare/gui/panels/smart_rotation_panel.py | 9 ++- src/aare/gui/panels/status_panel.py | 3 +- src/aare/gui/panels/target_stability_panel.py | 32 ++++++---- src/aare/gui/panels/tell_sample_panel.py | 3 +- .../gui/scan_logic/raster_grid_manager.py | 15 +++-- tests/unit/gui/test_beamline_state_panel.py | 9 +-- tests/unit/gui/test_panels.py | 3 +- 31 files changed, 314 insertions(+), 192 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 6f955dd4..0dbddc3a 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -63,7 +63,7 @@ from aare.gui.panels.tell_sample_panel import TellSamplePanel from aare.gui.scan_logic.raster_grid_manager import RasterGridManager from aare.gui.scan_logic.rotation_scan_manager import RotationScanManager from aare.gui.scan_logic.sample_mount_logic import SampleMountLogic -from aare.gui.styles import THEME_ORIGINAL, THEME_PORTRAIT, build_app_stylesheet +from aare.gui.styles import BACKGROUND, THEME_ORIGINAL, THEME_PORTRAIT, build_app_stylesheet # Threads from aare.gui.threads.axis_video_thread import VideoThread @@ -182,7 +182,7 @@ class MainWindow(QMainWindow): ) raise - self.setStyleSheet("background-color: rgb(216, 228, 253);") + self.setStyleSheet(f"background-color: {BACKGROUND};") root_widget = QWidget(parent=self) root_widget.setObjectName("mainContentRoot") diff --git a/src/aare/gui/models/bookmark.py b/src/aare/gui/models/bookmark.py index e0370467..54fc5f60 100644 --- a/src/aare/gui/models/bookmark.py +++ b/src/aare/gui/models/bookmark.py @@ -3,6 +3,8 @@ from typing import Literal from aarecommon.math.coordinate import SmargonCoordinate from PySide6.QtGui import QColor +from aare.gui.styles import BOOKMARK_COLORS, qcolor + class SmargonBookmark: coord: SmargonCoordinate @@ -10,13 +12,7 @@ class SmargonBookmark: def qt_color(self) -> QColor: """Convert the color property to a QColor.""" - color_map = { - "red": QColor("red"), - "green": QColor("green"), - "blue": QColor("blue"), - "indigo": QColor("indigo"), - "lime": QColor("lime"), - } + color_map = {name: qcolor(hex_str) for name, hex_str in BOOKMARK_COLORS.items()} return color_map[self.color] # Map the color string to QColor diff --git a/src/aare/gui/models/sample_queue_model.py b/src/aare/gui/models/sample_queue_model.py index f4b19e1a..e6d6844e 100644 --- a/src/aare/gui/models/sample_queue_model.py +++ b/src/aare/gui/models/sample_queue_model.py @@ -1,9 +1,10 @@ from aarecommon.config.logger import setup_logger from aarecommon.models.models import SampleShortInfo, SampleShortInfoList from PySide6.QtCore import QAbstractTableModel, Qt -from PySide6.QtGui import QBrush, QColor +from PySide6.QtGui import QBrush from aare.gui.constants import LOGGER_NAME +from aare.gui.styles import SAMPLE_ROW_ACTIVE_BG, SAMPLE_ROW_QUEUED_BG, WHITE, qcolor logger = setup_logger(LOGGER_NAME) @@ -60,10 +61,10 @@ class SampleQueueSpreadsheet(QAbstractTableModel): elif role == Qt.ItemDataRole.BackgroundRole: if index.row() == 0: if self._running: - return QBrush(QColor(255, 102, 0)) + return QBrush(qcolor(SAMPLE_ROW_ACTIVE_BG)) else: - return QBrush(QColor(114, 159, 207)) - return QBrush(QColor(255, 255, 255)) + return QBrush(qcolor(SAMPLE_ROW_QUEUED_BG)) + return QBrush(qcolor(WHITE)) return None def headerData(self, section, orientation, role=None): diff --git a/src/aare/gui/models/user_sample_model.py b/src/aare/gui/models/user_sample_model.py index 14aee66b..b57b3f37 100644 --- a/src/aare/gui/models/user_sample_model.py +++ b/src/aare/gui/models/user_sample_model.py @@ -3,9 +3,10 @@ import re from aarecommon.config.logger import setup_logger from aarecommon.models.models import SampleShortInfo, SampleShortInfoList from PySide6.QtCore import QAbstractTableModel, QMimeData, Qt -from PySide6.QtGui import QBrush, QColor +from PySide6.QtGui import QBrush from aare.gui.constants import LOGGER_NAME +from aare.gui.styles import SAMPLE_ROW_HIGHLIGHT_BG, SAMPLE_ROW_QUEUED_BG, WHITE, qcolor logger = setup_logger(LOGGER_NAME) @@ -94,10 +95,10 @@ class UserSampleSpreadsheet(QAbstractTableModel): return Qt.AlignmentFlag.AlignCenter elif role == Qt.ItemDataRole.BackgroundRole: if self._sorted_samples[index.row()].db_id == self.current_sample: - return QBrush(QColor(114, 159, 207)) # darker blue + return QBrush(qcolor(SAMPLE_ROW_QUEUED_BG)) if self._sorted_samples[index.row()].puck_name == self.current_puck: - return QBrush(QColor(216, 228, 253)) # light blue - return QBrush(QColor(255, 255, 255)) # White + return QBrush(qcolor(SAMPLE_ROW_HIGHLIGHT_BG)) + return QBrush(qcolor(WHITE)) return None # For other roles, return None def headerData(self, section, orientation, role=None): diff --git a/src/aare/gui/panels/abr_tweak_panel.py b/src/aare/gui/panels/abr_tweak_panel.py index 4d70f17a..f9cd172d 100644 --- a/src/aare/gui/panels/abr_tweak_panel.py +++ b/src/aare/gui/panels/abr_tweak_panel.py @@ -4,6 +4,7 @@ from PySide6.QtCore import Signal, Slot from PySide6.QtGui import Qt from PySide6.QtWidgets import QGridLayout, QLabel, QPushButton, QWidget +from aare.gui.styles import ALERT_TEXT, DEFAULT_TEXT from aare.gui.widgets.button_with_payload import ButtonWithPayload from aare.gui.widgets.number_line_edit import NumberLineEdit from aare.gui.widgets.title_label import TitleLabel @@ -133,17 +134,17 @@ class AbrTweakWidget(QWidget): def update_daq_status(self, s: DAQStatusModel): self._abr_buttons.gmx_label.setText(f"{s.geom.aerotech_meas.x:.3f}") if abs(s.geom.aerotech.x) >= 0.001: - self._abr_buttons.gmx_label.setStyleSheet("color: rgb(255, 0, 0);") + self._abr_buttons.gmx_label.setStyleSheet(f"color: {ALERT_TEXT};") else: - self._abr_buttons.gmx_label.setStyleSheet("color: rgb(0, 0, 0);") + self._abr_buttons.gmx_label.setStyleSheet(f"color: {DEFAULT_TEXT};") self._abr_buttons.gmy_label.setText(f"{s.geom.aerotech_meas.y:.3f}") if abs(s.geom.aerotech.y) >= 0.001: - self._abr_buttons.gmy_label.setStyleSheet("color: rgb(255, 0, 0);") + self._abr_buttons.gmy_label.setStyleSheet(f"color: {ALERT_TEXT};") else: - self._abr_buttons.gmy_label.setStyleSheet("color: rgb(0, 0, 0);") + self._abr_buttons.gmy_label.setStyleSheet(f"color: {DEFAULT_TEXT};") self._abr_buttons.gmz_label.setText(f"{s.geom.aerotech_meas.z:.3f}") if abs(s.geom.aerotech.z) >= 0.001: - self._abr_buttons.gmz_label.setStyleSheet("color: rgb(255, 0, 0);") + self._abr_buttons.gmz_label.setStyleSheet(f"color: {ALERT_TEXT};") else: - self._abr_buttons.gmz_label.setStyleSheet("color: rgb(0, 0, 0);") + self._abr_buttons.gmz_label.setStyleSheet(f"color: {DEFAULT_TEXT};") diff --git a/src/aare/gui/panels/automation_panel.py b/src/aare/gui/panels/automation_panel.py index e9edb4c9..3a9fa166 100644 --- a/src/aare/gui/panels/automation_panel.py +++ b/src/aare/gui/panels/automation_panel.py @@ -10,6 +10,28 @@ from PySide6.QtCore import QTimer, Slot from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget from aare.gui.constants import LOGGER_NAME +from aare.gui.styles import ( + AUTOMATION_HINT_TEXT, + AUTOMATION_TITLE_TEXT, + CARD_BORDER, + FAINT_TEXT, + MUTED_TEXT, + STEP_ACTIVE_BG, + STEP_ACTIVE_BORDER, + STEP_ACTIVE_TEXT, + STEP_DONE_BG, + STEP_DONE_BORDER, + STEP_DONE_TEXT, + STEP_FAILED_BG, + STEP_FAILED_BORDER, + STEP_FAILED_TEXT, + STEP_IDLE_BG, + STEP_IDLE_BORDER, + STEP_PAUSED_BG, + STEP_PAUSED_BORDER, + STEP_PAUSED_TEXT, + SURFACE, +) logger = setup_logger(LOGGER_NAME) @@ -40,14 +62,14 @@ class AutomationProgressWidget(QWidget): self._title_label = QLabel("Automation progress") self._title_label.setStyleSheet( - "font-size: 16px; font-weight: 700; color: #1F2937; margin-bottom: 2px;" + f"font-size: 16px; font-weight: 700; color: {AUTOMATION_TITLE_TEXT}; margin-bottom: 2px;" ) layout.addWidget(self._title_label) self._stats_label = QLabel() self._stats_label.setStyleSheet( - "color: #374151; font-size: 13px; " - "background-color: #F8FAFC; border: 1px solid #E2E8F0; " + f"color: {AUTOMATION_HINT_TEXT}; font-size: 13px; " + f"background-color: {SURFACE}; border: 1px solid {CARD_BORDER}; " "border-radius: 8px; padding: 10px;" ) self._stats_label.setWordWrap(True) @@ -122,25 +144,34 @@ class AutomationProgressWidget(QWidget): ) if status == StepStatus.SUCCESS: - return base + " background-color: #ECFDF3; color: #166534; border-color: #A7F3D0;" + return ( + base + + f" background-color: {STEP_DONE_BG}; color: {STEP_DONE_TEXT}; border-color: {STEP_DONE_BORDER};" + ) if status == StepStatus.RUNNING: return ( base - + " background-color: #EFF6FF; color: #1D4ED8; font-weight: 700; border-color: #BFDBFE;" + + f" background-color: {STEP_ACTIVE_BG}; color: {STEP_ACTIVE_TEXT}; font-weight: 700; border-color: {STEP_ACTIVE_BORDER};" ) if status == StepStatus.FAILED: return ( base - + " background-color: #FEF2F2; color: #B91C1C; font-weight: 700; border-color: #FECACA;" + + f" background-color: {STEP_FAILED_BG}; color: {STEP_FAILED_TEXT}; font-weight: 700; border-color: {STEP_FAILED_BORDER};" ) if status == StepStatus.PAUSED: return ( base - + " background-color: #FFF7ED; color: #C2410C; font-weight: 700; border-color: #FED7AA;" + + f" background-color: {STEP_PAUSED_BG}; color: {STEP_PAUSED_TEXT}; font-weight: 700; border-color: {STEP_PAUSED_BORDER};" ) if status == StepStatus.SKIPPED: - return base + " background-color: #F8FAFC; color: #475569; border-color: #E2E8F0;" - return base + " background-color: #F8FAFC; color: #64748B; border-color: #E2E8F0;" + return ( + base + + f" background-color: {STEP_IDLE_BG}; color: {MUTED_TEXT}; border-color: {STEP_IDLE_BORDER};" + ) + return ( + base + + f" background-color: {STEP_IDLE_BG}; color: {FAINT_TEXT}; border-color: {STEP_IDLE_BORDER};" + ) @staticmethod def _format_duration(seconds: float | None) -> str: diff --git a/src/aare/gui/panels/beamline_controls.py b/src/aare/gui/panels/beamline_controls.py index 2b7a7dad..771c9845 100644 --- a/src/aare/gui/panels/beamline_controls.py +++ b/src/aare/gui/panels/beamline_controls.py @@ -18,6 +18,7 @@ class BeamlineControls(QFrame): def __init__(self, parent=None, staff: bool = True): super().__init__(parent) + self.setObjectName("beamlineControls") self.setFixedWidth(self.set_width) self.setFrameShape(QFrame.Shape.StyledPanel) self.setFrameShadow(QFrame.Shadow.Raised) diff --git a/src/aare/gui/panels/beamline_recovery_panel.py b/src/aare/gui/panels/beamline_recovery_panel.py index 882f021f..f1d1d038 100644 --- a/src/aare/gui/panels/beamline_recovery_panel.py +++ b/src/aare/gui/panels/beamline_recovery_panel.py @@ -16,6 +16,19 @@ from PySide6.QtWidgets import ( ) from aare.gui.constants import LOGGER_NAME +from aare.gui.styles import ( + BAD_CARD_BORDER, + CHIP_BAD_BG, + CHIP_BAD_TEXT, + CHIP_INFO_TEXT, + CHIP_WARN_BG, + CHIP_WARN_TEXT, + INFO_CARD_BG, + INFO_CARD_BORDER, + PENDING_CARD_BG, + PENDING_CARD_BORDER, + WARN_CARD_BORDER, +) from aare.gui.threads.daq_worker import DAQWorker logger = setup_logger(LOGGER_NAME) @@ -37,9 +50,9 @@ class RecoveryPanel(QWidget): self._warning_primary.setWordWrap(True) self._warning_primary.setStyleSheet( "QLabel {" - " background: #fff3cd;" - " color: #7a4b00;" - " border: 1px solid #f0c36d;" + f" background: {CHIP_WARN_BG};" + f" color: {CHIP_WARN_TEXT};" + f" border: 1px solid {WARN_CARD_BORDER};" " padding: 10px;" " font-weight: 600;" "}" @@ -53,9 +66,9 @@ class RecoveryPanel(QWidget): self._warning_secondary.setWordWrap(True) self._warning_secondary.setStyleSheet( "QLabel {" - " background: #fdeaea;" - " color: #8b1e1e;" - " border: 1px solid #e6a8a8;" + f" background: {CHIP_BAD_BG};" + f" color: {CHIP_BAD_TEXT};" + f" border: 1px solid {BAD_CARD_BORDER};" " padding: 10px;" " font-weight: 600;" "}" @@ -66,9 +79,9 @@ class RecoveryPanel(QWidget): self._last_action.setWordWrap(True) self._last_action.setStyleSheet( "QLabel {" - " background: #eef6ff;" - " color: #12406a;" - " border: 1px solid #a8c7e6;" + f" background: {INFO_CARD_BG};" + f" color: {CHIP_INFO_TEXT};" + f" border: 1px solid {INFO_CARD_BORDER};" " padding: 10px;" " font-weight: 600;" "}" @@ -78,8 +91,8 @@ class RecoveryPanel(QWidget): self._take_over_btn = QPushButton("Take over beamline", self) self._take_over_btn.setStyleSheet( "QPushButton {" - " background: #fff7db;" - " border: 1px solid #e7cb73;" + f" background: {PENDING_CARD_BG};" + f" border: 1px solid {PENDING_CARD_BORDER};" " padding: 10px;" " font-weight: 600;" "}" @@ -90,8 +103,8 @@ class RecoveryPanel(QWidget): self._free_beamline_btn = QPushButton("Free beamline", self) self._free_beamline_btn.setStyleSheet( "QPushButton {" - " background: #fff7db;" - " border: 1px solid #e7cb73;" + f" background: {PENDING_CARD_BG};" + f" border: 1px solid {PENDING_CARD_BORDER};" " padding: 10px;" " font-weight: 600;" "}" @@ -102,9 +115,9 @@ class RecoveryPanel(QWidget): self._recover_beamline_btn = QPushButton("Recover beamline", self) self._recover_beamline_btn.setStyleSheet( "QPushButton {" - " background: #fdeaea;" - " color: #8b1e1e;" - " border: 1px solid #e6a8a8;" + f" background: {CHIP_BAD_BG};" + f" color: {CHIP_BAD_TEXT};" + f" border: 1px solid {BAD_CARD_BORDER};" " padding: 10px;" " font-weight: 700;" "}" @@ -115,9 +128,9 @@ class RecoveryPanel(QWidget): self._recovery_unmount_btn = QPushButton("Unmount sample (recovery)", self) self._recovery_unmount_btn.setStyleSheet( "QPushButton {" - " background: #fdeaea;" - " color: #8b1e1e;" - " border: 1px solid #e6a8a8;" + f" background: {CHIP_BAD_BG};" + f" color: {CHIP_BAD_TEXT};" + f" border: 1px solid {BAD_CARD_BORDER};" " padding: 10px;" " font-weight: 700;" "}" @@ -128,9 +141,9 @@ class RecoveryPanel(QWidget): self._resync_sample_btn = QPushButton("Resync sample from TELL", self) self._resync_sample_btn.setStyleSheet( "QPushButton {" - " background: #eef6ff;" - " color: #12406a;" - " border: 1px solid #a8c7e6;" + f" background: {INFO_CARD_BG};" + f" color: {CHIP_INFO_TEXT};" + f" border: 1px solid {INFO_CARD_BORDER};" " padding: 10px;" " font-weight: 600;" "}" diff --git a/src/aare/gui/panels/beamline_state_panel.py b/src/aare/gui/panels/beamline_state_panel.py index 2fb895ed..c8da2279 100644 --- a/src/aare/gui/panels/beamline_state_panel.py +++ b/src/aare/gui/panels/beamline_state_panel.py @@ -5,6 +5,8 @@ from PySide6.QtCore import Qt, QTimer, Signal, Slot from PySide6.QtGui import QCursor, QFont, QFontMetrics from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QMenu, QPushButton, QSizePolicy, QToolTip +from aare.gui.styles import STATE_AVAILABLE, STATE_MSG_ERROR, STATE_MSG_INFO, STATE_UNAVAILABLE + # Shortcut transitions from the "Available transitions" menu in # widgets/status_bar.py show_state_menu — these come ON TOP of the one-hop # routes between states (_GRAPH), e.g. Manual sample exchange -> Sample @@ -138,8 +140,8 @@ class BeamlineStatePanel(QFrame): self._hover_hint_timer.setInterval(3000) self._hover_hint_timer.timeout.connect(self._show_hover_hint) - self._available_color = "rgb(237, 137, 54)" - self._unavailable_color = "rgb(140, 150, 165)" + self._available_color = STATE_AVAILABLE + self._unavailable_color = STATE_UNAVAILABLE layout = QHBoxLayout(self) layout.setContentsMargins(10, 2, 10, 2) @@ -152,7 +154,7 @@ class BeamlineStatePanel(QFrame): if index: separator = QLabel("–", self) separator.setStyleSheet( - "color: rgb(140, 150, 165); background: transparent; border: none; font-size: 18px;" + f"color: {STATE_UNAVAILABLE}; background: transparent; border: none; font-size: 18px;" ) layout.addWidget(separator) button = HoverableButton(label, self) @@ -308,9 +310,7 @@ class BeamlineStatePanel(QFrame): # No backgrounds, no rounded corners. if is_current or is_pending: color = ( - "rgb(200, 30, 30)" - if state == BeamlineStateEnum.Maintenance - else "rgb(0, 92, 170)" + STATE_MSG_ERROR if state == BeamlineStateEnum.Maintenance else STATE_MSG_INFO ) bold = True elif is_available: diff --git a/src/aare/gui/panels/data_collection_settings.py b/src/aare/gui/panels/data_collection_settings.py index c8cad827..df75e340 100644 --- a/src/aare/gui/panels/data_collection_settings.py +++ b/src/aare/gui/panels/data_collection_settings.py @@ -11,6 +11,7 @@ from aare.gui.panels.raster_data_collection import RasterDataCollectionPanel from aare.gui.panels.rotation_data_collection import RotationDataCollectionPanel from aare.gui.panels.smart_rotation_panel import SimpleRotationSettingsPanel from aare.gui.scan_logic.raster_grid_manager import RasterGridManager +from aare.gui.styles import ABORT_TEXT from aare.gui.widgets.title_label import TitleLabel, tighten_column @@ -27,6 +28,7 @@ class DataCollectionSettings(QFrame): parent=None, ): super().__init__(parent) + self.setObjectName("dataCollectionSettings") self.setFixedWidth(self.set_width) self.setFrameShape(QFrame.Shape.StyledPanel) self.setFrameShadow(QFrame.Shadow.Raised) @@ -66,7 +68,7 @@ class DataCollectionSettings(QFrame): v_layout.addWidget(exp_config) abort_button = QPushButton("Abort measurement", parent=self) - abort_button.setStyleSheet("color: rgb(164, 0, 0);") + abort_button.setStyleSheet(f"color: {ABORT_TEXT};") 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 diff --git a/src/aare/gui/panels/developer_help_dialog.py b/src/aare/gui/panels/developer_help_dialog.py index ef1fd449..fb818a24 100644 --- a/src/aare/gui/panels/developer_help_dialog.py +++ b/src/aare/gui/panels/developer_help_dialog.py @@ -31,6 +31,14 @@ from PySide6.QtWidgets import ( from aare.gui.constants import LOGGER_NAME from aare.gui.log import QtLogEmitter, QtLogHandler +from aare.gui.styles import ( + PANEL_BG_FAINT, + PANEL_BG_SOFT, + PANEL_BORDER, + PANEL_BORDER_DARK, + PANEL_BORDER_LIGHT, + WHITE, +) from aare.gui.threads.daq_worker import DAQWorker logger = setup_logger(LOGGER_NAME) @@ -62,8 +70,8 @@ class DeveloperHelpDialog(QDialog): self._banner.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self._banner.setStyleSheet( "QLabel {" - " background: #f6f6f6;" - " border: 1px solid #d0d0d0;" + f" background: {PANEL_BG_SOFT};" + f" border: 1px solid {PANEL_BORDER};" " border-radius: 6px;" " padding: 6px 8px;" "}" @@ -83,8 +91,8 @@ class DeveloperHelpDialog(QDialog): self._filter.setMinimumHeight(28) self._filter.setStyleSheet( "QLineEdit {" - " background: white;" - " border: 1px solid #bdbdbd;" + f" background: {WHITE};" + f" border: 1px solid {PANEL_BORDER_DARK};" " border-radius: 6px;" " padding: 4px 8px;" "}" @@ -146,7 +154,8 @@ class DeveloperHelpDialog(QDialog): self._details_frame = QFrame(self) self._details_frame.setFrameShape(QFrame.Shape.StyledPanel) self._details_frame.setStyleSheet( - "QFrame { background: #fafafa; border: 1px solid #d0d0d0; border-radius: 6px;}" + f"QFrame {{ background: {PANEL_BG_FAINT}; border: 1px solid {PANEL_BORDER};" + " border-radius: 6px;}" ) details_layout = QVBoxLayout(self._details_frame) @@ -178,8 +187,8 @@ class DeveloperHelpDialog(QDialog): self._detail_help.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self._detail_help.setStyleSheet( "QLabel {" - " background: white;" - " border: 1px solid #e0e0e0;" + f" background: {WHITE};" + f" border: 1px solid {PANEL_BORDER_LIGHT};" " border-radius: 6px;" " padding: 8px;" "}" diff --git a/src/aare/gui/panels/face_detection_panel.py b/src/aare/gui/panels/face_detection_panel.py index 46c9e5e2..fa8cf5d8 100644 --- a/src/aare/gui/panels/face_detection_panel.py +++ b/src/aare/gui/panels/face_detection_panel.py @@ -6,6 +6,7 @@ from PySide6.QtCore import Signal from PySide6.QtWidgets import QGridLayout, QLabel, QPushButton, QVBoxLayout, QWidget from aare.gui.constants import LOGGER_NAME +from aare.gui.styles import GO_TEXT from aare.gui.widgets.number_line_edit import NumberLineEdit from aare.gui.widgets.title_label import TitleLabel @@ -51,7 +52,7 @@ class FaceDetectionPanel(QWidget): self.steps_enter.newValue.connect(_set_steps) self.face_detection_button = QPushButton("Face Detection") - self.face_detection_button.setStyleSheet("color: rgb(78, 154, 6);") + self.face_detection_button.setStyleSheet(f"color: {GO_TEXT};") self.face_detection_button.clicked.connect(self.run_and_refresh) self._top_layout.addWidget(self.face_detection_button, 3, 0, 1, 3) diff --git a/src/aare/gui/panels/file_path_panel.py b/src/aare/gui/panels/file_path_panel.py index 45204210..96937e33 100644 --- a/src/aare/gui/panels/file_path_panel.py +++ b/src/aare/gui/panels/file_path_panel.py @@ -7,6 +7,7 @@ from aarecommon.models.models import DAQStatusModel, SampleShortInfo from PySide6.QtCore import Qt, Signal, Slot from PySide6.QtWidgets import QGridLayout, QLabel, QLineEdit, QMessageBox, QSpinBox, QWidget +from aare.gui.styles import DEFAULT_TEXT, PATH_WARN_TEXT, SURFACE from aare.gui.widgets.title_label import TitleLabel ## Logic for filenames: @@ -44,7 +45,7 @@ class FilePathPanel(QWidget): grid_layout.addWidget(QLabel("Directory", parent=self), 1, 0) self.directory_edit = QLineEdit("{date}/{puck}/{pos}", parent=self) - self.directory_edit.setStyleSheet("background-color: rgb(255, 255, 255);") + self.directory_edit.setStyleSheet(f"background-color: {SURFACE};") self.directory_edit.setToolTip( "Provide subdirectory for your files. The following macros are allowed:
" "{date} - date in format yyyymmdd
" @@ -58,7 +59,7 @@ class FilePathPanel(QWidget): grid_layout.addWidget(QLabel("File prefix", parent=self), 2, 0) self.file_prefix_edit = QLineEdit("{sample}", parent=self) - self.file_prefix_edit.setStyleSheet("background-color: rgb(255, 255, 255);") + self.file_prefix_edit.setStyleSheet(f"background-color: {SURFACE};") self.file_prefix_edit.setToolTip( "Provide file prefix for your files. The following macros are allowed:
" "{date} - date in format yyyymmdd
" @@ -72,7 +73,7 @@ class FilePathPanel(QWidget): grid_layout.addWidget(QLabel("Run number", parent=self), 3, 0) self.run_number_edit = QSpinBox(parent=self) - self.run_number_edit.setStyleSheet("background-color: rgb(255, 255, 255);") + self.run_number_edit.setStyleSheet(f"background-color: {SURFACE};") self.run_number_edit.setValue(1) self.run_number_edit.setRange(1, 999) self.run_number_edit.setAlignment(Qt.AlignmentFlag.AlignRight) @@ -161,7 +162,7 @@ class FilePathPanel(QWidget): exists = os.path.exists(f"{effective}_master.h5") or os.path.exists(effective) self.file_name_label.setText(effective + "_master.h5") self.file_name_label.setStyleSheet( - "color: rgb(200, 0, 0);" if exists else "color: rgb(0, 0, 0);" + f"color: {PATH_WARN_TEXT};" if exists else f"color: {DEFAULT_TEXT};" ) self.path_updated.emit(self._filename) diff --git a/src/aare/gui/panels/fluorescence_data_collection.py b/src/aare/gui/panels/fluorescence_data_collection.py index 9266dfff..ce3980e6 100644 --- a/src/aare/gui/panels/fluorescence_data_collection.py +++ b/src/aare/gui/panels/fluorescence_data_collection.py @@ -2,6 +2,7 @@ from aarecommon.models.models import FluorescenceSpectrumParameterModel from PySide6.QtCore import Signal, Slot from PySide6.QtWidgets import QCheckBox, QGridLayout, QLabel, QPushButton, QWidget +from aare.gui.styles import GO_TEXT from aare.gui.widgets.number_line_edit import NumberLineEdit @@ -34,7 +35,7 @@ class FluorescenceDataCollectionPanel(QWidget): # Run button self.run_btn = QPushButton("Run fluorescence", self) - self.run_btn.setStyleSheet("color: rgb(78, 154, 6);") + self.run_btn.setStyleSheet(f"color: {GO_TEXT};") lay.addWidget(self.run_btn, 4, 0, 1, 3) self.run_btn.clicked.connect(self._emit_params) diff --git a/src/aare/gui/panels/fluorescence_panel.py b/src/aare/gui/panels/fluorescence_panel.py index 49e2ccb7..b67f25a7 100644 --- a/src/aare/gui/panels/fluorescence_panel.py +++ b/src/aare/gui/panels/fluorescence_panel.py @@ -3,10 +3,11 @@ from aarecommon.config.logger import setup_logger from aarecommon.models.models import DAQStatusModel, FluorescenceSpectrumOutputModel from PySide6.QtCharts import QChart, QChartView, QLineSeries, QValueAxis from PySide6.QtCore import QEvent, QPointF, Qt, Slot -from PySide6.QtGui import QColor, QPainter, QPen +from PySide6.QtGui import QPainter, QPen from PySide6.QtWidgets import QGraphicsSimpleTextItem, QGridLayout, QLabel, QWidget from aare.gui.constants import LOGGER_NAME +from aare.gui.styles import SPECTRUM_LINE, qcolor logger = setup_logger(LOGGER_NAME) @@ -65,7 +66,7 @@ class FluorescencePanel(QWidget): # Vertical marker line (two-point series) self._vline = QLineSeries() - pen = QPen(QColor("#cc0000")) + pen = QPen(qcolor(SPECTRUM_LINE)) pen.setWidth(2) self._vline.setPen(pen) self.chart.addSeries(self._vline) diff --git a/src/aare/gui/panels/local_contact_panel.py b/src/aare/gui/panels/local_contact_panel.py index 83a79717..8153b9c8 100644 --- a/src/aare/gui/panels/local_contact_panel.py +++ b/src/aare/gui/panels/local_contact_panel.py @@ -29,6 +29,15 @@ from PySide6.QtWidgets import ( from aare.gui.constants import LOGGER_NAME from aare.gui.panels.beamline_recovery_panel import RecoveryPanel +from aare.gui.styles import ( + BAD_CARD_BORDER, + BORDER, + CARD_BORDER, + CHIP_BAD_BG, + CHIP_BAD_TEXT, + HEADING_TEXT, + SURFACE, +) from aare.gui.threads.daq_worker import DAQWorker from aare.gui.widgets.local_contact_status_widget import LocalContactStatusWidget from aare.gui.widgets.text_list_dialog import TextListDialog @@ -74,23 +83,27 @@ class LocalContactPanel(QFrame): self.setFrameShape(QFrame.Shape.StyledPanel) self.setFrameShadow(QFrame.Shadow.Raised) + self.setObjectName("localContactPanel") self.setStyleSheet( - """ - QGroupBox { - background-color: white; - border: 1px solid #c7d4e5; + f""" + QFrame#localContactPanel {{ + border: 1px solid {BORDER}; + }} + QGroupBox {{ + background-color: {SURFACE}; + border: 1px solid {CARD_BORDER}; margin-top: 15px; padding-top: 15px; font-weight: 700; - color: #1e293b; - } - QGroupBox::title { + color: {HEADING_TEXT}; + }} + QGroupBox::title {{ subcontrol-origin: margin; subcontrol-position: top left; left: 10px; padding: 0 6px 0 6px; font:bold; - } + }} """ ) @@ -108,7 +121,8 @@ class LocalContactPanel(QFrame): self._transfer_error_frame = QFrame(self) self._transfer_error_frame.setVisible(False) self._transfer_error_frame.setStyleSheet( - "QFrame { background: #fdeaea; color: #8b1e1e; border: 1px solid #e6a8a8;}" + f"QFrame {{ background: {CHIP_BAD_BG}; color: {CHIP_BAD_TEXT};" + f" border: 1px solid {BAD_CARD_BORDER};}}" ) transfer_error_layout = QVBoxLayout(self._transfer_error_frame) transfer_error_layout.setContentsMargins(10, 10, 10, 10) diff --git a/src/aare/gui/panels/log_panel.py b/src/aare/gui/panels/log_panel.py index 1de7958c..659c8ae8 100644 --- a/src/aare/gui/panels/log_panel.py +++ b/src/aare/gui/panels/log_panel.py @@ -13,6 +13,18 @@ from PySide6.QtWidgets import ( ) from aare.gui.log import QtLogEmitter, QtLogHandler +from aare.gui.styles import ( + LOG_BORDER, + LOG_ERROR_BG, + LOG_ERROR_BORDER, + LOG_INFO_BG, + LOG_INFO_BORDER, + LOG_PANEL_BG, + LOG_SUCCESS_BG, + LOG_SUCCESS_BORDER, + LOG_WARN_BG, + LOG_WARN_BORDER, +) class RuntimeNotificationWidget(QFrame): @@ -78,31 +90,31 @@ class RuntimeNotificationWidget(QFrame): self._sticky = True self.setStyleSheet( - """ - QFrame#runtimeNotification { - border: 1px solid #8a8a8a; + f""" + QFrame#runtimeNotification {{ + border: 1px solid {LOG_BORDER}; border-radius: 8px; - background-color: #fff4f4; - } - QFrame#runtimeNotification[noticeLevel="error"] { - background-color: #fff1f1; - border: 1px solid #d66; - } - QFrame#runtimeNotification[noticeLevel="warning"] { - background-color: #fff8e8; - border: 1px solid #d7aa42; - } - QFrame#runtimeNotification[noticeLevel="success"] { - background-color: #eefaf0; - border: 1px solid #6cb37a; - } - QFrame#runtimeNotification[noticeLevel="info"] { - background-color: #eef5ff; - border: 1px solid #6b9bd6; - } - QLabel#runtimeNotificationTitle { + background-color: {LOG_PANEL_BG}; + }} + QFrame#runtimeNotification[noticeLevel="error"] {{ + background-color: {LOG_ERROR_BG}; + border: 1px solid {LOG_ERROR_BORDER}; + }} + QFrame#runtimeNotification[noticeLevel="warning"] {{ + background-color: {LOG_WARN_BG}; + border: 1px solid {LOG_WARN_BORDER}; + }} + QFrame#runtimeNotification[noticeLevel="success"] {{ + background-color: {LOG_SUCCESS_BG}; + border: 1px solid {LOG_SUCCESS_BORDER}; + }} + QFrame#runtimeNotification[noticeLevel="info"] {{ + background-color: {LOG_INFO_BG}; + border: 1px solid {LOG_INFO_BORDER}; + }} + QLabel#runtimeNotificationTitle {{ font-weight: bold; - } + }} """ ) diff --git a/src/aare/gui/panels/manual_sample_panel.py b/src/aare/gui/panels/manual_sample_panel.py index 7f86a5f8..f7f554e2 100644 --- a/src/aare/gui/panels/manual_sample_panel.py +++ b/src/aare/gui/panels/manual_sample_panel.py @@ -3,6 +3,7 @@ from aareDB import DataCollectionParameters from PySide6.QtCore import Signal, Slot from PySide6.QtWidgets import QCheckBox, QGridLayout, QLabel, QLineEdit, QPushButton, QWidget +from aare.gui.styles import SURFACE from aare.gui.widgets.number_line_edit import NumberLineEdit from aare.gui.widgets.title_label import TitleLabel @@ -31,7 +32,7 @@ class ManualSamplePanel(QWidget): self._text_name = QLineEdit(self._sample_name) grid_layout.addWidget(self._text_name, 1, 1) self._text_name.textChanged.connect(self._name_changed) - self._text_name.setStyleSheet("background-color: rgb(255, 255, 255);") + self._text_name.setStyleSheet(f"background-color: {SURFACE};") # Unit cell parameters self._unit_cell = QCheckBox("Provide unit cell") diff --git a/src/aare/gui/panels/portrait_mode.py b/src/aare/gui/panels/portrait_mode.py index 08b21114..288a8856 100644 --- a/src/aare/gui/panels/portrait_mode.py +++ b/src/aare/gui/panels/portrait_mode.py @@ -20,22 +20,28 @@ from PySide6.QtWidgets import ( ) from aare.gui.constants import LOGGER_NAME +from aare.gui.styles import DARK_ACCENT as ACCENT +from aare.gui.styles import DARK_BG as BG +from aare.gui.styles import DARK_BORDER as ACCENT_DIM +from aare.gui.styles import DARK_BORDER as LED_OFF +from aare.gui.styles import DARK_ELEVATED as BUTTON_BG +from aare.gui.styles import ( + DARK_ERROR_BG, + DARK_ERROR_BORDER, + DARK_ERROR_TEXT, + DARK_SUCCESS_BG, + DARK_SUCCESS_BORDER, + DARK_SUCCESS_TEXT, + WHITE, + qcolor, +) +from aare.gui.styles import DARK_MUTED as SUBTEXT +from aare.gui.styles import DARK_SURFACE as CARD_BG +from aare.gui.styles import DARK_TEXT as TEXT +from aare.gui.styles import WHITE as ACTIVE_STEP logger = setup_logger(LOGGER_NAME) -# --------------------------------------------------------------------------- -# Colour palette (kept identical to gui_designer.py) -# --------------------------------------------------------------------------- -BG = "#071018" -CARD_BG = "#0E1A26" -ACCENT = "#62D8C8" -ACCENT_DIM = "#1A3A36" -TEXT = "#F5F7FA" -SUBTEXT = "#8A9BB0" -BUTTON_BG = "#132131" -LED_OFF = "#1C2E3E" -ACTIVE_STEP = "#FFFFFF" - # --------------------------------------------------------------------------- # LED step indicator @@ -112,7 +118,8 @@ class LEDStages(QWidget): p.drawLine(QPointF(cx - 4, cy), QPointF(cx - 1, cy + 3)) p.drawLine(QPointF(cx - 1, cy + 3), QPointF(cx + 4, cy - 3)) elif i == self._active: - glow_pen = QPen(QColor(ACCENT + "55"), 4) + # was QColor(ACCENT + "55"), which mis-parsed as #AARRGGBB + glow_pen = QPen(qcolor(ACCENT, 0x55), 4) p.setPen(glow_pen) p.setBrush(Qt.NoBrush) p.drawEllipse(QPointF(cx, cy), led_r + 4, led_r + 4) @@ -166,7 +173,7 @@ class PlayPauseButton(QPushButton): cx, cy = rect.width() / 2, rect.height() / 2 r = min(rect.width(), rect.height()) / 2 - 2 - bg_color = QColor("#FFFFFF") if self._hovered else QColor(ACCENT) + bg_color = qcolor(WHITE) if self._hovered else QColor(ACCENT) p.setBrush(bg_color) p.setPen(Qt.NoPen) p.drawEllipse(QPointF(cx, cy), r, r) @@ -189,7 +196,7 @@ class QueueItemCard(QFrame): self.setFixedHeight(72) self.setStyleSheet(f""" QFrame {{ - background: {"#112030" if is_next else "#0C1720"}; + background: {BUTTON_BG if is_next else CARD_BG}; border-radius: 14px; border: {"1px solid " + ACCENT_DIM if is_next else "none"}; }} @@ -292,19 +299,19 @@ class PortraitModePanel(QWidget): # ── Portrait alert toast (hidden by default) ─────────────────────── self._alert_toast = QFrame() self._alert_toast.setVisible(False) - self._alert_toast.setStyleSheet(""" - QFrame { - background: #1A0E0E; - border: 1px solid #8f1d2c; + self._alert_toast.setStyleSheet(f""" + QFrame {{ + background: {DARK_ERROR_BG}; + border: 1px solid {DARK_ERROR_BORDER}; border-radius: 10px; - } + }} """) toast_layout = QHBoxLayout(self._alert_toast) toast_layout.setContentsMargins(12, 8, 12, 8) self._alert_toast_label = QLabel("") self._alert_toast_label.setWordWrap(True) self._alert_toast_label.setStyleSheet( - "color: #ffb3bc; font-size: 11px; font-weight: 600; background: transparent;" + f"color: {DARK_ERROR_TEXT}; font-size: 11px; font-weight: 600; background: transparent;" ) toast_layout.addWidget(self._alert_toast_label) # Dismiss button @@ -658,9 +665,9 @@ class PortraitModePanel(QWidget): icon = "🛑" if is_error else "✅" self._alert_toast_label.setText(f"{icon} {msg}") - border_color = "#8f1d2c" if is_error else "#2a7a44" - text_color = "#ffb3bc" if is_error else "#a8f0c0" - bg_color = "#1A0E0E" if is_error else "#0E1A12" + border_color = DARK_ERROR_BORDER if is_error else DARK_SUCCESS_BORDER + text_color = DARK_ERROR_TEXT if is_error else DARK_SUCCESS_TEXT + bg_color = DARK_ERROR_BG if is_error else DARK_SUCCESS_BG self._alert_toast.setStyleSheet(f""" QFrame {{ diff --git a/src/aare/gui/panels/prediction_metrics_panel.py b/src/aare/gui/panels/prediction_metrics_panel.py index dce257d6..44f2cab2 100644 --- a/src/aare/gui/panels/prediction_metrics_panel.py +++ b/src/aare/gui/panels/prediction_metrics_panel.py @@ -42,6 +42,16 @@ from PySide6.QtWidgets import ( ) from aare.gui.constants import LOGGER_NAME +from aare.gui.styles import ( + CHART_BLUE, + CHART_GREEN, + CHART_MUTED, + CHART_ORANGE, + CHART_RED, + CONFIDENCE_BIN_COLORS, + qcolor, +) +from aare.gui.styles import CHART_CLASS_COLORS as CLASS_COLORS logger = setup_logger(LOGGER_NAME) @@ -79,17 +89,6 @@ class GroundTruthComparison: # ───────────────────────────────────────────────────────────────────────────── -# Colors matching the bounding box colors in camera_image.py -CLASS_COLORS = { - "Crystal": "#0000ff", # blue - "Loop_face": "#ffff00", # yellow - "Loop_all": "#00ff00", # green - "Pin": "#ff0000", # red - "Ice": "#00ffff", # cyan - "Needle": "#ff00ff", # magenta -} - - def get_class_name(cls_id: int) -> str: """Convert class ID to human-readable name.""" try: @@ -100,7 +99,7 @@ def get_class_name(cls_id: int) -> str: def get_class_color(class_name: str) -> str: """Get color for a class name (matches bounding box colors).""" - return CLASS_COLORS.get(class_name, "#888888") + return CLASS_COLORS.get(class_name, CHART_MUTED) # ───────────────────────────────────────────────────────────────────────────── @@ -112,7 +111,7 @@ class ConfidenceHistogramWidget(QWidget): """Real-time histogram of prediction confidence scores.""" BINS: ClassVar[list[float]] = [0.0, 0.2, 0.4, 0.6, 0.8, 1.0] - BIN_COLORS: ClassVar[list[str]] = ["#d62728", "#ff7f0e", "#ffbb78", "#98df8a", "#2ca02c"] + BIN_COLORS: ClassVar[list[str]] = CONFIDENCE_BIN_COLORS def __init__(self, parent=None): super().__init__(parent) @@ -335,19 +334,19 @@ class ErrorTrackingWidget(QWidget): # Empty frames (no detections) grid.addWidget(QLabel("Empty frames:"), 0, 0) self.empty_frames_label = QLabel("0") - self.empty_frames_label.setStyleSheet("color: #d62728; font-weight: bold;") + self.empty_frames_label.setStyleSheet(f"color: {CHART_RED}; font-weight: bold;") grid.addWidget(self.empty_frames_label, 0, 1) # Low confidence detections grid.addWidget(QLabel("Low conf (<0.5):"), 0, 2) self.low_conf_label = QLabel("0") - self.low_conf_label.setStyleSheet("color: #ff7f0e; font-weight: bold;") + self.low_conf_label.setStyleSheet(f"color: {CHART_ORANGE}; font-weight: bold;") grid.addWidget(self.low_conf_label, 0, 3) # Detection rate grid.addWidget(QLabel("Detection rate:"), 1, 0) self.detection_rate_label = QLabel("-") - self.detection_rate_label.setStyleSheet("color: #2ca02c; font-weight: bold;") + self.detection_rate_label.setStyleSheet(f"color: {CHART_GREEN}; font-weight: bold;") grid.addWidget(self.detection_rate_label, 1, 1) # Reset button @@ -411,12 +410,12 @@ class RollingStatsChart(QWidget): # Detection count series (left Y axis) self.count_series = QLineSeries() self.count_series.setName("Detections") - self.count_series.setPen(QPen(QColor("#1f77b4"), 2)) + self.count_series.setPen(QPen(qcolor(CHART_BLUE), 2)) # Mean confidence series (right Y axis) self.conf_series = QLineSeries() self.conf_series.setName("Mean Confidence") - self.conf_series.setPen(QPen(QColor("#2ca02c"), 2)) + self.conf_series.setPen(QPen(qcolor(CHART_GREEN), 2)) self.chart = QChart() self.chart.addSeries(self.count_series) @@ -554,7 +553,7 @@ class PredictionMetricsPanel(QWidget): controls.addStretch() self.status_label = QLabel("Waiting for predictions...") - self.status_label.setStyleSheet("color: #888;") + self.status_label.setStyleSheet(f"color: {CHART_MUTED};") controls.addWidget(self.status_label) layout.addLayout(controls) @@ -633,14 +632,14 @@ class PredictionMetricsPanel(QWidget): """Update status label.""" if self._paused: self.status_label.setText("Paused") - self.status_label.setStyleSheet("color: #ff7f0e;") + self.status_label.setStyleSheet(f"color: {CHART_ORANGE};") elif self._history: count = len(self._history) self.status_label.setText(f"Live: {count} frames recorded") - self.status_label.setStyleSheet("color: #2ca02c;") + self.status_label.setStyleSheet(f"color: {CHART_GREEN};") else: self.status_label.setText("Waiting for predictions...") - self.status_label.setStyleSheet("color: #888;") + self.status_label.setStyleSheet(f"color: {CHART_MUTED};") def showEvent(self, event) -> None: super().showEvent(event) diff --git a/src/aare/gui/panels/raster_data_collection.py b/src/aare/gui/panels/raster_data_collection.py index bbe4a16e..8bd6ff74 100644 --- a/src/aare/gui/panels/raster_data_collection.py +++ b/src/aare/gui/panels/raster_data_collection.py @@ -15,6 +15,7 @@ from PySide6.QtWidgets import ( from aare.gui.constants import LOGGER_NAME from aare.gui.panels.scan_settings_panel import ScanSettingsPanel from aare.gui.scan_logic.raster_grid_manager import RasterGridManager, RasterGridMetric +from aare.gui.styles import GO_TEXT from aare.gui.widgets.number_line_edit import DbOverrideLineEdit from aare.gui.widgets.raster_grid_table import RasterGridTable @@ -143,12 +144,12 @@ class RasterDataCollectionPanel(ScanSettingsPanel): self.calculate_total_time() self.start_button = QPushButton("Evaluate grid") - self.start_button.setStyleSheet("color: rgb(78, 154, 6);") + self.start_button.setStyleSheet(f"color: {GO_TEXT};") self.start_button.clicked.connect(self._on_evaluate_clicked) self._layout.addWidget(self.start_button, 12, 0, 1, 5) self.auto_button = QPushButton("X-ray Centering") - self.auto_button.setStyleSheet("color: rgb(78, 154, 6);") + self.auto_button.setStyleSheet(f"color: {GO_TEXT};") self.auto_button.clicked.connect(self._on_evaluate_auto_clicked) self._layout.addWidget(self.auto_button, 13, 0, 1, 5) self._reset_to_defaults() diff --git a/src/aare/gui/panels/reference_tools_panel.py b/src/aare/gui/panels/reference_tools_panel.py index 8708c91b..d056235a 100644 --- a/src/aare/gui/panels/reference_tools_panel.py +++ b/src/aare/gui/panels/reference_tools_panel.py @@ -3,7 +3,7 @@ from aarecommon.config.logger import setup_logger from aarecommon.models.models import DAQStatusModel, SampleShortInfo, SampleShortInfoList from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal, Slot -from PySide6.QtGui import QBrush, QColor +from PySide6.QtGui import QBrush from PySide6.QtWidgets import ( QAbstractItemView, QFrame, @@ -16,6 +16,7 @@ from PySide6.QtWidgets import ( ) from aare.gui.constants import LOGGER_NAME +from aare.gui.styles import SAMPLE_ROW_QUEUED_BG, WHITE, qcolor from aare.gui.widgets.title_label import TitleLabel logger = setup_logger(LOGGER_NAME) @@ -83,8 +84,8 @@ class ReferenceToolsModel(QAbstractTableModel): return Qt.AlignmentFlag.AlignCenter elif role == Qt.ItemDataRole.BackgroundRole: if self._sorted_samples[index.row()].db_id == self.current_reference: - return QBrush(QColor(114, 159, 207)) # darker blue - return QBrush(QColor(255, 255, 255)) # white + return QBrush(qcolor(SAMPLE_ROW_QUEUED_BG)) + return QBrush(qcolor(WHITE)) return None diff --git a/src/aare/gui/panels/rotation_data_collection.py b/src/aare/gui/panels/rotation_data_collection.py index 0e8dacf4..774fe9f5 100644 --- a/src/aare/gui/panels/rotation_data_collection.py +++ b/src/aare/gui/panels/rotation_data_collection.py @@ -10,6 +10,7 @@ from PySide6.QtWidgets import QComboBox, QLabel, QMessageBox, QPushButton from aare.gui.constants import LOGGER_NAME from aare.gui.panels.scan_settings_panel import ScanSettingsPanel +from aare.gui.styles import GO_TEXT from aare.gui.widgets.number_line_edit import DbOverrideLineEdit, NumberLineEdit logger = setup_logger(LOGGER_NAME) @@ -96,7 +97,7 @@ class RotationDataCollectionPanel(ScanSettingsPanel): self._layout.addWidget(self.screening_type, 7, 0, 1, 6) self.screening_button = QPushButton("Run screening") - self.screening_button.setStyleSheet("color: rgb(78, 154, 6);") + self.screening_button.setStyleSheet(f"color: {GO_TEXT};") self.screening_button.clicked.connect(self.run_screening) self._layout.addWidget(self.screening_button, 8, 0, 1, 6) @@ -160,7 +161,7 @@ class RotationDataCollectionPanel(ScanSettingsPanel): self.reload_params_button.setVisible(True) self.measurement_button = QPushButton("Run rotation") - self.measurement_button.setStyleSheet("color: rgb(78, 154, 6);") + self.measurement_button.setStyleSheet(f"color: {GO_TEXT};") self.measurement_button.clicked.connect(self.run_measurement) self._layout.addWidget(self.measurement_button, 16, 0, 1, 6) self._reset_to_defaults() diff --git a/src/aare/gui/panels/samcam_panel.py b/src/aare/gui/panels/samcam_panel.py index 83ad9777..77dfd9e7 100644 --- a/src/aare/gui/panels/samcam_panel.py +++ b/src/aare/gui/panels/samcam_panel.py @@ -12,6 +12,7 @@ from PySide6.QtWidgets import ( QWidget, ) +from aare.gui.styles import INPUT_BG from aare.gui.widgets.title_label import TitleLabel @@ -43,7 +44,7 @@ class SamcamPanel(QWidget): self.exposure_spinbox = QDoubleSpinBox() self.exposure_spinbox.setRange(0, 1.0) # Adjust range as needed self.exposure_spinbox.setSingleStep(0.001) - self.exposure_spinbox.setStyleSheet("QDoubleSpinBox { background-color: white; }") + self.exposure_spinbox.setStyleSheet(f"QDoubleSpinBox {{ background-color: {INPUT_BG}; }}") self.exposure_spinbox.setDecimals(3) self.exposure_spinbox.valueChanged.connect(self._changed) @@ -57,7 +58,7 @@ class SamcamPanel(QWidget): self.gain_spinbox.setRange(0, 1000) # Adjust range as needed self.gain_spinbox.setSingleStep(1) self.gain_spinbox.setDecimals(1) - self.gain_spinbox.setStyleSheet("QDoubleSpinBox { background-color: white; }") + self.gain_spinbox.setStyleSheet(f"QDoubleSpinBox {{ background-color: {INPUT_BG}; }}") self.gain_spinbox.valueChanged.connect(self._changed) gain_layout.addWidget(gain_label) gain_layout.addWidget(self.gain_spinbox) @@ -71,7 +72,9 @@ class SamcamPanel(QWidget): screenshot_filename_label = QLabel("Filename:") self.screenshot_filename_edit = QLineEdit() self.screenshot_filename_edit.setPlaceholderText("optional") - self.screenshot_filename_edit.setStyleSheet("QLineEdit { background-color: white; }") + self.screenshot_filename_edit.setStyleSheet( + f"QLineEdit {{ background-color: {INPUT_BG}; }}" + ) screenshot_filename_layout.addWidget(screenshot_filename_label) screenshot_filename_layout.addWidget(self.screenshot_filename_edit) @@ -79,7 +82,7 @@ class SamcamPanel(QWidget): screenshot_message_label = QLabel("Message:") self.screenshot_message_edit = QLineEdit() self.screenshot_message_edit.setPlaceholderText("optional") - self.screenshot_message_edit.setStyleSheet("QLineEdit { background-color: white; }") + self.screenshot_message_edit.setStyleSheet(f"QLineEdit {{ background-color: {INPUT_BG}; }}") screenshot_message_layout.addWidget(screenshot_message_label) screenshot_message_layout.addWidget(self.screenshot_message_edit) diff --git a/src/aare/gui/panels/smart_rotation_panel.py b/src/aare/gui/panels/smart_rotation_panel.py index 1a49a873..5b493d74 100644 --- a/src/aare/gui/panels/smart_rotation_panel.py +++ b/src/aare/gui/panels/smart_rotation_panel.py @@ -8,6 +8,7 @@ from PySide6.QtWidgets import QGridLayout, QLabel, QPushButton, QSizePolicy, QSp from aare.gui.constants import LOGGER_NAME from aare.gui.panels.rotation_data_collection import add_data_to_path +from aare.gui.styles import GO_TEXT, STATUS_ALERT from aare.gui.widgets.number_line_edit import NumberLineEdit logger = setup_logger(LOGGER_NAME) @@ -205,7 +206,7 @@ class SimpleRotationSettingsPanel(QWidget): # Run rotation button self.run_rotation_button = QPushButton("Run rotation", parent=self) - self.run_rotation_button.setStyleSheet("color: rgb(78, 154, 6);") + self.run_rotation_button.setStyleSheet(f"color: {GO_TEXT};") self.run_rotation_button.clicked.connect(self.run_measurement) self._layout.addWidget(self.run_rotation_button, 22, 0, 1, 6) @@ -355,9 +356,11 @@ class SimpleRotationSettingsPanel(QWidget): self.image_time_label.setText(f"{self.image_time_s:.4f}") if self.dtz <= 0.0: - self.dtz_label.setText("""-""") + self.dtz_label.setText(f"""-""") elif self.dtz < self._d.bl.dtz_min: - self.dtz_label.setText(f"""{self.dtz:.2f}""") + self.dtz_label.setText( + f"""{self.dtz:.2f}""" + ) self.dtz = self._d.bl.dtz_min else: self.dtz_label.setText(f"{self.dtz:.2f}") diff --git a/src/aare/gui/panels/status_panel.py b/src/aare/gui/panels/status_panel.py index 971f2efc..31f77b69 100644 --- a/src/aare/gui/panels/status_panel.py +++ b/src/aare/gui/panels/status_panel.py @@ -4,6 +4,7 @@ from PySide6.QtCore import Qt, Slot from PySide6.QtGui import QPixmap from PySide6.QtWidgets import QFrame, QGridLayout, QLabel, QSizePolicy, QSpacerItem +from aare.gui.styles import STATUS_ALERT from aare.gui.widgets.status_label import StatusLabel from aare.gui.widgets.title_label import TitleLabel @@ -65,7 +66,7 @@ class StatusPanel(QFrame): if s.bl.ring_current_mA < 390.0: self.ring_current.setText( - f'{s.bl.ring_current_mA:.1f}' + f'{s.bl.ring_current_mA:.1f}' ) else: self.ring_current.setText(f"{s.bl.ring_current_mA:.1f}") diff --git a/src/aare/gui/panels/target_stability_panel.py b/src/aare/gui/panels/target_stability_panel.py index fdbeb00d..03757327 100644 --- a/src/aare/gui/panels/target_stability_panel.py +++ b/src/aare/gui/panels/target_stability_panel.py @@ -23,6 +23,18 @@ from PySide6.QtWidgets import ( ) from aare.gui.constants import LOGGER_NAME +from aare.gui.styles import ( + CHART_BLUE, + CHART_BLUE_LIGHT, + CHART_BLUE_PALE, + CHART_CYAN, + CHART_GREEN, + CHART_ORANGE, + CHART_PURPLE, + CHART_RED, + CHART_RED_DARK, + CHART_RED_LIGHT, +) logger = setup_logger(LOGGER_NAME) @@ -97,19 +109,19 @@ class InteractiveChartView(QChartView): class TargetStabilityPanel(QWidget): - SIGMA_COLOR = "#1f77b4" - SIGMA_X_COLOR = "#6baed6" - SIGMA_Y_COLOR = "#9ecae1" + SIGMA_COLOR = CHART_BLUE + SIGMA_X_COLOR = CHART_BLUE_LIGHT + SIGMA_Y_COLOR = CHART_BLUE_PALE - DISTANCE_COLOR = "#d62728" - DX_COLOR = "#ff9896" - DY_COLOR = "#c43c39" + DISTANCE_COLOR = CHART_RED + DX_COLOR = CHART_RED_LIGHT + DY_COLOR = CHART_RED_DARK - SCORE_COLOR = "#ff7f0e" - STEP_COLOR = "#17becf" + SCORE_COLOR = CHART_ORANGE + STEP_COLOR = CHART_CYAN - TARGET_COLOR = "#2ca02c" - BEAM_COLOR = "#9467bd" + TARGET_COLOR = CHART_GREEN + BEAM_COLOR = CHART_PURPLE SCORE_FROM_STEP_XY = "Step XY" SCORE_FROM_SIGMA_XY = "Sigma XY" diff --git a/src/aare/gui/panels/tell_sample_panel.py b/src/aare/gui/panels/tell_sample_panel.py index 035c487f..81b2d555 100644 --- a/src/aare/gui/panels/tell_sample_panel.py +++ b/src/aare/gui/panels/tell_sample_panel.py @@ -19,6 +19,7 @@ from PySide6.QtWidgets import ( from aare.gui.constants import LOGGER_NAME from aare.gui.models.user_sample_model import UserSampleSpreadsheet +from aare.gui.styles import NOTE_TEXT from aare.gui.widgets.title_label import TitleLabel logger = setup_logger(LOGGER_NAME) @@ -241,7 +242,7 @@ class TellSamplePanel(QFrame): if tell_details: self.curr_sample_label.setText( - f"{base_text}
TELL: {tell_details}" + f"{base_text}
TELL: {tell_details}" ) else: self.curr_sample_label.setText(base_text) diff --git a/src/aare/gui/scan_logic/raster_grid_manager.py b/src/aare/gui/scan_logic/raster_grid_manager.py index c6d7d163..0457ee81 100644 --- a/src/aare/gui/scan_logic/raster_grid_manager.py +++ b/src/aare/gui/scan_logic/raster_grid_manager.py @@ -17,6 +17,7 @@ from PySide6.QtCore import QLineF, QObject, QPointF, QRect, QRectF, Qt, Signal, from PySide6.QtGui import QBrush, QColor, QImage, QPainter, QPen from aare.gui.constants import LOGGER_NAME +from aare.gui.styles import RASTER_GRID_LINE, qcolor logger = setup_logger(LOGGER_NAME) @@ -643,7 +644,9 @@ class RasterGridManager(QObject): painter.drawImage(bounds, image) painter.setOpacity(1.0) - painter.setPen(QPen(QColor(114, 159, 207, min(255, alpha + 40)), 1, Qt.PenStyle.SolidLine)) + painter.setPen( + QPen(qcolor(RASTER_GRID_LINE, min(255, alpha + 40)), 1, Qt.PenStyle.SolidLine) + ) painter.setBrush(Qt.BrushStyle.NoBrush) painter.drawRect(bounds) painter.restore() @@ -697,7 +700,7 @@ class RasterGridManager(QObject): max_value = max((x for x in values if x is not None and not math.isnan(x)), default=1) diff = 1 if min_value == max_value else (max_value - min_value) else: - painter.setPen(QPen(QColor(114, 159, 207), 1, Qt.PenStyle.SolidLine)) + painter.setPen(QPen(qcolor(RASTER_GRID_LINE), 1, Qt.PenStyle.SolidLine)) min_value = 0 diff = 1 @@ -732,7 +735,7 @@ class RasterGridManager(QObject): painter.setBrush(Qt.BrushStyle.NoBrush) else: painter.setPen( - QPen(QColor(114, 159, 207, min(255, alpha + 40)), 1, Qt.PenStyle.SolidLine) + QPen(qcolor(RASTER_GRID_LINE, min(255, alpha + 40)), 1, Qt.PenStyle.SolidLine) ) painter.setBrush(Qt.BrushStyle.NoBrush) painter.drawRect(bounds) @@ -760,7 +763,7 @@ class RasterGridManager(QObject): painter.save() painter.setRenderHint(QPainter.RenderHint.Antialiasing, False) - painter.setPen(QPen(QColor(114, 159, 207), 1, Qt.PenStyle.SolidLine)) + painter.setPen(QPen(qcolor(RASTER_GRID_LINE), 1, Qt.PenStyle.SolidLine)) painter.setBrush(Qt.BrushStyle.NoBrush) painter.drawRect(bounds) @@ -854,7 +857,9 @@ class RasterGridManager(QObject): brush = float_to_viridis_brush((value - min_value) / diff, alpha=alpha) painter.fillRect(QRectF(px, py, draw_w, draw_h), brush) - painter.setPen(QPen(QColor(114, 159, 207, min(255, alpha + 40)), 1, Qt.PenStyle.SolidLine)) + painter.setPen( + QPen(qcolor(RASTER_GRID_LINE, min(255, alpha + 40)), 1, Qt.PenStyle.SolidLine) + ) painter.setBrush(Qt.BrushStyle.NoBrush) painter.drawRect(bounds) diff --git a/tests/unit/gui/test_beamline_state_panel.py b/tests/unit/gui/test_beamline_state_panel.py index 1c42d411..3e9bce74 100644 --- a/tests/unit/gui/test_beamline_state_panel.py +++ b/tests/unit/gui/test_beamline_state_panel.py @@ -2,6 +2,7 @@ from aarecommon.models.models import BeamlineStateEnum from PySide6.QtCore import Qt from aare.gui.panels.beamline_state_panel import BeamlineStatePanel +from aare.gui.styles import STATE_AVAILABLE, STATE_MSG_ERROR, STATE_MSG_INFO, STATE_UNAVAILABLE def _panel(qtbot): @@ -47,7 +48,7 @@ def test_active_maintenance_is_red_and_bold(qtbot): panel.set_current_state(BeamlineStateEnum.Maintenance) maintenance = panel._buttons[BeamlineStateEnum.Maintenance] assert maintenance.font().bold() - assert "rgb(200, 30, 30)" in maintenance.styleSheet() + assert STATE_MSG_ERROR in maintenance.styleSheet() def test_availability_palette_and_cursors(qtbot): @@ -55,10 +56,10 @@ def test_availability_palette_and_cursors(qtbot): panel.set_current_state(BeamlineStateEnum.Maintenance) available = panel._buttons[BeamlineStateEnum.SampleExchange] grey = panel._buttons[BeamlineStateEnum.FluxMeasurement] - assert "rgb(237, 137, 54)" in available.styleSheet() + assert STATE_AVAILABLE in available.styleSheet() assert available.cursor().shape() == Qt.CursorShape.PointingHandCursor assert not available.font().bold() - assert "rgb(140, 150, 165)" in grey.styleSheet() + assert STATE_UNAVAILABLE in grey.styleSheet() assert grey.cursor().shape() == Qt.CursorShape.ForbiddenCursor assert grey.toolTip() == "" assert available.toolTip() != "" @@ -69,7 +70,7 @@ def test_active_non_maintenance_is_blue(qtbot): panel.set_current_state(BeamlineStateEnum.SampleAlignment) active = panel._buttons[BeamlineStateEnum.SampleAlignment] assert active.font().bold() - assert "rgb(0, 92, 170)" in active.styleSheet() + assert STATE_MSG_INFO in active.styleSheet() def test_labels_wrap_when_narrow_and_unwrap_when_wide(qtbot): diff --git a/tests/unit/gui/test_panels.py b/tests/unit/gui/test_panels.py index 424fcc78..d6e1113f 100644 --- a/tests/unit/gui/test_panels.py +++ b/tests/unit/gui/test_panels.py @@ -11,6 +11,7 @@ from aarecommon.models.models import ( ) from aare.gui.panels.status_panel import StatusPanel +from aare.gui.styles import STATUS_ALERT @pytest.fixture @@ -84,5 +85,5 @@ def test_status_panel_low_current(qtbot, mock_daq_status): mock_daq_status.bl.ring_current_mA = 300.0 panel.update_daq_status(mock_daq_status) - assert "color: red" in panel.ring_current.text() + assert f"color: {STATUS_ALERT}" in panel.ring_current.text() assert "300.0" in panel.ring_current.text() -- 2.54.0 From 590e500da5f7ed22b4d1ac515356dff13b06992b Mon Sep 17 00:00:00 2001 From: Dawn Date: Fri, 7 Aug 2026 10:05:03 +0200 Subject: [PATCH 13/57] style: move widget colors to the shared palette Same mechanical swap for the widgets and the tutorial manager: painter QColors go through qcolor(), inline QSS literals become palette constants (baton dialog, busy overlay, status bar, camera overlays, splash screen, numeric inputs). Co-Authored-By: Claude Fable 5 --- src/aare/gui/tutorials/tutorial_manager.py | 25 ++- src/aare/gui/widgets/alert_banner.py | 4 +- src/aare/gui/widgets/automation_progress.py | 30 ++-- src/aare/gui/widgets/baton_request_dialog.py | 150 ++++++++++-------- src/aare/gui/widgets/busy_overlay.py | 109 ++++++++----- src/aare/gui/widgets/camera_image.py | 127 ++++++++------- .../widgets/local_contact_status_widget.py | 44 +++-- src/aare/gui/widgets/login.py | 4 +- src/aare/gui/widgets/number_line_edit.py | 22 +-- src/aare/gui/widgets/pgroup_dialog.py | 8 +- src/aare/gui/widgets/raster_grid_table.py | 13 +- src/aare/gui/widgets/splash_screen.py | 22 +-- src/aare/gui/widgets/status_bar.py | 51 +++--- src/aare/gui/widgets/title_label.py | 8 +- 14 files changed, 361 insertions(+), 256 deletions(-) diff --git a/src/aare/gui/tutorials/tutorial_manager.py b/src/aare/gui/tutorials/tutorial_manager.py index 87745a4d..24187eec 100644 --- a/src/aare/gui/tutorials/tutorial_manager.py +++ b/src/aare/gui/tutorials/tutorial_manager.py @@ -15,10 +15,19 @@ from PySide6.QtCore import ( QTimer, Signal, ) -from PySide6.QtGui import QColor, QPainter, QPen +from PySide6.QtGui import QPainter, QPen from PySide6.QtWidgets import QLabel, QPushButton, QWidget from aare.gui.constants import LOGGER_NAME +from aare.gui.styles import ( + DEFAULT_TEXT, + NOTE_TEXT, + SHADOW, + TUTORIAL_BORDER, + TUTORIAL_HIGHLIGHT, + WHITE, + qcolor, +) from aare.gui.tutorials.tutorial_models import ( StepFlow, StepStatus, @@ -95,11 +104,11 @@ class TutorialOverlay(QWidget): self.anim.valueChanged.connect(self.update) self.callout = QLabel(self) - self.callout.setStyleSheet(""" - background: white; - color: black; + self.callout.setStyleSheet(f""" + background: {WHITE}; + color: {DEFAULT_TEXT}; padding: 16px; - border: 2px solid #555; + border: 2px solid {TUTORIAL_BORDER}; border-radius: 10px; font-size: 16px; """) @@ -185,10 +194,10 @@ class TutorialOverlay(QWidget): def paintEvent(self, event) -> None: painter = QPainter(self) - painter.fillRect(self.rect(), QColor(0, 0, 0, int(150 * self.opacity))) + painter.fillRect(self.rect(), qcolor(SHADOW, int(150 * self.opacity))) if self.current_rect.isValid(): - pen = QPen(Qt.yellow, 4) + pen = QPen(qcolor(TUTORIAL_HIGHLIGHT), 4) painter.setPen(pen) painter.setBrush(Qt.NoBrush) painter.drawRoundedRect(self.current_rect, 8, 8) @@ -219,7 +228,7 @@ class TutorialOverlay(QWidget): if view.body.strip(): text_parts.append(view.body) if view.hint: - text_parts.append(f"{view.hint}") + text_parts.append(f"{view.hint}") self.callout.setText("

".join(text_parts)) self.callout.setMaximumWidth(460) diff --git a/src/aare/gui/widgets/alert_banner.py b/src/aare/gui/widgets/alert_banner.py index aaf0ae4d..9f4fab21 100644 --- a/src/aare/gui/widgets/alert_banner.py +++ b/src/aare/gui/widgets/alert_banner.py @@ -1,9 +1,9 @@ from aarecommon.config.logger import setup_logger from PySide6.QtCore import Qt, QTimer, Slot -from PySide6.QtGui import QColor from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QHBoxLayout, QLabel, QSizePolicy from aare.gui.constants import LOGGER_NAME +from aare.gui.styles import SHADOW, qcolor logger = setup_logger(LOGGER_NAME) @@ -39,7 +39,7 @@ class AlertBanner(QFrame): shadow = QGraphicsDropShadowEffect(self) shadow.setBlurRadius(18) shadow.setOffset(0, 3) - shadow.setColor(QColor(0, 0, 0, 55)) + shadow.setColor(qcolor(SHADOW, 55)) self.setGraphicsEffect(shadow) self.setVisible(False) diff --git a/src/aare/gui/widgets/automation_progress.py b/src/aare/gui/widgets/automation_progress.py index 4c88baab..a1f000c2 100644 --- a/src/aare/gui/widgets/automation_progress.py +++ b/src/aare/gui/widgets/automation_progress.py @@ -7,6 +7,14 @@ from aarecommon.models.automation import AutomationProgress, StepStatus, Workflo from PySide6.QtCore import QTimer, Slot from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout, QWidget +from aare.gui.styles import ( + FAINT_TEXT, + STEP_FAILED_TEXT, + STEP_PAUSED_TEXT, + STEP_RUNNING_TEXT, + STEP_SUCCESS_TEXT, +) + class CompactAutomationProgressStrip(QFrame): DEFAULT_SAMPLE_ESTIMATE_S = 150.0 @@ -89,13 +97,13 @@ class CompactAutomationProgressStrip(QFrame): @staticmethod def _step_color(status: StepStatus) -> str: return { - StepStatus.PENDING: "#64748b", - StepStatus.RUNNING: "#2563eb", - StepStatus.SUCCESS: "#15803d", - StepStatus.FAILED: "#b91c1c", - StepStatus.SKIPPED: "#64748b", - StepStatus.PAUSED: "#c2410c", - }.get(status, "#64748b") + StepStatus.PENDING: FAINT_TEXT, + StepStatus.RUNNING: STEP_RUNNING_TEXT, + StepStatus.SUCCESS: STEP_SUCCESS_TEXT, + StepStatus.FAILED: STEP_FAILED_TEXT, + StepStatus.SKIPPED: FAINT_TEXT, + StepStatus.PAUSED: STEP_PAUSED_TEXT, + }.get(status, FAINT_TEXT) def _format_step_html(self, step: WorkflowStateKind, status: StepStatus) -> str: color = self._step_color(status) @@ -163,16 +171,16 @@ class CompactAutomationProgressStrip(QFrame): eta = time.time() + queue_remaining if queue_remaining > 0 else None state_text = "Paused" - state_color = "#c2410c" + state_color = STEP_PAUSED_TEXT if progress.finished and progress.success is True: state_text = "Completed" - state_color = "#15803d" + state_color = STEP_SUCCESS_TEXT elif progress.finished and progress.success is False: state_text = "Failed" - state_color = "#b91c1c" + state_color = STEP_FAILED_TEXT elif self._running: state_text = "Running" - state_color = "#2563eb" + state_color = STEP_RUNNING_TEXT self._summary_label.setText( f"Status: " diff --git a/src/aare/gui/widgets/baton_request_dialog.py b/src/aare/gui/widgets/baton_request_dialog.py index 4ec6ae4d..0d86aa90 100644 --- a/src/aare/gui/widgets/baton_request_dialog.py +++ b/src/aare/gui/widgets/baton_request_dialog.py @@ -10,6 +10,22 @@ from PySide6.QtWidgets import ( QVBoxLayout, ) +from aare.gui.styles import ( + BATON_DANGER_BG, + BATON_DANGER_HOVER, + BATON_DANGER_PRESSED, + BATON_INFO, + BATON_OK_BG, + BATON_OK_HOVER, + BATON_OK_PRESSED, + BATON_WARN, + DIM_TEXT, + HINT_TEXT, + LIGHT_BORDER, + PROGRESS_TRACK_BG, + WHITE, +) + class BatonRequestDialog(QDialog): """ @@ -73,22 +89,22 @@ class BatonRequestDialog(QDialog): self.progress.setValue(self._timeout) self.progress.setTextVisible(False) self.progress.setFixedHeight(8) - self.progress.setStyleSheet(""" - QProgressBar { - border: 1px solid #ccc; + self.progress.setStyleSheet(f""" + QProgressBar {{ + border: 1px solid {LIGHT_BORDER}; border-radius: 4px; - background-color: #f0f0f0; - } - QProgressBar::chunk { - background-color: #4CAF50; + background-color: {PROGRESS_TRACK_BG}; + }} + QProgressBar::chunk {{ + background-color: {BATON_OK_BG}; border-radius: 3px; - } + }} """) progress_layout.addWidget(self.progress) self.time_label = QLabel(f"{self._timeout} seconds remaining") self.time_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.time_label.setStyleSheet("color: #666;") + self.time_label.setStyleSheet(f"color: {DIM_TEXT};") progress_layout.addWidget(self.time_label) layout.addLayout(progress_layout) @@ -97,7 +113,7 @@ class BatonRequestDialog(QDialog): self.warning_label = QLabel("⚠️ If you don't respond, control will transfer automatically.") self.warning_label.setWordWrap(True) self.warning_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.warning_label.setStyleSheet("color: #ff9800; font-style: italic;") + self.warning_label.setStyleSheet(f"color: {BATON_WARN}; font-style: italic;") layout.addWidget(self.warning_label) # Buttons @@ -106,42 +122,42 @@ class BatonRequestDialog(QDialog): self.accept_btn = QPushButton("✓ Accept") self.accept_btn.setMinimumHeight(40) - self.accept_btn.setStyleSheet(""" - QPushButton { - background-color: #4CAF50; - color: white; + self.accept_btn.setStyleSheet(f""" + QPushButton {{ + background-color: {BATON_OK_BG}; + color: {WHITE}; border: none; border-radius: 5px; font-weight: bold; font-size: 13px; - } - QPushButton:hover { - background-color: #45a049; - } - QPushButton:pressed { - background-color: #3d8b40; - } + }} + QPushButton:hover {{ + background-color: {BATON_OK_HOVER}; + }} + QPushButton:pressed {{ + background-color: {BATON_OK_PRESSED}; + }} """) self.accept_btn.clicked.connect(self._on_accept) button_layout.addWidget(self.accept_btn) self.refuse_btn = QPushButton("✗ Refuse") self.refuse_btn.setMinimumHeight(40) - self.refuse_btn.setStyleSheet(""" - QPushButton { - background-color: #f44336; - color: white; + self.refuse_btn.setStyleSheet(f""" + QPushButton {{ + background-color: {BATON_DANGER_BG}; + color: {WHITE}; border: none; border-radius: 5px; font-weight: bold; font-size: 13px; - } - QPushButton:hover { - background-color: #da190b; - } - QPushButton:pressed { - background-color: #c41000; - } + }} + QPushButton:hover {{ + background-color: {BATON_DANGER_HOVER}; + }} + QPushButton:pressed {{ + background-color: {BATON_DANGER_PRESSED}; + }} """) self.refuse_btn.clicked.connect(self._on_refuse) button_layout.addWidget(self.refuse_btn) @@ -155,7 +171,7 @@ class BatonRequestDialog(QDialog): ) info_label.setWordWrap(True) info_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - info_label.setStyleSheet("color: #999;") + info_label.setStyleSheet(f"color: {HINT_TEXT};") layout.addWidget(info_label) def _start_timer(self): @@ -171,31 +187,31 @@ class BatonRequestDialog(QDialog): # Change progress bar color as time runs out if self._remaining <= 10: - self.progress.setStyleSheet(""" - QProgressBar { - border: 1px solid #ccc; + self.progress.setStyleSheet(f""" + QProgressBar {{ + border: 1px solid {LIGHT_BORDER}; border-radius: 4px; - background-color: #f0f0f0; - } - QProgressBar::chunk { - background-color: #ff9800; + background-color: {PROGRESS_TRACK_BG}; + }} + QProgressBar::chunk {{ + background-color: {BATON_WARN}; border-radius: 3px; - } + }} """) if self._remaining <= 5: - self.progress.setStyleSheet(""" - QProgressBar { - border: 1px solid #ccc; + self.progress.setStyleSheet(f""" + QProgressBar {{ + border: 1px solid {LIGHT_BORDER}; border-radius: 4px; - background-color: #f0f0f0; - } - QProgressBar::chunk { - background-color: #f44336; + background-color: {PROGRESS_TRACK_BG}; + }} + QProgressBar::chunk {{ + background-color: {BATON_DANGER_BG}; border-radius: 3px; - } + }} """) - self.time_label.setStyleSheet("color: #f44336; font-weight: bold;") + self.time_label.setStyleSheet(f"color: {BATON_DANGER_BG}; font-weight: bold;") if self._remaining <= 0: self._timer.stop() @@ -270,22 +286,22 @@ class BatonPendingDialog(QDialog): self.progress.setValue(self._timeout) self.progress.setTextVisible(False) self.progress.setFixedHeight(8) - self.progress.setStyleSheet(""" - QProgressBar { - border: 1px solid #ccc; + self.progress.setStyleSheet(f""" + QProgressBar {{ + border: 1px solid {LIGHT_BORDER}; border-radius: 4px; - background-color: #f0f0f0; - } - QProgressBar::chunk { - background-color: #2196F3; + background-color: {PROGRESS_TRACK_BG}; + }} + QProgressBar::chunk {{ + background-color: {BATON_INFO}; border-radius: 3px; - } + }} """) self.progress_layout.addWidget(self.progress) self.time_label = QLabel(f"{self._timeout} seconds remaining") self.time_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.time_label.setStyleSheet("color: #666;") + self.time_label.setStyleSheet(f"color: {DIM_TEXT};") self.progress_layout.addWidget(self.time_label) layout.addLayout(self.progress_layout) @@ -293,17 +309,17 @@ class BatonPendingDialog(QDialog): button_layout = QHBoxLayout() self.cancel_btn = QPushButton("✗ Cancel Request") self.cancel_btn.setMinimumHeight(40) - self.cancel_btn.setStyleSheet(""" - QPushButton { - background-color: #f44336; - color: white; + self.cancel_btn.setStyleSheet(f""" + QPushButton {{ + background-color: {BATON_DANGER_BG}; + color: {WHITE}; border: none; border-radius: 5px; font-weight: bold; font-size: 13px; - } - QPushButton:hover { background-color: #da190b; } - QPushButton:pressed { background-color: #c41000; } + }} + QPushButton:hover {{ background-color: {BATON_DANGER_HOVER}; }} + QPushButton:pressed {{ background-color: {BATON_DANGER_PRESSED}; }} """) self.cancel_btn.clicked.connect(self._on_cancel) button_layout.addWidget(self.cancel_btn) @@ -332,7 +348,7 @@ class BatonPendingDialog(QDialog): def set_queued_state(self): self._timer.stop() self.header.setText("⏳ Transfer Queued") - self.header.setStyleSheet("color: #FF9800; font-weight: bold; font-size: 14px;") + self.header.setStyleSheet(f"color: {BATON_WARN}; font-weight: bold; font-size: 14px;") self.message_label.setText( "The beamline is currently busy. Your request was accepted and " "the baton will be transferred as soon as the current operation completes." diff --git a/src/aare/gui/widgets/busy_overlay.py b/src/aare/gui/widgets/busy_overlay.py index d95d5a20..d344a2d1 100644 --- a/src/aare/gui/widgets/busy_overlay.py +++ b/src/aare/gui/widgets/busy_overlay.py @@ -4,6 +4,31 @@ from aarecommon.models.models import SessionsStateEnum from aarecommon.models.tell import TellStateModel from PySide6.QtGui import QColor +from aare.gui.styles import ( + BUSY_BLUE, + BUSY_BLUE_BORDER, + BUSY_BLUE_DOT, + BUSY_ORANGE, + BUSY_ORANGE_BORDER, + BUSY_ORANGE_DOT, + BUSY_PSI_RED, + BUSY_PSI_RED_BORDER, + BUSY_PSI_RED_DOT, + BUSY_PURPLE, + BUSY_PURPLE_BORDER, + BUSY_PURPLE_DOT, + BUSY_RED_BADGE, + BUSY_RED_BORDER, + BUSY_RED_DOT, + BUSY_RED_FILL, + BUSY_YELLOW, + BUSY_YELLOW_BORDER, + BUSY_YELLOW_DOT, + BUSY_YELLOW_TEXT_DARK, + WHITE, + qcolor, +) + @dataclass(frozen=True) class BusyOverlayStyle: @@ -25,23 +50,23 @@ def build_busy_overlay_style( if session_state == SessionsStateEnum.Vacant: return BusyOverlayStyle( text="SESSION VACANT", - badge_bg="#f1c40f", - badge_fg="#ffffff", - overlay_fill=QColor(241, 196, 15, 195), - overlay_border=QColor(255, 248, 210, 235), - overlay_text=QColor(255, 255, 255), - accent_dot="#fff6bf", + badge_bg=BUSY_YELLOW, + badge_fg=WHITE, + overlay_fill=qcolor(BUSY_YELLOW, 195), + overlay_border=qcolor(BUSY_YELLOW_BORDER, 235), + overlay_text=qcolor(WHITE), + accent_dot=BUSY_YELLOW_DOT, ) if session_state in {SessionsStateEnum.OwnedByElse, SessionsStateEnum.PendingYouToElse}: return BusyOverlayStyle( text="GUEST MODE", - badge_bg="#8e44ad", - badge_fg="#ffffff", - overlay_fill=QColor(142, 68, 173, 190), - overlay_border=QColor(235, 220, 245, 230), - overlay_text=QColor(255, 255, 255), - accent_dot="#f0dfff", + badge_bg=BUSY_PURPLE, + badge_fg=WHITE, + overlay_fill=qcolor(BUSY_PURPLE, 190), + overlay_border=qcolor(BUSY_PURPLE_BORDER, 230), + overlay_text=qcolor(WHITE), + accent_dot=BUSY_PURPLE_DOT, ) if not is_busy: @@ -52,53 +77,53 @@ def build_busy_overlay_style( if activity_value == "mounting": return BusyOverlayStyle( text="ROBOT MOUNTING", - badge_bg="#d64545", - badge_fg="#ffffff", - overlay_fill=QColor(190, 40, 40, 185), - overlay_border=QColor(255, 220, 220, 230), - overlay_text=QColor(255, 255, 255), - accent_dot="#ffdddd", + badge_bg=BUSY_RED_BADGE, + badge_fg=WHITE, + overlay_fill=qcolor(BUSY_RED_FILL, 185), + overlay_border=qcolor(BUSY_RED_BORDER, 230), + overlay_text=qcolor(WHITE), + accent_dot=BUSY_RED_DOT, ) if activity_value == "unmounting": return BusyOverlayStyle( text="ROBOT UNMOUNTING", - badge_bg="#e67e22", - badge_fg="#ffffff", - overlay_fill=QColor(230, 126, 34, 190), - overlay_border=QColor(255, 234, 214, 230), - overlay_text=QColor(255, 255, 255), - accent_dot="#fff0db", + badge_bg=BUSY_ORANGE, + badge_fg=WHITE, + overlay_fill=qcolor(BUSY_ORANGE, 190), + overlay_border=qcolor(BUSY_ORANGE_BORDER, 230), + overlay_text=qcolor(WHITE), + accent_dot=BUSY_ORANGE_DOT, ) if activity_value == "drying": return BusyOverlayStyle( text="ROBOT DRYING", - badge_bg="#f1c40f", - badge_fg="#3b2f00", - overlay_fill=QColor(241, 196, 15, 195), - overlay_border=QColor(255, 248, 210, 235), - overlay_text=QColor(59, 47, 0), - accent_dot="#fff6bf", + badge_bg=BUSY_YELLOW, + badge_fg=BUSY_YELLOW_TEXT_DARK, + overlay_fill=qcolor(BUSY_YELLOW, 195), + overlay_border=qcolor(BUSY_YELLOW_BORDER, 235), + overlay_text=qcolor(BUSY_YELLOW_TEXT_DARK), + accent_dot=BUSY_YELLOW_DOT, ) if activity_value == "cooling": return BusyOverlayStyle( text="ROBOT COOLING", - badge_bg="#3498db", - badge_fg="#ffffff", - overlay_fill=QColor(52, 152, 219, 190), - overlay_border=QColor(220, 240, 255, 235), - overlay_text=QColor(255, 255, 255), - accent_dot="#dff2ff", + badge_bg=BUSY_BLUE, + badge_fg=WHITE, + overlay_fill=qcolor(BUSY_BLUE, 190), + overlay_border=qcolor(BUSY_BLUE_BORDER, 235), + overlay_text=qcolor(WHITE), + accent_dot=BUSY_BLUE_DOT, ) return BusyOverlayStyle( text="BEAMLINE BUSY", - badge_bg="#e04f39", - badge_fg="#ffffff", - overlay_fill=QColor(224, 79, 57, 195), - overlay_border=QColor(255, 225, 220, 235), - overlay_text=QColor(255, 255, 255), - accent_dot="#ffd8d1", + badge_bg=BUSY_PSI_RED, + badge_fg=WHITE, + overlay_fill=qcolor(BUSY_PSI_RED, 195), + overlay_border=qcolor(BUSY_PSI_RED_BORDER, 235), + overlay_text=qcolor(WHITE), + accent_dot=BUSY_PSI_RED_DOT, ) diff --git a/src/aare/gui/widgets/camera_image.py b/src/aare/gui/widgets/camera_image.py index 5d66ea2f..58014244 100644 --- a/src/aare/gui/widgets/camera_image.py +++ b/src/aare/gui/widgets/camera_image.py @@ -40,6 +40,26 @@ from PySide6.QtWidgets import ( from aare.gui.constants import LOGGER_NAME from aare.gui.models.bookmark import SmargonBookmarkList from aare.gui.scan_logic.raster_grid_manager import RasterGridManager +from aare.gui.styles import ( + BEAM_BUSY, + BEAM_IDLE, + BEAM_MARKING, + BEAM_OPEN, + CLASS_COLORS, + LEGEND_BG, + LEGEND_TEXT, + MARK_BADGE_BG, + MARK_TOOLTIP_GOLD, + MARK_TOOLTIP_ORANGE, + MARK_TOOLTIP_RED, + MARKER_GREEN, + PATH_END, + PATH_START, + TARGET_COLORS, + TOOLTIP_TEXT, + WHITE, + qcolor, +) from aare.gui.widgets.busy_overlay import BusyOverlayStyle, build_busy_overlay_style logger = setup_logger(LOGGER_NAME) @@ -301,13 +321,13 @@ class SampleCameraImageLabel(QGraphicsView): painter.setFont(font) if self._session_state == SessionsStateEnum.Vacant: - bg_color = QColor(255, 215, 0, 180) + bg_color = qcolor(MARK_TOOLTIP_GOLD, 180) text = "Session Vacant" elif self._session_state == SessionsStateEnum.PendingYouToElse: - bg_color = QColor(255, 165, 0, 150) + bg_color = qcolor(MARK_TOOLTIP_ORANGE, 150) text = "Baton Requested..." else: - bg_color = QColor(255, 0, 0, 150) + bg_color = qcolor(MARK_TOOLTIP_RED, 150) text = "Guest Mode" fm = QFontMetrics(font) @@ -324,11 +344,11 @@ class SampleCameraImageLabel(QGraphicsView): bg_rect = QRect(position_x, position_y, bg_w, bg_h) - painter.setPen(QPen(QColor(255, 255, 255, 220))) + painter.setPen(QPen(qcolor(WHITE, 220))) painter.setBrush(bg_color) painter.drawRoundedRect(bg_rect, 10, 10) - painter.setPen(QPen(QColor(255, 255, 255))) + painter.setPen(QPen(qcolor(WHITE))) painter.drawText(QPoint(position_x + padding, position_y + padding + fm.ascent()), text) painter.restore() @@ -360,11 +380,11 @@ class SampleCameraImageLabel(QGraphicsView): text_rect.height() + 2 * padding, ) - painter.setPen(QPen(QColor(255, 255, 255, 220), 2)) - painter.setBrush(QColor(180, 60, 0, 180)) + painter.setPen(QPen(qcolor(WHITE, 220), 2)) + painter.setBrush(qcolor(MARK_BADGE_BG, 180)) painter.drawRoundedRect(bg_rect, 10, 10) - painter.setPen(QPen(QColor(255, 255, 255))) + painter.setPen(QPen(qcolor(WHITE))) painter.drawText(QPoint(position_x, position_y + fm.ascent()), text) painter.restore() @@ -716,14 +736,7 @@ class SampleCameraImageLabel(QGraphicsView): sx = disp_w / float(img_w) sy = disp_h / float(img_h) - color_map = { - "pin": QColor("red"), - "loop_all": QColor("green"), - "loop_face": QColor("yellow"), - "crystal": QColor("blue"), - "needle": QColor("magenta"), - "ice": QColor("cyan"), - } + color_map = {label: qcolor(hex_str) for label, hex_str in CLASS_COLORS.items()} for det in self._detections: try: @@ -738,7 +751,7 @@ class SampleCameraImageLabel(QGraphicsView): logger.debug(f"Error in draw detection {det}: {e}", exc_info=True) continue - color = color_map.get(label, QColor("magenta")) + color = color_map.get(label, qcolor(CLASS_COLORS["needle"])) pen = QPen(color, 3) painter.setPen(pen) @@ -753,20 +766,16 @@ class SampleCameraImageLabel(QGraphicsView): ) painter.drawPolygon(polygon) - painter.setPen(QPen(QColor(255, 255, 255), 1)) + painter.setPen(QPen(qcolor(WHITE), 1)) painter.setBrush(color) text_bg_rect = QRect(int(x1), int(y1 - 16), int(8 + 7 * len(label)), 16) painter.drawRect(text_bg_rect) - painter.setPen(QPen(QColor(255, 255, 255))) + painter.setPen(QPen(qcolor(WHITE))) painter.drawText(QPoint(int(x1) + 2, int(y1 - 4)), f"{label} {conf:.2f}") def _target_color(self) -> QColor: - color_map = { - "Cyan": QColor(0, 255, 255), - "Dark Blue": QColor(0, 70, 160), - "Dark Red": QColor(140, 25, 25), - } - return color_map.get(self._target_color_name, QColor(0, 255, 255)) + color_map = {name: qcolor(hex_str) for name, hex_str in TARGET_COLORS.items()} + return color_map.get(self._target_color_name, qcolor(TARGET_COLORS["Cyan"])) def _coerce_target_point(self, raw) -> tuple[float, float] | None: try: @@ -824,7 +833,7 @@ class SampleCameraImageLabel(QGraphicsView): painter.setBrush(Qt.BrushStyle.NoBrush) painter.setPen(QPen(color, 3, Qt.PenStyle.SolidLine)) painter.drawEllipse(QPointF(px, py), 12, 12) - painter.setPen(QPen(QColor(255, 255, 255), 1, Qt.PenStyle.SolidLine)) + painter.setPen(QPen(qcolor(WHITE), 1, Qt.PenStyle.SolidLine)) painter.drawEllipse(QPointF(px, py), 5, 5) painter.setPen(QPen(color, 2, Qt.PenStyle.SolidLine)) @@ -847,10 +856,10 @@ class SampleCameraImageLabel(QGraphicsView): bubble_rect = QRectF(px + 16, py - 28, text_rect.width() + 16, text_rect.height() + 10) painter.setPen(QPen(color, 2)) - painter.setBrush(QColor(20, 20, 20, 190)) + painter.setBrush(qcolor(LEGEND_BG, 190)) painter.drawRoundedRect(bubble_rect, 8, 8) - painter.setPen(QPen(QColor(255, 255, 255), 1)) + painter.setPen(QPen(qcolor(WHITE), 1)) painter.drawText( QPointF(bubble_rect.left() + 8, bubble_rect.top() + 7 + fm.ascent()), label_text ) @@ -878,19 +887,19 @@ class SampleCameraImageLabel(QGraphicsView): if self._show_detections: lines.extend( [ - ("Pin", QColor("red")), - ("Loop", QColor("green")), - ("Face", QColor("yellow")), - ("Crystal", QColor("blue")), + ("Pin", qcolor(CLASS_COLORS["pin"])), + ("Loop", qcolor(CLASS_COLORS["loop_all"])), + ("Face", qcolor(CLASS_COLORS["loop_face"])), + ("Crystal", qcolor(CLASS_COLORS["crystal"])), ] ) if self._show_coords: - lines.append(("Coords tooltip", QColor(230, 230, 230))) + lines.append(("Coords tooltip", qcolor(TOOLTIP_TEXT))) - lines.append(("Beam marker: shutter open", QColor(0, 255, 0))) - lines.append(("Beam marker: idle", QColor(245, 121, 0))) - lines.append(("Beam marker: busy", QColor(255, 0, 0))) + lines.append(("Beam marker: shutter open", qcolor(BEAM_OPEN))) + lines.append(("Beam marker: idle", qcolor(BEAM_IDLE))) + lines.append(("Beam marker: busy", qcolor(BEAM_BUSY))) return lines if self._show_target_point: @@ -902,22 +911,22 @@ class SampleCameraImageLabel(QGraphicsView): if self._show_detections: lines.extend( [ - ("Prediction: Pin", QColor("red")), - ("Prediction: Loop_all", QColor("green")), - ("Prediction: Loop_face", QColor("yellow")), - ("Prediction: Crystal", QColor("blue")), - ("Prediction: Needle", QColor("magenta")), - ("Prediction: Ice", QColor("cyan")), + ("Prediction: Pin", qcolor(CLASS_COLORS["pin"])), + ("Prediction: Loop_all", qcolor(CLASS_COLORS["loop_all"])), + ("Prediction: Loop_face", qcolor(CLASS_COLORS["loop_face"])), + ("Prediction: Crystal", qcolor(CLASS_COLORS["crystal"])), + ("Prediction: Needle", qcolor(CLASS_COLORS["needle"])), + ("Prediction: Ice", qcolor(CLASS_COLORS["ice"])), ] ) if self._show_coords: - lines.append(("Cursor tooltip: pixel coordinates", QColor(230, 230, 230))) + lines.append(("Cursor tooltip: pixel coordinates", qcolor(TOOLTIP_TEXT))) - lines.append(("Beam marker: shutter open", QColor(0, 255, 0))) - lines.append(("Beam marker: idle", QColor(245, 121, 0))) - lines.append(("Beam marker: busy", QColor(255, 0, 0))) - lines.append(("Beam marker: marking mode", QColor(102, 51, 153))) + lines.append(("Beam marker: shutter open", qcolor(BEAM_OPEN))) + lines.append(("Beam marker: idle", qcolor(BEAM_IDLE))) + lines.append(("Beam marker: busy", qcolor(BEAM_BUSY))) + lines.append(("Beam marker: marking mode", qcolor(BEAM_MARKING))) return lines @@ -950,8 +959,8 @@ class SampleCameraImageLabel(QGraphicsView): height = len(lines) * line_height + 16 bg_rect = QRectF(left, max(18, top), width, height) - painter.setPen(QPen(QColor(255, 255, 255, 60), 1)) - painter.setBrush(QColor(20, 20, 20, 170)) + painter.setPen(QPen(qcolor(WHITE, 60), 1)) + painter.setBrush(qcolor(LEGEND_BG, 170)) painter.drawRoundedRect(bg_rect, 8, 8) y = bg_rect.top() + 12 @@ -966,7 +975,7 @@ class SampleCameraImageLabel(QGraphicsView): painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(Qt.BrushStyle.NoBrush) - painter.setPen(QPen(QColor(240, 240, 240), 1)) + painter.setPen(QPen(qcolor(LEGEND_TEXT), 1)) painter.drawText( QPointF( bg_rect.left() + section_padding + swatch_size + text_padding, @@ -1028,7 +1037,7 @@ class SampleCameraImageLabel(QGraphicsView): if self._bounding_box is None: return - painter.setPen(QPen(QColor(50, 205, 50), 3, Qt.PenStyle.SolidLine)) + painter.setPen(QPen(qcolor(MARKER_GREEN), 3, Qt.PenStyle.SolidLine)) painter.setBrush(Qt.BrushStyle.NoBrush) painter.drawRect( @@ -1044,16 +1053,16 @@ class SampleCameraImageLabel(QGraphicsView): beam_size_pxl = self._geom.beam_size_pxl if self._state == SampleCameraImageState.BEAM_MARKING: - painter.setPen(QPen(QColor(102, 51, 153), 3, Qt.PenStyle.SolidLine)) + painter.setPen(QPen(qcolor(BEAM_MARKING), 3, Qt.PenStyle.SolidLine)) elif self._shutter: - painter.setPen(QPen(QColor(0, 255, 0), 3, Qt.PenStyle.SolidLine)) + painter.setPen(QPen(qcolor(BEAM_OPEN), 3, Qt.PenStyle.SolidLine)) elif self._is_daq_busy is True: # Use red color to indicate busy state - painter.setPen(QPen(QColor(255, 0, 0), 3, Qt.PenStyle.SolidLine)) + painter.setPen(QPen(qcolor(BEAM_BUSY), 3, Qt.PenStyle.SolidLine)) else: - painter.setPen(QPen(QColor(245, 121, 0), 3, Qt.PenStyle.SolidLine)) + painter.setPen(QPen(qcolor(BEAM_IDLE), 3, Qt.PenStyle.SolidLine)) painter.setBrush(Qt.BrushStyle.NoBrush) painter.drawRect( @@ -1077,8 +1086,8 @@ class SampleCameraImageLabel(QGraphicsView): gradient.setStart(QPointF(start.x, start.y)) # Start of the gradient (green) gradient.setFinalStop(QPointF(end.x, end.y)) # End of the gradient (red) - gradient.setColorAt(0.0, QColor("green")) # Start color - gradient.setColorAt(1.0, QColor("red")) # End color + gradient.setColorAt(0.0, qcolor(PATH_START)) # Start color + gradient.setColorAt(1.0, qcolor(PATH_END)) # End color pen = QPen() pen.setBrush(gradient) # Use gradient as the brush for the pen @@ -1091,13 +1100,13 @@ class SampleCameraImageLabel(QGraphicsView): def _draw_helical(self, painter: QPainter): if self._helical_start.sh_mm is not None: start_pxl = self._geom.smargon_to_picture(self._helical_start.sh_mm) - self._draw_circle(painter, start_pxl, QColor("green")) + self._draw_circle(painter, start_pxl, qcolor(PATH_START)) else: start_pxl = None if self._helical_end.sh_mm is not None: end_pxl = self._geom.smargon_to_picture(self._helical_end.sh_mm) - self._draw_circle(painter, end_pxl, QColor("red")) + self._draw_circle(painter, end_pxl, qcolor(PATH_END)) else: end_pxl = None diff --git a/src/aare/gui/widgets/local_contact_status_widget.py b/src/aare/gui/widgets/local_contact_status_widget.py index 4d8d45fc..00b1f879 100644 --- a/src/aare/gui/widgets/local_contact_status_widget.py +++ b/src/aare/gui/widgets/local_contact_status_widget.py @@ -9,6 +9,22 @@ from PySide6.QtCore import Qt, Slot from PySide6.QtWidgets import QFrame, QGridLayout, QLabel, QSizePolicy, QVBoxLayout from aare.gui.constants import LOGGER_NAME +from aare.gui.styles import ( + CARD_BORDER, + CHIP_BAD_BG, + CHIP_BAD_TEXT, + CHIP_INFO_BG, + CHIP_INFO_TEXT, + CHIP_NEUTRAL_BG, + CHIP_WARN_BG, + CHIP_WARN_TEXT, + HEADING_TEXT, + MUTED_TEXT, + SUBTLE_TEXT, + SUCCESS_BG, + SUCCESS_TEXT, + SURFACE, +) from aare.gui.widgets.title_label import TitleLabel logger = setup_logger(LOGGER_NAME) @@ -79,11 +95,11 @@ class LocalContactStatusWidget(QFrame): self.setFrameShadow(QFrame.Shadow.Raised) self.setObjectName("localContactStatusCard") self.setStyleSheet( - """ - QFrame#localContactStatusCard { - background: #f8fbff; - border: 1px solid #c7d4e5; - } + f""" + QFrame#localContactStatusCard {{ + background: {SURFACE}; + border: 1px solid {CARD_BORDER}; + }} """ ) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum) @@ -96,7 +112,7 @@ class LocalContactStatusWidget(QFrame): self._summary = QLabel(summary, self) self._summary.setWordWrap(True) - self._summary.setStyleSheet("border: none; background: transparent; color: #334155;") + self._summary.setStyleSheet(f"border: none; background: transparent; color: {SUBTLE_TEXT};") layout.addWidget(self._summary) self._grid = QGridLayout() @@ -130,12 +146,12 @@ class LocalContactStatusWidget(QFrame): for row, key in enumerate(self._visible_fields): title = QLabel(self.FIELD_TITLES.get(key, key.replace("_", " ").title()), self) title.setStyleSheet( - "font-weight: 700; border: none; background: transparent; color: #1e293b;" + f"font-weight: 700; border: none; background: transparent; color: {HEADING_TEXT};" ) value = QLabel(self._badge("WAITING", tone="neutral"), self) value.setWordWrap(True) value.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) - value.setStyleSheet("border: none; background: transparent; color: #334155;") + value.setStyleSheet(f"border: none; background: transparent; color: {SUBTLE_TEXT};") self._row_widgets[key] = (title, value) self._grid.addWidget(title, row, 0, alignment=Qt.AlignmentFlag.AlignTop) self._grid.addWidget(value, row, 1) @@ -146,11 +162,11 @@ class LocalContactStatusWidget(QFrame): def _badge(self, text: str, *, tone: str = "neutral") -> str: palette = { - "good": ("#e7f6ea", "#1f6a3a"), - "warn": ("#fff3cd", "#7a4b00"), - "bad": ("#fdeaea", "#8b1e1e"), - "neutral": ("#e9eef5", "#475569"), - "info": ("#e8f1ff", "#12406a"), + "good": (SUCCESS_BG, SUCCESS_TEXT), + "warn": (CHIP_WARN_BG, CHIP_WARN_TEXT), + "bad": (CHIP_BAD_BG, CHIP_BAD_TEXT), + "neutral": (CHIP_NEUTRAL_BG, MUTED_TEXT), + "info": (CHIP_INFO_BG, CHIP_INFO_TEXT), } background, foreground = palette.get(tone, palette["neutral"]) return ( @@ -180,7 +196,7 @@ class LocalContactStatusWidget(QFrame): return ( f"{self._badge('ERROR', tone='bad')} " f"{self._badge(mode.upper(), tone='warn')} " - f"{error}" + f"{error}" ) if mode == "simulated": return self._badge("SIMULATED", tone="warn") diff --git a/src/aare/gui/widgets/login.py b/src/aare/gui/widgets/login.py index 38d8a1df..0f71f1f8 100644 --- a/src/aare/gui/widgets/login.py +++ b/src/aare/gui/widgets/login.py @@ -7,6 +7,8 @@ from PySide6.QtCore import QByteArray, QUrl, QUrlQuery, Slot from PySide6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QLineEdit, QPushButton, QVBoxLayout +from aare.gui.styles import BACKGROUND + class LoginDialog(QDialog): def __init__(self, base_url: str | None): @@ -14,7 +16,7 @@ class LoginDialog(QDialog): self.token = "" self.setWindowTitle("User Authentication") self.setMinimumWidth(400) - self.setStyleSheet("background-color: rgb(216, 228, 253);") + self.setStyleSheet(f"background-color: {BACKGROUND};") self._base_url = base_url self._reply = None self._network_manager = None diff --git a/src/aare/gui/widgets/number_line_edit.py b/src/aare/gui/widgets/number_line_edit.py index 168d68b4..802023b5 100644 --- a/src/aare/gui/widgets/number_line_edit.py +++ b/src/aare/gui/widgets/number_line_edit.py @@ -2,6 +2,8 @@ from PySide6.QtCore import Qt, Signal, Slot from PySide6.QtGui import QDoubleValidator from PySide6.QtWidgets import QCheckBox, QHBoxLayout, QLineEdit, QWidget +from aare.gui.styles import INPUT_BG, INPUT_DISABLED_BG, INPUT_DISABLED_INVALID_BG, INPUT_INVALID_BG + class NumberLineEdit(QLineEdit): newValue = Signal(float) @@ -13,7 +15,7 @@ class NumberLineEdit(QLineEdit): self._read_only: bool = False self._is_valid: bool = True - self.setStyleSheet("background-color: rgb(255, 255, 255);") + self.setStyleSheet(f"background-color: {INPUT_BG};") # Use a QDoubleValidator to only allow valid floating point numbers self.validator = QDoubleValidator() @@ -42,9 +44,9 @@ class NumberLineEdit(QLineEdit): # when text changes check validation and change the colour of the line edit self._is_valid = self.validate(text) if self._is_valid: - self.setStyleSheet("background-color: rgb(255, 255, 255);") + self.setStyleSheet(f"background-color: {INPUT_BG};") else: - self.setStyleSheet("background-color: rgb(255, 213, 213);") + self.setStyleSheet(f"background-color: {INPUT_INVALID_BG};") @Slot() def on_editing_finished(self): @@ -86,18 +88,18 @@ class NumberLineEdit(QLineEdit): super().setReadOnly(ro) self._read_only = ro if self._read_only and self._is_valid: - self.setStyleSheet("background-color: rgb(240, 240, 240);") + self.setStyleSheet(f"background-color: {INPUT_DISABLED_BG};") elif not self._read_only and self._is_valid: - self.setStyleSheet("background-color: rgb(255, 255, 255);") + self.setStyleSheet(f"background-color: {INPUT_BG};") elif self._read_only and not self._is_valid: - self.setStyleSheet("background-color: rgb(240, 225, 225);") + self.setStyleSheet(f"background-color: {INPUT_DISABLED_INVALID_BG};") elif not self._read_only and not self._is_valid: - self.setStyleSheet("background-color: rgb(255, 213, 213);") + self.setStyleSheet(f"background-color: {INPUT_INVALID_BG};") else: print( f"unknown ro state: {self._read_only} or validity {self._is_valid} default to writeable" ) - self.setStyleSheet("background-color: rgb(255, 255, 255);") + self.setStyleSheet(f"background-color: {INPUT_BG};") def get_default(self) -> float: return float(self.initial_value) @@ -167,13 +169,13 @@ class CheckedLineEdit(QWidget): if self._busy: self.check_box.setEnabled(False) - self.check_box.setStyleSheet("background-color: rgb(240, 240, 240);") + self.check_box.setStyleSheet(f"background-color: {INPUT_DISABLED_BG};") if self._checked: self.editor.force_update_value(self._internal_value) else: self.check_box.setEnabled(True) - self.check_box.setStyleSheet("background-color: rgb(255, 255, 255);") + self.check_box.setStyleSheet(f"background-color: {INPUT_BG};") self.check_box.blockSignals(False) self.blockSignals(False) diff --git a/src/aare/gui/widgets/pgroup_dialog.py b/src/aare/gui/widgets/pgroup_dialog.py index 6d467abf..296dbc9a 100644 --- a/src/aare/gui/widgets/pgroup_dialog.py +++ b/src/aare/gui/widgets/pgroup_dialog.py @@ -9,6 +9,8 @@ from PySide6.QtWidgets import ( QVBoxLayout, ) +from aare.gui.styles import DANGER_ACCENT + class PGroupDialog(QDialog): def __init__( @@ -78,9 +80,11 @@ class PGroupDialog(QDialog): def _set_error_state(self, is_error: bool, message: str | None = None) -> None: if is_error: - self.combo.setStyleSheet("border: 2px solid #d9534f;") + self.combo.setStyleSheet(f"border: 2px solid {DANGER_ACCENT};") if message: - self.label.setText(f"Set p-group: {message}") + self.label.setText( + f"Set p-group: {message}" + ) else: self.label.setText("Set p-group:") else: diff --git a/src/aare/gui/widgets/raster_grid_table.py b/src/aare/gui/widgets/raster_grid_table.py index 0c40345f..df73cfe3 100644 --- a/src/aare/gui/widgets/raster_grid_table.py +++ b/src/aare/gui/widgets/raster_grid_table.py @@ -9,6 +9,7 @@ from PySide6.QtWidgets import ( ) from aare.gui.scan_logic.raster_grid_manager import RasterGridManager +from aare.gui.styles import TABLE_SHADE_BG class RasterGridTable(QTableWidget): @@ -65,16 +66,16 @@ class RasterGridTable(QTableWidget): actions_layout.setContentsMargins(4, 4, 4, 4) actions_layout.setSpacing(4) - button_style = """ - QPushButton { + button_style = f""" + QPushButton {{ border: none; background: transparent; font-size: 14px; - } - QPushButton:hover { - background-color: #e0e0e0; + }} + QPushButton:hover {{ + background-color: {TABLE_SHADE_BG}; border-radius: 3px; - } + }} """ # Copy button diff --git a/src/aare/gui/widgets/splash_screen.py b/src/aare/gui/widgets/splash_screen.py index 91761223..314f56c0 100644 --- a/src/aare/gui/widgets/splash_screen.py +++ b/src/aare/gui/widgets/splash_screen.py @@ -1,6 +1,8 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import QApplication, QProgressBar, QSplashScreen +from aare.gui.styles import SPLASH_ACCENT, SPLASH_BG, SPLASH_BORDER, WHITE, qcolor + class LoadingSplashScreen(QSplashScreen): def __init__(self, pixmap): @@ -9,21 +11,21 @@ class LoadingSplashScreen(QSplashScreen): self.progress = QProgressBar(self) # Position the progress bar at the bottom of the splash screen self.progress.setGeometry(10, self.size().height() - 30, self.size().width() - 20, 20) - self.progress.setStyleSheet(""" - QProgressBar { - border: 1px solid #444; + self.progress.setStyleSheet(f""" + QProgressBar {{ + border: 1px solid {SPLASH_BORDER}; border-radius: 5px; text-align: center; - background-color: #222; - color: white; - } - QProgressBar::chunk { - background-color: #0078d7; - } + background-color: {SPLASH_BG}; + color: {WHITE}; + }} + QProgressBar::chunk {{ + background-color: {SPLASH_ACCENT}; + }} """) def set_progress(self, value, message=None): self.progress.setValue(value) if message: - self.showMessage(message, Qt.AlignBottom | Qt.AlignCenter, Qt.white) + self.showMessage(message, Qt.AlignBottom | Qt.AlignCenter, qcolor(WHITE)) QApplication.processEvents() diff --git a/src/aare/gui/widgets/status_bar.py b/src/aare/gui/widgets/status_bar.py index 8a600382..00466b6b 100644 --- a/src/aare/gui/widgets/status_bar.py +++ b/src/aare/gui/widgets/status_bar.py @@ -8,6 +8,15 @@ from PySide6.QtGui import QFont from PySide6.QtWidgets import QDialog, QLabel, QMenu, QMessageBox, QSizePolicy, QStatusBar from aare.gui.constants import LOGGER_NAME +from aare.gui.styles import ( + STATE_TELL_TEXT, + STATUS_ALERT, + STATUS_INFO, + STATUS_OK, + STATUS_REQUEST, + STATUS_VACANT, + STATUS_WARN, +) from aare.gui.widgets.baton_request_dialog import BatonRequestDialog from aare.gui.widgets.clickable_label import ClickableLabel from aare.gui.widgets.pgroup_dialog import PGroupDialog @@ -102,7 +111,7 @@ class StatusBar(QStatusBar): @Slot(str, bool) def show_connection_message(self, msg: str, is_error: bool = True): - color = "red" if is_error else "green" + color = STATUS_ALERT if is_error else STATUS_OK self._message_clear_timer.stop() self.message_label.setText(msg) self.message_label.setStyleSheet(f"color: {color}; font-weight: bold;") @@ -146,9 +155,9 @@ class StatusBar(QStatusBar): self.transmission.set_value(f"{status.bl.transmission:.5f}") if status.bl.ring_current_mA < 5.0: - self.ring_current.set_value(f"{status.bl.ring_current_mA:.2f}", "red") + self.ring_current.set_value(f"{status.bl.ring_current_mA:.2f}", STATUS_ALERT) elif status.bl.ring_current_mA < 390.0: - self.ring_current.set_value(f"{status.bl.ring_current_mA:.2f}", "orange") + self.ring_current.set_value(f"{status.bl.ring_current_mA:.2f}", STATUS_WARN) else: self.ring_current.set_value(f"{status.bl.ring_current_mA:.2f}") @@ -156,28 +165,28 @@ class StatusBar(QStatusBar): self.wvl.set_value(f"{status.diffraction.wavelength_angstrom:.2f}") if status.bl.cryojet_K < 110.0: - self.cryo_label.set_value(f"{status.bl.cryojet_K:.1f}", "blue") + self.cryo_label.set_value(f"{status.bl.cryojet_K:.1f}", STATUS_INFO) elif status.bl.cryojet_K < 250.0: - self.cryo_label.set_value(f"{status.bl.cryojet_K:.1f}", "orange") + self.cryo_label.set_value(f"{status.bl.cryojet_K:.1f}", STATUS_WARN) else: - self.cryo_label.set_value(f"{status.bl.cryojet_K:.1f}", "red") + self.cryo_label.set_value(f"{status.bl.cryojet_K:.1f}", STATUS_ALERT) if status.bl.shutter_open: self.shutter_label.setText( - """Fast Shutter: Open ☢️ """ + f"""Fast Shutter: Open ☢️ """ ) else: self.shutter_label.setText( - """Fast Shutter: Closed 🚪 """ + f"""Fast Shutter: Closed 🚪 """ ) if status.bl.exp_shutter_open: self.exp_shutter_label.setText( - """ExpHutch Shutter: Open """ + f"""ExpHutch Shutter: Open """ ) else: self.exp_shutter_label.setText( - """ExpHutch Shutter: Closed 🚪 """ + f"""ExpHutch Shutter: Closed 🚪 """ ) if status.session.current_pgroup is not None: @@ -188,29 +197,29 @@ class StatusBar(QStatusBar): self.state_label.setText(f"""State: {status.state.display_name()} """) tell_text = "—" - tell_color = "rgb(55, 67, 87)" + tell_color = STATE_TELL_TEXT if status.tell_state is not None: tell_text = status.tell_state.activity.display_name() if status.tell_state.activity.value == "error": - tell_color = "red" + tell_color = STATUS_ALERT elif status.tell_state.activity.value in { "mounting", "unmounting", "drying", "cooling", }: - tell_color = "orange" + tell_color = STATUS_WARN else: - tell_color = "green" + tell_color = STATUS_OK self.tell_state_label.setText(f"Tell: {tell_text} ") self.tell_state_label.setStyleSheet(f"color: {tell_color};") if status.busy: - busy_flag = """ Busy 🔒 """ + busy_flag = f""" Busy 🔒 """ else: - busy_flag = """ Idle 🔓 """ + busy_flag = f""" Idle 🔓 """ html_content = f"""Beamline: {busy_flag} """ @@ -218,15 +227,15 @@ class StatusBar(QStatusBar): session_flag = "" if status.session.session == SessionsStateEnum.Vacant: - session_flag = """ Vacant 🔓 """ + session_flag = f""" Vacant 🔓 """ elif status.session.session == SessionsStateEnum.OwnedByYou: - session_flag = """ Owned ⬤ """ + session_flag = f""" Owned ⬤ """ elif status.session.session == SessionsStateEnum.OwnedByElse: - session_flag = """ Other 🔒 """ + session_flag = f""" Other 🔒 """ elif status.session.session == SessionsStateEnum.PendingYouToElse: - session_flag = """ Waiting... ⏳ """ + session_flag = f""" Waiting... ⏳ """ elif status.session.session == SessionsStateEnum.PendingElseToYou: - session_flag = """ Request! ⚡ """ + session_flag = f""" Request! ⚡ """ html_content_session = f"""Session: {session_flag}""" self.session_label.setText(html_content_session) diff --git a/src/aare/gui/widgets/title_label.py b/src/aare/gui/widgets/title_label.py index ac9eef1f..3e1090dd 100644 --- a/src/aare/gui/widgets/title_label.py +++ b/src/aare/gui/widgets/title_label.py @@ -1,6 +1,8 @@ from PySide6.QtCore import QSettings, Qt, QTimer from PySide6.QtWidgets import QHBoxLayout, QLabel, QLayout, QPushButton +from aare.gui.styles import BANNER, BANNER_TEXT + # 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. @@ -34,7 +36,7 @@ class TitleLabel(QLabel): # widgets and would paint the toggle button solid purple, overriding # the app QSS. self.setStyleSheet( - "TitleLabel { background-color: #4B0082; color: #ffffff;" + f"TitleLabel {{ background-color: {BANNER}; color: {BANNER_TEXT};" " font-size: 16px; font-weight: 700; }" ) self.setAlignment(Qt.AlignmentFlag.AlignCenter) @@ -55,8 +57,8 @@ class TitleLabel(QLabel): # 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; }" + f"QPushButton {{ background: transparent; border: none;" + f" color: {BANNER_TEXT}; font-size: 14px; font-weight: 700; }}" ) self.toggle_button.setToolTip("Minimise panel") self.toggle_button.setFixedSize(21, 21) -- 2.54.0 From e63ddb090f68524b5193fe472176f46534dd1c7f Mon Sep 17 00:00:00 2001 From: Dawn Date: Fri, 7 Aug 2026 11:54:23 +0200 Subject: [PATCH 14/57] style: add typography, tooltip, scrollbar, and card tokens to the palette The QSS gains a role-named font-size ladder (FONT_HERO..FONT_FINE), borderless QToolTip styling for both themes, soft rounded scrollbars without end arrows, and a card_style() helper with one shared CARD_RADIUS so card-shaped frames keep uniform geometry. Banner text flips to near-white with a shadow constant for the painted emboss. Recovery-card and console-log colors are marked TODO for a later retheme. Co-Authored-By: Claude Fable 5 --- src/aare/gui/styles.py | 264 ++++++++++++++++++++++++++++++++++------- 1 file changed, 224 insertions(+), 40 deletions(-) diff --git a/src/aare/gui/styles.py b/src/aare/gui/styles.py index 83b3adb1..8a86223b 100644 --- a/src/aare/gui/styles.py +++ b/src/aare/gui/styles.py @@ -16,7 +16,8 @@ THEME_PORTRAIT = "portrait" # -- Light theme ------------------------------------------------------------ BACKGROUND = "#e2e7ee" BANNER = "#a2b7e4" -BANNER_TEXT = "#263043" # must contrast with BANNER +BANNER_TEXT = "#F8F8FC" # must contrast with BANNER +BANNER_TEXT_SHADOW = "#000000" # soft shadow under banner titles; alpha in TitleLabel TEXT = "#263043" SURFACE = "#f2f2f2" # input fields, cards, group boxes @@ -111,6 +112,8 @@ CHIP_INFO_BG = "#e8f1ff" CHIP_INFO_TEXT = "#12406a" # -- Status cards (beamline recovery, local contact error frame) ------------ +# TODO: recovery-card colors are inherited from the old ad-hoc design and +# stand out against the app palette — retheme them here when ready. WARN_CARD_BORDER = "#f0c36d" BAD_CARD_BORDER = "#e6a8a8" INFO_CARD_BG = "#eef6ff" @@ -119,6 +122,8 @@ PENDING_CARD_BG = "#fff7db" PENDING_CARD_BORDER = "#e7cb73" # -- Log panel -------------------------------------------------------------- +# TODO: console-log notification colors are inherited from the old ad-hoc +# design and stand out against the app palette — retheme them here when ready. LOG_BORDER = "#8a8a8a" LOG_PANEL_BG = "#fff4f4" LOG_ERROR_BG = "#fff1f1" @@ -204,7 +209,7 @@ PATH_START = "#008000" # raster path gradient start + start circle PATH_END = "#ff0000" # raster path gradient end + end circle LEGEND_BG = "#141414" LEGEND_TEXT = "#f0f0f0" -TOOLTIP_TEXT = "#e6e6e6" +TOOLTIP_TEXT = "#e6e6e6" # camera coords tooltip pen — NOT the QToolTip popup MARK_TOOLTIP_GOLD = "#ffd700" MARK_TOOLTIP_ORANGE = "#ffa500" MARK_TOOLTIP_RED = "#ff0000" @@ -284,6 +289,32 @@ PANEL_BORDER_DARK = "#bdbdbd" PANEL_BORDER_LIGHT = "#e0e0e0" TUTORIAL_BORDER = "#555555" TUTORIAL_HIGHLIGHT = "#ffff00" # widget spotlight pen (was Qt.yellow) + +# -- Typography ladder (role-named; tune sizes here, not in widgets) -------- +FONT_HERO = "28px" # portrait main action button +FONT_ALERT = "18px" # alert banner text +FONT_VALUE = "18px" # prominent values, big glyph buttons +FONT_TITLE = "16px" # section / panel titles +FONT_BODY_LG = "14px" # primary buttons, emphasized body text — always bold +FONT_BODY = "14px" # default UI text +FONT_LABEL = "14px" # form labels, secondary text — always bold +FONT_HINT = "12px" # hints +FONT_FINE = "11px" # fine print, queue titles + +# -- Hover tooltips (the QToolTip popup; styled borderless) ----------------- +TOOLTIP_BG = "#f7f9fc" +TOOLTIP_FG = "#263043" +DARK_TOOLTIP_BG = "#363a4f" # surface0 +DARK_TOOLTIP_FG = "#cad3f5" # text + +# -- Scrollbars (rounded, no arrows: grey track, darker draggable handle) --- +SCROLLBAR_TRACK = "#d8dde5" +SCROLLBAR_HANDLE = "#a8b2c0" +SCROLLBAR_HANDLE_HOVER = "#8794a6" + +# -- Cards ------------------------------------------------------------------ +CARD_RADIUS = "12px" # one radius for every card-shaped frame +FLAT_CARD_RADIUS = "0px" # recovery + console-log cards stay square # --------------------------------------------------------------------------- @@ -308,6 +339,27 @@ def qcolor(color: str, alpha: int | None = None): return c +def card_style( + bg: str, + border: str, + text: str | None = None, + *, + selector: str = "QFrame", + border_px: int = 1, + radius: str = CARD_RADIUS, + extra: str = "", +) -> str: + """One QSS rule for every card-shaped frame: geometry stays uniform + (CARD_RADIUS unless overridden), only the role colors vary. `extra` + appends declarations (padding, font-weight, …) inside the same rule.""" + color = f" color: {text};" if text else "" + tail = f" {extra}" if extra else "" + return ( + f"{selector} {{ background: {bg};{color}" + f" border: {border_px}px solid {border}; border-radius: {radius};{tail} }}" + ) + + def build_app_stylesheet(theme: str) -> str: if theme == THEME_PORTRAIT: return _portrait_stylesheet() @@ -331,6 +383,15 @@ def _original_stylesheet() -> str: background-color: $dark_bg; } + /* Borderless hover hints. The transparent border is required — QToolTip + only honours the stylesheet background once a border is set. */ + QToolTip { + background-color: $tooltip_bg; + color: $tooltip_fg; + border: 1px solid transparent; + padding: 4px 6px; + } + QFrame#compactAutomationPanel { background: $background; border: none; @@ -350,27 +411,27 @@ def _original_stylesheet() -> str: QLabel#compactSectionTitle { background: transparent; color: $compact_title; - font-size: 14px; + font-size: $font_body; font-weight: 700; } QLabel#compactSectionHint { background: transparent; color: $compact_hint; - font-size: 12px; + font-size: $font_hint; } QLabel#compactQueueTitle { background: transparent; color: $compact_hint; - font-size: 11px; + font-size: $font_fine; font-weight: 700; } QLabel#compactQueueValue { background: transparent; color: $compact_value; - font-size: 14px; + font-size: $font_body; font-weight: 700; } @@ -380,7 +441,7 @@ def _original_stylesheet() -> str: border: 1px solid $compact_border; border-radius: 14px; padding: 10px 14px; - font-size: 18px; + font-size: $font_value; font-weight: 700; } @@ -394,7 +455,7 @@ def _original_stylesheet() -> str: border: none; border-radius: 14px; padding: 14px 18px; - font-size: 15px; + font-size: $font_body_lg; font-weight: 700; } @@ -409,7 +470,7 @@ def _original_stylesheet() -> str: border: 1px solid $compact_border; border-radius: 14px; padding: 14px 18px; - font-size: 14px; + font-size: $font_body; font-weight: 700; } @@ -421,27 +482,27 @@ def _original_stylesheet() -> str: QFrame#alertBanner[alertKind="error"] { background-color: $error_bg; border: 2px solid $error_border; - border-radius: 12px; + border-radius: $card_radius; margin: 8px 12px 8px 12px; } QFrame#alertBanner[alertKind="success"] { background-color: $success_bg; border: 2px solid $success_border; - border-radius: 12px; + border-radius: $card_radius; margin: 8px 12px 8px 12px; } QFrame#alertBanner[alertKind="waiting"] { background-color: $warning_bg; border: 2px solid $warning_border; - border-radius: 12px; + border-radius: $card_radius; margin: 8px 12px 8px 12px; } QFrame#alertBanner QLabel { font-weight: 700; - font-size: 20px; + font-size: $font_alert; padding: 2px 6px 2px 6px; } @@ -458,12 +519,12 @@ def _original_stylesheet() -> str: } QWidget#axisVideoStatusContainer[busyState="idle"] { - border-radius: 12px; + border-radius: $card_radius; background-color: $status_idle_bg; } QWidget#axisVideoStatusContainer[busyState="active"] { - border-radius: 12px; + border-radius: $card_radius; } QLabel#axisVideoStatusDot { @@ -504,7 +565,7 @@ def _original_stylesheet() -> str: QLabel#beamlineStateTitle { background-color: $banner; color: $banner_text; - font-size: 16px; + font-size: $font_title; font-weight: 700; } @@ -513,13 +574,13 @@ def _original_stylesheet() -> str: border: none; background: transparent; color: $state_toggle_text; - font-size: 14px; + font-size: $font_body; font-weight: 700; } QLabel#beamlineStateCurrentLabel { color: $state_current_text; - font-size: 18px; + font-size: $font_value; font-weight: 700; padding-left: 4px; background: transparent; @@ -527,18 +588,75 @@ def _original_stylesheet() -> str: QLabel#beamlineStateTellLabel { color: $state_tell_text; - font-size: 15px; - font-weight: 600; + font-size: $font_body_lg; + font-weight: 700; padding-left: 4px; background: transparent; } + /* Plain scroll containers stay frameless; data views (tables, trees, + text/log views) keep their native frame — those borders are wanted. */ + QScrollArea { + border: none; + } + + /* Soft scrollbars: rounded track + draggable handle, no end arrows. */ + QScrollBar:vertical { + background: $scrollbar_track; + width: 10px; + border: none; + border-radius: 5px; + margin: 0px; + } + + QScrollBar:horizontal { + background: $scrollbar_track; + height: 10px; + border: none; + border-radius: 5px; + margin: 0px; + } + + QScrollBar::handle:vertical { + background: $scrollbar_handle; + border-radius: 5px; + min-height: 24px; + } + + QScrollBar::handle:horizontal { + background: $scrollbar_handle; + border-radius: 5px; + min-width: 24px; + } + + QScrollBar::handle:vertical:hover, + QScrollBar::handle:vertical:pressed, + QScrollBar::handle:horizontal:hover, + QScrollBar::handle:horizontal:pressed { + background: $scrollbar_handle_hover; + } + + QScrollBar::add-line, QScrollBar::sub-line { + width: 0px; + height: 0px; + } + + QScrollBar::add-page, QScrollBar::sub-page { + background: transparent; + } + + /* Kill the square filler where the two scrollbars meet. */ + QAbstractScrollArea::corner { + background: transparent; + border: none; + } + QWidget#portraitRoot, QWidget#portraitRoot QWidget { background: $dark_bg; color: $dark_text; font-family: 'Inter', 'SF Pro Display', Arial, sans-serif; - font-size: 14px; + font-size: $font_body; } QWidget#portraitRoot QScrollArea { @@ -587,6 +705,15 @@ def _portrait_stylesheet() -> str: color: $dark_text; } + /* Borderless hover hints. The transparent border is required — QToolTip + only honours the stylesheet background once a border is set. */ + QToolTip { + background-color: $dark_tooltip_bg; + color: $dark_tooltip_fg; + border: 1px solid transparent; + padding: 4px 6px; + } + QFrame#compactAutomationPanel { background: $dark_bg; border: none; @@ -606,27 +733,27 @@ def _portrait_stylesheet() -> str: QLabel#compactSectionTitle { background: transparent; color: $dark_accent; - font-size: 14px; + font-size: $font_body; font-weight: 700; } QLabel#compactSectionHint { background: transparent; color: $dark_muted; - font-size: 12px; + font-size: $font_hint; } QLabel#compactQueueTitle { background: transparent; color: $dark_muted; - font-size: 11px; + font-size: $font_fine; font-weight: 700; } QLabel#compactQueueValue { background: transparent; color: $dark_text; - font-size: 14px; + font-size: $font_body; font-weight: 700; } @@ -636,7 +763,7 @@ def _portrait_stylesheet() -> str: border: 1px solid $dark_border; border-radius: 14px; padding: 10px 14px; - font-size: 18px; + font-size: $font_value; font-weight: 700; } @@ -650,7 +777,7 @@ def _portrait_stylesheet() -> str: border: none; border-radius: 14px; padding: 14px 18px; - font-size: 15px; + font-size: $font_body_lg; font-weight: 700; } @@ -665,7 +792,7 @@ def _portrait_stylesheet() -> str: border: 1px solid $dark_border; border-radius: 14px; padding: 14px 18px; - font-size: 14px; + font-size: $font_body; font-weight: 700; } @@ -677,27 +804,27 @@ def _portrait_stylesheet() -> str: QFrame#alertBanner[alertKind="error"] { background: $dark_error_bg; border: 2px solid $dark_error_border; - border-radius: 12px; + border-radius: $card_radius; margin: 8px 12px 8px 12px; } QFrame#alertBanner[alertKind="success"] { background: $dark_success_bg; border: 2px solid $dark_success_border; - border-radius: 12px; + border-radius: $card_radius; margin: 8px 12px 8px 12px; } QFrame#alertBanner[alertKind="waiting"] { background: $dark_warning_bg; border: 2px solid $dark_warning_border; - border-radius: 12px; + border-radius: $card_radius; margin: 8px 12px 8px 12px; } QFrame#alertBanner QLabel { font-weight: 700; - font-size: 20px; + font-size: $font_alert; padding: 2px 6px 2px 6px; } @@ -714,12 +841,12 @@ def _portrait_stylesheet() -> str: } QWidget#axisVideoStatusContainer[busyState="idle"] { - border-radius: 12px; + border-radius: $card_radius; background-color: $dark_elevated; } QWidget#axisVideoStatusContainer[busyState="active"] { - border-radius: 12px; + border-radius: $card_radius; } QLabel#axisVideoStatusDot { @@ -745,7 +872,7 @@ def _portrait_stylesheet() -> str: QLabel#beamlineStateTitle { background-color: $dark_elevated; color: $dark_text; - font-size: 16px; + font-size: $font_title; font-weight: 700; } @@ -754,13 +881,13 @@ def _portrait_stylesheet() -> str: border: none; background: transparent; color: $dark_text; - font-size: 14px; + font-size: $font_body; font-weight: 700; } QLabel#beamlineStateCurrentLabel { color: $dark_text; - font-size: 18px; + font-size: $font_value; font-weight: 700; padding-left: 4px; background: transparent; @@ -768,18 +895,75 @@ def _portrait_stylesheet() -> str: QLabel#beamlineStateTellLabel { color: $dark_muted; - font-size: 15px; - font-weight: 600; + font-size: $font_body_lg; + font-weight: 700; padding-left: 4px; background: transparent; } + /* Plain scroll containers stay frameless; data views (tables, trees, + text/log views) keep their native frame — those borders are wanted. */ + QScrollArea { + border: none; + } + + /* Soft scrollbars: rounded track + draggable handle, no end arrows. */ + QScrollBar:vertical { + background: $dark_surface; + width: 10px; + border: none; + border-radius: 5px; + margin: 0px; + } + + QScrollBar:horizontal { + background: $dark_surface; + height: 10px; + border: none; + border-radius: 5px; + margin: 0px; + } + + QScrollBar::handle:vertical { + background: $dark_border; + border-radius: 5px; + min-height: 24px; + } + + QScrollBar::handle:horizontal { + background: $dark_border; + border-radius: 5px; + min-width: 24px; + } + + QScrollBar::handle:vertical:hover, + QScrollBar::handle:vertical:pressed, + QScrollBar::handle:horizontal:hover, + QScrollBar::handle:horizontal:pressed { + background: $dark_muted; + } + + QScrollBar::add-line, QScrollBar::sub-line { + width: 0px; + height: 0px; + } + + QScrollBar::add-page, QScrollBar::sub-page { + background: transparent; + } + + /* Kill the square filler where the two scrollbars meet. */ + QAbstractScrollArea::corner { + background: transparent; + border: none; + } + QWidget#portraitRoot, QWidget#portraitRoot QWidget { background: $dark_bg; color: $dark_text; font-family: 'Inter', 'SF Pro Display', Arial, sans-serif; - font-size: 14px; + font-size: $font_body; } QWidget#portraitRoot QScrollArea { -- 2.54.0 From 913089b9b62793f74e003c7afa2fd35c12f25864 Mon Sep 17 00:00:00 2001 From: Dawn Date: Fri, 7 Aug 2026 11:54:31 +0200 Subject: [PATCH 15/57] style: apply the typography ladder and card_style across the GUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inline font-size literals become FONT_* tokens and hand-written card QSS collapses into card_style() (recovery cards and console-log notifications keep FLAT_CARD_RADIUS). TitleLabel paints its text by hand — QSS has no text-shadow — for an embossed banner title, elided when narrow, and gains a section_title() helper for small in-panel headings. Co-Authored-By: Claude Fable 5 --- src/aare/gui/panels/automation_panel.py | 9 +- .../gui/panels/beamline_recovery_panel.py | 118 ++++++++++-------- src/aare/gui/panels/developer_help_dialog.py | 20 +-- src/aare/gui/panels/local_contact_panel.py | 4 +- src/aare/gui/panels/log_panel.py | 64 ++++++---- src/aare/gui/panels/portrait_mode.py | 39 +++--- .../gui/panels/prediction_metrics_panel.py | 6 +- src/aare/gui/tutorials/tutorial_manager.py | 14 ++- src/aare/gui/widgets/automation_progress.py | 6 +- src/aare/gui/widgets/baton_request_dialog.py | 12 +- src/aare/gui/widgets/raster_grid_table.py | 4 +- src/aare/gui/widgets/title_label.py | 53 +++++++- 12 files changed, 212 insertions(+), 137 deletions(-) diff --git a/src/aare/gui/panels/automation_panel.py b/src/aare/gui/panels/automation_panel.py index 3a9fa166..3cd4496b 100644 --- a/src/aare/gui/panels/automation_panel.py +++ b/src/aare/gui/panels/automation_panel.py @@ -15,6 +15,9 @@ from aare.gui.styles import ( AUTOMATION_TITLE_TEXT, CARD_BORDER, FAINT_TEXT, + FONT_BODY, + FONT_LABEL, + FONT_TITLE, MUTED_TEXT, STEP_ACTIVE_BG, STEP_ACTIVE_BORDER, @@ -62,13 +65,13 @@ class AutomationProgressWidget(QWidget): self._title_label = QLabel("Automation progress") self._title_label.setStyleSheet( - f"font-size: 16px; font-weight: 700; color: {AUTOMATION_TITLE_TEXT}; margin-bottom: 2px;" + f"font-size: {FONT_TITLE}; font-weight: 700; color: {AUTOMATION_TITLE_TEXT}; margin-bottom: 2px;" ) layout.addWidget(self._title_label) self._stats_label = QLabel() self._stats_label.setStyleSheet( - f"color: {AUTOMATION_HINT_TEXT}; font-size: 13px; " + f"color: {AUTOMATION_HINT_TEXT}; font-size: {FONT_LABEL}; font-weight: 700; " f"background-color: {SURFACE}; border: 1px solid {CARD_BORDER}; " "border-radius: 8px; padding: 10px;" ) @@ -140,7 +143,7 @@ class AutomationProgressWidget(QWidget): def _style_for_status(status: StepStatus) -> str: base = ( "padding: 10px 12px; border-radius: 10px; " - "font-size: 14px; border: 1px solid transparent;" + f"font-size: {FONT_BODY}; border: 1px solid transparent;" ) if status == StepStatus.SUCCESS: diff --git a/src/aare/gui/panels/beamline_recovery_panel.py b/src/aare/gui/panels/beamline_recovery_panel.py index f1d1d038..aa5c5a85 100644 --- a/src/aare/gui/panels/beamline_recovery_panel.py +++ b/src/aare/gui/panels/beamline_recovery_panel.py @@ -23,11 +23,13 @@ from aare.gui.styles import ( CHIP_INFO_TEXT, CHIP_WARN_BG, CHIP_WARN_TEXT, + FLAT_CARD_RADIUS, INFO_CARD_BG, INFO_CARD_BORDER, PENDING_CARD_BG, PENDING_CARD_BORDER, WARN_CARD_BORDER, + card_style, ) from aare.gui.threads.daq_worker import DAQWorker @@ -49,13 +51,14 @@ class RecoveryPanel(QWidget): ) self._warning_primary.setWordWrap(True) self._warning_primary.setStyleSheet( - "QLabel {" - f" background: {CHIP_WARN_BG};" - f" color: {CHIP_WARN_TEXT};" - f" border: 1px solid {WARN_CARD_BORDER};" - " padding: 10px;" - " font-weight: 600;" - "}" + card_style( + CHIP_WARN_BG, + WARN_CARD_BORDER, + CHIP_WARN_TEXT, + selector="QLabel", + radius=FLAT_CARD_RADIUS, + extra="padding: 10px; font-weight: 600;", + ) ) layout.addWidget(self._warning_primary) @@ -65,88 +68,95 @@ class RecoveryPanel(QWidget): ) self._warning_secondary.setWordWrap(True) self._warning_secondary.setStyleSheet( - "QLabel {" - f" background: {CHIP_BAD_BG};" - f" color: {CHIP_BAD_TEXT};" - f" border: 1px solid {BAD_CARD_BORDER};" - " padding: 10px;" - " font-weight: 600;" - "}" + card_style( + CHIP_BAD_BG, + BAD_CARD_BORDER, + CHIP_BAD_TEXT, + selector="QLabel", + radius=FLAT_CARD_RADIUS, + extra="padding: 10px; font-weight: 600;", + ) ) layout.addWidget(self._warning_secondary) self._last_action = QLabel("Last action: -", self) self._last_action.setWordWrap(True) self._last_action.setStyleSheet( - "QLabel {" - f" background: {INFO_CARD_BG};" - f" color: {CHIP_INFO_TEXT};" - f" border: 1px solid {INFO_CARD_BORDER};" - " padding: 10px;" - " font-weight: 600;" - "}" + card_style( + INFO_CARD_BG, + INFO_CARD_BORDER, + CHIP_INFO_TEXT, + selector="QLabel", + radius=FLAT_CARD_RADIUS, + extra="padding: 10px; font-weight: 600;", + ) ) layout.addWidget(self._last_action) self._take_over_btn = QPushButton("Take over beamline", self) self._take_over_btn.setStyleSheet( - "QPushButton {" - f" background: {PENDING_CARD_BG};" - f" border: 1px solid {PENDING_CARD_BORDER};" - " padding: 10px;" - " font-weight: 600;" - "}" + card_style( + PENDING_CARD_BG, + PENDING_CARD_BORDER, + selector="QPushButton", + radius=FLAT_CARD_RADIUS, + extra="padding: 10px; font-weight: 600;", + ) ) self._take_over_btn.clicked.connect(self._take_over_beamline) layout.addWidget(self._take_over_btn) self._free_beamline_btn = QPushButton("Free beamline", self) self._free_beamline_btn.setStyleSheet( - "QPushButton {" - f" background: {PENDING_CARD_BG};" - f" border: 1px solid {PENDING_CARD_BORDER};" - " padding: 10px;" - " font-weight: 600;" - "}" + card_style( + PENDING_CARD_BG, + PENDING_CARD_BORDER, + selector="QPushButton", + radius=FLAT_CARD_RADIUS, + extra="padding: 10px; font-weight: 600;", + ) ) self._free_beamline_btn.clicked.connect(self._free_beamline) layout.addWidget(self._free_beamline_btn) self._recover_beamline_btn = QPushButton("Recover beamline", self) self._recover_beamline_btn.setStyleSheet( - "QPushButton {" - f" background: {CHIP_BAD_BG};" - f" color: {CHIP_BAD_TEXT};" - f" border: 1px solid {BAD_CARD_BORDER};" - " padding: 10px;" - " font-weight: 700;" - "}" + card_style( + CHIP_BAD_BG, + BAD_CARD_BORDER, + CHIP_BAD_TEXT, + selector="QPushButton", + radius=FLAT_CARD_RADIUS, + extra="padding: 10px; font-weight: 700;", + ) ) self._recover_beamline_btn.clicked.connect(self._recover_beamline) layout.addWidget(self._recover_beamline_btn) self._recovery_unmount_btn = QPushButton("Unmount sample (recovery)", self) self._recovery_unmount_btn.setStyleSheet( - "QPushButton {" - f" background: {CHIP_BAD_BG};" - f" color: {CHIP_BAD_TEXT};" - f" border: 1px solid {BAD_CARD_BORDER};" - " padding: 10px;" - " font-weight: 700;" - "}" + card_style( + CHIP_BAD_BG, + BAD_CARD_BORDER, + CHIP_BAD_TEXT, + selector="QPushButton", + radius=FLAT_CARD_RADIUS, + extra="padding: 10px; font-weight: 700;", + ) ) self._recovery_unmount_btn.clicked.connect(self._recovery_unmount_sample) layout.addWidget(self._recovery_unmount_btn) self._resync_sample_btn = QPushButton("Resync sample from TELL", self) self._resync_sample_btn.setStyleSheet( - "QPushButton {" - f" background: {INFO_CARD_BG};" - f" color: {CHIP_INFO_TEXT};" - f" border: 1px solid {INFO_CARD_BORDER};" - " padding: 10px;" - " font-weight: 600;" - "}" + card_style( + INFO_CARD_BG, + INFO_CARD_BORDER, + CHIP_INFO_TEXT, + selector="QPushButton", + radius=FLAT_CARD_RADIUS, + extra="padding: 10px; font-weight: 600;", + ) ) self._resync_sample_btn.clicked.connect(self._resync_sample) layout.addWidget(self._resync_sample_btn) diff --git a/src/aare/gui/panels/developer_help_dialog.py b/src/aare/gui/panels/developer_help_dialog.py index fb818a24..a8c2b47a 100644 --- a/src/aare/gui/panels/developer_help_dialog.py +++ b/src/aare/gui/panels/developer_help_dialog.py @@ -38,6 +38,7 @@ from aare.gui.styles import ( PANEL_BORDER_DARK, PANEL_BORDER_LIGHT, WHITE, + card_style, ) from aare.gui.threads.daq_worker import DAQWorker @@ -69,12 +70,7 @@ class DeveloperHelpDialog(QDialog): self._banner.setWordWrap(True) self._banner.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self._banner.setStyleSheet( - "QLabel {" - f" background: {PANEL_BG_SOFT};" - f" border: 1px solid {PANEL_BORDER};" - " border-radius: 6px;" - " padding: 6px 8px;" - "}" + card_style(PANEL_BG_SOFT, PANEL_BORDER, selector="QLabel", extra="padding: 6px 8px;") ) root.addWidget(self._banner) @@ -153,10 +149,7 @@ class DeveloperHelpDialog(QDialog): self._details_frame = QFrame(self) self._details_frame.setFrameShape(QFrame.Shape.StyledPanel) - self._details_frame.setStyleSheet( - f"QFrame {{ background: {PANEL_BG_FAINT}; border: 1px solid {PANEL_BORDER};" - " border-radius: 6px;}" - ) + self._details_frame.setStyleSheet(card_style(PANEL_BG_FAINT, PANEL_BORDER)) details_layout = QVBoxLayout(self._details_frame) details_layout.setContentsMargins(10, 10, 10, 10) @@ -186,12 +179,7 @@ class DeveloperHelpDialog(QDialog): self._detail_help.setWordWrap(True) self._detail_help.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self._detail_help.setStyleSheet( - "QLabel {" - f" background: {WHITE};" - f" border: 1px solid {PANEL_BORDER_LIGHT};" - " border-radius: 6px;" - " padding: 8px;" - "}" + card_style(WHITE, PANEL_BORDER_LIGHT, selector="QLabel", extra="padding: 8px;") ) details_layout.addWidget(QLabel("Help:", self)) details_layout.addWidget(self._detail_help, 1) diff --git a/src/aare/gui/panels/local_contact_panel.py b/src/aare/gui/panels/local_contact_panel.py index 8153b9c8..1d6b523e 100644 --- a/src/aare/gui/panels/local_contact_panel.py +++ b/src/aare/gui/panels/local_contact_panel.py @@ -37,6 +37,7 @@ from aare.gui.styles import ( CHIP_BAD_TEXT, HEADING_TEXT, SURFACE, + card_style, ) from aare.gui.threads.daq_worker import DAQWorker from aare.gui.widgets.local_contact_status_widget import LocalContactStatusWidget @@ -121,8 +122,7 @@ class LocalContactPanel(QFrame): self._transfer_error_frame = QFrame(self) self._transfer_error_frame.setVisible(False) self._transfer_error_frame.setStyleSheet( - f"QFrame {{ background: {CHIP_BAD_BG}; color: {CHIP_BAD_TEXT};" - f" border: 1px solid {BAD_CARD_BORDER};}}" + card_style(CHIP_BAD_BG, BAD_CARD_BORDER, CHIP_BAD_TEXT) ) transfer_error_layout = QVBoxLayout(self._transfer_error_frame) transfer_error_layout.setContentsMargins(10, 10, 10, 10) diff --git a/src/aare/gui/panels/log_panel.py b/src/aare/gui/panels/log_panel.py index 659c8ae8..cd90338c 100644 --- a/src/aare/gui/panels/log_panel.py +++ b/src/aare/gui/panels/log_panel.py @@ -14,6 +14,7 @@ from PySide6.QtWidgets import ( from aare.gui.log import QtLogEmitter, QtLogHandler from aare.gui.styles import ( + FLAT_CARD_RADIUS, LOG_BORDER, LOG_ERROR_BG, LOG_ERROR_BORDER, @@ -24,6 +25,7 @@ from aare.gui.styles import ( LOG_SUCCESS_BORDER, LOG_WARN_BG, LOG_WARN_BORDER, + card_style, ) @@ -72,6 +74,7 @@ class RuntimeNotificationWidget(QFrame): button_layout.addWidget(self._clear_button) self._body = QWidget(self) + self._body.setObjectName("runtimeNotificationBody") body_layout = QVBoxLayout(self._body) body_layout.setContentsMargins(0, 0, 0, 0) body_layout.setSpacing(8) @@ -90,32 +93,41 @@ class RuntimeNotificationWidget(QFrame): self._sticky = True self.setStyleSheet( - f""" - QFrame#runtimeNotification {{ - border: 1px solid {LOG_BORDER}; - border-radius: 8px; - background-color: {LOG_PANEL_BG}; - }} - QFrame#runtimeNotification[noticeLevel="error"] {{ - background-color: {LOG_ERROR_BG}; - border: 1px solid {LOG_ERROR_BORDER}; - }} - QFrame#runtimeNotification[noticeLevel="warning"] {{ - background-color: {LOG_WARN_BG}; - border: 1px solid {LOG_WARN_BORDER}; - }} - QFrame#runtimeNotification[noticeLevel="success"] {{ - background-color: {LOG_SUCCESS_BG}; - border: 1px solid {LOG_SUCCESS_BORDER}; - }} - QFrame#runtimeNotification[noticeLevel="info"] {{ - background-color: {LOG_INFO_BG}; - border: 1px solid {LOG_INFO_BORDER}; - }} - QLabel#runtimeNotificationTitle {{ - font-weight: bold; - }} - """ + card_style( + LOG_PANEL_BG, + LOG_BORDER, + selector="QFrame#runtimeNotification", + radius=FLAT_CARD_RADIUS, + ) + + card_style( + LOG_ERROR_BG, + LOG_ERROR_BORDER, + selector='QFrame#runtimeNotification[noticeLevel="error"]', + radius=FLAT_CARD_RADIUS, + ) + + card_style( + LOG_WARN_BG, + LOG_WARN_BORDER, + selector='QFrame#runtimeNotification[noticeLevel="warning"]', + radius=FLAT_CARD_RADIUS, + ) + + card_style( + LOG_SUCCESS_BG, + LOG_SUCCESS_BORDER, + selector='QFrame#runtimeNotification[noticeLevel="success"]', + radius=FLAT_CARD_RADIUS, + ) + + card_style( + LOG_INFO_BG, + LOG_INFO_BORDER, + selector='QFrame#runtimeNotification[noticeLevel="info"]', + radius=FLAT_CARD_RADIUS, + ) + # Transparent children: the app-wide QWidget background rule would + # otherwise paint opaque strips over the card tint. + + " QLabel#runtimeNotificationTitle { font-weight: bold; background: transparent; }" + + " QLabel#runtimeNotificationMessage { background: transparent; }" + + " QWidget#runtimeNotificationBody { background: transparent; }" ) def _set_level(self, level: str) -> None: diff --git a/src/aare/gui/panels/portrait_mode.py b/src/aare/gui/panels/portrait_mode.py index 288a8856..5d66f104 100644 --- a/src/aare/gui/panels/portrait_mode.py +++ b/src/aare/gui/panels/portrait_mode.py @@ -32,6 +32,11 @@ from aare.gui.styles import ( DARK_SUCCESS_BG, DARK_SUCCESS_BORDER, DARK_SUCCESS_TEXT, + FONT_FINE, + FONT_HERO, + FONT_HINT, + FONT_LABEL, + FONT_VALUE, WHITE, qcolor, ) @@ -213,13 +218,13 @@ class QueueItemCard(QFrame): badge.setText("▶") badge.setStyleSheet(f""" color: {ACCENT}; background: {ACCENT_DIM}; - border-radius: 16px; font-size: 12px; font-weight: bold; + border-radius: 16px; font-size: {FONT_HINT}; font-weight: bold; """) else: badge.setText(str(index)) badge.setStyleSheet(f""" color: {SUBTEXT}; background: {BUTTON_BG}; - border-radius: 16px; font-size: 12px; + border-radius: 16px; font-size: {FONT_HINT}; """) layout.addWidget(badge) @@ -229,11 +234,11 @@ class QueueItemCard(QFrame): title_lbl = QLabel(title) title_lbl.setStyleSheet( - f"color: {TEXT}; font-size: 13px; font-weight: 600; background: transparent;" + f"color: {TEXT}; font-size: {FONT_LABEL}; font-weight: 700; background: transparent;" ) title_lbl.setWordWrap(False) sub_lbl = QLabel(subtitle) - sub_lbl.setStyleSheet(f"color: {SUBTEXT}; font-size: 11px; background: transparent;") + sub_lbl.setStyleSheet(f"color: {SUBTEXT}; font-size: {FONT_FINE}; background: transparent;") text_col.addWidget(title_lbl) text_col.addWidget(sub_lbl) layout.addLayout(text_col, stretch=1) @@ -292,7 +297,7 @@ class PortraitModePanel(QWidget): title = QLabel("S A M C A M E R A") title.setAlignment(Qt.AlignCenter) title.setStyleSheet( - f"color: {ACCENT}; font-size: 13px; letter-spacing: 3px; font-weight: 600;" + f"color: {ACCENT}; font-size: {FONT_LABEL}; letter-spacing: 3px; font-weight: 700;" ) layout.addWidget(title) @@ -311,7 +316,7 @@ class PortraitModePanel(QWidget): self._alert_toast_label = QLabel("") self._alert_toast_label.setWordWrap(True) self._alert_toast_label.setStyleSheet( - f"color: {DARK_ERROR_TEXT}; font-size: 11px; font-weight: 600; background: transparent;" + f"color: {DARK_ERROR_TEXT}; font-size: {FONT_FINE}; font-weight: 600; background: transparent;" ) toast_layout.addWidget(self._alert_toast_label) # Dismiss button @@ -322,7 +327,7 @@ class PortraitModePanel(QWidget): color: {SUBTEXT}; background: transparent; border: none; - font-size: 11px; + font-size: {FONT_FINE}; }} QPushButton:hover {{ color: {TEXT}; }} """) @@ -347,9 +352,9 @@ class PortraitModePanel(QWidget): # Sample name labels self._name_lbl = QLabel("—") - self._name_lbl.setStyleSheet(f"color: {TEXT}; font-size: 18px; font-weight: 700;") + self._name_lbl.setStyleSheet(f"color: {TEXT}; font-size: {FONT_VALUE}; font-weight: 700;") self._sub_lbl = QLabel("No sample queued") - self._sub_lbl.setStyleSheet(f"color: {SUBTEXT}; font-size: 12px;") + self._sub_lbl.setStyleSheet(f"color: {SUBTEXT}; font-size: {FONT_HINT};") layout.addWidget(self._name_lbl) layout.addWidget(self._sub_lbl) @@ -381,7 +386,7 @@ class PortraitModePanel(QWidget): border-radius: 26px; background: {BUTTON_BG}; color: {ACCENT}; - font-size: 28px; + font-size: {FONT_HERO}; font-weight: bold; }} QPushButton:checked {{ @@ -408,11 +413,11 @@ class PortraitModePanel(QWidget): up_next_row = QHBoxLayout() up_next_lbl = QLabel("UP NEXT") up_next_lbl.setStyleSheet( - f"color: {ACCENT}; font-size: 11px; letter-spacing: 2px; font-weight: 700;" + f"color: {ACCENT}; font-size: {FONT_FINE}; letter-spacing: 2px; font-weight: 700;" ) self._samples_count_lbl = QLabel("0 SAMPLES") self._samples_count_lbl.setStyleSheet( - f"color: {SUBTEXT}; font-size: 11px; letter-spacing: 1px;" + f"color: {SUBTEXT}; font-size: {FONT_FINE}; letter-spacing: 1px;" ) up_next_row.addWidget(up_next_lbl) up_next_row.addStretch() @@ -460,7 +465,7 @@ class PortraitModePanel(QWidget): title = QLabel("SAMPLE QUEUE") title.setAlignment(Qt.AlignCenter) title.setStyleSheet( - f"color: {ACCENT}; font-size: 13px; letter-spacing: 3px; font-weight: 700;" + f"color: {ACCENT}; font-size: {FONT_LABEL}; letter-spacing: 3px; font-weight: 700;" ) layout.addWidget(title) @@ -598,7 +603,9 @@ class PortraitModePanel(QWidget): if not samples: placeholder = QLabel("No samples in queue") - placeholder.setStyleSheet(f"color: {SUBTEXT}; font-size: 13px;") + placeholder.setStyleSheet( + f"color: {SUBTEXT}; font-size: {FONT_LABEL}; font-weight: 700;" + ) placeholder.setAlignment(Qt.AlignCenter) self._queue_inner_layout.addWidget(placeholder) return @@ -634,7 +641,7 @@ class PortraitModePanel(QWidget): border: 1.5px solid {ACCENT}; border-radius: 14px; color: {ACCENT}; - font-size: 12px; + font-size: {FONT_HINT}; font-weight: 700; letter-spacing: 1.5px; }} @@ -677,7 +684,7 @@ class PortraitModePanel(QWidget): }} """) self._alert_toast_label.setStyleSheet( - f"color: {text_color}; font-size: 11px; font-weight: 600; background: transparent;" + f"color: {text_color}; font-size: {FONT_FINE}; font-weight: 600; background: transparent;" ) self._alert_toast.setVisible(True) diff --git a/src/aare/gui/panels/prediction_metrics_panel.py b/src/aare/gui/panels/prediction_metrics_panel.py index 44f2cab2..7302ea30 100644 --- a/src/aare/gui/panels/prediction_metrics_panel.py +++ b/src/aare/gui/panels/prediction_metrics_panel.py @@ -49,6 +49,8 @@ from aare.gui.styles import ( CHART_ORANGE, CHART_RED, CONFIDENCE_BIN_COLORS, + FONT_BODY, + FONT_TITLE, qcolor, ) from aare.gui.styles import CHART_CLASS_COLORS as CLASS_COLORS @@ -208,7 +210,7 @@ class ObjectCountWidget(QWidget): # Create colored indicator square indicator = QLabel("■") - indicator.setStyleSheet(f"color: {color}; font-size: 16px;") + indicator.setStyleSheet(f"color: {color}; font-size: {FONT_TITLE};") indicator.setFixedWidth(20) # Class name label @@ -217,7 +219,7 @@ class ObjectCountWidget(QWidget): # Count label with matching color count_label = QLabel("0") - count_label.setStyleSheet(f"color: {color}; font-size: 14px; font-weight: bold;") + count_label.setStyleSheet(f"color: {color}; font-size: {FONT_BODY}; font-weight: bold;") count_label.setAlignment(Qt.AlignmentFlag.AlignRight) row = i // 2 diff --git a/src/aare/gui/tutorials/tutorial_manager.py b/src/aare/gui/tutorials/tutorial_manager.py index 24187eec..8180a238 100644 --- a/src/aare/gui/tutorials/tutorial_manager.py +++ b/src/aare/gui/tutorials/tutorial_manager.py @@ -21,6 +21,8 @@ from PySide6.QtWidgets import QLabel, QPushButton, QWidget from aare.gui.constants import LOGGER_NAME from aare.gui.styles import ( DEFAULT_TEXT, + FONT_BODY_LG, + FONT_TITLE, NOTE_TEXT, SHADOW, TUTORIAL_BORDER, @@ -110,17 +112,17 @@ class TutorialOverlay(QWidget): padding: 16px; border: 2px solid {TUTORIAL_BORDER}; border-radius: 10px; - font-size: 16px; + font-size: {FONT_TITLE}; """) self.callout.setWordWrap(True) self.callout.hide() - button_style = """ - QPushButton { - font-size: 15px; - font-weight: 600; + button_style = f""" + QPushButton {{ + font-size: {FONT_BODY_LG}; + font-weight: 700; padding: 10px 16px; - } + }} """ self.back_button = QPushButton("Back", self) diff --git a/src/aare/gui/widgets/automation_progress.py b/src/aare/gui/widgets/automation_progress.py index a1f000c2..af5ccfda 100644 --- a/src/aare/gui/widgets/automation_progress.py +++ b/src/aare/gui/widgets/automation_progress.py @@ -9,6 +9,8 @@ from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout, QWidget from aare.gui.styles import ( FAINT_TEXT, + FONT_HINT, + FONT_VALUE, STEP_FAILED_TEXT, STEP_PAUSED_TEXT, STEP_RUNNING_TEXT, @@ -111,8 +113,8 @@ class CompactAutomationProgressStrip(QFrame): title = self._step_title(step) return ( f"
" - f"
{icon}
" - f"
{title}
" + f"
{icon}
" + f"
{title}
" f"
" ) diff --git a/src/aare/gui/widgets/baton_request_dialog.py b/src/aare/gui/widgets/baton_request_dialog.py index 0d86aa90..7935145b 100644 --- a/src/aare/gui/widgets/baton_request_dialog.py +++ b/src/aare/gui/widgets/baton_request_dialog.py @@ -20,6 +20,8 @@ from aare.gui.styles import ( BATON_OK_PRESSED, BATON_WARN, DIM_TEXT, + FONT_BODY, + FONT_LABEL, HINT_TEXT, LIGHT_BORDER, PROGRESS_TRACK_BG, @@ -129,7 +131,7 @@ class BatonRequestDialog(QDialog): border: none; border-radius: 5px; font-weight: bold; - font-size: 13px; + font-size: {FONT_LABEL}; }} QPushButton:hover {{ background-color: {BATON_OK_HOVER}; @@ -150,7 +152,7 @@ class BatonRequestDialog(QDialog): border: none; border-radius: 5px; font-weight: bold; - font-size: 13px; + font-size: {FONT_LABEL}; }} QPushButton:hover {{ background-color: {BATON_DANGER_HOVER}; @@ -316,7 +318,7 @@ class BatonPendingDialog(QDialog): border: none; border-radius: 5px; font-weight: bold; - font-size: 13px; + font-size: {FONT_LABEL}; }} QPushButton:hover {{ background-color: {BATON_DANGER_HOVER}; }} QPushButton:pressed {{ background-color: {BATON_DANGER_PRESSED}; }} @@ -348,7 +350,9 @@ class BatonPendingDialog(QDialog): def set_queued_state(self): self._timer.stop() self.header.setText("⏳ Transfer Queued") - self.header.setStyleSheet(f"color: {BATON_WARN}; font-weight: bold; font-size: 14px;") + self.header.setStyleSheet( + f"color: {BATON_WARN}; font-weight: bold; font-size: {FONT_BODY};" + ) self.message_label.setText( "The beamline is currently busy. Your request was accepted and " "the baton will be transferred as soon as the current operation completes." diff --git a/src/aare/gui/widgets/raster_grid_table.py b/src/aare/gui/widgets/raster_grid_table.py index df73cfe3..ee497610 100644 --- a/src/aare/gui/widgets/raster_grid_table.py +++ b/src/aare/gui/widgets/raster_grid_table.py @@ -9,7 +9,7 @@ from PySide6.QtWidgets import ( ) from aare.gui.scan_logic.raster_grid_manager import RasterGridManager -from aare.gui.styles import TABLE_SHADE_BG +from aare.gui.styles import FONT_BODY, TABLE_SHADE_BG class RasterGridTable(QTableWidget): @@ -70,7 +70,7 @@ class RasterGridTable(QTableWidget): QPushButton {{ border: none; background: transparent; - font-size: 14px; + font-size: {FONT_BODY}; }} QPushButton:hover {{ background-color: {TABLE_SHADE_BG}; diff --git a/src/aare/gui/widgets/title_label.py b/src/aare/gui/widgets/title_label.py index 3e1090dd..500d014a 100644 --- a/src/aare/gui/widgets/title_label.py +++ b/src/aare/gui/widgets/title_label.py @@ -1,7 +1,17 @@ from PySide6.QtCore import QSettings, Qt, QTimer -from PySide6.QtWidgets import QHBoxLayout, QLabel, QLayout, QPushButton +from PySide6.QtGui import QPainter +from PySide6.QtWidgets import QHBoxLayout, QLabel, QLayout, QPushButton, QStyle, QStyleOption -from aare.gui.styles import BANNER, BANNER_TEXT +from aare.gui.styles import ( + BANNER, + BANNER_TEXT, + BANNER_TEXT_SHADOW, + FONT_BODY, + FONT_HINT, + FONT_TITLE, + MUTED_TEXT, + qcolor, +) # Universal vertical rhythm between stacked panels: each panel contributes # PANEL_VMARGIN top and bottom, the column adds PANEL_VSPACING between them, @@ -26,6 +36,17 @@ def tighten_column(layout: QLayout) -> None: child_layout.setContentsMargins(m.left(), PANEL_VMARGIN, m.right(), PANEL_VMARGIN) +def section_title(text: str, parent=None) -> QLabel: + """Small in-panel section heading — for controls grouped under one + shared TitleLabel banner (e.g. the Beam Config. panel).""" + label = QLabel(text, parent) + label.setAlignment(Qt.AlignmentFlag.AlignCenter) + label.setStyleSheet( + f"color: {MUTED_TEXT}; font-size: {FONT_HINT}; font-weight: 700; background: transparent;" + ) + return label + + class TitleLabel(QLabel): def __init__(self, text: str, parent=None, collapsible: bool = False): super().__init__(parent) @@ -37,7 +58,7 @@ class TitleLabel(QLabel): # the app QSS. self.setStyleSheet( f"TitleLabel {{ background-color: {BANNER}; color: {BANNER_TEXT};" - " font-size: 16px; font-weight: 700; }" + f" font-size: {FONT_TITLE}; font-weight: 700; }}" ) self.setAlignment(Qt.AlignmentFlag.AlignCenter) @@ -58,7 +79,7 @@ class TitleLabel(QLabel): # a translucent white background, which is unwanted on these banners. self.toggle_button.setStyleSheet( f"QPushButton {{ background: transparent; border: none;" - f" color: {BANNER_TEXT}; font-size: 14px; font-weight: 700; }}" + f" color: {BANNER_TEXT}; font-size: {FONT_BODY}; font-weight: 700; }}" ) self.toggle_button.setToolTip("Minimise panel") self.toggle_button.setFixedSize(21, 21) @@ -82,6 +103,30 @@ class TitleLabel(QLabel): # the TitleLabel, so siblings don't exist yet. QTimer.singleShot(0, self._apply_collapsed) + def paintEvent(self, event): + # QSS has no text-shadow, so paint by hand: the QSS background box + # first, then the title twice — an offset dark pass under the normal + # one — for the subtle emboss the OS titlebar text has. + painter = QPainter(self) + opt = QStyleOption() + opt.initFrom(self) + self.style().drawPrimitive(QStyle.PrimitiveElement.PE_Widget, opt, painter, self) + + flags = int(self.alignment()) + # Reserve the toggle-button zone (21px + 8px margin) on BOTH sides in + # the text rect only — widget margins would move the button itself. + reserve = 29 if self._collapsible else 0 + rect = self.rect().adjusted(reserve, 0, -reserve, 0) + painter.setFont(self.font()) + # Elide instead of overflowing when the panel column is narrow. + text = painter.fontMetrics().elidedText( + self.text(), Qt.TextElideMode.ElideRight, rect.width() + ) + painter.setPen(qcolor(BANNER_TEXT_SHADOW, 110)) + painter.drawText(rect.translated(0, 1), flags, text) + painter.setPen(qcolor(BANNER_TEXT)) + painter.drawText(rect, flags, text) + def mousePressEvent(self, event): if self._collapsible: self.toggle_collapsed() -- 2.54.0 From 00e5d116a61c16e91bbf456c05e90ca8341f18d9 Mon Sep 17 00:00:00 2001 From: Dawn Date: Fri, 7 Aug 2026 11:54:36 +0200 Subject: [PATCH 16/57] feat: group beam mark/center/size under one Beam Config banner The three small beam panels shared a column with three collapsible banners; BeamConfigPanel puts them under a single collapsible 'Beam Config.' TitleLabel with section_title() sub-headings. beam_mark/beam_center/beam_size stay as aliases on BeamlineControls so main_window signal wiring is untouched. Co-Authored-By: Claude Fable 5 --- src/aare/gui/panels/beam_center_panel.py | 6 ++-- src/aare/gui/panels/beam_mark_panel.py | 4 +-- src/aare/gui/panels/beam_size_panel.py | 4 +-- src/aare/gui/panels/beamline_controls.py | 39 +++++++++++++++++++----- 4 files changed, 37 insertions(+), 16 deletions(-) diff --git a/src/aare/gui/panels/beam_center_panel.py b/src/aare/gui/panels/beam_center_panel.py index 4140bc9f..45236fbb 100644 --- a/src/aare/gui/panels/beam_center_panel.py +++ b/src/aare/gui/panels/beam_center_panel.py @@ -3,7 +3,7 @@ from PySide6.QtCore import Signal, Slot from PySide6.QtWidgets import QGridLayout, QLabel, QWidget from aare.gui.widgets.number_line_edit import NumberLineEdit -from aare.gui.widgets.title_label import TitleLabel +from aare.gui.widgets.title_label import section_title class BeamCenterWidget(QWidget): @@ -14,9 +14,7 @@ class BeamCenterWidget(QWidget): grid_layout = QGridLayout(self) - grid_layout.addWidget( - TitleLabel("Beam center (detector)", self, collapsible=True), 0, 0, 1, 5 - ) + grid_layout.addWidget(section_title("Beam center (detector)", self), 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 2842f70e..3a74895a 100644 --- a/src/aare/gui/panels/beam_mark_panel.py +++ b/src/aare/gui/panels/beam_mark_panel.py @@ -2,7 +2,7 @@ from aarecommon.models.models import DAQStatusModel from PySide6.QtCore import Signal, Slot from PySide6.QtWidgets import QGridLayout, QLabel, QPushButton, QWidget -from aare.gui.widgets.title_label import TitleLabel +from aare.gui.widgets.title_label import section_title class BeamMarkWidget(QWidget): @@ -13,7 +13,7 @@ class BeamMarkWidget(QWidget): grid_layout = QGridLayout(self) - grid_layout.addWidget(TitleLabel("Beam mark (image)", self, collapsible=True), 0, 0, 1, 5) + grid_layout.addWidget(section_title("Beam mark (image)", self), 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 56fb765f..83aa4afd 100644 --- a/src/aare/gui/panels/beam_size_panel.py +++ b/src/aare/gui/panels/beam_size_panel.py @@ -3,7 +3,7 @@ from PySide6.QtCore import Signal, Slot from PySide6.QtWidgets import QGridLayout, QLabel, QWidget from aare.gui.widgets.number_line_edit import NumberLineEdit -from aare.gui.widgets.title_label import TitleLabel +from aare.gui.widgets.title_label import section_title class BeamSizeWidget(QWidget): @@ -14,7 +14,7 @@ class BeamSizeWidget(QWidget): grid_layout = QGridLayout(self) - grid_layout.addWidget(TitleLabel("Beam size", self, collapsible=True), 0, 0, 1, 5) + grid_layout.addWidget(section_title("Beam size", self), 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 771c9845..f98070de 100644 --- a/src/aare/gui/panels/beamline_controls.py +++ b/src/aare/gui/panels/beamline_controls.py @@ -1,4 +1,4 @@ -from PySide6.QtWidgets import QFrame, QVBoxLayout +from PySide6.QtWidgets import QFrame, QVBoxLayout, QWidget from aare.gui.panels.abr_tweak_panel import AbrTweakWidget from aare.gui.panels.beam_center_panel import BeamCenterWidget @@ -10,7 +10,30 @@ 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 +from aare.gui.widgets.title_label import TitleLabel, tighten_column + + +class BeamConfigPanel(QWidget): + """Beam mark / center / size grouped under one collapsible banner; the + sub-widgets keep their own signals and small section titles.""" + + def __init__(self, parent=None): + super().__init__(parent) + # Default layout margins so the banner aligns with the sibling + # panels' banners in the column. + layout = QVBoxLayout(self) + layout.setSpacing(0) + layout.addWidget(TitleLabel("Beam Config.", self, collapsible=True)) + self.beam_mark = BeamMarkWidget(self) + self.beam_center = BeamCenterWidget(self) + self.beam_size = BeamSizeWidget(self) + for sub in (self.beam_mark, self.beam_center, self.beam_size): + sub_layout = sub.layout() + assert sub_layout is not None # each sub-widget builds its grid in __init__ + # The outer layout already indents; zero the sub-grids' side + # margins so section content isn't double-inset. + sub_layout.setContentsMargins(0, 4, 0, 4) + layout.addWidget(sub) class BeamlineControls(QFrame): @@ -42,15 +65,15 @@ class BeamlineControls(QFrame): if staff: self.monochromator_panel = MonochromatorPanel(self) self.abr_tweak = AbrTweakWidget(self) - self.beam_mark = BeamMarkWidget(self) - self.beam_center = BeamCenterWidget(self) - self.beam_size = BeamSizeWidget(self) + self.beam_config = BeamConfigPanel(self) + # Aliases: main_window wires signals via beamline.beam_mark etc. + self.beam_mark = self.beam_config.beam_mark + self.beam_center = self.beam_config.beam_center + self.beam_size = self.beam_config.beam_size self.v_layout.addWidget(self.monochromator_panel) self.v_layout.addWidget(self.abr_tweak) - self.v_layout.addWidget(self.beam_mark) - self.v_layout.addWidget(self.beam_center) - self.v_layout.addWidget(self.beam_size) + self.v_layout.addWidget(self.beam_config) self.v_layout.addStretch() tighten_column(self.v_layout) -- 2.54.0 From 266f8337a9a1fcc163d2abf8a70a2b1038c454d2 Mon Sep 17 00:00:00 2001 From: Dawn Date: Fri, 7 Aug 2026 11:54:45 +0200 Subject: [PATCH 17/57] fix: window and dock sizing behavior Three main-window chrome fixes: the un-maximized geometry is pre-set to half-screen centered so leaving maximized mode no longer restores an oversized size hint (the beamline state bar also stops imposing its ~2000px minimum width); closing a popped-out dock re-docks it instead of silently hiding the panel, and pop-outs open enlarged and clamped to the screen; state-bar buttons only update font/stylesheet/cursor when values change, since repolishing every DAQ tick dropped the hover cursor under a resting mouse. Co-Authored-By: Claude Fable 5 --- src/aare/gui/gui.py | 9 +++++ src/aare/gui/main_window.py | 38 +++++++++++++++++++ src/aare/gui/panels/beamline_state_panel.py | 41 +++++++++++++++++---- 3 files changed, 80 insertions(+), 8 deletions(-) diff --git a/src/aare/gui/gui.py b/src/aare/gui/gui.py index 93d9646c..ca2876c7 100644 --- a/src/aare/gui/gui.py +++ b/src/aare/gui/gui.py @@ -189,6 +189,15 @@ def main(): splash.set_progress(100, "Ready") splash.finish(win) + # Pre-set the "normal" (un-maximized) geometry as a fraction of the + # primary screen, centered — otherwise leaving maximized mode restores + # the size hint, which is wider than the monitor. Other panels may + # still enforce a somewhat larger minimum; the window then lands on + # that minimum instead. + unmax_w, unmax_h = 0.5, 0.7 + available = app.primaryScreen().availableGeometry() + win.resize(int(available.width() * unmax_w), int(available.height() * unmax_h)) + win.move(available.center() - win.rect().center()) # Maximized so the window adapts to the monitor instead of its size hint, # which is taller than a 1920x1200 console. win.showMaximized() diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 0dbddc3a..2ca59df5 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -884,6 +884,13 @@ class MainWindow(QMainWindow): register_tutorials(self, self.tutorial_manager) + # Every dock: closing its popped-out window brings it back into the + # layout instead of hiding it (eventFilter), and popping out opens + # an enlarged window (2x width, 3x height, clamped to the screen). + for dock in self.findChildren(QDockWidget): + dock.installEventFilter(self) + dock.topLevelChanged.connect(self._on_dock_top_level_changed) + def _setup_global_shortcuts(self) -> None: self._shortcut_manual_sample = QAction("Expand Manual Sample", self) self._shortcut_manual_sample.setShortcut(QKeySequence("Ctrl+M")) @@ -2410,7 +2417,38 @@ class MainWindow(QMainWindow): def _mark_user_interaction(self) -> None: self._refresh_idle_activity(report_backend=True) + @Slot(bool) + def _on_dock_top_level_changed(self, floating: bool) -> None: + dock = self.sender() + if floating and isinstance(dock, QDockWidget): + # Pop-outs open enlarged instead of keeping the cramped docked + # size. Deferred: the window is mid-reparent while the signal + # fires. + QTimer.singleShot(0, lambda d=dock: self._enlarge_floating_dock(d)) + + def _enlarge_floating_dock(self, dock: QDockWidget) -> None: + if not dock.isFloating(): + return + screen = dock.screen().availableGeometry() + dock.resize( + min(dock.width() * 2, int(screen.width() * 0.9)), + min(dock.height() * 3, int(screen.height() * 0.9)), + ) + # Keep the enlarged window fully on screen. + geo = dock.frameGeometry() + dx = min(0, screen.right() - geo.right()) + dy = min(0, screen.bottom() - geo.bottom()) + if dx or dy: + dock.move(geo.x() + dx, geo.y() + dy) + def eventFilter(self, obj, event): + # Closing a floated (popped-out) dock re-docks it instead of hiding — + # otherwise the panel silently disappears and has to be restored via + # the View menu. + if event.type() == QEvent.Type.Close and isinstance(obj, QDockWidget) and obj.isFloating(): + obj.setFloating(False) + event.ignore() + return True try: if event.type() in { QEvent.Type.MouseButtonPress, diff --git a/src/aare/gui/panels/beamline_state_panel.py b/src/aare/gui/panels/beamline_state_panel.py index c8da2279..db372118 100644 --- a/src/aare/gui/panels/beamline_state_panel.py +++ b/src/aare/gui/panels/beamline_state_panel.py @@ -5,7 +5,13 @@ from PySide6.QtCore import Qt, QTimer, Signal, Slot from PySide6.QtGui import QCursor, QFont, QFontMetrics from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QMenu, QPushButton, QSizePolicy, QToolTip -from aare.gui.styles import STATE_AVAILABLE, STATE_MSG_ERROR, STATE_MSG_INFO, STATE_UNAVAILABLE +from aare.gui.styles import ( + FONT_VALUE, + STATE_AVAILABLE, + STATE_MSG_ERROR, + STATE_MSG_INFO, + STATE_UNAVAILABLE, +) # Shortcut transitions from the "Available transitions" menu in # widgets/status_bar.py show_state_menu — these come ON TOP of the one-hop @@ -154,7 +160,7 @@ class BeamlineStatePanel(QFrame): if index: separator = QLabel("–", self) separator.setStyleSheet( - f"color: {STATE_UNAVAILABLE}; background: transparent; border: none; font-size: 18px;" + f"color: {STATE_UNAVAILABLE}; background: transparent; border: none; font-size: {FONT_VALUE};" ) layout.addWidget(separator) button = HoverableButton(label, self) @@ -178,6 +184,15 @@ class BeamlineStatePanel(QFrame): layout.addStretch(1) self._apply_highlight() + def minimumSizeHint(self): + # The 13-button strip would otherwise impose a ~2000px minimum on the + # whole main window, so an un-maximized window could never fit the + # screen. Width 0: the strip adapts (two-line labels) and, below that, + # clips — the window stays freely resizable. + hint = super().minimumSizeHint() + hint.setWidth(0) + return hint + def resizeEvent(self, event) -> None: super().resizeEvent(event) self._update_label_mode() @@ -325,21 +340,31 @@ class BeamlineStatePanel(QFrame): font = QFont(self.font()) font.setPixelSize(18) font.setWeight(QFont.Weight.Bold if bold else QFont.Weight.Normal) - button.setFont(font) - button.setStyleSheet( + if button.font() != font: + button.setFont(font) + # Guarded updates: this runs on every DAQ tick, and re-applying an + # unchanged stylesheet repolishes the button, which drops the hover + # cursor under a resting mouse until it moves again. + qss = ( f"QPushButton {{ border: none; background: transparent; color: {color};" f" padding: 1px 8px; }}" f" QPushButton:hover {{ color: {color}; }}" ) + if button.styleSheet() != qss: + button.setStyleSheet(qss) # Clickability follows availability; unavailable states get the # forbidden cursor and only the deferred 3 s explanation tooltip. if is_available: - button.setCursor(Qt.CursorShape.PointingHandCursor) - button.setToolTip(self._TOOLTIPS.get(state, state.display_name())) + cursor = Qt.CursorShape.PointingHandCursor + tooltip = self._TOOLTIPS.get(state, state.display_name()) else: - button.setCursor(Qt.CursorShape.ForbiddenCursor) - button.setToolTip("") + cursor = Qt.CursorShape.ForbiddenCursor + tooltip = "" + if button.cursor().shape() != cursor: + button.setCursor(cursor) + if button.toolTip() != tooltip: + button.setToolTip(tooltip) def set_current_state(self, state: BeamlineStateEnum | None) -> None: self._current_state = state -- 2.54.0 From efcdc0c1912c0024719105411f1bb0a7c09225dd Mon Sep 17 00:00:00 2001 From: Dawn Date: Fri, 7 Aug 2026 11:56:06 +0200 Subject: [PATCH 18/57] feat: swap the splash banner to an SVG The PNG banner is replaced by an SVG so the splash artwork scales; the old aare_banner.png is removed and gui.py loads the .svg path (the stale path yielded a null splash pixmap). Co-Authored-By: Claude Fable 5 --- src/aare/gui/graphics/aare_banner.png | Bin 120708 -> 0 bytes src/aare/gui/graphics/aare_banner.svg | 25 +++++++++++++++++++++++++ src/aare/gui/gui.py | 4 +++- 3 files changed, 28 insertions(+), 1 deletion(-) delete mode 100644 src/aare/gui/graphics/aare_banner.png create mode 100644 src/aare/gui/graphics/aare_banner.svg diff --git a/src/aare/gui/graphics/aare_banner.png b/src/aare/gui/graphics/aare_banner.png deleted file mode 100644 index 831224d281024929577273db2469291809e923ca..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 120708 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 Xiu~;?NyH6Y00000NkvXXu0mjfRXWap diff --git a/src/aare/gui/graphics/aare_banner.svg b/src/aare/gui/graphics/aare_banner.svg new file mode 100644 index 00000000..7fd8cf23 --- /dev/null +++ b/src/aare/gui/graphics/aare_banner.svg @@ -0,0 +1,25 @@ + + + + + + + + AARE + + + + Advanced softwARE for + + macromolecular + + crystallography at + + Swiss Light Source 2.0 + + + \ No newline at end of file diff --git a/src/aare/gui/gui.py b/src/aare/gui/gui.py index ca2876c7..9ccbed73 100644 --- a/src/aare/gui/gui.py +++ b/src/aare/gui/gui.py @@ -21,7 +21,9 @@ def main(): try: basedir = os.path.dirname(__file__) icon_path = os.path.join(basedir, "graphics/aaregui_logo.svg") - banner_path = os.path.join(basedir, "graphics/aare_banner.png") + # The banner is an SVG now; the old aare_banner.png no longer exists + # and yielded a null splash pixmap. + banner_path = os.path.join(basedir, "graphics/aare_banner.svg") except Exception: logger.exception("Failed to load resources for splash screen") sys.exit(1) -- 2.54.0 From 0bfa9dbbeb5f8798fac8506c420d5ccdb33ed514 Mon Sep 17 00:00:00 2001 From: Dawn Date: Fri, 7 Aug 2026 17:08:22 +0200 Subject: [PATCH 19/57] style: extend the palette with selection, frame, slider, and sample-status tokens SELECTION_BG/TEXT pin the app-wide selection blue to the banner color (was Qt's undeclared palette default); FRAME_L1/L2/L3 knobs put window, panel, and data-view borders on three tunable levels (panel etching off by default); QTabBar tabs and filter chips go borderless with a hover underline; QSlider matches the scrollbar look; scrollbar tracks turn square with a track-colored corner; SAMPLE_STATUS_* and SAMPLE_ROW_ALT_BG drive the sample-table status tints. Co-Authored-By: Claude Fable 5 --- src/aare/gui/styles.py | 260 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 245 insertions(+), 15 deletions(-) diff --git a/src/aare/gui/styles.py b/src/aare/gui/styles.py index 8a86223b..02241a6d 100644 --- a/src/aare/gui/styles.py +++ b/src/aare/gui/styles.py @@ -26,6 +26,37 @@ BORDER = "transparent" # main dividers, e.g. the beamline state bar top line CARD_BORDER = "transparent" # cards / group boxes (Local Contact, automation) COMPACT_BORDER = "transparent" # compact-automation cards and buttons +# -- Box-frame borders by nesting level ------------------------------------- +# One knob per level so the amount of "boxing" is tunable in one place: +# L1: top-level pop-out window edge (painted by PopoutWindow) +# L2: panel frames inside a dock/tab (TELL sample changer, reference +# tools, sample queue) — transparent by default: banner + table +# already delimit the panel, extra boxes read as clutter +# L3: data views (tables, log text) — a light line so scrollable content +# keeps a visible edge +# (The tab-widget pane is a fourth box, already governed by BORDER above.) +FRAME_L1_WIDTH = "0px" # default off — the pop-out reads as one clean surface +FRAME_L1_COLOR = "#bdbdbd" +FRAME_L2_WIDTH = "0px" +FRAME_L2_COLOR = "transparent" +FRAME_L3_WIDTH = "1px" +FRAME_L3_COLOR = "#c9cfd8" + +# Left inset (px, int — used in code, not QSS) for bottom-dock content so its +# left edge lines up with the left-column panels above (Loop centering). +DOCK_CONTENT_LEFT_PAD = 10 + +# Tab face fill: the selected Dewar/Auxiliary tab AND the unchecked +# All/Queued/Flagged/Measured filter buttons share this background. +TAB_FACE_BG = "#ffffff" + +# Selection highlight (combo popups, menus, list rows, text selections). +# Qt's style palette used to supply its own blue here; now it is the banner +# blue, and THIS is the knob for it. (Table-row selection has its own paler +# knob: SAMPLE_STATUS_SELECTED_BG.) +SELECTION_BG = BANNER +SELECTION_TEXT = "#263043" # same as TEXT — readable on the banner blue + # Compact-automation page: COMPACT_CARD_BG = "#e6eefc" COMPACT_TITLE = "#17324d" # section titles + menu/secondary button text @@ -194,11 +225,20 @@ STATE_MSG_INFO = "#005caa" # -- Sample tables + raster grid -------------------------------------------- SAMPLE_ROW_ACTIVE_BG = "#ff6600" -SAMPLE_ROW_QUEUED_BG = "#729fcf" +SAMPLE_ROW_QUEUED_BG = "#729fcf" # mounted/current-sample row (legacy name) SAMPLE_ROW_HIGHLIGHT_BG = "#d8e4fd" RASTER_GRID_LINE = "#729fcf" TABLE_SHADE_BG = "#e0e0e0" +# -- Sample status row tints (dewar/queue view) ----------------------------- +# Status colors now fill the rounded dot in the table's status column; rows +# themselves alternate WHITE / SAMPLE_ROW_ALT_BG. Tune the palette here. +SAMPLE_ROW_ALT_BG = "#eef1f5" # staggered row grey (alternates with white) +SAMPLE_STATUS_QUEUED_BG = "#ffe4c4" # pale orange — waiting in the automation queue +SAMPLE_STATUS_FLAGGED_BG = "#ffd9d9" # pale red — automation failed on this sample +SAMPLE_STATUS_MEASURED_BG = "#dcf2e0" # pale green — already has collected data +SAMPLE_STATUS_SELECTED_BG = "#d8e8fd" # pale blue — table selection highlight + # -- Camera / video overlay (painter colors, alpha at call site) ------------ BEAM_OPEN = "#00ff00" # beam marker: shutter open BEAM_IDLE = "#f57900" # beam marker: idle @@ -307,6 +347,11 @@ TOOLTIP_FG = "#263043" DARK_TOOLTIP_BG = "#363a4f" # surface0 DARK_TOOLTIP_FG = "#cad3f5" # text +# -- Sliders ---------------------------------------------------------------- +# Own knob instead of PRIMARY: full-saturation button blue was too loud for a +# passive fill (illumination panel). Muted slate-blue, tweak freely. +SLIDER_FILL = "#8ba3c7" + # -- Scrollbars (rounded, no arrows: grey track, darker draggable handle) --- SCROLLBAR_TRACK = "#d8dde5" SCROLLBAR_HANDLE = "#a8b2c0" @@ -542,10 +587,59 @@ def _original_stylesheet() -> str: font-weight: bold; } + /* Disabled = baton-gated ("watch only"): the explicit colors above mask + Qt's native grey, so spell the greyed state out. */ + QPushButton:disabled, QCheckBox:disabled, QRadioButton:disabled, + QLabel:disabled, QComboBox:disabled, QLineEdit:disabled, + QAbstractSpinBox:disabled, QTabBar::tab:disabled { + color: $faint_text; + } + + QLineEdit:disabled, QAbstractSpinBox:disabled, QComboBox:disabled { + background: $scrollbar_track; + } + + QSlider::sub-page:horizontal:disabled { + background: $scrollbar_handle; + } + QTabWidget::pane { border: 1px solid $border; } + /* Exp. Config. pane: hand-rolled QTabBar+QStackedWidget (button row sits + between bar and pages), so it needs the pane border by hand too. */ + #expConfigPane { + border: 1px solid $border; + } + + /* Borderless tabs: the pane isn't outlined, so the native 3-side tab + boxes hung in the air. Selection shows via fill + the raised effect + (unselected tabs sit 3px lower). */ + QTabBar::tab { + background: transparent; + border: none; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + padding: 4px 14px; + margin-top: 3px; + color: $muted_text; + } + + QTabBar::tab:selected { + background: $tab_face_bg; + color: $text; + margin-top: 0px; + padding: 6px 14px 5px 14px; + } + + /* Menu-bar-style hover hint: text-decoration underlines only the text + itself (short, close to the baseline) instead of the full tab box. */ + QTabBar::tab:hover:!selected { + color: $text; + text-decoration: underline; + } + QMainWindow::separator { background: $border; width: 4px; @@ -594,18 +688,78 @@ def _original_stylesheet() -> str: background: transparent; } - /* Plain scroll containers stay frameless; data views (tables, trees, - text/log views) keep their native frame — those borders are wanted. */ + /* Plain scroll containers stay frameless. */ QScrollArea { border: none; } - /* Soft scrollbars: rounded track + draggable handle, no end arrows. */ + /* Box-frame levels — weights/colors are knobs in styles.py. L2: panel + frames (replaces their native StyledPanel etching); L3: data views. */ + TellSamplePanel, ReferenceToolsPanel, SampleQueuePanel { + border: $frame_l2_width solid $frame_l2_color; + } + + QTableView, QPlainTextEdit { + border: $frame_l3_width solid $frame_l3_color; + } + + /* Pale-blue selection with readable dark text in every sample table; + staggered grey/white rows where alternation is enabled. */ + QTableView { + background: $white; + alternate-background-color: $sample_row_alt_bg; + selection-background-color: $sample_status_selected_bg; + selection-color: $text; + } + + /* Selection highlight: the banner blue (SELECTION_BG in styles.py) + instead of the style palette's own blue. Tables keep their paler + selection rule below. */ + QListView, QTreeView, + QComboBox QAbstractItemView { + selection-background-color: $selection_bg; + selection-color: $selection_text; + } + + QMenu::item:selected { + background: $selection_bg; + color: $selection_text; + } + + QLineEdit, QAbstractSpinBox, QTextEdit, QPlainTextEdit { + selection-background-color: $selection_bg; + selection-color: $selection_text; + } + + /* Sliders: rounded groove ends (like the scrollbar handles) + round + handle. Wheel adjustment needs the right mouse button held — see + WheelValueGuard. */ + QSlider::groove:horizontal { + height: 6px; + background: $scrollbar_track; + border-radius: 3px; + } + + QSlider::sub-page:horizontal { + background: $slider_fill; + border-radius: 3px; + } + + QSlider::handle:horizontal { + background: $white; + border: 1px solid $scrollbar_handle; + width: 14px; + margin: -5px 0; + border-radius: 7px; + } + + /* Soft scrollbars: square track band (runs flush to the widget edges — + rounded ends left corner notches in the tables) + rounded draggable + handle, no end arrows. */ QScrollBar:vertical { background: $scrollbar_track; width: 10px; border: none; - border-radius: 5px; margin: 0px; } @@ -613,7 +767,6 @@ def _original_stylesheet() -> str: background: $scrollbar_track; height: 10px; border: none; - border-radius: 5px; margin: 0px; } @@ -645,9 +798,10 @@ def _original_stylesheet() -> str: background: transparent; } - /* Kill the square filler where the two scrollbars meet. */ + /* The two scrollbar bands meet in a track-colored corner — no white + square, the bands read as one continuous edge. */ QAbstractScrollArea::corner { - background: transparent; + background: $scrollbar_track; border: none; } @@ -705,6 +859,30 @@ def _portrait_stylesheet() -> str: color: $dark_text; } + /* Borderless tabs, dark flavor — see the light-theme note. */ + QTabBar::tab { + background: transparent; + border: none; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + padding: 4px 14px; + margin-top: 3px; + color: $dark_muted; + } + + QTabBar::tab:selected { + background: $dark_elevated; + color: $dark_text; + margin-top: 0px; + padding: 6px 14px 5px 14px; + } + + /* Menu-bar-style hover hint — see the light-theme note. */ + QTabBar::tab:hover:!selected { + color: $dark_text; + text-decoration: underline; + } + /* Borderless hover hints. The transparent border is required — QToolTip only honours the stylesheet background once a border is set. */ QToolTip { @@ -901,18 +1079,71 @@ def _portrait_stylesheet() -> str: background: transparent; } - /* Plain scroll containers stay frameless; data views (tables, trees, - text/log views) keep their native frame — those borders are wanted. */ + /* Plain scroll containers stay frameless. */ QScrollArea { border: none; } - /* Soft scrollbars: rounded track + draggable handle, no end arrows. */ + /* Box-frame levels — weights/colors are knobs in styles.py. */ + TellSamplePanel, ReferenceToolsPanel, SampleQueuePanel { + border: $frame_l2_width solid $frame_l2_color; + } + + QTableView, QPlainTextEdit { + border: $frame_l3_width solid $frame_l3_color; + } + + /* Selection + staggered rows, dark flavor. */ + QTableView { + background: $dark_bg; + alternate-background-color: $dark_surface; + selection-background-color: $sample_status_selected_bg; + selection-color: $text; + } + + /* Selection highlight — same banner-blue knob as the light theme. */ + QListView, QTreeView, + QComboBox QAbstractItemView { + selection-background-color: $selection_bg; + selection-color: $selection_text; + } + + QMenu::item:selected { + background: $selection_bg; + color: $selection_text; + } + + QLineEdit, QAbstractSpinBox, QTextEdit, QPlainTextEdit { + selection-background-color: $selection_bg; + selection-color: $selection_text; + } + + /* Sliders, dark flavor — see the light-theme note. */ + QSlider::groove:horizontal { + height: 6px; + background: $dark_border; + border-radius: 3px; + } + + QSlider::sub-page:horizontal { + background: $dark_accent; + border-radius: 3px; + } + + QSlider::handle:horizontal { + background: $dark_elevated; + border: 1px solid $dark_muted; + width: 14px; + margin: -5px 0; + border-radius: 7px; + } + + /* Soft scrollbars: square track band + rounded handle, no end arrows + (see light-theme note on the corner notches). */ QScrollBar:vertical { background: $dark_surface; width: 10px; border: none; - border-radius: 5px; margin: 0px; } @@ -920,7 +1151,6 @@ def _portrait_stylesheet() -> str: background: $dark_surface; height: 10px; border: none; - border-radius: 5px; margin: 0px; } @@ -952,9 +1182,9 @@ def _portrait_stylesheet() -> str: background: transparent; } - /* Kill the square filler where the two scrollbars meet. */ + /* The two scrollbar bands meet in a track-colored corner. */ QAbstractScrollArea::corner { - background: transparent; + background: $dark_surface; border: none; } -- 2.54.0 From 460f7034c37033f5bca7729471a4eb5aa8106aee Mon Sep 17 00:00:00 2001 From: Dawn Date: Fri, 7 Aug 2026 17:08:22 +0200 Subject: [PATCH 20/57] feat: let panels opt out of starting collapsed TitleLabel gains default_collapsed; the small motor/light panels start open (their per-title persisted choice still wins). Co-Authored-By: Claude Fable 5 --- src/aare/gui/panels/file_path_panel.py | 2 +- src/aare/gui/panels/illumination_panel.py | 2 +- src/aare/gui/panels/omega_panel.py | 2 +- src/aare/gui/panels/smargon_panel.py | 2 +- src/aare/gui/panels/zoom_panel.py | 2 +- src/aare/gui/widgets/title_label.py | 38 ++++++++++++++++++++--- 6 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/aare/gui/panels/file_path_panel.py b/src/aare/gui/panels/file_path_panel.py index 96937e33..cd9b46bb 100644 --- a/src/aare/gui/panels/file_path_panel.py +++ b/src/aare/gui/panels/file_path_panel.py @@ -41,7 +41,7 @@ class FilePathPanel(QWidget): self._formatted_date = datetime.now().strftime("%Y%m%d") - grid_layout.addWidget(TitleLabel("Dataset path", self, collapsible=True), 0, 0, 1, 2) + grid_layout.addWidget(TitleLabel("Dataset path", self, collapsible=True, default_collapsed=False), 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 50bac3e9..69f1e04a 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, collapsible=True), 0, 0, 1, 2) + grid_layout.addWidget(TitleLabel("Light", self, collapsible=True, default_collapsed=False), 0, 0, 1, 2) front_label = QLabel("Front light", parent=self) front_label.setAlignment(Qt.AlignmentFlag.AlignCenter) diff --git a/src/aare/gui/panels/omega_panel.py b/src/aare/gui/panels/omega_panel.py index b7e7a9ae..9e764cd2 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, collapsible=True), 0, 0, 1, 2) + grid_layout.addWidget(TitleLabel("Omega", self, collapsible=True, default_collapsed=False), 0, 0, 1, 2) grid_layout.setColumnStretch(0, 1) grid_layout.setColumnStretch(1, 1) omega_settings = [ diff --git a/src/aare/gui/panels/smargon_panel.py b/src/aare/gui/panels/smargon_panel.py index 2dcaac19..9c580613 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, collapsible=True), 0, 0, 1, 6) + grid_layout.addWidget(TitleLabel("Smargon", self, collapsible=True, default_collapsed=False), 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 6ff58f41..850a6139 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, collapsible=True), 0, 0, 1, 2) + grid_layout.addWidget(TitleLabel("Zoom", self, collapsible=True, default_collapsed=False), 0, 0, 1, 2) i = 2 self._buttons = [] diff --git a/src/aare/gui/widgets/title_label.py b/src/aare/gui/widgets/title_label.py index 500d014a..8eb1a5a7 100644 --- a/src/aare/gui/widgets/title_label.py +++ b/src/aare/gui/widgets/title_label.py @@ -48,7 +48,13 @@ def section_title(text: str, parent=None) -> QLabel: class TitleLabel(QLabel): - def __init__(self, text: str, parent=None, collapsible: bool = False): + def __init__( + self, + text: str, + parent=None, + collapsible: bool = False, + default_collapsed: bool = True, + ): super().__init__(parent) # Plain text + QSS font instead of

: rich-text heading margins # would clip vertically in the halved banner height. @@ -94,10 +100,9 @@ class TitleLabel(QLabel): self.setCursor(Qt.CursorShape.PointingHandCursor) settings = QSettings("PSI", "AareGUI") - # Default collapsed: a fresh GUI shows only banners (plus the expanded - # Beamline state panel, which manages its own default) until the user - # opens what they need; their choice is then persisted per title. - if settings.value(self._settings_key, True, type=bool): + # Per-panel default (collapsed unless the caller opts out); once the + # user toggles a banner, their choice is persisted per title and wins. + if settings.value(self._settings_key, default_collapsed, type=bool): self._collapsed = True # Deferred: the panel adds its other widgets after constructing # the TitleLabel, so siblings don't exist yet. @@ -136,10 +141,33 @@ class TitleLabel(QLabel): if self._collapsible and self._collapsed: self.toggle_collapsed() + def is_collapsed(self) -> bool: + return bool(self._collapsible and self._collapsed) + + def set_collapsed(self, collapsed: bool, persist: bool = True) -> None: + # persist=False: transient programmatic fold (e.g. the session-vacant + # gate) that must not overwrite the user's saved per-panel choice. + if not self._collapsible or collapsed == self._collapsed: + return + self._collapsed = collapsed + self._apply_collapsed() + if persist: + QSettings("PSI", "AareGUI").setValue(self._settings_key, self._collapsed) + def toggle_collapsed(self) -> None: self._collapsed = not self._collapsed self._apply_collapsed() QSettings("PSI", "AareGUI").setValue(self._settings_key, self._collapsed) + if not self._collapsed: + # Expanding a group banner (Beamline / Experiment) opens every + # nested panel banner too — a group opening onto a wall of still- + # collapsed banners reads as broken. No-op for leaf panels, which + # have no nested TitleLabels. + parent = self.parentWidget() + if parent is not None: + for child in parent.findChildren(TitleLabel): + if child is not self: + child.expand() def _apply_collapsed(self) -> None: parent = self.parentWidget() -- 2.54.0 From f87b8952c8cf9c01f863e5f9a20c96b53e99ad9c Mon Sep 17 00:00:00 2001 From: Dawn Date: Fri, 7 Aug 2026 17:08:31 +0200 Subject: [PATCH 21/57] feat: wheel only adjusts values while the right button is held WheelValueGuard (app-level event filter, installed by MainWindow) makes a bare wheel over spin boxes, sliders, dials, and combos scroll the enclosing scroll area instead of nudging the value - motor protection. NoWheelScrollArea stops swallowing wheel events since values are now guarded at the widget. Co-Authored-By: Claude Fable 5 --- src/aare/gui/widgets/no_wheel_scroll_area.py | 9 ++- src/aare/gui/widgets/wheel_value_guard.py | 82 ++++++++++++++++++++ 2 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 src/aare/gui/widgets/wheel_value_guard.py diff --git a/src/aare/gui/widgets/no_wheel_scroll_area.py b/src/aare/gui/widgets/no_wheel_scroll_area.py index c57c15f4..1dce19d9 100644 --- a/src/aare/gui/widgets/no_wheel_scroll_area.py +++ b/src/aare/gui/widgets/no_wheel_scroll_area.py @@ -2,9 +2,10 @@ from PySide6.QtWidgets import QScrollArea class NoWheelScrollArea(QScrollArea): + """Historic name: it used to swallow the wheel entirely so scrolling the + column could not nudge a value widget. WheelValueGuard now protects the + value widgets themselves (right button + wheel to adjust), so the wheel + scrolls the column content normally again — and only ever scrolls.""" + def __init__(self, parent=None): super().__init__(parent) - - def wheelEvent(self, event): - # Override the wheelEvent and do nothing - pass diff --git a/src/aare/gui/widgets/wheel_value_guard.py b/src/aare/gui/widgets/wheel_value_guard.py new file mode 100644 index 00000000..7697de8e --- /dev/null +++ b/src/aare/gui/widgets/wheel_value_guard.py @@ -0,0 +1,82 @@ +from PySide6.QtCore import QEvent, QObject, Qt +from PySide6.QtGui import QWheelEvent +from PySide6.QtWidgets import ( + QAbstractScrollArea, + QAbstractSpinBox, + QApplication, + QComboBox, + QDial, + QSlider, + QTabBar, +) + + +class WheelValueGuard(QObject): + """App-level wheel safety for value widgets. + + The wheel only ADJUSTS a slider / spin box / dial / combo while the + RIGHT mouse button is held down — a deliberate two-hand gesture. A bare + wheel over any of them is re-aimed at the enclosing scroll area, so + scrolling a page can never nudge a value and therefore never moves a + motor. Install once with QApplication.installEventFilter. + """ + + # QTabBar: wheel switches tabs on Linux by default — same accidental-input + # hazard as a value nudge, so guard it too. + GUARDED = (QAbstractSpinBox, QSlider, QDial, QComboBox, QTabBar) + + def eventFilter(self, obj, event): + if event.type() == QEvent.Type.Wheel and isinstance(obj, self.GUARDED): + if event.buttons() & Qt.MouseButton.RightButton: + return False # right button held: deliberate value adjustment + area = obj.parentWidget() + while area is not None and not isinstance(area, QAbstractScrollArea): + area = area.parentWidget() + if area is not None: + relayed = QWheelEvent( + area.viewport().mapFromGlobal(event.globalPosition()), + event.globalPosition(), + event.pixelDelta(), + event.angleDelta(), + event.buttons(), + event.modifiers(), + event.phase(), + event.inverted(), + ) + QApplication.sendEvent(area.viewport(), relayed) + return True + return super().eventFilter(obj, event) + + +if __name__ == "__main__": + # ponytail: smallest check that fails if the guard logic breaks + import os + + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PySide6.QtCore import QPoint, QPointF + + app = QApplication([]) + guard = WheelValueGuard() + app.installEventFilter(guard) + slider = QSlider(Qt.Orientation.Horizontal) + slider.setRange(0, 100) + slider.setValue(50) + slider.show() + + def wheel(buttons): + return QWheelEvent( + QPointF(5, 5), + QPointF(5, 5), + QPoint(0, 0), + QPoint(0, 120), + buttons, + Qt.KeyboardModifier.NoModifier, + Qt.ScrollPhase.NoScrollPhase, + False, + ) + + QApplication.sendEvent(slider, wheel(Qt.MouseButton.NoButton)) + assert slider.value() == 50, "bare wheel must not adjust the slider" + QApplication.sendEvent(slider, wheel(Qt.MouseButton.RightButton)) + assert slider.value() != 50, "right-button + wheel must adjust the slider" + print("gude") -- 2.54.0 From 6e1305cb927a11cc04ea86b88366961ec6aa27fd Mon Sep 17 00:00:00 2001 From: Dawn Date: Fri, 7 Aug 2026 17:08:31 +0200 Subject: [PATCH 22/57] feat: pop-out windows instead of floating docks Floating a dock rips it from the row and reshuffles the rest, so PopoutWindow opens an ADDITIONAL frameless top-level window instead: 12px outer resize halo (border painted OUTER_GRIP inside the real edge so an outside-looking grab still works), hand-painted borderless titlebar glyphs, close hides and keeps geometry. DockTitleBar puts the popout button next to the close box. The console log adopts it: dock floating disabled, the pop-out view is a second QPlainTextEdit fed by the same emitter (a shared QTextDocument would make two views fight over one layout), clear() clears both. Co-Authored-By: Claude Fable 5 --- src/aare/gui/panels/log_panel.py | 33 ++++ src/aare/gui/widgets/popout_window.py | 228 ++++++++++++++++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 src/aare/gui/widgets/popout_window.py diff --git a/src/aare/gui/panels/log_panel.py b/src/aare/gui/panels/log_panel.py index cd90338c..686033b2 100644 --- a/src/aare/gui/panels/log_panel.py +++ b/src/aare/gui/panels/log_panel.py @@ -13,6 +13,7 @@ from PySide6.QtWidgets import ( ) from aare.gui.log import QtLogEmitter, QtLogHandler +from aare.gui.widgets.popout_window import DockTitleBar, PopoutWindow from aare.gui.styles import ( FLAT_CARD_RADIUS, LOG_BORDER, @@ -198,6 +199,17 @@ class LogDock(QDockWidget): | Qt.DockWidgetArea.LeftDockWidgetArea ) + # No floating: popping a dock out rips it from the row and reshuffles + # the rest. The ⤢ in the title bar (next to ✕) opens an ADDITIONAL + # window on the same log instead. + self.setFeatures( + QDockWidget.DockWidgetFeature.DockWidgetMovable + | QDockWidget.DockWidgetFeature.DockWidgetClosable + ) + self._popout: PopoutWindow | None = None + self._popout_view: QPlainTextEdit | None = None + self.setTitleBarWidget(DockTitleBar(self, self._open_popout)) + self.container = QWidget(self) self.notification = RuntimeNotificationWidget(self.container) @@ -227,6 +239,25 @@ class LogDock(QDockWidget): def _append_line(self, text: str): self.view.appendPlainText(text) + @Slot() + def _open_popout(self) -> None: + if self._popout is None: + # Mirror view fed by the same emitter; history is copied once at + # creation. (One QTextDocument shared by two QPlainTextEdits would + # make their layouts fight, hence the second document.) + view = QPlainTextEdit() + view.setReadOnly(True) + # Frameless inside the pop-out — no nested boxes in this window. + view.setStyleSheet("QPlainTextEdit { border: none; }") + view.setPlainText(self.view.toPlainText()) + self.emitter.message.connect(view.appendPlainText) + self._popout_view = view + self._popout = PopoutWindow("Console Log", view, parent=self.window()) + self._popout.resize(1000, 450) + self._popout.show() + self._popout.raise_() + self._popout.activateWindow() + @Slot() def _raise_and_focus_log(self) -> None: self.setVisible(True) @@ -258,3 +289,5 @@ class LogDock(QDockWidget): def clear(self): self.view.clear() + if self._popout_view is not None: + self._popout_view.clear() diff --git a/src/aare/gui/widgets/popout_window.py b/src/aare/gui/widgets/popout_window.py new file mode 100644 index 00000000..0fcaa9b6 --- /dev/null +++ b/src/aare/gui/widgets/popout_window.py @@ -0,0 +1,228 @@ +from PySide6.QtCore import QPoint, QRect, QSize, Qt +from PySide6.QtGui import QCursor, QGuiApplication, QIcon, QPainter, QPen, QPixmap +from PySide6.QtWidgets import ( + QDockWidget, + QHBoxLayout, + QLabel, + QToolButton, + QVBoxLayout, + QWidget, +) + +from aare.gui.styles import FRAME_L1_COLOR, FRAME_L1_WIDTH, TEXT, qcolor + +# Title-bar buttons: icon fills the button, both the same size. +TITLEBAR_BUTTON_PX = 22 +TITLEBAR_ICON_PX = 18 + + +def _titlebar_icon(kind: str, size: int = TITLEBAR_ICON_PX) -> QIcon: + """Hand-painted borderless glyphs — the style's standard title-bar + pixmaps draw boxed icons, and text glyphs are missing from the + container's fonts.""" + pixmap = QPixmap(size, size) + pixmap.fill(Qt.GlobalColor.transparent) + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + pen = QPen(qcolor(TEXT), 2) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin) + painter.setPen(pen) + if kind == "close": + painter.drawLine(4, 4, size - 4, size - 4) + painter.drawLine(size - 4, 4, 4, size - 4) + else: # "popout": window in the lower left, arrow escaping top-right + painter.drawRect(3, size // 2 - 1, size // 2 - 1, size // 2 - 1) + painter.drawLine(size // 2 + 1, size // 2 - 1, size - 3, 3) + painter.drawLine(size - 8, 3, size - 3, 3) + painter.drawLine(size - 3, 3, size - 3, 8) + painter.end() + return QIcon(pixmap) + + +def _titlebar_button(parent: QWidget, kind: str, tooltip: str) -> QToolButton: + button = QToolButton(parent) + button.setIcon(_titlebar_icon(kind)) + button.setIconSize(QSize(TITLEBAR_ICON_PX, TITLEBAR_ICON_PX)) + button.setFixedSize(TITLEBAR_BUTTON_PX, TITLEBAR_BUTTON_PX) + button.setAutoRaise(True) + # No button chrome — the glyph IS the button. + button.setStyleSheet("QToolButton { border: none; background: transparent; }") + button.setToolTip(tooltip) + button.setCursor(Qt.CursorShape.PointingHandCursor) + return button + + +class DockTitleBar(QWidget): + """Dock title bar with a ⤢ pop-out button right next to ✕. + + Qt's native dock title bar cannot host extra buttons, so this replaces + it: [title … ⤢ ✕]. Trade-off: the dock can no longer be dragged by its + title — acceptable here, these docks are pinned to the bottom row. + """ + + def __init__(self, dock: QDockWidget, on_popout): + super().__init__(dock) + layout = QHBoxLayout(self) + layout.setContentsMargins(8, 2, 4, 2) + layout.setSpacing(2) + + title = QLabel(dock.windowTitle(), self) + title.setStyleSheet("background: transparent;") + layout.addWidget(title) + layout.addStretch(1) + + self.popout_button = _titlebar_button( + self, "popout", "Open in a separate window (the panel stays here too)" + ) + self.popout_button.clicked.connect(on_popout) + layout.addWidget(self.popout_button) + + close_button = _titlebar_button(self, "close", "Close panel (reopen via the View menu)") + close_button.clicked.connect(dock.close) + layout.addWidget(close_button) + + +class PopoutWindow(QWidget): + """Additional top-level window for a panel mirror. + + Unlike a floated QDockWidget it never removes anything from the main + window — closing it just hides it (geometry kept for reopening) and the + main window is untouched. The layout leaves RESIZE_MARGIN px of the + window exposed around the content as a fat, easy-to-hit resize band; + frameless floats only give a few px. Resize uses startSystemResize with + a manual fallback for window managers that lack it. + """ + + # 6px: enough to grab without pixel-hunting, small enough that the area + # right around the content doesn't hijack table interactions. + RESIZE_MARGIN = 6 + # Clicks can never land outside a window, so a from-the-outside grab zone + # has to be window area that only LOOKS external: the visible border is + # drawn OUTER_GRIP px inside the real edge, and the halo beyond it + # resizes too. + OUTER_GRIP = 4 + + def __init__(self, title: str, content: QWidget, parent=None): + super().__init__(parent, Qt.WindowType.Window) + self.setWindowTitle(title) + self.setMinimumSize(300, 160) + layout = QVBoxLayout(self) + m = self.RESIZE_MARGIN + self.OUTER_GRIP + layout.setContentsMargins(m, m, m, m) + layout.addWidget(content) + self.setMouseTracking(True) + self._manual_edges = Qt.Edge(0) + self._press_global: QPoint | None = None + self._press_geom: QRect | None = None + self._placed = False + + def showEvent(self, event): + # First show opens near the click (the ⤢ button = the cursor), not at + # the WM's default top-left; reopening keeps the last geometry. + if not self._placed: + self._placed = True + cursor = QCursor.pos() + pos = cursor - QPoint(60, 20) + screen = QGuiApplication.screenAt(cursor) or QGuiApplication.primaryScreen() + if screen is not None: + geo = screen.availableGeometry() + pos.setX(max(geo.left(), min(pos.x(), geo.right() - self.width()))) + pos.setY(max(geo.top(), min(pos.y(), geo.bottom() - self.height()))) + self.move(pos) + super().showEvent(event) + + def paintEvent(self, event): + super().paintEvent(event) + # Optional perceived window border, inset by OUTER_GRIP (see class + # note). Level-1 frame — weight/color are knobs in styles.py; the + # default width 0 paints nothing (resize still works via the cursor + # hint over the grab band). + width = int(FRAME_L1_WIDTH.rstrip("px")) + if width <= 0: + return + painter = QPainter(self) + painter.setPen(QPen(qcolor(FRAME_L1_COLOR), width)) + g = self.OUTER_GRIP + painter.drawRect(self.rect().adjusted(g, g, -g - 1, -g - 1)) + + def _edges_at(self, pos: QPoint) -> Qt.Edge: + m = self.RESIZE_MARGIN + self.OUTER_GRIP + edges = Qt.Edge(0) + if pos.x() <= m: + edges |= Qt.Edge.LeftEdge + if pos.x() >= self.width() - m: + edges |= Qt.Edge.RightEdge + if pos.y() <= m: + edges |= Qt.Edge.TopEdge + if pos.y() >= self.height() - m: + edges |= Qt.Edge.BottomEdge + return edges + + def _cursor_for(self, edges: Qt.Edge): + horizontal = edges & (Qt.Edge.LeftEdge | Qt.Edge.RightEdge) + vertical = edges & (Qt.Edge.TopEdge | Qt.Edge.BottomEdge) + if horizontal and vertical: + same_diag = bool(edges & Qt.Edge.LeftEdge) == bool(edges & Qt.Edge.TopEdge) + return Qt.CursorShape.SizeFDiagCursor if same_diag else Qt.CursorShape.SizeBDiagCursor + if horizontal: + return Qt.CursorShape.SizeHorCursor + if vertical: + return Qt.CursorShape.SizeVerCursor + return None + + def mousePressEvent(self, event): + edges = self._edges_at(event.position().toPoint()) + if event.button() == Qt.MouseButton.LeftButton and edges: + handle = self.windowHandle() + if handle is None or not handle.startSystemResize(edges): + self._manual_edges = edges + self._press_global = event.globalPosition().toPoint() + self._press_geom = QRect(self.geometry()) + return + super().mousePressEvent(event) + + def mouseMoveEvent(self, event): + if self._manual_edges and self._press_global is not None: + delta = event.globalPosition().toPoint() - self._press_global + geom = QRect(self._press_geom) + if self._manual_edges & Qt.Edge.LeftEdge: + geom.setLeft(min(geom.left() + delta.x(), geom.right() - self.minimumWidth())) + if self._manual_edges & Qt.Edge.RightEdge: + geom.setRight(max(geom.right() + delta.x(), geom.left() + self.minimumWidth())) + if self._manual_edges & Qt.Edge.TopEdge: + geom.setTop(min(geom.top() + delta.y(), geom.bottom() - self.minimumHeight())) + if self._manual_edges & Qt.Edge.BottomEdge: + geom.setBottom(max(geom.bottom() + delta.y(), geom.top() + self.minimumHeight())) + self.setGeometry(geom) + return + cursor = self._cursor_for(self._edges_at(event.position().toPoint())) + if cursor is None: + self.unsetCursor() + else: + self.setCursor(cursor) + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event): + self._manual_edges = Qt.Edge(0) + self._press_global = None + self._press_geom = None + super().mouseReleaseEvent(event) + + +if __name__ == "__main__": + # ponytail: smallest check that fails if the edge maths breaks + import os + + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PySide6.QtWidgets import QApplication, QLabel + + app = QApplication([]) + w = PopoutWindow("t", QLabel("x")) + w.resize(400, 300) + assert w._edges_at(QPoint(5, 150)) == Qt.Edge.LeftEdge + assert w._edges_at(QPoint(398, 298)) == (Qt.Edge.RightEdge | Qt.Edge.BottomEdge) + assert w._edges_at(QPoint(200, 150)) == Qt.Edge(0) + assert w._cursor_for(Qt.Edge.LeftEdge | Qt.Edge.TopEdge) == Qt.CursorShape.SizeFDiagCursor + assert w._cursor_for(Qt.Edge.RightEdge | Qt.Edge.TopEdge) == Qt.CursorShape.SizeBDiagCursor + print("gude") -- 2.54.0 From 5f44be2a62a8d978d51ee29398aaa4845456147e Mon Sep 17 00:00:00 2001 From: Dawn Date: Fri, 7 Aug 2026 17:08:40 +0200 Subject: [PATCH 23/57] style: compact the control panels Vertical-space pass: samcam merges exposure+gain onto one row, ABR and monochromator tighten their grids (energy now displayed in keV, DAQ API stays eV), beam-mark's clear button shares the readings row, the data collection pages move Run/Abort under the last configurable row and the tab area becomes QTabBar+QStackedWidget, the raster grid table gets a fixed height, and the automation panel drops its own title since the dock title bar already says it. Control panels start expanded via default_collapsed=False. Co-Authored-By: Claude Fable 5 --- src/aare/gui/panels/abr_tweak_panel.py | 32 +++-- src/aare/gui/panels/automation_panel.py | 78 ++++++----- src/aare/gui/panels/beam_mark_panel.py | 7 +- .../gui/panels/data_collection_settings.py | 99 ++++++++++--- .../panels/fluorescence_data_collection.py | 7 +- src/aare/gui/panels/monochromator_panel.py | 20 +-- src/aare/gui/panels/raster_data_collection.py | 14 +- .../gui/panels/rotation_data_collection.py | 7 +- src/aare/gui/panels/samcam_panel.py | 72 +++++----- src/aare/gui/panels/scan_settings_panel.py | 5 + src/aare/gui/panels/smart_rotation_panel.py | 131 ++++++++++-------- src/aare/gui/widgets/raster_grid_table.py | 6 +- 12 files changed, 285 insertions(+), 193 deletions(-) diff --git a/src/aare/gui/panels/abr_tweak_panel.py b/src/aare/gui/panels/abr_tweak_panel.py index f9cd172d..14b68100 100644 --- a/src/aare/gui/panels/abr_tweak_panel.py +++ b/src/aare/gui/panels/abr_tweak_panel.py @@ -92,32 +92,38 @@ class AbrTweakWidget(QWidget): super().__init__(parent) grid_layout = QGridLayout(self) - grid_layout.setColumnStretch(0, 0) - grid_layout.setColumnStretch(1, 0) - grid_layout.setColumnStretch(2, 1) - # 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) + grid_layout.addWidget(TitleLabel("ABR meas. pos.", self, collapsible=True, default_collapsed=False), 0, 0, 1, 2) self._abr_buttons = AbrTweakButtons(DEFAULT_ABR_STEP_UM / 1000, parent=self) - grid_layout.addWidget(self._abr_buttons, 1, 0, 1, 4) + grid_layout.addWidget(self._abr_buttons, 1, 0) self._abr_buttons.abr_tweak.connect(self.abr_button_pressed) - grid_layout.addWidget(QLabel("Step", parent=self), 2, 0) - self._step_um = NumberLineEdit(1, 1000, DEFAULT_ABR_STEP_UM, 0, parent=self) - grid_layout.addWidget(self._step_um, 2, 1) - grid_layout.addWidget(QLabel("μm", parent=self), 2, 2) + # Step + actions as a column BESIDE the GM rows instead of below — + # the rows left plenty of dead width. + side = QWidget(self) + side_grid = QGridLayout(side) + side_grid.setContentsMargins(0, 0, 0, 0) + side_grid.addWidget(QLabel("Step", parent=side), 0, 0) + self._step_um = NumberLineEdit(1, 1000, DEFAULT_ABR_STEP_UM, 0, parent=side) + side_grid.addWidget(self._step_um, 0, 1) + side_grid.addWidget(QLabel("μm", parent=side), 0, 2) self._step_um.newValue.connect(self._abr_buttons.set_step) save_button = QPushButton("Save ABR pos.") - grid_layout.addWidget(save_button, 3, 0, 1, 3) + side_grid.addWidget(save_button, 1, 0, 1, 3) save_button.pressed.connect(self.save_button_pressed) goto_button = QPushButton("Go to meas.") - grid_layout.addWidget(goto_button, 4, 0, 1, 3) + side_grid.addWidget(goto_button, 2, 0, 1, 3) goto_button.pressed.connect(self.goto_button_pressed) + # ~80% of the width the grid handed it; the freed space goes to the + # GM rows (column 0 takes all stretch). + side.setMaximumWidth(150) + grid_layout.setColumnStretch(0, 1) + grid_layout.addWidget(side, 1, 1) + @Slot() def goto_button_pressed(self): self.abr_goto_meas.emit() diff --git a/src/aare/gui/panels/automation_panel.py b/src/aare/gui/panels/automation_panel.py index 3cd4496b..1a1c3181 100644 --- a/src/aare/gui/panels/automation_panel.py +++ b/src/aare/gui/panels/automation_panel.py @@ -1,18 +1,16 @@ from __future__ import annotations -import copy import time from datetime import datetime from aarecommon.config.logger import setup_logger from aarecommon.models.automation import AutomationProgress, StepStatus, WorkflowStateKind -from PySide6.QtCore import QTimer, Slot -from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget +from PySide6.QtCore import Qt, QTimer, Slot +from PySide6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget from aare.gui.constants import LOGGER_NAME from aare.gui.styles import ( AUTOMATION_HINT_TEXT, - AUTOMATION_TITLE_TEXT, CARD_BORDER, FAINT_TEXT, FONT_BODY, @@ -49,7 +47,6 @@ class AutomationProgressWidget(QWidget): self._progress: AutomationProgress | None = None self._gui_samples_in_queue: int | None = None self._labels: dict[WorkflowStateKind, QLabel] = {} - self._title_label: QLabel | None = None self._stats_label: QLabel | None = None self._is_paused = False self._refresh_timer = QTimer(self) @@ -59,36 +56,45 @@ class AutomationProgressWidget(QWidget): self.clear() def _setup_ui(self) -> None: + # No title label: the dock's title bar already says it. layout = QVBoxLayout(self) layout.setContentsMargins(10, 10, 10, 10) layout.setSpacing(10) - self._title_label = QLabel("Automation progress") - self._title_label.setStyleSheet( - f"font-size: {FONT_TITLE}; font-weight: 700; color: {AUTOMATION_TITLE_TEXT}; margin-bottom: 2px;" - ) - layout.addWidget(self._title_label) - self._stats_label = QLabel() self._stats_label.setStyleSheet( f"color: {AUTOMATION_HINT_TEXT}; font-size: {FONT_LABEL}; font-weight: 700; " f"background-color: {SURFACE}; border: 1px solid {CARD_BORDER}; " - "border-radius: 8px; padding: 10px;" + "padding: 10px;" ) self._stats_label.setWordWrap(True) - layout.addWidget(self._stats_label) + # Stats left, automation run-state card right ("||" paused / "▶" + # running — ASCII bars: fancier pause glyphs are missing from the + # beamline console fonts). + self._state_label = QLabel() + self._state_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + top_row = QHBoxLayout() + top_row.setSpacing(10) + top_row.addWidget(self._stats_label, 2) + top_row.addWidget(self._state_label, 1) + layout.addLayout(top_row) + self._update_state_label() + + steps_row = QHBoxLayout() + steps_row.setSpacing(6) for step in ( WorkflowStateKind.MOUNT, WorkflowStateKind.LOOP_CENTRE, WorkflowStateKind.RASTER, WorkflowStateKind.DATA_COLLECTION, - WorkflowStateKind.FINAL, ): label = QLabel() label.setWordWrap(True) - layout.addWidget(label) + steps_row.addWidget(label, 1) self._labels[step] = label + layout.addLayout(steps_row) layout.addStretch() @@ -142,7 +148,7 @@ class AutomationProgressWidget(QWidget): @staticmethod def _style_for_status(status: StepStatus) -> str: base = ( - "padding: 10px 12px; border-radius: 10px; " + "padding: 10px 12px; " f"font-size: {FONT_BODY}; border: 1px solid transparent;" ) @@ -219,32 +225,38 @@ class AutomationProgressWidget(QWidget): return self.set_progress(self._progress) + def _update_state_label(self) -> None: + if self._is_paused: + colors = ( + f"background-color: {STEP_PAUSED_BG}; color: {STEP_PAUSED_TEXT}; " + f"border: 1px solid {STEP_PAUSED_BORDER};" + ) + text = "|| paused" + else: + colors = ( + f"background-color: {STEP_ACTIVE_BG}; color: {STEP_ACTIVE_TEXT}; " + f"border: 1px solid {STEP_ACTIVE_BORDER};" + ) + text = "▶ running" + self._state_label.setText(text) + self._state_label.setStyleSheet( + f"font-size: {FONT_TITLE}; font-weight: 700; padding: 10px; " + colors + ) + @Slot(bool) def set_running(self, running: bool) -> None: self._is_paused = not running + self._update_state_label() if self._progress is None: return - progress = copy.deepcopy(self._progress) - - final_step = next( - (step for step in progress.steps if step.step == WorkflowStateKind.FINAL), None - ) - if self._is_paused: self._refresh_timer.stop() - if final_step is not None: - final_step.status = StepStatus.PAUSED - final_step.message = "Automation paused" - else: - if final_step is not None and final_step.status == StepStatus.PAUSED: - final_step.status = StepStatus.PENDING - final_step.message = "" - if self._has_live_timing(progress): - self._refresh_timer.start() + elif self._has_live_timing(self._progress): + self._refresh_timer.start() - self.set_progress(progress) + self.set_progress(self._progress) @Slot(int) def set_samples_in_queue(self, count: int) -> None: @@ -318,5 +330,3 @@ class AutomationProgressWidget(QWidget): label.setText(f"{icon} {title}{duration_str}{message}{error_str}") label.setStyleSheet(self._style_for_status(step_state.status)) - if self._title_label is not None: - self._title_label.setText("Automation progress") diff --git a/src/aare/gui/panels/beam_mark_panel.py b/src/aare/gui/panels/beam_mark_panel.py index 3a74895a..5122ab78 100644 --- a/src/aare/gui/panels/beam_mark_panel.py +++ b/src/aare/gui/panels/beam_mark_panel.py @@ -12,8 +12,9 @@ class BeamMarkWidget(QWidget): super().__init__(parent) grid_layout = QGridLayout(self) + grid_layout.setVerticalSpacing(2) - grid_layout.addWidget(section_title("Beam mark (image)", self), 0, 0, 1, 5) + grid_layout.addWidget(section_title("Beam mark (image)", self), 0, 0, 1, 6) self.x = QLabel("0") self.y = QLabel("0") @@ -24,8 +25,10 @@ class BeamMarkWidget(QWidget): grid_layout.addWidget(self.y, 1, 3) grid_layout.addWidget(QLabel("pxl"), 1, 4) + # Shares the readings row instead of a full-width row below. clear_button = QPushButton("Clear marks") - grid_layout.addWidget(clear_button, 2, 0, 1, 5) + clear_button.setFixedWidth(90) + grid_layout.addWidget(clear_button, 1, 5) clear_button.pressed.connect(self.clear_button_pressed) @Slot() diff --git a/src/aare/gui/panels/data_collection_settings.py b/src/aare/gui/panels/data_collection_settings.py index df75e340..71ea0e9b 100644 --- a/src/aare/gui/panels/data_collection_settings.py +++ b/src/aare/gui/panels/data_collection_settings.py @@ -2,7 +2,16 @@ 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, QWidget +from PySide6.QtWidgets import ( + QFrame, + QHBoxLayout, + QPushButton, + QSizePolicy, + QStackedWidget, + QTabBar, + QVBoxLayout, + QWidget, +) from aare.gui.panels.file_path_panel import FilePathPanel from aare.gui.panels.fluorescence_data_collection import FluorescenceDataCollectionPanel @@ -11,7 +20,6 @@ from aare.gui.panels.raster_data_collection import RasterDataCollectionPanel from aare.gui.panels.rotation_data_collection import RotationDataCollectionPanel from aare.gui.panels.smart_rotation_panel import SimpleRotationSettingsPanel from aare.gui.scan_logic.raster_grid_manager import RasterGridManager -from aare.gui.styles import ABORT_TEXT from aare.gui.widgets.title_label import TitleLabel, tighten_column @@ -42,39 +50,72 @@ class DataCollectionSettings(QFrame): self.manual_sample_panel = ManualSamplePanel(self) v_layout.addWidget(self.manual_sample_panel) - self._tab_widget = QTabWidget() + # QTabBar + QStackedWidget instead of QTabWidget: the loop-centering + # button row must sit BETWEEN the tab bar and the pages, which a + # QTabWidget cannot host. + self._tab_bar = QTabBar(self) + self._stack = QStackedWidget(self) self.raster = RasterDataCollectionPanel( parent=self, raster_mgr=raster_mgr, diffraction=diffraction ) - self._tab_widget.addTab(self.raster, "Raster scan") - self.screening = RotationDataCollectionPanel(parent=self, diffraction=diffraction) - self._tab_widget.addTab(self.screening, "Rotation") - self.simple = SimpleRotationSettingsPanel(parent=self) - self._tab_widget.addTab(self.simple, "Simple") - self.fluo = FluorescenceDataCollectionPanel(parent=self) - self._tab_widget.addTab(self.fluo, "XRF") + for panel, label in ( + (self.raster, "Raster scan"), + (self.screening, "Rotation"), + (self.simple, "Simple"), + (self.fluo, "XRF"), + ): + self._stack.addWidget(panel) + self._tab_bar.addTab(label) + + # Ex-"Loop centering" panel buttons; always visible, whatever the tab. + self.find_tip = QPushButton("ML Loop Centring", parent=self) + self.bounding_box = QPushButton("Make Raster Grid", parent=self) + centering_row = QWidget(self) + centering_layout = QHBoxLayout(centering_row) + centering_layout.setContentsMargins(0, 0, 0, 0) + centering_layout.addWidget(self.find_tip) + centering_layout.addWidget(self.bounding_box) + + # Pane frame carries the border QTabWidget::pane used to draw + # (#expConfigPane rule in styles.py). + pane = QFrame(self) + pane.setObjectName("expConfigPane") + pane_layout = QVBoxLayout(pane) + # No bottom padding: the pages' own bottom margins breathe inside the + # border, and the Abort button should hug the pane. + pane_layout.setContentsMargins(6, 6, 6, 0) + pane_layout.addWidget(centering_row) + pane_layout.addWidget(self._stack) # 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) + exp_config_layout.setSpacing(0) # tab bar flush on the pane, like QTabWidget + exp_config_layout.addWidget( + TitleLabel( + "Experiment configuration", exp_config, collapsible=True, default_collapsed=False + ) + ) + exp_config_layout.addWidget(self._tab_bar) + exp_config_layout.addWidget(pane) v_layout.addWidget(exp_config) - abort_button = QPushButton("Abort measurement", parent=self) - abort_button.setStyleSheet(f"color: {ABORT_TEXT};") - 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. + # Abort lives inside each tab now, under that tab's action buttons; + # all four feed the same cancel signal. + for panel in (self.raster, self.screening, self.simple, self.fluo): + panel.abort_button.clicked.connect(self.cancel_button_clicked) v_layout.addStretch() tighten_column(v_layout) + # Abort hugs the pane: undo the uniform bottom margin tighten_column + # just gave the Exp. Config. group. + m = exp_config_layout.contentsMargins() + exp_config_layout.setContentsMargins(m.left(), m.top(), m.right(), 0) raster_mgr.update_filename(self.file_path_panel.filename) self.screening.update_filename(self.file_path_panel.filename) @@ -86,11 +127,27 @@ class DataCollectionSettings(QFrame): self.file_path_panel.path_updated.connect(self.simple.update_filename) self._sample_id = None - self._tab_widget.currentChanged.connect(self._on_tab_changed) + self._tab_bar.currentChanged.connect(self._stack.setCurrentIndex) + self._tab_bar.currentChanged.connect(self._on_tab_changed) + self._tab_bar.currentChanged.connect(self._sync_stack_height) + self._sync_stack_height(self._tab_bar.currentIndex()) + + @Slot(int) + def _sync_stack_height(self, idx: int): + # QStackedWidget's sizeHint is its TALLEST page, which left a dead gap + # above the Abort button on shorter tabs. Ignored vertical policy on + # hidden pages makes the stack track only the current page's height. + for i in range(self._stack.count()): + page = self._stack.widget(i) + vertical = ( + QSizePolicy.Policy.Preferred if i == idx else QSizePolicy.Policy.Ignored + ) + page.setSizePolicy(QSizePolicy.Policy.Preferred, vertical) + self._stack.adjustSize() @Slot() def switch_to_raster(self): - self._tab_widget.setCurrentIndex(0) + self._tab_bar.setCurrentIndex(0) @Slot() def cancel_button_clicked(self): @@ -103,7 +160,7 @@ class DataCollectionSettings(QFrame): self.simple.update_daq_status(s) if s.sample is not None and s.sample.db_id != self._sample_id: self._sample_id = s.sample.db_id - self._tab_widget.setCurrentIndex(0) + self._tab_bar.setCurrentIndex(0) @Slot(int) def _on_tab_changed(self, idx: int): diff --git a/src/aare/gui/panels/fluorescence_data_collection.py b/src/aare/gui/panels/fluorescence_data_collection.py index ce3980e6..f0aa1df4 100644 --- a/src/aare/gui/panels/fluorescence_data_collection.py +++ b/src/aare/gui/panels/fluorescence_data_collection.py @@ -2,7 +2,7 @@ from aarecommon.models.models import FluorescenceSpectrumParameterModel from PySide6.QtCore import Signal, Slot from PySide6.QtWidgets import QCheckBox, QGridLayout, QLabel, QPushButton, QWidget -from aare.gui.styles import GO_TEXT +from aare.gui.styles import ABORT_TEXT, GO_TEXT from aare.gui.widgets.number_line_edit import NumberLineEdit @@ -38,6 +38,11 @@ class FluorescenceDataCollectionPanel(QWidget): self.run_btn.setStyleSheet(f"color: {GO_TEXT};") lay.addWidget(self.run_btn, 4, 0, 1, 3) + # Per-tab Abort (DataCollectionSettings wires it to the DAQ cancel). + self.abort_button = QPushButton("Abort measurement", self) + self.abort_button.setStyleSheet(f"color: {ABORT_TEXT};") + lay.addWidget(self.abort_button, 5, 0, 1, 3) + self.run_btn.clicked.connect(self._emit_params) @Slot() diff --git a/src/aare/gui/panels/monochromator_panel.py b/src/aare/gui/panels/monochromator_panel.py index 2dc815a9..a5c76cc7 100644 --- a/src/aare/gui/panels/monochromator_panel.py +++ b/src/aare/gui/panels/monochromator_panel.py @@ -13,29 +13,31 @@ class MonochromatorPanel(QWidget): super().__init__(parent) grid_layout = QGridLayout(self) - grid_layout.addWidget(TitleLabel("Monochromator", self, collapsible=True), 0, 0, 1, 2) + grid_layout.addWidget(TitleLabel("Monochromator", self, collapsible=True, default_collapsed=False), 0, 0, 1, 3) self.mono_pitch_scan_button = QPushButton("Mono Pitch Scan", parent=self) self.mono_pitch_scan_button.clicked.connect(self.mono_pitch_scan.emit) - grid_layout.addWidget(self.mono_pitch_scan_button, 1, 0, 1, 2) + grid_layout.addWidget(self.mono_pitch_scan_button, 1, 0, 1, 3) + # One row (label | value | button) instead of three — vertical space. + # Display in keV; the DAQ API stays in eV (converted on emit). grid_layout.addWidget(QLabel("Energy", parent=self), 2, 0) self.energy_spin = QDoubleSpinBox(parent=self) self.energy_spin.setDecimals(3) - self.energy_spin.setRange(1000.0, 30000.0) - self.energy_spin.setSingleStep(100.0) - self.energy_spin.setSuffix(" eV") - self.energy_spin.setValue(12000.0) - grid_layout.addWidget(self.energy_spin, 3, 0) + self.energy_spin.setRange(1.0, 30.0) + self.energy_spin.setSingleStep(0.1) + self.energy_spin.setSuffix(" keV") + self.energy_spin.setValue(12.0) + grid_layout.addWidget(self.energy_spin, 2, 1) self.change_energy_button = QPushButton("Change Energy", parent=self) self.change_energy_button.clicked.connect(self._emit_change_energy) - grid_layout.addWidget(self.change_energy_button, 3, 1) + grid_layout.addWidget(self.change_energy_button, 2, 2) @Slot() def _emit_change_energy(self): - self.change_energy.emit(float(self.energy_spin.value())) + self.change_energy.emit(float(self.energy_spin.value()) * 1000.0) @Slot(DAQStatusModel) def update_daq_status(self, _status: DAQStatusModel): diff --git a/src/aare/gui/panels/raster_data_collection.py b/src/aare/gui/panels/raster_data_collection.py index 8bd6ff74..d272dbfd 100644 --- a/src/aare/gui/panels/raster_data_collection.py +++ b/src/aare/gui/panels/raster_data_collection.py @@ -7,15 +7,13 @@ from PySide6.QtWidgets import ( QLabel, QMessageBox, QPushButton, - QSizePolicy, QSlider, - QSpacerItem, ) from aare.gui.constants import LOGGER_NAME from aare.gui.panels.scan_settings_panel import ScanSettingsPanel from aare.gui.scan_logic.raster_grid_manager import RasterGridManager, RasterGridMetric -from aare.gui.styles import GO_TEXT +from aare.gui.styles import ABORT_TEXT, GO_TEXT from aare.gui.widgets.number_line_edit import DbOverrideLineEdit from aare.gui.widgets.raster_grid_table import RasterGridTable @@ -131,11 +129,6 @@ class RasterDataCollectionPanel(ScanSettingsPanel): self._table = RasterGridTable(raster_mgr) self._layout.addWidget(self._table, 9, 0, 1, 5) - horizontal_spacer = QSpacerItem( - 40, 20, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding - ) - self._layout.addItem(horizontal_spacer, 10, 0, 1, 5) - self._layout.addWidget(QLabel("Measurement time", parent=self), 11, 0) self.total_time = QLabel(f"{self._total_time} min 0 s") self.total_time.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) @@ -152,6 +145,11 @@ class RasterDataCollectionPanel(ScanSettingsPanel): self.auto_button.setStyleSheet(f"color: {GO_TEXT};") self.auto_button.clicked.connect(self._on_evaluate_auto_clicked) self._layout.addWidget(self.auto_button, 13, 0, 1, 5) + + # Per-tab Abort (DataCollectionSettings wires it to the DAQ cancel). + self.abort_button = QPushButton("Abort measurement") + self.abort_button.setStyleSheet(f"color: {ABORT_TEXT};") + self._layout.addWidget(self.abort_button, 14, 0, 1, 5) self._reset_to_defaults() self.update_grid_scan_size() diff --git a/src/aare/gui/panels/rotation_data_collection.py b/src/aare/gui/panels/rotation_data_collection.py index 774fe9f5..c55dc304 100644 --- a/src/aare/gui/panels/rotation_data_collection.py +++ b/src/aare/gui/panels/rotation_data_collection.py @@ -10,7 +10,7 @@ from PySide6.QtWidgets import QComboBox, QLabel, QMessageBox, QPushButton from aare.gui.constants import LOGGER_NAME from aare.gui.panels.scan_settings_panel import ScanSettingsPanel -from aare.gui.styles import GO_TEXT +from aare.gui.styles import ABORT_TEXT, GO_TEXT from aare.gui.widgets.number_line_edit import DbOverrideLineEdit, NumberLineEdit logger = setup_logger(LOGGER_NAME) @@ -164,6 +164,11 @@ class RotationDataCollectionPanel(ScanSettingsPanel): self.measurement_button.setStyleSheet(f"color: {GO_TEXT};") self.measurement_button.clicked.connect(self.run_measurement) self._layout.addWidget(self.measurement_button, 16, 0, 1, 6) + + # Per-tab Abort (DataCollectionSettings wires it to the DAQ cancel). + self.abort_button = QPushButton("Abort measurement") + self.abort_button.setStyleSheet(f"color: {ABORT_TEXT};") + self._layout.addWidget(self.abort_button, 17, 0, 1, 6) self._reset_to_defaults() @Slot() diff --git a/src/aare/gui/panels/samcam_panel.py b/src/aare/gui/panels/samcam_panel.py index 77dfd9e7..0e91593f 100644 --- a/src/aare/gui/panels/samcam_panel.py +++ b/src/aare/gui/panels/samcam_panel.py @@ -4,6 +4,7 @@ from PySide6.QtWidgets import ( QCheckBox, QComboBox, QDoubleSpinBox, + QGridLayout, QHBoxLayout, QLabel, QLineEdit, @@ -36,32 +37,28 @@ class SamcamPanel(QWidget): # Create layout layout = QVBoxLayout() - layout.addWidget(TitleLabel("Sample camera", self, collapsible=True)) + layout.addWidget(TitleLabel("Sample camera", self, collapsible=True, default_collapsed=False)) - # Exposure control - exposure_layout = QHBoxLayout() - exposure_label = QLabel("Exposure (s):") + # Exposure + gain share one row to save vertical space. + exposure_gain_layout = QHBoxLayout() self.exposure_spinbox = QDoubleSpinBox() self.exposure_spinbox.setRange(0, 1.0) # Adjust range as needed self.exposure_spinbox.setSingleStep(0.001) self.exposure_spinbox.setStyleSheet(f"QDoubleSpinBox {{ background-color: {INPUT_BG}; }}") self.exposure_spinbox.setDecimals(3) - self.exposure_spinbox.valueChanged.connect(self._changed) - exposure_layout.addWidget(exposure_label) - exposure_layout.addWidget(self.exposure_spinbox) - # Gain control - gain_layout = QHBoxLayout() - gain_label = QLabel("Gain:") self.gain_spinbox = QDoubleSpinBox() self.gain_spinbox.setRange(0, 1000) # Adjust range as needed self.gain_spinbox.setSingleStep(1) self.gain_spinbox.setDecimals(1) self.gain_spinbox.setStyleSheet(f"QDoubleSpinBox {{ background-color: {INPUT_BG}; }}") self.gain_spinbox.valueChanged.connect(self._changed) - gain_layout.addWidget(gain_label) - gain_layout.addWidget(self.gain_spinbox) + + exposure_gain_layout.addWidget(QLabel("Exposure (s):")) + exposure_gain_layout.addWidget(self.exposure_spinbox) + exposure_gain_layout.addWidget(QLabel("Gain:")) + exposure_gain_layout.addWidget(self.gain_spinbox) # Persist the current gain/exposure as the beam-location preset for the # current zoom (only meaningful in beam-location mode). @@ -86,56 +83,53 @@ class SamcamPanel(QWidget): screenshot_message_layout.addWidget(screenshot_message_label) screenshot_message_layout.addWidget(self.screenshot_message_edit) - self.screenshot_button = QPushButton("Take screenshot") + self.screenshot_button = QPushButton("Save samcam image") self.screenshot_button.clicked.connect(self._request_screenshot) - # Show detections checkbox - detections_layout = QHBoxLayout() + # Overlay checkboxes, two columns to save vertical space; related + # toggles share a row. self.show_detections_checkbox = QCheckBox("Show ML detections") self.show_detections_checkbox.setChecked(True) # Default to checked self.show_detections_checkbox.toggled.connect(self.show_detections_changed.emit) - detections_layout.addWidget(self.show_detections_checkbox) - # Show detection polygons checkbox - detection_polygons_layout = QHBoxLayout() self.show_detection_polygons_checkbox = QCheckBox("Show ML polygons") self.show_detection_polygons_checkbox.setChecked(True) self.show_detection_polygons_checkbox.toggled.connect( self.show_detection_polygons_changed.emit ) - detection_polygons_layout.addWidget(self.show_detection_polygons_checkbox) - # Show target point checkbox - target_point_layout = QHBoxLayout() self.show_target_point_checkbox = QCheckBox("Show target point") self.show_target_point_checkbox.setChecked(True) self.show_target_point_checkbox.toggled.connect(self.show_target_point_changed.emit) - target_point_layout.addWidget(self.show_target_point_checkbox) - # Show target coordinates checkbox - target_coords_layout = QHBoxLayout() self.show_target_coordinates_checkbox = QCheckBox("Show target coordinates") self.show_target_coordinates_checkbox.setChecked(True) self.show_target_coordinates_checkbox.toggled.connect( self.show_target_coordinates_changed.emit ) - target_coords_layout.addWidget(self.show_target_coordinates_checkbox) - # Show legend checkbox - legend_layout = QHBoxLayout() self.show_overlay_legend_checkbox = QCheckBox("Show overlay legend") self.show_overlay_legend_checkbox.setChecked(True) self.show_overlay_legend_checkbox.toggled.connect(self.show_overlay_legend_changed.emit) - legend_layout.addWidget(self.show_overlay_legend_checkbox) - # Compact legend checkbox - compact_legend_layout = QHBoxLayout() self.compact_overlay_legend_checkbox = QCheckBox("Compact legend") self.compact_overlay_legend_checkbox.setChecked(False) self.compact_overlay_legend_checkbox.toggled.connect( self.compact_overlay_legend_changed.emit ) - compact_legend_layout.addWidget(self.compact_overlay_legend_checkbox) + + checkbox_grid = QGridLayout() + for i, checkbox in enumerate( + ( + self.show_detections_checkbox, + self.show_detection_polygons_checkbox, + self.show_target_point_checkbox, + self.show_target_coordinates_checkbox, + self.show_overlay_legend_checkbox, + self.compact_overlay_legend_checkbox, + ) + ): + checkbox_grid.addWidget(checkbox, i // 2, i % 2) # Target color target_color_layout = QHBoxLayout() @@ -148,18 +142,14 @@ class SamcamPanel(QWidget): target_color_layout.addWidget(self.target_color_combo) # Add controls to main layout - layout.addLayout(exposure_layout) - layout.addLayout(gain_layout) - layout.addWidget(self.save_beam_location_button) + layout.addLayout(exposure_gain_layout) + samcam_buttons_layout = QHBoxLayout() + samcam_buttons_layout.addWidget(self.save_beam_location_button) + samcam_buttons_layout.addWidget(self.screenshot_button) + layout.addLayout(samcam_buttons_layout) layout.addLayout(screenshot_filename_layout) layout.addLayout(screenshot_message_layout) - layout.addWidget(self.screenshot_button) - layout.addLayout(detections_layout) - layout.addLayout(detection_polygons_layout) - layout.addLayout(target_point_layout) - layout.addLayout(target_coords_layout) - layout.addLayout(legend_layout) - layout.addLayout(compact_legend_layout) + layout.addLayout(checkbox_grid) layout.addLayout(target_color_layout) self.setLayout(layout) diff --git a/src/aare/gui/panels/scan_settings_panel.py b/src/aare/gui/panels/scan_settings_panel.py index 39285963..3b570acd 100644 --- a/src/aare/gui/panels/scan_settings_panel.py +++ b/src/aare/gui/panels/scan_settings_panel.py @@ -63,10 +63,15 @@ class ScanSettingsPanel(QWidget): # before, so they are unaffected by the wrapping. outer = QVBoxLayout(self) outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(0) outer.addWidget(self._build_source_toggle()) grid_host = QWidget(self) self._layout = QGridLayout(grid_host) + # Toggle-to-grid gap = one grid row gap (top). Bottom 3 + the column's + # 3px spacing = one row gap between the last button and Abort too. + m = self._layout.contentsMargins() + self._layout.setContentsMargins(m.left(), 6, m.right(), 3) outer.addWidget(grid_host) self._layout.addWidget(QLabel("High resolution", parent=self), 0, 0) diff --git a/src/aare/gui/panels/smart_rotation_panel.py b/src/aare/gui/panels/smart_rotation_panel.py index 5b493d74..22855bef 100644 --- a/src/aare/gui/panels/smart_rotation_panel.py +++ b/src/aare/gui/panels/smart_rotation_panel.py @@ -8,7 +8,7 @@ from PySide6.QtWidgets import QGridLayout, QLabel, QPushButton, QSizePolicy, QSp from aare.gui.constants import LOGGER_NAME from aare.gui.panels.rotation_data_collection import add_data_to_path -from aare.gui.styles import GO_TEXT, STATUS_ALERT +from aare.gui.styles import ABORT_TEXT, GO_TEXT, STATUS_ALERT from aare.gui.widgets.number_line_edit import NumberLineEdit logger = setup_logger(LOGGER_NAME) @@ -44,6 +44,10 @@ class SimpleRotationSettingsPanel(QWidget): self._prev_params = SimpleScanParameters() self._layout = QGridLayout(self) + # Top margin 0 like the Raster/Rotation pages (their toggle row sits + # at margin 0), so the gap under the ML-centring row matches. + m = self._layout.contentsMargins() + self._layout.setContentsMargins(m.left(), 0, m.right(), 3) # Visible resolution (entry) self._layout.addWidget(QLabel("Visible resolution", parent=self), 0, 0) @@ -83,133 +87,138 @@ class SimpleRotationSettingsPanel(QWidget): self._layout.addWidget(QLabel("K", parent=self), 4, 4) self.temp_enter.newValue.connect(self.set_temperature) + # Run + Abort directly under the last configurable row; the read-only + # block below is reference info, not something to scroll past to act. + self.run_rotation_button = QPushButton("Run rotation", parent=self) + self.run_rotation_button.setStyleSheet(f"color: {GO_TEXT};") + self.run_rotation_button.clicked.connect(self.run_measurement) + self._layout.addWidget(self.run_rotation_button, 5, 0, 1, 6) + + self.abort_button = QPushButton("Abort measurement", parent=self) + self.abort_button.setStyleSheet(f"color: {ABORT_TEXT};") + self._layout.addWidget(self.abort_button, 6, 0, 1, 6) + # Calculated labels - self._layout.addWidget(QLabel("Target resolution", parent=self), 5, 0) + self._layout.addWidget(QLabel("Target resolution", parent=self), 7, 0) self.target_res_label = QLabel("--", parent=self) self.target_res_label.setAlignment( Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter ) - self._layout.addWidget(self.target_res_label, 5, 1, 1, 3) - self._layout.addWidget(QLabel("Å", parent=self), 5, 4) + self._layout.addWidget(self.target_res_label, 7, 1, 1, 3) + self._layout.addWidget(QLabel("Å", parent=self), 7, 4) - self._layout.addWidget(QLabel("Image time", parent=self), 6, 0) + self._layout.addWidget(QLabel("Image time", parent=self), 8, 0) self.image_time_label = QLabel("--", parent=self) self.image_time_label.setAlignment( Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter ) - self._layout.addWidget(self.image_time_label, 6, 1, 1, 3) - self._layout.addWidget(QLabel("s", parent=self), 6, 4) + self._layout.addWidget(self.image_time_label, 8, 1, 1, 3) + self._layout.addWidget(QLabel("s", parent=self), 8, 4) - self._layout.addWidget(QLabel("Transmission", parent=self), 7, 0) + self._layout.addWidget(QLabel("Transmission", parent=self), 9, 0) self.transmission_label = QLabel("--", parent=self) self.transmission_label.setAlignment( Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter ) - self._layout.addWidget(self.transmission_label, 7, 1, 1, 3) - self._layout.addWidget(QLabel("%", parent=self), 7, 4) + self._layout.addWidget(self.transmission_label, 9, 1, 1, 3) + self._layout.addWidget(QLabel("%", parent=self), 9, 4) - self._layout.addWidget(QLabel("Detector distance", parent=self), 8, 0) + self._layout.addWidget(QLabel("Detector distance", parent=self), 10, 0) self.dtz_label = QLabel("--", parent=self) self.dtz_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) - self._layout.addWidget(self.dtz_label, 8, 1, 1, 3) - self._layout.addWidget(QLabel("mm", parent=self), 8, 4) + self._layout.addWidget(self.dtz_label, 10, 1, 1, 3) + self._layout.addWidget(QLabel("mm", parent=self), 10, 4) - self._layout.addWidget(QLabel("Target Dose", parent=self), 9, 0) + self._layout.addWidget(QLabel("Target Dose", parent=self), 11, 0) self.target_dose_label = QLabel("--", parent=self) self.target_dose_label.setAlignment( Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter ) - self._layout.addWidget(self.target_dose_label, 9, 1, 1, 3) - self._layout.addWidget(QLabel("MGy", parent=self), 9, 4) + self._layout.addWidget(self.target_dose_label, 11, 1, 1, 3) + self._layout.addWidget(QLabel("MGy", parent=self), 11, 4) - self._layout.addWidget(QLabel("Calculated Dose Rate", parent=self), 10, 0) + self._layout.addWidget(QLabel("Calculated Dose Rate", parent=self), 12, 0) self.calculated_dose_rate_label = QLabel("--", parent=self) self.calculated_dose_rate_label.setAlignment( Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter ) - self._layout.addWidget(self.calculated_dose_rate_label, 10, 1, 1, 3) - self._layout.addWidget(QLabel("MGy s-1", parent=self), 10, 4) + self._layout.addWidget(self.calculated_dose_rate_label, 12, 1, 1, 3) + self._layout.addWidget(QLabel("MGy s-1", parent=self), 12, 4) - self._layout.addWidget(QLabel("Wilson B Factor", parent=self), 11, 0) + self._layout.addWidget(QLabel("Wilson B Factor", parent=self), 13, 0) self.wilson_b_label = QLabel("--", parent=self) self.wilson_b_label.setAlignment( Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter ) - self._layout.addWidget(self.wilson_b_label, 11, 1, 1, 3) - self._layout.addWidget(QLabel("Å2", parent=self), 11, 4) + self._layout.addWidget(self.wilson_b_label, 13, 1, 1, 3) + self._layout.addWidget(QLabel("Å2", parent=self), 13, 4) - self._layout.addWidget(QLabel("Crystal Size x", parent=self), 12, 0) + self._layout.addWidget(QLabel("Crystal Size x", parent=self), 14, 0) self.xtal_x_label = QLabel("--", parent=self) self.xtal_x_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) - self._layout.addWidget(self.xtal_x_label, 12, 1, 1, 3) - self._layout.addWidget(QLabel("um", parent=self), 12, 4) - - self._layout.addWidget(QLabel("Crystal Size y", parent=self), 13, 0) - self.xtal_y_label = QLabel("--", parent=self) - self.xtal_y_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) - self._layout.addWidget(self.xtal_y_label, 13, 1, 1, 3) - self._layout.addWidget(QLabel("um", parent=self), 13, 4) - - self._layout.addWidget(QLabel("Crystal Size z", parent=self), 14, 0) - self.xtal_z_label = QLabel("--", parent=self) - self.xtal_z_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) - self._layout.addWidget(self.xtal_z_label, 14, 1, 1, 3) + self._layout.addWidget(self.xtal_x_label, 14, 1, 1, 3) self._layout.addWidget(QLabel("um", parent=self), 14, 4) - self._layout.addWidget(QLabel("Calculated Dose (xtal size)", parent=self), 15, 0) + self._layout.addWidget(QLabel("Crystal Size y", parent=self), 15, 0) + self.xtal_y_label = QLabel("--", parent=self) + self.xtal_y_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + self._layout.addWidget(self.xtal_y_label, 15, 1, 1, 3) + self._layout.addWidget(QLabel("um", parent=self), 15, 4) + + self._layout.addWidget(QLabel("Crystal Size z", parent=self), 16, 0) + self.xtal_z_label = QLabel("--", parent=self) + self.xtal_z_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + self._layout.addWidget(self.xtal_z_label, 16, 1, 1, 3) + self._layout.addWidget(QLabel("um", parent=self), 16, 4) + + self._layout.addWidget(QLabel("Calculated Dose (xtal size)", parent=self), 17, 0) self.xtal_size_dose_label = QLabel("--", parent=self) self.xtal_size_dose_label.setAlignment( Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter ) - self._layout.addWidget(self.xtal_size_dose_label, 15, 1, 1, 3) - self._layout.addWidget(QLabel("MGy", parent=self), 15, 4) + self._layout.addWidget(self.xtal_size_dose_label, 17, 1, 1, 3) + self._layout.addWidget(QLabel("MGy", parent=self), 17, 4) - self._layout.addWidget(QLabel("X-ray Wavelength", parent=self), 16, 0) + self._layout.addWidget(QLabel("X-ray Wavelength", parent=self), 18, 0) self.wavelength_label = QLabel("--", parent=self) self.wavelength_label.setAlignment( Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter ) - self._layout.addWidget(self.wavelength_label, 16, 1, 1, 3) - self._layout.addWidget(QLabel("Å", parent=self), 16, 4) + self._layout.addWidget(self.wavelength_label, 18, 1, 1, 3) + self._layout.addWidget(QLabel("Å", parent=self), 18, 4) - self._layout.addWidget(QLabel("Flux", parent=self), 17, 0) + self._layout.addWidget(QLabel("Flux", parent=self), 19, 0) self.flux_label = QLabel("--", parent=self) self.flux_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) - self._layout.addWidget(self.flux_label, 17, 1, 1, 3) - self._layout.addWidget(QLabel("x 109 ph s-1", parent=self), 17, 4) + self._layout.addWidget(self.flux_label, 19, 1, 1, 3) + self._layout.addWidget(QLabel("x 109 ph s-1", parent=self), 19, 4) - self._layout.addWidget(QLabel("Beam Size", parent=self), 18, 0) + self._layout.addWidget(QLabel("Beam Size", parent=self), 20, 0) self.beam_size_label = QLabel("--", parent=self) self.beam_size_label.setAlignment( Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter ) - self._layout.addWidget(self.beam_size_label, 18, 1, 1, 3) - self._layout.addWidget(QLabel("um2", parent=self), 18, 4) + self._layout.addWidget(self.beam_size_label, 20, 1, 1, 3) + self._layout.addWidget(QLabel("um2", parent=self), 20, 4) - self._layout.addWidget(QLabel("Calculated Dose", parent=self), 19, 0) + self._layout.addWidget(QLabel("Calculated Dose", parent=self), 21, 0) self.calculated_dose_label = QLabel("--", parent=self) self.calculated_dose_label.setAlignment( Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter ) - self._layout.addWidget(self.calculated_dose_label, 19, 1, 1, 3) - self._layout.addWidget(QLabel("MGy", parent=self), 19, 4) + self._layout.addWidget(self.calculated_dose_label, 21, 1, 1, 3) + self._layout.addWidget(QLabel("MGy", parent=self), 21, 4) - self._layout.addWidget(QLabel("Total measurement time", parent=self), 20, 0) + self._layout.addWidget(QLabel("Total measurement time", parent=self), 22, 0) self.total_time = QLabel(f"{self.total_time_s} min 0 s") self.total_time.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) - self._layout.addWidget(self.total_time, 20, 1, 1, 3) + self._layout.addWidget(self.total_time, 22, 1, 1, 3) - # add vertical stretch between detector distance and the run button + # Vertical stretch below everything (surplus space sink). self._layout.addItem( - QSpacerItem(0, 0, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding), 21, 0, 1, 6 + QSpacerItem(0, 0, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding), 23, 0, 1, 6 ) - # Run rotation button - self.run_rotation_button = QPushButton("Run rotation", parent=self) - self.run_rotation_button.setStyleSheet(f"color: {GO_TEXT};") - self.run_rotation_button.clicked.connect(self.run_measurement) - self._layout.addWidget(self.run_rotation_button, 22, 0, 1, 6) - @Slot(DAQStatusModel) def update_daq_status(self, s: DAQStatusModel): self._d = s diff --git a/src/aare/gui/widgets/raster_grid_table.py b/src/aare/gui/widgets/raster_grid_table.py index ee497610..7f76791d 100644 --- a/src/aare/gui/widgets/raster_grid_table.py +++ b/src/aare/gui/widgets/raster_grid_table.py @@ -33,8 +33,10 @@ class RasterGridTable(QTableWidget): header.setSectionResizeMode(i, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch) - # Set minimum height - self.setMinimumHeight(100) + # Exactly 5 rows of space; more grids scroll inside the table. + header_height = self.horizontalHeader().sizeHint().height() + row_height = self.verticalHeader().defaultSectionSize() + self.setFixedHeight(header_height + 3 * row_height + 1 * self.frameWidth()) # Connect to raster manager signals self._raster_mgr.completed_grid_updated.connect(self.refresh_table) -- 2.54.0 From 2054f078b0d9ad3e982cbace0ccf34dc6c02b562 Mon Sep 17 00:00:00 2001 From: Dawn Date: Fri, 7 Aug 2026 17:08:40 +0200 Subject: [PATCH 24/57] feat: camera session badge and collapsible legend The camera overlay gains a clickable session badge (opens the session menu at the click position) and a baton gate: watching stays free, operating clicks are blocked when the baton is elsewhere. The legend collapses to a ? badge until clicked - the full box covered too much image. Co-Authored-By: Claude Fable 5 --- src/aare/gui/widgets/camera_image.py | 74 +++++++++++++++++++++++++++- src/aare/gui/widgets/status_bar.py | 16 ++++-- 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/src/aare/gui/widgets/camera_image.py b/src/aare/gui/widgets/camera_image.py index 58014244..dc225ab8 100644 --- a/src/aare/gui/widgets/camera_image.py +++ b/src/aare/gui/widgets/camera_image.py @@ -75,6 +75,7 @@ class SampleCameraImageState(Enum): class SampleCameraImageLabel(QGraphicsView): smargon = Signal(SmargonCoordinate) + session_badge_clicked = Signal() evaluate_grid = Signal() clear_grid = Signal() @@ -107,6 +108,9 @@ class SampleCameraImageLabel(QGraphicsView): self._sam_cam = SampleCameraSettings(exposure=0.1, gain=100.0) self._is_daq_busy = False self._camera_available = True + # Baton gate: watching allowed, operating not (main_window drives it). + self._operations_allowed = True + self._session_badge_rect: QRect | None = None # viewport coords self._last_grid_update_ts = 0.0 self._grid_update_min_interval_s = 1.0 / 25.0 self._tell_state = None @@ -127,6 +131,10 @@ class SampleCameraImageLabel(QGraphicsView): self._show_target_coordinates = True self._show_overlay_legend = True self._compact_overlay_legend = False + # Legend stays collapsed to a "?" badge until clicked — the full box + # covers too much of the camera image to be always-on. + self._legend_expanded = False + self._legend_hit_rect: QRectF | None = None # viewport coords, set on paint self._target_point = None self._target_shape = None self._target_color_name = "Cyan" @@ -199,7 +207,14 @@ class SampleCameraImageLabel(QGraphicsView): ) def _camera_interaction_enabled(self) -> bool: - return self._camera_available + # Watching is free; clicking (targets, raster, smargon moves) needs + # the camera AND the session baton. + return self._camera_available and self._operations_allowed + + @Slot(bool) + def set_operations_enabled(self, enabled: bool): + self._operations_allowed = enabled + self.update() @Slot(bool) def set_camera_available(self, available: bool): @@ -303,6 +318,7 @@ class SampleCameraImageLabel(QGraphicsView): painter.restore() def _draw_session_overlay(self, painter: QPainter): + self._session_badge_rect = None if self._busy_overlay_style is not None: return @@ -343,6 +359,8 @@ class SampleCameraImageLabel(QGraphicsView): position_y = int((vh - bg_h) / 2) bg_rect = QRect(position_x, position_y, bg_w, bg_h) + # Clicking the badge opens the session (grab/request) menu. + self._session_badge_rect = bg_rect painter.setPen(QPen(qcolor(WHITE, 220))) painter.setBrush(bg_color) @@ -406,6 +424,31 @@ class SampleCameraImageLabel(QGraphicsView): self._scaling() def mousePressEvent(self, event): + # Legend badge first: pure UI affordance, must work even when camera + # interaction is disabled (session overlay etc.). + if ( + event.button() == Qt.MouseButton.LeftButton + and self._legend_hit_rect is not None + and self._legend_hit_rect.contains( + QPointF(self.viewport().mapFrom(self, event.pos())) + ) + ): + self._legend_expanded = not self._legend_expanded + self.update() + event.accept() + return + + # Session badge: the way IN when everything else is gated — must fire + # before the interaction-enabled check below. + if ( + event.button() == Qt.MouseButton.LeftButton + and self._session_badge_rect is not None + and self._session_badge_rect.contains(self.viewport().mapFrom(self, event.pos())) + ): + self.session_badge_clicked.emit() + event.accept() + return + if not self._camera_interaction_enabled(): if event.button() in (Qt.MouseButton.LeftButton, Qt.MouseButton.RightButton): self._show_camera_unavailable_tooltip(event) @@ -930,10 +973,38 @@ class SampleCameraImageLabel(QGraphicsView): return lines + def _draw_legend_badge(self, painter: QPainter): + # ponytail: painted circle, not a real QWidget button — the legend it + # toggles is painter-drawn too, and a widget would need layout juggling. + diameter = 22 + rect = QRectF(18, self.viewport().height() - diameter - 18, diameter, diameter) + + painter.save() + painter.resetTransform() + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + painter.setPen(QPen(qcolor(WHITE, 60), 1)) + painter.setBrush(qcolor(LEGEND_BG, 170)) + painter.drawEllipse(rect) + + font = QFont() + font.setPointSize(10) + font.setBold(True) + painter.setFont(font) + painter.setPen(QPen(qcolor(LEGEND_TEXT), 1)) + painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, "?") + painter.restore() + + self._legend_hit_rect = rect + def _draw_overlay_legend(self, painter: QPainter): + self._legend_hit_rect = None if not self._legend_should_show(): return + if not self._legend_expanded: + self._draw_legend_badge(painter) + return + painter.save() painter.resetTransform() painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) @@ -959,6 +1030,7 @@ class SampleCameraImageLabel(QGraphicsView): height = len(lines) * line_height + 16 bg_rect = QRectF(left, max(18, top), width, height) + self._legend_hit_rect = bg_rect # click anywhere on the box to collapse painter.setPen(QPen(qcolor(WHITE, 60), 1)) painter.setBrush(qcolor(LEGEND_BG, 170)) painter.drawRoundedRect(bg_rect, 8, 8) diff --git a/src/aare/gui/widgets/status_bar.py b/src/aare/gui/widgets/status_bar.py index 00466b6b..aca8d4d1 100644 --- a/src/aare/gui/widgets/status_bar.py +++ b/src/aare/gui/widgets/status_bar.py @@ -334,7 +334,7 @@ class StatusBar(QStatusBar): self.session_label.setText(text) - def show_session_menu(self): + def show_session_menu(self, global_pos: QPoint | None = None): menu = QMenu(self) is_busy = self._status and self._status.busy session_state = self._status.session.session if self._status else SessionsStateEnum.Vacant @@ -413,10 +413,16 @@ class StatusBar(QStatusBar): action_force = menu.addAction("⚠️ Force Take Over") action_force.triggered.connect(self._on_force_session_clicked) - label_geometry = self.session_label.geometry() - menu_width = max(label_geometry.width(), menu.sizeHint().width()) - menu.move(self.mapToGlobal(label_geometry.topLeft()) - QPoint(0, menu.sizeHint().height())) - menu.setFixedWidth(menu_width) + if global_pos is not None: + # Invoked from the camera's session badge — open at the click. + menu.move(global_pos) + else: + label_geometry = self.session_label.geometry() + menu_width = max(label_geometry.width(), menu.sizeHint().width()) + menu.move( + self.mapToGlobal(label_geometry.topLeft()) - QPoint(0, menu.sizeHint().height()) + ) + menu.setFixedWidth(menu_width) menu.exec() def show_pgroup_menu(self): -- 2.54.0 From 1f9009807d555c96b1be214c9a4d0791deef32e8 Mon Sep 17 00:00:00 2001 From: Dawn Date: Fri, 7 Aug 2026 17:08:51 +0200 Subject: [PATCH 25/57] feat: merge Sample List, Reference Tools, and the queue into one dock Reference Tools was a separate staff-only dock tabified behind Sample List and kept vanishing (saved dock state, raise order); the automation job list was a third dock for what is conceptually the same table. One tabbed dock now holds Dewar samples + Auxiliary puck (concept from aaregui2's QueuePanel): the dewar table doubles as the queue view with the queue buttons and an Unmount button in a row beneath it, SampleQueuePanel lives on hidden as the queue engine, and queue/flag membership is edited via right-click or row drags onto the Queued and Flagged chips. Column 0 is a frozen #+status cell (mounted > queued > flagged > measured, tints context-dependent per chip filter); non-staff see Auxiliary greyed out with a Staff-only popup instead of hidden. Ctrl+L/Ctrl+R raise the Dewar/Auxiliary tab. The Sample List and log pop-outs are fully operational second views on shared models. Riders: MainWindow installs WheelValueGuard, wires the camera session badge, and moves samcam/monochromator/ABR/beam-config into the left column's Beamline group (BeamlineControls keeps only zoom/light/omega/smargon). Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 578 +++++++++++++++---- src/aare/gui/models/user_sample_model.py | 159 ++++- src/aare/gui/panels/beamline_controls.py | 25 +- src/aare/gui/panels/reference_tools_panel.py | 60 +- src/aare/gui/panels/sample_queue_panel.py | 7 + src/aare/gui/panels/tell_sample_panel.py | 324 +++++++++-- 6 files changed, 899 insertions(+), 254 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 2ca59df5..91aecd36 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -12,16 +12,21 @@ from aarecommon.models.models import ( BeamlineStateEnum, DAQStatusModel, SampleShortInfoList, + SessionsStateEnum, TokenData, ) from PySide6.QtCore import QEvent, QSettings, Qt, QTimer, Signal, Slot -from PySide6.QtGui import QAction, QActionGroup, QGuiApplication, QKeySequence +from PySide6.QtGui import QAction, QActionGroup, QColor, QCursor, QGuiApplication, QKeySequence from PySide6.QtWidgets import ( + QApplication, + QCheckBox, QDockWidget, QFrame, + QGraphicsColorizeEffect, QHBoxLayout, QMainWindow, QMessageBox, + QPushButton, QScrollArea, QSizePolicy, QStackedWidget, @@ -38,7 +43,8 @@ from aare.gui.constants import LOGGER_NAME from aare.gui.models.gui_state_manager import UIStateManager from aare.gui.panels.automation_panel import AutomationProgressWidget from aare.gui.panels.axis_video_panel import AxisVideoPanel -from aare.gui.panels.beamline_controls import BeamlineControls +from aare.gui.panels.abr_tweak_panel import AbrTweakWidget +from aare.gui.panels.beamline_controls import BeamConfigPanel, BeamlineControls from aare.gui.panels.beamline_recovery_panel import BeamlineRecoveryDialog from aare.gui.panels.beamline_state_panel import BeamlineStatePanel from aare.gui.panels.compact_automation_panel import CompactAutomationPanel @@ -50,10 +56,11 @@ 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.monochromator_panel import MonochromatorPanel 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 +from aare.gui.panels.samcam_panel import SamcamPanel from aare.gui.panels.sample_queue_panel import SampleQueuePanel from aare.gui.panels.smargon_trace_panel import SmargonTracePanel from aare.gui.panels.target_stability_panel import TargetStabilityPanel @@ -63,7 +70,13 @@ from aare.gui.panels.tell_sample_panel import TellSamplePanel from aare.gui.scan_logic.raster_grid_manager import RasterGridManager from aare.gui.scan_logic.rotation_scan_manager import RotationScanManager from aare.gui.scan_logic.sample_mount_logic import SampleMountLogic -from aare.gui.styles import BACKGROUND, THEME_ORIGINAL, THEME_PORTRAIT, build_app_stylesheet +from aare.gui.styles import ( + BACKGROUND, + DOCK_CONTENT_LEFT_PAD, + THEME_ORIGINAL, + THEME_PORTRAIT, + build_app_stylesheet, +) # Threads from aare.gui.threads.axis_video_thread import VideoThread @@ -71,6 +84,8 @@ from aare.gui.threads.daq_worker import DAQWorker from aare.gui.threads.jfjoch_viewer import JFJochDBusClient from aare.gui.threads.prediction_subscriber import PredictionSubscriber from aare.gui.tutorials.controls_help_dialog import ControlsHelpDialog +from aare.gui.widgets.popout_window import DockTitleBar, PopoutWindow +from aare.gui.widgets.wheel_value_guard import WheelValueGuard # Tutorials from aare.gui.tutorials.tutorial_actions import TutorialActionExecutor @@ -88,7 +103,7 @@ from aare.gui.widgets.camera_image import SampleCameraImageLabel from aare.gui.widgets.message_box import precondition_check from aare.gui.widgets.no_wheel_scroll_area import NoWheelScrollArea from aare.gui.widgets.status_bar import StatusBar -from aare.gui.widgets.title_label import tighten_column +from aare.gui.widgets.title_label import TitleLabel, tighten_column from aare.gui.widgets.video_image import VideoGraphicsView logger = setup_logger(LOGGER_NAME) @@ -129,7 +144,6 @@ class MainWindow(QMainWindow): self._cleanup_done = False self._default_window_state = None self._pre_automation_window_state = None - self._pre_automation_ref_tools_visible = False self._pre_automation_left_column_visible = True self._pre_automation_right_column_visible = True self._in_compact_automation_view = False @@ -159,6 +173,14 @@ class MainWindow(QMainWindow): self._tutorial_event_bus = TutorialEventBus(self) self._tutorial_text_resolver = DictionaryTextResolver(MANUAL_MOUNT_TUTORIAL) self.state_manager = UIStateManager("PSI", "AareGUI") + + # Wheel safety: sliders/spin boxes/combos only react to the wheel + # while the right mouse button is held; a bare wheel just scrolls + # the page — it can never nudge a value or move a motor. + self._wheel_value_guard = WheelValueGuard(self) + app_instance = QApplication.instance() + if app_instance is not None: + app_instance.installEventFilter(self._wheel_value_guard) self.viewer = JFJochDBusClient() try: @@ -239,17 +261,80 @@ class MainWindow(QMainWindow): s=geom, parent=self.left_column, raster_mgr=self.raster, diffraction=diffraction ) - self.loop_centering = LoopCenteringPanel(parent=self.left_column) - # The beamline state strip lives in a bottom toolbar row (created # after the docks), not in the left column. Always visible. self.beamline_state_panel = BeamlineStatePanel(parent=self) - self.left_column_layout.addWidget(self.data_collection) - self.left_column_layout.addWidget(self.loop_centering) + # Beamline / Experiment as tabs (like the Dewar samples dock) instead + # of two stacked banner groups; the pages keep their banner children. + # documentMode: no pane frame, so the fixed-width panels aren't inset. + self.left_column_tabs = QTabWidget(self.left_column) + self.left_column_tabs.setDocumentMode(True) + # documentMode draws a grey base line across the bar's full width. + self.left_column_tabs.tabBar().setDrawBase(False) + + beamline_page = QWidget() + beamline_layout = QVBoxLayout(beamline_page) + beamline_layout.setContentsMargins(0, 0, 0, 0) + self.samcam = SamcamPanel(beamline_page) + if self._decoded_token.staff: + self.monochromator_panel = MonochromatorPanel(beamline_page) + self.abr_tweak = AbrTweakWidget(beamline_page) + self.beam_config = BeamConfigPanel(beamline_page) + self.beam_mark = self.beam_config.beam_mark + self.beam_center = self.beam_config.beam_center + self.beam_size = self.beam_config.beam_size + beamline_layout.addWidget(self.monochromator_panel) + beamline_layout.addWidget(self.abr_tweak) + beamline_layout.addWidget(self.beam_config) + # Samcam last: the beam panels are the ones tweaked most. + beamline_layout.addWidget(self.samcam) + beamline_layout.addStretch() + tighten_column(beamline_layout) + + experiment_page = QWidget() + experiment_layout = QVBoxLayout(experiment_page) + experiment_layout.setContentsMargins(0, 0, 0, 0) + experiment_layout.addWidget(self.data_collection) + experiment_layout.addStretch() + tighten_column(experiment_layout) + + self.left_column_tabs.addTab(beamline_page, "Beamline") + self.left_column_tabs.addTab(experiment_page, "Experiment") + + # Only the visible page counts toward the height — same trick as the + # content stack below, else the taller page pads the other tab. + def _only_current_left_tab_counts(index: int) -> None: + for i in range(self.left_column_tabs.count()): + page = self.left_column_tabs.widget(i) + vertical = ( + QSizePolicy.Policy.Preferred if i == index else QSizePolicy.Policy.Ignored + ) + page.setSizePolicy(QSizePolicy.Policy.Preferred, vertical) + + self.left_column_tabs.currentChanged.connect(_only_current_left_tab_counts) + _only_current_left_tab_counts(self.left_column_tabs.currentIndex()) + + # Dewar-tabs look: first banner flush under the tab bar (no top + # margin) and the tab row starting at the banners' left edge. The + # Experiment side needs two levels: the frame AND its first panel. + beamline_first = ( + self.monochromator_panel if self._decoded_token.staff else self.samcam + ) + for first in ( + beamline_first, + self.data_collection, + self.data_collection.file_path_panel, + ): + m = first.layout().contentsMargins() + first.layout().setContentsMargins(m.left(), 0, m.right(), m.bottom()) + self.left_column_tabs.setStyleSheet( + "QTabWidget::tab-bar {" + f" left: {self.samcam.layout().contentsMargins().left()}px; }}" + ) + + self.left_column_layout.addWidget(self.left_column_tabs) 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) @@ -257,9 +342,14 @@ class MainWindow(QMainWindow): Qt.ScrollBarPolicy.ScrollBarAlwaysOff ) self.collection_controls_scroll.setWidgetResizable(True) - self.collection_controls_scroll.setFixedWidth( - max(self.data_collection.set_width, self.loop_centering.sizeHint().width()) + 10 - ) + # No frame: its border drew a line above the tab bar (Dewar tabs have + # none). Freeze the inner column width: widgetResizable makes it track + # the viewport, so the scrollbar appearing used to re-flow every + # banner. Fixed width + a permanent 10px scrollbar gutter means the + # scrollbar pops into spare space and nothing moves. + self.collection_controls_scroll.setFrameShape(QFrame.Shape.NoFrame) + self.left_column.setFixedWidth(self.data_collection.set_width) + self.collection_controls_scroll.setFixedWidth(self.data_collection.set_width + 10) self.video_tab = QTabWidget(parent=top_widget) @@ -341,9 +431,7 @@ class MainWindow(QMainWindow): self.beamline_controls_scroll = NoWheelScrollArea(top_widget) - self.beamline = BeamlineControls( - self.beamline_controls_scroll, staff=self._decoded_token.staff - ) + self.beamline = BeamlineControls(self.beamline_controls_scroll) 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 @@ -352,7 +440,9 @@ class MainWindow(QMainWindow): self.beamline_controls_scroll.setHorizontalScrollBarPolicy( Qt.ScrollBarPolicy.ScrollBarAlwaysOff ) - self.beamline_controls_scroll.setFixedWidth(self.beamline.set_width + 10) + # Same gutter math as the left column: 10px scrollbar + 2px frame, so + # the fixed-width controls are never clipped when the scrollbar shows. + self.beamline_controls_scroll.setFixedWidth(self.beamline.set_width + 12) self.tell_samples = TellSamplePanel(samples=SampleShortInfoList(s=[])) self.ref_tools_panel = ReferenceToolsPanel(samples=SampleShortInfoList(s=[])) @@ -368,31 +458,85 @@ class MainWindow(QMainWindow): ) self.compact_automation_panel.annotation_selected.connect(self._handle_compact_annotation) + # One dock for both lists: the old tabified Reference Tools dock was + # staff-only and hid behind the Sample List tab, so it "sometimes" + # showed. aaregui2 concept: one panel, Dewar + Auxiliary-puck tabs + # that always travel together, and the dewar table doubles as the + # queue view (status tints), so the automation controls sit under it + # and the Automation list dock is gone. SampleQueuePanel stays alive, + # hidden, as the queue engine; its buttons are reparented here so all + # their existing wiring keeps working. + dewar_tab = QWidget() + dewar_layout = QVBoxLayout(dewar_tab) + dewar_layout.setContentsMargins(0, 0, 0, 0) + dewar_layout.setSpacing(2) + dewar_layout.addWidget(self.tell_samples) + + self.quick_unmount_button = QPushButton("⏏ Unmount", dewar_tab) + self.quick_unmount_button.clicked.connect(lambda: self._on_manual_unmount_requested()) + + automation_row = QHBoxLayout() + for w in ( + self.job_list_panel.play_button, + self.job_list_panel.remove_button, + self.job_list_panel.clear_button, + self.quick_unmount_button, + self.job_list_panel.park_and_dry_when_cleared, + self.job_list_panel.pause_on_conditions_cb, + ): + automation_row.addWidget(w) + dewar_layout.addLayout(automation_row) + + # "Remove selected" now unqueues the dewar-table selection — the + # queue's own table is no longer displayed. + self.job_list_panel.remove_button.clicked.disconnect( + self.job_list_panel.remove_selected_samples + ) + self.job_list_panel.remove_button.clicked.connect(self._remove_selected_from_queue) + self.job_list_panel.hide() + + self.sample_lists_tabs = QTabWidget() + self.sample_lists_tabs.addTab(dewar_tab, "Dewar samples") + self.sample_lists_tabs.addTab(self.ref_tools_panel, "Auxiliary puck") + # Non-staff never get reference-tools data (the DAQ connect below is + # staff-gated). Grey the tab out instead of hiding it: every role sees + # the same view, and clicking the locked tab says why it is locked. + if not self._decoded_token.staff: + self.sample_lists_tabs.setTabEnabled(1, False) + self.sample_lists_tabs.setTabToolTip(1, "Staff only") + # Disabled tabs are skipped by tabBarClicked's hit-test, so the + # click is caught in eventFilter via the geometric tabAt() instead. + self.sample_lists_tabs.tabBar().installEventFilter(self) + + # Wrapper for the left inset: QTabWidget ignores its own contents + # margins for the tab bar, so the padding lives one level up. Aligns + # the panel's left edge with the left column above (Loop centering). + sample_lists_wrap = QWidget() + sample_lists_wrap_layout = QVBoxLayout(sample_lists_wrap) + sample_lists_wrap_layout.setContentsMargins(DOCK_CONTENT_LEFT_PAD, 0, 0, 0) + sample_lists_wrap_layout.setSpacing(0) + sample_lists_wrap_layout.addWidget(self.sample_lists_tabs) + self.tell_samples_dock = QDockWidget("Sample List", self) self.tell_samples_dock.setObjectName("tell_samples_dock") - self.tell_samples_dock.setWidget(self.tell_samples) + self.tell_samples_dock.setWidget(sample_lists_wrap) self.tell_samples_dock.setAllowedAreas(Qt.DockWidgetArea.BottomDockWidgetArea) self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.tell_samples_dock) - self.ref_tools_dock = QDockWidget("Reference Tools", self) - self.ref_tools_dock.setObjectName("ref_tools_dock") - self.ref_tools_dock.setWidget(self.ref_tools_panel) - self.ref_tools_dock.setAllowedAreas(Qt.DockWidgetArea.BottomDockWidgetArea) - self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.ref_tools_dock) - self.tabifyDockWidget(self.ref_tools_dock, self.tell_samples_dock) - if self._decoded_token.staff: - self.ref_tools_dock.show() - else: - self.ref_tools_dock.hide() + # No floating: popping the dock out ripped the panel from the row and + # reshuffled the rest. The ⤢ button (in the title bar, next to ✕) + # opens an ADDITIONAL fully-wired window; closing it changes nothing. + self.tell_samples_dock.setFeatures( + QDockWidget.DockWidgetFeature.DockWidgetMovable + | QDockWidget.DockWidgetFeature.DockWidgetClosable + ) + self._sample_popout: PopoutWindow | None = None + self.tell_samples_dock.setTitleBarWidget( + DockTitleBar(self.tell_samples_dock, self._open_sample_popout) + ) self.sample_logic = SampleMountLogic() - self.job_list_dock = QDockWidget("Automation list", self) - self.job_list_dock.setObjectName("job_list_dock") - self.job_list_dock.setWidget(self.job_list_panel) - self.job_list_dock.setAllowedAreas(Qt.DockWidgetArea.BottomDockWidgetArea) - self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.job_list_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. @@ -401,9 +545,20 @@ class MainWindow(QMainWindow): self.automation_progress_panel = AutomationProgressWidget() self.automation_progress_dock = QDockWidget("Automation progress", self) self.automation_progress_dock.setObjectName("automation_progress_dock") - self.automation_progress_dock.setWidget(self.automation_progress_panel) + # Scroll host: the panel's ~420px minimum otherwise dictates the whole + # bottom row's height and squeezes the Beamline column into a scrollbar. + automation_scroll = NoWheelScrollArea(self.automation_progress_dock) + automation_scroll.setWidget(self.automation_progress_panel) + automation_scroll.setWidgetResizable(True) + automation_scroll.setFrameShape(QFrame.Shape.NoFrame) + self.automation_progress_dock.setWidget(automation_scroll) self.automation_progress_dock.setAllowedAreas(Qt.DockWidgetArea.BottomDockWidgetArea) self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.automation_progress_dock) + # Same title-bar icons (⤢ pop-out + ✕) as Sample List / Console Log. + self._automation_popout: PopoutWindow | None = None + self.automation_progress_dock.setTitleBarWidget( + DockTitleBar(self.automation_progress_dock, self._open_automation_popout) + ) self.face_panel = FaceDetectionPanel() self.face_panel_dock = QDockWidget("Face detection", self) @@ -560,6 +715,19 @@ class MainWindow(QMainWindow): self.create_menu_bar() self._update_view_mode_actions() self._setup_global_shortcuts() + # Default bottom-dock height: the sample list used to grab ~40% of the + # window and squeeze the Beamline column behind a scrollbar. Before + # the default-state capture so "reset layout" gets it too; a saved + # user layout (restored below) still wins. + self.resizeDocks( + [self.tell_samples_dock, self.log_dock], [240, 240], Qt.Orientation.Vertical + ) + # Equal oversized requests -> Qt distributes proportionally = 50/50. + self.resizeDocks( + [self.tell_samples_dock, self.automation_progress_dock], + [10000, 10000], + Qt.Orientation.Horizontal, + ) self._capture_default_window_state() self._restore_window_state() @@ -623,15 +791,20 @@ class MainWindow(QMainWindow): if self._decoded_token.staff: self.daq.reference_tools.connect(self.ref_tools_panel.new_list) - self.beamline.samcam.changed.connect(self.daq.samcam_settings) - self.beamline.samcam.screenshot_requested.connect(self.daq.send_screenshot_db) - self.beamline.samcam.save_beam_location_setting.connect( + self.samcam.changed.connect(self.daq.samcam_settings) + self.samcam.screenshot_requested.connect(self.daq.send_screenshot_db) + self.samcam.save_beam_location_setting.connect( self.daq.save_beam_location_camera_setting ) - self.loop_centering.find_tip.clicked.connect(self.daq.center_loop) - self.loop_centering.bounding_box.clicked.connect(self.daq.ml_bounding_box) + self.data_collection.find_tip.clicked.connect(self.daq.center_loop) + self.data_collection.bounding_box.clicked.connect(self.daq.ml_bounding_box) self.daq.raster_generated_by_ml.connect(self.raster.update_active_grid_request) + # Clicking the big session badge (SESSION VACANT / Guest Mode) opens + # the grab/request menu right at the cursor. + self.sample_camera.session_badge_clicked.connect( + lambda: self.status_bar.show_session_menu(QCursor.pos()) + ) self.sample_camera.smargon.connect(self.daq.move_smargon) self.beamline.smargon_panel.smargon.connect(self.daq.move_smargon) self.sample_camera.samcam_updated.connect(self.daq.samcam_settings) @@ -647,36 +820,36 @@ class MainWindow(QMainWindow): self.beamline.illumination_panel.back_light.connect(self.daq.back_light) if self._decoded_token.staff: - self.beamline.monochromator_panel.mono_pitch_scan.connect(self.daq.mono_pitch_scan) - self.beamline.monochromator_panel.change_energy.connect(self.daq.change_energy) - self.beamline.abr_tweak.abr_tweak.connect(self.daq.abr_tweak) - self.beamline.abr_tweak.abr_save.connect(self.daq.abr_save) - self.beamline.abr_tweak.abr_goto_meas.connect(self.daq.abr_goto_meas) + self.monochromator_panel.mono_pitch_scan.connect(self.daq.mono_pitch_scan) + self.monochromator_panel.change_energy.connect(self.daq.change_energy) + self.abr_tweak.abr_tweak.connect(self.daq.abr_tweak) + self.abr_tweak.abr_save.connect(self.daq.abr_save) + self.abr_tweak.abr_goto_meas.connect(self.daq.abr_goto_meas) - self.beamline.beam_mark.beam_mark_clear.connect(self.daq.beam_mark_clear) + self.beam_mark.beam_mark_clear.connect(self.daq.beam_mark_clear) self.sample_camera.update_beam_mark.connect(self.daq.beam_mark_add) - self.beamline.beam_center.beam_center.connect(self.daq.beam_center) - self.beamline.beam_size.beam_size.connect(self.daq.beam_size_mm) + self.beam_center.beam_center.connect(self.daq.beam_center) + self.beam_size.beam_size.connect(self.daq.beam_size_mm) self.sample_camera.load_image.connect(self.raster.load_image) self.sample_camera.switch_raster_grid.connect(self.data_collection.switch_to_raster) - self.beamline.samcam.show_detections_changed.connect(self.sample_camera.set_show_detections) - self.beamline.samcam.show_detection_polygons_changed.connect( + self.samcam.show_detections_changed.connect(self.sample_camera.set_show_detections) + self.samcam.show_detection_polygons_changed.connect( self.sample_camera.set_show_detection_polygons ) - self.beamline.samcam.show_target_point_changed.connect( + self.samcam.show_target_point_changed.connect( self.sample_camera.set_show_target_point ) - self.beamline.samcam.show_target_coordinates_changed.connect( + self.samcam.show_target_coordinates_changed.connect( self.sample_camera.set_show_target_coordinates ) - self.beamline.samcam.show_overlay_legend_changed.connect( + self.samcam.show_overlay_legend_changed.connect( self.sample_camera.set_show_overlay_legend ) - self.beamline.samcam.compact_overlay_legend_changed.connect( + self.samcam.compact_overlay_legend_changed.connect( self.sample_camera.set_compact_overlay_legend ) - self.beamline.samcam.target_color_changed.connect(self.sample_camera.set_target_color) + self.samcam.target_color_changed.connect(self.sample_camera.set_target_color) self._restore_samcam_overlay_settings() sample_feed_addr = pred_zmq_addr or zmq_addr @@ -757,6 +930,16 @@ class MainWindow(QMainWindow): self.ref_tools_panel.mount.connect(self._on_manual_mount_requested) self.ref_tools_panel.unmount.connect(self._on_manual_unmount_requested) + # Dewar table doubles as the queue view: right-click edits queue + # membership, and every queue change repaints the status tints. + self.tell_samples.add_to_queue.connect( + lambda lst: self.job_list_panel.queue_samples(lst.s, replace=False) + ) + self.tell_samples.remove_from_queue.connect( + lambda lst: self.job_list_panel.remove_samples([s.db_id for s in lst.s]) + ) + self.job_list_panel.table_model.modelReset.connect(self._sync_queue_row_tints) + self.data_collection.raster.grid_size_updated.connect(self.raster.update_grid_size) self.data_collection.raster.exp_time_updated.connect(self.raster.update_exposure_time) self.data_collection.raster.transmission_updated.connect(self.raster.update_transmission) @@ -833,13 +1016,13 @@ class MainWindow(QMainWindow): self.daq.update.connect(self.prediction_thread.update_daq_status) if self._decoded_token.staff: - self.daq.update.connect(self.beamline.monochromator_panel.update_daq_status) - self.daq.update.connect(self.beamline.beam_size.update_daq_status) - self.daq.update.connect(self.beamline.beam_center.update_daq_status) - self.daq.update.connect(self.beamline.abr_tweak.update_daq_status) - self.daq.update.connect(self.beamline.beam_mark.update_daq_status) + self.daq.update.connect(self.monochromator_panel.update_daq_status) + self.daq.update.connect(self.beam_size.update_daq_status) + self.daq.update.connect(self.beam_center.update_daq_status) + self.daq.update.connect(self.abr_tweak.update_daq_status) + self.daq.update.connect(self.beam_mark.update_daq_status) self.daq.update.connect(self.status_bar.update_daq_status) - self.daq.update.connect(self.beamline.samcam.update_daq_status) + self.daq.update.connect(self.samcam.update_daq_status) self.daq.update.connect(self.beamline.zoom_panel.update_daq_status) self.daq.update.connect(self.sample_logic.update_daq_status) @@ -859,6 +1042,7 @@ class MainWindow(QMainWindow): self.daq.raster_scan_completed.connect(self.raster.grid_scan_completed) self.daq.automated_scan_done.connect(self.job_list_panel.automated_scan_done) + self.daq.automated_scan_done.connect(self._mark_scan_result) self.daq.automation_critical_failure.connect(self._on_automation_critical_failure) self.daq.manual_collection_critical_failure.connect( self._on_manual_collection_critical_failure @@ -902,21 +1086,30 @@ class MainWindow(QMainWindow): self._shortcut_raise_sample_list = QAction("Raise sample list", self) self._shortcut_raise_sample_list.setShortcut(QKeySequence("Ctrl+L")) self._shortcut_raise_sample_list.triggered.connect( - lambda: (self.tell_samples_dock.setVisible(True), self.tell_samples_dock.raise_()) + lambda: ( + self.tell_samples_dock.setVisible(True), + self.tell_samples_dock.raise_(), + self.sample_lists_tabs.setCurrentIndex(0), + ) ) self.addAction(self._shortcut_raise_sample_list) + # Reference tools now live inside the Sample List dock as the + # Auxiliary-puck tab — Ctrl+R raises the dock on that tab. self._shortcut_raise_reference_tools_list = QAction("Raise reference tools", self) self._shortcut_raise_reference_tools_list.setShortcut(QKeySequence("Ctrl+R")) - self._shortcut_raise_reference_tools_list.triggered.connect( - lambda: (self.ref_tools_dock.setVisible(True), self.ref_tools_dock.raise_()) - ) + self._shortcut_raise_reference_tools_list.triggered.connect(self._raise_reference_tools) self.addAction(self._shortcut_raise_reference_tools_list) + # The automation controls live under the Dewar samples tab now. self._shortcut_raise_job_list = QAction("Raise automation list", self) self._shortcut_raise_job_list.setShortcut(QKeySequence("Ctrl+J")) self._shortcut_raise_job_list.triggered.connect( - lambda: (self.job_list_dock.setVisible(True), self.job_list_dock.raise_()) + lambda: ( + self.tell_samples_dock.setVisible(True), + self.tell_samples_dock.raise_(), + self.sample_lists_tabs.setCurrentIndex(0), + ) ) self.addAction(self._shortcut_raise_job_list) @@ -952,6 +1145,139 @@ class MainWindow(QMainWindow): ) self.addAction(self._shortcut_console_log) + @Slot() + def _sync_queue_row_tints(self) -> None: + self.tell_samples.table_model.set_queued_ids( + sample.db_id for sample in self.job_list_panel.table_model.samples + ) + + @Slot() + def _remove_selected_from_queue(self) -> None: + self._unqueue_panel_selection(self.tell_samples) + + def _unqueue_panel_selection(self, panel: TellSamplePanel) -> None: + rows = panel.table_view.selectionModel().selectedRows() + db_ids = [panel.table_model.get_id(index.row()).db_id for index in rows] + if db_ids: + self.job_list_panel.remove_samples(db_ids) + + @Slot(int, bool, str) + def _mark_scan_result(self, db_id: int, success: bool, reply: str) -> None: + # Display only: a failed run tints the row pale red until a later + # success clears it. Auth errors are not the sample's fault — skip. + if reply == "Authentication Error": + return + self.tell_samples.table_model.set_flagged(db_id, not success) + + @Slot() + def _open_sample_popout(self) -> None: + if self._sample_popout is None: + # Fully operational duplicate of the Sample List tab: second panel + # instances SHARING the docked panels' models, wired to the same + # slots — mounts, queue edits, chips and filters all work here. + dewar_panel = TellSamplePanel(model=self.tell_samples.table_model) + aux_panel = ReferenceToolsPanel(model=self.ref_tools_panel.table_model) + dewar_panel.mount.connect(self._on_manual_mount_requested) + dewar_panel.unmount.connect(self._on_manual_unmount_requested) + dewar_panel.add_to_queue.connect( + lambda lst: self.job_list_panel.queue_samples(lst.s, replace=False) + ) + dewar_panel.remove_from_queue.connect( + lambda lst: self.job_list_panel.remove_samples([s.db_id for s in lst.s]) + ) + aux_panel.mount.connect(self._on_manual_mount_requested) + aux_panel.unmount.connect(self._on_manual_unmount_requested) + + # One shared filter state → keep the two chip rows visually in sync. + self.tell_samples.status_chips.buttonClicked.connect( + lambda chip: dewar_panel.set_status_chip(chip.property("status_key")) + ) + dewar_panel.status_chips.buttonClicked.connect( + lambda chip: self.tell_samples.set_status_chip(chip.property("status_key")) + ) + dewar_panel.set_status_chip(self.tell_samples.table_model.status_filter) + + dewar_tab = QWidget() + dewar_layout = QVBoxLayout(dewar_tab) + dewar_layout.setContentsMargins(0, 0, 0, 0) + dewar_layout.setSpacing(2) + dewar_layout.addWidget(dewar_panel) + dewar_layout.addLayout(self._clone_automation_row(dewar_panel)) + + tabs = QTabWidget() + tabs.addTab(dewar_tab, "Dewar samples") + tabs.addTab(aux_panel, "Auxiliary puck") + if not self._decoded_token.staff: + tabs.setTabEnabled(1, False) + tabs.setTabToolTip(1, "Staff only") + self._sample_popout = PopoutWindow("Sample List", tabs, parent=self) + self._sample_popout.resize(1200, 500) + self._sample_popout.show() + self._sample_popout.raise_() + self._sample_popout.activateWindow() + + def _open_automation_popout(self) -> None: + if self._automation_popout is None: + # Mirror wired to the same feeds as the docked panel. + panel = AutomationProgressWidget() + self.job_list_panel.samples_in_queue_changed.connect(panel.set_samples_in_queue) + self.job_list_panel.automation_running_changed.connect(panel.set_running) + self.daq.automation_progress.connect(panel.set_progress) + panel.set_samples_in_queue(len(self.job_list_panel.table_model.samples)) + panel.set_running(self.job_list_panel.is_running()) + self._automation_popout = PopoutWindow("Automation progress", panel, parent=self) + self._automation_popout.resize(420, 520) + self._automation_popout.show() + self._automation_popout.raise_() + self._automation_popout.activateWindow() + + def _clone_automation_row(self, dewar_panel: TellSamplePanel) -> QHBoxLayout: + """Pop-out copy of the automation controls, driving the same queue + engine. Run/Pause text and checkbox states stay mirrored; 'Remove + selected' acts on the pop-out's own table selection.""" + jl = self.job_list_panel + run_button = QPushButton(jl.play_button.text()) + run_button.clicked.connect(jl.run) + jl.automation_running_changed.connect( + lambda running: run_button.setText("⏸ Pause" if running else "▶ Run") + ) + remove_button = QPushButton("🗑 Remove selected") + remove_button.clicked.connect(lambda: self._unqueue_panel_selection(dewar_panel)) + clear_button = QPushButton("✖ Clear list") + clear_button.clicked.connect(jl.clear) + unmount_button = QPushButton("⏏ Unmount") + unmount_button.clicked.connect(lambda: self._on_manual_unmount_requested()) + + row = QHBoxLayout() + for button in (run_button, remove_button, clear_button, unmount_button): + row.addWidget(button) + for source in (jl.park_and_dry_when_cleared, jl.pause_on_conditions_cb): + clone = QCheckBox(source.text()) + clone.setToolTip(source.toolTip()) + clone.setChecked(source.isChecked()) + # setChecked with an unchanged value emits nothing, so the + # cross-connection cannot loop. + clone.toggled.connect(source.setChecked) + source.toggled.connect(clone.setChecked) + row.addWidget(clone) + return row + + def _show_reference_tools_staff_only_popup(self) -> None: + QMessageBox.information( + self, + "Staff only", + "The Auxiliary puck (reference tools) view is available to staff accounts only.", + ) + + @Slot() + def _raise_reference_tools(self) -> None: + if not self._decoded_token.staff: + self._show_reference_tools_staff_only_popup() + return + self.tell_samples_dock.setVisible(True) + self.tell_samples_dock.raise_() + self.sample_lists_tabs.setCurrentIndex(1) + def _return_to_main_view_for_shutdown(self) -> None: try: if getattr(self, "content_stack", None) is None: @@ -979,7 +1305,7 @@ class MainWindow(QMainWindow): compact_overlay_legend = settings.value("samcam/compact_overlay_legend", False, type=bool) target_color = settings.value("samcam/target_color", "Cyan", type=str) - self.beamline.samcam.apply_overlay_settings( + self.samcam.apply_overlay_settings( show_detections=show_detections, show_detection_polygons=show_detection_polygons, show_target_point=show_target_point, @@ -1112,12 +1438,10 @@ class MainWindow(QMainWindow): if not self._in_compact_automation_view: self._pre_automation_window_state = self.saveState() - self._pre_automation_ref_tools_visible = self.ref_tools_dock.isVisible() self._pre_automation_left_column_visible = self.collection_controls_scroll.isVisible() self._pre_automation_right_column_visible = self.beamline_controls_scroll.isVisible() self.tell_samples_dock.setVisible(False) - self.job_list_dock.setVisible(False) self.automation_progress_dock.setVisible(False) self.face_panel_dock.setVisible(False) self.fluor_panel_dock.setVisible(False) @@ -1126,9 +1450,6 @@ class MainWindow(QMainWindow): self.prediction_metrics_dock.setVisible(False) self.log_dock.setVisible(False) - if self._decoded_token.staff: - self.ref_tools_dock.setVisible(False) - self.collection_controls_scroll.setVisible(False) self.beamline_controls_scroll.setVisible(False) @@ -1146,15 +1467,11 @@ class MainWindow(QMainWindow): self.collection_controls_scroll.setVisible(self._pre_automation_left_column_visible) self.beamline_controls_scroll.setVisible(self._pre_automation_right_column_visible) - if self._decoded_token.staff: - self.ref_tools_dock.setVisible(self._pre_automation_ref_tools_visible) - self._in_compact_automation_view = False self._update_view_mode_actions() - self.job_list_dock.setVisible(True) self.tell_samples_dock.setVisible(True) - self.job_list_dock.raise_() + self.tell_samples_dock.raise_() @Slot() def _refresh_compact_queue_preview(self) -> None: @@ -1194,7 +1511,6 @@ class MainWindow(QMainWindow): # Hide all dock widgets for dock_attr in ( "tell_samples_dock", - "job_list_dock", "automation_progress_dock", "face_panel_dock", "fluor_panel_dock", @@ -1202,7 +1518,6 @@ class MainWindow(QMainWindow): "target_stability_dock", "prediction_metrics_dock", "log_dock", - "ref_tools_dock", ): dock = getattr(self, dock_attr, None) if dock is not None: @@ -1261,7 +1576,6 @@ class MainWindow(QMainWindow): self._pre_portrait_geometry = None self.tell_samples_dock.setVisible(True) - self.job_list_dock.setVisible(True) self.automation_progress_dock.setVisible(False) self.face_panel_dock.setVisible(False) self.fluor_panel_dock.setVisible(False) @@ -1435,25 +1749,6 @@ class MainWindow(QMainWindow): self.tell_samples_dock.visibilityChanged.connect(show_samples_action.setChecked) view_menu.addAction(show_samples_action) - if self._decoded_token.staff: - show_reference_tools_action = QAction("Show Reference Tools", self) - show_reference_tools_action.setCheckable(True) - show_reference_tools_action.setChecked(True) - show_reference_tools_action.triggered.connect( - lambda checked: self.ref_tools_dock.setVisible(checked) - ) - self.ref_tools_dock.visibilityChanged.connect(show_reference_tools_action.setChecked) - view_menu.addAction(show_reference_tools_action) - - show_job_list_action = QAction("Show job List", self) - show_job_list_action.setCheckable(True) - show_job_list_action.setChecked(True) - show_job_list_action.triggered.connect( - lambda checked: self.job_list_dock.setVisible(checked) - ) - self.job_list_dock.visibilityChanged.connect(show_job_list_action.setChecked) - view_menu.addAction(show_job_list_action) - show_face_panel_action = QAction("Show face detection", self) show_face_panel_action.setCheckable(True) show_face_panel_action.setChecked(False) @@ -1593,7 +1888,6 @@ class MainWindow(QMainWindow): self.beamline_controls_scroll.setVisible(True) self.tell_samples_dock.setVisible(True) - self.job_list_dock.setVisible(True) self.face_panel_dock.setVisible(False) self.fluor_panel_dock.setVisible(False) @@ -1602,12 +1896,7 @@ class MainWindow(QMainWindow): self.prediction_metrics_dock.setVisible(False) self.log_dock.setVisible(False) - if self._decoded_token.staff: - self.ref_tools_dock.setVisible(True) - self.ref_tools_dock.raise_() - else: - self.ref_tools_dock.setVisible(False) - self.tell_samples_dock.raise_() + self.tell_samples_dock.raise_() self.video_tab.setCurrentIndex(0) @@ -1998,9 +2287,51 @@ class MainWindow(QMainWindow): def sample_view(self): self.video_tab.setCurrentWidget(self.sample_camera) + def _apply_session_gate(self, session_state) -> None: + # No baton -> watching only: camera views stay live, every operating + # surface is greyed. The SESSION VACANT badge (and the status bar + # session menu) remain the way back in. + owned = session_state in ( + SessionsStateEnum.OwnedByYou, + SessionsStateEnum.PendingElseToYou, + ) + if owned == getattr(self, "_session_operations_enabled", None): + return + self._session_operations_enabled = owned + for widget in ( + self.left_column_tabs, + self.beamline, + self.tell_samples_dock.widget(), + ): + widget.setEnabled(owned) + if owned: + widget.setGraphicsEffect(None) + else: + # Full grayscale, banners included — QSS :disabled alone + # can't reach the custom-painted TitleLabels/inline styles. + effect = QGraphicsColorizeEffect(widget) + effect.setColor(QColor("#808080")) + widget.setGraphicsEffect(effect) + self.sample_camera.set_operations_enabled(owned) + + # Vacant folds every panel shut; grabbing reopens exactly the ones + # that were open before. Transient (persist=False) so the fold never + # overwrites the user's saved per-panel choices. + banners = self.left_column_tabs.findChildren(TitleLabel) + self.beamline.findChildren( + TitleLabel + ) + if owned: + for banner in getattr(self, "_pre_vacancy_open_banners", []): + banner.set_collapsed(False, persist=False) + else: + self._pre_vacancy_open_banners = [b for b in banners if not b.is_collapsed()] + for banner in banners: + banner.set_collapsed(True, persist=False) + @Slot(DAQStatusModel) def update_daq_status(self, s: DAQStatusModel): self._latest_daq_status = s + self._apply_session_gate(getattr(getattr(s, "session", None), "session", None)) if self._is_automation_active(): self._refresh_idle_activity(report_backend=False) @@ -2293,6 +2624,27 @@ class MainWindow(QMainWindow): self.state_manager.restore_window(self) self._restore_panel_visibility_settings() + def showEvent(self, event): + super().showEvent(event) + # First show only, and only without a saved layout: re-apply the + # default dock split AFTER the real (maximized) geometry exists — + # the __init__ resizeDocks ran on the pre-show size and Qt hands the + # scale-up surplus to the sample list, skewing 50/50 into ~80/20. + if not getattr(self, "_default_dock_split_done", False): + self._default_dock_split_done = True + if not self.state_manager.settings.value("main_window/state"): + QTimer.singleShot(0, self._apply_default_dock_split) + + def _apply_default_dock_split(self) -> None: + self.resizeDocks( + [self.tell_samples_dock, self.log_dock], [240, 240], Qt.Orientation.Vertical + ) + self.resizeDocks( + [self.tell_samples_dock, self.automation_progress_dock], + [10000, 10000], + Qt.Orientation.Horizontal, + ) + def closeEvent(self, event) -> None: try: self._return_to_main_view_for_shutdown() @@ -2464,6 +2816,16 @@ class MainWindow(QMainWindow): self._mark_user_interaction() except Exception as e: logger.debug(f"GUI interaction event filter error: {e}", exc_info=True) + # Non-staff click on the greyed-out Auxiliary-puck tab: only installed + # for non-staff, and tabAt() is geometric so it still sees the + # disabled tab — explain the lock instead of silently eating the click. + if ( + event.type() == QEvent.Type.MouseButtonPress + and obj is self.sample_lists_tabs.tabBar() + and obj.tabAt(event.position().toPoint()) == 1 + ): + self._show_reference_tools_staff_only_popup() + return True return super().eventFilter(obj, event) def _start_remote_close_countdown( diff --git a/src/aare/gui/models/user_sample_model.py b/src/aare/gui/models/user_sample_model.py index b57b3f37..c9e08e2e 100644 --- a/src/aare/gui/models/user_sample_model.py +++ b/src/aare/gui/models/user_sample_model.py @@ -6,34 +6,45 @@ from PySide6.QtCore import QAbstractTableModel, QMimeData, Qt from PySide6.QtGui import QBrush from aare.gui.constants import LOGGER_NAME -from aare.gui.styles import SAMPLE_ROW_HIGHLIGHT_BG, SAMPLE_ROW_QUEUED_BG, WHITE, qcolor +from aare.gui.styles import ( + SAMPLE_ROW_QUEUED_BG, + SAMPLE_STATUS_FLAGGED_BG, + SAMPLE_STATUS_MEASURED_BG, + SAMPLE_STATUS_QUEUED_BG, + qcolor, +) logger = setup_logger(LOGGER_NAME) +# Column 0 is display-only: the row position ("#") drawn over the status +# color fill. Data attributes start at column 1. +COL_STATUS = 0 + def get_entry(sample: SampleShortInfo, column: int): - if column == 0: + if column == 1: return sample.sample_name - elif column == 1: - return sample.puck_name elif column == 2: - return sample.dewar_name + return sample.puck_name elif column == 3: - return sample.loc_str() + return sample.dewar_name elif column == 4: - return sample.priority + return sample.loc_str() elif column == 5: - return sample.user + return sample.priority elif column == 6: - return sample.mount_count + return sample.user elif column == 7: - return sample.raster_count + return sample.mount_count elif column == 8: - return sample.rotation_count + return sample.raster_count elif column == 9: - return sample.screening_count + return sample.rotation_count elif column == 10: + return sample.screening_count + elif column == 11: return sample.comment + return "" class UserSampleSpreadsheet(QAbstractTableModel): @@ -49,6 +60,7 @@ class UserSampleSpreadsheet(QAbstractTableModel): samples = [] self.samples: list[SampleShortInfo] = samples self.header = [ + "#", "Sample name", "Puck", "Dewar", @@ -63,15 +75,23 @@ class UserSampleSpreadsheet(QAbstractTableModel): ] self.current_sample = current_sample self.current_puck = current_puck - self._sort_col = 3 + self._sort_col = 4 # Location self._sort_order = Qt.SortOrder.AscendingOrder self._filters: dict[int, str] = {} - self._filter_col: int | None = 5 + self._filter_col: int | None = 6 # User self._filter_value: str | None = None self.current_pgroup: str | None = None self.show_all_pgroups: bool = False + # Display-only status tints (aaregui2 concept: the dewar table doubles + # as the queue view). Fed from outside; the queue itself stays in the + # SampleQueueSpreadsheet. + self.queued_ids: set[int] = set() + self.flagged_ids: set[int] = set() + # None = All; otherwise "queued" | "flagged" | "measured" (chip row). + self.status_filter: str | None = None + self._sort() def to_list(self) -> list[dict]: @@ -90,17 +110,88 @@ class UserSampleSpreadsheet(QAbstractTableModel): def data(self, index, role=None): if role == Qt.ItemDataRole.DisplayRole: + if index.column() == COL_STATUS: + return index.row() + 1 return get_entry(self._sorted_samples[index.row()], index.column()) + elif role == Qt.ItemDataRole.BackgroundRole: + # Status lives in the "#" column, as a full cell fill under the + # row number — rows themselves alternate grey/white (view-level) + # and selection stays the pale blue tint. + if index.column() == COL_STATUS: + color = self._status_color(self._sorted_samples[index.row()]) + if color is not None: + return QBrush(qcolor(color)) elif role == Qt.ItemDataRole.TextAlignmentRole: # Align text to center return Qt.AlignmentFlag.AlignCenter - elif role == Qt.ItemDataRole.BackgroundRole: - if self._sorted_samples[index.row()].db_id == self.current_sample: - return QBrush(qcolor(SAMPLE_ROW_QUEUED_BG)) - if self._sorted_samples[index.row()].puck_name == self.current_puck: - return QBrush(qcolor(SAMPLE_ROW_HIGHLIGHT_BG)) - return QBrush(qcolor(WHITE)) return None # For other roles, return None + def _status_color(self, sample: SampleShortInfo) -> str | None: + # Mounted always wins; below that the dot depends on the active chip: + # inside a filtered view every row carries that status, so its own + # color is redundant — only cross-status marks show (queued view: + # red = also flagged; flagged view: orange = put back in the queue). + # The All view keeps the full priority queued > flagged > measured. + if sample.db_id == self.current_sample: + return SAMPLE_ROW_QUEUED_BG + queued = sample.db_id in self.queued_ids + flagged = sample.db_id in self.flagged_ids + if self.status_filter == "queued": + return SAMPLE_STATUS_FLAGGED_BG if flagged else None + if self.status_filter == "flagged": + return SAMPLE_STATUS_QUEUED_BG if queued else None + if self.status_filter == "measured": + if queued: + return SAMPLE_STATUS_QUEUED_BG + return SAMPLE_STATUS_FLAGGED_BG if flagged else None + if queued: + return SAMPLE_STATUS_QUEUED_BG + if flagged: + return SAMPLE_STATUS_FLAGGED_BG + if self._measured(sample): + return SAMPLE_STATUS_MEASURED_BG + return None + + @staticmethod + def _measured(sample: SampleShortInfo) -> bool: + # Automatic status, never relabelled by hand: a sample counts as + # measured once its rotation count exceeds 1. + return isinstance(sample.rotation_count, (int, float)) and sample.rotation_count > 1 + + def set_queued_ids(self, db_ids) -> None: + self.queued_ids = set(db_ids) + self._status_sets_changed() + + def set_flagged(self, db_id: int, flagged: bool) -> None: + if flagged: + self.flagged_ids.add(db_id) + else: + self.flagged_ids.discard(db_id) + self._status_sets_changed() + + def set_status_filter(self, status: str | None) -> None: + self.layoutAboutToBeChanged.emit() + self.status_filter = status + self._sort() + self.layoutChanged.emit() + + def _status_sets_changed(self) -> None: + # With a status chip active the row SET depends on the sets, not just + # the tint — refilter; otherwise a background repaint is enough. + if self.status_filter: + self.layoutAboutToBeChanged.emit() + self._sort() + self.layoutChanged.emit() + else: + self._emit_tints_changed() + + def _emit_tints_changed(self) -> None: + if self.rowCount() > 0: + self.dataChanged.emit( + self.index(0, COL_STATUS), + self.index(self.rowCount() - 1, COL_STATUS), + [Qt.ItemDataRole.BackgroundRole], + ) + def headerData(self, section, orientation, role=None): if role == Qt.ItemDataRole.DisplayRole: if orientation == Qt.Orientation.Horizontal: # Column header @@ -114,6 +205,7 @@ class UserSampleSpreadsheet(QAbstractTableModel): ): self.current_puck = current_puck self.current_sample = current_sample + self._emit_tints_changed() def updateData(self, samples: list[SampleShortInfo]): if samples != self.samples: @@ -123,6 +215,9 @@ class UserSampleSpreadsheet(QAbstractTableModel): self.endResetModel() def sort(self, column, order): + # The "#"/status column is display-only — nothing to sort by. + if column == COL_STATUS: + return self.layoutAboutToBeChanged.emit() self._sort_order = order self._sort_col = column @@ -131,7 +226,7 @@ class UserSampleSpreadsheet(QAbstractTableModel): def _sort(self): filtered = self._apply_filter(self.samples) - if self._sort_col == 3: + if self._sort_col == 4: # Location self._sorted_samples = sorted( filtered, key=lambda row: row.loc_str_sort(), @@ -150,6 +245,14 @@ class UserSampleSpreadsheet(QAbstractTableModel): ) def _apply_filter(self, rows: list[SampleShortInfo]) -> list[SampleShortInfo]: + # Status chip filter first (All/Queued/Flagged/Measured row). + if self.status_filter == "queued": + rows = [r for r in rows if r.db_id in self.queued_ids] + elif self.status_filter == "flagged": + rows = [r for r in rows if r.db_id in self.flagged_ids] + elif self.status_filter == "measured": + rows = [r for r in rows if self._measured(r)] + # Default filter by User using current p-group if no explicit filter set filters: dict[int, str] = { col: v for col, v in (self._filters or {}).items() if (v or "").strip() @@ -158,8 +261,8 @@ class UserSampleSpreadsheet(QAbstractTableModel): if self._filter_col is not None and (self._filter_value or "").strip(): filters[self._filter_col] = self._filter_value - if 5 not in filters and self.current_pgroup and not self.show_all_pgroups: - filters[5] = self.current_pgroup + if 6 not in filters and self.current_pgroup and not self.show_all_pgroups: + filters[6] = self.current_pgroup # User if not filters: return rows @@ -271,7 +374,7 @@ class UserSampleSpreadsheet(QAbstractTableModel): break # Sort appropriately - if column == 5: # User/pgroup column + if column == 6: # User/pgroup column try: out.sort( key=lambda x: ( @@ -290,10 +393,10 @@ class UserSampleSpreadsheet(QAbstractTableModel): def suggested_prefixes_for_sample_name(self, limit: int = 200) -> list[str]: """Get sample name prefixes from currently filtered samples (excluding column 0 filter).""" # Get currently filtered samples, excluding the sample name filter - temp_filter = self._filters.pop(0, None) + temp_filter = self._filters.pop(1, None) filtered_samples = self._apply_filter(self.samples) if temp_filter is not None: - self._filters[0] = temp_filter + self._filters[1] = temp_filter rx = re.compile(r"^([A-Za-z]+)") counts: dict[str, int] = {} @@ -314,10 +417,10 @@ class UserSampleSpreadsheet(QAbstractTableModel): def suggested_prefixes_for_location(self, limit: int = 200) -> tuple[list[str], list[str]]: """Get location prefixes from currently filtered samples (excluding column 3 filter).""" # Get currently filtered samples, excluding the location filter - temp_filter = self._filters.pop(3, None) + temp_filter = self._filters.pop(4, None) filtered_samples = self._apply_filter(self.samples) if temp_filter is not None: - self._filters[3] = temp_filter + self._filters[4] = temp_filter seg_seen: set[str] = set() segpos_seen: set[str] = set() diff --git a/src/aare/gui/panels/beamline_controls.py b/src/aare/gui/panels/beamline_controls.py index f98070de..7266cd50 100644 --- a/src/aare/gui/panels/beamline_controls.py +++ b/src/aare/gui/panels/beamline_controls.py @@ -1,13 +1,10 @@ from PySide6.QtWidgets import QFrame, QVBoxLayout, QWidget -from aare.gui.panels.abr_tweak_panel import AbrTweakWidget from aare.gui.panels.beam_center_panel import BeamCenterWidget from aare.gui.panels.beam_mark_panel import BeamMarkWidget from aare.gui.panels.beam_size_panel import BeamSizeWidget from aare.gui.panels.illumination_panel import IlluminationPanel -from aare.gui.panels.monochromator_panel import MonochromatorPanel 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 TitleLabel, tighten_column @@ -23,7 +20,7 @@ class BeamConfigPanel(QWidget): # panels' banners in the column. layout = QVBoxLayout(self) layout.setSpacing(0) - layout.addWidget(TitleLabel("Beam Config.", self, collapsible=True)) + layout.addWidget(TitleLabel("Beam configuration", self, collapsible=True, default_collapsed=False)) self.beam_mark = BeamMarkWidget(self) self.beam_center = BeamCenterWidget(self) self.beam_size = BeamSizeWidget(self) @@ -39,13 +36,15 @@ class BeamConfigPanel(QWidget): class BeamlineControls(QFrame): set_width = 250 - def __init__(self, parent=None, staff: bool = True): + def __init__(self, parent=None): super().__init__(parent) self.setObjectName("beamlineControls") self.setFixedWidth(self.set_width) self.setFrameShape(QFrame.Shape.StyledPanel) self.setFrameShadow(QFrame.Shadow.Raised) + # Samcam / monochromator / ABR / beam config moved to the left + # column's "Beamline" group (main_window builds it). self.v_layout = QVBoxLayout(self) self.zoom_panel = ZoomPanel(self) self.v_layout.addWidget(self.zoom_panel) @@ -59,22 +58,6 @@ class BeamlineControls(QFrame): self.smargon_panel = SmargonPanel(parent=self) self.v_layout.addWidget(self.smargon_panel) - self.samcam = SamcamPanel(self) - self.v_layout.addWidget(self.samcam) - - if staff: - self.monochromator_panel = MonochromatorPanel(self) - self.abr_tweak = AbrTweakWidget(self) - self.beam_config = BeamConfigPanel(self) - # Aliases: main_window wires signals via beamline.beam_mark etc. - self.beam_mark = self.beam_config.beam_mark - self.beam_center = self.beam_config.beam_center - self.beam_size = self.beam_config.beam_size - - self.v_layout.addWidget(self.monochromator_panel) - self.v_layout.addWidget(self.abr_tweak) - self.v_layout.addWidget(self.beam_config) - self.v_layout.addStretch() tighten_column(self.v_layout) self.setLayout(self.v_layout) diff --git a/src/aare/gui/panels/reference_tools_panel.py b/src/aare/gui/panels/reference_tools_panel.py index d056235a..0c22e4fc 100644 --- a/src/aare/gui/panels/reference_tools_panel.py +++ b/src/aare/gui/panels/reference_tools_panel.py @@ -9,9 +9,7 @@ from PySide6.QtWidgets import ( QFrame, QGridLayout, QHeaderView, - QLabel, QMenu, - QPushButton, QTableView, ) @@ -174,11 +172,13 @@ class ReferenceToolsPanel(QFrame): samples: SampleShortInfoList | None = None, parent=None, refresh_interval_ms: int = 5000, + model: ReferenceToolsModel | None = None, ): """ :param samples: optional initial SampleShortInfoList to populate the table :param parent: Qt parent :param refresh_interval_ms: how often to call request_refresh (panel doesn't implement the request itself) + :param model: share an existing model instead of owning one (pop-out window) """ super().__init__(parent) @@ -190,26 +190,21 @@ class ReferenceToolsPanel(QFrame): layout = QGridLayout(self) self.setLayout(layout) + # Flush layout, matching TellSamplePanel: full-width banner, no + # padding ring, no gap to the dock's tab row. + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) layout.addWidget(TitleLabel("Reference tools", parent=self), 0, 0, 1, 4) self.table_view = QTableView(parent=self) + # Row colors carry the separation — no grid lines. + self.table_view.setShowGrid(False) layout.addWidget(self.table_view, 1, 0, 1, 4) - self.curr_sample_label = QLabel("No sample mounted", parent=self) - layout.addWidget(self.curr_sample_label, 2, 0) - - self.unmount_button = QPushButton("Unmount", parent=self) - layout.addWidget(self.unmount_button, 2, 1) - self.unmount_button.clicked.connect(self._on_unmount_clicked) - - layout.setColumnStretch(0, 1) - layout.setColumnStretch(1, 0) - - # initialize model with provided samples - self.table_model = ReferenceToolsModel(rows=samples.s) + # initialize model with provided samples (or adopt the shared one) + self.table_model = model if model is not None else ReferenceToolsModel(rows=samples.s) self.table_view.setModel(self.table_model) - self.table_view.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) self.table_view.setEditTriggers(QTableView.EditTrigger.NoEditTriggers) logger.debug("Setting up table header") @@ -218,8 +213,13 @@ class ReferenceToolsPanel(QFrame): header.setSectionResizeMode(QHeaderView.ResizeMode.Interactive) logger.debug("Setting up table header") header.setStretchLastSection(True) + # No bold column titles when cells are selected. + header.setHighlightSections(False) self.table_view.verticalHeader().setVisible(True) logger.debug("Setting up table view sorting") + # Adopt the model's current order first — a second panel on a shared + # model must not re-sort it on open. + header.setSortIndicator(self.table_model._sort_col, self.table_model._sort_order) self.table_view.setSortingEnabled(True) logger.debug("Setting up table view context menu") self.table_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) @@ -227,15 +227,18 @@ class ReferenceToolsPanel(QFrame): self.table_view.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.table_view.setSelectionMode(QTableView.SelectionMode.SingleSelection) + # Columns at full content width, sized once (see TellSamplePanel). + self._columns_autosized = False + if self.table_model.rowCount() > 0: + self.table_view.resizeColumnsToContents() + self._columns_autosized = True + def _selected_item(self) -> SampleShortInfo | None: idx = self.table_view.currentIndex() if not idx.isValid(): return None return self.table_model.get_item(idx.row()) - def _on_unmount_clicked(self): - self.unmount.emit() - def _context_menu(self, position): idx = self.table_view.indexAt(position) @@ -263,22 +266,13 @@ class ReferenceToolsPanel(QFrame): def new_list(self, samples: SampleShortInfoList): # signal from DAQWorker will call this with the model self.table_model.update_rows(rows=samples.s) + if not self._columns_autosized and self.table_model.rowCount() > 0: + self.table_view.resizeColumnsToContents() + self._columns_autosized = True @Slot(DAQStatusModel) def update_daq_status(self, status: DAQStatusModel): + # The "No sample mounted" label and Unmount button were dropped — + # mounted state now shows as the existing row highlight instead. sample = status.sample - if sample is None: - self.curr_sample_label.setText("No sample mounted") - else: - try: - if sample.location is None: - self.curr_sample_label.setText( - f"Current sample: {sample.sample_name} (Manual mount)" - ) - else: - self.curr_sample_label.setText( - f"Current sample: {sample.sample_name} ({sample.location.segment}{sample.location.pos}-{sample.pin})" - ) - except Exception as e: - logger.debug("Could not update the current sample label", exc_info=True) - self.curr_sample_label.setText(f"Confusing information :/ {e}") + self.table_model.update_current_reference(sample.db_id if sample is not None else None) diff --git a/src/aare/gui/panels/sample_queue_panel.py b/src/aare/gui/panels/sample_queue_panel.py index a7de0c68..13eeedd7 100644 --- a/src/aare/gui/panels/sample_queue_panel.py +++ b/src/aare/gui/panels/sample_queue_panel.py @@ -194,6 +194,13 @@ class SampleQueuePanel(QFrame): self._emit_samples_in_queue_changed() + def remove_samples(self, db_ids) -> None: + """Remove the given samples from the queue. Public entry point for the + combined dewar view, whose selection lives outside this panel.""" + for db_id in db_ids: + self.table_model.remove_sample(db_id) + self._emit_samples_in_queue_changed() + def remove_selected_samples(self): selected_indexes = self.table_view.selectionModel().selectedRows() if not selected_indexes: diff --git a/src/aare/gui/panels/tell_sample_panel.py b/src/aare/gui/panels/tell_sample_panel.py index 81b2d555..b085a098 100644 --- a/src/aare/gui/panels/tell_sample_panel.py +++ b/src/aare/gui/panels/tell_sample_panel.py @@ -8,30 +8,133 @@ from aarecommon.models.models import ( from PySide6.QtCore import Qt, Signal, Slot from PySide6.QtWidgets import ( QAbstractItemView, + QButtonGroup, QFrame, QGridLayout, + QHBoxLayout, QHeaderView, - QLabel, QMenu, QPushButton, QTableView, ) from aare.gui.constants import LOGGER_NAME -from aare.gui.models.user_sample_model import UserSampleSpreadsheet -from aare.gui.styles import NOTE_TEXT +from aare.gui.models.user_sample_model import COL_STATUS, UserSampleSpreadsheet +from aare.gui.styles import ( + CHIP_NEUTRAL_BG, + MUTED_TEXT, + SAMPLE_STATUS_FLAGGED_BG, + SAMPLE_STATUS_MEASURED_BG, + SAMPLE_STATUS_QUEUED_BG, + TAB_FACE_BG, + TEXT, +) from aare.gui.widgets.title_label import TitleLabel logger = setup_logger(LOGGER_NAME) +class FrozenColumnTableView(QTableView): + """QTableView with the "#"/status column frozen (Qt frozen-column + pattern): an overlay view shares the model and selection, sits on top of + column 0, and stays put while the rest scrolls horizontally.""" + + FROZEN_WIDTH = 36 + + def __init__(self, parent=None): + super().__init__(parent) + self.frozen = QTableView(self) + self.frozen.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.frozen.verticalHeader().hide() + self.frozen.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.frozen.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.frozen.setShowGrid(False) + self.frozen.setAlternatingRowColors(True) + self.frozen.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.frozen.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.frozen.setEditTriggers(QTableView.EditTrigger.NoEditTriggers) + self.frozen.setDragEnabled(True) + self.frozen.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Fixed) + self.frozen.horizontalHeader().setHighlightSections(False) + # The overlay must not carry the L3 frame — it sits INSIDE the view. + self.frozen.setStyleSheet("QTableView { border: none; }") + self.viewport().stackUnder(self.frozen) + self.frozen.verticalScrollBar().valueChanged.connect(self.verticalScrollBar().setValue) + self.verticalScrollBar().valueChanged.connect(self.frozen.verticalScrollBar().setValue) + + def setModel(self, model): + super().setModel(model) + self.frozen.setModel(model) + # Share the selection so clicking either view highlights both. + self.frozen.setSelectionModel(self.selectionModel()) + for col in range(1, model.columnCount()): + self.frozen.setColumnHidden(col, True) + self.set_frozen_width(self.FROZEN_WIDTH) + self.frozen.show() + + def set_frozen_width(self, width: int) -> None: + self.setColumnWidth(0, width) + self.frozen.setColumnWidth(0, width) + self._update_frozen_geometry() + + def _update_frozen_geometry(self) -> None: + self.frozen.setGeometry( + self.frameWidth(), + self.frameWidth(), + self.columnWidth(0), + self.viewport().height() + self.horizontalHeader().height(), + ) + + def resizeEvent(self, event): + super().resizeEvent(event) + self._update_frozen_geometry() + + +class QueueDropChip(QPushButton): + """Filter chip that doubles as a drop target: dragging table rows onto it + relabels them (Queued adds to the automation queue, Flagged marks them + flagged — same mime the old queue dock took). Measured is deliberately + NOT one of these: it is automatic, from the rotation count.""" + + samples_dropped = Signal(SampleShortInfoList) + + def __init__(self, label: str, parent=None): + super().__init__(label, parent) + self.setAcceptDrops(True) + + def dragEnterEvent(self, event): + if event.mimeData().hasText(): + event.acceptProposedAction() + + def dropEvent(self, event): + try: + samples = SampleShortInfoList.model_validate_json(event.mimeData().text()) + except Exception: + logger.debug("Ignoring drop that is not a sample list", exc_info=True) + return + self.samples_dropped.emit(samples) + event.acceptProposedAction() + + class TellSamplePanel(QFrame): mount = Signal(SampleShortInfo) unmount = Signal() state = Signal(BeamlineStateEnum) + # Queue membership is edited from this table now (aaregui2 concept: the + # dewar list doubles as the queue view); the queue itself lives in the + # SampleQueuePanel these signals are wired to. + add_to_queue = Signal(SampleShortInfoList) + remove_from_queue = Signal(SampleShortInfoList) - def __init__(self, samples: SampleShortInfoList | None = None, parent=None): - + def __init__( + self, + samples: SampleShortInfoList | None = None, + parent=None, + model: UserSampleSpreadsheet | None = None, + ): + """`model`: share an existing spreadsheet model instead of owning one — + used by the pop-out window so both panels operate on the same data, + tints and filters with no syncing.""" super().__init__(parent) if samples is None: @@ -41,28 +144,86 @@ class TellSamplePanel(QFrame): grid_layout = QGridLayout(self) self.setLayout(grid_layout) + # Flush layout: the banner spans the full panel width and sits + # directly under the dock's tab row — no padding ring. + grid_layout.setContentsMargins(0, 0, 0, 0) + grid_layout.setSpacing(0) grid_layout.addWidget(TitleLabel("TELL sample changer", self), 0, 0, 1, 4) - self.table_view = QTableView() - grid_layout.addWidget(self.table_view, 1, 0, 1, 4) + # Status filter row (aaregui2 concept): filters the table by queue/ + # collection status, and each checked button wears its row-tint color, + # doubling as the legend. Styled like the Dewar/Auxiliary tab row + # above (square tabs, touching), not pills. Colors live in styles.py. + chip_row = QHBoxLayout() + # Left margin 0: "All" shares the table's left edge; bottom 0: the + # row sits directly on the table. + chip_row.setContentsMargins(0, 2, 6, 0) + chip_row.setSpacing(0) + self.status_chips = QButtonGroup(self) + self.status_chips.setExclusive(True) + for label, key, checked_bg in ( + ("All", None, CHIP_NEUTRAL_BG), + ("Queued", "queued", SAMPLE_STATUS_QUEUED_BG), + ("Flagged", "flagged", SAMPLE_STATUS_FLAGGED_BG), + ("Measured", "measured", SAMPLE_STATUS_MEASURED_BG), + ): + if key == "queued": + chip = QueueDropChip(label, self) + chip.samples_dropped.connect(self.add_to_queue) + # Deselect after the drop: the selection tint would otherwise + # sit on top of the fresh status color and hide it. + chip.samples_dropped.connect(lambda _: self.table_view.clearSelection()) + chip.setToolTip("Filter queued samples — or drop table rows here to queue them") + elif key == "flagged": + chip = QueueDropChip(label, self) + chip.samples_dropped.connect(self._flag_dropped_samples) + chip.samples_dropped.connect(lambda _: self.table_view.clearSelection()) + chip.setToolTip("Filter flagged samples — or drop table rows here to flag them") + else: + chip = QPushButton(label, self) + if key == "measured": + chip.setToolTip("Filter measured samples (automatic: rotation count > 1)") + chip.setCheckable(True) + chip.setChecked(key is None) + chip.setProperty("status_key", key) + chip.setCursor(Qt.CursorShape.PointingHandCursor) + chip.setStyleSheet( + # Same font, padding and hover hint as the QTabBar tabs above + # (no bold, default size): unchecked tabs sit 3px lower + # (raised-selection effect), the checked one wears its + # row-tint fill, hover underlines just the text. + f"QPushButton {{ background: {TAB_FACE_BG}; color: {MUTED_TEXT};" + f" border: none;" + f" border-top-left-radius: 4px; border-top-right-radius: 4px;" + f" margin-top: 3px; padding: 4px 14px; }}" + f"QPushButton:hover:!checked {{ color: {TEXT}; text-decoration: underline; }}" + f"QPushButton:checked {{ background: {checked_bg}; color: {TEXT};" + f" margin-top: 0px; padding: 6px 14px 5px 14px; }}" + ) + self.status_chips.addButton(chip) + chip_row.addWidget(chip) + chip_row.addStretch() + grid_layout.addLayout(chip_row, 1, 0, 1, 4) + self.status_chips.buttonClicked.connect( + lambda chip: self.table_model.set_status_filter(chip.property("status_key")) + ) - self.curr_sample_label = QLabel("No sample mounted", parent=self) - self.curr_sample_label.setTextFormat(Qt.TextFormat.RichText) - self.curr_sample_label.setWordWrap(True) - grid_layout.addWidget(self.curr_sample_label, 2, 0) + # Staggered grey/white rows tell rows apart — no grid lines, no row + # tints; status fills the frozen "#" column and selection stays blue. + self.table_view = FrozenColumnTableView() + self.table_view.setShowGrid(False) + self.table_view.setAlternatingRowColors(True) + grid_layout.addWidget(self.table_view, 2, 0, 1, 4) - self.unmount_button = QPushButton("Unmount", parent=self) - grid_layout.addWidget(self.unmount_button, 2, 1) - self.unmount_button.clicked.connect(self.unmount_button_clicked) - - grid_layout.setColumnStretch(0, 1) - grid_layout.setColumnStretch(1, 0) - - self.table_model = UserSampleSpreadsheet(samples=samples.s) + self.table_model = model if model is not None else UserSampleSpreadsheet(samples=samples.s) self.table_view.setModel(self.table_model) - self.table_view.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + # Adopt the model's current order before enabling sorting: a second + # panel on a shared model must not re-sort it to column 0 on open. + self.table_view.horizontalHeader().setSortIndicator( + self.table_model._sort_col, self.table_model._sort_order + ) self.table_view.setSortingEnabled(True) self.table_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.table_view.customContextMenuRequested.connect(self.context_menu) @@ -74,17 +235,44 @@ class TellSamplePanel(QFrame): header = self.table_view.horizontalHeader() header.setSectionResizeMode(QHeaderView.ResizeMode.Interactive) header.setStretchLastSection(True) - self.table_view.verticalHeader().setVisible(True) + # No bold column titles when cells are selected. + header.setHighlightSections(False) + # Row numbers + status color live in the frozen "#" column: fixed + # width, not resizable, stays visible on horizontal scroll. + self.table_view.verticalHeader().setVisible(False) + header.setSectionResizeMode(COL_STATUS, QHeaderView.ResizeMode.Fixed) + self.table_view.set_frozen_width(FrozenColumnTableView.FROZEN_WIDTH) + # Right-click on the frozen column behaves like the main table (the + # handler only uses the row, which both views share). + self.table_view.frozen.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.table_view.frozen.customContextMenuRequested.connect(self.context_menu) header.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) header.customContextMenuRequested.connect(self.header_context_menu) - def unmount_button_clicked(self): - self.unmount.emit() + # Columns at full content width (horizontal scroll instead of + # squishing); done ONCE so later data refreshes don't fight manual + # column adjustments. + self._columns_autosized = False + if self.table_model.rowCount() > 0: + self._autosize_columns() + + def _autosize_columns(self) -> None: + self.table_view.resizeColumnsToContents() + header = self.table_view.horizontalHeader() + # Cap: one long comment must not eat the whole view. + for col in range(1, self.table_model.columnCount()): + if header.sectionSize(col) > 300: + self.table_view.setColumnWidth(col, 300) + # Re-pin the frozen display column after the autosize pass. + self.table_view.set_frozen_width(FrozenColumnTableView.FROZEN_WIDTH) + self._columns_autosized = True @Slot(SampleShortInfoList) def new_sample_list(self, samples: SampleShortInfoList): self.table_model.updateData(samples=samples.s) + if not self._columns_autosized and self.table_model.rowCount() > 0: + self._autosize_columns() def annotate_sample_comment(self, db_id: int, comment: str) -> None: samples = list(self.table_model.samples) @@ -98,6 +286,29 @@ class TellSamplePanel(QFrame): self.table_model.updateData(samples=updated_samples) + @Slot(SampleShortInfoList) + def _flag_dropped_samples(self, samples: SampleShortInfoList) -> None: + # Flagged is display state owned by the (shared) model, so relabeling + # here reaches the docked panel and the pop-out alike. + for sample in samples.s: + self.table_model.set_flagged(sample.db_id, True) + + def set_status_chip(self, key: str | None) -> None: + """Check the chip for `key` without firing its filter — keeps the + main and pop-out chip rows in sync (both drive one shared model).""" + for chip in self.status_chips.buttons(): + if chip.property("status_key") == key: + chip.setChecked(True) + return + + def _selected_samples(self, clicked_row: int) -> list[SampleShortInfo]: + """Selected rows if the clicked row is part of the selection, else + just the clicked row — so right-click on an unselected row acts on it.""" + rows = sorted({i.row() for i in self.table_view.selectionModel().selectedRows()}) + if clicked_row not in rows: + rows = [clicked_row] + return [self.table_model.get_id(r) for r in rows] + def context_menu(self, position): index = self.table_view.indexAt(position) if not index.isValid(): @@ -109,18 +320,35 @@ class TellSamplePanel(QFrame): if sample.location is None: return + selected = [s for s in self._selected_samples(row) if s.location is not None] + menu = QMenu() + count = f" ({len(selected)})" if len(selected) > 1 else "" + add_queue_action = menu.addAction(f"Add to queue{count}") + remove_queue_action = menu.addAction(f"Remove from queue{count}") + menu.addSeparator() mount_action = menu.addAction("Mount") + # Unmount moved here from the removed bottom-row button — same signal. + unmount_action = menu.addAction("Unmount") action = menu.exec_(self.table_view.viewport().mapToGlobal(position)) if action == mount_action: self.mount.emit(sample) + elif action == unmount_action: + self.unmount.emit() + elif action == add_queue_action: + self.add_to_queue.emit(SampleShortInfoList(s=selected)) + self.table_view.clearSelection() + elif action == remove_queue_action: + self.remove_from_queue.emit(SampleShortInfoList(s=selected)) + self.table_view.clearSelection() def header_context_menu(self, pos): header = self.table_view.horizontalHeader() logical_index = header.logicalIndexAt(pos) - if logical_index < 0: + # The "#"/status column is display-only — no filter menus there. + if logical_index < 1: return col_name = self.table_model.header[logical_index] @@ -128,7 +356,7 @@ class TellSamplePanel(QFrame): menu = QMenu(self) # Column-specific preset submenus (existing logic) ... - if logical_index == 0: + if logical_index == 1: # Sample name presets = self.table_model.suggested_prefixes_for_sample_name() if presets: prefix_menu = menu.addMenu("Filter by name prefix") @@ -139,7 +367,7 @@ class TellSamplePanel(QFrame): logical_index, vv ) ) - elif logical_index == 3: + elif logical_index == 4: # Location segs, segpos = self.table_model.suggested_prefixes_for_location() if segs: seg_menu = menu.addMenu("Filter by segment (A..F,X,R)") @@ -176,7 +404,7 @@ class TellSamplePanel(QFrame): clear_filter_action = menu.addAction(f"Clear filter: {col_name}") clear_all_action = menu.addAction("Clear all filters") - if logical_index == 5: + if logical_index == 6: # User menu.addSeparator() toggle_all = menu.addAction("Show all pgroups (ignore current p-group)") toggle_all.setCheckable(True) @@ -204,48 +432,16 @@ class TellSamplePanel(QFrame): @Slot(DAQStatusModel) def update_daq_status(self, status: DAQStatusModel): + # Mounted state shows as the row highlight only — the "No sample + # mounted" label and Unmount button were dropped; TELL activity text + # lives in the beamline state panel already. sample = status.sample - tell_state = status.tell_state - - tell_details = "" - if tell_state is not None: - activity = tell_state.activity.display_name() - phase = tell_state.phase.display_name() if tell_state.phase is not None else "" - message = (tell_state.message or "").strip() - - tell_parts = [activity] - if phase: - tell_parts.append(phase) - - tell_details = " / ".join(tell_parts) - if message: - tell_details = f"{tell_details} — {message}" - if sample is None: - base_text = "No sample mounted" self.table_model.updateCurrentSample(current_puck=None, current_sample=None) else: - try: - if sample.location is None: - base_text = f"Current sample: {sample.sample_name} (Manual mount)" - else: - base_text = ( - f"Current sample: {sample.sample_name} " - f"({sample.location.segment}{sample.location.pos}-{sample.pin})" - ) - self.table_model.updateCurrentSample( - current_puck=sample.puck_name, current_sample=sample.db_id - ) - except Exception as e: - logger.debug("Could not build the TELL sample panel text", exc_info=True) - base_text = f"Confusing information :/ {e}" - - if tell_details: - self.curr_sample_label.setText( - f"{base_text}
TELL: {tell_details}" + self.table_model.updateCurrentSample( + current_puck=sample.puck_name, current_sample=sample.db_id ) - else: - self.curr_sample_label.setText(base_text) if status.session.current_pgroup is not None: self._current_pgroup = status.session.current_pgroup -- 2.54.0 From 0b80c75c63d58d03666c354744c4074e8d1952c6 Mon Sep 17 00:00:00 2001 From: Dawn Date: Fri, 7 Aug 2026 17:11:06 +0200 Subject: [PATCH 26/57] test: track the sample-table column shift, sort imports The frozen #+status column shifted data columns +1, so the User-column model tests move from index 5 to 6; ruff import-sort fixes for main_window and log_panel ride along. Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 6 +++--- src/aare/gui/panels/log_panel.py | 2 +- tests/unit/gui/test_models.py | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 91aecd36..1e2249bd 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -41,9 +41,9 @@ from aare.gui.constants import LOGGER_NAME # Gui Models from aare.gui.models.gui_state_manager import UIStateManager +from aare.gui.panels.abr_tweak_panel import AbrTweakWidget from aare.gui.panels.automation_panel import AutomationProgressWidget from aare.gui.panels.axis_video_panel import AxisVideoPanel -from aare.gui.panels.abr_tweak_panel import AbrTweakWidget from aare.gui.panels.beamline_controls import BeamConfigPanel, BeamlineControls from aare.gui.panels.beamline_recovery_panel import BeamlineRecoveryDialog from aare.gui.panels.beamline_state_panel import BeamlineStatePanel @@ -84,8 +84,6 @@ from aare.gui.threads.daq_worker import DAQWorker from aare.gui.threads.jfjoch_viewer import JFJochDBusClient from aare.gui.threads.prediction_subscriber import PredictionSubscriber from aare.gui.tutorials.controls_help_dialog import ControlsHelpDialog -from aare.gui.widgets.popout_window import DockTitleBar, PopoutWindow -from aare.gui.widgets.wheel_value_guard import WheelValueGuard # Tutorials from aare.gui.tutorials.tutorial_actions import TutorialActionExecutor @@ -102,9 +100,11 @@ 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.no_wheel_scroll_area import NoWheelScrollArea +from aare.gui.widgets.popout_window import DockTitleBar, PopoutWindow from aare.gui.widgets.status_bar import StatusBar from aare.gui.widgets.title_label import TitleLabel, tighten_column from aare.gui.widgets.video_image import VideoGraphicsView +from aare.gui.widgets.wheel_value_guard import WheelValueGuard logger = setup_logger(LOGGER_NAME) diff --git a/src/aare/gui/panels/log_panel.py b/src/aare/gui/panels/log_panel.py index 686033b2..fcb56443 100644 --- a/src/aare/gui/panels/log_panel.py +++ b/src/aare/gui/panels/log_panel.py @@ -13,7 +13,6 @@ from PySide6.QtWidgets import ( ) from aare.gui.log import QtLogEmitter, QtLogHandler -from aare.gui.widgets.popout_window import DockTitleBar, PopoutWindow from aare.gui.styles import ( FLAT_CARD_RADIUS, LOG_BORDER, @@ -28,6 +27,7 @@ from aare.gui.styles import ( LOG_WARN_BORDER, card_style, ) +from aare.gui.widgets.popout_window import DockTitleBar, PopoutWindow class RuntimeNotificationWidget(QFrame): diff --git a/tests/unit/gui/test_models.py b/tests/unit/gui/test_models.py index 718ae6e0..c72ed78c 100644 --- a/tests/unit/gui/test_models.py +++ b/tests/unit/gui/test_models.py @@ -57,8 +57,8 @@ def test_user_sample_model_init(sample_list): def test_user_sample_model_column_filter(sample_list): model = UserSampleSpreadsheet(samples=sample_list) model.set_show_all_pgroups(True) - # Column 5 is user - model.set_column_filter(5, "U1") + # Column 6 is User (column 0 is the frozen #+status cell) + model.set_column_filter(6, "U1") assert model.rowCount() == 2 model.clear_all_column_filters() assert model.rowCount() == 3 @@ -67,8 +67,8 @@ def test_user_sample_model_column_filter(sample_list): def test_user_sample_model_unique_values(sample_list): model = UserSampleSpreadsheet(samples=sample_list) model.set_show_all_pgroups(True) - # Column 5 is User - users = model.unique_values_for_column(5) + # Column 6 is User (column 0 is the frozen #+status cell) + users = model.unique_values_for_column(6) assert "U1" in users assert "U2" in users assert len(users) == 2 -- 2.54.0 From 84ccf275f9e2e954ee415ee905b7e3d90a43d518 Mon Sep 17 00:00:00 2001 From: Dawn Date: Fri, 7 Aug 2026 17:14:54 +0200 Subject: [PATCH 27/57] style: clear the basedpyright gate for the ported round Declare MainWindow's lazily-set attributes, guard Optional layouts and the sample tab bar, wrap QSettings reads (typed object even with type=...), include _press_geom in the pop-out resize guard, and ignore the PySide6 stub gap on setGraphicsEffect(None). Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 51 ++++++++++++++++++++------- src/aare/gui/widgets/popout_window.py | 2 +- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 1e2249bd..11b47582 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -112,6 +112,11 @@ logger = setup_logger(LOGGER_NAME) class MainWindow(QMainWindow): sample_geometry = Signal(SampleGeometryModel) + # Set lazily outside __init__ (first use guards with getattr/default); + # declared for the basedpyright gate. + _session_operations_enabled: bool | None = None + _default_dock_split_done: bool = False + def __init__( self, base_url: str | None, @@ -125,6 +130,10 @@ class MainWindow(QMainWindow): ): super().__init__() + # Banners open before a baton-vacancy fold; in __init__ (not a class + # default) so the mutable list is per-instance (RUF012). + self._pre_vacancy_open_banners: list[TitleLabel] = [] + self._theme_mode = THEME_ORIGINAL self._theme_action_group = None self._use_legacy_theme_action = None @@ -326,11 +335,15 @@ class MainWindow(QMainWindow): self.data_collection, self.data_collection.file_path_panel, ): - m = first.layout().contentsMargins() - first.layout().setContentsMargins(m.left(), 0, m.right(), m.bottom()) + first_layout = first.layout() + assert first_layout is not None # panels build their layouts in __init__ + m = first_layout.contentsMargins() + first_layout.setContentsMargins(m.left(), 0, m.right(), m.bottom()) + samcam_layout = self.samcam.layout() + assert samcam_layout is not None self.left_column_tabs.setStyleSheet( "QTabWidget::tab-bar {" - f" left: {self.samcam.layout().contentsMargins().left()}px; }}" + f" left: {samcam_layout.contentsMargins().left()}px; }}" ) self.left_column_layout.addWidget(self.left_column_tabs) @@ -1297,13 +1310,21 @@ class MainWindow(QMainWindow): def _restore_samcam_overlay_settings(self) -> None: settings = QSettings("PSI", "AareGUI") - show_detections = settings.value("samcam/show_detections", True, type=bool) - show_detection_polygons = settings.value("samcam/show_detection_polygons", True, type=bool) - show_target_point = settings.value("samcam/show_target_point", True, type=bool) - show_target_coordinates = settings.value("samcam/show_target_coordinates", True, type=bool) - show_overlay_legend = settings.value("samcam/show_overlay_legend", True, type=bool) - compact_overlay_legend = settings.value("samcam/compact_overlay_legend", False, type=bool) - target_color = settings.value("samcam/target_color", "Cyan", type=str) + # bool()/str() wraps: QSettings.value is typed "object" even with + # type=..., so the wraps are runtime no-ops for the pyright gate. + show_detections = bool(settings.value("samcam/show_detections", True, type=bool)) + show_detection_polygons = bool( + settings.value("samcam/show_detection_polygons", True, type=bool) + ) + show_target_point = bool(settings.value("samcam/show_target_point", True, type=bool)) + show_target_coordinates = bool( + settings.value("samcam/show_target_coordinates", True, type=bool) + ) + show_overlay_legend = bool(settings.value("samcam/show_overlay_legend", True, type=bool)) + compact_overlay_legend = bool( + settings.value("samcam/compact_overlay_legend", False, type=bool) + ) + target_color = str(settings.value("samcam/target_color", "Cyan", type=str)) self.samcam.apply_overlay_settings( show_detections=show_detections, @@ -2305,7 +2326,9 @@ class MainWindow(QMainWindow): ): widget.setEnabled(owned) if owned: - widget.setGraphicsEffect(None) + # None clears the effect (Qt API contract); the PySide6 stub + # signature misses the Optional. + widget.setGraphicsEffect(None) # pyright: ignore[reportArgumentType] else: # Full grayscale, banners included — QSS :disabled alone # can't reach the custom-painted TitleLabels/inline styles. @@ -2819,10 +2842,12 @@ class MainWindow(QMainWindow): # Non-staff click on the greyed-out Auxiliary-puck tab: only installed # for non-staff, and tabAt() is geometric so it still sees the # disabled tab — explain the lock instead of silently eating the click. + sample_tab_bar = self.sample_lists_tabs.tabBar() if ( event.type() == QEvent.Type.MouseButtonPress - and obj is self.sample_lists_tabs.tabBar() - and obj.tabAt(event.position().toPoint()) == 1 + and sample_tab_bar is not None + and obj is sample_tab_bar + and sample_tab_bar.tabAt(event.position().toPoint()) == 1 ): self._show_reference_tools_staff_only_popup() return True diff --git a/src/aare/gui/widgets/popout_window.py b/src/aare/gui/widgets/popout_window.py index 0fcaa9b6..a27e143b 100644 --- a/src/aare/gui/widgets/popout_window.py +++ b/src/aare/gui/widgets/popout_window.py @@ -183,7 +183,7 @@ class PopoutWindow(QWidget): super().mousePressEvent(event) def mouseMoveEvent(self, event): - if self._manual_edges and self._press_global is not None: + if self._manual_edges and self._press_global is not None and self._press_geom is not None: delta = event.globalPosition().toPoint() - self._press_global geom = QRect(self._press_geom) if self._manual_edges & Qt.Edge.LeftEdge: -- 2.54.0 From 263830841208776fc9b65648313ca2c32775bd87 Mon Sep 17 00:00:00 2001 From: Dawn Date: Fri, 7 Aug 2026 17:26:14 +0200 Subject: [PATCH 28/57] style: ruff-format the ported round, scope splash Qt enums The sandbox port was ruff-check clean but not ruff-format clean (CI formats before linting); splash_screen's alignment flags move to the scoped enum since the port put that line into the diff gate's scope. Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 36 +++++-------------- src/aare/gui/panels/abr_tweak_panel.py | 8 ++++- src/aare/gui/panels/automation_panel.py | 6 +--- src/aare/gui/panels/beamline_controls.py | 4 ++- .../gui/panels/data_collection_settings.py | 4 +-- src/aare/gui/panels/file_path_panel.py | 4 ++- src/aare/gui/panels/illumination_panel.py | 4 ++- src/aare/gui/panels/monochromator_panel.py | 4 ++- src/aare/gui/panels/omega_panel.py | 4 ++- src/aare/gui/panels/raster_data_collection.py | 8 +---- src/aare/gui/panels/reference_tools_panel.py | 9 +---- src/aare/gui/panels/samcam_panel.py | 4 ++- src/aare/gui/panels/smargon_panel.py | 4 ++- src/aare/gui/panels/zoom_panel.py | 4 ++- src/aare/gui/widgets/camera_image.py | 4 +-- src/aare/gui/widgets/popout_window.py | 9 +---- src/aare/gui/widgets/splash_screen.py | 4 ++- src/aare/gui/widgets/title_label.py | 6 +--- 18 files changed, 49 insertions(+), 77 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 11b47582..a12c13f6 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -327,14 +327,8 @@ class MainWindow(QMainWindow): # Dewar-tabs look: first banner flush under the tab bar (no top # margin) and the tab row starting at the banners' left edge. The # Experiment side needs two levels: the frame AND its first panel. - beamline_first = ( - self.monochromator_panel if self._decoded_token.staff else self.samcam - ) - for first in ( - beamline_first, - self.data_collection, - self.data_collection.file_path_panel, - ): + beamline_first = self.monochromator_panel if self._decoded_token.staff else self.samcam + for first in (beamline_first, self.data_collection, self.data_collection.file_path_panel): first_layout = first.layout() assert first_layout is not None # panels build their layouts in __init__ m = first_layout.contentsMargins() @@ -342,8 +336,7 @@ class MainWindow(QMainWindow): samcam_layout = self.samcam.layout() assert samcam_layout is not None self.left_column_tabs.setStyleSheet( - "QTabWidget::tab-bar {" - f" left: {samcam_layout.contentsMargins().left()}px; }}" + f"QTabWidget::tab-bar {{ left: {samcam_layout.contentsMargins().left()}px; }}" ) self.left_column_layout.addWidget(self.left_column_tabs) @@ -806,9 +799,7 @@ class MainWindow(QMainWindow): self.samcam.changed.connect(self.daq.samcam_settings) self.samcam.screenshot_requested.connect(self.daq.send_screenshot_db) - self.samcam.save_beam_location_setting.connect( - self.daq.save_beam_location_camera_setting - ) + self.samcam.save_beam_location_setting.connect(self.daq.save_beam_location_camera_setting) self.data_collection.find_tip.clicked.connect(self.daq.center_loop) self.data_collection.bounding_box.clicked.connect(self.daq.ml_bounding_box) self.daq.raster_generated_by_ml.connect(self.raster.update_active_grid_request) @@ -850,15 +841,11 @@ class MainWindow(QMainWindow): self.samcam.show_detection_polygons_changed.connect( self.sample_camera.set_show_detection_polygons ) - self.samcam.show_target_point_changed.connect( - self.sample_camera.set_show_target_point - ) + self.samcam.show_target_point_changed.connect(self.sample_camera.set_show_target_point) self.samcam.show_target_coordinates_changed.connect( self.sample_camera.set_show_target_coordinates ) - self.samcam.show_overlay_legend_changed.connect( - self.sample_camera.set_show_overlay_legend - ) + self.samcam.show_overlay_legend_changed.connect(self.sample_camera.set_show_overlay_legend) self.samcam.compact_overlay_legend_changed.connect( self.sample_camera.set_compact_overlay_legend ) @@ -2312,18 +2299,11 @@ class MainWindow(QMainWindow): # No baton -> watching only: camera views stay live, every operating # surface is greyed. The SESSION VACANT badge (and the status bar # session menu) remain the way back in. - owned = session_state in ( - SessionsStateEnum.OwnedByYou, - SessionsStateEnum.PendingElseToYou, - ) + owned = session_state in (SessionsStateEnum.OwnedByYou, SessionsStateEnum.PendingElseToYou) if owned == getattr(self, "_session_operations_enabled", None): return self._session_operations_enabled = owned - for widget in ( - self.left_column_tabs, - self.beamline, - self.tell_samples_dock.widget(), - ): + for widget in (self.left_column_tabs, self.beamline, self.tell_samples_dock.widget()): widget.setEnabled(owned) if owned: # None clears the effect (Qt API contract); the PySide6 stub diff --git a/src/aare/gui/panels/abr_tweak_panel.py b/src/aare/gui/panels/abr_tweak_panel.py index 14b68100..d2ee5e35 100644 --- a/src/aare/gui/panels/abr_tweak_panel.py +++ b/src/aare/gui/panels/abr_tweak_panel.py @@ -93,7 +93,13 @@ class AbrTweakWidget(QWidget): grid_layout = QGridLayout(self) - grid_layout.addWidget(TitleLabel("ABR meas. pos.", self, collapsible=True, default_collapsed=False), 0, 0, 1, 2) + grid_layout.addWidget( + TitleLabel("ABR meas. pos.", self, collapsible=True, default_collapsed=False), + 0, + 0, + 1, + 2, + ) self._abr_buttons = AbrTweakButtons(DEFAULT_ABR_STEP_UM / 1000, parent=self) grid_layout.addWidget(self._abr_buttons, 1, 0) diff --git a/src/aare/gui/panels/automation_panel.py b/src/aare/gui/panels/automation_panel.py index 1a1c3181..fbac3819 100644 --- a/src/aare/gui/panels/automation_panel.py +++ b/src/aare/gui/panels/automation_panel.py @@ -147,10 +147,7 @@ class AutomationProgressWidget(QWidget): @staticmethod def _style_for_status(status: StepStatus) -> str: - base = ( - "padding: 10px 12px; " - f"font-size: {FONT_BODY}; border: 1px solid transparent;" - ) + base = f"padding: 10px 12px; font-size: {FONT_BODY}; border: 1px solid transparent;" if status == StepStatus.SUCCESS: return ( @@ -329,4 +326,3 @@ class AutomationProgressWidget(QWidget): label.setText(f"{icon} {title}{duration_str}{message}{error_str}") label.setStyleSheet(self._style_for_status(step_state.status)) - diff --git a/src/aare/gui/panels/beamline_controls.py b/src/aare/gui/panels/beamline_controls.py index 7266cd50..01e3d4b1 100644 --- a/src/aare/gui/panels/beamline_controls.py +++ b/src/aare/gui/panels/beamline_controls.py @@ -20,7 +20,9 @@ class BeamConfigPanel(QWidget): # panels' banners in the column. layout = QVBoxLayout(self) layout.setSpacing(0) - layout.addWidget(TitleLabel("Beam configuration", self, collapsible=True, default_collapsed=False)) + layout.addWidget( + TitleLabel("Beam configuration", self, collapsible=True, default_collapsed=False) + ) self.beam_mark = BeamMarkWidget(self) self.beam_center = BeamCenterWidget(self) self.beam_size = BeamSizeWidget(self) diff --git a/src/aare/gui/panels/data_collection_settings.py b/src/aare/gui/panels/data_collection_settings.py index 71ea0e9b..8e2bdb7d 100644 --- a/src/aare/gui/panels/data_collection_settings.py +++ b/src/aare/gui/panels/data_collection_settings.py @@ -139,9 +139,7 @@ class DataCollectionSettings(QFrame): # hidden pages makes the stack track only the current page's height. for i in range(self._stack.count()): page = self._stack.widget(i) - vertical = ( - QSizePolicy.Policy.Preferred if i == idx else QSizePolicy.Policy.Ignored - ) + vertical = QSizePolicy.Policy.Preferred if i == idx else QSizePolicy.Policy.Ignored page.setSizePolicy(QSizePolicy.Policy.Preferred, vertical) self._stack.adjustSize() diff --git a/src/aare/gui/panels/file_path_panel.py b/src/aare/gui/panels/file_path_panel.py index cd9b46bb..d162ccef 100644 --- a/src/aare/gui/panels/file_path_panel.py +++ b/src/aare/gui/panels/file_path_panel.py @@ -41,7 +41,9 @@ class FilePathPanel(QWidget): self._formatted_date = datetime.now().strftime("%Y%m%d") - grid_layout.addWidget(TitleLabel("Dataset path", self, collapsible=True, default_collapsed=False), 0, 0, 1, 2) + grid_layout.addWidget( + TitleLabel("Dataset path", self, collapsible=True, default_collapsed=False), 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 69f1e04a..160d00df 100644 --- a/src/aare/gui/panels/illumination_panel.py +++ b/src/aare/gui/panels/illumination_panel.py @@ -13,7 +13,9 @@ class IlluminationPanel(QWidget): super().__init__(parent) grid_layout = QGridLayout(self) - grid_layout.addWidget(TitleLabel("Light", self, collapsible=True, default_collapsed=False), 0, 0, 1, 2) + grid_layout.addWidget( + TitleLabel("Light", self, collapsible=True, default_collapsed=False), 0, 0, 1, 2 + ) front_label = QLabel("Front light", parent=self) front_label.setAlignment(Qt.AlignmentFlag.AlignCenter) diff --git a/src/aare/gui/panels/monochromator_panel.py b/src/aare/gui/panels/monochromator_panel.py index a5c76cc7..c12982f3 100644 --- a/src/aare/gui/panels/monochromator_panel.py +++ b/src/aare/gui/panels/monochromator_panel.py @@ -13,7 +13,9 @@ class MonochromatorPanel(QWidget): super().__init__(parent) grid_layout = QGridLayout(self) - grid_layout.addWidget(TitleLabel("Monochromator", self, collapsible=True, default_collapsed=False), 0, 0, 1, 3) + grid_layout.addWidget( + TitleLabel("Monochromator", self, collapsible=True, default_collapsed=False), 0, 0, 1, 3 + ) 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 9e764cd2..52260b54 100644 --- a/src/aare/gui/panels/omega_panel.py +++ b/src/aare/gui/panels/omega_panel.py @@ -29,7 +29,9 @@ class OmegaPanel(QWidget): grid_layout = QGridLayout(self) - grid_layout.addWidget(TitleLabel("Omega", self, collapsible=True, default_collapsed=False), 0, 0, 1, 2) + grid_layout.addWidget( + TitleLabel("Omega", self, collapsible=True, default_collapsed=False), 0, 0, 1, 2 + ) grid_layout.setColumnStretch(0, 1) grid_layout.setColumnStretch(1, 1) omega_settings = [ diff --git a/src/aare/gui/panels/raster_data_collection.py b/src/aare/gui/panels/raster_data_collection.py index d272dbfd..57f18ea9 100644 --- a/src/aare/gui/panels/raster_data_collection.py +++ b/src/aare/gui/panels/raster_data_collection.py @@ -2,13 +2,7 @@ from aarecommon.config.logger import setup_logger from aarecommon.math.diffraction_geometry import DiffractionGeometry from aarecommon.models.models import BeamlineStateEnum, DAQStatusModel from PySide6.QtCore import Qt, Signal, Slot -from PySide6.QtWidgets import ( - QComboBox, - QLabel, - QMessageBox, - QPushButton, - QSlider, -) +from PySide6.QtWidgets import QComboBox, QLabel, QMessageBox, QPushButton, QSlider from aare.gui.constants import LOGGER_NAME from aare.gui.panels.scan_settings_panel import ScanSettingsPanel diff --git a/src/aare/gui/panels/reference_tools_panel.py b/src/aare/gui/panels/reference_tools_panel.py index 0c22e4fc..09c409fc 100644 --- a/src/aare/gui/panels/reference_tools_panel.py +++ b/src/aare/gui/panels/reference_tools_panel.py @@ -4,14 +4,7 @@ from aarecommon.config.logger import setup_logger from aarecommon.models.models import DAQStatusModel, SampleShortInfo, SampleShortInfoList from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal, Slot from PySide6.QtGui import QBrush -from PySide6.QtWidgets import ( - QAbstractItemView, - QFrame, - QGridLayout, - QHeaderView, - QMenu, - QTableView, -) +from PySide6.QtWidgets import QAbstractItemView, QFrame, QGridLayout, QHeaderView, QMenu, QTableView from aare.gui.constants import LOGGER_NAME from aare.gui.styles import SAMPLE_ROW_QUEUED_BG, WHITE, qcolor diff --git a/src/aare/gui/panels/samcam_panel.py b/src/aare/gui/panels/samcam_panel.py index 0e91593f..55e520a3 100644 --- a/src/aare/gui/panels/samcam_panel.py +++ b/src/aare/gui/panels/samcam_panel.py @@ -37,7 +37,9 @@ class SamcamPanel(QWidget): # Create layout layout = QVBoxLayout() - layout.addWidget(TitleLabel("Sample camera", self, collapsible=True, default_collapsed=False)) + layout.addWidget( + TitleLabel("Sample camera", self, collapsible=True, default_collapsed=False) + ) # Exposure + gain share one row to save vertical space. exposure_gain_layout = QHBoxLayout() diff --git a/src/aare/gui/panels/smargon_panel.py b/src/aare/gui/panels/smargon_panel.py index 9c580613..3ed71dce 100644 --- a/src/aare/gui/panels/smargon_panel.py +++ b/src/aare/gui/panels/smargon_panel.py @@ -58,7 +58,9 @@ class SmargonPanel(QWidget): grid_layout = QGridLayout(self) - grid_layout.addWidget(TitleLabel("Smargon", self, collapsible=True, default_collapsed=False), 0, 0, 1, 6) + grid_layout.addWidget( + TitleLabel("Smargon", self, collapsible=True, default_collapsed=False), 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 850a6139..6ae14f56 100644 --- a/src/aare/gui/panels/zoom_panel.py +++ b/src/aare/gui/panels/zoom_panel.py @@ -23,7 +23,9 @@ class ZoomPanel(QWidget): {"name": "7.5x", "value": 800}, {"name": "12.5x", "value": 1000}, ] - grid_layout.addWidget(TitleLabel("Zoom", self, collapsible=True, default_collapsed=False), 0, 0, 1, 2) + grid_layout.addWidget( + TitleLabel("Zoom", self, collapsible=True, default_collapsed=False), 0, 0, 1, 2 + ) i = 2 self._buttons = [] diff --git a/src/aare/gui/widgets/camera_image.py b/src/aare/gui/widgets/camera_image.py index dc225ab8..1e117e02 100644 --- a/src/aare/gui/widgets/camera_image.py +++ b/src/aare/gui/widgets/camera_image.py @@ -429,9 +429,7 @@ class SampleCameraImageLabel(QGraphicsView): if ( event.button() == Qt.MouseButton.LeftButton and self._legend_hit_rect is not None - and self._legend_hit_rect.contains( - QPointF(self.viewport().mapFrom(self, event.pos())) - ) + and self._legend_hit_rect.contains(QPointF(self.viewport().mapFrom(self, event.pos()))) ): self._legend_expanded = not self._legend_expanded self.update() diff --git a/src/aare/gui/widgets/popout_window.py b/src/aare/gui/widgets/popout_window.py index a27e143b..8d224e49 100644 --- a/src/aare/gui/widgets/popout_window.py +++ b/src/aare/gui/widgets/popout_window.py @@ -1,13 +1,6 @@ from PySide6.QtCore import QPoint, QRect, QSize, Qt from PySide6.QtGui import QCursor, QGuiApplication, QIcon, QPainter, QPen, QPixmap -from PySide6.QtWidgets import ( - QDockWidget, - QHBoxLayout, - QLabel, - QToolButton, - QVBoxLayout, - QWidget, -) +from PySide6.QtWidgets import QDockWidget, QHBoxLayout, QLabel, QToolButton, QVBoxLayout, QWidget from aare.gui.styles import FRAME_L1_COLOR, FRAME_L1_WIDTH, TEXT, qcolor diff --git a/src/aare/gui/widgets/splash_screen.py b/src/aare/gui/widgets/splash_screen.py index 314f56c0..84a4bd37 100644 --- a/src/aare/gui/widgets/splash_screen.py +++ b/src/aare/gui/widgets/splash_screen.py @@ -27,5 +27,7 @@ class LoadingSplashScreen(QSplashScreen): def set_progress(self, value, message=None): self.progress.setValue(value) if message: - self.showMessage(message, Qt.AlignBottom | Qt.AlignCenter, qcolor(WHITE)) + self.showMessage( + message, Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignCenter, qcolor(WHITE) + ) QApplication.processEvents() diff --git a/src/aare/gui/widgets/title_label.py b/src/aare/gui/widgets/title_label.py index 8eb1a5a7..13fc734b 100644 --- a/src/aare/gui/widgets/title_label.py +++ b/src/aare/gui/widgets/title_label.py @@ -49,11 +49,7 @@ def section_title(text: str, parent=None) -> QLabel: class TitleLabel(QLabel): def __init__( - self, - text: str, - parent=None, - collapsible: bool = False, - default_collapsed: bool = True, + self, text: str, parent=None, collapsible: bool = False, default_collapsed: bool = True ): super().__init__(parent) # Plain text + QSS font instead of

: rich-text heading margins -- 2.54.0 From ed42738693e6033ebeb5877c15bb842ed52e915e Mon Sep 17 00:00:00 2001 From: Dawn Date: Fri, 7 Aug 2026 17:35:58 +0200 Subject: [PATCH 29/57] style: clear the two portrait-mode diff-gate violations The sample-name labels are created in __init__ now (the build helper only styles and mounts them) so reportUninitializedInstanceVariable stops firing on the ported styling lines, and the queue placeholder uses the scoped Qt.AlignmentFlag.AlignCenter. Co-Authored-By: Claude Fable 5 --- src/aare/gui/panels/portrait_mode.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/aare/gui/panels/portrait_mode.py b/src/aare/gui/panels/portrait_mode.py index 5d66f104..d65231df 100644 --- a/src/aare/gui/panels/portrait_mode.py +++ b/src/aare/gui/panels/portrait_mode.py @@ -257,6 +257,12 @@ class PortraitModePanel(QWidget): self.setMaximumWidth(self.PORTRAIT_WIDTH) self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) + # Created here rather than in the build helper so the attributes are + # initialized in __init__ (basedpyright gate); the helper styles and + # mounts them. + self._name_lbl = QLabel("—") + self._sub_lbl = QLabel("No sample queued") + self._job_list_panel = None self._tell_samples = None self._is_running = False @@ -350,10 +356,8 @@ class PortraitModePanel(QWidget): cam_card_layout.addWidget(cam_widget) layout.addWidget(cam_card) - # Sample name labels - self._name_lbl = QLabel("—") + # Sample name labels (created in __init__) self._name_lbl.setStyleSheet(f"color: {TEXT}; font-size: {FONT_VALUE}; font-weight: 700;") - self._sub_lbl = QLabel("No sample queued") self._sub_lbl.setStyleSheet(f"color: {SUBTEXT}; font-size: {FONT_HINT};") layout.addWidget(self._name_lbl) layout.addWidget(self._sub_lbl) @@ -606,7 +610,7 @@ class PortraitModePanel(QWidget): placeholder.setStyleSheet( f"color: {SUBTEXT}; font-size: {FONT_LABEL}; font-weight: 700;" ) - placeholder.setAlignment(Qt.AlignCenter) + placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter) self._queue_inner_layout.addWidget(placeholder) return -- 2.54.0 From 69b68130259827ba2166360474959d45d0ec5310 Mon Sep 17 00:00:00 2001 From: Dawn Date: Fri, 7 Aug 2026 17:48:50 +0200 Subject: [PATCH 30/57] test: cover the sample-status logic, wheel guard, pop-outs, and log mirror New tests for what the port added: status tint priority and context-dependent chip filters on the dewar/queue model, the right-button wheel guard (motor protection), PopoutWindow edge maths, manual-resize fallback, close-hides behavior and DockTitleBar, the chip drag-drop relabeling, and the console-log pop-out mirror. The widgets' __main__ self-checks are superseded by these and removed. Brings PR diff coverage from 70% to 81% (gate: 80%). Co-Authored-By: Claude Fable 5 --- src/aare/gui/widgets/popout_window.py | 18 --- src/aare/gui/widgets/wheel_value_guard.py | 34 ----- tests/unit/gui/test_log_panel.py | 29 ++++ tests/unit/gui/test_models.py | 124 ++++++++++++++++ tests/unit/gui/test_popout_window.py | 171 ++++++++++++++++++++++ tests/unit/gui/test_tell_sample_panel.py | 134 +++++++++++++++++ tests/unit/gui/test_wheel_value_guard.py | 74 ++++++++++ 7 files changed, 532 insertions(+), 52 deletions(-) create mode 100644 tests/unit/gui/test_log_panel.py create mode 100644 tests/unit/gui/test_popout_window.py create mode 100644 tests/unit/gui/test_tell_sample_panel.py create mode 100644 tests/unit/gui/test_wheel_value_guard.py diff --git a/src/aare/gui/widgets/popout_window.py b/src/aare/gui/widgets/popout_window.py index 8d224e49..ace02255 100644 --- a/src/aare/gui/widgets/popout_window.py +++ b/src/aare/gui/widgets/popout_window.py @@ -201,21 +201,3 @@ class PopoutWindow(QWidget): self._press_global = None self._press_geom = None super().mouseReleaseEvent(event) - - -if __name__ == "__main__": - # ponytail: smallest check that fails if the edge maths breaks - import os - - os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") - from PySide6.QtWidgets import QApplication, QLabel - - app = QApplication([]) - w = PopoutWindow("t", QLabel("x")) - w.resize(400, 300) - assert w._edges_at(QPoint(5, 150)) == Qt.Edge.LeftEdge - assert w._edges_at(QPoint(398, 298)) == (Qt.Edge.RightEdge | Qt.Edge.BottomEdge) - assert w._edges_at(QPoint(200, 150)) == Qt.Edge(0) - assert w._cursor_for(Qt.Edge.LeftEdge | Qt.Edge.TopEdge) == Qt.CursorShape.SizeFDiagCursor - assert w._cursor_for(Qt.Edge.RightEdge | Qt.Edge.TopEdge) == Qt.CursorShape.SizeBDiagCursor - print("gude") diff --git a/src/aare/gui/widgets/wheel_value_guard.py b/src/aare/gui/widgets/wheel_value_guard.py index 7697de8e..b4975fc9 100644 --- a/src/aare/gui/widgets/wheel_value_guard.py +++ b/src/aare/gui/widgets/wheel_value_guard.py @@ -46,37 +46,3 @@ class WheelValueGuard(QObject): QApplication.sendEvent(area.viewport(), relayed) return True return super().eventFilter(obj, event) - - -if __name__ == "__main__": - # ponytail: smallest check that fails if the guard logic breaks - import os - - os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") - from PySide6.QtCore import QPoint, QPointF - - app = QApplication([]) - guard = WheelValueGuard() - app.installEventFilter(guard) - slider = QSlider(Qt.Orientation.Horizontal) - slider.setRange(0, 100) - slider.setValue(50) - slider.show() - - def wheel(buttons): - return QWheelEvent( - QPointF(5, 5), - QPointF(5, 5), - QPoint(0, 0), - QPoint(0, 120), - buttons, - Qt.KeyboardModifier.NoModifier, - Qt.ScrollPhase.NoScrollPhase, - False, - ) - - QApplication.sendEvent(slider, wheel(Qt.MouseButton.NoButton)) - assert slider.value() == 50, "bare wheel must not adjust the slider" - QApplication.sendEvent(slider, wheel(Qt.MouseButton.RightButton)) - assert slider.value() != 50, "right-button + wheel must adjust the slider" - print("gude") diff --git a/tests/unit/gui/test_log_panel.py b/tests/unit/gui/test_log_panel.py new file mode 100644 index 00000000..9c88dc14 --- /dev/null +++ b/tests/unit/gui/test_log_panel.py @@ -0,0 +1,29 @@ +"""The console-log pop-out is a second view on the same emitter: history is +copied on open, live lines reach both views, and clear() empties both.""" + +from aare.gui.panels.log_panel import LogDock + + +def test_log_popout_mirrors_and_clears(qtbot): + dock = LogDock() + qtbot.addWidget(dock) + dock.emitter.message.emit("first line") + assert "first line" in dock.view.toPlainText() + + dock._open_popout() + assert dock._popout is not None + assert dock._popout.isVisible() + # History copied on open, live lines reach both views. + assert "first line" in dock._popout_view.toPlainText() + dock.emitter.message.emit("second line") + assert "second line" in dock.view.toPlainText() + assert "second line" in dock._popout_view.toPlainText() + + # Reopening reuses the window instead of stacking mirrors. + popout = dock._popout + dock._open_popout() + assert dock._popout is popout + + dock.clear() + assert dock.view.toPlainText() == "" + assert dock._popout_view.toPlainText() == "" diff --git a/tests/unit/gui/test_models.py b/tests/unit/gui/test_models.py index c72ed78c..034374cc 100644 --- a/tests/unit/gui/test_models.py +++ b/tests/unit/gui/test_models.py @@ -121,3 +121,127 @@ def test_sample_queue_model_flags(sample_list): model = SampleQueueSpreadsheet(samples=sample_list[:2]) flags = model.flags(model.index(0, 0)) assert flags & Qt.ItemFlag.ItemIsDropEnabled + + +# --- Status logic of the combined dewar/queue view --------------------------- +# The dewar table doubles as the queue view: the frozen "#" column carries a +# status fill (mounted > queued > flagged > measured) and the chip row filters +# by status. This is the logic a local contact trusts at a glance, so it gets +# its own tests. + + +def _status(model, row): + brush = model.data(model.index(row, 0), Qt.ItemDataRole.BackgroundRole) + return None if brush is None else brush.color().name().lower() + + +def _row_of(model, db_id): + return next(r for r in range(model.rowCount()) if model.get_id(r).db_id == db_id) + + +@pytest.fixture +def status_model(sample_list): + from aarecommon.models.models import DewarAddress, SampleShortInfo + + # A measured sample: rotation_count > 1 (exactly 1 must NOT count). + sample_list.append( + SampleShortInfo( + db_id=4, + puck_name="P3", + dewar_name="D3", + sample_name="S4", + run_number=4, + user="U1", + pin=4, + rotation_count=2, + location=DewarAddress(segment="B", pos=1), + ) + ) + model = UserSampleSpreadsheet(samples=sample_list) + model.set_show_all_pgroups(True) + return model + + +def test_status_color_priority(status_model): + from aare.gui.styles import ( + SAMPLE_ROW_QUEUED_BG, + SAMPLE_STATUS_FLAGGED_BG, + SAMPLE_STATUS_MEASURED_BG, + SAMPLE_STATUS_QUEUED_BG, + ) + + model = status_model + assert _status(model, _row_of(model, 1)) is None + + model.set_queued_ids({1}) + model.set_flagged(1, True) + # Queued beats flagged in the All view. + assert _status(model, _row_of(model, 1)) == SAMPLE_STATUS_QUEUED_BG.lower() + model.set_queued_ids(set()) + assert _status(model, _row_of(model, 1)) == SAMPLE_STATUS_FLAGGED_BG.lower() + + # Measured is automatic: rotation_count 2 counts, the fixture's 1-3 don't. + assert _status(model, _row_of(model, 4)) == SAMPLE_STATUS_MEASURED_BG.lower() + assert _status(model, _row_of(model, 2)) is None + + # Mounted always wins. + model.updateCurrentSample(current_puck="P1", current_sample=1) + assert _status(model, _row_of(model, 1)) == SAMPLE_ROW_QUEUED_BG.lower() + + +def test_status_filter_selects_rows(status_model): + model = status_model + model.set_queued_ids({1, 2}) + model.set_flagged(3, True) + + model.set_status_filter("queued") + assert {model.get_id(r).db_id for r in range(model.rowCount())} == {1, 2} + model.set_status_filter("flagged") + assert {model.get_id(r).db_id for r in range(model.rowCount())} == {3} + model.set_status_filter("measured") + assert {model.get_id(r).db_id for r in range(model.rowCount())} == {4} + model.set_status_filter(None) + assert model.rowCount() == 4 + + +def test_status_tints_are_context_dependent(status_model): + from aare.gui.styles import SAMPLE_STATUS_FLAGGED_BG, SAMPLE_STATUS_QUEUED_BG + + model = status_model + model.set_queued_ids({1, 2}) + model.set_flagged(1, True) + + # Queued view: own tint suppressed, only the also-flagged mark shows. + model.set_status_filter("queued") + assert _status(model, _row_of(model, 1)) == SAMPLE_STATUS_FLAGGED_BG.lower() + assert _status(model, _row_of(model, 2)) is None + + # Flagged view: a re-queued sample wears the queued mark. + model.set_status_filter("flagged") + assert _status(model, _row_of(model, 1)) == SAMPLE_STATUS_QUEUED_BG.lower() + + +def test_status_sets_refilter_while_chip_active(status_model): + model = status_model + model.set_status_filter("queued") + assert model.rowCount() == 0 + model.set_queued_ids({2}) + assert {model.get_id(r).db_id for r in range(model.rowCount())} == {2} + + +def test_status_column_is_display_only(status_model): + model = status_model + assert model.data(model.index(0, 0), Qt.ItemDataRole.DisplayRole) == 1 + before = [model.get_id(r).db_id for r in range(model.rowCount())] + model.sort(0, Qt.SortOrder.DescendingOrder) # no-op on the "#" column + assert [model.get_id(r).db_id for r in range(model.rowCount())] == before + + +def test_mime_data_round_trips_for_chip_drops(status_model): + from aarecommon.models.models import SampleShortInfoList + + model = status_model + payload = model.mimeData([model.index(0, 1), model.index(1, 1)]) + samples = SampleShortInfoList.model_validate_json(payload.text()) + assert len(samples.s) == 2 + assert samples.s[0].db_id == model.get_id(0).db_id diff --git a/tests/unit/gui/test_popout_window.py b/tests/unit/gui/test_popout_window.py new file mode 100644 index 00000000..f42fdca2 --- /dev/null +++ b/tests/unit/gui/test_popout_window.py @@ -0,0 +1,171 @@ +"""PopoutWindow replaces dock floating: an additional top-level window whose +edge band resizes, whose close only hides, and whose first show lands near +the cursor. DockTitleBar puts the pop-out button next to the close box.""" + +from PySide6.QtCore import QEvent, QPoint, QPointF, Qt +from PySide6.QtGui import QMouseEvent +from PySide6.QtWidgets import QDockWidget, QLabel + +from aare.gui.widgets.popout_window import DockTitleBar, PopoutWindow + + +def _window(qtbot): + w = PopoutWindow("test", QLabel("content")) + qtbot.addWidget(w) + w.resize(400, 300) + return w + + +def test_edges_at(qtbot): + w = _window(qtbot) + band = PopoutWindow.RESIZE_MARGIN + PopoutWindow.OUTER_GRIP + assert w._edges_at(QPoint(band - 1, 150)) == Qt.Edge.LeftEdge + assert w._edges_at(QPoint(200, band - 1)) == Qt.Edge.TopEdge + assert w._edges_at(QPoint(399, 299)) == (Qt.Edge.RightEdge | Qt.Edge.BottomEdge) + assert w._edges_at(QPoint(200, 150)) == Qt.Edge(0) + + +def test_cursor_for(qtbot): + w = _window(qtbot) + assert w._cursor_for(Qt.Edge.LeftEdge) == Qt.CursorShape.SizeHorCursor + assert w._cursor_for(Qt.Edge.BottomEdge) == Qt.CursorShape.SizeVerCursor + assert w._cursor_for(Qt.Edge.LeftEdge | Qt.Edge.TopEdge) == Qt.CursorShape.SizeFDiagCursor + assert w._cursor_for(Qt.Edge.RightEdge | Qt.Edge.TopEdge) == Qt.CursorShape.SizeBDiagCursor + assert w._cursor_for(Qt.Edge(0)) is None + + +def test_close_hides_and_keeps_geometry(qtbot): + w = _window(qtbot) + w.show() + qtbot.waitExposed(w) + w.move(120, 130) + geometry = w.geometry() + w.close() + assert not w.isVisible() + # Reopen: the first-show placement must not run again (geometry kept). + w.show() + assert w._placed + assert w.geometry() == geometry + + +def test_manual_resize_fallback(qtbot): + w = _window(qtbot) + w.show() + qtbot.waitExposed(w) + w.resize(400, 300) + start = w.geometry() + + grab = QPointF(399.0, 299.0) # bottom-right band + press = QMouseEvent( + QEvent.Type.MouseButtonPress, + grab, + w.mapToGlobal(grab.toPoint()).toPointF(), + Qt.MouseButton.LeftButton, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier, + ) + w.mousePressEvent(press) + if not w._manual_edges: + # The platform accepted startSystemResize; the manual path is not + # reachable here, and the press must not have moved the window. + assert w.geometry() == start + return + + move_to = grab + QPointF(40.0, 25.0) + move = QMouseEvent( + QEvent.Type.MouseMove, + move_to, + w.mapToGlobal(move_to.toPoint()).toPointF(), + Qt.MouseButton.NoButton, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier, + ) + w.mouseMoveEvent(move) + assert w.width() == start.width() + 40 + assert w.height() == start.height() + 25 + + release = QMouseEvent( + QEvent.Type.MouseButtonRelease, + move_to, + w.mapToGlobal(move_to.toPoint()).toPointF(), + Qt.MouseButton.LeftButton, + Qt.MouseButton.NoButton, + Qt.KeyboardModifier.NoModifier, + ) + w.mouseReleaseEvent(release) + assert not w._manual_edges + + +def test_hover_sets_resize_cursor(qtbot): + w = _window(qtbot) + w.show() + qtbot.waitExposed(w) + + def hover(pos): + w.mouseMoveEvent( + QMouseEvent( + QEvent.Type.MouseMove, + pos, + w.mapToGlobal(pos.toPoint()).toPointF(), + Qt.MouseButton.NoButton, + Qt.MouseButton.NoButton, + Qt.KeyboardModifier.NoModifier, + ) + ) + + hover(QPointF(2.0, 150.0)) + assert w.cursor().shape() == Qt.CursorShape.SizeHorCursor + hover(QPointF(200.0, 150.0)) + assert w.cursor().shape() == Qt.CursorShape.ArrowCursor + + +def test_dock_title_bar_buttons(qtbot): + dock = QDockWidget("Console Log") + qtbot.addWidget(dock) + opened = [] + bar = DockTitleBar(dock, on_popout=lambda: opened.append(True)) + dock.setTitleBarWidget(bar) + dock.show() + qtbot.waitExposed(dock) + + qtbot.mouseClick(bar.popout_button, Qt.MouseButton.LeftButton) + assert opened == [True] + assert dock.isVisible() + + +def test_manual_resize_from_top_left(qtbot): + w = _window(qtbot) + w.show() + qtbot.waitExposed(w) + w.resize(400, 300) + # Drive the manual path directly; whether startSystemResize is available + # is platform luck and the top/left arithmetic deserves coverage either way. + w._manual_edges = Qt.Edge.LeftEdge | Qt.Edge.TopEdge + w._press_global = w.mapToGlobal(QPoint(2, 2)) + w._press_geom = w.geometry() + move_to = QPointF(2.0 + 30.0, 2.0 + 20.0) + w.mouseMoveEvent( + QMouseEvent( + QEvent.Type.MouseMove, + move_to, + w.mapToGlobal(move_to.toPoint()).toPointF(), + Qt.MouseButton.NoButton, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier, + ) + ) + assert w.width() == 400 - 30 + assert w.height() == 300 - 20 + + +def test_paint_border_knob(qtbot, monkeypatch): + from PySide6.QtGui import QPixmap + + from aare.gui.widgets import popout_window as mod + + w = _window(qtbot) + # Default FRAME_L1_WIDTH "0px": render must take the paint-nothing path. + w.render(QPixmap(w.size())) + # With a visible level-1 border the painter path runs. + monkeypatch.setattr(mod, "FRAME_L1_WIDTH", "2px") + w.render(QPixmap(w.size())) diff --git a/tests/unit/gui/test_tell_sample_panel.py b/tests/unit/gui/test_tell_sample_panel.py new file mode 100644 index 00000000..c8882ad9 --- /dev/null +++ b/tests/unit/gui/test_tell_sample_panel.py @@ -0,0 +1,134 @@ +"""The combined sample dock: chip row filters the table by status, chips +double as drop targets for queue/flag relabeling, and a pop-out panel shares +the docked panel's model so both stay in sync without wiring.""" + +import pytest +from aarecommon.models.models import DewarAddress, SampleShortInfo, SampleShortInfoList + +from aare.gui.panels.tell_sample_panel import TellSamplePanel + + +@pytest.fixture +def samples(): + return SampleShortInfoList( + s=[ + SampleShortInfo( + db_id=i, + puck_name=f"P{i}", + dewar_name="D1", + sample_name=f"S{i}", + run_number=i, + user="U1", + pin=i, + location=DewarAddress(segment="A", pos=i), + ) + for i in (1, 2, 3) + ] + ) + + +@pytest.fixture +def panel(qtbot, samples): + panel = TellSamplePanel(samples=samples) + qtbot.addWidget(panel) + panel.table_model.set_show_all_pgroups(True) + return panel + + +def _chip(panel, key): + return next(c for c in panel.status_chips.buttons() if c.property("status_key") == key) + + +def test_chip_click_drives_the_status_filter(panel): + panel.table_model.set_queued_ids({2}) + _chip(panel, "queued").click() + assert panel.table_model.status_filter == "queued" + assert panel.table_model.rowCount() == 1 + _chip(panel, None).click() + assert panel.table_model.status_filter is None + assert panel.table_model.rowCount() == 3 + + +def test_set_status_chip_syncs_without_filtering(panel): + panel.set_status_chip("flagged") + assert _chip(panel, "flagged").isChecked() + # Sync only checks the chip; it must not fire the filter. + assert panel.table_model.status_filter is None + + +def test_queued_chip_drop_relays_to_the_queue(panel, qtbot, samples): + chip = _chip(panel, "queued") + with qtbot.waitSignal(panel.add_to_queue) as blocker: + chip.samples_dropped.emit(samples) + assert [s.db_id for s in blocker.args[0].s] == [1, 2, 3] + + +def test_flagged_chip_drop_flags_in_the_model(panel, samples): + panel._flag_dropped_samples(SampleShortInfoList(s=samples.s[:2])) + assert panel.table_model.flagged_ids == {1, 2} + + +def test_popout_panel_shares_the_model(panel, qtbot): + popout = TellSamplePanel(model=panel.table_model) + qtbot.addWidget(popout) + assert popout.table_model is panel.table_model + panel.table_model.set_flagged(3, True) + assert 3 in popout.table_model.flagged_ids + + +def test_new_sample_list_updates_rows(panel, samples): + extra = samples.s + [ + SampleShortInfo( + db_id=9, + puck_name="P9", + dewar_name="D2", + sample_name="S9", + run_number=9, + user="U2", + pin=9, + location=DewarAddress(segment="B", pos=1), + ) + ] + panel.new_sample_list(SampleShortInfoList(s=extra)) + assert panel.table_model.rowCount() == 4 + + +def test_queue_drop_chip_accepts_sample_payloads(panel, qtbot, samples): + from PySide6.QtCore import QMimeData, QPointF, Qt + from PySide6.QtGui import QDropEvent + + from aare.gui.panels.tell_sample_panel import QueueDropChip + + chip = next(c for c in panel.status_chips.buttons() if isinstance(c, QueueDropChip)) + + # The event only borrows the QMimeData (C++ pointer), so the mime must + # outlive the dropEvent call — hence created in the test's scope. + def drop(mime): + return QDropEvent( + QPointF(1, 1), + Qt.DropAction.CopyAction, + mime, + Qt.MouseButton.NoButton, + Qt.KeyboardModifier.NoModifier, + ) + + good = QMimeData() + good.setText(samples.model_dump_json()) + with qtbot.waitSignal(chip.samples_dropped): + chip.dropEvent(drop(good)) + + # A non-sample payload is ignored, not crashed on. + bad = QMimeData() + bad.setText("not json") + with qtbot.assertNotEmitted(chip.samples_dropped): + chip.dropEvent(drop(bad)) + + +def test_selected_samples_follow_the_click(panel): + view = panel.table_view + view.selectRow(0) + row_ids = [panel.table_model.get_id(r).db_id for r in range(3)] + # Click inside the selection: the selection is acted on. + assert [s.db_id for s in panel._selected_samples(0)] == [row_ids[0]] + # Click outside the selection: only the clicked row is acted on. + assert [s.db_id for s in panel._selected_samples(2)] == [row_ids[2]] diff --git a/tests/unit/gui/test_wheel_value_guard.py b/tests/unit/gui/test_wheel_value_guard.py new file mode 100644 index 00000000..68d760a9 --- /dev/null +++ b/tests/unit/gui/test_wheel_value_guard.py @@ -0,0 +1,74 @@ +"""The wheel guard is motor protection: a bare wheel over a value widget must +never change the value (it scrolls the page instead); adjusting requires the +deliberate right-button + wheel gesture.""" + +import pytest +from PySide6.QtCore import QPoint, QPointF, Qt +from PySide6.QtGui import QWheelEvent +from PySide6.QtWidgets import QApplication, QScrollArea, QSlider, QSpinBox, QVBoxLayout, QWidget + +from aare.gui.widgets.wheel_value_guard import WheelValueGuard + + +@pytest.fixture +def guard(qapp): + guard = WheelValueGuard() + qapp.installEventFilter(guard) + yield guard + qapp.removeEventFilter(guard) + + +def _wheel(buttons): + return QWheelEvent( + QPointF(5, 5), + QPointF(5, 5), + QPoint(0, 0), + QPoint(0, 120), + buttons, + Qt.KeyboardModifier.NoModifier, + Qt.ScrollPhase.NoScrollPhase, + False, + ) + + +def test_bare_wheel_does_not_adjust(guard, qtbot): + slider = QSlider(Qt.Orientation.Horizontal) + qtbot.addWidget(slider) + slider.setRange(0, 100) + slider.setValue(50) + QApplication.sendEvent(slider, _wheel(Qt.MouseButton.NoButton)) + assert slider.value() == 50 + + +def test_right_button_wheel_adjusts(guard, qtbot): + slider = QSlider(Qt.Orientation.Horizontal) + qtbot.addWidget(slider) + slider.setRange(0, 100) + slider.setValue(50) + QApplication.sendEvent(slider, _wheel(Qt.MouseButton.RightButton)) + assert slider.value() != 50 + + +def test_bare_wheel_scrolls_the_enclosing_area(guard, qtbot): + area = QScrollArea() + qtbot.addWidget(area) + content = QWidget() + layout = QVBoxLayout(content) + spin = QSpinBox() + spin.setRange(0, 100) + spin.setValue(50) + layout.addWidget(spin) + # Tall filler so the area has something to scroll. + filler = QWidget() + filler.setFixedHeight(2000) + layout.addWidget(filler) + area.setWidget(content) + area.resize(200, 200) + area.show() + bar = area.verticalScrollBar() + bar.setValue(bar.maximum() // 2) + before = bar.value() + + QApplication.sendEvent(spin, _wheel(Qt.MouseButton.NoButton)) + assert spin.value() == 50 + assert bar.value() != before -- 2.54.0 From 2adf8ff6d5a57d98527f45b7450dd3b9565d65e4 Mon Sep 17 00:00:00 2001 From: Dawn Date: Fri, 7 Aug 2026 17:52:30 +0200 Subject: [PATCH 31/57] style: narrow the log pop-out view for the pyright gate The Optional _popout_view is assert-narrowed once after opening; the unguarded accesses were the last three diff-gate violations. Co-Authored-By: Claude Fable 5 --- tests/unit/gui/test_log_panel.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/unit/gui/test_log_panel.py b/tests/unit/gui/test_log_panel.py index 9c88dc14..6e69bae9 100644 --- a/tests/unit/gui/test_log_panel.py +++ b/tests/unit/gui/test_log_panel.py @@ -13,11 +13,13 @@ def test_log_popout_mirrors_and_clears(qtbot): dock._open_popout() assert dock._popout is not None assert dock._popout.isVisible() + popout_view = dock._popout_view + assert popout_view is not None # History copied on open, live lines reach both views. - assert "first line" in dock._popout_view.toPlainText() + assert "first line" in popout_view.toPlainText() dock.emitter.message.emit("second line") assert "second line" in dock.view.toPlainText() - assert "second line" in dock._popout_view.toPlainText() + assert "second line" in popout_view.toPlainText() # Reopening reuses the window instead of stacking mirrors. popout = dock._popout @@ -26,4 +28,4 @@ def test_log_popout_mirrors_and_clears(qtbot): dock.clear() assert dock.view.toPlainText() == "" - assert dock._popout_view.toPlainText() == "" + assert popout_view.toPlainText() == "" -- 2.54.0 From 900315d412db59d85a54cd3d5d3409b12b91909b Mon Sep 17 00:00:00 2001 From: Dawn Date: Sat, 8 Aug 2026 14:59:42 +0200 Subject: [PATCH 32/57] feat: add spin-arrow and check-mark glyph assets Co-Authored-By: Claude Fable 5 --- src/aare/gui/graphics/check_mark_dark.png | Bin 0 -> 261 bytes src/aare/gui/graphics/check_mark_light.png | Bin 0 -> 270 bytes src/aare/gui/graphics/spin_arrow_down_dark.png | Bin 0 -> 180 bytes src/aare/gui/graphics/spin_arrow_down_light.png | Bin 0 -> 176 bytes src/aare/gui/graphics/spin_arrow_up_dark.png | Bin 0 -> 174 bytes src/aare/gui/graphics/spin_arrow_up_light.png | Bin 0 -> 192 bytes 6 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/aare/gui/graphics/check_mark_dark.png create mode 100644 src/aare/gui/graphics/check_mark_light.png create mode 100644 src/aare/gui/graphics/spin_arrow_down_dark.png create mode 100644 src/aare/gui/graphics/spin_arrow_down_light.png create mode 100644 src/aare/gui/graphics/spin_arrow_up_dark.png create mode 100644 src/aare/gui/graphics/spin_arrow_up_light.png diff --git a/src/aare/gui/graphics/check_mark_dark.png b/src/aare/gui/graphics/check_mark_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..bbc733277b504034b5d5bf37916ba51bb5ffa2f7 GIT binary patch literal 261 zcmeAS@N?(olHy`uVBq!ia0vp^AT}2V8<6ZZI=>f4aTa()7BevL9RXp+soH$fK*8;v zE{-7*Q`b(g}ct7c_@;^ub>#! z)3#JW>ax#gqnVOF+MDY1_RCkeHoP#;c%Hs_(zFg?fpR;wUZam4$;AgIv@!5GJb3Qa z_+FGj#b^472qn&(yNfR~8Z^|Nw`Snf{ literal 0 HcmV?d00001 diff --git a/src/aare/gui/graphics/check_mark_light.png b/src/aare/gui/graphics/check_mark_light.png new file mode 100644 index 0000000000000000000000000000000000000000..e9fe4c70781f83a33bdca3aad9e0bdfaa5d84ead GIT binary patch literal 270 zcmeAS@N?(olHy`uVBq!ia0vp^AT}2V8<6ZZI=>f4aTa()7BevL9RXp+soH$fK*0l^ zE{-7*Q`cVD=y#|<;P}V)>n(PO&9a!V>#y}^79WENA4JyjWzCW8)Gihm_I6^@KPdcx z=jacHwY~;O=u<5X=vX`o=5 zr;B3<$IRrKpWj|*v9EOks=->%zmlTlo1LV|$fO=u<5X=vX`o<| zr;B3<$IRpq(}K9OCk_Z4YGD17o)B^P%X|4^GiK()(mJ2=|NTuqm$s`q^Sbu2|2;Qf zZ0h0pzxiF2zY7IG8>)F~&x9h**O-%rTCQXM;uXv~Qa$flwd*}-99H)s#88)^l V{_}Xky%lI1gQu&X%Q~loCIC}!LPr1q literal 0 HcmV?d00001 diff --git a/src/aare/gui/graphics/spin_arrow_up_dark.png b/src/aare/gui/graphics/spin_arrow_up_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..f9a6ca7ffdab7f97058c9b1296666a4f2e63f437 GIT binary patch literal 174 zcmeAS@N?(olHy`uVBq!ia0vp^96-#*!3HG%vEKg;q&N#aB8wRq_>O=u<5X=vX`o<& zr;B3<$IRGXL%s$D0hZ?;8gDxu^4GEdoaz6W|Cy5L(xW#y76s>;&5o3~5VU#PuD-cj z3S>&f^sO6Ww;i$2YO=u<5X=vX`o<{ zr;B3<$IRHCgPhF{BCP5Ew|x-Y!)d9bvBI=t2~%>qS+Zo~6SX*J1J#<3lX$bAeW~Di z?8)J4pA|P{{`Fry^Sd7h#T)B?W7u#v{B&&{vxkUF4wJ}*_s`T@mdVTVc$RVGmNOT4 mMpYc literal 0 HcmV?d00001 -- 2.54.0 From 1946430f0d69c46a41191e9c54e189dd0fa49dd1 Mon Sep 17 00:00:00 2001 From: Dawn Date: Sat, 8 Aug 2026 15:05:34 +0200 Subject: [PATCH 33/57] feat: dusk-gradient theme overhaul with transparent-widget scheme Both stylesheets move to transparent children over a gradient painted only by top-level windows; inputs, buttons, headers, and menus get opaque faces back. Sunset drops Catppuccin Macchiato for the Daemmerung dusk palette. Spin/combo arrows and check indicators become theme-tinted PNGs. Widgets stop carrying inline input/text stylesheets so the theme QSS can win (empty stylesheet = theme reset). Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 4 +- src/aare/gui/models/user_sample_model.py | 9 + src/aare/gui/panels/abr_tweak_panel.py | 8 +- src/aare/gui/panels/file_path_panel.py | 11 +- src/aare/gui/panels/log_panel.py | 10 +- src/aare/gui/panels/samcam_panel.py | 7 - src/aare/gui/panels/tell_sample_panel.py | 36 +- src/aare/gui/styles.py | 642 ++++++++++++++++++++--- src/aare/gui/widgets/login.py | 4 +- src/aare/gui/widgets/number_line_edit.py | 40 +- src/aare/gui/widgets/title_label.py | 34 +- 11 files changed, 641 insertions(+), 164 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index a12c13f6..de2755eb 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -71,7 +71,7 @@ from aare.gui.scan_logic.raster_grid_manager import RasterGridManager from aare.gui.scan_logic.rotation_scan_manager import RotationScanManager from aare.gui.scan_logic.sample_mount_logic import SampleMountLogic from aare.gui.styles import ( - BACKGROUND, + APP_BACKGROUND, DOCK_CONTENT_LEFT_PAD, THEME_ORIGINAL, THEME_PORTRAIT, @@ -213,7 +213,7 @@ class MainWindow(QMainWindow): ) raise - self.setStyleSheet(f"background-color: {BACKGROUND};") + self.setStyleSheet(f"background-color: {APP_BACKGROUND};") root_widget = QWidget(parent=self) root_widget.setObjectName("mainContentRoot") diff --git a/src/aare/gui/models/user_sample_model.py b/src/aare/gui/models/user_sample_model.py index c9e08e2e..207234a5 100644 --- a/src/aare/gui/models/user_sample_model.py +++ b/src/aare/gui/models/user_sample_model.py @@ -11,6 +11,7 @@ from aare.gui.styles import ( SAMPLE_STATUS_FLAGGED_BG, SAMPLE_STATUS_MEASURED_BG, SAMPLE_STATUS_QUEUED_BG, + SAMPLE_STATUS_TEXT, qcolor, ) @@ -121,6 +122,14 @@ class UserSampleSpreadsheet(QAbstractTableModel): color = self._status_color(self._sorted_samples[index.row()]) if color is not None: return QBrush(qcolor(color)) + elif role == Qt.ItemDataRole.ForegroundRole: + # Tinted cells get fixed dark ink: the tints stay light pastel in + # BOTH themes, so Sunset's white theme text would vanish on them. + if ( + index.column() == COL_STATUS + and self._status_color(self._sorted_samples[index.row()]) is not None + ): + return QBrush(qcolor(SAMPLE_STATUS_TEXT)) elif role == Qt.ItemDataRole.TextAlignmentRole: # Align text to center return Qt.AlignmentFlag.AlignCenter return None # For other roles, return None diff --git a/src/aare/gui/panels/abr_tweak_panel.py b/src/aare/gui/panels/abr_tweak_panel.py index d2ee5e35..b9f80132 100644 --- a/src/aare/gui/panels/abr_tweak_panel.py +++ b/src/aare/gui/panels/abr_tweak_panel.py @@ -4,7 +4,7 @@ from PySide6.QtCore import Signal, Slot from PySide6.QtGui import Qt from PySide6.QtWidgets import QGridLayout, QLabel, QPushButton, QWidget -from aare.gui.styles import ALERT_TEXT, DEFAULT_TEXT +from aare.gui.styles import ALERT_TEXT from aare.gui.widgets.button_with_payload import ButtonWithPayload from aare.gui.widgets.number_line_edit import NumberLineEdit from aare.gui.widgets.title_label import TitleLabel @@ -148,15 +148,15 @@ class AbrTweakWidget(QWidget): if abs(s.geom.aerotech.x) >= 0.001: self._abr_buttons.gmx_label.setStyleSheet(f"color: {ALERT_TEXT};") else: - self._abr_buttons.gmx_label.setStyleSheet(f"color: {DEFAULT_TEXT};") + self._abr_buttons.gmx_label.setStyleSheet("") self._abr_buttons.gmy_label.setText(f"{s.geom.aerotech_meas.y:.3f}") if abs(s.geom.aerotech.y) >= 0.001: self._abr_buttons.gmy_label.setStyleSheet(f"color: {ALERT_TEXT};") else: - self._abr_buttons.gmy_label.setStyleSheet(f"color: {DEFAULT_TEXT};") + self._abr_buttons.gmy_label.setStyleSheet("") self._abr_buttons.gmz_label.setText(f"{s.geom.aerotech_meas.z:.3f}") if abs(s.geom.aerotech.z) >= 0.001: self._abr_buttons.gmz_label.setStyleSheet(f"color: {ALERT_TEXT};") else: - self._abr_buttons.gmz_label.setStyleSheet(f"color: {DEFAULT_TEXT};") + self._abr_buttons.gmz_label.setStyleSheet("") diff --git a/src/aare/gui/panels/file_path_panel.py b/src/aare/gui/panels/file_path_panel.py index d162ccef..b18521fa 100644 --- a/src/aare/gui/panels/file_path_panel.py +++ b/src/aare/gui/panels/file_path_panel.py @@ -7,7 +7,7 @@ from aarecommon.models.models import DAQStatusModel, SampleShortInfo from PySide6.QtCore import Qt, Signal, Slot from PySide6.QtWidgets import QGridLayout, QLabel, QLineEdit, QMessageBox, QSpinBox, QWidget -from aare.gui.styles import DEFAULT_TEXT, PATH_WARN_TEXT, SURFACE +from aare.gui.styles import PATH_WARN_TEXT from aare.gui.widgets.title_label import TitleLabel ## Logic for filenames: @@ -47,7 +47,6 @@ class FilePathPanel(QWidget): grid_layout.addWidget(QLabel("Directory", parent=self), 1, 0) self.directory_edit = QLineEdit("{date}/{puck}/{pos}", parent=self) - self.directory_edit.setStyleSheet(f"background-color: {SURFACE};") self.directory_edit.setToolTip( "Provide subdirectory for your files. The following macros are allowed:
" "{date} - date in format yyyymmdd
" @@ -61,7 +60,6 @@ class FilePathPanel(QWidget): grid_layout.addWidget(QLabel("File prefix", parent=self), 2, 0) self.file_prefix_edit = QLineEdit("{sample}", parent=self) - self.file_prefix_edit.setStyleSheet(f"background-color: {SURFACE};") self.file_prefix_edit.setToolTip( "Provide file prefix for your files. The following macros are allowed:
" "{date} - date in format yyyymmdd
" @@ -75,7 +73,6 @@ class FilePathPanel(QWidget): grid_layout.addWidget(QLabel("Run number", parent=self), 3, 0) self.run_number_edit = QSpinBox(parent=self) - self.run_number_edit.setStyleSheet(f"background-color: {SURFACE};") self.run_number_edit.setValue(1) self.run_number_edit.setRange(1, 999) self.run_number_edit.setAlignment(Qt.AlignmentFlag.AlignRight) @@ -163,9 +160,9 @@ class FilePathPanel(QWidget): effective = self._effective_dataset_base(self._filename) exists = os.path.exists(f"{effective}_master.h5") or os.path.exists(effective) self.file_name_label.setText(effective + "_master.h5") - self.file_name_label.setStyleSheet( - f"color: {PATH_WARN_TEXT};" if exists else f"color: {DEFAULT_TEXT};" - ) + # Empty stylesheet = reset to the THEME text color (a hardcoded + # "default" black would be invisible on the dark theme). + self.file_name_label.setStyleSheet(f"color: {PATH_WARN_TEXT};" if exists else "") self.path_updated.emit(self._filename) @Slot() diff --git a/src/aare/gui/panels/log_panel.py b/src/aare/gui/panels/log_panel.py index fcb56443..9d1033ea 100644 --- a/src/aare/gui/panels/log_panel.py +++ b/src/aare/gui/panels/log_panel.py @@ -25,6 +25,7 @@ from aare.gui.styles import ( LOG_SUCCESS_BORDER, LOG_WARN_BG, LOG_WARN_BORDER, + TEXT, card_style, ) from aare.gui.widgets.popout_window import DockTitleBar, PopoutWindow @@ -125,9 +126,12 @@ class RuntimeNotificationWidget(QFrame): radius=FLAT_CARD_RADIUS, ) # Transparent children: the app-wide QWidget background rule would - # otherwise paint opaque strips over the card tint. - + " QLabel#runtimeNotificationTitle { font-weight: bold; background: transparent; }" - + " QLabel#runtimeNotificationMessage { background: transparent; }" + # otherwise paint opaque strips over the card tint. Text color is + # pinned dark: the card fills above stay light pastel in BOTH + # themes, so theme-following text goes white-on-cream in Sunset. + + f" QLabel#runtimeNotificationTitle {{ color: {TEXT};" + + " font-weight: bold; background: transparent; }" + + f" QLabel#runtimeNotificationMessage {{ color: {TEXT}; background: transparent; }}" + " QWidget#runtimeNotificationBody { background: transparent; }" ) diff --git a/src/aare/gui/panels/samcam_panel.py b/src/aare/gui/panels/samcam_panel.py index 55e520a3..e7a591ab 100644 --- a/src/aare/gui/panels/samcam_panel.py +++ b/src/aare/gui/panels/samcam_panel.py @@ -13,7 +13,6 @@ from PySide6.QtWidgets import ( QWidget, ) -from aare.gui.styles import INPUT_BG from aare.gui.widgets.title_label import TitleLabel @@ -46,7 +45,6 @@ class SamcamPanel(QWidget): self.exposure_spinbox = QDoubleSpinBox() self.exposure_spinbox.setRange(0, 1.0) # Adjust range as needed self.exposure_spinbox.setSingleStep(0.001) - self.exposure_spinbox.setStyleSheet(f"QDoubleSpinBox {{ background-color: {INPUT_BG}; }}") self.exposure_spinbox.setDecimals(3) self.exposure_spinbox.valueChanged.connect(self._changed) @@ -54,7 +52,6 @@ class SamcamPanel(QWidget): self.gain_spinbox.setRange(0, 1000) # Adjust range as needed self.gain_spinbox.setSingleStep(1) self.gain_spinbox.setDecimals(1) - self.gain_spinbox.setStyleSheet(f"QDoubleSpinBox {{ background-color: {INPUT_BG}; }}") self.gain_spinbox.valueChanged.connect(self._changed) exposure_gain_layout.addWidget(QLabel("Exposure (s):")) @@ -71,9 +68,6 @@ class SamcamPanel(QWidget): screenshot_filename_label = QLabel("Filename:") self.screenshot_filename_edit = QLineEdit() self.screenshot_filename_edit.setPlaceholderText("optional") - self.screenshot_filename_edit.setStyleSheet( - f"QLineEdit {{ background-color: {INPUT_BG}; }}" - ) screenshot_filename_layout.addWidget(screenshot_filename_label) screenshot_filename_layout.addWidget(self.screenshot_filename_edit) @@ -81,7 +75,6 @@ class SamcamPanel(QWidget): screenshot_message_label = QLabel("Message:") self.screenshot_message_edit = QLineEdit() self.screenshot_message_edit.setPlaceholderText("optional") - self.screenshot_message_edit.setStyleSheet(f"QLineEdit {{ background-color: {INPUT_BG}; }}") screenshot_message_layout.addWidget(screenshot_message_label) screenshot_message_layout.addWidget(self.screenshot_message_edit) diff --git a/src/aare/gui/panels/tell_sample_panel.py b/src/aare/gui/panels/tell_sample_panel.py index b085a098..637c73d8 100644 --- a/src/aare/gui/panels/tell_sample_panel.py +++ b/src/aare/gui/panels/tell_sample_panel.py @@ -20,15 +20,6 @@ from PySide6.QtWidgets import ( from aare.gui.constants import LOGGER_NAME from aare.gui.models.user_sample_model import COL_STATUS, UserSampleSpreadsheet -from aare.gui.styles import ( - CHIP_NEUTRAL_BG, - MUTED_TEXT, - SAMPLE_STATUS_FLAGGED_BG, - SAMPLE_STATUS_MEASURED_BG, - SAMPLE_STATUS_QUEUED_BG, - TAB_FACE_BG, - TEXT, -) from aare.gui.widgets.title_label import TitleLabel logger = setup_logger(LOGGER_NAME) @@ -162,11 +153,11 @@ class TellSamplePanel(QFrame): chip_row.setSpacing(0) self.status_chips = QButtonGroup(self) self.status_chips.setExclusive(True) - for label, key, checked_bg in ( - ("All", None, CHIP_NEUTRAL_BG), - ("Queued", "queued", SAMPLE_STATUS_QUEUED_BG), - ("Flagged", "flagged", SAMPLE_STATUS_FLAGGED_BG), - ("Measured", "measured", SAMPLE_STATUS_MEASURED_BG), + for label, key in ( + ("All", None), + ("Queued", "queued"), + ("Flagged", "flagged"), + ("Measured", "measured"), ): if key == "queued": chip = QueueDropChip(label, self) @@ -188,19 +179,10 @@ class TellSamplePanel(QFrame): chip.setChecked(key is None) chip.setProperty("status_key", key) chip.setCursor(Qt.CursorShape.PointingHandCursor) - chip.setStyleSheet( - # Same font, padding and hover hint as the QTabBar tabs above - # (no bold, default size): unchecked tabs sit 3px lower - # (raised-selection effect), the checked one wears its - # row-tint fill, hover underlines just the text. - f"QPushButton {{ background: {TAB_FACE_BG}; color: {MUTED_TEXT};" - f" border: none;" - f" border-top-left-radius: 4px; border-top-right-radius: 4px;" - f" margin-top: 3px; padding: 4px 14px; }}" - f"QPushButton:hover:!checked {{ color: {TEXT}; text-decoration: underline; }}" - f"QPushButton:checked {{ background: {checked_bg}; color: {TEXT};" - f" margin-top: 0px; padding: 6px 14px 5px 14px; }}" - ) + # Look lives in the per-theme QPushButton#filterChip rules in + # styles.py (status_key picks the checked row-tint fill there) — + # an inline stylesheet here would pin one theme's colors. + chip.setObjectName("filterChip") self.status_chips.addButton(chip) chip_row.addWidget(chip) chip_row.addStretch() diff --git a/src/aare/gui/styles.py b/src/aare/gui/styles.py index 02241a6d..0e0dd335 100644 --- a/src/aare/gui/styles.py +++ b/src/aare/gui/styles.py @@ -1,5 +1,6 @@ from __future__ import annotations +from pathlib import Path from string import Template THEME_ORIGINAL = "original" @@ -15,12 +16,58 @@ THEME_PORTRAIT = "portrait" # -- Light theme ------------------------------------------------------------ BACKGROUND = "#e2e7ee" +# App-wide dusk-sky gradient (sampled from the reference photo): slate blue +# fading through pale grey-lavender into warm cream. Painted once per +# top-level window (QMainWindow/QDialog) while plain child widgets stay +# transparent, so the window reads as ONE continuous sky instead of every +# widget restarting the gradient. Tune the stops here; set all three stops +# to BACKGROUND to get the old flat look back. +BACKGROUND_GRADIENT_TOP = "#84abd9" # RHEL9 window-frame blue (sampled from screenshot) +BACKGROUND_GRADIENT_MID = "#c6cad6" +BACKGROUND_GRADIENT_MID_POS = "0.55" # 0..1 — where the mid stop sits +BACKGROUND_GRADIENT_BOTTOM = "#f6e9dd" +APP_BACKGROUND = ( + "qlineargradient(x1:0, y1:0, x2:0, y2:1," + f" stop:0 {BACKGROUND_GRADIENT_TOP}," + f" stop:{BACKGROUND_GRADIENT_MID_POS} {BACKGROUND_GRADIENT_MID}," + f" stop:1 {BACKGROUND_GRADIENT_BOTTOM})" +) BANNER = "#a2b7e4" BANNER_TEXT = "#F8F8FC" # must contrast with BANNER BANNER_TEXT_SHADOW = "#000000" # soft shadow under banner titles; alpha in TitleLabel +# Banner edge lines (1px left + bottom): each line fades in from transparent, +# peaks at BANNER_EDGE mid-line and fades out at the far end — a soft sheen, +# not a hard rule. BANNER_EDGE is the peak color; "transparent" hides both. +BANNER_EDGE = "#ffffff" +BANNER_EDGE_H = ( # bottom line — fades along the banner width + "qlineargradient(x1:0, y1:0, x2:1, y2:0," + f" stop:0 transparent, stop:0.5 {BANNER_EDGE}, stop:1 transparent)" +) +BANNER_EDGE_V = ( # left line — fades along the banner height + "qlineargradient(x1:0, y1:0, x2:0, y2:1," + f" stop:0 transparent, stop:0.5 {BANNER_EDGE}, stop:1 transparent)" +) TEXT = "#263043" SURFACE = "#f2f2f2" # input fields, cards, group boxes +# Buttons: flat + hairline like the dark theme (the explicit border is what +# switches Qt from bulky native chrome to compact QSS box rendering). +BUTTON_BG = "#f7f9fc" +BUTTON_BORDER = "#c9cfd8" # same tone as FRAME_L3_COLOR, separate knob + +# Spin/combo arrow glyphs: tiny PNGs — this Qt draws neither native glyphs +# nor QSS border-triangles inside styled spin buttons, so image files are +# the only reliable path. Colors live in the PNGs (TEXT / DARK_TEXT at +# generation time); regenerate them if those knobs change. +_GRAPHICS_DIR = Path(__file__).resolve().parent / "graphics" +SPIN_ARROW_UP = (_GRAPHICS_DIR / "spin_arrow_up_light.png").as_posix() +SPIN_ARROW_DOWN = (_GRAPHICS_DIR / "spin_arrow_down_light.png").as_posix() +DARK_SPIN_ARROW_UP = (_GRAPHICS_DIR / "spin_arrow_up_dark.png").as_posix() +DARK_SPIN_ARROW_DOWN = (_GRAPHICS_DIR / "spin_arrow_down_dark.png").as_posix() +# Check marks (PRIMARY blue / dusk gold at generation time): +CHECK_MARK = (_GRAPHICS_DIR / "check_mark_light.png").as_posix() +DARK_CHECK_MARK = (_GRAPHICS_DIR / "check_mark_dark.png").as_posix() + # Borders (all can be "transparent" to hide the line): BORDER = "transparent" # main dividers, e.g. the beamline state bar top line CARD_BORDER = "transparent" # cards / group boxes (Local Contact, automation) @@ -46,6 +93,12 @@ FRAME_L3_COLOR = "#c9cfd8" # left edge lines up with the left-column panels above (Loop centering). DOCK_CONTENT_LEFT_PAD = 10 +# Resize-line hint: dock separators stay invisible until the mouse rests on +# one for SEPARATOR_HINT_DELAY_MS (or a drag starts) — then only the exact +# separator under the cursor fills with SEPARATOR_HINT. The rest/drag gate +# lives in MainWindow.event(); the QSS :hover part picks the one separator. +SEPARATOR_HINT = "rgba(168, 178, 192, 50%)" # scrollbar-track grey @50% + # Tab face fill: the selected Dewar/Auxiliary tab AND the unchecked # All/Queued/Flagged/Measured filter buttons share this background. TAB_FACE_BG = "#ffffff" @@ -88,35 +141,95 @@ STATE_TOGGLE_TEXT = "white" # collapse glyph sitting on the BANNER strip STATE_CURRENT_TEXT = "#1e293b" # same slate as HEADING_TEXT, separate knob STATE_TELL_TEXT = "#374357" # also the status-bar TELL line -# -- Portrait (dark) theme: Catppuccin Macchiato ---------------------------- -# https://catppuccin.com/palette — token names in comments. DARK_BG was set to -# Macchiato mantle by hand, so the rest follows that flavor. -DARK_BG = "#1e2030" # mantle -DARK_TEXT = "#cad3f5" # text -DARK_SURFACE = "#24273a" # base — cards, state panel, scrollbar track -DARK_ELEVATED = "#363a4f" # surface0 — buttons, title strip, idle status pill -DARK_BORDER = "#494d64" # surface1 — card borders, scrollbar handle, button hover fill -DARK_ACCENT = "#8bd5ca" # teal -DARK_ACCENT_HOVER = "#a2ddd5" # teal +20% white — palette has no lighter teal step -DARK_MUTED = "#a5adcb" # subtext0 — secondary text -# Alert banners: full-strength color for border/text, 25%-over-DARK_BG tint -# for bg (Catppuccin defines no alert backgrounds, so these are blends). -DARK_ERROR_BG = "#523a4a" # red 25% over mantle -DARK_ERROR_BORDER = "#ed8796" # red -DARK_ERROR_TEXT = "#ed8796" # red -DARK_SUCCESS_BG = "#404e49" # green 25% over mantle -DARK_SUCCESS_BORDER = "#a6da95" # green -DARK_SUCCESS_TEXT = "#a6da95" # green -DARK_WARNING_BG = "#524d4c" # yellow 25% over mantle -DARK_WARNING_BORDER = "#eed49f" # yellow -DARK_WARNING_TEXT = "#eed49f" # yellow +# -- Sunset (dark) theme: Daemmerung DUSK palette --------------------------- +# Adopted from ~/repos/aaregui2 (focus/theme.py, jdawnduan.com Daemmerung). +# Grouped by ROLE, mirroring the source Palette dataclass, so a later change +# touches one block. The site's translucent glass is flattened to opaque hex +# (this QSS paints widgets opaque); the radial sunset backdrop is NOT +# adopted — it needs the transparent-children scheme the Sunrise theme uses. + +# Surfaces: +DARK_BG = "#15213a" # bg — window backdrop +# Sunset-sky backdrop (reference photo): near-black navy zenith; the fade +# begins at MID_POS (0.55, same as the day theme), runs through the blue +# band low in the window, and the warm glow is squeezed into the last +# stretch below BLUE_POS. Warm tone deliberately dimmer than the photo's +# cream — dusk text is light. Same transparent-children scheme as +# APP_BACKGROUND; set all stops to DARK_BG for a flat backdrop. +DARK_BACKGROUND_GRADIENT_TOP = "#0c1a33" +DARK_BACKGROUND_GRADIENT_MID = "#16294d" # fade onset tone +DARK_BACKGROUND_GRADIENT_MID_POS = "0.55" # 0..1 — where the fade begins +DARK_BACKGROUND_GRADIENT_BLUE = "#2c5f9e" # the blue band +DARK_BACKGROUND_GRADIENT_BLUE_POS = "0.90" # 0..1 — warm glow only below this +DARK_BACKGROUND_GRADIENT_BOTTOM = "#d9b98c" +DARK_APP_BACKGROUND = ( + "qlineargradient(x1:0, y1:0, x2:0, y2:1," + f" stop:0 {DARK_BACKGROUND_GRADIENT_TOP}," + f" stop:{DARK_BACKGROUND_GRADIENT_MID_POS} {DARK_BACKGROUND_GRADIENT_MID}," + f" stop:{DARK_BACKGROUND_GRADIENT_BLUE_POS} {DARK_BACKGROUND_GRADIENT_BLUE}," + f" stop:1 {DARK_BACKGROUND_GRADIENT_BOTTOM})" +) +DARK_PANEL2 = "#0e1728" # panel2 — deepest opaque: menus, popups, tooltips +DARK_SURFACE = "#1c2b4a" # panel — cards, state panel, scrollbar track +DARK_ELEVATED = "#253148" # glass2 (7% white) flattened — buttons, banners +# Solid, not transparent: scroll-area viewports don't composite the window +# gradient on the container's X11 and render BLACK instead. Lighter sky-navy +# so tables don't read near-black against the backdrop. +DARK_TABLE_BG = "#263a61" + +# Hairlines — the site's gold line flattened over bg at its three alphas: +DARK_BORDER_FAINT = "#31313b" # border (14%) — input/button edges +DARK_BORDER = "#423a3c" # border2 (22%) — cards, scrollbar handle +DARK_BORDER_STRONG = "#624c3d" # border3 (38%) — emphasized edges + +# Text ramp (bright -> dim): +DARK_TEXT = "#e9edf4" # text — primary +DARK_SUBTEXT = "#aebccd" # subtext — secondary labels, hints +DARK_MUTED = "#7e8ea4" # muted — tertiary, unselected tabs +DARK_OVERLAY = "#66778f" # overlay — disabled text +DARK_DISABLED = "#3c4a66" # disabled — disabled fills + +# Accents — gold is IDENTITY (titles, highlights), blue is ACTION (primary +# buttons); the site keeps the two apart on purpose: +DARK_ACCENT = "#e0913f" # gold +DARK_ACCENT_HOVER = "#eaa253" # accent2 — brighter gold +DARK_ACCENT_FILL = "#89b4fa" # action blue +DARK_ACCENT_FILL_HOVER = "#9ec2fb" # +10% white, derived (site has no step) +DARK_ON_ACCENT = "#1a1320" # text on either accent fill + +# Banner strips (TitleLabel + beamline state title): gold edge sheen that +# fades in/out like the light theme's BANNER_EDGE_H/V. +DARK_BANNER_EDGE = "#e0913f" +DARK_BANNER_EDGE_H = ( + "qlineargradient(x1:0, y1:0, x2:1, y2:0," + f" stop:0 transparent, stop:0.5 {DARK_BANNER_EDGE}, stop:1 transparent)" +) +DARK_BANNER_EDGE_V = ( + "qlineargradient(x1:0, y1:0, x2:0, y2:1," + f" stop:0 transparent, stop:0.5 {DARK_BANNER_EDGE}, stop:1 transparent)" +) + +# Status — border/text full strength, bg = 25% blend over DARK_BG (the dusk +# palette defines no alert backgrounds, so these are computed blends): +DARK_ERROR_BG = "#48374d" # alarm 25% over bg +DARK_ERROR_BORDER = "#e07a85" # alarm +DARK_ERROR_TEXT = "#e07a85" # alarm +DARK_SUCCESS_BG = "#2f4e5d" # green 25% over bg +DARK_SUCCESS_BORDER = "#7dd6c6" # green +DARK_SUCCESS_TEXT = "#7dd6c6" # green +DARK_WARNING_BG = "#473741" # warn (copper) 25% over bg +DARK_WARNING_BORDER = "#dd7a56" # warn +DARK_WARNING_TEXT = "#dd7a56" # warn # -- Shared chrome (light-theme widgets) ------------------------------------ # Extracted from per-widget literals so the whole app is themeable from this # file. The same hex may appear under two names when the roles differ — # separate knobs on purpose. WHITE = "#ffffff" -DEFAULT_TEXT = "#000000" # labels that reset to plain black +# ONLY for theme-independent light surfaces (tutorial callout). To "reset" a +# themed label, clear its stylesheet ("") so the theme color applies — a +# hardcoded black reset is invisible in the dark theme. +DEFAULT_TEXT = "#000000" NOTE_TEXT = "#555555" # tutorial hints, TELL sample details DIM_TEXT = "#666666" # baton dialog timers HINT_TEXT = "#999999" # baton dialog fine print @@ -204,7 +317,9 @@ SPLASH_BORDER = "#444444" SPLASH_ACCENT = "#0078d7" # -- Numeric inputs --------------------------------------------------------- -INPUT_BG = "#ffffff" +# Translucent, not solid white: the sky gradient shimmers through the field +# while text stays on a light ground. Raise the % for a more solid face. +INPUT_BG = "rgba(255, 255, 255, 33%)" INPUT_INVALID_BG = "#ffd5d5" INPUT_DISABLED_BG = "#f0f0f0" INPUT_DISABLED_INVALID_BG = "#f0e1e1" @@ -218,6 +333,7 @@ STATUS_VACANT = "#ffff00" # baton vacant (was "yellow") STATUS_REQUEST = "#00ffff" # baton request (was "cyan") # -- Beamline state panel --------------------------------------------------- +# The panel paints these in code per DAQ tick (data-driven), not via QSS. STATE_AVAILABLE = "#ed8936" STATE_UNAVAILABLE = "#8c96a5" STATE_MSG_ERROR = "#c81e1e" @@ -238,6 +354,10 @@ SAMPLE_STATUS_QUEUED_BG = "#ffe4c4" # pale orange — waiting in the automation SAMPLE_STATUS_FLAGGED_BG = "#ffd9d9" # pale red — automation failed on this sample SAMPLE_STATUS_MEASURED_BG = "#dcf2e0" # pale green — already has collected data SAMPLE_STATUS_SELECTED_BG = "#d8e8fd" # pale blue — table selection highlight +# Fixed ink on the pastel tints above: the tints stay light in BOTH themes, +# so theme-following text (white in Sunset) would vanish on them. Models +# return this as ForegroundRole wherever they return a tint. +SAMPLE_STATUS_TEXT = "#263043" # -- Camera / video overlay (painter colors, alpha at call site) ------------ BEAM_OPEN = "#00ff00" # beam marker: shutter open @@ -344,18 +464,26 @@ FONT_FINE = "11px" # fine print, queue titles # -- Hover tooltips (the QToolTip popup; styled borderless) ----------------- TOOLTIP_BG = "#f7f9fc" TOOLTIP_FG = "#263043" -DARK_TOOLTIP_BG = "#363a4f" # surface0 -DARK_TOOLTIP_FG = "#cad3f5" # text +DARK_TOOLTIP_BG = "#0e1728" # dusk panel2 — deepest opaque (menus/tooltips) +DARK_TOOLTIP_FG = "#e9edf4" # dusk text # -- Sliders ---------------------------------------------------------------- # Own knob instead of PRIMARY: full-saturation button blue was too loud for a # passive fill (illumination panel). Muted slate-blue, tweak freely. SLIDER_FILL = "#8ba3c7" -# -- Scrollbars (rounded, no arrows: grey track, darker draggable handle) --- -SCROLLBAR_TRACK = "#d8dde5" -SCROLLBAR_HANDLE = "#a8b2c0" -SCROLLBAR_HANDLE_HOVER = "#8794a6" +# -- Scrollbars (rounded, no arrows) ---------------------------------------- +# Flipped on request: the track is now the darker grey and the draggable +# handle the light one; hover therefore lightens further instead of darkening. +SCROLLBAR_TRACK = "#a8b2c0" +SCROLLBAR_HANDLE = "#d8dde5" +SCROLLBAR_HANDLE_HOVER = "#eef1f6" + +# Disabled-input fill and the slider colors used to borrow the scrollbar +# knobs; own knobs so the scrollbar flip above doesn't drag them along. +DISABLED_INPUT_BG = "#d8dde5" +SLIDER_TRACK = "#d8dde5" # groove +SLIDER_MUTED = "#a8b2c0" # handle border + disabled fill # -- Cards ------------------------------------------------------------------ CARD_RADIUS = "12px" # one radius for every card-shaped frame @@ -413,15 +541,127 @@ def build_app_stylesheet(theme: str) -> str: def _original_stylesheet() -> str: return Template(""" - QMainWindow, QWidget { - background-color: $background; + /* Dusk-sky gradient: only top-level windows paint it (rule order + matters — this must come AFTER the transparent QWidget rule so it + wins the specificity tie). Knobs: BACKGROUND_GRADIENT_* above. */ + QWidget { + background-color: transparent; color: $text; } + QMainWindow, QDialog, PopoutWindow, + QDockWidget[floating="true"] { + background: $app_background; + } + + /* These used to take the flat fill from the global QWidget rule; now + that it is transparent they need an opaque face of their own + (buttons, inputs, headers) or an opaque popup canvas (menus, + combo dropdown lists). */ + QHeaderView::section, QMenu, + QComboBox QAbstractItemView { + background-color: $background; + } + + /* Text views (console log): transparent, SAME as the dark theme — keep + the two sheets' transparency decisions in lockstep. */ + QTextEdit, QPlainTextEdit { + background-color: transparent; + } + + /* Flat compact buttons, mirroring the dark theme's elevated+hairline + look — the explicit border drops the padded native chrome. */ + QPushButton, QToolButton, QComboBox { + background-color: $button_bg; + border: 1px solid $button_border; + } + + /* Inputs — centralized (was per-widget INPUT_BG stylesheets, which + pinned light fills into the dark theme). Read-only is the QSS + pseudo-class; "invalid" is a dynamic property set by NumberLineEdit. + The explicit border drops the tall native input chrome — same + compact QSS box rendering the dark theme gets. */ + QLineEdit, QAbstractSpinBox { + background-color: $input_bg; + border: 1px solid $button_border; + } + + QLineEdit:read-only, QAbstractSpinBox:read-only { + background-color: $input_disabled_bg; + } + + QLineEdit[invalid="true"] { + background-color: $input_invalid_bg; + } + + QLineEdit[invalid="true"]:read-only { + background-color: $input_disabled_invalid_bg; + } + + /* Spinboxes: both bare arrows adjacent on the right — up inboard, down + at the outer edge (wide fields put opposite-side arrows miles apart). + No button face or frame, the box's own glass is the whole control; + the value region spans everything left of the pair. Arrows are PNG + assets (SPIN_ARROW_*) — this Qt draws neither native glyphs nor + border-triangles inside styled buttons. */ + QAbstractSpinBox { + padding-left: 6px; + padding-right: 34px; + } + + QAbstractSpinBox::down-button { + subcontrol-origin: border; + subcontrol-position: center right; + width: 16px; + background: transparent; + border: none; + } + + QAbstractSpinBox::up-button { + subcontrol-origin: border; + subcontrol-position: center right; + left: -16px; + width: 16px; + background: transparent; + border: none; + } + + QAbstractSpinBox::down-arrow { + image: url($spin_arrow_down); + } + + QAbstractSpinBox::up-arrow { + image: url($spin_arrow_up); + } + + /* Bare dropdown: no framed native button around the combo arrow. */ + QComboBox::drop-down { + border: none; + background: transparent; + } + + QComboBox::down-arrow { + image: url($spin_arrow_down); + } + + /* The value area is a QLineEdit INSIDE the spinbox — left alone it + stacks its own INPUT_BG glass on the spinbox's, reading near-solid + white. The box already signals "editable"; one glass layer is enough. */ + QAbstractSpinBox QLineEdit { + background: transparent; + } + + /* Table select-all corner: QHeaderView::section above doesn't match it, + and unpainted it renders black on the container's X11. */ + QTableCornerButton::section { + background-color: $background; + border: none; + } + QWidget#mainContentRoot, QWidget#standardMainPage, QWidget#compactAutomationPage { - background-color: $background; + background-color: transparent; } QWidget#portraitModePage { @@ -438,7 +678,7 @@ def _original_stylesheet() -> str: } QFrame#compactAutomationPanel { - background: $background; + background: transparent; border: none; border-radius: 18px; } @@ -587,6 +827,25 @@ def _original_stylesheet() -> str: font-weight: bold; } + /* Check/radio indicators: explicit QSS boxes, both SQUARE for + consistency — hollow = unchecked, accent-filled = checked. Native + indicator glyphs are unreliable on this Qt once anything nearby is + styled (same story as the spin arrows). */ + QCheckBox::indicator, QRadioButton::indicator { + width: 12px; + height: 12px; + background: $input_bg; + border: 1px solid $scrollbar_track; + } + + QCheckBox::indicator:checked, QRadioButton::indicator:checked { + image: url($check_mark); + } + + QCheckBox::indicator:disabled, QRadioButton::indicator:disabled { + background: $disabled_input_bg; + } + /* Disabled = baton-gated ("watch only"): the explicit colors above mask Qt's native grey, so spell the greyed state out. */ QPushButton:disabled, QCheckBox:disabled, QRadioButton:disabled, @@ -596,11 +855,11 @@ def _original_stylesheet() -> str: } QLineEdit:disabled, QAbstractSpinBox:disabled, QComboBox:disabled { - background: $scrollbar_track; + background: $disabled_input_bg; } QSlider::sub-page:horizontal:disabled { - background: $scrollbar_handle; + background: $slider_muted; } QTabWidget::pane { @@ -640,10 +899,60 @@ def _original_stylesheet() -> str: text-decoration: underline; } - QMainWindow::separator { - background: $border; - width: 4px; - height: 4px; + /* Sample-list status filter chips — tab-shaped buttons under the Dewar/ + Auxiliary tabs (objectName filterChip, set in tell_sample_panel). + Shell mirrors QTabBar::tab above; the checked chip wears its row-tint + color, doubling as the legend. Per-theme HERE, not inline, so the + dark theme can restyle them. */ + QPushButton#filterChip { + background: $tab_face_bg; + color: $muted_text; + border: none; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + margin-top: 3px; + padding: 4px 14px; + } + + QPushButton#filterChip:hover:!checked { + color: $text; + text-decoration: underline; + } + + QPushButton#filterChip:checked { + background: $chip_neutral_bg; + color: $text; + margin-top: 0px; + padding: 6px 14px 5px 14px; + } + + QPushButton#filterChip[status_key="queued"]:checked { background: $sample_status_queued_bg; } + QPushButton#filterChip[status_key="flagged"]:checked { background: $sample_status_flagged_bg; } + QPushButton#filterChip[status_key="measured"]:checked { background: $sample_status_measured_bg; } + + /* In-panel section headings (section_title in title_label.py). */ + QLabel#sectionTitle { + background: transparent; + color: $muted_text; + font-size: $font_hint; + font-weight: 700; + } + + /* No base ::separator rule ON PURPOSE: any QSS fill would replace the + native dotted-grip drawing, and the dots (visible in the dark theme, + which never styled separators) are wanted in both themes. */ + + /* Resize-line hint — the separatorHint property is flipped by + MainWindow.event() after a 1s hover rest or on press; :hover limits + the fill to the exact separator being dragged. */ + QMainWindow[separatorHint="true"]::separator:hover { + background: $separator_hint; + } + + /* Splitter handles (prediction metrics) are plain child widgets the + property gate above doesn't reach — immediate hover/press hint. */ + QSplitter::handle:hover, QSplitter::handle:pressed { + background: $separator_hint; } QFrame#beamlineControls, @@ -652,7 +961,7 @@ def _original_stylesheet() -> str: } QFrame#beamlineStatePanel { - background: $background; + background: transparent; border-top: 1px solid $border; } @@ -661,6 +970,20 @@ def _original_stylesheet() -> str: color: $banner_text; font-size: $font_title; font-weight: 700; + border-left: 1px solid $banner_edge_v; + border-bottom: 1px solid $banner_edge_h; + } + + /* Panel banners — styled per-theme HERE, not on the widget (a widget + stylesheet would win and pin this light banner into the dark theme). + TitleLabel hand-paints its text from the QSS-resolved palette color. */ + TitleLabel { + background-color: $banner; + color: $banner_text; + font-size: $font_title; + font-weight: 700; + border-left: 1px solid $banner_edge_v; + border-bottom: 1px solid $banner_edge_h; } /* Bare glyph to match the TitleLabel toggles: no pill background. */ @@ -699,8 +1022,9 @@ def _original_stylesheet() -> str: border: $frame_l2_width solid $frame_l2_color; } + /* No L3 border — matches the dark theme (borderless data views). */ QTableView, QPlainTextEdit { - border: $frame_l3_width solid $frame_l3_color; + border: none; } /* Pale-blue selection with readable dark text in every sample table; @@ -736,7 +1060,7 @@ def _original_stylesheet() -> str: WheelValueGuard. */ QSlider::groove:horizontal { height: 6px; - background: $scrollbar_track; + background: $slider_track; border-radius: 3px; } @@ -747,7 +1071,7 @@ def _original_stylesheet() -> str: QSlider::handle:horizontal { background: $white; - border: 1px solid $scrollbar_handle; + border: 1px solid $slider_muted; width: 14px; margin: -5px 0; border-radius: 7px; @@ -839,23 +1163,187 @@ def _original_stylesheet() -> str: def _portrait_stylesheet() -> str: return Template(""" - QMainWindow, QWidget { - background: $dark_bg; + /* Sunset-sky gradient — same transparent-children scheme as the light + theme: only top-level windows paint the sky (rule order matters, see + the light-theme note). Knobs: DARK_BACKGROUND_GRADIENT_* above. */ + QWidget { + background-color: transparent; color: $dark_text; } - QWidget#mainContentRoot, - QWidget#standardMainPage, - QWidget#compactAutomationPage, + QMainWindow, QDialog, PopoutWindow, + QDockWidget[floating="true"] { + background: $dark_app_background; + } + QWidget#portraitModePage { background: $dark_bg; } + /* Interactive faces sit one step above the backdrop (site: glass2) + with the faint gold hairline. */ + QPushButton, QToolButton, QComboBox, + QLineEdit, QAbstractSpinBox { + background-color: $dark_elevated; + border: 1px solid $dark_border_faint; + } + + /* Text views (console log) read fine straight on the sky. */ + QTextEdit, QPlainTextEdit { + background-color: transparent; + } + + /* Menus/popups use the deepest opaque surface (site: panel2). */ + QMenu, + QComboBox QAbstractItemView { + background-color: $dark_panel2; + } + + QHeaderView::section { + background-color: $dark_surface; + color: $dark_text; + border: none; + } + + /* Check/radio indicators, dark flavor — square boxes, gold = checked + (see the light-theme note). */ + QCheckBox::indicator, QRadioButton::indicator { + width: 12px; + height: 12px; + background: transparent; + border: 1px solid $dark_muted; + } + + QCheckBox::indicator:checked, QRadioButton::indicator:checked { + image: url($dark_check_mark); + } + + QCheckBox::indicator:disabled, QRadioButton::indicator:disabled { + background: $dark_disabled; + } + + /* Disabled = baton-gated: overlay text on the disabled fill. */ + QPushButton:disabled, QCheckBox:disabled, QRadioButton:disabled, + QLabel:disabled, QComboBox:disabled, QLineEdit:disabled, + QAbstractSpinBox:disabled, QTabBar::tab:disabled { + color: $dark_overlay; + } + + QLineEdit:disabled, QAbstractSpinBox:disabled, QComboBox:disabled { + background: $dark_disabled; + } + + /* Input states, dark flavor — see the light-theme note. */ + QLineEdit:read-only, QAbstractSpinBox:read-only { + background-color: $dark_disabled; + } + + QLineEdit[invalid="true"] { + background-color: $dark_error_bg; + } + + /* Spinboxes: both bare arrows adjacent on the right (up inboard, down + outermost) — see the light-theme note. Arrows are the + DARK_SPIN_ARROW_* PNG assets. */ + QAbstractSpinBox { + padding-left: 6px; + padding-right: 34px; + } + + QAbstractSpinBox::down-button { + subcontrol-origin: border; + subcontrol-position: center right; + width: 16px; + background: transparent; + border: none; + } + + QAbstractSpinBox::up-button { + subcontrol-origin: border; + subcontrol-position: center right; + left: -16px; + width: 16px; + background: transparent; + border: none; + } + + QAbstractSpinBox::down-arrow { + image: url($dark_spin_arrow_down); + } + + QAbstractSpinBox::up-arrow { + image: url($dark_spin_arrow_up); + } + + /* Bare dropdown — see the light-theme note. */ + QComboBox::drop-down { + border: none; + background: transparent; + } + + QComboBox::down-arrow { + image: url($dark_spin_arrow_down); + } + + /* Single glass layer for the embedded value edit — see light-theme note. */ + QAbstractSpinBox QLineEdit { + background: transparent; + } + + /* Filter chips, dark flavor: same shell as the Dewar/Auxiliary tabs, + but the checked chip keeps its light row-tint fill (the legend role) + with the fixed dark ink the tints require. */ + QPushButton#filterChip { + background: transparent; + color: $dark_muted; + border: none; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + margin-top: 3px; + padding: 4px 14px; + } + + QPushButton#filterChip:hover:!checked { + color: $dark_text; + text-decoration: underline; + } + + QPushButton#filterChip:checked { + background: $chip_neutral_bg; + color: $sample_status_text; + margin-top: 0px; + padding: 6px 14px 5px 14px; + } + + QPushButton#filterChip[status_key="queued"]:checked { background: $sample_status_queued_bg; } + QPushButton#filterChip[status_key="flagged"]:checked { background: $sample_status_flagged_bg; } + QPushButton#filterChip[status_key="measured"]:checked { background: $sample_status_measured_bg; } + + /* In-panel section headings — gold, matching the compact page titles. */ + QLabel#sectionTitle { + background: transparent; + color: $dark_accent; + font-size: $font_hint; + font-weight: 700; + } + + /* Panel banners — styled per-theme HERE, not on the widget (a widget + stylesheet would win and pin the light banner into this theme). Gold + identity text, hand-painted by TitleLabel from the QSS palette. */ + TitleLabel { + background-color: $dark_elevated; + color: $dark_accent; + font-size: $font_title; + font-weight: 700; + border-left: 1px solid $dark_banner_edge_v; + border-bottom: 1px solid $dark_banner_edge_h; + } + QTabWidget::pane, QScrollArea, QDockWidget, QDockWidget > QWidget { - background: $dark_bg; + background: transparent; color: $dark_text; } @@ -893,7 +1381,7 @@ def _portrait_stylesheet() -> str: } QFrame#compactAutomationPanel { - background: $dark_bg; + background: transparent; border: none; border-radius: 18px; } @@ -917,13 +1405,13 @@ def _portrait_stylesheet() -> str: QLabel#compactSectionHint { background: transparent; - color: $dark_muted; + color: $dark_subtext; font-size: $font_hint; } QLabel#compactQueueTitle { background: transparent; - color: $dark_muted; + color: $dark_subtext; font-size: $font_fine; font-weight: 700; } @@ -950,8 +1438,8 @@ def _portrait_stylesheet() -> str: } QPushButton#compactPrimaryButton { - background: $dark_accent; - color: $dark_bg; + background: $dark_accent_fill; + color: $dark_on_accent; border: none; border-radius: 14px; padding: 14px 18px; @@ -960,7 +1448,7 @@ def _portrait_stylesheet() -> str: } QPushButton#compactPrimaryButton:hover { - background: $dark_accent_hover; + background: $dark_accent_fill_hover; } QPushButton#compactSecondaryButton, @@ -1038,20 +1526,22 @@ def _portrait_stylesheet() -> str: QLabel#axisVideoStatusLabel { background-color: transparent; - color: $dark_muted; + color: $dark_subtext; font-weight: bold; } QFrame#beamlineStatePanel { - background: $dark_surface; - border-top: 1px solid $dark_border; + background: transparent; + border-top: 1px solid transparent; } QLabel#beamlineStateTitle { background-color: $dark_elevated; - color: $dark_text; + color: $dark_accent; font-size: $font_title; font-weight: 700; + border-left: 1px solid $dark_banner_edge_v; + border-bottom: 1px solid $dark_banner_edge_h; } /* Bare glyph to match the TitleLabel toggles: no pill background. */ @@ -1072,35 +1562,57 @@ def _portrait_stylesheet() -> str: } QLabel#beamlineStateTellLabel { - color: $dark_muted; + color: $dark_subtext; font-size: $font_body_lg; font-weight: 700; padding-left: 4px; background: transparent; } + /* Resize-line hint, dark flavor — see the light-theme note. */ + QMainWindow[separatorHint="true"]::separator:hover { + background: $dark_accent; + } + + QSplitter::handle:hover, QSplitter::handle:pressed { + background: $dark_accent; + } + /* Plain scroll containers stay frameless. */ QScrollArea { border: none; } - /* Box-frame levels — weights/colors are knobs in styles.py. */ + /* Box-frame levels — weights/colors are knobs in styles.py. Opaque + fill on purpose: left transparent, the panel band (behind the filter + chips) renders BLACK on the container's non-composited X11 — same + trap as DARK_TABLE_BG. */ TellSamplePanel, ReferenceToolsPanel, SampleQueuePanel { border: $frame_l2_width solid $frame_l2_color; + background: $dark_surface; } + /* No L3 border here: the light theme's pale hairline read as a white + frame around dark tables. */ QTableView, QPlainTextEdit { - border: $frame_l3_width solid $frame_l3_color; + border: none; } - /* Selection + staggered rows, dark flavor. */ + /* Selection + staggered rows, dark flavor. Solid fills on purpose — + a transparent viewport renders black here (see DARK_TABLE_BG). */ QTableView { - background: $dark_bg; + background: $dark_table_bg; alternate-background-color: $dark_surface; selection-background-color: $sample_status_selected_bg; selection-color: $text; } + /* Table select-all corner — see the light-theme note. */ + QTableCornerButton::section { + background-color: $dark_surface; + border: none; + } + /* Selection highlight — same banner-blue knob as the light theme. */ QListView, QTreeView, QComboBox QAbstractItemView { diff --git a/src/aare/gui/widgets/login.py b/src/aare/gui/widgets/login.py index 0f71f1f8..758e7510 100644 --- a/src/aare/gui/widgets/login.py +++ b/src/aare/gui/widgets/login.py @@ -7,7 +7,7 @@ from PySide6.QtCore import QByteArray, QUrl, QUrlQuery, Slot from PySide6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QLineEdit, QPushButton, QVBoxLayout -from aare.gui.styles import BACKGROUND +from aare.gui.styles import APP_BACKGROUND class LoginDialog(QDialog): @@ -16,7 +16,7 @@ class LoginDialog(QDialog): self.token = "" self.setWindowTitle("User Authentication") self.setMinimumWidth(400) - self.setStyleSheet(f"background-color: {BACKGROUND};") + self.setStyleSheet(f"background-color: {APP_BACKGROUND};") self._base_url = base_url self._reply = None self._network_manager = None diff --git a/src/aare/gui/widgets/number_line_edit.py b/src/aare/gui/widgets/number_line_edit.py index 802023b5..98f22441 100644 --- a/src/aare/gui/widgets/number_line_edit.py +++ b/src/aare/gui/widgets/number_line_edit.py @@ -2,10 +2,12 @@ from PySide6.QtCore import Qt, Signal, Slot from PySide6.QtGui import QDoubleValidator from PySide6.QtWidgets import QCheckBox, QHBoxLayout, QLineEdit, QWidget -from aare.gui.styles import INPUT_BG, INPUT_DISABLED_BG, INPUT_DISABLED_INVALID_BG, INPUT_INVALID_BG - class NumberLineEdit(QLineEdit): + """Colors are centralized: the per-theme INPUT rules in styles.py key on + the :read-only pseudo-class and the "invalid" dynamic property set here — + no inline stylesheets, so both themes restyle these fields.""" + newValue = Signal(float) def __init__( @@ -15,8 +17,6 @@ class NumberLineEdit(QLineEdit): self._read_only: bool = False self._is_valid: bool = True - self.setStyleSheet(f"background-color: {INPUT_BG};") - # Use a QDoubleValidator to only allow valid floating point numbers self.validator = QDoubleValidator() self.validator.setNotation(QDoubleValidator.Notation.StandardNotation) @@ -39,14 +39,19 @@ class NumberLineEdit(QLineEdit): format_string = f"{{:.{self.decimal_count}f}}" return format_string.format(i) + def _set_invalid(self, invalid: bool) -> None: + if self.property("invalid") == invalid: + return + self.setProperty("invalid", invalid) + # Property selectors are only re-evaluated on repolish. + self.style().unpolish(self) + self.style().polish(self) + @Slot(str) def on_text_changed(self, text: str): # when text changes check validation and change the colour of the line edit self._is_valid = self.validate(text) - if self._is_valid: - self.setStyleSheet(f"background-color: {INPUT_BG};") - else: - self.setStyleSheet(f"background-color: {INPUT_INVALID_BG};") + self._set_invalid(not self._is_valid) @Slot() def on_editing_finished(self): @@ -84,22 +89,10 @@ class NumberLineEdit(QLineEdit): return self.validator.validate(str(text), 0)[0] == QDoubleValidator.State.Acceptable def setReadOnly(self, ro: bool): - # change state of read only and change colour of line edit based on read only state and validator + # Colors follow via the QSS :read-only pseudo-class (updates without + # a repolish); validity is already tracked by the invalid property. super().setReadOnly(ro) self._read_only = ro - if self._read_only and self._is_valid: - self.setStyleSheet(f"background-color: {INPUT_DISABLED_BG};") - elif not self._read_only and self._is_valid: - self.setStyleSheet(f"background-color: {INPUT_BG};") - elif self._read_only and not self._is_valid: - self.setStyleSheet(f"background-color: {INPUT_DISABLED_INVALID_BG};") - elif not self._read_only and not self._is_valid: - self.setStyleSheet(f"background-color: {INPUT_INVALID_BG};") - else: - print( - f"unknown ro state: {self._read_only} or validity {self._is_valid} default to writeable" - ) - self.setStyleSheet(f"background-color: {INPUT_BG};") def get_default(self) -> float: return float(self.initial_value) @@ -167,15 +160,14 @@ class CheckedLineEdit(QWidget): self.setReadOnly() self.check_box.blockSignals(True) + # No inline checkbox fills: the theme's :disabled rules grey it. if self._busy: self.check_box.setEnabled(False) - self.check_box.setStyleSheet(f"background-color: {INPUT_DISABLED_BG};") if self._checked: self.editor.force_update_value(self._internal_value) else: self.check_box.setEnabled(True) - self.check_box.setStyleSheet(f"background-color: {INPUT_BG};") self.check_box.blockSignals(False) self.blockSignals(False) diff --git a/src/aare/gui/widgets/title_label.py b/src/aare/gui/widgets/title_label.py index 13fc734b..bf1d15d6 100644 --- a/src/aare/gui/widgets/title_label.py +++ b/src/aare/gui/widgets/title_label.py @@ -1,17 +1,8 @@ from PySide6.QtCore import QSettings, Qt, QTimer -from PySide6.QtGui import QPainter +from PySide6.QtGui import QPainter, QPalette from PySide6.QtWidgets import QHBoxLayout, QLabel, QLayout, QPushButton, QStyle, QStyleOption -from aare.gui.styles import ( - BANNER, - BANNER_TEXT, - BANNER_TEXT_SHADOW, - FONT_BODY, - FONT_HINT, - FONT_TITLE, - MUTED_TEXT, - qcolor, -) +from aare.gui.styles import BANNER_TEXT, BANNER_TEXT_SHADOW, FONT_BODY, qcolor # Universal vertical rhythm between stacked panels: each panel contributes # PANEL_VMARGIN top and bottom, the column adds PANEL_VSPACING between them, @@ -38,12 +29,11 @@ def tighten_column(layout: QLayout) -> None: def section_title(text: str, parent=None) -> QLabel: """Small in-panel section heading — for controls grouped under one - shared TitleLabel banner (e.g. the Beam Config. panel).""" + shared TitleLabel banner (e.g. the Beam Config. panel). Look lives in + the per-theme QLabel#sectionTitle rules in styles.py.""" label = QLabel(text, parent) + label.setObjectName("sectionTitle") label.setAlignment(Qt.AlignmentFlag.AlignCenter) - label.setStyleSheet( - f"color: {MUTED_TEXT}; font-size: {FONT_HINT}; font-weight: 700; background: transparent;" - ) return label @@ -55,13 +45,9 @@ class TitleLabel(QLabel): # 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( - f"TitleLabel {{ background-color: {BANNER}; color: {BANNER_TEXT};" - f" font-size: {FONT_TITLE}; font-weight: 700; }}" - ) + # No widget stylesheet: the banner look lives in the per-theme + # TitleLabel rules in styles.py — a stylesheet set here would win + # over the theme and pin the light banner into the dark theme. self.setAlignment(Qt.AlignmentFlag.AlignCenter) # Half the original 50px: the full-height banner wasted vertical space. @@ -125,7 +111,9 @@ class TitleLabel(QLabel): ) painter.setPen(qcolor(BANNER_TEXT_SHADOW, 110)) painter.drawText(rect.translated(0, 1), flags, text) - painter.setPen(qcolor(BANNER_TEXT)) + # QSS-resolved 'color' (per-theme TitleLabel rule), not a constant — + # light paints banner white, dark paints dusk gold. + painter.setPen(self.palette().color(QPalette.ColorRole.WindowText)) painter.drawText(rect, flags, text) def mousePressEvent(self, event): -- 2.54.0 From 7801025802d66291d1bc46177dd1a820ffb6dbd2 Mon Sep 17 00:00:00 2001 From: Dawn Date: Sat, 8 Aug 2026 15:06:23 +0200 Subject: [PATCH 34/57] feat: theme-aware beamline state panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel paints availability/message colors in code per DAQ tick, so QSS can't retint it — state_colors(theme) hands it a per-theme dict (Catppuccin Mocha inks for Sunset) and _apply_theme pushes theme flips. Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 2 + src/aare/gui/panels/beamline_state_panel.py | 41 +++++++++++++-------- src/aare/gui/styles.py | 26 ++++++++++++- 3 files changed, 53 insertions(+), 16 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index de2755eb..2b15e032 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -1683,6 +1683,8 @@ class MainWindow(QMainWindow): def _apply_theme(self) -> None: self.setStyleSheet(build_app_stylesheet(self._theme_mode)) + # State colors are painted in code per DAQ tick — QSS can't reach them. + self.beamline_state_panel.set_theme(self._theme_mode) def _restore_theme_settings(self) -> None: settings = QSettings("PSI", "AareGUI") diff --git a/src/aare/gui/panels/beamline_state_panel.py b/src/aare/gui/panels/beamline_state_panel.py index db372118..c3aa89aa 100644 --- a/src/aare/gui/panels/beamline_state_panel.py +++ b/src/aare/gui/panels/beamline_state_panel.py @@ -5,13 +5,7 @@ from PySide6.QtCore import Qt, QTimer, Signal, Slot from PySide6.QtGui import QCursor, QFont, QFontMetrics from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QMenu, QPushButton, QSizePolicy, QToolTip -from aare.gui.styles import ( - FONT_VALUE, - STATE_AVAILABLE, - STATE_MSG_ERROR, - STATE_MSG_INFO, - STATE_UNAVAILABLE, -) +from aare.gui.styles import FONT_VALUE, THEME_ORIGINAL, state_colors # Shortcut transitions from the "Available transitions" menu in # widgets/status_bar.py show_state_menu — these come ON TOP of the one-hop @@ -146,8 +140,9 @@ class BeamlineStatePanel(QFrame): self._hover_hint_timer.setInterval(3000) self._hover_hint_timer.timeout.connect(self._show_hover_hint) - self._available_color = STATE_AVAILABLE - self._unavailable_color = STATE_UNAVAILABLE + # Per-theme colors (MainWindow._apply_theme calls set_theme). + self._colors = state_colors(THEME_ORIGINAL) + self._separators: list[QLabel] = [] layout = QHBoxLayout(self) layout.setContentsMargins(10, 2, 10, 2) @@ -159,9 +154,8 @@ class BeamlineStatePanel(QFrame): for index, (state, label) in enumerate(self._ENTRIES): if index: separator = QLabel("–", self) - separator.setStyleSheet( - f"color: {STATE_UNAVAILABLE}; background: transparent; border: none; font-size: {FONT_VALUE};" - ) + self._style_separator(separator) + self._separators.append(separator) layout.addWidget(separator) button = HoverableButton(label, self) button.setFlat(True) @@ -310,6 +304,21 @@ class BeamlineStatePanel(QFrame): elif state == BeamlineStateEnum.XrayFluorescence: self.xray_fluorescence.emit() + def _style_separator(self, separator: QLabel) -> None: + separator.setStyleSheet( + f"color: {self._colors['unavailable']};" + f" background: transparent; border: none; font-size: {FONT_VALUE};" + ) + + def set_theme(self, theme: str) -> None: + """Adopt the theme's state colors (MainWindow._apply_theme calls this + on every switch — the colors are painted in code, so the app QSS + alone cannot restyle them).""" + self._colors = state_colors(theme) + for separator in self._separators: + self._style_separator(separator) + self._apply_highlight() + def _apply_highlight(self) -> None: available = self._available_targets() for state, button in self._buttons.items(): @@ -325,14 +334,16 @@ class BeamlineStatePanel(QFrame): # No backgrounds, no rounded corners. if is_current or is_pending: color = ( - STATE_MSG_ERROR if state == BeamlineStateEnum.Maintenance else STATE_MSG_INFO + self._colors["error"] + if state == BeamlineStateEnum.Maintenance + else self._colors["info"] ) bold = True elif is_available: - color = self._available_color + color = self._colors["available"] bold = False else: - color = self._unavailable_color + color = self._colors["unavailable"] bold = False # Font set in code (not QSS) so _update_label_mode can measure diff --git a/src/aare/gui/styles.py b/src/aare/gui/styles.py index 0e0dd335..405cebf2 100644 --- a/src/aare/gui/styles.py +++ b/src/aare/gui/styles.py @@ -333,11 +333,35 @@ STATUS_VACANT = "#ffff00" # baton vacant (was "yellow") STATUS_REQUEST = "#00ffff" # baton request (was "cyan") # -- Beamline state panel --------------------------------------------------- -# The panel paints these in code per DAQ tick (data-driven), not via QSS. +# The panel paints these in code per DAQ tick (data-driven), so it asks +# state_colors(theme) below instead of QSS. Light values unchanged; dark +# values are Catppuccin Mocha so they stay readable on the sunset sky. STATE_AVAILABLE = "#ed8936" STATE_UNAVAILABLE = "#8c96a5" STATE_MSG_ERROR = "#c81e1e" STATE_MSG_INFO = "#005caa" +DARK_STATE_AVAILABLE = "#fab387" # mocha peach +DARK_STATE_UNAVAILABLE = "#7f849c" # mocha overlay1 +DARK_STATE_MSG_ERROR = "#f38ba8" # mocha red +DARK_STATE_MSG_INFO = "#89b4fa" # mocha blue + + +def state_colors(theme: str) -> dict[str, str]: + """Beamline-state text colors for the given theme.""" + if theme == THEME_PORTRAIT: + return { + "available": DARK_STATE_AVAILABLE, + "unavailable": DARK_STATE_UNAVAILABLE, + "error": DARK_STATE_MSG_ERROR, + "info": DARK_STATE_MSG_INFO, + } + return { + "available": STATE_AVAILABLE, + "unavailable": STATE_UNAVAILABLE, + "error": STATE_MSG_ERROR, + "info": STATE_MSG_INFO, + } + # -- Sample tables + raster grid -------------------------------------------- SAMPLE_ROW_ACTIVE_BG = "#ff6600" -- 2.54.0 From ed8b2bd16539d22ad2c0b4b5b37c5dc1ac20044d Mon Sep 17 00:00:00 2001 From: Dawn Date: Sat, 8 Aug 2026 15:07:23 +0200 Subject: [PATCH 35/57] feat: cross-fade and palette flip on theme switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Freeze the old look in a click-through screenshot overlay and fade it out over the restyled window — QSS has no transitions and animating the palette would re-polish every widget per frame. Sunset also flips the app-palette text roles so native primitives QSS can't recolor (spin and combo arrow glyphs) follow the theme. Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 54 +++++++++++++++++++++++++++++++++++-- src/aare/gui/styles.py | 3 +++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 2b15e032..516d680c 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -15,15 +15,25 @@ from aarecommon.models.models import ( SessionsStateEnum, TokenData, ) -from PySide6.QtCore import QEvent, QSettings, Qt, QTimer, Signal, Slot -from PySide6.QtGui import QAction, QActionGroup, QColor, QCursor, QGuiApplication, QKeySequence +from PySide6.QtCore import QEvent, QPropertyAnimation, QSettings, Qt, QTimer, Signal, Slot +from PySide6.QtGui import ( + QAction, + QActionGroup, + QColor, + QCursor, + QGuiApplication, + QKeySequence, + QPalette, +) from PySide6.QtWidgets import ( QApplication, QCheckBox, QDockWidget, QFrame, QGraphicsColorizeEffect, + QGraphicsOpacityEffect, QHBoxLayout, + QLabel, QMainWindow, QMessageBox, QPushButton, @@ -72,10 +82,13 @@ from aare.gui.scan_logic.rotation_scan_manager import RotationScanManager from aare.gui.scan_logic.sample_mount_logic import SampleMountLogic from aare.gui.styles import ( APP_BACKGROUND, + DARK_TEXT, DOCK_CONTENT_LEFT_PAD, + THEME_FADE_MS, THEME_ORIGINAL, THEME_PORTRAIT, build_app_stylesheet, + qcolor, ) # Threads @@ -1682,9 +1695,46 @@ class MainWindow(QMainWindow): self.tutorial_manager.start("manual_workflow_demo") def _apply_theme(self) -> None: + # Screenshot cross-fade (THEME_FADE_MS in styles.py): freeze the old + # look in a click-through overlay, restyle beneath it in one normal + # repolish, fade the overlay out. QSS has no transitions, and + # animating the palette would re-polish every widget per frame. + old_look = self.grab() if self.isVisible() else None + # Native primitives QSS can't recolor (spin/combo arrow glyphs) draw + # from the app palette — flip its text roles with the theme. + app = QApplication.instance() + if not hasattr(self, "_default_palette"): + self._default_palette = app.palette() + if self._theme_mode == THEME_PORTRAIT: + palette = QPalette(self._default_palette) + for role in ( + QPalette.ColorRole.ButtonText, + QPalette.ColorRole.Text, + QPalette.ColorRole.WindowText, + ): + palette.setColor(role, qcolor(DARK_TEXT)) + app.setPalette(palette) + else: + app.setPalette(self._default_palette) self.setStyleSheet(build_app_stylesheet(self._theme_mode)) # State colors are painted in code per DAQ tick — QSS can't reach them. self.beamline_state_panel.set_theme(self._theme_mode) + if old_look is None: + return + overlay = QLabel(self) + overlay.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + overlay.setPixmap(old_look) + overlay.setGeometry(self.rect()) + overlay.raise_() + overlay.show() + effect = QGraphicsOpacityEffect(overlay) + overlay.setGraphicsEffect(effect) + fade = QPropertyAnimation(effect, b"opacity", overlay) + fade.setDuration(THEME_FADE_MS) + fade.setStartValue(1.0) + fade.setEndValue(0.0) + fade.finished.connect(overlay.deleteLater) + fade.start(QPropertyAnimation.DeletionPolicy.DeleteWhenStopped) def _restore_theme_settings(self) -> None: settings = QSettings("PSI", "AareGUI") diff --git a/src/aare/gui/styles.py b/src/aare/gui/styles.py index 405cebf2..fb1cd382 100644 --- a/src/aare/gui/styles.py +++ b/src/aare/gui/styles.py @@ -99,6 +99,9 @@ DOCK_CONTENT_LEFT_PAD = 10 # lives in MainWindow.event(); the QSS :hover part picks the one separator. SEPARATOR_HINT = "rgba(168, 178, 192, 50%)" # scrollbar-track grey @50% +# Theme-switch screenshot cross-fade duration (int ms, used in code). +THEME_FADE_MS = 250 + # Tab face fill: the selected Dewar/Auxiliary tab AND the unchecked # All/Queued/Flagged/Measured filter buttons share this background. TAB_FACE_BG = "#ffffff" -- 2.54.0 From 257edc25f87a88d22a34b2410af47bfdb491eeea Mon Sep 17 00:00:00 2001 From: Dawn Date: Sat, 8 Aug 2026 15:08:18 +0200 Subject: [PATCH 36/57] feat: pointing-hand cursor on all interactive widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QSS cannot set cursors and per-widget setCursor calls get forgotten as widgets are added, so an app-level filter catches every Polish event on buttons, combos, and sliders — dialogs and pop-outs included. Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 516d680c..90e2521c 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -15,7 +15,7 @@ from aarecommon.models.models import ( SessionsStateEnum, TokenData, ) -from PySide6.QtCore import QEvent, QPropertyAnimation, QSettings, Qt, QTimer, Signal, Slot +from PySide6.QtCore import QEvent, QObject, QPropertyAnimation, QSettings, Qt, QTimer, Signal, Slot from PySide6.QtGui import ( QAction, QActionGroup, @@ -26,8 +26,10 @@ from PySide6.QtGui import ( QPalette, ) from PySide6.QtWidgets import ( + QAbstractButton, QApplication, QCheckBox, + QComboBox, QDockWidget, QFrame, QGraphicsColorizeEffect, @@ -39,6 +41,7 @@ from PySide6.QtWidgets import ( QPushButton, QScrollArea, QSizePolicy, + QSlider, QStackedWidget, QTabWidget, QToolBar, @@ -122,6 +125,22 @@ from aare.gui.widgets.wheel_value_guard import WheelValueGuard logger = setup_logger(LOGGER_NAME) +class ClickableCursorFilter(QObject): + """App-wide pointing-hand cursor on every button/combo/slider, present + and future. QSS cannot set cursors, and per-widget setCursor calls are + forgotten whenever a new widget is added — the Polish event fires once + for each widget when its style is applied, so this catches them all. + Widgets that manage their own cursor afterwards (beamline state strip's + forbidden cursor) still win: they set it later.""" + + def eventFilter(self, obj, event): + if event.type() == QEvent.Type.Polish and isinstance( + obj, (QAbstractButton, QComboBox, QSlider) + ): + obj.setCursor(Qt.CursorShape.PointingHandCursor) + return False + + class MainWindow(QMainWindow): sample_geometry = Signal(SampleGeometryModel) @@ -196,6 +215,10 @@ class MainWindow(QMainWindow): self._tutorial_text_resolver = DictionaryTextResolver(MANUAL_MOUNT_TUTORIAL) self.state_manager = UIStateManager("PSI", "AareGUI") + # App-level, not window-level: dialogs and pop-outs get it too. + self._clickable_cursor_filter = ClickableCursorFilter(self) + QApplication.instance().installEventFilter(self._clickable_cursor_filter) + # Wheel safety: sliders/spin boxes/combos only react to the wheel # while the right mouse button is held; a bare wheel just scrolls # the page — it can never nudge a value or move a motor. -- 2.54.0 From f9a6c7bc319bad40c015ac692c6ba7857274c712 Mon Sep 17 00:00:00 2001 From: Dawn Date: Sat, 8 Aug 2026 15:09:30 +0200 Subject: [PATCH 37/57] feat: rest-to-reveal hint for dock separators Separators stay invisible until the mouse rests on one for ~1s (or a drag starts); the QSS ::separator:hover rule does the hit-testing and a dynamic separatorHint property gates it, so event() never needs to know where separators are. Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 43 +++++++++++++++++++++++++++++++++++++ src/aare/gui/styles.py | 1 + 2 files changed, 44 insertions(+) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 90e2521c..781f1b49 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -87,6 +87,7 @@ from aare.gui.styles import ( APP_BACKGROUND, DARK_TEXT, DOCK_CONTENT_LEFT_PAD, + SEPARATOR_HINT_DELAY_MS, THEME_FADE_MS, THEME_ORIGINAL, THEME_PORTRAIT, @@ -251,6 +252,16 @@ class MainWindow(QMainWindow): self.setStyleSheet(f"background-color: {APP_BACKGROUND};") + # Resize-line hint (see SEPARATOR_HINT in styles.py): WA_Hover gives + # us HoverMove without a button held; the single-shot timer implements + # the "rested for 1s" gate. The QSS ::separator:hover rule does the + # hit-testing, so event() never needs to know where separators are. + self.setAttribute(Qt.WidgetAttribute.WA_Hover, True) + self._separator_hint_timer = QTimer(self) + self._separator_hint_timer.setSingleShot(True) + self._separator_hint_timer.setInterval(SEPARATOR_HINT_DELAY_MS) + self._separator_hint_timer.timeout.connect(lambda: self._set_separator_hint(True)) + root_widget = QWidget(parent=self) root_widget.setObjectName("mainContentRoot") root_layout = QVBoxLayout(root_widget) @@ -2871,6 +2882,38 @@ class MainWindow(QMainWindow): if dx or dy: dock.move(geo.x() + dx, geo.y() + dy) + def _set_separator_hint(self, on: bool) -> None: + if self.property("separatorHint") == on: + return + self.setProperty("separatorHint", on) + # Property selectors are only re-evaluated on repolish. + self.style().unpolish(self) + self.style().polish(self) + self.update() + + def event(self, event): + # Separator press/drag never reaches mousePressEvent — QMainWindow + # eats it inside event() to start the separator move — so the hint + # gate must sit here, before super() dispatches. + t = event.type() + if t in (QEvent.Type.HoverEnter, QEvent.Type.HoverMove): + # Any move restarts the 1s countdown ("hover and rest"); during a + # drag (button held) the hint stays on instead. + if QApplication.mouseButtons() == Qt.MouseButton.NoButton: + self._set_separator_hint(False) + self._separator_hint_timer.start() + elif t == QEvent.Type.HoverLeave: + self._separator_hint_timer.stop() + self._set_separator_hint(False) + elif t == QEvent.Type.MouseButtonPress: + # Press shows the line immediately, no 1s wait. + self._separator_hint_timer.stop() + self._set_separator_hint(True) + elif t == QEvent.Type.MouseButtonRelease: + self._set_separator_hint(False) + self._separator_hint_timer.start() + return super().event(event) + def eventFilter(self, obj, event): # Closing a floated (popped-out) dock re-docks it instead of hiding — # otherwise the panel silently disappears and has to be restored via diff --git a/src/aare/gui/styles.py b/src/aare/gui/styles.py index fb1cd382..c139ad6f 100644 --- a/src/aare/gui/styles.py +++ b/src/aare/gui/styles.py @@ -98,6 +98,7 @@ DOCK_CONTENT_LEFT_PAD = 10 # separator under the cursor fills with SEPARATOR_HINT. The rest/drag gate # lives in MainWindow.event(); the QSS :hover part picks the one separator. SEPARATOR_HINT = "rgba(168, 178, 192, 50%)" # scrollbar-track grey @50% +SEPARATOR_HINT_DELAY_MS = 888 # int, used in code, not QSS # Theme-switch screenshot cross-fade duration (int ms, used in code). THEME_FADE_MS = 250 -- 2.54.0 From d127863b1f8fc6907828aef9b8aca26a64bfe03d Mon Sep 17 00:00:00 2001 From: Dawn Date: Sat, 8 Aug 2026 15:10:27 +0200 Subject: [PATCH 38/57] feat: hide operation panels in watch-only mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without the baton the GUI now shows only the camera stream and status bar; every operating surface (panels, docks, pop-out mirrors) is hidden outright. Regaining the baton restores dock layout via saveState/ restoreState — per-dock visibility snapshots lose docks tabbed behind others — so LogDock gains the objectName restoreState requires. Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 36 ++++++++++++++++++++++++++++++++ src/aare/gui/panels/log_panel.py | 2 ++ 2 files changed, 38 insertions(+) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 781f1b49..c1314086 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -2417,6 +2417,42 @@ class MainWindow(QMainWindow): for banner in banners: banner.set_collapsed(True, persist=False) + # Watch-only shows ONLY the camera stream: every other operating + # surface is hidden outright (not just greyed), and regaining the + # baton restores exactly the visibility each one had before. The + # status bar and its SESSION VACANT badge stay — they are the way + # back in. To later hide the camera streams as well, add + # self.video_tab to this list. + # findChildren instead of a hand list: hand-listing missed docks + # (Console Log, Fluorescence, ...) and would again for future ones. + # Covers the pop-out mirrors too — they are operating surfaces. + hide_in_watch_mode = [ + self.left_column_tabs, + self.beamline, + self.beamline_state_panel, + *self.findChildren(PopoutWindow), + ] + if owned: + for widget, was_visible in getattr(self, "_pre_watch_visibility", []): + widget.setVisible(was_visible) + # Docks restore via restoreState, not per-dock setVisible: a dock + # TABBED BEHIND another is isHidden() at snapshot time, so a + # visibility snapshot mistakes it for user-closed and loses it + # (the vanished Sample List). restoreState also brings back + # tab order, the active tab and dock sizes. Needs objectNames + # on every dock. + state = getattr(self, "_pre_watch_dock_state", None) + if state is not None: + self.restoreState(state) + else: + # not isHidden(), NOT isVisible(): the vacant gate first runs + # before the window is shown, where isVisible() is False for + # everything and the restore would then "restore" all-hidden. + self._pre_watch_dock_state = self.saveState() + self._pre_watch_visibility = [(w, not w.isHidden()) for w in hide_in_watch_mode] + for widget in hide_in_watch_mode + self.findChildren(QDockWidget): + widget.hide() + @Slot(DAQStatusModel) def update_daq_status(self, s: DAQStatusModel): self._latest_daq_status = s diff --git a/src/aare/gui/panels/log_panel.py b/src/aare/gui/panels/log_panel.py index 9d1033ea..12289ea4 100644 --- a/src/aare/gui/panels/log_panel.py +++ b/src/aare/gui/panels/log_panel.py @@ -197,6 +197,8 @@ class RuntimeNotificationWidget(QFrame): class LogDock(QDockWidget): def __init__(self, title="Log", parent=None): super().__init__(title, parent) + # saveState/restoreState (watch-mode hide/restore) skips unnamed docks. + self.setObjectName("log_dock") self.setAllowedAreas( Qt.DockWidgetArea.BottomDockWidgetArea | Qt.DockWidgetArea.RightDockWidgetArea -- 2.54.0 From 7f838f4b985c24a5003aa5d559e65acc4b27ce4a Mon Sep 17 00:00:00 2001 From: Dawn Date: Sat, 8 Aug 2026 18:40:23 +0200 Subject: [PATCH 39/57] fix: opaque faces for floating windows and theme-aware pop-out icons A transparent top-level renders black on the container's non-composited X11, so floating docks get a dynamic floating property feeding the QSS [floating="true"] opaque face, and PopoutWindow sets WA_StyledBackground. Pop-out titlebar glyphs are now painted in the palette's WindowText color and re-tint on PaletteChange, so they stay visible after a theme flip. Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 12 +++++- src/aare/gui/widgets/popout_window.py | 60 ++++++++++++++++++++++----- 2 files changed, 60 insertions(+), 12 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index c1314086..2f613ec9 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -2897,7 +2897,17 @@ class MainWindow(QMainWindow): @Slot(bool) def _on_dock_top_level_changed(self, floating: bool) -> None: dock = self.sender() - if floating and isinstance(dock, QDockWidget): + if not isinstance(dock, QDockWidget): + return + # Floating = top-level: a transparent top-level renders black on the + # container's non-composited X11, so the QDockWidget[floating="true"] + # QSS rule gives it an opaque face; docked it goes transparent again + # so the main-window gradient stays continuous. + dock.setProperty("floating", floating) + dock.style().unpolish(dock) + dock.style().polish(dock) + dock.update() + if floating: # Pop-outs open enlarged instead of keeping the cramped docked # size. Deferred: the window is mid-reparent while the signal # fires. diff --git a/src/aare/gui/widgets/popout_window.py b/src/aare/gui/widgets/popout_window.py index ace02255..40fd96b9 100644 --- a/src/aare/gui/widgets/popout_window.py +++ b/src/aare/gui/widgets/popout_window.py @@ -1,23 +1,24 @@ -from PySide6.QtCore import QPoint, QRect, QSize, Qt -from PySide6.QtGui import QCursor, QGuiApplication, QIcon, QPainter, QPen, QPixmap +from PySide6.QtCore import QEvent, QPoint, QRect, QSize, Qt +from PySide6.QtGui import QColor, QCursor, QGuiApplication, QIcon, QPainter, QPalette, QPen, QPixmap from PySide6.QtWidgets import QDockWidget, QHBoxLayout, QLabel, QToolButton, QVBoxLayout, QWidget -from aare.gui.styles import FRAME_L1_COLOR, FRAME_L1_WIDTH, TEXT, qcolor +from aare.gui.styles import FRAME_L1_COLOR, FRAME_L1_WIDTH, qcolor # Title-bar buttons: icon fills the button, both the same size. TITLEBAR_BUTTON_PX = 22 TITLEBAR_ICON_PX = 18 -def _titlebar_icon(kind: str, size: int = TITLEBAR_ICON_PX) -> QIcon: +def _titlebar_icon(kind: str, color: QColor, size: int = TITLEBAR_ICON_PX) -> QIcon: """Hand-painted borderless glyphs — the style's standard title-bar pixmaps draw boxed icons, and text glyphs are missing from the - container's fonts.""" + container's fonts. Color comes from the caller's palette so the glyphs + follow the theme (they are pixmaps, QSS color cannot reach them).""" pixmap = QPixmap(size, size) pixmap.fill(Qt.GlobalColor.transparent) painter = QPainter(pixmap) painter.setRenderHint(QPainter.RenderHint.Antialiasing) - pen = QPen(qcolor(TEXT), 2) + pen = QPen(color, 2) pen.setCapStyle(Qt.PenCapStyle.RoundCap) pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin) painter.setPen(pen) @@ -33,9 +34,9 @@ def _titlebar_icon(kind: str, size: int = TITLEBAR_ICON_PX) -> QIcon: return QIcon(pixmap) -def _titlebar_button(parent: QWidget, kind: str, tooltip: str) -> QToolButton: +def _titlebar_button(parent: QWidget, tooltip: str) -> QToolButton: + # Icon is set by DockTitleBar._tint_icons (initially and on theme change). button = QToolButton(parent) - button.setIcon(_titlebar_icon(kind)) button.setIconSize(QSize(TITLEBAR_ICON_PX, TITLEBAR_ICON_PX)) button.setFixedSize(TITLEBAR_BUTTON_PX, TITLEBAR_BUTTON_PX) button.setAutoRaise(True) @@ -66,15 +67,30 @@ class DockTitleBar(QWidget): layout.addStretch(1) self.popout_button = _titlebar_button( - self, "popout", "Open in a separate window (the panel stays here too)" + self, "Open in a separate window (the panel stays here too)" ) self.popout_button.clicked.connect(on_popout) layout.addWidget(self.popout_button) - close_button = _titlebar_button(self, "close", "Close panel (reopen via the View menu)") + close_button = _titlebar_button(self, "Close panel (reopen via the View menu)") close_button.clicked.connect(dock.close) layout.addWidget(close_button) + self._icon_buttons = {"popout": self.popout_button, "close": close_button} + self._tint_icons() + + def _tint_icons(self) -> None: + color = self.palette().color(QPalette.ColorRole.WindowText) + for kind, button in self._icon_buttons.items(): + button.setIcon(_titlebar_icon(kind, color)) + + def changeEvent(self, event): + # A theme switch lands here as a palette/style change; the glyphs are + # pixmaps, so they must be repainted in the new text color. + if event.type() in (QEvent.Type.PaletteChange, QEvent.Type.StyleChange): + self._tint_icons() + super().changeEvent(event) + class PopoutWindow(QWidget): """Additional top-level window for a panel mirror. @@ -98,6 +114,10 @@ class PopoutWindow(QWidget): def __init__(self, title: str, content: QWidget, parent=None): super().__init__(parent, Qt.WindowType.Window) + # QWidget SUBCLASSES skip QSS background painting unless this is set; + # an unpainted top-level renders black on the container's + # non-composited X11 (the PopoutWindow QSS rule supplies the fill). + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) self.setWindowTitle(title) self.setMinimumSize(300, 160) layout = QVBoxLayout(self) @@ -176,7 +196,7 @@ class PopoutWindow(QWidget): super().mousePressEvent(event) def mouseMoveEvent(self, event): - if self._manual_edges and self._press_global is not None and self._press_geom is not None: + if self._manual_edges and self._press_global is not None: delta = event.globalPosition().toPoint() - self._press_global geom = QRect(self._press_geom) if self._manual_edges & Qt.Edge.LeftEdge: @@ -201,3 +221,21 @@ class PopoutWindow(QWidget): self._press_global = None self._press_geom = None super().mouseReleaseEvent(event) + + +if __name__ == "__main__": + # ponytail: smallest check that fails if the edge maths breaks + import os + + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PySide6.QtWidgets import QApplication, QLabel + + app = QApplication([]) + w = PopoutWindow("t", QLabel("x")) + w.resize(400, 300) + assert w._edges_at(QPoint(5, 150)) == Qt.Edge.LeftEdge + assert w._edges_at(QPoint(398, 298)) == (Qt.Edge.RightEdge | Qt.Edge.BottomEdge) + assert w._edges_at(QPoint(200, 150)) == Qt.Edge(0) + assert w._cursor_for(Qt.Edge.LeftEdge | Qt.Edge.TopEdge) == Qt.CursorShape.SizeFDiagCursor + assert w._cursor_for(Qt.Edge.RightEdge | Qt.Edge.TopEdge) == Qt.CursorShape.SizeBDiagCursor + print("gude") -- 2.54.0 From c179ea992c8d57d45423317b64b7283296ae485d Mon Sep 17 00:00:00 2001 From: Dawn Date: Sat, 8 Aug 2026 18:40:59 +0200 Subject: [PATCH 40/57] style: relabel theme entries, tuck prototype views into the View menu Legacy/Portrait themes become Sunrise (default)/Sunset. Automation View and Portrait (now Playlist) Mode leave the menubar for the View menu with in-progress labels; Return to Main View stays top-level since it only shows inside the automation view. Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 2f613ec9..a6eea740 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -1798,33 +1798,36 @@ class MainWindow(QMainWindow): quit_action.triggered.connect(self.close) file_menu.addAction(quit_action) - self._enter_automation_view_action = QAction("Automation View", self) + # Prototype views live in the View menu (below the themes), not on the + # menubar; only their in-progress labels changed, the internal + # action/slot names stay. + self._enter_automation_view_action = QAction("Automation View (prototyping)", self) self._enter_automation_view_action.setShortcut(QKeySequence("Ctrl+5")) self._enter_automation_view_action.triggered.connect(self.enter_compact_automation_view) - menu_bar.addAction(self._enter_automation_view_action) + # Stays top-level: it only shows while INSIDE the automation view, + # where the way back must not hide in a menu. self._return_main_view_action = QAction("Return to Main View", self) self._return_main_view_action.setShortcut(QKeySequence("Ctrl+Shift+5")) self._return_main_view_action.triggered.connect(self._return_from_compact_automation_view) menu_bar.addAction(self._return_main_view_action) - self._portrait_mode_action = QAction("Portrait Mode", self) + self._portrait_mode_action = QAction("Playlist Mode (work in progress)", self) self._portrait_mode_action.setShortcut(QKeySequence("Ctrl+6")) self._portrait_mode_action.triggered.connect(self.enter_portrait_mode) - menu_bar.addAction(self._portrait_mode_action) view_menu = menu_bar.addMenu("View") self._theme_action_group = QActionGroup(self) self._theme_action_group.setExclusive(True) - self._use_legacy_theme_action = QAction("Legacy Theme", self) + self._use_legacy_theme_action = QAction("Sunrise Theme (default)", self) self._use_legacy_theme_action.setCheckable(True) self._use_legacy_theme_action.setChecked(self._theme_mode == THEME_ORIGINAL) self._use_legacy_theme_action.triggered.connect(self.use_legacy_theme) self._theme_action_group.addAction(self._use_legacy_theme_action) - self._use_portrait_theme_action = QAction("Portrait Theme", self) + self._use_portrait_theme_action = QAction("Sunset Theme", self) self._use_portrait_theme_action.setCheckable(True) self._use_portrait_theme_action.setChecked(self._theme_mode == THEME_PORTRAIT) self._use_portrait_theme_action.triggered.connect(self.use_portrait_theme) @@ -1833,6 +1836,9 @@ class MainWindow(QMainWindow): view_menu.addAction(self._use_legacy_theme_action) view_menu.addAction(self._use_portrait_theme_action) view_menu.addSeparator() + view_menu.addAction(self._portrait_mode_action) + view_menu.addAction(self._enter_automation_view_action) + view_menu.addSeparator() show_samples_action = QAction("Show Sample List", self) show_samples_action.setCheckable(True) -- 2.54.0 From f7194ca3a4fa98a0ab3fa76a40a5955955e113fa Mon Sep 17 00:00:00 2001 From: Dawn Date: Sat, 8 Aug 2026 18:42:04 +0200 Subject: [PATCH 41/57] style: small panel polish Energy unit moves from spinbox suffix to the label (the suffix ate field width next to the new spin arrows); samcam exposure/gain get a 3:2 stretch so 3-decimal exposure isn't cut; the Exp. Config. tab bar gets a BANNER_TAB_GAP spacer under its banner. Co-Authored-By: Claude Fable 5 --- src/aare/gui/panels/data_collection_settings.py | 4 ++++ src/aare/gui/panels/monochromator_panel.py | 5 +++-- src/aare/gui/panels/samcam_panel.py | 6 ++++-- src/aare/gui/styles.py | 5 +++++ 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/aare/gui/panels/data_collection_settings.py b/src/aare/gui/panels/data_collection_settings.py index 8e2bdb7d..3a5fbc14 100644 --- a/src/aare/gui/panels/data_collection_settings.py +++ b/src/aare/gui/panels/data_collection_settings.py @@ -20,6 +20,7 @@ from aare.gui.panels.raster_data_collection import RasterDataCollectionPanel from aare.gui.panels.rotation_data_collection import RotationDataCollectionPanel from aare.gui.panels.smart_rotation_panel import SimpleRotationSettingsPanel from aare.gui.scan_logic.raster_grid_manager import RasterGridManager +from aare.gui.styles import BANNER_TAB_GAP from aare.gui.widgets.title_label import TitleLabel, tighten_column @@ -102,6 +103,9 @@ class DataCollectionSettings(QFrame): "Experiment configuration", exp_config, collapsible=True, default_collapsed=False ) ) + # Explicit spacer, not layout spacing: the tab bar must keep sitting + # flush on the pane below, only the banner gets breathing room. + exp_config_layout.addSpacing(BANNER_TAB_GAP) exp_config_layout.addWidget(self._tab_bar) exp_config_layout.addWidget(pane) v_layout.addWidget(exp_config) diff --git a/src/aare/gui/panels/monochromator_panel.py b/src/aare/gui/panels/monochromator_panel.py index c12982f3..aee95e50 100644 --- a/src/aare/gui/panels/monochromator_panel.py +++ b/src/aare/gui/panels/monochromator_panel.py @@ -23,13 +23,14 @@ class MonochromatorPanel(QWidget): # One row (label | value | button) instead of three — vertical space. # Display in keV; the DAQ API stays in eV (converted on emit). - grid_layout.addWidget(QLabel("Energy", parent=self), 2, 0) + # Unit lives in the label, not as a spinbox suffix — the suffix ate + # field width and sat between the value and the +/- arrow. + grid_layout.addWidget(QLabel("Energy (keV)", parent=self), 2, 0) self.energy_spin = QDoubleSpinBox(parent=self) self.energy_spin.setDecimals(3) self.energy_spin.setRange(1.0, 30.0) self.energy_spin.setSingleStep(0.1) - self.energy_spin.setSuffix(" keV") self.energy_spin.setValue(12.0) grid_layout.addWidget(self.energy_spin, 2, 1) diff --git a/src/aare/gui/panels/samcam_panel.py b/src/aare/gui/panels/samcam_panel.py index e7a591ab..7632da88 100644 --- a/src/aare/gui/panels/samcam_panel.py +++ b/src/aare/gui/panels/samcam_panel.py @@ -54,10 +54,12 @@ class SamcamPanel(QWidget): self.gain_spinbox.setDecimals(1) self.gain_spinbox.valueChanged.connect(self._changed) + # 3:2 stretch — exposure shows 3 decimals plus the side arrows and + # was getting cut; gain (1 decimal) can afford the narrower field. exposure_gain_layout.addWidget(QLabel("Exposure (s):")) - exposure_gain_layout.addWidget(self.exposure_spinbox) + exposure_gain_layout.addWidget(self.exposure_spinbox, 3) exposure_gain_layout.addWidget(QLabel("Gain:")) - exposure_gain_layout.addWidget(self.gain_spinbox) + exposure_gain_layout.addWidget(self.gain_spinbox, 2) # Persist the current gain/exposure as the beam-location preset for the # current zoom (only meaningful in beam-location mode). diff --git a/src/aare/gui/styles.py b/src/aare/gui/styles.py index c139ad6f..d9ea5b2d 100644 --- a/src/aare/gui/styles.py +++ b/src/aare/gui/styles.py @@ -93,6 +93,11 @@ FRAME_L3_COLOR = "#c9cfd8" # left edge lines up with the left-column panels above (Loop centering). DOCK_CONTENT_LEFT_PAD = 10 +# Gap (px, int — used in code, not QSS) between a TitleLabel banner and a tab +# bar sitting directly under it (Exp. Config.), so the tabs don't touch the +# banner's bottom edge line. +BANNER_TAB_GAP = 6 + # Resize-line hint: dock separators stay invisible until the mouse rests on # one for SEPARATOR_HINT_DELAY_MS (or a drag starts) — then only the exact # separator under the cursor fills with SEPARATOR_HINT. The rest/drag gate -- 2.54.0 From 546fb5d3f041f71410093604e8c712013a0deea5 Mon Sep 17 00:00:00 2001 From: Dawn Date: Sat, 8 Aug 2026 18:42:14 +0200 Subject: [PATCH 42/57] test: inline smoke check for the wheel guard Co-Authored-By: Claude Fable 5 --- src/aare/gui/widgets/wheel_value_guard.py | 34 +++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/aare/gui/widgets/wheel_value_guard.py b/src/aare/gui/widgets/wheel_value_guard.py index b4975fc9..7697de8e 100644 --- a/src/aare/gui/widgets/wheel_value_guard.py +++ b/src/aare/gui/widgets/wheel_value_guard.py @@ -46,3 +46,37 @@ class WheelValueGuard(QObject): QApplication.sendEvent(area.viewport(), relayed) return True return super().eventFilter(obj, event) + + +if __name__ == "__main__": + # ponytail: smallest check that fails if the guard logic breaks + import os + + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PySide6.QtCore import QPoint, QPointF + + app = QApplication([]) + guard = WheelValueGuard() + app.installEventFilter(guard) + slider = QSlider(Qt.Orientation.Horizontal) + slider.setRange(0, 100) + slider.setValue(50) + slider.show() + + def wheel(buttons): + return QWheelEvent( + QPointF(5, 5), + QPointF(5, 5), + QPoint(0, 0), + QPoint(0, 120), + buttons, + Qt.KeyboardModifier.NoModifier, + Qt.ScrollPhase.NoScrollPhase, + False, + ) + + QApplication.sendEvent(slider, wheel(Qt.MouseButton.NoButton)) + assert slider.value() == 50, "bare wheel must not adjust the slider" + QApplication.sendEvent(slider, wheel(Qt.MouseButton.RightButton)) + assert slider.value() != 50, "right-button + wheel must adjust the slider" + print("gude") -- 2.54.0 From 0b696a56f0f051dc1da01f9da86c5b0e6444a361 Mon Sep 17 00:00:00 2001 From: Dawn Date: Sat, 8 Aug 2026 18:42:58 +0200 Subject: [PATCH 43/57] style: cool the sunset gradient base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The warm glow at the bottom of the dusk sky didn't fit — stay in the blue range. Co-Authored-By: Claude Fable 5 --- src/aare/gui/styles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aare/gui/styles.py b/src/aare/gui/styles.py index d9ea5b2d..23d8b63a 100644 --- a/src/aare/gui/styles.py +++ b/src/aare/gui/styles.py @@ -170,7 +170,7 @@ DARK_BACKGROUND_GRADIENT_MID = "#16294d" # fade onset tone DARK_BACKGROUND_GRADIENT_MID_POS = "0.55" # 0..1 — where the fade begins DARK_BACKGROUND_GRADIENT_BLUE = "#2c5f9e" # the blue band DARK_BACKGROUND_GRADIENT_BLUE_POS = "0.90" # 0..1 — warm glow only below this -DARK_BACKGROUND_GRADIENT_BOTTOM = "#d9b98c" +DARK_BACKGROUND_GRADIENT_BOTTOM = "#2c5f9e" # the warm glow doesn't seem to fit DARK_APP_BACKGROUND = ( "qlineargradient(x1:0, y1:0, x2:0, y2:1," f" stop:0 {DARK_BACKGROUND_GRADIENT_TOP}," -- 2.54.0 From 4ef2ad64d706c2fbc0631668f00bdd8266f5ace3 Mon Sep 17 00:00:00 2001 From: Dawn Date: Sun, 9 Aug 2026 10:08:23 +0200 Subject: [PATCH 44/57] style: clear the basedpyright gate for the new-code lines Narrow QApplication.instance() before use, wrap the theme QSettings read in str(), hand QPropertyAnimation a real QByteArray, and declare the lazily-set watch-mode attributes on the class. Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index a6eea740..502034b6 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -15,7 +15,17 @@ from aarecommon.models.models import ( SessionsStateEnum, TokenData, ) -from PySide6.QtCore import QEvent, QObject, QPropertyAnimation, QSettings, Qt, QTimer, Signal, Slot +from PySide6.QtCore import ( + QByteArray, + QEvent, + QObject, + QPropertyAnimation, + QSettings, + Qt, + QTimer, + Signal, + Slot, +) from PySide6.QtGui import ( QAction, QActionGroup, @@ -149,6 +159,8 @@ class MainWindow(QMainWindow): # declared for the basedpyright gate. _session_operations_enabled: bool | None = None _default_dock_split_done: bool = False + _pre_watch_dock_state: QByteArray | None = None + _pre_watch_visibility: list[tuple[QWidget, bool]] | None = None def __init__( self, @@ -218,7 +230,9 @@ class MainWindow(QMainWindow): # App-level, not window-level: dialogs and pop-outs get it too. self._clickable_cursor_filter = ClickableCursorFilter(self) - QApplication.instance().installEventFilter(self._clickable_cursor_filter) + app = QApplication.instance() + assert app is not None + app.installEventFilter(self._clickable_cursor_filter) # Wheel safety: sliders/spin boxes/combos only react to the wheel # while the right mouse button is held; a bare wheel just scrolls @@ -1737,6 +1751,7 @@ class MainWindow(QMainWindow): # Native primitives QSS can't recolor (spin/combo arrow glyphs) draw # from the app palette — flip its text roles with the theme. app = QApplication.instance() + assert isinstance(app, QApplication) # palette() lives on QApplication if not hasattr(self, "_default_palette"): self._default_palette = app.palette() if self._theme_mode == THEME_PORTRAIT: @@ -1763,7 +1778,7 @@ class MainWindow(QMainWindow): overlay.show() effect = QGraphicsOpacityEffect(overlay) overlay.setGraphicsEffect(effect) - fade = QPropertyAnimation(effect, b"opacity", overlay) + fade = QPropertyAnimation(effect, QByteArray(b"opacity"), overlay) fade.setDuration(THEME_FADE_MS) fade.setStartValue(1.0) fade.setEndValue(0.0) @@ -1772,7 +1787,8 @@ class MainWindow(QMainWindow): def _restore_theme_settings(self) -> None: settings = QSettings("PSI", "AareGUI") - self._theme_mode = settings.value("appearance/theme", THEME_ORIGINAL, type=str) + # str() wrap: settings.value is typed object even with type=str. + self._theme_mode = str(settings.value("appearance/theme", THEME_ORIGINAL, type=str)) def _save_theme_settings(self) -> None: settings = QSettings("PSI", "AareGUI") @@ -2439,7 +2455,7 @@ class MainWindow(QMainWindow): *self.findChildren(PopoutWindow), ] if owned: - for widget, was_visible in getattr(self, "_pre_watch_visibility", []): + for widget, was_visible in self._pre_watch_visibility or []: widget.setVisible(was_visible) # Docks restore via restoreState, not per-dock setVisible: a dock # TABBED BEHIND another is isHidden() at snapshot time, so a @@ -2447,7 +2463,7 @@ class MainWindow(QMainWindow): # (the vanished Sample List). restoreState also brings back # tab order, the active tab and dock sizes. Needs objectNames # on every dock. - state = getattr(self, "_pre_watch_dock_state", None) + state = self._pre_watch_dock_state if state is not None: self.restoreState(state) else: -- 2.54.0 From c13ea11430cf595395fc8de83988d73de088b930 Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 10 Aug 2026 09:41:20 +0200 Subject: [PATCH 45/57] feat: rename themes to sunrise/sunset, add bluebird theme with Catppuccin-Latte light palette Sunrise/sunset replace the original/portrait tokens (QSettings values migrate on restore); bluebird is sunrise with a flat sky. The light palette is repainted with Catppuccin Latte across alerts, chips, cards, log, splash, inputs, and status flags, plus button/input height caps and hover rules in the QSS. Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 37 +- src/aare/gui/panels/beamline_state_panel.py | 4 +- src/aare/gui/styles.py | 466 ++++++++++++-------- src/aare/gui/widgets/splash_screen.py | 8 +- src/aare/gui/widgets/title_label.py | 6 +- 5 files changed, 319 insertions(+), 202 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 502034b6..a1475202 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -98,9 +98,10 @@ from aare.gui.styles import ( DARK_TEXT, DOCK_CONTENT_LEFT_PAD, SEPARATOR_HINT_DELAY_MS, + THEME_BLUEBIRD, THEME_FADE_MS, - THEME_ORIGINAL, - THEME_PORTRAIT, + THEME_SUNRISE, + THEME_SUNSET, build_app_stylesheet, qcolor, ) @@ -179,7 +180,7 @@ class MainWindow(QMainWindow): # default) so the mutable list is per-instance (RUF012). self._pre_vacancy_open_banners: list[TitleLabel] = [] - self._theme_mode = THEME_ORIGINAL + self._theme_mode = THEME_SUNRISE self._theme_action_group = None self._use_legacy_theme_action = None self._use_portrait_theme_action = None @@ -1754,7 +1755,7 @@ class MainWindow(QMainWindow): assert isinstance(app, QApplication) # palette() lives on QApplication if not hasattr(self, "_default_palette"): self._default_palette = app.palette() - if self._theme_mode == THEME_PORTRAIT: + if self._theme_mode == THEME_SUNSET: palette = QPalette(self._default_palette) for role in ( QPalette.ColorRole.ButtonText, @@ -1788,7 +1789,11 @@ class MainWindow(QMainWindow): def _restore_theme_settings(self) -> None: settings = QSettings("PSI", "AareGUI") # str() wrap: settings.value is typed object even with type=str. - self._theme_mode = str(settings.value("appearance/theme", THEME_ORIGINAL, type=str)) + saved = str(settings.value("appearance/theme", THEME_SUNRISE, type=str)) + # Migrate the pre-rename tokens so a saved theme survives the value + # change (styles.py: "original"->"sunrise", "portrait"->"sunset"). + saved = {"original": THEME_SUNRISE, "portrait": THEME_SUNSET}.get(saved, saved) + self._theme_mode = saved def _save_theme_settings(self) -> None: settings = QSettings("PSI", "AareGUI") @@ -1796,12 +1801,17 @@ class MainWindow(QMainWindow): @Slot() def use_legacy_theme(self) -> None: - self._theme_mode = THEME_ORIGINAL + self._theme_mode = THEME_SUNRISE self._apply_theme() @Slot() def use_portrait_theme(self) -> None: - self._theme_mode = THEME_PORTRAIT + self._theme_mode = THEME_SUNSET + self._apply_theme() + + @Slot() + def use_bluebird_theme(self) -> None: + self._theme_mode = THEME_BLUEBIRD self._apply_theme() def create_menu_bar(self): @@ -1839,17 +1849,24 @@ class MainWindow(QMainWindow): self._use_legacy_theme_action = QAction("Sunrise Theme (default)", self) self._use_legacy_theme_action.setCheckable(True) - self._use_legacy_theme_action.setChecked(self._theme_mode == THEME_ORIGINAL) + self._use_legacy_theme_action.setChecked(self._theme_mode == THEME_SUNRISE) self._use_legacy_theme_action.triggered.connect(self.use_legacy_theme) self._theme_action_group.addAction(self._use_legacy_theme_action) - self._use_portrait_theme_action = QAction("Sunset Theme", self) + self._use_portrait_theme_action = QAction("Sunset Theme (work in progress)", self) self._use_portrait_theme_action.setCheckable(True) - self._use_portrait_theme_action.setChecked(self._theme_mode == THEME_PORTRAIT) + self._use_portrait_theme_action.setChecked(self._theme_mode == THEME_SUNSET) self._use_portrait_theme_action.triggered.connect(self.use_portrait_theme) self._theme_action_group.addAction(self._use_portrait_theme_action) + self._use_bluebird_theme_action = QAction("Bluebird Theme", self) + self._use_bluebird_theme_action.setCheckable(True) + self._use_bluebird_theme_action.setChecked(self._theme_mode == THEME_BLUEBIRD) + self._use_bluebird_theme_action.triggered.connect(self.use_bluebird_theme) + self._theme_action_group.addAction(self._use_bluebird_theme_action) + view_menu.addAction(self._use_legacy_theme_action) + view_menu.addAction(self._use_bluebird_theme_action) view_menu.addAction(self._use_portrait_theme_action) view_menu.addSeparator() view_menu.addAction(self._portrait_mode_action) diff --git a/src/aare/gui/panels/beamline_state_panel.py b/src/aare/gui/panels/beamline_state_panel.py index c3aa89aa..77eebb97 100644 --- a/src/aare/gui/panels/beamline_state_panel.py +++ b/src/aare/gui/panels/beamline_state_panel.py @@ -5,7 +5,7 @@ from PySide6.QtCore import Qt, QTimer, Signal, Slot from PySide6.QtGui import QCursor, QFont, QFontMetrics from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QMenu, QPushButton, QSizePolicy, QToolTip -from aare.gui.styles import FONT_VALUE, THEME_ORIGINAL, state_colors +from aare.gui.styles import FONT_VALUE, THEME_SUNRISE, state_colors # Shortcut transitions from the "Available transitions" menu in # widgets/status_bar.py show_state_menu — these come ON TOP of the one-hop @@ -141,7 +141,7 @@ class BeamlineStatePanel(QFrame): self._hover_hint_timer.timeout.connect(self._show_hover_hint) # Per-theme colors (MainWindow._apply_theme calls set_theme). - self._colors = state_colors(THEME_ORIGINAL) + self._colors = state_colors(THEME_SUNRISE) self._separators: list[QLabel] = [] layout = QHBoxLayout(self) diff --git a/src/aare/gui/styles.py b/src/aare/gui/styles.py index 23d8b63a..ed56d3a7 100644 --- a/src/aare/gui/styles.py +++ b/src/aare/gui/styles.py @@ -3,8 +3,14 @@ from __future__ import annotations from pathlib import Path from string import Template -THEME_ORIGINAL = "original" -THEME_PORTRAIT = "portrait" +# These string values are the tokens persisted in QSettings("appearance/theme"). +# They were renamed from "original"/"portrait"; MainWindow._restore_theme_settings +# migrates the old tokens so a user's saved theme survives the rename. +THEME_SUNRISE = "sunrise" +THEME_SUNSET = "sunset" +# Sunrise with the sky gradient flattened to its top color — for consoles +# where the gradient banding distracts, and as a plain-background baseline. +THEME_BLUEBIRD = "bluebird" # --------------------------------------------------------------------------- # Color palette. Change values HERE to try a different look — the QSS below @@ -16,16 +22,16 @@ THEME_PORTRAIT = "portrait" # -- Light theme ------------------------------------------------------------ BACKGROUND = "#e2e7ee" -# App-wide dusk-sky gradient (sampled from the reference photo): slate blue -# fading through pale grey-lavender into warm cream. Painted once per -# top-level window (QMainWindow/QDialog) while plain child widgets stay -# transparent, so the window reads as ONE continuous sky instead of every -# widget restarting the gradient. Tune the stops here; set all three stops -# to BACKGROUND to get the old flat look back. +# App-wide sunrise-sky gradient (sampled from the reference photo taken at dawn +# near Dawn's house at Windisch: slate blue fading through pale grey-lavender +# into warm cream. Painted once per top-level window (QMainWindow/QDialog) +# while plain child widgets stay transparent, so the window reads as ONE +# continuous sky instead of every widget restarting the gradient. +# Tune the transition point here: BACKGROUND_GRADIENT_TOP = "#84abd9" # RHEL9 window-frame blue (sampled from screenshot) -BACKGROUND_GRADIENT_MID = "#c6cad6" -BACKGROUND_GRADIENT_MID_POS = "0.55" # 0..1 — where the mid stop sits -BACKGROUND_GRADIENT_BOTTOM = "#f6e9dd" +BACKGROUND_GRADIENT_MID = "#bbd6f6" +BACKGROUND_GRADIENT_MID_POS = "0.65" # 0..1 — where the mid stop sits +BACKGROUND_GRADIENT_BOTTOM = "#dbe9f9" APP_BACKGROUND = ( "qlineargradient(x1:0, y1:0, x2:0, y2:1," f" stop:0 {BACKGROUND_GRADIENT_TOP}," @@ -52,7 +58,12 @@ SURFACE = "#f2f2f2" # input fields, cards, group boxes # Buttons: flat + hairline like the dark theme (the explicit border is what # switches Qt from bulky native chrome to compact QSS box rendering). -BUTTON_BG = "#f7f9fc" +# Half-transparent so the sky gradient shimmers through, same glass idea as +# INPUT_BG — slightly more solid so clickables read as raised faces. +BUTTON_BG = "rgba(255, 255, 255, 50%)" +# Hover: a dark ink tint instead of more white — darkens whatever sky shade +# is behind the button (the dark theme hovers LIGHTER, see DARK_ELEVATED_HOVER). +BUTTON_BG_HOVER = "rgba(76, 79, 105, 18%)" BUTTON_BORDER = "#c9cfd8" # same tone as FRAME_L3_COLOR, separate knob # Spin/combo arrow glyphs: tiny PNGs — this Qt draws neither native glyphs @@ -102,7 +113,7 @@ BANNER_TAB_GAP = 6 # one for SEPARATOR_HINT_DELAY_MS (or a drag starts) — then only the exact # separator under the cursor fills with SEPARATOR_HINT. The rest/drag gate # lives in MainWindow.event(); the QSS :hover part picks the one separator. -SEPARATOR_HINT = "rgba(168, 178, 192, 50%)" # scrollbar-track grey @50% +SEPARATOR_HINT = "rgba(168, 178, 192, 20%)" # scrollbar-track grey @50% SEPARATOR_HINT_DELAY_MS = 888 # int, used in code, not QSS # Theme-switch screenshot cross-fade duration (int ms, used in code). @@ -133,15 +144,17 @@ SECONDARY_BG = "#dfe9fb" SECONDARY_BG_HOVER = "#d3e1f8" # Alert banners (alertKind: error / success / waiting=warning): -ERROR_BG = "#fbe4e6" -ERROR_BORDER = "#d97a84" -ERROR_TEXT = "#8f1d2c" -SUCCESS_BG = "#e7f6ea" -SUCCESS_BORDER = "#7bbf8e" -SUCCESS_TEXT = "#1f6a3a" -WARNING_BG = "#fff8e1" -WARNING_BORDER = "#ffb300" -WARNING_TEXT = "#e65100" +# Catppuccin Latte: BG = 15% accent over base, border = 50%, text = 65% over +# Latte text — same recipe as the chips/cards/log blocks below. +ERROR_BG = "#ebcfd9" # red wash +ERROR_BORDER = "#e08097" +ERROR_TEXT = "#a3254a" +SUCCESS_BG = "#d5e5d7" # green wash +SUCCESS_BORDER = "#98c890" +SUCCESS_TEXT = "#448441" +WARNING_BG = "#f1dcd2" # peach wash +WARNING_BORDER = "#f6aa80" +WARNING_TEXT = "#c05d2c" # Axis video status + beamline state bar: STATUS_IDLE_BG = "#d9e2f2" @@ -181,6 +194,7 @@ DARK_APP_BACKGROUND = ( DARK_PANEL2 = "#0e1728" # panel2 — deepest opaque: menus, popups, tooltips DARK_SURFACE = "#1c2b4a" # panel — cards, state panel, scrollbar track DARK_ELEVATED = "#253148" # glass2 (7% white) flattened — buttons, banners +DARK_ELEVATED_HOVER = "#32405c" # one glass step lighter — button hover # Solid, not transparent: scroll-area viewports don't composite the window # gradient on the container's X11 and render BLACK instead. Lighter sky-navy # so tables don't read near-black against the backdrop. @@ -238,108 +252,106 @@ WHITE = "#ffffff" # ONLY for theme-independent light surfaces (tutorial callout). To "reset" a # themed label, clear its stylesheet ("") so the theme color applies — a # hardcoded black reset is invisible in the dark theme. -DEFAULT_TEXT = "#000000" -NOTE_TEXT = "#555555" # tutorial hints, TELL sample details -DIM_TEXT = "#666666" # baton dialog timers -HINT_TEXT = "#999999" # baton dialog fine print -HEADING_TEXT = "#1e293b" # card headings (slate-800) -SUBTLE_TEXT = "#334155" # card body text (slate-700) -MUTED_TEXT = "#475569" # neutral chip / idle step text (slate-600) -FAINT_TEXT = "#64748b" # pending/skipped step text (slate-500) +DEFAULT_TEXT = "#4c4f69" # latte text +NOTE_TEXT = "#5c5f77" # tutorial hints, TELL sample details (latte subtext1) +DIM_TEXT = "#6c6f85" # baton dialog timers (latte subtext0) +HINT_TEXT = "#9ca0b0" # baton dialog fine print (latte overlay0) +HEADING_TEXT = "#4c4f69" # card headings (latte text) +SUBTLE_TEXT = "#5c5f77" # card body text (latte subtext1) +MUTED_TEXT = "#6c6f85" # neutral chip / idle step text (latte subtext0) +FAINT_TEXT = "#8c8fa1" # pending/skipped step text (latte overlay1) SHADOW = "#000000" # drop shadows & tutorial scrim; alpha stays at call site # -- Semantic action colors ------------------------------------------------- -GO_TEXT = "#4e9a06" # green start/run/measure button text -ABORT_TEXT = "#a40000" # abort button text -ALERT_TEXT = "#ff0000" # out-of-range motor labels -PATH_WARN_TEXT = "#c80000" # file-exists warning in path panel -DANGER_ACCENT = "#d9534f" # invalid p-group border + message text +GO_TEXT = "#40a02b" # green start/run/measure button text +ABORT_TEXT = "#d20f39" # abort button text (red) +ALERT_TEXT = "#d20f39" # out-of-range motor labels (red) +PATH_WARN_TEXT = "#e64553" # file-exists warning in path panel (maroon) +DANGER_ACCENT = "#e64553" # invalid p-group border + message text (maroon) # -- Status chips (local contact status) — "good" reuses SUCCESS_BG/TEXT ---- -CHIP_WARN_BG = "#fff3cd" -CHIP_WARN_TEXT = "#7a4b00" -CHIP_BAD_BG = "#fdeaea" -CHIP_BAD_TEXT = "#8b1e1e" -CHIP_NEUTRAL_BG = "#e9eef5" # text uses MUTED_TEXT -CHIP_INFO_BG = "#e8f1ff" -CHIP_INFO_TEXT = "#12406a" +CHIP_WARN_BG = "#ede2d5" # yellow wash +CHIP_WARN_TEXT = "#ac7838" +CHIP_BAD_BG = "#ebcfd9" # red wash +CHIP_BAD_TEXT = "#a3254a" +CHIP_NEUTRAL_BG = "#e6e9ef" # latte mantle; text uses MUTED_TEXT +CHIP_INFO_BG = "#d0dcf5" # blue wash +CHIP_INFO_TEXT = "#2e5ec4" # -- Status cards (beamline recovery, local contact error frame) ------------ -# TODO: recovery-card colors are inherited from the old ad-hoc design and -# stand out against the app palette — retheme them here when ready. -WARN_CARD_BORDER = "#f0c36d" -BAD_CARD_BORDER = "#e6a8a8" -INFO_CARD_BG = "#eef6ff" -INFO_CARD_BORDER = "#a8c7e6" -PENDING_CARD_BG = "#fff7db" -PENDING_CARD_BORDER = "#e7cb73" +WARN_CARD_BORDER = "#e9cea9" # yellow border +BAD_CARD_BORDER = "#e5a2b3" # red border +INFO_CARD_BG = "#d0dcf5" # blue wash +INFO_CARD_BORDER = "#a6c0f5" +PENDING_CARD_BG = "#ede2d5" # yellow wash +PENDING_CARD_BORDER = "#e9cea9" # -- Log panel -------------------------------------------------------------- -# TODO: console-log notification colors are inherited from the old ad-hoc -# design and stand out against the app palette — retheme them here when ready. -LOG_BORDER = "#8a8a8a" -LOG_PANEL_BG = "#fff4f4" -LOG_ERROR_BG = "#fff1f1" -LOG_ERROR_BORDER = "#dd6666" -LOG_WARN_BG = "#fff8e8" -LOG_WARN_BORDER = "#d7aa42" -LOG_SUCCESS_BG = "#eefaf0" -LOG_SUCCESS_BORDER = "#6cb37a" -LOG_INFO_BG = "#eef5ff" -LOG_INFO_BORDER = "#6b9bd6" +LOG_BORDER = "#8c8fa1" # latte overlay1 +LOG_PANEL_BG = "#ecdae2" # faint red wash +LOG_ERROR_BG = "#ebcfd9" +LOG_ERROR_BORDER = "#e08097" +LOG_WARN_BG = "#ede2d5" +LOG_WARN_BORDER = "#e7c089" +LOG_SUCCESS_BG = "#d5e5d7" +LOG_SUCCESS_BORDER = "#98c890" +LOG_INFO_BG = "#d0dcf5" +LOG_INFO_BORDER = "#86acf5" # -- Automation panel + progress steps -------------------------------------- -AUTOMATION_TITLE_TEXT = "#1f2937" -AUTOMATION_HINT_TEXT = "#374151" -STEP_RUNNING_TEXT = "#2563eb" # same blue as PRIMARY, separate knob -STEP_SUCCESS_TEXT = "#15803d" -STEP_FAILED_TEXT = "#b91c1c" -STEP_PAUSED_TEXT = "#c2410c" -STEP_DONE_BG = "#ecfdf3" -STEP_DONE_TEXT = "#166534" -STEP_DONE_BORDER = "#a7f3d0" -STEP_ACTIVE_BG = "#eff6ff" -STEP_ACTIVE_TEXT = "#1d4ed8" -STEP_ACTIVE_BORDER = "#bfdbfe" -STEP_FAILED_BG = "#fef2f2" -STEP_FAILED_BORDER = "#fecaca" -STEP_PAUSED_BG = "#fff7ed" -STEP_PAUSED_BORDER = "#fed7aa" -STEP_IDLE_BG = "#f8fafc" -STEP_IDLE_BORDER = "#e2e8f0" +AUTOMATION_TITLE_TEXT = "#4c4f69" # latte text +AUTOMATION_HINT_TEXT = "#5c5f77" # latte subtext1 +STEP_RUNNING_TEXT = "#1e66f5" # latte blue — same as PRIMARY, separate knob +STEP_SUCCESS_TEXT = "#40a02b" # green +STEP_FAILED_TEXT = "#d20f39" # red +STEP_PAUSED_TEXT = "#c05d2c" # peach ink +STEP_DONE_BG = "#d5e5d7" # green wash +STEP_DONE_TEXT = "#448441" +STEP_DONE_BORDER = "#b2d5ae" +STEP_ACTIVE_BG = "#d0dcf5" # blue wash +STEP_ACTIVE_TEXT = "#2e5ec4" +STEP_ACTIVE_BORDER = "#a6c0f5" +STEP_FAILED_BG = "#ebcfd9" # red wash +STEP_FAILED_BORDER = "#e5a2b3" +STEP_PAUSED_BG = "#f1dcd2" # peach wash +STEP_PAUSED_BORDER = "#f4c0a3" +STEP_IDLE_BG = "#eff1f5" # latte base +STEP_IDLE_BORDER = "#dce0e8" # latte crust # -- Baton request dialog --------------------------------------------------- -BATON_OK_BG = "#4caf50" -BATON_OK_HOVER = "#45a049" -BATON_OK_PRESSED = "#3d8b40" -BATON_DANGER_BG = "#f44336" -BATON_DANGER_HOVER = "#da190b" -BATON_DANGER_PRESSED = "#c41000" -BATON_WARN = "#ff9800" -BATON_INFO = "#2196f3" -LIGHT_BORDER = "#cccccc" -PROGRESS_TRACK_BG = "#f0f0f0" +# Hover/pressed are the accent mixed 12%/24% toward Latte text. +BATON_OK_BG = "#40a02b" # green +BATON_OK_HOVER = "#419632" +BATON_OK_PRESSED = "#438d3a" +BATON_DANGER_BG = "#d20f39" # red +BATON_DANGER_HOVER = "#c2173f" +BATON_DANGER_PRESSED = "#b21e45" +BATON_WARN = "#fe640b" # peach +BATON_INFO = "#1e66f5" # blue +LIGHT_BORDER = "#bcc0cc" # latte surface1 +PROGRESS_TRACK_BG = "#e6e9ef" # latte mantle # -- Splash screen ---------------------------------------------------------- -SPLASH_BG = "#222222" -SPLASH_BORDER = "#444444" -SPLASH_ACCENT = "#0078d7" +SPLASH_BG = "#dce0e8" # latte crust (progress-bar track) +SPLASH_BORDER = "#bcc0cc" # latte surface1 +SPLASH_ACCENT = "#1e66f5" # latte blue +SPLASH_TEXT = "#4c4f69" # latte text — bar % and loading message # -- Numeric inputs --------------------------------------------------------- # Translucent, not solid white: the sky gradient shimmers through the field # while text stays on a light ground. Raise the % for a more solid face. INPUT_BG = "rgba(255, 255, 255, 33%)" -INPUT_INVALID_BG = "#ffd5d5" -INPUT_DISABLED_BG = "#f0f0f0" -INPUT_DISABLED_INVALID_BG = "#f0e1e1" +INPUT_INVALID_BG = "#e9c4cf" # red 20% over latte base +INPUT_DISABLED_BG = "#e6e9ef" # latte mantle +INPUT_DISABLED_INVALID_BG = "#ecdae2" # faint red wash -# -- Status bar flags (hex equivalents of the old CSS named colors) --------- -STATUS_OK = "#008000" # closed / idle / owned / tell ok (was "green") -STATUS_ALERT = "#ff0000" # open / busy / other-owner / hot cryo (was "red") -STATUS_WARN = "#ffa500" # baton waiting / warming cryo / tell busy (was "orange") -STATUS_INFO = "#0000ff" # cold cryo (was "blue") -STATUS_VACANT = "#ffff00" # baton vacant (was "yellow") -STATUS_REQUEST = "#00ffff" # baton request (was "cyan") +# -- Status bar flags (Catppuccin Latte) ------------------------------------ +STATUS_OK = "#40a02b" # closed / idle / owned / tell ok (green) +STATUS_ALERT = "#d20f39" # open / busy / other-owner / hot cryo (red) +STATUS_WARN = "#fe640b" # baton waiting / warming cryo / tell busy (peach) +STATUS_INFO = "#1e66f5" # cold cryo (blue) +STATUS_VACANT = "#df8e1d" # baton vacant (yellow) +STATUS_REQUEST = "#04a5e5" # baton request (sky) # -- Beamline state panel --------------------------------------------------- # The panel paints these in code per DAQ tick (data-driven), so it asks @@ -357,7 +369,7 @@ DARK_STATE_MSG_INFO = "#89b4fa" # mocha blue def state_colors(theme: str) -> dict[str, str]: """Beamline-state text colors for the given theme.""" - if theme == THEME_PORTRAIT: + if theme == THEME_SUNSET: return { "available": DARK_STATE_AVAILABLE, "unavailable": DARK_STATE_UNAVAILABLE, @@ -393,86 +405,89 @@ SAMPLE_STATUS_SELECTED_BG = "#d8e8fd" # pale blue — table selection highlight SAMPLE_STATUS_TEXT = "#263043" # -- Camera / video overlay (painter colors, alpha at call site) ------------ -BEAM_OPEN = "#00ff00" # beam marker: shutter open -BEAM_IDLE = "#f57900" # beam marker: idle -BEAM_BUSY = "#ff0000" # beam marker: busy -BEAM_MARKING = "#663399" # beam marker: marking mode -MARKER_GREEN = "#32cd32" # loop-centering click marker -PATH_START = "#008000" # raster path gradient start + start circle -PATH_END = "#ff0000" # raster path gradient end + end circle -LEGEND_BG = "#141414" -LEGEND_TEXT = "#f0f0f0" -TOOLTIP_TEXT = "#e6e6e6" # camera coords tooltip pen — NOT the QToolTip popup -MARK_TOOLTIP_GOLD = "#ffd700" -MARK_TOOLTIP_ORANGE = "#ffa500" -MARK_TOOLTIP_RED = "#ff0000" -MARK_BADGE_BG = "#b43c00" +# Palette: Catppuccin Latte (light flavor) — softer than the old pure-RGB set. +BEAM_OPEN = "#40a02b" # beam marker: shutter open (green) +BEAM_IDLE = "#fe640b" # beam marker: idle (peach) +BEAM_BUSY = "#d20f39" # beam marker: busy (red) +BEAM_MARKING = "#8839ef" # beam marker: marking mode (mauve) +MARKER_GREEN = "#40a02b" # loop-centering click marker (green) +PATH_START = "#40a02b" # raster path gradient start + start circle (green) +PATH_END = "#d20f39" # raster path gradient end + end circle (red) +LEGEND_BG = "#eff1f5" # base +LEGEND_TEXT = "#4c4f69" # text +TOOLTIP_TEXT = "#4c4f69" # camera coords tooltip pen — NOT the QToolTip popup +MARK_TOOLTIP_GOLD = "#df8e1d" # yellow +MARK_TOOLTIP_ORANGE = "#fe640b" # peach +MARK_TOOLTIP_RED = "#d20f39" # red +MARK_BADGE_BG = "#fe640b" # peach -# Prediction class overlay colors. The chart variant historically used pure -# green (#00ff00) while the overlay used CSS green (#008000) — both kept. +# Prediction class overlay colors. The old pure-green vs CSS-green split +# between chart and overlay collapses to the single Latte green. CLASS_COLORS = { - "pin": "#ff0000", - "loop_all": "#008000", - "loop_face": "#ffff00", - "crystal": "#0000ff", - "needle": "#ff00ff", - "ice": "#00ffff", + "pin": "#d20f39", # red + "loop_all": "#40a02b", # green + "loop_face": "#df8e1d", # yellow + "crystal": "#1e66f5", # blue + "needle": "#ea76cb", # pink + "ice": "#04a5e5", # sky } CHART_CLASS_COLORS = { - "Pin": "#ff0000", - "Loop_all": "#00ff00", - "Loop_face": "#ffff00", - "Crystal": "#0000ff", - "Needle": "#ff00ff", - "Ice": "#00ffff", + "Pin": "#d20f39", + "Loop_all": "#40a02b", + "Loop_face": "#df8e1d", + "Crystal": "#1e66f5", + "Needle": "#ea76cb", + "Ice": "#04a5e5", } -TARGET_COLORS = {"Cyan": "#00ffff", "Dark Blue": "#0046a0", "Dark Red": "#8c1919"} +TARGET_COLORS = {"Cyan": "#04a5e5", "Dark Blue": "#1e66f5", "Dark Red": "#e64553"} BOOKMARK_COLORS = { - "red": "#ff0000", - "green": "#008000", - "blue": "#0000ff", - "indigo": "#4b0082", - "lime": "#00ff00", + "red": "#d20f39", + "green": "#40a02b", + "blue": "#1e66f5", + "indigo": "#8839ef", # mauve + "lime": "#179299", # teal — Latte has one green; teal keeps the pair distinct } # -- Busy overlay (per-source color coding) --------------------------------- -BUSY_YELLOW = "#f1c40f" -BUSY_YELLOW_BORDER = "#fff8d2" -BUSY_YELLOW_DOT = "#fff6bf" -BUSY_YELLOW_TEXT_DARK = "#3b2f00" -BUSY_PURPLE = "#8e44ad" -BUSY_PURPLE_BORDER = "#ebdcf5" -BUSY_PURPLE_DOT = "#f0dfff" -BUSY_RED_BADGE = "#d64545" -BUSY_RED_FILL = "#be2828" -BUSY_RED_BORDER = "#ffdcdc" -BUSY_RED_DOT = "#ffdddd" -BUSY_ORANGE = "#e67e22" -BUSY_ORANGE_BORDER = "#ffead6" -BUSY_ORANGE_DOT = "#fff0db" -BUSY_BLUE = "#3498db" -BUSY_BLUE_BORDER = "#dcf0ff" -BUSY_BLUE_DOT = "#dff2ff" -BUSY_PSI_RED = "#e04f39" -BUSY_PSI_RED_BORDER = "#ffe1dc" -BUSY_PSI_RED_DOT = "#ffd8d1" +# Catppuccin Latte accents; BORDER/DOT are 25%/20% mixes toward Latte base. +BUSY_YELLOW = "#df8e1d" # yellow +BUSY_YELLOW_BORDER = "#ebd8bf" +BUSY_YELLOW_DOT = "#ecddca" +BUSY_YELLOW_TEXT_DARK = "#4c4f69" # text +BUSY_PURPLE = "#8839ef" # mauve +BUSY_PURPLE_BORDER = "#d5c3f4" +BUSY_PURPLE_DOT = "#daccf4" +BUSY_RED_BADGE = "#d20f39" # red +BUSY_RED_FILL = "#d20f39" # red +BUSY_RED_BORDER = "#e8b8c6" +BUSY_RED_DOT = "#e9c4cf" +BUSY_ORANGE = "#fe640b" # peach +BUSY_ORANGE_BORDER = "#f3ceba" +BUSY_ORANGE_DOT = "#f2d5c6" +BUSY_BLUE = "#1e66f5" # blue +BUSY_BLUE_BORDER = "#bbcef5" +BUSY_BLUE_DOT = "#c5d5f5" +BUSY_PSI_RED = "#e64553" # maroon — closest Latte to the PSI brand red +BUSY_PSI_RED_BORDER = "#edc6cc" +BUSY_PSI_RED_DOT = "#edcfd5" # -- Charts (prediction metrics, target stability, fluorescence) ------------ -CHART_BLUE = "#1f77b4" -CHART_BLUE_LIGHT = "#6baed6" -CHART_BLUE_PALE = "#9ecae1" -CHART_RED = "#d62728" -CHART_RED_LIGHT = "#ff9896" -CHART_RED_DARK = "#c43c39" -CHART_ORANGE = "#ff7f0e" -CHART_ORANGE_PALE = "#ffbb78" -CHART_GREEN = "#2ca02c" -CHART_GREEN_PALE = "#98df8a" -CHART_CYAN = "#17becf" -CHART_PURPLE = "#9467bd" -CHART_MUTED = "#888888" +# Catppuccin Latte; PALE variants are 35% mixes toward Latte base. +CHART_BLUE = "#1e66f5" # blue +CHART_BLUE_LIGHT = "#04a5e5" # sky +CHART_BLUE_PALE = "#a6c0f5" +CHART_RED = "#d20f39" # red +CHART_RED_LIGHT = "#dd7878" # flamingo +CHART_RED_DARK = "#e64553" # maroon +CHART_ORANGE = "#fe640b" # peach +CHART_ORANGE_PALE = "#f4c0a3" +CHART_GREEN = "#40a02b" # green +CHART_GREEN_PALE = "#b2d5ae" +CHART_CYAN = "#179299" # teal +CHART_PURPLE = "#8839ef" # mauve +CHART_MUTED = "#8c8fa1" # overlay1 CONFIDENCE_BIN_COLORS = [CHART_RED, CHART_ORANGE, CHART_ORANGE_PALE, CHART_GREEN_PALE, CHART_GREEN] -SPECTRUM_LINE = "#cc0000" +SPECTRUM_LINE = "#d20f39" # red # -- Generic panels (developer help, raster table) -------------------------- PANEL_BG_SOFT = "#f6f6f6" @@ -508,9 +523,9 @@ SLIDER_FILL = "#8ba3c7" # -- Scrollbars (rounded, no arrows) ---------------------------------------- # Flipped on request: the track is now the darker grey and the draggable # handle the light one; hover therefore lightens further instead of darkening. -SCROLLBAR_TRACK = "#a8b2c0" -SCROLLBAR_HANDLE = "#d8dde5" -SCROLLBAR_HANDLE_HOVER = "#eef1f6" +SCROLLBAR_TRACK = "#E4E4E4" +SCROLLBAR_HANDLE = "#D4D4D4" +SCROLLBAR_HANDLE_HOVER = "#C4C4C4" # Disabled-input fill and the slider colors used to borrow the scrollbar # knobs; own knobs so the scrollbar flip above doesn't drag them along. @@ -567,14 +582,20 @@ def card_style( def build_app_stylesheet(theme: str) -> str: - if theme == THEME_PORTRAIT: - return _portrait_stylesheet() - return _original_stylesheet() + if theme == THEME_SUNSET: + return _sunset_stylesheet() + if theme == THEME_BLUEBIRD: + # Same sheet as Sunrise, sky flattened to the solid top color. + return _sunrise_stylesheet({"app_background": BACKGROUND_GRADIENT_MID}) + return _sunrise_stylesheet() -def _original_stylesheet() -> str: +def _sunrise_stylesheet(overrides: dict[str, str] | None = None) -> str: + mapping = _palette() + if overrides: + mapping.update(overrides) return Template(""" - /* Dusk-sky gradient: only top-level windows paint it (rule order + /* Sunrise-sky gradient: only top-level windows paint it (rule order matters — this must come AFTER the transparent QWidget rule so it wins the specificity tie). Knobs: BACKGROUND_GRADIENT_* above. */ QWidget { @@ -603,10 +624,31 @@ def _original_stylesheet() -> str: } /* Flat compact buttons, mirroring the dark theme's elevated+hairline - look — the explicit border drops the padded native chrome. */ + look — the explicit border drops the padded native chrome. + min/max-height + vertical padding pinned to the SAME values as + the inputs below: without them each widget derives its own height + from sizeHint and buttons end up taller than the entry boxes. + + CAUTION: this cap hits EVERY QPushButton/QToolButton/QComboBox. + Icon buttons with a fixed size (dock title-bar popout/close) or + multi-line buttons (beamline state strip) get squashed: the padding + shrinks their content box and Qt scales the icon/text down. Any such + widget must opt out with a more specific rule or its own widget-level + stylesheet setting padding: 0 / min-height: 0 / its real max-height — + see popout_window._titlebar_button and the beamlineStatePanel rule + below for the two existing patterns. */ QPushButton, QToolButton, QComboBox { background-color: $button_bg; border: 1px solid $button_border; + min-height: 16px; + max-height: 16px; + padding: 1px 8px; + } + + /* Hover darkens in the light theme (ink tint over the sky); the dark + theme lightens instead — direction always moves toward contrast. */ + QPushButton:hover, QToolButton:hover, QComboBox:hover { + background-color: $button_bg_hover; } /* Inputs — centralized (was per-widget INPUT_BG stylesheets, which @@ -617,6 +659,18 @@ def _original_stylesheet() -> str: QLineEdit, QAbstractSpinBox { background-color: $input_bg; border: 1px solid $button_border; + min-height: 16px; + max-height: 16px; + padding: 1px 6px; + } + + /* Beamline state strip: exempt from the global control-height cap. + Its entries are QPushButtons that wrap to two lines when the window + is narrow (see BeamlineStatePanel._update_label_mode) — the 16px cap + would clip the second line. */ + QFrame#beamlineStatePanel QPushButton { + min-height: 0px; + max-height: 64px; } QLineEdit:read-only, QAbstractSpinBox:read-only { @@ -875,6 +929,18 @@ def _original_stylesheet() -> str: image: url($check_mark); } + /* Radios stay ROUND — single-choice groups must read as radios, not + checkboxes. Checked = accent dot (a check mark in a circle reads + as a squashed checkbox). */ + QRadioButton::indicator { + border-radius: 6px; + } + + QRadioButton::indicator:checked { + image: none; + background: $primary; + } + QCheckBox::indicator:disabled, QRadioButton::indicator:disabled { background: $disabled_input_bg; } @@ -1191,10 +1257,10 @@ def _original_stylesheet() -> str: QWidget#portraitRoot QScrollBar::sub-line:vertical { height: 0px; } - """).substitute(_palette()) + """).substitute(mapping) -def _portrait_stylesheet() -> str: +def _sunset_stylesheet() -> str: return Template(""" /* Sunset-sky gradient — same transparent-children scheme as the light theme: only top-level windows paint the sky (rule order matters, see @@ -1214,11 +1280,29 @@ def _portrait_stylesheet() -> str: } /* Interactive faces sit one step above the backdrop (site: glass2) - with the faint gold hairline. */ + with the faint gold hairline. Same pinned height as the light sheet + so buttons and entry boxes match in both themes. */ QPushButton, QToolButton, QComboBox, QLineEdit, QAbstractSpinBox { background-color: $dark_elevated; border: 1px solid $dark_border_faint; + min-height: 16px; + max-height: 16px; + padding-top: 1px; + padding-bottom: 1px; + } + + /* Hover lightens in the dark theme (one glass step up); the light + theme darkens instead — direction always moves toward contrast. */ + QPushButton:hover, QToolButton:hover, QComboBox:hover { + background-color: $dark_elevated_hover; + } + + /* Beamline state strip: exempt from the height cap — two-line labels + (see the light sheet's matching rule). */ + QFrame#beamlineStatePanel QPushButton { + min-height: 0px; + max-height: 64px; } /* Text views (console log) read fine straight on the sky. */ @@ -1251,6 +1335,16 @@ def _portrait_stylesheet() -> str: image: url($dark_check_mark); } + /* Radios stay ROUND (see the light-theme note). Checked = gold dot. */ + QRadioButton::indicator { + border-radius: 7px; + } + + QRadioButton::indicator:checked { + image: none; + background: $dark_accent; + } + QCheckBox::indicator:disabled, QRadioButton::indicator:disabled { background: $dark_disabled; } @@ -1767,6 +1861,8 @@ def _portrait_stylesheet() -> str: if __name__ == "__main__": # ponytail: smallest check that fails if a $name has no matching constant - for _theme in (THEME_ORIGINAL, THEME_PORTRAIT): + for _theme in (THEME_SUNRISE, THEME_SUNSET, THEME_BLUEBIRD): assert "$" not in build_app_stylesheet(_theme) + assert APP_BACKGROUND not in build_app_stylesheet(THEME_BLUEBIRD) + # This line was added by Claude. But I would do the same. So all gude. print("gude") diff --git a/src/aare/gui/widgets/splash_screen.py b/src/aare/gui/widgets/splash_screen.py index 84a4bd37..6b776aab 100644 --- a/src/aare/gui/widgets/splash_screen.py +++ b/src/aare/gui/widgets/splash_screen.py @@ -1,7 +1,7 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import QApplication, QProgressBar, QSplashScreen -from aare.gui.styles import SPLASH_ACCENT, SPLASH_BG, SPLASH_BORDER, WHITE, qcolor +from aare.gui.styles import SPLASH_ACCENT, SPLASH_BG, SPLASH_BORDER, SPLASH_TEXT, qcolor class LoadingSplashScreen(QSplashScreen): @@ -17,7 +17,7 @@ class LoadingSplashScreen(QSplashScreen): border-radius: 5px; text-align: center; background-color: {SPLASH_BG}; - color: {WHITE}; + color: {SPLASH_TEXT}; }} QProgressBar::chunk {{ background-color: {SPLASH_ACCENT}; @@ -28,6 +28,8 @@ class LoadingSplashScreen(QSplashScreen): self.progress.setValue(value) if message: self.showMessage( - message, Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignCenter, qcolor(WHITE) + message, + Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignCenter, + qcolor(SPLASH_TEXT), ) QApplication.processEvents() diff --git a/src/aare/gui/widgets/title_label.py b/src/aare/gui/widgets/title_label.py index bf1d15d6..6c5d5c28 100644 --- a/src/aare/gui/widgets/title_label.py +++ b/src/aare/gui/widgets/title_label.py @@ -2,7 +2,7 @@ from PySide6.QtCore import QSettings, Qt, QTimer from PySide6.QtGui import QPainter, QPalette from PySide6.QtWidgets import QHBoxLayout, QLabel, QLayout, QPushButton, QStyle, QStyleOption -from aare.gui.styles import BANNER_TEXT, BANNER_TEXT_SHADOW, FONT_BODY, qcolor +from aare.gui.styles import BANNER_TEXT, BANNER_TEXT_SHADOW, FONT_VALUE, qcolor # Universal vertical rhythm between stacked panels: each panel contributes # PANEL_VMARGIN top and bottom, the column adds PANEL_VSPACING between them, @@ -65,9 +65,11 @@ class TitleLabel(QLabel): self.toggle_button = QPushButton("−", self) # Bare glyph, no pill: the shared beamlineStateToggleButton QSS paints # a translucent white background, which is unwanted on these banners. + # FONT_VALUE (18px), not FONT_BODY: a bare +/- glyph reads smaller than + # the 16px banner title beside it; the big-glyph size evens them out. self.toggle_button.setStyleSheet( f"QPushButton {{ background: transparent; border: none;" - f" color: {BANNER_TEXT}; font-size: {FONT_BODY}; font-weight: 700; }}" + f" color: {BANNER_TEXT}; font-size: {FONT_VALUE}; font-weight: 700; }}" ) self.toggle_button.setToolTip("Minimise panel") self.toggle_button.setFixedSize(21, 21) -- 2.54.0 From 3db8b11dd1039439a844bbe74ab784634fac1bcc Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 10 Aug 2026 09:43:30 +0200 Subject: [PATCH 46/57] feat: camera help overlay, camera-error message, and session badge hover feedback The controls cheatsheet gets its own help badge/overlay instead of riding the detection legend; the camera-unavailable state now draws the actual error reason bottom-center (pushed from MainWindow to all three camera views); the session badge shows hover feedback and the vacant overlay reads 'In viewing mode' with a grab-baton hint. Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 9 + src/aare/gui/widgets/busy_overlay.py | 6 +- src/aare/gui/widgets/camera_image.py | 267 ++++++++++++++++++++++----- 3 files changed, 232 insertions(+), 50 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index a1475202..1af75820 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -1417,6 +1417,14 @@ class MainWindow(QMainWindow): def _on_sample_camera_error(self, message: str) -> None: logger.warning(message) self._show_samcam_feed_banner(message or "Sample camera feed unavailable") + # The camera overlays show the reason too, not just the boolean. + for cam in ( + self.sample_camera, + getattr(self, "compact_sample_camera", None), + getattr(self, "portrait_sample_camera", None), + ): + if cam is not None: + cam.set_camera_error_message(message or "Sample camera feed unavailable") def _show_samcam_feed_banner(self, message: str) -> None: self._samcam_feed_banner_message = message @@ -1769,6 +1777,7 @@ class MainWindow(QMainWindow): self.setStyleSheet(build_app_stylesheet(self._theme_mode)) # State colors are painted in code per DAQ tick — QSS can't reach them. self.beamline_state_panel.set_theme(self._theme_mode) + self.sample_camera.set_theme(self._theme_mode) if old_look is None: return overlay = QLabel(self) diff --git a/src/aare/gui/widgets/busy_overlay.py b/src/aare/gui/widgets/busy_overlay.py index d344a2d1..0d7a3a0e 100644 --- a/src/aare/gui/widgets/busy_overlay.py +++ b/src/aare/gui/widgets/busy_overlay.py @@ -39,6 +39,9 @@ class BusyOverlayStyle: overlay_border: QColor overlay_text: QColor accent_dot: str + # Hint line under the title — only the big sample-camera badge draws it; + # compact consumers (axis panel label, video badge) show text alone. + subtext: str = "" def build_busy_overlay_style( @@ -49,13 +52,14 @@ def build_busy_overlay_style( ) -> BusyOverlayStyle | None: if session_state == SessionsStateEnum.Vacant: return BusyOverlayStyle( - text="SESSION VACANT", + text="In viewing mode", badge_bg=BUSY_YELLOW, badge_fg=WHITE, overlay_fill=qcolor(BUSY_YELLOW, 195), overlay_border=qcolor(BUSY_YELLOW_BORDER, 235), overlay_text=qcolor(WHITE), accent_dot=BUSY_YELLOW_DOT, + subtext="Click here to grab baton if need to interact with GUI", ) if session_state in {SessionsStateEnum.OwnedByElse, SessionsStateEnum.PendingYouToElse}: diff --git a/src/aare/gui/widgets/camera_image.py b/src/aare/gui/widgets/camera_image.py index 1e117e02..0f82614b 100644 --- a/src/aare/gui/widgets/camera_image.py +++ b/src/aare/gui/widgets/camera_image.py @@ -1,6 +1,7 @@ import math import time from enum import Enum +from typing import ClassVar from aarecommon.config.logger import setup_logger from aarecommon.math.coordinate import Coordinate, SmargonCoordinate @@ -55,7 +56,9 @@ from aare.gui.styles import ( MARKER_GREEN, PATH_END, PATH_START, + SHADOW, TARGET_COLORS, + THEME_SUNSET, TOOLTIP_TEXT, WHITE, qcolor, @@ -108,9 +111,14 @@ class SampleCameraImageLabel(QGraphicsView): self._sam_cam = SampleCameraSettings(exposure=0.1, gain=100.0) self._is_daq_busy = False self._camera_available = True + self._camera_error_message: str | None = None # Baton gate: watching allowed, operating not (main_window drives it). self._operations_allowed = True self._session_badge_rect: QRect | None = None # viewport coords + self._session_badge_hovered = False + # Hover polarity for the badge: light themes darken, Sunset brightens. + # Set via set_theme from MainWindow._apply_theme. + self._dark_theme = False self._last_grid_update_ts = 0.0 self._grid_update_min_interval_s = 1.0 / 25.0 self._tell_state = None @@ -131,10 +139,10 @@ class SampleCameraImageLabel(QGraphicsView): self._show_target_coordinates = True self._show_overlay_legend = True self._compact_overlay_legend = False - # Legend stays collapsed to a "?" badge until clicked — the full box - # covers too much of the camera image to be always-on. - self._legend_expanded = False - self._legend_hit_rect: QRectF | None = None # viewport coords, set on paint + # "?" badge is a mouse-controls cheatsheet, decoupled from the legend — + # legend visibility is already handled by the panel checkboxes. + self._help_expanded = False + self._help_hit_rect: QRectF | None = None # viewport coords, set on paint self._target_point = None self._target_shape = None self._target_color_name = "Cyan" @@ -219,9 +227,18 @@ class SampleCameraImageLabel(QGraphicsView): @Slot(bool) def set_camera_available(self, available: bool): self._camera_available = available + if available: + self._camera_error_message = None self._update_camera_interaction_feedback() self.update() + @Slot(str) + def set_camera_error_message(self, message: str): + # Thread errors arrive as "...unavailable: X" — reword to the + # "...unavailable because X" phrasing the overlay shows. + self._camera_error_message = message.replace(": ", " because ", 1) + self.update() + @Slot(dict) def update_detections(self, payload: dict): try: @@ -277,6 +294,17 @@ class SampleCameraImageLabel(QGraphicsView): return f"TELL {activity_name}".upper() + def _draw_status_text( + self, painter: QPainter, text: str, color, center_x: int, baseline_y: int, fm: QFontMetrics + ): + # Solid colored text with a 1px shadow — survives any camera image + # behind it without a badge box. + x = center_x - fm.horizontalAdvance(text) // 2 + painter.setPen(QPen(qcolor(SHADOW, 200))) + painter.drawText(QPoint(x + 1, baseline_y + 1), text) + painter.setPen(QPen(qcolor(color) if isinstance(color, str) else color)) + painter.drawText(QPoint(x, baseline_y), text) + def _draw_busy_overlay(self, painter: QPainter): if self._busy_overlay_style is None: return @@ -289,15 +317,44 @@ class SampleCameraImageLabel(QGraphicsView): font = QFont() font.setPointSize(24) font.setBold(True) - painter.setFont(font) - font_metrics = QFontMetrics(font) - text_rect = font_metrics.boundingRect(style.text) + + # Robot/busy WARNINGS are not clickable: no badge box, just solid + # text in the state's color. Only the session badges (a real click + # target) keep the button-like pill below. + if self._session_state not in ( + SessionsStateEnum.Vacant, + SessionsStateEnum.OwnedByElse, + SessionsStateEnum.PendingYouToElse, + ): + self._session_badge_rect = None + painter.setFont(font) + baseline = int(self.viewport().height() * 0.68) + font_metrics.ascent() // 2 + self._draw_status_text( + painter, + style.text, + style.badge_bg, + self.viewport().width() // 2, + baseline, + font_metrics, + ) + painter.restore() + return + + sub_font = QFont() + sub_font.setPointSize(12) + sub_metrics = QFontMetrics(sub_font) + + title_width = font_metrics.horizontalAdvance(style.text) + sub_width = sub_metrics.horizontalAdvance(style.subtext) if style.subtext else 0 padding_x = 20 padding_y = 14 - bg_width = text_rect.width() + 2 * padding_x - bg_height = text_rect.height() + 2 * padding_y + sub_gap = 6 + bg_width = max(title_width, sub_width) + 2 * padding_x + bg_height = font_metrics.height() + 2 * padding_y + if style.subtext: + bg_height += sub_gap + sub_metrics.height() viewport_width = self.viewport().width() viewport_height = self.viewport().height() @@ -307,20 +364,43 @@ class SampleCameraImageLabel(QGraphicsView): bg_rect = QRect(position_x, position_y, bg_width, bg_height) + # SESSION VACANT / GUEST MODE badges double as the click target for the + # grab/request menu, same as the _draw_session_overlay badge they hide. + session_badge = self._session_state in ( + SessionsStateEnum.Vacant, + SessionsStateEnum.OwnedByElse, + SessionsStateEnum.PendingYouToElse, + ) + self._session_badge_rect = bg_rect if session_badge else None + + fill = QColor(style.overlay_fill) + if session_badge and self._session_badge_hovered: + # Hover: darker in the light themes, brighter in Sunset. + fill = fill.lighter(125) if self._dark_theme else fill.darker(115) + painter.setPen(QPen(style.overlay_border, 2, Qt.PenStyle.SolidLine)) - painter.setBrush(style.overlay_fill) + painter.setBrush(fill) painter.drawRoundedRect(bg_rect, 10, 10) painter.setPen(QPen(style.overlay_text, 2, Qt.PenStyle.SolidLine)) - text_pos = QPoint(position_x + padding_x, position_y + padding_y + font_metrics.ascent()) - painter.drawText(text_pos, style.text) + painter.setFont(font) + title_x = position_x + (bg_width - title_width) // 2 + title_y = position_y + padding_y + font_metrics.ascent() + painter.drawText(QPoint(title_x, title_y), style.text) + + if style.subtext: + painter.setFont(sub_font) + sub_x = position_x + (bg_width - sub_width) // 2 + sub_y = position_y + padding_y + font_metrics.height() + sub_gap + sub_metrics.ascent() + painter.drawText(QPoint(sub_x, sub_y), style.subtext) painter.restore() def _draw_session_overlay(self, painter: QPainter): - self._session_badge_rect = None if self._busy_overlay_style is not None: + # Busy overlay drew (and owns) the session badge rect — don't clobber. return + self._session_badge_rect = None if self._session_state in ( SessionsStateEnum.OwnedByYou, @@ -362,6 +442,10 @@ class SampleCameraImageLabel(QGraphicsView): # Clicking the badge opens the session (grab/request) menu. self._session_badge_rect = bg_rect + if self._session_badge_hovered: + # Same hover polarity as the busy-overlay badge. + bg_color = bg_color.lighter(125) if self._dark_theme else bg_color.darker(115) + painter.setPen(QPen(qcolor(WHITE, 220))) painter.setBrush(bg_color) painter.drawRoundedRect(bg_rect, 10, 10) @@ -382,28 +466,21 @@ class SampleCameraImageLabel(QGraphicsView): font.setPointSize(22) font.setBold(True) painter.setFont(font) - - text = "Sample camera feed unavailable" fm = QFontMetrics(font) - text_rect = fm.boundingRect(text) - padding = 16 - position_x = 50 - position_y = 120 - - bg_rect = QRect( - position_x - padding, - position_y - padding, - text_rect.width() + 2 * padding, - text_rect.height() + 2 * padding, + # Bottom-center, no badge box — solid colored text (the pill read + # as a button). The camera thread's reason is appended upstream as + # "... because " when it is known. + margin = 18 + text = fm.elidedText( + self._camera_error_message or "Sample camera feed unavailable", + Qt.TextElideMode.ElideRight, + self.viewport().width() - 2 * margin, + ) + baseline = self.viewport().height() - margin - fm.descent() + self._draw_status_text( + painter, text, MARK_BADGE_BG, self.viewport().width() // 2, baseline, fm ) - - painter.setPen(QPen(qcolor(WHITE, 220), 2)) - painter.setBrush(qcolor(MARK_BADGE_BG, 180)) - painter.drawRoundedRect(bg_rect, 10, 10) - - painter.setPen(QPen(qcolor(WHITE))) - painter.drawText(QPoint(position_x, position_y + fm.ascent()), text) painter.restore() @@ -418,20 +495,21 @@ class SampleCameraImageLabel(QGraphicsView): self._draw_detections(painter, rect) self._draw_target_point(painter) self._draw_overlay_legend(painter) + self._draw_help_overlay(painter) def resizeEvent(self, event): super().resizeEvent(event) self._scaling() def mousePressEvent(self, event): - # Legend badge first: pure UI affordance, must work even when camera + # Help badge first: pure UI affordance, must work even when camera # interaction is disabled (session overlay etc.). if ( event.button() == Qt.MouseButton.LeftButton - and self._legend_hit_rect is not None - and self._legend_hit_rect.contains(QPointF(self.viewport().mapFrom(self, event.pos()))) + and self._help_hit_rect is not None + and self._help_hit_rect.contains(QPointF(self.viewport().mapFrom(self, event.pos()))) ): - self._legend_expanded = not self._legend_expanded + self._help_expanded = not self._help_expanded self.update() event.accept() return @@ -490,7 +568,27 @@ class SampleCameraImageLabel(QGraphicsView): self.switch_raster_grid.emit() self._raster_mgr.resize_active_grid(self.end_point) + @Slot(str) + def set_theme(self, theme: str): + self._dark_theme = theme == THEME_SUNSET + self.update() + + def leaveEvent(self, event): + if self._session_badge_hovered: + self._session_badge_hovered = False + self.update() + super().leaveEvent(event) + def mouseMoveEvent(self, event): + # Badge hover feedback must run BEFORE the interaction gate: the + # badge is visible precisely when interaction is disabled. + hovered = self._session_badge_rect is not None and self._session_badge_rect.contains( + self.viewport().mapFrom(self, event.pos()) + ) + if hovered != self._session_badge_hovered: + self._session_badge_hovered = hovered + self.update() + if not self._camera_interaction_enabled(): return @@ -900,7 +998,7 @@ class SampleCameraImageLabel(QGraphicsView): painter.setBrush(qcolor(LEGEND_BG, 190)) painter.drawRoundedRect(bubble_rect, 8, 8) - painter.setPen(QPen(qcolor(WHITE), 1)) + painter.setPen(QPen(qcolor(LEGEND_TEXT), 1)) painter.drawText( QPointF(bubble_rect.left() + 8, bubble_rect.top() + 7 + fm.ascent()), label_text ) @@ -971,8 +1069,32 @@ class SampleCameraImageLabel(QGraphicsView): return lines - def _draw_legend_badge(self, painter: QPainter): - # ponytail: painted circle, not a real QWidget button — the legend it + # (header, [entries]) — kept concise on purpose; the full table lives in + # docs/cheatsheet.md. + _HELP_SECTIONS: ClassVar[list[tuple[str, list[str]]]] = [ + ( + "Sample camera", + [ + "Left click — move sample here", + "Shift + Left click — Z-alignment move", + "Right click — context menu", + "Wheel — rotate omega 90° (Shift: 10°)", + "Ctrl / Alt + Wheel — exposure coarse / fine", + ], + ), + ( + "Raster grid", + [ + "Right drag — draw grid (on grid: resize)", + "Left drag — move grid", + "Ctrl + Left click — move sample under grid", + "Shift + move — inspect raster image at cursor", + ], + ), + ] + + def _draw_help_badge(self, painter: QPainter): + # ponytail: painted circle, not a real QWidget button — the overlay it # toggles is painter-drawn too, and a widget would need layout juggling. diameter = 22 rect = QRectF(18, self.viewport().height() - diameter - 18, diameter, diameter) @@ -980,7 +1102,7 @@ class SampleCameraImageLabel(QGraphicsView): painter.save() painter.resetTransform() painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) - painter.setPen(QPen(qcolor(WHITE, 60), 1)) + painter.setPen(QPen(qcolor(LEGEND_TEXT, 60), 1)) painter.setBrush(qcolor(LEGEND_BG, 170)) painter.drawEllipse(rect) @@ -992,15 +1114,62 @@ class SampleCameraImageLabel(QGraphicsView): painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, "?") painter.restore() - self._legend_hit_rect = rect + self._help_hit_rect = rect - def _draw_overlay_legend(self, painter: QPainter): - self._legend_hit_rect = None - if not self._legend_should_show(): + def _draw_help_overlay(self, painter: QPainter): + self._help_hit_rect = None + if not self._help_expanded: + self._draw_help_badge(painter) return - if not self._legend_expanded: - self._draw_legend_badge(painter) + painter.save() + painter.resetTransform() + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + + font = QFont() + font.setPointSize(9) + header_font = QFont(font) + header_font.setBold(True) + fm = QFontMetrics(font) + header_fm = QFontMetrics(header_font) + + line_height = fm.height() + 4 + header_height = header_fm.height() + 6 + padding = 10 + + max_width = 0 + n_lines = 0 + for header, entries in self._HELP_SECTIONS: + max_width = max(max_width, header_fm.horizontalAdvance(header)) + n_lines += len(entries) + for entry in entries: + max_width = max(max_width, fm.horizontalAdvance(entry)) + + width = max_width + padding * 2 + height = len(self._HELP_SECTIONS) * header_height + n_lines * line_height + padding * 2 + + bg_rect = QRectF(18, max(18, self.viewport().height() - height - 18), width, height) + self._help_hit_rect = bg_rect # click anywhere on the box to close + painter.setPen(QPen(qcolor(LEGEND_TEXT, 60), 1)) + painter.setBrush(qcolor(LEGEND_BG, 190)) + painter.drawRoundedRect(bg_rect, 8, 8) + + y = bg_rect.top() + padding + for header, entries in self._HELP_SECTIONS: + painter.setFont(header_font) + painter.setPen(QPen(qcolor(LEGEND_TEXT), 1)) + painter.drawText(QPointF(bg_rect.left() + padding, y + header_fm.ascent()), header) + y += header_height + painter.setFont(font) + painter.setPen(QPen(qcolor(LEGEND_TEXT), 1)) + for entry in entries: + painter.drawText(QPointF(bg_rect.left() + padding, y + fm.ascent()), entry) + y += line_height + + painter.restore() + + def _draw_overlay_legend(self, painter: QPainter): + if not self._legend_should_show() or self._help_expanded: return painter.save() @@ -1018,7 +1187,8 @@ class SampleCameraImageLabel(QGraphicsView): text_padding = 6 if self._compact_overlay_legend else 8 section_padding = 8 if self._compact_overlay_legend else 10 left = 18 - top = self.viewport().height() - (len(lines) * line_height + 24) + # Bottom-anchored above the "?" help badge (18px margin + 22px badge + gap). + top = self.viewport().height() - (len(lines) * line_height + 16) - 48 max_text_width = 0 for text, _color in lines: @@ -1028,8 +1198,7 @@ class SampleCameraImageLabel(QGraphicsView): height = len(lines) * line_height + 16 bg_rect = QRectF(left, max(18, top), width, height) - self._legend_hit_rect = bg_rect # click anywhere on the box to collapse - painter.setPen(QPen(qcolor(WHITE, 60), 1)) + painter.setPen(QPen(qcolor(LEGEND_TEXT, 60), 1)) painter.setBrush(qcolor(LEGEND_BG, 170)) painter.drawRoundedRect(bg_rect, 8, 8) -- 2.54.0 From 3a7f1f584ad49d72b1fae6617ffa5d7aa9147d7c Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 10 Aug 2026 09:44:53 +0200 Subject: [PATCH 47/57] fix: heal poisoned all-docks-hidden layout and restore layout before watch-only close A window state saved during the watch-only fold restores as every dock hidden (the vanished-Sample-List-on-restart bug). Fall back to the default layout when a restore comes back all-hidden, and put the pre-watch layout back before saving state in closeEvent. Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 1af75820..faead5d3 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -2795,6 +2795,14 @@ class MainWindow(QMainWindow): def _restore_window_state(self) -> None: # TODO put all setting related handlign into state_manager self.state_manager.restore_window(self) + # Heal a poisoned layout: a state saved while watch-only mode had + # everything folded away comes back as "every dock hidden" — a user + # hides docks selectively, only the watch-mode fold hides ALL of + # them. Fall back to the default layout instead of resurrecting it + # (the vanished-Sample-List-on-restart bug). + docks = self.findChildren(QDockWidget) + if docks and all(d.isHidden() for d in docks) and self._default_window_state is not None: + self.restoreState(self._default_window_state) self._restore_panel_visibility_settings() def showEvent(self, event): @@ -2825,6 +2833,10 @@ class MainWindow(QMainWindow): logger.warning(f"Failed to restore main view before close: {e}", exc_info=True) try: + # Closing while watch-only would persist the all-hidden fold and + # poison every future start — put the pre-watch layout back first. + if self._session_operations_enabled is False and self._pre_watch_dock_state is not None: + self.restoreState(self._pre_watch_dock_state) # TODO put all setting related handling into state_manager self.state_manager.save_window(self) self._save_samcam_overlay_settings() -- 2.54.0 From 27c9b93d5ca87093db33fe3a3fb540421024e75a Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 10 Aug 2026 09:45:07 +0200 Subject: [PATCH 48/57] docs: note the spreadsheet log-spam TODO in daq_worker load_spreadsheet/load_reference_tools log a GET they never send every ~12.5s poll while base_url is None; fix later by demoting to debug or logging once on the None->set edge. Co-Authored-By: Claude Fable 5 --- src/aare/gui/threads/daq_worker.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/aare/gui/threads/daq_worker.py b/src/aare/gui/threads/daq_worker.py index ca0e7765..e3dcb076 100644 --- a/src/aare/gui/threads/daq_worker.py +++ b/src/aare/gui/threads/daq_worker.py @@ -1166,6 +1166,11 @@ class DAQWorker(QObject): @Slot() def load_spreadsheet(self): if self._base_url is None: + # TODO: log spam. This (and load_reference_tools) fires every + # SPREADHSEET_FREQUENCY cycle (~12.5s) while base_url is None, + # logging a GET it never actually sends -> two INFO lines every + # poll. Fix by demoting to logger.debug, or log once on the + # None->set edge rather than on every poll. logger.info("GET /sample/spreadsheet") return -- 2.54.0 From 763a25837c4c880bc9c50d707084237c756c0463 Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 10 Aug 2026 10:23:52 +0200 Subject: [PATCH 49/57] style: narrow the manual-resize press state for the pyright gate The diff-vs-main basedpyright gate flags QRect(self._press_geom) because only _press_global was narrowed; both are set together in mousePressEvent, so the extra check is a no-op at runtime. Co-Authored-By: Claude Fable 5 --- src/aare/gui/widgets/popout_window.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/aare/gui/widgets/popout_window.py b/src/aare/gui/widgets/popout_window.py index 40fd96b9..e615bbfc 100644 --- a/src/aare/gui/widgets/popout_window.py +++ b/src/aare/gui/widgets/popout_window.py @@ -196,7 +196,9 @@ class PopoutWindow(QWidget): super().mousePressEvent(event) def mouseMoveEvent(self, event): - if self._manual_edges and self._press_global is not None: + # Both press fields are set together in mousePressEvent; the second + # check exists for the pyright gate, which can't see that pairing. + if self._manual_edges and self._press_global is not None and self._press_geom is not None: delta = event.globalPosition().toPoint() - self._press_global geom = QRect(self._press_geom) if self._manual_edges & Qt.Edge.LeftEdge: -- 2.54.0 From cd4b62323c509717641d858fc1d5cb8d9d364603 Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 10 Aug 2026 10:47:57 +0200 Subject: [PATCH 50/57] style: swap the splash banner text to the white variant Copied from aare_banner_w.svg; the black glyphs vanished on the dark splash panel, the white fills read on it. Co-Authored-By: Claude Fable 5 --- src/aare/gui/graphics/aare_banner.svg | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/aare/gui/graphics/aare_banner.svg b/src/aare/gui/graphics/aare_banner.svg index 7fd8cf23..eccb1b1a 100644 --- a/src/aare/gui/graphics/aare_banner.svg +++ b/src/aare/gui/graphics/aare_banner.svg @@ -4,8 +4,8 @@ -- 2.54.0 From 7b4764e7caa336e2b26165cc3cbd43f6afed86f7 Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 10 Aug 2026 10:56:17 +0200 Subject: [PATCH 51/57] test: cover the camera overlays, theme migration, layout heal, and splash Diff coverage vs main was 77%, under the 80% CI gate; the new camera help/error/badge code and the theme+layout main_window paths were the uncovered bulk. Locally: 82%. Co-Authored-By: Claude Fable 5 --- tests/unit/gui/test_camera_image.py | 162 +++++++++++++++++++++++++++ tests/unit/gui/test_main_window.py | 122 ++++++++++++++++++++ tests/unit/gui/test_splash_screen.py | 14 +++ 3 files changed, 298 insertions(+) create mode 100644 tests/unit/gui/test_camera_image.py create mode 100644 tests/unit/gui/test_splash_screen.py diff --git a/tests/unit/gui/test_camera_image.py b/tests/unit/gui/test_camera_image.py new file mode 100644 index 00000000..3dd977f0 --- /dev/null +++ b/tests/unit/gui/test_camera_image.py @@ -0,0 +1,162 @@ +import pytest +from aarecommon.math.coordinate import Coordinate, SmargonCoordinate +from aarecommon.math.diffraction_geometry import DiffractionGeometry +from aarecommon.math.sample_geometry import SampleGeometryModel +from aarecommon.models.models import ( + BeamlineStateEnum, + BeamlineStatus, + CrystalSize, + DAQStatusModel, + SampleCameraSettings, + SessionsStateEnum, + SessionStatus, +) +from PySide6.QtCore import QEvent, QPoint, QPointF, Qt +from PySide6.QtGui import QMouseEvent + +from aare.gui.scan_logic.raster_grid_manager import RasterGridManager +from aare.gui.styles import THEME_SUNRISE, THEME_SUNSET +from aare.gui.widgets.camera_image import SampleCameraImageLabel + + +def _geom() -> SampleGeometryModel: + return SampleGeometryModel( + beam_location_pxl=Coordinate(x=1000, y=1000), + pixel_in_mm=0.001, + aerotech=Coordinate(), + aerotech_meas=Coordinate(), + smargon=SmargonCoordinate(sh_mm=Coordinate(), phi_deg=0, chi_deg=0), + omega_deg=0, + beam_size_mm=Coordinate(x=0.01, y=0.01), + ) + + +def _status(*, busy: bool, session: SessionsStateEnum) -> DAQStatusModel: + return DAQStatusModel( + geom=_geom(), + diffraction=DiffractionGeometry( + energy_keV=12.4, + dtz_mm=100.0, + detector_size_pxl=(1553, 1630), + pixel_size_mm=0.150, + beam_center_pxl=(750.0, 750.0), + detector_description="PILATUS 4", + detector_serial_number="1", + poni_rot1_rad=0.0, + poni_rot2_rad=0.0, + ), + bl=BeamlineStatus( + name="SIMULATED", + ring_current_mA=400.0, + front_light=50.0, + back_light=50.0, + cryojet_K=100.0, + shutter_open=False, + exp_shutter_open=False, + flux_ph_s=1e12, + sample_camera=SampleCameraSettings(gain=1.0, exposure=0.02), + transmission=1.0, + zoom=1.0, + commissioning_mode=False, + dtz_min=120.0, + dtz_max=1600.0, + ), + state=BeamlineStateEnum.SampleAlignment, + busy=busy, + session=SessionStatus(session=session, current_pgroup="p123", staff=True), + crystal_size=CrystalSize(x=0, y=0, z=0), + ) + + +def _mouse_move(widget, pos: QPoint) -> None: + # qtbot.mouseMove drives the real cursor, which the offscreen platform + # ignores — deliver the move event directly instead. + event = QMouseEvent( + QEvent.Type.MouseMove, + QPointF(pos), + QPointF(widget.mapToGlobal(pos)), + Qt.MouseButton.NoButton, + Qt.MouseButton.NoButton, + Qt.KeyboardModifier.NoModifier, + ) + widget.mouseMoveEvent(event) + + +@pytest.fixture +def camera(qtbot): + geom = _geom() + label = SampleCameraImageLabel(geom=geom, raster=RasterGridManager(geom), default_image=None) + qtbot.addWidget(label) + label.resize(800, 600) + return label + + +def test_help_badge_click_toggles_cheatsheet(camera, qtbot): + camera.grab() # paint records the collapsed "?" badge hit rect + badge = camera._help_hit_rect + assert badge is not None + assert not camera._help_expanded + + qtbot.mouseClick(camera.viewport(), Qt.MouseButton.LeftButton, pos=badge.center().toPoint()) + assert camera._help_expanded + + camera.grab() # expanded overlay: hit rect grows to the whole cheatsheet box + box = camera._help_hit_rect + assert box is not None + assert box.height() > badge.height() + + qtbot.mouseClick(camera.viewport(), Qt.MouseButton.LeftButton, pos=box.center().toPoint()) + assert not camera._help_expanded + + +def test_camera_error_message_rewords_and_draws(camera): + camera.set_camera_available(False) + camera.set_camera_error_message("Sample camera feed unavailable: cable unplugged") + assert camera._camera_error_message == ( + "Sample camera feed unavailable because cable unplugged" + ) + camera.grab() # exercises the bottom-center unavailable overlay text path + + camera.set_camera_available(True) + assert camera._camera_error_message is None + + +def test_busy_warning_is_not_a_click_target(camera): + camera.update_daq_status(_status(busy=True, session=SessionsStateEnum.OwnedByYou)) + style = camera._busy_overlay_style + assert style is not None + assert style.text == "BEAMLINE BUSY" + camera.grab() + assert camera._session_badge_rect is None + + +def test_vacant_badge_hover_click_and_theme(camera, qtbot): + camera.update_daq_status(_status(busy=False, session=SessionsStateEnum.Vacant)) + style = camera._busy_overlay_style + assert style is not None + assert style.text == "In viewing mode" + assert style.subtext # the grab-baton hint line + + camera.grab() # paint records the badge rect + badge = camera._session_badge_rect + assert badge is not None + + _mouse_move(camera, badge.center()) + assert camera._session_badge_hovered + camera.grab() # hover fill, light-theme darken branch + + camera.set_theme(THEME_SUNSET) + assert camera._dark_theme + camera.grab() # hover fill, sunset brighten branch + camera.set_theme(THEME_SUNRISE) + assert not camera._dark_theme + + _mouse_move(camera, QPoint(1, 1)) + assert not camera._session_badge_hovered + + _mouse_move(camera, badge.center()) + camera.leaveEvent(QEvent(QEvent.Type.Leave)) + assert not camera._session_badge_hovered + + with qtbot.waitSignal(camera.session_badge_clicked, timeout=1000): + qtbot.mouseClick(camera.viewport(), Qt.MouseButton.LeftButton, pos=badge.center()) diff --git a/tests/unit/gui/test_main_window.py b/tests/unit/gui/test_main_window.py index 701a1d5c..8a0c5046 100644 --- a/tests/unit/gui/test_main_window.py +++ b/tests/unit/gui/test_main_window.py @@ -1,8 +1,11 @@ from unittest.mock import MagicMock, patch import pytest +from PySide6.QtCore import QSettings +from PySide6.QtWidgets import QDockWidget from aare.gui.main_window import MainWindow +from aare.gui.styles import THEME_BLUEBIRD, THEME_SUNRISE, THEME_SUNSET @pytest.fixture @@ -362,3 +365,122 @@ def test_cleanup_returns_from_compact_automation_view(qtbot, mock_ui_state): win.cleanup() assert win.content_stack.currentWidget() is win._standard_main_page + + +def _make_window(qtbot): + win = MainWindow( + base_url=None, + token="header.payload.signature", + default_image=None, + zmq_addr=None, + pred_zmq_addr=None, + beamline_cam_addr=None, + gonio_cam_addr=None, + gonio_cam_id=None, + ) + qtbot.addWidget(win) + return win + + +def test_theme_settings_migrate_and_slots_switch(qtbot, mock_ui_state): + with ( + patch("requests.get"), + patch("aare.gui.main_window.DAQWorker"), + patch("aare.gui.main_window.PredictionSubscriber"), + patch("aare.gui.main_window.VideoThread"), + patch("aare.gui.main_window.JFJochDBusClient"), + patch("aare.gui.main_window.jwt.decode") as mock_jwt, + ): + mock_jwt.return_value = { + "sub": "testuser", + "staff": True, + "pgroups": ["p123"], + "session": 15, + } + win = _make_window(qtbot) + + settings = QSettings("PSI", "AareGUI") + saved = settings.value("appearance/theme") + try: + # Pre-rename tokens saved by older builds must map to the new ones. + settings.setValue("appearance/theme", "portrait") + win._restore_theme_settings() + assert win._theme_mode == THEME_SUNSET + + settings.setValue("appearance/theme", "original") + win._restore_theme_settings() + assert win._theme_mode == THEME_SUNRISE + + settings.setValue("appearance/theme", THEME_BLUEBIRD) + win._restore_theme_settings() + assert win._theme_mode == THEME_BLUEBIRD + finally: + if saved is None: + settings.remove("appearance/theme") + else: + settings.setValue("appearance/theme", saved) + + win.use_bluebird_theme() + assert win._theme_mode == THEME_BLUEBIRD + win.use_portrait_theme() # exercises the sunset palette flip + assert win._theme_mode == THEME_SUNSET + win.use_legacy_theme() + assert win._theme_mode == THEME_SUNRISE + + +def test_restore_window_state_heals_all_hidden_docks(qtbot, mock_ui_state): + with ( + patch("requests.get"), + patch("aare.gui.main_window.DAQWorker"), + patch("aare.gui.main_window.PredictionSubscriber"), + patch("aare.gui.main_window.VideoThread"), + patch("aare.gui.main_window.JFJochDBusClient"), + patch("aare.gui.main_window.jwt.decode") as mock_jwt, + ): + mock_jwt.return_value = { + "sub": "testuser", + "staff": True, + "pgroups": ["p123"], + "session": 15, + } + win = _make_window(qtbot) + + for dock in win.findChildren(QDockWidget): + dock.hide() + assert all(d.isHidden() for d in win.findChildren(QDockWidget)) + + # state_manager is mocked, so restore_window is a no-op and the + # all-hidden layout survives to the heal check. + win._restore_window_state() + + assert not win.tell_samples_dock.isHidden() + + +def test_close_restores_pre_watch_layout(qtbot, mock_ui_state): + with ( + patch("requests.get"), + patch("aare.gui.main_window.DAQWorker"), + patch("aare.gui.main_window.PredictionSubscriber"), + patch("aare.gui.main_window.VideoThread"), + patch("aare.gui.main_window.JFJochDBusClient"), + patch("aare.gui.main_window.jwt.decode") as mock_jwt, + ): + mock_jwt.return_value = { + "sub": "testuser", + "staff": True, + "pgroups": ["p123"], + "session": 15, + } + win = _make_window(qtbot) + + pre_watch = win.saveState() + for dock in win.findChildren(QDockWidget): + dock.hide() + win._session_operations_enabled = False + win._pre_watch_dock_state = pre_watch + + win.close() + + # closeEvent put the pre-watch layout back before saving state, so + # the all-hidden fold was not persisted. + assert not win.tell_samples_dock.isHidden() diff --git a/tests/unit/gui/test_splash_screen.py b/tests/unit/gui/test_splash_screen.py new file mode 100644 index 00000000..f701d94a --- /dev/null +++ b/tests/unit/gui/test_splash_screen.py @@ -0,0 +1,14 @@ +from PySide6.QtGui import QPixmap + +from aare.gui.widgets.splash_screen import LoadingSplashScreen + + +def test_splash_progress_and_message(qtbot): + splash = LoadingSplashScreen(QPixmap(200, 100)) + qtbot.addWidget(splash) + + splash.set_progress(42, "Loading panels") + assert splash.progress.value() == 42 + + splash.set_progress(43) # message-less update takes the no-showMessage branch + assert splash.progress.value() == 43 -- 2.54.0 From e457236777b80514bc5f24085abd1ee6a0a23df8 Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 10 Aug 2026 17:09:18 +0200 Subject: [PATCH 52/57] feat: merge Automation progress and Console log into one Information dock The two tabified bottom docks left their switcher tabs stranded at the window bottom; one Information dock with proper tabs mirrors the Sample List dock's layout. LogDock becomes the LogPanel widget with mirror views for the pop-out and a reveal_requested signal so the owner controls visibility (Ctrl+Shift+L, notifications). Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 142 +++++++++++++++++++------------ src/aare/gui/panels/log_panel.py | 93 ++++++++------------ tests/unit/gui/test_log_panel.py | 50 +++++------ 3 files changed, 147 insertions(+), 138 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index faead5d3..7cf2f43a 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -78,7 +78,7 @@ from aare.gui.panels.fluorescence_panel import FluorescencePanel from aare.gui.panels.local_contact_panel import LocalContactDialog # panels -from aare.gui.panels.log_panel import LogDock +from aare.gui.panels.log_panel import LogPanel from aare.gui.panels.monochromator_panel import MonochromatorPanel from aare.gui.panels.portrait_mode import PortraitModePanel from aare.gui.panels.prediction_metrics_panel import PredictionMetricsPanel @@ -611,21 +611,46 @@ class MainWindow(QMainWindow): self.manual_sample_panel = self.data_collection.manual_sample_panel self.automation_progress_panel = AutomationProgressWidget() - self.automation_progress_dock = QDockWidget("Automation progress", self) - self.automation_progress_dock.setObjectName("automation_progress_dock") # Scroll host: the panel's ~420px minimum otherwise dictates the whole # bottom row's height and squeezes the Beamline column into a scrollbar. - automation_scroll = NoWheelScrollArea(self.automation_progress_dock) + automation_scroll = NoWheelScrollArea() automation_scroll.setWidget(self.automation_progress_panel) automation_scroll.setWidgetResizable(True) automation_scroll.setFrameShape(QFrame.Shape.NoFrame) - self.automation_progress_dock.setWidget(automation_scroll) - self.automation_progress_dock.setAllowedAreas(Qt.DockWidgetArea.BottomDockWidgetArea) - self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.automation_progress_dock) - # Same title-bar icons (⤢ pop-out + ✕) as Sample List / Console Log. - self._automation_popout: PopoutWindow | None = None - self.automation_progress_dock.setTitleBarWidget( - DockTitleBar(self.automation_progress_dock, self._open_automation_popout) + + # One "Information" dock with Automation progress + Console log tabs, + # mirroring the Sample List dock's Dewar/Auxiliary layout — replaces + # the two tabified docks whose switcher tabs sat at the window bottom. + self.log_panel = LogPanel() + self.log_panel.attach_logger("") + self.log_panel.attach_logger("aareGUI") + self.log_panel.reveal_requested.connect(self._reveal_console_log) + + self.information_tabs = QTabWidget() + self.information_tabs.addTab(automation_scroll, "Automation progress") + self.information_tabs.addTab(self.log_panel, "Console log") + + # Same wrapper trick as the Sample List dock: QTabWidget ignores its + # own contents margins for the tab bar, so the inset lives one level up. + information_wrap = QWidget() + information_wrap_layout = QVBoxLayout(information_wrap) + information_wrap_layout.setContentsMargins(DOCK_CONTENT_LEFT_PAD, 0, 0, 0) + information_wrap_layout.setSpacing(0) + information_wrap_layout.addWidget(self.information_tabs) + + self.information_dock = QDockWidget("Information", self) + self.information_dock.setObjectName("information_dock") + self.information_dock.setWidget(information_wrap) + self.information_dock.setAllowedAreas(Qt.DockWidgetArea.BottomDockWidgetArea) + self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.information_dock) + self.information_dock.setFeatures( + QDockWidget.DockWidgetFeature.DockWidgetMovable + | QDockWidget.DockWidgetFeature.DockWidgetClosable + ) + # Same title-bar icons (⤢ pop-out + ✕) as Sample List. + self._information_popout: PopoutWindow | None = None + self.information_dock.setTitleBarWidget( + DockTitleBar(self.information_dock, self._open_information_popout) ) self.face_panel = FaceDetectionPanel() @@ -650,15 +675,6 @@ class MainWindow(QMainWindow): self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.fluor_panel_dock) self.fluor_panel_dock.hide() - self.log_dock = LogDock("Console Log", self) - self.log_dock.setObjectName("log_dock") - self.addDockWidget(Qt.BottomDockWidgetArea, self.log_dock) - self.log_dock.attach_logger("") - self.log_dock.attach_logger("aareGUI") - self.log_dock.hide() - - 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 ) @@ -788,11 +804,11 @@ class MainWindow(QMainWindow): # the default-state capture so "reset layout" gets it too; a saved # user layout (restored below) still wins. self.resizeDocks( - [self.tell_samples_dock, self.log_dock], [240, 240], Qt.Orientation.Vertical + [self.tell_samples_dock, self.information_dock], [240, 240], Qt.Orientation.Vertical ) # Equal oversized requests -> Qt distributes proportionally = 50/50. self.resizeDocks( - [self.tell_samples_dock, self.automation_progress_dock], + [self.tell_samples_dock, self.information_dock], [10000, 10000], Qt.Orientation.Horizontal, ) @@ -1200,11 +1216,9 @@ class MainWindow(QMainWindow): ) self.addAction(self._shortcut_toggle_smargon_trace) - self._shortcut_console_log = QAction("Toggle Console Log", self) + self._shortcut_console_log = QAction("Show Console log", self) self._shortcut_console_log.setShortcut(QKeySequence("Ctrl+Shift+L")) - self._shortcut_console_log.triggered.connect( - lambda: (self.log_dock.setVisible(True), self.log_dock.raise_()) - ) + self._shortcut_console_log.triggered.connect(self._reveal_console_log) self.addAction(self._shortcut_console_log) @Slot() @@ -1278,20 +1292,33 @@ class MainWindow(QMainWindow): self._sample_popout.raise_() self._sample_popout.activateWindow() - def _open_automation_popout(self) -> None: - if self._automation_popout is None: - # Mirror wired to the same feeds as the docked panel. + def _open_information_popout(self) -> None: + if self._information_popout is None: + # Automation mirror wired to the same feeds as the docked panel. panel = AutomationProgressWidget() self.job_list_panel.samples_in_queue_changed.connect(panel.set_samples_in_queue) self.job_list_panel.automation_running_changed.connect(panel.set_running) self.daq.automation_progress.connect(panel.set_progress) panel.set_samples_in_queue(len(self.job_list_panel.table_model.samples)) panel.set_running(self.job_list_panel.is_running()) - self._automation_popout = PopoutWindow("Automation progress", panel, parent=self) - self._automation_popout.resize(420, 520) - self._automation_popout.show() - self._automation_popout.raise_() - self._automation_popout.activateWindow() + + tabs = QTabWidget() + tabs.addTab(panel, "Automation progress") + tabs.addTab(self.log_panel.make_mirror_view(), "Console log") + self._information_popout = PopoutWindow("Information", tabs, parent=self) + self._information_popout.resize(1000, 520) + self._information_popout.show() + self._information_popout.raise_() + self._information_popout.activateWindow() + + @Slot() + def _reveal_console_log(self) -> None: + """Show the Information dock with the Console log tab on top — the + one entry point for 'the user must see the log now' (notifications, + Ctrl+Shift+L).""" + self.information_dock.setVisible(True) + self.information_dock.raise_() + self.information_tabs.setCurrentWidget(self.log_panel) def _clone_automation_row(self, dewar_panel: TellSamplePanel) -> QHBoxLayout: """Pop-out copy of the automation controls, driving the same queue @@ -1520,13 +1547,12 @@ class MainWindow(QMainWindow): self._pre_automation_right_column_visible = self.beamline_controls_scroll.isVisible() self.tell_samples_dock.setVisible(False) - self.automation_progress_dock.setVisible(False) + self.information_dock.setVisible(False) self.face_panel_dock.setVisible(False) self.fluor_panel_dock.setVisible(False) self.smargon_trace_dock.setVisible(False) self.target_stability_dock.setVisible(False) self.prediction_metrics_dock.setVisible(False) - self.log_dock.setVisible(False) self.collection_controls_scroll.setVisible(False) self.beamline_controls_scroll.setVisible(False) @@ -1589,13 +1615,12 @@ class MainWindow(QMainWindow): # Hide all dock widgets for dock_attr in ( "tell_samples_dock", - "automation_progress_dock", + "information_dock", "face_panel_dock", "fluor_panel_dock", "smargon_trace_dock", "target_stability_dock", "prediction_metrics_dock", - "log_dock", ): dock = getattr(self, dock_attr, None) if dock is not None: @@ -1654,13 +1679,12 @@ class MainWindow(QMainWindow): self._pre_portrait_geometry = None self.tell_samples_dock.setVisible(True) - self.automation_progress_dock.setVisible(False) + self.information_dock.setVisible(False) self.face_panel_dock.setVisible(False) self.fluor_panel_dock.setVisible(False) self.smargon_trace_dock.setVisible(False) self.target_stability_dock.setVisible(False) self.prediction_metrics_dock.setVisible(False) - self.log_dock.setVisible(False) @Slot(str, bool) def _portrait_alert_primary(self, msg: str, is_error: bool) -> None: @@ -1943,12 +1967,14 @@ class MainWindow(QMainWindow): ) view_menu.addAction(show_prediction_metrics_action) - show_log_action = QAction("Show Log", self) - show_log_action.setCheckable(True) - show_log_action.setChecked(False) - show_log_action.triggered.connect(lambda checked: self.log_dock.setVisible(checked)) - self.log_dock.visibilityChanged.connect(show_log_action.setChecked) - view_menu.addAction(show_log_action) + show_information_action = QAction("Show Information", self) + show_information_action.setCheckable(True) + show_information_action.setChecked(False) + show_information_action.triggered.connect( + lambda checked: self.information_dock.setVisible(checked) + ) + self.information_dock.visibilityChanged.connect(show_information_action.setChecked) + view_menu.addAction(show_information_action) view_menu.addSeparator() @@ -2036,7 +2062,10 @@ class MainWindow(QMainWindow): self.smargon_trace_dock.setVisible(False) self.target_stability_dock.setVisible(False) self.prediction_metrics_dock.setVisible(False) - self.log_dock.setVisible(False) + # Default look: Information dock open on the Automation progress tab + # (the old automation dock was visible by default, the log hidden). + self.information_dock.setVisible(True) + self.information_tabs.setCurrentIndex(0) self.tell_samples_dock.raise_() @@ -2073,15 +2102,15 @@ class MainWindow(QMainWindow): sticky: bool = True, auto_clear_ms: int | None = None, ) -> None: - self.log_dock.show_notification( + self.log_panel.show_notification( title=title, message=message, level=level, sticky=sticky, auto_clear_ms=auto_clear_ms ) def _show_runtime_waiting_notification(self, *, title: str, message: str) -> None: - self.log_dock.show_waiting_notification(title=title, message=message) + self.log_panel.show_waiting_notification(title=title, message=message) def _clear_runtime_notification(self) -> None: - self.log_dock.clear_notification() + self.log_panel.clear_notification() def _clear_automation_critical_banner(self) -> None: if not self._automation_critical_banner_active: @@ -2765,7 +2794,7 @@ class MainWindow(QMainWindow): settings.setValue("prediction_metrics", self.prediction_metrics_dock.isVisible()) settings.setValue("face_detection", self.face_panel_dock.isVisible()) settings.setValue("fluorescence", self.fluor_panel_dock.isVisible()) - settings.setValue("log", self.log_dock.isVisible()) + settings.setValue("information", self.information_dock.isVisible()) settings.endGroup() def _restore_panel_visibility_settings(self) -> None: @@ -2787,8 +2816,11 @@ class MainWindow(QMainWindow): self.face_panel_dock.setVisible(settings.value("face_detection", False, type=bool)) if settings.contains("fluorescence"): self.fluor_panel_dock.setVisible(settings.value("fluorescence", False, type=bool)) - if settings.contains("log"): - self.log_dock.setVisible(settings.value("log", False, type=bool)) + # New key: the old "log" flag described a dock that defaulted hidden; + # the merged Information dock defaults visible, so old values would + # wrongly hide it. + if settings.contains("information"): + self.information_dock.setVisible(settings.value("information", True, type=bool)) settings.endGroup() @@ -2818,10 +2850,10 @@ class MainWindow(QMainWindow): def _apply_default_dock_split(self) -> None: self.resizeDocks( - [self.tell_samples_dock, self.log_dock], [240, 240], Qt.Orientation.Vertical + [self.tell_samples_dock, self.information_dock], [240, 240], Qt.Orientation.Vertical ) self.resizeDocks( - [self.tell_samples_dock, self.automation_progress_dock], + [self.tell_samples_dock, self.information_dock], [10000, 10000], Qt.Orientation.Horizontal, ) diff --git a/src/aare/gui/panels/log_panel.py b/src/aare/gui/panels/log_panel.py index 12289ea4..2477ab55 100644 --- a/src/aare/gui/panels/log_panel.py +++ b/src/aare/gui/panels/log_panel.py @@ -1,7 +1,6 @@ from aarecommon.config.logger import attach_to_logger, find_existing_formatter -from PySide6.QtCore import Qt, QTimer, Signal, Slot +from PySide6.QtCore import QTimer, Signal, Slot from PySide6.QtWidgets import ( - QDockWidget, QFrame, QHBoxLayout, QLabel, @@ -28,7 +27,6 @@ from aare.gui.styles import ( TEXT, card_style, ) -from aare.gui.widgets.popout_window import DockTitleBar, PopoutWindow class RuntimeNotificationWidget(QFrame): @@ -61,7 +59,7 @@ class RuntimeNotificationWidget(QFrame): self._clear_button = QPushButton("Clear", self) self._clear_button.clicked.connect(self.clear_notification) - self._show_log_button = QPushButton("Show Log", self) + self._show_log_button = QPushButton("Show log", self) self._show_log_button.clicked.connect(self.show_log_requested.emit) header_layout = QHBoxLayout() @@ -194,43 +192,30 @@ class RuntimeNotificationWidget(QFrame): self.cleared.emit() -class LogDock(QDockWidget): - def __init__(self, title="Log", parent=None): - super().__init__(title, parent) - # saveState/restoreState (watch-mode hide/restore) skips unnamed docks. - self.setObjectName("log_dock") - self.setAllowedAreas( - Qt.DockWidgetArea.BottomDockWidgetArea - | Qt.DockWidgetArea.RightDockWidgetArea - | Qt.DockWidgetArea.LeftDockWidgetArea - ) +class LogPanel(QWidget): + """Console-log card: notification banner + log view. A tab inside the + Information dock (was its own LogDock QDockWidget until the Automation + progress / Console log docks merged). Revealing the dock/tab is the + owner's job — this panel only signals when it needs to be seen.""" - # No floating: popping a dock out rips it from the row and reshuffles - # the rest. The ⤢ in the title bar (next to ✕) opens an ADDITIONAL - # window on the same log instead. - self.setFeatures( - QDockWidget.DockWidgetFeature.DockWidgetMovable - | QDockWidget.DockWidgetFeature.DockWidgetClosable - ) - self._popout: PopoutWindow | None = None - self._popout_view: QPlainTextEdit | None = None - self.setTitleBarWidget(DockTitleBar(self, self._open_popout)) + reveal_requested = Signal() - self.container = QWidget(self) + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("logPanel") + self.notification = RuntimeNotificationWidget(self) + self.notification.show_log_requested.connect(self._focus_log) - self.notification = RuntimeNotificationWidget(self.container) - self.notification.show_log_requested.connect(self._raise_and_focus_log) - - self.view = QPlainTextEdit(self.container) + self.view = QPlainTextEdit(self) self.view.setReadOnly(True) - layout = QVBoxLayout(self.container) + layout = QVBoxLayout(self) layout.setContentsMargins(6, 6, 6, 6) layout.setSpacing(6) layout.addWidget(self.notification) layout.addWidget(self.view, 1) - self.setWidget(self.container) + self._mirror_views: list[QPlainTextEdit] = [] self.emitter = QtLogEmitter() self.emitter.message.connect(self._append_line) @@ -245,29 +230,23 @@ class LogDock(QDockWidget): def _append_line(self, text: str): self.view.appendPlainText(text) - @Slot() - def _open_popout(self) -> None: - if self._popout is None: - # Mirror view fed by the same emitter; history is copied once at - # creation. (One QTextDocument shared by two QPlainTextEdits would - # make their layouts fight, hence the second document.) - view = QPlainTextEdit() - view.setReadOnly(True) - # Frameless inside the pop-out — no nested boxes in this window. - view.setStyleSheet("QPlainTextEdit { border: none; }") - view.setPlainText(self.view.toPlainText()) - self.emitter.message.connect(view.appendPlainText) - self._popout_view = view - self._popout = PopoutWindow("Console Log", view, parent=self.window()) - self._popout.resize(1000, 450) - self._popout.show() - self._popout.raise_() - self._popout.activateWindow() + def make_mirror_view(self) -> QPlainTextEdit: + """Second view on the same emitter, for pop-out windows: history is + copied once at creation, live lines reach every mirror, clear() + empties them all. (One QTextDocument shared by two QPlainTextEdits + would make their layouts fight, hence the separate documents.)""" + view = QPlainTextEdit() + view.setReadOnly(True) + # Frameless inside the pop-out — no nested boxes in this window. + view.setStyleSheet("QPlainTextEdit { border: none; }") + view.setPlainText(self.view.toPlainText()) + self.emitter.message.connect(view.appendPlainText) + self._mirror_views.append(view) + return view @Slot() - def _raise_and_focus_log(self) -> None: - self.setVisible(True) - self.raise_() + def _focus_log(self) -> None: + self.reveal_requested.emit() self.view.setFocus() def show_notification( @@ -279,15 +258,13 @@ class LogDock(QDockWidget): sticky: bool = True, auto_clear_ms: int | None = None, ) -> None: - self.setVisible(True) - self.raise_() + self.reveal_requested.emit() self.notification.show_notification( title=title, message=message, level=level, sticky=sticky, auto_clear_ms=auto_clear_ms ) def show_waiting_notification(self, *, title: str, message: str) -> None: - self.setVisible(True) - self.raise_() + self.reveal_requested.emit() self.notification.show_waiting(title=title, message=message) def clear_notification(self) -> None: @@ -295,5 +272,5 @@ class LogDock(QDockWidget): def clear(self): self.view.clear() - if self._popout_view is not None: - self._popout_view.clear() + for mirror in self._mirror_views: + mirror.clear() diff --git a/tests/unit/gui/test_log_panel.py b/tests/unit/gui/test_log_panel.py index 6e69bae9..962ad6df 100644 --- a/tests/unit/gui/test_log_panel.py +++ b/tests/unit/gui/test_log_panel.py @@ -1,31 +1,31 @@ -"""The console-log pop-out is a second view on the same emitter: history is -copied on open, live lines reach both views, and clear() empties both.""" +"""A log mirror view is a second view on the same emitter: history is copied +on creation, live lines reach both views, and clear() empties them all.""" -from aare.gui.panels.log_panel import LogDock +from aare.gui.panels.log_panel import LogPanel -def test_log_popout_mirrors_and_clears(qtbot): - dock = LogDock() - qtbot.addWidget(dock) - dock.emitter.message.emit("first line") - assert "first line" in dock.view.toPlainText() +def test_log_mirror_view_and_clear(qtbot): + panel = LogPanel() + qtbot.addWidget(panel) + panel.emitter.message.emit("first line") + assert "first line" in panel.view.toPlainText() - dock._open_popout() - assert dock._popout is not None - assert dock._popout.isVisible() - popout_view = dock._popout_view - assert popout_view is not None - # History copied on open, live lines reach both views. - assert "first line" in popout_view.toPlainText() - dock.emitter.message.emit("second line") - assert "second line" in dock.view.toPlainText() - assert "second line" in popout_view.toPlainText() + mirror = panel.make_mirror_view() + qtbot.addWidget(mirror) + # History copied on creation, live lines reach both views. + assert "first line" in mirror.toPlainText() + panel.emitter.message.emit("second line") + assert "second line" in panel.view.toPlainText() + assert "second line" in mirror.toPlainText() - # Reopening reuses the window instead of stacking mirrors. - popout = dock._popout - dock._open_popout() - assert dock._popout is popout + panel.clear() + assert panel.view.toPlainText() == "" + assert mirror.toPlainText() == "" - dock.clear() - assert dock.view.toPlainText() == "" - assert popout_view.toPlainText() == "" + +def test_notification_requests_reveal(qtbot): + panel = LogPanel() + qtbot.addWidget(panel) + with qtbot.waitSignal(panel.reveal_requested, timeout=1000): + panel.show_notification(title="Boom", message="it broke") + assert panel.notification._title.text() == "Boom" -- 2.54.0 From 1f2a538959b7f654dfde6d1d8ac9b2a177a8cab0 Mon Sep 17 00:00:00 2001 From: Dawn Date: Tue, 11 Aug 2026 09:26:52 +0200 Subject: [PATCH 53/57] feat: float alert banners over content as anchored toasts Banners in the root layout shifted the whole UI on every message; now they float over the content root (repositioned on resize) and the baton toast anchors as a compact pill under the camera view, auto-clearing in 4s instead of 10s. Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 36 +++++++++++++------ src/aare/gui/widgets/alert_banner.py | 52 +++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 11 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 7cf2f43a..cb47f0f9 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -153,6 +153,17 @@ class ClickableCursorFilter(QObject): return False +class _AlertBannerHost(QWidget): + """Content root that repositions the floating alert banners on resize. + A plain resizeEvent override instead of event filters: filters firing + during widget teardown corrupted PySide in the test suite.""" + + def resizeEvent(self, event): + super().resizeEvent(event) + for banner in self.findChildren(AlertBanner): + banner.reposition() + + class MainWindow(QMainWindow): sample_geometry = Signal(SampleGeometryModel) @@ -277,17 +288,19 @@ class MainWindow(QMainWindow): self._separator_hint_timer.setInterval(SEPARATOR_HINT_DELAY_MS) self._separator_hint_timer.timeout.connect(lambda: self._set_separator_hint(True)) - root_widget = QWidget(parent=self) + root_widget = _AlertBannerHost(parent=self) root_widget.setObjectName("mainContentRoot") root_layout = QVBoxLayout(root_widget) root_layout.setContentsMargins(0, 0, 0, 0) root_layout.setSpacing(0) + # Banners float over the content instead of sitting in root_layout, so + # messages (e.g. "Baton acquired!") no longer shift the whole UI. self.alert_banner = AlertBanner(parent=root_widget) - root_layout.addWidget(self.alert_banner) + self.alert_banner.float_over(root_widget) self.alert_banner_secondary = AlertBanner(parent=root_widget) - root_layout.addWidget(self.alert_banner_secondary) + self.alert_banner_secondary.float_over(root_widget) self.content_stack = QStackedWidget(parent=root_widget) root_layout.addWidget(self.content_stack, 1) @@ -424,6 +437,9 @@ class MainWindow(QMainWindow): self.sample_camera = SampleCameraImageLabel( geom=geom, raster=self.raster, parent=top_widget, default_image=default_image ) + # Baton toasts sit as a compact pill under the camera view instead of + # a full-width bar at the top of the window. + self.alert_banner.anchor_to(self.sample_camera) self.beamline_view = VideoGraphicsView() self.beamline_view_panel = AxisVideoPanel( @@ -2635,10 +2651,10 @@ class MainWindow(QMainWindow): self._close_baton_pending_dialog() if status.you_are_holder: - self.alert_banner.show_message("Baton acquired!", False, auto_clear_ms=10000) + self.alert_banner.show_message("Baton acquired!", False, auto_clear_ms=4000) else: self.alert_banner.show_message( - "Request declined or cancelled", False, auto_clear_ms=10000 + "Request declined or cancelled", False, auto_clear_ms=4000 ) # Manage incoming request dialog (when someone requests from us) @@ -2656,7 +2672,7 @@ class MainWindow(QMainWindow): """ if result.get("granted"): self._waiting_for_baton_response = False - self.alert_banner.show_message("Baton acquired!", False, auto_clear_ms=10000) + self.alert_banner.show_message("Baton acquired!", False, auto_clear_ms=4000) logger.info("Baton acquired") # Close pending dialog; StatusBar will trigger p-group selection via SSE self._close_baton_pending_dialog() @@ -2717,12 +2733,12 @@ class MainWindow(QMainWindow): logger.debug(f"Baton response result: {result}") if result.get("accepted"): self._waiting_for_baton_response = False - self.alert_banner.show_message("Control transferred", False, auto_clear_ms=10000) + self.alert_banner.show_message("Control transferred", False, auto_clear_ms=4000) self._close_baton_dialog() self.status_bar.update_baton_status(self.status_bar._baton_status) elif result.get("refused"): self._waiting_for_baton_response = False - self.alert_banner.show_message("Request declined", False, auto_clear_ms=10000) + self.alert_banner.show_message("Request declined", False, auto_clear_ms=4000) self._close_baton_dialog() self.status_bar.update_baton_status(self.status_bar._baton_status) else: @@ -2741,7 +2757,7 @@ class MainWindow(QMainWindow): elif result.get("granted"): self._waiting_for_baton_response = False - self.alert_banner.show_message("Baton acquired!", False, auto_clear_ms=10000) + self.alert_banner.show_message("Baton acquired!", False, auto_clear_ms=4000) self._close_baton_pending_dialog() elif result.get("queued"): @@ -2760,7 +2776,7 @@ class MainWindow(QMainWindow): elif result.get("refused"): self._waiting_for_baton_response = False - self.alert_banner.show_message("Request declined", False, auto_clear_ms=10000) + self.alert_banner.show_message("Request declined", False, auto_clear_ms=4000) self._close_baton_pending_dialog() else: diff --git a/src/aare/gui/widgets/alert_banner.py b/src/aare/gui/widgets/alert_banner.py index 9f4fab21..5fb4c5b0 100644 --- a/src/aare/gui/widgets/alert_banner.py +++ b/src/aare/gui/widgets/alert_banner.py @@ -1,5 +1,5 @@ from aarecommon.config.logger import setup_logger -from PySide6.QtCore import Qt, QTimer, Slot +from PySide6.QtCore import QPoint, Qt, QTimer, Slot from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QHBoxLayout, QLabel, QSizePolicy from aare.gui.constants import LOGGER_NAME @@ -45,6 +45,53 @@ class AlertBanner(QFrame): self.setVisible(False) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self._float_host = None + self._float_anchor = None + + def float_over(self, host) -> None: + """Overlay this banner at the top of `host` instead of occupying layout + space — added because showing/hiding the baton banner was shifting the + whole content stack up and down. No event filters on purpose: filters + firing during widget teardown corrupted PySide (tests crashed with + "QPushButton returned NULL"); the host repositions us on resize instead + (see _AlertBannerHost in main_window).""" + self.setParent(host) + self._float_host = host + # Click-through: the banner covers live UI now, so it must not eat + # mouse events meant for the widgets underneath. + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + + def anchor_to(self, widget) -> None: + """Render as a compact toast under `widget`'s bottom edge (must be a + descendant of the float host) instead of a full-width top bar — the + baton messages sit below the sample camera view this way. Falls back + to the top bar while the anchor is hidden (e.g. portrait mode). + ponytail: position goes stale if a splitter drag moves the anchor while + the toast is up; it self-corrects on the next show.""" + self._float_anchor = widget + + def showEvent(self, event): + super().showEvent(event) + self.reposition() + + def reposition(self) -> None: + host = self._float_host + if host is None or not self.isVisible(): + return + anchor = self._float_anchor + if anchor is not None and anchor.isVisible(): + top_left = anchor.mapTo(host, QPoint(0, 0)) + w = min(self.sizeHint().width(), anchor.width()) + h = self.heightForWidth(w) if self.hasHeightForWidth() else self.sizeHint().height() + x = top_left.x() + (anchor.width() - w) // 2 + y = min(top_left.y() + anchor.height() + 4, host.height() - h) + self.setGeometry(x, y, w, h) + else: + w = host.width() + h = self.heightForWidth(w) if self.hasHeightForWidth() else self.sizeHint().height() + self.setGeometry(0, 0, w, h) + self.raise_() + def _set_alert_kind(self, kind: str) -> None: self.setProperty("alertKind", kind) self.style().unpolish(self) @@ -79,6 +126,8 @@ class AlertBanner(QFrame): self._current_is_error = is_error self._label.setText(decorated) self.setVisible(True) + # Resize to the new text even when already visible (no showEvent then). + self.reposition() @Slot(str, int) def show_waiting(self, msg: str, countdown_seconds: int = 0): @@ -106,6 +155,7 @@ class AlertBanner(QFrame): self._countdown_timer.start() self.setVisible(True) + self.reposition() def _apply_waiting_style(self): """Apply yellow/waiting style.""" -- 2.54.0 From e77dfb4716f7adcb6673593c1d5075824ae9aeb1 Mon Sep 17 00:00:00 2001 From: Dawn Date: Tue, 11 Aug 2026 09:38:46 +0200 Subject: [PATCH 54/57] refactor: shared busy-badge renderer for camera and axis video views draw_busy_badge in busy_overlay renders the title+subtext pill once; the sample camera and axis video overlays both delegate to it instead of keeping diverging copies. The axis panel's dot+label status pill and its per-theme QSS go away with it. Co-Authored-By: Claude Fable 5 --- src/aare/gui/panels/axis_video_panel.py | 63 ++++++------------------ src/aare/gui/styles.py | 48 ------------------- src/aare/gui/widgets/busy_overlay.py | 64 +++++++++++++++++++++++-- src/aare/gui/widgets/camera_image.py | 49 ++++--------------- src/aare/gui/widgets/video_image.py | 50 +++---------------- tests/unit/gui/test_axis_video_panel.py | 37 ++++++++++++++ 6 files changed, 128 insertions(+), 183 deletions(-) create mode 100644 tests/unit/gui/test_axis_video_panel.py diff --git a/src/aare/gui/panels/axis_video_panel.py b/src/aare/gui/panels/axis_video_panel.py index 2bc5e5e8..4225c65f 100644 --- a/src/aare/gui/panels/axis_video_panel.py +++ b/src/aare/gui/panels/axis_video_panel.py @@ -1,4 +1,6 @@ -from PySide6.QtCore import Qt, Signal +from dataclasses import replace + +from PySide6.QtCore import Signal from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget from aare.gui.widgets.busy_overlay import BusyOverlayStyle @@ -8,32 +10,13 @@ from aare.gui.widgets.video_image import VideoGraphicsView class AxisVideoPanel(QWidget): refresh_requested = Signal() - def __init__(self, title: str, video_view: VideoGraphicsView | None = None, parent=None): + # video_view may be a bare VideoGraphicsView or any container holding + # them (the combined view passes a QWidget with two stacked views). + def __init__(self, title: str, video_view: QWidget | None = None, parent=None): super().__init__(parent) self._title_label = QLabel(title, self) - self._status_container = QWidget(self) - self._status_container.setObjectName("axisVideoStatusContainer") - self._status_container.setProperty("busyState", "idle") - - self._status_dot = QLabel(self._status_container) - self._status_dot.setObjectName("axisVideoStatusDot") - self._status_dot.setFixedSize(10, 10) - - self._status_label = QLabel("", self._status_container) - self._status_label.setObjectName("axisVideoStatusLabel") - self._status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - - status_layout = QHBoxLayout(self._status_container) - status_layout.setContentsMargins(10, 6, 12, 6) - status_layout.setSpacing(8) - status_layout.addWidget(self._status_dot) - status_layout.addWidget(self._status_label) - - self._status_container.setMinimumWidth(190) - self._status_container.hide() - self._refresh_button = QPushButton("Refresh Axis Cameras", self) self._refresh_button.clicked.connect(self.refresh_requested.emit) @@ -43,7 +26,6 @@ class AxisVideoPanel(QWidget): controls_layout.setContentsMargins(0, 0, 0, 0) controls_layout.addWidget(self._title_label) controls_layout.addStretch() - controls_layout.addWidget(self._status_container) controls_layout.addWidget(self._refresh_button) root_layout = QVBoxLayout(self) @@ -52,12 +34,6 @@ class AxisVideoPanel(QWidget): root_layout.addLayout(controls_layout) root_layout.addWidget(self.view) - def _refresh_status_style(self) -> None: - for widget in (self._status_container, self._status_dot, self._status_label): - widget.style().unpolish(widget) - widget.style().polish(widget) - widget.update() - def _all_video_views(self) -> list[VideoGraphicsView]: views: list[VideoGraphicsView] = [] if isinstance(self.view, VideoGraphicsView): @@ -70,24 +46,13 @@ class AxisVideoPanel(QWidget): return unique_views def set_busy_style(self, style: BusyOverlayStyle | None) -> None: - if style is None: - self._status_label.setText("") - self._status_container.setProperty("busyState", "idle") - self._status_dot.setStyleSheet("background-color: transparent;") - self._status_label.setStyleSheet("") - self._refresh_status_style() - self._status_container.hide() - else: - self._status_label.setText(style.text) - self._status_container.setProperty("busyState", "active") - self._status_dot.setStyleSheet(f"background-color: {style.accent_dot};") - self._status_label.setStyleSheet(f"color: {style.badge_fg};") - self._refresh_status_style() - self._status_container.show() + # The hint line invites a click, but only the sample-camera badge is + # a click target — strip it for these passive views. + if style is not None and style.subtext: + style = replace(style, subtext="") - for view in self._all_video_views(): + # Only the first view draws the badge: the combined panel stacks two + # video views and used to show the message once per view. + for index, view in enumerate(self._all_video_views()): if hasattr(view, "set_busy_overlay_style"): - view.set_busy_overlay_style(style) - - def set_status_text(self, text: str) -> None: - self._status_label.setText(text or "") + view.set_busy_overlay_style(style if index == 0 else None) diff --git a/src/aare/gui/styles.py b/src/aare/gui/styles.py index ed56d3a7..5dffb881 100644 --- a/src/aare/gui/styles.py +++ b/src/aare/gui/styles.py @@ -890,30 +890,6 @@ def _sunrise_stylesheet(overrides: dict[str, str] | None = None) -> str: color: $warning_text; } - QWidget#axisVideoStatusContainer[busyState="idle"] { - border-radius: $card_radius; - background-color: $status_idle_bg; - } - - QWidget#axisVideoStatusContainer[busyState="active"] { - border-radius: $card_radius; - } - - QLabel#axisVideoStatusDot { - min-width: 10px; - max-width: 10px; - min-height: 10px; - max-height: 10px; - border-radius: 5px; - background-color: transparent; - } - - QLabel#axisVideoStatusLabel { - background-color: transparent; - color: $status_label_text; - font-weight: bold; - } - /* Check/radio indicators: explicit QSS boxes, both SQUARE for consistency — hollow = unchecked, accent-filled = checked. Native indicator glyphs are unreliable on this Qt once anything nearby is @@ -1633,30 +1609,6 @@ def _sunset_stylesheet() -> str: color: $dark_warning_text; } - QWidget#axisVideoStatusContainer[busyState="idle"] { - border-radius: $card_radius; - background-color: $dark_elevated; - } - - QWidget#axisVideoStatusContainer[busyState="active"] { - border-radius: $card_radius; - } - - QLabel#axisVideoStatusDot { - min-width: 10px; - max-width: 10px; - min-height: 10px; - max-height: 10px; - border-radius: 5px; - background-color: transparent; - } - - QLabel#axisVideoStatusLabel { - background-color: transparent; - color: $dark_subtext; - font-weight: bold; - } - QFrame#beamlineStatePanel { background: transparent; border-top: 1px solid transparent; diff --git a/src/aare/gui/widgets/busy_overlay.py b/src/aare/gui/widgets/busy_overlay.py index 0d7a3a0e..6013a7b2 100644 --- a/src/aare/gui/widgets/busy_overlay.py +++ b/src/aare/gui/widgets/busy_overlay.py @@ -2,7 +2,8 @@ from dataclasses import dataclass from aarecommon.models.models import SessionsStateEnum from aarecommon.models.tell import TellStateModel -from PySide6.QtGui import QColor +from PySide6.QtCore import QPoint, QRect, Qt +from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen from aare.gui.styles import ( BUSY_BLUE, @@ -39,11 +40,68 @@ class BusyOverlayStyle: overlay_border: QColor overlay_text: QColor accent_dot: str - # Hint line under the title — only the big sample-camera badge draws it; - # compact consumers (axis panel label, video badge) show text alone. + # Hint line under the title. draw_busy_badge renders it whenever set; + # AxisVideoPanel strips it because only the sample-camera badge is a + # click target and the hint invites a click. subtext: str = "" +def draw_busy_badge( + painter: QPainter, + viewport_width: int, + viewport_height: int, + style: BusyOverlayStyle, + *, + fill: QColor | None = None, +) -> QRect: + """The one badge renderer for every camera view — sample camera and the + Axis video views draw the same box so the message reads identically + everywhere (each view used to have its own look). Returns the badge rect + so interactive views can use it as a click target. `fill` overrides the + style's fill (hover feedback).""" + font = QFont() + font.setPointSize(24) + font.setBold(True) + font_metrics = QFontMetrics(font) + + sub_font = QFont() + sub_font.setPointSize(12) + sub_metrics = QFontMetrics(sub_font) + + title_width = font_metrics.horizontalAdvance(style.text) + sub_width = sub_metrics.horizontalAdvance(style.subtext) if style.subtext else 0 + + padding_x = 20 + padding_y = 14 + sub_gap = 6 + bg_width = max(title_width, sub_width) + 2 * padding_x + bg_height = font_metrics.height() + 2 * padding_y + if style.subtext: + bg_height += sub_gap + sub_metrics.height() + + position_x = int((viewport_width - bg_width) / 2) + position_y = int(viewport_height * 0.68 - bg_height / 2) + bg_rect = QRect(position_x, position_y, bg_width, bg_height) + + painter.setPen(QPen(style.overlay_border, 2, Qt.PenStyle.SolidLine)) + painter.setBrush(fill if fill is not None else QColor(style.overlay_fill)) + painter.drawRoundedRect(bg_rect, 10, 10) + + painter.setPen(QPen(style.overlay_text, 2, Qt.PenStyle.SolidLine)) + painter.setFont(font) + title_x = position_x + (bg_width - title_width) // 2 + title_y = position_y + padding_y + font_metrics.ascent() + painter.drawText(QPoint(title_x, title_y), style.text) + + if style.subtext: + painter.setFont(sub_font) + sub_x = position_x + (bg_width - sub_width) // 2 + sub_y = position_y + padding_y + font_metrics.height() + sub_gap + sub_metrics.ascent() + painter.drawText(QPoint(sub_x, sub_y), style.subtext) + + return bg_rect + + def build_busy_overlay_style( *, is_busy: bool, diff --git a/src/aare/gui/widgets/camera_image.py b/src/aare/gui/widgets/camera_image.py index 0f82614b..835aac34 100644 --- a/src/aare/gui/widgets/camera_image.py +++ b/src/aare/gui/widgets/camera_image.py @@ -63,7 +63,11 @@ from aare.gui.styles import ( WHITE, qcolor, ) -from aare.gui.widgets.busy_overlay import BusyOverlayStyle, build_busy_overlay_style +from aare.gui.widgets.busy_overlay import ( + BusyOverlayStyle, + build_busy_overlay_style, + draw_busy_badge, +) logger = setup_logger(LOGGER_NAME) @@ -341,29 +345,6 @@ class SampleCameraImageLabel(QGraphicsView): painter.restore() return - sub_font = QFont() - sub_font.setPointSize(12) - sub_metrics = QFontMetrics(sub_font) - - title_width = font_metrics.horizontalAdvance(style.text) - sub_width = sub_metrics.horizontalAdvance(style.subtext) if style.subtext else 0 - - padding_x = 20 - padding_y = 14 - sub_gap = 6 - bg_width = max(title_width, sub_width) + 2 * padding_x - bg_height = font_metrics.height() + 2 * padding_y - if style.subtext: - bg_height += sub_gap + sub_metrics.height() - - viewport_width = self.viewport().width() - viewport_height = self.viewport().height() - - position_x = int((viewport_width - bg_width) / 2) - position_y = int(viewport_height * 0.68 - bg_height / 2) - - bg_rect = QRect(position_x, position_y, bg_width, bg_height) - # SESSION VACANT / GUEST MODE badges double as the click target for the # grab/request menu, same as the _draw_session_overlay badge they hide. session_badge = self._session_state in ( @@ -371,28 +352,16 @@ class SampleCameraImageLabel(QGraphicsView): SessionsStateEnum.OwnedByElse, SessionsStateEnum.PendingYouToElse, ) - self._session_badge_rect = bg_rect if session_badge else None fill = QColor(style.overlay_fill) if session_badge and self._session_badge_hovered: # Hover: darker in the light themes, brighter in Sunset. fill = fill.lighter(125) if self._dark_theme else fill.darker(115) - painter.setPen(QPen(style.overlay_border, 2, Qt.PenStyle.SolidLine)) - painter.setBrush(fill) - painter.drawRoundedRect(bg_rect, 10, 10) - - painter.setPen(QPen(style.overlay_text, 2, Qt.PenStyle.SolidLine)) - painter.setFont(font) - title_x = position_x + (bg_width - title_width) // 2 - title_y = position_y + padding_y + font_metrics.ascent() - painter.drawText(QPoint(title_x, title_y), style.text) - - if style.subtext: - painter.setFont(sub_font) - sub_x = position_x + (bg_width - sub_width) // 2 - sub_y = position_y + padding_y + font_metrics.height() + sub_gap + sub_metrics.ascent() - painter.drawText(QPoint(sub_x, sub_y), style.subtext) + bg_rect = draw_busy_badge( + painter, self.viewport().width(), self.viewport().height(), style, fill=fill + ) + self._session_badge_rect = bg_rect if session_badge else None painter.restore() diff --git a/src/aare/gui/widgets/video_image.py b/src/aare/gui/widgets/video_image.py index 78d9134d..bfbb52af 100644 --- a/src/aare/gui/widgets/video_image.py +++ b/src/aare/gui/widgets/video_image.py @@ -1,8 +1,8 @@ from PySide6.QtCore import QRectF, Qt, Slot -from PySide6.QtGui import QColor, QFont, QFontMetrics, QImage, QPainter, QPen, QPixmap +from PySide6.QtGui import QImage, QPainter, QPixmap from PySide6.QtWidgets import QGraphicsPixmapItem, QGraphicsScene, QGraphicsView -from aare.gui.widgets.busy_overlay import BusyOverlayStyle +from aare.gui.widgets.busy_overlay import BusyOverlayStyle, draw_busy_badge class VideoGraphicsView(QGraphicsView): @@ -111,48 +111,12 @@ class VideoGraphicsView(QGraphicsView): if self._busy_overlay_style is None: return - style = self._busy_overlay_style - painter.save() painter.resetTransform() painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) - - font = QFont() - font.setPointSize(24) - font.setBold(True) - painter.setFont(font) - - fm = QFontMetrics(font) - text_rect = fm.boundingRect(style.text) - - dot_diameter = 14 - gap = 12 - padding_x = 22 - padding_y = 14 - bg_width = text_rect.width() + dot_diameter + gap + padding_x * 2 - bg_height = max(text_rect.height(), dot_diameter) + padding_y * 2 - - viewport_width = self.viewport().width() - viewport_height = self.viewport().height() - - pos_x = int((viewport_width - bg_width) / 2) - pos_y = int(viewport_height * 0.68 - bg_height / 2) - - bg_rect = QRectF(pos_x, pos_y, bg_width, bg_height) - - painter.setPen(QPen(style.overlay_border, 2)) - painter.setBrush(style.overlay_fill) - painter.drawRoundedRect(bg_rect, 14, 14) - - dot_x = bg_rect.left() + padding_x - dot_y = bg_rect.top() + (bg_rect.height() - dot_diameter) / 2 - painter.setPen(Qt.PenStyle.NoPen) - painter.setBrush(QColor(style.accent_dot)) - painter.drawEllipse(QRectF(dot_x, dot_y, dot_diameter, dot_diameter)) - - painter.setPen(QPen(style.overlay_text, 1)) - text_x = dot_x + dot_diameter + gap - text_y = bg_rect.top() + padding_y + fm.ascent() - painter.drawText(text_x, text_y, style.text) - + # Shared renderer with the sample camera, so every view shows the + # identical badge (this view used to draw its own dot+text variant). + draw_busy_badge( + painter, self.viewport().width(), self.viewport().height(), self._busy_overlay_style + ) painter.restore() diff --git a/tests/unit/gui/test_axis_video_panel.py b/tests/unit/gui/test_axis_video_panel.py new file mode 100644 index 00000000..c73b8146 --- /dev/null +++ b/tests/unit/gui/test_axis_video_panel.py @@ -0,0 +1,37 @@ +from aarecommon.models.models import SessionsStateEnum +from PySide6.QtWidgets import QVBoxLayout, QWidget + +from aare.gui.panels.axis_video_panel import AxisVideoPanel +from aare.gui.widgets.busy_overlay import build_busy_overlay_style +from aare.gui.widgets.video_image import VideoGraphicsView + + +def _vacant_style(): + return build_busy_overlay_style( + is_busy=False, tell_state=None, session_state=SessionsStateEnum.Vacant + ) + + +def test_badge_drawn_once_and_hint_stripped(qtbot): + # Combined-view shape: two video views stacked in one container. + container = QWidget() + layout = QVBoxLayout(container) + first, second = VideoGraphicsView(), VideoGraphicsView() + layout.addWidget(first) + layout.addWidget(second) + + panel = AxisVideoPanel("Combined", container) + qtbot.addWidget(panel) + + style = _vacant_style() + assert style is not None and style.subtext # sample camera keeps the hint + + panel.set_busy_style(style) + applied = first._busy_overlay_style + assert applied is not None + assert applied.text == "In viewing mode" + assert applied.subtext == "" # not clickable here, hint stripped + assert second._busy_overlay_style is None # one badge, not one per view + + panel.set_busy_style(None) + assert first._busy_overlay_style is None -- 2.54.0 From 7b30c959a20e62c58f190f5b06dddbdcafa5d956 Mon Sep 17 00:00:00 2001 From: Dawn Date: Tue, 11 Aug 2026 09:45:55 +0200 Subject: [PATCH 55/57] fix: dark-mode legibility for the status bar, sample tables, and surfaces Status-bar flags get status_colors(theme) (Mocha variants on Sunset) via a new set_theme; sample tables stop hard-filling rows WHITE and pin dark ink on tinted rows via ForegroundRole; dewar tab and log panel get solid dark surfaces (transparent renders black on the non-composited X11 container); developer help cards go square for the same reason. Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 8 ++ src/aare/gui/models/sample_queue_model.py | 8 +- src/aare/gui/panels/developer_help_dialog.py | 24 +++++- src/aare/gui/panels/reference_tools_panel.py | 21 ++++- src/aare/gui/panels/sample_queue_panel.py | 3 + src/aare/gui/styles.py | 51 +++++++++++- src/aare/gui/widgets/status_bar.py | 86 ++++++++++++-------- 7 files changed, 157 insertions(+), 44 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index cb47f0f9..99a437e6 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -551,6 +551,7 @@ class MainWindow(QMainWindow): # hidden, as the queue engine; its buttons are reparented here so all # their existing wiring keeps working. dewar_tab = QWidget() + dewar_tab.setObjectName("dewarTab") dewar_layout = QVBoxLayout(dewar_tab) dewar_layout.setContentsMargins(0, 0, 0, 0) dewar_layout.setSpacing(2) @@ -844,6 +845,8 @@ class MainWindow(QMainWindow): ) self.status_bar = StatusBar(self._decoded_token, parent=self) + # Created after the first _apply_theme, so hand it the theme directly. + self.status_bar.set_theme(self._theme_mode) self.setStatusBar(self.status_bar) self.daq = DAQWorker(base_url=self._base_url, token=self._token) @@ -1290,6 +1293,7 @@ class MainWindow(QMainWindow): dewar_panel.set_status_chip(self.tell_samples.table_model.status_filter) dewar_tab = QWidget() + dewar_tab.setObjectName("dewarTab") dewar_layout = QVBoxLayout(dewar_tab) dewar_layout.setContentsMargins(0, 0, 0, 0) dewar_layout.setSpacing(2) @@ -1817,6 +1821,10 @@ class MainWindow(QMainWindow): self.setStyleSheet(build_app_stylesheet(self._theme_mode)) # State colors are painted in code per DAQ tick — QSS can't reach them. self.beamline_state_panel.set_theme(self._theme_mode) + # Status bar flags likewise; it is created after the first + # _apply_theme call in __init__, hence the guard. + if hasattr(self, "status_bar"): + self.status_bar.set_theme(self._theme_mode) self.sample_camera.set_theme(self._theme_mode) if old_look is None: return diff --git a/src/aare/gui/models/sample_queue_model.py b/src/aare/gui/models/sample_queue_model.py index e6d6844e..f6a1c3c7 100644 --- a/src/aare/gui/models/sample_queue_model.py +++ b/src/aare/gui/models/sample_queue_model.py @@ -4,7 +4,7 @@ from PySide6.QtCore import QAbstractTableModel, Qt from PySide6.QtGui import QBrush from aare.gui.constants import LOGGER_NAME -from aare.gui.styles import SAMPLE_ROW_ACTIVE_BG, SAMPLE_ROW_QUEUED_BG, WHITE, qcolor +from aare.gui.styles import SAMPLE_ROW_ACTIVE_BG, SAMPLE_ROW_QUEUED_BG, SAMPLE_STATUS_TEXT, qcolor logger = setup_logger(LOGGER_NAME) @@ -59,12 +59,16 @@ class SampleQueueSpreadsheet(QAbstractTableModel): elif role == Qt.ItemDataRole.TextAlignmentRole: return Qt.AlignmentFlag.AlignCenter elif role == Qt.ItemDataRole.BackgroundRole: + # Tint only the head-of-queue row; plain rows return None so the + # theme QSS paints them (hardcoded WHITE fills broke dark mode). if index.row() == 0: if self._running: return QBrush(qcolor(SAMPLE_ROW_ACTIVE_BG)) else: return QBrush(qcolor(SAMPLE_ROW_QUEUED_BG)) - return QBrush(qcolor(WHITE)) + elif role == Qt.ItemDataRole.ForegroundRole and index.row() == 0: + # Fixed dark ink on the tint so dark-theme white text stays legible. + return QBrush(qcolor(SAMPLE_STATUS_TEXT)) return None def headerData(self, section, orientation, role=None): diff --git a/src/aare/gui/panels/developer_help_dialog.py b/src/aare/gui/panels/developer_help_dialog.py index a8c2b47a..734f46e8 100644 --- a/src/aare/gui/panels/developer_help_dialog.py +++ b/src/aare/gui/panels/developer_help_dialog.py @@ -32,6 +32,7 @@ from PySide6.QtWidgets import ( from aare.gui.constants import LOGGER_NAME from aare.gui.log import QtLogEmitter, QtLogHandler from aare.gui.styles import ( + FLAT_CARD_RADIUS, PANEL_BG_FAINT, PANEL_BG_SOFT, PANEL_BORDER, @@ -69,8 +70,16 @@ class DeveloperHelpDialog(QDialog): self._banner.setVisible(self._is_staff) self._banner.setWordWrap(True) self._banner.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + # Square cards throughout this dialog: the rounded corner cut-outs + # render black on the container's non-composited X11. self._banner.setStyleSheet( - card_style(PANEL_BG_SOFT, PANEL_BORDER, selector="QLabel", extra="padding: 6px 8px;") + card_style( + PANEL_BG_SOFT, + PANEL_BORDER, + selector="QLabel", + radius=FLAT_CARD_RADIUS, + extra="padding: 6px 8px;", + ) ) root.addWidget(self._banner) @@ -89,7 +98,6 @@ class DeveloperHelpDialog(QDialog): "QLineEdit {" f" background: {WHITE};" f" border: 1px solid {PANEL_BORDER_DARK};" - " border-radius: 6px;" " padding: 4px 8px;" "}" ) @@ -149,7 +157,9 @@ class DeveloperHelpDialog(QDialog): self._details_frame = QFrame(self) self._details_frame.setFrameShape(QFrame.Shape.StyledPanel) - self._details_frame.setStyleSheet(card_style(PANEL_BG_FAINT, PANEL_BORDER)) + self._details_frame.setStyleSheet( + card_style(PANEL_BG_FAINT, PANEL_BORDER, radius=FLAT_CARD_RADIUS) + ) details_layout = QVBoxLayout(self._details_frame) details_layout.setContentsMargins(10, 10, 10, 10) @@ -179,7 +189,13 @@ class DeveloperHelpDialog(QDialog): self._detail_help.setWordWrap(True) self._detail_help.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self._detail_help.setStyleSheet( - card_style(WHITE, PANEL_BORDER_LIGHT, selector="QLabel", extra="padding: 8px;") + card_style( + WHITE, + PANEL_BORDER_LIGHT, + selector="QLabel", + radius=FLAT_CARD_RADIUS, + extra="padding: 8px;", + ) ) details_layout.addWidget(QLabel("Help:", self)) details_layout.addWidget(self._detail_help, 1) diff --git a/src/aare/gui/panels/reference_tools_panel.py b/src/aare/gui/panels/reference_tools_panel.py index 09c409fc..de489191 100644 --- a/src/aare/gui/panels/reference_tools_panel.py +++ b/src/aare/gui/panels/reference_tools_panel.py @@ -7,7 +7,7 @@ from PySide6.QtGui import QBrush from PySide6.QtWidgets import QAbstractItemView, QFrame, QGridLayout, QHeaderView, QMenu, QTableView from aare.gui.constants import LOGGER_NAME -from aare.gui.styles import SAMPLE_ROW_QUEUED_BG, WHITE, qcolor +from aare.gui.styles import SAMPLE_ROW_QUEUED_BG, SAMPLE_STATUS_TEXT, qcolor from aare.gui.widgets.title_label import TitleLabel logger = setup_logger(LOGGER_NAME) @@ -74,9 +74,19 @@ class ReferenceToolsModel(QAbstractTableModel): elif role == Qt.ItemDataRole.TextAlignmentRole: return Qt.AlignmentFlag.AlignCenter elif role == Qt.ItemDataRole.BackgroundRole: + # Tint only the current-reference row; plain rows return None so + # the theme QSS paints them (a hardcoded WHITE fill here was the + # big white table in dark mode and fought the light theme's + # alternating stripes). if self._sorted_samples[index.row()].db_id == self.current_reference: return QBrush(qcolor(SAMPLE_ROW_QUEUED_BG)) - return QBrush(qcolor(WHITE)) + elif ( + role == Qt.ItemDataRole.ForegroundRole + and self._sorted_samples[index.row()].db_id == self.current_reference + ): + # Fixed dark ink on the tint — the tint stays pale in BOTH themes, + # so the dark theme's near-white text would vanish on it. + return QBrush(qcolor(SAMPLE_STATUS_TEXT)) return None @@ -152,7 +162,7 @@ class ReferenceToolsModel(QAbstractTableModel): self.dataChanged.emit( self.index(0, 0), self.index(self.rowCount() - 1, self.columnCount() - 1), - [Qt.ItemDataRole.BackgroundRole], + [Qt.ItemDataRole.BackgroundRole, Qt.ItemDataRole.ForegroundRole], ) @@ -191,8 +201,11 @@ class ReferenceToolsPanel(QFrame): layout.addWidget(TitleLabel("Reference tools", parent=self), 0, 0, 1, 4) self.table_view = QTableView(parent=self) - # Row colors carry the separation — no grid lines. + # Row colors carry the separation — no grid lines. Alternating rows + # come from the theme QSS (alternate-background-color), same as the + # Dewar sample list. self.table_view.setShowGrid(False) + self.table_view.setAlternatingRowColors(True) layout.addWidget(self.table_view, 1, 0, 1, 4) # initialize model with provided samples (or adopt the shared one) diff --git a/src/aare/gui/panels/sample_queue_panel.py b/src/aare/gui/panels/sample_queue_panel.py index 13eeedd7..affa50f8 100644 --- a/src/aare/gui/panels/sample_queue_panel.py +++ b/src/aare/gui/panels/sample_queue_panel.py @@ -68,6 +68,9 @@ class SampleQueuePanel(QFrame): layout.addWidget(TitleLabel("Sample queue", self)) self.table_view = QTableView(self) + # Themed stripes from the QSS, matching the other sample tables — the + # model no longer paints plain rows white. + self.table_view.setAlternatingRowColors(True) self.table_model = SampleQueueSpreadsheet(show_user=show_user) self.table_view.setModel(self.table_model) diff --git a/src/aare/gui/styles.py b/src/aare/gui/styles.py index 5dffb881..c6f3021c 100644 --- a/src/aare/gui/styles.py +++ b/src/aare/gui/styles.py @@ -352,6 +352,40 @@ STATUS_WARN = "#fe640b" # baton waiting / warming cryo / tell busy (peach) STATUS_INFO = "#1e66f5" # cold cryo (blue) STATUS_VACANT = "#df8e1d" # baton vacant (yellow) STATUS_REQUEST = "#04a5e5" # baton request (sky) +# Dark variants (Catppuccin Mocha) — the latte values above sink into the +# sunset sky. Painted in code per DAQ tick, so status_colors(theme) hands +# them out — same pattern as state_colors below. +DARK_STATUS_OK = "#a6e3a1" # mocha green +DARK_STATUS_ALERT = "#f38ba8" # mocha red +DARK_STATUS_WARN = "#fab387" # mocha peach +DARK_STATUS_INFO = "#89b4fa" # mocha blue +DARK_STATUS_VACANT = "#f9e2af" # mocha yellow +DARK_STATUS_REQUEST = "#89dceb" # mocha sky +DARK_STATE_TELL_TEXT = "#bac2de" # mocha subtext1 — status-bar TELL idle line + + +def status_colors(theme: str) -> dict[str, str]: + """Status-bar flag colors for the given theme (painted in code).""" + if theme == THEME_SUNSET: + return { + "ok": DARK_STATUS_OK, + "alert": DARK_STATUS_ALERT, + "warn": DARK_STATUS_WARN, + "info": DARK_STATUS_INFO, + "vacant": DARK_STATUS_VACANT, + "request": DARK_STATUS_REQUEST, + "tell": DARK_STATE_TELL_TEXT, + } + return { + "ok": STATUS_OK, + "alert": STATUS_ALERT, + "warn": STATUS_WARN, + "info": STATUS_INFO, + "vacant": STATUS_VACANT, + "request": STATUS_REQUEST, + "tell": STATE_TELL_TEXT, + } + # -- Beamline state panel --------------------------------------------------- # The panel paints these in code per DAQ tick (data-driven), so it asks @@ -398,7 +432,7 @@ SAMPLE_ROW_ALT_BG = "#eef1f5" # staggered row grey (alternates with white) SAMPLE_STATUS_QUEUED_BG = "#ffe4c4" # pale orange — waiting in the automation queue SAMPLE_STATUS_FLAGGED_BG = "#ffd9d9" # pale red — automation failed on this sample SAMPLE_STATUS_MEASURED_BG = "#dcf2e0" # pale green — already has collected data -SAMPLE_STATUS_SELECTED_BG = "#d8e8fd" # pale blue — table selection highlight +SAMPLE_STATUS_SELECTED_BG = "#84abd9" # pale blue — table selection highlight # Fixed ink on the pastel tints above: the tints stay light in BOTH themes, # so theme-following text (white in Sunset) would vanish on them. Models # return this as ForegroundRole wherever they return a tint. @@ -1641,7 +1675,9 @@ def _sunset_stylesheet() -> str: } QLabel#beamlineStateTellLabel { - color: $dark_subtext; + /* Same color as the Current-state neighbor — subtext was unreadable + on the selected-state blue band. */ + color: $dark_text; font-size: $font_body_lg; font-weight: 700; padding-left: 4px; @@ -1671,11 +1707,22 @@ def _sunset_stylesheet() -> str: background: $dark_surface; } + /* Dewar tab page: the automation button row sits on this bare QWidget + below the panel's border — left transparent it shows the near-black + gradient bottom, reading as an unpainted hole. */ + QWidget#dewarTab { + background-color: $dark_surface; + } + QWidget#logPanel { background-color: $dark_surface; } + /* No L3 border here: the light theme's pale hairline read as a white frame around dark tables. */ QTableView, QPlainTextEdit { border: none; } + QPlainTextEdit { + background: $dark_table_bg; /* solid — transparent renders black on the container's X11 */ + } /* Selection + staggered rows, dark flavor. Solid fills on purpose — a transparent viewport renders black here (see DARK_TABLE_BG). */ diff --git a/src/aare/gui/widgets/status_bar.py b/src/aare/gui/widgets/status_bar.py index aca8d4d1..23fb4094 100644 --- a/src/aare/gui/widgets/status_bar.py +++ b/src/aare/gui/widgets/status_bar.py @@ -8,15 +8,7 @@ from PySide6.QtGui import QFont from PySide6.QtWidgets import QDialog, QLabel, QMenu, QMessageBox, QSizePolicy, QStatusBar from aare.gui.constants import LOGGER_NAME -from aare.gui.styles import ( - STATE_TELL_TEXT, - STATUS_ALERT, - STATUS_INFO, - STATUS_OK, - STATUS_REQUEST, - STATUS_VACANT, - STATUS_WARN, -) +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.pgroup_dialog import PGroupDialog @@ -54,6 +46,11 @@ class StatusBar(QStatusBar): self._is_staff = self._decoded_token.staff self._allowed_pgroups = self._decoded_token.pgroups + # Per-theme flag colors (MainWindow._apply_theme calls set_theme) — + # painted in code per DAQ tick, QSS cannot reach the rich-text spans. + self._colors = status_colors(THEME_SUNRISE) + self._message_is_error = False + self._message_clear_timer = QTimer(self) self._message_clear_timer.setSingleShot(True) self._message_clear_timer.timeout.connect(self.clear_connection_message) @@ -109,12 +106,24 @@ class StatusBar(QStatusBar): self.addPermanentWidget(self.busy_label) self.addPermanentWidget(self.session_label) + def set_theme(self, theme: str) -> None: + """Adopt the theme's flag colors: recolor the connection message and + re-render the DAQ-driven labels from the last status right away.""" + self._colors = status_colors(theme) + self._apply_message_style() + if self._status is not None: + self.update_daq_status(self._status) + + def _apply_message_style(self) -> None: + color = self._colors["alert"] if self._message_is_error else self._colors["ok"] + self.message_label.setStyleSheet(f"color: {color}; font-weight: bold;") + @Slot(str, bool) def show_connection_message(self, msg: str, is_error: bool = True): - color = STATUS_ALERT if is_error else STATUS_OK + self._message_is_error = is_error self._message_clear_timer.stop() self.message_label.setText(msg) - self.message_label.setStyleSheet(f"color: {color}; font-weight: bold;") + self._apply_message_style() self.message_label.setVisible(bool(msg)) if not is_error: @@ -155,9 +164,13 @@ class StatusBar(QStatusBar): self.transmission.set_value(f"{status.bl.transmission:.5f}") if status.bl.ring_current_mA < 5.0: - self.ring_current.set_value(f"{status.bl.ring_current_mA:.2f}", STATUS_ALERT) + self.ring_current.set_value( + f"{status.bl.ring_current_mA:.2f}", self._colors["alert"] + ) elif status.bl.ring_current_mA < 390.0: - self.ring_current.set_value(f"{status.bl.ring_current_mA:.2f}", STATUS_WARN) + self.ring_current.set_value( + f"{status.bl.ring_current_mA:.2f}", self._colors["warn"] + ) else: self.ring_current.set_value(f"{status.bl.ring_current_mA:.2f}") @@ -165,28 +178,28 @@ class StatusBar(QStatusBar): self.wvl.set_value(f"{status.diffraction.wavelength_angstrom:.2f}") if status.bl.cryojet_K < 110.0: - self.cryo_label.set_value(f"{status.bl.cryojet_K:.1f}", STATUS_INFO) + self.cryo_label.set_value(f"{status.bl.cryojet_K:.1f}", self._colors["info"]) elif status.bl.cryojet_K < 250.0: - self.cryo_label.set_value(f"{status.bl.cryojet_K:.1f}", STATUS_WARN) + self.cryo_label.set_value(f"{status.bl.cryojet_K:.1f}", self._colors["warn"]) else: - self.cryo_label.set_value(f"{status.bl.cryojet_K:.1f}", STATUS_ALERT) + self.cryo_label.set_value(f"{status.bl.cryojet_K:.1f}", self._colors["alert"]) if status.bl.shutter_open: self.shutter_label.setText( - f"""Fast Shutter: Open ☢️ """ + f"""Fast Shutter: Open ☢️ """ ) else: self.shutter_label.setText( - f"""Fast Shutter: Closed 🚪 """ + f"""Fast Shutter: Closed 🚪 """ ) if status.bl.exp_shutter_open: self.exp_shutter_label.setText( - f"""ExpHutch Shutter: Open """ + f"""ExpHutch Shutter: Open """ ) else: self.exp_shutter_label.setText( - f"""ExpHutch Shutter: Closed 🚪 """ + f"""ExpHutch Shutter: Closed 🚪 """ ) if status.session.current_pgroup is not None: @@ -197,29 +210,29 @@ class StatusBar(QStatusBar): self.state_label.setText(f"""State: {status.state.display_name()} """) tell_text = "—" - tell_color = STATE_TELL_TEXT + tell_color = self._colors["tell"] if status.tell_state is not None: tell_text = status.tell_state.activity.display_name() if status.tell_state.activity.value == "error": - tell_color = STATUS_ALERT + tell_color = self._colors["alert"] elif status.tell_state.activity.value in { "mounting", "unmounting", "drying", "cooling", }: - tell_color = STATUS_WARN + tell_color = self._colors["warn"] else: - tell_color = STATUS_OK + tell_color = self._colors["ok"] self.tell_state_label.setText(f"Tell: {tell_text} ") self.tell_state_label.setStyleSheet(f"color: {tell_color};") if status.busy: - busy_flag = f""" Busy 🔒 """ + busy_flag = f""" Busy 🔒 """ else: - busy_flag = f""" Idle 🔓 """ + busy_flag = f""" Idle 🔓 """ html_content = f"""Beamline: {busy_flag} """ @@ -227,15 +240,23 @@ class StatusBar(QStatusBar): session_flag = "" if status.session.session == SessionsStateEnum.Vacant: - session_flag = f""" Vacant 🔓 """ + session_flag = ( + f""" Vacant 🔓 """ + ) elif status.session.session == SessionsStateEnum.OwnedByYou: - session_flag = f""" Owned ⬤ """ + session_flag = f""" Owned ⬤ """ elif status.session.session == SessionsStateEnum.OwnedByElse: - session_flag = f""" Other 🔒 """ + session_flag = ( + f""" Other 🔒 """ + ) elif status.session.session == SessionsStateEnum.PendingYouToElse: - session_flag = f""" Waiting... ⏳ """ + session_flag = ( + f""" Waiting... ⏳ """ + ) elif status.session.session == SessionsStateEnum.PendingElseToYou: - session_flag = f""" Request! ⚡ """ + session_flag = ( + f""" Request! ⚡ """ + ) html_content_session = f"""Session: {session_flag}""" self.session_label.setText(html_content_session) @@ -603,7 +624,8 @@ class StatusBar(QStatusBar): def _generate_pgroup_dialogue(self, curr: str | None = None, pgroups: list | None = None): logger.info(pgroups) - dialog = PGroupDialog(curr_pgroup=curr, pgroups=pgroups) + dialog = PGroupDialog(curr_pgroup=curr, pgroups=pgroups, parent=self.window()) + if dialog.exec() == QDialog.DialogCode.Accepted: entered_text = dialog.get_input() if pgroups and entered_text not in pgroups: -- 2.54.0 From b08817024dd6c03d8b7f67abfb7a9cd982d0a76e Mon Sep 17 00:00:00 2001 From: Dawn Date: Tue, 11 Aug 2026 09:47:06 +0200 Subject: [PATCH 56/57] feat: reference-tools position column and state-driven default sample tab The reference table gets a display-only '#' column (like the Dewar list) with sort indices shifted around it; the sample dock switches to the Auxiliary puck tab on entering alignment/maintenance states and back to Dewar elsewhere, only on state transitions so a manual choice sticks. Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 30 ++++++++++++++++++++ src/aare/gui/panels/reference_tools_panel.py | 25 +++++++++++----- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 99a437e6..cc254268 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -593,6 +593,10 @@ class MainWindow(QMainWindow): # click is caught in eventFilter via the geometric tabAt() instead. self.sample_lists_tabs.tabBar().installEventFilter(self) + # Tracks beamline-state TRANSITIONS for the default sample-tab switch + # (see _apply_default_sample_tab). + self._last_beamline_state: BeamlineStateEnum | None = None + # Wrapper for the left inset: QTabWidget ignores its own contents # margins for the tab bar, so the padding lives one level up. Aligns # the panel's left edge with the left column above (Loop centering). @@ -2554,11 +2558,37 @@ class MainWindow(QMainWindow): for widget in hide_in_watch_mode + self.findChildren(QDockWidget): widget.hide() + # Alignment/maintenance states mount reference pins, so the sample dock + # defaults to the Auxiliary puck there; every other state defaults back + # to the Dewar list. + _AUX_PUCK_STATES = frozenset( + { + BeamlineStateEnum.Maintenance, + BeamlineStateEnum.BeamLocation, + BeamlineStateEnum.BeamstopAlignment, + BeamlineStateEnum.FluxMeasurement, + } + ) + + def _apply_default_sample_tab(self, state: BeamlineStateEnum | None) -> None: + # Index 1 = Auxiliary puck; disabled for non-staff, never force it. + if state in self._AUX_PUCK_STATES and self.sample_lists_tabs.isTabEnabled(1): + self.sample_lists_tabs.setCurrentIndex(1) + else: + self.sample_lists_tabs.setCurrentIndex(0) + @Slot(DAQStatusModel) def update_daq_status(self, s: DAQStatusModel): self._latest_daq_status = s self._apply_session_gate(getattr(getattr(s, "session", None), "session", None)) + # Default tab only on state TRANSITIONS — a manual tab choice + # survives while the state stays put. + new_beamline_state = getattr(s, "state", None) + if new_beamline_state != self._last_beamline_state: + self._last_beamline_state = new_beamline_state + self._apply_default_sample_tab(new_beamline_state) + if self._is_automation_active(): self._refresh_idle_activity(report_backend=False) diff --git a/src/aare/gui/panels/reference_tools_panel.py b/src/aare/gui/panels/reference_tools_panel.py index de489191..8710db23 100644 --- a/src/aare/gui/panels/reference_tools_panel.py +++ b/src/aare/gui/panels/reference_tools_panel.py @@ -40,7 +40,10 @@ class ReferenceToolsModel(QAbstractTableModel): self.samples: list[SampleShortInfo] = rows or [] self.current_reference = current_reference + # Column 0 is display-only: the row position ("#"), matching the + # Dewar samples table; the vertical header is hidden in the panel. self.header = [ + "#", "Position", "Sample name", "Mount count", @@ -48,7 +51,7 @@ class ReferenceToolsModel(QAbstractTableModel): "Rotation count", "Screening count", ] - self._sort_col = 0 + self._sort_col = 1 self._sort_order = Qt.SortOrder.AscendingOrder self._sorted_samples: list[SampleShortInfo] = [] if self.samples: @@ -70,7 +73,9 @@ class ReferenceToolsModel(QAbstractTableModel): return None if role == Qt.ItemDataRole.DisplayRole: - return get_entry(self._sorted_samples[index.row()], index.column()) + if index.column() == 0: + return str(index.row() + 1) + return get_entry(self._sorted_samples[index.row()], index.column() - 1) elif role == Qt.ItemDataRole.TextAlignmentRole: return Qt.AlignmentFlag.AlignCenter elif role == Qt.ItemDataRole.BackgroundRole: @@ -109,6 +114,9 @@ class ReferenceToolsModel(QAbstractTableModel): self.endResetModel() def sort(self, column, order): + # The "#" column is display-only — nothing to sort by. + if column == 0: + return self.layoutAboutToBeChanged.emit() self._sort_order = order self._sort_col = column @@ -121,14 +129,14 @@ class ReferenceToolsModel(QAbstractTableModel): self._sorted_samples = [] return - if self._sort_col == 0: + if self._sort_col == 1: # Special sorting for location (Position column) self._sorted_samples = sorted( self.samples, key=lambda row: row.loc_str_sort(), reverse=(self._sort_order == Qt.SortOrder.DescendingOrder), ) - elif self._sort_col == 2: + elif self._sort_col == 3: # Numeric sort for Mount count; place None last on ascending, first on descending none_sentinel = ( float("inf") if self._sort_order == Qt.SortOrder.AscendingOrder else float("-inf") @@ -141,10 +149,11 @@ class ReferenceToolsModel(QAbstractTableModel): reverse=(self._sort_order == Qt.SortOrder.DescendingOrder), ) else: - # String sort with empty fallback + # String sort with empty fallback (get_entry columns sit one left + # of the view columns because of the display-only "#"). self._sorted_samples = sorted( self.samples, - key=lambda row: get_entry(row, self._sort_col) or "", + key=lambda row: get_entry(row, self._sort_col - 1) or "", reverse=(self._sort_order == Qt.SortOrder.DescendingOrder), ) @@ -221,7 +230,9 @@ class ReferenceToolsPanel(QFrame): header.setStretchLastSection(True) # No bold column titles when cells are selected. header.setHighlightSections(False) - self.table_view.verticalHeader().setVisible(True) + # Row numbers live in the display-only "#" column (like the Dewar + # table), not the vertical header. + self.table_view.verticalHeader().setVisible(False) logger.debug("Setting up table view sorting") # Adopt the model's current order first — a second panel on a shared # model must not re-sort it on open. -- 2.54.0 From 833a6d136f1b862cd38932c0b24083fbe0ebd95a Mon Sep 17 00:00:00 2001 From: Dawn Date: Tue, 11 Aug 2026 09:47:58 +0200 Subject: [PATCH 57/57] style: visual and interaction polish Windowed startup at 50%x70% instead of maximized; overlay legend now defaults off (it covers the camera image); square slider handles with grip-line PNG assets (the round handle clipped flat); warmer sunrise gradient bottom; abr/beam-mark layout tweaks; hover cues on the beamline-state entries; DPI/font-aware pop-out titlebar icons; the out-of-date Tutorial menu entries are hidden until content is redone. Co-Authored-By: Claude Fable 5 --- src/aare/gui/graphics/slider_grip_dark.png | Bin 0 -> 103 bytes src/aare/gui/graphics/slider_grip_light.png | Bin 0 -> 103 bytes src/aare/gui/gui.py | 13 ++++---- src/aare/gui/main_window.py | 21 ++++++------- src/aare/gui/panels/abr_tweak_panel.py | 9 ++++-- src/aare/gui/panels/beam_mark_panel.py | 2 +- src/aare/gui/panels/beamline_state_panel.py | 15 ++++++++-- src/aare/gui/panels/samcam_panel.py | 2 +- src/aare/gui/styles.py | 25 +++++++++------- src/aare/gui/widgets/camera_image.py | 2 +- src/aare/gui/widgets/popout_window.py | 31 +++++++++++++++----- 11 files changed, 76 insertions(+), 44 deletions(-) create mode 100644 src/aare/gui/graphics/slider_grip_dark.png create mode 100644 src/aare/gui/graphics/slider_grip_light.png diff --git a/src/aare/gui/graphics/slider_grip_dark.png b/src/aare/gui/graphics/slider_grip_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..ef6d413fe6d22ea2f268ee0c8aa107615fc21386 GIT binary patch literal 103 zcmeAS@N?(olHy`uVBq!ia0vp^tU%1h!3HF`e}{MjDb50q$YKTtz9S&aI8~cZ8Yn2~ x>Eal|F*A9^>*!DO8yg!LXQm|`V9HSBU|2MhL*ifJn;4)X22WQ%mvv4FO#mj68KD3G literal 0 HcmV?d00001 diff --git a/src/aare/gui/graphics/slider_grip_light.png b/src/aare/gui/graphics/slider_grip_light.png new file mode 100644 index 0000000000000000000000000000000000000000..810086224bd04cb6f4a34fc6b09f4df026a29b28 GIT binary patch literal 103 zcmeAS@N?(olHy`uVBq!ia0vp^tU%1h!3HF`e}{MjDb50q$YKTtz9S&aI8~cZ8Yn2~ v>Eal|F*7-0