diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 507a68a5..1baf7b61 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -2,7 +2,7 @@ import time import jwt from PySide6.QtCore import Qt, Slot, Signal, QTimer, QSettings, QEvent -from PySide6.QtGui import QAction, QPixmap, QKeySequence, QGuiApplication +from PySide6.QtGui import QAction, QPixmap, QKeySequence, QGuiApplication, QActionGroup from PySide6.QtWidgets import ( QMainWindow, QWidget, @@ -11,12 +11,11 @@ from PySide6.QtWidgets import ( QMessageBox, QDockWidget, QTabWidget, - QStackedWidget + QStackedWidget, ) #Common imports from aare.common.auth_models import BatonStatus -from aare.common.beamline import cfg_get from aare.common.coordinate import Coordinate, SmargonCoordinate from aare.common.diffraction_geometry import DiffractionGeometry from aare.common.logger_config import setup_logger @@ -25,6 +24,7 @@ from aare.common.sample_geometry import SampleGeometryModel #Gui Models from aare.gui.models.gui_state_manager import UIStateManager +from aare.gui.styles import build_app_stylesheet, THEME_ORIGINAL, THEME_PORTRAIT #panels from aare.gui.panels.LogPanel import LogDock @@ -95,11 +95,17 @@ class MainWindow(QMainWindow): ): super().__init__() + self._theme_mode = THEME_ORIGINAL + self._theme_action_group = None + self._use_legacy_theme_action = None + self._use_portrait_theme_action = None + self.__base_url = base_url self.__token = token self.__mounting = False self.__samcam_feed_banner_active = False self.__samcam_feed_banner_message = "Sample camera feed unavailable" + self._automation_critical_banner_active = False self._dev_help_dialog = None self._beamline_recovery_dialog = None @@ -166,6 +172,7 @@ class MainWindow(QMainWindow): self.setStyleSheet("background-color: rgb(216, 228, 253);") root_widget = QWidget(parent=self) + root_widget.setObjectName("mainContentRoot") root_layout = QVBoxLayout(root_widget) root_layout.setContentsMargins(0, 0, 0, 0) root_layout.setSpacing(0) @@ -180,6 +187,7 @@ class MainWindow(QMainWindow): root_layout.addWidget(self.content_stack, 1) top_widget = QWidget(parent=root_widget) + top_widget.setObjectName("standardMainPage") top_widget_layout = QHBoxLayout(top_widget) top_widget.setLayout(top_widget_layout) @@ -300,21 +308,12 @@ class MainWindow(QMainWindow): self.compact_automation_page = QWidget(parent=root_widget) self.compact_automation_page.setObjectName("compactAutomationPage") - self.compact_automation_page.setStyleSheet( - """ - QWidget#compactAutomationPage { - background-color: rgb(216, 228, 253); - } - """ - ) self.compact_automation_page_layout = QVBoxLayout(self.compact_automation_page) self.compact_automation_page_layout.setContentsMargins(18, 18, 18, 18) self.compact_automation_page_layout.setSpacing(0) self.compact_automation_page_layout.addWidget(self.compact_automation_panel) # ── Portrait mode page ────────────────────────────────────────── - # Uses a dedicated SampleCameraImageLabel so the compact_sample_camera - # remains available for the existing compact automation view. self.portrait_sample_camera = SampleCameraImageLabel( geom=geom, raster=self.raster, @@ -325,21 +324,9 @@ class MainWindow(QMainWindow): sample_camera_widget=self.portrait_sample_camera, parent=root_widget, ) - self.portrait_mode_panel.setObjectName("portraitModePage") - self.portrait_mode_panel.setStyleSheet( - self.portrait_mode_panel.styleSheet() + - "QWidget#portraitModePage { background-color: #071018; }" - ) self.portrait_mode_page = QWidget(parent=root_widget) self.portrait_mode_page.setObjectName("portraitModePage") - self.portrait_mode_page.setStyleSheet( - """ - QWidget#portraitModePage { - background-color: #071018; - } - """ - ) portrait_page_layout = QHBoxLayout(self.portrait_mode_page) portrait_page_layout.setContentsMargins(0, 0, 0, 0) portrait_page_layout.setSpacing(0) @@ -534,6 +521,8 @@ class MainWindow(QMainWindow): self.setCentralWidget(root_widget) self.setWindowTitle("AareGUI") + self._restore_theme_settings() + self._apply_theme() self.create_menu_bar() self._update_view_mode_actions() self._setup_global_shortcuts() @@ -874,6 +863,23 @@ class MainWindow(QMainWindow): ) self.addAction(self._shortcut_console_log) + def _return_to_main_view_for_shutdown(self) -> None: + try: + if getattr(self, "content_stack", None) is None: + return + + current_widget = self.content_stack.currentWidget() + + if hasattr(self, "portrait_mode_page") and current_widget is self.portrait_mode_page: + self._return_from_portrait_mode() + elif bool(getattr(self, "_in_compact_automation_view", False)): + self._return_from_compact_automation_view() + + if hasattr(self, "content_stack") and hasattr(self, "_standard_main_page"): + self.content_stack.setCurrentWidget(self._standard_main_page) + except Exception as e: + logger.warning(f"Failed to return to main view during shutdown: {e}") + def _restore_samcam_overlay_settings(self) -> None: settings = QSettings("PSI", "AareGUI") show_detections = settings.value("samcam/show_detections", True, type=bool) @@ -1253,9 +1259,29 @@ class MainWindow(QMainWindow): def start_interactive_tutorial(self) -> None: self.tutorial_manager.start("manual_workflow_demo") + def _apply_theme(self) -> None: + self.setStyleSheet(build_app_stylesheet(self._theme_mode)) + + def _restore_theme_settings(self) -> None: + settings = QSettings("PSI", "AareGUI") + self._theme_mode = settings.value("appearance/theme", THEME_ORIGINAL, type=str) + + def _save_theme_settings(self) -> None: + settings = QSettings("PSI", "AareGUI") + settings.setValue("appearance/theme", self._theme_mode) + + @Slot() + def use_legacy_theme(self) -> None: + self._theme_mode = THEME_ORIGINAL + self._apply_theme() + + @Slot() + def use_portrait_theme(self) -> None: + self._theme_mode = THEME_PORTRAIT + self._apply_theme() + def create_menu_bar(self): """Create a menu bar with File->Quit and Help->About.""" - # Main menu bar menu_bar = self.menuBar() file_menu = menu_bar.addMenu("File") @@ -1281,6 +1307,25 @@ class MainWindow(QMainWindow): 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.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.setCheckable(True) + self._use_portrait_theme_action.setChecked(self._theme_mode == THEME_PORTRAIT) + self._use_portrait_theme_action.triggered.connect(self.use_portrait_theme) + self._theme_action_group.addAction(self._use_portrait_theme_action) + + view_menu.addAction(self._use_legacy_theme_action) + 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) @@ -2006,16 +2051,22 @@ class MainWindow(QMainWindow): self._restore_panel_visibility_settings() def closeEvent(self, event) -> None: + try: + self._return_to_main_view_for_shutdown() + except Exception as e: + logger.warning(f"Failed to restore main view before close: {e}") + try: # TODO put all setting related handling into state_manager self.state_manager.save_window(self) self._save_samcam_overlay_settings() self._save_panel_visibility_settings() + self._save_theme_settings() except Exception as e: logger.warning(f"Failed to save main window state: {e}") - # End session before closing so the backend removes this GUI from Redis immediately + # End session before closing so the backend removes this GUI from Redis immediately try: self.daq.end_session_on_close() except Exception as e: @@ -2031,6 +2082,12 @@ class MainWindow(QMainWindow): def cleanup(self): if getattr(self, "_cleanup_done", False): return + + try: + self._return_to_main_view_for_shutdown() + except Exception as e: + logger.warning(f"Failed to restore main view during cleanup: {e}") + self._cleanup_done = True try: @@ -2066,7 +2123,7 @@ class MainWindow(QMainWindow): self._stop_axis_camera_threads() for attr_name in ( - "prediction_thread", + "prediction_thread", ): thread = getattr(self, attr_name, None) if thread is None: diff --git a/src/aare/gui/panels/axis_video_panel.py b/src/aare/gui/panels/axis_video_panel.py index f1fa36ea..216ffd11 100644 --- a/src/aare/gui/panels/axis_video_panel.py +++ b/src/aare/gui/panels/axis_video_panel.py @@ -12,38 +12,18 @@ class AxisVideoPanel(QWidget): super().__init__(parent) self._title_label = QLabel(title, self) - self._title_label.setStyleSheet("font-weight: bold;") self._status_container = QWidget(self) - self._status_container.setStyleSheet( - "QWidget {" - " border-radius: 12px;" - " background-color: #d9e2f2;" - "}" - ) + 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_dot.setStyleSheet( - "QLabel {" - " min-width: 10px;" - " max-width: 10px;" - " min-height: 10px;" - " max-height: 10px;" - " border-radius: 5px;" - " background-color: transparent;" - "}" - ) self._status_label = QLabel("", self._status_container) + self._status_label.setObjectName("axisVideoStatusLabel") self._status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._status_label.setStyleSheet( - "QLabel {" - " background-color: transparent;" - " color: #2f3b52;" - " font-weight: bold;" - "}" - ) status_layout = QHBoxLayout(self._status_container) status_layout.setContentsMargins(10, 6, 12, 6) @@ -72,6 +52,12 @@ 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): @@ -86,55 +72,21 @@ class AxisVideoPanel(QWidget): def set_busy_style(self, style: BusyOverlayStyle | None) -> None: if style is None: self._status_label.setText("") - self._status_container.setStyleSheet( - "QWidget {" - " border-radius: 12px;" - " background-color: #d9e2f2;" - "}" - ) - self._status_dot.setStyleSheet( - "QLabel {" - " min-width: 10px;" - " max-width: 10px;" - " min-height: 10px;" - " max-height: 10px;" - " border-radius: 5px;" - " background-color: transparent;" - "}" - ) - self._status_label.setStyleSheet( - "QLabel {" - " background-color: transparent;" - " color: #2f3b52;" - " font-weight: bold;" - "}" - ) + 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.setStyleSheet( - "QWidget {" - " border-radius: 12px;" - f" background-color: {style.badge_bg};" - "}" - ) + self._status_container.setProperty("busyState", "active") self._status_dot.setStyleSheet( - "QLabel {" - " min-width: 10px;" - " max-width: 10px;" - " min-height: 10px;" - " max-height: 10px;" - " border-radius: 5px;" - f" background-color: {style.accent_dot};" - "}" + f"background-color: {style.accent_dot};" ) self._status_label.setStyleSheet( - "QLabel {" - " background-color: transparent;" - f" color: {style.badge_fg};" - " font-weight: bold;" - "}" + f"color: {style.badge_fg};" ) + self._refresh_status_style() self._status_container.show() for view in self._all_video_views(): diff --git a/src/aare/gui/panels/beamline_state_panel.py b/src/aare/gui/panels/beamline_state_panel.py index 38f5a4b6..9e41a56c 100644 --- a/src/aare/gui/panels/beamline_state_panel.py +++ b/src/aare/gui/panels/beamline_state_panel.py @@ -64,6 +64,7 @@ class BeamlineStatePanel(QFrame): 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) @@ -87,16 +88,16 @@ class BeamlineStatePanel(QFrame): self._label_hover_bg = "rgba(244, 196, 48, 0.22)" self._group_colors: dict[BeamlineStateEnum, QColor] = { - BeamlineStateEnum.DewarTransfer: QColor(128, 90, 213), # Purple - BeamlineStateEnum.SampleExchange: QColor(237, 137, 54), # Orange - BeamlineStateEnum.RobotSampleExchange: QColor(237, 137, 54), # Orange - BeamlineStateEnum.SampleAlignment: QColor(72, 187, 120), # Green - BeamlineStateEnum.BeamLocation: QColor(72, 187, 120), # Green - BeamlineStateEnum.BeamstopAlignment: QColor(72, 187, 120), # Green - BeamlineStateEnum.FluxMeasurement: QColor(72, 187, 120), # Green - BeamlineStateEnum.DataCollection: QColor(236, 72, 153), # Pink - BeamlineStateEnum.XtalSnapshot: QColor(236, 72, 153), # Pink - BeamlineStateEnum.XrayFluorescence: QColor(236, 72, 153), # Pink + 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), } self._group_label_colors: dict[BeamlineStateEnum, str] = { @@ -153,54 +154,26 @@ class BeamlineStatePanel(QFrame): self._station_widgets: dict[BeamlineStateEnum, QLabel | QPushButton] = {} self.title = QLabel(self) + self.title.setObjectName("beamlineStateTitle") self.title.setText("

Beamline state

") - self.title.setStyleSheet("background-color: #4B0082; color: #ffffff;") self.title.setAlignment(Qt.AlignmentFlag.AlignCenter) self.title.setFixedHeight(self.title_height) self.title.setGeometry(0, 0, self.set_width, self.title_height) 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.clicked.connect(self.toggle_collapsed) - self.toggle_button.setStyleSheet(""" - QPushButton { - border: none; - border-radius: 14px; - background-color: rgba(255, 255, 255, 0.20); - color: white; - font-size: 16px; - font-weight: 700; - } - QPushButton:hover { - background-color: rgba(255, 255, 255, 0.32); - } - """) self.current_label = QLabel("Current: —", self) - self.current_label.setStyleSheet(""" - QLabel { - color: rgb(30, 41, 59); - font-size: 18px; - font-weight: 700; - padding-left: 4px; - background: transparent; - } - """) + self.current_label.setObjectName("beamlineStateCurrentLabel") self.current_label.move(14, 58) self.current_label.adjustSize() self.tell_label = QLabel("Tell: —", self) - self.tell_label.setStyleSheet(""" - QLabel { - color: rgb(55, 67, 87); - font-size: 15px; - font-weight: 600; - padding-left: 4px; - background: transparent; - } - """) + self.tell_label.setObjectName("beamlineStateTellLabel") self.tell_label.move(14, 86) self.tell_label.adjustSize() @@ -580,15 +553,7 @@ class BeamlineStatePanel(QFrame): tell_color = "green" self.tell_label.setText(tell_text) - self.tell_label.setStyleSheet(f""" - QLabel {{ - color: {tell_color}; - font-size: 15px; - font-weight: 600; - padding-left: 4px; - background: transparent; - }} - """) + self.tell_label.setStyleSheet(f"color: {tell_color};") self.tell_label.adjustSize() def set_current_state(self, state: BeamlineStateEnum | None) -> None: diff --git a/src/aare/gui/panels/compact_automation_panel.py b/src/aare/gui/panels/compact_automation_panel.py index 3c64887f..d3545f34 100644 --- a/src/aare/gui/panels/compact_automation_panel.py +++ b/src/aare/gui/panels/compact_automation_panel.py @@ -34,110 +34,6 @@ class CompactAutomationPanel(QFrame): self.setFrameShape(QFrame.Shape.NoFrame) self.setObjectName("compactAutomationPanel") - self.setStyleSheet( - """ - QFrame#compactAutomationPanel { - background: #d8e4fd; - border: none; - border-radius: 18px; - } - - QFrame#compactCameraCard, - QFrame#compactControlsCard, - QFrame#compactProgressCard, - QFrame#compactQueueCard, - QFrame#compactQueueItem { - background: #e6eefc; - border: 1px solid #b9ccee; - border-radius: 16px; - } - - QLabel#compactSectionTitle { - background: transparent; - color: #17324d; - font-size: 14px; - font-weight: 700; - } - - QLabel#compactSectionHint { - background: transparent; - color: #51657d; - font-size: 12px; - } - - QLabel#compactQueueTitle { - background: transparent; - color: #51657d; - font-size: 11px; - font-weight: 700; - } - - QLabel#compactQueueValue { - background: transparent; - color: #10263a; - font-size: 14px; - font-weight: 700; - } - - QToolButton#compactMenuButton { - background: #cbdcf8; - color: #17324d; - border: 1px solid #9fb9e5; - border-radius: 14px; - padding: 10px 14px; - font-size: 18px; - font-weight: 700; - } - - QToolButton#compactMenuButton:hover { - background: #bfd4f6; - } - - QLabel#compactProgressSummary { - background: transparent; - color: #17324d; - font-size: 13px; - padding: 2px 2px 6px 2px; - } - - QLabel#compactProgressStep { - background: #dfe9fb; - border: 1px solid #bfd1ef; - border-radius: 10px; - padding: 8px 6px; - } - - QPushButton#compactPrimaryButton { - background: #2563eb; - color: white; - border: none; - border-radius: 14px; - padding: 14px 18px; - font-size: 15px; - font-weight: 700; - } - - QPushButton#compactPrimaryButton:hover { - background: #1d4ed8; - } - - QPushButton#compactSecondaryButton, - QToolButton#compactSecondaryButton { - background: #dfe9fb; - color: #17324d; - border: 1px solid #b2c7eb; - border-radius: 14px; - padding: 14px 18px; - font-size: 14px; - font-weight: 700; - } - - QPushButton#compactSecondaryButton:hover, - QToolButton#compactSecondaryButton:hover { - background: #d3e1f8; - } - """ - ) main_layout = QVBoxLayout(self) main_layout.setContentsMargins(18, 18, 18, 18) diff --git a/src/aare/gui/panels/portrait_mode.py b/src/aare/gui/panels/portrait_mode.py index d5c1a977..26d7d4fd 100644 --- a/src/aare/gui/panels/portrait_mode.py +++ b/src/aare/gui/panels/portrait_mode.py @@ -26,33 +26,6 @@ BUTTON_BG = "#132131" LED_OFF = "#1C2E3E" ACTIVE_STEP = "#FFFFFF" -PORTRAIT_STYLE = f""" -QWidget {{ - background: {BG}; - color: {TEXT}; - font-family: 'Inter', 'SF Pro Display', Arial, sans-serif; - font-size: 14px; -}} -QScrollArea {{ - border: none; - background: transparent; -}} -QScrollBar:vertical {{ - background: {CARD_BG}; - width: 4px; - border-radius: 2px; -}} -QScrollBar::handle:vertical {{ - background: {ACCENT_DIM}; - border-radius: 2px; - min-height: 20px; -}} -QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ - height: 0px; -}} -""" - - # --------------------------------------------------------------------------- # LED step indicator # --------------------------------------------------------------------------- @@ -269,7 +242,7 @@ class PortraitModePanel(QWidget): def __init__(self, sample_camera_widget: QWidget, parent=None): super().__init__(parent) - self.setStyleSheet(PORTRAIT_STYLE) + self.setObjectName("portraitRoot") self.setMaximumWidth(self.PORTRAIT_WIDTH) self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) diff --git a/src/aare/gui/styles.py b/src/aare/gui/styles.py new file mode 100644 index 00000000..9e2be286 --- /dev/null +++ b/src/aare/gui/styles.py @@ -0,0 +1,493 @@ +from __future__ import annotations + +THEME_ORIGINAL = "original" +THEME_PORTRAIT = "portrait" + + +def build_app_stylesheet(theme: str) -> str: + if theme == THEME_PORTRAIT: + return _portrait_stylesheet() + return _original_stylesheet() + + +def _original_stylesheet() -> str: + return """ + QMainWindow, QWidget { + background-color: rgb(216, 228, 253); + color: rgb(30, 41, 59); + } + + QWidget#mainContentRoot, + QWidget#standardMainPage, + QWidget#compactAutomationPage { + background-color: rgb(216, 228, 253); + } + + QWidget#portraitModePage { + background-color: #071018; + } + + QFrame#compactAutomationPanel { + background: #d8e4fd; + border: none; + border-radius: 18px; + } + + QFrame#compactCameraCard, + QFrame#compactControlsCard, + QFrame#compactProgressCard, + QFrame#compactQueueCard, + QFrame#compactQueueItem { + background: #e6eefc; + border: 1px solid #b9ccee; + border-radius: 16px; + } + + QLabel#compactSectionTitle { + background: transparent; + color: #17324d; + font-size: 14px; + font-weight: 700; + } + + QLabel#compactSectionHint { + background: transparent; + color: #51657d; + font-size: 12px; + } + + QLabel#compactQueueTitle { + background: transparent; + color: #51657d; + font-size: 11px; + font-weight: 700; + } + + QLabel#compactQueueValue { + background: transparent; + color: #10263a; + font-size: 14px; + font-weight: 700; + } + + QToolButton#compactMenuButton { + background: #cbdcf8; + color: #17324d; + border: 1px solid #9fb9e5; + border-radius: 14px; + padding: 10px 14px; + font-size: 18px; + font-weight: 700; + } + + QToolButton#compactMenuButton:hover { + background: #bfd4f6; + } + + QPushButton#compactPrimaryButton { + background: #2563eb; + color: white; + border: none; + border-radius: 14px; + padding: 14px 18px; + font-size: 15px; + font-weight: 700; + } + + QPushButton#compactPrimaryButton:hover { + background: #1d4ed8; + } + + QPushButton#compactSecondaryButton, + QToolButton#compactSecondaryButton { + background: #dfe9fb; + color: #17324d; + border: 1px solid #b2c7eb; + border-radius: 14px; + padding: 14px 18px; + font-size: 14px; + font-weight: 700; + } + + QPushButton#compactSecondaryButton:hover, + QToolButton#compactSecondaryButton:hover { + background: #d3e1f8; + } + + QFrame#alertBanner[alertKind="error"] { + background-color: #fbe4e6; + border: 2px solid #d97a84; + border-radius: 12px; + margin: 8px 12px 8px 12px; + } + + QFrame#alertBanner[alertKind="success"] { + background-color: #e7f6ea; + border: 2px solid #7bbf8e; + border-radius: 12px; + margin: 8px 12px 8px 12px; + } + + QFrame#alertBanner[alertKind="waiting"] { + background-color: #fff8e1; + border: 2px solid #ffb300; + border-radius: 12px; + margin: 8px 12px 8px 12px; + } + + QFrame#alertBanner QLabel { + font-weight: 700; + font-size: 20px; + padding: 2px 6px 2px 6px; + } + + QFrame#alertBanner[alertKind="error"] QLabel { + color: #8f1d2c; + } + + QFrame#alertBanner[alertKind="success"] QLabel { + color: #1f6a3a; + } + + QFrame#alertBanner[alertKind="waiting"] QLabel { + color: #e65100; + } + + QWidget#axisVideoStatusContainer[busyState="idle"] { + border-radius: 12px; + background-color: #d9e2f2; + } + + QWidget#axisVideoStatusContainer[busyState="active"] { + border-radius: 12px; + } + + 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: #2f3b52; + font-weight: bold; + } + + QFrame#beamlineStatePanel { + background: rgb(216, 228, 253); + border: 1px solid rgb(185, 204, 238); + border-radius: 12px; + } + + 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); + } + + QLabel#beamlineStateCurrentLabel { + color: rgb(30, 41, 59); + font-size: 18px; + font-weight: 700; + padding-left: 4px; + background: transparent; + } + + QLabel#beamlineStateTellLabel { + color: rgb(55, 67, 87); + font-size: 15px; + font-weight: 600; + padding-left: 4px; + background: transparent; + } + + QWidget#portraitRoot, + QWidget#portraitRoot QWidget { + background: #071018; + color: #F5F7FA; + font-family: 'Inter', 'SF Pro Display', Arial, sans-serif; + font-size: 14px; + } + + QWidget#portraitRoot QScrollArea { + border: none; + background: transparent; + } + + QWidget#portraitRoot QScrollBar:vertical { + background: #0E1A26; + width: 4px; + border-radius: 2px; + } + + QWidget#portraitRoot QScrollBar::handle:vertical { + background: #1A3A36; + border-radius: 2px; + min-height: 20px; + } + + QWidget#portraitRoot QScrollBar::add-line:vertical, + QWidget#portraitRoot QScrollBar::sub-line:vertical { + height: 0px; + } + """ + + +def _portrait_stylesheet() -> str: + return """ + QMainWindow, QWidget { + background: #071018; + color: #F5F7FA; + } + + QWidget#mainContentRoot, + QWidget#standardMainPage, + QWidget#compactAutomationPage, + QWidget#portraitModePage { + background: #071018; + } + + QTabWidget::pane, + QScrollArea, + QDockWidget, + QDockWidget > QWidget { + background: #071018; + color: #F5F7FA; + } + + QFrame#compactAutomationPanel { + background: #071018; + border: none; + border-radius: 18px; + } + + QFrame#compactCameraCard, + QFrame#compactControlsCard, + QFrame#compactProgressCard, + QFrame#compactQueueCard, + QFrame#compactQueueItem { + background: #0E1A26; + border: 1px solid #1A3A36; + border-radius: 16px; + } + + QLabel#compactSectionTitle { + background: transparent; + color: #62D8C8; + font-size: 14px; + font-weight: 700; + } + + QLabel#compactSectionHint { + background: transparent; + color: #8A9BB0; + font-size: 12px; + } + + QLabel#compactQueueTitle { + background: transparent; + color: #8A9BB0; + font-size: 11px; + font-weight: 700; + } + + QLabel#compactQueueValue { + background: transparent; + color: #F5F7FA; + font-size: 14px; + font-weight: 700; + } + + QToolButton#compactMenuButton { + background: #132131; + color: #62D8C8; + border: 1px solid #1A3A36; + border-radius: 14px; + padding: 10px 14px; + font-size: 18px; + font-weight: 700; + } + + QToolButton#compactMenuButton:hover { + background: #1A3A36; + } + + QPushButton#compactPrimaryButton { + background: #62D8C8; + color: #071018; + border: none; + border-radius: 14px; + padding: 14px 18px; + font-size: 15px; + font-weight: 700; + } + + QPushButton#compactPrimaryButton:hover { + background: #7ce6d8; + } + + QPushButton#compactSecondaryButton, + QToolButton#compactSecondaryButton { + background: #132131; + color: #F5F7FA; + border: 1px solid #1A3A36; + border-radius: 14px; + padding: 14px 18px; + font-size: 14px; + font-weight: 700; + } + + QPushButton#compactSecondaryButton:hover, + QToolButton#compactSecondaryButton:hover { + background: #1A3A36; + } + + QFrame#alertBanner[alertKind="error"] { + background: #1A0E0E; + border: 2px solid #8f1d2c; + border-radius: 12px; + margin: 8px 12px 8px 12px; + } + + QFrame#alertBanner[alertKind="success"] { + background: #0E1A12; + border: 2px solid #2a7a44; + border-radius: 12px; + margin: 8px 12px 8px 12px; + } + + QFrame#alertBanner[alertKind="waiting"] { + background: #2B2208; + border: 2px solid #ffb300; + border-radius: 12px; + margin: 8px 12px 8px 12px; + } + + QFrame#alertBanner QLabel { + font-weight: 700; + font-size: 20px; + padding: 2px 6px 2px 6px; + } + + QFrame#alertBanner[alertKind="error"] QLabel { + color: #ffb3bc; + } + + QFrame#alertBanner[alertKind="success"] QLabel { + color: #a8f0c0; + } + + QFrame#alertBanner[alertKind="waiting"] QLabel { + color: #ffd166; + } + + QWidget#axisVideoStatusContainer[busyState="idle"] { + border-radius: 12px; + background-color: #132131; + } + + QWidget#axisVideoStatusContainer[busyState="active"] { + border-radius: 12px; + } + + 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: #8A9BB0; + font-weight: bold; + } + + QFrame#beamlineStatePanel { + background: #0E1A26; + border: 1px solid #1A3A36; + border-radius: 12px; + } + + 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); + } + + QLabel#beamlineStateCurrentLabel { + color: #F5F7FA; + font-size: 18px; + font-weight: 700; + padding-left: 4px; + background: transparent; + } + + QLabel#beamlineStateTellLabel { + color: #8A9BB0; + font-size: 15px; + font-weight: 600; + padding-left: 4px; + background: transparent; + } + + QWidget#portraitRoot, + QWidget#portraitRoot QWidget { + background: #071018; + color: #F5F7FA; + font-family: 'Inter', 'SF Pro Display', Arial, sans-serif; + font-size: 14px; + } + + QWidget#portraitRoot QScrollArea { + border: none; + background: transparent; + } + + QWidget#portraitRoot QScrollBar:vertical { + background: #0E1A26; + width: 4px; + border-radius: 2px; + } + + QWidget#portraitRoot QScrollBar::handle:vertical { + background: #1A3A36; + border-radius: 2px; + min-height: 20px; + } + + QWidget#portraitRoot QScrollBar::add-line:vertical, + QWidget#portraitRoot QScrollBar::sub-line:vertical { + height: 0px; + } + """ \ No newline at end of file diff --git a/src/aare/gui/widgets/alert_banner.py b/src/aare/gui/widgets/alert_banner.py index 2102a2ef..dd5c8716 100644 --- a/src/aare/gui/widgets/alert_banner.py +++ b/src/aare/gui/widgets/alert_banner.py @@ -11,6 +11,9 @@ class AlertBanner(QFrame): def __init__(self, parent=None): super().__init__(parent) + self.setObjectName("alertBanner") + self.setProperty("alertKind", "error") + self._current_message = None self._current_is_error = None self._clear_timer = QTimer(self) @@ -41,6 +44,14 @@ class AlertBanner(QFrame): self.setVisible(False) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + def _set_alert_kind(self, kind: str) -> None: + self.setProperty("alertKind", kind) + self.style().unpolish(self) + self.style().polish(self) + self.style().unpolish(self._label) + self.style().polish(self._label) + self.update() + @Slot(str, bool) def show_message(self, msg: str, is_error: bool = True, auto_clear_ms: int | None = None): """Show error (red) or success (green) message.""" @@ -53,36 +64,10 @@ class AlertBanner(QFrame): if is_error: decorated = f"🛑 {msg} 🛑" - self.setStyleSheet( - "QFrame {" - " background-color: #fbe4e6;" - " border: 2px solid #d97a84;" - " border-radius: 12px;" - " margin: 8px 12px 8px 12px;" - "}" - "QLabel {" - " color: #8f1d2c;" - " font-weight: 700;" - " font-size: 20px;" - " padding: 2px 6px 2px 6px;" - "}" - ) + self._set_alert_kind("error") else: decorated = f"✅ {msg} ✅" - self.setStyleSheet( - "QFrame {" - " background-color: #e7f6ea;" - " border: 2px solid #7bbf8e;" - " border-radius: 12px;" - " margin: 8px 12px 8px 12px;" - "}" - "QLabel {" - " color: #1f6a3a;" - " font-weight: 700;" - " font-size: 20px;" - " padding: 2px 6px 2px 6px;" - "}" - ) + self._set_alert_kind("success") if auto_clear_ms is None: timeout = 5000 else: @@ -123,20 +108,7 @@ class AlertBanner(QFrame): def _apply_waiting_style(self): """Apply yellow/waiting style.""" - self.setStyleSheet( - "QFrame {" - " background-color: #fff8e1;" - " border: 2px solid #ffb300;" - " border-radius: 12px;" - " margin: 8px 12px 8px 12px;" - "}" - "QLabel {" - " color: #e65100;" - " font-weight: 700;" - " font-size: 20px;" - " padding: 2px 6px 2px 6px;" - "}" - ) + self._set_alert_kind("waiting") def _update_waiting_text(self): """Update the waiting message text, including countdown if active.""" @@ -167,5 +139,6 @@ class AlertBanner(QFrame): self._current_message = None self._current_is_error = None self._clear_timer.stop() + self._set_alert_kind("error") self._label.clear() - self.setVisible(False) + self.setVisible(False) \ No newline at end of file diff --git a/tests/unit/gui/test_main_window.py b/tests/unit/gui/test_main_window.py index 546f7251..0f6e026a 100644 --- a/tests/unit/gui/test_main_window.py +++ b/tests/unit/gui/test_main_window.py @@ -3,11 +3,13 @@ from unittest.mock import MagicMock, patch from PySide6.QtCore import Qt from aare.gui.main_window import MainWindow + @pytest.fixture def mock_ui_state(): with patch("aare.gui.main_window.UIStateManager") as mock: yield mock + def test_main_window_init(qtbot, mock_ui_state): with patch("requests.get") as mock_get, \ patch("aare.gui.main_window.DAQWorker"), \ @@ -241,4 +243,65 @@ def test_idle_timeout_does_not_close_while_automation_active(qtbot, mock_ui_stat with patch("aare.gui.main_window.time.time", return_value=111.0): win._check_idle_timeout() - win.close.assert_not_called() \ No newline at end of file + win.close.assert_not_called() + +def test_cleanup_returns_from_portrait_mode(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} + fake_token = "header.payload.signature" + + win = MainWindow( + base_url=None, + token=fake_token, + 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) + + win.enter_portrait_mode() + assert win.content_stack.currentWidget() is win.portrait_mode_page + + win.cleanup() + + assert win.content_stack.currentWidget() is win._standard_main_page + + +def test_cleanup_returns_from_compact_automation_view(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} + fake_token = "header.payload.signature" + + win = MainWindow( + base_url=None, + token=fake_token, + 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) + + win.enter_compact_automation_view() + assert win.content_stack.currentWidget() is win.compact_automation_page + + win.cleanup() + + assert win.content_stack.currentWidget() is win._standard_main_page \ No newline at end of file