From 569b0244565b547d57faace86faffdb73191c24c Mon Sep 17 00:00:00 2001 From: Dawn Date: Tue, 18 Aug 2026 20:35:44 +0200 Subject: [PATCH] feat: admin-gate beam diagnostics states, warn via passive red tip Beam location, Beamstop alignment and Flux measurement grey out for non-staff in the state strip and the status-bar transitions menu. All "not admin" denials now show as an auto-dismissing QToolTip on a Catppuccin red wash instead of a click-to-close QMessageBox. Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 27 ++++++++++----- src/aare/gui/panels/beamline_state_panel.py | 37 +++++++++++++++++++-- src/aare/gui/styles.py | 24 +++++++++++++ src/aare/gui/widgets/status_bar.py | 8 ++++- tests/unit/gui/test_beamline_state_panel.py | 22 ++++++++++-- tests/unit/gui/test_main_window.py | 15 +++++---- 6 files changed, 112 insertions(+), 21 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 02b97b5c..43f69d08 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,7 @@ 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) # Beamline / Experiment as tabs (like the Dewar samples dock) instead # of two stacked banner groups; the pages keep their banner children. @@ -1473,20 +1475,27 @@ 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. QToolTip inherits QSS from the widget it is + # shown for, so a hidden anchor keeps the red wash off every other + # tooltip; styled at show time so it always matches the theme. + if not hasattr(self, "_admin_tip_anchor"): + self._admin_tip_anchor = QLabel(self) + self._admin_tip_anchor.hide() + 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: 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/styles.py b/src/aare/gui/styles.py index 1f35f723..7cdd5d56 100644 --- a/src/aare/gui/styles.py +++ b/src/aare/gui/styles.py @@ -559,6 +559,30 @@ TOOLTIP_FG = "#263043" DARK_TOOLTIP_BG = "#0e1728" # dusk panel2 — deepest opaque (menus/tooltips) DARK_TOOLTIP_FG = "#e9edf4" # dusk text +# "Needs admin/staff" warning tips: same passive QToolTip, on a Catppuccin +# red wash — a denial reads as a warning without a click-to-close dialog. +ADMIN_TIP_BG = "#d20f39" # latte red +ADMIN_TIP_FG = "#ffffff" +DARK_ADMIN_TIP_BG = "#f38ba8" # mocha red +DARK_ADMIN_TIP_FG = "#11111b" # mocha crust + + +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_ADMIN_TIP_BG, DARK_ADMIN_TIP_FG) + if theme == THEME_SUNSET + else (ADMIN_TIP_BG, ADMIN_TIP_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. diff --git a/src/aare/gui/widgets/status_bar.py b/src/aare/gui/widgets/status_bar.py index 485a47f5..e235453a 100644 --- a/src/aare/gui/widgets/status_bar.py +++ b/src/aare/gui/widgets/status_bar.py @@ -539,7 +539,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..944a3dfd 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,24 @@ 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) + + def test_no_targets_while_moving_or_unknown(qtbot): panel = _panel(qtbot) panel.set_current_state(None) diff --git a/tests/unit/gui/test_main_window.py b/tests/unit/gui/test_main_window.py index 45f6c84e..1f8a2019 100644 --- a/tests/unit/gui/test_main_window.py +++ b/tests/unit/gui/test_main_window.py @@ -578,21 +578,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"