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 <state>'; 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 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 18:32:07 +02:00
co-authored by Claude Fable 5
parent f540875a43
commit f104faa149
3 changed files with 282 additions and 598 deletions
+19 -31
View File
@@ -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)
+261 -563
View File
@@ -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: <H3> 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 <state>"; 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)
+2 -4
View File
@@ -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 {