From 5d0ec2186b3f3dd6baf571ade2d51ee9d80a2b8a Mon Sep 17 00:00:00 2001 From: appel_c Date: Fri, 22 Aug 2025 07:55:33 +0200 Subject: [PATCH] feat(dm-view): initial device manager view added --- bec_widgets/applications/main_app.py | 12 + .../views/device_manager_view/__init__.py | 0 .../device_manager_view.py | 687 +++++++++++ .../device_manager_widget.py | 119 ++ bec_widgets/utils/bec_widget.py | 1 + bec_widgets/utils/expandable_frame.py | 36 +- bec_widgets/utils/forms_from_types/forms.py | 28 +- bec_widgets/utils/forms_from_types/items.py | 42 +- .../utils/help_inspector/help_inspector.py | 27 +- .../utils/list_of_expandable_frames.py | 133 +++ .../advanced_dock_area/states/user/test.ini | 234 ++++ .../device_manager/components/__init__.py | 4 + .../device_manager/components/_util.py | 53 + .../available_device_resources/__init__.py | 3 + .../available_device_group.py | 230 ++++ .../available_device_group_ui.py | 56 + .../available_device_resources.py | 128 ++ .../available_device_resources_ui.py | 135 +++ .../device_resource_backend.py | 140 +++ .../device_manager/components/constants.py | 72 ++ .../components/device_table_view.py | 1038 ++++++++++++----- .../components/dm_config_view.py | 100 ++ .../components/dm_docstring_view.py | 133 +++ .../components/dm_ophyd_test.py | 418 +++++++ .../services/device_browser/device_browser.py | 57 +- .../services/device_browser/device_browser.ui | 173 ++- .../device_item/config_communicator.py | 9 +- .../device_item/device_config_dialog.py | 255 ++-- .../device_item/device_config_form.py | 27 +- .../device_browser/device_item/device_item.py | 7 +- tests/unit_tests/test_device_browser.py | 34 +- .../test_device_config_form_dialog.py | 6 +- tests/unit_tests/test_device_input_base.py | 6 +- .../test_device_manager_components.py | 869 ++++++++++++++ tests/unit_tests/test_device_manager_view.py | 224 ++++ tests/unit_tests/test_help_inspector.py | 51 + 36 files changed, 4995 insertions(+), 552 deletions(-) create mode 100644 bec_widgets/applications/views/device_manager_view/__init__.py create mode 100644 bec_widgets/applications/views/device_manager_view/device_manager_view.py create mode 100644 bec_widgets/applications/views/device_manager_view/device_manager_widget.py create mode 100644 bec_widgets/utils/list_of_expandable_frames.py create mode 100644 bec_widgets/widgets/containers/advanced_dock_area/states/user/test.ini create mode 100644 bec_widgets/widgets/control/device_manager/components/_util.py create mode 100644 bec_widgets/widgets/control/device_manager/components/available_device_resources/__init__.py create mode 100644 bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py create mode 100644 bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py create mode 100644 bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py create mode 100644 bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py create mode 100644 bec_widgets/widgets/control/device_manager/components/available_device_resources/device_resource_backend.py create mode 100644 bec_widgets/widgets/control/device_manager/components/constants.py create mode 100644 bec_widgets/widgets/control/device_manager/components/dm_config_view.py create mode 100644 bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py create mode 100644 bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py create mode 100644 tests/unit_tests/test_device_manager_components.py create mode 100644 tests/unit_tests/test_device_manager_view.py diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py index 791f0751..da210c97 100644 --- a/bec_widgets/applications/main_app.py +++ b/bec_widgets/applications/main_app.py @@ -3,6 +3,9 @@ from qtpy.QtWidgets import QApplication, QHBoxLayout, QStackedWidget, QWidget from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION from bec_widgets.applications.navigation_centre.side_bar import SideBar from bec_widgets.applications.navigation_centre.side_bar_components import NavigationItem +from bec_widgets.applications.views.device_manager_view.device_manager_widget import ( + DeviceManagerWidget, +) from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup from bec_widgets.utils.colors import apply_theme from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea @@ -44,10 +47,18 @@ class BECMainApp(BECMainWindow): def _add_views(self): self.add_section("BEC Applications", "bec_apps") self.ads = AdvancedDockArea(self) + self.device_manager = DeviceManagerWidget(self) self.add_view( icon="widgets", title="Dock Area", id="dock_area", widget=self.ads, mini_text="Docks" ) + self.add_view( + icon="display_settings", + title="Device Manager", + id="device_manager", + widget=self.device_manager, + mini_text="DM", + ) if self._show_examples: self.add_section("Examples", "examples") @@ -184,6 +195,7 @@ if __name__ == "__main__": # pragma: no cover app = QApplication([sys.argv[0], *qt_args]) apply_theme("dark") w = BECMainApp(show_examples=args.examples) + w.resize(1920, 1200) w.show() sys.exit(app.exec()) diff --git a/bec_widgets/applications/views/device_manager_view/__init__.py b/bec_widgets/applications/views/device_manager_view/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_view.py b/bec_widgets/applications/views/device_manager_view/device_manager_view.py new file mode 100644 index 00000000..9acdb5a3 --- /dev/null +++ b/bec_widgets/applications/views/device_manager_view/device_manager_view.py @@ -0,0 +1,687 @@ +from __future__ import annotations + +import os +from functools import partial +from typing import List, Literal + +import PySide6QtAds as QtAds +import yaml +from bec_lib import config_helper +from bec_lib.bec_yaml_loader import yaml_load +from bec_lib.file_utils import DeviceConfigWriter +from bec_lib.logger import bec_logger +from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path +from bec_qthemes import apply_theme +from PySide6QtAds import CDockManager, CDockWidget +from qtpy.QtCore import Qt, QThreadPool, QTimer +from qtpy.QtWidgets import ( + QDialog, + QFileDialog, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QSizePolicy, + QSplitter, + QTextEdit, + QVBoxLayout, + QWidget, +) + +from bec_widgets import BECWidget +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.help_inspector.help_inspector import HelpInspector +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_manager.components import ( + DeviceTableView, + DMConfigView, + DMOphydTest, + DocstringView, +) +from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal +from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources import ( + AvailableDeviceResources, +) +from bec_widgets.widgets.services.device_browser.device_item.config_communicator import ( + CommunicateConfigAction, +) +from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import ( + PresetClassDeviceConfigDialog, +) + +logger = bec_logger.logger + +_yes_no_question = partial( + QMessageBox.question, + buttons=QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + defaultButton=QMessageBox.StandardButton.No, +) + + +def set_splitter_weights(splitter: QSplitter, weights: List[float]) -> None: + """ + Apply initial sizes to a splitter using weight ratios, e.g. [1,3,2,1]. + Works for horizontal or vertical splitters and sets matching stretch factors. + """ + + def apply(): + n = splitter.count() + if n == 0: + return + w = list(weights[:n]) + [1] * max(0, n - len(weights)) + w = [max(0.0, float(x)) for x in w] + tot_w = sum(w) + if tot_w <= 0: + w = [1.0] * n + tot_w = float(n) + total_px = ( + splitter.width() + if splitter.orientation() == Qt.Orientation.Horizontal + else splitter.height() + ) + if total_px < 2: + QTimer.singleShot(0, apply) + return + sizes = [max(1, int(total_px * (wi / tot_w))) for wi in w] + diff = total_px - sum(sizes) + if diff != 0: + idx = max(range(n), key=lambda i: w[i]) + sizes[idx] = max(1, sizes[idx] + diff) + splitter.setSizes(sizes) + for i, wi in enumerate(w): + splitter.setStretchFactor(i, max(1, int(round(wi * 100)))) + + QTimer.singleShot(0, apply) + + +class ConfigChoiceDialog(QDialog): + REPLACE = 1 + ADD = 2 + CANCEL = 0 + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Load Config") + layout = QVBoxLayout(self) + + label = QLabel("Do you want to replace the current config or add to it?") + label.setWordWrap(True) + layout.addWidget(label) + + # Buttons: equal size, stacked vertically + self.replace_btn = QPushButton("Replace") + self.add_btn = QPushButton("Add") + self.cancel_btn = QPushButton("Cancel") + btn_layout = QHBoxLayout() + for btn in (self.replace_btn, self.add_btn, self.cancel_btn): + btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + btn_layout.addWidget(btn) + layout.addLayout(btn_layout) + + # Connect signals to explicit slots + self.replace_btn.clicked.connect(self.accept_replace) + self.add_btn.clicked.connect(self.accept_add) + self.cancel_btn.clicked.connect(self.reject_cancel) + + self._result = self.CANCEL + + def accept_replace(self): + self._result = self.REPLACE + self.accept() + + def accept_add(self): + self._result = self.ADD + self.accept() + + def reject_cancel(self): + self._result = self.CANCEL + self.reject() + + def result(self): + return self._result + + +AVAILABLE_RESOURCE_IS_READY = False + + +class DeviceManagerView(BECWidget, QWidget): + + def __init__(self, parent=None, *args, **kwargs): + super().__init__(parent=parent, client=None, *args, **kwargs) + + self._config_helper = config_helper.ConfigHelper(self.client.connector) + self._shared_selection = SharedSelectionSignal() + + # Top-level layout hosting a toolbar and the dock manager + self._root_layout = QVBoxLayout(self) + self._root_layout.setContentsMargins(0, 0, 0, 0) + self._root_layout.setSpacing(0) + self.dock_manager = CDockManager(self) + self.dock_manager.setStyleSheet("") + self._root_layout.addWidget(self.dock_manager) + + # Device Table View widget + self.device_table_view = DeviceTableView( + self, shared_selection_signal=self._shared_selection + ) + self.device_table_view_dock = QtAds.CDockWidget(self.dock_manager, "Device Table", self) + self.device_table_view_dock.setWidget(self.device_table_view) + + # Device Config View widget + self.dm_config_view = DMConfigView(self) + self.dm_config_view_dock = QtAds.CDockWidget(self.dock_manager, "Device Config View", self) + self.dm_config_view_dock.setWidget(self.dm_config_view) + + # Docstring View + self.dm_docs_view = DocstringView(self) + self.dm_docs_view_dock = QtAds.CDockWidget(self.dock_manager, "Docstring View", self) + self.dm_docs_view_dock.setWidget(self.dm_docs_view) + + # Ophyd Test view + self.ophyd_test_view = DMOphydTest(self) + self.ophyd_test_dock_view = QtAds.CDockWidget(self.dock_manager, "Ophyd Test View", self) + self.ophyd_test_dock_view.setWidget(self.ophyd_test_view) + + # Help Inspector + widget = QWidget(self) + layout = QVBoxLayout(widget) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + self.help_inspector = HelpInspector(self) + layout.addWidget(self.help_inspector) + text_box = QTextEdit(self) + text_box.setReadOnly(False) + text_box.setPlaceholderText("Help text will appear here...") + layout.addWidget(text_box) + self.help_inspector_dock = QtAds.CDockWidget(self.dock_manager, "Help Inspector", self) + self.help_inspector_dock.setWidget(widget) + + # Register callback + self.help_inspector.bec_widget_help.connect(text_box.setMarkdown) + + # Error Logs View + self.error_logs_view = QTextEdit(self) + self.error_logs_view.setReadOnly(True) + self.error_logs_view.setPlaceholderText("Error logs will appear here...") + self.error_logs_dock = QtAds.CDockWidget(self.dock_manager, "Error Logs", self) + self.error_logs_dock.setWidget(self.error_logs_view) + self.ophyd_test_view.validation_msg_md.connect(self.error_logs_view.setMarkdown) + + # Arrange widgets within the QtAds dock manager + # Central widget area + self.central_dock_area = self.dock_manager.setCentralWidget(self.device_table_view_dock) + # Right area - should be pushed into view if something is active + self.dock_manager.addDockWidget( + QtAds.DockWidgetArea.RightDockWidgetArea, + self.ophyd_test_dock_view, + self.central_dock_area, + ) + # create bottom area (2-arg -> area) + self.bottom_dock_area = self.dock_manager.addDockWidget( + QtAds.DockWidgetArea.BottomDockWidgetArea, self.dm_docs_view_dock + ) + + # YAML view left of docstrings (docks relative to bottom area) + self.dock_manager.addDockWidget( + QtAds.DockWidgetArea.LeftDockWidgetArea, self.dm_config_view_dock, self.bottom_dock_area + ) + + # Error/help area right of docstrings (dock relative to bottom area) + area = self.dock_manager.addDockWidget( + QtAds.DockWidgetArea.RightDockWidgetArea, + self.help_inspector_dock, + self.bottom_dock_area, + ) + self.dock_manager.addDockWidgetTabToArea(self.error_logs_dock, area) + + for dock in self.dock_manager.dockWidgets(): + dock.setFeature(CDockWidget.DockWidgetClosable, False) + dock.setFeature(CDockWidget.DockWidgetFloatable, False) + dock.setFeature(CDockWidget.DockWidgetMovable, False) + + # Apply stretch after the layout is done + self.set_default_view([2, 8, 2], [7, 3]) + + for signal, slots in [ + ( + self.device_table_view.selected_devices, + (self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config), + ), + ( + self.ophyd_test_view.device_validated, + (self.device_table_view.update_device_validation,), + ), + ( + self.device_table_view.device_configs_changed, + (self.ophyd_test_view.change_device_configs,), + ), + ]: + for slot in slots: + signal.connect(slot) + + # Once available resource is ready, add it to the view again + if AVAILABLE_RESOURCE_IS_READY: + # Available Resources Widget + self.available_devices = AvailableDeviceResources( + self, shared_selection_signal=self._shared_selection + ) + self.available_devices_dock = QtAds.CDockWidget( + self.dock_manager, "Available Devices", self + ) + self.available_devices_dock.setWidget(self.available_devices) + # Connect slots for available reosource + for signal, slots in [ + ( + self.available_devices.selected_devices, + (self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config), + ), + ( + self.device_table_view.device_configs_changed, + (self.available_devices.mark_devices_used,), + ), + ( + self.available_devices.add_selected_devices, + (self.device_table_view.add_device_configs,), + ), + ( + self.available_devices.del_selected_devices, + (self.device_table_view.remove_device_configs,), + ), + ]: + for slot in slots: + signal.connect(slot) + + # Add toolbar + self._add_toolbar() + + def _add_toolbar(self): + self.toolbar = ModularToolBar(self) + + # Add IO actions + self._add_io_actions() + self._add_table_actions() + self.toolbar.show_bundles(["IO", "Table"]) + self._root_layout.insertWidget(0, self.toolbar) + + def _add_io_actions(self): + # Create IO bundle + io_bundle = ToolbarBundle("IO", self.toolbar.components) + + # Load from disk + load = MaterialIconAction( + text_position="under", + icon_name="file_open", + parent=self, + tooltip="Load configuration file from disk", + label_text="Load Config", + ) + self.toolbar.components.add_safe("load", load) + load.action.triggered.connect(self._load_file_action) + io_bundle.add_action("load") + + # Add safe to disk + save_to_disk = MaterialIconAction( + text_position="under", + icon_name="file_save", + parent=self, + tooltip="Save config to disk", + label_text="Save Config", + ) + self.toolbar.components.add_safe("save_to_disk", save_to_disk) + save_to_disk.action.triggered.connect(self._save_to_disk_action) + io_bundle.add_action("save_to_disk") + + # Add load config from redis + load_redis = MaterialIconAction( + text_position="under", + icon_name="cached", + parent=self, + tooltip="Load current config from Redis", + label_text="Get Current Config", + ) + load_redis.action.triggered.connect(self._load_redis_action) + self.toolbar.components.add_safe("load_redis", load_redis) + io_bundle.add_action("load_redis") + + # Update config action + update_config_redis = MaterialIconAction( + text_position="under", + icon_name="cloud_upload", + parent=self, + tooltip="Update current config in Redis", + label_text="Update Config", + ) + update_config_redis.action.setEnabled(False) + update_config_redis.action.triggered.connect(self._update_redis_action) + self.toolbar.components.add_safe("update_config_redis", update_config_redis) + io_bundle.add_action("update_config_redis") + + # Add load config from plugin dir + self.toolbar.add_bundle(io_bundle) + + # Table actions + + def _add_table_actions(self) -> None: + table_bundle = ToolbarBundle("Table", self.toolbar.components) + + # Reset composed view + reset_composed = MaterialIconAction( + text_position="under", + icon_name="delete_sweep", + parent=self, + tooltip="Reset current composed config view", + label_text="Reset Config", + ) + reset_composed.action.triggered.connect(self._reset_composed_view) + self.toolbar.components.add_safe("reset_composed", reset_composed) + table_bundle.add_action("reset_composed") + + # Add device + add_device = MaterialIconAction( + text_position="under", + icon_name="add", + parent=self, + tooltip="Add new device", + label_text="Add Device", + ) + add_device.action.triggered.connect(self._add_device_action) + self.toolbar.components.add_safe("add_device", add_device) + table_bundle.add_action("add_device") + + # Remove device + remove_device = MaterialIconAction( + text_position="under", + icon_name="remove", + parent=self, + tooltip="Remove device", + label_text="Remove Device", + ) + remove_device.action.triggered.connect(self._remove_device_action) + self.toolbar.components.add_safe("remove_device", remove_device) + table_bundle.add_action("remove_device") + + # Rerun validation + rerun_validation = MaterialIconAction( + text_position="under", + icon_name="checklist", + parent=self, + tooltip="Run device validation with 'connect' on selected devices", + label_text="Validate Connection", + ) + rerun_validation.action.triggered.connect(self._rerun_validation_action) + self.toolbar.components.add_safe("rerun_validation", rerun_validation) + table_bundle.add_action("rerun_validation") + + # Add load config from plugin dir + self.toolbar.add_bundle(table_bundle) + + # IO actions + def _coming_soon(self): + return QMessageBox.question( + self, + "Not implemented yet", + "This feature has not been implemented yet, will be coming soon...!!", + QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.Cancel, + ) + + @SafeSlot() + def _load_file_action(self): + """Action for the 'load' action to load a config from disk for the io_bundle of the toolbar.""" + try: + plugin_path = plugin_repo_path() + plugin_name = plugin_package_name() + config_path = os.path.join(plugin_path, plugin_name, "device_configs") + except ValueError: + # Get the recovery config path as fallback + config_path = self._get_recovery_config_path() + logger.warning( + f"No plugin repository installed, fallback to recovery config path: {config_path}" + ) + + # Implement the file loading logic here + start_dir = os.path.abspath(config_path) + file_path = self._get_file_path(start_dir, "open_file") + if file_path: + self._load_config_from_file(file_path) + + def _get_file_path(self, start_dir: str, mode: Literal["open_file", "save_file"]) -> str: + if mode == "open_file": + file_path, _ = QFileDialog.getOpenFileName( + self, caption="Select Config File", dir=start_dir + ) + else: + file_path, _ = QFileDialog.getSaveFileName( + self, caption="Save Config File", dir=start_dir + ) + return file_path + + def _load_config_from_file(self, file_path: str): + """ + Load device config from a given file path and update the device table view. + + Args: + file_path (str): Path to the configuration file. + """ + try: + config = [{"name": k, **v} for k, v in yaml_load(file_path).items()] + except Exception as e: + logger.error(f"Failed to load config from file {file_path}. Error: {e}") + return + self._open_config_choice_dialog(config) + + def _open_config_choice_dialog(self, config: List[dict]): + """ + Open a dialog to choose whether to replace or add the loaded config. + + Args: + config (List[dict]): List of device configurations loaded from the file. + """ + dialog = ConfigChoiceDialog(self) + if dialog.exec(): + if dialog.result() == ConfigChoiceDialog.REPLACE: + self.device_table_view.set_device_config(config) + elif dialog.result() == ConfigChoiceDialog.ADD: + self.device_table_view.add_device_configs(config) + + # TODO would we ever like to add the current config to an existing composition + @SafeSlot() + def _load_redis_action(self): + """Action for the 'load_redis' action to load the current config from Redis for the io_bundle of the toolbar.""" + reply = _yes_no_question( + self, + "Load currently active config", + "Do you really want to discard the current config and reload?", + ) + if reply == QMessageBox.StandardButton.Yes and self.client.device_manager is not None: + self.device_table_view.set_device_config( + self.client.device_manager._get_redis_device_config() + ) + else: + return + + @SafeSlot() + def _update_redis_action(self) -> None | QMessageBox.StandardButton: + """Action to push the current composition to Redis""" + reply = _yes_no_question( + self, + "Push composition to Redis", + "Do you really want to replace the active configuration in the BEC server with the current composition? ", + ) + if reply != QMessageBox.StandardButton.Yes: + return + if self.device_table_view.table.contains_invalid_devices(): + return QMessageBox.warning( + self, "Validation has errors!", "Please resolve before proceeding." + ) + if self.ophyd_test_view.validation_running(): + return QMessageBox.warning( + self, "Validation has not completed.", "Please wait for the validation to finish." + ) + self._push_composition_to_redis() + + def _push_composition_to_redis(self): + config = {cfg.pop("name"): cfg for cfg in self.device_table_view.table.all_configs()} + threadpool = QThreadPool.globalInstance() + comm = CommunicateConfigAction(self._config_helper, None, config, "set") + threadpool.start(comm) + + @SafeSlot() + def _save_to_disk_action(self): + """Action for the 'save_to_disk' action to save the current config to disk.""" + # Check if plugin repo is installed... + try: + config_path = self._get_recovery_config_path() + except ValueError: + # Get the recovery config path as fallback + config_path = os.path.abspath(os.path.expanduser("~")) + logger.warning(f"Failed to find recovery config path, fallback to: {config_path}") + + # Implement the file loading logic here + file_path = self._get_file_path(config_path, "save_file") + if file_path: + config = {cfg.pop("name"): cfg for cfg in self.device_table_view.get_device_config()} + with open(file_path, "w") as file: + file.write(yaml.dump(config)) + + # Table actions + @SafeSlot() + def _reset_composed_view(self): + """Action for the 'reset_composed_view' action to reset the composed view.""" + reply = _yes_no_question( + self, + "Clear View", + "You are about to clear the current composed config view, please confirm...", + ) + if reply == QMessageBox.StandardButton.Yes: + self.device_table_view.clear_device_configs() + + # TODO Bespoke Form to add a new device + @SafeSlot() + def _add_device_action(self): + """Action for the 'add_device' action to add a new device.""" + dialog = PresetClassDeviceConfigDialog(parent=self) + dialog.accepted_data.connect(self._add_to_table_from_dialog) + dialog.open() + + @SafeSlot(dict) + def _add_to_table_from_dialog(self, data): + self.device_table_view.add_device_configs([data]) + + @SafeSlot() + def _remove_device_action(self): + """Action for the 'remove_device' action to remove a device.""" + self.device_table_view.remove_selected_rows() + + @SafeSlot() + @SafeSlot(bool) + def _rerun_validation_action(self, connect: bool = True): + """Action for the 'rerun_validation' action to rerun validation on selected devices.""" + configs = self.device_table_view.table.selected_configs() + self.ophyd_test_view.change_device_configs(configs, True, connect) + + ####### Default view has to be done with setting up splitters ######## + def set_default_view( + self, horizontal_weights: list, vertical_weights: list + ): # TODO separate logic for all ads based widgets + """Apply initial weights to every horizontal and vertical splitter. + + Examples: + horizontal_weights = [1, 3, 2, 1] + vertical_weights = [3, 7] # top:bottom = 30:70 + """ + splitters_h = [] + splitters_v = [] + for splitter in self.findChildren(QSplitter): + if splitter.orientation() == Qt.Orientation.Horizontal: + splitters_h.append(splitter) + elif splitter.orientation() == Qt.Orientation.Vertical: + splitters_v.append(splitter) + + def apply_all(): + for s in splitters_h: + set_splitter_weights(s, horizontal_weights) + for s in splitters_v: + set_splitter_weights(s, vertical_weights) + + QTimer.singleShot(0, apply_all) + + def set_stretch( + self, *, horizontal=None, vertical=None + ): # TODO separate logic for all ads based widgets + """Update splitter weights and re-apply to all splitters. + + Accepts either a list/tuple of weights (e.g., [1,3,2,1]) or a role dict + for convenience: horizontal roles = {"left","center","right"}, + vertical roles = {"top","bottom"}. + """ + + def _coerce_h(x): + if x is None: + return None + if isinstance(x, (list, tuple)): + return list(map(float, x)) + if isinstance(x, dict): + return [ + float(x.get("left", 1)), + float(x.get("center", x.get("middle", 1))), + float(x.get("right", 1)), + ] + return None + + def _coerce_v(x): + if x is None: + return None + if isinstance(x, (list, tuple)): + return list(map(float, x)) + if isinstance(x, dict): + return [float(x.get("top", 1)), float(x.get("bottom", 1))] + return None + + h = _coerce_h(horizontal) + v = _coerce_v(vertical) + if h is None: + h = [1, 1, 1] + if v is None: + v = [1, 1] + self.set_default_view(h, v) + + def _get_recovery_config_path(self) -> str: + """Get the recovery config path from the log_writer config.""" + # pylint: disable=protected-access + log_writer_config = self.client._service_config.config.get("log_writer", {}) + writer = DeviceConfigWriter(service_config=log_writer_config) + return os.path.abspath(os.path.expanduser(writer.get_recovery_directory())) + + +if __name__ == "__main__": + import sys + from copy import deepcopy + + from bec_lib.bec_yaml_loader import yaml_load + from qtpy.QtWidgets import QApplication + + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton + + app = QApplication(sys.argv) + w = QWidget() + l = QVBoxLayout() + w.setLayout(l) + apply_theme("dark") + button = DarkModeButton() + l.addWidget(button) + device_manager_view = DeviceManagerView() + l.addWidget(device_manager_view) + # config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/first_light.yaml" + # cfg = yaml_load(config_path) + # cfg.update({"device_will_fail": {"name": "device_will_fail", "some_param": 1}}) + + # # config = device_manager_view.client.device_manager._get_redis_device_config() + # device_manager_view.device_table_view.set_device_config(cfg) + w.show() + w.setWindowTitle("Device Manager View") + w.resize(1920, 1080) + # developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime + sys.exit(app.exec_()) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_widget.py b/bec_widgets/applications/views/device_manager_view/device_manager_widget.py new file mode 100644 index 00000000..8c24a9b9 --- /dev/null +++ b/bec_widgets/applications/views/device_manager_view/device_manager_widget.py @@ -0,0 +1,119 @@ +"""Top Level wrapper for device_manager widget""" + +from __future__ import annotations + +import os + +from bec_lib.bec_yaml_loader import yaml_load +from bec_lib.logger import bec_logger +from bec_qthemes import material_icon +from qtpy import QtCore, QtWidgets + +from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.error_popups import SafeSlot + +logger = bec_logger.logger + + +class DeviceManagerWidget(BECWidget, QtWidgets.QWidget): + + def __init__(self, parent=None, client=None): + super().__init__(client=client, parent=parent) + self.stacked_layout = QtWidgets.QStackedLayout() + self.stacked_layout.setContentsMargins(0, 0, 0, 0) + self.stacked_layout.setSpacing(0) + self.stacked_layout.setStackingMode(QtWidgets.QStackedLayout.StackAll) + self.setLayout(self.stacked_layout) + + # Add device manager view + self.device_manager_view = DeviceManagerView() + self.stacked_layout.addWidget(self.device_manager_view) + + # Add overlay widget + self._overlay_widget = QtWidgets.QWidget(self) + self._customize_overlay() + self.stacked_layout.addWidget(self._overlay_widget) + self.stacked_layout.setCurrentWidget(self._overlay_widget) + + def _customize_overlay(self): + self._overlay_widget.setAutoFillBackground(True) + self._overlay_layout = QtWidgets.QVBoxLayout() + self._overlay_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self._overlay_widget.setLayout(self._overlay_layout) + self._overlay_widget.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding + ) + # Load current config + self.button_load_current_config = QtWidgets.QPushButton("Load Current Config") + icon = material_icon(icon_name="database", size=(24, 24), convert_to_pixmap=False) + self.button_load_current_config.setIcon(icon) + self._overlay_layout.addWidget(self.button_load_current_config) + self.button_load_current_config.clicked.connect(self._load_config_clicked) + # Load config from disk + self.button_load_config_from_file = QtWidgets.QPushButton("Load Config From File") + icon = material_icon(icon_name="folder", size=(24, 24), convert_to_pixmap=False) + self.button_load_config_from_file.setIcon(icon) + self._overlay_layout.addWidget(self.button_load_config_from_file) + self.button_load_config_from_file.clicked.connect(self._load_config_from_file_clicked) + self._overlay_widget.setVisible(True) + + def _load_config_from_file_clicked(self): + """Handle click on 'Load Config From File' button.""" + start_dir = os.path.expanduser("~") + file_path, _ = QtWidgets.QFileDialog.getOpenFileName( + self, caption="Select Config File", dir=start_dir + ) + if file_path: + self._load_config_from_file(file_path) + + def _load_config_from_file(self, file_path: str): + try: + config = yaml_load(file_path) + except Exception as e: + logger.error(f"Failed to load config from file {file_path}. Error: {e}") + return + config_list = [] + for name, cfg in config.items(): + config_list.append(cfg) + config_list[-1]["name"] = name + self.device_manager_view.device_table_view.set_device_config(config_list) + # self.device_manager_view.ophyd_test.on_device_config_update(config) + self.stacked_layout.setCurrentWidget(self.device_manager_view) + + @SafeSlot() + def _load_config_clicked(self): + """Handle click on 'Load Current Config' button.""" + config = self.client.device_manager._get_redis_device_config() + self.device_manager_view.device_table_view.set_device_config(config) + self.stacked_layout.setCurrentWidget(self.device_manager_view) + + +if __name__ == "__main__": # pragma: no cover + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + from bec_widgets.utils.colors import apply_theme + + apply_theme("light") + + widget = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(widget) + widget.setLayout(layout) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + device_manager = DeviceManagerWidget() + # config = device_manager.client.device_manager._get_redis_device_config() + # device_manager.device_table_view.set_device_config(config) + layout.addWidget(device_manager) + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton + + dark_mode_button = DarkModeButton() + layout.addWidget(dark_mode_button) + widget.show() + device_manager.setWindowTitle("Device Manager View") + device_manager.resize(1600, 1200) + # developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime + sys.exit(app.exec_()) diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index 02c4d607..ef397d03 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -192,6 +192,7 @@ class BECWidget(BECConnector): Returns: str: The help text in markdown format. """ + return "" @SafeSlot() @SafeSlot(str) diff --git a/bec_widgets/utils/expandable_frame.py b/bec_widgets/utils/expandable_frame.py index 9f65500e..08a4d95f 100644 --- a/bec_widgets/utils/expandable_frame.py +++ b/bec_widgets/utils/expandable_frame.py @@ -1,7 +1,7 @@ from __future__ import annotations from bec_qthemes import material_icon -from qtpy.QtCore import Signal +from qtpy.QtCore import QSize, Signal from qtpy.QtWidgets import ( QApplication, QFrame, @@ -19,7 +19,8 @@ from bec_widgets.utils.error_popups import SafeProperty, SafeSlot class ExpandableGroupFrame(QFrame): - + broadcast_size_hint = Signal(QSize) + imminent_deletion = Signal() expansion_state_changed = Signal() EXPANDED_ICON_NAME: str = "collapse_all" @@ -31,10 +32,11 @@ class ExpandableGroupFrame(QFrame): super().__init__(parent=parent) self._expanded = expanded - self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Plain) + self._title_text = f"{title}" + self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Raised) self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) self._layout = QVBoxLayout() - self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setContentsMargins(5, 0, 0, 0) self.setLayout(self._layout) self._create_title_layout(title, icon) @@ -49,21 +51,27 @@ class ExpandableGroupFrame(QFrame): def _create_title_layout(self, title: str, icon: str): self._title_layout = QHBoxLayout() self._layout.addLayout(self._title_layout) + self._internal_title_layout = QHBoxLayout() + self._title_layout.addLayout(self._internal_title_layout) - self._title = ClickableLabel(f"{title}") + self._title = ClickableLabel() + self._set_title_text(self._title_text) self._title_icon = ClickableLabel() - self._title_layout.addWidget(self._title_icon) - self._title_layout.addWidget(self._title) + self._internal_title_layout.addWidget(self._title_icon) + self._internal_title_layout.addWidget(self._title) self.icon_name = icon self._title.clicked.connect(self.switch_expanded_state) self._title_icon.clicked.connect(self.switch_expanded_state) - self._title_layout.addStretch(1) + self._internal_title_layout.addStretch(1) self._expansion_button = QToolButton() self._update_expansion_icon() self._title_layout.addWidget(self._expansion_button, stretch=1) + def get_title_layout(self) -> QHBoxLayout: + return self._internal_title_layout + def set_layout(self, layout: QLayout) -> None: self._contents.setLayout(layout) self._contents.layout().setContentsMargins(0, 0, 0, 0) # type: ignore @@ -112,6 +120,18 @@ class ExpandableGroupFrame(QFrame): else: self._title_icon.setVisible(False) + @SafeProperty(str) + def title_text(self): # type: ignore + return self._title_text + + @title_text.setter + def title_text(self, title_text: str): + self._title_text = title_text + self._set_title_text(self._title_text) + + def _set_title_text(self, title_text: str): + self._title.setText(title_text) + # Application example if __name__ == "__main__": # pragma: no cover diff --git a/bec_widgets/utils/forms_from_types/forms.py b/bec_widgets/utils/forms_from_types/forms.py index eb5e31e6..9797af2e 100644 --- a/bec_widgets/utils/forms_from_types/forms.py +++ b/bec_widgets/utils/forms_from_types/forms.py @@ -1,6 +1,6 @@ from __future__ import annotations -from types import NoneType +from types import GenericAlias, NoneType, UnionType from typing import NamedTuple from bec_lib.logger import bec_logger @@ -11,7 +11,7 @@ from qtpy.QtWidgets import QApplication, QGridLayout, QLabel, QSizePolicy, QVBox from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.compact_popup import CompactPopupWidget -from bec_widgets.utils.error_popups import SafeProperty +from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.forms_from_types import styles from bec_widgets.utils.forms_from_types.items import ( DynamicFormItem, @@ -215,6 +215,9 @@ class PydanticModelForm(TypedForm): self._connect_to_theme_change() + @SafeSlot() + def clear(self): ... + def set_pretty_display_theme(self, theme: str = "dark"): if self._pretty_display: self.setStyleSheet(styles.pretty_display_theme(theme)) @@ -279,3 +282,24 @@ class PydanticModelForm(TypedForm): self.form_data_cleared.emit(None) self.validity_proc.emit(False) return False + + +class PydanticModelFormItem(DynamicFormItem): + def __init__( + self, parent: QWidget | None = None, *, spec: FormItemSpec, model: type[BaseModel] + ) -> None: + self._data_model = model + + super().__init__(parent=parent, spec=spec) + self._main_widget.form_data_updated.connect(self._value_changed) + + def _add_main_widget(self) -> None: + + self._main_widget = PydanticModelForm(data_model=self._data_model) + self._layout.addWidget(self._main_widget) + + def getValue(self): + return self._main_widget.get_form_data() + + def setValue(self, value: dict): + self._main_widget.set_data(self._data_model.model_validate(value)) diff --git a/bec_widgets/utils/forms_from_types/items.py b/bec_widgets/utils/forms_from_types/items.py index 04acf7ff..a0b8e1f7 100644 --- a/bec_widgets/utils/forms_from_types/items.py +++ b/bec_widgets/utils/forms_from_types/items.py @@ -1,5 +1,6 @@ from __future__ import annotations +import inspect import typing from abc import abstractmethod from decimal import Decimal @@ -14,8 +15,10 @@ from typing import ( NamedTuple, Optional, OrderedDict, + Protocol, TypeVar, get_args, + runtime_checkable, ) from bec_lib.logger import bec_logger @@ -170,9 +173,10 @@ class DynamicFormItem(QWidget): self._desc = self._spec.info.description self.setLayout(self._layout) self._add_main_widget() + # Sadly, QWidget and ABC are not compatible assert isinstance(self._main_widget, QWidget), "Please set a widget in _add_main_widget()" # type: ignore - self._main_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum) - self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum) + self._main_widget.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) + self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) if not spec.pretty_display: if clearable_required(spec.info): self._add_clear_button() @@ -187,6 +191,7 @@ class DynamicFormItem(QWidget): @abstractmethod def _add_main_widget(self) -> None: + self._main_widget: QWidget """Add the main data entry widget to self._main_widget and appply any constraints from the field info""" @@ -404,7 +409,7 @@ class ListFormItem(DynamicFormItem): def sizeHint(self): default = super().sizeHint() - return QSize(default.width(), QFontMetrics(self.font()).height() * 6) + return QSize(default.width(), QFontMetrics(self.font()).height() * 4) def _add_main_widget(self) -> None: self._main_widget = QListWidget() @@ -454,10 +459,17 @@ class ListFormItem(DynamicFormItem): self._add_list_item(val) self._repop(self._data) + def _item_height(self): + return int(QFontMetrics(self.font()).height() * 1.5) + def _add_list_item(self, val): item = QListWidgetItem(self._main_widget) item.setFlags(item.flags() | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsEditable) item_widget = self._types.widget(parent=self) + item_widget.setMinimumHeight(self._item_height()) + self._main_widget.setGridSize(QSize(0, self._item_height())) + if (layout := item_widget.layout()) is not None: + layout.setContentsMargins(0, 0, 0, 0) WidgetIO.set_value(item_widget, val) self._main_widget.setItemWidget(item, item_widget) self._main_widget.addItem(item) @@ -494,14 +506,11 @@ class ListFormItem(DynamicFormItem): self._data = list(value) self._repop(self._data) - def _line_height(self): - return QFontMetrics(self._main_widget.font()).height() - def set_max_height_in_lines(self, lines: int): outer_inc = 1 if self._spec.pretty_display else 3 - self._main_widget.setFixedHeight(self._line_height() * max(lines, self._min_lines)) - self._button_holder.setFixedHeight(self._line_height() * (max(lines, self._min_lines) + 1)) - self.setFixedHeight(self._line_height() * (max(lines, self._min_lines) + outer_inc)) + self._main_widget.setFixedHeight(self._item_height() * max(lines, self._min_lines)) + self._button_holder.setFixedHeight(self._item_height() * (max(lines, self._min_lines) + 1)) + self.setFixedHeight(self._item_height() * (max(lines, self._min_lines) + outer_inc)) def scale_to_data(self, *_): self.set_max_height_in_lines(self._main_widget.count() + 1) @@ -584,6 +593,16 @@ class OptionalStrLiteralFormItem(StrLiteralFormItem): WidgetTypeRegistry = OrderedDict[str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem]]] +@runtime_checkable +class _ItemTypeFn(Protocol): + def __call__(self, spec: FormItemSpec) -> type[DynamicFormItem]: ... + + +WidgetTypeRegistry = OrderedDict[ + str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem] | _ItemTypeFn] +] + + def _is_string_literal(t: type): return type(t) is type(Literal[""]) and set(type(arg) for arg in get_args(t)) == {str} @@ -637,7 +656,10 @@ def widget_from_type( widget_types = widget_types or DEFAULT_WIDGET_TYPES for predicate, widget_type in widget_types.values(): if predicate(spec): - return widget_type + if inspect.isclass(widget_type) and issubclass(widget_type, DynamicFormItem): + return widget_type + return widget_type(spec) + logger.warning( f"Type {spec.item_type=} / {spec.info.annotation=} is not (yet) supported in dynamic form creation." ) diff --git a/bec_widgets/utils/help_inspector/help_inspector.py b/bec_widgets/utils/help_inspector/help_inspector.py index e9976945..9a73cd34 100644 --- a/bec_widgets/utils/help_inspector/help_inspector.py +++ b/bec_widgets/utils/help_inspector/help_inspector.py @@ -11,6 +11,7 @@ from qtpy import QtCore, QtWidgets from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.colors import AccentColors, get_accent_colors from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.widget_io import WidgetHierarchy logger = bec_logger.logger @@ -100,7 +101,7 @@ class HelpInspector(BECWidget, QtWidgets.QWidget): self._button.setChecked(False) QtWidgets.QApplication.restoreOverrideCursor() - def eventFilter(self, obj, event): + def eventFilter(self, obj: QtWidgets.QWidget, event: QtCore.QEvent) -> bool: """ Filter events to capture Key_Escape event, and mouse clicks if event filter is active. Any click event on a widget is suppressed, if @@ -111,25 +112,33 @@ class HelpInspector(BECWidget, QtWidgets.QWidget): obj (QObject): The object that received the event. event (QEvent): The event to filter. """ - if ( - event.type() == QtCore.QEvent.KeyPress - and event.key() == QtCore.Qt.Key_Escape - and self._active - ): + # If not active, return immediately + if not self._active: + return super().eventFilter(obj, event) + # If active, handle escape key + if event.type() == QtCore.QEvent.KeyPress and event.key() == QtCore.Qt.Key_Escape: self._toggle_mode(False) return super().eventFilter(obj, event) - if self._active and event.type() == QtCore.QEvent.MouseButtonPress: + # If active, and left mouse button pressed, handle click + if event.type() == QtCore.QEvent.MouseButtonPress: if event.button() == QtCore.Qt.LeftButton: widget = self._app.widgetAt(event.globalPos()) + if widget is None: + return super().eventFilter(obj, event) + # Get BECWidget ancestor + # TODO check what happens if the HELP Inspector itself is embedded in another BECWidget + # I suppose we would like to get the first ancestor that is a BECWidget, not the topmost one + if not isinstance(widget, BECWidget): + widget = WidgetHierarchy._get_becwidget_ancestor(widget) if widget: - if widget is self or self.isAncestorOf(widget): + if widget is self: self._toggle_mode(False) return True for cb in self._callbacks.values(): try: cb(widget) except Exception as e: - print(f"Error occurred in callback {cb}: {e}") + logger.error(f"Error occurred in callback {cb}: {e}") return True return super().eventFilter(obj, event) diff --git a/bec_widgets/utils/list_of_expandable_frames.py b/bec_widgets/utils/list_of_expandable_frames.py new file mode 100644 index 00000000..7ad85a71 --- /dev/null +++ b/bec_widgets/utils/list_of_expandable_frames.py @@ -0,0 +1,133 @@ +import re +from functools import partial +from re import Pattern +from typing import Generic, Iterable, NamedTuple, TypeVar + +from bec_lib.logger import bec_logger +from qtpy.QtCore import QSize, Qt +from qtpy.QtWidgets import QListWidget, QListWidgetItem, QWidget + +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.expandable_frame import ExpandableGroupFrame +from bec_widgets.widgets.control.device_manager.components._util import ( + SORT_KEY_ROLE, + SortableQListWidgetItem, +) + +logger = bec_logger.logger + + +_EF = TypeVar("_EF", bound=ExpandableGroupFrame) + + +class ListOfExpandableFrames(QListWidget, Generic[_EF]): + def __init__( + self, /, parent: QWidget | None = None, item_class: type[_EF] = ExpandableGroupFrame + ) -> None: + super().__init__(parent) + _Items = NamedTuple("_Items", (("item", QListWidgetItem), ("widget", _EF))) + self.item_tuple = _Items + self._item_class = item_class + self._item_dict: dict[str, _Items] = {} + + def __contains__(self, id: str): + return id in self._item_dict + + def clear(self) -> None: + self._item_dict = {} + return super().clear() + + def add_item(self, id: str, *args, **kwargs) -> tuple[QListWidgetItem, _EF]: + """Adds the specified type of widget as an item. args and kwargs are passed to the constructor. + + Args: + id (str): the key under which to store the list item in the internal dict + + Returns: + The widget created in the addition process + """ + + def _remove_item(item: QListWidgetItem): + self.takeItem(self.row(item)) + del self._item_dict[id] + self.sortItems() + + def _updatesize(item: QListWidgetItem, item_widget: _EF): + item_widget.adjustSize() + item.setSizeHint(QSize(item_widget.width(), item_widget.height())) + + item = SortableQListWidgetItem(self) + item.setData(SORT_KEY_ROLE, id) # used for sorting + + item_widget = self._item_class(*args, **kwargs) + item_widget.expansion_state_changed.connect(partial(_updatesize, item, item_widget)) + item_widget.imminent_deletion.connect(partial(_remove_item, item)) + item_widget.broadcast_size_hint.connect(item.setSizeHint) + + self.addItem(item) + self.setItemWidget(item, item_widget) + self._item_dict[id] = self.item_tuple(item, item_widget) + + item.setSizeHint(item_widget.sizeHint()) + return (item, item_widget) + + def sort_by_key(self, role=SORT_KEY_ROLE, order=Qt.SortOrder.AscendingOrder): + items = [self.takeItem(0) for i in range(self.count())] + items.sort(key=lambda it: it.data(role), reverse=(order == Qt.SortOrder.DescendingOrder)) + + for it in items: + self.addItem(it) + # reattach its custom widget + widget = self.itemWidget(it) + if widget: + self.setItemWidget(it, widget) + + def item_widget_pairs(self): + return self._item_dict.values() + + def widgets(self): + return (i.widget for i in self._item_dict.values()) + + def get_item_widget(self, id: str): + if (item := self._item_dict.get(id)) is None: + return None + return item + + def set_hidden_pattern(self, pattern: Pattern): + self.hide_all() + self._set_hidden(filter(pattern.search, self._item_dict.keys()), False) + + def set_hidden(self, ids: Iterable[str]): + self._set_hidden(ids, True) + + def _set_hidden(self, ids: Iterable[str], hidden: bool): + for id in ids: + if (_item := self._item_dict.get(id)) is not None: + _item.item.setHidden(hidden) + _item.widget.setHidden(hidden) + else: + logger.warning( + f"List {self.__qualname__} does not have an item with ID {id} to hide!" + ) + self.sortItems() + + def hide_all(self): + self.set_hidden_state_on_all(True) + + def unhide_all(self): + self.set_hidden_state_on_all(False) + + def set_hidden_state_on_all(self, hidden: bool): + for _item in self._item_dict.values(): + _item.item.setHidden(hidden) + _item.widget.setHidden(hidden) + self.sortItems() + + @SafeSlot(str) + def update_filter(self, value: str): + if value == "": + return self.unhide_all() + try: + self.set_hidden_pattern(re.compile(value, re.IGNORECASE)) + except Exception: + self.unhide_all() diff --git a/bec_widgets/widgets/containers/advanced_dock_area/states/user/test.ini b/bec_widgets/widgets/containers/advanced_dock_area/states/user/test.ini new file mode 100644 index 00000000..6188162c --- /dev/null +++ b/bec_widgets/widgets/containers/advanced_dock_area/states/user/test.ini @@ -0,0 +1,234 @@ +[BECMainWindowNoRPC.AdvancedDockArea] +acceptDrops=false +accessibleDescription= +accessibleIdentifier= +accessibleName= +autoFillBackground=false +baseSize=@Size(0 0) +contextMenuPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0\x66\x80\x4\x95[\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x14Qt.ContextMenuPolicy\x94\x93\x94\x8c\x12\x44\x65\x66\x61ultContextMenu\x94\x86\x94R\x94.) +cursor=@Variant(\0\0\0J\0\0) +enabled=true +focusPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0U\x80\x4\x95J\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\xeQt.FocusPolicy\x94\x93\x94\x8c\aNoFocus\x94\x86\x94R\x94.) +font=@Variant(\0\0\0@\0\0\0$\0.\0\x41\0p\0p\0l\0\x65\0S\0y\0s\0t\0\x65\0m\0U\0I\0\x46\0o\0n\0t@*\0\0\0\0\0\0\xff\xff\xff\xff\x5\x1\0\x32\x10) +geometry=@Rect(0 29 2075 974) +inputMethodHints=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.InputMethodHint\x94\x93\x94\x8c\aImhNone\x94\x86\x94R\x94.) +layoutDirection=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0]\x80\x4\x95R\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.LayoutDirection\x94\x93\x94\x8c\vLeftToRight\x94\x86\x94R\x94.) +locale=@Variant(\0\0\0\x12\0\0\0\n\0\x65\0n\0_\0\x43\0H) +lock_workspace=false +maximumSize=@Size(16777215 16777215) +minimumSize=@Size(0 0) +mode=developer +mouseTracking=false +palette=@Variant(\0\0\0\x44\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0) +sizeIncrement=@Size(0 0) +sizePolicy=@Variant(\0\0\0K\0\0\0U) +statusTip= +styleSheet= +tabletTracking=false +toolTip= +toolTipDuration=-1 +updatesEnabled=true +visible=true +whatsThis= +windowFilePath= +windowIcon="@Variant(\0\0\0\x45\0\0\0\x1\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0,\0\0\0,\b\x6\0\0\0\x1e\x84Z\x1\0\0\x1\x81iCCPsRGB IEC61966-2.1\0\0(\x91u\x91\xb9KCA\x10\x87\xbf\x1c\x1ehB\x4-,,\x82\x44\xab(^\x4m\x4\x13\x44\x85 !F\xf0j\x92g\xe!\xc7\xe3\xbd\x88\x4[\xc1\x36\xa0 \xdax\x15\xfa\x17h+X\v\x82\xa2\bb\x9dZ\xd1\x46\xc3s\x9e\x11\x12\xc4\xcc\x32;\xdf\xfevg\xd8\x9d\x5k$\xad\x64t\xfb\0\x64\xb2y-<\xe5w/,.\xb9\x9bJ4\xd0\x88\x13;#QEW'B\xa1 u\xed\xe3\x1\x8b\x19\xef\xfa\xccZ\xf5\xcf\xfdk\xad\xabq]\x1K\xb3\xf0\xb8\xa2jy\xe1i\xe1\xe0\x46^5yW\xb8\x43IEW\x85\xcf\x85\xbd\x9a\\P\xf8\xde\xd4\x63\x15.\x99\x9c\xac\xf0\x97\xc9Z$\x1c\0k\x9b\xb0;Y\xc3\xb1\x1aVRZFX^\x8e'\x93^W~\xef\x63\xbe\xc4\x11\xcf\xce\xcfI\xec\x16\xef\x42'\xcc\x14~\xdc\xcc\x30I\0\x1f\x83\x8c\xc9\xec\xa3\x8f!\xfa\x65\x45\x9d\xfc\x81\x9f\xfcYr\x92\xab\xc8\xacR@c\x8d$)\xf2xE]\x97\xeaq\x89\t\xd1\xe3\x32\xd2\x14\xcc\xfe\xff\xed\xab\x9e\x18\x1e\xaaTw\xf8\xa1\xe1\xc5\x30\xdez\xa0i\a\xca\x45\xc3\xf8<6\x8c\xf2\t\xd8\x9e\xe1*[\xcd\xcf\x1d\xc1\xe8\xbb\xe8\xc5\xaa\xe6\x39\x4\xd7\x16\\\\W\xb5\xd8\x1e\\nC\xe7\x93\x1a\xd5\xa2?\x92M\xdc\x9aH\xc0\xeb\x19\x38\x17\xa1\xfd\x16Z\x96+=\xfb\xdd\xe7\xf4\x11\"\x9b\xf2U7\xb0\x7f\0\xbdr\xde\xb5\xf2\r+\xf4g\xcbwA\x5\xc7\0\0\0\tpHYs\0\0=\x84\0\0=\x84\x1\xd5\xac\xaft\0\0\x6\xdaIDATX\x85\xed\x99mp\x15W\x19\xc7\x7fgor\xc3\xbd\xb9I/\x91$\xb7XB^J\x81\x84*F\xd3\x32\x86ON\xd5\nh\xb4\x36\x43p\xa0\xadT3\x9d\x86\xfa\xc1R-\x1a\x64\xca@\xeb\xe0\x94\xce\xd8\xb4\xe\x38\x3\xbe\xd1J\x98I\xc6\t\x16\x11\x61\x98\x90\x12\xa0)\x4\xa5-\xa0\xd4\xb6\xa9\x91\x4\b$$$\xdc\xdd=\xc7\xf\xbb\xf7}s\xefM\x88\xce\x38\xc3\x7f\x66gw\xcfy\xces\xfe\xe7\x7f\x9e}\xce\xd9]\xb8\x8d\xdb\xf8\xff\x86H\xc7\x66\xfb\xa7\x1f\x99\x93\xe5r\xd7(\xb4O\xe9(\x7fP\xa1\x19h\x18\n\x82\x80\x1\x18\x12\fD\xf8^W\x1a:`(\xd0\xb1\xe\x43\n\xeb\f\x4\x95\xc0\x4\x19\x84k\x86\x14\x7f\xbd\xa1\x8f\xfd\xe1\xf4\xfb\xbf\xfc;\xa0&M\xf8\x95\x5\x35\xb3\n\xb2\xf2\xb7*\xa1=\xa4\x83\x66u.\xd0U\x88\x94M\xc0>[\xe5V\xbdn\xd7\x1bv\xb9\x8e \xdc><\x90\x88\xaf\xa0\x12\xd2P\xb2\xe5\xfa\xf5K\xdf\xbf\xd0\xfb\xfb\x9e\xf1\x38i\xe3U\xec\xacX^U\xe4\xc9?\x91\xa1\x89\x87\x93\xd9M\b!y\x1c\x34\x14\xa0!\xb4\x87\xa7\xf9\n\xdf*-}\xacj<\x17\x8e\x44\x9a\xe6/\x99]\xe8\xcdi\x13\x88\xc0\x94\x10\r1U\xb1\xb7\x8e\x96\x42\x14\x66\x64\xdc\xd1\x16\b|\xbd\xd8\xa9\xde\x89\xb0\xab\xd4[\xf8\x92&D\xe1-qt\xea\xcc\xa5QQu7\xbe\\OR;!D\xa1\x37\xe7\xae\xad\x80+\xa1.\xbe\xe0\x17\xf3\x97.\xb8\xc7w\xe7i]\b-\x14_\xae|?\xca\x33\r\x13+\xf6\f\xac\xd8\x8c\xbf\xbe\xd2{\x95\xb1\x9b\x86\x63\fKM\xa3~\xf3*\x16/\xab\xa2\xe7\xc2\x45~\xb8\xba\x89\xfe\xcb\xd7\x93=\xfr\xe4\xc6\xbf\x16\xf6}\xbc\xe7o\xd1\xfc\x32\xe2\t\xcf\xf2\xe4\xd5 \"\xcag\x97\x4X\xf8\xf2\x1a\x84\x96:\x8c/\x1c=K\xebO^O(\xd7\\\x1a\xdf\xdd\xbc\x8a\xea\xa5Vh\xce*\v\xf0\xd3\x1dkX\xbb\xba\x89\xbeK\xd7\x63l\xa3\xc2[sg\xe5\x7f\r\x88!\x1c\xcf\x42\xcb\xd4\\\xf7\x46\x17\x64\xfa}\x16Y\xa5PI\xe\x80\xbc\xd9\xf9\x31\xcf\x93\0\x34-BV)\xc5\xa9\x8e\xf7\x30\f\x93\xa2\xb2\0?\xdb\xb1\x86\xbc\x19\x39\x31\x4\x62\xa6\\h\xf7\xc6s\x8cWX\b\x81?^\xa1\xd0\xc8\xf|s\x13\xba\x94\x91\xa9\xc6\x9a\xc2\x39\xcb\xaaXT\xff`B\x87\xc2%X\xbdi\x15\xf7\xdb\xca\x1e\xd8\xdd\xc1\xb6\xcd{\xb8\xef\x8b\vyz\xcb\xa3\xcc.\v\xf0\xd2\xce\x35<\xf5\xedW\x12\x94\xb6\x31=~\f\xf1\n\v\xa5\xc6Oa\xc6X\x10\x63L\xb7\xcf\x41\x8cQ\x1d},\x88\xd4\xcd\x4[M\xd3xt\xd3#Qd\x8f\xb0\xe3\xf9=(\x5\x1d\xfb\xbb\xd9\xf2\x83\xdf`\x18&\xc5\x65\x1^\xde\xd9\xc0'\xe2\x94\xb6U\xd2R\x11\x46\xa1\xc6M:\xe9,\x8b!\xc3o=\xb7\x92\xfbl\xb2\aw\x1f\xe1\xd7\x9b\x9bQ2\x12\x30G\xf6\x9f\xe2\x85g\"\xa4_\xfdU\x3\xb9\xb9\xde\xe4\xcb\x9c\x13\xe1\xa9\x80\x10P\xf9`%\0\xc3\xd7\x46hm\xda\x8bRDFl\x9f\xdb\xfts\xf2\xd8y\0J\xca\x2\xcc\x99;3\xa5(SNX\x1J\xc2\x9e\xe7\x9b\x91R\xe2\xf3g\xb3v[\x3\xbe\\oL\n\x10\x42\xf0\xbd\xc6Z\xaa\xaa\xe7\x1\xb0\xaf\xad\x8b\xee\xb7\xdfO\xe9\x7f\xca\t\x87\x14\xeal=\xcak\x1b_GJIqy\x11\xcfno \xfb\xe\xafM\x16\x9el\xac\xe5\xabu\x8b\x11\x42\xb0\xbf\xad\x8b\x8d\xebvaJ\x99\xd2\x7f\x42\x1e\x8e z\xe-\x14V\x97\x63H\x85\t\x98\xd6n\vSA^\xa9\xf3\n~\xb4\xa5\x13S\xc1\xaa\r+(\xa9(\xa2q{\x3\xcf\xd5\xbf\xca\xf2\xa7\x96\xb2\xa4n1\0\a\xda\xba\xd8\xb4n\x17\xa6\xa9\xc2\xbdN\x92p,Y!\x4\x95?^\x91\xd4\x99\x32\x63\x15R@Gk'&\xf0\xd8\x86\x15\x94V\x14\xd1\xf4\xc7\xf5\xe4\xf8\xb3\x1\xf8\xcb\xde.^\xf8\xd1.\xa4\x94\t\xfdM\x82\xb0\x85\x1b\x1f]b\xe4\xc3>\\>\xf\xa8\x88\x2N\xe7\xf3\x87\xcf\x38\xfaho\xe9\xc4\x44\xf0\xf8\x86\xba\x30\xd9\x43{\xbb\xd8\xb2\xeewae\xd3\x45J\xc2\xc1+\x83\x9cx\xe2\xe7\x8e{X\xa7\xfd\xf0xJ\x1dn\xe9\x44*X\xddXK\xfb\xbeSl]\xff\x1aRN\x8clJ\xc2i\xe7\xdd\x34q\xa8\xb5\x93?\xb7\x1e#\xa8@&\x19\\2$\xcd\x12\x13\x1f\x7fj(uk\x8eS\x86\x84\xdb\xef\x63\xd1\xc6\xc7\xc9\xf4Y\xab\x90\xf3!P\nN\xee=N\xc7\xee\xf6p\xdb\x65O.\xa1\xbc\xba<\xd6VE\xb5\xb1\xcb\xfa\xfb\ai|\xe6\xb7\xe8\x63zJ\xcdS\x12\xf6\xdf\x33\x8b\xc0\xa2\x8aTf\x96\x33o\x16oF\x11\xae~\xe8\xf3\xf8\v\x1c\xf7R1\x98\xf\xdc=w&\xdd\xa7?L\xddGZL\0\xa5\x14o\xbd\xd8\x8ci\xe7\x61I$\xf\xdf\xf5\xd9\x39\xcc{\xa0\x92\x8cL\x97\xe3lw\xbe\xd1\xc5\xf9\xee\x7f\"\x1\x33\xaa\x9d\xcb\x9d\x41\xfd\xda\x1a\x84\x10\x64\x66$\xbc\\L\x9cp\xcc\xf4(8\xd7|\x98\xa0T\tYB\x2\xf3\x1e\xa8Llc\xe3\xdd\xe3\xe7\x38\xd4r,\xe1\xad\xd9\xe5\xc9\xa2~mMZDC\xf8\xdf>tS\x90v\xa6t/\x91r\x80i(pK\xdb\xcb\x89\n2\xd5y\xdb\t\xf1\x84\x95\xb0\x42\xd2\xba\x99\x84\xc3[\r\xa3\xd8w:\x95\xe0.Aa\xa9\xd4\xe0\x64;K\xdc\xdf%1L\xa3J\xa1\x6\xe3\xad\xe3\xb3\x84\xbc\x61\x1a\xef\xba]\xee\x44O\x2\x96\x1f|\x91\xe8\x31\x87.]\xee\x8c\x84\xb2h\xac|\xb6\x96\xba\xa7\xbf\xe1P\x9f\x62x\xd2x'\x15\x61ur\xb0\xe7\x8d/\xcc\x98\xbb\x1e\x81&\0}x\x14\xa5\x14\x42\b\xdc\x39\xde\xa4\xfe\a\xff=\x10\x43\x61th\x14\x7f\x81\x9f,\x8f\x9b,\x8f\x83\b6LS\xd2\xdfw-\x81\xee\xe8\xc8\a\xfb\x88\nQp\x1e\xa2\xaf\xe5\x33+\x9b\xbd\x99\x9e\xaf\x84\xf2mNy\t\"\xdb\x13N\xf8\x86\x9d\xfc\x8d\xe8{\x5=\xe7>f\xe8\xeaH\xf8\xcb\x8f\xaf`:3J\x2Q\xed\xac\x85#\xbe\xdd\xe5\x81\x61\xde;\xdb\x1b\xf3\xb5\xe8\xa6\x31\xfa\xa7\xf.l\xab\x5\x86S\x11\xd6\xea\xee\\P\xb9\xf2\x93\xf7\xefSB\x9b\x11\xbb\x8d\xfc\xef}n\x8d\xf6\x15T\xf2\xf2\xc5\x8b\xedK\x86\x86\xba\xdf\x8eW\xd8i=Tg\x86\xfb\a\xf2\\\xdew\x8a\xb3\xf3\xbe\x84\xd0\xbc\x12\x81\xb4[:_\xa7\xaaO\xdf\xd6P\xf2\xca\xd5\xc1\x33\xdf\x19\x18\x38v\x14k\xdc\x31\x18o\x1\x37N\f\xf5|4l\x8c\x1e.\xf1\x16\x14\xb9]\x99%\x12\xc4\xe4\t\xa7\x35 \x19\xd4G\xe\xf6\xf6\x1dy\xe2\xf2\xc0\x89\x37\x81Q'b\xe9\xec\xe6\xa6\xd7\xce\xfc\xdc\xc2J\x7f\xf1\x97\xbd\xaei\xf3\xa4\x10\xb9\x6\x42\xe8\baH\xc2\xbf\0\x42_2#\xf7\xf6/\x3I\xccg-\x83\xf0/\x5\x65\xa2\xd4M%\x87\xc6\x8c\x9bg/\r\xfe\x63\x7f\xef\xc0\xf1\xd3\xc0U'e\xd3%\x1c\x82\v\xc8\xb4\xf\x17S\xb7\xa4[\x1b\x38\xfb\xaf\x81}}\x1b\xb7\x31\x11\xfc\a\x15\xfav.&\xc5\xfc~\0\0\0\0IEND\xae\x42`\x82)" +windowIconText= +windowModality=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x11Qt.WindowModality\x94\x93\x94\x8c\bNonModal\x94\x86\x94R\x94.) +windowModified=false +windowOpacity=1 +windowTitle=Advanced Dock Area + +[BECMainWindowNoRPC.AdvancedDockArea.CDockManager.ads%3A%3ACDockSplitter.ads%3A%3ACDockAreaWidget.BECQueue.dockWidgetScrollArea.qt_scrollarea_viewport.BECQueue] +acceptDrops=false +accessibleDescription= +accessibleIdentifier= +accessibleName= +autoFillBackground=false +baseSize=@Size(0 0) +compact_view=false +contextMenuPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0\x66\x80\x4\x95[\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x14Qt.ContextMenuPolicy\x94\x93\x94\x8c\x12\x44\x65\x66\x61ultContextMenu\x94\x86\x94R\x94.) +cursor=@Variant(\0\0\0J\0\0) +enabled=true +expand_popup=true +focusPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0U\x80\x4\x95J\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\xeQt.FocusPolicy\x94\x93\x94\x8c\aNoFocus\x94\x86\x94R\x94.) +font=@Variant(\0\0\0@\0\0\0$\0.\0\x41\0p\0p\0l\0\x65\0S\0y\0s\0t\0\x65\0m\0U\0I\0\x46\0o\0n\0t@*\0\0\0\0\0\0\xff\xff\xff\xff\x5\x1\0\x32\x10) +geometry=@Rect(0 0 1252 897) +hide_toolbar=false +inputMethodHints=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.InputMethodHint\x94\x93\x94\x8c\aImhNone\x94\x86\x94R\x94.) +label=BEC Queue +layoutDirection=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0]\x80\x4\x95R\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.LayoutDirection\x94\x93\x94\x8c\vLeftToRight\x94\x86\x94R\x94.) +locale=@Variant(\0\0\0\x12\0\0\0\n\0\x65\0n\0_\0\x43\0H) +maximumSize=@Size(16777215 16777215) +minimumSize=@Size(0 0) +mouseTracking=false +palette=@Variant(\0\0\0\x44\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0) +sizeIncrement=@Size(0 0) +sizePolicy=@Variant(\0\0\0K\0\0\0U) +statusTip= +styleSheet= +tabletTracking=false +toolTip= +toolTipDuration=-1 +tooltip=BEC Queue status +updatesEnabled=true +visible=true +whatsThis= +windowFilePath= +windowIcon="@Variant(\0\0\0\x45\0\0\0\x1\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0,\0\0\0,\b\x6\0\0\0\x1e\x84Z\x1\0\0\x1\x81iCCPsRGB IEC61966-2.1\0\0(\x91u\x91\xb9KCA\x10\x87\xbf\x1c\x1ehB\x4-,,\x82\x44\xab(^\x4m\x4\x13\x44\x85 !F\xf0j\x92g\xe!\xc7\xe3\xbd\x88\x4[\xc1\x36\xa0 \xdax\x15\xfa\x17h+X\v\x82\xa2\bb\x9dZ\xd1\x46\xc3s\x9e\x11\x12\xc4\xcc\x32;\xdf\xfevg\xd8\x9d\x5k$\xad\x64t\xfb\0\x64\xb2y-<\xe5w/,.\xb9\x9bJ4\xd0\x88\x13;#QEW'B\xa1 u\xed\xe3\x1\x8b\x19\xef\xfa\xccZ\xf5\xcf\xfdk\xad\xabq]\x1K\xb3\xf0\xb8\xa2jy\xe1i\xe1\xe0\x46^5yW\xb8\x43IEW\x85\xcf\x85\xbd\x9a\\P\xf8\xde\xd4\x63\x15.\x99\x9c\xac\xf0\x97\xc9Z$\x1c\0k\x9b\xb0;Y\xc3\xb1\x1aVRZFX^\x8e'\x93^W~\xef\x63\xbe\xc4\x11\xcf\xce\xcfI\xec\x16\xef\x42'\xcc\x14~\xdc\xcc\x30I\0\x1f\x83\x8c\xc9\xec\xa3\x8f!\xfa\x65\x45\x9d\xfc\x81\x9f\xfcYr\x92\xab\xc8\xacR@c\x8d$)\xf2xE]\x97\xeaq\x89\t\xd1\xe3\x32\xd2\x14\xcc\xfe\xff\xed\xab\x9e\x18\x1e\xaaTw\xf8\xa1\xe1\xc5\x30\xdez\xa0i\a\xca\x45\xc3\xf8<6\x8c\xf2\t\xd8\x9e\xe1*[\xcd\xcf\x1d\xc1\xe8\xbb\xe8\xc5\xaa\xe6\x39\x4\xd7\x16\\\\W\xb5\xd8\x1e\\nC\xe7\x93\x1a\xd5\xa2?\x92M\xdc\x9aH\xc0\xeb\x19\x38\x17\xa1\xfd\x16Z\x96+=\xfb\xdd\xe7\xf4\x11\"\x9b\xf2U7\xb0\x7f\0\xbdr\xde\xb5\xf2\r+\xf4g\xcbwA\x5\xc7\0\0\0\tpHYs\0\0=\x84\0\0=\x84\x1\xd5\xac\xaft\0\0\x6\xdaIDATX\x85\xed\x99mp\x15W\x19\xc7\x7fgor\xc3\xbd\xb9I/\x91$\xb7XB^J\x81\x84*F\xd3\x32\x86ON\xd5\nh\xb4\x36\x43p\xa0\xadT3\x9d\x86\xfa\xc1R-\x1a\x64\xca@\xeb\xe0\x94\xce\xd8\xb4\xe\x38\x3\xbe\xd1J\x98I\xc6\t\x16\x11\x61\x98\x90\x12\xa0)\x4\xa5-\xa0\xd4\xb6\xa9\x91\x4\b$$$\xdc\xdd=\xc7\xf\xbb\xf7}s\xefM\x88\xce\x38\xc3\x7f\x66gw\xcfy\xces\xfe\xe7\x7f\x9e}\xce\xd9]\xb8\x8d\xdb\xf8\xff\x86H\xc7\x66\xfb\xa7\x1f\x99\x93\xe5r\xd7(\xb4O\xe9(\x7fP\xa1\x19h\x18\n\x82\x80\x1\x18\x12\fD\xf8^W\x1a:`(\xd0\xb1\xe\x43\n\xeb\f\x4\x95\xc0\x4\x19\x84k\x86\x14\x7f\xbd\xa1\x8f\xfd\xe1\xf4\xfb\xbf\xfc;\xa0&M\xf8\x95\x5\x35\xb3\n\xb2\xf2\xb7*\xa1=\xa4\x83\x66u.\xd0U\x88\x94M\xc0>[\xe5V\xbdn\xd7\x1bv\xb9\x8e \xdc><\x90\x88\xaf\xa0\x12\xd2P\xb2\xe5\xfa\xf5K\xdf\xbf\xd0\xfb\xfb\x9e\xf1\x38i\xe3U\xec\xacX^U\xe4\xc9?\x91\xa1\x89\x87\x93\xd9M\b!y\x1c\x34\x14\xa0!\xb4\x87\xa7\xf9\n\xdf*-}\xacj<\x17\x8e\x44\x9a\xe6/\x99]\xe8\xcdi\x13\x88\xc0\x94\x10\r1U\xb1\xb7\x8e\x96\x42\x14\x66\x64\xdc\xd1\x16\b|\xbd\xd8\xa9\xde\x89\xb0\xab\xd4[\xf8\x92&D\xe1-qt\xea\xcc\xa5QQu7\xbe\\OR;!D\xa1\x37\xe7\xae\xad\x80+\xa1.\xbe\xe0\x17\xf3\x97.\xb8\xc7w\xe7i]\b-\x14_\xae|?\xca\x33\r\x13+\xf6\f\xac\xd8\x8c\xbf\xbe\xd2{\x95\xb1\x9b\x86\x63\fKM\xa3~\xf3*\x16/\xab\xa2\xe7\xc2\x45~\xb8\xba\x89\xfe\xcb\xd7\x93=\xfr\xe4\xc6\xbf\x16\xf6}\xbc\xe7o\xd1\xfc\x32\xe2\t\xcf\xf2\xe4\xd5 \"\xcag\x97\x4X\xf8\xf2\x1a\x84\x96:\x8c/\x1c=K\xebO^O(\xd7\\\x1a\xdf\xdd\xbc\x8a\xea\xa5Vh\xce*\v\xf0\xd3\x1dkX\xbb\xba\x89\xbeK\xd7\x63l\xa3\xc2[sg\xe5\x7f\r\x88!\x1c\xcf\x42\xcb\xd4\\\xf7\x46\x17\x64\xfa}\x16Y\xa5PI\xe\x80\xbc\xd9\xf9\x31\xcf\x93\0\x34-BV)\xc5\xa9\x8e\xf7\x30\f\x93\xa2\xb2\0?\xdb\xb1\x86\xbc\x19\x39\x31\x4\x62\xa6\\h\xf7\xc6s\x8cWX\b\x81?^\xa1\xd0\xc8\xf|s\x13\xba\x94\x91\xa9\xc6\x9a\xc2\x39\xcb\xaaXT\xff`B\x87\xc2%X\xbdi\x15\xf7\xdb\xca\x1e\xd8\xdd\xc1\xb6\xcd{\xb8\xef\x8b\vyz\xcb\xa3\xcc.\v\xf0\xd2\xce\x35<\xf5\xedW\x12\x94\xb6\x31=~\f\xf1\n\v\xa5\xc6Oa\xc6X\x10\x63L\xb7\xcf\x41\x8cQ\x1d},\x88\xd4\xcd\x4[M\xd3xt\xd3#Qd\x8f\xb0\xe3\xf9=(\x5\x1d\xfb\xbb\xd9\xf2\x83\xdf`\x18&\xc5\x65\x1^\xde\xd9\xc0'\xe2\x94\xb6U\xd2R\x11\x46\xa1\xc6M:\xe9,\x8b!\xc3o=\xb7\x92\xfbl\xb2\aw\x1f\xe1\xd7\x9b\x9bQ2\x12\x30G\xf6\x9f\xe2\x85g\"\xa4_\xfdU\x3\xb9\xb9\xde\xe4\xcb\x9c\x13\xe1\xa9\x80\x10P\xf9`%\0\xc3\xd7\x46hm\xda\x8bRDFl\x9f\xdb\xfts\xf2\xd8y\0J\xca\x2\xcc\x99;3\xa5(SNX\x1J\xc2\x9e\xe7\x9b\x91R\xe2\xf3g\xb3v[\x3\xbe\\oL\n\x10\x42\xf0\xbd\xc6Z\xaa\xaa\xe7\x1\xb0\xaf\xad\x8b\xee\xb7\xdfO\xe9\x7f\xca\t\x87\x14\xeal=\xcak\x1b_GJIqy\x11\xcfno \xfb\xe\xafM\x16\x9el\xac\xe5\xabu\x8b\x11\x42\xb0\xbf\xad\x8b\x8d\xebvaJ\x99\xd2\x7f\x42\x1e\x8e z\xe-\x14V\x97\x63H\x85\t\x98\xd6n\vSA^\xa9\xf3\n~\xb4\xa5\x13S\xc1\xaa\r+(\xa9(\xa2q{\x3\xcf\xd5\xbf\xca\xf2\xa7\x96\xb2\xa4n1\0\a\xda\xba\xd8\xb4n\x17\xa6\xa9\xc2\xbdN\x92p,Y!\x4\x95?^\x91\xd4\x99\x32\x63\x15R@Gk'&\xf0\xd8\x86\x15\x94V\x14\xd1\xf4\xc7\xf5\xe4\xf8\xb3\x1\xf8\xcb\xde.^\xf8\xd1.\xa4\x94\t\xfdM\x82\xb0\x85\x1b\x1f]b\xe4\xc3>\\>\xf\xa8\x88\x2N\xe7\xf3\x87\xcf\x38\xfaho\xe9\xc4\x44\xf0\xf8\x86\xba\x30\xd9\x43{\xbb\xd8\xb2\xeewae\xd3\x45J\xc2\xc1+\x83\x9cx\xe2\xe7\x8e{X\xa7\xfd\xf0xJ\x1dn\xe9\x44*X\xddXK\xfb\xbeSl]\xff\x1aRN\x8clJ\xc2i\xe7\xdd\x34q\xa8\xb5\x93?\xb7\x1e#\xa8@&\x19\\2$\xcd\x12\x13\x1f\x7fj(uk\x8eS\x86\x84\xdb\xef\x63\xd1\xc6\xc7\xc9\xf4Y\xab\x90\xf3!P\nN\xee=N\xc7\xee\xf6p\xdb\x65O.\xa1\xbc\xba<\xd6VE\xb5\xb1\xcb\xfa\xfb\ai|\xe6\xb7\xe8\x63zJ\xcdS\x12\xf6\xdf\x33\x8b\xc0\xa2\x8aTf\x96\x33o\x16oF\x11\xae~\xe8\xf3\xf8\v\x1c\xf7R1\x98\xf\xdc=w&\xdd\xa7?L\xddGZL\0\xa5\x14o\xbd\xd8\x8ci\xe7\x61I$\xf\xdf\xf5\xd9\x39\xcc{\xa0\x92\x8cL\x97\xe3lw\xbe\xd1\xc5\xf9\xee\x7f\"\x1\x33\xaa\x9d\xcb\x9d\x41\xfd\xda\x1a\x84\x10\x64\x66$\xbc\\L\x9cp\xcc\xf4(8\xd7|\x98\xa0T\tYB\x2\xf3\x1e\xa8Llc\xe3\xdd\xe3\xe7\x38\xd4r,\xe1\xad\xd9\xe5\xc9\xa2~mMZDC\xf8\xdf>tS\x90v\xa6t/\x91r\x80i(pK\xdb\xcb\x89\n2\xd5y\xdb\t\xf1\x84\x95\xb0\x42\xd2\xba\x99\x84\xc3[\r\xa3\xd8w:\x95\xe0.Aa\xa9\xd4\xe0\x64;K\xdc\xdf%1L\xa3J\xa1\x6\xe3\xad\xe3\xb3\x84\xbc\x61\x1a\xef\xba]\xee\x44O\x2\x96\x1f|\x91\xe8\x31\x87.]\xee\x8c\x84\xb2h\xac|\xb6\x96\xba\xa7\xbf\xe1P\x9f\x62x\xd2x'\x15\x61ur\xb0\xe7\x8d/\xcc\x98\xbb\x1e\x81&\0}x\x14\xa5\x14\x42\b\xdc\x39\xde\xa4\xfe\a\xff=\x10\x43\x61th\x14\x7f\x81\x9f,\x8f\x9b,\x8f\x83\b6LS\xd2\xdfw-\x81\xee\xe8\xc8\a\xfb\x88\nQp\x1e\xa2\xaf\xe5\x33+\x9b\xbd\x99\x9e\xaf\x84\xf2mNy\t\"\xdb\x13N\xf8\x86\x9d\xfc\x8d\xe8{\x5=\xe7>f\xe8\xeaH\xf8\xcb\x8f\xaf`:3J\x2Q\xed\xac\x85#\xbe\xdd\xe5\x81\x61\xde;\xdb\x1b\xf3\xb5\xe8\xa6\x31\xfa\xa7\xf.l\xab\x5\x86S\x11\xd6\xea\xee\\P\xb9\xf2\x93\xf7\xefSB\x9b\x11\xbb\x8d\xfc\xef}n\x8d\xf6\x15T\xf2\xf2\xc5\x8b\xedK\x86\x86\xba\xdf\x8eW\xd8i=Tg\x86\xfb\a\xf2\\\xdew\x8a\xb3\xf3\xbe\x84\xd0\xbc\x12\x81\xb4[:_\xa7\xaaO\xdf\xd6P\xf2\xca\xd5\xc1\x33\xdf\x19\x18\x38v\x14k\xdc\x31\x18o\x1\x37N\f\xf5|4l\x8c\x1e.\xf1\x16\x14\xb9]\x99%\x12\xc4\xe4\t\xa7\x35 \x19\xd4G\xe\xf6\xf6\x1dy\xe2\xf2\xc0\x89\x37\x81Q'b\xe9\xec\xe6\xa6\xd7\xce\xfc\xdc\xc2J\x7f\xf1\x97\xbd\xaei\xf3\xa4\x10\xb9\x6\x42\xe8\baH\xc2\xbf\0\x42_2#\xf7\xf6/\x3I\xccg-\x83\xf0/\x5\x65\xa2\xd4M%\x87\xc6\x8c\x9bg/\r\xfe\x63\x7f\xef\xc0\xf1\xd3\xc0U'e\xd3%\x1c\x82\v\xc8\xb4\xf\x17S\xb7\xa4[\x1b\x38\xfb\xaf\x81}}\x1b\xb7\x31\x11\xfc\a\x15\xfav.&\xc5\xfc~\0\0\0\0IEND\xae\x42`\x82)" +windowIconText= +windowModality=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x11Qt.WindowModality\x94\x93\x94\x8c\bNonModal\x94\x86\x94R\x94.) +windowModified=false +windowOpacity=1 +windowTitle= + +[BECMainWindowNoRPC.AdvancedDockArea.CDockManager.ads%3A%3ACDockSplitter.ads%3A%3ACDockAreaWidget.Waveform.dockWidgetScrollArea.qt_scrollarea_viewport.Waveform] +acceptDrops=false +accessibleDescription= +accessibleIdentifier= +accessibleName= +autoFillBackground=false +auto_range_x=true +auto_range_y=true +baseSize=@Size(0 0) +color_palette=plasma +contextMenuPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0\x66\x80\x4\x95[\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x14Qt.ContextMenuPolicy\x94\x93\x94\x8c\x12\x44\x65\x66\x61ultContextMenu\x94\x86\x94R\x94.) +cursor=@Variant(\0\0\0J\0\0) +curve_json=[] +enable_fps_monitor=false +enable_popups=true +enable_side_panel=false +enable_toolbar=true +enabled=true +focusPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0U\x80\x4\x95J\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\xeQt.FocusPolicy\x94\x93\x94\x8c\aNoFocus\x94\x86\x94R\x94.) +font=@Variant(\0\0\0@\0\0\0$\0.\0\x41\0p\0p\0l\0\x65\0S\0y\0s\0t\0\x65\0m\0U\0I\0\x46\0o\0n\0t@*\0\0\0\0\0\0\xff\xff\xff\xff\x5\x1\0\x32\x10) +geometry=@Rect(0 0 798 897) +inner_axes=true +inputMethodHints=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.InputMethodHint\x94\x93\x94\x8c\aImhNone\x94\x86\x94R\x94.) +layoutDirection=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0]\x80\x4\x95R\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.LayoutDirection\x94\x93\x94\x8c\vLeftToRight\x94\x86\x94R\x94.) +legend_label_size=9 +locale=@Variant(\0\0\0\x12\0\0\0\n\0\x65\0n\0_\0\x43\0H) +lock_aspect_ratio=false +max_dataset_size_mb=10 +maximumSize=@Size(16777215 16777215) +minimal_crosshair_precision=3 +minimumSize=@Size(0 0) +mouseTracking=false +outer_axes=false +palette=@Variant(\0\0\0\x44\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0) +sizeIncrement=@Size(0 0) +sizePolicy=@Variant(\0\0\0K\0\0\0U) +skip_large_dataset_check=false +skip_large_dataset_warning=false +statusTip= +styleSheet= +tabletTracking=false +title= +toolTip= +toolTipDuration=-1 +updatesEnabled=true +visible=true +whatsThis= +windowFilePath= +windowIcon="@Variant(\0\0\0\x45\0\0\0\x1\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0,\0\0\0,\b\x6\0\0\0\x1e\x84Z\x1\0\0\x1\x81iCCPsRGB IEC61966-2.1\0\0(\x91u\x91\xb9KCA\x10\x87\xbf\x1c\x1ehB\x4-,,\x82\x44\xab(^\x4m\x4\x13\x44\x85 !F\xf0j\x92g\xe!\xc7\xe3\xbd\x88\x4[\xc1\x36\xa0 \xdax\x15\xfa\x17h+X\v\x82\xa2\bb\x9dZ\xd1\x46\xc3s\x9e\x11\x12\xc4\xcc\x32;\xdf\xfevg\xd8\x9d\x5k$\xad\x64t\xfb\0\x64\xb2y-<\xe5w/,.\xb9\x9bJ4\xd0\x88\x13;#QEW'B\xa1 u\xed\xe3\x1\x8b\x19\xef\xfa\xccZ\xf5\xcf\xfdk\xad\xabq]\x1K\xb3\xf0\xb8\xa2jy\xe1i\xe1\xe0\x46^5yW\xb8\x43IEW\x85\xcf\x85\xbd\x9a\\P\xf8\xde\xd4\x63\x15.\x99\x9c\xac\xf0\x97\xc9Z$\x1c\0k\x9b\xb0;Y\xc3\xb1\x1aVRZFX^\x8e'\x93^W~\xef\x63\xbe\xc4\x11\xcf\xce\xcfI\xec\x16\xef\x42'\xcc\x14~\xdc\xcc\x30I\0\x1f\x83\x8c\xc9\xec\xa3\x8f!\xfa\x65\x45\x9d\xfc\x81\x9f\xfcYr\x92\xab\xc8\xacR@c\x8d$)\xf2xE]\x97\xeaq\x89\t\xd1\xe3\x32\xd2\x14\xcc\xfe\xff\xed\xab\x9e\x18\x1e\xaaTw\xf8\xa1\xe1\xc5\x30\xdez\xa0i\a\xca\x45\xc3\xf8<6\x8c\xf2\t\xd8\x9e\xe1*[\xcd\xcf\x1d\xc1\xe8\xbb\xe8\xc5\xaa\xe6\x39\x4\xd7\x16\\\\W\xb5\xd8\x1e\\nC\xe7\x93\x1a\xd5\xa2?\x92M\xdc\x9aH\xc0\xeb\x19\x38\x17\xa1\xfd\x16Z\x96+=\xfb\xdd\xe7\xf4\x11\"\x9b\xf2U7\xb0\x7f\0\xbdr\xde\xb5\xf2\r+\xf4g\xcbwA\x5\xc7\0\0\0\tpHYs\0\0=\x84\0\0=\x84\x1\xd5\xac\xaft\0\0\x6\xdaIDATX\x85\xed\x99mp\x15W\x19\xc7\x7fgor\xc3\xbd\xb9I/\x91$\xb7XB^J\x81\x84*F\xd3\x32\x86ON\xd5\nh\xb4\x36\x43p\xa0\xadT3\x9d\x86\xfa\xc1R-\x1a\x64\xca@\xeb\xe0\x94\xce\xd8\xb4\xe\x38\x3\xbe\xd1J\x98I\xc6\t\x16\x11\x61\x98\x90\x12\xa0)\x4\xa5-\xa0\xd4\xb6\xa9\x91\x4\b$$$\xdc\xdd=\xc7\xf\xbb\xf7}s\xefM\x88\xce\x38\xc3\x7f\x66gw\xcfy\xces\xfe\xe7\x7f\x9e}\xce\xd9]\xb8\x8d\xdb\xf8\xff\x86H\xc7\x66\xfb\xa7\x1f\x99\x93\xe5r\xd7(\xb4O\xe9(\x7fP\xa1\x19h\x18\n\x82\x80\x1\x18\x12\fD\xf8^W\x1a:`(\xd0\xb1\xe\x43\n\xeb\f\x4\x95\xc0\x4\x19\x84k\x86\x14\x7f\xbd\xa1\x8f\xfd\xe1\xf4\xfb\xbf\xfc;\xa0&M\xf8\x95\x5\x35\xb3\n\xb2\xf2\xb7*\xa1=\xa4\x83\x66u.\xd0U\x88\x94M\xc0>[\xe5V\xbdn\xd7\x1bv\xb9\x8e \xdc><\x90\x88\xaf\xa0\x12\xd2P\xb2\xe5\xfa\xf5K\xdf\xbf\xd0\xfb\xfb\x9e\xf1\x38i\xe3U\xec\xacX^U\xe4\xc9?\x91\xa1\x89\x87\x93\xd9M\b!y\x1c\x34\x14\xa0!\xb4\x87\xa7\xf9\n\xdf*-}\xacj<\x17\x8e\x44\x9a\xe6/\x99]\xe8\xcdi\x13\x88\xc0\x94\x10\r1U\xb1\xb7\x8e\x96\x42\x14\x66\x64\xdc\xd1\x16\b|\xbd\xd8\xa9\xde\x89\xb0\xab\xd4[\xf8\x92&D\xe1-qt\xea\xcc\xa5QQu7\xbe\\OR;!D\xa1\x37\xe7\xae\xad\x80+\xa1.\xbe\xe0\x17\xf3\x97.\xb8\xc7w\xe7i]\b-\x14_\xae|?\xca\x33\r\x13+\xf6\f\xac\xd8\x8c\xbf\xbe\xd2{\x95\xb1\x9b\x86\x63\fKM\xa3~\xf3*\x16/\xab\xa2\xe7\xc2\x45~\xb8\xba\x89\xfe\xcb\xd7\x93=\xfr\xe4\xc6\xbf\x16\xf6}\xbc\xe7o\xd1\xfc\x32\xe2\t\xcf\xf2\xe4\xd5 \"\xcag\x97\x4X\xf8\xf2\x1a\x84\x96:\x8c/\x1c=K\xebO^O(\xd7\\\x1a\xdf\xdd\xbc\x8a\xea\xa5Vh\xce*\v\xf0\xd3\x1dkX\xbb\xba\x89\xbeK\xd7\x63l\xa3\xc2[sg\xe5\x7f\r\x88!\x1c\xcf\x42\xcb\xd4\\\xf7\x46\x17\x64\xfa}\x16Y\xa5PI\xe\x80\xbc\xd9\xf9\x31\xcf\x93\0\x34-BV)\xc5\xa9\x8e\xf7\x30\f\x93\xa2\xb2\0?\xdb\xb1\x86\xbc\x19\x39\x31\x4\x62\xa6\\h\xf7\xc6s\x8cWX\b\x81?^\xa1\xd0\xc8\xf|s\x13\xba\x94\x91\xa9\xc6\x9a\xc2\x39\xcb\xaaXT\xff`B\x87\xc2%X\xbdi\x15\xf7\xdb\xca\x1e\xd8\xdd\xc1\xb6\xcd{\xb8\xef\x8b\vyz\xcb\xa3\xcc.\v\xf0\xd2\xce\x35<\xf5\xedW\x12\x94\xb6\x31=~\f\xf1\n\v\xa5\xc6Oa\xc6X\x10\x63L\xb7\xcf\x41\x8cQ\x1d},\x88\xd4\xcd\x4[M\xd3xt\xd3#Qd\x8f\xb0\xe3\xf9=(\x5\x1d\xfb\xbb\xd9\xf2\x83\xdf`\x18&\xc5\x65\x1^\xde\xd9\xc0'\xe2\x94\xb6U\xd2R\x11\x46\xa1\xc6M:\xe9,\x8b!\xc3o=\xb7\x92\xfbl\xb2\aw\x1f\xe1\xd7\x9b\x9bQ2\x12\x30G\xf6\x9f\xe2\x85g\"\xa4_\xfdU\x3\xb9\xb9\xde\xe4\xcb\x9c\x13\xe1\xa9\x80\x10P\xf9`%\0\xc3\xd7\x46hm\xda\x8bRDFl\x9f\xdb\xfts\xf2\xd8y\0J\xca\x2\xcc\x99;3\xa5(SNX\x1J\xc2\x9e\xe7\x9b\x91R\xe2\xf3g\xb3v[\x3\xbe\\oL\n\x10\x42\xf0\xbd\xc6Z\xaa\xaa\xe7\x1\xb0\xaf\xad\x8b\xee\xb7\xdfO\xe9\x7f\xca\t\x87\x14\xeal=\xcak\x1b_GJIqy\x11\xcfno \xfb\xe\xafM\x16\x9el\xac\xe5\xabu\x8b\x11\x42\xb0\xbf\xad\x8b\x8d\xebvaJ\x99\xd2\x7f\x42\x1e\x8e z\xe-\x14V\x97\x63H\x85\t\x98\xd6n\vSA^\xa9\xf3\n~\xb4\xa5\x13S\xc1\xaa\r+(\xa9(\xa2q{\x3\xcf\xd5\xbf\xca\xf2\xa7\x96\xb2\xa4n1\0\a\xda\xba\xd8\xb4n\x17\xa6\xa9\xc2\xbdN\x92p,Y!\x4\x95?^\x91\xd4\x99\x32\x63\x15R@Gk'&\xf0\xd8\x86\x15\x94V\x14\xd1\xf4\xc7\xf5\xe4\xf8\xb3\x1\xf8\xcb\xde.^\xf8\xd1.\xa4\x94\t\xfdM\x82\xb0\x85\x1b\x1f]b\xe4\xc3>\\>\xf\xa8\x88\x2N\xe7\xf3\x87\xcf\x38\xfaho\xe9\xc4\x44\xf0\xf8\x86\xba\x30\xd9\x43{\xbb\xd8\xb2\xeewae\xd3\x45J\xc2\xc1+\x83\x9cx\xe2\xe7\x8e{X\xa7\xfd\xf0xJ\x1dn\xe9\x44*X\xddXK\xfb\xbeSl]\xff\x1aRN\x8clJ\xc2i\xe7\xdd\x34q\xa8\xb5\x93?\xb7\x1e#\xa8@&\x19\\2$\xcd\x12\x13\x1f\x7fj(uk\x8eS\x86\x84\xdb\xef\x63\xd1\xc6\xc7\xc9\xf4Y\xab\x90\xf3!P\nN\xee=N\xc7\xee\xf6p\xdb\x65O.\xa1\xbc\xba<\xd6VE\xb5\xb1\xcb\xfa\xfb\ai|\xe6\xb7\xe8\x63zJ\xcdS\x12\xf6\xdf\x33\x8b\xc0\xa2\x8aTf\x96\x33o\x16oF\x11\xae~\xe8\xf3\xf8\v\x1c\xf7R1\x98\xf\xdc=w&\xdd\xa7?L\xddGZL\0\xa5\x14o\xbd\xd8\x8ci\xe7\x61I$\xf\xdf\xf5\xd9\x39\xcc{\xa0\x92\x8cL\x97\xe3lw\xbe\xd1\xc5\xf9\xee\x7f\"\x1\x33\xaa\x9d\xcb\x9d\x41\xfd\xda\x1a\x84\x10\x64\x66$\xbc\\L\x9cp\xcc\xf4(8\xd7|\x98\xa0T\tYB\x2\xf3\x1e\xa8Llc\xe3\xdd\xe3\xe7\x38\xd4r,\xe1\xad\xd9\xe5\xc9\xa2~mMZDC\xf8\xdf>tS\x90v\xa6t/\x91r\x80i(pK\xdb\xcb\x89\n2\xd5y\xdb\t\xf1\x84\x95\xb0\x42\xd2\xba\x99\x84\xc3[\r\xa3\xd8w:\x95\xe0.Aa\xa9\xd4\xe0\x64;K\xdc\xdf%1L\xa3J\xa1\x6\xe3\xad\xe3\xb3\x84\xbc\x61\x1a\xef\xba]\xee\x44O\x2\x96\x1f|\x91\xe8\x31\x87.]\xee\x8c\x84\xb2h\xac|\xb6\x96\xba\xa7\xbf\xe1P\x9f\x62x\xd2x'\x15\x61ur\xb0\xe7\x8d/\xcc\x98\xbb\x1e\x81&\0}x\x14\xa5\x14\x42\b\xdc\x39\xde\xa4\xfe\a\xff=\x10\x43\x61th\x14\x7f\x81\x9f,\x8f\x9b,\x8f\x83\b6LS\xd2\xdfw-\x81\xee\xe8\xc8\a\xfb\x88\nQp\x1e\xa2\xaf\xe5\x33+\x9b\xbd\x99\x9e\xaf\x84\xf2mNy\t\"\xdb\x13N\xf8\x86\x9d\xfc\x8d\xe8{\x5=\xe7>f\xe8\xeaH\xf8\xcb\x8f\xaf`:3J\x2Q\xed\xac\x85#\xbe\xdd\xe5\x81\x61\xde;\xdb\x1b\xf3\xb5\xe8\xa6\x31\xfa\xa7\xf.l\xab\x5\x86S\x11\xd6\xea\xee\\P\xb9\xf2\x93\xf7\xefSB\x9b\x11\xbb\x8d\xfc\xef}n\x8d\xf6\x15T\xf2\xf2\xc5\x8b\xedK\x86\x86\xba\xdf\x8eW\xd8i=Tg\x86\xfb\a\xf2\\\xdew\x8a\xb3\xf3\xbe\x84\xd0\xbc\x12\x81\xb4[:_\xa7\xaaO\xdf\xd6P\xf2\xca\xd5\xc1\x33\xdf\x19\x18\x38v\x14k\xdc\x31\x18o\x1\x37N\f\xf5|4l\x8c\x1e.\xf1\x16\x14\xb9]\x99%\x12\xc4\xe4\t\xa7\x35 \x19\xd4G\xe\xf6\xf6\x1dy\xe2\xf2\xc0\x89\x37\x81Q'b\xe9\xec\xe6\xa6\xd7\xce\xfc\xdc\xc2J\x7f\xf1\x97\xbd\xaei\xf3\xa4\x10\xb9\x6\x42\xe8\baH\xc2\xbf\0\x42_2#\xf7\xf6/\x3I\xccg-\x83\xf0/\x5\x65\xa2\xd4M%\x87\xc6\x8c\x9bg/\r\xfe\x63\x7f\xef\xc0\xf1\xd3\xc0U'e\xd3%\x1c\x82\v\xc8\xb4\xf\x17S\xb7\xa4[\x1b\x38\xfb\xaf\x81}}\x1b\xb7\x31\x11\xfc\a\x15\xfav.&\xc5\xfc~\0\0\0\0IEND\xae\x42`\x82)" +windowIconText= +windowModality=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x11Qt.WindowModality\x94\x93\x94\x8c\bNonModal\x94\x86\x94R\x94.) +windowModified=false +windowOpacity=1 +windowTitle= +x_entry= +x_grid=false +x_label= +x_limits=@Variant(\0\0\0\x1a\0\0\0\0\0\0\0\0?\xf0\0\0\0\0\0\0) +x_log=false +x_mode=auto +y_grid=false +y_label= +y_limits=@Variant(\0\0\0\x1a\0\0\0\0\0\0\0\0?\xf0\0\0\0\0\0\0) +y_log=false + +[BECMainWindowNoRPC.AdvancedDockArea.ModularToolBar.QWidget.DarkModeButton] +acceptDrops=false +accessibleDescription= +accessibleIdentifier= +accessibleName= +autoFillBackground=false +baseSize=@Size(0 0) +contextMenuPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0\x66\x80\x4\x95[\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x14Qt.ContextMenuPolicy\x94\x93\x94\x8c\x12\x44\x65\x66\x61ultContextMenu\x94\x86\x94R\x94.) +cursor=@Variant(\0\0\0J\0\0) +dark_mode_enabled=false +enabled=true +focusPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0U\x80\x4\x95J\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\xeQt.FocusPolicy\x94\x93\x94\x8c\aNoFocus\x94\x86\x94R\x94.) +font=@Variant(\0\0\0@\0\0\0$\0.\0\x41\0p\0p\0l\0\x65\0S\0y\0s\0t\0\x65\0m\0U\0I\0\x46\0o\0n\0t@*\0\0\0\0\0\0\xff\xff\xff\xff\x5\x1\0\x32\x10) +geometry=@Rect(0 0 40 40) +inputMethodHints=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.InputMethodHint\x94\x93\x94\x8c\aImhNone\x94\x86\x94R\x94.) +layoutDirection=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0]\x80\x4\x95R\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.LayoutDirection\x94\x93\x94\x8c\vLeftToRight\x94\x86\x94R\x94.) +locale=@Variant(\0\0\0\x12\0\0\0\n\0\x65\0n\0_\0\x43\0H) +maximumSize=@Size(40 40) +minimumSize=@Size(40 40) +mouseTracking=false +palette=@Variant(\0\0\0\x44\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0) +sizeIncrement=@Size(0 0) +sizePolicy=@Variant(\0\0\0K\0\0\0U) +statusTip= +styleSheet= +tabletTracking=false +toolTip= +toolTipDuration=-1 +updatesEnabled=true +visible=true +whatsThis= +windowFilePath= +windowIcon="@Variant(\0\0\0\x45\0\0\0\x1\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0,\0\0\0,\b\x6\0\0\0\x1e\x84Z\x1\0\0\x1\x81iCCPsRGB IEC61966-2.1\0\0(\x91u\x91\xb9KCA\x10\x87\xbf\x1c\x1ehB\x4-,,\x82\x44\xab(^\x4m\x4\x13\x44\x85 !F\xf0j\x92g\xe!\xc7\xe3\xbd\x88\x4[\xc1\x36\xa0 \xdax\x15\xfa\x17h+X\v\x82\xa2\bb\x9dZ\xd1\x46\xc3s\x9e\x11\x12\xc4\xcc\x32;\xdf\xfevg\xd8\x9d\x5k$\xad\x64t\xfb\0\x64\xb2y-<\xe5w/,.\xb9\x9bJ4\xd0\x88\x13;#QEW'B\xa1 u\xed\xe3\x1\x8b\x19\xef\xfa\xccZ\xf5\xcf\xfdk\xad\xabq]\x1K\xb3\xf0\xb8\xa2jy\xe1i\xe1\xe0\x46^5yW\xb8\x43IEW\x85\xcf\x85\xbd\x9a\\P\xf8\xde\xd4\x63\x15.\x99\x9c\xac\xf0\x97\xc9Z$\x1c\0k\x9b\xb0;Y\xc3\xb1\x1aVRZFX^\x8e'\x93^W~\xef\x63\xbe\xc4\x11\xcf\xce\xcfI\xec\x16\xef\x42'\xcc\x14~\xdc\xcc\x30I\0\x1f\x83\x8c\xc9\xec\xa3\x8f!\xfa\x65\x45\x9d\xfc\x81\x9f\xfcYr\x92\xab\xc8\xacR@c\x8d$)\xf2xE]\x97\xeaq\x89\t\xd1\xe3\x32\xd2\x14\xcc\xfe\xff\xed\xab\x9e\x18\x1e\xaaTw\xf8\xa1\xe1\xc5\x30\xdez\xa0i\a\xca\x45\xc3\xf8<6\x8c\xf2\t\xd8\x9e\xe1*[\xcd\xcf\x1d\xc1\xe8\xbb\xe8\xc5\xaa\xe6\x39\x4\xd7\x16\\\\W\xb5\xd8\x1e\\nC\xe7\x93\x1a\xd5\xa2?\x92M\xdc\x9aH\xc0\xeb\x19\x38\x17\xa1\xfd\x16Z\x96+=\xfb\xdd\xe7\xf4\x11\"\x9b\xf2U7\xb0\x7f\0\xbdr\xde\xb5\xf2\r+\xf4g\xcbwA\x5\xc7\0\0\0\tpHYs\0\0=\x84\0\0=\x84\x1\xd5\xac\xaft\0\0\x6\xdaIDATX\x85\xed\x99mp\x15W\x19\xc7\x7fgor\xc3\xbd\xb9I/\x91$\xb7XB^J\x81\x84*F\xd3\x32\x86ON\xd5\nh\xb4\x36\x43p\xa0\xadT3\x9d\x86\xfa\xc1R-\x1a\x64\xca@\xeb\xe0\x94\xce\xd8\xb4\xe\x38\x3\xbe\xd1J\x98I\xc6\t\x16\x11\x61\x98\x90\x12\xa0)\x4\xa5-\xa0\xd4\xb6\xa9\x91\x4\b$$$\xdc\xdd=\xc7\xf\xbb\xf7}s\xefM\x88\xce\x38\xc3\x7f\x66gw\xcfy\xces\xfe\xe7\x7f\x9e}\xce\xd9]\xb8\x8d\xdb\xf8\xff\x86H\xc7\x66\xfb\xa7\x1f\x99\x93\xe5r\xd7(\xb4O\xe9(\x7fP\xa1\x19h\x18\n\x82\x80\x1\x18\x12\fD\xf8^W\x1a:`(\xd0\xb1\xe\x43\n\xeb\f\x4\x95\xc0\x4\x19\x84k\x86\x14\x7f\xbd\xa1\x8f\xfd\xe1\xf4\xfb\xbf\xfc;\xa0&M\xf8\x95\x5\x35\xb3\n\xb2\xf2\xb7*\xa1=\xa4\x83\x66u.\xd0U\x88\x94M\xc0>[\xe5V\xbdn\xd7\x1bv\xb9\x8e \xdc><\x90\x88\xaf\xa0\x12\xd2P\xb2\xe5\xfa\xf5K\xdf\xbf\xd0\xfb\xfb\x9e\xf1\x38i\xe3U\xec\xacX^U\xe4\xc9?\x91\xa1\x89\x87\x93\xd9M\b!y\x1c\x34\x14\xa0!\xb4\x87\xa7\xf9\n\xdf*-}\xacj<\x17\x8e\x44\x9a\xe6/\x99]\xe8\xcdi\x13\x88\xc0\x94\x10\r1U\xb1\xb7\x8e\x96\x42\x14\x66\x64\xdc\xd1\x16\b|\xbd\xd8\xa9\xde\x89\xb0\xab\xd4[\xf8\x92&D\xe1-qt\xea\xcc\xa5QQu7\xbe\\OR;!D\xa1\x37\xe7\xae\xad\x80+\xa1.\xbe\xe0\x17\xf3\x97.\xb8\xc7w\xe7i]\b-\x14_\xae|?\xca\x33\r\x13+\xf6\f\xac\xd8\x8c\xbf\xbe\xd2{\x95\xb1\x9b\x86\x63\fKM\xa3~\xf3*\x16/\xab\xa2\xe7\xc2\x45~\xb8\xba\x89\xfe\xcb\xd7\x93=\xfr\xe4\xc6\xbf\x16\xf6}\xbc\xe7o\xd1\xfc\x32\xe2\t\xcf\xf2\xe4\xd5 \"\xcag\x97\x4X\xf8\xf2\x1a\x84\x96:\x8c/\x1c=K\xebO^O(\xd7\\\x1a\xdf\xdd\xbc\x8a\xea\xa5Vh\xce*\v\xf0\xd3\x1dkX\xbb\xba\x89\xbeK\xd7\x63l\xa3\xc2[sg\xe5\x7f\r\x88!\x1c\xcf\x42\xcb\xd4\\\xf7\x46\x17\x64\xfa}\x16Y\xa5PI\xe\x80\xbc\xd9\xf9\x31\xcf\x93\0\x34-BV)\xc5\xa9\x8e\xf7\x30\f\x93\xa2\xb2\0?\xdb\xb1\x86\xbc\x19\x39\x31\x4\x62\xa6\\h\xf7\xc6s\x8cWX\b\x81?^\xa1\xd0\xc8\xf|s\x13\xba\x94\x91\xa9\xc6\x9a\xc2\x39\xcb\xaaXT\xff`B\x87\xc2%X\xbdi\x15\xf7\xdb\xca\x1e\xd8\xdd\xc1\xb6\xcd{\xb8\xef\x8b\vyz\xcb\xa3\xcc.\v\xf0\xd2\xce\x35<\xf5\xedW\x12\x94\xb6\x31=~\f\xf1\n\v\xa5\xc6Oa\xc6X\x10\x63L\xb7\xcf\x41\x8cQ\x1d},\x88\xd4\xcd\x4[M\xd3xt\xd3#Qd\x8f\xb0\xe3\xf9=(\x5\x1d\xfb\xbb\xd9\xf2\x83\xdf`\x18&\xc5\x65\x1^\xde\xd9\xc0'\xe2\x94\xb6U\xd2R\x11\x46\xa1\xc6M:\xe9,\x8b!\xc3o=\xb7\x92\xfbl\xb2\aw\x1f\xe1\xd7\x9b\x9bQ2\x12\x30G\xf6\x9f\xe2\x85g\"\xa4_\xfdU\x3\xb9\xb9\xde\xe4\xcb\x9c\x13\xe1\xa9\x80\x10P\xf9`%\0\xc3\xd7\x46hm\xda\x8bRDFl\x9f\xdb\xfts\xf2\xd8y\0J\xca\x2\xcc\x99;3\xa5(SNX\x1J\xc2\x9e\xe7\x9b\x91R\xe2\xf3g\xb3v[\x3\xbe\\oL\n\x10\x42\xf0\xbd\xc6Z\xaa\xaa\xe7\x1\xb0\xaf\xad\x8b\xee\xb7\xdfO\xe9\x7f\xca\t\x87\x14\xeal=\xcak\x1b_GJIqy\x11\xcfno \xfb\xe\xafM\x16\x9el\xac\xe5\xabu\x8b\x11\x42\xb0\xbf\xad\x8b\x8d\xebvaJ\x99\xd2\x7f\x42\x1e\x8e z\xe-\x14V\x97\x63H\x85\t\x98\xd6n\vSA^\xa9\xf3\n~\xb4\xa5\x13S\xc1\xaa\r+(\xa9(\xa2q{\x3\xcf\xd5\xbf\xca\xf2\xa7\x96\xb2\xa4n1\0\a\xda\xba\xd8\xb4n\x17\xa6\xa9\xc2\xbdN\x92p,Y!\x4\x95?^\x91\xd4\x99\x32\x63\x15R@Gk'&\xf0\xd8\x86\x15\x94V\x14\xd1\xf4\xc7\xf5\xe4\xf8\xb3\x1\xf8\xcb\xde.^\xf8\xd1.\xa4\x94\t\xfdM\x82\xb0\x85\x1b\x1f]b\xe4\xc3>\\>\xf\xa8\x88\x2N\xe7\xf3\x87\xcf\x38\xfaho\xe9\xc4\x44\xf0\xf8\x86\xba\x30\xd9\x43{\xbb\xd8\xb2\xeewae\xd3\x45J\xc2\xc1+\x83\x9cx\xe2\xe7\x8e{X\xa7\xfd\xf0xJ\x1dn\xe9\x44*X\xddXK\xfb\xbeSl]\xff\x1aRN\x8clJ\xc2i\xe7\xdd\x34q\xa8\xb5\x93?\xb7\x1e#\xa8@&\x19\\2$\xcd\x12\x13\x1f\x7fj(uk\x8eS\x86\x84\xdb\xef\x63\xd1\xc6\xc7\xc9\xf4Y\xab\x90\xf3!P\nN\xee=N\xc7\xee\xf6p\xdb\x65O.\xa1\xbc\xba<\xd6VE\xb5\xb1\xcb\xfa\xfb\ai|\xe6\xb7\xe8\x63zJ\xcdS\x12\xf6\xdf\x33\x8b\xc0\xa2\x8aTf\x96\x33o\x16oF\x11\xae~\xe8\xf3\xf8\v\x1c\xf7R1\x98\xf\xdc=w&\xdd\xa7?L\xddGZL\0\xa5\x14o\xbd\xd8\x8ci\xe7\x61I$\xf\xdf\xf5\xd9\x39\xcc{\xa0\x92\x8cL\x97\xe3lw\xbe\xd1\xc5\xf9\xee\x7f\"\x1\x33\xaa\x9d\xcb\x9d\x41\xfd\xda\x1a\x84\x10\x64\x66$\xbc\\L\x9cp\xcc\xf4(8\xd7|\x98\xa0T\tYB\x2\xf3\x1e\xa8Llc\xe3\xdd\xe3\xe7\x38\xd4r,\xe1\xad\xd9\xe5\xc9\xa2~mMZDC\xf8\xdf>tS\x90v\xa6t/\x91r\x80i(pK\xdb\xcb\x89\n2\xd5y\xdb\t\xf1\x84\x95\xb0\x42\xd2\xba\x99\x84\xc3[\r\xa3\xd8w:\x95\xe0.Aa\xa9\xd4\xe0\x64;K\xdc\xdf%1L\xa3J\xa1\x6\xe3\xad\xe3\xb3\x84\xbc\x61\x1a\xef\xba]\xee\x44O\x2\x96\x1f|\x91\xe8\x31\x87.]\xee\x8c\x84\xb2h\xac|\xb6\x96\xba\xa7\xbf\xe1P\x9f\x62x\xd2x'\x15\x61ur\xb0\xe7\x8d/\xcc\x98\xbb\x1e\x81&\0}x\x14\xa5\x14\x42\b\xdc\x39\xde\xa4\xfe\a\xff=\x10\x43\x61th\x14\x7f\x81\x9f,\x8f\x9b,\x8f\x83\b6LS\xd2\xdfw-\x81\xee\xe8\xc8\a\xfb\x88\nQp\x1e\xa2\xaf\xe5\x33+\x9b\xbd\x99\x9e\xaf\x84\xf2mNy\t\"\xdb\x13N\xf8\x86\x9d\xfc\x8d\xe8{\x5=\xe7>f\xe8\xeaH\xf8\xcb\x8f\xaf`:3J\x2Q\xed\xac\x85#\xbe\xdd\xe5\x81\x61\xde;\xdb\x1b\xf3\xb5\xe8\xa6\x31\xfa\xa7\xf.l\xab\x5\x86S\x11\xd6\xea\xee\\P\xb9\xf2\x93\xf7\xefSB\x9b\x11\xbb\x8d\xfc\xef}n\x8d\xf6\x15T\xf2\xf2\xc5\x8b\xedK\x86\x86\xba\xdf\x8eW\xd8i=Tg\x86\xfb\a\xf2\\\xdew\x8a\xb3\xf3\xbe\x84\xd0\xbc\x12\x81\xb4[:_\xa7\xaaO\xdf\xd6P\xf2\xca\xd5\xc1\x33\xdf\x19\x18\x38v\x14k\xdc\x31\x18o\x1\x37N\f\xf5|4l\x8c\x1e.\xf1\x16\x14\xb9]\x99%\x12\xc4\xe4\t\xa7\x35 \x19\xd4G\xe\xf6\xf6\x1dy\xe2\xf2\xc0\x89\x37\x81Q'b\xe9\xec\xe6\xa6\xd7\xce\xfc\xdc\xc2J\x7f\xf1\x97\xbd\xaei\xf3\xa4\x10\xb9\x6\x42\xe8\baH\xc2\xbf\0\x42_2#\xf7\xf6/\x3I\xccg-\x83\xf0/\x5\x65\xa2\xd4M%\x87\xc6\x8c\x9bg/\r\xfe\x63\x7f\xef\xc0\xf1\xd3\xc0U'e\xd3%\x1c\x82\v\xc8\xb4\xf\x17S\xb7\xa4[\x1b\x38\xfb\xaf\x81}}\x1b\xb7\x31\x11\xfc\a\x15\xfav.&\xc5\xfc~\0\0\0\0IEND\xae\x42`\x82)" +windowIconText= +windowModality=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x11Qt.WindowModality\x94\x93\x94\x8c\bNonModal\x94\x86\x94R\x94.) +windowModified=false +windowOpacity=1 +windowTitle= + +[BECMainWindowNoRPC.AdvancedDockArea.dockSettingsAction] +autoRepeat=true +checkable=false +checked=false +enabled=true +font=@Variant(\0\0\0@\0\0\0$\0.\0\x41\0p\0p\0l\0\x65\0S\0y\0s\0t\0\x65\0m\0U\0I\0\x46\0o\0n\0t@*\0\0\0\0\0\0\xff\xff\xff\xff\x5\x1\0\x32\x10) +icon="@Variant(\0\0\0\x45\0\0\0\x1\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0,\0\0\0,\b\x6\0\0\0\x1e\x84Z\x1\0\0\0\tpHYs\0\0\v\x13\0\0\v\x13\x1\0\x9a\x9c\x18\0\0\x4\xc9IDATX\x85\xed\x99]lSu\x18\xc6\x7f\xef\xe9\x6\x85\xb0\xa0\xc1\x8b\xe9\x2\x46\xe3`\xa0!\xa0\x88\x6\x35*\x9a\xb9\x96\xb6\v\xed*\x82\x80\x46\x12\xe3\x95\x1a\x14I\xc4\x8b\xde(~$&&\x10/d\x17\x80\xdf]7i\xb7u \xc1%\x9a\x18\xd4\xa0\xf1\x3\x18j\"D\x3\xc6\xc4\x44\x18\xee\xa3=\xff\xd7\v$t]{\xce\x61\xad\xf3\x66\xcf\xddy\xdf\xe7<\xefs\xcey\xfb?\xef\xf9\x17\xa6\x30\x85\x31\x90j\x88\xb4\x84\xa2\xab\x5iu\xe2(\xba\xaf\xaf\xbb\xb3\xab\xd2Z5\x95\n\0 \xb2\x1a\xd8\xe0\x42\xb2\x80\x8a\r[\x95\n\0\b,\xf4\xc0i\xaa\x46\xadj\x18\x16\xbc\x99i\xa2\n-X\xb1\xe1P(~\r0\xcb\x3\xb5.\x10\x88\x36TZ\xcf\xd1p(\x14o\b\x6\xe3\xf5N\x9c\x9c\xcf\xb8\xb6\xc3\x45H\xad:r\x83\xc1x}(\x14w\xbc(G\xc3y1;\xd5gN\x6\xc3\xb1]\xcd\xe1\xd8\xb8\xc7\x1e\x88\xc4\x96Z\x86-\xde\xec\x82\xaal[\xd5\xda\xb6\xa2\x38\xde\x12\x89/h\tG\xdfR\x9f\x39i\x8b\xd9\xe1\xa4Q\xb6\xa7VE\xa2\xf7\x1a\x95\x43\x45\xe1\x8c\x31\xf2Z~\x9a}tZNv \xf2\x90W\xb3\x63\xabJ\x97\xed\xb3\x9f\xf0\xd9\xd2\xa8*[\x4\"\x85^,\xd1\x95=\xe9\xceO<\x1b\x8e\xc7\xe3\xbe\xc1\x11\xf3\x15\xca\x92\x32%\x87\x1\xff\x84\xccz\xd1\x10\xbe\x99\x35\xddZ\x96L&\xed\xe2T\xc9\x96\x18\x1c\x31\x8f\x38\x98\xa5l\xa1\xcb\x43y\re\xc9\xf9!\xddX*5\xee\xeG\"\x91\xba\x9c\xd6\x9e\0\x1c\x7fl\x93\x80\x33\x43~\xab\xb1?\x99\x1c,\f\x8e\xbb\xc3\x39\xad\xdd\xca\xffo\x16\xa0~\xe6\x88n-\xe\x8e\xb9\xc3\xe1p|^\x1e\x33@u\x1ey50\\\x83\xb5 \x93I\x9e\xba\x18\x18s\x87m\xb1\x1f\xa6\x12\xb3\xa2{\xc0\xba\xa7Vr\xf5\x62[W\x83\xb9\xf\xf8`\xc2z\xe0\xcf\x61\xd6\x15\x6\xc6\f?\xcbo^\xfc\xca\xe1#\xdf\x8e\xa2\xb2\x1d\xa8\xbd\f\xe1?Ty\xb4/\xd3\xd9[\x14?\x3\x1cj\x89\xb4}(\xaa\xed\xc0\x15\x97\xa1\x39\x82\xf0l_:\xb5\xb3\x30XrYk\tGo\x15\xe4}\xe0z/\xcajh\xe9\xebI\xedw\xe2\x4\xc3\xb1\x98\x42\x87G\xb3\xdfY\xc6\xac\xeb\xe9\xe9\xfa\xbe\x38QrY\xeb\xcbt~I\xde\xbf\x14x\xd7MY\xa0\xdd\xcd,@o&\x95\xc2K{(o\f\x9d\xab[^\xca,8\xcc\xc3\xd9\xec;g\x81\xf5\x81p\xb4\x1\xe4\xeer<\xa3V\xbb\xab\x89\x7f!b\xdaU\xad\x35\x65\xf3\xca\xc1\xde\xee\xd4\xd3N\x1an\xd3\x9a\"\xe2\x38\x89\xf9kr\xc7\\4.\x89\xe5\xc4\x91\xab\x16W\xbaiTe\x80\x9fL\xb8\x19\x16T\a\x9d\b\xc3\xf9\xda\xaa\x8d\x97\xa8\xfc\xe5\xa6Q\xb6\x87\xef\x8f\xc7gO\x1b\x36o*\x94\xed_\0K\xcc&\xe0s\xb7\x42\0\xaa\xd6&\x17\xc6\xca`8\xb6+?2\xf3\xa9\x3\a\xf6\x9e/\xc5(\xb9\xac\x5[c\xb7\x61xO\xe1:OF\xaa\xbd\xac\t'\x8c\xea\xda\xfd\x99\xce#\xc5)_\xe1\x41\"\x91\xb0\x66\xd4]\xf5\x1c\xca\xdb\xc0\x1cO\xe2\x80\b\xf\xdc\x30\x7f\xd1\xf?\x9d\x38\xf6\x63\xa9|K\xa4-\n\xb4\xe3\xfd-:G\x90\xc7\x1a\x9b\x16\xfe\xbd~\xed\x9a\xc3\xfd\xfd\xfdz\xe9Z\n\x10\b\xb7=\xf\xfa\xa2W\xa3\xc5P\xd8+X\xed\x62\x33`\xfbm\x91Qk\x11\xa2\x8f\v<8QM\x90m\xd9L\xc7K\x17\x8f\xc6\xf4\xb0m\xe5\xf6\xfaL\xcd\v\xc0\x8c\tI\xc3\x6\x30\x1b\xd4\aVN@\xd4\xfd$g\f\x91\x37{\n\x3\x63Z\xe2\xe7\x81\x81\xb3\x8d\v\x16NwzQL2\xb6g{:\xd3\x85\x81q\xcb\xda\x90\xdf\xf7*pz\xd2,\x95\xc7\xe9!\xbf\xf5Zq\xd0W\x1c\xf8\xe5\xe8\xd1\xd1\xf9\xf3o\xfc\x13\xc1q\xaf\xec\xbf\x86\xa2O\x1e\xecJ}Q\x1c/\xf9\xe2X\xbe\xec\xa6\xdd\xc0\xd7\xez\xc3U\xf0\xe4\xa4q\xe4\xf6[\x16\xef)\x95(i8\x91H\x18U\xd9\\\x14V\xe0#\xcb\x92;\xec\x1as-\x90\x9a\x98OP\xd8G\xde\x9a\x87\xe8\x9d\n\xfbJ\xd8\xda\x9cH$L\xa9s\x1d\xf7\xba\x2\xe1X\x17\x10\0v\xabX\xaf\xf7\xa5\x93\x3\x85\xf9U\xadm+T\xf5\x65U\xee\xf2\x62T\x84Om[\xb6\xed\xef\xe9\xf8\xec\x82\xef\vh\xe\xc7\x9a,\xf4\x19\x41\x36\"\xd2\x93MwD\xcbj8\x15\b\x85\xe2\r\x96\x35\x92O\xa7\xd3\xbf\x97\xbf\xa8\xb6\x66P\xd7y\xf8\x82\x61\xd3\xdc\x9b\xee\xfa\xb8\\>\x18\x8c\xd7[\x16\xbe\xee\xee\xe4o\x13\x32\xec\x5\xcd\xad\xads}\xa6\xe6\x94;\x13\xc8\xeb\xdcl\xb6\xf3\xd7J\xeaUc\a^\x2\xe1\xd8Y\xdcw0\xcf\x65\x33\xa9\xd9\x14\xb4\xc2\x44P\x8dyX\x81\xe3\x1ex\xc7\xa9\xd0,Ti\x80Wp\xff\xea\x10\xf5\xfc\x65\xe2\x84\xaa\xfc\xc7\x61!)\xd0Q'\x8e\x81\xb4S~\nS\x98 \xfe\x1\x1\xb5\x93\xa4\x97\x89\xb7\xcb\0\0\0\0IEND\xae\x42`\x82)" +iconText=Dock settings +iconVisibleInMenu=false +menuRole=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0`\x80\x4\x95U\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\rPySide6.QtGui\x94\x8c\x10QAction.MenuRole\x94\x93\x94\x8c\x11TextHeuristicRole\x94\x86\x94R\x94.) +priority=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0]\x80\x4\x95R\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\rPySide6.QtGui\x94\x8c\x10QAction.Priority\x94\x93\x94\x8c\xeNormalPriority\x94\x86\x94R\x94.) +shortcut= +shortcutContext=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0`\x80\x4\x95U\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.ShortcutContext\x94\x93\x94\x8c\xeWindowShortcut\x94\x86\x94R\x94.) +shortcutVisibleInContextMenu=false +statusTip= +text=Dock settings +toolTip=Dock settings +visible=true +whatsThis= + +[Perspectives] +1\Name=test +1\State="@ByteArray(\0\0\x1\xb3x\xdau\x90\x41O\xc3\x30\f\x85\xef\xfc\n+\xf7\xd1\xae\x12h\x87\x34\xd3V\xd8\x11\x98\xba\xb1sh\xcc\x14\xadMP\x92V\x3\xf1\xe3q\n\x8aV\x4\xa7\xd8\xcf/\xdfK\xcc\x97\xe7\xae\x85\x1\x9d\xd7\xd6\x94l~\x9d\x33@\xd3X\xa5\xcd\xb1\x64\xfb\xdd\x66\xb6`K\xc1\xb7\x61\xa5\x6i\x1aTw\xb6\x39\xd1\xac~\xf7\x1;xN\x17\x19\xec=\xba\xd4\x13\xa6\xb2&HmH\x89\x63\xc1S\xf\x9b\xd6\xca\x30\x6\xe4\xa4\xd7o\xad\xe\x81\xe4G\xa7\x91,a\x4|F@oB\xc9\n\xf2\xac\x1cJ\xd8\xc9\x97\x11\x5U\xef\x1c\xc6\xd1\x41\xe\xf8j]G\x8e\x83VG\f\xf0 ;\xbc\xd0\xa1j\xadG\x15\x83\x32\xc1\xb3\x88\x99\xc0\x8a\v\xd8\xfa\xbe\xda\xf6\xd8\xe3oX\xd2\xa7\xb0\x89\xe7\xc9z\x1d\xdf\x8dnm\xcf\x7f\xa7\xd6\xfa\x3\xbdX\xe4\x5\xcc\x8b\x9b[\xe0\xd9\xb7@\xe7\xcf\xff\xa9L+\xa2\xfa\x9f\x95\x8b\xab/_\xa2\x8f\x42)" +size=1 + +[mainWindow] +DockingState="@ByteArray(\0\0\x1\xb3x\xdau\x90\x41O\xc3\x30\f\x85\xef\xfc\n+\xf7\xd1\xae\x12h\x87\x34\xd3V\xd8\x11\x98\xba\xb1sh\xcc\x14\xadMP\x92V\x3\xf1\xe3q\n\x8aV\x4\xa7\xd8\xcf/\xdfK\xcc\x97\xe7\xae\x85\x1\x9d\xd7\xd6\x94l~\x9d\x33@\xd3X\xa5\xcd\xb1\x64\xfb\xdd\x66\xb6`K\xc1\xb7\x61\xa5\x6i\x1aTw\xb6\x39\xd1\xac~\xf7\x1;xN\x17\x19\xec=\xba\xd4\x13\xa6\xb2&HmH\x89\x63\xc1S\xf\x9b\xd6\xca\x30\x6\xe4\xa4\xd7o\xad\xe\x81\xe4G\xa7\x91,a\x4|F@oB\xc9\n\xf2\xac\x1cJ\xd8\xc9\x97\x11\x5U\xef\x1c\xc6\xd1\x41\xe\xf8j]G\x8e\x83VG\f\xf0 ;\xbc\xd0\xa1j\xadG\x15\x83\x32\xc1\xb3\x88\x99\xc0\x8a\v\xd8\xfa\xbe\xda\xf6\xd8\xe3oX\xd2\xa7\xb0\x89\xe7\xc9z\x1d\xdf\x8dnm\xcf\x7f\xa7\xd6\xfa\x3\xbdX\xe4\x5\xcc\x8b\x9b[\xe0\xd9\xb7@\xe7\xcf\xff\xa9L+\xa2\xfa\x9f\x95\x8b\xab/_\xa2\x8f\x42)" +Geometry=@ByteArray(\x1\xd9\xd0\xcb\0\x3\0\0\0\0\0\0\0\0\0\x1d\0\0\b\x1a\0\0\x3\xea\0\0\0\0\0\0\0\0\xff\xff\xff\xff\xff\xff\xff\xff\0\0\0\x1\0\0\0\0\xf\0\0\0\0\0\0\0\0\x1d\0\0\b\x1a\0\0\x3\xea) +State=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0\xf\x80\x4\x95\x4\0\0\0\0\0\0\0\x43\0\x94.) + +[manifest] +widgets\1\closable=true +widgets\1\floatable=true +widgets\1\movable=true +widgets\1\object_name=BECQueue +widgets\1\widget_class=BECQueue +widgets\2\closable=true +widgets\2\floatable=true +widgets\2\movable=true +widgets\2\object_name=PositionerBox +widgets\2\widget_class=PositionerBox +widgets\3\closable=true +widgets\3\floatable=true +widgets\3\movable=true +widgets\3\object_name=Waveform +widgets\3\widget_class=Waveform +widgets\size=3 diff --git a/bec_widgets/widgets/control/device_manager/components/__init__.py b/bec_widgets/widgets/control/device_manager/components/__init__.py index e69de29b..bec612ee 100644 --- a/bec_widgets/widgets/control/device_manager/components/__init__.py +++ b/bec_widgets/widgets/control/device_manager/components/__init__.py @@ -0,0 +1,4 @@ +from .device_table_view import DeviceTableView +from .dm_config_view import DMConfigView +from .dm_docstring_view import DocstringView +from .dm_ophyd_test import DMOphydTest diff --git a/bec_widgets/widgets/control/device_manager/components/_util.py b/bec_widgets/widgets/control/device_manager/components/_util.py new file mode 100644 index 00000000..fb1f6993 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/_util.py @@ -0,0 +1,53 @@ +import json +from typing import Any, Callable, Generator, Iterable, TypeVar + +from bec_lib.utils.json import ExtendedEncoder +from qtpy.QtCore import QByteArray, QMimeData, QObject, Signal # type: ignore +from qtpy.QtWidgets import QListWidgetItem + +from bec_widgets.widgets.control.device_manager.components.constants import ( + MIME_DEVICE_CONFIG, + SORT_KEY_ROLE, +) + +_T = TypeVar("_T") +_RT = TypeVar("_RT") + + +def yield_only_passing(fn: Callable[[_T], _RT], vals: Iterable[_T]) -> Generator[_RT, Any, None]: + for v in vals: + try: + yield fn(v) + except BaseException: + pass + + +def mimedata_from_configs(configs: Iterable[dict]) -> QMimeData: + """Takes an iterable of device configs, gives a QMimeData with the configs json-encoded under the type MIME_DEVICE_CONFIG""" + mime_obj = QMimeData() + byte_array = QByteArray(json.dumps(list(configs), cls=ExtendedEncoder).encode("utf-8")) + mime_obj.setData(MIME_DEVICE_CONFIG, byte_array) + return mime_obj + + +class SortableQListWidgetItem(QListWidgetItem): + """Store a sorting string key with .setData(SORT_KEY_ROLE, key) to be able to sort a list with + custom widgets and this item.""" + + def __gt__(self, other): + if (self_key := self.data(SORT_KEY_ROLE)) is None or ( + other_key := other.data(SORT_KEY_ROLE) + ) is None: + return False + return self_key.lower() > other_key.lower() + + def __lt__(self, other): + if (self_key := self.data(SORT_KEY_ROLE)) is None or ( + other_key := other.data(SORT_KEY_ROLE) + ) is None: + return False + return self_key.lower() < other_key.lower() + + +class SharedSelectionSignal(QObject): + proc = Signal(str) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/__init__.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/__init__.py new file mode 100644 index 00000000..83d4d4d0 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/__init__.py @@ -0,0 +1,3 @@ +from .available_device_resources import AvailableDeviceResources + +__all__ = ["AvailableDeviceResources"] diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py new file mode 100644 index 00000000..96759d7b --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py @@ -0,0 +1,230 @@ +from textwrap import dedent +from typing import NamedTuple +from uuid import uuid4 + +from bec_qthemes import material_icon +from qtpy.QtCore import QItemSelection, QSize, Signal +from qtpy.QtWidgets import QFrame, QHBoxLayout, QLabel, QListWidgetItem, QVBoxLayout, QWidget + +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.expandable_frame import ExpandableGroupFrame +from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal +from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_group_ui import ( + Ui_AvailableDeviceGroup, +) +from bec_widgets.widgets.control.device_manager.components.available_device_resources.device_resource_backend import ( + HashableDevice, +) +from bec_widgets.widgets.control.device_manager.components.constants import CONFIG_DATA_ROLE + + +def _warning_string(spec: HashableDevice): + name_warning = ( + "Device defined with multiple names! Please check:\n " + "\n ".join(spec.names) + if len(spec.names) > 1 + else "" + ) + source_warning = ( + "Device found in multiple source files! Please check:\n " + "\n ".join(spec._source_files) + if len(spec._source_files) > 1 + else "" + ) + return f"{name_warning}{source_warning}" + + +class _DeviceEntryWidget(QFrame): + + def __init__(self, device_spec: HashableDevice, parent=None, **kwargs): + super().__init__(parent, **kwargs) + self._device_spec = device_spec + self.included: bool = False + + self.setFrameStyle(0) + + self._layout = QVBoxLayout() + self._layout.setContentsMargins(2, 2, 2, 2) + self.setLayout(self._layout) + + self.setup_title_layout(device_spec) + self.check_and_display_warning() + + self.setToolTip(self._rich_text()) + + def _rich_text(self): + return dedent( + f""" +

