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