From f104faa1493c5ac2656c2d50f45e1cda035bb05d Mon Sep 17 00:00:00 2001 From: Dawn Date: Thu, 6 Aug 2026 18:32:07 +0200 Subject: [PATCH] feat: horizontal beamline state bar above the status bar Replaces the vertical station map that consumed left-column space with a slim always-visible strip in the bottom toolbar area, directly above the status bar: - states in physical beamline order, 18px text, one line when the bar is wide enough and wrapped at the last space otherwise - availability coloring from the route graph plus the status-bar shortcut transitions: bold blue = active (red for Maintenance), orange = reachable in one step, grey = not reachable; no backgrounds or rounded corners - transitions only via right-click 'Go to '; left click shows a reminder tip for available states and the reachability explanation for grey ones; hovering a grey state for 3 s shows the same explanation - whole entry is the hover/click region; tips anchor to the entry and disappear when the mouse leaves - always visible: no View-menu toggle, not hidden by portrait or compact modes Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 50 +- src/aare/gui/panels/beamline_state_panel.py | 824 +++++++------------- src/aare/gui/styles.py | 6 +- 3 files changed, 282 insertions(+), 598 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index f8405460..6f955dd4 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -26,6 +26,7 @@ from PySide6.QtWidgets import ( QSizePolicy, QStackedWidget, QTabWidget, + QToolBar, QVBoxLayout, QWidget, ) @@ -142,8 +143,6 @@ class MainWindow(QMainWindow): self.beamline_camera_thread = None self.gonio_camera_thread = None - self._show_beamline_state_panel_for_users = True - self._waiting_for_baton_response: bool = False self._baton_request_dialog: BatonRequestDialog | None = None self._baton_pending_dialog: BatonPendingDialog | None = None @@ -242,17 +241,12 @@ class MainWindow(QMainWindow): self.loop_centering = LoopCenteringPanel(parent=self.left_column) - self.beamline_state_panel = BeamlineStatePanel(parent=self.left_column) - self._beamline_state_panel_enabled = bool( - self._decoded_token.staff or self._show_beamline_state_panel_for_users - ) + # 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.left_column_layout.addWidget(self.data_collection) self.left_column_layout.addWidget(self.loop_centering) - if self._beamline_state_panel_enabled: - self.left_column_layout.addWidget(self.beamline_state_panel) - else: - self.beamline_state_panel.hide() self.left_column_layout.addStretch() # Same universal banner gap as inside the panel columns. tighten_column(self.left_column_layout) @@ -264,12 +258,7 @@ class MainWindow(QMainWindow): ) self.collection_controls_scroll.setWidgetResizable(True) self.collection_controls_scroll.setFixedWidth( - max( - self.data_collection.set_width, - self.loop_centering.sizeHint().width(), - self.beamline_state_panel.set_width, - ) - + 10 + max(self.data_collection.set_width, self.loop_centering.sizeHint().width()) + 10 ) self.video_tab = QTabWidget(parent=top_widget) @@ -521,6 +510,20 @@ class MainWindow(QMainWindow): self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.prediction_metrics_dock) self.prediction_metrics_dock.hide() + # Full-width state strip directly above the status bar: the bottom + # toolbar area is guaranteed to sit below the bottom dock area (a + # bottom dock would land beside the existing docks instead). + self.beamline_state_toolbar = QToolBar("Beamline state", self) + self.beamline_state_toolbar.setObjectName("beamline_state_toolbar") + self.beamline_state_toolbar.setMovable(False) + self.beamline_state_toolbar.setFloatable(False) + self.beamline_state_toolbar.setAllowedAreas(Qt.ToolBarArea.BottomToolBarArea) + self.beamline_state_panel.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred + ) + self.beamline_state_toolbar.addWidget(self.beamline_state_panel) + self.addToolBar(Qt.ToolBarArea.BottomToolBarArea, self.beamline_state_toolbar) + # The standard page's combined panel minimum (~1400x1200) exceeds many # monitors, which pushed the status bar off-screen. Scrolling instead of # clamping lets the window shrink to any screen; scrollbars only appear @@ -1416,15 +1419,6 @@ class MainWindow(QMainWindow): view_menu.addAction(self._use_portrait_theme_action) view_menu.addSeparator() - if self._beamline_state_panel_enabled: - self._show_beamline_state_action = QAction("Show Beamline State Panel", self) - self._show_beamline_state_action.setCheckable(True) - self._show_beamline_state_action.setChecked(self.beamline_state_panel.isVisible()) - self._show_beamline_state_action.triggered.connect( - lambda checked: self.beamline_state_panel.setVisible(checked) - ) - view_menu.addAction(self._show_beamline_state_action) - show_samples_action = QAction("Show Sample List", self) show_samples_action.setCheckable(True) show_samples_action.setChecked(True) @@ -1594,12 +1588,6 @@ class MainWindow(QMainWindow): self.tell_samples_dock.setVisible(True) self.job_list_dock.setVisible(True) - if self._beamline_state_panel_enabled: - self.beamline_state_panel.setVisible(True) - self.beamline_state_panel.set_collapsed(False) - if hasattr(self, "_show_beamline_state_action"): - self._show_beamline_state_action.setChecked(True) - self.face_panel_dock.setVisible(False) self.fluor_panel_dock.setVisible(False) self.smargon_trace_dock.setVisible(False) diff --git a/src/aare/gui/panels/beamline_state_panel.py b/src/aare/gui/panels/beamline_state_panel.py index e38b6b93..e5006faa 100644 --- a/src/aare/gui/panels/beamline_state_panel.py +++ b/src/aare/gui/panels/beamline_state_panel.py @@ -1,35 +1,62 @@ -from collections import deque -from dataclasses import dataclass +from typing import ClassVar from aarecommon.models.models import BeamlineStateEnum, DAQStatusModel -from PySide6.QtCore import QEvent, QObject, QPoint, QRect, Qt, Signal, Slot -from PySide6.QtGui import QColor, QPainter, QPen -from PySide6.QtWidgets import QFrame, QLabel, QPushButton +from PySide6.QtCore import Qt, QTimer, Signal, Slot +from PySide6.QtGui import QCursor, QFont, QFontMetrics +from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QMenu, QPushButton, QSizePolicy, QToolTip -from aare.gui.widgets.title_label import PANEL_VMARGIN +# Shortcut transitions from the "Available transitions" menu in +# widgets/status_bar.py show_state_menu — these come ON TOP of the one-hop +# routes between states (_GRAPH), e.g. Manual sample exchange -> Sample +# alignment skipping the robot station, and Maintenance which sits outside +# the route graph. Keep in sync until the server exposes transitions. +_TO_SAMPLE_ALIGNMENT = frozenset({BeamlineStateEnum.SampleAlignment}) +MENU_TRANSITIONS: dict[BeamlineStateEnum, frozenset[BeamlineStateEnum]] = { + BeamlineStateEnum.RobotSampleExchange: _TO_SAMPLE_ALIGNMENT, + BeamlineStateEnum.SampleExchange: _TO_SAMPLE_ALIGNMENT, + BeamlineStateEnum.DewarTransfer: _TO_SAMPLE_ALIGNMENT, + BeamlineStateEnum.BeamLocation: _TO_SAMPLE_ALIGNMENT, + BeamlineStateEnum.DataCollection: _TO_SAMPLE_ALIGNMENT, + BeamlineStateEnum.XrayFluorescence: _TO_SAMPLE_ALIGNMENT, + BeamlineStateEnum.XtalSnapshot: _TO_SAMPLE_ALIGNMENT, + BeamlineStateEnum.SampleAlignment: frozenset( + { + BeamlineStateEnum.SampleExchange, + BeamlineStateEnum.DewarTransfer, + BeamlineStateEnum.BeamLocation, + } + ), + BeamlineStateEnum.Maintenance: frozenset({BeamlineStateEnum.SampleExchange}), +} + +# One-hop routes between states (the former map's segments). +_SEGMENTS = [ + (BeamlineStateEnum.DewarTransfer, BeamlineStateEnum.SampleExchange), + (BeamlineStateEnum.SampleExchange, BeamlineStateEnum.RobotSampleExchange), + (BeamlineStateEnum.RobotSampleExchange, BeamlineStateEnum.SampleAlignment), + (BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.BeamLocation), + (BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.BeamstopAlignment), + (BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.FluxMeasurement), + (BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.DataCollection), + (BeamlineStateEnum.BeamLocation, BeamlineStateEnum.BeamstopAlignment), + (BeamlineStateEnum.BeamLocation, BeamlineStateEnum.FluxMeasurement), + (BeamlineStateEnum.BeamstopAlignment, BeamlineStateEnum.FluxMeasurement), + (BeamlineStateEnum.DataCollection, BeamlineStateEnum.XtalSnapshot), + (BeamlineStateEnum.DataCollection, BeamlineStateEnum.XrayFluorescence), +] -@dataclass(frozen=True) -class StationSpec: - state: BeamlineStateEnum - label: str - x: int - y: int - clickable: bool = False - tooltip: str = "" +def _build_graph( + segments: list[tuple[BeamlineStateEnum, BeamlineStateEnum]], +) -> dict[BeamlineStateEnum, set[BeamlineStateEnum]]: + graph: dict[BeamlineStateEnum, set[BeamlineStateEnum]] = {} + for a, b in segments: + graph.setdefault(a, set()).add(b) + graph.setdefault(b, set()).add(a) + return graph -class HoverableLabel(QLabel): - hovered = Signal(object) - unhovered = Signal() - - def enterEvent(self, event) -> None: - self.hovered.emit(getattr(self, "_beamline_state", None)) - super().enterEvent(event) - - def leaveEvent(self, event) -> None: - self.unhovered.emit() - super().leaveEvent(event) +_GRAPH = _build_graph(_SEGMENTS) class HoverableButton(QPushButton): @@ -46,6 +73,13 @@ class HoverableButton(QPushButton): class BeamlineStatePanel(QFrame): + """Slim horizontal state strip shown directly above the status bar. + + Replaces the former vertical station map: two-line entries in beamline + order, colored by availability (blue/red = active, orange = reachable in + one transition, grey = not reachable). + """ + sample_exchange = Signal() sample_alignment = Signal() dewar_exchange = Signal() @@ -57,346 +91,183 @@ class BeamlineStatePanel(QFrame): beamstop_alignment = Signal() flux_measurement = Signal() - set_width = 400 - map_height = 542 - # 25 matches the halved TitleLabel banners used by every other panel; - # PANEL_VMARGIN mimics the layout margin other panels get from - # tighten_column, so the inter-banner gap stays universal. - title_height = 25 - collapsed_height = title_height + 2 * PANEL_VMARGIN - station_radius = 8 + # Physical beamline order. Labels are one line when the bar is wide + # enough and wrap at the last space to two lines when it is not. + _ENTRIES: tuple[tuple[BeamlineStateEnum, str], ...] = ( + (BeamlineStateEnum.Maintenance, "Maintenance mode"), + (BeamlineStateEnum.BeamLocation, "Beam location"), + (BeamlineStateEnum.BeamstopAlignment, "Beamstop alignment"), + (BeamlineStateEnum.FluxMeasurement, "Flux measurement"), + (BeamlineStateEnum.SampleAlignment, "Sample alignment"), + (BeamlineStateEnum.SampleExchange, "Manual sample exchange"), + (BeamlineStateEnum.RobotSampleExchange, "Robot sample exchange"), + (BeamlineStateEnum.DewarTransfer, "Dewar transfer"), + (BeamlineStateEnum.DataCollection, "Data collection"), + (BeamlineStateEnum.XtalSnapshot, "Crystal snapshot"), + (BeamlineStateEnum.XrayFluorescence, "X-ray fluorescence"), + ) + + _TOOLTIPS: ClassVar[dict[BeamlineStateEnum, str]] = { + BeamlineStateEnum.DewarTransfer: "Dewar transfer mode", + BeamlineStateEnum.SampleExchange: "Manual sample exchange mode", + BeamlineStateEnum.RobotSampleExchange: "Robot-assisted sample exchange", + BeamlineStateEnum.SampleAlignment: "Sample centring and alignment mode", + BeamlineStateEnum.BeamLocation: "Beam location mode", + BeamlineStateEnum.BeamstopAlignment: "Beamstop alignment mode", + BeamlineStateEnum.FluxMeasurement: "Flux measurement mode", + BeamlineStateEnum.DataCollection: "Measurement / collection mode", + BeamlineStateEnum.XtalSnapshot: "Crystal snapshot mode", + BeamlineStateEnum.XrayFluorescence: "X-ray fluorescence mode", + } def __init__(self, parent=None): super().__init__(parent) self.setObjectName("beamlineStatePanel") - self.setFrameShape(QFrame.Shape.StyledPanel) - self.setFrameShadow(QFrame.Shadow.Raised) - self.setFixedWidth(self.set_width) - self._is_collapsed = False - self.setMinimumHeight(self.map_height) - self.setMaximumHeight(self.map_height) self._current_state: BeamlineStateEnum | None = None self._hovered_state: BeamlineStateEnum | None = None self._pending_target_state: BeamlineStateEnum | None = None - self._last_stable_state: BeamlineStateEnum | None = None - self._line_color = QColor(111, 129, 160) - self._line_current = QColor(0, 126, 229) - self._line_hover = QColor(244, 196, 48) + # After 3 s of hovering an unavailable state, explain which states + # it can be reached from. + self._hover_hint_timer = QTimer(self) + self._hover_hint_timer.setSingleShot(True) + self._hover_hint_timer.setInterval(3000) + self._hover_hint_timer.timeout.connect(self._show_hover_hint) - self._station_current = QColor(0, 126, 229) - self._station_current_ring = QColor(120, 195, 255) - self._station_hover = QColor(244, 196, 48) - self._label_current_bg = "rgba(0, 126, 229, 0.12)" - self._label_hover_bg = "rgba(244, 196, 48, 0.22)" + self._available_color = "rgb(237, 137, 54)" + self._unavailable_color = "rgb(140, 150, 165)" - self._group_colors: dict[BeamlineStateEnum, QColor] = { - BeamlineStateEnum.DewarTransfer: QColor(128, 90, 213), - BeamlineStateEnum.SampleExchange: QColor(237, 137, 54), - BeamlineStateEnum.RobotSampleExchange: QColor(237, 137, 54), - BeamlineStateEnum.SampleAlignment: QColor(72, 187, 120), - BeamlineStateEnum.BeamLocation: QColor(72, 187, 120), - BeamlineStateEnum.BeamstopAlignment: QColor(72, 187, 120), - BeamlineStateEnum.FluxMeasurement: QColor(72, 187, 120), - BeamlineStateEnum.DataCollection: QColor(236, 72, 153), - BeamlineStateEnum.XtalSnapshot: QColor(236, 72, 153), - BeamlineStateEnum.XrayFluorescence: QColor(236, 72, 153), - } + layout = QHBoxLayout(self) + layout.setContentsMargins(10, 2, 10, 2) + layout.setSpacing(2) + layout.addStretch(1) - self._group_label_colors: dict[BeamlineStateEnum, str] = { - state: color.name() for state, color in self._group_colors.items() - } - - self._group_label_backgrounds: dict[BeamlineStateEnum, str] = { - BeamlineStateEnum.DewarTransfer: "rgba(128, 90, 213, 0.14)", - BeamlineStateEnum.SampleExchange: "rgba(237, 137, 54, 0.16)", - BeamlineStateEnum.RobotSampleExchange: "rgba(237, 137, 54, 0.16)", - BeamlineStateEnum.SampleAlignment: "rgba(72, 187, 120, 0.16)", - BeamlineStateEnum.BeamLocation: "rgba(72, 187, 120, 0.16)", - BeamlineStateEnum.BeamstopAlignment: "rgba(72, 187, 120, 0.16)", - BeamlineStateEnum.FluxMeasurement: "rgba(72, 187, 120, 0.16)", - BeamlineStateEnum.DataCollection: "rgba(236, 72, 153, 0.14)", - BeamlineStateEnum.XtalSnapshot: "rgba(236, 72, 153, 0.14)", - BeamlineStateEnum.XrayFluorescence: "rgba(236, 72, 153, 0.14)", - } - - self._stations = [ - StationSpec( - BeamlineStateEnum.DewarTransfer, - "Dewar transfer", - 54, - 140, - True, - "Dewar transfer mode", - ), - StationSpec( - BeamlineStateEnum.SampleExchange, - "Manual sample exchange", - 54, - 176, - True, - "Manual sample exchange mode", - ), - StationSpec( - BeamlineStateEnum.RobotSampleExchange, - "Robot sample exchange", - 54, - 212, - True, - "Robot-assisted sample exchange", - ), - StationSpec( - BeamlineStateEnum.SampleAlignment, - "Sample alignment", - 54, - 248, - True, - "Sample centring and alignment mode", - ), - StationSpec( - BeamlineStateEnum.BeamLocation, "Beam location", 54, 284, True, "Beam location mode" - ), - StationSpec( - BeamlineStateEnum.BeamstopAlignment, - "Beamstop alignment", - 54, - 320, - True, - "Beamstop alignment mode", - ), - StationSpec( - BeamlineStateEnum.FluxMeasurement, - "Flux measurement", - 54, - 356, - True, - "Flux measurement mode", - ), - StationSpec( - BeamlineStateEnum.DataCollection, - "Data collection", - 54, - 392, - True, - "Measurement / collection mode", - ), - StationSpec( - BeamlineStateEnum.XtalSnapshot, - "Crystal snapshot", - 54, - 428, - True, - "Crystal snapshot mode", - ), - StationSpec( - BeamlineStateEnum.XrayFluorescence, "XRF", 54, 464, True, "X-ray fluorescence mode" - ), - ] - - self._segments = [ - (BeamlineStateEnum.DewarTransfer, BeamlineStateEnum.SampleExchange), - (BeamlineStateEnum.SampleExchange, BeamlineStateEnum.RobotSampleExchange), - (BeamlineStateEnum.RobotSampleExchange, BeamlineStateEnum.SampleAlignment), - (BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.BeamLocation), - (BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.BeamstopAlignment), - (BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.FluxMeasurement), - (BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.DataCollection), - (BeamlineStateEnum.BeamLocation, BeamlineStateEnum.BeamstopAlignment), - (BeamlineStateEnum.BeamLocation, BeamlineStateEnum.FluxMeasurement), - (BeamlineStateEnum.BeamstopAlignment, BeamlineStateEnum.FluxMeasurement), - (BeamlineStateEnum.DataCollection, BeamlineStateEnum.XtalSnapshot), - (BeamlineStateEnum.DataCollection, BeamlineStateEnum.XrayFluorescence), - ] - - self._graph = self._build_graph(self._segments) - self._station_widgets: dict[BeamlineStateEnum, QLabel | QPushButton] = {} - - self.title = QLabel(self) - self.title.setObjectName("beamlineStateTitle") - # Plain text + QSS font:

