From 5de5b939e5976977d9fbe0f65334ee1fc659a370 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 29 May 2026 15:01:22 +0200 Subject: [PATCH] feat(beamline_state_manager): new widget --- bec_widgets/cli/client.py | 106 ++ bec_widgets/cli/designer_plugins.py | 10 + .../widgets/containers/dock_area/dock_area.py | 5 + .../containers/main_window/main_window.py | 2 + .../services/beamline_states/__init__.py | 0 .../beamline_state_manager.pyproject | 1 + .../beamline_state_manager_plugin.py | 57 + .../beamline_states/beamline_state_pill.py | 1491 +++++++++++++++++ .../beamline_state_pill.pyproject | 1 + .../beamline_state_pill_plugin.py | 57 + .../services/beamline_states/dialogs.py | 327 ++++ .../register_beamline_state_manager.py | 17 + .../register_beamline_state_pill.py | 17 + tests/unit_tests/test_beamline_state_pill.py | 641 +++++++ tests/unit_tests/test_dock_area.py | 11 +- tests/unit_tests/test_main_widnow.py | 6 +- 16 files changed, 2747 insertions(+), 2 deletions(-) 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 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/containers/dock_area/dock_area.py b/bec_widgets/widgets/containers/dock_area/dock_area.py index 01e0656e..acd03392 100644 --- a/bec_widgets/widgets/containers/dock_area/dock_area.py +++ b/bec_widgets/widgets/containers/dock_area/dock_area.py @@ -385,6 +385,11 @@ class BECDockArea(DockAreaWidget): "bec_shell": (widget_icons["BECShell"], "Add BEC Shell", "BECShell"), "sbb_monitor": (widget_icons["SBBMonitor"], "Add SBB Monitor", "SBBMonitor"), "log_panel": (widget_icons["LogPanel"], "Add LogPanel", "LogPanel"), + "beamline_state_manager": ( + widget_icons["BeamlineStateManager"], + "Add Beamline State Manager", + "BeamlineStateManager", + ), } # Create expandable menu actions (original behavior) diff --git a/bec_widgets/widgets/containers/main_window/main_window.py b/bec_widgets/widgets/containers/main_window/main_window.py index 84b92de7..83a18b04 100644 --- a/bec_widgets/widgets/containers/main_window/main_window.py +++ b/bec_widgets/widgets/containers/main_window/main_window.py @@ -413,6 +413,8 @@ class BECMainWindow(BECWidget, QMainWindow): apply_theme(theme) # emits theme_updated and applies palette globally def event(self, event): + if not isinstance(event, QEvent): + return False if event.type() == QEvent.Type.StatusTip: return True return super().event(event) 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..ef7b28e0 --- /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 "" + + 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..01dba742 --- /dev/null +++ b/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py @@ -0,0 +1,1491 @@ +from __future__ import annotations + +import sys +from typing import Any + +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, + QLineEdit, + 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.error_popups import SafeProperty, SafeSlot +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.control.device_input.device_combobox.device_combobox import DeviceComboBox +from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox +from bec_widgets.widgets.services.beamline_states.dialogs import ( + BEAMLINE_STATE_STATUS_LABELS, + AddBeamlineStateDialog, + DeviceFilterDialog, + StatusFilterDialog, +) +from bec_widgets.widgets.utility.spinbox.decimal_spinbox import BECSpinBox + +__all__ = [ + "AddBeamlineStateDialog", + "BeamlineStateManager", + "BeamlineStatePill", + "DeviceFilterDialog", + "StatusFilterDialog", +] + + +def _coerce_bool(value: Any) -> bool: + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + + +class _BeamlineStatePillHeader(QWidget): + """Header surface responsible for pill click gestures.""" + + clicked = Signal() + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._press_active = False + + def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802 + if event.button() == Qt.MouseButton.LeftButton: + self._press_active = True + event.ignore() + return + super().mousePressEvent(event) + + def mouseReleaseEvent(self, event: QMouseEvent) -> None: # noqa: N802 + clicked = ( + event.button() == Qt.MouseButton.LeftButton + and self._press_active + and self.rect().contains(event.pos()) + ) + self._press_active = False + if clicked: + self.clicked.emit() + event.accept() + return + super().mouseReleaseEvent(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, dict) + 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", + } + _SETTINGS_FIELD_WIDTH = 180 + + 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._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._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._name_value = QLabel(self._settings) + self._title_edit = QLineEdit(self._settings) + self._device_edit = DeviceComboBox(parent=self._settings, client=client) + self._signal_edit = SignalComboBox( + parent=self._settings, client=client, require_device=True + ) + self._low_limit_enabled, self._low_limit = self._create_optional_limit_row() + self._high_limit_enabled, self._high_limit = self._create_optional_limit_row() + self._tolerance = BECSpinBox(self._settings) + self._configure_settings_spinbox(self._tolerance) + self._device_edit.device_selected.connect(self._on_settings_device_selected) + self._device_edit.device_reset.connect(self._on_settings_device_reset) + self._device_edit.currentTextChanged.connect(self._on_text_settings_changed) + self._signal_edit.currentTextChanged.connect(self._on_text_settings_changed) + self._title_edit.textChanged.connect(self._on_text_settings_changed) + self._low_limit_enabled.toggled.connect(self._on_low_limit_enabled_changed) + self._high_limit_enabled.toggled.connect(self._on_high_limit_enabled_changed) + self._low_limit.valueChanged.connect(self._on_numeric_settings_changed) + self._high_limit.valueChanged.connect(self._on_numeric_settings_changed) + self._tolerance.valueChanged.connect(self._on_numeric_settings_changed) + + for field in self._settings_input_fields(): + field.setMinimumWidth(self._SETTINGS_FIELD_WIDTH) + field.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + + 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(12, 8, 12, 8) + 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) + self._settings_form.addRow("Name", self._name_value) + self._settings_form.addRow("Title", self._title_edit) + self._settings_form.addRow("Device", self._device_edit) + self._settings_form.addRow("Signal", self._signal_edit) + self._settings_form.addRow("Low limit", self._low_limit.parentWidget()) + self._settings_form.addRow("High limit", self._high_limit.parentWidget()) + self._settings_form.addRow("Tolerance", self._tolerance) + self._settings_form.addRow(button_layout) + + self._limit_widgets = ( + self._low_limit.parentWidget(), + self._high_limit.parentWidget(), + self._tolerance, + ) + + settings_layout = QVBoxLayout(self._settings) + settings_layout.setContentsMargins(0, 0, 0, 0) + settings_layout.setSpacing(0) + settings_layout.addLayout(self._settings_form) + + 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.setMinimumHeight(58) + self.set_state_name(state_name, title=title) + self._mark_settings_clean_from_current() + + @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._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" + if active_card: + background = self._card_gradient(colors) + border = colors["card_border"] + hover_background = self._card_gradient(colors) + 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 + self._expanded = expanded + self._settings.setVisible(expanded) + self._apply_visual_state() + self.size_hint_changed.emit() + + def _populate_settings(self) -> None: + self._populating_settings = True + try: + state_type = self._state_type() + self._state_type_value.setText(state_type or "-") + self._name_value.setText(self._state_name or "-") + self._title_edit.setText(str(self._state_field("title") or "")) + device = str(self._state_field("device") or "") + signal = str(self._state_field("signal") or "") + self._set_settings_device(device) + self._set_settings_signal(signal) + + show_limits = self._show_limit_settings() + for widget in self._limit_widgets: + self._set_form_row_visible(widget, show_limits) + + if not show_limits: + return + + self._set_optional_limit( + self._low_limit_enabled, self._low_limit, self._state_field("low_limit") + ) + self._set_optional_limit( + self._high_limit_enabled, self._high_limit, self._state_field("high_limit") + ) + tolerance = self._state_field("tolerance") + self._tolerance.setValue(float(tolerance) if tolerance is not None else 0.1) + finally: + self._populating_settings = False + self._update_settings_dirty_state() + + def edited_parameters(self) -> dict[str, Any]: + """Return editable parameters from the expanded settings panel.""" + device = self._device_edit.currentText().strip() + signal = self._optional_signal() + if not device: + raise ValueError("Device is required.") + + params: dict[str, Any] = { + "title": self._optional_text(self._title_edit), + "device": device, + "signal": signal, + } + if self._state_type() == "DeviceWithinLimitsState": + params.update( + { + "low_limit": ( + self._low_limit.value() if self._low_limit_enabled.isChecked() else None + ), + "high_limit": ( + self._high_limit.value() if self._high_limit_enabled.isChecked() else None + ), + "tolerance": self._tolerance.value(), + } + ) + return params + + def mark_current_settings_clean(self, parameters: dict[str, Any] | None = None) -> None: + """Mark the current editor values as saved.""" + if parameters is None: + parameters = self._current_settings_values() + self._update_state_config_parameters(parameters) + self._mark_settings_clean_from_current() + + def _mark_settings_clean_from_current(self) -> None: + self._settings_baseline = self._current_settings_values() + self._update_settings_dirty_state() + + def _show_limit_settings(self) -> bool: + return self._state_type() == "DeviceWithinLimitsState" or any( + self._state_field(key) is not None for key in ("low_limit", "high_limit", "tolerance") + ) + + def _current_settings_values(self) -> dict[str, Any]: + values: dict[str, Any] = { + "title": self._optional_text(self._title_edit), + "device": self._device_edit.currentText().strip(), + "signal": self._optional_signal(), + } + if self._show_limit_settings(): + values.update( + { + "low_limit": ( + self._low_limit.value() if self._low_limit_enabled.isChecked() else None + ), + "high_limit": ( + self._high_limit.value() if self._high_limit_enabled.isChecked() else None + ), + "tolerance": self._tolerance.value(), + } + ) + return values + + def _update_state_config_parameters(self, parameters: dict[str, Any]) -> None: + if not self._state_config: + return + state_parameters = self._state_config.get("parameters") + if isinstance(state_parameters, dict): + state_parameters.update(parameters) + else: + self._state_config.update(parameters) + for key in ("title",): + if key in parameters: + self._state_config[key] = parameters[key] + + @SafeSlot() + def _revert_settings(self) -> None: + self._apply_settings_values(self._settings_baseline) + self._update_settings_dirty_state() + + def _apply_settings_values(self, values: dict[str, Any]) -> None: + self._populating_settings = True + try: + self._title_edit.setText(str(values.get("title") or "")) + device = str(values.get("device") or "") + signal = str(values.get("signal") or "") + self._set_settings_device(device) + self._set_settings_signal(signal) + + if not self._show_limit_settings(): + return + + self._set_optional_limit( + self._low_limit_enabled, self._low_limit, values.get("low_limit") + ) + self._set_optional_limit( + self._high_limit_enabled, self._high_limit, values.get("high_limit") + ) + tolerance = values.get("tolerance") + self._tolerance.setValue(float(tolerance) if tolerance is not None else 0.1) + finally: + self._populating_settings = False + + @SafeSlot(str) + def _on_text_settings_changed(self, _value: str) -> None: + self._update_settings_dirty_state() + + @SafeSlot(float) + def _on_numeric_settings_changed(self, _value: float) -> None: + self._update_settings_dirty_state() + + @SafeSlot(bool) + def _on_low_limit_enabled_changed(self, enabled: bool) -> None: + self._low_limit.setEnabled(enabled) + self._update_settings_dirty_state() + + @SafeSlot(bool) + def _on_high_limit_enabled_changed(self, enabled: bool) -> None: + self._high_limit.setEnabled(enabled) + self._update_settings_dirty_state() + + def _update_settings_dirty_state(self) -> None: + if self._populating_settings: + return + + current_values = self._current_settings_values() + fields = set(current_values) | set(self._settings_baseline) + self._settings_dirty_fields = { + field + for field in fields + if not self._settings_values_equal( + current_values.get(field), self._settings_baseline.get(field) + ) + } + + has_changes = bool(self._settings_dirty_fields) + self._update_button.setEnabled(has_changes) + self._revert_button.setEnabled(has_changes) + self._apply_dirty_field_highlights() + + @staticmethod + def _settings_values_equal(left: Any, right: Any) -> bool: + if left is None or right is None: + return left is None and right is None + if isinstance(left, float) or isinstance(right, float): + return abs(float(left) - float(right)) < 1e-9 + return left == right + + def _apply_dirty_field_highlights(self) -> None: + field_widgets = { + "title": self._title_edit, + "device": self._device_edit, + "signal": self._signal_edit, + "low_limit": self._low_limit.parentWidget(), + "high_limit": self._high_limit.parentWidget(), + "tolerance": self._tolerance, + } + for field, widget in field_widgets.items(): + self._set_dirty_property(widget, field in self._settings_dirty_fields) + + @staticmethod + def _set_dirty_property(widget: QWidget | None, dirty: bool) -> None: + if widget is None or widget.property("beamlineStateDirty") == dirty: + return + widget.setProperty("beamlineStateDirty", dirty) + widget.style().unpolish(widget) + widget.style().polish(widget) + widget.update() + + @SafeSlot() + def _emit_update_requested(self) -> None: + if self._state_name is None: + return + if not self._settings_dirty_fields: + return + try: + parameters = self.edited_parameters() + except ValueError as exc: + QMessageBox.warning(self, "Invalid Beamline State", str(exc)) + return + self.update_requested.emit(self._state_name, parameters) + + @SafeSlot() + def _emit_remove_requested(self) -> None: + if self._state_name is None: + return + self.remove_requested.emit(self._state_name) + + def _state_field(self, name: str) -> Any: + parameters = self._state_config.get("parameters") + if isinstance(parameters, dict) and name in parameters: + return parameters.get(name) + return self._state_config.get(name) + + def _state_type(self) -> str: + return str(self._state_config.get("state_type") or self._state_field("state_type") or "") + + @SafeSlot(str) + def _on_settings_device_selected(self, device: str) -> None: + self._signal_edit.set_device(device) + + @SafeSlot() + def _on_settings_device_reset(self) -> None: + self._signal_edit.set_device(None) + + def _set_settings_device(self, device: str) -> None: + if not device: + self._device_edit.setCurrentText("") + self._signal_edit.set_device(None) + return + self._device_edit.set_device(device) + if self._device_edit.currentText() != device: + self._device_edit.setCurrentText(device) + if self._device_edit.is_valid_input: + self._signal_edit.set_device(device) + + def _set_settings_signal(self, signal: str) -> None: + if not signal: + self._signal_edit.setCurrentText("") + return + self._signal_edit.set_signal(signal) + if ( + self._signal_edit.currentText() != signal + and self._signal_edit.get_signal_name() != signal + ): + self._signal_edit.setCurrentText(signal) + + @staticmethod + def _optional_text(line_edit: QLineEdit) -> str | None: + value = line_edit.text().strip() + return value or None + + def _optional_signal(self) -> str | None: + value = self._signal_edit.get_signal_name().strip() + return value or None + + @staticmethod + def _configure_settings_spinbox(spin_box: BECSpinBox) -> None: + spin_box.setRange(-1_000_000_000, 1_000_000_000) + spin_box.setDecimals(6) + + def _create_optional_limit_row(self) -> tuple[QCheckBox, BECSpinBox]: + container = QWidget(self) + container.setObjectName("beamline_state_limit_row") + container.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + checkbox = QCheckBox(container) + spin_box = BECSpinBox(container) + self._configure_settings_spinbox(spin_box) + spin_box.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + layout = QHBoxLayout(container) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(8) + layout.addWidget(checkbox) + layout.addWidget(spin_box, 1) + return checkbox, spin_box + + @staticmethod + def _set_optional_limit(checkbox: QCheckBox, spin_box: BECSpinBox, value: Any) -> None: + enabled = value is not None + checkbox.setChecked(enabled) + spin_box.setEnabled(enabled) + if enabled: + spin_box.setValue(float(value)) + + def _settings_input_fields(self) -> tuple[QWidget, ...]: + return ( + self._state_type_value, + self._name_value, + self._title_edit, + self._device_edit, + self._signal_edit, + self._low_limit.parentWidget(), + self._high_limit.parentWidget(), + self._tolerance, + ) + + def _set_form_row_visible(self, widget: QWidget, visible: bool) -> None: + widget.setVisible(visible) + label = self._settings_form.labelForField(widget) + if label is not None: + label.setVisible(visible) + + @classmethod + def _state_colors(cls, 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 = cls._is_light_theme(theme, palette) + + foreground = cls._theme_color(theme, "FG", palette.text().color()) + on_primary = cls._theme_color(theme, "ON_PRIMARY", QColor("#ffffff")) + + accents = getattr(theme, "accent_colors", None) + warning = getattr(accents, "warning", QColor("#EAC435")) + accent = { + "valid": getattr(accents, "success", QColor("#2CA58D")), + "invalid": getattr(accents, "emergency", QColor("#CC181E")), + "warning": warning, + "unknown": cls._theme_color(theme, "ACCENT_DEFAULT", QColor("#7a7a7a")), + }.get(status, QColor("#7a7a7a")) + + if not light_theme: + card_bg = cls._theme_color(theme, "CARD_BG", palette.window().color()) + border = cls._theme_color(theme, "BORDER", palette.mid().color()) + return { + "accent": accent.name(), + "on_accent": on_primary.name(), + "card_background": card_bg.name(), + "card_border": cls._blend(border, accent, 0.45).name(), + "gradient_accent": cls._rgba(accent, 62), + "gradient_stop": "0.62", + "background": cls._blend(card_bg, accent, 0.10).name(), + "border": cls._blend(border, accent, 0.35).name(), + "dirty_background": cls._blend(card_bg, warning, 0.18).name(), + "dirty_border": cls._blend(border, warning, 0.70).name(), + "foreground": foreground.name(), + "muted": cls._blend(card_bg, foreground, 0.66).name(), + "shadow": "#00000078", + "shadow_blur": "18", + "shadow_y_offset": "2", + } + + accent = accent.darker(118) + card_bg = cls._theme_color(theme, "CARD_BG", QColor("#ffffff")) + border = cls._theme_color(theme, "BORDER", QColor("#d9e2ec")) + muted = QColor("#667085") + card_border = cls._blend(border, accent, 0.34) + dirty_background = QColor("#fff8db") + dirty_border = cls._blend(border, warning, 0.72) + + return { + "accent": accent.name(), + "on_accent": on_primary.name(), + "card_background": card_bg.name(), + "card_border": card_border.name(), + "gradient_accent": cls._rgba(accent, 18), + "gradient_stop": "0.38", + "background": card_bg.name(), + "border": card_border.name(), + "dirty_background": dirty_background.name(), + "dirty_border": dirty_border.name(), + "foreground": foreground.name(), + "muted": muted.name(), + "shadow": "#00000024", + "shadow_blur": "24", + "shadow_y_offset": "3", + } + + @staticmethod + def _is_light_theme(theme: Any, palette: QPalette) -> bool: + theme_name = str(getattr(theme, "theme", "")).lower() + if theme_name in {"light", "dark"}: + return theme_name == "light" + return palette.window().color().lightness() > 128 + + @staticmethod + def _rgba(color: QColor, alpha: int) -> str: + return f"rgba({color.red()}, {color.green()}, {color.blue()}, {max(0, min(255, alpha))})" + + @staticmethod + def _card_gradient(colors: dict[str, str]) -> str: + return ( + "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']}" + ")" + ) + + @staticmethod + def _theme_color(theme: Any, key: str, fallback: QColor) -> QColor: + if theme is None: + return fallback + color = theme.color(key, fallback.name()) + return color if isinstance(color, QColor) else QColor(str(color)) + + @staticmethod + def _blend(base: QColor, overlay: QColor, overlay_alpha: float) -> QColor: + overlay_alpha = max(0.0, min(1.0, overlay_alpha)) + base_alpha = 1.0 - overlay_alpha + return QColor( + round(base.red() * base_alpha + overlay.red() * overlay_alpha), + round(base.green() * base_alpha + overlay.green() * overlay_alpha), + round(base.blue() * base_alpha + overlay.blue() * overlay_alpha), + ) + + 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) + ) + 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(360, 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: + self.beginResetModel() + self._state_order = [str(state["name"]) for state in state_configs if state.get("name")] + self._state_rows = {name: row for row, name in enumerate(self._state_order)} + self._state_configs = { + str(state["name"]): state for state in state_configs if state.get("name") + } + self.endResetModel() + + 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) + + def name_at(self, row: int) -> str: + return self._state_order[row] + + +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 pill.sizeHint() + return QSize(option.rect.width() if option.rect.width() > 0 else 360, 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.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;" + "}" + ) + + +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._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(), + from_start=True, + ) + self.refresh_states() + self._refresh_hidden_summary() + + @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: + dialog = DeviceFilterDialog( + self._available_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")] + 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) + + self._apply_row_visibility(visible_names, hidden_names) + 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 _apply_row_visibility(self, visible_names: list[str], hidden_names: list[str]) -> None: + 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) + + 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, dict) + def _update_state_parameters(self, state_name: str, parameters: dict[str, Any]) -> 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: + 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(parameters) + + @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." + ) + + def _available_devices(self) -> list[str]: + devices = { + device + for state in self._state_configs.values() + if (device := self._state_device(state)) is not None + } + return sorted(devices) + + @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..cf9611fb --- /dev/null +++ b/bec_widgets/widgets/services/beamline_states/dialogs.py @@ -0,0 +1,327 @@ +from __future__ import annotations + +import re + +from bec_lib import bl_states +from qtpy.QtWidgets import ( + QCheckBox, + QComboBox, + QDialog, + QDialogButtonBox, + QFormLayout, + QGroupBox, + QHBoxLayout, + QLineEdit, + QMessageBox, + QPushButton, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox +from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox +from bec_widgets.widgets.utility.spinbox.decimal_spinbox import BECSpinBox + +BEAMLINE_STATE_STATUS_LABELS = { + "valid": "VALID", + "invalid": "INVALID", + "warning": "WARNING", + "unknown": "UNKNOWN", +} + + +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._config: ( + bl_states.DeviceStateConfig | bl_states.DeviceWithinLimitsStateConfig | None + ) = None + self._auto_generated_name: str | None = None + + self._type_combo = QComboBox(self) + self._type_combo.addItem("Device within limits", "device_within_limits") + self._type_combo.addItem("Shutter", "shutter") + self._type_combo.currentIndexChanged.connect(self._update_field_visibility) + + self._name = QLineEdit(self) + self._name.setPlaceholderText("samx_limits") + self._title = QLineEdit(self) + self._device = DeviceComboBox(parent=self, client=client) + self._signal = SignalComboBox(parent=self, client=client, require_device=True) + self._low_limit = BECSpinBox(self) + self._high_limit = BECSpinBox(self) + self._tolerance = BECSpinBox(self) + self._device.device_selected.connect(self._on_valid_device_selected) + self._device.device_reset.connect(self._on_device_reset) + + for spin_box in (self._low_limit, self._high_limit): + spin_box.setRange(-1_000_000_000, 1_000_000_000) + spin_box.setDecimals(6) + self._low_limit.setValue(0.0) + self._high_limit.setValue(10.0) + self._tolerance.setRange(0.0, 1_000_000_000) + self._tolerance.setDecimals(6) + self._tolerance.setValue(0.1) + for field in self._input_fields(): + field.setFixedWidth(280) + for spin_box in (self._low_limit, self._high_limit, self._tolerance): + spin_box.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + + self._form = QFormLayout() + self._form.addRow("State type", self._type_combo) + self._form.addRow("Name", self._name) + self._form.addRow("Title", self._title) + self._form.addRow("Device", self._device) + self._form.addRow("Signal", self._signal) + self._form.addRow("Low limit", self._low_limit) + self._form.addRow("High limit", self._high_limit) + self._form.addRow("Tolerance", self._tolerance) + + 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.addWidget(self._buttons) + self.setLayout(layout) + self._update_field_visibility() + self.setFixedSize(self.sizeHint().expandedTo(self.minimumSizeHint())) + + def config(self) -> bl_states.DeviceStateConfig | bl_states.DeviceWithinLimitsStateConfig: + state_type = self._type_combo.currentData() + name = self._state_name() + title = self._optional_text(self._title) + device = self._selected_device() + signal = self._selected_signal() + + if state_type == "shutter": + return bl_states.DeviceStateConfig(name=name, title=title, device=device, signal=signal) + + return bl_states.DeviceWithinLimitsStateConfig( + name=name, + title=title, + device=device, + signal=signal, + low_limit=self._low_limit.value(), + high_limit=self._high_limit.value(), + tolerance=self._tolerance.value(), + ) + + 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.DeviceStateConfig | bl_states.DeviceWithinLimitsStateConfig: + 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 + self._device.close() + self._device.deleteLater() + self._signal.close() + self._signal.deleteLater() + + @SafeSlot(str) + def _on_valid_device_selected(self, device: str) -> None: + if self._cleaned_up: + return + self._signal.set_device(device) + current_name = self._name.text().strip() + if current_name and current_name != self._auto_generated_name: + return + generated_name = f"{self._normalize_identifier(device)}_{self._state_name_suffix()}" + self._auto_generated_name = generated_name + self._name.setText(generated_name) + + @SafeSlot() + def _on_device_reset(self) -> None: + if self._cleaned_up: + return + self._signal.set_device(None) + + @SafeSlot(int) + def _update_field_visibility(self, _index: int = 0) -> None: + show_limits = self._type_combo.currentData() == "device_within_limits" + for widget in (self._low_limit, self._high_limit, self._tolerance): + widget.setVisible(show_limits) + label = self._form.labelForField(widget) + if label is not None: + label.setVisible(show_limits) + + def _state_name(self) -> str: + raw_name = self._name.text().strip() + if not raw_name: + raise ValueError("Name is required.") + name = self._normalize_identifier(raw_name) + self._name.setText(name) + return name + + def _selected_device(self) -> str: + device = self._device.currentText().strip() + if not device: + raise ValueError("Device is required.") + if not self._device.is_valid_input: + raise ValueError(f"Device '{device}' is not available.") + return device + + def _selected_signal(self) -> str | None: + signal = self._signal.get_signal_name().strip() + if not signal: + return None + if not self._signal.is_valid_input: + raise ValueError( + f"Signal '{signal}' is not available for device '{self._device.currentText()}'." + ) + return signal + + @staticmethod + def _optional_text(line_edit: QLineEdit) -> str | None: + value = line_edit.text().strip() + return value or None + + @staticmethod + def _normalize_identifier(value: str) -> str: + name = re.sub(r"\W+", "_", value.strip()) + name = re.sub(r"_+", "_", name).strip("_") + if not name: + raise ValueError("Name must contain at least one letter, number, or underscore.") + if name[0].isdigit(): + name = f"state_{name}" + return name + + def _state_name_suffix(self) -> str: + if self._type_combo.currentData() == "device_within_limits": + return "limits" + return "state" + + def _input_fields(self) -> tuple[QWidget, ...]: + return ( + self._type_combo, + self._name, + self._title, + self._device, + self._signal, + self._low_limit, + self._high_limit, + self._tolerance, + ) + + +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/tests/unit_tests/test_beamline_state_pill.py b/tests/unit_tests/test_beamline_state_pill.py new file mode 100644 index 00000000..50efed90 --- /dev/null +++ b/tests/unit_tests/test_beamline_state_pill.py @@ -0,0 +1,641 @@ +from pathlib import Path + +import shiboken6 +from bec_lib import messages +from qtpy.QtCore import QCoreApplication, QEvent, QPoint, Qt +from qtpy.QtGui import QColor +from qtpy.QtWidgets import QApplication, QMessageBox, QSizePolicy + +from bec_widgets.utils.toolbars.toolbar import ModularToolBar +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, + _BeamlineStatePillHeader, +) +from bec_widgets.widgets.services.beamline_states.dialogs import AddBeamlineStateDialog +from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox +from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox +from bec_widgets.widgets.utility.spinbox.decimal_spinbox import BECSpinBox + +from .client_mocks import mocked_client + + +class _FakeAccentColors: + default = QColor("#0a60ff") + success = QColor("#2CA58D") + emergency = QColor("#CC181E") + warning = QColor("#EAC435") + + +class _FakeTheme: + def __init__(self, theme: str) -> None: + self.theme = theme + self.accent_colors = _FakeAccentColors() + self._colors = { + "CARD_BG": "#ffffff" if theme == "light" else "#171a21", + "FG": "#151924" if theme == "light" else "#e8ebf1", + "BORDER": "#d9dde6" if theme == "light" else "#2a2f3a", + "ON_PRIMARY": "#ffffff", + "ACCENT_DEFAULT": "#0a60ff" if theme == "light" else "#8ab4f7", + } + + def color(self, key: str, fallback: str = "#000000") -> QColor: + return QColor(self._colors.get(key, fallback)) + + +def _gradient_alpha(colors: dict[str, str]) -> int: + return int(colors["gradient_accent"].rsplit(",", 1)[1].strip(" )")) + + +def test_beamline_state_pill_updates_from_message(qtbot, mocked_client): + widget = BeamlineStatePill(state_name="shutter_open", title="Shutter", client=mocked_client) + qtbot.addWidget(widget) + + widget.update_state( + {"name": "shutter_open", "status": "valid", "label": "Shutter is open."}, {} + ) + + assert widget.state_name == "shutter_open" + assert widget._name_label.text() == "Shutter" + assert widget._status_label.text() == "VALID" + assert widget._detail_label.text() == "Shutter is open." + assert not widget._icon_label.pixmap().isNull() + assert widget.toolTip() == "Shutter is open." + + +def test_beamline_state_pill_ignores_other_states(qtbot, mocked_client): + widget = BeamlineStatePill(state_name="shutter_open", client=mocked_client) + qtbot.addWidget(widget) + + widget.update_state( + {"name": "other_state", "status": "invalid", "label": "Should be ignored."}, {} + ) + + assert widget._status_label.text() == "UNKNOWN" + assert widget.toolTip() == "No state information available." + + +def test_beamline_states_init_is_empty(): + assert Path(pill_module.__file__).with_name("__init__.py").read_text() == "" + + +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) + 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 not widget._update_button.isEnabled() + assert not widget._revert_button.isEnabled() + + qtbot.mouseClick(widget._header, Qt.MouseButton.LeftButton) + widget._high_limit.setValue(20.0) + + assert not widget._settings.isHidden() + assert widget._update_button.isEnabled() + assert widget._revert_button.isEnabled() + assert widget._high_limit.parentWidget().property("beamlineStateDirty") is True + assert isinstance(widget._device_edit, DeviceComboBox) + assert isinstance(widget._signal_edit, SignalComboBox) + assert widget._device_edit.currentText() == "samx" + for field in widget._settings_input_fields(): + assert field.minimumWidth() == widget._SETTINGS_FIELD_WIDTH + assert field.sizePolicy().horizontalPolicy() == QSizePolicy.Policy.Expanding + assert widget.edited_parameters()["high_limit"] == 20.0 + + with qtbot.waitSignal(widget.update_requested) as signal: + widget._update_button.click() + + assert signal.args[0] == "limits" + 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_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._low_limit.setValue(-5.0) + + assert widget._update_button.isEnabled() + assert widget._low_limit.parentWidget().property("beamlineStateDirty") is True + + widget._revert_button.click() + + assert widget._low_limit.value() == 0.0 + assert not widget._update_button.isEnabled() + assert not widget._revert_button.isEnabled() + assert widget._low_limit.parentWidget().property("beamlineStateDirty") is False + + +def test_beamline_state_pill_uses_card_style_when_expanded(qtbot, mocked_client): + widget = BeamlineStatePill(state_name="limits", title="Limits", client=mocked_client) + qtbot.addWidget(widget) + + assert "#BeamlineStatePill {background: transparent" in widget.styleSheet() + assert "#BeamlineStatePill:hover {background: qlineargradient" in widget.styleSheet() + + widget._toggle_expanded() + + assert "#BeamlineStatePill {background: qlineargradient" in widget.styleSheet() + assert widget._shadow.isEnabled() + + +def test_beamline_state_pill_can_keep_idle_background(qtbot, mocked_client): + widget = BeamlineStatePill(state_name="limits", title="Limits", client=mocked_client) + qtbot.addWidget(widget) + + assert "#BeamlineStatePill {background: transparent" in widget.styleSheet() + + widget.idle_card_background = True + + assert "#BeamlineStatePill {background: transparent" not in widget.styleSheet() + + +def test_beamline_state_pill_declares_card_style_for_hover(qtbot, mocked_client): + widget = BeamlineStatePill(state_name="limits", title="Limits", client=mocked_client) + qtbot.addWidget(widget) + + assert "#BeamlineStatePill:hover {background: qlineargradient" in widget.styleSheet() + assert not widget._shadow.isEnabled() + + +def test_beamline_state_pill_light_mode_uses_neutral_card_with_subtle_left_gradient( + qtbot, monkeypatch +): + app = QApplication.instance() + monkeypatch.setattr(app, "theme", _FakeTheme("light"), raising=False) + + for status in ("valid", "invalid", "warning"): + colors = BeamlineStatePill._state_colors(status) + + assert _gradient_alpha(colors) == 18 + assert colors["gradient_stop"] == "0.38" + assert colors["card_background"] == "#ffffff" + assert colors["background"] == "#ffffff" + + +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_pill_dark_mode_keeps_existing_gradient_strength(qtbot, monkeypatch): + app = QApplication.instance() + monkeypatch.setattr(app, "theme", _FakeTheme("dark"), raising=False) + + colors = BeamlineStatePill._state_colors("warning") + + assert _gradient_alpha(colors) == 62 + assert colors["gradient_stop"] == "0.62" + + +def test_beamline_state_pill_header_emits_click_without_pointer_move(qtbot): + header = _BeamlineStatePillHeader() + header.resize(120, 32) + qtbot.addWidget(header) + + clicked = [] + header.clicked.connect(lambda: clicked.append(True)) + + qtbot.mousePress(header, Qt.MouseButton.LeftButton, pos=QPoint(8, 8)) + qtbot.mouseRelease(header, Qt.MouseButton.LeftButton, pos=QPoint(8, 8)) + + assert clicked == [True] + + +def test_beamline_state_pill_header_suppresses_click_after_release_outside(qtbot): + header = _BeamlineStatePillHeader() + header.resize(120, 32) + qtbot.addWidget(header) + + clicked = [] + header.clicked.connect(lambda: clicked.append(True)) + + qtbot.mousePress(header, Qt.MouseButton.LeftButton, pos=QPoint(8, 8)) + qtbot.mouseRelease(header, Qt.MouseButton.LeftButton, pos=QPoint(140, 8)) + + assert clicked == [] + + +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_items_are_not_draggable(qtbot, mocked_client): + widget = BeamlineStateManager(client=mocked_client) + qtbot.addWidget(widget) + widget.update_available_states( + { + "states": [ + { + "name": "limits", + "title": "Limits", + "state_type": "DeviceWithinLimitsState", + "parameters": {}, + } + ] + }, + {}, + ) + + flags = widget._model.flags(widget._model.index_for_name("limits")) + + assert not flags & Qt.ItemFlag.ItemIsDragEnabled + + +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): + 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() + assert widget._available_devices() == ["samx", "samy"] + + +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._high_limit.setValue(20.0) + + assert pill._update_button.isEnabled() + + widget._update_state_parameters("limits", pill.edited_parameters()) + + 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._high_limit.parentWidget().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_device_signal_widgets_and_normalizes_name( + qtbot, mocked_client +): + dialog = AddBeamlineStateDialog(client=mocked_client) + qtbot.addWidget(dialog) + + dialog._name.setText("samx-limits") + dialog._title.setText("samx-limits-15") + dialog._device.set_device("samx") + dialog._signal.set_signal("samx") + dialog._high_limit.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 + assert isinstance(dialog._low_limit, BECSpinBox) + assert isinstance(dialog._high_limit, BECSpinBox) + assert dialog._low_limit.width() == dialog._device.width() + + +def test_add_beamline_state_dialog_generates_name_only_after_valid_device_selection( + qtbot, mocked_client +): + dialog = AddBeamlineStateDialog(client=mocked_client) + qtbot.addWidget(dialog) + + dialog._device.setCurrentText("s") + + assert dialog._name.text() == "" + + dialog._device.set_device("samx") + + assert dialog._name.text() == "samx_limits" + + +def test_add_beamline_state_dialog_cleanup_deletes_device_widgets(qtbot, mocked_client): + dialog = AddBeamlineStateDialog(client=mocked_client) + qtbot.addWidget(dialog) + device = dialog._device + signal = dialog._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_dock_area.py b/tests/unit_tests/test_dock_area.py index 35c1900b..10334ede 100644 --- a/tests/unit_tests/test_dock_area.py +++ b/tests/unit_tests/test_dock_area.py @@ -869,7 +869,14 @@ class TestToolbarFunctionality: def test_toolbar_utils_actions(self, advanced_dock_area): """Test utils toolbar actions trigger widget creation.""" - utils_actions = ["queue", "terminal", "status", "progress_bar", "sbb_monitor"] + utils_actions = [ + "queue", + "terminal", + "status", + "progress_bar", + "sbb_monitor", + "beamline_state_manager", + ] for action_name in utils_actions: with patch.object(advanced_dock_area, "new") as mock_new: @@ -2428,6 +2435,7 @@ class TestFlatToolbarActions: "flat_terminal", "flat_bec_shell", "flat_sbb_monitor", + "flat_beamline_state_manager", ] for action_name in utils_actions: @@ -2472,6 +2480,7 @@ class TestFlatToolbarActions: "flat_terminal": "BecConsole", "flat_bec_shell": "BECShell", "flat_sbb_monitor": "SBBMonitor", + "flat_beamline_state_manager": "BeamlineStateManager", } for action_name, widget_type in utils_action_mapping.items(): diff --git a/tests/unit_tests/test_main_widnow.py b/tests/unit_tests/test_main_widnow.py index 2b0fd40e..6c4261c3 100644 --- a/tests/unit_tests/test_main_widnow.py +++ b/tests/unit_tests/test_main_widnow.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch import pytest from qtpy.QtCore import QEvent, QPoint, QPointF -from qtpy.QtGui import QEnterEvent +from qtpy.QtGui import QEnterEvent, QStandardItem from qtpy.QtWidgets import QApplication, QFrame, QLabel from bec_widgets.widgets.containers.main_window.addons.hover_widget import ( @@ -74,6 +74,10 @@ def test_event_consumes_status_tip(bec_main_window): assert bec_main_window.event(status_tip_event) is True +def test_event_ignores_non_qevent(bec_main_window): + assert bec_main_window.event(QStandardItem()) is False + + def test_get_launcher_from_qapp_returns_none_when_absent(bec_main_window): with patch.object( QApplication, "instance", return_value=SimpleNamespace(topLevelWidgets=lambda: [])