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 @@
+
\ 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