{self._device_spec.name}:

+ + + + + +
description: {self._device_spec.description}
config: {self._device_spec.deviceConfig}
enabled: {self._device_spec.enabled}
read only: {self._device_spec.readOnly}
+ """ + ) + + def setup_title_layout(self, device_spec: HashableDevice): + self._title_layout = QHBoxLayout() + self._title_layout.setContentsMargins(0, 0, 0, 0) + self._title_container = QWidget(parent=self) + self._title_container.setLayout(self._title_layout) + + self._warning_label = QLabel() + self._title_layout.addWidget(self._warning_label) + + self.title = QLabel(device_spec.name) + self.title.setToolTip(device_spec.name) + self.title.setStyleSheet(self.title_style("#FF0000")) + self._title_layout.addWidget(self.title) + + self._title_layout.addStretch(1) + self._layout.addWidget(self._title_container) + + def check_and_display_warning(self): + if len(self._device_spec.names) == 1 and len(self._device_spec._source_files) == 1: + self._warning_label.setText("") + self._warning_label.setToolTip("") + else: + self._warning_label.setPixmap(material_icon("warning", size=(12, 12), color="#FFAA00")) + self._warning_label.setToolTip(_warning_string(self._device_spec)) + + @property + def device_hash(self): + return hash(self._device_spec) + + def title_style(self, color: str) -> str: + return f"QLabel {{ color: {color}; font-weight: bold; font-size: 10pt; }}" + + def setTitle(self, text: str): + self.title.setText(text) + + def set_included(self, included: bool): + self.included = included + self.title.setStyleSheet(self.title_style("#00FF00" if included else "#FF0000")) + + +class _DeviceEntry(NamedTuple): + list_item: QListWidgetItem + widget: _DeviceEntryWidget + + +class AvailableDeviceGroup(ExpandableGroupFrame, Ui_AvailableDeviceGroup): + + selected_devices = Signal(list) + + def __init__( + self, + parent=None, + name: str = "TagGroupTitle", + data: set[HashableDevice] = set(), + shared_selection_signal=SharedSelectionSignal(), + **kwargs, + ): + super().__init__(parent=parent, **kwargs) + self.setupUi(self) + + self._shared_selection_signal = shared_selection_signal + self._shared_selection_uuid = str(uuid4()) + self._shared_selection_signal.proc.connect(self._handle_shared_selection_signal) + self.device_list.selectionModel().selectionChanged.connect(self._on_selection_changed) + + self.title_text = name # type: ignore + self._mime_data = [] + self._devices: dict[str, _DeviceEntry] = {} + for device in data: + self._add_item(device) + self.device_list.sortItems() + self.setMinimumSize(self.device_list.sizeHint()) + self._update_num_included() + + def _add_item(self, device: HashableDevice): + item = QListWidgetItem(self.device_list) + device_dump = device.model_dump(exclude_defaults=True) + item.setData(CONFIG_DATA_ROLE, device_dump) + self._mime_data.append(device_dump) + widget = _DeviceEntryWidget(device, self) + item.setSizeHint(QSize(widget.width(), widget.height())) + self.device_list.setItemWidget(item, widget) + self.device_list.addItem(item) + self._devices[device.name] = _DeviceEntry(item, widget) + + def create_mime_data(self): + return self._mime_data + + def reset_devices_state(self): + for dev in self._devices.values(): + dev.widget.set_included(False) + self._update_num_included() + + def set_item_state(self, /, device_hash: int, included: bool): + for dev in self._devices.values(): + if dev.widget.device_hash == device_hash: + dev.widget.set_included(included) + self._update_num_included() + + def _update_num_included(self): + n_included = sum(int(dev.widget.included) for dev in self._devices.values()) + if n_included == 0: + color = "#FF0000" + elif n_included == len(self._devices): + color = "#00FF00" + else: + color = "#FFAA00" + self.n_included.setText(f"{n_included} / {len(self._devices)}") + self.n_included.setStyleSheet(f"QLabel {{ color: {color}; }}") + + def sizeHint(self) -> QSize: + if not getattr(self, "device_list", None) or not self.expanded: + return super().sizeHint() + return QSize( + max(150, self.device_list.viewport().width()), + self.device_list.sizeHintForRow(0) * self.device_list.count() + 50, + ) + + @SafeSlot(QItemSelection, QItemSelection) + def _on_selection_changed(self, selected: QItemSelection, deselected: QItemSelection) -> None: + self._shared_selection_signal.proc.emit(self._shared_selection_uuid) + config = [dev.as_normal_device().model_dump() for dev in self.get_selection()] + self.selected_devices.emit(config) + + @SafeSlot(str) + def _handle_shared_selection_signal(self, uuid: str): + if uuid != self._shared_selection_uuid: + self.device_list.clearSelection() + + def resizeEvent(self, event): + super().resizeEvent(event) + self.setMinimumHeight(self.sizeHint().height()) + self.setMaximumHeight(self.sizeHint().height()) + + def get_selection(self) -> set[HashableDevice]: + selection = self.device_list.selectedItems() + widgets = (w.widget for _, w in self._devices.items() if w.list_item in selection) + return set(w._device_spec for w in widgets) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}: {self.title_text}" + + +if __name__ == "__main__": + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + widget = AvailableDeviceGroup(name="Tag group 1") + for item in [ + HashableDevice( + **{ + "name": f"test_device_{i}", + "deviceClass": "TestDeviceClass", + "readoutPriority": "baseline", + "enabled": True, + } + ) + for i in range(5) + ]: + widget._add_item(item) + widget._update_num_included() + widget.show() + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py new file mode 100644 index 00000000..bea0a1c3 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py @@ -0,0 +1,56 @@ +from typing import TYPE_CHECKING + +from qtpy.QtCore import QMetaObject, Qt +from qtpy.QtWidgets import QFrame, QLabel, QListWidget, QVBoxLayout + +from bec_widgets.widgets.control.device_manager.components._util import mimedata_from_configs +from bec_widgets.widgets.control.device_manager.components.constants import ( + CONFIG_DATA_ROLE, + MIME_DEVICE_CONFIG, +) + +if TYPE_CHECKING: + from .available_device_group import AvailableDeviceGroup + + +class _DeviceListWiget(QListWidget): + + def _item_iter(self): + return (self.item(i) for i in range(self.count())) + + def all_configs(self): + return [item.data(CONFIG_DATA_ROLE) for item in self._item_iter()] + + def mimeTypes(self): + return [MIME_DEVICE_CONFIG] + + def mimeData(self, items): + return mimedata_from_configs(item.data(CONFIG_DATA_ROLE) for item in items) + + +class Ui_AvailableDeviceGroup(object): + def setupUi(self, AvailableDeviceGroup: "AvailableDeviceGroup"): + if not AvailableDeviceGroup.objectName(): + AvailableDeviceGroup.setObjectName("AvailableDeviceGroup") + AvailableDeviceGroup.setMinimumWidth(150) + + self.verticalLayout = QVBoxLayout() + self.verticalLayout.setObjectName("verticalLayout") + AvailableDeviceGroup.set_layout(self.verticalLayout) + + title_layout = AvailableDeviceGroup.get_title_layout() + + self.n_included = QLabel(AvailableDeviceGroup, text="...") + self.n_included.setObjectName("n_included") + title_layout.addWidget(self.n_included) + + self.device_list = _DeviceListWiget(AvailableDeviceGroup) + self.device_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection) + self.device_list.setObjectName("device_list") + self.device_list.setFrameStyle(0) + self.device_list.setDragEnabled(True) + self.device_list.setAcceptDrops(False) + self.device_list.setDefaultDropAction(Qt.DropAction.CopyAction) + self.verticalLayout.addWidget(self.device_list) + AvailableDeviceGroup.setFrameStyle(QFrame.Shadow.Plain | QFrame.Shape.Box) + QMetaObject.connectSlotsByName(AvailableDeviceGroup) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py new file mode 100644 index 00000000..93e81015 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py @@ -0,0 +1,128 @@ +from random import randint +from typing import Any, Iterable +from uuid import uuid4 + +from qtpy.QtCore import QItemSelection, Signal # type: ignore +from qtpy.QtWidgets import QWidget + +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.control.device_manager.components._util import ( + SharedSelectionSignal, + yield_only_passing, +) +from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources_ui import ( + Ui_availableDeviceResources, +) +from bec_widgets.widgets.control.device_manager.components.available_device_resources.device_resource_backend import ( + HashableDevice, + get_backend, +) +from bec_widgets.widgets.control.device_manager.components.constants import CONFIG_DATA_ROLE + + +class AvailableDeviceResources(BECWidget, QWidget, Ui_availableDeviceResources): + + selected_devices = Signal(list) # list[dict[str,Any]] of device configs currently selected + add_selected_devices = Signal(list) + del_selected_devices = Signal(list) + + def __init__(self, parent=None, shared_selection_signal=SharedSelectionSignal(), **kwargs): + super().__init__(parent=parent, **kwargs) + self.setupUi(self) + self._backend = get_backend() + self._shared_selection_signal = shared_selection_signal + self._shared_selection_uuid = str(uuid4()) + self._shared_selection_signal.proc.connect(self._handle_shared_selection_signal) + self.device_groups_list.selectionModel().selectionChanged.connect( + self._on_selection_changed + ) + self.grouping_selector.addItem("deviceTags") + self.grouping_selector.addItems(self._backend.allowed_sort_keys) + self._grouping_selection_changed("deviceTags") + self.grouping_selector.currentTextChanged.connect(self._grouping_selection_changed) + self.search_box.textChanged.connect(self.device_groups_list.update_filter) + + self.tb_add_selected.action.triggered.connect(self._add_selected_action) + self.tb_del_selected.action.triggered.connect(self._del_selected_action) + + def refresh_full_list(self, device_groups: dict[str, set[HashableDevice]]): + self.device_groups_list.clear() + for device_group, devices in device_groups.items(): + self._add_device_group(device_group, devices) + if self.grouping_selector.currentText == "deviceTags": + self._add_device_group("Untagged devices", self._backend.untagged_devices) + self.device_groups_list.sortItems() + + def _add_device_group(self, device_group: str, devices: set[HashableDevice]): + item, widget = self.device_groups_list.add_item( + device_group, + self.device_groups_list, + device_group, + devices, + shared_selection_signal=self._shared_selection_signal, + expanded=False, + ) + item.setData(CONFIG_DATA_ROLE, widget.create_mime_data()) + # Re-emit the selected items from a subgroup - all other selections should be disabled anyway + widget.selected_devices.connect(self.selected_devices) + + def resizeEvent(self, event): + super().resizeEvent(event) + for list_item, device_group_widget in self.device_groups_list.item_widget_pairs(): + list_item.setSizeHint(device_group_widget.sizeHint()) + + @SafeSlot() + def _add_selected_action(self): + self.add_selected_devices.emit(self.device_groups_list.any_selected_devices()) + + @SafeSlot() + def _del_selected_action(self): + self.del_selected_devices.emit(self.device_groups_list.any_selected_devices()) + + @SafeSlot(QItemSelection, QItemSelection) + def _on_selection_changed(self, selected: QItemSelection, deselected: QItemSelection) -> None: + self.selected_devices.emit(self.device_groups_list.selected_devices_from_groups()) + self._shared_selection_signal.proc.emit(self._shared_selection_uuid) + + @SafeSlot(str) + def _handle_shared_selection_signal(self, uuid: str): + if uuid != self._shared_selection_uuid: + self.device_groups_list.clearSelection() + + def _set_devices_state(self, devices: Iterable[HashableDevice], included: bool): + for device in devices: + for device_group in self.device_groups_list.widgets(): + device_group.set_item_state(hash(device), included) + + @SafeSlot(list) + def mark_devices_used(self, config_list: list[dict[str, Any]], used: bool): + """Set the display color of individual devices and update the group display of numbers + included. Accepts a list of dicts with the complete config as used in + bec_lib.atlas_models.Device.""" + self._set_devices_state( + yield_only_passing(HashableDevice.model_validate, config_list), used + ) + + @SafeSlot(str) + def _grouping_selection_changed(self, sort_key: str): + self.search_box.setText("") + if sort_key == "deviceTags": + device_groups = self._backend.tag_groups + else: + device_groups = self._backend.group_by_key(sort_key) + self.refresh_full_list(device_groups) + + +if __name__ == "__main__": + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + widget = AvailableDeviceResources() + widget._set_devices_state( + list(filter(lambda _: randint(0, 1) == 1, widget._backend.all_devices)), True + ) + widget.show() + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py new file mode 100644 index 00000000..05701864 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import itertools + +from qtpy.QtCore import QMetaObject, Qt +from qtpy.QtWidgets import ( + QAbstractItemView, + QComboBox, + QGridLayout, + QLabel, + QLineEdit, + QListView, + QListWidget, + QListWidgetItem, + QSizePolicy, + QVBoxLayout, +) + +from bec_widgets.utils.list_of_expandable_frames import ListOfExpandableFrames +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_manager.components._util import mimedata_from_configs +from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_group import ( + AvailableDeviceGroup, +) +from bec_widgets.widgets.control.device_manager.components.constants import ( + CONFIG_DATA_ROLE, + MIME_DEVICE_CONFIG, +) + + +class _ListOfDeviceGroups(ListOfExpandableFrames[AvailableDeviceGroup]): + + def itemWidget(self, item: QListWidgetItem) -> AvailableDeviceGroup: + return super().itemWidget(item) # type: ignore + + def any_selected_devices(self): + return self.selected_individual_devices() or self.selected_devices_from_groups() + + def selected_individual_devices(self): + for widget in (self.itemWidget(self.item(i)) for i in range(self.count())): + if (selected := widget.get_selection()) != set(): + return [dev.as_normal_device().model_dump() for dev in selected] + return [] + + def selected_devices_from_groups(self): + selected_items = (self.item(r.row()) for r in self.selectionModel().selectedRows()) + widgets = (self.itemWidget(item) for item in selected_items) + return list(itertools.chain.from_iterable(w.device_list.all_configs() for w in widgets)) + + def mimeTypes(self): + return [MIME_DEVICE_CONFIG] + + def mimeData(self, items): + return mimedata_from_configs( + itertools.chain.from_iterable(item.data(CONFIG_DATA_ROLE) for item in items) + ) + + +class Ui_availableDeviceResources(object): + def setupUi(self, availableDeviceResources): + if not availableDeviceResources.objectName(): + availableDeviceResources.setObjectName("availableDeviceResources") + self.verticalLayout = QVBoxLayout(availableDeviceResources) + self.verticalLayout.setObjectName("verticalLayout") + + self._add_toolbar() + + # Main area with search and filter using a grid layout + self.search_layout = QVBoxLayout() + self.grid_layout = QGridLayout() + + self.grouping_selector = QComboBox() + self.grouping_selector.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + lbl_group = QLabel("Group by:") + lbl_group.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.grid_layout.addWidget(lbl_group, 0, 0) + self.grid_layout.addWidget(self.grouping_selector, 0, 1) + + self.search_box = QLineEdit() + self.search_box.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + lbl_filter = QLabel("Filter:") + lbl_filter.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.grid_layout.addWidget(lbl_filter, 1, 0) + self.grid_layout.addWidget(self.search_box, 1, 1) + + self.grid_layout.setColumnStretch(0, 0) + self.grid_layout.setColumnStretch(1, 1) + + self.search_layout.addLayout(self.grid_layout) + self.verticalLayout.addLayout(self.search_layout) + + self.device_groups_list = _ListOfDeviceGroups( + availableDeviceResources, AvailableDeviceGroup + ) + self.device_groups_list.setObjectName("device_groups_list") + self.device_groups_list.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) + self.device_groups_list.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) + self.device_groups_list.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) + self.device_groups_list.setMovement(QListView.Movement.Static) + self.device_groups_list.setSpacing(4) + self.device_groups_list.setDragDropMode(QListWidget.DragDropMode.DragOnly) + self.device_groups_list.setSelectionBehavior(QListWidget.SelectionBehavior.SelectItems) + self.device_groups_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection) + self.device_groups_list.setDragEnabled(True) + self.device_groups_list.setAcceptDrops(False) + self.device_groups_list.setDefaultDropAction(Qt.DropAction.CopyAction) + self.device_groups_list.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + availableDeviceResources.setMinimumWidth(250) + availableDeviceResources.resize(250, availableDeviceResources.height()) + + self.verticalLayout.addWidget(self.device_groups_list) + + QMetaObject.connectSlotsByName(availableDeviceResources) + + def _add_toolbar(self): + self.toolbar = ModularToolBar(self) + io_bundle = ToolbarBundle("IO", self.toolbar.components) + + self.tb_add_selected = MaterialIconAction( + icon_name="add_box", parent=self, tooltip="Add selected devices to composition" + ) + self.toolbar.components.add_safe("add_selected", self.tb_add_selected) + io_bundle.add_action("add_selected") + + self.tb_del_selected = MaterialIconAction( + icon_name="chips", parent=self, tooltip="Remove selected devices from composition" + ) + self.toolbar.components.add_safe("del_selected", self.tb_del_selected) + io_bundle.add_action("del_selected") + + self.verticalLayout.addWidget(self.toolbar) + self.toolbar.add_bundle(io_bundle) + self.toolbar.show_bundles(["IO"]) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_resource_backend.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_resource_backend.py new file mode 100644 index 00000000..145d2110 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_resource_backend.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import operator +import os +from enum import Enum, auto +from functools import partial, reduce +from glob import glob +from pathlib import Path +from typing import Protocol + +import bec_lib +from bec_lib.atlas_models import HashableDevice, HashableDeviceSet +from bec_lib.bec_yaml_loader import yaml_load +from bec_lib.logger import bec_logger +from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path, plugins_installed + +logger = bec_logger.logger + +# use the last n recovery files +_N_RECOVERY_FILES = 3 +_BASE_REPO_PATH = Path(os.path.dirname(bec_lib.__file__)) / "../.." + + +def get_backend() -> DeviceResourceBackend: + return _ConfigFileBackend() + + +class HashModel(str, Enum): + DEFAULT = auto() + DEFAULT_DEVICECONFIG = auto() + DEFAULT_EPICS = auto() + + +class DeviceResourceBackend(Protocol): + @property + def tag_groups(self) -> dict[str, set[HashableDevice]]: + """A dictionary of all availble devices separated by tag groups. The same device may + appear more than once (in different groups).""" + ... + + @property + def all_devices(self) -> set[HashableDevice]: + """A set of all availble devices. The same device may not appear more than once.""" + ... + + @property + def untagged_devices(self) -> set[HashableDevice]: + """A set of all untagged devices. The same device may not appear more than once.""" + ... + + @property + def allowed_sort_keys(self) -> set[str]: + """A set of all fields which you may group devices by""" + ... + + def tags(self) -> set[str]: + """Returns a set of all the tags in all available devices.""" + ... + + def tag_group(self, tag: str) -> set[HashableDevice]: + """Returns a set of the devices in the tag group with the given key.""" + ... + + def group_by_key(self, key: str) -> dict[str, set[HashableDevice]]: + """Return a dict of all devices, organised by the specified key, which must be one of + the string keys in the Device model.""" + ... + + +def _devices_from_file(file: str, include_source: bool = True): + data = yaml_load(file, process_includes=False) + return HashableDeviceSet( + HashableDevice.model_validate( + dev | {"name": name, "source_files": {file} if include_source else set()} + ) + for name, dev in data.items() + ) + + +class _ConfigFileBackend(DeviceResourceBackend): + def __init__(self) -> None: + self._raw_device_set: set[HashableDevice] = self._get_config_from_backup_files() + if plugins_installed() == 1: + self._raw_device_set.update( + self._get_configs_from_plugin_files( + Path(plugin_repo_path()) / plugin_package_name() / "device_configs/" + ) + ) + self._device_groups = self._get_tag_groups() + + def _get_config_from_backup_files(self): + dir = _BASE_REPO_PATH / "logs/device_configs/recovery_configs" + files = sorted(glob("*.yaml", root_dir=dir)) + last_n_files = files[-_N_RECOVERY_FILES:] + return reduce( + operator.or_, + map( + partial(_devices_from_file, include_source=False), + (str(dir / f) for f in last_n_files), + ), + set(), + ) + + def _get_configs_from_plugin_files(self, dir: Path): + files = glob("*.yaml", root_dir=dir, recursive=True) + return reduce(operator.or_, map(_devices_from_file, (str(dir / f) for f in files)), set()) + + def _get_tag_groups(self) -> dict[str, set[HashableDevice]]: + return { + tag: set(filter(lambda dev: tag in dev.deviceTags, self._raw_device_set)) + for tag in self.tags() + } + + @property + def tag_groups(self): + return self._device_groups + + @property + def all_devices(self): + return self._raw_device_set + + @property + def untagged_devices(self): + return {d for d in self._raw_device_set if d.deviceTags == set()} + + @property + def allowed_sort_keys(self) -> set[str]: + return {n for n, info in HashableDevice.model_fields.items() if info.annotation is str} + + def tags(self) -> set[str]: + return reduce(operator.or_, (dev.deviceTags for dev in self._raw_device_set), set()) + + def tag_group(self, tag: str) -> set[HashableDevice]: + return self.tag_groups[tag] + + def group_by_key(self, key: str) -> dict[str, set[HashableDevice]]: + if key not in self.allowed_sort_keys: + raise ValueError(f"Cannot group available devices by model key {key}") + group_names: set[str] = {getattr(item, key) for item in self._raw_device_set} + return {g: {d for d in self._raw_device_set if getattr(d, key) == g} for g in group_names} diff --git a/bec_widgets/widgets/control/device_manager/components/constants.py b/bec_widgets/widgets/control/device_manager/components/constants.py new file mode 100644 index 00000000..b3f72051 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/constants.py @@ -0,0 +1,72 @@ +from typing import Final + +# Denotes a MIME type for JSON-encoded list of device config dictionaries +MIME_DEVICE_CONFIG: Final[str] = "application/x-bec_device_config" + +# Custom user roles +SORT_KEY_ROLE: Final[int] = 117 +CONFIG_DATA_ROLE: Final[int] = 118 + +# TODO 882 keep in sync with headers in device_table_view.py +HEADERS_HELP_MD: dict[str, str] = { + "status": "\n".join( + [ + "## Status", + "The current status of the device. Can be one of the following values: ", + "### **LOADED** \n The device with the specified configuration is loaded in the current config.", + "### **CONNECT_READY** \n The device config is valid and the connection has been validated. It has not yet been loaded to the current config.", + "### **CONNECT_FAILED** \n The device config is valid, but the connection could not be established.", + "### **VALID** \n The device config is valid, but the connection has not yet been validated.", + "### **INVALID** \n The device config is invalid and can not be loaded to the current config.", + ] + ), + "name": "\n".join(["## Name ", "The name of the device."]), + "deviceClass": "\n".join( + [ + "## Device Class", + "The device class specifies the type of the device. It will be used to create the instance.", + ] + ), + "readoutPriority": "\n".join( + [ + "## Readout Priority", + "The readout priority of the device. Can be one of the following values: ", + "### **monitored** \n The monitored priority is used for devices that are read out during the scan (i.e. at every step) and whose value may change during the scan.", + "### **baseline** \n The baseline priority is used for devices that are read out at the beginning of the scan and whose value does not change during the scan.", + "### **async** \n The async priority is used for devices that are asynchronous to the monitored devices, and send their data independently.", + "### **continuous** \n The continuous priority is used for devices that are read out continuously during the scan.", + "### **on_request** \n The on_request priority is used for devices that should not be read out during the scan, yet are configured to be read out manually.", + ] + ), + "deviceTags": "\n".join( + [ + "## Device Tags", + "A list of tags associated with the device. Tags can be used to group devices and filter them in the device manager.", + ] + ), + "enabled": "\n".join( + [ + "## Enabled", + "Indicator whether the device is enabled or disabled. Disabled devices can not be used.", + ] + ), + "readOnly": "\n".join( + ["## Read Only", "Indicator that a device is read-only or can be modified."] + ), + "onFailure": "\n".join( + [ + "## On Failure", + "Specifies the behavior of the device in case of a failure. Can be one of the following values: ", + "### **buffer** \n The device readback will fall back to the last known value.", + "### **retry** \n The device readback will be retried once, and raises an error if it fails again.", + "### **raise** \n The device readback will raise immediately.", + ] + ), + "softwareTrigger": "\n".join( + [ + "## Software Trigger", + "Indicator whether the device receives a software trigger from BEC during a scan.", + ] + ), + "description": "\n".join(["## Description", "A short description of the device."]), +} diff --git a/bec_widgets/widgets/control/device_manager/components/device_table_view.py b/bec_widgets/widgets/control/device_manager/components/device_table_view.py index b541916b..886b02c7 100644 --- a/bec_widgets/widgets/control/device_manager/components/device_table_view.py +++ b/bec_widgets/widgets/control/device_manager/components/device_table_view.py @@ -4,114 +4,327 @@ from __future__ import annotations import copy import json +import textwrap +from contextlib import contextmanager +from functools import partial +from typing import TYPE_CHECKING, Any, Iterable, List, Literal +from uuid import uuid4 +from bec_lib.atlas_models import Device from bec_lib.logger import bec_logger from bec_qthemes import material_icon from qtpy import QtCore, QtGui, QtWidgets +from qtpy.QtCore import QModelIndex, QPersistentModelIndex, Qt, QTimer +from qtpy.QtWidgets import QAbstractItemView, QHeaderView, QMessageBox from thefuzz import fuzz +from bec_widgets.utils.bec_signal_proxy import BECSignalProxy from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.colors import get_accent_colors from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal +from bec_widgets.widgets.control.device_manager.components.constants import ( + HEADERS_HELP_MD, + MIME_DEVICE_CONFIG, +) +from bec_widgets.widgets.control.device_manager.components.dm_ophyd_test import ValidationStatus + +if TYPE_CHECKING: # pragma: no cover + from bec_qthemes._theme import AccentColors logger = bec_logger.logger +_DeviceCfgIter = Iterable[dict[str, Any]] + # Threshold for fuzzy matching, careful with adjusting this. 80 seems good FUZZY_SEARCH_THRESHOLD = 80 +# +USER_CHECK_DATA_ROLE = 101 + class DictToolTipDelegate(QtWidgets.QStyledItemDelegate): """Delegate that shows all key-value pairs of a rows's data as a YAML-like tooltip.""" - @staticmethod - def dict_to_str(d: dict) -> str: - """Convert a dictionary to a formatted string.""" - return json.dumps(d, indent=4) - - def helpEvent(self, event, view, option, index): + def helpEvent( + self, + event: QtCore.QEvent, + view: QtWidgets.QAbstractItemView, + option: QtWidgets.QStyleOptionViewItem, + index: QModelIndex, + ): """Override to show tooltip when hovering.""" - if event.type() != QtCore.QEvent.ToolTip: + if event.type() != QtCore.QEvent.Type.ToolTip: return super().helpEvent(event, view, option, index) model: DeviceFilterProxyModel = index.model() model_index = model.mapToSource(index) - row_dict = model.sourceModel().row_data(model_index) - row_dict.pop("description", None) - QtWidgets.QToolTip.showText(event.globalPos(), self.dict_to_str(row_dict), view) + row_dict = model.sourceModel().get_row_data(model_index) + description = row_dict.get("description", "") + QtWidgets.QToolTip.showText(event.globalPos(), description, view) return True -class CenterCheckBoxDelegate(DictToolTipDelegate): +class CustomDisplayDelegate(DictToolTipDelegate): + _paint_test_role = Qt.ItemDataRole.DisplayRole + + def displayText(self, value: Any, locale: QtCore.QLocale | QtCore.QLocale.Language) -> str: + return "" + + def _test_custom_paint( + self, painter: QtGui.QPainter, option: QtWidgets.QStyleOptionViewItem, index: QModelIndex + ): + v = index.model().data(index, self._paint_test_role) + return (v is not None), v + + def _do_custom_paint( + self, + painter: QtGui.QPainter, + option: QtWidgets.QStyleOptionViewItem, + index: QModelIndex, + value: Any, + ): ... + + def paint( + self, painter: QtGui.QPainter, option: QtWidgets.QStyleOptionViewItem, index: QModelIndex + ) -> None: + (check, value) = self._test_custom_paint(painter, option, index) + if not check: + return super().paint(painter, option, index) + super().paint(painter, option, index) + painter.save() + self._do_custom_paint(painter, option, index, value) + painter.restore() + + +class WrappingTextDelegate(CustomDisplayDelegate): + """A lightweight delegate that wraps text without expensive size recalculation.""" + + def __init__(self, parent: BECTableView | None = None, max_width: int = 300, margin: int = 6): + super().__init__(parent) + self._parent = parent + self.max_width = max_width + self.margin = margin + self._cache = {} # cache text metrics for performance + self._wrapping_text_columns = None + + @property + def wrapping_text_columns(self) -> List[int]: + # Compute once, cache for later + if self._wrapping_text_columns is None: + self._wrapping_text_columns = [] + view = self._parent + proxy: DeviceFilterProxyModel = self._parent.model() + for col in range(proxy.columnCount()): + delegate = view.itemDelegateForColumn(col) + if isinstance(delegate, WrappingTextDelegate): + self._wrapping_text_columns.append(col) + return self._wrapping_text_columns + + def _do_custom_paint( + self, + painter: QtGui.QPainter, + option: QtWidgets.QStyleOptionViewItem, + index: QModelIndex, + value: str, + ): + text = str(value) + if not text: + return + painter.save() + painter.setClipRect(option.rect) + + # Use cached layout if available + cache_key = (text, option.rect.width()) + layout = self._cache.get(cache_key) + if layout is None: + layout = self._compute_layout(text, option) + self._cache[cache_key] = layout + + # Draw text + painter.setPen(option.palette.text().color()) + layout.draw(painter, option.rect.topLeft()) + painter.restore() + + def _compute_layout( + self, text: str, option: QtWidgets.QStyleOptionViewItem + ) -> QtGui.QTextLayout: + """Compute and return the text layout for given text and option.""" + layout = self._get_layout(text, option.font) + text_option = QtGui.QTextOption() + text_option.setWrapMode(QtGui.QTextOption.WrapAnywhere) + layout.setTextOption(text_option) + layout.beginLayout() + height = 0 + max_lines = 100 # safety cap, should never be more than 100 lines.. + for _ in range(max_lines): + line = layout.createLine() + if not line.isValid(): + break + line.setLineWidth(option.rect.width() - self.margin) + line.setPosition(QtCore.QPointF(self.margin / 2, height)) + line_height = line.height() + if line_height <= 0: + break # avoid negative or zero height lines to be added + height += line_height + layout.endLayout() + return layout + + def _get_layout(self, text: str, font_option: QtGui.QFont) -> QtGui.QTextLayout: + return QtGui.QTextLayout(text, font_option) + + def sizeHint(self, option: QtWidgets.QStyleOptionViewItem, index: QModelIndex) -> QtCore.QSize: + """Return a cached or approximate height; avoids costly recomputation.""" + text = str(index.data(QtCore.Qt.DisplayRole) or "") + view = self._parent + view.initViewItemOption(option) + if view.isColumnHidden(index.column()) or not view.isVisible() or not text: + return QtCore.QSize(0, option.fontMetrics.height() + 2 * self.margin) + + # Use cache for consistent size computation + cache_key = (text, self.max_width) + if cache_key in self._cache: + layout = self._cache[cache_key] + height = 0 + for i in range(layout.lineCount()): + height += layout.lineAt(i).height() + return QtCore.QSize(self.max_width, int(height + self.margin)) + + # Approximate without layout (fast path) + metrics = option.fontMetrics + pixel_width = max(self._parent.columnWidth(index.column()), 100) + if pixel_width > 2000: # safeguard against uninitialized columns, may return large values + pixel_width = 100 + char_per_line = self.estimate_chars_per_line(text, option, pixel_width - 2 * self.margin) + wrapped_lines = textwrap.wrap(text, width=char_per_line) + lines = len(wrapped_lines) + return QtCore.QSize(pixel_width, lines * (metrics.height()) + 2 * self.margin) + + def estimate_chars_per_line( + self, text: str, option: QtWidgets.QStyleOptionViewItem, column_width: int + ) -> int: + """Estimate number of characters that fit in a line for given width.""" + metrics = option.fontMetrics + elided = metrics.elidedText(text, Qt.ElideRight, column_width) + return len(elided.rstrip("…")) + + @SafeSlot(int, int, int) + @SafeSlot(int) + def _on_section_resized( + self, logical_index: int, old_size: int | None = None, new_size: int | None = None + ): + """Only update rows if a wrapped column was resized.""" + self._cache.clear() + # Make sure layout is computed first + QtCore.QTimer.singleShot(0, self._update_row_heights) + + def _update_row_heights(self): + """Efficiently adjust row heights based on wrapped columns.""" + view = self._parent + proxy = view.model() + option = QtWidgets.QStyleOptionViewItem() + view.initViewItemOption(option) + for row in range(proxy.rowCount()): + max_height = 18 + for column in self.wrapping_text_columns: + index = proxy.index(row, column) + delegate = view.itemDelegateForColumn(column) + hint = delegate.sizeHint(option, index) + max_height = max(max_height, hint.height()) + if view.rowHeight(row) != max_height: + view.setRowHeight(row, max_height) + + +class CenterCheckBoxDelegate(CustomDisplayDelegate): """Custom checkbox delegate to center checkboxes in table cells.""" - def __init__(self, parent=None): + _paint_test_role = USER_CHECK_DATA_ROLE + + def __init__(self, parent: BECTableView | None = None, colors: AccentColors | None = None): super().__init__(parent) - colors = get_accent_colors() - self._icon_checked = material_icon( - "check_box", size=QtCore.QSize(16, 16), color=colors.default - ) - self._icon_unchecked = material_icon( - "check_box_outline_blank", size=QtCore.QSize(16, 16), color=colors.default - ) + colors: AccentColors = colors if colors else get_accent_colors() # type: ignore + _icon = partial(material_icon, size=(16, 16), color=colors.default, filled=True) + self._icon_checked = _icon("check_box") + self._icon_unchecked = _icon("check_box_outline_blank") def apply_theme(self, theme: str | None = None): colors = get_accent_colors() - self._icon_checked.setColor(colors.default) - self._icon_unchecked.setColor(colors.default) + _icon = partial(material_icon, size=(16, 16), color=colors.default, filled=True) + self._icon_checked = _icon("check_box") + self._icon_unchecked = _icon("check_box_outline_blank") - def paint(self, painter, option, index): - value = index.model().data(index, QtCore.Qt.CheckStateRole) - if value is None: - super().paint(painter, option, index) - return - - # Choose icon based on state - pixmap = self._icon_checked if value == QtCore.Qt.Checked else self._icon_unchecked - - # Draw icon centered - rect = option.rect + def _do_custom_paint( + self, + painter: QtGui.QPainter, + option: QtWidgets.QStyleOptionViewItem, + index: QModelIndex, + value: Literal[ + Qt.CheckState.Checked | Qt.CheckState.Unchecked | Qt.CheckState.PartiallyChecked + ], + ): + pixmap = self._icon_checked if value == Qt.CheckState.Checked else self._icon_unchecked pix_rect = pixmap.rect() - pix_rect.moveCenter(rect.center()) + pix_rect.moveCenter(option.rect.center()) painter.drawPixmap(pix_rect.topLeft(), pixmap) - def editorEvent(self, event, model, option, index): - if event.type() != QtCore.QEvent.MouseButtonRelease: + def editorEvent( + self, + event: QtCore.QEvent, + model: QtCore.QSortFilterProxyModel, + option: QtWidgets.QStyleOptionViewItem, + index: QModelIndex, + ): + if event.type() != QtCore.QEvent.Type.MouseButtonRelease: return False - current = model.data(index, QtCore.Qt.CheckStateRole) - new_state = QtCore.Qt.Unchecked if current == QtCore.Qt.Checked else QtCore.Qt.Checked - return model.setData(index, new_state, QtCore.Qt.CheckStateRole) + current = model.data(index, USER_CHECK_DATA_ROLE) + new_state = ( + Qt.CheckState.Unchecked if current == Qt.CheckState.Checked else Qt.CheckState.Checked + ) + return model.setData(index, new_state, USER_CHECK_DATA_ROLE) -class WrappingTextDelegate(DictToolTipDelegate): - """Custom delegate for wrapping text in table cells.""" +class DeviceValidatedDelegate(CustomDisplayDelegate): + """Custom delegate for displaying validated device configurations.""" - def paint(self, painter, option, index): - text = index.model().data(index, QtCore.Qt.DisplayRole) - if not text: - return super().paint(painter, option, index) + def __init__(self, parent: BECTableView | None = None, colors: AccentColors | None = None): + super().__init__(parent) + colors = colors if colors else get_accent_colors() + _icon = partial(material_icon, icon_name="circle", size=(12, 12), filled=True) + self._icons = { + ValidationStatus.PENDING: _icon(color=colors.default), + ValidationStatus.VALID: _icon(color=colors.success), + ValidationStatus.FAILED: _icon(color=colors.emergency), + } - painter.save() - painter.setClipRect(option.rect) - text_option = QtCore.Qt.TextWordWrap | QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop - painter.drawText(option.rect.adjusted(4, 2, -4, -2), text_option, text) - painter.restore() + def apply_theme(self, theme: str | None = None): + colors = get_accent_colors() + _icon = partial(material_icon, icon_name="circle", size=(12, 12), filled=True) + self._icons = { + ValidationStatus.PENDING: _icon(color=colors.default), + ValidationStatus.VALID: _icon(color=colors.success), + ValidationStatus.FAILED: _icon(color=colors.emergency), + } - def sizeHint(self, option, index): - text = str(index.model().data(index, QtCore.Qt.DisplayRole) or "") - # if not text: - # return super().sizeHint(option, index) + def _do_custom_paint( + self, + painter: QtGui.QPainter, + option: QtWidgets.QStyleOptionViewItem, + index: QModelIndex, + value: Literal[0, 1, 2], + ): + """ + Paint the validation status icon centered in the cell. - # Use the actual column width - table = index.model().parent() # or store reference to QTableView - column_width = table.columnWidth(index.column()) # - 8 - - doc = QtGui.QTextDocument() - doc.setDefaultFont(option.font) - doc.setTextWidth(column_width) - doc.setPlainText(text) - - layout_height = doc.documentLayout().documentSize().height() - height = int(layout_height) + 4 # Needs some extra padding, otherwise it gets cut off - return QtCore.QSize(column_width, height) + Args: + painter (QtGui.QPainter): The painter object. + option (QtWidgets.QStyleOptionViewItem): The style options for the item. + index (QModelIndex): The model index of the item. + value (Literal[0,1,2]): The validation status value, where 0=Pending, 1=Valid, 2=Failed. + Relates to ValidationStatus enum. + """ + if pixmap := self._icons.get(value): + pix_rect = pixmap.rect() + pix_rect.moveCenter(option.rect.center()) + painter.drawPixmap(pix_rect.topLeft(), pixmap) class DeviceTableModel(QtCore.QAbstractTableModel): @@ -121,62 +334,86 @@ class DeviceTableModel(QtCore.QAbstractTableModel): Sort logic is implemented directly on the data of the table view. """ - def __init__(self, device_config: list[dict] | None = None, parent=None): + # tuple of list[dict[str, Any]] of configs which were added and bool True if added or False if removed + configs_changed = QtCore.Signal(list, bool) + + def __init__(self, parent: DeviceTableModel | None = None): super().__init__(parent) - self._device_config = device_config or [] + self._device_config: list[dict[str, Any]] = [] + self._validation_status: dict[str, ValidationStatus] = {} + # TODO 882 keep in sync with HEADERS_HELP_MD self.headers = [ + "status", "name", "deviceClass", "readoutPriority", - "enabled", - "readOnly", + "onFailure", "deviceTags", "description", + "enabled", + "readOnly", + "softwareTrigger", ] self._checkable_columns_enabled = {"enabled": True, "readOnly": True} + self._device_model_schema = Device.model_json_schema() ############################################### - ########## Overwrite custom Qt methods ######## + ########## Override custom Qt methods ######### ############################################### - def rowCount(self, parent=QtCore.QModelIndex()) -> int: + def rowCount(self, parent: QModelIndex | QPersistentModelIndex = QtCore.QModelIndex()) -> int: return len(self._device_config) - def columnCount(self, parent=QtCore.QModelIndex()) -> int: + def columnCount( + self, parent: QModelIndex | QPersistentModelIndex = QtCore.QModelIndex() + ) -> int: return len(self.headers) - def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole): - if role == QtCore.Qt.DisplayRole and orientation == QtCore.Qt.Horizontal: + def headerData(self, section, orientation, role=int(Qt.ItemDataRole.DisplayRole)): + if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal: + if section == 9: # softwareTrigger + return "softTrig" return self.headers[section] return None - def row_data(self, index: QtCore.QModelIndex) -> dict: + def get_row_data(self, index: QtCore.QModelIndex) -> dict: """Return the row data for the given index.""" if not index.isValid(): return {} return copy.deepcopy(self._device_config[index.row()]) - def data(self, index, role=QtCore.Qt.DisplayRole): + def data(self, index, role=int(Qt.ItemDataRole.DisplayRole)): """Return data for the given index and role.""" if not index.isValid(): return None row, col = index.row(), index.column() - key = self.headers[col] - value = self._device_config[row].get(key) - if role == QtCore.Qt.DisplayRole: - if key in ("enabled", "readOnly"): + if col == 0 and role == Qt.ItemDataRole.DisplayRole: + dev_name = self._device_config[row].get("name", "") + return self._validation_status.get(dev_name, ValidationStatus.PENDING) + + key = self.headers[col] + value = self._device_config[row].get(key, None) + if value is None: + value = ( + self._device_model_schema.get("properties", {}).get(key, {}).get("default", None) + ) + + if role == Qt.ItemDataRole.DisplayRole: + if key in ("enabled", "readOnly", "softwareTrigger"): return bool(value) if key == "deviceTags": return ", ".join(str(tag) for tag in value) if value else "" + if key == "deviceClass": + return str(value).split(".")[-1] return str(value) if value is not None else "" - if role == QtCore.Qt.CheckStateRole and key in ("enabled", "readOnly"): - return QtCore.Qt.Checked if value else QtCore.Qt.Unchecked - if role == QtCore.Qt.TextAlignmentRole: - if key in ("enabled", "readOnly"): - return QtCore.Qt.AlignCenter - return QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter - if role == QtCore.Qt.FontRole: + if role == USER_CHECK_DATA_ROLE and key in ("enabled", "readOnly", "softwareTrigger"): + return Qt.CheckState.Checked if value else Qt.CheckState.Unchecked + if role == Qt.ItemDataRole.TextAlignmentRole: + if key in ("enabled", "readOnly", "softwareTrigger"): + return Qt.AlignmentFlag.AlignCenter + return Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter + if role == Qt.ItemDataRole.FontRole: font = QtGui.QFont() return font return None @@ -184,18 +421,21 @@ class DeviceTableModel(QtCore.QAbstractTableModel): def flags(self, index): """Flags for the table model.""" if not index.isValid(): - return QtCore.Qt.NoItemFlags + return Qt.ItemFlag.NoItemFlags key = self.headers[index.column()] - if key in ("enabled", "readOnly"): - base_flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + base_flags = super().flags(index) | ( + Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsDropEnabled + ) + + if key in ("enabled", "readOnly", "softwareTrigger"): if self._checkable_columns_enabled.get(key, True): - return base_flags | QtCore.Qt.ItemIsUserCheckable + return base_flags | Qt.ItemFlag.ItemIsUserCheckable else: return base_flags # disable editing but still visible - return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + return base_flags - def setData(self, index, value, role=QtCore.Qt.EditRole) -> bool: + def setData(self, index, value, role=int(Qt.ItemDataRole.EditRole)) -> bool: """ Method to set the data of the table. @@ -210,106 +450,172 @@ class DeviceTableModel(QtCore.QAbstractTableModel): if not index.isValid(): return False key = self.headers[index.column()] - row = index.row() - - if key in ("enabled", "readOnly") and role == QtCore.Qt.CheckStateRole: + if key in ("enabled", "readOnly", "softwareTrigger") and role == USER_CHECK_DATA_ROLE: if not self._checkable_columns_enabled.get(key, True): return False # ignore changes if column is disabled - self._device_config[row][key] = value == QtCore.Qt.Checked - self.dataChanged.emit(index, index, [QtCore.Qt.CheckStateRole]) + self._device_config[index.row()][key] = value == Qt.CheckState.Checked + self.dataChanged.emit(index, index, [Qt.ItemDataRole.DisplayRole, USER_CHECK_DATA_ROLE]) return True return False + #################################### + ############ Drag and Drop ######### + #################################### + + def mimeTypes(self) -> List[str]: + return [*super().mimeTypes(), MIME_DEVICE_CONFIG] + + def supportedDropActions(self): + return Qt.DropAction.CopyAction | Qt.DropAction.MoveAction + + def dropMimeData(self, data, action, row, column, parent): + if action not in [Qt.DropAction.CopyAction, Qt.DropAction.MoveAction]: + return False + if (raw_data := data.data(MIME_DEVICE_CONFIG)) is None: + return False + self.add_device_configs(json.loads(raw_data.toStdString())) + return True + #################################### ############ Public methods ######## #################################### - def get_device_config(self) -> list[dict]: - """Return the current device config (with checkbox updates applied).""" - return self._device_config + def get_device_config(self) -> list[dict[str, Any]]: + """Method to get the device configuration.""" + return copy.deepcopy(self._device_config) - def set_checkbox_enabled(self, column_name: str, enabled: bool): + def device_names(self, configs: _DeviceCfgIter | None = None) -> set[str]: + _configs = self._device_config if configs is None else configs + return set(cfg.get("name") for cfg in _configs if cfg.get("name") is not None) # type: ignore + + def _name_exists_in_config(self, name: str, exists: bool): + if (name in self.device_names()) == exists: + return True + return not exists + + def add_device_configs(self, device_configs: _DeviceCfgIter): """ - Enable/Disable the checkbox column. + Add devices to the model. Args: - column_name (str): The name of the column to modify. - enabled (bool): Whether the checkbox should be enabled or disabled. + device_configs (_DeviceCfgList): An iterable of device configurations to add. """ - if column_name in self._checkable_columns_enabled: - self._checkable_columns_enabled[column_name] = enabled - col = self.headers.index(column_name) - top_left = self.index(0, col) - bottom_right = self.index(self.rowCount() - 1, col) - self.dataChanged.emit( - top_left, bottom_right, [QtCore.Qt.CheckStateRole, QtCore.Qt.DisplayRole] - ) + already_in_list = [] + added_configs = [] + for cfg in device_configs: + if self._name_exists_in_config(name := cfg.get("name", ""), True): + logger.warning(f"Device {name} is already in the config. It will be updated.") + self.remove_configs_by_name([name]) + row = len(self._device_config) + self.beginInsertRows(QtCore.QModelIndex(), row, row) + self._device_config.append(copy.deepcopy(cfg)) + added_configs.append(cfg) + self.endInsertRows() + self.configs_changed.emit(device_configs, True) - def set_device_config(self, device_config: list[dict]): + def remove_device_configs(self, device_configs: _DeviceCfgIter): + """ + Remove devices from the model. + + Args: + device_configs (_DeviceCfgList): An iterable of device configurations to remove. + """ + removed = [] + for cfg in device_configs: + if cfg not in self._device_config: + logger.warning(f"Device {cfg.get('name')} does not exist in the model.") + continue + with self._remove_row(self._device_config.index(cfg)) as row: + removed.append(self._device_config.pop(row)) + self.configs_changed.emit(removed, False) + + def remove_configs_by_name(self, names: Iterable[str]): + configs = filter(lambda cfg: cfg is not None, (self.get_by_name(name) for name in names)) + self.remove_device_configs(configs) # type: ignore # Nones are filtered + + def get_by_name(self, name: str) -> dict[str, Any] | None: + for cfg in self._device_config: + if cfg.get("name") == name: + return cfg + logger.warning(f"Device {name} does not exist in the model.") + return None + + @contextmanager + def _remove_row(self, row: int): + self.beginRemoveRows(QtCore.QModelIndex(), row, row) + try: + yield row + finally: + self.endRemoveRows() + + def set_device_config(self, device_configs: _DeviceCfgIter): """ Replace the device config. Args: - device_config (list[dict]): The new device config to set. + device_config (Iterable[dict[str,Any]]): An iterable of device configurations to set. + """ + diff_names = self.device_names(device_configs) - self.device_names() + diff = [cfg for cfg in self._device_config if cfg.get("name") in diff_names] + self.beginResetModel() + self._device_config = copy.deepcopy(list(device_configs)) + self.endResetModel() + self.configs_changed.emit(diff, False) + self.configs_changed.emit(device_configs, True) + + def clear_table(self): + """ + Clear the table. """ self.beginResetModel() - self._device_config = list(device_config) + self._device_config.clear() self.endResetModel() + self.configs_changed.emit(self._device_config, False) - @SafeSlot(dict) - def add_device(self, device: dict): + def update_validation_status(self, device_name: str, status: int | ValidationStatus): """ - Add an extra device to the device config at the bottom. + Handle device status changes. Args: - device (dict): The device configuration to add. + device_name (str): The name of the device. + status (int): The new status of the device. """ - row = len(self._device_config) - self.beginInsertRows(QtCore.QModelIndex(), row, row) - self._device_config.append(device) - self.endInsertRows() - - @SafeSlot(int) - def remove_device_by_row(self, row: int): - """ - Remove one device row by index. This maps to the row to the source of the data model - - Args: - row (int): The index of the device row to remove. - """ - if 0 <= row < len(self._device_config): - self.beginRemoveRows(QtCore.QModelIndex(), row, row) - self._device_config.pop(row) - self.endRemoveRows() - - @SafeSlot(list) - def remove_devices_by_rows(self, rows: list[int]): - """ - Remove multiple device rows by their indices. - - Args: - rows (list[int]): The indices of the device rows to remove. - """ - for row in sorted(rows, reverse=True): - self.remove_device_by_row(row) - - @SafeSlot(str) - def remove_device_by_name(self, name: str): - """ - Remove one device row by name. - - Args: - name (str): The name of the device to remove. - """ - for row, device in enumerate(self._device_config): - if device.get("name") == name: - self.remove_device_by_row(row) + if isinstance(status, int): + status = ValidationStatus(status) + if device_name not in self.device_names(): + logger.warning(f"Device {device_name} not found in table") + return + self._validation_status[device_name] = status + row = None + for ii, item in enumerate(self._device_config): + if item["name"] == device_name: + row = ii break + if row is None: + logger.warning( + f"Device {device_name} not found in device_status dict {self._validation_status}" + ) + return + # Emit dataChanged for column 0 (status column) + index = self.index(row, 0) + self.dataChanged.emit(index, index, [Qt.ItemDataRole.DisplayRole]) + + def validation_statuses(self): + return copy.deepcopy(self._validation_status) class BECTableView(QtWidgets.QTableView): """Table View with custom keyPressEvent to delete rows with backspace or delete key""" + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.setAcceptDrops(True) + self.setDropIndicatorShown(True) + self.setDragDropMode(QtWidgets.QTableView.DragDropMode.DropOnly) + + def model(self) -> DeviceFilterProxyModel: + return super().model() # type: ignore + def keyPressEvent(self, event) -> None: """ Delete selected rows with backspace or delete key @@ -317,50 +623,80 @@ class BECTableView(QtWidgets.QTableView): Args: event: keyPressEvent """ - if event.key() not in (QtCore.Qt.Key_Backspace, QtCore.Qt.Key_Delete): - return super().keyPressEvent(event) + if event.key() in (Qt.Key.Key_Backspace, Qt.Key.Key_Delete): + return self.delete_selected() + return super().keyPressEvent(event) - proxy_indexes = self.selectedIndexes() + def contains_invalid_devices(self): + return ValidationStatus.FAILED in self.model().sourceModel().validation_statuses().values() + + def all_configs(self): + return self.model().sourceModel().get_device_config() + + def selected_configs(self): + return self.model().get_row_data(self.selectionModel().selectedRows()) + + def delete_selected(self): + proxy_indexes = self.selectionModel().selectedRows() if not proxy_indexes: return - - # Get unique rows (proxy indices) in reverse order so removal indexes stay valid - proxy_rows = sorted({idx.row() for idx in proxy_indexes}, reverse=True) - # Map to source model rows - source_rows = [ - self.model().mapToSource(self.model().index(row, 0)).row() for row in proxy_rows - ] - model: DeviceTableModel = self.model().sourceModel() # access underlying model - # Delegate confirmation and removal to helper - removed = self._confirm_and_remove_rows(model, source_rows) - if not removed: - return + self._confirm_and_remove_rows(model, self._get_source_rows(proxy_indexes)) - def _confirm_and_remove_rows(self, model: DeviceTableModel, source_rows: list[int]) -> bool: + def _get_source_rows(self, proxy_indexes: list[QModelIndex]) -> list[QModelIndex]: + """ + Map proxy model indices to source model row indices. + + Args: + proxy_indexes (list[QModelIndex]): List of proxy model indices. + + Returns: + list[int]: List of source model row indices. + """ + proxy_rows = sorted({idx for idx in proxy_indexes}, reverse=True) + return list(set(self.model().mapToSource(idx) for idx in proxy_rows)) + + def _confirm_and_remove_rows( + self, model: DeviceTableModel, source_rows: list[QModelIndex] + ) -> bool: """ Prompt the user to confirm removal of rows and remove them from the model if accepted. Returns True if rows were removed, False otherwise. """ - cfg = model.get_device_config() - names = [str(cfg[r].get("name", "")) for r in sorted(source_rows)] + configs = [model.get_row_data(r) for r in sorted(source_rows, key=lambda r: r.row())] + names = [cfg.get("name", "") for cfg in configs] + if not names: + logger.warning("No device names found for selected rows.") + return False + if self._remove_rows_msg_dialog(names): + model.remove_device_configs(configs) + return True + return False - msg = QtWidgets.QMessageBox(self) - msg.setIcon(QtWidgets.QMessageBox.Warning) - msg.setWindowTitle("Confirm remove devices") - if len(names) == 1: - msg.setText(f"Remove device '{names[0]}'?") - else: - msg.setText(f"Remove {len(names)} devices?") - msg.setInformativeText("\n".join(names)) - msg.setStandardButtons(QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel) - msg.setDefaultButton(QtWidgets.QMessageBox.Cancel) + def _remove_rows_msg_dialog(self, names: list[str]) -> bool: + """ + Prompt the user to confirm removal of rows and remove them from the model if accepted. + + Args: + names (list[str]): List of device names to be removed. + + Returns: + bool: True if the user confirmed removal, False otherwise. + """ + msg = QMessageBox(self) + msg.setIcon(QMessageBox.Icon.Warning) + msg.setWindowTitle("Confirm device removal") + msg.setText( + f"Remove device '{names[0]}'?" if len(names) == 1 else f"Remove {len(names)} devices?" + ) + separator = "\n" if len(names) < 12 else ", " + msg.setInformativeText("Selected devices: \n" + separator.join(names)) + msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel) + msg.setDefaultButton(QMessageBox.StandardButton.Cancel) res = msg.exec_() - if res == QtWidgets.QMessageBox.Ok: - model.remove_devices_by_rows(source_rows) - # TODO add signal for removed devices + if res == QMessageBox.StandardButton.Ok: return True return False @@ -372,7 +708,18 @@ class DeviceFilterProxyModel(QtCore.QSortFilterProxyModel): self._hidden_rows = set() self._filter_text = "" self._enable_fuzzy = True - self._filter_columns = [0, 1] # name and deviceClass for search + self._filter_columns = [1, 2, 6] # name, deviceClass and description for search + self._status_order = { + ValidationStatus.VALID: 0, + ValidationStatus.PENDING: 1, + ValidationStatus.FAILED: 2, + } + + def get_row_data(self, rows: Iterable[QModelIndex]) -> Iterable[dict[str, Any]]: + return (self.sourceModel().get_row_data(self.mapToSource(idx)) for idx in rows) + + def sourceModel(self) -> DeviceTableModel: + return super().sourceModel() # type: ignore def hide_rows(self, row_indices: list[int]): """ @@ -384,6 +731,14 @@ class DeviceFilterProxyModel(QtCore.QSortFilterProxyModel): self._hidden_rows.update(row_indices) self.invalidateFilter() + def lessThan(self, left, right): + """Add custom sorting for the status column""" + if left.column() != 0 or right.column() != 0: + return super().lessThan(left, right) + left_data = self.sourceModel().data(left, Qt.ItemDataRole.DisplayRole) + right_data = self.sourceModel().data(right, Qt.ItemDataRole.DisplayRole) + return self._status_order.get(left_data, 99) < self._status_order.get(right_data, 99) + def show_rows(self, row_indices: list[int]): """ Show specific rows in the model. @@ -422,7 +777,7 @@ class DeviceFilterProxyModel(QtCore.QSortFilterProxyModel): text = self._filter_text.lower() for column in self._filter_columns: index = model.index(source_row, column, source_parent) - data = str(model.data(index, QtCore.Qt.DisplayRole) or "") + data = str(model.data(index, Qt.ItemDataRole.DisplayRole) or "") if self._enable_fuzzy is True: match_ratio = fuzz.partial_ratio(self._filter_text.lower(), data.lower()) if match_ratio >= FUZZY_SEARCH_THRESHOLD: @@ -432,28 +787,68 @@ class DeviceFilterProxyModel(QtCore.QSortFilterProxyModel): return True return False + def flags(self, index): + return super().flags(index) | Qt.ItemFlag.ItemIsDropEnabled + + def supportedDropActions(self): + return self.sourceModel().supportedDropActions() + + def mimeTypes(self): + return self.sourceModel().mimeTypes() + + def dropMimeData(self, data, action, row, column, parent): + sp = self.mapToSource(parent) if parent.isValid() else QtCore.QModelIndex() + return self.sourceModel().dropMimeData(data, action, row, column, sp) + class DeviceTableView(BECWidget, QtWidgets.QWidget): """Device Table View for the device manager.""" + # Selected device configuration list[dict[str, Any]] + selected_devices = QtCore.Signal(list) # type: ignore + # tuple of list[dict[str, Any]] of configs which were added and bool True if added or False if removed + device_configs_changed = QtCore.Signal(list, bool) # type: ignore + RPC = False PLUGIN = False - devices_removed = QtCore.Signal(list) - def __init__(self, parent=None, client=None): + def __init__(self, parent=None, client=None, shared_selection_signal=SharedSelectionSignal()): super().__init__(client=client, parent=parent, theme_update=True) - self.layout = QtWidgets.QVBoxLayout(self) - self.layout.setContentsMargins(0, 0, 0, 0) - self.layout.setSpacing(4) + self._shared_selection_signal = shared_selection_signal + self._shared_selection_uuid = str(uuid4()) + self._shared_selection_signal.proc.connect(self._handle_shared_selection_signal) + + self._layout = QtWidgets.QVBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setSpacing(4) + self.setLayout(self._layout) # Setup table view self._setup_table_view() # Setup search view, needs table proxy to be iniditate self._setup_search() # Add widgets to main layout - self.layout.addLayout(self.search_controls) - self.layout.addWidget(self.table) + self._layout.addLayout(self.search_controls) + self._layout.addWidget(self.table) + + # Connect signals + self._model.configs_changed.connect(self.device_configs_changed.emit) + + def get_help_md(self) -> str: + """ + Generate Markdown help for a cell or header. + """ + pos = self.table.mapFromGlobal(QtGui.QCursor.pos()) + model: DeviceTableModel = self._model # access underlying model + index = self.table.indexAt(pos) + if index.isValid(): + column = index.column() + label = model.headerData(column, QtCore.Qt.Horizontal, QtCore.Qt.DisplayRole) + if label == "softTrig": + label = "softwareTrigger" + return HEADERS_HELP_MD.get(label, "") + return "" def _setup_search(self): """Create components related to the search functionality""" @@ -489,143 +884,246 @@ class DeviceTableView(BECWidget, QtWidgets.QWidget): self.search_controls.addLayout(self.search_layout) self.search_controls.addSpacing(20) # Add some space between the search box and toggle self.search_controls.addLayout(self.fuzzy_layout) - QtCore.QTimer.singleShot(0, lambda: self.fuzzy_is_disabled.stateChanged.emit(0)) + QTimer.singleShot(0, lambda: self.fuzzy_is_disabled.stateChanged.emit(0)) def _setup_table_view(self) -> None: """Setup the table view.""" # Model + Proxy self.table = BECTableView(self) - self.model = DeviceTableModel(parent=self.table) + self._model = DeviceTableModel(parent=self.table) self.proxy = DeviceFilterProxyModel(parent=self.table) - self.proxy.setSourceModel(self.model) + self.proxy.setSourceModel(self._model) self.table.setModel(self.proxy) self.table.setSortingEnabled(True) # Delegates - self.checkbox_delegate = CenterCheckBoxDelegate(self.table) - self.wrap_delegate = WrappingTextDelegate(self.table) + colors = get_accent_colors() + self.checkbox_delegate = CenterCheckBoxDelegate(self.table, colors=colors) self.tool_tip_delegate = DictToolTipDelegate(self.table) - self.table.setItemDelegateForColumn(0, self.tool_tip_delegate) # name - self.table.setItemDelegateForColumn(1, self.tool_tip_delegate) # deviceClass - self.table.setItemDelegateForColumn(2, self.tool_tip_delegate) # readoutPriority - self.table.setItemDelegateForColumn(3, self.checkbox_delegate) # enabled - self.table.setItemDelegateForColumn(4, self.checkbox_delegate) # readOnly - self.table.setItemDelegateForColumn(5, self.wrap_delegate) # deviceTags - self.table.setItemDelegateForColumn(6, self.wrap_delegate) # description + self.validated_delegate = DeviceValidatedDelegate(self.table, colors=colors) + self.wrapped_delegate = WrappingTextDelegate(self.table, max_width=300) + # Add resize handling for wrapped delegate + header = self.table.horizontalHeader() + + self.table.setItemDelegateForColumn(0, self.validated_delegate) # status + self.table.setItemDelegateForColumn(1, self.tool_tip_delegate) # name + self.table.setItemDelegateForColumn(2, self.tool_tip_delegate) # deviceClass + self.table.setItemDelegateForColumn(3, self.tool_tip_delegate) # readoutPriority + self.table.setItemDelegateForColumn(4, self.tool_tip_delegate) # onFailure + self.table.setItemDelegateForColumn(5, self.wrapped_delegate) # deviceTags + self.table.setItemDelegateForColumn(6, self.wrapped_delegate) # description + self.table.setItemDelegateForColumn(7, self.checkbox_delegate) # enabled + self.table.setItemDelegateForColumn(8, self.checkbox_delegate) # readOnly + self.table.setItemDelegateForColumn(9, self.checkbox_delegate) # softwareTrigger + + # Disable wrapping, use eliding, and smooth scrolling + self.table.setWordWrap(False) + self.table.setTextElideMode(QtCore.Qt.TextElideMode.ElideRight) + self.table.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + self.table.setHorizontalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) # Column resize policies - # TODO maybe we need here a flexible header options as deviceClass - # may get quite long for beamlines plugin repos header = self.table.horizontalHeader() - header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents) # name - header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeToContents) # deviceClass - header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeToContents) # readoutPriority - header.setSectionResizeMode(3, QtWidgets.QHeaderView.Fixed) # enabled - header.setSectionResizeMode(4, QtWidgets.QHeaderView.Fixed) # readOnly - # TODO maybe better stretch... - header.setSectionResizeMode(5, QtWidgets.QHeaderView.ResizeToContents) # deviceTags - header.setSectionResizeMode(6, QtWidgets.QHeaderView.Stretch) # description - self.table.setColumnWidth(3, 82) - self.table.setColumnWidth(4, 82) + header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) # ValidationStatus + header.setSectionResizeMode(1, QHeaderView.ResizeMode.Interactive) # name + header.setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive) # deviceClass + header.setSectionResizeMode(3, QHeaderView.ResizeMode.Interactive) # readoutPriority + header.setSectionResizeMode(4, QHeaderView.ResizeMode.Interactive) # onFailure + header.setSectionResizeMode( + 5, QHeaderView.ResizeMode.Interactive + ) # deviceTags: expand to fill + header.setSectionResizeMode(6, QHeaderView.ResizeMode.Stretch) # descript: expand to fill + header.setSectionResizeMode(7, QHeaderView.ResizeMode.Fixed) # enabled + header.setSectionResizeMode(8, QHeaderView.ResizeMode.Fixed) # readOnly + header.setSectionResizeMode(9, QHeaderView.ResizeMode.Fixed) # softwareTrigger + + self.table.setColumnWidth(0, 70) + self.table.setColumnWidth(5, 200) + self.table.setColumnWidth(6, 200) + self.table.setColumnWidth(7, 70) + self.table.setColumnWidth(8, 70) + self.table.setColumnWidth(9, 70) # Ensure column widths stay fixed - header.setMinimumSectionSize(70) + header.setMinimumSectionSize(25) header.setDefaultSectionSize(90) + header.setStretchLastSection(False) - # Enable resizing of column - header.sectionResized.connect(self.on_table_resized) + # Resize policy for wrapped text delegate + self._resize_proxy = BECSignalProxy( + header.sectionResized, + rateLimit=25, + slot=self.wrapped_delegate._on_section_resized, + timeout=1.0, + ) # Selection behavior - self.table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) - self.table.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) + self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + # Connect to selection model to get selection changes + self.table.selectionModel().selectionChanged.connect(self._on_selection_changed) self.table.horizontalHeader().setHighlightSections(False) - # QtCore.QTimer.singleShot(0, lambda: header.sectionResized.emit(0, 0, 0)) + # Connect model signals to autosize request + self._model.rowsInserted.connect(self._request_autosize_columns) + self._model.rowsRemoved.connect(self._request_autosize_columns) + self._model.modelReset.connect(self._request_autosize_columns) + self._model.dataChanged.connect(self._request_autosize_columns) - def device_config(self) -> list[dict]: + def remove_selected_rows(self): + self.table.delete_selected() + + def get_device_config(self) -> list[dict[str, Any]]: """Get the device config.""" - return self.model.get_device_config() + return self._model.get_device_config() def apply_theme(self, theme: str | None = None): self.checkbox_delegate.apply_theme(theme) + self.validated_delegate.apply_theme(theme) ###################################### ########### Slot API ################# ###################################### - @SafeSlot(int, int, int) - def on_table_resized(self, column, old_width, new_width): - """Handle changes to the table column resizing.""" - if column != len(self.model.headers) - 1: - return + def _request_autosize_columns(self, *args): + if not hasattr(self, "_autosize_timer"): + self._autosize_timer = QtCore.QTimer(self) + self._autosize_timer.setSingleShot(True) + self._autosize_timer.timeout.connect(self._autosize_columns) + self._autosize_timer.start(0) - for row in range(self.table.model().rowCount()): - index = self.table.model().index(row, column) - delegate = self.table.itemDelegate(index) - option = QtWidgets.QStyleOptionViewItem() - height = delegate.sizeHint(option, index).height() - self.table.setRowHeight(row, height) + @SafeSlot() + def _autosize_columns(self): + if self._model.rowCount() == 0: + return + for col in (1, 2, 3): + self.table.resizeColumnToContents(col) + + @SafeSlot(str) + def _handle_shared_selection_signal(self, uuid: str): + if uuid != self._shared_selection_uuid: + self.table.clearSelection() + + @SafeSlot(QtCore.QItemSelection, QtCore.QItemSelection) + def _on_selection_changed( + self, selected: QtCore.QItemSelection, deselected: QtCore.QItemSelection + ) -> None: + """ + Handle selection changes in the device table. + + Args: + selected (QtCore.QItemSelection): The selected items. + deselected (QtCore.QItemSelection): The deselected items. + """ + self._shared_selection_signal.proc.emit(self._shared_selection_uuid) + if not (selected_configs := list(self.table.selected_configs())): + return + self.selected_devices.emit(selected_configs) ###################################### ##### Ext. Slot API ################# ###################################### @SafeSlot(list) - def set_device_config(self, config: list[dict]): + def set_device_config(self, device_configs: _DeviceCfgIter): """ Set the device config. Args: - config (list[dict]): The device config to set. + config (Iterable[str,dict]): The device config to set. """ - self.model.set_device_config(config) + self._model.set_device_config(device_configs) @SafeSlot() - def clear_device_config(self): - """ - Clear the device config. - """ - self.model.set_device_config([]) + def clear_device_configs(self): + """Clear the device configs.""" + self._model.clear_table() - @SafeSlot(dict) - def add_device(self, device: dict): + @SafeSlot(list) + def add_device_configs(self, device_configs: _DeviceCfgIter): """ - Add a device to the config. + Add devices to the config. Args: - device (dict): The device to add. + device_configs (dict[str, dict]): The device configs to add. """ - self.model.add_device(device) + self._model.add_device_configs(device_configs) + + @SafeSlot(list) + def remove_device_configs(self, device_configs: _DeviceCfgIter): + """ + Remove devices from the config. + + Args: + device_configs (dict[str, dict]): The device configs to remove. + """ + self._model.remove_device_configs(device_configs) - @SafeSlot(int) @SafeSlot(str) - def remove_device(self, dev: int | str): + def remove_device(self, device_name: str): """ - Remove the device from the config either by row id, or device name. + Remove a device from the config. Args: - dev (int | str): The device to remove, either by row id or device name. + device_name (str): The name of the device to remove. """ - if isinstance(dev, int): - # TODO test this properly, check with proxy index and source index - # Use the proxy model to map to the correct row - model_source_index = self.table.model().mapToSource(self.table.model().index(dev, 0)) - self.model.remove_device_by_row(model_source_index.row()) - return - if isinstance(dev, str): - self.model.remove_device_by_name(dev) - return + self._model.remove_configs_by_name([device_name]) + + @SafeSlot(str, int) + def update_device_validation( + self, device_name: str, validation_status: int | ValidationStatus + ) -> None: + """ + Update the validation status of a device. + + Args: + device_name (str): The name of the device. + validation_status (int | ValidationStatus): The new validation status. + """ + self._model.update_validation_status(device_name, validation_status) if __name__ == "__main__": import sys + import numpy as np from qtpy.QtWidgets import QApplication app = QApplication(sys.argv) + widget = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(widget) + layout.setContentsMargins(0, 0, 0, 0) window = DeviceTableView() + layout.addWidget(window) + # QPushButton + button = QtWidgets.QPushButton("Test status_update") + layout.addWidget(button) + + def _button_clicked(): + names = list(window._model.device_names()) + for name in names: + window.update_device_validation( + name, ValidationStatus.VALID if np.random.rand() > 0.5 else ValidationStatus.FAILED + ) + + button.clicked.connect(_button_clicked) # pylint: disable=protected-access config = window.client.device_manager._get_redis_device_config() + config.insert( + 0, + { + "name": "TestDevice", + "deviceClass": "bec.devices.MockDevice", + "description": "Thisisaverylongsinglestringwhichisquiteannoyingmoreover, this is a test device with a very long description that should wrap around in the table view to test the wrapping functionality.", + "deviceTags": ["test", "mock", "longtagnameexample"], + "enabled": True, + "readOnly": False, + "softwareTrigger": True, + }, + ) + # names = [cfg.pop("name") for cfg in config] + # config_dict = {name: cfg for name, cfg in zip(names, config)} window.set_device_config(config) - window.show() + window.resize(1920, 1200) + widget.show() sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/control/device_manager/components/dm_config_view.py b/bec_widgets/widgets/control/device_manager/components/dm_config_view.py new file mode 100644 index 00000000..245080f3 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/dm_config_view.py @@ -0,0 +1,100 @@ +"""Module with a config view for the device manager.""" + +from __future__ import annotations + +import traceback + +import yaml +from bec_lib.logger import bec_logger +from qtpy import QtCore, QtWidgets + +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget + +logger = bec_logger.logger + + +class DMConfigView(BECWidget, QtWidgets.QWidget): + def __init__(self, parent=None, client=None): + super().__init__(client=client, parent=parent, theme_update=True) + self.stacked_layout = QtWidgets.QStackedLayout() + self.stacked_layout.setContentsMargins(0, 0, 0, 0) + self.stacked_layout.setSpacing(0) + self.setLayout(self.stacked_layout) + + # Monaco widget + self.monaco_editor = MonacoWidget() + self._customize_monaco() + self.stacked_layout.addWidget(self.monaco_editor) + + self._overlay_widget = QtWidgets.QLabel(text="Select single device to show config") + self._customize_overlay() + self.stacked_layout.addWidget(self._overlay_widget) + self.stacked_layout.setCurrentWidget(self._overlay_widget) + + def _customize_monaco(self): + + self.monaco_editor.set_language("yaml") + self.monaco_editor.set_vim_mode_enabled(False) + self.monaco_editor.set_minimap_enabled(False) + # self.monaco_editor.setFixedHeight(600) + self.monaco_editor.set_readonly(True) + self.monaco_editor.editor.set_scroll_beyond_last_line_enabled(False) + self.monaco_editor.editor.set_line_numbers_mode("off") + + def _customize_overlay(self): + self._overlay_widget.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self._overlay_widget.setAutoFillBackground(True) + self._overlay_widget.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding + ) + + @SafeSlot(dict) + def on_select_config(self, device: list[dict]): + """Handle selection of a device from the device table.""" + if len(device) != 1: + text = "" + self.stacked_layout.setCurrentWidget(self._overlay_widget) + else: + try: + text = yaml.dump(device[0], default_flow_style=False) + self.stacked_layout.setCurrentWidget(self.monaco_editor) + except Exception: + content = traceback.format_exc() + logger.error(f"Error converting device to YAML:\n{content}") + text = "" + self.stacked_layout.setCurrentWidget(self._overlay_widget) + self.monaco_editor.set_readonly(False) # Enable editing + text = text.rstrip() + self.monaco_editor.set_text(text) + self.monaco_editor.set_readonly(True) # Disable editing again + + +if __name__ == "__main__": + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + widget = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(widget) + widget.setLayout(layout) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + config_view = DMConfigView() + layout.addWidget(config_view) + combo_box = QtWidgets.QComboBox() + config = config_view.client.device_manager._get_redis_device_config() + combo_box.addItems([""] + [str(v) for v, item in enumerate(config)]) + + def on_select(text): + if text == "": + config_view.on_select_config([]) + else: + config_view.on_select_config([config[int(text)]]) + + combo_box.currentTextChanged.connect(on_select) + layout.addWidget(combo_box) + widget.show() + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py b/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py new file mode 100644 index 00000000..553462a0 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py @@ -0,0 +1,133 @@ +"""Module to visualize the docstring of a device class.""" + +from __future__ import annotations + +import inspect +import re +import textwrap +import traceback + +from bec_lib.logger import bec_logger +from bec_lib.plugin_helper import get_plugin_class, plugin_package_name +from bec_lib.utils.rpc_utils import rgetattr +from qtpy import QtCore, QtWidgets + +from bec_widgets.utils.error_popups import SafeSlot + +logger = bec_logger.logger + +try: + import ophyd + import ophyd_devices + + READY_TO_VIEW = True +except ImportError: + logger.warning(f"Optional dependencies not available: {ImportError}") + ophyd_devices = None + ophyd = None + + +def docstring_to_markdown(obj) -> str: + """ + Convert a Python docstring to Markdown suitable for QTextEdit.setMarkdown. + """ + raw = inspect.getdoc(obj) or "*No docstring available.*" + + # Dedent and normalize newlines + text = textwrap.dedent(raw).strip() + + md = "" + if hasattr(obj, "__name__"): + md += f"# {obj.__name__}\n\n" + + # Highlight section headers for Markdown + headers = ["Parameters", "Args", "Returns", "Raises", "Attributes", "Examples", "Notes"] + for h in headers: + text = re.sub(rf"(?m)^({h})\s*:?\s*$", rf"### \1", text) + + # Preserve code blocks (4+ space indented lines) + def fence_code(match: re.Match) -> str: + block = re.sub(r"^ {4}", "", match.group(0), flags=re.M) + return f"```\n{block}\n```" + + doc = re.sub(r"(?m)(^ {4,}.*(\n {4,}.*)*)", fence_code, text) + + # Preserve normal line breaks for Markdown + lines = doc.splitlines() + processed_lines = [] + for line in lines: + if line.strip() == "": + processed_lines.append("") + else: + processed_lines.append(line + " ") + doc = "\n".join(processed_lines) + + md += doc + return md + + +class DocstringView(QtWidgets.QTextEdit): + def __init__(self, parent: QtWidgets.QWidget | None = None): + super().__init__(parent) + self.setReadOnly(True) + self.setFocusPolicy(QtCore.Qt.FocusPolicy.NoFocus) + if not READY_TO_VIEW: + self._set_text("Ophyd or ophyd_devices not installed, cannot show docstrings.") + self.setEnabled(False) + return + + def _set_text(self, text: str): + self.setReadOnly(False) + self.setMarkdown(text) + self.setReadOnly(True) + + @SafeSlot(list) + def on_select_config(self, device: list[dict]): + if len(device) != 1: + self._set_text("") + return + device_class = device[0].get("deviceClass", "") + self.set_device_class(device_class) + + @SafeSlot(str) + def set_device_class(self, device_class_str: str) -> None: + if not READY_TO_VIEW: + return + try: + module_cls = get_plugin_class(device_class_str, [ophyd_devices, ophyd]) + markdown = docstring_to_markdown(module_cls) + self._set_text(markdown) + except Exception: + logger.exception("Error retrieving docstring") + self._set_text(f"*Error retrieving docstring for `{device_class_str}`*") + + +if __name__ == "__main__": + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + widget = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(widget) + widget.setLayout(layout) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + config_view = DocstringView() + config_view.set_device_class("ophyd_devices.sim.sim_camera.SimCamera") + layout.addWidget(config_view) + combo = QtWidgets.QComboBox() + combo.addItems( + [ + "", + "ophyd_devices.sim.sim_camera.SimCamera", + "ophyd.EpicsSignalWithRBV", + "ophyd.EpicsMotor", + "csaxs_bec.devices.epics.mcs_card.mcs_card_csaxs.MCSCardCSAXS", + ] + ) + combo.currentTextChanged.connect(config_view.set_device_class) + layout.addWidget(combo) + widget.show() + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py b/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py new file mode 100644 index 00000000..a73ada11 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py @@ -0,0 +1,418 @@ +"""Module to run a static tests for devices from a yaml config.""" + +from __future__ import annotations + +import enum +import re +from collections import deque +from concurrent.futures import CancelledError, Future, ThreadPoolExecutor +from html import escape +from threading import Event, RLock +from typing import Any, Iterable + +from bec_lib.logger import bec_logger +from bec_qthemes import material_icon +from qtpy import QtCore, QtWidgets + +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.colors import get_accent_colors +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget + +READY_TO_TEST = False + +logger = bec_logger.logger + +try: + import bec_server + import ophyd_devices + + READY_TO_TEST = True +except ImportError: + logger.warning(f"Optional dependencies not available: {ImportError}") + ophyd_devices = None + bec_server = None + +try: + from ophyd_devices.utils.static_device_test import StaticDeviceTest +except ImportError: + StaticDeviceTest = None + + +class ValidationStatus(int, enum.Enum): + """Validation status for device configurations.""" + + PENDING = 0 # colors.default + VALID = 1 # colors.highlight + FAILED = 2 # colors.emergency + + +class DeviceValidationResult(QtCore.QObject): + """Simple object to inject validation signals into QRunnable.""" + + # Device validation signal, device_name, ValidationStatus as int, error message or '' + device_validated = QtCore.Signal(str, bool, str) + + +class DeviceTester(QtCore.QRunnable): + def __init__(self, config: dict) -> None: + super().__init__() + self.signals = DeviceValidationResult() + self.shutdown_event = Event() + + self._config = config + + self._max_threads = 4 + self._pending_event = Event() + self._lock = RLock() + self._test_executor = ThreadPoolExecutor(self._max_threads, "device_manager_tester") + + self._pending_queue: deque[tuple[str, dict]] = deque([]) + self._active: set[str] = set() + + QtWidgets.QApplication.instance().aboutToQuit.connect(lambda: self.shutdown_event.set()) + + def run(self): + if StaticDeviceTest is None: + logger.error("Ophyd devices or bec_server not available, cannot run validation.") + return + while not self.shutdown_event.is_set(): + self._pending_event.wait(timeout=0.5) # check if shutting down every 0.5s + if len(self._active) >= self._max_threads: + self._pending_event.clear() # it will be set again on removing something from active + continue + with self._lock: + if len(self._pending_queue) > 0: + item, cfg, connect = self._pending_queue.pop() + self._active.add(item) + fut = self._test_executor.submit(self._run_test, item, {item: cfg}, connect) + fut.__dict__["__device_name"] = item + fut.add_done_callback(self._done_cb) + self._safe_check_and_clear() + self._cleanup() + + def submit(self, devices: Iterable[tuple[str, dict, bool]]): + with self._lock: + self._pending_queue.extend(devices) + self._pending_event.set() + + @staticmethod + def _run_test(name: str, config: dict, connect: bool) -> tuple[str, bool, str]: + tester = StaticDeviceTest(config_dict=config) # type: ignore # we exit early if it is None + results = tester.run_with_list_output(connect=connect) + return name, results[0].success, results[0].message + + def _safe_check_and_clear(self): + with self._lock: + if len(self._pending_queue) == 0: + self._pending_event.clear() + + def _safe_remove_from_active(self, name: str): + with self._lock: + self._active.remove(name) + self._pending_event.set() # check again once a completed task is removed + + def _done_cb(self, future: Future): + try: + name, success, message = future.result() + except CancelledError: + return + except Exception as e: + name, success, message = future.__dict__["__device_name"], False, str(e) + finally: + self._safe_remove_from_active(future.__dict__["__device_name"]) + self.signals.device_validated.emit(name, success, message) + + def _cleanup(self): ... + + +class ValidationListItem(QtWidgets.QWidget): + """Custom list item widget showing device name and validation status.""" + + def __init__(self, device_name: str, device_config: dict, parent=None): + """ + Initialize the validation list item. + + Args: + device_name (str): The name of the device. + device_config (dict): The configuration of the device. + validation_colors (dict[ValidationStatus, QtGui.QColor]): The colors for each validation status. + parent (QtWidgets.QWidget, optional): The parent widget. + """ + super().__init__(parent) + self.main_layout = QtWidgets.QHBoxLayout(self) + self.main_layout.setContentsMargins(2, 2, 2, 2) + self.main_layout.setSpacing(4) + self.device_name = device_name + self.device_config = device_config + self.validation_msg = "Validation in progress..." + self._setup_ui() + + def _setup_ui(self): + """Setup the UI for the list item.""" + label = QtWidgets.QLabel(self.device_name) + self.main_layout.addWidget(label) + self.main_layout.addStretch() + self._spinner = SpinnerWidget(parent=self) + self._spinner.speed = 80 + self._spinner.setFixedSize(24, 24) + self.main_layout.addWidget(self._spinner) + self._base_style = "font-weight: bold;" + self.setStyleSheet(self._base_style) + self._start_spinner() + + def _start_spinner(self): + """Start the spinner animation.""" + self._spinner.start() + + def _stop_spinner(self): + """Stop the spinner animation.""" + self._spinner.stop() + self._spinner.setVisible(False) + + @SafeSlot() + def on_validation_restart(self): + """Handle validation restart.""" + self.validation_msg = "" + self._start_spinner() + self.setStyleSheet("") # Check if this works as expected + + @SafeSlot(str) + def on_validation_failed(self, error_msg: str): + """Handle validation failure.""" + self.validation_msg = error_msg + colors = get_accent_colors() + self._stop_spinner() + self.main_layout.removeWidget(self._spinner) + self._spinner.deleteLater() + label = QtWidgets.QLabel("") + icon = material_icon("error", color=colors.emergency, size=(24, 24)) + label.setPixmap(icon) + self.main_layout.addWidget(label) + + +class DMOphydTest(BECWidget, QtWidgets.QWidget): + """Widget to test device configurations using ophyd devices.""" + + # Signal to emit the validation status of a device + device_validated = QtCore.Signal(str, int) + # validation_msg in markdown format + validation_msg_md = QtCore.Signal(str) + + def __init__(self, parent=None, client=None): + super().__init__(parent=parent, client=client) + if not READY_TO_TEST: + self.setDisabled(True) + self.tester = None + else: + self.tester = DeviceTester({}) + self.tester.signals.device_validated.connect(self._on_device_validated) + QtCore.QThreadPool.globalInstance().start(self.tester) + self._device_list_items: dict[str, QtWidgets.QListWidgetItem] = {} + # TODO Consider using the thread pool from BECConnector instead of fetching the global instance! + self._thread_pool = QtCore.QThreadPool.globalInstance() + + self._main_layout = QtWidgets.QVBoxLayout(self) + self._main_layout.setContentsMargins(0, 0, 0, 0) + self._main_layout.setSpacing(0) + + # We add a splitter between the list and the text box + self.splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical) + self._main_layout.addWidget(self.splitter) + + self._setup_list_ui() + + def _setup_list_ui(self): + """Setup the list UI.""" + self._list_widget = QtWidgets.QListWidget(self) + self._list_widget.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) + self.splitter.addWidget(self._list_widget) + # Connect signals + self._list_widget.currentItemChanged.connect(self._on_current_item_changed) + + @SafeSlot(list, bool) + @SafeSlot(list, bool, bool) + def change_device_configs( + self, device_configs: list[dict[str, Any]], added: bool, connect: bool = False + ) -> None: + """Receive an update with device configs. + + Args: + device_configs (list[dict[str, Any]]): The updated device configurations. + """ + for cfg in device_configs: + name = cfg.get("name", "") + if added: + if name in self._device_list_items: + continue + if self.tester: + self._add_device(name, cfg) + self.tester.submit([(name, cfg, connect)]) + continue + if name not in self._device_list_items: + continue + self._remove_list_item(name) + + def _add_device(self, name, cfg): + item = QtWidgets.QListWidgetItem(self._list_widget) + widget = ValidationListItem(device_name=name, device_config=cfg) + + # wrap it in a QListWidgetItem + item.setSizeHint(widget.sizeHint()) + self._list_widget.addItem(item) + self._list_widget.setItemWidget(item, widget) + self._device_list_items[name] = item + + def _remove_list_item(self, device_name: str): + """Remove a device from the list.""" + # Get the list item + item = self._device_list_items.pop(device_name) + + # Retrieve the custom widget attached to the item + widget = self._list_widget.itemWidget(item) + if widget is not None: + widget.deleteLater() # clean up custom widget + + # Remove the item from the QListWidget + row = self._list_widget.row(item) + self._list_widget.takeItem(row) + + @SafeSlot(str, bool, str) + def _on_device_validated(self, device_name: str, success: bool, message: str): + """Handle the device validation result. + + Args: + device_name (str): The name of the device. + success (bool): Whether the validation was successful. + message (str): The validation message. + """ + logger.info(f"Device {device_name} validation result: {success}, message: {message}") + item = self._device_list_items.get(device_name, None) + if not item: + logger.error(f"Device {device_name} not found in the list.") + return + if success: + self._remove_list_item(device_name=device_name) + self.device_validated.emit(device_name, ValidationStatus.VALID.value) + else: + widget: ValidationListItem = self._list_widget.itemWidget(item) + widget.on_validation_failed(message) + self.device_validated.emit(device_name, ValidationStatus.FAILED.value) + + def _on_current_item_changed( + self, current: QtWidgets.QListWidgetItem, previous: QtWidgets.QListWidgetItem + ): + """Handle the current item change in the list widget. + + Args: + current (QListWidgetItem): The currently selected item. + previous (QListWidgetItem): The previously selected item. + """ + widget: ValidationListItem = self._list_widget.itemWidget(current) + if widget: + try: + formatted_md = self._format_markdown_text(widget.device_name, widget.validation_msg) + self.validation_msg_md.emit(formatted_md) + except Exception as e: + logger.error( + f"##Error formatting validation message for device {widget.device_name}:\n{e}" + ) + self.validation_msg_md.emit(widget.validation_msg) + else: + self.validation_msg_md.emit("") + + def _format_markdown_text(self, device_name: str, raw_msg: str) -> str: + """ + Simple HTML formatting for validation messages, wrapping text naturally. + + Args: + device_name (str): The name of the device. + raw_msg (str): The raw validation message. + """ + if not raw_msg.strip() or raw_msg.strip() == "Validation in progress...": + return f"### Validation in progress for {device_name}... \n\n" + + # Regex to capture repeated ERROR patterns + pat = re.compile( + r"ERROR:\s*(?P[^\s]+)\s+" + r"(?Pis not valid|is not connectable|failed):\s*" + r"(?P.*?)(?=ERROR:|$)", + re.DOTALL, + ) + blocks = [] + for m in pat.finditer(raw_msg): + dev = m.group("device") + status = m.group("status") + detail = m.group("detail").strip() + lines = [f"## Error for {dev}", f"**{dev} {status}**", f"```\n{detail}\n```"] + blocks.append("\n\n".join(lines)) + + # Fallback: If no patterns matched, return the raw message + if not blocks: + return f"## Error for {device_name}\n```\n{raw_msg.strip()}\n```" + + return "\n\n---\n\n".join(blocks) + + def validation_running(self): + return self._device_list_items != {} + + @SafeSlot() + def clear_list(self): + """Clear the device list.""" + self._thread_pool.clear() + if self._thread_pool.waitForDone(2000) is False: # Wait for threads to finish + logger.error("Failed to wait for threads to finish. Removing items from the list.") + self._device_list_items.clear() + self._list_widget.clear() + self.validation_msg_md.emit("") + + def remove_device(self, device_name: str): + """Remove a device from the list.""" + item = self._device_list_items.pop(device_name, None) + if item: + self._list_widget.removeItemWidget(item) + + def cleanup(self): + if self.tester: + self.tester.shutdown_event.set() + return super().cleanup() + + +if __name__ == "__main__": + import sys + + from bec_lib.bec_yaml_loader import yaml_load + + # pylint: disable=ungrouped-imports + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + wid = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(wid) + wid.setLayout(layout) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + device_manager_ophyd_test = DMOphydTest() + try: + config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/first_light.yaml" + config = [{"name": k, **v} for k, v in yaml_load(config_path).items()] + except Exception as e: + logger.error(f"Error loading config: {e}") + import os + + import bec_lib + + config_path = os.path.join(os.path.dirname(bec_lib.__file__), "configs", "demo_config.yaml") + config = [{"name": k, **v} for k, v in yaml_load(config_path).items()] + + config.append({"name": "non_existing_device", "type": "NonExistingDevice"}) + device_manager_ophyd_test.change_device_configs(config, True, True) + layout.addWidget(device_manager_ophyd_test) + device_manager_ophyd_test.setWindowTitle("Device Manager Ophyd Test") + device_manager_ophyd_test.resize(800, 600) + text_box = QtWidgets.QTextEdit() + text_box.setReadOnly(True) + layout.addWidget(text_box) + device_manager_ophyd_test.validation_msg_md.connect(text_box.setMarkdown) + wid.show() + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/services/device_browser/device_browser.py b/bec_widgets/widgets/services/device_browser/device_browser.py index be9382ea..9aa0e789 100644 --- a/bec_widgets/widgets/services/device_browser/device_browser.py +++ b/bec_widgets/widgets/services/device_browser/device_browser.py @@ -1,6 +1,4 @@ import os -import re -from functools import partial from typing import Callable import bec_lib @@ -11,23 +9,17 @@ from bec_lib.logger import bec_logger from bec_lib.messages import ConfigAction, ScanStatusMessage from bec_qthemes import material_icon from pyqtgraph import SignalProxy -from qtpy.QtCore import QSize, QThreadPool, Signal -from qtpy.QtWidgets import ( - QFileDialog, - QListWidget, - QListWidgetItem, - QToolButton, - QVBoxLayout, - QWidget, -) +from qtpy.QtCore import QThreadPool, Signal +from qtpy.QtWidgets import QFileDialog, QListWidget, QToolButton, QVBoxLayout, QWidget from bec_widgets.cli.rpc.rpc_register import RPCRegister from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.list_of_expandable_frames import ListOfExpandableFrames from bec_widgets.utils.ui_loader import UILoader from bec_widgets.widgets.services.device_browser.device_item import DeviceItem from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import ( - DeviceConfigDialog, + DirectUpdateDeviceConfigDialog, ) from bec_widgets.widgets.services.device_browser.util import map_device_type_to_icon @@ -61,7 +53,8 @@ class DeviceBrowser(BECWidget, QWidget): self._q_threadpool = QThreadPool() self.ui = None self.init_ui() - self.dev_list: QListWidget = self.ui.device_list + self.dev_list = ListOfExpandableFrames(self, DeviceItem) + self.ui.verticalLayout.addWidget(self.dev_list) self.dev_list.setVerticalScrollMode(QListWidget.ScrollMode.ScrollPerPixel) self.proxy_device_update = SignalProxy( self.ui.filter_input.textChanged, rateLimit=500, slot=self.update_device_list @@ -116,7 +109,7 @@ class DeviceBrowser(BECWidget, QWidget): ) def _create_add_dialog(self): - dialog = DeviceConfigDialog(parent=self, device=None, action="add") + dialog = DirectUpdateDeviceConfigDialog(parent=self, device=None, action="add") dialog.open() def on_device_update(self, action: ConfigAction, content: dict) -> None: @@ -134,25 +127,15 @@ class DeviceBrowser(BECWidget, QWidget): def init_device_list(self): self.dev_list.clear() - self._device_items: dict[str, QListWidgetItem] = {} with RPCRegister.delayed_broadcast(): for device, device_obj in self.dev.items(): self._add_item_to_list(device, device_obj) def _add_item_to_list(self, device: str, device_obj): - def _updatesize(item: QListWidgetItem, device_item: DeviceItem): - device_item.adjustSize() - item.setSizeHint(QSize(device_item.width(), device_item.height())) - logger.debug(f"Adjusting {item} size to {device_item.width(), device_item.height()}") - def _remove_item(item: QListWidgetItem): - self.dev_list.takeItem(self.dev_list.row(item)) - del self._device_items[device] - self.dev_list.sortItems() - - item = QListWidgetItem(self.dev_list) - device_item = DeviceItem( + _, device_item = self.dev_list.add_item( + id=device, parent=self, device=device, devices=self.dev, @@ -160,18 +143,11 @@ class DeviceBrowser(BECWidget, QWidget): config_helper=self._config_helper, q_threadpool=self._q_threadpool, ) - device_item.expansion_state_changed.connect(partial(_updatesize, item, device_item)) - device_item.imminent_deletion.connect(partial(_remove_item, item)) + self.editing_enabled.connect(device_item.set_editable) self.device_update.connect(device_item.config_update) tooltip = self.dev[device]._config.get("description", "") device_item.setToolTip(tooltip) - device_item.broadcast_size_hint.connect(item.setSizeHint) - item.setSizeHint(device_item.sizeHint()) - - self.dev_list.setItemWidget(item, device_item) - self.dev_list.addItem(item) - self._device_items[device] = item @SafeSlot(dict, dict) def scan_status_changed(self, scan_info: dict, _: dict): @@ -200,20 +176,11 @@ class DeviceBrowser(BECWidget, QWidget): Either way, the function will filter the devices based on the filter input text and update the device list. """ - filter_text = self.ui.filter_input.text() for device in self.dev: - if device not in self._device_items: + if device not in self.dev_list: # it is possible the device has just been added to the config self._add_item_to_list(device, self.dev[device]) - try: - self.regex = re.compile(filter_text, re.IGNORECASE) - except re.error: - self.regex = None # Invalid regex, disable filtering - for device in self.dev: - self._device_items[device].setHidden(False) - return - for device in self.dev: - self._device_items[device].setHidden(not self.regex.search(device)) + self.dev_list.update_filter(self.ui.filter_input.text()) @SafeSlot() def _load_from_file(self): diff --git a/bec_widgets/widgets/services/device_browser/device_browser.ui b/bec_widgets/widgets/services/device_browser/device_browser.ui index 9a2d4ce2..0903854c 100644 --- a/bec_widgets/widgets/services/device_browser/device_browser.ui +++ b/bec_widgets/widgets/services/device_browser/device_browser.ui @@ -1,93 +1,90 @@ - Form - - - - 0 - 0 - 406 - 500 - - - - Form - - - - - - Device Browser - - - - - - - - Filter - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - ... - - - - - - - ... - - - - - - - ... - - - - - - - - - - - - + Form + + + + 0 + 0 + 406 + 500 + - - warning + + Form - - - - - - + + + + + Device Browser + + + + + + + + Filter + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + ... + + + + + + + ... + + + + + + + ... + + + + + + + + + + + + + + + warning + + + + + + + - - - - - - + + + \ No newline at end of file diff --git a/bec_widgets/widgets/services/device_browser/device_item/config_communicator.py b/bec_widgets/widgets/services/device_browser/device_item/config_communicator.py index 4a469dbb..ca1d66f7 100644 --- a/bec_widgets/widgets/services/device_browser/device_item/config_communicator.py +++ b/bec_widgets/widgets/services/device_browser/device_item/config_communicator.py @@ -34,7 +34,11 @@ class CommunicateConfigAction(QRunnable): @SafeSlot() def run(self): try: - if self.action in ["add", "update", "remove"]: + if self.action == "set": + self._process( + {"action": self.action, "config": self.config, "wait_for_response": False} + ) + elif self.action in ["add", "update", "remove"]: if (dev_name := self.device or self.config.get("name")) is None: raise ValueError( "Must be updating a device or be supplied a name for a new device" @@ -57,6 +61,9 @@ class CommunicateConfigAction(QRunnable): "config": {dev_name: self.config}, "wait_for_response": False, } + self._process(req_args) + + def _process(self, req_args: dict): timeout = ( self.config_helper.suggested_timeout_s(self.config) if self.config is not None else 20 ) diff --git a/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py b/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py index 4df088a6..ceaea99a 100644 --- a/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py +++ b/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py @@ -5,12 +5,14 @@ from bec_lib.atlas_models import Device as DeviceConfigModel from bec_lib.config_helper import CONF as DEVICE_CONF_KEYS from bec_lib.config_helper import ConfigHelper from bec_lib.logger import bec_logger -from pydantic import field_validator -from qtpy.QtCore import QSize, Qt, QThreadPool, Signal +from pydantic import BaseModel, field_validator +from qtpy.QtCore import QSize, Qt, QThreadPool, Signal # type: ignore from qtpy.QtWidgets import ( QApplication, + QComboBox, QDialog, QDialogButtonBox, + QHBoxLayout, QLabel, QStackedLayout, QVBoxLayout, @@ -19,6 +21,7 @@ from qtpy.QtWidgets import ( from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.forms_from_types.items import DynamicFormItem, DynamicFormItemType from bec_widgets.widgets.services.device_browser.device_item.config_communicator import ( CommunicateConfigAction, ) @@ -29,6 +32,8 @@ from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget logger = bec_logger.logger +_StdBtn = QDialogButtonBox.StandardButton + def _try_literal_eval(value: str): if value == "": @@ -39,79 +44,36 @@ def _try_literal_eval(value: str): raise ValueError(f"Entered config value {value} is not a valid python value!") from e -class DeviceConfigDialog(BECWidget, QDialog): +class DeviceConfigDialog(QDialog): RPC = False applied = Signal() + accepted_data = Signal(dict) def __init__( - self, - *, - parent=None, - device: str | None = None, - config_helper: ConfigHelper | None = None, - action: Literal["update", "add"] = "update", - threadpool: QThreadPool | None = None, - **kwargs, + self, *, parent=None, class_deviceconfig_item: type[DynamicFormItem] | None = None, **kwargs ): - """A dialog to edit the configuration of a device in BEC. Generated from the pydantic model - for device specification in bec_lib.atlas_models. - Args: - parent (QObject): the parent QObject - device (str | None): the name of the device. used with the "update" action to prefill the dialog and validate entries. - config_helper (ConfigHelper | None): a ConfigHelper object for communication with Redis, will be created if necessary. - action (Literal["update", "add"]): the action which the form should perform on application or acceptance. - """ self._initial_config = {} + self._class_deviceconfig_item = class_deviceconfig_item super().__init__(parent=parent, **kwargs) - self._config_helper = config_helper or ConfigHelper( - self.client.connector, self.client._service_name, self.client.device_manager - ) - self._device = device - self._action: Literal["update", "add"] = action - self._q_threadpool = threadpool or QThreadPool() - self.setWindowTitle(f"Edit config for: {device}") + self._container = QStackedLayout() - self._container.setStackingMode(QStackedLayout.StackAll) + self._container.setStackingMode(QStackedLayout.StackingMode.StackAll) self._layout = QVBoxLayout() - user_warning = QLabel( - "Warning: edit items here at your own risk - minimal validation is applied to the entered values.\n" - "Items in the deviceConfig dictionary should correspond to python literals, e.g. numbers, lists, strings (including quotes), etc." - ) - user_warning.setWordWrap(True) - user_warning.setStyleSheet("QLabel { color: red; }") - self._layout.addWidget(user_warning) - self.get_bec_shortcuts() + self._data = {} self._add_form() - if self._action == "update": - self._form._validity.setVisible(False) - else: - self._set_schema_to_check_devices() - # TODO: replace when https://github.com/bec-project/bec/issues/528 https://github.com/bec-project/bec/issues/547 are resolved - # self._form._validity.setVisible(True) - self._form.validity_proc.connect(self.enable_buttons_for_validity) self._add_overlay() self._add_buttons() - + self.setWindowTitle("Add new device") self.setLayout(self._container) - self._form.validate_form() self._overlay_widget.setVisible(False) + self._form._validity.setVisible(True) + self._connect_form() - def _set_schema_to_check_devices(self): - class _NameValidatedConfigModel(DeviceConfigModel): - @field_validator("name") - @staticmethod - def _validate_name(value: str, *_): - if not value.isidentifier(): - raise ValueError( - f"Invalid device name: {value}. Device names must be valid Python identifiers." - ) - if value in self.dev: - raise ValueError(f"A device with name {value} already exists!") - return value - - self._form.set_schema(_NameValidatedConfigModel) + def _connect_form(self): + self._form.validity_proc.connect(self.enable_buttons_for_validity) + self._form.validate_form() def _add_form(self): self._form_widget = QWidget() @@ -119,16 +81,6 @@ class DeviceConfigDialog(BECWidget, QDialog): self._form = DeviceConfigForm() self._layout.addWidget(self._form) - for row in self._form.enumerate_form_widgets(): - if ( - row.label.property("_model_field_name") in DEVICE_CONF_KEYS.NON_UPDATABLE - and self._action == "update" - ): - row.widget._set_pretty_display() - - if self._action == "update" and self._device in self.dev: - self._fetch_config() - self._fill_form() self._container.addWidget(self._form_widget) def _add_overlay(self): @@ -145,21 +97,12 @@ class DeviceConfigDialog(BECWidget, QDialog): self._container.addWidget(self._overlay_widget) def _add_buttons(self): - self.button_box = QDialogButtonBox( - QDialogButtonBox.Apply | QDialogButtonBox.Ok | QDialogButtonBox.Cancel - ) - self.button_box.button(QDialogButtonBox.Apply).clicked.connect(self.apply) + self.button_box = QDialogButtonBox(_StdBtn.Apply | _StdBtn.Ok | _StdBtn.Cancel) + self.button_box.button(_StdBtn.Apply).clicked.connect(self.apply) self.button_box.accepted.connect(self.accept) self.button_box.rejected.connect(self.reject) self._layout.addWidget(self.button_box) - def _fetch_config(self): - if ( - self.client.device_manager is not None - and self._device in self.client.device_manager.devices - ): - self._initial_config = self.client.device_manager.devices.get(self._device)._config - def _fill_form(self): self._form.set_data(DeviceConfigModel.model_validate(self._initial_config)) @@ -190,12 +133,16 @@ class DeviceConfigDialog(BECWidget, QDialog): @SafeSlot(bool) def enable_buttons_for_validity(self, valid: bool): # TODO: replace when https://github.com/bec-project/bec/issues/528 https://github.com/bec-project/bec/issues/547 are resolved - for button in [ - self.button_box.button(b) for b in [QDialogButtonBox.Apply, QDialogButtonBox.Ok] - ]: + for button in [self.button_box.button(b) for b in [_StdBtn.Apply, _StdBtn.Ok]]: button.setEnabled(valid) button.setToolTip(self._form._validity_message.text()) + def _process_action(self): + self.accepted_data.emit(self._form.get_form_data()) + + def get_data(self): + return self._data + @SafeSlot(popup_error=True) def apply(self): self._process_action() @@ -206,10 +153,138 @@ class DeviceConfigDialog(BECWidget, QDialog): self._process_action() return super().accept() + +class EpicsMotorConfig(BaseModel): + prefix: str + + +class EpicsSignalROConfig(BaseModel): + read_pv: str + + +class EpicsSignalConfig(BaseModel): + read_pv: str + write_pv: str | None = None + + +class PresetClassDeviceConfigDialog(DeviceConfigDialog): + def __init__(self, *, parent=None, **kwargs): + super().__init__(parent=parent, **kwargs) + self._device_models = { + "EpicsMotor": (EpicsMotorConfig, {"deviceClass": ("ophyd.EpicsMotor", False)}), + "EpicsSignalRO": (EpicsSignalROConfig, {"deviceClass": ("ophyd.EpicsSignalRO", False)}), + "EpicsSignal": (EpicsSignalConfig, {"deviceClass": ("ophyd.EpicsSignal", False)}), + "Custom": (None, {}), + } + self._create_selection_box() + self._selection_box.currentTextChanged.connect(self._replace_form) + + def _apply_constraints(self, constraints: dict[str, tuple[DynamicFormItemType, bool]]): + for field_name, (value, editable) in constraints.items(): + if (widget := self._form.widget_dict.get(field_name)) is not None: + widget.setValue(value) + if not editable: + widget._set_pretty_display() + + def _replace_form(self, deviceconfig_cls_key): + self._form.deleteLater() + if (devmodel_params := self._device_models.get(deviceconfig_cls_key)) is not None: + devmodel, params = devmodel_params + else: + devmodel, params = None, {} + self._form = DeviceConfigForm(class_deviceconfig_item=devmodel) + self._apply_constraints(params) + self._layout.insertWidget(1, self._form) + self._connect_form() + + def _create_selection_box(self): + layout = QHBoxLayout() + self._selection_box = QComboBox() + self._selection_box.addItems(list(self._device_models.keys())) + layout.addWidget(QLabel("Choose a device class: ")) + layout.addWidget(self._selection_box) + self._layout.insertLayout(0, layout) + + +class DirectUpdateDeviceConfigDialog(BECWidget, DeviceConfigDialog): + def __init__( + self, + *, + parent=None, + device: str | None = None, + config_helper: ConfigHelper | None = None, + action: Literal["update"] | Literal["add"] = "update", + threadpool: QThreadPool | None = None, + **kwargs, + ): + """A dialog to edit the configuration of a device in BEC. Generated from the pydantic model + for device specification in bec_lib.atlas_models. + + Args: + parent (QObject): the parent QObject + device (str | None): the name of the device. used with the "update" action to prefill the dialog and validate entries. + config_helper (ConfigHelper | None): a ConfigHelper object for communication with Redis, will be created if necessary. + action (Literal["update", "add"]): the action which the form should perform on application or acceptance. + """ + self._device = device + self._q_threadpool = threadpool or QThreadPool() + self._config_helper = config_helper or ConfigHelper( + self.client.connector, self.client._service_name + ) + super().__init__(parent=parent, **kwargs) + self.get_bec_shortcuts() + self._action: Literal["update", "add"] = action + user_warning = QLabel( + "Warning: edit items here at your own risk - minimal validation is applied to the entered values.\n" + "Items in the deviceConfig dictionary should correspond to python literals, e.g. numbers, lists, strings (including quotes), etc." + ) + user_warning.setWordWrap(True) + user_warning.setStyleSheet("QLabel { color: red; }") + self._layout.insertWidget(0, user_warning) + self.setWindowTitle( + f"Edit config for: {device}" if action == "update" else "Add new device" + ) + + if self._action == "update": + self._modify_for_update() + self._form.validity_proc.disconnect(self.enable_buttons_for_validity) + else: + self._set_schema_to_check_devices() + # TODO: replace when https://github.com/bec-project/bec/issues/528 https://github.com/bec-project/bec/issues/547 are resolved + # self._form._validity.setVisible(True) + + def _modify_for_update(self): + for row in self._form.enumerate_form_widgets(): + if row.label.property("_model_field_name") in DEVICE_CONF_KEYS.NON_UPDATABLE: + row.widget._set_pretty_display() + if self._device in self.dev: + self._fetch_config() + self._fill_form() + self._form._validity.setVisible(False) + + def _set_schema_to_check_devices(self): + class _NameValidatedConfigModel(DeviceConfigModel): + @field_validator("name") + @staticmethod + def _validate_name(value: str, *_): + if not value.isidentifier(): + raise ValueError( + f"Invalid device name: {value}. Device names must be valid Python identifiers." + ) + if value in self.dev: + raise ValueError(f"A device with name {value} already exists!") + return value + + self._form.set_schema(_NameValidatedConfigModel) + + def _fetch_config(self): + if self.dev is not None and (device := self.dev.get(self._device)) is not None: # type: ignore + self._initial_config = device._config + def _process_action(self): updated_config = self.updated_config() if self._action == "add": - if (name := updated_config.get("name")) in self.dev: + if self.dev is not None and (name := updated_config.get("name")) in self.dev: raise ValueError( f"Can't create a new device with the same name as already existing device {name}!" ) @@ -249,12 +324,12 @@ class DeviceConfigDialog(BECWidget, QDialog): def _start_waiting_display(self): self._overlay_widget.setVisible(True) self._spinner.start() - QApplication.processEvents() + QApplication.processEvents() # TODO check if this kills performance and scheduling! def _stop_waiting_display(self): self._overlay_widget.setVisible(False) self._spinner.stop() - QApplication.processEvents() + QApplication.processEvents() # TODO check if this kills performance and scheduling! def main(): # pragma: no cover @@ -269,10 +344,10 @@ def main(): # pragma: no cover app = QApplication(sys.argv) apply_theme("light") widget = QWidget() - widget.setLayout(QVBoxLayout()) + widget.setLayout(layout := QVBoxLayout()) device = QLineEdit() - widget.layout().addWidget(device) + layout.addWidget(device) def _destroy_dialog(*_): nonlocal dialog @@ -285,14 +360,14 @@ def main(): # pragma: no cover def _show_dialog(*_): nonlocal dialog if dialog is None: - kwargs = {"device": dev} if (dev := device.text()) else {"action": "add"} - dialog = DeviceConfigDialog(**kwargs) + kwargs = {} # kwargs = {"device": dev} if (dev := device.text()) else {"action": "add"} + dialog = PresetClassDeviceConfigDialog(**kwargs) # type: ignore dialog.accepted.connect(accept) dialog.rejected.connect(_destroy_dialog) dialog.open() button = QPushButton("Show device dialog") - widget.layout().addWidget(button) + layout.addWidget(button) button.clicked.connect(_show_dialog) widget.show() sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/services/device_browser/device_item/device_config_form.py b/bec_widgets/widgets/services/device_browser/device_item/device_config_form.py index 0b8c1aeb..a783d988 100644 --- a/bec_widgets/widgets/services/device_browser/device_item/device_config_form.py +++ b/bec_widgets/widgets/services/device_browser/device_item/device_config_form.py @@ -1,16 +1,20 @@ from __future__ import annotations +from functools import partial + from bec_lib.atlas_models import Device as DeviceConfigModel from pydantic import BaseModel from qtpy.QtWidgets import QApplication from bec_widgets.utils.colors import get_theme_name from bec_widgets.utils.forms_from_types import styles -from bec_widgets.utils.forms_from_types.forms import PydanticModelForm +from bec_widgets.utils.forms_from_types.forms import PydanticModelForm, PydanticModelFormItem from bec_widgets.utils.forms_from_types.items import ( DEFAULT_WIDGET_TYPES, BoolFormItem, BoolToggleFormItem, + DictFormItem, + FormItemSpec, ) @@ -18,7 +22,14 @@ class DeviceConfigForm(PydanticModelForm): RPC = False PLUGIN = False - def __init__(self, parent=None, client=None, pretty_display=False, **kwargs): + def __init__( + self, + parent=None, + client=None, + pretty_display=False, + class_deviceconfig_item: type[BaseModel] | None = None, + **kwargs, + ): super().__init__( parent=parent, data_model=DeviceConfigModel, @@ -26,18 +37,28 @@ class DeviceConfigForm(PydanticModelForm): client=client, **kwargs, ) + self._class_deviceconfig_item: type[BaseModel] | None = class_deviceconfig_item self._widget_types = DEFAULT_WIDGET_TYPES.copy() self._widget_types["bool"] = (lambda spec: spec.item_type is bool, BoolToggleFormItem) self._widget_types["optional_bool"] = ( lambda spec: spec.item_type == bool | None, BoolFormItem, ) - self._validity.setVisible(False) + pred, _ = self._widget_types["dict"] + self._widget_types["dict"] = pred, self._custom_device_config_item + self._validity.setVisible(True) self._connect_to_theme_change() self.populate() def _post_init(self): ... + def _custom_device_config_item(self, spec: FormItemSpec): + if spec.name != "deviceConfig": + return DictFormItem + if self._class_deviceconfig_item is not None: + return partial(PydanticModelFormItem, model=self._class_deviceconfig_item) + return DictFormItem + def set_pretty_display_theme(self, theme: str | None = None): if theme is None: theme = get_theme_name() diff --git a/bec_widgets/widgets/services/device_browser/device_item/device_item.py b/bec_widgets/widgets/services/device_browser/device_item/device_item.py index def709eb..45f233cb 100644 --- a/bec_widgets/widgets/services/device_browser/device_item/device_item.py +++ b/bec_widgets/widgets/services/device_browser/device_item/device_item.py @@ -18,7 +18,7 @@ from bec_widgets.widgets.services.device_browser.device_item.config_communicator CommunicateConfigAction, ) from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import ( - DeviceConfigDialog, + DirectUpdateDeviceConfigDialog, ) from bec_widgets.widgets.services.device_browser.device_item.device_config_form import ( DeviceConfigForm, @@ -35,9 +35,6 @@ logger = bec_logger.logger class DeviceItem(ExpandableGroupFrame): - broadcast_size_hint = Signal(QSize) - imminent_deletion = Signal() - RPC = False def __init__( @@ -94,7 +91,7 @@ class DeviceItem(ExpandableGroupFrame): @SafeSlot() def _create_edit_dialog(self): - dialog = DeviceConfigDialog( + dialog = DirectUpdateDeviceConfigDialog( parent=self, device=self.device, config_helper=self._config_helper, diff --git a/tests/unit_tests/test_device_browser.py b/tests/unit_tests/test_device_browser.py index 3ef97af8..7c36594e 100644 --- a/tests/unit_tests/test_device_browser.py +++ b/tests/unit_tests/test_device_browser.py @@ -37,11 +37,11 @@ def device_browser(qtbot, mocked_client): yield dev_browser -def test_device_browser_init_with_devices(device_browser): +def test_device_browser_init_with_devices(device_browser: DeviceBrowser): """ Test that the device browser is initialized with the correct number of devices. """ - device_list = device_browser.ui.device_list + device_list = device_browser.dev_list assert device_list.count() == len(device_browser.dev) @@ -58,11 +58,11 @@ def test_device_browser_filtering( expected = expected_num_visible if expected_num_visible >= 0 else len(device_browser.dev) def num_visible(item_dict): - return len(list(filter(lambda i: not i.isHidden(), item_dict.values()))) + return len(list(filter(lambda i: not i.widget.isHidden(), item_dict.values()))) device_browser.ui.filter_input.setText(search_term) qtbot.wait(100) - assert num_visible(device_browser._device_items) == expected + assert num_visible(device_browser.dev_list._item_dict) == expected def test_device_item_mouse_press_event(device_browser, qtbot): @@ -70,8 +70,8 @@ def test_device_item_mouse_press_event(device_browser, qtbot): Test that the mousePressEvent is triggered correctly. """ # Simulate a left mouse press event on the device item - device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0) - widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item) + device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0) + widget: DeviceItem = device_browser.dev_list.itemWidget(device_item) qtbot.mouseClick(widget._title, Qt.MouseButton.LeftButton) @@ -88,8 +88,8 @@ def test_device_item_expansion(device_browser, qtbot): Test that the form is displayed when the item is expanded, and that the expansion is triggered by clicking on the expansion button, the title, or the device icon """ - device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0) - widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item) + device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0) + widget: DeviceItem = device_browser.dev_list.itemWidget(device_item) qtbot.mouseClick(widget._expansion_button, Qt.MouseButton.LeftButton) tab_widget: QTabWidget = widget._contents.layout().itemAt(0).widget() qtbot.waitUntil(lambda: tab_widget.widget(0) is not None, timeout=100) @@ -100,7 +100,7 @@ def test_device_item_expansion(device_browser, qtbot): form = tab_widget.widget(0).layout().itemAt(0).widget() assert widget.expanded assert (name_field := form.widget_dict.get("name")) is not None - qtbot.waitUntil(lambda: name_field.getValue() == "samx", timeout=500) + qtbot.waitUntil(lambda: name_field.getValue() == "aptrx", timeout=500) qtbot.mouseClick(widget._expansion_button, Qt.MouseButton.LeftButton) assert not widget.expanded @@ -115,8 +115,8 @@ def test_device_item_mouse_press_and_move_events_creates_drag(device_browser, qt """ Test that the mousePressEvent is triggered correctly and initiates a drag. """ - device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0) - widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item) + device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0) + widget: DeviceItem = device_browser.dev_list.itemWidget(device_item) device_name = widget.device with mock.patch("qtpy.QtGui.QDrag.exec_") as mock_exec: with mock.patch("qtpy.QtGui.QDrag.setMimeData") as mock_set_mimedata: @@ -133,19 +133,19 @@ def test_device_item_double_click_event(device_browser, qtbot): Test that the mouseDoubleClickEvent is triggered correctly. """ # Simulate a left mouse press event on the device item - device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0) - widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item) + device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0) + widget: DeviceItem = device_browser.dev_list.itemWidget(device_item) qtbot.mouseDClick(widget, Qt.LeftButton) def test_device_deletion(device_browser, qtbot): - device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0) - widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item) + device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0) + widget: DeviceItem = device_browser.dev_list.itemWidget(device_item) widget._config_helper = mock.MagicMock() - assert widget.device in device_browser._device_items + assert widget.device in device_browser.dev_list._item_dict qtbot.mouseClick(widget.delete_button, Qt.LeftButton) - qtbot.waitUntil(lambda: widget.device not in device_browser._device_items, timeout=10000) + qtbot.waitUntil(lambda: widget.device not in device_browser.dev_list._item_dict, timeout=10000) def test_signal_display(mocked_client, qtbot): diff --git a/tests/unit_tests/test_device_config_form_dialog.py b/tests/unit_tests/test_device_config_form_dialog.py index 54bcbe35..219350d2 100644 --- a/tests/unit_tests/test_device_config_form_dialog.py +++ b/tests/unit_tests/test_device_config_form_dialog.py @@ -6,7 +6,7 @@ from qtpy.QtWidgets import QDialogButtonBox, QPushButton from bec_widgets.utils.forms_from_types.items import StrFormItem from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import ( - DeviceConfigDialog, + DirectUpdateDeviceConfigDialog, _try_literal_eval, ) @@ -29,7 +29,7 @@ def mock_client(): @pytest.fixture def update_dialog(mock_client, qtbot): """Fixture to create a DeviceConfigDialog instance.""" - update_dialog = DeviceConfigDialog( + update_dialog = DirectUpdateDeviceConfigDialog( device="test_device", config_helper=MagicMock(), client=mock_client ) qtbot.addWidget(update_dialog) @@ -39,7 +39,7 @@ def update_dialog(mock_client, qtbot): @pytest.fixture def add_dialog(mock_client, qtbot): """Fixture to create a DeviceConfigDialog instance.""" - add_dialog = DeviceConfigDialog( + add_dialog = DirectUpdateDeviceConfigDialog( device=None, config_helper=MagicMock(), client=mock_client, action="add" ) qtbot.addWidget(add_dialog) diff --git a/tests/unit_tests/test_device_input_base.py b/tests/unit_tests/test_device_input_base.py index 02ae550d..7ab73e94 100644 --- a/tests/unit_tests/test_device_input_base.py +++ b/tests/unit_tests/test_device_input_base.py @@ -43,7 +43,7 @@ def test_device_input_base_init(device_input_base): assert device_input_base.devices == [] -def test_device_input_base_init_with_config(mocked_client): +def test_device_input_base_init_with_config(qtbot, mocked_client): """Test init with Config""" config = { "widget_class": "DeviceInputWidget", @@ -55,6 +55,10 @@ def test_device_input_base_init_with_config(mocked_client): widget2 = DeviceInputWidget( client=mocked_client, config=DeviceInputConfig.model_validate(config) ) + qtbot.addWidget(widget) + qtbot.addWidget(widget2) + qtbot.waitExposed(widget) + qtbot.waitExposed(widget2) for w in [widget, widget2]: assert w.config.gui_id == "test_gui_id" assert w.config.device_filter == ["Positioner"] diff --git a/tests/unit_tests/test_device_manager_components.py b/tests/unit_tests/test_device_manager_components.py new file mode 100644 index 00000000..b4454cfd --- /dev/null +++ b/tests/unit_tests/test_device_manager_components.py @@ -0,0 +1,869 @@ +"""Unit tests for device_manager_components module.""" + +from unittest import mock + +import pytest +import yaml +from bec_lib.atlas_models import Device as DeviceModel +from qtpy import QtCore, QtGui, QtWidgets + +from bec_widgets.widgets.control.device_manager.components.constants import HEADERS_HELP_MD +from bec_widgets.widgets.control.device_manager.components.device_table_view import ( + USER_CHECK_DATA_ROLE, + BECTableView, + CenterCheckBoxDelegate, + CustomDisplayDelegate, + DeviceFilterProxyModel, + DeviceTableModel, + DeviceTableView, + DeviceValidatedDelegate, + DictToolTipDelegate, + WrappingTextDelegate, +) +from bec_widgets.widgets.control.device_manager.components.dm_config_view import DMConfigView +from bec_widgets.widgets.control.device_manager.components.dm_docstring_view import ( + DocstringView, + docstring_to_markdown, +) +from bec_widgets.widgets.control.device_manager.components.dm_ophyd_test import ValidationStatus + + +### Constants #### +def test_constants_headers_help_md(): + """Test that HEADERS_HELP_MD is a dictionary with expected keys and markdown format.""" + assert isinstance(HEADERS_HELP_MD, dict) + expected_keys = { + "status", + "name", + "deviceClass", + "readoutPriority", + "deviceTags", + "enabled", + "readOnly", + "onFailure", + "softwareTrigger", + "description", + } + assert set(HEADERS_HELP_MD.keys()) == expected_keys + for _, value in HEADERS_HELP_MD.items(): + assert isinstance(value, str) + assert value.startswith("## ") # Each entry should start with a markdown header + + +### DM Docstring View #### + + +@pytest.fixture +def docstring_view(qtbot): + """Fixture to create a DocstringView instance.""" + view = DocstringView() + qtbot.addWidget(view) + qtbot.waitExposed(view) + yield view + + +class NumPyStyleClass: + """Perform simple signal operations. + + Parameters + ---------- + data : numpy.ndarray + Input signal data. + + Attributes + ---------- + data : numpy.ndarray + The original signal data. + + Returns + ------- + SignalProcessor + An initialized signal processor instance. + """ + + +class GoogleStyleClass: + """Analyze spectral properties of a signal. + + Args: + frequencies (list[float]): Frequency bins. + amplitudes (list[float]): Corresponding amplitude values. + + Returns: + dict: A dictionary with spectral analysis results. + + Raises: + ValueError: If input lists are of unequal length. + """ + + +def test_docstring_view_docstring_to_markdown(): + """Test the docstring_to_markdown function with a sample class.""" + numpy_md = docstring_to_markdown(NumPyStyleClass) + assert "# NumPyStyleClass" in numpy_md + assert "### Parameters" in numpy_md + assert "### Attributes" in numpy_md + assert "### Returns" in numpy_md + assert "```" in numpy_md # Check for code block formatting + + google_md = docstring_to_markdown(GoogleStyleClass) + assert "# GoogleStyleClass" in google_md + assert "### Args" in google_md + assert "### Returns" in google_md + assert "### Raises" in google_md + assert "```" in google_md # Check for code block formatting + + +def test_docstring_view_on_select_config(docstring_view): + """Test the DocstringView on_select_config method. Called with single and multiple devices.""" + with ( + mock.patch.object(docstring_view, "set_device_class") as mock_set_device_class, + mock.patch.object(docstring_view, "_set_text") as mock_set_text, + ): + # Test with single device + docstring_view.on_select_config([{"deviceClass": "NumPyStyleClass"}]) + mock_set_device_class.assert_called_once_with("NumPyStyleClass") + + mock_set_device_class.reset_mock() + # Test with multiple devices, should not show anything + docstring_view.on_select_config( + [{"deviceClass": "NumPyStyleClass"}, {"deviceClass": "GoogleStyleClass"}] + ) + mock_set_device_class.assert_not_called() + mock_set_text.assert_called_once_with("") + + +def test_docstring_view_set_device_class(docstring_view): + """Test the DocstringView set_device_class method with valid and invalid class names.""" + with mock.patch( + "bec_widgets.widgets.control.device_manager.components.dm_docstring_view.get_plugin_class" + ) as mock_get_plugin_class: + + # Mock a valid class retrieval + mock_get_plugin_class.return_value = NumPyStyleClass + docstring_view.set_device_class("NumPyStyleClass") + assert "NumPyStyleClass" in docstring_view.toPlainText() + assert "Parameters" in docstring_view.toPlainText() + + # Mock an invalid class retrieval + mock_get_plugin_class.side_effect = ImportError("Class not found") + docstring_view.set_device_class("NonExistentClass") + assert "Error retrieving docstring for NonExistentClass" == docstring_view.toPlainText() + + # Test if READY_TO_VIEW is False + with mock.patch( + "bec_widgets.widgets.control.device_manager.components.dm_docstring_view.READY_TO_VIEW", + False, + ): + call_count = mock_get_plugin_class.call_count + docstring_view.set_device_class("NumPyStyleClass") # Should do nothing + assert mock_get_plugin_class.call_count == call_count # No new calls made + + +#### DM Config View #### + + +@pytest.fixture +def dm_config_view(qtbot): + """Fixture to create a DMConfigView instance.""" + view = DMConfigView() + qtbot.addWidget(view) + qtbot.waitExposed(view) + yield view + + +def test_dm_config_view_initialization(dm_config_view): + """Test DMConfigView proper initialization.""" + # Check that the stacked layout is set up correctly + assert dm_config_view.stacked_layout is not None + assert dm_config_view.stacked_layout.count() == 2 + # Assert Monaco editor is initialized + assert dm_config_view.monaco_editor.get_language() == "yaml" + assert dm_config_view.monaco_editor.editor._readonly is True + + # Check overlay widget + assert dm_config_view._overlay_widget is not None + assert dm_config_view._overlay_widget.text() == "Select single device to show config" + + # Check that overlay is initially shown + assert dm_config_view.stacked_layout.currentWidget() == dm_config_view._overlay_widget + + +def test_dm_config_view_on_select_config(dm_config_view): + """Test DMConfigView on_select_config with empty selection.""" + # Test with empty list of configs + dm_config_view.on_select_config([]) + assert dm_config_view.stacked_layout.currentWidget() == dm_config_view._overlay_widget + + # Test with a single config + cfgs = [{"name": "test_device", "deviceClass": "TestClass", "enabled": True}] + dm_config_view.on_select_config(cfgs) + assert dm_config_view.stacked_layout.currentWidget() == dm_config_view.monaco_editor + text = yaml.dump(cfgs[0], default_flow_style=False) + assert text.strip("\n") == dm_config_view.monaco_editor.get_text().strip("\n") + + # Test with multiple configs + cfgs = 2 * [{"name": "test_device", "deviceClass": "TestClass", "enabled": True}] + dm_config_view.on_select_config(cfgs) + assert dm_config_view.stacked_layout.currentWidget() == dm_config_view._overlay_widget + assert dm_config_view.monaco_editor.get_text() == "" # Should remain unchanged + + +### Device Table View #### +# Not sure how to nicely test the delegates. + + +@pytest.fixture +def mock_table_view(qtbot): + """Create a mock table view for delegate testing.""" + table = BECTableView() + qtbot.addWidget(table) + qtbot.waitExposed(table) + yield table + + +@pytest.fixture +def device_table_model(qtbot, mock_table_view): + """Fixture to create a DeviceTableModel instance.""" + model = DeviceTableModel(mock_table_view) + yield model + + +@pytest.fixture +def device_proxy_model(qtbot, mock_table_view, device_table_model): + """Fixture to create a DeviceFilterProxyModel instance.""" + model = DeviceFilterProxyModel(mock_table_view) + model.setSourceModel(device_table_model) + mock_table_view.setModel(model) + yield model + + +@pytest.fixture +def qevent_mock() -> QtCore.QEvent: + """Create a mock QEvent for testing.""" + event = mock.MagicMock(spec=QtCore.QEvent) + yield event + + +@pytest.fixture +def view_mock() -> QtWidgets.QAbstractItemView: + """Create a mock QAbstractItemView for testing.""" + view = mock.MagicMock(spec=QtWidgets.QAbstractItemView) + yield view + + +@pytest.fixture +def index_mock(device_proxy_model) -> QtCore.QModelIndex: + """Create a mock QModelIndex for testing.""" + index = mock.MagicMock(spec=QtCore.QModelIndex) + index.model.return_value = device_proxy_model + yield index + + +@pytest.fixture +def option_mock() -> QtWidgets.QStyleOptionViewItem: + """Create a mock QStyleOptionViewItem for testing.""" + option = mock.MagicMock(spec=QtWidgets.QStyleOptionViewItem) + yield option + + +@pytest.fixture +def painter_mock() -> QtGui.QPainter: + """Create a mock QPainter for testing.""" + painter = mock.MagicMock(spec=QtGui.QPainter) + yield painter + + +def test_tooltip_delegate( + mock_table_view, qevent_mock, view_mock, option_mock, index_mock, device_proxy_model +): + """Test DictToolTipDelegate tooltip generation.""" + # No ToolTip event + delegate = DictToolTipDelegate(mock_table_view) + qevent_mock.type.return_value = QtCore.QEvent.Type.TouchCancel + # nothing should happen + with mock.patch.object( + QtWidgets.QStyledItemDelegate, "helpEvent", return_value=False + ) as super_mock: + result = delegate.helpEvent(qevent_mock, view_mock, option_mock, index_mock) + + super_mock.assert_called_once_with(qevent_mock, view_mock, option_mock, index_mock) + assert result is False + + # ToolTip event + qevent_mock.type.return_value = QtCore.QEvent.Type.ToolTip + qevent_mock.globalPos = mock.MagicMock(return_value=QtCore.QPoint(10, 20)) + + source_model = device_proxy_model.sourceModel() + with ( + mock.patch.object( + source_model, "get_row_data", return_value={"description": "Mock description"} + ), + mock.patch.object(device_proxy_model, "mapToSource", return_value=index_mock), + mock.patch.object(QtWidgets.QToolTip, "showText") as show_text_mock, + ): + result = delegate.helpEvent(qevent_mock, view_mock, option_mock, index_mock) + show_text_mock.assert_called_once_with(QtCore.QPoint(10, 20), "Mock description", view_mock) + assert result is True + + +def test_custom_display_delegate(qtbot, mock_table_view, painter_mock, option_mock, index_mock): + """Test CustomDisplayDelegate initialization.""" + delegate = CustomDisplayDelegate(mock_table_view) + + # Test _test_custom_paint, with None and a value + def _return_data(): + yield None + yield "Test Value" + + proxy_model = index_mock.model() + with ( + mock.patch.object(proxy_model, "data", side_effect=_return_data()), + mock.patch.object( + QtWidgets.QStyledItemDelegate, "paint", return_value=None + ) as super_paint_mock, + mock.patch.object(delegate, "_do_custom_paint", return_value=None) as custom_paint_mock, + ): + delegate.paint(painter_mock, option_mock, index_mock) + super_paint_mock.assert_called_once_with(painter_mock, option_mock, index_mock) + custom_paint_mock.assert_not_called() + # Call again for the value case + delegate.paint(painter_mock, option_mock, index_mock) + super_paint_mock.assert_called_with(painter_mock, option_mock, index_mock) + assert super_paint_mock.call_count == 2 + custom_paint_mock.assert_called_once_with( + painter_mock, option_mock, index_mock, "Test Value" + ) + + +def test_center_checkbox_delegate( + mock_table_view, qevent_mock, painter_mock, option_mock, index_mock +): + """Test CenterCheckBoxDelegate initialization.""" + delegate = CenterCheckBoxDelegate(mock_table_view) + + option_mock.rect = QtCore.QRect(0, 0, 100, 20) + delegate._do_custom_paint(painter_mock, option_mock, index_mock, QtCore.Qt.CheckState.Checked) + # Check that the checkbox is centered + pixrect = delegate._icon_checked.rect() + pixrect.moveCenter(option_mock.rect.center()) + painter_mock.drawPixmap.assert_called_once_with(pixrect.topLeft(), delegate._icon_checked) + + model = index_mock.model() + + # Editor event with non-check state role + qevent_mock.type.return_value = QtCore.QEvent.Type.MouseTrackingChange + assert not delegate.editorEvent(qevent_mock, model, option_mock, index_mock) + + # Editor event with check state role but not mouse button event + qevent_mock.type.return_value = QtCore.QEvent.Type.MouseButtonRelease + with ( + mock.patch.object(model, "data", return_value=QtCore.Qt.CheckState.Checked), + mock.patch.object(model, "setData") as mock_model_set, + ): + delegate.editorEvent(qevent_mock, model, option_mock, index_mock) + mock_model_set.assert_called_once_with( + index_mock, QtCore.Qt.CheckState.Unchecked, USER_CHECK_DATA_ROLE + ) + + +def test_device_validated_delegate( + mock_table_view, qevent_mock, painter_mock, option_mock, index_mock +): + """Test DeviceValidatedDelegate initialization.""" + # Invalid value + delegate = DeviceValidatedDelegate(mock_table_view) + delegate._do_custom_paint(painter_mock, option_mock, index_mock, "wrong_value") + painter_mock.drawPixmap.assert_not_called() + + # Valid value + option_mock.rect = QtCore.QRect(0, 0, 100, 20) + delegate._do_custom_paint(painter_mock, option_mock, index_mock, ValidationStatus.VALID.value) + icon = delegate._icons[ValidationStatus.VALID.value] + pixrect = icon.rect() + pixrect.moveCenter(option_mock.rect.center()) + painter_mock.drawPixmap.assert_called_once_with(pixrect.topLeft(), icon) + + +def test_wrapping_text_delegate_do_custom_paint( + mock_table_view, painter_mock, option_mock, index_mock +): + """Test WrappingTextDelegate _do_custom_paint method.""" + delegate = WrappingTextDelegate(mock_table_view) + + # First case, empty text, nothing should happen + delegate._do_custom_paint(painter_mock, option_mock, index_mock, "") + painter_mock.setPen.assert_not_called() + layout_mock = mock.MagicMock() + + def _layout_comput_return(*args, **kwargs): + return layout_mock + + layout_mock.draw.return_value = None + with mock.patch.object(delegate, "_compute_layout", side_effect=_layout_comput_return): + delegate._do_custom_paint(painter_mock, option_mock, index_mock, "New Docstring") + layout_mock.draw.assert_called_with(painter_mock, option_mock.rect.topLeft()) + + +TEST_RECT_FOR = QtCore.QRect(0, 0, 100, 20) +TEST_TEXT_WITH_4_LINES = "This is a test string to check text wrapping in the delegate." + + +def test_wrapping_text_delegate_compute_layout(mock_table_view, option_mock): + """Test WrappingTextDelegate _compute_layout method.""" + delegate = WrappingTextDelegate(mock_table_view) + layout_mock = mock.MagicMock(spec=QtGui.QTextLayout) + + # This combination should yield 4 lines + with mock.patch.object(delegate, "_get_layout", return_value=layout_mock): + layout_mock.createLine.return_value = mock_line = mock.MagicMock(spec=QtGui.QTextLine) + mock_line.height.return_value = 10 + mock_line.isValid = mock.MagicMock(side_effect=[True, True, True, False]) + + option_mock.rect = TEST_RECT_FOR + option_mock.font = QtGui.QFont() + layout: QtGui.QTextLayout = delegate._compute_layout(TEST_TEXT_WITH_4_LINES, option_mock) + assert layout.createLine.call_count == 4 # pylint: disable=E1101 + assert mock_line.setPosition.call_count == 3 + assert mock_line.setPosition.call_args_list[-1] == mock.call( + QtCore.QPointF(delegate.margin / 2, 20) # 0, 10, 20 # Then false and exit + ) + + +def test_wrapping_text_delegate_size_hint(mock_table_view, option_mock, index_mock): + """Test WrappingTextDelegate sizeHint method. Use the test text that should wrap to 4 lines.""" + delegate = WrappingTextDelegate(mock_table_view) + assert delegate.margin == 6 + with ( + mock.patch.object(mock_table_view, "initViewItemOption"), + mock.patch.object(mock_table_view, "isColumnHidden", side_effect=[False, False]), + mock.patch.object(mock_table_view, "isVisible", side_effect=[True, True]), + ): + # Test with empty text, should return height + 2*margin + index_mock.data.return_value = "" + option_mock.rect = TEST_RECT_FOR + font_metrics = option_mock.fontMetrics = QtGui.QFontMetrics(QtGui.QFont()) + size = delegate.sizeHint(option_mock, index_mock) + assert size == QtCore.QSize(0, font_metrics.height() + 2 * delegate.margin) + + # Now test with the text that should wrap to 4 lines + index_mock.data.return_value = TEST_TEXT_WITH_4_LINES + size = delegate.sizeHint(option_mock, index_mock) + # The estimate goes to 5 lines + 2* margin + expected_lines = 5 + assert size == QtCore.QSize( + 100, font_metrics.height() * expected_lines + 2 * delegate.margin + ) + + +def test_wrapping_text_delegate_update_row_heights(mock_table_view, device_proxy_model): + """Test WrappingTextDelegate update_row_heights method.""" + device_cfg = DeviceModel( + name="test_device", deviceClass="TestClass", enabled=True, readoutPriority="baseline" + ).model_dump() + # Add single device to config + delegate = WrappingTextDelegate(mock_table_view) + row_heights = [25, 40] + + with mock.patch.object( + delegate, + "sizeHint", + side_effect=[QtCore.QSize(100, row_heights[0]), QtCore.QSize(100, row_heights[1])], + ): + mock_table_view.setItemDelegateForColumn(5, delegate) + mock_table_view.setItemDelegateForColumn(6, delegate) + device_proxy_model.sourceModel().set_device_config([device_cfg]) + assert delegate._wrapping_text_columns is None + assert mock_table_view.rowHeight(0) == 30 # Default height + delegate._update_row_heights() + assert delegate._wrapping_text_columns == [5, 6] + assert mock_table_view.rowHeight(0) == max(row_heights) + + +def test_device_validation_delegate( + mock_table_view, qevent_mock, painter_mock, option_mock, index_mock +): + """Test DeviceValidatedDelegate initialization.""" + delegate = DeviceValidatedDelegate(mock_table_view) + + option_mock.rect = QtCore.QRect(0, 0, 100, 20) + delegate._do_custom_paint(painter_mock, option_mock, index_mock, ValidationStatus.VALID) + # Check that the checkbox is centered + + pixrect = delegate._icons[ValidationStatus.VALID.value].rect() + pixrect.moveCenter(option_mock.rect.center()) + painter_mock.drawPixmap.assert_called_once_with( + pixrect.topLeft(), delegate._icons[ValidationStatus.VALID.value] + ) + + # Should not be called if invalid value + delegate._do_custom_paint(painter_mock, option_mock, index_mock, 10) + + # Check that the checkbox is centered + assert painter_mock.drawPixmap.call_count == 1 + + +### +# Test DeviceTableModel & DeviceFilterProxyModel +### + + +def test_device_table_model_data(device_proxy_model): + """Test the device table model data retrieval.""" + source_model = device_proxy_model.sourceModel() + test_device = { + "status": ValidationStatus.PENDING, + "name": "test_device", + "deviceClass": "TestClass", + "readoutPriority": "baseline", + "onFailure": "retry", + "enabled": True, + "readOnly": False, + "softwareTrigger": True, + "deviceTags": ["tag1", "tag2"], + "description": "Test device", + } + source_model.add_device_configs([test_device]) + assert source_model.rowCount() == 1 + assert source_model.columnCount() == 10 + + # Check data retrieval for each column + expected_data = { + 0: ValidationStatus.PENDING, # Default status + 1: "test_device", # name + 2: "TestClass", # deviceClass + 3: "baseline", # readoutPriority + 4: "retry", # onFailure + 5: "tag1, tag2", # deviceTags + 6: "Test device", # description + 7: True, # enabled + 8: False, # readOnly + 9: True, # softwareTrigger + } + + for col, expected in expected_data.items(): + index = source_model.index(0, col) + data = source_model.data(index, QtCore.Qt.DisplayRole) + assert data == expected + + +def test_device_table_model_with_data(device_table_model, device_proxy_model): + """Test (A): DeviceTableModel and DeviceFilterProxyModel with 3 rows of data.""" + # Create 3 test devices - names NOT alphabetically sorted + test_devices = [ + { + "name": "zebra_device", + "deviceClass": "TestClass1", + "enabled": True, + "readOnly": False, + "readoutPriority": "baseline", + "deviceTags": ["tag1", "tag2"], + "description": "Test device Z", + }, + { + "name": "alpha_device", + "deviceClass": "TestClass2", + "enabled": False, + "readOnly": True, + "readoutPriority": "primary", + "deviceTags": ["tag3"], + "description": "Test device A", + }, + { + "name": "beta_device", + "deviceClass": "TestClass3", + "enabled": True, + "readOnly": False, + "readoutPriority": "secondary", + "deviceTags": [], + "description": "Test device B", + }, + ] + + # Add devices to source model + device_table_model.add_device_configs(test_devices) + + # Check source model has 3 rows and proper columns + assert device_table_model.rowCount() == 3 + assert device_table_model.columnCount() == 10 + + # Check proxy model propagates the data + assert device_proxy_model.rowCount() == 3 + assert device_proxy_model.columnCount() == 10 + + # Verify data propagation through proxy - check names in original order + for i, expected_device in enumerate(test_devices): + proxy_index = device_proxy_model.index(i, 1) # Column 1 is name + source_index = device_proxy_model.mapToSource(proxy_index) + source_data = device_table_model.data(source_index, QtCore.Qt.DisplayRole) + assert source_data == expected_device["name"] + + # Check proxy data matches source + proxy_data = device_proxy_model.data(proxy_index, QtCore.Qt.DisplayRole) + assert proxy_data == source_data + + # Verify all columns are accessible + headers = device_table_model.headers + for col, header in enumerate(headers): + header_data = device_table_model.headerData( + col, QtCore.Qt.Horizontal, QtCore.Qt.DisplayRole + ) + assert header_data is not None + + +def test_device_table_sorting(qtbot, mock_table_view, device_table_model, device_proxy_model): + """Test (B): Sorting functionality - original row 2 (alpha) should become row 0 after sort.""" + # Use same test data as above - zebra, alpha, beta (not alphabetically sorted) + test_devices = [ + { + "status": ValidationStatus.VALID, + "name": "zebra_device", + "deviceClass": "TestClass1", + "enabled": True, + }, + { + "status": ValidationStatus.PENDING, + "name": "alpha_device", + "deviceClass": "TestClass2", + "enabled": False, + }, + { + "status": ValidationStatus.FAILED, + "name": "beta_device", + "deviceClass": "TestClass3", + "enabled": True, + }, + ] + + device_table_model.add_device_configs(test_devices) + + # Verify initial order (unsorted) + assert ( + device_proxy_model.data(device_proxy_model.index(0, 1), QtCore.Qt.DisplayRole) + == "zebra_device" + ) + assert ( + device_proxy_model.data(device_proxy_model.index(1, 1), QtCore.Qt.DisplayRole) + == "alpha_device" + ) + assert ( + device_proxy_model.data(device_proxy_model.index(2, 1), QtCore.Qt.DisplayRole) + == "beta_device" + ) + + # Enable sorting and sort by name column (column 1) + mock_table_view.setSortingEnabled(True) + # header = mock_table_view.horizontalHeader() + # qtbot.mouseClick(header.sectionPosition(1), QtCore.Qt.LeftButton) + device_proxy_model.sort(1, QtCore.Qt.AscendingOrder) + + # After sorting, verify alphabetical order: alpha, beta, zebra + assert ( + device_proxy_model.data(device_proxy_model.index(0, 1), QtCore.Qt.DisplayRole) + == "alpha_device" + ) + assert ( + device_proxy_model.data(device_proxy_model.index(1, 1), QtCore.Qt.DisplayRole) + == "beta_device" + ) + assert ( + device_proxy_model.data(device_proxy_model.index(2, 1), QtCore.Qt.DisplayRole) + == "zebra_device" + ) + + +def test_bec_table_view_remove_rows(qtbot, mock_table_view, device_table_model, device_proxy_model): + """Test (C): Remove rows from BECTableView and verify propagation.""" + # Set up test data + test_devices = [ + {"name": "device_to_keep", "deviceClass": "KeepClass", "enabled": True}, + {"name": "device_to_remove", "deviceClass": "RemoveClass", "enabled": False}, + {"name": "another_keeper", "deviceClass": "KeepClass2", "enabled": True}, + ] + + device_table_model.add_device_configs(test_devices) + assert device_table_model.rowCount() == 3 + assert device_proxy_model.rowCount() == 3 + + # Mock the confirmation dialog to first cancel, then confirm + with mock.patch.object( + mock_table_view, "_remove_rows_msg_dialog", side_effect=[False, True] + ) as mock_confirm: + + # Create mock selection for middle device (device_to_remove at row 1) + selection_model = mock.MagicMock() + proxy_index_to_remove = device_proxy_model.index(1, 0) # Row 1, any column + selection_model.selectedRows.return_value = [proxy_index_to_remove] + + mock_table_view.selectionModel = mock.MagicMock(return_value=selection_model) + + # Verify the device we're about to remove + device_name_to_remove = device_proxy_model.data( + device_proxy_model.index(1, 1), QtCore.Qt.DisplayRole + ) + assert device_name_to_remove == "device_to_remove" + + # Call delete_selected method + mock_table_view.delete_selected() + + # Verify confirmation was called + mock_confirm.assert_called_once() + + assert device_table_model.rowCount() == 3 # No change on first call + assert device_proxy_model.rowCount() == 3 + + # Call delete_selected again, this time it should confirm + mock_table_view.delete_selected() + + # Check that the device was removed from source model + assert device_table_model.rowCount() == 2 + assert device_proxy_model.rowCount() == 2 + + # Verify the remaining devices are correct + remaining_names = [] + for i in range(device_proxy_model.rowCount()): + name = device_proxy_model.data(device_proxy_model.index(i, 1), QtCore.Qt.DisplayRole) + remaining_names.append(name) + + assert "device_to_remove" not in remaining_names + + +def test_device_filter_proxy_model_filtering(device_table_model, device_proxy_model): + """Test DeviceFilterProxyModel text filtering functionality.""" + # Set up test data with different device names and classes + test_devices = [ + {"name": "motor_x", "deviceClass": "EpicsMotor", "description": "X-axis motor"}, + {"name": "detector_main", "deviceClass": "EpicsDetector", "description": "Main detector"}, + {"name": "motor_y", "deviceClass": "EpicsMotor", "description": "Y-axis motor"}, + ] + + device_table_model.add_device_configs(test_devices) + assert device_proxy_model.rowCount() == 3 + + # Test filtering by name + device_proxy_model.setFilterText("motor") + assert device_proxy_model.rowCount() == 2 + # Should show 2 rows (motor_x and motor_y) + visible_count = 0 + for i in range(device_proxy_model.rowCount()): + if not device_proxy_model.filterAcceptsRow(i, QtCore.QModelIndex()): + continue + visible_count += 1 + + # Test filtering by device class + device_proxy_model.setFilterText("EpicsDetector") + # Should show 1 row (detector_main) + detector_visible = False + assert device_proxy_model.rowCount() == 1 + for i in range(device_table_model.rowCount()): + if device_proxy_model.filterAcceptsRow(i, QtCore.QModelIndex()): + source_index = device_table_model.index(i, 1) # Name column + name = device_table_model.data(source_index, QtCore.Qt.DisplayRole) + if name == "detector_main": + detector_visible = True + break + assert detector_visible + + # Clear filter + device_proxy_model.setFilterText("") + assert device_proxy_model.rowCount() == 3 + # Should show all 3 rows again + all_visible = all( + device_proxy_model.filterAcceptsRow(i, QtCore.QModelIndex()) + for i in range(device_table_model.rowCount()) + ) + assert all_visible + + +### +# Test DeviceTableView +### + + +@pytest.fixture +def device_table_view(qtbot): + """Fixture to create a DeviceTableView instance.""" + view = DeviceTableView() + qtbot.addWidget(view) + qtbot.waitExposed(view) + yield view + + +def test_device_table_view_initialization(qtbot, device_table_view): + """Test the DeviceTableView search method.""" + + # Check that the search input fields are properly initialized and connected + qtbot.keyClicks(device_table_view.search_input, "zebra") + qtbot.waitUntil(lambda: device_table_view.proxy._filter_text == "zebra", timeout=2000) + qtbot.mouseClick(device_table_view.fuzzy_is_disabled, QtCore.Qt.LeftButton) + qtbot.waitUntil(lambda: device_table_view.proxy._enable_fuzzy is True, timeout=2000) + + # Check table setup + + # header + header = device_table_view.table.horizontalHeader() + assert header.sectionResizeMode(5) == QtWidgets.QHeaderView.ResizeMode.Interactive # tags + assert header.sectionResizeMode(6) == QtWidgets.QHeaderView.ResizeMode.Stretch # description + + # table selection + assert ( + device_table_view.table.selectionBehavior() + == QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows + ) + assert ( + device_table_view.table.selectionMode() + == QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection + ) + + +def test_device_table_theme_update(device_table_view): + """Test DeviceTableView apply_theme method.""" + # Check apply theme propagates + with ( + mock.patch.object(device_table_view.checkbox_delegate, "apply_theme") as mock_apply, + mock.patch.object(device_table_view.validated_delegate, "apply_theme") as mock_validated, + ): + device_table_view.apply_theme("dark") + mock_apply.assert_called_once_with("dark") + mock_validated.assert_called_once_with("dark") + + +def test_device_table_view_updates(device_table_view): + """Test DeviceTableView methods that update the view and model.""" + # Test theme update triggered.. + + cfgs = [ + {"status": 0, "name": "test_device", "deviceClass": "TestClass", "enabled": True}, + {"status": 1, "name": "another_device", "deviceClass": "AnotherClass", "enabled": False}, + {"status": 2, "name": "zebra_device", "deviceClass": "ZebraClass", "enabled": True}, + ] + with mock.patch.object(device_table_view, "_request_autosize_columns") as mock_autosize: + # Should be called once for rowsInserted + device_table_view.set_device_config(cfgs) + assert device_table_view.get_device_config() == cfgs + mock_autosize.assert_called_once() + # Update validation status, should be called again + device_table_view.update_device_validation("test_device", ValidationStatus.VALID) + assert mock_autosize.call_count == 2 + # Remove a device, should triggere also a _request_autosize_columns call + device_table_view.remove_device_configs([cfgs[0]]) + assert device_table_view.get_device_config() == cfgs[1:] + assert mock_autosize.call_count == 3 + # Remove one device manually + device_table_view.remove_device("another_device") # Should remove the last device + assert device_table_view.get_device_config() == cfgs[2:] + assert mock_autosize.call_count == 4 + # Reset the model should call it once again + device_table_view.clear_device_configs() + assert mock_autosize.call_count == 5 + assert device_table_view.get_device_config() == [] + + +def test_device_table_view_get_help_md(device_table_view): + """Test DeviceTableView get_help_md method.""" + with mock.patch.object(device_table_view.table, "indexAt") as mock_index_at: + mock_index_at.isValid = mock.MagicMock(return_value=True) + with mock.patch.object(device_table_view, "_model") as mock_model: + mock_model.headerData = mock.MagicMock(side_effect=["softTrig"]) + # Second call is True, should return the corresponding help md + assert device_table_view.get_help_md() == HEADERS_HELP_MD["softwareTrigger"] diff --git a/tests/unit_tests/test_device_manager_view.py b/tests/unit_tests/test_device_manager_view.py new file mode 100644 index 00000000..a85be731 --- /dev/null +++ b/tests/unit_tests/test_device_manager_view.py @@ -0,0 +1,224 @@ +"""Unit tests for the device manager view""" + +# pylint: disable=protected-access,redefined-outer-name + +from unittest import mock + +import pytest +from qtpy import QtCore +from qtpy.QtWidgets import QFileDialog, QMessageBox + +from bec_widgets.applications.views.device_manager_view.device_manager_view import ( + ConfigChoiceDialog, + DeviceManagerView, +) +from bec_widgets.utils.help_inspector.help_inspector import HelpInspector +from bec_widgets.widgets.control.device_manager.components import ( + DeviceTableView, + DMConfigView, + DMOphydTest, + DocstringView, +) + + +@pytest.fixture +def dm_view(qtbot): + """Fixture for DeviceManagerView.""" + widget = DeviceManagerView() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +@pytest.fixture +def config_choice_dialog(qtbot, dm_view): + """Fixture for ConfigChoiceDialog.""" + dialog = ConfigChoiceDialog(dm_view) + qtbot.addWidget(dialog) + qtbot.waitExposed(dialog) + yield dialog + + +def test_device_manager_view_config_choice_dialog(qtbot, dm_view, config_choice_dialog): + """Test the configuration choice dialog.""" + assert config_choice_dialog is not None + assert config_choice_dialog.parent() == dm_view + + # Test dialog components + with ( + mock.patch.object(config_choice_dialog, "accept") as mock_accept, + mock.patch.object(config_choice_dialog, "reject") as mock_reject, + ): + + # Replace + qtbot.mouseClick(config_choice_dialog.replace_btn, QtCore.Qt.LeftButton) + mock_accept.assert_called_once() + mock_reject.assert_not_called() + mock_accept.reset_mock() + assert config_choice_dialog.result() == config_choice_dialog.REPLACE + # Add + qtbot.mouseClick(config_choice_dialog.add_btn, QtCore.Qt.LeftButton) + mock_accept.assert_called_once() + mock_reject.assert_not_called() + mock_accept.reset_mock() + assert config_choice_dialog.result() == config_choice_dialog.ADD + # Cancel + qtbot.mouseClick(config_choice_dialog.cancel_btn, QtCore.Qt.LeftButton) + mock_accept.assert_not_called() + mock_reject.assert_called_once() + assert config_choice_dialog.result() == config_choice_dialog.CANCEL + + +class TestDeviceManagerViewInitialization: + """Test class for DeviceManagerView initialization and basic components.""" + + def test_dock_manager_initialization(self, dm_view): + """Test that the QtAds DockManager is properly initialized.""" + assert dm_view.dock_manager is not None + assert dm_view.dock_manager.centralWidget() is not None + + def test_central_widget_is_device_table_view(self, dm_view): + """Test that the central widget is DeviceTableView.""" + central_widget = dm_view.dock_manager.centralWidget().widget() + assert isinstance(central_widget, DeviceTableView) + assert central_widget is dm_view.device_table_view + + def test_dock_widgets_exist(self, dm_view): + """Test that all required dock widgets are created.""" + dock_widgets = dm_view.dock_manager.dockWidgets() + + # Check that we have the expected number of dock widgets + assert len(dock_widgets) >= 4 + + # Check for specific widget types + widget_types = [dock.widget().__class__ for dock in dock_widgets] + + assert DMConfigView in widget_types + assert DMOphydTest in widget_types + assert DocstringView in widget_types + + def test_toolbar_initialization(self, dm_view): + """Test that the toolbar is properly initialized with expected bundles.""" + assert dm_view.toolbar is not None + assert "IO" in dm_view.toolbar.bundles + assert "Table" in dm_view.toolbar.bundles + + def test_toolbar_components_exist(self, dm_view): + """Test that all expected toolbar components exist.""" + expected_components = [ + "load", + "save_to_disk", + "load_redis", + "update_config_redis", + "reset_composed", + "add_device", + "remove_device", + "rerun_validation", + ] + + for component in expected_components: + assert dm_view.toolbar.components.exists(component) + + def test_signal_connections(self, dm_view): + """Test that signals are properly connected between components.""" + # Test that device_table_view signals are connected + assert dm_view.device_table_view.selected_devices is not None + assert dm_view.device_table_view.device_configs_changed is not None + + # Test that ophyd_test_view signals are connected + assert dm_view.ophyd_test_view.device_validated is not None + + +class TestDeviceManagerViewIOBundle: + """Test class for DeviceManagerView IO bundle actions.""" + + def test_io_bundle_exists(self, dm_view): + """Test that IO bundle exists and contains expected actions.""" + assert "IO" in dm_view.toolbar.bundles + io_actions = ["load", "save_to_disk", "load_redis", "update_config_redis"] + for action in io_actions: + assert dm_view.toolbar.components.exists(action) + + def test_load_file_action_triggered(self, tmp_path, dm_view): + """Test load file action trigger mechanism.""" + + with ( + mock.patch.object(dm_view, "_get_file_path", return_value=tmp_path), + mock.patch( + "bec_widgets.applications.views.device_manager_view.device_manager_view.yaml_load" + ) as mock_yaml_load, + mock.patch.object(dm_view, "_open_config_choice_dialog") as mock_open_dialog, + ): + mock_yaml_data = {"device1": {"param1": "value1"}} + mock_yaml_load.return_value = mock_yaml_data + + # Setup dialog mock + dm_view.toolbar.components._components["load"].action.action.triggered.emit() + mock_yaml_load.assert_called_once_with(tmp_path) + mock_open_dialog.assert_called_once_with([{"name": "device1", "param1": "value1"}]) + + def test_save_config_to_file(self, tmp_path, dm_view): + """Test saving config to file.""" + yaml_path = tmp_path / "test_save.yaml" + mock_config = [{"name": "device1", "param1": "value1"}] + with ( + mock.patch.object(dm_view, "_get_file_path", return_value=tmp_path), + mock.patch.object(dm_view, "_get_recovery_config_path", return_value=tmp_path), + mock.patch.object(dm_view, "_get_file_path", return_value=yaml_path), + mock.patch.object( + dm_view.device_table_view, "get_device_config", return_value=mock_config + ), + ): + dm_view.toolbar.components._components["save_to_disk"].action.action.triggered.emit() + assert yaml_path.exists() + + +class TestDeviceManagerViewTableBundle: + """Test class for DeviceManagerView Table bundle actions.""" + + def test_table_bundle_exists(self, dm_view): + """Test that Table bundle exists and contains expected actions.""" + assert "Table" in dm_view.toolbar.bundles + table_actions = ["reset_composed", "add_device", "remove_device", "rerun_validation"] + for action in table_actions: + assert dm_view.toolbar.components.exists(action) + + @mock.patch( + "bec_widgets.applications.views.device_manager_view.device_manager_view._yes_no_question" + ) + def test_reset_composed_view(self, mock_question, dm_view): + """Test reset composed view when user confirms.""" + with mock.patch.object(dm_view.device_table_view, "clear_device_configs") as mock_clear: + mock_question.return_value = QMessageBox.StandardButton.Yes + dm_view.toolbar.components._components["reset_composed"].action.action.triggered.emit() + mock_clear.assert_called_once() + mock_clear.reset_mock() + mock_question.return_value = QMessageBox.StandardButton.No + dm_view.toolbar.components._components["reset_composed"].action.action.triggered.emit() + mock_clear.assert_not_called() + + def test_add_device_action_connected(self, dm_view): + """Test add device action opens dialog correctly.""" + with mock.patch.object(dm_view, "_add_device_action") as mock_add: + dm_view.toolbar.components._components["add_device"].action.action.triggered.emit() + mock_add.assert_called_once() + + def test_remove_device_action(self, dm_view): + """Test remove device action.""" + with mock.patch.object(dm_view.device_table_view, "remove_selected_rows") as mock_remove: + dm_view.toolbar.components._components["remove_device"].action.action.triggered.emit() + mock_remove.assert_called_once() + + def test_rerun_device_validation(self, dm_view): + """Test rerun device validation action.""" + cfgs = [{"name": "device1", "param1": "value1"}] + with ( + mock.patch.object(dm_view.ophyd_test_view, "change_device_configs") as mock_change, + mock.patch.object( + dm_view.device_table_view.table, "selected_configs", return_value=cfgs + ), + ): + dm_view.toolbar.components._components[ + "rerun_validation" + ].action.action.triggered.emit() + mock_change.assert_called_once_with(cfgs, True, True) diff --git a/tests/unit_tests/test_help_inspector.py b/tests/unit_tests/test_help_inspector.py index 75cd738b..5ab96274 100644 --- a/tests/unit_tests/test_help_inspector.py +++ b/tests/unit_tests/test_help_inspector.py @@ -1,9 +1,12 @@ # pylint: disable=missing-function-docstring, missing-module-docstring, unused-import +from unittest import mock + import pytest from qtpy import QtCore, QtWidgets from bec_widgets.utils.help_inspector.help_inspector import HelpInspector +from bec_widgets.utils.widget_io import WidgetHierarchy from bec_widgets.widgets.control.buttons.button_abort.button_abort import AbortButton from .client_mocks import mocked_client @@ -79,3 +82,51 @@ def test_help_inspector_escape_key(qtbot, help_inspector): assert not help_inspector._active assert not help_inspector._button.isChecked() assert QtWidgets.QApplication.overrideCursor() is None + + +def test_help_inspector_event_filter(help_inspector, abort_button): + """Test the event filter of the HelpInspector.""" + # Test nothing happens when not active + obj = mock.MagicMock(spec=QtWidgets.QWidget) + event = mock.MagicMock(spec=QtCore.QEvent) + assert help_inspector._active is False + with mock.patch.object( + QtWidgets.QWidget, "eventFilter", return_value=False + ) as super_event_filter: + help_inspector.eventFilter(obj, event) # should do nothing and return False + super_event_filter.assert_called_once_with(obj, event) + super_event_filter.reset_mock() + + help_inspector._active = True + with mock.patch.object(help_inspector, "_toggle_mode") as mock_toggle: + # Key press Escape + event.type = mock.MagicMock(return_value=QtCore.QEvent.KeyPress) + event.key = mock.MagicMock(return_value=QtCore.Qt.Key.Key_Escape) + help_inspector.eventFilter(obj, event) + mock_toggle.assert_called_once_with(False) + mock_toggle.reset_mock() + + # Click on itself + event.type = mock.MagicMock(return_value=QtCore.QEvent.MouseButtonPress) + event.button = mock.MagicMock(return_value=QtCore.Qt.LeftButton) + event.globalPos = mock.MagicMock(return_value=QtCore.QPoint(1, 1)) + with mock.patch.object( + help_inspector._app, "widgetAt", side_effect=[help_inspector, abort_button] + ): + # Return for self call + help_inspector.eventFilter(obj, event) + mock_toggle.assert_called_once_with(False) + mock_toggle.reset_mock() + # Run Callback for abort_button + callback_data = [] + + def _my_callback(widget): + callback_data.append(widget) + + help_inspector.register_callback(_my_callback) + + help_inspector.eventFilter(obj, event) + mock_toggle.assert_not_called() + assert len(callback_data) == 1 + assert callback_data[0] == abort_button + callback_data.clear()