From c24da7739b2f845094b875ad2943f0d736b66b3f Mon Sep 17 00:00:00 2001 From: Dawn Date: Sat, 15 Aug 2026 14:11:50 +0200 Subject: [PATCH 01/23] refactor: replace Qt overrides with native mechanisms, install app filters once Per-window installs of the two QApplication-level filters (cursor, wheel guard) stacked one stale copy per MainWindow; now installed once per process, parented to the app. closeEvent overrides in the baton dialogs replaced by finished.connect(timer.stop); keyPressEvent in VideoGraphicsView replaced by WidgetShortcut QShortcuts; hover enter/leave overrides in PlayPauseButton replaced by underMouse(); ValueLabel inherits ClickableLabel instead of duplicating its mousePressEvent. Dead raster-timer branch and a PyQt5-era event-position fallback deleted; redundant try/except and getattr guards dropped now that class-level defaults cover pre-init reads. Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 62 +++++------ src/aare/gui/panels/fluorescence_panel.py | 16 +-- src/aare/gui/panels/portrait_mode.py | 14 +-- src/aare/gui/widgets/baton_request_dialog.py | 20 ++-- src/aare/gui/widgets/camera_image.py | 6 -- src/aare/gui/widgets/motor_move_group.py | 32 +++--- src/aare/gui/widgets/value_label.py | 14 +-- src/aare/gui/widgets/video_image.py | 29 +++--- tests/unit/gui/test_qt_override_reduction.py | 103 +++++++++++++++++++ 9 files changed, 183 insertions(+), 113 deletions(-) create mode 100644 tests/unit/gui/test_qt_override_reduction.py diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 4022bbed..982923db 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -167,10 +167,12 @@ class _AlertBannerHost(QWidget): class MainWindow(QMainWindow): sample_geometry = Signal(SampleGeometryModel) - # Set lazily outside __init__ (first use guards with getattr/default); - # declared for the basedpyright gate. + # Class-level defaults: Qt can call overrides (showEvent, closeEvent) + # before __init__ finishes, so these must be readable without a getattr + # guard; also typed for the basedpyright gate. _session_operations_enabled: bool | None = None _default_dock_split_done: bool = False + _cleanup_done: bool = False _pre_watch_dock_state: QByteArray | None = None _pre_watch_visibility: list[tuple[QWidget, bool]] | None = None @@ -207,7 +209,6 @@ class MainWindow(QMainWindow): self._beamline_recovery_dialog = None self._local_contact_dialog = None self._controls_help_dialog = None - self._cleanup_done = False self._default_window_state = None self._pre_automation_window_state = None self._pre_automation_left_column_visible = True @@ -241,18 +242,19 @@ class MainWindow(QMainWindow): self.state_manager = UIStateManager("PSI", "AareGUI") # App-level, not window-level: dialogs and pop-outs get it too. - self._clickable_cursor_filter = ClickableCursorFilter(self) + # Installed once per process and parented to the app, NOT the window: + # the old per-window copies stacked up (one per MainWindow the test + # suite builds) and kept filtering every process event after their + # parent window died — stale wrappers in the hottest Qt→Python path. 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) + if not app.property("_aare_app_filters_installed"): + app.setProperty("_aare_app_filters_installed", True) + app.installEventFilter(ClickableCursorFilter(app)) + # 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. + app.installEventFilter(WheelValueGuard(app)) self.viewer = JFJochDBusClient() try: @@ -2992,7 +2994,7 @@ class MainWindow(QMainWindow): # 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): + if not self._default_dock_split_done: self._default_dock_split_done = True if not self.state_manager.settings.value("main_window/state"): QTimer.singleShot(0, self._apply_default_dock_split) @@ -3041,7 +3043,7 @@ class MainWindow(QMainWindow): super().closeEvent(event) def cleanup(self): - if getattr(self, "_cleanup_done", False): + if self._cleanup_done: return try: @@ -3209,21 +3211,21 @@ class MainWindow(QMainWindow): obj.setFloating(False) event.ignore() return True - try: - if event.type() in { - QEvent.Type.MouseButtonPress, - QEvent.Type.MouseButtonRelease, - QEvent.Type.MouseMove, - QEvent.Type.Wheel, - QEvent.Type.KeyPress, - QEvent.Type.KeyRelease, - QEvent.Type.FocusIn, - QEvent.Type.TouchBegin, - QEvent.Type.TouchUpdate, - }: - self._mark_user_interaction() - except Exception as e: - logger.debug(f"GUI interaction event filter error: {e}", exc_info=True) + # No try/except: every attribute this touches exists before the first + # install, and the one risky call (backend report) guards itself in + # _refresh_idle_activity. + if event.type() in { + QEvent.Type.MouseButtonPress, + QEvent.Type.MouseButtonRelease, + QEvent.Type.MouseMove, + QEvent.Type.Wheel, + QEvent.Type.KeyPress, + QEvent.Type.KeyRelease, + QEvent.Type.FocusIn, + QEvent.Type.TouchBegin, + QEvent.Type.TouchUpdate, + }: + self._mark_user_interaction() # getattr defaults: this filter also runs for events delivered while # __init__ is still building (or teardown is tearing down) the very # widgets it inspects — a raise here spams every event and breaks diff --git a/src/aare/gui/panels/fluorescence_panel.py b/src/aare/gui/panels/fluorescence_panel.py index b67f25a7..84109e2c 100644 --- a/src/aare/gui/panels/fluorescence_panel.py +++ b/src/aare/gui/panels/fluorescence_panel.py @@ -2,7 +2,7 @@ import numpy as np 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.QtCore import QEvent, Qt, Slot from PySide6.QtGui import QPainter, QPen from PySide6.QtWidgets import QGraphicsSimpleTextItem, QGridLayout, QLabel, QWidget @@ -82,8 +82,7 @@ class FluorescencePanel(QWidget): def eventFilter(self, obj, event): try: if obj is self.chart_view.viewport() and event.type() == QEvent.Type.MouseMove: - pos = event.position() if hasattr(event, "position") else event.pos() - p = QPointF(pos.x(), pos.y()) + p = event.position() plot = self.chart.plotArea() if not plot.contains(p) or self.series.count() == 0: self.chart_view.setToolTip("") @@ -94,19 +93,12 @@ class FluorescencePanel(QWidget): (p.x() - plot.left()) / plot.width() ) - # Snap to the largest Y within +/- 3 indices around nearest index + # Snap to the largest Y within +/- 10 indices around nearest index center = self._nearest_index(x_val) n = self.series.count() left = max(0, center - 10) right = min(n - 1, center + 10) - - best_i = left - best_y = self.series.at(best_i).y() - for i in range(left + 1, right + 1): - yi = self.series.at(i).y() - if yi > best_y: - best_y = yi - best_i = i + best_i = max(range(left, right + 1), key=lambda i: self.series.at(i).y()) pt = self.series.at(best_i) self.chart_view.setToolTip(f"Energy {pt.x():.3f} keV counts {pt.y():.3f}") diff --git a/src/aare/gui/panels/portrait_mode.py b/src/aare/gui/panels/portrait_mode.py index d65231df..50db0af5 100644 --- a/src/aare/gui/panels/portrait_mode.py +++ b/src/aare/gui/panels/portrait_mode.py @@ -154,7 +154,6 @@ class LEDStages(QWidget): class PlayPauseButton(QPushButton): def __init__(self, parent=None): super().__init__(parent) - self._hovered = False self._running = False self.setFixedSize(64, 64) self.setMouseTracking(True) @@ -163,14 +162,6 @@ class PlayPauseButton(QPushButton): self._running = running self.update() - def enterEvent(self, event): - self._hovered = True - self.update() - - def leaveEvent(self, event): - self._hovered = False - self.update() - def paintEvent(self, event): p = QPainter(self) p.setRenderHint(QPainter.Antialiasing) @@ -178,7 +169,10 @@ class PlayPauseButton(QPushButton): cx, cy = rect.width() / 2, rect.height() / 2 r = min(rect.width(), rect.height()) / 2 - 2 - bg_color = qcolor(WHITE) if self._hovered else QColor(ACCENT) + # underMouse() instead of enter/leave overrides tracking a _hovered + # flag: QPushButton already repaints on hover (WA_Hover), so the + # two extra Qt→Python callbacks bought nothing. + bg_color = qcolor(WHITE) if self.underMouse() else QColor(ACCENT) p.setBrush(bg_color) p.setPen(Qt.NoPen) p.drawEllipse(QPointF(cx, cy), r, r) diff --git a/src/aare/gui/widgets/baton_request_dialog.py b/src/aare/gui/widgets/baton_request_dialog.py index 7935145b..4943bab9 100644 --- a/src/aare/gui/widgets/baton_request_dialog.py +++ b/src/aare/gui/widgets/baton_request_dialog.py @@ -180,6 +180,9 @@ class BatonRequestDialog(QDialog): self._timer = QTimer(self) self._timer.setInterval(1000) self._timer.timeout.connect(self._tick) + # finished fires on accept/reject/close alike — replaces the + # closeEvent override that existed only to stop this timer. + self.finished.connect(self._timer.stop) self._timer.start() def _tick(self): @@ -221,22 +224,13 @@ class BatonRequestDialog(QDialog): self._on_accept() def _on_accept(self): - self._timer.stop() self.accepted_signal.emit() self.accept() def _on_refuse(self): - self._timer.stop() self.refused_signal.emit() self.reject() - def closeEvent(self, event): - """Closing the dialog counts as ignoring = auto-accept on timeout.""" - # Don't emit anything here - let the timeout handle it - # or the SSE stream will close the dialog when resolved - self._timer.stop() - super().closeEvent(event) - class BatonPendingDialog(QDialog): """ @@ -331,6 +325,9 @@ class BatonPendingDialog(QDialog): self._timer = QTimer(self) self._timer.setInterval(1000) self._timer.timeout.connect(self._tick) + # finished fires on accept/reject/close alike — replaces the + # closeEvent override that existed only to stop this timer. + self.finished.connect(self._timer.stop) self._timer.start() def _tick(self): @@ -364,10 +361,5 @@ class BatonPendingDialog(QDialog): # Keep cancel button so they can abort the wait if they change their mind def _on_cancel(self): - self._timer.stop() self.cancelled_signal.emit() self.reject() - - def closeEvent(self, event): - self._timer.stop() - super().closeEvent(event) diff --git a/src/aare/gui/widgets/camera_image.py b/src/aare/gui/widgets/camera_image.py index f58f2528..763c5cdf 100644 --- a/src/aare/gui/widgets/camera_image.py +++ b/src/aare/gui/widgets/camera_image.py @@ -609,12 +609,6 @@ class SampleCameraImageLabel(QGraphicsView): if self._pending_load_pos is not None: self.load_image.emit(self._pending_load_pos) self._pending_load_pos = None - return - - if self._pending_load_pos is not None: - self.load_image.emit(self._pending_load_pos) - self._pending_load_pos = None - self.raster_timer.start(self.raster_timer_interval) def mouseReleaseEvent(self, event): if not self._camera_interaction_enabled(): diff --git a/src/aare/gui/widgets/motor_move_group.py b/src/aare/gui/widgets/motor_move_group.py index 2013d110..2248ad48 100644 --- a/src/aare/gui/widgets/motor_move_group.py +++ b/src/aare/gui/widgets/motor_move_group.py @@ -86,24 +86,22 @@ class MotorMoveGroup(QObject): self._set_state(name, "pending") def eventFilter(self, obj, event): - if event.type() == QEvent.Type.KeyPress and event.key() in ( - Qt.Key.Key_Return, - Qt.Key.Key_Enter, + if ( + event.type() == QEvent.Type.KeyPress + and event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter) + and obj in self._boxes.values() ): - for box in self._boxes.values(): - if obj is box: - try: - value = float(box.text()) - except ValueError: - break - bottom = box.range_validator.bottom() - if value < bottom: - QToolTip.showText( - box.mapToGlobal(QPoint(0, box.height())), - f"Too small — minimum value: {box.to_string(bottom)}", - box, - ) - break + try: + value = float(obj.text()) + except ValueError: + return super().eventFilter(obj, event) + bottom = obj.range_validator.bottom() + if value < bottom: + QToolTip.showText( + obj.mapToGlobal(QPoint(0, obj.height())), + f"Too small — minimum value: {obj.to_string(bottom)}", + obj, + ) return super().eventFilter(obj, event) @Slot() diff --git a/src/aare/gui/widgets/value_label.py b/src/aare/gui/widgets/value_label.py index 05ffb7ed..ef0f90e5 100644 --- a/src/aare/gui/widgets/value_label.py +++ b/src/aare/gui/widgets/value_label.py @@ -1,9 +1,9 @@ -from PySide6.QtCore import Qt, Signal -from PySide6.QtWidgets import QLabel +from aare.gui.widgets.clickable_label import ClickableLabel -class ValueLabel(QLabel): - clicked = Signal() +class ValueLabel(ClickableLabel): + # clicked signal + left-click mousePressEvent inherited from + # ClickableLabel — this class only adds the "descr: value unit" text. def __init__(self, text: str, unit: str = "", parent=None): super().__init__(parent) @@ -17,9 +17,3 @@ class ValueLabel(QLabel): ) else: self.setText(f"{self._descr}: {s} {self._unit}") - - def mousePressEvent(self, event): - if event.button() == Qt.MouseButton.LeftButton: - self.clicked.emit() - else: - super().mousePressEvent(event) diff --git a/src/aare/gui/widgets/video_image.py b/src/aare/gui/widgets/video_image.py index 263433a5..a03cee45 100644 --- a/src/aare/gui/widgets/video_image.py +++ b/src/aare/gui/widgets/video_image.py @@ -1,5 +1,5 @@ from PySide6.QtCore import QRectF, Qt, Slot -from PySide6.QtGui import QImage, QPainter, QPixmap +from PySide6.QtGui import QImage, QKeySequence, QPainter, QPixmap, QShortcut from PySide6.QtWidgets import QGraphicsPixmapItem, QGraphicsScene, QGraphicsView from aare.gui.widgets.busy_overlay import BusyOverlayStyle, draw_busy_status_text @@ -34,6 +34,20 @@ class VideoGraphicsView(QGraphicsView): self._busy_overlay_style: BusyOverlayStyle | None = None + # QShortcut instead of a keyPressEvent override: one fewer Qt→Python + # callback on the render path, same focus behavior (WidgetShortcut = + # active only while the view has focus). + for key, slot in ( + (Qt.Key.Key_F, self.fit_to_view), + (Qt.Key.Key_R, self.reset_zoom), + (Qt.Key.Key_Plus, self.zoom_in), + (Qt.Key.Key_Equal, self.zoom_in), + (Qt.Key.Key_Minus, self.zoom_out), + ): + shortcut = QShortcut(QKeySequence(key), self) + shortcut.setContext(Qt.ShortcutContext.WidgetShortcut) + shortcut.activated.connect(slot) + @Slot(QImage) def update_frame(self, qt_image: QImage): """Update the video frame""" @@ -92,19 +106,6 @@ class VideoGraphicsView(QGraphicsView): # Normal scrolling super().wheelEvent(event) - def keyPressEvent(self, event): - """Handle keyboard shortcuts""" - if event.key() == Qt.Key.Key_F: - self.fit_to_view() - elif event.key() == Qt.Key.Key_R: - self.reset_zoom() - elif event.key() == Qt.Key.Key_Plus or event.key() == Qt.Key.Key_Equal: - self.zoom_in() - elif event.key() == Qt.Key.Key_Minus: - self.zoom_out() - else: - super().keyPressEvent(event) - def drawForeground(self, painter: QPainter, rect: QRectF): super().drawForeground(painter, rect) diff --git a/tests/unit/gui/test_qt_override_reduction.py b/tests/unit/gui/test_qt_override_reduction.py new file mode 100644 index 00000000..83934a53 --- /dev/null +++ b/tests/unit/gui/test_qt_override_reduction.py @@ -0,0 +1,103 @@ +"""Covers the code paths touched by the reduce-overwriting-qt-method +refactor: Qt-native replacements (signals, shortcuts, underMouse) for +virtual-method overrides, so the diff-coverage gate sees them executed.""" + +from PySide6.QtCore import QEvent, QPointF, Qt +from PySide6.QtGui import QMouseEvent + +from aare.gui.panels.fluorescence_panel import FluorescencePanel +from aare.gui.panels.portrait_mode import PlayPauseButton +from aare.gui.widgets.baton_request_dialog import BatonPendingDialog, BatonRequestDialog +from aare.gui.widgets.value_label import ValueLabel +from aare.gui.widgets.video_image import VideoGraphicsView + + +def test_baton_dialogs_stop_timer_via_finished(qtbot): + """finished.connect replaced the closeEvent overrides: the timer must + stop on accept, reject AND plain close — the path closeEvent used to + handle.""" + req = BatonRequestDialog("someone") + qtbot.addWidget(req) + assert req._timer.isActive() + req._on_accept() + assert not req._timer.isActive() + + req2 = BatonRequestDialog("someone") + qtbot.addWidget(req2) + req2._on_refuse() + assert not req2._timer.isActive() + + # close() only delivers a close event to a SHOWN dialog — same held for + # the old closeEvent override, so showing first keeps the test honest. + pend = BatonPendingDialog("user") + qtbot.addWidget(pend) + pend.show() + qtbot.waitExposed(pend) + assert pend._timer.isActive() + pend.close() + assert not pend._timer.isActive() + + +def test_fluorescence_hover_snaps_to_peak(qtbot): + """Drives a MouseMove through the viewport filter: event.position() + (the PyQt5-era hasattr fallback is gone) and the max(range) peak snap.""" + panel = FluorescencePanel() + qtbot.addWidget(panel) + panel.resize(500, 400) + panel.show() + qtbot.waitExposed(panel) + + panel.axis_x.setRange(0.0, 10.0) + panel.axis_y.setRange(0.0, 100.0) + for i in range(50): + panel.series.append(i * 0.2, 90.0 if i == 25 else 10.0) + + plot = panel.chart.plotArea() + pos = QPointF(plot.center()) + ev = QMouseEvent( + QEvent.Type.MouseMove, + pos, + panel.chart_view.viewport().mapToGlobal(pos.toPoint()), + Qt.MouseButton.NoButton, + Qt.MouseButton.NoButton, + Qt.KeyboardModifier.NoModifier, + ) + assert panel.eventFilter(panel.chart_view.viewport(), ev) is False + assert "keV" in panel.chart_view.toolTip() + + +def test_video_view_shortcuts_replace_keypress_override(qtbot): + view = VideoGraphicsView() + qtbot.addWidget(view) + view.show() + qtbot.waitExposed(view) + # WidgetShortcut context needs real focus; offscreen grants it only + # after the window is active. + view.activateWindow() + view.setFocus() + qtbot.waitUntil(view.hasFocus, timeout=2000) + + qtbot.keyClick(view, Qt.Key.Key_Plus) + assert view.zoom_factor > 1.0 + qtbot.keyClick(view, Qt.Key.Key_R) + assert view.zoom_factor == 1.0 + qtbot.keyClick(view, Qt.Key.Key_Minus) + assert view.zoom_factor < 1.0 + qtbot.keyClick(view, Qt.Key.Key_F) # fit_to_view: just must not raise + + +def test_play_pause_button_paints_without_hover_overrides(qtbot): + btn = PlayPauseButton() + qtbot.addWidget(btn) + btn.set_running(True) + # grab() forces a real paintEvent pass over the underMouse() branch + assert not btn.grab().isNull() + + +def test_value_label_inherits_click(qtbot): + label = ValueLabel("Energy", "keV") + qtbot.addWidget(label) + label.set_value("12.4") + assert "12.4" in label.text() + with qtbot.waitSignal(label.clicked, timeout=1000): + qtbot.mousePress(label, Qt.MouseButton.LeftButton) -- 2.54.0 From fbd40eee90b96e517252a757c3e00e9af4902977 Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 17 Aug 2026 13:58:24 +0200 Subject: [PATCH 02/23] feat: breathe the pending transition target in the state strip While Moving, the target state sat solid blue and read as already reached. An opacity pulse (QGraphicsOpacityEffect + QPropertyAnimation) marks it as in progress; a stylesheet animation would repolish the button every frame, which the guarded updates in _apply_highlight exist to avoid. Co-Authored-By: Claude Fable 5 --- src/aare/gui/panels/beamline_state_panel.py | 41 ++++++++++++++++++++- tests/unit/gui/test_beamline_state_panel.py | 16 ++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/aare/gui/panels/beamline_state_panel.py b/src/aare/gui/panels/beamline_state_panel.py index 0719ded7..da6854e5 100644 --- a/src/aare/gui/panels/beamline_state_panel.py +++ b/src/aare/gui/panels/beamline_state_panel.py @@ -1,9 +1,18 @@ from typing import ClassVar from aarecommon.models.models import BeamlineStateEnum, DAQStatusModel -from PySide6.QtCore import Qt, QTimer, Signal, Slot +from PySide6.QtCore import QByteArray, QPropertyAnimation, Qt, QTimer, Signal, Slot from PySide6.QtGui import QCursor, QFont, QFontMetrics -from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QMenu, QPushButton, QSizePolicy, QToolTip +from PySide6.QtWidgets import ( + QFrame, + QGraphicsOpacityEffect, + QHBoxLayout, + QLabel, + QMenu, + QPushButton, + QSizePolicy, + QToolTip, +) from aare.gui.styles import FONT_VALUE, THEME_SUNRISE, state_colors @@ -133,6 +142,7 @@ class BeamlineStatePanel(QFrame): self._hovered_state: BeamlineStateEnum | None = None self._pending_target_state: BeamlineStateEnum | None = None self._busy = False + self._breathing_button: HoverableButton | None = None # After 3 s of hovering an unavailable state, explain which states # it can be reached from. @@ -322,8 +332,31 @@ class BeamlineStatePanel(QFrame): self._style_separator(separator) self._apply_highlight() + def _sync_breathing(self, button: HoverableButton | None) -> None: + # The transition target used to sit solid blue while Moving, reading + # as "already there". Breathe it via an opacity effect instead of a + # stylesheet animation: restyling would repolish the button per frame. + if button is self._breathing_button: + return + if self._breathing_button is not None: + # Qt deletes the old effect (and the animation parented to it). + self._breathing_button.setGraphicsEffect(None) # type: ignore[arg-type] + self._breathing_button = button + if button is None: + return + effect = QGraphicsOpacityEffect(button) + button.setGraphicsEffect(effect) + animation = QPropertyAnimation(effect, QByteArray(b"opacity"), effect) + animation.setDuration(1600) + animation.setStartValue(1.0) + animation.setKeyValueAt(0.5, 0.35) + animation.setEndValue(1.0) + animation.setLoopCount(-1) + animation.start() + def _apply_highlight(self) -> None: available = self._available_targets() + pending_button: HoverableButton | None = None for state, button in self._buttons.items(): is_current = state == self._current_state is_pending = ( @@ -331,6 +364,8 @@ class BeamlineStatePanel(QFrame): and self._current_state == BeamlineStateEnum.Moving ) is_available = state in available + if is_pending: + pending_button = button # Availability drives the look: active = bold (red for # Maintenance, blue otherwise), reachable = orange, rest = grey. @@ -391,6 +426,8 @@ class BeamlineStatePanel(QFrame): if button.toolTip() != tooltip: button.setToolTip(tooltip) + self._sync_breathing(pending_button) + def set_current_state(self, state: BeamlineStateEnum | None) -> None: self._current_state = state if ( diff --git a/tests/unit/gui/test_beamline_state_panel.py b/tests/unit/gui/test_beamline_state_panel.py index 3e9bce74..0e4c7bb9 100644 --- a/tests/unit/gui/test_beamline_state_panel.py +++ b/tests/unit/gui/test_beamline_state_panel.py @@ -121,3 +121,19 @@ def test_pending_target_cleared_on_arrival(qtbot): assert panel._pending_target_state == BeamlineStateEnum.SampleExchange panel.set_current_state(BeamlineStateEnum.SampleExchange) assert panel._pending_target_state is None + + +def test_pending_target_breathes_only_while_moving(qtbot): + panel = _panel(qtbot) + panel.set_current_state(BeamlineStateEnum.Maintenance) + panel._emit_for_state(BeamlineStateEnum.SampleExchange) + target = panel._buttons[BeamlineStateEnum.SampleExchange] + assert target.graphicsEffect() is None # not Moving yet + + panel.set_current_state(BeamlineStateEnum.Moving) + assert panel._breathing_button is target + assert target.graphicsEffect() is not None + + panel.set_current_state(BeamlineStateEnum.SampleExchange) + assert panel._breathing_button is None + assert target.graphicsEffect() is None -- 2.54.0 From 80063a5df1c7c8b3c0c04aed6dfb5f160e477d47 Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 17 Aug 2026 13:58:28 +0200 Subject: [PATCH 03/23] feat: wave-animate the busy overlay titles BEAMLINE BUSY and the robot warnings now hop per letter (clipped sine) so they read as an in-progress signal, not a frozen label. Gated by a new BusyOverlayStyle.animate flag: 'In viewing mode' stays static because it is a passive notice. No timer - the phase rides the wall clock and repaints ride the ~20 fps camera frames, so the wave freezes with a stalled feed (which has its own error surface). Co-Authored-By: Claude Fable 5 --- src/aare/gui/widgets/busy_overlay.py | 55 +++++++++++++++++++++++++++- src/aare/gui/widgets/camera_image.py | 29 +++++++++++---- 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/src/aare/gui/widgets/busy_overlay.py b/src/aare/gui/widgets/busy_overlay.py index ca67a0aa..8bb9b905 100644 --- a/src/aare/gui/widgets/busy_overlay.py +++ b/src/aare/gui/widgets/busy_overlay.py @@ -1,3 +1,5 @@ +import math +import time from dataclasses import dataclass from aarecommon.models.models import SessionsStateEnum @@ -42,6 +44,36 @@ class BusyOverlayStyle: # AxisVideoPanel strips it because only the sample-camera badge is a # click target and the hint invites a click. subtext: str = "" + # Wave-animate the title letters. Only the busy/robot warnings: a static + # "In viewing mode" badge must not look like an in-progress operation. + animate: bool = False + + +def draw_wave_text( + painter: QPainter, + x: int, + baseline: int, + text: str, + fm: QFontMetrics, + fg: QColor, + shadow: QColor | None = None, + amplitude: int = 5, +) -> None: + """Per-letter hop wave for the busy warnings, so the text reads as an + in-progress signal instead of a frozen label. The phase comes from the + wall clock and repaints ride the ~20 fps camera frames — no timer here; + if the stream stalls the wave freezes, which is acceptable (the text + stays legible and a stalled feed has its own error surface).""" + now = time.monotonic() + for index, char in enumerate(text): + # Clipped sine: letters rest on the baseline and hop up in sequence. + lift = -round(amplitude * max(0.0, math.sin(now * 5.5 - index * 0.55))) + if shadow is not None: + painter.setPen(QPen(shadow)) + painter.drawText(QPoint(x + 1, baseline + lift + 1), char) + painter.setPen(QPen(fg)) + painter.drawText(QPoint(x, baseline + lift), char) + x += fm.horizontalAdvance(char) def draw_busy_badge( @@ -89,7 +121,12 @@ def draw_busy_badge( 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.animate: + draw_wave_text( + painter, title_x, title_y, style.text, font_metrics, QColor(style.overlay_text) + ) + else: + painter.drawText(QPoint(title_x, title_y), style.text) if style.subtext: painter.setFont(sub_font) @@ -115,6 +152,17 @@ def draw_busy_status_text( x = (viewport_width - font_metrics.horizontalAdvance(style.text)) // 2 baseline = int(viewport_height * 0.68) + font_metrics.ascent() // 2 + if style.animate: + draw_wave_text( + painter, + x, + baseline, + style.text, + font_metrics, + qcolor(style.badge_bg), + shadow=qcolor(SHADOW, 200), + ) + return painter.setPen(QPen(qcolor(SHADOW, 200))) painter.drawText(QPoint(x + 1, baseline + 1), style.text) painter.setPen(QPen(qcolor(style.badge_bg))) @@ -156,6 +204,7 @@ def build_busy_overlay_style( overlay_border=qcolor(BUSY_RED_BORDER, 230), overlay_text=qcolor(WHITE), accent_dot=BUSY_RED_DOT, + animate=True, ) if activity_value == "unmounting": @@ -167,6 +216,7 @@ def build_busy_overlay_style( overlay_border=qcolor(BUSY_ORANGE_BORDER, 230), overlay_text=qcolor(WHITE), accent_dot=BUSY_ORANGE_DOT, + animate=True, ) if activity_value == "drying": @@ -178,6 +228,7 @@ def build_busy_overlay_style( overlay_border=qcolor(BUSY_YELLOW_BORDER, 235), overlay_text=qcolor(BUSY_YELLOW_TEXT_DARK), accent_dot=BUSY_YELLOW_DOT, + animate=True, ) if activity_value == "cooling": @@ -189,6 +240,7 @@ def build_busy_overlay_style( overlay_border=qcolor(BUSY_BLUE_BORDER, 235), overlay_text=qcolor(WHITE), accent_dot=BUSY_BLUE_DOT, + animate=True, ) return BusyOverlayStyle( @@ -199,4 +251,5 @@ def build_busy_overlay_style( overlay_border=qcolor(BUSY_PSI_RED_BORDER, 235), overlay_text=qcolor(WHITE), accent_dot=BUSY_PSI_RED_DOT, + animate=True, ) diff --git a/src/aare/gui/widgets/camera_image.py b/src/aare/gui/widgets/camera_image.py index 763c5cdf..88c1b5d8 100644 --- a/src/aare/gui/widgets/camera_image.py +++ b/src/aare/gui/widgets/camera_image.py @@ -67,6 +67,7 @@ from aare.gui.widgets.busy_overlay import ( BusyOverlayStyle, build_busy_overlay_style, draw_busy_badge, + draw_wave_text, ) logger = setup_logger(LOGGER_NAME) @@ -337,14 +338,26 @@ class SampleCameraImageLabel(QGraphicsView): 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, - ) + if style.animate: + x = self.viewport().width() // 2 - font_metrics.horizontalAdvance(style.text) // 2 + draw_wave_text( + painter, + x, + baseline, + style.text, + font_metrics, + qcolor(style.badge_bg), + shadow=qcolor(SHADOW, 200), + ) + else: + self._draw_status_text( + painter, + style.text, + style.badge_bg, + self.viewport().width() // 2, + baseline, + font_metrics, + ) painter.restore() return -- 2.54.0 From f303f29214c780b9c97e360b598d6a849e2f7a14 Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 17 Aug 2026 14:01:28 +0200 Subject: [PATCH 04/23] fix: order sample count columns by workflow, unwire crossed columns Both bottom-dock tables now list mount -> raster -> screening -> rotation, the order the work actually happens in. In the Auxiliary table the swap also fixes a real bug: data() feeds get_entry header-index-minus-1, but rotation/raster were crossed against the header, so 'Raster count' displayed rotation counts and vice versa. Co-Authored-By: Claude Fable 5 --- src/aare/gui/models/user_sample_model.py | 8 +++++--- src/aare/gui/panels/reference_tools_panel.py | 12 ++++++++---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/aare/gui/models/user_sample_model.py b/src/aare/gui/models/user_sample_model.py index 207234a5..8d8fc512 100644 --- a/src/aare/gui/models/user_sample_model.py +++ b/src/aare/gui/models/user_sample_model.py @@ -40,9 +40,9 @@ def get_entry(sample: SampleShortInfo, column: int): elif column == 8: return sample.raster_count elif column == 9: - return sample.rotation_count - elif column == 10: return sample.screening_count + elif column == 10: + return sample.rotation_count elif column == 11: return sample.comment return "" @@ -70,8 +70,10 @@ class UserSampleSpreadsheet(QAbstractTableModel): "User", "Mount count", "Raster count", - "Rotation count", + # Workflow order: a sample is screened before rotation data is + # collected, so Screening sits left of Rotation. "Screening count", + "Rotation count", "Comment", ] self.current_sample = current_sample diff --git a/src/aare/gui/panels/reference_tools_panel.py b/src/aare/gui/panels/reference_tools_panel.py index 8710db23..6095a3ab 100644 --- a/src/aare/gui/panels/reference_tools_panel.py +++ b/src/aare/gui/panels/reference_tools_panel.py @@ -20,12 +20,16 @@ def get_entry(sample: SampleShortInfo, column: int): return sample.sample_name elif column == 2: return sample.mount_count + # data() feeds this `header index - 1`: rotation/raster were cross-wired + # against the header, so "Raster count" showed rotation counts and vice + # versa. Order now matches the header (workflow order, same as the Dewar + # table): mount -> raster -> screening -> rotation. elif column == 3: - return sample.rotation_count - elif column == 4: return sample.raster_count - elif column == 5: + elif column == 4: return sample.screening_count + elif column == 5: + return sample.rotation_count return "" @@ -48,8 +52,8 @@ class ReferenceToolsModel(QAbstractTableModel): "Sample name", "Mount count", "Raster count", - "Rotation count", "Screening count", + "Rotation count", ] self._sort_col = 1 self._sort_order = Qt.SortOrder.AscendingOrder -- 2.54.0 From 4175cb8edfa6b732260d9a4f2c3f339216e9ba04 Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 17 Aug 2026 14:02:16 +0200 Subject: [PATCH 05/23] feat: add Unmeasured filter chip to the sample table Complement of Measured (rotation count <= 1): one click shows everything still to be done, so it can be selected wholesale and queued. Plain chip like All - unmeasured rows carry no tint, so there is no legend color to wear; cross-status marks (queued/ flagged) show like in the Measured view. Co-Authored-By: Claude Fable 5 --- src/aare/gui/models/user_sample_model.py | 8 ++++++-- src/aare/gui/panels/tell_sample_panel.py | 6 ++++++ tests/unit/gui/test_models.py | 3 +++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/aare/gui/models/user_sample_model.py b/src/aare/gui/models/user_sample_model.py index 8d8fc512..5e1bc4de 100644 --- a/src/aare/gui/models/user_sample_model.py +++ b/src/aare/gui/models/user_sample_model.py @@ -92,7 +92,8 @@ class UserSampleSpreadsheet(QAbstractTableModel): # SampleQueueSpreadsheet. self.queued_ids: set[int] = set() self.flagged_ids: set[int] = set() - # None = All; otherwise "queued" | "flagged" | "measured" (chip row). + # None = All; otherwise "queued" | "flagged" | "measured" | + # "unmeasured" (chip row). self.status_filter: str | None = None self._sort() @@ -150,7 +151,7 @@ class UserSampleSpreadsheet(QAbstractTableModel): 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 self.status_filter in ("measured", "unmeasured"): if queued: return SAMPLE_STATUS_QUEUED_BG return SAMPLE_STATUS_FLAGGED_BG if flagged else None @@ -263,6 +264,9 @@ class UserSampleSpreadsheet(QAbstractTableModel): 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)] + elif self.status_filter == "unmeasured": + # Everything still to be done — the view to select-all and queue. + rows = [r for r in rows if not self._measured(r)] # Default filter by User using current p-group if no explicit filter set filters: dict[int, str] = { diff --git a/src/aare/gui/panels/tell_sample_panel.py b/src/aare/gui/panels/tell_sample_panel.py index 637c73d8..26a4ee8a 100644 --- a/src/aare/gui/panels/tell_sample_panel.py +++ b/src/aare/gui/panels/tell_sample_panel.py @@ -158,6 +158,7 @@ class TellSamplePanel(QFrame): ("Queued", "queued"), ("Flagged", "flagged"), ("Measured", "measured"), + ("Unmeasured", "unmeasured"), ): if key == "queued": chip = QueueDropChip(label, self) @@ -175,6 +176,11 @@ class TellSamplePanel(QFrame): chip = QPushButton(label, self) if key == "measured": chip.setToolTip("Filter measured samples (automatic: rotation count > 1)") + elif key == "unmeasured": + chip.setToolTip( + "Filter samples not yet measured — select all here to queue" + " everything still to be done" + ) chip.setCheckable(True) chip.setChecked(key is None) chip.setProperty("status_key", key) diff --git a/tests/unit/gui/test_models.py b/tests/unit/gui/test_models.py index 034374cc..6459d835 100644 --- a/tests/unit/gui/test_models.py +++ b/tests/unit/gui/test_models.py @@ -200,6 +200,9 @@ def test_status_filter_selects_rows(status_model): 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} + # Unmeasured is the complement: everything still to be done. + model.set_status_filter("unmeasured") + assert {model.get_id(r).db_id for r in range(model.rowCount())} == {1, 2, 3} model.set_status_filter(None) assert model.rowCount() == 4 -- 2.54.0 From 900bc2f6f7f9c58a8c45d123f5c9bfb70319cb5e Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 17 Aug 2026 14:02:41 +0200 Subject: [PATCH 06/23] feat: add Unflag to the sample table context menu Below Mount/Unmount, acting on the selection like the queue actions. Flagging happens by dropping rows on the Flagged chip; until now there was no way back. Disabled when nothing in the selection is flagged. Co-Authored-By: Claude Fable 5 --- src/aare/gui/panels/tell_sample_panel.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/aare/gui/panels/tell_sample_panel.py b/src/aare/gui/panels/tell_sample_panel.py index 26a4ee8a..f811f02c 100644 --- a/src/aare/gui/panels/tell_sample_panel.py +++ b/src/aare/gui/panels/tell_sample_panel.py @@ -318,6 +318,11 @@ class TellSamplePanel(QFrame): mount_action = menu.addAction("Mount") # Unmount moved here from the removed bottom-row button — same signal. unmount_action = menu.addAction("Unmount") + menu.addSeparator() + # Flagging happens by dropping rows on the Flagged chip; this is the + # only way back, so it lives here even though Flag does not. + unflag_action = menu.addAction(f"Unflag{count}") + unflag_action.setEnabled(any(s.db_id in self.table_model.flagged_ids for s in selected)) action = menu.exec_(self.table_view.viewport().mapToGlobal(position)) @@ -331,6 +336,10 @@ class TellSamplePanel(QFrame): elif action == remove_queue_action: self.remove_from_queue.emit(SampleShortInfoList(s=selected)) self.table_view.clearSelection() + elif action == unflag_action: + for s in selected: + self.table_model.set_flagged(s.db_id, False) + self.table_view.clearSelection() def header_context_menu(self, pos): header = self.table_view.horizontalHeader() -- 2.54.0 From 2a82c598c0010a8d766cb781f31f0a4d85f09b2e Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 17 Aug 2026 14:03:58 +0200 Subject: [PATCH 07/23] feat: live energy/wavelength readout in Beamline setup New 'Current energy / lambda' row above the setpoint, fed per DAQ tick from diffraction.energy_keV; the setpoint row is renamed 'Set Energy (keV)' so 'what is' and 'what to set' are distinct. Guards energy 0.0 (server's detector-unavailable fallback) because the wavelength property divides by it. The copied energy row in the experiment configuration is renamed to match. Co-Authored-By: Claude Fable 5 --- .../gui/panels/data_collection_settings.py | 2 +- src/aare/gui/panels/monochromator_panel.py | 27 ++++++++++++++----- tests/unit/gui/test_monochromator_panel.py | 16 +++++++++++ 3 files changed, 38 insertions(+), 7 deletions(-) create mode 100644 tests/unit/gui/test_monochromator_panel.py diff --git a/src/aare/gui/panels/data_collection_settings.py b/src/aare/gui/panels/data_collection_settings.py index e501dc45..271f5999 100644 --- a/src/aare/gui/panels/data_collection_settings.py +++ b/src/aare/gui/panels/data_collection_settings.py @@ -97,7 +97,7 @@ class DataCollectionSettings(QFrame): energy_row = QWidget(self) energy_layout = QHBoxLayout(energy_row) energy_layout.setContentsMargins(0, 0, 0, 0) - energy_layout.addWidget(QLabel("Energy (keV)", parent=energy_row)) + energy_layout.addWidget(QLabel("Set Energy (keV)", parent=energy_row)) energy_layout.addWidget(self.energy_spin) energy_layout.addWidget(self.change_energy_button) diff --git a/src/aare/gui/panels/monochromator_panel.py b/src/aare/gui/panels/monochromator_panel.py index 6e2efb8e..c4511fa6 100644 --- a/src/aare/gui/panels/monochromator_panel.py +++ b/src/aare/gui/panels/monochromator_panel.py @@ -26,22 +26,28 @@ class MonochromatorPanel(QWidget): self.mono_pitch_scan_button.clicked.connect(self.mono_pitch_scan.emit) grid_layout.addWidget(self.mono_pitch_scan_button, 1, 0, 1, 3) + # Live readout above the setpoint, so "what is" and "what to set" + # stop sharing one ambiguous Energy row. Fed per DAQ tick. + grid_layout.addWidget(QLabel("Current energy / λ", parent=self), 2, 0) + self.current_energy_label = QLabel("—", parent=self) + grid_layout.addWidget(self.current_energy_label, 2, 1, 1, 2) + # 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) + grid_layout.addWidget(QLabel("Set Energy (keV)", parent=self), 3, 0) self.energy_spin = QDoubleSpinBox(parent=self) self.energy_spin.setDecimals(3) self.energy_spin.setRange(1.0, 30.0) self.energy_spin.setSingleStep(0.1) self.energy_spin.setValue(12.0) - grid_layout.addWidget(self.energy_spin, 2, 1) + grid_layout.addWidget(self.energy_spin, 3, 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, 2, 2) + grid_layout.addWidget(self.change_energy_button, 3, 2) # TODO(wire backend): no DAQ endpoint exists yet for moving the beam # to the box center — shown disabled as WIP until the operation is @@ -51,12 +57,21 @@ class MonochromatorPanel(QWidget): self.move_beam_to_box_button.setToolTip("Coming soon — not functional yet.") self.move_beam_to_box_button.setEnabled(False) self.move_beam_to_box_button.clicked.connect(self.move_beam_to_box.emit) - grid_layout.addWidget(self.move_beam_to_box_button, 3, 0, 1, 3) + grid_layout.addWidget(self.move_beam_to_box_button, 4, 0, 1, 3) @Slot() def _emit_change_energy(self): self.change_energy.emit(float(self.energy_spin.value()) * 1000.0) @Slot(DAQStatusModel) - def update_daq_status(self, _status: DAQStatusModel): - pass + def update_daq_status(self, status: DAQStatusModel): + energy = status.diffraction.energy_keV + # 0.0 is the server's detector-unavailable fallback, and the + # wavelength property divides by it — guard before touching it. + if not energy: + text = "— / —" + else: + text = f"{energy:.3f} keV / {status.diffraction.wavelength_angstrom:.4f} Å" + # Guarded: runs per DAQ tick (2 Hz), skip the repaint when unchanged. + if self.current_energy_label.text() != text: + self.current_energy_label.setText(text) diff --git a/tests/unit/gui/test_monochromator_panel.py b/tests/unit/gui/test_monochromator_panel.py new file mode 100644 index 00000000..6286645a --- /dev/null +++ b/tests/unit/gui/test_monochromator_panel.py @@ -0,0 +1,16 @@ +from aare.gui.panels.monochromator_panel import MonochromatorPanel + + +def test_current_energy_readout(qtbot, daq_status_factory): + panel = MonochromatorPanel() + qtbot.addWidget(panel) + + status = daq_status_factory() + panel.update_daq_status(status) + assert panel.current_energy_label.text() == "12.000 keV / 1.0332 Å" + + # 0.0 is the server's detector-unavailable fallback; the wavelength + # property divides by energy, so the readout must not touch it. + status.diffraction = status.diffraction.model_copy(update={"energy_keV": 0.0}) + panel.update_daq_status(status) + assert panel.current_energy_label.text() == "— / —" -- 2.54.0 From 977483ef2843d56abe921fb8c6d999a130423c2e Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 17 Aug 2026 14:13:47 +0200 Subject: [PATCH 08/23] feat: put the ExpHutch shutter left of the fast shutter Beam-path order in the status bar: the hutch shutter sits upstream of the fast shutter, so it reads left to right along the beam. Co-Authored-By: Claude Fable 5 --- src/aare/gui/widgets/status_bar.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/aare/gui/widgets/status_bar.py b/src/aare/gui/widgets/status_bar.py index 34a14b9d..c19a53be 100644 --- a/src/aare/gui/widgets/status_bar.py +++ b/src/aare/gui/widgets/status_bar.py @@ -98,8 +98,9 @@ class StatusBar(QStatusBar): self.addPermanentWidget(self.ring_current) self.addPermanentWidget(self.wvl) self.addPermanentWidget(self.cryo_label) - self.addPermanentWidget(self.shutter_label) + # Beam-path order: the hutch shutter sits upstream of the fast shutter. self.addPermanentWidget(self.exp_shutter_label) + self.addPermanentWidget(self.shutter_label) self.addPermanentWidget(self.pgroup_label) self.addPermanentWidget(self.state_label) self.addPermanentWidget(self.tell_state_label) -- 2.54.0 From 378c25799f9da4e393619439776d2e5af69d389e Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 17 Aug 2026 14:15:38 +0200 Subject: [PATCH 09/23] style: move Unmeasured next to All, 1px gap between filter chips Unmeasured and All are both 'what is left' views; the hand-applied labels (Queued/Flagged/Measured) follow. The 1px spacing keeps the chips readable as separate buttons instead of one solid bar. Co-Authored-By: Claude Fable 5 --- src/aare/gui/panels/tell_sample_panel.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/aare/gui/panels/tell_sample_panel.py b/src/aare/gui/panels/tell_sample_panel.py index f811f02c..59f8a3db 100644 --- a/src/aare/gui/panels/tell_sample_panel.py +++ b/src/aare/gui/panels/tell_sample_panel.py @@ -150,15 +150,18 @@ class TellSamplePanel(QFrame): # 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) + # 1px gap so the chips read as separate buttons, not one solid bar. + chip_row.setSpacing(1) self.status_chips = QButtonGroup(self) self.status_chips.setExclusive(True) for label, key in ( ("All", None), + # Unmeasured next to All: both are "what is left" views, the + # remaining chips are hand-applied labels. + ("Unmeasured", "unmeasured"), ("Queued", "queued"), ("Flagged", "flagged"), ("Measured", "measured"), - ("Unmeasured", "unmeasured"), ): if key == "queued": chip = QueueDropChip(label, self) -- 2.54.0 From a0baa2b54dde762af27be62c7c15658728e797b6 Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 17 Aug 2026 14:20:24 +0200 Subject: [PATCH 10/23] style: help badge at center top, More link right-aligned The '?' badge sat in a corner, which drifts oddly when the view is scaled to fit; the top middle stays put and out of the sample's way. Transparency unchanged - visible enough to find, not disturbing. 'More... (F1)' moves to the overlay's right edge, reading like a dialog's action button. Co-Authored-By: Claude Fable 5 --- src/aare/gui/widgets/camera_image.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/aare/gui/widgets/camera_image.py b/src/aare/gui/widgets/camera_image.py index 88c1b5d8..cb3cde8f 100644 --- a/src/aare/gui/widgets/camera_image.py +++ b/src/aare/gui/widgets/camera_image.py @@ -1089,7 +1089,9 @@ class SampleCameraImageLabel(QGraphicsView): # 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) + # Center top, not a corner: corners drift oddly on scale-to-fit, + # the top middle stays put and stays out of the sample's way. + rect = QRectF((self.viewport().width() - diameter) / 2, 18, diameter, diameter) painter.save() painter.resetTransform() @@ -1166,15 +1168,15 @@ class SampleCameraImageLabel(QGraphicsView): painter.drawText(QPointF(bg_rect.left() + padding, y + fm.ascent()), entry) y += line_height - # Trailing link to the full F1 dialog; underlined so it reads as clickable. + # Trailing link to the full F1 dialog; underlined so it reads as + # clickable, right-aligned like a dialog's action button. link_font = QFont(font) link_font.setUnderline(True) painter.setFont(link_font) painter.setPen(QPen(qcolor(LEGEND_TEXT), 1)) - painter.drawText(QPointF(bg_rect.left() + padding, y + fm.ascent()), more_text) - self._help_more_rect = QRectF( - bg_rect.left() + padding, y, fm.horizontalAdvance(more_text), line_height - ) + more_x = bg_rect.right() - padding - fm.horizontalAdvance(more_text) + painter.drawText(QPointF(more_x, y + fm.ascent()), more_text) + self._help_more_rect = QRectF(more_x, y, fm.horizontalAdvance(more_text), line_height) painter.restore() -- 2.54.0 From 2909182bb104d96a5b2d44ae8bb33a5056a69d67 Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 17 Aug 2026 14:28:39 +0200 Subject: [PATCH 11/23] fix: measured threshold is rotation count >= 1 Confirmed by Dawn: any rotation data counts as measured; unmeasured is exactly rotation count 0. The old > 1 rule left single-rotation samples looking like still-to-do work in the Unmeasured view. Co-Authored-By: Claude Fable 5 --- src/aare/gui/models/user_sample_model.py | 6 +++--- src/aare/gui/panels/tell_sample_panel.py | 6 +++--- tests/unit/gui/test_models.py | 7 ++++--- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/aare/gui/models/user_sample_model.py b/src/aare/gui/models/user_sample_model.py index 5e1bc4de..2890a43c 100644 --- a/src/aare/gui/models/user_sample_model.py +++ b/src/aare/gui/models/user_sample_model.py @@ -165,9 +165,9 @@ class UserSampleSpreadsheet(QAbstractTableModel): @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 + # Automatic status, never relabelled by hand: any rotation data + # counts as measured (>= 1); unmeasured is exactly rotation count 0. + 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) diff --git a/src/aare/gui/panels/tell_sample_panel.py b/src/aare/gui/panels/tell_sample_panel.py index 59f8a3db..de664539 100644 --- a/src/aare/gui/panels/tell_sample_panel.py +++ b/src/aare/gui/panels/tell_sample_panel.py @@ -178,11 +178,11 @@ class TellSamplePanel(QFrame): else: chip = QPushButton(label, self) if key == "measured": - chip.setToolTip("Filter measured samples (automatic: rotation count > 1)") + chip.setToolTip("Filter measured samples (automatic: rotation count ≥ 1)") elif key == "unmeasured": chip.setToolTip( - "Filter samples not yet measured — select all here to queue" - " everything still to be done" + "Filter samples with no rotation data yet — select all here to" + " queue everything still to be done" ) chip.setCheckable(True) chip.setChecked(key is None) diff --git a/tests/unit/gui/test_models.py b/tests/unit/gui/test_models.py index 6459d835..4daa177a 100644 --- a/tests/unit/gui/test_models.py +++ b/tests/unit/gui/test_models.py @@ -143,7 +143,8 @@ def _row_of(model, db_id): def status_model(sample_list): from aarecommon.models.models import DewarAddress, SampleShortInfo - # A measured sample: rotation_count > 1 (exactly 1 must NOT count). + # A measured sample: any rotation data counts (exactly 1 MUST count; + # unmeasured is rotation_count 0, like the fixture's samples 1-3). sample_list.append( SampleShortInfo( db_id=4, @@ -153,7 +154,7 @@ def status_model(sample_list): run_number=4, user="U1", pin=4, - rotation_count=2, + rotation_count=1, location=DewarAddress(segment="B", pos=1), ) ) @@ -180,7 +181,7 @@ def test_status_color_priority(status_model): 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. + # Measured is automatic: rotation_count 1 counts, the 0s of 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 -- 2.54.0 From 4a728baf79a9fa677e83ccb895a1689901b6ce23 Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 17 Aug 2026 14:30:10 +0200 Subject: [PATCH 12/23] feat: clicking the '#' header selects all rows The status column holds no sortable data, so its header click slot was free: it now does Ctrl+A, pairing with the Unmeasured chip to grab everything still to be done in one motion. The sort indicator is put back afterwards - the click drags it onto '#' even though the model ignores sorting there. Co-Authored-By: Claude Fable 5 --- src/aare/gui/panels/tell_sample_panel.py | 21 +++++++++++++++++++++ tests/unit/gui/test_tell_sample_panel.py | 10 ++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/aare/gui/panels/tell_sample_panel.py b/src/aare/gui/panels/tell_sample_panel.py index de664539..9e640b7b 100644 --- a/src/aare/gui/panels/tell_sample_panel.py +++ b/src/aare/gui/panels/tell_sample_panel.py @@ -237,6 +237,16 @@ class TellSamplePanel(QFrame): # 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) + # "#" holds no sortable data; clicking its header selects all rows + # instead (Ctrl+A) — the quick way to act on everything visible. + # The frozen overlay owns the visible "#" header (not sorting-enabled, + # so its sections need explicit clickability); the main header is + # connected too in case the overlay is ever dropped. + self.table_view.frozen.horizontalHeader().setSectionsClickable(True) + self.table_view.frozen.horizontalHeader().sectionClicked.connect( + self._select_all_from_status_header + ) + header.sectionClicked.connect(self._select_all_from_status_header) header.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) header.customContextMenuRequested.connect(self.header_context_menu) @@ -292,6 +302,17 @@ class TellSamplePanel(QFrame): chip.setChecked(True) return + @Slot(int) + def _select_all_from_status_header(self, section: int) -> None: + if section != COL_STATUS: + return + self.table_view.selectAll() + # The click also dragged the sort indicator onto "#" (the model + # ignores sorting there) — put it back where the data actually is. + self.table_view.horizontalHeader().setSortIndicator( + self.table_model._sort_col, self.table_model._sort_order + ) + 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.""" diff --git a/tests/unit/gui/test_tell_sample_panel.py b/tests/unit/gui/test_tell_sample_panel.py index c8882ad9..2774afb0 100644 --- a/tests/unit/gui/test_tell_sample_panel.py +++ b/tests/unit/gui/test_tell_sample_panel.py @@ -124,6 +124,16 @@ def test_queue_drop_chip_accepts_sample_payloads(panel, qtbot, samples): chip.dropEvent(drop(bad)) +def test_status_header_click_selects_all(panel): + header = panel.table_view.frozen.horizontalHeader() + header.sectionClicked.emit(0) + assert len(panel.table_view.selectionModel().selectedRows()) == 3 + # Other sections keep their normal sort-click behavior. + panel.table_view.clearSelection() + header.sectionClicked.emit(1) + assert len(panel.table_view.selectionModel().selectedRows()) == 0 + + def test_selected_samples_follow_the_click(panel): view = panel.table_view view.selectRow(0) -- 2.54.0 From 3053aaae9386bc108b429b036dab0b359a9446f1 Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 17 Aug 2026 14:31:56 +0200 Subject: [PATCH 13/23] style: expanded help overlay opens top center, under the badge The '?' badge moved to the top middle but the cheat sheet still opened bottom-left, sending the mouse across the view to close it. The overlay now drops in where the badge was. Co-Authored-By: Claude Fable 5 --- src/aare/gui/widgets/camera_image.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/aare/gui/widgets/camera_image.py b/src/aare/gui/widgets/camera_image.py index cb3cde8f..e828e842 100644 --- a/src/aare/gui/widgets/camera_image.py +++ b/src/aare/gui/widgets/camera_image.py @@ -1150,7 +1150,9 @@ class SampleCameraImageLabel(QGraphicsView): + padding * 2 ) - bg_rect = QRectF(18, max(18, self.viewport().height() - height - 18), width, height) + # Top center, where the collapsed "?" badge sits — expanding must not + # send the mouse to the other end of the view to close it again. + bg_rect = QRectF((self.viewport().width() - width) / 2, 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)) -- 2.54.0 From 8f91f1ed2ed1cbca04dcd00f761292b0108b19a7 Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 17 Aug 2026 14:50:48 +0200 Subject: [PATCH 14/23] feat: fast shutter open/close row in Beamline setup Status flag on the left (same rich-text scheme as the status bar, theme-aware via set_theme), Open/Close buttons on the right, wired to the same daq.open_shutter/close_shutter slots staff-gated in main_window. Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 5 +++ src/aare/gui/panels/monochromator_panel.py | 39 +++++++++++++++++++++- tests/unit/gui/test_monochromator_panel.py | 22 ++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 982923db..29838c51 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -972,6 +972,8 @@ class MainWindow(QMainWindow): if self._decoded_token.staff: self.monochromator_panel.mono_pitch_scan.connect(self.daq.mono_pitch_scan) self.monochromator_panel.change_energy.connect(self.daq.change_energy) + self.monochromator_panel.open_shutter.connect(self.daq.open_shutter) + self.monochromator_panel.close_shutter.connect(self.daq.close_shutter) 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) @@ -1899,6 +1901,9 @@ class MainWindow(QMainWindow): # _apply_theme call in __init__, hence the guard. if hasattr(self, "status_bar"): self.status_bar.set_theme(self._theme_mode) + # Shutter flag in Beamline setup — staff-only panel, hence the guard. + if hasattr(self, "monochromator_panel"): + self.monochromator_panel.set_theme(self._theme_mode) self.sample_camera.set_theme(self._theme_mode) if old_look is None: return diff --git a/src/aare/gui/panels/monochromator_panel.py b/src/aare/gui/panels/monochromator_panel.py index c4511fa6..6b3dcee7 100644 --- a/src/aare/gui/panels/monochromator_panel.py +++ b/src/aare/gui/panels/monochromator_panel.py @@ -2,6 +2,7 @@ from aarecommon.models.models import DAQStatusModel from PySide6.QtCore import Signal, Slot from PySide6.QtWidgets import QDoubleSpinBox, QGridLayout, QLabel, QPushButton, QWidget +from aare.gui.styles import THEME_SUNRISE, status_colors from aare.gui.widgets.title_label import TitleLabel @@ -9,6 +10,8 @@ class MonochromatorPanel(QWidget): mono_pitch_scan = Signal() change_energy = Signal(float) move_beam_to_box = Signal() + open_shutter = Signal() + close_shutter = Signal() def __init__(self, parent=None): super().__init__(parent) @@ -49,6 +52,22 @@ class MonochromatorPanel(QWidget): self.change_energy_button.clicked.connect(self._emit_change_energy) grid_layout.addWidget(self.change_energy_button, 3, 2) + # Fast shutter row: status left, Open/Close buttons right — same + # rich-text scheme as the status bar flag so the two readouts match. + # Colors are painted in code (set_theme), QSS can't reach the spans. + self._colors = status_colors(THEME_SUNRISE) + self._shutter_open: bool | None = None + self.shutter_status_label = QLabel("Fast Shutter: —", parent=self) + grid_layout.addWidget(self.shutter_status_label, 4, 0) + + self.open_shutter_button = QPushButton("Open", parent=self) + self.open_shutter_button.clicked.connect(self.open_shutter.emit) + grid_layout.addWidget(self.open_shutter_button, 4, 1) + + self.close_shutter_button = QPushButton("Close", parent=self) + self.close_shutter_button.clicked.connect(self.close_shutter.emit) + grid_layout.addWidget(self.close_shutter_button, 4, 2) + # TODO(wire backend): no DAQ endpoint exists yet for moving the beam # to the box center — shown disabled as WIP until the operation is # defined server-side; then drop "(WIP)", enable, and connect the @@ -57,7 +76,22 @@ class MonochromatorPanel(QWidget): self.move_beam_to_box_button.setToolTip("Coming soon — not functional yet.") self.move_beam_to_box_button.setEnabled(False) self.move_beam_to_box_button.clicked.connect(self.move_beam_to_box.emit) - grid_layout.addWidget(self.move_beam_to_box_button, 4, 0, 1, 3) + grid_layout.addWidget(self.move_beam_to_box_button, 5, 0, 1, 3) + + def set_theme(self, theme: str) -> None: + """Adopt the theme's flag colors and re-render the shutter status.""" + self._colors = status_colors(theme) + self._render_shutter() + + def _render_shutter(self) -> None: + if self._shutter_open is None: + text = "Fast Shutter: —" + elif self._shutter_open: + text = f"""Fast Shutter: Open ☢️ """ + else: + text = f"""Fast Shutter: Closed 🚪 """ + if self.shutter_status_label.text() != text: + self.shutter_status_label.setText(text) @Slot() def _emit_change_energy(self): @@ -75,3 +109,6 @@ class MonochromatorPanel(QWidget): # Guarded: runs per DAQ tick (2 Hz), skip the repaint when unchanged. if self.current_energy_label.text() != text: self.current_energy_label.setText(text) + + self._shutter_open = bool(status.bl.shutter_open) + self._render_shutter() diff --git a/tests/unit/gui/test_monochromator_panel.py b/tests/unit/gui/test_monochromator_panel.py index 6286645a..dabd2d2a 100644 --- a/tests/unit/gui/test_monochromator_panel.py +++ b/tests/unit/gui/test_monochromator_panel.py @@ -14,3 +14,25 @@ def test_current_energy_readout(qtbot, daq_status_factory): status.diffraction = status.diffraction.model_copy(update={"energy_keV": 0.0}) panel.update_daq_status(status) assert panel.current_energy_label.text() == "— / —" + + +def test_fast_shutter_row(qtbot, daq_status_factory): + panel = MonochromatorPanel() + qtbot.addWidget(panel) + + # Placeholder until the first DAQ tick. + assert panel.shutter_status_label.text() == "Fast Shutter: —" + + status = daq_status_factory() + panel.update_daq_status(status) + assert "Closed" in panel.shutter_status_label.text() + + status.bl = status.bl.model_copy(update={"shutter_open": True}) + panel.update_daq_status(status) + assert "Open" in panel.shutter_status_label.text() + + # Buttons relay to the same DAQ signals the status-bar menu uses. + with qtbot.waitSignal(panel.open_shutter, timeout=1000): + panel.open_shutter_button.click() + with qtbot.waitSignal(panel.close_shutter, timeout=1000): + panel.close_shutter_button.click() -- 2.54.0 From 32a835b6616425fca6b2a9444705212cc98a5b1c Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 17 Aug 2026 14:57:30 +0200 Subject: [PATCH 15/23] style: wrap shutter flag f-strings for ruff format Co-Authored-By: Claude Fable 5 --- src/aare/gui/panels/monochromator_panel.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/aare/gui/panels/monochromator_panel.py b/src/aare/gui/panels/monochromator_panel.py index 6b3dcee7..9b2a3815 100644 --- a/src/aare/gui/panels/monochromator_panel.py +++ b/src/aare/gui/panels/monochromator_panel.py @@ -87,9 +87,13 @@ class MonochromatorPanel(QWidget): if self._shutter_open is None: text = "Fast Shutter: —" elif self._shutter_open: - text = f"""Fast Shutter: Open ☢️ """ + text = ( + f"""Fast Shutter: Open ☢️ """ + ) else: - text = f"""Fast Shutter: Closed 🚪 """ + text = ( + f"""Fast Shutter: Closed 🚪 """ + ) if self.shutter_status_label.text() != text: self.shutter_status_label.setText(text) -- 2.54.0 From 03ae77fa749a216412dc23045f2660e840ae1b98 Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 17 Aug 2026 15:16:53 +0200 Subject: [PATCH 16/23] fix: pyright-style ignore for setGraphicsEffect(None) basedpyright disables mypy-style 'type: ignore' comments by default, so the diff-quality gate still counted the violation; match the existing 'pyright: ignore[reportArgumentType]' convention from main_window. Co-Authored-By: Claude Fable 5 --- src/aare/gui/panels/beamline_state_panel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aare/gui/panels/beamline_state_panel.py b/src/aare/gui/panels/beamline_state_panel.py index da6854e5..5e0493fa 100644 --- a/src/aare/gui/panels/beamline_state_panel.py +++ b/src/aare/gui/panels/beamline_state_panel.py @@ -340,7 +340,7 @@ class BeamlineStatePanel(QFrame): return if self._breathing_button is not None: # Qt deletes the old effect (and the animation parented to it). - self._breathing_button.setGraphicsEffect(None) # type: ignore[arg-type] + self._breathing_button.setGraphicsEffect(None) # pyright: ignore[reportArgumentType] self._breathing_button = button if button is None: return -- 2.54.0 From 1fef28231b707118f645e02f04ad3cf81e953349 Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 17 Aug 2026 16:02:06 +0200 Subject: [PATCH 17/23] feat: current energy readout in Exp. Config energy row Mirrors the Beamline setup live readout above Set Energy, fed by the same DAQ tick with the zero-energy guard and repaint skip. Co-Authored-By: Claude Fable 5 --- .../gui/panels/data_collection_settings.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/aare/gui/panels/data_collection_settings.py b/src/aare/gui/panels/data_collection_settings.py index 271f5999..167d53ce 100644 --- a/src/aare/gui/panels/data_collection_settings.py +++ b/src/aare/gui/panels/data_collection_settings.py @@ -85,6 +85,17 @@ class DataCollectionSettings(QFrame): centering_layout.addWidget(self.find_tip) centering_layout.addWidget(self.bounding_box) + # Live readout mirrored from the Beamline setup panel, same reason: + # "what is" and "what to set" must not share one ambiguous row. + # Fed per DAQ tick in update_daq_status. + self.current_energy_label = QLabel("—", parent=self) + current_energy_row = QWidget(self) + current_energy_layout = QHBoxLayout(current_energy_row) + current_energy_layout.setContentsMargins(0, 0, 0, 0) + current_energy_layout.addWidget(QLabel("Current energy / λ", parent=current_energy_row)) + current_energy_layout.addWidget(self.current_energy_label) + current_energy_layout.addStretch() + # Energy row copied from the Beamline setup panel so users can change # energy without leaving the experiment configuration. self.energy_spin = QDoubleSpinBox(parent=self) @@ -110,6 +121,7 @@ class DataCollectionSettings(QFrame): # border, and the Abort button should hug the pane. pane_layout.setContentsMargins(6, 6, 6, 0) pane_layout.addWidget(centering_row) + pane_layout.addWidget(current_energy_row) pane_layout.addWidget(energy_row) pane_layout.addWidget(self._stack) @@ -182,6 +194,17 @@ class DataCollectionSettings(QFrame): @Slot(DAQStatusModel) def update_daq_status(self, s: DAQStatusModel): + energy = s.diffraction.energy_keV + # 0.0 is the server's detector-unavailable fallback, and the + # wavelength property divides by it — guard before touching it. + if not energy: + text = "— / —" + else: + text = f"{energy:.3f} keV / {s.diffraction.wavelength_angstrom:.4f} Å" + # Guarded: runs per DAQ tick (2 Hz), skip the repaint when unchanged. + if self.current_energy_label.text() != text: + self.current_energy_label.setText(text) + self.raster.update_daq_status(s) self.screening.update_daq_status(s) self.simple.update_daq_status(s) -- 2.54.0 From 344888dac072090fcf33a751dfb330bd2094bee1 Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 17 Aug 2026 16:10:38 +0200 Subject: [PATCH 18/23] feat: Queued chip view shows the actual run order The queue engine keeps insertion order and Run pops its head, but the Queued view sorted by the dewar table's header sort - the top row was not what runs next. set_queued_ids now keeps the order it is fed (the queue model's order) and the Queued view displays exactly that; header clicks no longer reorder it. Co-Authored-By: Claude Fable 5 --- src/aare/gui/models/user_sample_model.py | 16 +++++++++++++++- tests/unit/gui/test_models.py | 14 ++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/aare/gui/models/user_sample_model.py b/src/aare/gui/models/user_sample_model.py index 2890a43c..0d25a5ce 100644 --- a/src/aare/gui/models/user_sample_model.py +++ b/src/aare/gui/models/user_sample_model.py @@ -91,6 +91,9 @@ class UserSampleSpreadsheet(QAbstractTableModel): # as the queue view). Fed from outside; the queue itself stays in the # SampleQueueSpreadsheet. self.queued_ids: set[int] = set() + # Queue order as the engine holds it (Run pops its head): the Queued + # chip view displays THIS order, not the header sort. + self.queued_order: list[int] = [] self.flagged_ids: set[int] = set() # None = All; otherwise "queued" | "flagged" | "measured" | # "unmeasured" (chip row). @@ -170,7 +173,10 @@ class UserSampleSpreadsheet(QAbstractTableModel): 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) + # Callers pass the ids in queue order (main_window feeds them straight + # from the queue model) — keep it for the Queued view's row order. + self.queued_order = list(db_ids) + self.queued_ids = set(self.queued_order) self._status_sets_changed() def set_flagged(self, db_id: int, flagged: bool) -> None: @@ -238,6 +244,14 @@ class UserSampleSpreadsheet(QAbstractTableModel): def _sort(self): filtered = self._apply_filter(self.samples) + if self.status_filter == "queued": + # The Queued view is the run order — row 1 runs next. Header + # clicks still move the indicator but must not reorder it. + position = {db_id: i for i, db_id in enumerate(self.queued_order)} + self._sorted_samples = sorted( + filtered, key=lambda row: position.get(row.db_id, len(position)) + ) + return if self._sort_col == 4: # Location self._sorted_samples = sorted( filtered, diff --git a/tests/unit/gui/test_models.py b/tests/unit/gui/test_models.py index 4daa177a..95c634bc 100644 --- a/tests/unit/gui/test_models.py +++ b/tests/unit/gui/test_models.py @@ -208,6 +208,20 @@ def test_status_filter_selects_rows(status_model): assert model.rowCount() == 4 +def test_queued_view_shows_queue_order(status_model): + model = status_model + # Queue order deliberately different from location/db order. + model.set_queued_ids([3, 1, 2]) + model.set_status_filter("queued") + assert [model.get_id(r).db_id for r in range(model.rowCount())] == [3, 1, 2] + # Header sorts must not reorder the queue view — row 1 runs next. + model.sort(1, Qt.SortOrder.AscendingOrder) + assert [model.get_id(r).db_id for r in range(model.rowCount())] == [3, 1, 2] + # Other views keep the normal header sort. + model.set_status_filter(None) + assert [model.get_id(r).db_id for r in range(model.rowCount())] != [3, 1, 2] + + def test_status_tints_are_context_dependent(status_model): from aare.gui.styles import SAMPLE_STATUS_FLAGGED_BG, SAMPLE_STATUS_QUEUED_BG -- 2.54.0 From 6ace1fcbcfd8815f75c3d4498427050b35edc659 Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 17 Aug 2026 16:11:35 +0200 Subject: [PATCH 19/23] feat: queue controls live only in the Queued view Run/Remove selected/Clear list act on the queue, whose order only the Queued chip view shows - in other views they are greyed out with a tooltip pointing at the chip. Run stays enabled while automation runs so pausing is always possible. Applies to the docked trio and the pop-out clones alike. Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 40 +++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 29838c51..ea663b8a 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -608,6 +608,23 @@ class MainWindow(QMainWindow): self.job_list_panel.remove_button.clicked.connect(self._remove_selected_from_queue) self.job_list_panel.hide() + # Queue controls act on the queue, whose order only the Queued chip + # view shows — so they are live there and greyed out elsewhere. The + # pop-out clones register themselves in _clone_automation_row. "run" + # kind stays enabled while automation runs: pausing is always allowed. + self._queue_action_buttons: list[tuple[QPushButton, str]] = [ + (self.job_list_panel.play_button, "run"), + (self.job_list_panel.remove_button, "queue"), + (self.job_list_panel.clear_button, "queue"), + ] + self.tell_samples.status_chips.buttonClicked.connect( + lambda _chip: self._update_queue_buttons_enabled() + ) + self.job_list_panel.automation_running_changed.connect( + lambda _running: self._update_queue_buttons_enabled() + ) + self._update_queue_buttons_enabled() + 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") @@ -1297,6 +1314,16 @@ class MainWindow(QMainWindow): sample.db_id for sample in self.job_list_panel.table_model.samples ) + @Slot() + def _update_queue_buttons_enabled(self) -> None: + queued_view = self.tell_samples.table_model.status_filter == "queued" + running = self.job_list_panel.is_running() + tip = "Active in the Queued view — click the Queued chip above the table" + for button, kind in self._queue_action_buttons: + enabled = queued_view or (kind == "run" and running) + button.setEnabled(enabled) + button.setToolTip("" if enabled else tip) + @Slot() def _remove_selected_from_queue(self) -> None: self._unqueue_panel_selection(self.tell_samples) @@ -1341,6 +1368,11 @@ class MainWindow(QMainWindow): dewar_panel.status_chips.buttonClicked.connect( lambda chip: self.tell_samples.set_status_chip(chip.property("status_key")) ) + # Pop-out chips drive the same shared filter — regate the queue + # buttons from here too. + dewar_panel.status_chips.buttonClicked.connect( + lambda _chip: self._update_queue_buttons_enabled() + ) dewar_panel.set_status_chip(self.tell_samples.table_model.status_filter) dewar_tab = QWidget() @@ -1408,6 +1440,14 @@ class MainWindow(QMainWindow): unmount_button = QPushButton("⏏ Unmount") unmount_button.clicked.connect(lambda: self._on_manual_unmount_requested()) + # Same Queued-view gating as the docked trio. + self._queue_action_buttons += [ + (run_button, "run"), + (remove_button, "queue"), + (clear_button, "queue"), + ] + self._update_queue_buttons_enabled() + row = QHBoxLayout() for button in (run_button, remove_button, clear_button, unmount_button): row.addWidget(button) -- 2.54.0 From b58c23d0f65207a6ed15489fbcdeaa04acc2265a Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 17 Aug 2026 16:49:30 +0200 Subject: [PATCH 20/23] feat: sample camera starts in scale-to-fit _autoscale now defaults on, so the view fits from the first layout (the constructor's placeholder already carries the camera size and resizeEvent refits on every resize). update_pixmap additionally refits when the frame size changes - the real stream resolution can differ from the placeholder, and only a view resize refit before. Right-click 'Scale to fit' still toggles back to 1:1. Co-Authored-By: Claude Fable 5 --- src/aare/gui/widgets/camera_image.py | 10 +++++++++- tests/unit/gui/test_camera_image.py | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/aare/gui/widgets/camera_image.py b/src/aare/gui/widgets/camera_image.py index e828e842..423a3bb5 100644 --- a/src/aare/gui/widgets/camera_image.py +++ b/src/aare/gui/widgets/camera_image.py @@ -133,7 +133,9 @@ class SampleCameraImageLabel(QGraphicsView): self._geom = geom self._bookmarks: SmargonBookmarkList = SmargonBookmarkList() - self._autoscale = False + # Fit-to-view from the first frame; the right-click "Scale to fit" + # toggle can still switch back to 1:1. + self._autoscale = True self._show_coords = False self._helical_start = SmargonCoordinate() self._helical_end = SmargonCoordinate() @@ -776,12 +778,18 @@ class SampleCameraImageLabel(QGraphicsView): @Slot(QPixmap) def update_pixmap(self, pixmap: QPixmap): + size_changed = self.pixmap_item is None or self.pixmap_item.pixmap().size() != pixmap.size() if self.pixmap_item is not None: # Ensure pixmap_item exists self.pixmap_item.setPixmap(pixmap) # Update the pixmap in the item else: # If no pixmap item exists (rare case), create one self.pixmap_item = QGraphicsPixmapItem(pixmap) self.scene.addItem(self.pixmap_item) + if size_changed and self._autoscale: + # Refit when the frame size differs from what was fitted (real + # stream resolution vs the 2000x2000 startup placeholder, or a + # camera source switch) — resizeEvent only refits on view resize. + self._scaling() self.viewport().update() # Request an update to redraw the view @Slot(DAQStatusModel) diff --git a/tests/unit/gui/test_camera_image.py b/tests/unit/gui/test_camera_image.py index a5836b95..4730fb6e 100644 --- a/tests/unit/gui/test_camera_image.py +++ b/tests/unit/gui/test_camera_image.py @@ -209,3 +209,19 @@ def test_alt_wheel_axis_swap_still_changes_exposure(camera): n = len(sent) camera.wheelEvent(_wheel(camera)) # zero delta: ignored assert len(sent) == n + + +def test_autoscale_fits_from_the_first_frame(camera): + from PySide6.QtGui import QPixmap + + # Fit-to-view is the default; a frame whose size differs from the fitted + # one (here: the 2000x2000 startup placeholder) must refit immediately, + # not wait for the next view resize. + assert camera._autoscale + camera.update_pixmap(QPixmap(4000, 4000)) + assert camera.transform().m11() < 1.0 + + # The right-click toggle still restores 1:1. + camera._autoscale = False + camera._scaling() + assert camera.transform().isIdentity() -- 2.54.0 From cb5837d90f03c02c56fd017f9eb714a2164e1753 Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 17 Aug 2026 16:50:49 +0200 Subject: [PATCH 21/23] fix: no auto-switch to beamline view during Sample alignment Busy in Sample alignment IS the alignment (centring moves) - yanking the view away to the combined beamline tab hid exactly what the user was doing. The motion-watch switch now skips that state; robot exchange and other busy transitions still switch as before. Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index ea663b8a..115c080c 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -2773,8 +2773,12 @@ class MainWindow(QMainWindow): # Busy (or the robot station) means something is physically moving: # show the Beamline combined view so the motion can be watched, and # return to the sample camera once it is done. Edge-triggered so a - # manual tab choice survives between transitions. - moving = bool(s.busy) or s.state == BeamlineStateEnum.RobotSampleExchange + # manual tab choice survives between transitions. Sample alignment is + # the exception: its busy moves ARE the alignment, and the user needs + # to keep watching the sample camera, not the beamline view. + moving = ( + bool(s.busy) or s.state == BeamlineStateEnum.RobotSampleExchange + ) and s.state != BeamlineStateEnum.SampleAlignment if moving and not self._watching_motion: self._watching_motion = True self.video_tab.setCurrentWidget(self.beamline_combined_panel) -- 2.54.0 From adcc63d45cfaf95357b86fd311d8b5c8a5d06b22 Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 17 Aug 2026 16:54:19 +0200 Subject: [PATCH 22/23] chore: clarify move to mounting position in smargon movement --- src/aare/gui/panels/smargon_panel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aare/gui/panels/smargon_panel.py b/src/aare/gui/panels/smargon_panel.py index 462030e0..5e65966f 100644 --- a/src/aare/gui/panels/smargon_panel.py +++ b/src/aare/gui/panels/smargon_panel.py @@ -83,7 +83,7 @@ class SmargonPanel(QWidget): # Own row: sharing row 1 squeezed the Chi/Phi entry boxes. grid_layout.addWidget(self.move_group.button, 2, 0, 1, 7) - self.home_button = QPushButton("Move home", parent=self) + self.home_button = QPushButton("Move to mounting position", parent=self) grid_layout.addWidget(self.home_button, 3, 0, 1, 7) self.home_button.clicked.connect(self.home) -- 2.54.0 From e9428ca64dece6f9a9c2be6fc5867c635b08449c Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 17 Aug 2026 17:00:26 +0200 Subject: [PATCH 23/23] test: pin the Sample-alignment motion-watch exception Robot motion switches to the combined view; alignment beginning switches straight back while busy is still set, and staying busy in alignment never re-triggers. Piggybacks on the init test's window - extra MainWindow constructions feed the PySide teardown flake. Co-Authored-By: Claude Fable 5 --- tests/unit/gui/test_main_window.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/unit/gui/test_main_window.py b/tests/unit/gui/test_main_window.py index c1cd7040..45f6c84e 100644 --- a/tests/unit/gui/test_main_window.py +++ b/tests/unit/gui/test_main_window.py @@ -14,7 +14,7 @@ def mock_ui_state(): yield mock -def test_main_window_init(qtbot, mock_ui_state): +def test_main_window_init(qtbot, mock_ui_state, daq_status_factory): with ( patch("requests.get") as mock_get, patch("aare.gui.main_window.DAQWorker"), @@ -71,6 +71,25 @@ def test_main_window_init(qtbot, mock_ui_state): win.data_collection._emit_change_energy() assert sent and abs(sent[0] - 12400.0) < 1e-6 + # Motion watch: robot motion switches to the combined beamline view; + # alignment beginning switches straight back to the sample camera even + # while the busy flag is still set — busy moves during Sample + # alignment ARE the alignment, so it never re-triggers the switch. + win.update_daq_status( + daq_status_factory(state=BeamlineStateEnum.RobotSampleExchange, busy=True) + ) + assert win._watching_motion + assert win.video_tab.currentWidget() is win.beamline_combined_panel + win.update_daq_status( + daq_status_factory(state=BeamlineStateEnum.SampleAlignment, busy=True) + ) + assert not win._watching_motion + assert win.video_tab.currentWidget() is win.sample_camera + win.update_daq_status( + daq_status_factory(state=BeamlineStateEnum.SampleAlignment, busy=True) + ) + assert not win._watching_motion + def test_main_window_mount_view(qtbot, mock_ui_state): with ( -- 2.54.0