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 @@
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 00000000..bbc73327
Binary files /dev/null and b/src/aare/gui/graphics/check_mark_dark.png differ
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 00000000..e9fe4c70
Binary files /dev/null and b/src/aare/gui/graphics/check_mark_light.png differ
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 00000000..ef6d413f
Binary files /dev/null and b/src/aare/gui/graphics/slider_grip_dark.png differ
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 00000000..81008622
Binary files /dev/null and b/src/aare/gui/graphics/slider_grip_light.png differ
diff --git a/src/aare/gui/graphics/spin_arrow_down_dark.png b/src/aare/gui/graphics/spin_arrow_down_dark.png
new file mode 100644
index 00000000..645f0076
Binary files /dev/null and b/src/aare/gui/graphics/spin_arrow_down_dark.png differ
diff --git a/src/aare/gui/graphics/spin_arrow_down_light.png b/src/aare/gui/graphics/spin_arrow_down_light.png
new file mode 100644
index 00000000..2335ba1d
Binary files /dev/null and b/src/aare/gui/graphics/spin_arrow_down_light.png differ
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 00000000..f9a6ca7f
Binary files /dev/null and b/src/aare/gui/graphics/spin_arrow_up_dark.png differ
diff --git a/src/aare/gui/graphics/spin_arrow_up_light.png b/src/aare/gui/graphics/spin_arrow_up_light.png
new file mode 100644
index 00000000..25533140
Binary files /dev/null and b/src/aare/gui/graphics/spin_arrow_up_light.png differ
diff --git a/src/aare/gui/gui.py b/src/aare/gui/gui.py
index 9ccbed73..02303885 100644
--- a/src/aare/gui/gui.py
+++ b/src/aare/gui/gui.py
@@ -191,18 +191,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.
+ # Start windowed (not maximized) at a fraction of the primary screen,
+ # centered — sized explicitly because the size hint 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()
+ win.show()
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 a12c13f6..c48a5627 100644
--- a/src/aare/gui/main_window.py
+++ b/src/aare/gui/main_window.py
@@ -15,20 +15,43 @@ 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 (
+ QByteArray,
+ QEvent,
+ QObject,
+ QPropertyAnimation,
+ QSettings,
+ Qt,
+ QTimer,
+ Signal,
+ Slot,
+)
+from PySide6.QtGui import (
+ QAction,
+ QActionGroup,
+ QColor,
+ QCursor,
+ QGuiApplication,
+ QKeySequence,
+ QPalette,
+)
from PySide6.QtWidgets import (
+ QAbstractButton,
QApplication,
QCheckBox,
+ QComboBox,
QDockWidget,
QFrame,
QGraphicsColorizeEffect,
+ QGraphicsOpacityEffect,
QHBoxLayout,
+ QLabel,
QMainWindow,
QMessageBox,
QPushButton,
QScrollArea,
QSizePolicy,
+ QSlider,
QStackedWidget,
QTabWidget,
QToolBar,
@@ -55,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
@@ -71,11 +94,16 @@ 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,
+ DARK_TEXT,
DOCK_CONTENT_LEFT_PAD,
- THEME_ORIGINAL,
- THEME_PORTRAIT,
+ SEPARATOR_HINT_DELAY_MS,
+ THEME_BLUEBIRD,
+ THEME_FADE_MS,
+ THEME_SUNRISE,
+ THEME_SUNSET,
build_app_stylesheet,
+ qcolor,
)
# Threads
@@ -109,6 +137,33 @@ 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 _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)
@@ -116,6 +171,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,
@@ -134,7 +191,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
@@ -183,6 +240,12 @@ 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)
+ 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
# the page — it can never nudge a value or move a motor.
@@ -213,19 +276,31 @@ class MainWindow(QMainWindow):
)
raise
- self.setStyleSheet(f"background-color: {BACKGROUND};")
+ self.setStyleSheet(f"background-color: {APP_BACKGROUND};")
- root_widget = QWidget(parent=self)
+ # 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 = _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)
@@ -362,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(
@@ -473,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)
@@ -514,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).
@@ -549,21 +632,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()
@@ -588,15 +696,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
)
@@ -726,11 +825,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,
)
@@ -750,6 +849,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)
@@ -1138,11 +1239,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()
@@ -1198,6 +1297,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)
@@ -1216,20 +1316,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
@@ -1307,7 +1420,9 @@ class MainWindow(QMainWindow):
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))
+ # Legend defaults off — it covers the camera image; users opt in and
+ # the choice persists via QSettings.
+ show_overlay_legend = bool(settings.value("samcam/show_overlay_legend", False, type=bool))
compact_overlay_legend = bool(
settings.value("samcam/compact_overlay_legend", False, type=bool)
)
@@ -1355,6 +1470,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
@@ -1450,13 +1573,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)
@@ -1519,13 +1641,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:
@@ -1574,7 +1695,7 @@ class MainWindow(QMainWindow):
try:
settings = self.portrait_sample_camera.target_overlay_settings()
self.portrait_sample_camera.set_show_overlay_legend(
- settings.get("show_overlay_legend", True)
+ settings.get("show_overlay_legend", False)
)
except Exception:
logger.debug("Could not restore the camera overlay legend", exc_info=True)
@@ -1584,13 +1705,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:
@@ -1682,11 +1802,61 @@ 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()
+ assert isinstance(app, QApplication) # palette() lives on QApplication
+ if not hasattr(self, "_default_palette"):
+ self._default_palette = app.palette()
+ if self._theme_mode == THEME_SUNSET:
+ 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)
+ # 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
+ 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, QByteArray(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")
- self._theme_mode = settings.value("appearance/theme", THEME_ORIGINAL, type=str)
+ # str() wrap: settings.value is typed object even with 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")
@@ -1694,12 +1864,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):
@@ -1712,41 +1887,54 @@ 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.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("Portrait 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)
+ view_menu.addAction(self._enter_automation_view_action)
+ view_menu.addSeparator()
show_samples_action = QAction("Show Sample List", self)
show_samples_action.setCheckable(True)
@@ -1809,12 +1997,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()
@@ -1870,15 +2060,9 @@ class MainWindow(QMainWindow):
local_contact_action.triggered.connect(self.show_local_contact)
help_menu.addAction(local_contact_action)
- help_menu.addSeparator()
-
- start_text_tutorial_action = QAction("Start Tutorial (Text)", self)
- start_text_tutorial_action.triggered.connect(self.start_text_tutorial)
- help_menu.addAction(start_text_tutorial_action)
-
- start_interactive_tutorial_action = QAction("Start Tutorial (Interactive)", self)
- start_interactive_tutorial_action.triggered.connect(self.start_interactive_tutorial)
- help_menu.addAction(start_interactive_tutorial_action)
+ # Tutorial entries hidden 2026-08-10: content is out of date. The
+ # tutorial machinery stays wired — restore the two QActions here
+ # (Start Tutorial (Text)/(Interactive)) once the content is refreshed.
def _capture_default_window_state(self) -> None:
self._default_window_state = self.saveState()
@@ -1902,7 +2086,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_()
@@ -1939,15 +2126,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:
@@ -2331,11 +2518,73 @@ 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 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
+ # 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 = self._pre_watch_dock_state
+ 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()
+
+ # 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)
@@ -2436,10 +2685,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)
@@ -2457,7 +2706,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()
@@ -2518,12 +2767,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:
@@ -2542,7 +2791,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"):
@@ -2561,7 +2810,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:
@@ -2595,7 +2844,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:
@@ -2617,14 +2866,26 @@ 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"):
+ # bool() wrap: settings.value is typed object even with type=bool.
+ self.information_dock.setVisible(bool(settings.value("information", True, type=bool)))
settings.endGroup()
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):
@@ -2640,10 +2901,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,
)
@@ -2655,6 +2916,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()
@@ -2775,7 +3040,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.
@@ -2796,6 +3071,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/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/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..db949e33 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
@@ -20,10 +20,15 @@ class AbrTweakButtons(QWidget):
self._step_mm = step_mm
grid_layout = QGridLayout(self)
- grid_layout.setColumnStretch(0, 1)
+ # Caption, arrows, and value pack tight to the left; the empty
+ # trailing column soaks up all dead width. The values keep AlignRight
+ # in their content-width column so the decimal points line up without
+ # drifting over next to the Step column.
+ grid_layout.setColumnStretch(0, 0)
grid_layout.setColumnStretch(1, 0)
grid_layout.setColumnStretch(2, 0)
- grid_layout.setColumnStretch(3, 3)
+ grid_layout.setColumnStretch(3, 0)
+ grid_layout.setColumnStretch(4, 1)
grid_layout.addWidget(QLabel("GMX"), 0, 0)
button_gmx_minus = ButtonWithPayload("←", payload={"x": -1, "y": 0, "z": 0})
@@ -148,15 +153,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/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/panels/beam_mark_panel.py b/src/aare/gui/panels/beam_mark_panel.py
index 5122ab78..3a892b3b 100644
--- a/src/aare/gui/panels/beam_mark_panel.py
+++ b/src/aare/gui/panels/beam_mark_panel.py
@@ -27,7 +27,7 @@ class BeamMarkWidget(QWidget):
# Shares the readings row instead of a full-width row below.
clear_button = QPushButton("Clear marks")
- clear_button.setFixedWidth(90)
+ clear_button.setFixedWidth(100) # 100 is needed to see everything
grid_layout.addWidget(clear_button, 1, 5)
clear_button.pressed.connect(self.clear_button_pressed)
diff --git a/src/aare/gui/panels/beamline_state_panel.py b/src/aare/gui/panels/beamline_state_panel.py
index db372118..50536ad8 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_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
@@ -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_SUNRISE)
+ 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
@@ -345,17 +356,28 @@ class BeamlineStatePanel(QFrame):
# 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.
+ # Hover underline (the app-wide tab/chip affordance) only on
+ # entries that mean something: the current state and reachable
+ # targets — not the grey dead ends.
+ hover_underline = (
+ " text-decoration: underline;" if (is_current or is_pending or is_available) else ""
+ )
qss = (
f"QPushButton {{ border: none; background: transparent; color: {color};"
f" padding: 1px 8px; }}"
- f" QPushButton:hover {{ color: {color}; }}"
+ f" QPushButton:hover {{ color: {color};{hover_underline} }}"
)
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:
+ # The current state is not a click target, but it is not
+ # forbidden either — plain cursor + a "you are here" tip.
+ if is_current or is_pending:
+ cursor = Qt.CursorShape.ArrowCursor
+ tooltip = f"{state.display_name()}: this is the current state"
+ elif is_available:
cursor = Qt.CursorShape.PointingHandCursor
tooltip = self._TOOLTIPS.get(state, state.display_name())
else:
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/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/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..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,
@@ -25,9 +24,9 @@ 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
class RuntimeNotificationWidget(QFrame):
@@ -60,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()
@@ -125,9 +124,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; }"
)
@@ -190,41 +192,30 @@ class RuntimeNotificationWidget(QFrame):
self.cleared.emit()
-class LogDock(QDockWidget):
- def __init__(self, title="Log", parent=None):
- super().__init__(title, parent)
- 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)
@@ -239,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(
@@ -273,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:
@@ -289,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/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/reference_tools_panel.py b/src/aare/gui/panels/reference_tools_panel.py
index 09c409fc..8710db23 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)
@@ -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,13 +73,25 @@ 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:
+ # 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
@@ -99,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
@@ -111,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")
@@ -131,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),
)
@@ -152,7 +171,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 +210,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)
@@ -208,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.
diff --git a/src/aare/gui/panels/samcam_panel.py b/src/aare/gui/panels/samcam_panel.py
index 55e520a3..f4498d85 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,13 +52,14 @@ 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)
+ # 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).
@@ -71,9 +70,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 +77,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)
@@ -111,7 +106,7 @@ class SamcamPanel(QWidget):
)
self.show_overlay_legend_checkbox = QCheckBox("Show overlay legend")
- self.show_overlay_legend_checkbox.setChecked(True)
+ self.show_overlay_legend_checkbox.setChecked(False)
self.show_overlay_legend_checkbox.toggled.connect(self.show_overlay_legend_changed.emit)
self.compact_overlay_legend_checkbox = QCheckBox("Compact legend")
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/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..9394a5c7 100644
--- a/src/aare/gui/styles.py
+++ b/src/aare/gui/styles.py
@@ -1,9 +1,16 @@
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
@@ -15,12 +22,67 @@ THEME_PORTRAIT = "portrait"
# -- Light theme ------------------------------------------------------------
BACKGROUND = "#e2e7ee"
+# 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 = "#adccf1"
+BACKGROUND_GRADIENT_MID_POS = "0.55" # 0..1 — where the mid stop sits
+BACKGROUND_GRADIENT_BOTTOM = "#f8e9c5"
+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).
+# 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
+# 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()
+# Slider-handle grip lines (3 vertical ticks) — QSS can't draw interior
+# lines, so they are tiny PNG assets like the spin arrows above.
+SLIDER_GRIP = (_GRAPHICS_DIR / "slider_grip_light.png").as_posix()
+DARK_SLIDER_GRIP = (_GRAPHICS_DIR / "slider_grip_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 +108,21 @@ 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
+# lives in MainWindow.event(); the QSS :hover part picks the one separator.
+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).
+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"
@@ -71,15 +148,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"
@@ -88,140 +167,260 @@ 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 = "#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},"
+ 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
+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.
+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
-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)
+# 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 = "#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 ---------------------------------------------------------
-INPUT_BG = "#ffffff"
-INPUT_INVALID_BG = "#ffd5d5"
-INPUT_DISABLED_BG = "#f0f0f0"
-INPUT_DISABLED_INVALID_BG = "#f0e1e1"
+# 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 = "#e9c4cf" # red 20% over latte base
+INPUT_DISABLED_BG = "#e6e9ef" # latte mantle
+INPUT_DISABLED_INVALID_BG = "#ecdae2" # faint red wash
+
+# -- 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)
+# 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,
+ }
-# -- 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 ---------------------------------------------------
+# 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_SUNSET:
+ 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"
@@ -237,89 +436,96 @@ 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.
+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"
@@ -344,18 +550,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 = "#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.
+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
@@ -406,22 +620,173 @@ 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("""
- QMainWindow, QWidget {
- background-color: $background;
+ /* 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 {
+ 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.
+ 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
+ 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;
+ 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 {
+ 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 +803,7 @@ def _original_stylesheet() -> str:
}
QFrame#compactAutomationPanel {
- background: $background;
+ background: transparent;
border: none;
border-radius: 18px;
}
@@ -563,28 +928,35 @@ def _original_stylesheet() -> str:
color: $warning_text;
}
- QWidget#axisVideoStatusContainer[busyState="idle"] {
- border-radius: $card_radius;
- background-color: $status_idle_bg;
+ /* 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;
}
- QWidget#axisVideoStatusContainer[busyState="active"] {
- border-radius: $card_radius;
+ QCheckBox::indicator:checked, QRadioButton::indicator:checked {
+ image: url($check_mark);
}
- QLabel#axisVideoStatusDot {
- min-width: 10px;
- max-width: 10px;
- min-height: 10px;
- max-height: 10px;
- border-radius: 5px;
- background-color: transparent;
+ /* 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;
}
- QLabel#axisVideoStatusLabel {
- background-color: transparent;
- color: $status_label_text;
- font-weight: bold;
+ QRadioButton::indicator:checked {
+ image: none;
+ background: $primary;
+ }
+
+ QCheckBox::indicator:disabled, QRadioButton::indicator:disabled {
+ background: $disabled_input_bg;
}
/* Disabled = baton-gated ("watch only"): the explicit colors above mask
@@ -596,11 +968,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 +1012,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 +1074,7 @@ def _original_stylesheet() -> str:
}
QFrame#beamlineStatePanel {
- background: $background;
+ background: transparent;
border-top: 1px solid $border;
}
@@ -661,6 +1083,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 +1135,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;
@@ -731,12 +1168,13 @@ def _original_stylesheet() -> str:
selection-color: $selection_text;
}
- /* Sliders: rounded groove ends (like the scrollbar handles) + round
- handle. Wheel adjustment needs the right mouse button held — see
- WheelValueGuard. */
+ /* Sliders: rounded groove ends (like the scrollbar handles) + square
+ handle with grip lines (SLIDER_GRIP asset) — the old 16px round handle
+ was clipped flat by the slider's widget height. Wheel adjustment needs
+ the right mouse button held — see WheelValueGuard. */
QSlider::groove:horizontal {
height: 6px;
- background: $scrollbar_track;
+ background: $slider_track;
border-radius: 3px;
}
@@ -747,10 +1185,10 @@ 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;
+ margin: -3px 0;
+ image: url($slider_grip);
}
/* Soft scrollbars: square track band (runs flush to the widget edges —
@@ -834,28 +1272,220 @@ 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("""
- 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. 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. */
+ 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);
+ }
+
+ /* 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;
+ }
+
+ /* 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 +1523,7 @@ def _portrait_stylesheet() -> str:
}
QFrame#compactAutomationPanel {
- background: $dark_bg;
+ background: transparent;
border: none;
border-radius: 18px;
}
@@ -917,13 +1547,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 +1580,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 +1590,7 @@ def _portrait_stylesheet() -> str:
}
QPushButton#compactPrimaryButton:hover {
- background: $dark_accent_hover;
+ background: $dark_accent_fill_hover;
}
QPushButton#compactSecondaryButton,
@@ -1018,40 +1648,18 @@ def _portrait_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_muted;
- 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 +1680,70 @@ def _portrait_stylesheet() -> str:
}
QLabel#beamlineStateTellLabel {
- color: $dark_muted;
+ /* 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;
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;
}
+ /* 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: $frame_l3_width solid $frame_l3_color;
+ border: none;
+ }
+ QPlainTextEdit {
+ background: $dark_table_bg; /* solid — transparent renders black on the container's X11 */
}
- /* 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 {
@@ -1134,8 +1777,8 @@ def _portrait_stylesheet() -> str:
background: $dark_elevated;
border: 1px solid $dark_muted;
width: 14px;
- margin: -5px 0;
- border-radius: 7px;
+ margin: -3px 0;
+ image: url($dark_slider_grip);
}
/* Soft scrollbars: square track band + rounded handle, no end arrows
@@ -1222,6 +1865,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/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
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."""
diff --git a/src/aare/gui/widgets/busy_overlay.py b/src/aare/gui/widgets/busy_overlay.py
index d344a2d1..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,6 +40,66 @@ class BusyOverlayStyle:
overlay_border: QColor
overlay_text: QColor
accent_dot: str
+ # 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(
@@ -49,13 +110,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..27ee8a54 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,12 +56,18 @@ from aare.gui.styles import (
MARKER_GREEN,
PATH_END,
PATH_START,
+ SHADOW,
TARGET_COLORS,
+ THEME_SUNSET,
TOOLTIP_TEXT,
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)
@@ -108,9 +115,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
@@ -129,12 +141,12 @@ class SampleCameraImageLabel(QGraphicsView):
self._show_target_point = True
self._show_target_coordinates = True
- self._show_overlay_legend = True
+ self._show_overlay_legend = False
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 +231,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 +298,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,38 +321,55 @@ 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)
- padding_x = 20
- padding_y = 14
- bg_width = text_rect.width() + 2 * padding_x
- bg_height = text_rect.height() + 2 * padding_y
+ # 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
- viewport_width = self.viewport().width()
- viewport_height = self.viewport().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,
+ )
- position_x = int((viewport_width - bg_width) / 2)
- position_y = int(viewport_height * 0.68 - bg_height / 2)
+ 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)
- bg_rect = QRect(position_x, position_y, bg_width, bg_height)
-
- painter.setPen(QPen(style.overlay_border, 2, Qt.PenStyle.SolidLine))
- painter.setBrush(style.overlay_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)
+ 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()
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 +411,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 +435,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 +464,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 +537,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 +967,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 +1038,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 +1071,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 +1083,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 +1156,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 +1167,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)
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/popout_window.py b/src/aare/gui/widgets/popout_window.py
index ace02255..980c228e 100644
--- a/src/aare/gui/widgets/popout_window.py
+++ b/src/aare/gui/widgets/popout_window.py
@@ -1,23 +1,31 @@
-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.
+# Title-bar buttons: icon fills the button, both the same size. These are
+# minimums — the real size follows the font metrics (see _titlebar_button),
+# so the glyphs keep up with the DPI/font settings of the machine (fixed px
+# rendered tiny on the RHEL9 consoles).
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."""
- pixmap = QPixmap(size, size)
+ container's fonts. Color comes from the caller's palette so the glyphs
+ follow the theme (they are pixmaps, QSS color cannot reach them)."""
+ # Paint at the physical resolution so the glyph stays crisp on HiDPI.
+ screen = QGuiApplication.primaryScreen()
+ dpr = screen.devicePixelRatio() if screen is not None else 1.0
+ pixmap = QPixmap(round(size * dpr), round(size * dpr))
+ pixmap.setDevicePixelRatio(dpr)
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,14 +41,24 @@ 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)
+ # Size from the font, not fixed px: a setup with larger fonts/DPI gets
+ # proportionally larger glyphs. The constants act as the floor.
+ icon_px = max(TITLEBAR_ICON_PX, parent.fontMetrics().height())
+ button_px = icon_px + (TITLEBAR_BUTTON_PX - TITLEBAR_ICON_PX)
+ button.setIconSize(QSize(icon_px, icon_px))
+ button.setFixedSize(button_px, button_px)
button.setAutoRaise(True)
- # No button chrome — the glyph IS the button.
- button.setStyleSheet("QToolButton { border: none; background: transparent; }")
+ # No button chrome — the glyph IS the button. The padding/height zeroing
+ # is the opt-out from the app sheets' global QToolButton cap (16px +
+ # padding): without it the light theme's horizontal padding shrinks the
+ # content box and Qt scales the glyph down to a speck.
+ button.setStyleSheet(
+ "QToolButton { border: none; background: transparent;"
+ f" padding: 0px; min-height: 0px; max-height: {button_px}px; }}"
+ )
button.setToolTip(tooltip)
button.setCursor(Qt.CursorShape.PointingHandCursor)
return button
@@ -66,15 +84,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, button.iconSize().width()))
+
+ 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 +131,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,6 +213,8 @@ class PopoutWindow(QWidget):
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
+ # 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)
@@ -201,3 +240,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")
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/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:
diff --git a/src/aare/gui/widgets/title_label.py b/src/aare/gui/widgets/title_label.py
index 13fc734b..6c5d5c28 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_VALUE, 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.
@@ -79,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)
@@ -125,7 +113,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):
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/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")
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
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_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"
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