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 {