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