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()