diff --git a/src/aare/gui/graphics/aare_banner.png b/src/aare/gui/graphics/aare_banner.png deleted file mode 100644 index 831224d2..00000000 Binary files a/src/aare/gui/graphics/aare_banner.png and /dev/null differ diff --git a/src/aare/gui/graphics/aare_banner.svg b/src/aare/gui/graphics/aare_banner.svg new file mode 100644 index 00000000..eccb1b1a --- /dev/null +++ b/src/aare/gui/graphics/aare_banner.svg @@ -0,0 +1,25 @@ + + + + + + + + AARE + + + + Advanced softwARE for + + macromolecular + + crystallography at + + Swiss Light Source 2.0 + + + \ No newline at end of file diff --git a/src/aare/gui/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 e5387451..02303885 100644 --- a/src/aare/gui/gui.py +++ b/src/aare/gui/gui.py @@ -21,7 +21,9 @@ def main(): try: basedir = os.path.dirname(__file__) icon_path = os.path.join(basedir, "graphics/aaregui_logo.svg") - banner_path = os.path.join(basedir, "graphics/aare_banner.png") + # The banner is an SVG now; the old aare_banner.png no longer exists + # and yielded a null splash pixmap. + banner_path = os.path.join(basedir, "graphics/aare_banner.svg") except Exception: logger.exception("Failed to load resources for splash screen") sys.exit(1) @@ -189,6 +191,14 @@ def main(): splash.set_progress(100, "Ready") splash.finish(win) + # 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()) win.show() sys.exit(app.exec()) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 061f11df..c48a5627 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -12,17 +12,49 @@ from aarecommon.models.models import ( BeamlineStateEnum, DAQStatusModel, SampleShortInfoList, + SessionsStateEnum, TokenData, ) -from PySide6.QtCore import QEvent, QSettings, Qt, QTimer, Signal, Slot -from PySide6.QtGui import QAction, QActionGroup, QGuiApplication, QKeySequence +from PySide6.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, QVBoxLayout, QWidget, ) @@ -32,9 +64,10 @@ from aare.gui.constants import LOGGER_NAME # Gui Models from aare.gui.models.gui_state_manager import UIStateManager +from aare.gui.panels.abr_tweak_panel import AbrTweakWidget from aare.gui.panels.automation_panel import AutomationProgressWidget from aare.gui.panels.axis_video_panel import AxisVideoPanel -from aare.gui.panels.beamline_controls import BeamlineControls +from aare.gui.panels.beamline_controls import BeamConfigPanel, BeamlineControls from aare.gui.panels.beamline_recovery_panel import BeamlineRecoveryDialog from aare.gui.panels.beamline_state_panel import BeamlineStatePanel from aare.gui.panels.compact_automation_panel import CompactAutomationPanel @@ -45,12 +78,12 @@ 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.loop_centering_panel import LoopCenteringPanel -from aare.gui.panels.manual_sample_panel import ManualSamplePanel +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 from aare.gui.panels.reference_tools_panel import ReferenceToolsPanel +from aare.gui.panels.samcam_panel import SamcamPanel from aare.gui.panels.sample_queue_panel import SampleQueuePanel from aare.gui.panels.smargon_trace_panel import SmargonTracePanel from aare.gui.panels.target_stability_panel import TargetStabilityPanel @@ -60,7 +93,18 @@ from aare.gui.panels.tell_sample_panel import TellSamplePanel from aare.gui.scan_logic.raster_grid_manager import RasterGridManager from aare.gui.scan_logic.rotation_scan_manager import RotationScanManager from aare.gui.scan_logic.sample_mount_logic import SampleMountLogic -from aare.gui.styles import THEME_ORIGINAL, THEME_PORTRAIT, build_app_stylesheet +from aare.gui.styles import ( + APP_BACKGROUND, + DARK_TEXT, + DOCK_CONTENT_LEFT_PAD, + SEPARATOR_HINT_DELAY_MS, + THEME_BLUEBIRD, + THEME_FADE_MS, + THEME_SUNRISE, + THEME_SUNSET, + build_app_stylesheet, + qcolor, +) # Threads from aare.gui.threads.axis_video_thread import VideoThread @@ -84,15 +128,52 @@ from aare.gui.widgets.busy_overlay import build_busy_overlay_style from aare.gui.widgets.camera_image import SampleCameraImageLabel from aare.gui.widgets.message_box import precondition_check from aare.gui.widgets.no_wheel_scroll_area import NoWheelScrollArea +from aare.gui.widgets.popout_window import DockTitleBar, PopoutWindow from aare.gui.widgets.status_bar import StatusBar +from aare.gui.widgets.title_label import TitleLabel, tighten_column from aare.gui.widgets.video_image import VideoGraphicsView +from aare.gui.widgets.wheel_value_guard import WheelValueGuard logger = setup_logger(LOGGER_NAME) +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) + # Set lazily outside __init__ (first use guards with getattr/default); + # declared for the basedpyright gate. + _session_operations_enabled: bool | None = None + _default_dock_split_done: bool = False + _pre_watch_dock_state: QByteArray | None = None + _pre_watch_visibility: list[tuple[QWidget, bool]] | None = None + def __init__( self, base_url: str | None, @@ -106,7 +187,11 @@ class MainWindow(QMainWindow): ): super().__init__() - self._theme_mode = THEME_ORIGINAL + # Banners open before a baton-vacancy fold; in __init__ (not a class + # default) so the mutable list is per-instance (RUF012). + self._pre_vacancy_open_banners: list[TitleLabel] = [] + + self._theme_mode = THEME_SUNRISE self._theme_action_group = None self._use_legacy_theme_action = None self._use_portrait_theme_action = None @@ -125,7 +210,6 @@ class MainWindow(QMainWindow): self._cleanup_done = False self._default_window_state = None self._pre_automation_window_state = None - self._pre_automation_ref_tools_visible = False self._pre_automation_left_column_visible = True self._pre_automation_right_column_visible = True self._in_compact_automation_view = False @@ -139,8 +223,6 @@ class MainWindow(QMainWindow): self.beamline_camera_thread = None self.gonio_camera_thread = None - self._show_beamline_state_panel_for_users = True - self._waiting_for_baton_response: bool = False self._baton_request_dialog: BatonRequestDialog | None = None self._baton_pending_dialog: BatonPendingDialog | None = None @@ -157,6 +239,20 @@ class MainWindow(QMainWindow): self._tutorial_event_bus = TutorialEventBus(self) 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. + self._wheel_value_guard = WheelValueGuard(self) + app_instance = QApplication.instance() + if app_instance is not None: + app_instance.installEventFilter(self._wheel_value_guard) self.viewer = JFJochDBusClient() try: @@ -180,19 +276,31 @@ class MainWindow(QMainWindow): ) raise - self.setStyleSheet("background-color: rgb(216, 228, 253);") + 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) @@ -232,25 +340,81 @@ class MainWindow(QMainWindow): self.left_column = QWidget(parent=top_widget) self.left_column_layout = QVBoxLayout(self.left_column) self.left_column_layout.setContentsMargins(0, 0, 0, 0) - self.left_column_layout.setSpacing(8) self.data_collection = DataCollectionSettings( s=geom, parent=self.left_column, raster_mgr=self.raster, diffraction=diffraction ) - self.loop_centering = LoopCenteringPanel(parent=self.left_column) + # The beamline state strip lives in a bottom toolbar row (created + # after the docks), not in the left column. Always visible. + self.beamline_state_panel = BeamlineStatePanel(parent=self) - self.beamline_state_panel = BeamlineStatePanel(parent=self.left_column) - self._beamline_state_panel_enabled = bool( - self._decoded_token.staff or self._show_beamline_state_panel_for_users + # Beamline / Experiment as tabs (like the Dewar samples dock) instead + # of two stacked banner groups; the pages keep their banner children. + # documentMode: no pane frame, so the fixed-width panels aren't inset. + self.left_column_tabs = QTabWidget(self.left_column) + self.left_column_tabs.setDocumentMode(True) + # documentMode draws a grey base line across the bar's full width. + self.left_column_tabs.tabBar().setDrawBase(False) + + beamline_page = QWidget() + beamline_layout = QVBoxLayout(beamline_page) + beamline_layout.setContentsMargins(0, 0, 0, 0) + self.samcam = SamcamPanel(beamline_page) + if self._decoded_token.staff: + self.monochromator_panel = MonochromatorPanel(beamline_page) + self.abr_tweak = AbrTweakWidget(beamline_page) + self.beam_config = BeamConfigPanel(beamline_page) + self.beam_mark = self.beam_config.beam_mark + self.beam_center = self.beam_config.beam_center + self.beam_size = self.beam_config.beam_size + beamline_layout.addWidget(self.monochromator_panel) + beamline_layout.addWidget(self.abr_tweak) + beamline_layout.addWidget(self.beam_config) + # Samcam last: the beam panels are the ones tweaked most. + beamline_layout.addWidget(self.samcam) + beamline_layout.addStretch() + tighten_column(beamline_layout) + + experiment_page = QWidget() + experiment_layout = QVBoxLayout(experiment_page) + experiment_layout.setContentsMargins(0, 0, 0, 0) + experiment_layout.addWidget(self.data_collection) + experiment_layout.addStretch() + tighten_column(experiment_layout) + + self.left_column_tabs.addTab(beamline_page, "Beamline") + self.left_column_tabs.addTab(experiment_page, "Experiment") + + # Only the visible page counts toward the height — same trick as the + # content stack below, else the taller page pads the other tab. + def _only_current_left_tab_counts(index: int) -> None: + for i in range(self.left_column_tabs.count()): + page = self.left_column_tabs.widget(i) + vertical = ( + QSizePolicy.Policy.Preferred if i == index else QSizePolicy.Policy.Ignored + ) + page.setSizePolicy(QSizePolicy.Policy.Preferred, vertical) + + self.left_column_tabs.currentChanged.connect(_only_current_left_tab_counts) + _only_current_left_tab_counts(self.left_column_tabs.currentIndex()) + + # Dewar-tabs look: first banner flush under the tab bar (no top + # margin) and the tab row starting at the banners' left edge. The + # Experiment side needs two levels: the frame AND its first panel. + beamline_first = self.monochromator_panel if self._decoded_token.staff else self.samcam + for first in (beamline_first, self.data_collection, self.data_collection.file_path_panel): + first_layout = first.layout() + assert first_layout is not None # panels build their layouts in __init__ + m = first_layout.contentsMargins() + first_layout.setContentsMargins(m.left(), 0, m.right(), m.bottom()) + samcam_layout = self.samcam.layout() + assert samcam_layout is not None + self.left_column_tabs.setStyleSheet( + f"QTabWidget::tab-bar {{ left: {samcam_layout.contentsMargins().left()}px; }}" ) - self.left_column_layout.addWidget(self.data_collection) - self.left_column_layout.addWidget(self.loop_centering) - if self._beamline_state_panel_enabled: - self.left_column_layout.addWidget(self.beamline_state_panel) - else: - self.beamline_state_panel.hide() + self.left_column_layout.addWidget(self.left_column_tabs) self.left_column_layout.addStretch() top_widget_layout.addWidget(self.collection_controls_scroll) @@ -259,20 +423,23 @@ class MainWindow(QMainWindow): Qt.ScrollBarPolicy.ScrollBarAlwaysOff ) self.collection_controls_scroll.setWidgetResizable(True) - self.collection_controls_scroll.setFixedWidth( - max( - self.data_collection.set_width, - self.loop_centering.sizeHint().width(), - self.beamline_state_panel.set_width, - ) - + 10 - ) + # No frame: its border drew a line above the tab bar (Dewar tabs have + # none). Freeze the inner column width: widgetResizable makes it track + # the viewport, so the scrollbar appearing used to re-flow every + # banner. Fixed width + a permanent 10px scrollbar gutter means the + # scrollbar pops into spare space and nothing moves. + self.collection_controls_scroll.setFrameShape(QFrame.Shape.NoFrame) + self.left_column.setFixedWidth(self.data_collection.set_width) + self.collection_controls_scroll.setFixedWidth(self.data_collection.set_width + 10) self.video_tab = QTabWidget(parent=top_widget) 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( @@ -348,15 +515,18 @@ class MainWindow(QMainWindow): self.beamline_controls_scroll = NoWheelScrollArea(top_widget) - self.beamline = BeamlineControls( - self.beamline_controls_scroll, staff=self._decoded_token.staff - ) + self.beamline = BeamlineControls(self.beamline_controls_scroll) top_widget_layout.addWidget(self.beamline_controls_scroll) self.beamline_controls_scroll.setWidget(self.beamline) + # Resizable so the column shrinks when panels collapse; without it the + # scrollbar keeps dead range below the collapsed panels. + self.beamline_controls_scroll.setWidgetResizable(True) self.beamline_controls_scroll.setHorizontalScrollBarPolicy( Qt.ScrollBarPolicy.ScrollBarAlwaysOff ) - self.beamline_controls_scroll.setFixedWidth(self.beamline.set_width + 10) + # Same gutter math as the left column: 10px scrollbar + 2px frame, so + # the fixed-width controls are never clipped when the scrollbar shows. + self.beamline_controls_scroll.setFixedWidth(self.beamline.set_width + 12) self.tell_samples = TellSamplePanel(samples=SampleShortInfoList(s=[])) self.ref_tools_panel = ReferenceToolsPanel(samples=SampleShortInfoList(s=[])) @@ -372,45 +542,137 @@ class MainWindow(QMainWindow): ) self.compact_automation_panel.annotation_selected.connect(self._handle_compact_annotation) + # One dock for both lists: the old tabified Reference Tools dock was + # staff-only and hid behind the Sample List tab, so it "sometimes" + # showed. aaregui2 concept: one panel, Dewar + Auxiliary-puck tabs + # that always travel together, and the dewar table doubles as the + # queue view (status tints), so the automation controls sit under it + # and the Automation list dock is gone. SampleQueuePanel stays alive, + # hidden, as the queue engine; its buttons are reparented here so all + # their existing wiring keeps working. + dewar_tab = QWidget() + dewar_tab.setObjectName("dewarTab") + dewar_layout = QVBoxLayout(dewar_tab) + dewar_layout.setContentsMargins(0, 0, 0, 0) + dewar_layout.setSpacing(2) + dewar_layout.addWidget(self.tell_samples) + + self.quick_unmount_button = QPushButton("⏏ Unmount", dewar_tab) + self.quick_unmount_button.clicked.connect(lambda: self._on_manual_unmount_requested()) + + automation_row = QHBoxLayout() + for w in ( + self.job_list_panel.play_button, + self.job_list_panel.remove_button, + self.job_list_panel.clear_button, + self.quick_unmount_button, + self.job_list_panel.park_and_dry_when_cleared, + self.job_list_panel.pause_on_conditions_cb, + ): + automation_row.addWidget(w) + dewar_layout.addLayout(automation_row) + + # "Remove selected" now unqueues the dewar-table selection — the + # queue's own table is no longer displayed. + self.job_list_panel.remove_button.clicked.disconnect( + self.job_list_panel.remove_selected_samples + ) + self.job_list_panel.remove_button.clicked.connect(self._remove_selected_from_queue) + self.job_list_panel.hide() + + self.sample_lists_tabs = QTabWidget() + self.sample_lists_tabs.addTab(dewar_tab, "Dewar samples") + self.sample_lists_tabs.addTab(self.ref_tools_panel, "Auxiliary puck") + # Non-staff never get reference-tools data (the DAQ connect below is + # staff-gated). Grey the tab out instead of hiding it: every role sees + # the same view, and clicking the locked tab says why it is locked. + if not self._decoded_token.staff: + self.sample_lists_tabs.setTabEnabled(1, False) + self.sample_lists_tabs.setTabToolTip(1, "Staff only") + # Disabled tabs are skipped by tabBarClicked's hit-test, so the + # click is caught in eventFilter via the geometric tabAt() instead. + self.sample_lists_tabs.tabBar().installEventFilter(self) + + # 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). + sample_lists_wrap = QWidget() + sample_lists_wrap_layout = QVBoxLayout(sample_lists_wrap) + sample_lists_wrap_layout.setContentsMargins(DOCK_CONTENT_LEFT_PAD, 0, 0, 0) + sample_lists_wrap_layout.setSpacing(0) + sample_lists_wrap_layout.addWidget(self.sample_lists_tabs) + self.tell_samples_dock = QDockWidget("Sample List", self) self.tell_samples_dock.setObjectName("tell_samples_dock") - self.tell_samples_dock.setWidget(self.tell_samples) + self.tell_samples_dock.setWidget(sample_lists_wrap) self.tell_samples_dock.setAllowedAreas(Qt.DockWidgetArea.BottomDockWidgetArea) self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.tell_samples_dock) - self.ref_tools_dock = QDockWidget("Reference Tools", self) - self.ref_tools_dock.setObjectName("ref_tools_dock") - self.ref_tools_dock.setWidget(self.ref_tools_panel) - self.ref_tools_dock.setAllowedAreas(Qt.DockWidgetArea.BottomDockWidgetArea) - self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.ref_tools_dock) - self.tabifyDockWidget(self.ref_tools_dock, self.tell_samples_dock) - if self._decoded_token.staff: - self.ref_tools_dock.show() - else: - self.ref_tools_dock.hide() + # No floating: popping the dock out ripped the panel from the row and + # reshuffled the rest. The ⤢ button (in the title bar, next to ✕) + # opens an ADDITIONAL fully-wired window; closing it changes nothing. + self.tell_samples_dock.setFeatures( + QDockWidget.DockWidgetFeature.DockWidgetMovable + | QDockWidget.DockWidgetFeature.DockWidgetClosable + ) + self._sample_popout: PopoutWindow | None = None + self.tell_samples_dock.setTitleBarWidget( + DockTitleBar(self.tell_samples_dock, self._open_sample_popout) + ) self.sample_logic = SampleMountLogic() - self.job_list_dock = QDockWidget("Automation list", self) - self.job_list_dock.setObjectName("job_list_dock") - self.job_list_dock.setWidget(self.job_list_panel) - self.job_list_dock.setAllowedAreas(Qt.DockWidgetArea.BottomDockWidgetArea) - self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.job_list_dock) - - self.manual_sample_panel = ManualSamplePanel() - - self.manual_sample_dock = QDockWidget("Manual sample", self) - self.manual_sample_dock.setObjectName("manual_sample_dock") - self.manual_sample_dock.setWidget(self.manual_sample_panel) - self.manual_sample_dock.setAllowedAreas(Qt.DockWidgetArea.BottomDockWidgetArea) - self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.manual_sample_dock) + # Manual sample lives in the left column (DataCollectionSettings) + # between Dataset path and Exp. Config., collapsible like its + # neighbors — it is no longer a bottom dock. + self.manual_sample_panel = self.data_collection.manual_sample_panel self.automation_progress_panel = AutomationProgressWidget() - self.automation_progress_dock = QDockWidget("Automation progress", self) - self.automation_progress_dock.setObjectName("automation_progress_dock") - self.automation_progress_dock.setWidget(self.automation_progress_panel) - self.automation_progress_dock.setAllowedAreas(Qt.DockWidgetArea.BottomDockWidgetArea) - self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.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() + automation_scroll.setWidget(self.automation_progress_panel) + automation_scroll.setWidgetResizable(True) + automation_scroll.setFrameShape(QFrame.Shape.NoFrame) + + # 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() self.face_panel_dock = QDockWidget("Face detection", self) @@ -434,18 +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.tabifyDockWidget(self.manual_sample_dock, self.automation_progress_dock) - self.tabifyDockWidget(self.automation_progress_dock, self.log_dock) - self.job_list_panel.samples_in_queue_changed.connect( self.automation_progress_panel.set_samples_in_queue ) @@ -520,11 +770,47 @@ class MainWindow(QMainWindow): self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.prediction_metrics_dock) self.prediction_metrics_dock.hide() - self.content_stack.addWidget(top_widget) + # Full-width state strip directly above the status bar: the bottom + # toolbar area is guaranteed to sit below the bottom dock area (a + # bottom dock would land beside the existing docks instead). + self.beamline_state_toolbar = QToolBar("Beamline state", self) + self.beamline_state_toolbar.setObjectName("beamline_state_toolbar") + self.beamline_state_toolbar.setMovable(False) + self.beamline_state_toolbar.setFloatable(False) + self.beamline_state_toolbar.setAllowedAreas(Qt.ToolBarArea.BottomToolBarArea) + self.beamline_state_panel.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred + ) + self.beamline_state_toolbar.addWidget(self.beamline_state_panel) + self.addToolBar(Qt.ToolBarArea.BottomToolBarArea, self.beamline_state_toolbar) + + # The standard page's combined panel minimum (~1400x1200) exceeds many + # monitors, which pushed the status bar off-screen. Scrolling instead of + # clamping lets the window shrink to any screen; scrollbars only appear + # when the monitor is actually smaller than the panels. + standard_page_scroll = QScrollArea(parent=root_widget) + standard_page_scroll.setWidgetResizable(True) + standard_page_scroll.setFrameShape(QFrame.Shape.NoFrame) + standard_page_scroll.setWidget(top_widget) + + self.content_stack.addWidget(standard_page_scroll) self.content_stack.addWidget(self.compact_automation_page) self.content_stack.addWidget(self.portrait_mode_page) - self.content_stack.setCurrentWidget(top_widget) - self._standard_main_page = top_widget + self.content_stack.setCurrentWidget(standard_page_scroll) + self._standard_main_page = standard_page_scroll + + # QStackedWidget's minimum size is the max over ALL pages, so the hidden + # portrait/compact pages inflated the window minimum past small monitors + # (portrait alone demands 720px height). Only the visible page should + # count — the mode-switch code resizes the window explicitly anyway. + def _only_current_page_counts(index: int) -> None: + for i in range(self.content_stack.count()): + page = self.content_stack.widget(i) + policy = QSizePolicy.Policy.Preferred if i == index else QSizePolicy.Policy.Ignored + page.setSizePolicy(policy, policy) + + self.content_stack.currentChanged.connect(_only_current_page_counts) + _only_current_page_counts(self.content_stack.currentIndex()) self.setCentralWidget(root_widget) @@ -534,6 +820,19 @@ class MainWindow(QMainWindow): self.create_menu_bar() self._update_view_mode_actions() self._setup_global_shortcuts() + # Default bottom-dock height: the sample list used to grab ~40% of the + # window and squeeze the Beamline column behind a scrollbar. Before + # the default-state capture so "reset layout" gets it too; a saved + # user layout (restored below) still wins. + self.resizeDocks( + [self.tell_samples_dock, self.information_dock], [240, 240], Qt.Orientation.Vertical + ) + # Equal oversized requests -> Qt distributes proportionally = 50/50. + self.resizeDocks( + [self.tell_samples_dock, self.information_dock], + [10000, 10000], + Qt.Orientation.Horizontal, + ) self._capture_default_window_state() self._restore_window_state() @@ -550,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) @@ -597,15 +898,18 @@ class MainWindow(QMainWindow): if self._decoded_token.staff: self.daq.reference_tools.connect(self.ref_tools_panel.new_list) - self.beamline.samcam.changed.connect(self.daq.samcam_settings) - self.beamline.samcam.screenshot_requested.connect(self.daq.send_screenshot_db) - self.beamline.samcam.save_beam_location_setting.connect( - self.daq.save_beam_location_camera_setting - ) - self.loop_centering.find_tip.clicked.connect(self.daq.center_loop) - self.loop_centering.bounding_box.clicked.connect(self.daq.ml_bounding_box) + self.samcam.changed.connect(self.daq.samcam_settings) + self.samcam.screenshot_requested.connect(self.daq.send_screenshot_db) + self.samcam.save_beam_location_setting.connect(self.daq.save_beam_location_camera_setting) + self.data_collection.find_tip.clicked.connect(self.daq.center_loop) + self.data_collection.bounding_box.clicked.connect(self.daq.ml_bounding_box) self.daq.raster_generated_by_ml.connect(self.raster.update_active_grid_request) + # Clicking the big session badge (SESSION VACANT / Guest Mode) opens + # the grab/request menu right at the cursor. + self.sample_camera.session_badge_clicked.connect( + lambda: self.status_bar.show_session_menu(QCursor.pos()) + ) self.sample_camera.smargon.connect(self.daq.move_smargon) self.beamline.smargon_panel.smargon.connect(self.daq.move_smargon) self.sample_camera.samcam_updated.connect(self.daq.samcam_settings) @@ -621,36 +925,32 @@ class MainWindow(QMainWindow): self.beamline.illumination_panel.back_light.connect(self.daq.back_light) if self._decoded_token.staff: - self.beamline.monochromator_panel.mono_pitch_scan.connect(self.daq.mono_pitch_scan) - self.beamline.monochromator_panel.change_energy.connect(self.daq.change_energy) - self.beamline.abr_tweak.abr_tweak.connect(self.daq.abr_tweak) - self.beamline.abr_tweak.abr_save.connect(self.daq.abr_save) - self.beamline.abr_tweak.abr_goto_meas.connect(self.daq.abr_goto_meas) + self.monochromator_panel.mono_pitch_scan.connect(self.daq.mono_pitch_scan) + self.monochromator_panel.change_energy.connect(self.daq.change_energy) + self.abr_tweak.abr_tweak.connect(self.daq.abr_tweak) + self.abr_tweak.abr_save.connect(self.daq.abr_save) + self.abr_tweak.abr_goto_meas.connect(self.daq.abr_goto_meas) - self.beamline.beam_mark.beam_mark_clear.connect(self.daq.beam_mark_clear) + self.beam_mark.beam_mark_clear.connect(self.daq.beam_mark_clear) self.sample_camera.update_beam_mark.connect(self.daq.beam_mark_add) - self.beamline.beam_center.beam_center.connect(self.daq.beam_center) - self.beamline.beam_size.beam_size.connect(self.daq.beam_size_mm) + self.beam_center.beam_center.connect(self.daq.beam_center) + self.beam_size.beam_size.connect(self.daq.beam_size_mm) self.sample_camera.load_image.connect(self.raster.load_image) self.sample_camera.switch_raster_grid.connect(self.data_collection.switch_to_raster) - self.beamline.samcam.show_detections_changed.connect(self.sample_camera.set_show_detections) - self.beamline.samcam.show_detection_polygons_changed.connect( + self.samcam.show_detections_changed.connect(self.sample_camera.set_show_detections) + self.samcam.show_detection_polygons_changed.connect( self.sample_camera.set_show_detection_polygons ) - self.beamline.samcam.show_target_point_changed.connect( - self.sample_camera.set_show_target_point - ) - self.beamline.samcam.show_target_coordinates_changed.connect( + self.samcam.show_target_point_changed.connect(self.sample_camera.set_show_target_point) + self.samcam.show_target_coordinates_changed.connect( self.sample_camera.set_show_target_coordinates ) - self.beamline.samcam.show_overlay_legend_changed.connect( - self.sample_camera.set_show_overlay_legend - ) - self.beamline.samcam.compact_overlay_legend_changed.connect( + self.samcam.show_overlay_legend_changed.connect(self.sample_camera.set_show_overlay_legend) + self.samcam.compact_overlay_legend_changed.connect( self.sample_camera.set_compact_overlay_legend ) - self.beamline.samcam.target_color_changed.connect(self.sample_camera.set_target_color) + self.samcam.target_color_changed.connect(self.sample_camera.set_target_color) self._restore_samcam_overlay_settings() sample_feed_addr = pred_zmq_addr or zmq_addr @@ -731,6 +1031,16 @@ class MainWindow(QMainWindow): self.ref_tools_panel.mount.connect(self._on_manual_mount_requested) self.ref_tools_panel.unmount.connect(self._on_manual_unmount_requested) + # Dewar table doubles as the queue view: right-click edits queue + # membership, and every queue change repaints the status tints. + self.tell_samples.add_to_queue.connect( + lambda lst: self.job_list_panel.queue_samples(lst.s, replace=False) + ) + self.tell_samples.remove_from_queue.connect( + lambda lst: self.job_list_panel.remove_samples([s.db_id for s in lst.s]) + ) + self.job_list_panel.table_model.modelReset.connect(self._sync_queue_row_tints) + self.data_collection.raster.grid_size_updated.connect(self.raster.update_grid_size) self.data_collection.raster.exp_time_updated.connect(self.raster.update_exposure_time) self.data_collection.raster.transmission_updated.connect(self.raster.update_transmission) @@ -807,13 +1117,13 @@ class MainWindow(QMainWindow): self.daq.update.connect(self.prediction_thread.update_daq_status) if self._decoded_token.staff: - self.daq.update.connect(self.beamline.monochromator_panel.update_daq_status) - self.daq.update.connect(self.beamline.beam_size.update_daq_status) - self.daq.update.connect(self.beamline.beam_center.update_daq_status) - self.daq.update.connect(self.beamline.abr_tweak.update_daq_status) - self.daq.update.connect(self.beamline.beam_mark.update_daq_status) + self.daq.update.connect(self.monochromator_panel.update_daq_status) + self.daq.update.connect(self.beam_size.update_daq_status) + self.daq.update.connect(self.beam_center.update_daq_status) + self.daq.update.connect(self.abr_tweak.update_daq_status) + self.daq.update.connect(self.beam_mark.update_daq_status) self.daq.update.connect(self.status_bar.update_daq_status) - self.daq.update.connect(self.beamline.samcam.update_daq_status) + self.daq.update.connect(self.samcam.update_daq_status) self.daq.update.connect(self.beamline.zoom_panel.update_daq_status) self.daq.update.connect(self.sample_logic.update_daq_status) @@ -833,6 +1143,7 @@ class MainWindow(QMainWindow): self.daq.raster_scan_completed.connect(self.raster.grid_scan_completed) self.daq.automated_scan_done.connect(self.job_list_panel.automated_scan_done) + self.daq.automated_scan_done.connect(self._mark_scan_result) self.daq.automation_critical_failure.connect(self._on_automation_critical_failure) self.daq.manual_collection_critical_failure.connect( self._on_manual_collection_critical_failure @@ -858,32 +1169,48 @@ class MainWindow(QMainWindow): register_tutorials(self, self.tutorial_manager) + # Every dock: closing its popped-out window brings it back into the + # layout instead of hiding it (eventFilter), and popping out opens + # an enlarged window (2x width, 3x height, clamped to the screen). + for dock in self.findChildren(QDockWidget): + dock.installEventFilter(self) + dock.topLevelChanged.connect(self._on_dock_top_level_changed) + def _setup_global_shortcuts(self) -> None: - self._shortcut_manual_sample = QAction("Raise Manual Sample Dock", self) + self._shortcut_manual_sample = QAction("Expand Manual Sample", self) self._shortcut_manual_sample.setShortcut(QKeySequence("Ctrl+M")) self._shortcut_manual_sample.triggered.connect( - lambda: (self.manual_sample_dock.setVisible(True), self.manual_sample_dock.raise_()) + lambda: self.manual_sample_panel.title.expand() ) self.addAction(self._shortcut_manual_sample) self._shortcut_raise_sample_list = QAction("Raise sample list", self) self._shortcut_raise_sample_list.setShortcut(QKeySequence("Ctrl+L")) self._shortcut_raise_sample_list.triggered.connect( - lambda: (self.tell_samples_dock.setVisible(True), self.tell_samples_dock.raise_()) + lambda: ( + self.tell_samples_dock.setVisible(True), + self.tell_samples_dock.raise_(), + self.sample_lists_tabs.setCurrentIndex(0), + ) ) self.addAction(self._shortcut_raise_sample_list) + # Reference tools now live inside the Sample List dock as the + # Auxiliary-puck tab — Ctrl+R raises the dock on that tab. self._shortcut_raise_reference_tools_list = QAction("Raise reference tools", self) self._shortcut_raise_reference_tools_list.setShortcut(QKeySequence("Ctrl+R")) - self._shortcut_raise_reference_tools_list.triggered.connect( - lambda: (self.ref_tools_dock.setVisible(True), self.ref_tools_dock.raise_()) - ) + self._shortcut_raise_reference_tools_list.triggered.connect(self._raise_reference_tools) self.addAction(self._shortcut_raise_reference_tools_list) + # The automation controls live under the Dewar samples tab now. self._shortcut_raise_job_list = QAction("Raise automation list", self) self._shortcut_raise_job_list.setShortcut(QKeySequence("Ctrl+J")) self._shortcut_raise_job_list.triggered.connect( - lambda: (self.job_list_dock.setVisible(True), self.job_list_dock.raise_()) + lambda: ( + self.tell_samples_dock.setVisible(True), + self.tell_samples_dock.raise_(), + self.sample_lists_tabs.setCurrentIndex(0), + ) ) self.addAction(self._shortcut_raise_job_list) @@ -912,13 +1239,158 @@ 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() + def _sync_queue_row_tints(self) -> None: + self.tell_samples.table_model.set_queued_ids( + sample.db_id for sample in self.job_list_panel.table_model.samples + ) + + @Slot() + def _remove_selected_from_queue(self) -> None: + self._unqueue_panel_selection(self.tell_samples) + + def _unqueue_panel_selection(self, panel: TellSamplePanel) -> None: + rows = panel.table_view.selectionModel().selectedRows() + db_ids = [panel.table_model.get_id(index.row()).db_id for index in rows] + if db_ids: + self.job_list_panel.remove_samples(db_ids) + + @Slot(int, bool, str) + def _mark_scan_result(self, db_id: int, success: bool, reply: str) -> None: + # Display only: a failed run tints the row pale red until a later + # success clears it. Auth errors are not the sample's fault — skip. + if reply == "Authentication Error": + return + self.tell_samples.table_model.set_flagged(db_id, not success) + + @Slot() + def _open_sample_popout(self) -> None: + if self._sample_popout is None: + # Fully operational duplicate of the Sample List tab: second panel + # instances SHARING the docked panels' models, wired to the same + # slots — mounts, queue edits, chips and filters all work here. + dewar_panel = TellSamplePanel(model=self.tell_samples.table_model) + aux_panel = ReferenceToolsPanel(model=self.ref_tools_panel.table_model) + dewar_panel.mount.connect(self._on_manual_mount_requested) + dewar_panel.unmount.connect(self._on_manual_unmount_requested) + dewar_panel.add_to_queue.connect( + lambda lst: self.job_list_panel.queue_samples(lst.s, replace=False) + ) + dewar_panel.remove_from_queue.connect( + lambda lst: self.job_list_panel.remove_samples([s.db_id for s in lst.s]) + ) + aux_panel.mount.connect(self._on_manual_mount_requested) + aux_panel.unmount.connect(self._on_manual_unmount_requested) + + # One shared filter state → keep the two chip rows visually in sync. + self.tell_samples.status_chips.buttonClicked.connect( + lambda chip: dewar_panel.set_status_chip(chip.property("status_key")) + ) + dewar_panel.status_chips.buttonClicked.connect( + lambda chip: self.tell_samples.set_status_chip(chip.property("status_key")) + ) + dewar_panel.set_status_chip(self.tell_samples.table_model.status_filter) + + dewar_tab = QWidget() + dewar_tab.setObjectName("dewarTab") + dewar_layout = QVBoxLayout(dewar_tab) + dewar_layout.setContentsMargins(0, 0, 0, 0) + dewar_layout.setSpacing(2) + dewar_layout.addWidget(dewar_panel) + dewar_layout.addLayout(self._clone_automation_row(dewar_panel)) + + tabs = QTabWidget() + tabs.addTab(dewar_tab, "Dewar samples") + tabs.addTab(aux_panel, "Auxiliary puck") + if not self._decoded_token.staff: + tabs.setTabEnabled(1, False) + tabs.setTabToolTip(1, "Staff only") + self._sample_popout = PopoutWindow("Sample List", tabs, parent=self) + self._sample_popout.resize(1200, 500) + self._sample_popout.show() + self._sample_popout.raise_() + self._sample_popout.activateWindow() + + def _open_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()) + + 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 + engine. Run/Pause text and checkbox states stay mirrored; 'Remove + selected' acts on the pop-out's own table selection.""" + jl = self.job_list_panel + run_button = QPushButton(jl.play_button.text()) + run_button.clicked.connect(jl.run) + jl.automation_running_changed.connect( + lambda running: run_button.setText("⏸ Pause" if running else "▶ Run") + ) + remove_button = QPushButton("🗑 Remove selected") + remove_button.clicked.connect(lambda: self._unqueue_panel_selection(dewar_panel)) + clear_button = QPushButton("✖ Clear list") + clear_button.clicked.connect(jl.clear) + unmount_button = QPushButton("⏏ Unmount") + unmount_button.clicked.connect(lambda: self._on_manual_unmount_requested()) + + row = QHBoxLayout() + for button in (run_button, remove_button, clear_button, unmount_button): + row.addWidget(button) + for source in (jl.park_and_dry_when_cleared, jl.pause_on_conditions_cb): + clone = QCheckBox(source.text()) + clone.setToolTip(source.toolTip()) + clone.setChecked(source.isChecked()) + # setChecked with an unchanged value emits nothing, so the + # cross-connection cannot loop. + clone.toggled.connect(source.setChecked) + source.toggled.connect(clone.setChecked) + row.addWidget(clone) + return row + + def _show_reference_tools_staff_only_popup(self) -> None: + QMessageBox.information( + self, + "Staff only", + "The Auxiliary puck (reference tools) view is available to staff accounts only.", + ) + + @Slot() + def _raise_reference_tools(self) -> None: + if not self._decoded_token.staff: + self._show_reference_tools_staff_only_popup() + return + self.tell_samples_dock.setVisible(True) + self.tell_samples_dock.raise_() + self.sample_lists_tabs.setCurrentIndex(1) + def _return_to_main_view_for_shutdown(self) -> None: try: if getattr(self, "content_stack", None) is None: @@ -938,15 +1410,25 @@ class MainWindow(QMainWindow): def _restore_samcam_overlay_settings(self) -> None: settings = QSettings("PSI", "AareGUI") - show_detections = settings.value("samcam/show_detections", True, type=bool) - show_detection_polygons = settings.value("samcam/show_detection_polygons", True, type=bool) - show_target_point = settings.value("samcam/show_target_point", True, type=bool) - show_target_coordinates = settings.value("samcam/show_target_coordinates", True, type=bool) - show_overlay_legend = settings.value("samcam/show_overlay_legend", True, type=bool) - compact_overlay_legend = settings.value("samcam/compact_overlay_legend", False, type=bool) - target_color = settings.value("samcam/target_color", "Cyan", type=str) + # bool()/str() wraps: QSettings.value is typed "object" even with + # type=..., so the wraps are runtime no-ops for the pyright gate. + show_detections = bool(settings.value("samcam/show_detections", True, type=bool)) + show_detection_polygons = bool( + settings.value("samcam/show_detection_polygons", True, type=bool) + ) + show_target_point = bool(settings.value("samcam/show_target_point", True, type=bool)) + show_target_coordinates = bool( + settings.value("samcam/show_target_coordinates", True, type=bool) + ) + # 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) + ) + target_color = str(settings.value("samcam/target_color", "Cyan", type=str)) - self.beamline.samcam.apply_overlay_settings( + self.samcam.apply_overlay_settings( show_detections=show_detections, show_detection_polygons=show_detection_polygons, show_target_point=show_target_point, @@ -988,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 @@ -1079,23 +1569,16 @@ class MainWindow(QMainWindow): if not self._in_compact_automation_view: self._pre_automation_window_state = self.saveState() - self._pre_automation_ref_tools_visible = self.ref_tools_dock.isVisible() self._pre_automation_left_column_visible = self.collection_controls_scroll.isVisible() self._pre_automation_right_column_visible = self.beamline_controls_scroll.isVisible() self.tell_samples_dock.setVisible(False) - self.job_list_dock.setVisible(False) - self.manual_sample_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) - - if self._decoded_token.staff: - self.ref_tools_dock.setVisible(False) self.collection_controls_scroll.setVisible(False) self.beamline_controls_scroll.setVisible(False) @@ -1114,15 +1597,11 @@ class MainWindow(QMainWindow): self.collection_controls_scroll.setVisible(self._pre_automation_left_column_visible) self.beamline_controls_scroll.setVisible(self._pre_automation_right_column_visible) - if self._decoded_token.staff: - self.ref_tools_dock.setVisible(self._pre_automation_ref_tools_visible) - self._in_compact_automation_view = False self._update_view_mode_actions() - self.job_list_dock.setVisible(True) self.tell_samples_dock.setVisible(True) - self.job_list_dock.raise_() + self.tell_samples_dock.raise_() @Slot() def _refresh_compact_queue_preview(self) -> None: @@ -1162,16 +1641,12 @@ class MainWindow(QMainWindow): # Hide all dock widgets for dock_attr in ( "tell_samples_dock", - "job_list_dock", - "manual_sample_dock", - "automation_progress_dock", + "information_dock", "face_panel_dock", "fluor_panel_dock", "smargon_trace_dock", "target_stability_dock", "prediction_metrics_dock", - "log_dock", - "ref_tools_dock", ): dock = getattr(self, dock_attr, None) if dock is not None: @@ -1220,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) @@ -1230,15 +1705,12 @@ class MainWindow(QMainWindow): self._pre_portrait_geometry = None self.tell_samples_dock.setVisible(True) - self.job_list_dock.setVisible(True) - self.manual_sample_dock.setVisible(True) - self.automation_progress_dock.setVisible(False) + self.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: @@ -1330,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") @@ -1342,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): @@ -1360,50 +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() - - if self._beamline_state_panel_enabled: - self._show_beamline_state_action = QAction("Show Beamline State Panel", self) - self._show_beamline_state_action.setCheckable(True) - self._show_beamline_state_action.setChecked(self.beamline_state_panel.isVisible()) - self._show_beamline_state_action.triggered.connect( - lambda checked: self.beamline_state_panel.setVisible(checked) - ) - view_menu.addAction(self._show_beamline_state_action) + 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) @@ -1414,34 +1945,6 @@ class MainWindow(QMainWindow): self.tell_samples_dock.visibilityChanged.connect(show_samples_action.setChecked) view_menu.addAction(show_samples_action) - if self._decoded_token.staff: - show_reference_tools_action = QAction("Show Reference Tools", self) - show_reference_tools_action.setCheckable(True) - show_reference_tools_action.setChecked(True) - show_reference_tools_action.triggered.connect( - lambda checked: self.ref_tools_dock.setVisible(checked) - ) - self.ref_tools_dock.visibilityChanged.connect(show_reference_tools_action.setChecked) - view_menu.addAction(show_reference_tools_action) - - show_job_list_action = QAction("Show job List", self) - show_job_list_action.setCheckable(True) - show_job_list_action.setChecked(True) - show_job_list_action.triggered.connect( - lambda checked: self.job_list_dock.setVisible(checked) - ) - self.job_list_dock.visibilityChanged.connect(show_job_list_action.setChecked) - view_menu.addAction(show_job_list_action) - - show_manual_sample_action = QAction("Show manual sample", self) - show_manual_sample_action.setCheckable(True) - show_manual_sample_action.setChecked(True) - show_manual_sample_action.triggered.connect( - lambda checked: self.manual_sample_dock.setVisible(checked) - ) - self.manual_sample_dock.visibilityChanged.connect(show_manual_sample_action.setChecked) - view_menu.addAction(show_manual_sample_action) - show_face_panel_action = QAction("Show face detection", self) show_face_panel_action.setCheckable(True) show_face_panel_action.setChecked(False) @@ -1494,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() @@ -1555,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() @@ -1581,28 +2080,18 @@ class MainWindow(QMainWindow): self.beamline_controls_scroll.setVisible(True) self.tell_samples_dock.setVisible(True) - self.job_list_dock.setVisible(True) - self.manual_sample_dock.setVisible(True) - - if self._beamline_state_panel_enabled: - self.beamline_state_panel.setVisible(True) - self.beamline_state_panel.set_collapsed(False) - if hasattr(self, "_show_beamline_state_action"): - self._show_beamline_state_action.setChecked(True) self.face_panel_dock.setVisible(False) self.fluor_panel_dock.setVisible(False) self.smargon_trace_dock.setVisible(False) 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) - if self._decoded_token.staff: - self.ref_tools_dock.setVisible(True) - self.ref_tools_dock.raise_() - else: - self.ref_tools_dock.setVisible(False) - self.tell_samples_dock.raise_() + self.tell_samples_dock.raise_() self.video_tab.setCurrentIndex(0) @@ -1637,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: @@ -1993,9 +2482,108 @@ class MainWindow(QMainWindow): def sample_view(self): self.video_tab.setCurrentWidget(self.sample_camera) + def _apply_session_gate(self, session_state) -> None: + # No baton -> watching only: camera views stay live, every operating + # surface is greyed. The SESSION VACANT badge (and the status bar + # session menu) remain the way back in. + owned = session_state in (SessionsStateEnum.OwnedByYou, SessionsStateEnum.PendingElseToYou) + if owned == getattr(self, "_session_operations_enabled", None): + return + self._session_operations_enabled = owned + for widget in (self.left_column_tabs, self.beamline, self.tell_samples_dock.widget()): + widget.setEnabled(owned) + if owned: + # None clears the effect (Qt API contract); the PySide6 stub + # signature misses the Optional. + widget.setGraphicsEffect(None) # pyright: ignore[reportArgumentType] + else: + # Full grayscale, banners included — QSS :disabled alone + # can't reach the custom-painted TitleLabels/inline styles. + effect = QGraphicsColorizeEffect(widget) + effect.setColor(QColor("#808080")) + widget.setGraphicsEffect(effect) + self.sample_camera.set_operations_enabled(owned) + + # Vacant folds every panel shut; grabbing reopens exactly the ones + # that were open before. Transient (persist=False) so the fold never + # overwrites the user's saved per-panel choices. + banners = self.left_column_tabs.findChildren(TitleLabel) + self.beamline.findChildren( + TitleLabel + ) + if owned: + for banner in getattr(self, "_pre_vacancy_open_banners", []): + banner.set_collapsed(False, persist=False) + else: + self._pre_vacancy_open_banners = [b for b in banners if not b.is_collapsed()] + for banner in banners: + banner.set_collapsed(True, persist=False) + + # 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) @@ -2097,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) @@ -2118,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() @@ -2179,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: @@ -2203,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"): @@ -2222,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: @@ -2256,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: @@ -2278,16 +2866,49 @@ 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): + super().showEvent(event) + # First show only, and only without a saved layout: re-apply the + # default dock split AFTER the real (maximized) geometry exists — + # the __init__ resizeDocks ran on the pre-show size and Qt hands the + # scale-up surplus to the sample list, skewing 50/50 into ~80/20. + if not getattr(self, "_default_dock_split_done", False): + self._default_dock_split_done = True + if not self.state_manager.settings.value("main_window/state"): + QTimer.singleShot(0, self._apply_default_dock_split) + + def _apply_default_dock_split(self) -> None: + self.resizeDocks( + [self.tell_samples_dock, self.information_dock], [240, 240], Qt.Orientation.Vertical + ) + self.resizeDocks( + [self.tell_samples_dock, self.information_dock], + [10000, 10000], + Qt.Orientation.Horizontal, + ) + def closeEvent(self, event) -> None: try: self._return_to_main_view_for_shutdown() @@ -2295,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() @@ -2412,7 +3037,80 @@ class MainWindow(QMainWindow): def _mark_user_interaction(self) -> None: self._refresh_idle_activity(report_backend=True) + @Slot(bool) + def _on_dock_top_level_changed(self, floating: bool) -> None: + dock = self.sender() + if 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. + QTimer.singleShot(0, lambda d=dock: self._enlarge_floating_dock(d)) + + def _enlarge_floating_dock(self, dock: QDockWidget) -> None: + if not dock.isFloating(): + return + screen = dock.screen().availableGeometry() + dock.resize( + min(dock.width() * 2, int(screen.width() * 0.9)), + min(dock.height() * 3, int(screen.height() * 0.9)), + ) + # Keep the enlarged window fully on screen. + geo = dock.frameGeometry() + dx = min(0, screen.right() - geo.right()) + dy = min(0, screen.bottom() - geo.bottom()) + if dx or dy: + dock.move(geo.x() + dx, geo.y() + dy) + + def _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 + # the View menu. + if event.type() == QEvent.Type.Close and isinstance(obj, QDockWidget) and obj.isFloating(): + obj.setFloating(False) + event.ignore() + return True try: if event.type() in { QEvent.Type.MouseButtonPress, @@ -2428,6 +3126,18 @@ class MainWindow(QMainWindow): self._mark_user_interaction() except Exception as e: logger.debug(f"GUI interaction event filter error: {e}", exc_info=True) + # Non-staff click on the greyed-out Auxiliary-puck tab: only installed + # for non-staff, and tabAt() is geometric so it still sees the + # disabled tab — explain the lock instead of silently eating the click. + sample_tab_bar = self.sample_lists_tabs.tabBar() + if ( + event.type() == QEvent.Type.MouseButtonPress + and sample_tab_bar is not None + and obj is sample_tab_bar + and sample_tab_bar.tabAt(event.position().toPoint()) == 1 + ): + self._show_reference_tools_staff_only_popup() + return True return super().eventFilter(obj, event) def _start_remote_close_countdown( diff --git a/src/aare/gui/models/bookmark.py b/src/aare/gui/models/bookmark.py index e0370467..54fc5f60 100644 --- a/src/aare/gui/models/bookmark.py +++ b/src/aare/gui/models/bookmark.py @@ -3,6 +3,8 @@ from typing import Literal from aarecommon.math.coordinate import SmargonCoordinate from PySide6.QtGui import QColor +from aare.gui.styles import BOOKMARK_COLORS, qcolor + class SmargonBookmark: coord: SmargonCoordinate @@ -10,13 +12,7 @@ class SmargonBookmark: def qt_color(self) -> QColor: """Convert the color property to a QColor.""" - color_map = { - "red": QColor("red"), - "green": QColor("green"), - "blue": QColor("blue"), - "indigo": QColor("indigo"), - "lime": QColor("lime"), - } + color_map = {name: qcolor(hex_str) for name, hex_str in BOOKMARK_COLORS.items()} return color_map[self.color] # Map the color string to QColor diff --git a/src/aare/gui/models/sample_queue_model.py b/src/aare/gui/models/sample_queue_model.py index f4b19e1a..f6a1c3c7 100644 --- a/src/aare/gui/models/sample_queue_model.py +++ b/src/aare/gui/models/sample_queue_model.py @@ -1,9 +1,10 @@ from aarecommon.config.logger import setup_logger from aarecommon.models.models import SampleShortInfo, SampleShortInfoList from PySide6.QtCore import QAbstractTableModel, Qt -from PySide6.QtGui import QBrush, QColor +from PySide6.QtGui import QBrush from aare.gui.constants import LOGGER_NAME +from aare.gui.styles import SAMPLE_ROW_ACTIVE_BG, SAMPLE_ROW_QUEUED_BG, SAMPLE_STATUS_TEXT, qcolor logger = setup_logger(LOGGER_NAME) @@ -58,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(255, 102, 0)) + return QBrush(qcolor(SAMPLE_ROW_ACTIVE_BG)) else: - return QBrush(QColor(114, 159, 207)) - return QBrush(QColor(255, 255, 255)) + return QBrush(qcolor(SAMPLE_ROW_QUEUED_BG)) + 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 14aee66b..207234a5 100644 --- a/src/aare/gui/models/user_sample_model.py +++ b/src/aare/gui/models/user_sample_model.py @@ -3,36 +3,49 @@ import re from aarecommon.config.logger import setup_logger from aarecommon.models.models import SampleShortInfo, SampleShortInfoList from PySide6.QtCore import QAbstractTableModel, QMimeData, Qt -from PySide6.QtGui import QBrush, QColor +from PySide6.QtGui import QBrush from aare.gui.constants import LOGGER_NAME +from aare.gui.styles import ( + SAMPLE_ROW_QUEUED_BG, + SAMPLE_STATUS_FLAGGED_BG, + SAMPLE_STATUS_MEASURED_BG, + SAMPLE_STATUS_QUEUED_BG, + SAMPLE_STATUS_TEXT, + qcolor, +) logger = setup_logger(LOGGER_NAME) +# Column 0 is display-only: the row position ("#") drawn over the status +# color fill. Data attributes start at column 1. +COL_STATUS = 0 + def get_entry(sample: SampleShortInfo, column: int): - if column == 0: + if column == 1: return sample.sample_name - elif column == 1: - return sample.puck_name elif column == 2: - return sample.dewar_name + return sample.puck_name elif column == 3: - return sample.loc_str() + return sample.dewar_name elif column == 4: - return sample.priority + return sample.loc_str() elif column == 5: - return sample.user + return sample.priority elif column == 6: - return sample.mount_count + return sample.user elif column == 7: - return sample.raster_count + return sample.mount_count elif column == 8: - return sample.rotation_count + return sample.raster_count elif column == 9: - return sample.screening_count + return sample.rotation_count elif column == 10: + return sample.screening_count + elif column == 11: return sample.comment + return "" class UserSampleSpreadsheet(QAbstractTableModel): @@ -48,6 +61,7 @@ class UserSampleSpreadsheet(QAbstractTableModel): samples = [] self.samples: list[SampleShortInfo] = samples self.header = [ + "#", "Sample name", "Puck", "Dewar", @@ -62,15 +76,23 @@ class UserSampleSpreadsheet(QAbstractTableModel): ] self.current_sample = current_sample self.current_puck = current_puck - self._sort_col = 3 + self._sort_col = 4 # Location self._sort_order = Qt.SortOrder.AscendingOrder self._filters: dict[int, str] = {} - self._filter_col: int | None = 5 + self._filter_col: int | None = 6 # User self._filter_value: str | None = None self.current_pgroup: str | None = None self.show_all_pgroups: bool = False + # Display-only status tints (aaregui2 concept: the dewar table doubles + # as the queue view). Fed from outside; the queue itself stays in the + # SampleQueueSpreadsheet. + self.queued_ids: set[int] = set() + self.flagged_ids: set[int] = set() + # None = All; otherwise "queued" | "flagged" | "measured" (chip row). + self.status_filter: str | None = None + self._sort() def to_list(self) -> list[dict]: @@ -89,17 +111,96 @@ class UserSampleSpreadsheet(QAbstractTableModel): def data(self, index, role=None): if role == Qt.ItemDataRole.DisplayRole: + if index.column() == COL_STATUS: + return index.row() + 1 return get_entry(self._sorted_samples[index.row()], index.column()) + elif role == Qt.ItemDataRole.BackgroundRole: + # Status lives in the "#" column, as a full cell fill under the + # row number — rows themselves alternate grey/white (view-level) + # and selection stays the pale blue tint. + if index.column() == COL_STATUS: + color = self._status_color(self._sorted_samples[index.row()]) + if color is not None: + return QBrush(qcolor(color)) + elif role == Qt.ItemDataRole.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 - elif role == Qt.ItemDataRole.BackgroundRole: - if self._sorted_samples[index.row()].db_id == self.current_sample: - return QBrush(QColor(114, 159, 207)) # darker blue - if self._sorted_samples[index.row()].puck_name == self.current_puck: - return QBrush(QColor(216, 228, 253)) # light blue - return QBrush(QColor(255, 255, 255)) # White return None # For other roles, return None + def _status_color(self, sample: SampleShortInfo) -> str | None: + # Mounted always wins; below that the dot depends on the active chip: + # inside a filtered view every row carries that status, so its own + # color is redundant — only cross-status marks show (queued view: + # red = also flagged; flagged view: orange = put back in the queue). + # The All view keeps the full priority queued > flagged > measured. + if sample.db_id == self.current_sample: + return SAMPLE_ROW_QUEUED_BG + queued = sample.db_id in self.queued_ids + flagged = sample.db_id in self.flagged_ids + if self.status_filter == "queued": + return SAMPLE_STATUS_FLAGGED_BG if flagged else None + if self.status_filter == "flagged": + return SAMPLE_STATUS_QUEUED_BG if queued else None + if self.status_filter == "measured": + if queued: + return SAMPLE_STATUS_QUEUED_BG + return SAMPLE_STATUS_FLAGGED_BG if flagged else None + if queued: + return SAMPLE_STATUS_QUEUED_BG + if flagged: + return SAMPLE_STATUS_FLAGGED_BG + if self._measured(sample): + return SAMPLE_STATUS_MEASURED_BG + return None + + @staticmethod + def _measured(sample: SampleShortInfo) -> bool: + # Automatic status, never relabelled by hand: a sample counts as + # measured once its rotation count exceeds 1. + return isinstance(sample.rotation_count, (int, float)) and sample.rotation_count > 1 + + def set_queued_ids(self, db_ids) -> None: + self.queued_ids = set(db_ids) + self._status_sets_changed() + + def set_flagged(self, db_id: int, flagged: bool) -> None: + if flagged: + self.flagged_ids.add(db_id) + else: + self.flagged_ids.discard(db_id) + self._status_sets_changed() + + def set_status_filter(self, status: str | None) -> None: + self.layoutAboutToBeChanged.emit() + self.status_filter = status + self._sort() + self.layoutChanged.emit() + + def _status_sets_changed(self) -> None: + # With a status chip active the row SET depends on the sets, not just + # the tint — refilter; otherwise a background repaint is enough. + if self.status_filter: + self.layoutAboutToBeChanged.emit() + self._sort() + self.layoutChanged.emit() + else: + self._emit_tints_changed() + + def _emit_tints_changed(self) -> None: + if self.rowCount() > 0: + self.dataChanged.emit( + self.index(0, COL_STATUS), + self.index(self.rowCount() - 1, COL_STATUS), + [Qt.ItemDataRole.BackgroundRole], + ) + def headerData(self, section, orientation, role=None): if role == Qt.ItemDataRole.DisplayRole: if orientation == Qt.Orientation.Horizontal: # Column header @@ -113,6 +214,7 @@ class UserSampleSpreadsheet(QAbstractTableModel): ): self.current_puck = current_puck self.current_sample = current_sample + self._emit_tints_changed() def updateData(self, samples: list[SampleShortInfo]): if samples != self.samples: @@ -122,6 +224,9 @@ class UserSampleSpreadsheet(QAbstractTableModel): self.endResetModel() def sort(self, column, order): + # The "#"/status column is display-only — nothing to sort by. + if column == COL_STATUS: + return self.layoutAboutToBeChanged.emit() self._sort_order = order self._sort_col = column @@ -130,7 +235,7 @@ class UserSampleSpreadsheet(QAbstractTableModel): def _sort(self): filtered = self._apply_filter(self.samples) - if self._sort_col == 3: + if self._sort_col == 4: # Location self._sorted_samples = sorted( filtered, key=lambda row: row.loc_str_sort(), @@ -149,6 +254,14 @@ class UserSampleSpreadsheet(QAbstractTableModel): ) def _apply_filter(self, rows: list[SampleShortInfo]) -> list[SampleShortInfo]: + # Status chip filter first (All/Queued/Flagged/Measured row). + if self.status_filter == "queued": + rows = [r for r in rows if r.db_id in self.queued_ids] + elif self.status_filter == "flagged": + rows = [r for r in rows if r.db_id in self.flagged_ids] + elif self.status_filter == "measured": + rows = [r for r in rows if self._measured(r)] + # Default filter by User using current p-group if no explicit filter set filters: dict[int, str] = { col: v for col, v in (self._filters or {}).items() if (v or "").strip() @@ -157,8 +270,8 @@ class UserSampleSpreadsheet(QAbstractTableModel): if self._filter_col is not None and (self._filter_value or "").strip(): filters[self._filter_col] = self._filter_value - if 5 not in filters and self.current_pgroup and not self.show_all_pgroups: - filters[5] = self.current_pgroup + if 6 not in filters and self.current_pgroup and not self.show_all_pgroups: + filters[6] = self.current_pgroup # User if not filters: return rows @@ -270,7 +383,7 @@ class UserSampleSpreadsheet(QAbstractTableModel): break # Sort appropriately - if column == 5: # User/pgroup column + if column == 6: # User/pgroup column try: out.sort( key=lambda x: ( @@ -289,10 +402,10 @@ class UserSampleSpreadsheet(QAbstractTableModel): def suggested_prefixes_for_sample_name(self, limit: int = 200) -> list[str]: """Get sample name prefixes from currently filtered samples (excluding column 0 filter).""" # Get currently filtered samples, excluding the sample name filter - temp_filter = self._filters.pop(0, None) + temp_filter = self._filters.pop(1, None) filtered_samples = self._apply_filter(self.samples) if temp_filter is not None: - self._filters[0] = temp_filter + self._filters[1] = temp_filter rx = re.compile(r"^([A-Za-z]+)") counts: dict[str, int] = {} @@ -313,10 +426,10 @@ class UserSampleSpreadsheet(QAbstractTableModel): def suggested_prefixes_for_location(self, limit: int = 200) -> tuple[list[str], list[str]]: """Get location prefixes from currently filtered samples (excluding column 3 filter).""" # Get currently filtered samples, excluding the location filter - temp_filter = self._filters.pop(3, None) + temp_filter = self._filters.pop(4, None) filtered_samples = self._apply_filter(self.samples) if temp_filter is not None: - self._filters[3] = temp_filter + self._filters[4] = temp_filter seg_seen: set[str] = set() segpos_seen: set[str] = set() diff --git a/src/aare/gui/panels/abr_tweak_panel.py b/src/aare/gui/panels/abr_tweak_panel.py index 573a9bdf..db949e33 100644 --- a/src/aare/gui/panels/abr_tweak_panel.py +++ b/src/aare/gui/panels/abr_tweak_panel.py @@ -4,6 +4,7 @@ from PySide6.QtCore import Signal, Slot from PySide6.QtGui import Qt from PySide6.QtWidgets import QGridLayout, QLabel, QPushButton, QWidget +from aare.gui.styles import ALERT_TEXT 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 @@ -19,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}) @@ -91,30 +97,44 @@ class AbrTweakWidget(QWidget): super().__init__(parent) grid_layout = QGridLayout(self) - grid_layout.setColumnStretch(0, 0) - grid_layout.setColumnStretch(1, 0) - grid_layout.setColumnStretch(2, 1) - grid_layout.addWidget(TitleLabel("ABR meas. pos.", self), 0, 0, 1, 3) + grid_layout.addWidget( + TitleLabel("ABR meas. pos.", self, collapsible=True, default_collapsed=False), + 0, + 0, + 1, + 2, + ) self._abr_buttons = AbrTweakButtons(DEFAULT_ABR_STEP_UM / 1000, parent=self) - grid_layout.addWidget(self._abr_buttons, 1, 0, 1, 4) + grid_layout.addWidget(self._abr_buttons, 1, 0) self._abr_buttons.abr_tweak.connect(self.abr_button_pressed) - grid_layout.addWidget(QLabel("Step", parent=self), 2, 0) - self._step_um = NumberLineEdit(1, 1000, DEFAULT_ABR_STEP_UM, 0, parent=self) - grid_layout.addWidget(self._step_um, 2, 1) - grid_layout.addWidget(QLabel("μm", parent=self), 2, 2) + # Step + actions as a column BESIDE the GM rows instead of below — + # the rows left plenty of dead width. + side = QWidget(self) + side_grid = QGridLayout(side) + side_grid.setContentsMargins(0, 0, 0, 0) + side_grid.addWidget(QLabel("Step", parent=side), 0, 0) + self._step_um = NumberLineEdit(1, 1000, DEFAULT_ABR_STEP_UM, 0, parent=side) + side_grid.addWidget(self._step_um, 0, 1) + side_grid.addWidget(QLabel("μm", parent=side), 0, 2) self._step_um.newValue.connect(self._abr_buttons.set_step) save_button = QPushButton("Save ABR pos.") - grid_layout.addWidget(save_button, 3, 0, 1, 3) + side_grid.addWidget(save_button, 1, 0, 1, 3) save_button.pressed.connect(self.save_button_pressed) goto_button = QPushButton("Go to meas.") - grid_layout.addWidget(goto_button, 4, 0, 1, 3) + side_grid.addWidget(goto_button, 2, 0, 1, 3) goto_button.pressed.connect(self.goto_button_pressed) + # ~80% of the width the grid handed it; the freed space goes to the + # GM rows (column 0 takes all stretch). + side.setMaximumWidth(150) + grid_layout.setColumnStretch(0, 1) + grid_layout.addWidget(side, 1, 1) + @Slot() def goto_button_pressed(self): self.abr_goto_meas.emit() @@ -131,17 +151,17 @@ class AbrTweakWidget(QWidget): def update_daq_status(self, s: DAQStatusModel): self._abr_buttons.gmx_label.setText(f"{s.geom.aerotech_meas.x:.3f}") if abs(s.geom.aerotech.x) >= 0.001: - self._abr_buttons.gmx_label.setStyleSheet("color: rgb(255, 0, 0);") + self._abr_buttons.gmx_label.setStyleSheet(f"color: {ALERT_TEXT};") else: - self._abr_buttons.gmx_label.setStyleSheet("color: rgb(0, 0, 0);") + self._abr_buttons.gmx_label.setStyleSheet("") self._abr_buttons.gmy_label.setText(f"{s.geom.aerotech_meas.y:.3f}") if abs(s.geom.aerotech.y) >= 0.001: - self._abr_buttons.gmy_label.setStyleSheet("color: rgb(255, 0, 0);") + self._abr_buttons.gmy_label.setStyleSheet(f"color: {ALERT_TEXT};") else: - self._abr_buttons.gmy_label.setStyleSheet("color: rgb(0, 0, 0);") + self._abr_buttons.gmy_label.setStyleSheet("") self._abr_buttons.gmz_label.setText(f"{s.geom.aerotech_meas.z:.3f}") if abs(s.geom.aerotech.z) >= 0.001: - self._abr_buttons.gmz_label.setStyleSheet("color: rgb(255, 0, 0);") + self._abr_buttons.gmz_label.setStyleSheet(f"color: {ALERT_TEXT};") else: - self._abr_buttons.gmz_label.setStyleSheet("color: rgb(0, 0, 0);") + self._abr_buttons.gmz_label.setStyleSheet("") diff --git a/src/aare/gui/panels/automation_panel.py b/src/aare/gui/panels/automation_panel.py index e9edb4c9..fbac3819 100644 --- a/src/aare/gui/panels/automation_panel.py +++ b/src/aare/gui/panels/automation_panel.py @@ -1,15 +1,38 @@ from __future__ import annotations -import copy import time from datetime import datetime from aarecommon.config.logger import setup_logger from aarecommon.models.automation import AutomationProgress, StepStatus, WorkflowStateKind -from PySide6.QtCore import QTimer, Slot -from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget +from PySide6.QtCore import Qt, QTimer, Slot +from PySide6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget from aare.gui.constants import LOGGER_NAME +from aare.gui.styles import ( + AUTOMATION_HINT_TEXT, + CARD_BORDER, + FAINT_TEXT, + FONT_BODY, + FONT_LABEL, + FONT_TITLE, + MUTED_TEXT, + STEP_ACTIVE_BG, + STEP_ACTIVE_BORDER, + STEP_ACTIVE_TEXT, + STEP_DONE_BG, + STEP_DONE_BORDER, + STEP_DONE_TEXT, + STEP_FAILED_BG, + STEP_FAILED_BORDER, + STEP_FAILED_TEXT, + STEP_IDLE_BG, + STEP_IDLE_BORDER, + STEP_PAUSED_BG, + STEP_PAUSED_BORDER, + STEP_PAUSED_TEXT, + SURFACE, +) logger = setup_logger(LOGGER_NAME) @@ -24,7 +47,6 @@ class AutomationProgressWidget(QWidget): self._progress: AutomationProgress | None = None self._gui_samples_in_queue: int | None = None self._labels: dict[WorkflowStateKind, QLabel] = {} - self._title_label: QLabel | None = None self._stats_label: QLabel | None = None self._is_paused = False self._refresh_timer = QTimer(self) @@ -34,36 +56,45 @@ class AutomationProgressWidget(QWidget): self.clear() def _setup_ui(self) -> None: + # No title label: the dock's title bar already says it. layout = QVBoxLayout(self) layout.setContentsMargins(10, 10, 10, 10) layout.setSpacing(10) - self._title_label = QLabel("Automation progress") - self._title_label.setStyleSheet( - "font-size: 16px; font-weight: 700; color: #1F2937; margin-bottom: 2px;" - ) - layout.addWidget(self._title_label) - self._stats_label = QLabel() self._stats_label.setStyleSheet( - "color: #374151; font-size: 13px; " - "background-color: #F8FAFC; border: 1px solid #E2E8F0; " - "border-radius: 8px; padding: 10px;" + f"color: {AUTOMATION_HINT_TEXT}; font-size: {FONT_LABEL}; font-weight: 700; " + f"background-color: {SURFACE}; border: 1px solid {CARD_BORDER}; " + "padding: 10px;" ) self._stats_label.setWordWrap(True) - layout.addWidget(self._stats_label) + # Stats left, automation run-state card right ("||" paused / "▶" + # running — ASCII bars: fancier pause glyphs are missing from the + # beamline console fonts). + self._state_label = QLabel() + self._state_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + top_row = QHBoxLayout() + top_row.setSpacing(10) + top_row.addWidget(self._stats_label, 2) + top_row.addWidget(self._state_label, 1) + layout.addLayout(top_row) + self._update_state_label() + + steps_row = QHBoxLayout() + steps_row.setSpacing(6) for step in ( WorkflowStateKind.MOUNT, WorkflowStateKind.LOOP_CENTRE, WorkflowStateKind.RASTER, WorkflowStateKind.DATA_COLLECTION, - WorkflowStateKind.FINAL, ): label = QLabel() label.setWordWrap(True) - layout.addWidget(label) + steps_row.addWidget(label, 1) self._labels[step] = label + layout.addLayout(steps_row) layout.addStretch() @@ -116,31 +147,37 @@ class AutomationProgressWidget(QWidget): @staticmethod def _style_for_status(status: StepStatus) -> str: - base = ( - "padding: 10px 12px; border-radius: 10px; " - "font-size: 14px; border: 1px solid transparent;" - ) + base = f"padding: 10px 12px; font-size: {FONT_BODY}; border: 1px solid transparent;" if status == StepStatus.SUCCESS: - return base + " background-color: #ECFDF3; color: #166534; border-color: #A7F3D0;" + return ( + base + + f" background-color: {STEP_DONE_BG}; color: {STEP_DONE_TEXT}; border-color: {STEP_DONE_BORDER};" + ) if status == StepStatus.RUNNING: return ( base - + " background-color: #EFF6FF; color: #1D4ED8; font-weight: 700; border-color: #BFDBFE;" + + f" background-color: {STEP_ACTIVE_BG}; color: {STEP_ACTIVE_TEXT}; font-weight: 700; border-color: {STEP_ACTIVE_BORDER};" ) if status == StepStatus.FAILED: return ( base - + " background-color: #FEF2F2; color: #B91C1C; font-weight: 700; border-color: #FECACA;" + + f" background-color: {STEP_FAILED_BG}; color: {STEP_FAILED_TEXT}; font-weight: 700; border-color: {STEP_FAILED_BORDER};" ) if status == StepStatus.PAUSED: return ( base - + " background-color: #FFF7ED; color: #C2410C; font-weight: 700; border-color: #FED7AA;" + + f" background-color: {STEP_PAUSED_BG}; color: {STEP_PAUSED_TEXT}; font-weight: 700; border-color: {STEP_PAUSED_BORDER};" ) if status == StepStatus.SKIPPED: - return base + " background-color: #F8FAFC; color: #475569; border-color: #E2E8F0;" - return base + " background-color: #F8FAFC; color: #64748B; border-color: #E2E8F0;" + return ( + base + + f" background-color: {STEP_IDLE_BG}; color: {MUTED_TEXT}; border-color: {STEP_IDLE_BORDER};" + ) + return ( + base + + f" background-color: {STEP_IDLE_BG}; color: {FAINT_TEXT}; border-color: {STEP_IDLE_BORDER};" + ) @staticmethod def _format_duration(seconds: float | None) -> str: @@ -185,32 +222,38 @@ class AutomationProgressWidget(QWidget): return self.set_progress(self._progress) + def _update_state_label(self) -> None: + if self._is_paused: + colors = ( + f"background-color: {STEP_PAUSED_BG}; color: {STEP_PAUSED_TEXT}; " + f"border: 1px solid {STEP_PAUSED_BORDER};" + ) + text = "|| paused" + else: + colors = ( + f"background-color: {STEP_ACTIVE_BG}; color: {STEP_ACTIVE_TEXT}; " + f"border: 1px solid {STEP_ACTIVE_BORDER};" + ) + text = "▶ running" + self._state_label.setText(text) + self._state_label.setStyleSheet( + f"font-size: {FONT_TITLE}; font-weight: 700; padding: 10px; " + colors + ) + @Slot(bool) def set_running(self, running: bool) -> None: self._is_paused = not running + self._update_state_label() if self._progress is None: return - progress = copy.deepcopy(self._progress) - - final_step = next( - (step for step in progress.steps if step.step == WorkflowStateKind.FINAL), None - ) - if self._is_paused: self._refresh_timer.stop() - if final_step is not None: - final_step.status = StepStatus.PAUSED - final_step.message = "Automation paused" - else: - if final_step is not None and final_step.status == StepStatus.PAUSED: - final_step.status = StepStatus.PENDING - final_step.message = "" - if self._has_live_timing(progress): - self._refresh_timer.start() + elif self._has_live_timing(self._progress): + self._refresh_timer.start() - self.set_progress(progress) + self.set_progress(self._progress) @Slot(int) def set_samples_in_queue(self, count: int) -> None: @@ -283,6 +326,3 @@ class AutomationProgressWidget(QWidget): label.setText(f"{icon} {title}{duration_str}{message}{error_str}") label.setStyleSheet(self._style_for_status(step_state.status)) - - if self._title_label is not None: - self._title_label.setText("Automation progress") diff --git a/src/aare/gui/panels/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_center_panel.py b/src/aare/gui/panels/beam_center_panel.py index c14a39a1..45236fbb 100644 --- a/src/aare/gui/panels/beam_center_panel.py +++ b/src/aare/gui/panels/beam_center_panel.py @@ -3,7 +3,7 @@ from PySide6.QtCore import Signal, Slot from PySide6.QtWidgets import QGridLayout, QLabel, QWidget from aare.gui.widgets.number_line_edit import NumberLineEdit -from aare.gui.widgets.title_label import TitleLabel +from aare.gui.widgets.title_label import section_title class BeamCenterWidget(QWidget): @@ -14,7 +14,7 @@ class BeamCenterWidget(QWidget): grid_layout = QGridLayout(self) - grid_layout.addWidget(TitleLabel("Beam center (detector)", self), 0, 0, 1, 5) + grid_layout.addWidget(section_title("Beam center (detector)", self), 0, 0, 1, 5) self.x = NumberLineEdit(-4000, 4000, 0, parent=self) self.x.newValue.connect(self.beam_center_edited) diff --git a/src/aare/gui/panels/beam_mark_panel.py b/src/aare/gui/panels/beam_mark_panel.py index de1b3da0..3a892b3b 100644 --- a/src/aare/gui/panels/beam_mark_panel.py +++ b/src/aare/gui/panels/beam_mark_panel.py @@ -2,7 +2,7 @@ from aarecommon.models.models import DAQStatusModel from PySide6.QtCore import Signal, Slot from PySide6.QtWidgets import QGridLayout, QLabel, QPushButton, QWidget -from aare.gui.widgets.title_label import TitleLabel +from aare.gui.widgets.title_label import section_title class BeamMarkWidget(QWidget): @@ -12,8 +12,9 @@ class BeamMarkWidget(QWidget): super().__init__(parent) grid_layout = QGridLayout(self) + grid_layout.setVerticalSpacing(2) - grid_layout.addWidget(TitleLabel("Beam mark (image)", self), 0, 0, 1, 5) + grid_layout.addWidget(section_title("Beam mark (image)", self), 0, 0, 1, 6) self.x = QLabel("0") self.y = QLabel("0") @@ -24,8 +25,10 @@ class BeamMarkWidget(QWidget): grid_layout.addWidget(self.y, 1, 3) grid_layout.addWidget(QLabel("pxl"), 1, 4) + # Shares the readings row instead of a full-width row below. clear_button = QPushButton("Clear marks") - grid_layout.addWidget(clear_button, 2, 0, 1, 5) + clear_button.setFixedWidth(100) # 100 is needed to see everything + grid_layout.addWidget(clear_button, 1, 5) clear_button.pressed.connect(self.clear_button_pressed) @Slot() diff --git a/src/aare/gui/panels/beam_size_panel.py b/src/aare/gui/panels/beam_size_panel.py index 3a9219ff..83aa4afd 100644 --- a/src/aare/gui/panels/beam_size_panel.py +++ b/src/aare/gui/panels/beam_size_panel.py @@ -3,7 +3,7 @@ from PySide6.QtCore import Signal, Slot from PySide6.QtWidgets import QGridLayout, QLabel, QWidget from aare.gui.widgets.number_line_edit import NumberLineEdit -from aare.gui.widgets.title_label import TitleLabel +from aare.gui.widgets.title_label import section_title class BeamSizeWidget(QWidget): @@ -14,7 +14,7 @@ class BeamSizeWidget(QWidget): grid_layout = QGridLayout(self) - grid_layout.addWidget(TitleLabel("Beam size", self), 0, 0, 1, 5) + grid_layout.addWidget(section_title("Beam size", self), 0, 0, 1, 5) self.x = NumberLineEdit(1, 400.0, 10, parent=self) self.x.newValue.connect(self.beam_size_edited) diff --git a/src/aare/gui/panels/beamline_controls.py b/src/aare/gui/panels/beamline_controls.py index 588470fd..01e3d4b1 100644 --- a/src/aare/gui/panels/beamline_controls.py +++ b/src/aare/gui/panels/beamline_controls.py @@ -1,26 +1,52 @@ -from PySide6.QtWidgets import QFrame, QVBoxLayout +from PySide6.QtWidgets import QFrame, QVBoxLayout, QWidget -from aare.gui.panels.abr_tweak_panel import AbrTweakWidget from aare.gui.panels.beam_center_panel import BeamCenterWidget from aare.gui.panels.beam_mark_panel import BeamMarkWidget from aare.gui.panels.beam_size_panel import BeamSizeWidget from aare.gui.panels.illumination_panel import IlluminationPanel -from aare.gui.panels.monochromator_panel import MonochromatorPanel from aare.gui.panels.omega_panel import OmegaPanel -from aare.gui.panels.samcam_panel import SamcamPanel from aare.gui.panels.smargon_panel import SmargonPanel from aare.gui.panels.zoom_panel import ZoomPanel +from aare.gui.widgets.title_label import TitleLabel, tighten_column + + +class BeamConfigPanel(QWidget): + """Beam mark / center / size grouped under one collapsible banner; the + sub-widgets keep their own signals and small section titles.""" + + def __init__(self, parent=None): + super().__init__(parent) + # Default layout margins so the banner aligns with the sibling + # panels' banners in the column. + layout = QVBoxLayout(self) + layout.setSpacing(0) + layout.addWidget( + TitleLabel("Beam configuration", self, collapsible=True, default_collapsed=False) + ) + self.beam_mark = BeamMarkWidget(self) + self.beam_center = BeamCenterWidget(self) + self.beam_size = BeamSizeWidget(self) + for sub in (self.beam_mark, self.beam_center, self.beam_size): + sub_layout = sub.layout() + assert sub_layout is not None # each sub-widget builds its grid in __init__ + # The outer layout already indents; zero the sub-grids' side + # margins so section content isn't double-inset. + sub_layout.setContentsMargins(0, 4, 0, 4) + layout.addWidget(sub) class BeamlineControls(QFrame): set_width = 250 - def __init__(self, parent=None, staff: bool = True): + def __init__(self, parent=None): super().__init__(parent) + self.setObjectName("beamlineControls") self.setFixedWidth(self.set_width) self.setFrameShape(QFrame.Shape.StyledPanel) self.setFrameShadow(QFrame.Shadow.Raised) + # Samcam / monochromator / ABR / beam config moved to the left + # column's "Beamline" group (main_window builds it). self.v_layout = QVBoxLayout(self) self.zoom_panel = ZoomPanel(self) self.v_layout.addWidget(self.zoom_panel) @@ -34,21 +60,6 @@ class BeamlineControls(QFrame): self.smargon_panel = SmargonPanel(parent=self) self.v_layout.addWidget(self.smargon_panel) - self.samcam = SamcamPanel(self) - self.v_layout.addWidget(self.samcam) - - if staff: - self.monochromator_panel = MonochromatorPanel(self) - self.abr_tweak = AbrTweakWidget(self) - self.beam_mark = BeamMarkWidget(self) - self.beam_center = BeamCenterWidget(self) - self.beam_size = BeamSizeWidget(self) - - self.v_layout.addWidget(self.monochromator_panel) - self.v_layout.addWidget(self.abr_tweak) - self.v_layout.addWidget(self.beam_mark) - self.v_layout.addWidget(self.beam_center) - self.v_layout.addWidget(self.beam_size) - self.v_layout.addStretch() + tighten_column(self.v_layout) self.setLayout(self.v_layout) diff --git a/src/aare/gui/panels/beamline_recovery_panel.py b/src/aare/gui/panels/beamline_recovery_panel.py index 17933205..aa5c5a85 100644 --- a/src/aare/gui/panels/beamline_recovery_panel.py +++ b/src/aare/gui/panels/beamline_recovery_panel.py @@ -16,6 +16,21 @@ from PySide6.QtWidgets import ( ) from aare.gui.constants import LOGGER_NAME +from aare.gui.styles import ( + BAD_CARD_BORDER, + CHIP_BAD_BG, + CHIP_BAD_TEXT, + CHIP_INFO_TEXT, + CHIP_WARN_BG, + CHIP_WARN_TEXT, + FLAT_CARD_RADIUS, + INFO_CARD_BG, + INFO_CARD_BORDER, + PENDING_CARD_BG, + PENDING_CARD_BORDER, + WARN_CARD_BORDER, + card_style, +) from aare.gui.threads.daq_worker import DAQWorker logger = setup_logger(LOGGER_NAME) @@ -36,14 +51,14 @@ class RecoveryPanel(QWidget): ) self._warning_primary.setWordWrap(True) self._warning_primary.setStyleSheet( - "QLabel {" - " background: #fff3cd;" - " color: #7a4b00;" - " border: 1px solid #f0c36d;" - " border-radius: 8px;" - " padding: 10px;" - " font-weight: 600;" - "}" + card_style( + CHIP_WARN_BG, + WARN_CARD_BORDER, + CHIP_WARN_TEXT, + selector="QLabel", + radius=FLAT_CARD_RADIUS, + extra="padding: 10px; font-weight: 600;", + ) ) layout.addWidget(self._warning_primary) @@ -53,95 +68,95 @@ class RecoveryPanel(QWidget): ) self._warning_secondary.setWordWrap(True) self._warning_secondary.setStyleSheet( - "QLabel {" - " background: #fdeaea;" - " color: #8b1e1e;" - " border: 1px solid #e6a8a8;" - " border-radius: 8px;" - " padding: 10px;" - " font-weight: 600;" - "}" + card_style( + CHIP_BAD_BG, + BAD_CARD_BORDER, + CHIP_BAD_TEXT, + selector="QLabel", + radius=FLAT_CARD_RADIUS, + extra="padding: 10px; font-weight: 600;", + ) ) layout.addWidget(self._warning_secondary) self._last_action = QLabel("Last action: -", self) self._last_action.setWordWrap(True) self._last_action.setStyleSheet( - "QLabel {" - " background: #eef6ff;" - " color: #12406a;" - " border: 1px solid #a8c7e6;" - " border-radius: 8px;" - " padding: 10px;" - " font-weight: 600;" - "}" + card_style( + INFO_CARD_BG, + INFO_CARD_BORDER, + CHIP_INFO_TEXT, + selector="QLabel", + radius=FLAT_CARD_RADIUS, + extra="padding: 10px; font-weight: 600;", + ) ) layout.addWidget(self._last_action) self._take_over_btn = QPushButton("Take over beamline", self) self._take_over_btn.setStyleSheet( - "QPushButton {" - " background: #fff7db;" - " border: 1px solid #e7cb73;" - " border-radius: 8px;" - " padding: 10px;" - " font-weight: 600;" - "}" + card_style( + PENDING_CARD_BG, + PENDING_CARD_BORDER, + selector="QPushButton", + radius=FLAT_CARD_RADIUS, + extra="padding: 10px; font-weight: 600;", + ) ) self._take_over_btn.clicked.connect(self._take_over_beamline) layout.addWidget(self._take_over_btn) self._free_beamline_btn = QPushButton("Free beamline", self) self._free_beamline_btn.setStyleSheet( - "QPushButton {" - " background: #fff7db;" - " border: 1px solid #e7cb73;" - " border-radius: 8px;" - " padding: 10px;" - " font-weight: 600;" - "}" + card_style( + PENDING_CARD_BG, + PENDING_CARD_BORDER, + selector="QPushButton", + radius=FLAT_CARD_RADIUS, + extra="padding: 10px; font-weight: 600;", + ) ) self._free_beamline_btn.clicked.connect(self._free_beamline) layout.addWidget(self._free_beamline_btn) self._recover_beamline_btn = QPushButton("Recover beamline", self) self._recover_beamline_btn.setStyleSheet( - "QPushButton {" - " background: #fdeaea;" - " color: #8b1e1e;" - " border: 1px solid #e6a8a8;" - " border-radius: 8px;" - " padding: 10px;" - " font-weight: 700;" - "}" + card_style( + CHIP_BAD_BG, + BAD_CARD_BORDER, + CHIP_BAD_TEXT, + selector="QPushButton", + radius=FLAT_CARD_RADIUS, + extra="padding: 10px; font-weight: 700;", + ) ) self._recover_beamline_btn.clicked.connect(self._recover_beamline) layout.addWidget(self._recover_beamline_btn) self._recovery_unmount_btn = QPushButton("Unmount sample (recovery)", self) self._recovery_unmount_btn.setStyleSheet( - "QPushButton {" - " background: #fdeaea;" - " color: #8b1e1e;" - " border: 1px solid #e6a8a8;" - " border-radius: 8px;" - " padding: 10px;" - " font-weight: 700;" - "}" + card_style( + CHIP_BAD_BG, + BAD_CARD_BORDER, + CHIP_BAD_TEXT, + selector="QPushButton", + radius=FLAT_CARD_RADIUS, + extra="padding: 10px; font-weight: 700;", + ) ) self._recovery_unmount_btn.clicked.connect(self._recovery_unmount_sample) layout.addWidget(self._recovery_unmount_btn) self._resync_sample_btn = QPushButton("Resync sample from TELL", self) self._resync_sample_btn.setStyleSheet( - "QPushButton {" - " background: #eef6ff;" - " color: #12406a;" - " border: 1px solid #a8c7e6;" - " border-radius: 8px;" - " padding: 10px;" - " font-weight: 600;" - "}" + card_style( + INFO_CARD_BG, + INFO_CARD_BORDER, + CHIP_INFO_TEXT, + selector="QPushButton", + radius=FLAT_CARD_RADIUS, + extra="padding: 10px; font-weight: 600;", + ) ) self._resync_sample_btn.clicked.connect(self._resync_sample) layout.addWidget(self._resync_sample_btn) diff --git a/src/aare/gui/panels/beamline_state_panel.py b/src/aare/gui/panels/beamline_state_panel.py index 26210c41..50536ad8 100644 --- a/src/aare/gui/panels/beamline_state_panel.py +++ b/src/aare/gui/panels/beamline_state_panel.py @@ -1,41 +1,75 @@ -from collections import deque -from dataclasses import dataclass +from typing import ClassVar from aarecommon.models.models import BeamlineStateEnum, DAQStatusModel -from PySide6.QtCore import QPoint, QRect, Qt, Signal, Slot -from PySide6.QtGui import QColor, QPainter, QPen -from PySide6.QtWidgets import QFrame, QLabel, QPushButton +from PySide6.QtCore import Qt, QTimer, Signal, Slot +from PySide6.QtGui import QCursor, QFont, QFontMetrics +from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QMenu, QPushButton, QSizePolicy, QToolTip + +from aare.gui.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 +# routes between states (_GRAPH), e.g. Manual sample exchange -> Sample +# alignment skipping the robot station, and Maintenance which sits outside +# the route graph. Keep in sync until the server exposes transitions. +_TO_SAMPLE_ALIGNMENT = frozenset({BeamlineStateEnum.SampleAlignment}) +MENU_TRANSITIONS: dict[BeamlineStateEnum, frozenset[BeamlineStateEnum]] = { + BeamlineStateEnum.RobotSampleExchange: _TO_SAMPLE_ALIGNMENT, + BeamlineStateEnum.SampleExchange: _TO_SAMPLE_ALIGNMENT, + BeamlineStateEnum.DewarTransfer: _TO_SAMPLE_ALIGNMENT, + BeamlineStateEnum.BeamLocation: _TO_SAMPLE_ALIGNMENT, + BeamlineStateEnum.DataCollection: _TO_SAMPLE_ALIGNMENT, + BeamlineStateEnum.XrayFluorescence: _TO_SAMPLE_ALIGNMENT, + BeamlineStateEnum.XtalSnapshot: _TO_SAMPLE_ALIGNMENT, + BeamlineStateEnum.SampleAlignment: frozenset( + { + BeamlineStateEnum.SampleExchange, + BeamlineStateEnum.DewarTransfer, + BeamlineStateEnum.BeamLocation, + } + ), + BeamlineStateEnum.Maintenance: frozenset({BeamlineStateEnum.SampleExchange}), +} + +# One-hop routes between states (the former map's segments). +_SEGMENTS = [ + (BeamlineStateEnum.DewarTransfer, BeamlineStateEnum.SampleExchange), + (BeamlineStateEnum.SampleExchange, BeamlineStateEnum.RobotSampleExchange), + (BeamlineStateEnum.RobotSampleExchange, BeamlineStateEnum.SampleAlignment), + (BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.BeamLocation), + (BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.BeamstopAlignment), + (BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.FluxMeasurement), + (BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.DataCollection), + (BeamlineStateEnum.BeamLocation, BeamlineStateEnum.BeamstopAlignment), + (BeamlineStateEnum.BeamLocation, BeamlineStateEnum.FluxMeasurement), + (BeamlineStateEnum.BeamstopAlignment, BeamlineStateEnum.FluxMeasurement), + (BeamlineStateEnum.DataCollection, BeamlineStateEnum.XtalSnapshot), + (BeamlineStateEnum.DataCollection, BeamlineStateEnum.XrayFluorescence), +] -@dataclass(frozen=True) -class StationSpec: - state: BeamlineStateEnum - label: str - x: int - y: int - clickable: bool = False - tooltip: str = "" +def _build_graph( + segments: list[tuple[BeamlineStateEnum, BeamlineStateEnum]], +) -> dict[BeamlineStateEnum, set[BeamlineStateEnum]]: + graph: dict[BeamlineStateEnum, set[BeamlineStateEnum]] = {} + for a, b in segments: + graph.setdefault(a, set()).add(b) + graph.setdefault(b, set()).add(a) + return graph -class HoverableLabel(QLabel): - hovered = Signal(object) - unhovered = Signal() - - def enterEvent(self, event) -> None: - self.hovered.emit(getattr(self, "_beamline_state", None)) - super().enterEvent(event) - - def leaveEvent(self, event) -> None: - self.unhovered.emit() - super().leaveEvent(event) +_GRAPH = _build_graph(_SEGMENTS) class HoverableButton(QPushButton): hovered = Signal(object) unhovered = Signal() + # Set by the panel right after construction; carried in hover signals. + _beamline_state: BeamlineStateEnum | None = None + def enterEvent(self, event) -> None: - self.hovered.emit(getattr(self, "_beamline_state", None)) + self.hovered.emit(self._beamline_state) super().enterEvent(event) def leaveEvent(self, event) -> None: @@ -44,6 +78,13 @@ class HoverableButton(QPushButton): class BeamlineStatePanel(QFrame): + """Slim horizontal state strip shown directly above the status bar. + + Replaces the former vertical station map: two-line entries in beamline + order, colored by availability (blue/red = active, orange = reachable in + one transition, grey = not reachable). + """ + sample_exchange = Signal() sample_alignment = Signal() dewar_exchange = Signal() @@ -55,332 +96,192 @@ class BeamlineStatePanel(QFrame): beamstop_alignment = Signal() flux_measurement = Signal() - set_width = 400 - map_height = 542 - title_height = 50 - collapsed_height = 50 - station_radius = 8 + # Physical beamline order. Labels are one line when the bar is wide + # enough and wrap at the last space to two lines when it is not. + _ENTRIES: tuple[tuple[BeamlineStateEnum, str], ...] = ( + (BeamlineStateEnum.Maintenance, "Maintenance mode"), + (BeamlineStateEnum.BeamLocation, "Beam location"), + (BeamlineStateEnum.BeamstopAlignment, "Beamstop alignment"), + (BeamlineStateEnum.FluxMeasurement, "Flux measurement"), + (BeamlineStateEnum.SampleAlignment, "Sample alignment"), + (BeamlineStateEnum.SampleExchange, "Manual sample exchange"), + (BeamlineStateEnum.RobotSampleExchange, "Robot sample exchange"), + (BeamlineStateEnum.DewarTransfer, "Dewar transfer"), + (BeamlineStateEnum.DataCollection, "Data collection"), + (BeamlineStateEnum.XtalSnapshot, "Crystal snapshot"), + (BeamlineStateEnum.XrayFluorescence, "X-ray fluorescence"), + ) + + _TOOLTIPS: ClassVar[dict[BeamlineStateEnum, str]] = { + BeamlineStateEnum.DewarTransfer: "Dewar transfer mode", + BeamlineStateEnum.SampleExchange: "Manual sample exchange mode", + BeamlineStateEnum.RobotSampleExchange: "Robot-assisted sample exchange", + BeamlineStateEnum.SampleAlignment: "Sample centring and alignment mode", + BeamlineStateEnum.BeamLocation: "Beam location mode", + BeamlineStateEnum.BeamstopAlignment: "Beamstop alignment mode", + BeamlineStateEnum.FluxMeasurement: "Flux measurement mode", + BeamlineStateEnum.DataCollection: "Measurement / collection mode", + BeamlineStateEnum.XtalSnapshot: "Crystal snapshot mode", + BeamlineStateEnum.XrayFluorescence: "X-ray fluorescence mode", + } def __init__(self, parent=None): super().__init__(parent) self.setObjectName("beamlineStatePanel") - self.setFrameShape(QFrame.Shape.StyledPanel) - self.setFrameShadow(QFrame.Shadow.Raised) - self.setFixedWidth(self.set_width) - self._is_collapsed = False - self.setMinimumHeight(self.map_height) - self.setMaximumHeight(self.map_height) self._current_state: BeamlineStateEnum | None = None self._hovered_state: BeamlineStateEnum | None = None self._pending_target_state: BeamlineStateEnum | None = None - self._last_stable_state: BeamlineStateEnum | None = None - self._line_color = QColor(111, 129, 160) - self._line_current = QColor(0, 126, 229) - self._line_hover = QColor(244, 196, 48) + # After 3 s of hovering an unavailable state, explain which states + # it can be reached from. + self._hover_hint_timer = QTimer(self) + self._hover_hint_timer.setSingleShot(True) + self._hover_hint_timer.setInterval(3000) + self._hover_hint_timer.timeout.connect(self._show_hover_hint) - self._station_current = QColor(0, 126, 229) - self._station_current_ring = QColor(120, 195, 255) - self._station_hover = QColor(244, 196, 48) - self._label_current_bg = "rgba(0, 126, 229, 0.12)" - self._label_hover_bg = "rgba(244, 196, 48, 0.22)" + # Per-theme colors (MainWindow._apply_theme calls set_theme). + self._colors = state_colors(THEME_SUNRISE) + self._separators: list[QLabel] = [] - self._group_colors: dict[BeamlineStateEnum, QColor] = { - BeamlineStateEnum.DewarTransfer: QColor(128, 90, 213), - BeamlineStateEnum.SampleExchange: QColor(237, 137, 54), - BeamlineStateEnum.RobotSampleExchange: QColor(237, 137, 54), - BeamlineStateEnum.SampleAlignment: QColor(72, 187, 120), - BeamlineStateEnum.BeamLocation: QColor(72, 187, 120), - BeamlineStateEnum.BeamstopAlignment: QColor(72, 187, 120), - BeamlineStateEnum.FluxMeasurement: QColor(72, 187, 120), - BeamlineStateEnum.DataCollection: QColor(236, 72, 153), - BeamlineStateEnum.XtalSnapshot: QColor(236, 72, 153), - BeamlineStateEnum.XrayFluorescence: QColor(236, 72, 153), - } + layout = QHBoxLayout(self) + layout.setContentsMargins(10, 2, 10, 2) + layout.setSpacing(2) + layout.addStretch(1) - self._group_label_colors: dict[BeamlineStateEnum, str] = { - state: color.name() for state, color in self._group_colors.items() - } - - self._group_label_backgrounds: dict[BeamlineStateEnum, str] = { - BeamlineStateEnum.DewarTransfer: "rgba(128, 90, 213, 0.14)", - BeamlineStateEnum.SampleExchange: "rgba(237, 137, 54, 0.16)", - BeamlineStateEnum.RobotSampleExchange: "rgba(237, 137, 54, 0.16)", - BeamlineStateEnum.SampleAlignment: "rgba(72, 187, 120, 0.16)", - BeamlineStateEnum.BeamLocation: "rgba(72, 187, 120, 0.16)", - BeamlineStateEnum.BeamstopAlignment: "rgba(72, 187, 120, 0.16)", - BeamlineStateEnum.FluxMeasurement: "rgba(72, 187, 120, 0.16)", - BeamlineStateEnum.DataCollection: "rgba(236, 72, 153, 0.14)", - BeamlineStateEnum.XtalSnapshot: "rgba(236, 72, 153, 0.14)", - BeamlineStateEnum.XrayFluorescence: "rgba(236, 72, 153, 0.14)", - } - - self._stations = [ - StationSpec( - BeamlineStateEnum.DewarTransfer, - "Dewar transfer", - 54, - 140, - True, - "Dewar transfer mode", - ), - StationSpec( - BeamlineStateEnum.SampleExchange, - "Manual sample exchange", - 54, - 176, - True, - "Manual sample exchange mode", - ), - StationSpec( - BeamlineStateEnum.RobotSampleExchange, - "Robot sample exchange", - 54, - 212, - True, - "Robot-assisted sample exchange", - ), - StationSpec( - BeamlineStateEnum.SampleAlignment, - "Sample alignment", - 54, - 248, - True, - "Sample centring and alignment mode", - ), - StationSpec( - BeamlineStateEnum.BeamLocation, "Beam location", 54, 284, True, "Beam location mode" - ), - StationSpec( - BeamlineStateEnum.BeamstopAlignment, - "Beamstop alignment", - 54, - 320, - True, - "Beamstop alignment mode", - ), - StationSpec( - BeamlineStateEnum.FluxMeasurement, - "Flux measurement", - 54, - 356, - True, - "Flux measurement mode", - ), - StationSpec( - BeamlineStateEnum.DataCollection, - "Data collection", - 54, - 392, - True, - "Measurement / collection mode", - ), - StationSpec( - BeamlineStateEnum.XtalSnapshot, - "Crystal snapshot", - 54, - 428, - True, - "Crystal snapshot mode", - ), - StationSpec( - BeamlineStateEnum.XrayFluorescence, "XRF", 54, 464, True, "X-ray fluorescence mode" - ), - ] - - self._segments = [ - (BeamlineStateEnum.DewarTransfer, BeamlineStateEnum.SampleExchange), - (BeamlineStateEnum.SampleExchange, BeamlineStateEnum.RobotSampleExchange), - (BeamlineStateEnum.RobotSampleExchange, BeamlineStateEnum.SampleAlignment), - (BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.BeamLocation), - (BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.BeamstopAlignment), - (BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.FluxMeasurement), - (BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.DataCollection), - (BeamlineStateEnum.BeamLocation, BeamlineStateEnum.BeamstopAlignment), - (BeamlineStateEnum.BeamLocation, BeamlineStateEnum.FluxMeasurement), - (BeamlineStateEnum.BeamstopAlignment, BeamlineStateEnum.FluxMeasurement), - (BeamlineStateEnum.DataCollection, BeamlineStateEnum.XtalSnapshot), - (BeamlineStateEnum.DataCollection, BeamlineStateEnum.XrayFluorescence), - ] - - self._graph = self._build_graph(self._segments) - self._station_widgets: dict[BeamlineStateEnum, QLabel | QPushButton] = {} - - self.title = QLabel(self) - self.title.setObjectName("beamlineStateTitle") - self.title.setText("

Beamline state

") - self.title.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.title.setFixedHeight(self.title_height) - self.title.setGeometry(0, 0, self.set_width, self.title_height) - - self.toggle_button = QPushButton("−", self) - self.toggle_button.setObjectName("beamlineStateToggleButton") - self.toggle_button.setToolTip("Minimise beamline state panel") - self.toggle_button.setFixedSize(28, 28) - self.toggle_button.move(self.set_width - 36, 11) - self.toggle_button.clicked.connect(self.toggle_collapsed) - - self.current_label = QLabel("Current: —", self) - self.current_label.setObjectName("beamlineStateCurrentLabel") - self.current_label.move(14, 58) - self.current_label.adjustSize() - - self.tell_label = QLabel("Tell: —", self) - self.tell_label.setObjectName("beamlineStateTellLabel") - self.tell_label.move(14, 86) - self.tell_label.adjustSize() - - self._build_station_widgets() - self._position_station_widgets() - self._update_collapsed_state() - - @staticmethod - def _canon_segment( - a: BeamlineStateEnum, b: BeamlineStateEnum - ) -> tuple[BeamlineStateEnum, BeamlineStateEnum]: - return tuple(sorted((a, b), key=lambda state: state.value)) - - def _build_graph( - self, segments: list[tuple[BeamlineStateEnum, BeamlineStateEnum]] - ) -> dict[BeamlineStateEnum, set[BeamlineStateEnum]]: - graph: dict[BeamlineStateEnum, set[BeamlineStateEnum]] = {} - for a, b in segments: - graph.setdefault(a, set()).add(b) - graph.setdefault(b, set()).add(a) - return graph - - def _station_map(self) -> dict[BeamlineStateEnum, StationSpec]: - return {station.state: station for station in self._stations} - - def _path_segments_between( - self, start: BeamlineStateEnum | None, end: BeamlineStateEnum | None - ) -> set[tuple[BeamlineStateEnum, BeamlineStateEnum]]: - if start is None or end is None: - return set() - - if start == BeamlineStateEnum.Moving or end == BeamlineStateEnum.Moving: - return set() - - if start == end: - return set() - - queue = deque([start]) - previous: dict[BeamlineStateEnum, BeamlineStateEnum | None] = {start: None} - - while queue: - node = queue.popleft() - if node == end: - break - - for neighbour in self._graph.get(node, set()): - if neighbour in previous: - continue - previous[neighbour] = node - queue.append(neighbour) - - if end not in previous: - return set() - - path_segments: set[tuple[BeamlineStateEnum, BeamlineStateEnum]] = set() - cursor = end - while previous[cursor] is not None: - parent = previous[cursor] - path_segments.add(self._canon_segment(cursor, parent)) - cursor = parent - - return path_segments - - def _active_hover_route(self) -> set[tuple[BeamlineStateEnum, BeamlineStateEnum]]: - if self._hovered_state is not None: - route_source = ( - self._last_stable_state - if self._current_state == BeamlineStateEnum.Moving - else self._current_state + self._buttons: dict[BeamlineStateEnum, HoverableButton] = {} + self._single_line = True + for index, (state, label) in enumerate(self._ENTRIES): + if index: + separator = QLabel("–", self) + self._style_separator(separator) + self._separators.append(separator) + layout.addWidget(separator) + button = HoverableButton(label, self) + button.setFlat(True) + # Fill the bar height so the hover region is the whole entry, + # not just the text line. + button.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding) + button._beamline_state = state + # Transitions only via right-click -> "Go to "; a plain + # left click must not move the beamline. + button.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + button.customContextMenuRequested.connect( + lambda pos, s=state, b=button: self._show_state_menu(s, b, pos) ) - return self._path_segments_between(route_source, self._hovered_state) + button.clicked.connect(lambda _checked=False, s=state: self._on_left_click(s)) + button.hovered.connect(self._set_hovered_state) + button.unhovered.connect(self._clear_hovered_state) + self._buttons[state] = button + layout.addWidget(button) - if ( - self._current_state == BeamlineStateEnum.Moving - and self._pending_target_state is not None - ): - return self._path_segments_between(self._last_stable_state, self._pending_target_state) + layout.addStretch(1) + self._apply_highlight() - return set() + def minimumSizeHint(self): + # The 13-button strip would otherwise impose a ~2000px minimum on the + # whole main window, so an un-maximized window could never fit the + # screen. Width 0: the strip adapts (two-line labels) and, below that, + # clips — the window stays freely resizable. + hint = super().minimumSizeHint() + hint.setWidth(0) + return hint - def _build_station_widgets(self) -> None: - for station in self._stations: - if station.clickable: - widget: QLabel | QPushButton = HoverableButton(station.label, self) - widget.setFlat(True) - widget.setCursor(Qt.CursorShape.PointingHandCursor) - widget.clicked.connect( - lambda _checked=False, state=station.state: self._emit_for_state(state) - ) - else: - widget = HoverableLabel(station.label, self) + def resizeEvent(self, event) -> None: + super().resizeEvent(event) + self._update_label_mode() - widget._beamline_state = station.state - widget.hovered.connect(self._set_hovered_state) - widget.unhovered.connect(self._clear_hovered_state) - widget.setToolTip(station.tooltip or station.label) - self._station_widgets[station.state] = widget - - self._apply_station_highlight() - - def _position_station_widgets(self) -> None: - station_map = self._station_map() - - for state, widget in self._station_widgets.items(): - station = station_map[state] - widget.setText(station.label) - widget.adjustSize() - - label_x = station.x + 20 - label_y = station.y - 12 - - widget.move(label_x, label_y) - widget.show() - - self._apply_station_highlight() - - def toggle_collapsed(self) -> None: - self._is_collapsed = not self._is_collapsed - self._update_collapsed_state() - - def set_collapsed(self, collapsed: bool) -> None: - if self._is_collapsed == collapsed: + def _update_label_mode(self) -> None: + # One line while it fits, wrapped at the last space otherwise. + # Measured with bold so the mode does not flap when the active + # state (the only bold entry) changes. + font = QFont(self.font()) + font.setPixelSize(18) + font.setWeight(QFont.Weight.Bold) + fm = QFontMetrics(font) + needed = 20 # layout margins + for index, (_state, label) in enumerate(self._ENTRIES): + if index: + needed += fm.horizontalAdvance("–") + 4 + needed += fm.horizontalAdvance(label) + 20 # padding + frame + single_line = needed <= self.width() + if single_line == self._single_line: return - self._is_collapsed = collapsed - self._update_collapsed_state() + self._single_line = single_line + for state, label in self._ENTRIES: + text = label if single_line else "\n".join(label.rsplit(" ", 1)) + self._buttons[state].setText(text) - def _update_collapsed_state(self) -> None: - show_content = not self._is_collapsed + def _show_state_menu(self, state: BeamlineStateEnum, button: QPushButton, pos) -> None: + if state not in self._available_targets(): + return + menu = QMenu(button) + go_action = menu.addAction(f"Go to {state.display_name()}") + go_action.triggered.connect(lambda: self._emit_for_state(state)) + menu.exec(button.mapToGlobal(pos)) - self.current_label.setVisible(show_content) - self.tell_label.setVisible(show_content) - - for widget in self._station_widgets.values(): - widget.setVisible(show_content) - - if self._is_collapsed: - self.setMinimumHeight(self.collapsed_height) - self.setMaximumHeight(self.collapsed_height) - self.toggle_button.setText("+") - self.toggle_button.setToolTip("Restore beamline state panel") + def _on_left_click(self, state: BeamlineStateEnum) -> None: + # Left click never moves the beamline: remind about right-click for + # available states, explain unreachability for the rest. + if state == self._current_state: + return + if state in self._available_targets(): + button = self._buttons[state] + QToolTip.showText( + QCursor.pos(), + f"Right-click to go to {state.display_name()}.", + button, + button.rect(), + ) else: - self.setMinimumHeight(self.map_height) - self.setMaximumHeight(self.map_height) - self.toggle_button.setText("−") - self.toggle_button.setToolTip("Minimise beamline state panel") + self._show_unavailable_hint(state) - self.updateGeometry() - self.update() + def _available_targets(self) -> frozenset[BeamlineStateEnum]: + current = self._current_state + if current is None or current == BeamlineStateEnum.Moving: + return frozenset() + # Reachable in one step: the route graph plus the status-bar + # shortcut transitions. + return frozenset(_GRAPH.get(current, set())) | MENU_TRANSITIONS.get(current, frozenset()) + + def _set_hovered_state(self, state: BeamlineStateEnum | None) -> None: + self._hovered_state = state + self._hover_hint_timer.start() + + @Slot() + def _clear_hovered_state(self) -> None: + self._hovered_state = None + self._hover_hint_timer.stop() + QToolTip.hideText() + + def _show_hover_hint(self) -> None: + state = self._hovered_state + if state is None or state == self._current_state or state in self._available_targets(): + return + self._show_unavailable_hint(state) + + def _show_unavailable_hint(self, state: BeamlineStateEnum) -> None: + sources = set(_GRAPH.get(state, set())) + sources |= {s for s, targets in MENU_TRANSITIONS.items() if state in targets} + sources.discard(state) + if not sources: + return + reachable = ", ".join(sorted(s.display_name() for s in sources)) + button = self._buttons[state] + # Anchoring to the button rect makes Qt drop the tip as soon as the + # mouse leaves the entry, instead of letting it linger. + QToolTip.showText( + QCursor.pos(), + f"You can only go to {state.display_name()} by being in: {reachable}.", + button, + button.rect(), + ) def _emit_for_state(self, state: BeamlineStateEnum) -> None: + # Unavailable transitions are not clickable (grey + forbidden cursor). + if state not in self._available_targets(): + return self._pending_target_state = state - self._hovered_state = None - self.update() if state == BeamlineStateEnum.SampleExchange: self.sample_exchange.emit() @@ -403,236 +304,99 @@ class BeamlineStatePanel(QFrame): elif state == BeamlineStateEnum.XrayFluorescence: self.xray_fluorescence.emit() - @Slot(object) - def _set_hovered_state(self, state: BeamlineStateEnum | None) -> None: - self._hovered_state = state - self._apply_station_highlight() + def _style_separator(self, separator: QLabel) -> None: + separator.setStyleSheet( + f"color: {self._colors['unavailable']};" + f" background: transparent; border: none; font-size: {FONT_VALUE};" + ) - @Slot() - def _clear_hovered_state(self) -> None: - self._hovered_state = None - self._apply_station_highlight() + 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_station_highlight(self) -> None: - for station in self._stations: - widget = self._station_widgets[station.state] - is_current = station.state == self._current_state - is_hovered = station.state == self._hovered_state + def _apply_highlight(self) -> None: + available = self._available_targets() + for state, button in self._buttons.items(): + is_current = state == self._current_state is_pending = ( - station.state == self._pending_target_state + state == self._pending_target_state and self._current_state == BeamlineStateEnum.Moving ) + is_available = state in available - label_color = self._group_label_colors.get(station.state, "rgb(55, 67, 87)") - label_bg = self._group_label_backgrounds.get(station.state, "transparent") - - if isinstance(widget, QPushButton): - if is_current: - widget.setStyleSheet(f""" - QPushButton {{ - border: none; - border-radius: 10px; - background: {self._label_current_bg}; - color: rgb(0, 92, 170); - font-size: 14px; - font-weight: 700; - text-align: left; - padding: 2px 6px 2px 8px; - }} - QPushButton:hover {{ - color: rgb(0, 92, 170); - }} - """) - elif is_hovered or is_pending: - widget.setStyleSheet(f""" - QPushButton {{ - border: none; - border-radius: 10px; - background: {self._label_hover_bg}; - color: rgb(115, 88, 0); - font-size: 14px; - font-weight: 700; - text-align: left; - padding: 2px 6px 2px 8px; - }} - QPushButton:hover {{ - color: rgb(115, 88, 0); - }} - """) - else: - widget.setStyleSheet(f""" - QPushButton {{ - border: none; - border-radius: 10px; - background: {label_bg}; - color: {label_color}; - font-size: 14px; - font-weight: 600; - text-align: left; - padding: 2px 6px 2px 8px; - }} - QPushButton:hover {{ - color: {label_color}; - }} - """) - else: - if is_current: - widget.setStyleSheet(f""" - QLabel {{ - border-radius: 10px; - background: {self._label_current_bg}; - color: rgb(0, 92, 170); - font-size: 14px; - font-weight: 700; - padding: 2px 6px 2px 8px; - }} - """) - elif is_hovered or is_pending: - widget.setStyleSheet(f""" - QLabel {{ - border-radius: 10px; - background: {self._label_hover_bg}; - color: rgb(115, 88, 0); - font-size: 14px; - font-weight: 700; - padding: 2px 6px 2px 8px; - }} - """) - else: - widget.setStyleSheet(f""" - QLabel {{ - border-radius: 10px; - background: {label_bg}; - color: {label_color}; - font-size: 14px; - font-weight: 600; - padding: 2px 6px 2px 8px; - }} - """) - - widget.adjustSize() - - self.update() - - def _station_center(self, state: BeamlineStateEnum) -> QPoint: - station = self._station_map()[state] - return QPoint(station.x, station.y) - - def _segment_color(self, a: BeamlineStateEnum, b: BeamlineStateEnum) -> QColor: - segment = self._canon_segment(a, b) - - active_hover_route = self._active_hover_route() - if segment in active_hover_route: - return self._line_hover - - current_path = self._path_segments_between( - BeamlineStateEnum.DewarTransfer, self._last_stable_state or self._current_state - ) - if segment in current_path: - return self._line_current - - return self._line_color - - def _draw_segment(self, painter: QPainter, start: QPoint, end: QPoint, color: QColor) -> None: - pen = QPen(color, 3) - pen.setCapStyle(Qt.PenCapStyle.RoundCap) - painter.setPen(pen) - painter.drawLine(start, end) - - def _station_base_color(self, state: BeamlineStateEnum) -> QColor: - return self._group_colors.get(state, QColor(180, 190, 210)) - - def _draw_station(self, painter: QPainter, station: StationSpec) -> None: - center = self._station_center(station.state) - rect = QRect( - center.x() - self.station_radius, - center.y() - self.station_radius, - self.station_radius * 2, - self.station_radius * 2, - ) - - if station.state == self._hovered_state or ( - self._current_state == BeamlineStateEnum.Moving - and station.state == self._pending_target_state - ): - painter.setPen(QPen(self._station_hover, 3)) - painter.setBrush(self._station_hover) - elif station.state == self._current_state: - painter.setPen(QPen(self._station_current_ring, 3)) - painter.setBrush(Qt.BrushStyle.NoBrush) - painter.drawEllipse( - QRect( - center.x() - self.station_radius - 3, - center.y() - self.station_radius - 3, - (self.station_radius + 3) * 2, - (self.station_radius + 3) * 2, + # Availability drives the look: active = bold (red for + # Maintenance, blue otherwise), reachable = orange, rest = grey. + # No backgrounds, no rounded corners. + if is_current or is_pending: + color = ( + self._colors["error"] + if state == BeamlineStateEnum.Maintenance + else self._colors["info"] ) - ) - painter.setPen(QPen(self._station_current, 2)) - painter.setBrush(self._station_current) - else: - base_color = self._station_base_color(station.state) - painter.setPen(QPen(base_color.darker(125), 2)) - painter.setBrush(base_color) - - painter.drawEllipse(rect) - - def paintEvent(self, event) -> None: - super().paintEvent(event) - - if self._is_collapsed: - return - - painter = QPainter(self) - painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) - - for start_state, end_state in self._segments: - start = self._station_center(start_state) - end = self._station_center(end_state) - color = self._segment_color(start_state, end_state) - self._draw_segment(painter, start, end, color) - - for station in self._stations: - self._draw_station(painter, station) - - @Slot(DAQStatusModel) - def update_daq_status(self, status: DAQStatusModel) -> None: - self.set_current_state(status.state) - - tell_text = "Tell: —" - tell_color = "rgb(55, 67, 87)" - if status.tell_state is not None: - tell_state = status.tell_state - tell_text = f"Tell: {tell_state.activity.display_name()}" - - tell_phase = tell_state.phase.display_name() if tell_state.phase is not None else "" - tell_message = (tell_state.message or "").strip() - - if tell_phase: - tell_text = f"{tell_text} ({tell_phase})" - elif tell_message: - tell_text = f"{tell_text} ({tell_message})" - - if tell_state.activity.value == "error": - tell_color = "red" - elif tell_state.activity.value in {"mounting", "unmounting", "drying", "cooling"}: - tell_color = "orange" + bold = True + elif is_available: + color = self._colors["available"] + bold = False else: - tell_color = "green" + color = self._colors["unavailable"] + bold = False - self.tell_label.setText(tell_text) - self.tell_label.setStyleSheet(f"color: {tell_color};") - self.tell_label.adjustSize() + # Font set in code (not QSS) so _update_label_mode can measure + # the real metrics when deciding one- vs two-line labels. + font = QFont(self.font()) + font.setPixelSize(18) + font.setWeight(QFont.Weight.Bold if bold else QFont.Weight.Normal) + if button.font() != font: + button.setFont(font) + # Guarded updates: this runs on every DAQ tick, and re-applying an + # unchanged stylesheet repolishes the button, which drops the hover + # cursor under a resting mouse until it moves again. + # 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};{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. + # 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: + cursor = Qt.CursorShape.ForbiddenCursor + tooltip = "" + if button.cursor().shape() != cursor: + button.setCursor(cursor) + if button.toolTip() != tooltip: + button.setToolTip(tooltip) def set_current_state(self, state: BeamlineStateEnum | None) -> None: self._current_state = state + if ( + state is not None + and state != BeamlineStateEnum.Moving + and self._pending_target_state == state + ): + self._pending_target_state = None + self._apply_highlight() - if state is not None and state != BeamlineStateEnum.Moving: - self._last_stable_state = state - if self._pending_target_state == state: - self._pending_target_state = None - - label = state.display_name() if state is not None else "—" - self.current_label.setText(f"Current: {label}") - self.current_label.adjustSize() - self._apply_station_highlight() + def update_daq_status(self, status: DAQStatusModel) -> None: + self.set_current_state(status.state) diff --git a/src/aare/gui/panels/data_collection_settings.py b/src/aare/gui/panels/data_collection_settings.py index 40a0b6b8..3a5fbc14 100644 --- a/src/aare/gui/panels/data_collection_settings.py +++ b/src/aare/gui/panels/data_collection_settings.py @@ -2,14 +2,26 @@ from aarecommon.math.diffraction_geometry import DiffractionGeometry from aarecommon.math.sample_geometry import SampleGeometryModel from aarecommon.models.models import DAQStatusModel from PySide6.QtCore import Signal, Slot -from PySide6.QtWidgets import QFrame, QPushButton, QTabWidget, QVBoxLayout +from PySide6.QtWidgets import ( + QFrame, + QHBoxLayout, + QPushButton, + QSizePolicy, + QStackedWidget, + QTabBar, + QVBoxLayout, + QWidget, +) from aare.gui.panels.file_path_panel import FilePathPanel from aare.gui.panels.fluorescence_data_collection import FluorescenceDataCollectionPanel +from aare.gui.panels.manual_sample_panel import ManualSamplePanel from aare.gui.panels.raster_data_collection import RasterDataCollectionPanel from aare.gui.panels.rotation_data_collection import RotationDataCollectionPanel from aare.gui.panels.smart_rotation_panel import SimpleRotationSettingsPanel from aare.gui.scan_logic.raster_grid_manager import RasterGridManager +from aare.gui.styles import BANNER_TAB_GAP +from aare.gui.widgets.title_label import TitleLabel, tighten_column class DataCollectionSettings(QFrame): @@ -25,6 +37,7 @@ class DataCollectionSettings(QFrame): parent=None, ): super().__init__(parent) + self.setObjectName("dataCollectionSettings") self.setFixedWidth(self.set_width) self.setFrameShape(QFrame.Shape.StyledPanel) self.setFrameShadow(QFrame.Shadow.Raised) @@ -33,29 +46,80 @@ class DataCollectionSettings(QFrame): self.file_path_panel = FilePathPanel(self) v_layout.addWidget(self.file_path_panel) - self._tab_widget = QTabWidget() + # Between Dataset path and Exp. Config., collapsible like both; + # main_window aliases this instead of the former bottom dock. + self.manual_sample_panel = ManualSamplePanel(self) + v_layout.addWidget(self.manual_sample_panel) + + # QTabBar + QStackedWidget instead of QTabWidget: the loop-centering + # button row must sit BETWEEN the tab bar and the pages, which a + # QTabWidget cannot host. + self._tab_bar = QTabBar(self) + self._stack = QStackedWidget(self) self.raster = RasterDataCollectionPanel( parent=self, raster_mgr=raster_mgr, diffraction=diffraction ) - self._tab_widget.addTab(self.raster, "Raster scan") - self.screening = RotationDataCollectionPanel(parent=self, diffraction=diffraction) - self._tab_widget.addTab(self.screening, "Rotation") - self.simple = SimpleRotationSettingsPanel(parent=self) - self._tab_widget.addTab(self.simple, "Simple") - self.fluo = FluorescenceDataCollectionPanel(parent=self) - self._tab_widget.addTab(self.fluo, "XRF") + for panel, label in ( + (self.raster, "Raster scan"), + (self.screening, "Rotation"), + (self.simple, "Simple"), + (self.fluo, "XRF"), + ): + self._stack.addWidget(panel) + self._tab_bar.addTab(label) - v_layout.addWidget(self._tab_widget) + # Ex-"Loop centering" panel buttons; always visible, whatever the tab. + self.find_tip = QPushButton("ML Loop Centring", parent=self) + self.bounding_box = QPushButton("Make Raster Grid", parent=self) + centering_row = QWidget(self) + centering_layout = QHBoxLayout(centering_row) + centering_layout.setContentsMargins(0, 0, 0, 0) + centering_layout.addWidget(self.find_tip) + centering_layout.addWidget(self.bounding_box) + + # Pane frame carries the border QTabWidget::pane used to draw + # (#expConfigPane rule in styles.py). + pane = QFrame(self) + pane.setObjectName("expConfigPane") + pane_layout = QVBoxLayout(pane) + # No bottom padding: the pages' own bottom margins breathe inside the + # border, and the Abort button should hug the pane. + pane_layout.setContentsMargins(6, 6, 6, 0) + pane_layout.addWidget(centering_row) + pane_layout.addWidget(self._stack) + + # Own container: TitleLabel collapse hides its siblings, so without it + # "Exp. Config." would also swallow the dataset path and abort button. + exp_config = QWidget(self) + exp_config_layout = QVBoxLayout(exp_config) + exp_config_layout.setContentsMargins(0, 0, 0, 0) + exp_config_layout.setSpacing(0) # tab bar flush on the pane, like QTabWidget + exp_config_layout.addWidget( + TitleLabel( + "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) + + # Abort lives inside each tab now, under that tab's action buttons; + # all four feed the same cancel signal. + for panel in (self.raster, self.screening, self.simple, self.fluo): + panel.abort_button.clicked.connect(self.cancel_button_clicked) v_layout.addStretch() - - abort_button = QPushButton("Abort measurement", parent=self) - abort_button.setStyleSheet("color: rgb(164, 0, 0);") - abort_button.clicked.connect(self.cancel_button_clicked) - v_layout.addWidget(abort_button) + tighten_column(v_layout) + # Abort hugs the pane: undo the uniform bottom margin tighten_column + # just gave the Exp. Config. group. + m = exp_config_layout.contentsMargins() + exp_config_layout.setContentsMargins(m.left(), m.top(), m.right(), 0) raster_mgr.update_filename(self.file_path_panel.filename) self.screening.update_filename(self.file_path_panel.filename) @@ -67,11 +131,25 @@ class DataCollectionSettings(QFrame): self.file_path_panel.path_updated.connect(self.simple.update_filename) self._sample_id = None - self._tab_widget.currentChanged.connect(self._on_tab_changed) + self._tab_bar.currentChanged.connect(self._stack.setCurrentIndex) + self._tab_bar.currentChanged.connect(self._on_tab_changed) + self._tab_bar.currentChanged.connect(self._sync_stack_height) + self._sync_stack_height(self._tab_bar.currentIndex()) + + @Slot(int) + def _sync_stack_height(self, idx: int): + # QStackedWidget's sizeHint is its TALLEST page, which left a dead gap + # above the Abort button on shorter tabs. Ignored vertical policy on + # hidden pages makes the stack track only the current page's height. + for i in range(self._stack.count()): + page = self._stack.widget(i) + vertical = QSizePolicy.Policy.Preferred if i == idx else QSizePolicy.Policy.Ignored + page.setSizePolicy(QSizePolicy.Policy.Preferred, vertical) + self._stack.adjustSize() @Slot() def switch_to_raster(self): - self._tab_widget.setCurrentIndex(0) + self._tab_bar.setCurrentIndex(0) @Slot() def cancel_button_clicked(self): @@ -84,7 +162,7 @@ class DataCollectionSettings(QFrame): self.simple.update_daq_status(s) if s.sample is not None and s.sample.db_id != self._sample_id: self._sample_id = s.sample.db_id - self._tab_widget.setCurrentIndex(0) + self._tab_bar.setCurrentIndex(0) @Slot(int) def _on_tab_changed(self, idx: int): diff --git a/src/aare/gui/panels/developer_help_dialog.py b/src/aare/gui/panels/developer_help_dialog.py index ef1fd449..734f46e8 100644 --- a/src/aare/gui/panels/developer_help_dialog.py +++ b/src/aare/gui/panels/developer_help_dialog.py @@ -31,6 +31,16 @@ 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, + PANEL_BORDER_DARK, + PANEL_BORDER_LIGHT, + WHITE, + card_style, +) from aare.gui.threads.daq_worker import DAQWorker logger = setup_logger(LOGGER_NAME) @@ -60,13 +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( - "QLabel {" - " background: #f6f6f6;" - " border: 1px solid #d0d0d0;" - " border-radius: 6px;" - " padding: 6px 8px;" - "}" + card_style( + PANEL_BG_SOFT, + PANEL_BORDER, + selector="QLabel", + radius=FLAT_CARD_RADIUS, + extra="padding: 6px 8px;", + ) ) root.addWidget(self._banner) @@ -83,9 +96,8 @@ class DeveloperHelpDialog(QDialog): self._filter.setMinimumHeight(28) self._filter.setStyleSheet( "QLineEdit {" - " background: white;" - " border: 1px solid #bdbdbd;" - " border-radius: 6px;" + f" background: {WHITE};" + f" border: 1px solid {PANEL_BORDER_DARK};" " padding: 4px 8px;" "}" ) @@ -146,7 +158,7 @@ class DeveloperHelpDialog(QDialog): self._details_frame = QFrame(self) self._details_frame.setFrameShape(QFrame.Shape.StyledPanel) self._details_frame.setStyleSheet( - "QFrame { background: #fafafa; border: 1px solid #d0d0d0; border-radius: 6px;}" + card_style(PANEL_BG_FAINT, PANEL_BORDER, radius=FLAT_CARD_RADIUS) ) details_layout = QVBoxLayout(self._details_frame) @@ -177,12 +189,13 @@ class DeveloperHelpDialog(QDialog): self._detail_help.setWordWrap(True) self._detail_help.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self._detail_help.setStyleSheet( - "QLabel {" - " background: white;" - " border: 1px solid #e0e0e0;" - " border-radius: 6px;" - " 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/face_detection_panel.py b/src/aare/gui/panels/face_detection_panel.py index 46c9e5e2..fa8cf5d8 100644 --- a/src/aare/gui/panels/face_detection_panel.py +++ b/src/aare/gui/panels/face_detection_panel.py @@ -6,6 +6,7 @@ from PySide6.QtCore import Signal from PySide6.QtWidgets import QGridLayout, QLabel, QPushButton, QVBoxLayout, QWidget from aare.gui.constants import LOGGER_NAME +from aare.gui.styles import GO_TEXT from aare.gui.widgets.number_line_edit import NumberLineEdit from aare.gui.widgets.title_label import TitleLabel @@ -51,7 +52,7 @@ class FaceDetectionPanel(QWidget): self.steps_enter.newValue.connect(_set_steps) self.face_detection_button = QPushButton("Face Detection") - self.face_detection_button.setStyleSheet("color: rgb(78, 154, 6);") + self.face_detection_button.setStyleSheet(f"color: {GO_TEXT};") self.face_detection_button.clicked.connect(self.run_and_refresh) self._top_layout.addWidget(self.face_detection_button, 3, 0, 1, 3) diff --git a/src/aare/gui/panels/file_path_panel.py b/src/aare/gui/panels/file_path_panel.py index 08f27d11..b18521fa 100644 --- a/src/aare/gui/panels/file_path_panel.py +++ b/src/aare/gui/panels/file_path_panel.py @@ -7,6 +7,7 @@ from aarecommon.models.models import DAQStatusModel, SampleShortInfo from PySide6.QtCore import Qt, Signal, Slot from PySide6.QtWidgets import QGridLayout, QLabel, QLineEdit, QMessageBox, QSpinBox, QWidget +from aare.gui.styles import PATH_WARN_TEXT from aare.gui.widgets.title_label import TitleLabel ## Logic for filenames: @@ -23,6 +24,10 @@ class FilePathPanel(QWidget): def __init__(self, parent=None): super().__init__(parent) grid_layout = QGridLayout(self) + # No horizontal inset: aligns the banner and fields edge-to-edge with + # the zero-margin Exp. Config. section below. + _m = grid_layout.contentsMargins() + grid_layout.setContentsMargins(0, _m.top(), 0, _m.bottom()) self._sample_name = "sample" self._sample_id = -1 self._dewar_pos = "None" @@ -36,11 +41,12 @@ class FilePathPanel(QWidget): self._formatted_date = datetime.now().strftime("%Y%m%d") - grid_layout.addWidget(TitleLabel("Dataset path", self), 0, 0, 1, 2) + grid_layout.addWidget( + TitleLabel("Dataset path", self, collapsible=True, default_collapsed=False), 0, 0, 1, 2 + ) grid_layout.addWidget(QLabel("Directory", parent=self), 1, 0) self.directory_edit = QLineEdit("{date}/{puck}/{pos}", parent=self) - self.directory_edit.setStyleSheet("background-color: rgb(255, 255, 255);") self.directory_edit.setToolTip( "Provide subdirectory for your files. The following macros are allowed:
" "{date} - date in format yyyymmdd
" @@ -54,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("background-color: rgb(255, 255, 255);") self.file_prefix_edit.setToolTip( "Provide file prefix for your files. The following macros are allowed:
" "{date} - date in format yyyymmdd
" @@ -68,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("background-color: rgb(255, 255, 255);") self.run_number_edit.setValue(1) self.run_number_edit.setRange(1, 999) self.run_number_edit.setAlignment(Qt.AlignmentFlag.AlignRight) @@ -156,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( - "color: rgb(200, 0, 0);" if exists else "color: rgb(0, 0, 0);" - ) + # 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/fluorescence_data_collection.py b/src/aare/gui/panels/fluorescence_data_collection.py index 9266dfff..f0aa1df4 100644 --- a/src/aare/gui/panels/fluorescence_data_collection.py +++ b/src/aare/gui/panels/fluorescence_data_collection.py @@ -2,6 +2,7 @@ from aarecommon.models.models import FluorescenceSpectrumParameterModel from PySide6.QtCore import Signal, Slot from PySide6.QtWidgets import QCheckBox, QGridLayout, QLabel, QPushButton, QWidget +from aare.gui.styles import ABORT_TEXT, GO_TEXT from aare.gui.widgets.number_line_edit import NumberLineEdit @@ -34,9 +35,14 @@ class FluorescenceDataCollectionPanel(QWidget): # Run button self.run_btn = QPushButton("Run fluorescence", self) - self.run_btn.setStyleSheet("color: rgb(78, 154, 6);") + self.run_btn.setStyleSheet(f"color: {GO_TEXT};") lay.addWidget(self.run_btn, 4, 0, 1, 3) + # Per-tab Abort (DataCollectionSettings wires it to the DAQ cancel). + self.abort_button = QPushButton("Abort measurement", self) + self.abort_button.setStyleSheet(f"color: {ABORT_TEXT};") + lay.addWidget(self.abort_button, 5, 0, 1, 3) + self.run_btn.clicked.connect(self._emit_params) @Slot() diff --git a/src/aare/gui/panels/fluorescence_panel.py b/src/aare/gui/panels/fluorescence_panel.py index 49e2ccb7..b67f25a7 100644 --- a/src/aare/gui/panels/fluorescence_panel.py +++ b/src/aare/gui/panels/fluorescence_panel.py @@ -3,10 +3,11 @@ from aarecommon.config.logger import setup_logger from aarecommon.models.models import DAQStatusModel, FluorescenceSpectrumOutputModel from PySide6.QtCharts import QChart, QChartView, QLineSeries, QValueAxis from PySide6.QtCore import QEvent, QPointF, Qt, Slot -from PySide6.QtGui import QColor, QPainter, QPen +from PySide6.QtGui import QPainter, QPen from PySide6.QtWidgets import QGraphicsSimpleTextItem, QGridLayout, QLabel, QWidget from aare.gui.constants import LOGGER_NAME +from aare.gui.styles import SPECTRUM_LINE, qcolor logger = setup_logger(LOGGER_NAME) @@ -65,7 +66,7 @@ class FluorescencePanel(QWidget): # Vertical marker line (two-point series) self._vline = QLineSeries() - pen = QPen(QColor("#cc0000")) + pen = QPen(qcolor(SPECTRUM_LINE)) pen.setWidth(2) self._vline.setPen(pen) self.chart.addSeries(self._vline) diff --git a/src/aare/gui/panels/illumination_panel.py b/src/aare/gui/panels/illumination_panel.py index f14094b7..160d00df 100644 --- a/src/aare/gui/panels/illumination_panel.py +++ b/src/aare/gui/panels/illumination_panel.py @@ -13,7 +13,9 @@ class IlluminationPanel(QWidget): super().__init__(parent) grid_layout = QGridLayout(self) - grid_layout.addWidget(TitleLabel("Light", self), 0, 0, 1, 2) + grid_layout.addWidget( + TitleLabel("Light", self, collapsible=True, default_collapsed=False), 0, 0, 1, 2 + ) front_label = QLabel("Front light", parent=self) front_label.setAlignment(Qt.AlignmentFlag.AlignCenter) diff --git a/src/aare/gui/panels/local_contact_panel.py b/src/aare/gui/panels/local_contact_panel.py index 7b6630c4..1d6b523e 100644 --- a/src/aare/gui/panels/local_contact_panel.py +++ b/src/aare/gui/panels/local_contact_panel.py @@ -20,6 +20,7 @@ from PySide6.QtWidgets import ( QLabel, QMessageBox, QPushButton, + QScrollArea, QTabWidget, QTextEdit, QVBoxLayout, @@ -28,10 +29,19 @@ from PySide6.QtWidgets import ( from aare.gui.constants import LOGGER_NAME from aare.gui.panels.beamline_recovery_panel import RecoveryPanel +from aare.gui.styles import ( + BAD_CARD_BORDER, + BORDER, + CARD_BORDER, + CHIP_BAD_BG, + CHIP_BAD_TEXT, + HEADING_TEXT, + SURFACE, + card_style, +) from aare.gui.threads.daq_worker import DAQWorker from aare.gui.widgets.local_contact_status_widget import LocalContactStatusWidget from aare.gui.widgets.text_list_dialog import TextListDialog -from aare.gui.widgets.title_label import TitleLabel logger = setup_logger(LOGGER_NAME) @@ -65,30 +75,36 @@ class LocalContactPanel(QFrame): self._bec_macros_dialog: TextListDialog | None = None self._bec_devices_dialog: TextListDialog | None = None self._local_contact_config_payload: dict = {} - self._mount_to_center_sleep_s = QDoubleSpinBox(self) - self._line_scan_loop_face_y_padding_fraction_each_side = QDoubleSpinBox(self) - self._line_scan_loop_all_y_padding_fraction_each_side = QDoubleSpinBox(self) + # Recreated with proper parents in _build_config_tab; parentless here + # so no orphan widget floats over the panel (the old parent=self + # copies painted themselves over the top-left corner). + self._mount_to_center_sleep_s = QDoubleSpinBox() + self._line_scan_loop_face_y_padding_fraction_each_side = QDoubleSpinBox() + self._line_scan_loop_all_y_padding_fraction_each_side = QDoubleSpinBox() self.setFrameShape(QFrame.Shape.StyledPanel) self.setFrameShadow(QFrame.Shadow.Raised) + self.setObjectName("localContactPanel") self.setStyleSheet( - """ - QGroupBox { - background-color: white; - border: 1px solid #c7d4e5; - border-radius: 5px; + f""" + QFrame#localContactPanel {{ + border: 1px solid {BORDER}; + }} + QGroupBox {{ + background-color: {SURFACE}; + border: 1px solid {CARD_BORDER}; margin-top: 15px; padding-top: 15px; font-weight: 700; - color: #1e293b; - } - QGroupBox::title { + color: {HEADING_TEXT}; + }} + QGroupBox::title {{ subcontrol-origin: margin; subcontrol-position: top left; left: 10px; padding: 0 6px 0 6px; font:bold; - } + }} """ ) @@ -96,8 +112,7 @@ class LocalContactPanel(QFrame): layout.setContentsMargins(8, 8, 8, 8) layout.setSpacing(8) - layout.addWidget(TitleLabel("Local Contact", parent=self)) - + # No TitleLabel banner: the dialog window title already says it. self._info_label = QLabel( "Staff tools for beamline recovery and local-contact operations.", self ) @@ -107,12 +122,7 @@ class LocalContactPanel(QFrame): self._transfer_error_frame = QFrame(self) self._transfer_error_frame.setVisible(False) self._transfer_error_frame.setStyleSheet( - "QFrame {" - " background: #fdeaea;" - " color: #8b1e1e;" - " border: 1px solid #e6a8a8;" - " border-radius: 8px;" - "}" + card_style(CHIP_BAD_BG, BAD_CARD_BORDER, CHIP_BAD_TEXT) ) transfer_error_layout = QVBoxLayout(self._transfer_error_frame) transfer_error_layout.setContentsMargins(10, 10, 10, 10) @@ -133,13 +143,16 @@ class LocalContactPanel(QFrame): self._tabs = QTabWidget(self) layout.addWidget(self._tabs, 1) - self._tabs.addTab(self._build_status_tab(), self.TAB_STATUS) - self._tabs.addTab(self._build_recovery_tab(), self.TAB_RECOVERY) - self._tabs.addTab(self._build_tell_tab(), self.TAB_TELL) - self._tabs.addTab(self._build_bec_tab(), self.TAB_BEC) - self._tabs.addTab(self._build_hardware_tab(), self.TAB_HARDWARE) - self._tabs.addTab(self._build_detector_tab(), self.TAB_DETECTOR) - self._tabs.addTab(self._build_config_tab(), self.TAB_CONFIG) + # Every tab scrolls: the dialog's explicit minimum size is smaller + # than some tabs' content, and without a scroll area Qt squeezes the + # rows below text height (clipped labels on the Hardware tab). + self._tabs.addTab(self._scrolled(self._build_status_tab()), self.TAB_STATUS) + self._tabs.addTab(self._scrolled(self._build_recovery_tab()), self.TAB_RECOVERY) + self._tabs.addTab(self._scrolled(self._build_tell_tab()), self.TAB_TELL) + self._tabs.addTab(self._scrolled(self._build_bec_tab()), self.TAB_BEC) + self._tabs.addTab(self._scrolled(self._build_hardware_tab()), self.TAB_HARDWARE) + self._tabs.addTab(self._scrolled(self._build_detector_tab()), self.TAB_DETECTOR) + self._tabs.addTab(self._scrolled(self._build_config_tab()), self.TAB_CONFIG) self._daq.local_contact_simulation_state_loaded.connect(self._apply_simulation_state) self._daq.local_contact_device_state_loaded.connect(self._apply_device_state) @@ -179,6 +192,14 @@ class LocalContactPanel(QFrame): ) return self._register_status_widget(widget) + @staticmethod + def _scrolled(widget: QWidget) -> QScrollArea: + area = QScrollArea() + area.setWidgetResizable(True) + area.setFrameShape(QFrame.Shape.NoFrame) + area.setWidget(widget) + return area + def _build_status_tab(self) -> QWidget: tab = QWidget(self) layout = QVBoxLayout(tab) diff --git a/src/aare/gui/panels/log_panel.py b/src/aare/gui/panels/log_panel.py index 1de7958c..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, @@ -13,6 +12,21 @@ from PySide6.QtWidgets import ( ) from aare.gui.log import QtLogEmitter, QtLogHandler +from aare.gui.styles import ( + FLAT_CARD_RADIUS, + LOG_BORDER, + LOG_ERROR_BG, + LOG_ERROR_BORDER, + LOG_INFO_BG, + LOG_INFO_BORDER, + LOG_PANEL_BG, + LOG_SUCCESS_BG, + LOG_SUCCESS_BORDER, + LOG_WARN_BG, + LOG_WARN_BORDER, + TEXT, + card_style, +) class RuntimeNotificationWidget(QFrame): @@ -45,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() @@ -60,6 +74,7 @@ class RuntimeNotificationWidget(QFrame): button_layout.addWidget(self._clear_button) self._body = QWidget(self) + self._body.setObjectName("runtimeNotificationBody") body_layout = QVBoxLayout(self._body) body_layout.setContentsMargins(0, 0, 0, 0) body_layout.setSpacing(8) @@ -78,32 +93,44 @@ class RuntimeNotificationWidget(QFrame): self._sticky = True self.setStyleSheet( - """ - QFrame#runtimeNotification { - border: 1px solid #8a8a8a; - border-radius: 8px; - background-color: #fff4f4; - } - QFrame#runtimeNotification[noticeLevel="error"] { - background-color: #fff1f1; - border: 1px solid #d66; - } - QFrame#runtimeNotification[noticeLevel="warning"] { - background-color: #fff8e8; - border: 1px solid #d7aa42; - } - QFrame#runtimeNotification[noticeLevel="success"] { - background-color: #eefaf0; - border: 1px solid #6cb37a; - } - QFrame#runtimeNotification[noticeLevel="info"] { - background-color: #eef5ff; - border: 1px solid #6b9bd6; - } - QLabel#runtimeNotificationTitle { - font-weight: bold; - } - """ + card_style( + LOG_PANEL_BG, + LOG_BORDER, + selector="QFrame#runtimeNotification", + radius=FLAT_CARD_RADIUS, + ) + + card_style( + LOG_ERROR_BG, + LOG_ERROR_BORDER, + selector='QFrame#runtimeNotification[noticeLevel="error"]', + radius=FLAT_CARD_RADIUS, + ) + + card_style( + LOG_WARN_BG, + LOG_WARN_BORDER, + selector='QFrame#runtimeNotification[noticeLevel="warning"]', + radius=FLAT_CARD_RADIUS, + ) + + card_style( + LOG_SUCCESS_BG, + LOG_SUCCESS_BORDER, + selector='QFrame#runtimeNotification[noticeLevel="success"]', + radius=FLAT_CARD_RADIUS, + ) + + card_style( + LOG_INFO_BG, + LOG_INFO_BORDER, + selector='QFrame#runtimeNotification[noticeLevel="info"]', + radius=FLAT_CARD_RADIUS, + ) + # Transparent children: the app-wide QWidget background rule would + # otherwise paint opaque strips over the card tint. 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; }" ) def _set_level(self, level: str) -> None: @@ -165,30 +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.""" - self.container = QWidget(self) + reveal_requested = Signal() - self.notification = RuntimeNotificationWidget(self.container) - self.notification.show_log_requested.connect(self._raise_and_focus_log) + 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.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) @@ -203,10 +230,23 @@ class LogDock(QDockWidget): def _append_line(self, text: str): self.view.appendPlainText(text) + 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( @@ -218,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: @@ -234,3 +272,5 @@ class LogDock(QDockWidget): def clear(self): self.view.clear() + for mirror in self._mirror_views: + mirror.clear() diff --git a/src/aare/gui/panels/loop_centering_panel.py b/src/aare/gui/panels/loop_centering_panel.py index 15d9a179..ee2a3a5d 100644 --- a/src/aare/gui/panels/loop_centering_panel.py +++ b/src/aare/gui/panels/loop_centering_panel.py @@ -8,7 +8,7 @@ class LoopCenteringPanel(QWidget): super().__init__(parent) grid_layout = QGridLayout(self) - grid_layout.addWidget(TitleLabel("Loop centering", self), 0, 0, 1, 2) + grid_layout.addWidget(TitleLabel("Loop centering", self, collapsible=True), 0, 0, 1, 2) grid_layout.setColumnStretch(0, 1) grid_layout.setColumnStretch(1, 1) diff --git a/src/aare/gui/panels/manual_sample_panel.py b/src/aare/gui/panels/manual_sample_panel.py index 9b071140..f7f554e2 100644 --- a/src/aare/gui/panels/manual_sample_panel.py +++ b/src/aare/gui/panels/manual_sample_panel.py @@ -3,6 +3,7 @@ from aareDB import DataCollectionParameters from PySide6.QtCore import Signal, Slot from PySide6.QtWidgets import QCheckBox, QGridLayout, QLabel, QLineEdit, QPushButton, QWidget +from aare.gui.styles import SURFACE from aare.gui.widgets.number_line_edit import NumberLineEdit from aare.gui.widgets.title_label import TitleLabel @@ -18,14 +19,20 @@ class ManualSamplePanel(QWidget): self._pgroup = "p16371" grid_layout = QGridLayout(self) + # No horizontal inset: aligns the banner edge-to-edge with the Dataset + # path / Exp. Config. sections around it in the left column. + _m = grid_layout.contentsMargins() + grid_layout.setContentsMargins(0, _m.top(), 0, _m.bottom()) - grid_layout.addWidget(TitleLabel("Manual sample", self), 0, 0, 1, 3) + # Kept as attribute: the Ctrl+M shortcut expands the panel via title. + self.title = TitleLabel("Manual sample", self, collapsible=True) + grid_layout.addWidget(self.title, 0, 0, 1, 3) grid_layout.addWidget(QLabel("Sample"), 1, 0) self._text_name = QLineEdit(self._sample_name) grid_layout.addWidget(self._text_name, 1, 1) self._text_name.textChanged.connect(self._name_changed) - self._text_name.setStyleSheet("background-color: rgb(255, 255, 255);") + self._text_name.setStyleSheet(f"background-color: {SURFACE};") # Unit cell parameters self._unit_cell = QCheckBox("Provide unit cell") diff --git a/src/aare/gui/panels/monochromator_panel.py b/src/aare/gui/panels/monochromator_panel.py index ed637c87..aee95e50 100644 --- a/src/aare/gui/panels/monochromator_panel.py +++ b/src/aare/gui/panels/monochromator_panel.py @@ -13,29 +13,34 @@ class MonochromatorPanel(QWidget): super().__init__(parent) grid_layout = QGridLayout(self) - grid_layout.addWidget(TitleLabel("Monochromator", self), 0, 0, 1, 2) + grid_layout.addWidget( + TitleLabel("Monochromator", self, collapsible=True, default_collapsed=False), 0, 0, 1, 3 + ) self.mono_pitch_scan_button = QPushButton("Mono Pitch Scan", parent=self) self.mono_pitch_scan_button.clicked.connect(self.mono_pitch_scan.emit) - grid_layout.addWidget(self.mono_pitch_scan_button, 1, 0, 1, 2) + grid_layout.addWidget(self.mono_pitch_scan_button, 1, 0, 1, 3) - grid_layout.addWidget(QLabel("Energy", parent=self), 2, 0) + # One row (label | value | button) instead of three — vertical space. + # Display in keV; the DAQ API stays in eV (converted on emit). + # 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(1000.0, 30000.0) - self.energy_spin.setSingleStep(100.0) - self.energy_spin.setSuffix(" eV") - self.energy_spin.setValue(12000.0) - grid_layout.addWidget(self.energy_spin, 3, 0) + self.energy_spin.setRange(1.0, 30.0) + self.energy_spin.setSingleStep(0.1) + self.energy_spin.setValue(12.0) + grid_layout.addWidget(self.energy_spin, 2, 1) self.change_energy_button = QPushButton("Change Energy", parent=self) self.change_energy_button.clicked.connect(self._emit_change_energy) - grid_layout.addWidget(self.change_energy_button, 3, 1) + grid_layout.addWidget(self.change_energy_button, 2, 2) @Slot() def _emit_change_energy(self): - self.change_energy.emit(float(self.energy_spin.value())) + self.change_energy.emit(float(self.energy_spin.value()) * 1000.0) @Slot(DAQStatusModel) def update_daq_status(self, _status: DAQStatusModel): diff --git a/src/aare/gui/panels/omega_panel.py b/src/aare/gui/panels/omega_panel.py index e2585af8..52260b54 100644 --- a/src/aare/gui/panels/omega_panel.py +++ b/src/aare/gui/panels/omega_panel.py @@ -29,7 +29,9 @@ class OmegaPanel(QWidget): grid_layout = QGridLayout(self) - grid_layout.addWidget(TitleLabel("Omega", self), 0, 0, 1, 2) + grid_layout.addWidget( + TitleLabel("Omega", self, collapsible=True, default_collapsed=False), 0, 0, 1, 2 + ) grid_layout.setColumnStretch(0, 1) grid_layout.setColumnStretch(1, 1) omega_settings = [ diff --git a/src/aare/gui/panels/portrait_mode.py b/src/aare/gui/panels/portrait_mode.py index 08b21114..d65231df 100644 --- a/src/aare/gui/panels/portrait_mode.py +++ b/src/aare/gui/panels/portrait_mode.py @@ -20,22 +20,33 @@ from PySide6.QtWidgets import ( ) from aare.gui.constants import LOGGER_NAME +from aare.gui.styles import DARK_ACCENT as ACCENT +from aare.gui.styles import DARK_BG as BG +from aare.gui.styles import DARK_BORDER as ACCENT_DIM +from aare.gui.styles import DARK_BORDER as LED_OFF +from aare.gui.styles import DARK_ELEVATED as BUTTON_BG +from aare.gui.styles import ( + DARK_ERROR_BG, + DARK_ERROR_BORDER, + DARK_ERROR_TEXT, + DARK_SUCCESS_BG, + DARK_SUCCESS_BORDER, + DARK_SUCCESS_TEXT, + FONT_FINE, + FONT_HERO, + FONT_HINT, + FONT_LABEL, + FONT_VALUE, + WHITE, + qcolor, +) +from aare.gui.styles import DARK_MUTED as SUBTEXT +from aare.gui.styles import DARK_SURFACE as CARD_BG +from aare.gui.styles import DARK_TEXT as TEXT +from aare.gui.styles import WHITE as ACTIVE_STEP logger = setup_logger(LOGGER_NAME) -# --------------------------------------------------------------------------- -# Colour palette (kept identical to gui_designer.py) -# --------------------------------------------------------------------------- -BG = "#071018" -CARD_BG = "#0E1A26" -ACCENT = "#62D8C8" -ACCENT_DIM = "#1A3A36" -TEXT = "#F5F7FA" -SUBTEXT = "#8A9BB0" -BUTTON_BG = "#132131" -LED_OFF = "#1C2E3E" -ACTIVE_STEP = "#FFFFFF" - # --------------------------------------------------------------------------- # LED step indicator @@ -112,7 +123,8 @@ class LEDStages(QWidget): p.drawLine(QPointF(cx - 4, cy), QPointF(cx - 1, cy + 3)) p.drawLine(QPointF(cx - 1, cy + 3), QPointF(cx + 4, cy - 3)) elif i == self._active: - glow_pen = QPen(QColor(ACCENT + "55"), 4) + # was QColor(ACCENT + "55"), which mis-parsed as #AARRGGBB + glow_pen = QPen(qcolor(ACCENT, 0x55), 4) p.setPen(glow_pen) p.setBrush(Qt.NoBrush) p.drawEllipse(QPointF(cx, cy), led_r + 4, led_r + 4) @@ -166,7 +178,7 @@ class PlayPauseButton(QPushButton): cx, cy = rect.width() / 2, rect.height() / 2 r = min(rect.width(), rect.height()) / 2 - 2 - bg_color = QColor("#FFFFFF") if self._hovered else QColor(ACCENT) + bg_color = qcolor(WHITE) if self._hovered else QColor(ACCENT) p.setBrush(bg_color) p.setPen(Qt.NoPen) p.drawEllipse(QPointF(cx, cy), r, r) @@ -189,7 +201,7 @@ class QueueItemCard(QFrame): self.setFixedHeight(72) self.setStyleSheet(f""" QFrame {{ - background: {"#112030" if is_next else "#0C1720"}; + background: {BUTTON_BG if is_next else CARD_BG}; border-radius: 14px; border: {"1px solid " + ACCENT_DIM if is_next else "none"}; }} @@ -206,13 +218,13 @@ class QueueItemCard(QFrame): badge.setText("▶") badge.setStyleSheet(f""" color: {ACCENT}; background: {ACCENT_DIM}; - border-radius: 16px; font-size: 12px; font-weight: bold; + border-radius: 16px; font-size: {FONT_HINT}; font-weight: bold; """) else: badge.setText(str(index)) badge.setStyleSheet(f""" color: {SUBTEXT}; background: {BUTTON_BG}; - border-radius: 16px; font-size: 12px; + border-radius: 16px; font-size: {FONT_HINT}; """) layout.addWidget(badge) @@ -222,11 +234,11 @@ class QueueItemCard(QFrame): title_lbl = QLabel(title) title_lbl.setStyleSheet( - f"color: {TEXT}; font-size: 13px; font-weight: 600; background: transparent;" + f"color: {TEXT}; font-size: {FONT_LABEL}; font-weight: 700; background: transparent;" ) title_lbl.setWordWrap(False) sub_lbl = QLabel(subtitle) - sub_lbl.setStyleSheet(f"color: {SUBTEXT}; font-size: 11px; background: transparent;") + sub_lbl.setStyleSheet(f"color: {SUBTEXT}; font-size: {FONT_FINE}; background: transparent;") text_col.addWidget(title_lbl) text_col.addWidget(sub_lbl) layout.addLayout(text_col, stretch=1) @@ -245,6 +257,12 @@ class PortraitModePanel(QWidget): self.setMaximumWidth(self.PORTRAIT_WIDTH) self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) + # Created here rather than in the build helper so the attributes are + # initialized in __init__ (basedpyright gate); the helper styles and + # mounts them. + self._name_lbl = QLabel("—") + self._sub_lbl = QLabel("No sample queued") + self._job_list_panel = None self._tell_samples = None self._is_running = False @@ -285,26 +303,26 @@ class PortraitModePanel(QWidget): title = QLabel("S A M C A M E R A") title.setAlignment(Qt.AlignCenter) title.setStyleSheet( - f"color: {ACCENT}; font-size: 13px; letter-spacing: 3px; font-weight: 600;" + f"color: {ACCENT}; font-size: {FONT_LABEL}; letter-spacing: 3px; font-weight: 700;" ) layout.addWidget(title) # ── Portrait alert toast (hidden by default) ─────────────────────── self._alert_toast = QFrame() self._alert_toast.setVisible(False) - self._alert_toast.setStyleSheet(""" - QFrame { - background: #1A0E0E; - border: 1px solid #8f1d2c; + self._alert_toast.setStyleSheet(f""" + QFrame {{ + background: {DARK_ERROR_BG}; + border: 1px solid {DARK_ERROR_BORDER}; border-radius: 10px; - } + }} """) toast_layout = QHBoxLayout(self._alert_toast) toast_layout.setContentsMargins(12, 8, 12, 8) self._alert_toast_label = QLabel("") self._alert_toast_label.setWordWrap(True) self._alert_toast_label.setStyleSheet( - "color: #ffb3bc; font-size: 11px; font-weight: 600; background: transparent;" + f"color: {DARK_ERROR_TEXT}; font-size: {FONT_FINE}; font-weight: 600; background: transparent;" ) toast_layout.addWidget(self._alert_toast_label) # Dismiss button @@ -315,7 +333,7 @@ class PortraitModePanel(QWidget): color: {SUBTEXT}; background: transparent; border: none; - font-size: 11px; + font-size: {FONT_FINE}; }} QPushButton:hover {{ color: {TEXT}; }} """) @@ -338,11 +356,9 @@ class PortraitModePanel(QWidget): cam_card_layout.addWidget(cam_widget) layout.addWidget(cam_card) - # Sample name labels - self._name_lbl = QLabel("—") - self._name_lbl.setStyleSheet(f"color: {TEXT}; font-size: 18px; font-weight: 700;") - self._sub_lbl = QLabel("No sample queued") - self._sub_lbl.setStyleSheet(f"color: {SUBTEXT}; font-size: 12px;") + # Sample name labels (created in __init__) + self._name_lbl.setStyleSheet(f"color: {TEXT}; font-size: {FONT_VALUE}; font-weight: 700;") + self._sub_lbl.setStyleSheet(f"color: {SUBTEXT}; font-size: {FONT_HINT};") layout.addWidget(self._name_lbl) layout.addWidget(self._sub_lbl) @@ -374,7 +390,7 @@ class PortraitModePanel(QWidget): border-radius: 26px; background: {BUTTON_BG}; color: {ACCENT}; - font-size: 28px; + font-size: {FONT_HERO}; font-weight: bold; }} QPushButton:checked {{ @@ -401,11 +417,11 @@ class PortraitModePanel(QWidget): up_next_row = QHBoxLayout() up_next_lbl = QLabel("UP NEXT") up_next_lbl.setStyleSheet( - f"color: {ACCENT}; font-size: 11px; letter-spacing: 2px; font-weight: 700;" + f"color: {ACCENT}; font-size: {FONT_FINE}; letter-spacing: 2px; font-weight: 700;" ) self._samples_count_lbl = QLabel("0 SAMPLES") self._samples_count_lbl.setStyleSheet( - f"color: {SUBTEXT}; font-size: 11px; letter-spacing: 1px;" + f"color: {SUBTEXT}; font-size: {FONT_FINE}; letter-spacing: 1px;" ) up_next_row.addWidget(up_next_lbl) up_next_row.addStretch() @@ -453,7 +469,7 @@ class PortraitModePanel(QWidget): title = QLabel("SAMPLE QUEUE") title.setAlignment(Qt.AlignCenter) title.setStyleSheet( - f"color: {ACCENT}; font-size: 13px; letter-spacing: 3px; font-weight: 700;" + f"color: {ACCENT}; font-size: {FONT_LABEL}; letter-spacing: 3px; font-weight: 700;" ) layout.addWidget(title) @@ -591,8 +607,10 @@ class PortraitModePanel(QWidget): if not samples: placeholder = QLabel("No samples in queue") - placeholder.setStyleSheet(f"color: {SUBTEXT}; font-size: 13px;") - placeholder.setAlignment(Qt.AlignCenter) + placeholder.setStyleSheet( + f"color: {SUBTEXT}; font-size: {FONT_LABEL}; font-weight: 700;" + ) + placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter) self._queue_inner_layout.addWidget(placeholder) return @@ -627,7 +645,7 @@ class PortraitModePanel(QWidget): border: 1.5px solid {ACCENT}; border-radius: 14px; color: {ACCENT}; - font-size: 12px; + font-size: {FONT_HINT}; font-weight: 700; letter-spacing: 1.5px; }} @@ -658,9 +676,9 @@ class PortraitModePanel(QWidget): icon = "🛑" if is_error else "✅" self._alert_toast_label.setText(f"{icon} {msg}") - border_color = "#8f1d2c" if is_error else "#2a7a44" - text_color = "#ffb3bc" if is_error else "#a8f0c0" - bg_color = "#1A0E0E" if is_error else "#0E1A12" + border_color = DARK_ERROR_BORDER if is_error else DARK_SUCCESS_BORDER + text_color = DARK_ERROR_TEXT if is_error else DARK_SUCCESS_TEXT + bg_color = DARK_ERROR_BG if is_error else DARK_SUCCESS_BG self._alert_toast.setStyleSheet(f""" QFrame {{ @@ -670,7 +688,7 @@ class PortraitModePanel(QWidget): }} """) self._alert_toast_label.setStyleSheet( - f"color: {text_color}; font-size: 11px; font-weight: 600; background: transparent;" + f"color: {text_color}; font-size: {FONT_FINE}; font-weight: 600; background: transparent;" ) self._alert_toast.setVisible(True) diff --git a/src/aare/gui/panels/prediction_metrics_panel.py b/src/aare/gui/panels/prediction_metrics_panel.py index dce257d6..7302ea30 100644 --- a/src/aare/gui/panels/prediction_metrics_panel.py +++ b/src/aare/gui/panels/prediction_metrics_panel.py @@ -42,6 +42,18 @@ from PySide6.QtWidgets import ( ) from aare.gui.constants import LOGGER_NAME +from aare.gui.styles import ( + CHART_BLUE, + CHART_GREEN, + CHART_MUTED, + CHART_ORANGE, + CHART_RED, + CONFIDENCE_BIN_COLORS, + FONT_BODY, + FONT_TITLE, + qcolor, +) +from aare.gui.styles import CHART_CLASS_COLORS as CLASS_COLORS logger = setup_logger(LOGGER_NAME) @@ -79,17 +91,6 @@ class GroundTruthComparison: # ───────────────────────────────────────────────────────────────────────────── -# Colors matching the bounding box colors in camera_image.py -CLASS_COLORS = { - "Crystal": "#0000ff", # blue - "Loop_face": "#ffff00", # yellow - "Loop_all": "#00ff00", # green - "Pin": "#ff0000", # red - "Ice": "#00ffff", # cyan - "Needle": "#ff00ff", # magenta -} - - def get_class_name(cls_id: int) -> str: """Convert class ID to human-readable name.""" try: @@ -100,7 +101,7 @@ def get_class_name(cls_id: int) -> str: def get_class_color(class_name: str) -> str: """Get color for a class name (matches bounding box colors).""" - return CLASS_COLORS.get(class_name, "#888888") + return CLASS_COLORS.get(class_name, CHART_MUTED) # ───────────────────────────────────────────────────────────────────────────── @@ -112,7 +113,7 @@ class ConfidenceHistogramWidget(QWidget): """Real-time histogram of prediction confidence scores.""" BINS: ClassVar[list[float]] = [0.0, 0.2, 0.4, 0.6, 0.8, 1.0] - BIN_COLORS: ClassVar[list[str]] = ["#d62728", "#ff7f0e", "#ffbb78", "#98df8a", "#2ca02c"] + BIN_COLORS: ClassVar[list[str]] = CONFIDENCE_BIN_COLORS def __init__(self, parent=None): super().__init__(parent) @@ -209,7 +210,7 @@ class ObjectCountWidget(QWidget): # Create colored indicator square indicator = QLabel("■") - indicator.setStyleSheet(f"color: {color}; font-size: 16px;") + indicator.setStyleSheet(f"color: {color}; font-size: {FONT_TITLE};") indicator.setFixedWidth(20) # Class name label @@ -218,7 +219,7 @@ class ObjectCountWidget(QWidget): # Count label with matching color count_label = QLabel("0") - count_label.setStyleSheet(f"color: {color}; font-size: 14px; font-weight: bold;") + count_label.setStyleSheet(f"color: {color}; font-size: {FONT_BODY}; font-weight: bold;") count_label.setAlignment(Qt.AlignmentFlag.AlignRight) row = i // 2 @@ -335,19 +336,19 @@ class ErrorTrackingWidget(QWidget): # Empty frames (no detections) grid.addWidget(QLabel("Empty frames:"), 0, 0) self.empty_frames_label = QLabel("0") - self.empty_frames_label.setStyleSheet("color: #d62728; font-weight: bold;") + self.empty_frames_label.setStyleSheet(f"color: {CHART_RED}; font-weight: bold;") grid.addWidget(self.empty_frames_label, 0, 1) # Low confidence detections grid.addWidget(QLabel("Low conf (<0.5):"), 0, 2) self.low_conf_label = QLabel("0") - self.low_conf_label.setStyleSheet("color: #ff7f0e; font-weight: bold;") + self.low_conf_label.setStyleSheet(f"color: {CHART_ORANGE}; font-weight: bold;") grid.addWidget(self.low_conf_label, 0, 3) # Detection rate grid.addWidget(QLabel("Detection rate:"), 1, 0) self.detection_rate_label = QLabel("-") - self.detection_rate_label.setStyleSheet("color: #2ca02c; font-weight: bold;") + self.detection_rate_label.setStyleSheet(f"color: {CHART_GREEN}; font-weight: bold;") grid.addWidget(self.detection_rate_label, 1, 1) # Reset button @@ -411,12 +412,12 @@ class RollingStatsChart(QWidget): # Detection count series (left Y axis) self.count_series = QLineSeries() self.count_series.setName("Detections") - self.count_series.setPen(QPen(QColor("#1f77b4"), 2)) + self.count_series.setPen(QPen(qcolor(CHART_BLUE), 2)) # Mean confidence series (right Y axis) self.conf_series = QLineSeries() self.conf_series.setName("Mean Confidence") - self.conf_series.setPen(QPen(QColor("#2ca02c"), 2)) + self.conf_series.setPen(QPen(qcolor(CHART_GREEN), 2)) self.chart = QChart() self.chart.addSeries(self.count_series) @@ -554,7 +555,7 @@ class PredictionMetricsPanel(QWidget): controls.addStretch() self.status_label = QLabel("Waiting for predictions...") - self.status_label.setStyleSheet("color: #888;") + self.status_label.setStyleSheet(f"color: {CHART_MUTED};") controls.addWidget(self.status_label) layout.addLayout(controls) @@ -633,14 +634,14 @@ class PredictionMetricsPanel(QWidget): """Update status label.""" if self._paused: self.status_label.setText("Paused") - self.status_label.setStyleSheet("color: #ff7f0e;") + self.status_label.setStyleSheet(f"color: {CHART_ORANGE};") elif self._history: count = len(self._history) self.status_label.setText(f"Live: {count} frames recorded") - self.status_label.setStyleSheet("color: #2ca02c;") + self.status_label.setStyleSheet(f"color: {CHART_GREEN};") else: self.status_label.setText("Waiting for predictions...") - self.status_label.setStyleSheet("color: #888;") + self.status_label.setStyleSheet(f"color: {CHART_MUTED};") def showEvent(self, event) -> None: super().showEvent(event) diff --git a/src/aare/gui/panels/raster_data_collection.py b/src/aare/gui/panels/raster_data_collection.py index bbe4a16e..57f18ea9 100644 --- a/src/aare/gui/panels/raster_data_collection.py +++ b/src/aare/gui/panels/raster_data_collection.py @@ -2,19 +2,12 @@ from aarecommon.config.logger import setup_logger from aarecommon.math.diffraction_geometry import DiffractionGeometry from aarecommon.models.models import BeamlineStateEnum, DAQStatusModel from PySide6.QtCore import Qt, Signal, Slot -from PySide6.QtWidgets import ( - QComboBox, - QLabel, - QMessageBox, - QPushButton, - QSizePolicy, - QSlider, - QSpacerItem, -) +from PySide6.QtWidgets import QComboBox, QLabel, QMessageBox, QPushButton, QSlider from aare.gui.constants import LOGGER_NAME from aare.gui.panels.scan_settings_panel import ScanSettingsPanel from aare.gui.scan_logic.raster_grid_manager import RasterGridManager, RasterGridMetric +from aare.gui.styles import ABORT_TEXT, GO_TEXT from aare.gui.widgets.number_line_edit import DbOverrideLineEdit from aare.gui.widgets.raster_grid_table import RasterGridTable @@ -130,11 +123,6 @@ class RasterDataCollectionPanel(ScanSettingsPanel): self._table = RasterGridTable(raster_mgr) self._layout.addWidget(self._table, 9, 0, 1, 5) - horizontal_spacer = QSpacerItem( - 40, 20, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding - ) - self._layout.addItem(horizontal_spacer, 10, 0, 1, 5) - self._layout.addWidget(QLabel("Measurement time", parent=self), 11, 0) self.total_time = QLabel(f"{self._total_time} min 0 s") self.total_time.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) @@ -143,14 +131,19 @@ class RasterDataCollectionPanel(ScanSettingsPanel): self.calculate_total_time() self.start_button = QPushButton("Evaluate grid") - self.start_button.setStyleSheet("color: rgb(78, 154, 6);") + self.start_button.setStyleSheet(f"color: {GO_TEXT};") self.start_button.clicked.connect(self._on_evaluate_clicked) self._layout.addWidget(self.start_button, 12, 0, 1, 5) self.auto_button = QPushButton("X-ray Centering") - self.auto_button.setStyleSheet("color: rgb(78, 154, 6);") + self.auto_button.setStyleSheet(f"color: {GO_TEXT};") self.auto_button.clicked.connect(self._on_evaluate_auto_clicked) self._layout.addWidget(self.auto_button, 13, 0, 1, 5) + + # Per-tab Abort (DataCollectionSettings wires it to the DAQ cancel). + self.abort_button = QPushButton("Abort measurement") + self.abort_button.setStyleSheet(f"color: {ABORT_TEXT};") + self._layout.addWidget(self.abort_button, 14, 0, 1, 5) self._reset_to_defaults() self.update_grid_scan_size() diff --git a/src/aare/gui/panels/reference_tools_panel.py b/src/aare/gui/panels/reference_tools_panel.py index 8708c91b..8710db23 100644 --- a/src/aare/gui/panels/reference_tools_panel.py +++ b/src/aare/gui/panels/reference_tools_panel.py @@ -3,19 +3,11 @@ from aarecommon.config.logger import setup_logger from aarecommon.models.models import DAQStatusModel, SampleShortInfo, SampleShortInfoList from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal, Slot -from PySide6.QtGui import QBrush, QColor -from PySide6.QtWidgets import ( - QAbstractItemView, - QFrame, - QGridLayout, - QHeaderView, - QLabel, - QMenu, - QPushButton, - QTableView, -) +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, SAMPLE_STATUS_TEXT, qcolor from aare.gui.widgets.title_label import TitleLabel logger = setup_logger(LOGGER_NAME) @@ -48,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", @@ -56,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: @@ -78,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(114, 159, 207)) # darker blue - return QBrush(QColor(255, 255, 255)) # white + return QBrush(qcolor(SAMPLE_ROW_QUEUED_BG)) + 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 @@ -107,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 @@ -119,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") @@ -139,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), ) @@ -160,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], ) @@ -173,11 +184,13 @@ class ReferenceToolsPanel(QFrame): samples: SampleShortInfoList | None = None, parent=None, refresh_interval_ms: int = 5000, + model: ReferenceToolsModel | None = None, ): """ :param samples: optional initial SampleShortInfoList to populate the table :param parent: Qt parent :param refresh_interval_ms: how often to call request_refresh (panel doesn't implement the request itself) + :param model: share an existing model instead of owning one (pop-out window) """ super().__init__(parent) @@ -189,26 +202,24 @@ class ReferenceToolsPanel(QFrame): layout = QGridLayout(self) self.setLayout(layout) + # Flush layout, matching TellSamplePanel: full-width banner, no + # padding ring, no gap to the dock's tab row. + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) layout.addWidget(TitleLabel("Reference tools", parent=self), 0, 0, 1, 4) self.table_view = QTableView(parent=self) + # Row colors carry the separation — no grid lines. 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) - self.curr_sample_label = QLabel("No sample mounted", parent=self) - layout.addWidget(self.curr_sample_label, 2, 0) - - self.unmount_button = QPushButton("Unmount", parent=self) - layout.addWidget(self.unmount_button, 2, 1) - self.unmount_button.clicked.connect(self._on_unmount_clicked) - - layout.setColumnStretch(0, 1) - layout.setColumnStretch(1, 0) - - # initialize model with provided samples - self.table_model = ReferenceToolsModel(rows=samples.s) + # initialize model with provided samples (or adopt the shared one) + self.table_model = model if model is not None else ReferenceToolsModel(rows=samples.s) self.table_view.setModel(self.table_model) - self.table_view.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) self.table_view.setEditTriggers(QTableView.EditTrigger.NoEditTriggers) logger.debug("Setting up table header") @@ -217,8 +228,15 @@ class ReferenceToolsPanel(QFrame): header.setSectionResizeMode(QHeaderView.ResizeMode.Interactive) logger.debug("Setting up table header") header.setStretchLastSection(True) - self.table_view.verticalHeader().setVisible(True) + # No bold column titles when cells are selected. + header.setHighlightSections(False) + # 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. + header.setSortIndicator(self.table_model._sort_col, self.table_model._sort_order) self.table_view.setSortingEnabled(True) logger.debug("Setting up table view context menu") self.table_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) @@ -226,15 +244,18 @@ class ReferenceToolsPanel(QFrame): self.table_view.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.table_view.setSelectionMode(QTableView.SelectionMode.SingleSelection) + # Columns at full content width, sized once (see TellSamplePanel). + self._columns_autosized = False + if self.table_model.rowCount() > 0: + self.table_view.resizeColumnsToContents() + self._columns_autosized = True + def _selected_item(self) -> SampleShortInfo | None: idx = self.table_view.currentIndex() if not idx.isValid(): return None return self.table_model.get_item(idx.row()) - def _on_unmount_clicked(self): - self.unmount.emit() - def _context_menu(self, position): idx = self.table_view.indexAt(position) @@ -262,22 +283,13 @@ class ReferenceToolsPanel(QFrame): def new_list(self, samples: SampleShortInfoList): # signal from DAQWorker will call this with the model self.table_model.update_rows(rows=samples.s) + if not self._columns_autosized and self.table_model.rowCount() > 0: + self.table_view.resizeColumnsToContents() + self._columns_autosized = True @Slot(DAQStatusModel) def update_daq_status(self, status: DAQStatusModel): + # The "No sample mounted" label and Unmount button were dropped — + # mounted state now shows as the existing row highlight instead. sample = status.sample - if sample is None: - self.curr_sample_label.setText("No sample mounted") - else: - try: - if sample.location is None: - self.curr_sample_label.setText( - f"Current sample: {sample.sample_name} (Manual mount)" - ) - else: - self.curr_sample_label.setText( - f"Current sample: {sample.sample_name} ({sample.location.segment}{sample.location.pos}-{sample.pin})" - ) - except Exception as e: - logger.debug("Could not update the current sample label", exc_info=True) - self.curr_sample_label.setText(f"Confusing information :/ {e}") + self.table_model.update_current_reference(sample.db_id if sample is not None else None) diff --git a/src/aare/gui/panels/rotation_data_collection.py b/src/aare/gui/panels/rotation_data_collection.py index 0e8dacf4..c55dc304 100644 --- a/src/aare/gui/panels/rotation_data_collection.py +++ b/src/aare/gui/panels/rotation_data_collection.py @@ -10,6 +10,7 @@ from PySide6.QtWidgets import QComboBox, QLabel, QMessageBox, QPushButton from aare.gui.constants import LOGGER_NAME from aare.gui.panels.scan_settings_panel import ScanSettingsPanel +from aare.gui.styles import ABORT_TEXT, GO_TEXT from aare.gui.widgets.number_line_edit import DbOverrideLineEdit, NumberLineEdit logger = setup_logger(LOGGER_NAME) @@ -96,7 +97,7 @@ class RotationDataCollectionPanel(ScanSettingsPanel): self._layout.addWidget(self.screening_type, 7, 0, 1, 6) self.screening_button = QPushButton("Run screening") - self.screening_button.setStyleSheet("color: rgb(78, 154, 6);") + self.screening_button.setStyleSheet(f"color: {GO_TEXT};") self.screening_button.clicked.connect(self.run_screening) self._layout.addWidget(self.screening_button, 8, 0, 1, 6) @@ -160,9 +161,14 @@ class RotationDataCollectionPanel(ScanSettingsPanel): self.reload_params_button.setVisible(True) self.measurement_button = QPushButton("Run rotation") - self.measurement_button.setStyleSheet("color: rgb(78, 154, 6);") + self.measurement_button.setStyleSheet(f"color: {GO_TEXT};") self.measurement_button.clicked.connect(self.run_measurement) self._layout.addWidget(self.measurement_button, 16, 0, 1, 6) + + # Per-tab Abort (DataCollectionSettings wires it to the DAQ cancel). + self.abort_button = QPushButton("Abort measurement") + self.abort_button.setStyleSheet(f"color: {ABORT_TEXT};") + self._layout.addWidget(self.abort_button, 17, 0, 1, 6) self._reset_to_defaults() @Slot() diff --git a/src/aare/gui/panels/samcam_panel.py b/src/aare/gui/panels/samcam_panel.py index b69e5c8b..f4498d85 100644 --- a/src/aare/gui/panels/samcam_panel.py +++ b/src/aare/gui/panels/samcam_panel.py @@ -4,6 +4,7 @@ from PySide6.QtWidgets import ( QCheckBox, QComboBox, QDoubleSpinBox, + QGridLayout, QHBoxLayout, QLabel, QLineEdit, @@ -35,32 +36,30 @@ class SamcamPanel(QWidget): # Create layout layout = QVBoxLayout() - layout.addWidget(TitleLabel("Sample camera", self)) + layout.addWidget( + TitleLabel("Sample camera", self, collapsible=True, default_collapsed=False) + ) - # Exposure control - exposure_layout = QHBoxLayout() - exposure_label = QLabel("Exposure (s):") + # Exposure + gain share one row to save vertical space. + exposure_gain_layout = QHBoxLayout() self.exposure_spinbox = QDoubleSpinBox() self.exposure_spinbox.setRange(0, 1.0) # Adjust range as needed self.exposure_spinbox.setSingleStep(0.001) - self.exposure_spinbox.setStyleSheet("QDoubleSpinBox { background-color: white; }") self.exposure_spinbox.setDecimals(3) - self.exposure_spinbox.valueChanged.connect(self._changed) - exposure_layout.addWidget(exposure_label) - exposure_layout.addWidget(self.exposure_spinbox) - # Gain control - gain_layout = QHBoxLayout() - gain_label = QLabel("Gain:") self.gain_spinbox = QDoubleSpinBox() self.gain_spinbox.setRange(0, 1000) # Adjust range as needed self.gain_spinbox.setSingleStep(1) self.gain_spinbox.setDecimals(1) - self.gain_spinbox.setStyleSheet("QDoubleSpinBox { background-color: white; }") self.gain_spinbox.valueChanged.connect(self._changed) - gain_layout.addWidget(gain_label) - gain_layout.addWidget(self.gain_spinbox) + + # 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, 3) + exposure_gain_layout.addWidget(QLabel("Gain:")) + 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,7 +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("QLineEdit { background-color: white; }") screenshot_filename_layout.addWidget(screenshot_filename_label) screenshot_filename_layout.addWidget(self.screenshot_filename_edit) @@ -79,60 +77,56 @@ class SamcamPanel(QWidget): screenshot_message_label = QLabel("Message:") self.screenshot_message_edit = QLineEdit() self.screenshot_message_edit.setPlaceholderText("optional") - self.screenshot_message_edit.setStyleSheet("QLineEdit { background-color: white; }") screenshot_message_layout.addWidget(screenshot_message_label) screenshot_message_layout.addWidget(self.screenshot_message_edit) - self.screenshot_button = QPushButton("Take screenshot") + self.screenshot_button = QPushButton("Save samcam image") self.screenshot_button.clicked.connect(self._request_screenshot) - # Show detections checkbox - detections_layout = QHBoxLayout() + # Overlay checkboxes, two columns to save vertical space; related + # toggles share a row. self.show_detections_checkbox = QCheckBox("Show ML detections") self.show_detections_checkbox.setChecked(True) # Default to checked self.show_detections_checkbox.toggled.connect(self.show_detections_changed.emit) - detections_layout.addWidget(self.show_detections_checkbox) - # Show detection polygons checkbox - detection_polygons_layout = QHBoxLayout() self.show_detection_polygons_checkbox = QCheckBox("Show ML polygons") self.show_detection_polygons_checkbox.setChecked(True) self.show_detection_polygons_checkbox.toggled.connect( self.show_detection_polygons_changed.emit ) - detection_polygons_layout.addWidget(self.show_detection_polygons_checkbox) - # Show target point checkbox - target_point_layout = QHBoxLayout() self.show_target_point_checkbox = QCheckBox("Show target point") self.show_target_point_checkbox.setChecked(True) self.show_target_point_checkbox.toggled.connect(self.show_target_point_changed.emit) - target_point_layout.addWidget(self.show_target_point_checkbox) - # Show target coordinates checkbox - target_coords_layout = QHBoxLayout() self.show_target_coordinates_checkbox = QCheckBox("Show target coordinates") self.show_target_coordinates_checkbox.setChecked(True) self.show_target_coordinates_checkbox.toggled.connect( self.show_target_coordinates_changed.emit ) - target_coords_layout.addWidget(self.show_target_coordinates_checkbox) - # Show legend checkbox - legend_layout = QHBoxLayout() self.show_overlay_legend_checkbox = QCheckBox("Show overlay legend") - self.show_overlay_legend_checkbox.setChecked(True) + self.show_overlay_legend_checkbox.setChecked(False) self.show_overlay_legend_checkbox.toggled.connect(self.show_overlay_legend_changed.emit) - legend_layout.addWidget(self.show_overlay_legend_checkbox) - # Compact legend checkbox - compact_legend_layout = QHBoxLayout() self.compact_overlay_legend_checkbox = QCheckBox("Compact legend") self.compact_overlay_legend_checkbox.setChecked(False) self.compact_overlay_legend_checkbox.toggled.connect( self.compact_overlay_legend_changed.emit ) - compact_legend_layout.addWidget(self.compact_overlay_legend_checkbox) + + checkbox_grid = QGridLayout() + for i, checkbox in enumerate( + ( + self.show_detections_checkbox, + self.show_detection_polygons_checkbox, + self.show_target_point_checkbox, + self.show_target_coordinates_checkbox, + self.show_overlay_legend_checkbox, + self.compact_overlay_legend_checkbox, + ) + ): + checkbox_grid.addWidget(checkbox, i // 2, i % 2) # Target color target_color_layout = QHBoxLayout() @@ -145,18 +139,14 @@ class SamcamPanel(QWidget): target_color_layout.addWidget(self.target_color_combo) # Add controls to main layout - layout.addLayout(exposure_layout) - layout.addLayout(gain_layout) - layout.addWidget(self.save_beam_location_button) + layout.addLayout(exposure_gain_layout) + samcam_buttons_layout = QHBoxLayout() + samcam_buttons_layout.addWidget(self.save_beam_location_button) + samcam_buttons_layout.addWidget(self.screenshot_button) + layout.addLayout(samcam_buttons_layout) layout.addLayout(screenshot_filename_layout) layout.addLayout(screenshot_message_layout) - layout.addWidget(self.screenshot_button) - layout.addLayout(detections_layout) - layout.addLayout(detection_polygons_layout) - layout.addLayout(target_point_layout) - layout.addLayout(target_coords_layout) - layout.addLayout(legend_layout) - layout.addLayout(compact_legend_layout) + layout.addLayout(checkbox_grid) layout.addLayout(target_color_layout) self.setLayout(layout) diff --git a/src/aare/gui/panels/sample_queue_panel.py b/src/aare/gui/panels/sample_queue_panel.py index a7de0c68..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) @@ -194,6 +197,13 @@ class SampleQueuePanel(QFrame): self._emit_samples_in_queue_changed() + def remove_samples(self, db_ids) -> None: + """Remove the given samples from the queue. Public entry point for the + combined dewar view, whose selection lives outside this panel.""" + for db_id in db_ids: + self.table_model.remove_sample(db_id) + self._emit_samples_in_queue_changed() + def remove_selected_samples(self): selected_indexes = self.table_view.selectionModel().selectedRows() if not selected_indexes: diff --git a/src/aare/gui/panels/scan_settings_panel.py b/src/aare/gui/panels/scan_settings_panel.py index 39285963..3b570acd 100644 --- a/src/aare/gui/panels/scan_settings_panel.py +++ b/src/aare/gui/panels/scan_settings_panel.py @@ -63,10 +63,15 @@ class ScanSettingsPanel(QWidget): # before, so they are unaffected by the wrapping. outer = QVBoxLayout(self) outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(0) outer.addWidget(self._build_source_toggle()) grid_host = QWidget(self) self._layout = QGridLayout(grid_host) + # Toggle-to-grid gap = one grid row gap (top). Bottom 3 + the column's + # 3px spacing = one row gap between the last button and Abort too. + m = self._layout.contentsMargins() + self._layout.setContentsMargins(m.left(), 6, m.right(), 3) outer.addWidget(grid_host) self._layout.addWidget(QLabel("High resolution", parent=self), 0, 0) diff --git a/src/aare/gui/panels/smargon_panel.py b/src/aare/gui/panels/smargon_panel.py index d4aa81c0..3ed71dce 100644 --- a/src/aare/gui/panels/smargon_panel.py +++ b/src/aare/gui/panels/smargon_panel.py @@ -58,7 +58,9 @@ class SmargonPanel(QWidget): grid_layout = QGridLayout(self) - grid_layout.addWidget(TitleLabel("Smargon", self), 0, 0, 1, 6) + grid_layout.addWidget( + TitleLabel("Smargon", self, collapsible=True, default_collapsed=False), 0, 0, 1, 6 + ) grid_layout.addWidget(QLabel("Chi", parent=self), 1, 0) self.chi_enter = NumberLineEdit(-0.2, 40, decimals=1, parent=self) diff --git a/src/aare/gui/panels/smart_rotation_panel.py b/src/aare/gui/panels/smart_rotation_panel.py index 1a49a873..22855bef 100644 --- a/src/aare/gui/panels/smart_rotation_panel.py +++ b/src/aare/gui/panels/smart_rotation_panel.py @@ -8,6 +8,7 @@ from PySide6.QtWidgets import QGridLayout, QLabel, QPushButton, QSizePolicy, QSp from aare.gui.constants import LOGGER_NAME from aare.gui.panels.rotation_data_collection import add_data_to_path +from aare.gui.styles import ABORT_TEXT, GO_TEXT, STATUS_ALERT from aare.gui.widgets.number_line_edit import NumberLineEdit logger = setup_logger(LOGGER_NAME) @@ -43,6 +44,10 @@ class SimpleRotationSettingsPanel(QWidget): self._prev_params = SimpleScanParameters() self._layout = QGridLayout(self) + # Top margin 0 like the Raster/Rotation pages (their toggle row sits + # at margin 0), so the gap under the ML-centring row matches. + m = self._layout.contentsMargins() + self._layout.setContentsMargins(m.left(), 0, m.right(), 3) # Visible resolution (entry) self._layout.addWidget(QLabel("Visible resolution", parent=self), 0, 0) @@ -82,133 +87,138 @@ class SimpleRotationSettingsPanel(QWidget): self._layout.addWidget(QLabel("K", parent=self), 4, 4) self.temp_enter.newValue.connect(self.set_temperature) + # Run + Abort directly under the last configurable row; the read-only + # block below is reference info, not something to scroll past to act. + self.run_rotation_button = QPushButton("Run rotation", parent=self) + self.run_rotation_button.setStyleSheet(f"color: {GO_TEXT};") + self.run_rotation_button.clicked.connect(self.run_measurement) + self._layout.addWidget(self.run_rotation_button, 5, 0, 1, 6) + + self.abort_button = QPushButton("Abort measurement", parent=self) + self.abort_button.setStyleSheet(f"color: {ABORT_TEXT};") + self._layout.addWidget(self.abort_button, 6, 0, 1, 6) + # Calculated labels - self._layout.addWidget(QLabel("Target resolution", parent=self), 5, 0) + self._layout.addWidget(QLabel("Target resolution", parent=self), 7, 0) self.target_res_label = QLabel("--", parent=self) self.target_res_label.setAlignment( Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter ) - self._layout.addWidget(self.target_res_label, 5, 1, 1, 3) - self._layout.addWidget(QLabel("Å", parent=self), 5, 4) + self._layout.addWidget(self.target_res_label, 7, 1, 1, 3) + self._layout.addWidget(QLabel("Å", parent=self), 7, 4) - self._layout.addWidget(QLabel("Image time", parent=self), 6, 0) + self._layout.addWidget(QLabel("Image time", parent=self), 8, 0) self.image_time_label = QLabel("--", parent=self) self.image_time_label.setAlignment( Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter ) - self._layout.addWidget(self.image_time_label, 6, 1, 1, 3) - self._layout.addWidget(QLabel("s", parent=self), 6, 4) + self._layout.addWidget(self.image_time_label, 8, 1, 1, 3) + self._layout.addWidget(QLabel("s", parent=self), 8, 4) - self._layout.addWidget(QLabel("Transmission", parent=self), 7, 0) + self._layout.addWidget(QLabel("Transmission", parent=self), 9, 0) self.transmission_label = QLabel("--", parent=self) self.transmission_label.setAlignment( Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter ) - self._layout.addWidget(self.transmission_label, 7, 1, 1, 3) - self._layout.addWidget(QLabel("%", parent=self), 7, 4) + self._layout.addWidget(self.transmission_label, 9, 1, 1, 3) + self._layout.addWidget(QLabel("%", parent=self), 9, 4) - self._layout.addWidget(QLabel("Detector distance", parent=self), 8, 0) + self._layout.addWidget(QLabel("Detector distance", parent=self), 10, 0) self.dtz_label = QLabel("--", parent=self) self.dtz_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) - self._layout.addWidget(self.dtz_label, 8, 1, 1, 3) - self._layout.addWidget(QLabel("mm", parent=self), 8, 4) + self._layout.addWidget(self.dtz_label, 10, 1, 1, 3) + self._layout.addWidget(QLabel("mm", parent=self), 10, 4) - self._layout.addWidget(QLabel("Target Dose", parent=self), 9, 0) + self._layout.addWidget(QLabel("Target Dose", parent=self), 11, 0) self.target_dose_label = QLabel("--", parent=self) self.target_dose_label.setAlignment( Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter ) - self._layout.addWidget(self.target_dose_label, 9, 1, 1, 3) - self._layout.addWidget(QLabel("MGy", parent=self), 9, 4) + self._layout.addWidget(self.target_dose_label, 11, 1, 1, 3) + self._layout.addWidget(QLabel("MGy", parent=self), 11, 4) - self._layout.addWidget(QLabel("Calculated Dose Rate", parent=self), 10, 0) + self._layout.addWidget(QLabel("Calculated Dose Rate", parent=self), 12, 0) self.calculated_dose_rate_label = QLabel("--", parent=self) self.calculated_dose_rate_label.setAlignment( Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter ) - self._layout.addWidget(self.calculated_dose_rate_label, 10, 1, 1, 3) - self._layout.addWidget(QLabel("MGy s-1", parent=self), 10, 4) + self._layout.addWidget(self.calculated_dose_rate_label, 12, 1, 1, 3) + self._layout.addWidget(QLabel("MGy s-1", parent=self), 12, 4) - self._layout.addWidget(QLabel("Wilson B Factor", parent=self), 11, 0) + self._layout.addWidget(QLabel("Wilson B Factor", parent=self), 13, 0) self.wilson_b_label = QLabel("--", parent=self) self.wilson_b_label.setAlignment( Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter ) - self._layout.addWidget(self.wilson_b_label, 11, 1, 1, 3) - self._layout.addWidget(QLabel("Å2", parent=self), 11, 4) + self._layout.addWidget(self.wilson_b_label, 13, 1, 1, 3) + self._layout.addWidget(QLabel("Å2", parent=self), 13, 4) - self._layout.addWidget(QLabel("Crystal Size x", parent=self), 12, 0) + self._layout.addWidget(QLabel("Crystal Size x", parent=self), 14, 0) self.xtal_x_label = QLabel("--", parent=self) self.xtal_x_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) - self._layout.addWidget(self.xtal_x_label, 12, 1, 1, 3) - self._layout.addWidget(QLabel("um", parent=self), 12, 4) - - self._layout.addWidget(QLabel("Crystal Size y", parent=self), 13, 0) - self.xtal_y_label = QLabel("--", parent=self) - self.xtal_y_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) - self._layout.addWidget(self.xtal_y_label, 13, 1, 1, 3) - self._layout.addWidget(QLabel("um", parent=self), 13, 4) - - self._layout.addWidget(QLabel("Crystal Size z", parent=self), 14, 0) - self.xtal_z_label = QLabel("--", parent=self) - self.xtal_z_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) - self._layout.addWidget(self.xtal_z_label, 14, 1, 1, 3) + self._layout.addWidget(self.xtal_x_label, 14, 1, 1, 3) self._layout.addWidget(QLabel("um", parent=self), 14, 4) - self._layout.addWidget(QLabel("Calculated Dose (xtal size)", parent=self), 15, 0) + self._layout.addWidget(QLabel("Crystal Size y", parent=self), 15, 0) + self.xtal_y_label = QLabel("--", parent=self) + self.xtal_y_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + self._layout.addWidget(self.xtal_y_label, 15, 1, 1, 3) + self._layout.addWidget(QLabel("um", parent=self), 15, 4) + + self._layout.addWidget(QLabel("Crystal Size z", parent=self), 16, 0) + self.xtal_z_label = QLabel("--", parent=self) + self.xtal_z_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + self._layout.addWidget(self.xtal_z_label, 16, 1, 1, 3) + self._layout.addWidget(QLabel("um", parent=self), 16, 4) + + self._layout.addWidget(QLabel("Calculated Dose (xtal size)", parent=self), 17, 0) self.xtal_size_dose_label = QLabel("--", parent=self) self.xtal_size_dose_label.setAlignment( Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter ) - self._layout.addWidget(self.xtal_size_dose_label, 15, 1, 1, 3) - self._layout.addWidget(QLabel("MGy", parent=self), 15, 4) + self._layout.addWidget(self.xtal_size_dose_label, 17, 1, 1, 3) + self._layout.addWidget(QLabel("MGy", parent=self), 17, 4) - self._layout.addWidget(QLabel("X-ray Wavelength", parent=self), 16, 0) + self._layout.addWidget(QLabel("X-ray Wavelength", parent=self), 18, 0) self.wavelength_label = QLabel("--", parent=self) self.wavelength_label.setAlignment( Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter ) - self._layout.addWidget(self.wavelength_label, 16, 1, 1, 3) - self._layout.addWidget(QLabel("Å", parent=self), 16, 4) + self._layout.addWidget(self.wavelength_label, 18, 1, 1, 3) + self._layout.addWidget(QLabel("Å", parent=self), 18, 4) - self._layout.addWidget(QLabel("Flux", parent=self), 17, 0) + self._layout.addWidget(QLabel("Flux", parent=self), 19, 0) self.flux_label = QLabel("--", parent=self) self.flux_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) - self._layout.addWidget(self.flux_label, 17, 1, 1, 3) - self._layout.addWidget(QLabel("x 109 ph s-1", parent=self), 17, 4) + self._layout.addWidget(self.flux_label, 19, 1, 1, 3) + self._layout.addWidget(QLabel("x 109 ph s-1", parent=self), 19, 4) - self._layout.addWidget(QLabel("Beam Size", parent=self), 18, 0) + self._layout.addWidget(QLabel("Beam Size", parent=self), 20, 0) self.beam_size_label = QLabel("--", parent=self) self.beam_size_label.setAlignment( Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter ) - self._layout.addWidget(self.beam_size_label, 18, 1, 1, 3) - self._layout.addWidget(QLabel("um2", parent=self), 18, 4) + self._layout.addWidget(self.beam_size_label, 20, 1, 1, 3) + self._layout.addWidget(QLabel("um2", parent=self), 20, 4) - self._layout.addWidget(QLabel("Calculated Dose", parent=self), 19, 0) + self._layout.addWidget(QLabel("Calculated Dose", parent=self), 21, 0) self.calculated_dose_label = QLabel("--", parent=self) self.calculated_dose_label.setAlignment( Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter ) - self._layout.addWidget(self.calculated_dose_label, 19, 1, 1, 3) - self._layout.addWidget(QLabel("MGy", parent=self), 19, 4) + self._layout.addWidget(self.calculated_dose_label, 21, 1, 1, 3) + self._layout.addWidget(QLabel("MGy", parent=self), 21, 4) - self._layout.addWidget(QLabel("Total measurement time", parent=self), 20, 0) + self._layout.addWidget(QLabel("Total measurement time", parent=self), 22, 0) self.total_time = QLabel(f"{self.total_time_s} min 0 s") self.total_time.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) - self._layout.addWidget(self.total_time, 20, 1, 1, 3) + self._layout.addWidget(self.total_time, 22, 1, 1, 3) - # add vertical stretch between detector distance and the run button + # Vertical stretch below everything (surplus space sink). self._layout.addItem( - QSpacerItem(0, 0, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding), 21, 0, 1, 6 + QSpacerItem(0, 0, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding), 23, 0, 1, 6 ) - # Run rotation button - self.run_rotation_button = QPushButton("Run rotation", parent=self) - self.run_rotation_button.setStyleSheet("color: rgb(78, 154, 6);") - self.run_rotation_button.clicked.connect(self.run_measurement) - self._layout.addWidget(self.run_rotation_button, 22, 0, 1, 6) - @Slot(DAQStatusModel) def update_daq_status(self, s: DAQStatusModel): self._d = s @@ -355,9 +365,11 @@ class SimpleRotationSettingsPanel(QWidget): self.image_time_label.setText(f"{self.image_time_s:.4f}") if self.dtz <= 0.0: - self.dtz_label.setText("""-""") + self.dtz_label.setText(f"""-""") elif self.dtz < self._d.bl.dtz_min: - self.dtz_label.setText(f"""{self.dtz:.2f}""") + self.dtz_label.setText( + f"""{self.dtz:.2f}""" + ) self.dtz = self._d.bl.dtz_min else: self.dtz_label.setText(f"{self.dtz:.2f}") diff --git a/src/aare/gui/panels/status_panel.py b/src/aare/gui/panels/status_panel.py index 971f2efc..31f77b69 100644 --- a/src/aare/gui/panels/status_panel.py +++ b/src/aare/gui/panels/status_panel.py @@ -4,6 +4,7 @@ from PySide6.QtCore import Qt, Slot from PySide6.QtGui import QPixmap from PySide6.QtWidgets import QFrame, QGridLayout, QLabel, QSizePolicy, QSpacerItem +from aare.gui.styles import STATUS_ALERT from aare.gui.widgets.status_label import StatusLabel from aare.gui.widgets.title_label import TitleLabel @@ -65,7 +66,7 @@ class StatusPanel(QFrame): if s.bl.ring_current_mA < 390.0: self.ring_current.setText( - f'{s.bl.ring_current_mA:.1f}' + f'{s.bl.ring_current_mA:.1f}' ) else: self.ring_current.setText(f"{s.bl.ring_current_mA:.1f}") diff --git a/src/aare/gui/panels/target_stability_panel.py b/src/aare/gui/panels/target_stability_panel.py index fdbeb00d..03757327 100644 --- a/src/aare/gui/panels/target_stability_panel.py +++ b/src/aare/gui/panels/target_stability_panel.py @@ -23,6 +23,18 @@ from PySide6.QtWidgets import ( ) from aare.gui.constants import LOGGER_NAME +from aare.gui.styles import ( + CHART_BLUE, + CHART_BLUE_LIGHT, + CHART_BLUE_PALE, + CHART_CYAN, + CHART_GREEN, + CHART_ORANGE, + CHART_PURPLE, + CHART_RED, + CHART_RED_DARK, + CHART_RED_LIGHT, +) logger = setup_logger(LOGGER_NAME) @@ -97,19 +109,19 @@ class InteractiveChartView(QChartView): class TargetStabilityPanel(QWidget): - SIGMA_COLOR = "#1f77b4" - SIGMA_X_COLOR = "#6baed6" - SIGMA_Y_COLOR = "#9ecae1" + SIGMA_COLOR = CHART_BLUE + SIGMA_X_COLOR = CHART_BLUE_LIGHT + SIGMA_Y_COLOR = CHART_BLUE_PALE - DISTANCE_COLOR = "#d62728" - DX_COLOR = "#ff9896" - DY_COLOR = "#c43c39" + DISTANCE_COLOR = CHART_RED + DX_COLOR = CHART_RED_LIGHT + DY_COLOR = CHART_RED_DARK - SCORE_COLOR = "#ff7f0e" - STEP_COLOR = "#17becf" + SCORE_COLOR = CHART_ORANGE + STEP_COLOR = CHART_CYAN - TARGET_COLOR = "#2ca02c" - BEAM_COLOR = "#9467bd" + TARGET_COLOR = CHART_GREEN + BEAM_COLOR = CHART_PURPLE SCORE_FROM_STEP_XY = "Step XY" SCORE_FROM_SIGMA_XY = "Sigma XY" diff --git a/src/aare/gui/panels/tell_sample_panel.py b/src/aare/gui/panels/tell_sample_panel.py index 035c487f..637c73d8 100644 --- a/src/aare/gui/panels/tell_sample_panel.py +++ b/src/aare/gui/panels/tell_sample_panel.py @@ -8,29 +8,124 @@ from aarecommon.models.models import ( from PySide6.QtCore import Qt, Signal, Slot from PySide6.QtWidgets import ( QAbstractItemView, + QButtonGroup, QFrame, QGridLayout, + QHBoxLayout, QHeaderView, - QLabel, QMenu, QPushButton, QTableView, ) from aare.gui.constants import LOGGER_NAME -from aare.gui.models.user_sample_model import UserSampleSpreadsheet +from aare.gui.models.user_sample_model import COL_STATUS, UserSampleSpreadsheet from aare.gui.widgets.title_label import TitleLabel logger = setup_logger(LOGGER_NAME) +class FrozenColumnTableView(QTableView): + """QTableView with the "#"/status column frozen (Qt frozen-column + pattern): an overlay view shares the model and selection, sits on top of + column 0, and stays put while the rest scrolls horizontally.""" + + FROZEN_WIDTH = 36 + + def __init__(self, parent=None): + super().__init__(parent) + self.frozen = QTableView(self) + self.frozen.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.frozen.verticalHeader().hide() + self.frozen.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.frozen.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.frozen.setShowGrid(False) + self.frozen.setAlternatingRowColors(True) + self.frozen.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.frozen.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.frozen.setEditTriggers(QTableView.EditTrigger.NoEditTriggers) + self.frozen.setDragEnabled(True) + self.frozen.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Fixed) + self.frozen.horizontalHeader().setHighlightSections(False) + # The overlay must not carry the L3 frame — it sits INSIDE the view. + self.frozen.setStyleSheet("QTableView { border: none; }") + self.viewport().stackUnder(self.frozen) + self.frozen.verticalScrollBar().valueChanged.connect(self.verticalScrollBar().setValue) + self.verticalScrollBar().valueChanged.connect(self.frozen.verticalScrollBar().setValue) + + def setModel(self, model): + super().setModel(model) + self.frozen.setModel(model) + # Share the selection so clicking either view highlights both. + self.frozen.setSelectionModel(self.selectionModel()) + for col in range(1, model.columnCount()): + self.frozen.setColumnHidden(col, True) + self.set_frozen_width(self.FROZEN_WIDTH) + self.frozen.show() + + def set_frozen_width(self, width: int) -> None: + self.setColumnWidth(0, width) + self.frozen.setColumnWidth(0, width) + self._update_frozen_geometry() + + def _update_frozen_geometry(self) -> None: + self.frozen.setGeometry( + self.frameWidth(), + self.frameWidth(), + self.columnWidth(0), + self.viewport().height() + self.horizontalHeader().height(), + ) + + def resizeEvent(self, event): + super().resizeEvent(event) + self._update_frozen_geometry() + + +class QueueDropChip(QPushButton): + """Filter chip that doubles as a drop target: dragging table rows onto it + relabels them (Queued adds to the automation queue, Flagged marks them + flagged — same mime the old queue dock took). Measured is deliberately + NOT one of these: it is automatic, from the rotation count.""" + + samples_dropped = Signal(SampleShortInfoList) + + def __init__(self, label: str, parent=None): + super().__init__(label, parent) + self.setAcceptDrops(True) + + def dragEnterEvent(self, event): + if event.mimeData().hasText(): + event.acceptProposedAction() + + def dropEvent(self, event): + try: + samples = SampleShortInfoList.model_validate_json(event.mimeData().text()) + except Exception: + logger.debug("Ignoring drop that is not a sample list", exc_info=True) + return + self.samples_dropped.emit(samples) + event.acceptProposedAction() + + class TellSamplePanel(QFrame): mount = Signal(SampleShortInfo) unmount = Signal() state = Signal(BeamlineStateEnum) + # Queue membership is edited from this table now (aaregui2 concept: the + # dewar list doubles as the queue view); the queue itself lives in the + # SampleQueuePanel these signals are wired to. + add_to_queue = Signal(SampleShortInfoList) + remove_from_queue = Signal(SampleShortInfoList) - def __init__(self, samples: SampleShortInfoList | None = None, parent=None): - + def __init__( + self, + samples: SampleShortInfoList | None = None, + parent=None, + model: UserSampleSpreadsheet | None = None, + ): + """`model`: share an existing spreadsheet model instead of owning one — + used by the pop-out window so both panels operate on the same data, + tints and filters with no syncing.""" super().__init__(parent) if samples is None: @@ -40,28 +135,77 @@ class TellSamplePanel(QFrame): grid_layout = QGridLayout(self) self.setLayout(grid_layout) + # Flush layout: the banner spans the full panel width and sits + # directly under the dock's tab row — no padding ring. + grid_layout.setContentsMargins(0, 0, 0, 0) + grid_layout.setSpacing(0) grid_layout.addWidget(TitleLabel("TELL sample changer", self), 0, 0, 1, 4) - self.table_view = QTableView() - grid_layout.addWidget(self.table_view, 1, 0, 1, 4) + # Status filter row (aaregui2 concept): filters the table by queue/ + # collection status, and each checked button wears its row-tint color, + # doubling as the legend. Styled like the Dewar/Auxiliary tab row + # above (square tabs, touching), not pills. Colors live in styles.py. + chip_row = QHBoxLayout() + # Left margin 0: "All" shares the table's left edge; bottom 0: the + # row sits directly on the table. + chip_row.setContentsMargins(0, 2, 6, 0) + chip_row.setSpacing(0) + self.status_chips = QButtonGroup(self) + self.status_chips.setExclusive(True) + for label, key in ( + ("All", None), + ("Queued", "queued"), + ("Flagged", "flagged"), + ("Measured", "measured"), + ): + if key == "queued": + chip = QueueDropChip(label, self) + chip.samples_dropped.connect(self.add_to_queue) + # Deselect after the drop: the selection tint would otherwise + # sit on top of the fresh status color and hide it. + chip.samples_dropped.connect(lambda _: self.table_view.clearSelection()) + chip.setToolTip("Filter queued samples — or drop table rows here to queue them") + elif key == "flagged": + chip = QueueDropChip(label, self) + chip.samples_dropped.connect(self._flag_dropped_samples) + chip.samples_dropped.connect(lambda _: self.table_view.clearSelection()) + chip.setToolTip("Filter flagged samples — or drop table rows here to flag them") + else: + chip = QPushButton(label, self) + if key == "measured": + chip.setToolTip("Filter measured samples (automatic: rotation count > 1)") + chip.setCheckable(True) + chip.setChecked(key is None) + chip.setProperty("status_key", key) + chip.setCursor(Qt.CursorShape.PointingHandCursor) + # 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() + grid_layout.addLayout(chip_row, 1, 0, 1, 4) + self.status_chips.buttonClicked.connect( + lambda chip: self.table_model.set_status_filter(chip.property("status_key")) + ) - self.curr_sample_label = QLabel("No sample mounted", parent=self) - self.curr_sample_label.setTextFormat(Qt.TextFormat.RichText) - self.curr_sample_label.setWordWrap(True) - grid_layout.addWidget(self.curr_sample_label, 2, 0) + # Staggered grey/white rows tell rows apart — no grid lines, no row + # tints; status fills the frozen "#" column and selection stays blue. + self.table_view = FrozenColumnTableView() + self.table_view.setShowGrid(False) + self.table_view.setAlternatingRowColors(True) + grid_layout.addWidget(self.table_view, 2, 0, 1, 4) - self.unmount_button = QPushButton("Unmount", parent=self) - grid_layout.addWidget(self.unmount_button, 2, 1) - self.unmount_button.clicked.connect(self.unmount_button_clicked) - - grid_layout.setColumnStretch(0, 1) - grid_layout.setColumnStretch(1, 0) - - self.table_model = UserSampleSpreadsheet(samples=samples.s) + self.table_model = model if model is not None else UserSampleSpreadsheet(samples=samples.s) self.table_view.setModel(self.table_model) - self.table_view.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + # Adopt the model's current order before enabling sorting: a second + # panel on a shared model must not re-sort it to column 0 on open. + self.table_view.horizontalHeader().setSortIndicator( + self.table_model._sort_col, self.table_model._sort_order + ) self.table_view.setSortingEnabled(True) self.table_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.table_view.customContextMenuRequested.connect(self.context_menu) @@ -73,17 +217,44 @@ class TellSamplePanel(QFrame): header = self.table_view.horizontalHeader() header.setSectionResizeMode(QHeaderView.ResizeMode.Interactive) header.setStretchLastSection(True) - self.table_view.verticalHeader().setVisible(True) + # No bold column titles when cells are selected. + header.setHighlightSections(False) + # Row numbers + status color live in the frozen "#" column: fixed + # width, not resizable, stays visible on horizontal scroll. + self.table_view.verticalHeader().setVisible(False) + header.setSectionResizeMode(COL_STATUS, QHeaderView.ResizeMode.Fixed) + self.table_view.set_frozen_width(FrozenColumnTableView.FROZEN_WIDTH) + # Right-click on the frozen column behaves like the main table (the + # handler only uses the row, which both views share). + self.table_view.frozen.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.table_view.frozen.customContextMenuRequested.connect(self.context_menu) header.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) header.customContextMenuRequested.connect(self.header_context_menu) - def unmount_button_clicked(self): - self.unmount.emit() + # Columns at full content width (horizontal scroll instead of + # squishing); done ONCE so later data refreshes don't fight manual + # column adjustments. + self._columns_autosized = False + if self.table_model.rowCount() > 0: + self._autosize_columns() + + def _autosize_columns(self) -> None: + self.table_view.resizeColumnsToContents() + header = self.table_view.horizontalHeader() + # Cap: one long comment must not eat the whole view. + for col in range(1, self.table_model.columnCount()): + if header.sectionSize(col) > 300: + self.table_view.setColumnWidth(col, 300) + # Re-pin the frozen display column after the autosize pass. + self.table_view.set_frozen_width(FrozenColumnTableView.FROZEN_WIDTH) + self._columns_autosized = True @Slot(SampleShortInfoList) def new_sample_list(self, samples: SampleShortInfoList): self.table_model.updateData(samples=samples.s) + if not self._columns_autosized and self.table_model.rowCount() > 0: + self._autosize_columns() def annotate_sample_comment(self, db_id: int, comment: str) -> None: samples = list(self.table_model.samples) @@ -97,6 +268,29 @@ class TellSamplePanel(QFrame): self.table_model.updateData(samples=updated_samples) + @Slot(SampleShortInfoList) + def _flag_dropped_samples(self, samples: SampleShortInfoList) -> None: + # Flagged is display state owned by the (shared) model, so relabeling + # here reaches the docked panel and the pop-out alike. + for sample in samples.s: + self.table_model.set_flagged(sample.db_id, True) + + def set_status_chip(self, key: str | None) -> None: + """Check the chip for `key` without firing its filter — keeps the + main and pop-out chip rows in sync (both drive one shared model).""" + for chip in self.status_chips.buttons(): + if chip.property("status_key") == key: + chip.setChecked(True) + return + + def _selected_samples(self, clicked_row: int) -> list[SampleShortInfo]: + """Selected rows if the clicked row is part of the selection, else + just the clicked row — so right-click on an unselected row acts on it.""" + rows = sorted({i.row() for i in self.table_view.selectionModel().selectedRows()}) + if clicked_row not in rows: + rows = [clicked_row] + return [self.table_model.get_id(r) for r in rows] + def context_menu(self, position): index = self.table_view.indexAt(position) if not index.isValid(): @@ -108,18 +302,35 @@ class TellSamplePanel(QFrame): if sample.location is None: return + selected = [s for s in self._selected_samples(row) if s.location is not None] + menu = QMenu() + count = f" ({len(selected)})" if len(selected) > 1 else "" + add_queue_action = menu.addAction(f"Add to queue{count}") + remove_queue_action = menu.addAction(f"Remove from queue{count}") + menu.addSeparator() mount_action = menu.addAction("Mount") + # Unmount moved here from the removed bottom-row button — same signal. + unmount_action = menu.addAction("Unmount") action = menu.exec_(self.table_view.viewport().mapToGlobal(position)) if action == mount_action: self.mount.emit(sample) + elif action == unmount_action: + self.unmount.emit() + elif action == add_queue_action: + self.add_to_queue.emit(SampleShortInfoList(s=selected)) + self.table_view.clearSelection() + elif action == remove_queue_action: + self.remove_from_queue.emit(SampleShortInfoList(s=selected)) + self.table_view.clearSelection() def header_context_menu(self, pos): header = self.table_view.horizontalHeader() logical_index = header.logicalIndexAt(pos) - if logical_index < 0: + # The "#"/status column is display-only — no filter menus there. + if logical_index < 1: return col_name = self.table_model.header[logical_index] @@ -127,7 +338,7 @@ class TellSamplePanel(QFrame): menu = QMenu(self) # Column-specific preset submenus (existing logic) ... - if logical_index == 0: + if logical_index == 1: # Sample name presets = self.table_model.suggested_prefixes_for_sample_name() if presets: prefix_menu = menu.addMenu("Filter by name prefix") @@ -138,7 +349,7 @@ class TellSamplePanel(QFrame): logical_index, vv ) ) - elif logical_index == 3: + elif logical_index == 4: # Location segs, segpos = self.table_model.suggested_prefixes_for_location() if segs: seg_menu = menu.addMenu("Filter by segment (A..F,X,R)") @@ -175,7 +386,7 @@ class TellSamplePanel(QFrame): clear_filter_action = menu.addAction(f"Clear filter: {col_name}") clear_all_action = menu.addAction("Clear all filters") - if logical_index == 5: + if logical_index == 6: # User menu.addSeparator() toggle_all = menu.addAction("Show all pgroups (ignore current p-group)") toggle_all.setCheckable(True) @@ -203,48 +414,16 @@ class TellSamplePanel(QFrame): @Slot(DAQStatusModel) def update_daq_status(self, status: DAQStatusModel): + # Mounted state shows as the row highlight only — the "No sample + # mounted" label and Unmount button were dropped; TELL activity text + # lives in the beamline state panel already. sample = status.sample - tell_state = status.tell_state - - tell_details = "" - if tell_state is not None: - activity = tell_state.activity.display_name() - phase = tell_state.phase.display_name() if tell_state.phase is not None else "" - message = (tell_state.message or "").strip() - - tell_parts = [activity] - if phase: - tell_parts.append(phase) - - tell_details = " / ".join(tell_parts) - if message: - tell_details = f"{tell_details} — {message}" - if sample is None: - base_text = "No sample mounted" self.table_model.updateCurrentSample(current_puck=None, current_sample=None) else: - try: - if sample.location is None: - base_text = f"Current sample: {sample.sample_name} (Manual mount)" - else: - base_text = ( - f"Current sample: {sample.sample_name} " - f"({sample.location.segment}{sample.location.pos}-{sample.pin})" - ) - self.table_model.updateCurrentSample( - current_puck=sample.puck_name, current_sample=sample.db_id - ) - except Exception as e: - logger.debug("Could not build the TELL sample panel text", exc_info=True) - base_text = f"Confusing information :/ {e}" - - if tell_details: - self.curr_sample_label.setText( - f"{base_text}
TELL: {tell_details}" + self.table_model.updateCurrentSample( + current_puck=sample.puck_name, current_sample=sample.db_id ) - else: - self.curr_sample_label.setText(base_text) if status.session.current_pgroup is not None: self._current_pgroup = status.session.current_pgroup diff --git a/src/aare/gui/panels/zoom_panel.py b/src/aare/gui/panels/zoom_panel.py index 27bdc8f2..6ae14f56 100644 --- a/src/aare/gui/panels/zoom_panel.py +++ b/src/aare/gui/panels/zoom_panel.py @@ -23,7 +23,9 @@ class ZoomPanel(QWidget): {"name": "7.5x", "value": 800}, {"name": "12.5x", "value": 1000}, ] - grid_layout.addWidget(TitleLabel("Zoom", self), 0, 0, 1, 2) + grid_layout.addWidget( + TitleLabel("Zoom", self, collapsible=True, default_collapsed=False), 0, 0, 1, 2 + ) i = 2 self._buttons = [] diff --git a/src/aare/gui/scan_logic/raster_grid_manager.py b/src/aare/gui/scan_logic/raster_grid_manager.py index c6d7d163..0457ee81 100644 --- a/src/aare/gui/scan_logic/raster_grid_manager.py +++ b/src/aare/gui/scan_logic/raster_grid_manager.py @@ -17,6 +17,7 @@ from PySide6.QtCore import QLineF, QObject, QPointF, QRect, QRectF, Qt, Signal, from PySide6.QtGui import QBrush, QColor, QImage, QPainter, QPen from aare.gui.constants import LOGGER_NAME +from aare.gui.styles import RASTER_GRID_LINE, qcolor logger = setup_logger(LOGGER_NAME) @@ -643,7 +644,9 @@ class RasterGridManager(QObject): painter.drawImage(bounds, image) painter.setOpacity(1.0) - painter.setPen(QPen(QColor(114, 159, 207, min(255, alpha + 40)), 1, Qt.PenStyle.SolidLine)) + painter.setPen( + QPen(qcolor(RASTER_GRID_LINE, min(255, alpha + 40)), 1, Qt.PenStyle.SolidLine) + ) painter.setBrush(Qt.BrushStyle.NoBrush) painter.drawRect(bounds) painter.restore() @@ -697,7 +700,7 @@ class RasterGridManager(QObject): max_value = max((x for x in values if x is not None and not math.isnan(x)), default=1) diff = 1 if min_value == max_value else (max_value - min_value) else: - painter.setPen(QPen(QColor(114, 159, 207), 1, Qt.PenStyle.SolidLine)) + painter.setPen(QPen(qcolor(RASTER_GRID_LINE), 1, Qt.PenStyle.SolidLine)) min_value = 0 diff = 1 @@ -732,7 +735,7 @@ class RasterGridManager(QObject): painter.setBrush(Qt.BrushStyle.NoBrush) else: painter.setPen( - QPen(QColor(114, 159, 207, min(255, alpha + 40)), 1, Qt.PenStyle.SolidLine) + QPen(qcolor(RASTER_GRID_LINE, min(255, alpha + 40)), 1, Qt.PenStyle.SolidLine) ) painter.setBrush(Qt.BrushStyle.NoBrush) painter.drawRect(bounds) @@ -760,7 +763,7 @@ class RasterGridManager(QObject): painter.save() painter.setRenderHint(QPainter.RenderHint.Antialiasing, False) - painter.setPen(QPen(QColor(114, 159, 207), 1, Qt.PenStyle.SolidLine)) + painter.setPen(QPen(qcolor(RASTER_GRID_LINE), 1, Qt.PenStyle.SolidLine)) painter.setBrush(Qt.BrushStyle.NoBrush) painter.drawRect(bounds) @@ -854,7 +857,9 @@ class RasterGridManager(QObject): brush = float_to_viridis_brush((value - min_value) / diff, alpha=alpha) painter.fillRect(QRectF(px, py, draw_w, draw_h), brush) - painter.setPen(QPen(QColor(114, 159, 207, min(255, alpha + 40)), 1, Qt.PenStyle.SolidLine)) + painter.setPen( + QPen(qcolor(RASTER_GRID_LINE, min(255, alpha + 40)), 1, Qt.PenStyle.SolidLine) + ) painter.setBrush(Qt.BrushStyle.NoBrush) painter.drawRect(bounds) diff --git a/src/aare/gui/styles.py b/src/aare/gui/styles.py index 1dbf1f3c..9394a5c7 100644 --- a/src/aare/gui/styles.py +++ b/src/aare/gui/styles.py @@ -1,34 +1,809 @@ from __future__ import annotations -THEME_ORIGINAL = "original" -THEME_PORTRAIT = "portrait" +from pathlib import Path +from string import Template + +# 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 +# and the widgets that import these use them everywhere. Every UPPERCASE +# constant is exposed to the QSS templates as $lowercase_name (see _palette), +# so no color literals live inside the stylesheets themselves. +# Any value can be "transparent" (e.g. to hide a border or a fill). +# string.Template ($name) instead of f-strings so the QSS braces stay literal. + +# -- Light theme ------------------------------------------------------------ +BACKGROUND = "#e2e7ee" +# 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) +COMPACT_BORDER = "transparent" # compact-automation cards and buttons + +# -- Box-frame borders by nesting level ------------------------------------- +# One knob per level so the amount of "boxing" is tunable in one place: +# L1: top-level pop-out window edge (painted by PopoutWindow) +# L2: panel frames inside a dock/tab (TELL sample changer, reference +# tools, sample queue) — transparent by default: banner + table +# already delimit the panel, extra boxes read as clutter +# L3: data views (tables, log text) — a light line so scrollable content +# keeps a visible edge +# (The tab-widget pane is a fourth box, already governed by BORDER above.) +FRAME_L1_WIDTH = "0px" # default off — the pop-out reads as one clean surface +FRAME_L1_COLOR = "#bdbdbd" +FRAME_L2_WIDTH = "0px" +FRAME_L2_COLOR = "transparent" +FRAME_L3_WIDTH = "1px" +FRAME_L3_COLOR = "#c9cfd8" + +# Left inset (px, int — used in code, not QSS) for bottom-dock content so its +# left edge lines up with the left-column panels above (Loop centering). +DOCK_CONTENT_LEFT_PAD = 10 + +# 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" + +# Selection highlight (combo popups, menus, list rows, text selections). +# Qt's style palette used to supply its own blue here; now it is the banner +# blue, and THIS is the knob for it. (Table-row selection has its own paler +# knob: SAMPLE_STATUS_SELECTED_BG.) +SELECTION_BG = BANNER +SELECTION_TEXT = "#263043" # same as TEXT — readable on the banner blue + +# Compact-automation page: +COMPACT_CARD_BG = "#e6eefc" +COMPACT_TITLE = "#17324d" # section titles + menu/secondary button text +COMPACT_HINT = "#51657d" # secondary text: hints, queue titles +COMPACT_VALUE = "#10263a" +COMPACT_MENU_BG = "#cbdcf8" +COMPACT_MENU_BG_HOVER = "#bfd4f6" +PRIMARY = "#2563eb" # main action button +PRIMARY_HOVER = "#1d4ed8" +PRIMARY_TEXT = "white" +SECONDARY_BG = "#dfe9fb" +SECONDARY_BG_HOVER = "#d3e1f8" + +# Alert banners (alertKind: error / success / waiting=warning): +# 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" +STATUS_LABEL_TEXT = "#2f3b52" +STATE_TOGGLE_TEXT = "white" # collapse glyph sitting on the BANNER strip +STATE_CURRENT_TEXT = "#1e293b" # same slate as HEADING_TEXT, separate knob +STATE_TELL_TEXT = "#374357" # also the status-bar TELL line + +# -- 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" +# 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 = "#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 = "#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) ------------ +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 -------------------------------------------------------------- +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 = "#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 --------------------------------------------------- +# 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 = "#dce0e8" # latte crust (progress-bar track) +SPLASH_BORDER = "#bcc0cc" # latte surface1 +SPLASH_ACCENT = "#1e66f5" # latte blue +SPLASH_TEXT = "#4c4f69" # latte text — bar % and loading message + +# -- Numeric inputs --------------------------------------------------------- +# Translucent, not solid white: the sky gradient shimmers through the field +# while text stays on a light ground. Raise the % for a more solid face. +INPUT_BG = "rgba(255, 255, 255, 33%)" +INPUT_INVALID_BG = "#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, + } + + +# -- 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" +SAMPLE_ROW_QUEUED_BG = "#729fcf" # mounted/current-sample row (legacy name) +SAMPLE_ROW_HIGHLIGHT_BG = "#d8e4fd" +RASTER_GRID_LINE = "#729fcf" +TABLE_SHADE_BG = "#e0e0e0" + +# -- Sample status row tints (dewar/queue view) ----------------------------- +# Status colors now fill the rounded dot in the table's status column; rows +# themselves alternate WHITE / SAMPLE_ROW_ALT_BG. Tune the palette here. +SAMPLE_ROW_ALT_BG = "#eef1f5" # staggered row grey (alternates with white) +SAMPLE_STATUS_QUEUED_BG = "#ffe4c4" # pale orange — waiting in the automation queue +SAMPLE_STATUS_FLAGGED_BG = "#ffd9d9" # pale red — automation failed on this sample +SAMPLE_STATUS_MEASURED_BG = "#dcf2e0" # pale green — already has collected data +SAMPLE_STATUS_SELECTED_BG = "#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) ------------ +# 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 old pure-green vs CSS-green split +# between chart and overlay collapses to the single Latte green. +CLASS_COLORS = { + "pin": "#d20f39", # red + "loop_all": "#40a02b", # green + "loop_face": "#df8e1d", # yellow + "crystal": "#1e66f5", # blue + "needle": "#ea76cb", # pink + "ice": "#04a5e5", # sky +} +CHART_CLASS_COLORS = { + "Pin": "#d20f39", + "Loop_all": "#40a02b", + "Loop_face": "#df8e1d", + "Crystal": "#1e66f5", + "Needle": "#ea76cb", + "Ice": "#04a5e5", +} +TARGET_COLORS = {"Cyan": "#04a5e5", "Dark Blue": "#1e66f5", "Dark Red": "#e64553"} +BOOKMARK_COLORS = { + "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) --------------------------------- +# 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) ------------ +# 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 = "#d20f39" # red + +# -- Generic panels (developer help, raster table) -------------------------- +PANEL_BG_SOFT = "#f6f6f6" +PANEL_BG_FAINT = "#fafafa" +PANEL_BORDER = "#d0d0d0" +PANEL_BORDER_DARK = "#bdbdbd" +PANEL_BORDER_LIGHT = "#e0e0e0" +TUTORIAL_BORDER = "#555555" +TUTORIAL_HIGHLIGHT = "#ffff00" # widget spotlight pen (was Qt.yellow) + +# -- Typography ladder (role-named; tune sizes here, not in widgets) -------- +FONT_HERO = "28px" # portrait main action button +FONT_ALERT = "18px" # alert banner text +FONT_VALUE = "18px" # prominent values, big glyph buttons +FONT_TITLE = "16px" # section / panel titles +FONT_BODY_LG = "14px" # primary buttons, emphasized body text — always bold +FONT_BODY = "14px" # default UI text +FONT_LABEL = "14px" # form labels, secondary text — always bold +FONT_HINT = "12px" # hints +FONT_FINE = "11px" # fine print, queue titles + +# -- Hover tooltips (the QToolTip popup; styled borderless) ----------------- +TOOLTIP_BG = "#f7f9fc" +TOOLTIP_FG = "#263043" +DARK_TOOLTIP_BG = "#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) ---------------------------------------- +# 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 +FLAT_CARD_RADIUS = "0px" # recovery + console-log cards stay square +# --------------------------------------------------------------------------- + + +def _palette() -> dict[str, str]: + # Why: one substitution mapping instead of ~40 kwargs per stylesheet, and + # a renamed/missing constant fails loudly (KeyError) instead of silently. + return {k.lower(): v for k, v in globals().items() if k.isupper() and isinstance(v, str)} + + +def qcolor(color: str, alpha: int | None = None): + """QColor from a palette hex string, optional 0-255 alpha. + + Hue lives here (theme), alpha stays at the call site (behavior). Lazy Qt + import so this module — and its self-check — stay importable without + PySide6. + """ + from PySide6.QtGui import QColor + + c = QColor(color) + if alpha is not None: + c.setAlpha(alpha) + return c + + +def card_style( + bg: str, + border: str, + text: str | None = None, + *, + selector: str = "QFrame", + border_px: int = 1, + radius: str = CARD_RADIUS, + extra: str = "", +) -> str: + """One QSS rule for every card-shaped frame: geometry stays uniform + (CARD_RADIUS unless overridden), only the role colors vary. `extra` + appends declarations (padding, font-weight, …) inside the same rule.""" + color = f" color: {text};" if text else "" + tail = f" {extra}" if extra else "" + return ( + f"{selector} {{ background: {bg};{color}" + f" border: {border_px}px solid {border}; border-radius: {radius};{tail} }}" + ) def build_app_stylesheet(theme: str) -> str: - if theme == THEME_PORTRAIT: - return _portrait_stylesheet() - 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: - return """ - QMainWindow, QWidget { - background-color: rgb(216, 228, 253); - color: rgb(30, 41, 59); +def _sunrise_stylesheet(overrides: dict[str, str] | None = None) -> str: + mapping = _palette() + if overrides: + mapping.update(overrides) + return Template(""" + /* 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: rgb(216, 228, 253); + background-color: transparent; } QWidget#portraitModePage { - background-color: #071018; + background-color: $dark_bg; + } + + /* Borderless hover hints. The transparent border is required — QToolTip + only honours the stylesheet background once a border is set. */ + QToolTip { + background-color: $tooltip_bg; + color: $tooltip_fg; + border: 1px solid transparent; + padding: 4px 6px; } QFrame#compactAutomationPanel { - background: #d8e4fd; + background: transparent; border: none; border-radius: 18px; } @@ -38,191 +813,442 @@ def _original_stylesheet() -> str: QFrame#compactProgressCard, QFrame#compactQueueCard, QFrame#compactQueueItem { - background: #e6eefc; - border: 1px solid #b9ccee; + background: $compact_card_bg; + border: 1px solid $compact_border; border-radius: 16px; } QLabel#compactSectionTitle { background: transparent; - color: #17324d; - font-size: 14px; + color: $compact_title; + font-size: $font_body; font-weight: 700; } QLabel#compactSectionHint { background: transparent; - color: #51657d; - font-size: 12px; + color: $compact_hint; + font-size: $font_hint; } QLabel#compactQueueTitle { background: transparent; - color: #51657d; - font-size: 11px; + color: $compact_hint; + font-size: $font_fine; font-weight: 700; } QLabel#compactQueueValue { background: transparent; - color: #10263a; - font-size: 14px; + color: $compact_value; + font-size: $font_body; font-weight: 700; } QToolButton#compactMenuButton { - background: #cbdcf8; - color: #17324d; - border: 1px solid #9fb9e5; + background: $compact_menu_bg; + color: $compact_title; + border: 1px solid $compact_border; border-radius: 14px; padding: 10px 14px; - font-size: 18px; + font-size: $font_value; font-weight: 700; } QToolButton#compactMenuButton:hover { - background: #bfd4f6; + background: $compact_menu_bg_hover; } QPushButton#compactPrimaryButton { - background: #2563eb; - color: white; + background: $primary; + color: $primary_text; border: none; border-radius: 14px; padding: 14px 18px; - font-size: 15px; + font-size: $font_body_lg; font-weight: 700; } QPushButton#compactPrimaryButton:hover { - background: #1d4ed8; + background: $primary_hover; } QPushButton#compactSecondaryButton, QToolButton#compactSecondaryButton { - background: #dfe9fb; - color: #17324d; - border: 1px solid #b2c7eb; + background: $secondary_bg; + color: $compact_title; + border: 1px solid $compact_border; border-radius: 14px; padding: 14px 18px; - font-size: 14px; + font-size: $font_body; font-weight: 700; } QPushButton#compactSecondaryButton:hover, QToolButton#compactSecondaryButton:hover { - background: #d3e1f8; + background: $secondary_bg_hover; } QFrame#alertBanner[alertKind="error"] { - background-color: #fbe4e6; - border: 2px solid #d97a84; - border-radius: 12px; + background-color: $error_bg; + border: 2px solid $error_border; + border-radius: $card_radius; margin: 8px 12px 8px 12px; } QFrame#alertBanner[alertKind="success"] { - background-color: #e7f6ea; - border: 2px solid #7bbf8e; - border-radius: 12px; + background-color: $success_bg; + border: 2px solid $success_border; + border-radius: $card_radius; margin: 8px 12px 8px 12px; } QFrame#alertBanner[alertKind="waiting"] { - background-color: #fff8e1; - border: 2px solid #ffb300; - border-radius: 12px; + background-color: $warning_bg; + border: 2px solid $warning_border; + border-radius: $card_radius; margin: 8px 12px 8px 12px; } QFrame#alertBanner QLabel { font-weight: 700; - font-size: 20px; + font-size: $font_alert; padding: 2px 6px 2px 6px; } QFrame#alertBanner[alertKind="error"] QLabel { - color: #8f1d2c; + color: $error_text; } QFrame#alertBanner[alertKind="success"] QLabel { - color: #1f6a3a; + color: $success_text; } QFrame#alertBanner[alertKind="waiting"] QLabel { - color: #e65100; + color: $warning_text; } - QWidget#axisVideoStatusContainer[busyState="idle"] { - border-radius: 12px; - background-color: #d9e2f2; + /* 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: 12px; + 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: #2f3b52; - font-weight: bold; + QRadioButton::indicator:checked { + image: none; + background: $primary; } - QFrame#beamlineStatePanel { - background: rgb(216, 228, 253); - border: 1px solid rgb(185, 204, 238); - border-radius: 12px; + QCheckBox::indicator:disabled, QRadioButton::indicator:disabled { + background: $disabled_input_bg; } - QLabel#beamlineStateTitle { - background-color: #4B0082; - color: #ffffff; + /* Disabled = baton-gated ("watch only"): the explicit colors above mask + Qt's native grey, so spell the greyed state out. */ + QPushButton:disabled, QCheckBox:disabled, QRadioButton:disabled, + QLabel:disabled, QComboBox:disabled, QLineEdit:disabled, + QAbstractSpinBox:disabled, QTabBar::tab:disabled { + color: $faint_text; } - QPushButton#beamlineStateToggleButton { + QLineEdit:disabled, QAbstractSpinBox:disabled, QComboBox:disabled { + background: $disabled_input_bg; + } + + QSlider::sub-page:horizontal:disabled { + background: $slider_muted; + } + + QTabWidget::pane { + border: 1px solid $border; + } + + /* Exp. Config. pane: hand-rolled QTabBar+QStackedWidget (button row sits + between bar and pages), so it needs the pane border by hand too. */ + #expConfigPane { + border: 1px solid $border; + } + + /* Borderless tabs: the pane isn't outlined, so the native 3-side tab + boxes hung in the air. Selection shows via fill + the raised effect + (unselected tabs sit 3px lower). */ + QTabBar::tab { + background: transparent; border: none; - border-radius: 14px; - background-color: rgba(255, 255, 255, 0.20); - color: white; - font-size: 16px; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + padding: 4px 14px; + margin-top: 3px; + color: $muted_text; + } + + QTabBar::tab:selected { + background: $tab_face_bg; + color: $text; + margin-top: 0px; + padding: 6px 14px 5px 14px; + } + + /* Menu-bar-style hover hint: text-decoration underlines only the text + itself (short, close to the baseline) instead of the full tab box. */ + QTabBar::tab:hover:!selected { + color: $text; + text-decoration: underline; + } + + /* 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; } - QPushButton#beamlineStateToggleButton:hover { - background-color: rgba(255, 255, 255, 0.32); + /* 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, + QFrame#dataCollectionSettings { + border: 1px solid $border; + } + + QFrame#beamlineStatePanel { + background: transparent; + border-top: 1px solid $border; + } + + QLabel#beamlineStateTitle { + 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; + } + + /* 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. */ + QPushButton#beamlineStateToggleButton { + border: none; + background: transparent; + color: $state_toggle_text; + font-size: $font_body; + font-weight: 700; } QLabel#beamlineStateCurrentLabel { - color: rgb(30, 41, 59); - font-size: 18px; + color: $state_current_text; + font-size: $font_value; font-weight: 700; padding-left: 4px; background: transparent; } QLabel#beamlineStateTellLabel { - color: rgb(55, 67, 87); - font-size: 15px; - font-weight: 600; + color: $state_tell_text; + font-size: $font_body_lg; + font-weight: 700; padding-left: 4px; background: transparent; } + /* Plain scroll containers stay frameless. */ + QScrollArea { + border: none; + } + + /* Box-frame levels — weights/colors are knobs in styles.py. L2: panel + frames (replaces their native StyledPanel etching); L3: data views. */ + TellSamplePanel, ReferenceToolsPanel, SampleQueuePanel { + border: $frame_l2_width solid $frame_l2_color; + } + + /* No L3 border — matches the dark theme (borderless data views). */ + QTableView, QPlainTextEdit { + border: none; + } + + /* Pale-blue selection with readable dark text in every sample table; + staggered grey/white rows where alternation is enabled. */ + QTableView { + background: $white; + alternate-background-color: $sample_row_alt_bg; + selection-background-color: $sample_status_selected_bg; + selection-color: $text; + } + + /* Selection highlight: the banner blue (SELECTION_BG in styles.py) + instead of the style palette's own blue. Tables keep their paler + selection rule below. */ + QListView, QTreeView, + QComboBox QAbstractItemView { + selection-background-color: $selection_bg; + selection-color: $selection_text; + } + + QMenu::item:selected { + background: $selection_bg; + color: $selection_text; + } + + QLineEdit, QAbstractSpinBox, QTextEdit, QPlainTextEdit { + selection-background-color: $selection_bg; + selection-color: $selection_text; + } + + /* Sliders: rounded groove ends (like the scrollbar handles) + 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: $slider_track; + border-radius: 3px; + } + + QSlider::sub-page:horizontal { + background: $slider_fill; + border-radius: 3px; + } + + QSlider::handle:horizontal { + background: $white; + border: 1px solid $slider_muted; + width: 14px; + margin: -3px 0; + image: url($slider_grip); + } + + /* Soft scrollbars: square track band (runs flush to the widget edges — + rounded ends left corner notches in the tables) + rounded draggable + handle, no end arrows. */ + QScrollBar:vertical { + background: $scrollbar_track; + width: 10px; + border: none; + margin: 0px; + } + + QScrollBar:horizontal { + background: $scrollbar_track; + height: 10px; + border: none; + margin: 0px; + } + + QScrollBar::handle:vertical { + background: $scrollbar_handle; + border-radius: 5px; + min-height: 24px; + } + + QScrollBar::handle:horizontal { + background: $scrollbar_handle; + border-radius: 5px; + min-width: 24px; + } + + QScrollBar::handle:vertical:hover, + QScrollBar::handle:vertical:pressed, + QScrollBar::handle:horizontal:hover, + QScrollBar::handle:horizontal:pressed { + background: $scrollbar_handle_hover; + } + + QScrollBar::add-line, QScrollBar::sub-line { + width: 0px; + height: 0px; + } + + QScrollBar::add-page, QScrollBar::sub-page { + background: transparent; + } + + /* The two scrollbar bands meet in a track-colored corner — no white + square, the bands read as one continuous edge. */ + QAbstractScrollArea::corner { + background: $scrollbar_track; + border: none; + } + QWidget#portraitRoot, QWidget#portraitRoot QWidget { - background: #071018; - color: #F5F7FA; + background: $dark_bg; + color: $dark_text; font-family: 'Inter', 'SF Pro Display', Arial, sans-serif; - font-size: 14px; + font-size: $font_body; } QWidget#portraitRoot QScrollArea { @@ -231,13 +1257,13 @@ def _original_stylesheet() -> str: } QWidget#portraitRoot QScrollBar:vertical { - background: #0E1A26; + background: $dark_surface; width: 4px; border-radius: 2px; } QWidget#portraitRoot QScrollBar::handle:vertical { - background: #1A3A36; + background: $dark_border; border-radius: 2px; min-height: 20px; } @@ -246,33 +1272,258 @@ def _original_stylesheet() -> str: QWidget#portraitRoot QScrollBar::sub-line:vertical { height: 0px; } - """ + """).substitute(mapping) -def _portrait_stylesheet() -> str: - return """ - QMainWindow, QWidget { - background: #071018; - color: #F5F7FA; +def _sunset_stylesheet() -> str: + return Template(""" + /* Sunset-sky gradient — same transparent-children scheme as the light + theme: only top-level windows paint the sky (rule order matters, see + the light-theme note). Knobs: DARK_BACKGROUND_GRADIENT_* above. */ + QWidget { + background-color: transparent; + color: $dark_text; + } + + QMainWindow, QDialog, PopoutWindow, + QDockWidget[floating="true"] { + background: $dark_app_background; } - QWidget#mainContentRoot, - QWidget#standardMainPage, - QWidget#compactAutomationPage, QWidget#portraitModePage { - background: #071018; + 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: #071018; - color: #F5F7FA; + background: transparent; + color: $dark_text; + } + + /* Borderless tabs, dark flavor — see the light-theme note. */ + QTabBar::tab { + background: transparent; + border: none; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + padding: 4px 14px; + margin-top: 3px; + color: $dark_muted; + } + + QTabBar::tab:selected { + background: $dark_elevated; + color: $dark_text; + margin-top: 0px; + padding: 6px 14px 5px 14px; + } + + /* Menu-bar-style hover hint — see the light-theme note. */ + QTabBar::tab:hover:!selected { + color: $dark_text; + text-decoration: underline; + } + + /* Borderless hover hints. The transparent border is required — QToolTip + only honours the stylesheet background once a border is set. */ + QToolTip { + background-color: $dark_tooltip_bg; + color: $dark_tooltip_fg; + border: 1px solid transparent; + padding: 4px 6px; } QFrame#compactAutomationPanel { - background: #071018; + background: transparent; border: none; border-radius: 18px; } @@ -282,191 +1533,310 @@ def _portrait_stylesheet() -> str: QFrame#compactProgressCard, QFrame#compactQueueCard, QFrame#compactQueueItem { - background: #0E1A26; - border: 1px solid #1A3A36; + background: $dark_surface; + border: 1px solid $dark_border; border-radius: 16px; } QLabel#compactSectionTitle { background: transparent; - color: #62D8C8; - font-size: 14px; + color: $dark_accent; + font-size: $font_body; font-weight: 700; } QLabel#compactSectionHint { background: transparent; - color: #8A9BB0; - font-size: 12px; + color: $dark_subtext; + font-size: $font_hint; } QLabel#compactQueueTitle { background: transparent; - color: #8A9BB0; - font-size: 11px; + color: $dark_subtext; + font-size: $font_fine; font-weight: 700; } QLabel#compactQueueValue { background: transparent; - color: #F5F7FA; - font-size: 14px; + color: $dark_text; + font-size: $font_body; font-weight: 700; } QToolButton#compactMenuButton { - background: #132131; - color: #62D8C8; - border: 1px solid #1A3A36; + background: $dark_elevated; + color: $dark_accent; + border: 1px solid $dark_border; border-radius: 14px; padding: 10px 14px; - font-size: 18px; + font-size: $font_value; font-weight: 700; } QToolButton#compactMenuButton:hover { - background: #1A3A36; + background: $dark_border; } QPushButton#compactPrimaryButton { - background: #62D8C8; - color: #071018; + background: $dark_accent_fill; + color: $dark_on_accent; border: none; border-radius: 14px; padding: 14px 18px; - font-size: 15px; + font-size: $font_body_lg; font-weight: 700; } QPushButton#compactPrimaryButton:hover { - background: #7ce6d8; + background: $dark_accent_fill_hover; } QPushButton#compactSecondaryButton, QToolButton#compactSecondaryButton { - background: #132131; - color: #F5F7FA; - border: 1px solid #1A3A36; + background: $dark_elevated; + color: $dark_text; + border: 1px solid $dark_border; border-radius: 14px; padding: 14px 18px; - font-size: 14px; + font-size: $font_body; font-weight: 700; } QPushButton#compactSecondaryButton:hover, QToolButton#compactSecondaryButton:hover { - background: #1A3A36; + background: $dark_border; } QFrame#alertBanner[alertKind="error"] { - background: #1A0E0E; - border: 2px solid #8f1d2c; - border-radius: 12px; + background: $dark_error_bg; + border: 2px solid $dark_error_border; + border-radius: $card_radius; margin: 8px 12px 8px 12px; } QFrame#alertBanner[alertKind="success"] { - background: #0E1A12; - border: 2px solid #2a7a44; - border-radius: 12px; + background: $dark_success_bg; + border: 2px solid $dark_success_border; + border-radius: $card_radius; margin: 8px 12px 8px 12px; } QFrame#alertBanner[alertKind="waiting"] { - background: #2B2208; - border: 2px solid #ffb300; - border-radius: 12px; + background: $dark_warning_bg; + border: 2px solid $dark_warning_border; + border-radius: $card_radius; margin: 8px 12px 8px 12px; } QFrame#alertBanner QLabel { font-weight: 700; - font-size: 20px; + font-size: $font_alert; padding: 2px 6px 2px 6px; } QFrame#alertBanner[alertKind="error"] QLabel { - color: #ffb3bc; + color: $dark_error_text; } QFrame#alertBanner[alertKind="success"] QLabel { - color: #a8f0c0; + color: $dark_success_text; } QFrame#alertBanner[alertKind="waiting"] QLabel { - color: #ffd166; - } - - QWidget#axisVideoStatusContainer[busyState="idle"] { - border-radius: 12px; - background-color: #132131; - } - - QWidget#axisVideoStatusContainer[busyState="active"] { - border-radius: 12px; - } - - QLabel#axisVideoStatusDot { - min-width: 10px; - max-width: 10px; - min-height: 10px; - max-height: 10px; - border-radius: 5px; - background-color: transparent; - } - - QLabel#axisVideoStatusLabel { - background-color: transparent; - color: #8A9BB0; - font-weight: bold; + color: $dark_warning_text; } QFrame#beamlineStatePanel { - background: #0E1A26; - border: 1px solid #1A3A36; - border-radius: 12px; + background: transparent; + border-top: 1px solid transparent; } QLabel#beamlineStateTitle { - background-color: #132131; - color: #F5F7FA; + 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; } + /* Bare glyph to match the TitleLabel toggles: no pill background. */ QPushButton#beamlineStateToggleButton { border: none; - border-radius: 14px; - background-color: rgba(255, 255, 255, 0.10); - color: #F5F7FA; - font-size: 16px; + background: transparent; + color: $dark_text; + font-size: $font_body; font-weight: 700; } - QPushButton#beamlineStateToggleButton:hover { - background-color: rgba(255, 255, 255, 0.18); - } - QLabel#beamlineStateCurrentLabel { - color: #F5F7FA; - font-size: 18px; + color: $dark_text; + font-size: $font_value; font-weight: 700; padding-left: 4px; background: transparent; } QLabel#beamlineStateTellLabel { - color: #8A9BB0; - font-size: 15px; - font-weight: 600; + /* 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. 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: none; + } + QPlainTextEdit { + background: $dark_table_bg; /* solid — transparent renders black on the container's X11 */ + } + + /* Selection + staggered rows, dark flavor. Solid fills on purpose — + a transparent viewport renders black here (see DARK_TABLE_BG). */ + QTableView { + 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 { + selection-background-color: $selection_bg; + selection-color: $selection_text; + } + + QMenu::item:selected { + background: $selection_bg; + color: $selection_text; + } + + QLineEdit, QAbstractSpinBox, QTextEdit, QPlainTextEdit { + selection-background-color: $selection_bg; + selection-color: $selection_text; + } + + /* Sliders, dark flavor — see the light-theme note. */ + QSlider::groove:horizontal { + height: 6px; + background: $dark_border; + border-radius: 3px; + } + + QSlider::sub-page:horizontal { + background: $dark_accent; + border-radius: 3px; + } + + QSlider::handle:horizontal { + background: $dark_elevated; + border: 1px solid $dark_muted; + width: 14px; + margin: -3px 0; + image: url($dark_slider_grip); + } + + /* Soft scrollbars: square track band + rounded handle, no end arrows + (see light-theme note on the corner notches). */ + QScrollBar:vertical { + background: $dark_surface; + width: 10px; + border: none; + margin: 0px; + } + + QScrollBar:horizontal { + background: $dark_surface; + height: 10px; + border: none; + margin: 0px; + } + + QScrollBar::handle:vertical { + background: $dark_border; + border-radius: 5px; + min-height: 24px; + } + + QScrollBar::handle:horizontal { + background: $dark_border; + border-radius: 5px; + min-width: 24px; + } + + QScrollBar::handle:vertical:hover, + QScrollBar::handle:vertical:pressed, + QScrollBar::handle:horizontal:hover, + QScrollBar::handle:horizontal:pressed { + background: $dark_muted; + } + + QScrollBar::add-line, QScrollBar::sub-line { + width: 0px; + height: 0px; + } + + QScrollBar::add-page, QScrollBar::sub-page { + background: transparent; + } + + /* The two scrollbar bands meet in a track-colored corner. */ + QAbstractScrollArea::corner { + background: $dark_surface; + border: none; + } + QWidget#portraitRoot, QWidget#portraitRoot QWidget { - background: #071018; - color: #F5F7FA; + background: $dark_bg; + color: $dark_text; font-family: 'Inter', 'SF Pro Display', Arial, sans-serif; - font-size: 14px; + font-size: $font_body; } QWidget#portraitRoot QScrollArea { @@ -475,13 +1845,13 @@ def _portrait_stylesheet() -> str: } QWidget#portraitRoot QScrollBar:vertical { - background: #0E1A26; + background: $dark_surface; width: 4px; border-radius: 2px; } QWidget#portraitRoot QScrollBar::handle:vertical { - background: #1A3A36; + background: $dark_border; border-radius: 2px; min-height: 20px; } @@ -490,4 +1860,13 @@ def _portrait_stylesheet() -> str: QWidget#portraitRoot QScrollBar::sub-line:vertical { height: 0px; } - """ + """).substitute(_palette()) + + +if __name__ == "__main__": + # ponytail: smallest check that fails if a $name has no matching constant + for _theme in (THEME_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/tutorials/tutorial_manager.py b/src/aare/gui/tutorials/tutorial_manager.py index 87745a4d..8180a238 100644 --- a/src/aare/gui/tutorials/tutorial_manager.py +++ b/src/aare/gui/tutorials/tutorial_manager.py @@ -15,10 +15,21 @@ from PySide6.QtCore import ( QTimer, Signal, ) -from PySide6.QtGui import QColor, QPainter, QPen +from PySide6.QtGui import QPainter, QPen from PySide6.QtWidgets import QLabel, QPushButton, QWidget from aare.gui.constants import LOGGER_NAME +from aare.gui.styles import ( + DEFAULT_TEXT, + FONT_BODY_LG, + FONT_TITLE, + NOTE_TEXT, + SHADOW, + TUTORIAL_BORDER, + TUTORIAL_HIGHLIGHT, + WHITE, + qcolor, +) from aare.gui.tutorials.tutorial_models import ( StepFlow, StepStatus, @@ -95,23 +106,23 @@ class TutorialOverlay(QWidget): self.anim.valueChanged.connect(self.update) self.callout = QLabel(self) - self.callout.setStyleSheet(""" - background: white; - color: black; + self.callout.setStyleSheet(f""" + background: {WHITE}; + color: {DEFAULT_TEXT}; padding: 16px; - border: 2px solid #555; + border: 2px solid {TUTORIAL_BORDER}; border-radius: 10px; - font-size: 16px; + font-size: {FONT_TITLE}; """) self.callout.setWordWrap(True) self.callout.hide() - button_style = """ - QPushButton { - font-size: 15px; - font-weight: 600; + button_style = f""" + QPushButton {{ + font-size: {FONT_BODY_LG}; + font-weight: 700; padding: 10px 16px; - } + }} """ self.back_button = QPushButton("Back", self) @@ -185,10 +196,10 @@ class TutorialOverlay(QWidget): def paintEvent(self, event) -> None: painter = QPainter(self) - painter.fillRect(self.rect(), QColor(0, 0, 0, int(150 * self.opacity))) + painter.fillRect(self.rect(), qcolor(SHADOW, int(150 * self.opacity))) if self.current_rect.isValid(): - pen = QPen(Qt.yellow, 4) + pen = QPen(qcolor(TUTORIAL_HIGHLIGHT), 4) painter.setPen(pen) painter.setBrush(Qt.NoBrush) painter.drawRoundedRect(self.current_rect, 8, 8) @@ -219,7 +230,7 @@ class TutorialOverlay(QWidget): if view.body.strip(): text_parts.append(view.body) if view.hint: - text_parts.append(f"{view.hint}") + text_parts.append(f"{view.hint}") self.callout.setText("

".join(text_parts)) self.callout.setMaximumWidth(460) diff --git a/src/aare/gui/widgets/alert_banner.py b/src/aare/gui/widgets/alert_banner.py index aaf0ae4d..5fb4c5b0 100644 --- a/src/aare/gui/widgets/alert_banner.py +++ b/src/aare/gui/widgets/alert_banner.py @@ -1,9 +1,9 @@ from aarecommon.config.logger import setup_logger -from PySide6.QtCore import Qt, QTimer, Slot -from PySide6.QtGui import QColor +from PySide6.QtCore import QPoint, Qt, QTimer, Slot from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QHBoxLayout, QLabel, QSizePolicy from aare.gui.constants import LOGGER_NAME +from aare.gui.styles import SHADOW, qcolor logger = setup_logger(LOGGER_NAME) @@ -39,12 +39,59 @@ class AlertBanner(QFrame): shadow = QGraphicsDropShadowEffect(self) shadow.setBlurRadius(18) shadow.setOffset(0, 3) - shadow.setColor(QColor(0, 0, 0, 55)) + shadow.setColor(qcolor(SHADOW, 55)) self.setGraphicsEffect(shadow) self.setVisible(False) 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/automation_progress.py b/src/aare/gui/widgets/automation_progress.py index 4c88baab..af5ccfda 100644 --- a/src/aare/gui/widgets/automation_progress.py +++ b/src/aare/gui/widgets/automation_progress.py @@ -7,6 +7,16 @@ from aarecommon.models.automation import AutomationProgress, StepStatus, Workflo from PySide6.QtCore import QTimer, Slot from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout, QWidget +from aare.gui.styles import ( + FAINT_TEXT, + FONT_HINT, + FONT_VALUE, + STEP_FAILED_TEXT, + STEP_PAUSED_TEXT, + STEP_RUNNING_TEXT, + STEP_SUCCESS_TEXT, +) + class CompactAutomationProgressStrip(QFrame): DEFAULT_SAMPLE_ESTIMATE_S = 150.0 @@ -89,13 +99,13 @@ class CompactAutomationProgressStrip(QFrame): @staticmethod def _step_color(status: StepStatus) -> str: return { - StepStatus.PENDING: "#64748b", - StepStatus.RUNNING: "#2563eb", - StepStatus.SUCCESS: "#15803d", - StepStatus.FAILED: "#b91c1c", - StepStatus.SKIPPED: "#64748b", - StepStatus.PAUSED: "#c2410c", - }.get(status, "#64748b") + StepStatus.PENDING: FAINT_TEXT, + StepStatus.RUNNING: STEP_RUNNING_TEXT, + StepStatus.SUCCESS: STEP_SUCCESS_TEXT, + StepStatus.FAILED: STEP_FAILED_TEXT, + StepStatus.SKIPPED: FAINT_TEXT, + StepStatus.PAUSED: STEP_PAUSED_TEXT, + }.get(status, FAINT_TEXT) def _format_step_html(self, step: WorkflowStateKind, status: StepStatus) -> str: color = self._step_color(status) @@ -103,8 +113,8 @@ class CompactAutomationProgressStrip(QFrame): title = self._step_title(step) return ( f"
" - f"
{icon}
" - f"
{title}
" + f"
{icon}
" + f"
{title}
" f"
" ) @@ -163,16 +173,16 @@ class CompactAutomationProgressStrip(QFrame): eta = time.time() + queue_remaining if queue_remaining > 0 else None state_text = "Paused" - state_color = "#c2410c" + state_color = STEP_PAUSED_TEXT if progress.finished and progress.success is True: state_text = "Completed" - state_color = "#15803d" + state_color = STEP_SUCCESS_TEXT elif progress.finished and progress.success is False: state_text = "Failed" - state_color = "#b91c1c" + state_color = STEP_FAILED_TEXT elif self._running: state_text = "Running" - state_color = "#2563eb" + state_color = STEP_RUNNING_TEXT self._summary_label.setText( f"Status: " diff --git a/src/aare/gui/widgets/baton_request_dialog.py b/src/aare/gui/widgets/baton_request_dialog.py index 4ec6ae4d..7935145b 100644 --- a/src/aare/gui/widgets/baton_request_dialog.py +++ b/src/aare/gui/widgets/baton_request_dialog.py @@ -10,6 +10,24 @@ from PySide6.QtWidgets import ( QVBoxLayout, ) +from aare.gui.styles import ( + BATON_DANGER_BG, + BATON_DANGER_HOVER, + BATON_DANGER_PRESSED, + BATON_INFO, + BATON_OK_BG, + BATON_OK_HOVER, + BATON_OK_PRESSED, + BATON_WARN, + DIM_TEXT, + FONT_BODY, + FONT_LABEL, + HINT_TEXT, + LIGHT_BORDER, + PROGRESS_TRACK_BG, + WHITE, +) + class BatonRequestDialog(QDialog): """ @@ -73,22 +91,22 @@ class BatonRequestDialog(QDialog): self.progress.setValue(self._timeout) self.progress.setTextVisible(False) self.progress.setFixedHeight(8) - self.progress.setStyleSheet(""" - QProgressBar { - border: 1px solid #ccc; + self.progress.setStyleSheet(f""" + QProgressBar {{ + border: 1px solid {LIGHT_BORDER}; border-radius: 4px; - background-color: #f0f0f0; - } - QProgressBar::chunk { - background-color: #4CAF50; + background-color: {PROGRESS_TRACK_BG}; + }} + QProgressBar::chunk {{ + background-color: {BATON_OK_BG}; border-radius: 3px; - } + }} """) progress_layout.addWidget(self.progress) self.time_label = QLabel(f"{self._timeout} seconds remaining") self.time_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.time_label.setStyleSheet("color: #666;") + self.time_label.setStyleSheet(f"color: {DIM_TEXT};") progress_layout.addWidget(self.time_label) layout.addLayout(progress_layout) @@ -97,7 +115,7 @@ class BatonRequestDialog(QDialog): self.warning_label = QLabel("⚠️ If you don't respond, control will transfer automatically.") self.warning_label.setWordWrap(True) self.warning_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.warning_label.setStyleSheet("color: #ff9800; font-style: italic;") + self.warning_label.setStyleSheet(f"color: {BATON_WARN}; font-style: italic;") layout.addWidget(self.warning_label) # Buttons @@ -106,42 +124,42 @@ class BatonRequestDialog(QDialog): self.accept_btn = QPushButton("✓ Accept") self.accept_btn.setMinimumHeight(40) - self.accept_btn.setStyleSheet(""" - QPushButton { - background-color: #4CAF50; - color: white; + self.accept_btn.setStyleSheet(f""" + QPushButton {{ + background-color: {BATON_OK_BG}; + color: {WHITE}; border: none; border-radius: 5px; font-weight: bold; - font-size: 13px; - } - QPushButton:hover { - background-color: #45a049; - } - QPushButton:pressed { - background-color: #3d8b40; - } + font-size: {FONT_LABEL}; + }} + QPushButton:hover {{ + background-color: {BATON_OK_HOVER}; + }} + QPushButton:pressed {{ + background-color: {BATON_OK_PRESSED}; + }} """) self.accept_btn.clicked.connect(self._on_accept) button_layout.addWidget(self.accept_btn) self.refuse_btn = QPushButton("✗ Refuse") self.refuse_btn.setMinimumHeight(40) - self.refuse_btn.setStyleSheet(""" - QPushButton { - background-color: #f44336; - color: white; + self.refuse_btn.setStyleSheet(f""" + QPushButton {{ + background-color: {BATON_DANGER_BG}; + color: {WHITE}; border: none; border-radius: 5px; font-weight: bold; - font-size: 13px; - } - QPushButton:hover { - background-color: #da190b; - } - QPushButton:pressed { - background-color: #c41000; - } + font-size: {FONT_LABEL}; + }} + QPushButton:hover {{ + background-color: {BATON_DANGER_HOVER}; + }} + QPushButton:pressed {{ + background-color: {BATON_DANGER_PRESSED}; + }} """) self.refuse_btn.clicked.connect(self._on_refuse) button_layout.addWidget(self.refuse_btn) @@ -155,7 +173,7 @@ class BatonRequestDialog(QDialog): ) info_label.setWordWrap(True) info_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - info_label.setStyleSheet("color: #999;") + info_label.setStyleSheet(f"color: {HINT_TEXT};") layout.addWidget(info_label) def _start_timer(self): @@ -171,31 +189,31 @@ class BatonRequestDialog(QDialog): # Change progress bar color as time runs out if self._remaining <= 10: - self.progress.setStyleSheet(""" - QProgressBar { - border: 1px solid #ccc; + self.progress.setStyleSheet(f""" + QProgressBar {{ + border: 1px solid {LIGHT_BORDER}; border-radius: 4px; - background-color: #f0f0f0; - } - QProgressBar::chunk { - background-color: #ff9800; + background-color: {PROGRESS_TRACK_BG}; + }} + QProgressBar::chunk {{ + background-color: {BATON_WARN}; border-radius: 3px; - } + }} """) if self._remaining <= 5: - self.progress.setStyleSheet(""" - QProgressBar { - border: 1px solid #ccc; + self.progress.setStyleSheet(f""" + QProgressBar {{ + border: 1px solid {LIGHT_BORDER}; border-radius: 4px; - background-color: #f0f0f0; - } - QProgressBar::chunk { - background-color: #f44336; + background-color: {PROGRESS_TRACK_BG}; + }} + QProgressBar::chunk {{ + background-color: {BATON_DANGER_BG}; border-radius: 3px; - } + }} """) - self.time_label.setStyleSheet("color: #f44336; font-weight: bold;") + self.time_label.setStyleSheet(f"color: {BATON_DANGER_BG}; font-weight: bold;") if self._remaining <= 0: self._timer.stop() @@ -270,22 +288,22 @@ class BatonPendingDialog(QDialog): self.progress.setValue(self._timeout) self.progress.setTextVisible(False) self.progress.setFixedHeight(8) - self.progress.setStyleSheet(""" - QProgressBar { - border: 1px solid #ccc; + self.progress.setStyleSheet(f""" + QProgressBar {{ + border: 1px solid {LIGHT_BORDER}; border-radius: 4px; - background-color: #f0f0f0; - } - QProgressBar::chunk { - background-color: #2196F3; + background-color: {PROGRESS_TRACK_BG}; + }} + QProgressBar::chunk {{ + background-color: {BATON_INFO}; border-radius: 3px; - } + }} """) self.progress_layout.addWidget(self.progress) self.time_label = QLabel(f"{self._timeout} seconds remaining") self.time_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.time_label.setStyleSheet("color: #666;") + self.time_label.setStyleSheet(f"color: {DIM_TEXT};") self.progress_layout.addWidget(self.time_label) layout.addLayout(self.progress_layout) @@ -293,17 +311,17 @@ class BatonPendingDialog(QDialog): button_layout = QHBoxLayout() self.cancel_btn = QPushButton("✗ Cancel Request") self.cancel_btn.setMinimumHeight(40) - self.cancel_btn.setStyleSheet(""" - QPushButton { - background-color: #f44336; - color: white; + self.cancel_btn.setStyleSheet(f""" + QPushButton {{ + background-color: {BATON_DANGER_BG}; + color: {WHITE}; border: none; border-radius: 5px; font-weight: bold; - font-size: 13px; - } - QPushButton:hover { background-color: #da190b; } - QPushButton:pressed { background-color: #c41000; } + font-size: {FONT_LABEL}; + }} + QPushButton:hover {{ background-color: {BATON_DANGER_HOVER}; }} + QPushButton:pressed {{ background-color: {BATON_DANGER_PRESSED}; }} """) self.cancel_btn.clicked.connect(self._on_cancel) button_layout.addWidget(self.cancel_btn) @@ -332,7 +350,9 @@ class BatonPendingDialog(QDialog): def set_queued_state(self): self._timer.stop() self.header.setText("⏳ Transfer Queued") - self.header.setStyleSheet("color: #FF9800; font-weight: bold; font-size: 14px;") + self.header.setStyleSheet( + f"color: {BATON_WARN}; font-weight: bold; font-size: {FONT_BODY};" + ) self.message_label.setText( "The beamline is currently busy. Your request was accepted and " "the baton will be transferred as soon as the current operation completes." diff --git a/src/aare/gui/widgets/busy_overlay.py b/src/aare/gui/widgets/busy_overlay.py index d95d5a20..6013a7b2 100644 --- a/src/aare/gui/widgets/busy_overlay.py +++ b/src/aare/gui/widgets/busy_overlay.py @@ -2,7 +2,33 @@ 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, + BUSY_BLUE_BORDER, + BUSY_BLUE_DOT, + BUSY_ORANGE, + BUSY_ORANGE_BORDER, + BUSY_ORANGE_DOT, + BUSY_PSI_RED, + BUSY_PSI_RED_BORDER, + BUSY_PSI_RED_DOT, + BUSY_PURPLE, + BUSY_PURPLE_BORDER, + BUSY_PURPLE_DOT, + BUSY_RED_BADGE, + BUSY_RED_BORDER, + BUSY_RED_DOT, + BUSY_RED_FILL, + BUSY_YELLOW, + BUSY_YELLOW_BORDER, + BUSY_YELLOW_DOT, + BUSY_YELLOW_TEXT_DARK, + WHITE, + qcolor, +) @dataclass(frozen=True) @@ -14,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( @@ -24,24 +110,25 @@ def build_busy_overlay_style( ) -> BusyOverlayStyle | None: if session_state == SessionsStateEnum.Vacant: return BusyOverlayStyle( - text="SESSION VACANT", - badge_bg="#f1c40f", - badge_fg="#ffffff", - overlay_fill=QColor(241, 196, 15, 195), - overlay_border=QColor(255, 248, 210, 235), - overlay_text=QColor(255, 255, 255), - accent_dot="#fff6bf", + 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}: return BusyOverlayStyle( text="GUEST MODE", - badge_bg="#8e44ad", - badge_fg="#ffffff", - overlay_fill=QColor(142, 68, 173, 190), - overlay_border=QColor(235, 220, 245, 230), - overlay_text=QColor(255, 255, 255), - accent_dot="#f0dfff", + badge_bg=BUSY_PURPLE, + badge_fg=WHITE, + overlay_fill=qcolor(BUSY_PURPLE, 190), + overlay_border=qcolor(BUSY_PURPLE_BORDER, 230), + overlay_text=qcolor(WHITE), + accent_dot=BUSY_PURPLE_DOT, ) if not is_busy: @@ -52,53 +139,53 @@ def build_busy_overlay_style( if activity_value == "mounting": return BusyOverlayStyle( text="ROBOT MOUNTING", - badge_bg="#d64545", - badge_fg="#ffffff", - overlay_fill=QColor(190, 40, 40, 185), - overlay_border=QColor(255, 220, 220, 230), - overlay_text=QColor(255, 255, 255), - accent_dot="#ffdddd", + badge_bg=BUSY_RED_BADGE, + badge_fg=WHITE, + overlay_fill=qcolor(BUSY_RED_FILL, 185), + overlay_border=qcolor(BUSY_RED_BORDER, 230), + overlay_text=qcolor(WHITE), + accent_dot=BUSY_RED_DOT, ) if activity_value == "unmounting": return BusyOverlayStyle( text="ROBOT UNMOUNTING", - badge_bg="#e67e22", - badge_fg="#ffffff", - overlay_fill=QColor(230, 126, 34, 190), - overlay_border=QColor(255, 234, 214, 230), - overlay_text=QColor(255, 255, 255), - accent_dot="#fff0db", + badge_bg=BUSY_ORANGE, + badge_fg=WHITE, + overlay_fill=qcolor(BUSY_ORANGE, 190), + overlay_border=qcolor(BUSY_ORANGE_BORDER, 230), + overlay_text=qcolor(WHITE), + accent_dot=BUSY_ORANGE_DOT, ) if activity_value == "drying": return BusyOverlayStyle( text="ROBOT DRYING", - badge_bg="#f1c40f", - badge_fg="#3b2f00", - overlay_fill=QColor(241, 196, 15, 195), - overlay_border=QColor(255, 248, 210, 235), - overlay_text=QColor(59, 47, 0), - accent_dot="#fff6bf", + badge_bg=BUSY_YELLOW, + badge_fg=BUSY_YELLOW_TEXT_DARK, + overlay_fill=qcolor(BUSY_YELLOW, 195), + overlay_border=qcolor(BUSY_YELLOW_BORDER, 235), + overlay_text=qcolor(BUSY_YELLOW_TEXT_DARK), + accent_dot=BUSY_YELLOW_DOT, ) if activity_value == "cooling": return BusyOverlayStyle( text="ROBOT COOLING", - badge_bg="#3498db", - badge_fg="#ffffff", - overlay_fill=QColor(52, 152, 219, 190), - overlay_border=QColor(220, 240, 255, 235), - overlay_text=QColor(255, 255, 255), - accent_dot="#dff2ff", + badge_bg=BUSY_BLUE, + badge_fg=WHITE, + overlay_fill=qcolor(BUSY_BLUE, 190), + overlay_border=qcolor(BUSY_BLUE_BORDER, 235), + overlay_text=qcolor(WHITE), + accent_dot=BUSY_BLUE_DOT, ) return BusyOverlayStyle( text="BEAMLINE BUSY", - badge_bg="#e04f39", - badge_fg="#ffffff", - overlay_fill=QColor(224, 79, 57, 195), - overlay_border=QColor(255, 225, 220, 235), - overlay_text=QColor(255, 255, 255), - accent_dot="#ffd8d1", + badge_bg=BUSY_PSI_RED, + badge_fg=WHITE, + overlay_fill=qcolor(BUSY_PSI_RED, 195), + overlay_border=qcolor(BUSY_PSI_RED_BORDER, 235), + overlay_text=qcolor(WHITE), + accent_dot=BUSY_PSI_RED_DOT, ) diff --git a/src/aare/gui/widgets/camera_image.py b/src/aare/gui/widgets/camera_image.py index 5d66ea2f..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 @@ -40,7 +41,33 @@ from PySide6.QtWidgets import ( from aare.gui.constants import LOGGER_NAME from aare.gui.models.bookmark import SmargonBookmarkList from aare.gui.scan_logic.raster_grid_manager import RasterGridManager -from aare.gui.widgets.busy_overlay import BusyOverlayStyle, build_busy_overlay_style +from aare.gui.styles import ( + BEAM_BUSY, + BEAM_IDLE, + BEAM_MARKING, + BEAM_OPEN, + CLASS_COLORS, + LEGEND_BG, + LEGEND_TEXT, + MARK_BADGE_BG, + MARK_TOOLTIP_GOLD, + MARK_TOOLTIP_ORANGE, + MARK_TOOLTIP_RED, + MARKER_GREEN, + PATH_END, + PATH_START, + SHADOW, + TARGET_COLORS, + THEME_SUNSET, + TOOLTIP_TEXT, + WHITE, + qcolor, +) +from aare.gui.widgets.busy_overlay import ( + BusyOverlayStyle, + build_busy_overlay_style, + draw_busy_badge, +) logger = setup_logger(LOGGER_NAME) @@ -55,6 +82,7 @@ class SampleCameraImageState(Enum): class SampleCameraImageLabel(QGraphicsView): smargon = Signal(SmargonCoordinate) + session_badge_clicked = Signal() evaluate_grid = Signal() clear_grid = Signal() @@ -87,6 +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 @@ -105,8 +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 + # "?" 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" @@ -179,14 +219,30 @@ class SampleCameraImageLabel(QGraphicsView): ) def _camera_interaction_enabled(self) -> bool: - return self._camera_available + # Watching is free; clicking (targets, raster, smargon moves) needs + # the camera AND the session baton. + return self._camera_available and self._operations_allowed + + @Slot(bool) + def set_operations_enabled(self, enabled: bool): + self._operations_allowed = enabled + self.update() @Slot(bool) def set_camera_available(self, available: bool): 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: @@ -242,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 @@ -254,37 +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): 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, @@ -301,13 +386,13 @@ class SampleCameraImageLabel(QGraphicsView): painter.setFont(font) if self._session_state == SessionsStateEnum.Vacant: - bg_color = QColor(255, 215, 0, 180) + bg_color = qcolor(MARK_TOOLTIP_GOLD, 180) text = "Session Vacant" elif self._session_state == SessionsStateEnum.PendingYouToElse: - bg_color = QColor(255, 165, 0, 150) + bg_color = qcolor(MARK_TOOLTIP_ORANGE, 150) text = "Baton Requested..." else: - bg_color = QColor(255, 0, 0, 150) + bg_color = qcolor(MARK_TOOLTIP_RED, 150) text = "Guest Mode" fm = QFontMetrics(font) @@ -323,12 +408,18 @@ class SampleCameraImageLabel(QGraphicsView): position_y = int((vh - bg_h) / 2) bg_rect = QRect(position_x, position_y, bg_w, bg_h) + # Clicking the badge opens the session (grab/request) menu. + self._session_badge_rect = bg_rect - painter.setPen(QPen(QColor(255, 255, 255, 220))) + 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) - painter.setPen(QPen(QColor(255, 255, 255))) + painter.setPen(QPen(qcolor(WHITE))) painter.drawText(QPoint(position_x + padding, position_y + padding + fm.ascent()), text) painter.restore() @@ -344,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(255, 255, 255, 220), 2)) - painter.setBrush(QColor(180, 60, 0, 180)) - painter.drawRoundedRect(bg_rect, 10, 10) - - painter.setPen(QPen(QColor(255, 255, 255))) - painter.drawText(QPoint(position_x, position_y + fm.ascent()), text) painter.restore() @@ -380,12 +464,36 @@ 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): + # 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._help_hit_rect is not None + and self._help_hit_rect.contains(QPointF(self.viewport().mapFrom(self, event.pos()))) + ): + self._help_expanded = not self._help_expanded + self.update() + event.accept() + return + + # Session badge: the way IN when everything else is gated — must fire + # before the interaction-enabled check below. + if ( + event.button() == Qt.MouseButton.LeftButton + and self._session_badge_rect is not None + and self._session_badge_rect.contains(self.viewport().mapFrom(self, event.pos())) + ): + self.session_badge_clicked.emit() + event.accept() + return + if not self._camera_interaction_enabled(): if event.button() in (Qt.MouseButton.LeftButton, Qt.MouseButton.RightButton): self._show_camera_unavailable_tooltip(event) @@ -429,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 @@ -716,14 +844,7 @@ class SampleCameraImageLabel(QGraphicsView): sx = disp_w / float(img_w) sy = disp_h / float(img_h) - color_map = { - "pin": QColor("red"), - "loop_all": QColor("green"), - "loop_face": QColor("yellow"), - "crystal": QColor("blue"), - "needle": QColor("magenta"), - "ice": QColor("cyan"), - } + color_map = {label: qcolor(hex_str) for label, hex_str in CLASS_COLORS.items()} for det in self._detections: try: @@ -738,7 +859,7 @@ class SampleCameraImageLabel(QGraphicsView): logger.debug(f"Error in draw detection {det}: {e}", exc_info=True) continue - color = color_map.get(label, QColor("magenta")) + color = color_map.get(label, qcolor(CLASS_COLORS["needle"])) pen = QPen(color, 3) painter.setPen(pen) @@ -753,20 +874,16 @@ class SampleCameraImageLabel(QGraphicsView): ) painter.drawPolygon(polygon) - painter.setPen(QPen(QColor(255, 255, 255), 1)) + painter.setPen(QPen(qcolor(WHITE), 1)) painter.setBrush(color) text_bg_rect = QRect(int(x1), int(y1 - 16), int(8 + 7 * len(label)), 16) painter.drawRect(text_bg_rect) - painter.setPen(QPen(QColor(255, 255, 255))) + painter.setPen(QPen(qcolor(WHITE))) painter.drawText(QPoint(int(x1) + 2, int(y1 - 4)), f"{label} {conf:.2f}") def _target_color(self) -> QColor: - color_map = { - "Cyan": QColor(0, 255, 255), - "Dark Blue": QColor(0, 70, 160), - "Dark Red": QColor(140, 25, 25), - } - return color_map.get(self._target_color_name, QColor(0, 255, 255)) + color_map = {name: qcolor(hex_str) for name, hex_str in TARGET_COLORS.items()} + return color_map.get(self._target_color_name, qcolor(TARGET_COLORS["Cyan"])) def _coerce_target_point(self, raw) -> tuple[float, float] | None: try: @@ -824,7 +941,7 @@ class SampleCameraImageLabel(QGraphicsView): painter.setBrush(Qt.BrushStyle.NoBrush) painter.setPen(QPen(color, 3, Qt.PenStyle.SolidLine)) painter.drawEllipse(QPointF(px, py), 12, 12) - painter.setPen(QPen(QColor(255, 255, 255), 1, Qt.PenStyle.SolidLine)) + painter.setPen(QPen(qcolor(WHITE), 1, Qt.PenStyle.SolidLine)) painter.drawEllipse(QPointF(px, py), 5, 5) painter.setPen(QPen(color, 2, Qt.PenStyle.SolidLine)) @@ -847,10 +964,10 @@ class SampleCameraImageLabel(QGraphicsView): bubble_rect = QRectF(px + 16, py - 28, text_rect.width() + 16, text_rect.height() + 10) painter.setPen(QPen(color, 2)) - painter.setBrush(QColor(20, 20, 20, 190)) + painter.setBrush(qcolor(LEGEND_BG, 190)) painter.drawRoundedRect(bubble_rect, 8, 8) - painter.setPen(QPen(QColor(255, 255, 255), 1)) + painter.setPen(QPen(qcolor(LEGEND_TEXT), 1)) painter.drawText( QPointF(bubble_rect.left() + 8, bubble_rect.top() + 7 + fm.ascent()), label_text ) @@ -878,19 +995,19 @@ class SampleCameraImageLabel(QGraphicsView): if self._show_detections: lines.extend( [ - ("Pin", QColor("red")), - ("Loop", QColor("green")), - ("Face", QColor("yellow")), - ("Crystal", QColor("blue")), + ("Pin", qcolor(CLASS_COLORS["pin"])), + ("Loop", qcolor(CLASS_COLORS["loop_all"])), + ("Face", qcolor(CLASS_COLORS["loop_face"])), + ("Crystal", qcolor(CLASS_COLORS["crystal"])), ] ) if self._show_coords: - lines.append(("Coords tooltip", QColor(230, 230, 230))) + lines.append(("Coords tooltip", qcolor(TOOLTIP_TEXT))) - lines.append(("Beam marker: shutter open", QColor(0, 255, 0))) - lines.append(("Beam marker: idle", QColor(245, 121, 0))) - lines.append(("Beam marker: busy", QColor(255, 0, 0))) + lines.append(("Beam marker: shutter open", qcolor(BEAM_OPEN))) + lines.append(("Beam marker: idle", qcolor(BEAM_IDLE))) + lines.append(("Beam marker: busy", qcolor(BEAM_BUSY))) return lines if self._show_target_point: @@ -902,27 +1019,126 @@ class SampleCameraImageLabel(QGraphicsView): if self._show_detections: lines.extend( [ - ("Prediction: Pin", QColor("red")), - ("Prediction: Loop_all", QColor("green")), - ("Prediction: Loop_face", QColor("yellow")), - ("Prediction: Crystal", QColor("blue")), - ("Prediction: Needle", QColor("magenta")), - ("Prediction: Ice", QColor("cyan")), + ("Prediction: Pin", qcolor(CLASS_COLORS["pin"])), + ("Prediction: Loop_all", qcolor(CLASS_COLORS["loop_all"])), + ("Prediction: Loop_face", qcolor(CLASS_COLORS["loop_face"])), + ("Prediction: Crystal", qcolor(CLASS_COLORS["crystal"])), + ("Prediction: Needle", qcolor(CLASS_COLORS["needle"])), + ("Prediction: Ice", qcolor(CLASS_COLORS["ice"])), ] ) if self._show_coords: - lines.append(("Cursor tooltip: pixel coordinates", QColor(230, 230, 230))) + lines.append(("Cursor tooltip: pixel coordinates", qcolor(TOOLTIP_TEXT))) - lines.append(("Beam marker: shutter open", QColor(0, 255, 0))) - lines.append(("Beam marker: idle", QColor(245, 121, 0))) - lines.append(("Beam marker: busy", QColor(255, 0, 0))) - lines.append(("Beam marker: marking mode", QColor(102, 51, 153))) + lines.append(("Beam marker: shutter open", qcolor(BEAM_OPEN))) + lines.append(("Beam marker: idle", qcolor(BEAM_IDLE))) + lines.append(("Beam marker: busy", qcolor(BEAM_BUSY))) + lines.append(("Beam marker: marking mode", qcolor(BEAM_MARKING))) return lines + # (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) + + painter.save() + painter.resetTransform() + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + painter.setPen(QPen(qcolor(LEGEND_TEXT, 60), 1)) + painter.setBrush(qcolor(LEGEND_BG, 170)) + painter.drawEllipse(rect) + + font = QFont() + font.setPointSize(10) + font.setBold(True) + painter.setFont(font) + painter.setPen(QPen(qcolor(LEGEND_TEXT), 1)) + painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, "?") + painter.restore() + + self._help_hit_rect = rect + + def _draw_help_overlay(self, painter: QPainter): + self._help_hit_rect = None + if not self._help_expanded: + self._draw_help_badge(painter) + return + + 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(): + if not self._legend_should_show() or self._help_expanded: return painter.save() @@ -940,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: @@ -950,8 +1167,8 @@ class SampleCameraImageLabel(QGraphicsView): height = len(lines) * line_height + 16 bg_rect = QRectF(left, max(18, top), width, height) - painter.setPen(QPen(QColor(255, 255, 255, 60), 1)) - painter.setBrush(QColor(20, 20, 20, 170)) + painter.setPen(QPen(qcolor(LEGEND_TEXT, 60), 1)) + painter.setBrush(qcolor(LEGEND_BG, 170)) painter.drawRoundedRect(bg_rect, 8, 8) y = bg_rect.top() + 12 @@ -966,7 +1183,7 @@ class SampleCameraImageLabel(QGraphicsView): painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(Qt.BrushStyle.NoBrush) - painter.setPen(QPen(QColor(240, 240, 240), 1)) + painter.setPen(QPen(qcolor(LEGEND_TEXT), 1)) painter.drawText( QPointF( bg_rect.left() + section_padding + swatch_size + text_padding, @@ -1028,7 +1245,7 @@ class SampleCameraImageLabel(QGraphicsView): if self._bounding_box is None: return - painter.setPen(QPen(QColor(50, 205, 50), 3, Qt.PenStyle.SolidLine)) + painter.setPen(QPen(qcolor(MARKER_GREEN), 3, Qt.PenStyle.SolidLine)) painter.setBrush(Qt.BrushStyle.NoBrush) painter.drawRect( @@ -1044,16 +1261,16 @@ class SampleCameraImageLabel(QGraphicsView): beam_size_pxl = self._geom.beam_size_pxl if self._state == SampleCameraImageState.BEAM_MARKING: - painter.setPen(QPen(QColor(102, 51, 153), 3, Qt.PenStyle.SolidLine)) + painter.setPen(QPen(qcolor(BEAM_MARKING), 3, Qt.PenStyle.SolidLine)) elif self._shutter: - painter.setPen(QPen(QColor(0, 255, 0), 3, Qt.PenStyle.SolidLine)) + painter.setPen(QPen(qcolor(BEAM_OPEN), 3, Qt.PenStyle.SolidLine)) elif self._is_daq_busy is True: # Use red color to indicate busy state - painter.setPen(QPen(QColor(255, 0, 0), 3, Qt.PenStyle.SolidLine)) + painter.setPen(QPen(qcolor(BEAM_BUSY), 3, Qt.PenStyle.SolidLine)) else: - painter.setPen(QPen(QColor(245, 121, 0), 3, Qt.PenStyle.SolidLine)) + painter.setPen(QPen(qcolor(BEAM_IDLE), 3, Qt.PenStyle.SolidLine)) painter.setBrush(Qt.BrushStyle.NoBrush) painter.drawRect( @@ -1077,8 +1294,8 @@ class SampleCameraImageLabel(QGraphicsView): gradient.setStart(QPointF(start.x, start.y)) # Start of the gradient (green) gradient.setFinalStop(QPointF(end.x, end.y)) # End of the gradient (red) - gradient.setColorAt(0.0, QColor("green")) # Start color - gradient.setColorAt(1.0, QColor("red")) # End color + gradient.setColorAt(0.0, qcolor(PATH_START)) # Start color + gradient.setColorAt(1.0, qcolor(PATH_END)) # End color pen = QPen() pen.setBrush(gradient) # Use gradient as the brush for the pen @@ -1091,13 +1308,13 @@ class SampleCameraImageLabel(QGraphicsView): def _draw_helical(self, painter: QPainter): if self._helical_start.sh_mm is not None: start_pxl = self._geom.smargon_to_picture(self._helical_start.sh_mm) - self._draw_circle(painter, start_pxl, QColor("green")) + self._draw_circle(painter, start_pxl, qcolor(PATH_START)) else: start_pxl = None if self._helical_end.sh_mm is not None: end_pxl = self._geom.smargon_to_picture(self._helical_end.sh_mm) - self._draw_circle(painter, end_pxl, QColor("red")) + self._draw_circle(painter, end_pxl, qcolor(PATH_END)) else: end_pxl = None diff --git a/src/aare/gui/widgets/local_contact_status_widget.py b/src/aare/gui/widgets/local_contact_status_widget.py index 328e9157..00b1f879 100644 --- a/src/aare/gui/widgets/local_contact_status_widget.py +++ b/src/aare/gui/widgets/local_contact_status_widget.py @@ -9,6 +9,22 @@ from PySide6.QtCore import Qt, Slot from PySide6.QtWidgets import QFrame, QGridLayout, QLabel, QSizePolicy, QVBoxLayout from aare.gui.constants import LOGGER_NAME +from aare.gui.styles import ( + CARD_BORDER, + CHIP_BAD_BG, + CHIP_BAD_TEXT, + CHIP_INFO_BG, + CHIP_INFO_TEXT, + CHIP_NEUTRAL_BG, + CHIP_WARN_BG, + CHIP_WARN_TEXT, + HEADING_TEXT, + MUTED_TEXT, + SUBTLE_TEXT, + SUCCESS_BG, + SUCCESS_TEXT, + SURFACE, +) from aare.gui.widgets.title_label import TitleLabel logger = setup_logger(LOGGER_NAME) @@ -79,12 +95,11 @@ class LocalContactStatusWidget(QFrame): self.setFrameShadow(QFrame.Shadow.Raised) self.setObjectName("localContactStatusCard") self.setStyleSheet( - """ - QFrame#localContactStatusCard { - background: #f8fbff; - border: 1px solid #c7d4e5; - border-radius: 10px; - } + f""" + QFrame#localContactStatusCard {{ + background: {SURFACE}; + border: 1px solid {CARD_BORDER}; + }} """ ) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum) @@ -97,13 +112,14 @@ class LocalContactStatusWidget(QFrame): self._summary = QLabel(summary, self) self._summary.setWordWrap(True) - self._summary.setStyleSheet("border: none; background: transparent; color: #334155;") + self._summary.setStyleSheet(f"border: none; background: transparent; color: {SUBTLE_TEXT};") layout.addWidget(self._summary) self._grid = QGridLayout() self._grid.setContentsMargins(0, 0, 0, 0) self._grid.setHorizontalSpacing(14) - self._grid.setVerticalSpacing(5) + # Tight rows; the badge keeps 2px vertical padding so text never clips. + self._grid.setVerticalSpacing(2) layout.addLayout(self._grid) self._rebuild_rows() @@ -130,28 +146,32 @@ class LocalContactStatusWidget(QFrame): for row, key in enumerate(self._visible_fields): title = QLabel(self.FIELD_TITLES.get(key, key.replace("_", " ").title()), self) title.setStyleSheet( - "font-weight: 700; border: none; background: transparent; color: #1e293b;" + f"font-weight: 700; border: none; background: transparent; color: {HEADING_TEXT};" ) value = QLabel(self._badge("WAITING", tone="neutral"), self) value.setWordWrap(True) value.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) - value.setStyleSheet("border: none; background: transparent; color: #334155;") + value.setStyleSheet(f"border: none; background: transparent; color: {SUBTLE_TEXT};") self._row_widgets[key] = (title, value) self._grid.addWidget(title, row, 0, alignment=Qt.AlignmentFlag.AlignTop) self._grid.addWidget(value, row, 1) + # Word-wrapped value labels report a near-zero minimum height, so + # cramped tabs (e.g. Hardware) could compress rows until the text + # clipped; guarantee one full text line + badge padding per row. + self._grid.setRowMinimumHeight(row, self.fontMetrics().height() + 6) def _badge(self, text: str, *, tone: str = "neutral") -> str: palette = { - "good": ("#e7f6ea", "#1f6a3a"), - "warn": ("#fff3cd", "#7a4b00"), - "bad": ("#fdeaea", "#8b1e1e"), - "neutral": ("#e9eef5", "#475569"), - "info": ("#e8f1ff", "#12406a"), + "good": (SUCCESS_BG, SUCCESS_TEXT), + "warn": (CHIP_WARN_BG, CHIP_WARN_TEXT), + "bad": (CHIP_BAD_BG, CHIP_BAD_TEXT), + "neutral": (CHIP_NEUTRAL_BG, MUTED_TEXT), + "info": (CHIP_INFO_BG, CHIP_INFO_TEXT), } background, foreground = palette.get(tone, palette["neutral"]) return ( f"{text}" + f"padding:2px 6px;'>{text}" ) def _format_bool( @@ -176,7 +196,7 @@ class LocalContactStatusWidget(QFrame): return ( f"{self._badge('ERROR', tone='bad')} " f"{self._badge(mode.upper(), tone='warn')} " - f"{error}" + f"{error}" ) if mode == "simulated": return self._badge("SIMULATED", tone="warn") diff --git a/src/aare/gui/widgets/login.py b/src/aare/gui/widgets/login.py index 38d8a1df..758e7510 100644 --- a/src/aare/gui/widgets/login.py +++ b/src/aare/gui/widgets/login.py @@ -7,6 +7,8 @@ from PySide6.QtCore import QByteArray, QUrl, QUrlQuery, Slot from PySide6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QLineEdit, QPushButton, QVBoxLayout +from aare.gui.styles import APP_BACKGROUND + class LoginDialog(QDialog): def __init__(self, base_url: str | None): @@ -14,7 +16,7 @@ class LoginDialog(QDialog): self.token = "" self.setWindowTitle("User Authentication") self.setMinimumWidth(400) - self.setStyleSheet("background-color: rgb(216, 228, 253);") + self.setStyleSheet(f"background-color: {APP_BACKGROUND};") self._base_url = base_url self._reply = None self._network_manager = None diff --git a/src/aare/gui/widgets/no_wheel_scroll_area.py b/src/aare/gui/widgets/no_wheel_scroll_area.py index c57c15f4..1dce19d9 100644 --- a/src/aare/gui/widgets/no_wheel_scroll_area.py +++ b/src/aare/gui/widgets/no_wheel_scroll_area.py @@ -2,9 +2,10 @@ from PySide6.QtWidgets import QScrollArea class NoWheelScrollArea(QScrollArea): + """Historic name: it used to swallow the wheel entirely so scrolling the + column could not nudge a value widget. WheelValueGuard now protects the + value widgets themselves (right button + wheel to adjust), so the wheel + scrolls the column content normally again — and only ever scrolls.""" + def __init__(self, parent=None): super().__init__(parent) - - def wheelEvent(self, event): - # Override the wheelEvent and do nothing - pass diff --git a/src/aare/gui/widgets/number_line_edit.py b/src/aare/gui/widgets/number_line_edit.py index 168d68b4..98f22441 100644 --- a/src/aare/gui/widgets/number_line_edit.py +++ b/src/aare/gui/widgets/number_line_edit.py @@ -4,6 +4,10 @@ from PySide6.QtWidgets import QCheckBox, QHBoxLayout, QLineEdit, QWidget 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__( @@ -13,8 +17,6 @@ class NumberLineEdit(QLineEdit): self._read_only: bool = False self._is_valid: bool = True - self.setStyleSheet("background-color: rgb(255, 255, 255);") - # Use a QDoubleValidator to only allow valid floating point numbers self.validator = QDoubleValidator() self.validator.setNotation(QDoubleValidator.Notation.StandardNotation) @@ -37,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("background-color: rgb(255, 255, 255);") - else: - self.setStyleSheet("background-color: rgb(255, 213, 213);") + self._set_invalid(not self._is_valid) @Slot() def on_editing_finished(self): @@ -82,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("background-color: rgb(240, 240, 240);") - elif not self._read_only and self._is_valid: - self.setStyleSheet("background-color: rgb(255, 255, 255);") - elif self._read_only and not self._is_valid: - self.setStyleSheet("background-color: rgb(240, 225, 225);") - elif not self._read_only and not self._is_valid: - self.setStyleSheet("background-color: rgb(255, 213, 213);") - else: - print( - f"unknown ro state: {self._read_only} or validity {self._is_valid} default to writeable" - ) - self.setStyleSheet("background-color: rgb(255, 255, 255);") def get_default(self) -> float: return float(self.initial_value) @@ -165,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("background-color: rgb(240, 240, 240);") if self._checked: self.editor.force_update_value(self._internal_value) else: self.check_box.setEnabled(True) - self.check_box.setStyleSheet("background-color: rgb(255, 255, 255);") self.check_box.blockSignals(False) self.blockSignals(False) diff --git a/src/aare/gui/widgets/pgroup_dialog.py b/src/aare/gui/widgets/pgroup_dialog.py index 6d467abf..296dbc9a 100644 --- a/src/aare/gui/widgets/pgroup_dialog.py +++ b/src/aare/gui/widgets/pgroup_dialog.py @@ -9,6 +9,8 @@ from PySide6.QtWidgets import ( QVBoxLayout, ) +from aare.gui.styles import DANGER_ACCENT + class PGroupDialog(QDialog): def __init__( @@ -78,9 +80,11 @@ class PGroupDialog(QDialog): def _set_error_state(self, is_error: bool, message: str | None = None) -> None: if is_error: - self.combo.setStyleSheet("border: 2px solid #d9534f;") + self.combo.setStyleSheet(f"border: 2px solid {DANGER_ACCENT};") if message: - self.label.setText(f"Set p-group: {message}") + self.label.setText( + f"Set p-group: {message}" + ) else: self.label.setText("Set p-group:") else: diff --git a/src/aare/gui/widgets/popout_window.py b/src/aare/gui/widgets/popout_window.py new file mode 100644 index 00000000..980c228e --- /dev/null +++ b/src/aare/gui/widgets/popout_window.py @@ -0,0 +1,260 @@ +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, qcolor + +# 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, 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. 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(color, 2) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin) + painter.setPen(pen) + if kind == "close": + painter.drawLine(4, 4, size - 4, size - 4) + painter.drawLine(size - 4, 4, 4, size - 4) + else: # "popout": window in the lower left, arrow escaping top-right + painter.drawRect(3, size // 2 - 1, size // 2 - 1, size // 2 - 1) + painter.drawLine(size // 2 + 1, size // 2 - 1, size - 3, 3) + painter.drawLine(size - 8, 3, size - 3, 3) + painter.drawLine(size - 3, 3, size - 3, 8) + painter.end() + return QIcon(pixmap) + + +def _titlebar_button(parent: QWidget, tooltip: str) -> QToolButton: + # Icon is set by DockTitleBar._tint_icons (initially and on theme change). + button = QToolButton(parent) + # 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. 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 + + +class DockTitleBar(QWidget): + """Dock title bar with a ⤢ pop-out button right next to ✕. + + Qt's native dock title bar cannot host extra buttons, so this replaces + it: [title … ⤢ ✕]. Trade-off: the dock can no longer be dragged by its + title — acceptable here, these docks are pinned to the bottom row. + """ + + def __init__(self, dock: QDockWidget, on_popout): + super().__init__(dock) + layout = QHBoxLayout(self) + layout.setContentsMargins(8, 2, 4, 2) + layout.setSpacing(2) + + title = QLabel(dock.windowTitle(), self) + title.setStyleSheet("background: transparent;") + layout.addWidget(title) + layout.addStretch(1) + + self.popout_button = _titlebar_button( + self, "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 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. + + Unlike a floated QDockWidget it never removes anything from the main + window — closing it just hides it (geometry kept for reopening) and the + main window is untouched. The layout leaves RESIZE_MARGIN px of the + window exposed around the content as a fat, easy-to-hit resize band; + frameless floats only give a few px. Resize uses startSystemResize with + a manual fallback for window managers that lack it. + """ + + # 6px: enough to grab without pixel-hunting, small enough that the area + # right around the content doesn't hijack table interactions. + RESIZE_MARGIN = 6 + # Clicks can never land outside a window, so a from-the-outside grab zone + # has to be window area that only LOOKS external: the visible border is + # drawn OUTER_GRIP px inside the real edge, and the halo beyond it + # resizes too. + OUTER_GRIP = 4 + + def __init__(self, title: str, content: QWidget, parent=None): + super().__init__(parent, Qt.WindowType.Window) + # 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) + m = self.RESIZE_MARGIN + self.OUTER_GRIP + layout.setContentsMargins(m, m, m, m) + layout.addWidget(content) + self.setMouseTracking(True) + self._manual_edges = Qt.Edge(0) + self._press_global: QPoint | None = None + self._press_geom: QRect | None = None + self._placed = False + + def showEvent(self, event): + # First show opens near the click (the ⤢ button = the cursor), not at + # the WM's default top-left; reopening keeps the last geometry. + if not self._placed: + self._placed = True + cursor = QCursor.pos() + pos = cursor - QPoint(60, 20) + screen = QGuiApplication.screenAt(cursor) or QGuiApplication.primaryScreen() + if screen is not None: + geo = screen.availableGeometry() + pos.setX(max(geo.left(), min(pos.x(), geo.right() - self.width()))) + pos.setY(max(geo.top(), min(pos.y(), geo.bottom() - self.height()))) + self.move(pos) + super().showEvent(event) + + def paintEvent(self, event): + super().paintEvent(event) + # Optional perceived window border, inset by OUTER_GRIP (see class + # note). Level-1 frame — weight/color are knobs in styles.py; the + # default width 0 paints nothing (resize still works via the cursor + # hint over the grab band). + width = int(FRAME_L1_WIDTH.rstrip("px")) + if width <= 0: + return + painter = QPainter(self) + painter.setPen(QPen(qcolor(FRAME_L1_COLOR), width)) + g = self.OUTER_GRIP + painter.drawRect(self.rect().adjusted(g, g, -g - 1, -g - 1)) + + def _edges_at(self, pos: QPoint) -> Qt.Edge: + m = self.RESIZE_MARGIN + self.OUTER_GRIP + edges = Qt.Edge(0) + if pos.x() <= m: + edges |= Qt.Edge.LeftEdge + if pos.x() >= self.width() - m: + edges |= Qt.Edge.RightEdge + if pos.y() <= m: + edges |= Qt.Edge.TopEdge + if pos.y() >= self.height() - m: + edges |= Qt.Edge.BottomEdge + return edges + + def _cursor_for(self, edges: Qt.Edge): + horizontal = edges & (Qt.Edge.LeftEdge | Qt.Edge.RightEdge) + vertical = edges & (Qt.Edge.TopEdge | Qt.Edge.BottomEdge) + if horizontal and vertical: + same_diag = bool(edges & Qt.Edge.LeftEdge) == bool(edges & Qt.Edge.TopEdge) + return Qt.CursorShape.SizeFDiagCursor if same_diag else Qt.CursorShape.SizeBDiagCursor + if horizontal: + return Qt.CursorShape.SizeHorCursor + if vertical: + return Qt.CursorShape.SizeVerCursor + return None + + def mousePressEvent(self, event): + edges = self._edges_at(event.position().toPoint()) + if event.button() == Qt.MouseButton.LeftButton and edges: + handle = self.windowHandle() + if handle is None or not handle.startSystemResize(edges): + self._manual_edges = edges + self._press_global = event.globalPosition().toPoint() + self._press_geom = QRect(self.geometry()) + return + super().mousePressEvent(event) + + def mouseMoveEvent(self, event): + # Both press fields are set together in mousePressEvent; the second + # check exists for the pyright gate, which can't see that pairing. + if self._manual_edges and self._press_global is not None and self._press_geom is not None: + delta = event.globalPosition().toPoint() - self._press_global + geom = QRect(self._press_geom) + if self._manual_edges & Qt.Edge.LeftEdge: + geom.setLeft(min(geom.left() + delta.x(), geom.right() - self.minimumWidth())) + if self._manual_edges & Qt.Edge.RightEdge: + geom.setRight(max(geom.right() + delta.x(), geom.left() + self.minimumWidth())) + if self._manual_edges & Qt.Edge.TopEdge: + geom.setTop(min(geom.top() + delta.y(), geom.bottom() - self.minimumHeight())) + if self._manual_edges & Qt.Edge.BottomEdge: + geom.setBottom(max(geom.bottom() + delta.y(), geom.top() + self.minimumHeight())) + self.setGeometry(geom) + return + cursor = self._cursor_for(self._edges_at(event.position().toPoint())) + if cursor is None: + self.unsetCursor() + else: + self.setCursor(cursor) + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event): + self._manual_edges = Qt.Edge(0) + self._press_global = None + self._press_geom = None + super().mouseReleaseEvent(event) + + +if __name__ == "__main__": + # ponytail: smallest check that fails if the edge maths breaks + import os + + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PySide6.QtWidgets import QApplication, QLabel + + app = QApplication([]) + w = PopoutWindow("t", QLabel("x")) + w.resize(400, 300) + assert w._edges_at(QPoint(5, 150)) == Qt.Edge.LeftEdge + assert w._edges_at(QPoint(398, 298)) == (Qt.Edge.RightEdge | Qt.Edge.BottomEdge) + assert w._edges_at(QPoint(200, 150)) == Qt.Edge(0) + assert w._cursor_for(Qt.Edge.LeftEdge | Qt.Edge.TopEdge) == Qt.CursorShape.SizeFDiagCursor + assert w._cursor_for(Qt.Edge.RightEdge | Qt.Edge.TopEdge) == Qt.CursorShape.SizeBDiagCursor + print("gude") diff --git a/src/aare/gui/widgets/raster_grid_table.py b/src/aare/gui/widgets/raster_grid_table.py index 0c40345f..7f76791d 100644 --- a/src/aare/gui/widgets/raster_grid_table.py +++ b/src/aare/gui/widgets/raster_grid_table.py @@ -9,6 +9,7 @@ from PySide6.QtWidgets import ( ) from aare.gui.scan_logic.raster_grid_manager import RasterGridManager +from aare.gui.styles import FONT_BODY, TABLE_SHADE_BG class RasterGridTable(QTableWidget): @@ -32,8 +33,10 @@ class RasterGridTable(QTableWidget): header.setSectionResizeMode(i, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch) - # Set minimum height - self.setMinimumHeight(100) + # Exactly 5 rows of space; more grids scroll inside the table. + header_height = self.horizontalHeader().sizeHint().height() + row_height = self.verticalHeader().defaultSectionSize() + self.setFixedHeight(header_height + 3 * row_height + 1 * self.frameWidth()) # Connect to raster manager signals self._raster_mgr.completed_grid_updated.connect(self.refresh_table) @@ -65,16 +68,16 @@ class RasterGridTable(QTableWidget): actions_layout.setContentsMargins(4, 4, 4, 4) actions_layout.setSpacing(4) - button_style = """ - QPushButton { + button_style = f""" + QPushButton {{ border: none; background: transparent; - font-size: 14px; - } - QPushButton:hover { - background-color: #e0e0e0; + font-size: {FONT_BODY}; + }} + QPushButton:hover {{ + background-color: {TABLE_SHADE_BG}; border-radius: 3px; - } + }} """ # Copy button diff --git a/src/aare/gui/widgets/splash_screen.py b/src/aare/gui/widgets/splash_screen.py index 91761223..6b776aab 100644 --- a/src/aare/gui/widgets/splash_screen.py +++ b/src/aare/gui/widgets/splash_screen.py @@ -1,6 +1,8 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import QApplication, QProgressBar, QSplashScreen +from aare.gui.styles import SPLASH_ACCENT, SPLASH_BG, SPLASH_BORDER, SPLASH_TEXT, qcolor + class LoadingSplashScreen(QSplashScreen): def __init__(self, pixmap): @@ -9,21 +11,25 @@ class LoadingSplashScreen(QSplashScreen): self.progress = QProgressBar(self) # Position the progress bar at the bottom of the splash screen self.progress.setGeometry(10, self.size().height() - 30, self.size().width() - 20, 20) - self.progress.setStyleSheet(""" - QProgressBar { - border: 1px solid #444; + self.progress.setStyleSheet(f""" + QProgressBar {{ + border: 1px solid {SPLASH_BORDER}; border-radius: 5px; text-align: center; - background-color: #222; - color: white; - } - QProgressBar::chunk { - background-color: #0078d7; - } + background-color: {SPLASH_BG}; + color: {SPLASH_TEXT}; + }} + QProgressBar::chunk {{ + background-color: {SPLASH_ACCENT}; + }} """) def set_progress(self, value, message=None): self.progress.setValue(value) if message: - self.showMessage(message, Qt.AlignBottom | Qt.AlignCenter, Qt.white) + self.showMessage( + message, + Qt.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 8a600382..23fb4094 100644 --- a/src/aare/gui/widgets/status_bar.py +++ b/src/aare/gui/widgets/status_bar.py @@ -8,6 +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 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 @@ -45,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) @@ -100,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 = "red" if is_error else "green" + 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: @@ -146,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}", "red") + 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}", "orange") + 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}") @@ -156,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}", "blue") + 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}", "orange") + 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}", "red") + self.cryo_label.set_value(f"{status.bl.cryojet_K:.1f}", self._colors["alert"]) if status.bl.shutter_open: self.shutter_label.setText( - """Fast Shutter: Open ☢️ """ + f"""Fast Shutter: Open ☢️ """ ) else: self.shutter_label.setText( - """Fast Shutter: Closed 🚪 """ + f"""Fast Shutter: Closed 🚪 """ ) if status.bl.exp_shutter_open: self.exp_shutter_label.setText( - """ExpHutch Shutter: Open """ + f"""ExpHutch Shutter: Open """ ) else: self.exp_shutter_label.setText( - """ExpHutch Shutter: Closed 🚪 """ + f"""ExpHutch Shutter: Closed 🚪 """ ) if status.session.current_pgroup is not None: @@ -188,29 +210,29 @@ class StatusBar(QStatusBar): self.state_label.setText(f"""State: {status.state.display_name()} """) tell_text = "—" - tell_color = "rgb(55, 67, 87)" + 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 = "red" + tell_color = self._colors["alert"] elif status.tell_state.activity.value in { "mounting", "unmounting", "drying", "cooling", }: - tell_color = "orange" + tell_color = self._colors["warn"] else: - tell_color = "green" + 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 = """ Busy 🔒 """ + busy_flag = f""" Busy 🔒 """ else: - busy_flag = """ Idle 🔓 """ + busy_flag = f""" Idle 🔓 """ html_content = f"""Beamline: {busy_flag} """ @@ -218,15 +240,23 @@ class StatusBar(QStatusBar): session_flag = "" if status.session.session == SessionsStateEnum.Vacant: - session_flag = """ Vacant 🔓 """ + session_flag = ( + f""" Vacant 🔓 """ + ) elif status.session.session == SessionsStateEnum.OwnedByYou: - session_flag = """ Owned ⬤ """ + session_flag = f""" Owned ⬤ """ elif status.session.session == SessionsStateEnum.OwnedByElse: - session_flag = """ Other 🔒 """ + session_flag = ( + f""" Other 🔒 """ + ) elif status.session.session == SessionsStateEnum.PendingYouToElse: - session_flag = """ Waiting... ⏳ """ + session_flag = ( + f""" Waiting... ⏳ """ + ) elif status.session.session == SessionsStateEnum.PendingElseToYou: - session_flag = """ Request! ⚡ """ + session_flag = ( + f""" Request! ⚡ """ + ) html_content_session = f"""Session: {session_flag}""" self.session_label.setText(html_content_session) @@ -325,7 +355,7 @@ class StatusBar(QStatusBar): self.session_label.setText(text) - def show_session_menu(self): + def show_session_menu(self, global_pos: QPoint | None = None): menu = QMenu(self) is_busy = self._status and self._status.busy session_state = self._status.session.session if self._status else SessionsStateEnum.Vacant @@ -404,10 +434,16 @@ class StatusBar(QStatusBar): action_force = menu.addAction("⚠️ Force Take Over") action_force.triggered.connect(self._on_force_session_clicked) - label_geometry = self.session_label.geometry() - menu_width = max(label_geometry.width(), menu.sizeHint().width()) - menu.move(self.mapToGlobal(label_geometry.topLeft()) - QPoint(0, menu.sizeHint().height())) - menu.setFixedWidth(menu_width) + if global_pos is not None: + # Invoked from the camera's session badge — open at the click. + menu.move(global_pos) + else: + label_geometry = self.session_label.geometry() + menu_width = max(label_geometry.width(), menu.sizeHint().width()) + menu.move( + self.mapToGlobal(label_geometry.topLeft()) - QPoint(0, menu.sizeHint().height()) + ) + menu.setFixedWidth(menu_width) menu.exec() def show_pgroup_menu(self): @@ -588,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 44e7a55c..6c5d5c28 100644 --- a/src/aare/gui/widgets/title_label.py +++ b/src/aare/gui/widgets/title_label.py @@ -1,12 +1,179 @@ -from PySide6.QtCore import Qt -from PySide6.QtWidgets import QLabel +from PySide6.QtCore import QSettings, Qt, QTimer +from PySide6.QtGui import QPainter, QPalette +from PySide6.QtWidgets import QHBoxLayout, QLabel, QLayout, QPushButton, QStyle, QStyleOption + +from aare.gui.styles import BANNER_TEXT, BANNER_TEXT_SHADOW, FONT_VALUE, qcolor + +# Universal vertical rhythm between stacked panels: each panel contributes +# PANEL_VMARGIN top and bottom, the column adds PANEL_VSPACING between them, +# so every banner-to-banner gap is 4 + 3 + 4 = 11px in every column. +PANEL_VSPACING = 3 +PANEL_VMARGIN = 4 + + +def tighten_column(layout: QLayout) -> None: + """Apply the universal panel gap to a column layout and its child panels. + + Qt's defaults (9px margins + 6px spacing) and ad-hoc per-panel margins + made the gaps uneven between the left and right columns. + """ + layout.setSpacing(PANEL_VSPACING) + for i in range(layout.count()): + item = layout.itemAt(i) + widget = item.widget() if item is not None else None + child_layout = widget.layout() if widget is not None else None + if child_layout is not None: + m = child_layout.contentsMargins() + child_layout.setContentsMargins(m.left(), PANEL_VMARGIN, m.right(), PANEL_VMARGIN) + + +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). Look lives in + the per-theme QLabel#sectionTitle rules in styles.py.""" + label = QLabel(text, parent) + label.setObjectName("sectionTitle") + label.setAlignment(Qt.AlignmentFlag.AlignCenter) + return label class TitleLabel(QLabel): - def __init__(self, text: str, parent=None): + def __init__( + self, text: str, parent=None, collapsible: bool = False, default_collapsed: bool = True + ): super().__init__(parent) - self.setText(f"

{text}

") - self.setStyleSheet("background-color: #4B0082; color: #ffffff;") + # Plain text + QSS font instead of

: rich-text heading margins + # would clip vertically in the halved banner height. + self.setText(text) + # 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) - self.setFixedHeight(50) + # Half the original 50px: the full-height banner wasted vertical space. + self.setFixedHeight(25) + + self._collapsible = collapsible + if not collapsible: + return + + self._collapsed = False + # ponytail: settings key is the title text — unique across panels; + # renaming a title just resets that panel to expanded once. + self._settings_key = f"panel_collapsed/{text}" + + self.toggle_button = QPushButton("−", self) + # Bare glyph, no pill: the shared beamlineStateToggleButton QSS paints + # a translucent white background, which is unwanted on these banners. + # 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_VALUE}; font-weight: 700; }}" + ) + self.toggle_button.setToolTip("Minimise panel") + self.toggle_button.setFixedSize(21, 21) + self.toggle_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.toggle_button.clicked.connect(self.toggle_collapsed) + + button_layout = QHBoxLayout(self) + button_layout.setContentsMargins(0, 0, 8, 0) + button_layout.addStretch() + button_layout.addWidget(self.toggle_button) + + self.setCursor(Qt.CursorShape.PointingHandCursor) + + settings = QSettings("PSI", "AareGUI") + # Per-panel default (collapsed unless the caller opts out); once the + # user toggles a banner, their choice is persisted per title and wins. + if settings.value(self._settings_key, default_collapsed, type=bool): + self._collapsed = True + # Deferred: the panel adds its other widgets after constructing + # the TitleLabel, so siblings don't exist yet. + QTimer.singleShot(0, self._apply_collapsed) + + def paintEvent(self, event): + # QSS has no text-shadow, so paint by hand: the QSS background box + # first, then the title twice — an offset dark pass under the normal + # one — for the subtle emboss the OS titlebar text has. + painter = QPainter(self) + opt = QStyleOption() + opt.initFrom(self) + self.style().drawPrimitive(QStyle.PrimitiveElement.PE_Widget, opt, painter, self) + + flags = int(self.alignment()) + # Reserve the toggle-button zone (21px + 8px margin) on BOTH sides in + # the text rect only — widget margins would move the button itself. + reserve = 29 if self._collapsible else 0 + rect = self.rect().adjusted(reserve, 0, -reserve, 0) + painter.setFont(self.font()) + # Elide instead of overflowing when the panel column is narrow. + text = painter.fontMetrics().elidedText( + self.text(), Qt.TextElideMode.ElideRight, rect.width() + ) + painter.setPen(qcolor(BANNER_TEXT_SHADOW, 110)) + painter.drawText(rect.translated(0, 1), flags, text) + # 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): + if self._collapsible: + self.toggle_collapsed() + super().mousePressEvent(event) + + def expand(self) -> None: + if self._collapsible and self._collapsed: + self.toggle_collapsed() + + def is_collapsed(self) -> bool: + return bool(self._collapsible and self._collapsed) + + def set_collapsed(self, collapsed: bool, persist: bool = True) -> None: + # persist=False: transient programmatic fold (e.g. the session-vacant + # gate) that must not overwrite the user's saved per-panel choice. + if not self._collapsible or collapsed == self._collapsed: + return + self._collapsed = collapsed + self._apply_collapsed() + if persist: + QSettings("PSI", "AareGUI").setValue(self._settings_key, self._collapsed) + + def toggle_collapsed(self) -> None: + self._collapsed = not self._collapsed + self._apply_collapsed() + QSettings("PSI", "AareGUI").setValue(self._settings_key, self._collapsed) + if not self._collapsed: + # Expanding a group banner (Beamline / Experiment) opens every + # nested panel banner too — a group opening onto a wall of still- + # collapsed banners reads as broken. No-op for leaf panels, which + # have no nested TitleLabels. + parent = self.parentWidget() + if parent is not None: + for child in parent.findChildren(TitleLabel): + if child is not self: + child.expand() + + def _apply_collapsed(self) -> None: + parent = self.parentWidget() + parent_layout = parent.layout() if parent is not None else None + if parent_layout is None: + return + self._set_visible(parent_layout, not self._collapsed) + self.toggle_button.setText("+" if self._collapsed else "−") + self.toggle_button.setToolTip("Restore panel" if self._collapsed else "Minimise panel") + + def _set_visible(self, layout: QLayout, visible: bool) -> None: + # Recursive: panels like SamcamPanel nest sub-layouts via addLayout. + for i in range(layout.count()): + item = layout.itemAt(i) + if item is None: + continue + widget = item.widget() + child_layout = item.layout() + if widget is not None: + if widget is not self: + widget.setVisible(visible) + elif child_layout is not None: + self._set_visible(child_layout, visible) 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 new file mode 100644 index 00000000..7697de8e --- /dev/null +++ b/src/aare/gui/widgets/wheel_value_guard.py @@ -0,0 +1,82 @@ +from PySide6.QtCore import QEvent, QObject, Qt +from PySide6.QtGui import QWheelEvent +from PySide6.QtWidgets import ( + QAbstractScrollArea, + QAbstractSpinBox, + QApplication, + QComboBox, + QDial, + QSlider, + QTabBar, +) + + +class WheelValueGuard(QObject): + """App-level wheel safety for value widgets. + + The wheel only ADJUSTS a slider / spin box / dial / combo while the + RIGHT mouse button is held down — a deliberate two-hand gesture. A bare + wheel over any of them is re-aimed at the enclosing scroll area, so + scrolling a page can never nudge a value and therefore never moves a + motor. Install once with QApplication.installEventFilter. + """ + + # QTabBar: wheel switches tabs on Linux by default — same accidental-input + # hazard as a value nudge, so guard it too. + GUARDED = (QAbstractSpinBox, QSlider, QDial, QComboBox, QTabBar) + + def eventFilter(self, obj, event): + if event.type() == QEvent.Type.Wheel and isinstance(obj, self.GUARDED): + if event.buttons() & Qt.MouseButton.RightButton: + return False # right button held: deliberate value adjustment + area = obj.parentWidget() + while area is not None and not isinstance(area, QAbstractScrollArea): + area = area.parentWidget() + if area is not None: + relayed = QWheelEvent( + area.viewport().mapFromGlobal(event.globalPosition()), + event.globalPosition(), + event.pixelDelta(), + event.angleDelta(), + event.buttons(), + event.modifiers(), + event.phase(), + event.inverted(), + ) + QApplication.sendEvent(area.viewport(), relayed) + return True + return super().eventFilter(obj, event) + + +if __name__ == "__main__": + # ponytail: smallest check that fails if the guard logic breaks + import os + + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PySide6.QtCore import QPoint, QPointF + + app = QApplication([]) + guard = WheelValueGuard() + app.installEventFilter(guard) + slider = QSlider(Qt.Orientation.Horizontal) + slider.setRange(0, 100) + slider.setValue(50) + slider.show() + + def wheel(buttons): + return QWheelEvent( + QPointF(5, 5), + QPointF(5, 5), + QPoint(0, 0), + QPoint(0, 120), + buttons, + Qt.KeyboardModifier.NoModifier, + Qt.ScrollPhase.NoScrollPhase, + False, + ) + + QApplication.sendEvent(slider, wheel(Qt.MouseButton.NoButton)) + assert slider.value() == 50, "bare wheel must not adjust the slider" + QApplication.sendEvent(slider, wheel(Qt.MouseButton.RightButton)) + assert slider.value() != 50, "right-button + wheel must adjust the slider" + print("gude") 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_beamline_state_panel.py b/tests/unit/gui/test_beamline_state_panel.py new file mode 100644 index 00000000..3e9bce74 --- /dev/null +++ b/tests/unit/gui/test_beamline_state_panel.py @@ -0,0 +1,123 @@ +from aarecommon.models.models import BeamlineStateEnum +from PySide6.QtCore import Qt + +from aare.gui.panels.beamline_state_panel import BeamlineStatePanel +from aare.gui.styles import STATE_AVAILABLE, STATE_MSG_ERROR, STATE_MSG_INFO, STATE_UNAVAILABLE + + +def _panel(qtbot): + panel = BeamlineStatePanel() + qtbot.addWidget(panel) + return panel + + +def test_availability_from_maintenance(qtbot): + panel = _panel(qtbot) + panel.set_current_state(BeamlineStateEnum.Maintenance) + assert panel._available_targets() == frozenset({BeamlineStateEnum.SampleExchange}) + + +def test_availability_is_union_of_routes_and_menu_shortcuts(qtbot): + panel = _panel(qtbot) + panel.set_current_state(BeamlineStateEnum.SampleAlignment) + targets = panel._available_targets() + assert BeamlineStateEnum.FluxMeasurement in targets # one-hop route + assert BeamlineStateEnum.SampleExchange in targets # status-bar shortcut + assert BeamlineStateEnum.XtalSnapshot not in targets # two hops away + + +def test_no_targets_while_moving_or_unknown(qtbot): + panel = _panel(qtbot) + panel.set_current_state(None) + assert panel._available_targets() == frozenset() + panel.set_current_state(BeamlineStateEnum.Moving) + assert panel._available_targets() == frozenset() + + +def test_emit_gated_by_availability(qtbot): + panel = _panel(qtbot) + panel.set_current_state(BeamlineStateEnum.Maintenance) + with qtbot.waitSignal(panel.sample_exchange, timeout=1000): + panel._emit_for_state(BeamlineStateEnum.SampleExchange) + with qtbot.assertNotEmitted(panel.flux_measurement): + panel._emit_for_state(BeamlineStateEnum.FluxMeasurement) + + +def test_active_maintenance_is_red_and_bold(qtbot): + panel = _panel(qtbot) + panel.set_current_state(BeamlineStateEnum.Maintenance) + maintenance = panel._buttons[BeamlineStateEnum.Maintenance] + assert maintenance.font().bold() + assert STATE_MSG_ERROR in maintenance.styleSheet() + + +def test_availability_palette_and_cursors(qtbot): + panel = _panel(qtbot) + panel.set_current_state(BeamlineStateEnum.Maintenance) + available = panel._buttons[BeamlineStateEnum.SampleExchange] + grey = panel._buttons[BeamlineStateEnum.FluxMeasurement] + assert STATE_AVAILABLE in available.styleSheet() + assert available.cursor().shape() == Qt.CursorShape.PointingHandCursor + assert not available.font().bold() + assert STATE_UNAVAILABLE in grey.styleSheet() + assert grey.cursor().shape() == Qt.CursorShape.ForbiddenCursor + assert grey.toolTip() == "" + assert available.toolTip() != "" + + +def test_active_non_maintenance_is_blue(qtbot): + panel = _panel(qtbot) + panel.set_current_state(BeamlineStateEnum.SampleAlignment) + active = panel._buttons[BeamlineStateEnum.SampleAlignment] + assert active.font().bold() + assert STATE_MSG_INFO in active.styleSheet() + + +def test_labels_wrap_when_narrow_and_unwrap_when_wide(qtbot): + panel = _panel(qtbot) + sample_exchange = panel._buttons[BeamlineStateEnum.SampleExchange] + + panel.resize(600, 60) + panel._update_label_mode() + assert not panel._single_line + assert sample_exchange.text() == "Manual sample\nexchange" + + panel.resize(4000, 60) + panel._update_label_mode() + assert panel._single_line + assert sample_exchange.text() == "Manual sample exchange" + + +def test_left_click_never_transitions_but_hints(qtbot): + panel = _panel(qtbot) + panel.set_current_state(BeamlineStateEnum.Maintenance) + with qtbot.assertNotEmitted(panel.sample_exchange): + panel._on_left_click(BeamlineStateEnum.SampleExchange) # reminder tip + panel._on_left_click(BeamlineStateEnum.FluxMeasurement) # reachability hint + panel._on_left_click(BeamlineStateEnum.Maintenance) # current: no-op + + +def test_hover_hint_timer_lifecycle(qtbot): + panel = _panel(qtbot) + panel.set_current_state(BeamlineStateEnum.Maintenance) + panel._set_hovered_state(BeamlineStateEnum.FluxMeasurement) + assert panel._hover_hint_timer.isActive() + panel._show_hover_hint() # runs the unavailable-hint path + panel._clear_hovered_state() + assert not panel._hover_hint_timer.isActive() + assert panel._hovered_state is None + + +def test_update_daq_status_sets_state(qtbot, daq_status_factory): + panel = _panel(qtbot) + panel.update_daq_status(daq_status_factory(state=BeamlineStateEnum.SampleAlignment)) + assert panel._current_state == BeamlineStateEnum.SampleAlignment + + +def test_pending_target_cleared_on_arrival(qtbot): + panel = _panel(qtbot) + panel.set_current_state(BeamlineStateEnum.Maintenance) + panel._emit_for_state(BeamlineStateEnum.SampleExchange) + assert panel._pending_target_state == BeamlineStateEnum.SampleExchange + panel.set_current_state(BeamlineStateEnum.SampleExchange) + assert panel._pending_target_state is None 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 new file mode 100644 index 00000000..962ad6df --- /dev/null +++ b/tests/unit/gui/test_log_panel.py @@ -0,0 +1,31 @@ +"""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 LogPanel + + +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() + + 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() + + panel.clear() + assert panel.view.toPlainText() == "" + assert mirror.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_models.py b/tests/unit/gui/test_models.py index 718ae6e0..034374cc 100644 --- a/tests/unit/gui/test_models.py +++ b/tests/unit/gui/test_models.py @@ -57,8 +57,8 @@ def test_user_sample_model_init(sample_list): def test_user_sample_model_column_filter(sample_list): model = UserSampleSpreadsheet(samples=sample_list) model.set_show_all_pgroups(True) - # Column 5 is user - model.set_column_filter(5, "U1") + # Column 6 is User (column 0 is the frozen #+status cell) + model.set_column_filter(6, "U1") assert model.rowCount() == 2 model.clear_all_column_filters() assert model.rowCount() == 3 @@ -67,8 +67,8 @@ def test_user_sample_model_column_filter(sample_list): def test_user_sample_model_unique_values(sample_list): model = UserSampleSpreadsheet(samples=sample_list) model.set_show_all_pgroups(True) - # Column 5 is User - users = model.unique_values_for_column(5) + # Column 6 is User (column 0 is the frozen #+status cell) + users = model.unique_values_for_column(6) assert "U1" in users assert "U2" in users assert len(users) == 2 @@ -121,3 +121,127 @@ def test_sample_queue_model_flags(sample_list): model = SampleQueueSpreadsheet(samples=sample_list[:2]) flags = model.flags(model.index(0, 0)) assert flags & Qt.ItemFlag.ItemIsDropEnabled + + +# --- Status logic of the combined dewar/queue view --------------------------- +# The dewar table doubles as the queue view: the frozen "#" column carries a +# status fill (mounted > queued > flagged > measured) and the chip row filters +# by status. This is the logic a local contact trusts at a glance, so it gets +# its own tests. + + +def _status(model, row): + brush = model.data(model.index(row, 0), Qt.ItemDataRole.BackgroundRole) + return None if brush is None else brush.color().name().lower() + + +def _row_of(model, db_id): + return next(r for r in range(model.rowCount()) if model.get_id(r).db_id == db_id) + + +@pytest.fixture +def status_model(sample_list): + from aarecommon.models.models import DewarAddress, SampleShortInfo + + # A measured sample: rotation_count > 1 (exactly 1 must NOT count). + sample_list.append( + SampleShortInfo( + db_id=4, + puck_name="P3", + dewar_name="D3", + sample_name="S4", + run_number=4, + user="U1", + pin=4, + rotation_count=2, + location=DewarAddress(segment="B", pos=1), + ) + ) + model = UserSampleSpreadsheet(samples=sample_list) + model.set_show_all_pgroups(True) + return model + + +def test_status_color_priority(status_model): + from aare.gui.styles import ( + SAMPLE_ROW_QUEUED_BG, + SAMPLE_STATUS_FLAGGED_BG, + SAMPLE_STATUS_MEASURED_BG, + SAMPLE_STATUS_QUEUED_BG, + ) + + model = status_model + assert _status(model, _row_of(model, 1)) is None + + model.set_queued_ids({1}) + model.set_flagged(1, True) + # Queued beats flagged in the All view. + assert _status(model, _row_of(model, 1)) == SAMPLE_STATUS_QUEUED_BG.lower() + model.set_queued_ids(set()) + assert _status(model, _row_of(model, 1)) == SAMPLE_STATUS_FLAGGED_BG.lower() + + # Measured is automatic: rotation_count 2 counts, the fixture's 1-3 don't. + assert _status(model, _row_of(model, 4)) == SAMPLE_STATUS_MEASURED_BG.lower() + assert _status(model, _row_of(model, 2)) is None + + # Mounted always wins. + model.updateCurrentSample(current_puck="P1", current_sample=1) + assert _status(model, _row_of(model, 1)) == SAMPLE_ROW_QUEUED_BG.lower() + + +def test_status_filter_selects_rows(status_model): + model = status_model + model.set_queued_ids({1, 2}) + model.set_flagged(3, True) + + model.set_status_filter("queued") + assert {model.get_id(r).db_id for r in range(model.rowCount())} == {1, 2} + model.set_status_filter("flagged") + assert {model.get_id(r).db_id for r in range(model.rowCount())} == {3} + model.set_status_filter("measured") + assert {model.get_id(r).db_id for r in range(model.rowCount())} == {4} + model.set_status_filter(None) + assert model.rowCount() == 4 + + +def test_status_tints_are_context_dependent(status_model): + from aare.gui.styles import SAMPLE_STATUS_FLAGGED_BG, SAMPLE_STATUS_QUEUED_BG + + model = status_model + model.set_queued_ids({1, 2}) + model.set_flagged(1, True) + + # Queued view: own tint suppressed, only the also-flagged mark shows. + model.set_status_filter("queued") + assert _status(model, _row_of(model, 1)) == SAMPLE_STATUS_FLAGGED_BG.lower() + assert _status(model, _row_of(model, 2)) is None + + # Flagged view: a re-queued sample wears the queued mark. + model.set_status_filter("flagged") + assert _status(model, _row_of(model, 1)) == SAMPLE_STATUS_QUEUED_BG.lower() + + +def test_status_sets_refilter_while_chip_active(status_model): + model = status_model + model.set_status_filter("queued") + assert model.rowCount() == 0 + model.set_queued_ids({2}) + assert {model.get_id(r).db_id for r in range(model.rowCount())} == {2} + + +def test_status_column_is_display_only(status_model): + model = status_model + assert model.data(model.index(0, 0), Qt.ItemDataRole.DisplayRole) == 1 + before = [model.get_id(r).db_id for r in range(model.rowCount())] + model.sort(0, Qt.SortOrder.DescendingOrder) # no-op on the "#" column + assert [model.get_id(r).db_id for r in range(model.rowCount())] == before + + +def test_mime_data_round_trips_for_chip_drops(status_model): + from aarecommon.models.models import SampleShortInfoList + + model = status_model + payload = model.mimeData([model.index(0, 1), model.index(1, 1)]) + samples = SampleShortInfoList.model_validate_json(payload.text()) + assert len(samples.s) == 2 + assert samples.s[0].db_id == model.get_id(0).db_id diff --git a/tests/unit/gui/test_panels.py b/tests/unit/gui/test_panels.py index 424fcc78..d6e1113f 100644 --- a/tests/unit/gui/test_panels.py +++ b/tests/unit/gui/test_panels.py @@ -11,6 +11,7 @@ from aarecommon.models.models import ( ) from aare.gui.panels.status_panel import StatusPanel +from aare.gui.styles import STATUS_ALERT @pytest.fixture @@ -84,5 +85,5 @@ def test_status_panel_low_current(qtbot, mock_daq_status): mock_daq_status.bl.ring_current_mA = 300.0 panel.update_daq_status(mock_daq_status) - assert "color: red" in panel.ring_current.text() + assert f"color: {STATUS_ALERT}" in panel.ring_current.text() assert "300.0" in panel.ring_current.text() diff --git a/tests/unit/gui/test_popout_window.py b/tests/unit/gui/test_popout_window.py new file mode 100644 index 00000000..f42fdca2 --- /dev/null +++ b/tests/unit/gui/test_popout_window.py @@ -0,0 +1,171 @@ +"""PopoutWindow replaces dock floating: an additional top-level window whose +edge band resizes, whose close only hides, and whose first show lands near +the cursor. DockTitleBar puts the pop-out button next to the close box.""" + +from PySide6.QtCore import QEvent, QPoint, QPointF, Qt +from PySide6.QtGui import QMouseEvent +from PySide6.QtWidgets import QDockWidget, QLabel + +from aare.gui.widgets.popout_window import DockTitleBar, PopoutWindow + + +def _window(qtbot): + w = PopoutWindow("test", QLabel("content")) + qtbot.addWidget(w) + w.resize(400, 300) + return w + + +def test_edges_at(qtbot): + w = _window(qtbot) + band = PopoutWindow.RESIZE_MARGIN + PopoutWindow.OUTER_GRIP + assert w._edges_at(QPoint(band - 1, 150)) == Qt.Edge.LeftEdge + assert w._edges_at(QPoint(200, band - 1)) == Qt.Edge.TopEdge + assert w._edges_at(QPoint(399, 299)) == (Qt.Edge.RightEdge | Qt.Edge.BottomEdge) + assert w._edges_at(QPoint(200, 150)) == Qt.Edge(0) + + +def test_cursor_for(qtbot): + w = _window(qtbot) + assert w._cursor_for(Qt.Edge.LeftEdge) == Qt.CursorShape.SizeHorCursor + assert w._cursor_for(Qt.Edge.BottomEdge) == Qt.CursorShape.SizeVerCursor + assert w._cursor_for(Qt.Edge.LeftEdge | Qt.Edge.TopEdge) == Qt.CursorShape.SizeFDiagCursor + assert w._cursor_for(Qt.Edge.RightEdge | Qt.Edge.TopEdge) == Qt.CursorShape.SizeBDiagCursor + assert w._cursor_for(Qt.Edge(0)) is None + + +def test_close_hides_and_keeps_geometry(qtbot): + w = _window(qtbot) + w.show() + qtbot.waitExposed(w) + w.move(120, 130) + geometry = w.geometry() + w.close() + assert not w.isVisible() + # Reopen: the first-show placement must not run again (geometry kept). + w.show() + assert w._placed + assert w.geometry() == geometry + + +def test_manual_resize_fallback(qtbot): + w = _window(qtbot) + w.show() + qtbot.waitExposed(w) + w.resize(400, 300) + start = w.geometry() + + grab = QPointF(399.0, 299.0) # bottom-right band + press = QMouseEvent( + QEvent.Type.MouseButtonPress, + grab, + w.mapToGlobal(grab.toPoint()).toPointF(), + Qt.MouseButton.LeftButton, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier, + ) + w.mousePressEvent(press) + if not w._manual_edges: + # The platform accepted startSystemResize; the manual path is not + # reachable here, and the press must not have moved the window. + assert w.geometry() == start + return + + move_to = grab + QPointF(40.0, 25.0) + move = QMouseEvent( + QEvent.Type.MouseMove, + move_to, + w.mapToGlobal(move_to.toPoint()).toPointF(), + Qt.MouseButton.NoButton, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier, + ) + w.mouseMoveEvent(move) + assert w.width() == start.width() + 40 + assert w.height() == start.height() + 25 + + release = QMouseEvent( + QEvent.Type.MouseButtonRelease, + move_to, + w.mapToGlobal(move_to.toPoint()).toPointF(), + Qt.MouseButton.LeftButton, + Qt.MouseButton.NoButton, + Qt.KeyboardModifier.NoModifier, + ) + w.mouseReleaseEvent(release) + assert not w._manual_edges + + +def test_hover_sets_resize_cursor(qtbot): + w = _window(qtbot) + w.show() + qtbot.waitExposed(w) + + def hover(pos): + w.mouseMoveEvent( + QMouseEvent( + QEvent.Type.MouseMove, + pos, + w.mapToGlobal(pos.toPoint()).toPointF(), + Qt.MouseButton.NoButton, + Qt.MouseButton.NoButton, + Qt.KeyboardModifier.NoModifier, + ) + ) + + hover(QPointF(2.0, 150.0)) + assert w.cursor().shape() == Qt.CursorShape.SizeHorCursor + hover(QPointF(200.0, 150.0)) + assert w.cursor().shape() == Qt.CursorShape.ArrowCursor + + +def test_dock_title_bar_buttons(qtbot): + dock = QDockWidget("Console Log") + qtbot.addWidget(dock) + opened = [] + bar = DockTitleBar(dock, on_popout=lambda: opened.append(True)) + dock.setTitleBarWidget(bar) + dock.show() + qtbot.waitExposed(dock) + + qtbot.mouseClick(bar.popout_button, Qt.MouseButton.LeftButton) + assert opened == [True] + assert dock.isVisible() + + +def test_manual_resize_from_top_left(qtbot): + w = _window(qtbot) + w.show() + qtbot.waitExposed(w) + w.resize(400, 300) + # Drive the manual path directly; whether startSystemResize is available + # is platform luck and the top/left arithmetic deserves coverage either way. + w._manual_edges = Qt.Edge.LeftEdge | Qt.Edge.TopEdge + w._press_global = w.mapToGlobal(QPoint(2, 2)) + w._press_geom = w.geometry() + move_to = QPointF(2.0 + 30.0, 2.0 + 20.0) + w.mouseMoveEvent( + QMouseEvent( + QEvent.Type.MouseMove, + move_to, + w.mapToGlobal(move_to.toPoint()).toPointF(), + Qt.MouseButton.NoButton, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier, + ) + ) + assert w.width() == 400 - 30 + assert w.height() == 300 - 20 + + +def test_paint_border_knob(qtbot, monkeypatch): + from PySide6.QtGui import QPixmap + + from aare.gui.widgets import popout_window as mod + + w = _window(qtbot) + # Default FRAME_L1_WIDTH "0px": render must take the paint-nothing path. + w.render(QPixmap(w.size())) + # With a visible level-1 border the painter path runs. + monkeypatch.setattr(mod, "FRAME_L1_WIDTH", "2px") + w.render(QPixmap(w.size())) diff --git a/tests/unit/gui/test_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 diff --git a/tests/unit/gui/test_tell_sample_panel.py b/tests/unit/gui/test_tell_sample_panel.py new file mode 100644 index 00000000..c8882ad9 --- /dev/null +++ b/tests/unit/gui/test_tell_sample_panel.py @@ -0,0 +1,134 @@ +"""The combined sample dock: chip row filters the table by status, chips +double as drop targets for queue/flag relabeling, and a pop-out panel shares +the docked panel's model so both stay in sync without wiring.""" + +import pytest +from aarecommon.models.models import DewarAddress, SampleShortInfo, SampleShortInfoList + +from aare.gui.panels.tell_sample_panel import TellSamplePanel + + +@pytest.fixture +def samples(): + return SampleShortInfoList( + s=[ + SampleShortInfo( + db_id=i, + puck_name=f"P{i}", + dewar_name="D1", + sample_name=f"S{i}", + run_number=i, + user="U1", + pin=i, + location=DewarAddress(segment="A", pos=i), + ) + for i in (1, 2, 3) + ] + ) + + +@pytest.fixture +def panel(qtbot, samples): + panel = TellSamplePanel(samples=samples) + qtbot.addWidget(panel) + panel.table_model.set_show_all_pgroups(True) + return panel + + +def _chip(panel, key): + return next(c for c in panel.status_chips.buttons() if c.property("status_key") == key) + + +def test_chip_click_drives_the_status_filter(panel): + panel.table_model.set_queued_ids({2}) + _chip(panel, "queued").click() + assert panel.table_model.status_filter == "queued" + assert panel.table_model.rowCount() == 1 + _chip(panel, None).click() + assert panel.table_model.status_filter is None + assert panel.table_model.rowCount() == 3 + + +def test_set_status_chip_syncs_without_filtering(panel): + panel.set_status_chip("flagged") + assert _chip(panel, "flagged").isChecked() + # Sync only checks the chip; it must not fire the filter. + assert panel.table_model.status_filter is None + + +def test_queued_chip_drop_relays_to_the_queue(panel, qtbot, samples): + chip = _chip(panel, "queued") + with qtbot.waitSignal(panel.add_to_queue) as blocker: + chip.samples_dropped.emit(samples) + assert [s.db_id for s in blocker.args[0].s] == [1, 2, 3] + + +def test_flagged_chip_drop_flags_in_the_model(panel, samples): + panel._flag_dropped_samples(SampleShortInfoList(s=samples.s[:2])) + assert panel.table_model.flagged_ids == {1, 2} + + +def test_popout_panel_shares_the_model(panel, qtbot): + popout = TellSamplePanel(model=panel.table_model) + qtbot.addWidget(popout) + assert popout.table_model is panel.table_model + panel.table_model.set_flagged(3, True) + assert 3 in popout.table_model.flagged_ids + + +def test_new_sample_list_updates_rows(panel, samples): + extra = samples.s + [ + SampleShortInfo( + db_id=9, + puck_name="P9", + dewar_name="D2", + sample_name="S9", + run_number=9, + user="U2", + pin=9, + location=DewarAddress(segment="B", pos=1), + ) + ] + panel.new_sample_list(SampleShortInfoList(s=extra)) + assert panel.table_model.rowCount() == 4 + + +def test_queue_drop_chip_accepts_sample_payloads(panel, qtbot, samples): + from PySide6.QtCore import QMimeData, QPointF, Qt + from PySide6.QtGui import QDropEvent + + from aare.gui.panels.tell_sample_panel import QueueDropChip + + chip = next(c for c in panel.status_chips.buttons() if isinstance(c, QueueDropChip)) + + # The event only borrows the QMimeData (C++ pointer), so the mime must + # outlive the dropEvent call — hence created in the test's scope. + def drop(mime): + return QDropEvent( + QPointF(1, 1), + Qt.DropAction.CopyAction, + mime, + Qt.MouseButton.NoButton, + Qt.KeyboardModifier.NoModifier, + ) + + good = QMimeData() + good.setText(samples.model_dump_json()) + with qtbot.waitSignal(chip.samples_dropped): + chip.dropEvent(drop(good)) + + # A non-sample payload is ignored, not crashed on. + bad = QMimeData() + bad.setText("not json") + with qtbot.assertNotEmitted(chip.samples_dropped): + chip.dropEvent(drop(bad)) + + +def test_selected_samples_follow_the_click(panel): + view = panel.table_view + view.selectRow(0) + row_ids = [panel.table_model.get_id(r).db_id for r in range(3)] + # Click inside the selection: the selection is acted on. + assert [s.db_id for s in panel._selected_samples(0)] == [row_ids[0]] + # Click outside the selection: only the clicked row is acted on. + assert [s.db_id for s in panel._selected_samples(2)] == [row_ids[2]] diff --git a/tests/unit/gui/test_title_label.py b/tests/unit/gui/test_title_label.py new file mode 100644 index 00000000..168dc31d --- /dev/null +++ b/tests/unit/gui/test_title_label.py @@ -0,0 +1,75 @@ +from PySide6.QtCore import QSettings +from PySide6.QtWidgets import QGridLayout, QHBoxLayout, QPushButton, QWidget + +from aare.gui.widgets.title_label import TitleLabel + +# Unique title so the test never clashes with real panel settings. +TITLE = "TitleLabelTestPanel" +KEY = f"panel_collapsed/{TITLE}" + + +def _remove_key(): + QSettings("PSI", "AareGUI").remove(KEY) + + +def _build_panel(qtbot): + panel = QWidget() + qtbot.addWidget(panel) + grid = QGridLayout(panel) + title = TitleLabel(TITLE, panel, collapsible=True) + grid.addWidget(title, 0, 0, 1, 2) + direct_child = QPushButton("direct", panel) + grid.addWidget(direct_child, 1, 0) + nested = QHBoxLayout() + nested_child = QPushButton("nested", panel) + nested.addWidget(nested_child) + grid.addLayout(nested, 1, 1) + return panel, title, direct_child, nested_child + + +def test_starts_collapsed_by_default_and_toggle_persists(qtbot): + _remove_key() + try: + _panel, title, direct_child, nested_child = _build_panel(qtbot) + # Default is collapsed; applied deferred with a 0 ms timer (siblings + # don't exist yet at TitleLabel construction). + qtbot.waitUntil(lambda: direct_child.isHidden(), timeout=1000) + assert nested_child.isHidden() + assert not title.isHidden() + assert title.toggle_button.text() == "+" + + title.toggle_collapsed() + assert not direct_child.isHidden() + assert not nested_child.isHidden() + assert title.toggle_button.text() == "−" + assert QSettings("PSI", "AareGUI").value(KEY, True, type=bool) is False + + title.toggle_collapsed() + assert direct_child.isHidden() + assert title.toggle_button.text() == "+" + assert QSettings("PSI", "AareGUI").value(KEY, False, type=bool) is True + finally: + _remove_key() + + +def test_saved_expanded_state_restored_on_construction(qtbot): + QSettings("PSI", "AareGUI").setValue(KEY, False) + try: + _panel, title, direct_child, nested_child = _build_panel(qtbot) + # A saved expanded state must override the collapsed default; give the + # (absent) deferred collapse a chance to run before asserting. + qtbot.wait(100) + assert not direct_child.isHidden() + assert not nested_child.isHidden() + assert title.toggle_button.text() == "−" + finally: + _remove_key() + + +def test_not_collapsible_by_default(qtbot): + panel = QWidget() + qtbot.addWidget(panel) + grid = QGridLayout(panel) + title = TitleLabel("Plain", panel) + grid.addWidget(title, 0, 0) + assert not hasattr(title, "toggle_button") diff --git a/tests/unit/gui/test_wheel_value_guard.py b/tests/unit/gui/test_wheel_value_guard.py new file mode 100644 index 00000000..68d760a9 --- /dev/null +++ b/tests/unit/gui/test_wheel_value_guard.py @@ -0,0 +1,74 @@ +"""The wheel guard is motor protection: a bare wheel over a value widget must +never change the value (it scrolls the page instead); adjusting requires the +deliberate right-button + wheel gesture.""" + +import pytest +from PySide6.QtCore import QPoint, QPointF, Qt +from PySide6.QtGui import QWheelEvent +from PySide6.QtWidgets import QApplication, QScrollArea, QSlider, QSpinBox, QVBoxLayout, QWidget + +from aare.gui.widgets.wheel_value_guard import WheelValueGuard + + +@pytest.fixture +def guard(qapp): + guard = WheelValueGuard() + qapp.installEventFilter(guard) + yield guard + qapp.removeEventFilter(guard) + + +def _wheel(buttons): + return QWheelEvent( + QPointF(5, 5), + QPointF(5, 5), + QPoint(0, 0), + QPoint(0, 120), + buttons, + Qt.KeyboardModifier.NoModifier, + Qt.ScrollPhase.NoScrollPhase, + False, + ) + + +def test_bare_wheel_does_not_adjust(guard, qtbot): + slider = QSlider(Qt.Orientation.Horizontal) + qtbot.addWidget(slider) + slider.setRange(0, 100) + slider.setValue(50) + QApplication.sendEvent(slider, _wheel(Qt.MouseButton.NoButton)) + assert slider.value() == 50 + + +def test_right_button_wheel_adjusts(guard, qtbot): + slider = QSlider(Qt.Orientation.Horizontal) + qtbot.addWidget(slider) + slider.setRange(0, 100) + slider.setValue(50) + QApplication.sendEvent(slider, _wheel(Qt.MouseButton.RightButton)) + assert slider.value() != 50 + + +def test_bare_wheel_scrolls_the_enclosing_area(guard, qtbot): + area = QScrollArea() + qtbot.addWidget(area) + content = QWidget() + layout = QVBoxLayout(content) + spin = QSpinBox() + spin.setRange(0, 100) + spin.setValue(50) + layout.addWidget(spin) + # Tall filler so the area has something to scroll. + filler = QWidget() + filler.setFixedHeight(2000) + layout.addWidget(filler) + area.setWidget(content) + area.resize(200, 200) + area.show() + bar = area.verticalScrollBar() + bar.setValue(bar.maximum() // 2) + before = bar.value() + + QApplication.sendEvent(spin, _wheel(Qt.MouseButton.NoButton)) + assert spin.value() == 50 + assert bar.value() != before