diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py
index 4022bbed..115c080c 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:
@@ -606,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")
@@ -970,6 +989,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)
@@ -1293,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)
@@ -1337,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()
@@ -1404,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)
@@ -1897,6 +1941,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
@@ -2726,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)
@@ -2992,7 +3043,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 +3092,7 @@ class MainWindow(QMainWindow):
super().closeEvent(event)
def cleanup(self):
- if getattr(self, "_cleanup_done", False):
+ if self._cleanup_done:
return
try:
@@ -3209,21 +3260,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/models/user_sample_model.py b/src/aare/gui/models/user_sample_model.py
index 207234a5..0d25a5ce 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
@@ -89,8 +91,12 @@ 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" (chip row).
+ # None = All; otherwise "queued" | "flagged" | "measured" |
+ # "unmeasured" (chip row).
self.status_filter: str | None = None
self._sort()
@@ -148,7 +154,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
@@ -162,12 +168,15 @@ 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)
+ # 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:
@@ -235,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,
@@ -261,6 +278,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/beamline_state_panel.py b/src/aare/gui/panels/beamline_state_panel.py
index 0719ded7..5e0493fa 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) # pyright: ignore[reportArgumentType]
+ 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/src/aare/gui/panels/data_collection_settings.py b/src/aare/gui/panels/data_collection_settings.py
index e501dc45..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)
@@ -97,7 +108,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)
@@ -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)
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/monochromator_panel.py b/src/aare/gui/panels/monochromator_panel.py
index 6e2efb8e..9b2a3815 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)
@@ -26,22 +29,44 @@ 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)
+
+ # 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
@@ -51,12 +76,43 @@ 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, 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):
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)
+
+ self._shutter_open = bool(status.bl.shutter_open)
+ self._render_shutter()
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/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
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)
diff --git a/src/aare/gui/panels/tell_sample_panel.py b/src/aare/gui/panels/tell_sample_panel.py
index 637c73d8..9e640b7b 100644
--- a/src/aare/gui/panels/tell_sample_panel.py
+++ b/src/aare/gui/panels/tell_sample_panel.py
@@ -150,11 +150,15 @@ 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"),
@@ -174,7 +178,12 @@ 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 with no rotation data yet — select all here to"
+ " queue everything still to be done"
+ )
chip.setCheckable(True)
chip.setChecked(key is None)
chip.setProperty("status_key", key)
@@ -228,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)
@@ -283,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."""
@@ -312,6 +342,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))
@@ -325,6 +360,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()
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/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 f58f2528..423a3bb5 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)
@@ -132,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()
@@ -337,14 +340,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
@@ -609,12 +624,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():
@@ -769,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)
@@ -1082,7 +1097,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()
@@ -1141,7 +1158,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))
@@ -1159,15 +1178,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()
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/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)
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_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
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()
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 (
diff --git a/tests/unit/gui/test_models.py b/tests/unit/gui/test_models.py
index 034374cc..95c634bc 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
@@ -200,10 +201,27 @@ 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
+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
diff --git a/tests/unit/gui/test_monochromator_panel.py b/tests/unit/gui/test_monochromator_panel.py
new file mode 100644
index 00000000..dabd2d2a
--- /dev/null
+++ b/tests/unit/gui/test_monochromator_panel.py
@@ -0,0 +1,38 @@
+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() == "— / —"
+
+
+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()
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)
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)