feat: user GUI optimizations #146
+2
-2
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user