feat: user GUI optimizations #146

Merged
duan_j merged 16 commits from feat/user-gui-optimizations into main 2026-08-20 20:01:10 +02:00
17 changed files with 458 additions and 65 deletions
+2 -2
View File
@@ -473,8 +473,8 @@ Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
+31
View File
@@ -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.
+1 -1
View File
@@ -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")
+1 -1
View File
@@ -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"
+28 -18
View File
@@ -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)
+34 -3
View File
@@ -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)
+44 -14
View File
@@ -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:
+36 -3
View File
@@ -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 {
+3 -3
View File
@@ -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};
+92
View File
@@ -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
+8 -1
View File
@@ -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)
+21 -2
View File
@@ -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)
+47
View File
@@ -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
@@ -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
+20 -11
View File
@@ -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"
+29
View File
@@ -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
Generated
+4 -4
View File
@@ -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 = [