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:
+19
-31
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user