diff --git a/CHANGELOG.md b/CHANGELOG.md index dffa2378..d9ded00d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -473,8 +473,8 @@ Co-Authored-By: Claude Fable 5 - Add Change Energy row to experiment configuration ([`c618354`](https://gitea.psi.ch/mx/AareDAQ/commit/c618354c0146e6a071873507aad4d085c71e344f)) -Copy of the Beamline Setup energy row (keV display, eV emit), placed below the ML Loop Centring / - Make Raster Grid row and always visible. Wired to the same daq.change_energy; not staff-gated, +Copy of the Beamline Setup energy row (keV display, eV emit), placed below the Auto Centering / + Draw a Grid row and always visible. Wired to the same daq.change_energy; not staff-gated, server enforces write permission. Co-Authored-By: Claude Fable 5 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..e48f2f9e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,31 @@ +# AareDAQ project notes + +## CI gates — run locally BEFORE every commit/push + +CI enforces more than `ruff check` + green tests. Two extra gates diff +against `origin/main`, so they fail on changed lines that a plain lint or +full-suite pass never inspects (`git fetch origin main` first): + +1. `uv run ruff check` and `uv run ruff format --check` +2. Typecheck the diff (part of the lint job; ONE new basedpyright + violation on a changed line fails CI): + `uv run diff-quality --violations=basedpyright --fail-under=100 --compare-branch=origin/main` +3. Diff coverage >= 80% (coverage-analysis job): + `QT_QPA_PLATFORM=offscreen uv run pytest --cov=aare --cov-config=./pyproject.toml --cov-branch --cov-report=xml --no-cov-on-fail ./tests/unit` + `uv run diff-cover coverage.xml --compare-branch=origin/main --fail-under=80` + +A pre-push hook in the shared `.git/hooks` runs all of the above; bypass +only deliberately with `git push --no-verify`. + +Gotchas learned the hard way: +- The `diff-quality` basedpyright plugin is an editable install from + `~/repos/aare_suite/diff_quality_basedpyright` (gitea + mx/diff_quality_basedpyright). On ModuleNotFoundError reinstall with + `uv pip install -e ~/repos/aare_suite/diff_quality_basedpyright`. +- basedpyright rejects lazily created instance attributes — initialize in + `__init__`, no `hasattr` patterns. +- Every new branch needs a test that executes it or diff coverage sinks. +- Run GUI tests with `QT_QPA_PLATFORM=offscreen`; a blocking popup (e.g. a + real `QMenu.exec()`) otherwise freezes the suite on macOS. PySide method + lookup ignores class-attribute monkeypatches — patch by swapping in a + Python subclass instead. diff --git a/src/aare/daq/auth.py b/src/aare/daq/auth.py index f7ca9696..aeadb149 100644 --- a/src/aare/daq/auth.py +++ b/src/aare/daq/auth.py @@ -29,7 +29,7 @@ ACCESS_TOKEN_EXPIRE_MINUTES = 24 * 60 * 7 # 1 week SESSION_EXPIRE_SECONDS = 60 * 10 BATON_REQUEST_TIMEOUT_SECONDS = 30 -STAFF_GROUP = "unx-MXgroup" +STAFF_GROUP = "unx-sls_mx_bs" # unx-mxgroup no longer exists SUPER_USERS = ["e10019", "e11206", "e18747"] oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") diff --git a/src/aare/daq/server.py b/src/aare/daq/server.py index b2085a5b..8cf15fda 100644 --- a/src/aare/daq/server.py +++ b/src/aare/daq/server.py @@ -533,7 +533,7 @@ async def change_energy(value: float, plot: bool = False, token: str = Depends(o "OK" on success. """ logger.debug(f"Changing energy to {value} (plot={plot})") - auth.check_jwt_staff(cfg, auth.parse_token(token)) + auth.parse_token(token) # non-staff should be able to change_energy daq.change_energy(value=value, plot=plot) return "OK" diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 02b97b5c..cb1688b7 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -55,6 +55,7 @@ from PySide6.QtWidgets import ( QStackedWidget, QTabWidget, QToolBar, + QToolTip, QVBoxLayout, QWidget, ) @@ -102,6 +103,7 @@ from aare.gui.styles import ( THEME_FADE_MS, THEME_SUNRISE, THEME_SUNSET, + admin_tip_qss, build_app_stylesheet, qcolor, ) @@ -349,7 +351,14 @@ class MainWindow(QMainWindow): # The beamline state strip lives in a bottom toolbar row (created # after the docks), not in the left column. Always visible. - self.beamline_state_panel = BeamlineStatePanel(parent=self) + self.beamline_state_panel = BeamlineStatePanel(staff=self._decoded_token.staff, parent=self) + + # Anchor for _show_admin_tip: QToolTip inherits QSS from the widget + # it is shown for, so this hidden label carries the red warning wash + # without reddening any other tooltip. Eager, not lazy — basedpyright + # requires instance attributes to exist after __init__. + self._admin_tip_anchor = QLabel(self) + self._admin_tip_anchor.hide() # Beamline / Experiment as tabs (like the Dewar samples dock) instead # of two stacked banner groups; the pages keep their banner children. @@ -1473,20 +1482,23 @@ class MainWindow(QMainWindow): row.addWidget(clone) return row + def _show_admin_tip(self, message: str) -> None: + # "Not admin" denials are passive red tips, not QMessageBoxes: + # nothing to click away. Styled at show time so it always matches + # the theme. + self._admin_tip_anchor.setStyleSheet(admin_tip_qss(self._theme_mode)) + QToolTip.showText(QCursor.pos(), message, self._admin_tip_anchor) + def _show_reference_tools_staff_only_popup(self) -> None: - QMessageBox.information( - self, - "Staff only", - "The Auxiliary puck (reference tools) view is available to staff accounts only.", + self._show_admin_tip( + "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", + self._show_admin_tip( "Beamline configuration is available to staff accounts only.\n" "Log in with a staff account or contact your local contact " - "to configure the beamline.", + "to configure the beamline." ) def _show_beamline_tab_gated_popup(self) -> None: @@ -2783,15 +2795,13 @@ class MainWindow(QMainWindow): if self._remote_close_deadline_ts is not None: self._clear_remote_close_request() - # 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. 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 + # Only robot-scale motion flips to the Beamline combined view: state + # transitions (the server parks in Moving while driving motors) and + # the robot station. Busy alone no longer triggers it — Sample + # alignment, Beam location, Beamstop alignment, Flux measurement + # etc. run busy while the user watches the sample camera itself. + # Edge-triggered so a manual tab choice survives between transitions. + moving = s.state in (BeamlineStateEnum.Moving, BeamlineStateEnum.RobotSampleExchange) if moving and not self._watching_motion: self._watching_motion = True self.video_tab.setCurrentWidget(self.beamline_combined_panel) diff --git a/src/aare/gui/panels/beamline_state_panel.py b/src/aare/gui/panels/beamline_state_panel.py index 5e0493fa..3401efaa 100644 --- a/src/aare/gui/panels/beamline_state_panel.py +++ b/src/aare/gui/panels/beamline_state_panel.py @@ -14,7 +14,7 @@ from PySide6.QtWidgets import ( QToolTip, ) -from aare.gui.styles import FONT_VALUE, THEME_SUNRISE, state_colors +from aare.gui.styles import FONT_VALUE, THEME_SUNRISE, admin_tip_qss, state_colors # Shortcut transitions from the "Available transitions" menu in # widgets/status_bar.py show_state_menu — these come ON TOP of the one-hop @@ -121,6 +121,16 @@ class BeamlineStatePanel(QFrame): (BeamlineStateEnum.XrayFluorescence, "X-ray fluorescence"), ) + # Beam-optics diagnostic states are admin (staff) only: greyed for + # everyone else, with a red warning tip instead of the routes hint. + _STAFF_ONLY_STATES: ClassVar[frozenset[BeamlineStateEnum]] = frozenset( + { + BeamlineStateEnum.BeamLocation, + BeamlineStateEnum.BeamstopAlignment, + BeamlineStateEnum.FluxMeasurement, + } + ) + _TOOLTIPS: ClassVar[dict[BeamlineStateEnum, str]] = { BeamlineStateEnum.DewarTransfer: "Dewar transfer mode", BeamlineStateEnum.SampleExchange: "Manual sample exchange mode", @@ -134,10 +144,12 @@ class BeamlineStatePanel(QFrame): BeamlineStateEnum.XrayFluorescence: "X-ray fluorescence mode", } - def __init__(self, parent=None): + def __init__(self, staff: bool = False, parent=None): super().__init__(parent) self.setObjectName("beamlineStatePanel") + # Fail-closed: callers must opt in to the staff-only states. + self._staff = staff self._current_state: BeamlineStateEnum | None = None self._hovered_state: BeamlineStateEnum | None = None self._pending_target_state: BeamlineStateEnum | None = None @@ -152,6 +164,7 @@ class BeamlineStatePanel(QFrame): self._hover_hint_timer.timeout.connect(self._show_hover_hint) # Per-theme colors (MainWindow._apply_theme calls set_theme). + self._theme = THEME_SUNRISE self._colors = state_colors(THEME_SUNRISE) self._separators: list[QLabel] = [] @@ -255,7 +268,8 @@ class BeamlineStatePanel(QFrame): return frozenset() # Reachable in one step: the route graph plus the status-bar # shortcut transitions. - return frozenset(_GRAPH.get(current, set())) | MENU_TRANSITIONS.get(current, frozenset()) + targets = frozenset(_GRAPH.get(current, set())) | MENU_TRANSITIONS.get(current, frozenset()) + return targets if self._staff else targets - self._STAFF_ONLY_STATES def _set_hovered_state(self, state: BeamlineStateEnum | None) -> None: self._hovered_state = state @@ -274,6 +288,17 @@ class BeamlineStatePanel(QFrame): self._show_unavailable_hint(state) def _show_unavailable_hint(self, state: BeamlineStateEnum) -> None: + if not self._staff and state in self._STAFF_ONLY_STATES: + # Not a routes problem: the state is admin-gated. Red warning + # tip (QSS appended in _apply_highlight), no dialog to close. + button = self._buttons[state] + QToolTip.showText( + QCursor.pos(), + f"{state.display_name()} requires admin mode (staff accounts only).", + button, + button.rect(), + ) + return sources = set(_GRAPH.get(state, set())) sources |= {s for s, targets in MENU_TRANSITIONS.items() if state in targets} sources.discard(state) @@ -327,6 +352,7 @@ class BeamlineStatePanel(QFrame): """Adopt the theme's state colors (MainWindow._apply_theme calls this on every switch — the colors are painted in code, so the app QSS alone cannot restyle them).""" + self._theme = theme self._colors = state_colors(theme) for separator in self._separators: self._style_separator(separator) @@ -405,6 +431,11 @@ class BeamlineStatePanel(QFrame): f" padding: 1px 8px; }}" f" QPushButton:hover {{ color: {color};{hover_underline} }}" ) + # QToolTip inherits QSS from its widget: red-wash only the + # admin-gated buttons' tips, so the current-state tip (a staff + # user may have parked the beamline here) stays normal. + if not self._staff and state in self._STAFF_ONLY_STATES and not is_current: + qss += " " + admin_tip_qss(self._theme) if button.styleSheet() != qss: button.setStyleSheet(qss) diff --git a/src/aare/gui/panels/data_collection_settings.py b/src/aare/gui/panels/data_collection_settings.py index 167d53ce..cf9ad649 100644 --- a/src/aare/gui/panels/data_collection_settings.py +++ b/src/aare/gui/panels/data_collection_settings.py @@ -3,6 +3,7 @@ from aarecommon.math.sample_geometry import SampleGeometryModel from aarecommon.models.models import DAQStatusModel from PySide6.QtCore import Signal, Slot from PySide6.QtWidgets import ( + QCheckBox, QDoubleSpinBox, QFrame, QHBoxLayout, @@ -55,9 +56,24 @@ class DataCollectionSettings(QFrame): self.manual_sample_panel = ManualSamplePanel(self) v_layout.addWidget(self.manual_sample_panel) - # QTabBar + QStackedWidget instead of QTabWidget: the loop-centering - # button row must sit BETWEEN the tab bar and the pages, which a - # QTabWidget cannot host. + # Auto Centering applies to every scan type, so it lives outside the + # experiment tabs, between Manual sample and Exp. Config. The checkbox + # arms an automatic run after each sample mount (see update_daq_status). + self.find_tip = QPushButton("Auto Centering", parent=self) + self.auto_center_after_mount = QCheckBox("apply after mounting", parent=self) + self.auto_center_after_mount.setToolTip( + "Run Auto Centering automatically after each sample mount" + ) + auto_center_row = QWidget(self) + auto_center_layout = QHBoxLayout(auto_center_row) + auto_center_layout.setContentsMargins(0, 0, 0, 0) + auto_center_layout.addWidget(self.find_tip, 1) + auto_center_layout.addWidget(self.auto_center_after_mount) + v_layout.addWidget(auto_center_row) + + # QTabBar + QStackedWidget instead of QTabWidget: the grid button + # must sit BETWEEN the tab bar and the pages, which a QTabWidget + # cannot host. self._tab_bar = QTabBar(self) self._stack = QStackedWidget(self) @@ -76,14 +92,9 @@ class DataCollectionSettings(QFrame): self._stack.addWidget(panel) self._tab_bar.addTab(label) - # Ex-"Loop centering" panel buttons; always visible, whatever the tab. - self.find_tip = QPushButton("ML Loop Centring", parent=self) - self.bounding_box = QPushButton("Make Raster Grid", parent=self) - centering_row = QWidget(self) - centering_layout = QHBoxLayout(centering_row) - centering_layout.setContentsMargins(0, 0, 0, 0) - centering_layout.addWidget(self.find_tip) - centering_layout.addWidget(self.bounding_box) + # Only meaningful for raster scans; hidden on the other tabs + # (_on_tab_changed). + self.bounding_box = QPushButton("Draw a Grid", parent=self) # Live readout mirrored from the Beamline setup panel, same reason: # "what is" and "what to set" must not share one ambiguous row. @@ -120,7 +131,7 @@ class DataCollectionSettings(QFrame): # No bottom padding: the pages' own bottom margins breathe inside the # border, and the Abort button should hug the pane. pane_layout.setContentsMargins(6, 6, 6, 0) - pane_layout.addWidget(centering_row) + pane_layout.addWidget(self.bounding_box) pane_layout.addWidget(current_energy_row) pane_layout.addWidget(energy_row) pane_layout.addWidget(self._stack) @@ -163,6 +174,10 @@ class DataCollectionSettings(QFrame): self.file_path_panel.path_updated.connect(self.screening.update_filename) self.file_path_panel.path_updated.connect(self.simple.update_filename) self._sample_id = None + # False until the first DAQ status: a sample already mounted at GUI + # startup must not trigger an auto-centering (hardware would move + # uninvited on every restart). + self._status_seen = False self._tab_bar.currentChanged.connect(self._stack.setCurrentIndex) self._tab_bar.currentChanged.connect(self._on_tab_changed) @@ -208,12 +223,27 @@ class DataCollectionSettings(QFrame): self.raster.update_daq_status(s) self.screening.update_daq_status(s) self.simple.update_daq_status(s) - if s.sample is not None and s.sample.db_id != self._sample_id: - self._sample_id = s.sample.db_id + self._track_sample(None if s.sample is None else s.sample.db_id) + + def _track_sample(self, db_id: int | None): + # Separate from update_daq_status so tests can drive mount detection + # without building a full DAQStatusModel. + if db_id is None: + # Forget on unmount so remounting the same sample counts as a + # fresh mount below. + self._sample_id = None + elif db_id != self._sample_id: + self._sample_id = db_id self._tab_bar.setCurrentIndex(0) + if self._status_seen and self.auto_center_after_mount.isChecked(): + # Reuse the button wiring (clicked -> daq.center_loop in + # main_window) instead of a second signal path. + self.find_tip.click() + self._status_seen = True @Slot(int) def _on_tab_changed(self, idx: int): + self.bounding_box.setVisible(idx == 0) # 0: Raster, 1: Rotation (incl. screening), 2: Simple (rotation wrapper), 3: XRF (ignore) kind = "rotation" if idx == 0: diff --git a/src/aare/gui/styles.py b/src/aare/gui/styles.py index 1f35f723..7d94a09d 100644 --- a/src/aare/gui/styles.py +++ b/src/aare/gui/styles.py @@ -199,6 +199,10 @@ DARK_PANEL2 = "#0e1728" # panel2 — deepest opaque: menus, popups, tooltips DARK_SURFACE = "#1c2b4a" # panel — cards, state panel, scrollbar track DARK_ELEVATED = "#253148" # glass2 (7% white) flattened — buttons, banners DARK_ELEVATED_HOVER = "#32405c" # one glass step lighter — button hover +# Half the button glass (7% -> 4% white), translucent so the sky shows +# through: inputs sank to wells, buttons stay raised faces — same +# button-vs-input split the light theme makes with INPUT_BG vs BUTTON_BG. +DARK_INPUT_BG = "rgba(255, 255, 255, 4%)" # Solid, not transparent: scroll-area viewports don't composite the window # gradient on the container's X11 and render BLACK instead. Lighter sky-navy # so tables don't read near-black against the backdrop. @@ -344,7 +348,9 @@ SPLASH_TEXT = "#4c4f69" # latte text — bar % and loading message # -- Numeric inputs --------------------------------------------------------- # Translucent, not solid white: the sky gradient shimmers through the field # while text stays on a light ground. Raise the % for a more solid face. -INPUT_BG = "rgba(255, 255, 255, 33%)" +# Why 16%: half of BUTTON_BG's 50% — at 33% inputs read as clickable button +# faces; the deeper glass keeps them visually distinct as wells, not buttons. +INPUT_BG = "rgba(255, 255, 255, 16%)" INPUT_INVALID_BG = "#e9c4cf" # red 20% over latte base INPUT_DISABLED_BG = "#e6e9ef" # latte mantle INPUT_DISABLED_INVALID_BG = "#ecdae2" # faint red wash @@ -460,6 +466,7 @@ PATH_END = "#d20f39" # raster path gradient end + end circle (red) LEGEND_BG = "#eff1f5" # base LEGEND_TEXT = "#4c4f69" # text TOOLTIP_TEXT = "#4c4f69" # camera coords tooltip pen — NOT the QToolTip popup +SCALE_BAR_GREY = "#8c8fa1" # hover HUD scale bar (Latte overlay1 grey) MARK_TOOLTIP_GOLD = "#df8e1d" # yellow MARK_TOOLTIP_ORANGE = "#fe640b" # peach MARK_TOOLTIP_RED = "#d20f39" # red @@ -559,6 +566,21 @@ TOOLTIP_FG = "#263043" DARK_TOOLTIP_BG = "#0e1728" # dusk panel2 — deepest opaque (menus/tooltips) DARK_TOOLTIP_FG = "#e9edf4" # dusk text + +def admin_tip_qss(theme: str) -> str: + """QToolTip rule for admin-only warning tips. Set it on the widget the + tip is shown for, never app-wide — QToolTip inherits QSS from that + widget, and an app-wide rule would turn every tooltip red.""" + bg, fg = ( + (DARK_TOOLTIP_BG, DARK_TOOLTIP_FG) if theme == THEME_SUNSET else (TOOLTIP_BG, TOOLTIP_FG) + ) + # Transparent border required, same as the app-wide QToolTip rule. + return ( + f"QToolTip {{ background-color: {bg}; color: {fg};" + f" border: 1px solid transparent; padding: 4px 6px; }}" + ) + + # -- Sliders ---------------------------------------------------------------- # Own knob instead of PRIMARY: full-saturation button blue was too loud for a # passive fill (illumination panel). Muted slate-blue, tweak freely. @@ -1313,8 +1335,7 @@ def _sunset_stylesheet() -> str: /* Interactive faces sit one step above the backdrop (site: glass2) with the faint gold hairline. Same pinned height as the light sheet so buttons and entry boxes match in both themes. */ - QPushButton, QToolButton, QComboBox, - QLineEdit, QAbstractSpinBox { + QPushButton, QToolButton, QComboBox { background-color: $dark_elevated; border: 1px solid $dark_border_faint; min-height: 16px; @@ -1323,6 +1344,18 @@ def _sunset_stylesheet() -> str: padding-bottom: 1px; } + /* Inputs: half the button glass (DARK_INPUT_BG) — a shared fill made + entry boxes indistinguishable from clickable buttons. Same box + metrics as above so the two still line up in rows. */ + QLineEdit, QAbstractSpinBox { + background-color: $dark_input_bg; + border: 1px solid $dark_border_faint; + min-height: 16px; + max-height: 16px; + padding-top: 1px; + padding-bottom: 1px; + } + /* Hover lightens in the dark theme (one glass step up); the light theme darkens instead — direction always moves toward contrast. */ QPushButton:hover, QToolButton:hover, QComboBox:hover { diff --git a/src/aare/gui/widgets/baton_request_dialog.py b/src/aare/gui/widgets/baton_request_dialog.py index 4943bab9..1d0136b8 100644 --- a/src/aare/gui/widgets/baton_request_dialog.py +++ b/src/aare/gui/widgets/baton_request_dialog.py @@ -123,7 +123,7 @@ class BatonRequestDialog(QDialog): button_layout.setSpacing(20) self.accept_btn = QPushButton("✓ Accept") - self.accept_btn.setMinimumHeight(40) + self.accept_btn.setMinimumHeight(88) self.accept_btn.setStyleSheet(f""" QPushButton {{ background-color: {BATON_OK_BG}; @@ -144,7 +144,7 @@ class BatonRequestDialog(QDialog): button_layout.addWidget(self.accept_btn) self.refuse_btn = QPushButton("✗ Refuse") - self.refuse_btn.setMinimumHeight(40) + self.refuse_btn.setMinimumHeight(88) self.refuse_btn.setStyleSheet(f""" QPushButton {{ background-color: {BATON_DANGER_BG}; @@ -304,7 +304,7 @@ class BatonPendingDialog(QDialog): button_layout = QHBoxLayout() self.cancel_btn = QPushButton("✗ Cancel Request") - self.cancel_btn.setMinimumHeight(40) + self.cancel_btn.setMinimumHeight(88) self.cancel_btn.setStyleSheet(f""" QPushButton {{ background-color: {BATON_DANGER_BG}; diff --git a/src/aare/gui/widgets/camera_image.py b/src/aare/gui/widgets/camera_image.py index b8e4171d..c13ff300 100644 --- a/src/aare/gui/widgets/camera_image.py +++ b/src/aare/gui/widgets/camera_image.py @@ -56,6 +56,7 @@ from aare.gui.styles import ( MARKER_GREEN, PATH_END, PATH_START, + SCALE_BAR_GREY, SHADOW, TARGET_COLORS, THEME_SUNSET, @@ -207,6 +208,9 @@ class SampleCameraImageLabel(QGraphicsView): self._detections = [] # list of dicts from publisher self._det_shape = None # shape from payload [h,w] so we can scale + # Scene position under the cursor; drives the corner coords/scale HUD. + self._hover_pos: QPointF | None = None + def _update_camera_interaction_feedback(self) -> None: if self._camera_available: self.viewport().setCursor(Qt.CursorShape.ArrowCursor) @@ -482,6 +486,7 @@ class SampleCameraImageLabel(QGraphicsView): self._draw_detections(painter, rect) self._draw_target_point(painter) self._draw_overlay_legend(painter) + self._draw_hover_hud(painter) self._draw_help_overlay(painter) def resizeEvent(self, event): @@ -577,9 +582,35 @@ class SampleCameraImageLabel(QGraphicsView): if self._session_badge_hovered: self._session_badge_hovered = False self.update() + if self._hover_pos is not None: + self._hover_pos = None + self.update() + if self._help_expanded: + # Pointer left the widget with the cheatsheet open — fold it, + # same as wandering off the box below. + self._help_expanded = False + self.update() super().leaveEvent(event) def mouseMoveEvent(self, event): + # Hover HUD before the interaction gate: watching is free, so the + # coords/scale readout must work even without the session baton. + self._hover_pos = self.mapToScene(event.pos()) + self.update() + + # Expanded help folds when the pointer wanders off the box — click + # is not the only way back to the "?" badge. Before the gate: the + # badge is a pure UI affordance, live even in viewing mode. + if ( + self._help_expanded + and self._help_hit_rect is not None + and not self._help_hit_rect.contains( + QPointF(self.viewport().mapFrom(self, event.pos())) + ) + ): + self._help_expanded = False + self.update() + # Badge hover feedback must run BEFORE the interaction gate: the # badge is visible precisely when interaction is disabled. hovered = self._session_badge_rect is not None and self._session_badge_rect.contains( @@ -1254,6 +1285,67 @@ class SampleCameraImageLabel(QGraphicsView): painter.restore() + @staticmethod + def _scale_bar(um_per_view_px: float) -> tuple[float, str]: + """Nice 1-2-5 scale-bar length (µm) for the current zoom, with label.""" + target_um = um_per_view_px * 120.0 # aim for a ~120 px wide bar + exponent = math.floor(math.log10(target_um)) + base = target_um / 10.0**exponent + nice = 5.0 if base >= 5.0 else 2.0 if base >= 2.0 else 1.0 + bar_um = nice * 10.0**exponent + label = f"{bar_um / 1000.0:g} mm" if bar_um >= 1000.0 else f"{bar_um:g} µm" + return bar_um, label + + def _draw_hover_hud(self, painter: QPainter): + # Bottom-right HUD: grey scale bar over the hovered pixel coordinates. + if self._hover_pos is None or self.pixmap_item is None: + return + if not self.pixmap_item.sceneBoundingRect().contains(self._hover_pos): + return + + painter.save() + painter.resetTransform() + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + + font = QFont() + font.setPointSize(10) + font.setBold(True) + painter.setFont(font) + fm = QFontMetrics(font) + + margin = 18 + right = self.viewport().width() - margin + coords_text = f"{self._hover_pos.x():.0f}, {self._hover_pos.y():.0f} pxl" + coords_baseline = self.viewport().height() - margin - fm.descent() + grey = qcolor(SCALE_BAR_GREY) + + def shadowed_text(x: float, baseline: float, text: str, color: QColor): + painter.setPen(QPen(qcolor(SHADOW, 200))) + painter.drawText(QPointF(x + 1, baseline + 1), text) + painter.setPen(QPen(color)) + painter.drawText(QPointF(x, baseline), text) + + shadowed_text( + right - fm.horizontalAdvance(coords_text), coords_baseline, coords_text, qcolor(WHITE) + ) + + # view-pixel scale: scene px -> viewport px via the current zoom. + zoom = self.transform().m11() + if zoom > 0: + um_per_view_px = self._geom.pixel_in_mm * 1000.0 / zoom + bar_um, label = self._scale_bar(um_per_view_px) + bar_px = bar_um / um_per_view_px + bar_y = coords_baseline - fm.ascent() - 12 + painter.setPen(QPen(grey, 3)) + painter.drawLine(QPointF(right - bar_px, bar_y), QPointF(right, bar_y)) + painter.drawLine(QPointF(right - bar_px, bar_y - 4), QPointF(right - bar_px, bar_y + 4)) + painter.drawLine(QPointF(right, bar_y - 4), QPointF(right, bar_y + 4)) + shadowed_text( + right - (bar_px + fm.horizontalAdvance(label)) / 2, bar_y - 8, label, grey + ) + + painter.restore() + @Slot(bool) def set_show_detections(self, show: bool): self._show_detections = show diff --git a/src/aare/gui/widgets/status_bar.py b/src/aare/gui/widgets/status_bar.py index 485a47f5..69c840e0 100644 --- a/src/aare/gui/widgets/status_bar.py +++ b/src/aare/gui/widgets/status_bar.py @@ -323,6 +323,7 @@ class StatusBar(QStatusBar): return self._pgroup_dialog_shown_for_current_baton = True + QTimer.singleShot(0, self.show_change_dialog) # Show after event loop returns self.show_change_dialog() def _emit_incoming_baton_request(self, status: BatonStatus) -> None: @@ -539,7 +540,13 @@ class StatusBar(QStatusBar): action_3 = menu.addAction("Dewar transfer") action_3.triggered.connect(self.dl) action_4 = menu.addAction("Beam location") - action_4.triggered.connect(self.beam_location) + if self._is_staff: + action_4.triggered.connect(self.beam_location) + else: + # Same gate as the state strip: greyed, not hidden, so + # non-staff learn the state exists but needs admin mode. + action_4.setText("Beam location (admin mode only)") + action_4.setEnabled(False) elif self._status.state in [BeamlineStateEnum.Maintenance]: action_2 = menu.addAction("Manual sample exchange") action_2.triggered.connect(self.se) diff --git a/tests/unit/gui/test_beamline_state_panel.py b/tests/unit/gui/test_beamline_state_panel.py index 0e4c7bb9..1b85c3b5 100644 --- a/tests/unit/gui/test_beamline_state_panel.py +++ b/tests/unit/gui/test_beamline_state_panel.py @@ -5,8 +5,8 @@ from aare.gui.panels.beamline_state_panel import BeamlineStatePanel from aare.gui.styles import STATE_AVAILABLE, STATE_MSG_ERROR, STATE_MSG_INFO, STATE_UNAVAILABLE -def _panel(qtbot): - panel = BeamlineStatePanel() +def _panel(qtbot, staff=True): + panel = BeamlineStatePanel(staff=staff) qtbot.addWidget(panel) return panel @@ -26,6 +26,25 @@ def test_availability_is_union_of_routes_and_menu_shortcuts(qtbot): assert BeamlineStateEnum.XtalSnapshot not in targets # two hops away +def test_non_staff_never_reach_admin_only_states(qtbot): + panel = _panel(qtbot, staff=False) + panel.set_current_state(BeamlineStateEnum.SampleAlignment) + targets = panel._available_targets() + assert BeamlineStateEnum.BeamLocation not in targets + assert BeamlineStateEnum.BeamstopAlignment not in targets + assert BeamlineStateEnum.FluxMeasurement not in targets + assert BeamlineStateEnum.DataCollection in targets # non-admin route stays + # Greyed like unreachable states, and the warning tip carries the red + # QToolTip wash while a normal-hint button does not. + gated = panel._buttons[BeamlineStateEnum.BeamLocation] + assert gated.cursor().shape() == Qt.CursorShape.ForbiddenCursor + assert "QToolTip" in gated.styleSheet() + assert "QToolTip" not in panel._buttons[BeamlineStateEnum.XtalSnapshot].styleSheet() + with qtbot.assertNotEmitted(panel.beam_location): + panel._emit_for_state(BeamlineStateEnum.BeamLocation) + panel._on_left_click(BeamlineStateEnum.BeamLocation) # shows the admin tip + + def test_no_targets_while_moving_or_unknown(qtbot): panel = _panel(qtbot) panel.set_current_state(None) diff --git a/tests/unit/gui/test_camera_image.py b/tests/unit/gui/test_camera_image.py index 2451310b..68045ff5 100644 --- a/tests/unit/gui/test_camera_image.py +++ b/tests/unit/gui/test_camera_image.py @@ -114,6 +114,29 @@ def test_help_badge_click_toggles_cheatsheet(camera, qtbot): assert not camera._help_expanded +def test_help_overlay_folds_when_mouse_leaves_box(camera, qtbot): + camera.grab() # paint records the collapsed "?" 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 paint records the full box rect + box = camera._help_hit_rect + + _mouse_move(camera, box.center().toPoint()) # inside the box: stays open + assert camera._help_expanded + + _mouse_move(camera, QPoint(int(box.left()) - 40, int(box.bottom()) + 40)) + assert not camera._help_expanded, "moving off the box must fold it back to the badge" + + # Leaving the widget entirely folds it too. + camera.grab() + badge = camera._help_hit_rect + qtbot.mouseClick(camera.viewport(), Qt.MouseButton.LeftButton, pos=badge.center().toPoint()) + assert camera._help_expanded + camera.leaveEvent(QEvent(QEvent.Type.Leave)) + assert not camera._help_expanded + + def test_camera_error_message_rewords_and_draws(camera): camera.set_camera_available(False) camera.set_camera_error_message("Sample camera feed unavailable: cable unplugged") @@ -229,6 +252,30 @@ def test_alt_wheel_axis_swap_still_changes_exposure(camera): assert len(sent) == n +def test_hover_hud_coords_and_scale_bar(camera): + # Hover inside the image: bottom-right HUD paints coords + grey scale bar. + _mouse_move(camera, QPoint(400, 300)) + assert camera._hover_pos is not None + camera.grab() + + # Hover outside the image (negative scene coords): HUD skips drawing. + _mouse_move(camera, QPoint(-10, -10)) + camera.grab() + + # Leaving the view clears the readout entirely. + camera.leaveEvent(QEvent(QEvent.Type.Leave)) + assert camera._hover_pos is None + camera.grab() + + +def test_scale_bar_nice_numbers(): + # 1-2-5 progression against the ~120 px target, and the mm label swap. + assert SampleCameraImageLabel._scale_bar(1.0) == (100.0, "100 µm") + assert SampleCameraImageLabel._scale_bar(2.0) == (200.0, "200 µm") + assert SampleCameraImageLabel._scale_bar(5.0) == (500.0, "500 µm") + assert SampleCameraImageLabel._scale_bar(10.0) == (1000.0, "1 mm") + + def test_autoscale_fits_from_the_first_frame(camera): from PySide6.QtGui import QPixmap diff --git a/tests/unit/gui/test_data_collection_settings.py b/tests/unit/gui/test_data_collection_settings.py index a9924864..565e9402 100644 --- a/tests/unit/gui/test_data_collection_settings.py +++ b/tests/unit/gui/test_data_collection_settings.py @@ -12,6 +12,7 @@ from aarecommon.math.coordinate import Coordinate, SmargonCoordinate from aarecommon.math.diffraction_geometry import DiffractionGeometry from aarecommon.models.models import SampleGeometryModel +from aare.gui.panels.data_collection_settings import DataCollectionSettings from aare.gui.panels.raster_data_collection import RasterDataCollectionPanel from aare.gui.panels.rotation_data_collection import RotationDataCollectionPanel from aare.gui.scan_logic.raster_grid_manager import RasterGridManager @@ -171,8 +172,8 @@ def test_user_override_persists_across_samples(panel, diffraction): @pytest.fixture -def raster_panel(qapp, diffraction): - geom = SampleGeometryModel( +def geom(): + return SampleGeometryModel( beam_location_pxl=Coordinate(x=500, y=500), pixel_in_mm=0.001, aerotech=Coordinate(x=0, y=0, z=0), @@ -181,6 +182,10 @@ def raster_panel(qapp, diffraction): omega_deg=0.0, beam_size_mm=Coordinate(x=0.01, y=0.01), ) + + +@pytest.fixture +def raster_panel(qapp, diffraction, geom): mgr = RasterGridManager(geom) return RasterDataCollectionPanel(raster_mgr=mgr, diffraction=diffraction) @@ -212,3 +217,53 @@ def test_grid_size_field_shares_panel_toggle(raster_panel): _edit(raster_panel.high_res_enter, "2.50") assert raster_panel._source == DbOverrideLineEdit.SOURCE_MINE assert raster_panel.width_enter.source() == DbOverrideLineEdit.SOURCE_MINE + + +# --------------------------------------------------------------------------- +# DataCollectionSettings: per-tab grid button + auto-center after mount +# --------------------------------------------------------------------------- + + +@pytest.fixture +def settings_panel(qapp, diffraction, geom): + return DataCollectionSettings(geom, RasterGridManager(geom), diffraction) + + +def test_grid_button_only_on_raster_tab(settings_panel): + assert not settings_panel.bounding_box.isHidden() # Raster is the default tab + for idx in (1, 2, 3): + settings_panel._tab_bar.setCurrentIndex(idx) + assert settings_panel.bounding_box.isHidden() + settings_panel._tab_bar.setCurrentIndex(0) + assert not settings_panel.bounding_box.isHidden() + + +def test_auto_center_fires_on_mount_only_when_armed(settings_panel): + clicks = [] + settings_panel.find_tip.clicked.connect(lambda: clicks.append(1)) + settings_panel.auto_center_after_mount.setChecked(True) + + # First status with a sample already mounted = GUI (re)start: hardware + # must not move, only the id is recorded. + settings_panel._track_sample(1) + assert not clicks + + # Real mount after an unmount fires the centering. + settings_panel._track_sample(None) + settings_panel._track_sample(2) + assert len(clicks) == 1 + + # Direct sample exchange (no unmount tick in between) also fires. + settings_panel._track_sample(3) + assert len(clicks) == 2 + + # Remounting the SAME sample counts as a fresh mount. + settings_panel._track_sample(None) + settings_panel._track_sample(3) + assert len(clicks) == 3 + + # Disarmed: mounts no longer trigger. + settings_panel.auto_center_after_mount.setChecked(False) + settings_panel._track_sample(None) + settings_panel._track_sample(4) + assert len(clicks) == 3 diff --git a/tests/unit/gui/test_main_window.py b/tests/unit/gui/test_main_window.py index 45f6c84e..1f8c2262 100644 --- a/tests/unit/gui/test_main_window.py +++ b/tests/unit/gui/test_main_window.py @@ -71,10 +71,10 @@ def test_main_window_init(qtbot, mock_ui_state, daq_status_factory): 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. + # Motion watch: only a state transition (Moving) or the robot station + # switches to the combined beamline view. Busy alone never does — + # Sample alignment, Beam location etc. run busy while the user + # watches the sample camera itself. win.update_daq_status( daq_status_factory(state=BeamlineStateEnum.RobotSampleExchange, busy=True) ) @@ -85,10 +85,16 @@ def test_main_window_init(qtbot, mock_ui_state, daq_status_factory): ) assert not win._watching_motion assert win.video_tab.currentWidget() is win.sample_camera + win.update_daq_status(daq_status_factory(state=BeamlineStateEnum.BeamLocation, busy=True)) + assert not win._watching_motion + win.update_daq_status(daq_status_factory(state=BeamlineStateEnum.Moving, 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) + daq_status_factory(state=BeamlineStateEnum.DataCollection, busy=False) ) assert not win._watching_motion + assert win.video_tab.currentWidget() is win.sample_camera def test_main_window_mount_view(qtbot, mock_ui_state): @@ -578,21 +584,24 @@ def test_nonstaff_beamline_gate_popups(qtbot, mock_ui_state): assert titles == ["Beamline setup", "ABR meas. pos.", "Beam configuration"] assert not hasattr(win, "monochromator_panel") - with patch("aare.gui.main_window.QMessageBox") as popup: + # Staff-gate denials are passive red tips now, not QMessageBoxes. + with patch("aare.gui.main_window.QToolTip") as tip: qtbot.mousePress(win._locked_beamline_banners[0], Qt.MouseButton.LeftButton) - assert popup.information.called, "banner click must explain the staff gate" + assert tip.showText.called, "banner click must explain the staff gate" + # The tip anchor carries the red warning wash (per-widget QToolTip QSS). + assert "QToolTip" in win._admin_tip_anchor.styleSheet() # 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: + with patch("aare.gui.main_window.QToolTip") as tip: qtbot.mousePress(bar, Qt.MouseButton.LeftButton, pos=bar.tabRect(0).center()) - assert popup.information.called, "gated tab click must explain the gate" + assert tip.showText.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: + with patch("aare.gui.main_window.QToolTip") as tip: 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" + assert tip.showText.called, "aux-puck tab click must explain the lock" diff --git a/tests/unit/gui/test_status_bar.py b/tests/unit/gui/test_status_bar.py index 291db130..1f5bcbde 100644 --- a/tests/unit/gui/test_status_bar.py +++ b/tests/unit/gui/test_status_bar.py @@ -33,6 +33,35 @@ def test_operables_share_the_hover_affordance(qtbot): assert not bar.state_label.font().underline() +def test_state_menu_gates_beam_location_for_non_staff(qtbot, daq_status_factory, monkeypatch): + from aarecommon.models.models import BeamlineStateEnum + from PySide6.QtWidgets import QMenu + + import aare.gui.widgets.status_bar as status_bar_module + + # exec() would block on a real popup, and PySide's method lookup ignores + # a class-attribute monkeypatch — swap in a subclass instead. The menu + # stays inspectable as a child of the bar afterwards. + class _NoExecMenu(QMenu): + def exec(self): # pyright: ignore[reportIncompatibleMethodOverride] + return None + + monkeypatch.setattr(status_bar_module, "QMenu", _NoExecMenu) + + def _state_menu_entries(bar): + bar._status = daq_status_factory(state=BeamlineStateEnum.SampleAlignment) + bar.show_state_menu() + # Plain data, not QAction refs: the menu (and its actions) only + # lives until the next GC pass once show_state_menu returns. + return {a.text(): a.isEnabled() for a in bar.findChildren(QMenu)[-1].actions()} + + assert _state_menu_entries(_bar(qtbot))["Beam location"] + + non_staff = StatusBar(token=TokenData(sub="u", staff=False, pgroups=["p1"], session=1)) + qtbot.addWidget(non_staff) + assert not _state_menu_entries(non_staff)["Beam location (admin mode only)"] + + def test_passives_left_operables_right(qtbot): bar = _bar(qtbot) # QStatusBar hides only the non-permanent (left) section behind a diff --git a/uv.lock b/uv.lock index 72a73a21..e52d0b16 100644 --- a/uv.lock +++ b/uv.lock @@ -31,7 +31,7 @@ wheels = [ [[package]] name = "aaredaq" -version = "0.8.3" +version = "0.12.5" source = { editable = "." } dependencies = [ { name = "aarecommon" }, @@ -330,7 +330,7 @@ name = "cffi" version = "2.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, + { name = "pycparser" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } wheels = [ @@ -2601,7 +2601,7 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -2675,7 +2675,7 @@ resolution-markers = [ "python_full_version >= '3.12'", ] dependencies = [ - { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } wheels = [