diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 41c49264..6fe73b17 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -72,7 +72,11 @@ jobs: BEAMLINE: SIMULATED run: | source .venv/bin/activate - pytest ./tests/unit + # -vv: one line per test, so a hung job shows the exact test it + # died in, not just a file of dots. -s: no output capture — prints + # and logs stream live instead of vanishing with a wedged worker + # (a pxiii_bec job once sat silent at 81% for hours). + pytest -s -vv ./tests/unit test-with-beamline-plugins: runs-on: ubuntu-latest @@ -105,7 +109,9 @@ jobs: BEAMLINE: SIMULATED run: | source .venv/bin/activate - pytest ./tests/unit + # Same -s -vv rationale as the plain test job: live, per-test + # output so a plugin-variant hang is diagnosable from the log. + pytest -s -vv ./tests/unit test-with-coverage: runs-on: ubuntu-latest diff --git a/src/aare/gui/graphics/aare_banner.png b/src/aare/gui/graphics/aare_banner.png new file mode 100644 index 00000000..f9f4e829 Binary files /dev/null and b/src/aare/gui/graphics/aare_banner.png differ diff --git a/src/aare/gui/gui.py b/src/aare/gui/gui.py index 02303885..1b1fb78f 100644 --- a/src/aare/gui/gui.py +++ b/src/aare/gui/gui.py @@ -21,9 +21,8 @@ def main(): try: basedir = os.path.dirname(__file__) icon_path = os.path.join(basedir, "graphics/aaregui_logo.svg") - # The banner is an SVG now; the old aare_banner.png no longer exists - # and yielded a null splash pixmap. - banner_path = os.path.join(basedir, "graphics/aare_banner.svg") + # SVG looks strange on consoles...only show one line + banner_path = os.path.join(basedir, "graphics/aare_banner.png") except Exception: logger.exception("Failed to load resources for splash screen") sys.exit(1) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index ac34b442..4022bbed 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -198,7 +198,7 @@ class MainWindow(QMainWindow): self._base_url = base_url self._token = token - self._mounting = False + self._watching_motion = False self._samcam_feed_banner_active = False self._samcam_feed_banner_message = "Sample camera feed unavailable" @@ -361,6 +361,10 @@ class MainWindow(QMainWindow): beamline_layout = QVBoxLayout(beamline_page) beamline_layout.setContentsMargins(0, 0, 0, 0) self.samcam = SamcamPanel(beamline_page) + # Non-staff keep the staff-only banners visible (locked stand-ins, a + # click explains the gate) so the Beamline tab reads the same for + # every role instead of looking amputated. + self._locked_beamline_banners: list[TitleLabel] = [] if self._decoded_token.staff: self.monochromator_panel = MonochromatorPanel(beamline_page) self.abr_tweak = AbrTweakWidget(beamline_page) @@ -371,6 +375,21 @@ class MainWindow(QMainWindow): beamline_layout.addWidget(self.monochromator_panel) beamline_layout.addWidget(self.abr_tweak) beamline_layout.addWidget(self.beam_config) + else: + for title in ("Beamline setup", "ABR meas. pos.", "Beam configuration"): + # Holder with default layout margins so the banner aligns with + # the real panels' banners (same trick as BeamConfigPanel). + holder = QWidget(beamline_page) + holder_layout = QVBoxLayout(holder) + holder_layout.setSpacing(0) + banner = TitleLabel(title, holder) + banner.setCursor(Qt.CursorShape.PointingHandCursor) + banner.setToolTip("Only accessible for beamline scientists") + # eventFilter install happens LATE in __init__ (next to + # installEventFilter(self)) — see the comment there. + holder_layout.addWidget(banner) + beamline_layout.addWidget(holder) + self._locked_beamline_banners.append(banner) # Samcam last: the beam panels are the ones tweaked most. beamline_layout.addWidget(self.samcam) beamline_layout.addStretch() @@ -402,7 +421,14 @@ class MainWindow(QMainWindow): # Dewar-tabs look: first banner flush under the tab bar (no top # margin) and the tab row starting at the banners' left edge. The # Experiment side needs two levels: the frame AND its first panel. - beamline_first = self.monochromator_panel if self._decoded_token.staff else self.samcam + beamline_first = ( + self.monochromator_panel + if self._decoded_token.staff + # Non-staff: the first locked banner's holder is the column's + # first widget now, not samcam. + else self._locked_beamline_banners[0].parentWidget() + ) + assert beamline_first is not None # the locked banner always has its holder for first in (beamline_first, self.data_collection, self.data_collection.file_path_panel): first_layout = first.layout() assert first_layout is not None # panels build their layouts in __init__ @@ -596,6 +622,9 @@ class MainWindow(QMainWindow): # Tracks beamline-state TRANSITIONS for the default sample-tab switch # (see _apply_default_sample_tab). self._last_beamline_state: BeamlineStateEnum | None = None + # None = not yet evaluated, so the first status tick always applies + # the gate (the GUI may start while a visitor pgroup is active). + self._beamline_tab_gated: bool | None = None # Wrapper for the left inset: QTabWidget ignores its own contents # margins for the tab bar, so the padding lives one level up. Aligns @@ -856,6 +885,15 @@ class MainWindow(QMainWindow): self.daq = DAQWorker(base_url=self._base_url, token=self._token) self.installEventFilter(self) + # These share MainWindow.eventFilter, which reads sample_lists_tabs + # and left_column_tabs on every event — so they must install only + # HERE, after every attribute the filter touches exists. Installing + # them at widget-construction time made each early event raise + # AttributeError inside the filter ("Error calling Python override + # of QObject::eventFilter()" spam that poisoned CI teardowns). + self.left_column_tabs.tabBar().installEventFilter(self) + for locked_banner in self._locked_beamline_banners: + locked_banner.installEventFilter(self) self._idle_timer = QTimer(self) self._idle_timer.setInterval(60_000) @@ -913,6 +951,7 @@ class MainWindow(QMainWindow): self.sample_camera.smargon.connect(self.daq.move_smargon) self.beamline.smargon_panel.smargon.connect(self.daq.move_smargon) self.sample_camera.samcam_updated.connect(self.daq.samcam_settings) + self.sample_camera.open_full_help.connect(self.show_controls_help) self.raster.omega.connect(self.daq.set_omega) self.raster.smargon.connect(self.daq.move_smargon) @@ -924,6 +963,10 @@ class MainWindow(QMainWindow): self.beamline.illumination_panel.front_light.connect(self.daq.front_light) self.beamline.illumination_panel.back_light.connect(self.daq.back_light) + # Energy row inside Exp. Config. — not staff-gated like the + # Beamline setup copy; the server enforces write permission anyway. + self.data_collection.change_energy.connect(self.daq.change_energy) + 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) @@ -1382,6 +1425,29 @@ class MainWindow(QMainWindow): "The Auxiliary puck (reference tools) view is available to staff accounts only.", ) + def _show_beamline_staff_only_popup(self) -> None: + QMessageBox.information( + self, + "Staff only", + "Beamline configuration is available to staff accounts only.\n" + "Log in with a staff account or contact your local contact " + "to configure the beamline.", + ) + + def _show_beamline_tab_gated_popup(self) -> None: + # The tab is only ever disabled by the pgroup gate; staff hitting it + # are running a visitor's pgroup, users simply lack the rights. + if self._decoded_token.staff: + QMessageBox.information( + self, + "Beamline tab disabled", + "The active pgroup is not one of yours, so the Beamline tab " + "is disabled while running a visitor's experiment. Switch " + "back to your own pgroup to configure the beamline.", + ) + else: + self._show_beamline_staff_only_popup() + @Slot() def _raise_reference_tools(self) -> None: if not self._decoded_token.staff: @@ -1910,7 +1976,7 @@ class MainWindow(QMainWindow): self._theme_action_group = QActionGroup(self) self._theme_action_group.setExclusive(True) - self._use_legacy_theme_action = QAction("Sunrise Theme (default)", self) + self._use_legacy_theme_action = QAction("Sunrise Theme", self) self._use_legacy_theme_action.setCheckable(True) self._use_legacy_theme_action.setChecked(self._theme_mode == THEME_SUNRISE) self._use_legacy_theme_action.triggered.connect(self.use_legacy_theme) @@ -1922,7 +1988,7 @@ class MainWindow(QMainWindow): self._use_portrait_theme_action.triggered.connect(self.use_portrait_theme) self._theme_action_group.addAction(self._use_portrait_theme_action) - self._use_bluebird_theme_action = QAction("Bluebird Theme", self) + self._use_bluebird_theme_action = QAction("Bluebird Theme (default)", self) self._use_bluebird_theme_action.setCheckable(True) self._use_bluebird_theme_action.setChecked(self._theme_mode == THEME_BLUEBIRD) self._use_bluebird_theme_action.triggered.connect(self.use_bluebird_theme) @@ -2573,10 +2639,37 @@ class MainWindow(QMainWindow): else: self.sample_lists_tabs.setCurrentIndex(0) + def _apply_pgroup_gate(self, pgroup: str | None) -> None: + # Switching to a pgroup outside the token's own (admin) pgroups means + # running a visitor's experiment: the Beamline tab is then off-limits, + # and the Experiment tab comes up with Dataset path + Exp. Config. + # open. Applied only on gate TRANSITIONS so a manual tab choice + # survives the 1 Hz status ticks. + gated = pgroup is not None and pgroup not in (self._decoded_token.pgroups or []) + if gated == self._beamline_tab_gated: + return + self._beamline_tab_gated = gated + self.left_column_tabs.setTabEnabled(0, not gated) + self.left_column_tabs.setTabToolTip( + 0, "Only accessible for beamline scientists" if gated else "" + ) + if gated: + self.left_column_tabs.setCurrentIndex(1) + # persist=False: programmatic open must not overwrite the user's + # saved per-panel collapse choice (same rule as the session gate). + for banner in self.data_collection.findChildren(TitleLabel): + if banner.text() in ("Dataset path", "Experiment configuration"): + banner.set_collapsed(False, persist=False) + @Slot(DAQStatusModel) def update_daq_status(self, s: DAQStatusModel): self._latest_daq_status = s self._apply_session_gate(getattr(getattr(s, "session", None), "session", None)) + self._apply_pgroup_gate(getattr(getattr(s, "session", None), "current_pgroup", None)) + + # No login-default state: the GUI adopts whatever state the server + # reports and never posts a transition on startup — a state POST can + # move motors, and merely logging in must not move hardware. # Default tab only on state TRANSITIONS — a manual tab choice # survives while the state stays put. @@ -2630,11 +2723,16 @@ class MainWindow(QMainWindow): if self._remote_close_deadline_ts is not None: self._clear_remote_close_request() - if not self._mounting and s.state == BeamlineStateEnum.RobotSampleExchange: - self._mounting = True + # 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 + if moving and not self._watching_motion: + self._watching_motion = True self.video_tab.setCurrentWidget(self.beamline_combined_panel) - elif self._mounting and s.state != BeamlineStateEnum.RobotSampleExchange: - self._mounting = False + elif not moving and self._watching_motion: + self._watching_motion = False self.video_tab.setCurrentWidget(self.sample_camera) # ========== BATON DIALOG HANDLING ========== @@ -3126,18 +3224,42 @@ class MainWindow(QMainWindow): self._mark_user_interaction() except Exception as e: logger.debug(f"GUI interaction event filter error: {e}", exc_info=True) - # Non-staff click on the greyed-out Auxiliary-puck tab: only installed - # for non-staff, and tabAt() is geometric so it still sees the - # disabled tab — explain the lock instead of silently eating the click. - sample_tab_bar = self.sample_lists_tabs.tabBar() - if ( - event.type() == QEvent.Type.MouseButtonPress - and sample_tab_bar is not None - and obj is sample_tab_bar - and sample_tab_bar.tabAt(event.position().toPoint()) == 1 - ): - self._show_reference_tools_staff_only_popup() - return True + # 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 + # widget cleanup, so missing attributes must mean "not my click". + if event.type() == QEvent.Type.MouseButtonPress: + # Non-staff click on the greyed-out Auxiliary-puck tab: only + # installed for non-staff, and tabAt() is geometric so it still + # sees the disabled tab — explain the lock instead of silently + # eating the click. + sample_tabs = getattr(self, "sample_lists_tabs", None) + sample_tab_bar = sample_tabs.tabBar() if sample_tabs is not None else None + if ( + sample_tab_bar is not None + and obj is sample_tab_bar + and sample_tab_bar.tabAt(event.position().toPoint()) == 1 + ): + self._show_reference_tools_staff_only_popup() + return True + # Non-staff click on a locked Beamline banner: same treatment as + # the Auxiliary-puck tab — explain the gate, don't eat the click. + if obj in getattr(self, "_locked_beamline_banners", ()): + self._show_beamline_staff_only_popup() + return True + # Click on the pgroup-gated (disabled) Beamline tab: tabAt() is + # geometric, so it still sees the disabled tab under the cursor. + left_tabs = getattr(self, "left_column_tabs", None) + if left_tabs is not None: + left_tab_bar = left_tabs.tabBar() + if ( + left_tab_bar is not None + and obj is left_tab_bar + and left_tab_bar.tabAt(event.position().toPoint()) == 0 + and not left_tabs.isTabEnabled(0) + ): + self._show_beamline_tab_gated_popup() + return True return super().eventFilter(obj, event) def _start_remote_close_countdown( diff --git a/src/aare/gui/panels/abr_tweak_panel.py b/src/aare/gui/panels/abr_tweak_panel.py index db949e33..e1deec91 100644 --- a/src/aare/gui/panels/abr_tweak_panel.py +++ b/src/aare/gui/panels/abr_tweak_panel.py @@ -71,6 +71,13 @@ class AbrTweakButtons(QWidget): self.gmz_label.setAlignment(Qt.AlignmentFlag.AlignRight) grid_layout.addWidget(self.gmz_label, 2, 3) + # Reserve room for the sign up front: the column is content-sized, so + # without this it widens whenever a value flips negative and the whole + # row shifts. Right alignment keeps the digits anchored in the gap. + sign_width = self.fontMetrics().horizontalAdvance("-88.888") + for label in (self.gmx_label, self.gmy_label, self.gmz_label): + label.setMinimumWidth(sign_width) + @Slot(dict) def abr_button(self, payload: dict): self.abr_tweak.emit( @@ -149,6 +156,13 @@ class AbrTweakWidget(QWidget): @Slot(DAQStatusModel) def update_daq_status(self, s: DAQStatusModel): + # TODO(confirm intent): labels DISPLAY aerotech_meas (saved measurement + # pos) but the red highlight CHECKS aerotech (live stage pos), so a + # label can read 0.000 and still be red. Guess: red was meant to warn + # "live stage is off the meas pos", i.e. the check should be + # abs(aerotech - aerotech_meas) >= 0.001 per axis; alternative reading + # is it should flag the displayed meas value itself being nonzero. + # Confirm which and re-evaluate before changing the condition. self._abr_buttons.gmx_label.setText(f"{s.geom.aerotech_meas.x:.3f}") if abs(s.geom.aerotech.x) >= 0.001: self._abr_buttons.gmx_label.setStyleSheet(f"color: {ALERT_TEXT};") diff --git a/src/aare/gui/panels/beamline_state_panel.py b/src/aare/gui/panels/beamline_state_panel.py index 50536ad8..0719ded7 100644 --- a/src/aare/gui/panels/beamline_state_panel.py +++ b/src/aare/gui/panels/beamline_state_panel.py @@ -132,6 +132,7 @@ class BeamlineStatePanel(QFrame): self._current_state: BeamlineStateEnum | None = None self._hovered_state: BeamlineStateEnum | None = None self._pending_target_state: BeamlineStateEnum | None = None + self._busy = False # After 3 s of hovering an unavailable state, explain which states # it can be reached from. @@ -238,7 +239,9 @@ class BeamlineStatePanel(QFrame): def _available_targets(self) -> frozenset[BeamlineStateEnum]: current = self._current_state - if current is None or current == BeamlineStateEnum.Moving: + # Busy greys the whole strip like Moving does: a transition posted + # mid-operation would clobber it (same guard as the status-bar menu). + if self._busy or current is None or current == BeamlineStateEnum.Moving: return frozenset() # Reachable in one step: the route graph plus the status-bar # shortcut transitions. @@ -399,4 +402,7 @@ class BeamlineStatePanel(QFrame): self._apply_highlight() def update_daq_status(self, status: DAQStatusModel) -> None: + self._busy = bool(status.busy) + # set_current_state re-applies the highlight, so a busy flip + # restyles the strip on the same tick even if the state held. self.set_current_state(status.state) diff --git a/src/aare/gui/panels/data_collection_settings.py b/src/aare/gui/panels/data_collection_settings.py index 3a5fbc14..e501dc45 100644 --- a/src/aare/gui/panels/data_collection_settings.py +++ b/src/aare/gui/panels/data_collection_settings.py @@ -3,8 +3,10 @@ from aarecommon.math.sample_geometry import SampleGeometryModel from aarecommon.models.models import DAQStatusModel from PySide6.QtCore import Signal, Slot from PySide6.QtWidgets import ( + QDoubleSpinBox, QFrame, QHBoxLayout, + QLabel, QPushButton, QSizePolicy, QStackedWidget, @@ -26,6 +28,8 @@ from aare.gui.widgets.title_label import TitleLabel, tighten_column class DataCollectionSettings(QFrame): cancel = Signal() + # Same convention as the Beamline setup panel: keV in the GUI, eV on emit. + change_energy = Signal(float) set_width = 400 @@ -81,6 +85,22 @@ class DataCollectionSettings(QFrame): centering_layout.addWidget(self.find_tip) centering_layout.addWidget(self.bounding_box) + # Energy row copied from the Beamline setup panel so users can change + # energy without leaving the experiment configuration. + 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) + self.change_energy_button = QPushButton("Change Energy", parent=self) + self.change_energy_button.clicked.connect(self._emit_change_energy) + 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(self.energy_spin) + energy_layout.addWidget(self.change_energy_button) + # Pane frame carries the border QTabWidget::pane used to draw # (#expConfigPane rule in styles.py). pane = QFrame(self) @@ -90,6 +110,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(energy_row) pane_layout.addWidget(self._stack) # Own container: TitleLabel collapse hides its siblings, so without it @@ -147,6 +168,10 @@ class DataCollectionSettings(QFrame): page.setSizePolicy(QSizePolicy.Policy.Preferred, vertical) self._stack.adjustSize() + @Slot() + def _emit_change_energy(self): + self.change_energy.emit(float(self.energy_spin.value()) * 1000.0) + @Slot() def switch_to_raster(self): self._tab_bar.setCurrentIndex(0) diff --git a/src/aare/gui/panels/illumination_panel.py b/src/aare/gui/panels/illumination_panel.py index 160d00df..d96bcbc6 100644 --- a/src/aare/gui/panels/illumination_panel.py +++ b/src/aare/gui/panels/illumination_panel.py @@ -1,9 +1,15 @@ +import time + from aarecommon.models.models import DAQStatusModel from PySide6.QtCore import Qt, Signal, Slot from PySide6.QtWidgets import QGridLayout, QLabel, QSlider, QWidget from aare.gui.widgets.title_label import TitleLabel +# Skip readback updates this long after a user change: a status response already +# in flight when the PUT was queued would otherwise snap the slider back. +READBACK_GRACE_S = 1.5 + class IlluminationPanel(QWidget): front_light = Signal(int) @@ -21,11 +27,18 @@ class IlluminationPanel(QWidget): front_label.setAlignment(Qt.AlignmentFlag.AlignCenter) grid_layout.addWidget(front_label, 1, 0, 1, 2) self.is_sliding = False + self._last_user_change = 0.0 self.front_light_slider = QSlider(orientation=Qt.Orientation.Horizontal, parent=self) self.front_light_slider.setRange(0, 100) + # tracking off: valueChanged fires once per deliberate change (wheel notch, + # key step, groove page-step, drag release). sliderReleased alone missed + # every path except a handle drag, so wheel adjustments were never sent + # and the 500 ms status poll reverted them. + self.front_light_slider.setTracking(False) self.front_light_slider.sliderPressed.connect(self.on_slider_pressed) - self.front_light_slider.sliderReleased.connect(self.on_front_slider_released) + self.front_light_slider.sliderReleased.connect(self.on_slider_released) + self.front_light_slider.valueChanged.connect(self.on_front_value_changed) grid_layout.addWidget(self.front_light_slider, 2, 0, 1, 2) back_label = QLabel("Back light", parent=self) @@ -34,8 +47,10 @@ class IlluminationPanel(QWidget): self.back_light_slider = QSlider(orientation=Qt.Orientation.Horizontal, parent=self) self.back_light_slider.setRange(0, 100) + self.back_light_slider.setTracking(False) self.back_light_slider.sliderPressed.connect(self.on_slider_pressed) - self.back_light_slider.sliderReleased.connect(self.on_back_slider_released) + self.back_light_slider.sliderReleased.connect(self.on_slider_released) + self.back_light_slider.valueChanged.connect(self.on_back_value_changed) grid_layout.addWidget(self.back_light_slider, 4, 0, 1, 2) @Slot() @@ -43,17 +58,29 @@ class IlluminationPanel(QWidget): self.is_sliding = True @Slot() - def on_front_slider_released(self): + def on_slider_released(self): self.is_sliding = False - self.front_light.emit(self.front_light_slider.value()) - @Slot() - def on_back_slider_released(self): - self.is_sliding = False - self.back_light.emit(self.back_light_slider.value()) + @Slot(int) + def on_front_value_changed(self, v: int): + self._last_user_change = time.monotonic() + self.front_light.emit(v) + + @Slot(int) + def on_back_value_changed(self, v: int): + self._last_user_change = time.monotonic() + self.back_light.emit(v) @Slot(DAQStatusModel) def update_daq_status(self, s: DAQStatusModel): - if not self.is_sliding: # Update only if not sliding - self.front_light_slider.setValue(round(s.bl.front_light)) - self.back_light_slider.setValue(round(s.bl.back_light)) + if self.is_sliding or time.monotonic() - self._last_user_change < READBACK_GRACE_S: + return + # blockSignals: readback must not loop back into valueChanged and echo + # a PUT to the server every poll. + for slider, val in ( + (self.front_light_slider, s.bl.front_light), + (self.back_light_slider, s.bl.back_light), + ): + slider.blockSignals(True) + slider.setValue(round(val)) + slider.blockSignals(False) diff --git a/src/aare/gui/panels/monochromator_panel.py b/src/aare/gui/panels/monochromator_panel.py index aee95e50..6e2efb8e 100644 --- a/src/aare/gui/panels/monochromator_panel.py +++ b/src/aare/gui/panels/monochromator_panel.py @@ -8,16 +8,21 @@ from aare.gui.widgets.title_label import TitleLabel class MonochromatorPanel(QWidget): mono_pitch_scan = Signal() change_energy = Signal(float) + move_beam_to_box = Signal() def __init__(self, parent=None): super().__init__(parent) grid_layout = QGridLayout(self) grid_layout.addWidget( - TitleLabel("Monochromator", self, collapsible=True, default_collapsed=False), 0, 0, 1, 3 + TitleLabel("Beamline setup", self, collapsible=True, default_collapsed=False), + 0, + 0, + 1, + 3, ) - self.mono_pitch_scan_button = QPushButton("Mono Pitch Scan", parent=self) + self.mono_pitch_scan_button = QPushButton("Mono Pitch Rock and Go to Max", parent=self) self.mono_pitch_scan_button.clicked.connect(self.mono_pitch_scan.emit) grid_layout.addWidget(self.mono_pitch_scan_button, 1, 0, 1, 3) @@ -38,6 +43,16 @@ class MonochromatorPanel(QWidget): self.change_energy_button.clicked.connect(self._emit_change_energy) grid_layout.addWidget(self.change_energy_button, 2, 2) + # TODO(wire backend): no DAQ endpoint exists yet for moving the beam + # to the box center — shown disabled as WIP until the operation is + # defined server-side; then drop "(WIP)", enable, and connect the + # signal in main_window. + self.move_beam_to_box_button = QPushButton("Move Beam to Box (center) (WIP)", parent=self) + 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) + @Slot() def _emit_change_energy(self): self.change_energy.emit(float(self.energy_spin.value()) * 1000.0) diff --git a/src/aare/gui/panels/omega_panel.py b/src/aare/gui/panels/omega_panel.py index 52260b54..8f7aee08 100644 --- a/src/aare/gui/panels/omega_panel.py +++ b/src/aare/gui/panels/omega_panel.py @@ -35,7 +35,7 @@ class OmegaPanel(QWidget): grid_layout.setColumnStretch(0, 1) grid_layout.setColumnStretch(1, 1) omega_settings = [ - {"name": " 0°", "payload": {"abs": 0.0}}, + {"name": "Go to 0°", "payload": {"abs": 0.0}}, {"name": "-10°", "payload": {"rel": -10.0}}, {"name": "+10°", "payload": {"rel": 10.0}}, {"name": "-45°", "payload": {"rel": -45.0}}, diff --git a/src/aare/gui/panels/smargon_panel.py b/src/aare/gui/panels/smargon_panel.py index 3ed71dce..462030e0 100644 --- a/src/aare/gui/panels/smargon_panel.py +++ b/src/aare/gui/panels/smargon_panel.py @@ -5,6 +5,7 @@ from PySide6.QtCore import Signal, Slot from PySide6.QtWidgets import QGridLayout, QLabel, QPushButton, QWidget from aare.gui.widgets.button_with_payload import ButtonWithPayload +from aare.gui.widgets.motor_move_group import MotorMoveGroup from aare.gui.widgets.number_line_edit import NumberLineEdit from aare.gui.widgets.title_label import TitleLabel @@ -59,34 +60,43 @@ class SmargonPanel(QWidget): grid_layout = QGridLayout(self) grid_layout.addWidget( - TitleLabel("Smargon", self, collapsible=True, default_collapsed=False), 0, 0, 1, 6 + TitleLabel("Smargon", self, collapsible=True, default_collapsed=False), 0, 0, 1, 7 ) grid_layout.addWidget(QLabel("Chi", parent=self), 1, 0) self.chi_enter = NumberLineEdit(-0.2, 40, decimals=1, parent=self) - self.chi_enter.newValue.connect(self.chi) grid_layout.addWidget(self.chi_enter, 1, 1) grid_layout.addWidget(QLabel("°", parent=self), 1, 2) grid_layout.addWidget(QLabel("Phi", parent=self), 1, 3) self.phi_enter = NumberLineEdit(-0.2, 360, decimals=1, parent=self) - self.phi_enter.newValue.connect(self.phi) grid_layout.addWidget(self.phi_enter, 1, 4) grid_layout.addWidget(QLabel("°", parent=self), 1, 5) + # Chi/Phi are staged (orange) and only sent on Move (green until the + # motor reports the target reached) — see MotorMoveGroup. + self.move_group = MotorMoveGroup(parent=self) + self.move_group.add_box("chi", self.chi_enter) + self.move_group.add_box("phi", self.phi_enter) + self.move_group.applied.connect(self._move_axes) + # 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) - grid_layout.addWidget(self.home_button, 2, 0, 1, 6) + grid_layout.addWidget(self.home_button, 3, 0, 1, 7) self.home_button.clicked.connect(self.home) self.move_panel = SmargonMoveWidget(parent=self) - grid_layout.addWidget(self.move_panel, 3, 0, 1, 6) + grid_layout.addWidget(self.move_panel, 4, 0, 1, 7) self.move_panel.smargon_rel.connect(self.smargon_rel) - grid_layout.addWidget(QLabel("Step", parent=self), 4, 0) + grid_layout.addWidget(QLabel("Step", parent=self), 5, 0) self.step = NumberLineEdit(1, 1000, 100, 0, parent=self) - grid_layout.addWidget(self.step, 4, 1) - grid_layout.addWidget(QLabel("μm", parent=self), 4, 2) + # Span to the μm label so the box gets real width instead of the + # narrow column shared with the Chi entry. + grid_layout.addWidget(self.step, 5, 1, 1, 4) + grid_layout.addWidget(QLabel("μm", parent=self), 5, 5) self.step.newValue.connect(self.step_changed) @Slot(float) @@ -98,18 +108,15 @@ class SmargonPanel(QWidget): # TODO move SMARGON_HOME to REDIS, allow GUI to read this value self.smargon.emit(SmargonCoordinate(sh_mm=Coordinate(x=0, y=0, z=18), phi_deg=0, chi_deg=0)) - @Slot(float) - def phi(self, f: float): - self.smargon.emit(SmargonCoordinate(phi_deg=f)) - - @Slot(float) - def chi(self, f: float): - self.smargon.emit(SmargonCoordinate(chi_deg=f)) + @Slot(dict) + def _move_axes(self, targets: dict[str, float]): + # only the staged axes move — absent keys stay None (no motion) + self.smargon.emit(SmargonCoordinate(chi_deg=targets.get("chi"), phi_deg=targets.get("phi"))) @Slot(DAQStatusModel) def update_daq_status(self, s: DAQStatusModel): - self.chi_enter.update_value(s.geom.smargon.chi_deg) - self.phi_enter.update_value(s.geom.smargon.phi_deg) + self.move_group.update_actual("chi", s.geom.smargon.chi_deg) + self.move_group.update_actual("phi", s.geom.smargon.phi_deg) self._geom = s.geom @Slot(Coordinate) diff --git a/src/aare/gui/styles.py b/src/aare/gui/styles.py index 9394a5c7..1f35f723 100644 --- a/src/aare/gui/styles.py +++ b/src/aare/gui/styles.py @@ -348,6 +348,12 @@ INPUT_BG = "rgba(255, 255, 255, 33%)" INPUT_INVALID_BG = "#e9c4cf" # red 20% over latte base INPUT_DISABLED_BG = "#e6e9ef" # latte mantle INPUT_DISABLED_INVALID_BG = "#ecdae2" # faint red wash +# Motor batch-move states (MotorMoveGroup "movestate" dynamic property): +# staged-but-not-sent target vs motor in motion — see motor_move_group.py. +INPUT_PENDING_BG = "#f2cdba" # peach 25% over latte base +INPUT_MOVING_BG = "#c3ddc3" # green 25% over latte base +DARK_INPUT_PENDING_BG = "#473741" # warn (copper) 25% over bg +DARK_INPUT_MOVING_BG = "#2f4e5d" # green 25% over bg # -- Status bar flags (Catppuccin Latte) ------------------------------------ STATUS_OK = "#40a02b" # closed / idle / owned / tell ok (green) @@ -715,6 +721,16 @@ def _sunrise_stylesheet(overrides: dict[str, str] | None = None) -> str: background-color: $input_disabled_bg; } + /* Motor batch-move states (before the invalid rules on purpose: equal + specificity, so out-of-range red must come last to win). */ + QLineEdit[movestate="pending"] { + background-color: $input_pending_bg; + } + + QLineEdit[movestate="moving"] { + background-color: $input_moving_bg; + } + QLineEdit[invalid="true"] { background-color: $input_invalid_bg; } @@ -1380,6 +1396,14 @@ def _sunset_stylesheet() -> str: background-color: $dark_disabled; } + QLineEdit[movestate="pending"] { + background-color: $dark_input_pending_bg; + } + + QLineEdit[movestate="moving"] { + background-color: $dark_input_moving_bg; + } + QLineEdit[invalid="true"] { background-color: $dark_error_bg; } diff --git a/src/aare/gui/threads/prediction_subscriber.py b/src/aare/gui/threads/prediction_subscriber.py index 044fe99f..85b5a24f 100644 --- a/src/aare/gui/threads/prediction_subscriber.py +++ b/src/aare/gui/threads/prediction_subscriber.py @@ -87,6 +87,10 @@ class PredictionSubscriber(QThread): self._measure_focus = enabled def _try_parse_json(self, part: bytes) -> dict | None: + # Binary frames (JPEG starts with 0xff) are not JSON; decoding them as + # utf-8 raises UnicodeDecodeError on every frame. Cheap sniff instead. + if part.lstrip()[:1] != b"{": + return None try: decoded = json.loads(part.decode("utf-8")) return decoded if isinstance(decoded, dict) else None @@ -254,11 +258,9 @@ class PredictionSubscriber(QThread): self.running = False self.requestInterruption() - try: - if self._sock is not None: - self._sock.close(0) - except Exception: - logger.debug("Error while stopping the prediction subscriber", exc_info=True) - + # Do NOT close the socket here: zmq sockets are not thread-safe, and + # closing from the GUI thread while run() uses it aborts libzmq + # (Assertion failed: pfd.revents & POLLIN, signaler.cpp). RCVTIMEO=500ms + # guarantees run() notices running=False and closes it in its own thread. if not self.wait(1500): logger.warning("PredictionSubscriber did not stop within timeout") diff --git a/src/aare/gui/widgets/busy_overlay.py b/src/aare/gui/widgets/busy_overlay.py index e41604f6..ca67a0aa 100644 --- a/src/aare/gui/widgets/busy_overlay.py +++ b/src/aare/gui/widgets/busy_overlay.py @@ -23,6 +23,7 @@ from aare.gui.styles import ( BUSY_YELLOW_BORDER, BUSY_YELLOW_DOT, BUSY_YELLOW_TEXT_DARK, + SHADOW, WHITE, qcolor, ) @@ -99,6 +100,27 @@ def draw_busy_badge( return bg_rect +def draw_busy_status_text( + painter: QPainter, viewport_width: int, viewport_height: int, style: BusyOverlayStyle +) -> None: + """Non-clickable busy status for the passive Axis views: solid colored + text with a 1px shadow, no badge box — the pill read as a button (same + look as the sample camera's own warning text). Same position/typeface as + draw_busy_badge so the message reads identically everywhere.""" + font = QFont() + font.setPointSize(24) + font.setBold(True) + font_metrics = QFontMetrics(font) + painter.setFont(font) + + x = (viewport_width - font_metrics.horizontalAdvance(style.text)) // 2 + baseline = int(viewport_height * 0.68) + font_metrics.ascent() // 2 + painter.setPen(QPen(qcolor(SHADOW, 200))) + painter.drawText(QPoint(x + 1, baseline + 1), style.text) + painter.setPen(QPen(qcolor(style.badge_bg))) + painter.drawText(QPoint(x, baseline), style.text) + + def build_busy_overlay_style( *, is_busy: bool, diff --git a/src/aare/gui/widgets/camera_image.py b/src/aare/gui/widgets/camera_image.py index 27ee8a54..f58f2528 100644 --- a/src/aare/gui/widgets/camera_image.py +++ b/src/aare/gui/widgets/camera_image.py @@ -98,6 +98,8 @@ class SampleCameraImageLabel(QGraphicsView): samcam_updated = Signal(SampleCameraSettings) switch_raster_grid = Signal() + # "More…" link in the help overlay; main window opens the full F1 dialog. + open_full_help = Signal() def __init__( self, @@ -147,6 +149,7 @@ class SampleCameraImageLabel(QGraphicsView): # legend visibility is already handled by the panel checkboxes. self._help_expanded = False self._help_hit_rect: QRectF | None = None # viewport coords, set on paint + self._help_more_rect: QRectF | None = None # "More…" link inside the overlay self._target_point = None self._target_shape = None self._target_color_name = "Cyan" @@ -471,6 +474,19 @@ class SampleCameraImageLabel(QGraphicsView): self._scaling() def mousePressEvent(self, event): + # "More…" link before the box toggle: its rect lies inside the help box, + # so the toggle below would swallow the click otherwise. + if ( + event.button() == Qt.MouseButton.LeftButton + and self._help_more_rect is not None + and self._help_more_rect.contains(QPointF(self.viewport().mapFrom(self, event.pos()))) + ): + self._help_expanded = False + self.update() + self.open_full_help.emit() + event.accept() + return + # Help badge first: pure UI affordance, must work even when camera # interaction is disabled (session overlay etc.). if ( @@ -1087,6 +1103,7 @@ class SampleCameraImageLabel(QGraphicsView): def _draw_help_overlay(self, painter: QPainter): self._help_hit_rect = None + self._help_more_rect = None if not self._help_expanded: self._draw_help_badge(painter) return @@ -1114,8 +1131,15 @@ class SampleCameraImageLabel(QGraphicsView): for entry in entries: max_width = max(max_width, fm.horizontalAdvance(entry)) + more_text = "More… (F1)" + max_width = max(max_width, fm.horizontalAdvance(more_text)) + width = max_width + padding * 2 - height = len(self._HELP_SECTIONS) * header_height + n_lines * line_height + padding * 2 + height = ( + len(self._HELP_SECTIONS) * header_height + + (n_lines + 1) * line_height # +1: trailing "More…" link line + + padding * 2 + ) bg_rect = QRectF(18, max(18, self.viewport().height() - height - 18), width, height) self._help_hit_rect = bg_rect # click anywhere on the box to close @@ -1135,6 +1159,16 @@ 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. + 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 + ) + painter.restore() def _draw_overlay_legend(self, painter: QPainter): @@ -1354,7 +1388,13 @@ class SampleCameraImageLabel(QGraphicsView): def wheelEvent(self, event: QWheelEvent): if not self.wheel_event_timer.isActive(): - delta_y = event.angleDelta().y() + # Qt's xcb/windows platform plugins swap wheel axes while Alt is held, + # so a vertical scroll lands in angleDelta().x() and y() is 0. Without + # this fallback, copysign(_, 0) is always positive and Alt+wheel + # exposure can only ever increase. + delta_y = event.angleDelta().y() or event.angleDelta().x() + if delta_y == 0: + return match self._state: case SampleCameraImageState.BEAM_MARKING: if event.modifiers() & Qt.KeyboardModifier.AltModifier: diff --git a/src/aare/gui/widgets/motor_move_group.py b/src/aare/gui/widgets/motor_move_group.py new file mode 100644 index 00000000..2013d110 --- /dev/null +++ b/src/aare/gui/widgets/motor_move_group.py @@ -0,0 +1,132 @@ +"""Batch entry -> apply for motor value boxes. + +Why: typing into a motor box used to fire the move on Enter, one axis at a +time. Operators want to stage several targets, review them, then start all +motors with one click. The group tracks a per-box state machine: + + neutral (theme default) --user edit--> pending (orange) + pending --Move clicked--> moving (green) + moving --actual reaches target--> neutral + +Colors are QSS dynamic properties ("movestate" on the box), styled per theme +in styles.py — same pattern as NumberLineEdit's "invalid" property, so the +existing out-of-range red still wins while typing. + +Modular on purpose: any panel opts a NumberLineEdit in with add_box(); boxes +not registered keep their old behavior. +""" + +from PySide6.QtCore import QEvent, QObject, QPoint, Qt, Signal, Slot +from PySide6.QtWidgets import QPushButton, QToolTip + +from aare.gui.widgets.number_line_edit import NumberLineEdit + + +class MotorMoveGroup(QObject): + # {name: target} for every box that was pending when Move was clicked + applied = Signal(dict) + + def __init__(self, button_text: str = "Move", parent=None): + super().__init__(parent) + self.button = QPushButton(button_text) + self.button.setEnabled(False) + self.button.clicked.connect(self._apply) + self._boxes: dict[str, NumberLineEdit] = {} + self._tols: dict[str, float] = {} + self._state: dict[str, str] = {} # "" (neutral) | "pending" | "moving" + self._targets: dict[str, float] = {} + + def add_box(self, name: str, box: NumberLineEdit, tol: float = 0.1): + # tol is the "at target" window — hardware never lands exactly on the + # setpoint, so this stays a per-box knob (encoder resolution differs + # per motor). + self._boxes[name] = box + self._tols[name] = tol + self._state[name] = "" + box.textEdited.connect(lambda _text, n=name: self._on_edited(n)) + # Below-min can only be judged on Enter (typing "3" may become "30"), + # and the validator swallows editingFinished for out-of-range text — + # so catch the key directly. + box.installEventFilter(self) + + # -- panel-facing API ---------------------------------------------------- + @Slot(str, float) + def update_actual(self, name: str, value: float): + """Feed the motor's actual position. Neutral boxes track it (same as + the old direct update_value call); pending/moving boxes keep showing + the user's target until the move completes.""" + box = self._boxes[name] + state = self._state[name] + if state == "moving" and abs(value - self._targets[name]) <= self._tols[name]: + self._set_state(name, "") + state = "" + if state == "": + box.update_value(value) + + # -- internals ----------------------------------------------------------- + def _on_edited(self, name: str): + box = self._boxes[name] + try: + value = float(box.text()) + except ValueError: + # incomplete entry ("", "-", "1e"): stay/become pending, no tips + self._set_state(name, "pending") + return + top = box.range_validator.top() + if value > top: + QToolTip.showText( + box.mapToGlobal(QPoint(0, box.height())), + f"Maximum value: {box.to_string(top)}", + box, + ) + # editing back to the current position cancels the pending move + if abs(value - box.saved_value) <= self._tols[name]: + self._set_state(name, "") + else: + 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, + ): + 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 + return super().eventFilter(obj, event) + + @Slot() + def _apply(self): + targets = {} + for name, box in self._boxes.items(): + # out-of-range text stays pending (red via the invalid property); + # only valid targets are sent + if self._state[name] == "pending" and box.validate(box.text()): + targets[name] = box.value + self._targets[name] = box.value + self._set_state(name, "moving") + if targets: + self.applied.emit(targets) + # ponytail: no timeout — a move that never reaches target leaves the + # box green until the user re-edits it; add a watchdog if that bites. + + def _set_state(self, name: str, state: str): + box = self._boxes[name] + if box.property("movestate") != state: + box.setProperty("movestate", state) + # property selectors only re-evaluate on repolish + box.style().unpolish(box) + box.style().polish(box) + self._state[name] = state + self.button.setEnabled(any(s == "pending" for s in self._state.values())) diff --git a/src/aare/gui/widgets/number_line_edit.py b/src/aare/gui/widgets/number_line_edit.py index 98f22441..3a0de0db 100644 --- a/src/aare/gui/widgets/number_line_edit.py +++ b/src/aare/gui/widgets/number_line_edit.py @@ -17,12 +17,14 @@ class NumberLineEdit(QLineEdit): self._read_only: bool = False self._is_valid: bool = True - # Use a QDoubleValidator to only allow valid floating point numbers - self.validator = QDoubleValidator() - self.validator.setNotation(QDoubleValidator.Notation.StandardNotation) + # Use a QDoubleValidator to only allow valid floating point numbers. + # Named range_validator: plain "validator" would shadow + # QLineEdit.validator() and break type checking at call sites. + self.range_validator = QDoubleValidator() + self.range_validator.setNotation(QDoubleValidator.Notation.StandardNotation) self.decimal_count = decimals - self.validator.setRange(min_val, max_val, self.decimal_count) - self.setValidator(self.validator) + self.range_validator.setRange(min_val, max_val, self.decimal_count) + self.setValidator(self.range_validator) self.setAlignment(Qt.AlignmentFlag.AlignRight) self.setToolTip( f"Minimum: {self.to_string(min_val):s}\nMaximum: {self.to_string(max_val):s}" @@ -80,13 +82,13 @@ class NumberLineEdit(QLineEdit): @Slot(float, float) def update_limits(self, min_val: float, max_val: float): - self.validator.setRange(min_val, max_val, self.decimal_count) + self.range_validator.setRange(min_val, max_val, self.decimal_count) self.setToolTip( f"Minimum: {self.to_string(min_val):s}\nMaximum: {self.to_string(max_val):s}" ) def validate(self, text) -> bool: - return self.validator.validate(str(text), 0)[0] == QDoubleValidator.State.Acceptable + return self.range_validator.validate(str(text), 0)[0] == QDoubleValidator.State.Acceptable def setReadOnly(self, ro: bool): # Colors follow via the QSS :read-only pseudo-class (updates without diff --git a/src/aare/gui/widgets/video_image.py b/src/aare/gui/widgets/video_image.py index bfbb52af..263433a5 100644 --- a/src/aare/gui/widgets/video_image.py +++ b/src/aare/gui/widgets/video_image.py @@ -2,7 +2,7 @@ from PySide6.QtCore import QRectF, Qt, Slot from PySide6.QtGui import QImage, QPainter, QPixmap from PySide6.QtWidgets import QGraphicsPixmapItem, QGraphicsScene, QGraphicsView -from aare.gui.widgets.busy_overlay import BusyOverlayStyle, draw_busy_badge +from aare.gui.widgets.busy_overlay import BusyOverlayStyle, draw_busy_status_text class VideoGraphicsView(QGraphicsView): @@ -114,9 +114,9 @@ class VideoGraphicsView(QGraphicsView): painter.save() painter.resetTransform() painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) - # Shared renderer with the sample camera, so every view shows the - # identical badge (this view used to draw its own dot+text variant). - draw_busy_badge( + # Plain shadowed text like the sample camera's warnings, not the + # badge pill: these views are passive and the pill read as a button. + draw_busy_status_text( painter, self.viewport().width(), self.viewport().height(), self._busy_overlay_style ) painter.restore() diff --git a/tests/unit/gui/test_busy_overlay.py b/tests/unit/gui/test_busy_overlay.py new file mode 100644 index 00000000..2b83d71c --- /dev/null +++ b/tests/unit/gui/test_busy_overlay.py @@ -0,0 +1,36 @@ +"""The Axis views' busy flag is a passive status: plain shadowed text, not +the badge pill (which reads as a button). These checks fail if the text +renderer stops painting or the video view stops routing through it.""" + +from PySide6.QtCore import Qt +from PySide6.QtGui import QPainter, QPixmap + +from aare.gui.widgets.busy_overlay import build_busy_overlay_style, draw_busy_status_text +from aare.gui.widgets.video_image import VideoGraphicsView + + +def _busy_style(): + style = build_busy_overlay_style(is_busy=True, tell_state=None) + assert style is not None and style.text == "BEAMLINE BUSY" + return style + + +def test_status_text_paints_something(qapp): + pixmap = QPixmap(400, 300) + pixmap.fill(Qt.GlobalColor.black) + blank = pixmap.toImage() + painter = QPainter(pixmap) + draw_busy_status_text(painter, 400, 300, _busy_style()) + painter.end() + assert pixmap.toImage() != blank, "busy text must actually paint" + + +def test_video_view_paints_busy_text(qtbot): + view = VideoGraphicsView() + qtbot.addWidget(view) + view.resize(400, 300) + idle = view.grab().toImage() + view.set_busy_overlay_style(_busy_style()) + assert view.grab().toImage() != idle, "busy style must change the rendered view" + view.set_busy_overlay_style(None) + assert view.grab().toImage() == idle, "clearing the style must restore the view" diff --git a/tests/unit/gui/test_camera_image.py b/tests/unit/gui/test_camera_image.py index 3dd977f0..a5836b95 100644 --- a/tests/unit/gui/test_camera_image.py +++ b/tests/unit/gui/test_camera_image.py @@ -160,3 +160,52 @@ def test_vacant_badge_hover_click_and_theme(camera, qtbot): with qtbot.waitSignal(camera.session_badge_clicked, timeout=1000): qtbot.mouseClick(camera.viewport(), Qt.MouseButton.LeftButton, pos=badge.center()) + + +def test_help_more_link_opens_full_dialog(camera, qtbot): + camera.grab() # paint records the "?" badge hit rect + badge = camera._help_hit_rect + qtbot.mouseClick(camera.viewport(), Qt.MouseButton.LeftButton, pos=badge.center().toPoint()) + assert camera._help_expanded + camera.grab() # expanded overlay paint records the More... link rect + more = camera._help_more_rect + assert more is not None + + opened = [] + camera.open_full_help.connect(lambda: opened.append(True)) + qtbot.mouseClick(camera.viewport(), Qt.MouseButton.LeftButton, pos=more.center().toPoint()) + assert opened, "More... must open the full F1 dialog" + assert not camera._help_expanded # cheatsheet folds behind the dialog + + +def _wheel(widget, *, x=0, y=0, modifiers=Qt.KeyboardModifier.NoModifier): + from PySide6.QtGui import QWheelEvent + + return QWheelEvent( + QPointF(5, 5), + QPointF(widget.mapToGlobal(QPoint(5, 5))), + QPoint(0, 0), + QPoint(x, y), + Qt.MouseButton.NoButton, + modifiers, + Qt.ScrollPhase.NoScrollPhase, + False, + ) + + +def test_alt_wheel_axis_swap_still_changes_exposure(camera): + """xcb/windows swap wheel axes while Alt is held: the scroll lands in + angleDelta().x(). The fallback must still reach the exposure branch — + and a genuinely empty wheel event must do nothing.""" + camera.update_daq_status(_status(busy=False, session=SessionsStateEnum.OwnedByYou)) + sent = [] + camera.samcam_updated.connect(sent.append) + + camera.wheelEvent(_wheel(camera, x=-120, modifiers=Qt.KeyboardModifier.AltModifier)) + assert sent, "Alt+wheel with x-only delta must still adjust exposure" + assert sent[0].exposure < 0.02, "negative delta must decrease exposure" + + camera.wheel_event_timer.stop() # bypass the throttle for the second event + n = len(sent) + camera.wheelEvent(_wheel(camera)) # zero delta: ignored + assert len(sent) == n diff --git a/tests/unit/gui/test_gui_entry.py b/tests/unit/gui/test_gui_entry.py new file mode 100644 index 00000000..a29a0d14 --- /dev/null +++ b/tests/unit/gui/test_gui_entry.py @@ -0,0 +1,23 @@ +"""gui.main() startup path: the splash banner is a PNG again (the SVG +rendered wrong on RHEL9 consoles), so the resource block must resolve it — +and a failed startup must exit through the graceful error path, not crash.""" + +from unittest.mock import MagicMock, patch + +import pytest + + +def test_main_resolves_banner_and_exits_gracefully(qapp): + from aare.gui import gui + + with ( + # A second QApplication would abort; hand main() a stand-in instead. + patch.object(gui, "QApplication", return_value=MagicMock()), + # process() on the stand-in app exits the interpreter at C level + # ("argument list cannot be empty") — option defaults still apply. + patch.object(gui.QCommandLineParser, "process", lambda self, app: None), + patch.object(gui, "QMessageBox"), + patch.object(gui, "auth", side_effect=RuntimeError("no server")), + pytest.raises(SystemExit), + ): + gui.main() diff --git a/tests/unit/gui/test_illumination_panel.py b/tests/unit/gui/test_illumination_panel.py new file mode 100644 index 00000000..897509cb --- /dev/null +++ b/tests/unit/gui/test_illumination_panel.py @@ -0,0 +1,80 @@ +"""Send/readback wiring for the light sliders (was a __main__ self-check in +the panel; promoted here so CI counts it). Every deliberate input path must +emit, and the status-poll readback must neither clobber a fresh user change +nor echo a PUT back to the server.""" + +import time +from types import SimpleNamespace + +import pytest +from PySide6.QtCore import QPoint, QPointF, Qt +from PySide6.QtGui import QWheelEvent +from PySide6.QtWidgets import QApplication + +from aare.gui.panels.illumination_panel import READBACK_GRACE_S, IlluminationPanel + + +@pytest.fixture +def panel(qtbot): + panel = IlluminationPanel() + qtbot.addWidget(panel) + return panel + + +def _wheel_notch(slider): + # One wheel notch; sliderReleased never fires for this path, which is + # exactly the case the valueChanged wiring exists for. + QApplication.sendEvent( + slider, + QWheelEvent( + QPointF(5, 5), + QPointF(5, 5), + QPoint(0, 0), + QPoint(0, 120), + Qt.MouseButton.NoButton, + Qt.KeyboardModifier.NoModifier, + Qt.ScrollPhase.NoScrollPhase, + False, + ), + ) + + +def _status(front=90.0, back=10.0): + return SimpleNamespace(bl=SimpleNamespace(front_light=front, back_light=back)) + + +def test_wheel_notch_emits_both_lights(panel): + front, back = [], [] + panel.front_light.connect(front.append) + panel.back_light.connect(back.append) + _wheel_notch(panel.front_light_slider) + _wheel_notch(panel.back_light_slider) + assert front, "wheel adjustment must emit front_light" + assert back, "wheel adjustment must emit back_light" + + +def test_readback_grace_holds_user_value(panel): + _wheel_notch(panel.front_light_slider) + held = panel.front_light_slider.value() + panel.update_daq_status(_status()) + assert panel.front_light_slider.value() == held, "grace window must hold user value" + + +def test_readback_applies_after_grace_without_echo(panel): + sent = [] + panel.front_light.connect(sent.append) + panel._last_user_change = time.monotonic() - READBACK_GRACE_S - 1 + panel.update_daq_status(_status()) + assert panel.front_light_slider.value() == 90, "readback must apply after grace" + assert panel.back_light_slider.value() == 10 + assert not sent, "readback must not echo a PUT" + + +def test_readback_skipped_while_sliding(panel): + panel.on_slider_pressed() + panel._last_user_change = 0.0 + panel.update_daq_status(_status(front=55.0)) + assert panel.front_light_slider.value() != 55 + panel.on_slider_released() + panel.update_daq_status(_status(front=55.0)) + assert panel.front_light_slider.value() == 55 diff --git a/tests/unit/gui/test_main_window.py b/tests/unit/gui/test_main_window.py index 8a0c5046..c1cd7040 100644 --- a/tests/unit/gui/test_main_window.py +++ b/tests/unit/gui/test_main_window.py @@ -1,7 +1,7 @@ from unittest.mock import MagicMock, patch import pytest -from PySide6.QtCore import QSettings +from PySide6.QtCore import QSettings, Qt from PySide6.QtWidgets import QDockWidget from aare.gui.main_window import MainWindow @@ -50,6 +50,27 @@ def test_main_window_init(qtbot, mock_ui_state): assert win.windowTitle() == "AareGUI" assert win.isVisible() + # Staff-side gate behaviors piggyback on this window: every extra + # MainWindow construction raises the odds of the PySide SystemError + # flake, so don't build another one just for these. + from aarecommon.models.models import BeamlineStateEnum + + with patch("aare.gui.main_window.QMessageBox") as popup: + win._show_beamline_tab_gated_popup() + assert popup.information.called, "staff popup must explain the visitor pgroup" + + win._apply_default_sample_tab(BeamlineStateEnum.BeamLocation) + assert win.sample_lists_tabs.currentIndex() == 1 # Auxiliary puck + win._apply_default_sample_tab(BeamlineStateEnum.DataCollection) + assert win.sample_lists_tabs.currentIndex() == 0 + + # Energy row inside Exp. Config. emits eV (the GUI shows keV) + sent = [] + win.data_collection.change_energy.connect(sent.append) + win.data_collection.energy_spin.setValue(12.4) + win.data_collection._emit_change_energy() + assert sent and abs(sent[0] - 12400.0) < 1e-6 + def test_main_window_mount_view(qtbot, mock_ui_state): with ( @@ -213,6 +234,33 @@ def test_automation_activity_refreshes_idle_timestamp(qtbot, mock_ui_state): assert win._last_user_interaction_ts == 250.0 + # Remote-close bookkeeping in the same status loop: an unreadable + # session entry is skipped, a close request for THIS session starts + # the countdown, and its disappearance clears it again. + from types import SimpleNamespace + + unreadable = status.model_copy(update={"open_guis": [SimpleNamespace(session="bad")]}) + win.update_daq_status(unreadable) + assert win._remote_close_deadline_ts is None + + close_req = status.model_copy( + update={ + "open_guis": [ + SimpleNamespace( + session=15, + close_requested=True, + close_requested_by="ops", + close_grace_seconds=5, + ) + ] + } + ) + win.update_daq_status(close_req) + assert win._remote_close_deadline_ts is not None + + win.update_daq_status(status) + assert win._remote_close_deadline_ts is None + def test_idle_timeout_closes_when_inactive_and_not_running(qtbot, mock_ui_state): with ( @@ -484,3 +532,48 @@ def test_close_restores_pre_watch_layout(qtbot, mock_ui_state): # closeEvent put the pre-watch layout back before saving state, so # the all-hidden fold was not persisted. assert not win.tell_samples_dock.isHidden() + + +def test_nonstaff_beamline_gate_popups(qtbot, mock_ui_state): + """Non-staff must SEE the staff-only Beamline panels (as locked banners) + and get an explanation on click — both on a banner and on the + pgroup-gated (disabled) Beamline tab, which used to eat clicks silently.""" + with ( + patch("requests.get"), + patch("aare.gui.main_window.DAQWorker"), + patch("aare.gui.main_window.PredictionSubscriber"), + patch("aare.gui.main_window.VideoThread"), + patch("aare.gui.main_window.JFJochDBusClient"), + patch("aare.gui.main_window.jwt.decode") as mock_jwt, + ): + mock_jwt.return_value = { + "sub": "visitor", + "staff": False, + "pgroups": ["p123"], + "session": 15, + } + win = _make_window(qtbot) + + # Locked stand-ins exist in place of the real staff panels + titles = [b.text() for b in win._locked_beamline_banners] + assert titles == ["Beamline setup", "ABR meas. pos.", "Beam configuration"] + assert not hasattr(win, "monochromator_panel") + + with patch("aare.gui.main_window.QMessageBox") as popup: + qtbot.mousePress(win._locked_beamline_banners[0], Qt.MouseButton.LeftButton) + assert popup.information.called, "banner click must explain the staff gate" + + # Active pgroup outside the token disables the whole Beamline tab; + # a click on the disabled tab must explain itself, not vanish. + win._apply_pgroup_gate("p999") + assert not win.left_column_tabs.isTabEnabled(0) + bar = win.left_column_tabs.tabBar() + with patch("aare.gui.main_window.QMessageBox") as popup: + qtbot.mousePress(bar, Qt.MouseButton.LeftButton, pos=bar.tabRect(0).center()) + assert popup.information.called, "gated tab click must explain the gate" + + # The greyed-out Auxiliary-puck tab explains itself the same way. + aux_bar = win.sample_lists_tabs.tabBar() + with patch("aare.gui.main_window.QMessageBox") as popup: + qtbot.mousePress(aux_bar, Qt.MouseButton.LeftButton, pos=aux_bar.tabRect(1).center()) + assert popup.information.called, "aux-puck tab click must explain the lock" diff --git a/tests/unit/gui/test_motor_move_group.py b/tests/unit/gui/test_motor_move_group.py new file mode 100644 index 00000000..0eee1b70 --- /dev/null +++ b/tests/unit/gui/test_motor_move_group.py @@ -0,0 +1,94 @@ +"""MotorMoveGroup is motor protection UX: typing must never start a move — +targets are staged (orange), sent only by the Move button (green), and the +box returns to neutral when the motor actually arrives.""" + +import pytest +from PySide6.QtCore import Qt + +from aare.gui.widgets.motor_move_group import MotorMoveGroup +from aare.gui.widgets.number_line_edit import NumberLineEdit + + +@pytest.fixture +def chi(qtbot): + box = NumberLineEdit(-0.2, 40, decimals=1) + qtbot.addWidget(box) + group = MotorMoveGroup() + group.add_box("chi", box) + return group, box + + +def _type(qtbot, box, text): + box.clear() + qtbot.keyClicks(box, text) + + +def test_stage_apply_settle(chi, qtbot): + group, box = chi + sent = [] + group.applied.connect(sent.append) + + _type(qtbot, box, "25.0") + assert box.property("movestate") == "pending" + assert group.button.isEnabled() + assert not sent # typing (even Enter) must not move the motor + + group.button.click() + assert sent == [{"chi": 25.0}] + assert box.property("movestate") == "moving" + assert not group.button.isEnabled() + + group.update_actual("chi", 10.0) # still travelling + assert box.property("movestate") == "moving" + assert box.value == 25.0 # box keeps showing the target + + group.update_actual("chi", 25.05) # within tol -> arrived + assert box.property("movestate") == "" + group.update_actual("chi", 3.0) # neutral boxes track the actual again + assert box.value == 3.0 + + +def test_edit_back_to_current_cancels_pending(chi, qtbot): + group, box = chi + _type(qtbot, box, "5.0") + assert group.button.isEnabled() + _type(qtbot, box, "0.0") # back to the actual position + assert box.property("movestate") == "" + assert not group.button.isEnabled() + + +def test_over_max_is_invalid_and_never_sent(chi, qtbot): + group, box = chi + sent = [] + group.applied.connect(sent.append) + _type(qtbot, box, "500") + assert box.property("invalid") is True # red via existing validator path + group.button.click() + assert not sent # out-of-range target is never applied + assert box.property("movestate") == "pending" + + +def test_incomplete_entry_stays_pending(chi, qtbot): + _group, box = chi + _type(qtbot, box, "-") # not a number (yet): no tips, no crash + assert box.property("movestate") == "pending" + # Enter on the incomplete text must be swallowed just as quietly + qtbot.keyClick(box, Qt.Key.Key_Return) + assert box.property("movestate") == "pending" + + +def test_below_min_enter_hints_but_never_sends(chi, qtbot): + group, box = chi + sent = [] + group.applied.connect(sent.append) + _type(qtbot, box, "-5") # below the -0.2 bottom + qtbot.keyClick(box, Qt.Key.Key_Return) # triggers the min-value tooltip path + assert not sent # Enter must never start a move + + +def test_update_limits_reranges_box(chi, qtbot): + _group, box = chi + box.update_limits(-1.0, 50.0) + assert box.range_validator.bottom() == -1.0 + assert box.range_validator.top() == 50.0 + assert "50" in box.toolTip() diff --git a/tests/unit/gui/test_panels.py b/tests/unit/gui/test_panels.py index d6e1113f..c4463105 100644 --- a/tests/unit/gui/test_panels.py +++ b/tests/unit/gui/test_panels.py @@ -87,3 +87,21 @@ def test_status_panel_low_current(qtbot, mock_daq_status): assert f"color: {STATUS_ALERT}" in panel.ring_current.text() assert "300.0" in panel.ring_current.text() + + +def test_smargon_staged_axes_move_and_actual_tracking(qtbot, mock_daq_status): + """_move_axes sends only the staged axes (absent keys stay None = no + motion) and the status tick feeds actuals back into the move group.""" + from aare.gui.panels.smargon_panel import SmargonPanel + + panel = SmargonPanel() + qtbot.addWidget(panel) + sent = [] + panel.smargon.connect(sent.append) + + panel._move_axes({"chi": 2.0}) + assert sent and sent[0].chi_deg == 2.0 + assert sent[0].phi_deg is None # phi was not staged: must not move + + panel.update_daq_status(mock_daq_status) + assert panel._geom is mock_daq_status.geom diff --git a/tests/unit/gui/test_prediction_subscriber.py b/tests/unit/gui/test_prediction_subscriber.py index e69de29b..c57552fb 100644 --- a/tests/unit/gui/test_prediction_subscriber.py +++ b/tests/unit/gui/test_prediction_subscriber.py @@ -0,0 +1,27 @@ +"""Checks the binary-frame sniff in _try_parse_json: JPEG frames (0xff lead +byte) must be rejected without attempting utf-8 decode (previously raised +UnicodeDecodeError on every frame).""" + +from typing import cast + +from aare.gui.threads.prediction_subscriber import PredictionSubscriber + + +def _parse(part: bytes): + # Called unbound: _try_parse_json touches no instance state, so no + # QThread/zmq construction is needed. cast keeps basedpyright happy + # about the stand-in self. + return PredictionSubscriber._try_parse_json(cast(PredictionSubscriber, object()), part) + + +def test_binary_jpeg_frame_is_not_json(): + assert _parse(b"\xff\xd8\xff\xe0somejpegbytes") is None + + +def test_json_dict_is_parsed(): + assert _parse(b'{"encoding": "jpeg"}') == {"encoding": "jpeg"} + + +def test_json_non_dict_is_rejected(): + assert _parse(b"[1, 2]") is None + assert _parse(b"") is None diff --git a/uv.lock b/uv.lock index 9a39da31..72a73a21 100644 --- a/uv.lock +++ b/uv.lock @@ -31,7 +31,7 @@ wheels = [ [[package]] name = "aaredaq" -version = "0.8.1" +version = "0.8.3" source = { editable = "." } dependencies = [ { name = "aarecommon" },