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)