margins would clip in the 25px banner. - self.title.setText("Beamline state") - self.title.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.title.setFixedHeight(self.title_height) - self.title.setGeometry(0, PANEL_VMARGIN, self.set_width, self.title_height) - # Whole banner toggles via eventFilter, like TitleLabel; the +/- glyph - # is only the indicator. - self.title.setCursor(Qt.CursorShape.PointingHandCursor) - self.title.installEventFilter(self) - - self.toggle_button = QPushButton("−", self) - self.toggle_button.setObjectName("beamlineStateToggleButton") - self.toggle_button.setToolTip("Minimise beamline state panel") - self.toggle_button.setFixedSize(21, 21) - self.toggle_button.move(self.set_width - 29, PANEL_VMARGIN + (self.title_height - 21) // 2) - self.toggle_button.clicked.connect(self.toggle_collapsed) - - self.current_label = QLabel("Current: —", self) - self.current_label.setObjectName("beamlineStateCurrentLabel") - self.current_label.move(14, 58) - self.current_label.adjustSize() - - self.tell_label = QLabel("Tell: —", self) - self.tell_label.setObjectName("beamlineStateTellLabel") - self.tell_label.move(14, 86) - self.tell_label.adjustSize() - - self._build_station_widgets() - self._position_station_widgets() - self._update_collapsed_state() - - @staticmethod - def _canon_segment( - a: BeamlineStateEnum, b: BeamlineStateEnum - ) -> tuple[BeamlineStateEnum, BeamlineStateEnum]: - return tuple(sorted((a, b), key=lambda state: state.value)) - - def _build_graph( - self, segments: list[tuple[BeamlineStateEnum, BeamlineStateEnum]] - ) -> dict[BeamlineStateEnum, set[BeamlineStateEnum]]: - graph: dict[BeamlineStateEnum, set[BeamlineStateEnum]] = {} - for a, b in segments: - graph.setdefault(a, set()).add(b) - graph.setdefault(b, set()).add(a) - return graph - - def _station_map(self) -> dict[BeamlineStateEnum, StationSpec]: - return {station.state: station for station in self._stations} - - def _path_segments_between( - self, start: BeamlineStateEnum | None, end: BeamlineStateEnum | None - ) -> set[tuple[BeamlineStateEnum, BeamlineStateEnum]]: - if start is None or end is None: - return set() - - if start == BeamlineStateEnum.Moving or end == BeamlineStateEnum.Moving: - return set() - - if start == end: - return set() - - queue = deque([start]) - previous: dict[BeamlineStateEnum, BeamlineStateEnum | None] = {start: None} - - while queue: - node = queue.popleft() - if node == end: - break - - for neighbour in self._graph.get(node, set()): - if neighbour in previous: - continue - previous[neighbour] = node - queue.append(neighbour) - - if end not in previous: - return set() - - path_segments: set[tuple[BeamlineStateEnum, BeamlineStateEnum]] = set() - cursor = end - while previous[cursor] is not None: - parent = previous[cursor] - path_segments.add(self._canon_segment(cursor, parent)) - cursor = parent - - return path_segments - - def _active_hover_route(self) -> set[tuple[BeamlineStateEnum, BeamlineStateEnum]]: - if self._hovered_state is not None: - route_source = ( - self._last_stable_state - if self._current_state == BeamlineStateEnum.Moving - else self._current_state - ) - return self._path_segments_between(route_source, self._hovered_state) - - if ( - self._current_state == BeamlineStateEnum.Moving - and self._pending_target_state is not None - ): - return self._path_segments_between(self._last_stable_state, self._pending_target_state) - - return set() - - def _build_station_widgets(self) -> None: - for station in self._stations: - if station.clickable: - widget: QLabel | QPushButton = HoverableButton(station.label, self) - widget.setFlat(True) - widget.setCursor(Qt.CursorShape.PointingHandCursor) - widget.clicked.connect( - lambda _checked=False, state=station.state: self._emit_for_state(state) + self._buttons: dict[BeamlineStateEnum, HoverableButton] = {} + self._single_line = True + for index, (state, label) in enumerate(self._ENTRIES): + if index: + separator = QLabel("–", self) + separator.setStyleSheet( + "color: rgb(140, 150, 165); background: transparent; border: none; font-size: 18px;" ) - else: - widget = HoverableLabel(station.label, self) + layout.addWidget(separator) + button = HoverableButton(label, self) + button.setFlat(True) + # Fill the bar height so the hover region is the whole entry, + # not just the text line. + button.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding) + button._beamline_state = state + # Transitions only via right-click -> "Go to "; a plain + # left click must not move the beamline. + button.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + button.customContextMenuRequested.connect( + lambda pos, s=state, b=button: self._show_state_menu(s, b, pos) + ) + button.clicked.connect(lambda _checked=False, s=state: self._on_left_click(s)) + button.hovered.connect(self._set_hovered_state) + button.unhovered.connect(self._clear_hovered_state) + self._buttons[state] = button + layout.addWidget(button) - widget._beamline_state = station.state - widget.hovered.connect(self._set_hovered_state) - widget.unhovered.connect(self._clear_hovered_state) - widget.setToolTip(station.tooltip or station.label) - self._station_widgets[station.state] = widget + layout.addStretch(1) + self._apply_highlight() - self._apply_station_highlight() + def resizeEvent(self, event) -> None: + super().resizeEvent(event) + self._update_label_mode() - def _position_station_widgets(self) -> None: - station_map = self._station_map() - - for state, widget in self._station_widgets.items(): - station = station_map[state] - widget.setText(station.label) - widget.adjustSize() - - label_x = station.x + 20 - label_y = station.y - 12 - - widget.move(label_x, label_y) - widget.show() - - self._apply_station_highlight() - - def eventFilter(self, watched: QObject, event: QEvent) -> bool: - if watched is self.title and event.type() == QEvent.Type.MouseButtonPress: - self.toggle_collapsed() - return True - return super().eventFilter(watched, event) - - def toggle_collapsed(self) -> None: - self._is_collapsed = not self._is_collapsed - self._update_collapsed_state() - - def set_collapsed(self, collapsed: bool) -> None: - if self._is_collapsed == collapsed: + def _update_label_mode(self) -> None: + # One line while it fits, wrapped at the last space otherwise. + # Measured with bold so the mode does not flap when the active + # state (the only bold entry) changes. + font = QFont(self.font()) + font.setPixelSize(18) + font.setWeight(QFont.Weight.Bold) + fm = QFontMetrics(font) + needed = 20 # layout margins + for index, (_state, label) in enumerate(self._ENTRIES): + if index: + needed += fm.horizontalAdvance("–") + 4 + needed += fm.horizontalAdvance(label) + 20 # padding + frame + single_line = needed <= self.width() + if single_line == self._single_line: return - self._is_collapsed = collapsed - self._update_collapsed_state() + self._single_line = single_line + for state, label in self._ENTRIES: + text = label if single_line else "\n".join(label.rsplit(" ", 1)) + self._buttons[state].setText(text) - def _update_collapsed_state(self) -> None: - show_content = not self._is_collapsed + def _show_state_menu(self, state: BeamlineStateEnum, button: QPushButton, pos) -> None: + if state not in self._available_targets(): + return + menu = QMenu(button) + go_action = menu.addAction(f"Go to {state.display_name()}") + go_action.triggered.connect(lambda: self._emit_for_state(state)) + menu.exec(button.mapToGlobal(pos)) - self.current_label.setVisible(show_content) - self.tell_label.setVisible(show_content) - - for widget in self._station_widgets.values(): - widget.setVisible(show_content) - - if self._is_collapsed: - self.setMinimumHeight(self.collapsed_height) - self.setMaximumHeight(self.collapsed_height) - self.toggle_button.setText("+") - self.toggle_button.setToolTip("Restore beamline state panel") + def _on_left_click(self, state: BeamlineStateEnum) -> None: + # Left click never moves the beamline: remind about right-click for + # available states, explain unreachability for the rest. + if state == self._current_state: + return + if state in self._available_targets(): + button = self._buttons[state] + QToolTip.showText( + QCursor.pos(), + f"Right-click to go to {state.display_name()}.", + button, + button.rect(), + ) else: - self.setMinimumHeight(self.map_height) - self.setMaximumHeight(self.map_height) - self.toggle_button.setText("−") - self.toggle_button.setToolTip("Minimise beamline state panel") + self._show_unavailable_hint(state) - self.updateGeometry() - self.update() + def _available_targets(self) -> frozenset[BeamlineStateEnum]: + current = self._current_state + if current is None or current == BeamlineStateEnum.Moving: + 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()) + + def _set_hovered_state(self, state: BeamlineStateEnum | None) -> None: + self._hovered_state = state + self._hover_hint_timer.start() + + @Slot() + def _clear_hovered_state(self) -> None: + self._hovered_state = None + self._hover_hint_timer.stop() + QToolTip.hideText() + + def _show_hover_hint(self) -> None: + state = self._hovered_state + if state is None or state == self._current_state or state in self._available_targets(): + return + self._show_unavailable_hint(state) + + def _show_unavailable_hint(self, state: BeamlineStateEnum) -> None: + sources = set(_GRAPH.get(state, set())) + sources |= {s for s, targets in MENU_TRANSITIONS.items() if state in targets} + sources.discard(state) + if not sources: + return + reachable = ", ".join(sorted(s.display_name() for s in sources)) + button = self._buttons[state] + # Anchoring to the button rect makes Qt drop the tip as soon as the + # mouse leaves the entry, instead of letting it linger. + QToolTip.showText( + QCursor.pos(), + f"You can only go to {state.display_name()} by being in: {reachable}.", + button, + button.rect(), + ) def _emit_for_state(self, state: BeamlineStateEnum) -> None: + # Unavailable transitions are not clickable (grey + forbidden cursor). + if state not in self._available_targets(): + return self._pending_target_state = state - self._hovered_state = None - self.update() if state == BeamlineStateEnum.SampleExchange: self.sample_exchange.emit() @@ -419,236 +290,63 @@ class BeamlineStatePanel(QFrame): elif state == BeamlineStateEnum.XrayFluorescence: self.xray_fluorescence.emit() - @Slot(object) - def _set_hovered_state(self, state: BeamlineStateEnum | None) -> None: - self._hovered_state = state - self._apply_station_highlight() - - @Slot() - def _clear_hovered_state(self) -> None: - self._hovered_state = None - self._apply_station_highlight() - - def _apply_station_highlight(self) -> None: - for station in self._stations: - widget = self._station_widgets[station.state] - is_current = station.state == self._current_state - is_hovered = station.state == self._hovered_state + def _apply_highlight(self) -> None: + available = self._available_targets() + for state, button in self._buttons.items(): + is_current = state == self._current_state is_pending = ( - station.state == self._pending_target_state + state == self._pending_target_state and self._current_state == BeamlineStateEnum.Moving ) + is_available = state in available - label_color = self._group_label_colors.get(station.state, "rgb(55, 67, 87)") - label_bg = self._group_label_backgrounds.get(station.state, "transparent") - - if isinstance(widget, QPushButton): - if is_current: - widget.setStyleSheet(f""" - QPushButton {{ - border: none; - border-radius: 10px; - background: {self._label_current_bg}; - color: rgb(0, 92, 170); - font-size: 14px; - font-weight: 700; - text-align: left; - padding: 2px 6px 2px 8px; - }} - QPushButton:hover {{ - color: rgb(0, 92, 170); - }} - """) - elif is_hovered or is_pending: - widget.setStyleSheet(f""" - QPushButton {{ - border: none; - border-radius: 10px; - background: {self._label_hover_bg}; - color: rgb(115, 88, 0); - font-size: 14px; - font-weight: 700; - text-align: left; - padding: 2px 6px 2px 8px; - }} - QPushButton:hover {{ - color: rgb(115, 88, 0); - }} - """) - else: - widget.setStyleSheet(f""" - QPushButton {{ - border: none; - border-radius: 10px; - background: {label_bg}; - color: {label_color}; - font-size: 14px; - font-weight: 600; - text-align: left; - padding: 2px 6px 2px 8px; - }} - QPushButton:hover {{ - color: {label_color}; - }} - """) - else: - if is_current: - widget.setStyleSheet(f""" - QLabel {{ - border-radius: 10px; - background: {self._label_current_bg}; - color: rgb(0, 92, 170); - font-size: 14px; - font-weight: 700; - padding: 2px 6px 2px 8px; - }} - """) - elif is_hovered or is_pending: - widget.setStyleSheet(f""" - QLabel {{ - border-radius: 10px; - background: {self._label_hover_bg}; - color: rgb(115, 88, 0); - font-size: 14px; - font-weight: 700; - padding: 2px 6px 2px 8px; - }} - """) - else: - widget.setStyleSheet(f""" - QLabel {{ - border-radius: 10px; - background: {label_bg}; - color: {label_color}; - font-size: 14px; - font-weight: 600; - padding: 2px 6px 2px 8px; - }} - """) - - widget.adjustSize() - - self.update() - - def _station_center(self, state: BeamlineStateEnum) -> QPoint: - station = self._station_map()[state] - return QPoint(station.x, station.y) - - def _segment_color(self, a: BeamlineStateEnum, b: BeamlineStateEnum) -> QColor: - segment = self._canon_segment(a, b) - - active_hover_route = self._active_hover_route() - if segment in active_hover_route: - return self._line_hover - - current_path = self._path_segments_between( - BeamlineStateEnum.DewarTransfer, self._last_stable_state or self._current_state - ) - if segment in current_path: - return self._line_current - - return self._line_color - - def _draw_segment(self, painter: QPainter, start: QPoint, end: QPoint, color: QColor) -> None: - pen = QPen(color, 3) - pen.setCapStyle(Qt.PenCapStyle.RoundCap) - painter.setPen(pen) - painter.drawLine(start, end) - - def _station_base_color(self, state: BeamlineStateEnum) -> QColor: - return self._group_colors.get(state, QColor(180, 190, 210)) - - def _draw_station(self, painter: QPainter, station: StationSpec) -> None: - center = self._station_center(station.state) - rect = QRect( - center.x() - self.station_radius, - center.y() - self.station_radius, - self.station_radius * 2, - self.station_radius * 2, - ) - - if station.state == self._hovered_state or ( - self._current_state == BeamlineStateEnum.Moving - and station.state == self._pending_target_state - ): - painter.setPen(QPen(self._station_hover, 3)) - painter.setBrush(self._station_hover) - elif station.state == self._current_state: - painter.setPen(QPen(self._station_current_ring, 3)) - painter.setBrush(Qt.BrushStyle.NoBrush) - painter.drawEllipse( - QRect( - center.x() - self.station_radius - 3, - center.y() - self.station_radius - 3, - (self.station_radius + 3) * 2, - (self.station_radius + 3) * 2, + # Availability drives the look: active = bold (red for + # Maintenance, blue otherwise), reachable = orange, rest = grey. + # No backgrounds, no rounded corners. + if is_current or is_pending: + color = ( + "rgb(200, 30, 30)" + if state == BeamlineStateEnum.Maintenance + else "rgb(0, 92, 170)" ) - ) - painter.setPen(QPen(self._station_current, 2)) - painter.setBrush(self._station_current) - else: - base_color = self._station_base_color(station.state) - painter.setPen(QPen(base_color.darker(125), 2)) - painter.setBrush(base_color) - - painter.drawEllipse(rect) - - def paintEvent(self, event) -> None: - super().paintEvent(event) - - if self._is_collapsed: - return - - painter = QPainter(self) - painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) - - for start_state, end_state in self._segments: - start = self._station_center(start_state) - end = self._station_center(end_state) - color = self._segment_color(start_state, end_state) - self._draw_segment(painter, start, end, color) - - for station in self._stations: - self._draw_station(painter, station) - - @Slot(DAQStatusModel) - def update_daq_status(self, status: DAQStatusModel) -> None: - self.set_current_state(status.state) - - tell_text = "Tell: —" - tell_color = "rgb(55, 67, 87)" - if status.tell_state is not None: - tell_state = status.tell_state - tell_text = f"Tell: {tell_state.activity.display_name()}" - - tell_phase = tell_state.phase.display_name() if tell_state.phase is not None else "" - tell_message = (tell_state.message or "").strip() - - if tell_phase: - tell_text = f"{tell_text} ({tell_phase})" - elif tell_message: - tell_text = f"{tell_text} ({tell_message})" - - if tell_state.activity.value == "error": - tell_color = "red" - elif tell_state.activity.value in {"mounting", "unmounting", "drying", "cooling"}: - tell_color = "orange" + bold = True + elif is_available: + color = self._available_color + bold = False else: - tell_color = "green" + color = self._unavailable_color + bold = False - self.tell_label.setText(tell_text) - self.tell_label.setStyleSheet(f"color: {tell_color};") - self.tell_label.adjustSize() + # Font set in code (not QSS) so _update_label_mode can measure + # the real metrics when deciding one- vs two-line labels. + font = QFont(self.font()) + font.setPixelSize(18) + font.setWeight(QFont.Weight.Bold if bold else QFont.Weight.Normal) + button.setFont(font) + button.setStyleSheet( + f"QPushButton {{ border: none; background: transparent; color: {color};" + f" padding: 1px 8px; }}" + f" QPushButton:hover {{ color: {color}; }}" + ) + + # Clickability follows availability; unavailable states get the + # forbidden cursor and only the deferred 3 s explanation tooltip. + if is_available: + button.setCursor(Qt.CursorShape.PointingHandCursor) + button.setToolTip(self._TOOLTIPS.get(state, state.display_name())) + else: + button.setCursor(Qt.CursorShape.ForbiddenCursor) + button.setToolTip("") def set_current_state(self, state: BeamlineStateEnum | None) -> None: self._current_state = state + if ( + state is not None + and state != BeamlineStateEnum.Moving + and self._pending_target_state == state + ): + self._pending_target_state = None + self._apply_highlight() - if state is not None and state != BeamlineStateEnum.Moving: - self._last_stable_state = state - if self._pending_target_state == state: - self._pending_target_state = None - - label = state.display_name() if state is not None else "—" - self.current_label.setText(f"Current: {label}") - self.current_label.adjustSize() - self._apply_station_highlight() + def update_daq_status(self, status: DAQStatusModel) -> None: + self.set_current_state(status.state) diff --git a/src/aare/gui/styles.py b/src/aare/gui/styles.py index 527911ae..4628e432 100644 --- a/src/aare/gui/styles.py +++ b/src/aare/gui/styles.py @@ -179,8 +179,7 @@ def _original_stylesheet() -> str: QFrame#beamlineStatePanel { background: rgb(216, 228, 253); - border: 1px solid rgb(185, 204, 238); - border-radius: 12px; + border-top: 1px solid rgb(185, 204, 238); } QLabel#beamlineStateTitle { @@ -421,8 +420,7 @@ def _portrait_stylesheet() -> str: QFrame#beamlineStatePanel { background: #0E1A26; - border: 1px solid #1A3A36; - border-radius: 12px; + border-top: 1px solid #1A3A36; } QLabel#beamlineStateTitle {