From d2bfe92aae68a226e97ab82bc19c08be596d4434 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 9 Jun 2026 11:02:57 +0200 Subject: [PATCH] feat(beamline-states): add state manager widget --- bec_widgets/cli/client.py | 106 ++ bec_widgets/cli/designer_plugins.py | 10 + .../services/beamline_states/__init__.py | 0 .../beamline_state_manager.pyproject | 1 + .../beamline_state_manager_plugin.py | 57 + .../beamline_states/beamline_state_pill.py | 1289 +++++++++++++++++ .../beamline_state_pill.pyproject | 1 + .../beamline_state_pill_plugin.py | 57 + .../services/beamline_states/dialogs.py | 267 ++++ .../register_beamline_state_manager.py | 17 + .../register_beamline_state_pill.py | 17 + pyproject.toml | 1 + tests/unit_tests/test_beamline_state_pill.py | 696 +++++++++ tests/unit_tests/test_name_utils.py | 13 + 14 files changed, 2532 insertions(+) create mode 100644 bec_widgets/widgets/services/beamline_states/__init__.py create mode 100644 bec_widgets/widgets/services/beamline_states/beamline_state_manager.pyproject create mode 100644 bec_widgets/widgets/services/beamline_states/beamline_state_manager_plugin.py create mode 100644 bec_widgets/widgets/services/beamline_states/beamline_state_pill.py create mode 100644 bec_widgets/widgets/services/beamline_states/beamline_state_pill.pyproject create mode 100644 bec_widgets/widgets/services/beamline_states/beamline_state_pill_plugin.py create mode 100644 bec_widgets/widgets/services/beamline_states/dialogs.py create mode 100644 bec_widgets/widgets/services/beamline_states/register_beamline_state_manager.py create mode 100644 bec_widgets/widgets/services/beamline_states/register_beamline_state_pill.py create mode 100644 tests/unit_tests/test_beamline_state_pill.py create mode 100644 tests/unit_tests/test_name_utils.py diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 1f84fa5a..5bf8e6dd 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -32,6 +32,8 @@ _Widgets = { "BECQueue": "BECQueue", "BECShell": "BECShell", "BECStatusBox": "BECStatusBox", + "BeamlineStateManager": "BeamlineStateManager", + "BeamlineStatePill": "BeamlineStatePill", "BecConsole": "BecConsole", "DapComboBox": "DapComboBox", "DeviceBrowser": "DeviceBrowser", @@ -717,6 +719,110 @@ class BaseROI(RPCBase): """ +class BeamlineStateManager(RPCBase): + """Widget displaying and managing all BEC beamline states.""" + + _IMPORT_MODULE = "bec_widgets.widgets.services.beamline_states.beamline_state_pill" + + @property + @rpc_call + def idle_card_background(self) -> "bool": + """ + Whether idle collapsed pills keep the status-tinted card background. + """ + + @rpc_call + def set_idle_card_background(self, enabled: "bool") -> "None": + """ + Set whether idle collapsed pills keep the status-tinted card background. + """ + + @rpc_call + def refresh_states(self) -> "None": + """ + Fetch the latest cached available beamline states and update the list immediately. + """ + + @rpc_call + def clear_filters(self) -> "None": + """ + None + """ + + @rpc_call + def remove(self): + """ + Cleanup the BECConnector + """ + + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + + @rpc_timeout(None) + @rpc_call + def screenshot(self, file_name: "str | None" = None): + """ + Take a screenshot of the dock area and save it to a file. + """ + + +class BeamlineStatePill(RPCBase): + """Compact widget showing one BEC beamline state.""" + + _IMPORT_MODULE = "bec_widgets.widgets.services.beamline_states.beamline_state_pill" + + @property + @rpc_call + def state_name(self) -> "str | None": + """ + Name of the BEC beamline state displayed by this pill. + """ + + @rpc_call + def set_state_name(self, state_name: "str | None", title: "str | None" = None) -> "None": + """ + Set the BEC beamline state this pill displays. + + Args: + state_name: State name as published by ``AvailableBeamlineStatesMessage``. + title: Optional human-readable title for the state. + """ + + @rpc_call + def remove(self): + """ + Cleanup the BECConnector + """ + + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + + @rpc_timeout(None) + @rpc_call + def screenshot(self, file_name: "str | None" = None): + """ + Take a screenshot of the dock area and save it to a file. + """ + + class BecConsole(RPCBase): """A console widget with access to a shared registry of terminals, such that instances can be moved around.""" diff --git a/bec_widgets/cli/designer_plugins.py b/bec_widgets/cli/designer_plugins.py index bdbdcb6f..6315596e 100644 --- a/bec_widgets/cli/designer_plugins.py +++ b/bec_widgets/cli/designer_plugins.py @@ -19,6 +19,14 @@ designer_plugins = { "BECShell": ("bec_widgets.widgets.editors.bec_console.bec_console", "BECShell"), "BECSpinBox": ("bec_widgets.widgets.utility.spinbox.decimal_spinbox", "BECSpinBox"), "BECStatusBox": ("bec_widgets.widgets.services.bec_status_box.bec_status_box", "BECStatusBox"), + "BeamlineStateManager": ( + "bec_widgets.widgets.services.beamline_states.beamline_state_pill", + "BeamlineStateManager", + ), + "BeamlineStatePill": ( + "bec_widgets.widgets.services.beamline_states.beamline_state_pill", + "BeamlineStatePill", + ), "BecConsole": ("bec_widgets.widgets.editors.bec_console.bec_console", "BecConsole"), "ColorButton": ("bec_widgets.widgets.utility.visual.color_button.color_button", "ColorButton"), "ColorButtonNative": ( @@ -118,6 +126,8 @@ widget_icons = { "BECShell": "hub", "BECSpinBox": "123", "BECStatusBox": "widgets", + "BeamlineStateManager": "format_list_bulleted", + "BeamlineStatePill": "info", "BecConsole": "terminal", "ColorButton": "colors", "ColorButtonNative": "colors", diff --git a/bec_widgets/widgets/services/beamline_states/__init__.py b/bec_widgets/widgets/services/beamline_states/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/widgets/services/beamline_states/beamline_state_manager.pyproject b/bec_widgets/widgets/services/beamline_states/beamline_state_manager.pyproject new file mode 100644 index 00000000..340bae9a --- /dev/null +++ b/bec_widgets/widgets/services/beamline_states/beamline_state_manager.pyproject @@ -0,0 +1 @@ +{'files': ['beamline_state_pill.py']} \ No newline at end of file diff --git a/bec_widgets/widgets/services/beamline_states/beamline_state_manager_plugin.py b/bec_widgets/widgets/services/beamline_states/beamline_state_manager_plugin.py new file mode 100644 index 00000000..5a76b475 --- /dev/null +++ b/bec_widgets/widgets/services/beamline_states/beamline_state_manager_plugin.py @@ -0,0 +1,57 @@ +# Copyright (C) 2022 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause + +from qtpy.QtDesigner import QDesignerCustomWidgetInterface +from qtpy.QtWidgets import QWidget + +from bec_widgets.utils.bec_designer import designer_material_icon +from bec_widgets.widgets.services.beamline_states.beamline_state_pill import BeamlineStateManager + +DOM_XML = """ + + + + +""" + + +class BeamlineStateManagerPlugin(QDesignerCustomWidgetInterface): # pragma: no cover + def __init__(self): + super().__init__() + self._form_editor = None + + def createWidget(self, parent): + if parent is None: + return QWidget() + t = BeamlineStateManager(parent) + return t + + def domXml(self): + return DOM_XML + + def group(self): + return "BEC Services" + + def icon(self): + return designer_material_icon(BeamlineStateManager.ICON_NAME) + + def includeFile(self): + return "beamline_state_manager" + + def initialize(self, form_editor): + self._form_editor = form_editor + + def isContainer(self): + return False + + def isInitialized(self): + return self._form_editor is not None + + def name(self): + return "BeamlineStateManager" + + def toolTip(self): + return "" + + def whatsThis(self): + return self.toolTip() diff --git a/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py b/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py new file mode 100644 index 00000000..f4b86cee --- /dev/null +++ b/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py @@ -0,0 +1,1289 @@ +from __future__ import annotations + +import sys +from typing import Any + +from bec_lib import bl_states +from bec_lib.endpoints import MessageEndpoints +from bec_qthemes import material_icon +from qtpy.QtCore import QAbstractListModel, QModelIndex, QSize, Qt, Signal +from qtpy.QtGui import QColor, QMouseEvent, QPalette +from qtpy.QtWidgets import ( + QAbstractItemView, + QApplication, + QCheckBox, + QDialog, + QFormLayout, + QGraphicsDropShadowEffect, + QHBoxLayout, + QLabel, + QListView, + QMessageBox, + QPushButton, + QSizePolicy, + QStyledItemDelegate, + QStyleOptionViewItem, + QToolButton, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.bec_connector import ConnectionConfig +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.colors import Colors, get_accent_colors, get_theme_name, rgba, theme_color +from bec_widgets.utils.error_popups import SafeProperty, SafeSlot +from bec_widgets.utils.forms_from_types.pydantic_widget_form import ( + OptionalValueWidget, + PydanticWidgetForm, +) +from bec_widgets.utils.toolbars.actions import MaterialIconAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle +from bec_widgets.utils.toolbars.toolbar import ModularToolBar +from bec_widgets.widgets.services.beamline_states.dialogs import ( + BEAMLINE_STATE_STATUS_LABELS, + SUPPORTED_BEAMLINE_STATES, + AddBeamlineStateDialog, + DeviceFilterDialog, + StatusFilterDialog, +) + + +def _coerce_bool(value: Any) -> bool: + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + + +def _config_class_for_state_type(state_type: str) -> type[bl_states.BeamlineStateConfig]: + for state_class in SUPPORTED_BEAMLINE_STATES: + if state_type in {state_class.__name__, state_class.CONFIG_CLASS.state_type}: + return state_class.CONFIG_CLASS + return bl_states.DeviceStateConfig + + +def _update_parameters_from_config(config: object) -> dict[str, Any]: + if isinstance(config, bl_states.BeamlineStateConfig): + return config.model_dump(exclude={"name"}) + if isinstance(config, dict): + return {key: value for key, value in config.items() if key != "name"} + if hasattr(config, "model_dump"): + data = config.model_dump() + return {key: value for key, value in data.items() if key != "name"} + raise TypeError(f"Unsupported beamline state config type: {type(config)!r}") + + +class _BeamlineStatePillHeader(QWidget): + """Header surface responsible for pill click gestures.""" + + clicked = Signal() + + def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802 + if event.button() == Qt.MouseButton.LeftButton: + self.clicked.emit() + event.accept() + return + super().mousePressEvent(event) + + +class BeamlineStatePill(BECWidget, QWidget): + """ + Compact widget showing one BEC beamline state. + + The pill subscribes to ``MessageEndpoints.beamline_state(state_name)`` and updates whenever + a ``BeamlineStateMessage`` is published for that state. + """ + + PLUGIN = True + ICON_NAME = "info" + USER_ACCESS = ["state_name", "set_state_name", "remove", "attach", "detach", "screenshot"] + + state_changed = Signal(str, str, str) + update_requested = Signal(str, object) + remove_requested = Signal(str) + size_hint_changed = Signal() + + _STATUS_LABELS = BEAMLINE_STATE_STATUS_LABELS + _STATUS_ICONS = { + "valid": "check_circle", + "invalid": "cancel", + "warning": "warning", + "unknown": "help", + } + + def __init__( + self, + parent: QWidget | None = None, + state_name: str | None = None, + title: str | None = None, + client=None, + config: ConnectionConfig | None = None, + gui_id: str | None = None, + **kwargs, + ) -> None: + super().__init__( + parent=parent, client=client, config=config, gui_id=gui_id, theme_update=True, **kwargs + ) + self.setObjectName("BeamlineStatePill") + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) + self._state_name: str | None = None + self._title: str | None = None + self._state_config: dict[str, Any] = {} + self._status = "unknown" + self._label = "No state information available." + self._expanded = False + self._idle_card_background = False + self._populating_settings = False + self._settings_baseline: dict[str, Any] = {} + self._settings_dirty_fields: set[str] = set() + self._settings_form_stale = True + + self._init_ui(state_name, title) + + def _init_ui(self, state_name: str | None = None, title: str | None = None) -> None: + self._shadow = QGraphicsDropShadowEffect(self) + self._shadow.setBlurRadius(18) + self._shadow.setOffset(0, 2) + self._shadow.setColor(QColor(0, 0, 0, 120)) + self._shadow.setEnabled(False) + self.setGraphicsEffect(self._shadow) + + self._header = _BeamlineStatePillHeader(self) + self._header.setObjectName("beamline_state_header") + self._header.setCursor(Qt.CursorShape.PointingHandCursor) + self._header.clicked.connect(self._toggle_expanded) + + self._stripe = QWidget(self) + self._stripe.setObjectName("beamline_state_stripe") + self._stripe.setFixedWidth(4) + self._stripe.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding) + self._stripe.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + + self._icon_label = QLabel(self) + self._icon_label.setObjectName("beamline_state_icon") + self._icon_label.setFixedSize(32, 32) + self._icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._icon_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + self._name_label = QLabel(self) + self._name_label.setObjectName("beamline_state_name") + self._name_label.setTextFormat(Qt.TextFormat.PlainText) + self._name_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + self._status_label = QLabel(self) + self._status_label.setObjectName("beamline_state_status") + self._status_label.setTextFormat(Qt.TextFormat.PlainText) + self._status_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + self._detail_label = QLabel(self) + self._detail_label.setObjectName("beamline_state_detail") + self._detail_label.setTextFormat(Qt.TextFormat.PlainText) + self._detail_label.setWordWrap(True) + self._detail_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + self._expand_button = QToolButton(self) + self._expand_button.setObjectName("beamline_state_expand") + self._expand_button.setAutoRaise(True) + self._expand_button.setCursor(Qt.CursorShape.PointingHandCursor) + self._expand_button.clicked.connect(self._toggle_expanded) + + text_layout = QVBoxLayout() + text_layout.setContentsMargins(0, 0, 0, 0) + text_layout.setSpacing(1) + text_layout.addWidget(self._name_label) + text_layout.addWidget(self._detail_label) + + header_layout = QHBoxLayout(self._header) + header_layout.setContentsMargins(10, 8, 12, 8) + header_layout.setSpacing(10) + header_layout.addWidget(self._stripe) + header_layout.addWidget(self._icon_label) + header_layout.addLayout(text_layout, 1) + header_layout.addWidget(self._status_label, 0, Qt.AlignmentFlag.AlignRight) + header_layout.addWidget(self._expand_button) + + self._settings = QWidget(self) + self._settings.setObjectName("beamline_state_settings") + self._settings.setVisible(False) + self._state_type_value = QLabel(self._settings) + self._config_form: PydanticWidgetForm | None = None + self._config_form_host = QVBoxLayout() + self._config_form_host.setContentsMargins(0, 0, 0, 0) + self._config_form_host.setSpacing(0) + + button_layout = QHBoxLayout() + button_layout.setContentsMargins(0, 0, 0, 0) + button_layout.setSpacing(8) + self._update_button = QPushButton("Update", self._settings) + self._update_button.setIcon(material_icon("save", convert_to_pixmap=False)) + self._revert_button = QPushButton("Revert", self._settings) + self._revert_button.setIcon(material_icon("undo", convert_to_pixmap=False)) + self._remove_button = QPushButton("Remove", self._settings) + self._remove_button.setObjectName("beamline_state_remove_button") + self._remove_button.setIcon(material_icon("delete", convert_to_pixmap=False)) + self._update_button.clicked.connect(self._emit_update_requested) + self._revert_button.clicked.connect(self._revert_settings) + self._remove_button.clicked.connect(self._emit_remove_requested) + button_layout.addWidget(self._update_button) + button_layout.addWidget(self._revert_button) + button_layout.addWidget(self._remove_button) + button_layout.addStretch(1) + + self._settings_form = QFormLayout() + self._settings_form.setContentsMargins(0, 0, 0, 0) + self._settings_form.setHorizontalSpacing(10) + self._settings_form.setVerticalSpacing(8) + self._settings_form.setFieldGrowthPolicy( + QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow + ) + self._settings_form.setLabelAlignment(Qt.AlignmentFlag.AlignRight) + self._settings_form.addRow("Type", self._state_type_value) + + settings_layout = QVBoxLayout(self._settings) + settings_layout.setContentsMargins(12, 8, 12, 12) + settings_layout.setSpacing(8) + settings_layout.addLayout(self._settings_form) + settings_layout.addLayout(self._config_form_host) + settings_layout.addLayout(button_layout) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(self._header) + layout.addWidget(self._settings) + self.setLayout(layout) + + self.setMinimumWidth(0) + self._header.setMinimumWidth(0) + self._settings.setMinimumWidth(0) + self._settings.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) + self.setMinimumHeight(58) + self.set_state_name(state_name, title=title) + self._update_button.setEnabled(False) + self._revert_button.setEnabled(False) + + def minimumSizeHint(self) -> QSize: # noqa: N802 + hint = super().minimumSizeHint() + return QSize(0, hint.height()) + + @SafeProperty(str, default=None) + def state_name(self) -> str | None: + """Name of the BEC beamline state displayed by this pill.""" + return self._state_name + + @state_name.setter + def state_name(self, state_name: str | None) -> None: + self.set_state_name(state_name) + + def set_state_name(self, state_name: str | None, title: str | None = None) -> None: + """ + Set the BEC beamline state this pill displays. + + Args: + state_name: State name as published by ``AvailableBeamlineStatesMessage``. + title: Optional human-readable title for the state. + """ + if state_name == self._state_name and title == self._title: + return + + if self._state_name is not None: + self.bec_dispatcher.disconnect_slot( + self.update_state, MessageEndpoints.beamline_state(self._state_name) + ) + + self._state_name = state_name + self._title = title + self._name_label.setText(title or state_name or "Beamline state") + + if self._state_name is None: + self._set_visual_state("unknown", "No beamline state selected.") + return + + self._set_visual_state("unknown", "No state information available.") + self._refresh_latest_state() + self.bec_dispatcher.connect_slot( + self.update_state, MessageEndpoints.beamline_state(self._state_name) + ) + + def set_state_config(self, state_config: dict[str, Any]) -> None: + """Set the editable BEC state configuration displayed by the expanded panel.""" + self._state_config = state_config + self._settings_form_stale = True + if self._config_form is not None: + self._populate_settings() + self._mark_settings_clean_from_current() + + @SafeProperty(bool, default=False) + def idle_card_background(self) -> bool: + """ + Whether idle collapsed pills keep the status-tinted card background. + """ + return self._idle_card_background + + @idle_card_background.setter + def idle_card_background(self, enabled: bool) -> None: + self._idle_card_background = _coerce_bool(enabled) + self._apply_visual_state() + + def set_idle_card_background(self, enabled: bool) -> None: + """Set whether idle collapsed pills keep the status-tinted card background.""" + self.idle_card_background = enabled + + def _refresh_latest_state(self) -> None: + if self._state_name is None: + return + msg_container = self.client.connector.get_last( + MessageEndpoints.beamline_state(self._state_name) + ) + if not msg_container: + return + data = msg_container.get("data") if isinstance(msg_container, dict) else None + content = getattr(data, "content", data) + if isinstance(content, dict): + self.update_state(content, getattr(data, "metadata", {})) + + @SafeSlot(dict, dict) + def update_state( + self, content: dict[str, Any], _metadata: dict[str, Any] | None = None + ) -> None: + """ + Update this pill from a ``BeamlineStateMessage`` content dictionary. + """ + name = content.get("name") + if self._state_name is not None and name and name != self._state_name: + return + + status = str(content.get("status", "unknown")).lower() + label = str(content.get("label", "No state information available.")) + self._set_visual_state(status, label) + self.state_changed.emit(self._state_name or str(name or ""), status, label) + + @SafeSlot(str) + def apply_theme(self, _theme: str) -> None: + self._apply_visual_state() + + def _set_visual_state(self, status: str, label: str) -> None: + status = status if status in self._STATUS_LABELS else "unknown" + self._status = status + self._label = label + + self._apply_visual_state() + + def _apply_visual_state(self) -> None: + colors = self._state_colors(self._status) + accent = colors["accent"] + on_accent = colors["on_accent"] + active_card = self._expanded + border = colors["border"] if self._idle_card_background else "transparent" + background = colors["background"] if self._idle_card_background else "transparent" + card_gradient = ( + "qlineargradient(" + "x1:0, y1:0, x2:1, y2:0, " + f"stop:0 {colors['gradient_accent']}, " + f"stop:{colors['gradient_stop']} {colors['card_background']}, " + f"stop:1 {colors['card_background']}" + ")" + ) + if active_card: + background = card_gradient + border = colors["card_border"] + hover_background = card_gradient + self._shadow.setColor(QColor(colors["shadow"])) + self._shadow.setBlurRadius(int(colors["shadow_blur"])) + self._shadow.setOffset(0, int(colors["shadow_y_offset"])) + self._shadow.setEnabled(active_card) + + icon_name = self._STATUS_ICONS[self._status] + self._icon_label.setPixmap( + material_icon(icon_name, size=(20, 20), color=on_accent, filled=True) + ) + expand_icon = "expand_less" if self._expanded else "expand_more" + self._expand_button.setIcon(material_icon(expand_icon, convert_to_pixmap=False)) + self._status_label.setText(self._STATUS_LABELS[self._status]) + self._detail_label.setText(self._label) + self.setToolTip(self._label) + self.setStyleSheet( + "#BeamlineStatePill {" + f"background: {background};" + f"border: 1px solid {border};" + f"border-radius: {'12px' if active_card else '8px'};" + "}" + "#BeamlineStatePill:hover {" + f"background: {hover_background};" + f"border: 1px solid {colors['card_border']};" + "border-radius: 12px;" + "}" + "QWidget#beamline_state_header {" + "background: transparent;" + "}" + "QWidget#beamline_state_stripe {" + f"background-color: {accent};" + "border-radius: 2px;" + "}" + "QLabel#beamline_state_icon {" + f"background-color: {accent};" + "border-radius: 16px;" + "}" + "QLabel#beamline_state_name {" + f"color: {colors['foreground']};" + "font-weight: 600;" + "}" + "QLabel#beamline_state_status {" + f"color: {accent};" + "font-weight: 700;" + "font-size: 13px;" + "}" + "QLabel#beamline_state_detail {" + f"color: {colors['muted']};" + "font-size: 11px;" + "}" + "QWidget#beamline_state_settings {" + "background: transparent;" + f"border-top: 1px solid {colors['border']};" + "}" + '*[beamlineStateDirty="true"] {' + f"background-color: {colors['dirty_background']};" + f"border: 1px solid {colors['dirty_border']};" + "border-radius: 4px;" + "}" + "QPushButton#beamline_state_remove_button {" + "background-color: #cc181e;" + "border: 1px solid #cc181e;" + "color: white;" + "border-radius: 4px;" + "padding: 4px 10px;" + "}" + "QPushButton#beamline_state_remove_button:hover {" + "background-color: #a91419;" + "border-color: #a91419;" + "}" + ) + + @SafeSlot() + def _toggle_expanded(self) -> None: + self.set_expanded(not self._expanded) + + def is_expanded(self) -> bool: + """Return whether the editable settings panel is expanded.""" + return self._expanded + + def set_expanded(self, expanded: bool) -> None: + """Set the editable settings panel expanded state.""" + expanded = bool(expanded) + if expanded == self._expanded: + return + if expanded: + self._ensure_settings_form_current() + self._expanded = expanded + self._settings.setVisible(expanded) + self._apply_visual_state() + self.size_hint_changed.emit() + + def _ensure_config_form( + self, config_class: type[bl_states.BeamlineStateConfig] = bl_states.DeviceStateConfig + ) -> PydanticWidgetForm: + if self._config_form is None: + self._config_form = PydanticWidgetForm( + config_class, parent=self._settings, client=self.client, read_only_fields={"name"} + ) + self._config_form.changed.connect(self._update_settings_dirty_state) + self._config_form.setMinimumWidth(0) + self._config_form_host.addWidget(self._config_form) + return self._config_form + + def _ensure_settings_form_current(self) -> PydanticWidgetForm: + if self._settings_form_stale: + self._populate_settings() + self._mark_settings_clean_from_current() + return self._ensure_config_form() + + def _populate_settings(self) -> None: + self._populating_settings = True + try: + state_type = str(self._state_config.get("state_type") or "") + config_class = _config_class_for_state_type(state_type) + config_form = self._ensure_config_form(config_class) + if config_form.model is not config_class: + config_form.set_model(config_class) + self._state_type_value.setText(state_type or "-") + config_form.set_partial_data(self._state_data_for_form(config_class)) + self._settings_form_stale = False + finally: + self._populating_settings = False + self._update_settings_dirty_state() + + def edited_config(self) -> bl_states.BeamlineStateConfig: + """Return the validated config currently represented by the expanded settings panel.""" + config = self._ensure_settings_form_current().model_instance() + return config # type: ignore[return-value] + + def mark_current_settings_clean(self, config: object | None = None) -> None: + """Mark the current editor values as saved.""" + if config is None: + parameters = self._ensure_config_form().raw_editable_data() + else: + parameters = _update_parameters_from_config(config) + if self._state_config: + state_parameters = self._state_config.get("parameters") + if isinstance(state_parameters, dict): + state_parameters.update(parameters) + else: + self._state_config.update(parameters) + if "title" in parameters: + self._state_config["title"] = parameters["title"] + self._mark_settings_clean_from_current() + + def _mark_settings_clean_from_current(self) -> None: + config_form = self._ensure_config_form() + self._settings_baseline = config_form.raw_editable_data() + config_form.mark_clean() + self._update_settings_dirty_state() + + @SafeSlot() + def _revert_settings(self) -> None: + self._populating_settings = True + try: + self._ensure_config_form().set_partial_data(self._settings_baseline) + finally: + self._populating_settings = False + self._update_settings_dirty_state() + + def _update_settings_dirty_state(self) -> None: + if self._populating_settings: + return + if self._config_form is None: + self._settings_dirty_fields = set() + self._update_button.setEnabled(False) + self._revert_button.setEnabled(False) + return + + self._settings_dirty_fields = self._config_form.dirty_fields() - {"name"} + + has_changes = bool(self._settings_dirty_fields) + self._update_button.setEnabled(has_changes) + self._revert_button.setEnabled(has_changes) + self._apply_dirty_field_highlights() + + def _apply_dirty_field_highlights(self) -> None: + if self._config_form is None: + return + for name, widget in self._config_form.widgets.items(): + self._set_dirty_property(widget, name in self._settings_dirty_fields) + + @staticmethod + def _set_dirty_property(widget: QWidget, dirty: bool) -> None: + widgets = [widget] + if isinstance(widget, OptionalValueWidget): + widgets.append(widget.value_widget) + if widget.value_widget.parentWidget() is not None: + widgets.append(widget.value_widget.parentWidget()) + for target in widgets: + if target.property("beamlineStateDirty") == dirty: + continue + target.setProperty("beamlineStateDirty", dirty) + target.style().unpolish(target) + target.style().polish(target) + target.update() + + @SafeSlot() + def _emit_update_requested(self) -> None: + if self._state_name is None: + return + if not self._settings_dirty_fields: + return + try: + config = self.edited_config() + except ValueError as exc: + QMessageBox.warning(self, "Invalid Beamline State", str(exc)) + return + self.update_requested.emit(self._state_name, config) + + @SafeSlot() + def _emit_remove_requested(self) -> None: + if self._state_name is None: + return + self.remove_requested.emit(self._state_name) + + def _state_data_for_form( + self, config_class: type[bl_states.BeamlineStateConfig] + ) -> dict[str, Any]: + data: dict[str, Any] = {} + parameters = self._state_config.get("parameters") + parameter_values = parameters if isinstance(parameters, dict) else {} + for name in config_class.model_fields: + if name == "name": + data[name] = self._state_name + elif name in parameter_values: + data[name] = parameter_values[name] + elif name in self._state_config: + data[name] = self._state_config[name] + return data + + @staticmethod + def _state_colors(status: str) -> dict[str, str]: + app = QApplication.instance() + palette = app.palette() if app is not None else QPalette() + theme = getattr(app, "theme", None) if app is not None else None + light_theme = get_theme_name() == "light" + accents = get_accent_colors() + + card_bg = theme_color(theme, "CARD_BG", palette.window().color()) + border = theme_color(theme, "BORDER", palette.mid().color()) + foreground = theme_color(theme, "FG", palette.text().color()) + on_primary = theme_color(theme, "ON_PRIMARY", QColor("#ffffff")) + warning = accents.warning + accent = { + "valid": accents.success, + "invalid": accents.emergency, + "warning": warning, + "unknown": accents.default, + }.get(status, accents.default) + + gradient_alpha = 18 if light_theme else 62 + gradient_stop = "0.38" if light_theme else "0.62" + background_mix = 0.0 if light_theme else 0.10 + card_border_mix = 0.34 if light_theme else 0.45 + border_mix = 0.34 if light_theme else 0.35 + + return { + "accent": accent.name(), + "on_accent": on_primary.name(), + "card_background": card_bg.name(), + "card_border": Colors._blend(border, accent, card_border_mix).name(), + "gradient_accent": rgba(accent, gradient_alpha), + "gradient_stop": gradient_stop, + "background": Colors._blend(card_bg, accent, background_mix).name(), + "border": Colors._blend(border, accent, border_mix).name(), + "dirty_background": Colors._blend( + card_bg, warning, 0.12 if light_theme else 0.18 + ).name(), + "dirty_border": Colors._blend(border, warning, 0.70).name(), + "foreground": foreground.name(), + "muted": Colors._blend(card_bg, foreground, 0.66).name(), + "shadow": "#00000024" if light_theme else "#00000078", + "shadow_blur": "24" if light_theme else "18", + "shadow_y_offset": "3" if light_theme else "2", + } + + def cleanup(self) -> None: + if self._state_name is not None: + self.bec_dispatcher.disconnect_slot( + self.update_state, MessageEndpoints.beamline_state(self._state_name) + ) + if self._config_form is not None: + self._config_form.cleanup() + super().cleanup() + + +class _BeamlineStateListModel(QAbstractListModel): + """Model owning beamline state row identity and configuration data.""" + + NameRole = Qt.ItemDataRole.UserRole + 1 + ConfigRole = Qt.ItemDataRole.UserRole + 2 + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._state_order: list[str] = [] + self._state_rows: dict[str, int] = {} + self._state_configs: dict[str, dict[str, Any]] = {} + + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: # noqa: N802 + return 0 if parent.isValid() else len(self._state_order) + + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: + if not index.isValid() or not 0 <= index.row() < len(self._state_order): + return None + name = self._state_order[index.row()] + if role in (Qt.ItemDataRole.DisplayRole, self.NameRole): + return name + if role == self.ConfigRole: + return self._state_configs.get(name, {}) + if role == Qt.ItemDataRole.SizeHintRole: + return QSize(0, 58) + return None + + def flags(self, index: QModelIndex) -> Qt.ItemFlag: + if not index.isValid(): + return Qt.ItemFlag.NoItemFlags + return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable + + def set_states(self, state_configs: list[dict[str, Any]]) -> None: + new_order = [str(state["name"]) for state in state_configs if state.get("name")] + new_configs = {str(state["name"]): state for state in state_configs if state.get("name")} + + for row in reversed( + [row for row, name in enumerate(self._state_order) if name not in new_configs] + ): + self.beginRemoveRows(QModelIndex(), row, row) + name = self._state_order.pop(row) + self._state_configs.pop(name, None) + self.endRemoveRows() + self._rebuild_rows() + + for target_row, name in enumerate(new_order): + if name not in self._state_rows: + self.beginInsertRows(QModelIndex(), target_row, target_row) + self._state_order.insert(target_row, name) + self._state_configs[name] = new_configs[name] + self.endInsertRows() + self._rebuild_rows() + continue + + current_row = self._state_rows[name] + if current_row != target_row: + destination_row = target_row if current_row > target_row else target_row + 1 + self.beginMoveRows( + QModelIndex(), current_row, current_row, QModelIndex(), destination_row + ) + self._state_order.insert(target_row, self._state_order.pop(current_row)) + self.endMoveRows() + self._rebuild_rows() + + if self._state_configs.get(name) != new_configs[name]: + self._state_configs[name] = new_configs[name] + index = self.index(self._state_rows[name], 0) + self.dataChanged.emit(index, index, [self.ConfigRole]) + + def _rebuild_rows(self) -> None: + self._state_rows = {name: row for row, name in enumerate(self._state_order)} + + def index_for_name(self, name: str) -> QModelIndex: + row = self._state_rows.get(name) + if row is None: + return QModelIndex() + return self.index(row, 0) + + +class _BeamlineStatePillDelegate(QStyledItemDelegate): + """Delegate that provides BeamlineStatePill persistent editors for list rows.""" + + def __init__(self, manager: "BeamlineStateManager") -> None: + super().__init__(manager) + self._manager = manager + + def paint(self, _painter, _option: QStyleOptionViewItem, _index: QModelIndex) -> None: + return + + def createEditor( # noqa: N802 + self, parent: QWidget, _option: QStyleOptionViewItem, index: QModelIndex + ) -> QWidget: + name = index.data(_BeamlineStateListModel.NameRole) + state_config = index.data(_BeamlineStateListModel.ConfigRole) or {} + title = state_config.get("title") or name + pill = BeamlineStatePill( + parent=parent, state_name=name, title=title, client=self._manager.client + ) + pill.idle_card_background = self._manager.idle_card_background + pill.set_state_config(state_config) + pill.state_changed.connect(self._manager._on_pill_state_changed) + pill.update_requested.connect(self._manager._update_state_parameters) + pill.remove_requested.connect(self._manager._remove_state_requested) + pill.size_hint_changed.connect(lambda name=name: self._manager._sync_pill_item_size(name)) + self._manager._state_pills[str(name)] = pill + return pill + + def setEditorData(self, editor: QWidget, index: QModelIndex) -> None: # noqa: N802 + if not isinstance(editor, BeamlineStatePill): + return + name = index.data(_BeamlineStateListModel.NameRole) + state_config = index.data(_BeamlineStateListModel.ConfigRole) or {} + title = state_config.get("title") or name + editor.set_state_name(str(name), title=str(title)) + editor.idle_card_background = self._manager.idle_card_background + editor.set_state_config(state_config) + + def updateEditorGeometry( # noqa: N802 + self, editor: QWidget, option: QStyleOptionViewItem, _index: QModelIndex + ) -> None: + editor.setGeometry(option.rect) + + def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize: # noqa: N802 + name = index.data(_BeamlineStateListModel.NameRole) + pill = self._manager._state_pills.get(str(name)) + if pill is not None: + return QSize(0, pill.sizeHint().height()) + return QSize(0, 58) + + def destroyEditor(self, editor: QWidget, index: QModelIndex) -> None: # noqa: N802 + if isinstance(editor, BeamlineStatePill): + name = editor.state_name + if name and self._manager._state_pills.get(name) is editor: + self._manager._state_pills.pop(name, None) + editor.cleanup() + super().destroyEditor(editor, index) + + +class _BeamlineStateListView(QListView): + """List view using persistent pill editors.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("beamline_state_pill_view") + self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setFrameShape(QListView.Shape.NoFrame) + self.setSpacing(6) + self.setMinimumWidth(0) + self.viewport().setMinimumWidth(0) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.setStyleSheet( + "QListView#beamline_state_pill_view {" + "background: transparent;" + "border: none;" + "}" + "QListView#beamline_state_pill_view::item {" + "background: transparent;" + "border: none;" + "padding: 0;" + "}" + "QListView#beamline_state_pill_view::item:selected {" + "background: transparent;" + "border: none;" + "}" + ) + + def minimumSizeHint(self) -> QSize: # noqa: N802 + hint = super().minimumSizeHint() + return QSize(0, hint.height()) + + def sizeHint(self) -> QSize: # noqa: N802 + hint = super().sizeHint() + return QSize(0, hint.height()) + + +class BeamlineStateManager(BECWidget, QWidget): + """ + Widget displaying and managing all BEC beamline states. + + The manager subscribes to ``MessageEndpoints.available_beamline_states()`` and creates, + updates, or removes child ``BeamlineStatePill`` widgets as the set of configured states changes. + """ + + PLUGIN = True + ICON_NAME = "format_list_bulleted" + USER_ACCESS = [ + "idle_card_background", + "set_idle_card_background", + "refresh_states", + "clear_filters", + "remove", + "attach", + "detach", + "screenshot", + ] + + def __init__( + self, + parent: QWidget | None = None, + client=None, + config: ConnectionConfig | None = None, + gui_id: str | None = None, + idle_card_background: bool = False, + **kwargs, + ) -> None: + super().__init__( + parent=parent, client=client, config=config, gui_id=gui_id, theme_update=True, **kwargs + ) + self.setMinimumWidth(0) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self._state_pills: dict[str, BeamlineStatePill] = {} + self._state_configs: dict[str, dict[str, Any]] = {} + self._state_order: list[str] = [] + self._selected_statuses: set[str] | None = None + self._selected_devices: set[str] | None = None + self._device_filter_text = "" + self._hidden_expanded = False + self._idle_card_background = False + self.idle_card_background = idle_card_background + + self._empty_label = QLabel( + "No beamline states available.\n Add new state from toolbar or CLI.", self + ) + self._empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + self._toolbar = self._create_toolbar() + self._model = _BeamlineStateListModel(self) + self._view = _BeamlineStateListView(self) + self._delegate = _BeamlineStatePillDelegate(self) + self._view.setModel(self._model) + self._view.setItemDelegate(self._delegate) + + layout = QVBoxLayout(self) + layout.setContentsMargins(8, 8, 8, 8) + layout.setSpacing(6) + layout.addWidget(self._toolbar) + layout.addWidget(self._empty_label) + layout.addWidget(self._view, 1) + self._hidden_summary = QToolButton(self) + self._hidden_summary.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) + self._hidden_summary.setCheckable(True) + self._hidden_summary.clicked.connect(self._toggle_hidden_states) + layout.addWidget(self._hidden_summary) + self.setLayout(layout) + + self.bec_dispatcher.connect_slot( + self.update_available_states, MessageEndpoints.available_beamline_states() + ) + self.refresh_states() + self._refresh_hidden_summary() + + def minimumSizeHint(self) -> QSize: # noqa: N802 + hint = super().minimumSizeHint() + return QSize(0, hint.height()) + + @SafeProperty(bool, default=False) + def idle_card_background(self) -> bool: + """ + Whether idle collapsed pills keep the status-tinted card background. + """ + return self._idle_card_background + + @idle_card_background.setter + def idle_card_background(self, enabled: bool) -> None: + self._idle_card_background = _coerce_bool(enabled) + for pill in self._state_pills.values(): + pill.idle_card_background = self._idle_card_background + + def set_idle_card_background(self, enabled: bool) -> None: + """Set whether idle collapsed pills keep the status-tinted card background.""" + self.idle_card_background = enabled + + def _create_toolbar(self) -> ModularToolBar: + toolbar = ModularToolBar(parent=self) + + add_state = MaterialIconAction("add", "Add beamline state", filled=True, parent=self) + filter_states = MaterialIconAction( + "filter_alt", "Filter displayed state status", filled=True, parent=self + ) + filter_devices = MaterialIconAction( + "devices", "Filter displayed devices", filled=True, parent=self + ) + clear_filters = MaterialIconAction( + "filter_alt_off", "Clear beamline state filters", filled=True, parent=self + ) + refresh = MaterialIconAction( + "restart_alt", "Refresh beamline states", filled=True, parent=self + ) + + add_state.action.triggered.connect(self.open_add_state_dialog) + filter_states.action.triggered.connect(self.open_status_filter_dialog) + filter_devices.action.triggered.connect(self.open_device_filter_dialog) + clear_filters.action.triggered.connect(self.clear_filters) + refresh.action.triggered.connect(self.refresh_states) + + toolbar.components.add_safe("add_state", add_state) + toolbar.components.add_safe("filter_states", filter_states) + toolbar.components.add_safe("filter_devices", filter_devices) + toolbar.components.add_safe("clear_filters", clear_filters) + toolbar.components.add_safe("refresh", refresh) + + bundle = ToolbarBundle("beamline_state_manager", toolbar.components) + bundle.add_action("add_state") + bundle.add_action("filter_states") + bundle.add_action("filter_devices") + bundle.add_action("clear_filters") + bundle.add_action("refresh") + toolbar.add_bundle(bundle) + toolbar.show_bundles(["beamline_state_manager"]) + return toolbar + + @SafeSlot(str) + def apply_theme(self, _theme: str) -> None: + colors = BeamlineStatePill._state_colors("unknown") + self.setStyleSheet( + "BeamlineStateManager { border: none; }" + "QToolButton#hidden_states_summary {" + f"background-color: {colors['background']};" + f"border: 1px solid {colors['border']};" + "border-radius: 6px;" + "padding: 6px;" + "text-align: left;" + "}" + ) + for pill in self._state_pills.values(): + pill.apply_theme(_theme) + self._refresh_hidden_summary() + + @SafeSlot() + def open_add_state_dialog(self) -> None: + dialog = AddBeamlineStateDialog(self, client=self.client) + config = None + try: + accepted = dialog.exec() == QDialog.Accepted + if accepted: + config = dialog.config_result + finally: + dialog.cleanup() + dialog.deleteLater() + + if config is None: + return + beamline_states = getattr(self.client, "beamline_states", None) + if beamline_states is None: + QMessageBox.warning( + self, "Cannot Add State", "BEC client has no beamline state manager." + ) + return + try: + beamline_states.add(config) + except Exception as exc: + QMessageBox.warning(self, "Cannot Add State", str(exc)) + + @SafeSlot() + def open_status_filter_dialog(self) -> None: + dialog = StatusFilterDialog(self._selected_statuses, self) + if dialog.exec() != QDialog.Accepted: + return + self._selected_statuses = dialog.selected_statuses() + self._apply_filters() + + @SafeSlot() + def open_device_filter_dialog(self) -> None: + devices = sorted( + { + device + for state in self._state_configs.values() + if (device := self._state_device(state)) is not None + } + ) + dialog = DeviceFilterDialog(devices, self._selected_devices, self._device_filter_text, self) + if dialog.exec() != QDialog.Accepted: + return + self._selected_devices = dialog.selected_devices() + self._device_filter_text = dialog.filter_text() + self._apply_filters() + + @SafeSlot() + def clear_filters(self) -> None: + self._selected_statuses = None + self._selected_devices = None + self._device_filter_text = "" + self._hidden_expanded = False + self._apply_filters() + + @SafeSlot() + def refresh_states(self) -> None: + """Fetch the latest cached available beamline states and update the list immediately.""" + msg_container = self.client.connector.get_last(MessageEndpoints.available_beamline_states()) + if not msg_container: + return + data = msg_container.get("data") if isinstance(msg_container, dict) else None + content = getattr(data, "content", data) + if isinstance(content, dict): + self.update_available_states(content, getattr(data, "metadata", {})) + + @SafeSlot(dict, dict) + def update_available_states( + self, content: dict[str, Any], _metadata: dict[str, Any] | None = None + ) -> None: + """Update the displayed pills from ``AvailableBeamlineStatesMessage`` content.""" + expanded_names = {name for name, pill in self._state_pills.items() if pill.is_expanded()} + states = content.get("states", []) + state_configs = [self._state_config_to_dict(state) for state in states] + state_configs = [state for state in state_configs if state.get("name")] + if state_configs == [self._state_configs.get(name) for name in self._state_order]: + self._apply_filters() + return + self._state_configs = {str(state["name"]): state for state in state_configs} + self._state_order = [str(state["name"]) for state in state_configs] + self._model.set_states(state_configs) + self._open_persistent_editors(expanded_names) + self._apply_filters() + + def _open_persistent_editors(self, expanded_names: set[str] | None = None) -> None: + expanded_names = expanded_names or set() + for row in range(self._model.rowCount()): + index = self._model.index(row, 0) + self._view.openPersistentEditor(index) + name = str(index.data(_BeamlineStateListModel.NameRole)) + pill = self._state_pills.get(name) + if pill is not None: + pill.set_expanded(name in expanded_names) + self._sync_pill_item_size(name) + + def _apply_filters(self) -> None: + visible_names = [] + hidden_names = [] + for name in self._state_order: + if self._is_state_visible(name): + visible_names.append(name) + else: + hidden_names.append(name) + + visible_set = set(visible_names) + show_hidden = self._hidden_expanded and bool(hidden_names) + for row, name in enumerate(self._state_order): + hidden_by_filter = name not in visible_set + self._view.setRowHidden(row, hidden_by_filter and not show_hidden) + self._sync_pill_item_size(name) + self._empty_label.setVisible( + not visible_names and not (self._hidden_expanded and hidden_names) + ) + self._view.setVisible(bool(visible_names) or (self._hidden_expanded and bool(hidden_names))) + self._refresh_hidden_summary(hidden_count=len(hidden_names)) + + def _sync_pill_item_size(self, name: str) -> None: + index = self._model.index_for_name(name) + if not index.isValid(): + return + self._model.dataChanged.emit(index, index, [Qt.ItemDataRole.SizeHintRole]) + self._view.update(index) + + def _is_state_visible(self, name: str) -> bool: + pill = self._state_pills.get(name) + if self._selected_statuses is not None and ( + pill is None or pill._status not in self._selected_statuses + ): + return False + + device = self._state_device(self._state_configs.get(name, {})) + if self._selected_devices is not None and device not in self._selected_devices: + return False + + tokens = [ + token.strip().casefold() + for token in self._device_filter_text.split(",") + if token.strip() + ] + if tokens: + if device is None: + return False + device_lower = device.casefold() + if not any(token in device_lower for token in tokens): + return False + return True + + @SafeSlot(str, str, str) + def _on_pill_state_changed(self, _name: str, _status: str, _label: str) -> None: + if self._selected_statuses is not None: + self._apply_filters() + + @SafeSlot(str, object) + def _update_state_parameters(self, state_name: str, config: object) -> None: + beamline_states = getattr(self.client, "beamline_states", None) + state_client = getattr(beamline_states, state_name, None) if beamline_states else None + if state_client is None or not hasattr(state_client, "update_parameters"): + QMessageBox.warning( + self, "Cannot Update State", f"Beamline state '{state_name}' is not available." + ) + return + try: + parameters = _update_parameters_from_config(config) + state_client.update_parameters(**parameters) + except Exception as exc: + QMessageBox.warning(self, "Cannot Update State", str(exc)) + return + pill = self._state_pills.get(state_name) + if pill is not None: + pill.mark_current_settings_clean(config) + + @SafeSlot(str) + def _remove_state_requested(self, state_name: str) -> None: + reply = QMessageBox.question( + self, + "Remove Beamline State", + f"Remove beamline state '{state_name}'?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + + beamline_states = getattr(self.client, "beamline_states", None) + if beamline_states is None or not hasattr(beamline_states, "delete"): + QMessageBox.warning( + self, "Cannot Remove State", "BEC client has no beamline state manager." + ) + return + try: + beamline_states.delete(state_name) + except Exception as exc: + QMessageBox.warning(self, "Cannot Remove State", str(exc)) + + @SafeSlot(bool) + def _toggle_hidden_states(self, checked: bool | None = None) -> None: + if checked is None: + checked = self._hidden_summary.isChecked() + self._hidden_expanded = bool(checked) + self._apply_filters() + + def _refresh_hidden_summary(self, hidden_count: int | None = None) -> None: + if hidden_count is None: + hidden_count = sum(1 for name in self._state_order if not self._is_state_visible(name)) + self._hidden_summary.setObjectName("hidden_states_summary") + self._hidden_summary.setVisible(hidden_count > 0) + self._hidden_summary.setChecked(self._hidden_expanded and hidden_count > 0) + icon_name = "expand_less" if self._hidden_expanded else "expand_more" + self._hidden_summary.setIcon(material_icon(icon_name, convert_to_pixmap=False)) + suffix = "state is" if hidden_count == 1 else "states are" + action = "Hide" if self._hidden_expanded else "Show" + self._hidden_summary.setText( + f"{hidden_count} {suffix} hidden by filters. {action} hidden states." + ) + + @staticmethod + def _state_device(state: dict[str, Any]) -> str | None: + parameters = state.get("parameters") + if isinstance(parameters, dict): + device = parameters.get("device") + else: + device = state.get("device") + return str(device) if device else None + + @staticmethod + def _state_config_to_dict(state: Any) -> dict[str, Any]: + if isinstance(state, dict): + return state + if hasattr(state, "model_dump"): + state_dict = state.model_dump() + state_type = getattr(state, "state_type", None) + if state_type is not None: + state_dict.setdefault("state_type", state_type) + return state_dict + return {"name": getattr(state, "name", None), "title": getattr(state, "title", None)} + + def cleanup(self) -> None: + self.bec_dispatcher.disconnect_slot( + self.update_available_states, MessageEndpoints.available_beamline_states() + ) + for row in range(self._model.rowCount()): + self._view.closePersistentEditor(self._model.index(row, 0)) + for pill in list(self._state_pills.values()): + pill.cleanup() + pill.deleteLater() + self._state_pills.clear() + self._toolbar.components.cleanup() + super().cleanup() + + +if __name__ == "__main__": # pragma: no cover + app = QApplication(sys.argv) + + from bec_widgets.utils.colors import apply_theme + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton + + apply_theme("dark") + + window = QWidget() + window.setWindowTitle("Beamline States") + layout = QVBoxLayout(window) + layout.setContentsMargins(12, 12, 12, 12) + layout.setSpacing(8) + + theme_row = QHBoxLayout() + theme_row.addStretch(1) + theme_row.addWidget(DarkModeButton(parent=window)) + layout.addLayout(theme_row) + + manager = BeamlineStateManager(parent=window) + idle_background = QCheckBox("Idle card background", window) + idle_background.toggled.connect( + lambda checked: setattr(manager, "idle_card_background", checked) + ) + + options_row = QHBoxLayout() + options_row.addWidget(idle_background) + options_row.addStretch(1) + layout.addLayout(options_row) + layout.addWidget(manager, 1) + + window.resize(760, 480) + window.show() + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/services/beamline_states/beamline_state_pill.pyproject b/bec_widgets/widgets/services/beamline_states/beamline_state_pill.pyproject new file mode 100644 index 00000000..340bae9a --- /dev/null +++ b/bec_widgets/widgets/services/beamline_states/beamline_state_pill.pyproject @@ -0,0 +1 @@ +{'files': ['beamline_state_pill.py']} \ No newline at end of file diff --git a/bec_widgets/widgets/services/beamline_states/beamline_state_pill_plugin.py b/bec_widgets/widgets/services/beamline_states/beamline_state_pill_plugin.py new file mode 100644 index 00000000..537d2279 --- /dev/null +++ b/bec_widgets/widgets/services/beamline_states/beamline_state_pill_plugin.py @@ -0,0 +1,57 @@ +# Copyright (C) 2022 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause + +from qtpy.QtDesigner import QDesignerCustomWidgetInterface +from qtpy.QtWidgets import QWidget + +from bec_widgets.utils.bec_designer import designer_material_icon +from bec_widgets.widgets.services.beamline_states.beamline_state_pill import BeamlineStatePill + +DOM_XML = """ + + + + +""" + + +class BeamlineStatePillPlugin(QDesignerCustomWidgetInterface): # pragma: no cover + def __init__(self): + super().__init__() + self._form_editor = None + + def createWidget(self, parent): + if parent is None: + return QWidget() + t = BeamlineStatePill(parent) + return t + + def domXml(self): + return DOM_XML + + def group(self): + return "" + + def icon(self): + return designer_material_icon(BeamlineStatePill.ICON_NAME) + + def includeFile(self): + return "beamline_state_pill" + + def initialize(self, form_editor): + self._form_editor = form_editor + + def isContainer(self): + return False + + def isInitialized(self): + return self._form_editor is not None + + def name(self): + return "BeamlineStatePill" + + def toolTip(self): + return "" + + def whatsThis(self): + return self.toolTip() diff --git a/bec_widgets/widgets/services/beamline_states/dialogs.py b/bec_widgets/widgets/services/beamline_states/dialogs.py new file mode 100644 index 00000000..ebcef36f --- /dev/null +++ b/bec_widgets/widgets/services/beamline_states/dialogs.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +import slugify +from bec_lib import bl_states +from qtpy.QtWidgets import ( + QCheckBox, + QComboBox, + QDialog, + QDialogButtonBox, + QFormLayout, + QGroupBox, + QHBoxLayout, + QLineEdit, + QMessageBox, + QPushButton, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.forms_from_types.pydantic_widget_form import PydanticWidgetForm +from bec_widgets.utils.name_utils import pascal_to_snake +from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox + +BEAMLINE_STATE_STATUS_LABELS = { + "valid": "VALID", + "invalid": "INVALID", + "warning": "WARNING", + "unknown": "UNKNOWN", +} + +SUPPORTED_BEAMLINE_STATES: tuple[type[bl_states.BeamlineState], ...] = ( + bl_states.DeviceWithinLimitsState, + bl_states.ShutterState, +) + + +class AddBeamlineStateDialog(QDialog): + """Dialog for creating supported beamline state configurations.""" + + def __init__(self, parent: QWidget | None = None, client=None) -> None: + super().__init__(parent=parent) + self.setWindowTitle("Add Beamline State") + self._cleaned_up = False + self._client = client + self._config: bl_states.BeamlineStateConfig | None = None + self._auto_generated_name: str | None = None + + self._type_combo = QComboBox(self) + for state_class in SUPPORTED_BEAMLINE_STATES: + self._type_combo.addItem(state_class.__name__, state_class) + self._type_combo.currentIndexChanged.connect(self._update_config_form) + + self._form = QFormLayout() + self._form.addRow("State type", self._type_combo) + self._config_form_host = QVBoxLayout() + self._config_form: PydanticWidgetForm | None = None + + self._buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self + ) + self._buttons.accepted.connect(self.accept) + self._buttons.rejected.connect(self.reject) + + layout = QVBoxLayout(self) + layout.addLayout(self._form) + layout.addLayout(self._config_form_host) + layout.addWidget(self._buttons) + self.setLayout(layout) + self.setMinimumWidth(280) + self._update_config_form() + self._fit_height_to_contents() + + def config(self) -> bl_states.BeamlineStateConfig: + state_class = self._selected_state_class() + config_class = state_class.CONFIG_CLASS + name = self._state_name() + data = self._config_form.get_data() + data["name"] = name + return config_class.model_validate(data) + + def accept(self) -> None: + try: + self._config = self.config() + except Exception as exc: + QMessageBox.warning(self, "Invalid Beamline State", str(exc)) + return + super().accept() + + @property + def config_result(self) -> bl_states.BeamlineStateConfig: + if self._config is None: + raise RuntimeError("Beamline state dialog was not accepted with a valid config.") + return self._config + + def cleanup(self) -> None: + if self._cleaned_up: + return + self._cleaned_up = True + if self._config_form is not None: + self._config_form.cleanup() + self._config_form.close() + self._config_form.deleteLater() + + def closeEvent(self, event) -> None: # noqa: N802 + self.cleanup() + super().closeEvent(event) + + @SafeSlot(str) + def _on_valid_device_selected(self, device: str) -> None: + if self._cleaned_up: + return + name_widget = self._config_form.input_widget("name") + current_name = name_widget.text().strip() + if current_name and current_name != self._auto_generated_name: + return + suffix = slugify.slugify( + pascal_to_snake(self._selected_state_class().__name__), separator="_" + ) + generated_name = f"{slugify.slugify(device, separator='_')}_{suffix}" + self._auto_generated_name = generated_name + name_widget.setText(generated_name) + + @SafeSlot(int) + def _update_config_form(self, _index: int = 0) -> None: + previous_data = self._config_form.raw_data() if self._config_form is not None else {} + if self._config_form is not None: + self._config_form_host.removeWidget(self._config_form) + self._config_form.cleanup() + self._config_form.setParent(None) + self._config_form.deleteLater() + config_class = self._selected_state_class().CONFIG_CLASS + data = { + key: value + for key, value in previous_data.items() + if key in config_class.model_fields and value is not None + } + self._config_form = PydanticWidgetForm(config_class, parent=self, client=self._client) + self._config_form.set_partial_data(data) + self._config_form_host.addWidget(self._config_form) + for device_widget in self._config_form.input_widgets_by_type(DeviceComboBox): + device_widget.device_selected.connect(self._on_valid_device_selected) + self._fit_height_to_contents() + + def _fit_height_to_contents(self) -> None: + self.setMinimumHeight(0) + self.setMaximumHeight(16777215) + self.layout().activate() + self.adjustSize() + height = self.sizeHint().expandedTo(self.minimumSizeHint()).height() + self.setMinimumHeight(height) + self.setMaximumHeight(height) + + def _selected_state_class(self) -> type[bl_states.BeamlineState]: + state_class = self._type_combo.currentData() + if state_class is None: + raise RuntimeError("No beamline state class selected.") + return state_class + + def _state_name(self) -> str: + name_widget = self._config_form.input_widget("name") + raw_name = name_widget.text().strip() + if not raw_name: + raise ValueError("Name is required.") + name = slugify.slugify(raw_name, separator="_") + name_widget.setText(name) + return name + + +class StatusFilterDialog(QDialog): + """Dialog for selecting visible beamline state statuses.""" + + def __init__(self, selected_statuses: set[str] | None, parent: QWidget | None = None) -> None: + super().__init__(parent=parent) + self.setWindowTitle("Filter Beamline State Status") + self._checkboxes: dict[str, QCheckBox] = {} + + controls = QHBoxLayout() + select_all = QPushButton("Select all", self) + clear = QPushButton("Clear", self) + select_all.clicked.connect(lambda: self._set_all(True)) + clear.clicked.connect(lambda: self._set_all(False)) + controls.addWidget(select_all) + controls.addWidget(clear) + controls.addStretch(1) + + list_layout = QVBoxLayout() + for status, label in BEAMLINE_STATE_STATUS_LABELS.items(): + checkbox = QCheckBox(label, self) + checkbox.setChecked(selected_statuses is None or status in selected_statuses) + self._checkboxes[status] = checkbox + list_layout.addWidget(checkbox) + list_layout.addStretch(1) + + box = QGroupBox("Displayed status", self) + box.setLayout(list_layout) + + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + + layout = QVBoxLayout(self) + layout.addLayout(controls) + layout.addWidget(box) + layout.addWidget(buttons) + self.setLayout(layout) + + def selected_statuses(self) -> set[str] | None: + selected = {status for status, checkbox in self._checkboxes.items() if checkbox.isChecked()} + if selected == set(self._checkboxes): + return None + return selected + + def _set_all(self, checked: bool) -> None: + for checkbox in self._checkboxes.values(): + checkbox.setChecked(checked) + + +class DeviceFilterDialog(QDialog): + """Dialog for filtering beamline states by configured device.""" + + def __init__( + self, + devices: list[str], + selected_devices: set[str] | None, + device_filter_text: str, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent=parent) + self.setWindowTitle("Filter Beamline State Devices") + self._checkboxes: dict[str, QCheckBox] = {} + + self._device_text = QLineEdit(self) + self._device_text.setPlaceholderText("Device name or comma-separated names") + self._device_text.setText(device_filter_text) + + list_layout = QVBoxLayout() + for device in devices: + checkbox = QCheckBox(device, self) + checkbox.setChecked(selected_devices is not None and device in selected_devices) + self._checkboxes[device] = checkbox + list_layout.addWidget(checkbox) + list_layout.addStretch(1) + + box = QGroupBox("Known devices", self) + box.setLayout(list_layout) + + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + + layout = QVBoxLayout(self) + layout.addWidget(self._device_text) + layout.addWidget(box) + layout.addWidget(buttons) + self.setLayout(layout) + + def selected_devices(self) -> set[str] | None: + selected = {device for device, checkbox in self._checkboxes.items() if checkbox.isChecked()} + return selected or None + + def filter_text(self) -> str: + return self._device_text.text().strip() diff --git a/bec_widgets/widgets/services/beamline_states/register_beamline_state_manager.py b/bec_widgets/widgets/services/beamline_states/register_beamline_state_manager.py new file mode 100644 index 00000000..7695645a --- /dev/null +++ b/bec_widgets/widgets/services/beamline_states/register_beamline_state_manager.py @@ -0,0 +1,17 @@ +def main(): # pragma: no cover + from qtpy import PYSIDE6 + + if not PYSIDE6: + print("PYSIDE6 is not available in the environment. Cannot patch designer.") + return + from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection + + from bec_widgets.widgets.services.beamline_states.beamline_state_manager_plugin import ( + BeamlineStateManagerPlugin, + ) + + QPyDesignerCustomWidgetCollection.addCustomWidget(BeamlineStateManagerPlugin()) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/bec_widgets/widgets/services/beamline_states/register_beamline_state_pill.py b/bec_widgets/widgets/services/beamline_states/register_beamline_state_pill.py new file mode 100644 index 00000000..5c032ab5 --- /dev/null +++ b/bec_widgets/widgets/services/beamline_states/register_beamline_state_pill.py @@ -0,0 +1,17 @@ +def main(): # pragma: no cover + from qtpy import PYSIDE6 + + if not PYSIDE6: + print("PYSIDE6 is not available in the environment. Cannot patch designer.") + return + from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection + + from bec_widgets.widgets.services.beamline_states.beamline_state_pill_plugin import ( + BeamlineStatePillPlugin, + ) + + QPyDesignerCustomWidgetCollection.addCustomWidget(BeamlineStatePillPlugin()) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/pyproject.toml b/pyproject.toml index 8662e11b..e49870e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ "pydantic~=2.0", "pylsp-bec~=1.2", "pyqtgraph==0.13.7", + "python-slugify~=8.0", "qtconsole~=5.5, >=5.5.1", # needed for jupyter console "qtmonaco~=0.8, >=0.8.1", "qtpy~=2.4", diff --git a/tests/unit_tests/test_beamline_state_pill.py b/tests/unit_tests/test_beamline_state_pill.py new file mode 100644 index 00000000..004a303f --- /dev/null +++ b/tests/unit_tests/test_beamline_state_pill.py @@ -0,0 +1,696 @@ +from typing import Any, Generator + +import pytest +import shiboken6 +from bec_lib import bl_states +from qtpy.QtCore import QCoreApplication, QEvent, Qt +from qtpy.QtWidgets import QMessageBox, QStyleOptionViewItem + +from bec_widgets.utils.toolbars.toolbar import ModularToolBar +from bec_widgets.utils.widget_io import WidgetIO +from bec_widgets.widgets.services.beamline_states import beamline_state_pill as pill_module +from bec_widgets.widgets.services.beamline_states.beamline_state_pill import ( + BeamlineStateManager, + BeamlineStatePill, +) +from bec_widgets.widgets.services.beamline_states.dialogs import AddBeamlineStateDialog + +from .client_mocks import mocked_client + + +@pytest.fixture +def pill(qtbot, mocked_client) -> Generator[BeamlineStatePill, Any, None]: + widget = BeamlineStatePill(state_name="shutter_open", title="Shutter", client=mocked_client) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +def test_beamline_state_pill_updates_from_message(pill): + pill.update_state({"name": "shutter_open", "status": "valid", "label": "Shutter is open."}, {}) + + assert pill._state_name == "shutter_open" + assert pill._name_label.text() == "Shutter" + assert pill._status_label.text() == "VALID" + assert pill._detail_label.text() == "Shutter is open." + assert not pill._icon_label.pixmap().isNull() + assert pill.toolTip() == "Shutter is open." + + +def test_beamline_state_pill_ignores_other_states(pill): + pill.update_state( + {"name": "other_state", "status": "invalid", "label": "Should be ignored."}, {} + ) + + assert pill._status_label.text() == "UNKNOWN" + assert pill.toolTip() == "No state information available." + + +def test_beamline_state_pill_expands_and_emits_updated_limits(qtbot, mocked_client): + widget = BeamlineStatePill(state_name="limits", title="Limits", client=mocked_client) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + widget.set_state_config( + { + "name": "limits", + "title": "Limits", + "state_type": "DeviceWithinLimitsState", + "parameters": { + "name": "limits", + "title": "Limits", + "device": "samx", + "signal": "samx", + "low_limit": 0.0, + "high_limit": 10.0, + "tolerance": 0.1, + }, + } + ) + + assert widget._settings.isHidden() + assert widget._config_form is None + assert not widget._update_button.isEnabled() + assert not widget._revert_button.isEnabled() + + qtbot.mouseClick(widget._header, Qt.MouseButton.LeftButton) + assert widget._config_form is not None + high_limit = widget._config_form.input_widget("high_limit") + high_limit.setValue(20.0) + + assert not widget._settings.isHidden() + assert widget._update_button.isEnabled() + assert widget._revert_button.isEnabled() + assert widget._config_form.field_widget("high_limit").property("beamlineStateDirty") is True + assert widget._config_form.get_data()["device"] == "samx" + assert widget.edited_config().high_limit == 20.0 + + with qtbot.waitSignal(widget.update_requested) as signal: + widget._update_button.click() + + assert signal.args[0] == "limits" + assert isinstance(signal.args[1], bl_states.DeviceWithinLimitsState.CONFIG_CLASS) + assert signal.args[1].device == "samx" + assert signal.args[1].signal == "samx" + assert signal.args[1].low_limit == 0.0 + assert signal.args[1].high_limit == 20.0 + assert signal.args[1].tolerance == 0.1 + assert not widget._settings.isHidden() + + +def test_beamline_state_pill_first_expand_uses_config_class_without_rebuild( + qtbot, mocked_client, monkeypatch +): + set_model_calls = [] + original_set_model = pill_module.PydanticWidgetForm.set_model + + def set_model_spy(self, model, data=None): + set_model_calls.append(model) + return original_set_model(self, model, data=data) + + monkeypatch.setattr(pill_module.PydanticWidgetForm, "set_model", set_model_spy) + widget = BeamlineStatePill(state_name="limits", title="Limits", client=mocked_client) + qtbot.addWidget(widget) + widget.set_state_config( + { + "name": "limits", + "title": "Limits", + "state_type": "DeviceWithinLimitsState", + "parameters": { + "device": "samx", + "signal": "samx", + "low_limit": 0.0, + "high_limit": 10.0, + "tolerance": 0.1, + }, + } + ) + + widget.set_expanded(True) + assert widget._config_form is not None + assert set_model_calls == [] + + +def test_beamline_state_pill_reverts_changed_settings(qtbot, mocked_client): + widget = BeamlineStatePill(state_name="limits", title="Limits", client=mocked_client) + qtbot.addWidget(widget) + widget.set_state_config( + { + "name": "limits", + "title": "Limits", + "state_type": "DeviceWithinLimitsState", + "parameters": { + "device": "samx", + "signal": "samx", + "low_limit": 0.0, + "high_limit": 10.0, + "tolerance": 0.1, + }, + } + ) + + widget.set_expanded(True) + assert widget._config_form is not None + low_limit = widget._config_form.input_widget("low_limit") + low_limit.setValue(-5.0) + + assert widget._update_button.isEnabled() + assert widget._config_form.field_widget("low_limit").property("beamlineStateDirty") is True + + widget._revert_button.click() + + assert low_limit.value() == 0.0 + assert not widget._update_button.isEnabled() + assert not widget._revert_button.isEnabled() + assert widget._config_form.field_widget("low_limit").property("beamlineStateDirty") is False + + +def test_beamline_state_pill_does_not_override_themed_input_controls(qtbot, mocked_client): + widget = BeamlineStatePill(state_name="limits", title="Limits", client=mocked_client) + qtbot.addWidget(widget) + widget.set_expanded(True) + + stylesheet = widget.styleSheet() + + assert "QAbstractSpinBox" not in stylesheet + assert "QComboBox" not in stylesheet + assert "QCheckBox::indicator" not in stylesheet + + +def test_beamline_state_manager_adds_and_removes_pills(qtbot, mocked_client): + widget = BeamlineStateManager(client=mocked_client) + qtbot.addWidget(widget) + + widget.update_available_states( + { + "states": [ + messages.BeamlineStateConfig( + name="shutter_open", title="Shutter", state_type="ShutterState", parameters={} + ), + { + "name": "limits", + "title": "Limits", + "state_type": "DeviceWithinLimitsState", + "parameters": {}, + }, + ] + }, + {}, + ) + + assert sorted(widget._state_pills) == ["limits", "shutter_open"] + assert widget._model.rowCount() == 2 + assert widget._state_pills["shutter_open"]._name_label.text() == "Shutter" + assert not widget._empty_label.isVisible() + + widget.update_available_states( + { + "states": [ + { + "name": "limits", + "title": "Limits", + "state_type": "DeviceWithinLimitsState", + "parameters": {}, + } + ] + }, + {}, + ) + + assert sorted(widget._state_pills) == ["limits"] + assert widget._model.rowCount() == 1 + + +def test_beamline_state_manager_ignores_unchanged_available_states(qtbot, mocked_client): + widget = BeamlineStateManager(client=mocked_client) + qtbot.addWidget(widget) + content = { + "states": [ + { + "name": "limits", + "title": "Limits", + "state_type": "DeviceWithinLimitsState", + "parameters": { + "device": "samx", + "signal": "samx", + "low_limit": 0.0, + "high_limit": 10.0, + "tolerance": 0.1, + }, + } + ] + } + + widget.update_available_states(content, {}) + pill = widget._state_pills["limits"] + + widget.update_available_states(content, {}) + + assert widget._state_pills["limits"] is pill + assert pill._config_form is None + + +def test_beamline_state_manager_adds_state_without_recreating_existing_pills(qtbot, mocked_client): + widget = BeamlineStateManager(client=mocked_client) + qtbot.addWidget(widget) + limits_state = { + "name": "limits", + "title": "Limits", + "state_type": "DeviceWithinLimitsState", + "parameters": { + "device": "samx", + "signal": "samx", + "low_limit": 0.0, + "high_limit": 10.0, + "tolerance": 0.1, + }, + } + shutter_state = { + "name": "shutter_open", + "title": "Shutter", + "state_type": "ShutterState", + "parameters": {}, + } + + widget.update_available_states({"states": [limits_state]}, {}) + pill = widget._state_pills["limits"] + pill.set_expanded(True) + config_form = pill._config_form + + widget.update_available_states({"states": [limits_state, shutter_state]}, {}) + + assert widget._state_pills["limits"] is pill + assert pill._config_form is config_form + assert pill.is_expanded() + assert sorted(widget._state_pills) == ["limits", "shutter_open"] + + +def test_beamline_state_manager_does_not_force_horizontal_minimum(qtbot, mocked_client): + widget = BeamlineStateManager(client=mocked_client) + qtbot.addWidget(widget) + + widget.update_available_states( + { + "states": [ + { + "name": "limits", + "title": "Limits", + "state_type": "DeviceWithinLimitsState", + "parameters": { + "device": "samx", + "signal": "samx", + "low_limit": 0.0, + "high_limit": 10.0, + "tolerance": 0.1, + }, + } + ] + }, + {}, + ) + + index = widget._model.index_for_name("limits") + hint = widget._delegate.sizeHint(QStyleOptionViewItem(), index) + + assert widget.minimumWidth() == 0 + assert widget.minimumSizeHint().width() == 0 + assert widget._view.minimumWidth() == 0 + assert widget._view.minimumSizeHint().width() == 0 + assert hint.width() == 0 + + +def test_beamline_state_manager_header_click_expands_pill_once(qtbot, mocked_client): + widget = BeamlineStateManager(client=mocked_client) + qtbot.addWidget(widget) + widget.update_available_states( + { + "states": [ + { + "name": "limits", + "title": "Limits", + "state_type": "DeviceWithinLimitsState", + "parameters": {"device": "samx"}, + } + ] + }, + {}, + ) + + pill = widget._state_pills["limits"] + assert pill._settings.isHidden() + + qtbot.mouseClick(pill._header, Qt.MouseButton.LeftButton) + + assert not pill._settings.isHidden() + + +def test_beamline_state_manager_preserves_expanded_pill_on_refresh(qtbot, mocked_client): + widget = BeamlineStateManager(client=mocked_client) + qtbot.addWidget(widget) + state = { + "name": "limits", + "title": "Limits", + "state_type": "DeviceWithinLimitsState", + "parameters": {"device": "samx", "high_limit": 10.0}, + } + widget.update_available_states({"states": [state]}, {}) + + widget._state_pills["limits"].set_expanded(True) + widget.update_available_states({"states": [state]}, {}) + + assert widget._state_pills["limits"].is_expanded() + assert not widget._state_pills["limits"]._settings.isHidden() + + +def test_beamline_state_manager_propagates_idle_card_background(qtbot, mocked_client): + widget = BeamlineStateManager(client=mocked_client, idle_card_background=True) + qtbot.addWidget(widget) + + widget.update_available_states( + { + "states": [ + { + "name": "limits", + "title": "Limits", + "state_type": "DeviceWithinLimitsState", + "parameters": {"device": "samx"}, + } + ] + }, + {}, + ) + + assert widget._state_pills["limits"]._idle_card_background is True + + widget.idle_card_background = False + + assert widget._state_pills["limits"]._idle_card_background is False + + +def test_beamline_state_manager_filters_status(qtbot, mocked_client): + widget = BeamlineStateManager(client=mocked_client) + qtbot.addWidget(widget) + + widget.update_available_states( + { + "states": [ + { + "name": "shutter_open", + "title": "Shutter", + "state_type": "ShutterState", + "parameters": {"device": "samy"}, + }, + { + "name": "limits", + "title": "Limits", + "state_type": "DeviceWithinLimitsState", + "parameters": {"device": "samx"}, + }, + ] + }, + {}, + ) + + assert isinstance(widget._toolbar, ModularToolBar) + + widget._state_pills["limits"].update_state( + {"name": "limits", "status": "valid", "label": "Within limits."}, {} + ) + widget._state_pills["shutter_open"].update_state( + {"name": "shutter_open", "status": "invalid", "label": "Closed."}, {} + ) + widget._selected_statuses = {"valid"} + widget._apply_filters() + + assert not widget._hidden_summary.isHidden() + assert "1 state is hidden" in widget._hidden_summary.text() + assert not widget._view.isRowHidden(widget._model.index_for_name("limits").row()) + assert widget._view.isRowHidden(widget._model.index_for_name("shutter_open").row()) + + widget._hidden_summary.click() + + assert not widget._view.isRowHidden(widget._model.index_for_name("shutter_open").row()) + assert shiboken6.isValid(widget._state_pills["shutter_open"]) + + widget._hidden_summary.click() + + assert widget._view.isRowHidden(widget._model.index_for_name("shutter_open").row()) + assert shiboken6.isValid(widget._state_pills["shutter_open"]) + + +def test_beamline_state_manager_status_filter_reacts_to_state_changes(qtbot, mocked_client): + widget = BeamlineStateManager(client=mocked_client) + qtbot.addWidget(widget) + + widget.update_available_states( + { + "states": [ + { + "name": "limits", + "title": "Limits", + "state_type": "DeviceWithinLimitsState", + "parameters": {"device": "samx"}, + } + ] + }, + {}, + ) + + widget._selected_statuses = {"valid"} + widget._state_pills["limits"].update_state( + {"name": "limits", "status": "valid", "label": "Within limits."}, {} + ) + + assert widget._hidden_summary.isHidden() + + widget._state_pills["limits"].update_state( + {"name": "limits", "status": "invalid", "label": "Out of limits."}, {} + ) + + assert not widget._hidden_summary.isHidden() + assert widget._view.isRowHidden(widget._model.index_for_name("limits").row()) + + +def test_beamline_state_manager_filters_devices(qtbot, mocked_client, monkeypatch): + widget = BeamlineStateManager(client=mocked_client) + qtbot.addWidget(widget) + + widget.update_available_states( + { + "states": [ + { + "name": "samx_limits", + "title": "samx", + "state_type": "DeviceWithinLimitsState", + "parameters": {"device": "samx"}, + }, + { + "name": "samy_limits", + "title": "samy", + "state_type": "DeviceWithinLimitsState", + "parameters": {"device": "samy"}, + }, + ] + }, + {}, + ) + + widget._device_filter_text = "samx" + widget._apply_filters() + + assert not widget._hidden_summary.isHidden() + assert "1 state is hidden" in widget._hidden_summary.text() + + captured = {} + + class FakeDeviceFilterDialog: + def __init__(self, devices, selected_devices, device_filter_text, parent): + captured["devices"] = devices + captured["selected_devices"] = selected_devices + captured["device_filter_text"] = device_filter_text + captured["parent"] = parent + + def exec(self): + return 0 + + monkeypatch.setattr(pill_module, "DeviceFilterDialog", FakeDeviceFilterDialog) + + widget.open_device_filter_dialog() + + assert captured["devices"] == ["samx", "samy"] + assert captured["device_filter_text"] == "samx" + assert captured["parent"] is widget + + +def test_beamline_state_manager_updates_state_parameters(qtbot, mocked_client): + widget = BeamlineStateManager(client=mocked_client) + qtbot.addWidget(widget) + widget.update_available_states( + { + "states": [ + { + "name": "limits", + "title": "Limits", + "state_type": "DeviceWithinLimitsState", + "parameters": { + "device": "samx", + "signal": "samx", + "low_limit": 0.0, + "high_limit": 10.0, + "tolerance": 0.1, + }, + } + ] + }, + {}, + ) + + class StateClient: + def __init__(self): + self.parameters = None + + def update_parameters(self, **kwargs): + self.parameters = kwargs + + class StateManager: + def __init__(self): + self.limits = StateClient() + + mocked_client.beamline_states = StateManager() + pill = widget._state_pills["limits"] + pill.set_expanded(True) + high_limit = pill._config_form.input_widget("high_limit") + high_limit.setValue(20.0) + + assert pill._update_button.isEnabled() + + widget._update_state_parameters("limits", pill.edited_config()) + + assert mocked_client.beamline_states.limits.parameters == { + "title": "Limits", + "device": "samx", + "signal": "samx", + "low_limit": 0.0, + "high_limit": 20.0, + "tolerance": 0.1, + } + assert not pill._update_button.isEnabled() + assert pill._config_form.field_widget("high_limit").property("beamlineStateDirty") is False + + +def test_beamline_state_manager_removes_state(qtbot, mocked_client, monkeypatch): + widget = BeamlineStateManager(client=mocked_client) + qtbot.addWidget(widget) + + class StateManager: + def __init__(self): + self.deleted = None + + def delete(self, state_name): + self.deleted = state_name + + mocked_client.beamline_states = StateManager() + monkeypatch.setattr( + QMessageBox, "question", lambda *args, **kwargs: QMessageBox.StandardButton.Yes + ) + + widget._remove_state_requested("limits") + + assert mocked_client.beamline_states.deleted == "limits" + + +def test_add_beamline_state_dialog_uses_generated_widgets_and_normalizes_name(qtbot, mocked_client): + dialog = AddBeamlineStateDialog(client=mocked_client) + qtbot.addWidget(dialog) + limits_index = dialog._type_combo.findText(bl_states.DeviceWithinLimitsState.__name__) + assert limits_index >= 0 + dialog._type_combo.setCurrentIndex(limits_index) + + assert dialog._config_form.model is bl_states.DeviceWithinLimitsState.CONFIG_CLASS + + name = dialog._config_form.input_widget("name") + title = dialog._config_form.input_widget("title") + device = dialog._config_form.input_widget("device") + signal = dialog._config_form.input_widget("signal") + low_limit = dialog._config_form.field_widget("low_limit") + high_limit = dialog._config_form.field_widget("high_limit") + + name.setText("samx-limits") + title.setText("samx-limits-15") + WidgetIO.set_value(device, "samx") + WidgetIO.set_value(signal, "samx") + low_limit.checkbox.setChecked(True) + high_limit.checkbox.setChecked(True) + high_limit.value_widget.setValue(15.0) + + config = dialog.config() + + assert config.name == "samx_limits" + assert config.title == "samx-limits-15" + assert config.device == "samx" + assert config.signal == "samx" + assert config.low_limit == 0.0 + assert config.high_limit == 15.0 + + +def test_add_beamline_state_dialog_generates_name_only_after_valid_device_selection( + qtbot, mocked_client +): + dialog = AddBeamlineStateDialog(client=mocked_client) + qtbot.addWidget(dialog) + name = dialog._config_form.input_widget("name") + device = dialog._config_form.input_widget("device") + + device.setCurrentText("s") + + assert name.text() == "" + + device.set_device("samx") + + assert name.text() == "samx_device_within_limits_state" + + +def test_add_beamline_state_dialog_switches_state_type_without_collapsing(qtbot, mocked_client): + dialog = AddBeamlineStateDialog(client=mocked_client) + qtbot.addWidget(dialog) + + initial_height = dialog.height() + limits_index = dialog._type_combo.findText("DeviceWithinLimitsState") + assert limits_index >= 0 + shutter_index = dialog._type_combo.findText("ShutterState") + assert shutter_index >= 0 + + dialog._type_combo.setCurrentIndex(shutter_index) + qtbot.wait(0) + + assert dialog._config_form.model is bl_states.DeviceStateConfig + assert dialog._config_form_host.count() == 1 + assert not dialog._config_form.isHidden() + assert not dialog._buttons.isHidden() + assert dialog.sizeHint().height() > dialog._buttons.sizeHint().height() + assert dialog.minimumWidth() == 280 + assert dialog.maximumWidth() > dialog.minimumWidth() + assert dialog.minimumHeight() == dialog.maximumHeight() + + dialog._type_combo.setCurrentIndex(limits_index) + qtbot.wait(0) + + assert dialog._config_form.model is bl_states.DeviceWithinLimitsState.CONFIG_CLASS + assert dialog.height() >= initial_height + assert dialog.minimumHeight() == dialog.maximumHeight() + + +def test_add_beamline_state_dialog_cleanup_deletes_device_widgets(qtbot, mocked_client): + dialog = AddBeamlineStateDialog(client=mocked_client) + qtbot.addWidget(dialog) + device = dialog._config_form.input_widget("device") + signal = dialog._config_form.input_widget("signal") + + dialog.reject() + assert shiboken6.isValid(device) + assert shiboken6.isValid(signal) + + dialog.cleanup() + QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete) + + assert not shiboken6.isValid(device) + assert not shiboken6.isValid(signal) diff --git a/tests/unit_tests/test_name_utils.py b/tests/unit_tests/test_name_utils.py new file mode 100644 index 00000000..665bd5c4 --- /dev/null +++ b/tests/unit_tests/test_name_utils.py @@ -0,0 +1,13 @@ +from bec_widgets.utils.name_utils import pascal_to_snake, sanitize_namespace + + +def test_pascal_to_snake(): + assert pascal_to_snake("DeviceWithinLimitsState") == "device_within_limits_state" + assert pascal_to_snake("BECStatusWidget") == "bec_status_widget" + + +def test_sanitize_namespace(): + assert sanitize_namespace("scan 1 / user") == "scan_1_user" + assert sanitize_namespace(" beamline.state-1 ") == "beamline.state-1" + assert sanitize_namespace(" ") is None + assert sanitize_namespace(None) is None