GUI: dynamic signals, sample tools, fast shutter row #144

Merged
duan_j merged 23 commits from feat/gui-dynamic-signals-and-sample-tools into main 2026-08-17 18:19:23 +02:00
24 changed files with 656 additions and 162 deletions
+83 -32
View File
@@ -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
+29 -9
View File
@@ -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] = {
+39 -2
View File
@@ -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 (
@@ -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)
+4 -12
View File
@@ -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}")
+62 -6
View File
@@ -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: <span style="color: {self._colors["alert"]} ; "> Open ☢️ </span>"""
)
else:
text = (
f"""Fast Shutter: <span style="color: {self._colors["ok"]} ; "> Closed 🚪 </span>"""
)
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()
+4 -10
View File
@@ -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)
+8 -4
View File
@@ -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
+1 -1
View File
@@ -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)
+41 -2
View File
@@ -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()
+6 -14
View File
@@ -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)
+54 -1
View File
@@ -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,
)
+41 -22
View File
@@ -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()
+15 -17
View File
@@ -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()
+2 -1
View File
@@ -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)
+4 -10
View File
@@ -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}: <b>{s}</b> {self._unit}")
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self.clicked.emit()
else:
super().mousePressEvent(event)
+15 -14
View File
@@ -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)
@@ -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
+16
View File
@@ -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()
+20 -1
View File
@@ -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 (
+21 -3
View File
@@ -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
@@ -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()
@@ -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)
+10
View File
@@ -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)