diff --git a/bec_widgets/examples/device_manager_view/device_manager_view.py b/bec_widgets/examples/device_manager_view/device_manager_view.py
index d3950dca..db037ec6 100644
--- a/bec_widgets/examples/device_manager_view/device_manager_view.py
+++ b/bec_widgets/examples/device_manager_view/device_manager_view.py
@@ -18,6 +18,9 @@ from qtpy.QtWidgets import (
from bec_widgets import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
+from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources import (
+ AvailableDeviceResources,
+)
from bec_widgets.widgets.control.device_manager.components.device_table_view import DeviceTableView
from bec_widgets.widgets.control.device_manager.components.dm_config_view import DMConfigView
from bec_widgets.widgets.control.device_manager.components.dm_ophyd_test import (
@@ -76,7 +79,7 @@ class DeviceManagerView(BECWidget, QWidget):
self._root_layout.addWidget(self.dock_manager)
# Initialize the widgets
- self.explorer = IDEExplorer(self) # TODO will be replaced by explorer widget
+ self.available_devices = AvailableDeviceResources(self)
self.device_table_view = DeviceTableView(self)
# Placeholder
self.dm_config_view = DMConfigView(self)
@@ -88,8 +91,8 @@ class DeviceManagerView(BECWidget, QWidget):
self.ophyd_test_dock.setWidget(self.ophyd_test)
# Create the dock widgets
- self.explorer_dock = QtAds.CDockWidget("Explorer", self)
- self.explorer_dock.setWidget(self.explorer)
+ self.available_devices_dock = QtAds.CDockWidget("Explorer", self)
+ self.available_devices_dock.setWidget(self.available_devices)
self.device_table_view_dock = QtAds.CDockWidget("Device Table", self)
self.device_table_view_dock.setWidget(self.device_table_view)
@@ -101,7 +104,9 @@ class DeviceManagerView(BECWidget, QWidget):
self.dm_config_view_dock.setWidget(self.dm_config_view)
# Add the dock widgets to the dock manager
- self.dock_manager.addDockWidget(QtAds.DockWidgetArea.LeftDockWidgetArea, self.explorer_dock)
+ self.dock_manager.addDockWidget(
+ QtAds.DockWidgetArea.LeftDockWidgetArea, self.available_devices_dock
+ )
monaco_yaml_area = self.dock_manager.addDockWidget(
QtAds.DockWidgetArea.RightDockWidgetArea, self.dm_config_view_dock
)
@@ -126,6 +131,9 @@ class DeviceManagerView(BECWidget, QWidget):
# Connect slots
self.device_table_view.selected_device.connect(self.dm_config_view.on_select_config)
+ self.device_table_view.model.devices_reset.connect(
+ self.available_devices.update_devices_state
+ )
####### Default view has to be done with setting up splitters ########
def set_default_view(self, horizontal_weights: list, vertical_weights: list):
diff --git a/bec_widgets/utils/expandable_frame.py b/bec_widgets/utils/expandable_frame.py
index 82db8bfc..138dac27 100644
--- a/bec_widgets/utils/expandable_frame.py
+++ b/bec_widgets/utils/expandable_frame.py
@@ -32,6 +32,7 @@ class ExpandableGroupFrame(QFrame):
super().__init__(parent=parent)
self._expanded = expanded
+ self._title_text = f"{title}"
self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Plain)
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
self._layout = QVBoxLayout()
@@ -50,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
@@ -113,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/list_of_expandable_frames.py b/bec_widgets/utils/list_of_expandable_frames.py
index a6d964ad..7a6048c6 100644
--- a/bec_widgets/utils/list_of_expandable_frames.py
+++ b/bec_widgets/utils/list_of_expandable_frames.py
@@ -1,11 +1,15 @@
+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 more_itertools import consume
from PySide6.QtWidgets import QListWidgetItem, QWidget
from qtpy.QtCore import QSize
from qtpy.QtWidgets import QListWidget
+from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.expandable_frame import ExpandableGroupFrame
logger = bec_logger.logger
@@ -54,29 +58,61 @@ class ListOfExpandableFrames(QListWidget, Generic[_EF]):
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)
- item.setSizeHint(item_widget.sizeHint())
self.setItemWidget(item, item_widget)
self.addItem(item)
self._item_dict[id] = self.item_tuple(item, item_widget)
+ item.setSizeHint(item_widget.sizeHint())
return item_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.widget.setHidden(True)
+ _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):
- map(lambda i: i.widget.setHidden(False), self._item_dict.values())
+ 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/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_resources.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py
new file mode 100644
index 00000000..cd652269
--- /dev/null
+++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py
@@ -0,0 +1,79 @@
+from random import randint
+from typing import Any, Callable, Generator, Iterable, TypeVar
+
+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.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,
+)
+
+_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
+
+
+class AvailableDeviceResources(BECWidget, QWidget, Ui_availableDeviceResources):
+ def __init__(self, parent=None, **kwargs):
+ super().__init__(parent=parent, **kwargs)
+ self.setupUi(self)
+ self._backend = get_backend()
+ self.refresh_full_list()
+ self.search_box.textChanged.connect(self.tag_groups_list.update_filter)
+
+ def refresh_full_list(self):
+ self.tag_groups_list.clear()
+ for tag_group, devices in self._backend.tag_groups.items():
+ self._add_tag_group(tag_group, devices)
+ self._add_tag_group("Untagged devices", self._backend.untagged_devices)
+
+ def _add_tag_group(self, tag_group: str, devices: set[HashableDevice]):
+ self.tag_groups_list.add_item(
+ tag_group, self.tag_groups_list, tag_group, devices, expanded=False
+ )
+
+ def _reset_devices_state(self):
+ for tag_group in self.tag_groups_list.widgets():
+ tag_group.reset_devices_state()
+
+ def set_devices_state(self, devices: Iterable[HashableDevice], included: bool):
+ for device in devices:
+ for tag_group in self.tag_groups_list.widgets():
+ tag_group.set_item_state(hash(device), included)
+
+ def resizeEvent(self, event):
+ super().resizeEvent(event)
+ for list_item, tag_group_widget in self.tag_groups_list.item_widget_pairs():
+ list_item.setSizeHint(tag_group_widget.sizeHint())
+
+ @SafeSlot(list)
+ def update_devices_state(self, config_list: list[dict[str, Any]]):
+ self.set_devices_state(
+ _yield_only_passing(HashableDevice.model_validate, config_list), True
+ )
+
+
+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..242c9d8c
--- /dev/null
+++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py
@@ -0,0 +1,40 @@
+from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit
+from qtpy.QtCore import QMetaObject, Qt
+from qtpy.QtWidgets import QAbstractItemView, QListView, QListWidget, QVBoxLayout
+
+from bec_widgets.utils.list_of_expandable_frames import ListOfExpandableFrames
+from bec_widgets.widgets.control.device_manager.components.available_device_resources.device_tag_group import (
+ DeviceTagGroup,
+)
+
+
+class Ui_availableDeviceResources(object):
+ def setupUi(self, availableDeviceResources):
+ if not availableDeviceResources.objectName():
+ availableDeviceResources.setObjectName("availableDeviceResources")
+ self.verticalLayout = QVBoxLayout(availableDeviceResources)
+ self.verticalLayout.setObjectName("verticalLayout")
+
+ self.search_layout = QHBoxLayout()
+ self.verticalLayout.addLayout(self.search_layout)
+ self.search_layout.addWidget(QLabel("Filter tags: "))
+ self.search_box = QLineEdit()
+ self.search_layout.addWidget(self.search_box)
+
+ self.tag_groups_list = ListOfExpandableFrames(availableDeviceResources, DeviceTagGroup)
+ self.tag_groups_list.setObjectName("tag_groups_list")
+ self.tag_groups_list.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
+ self.tag_groups_list.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
+ self.tag_groups_list.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
+ self.tag_groups_list.setMovement(QListView.Movement.Static)
+ self.tag_groups_list.setSpacing(2)
+ self.tag_groups_list.setDragDropMode(QListWidget.DragDropMode.DragOnly)
+ self.tag_groups_list.setDragEnabled(True)
+ self.tag_groups_list.setAcceptDrops(False)
+ self.tag_groups_list.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ availableDeviceResources.setMinimumWidth(250)
+ availableDeviceResources.resize(250, availableDeviceResources.height())
+
+ self.verticalLayout.addWidget(self.tag_groups_list)
+
+ QMetaObject.connectSlotsByName(availableDeviceResources)
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..12509d43
--- /dev/null
+++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_resource_backend.py
@@ -0,0 +1,111 @@
+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
+
+logger = bec_logger.logger
+
+_BASE_REPO_PATH = Path(os.path.dirname(bec_lib.__file__)) / "../.."
+
+
+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."""
+ ...
+
+ 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 _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() | self._get_configs_from_plugin_files(
+ Path(plugin_repo_path()) / plugin_package_name() / "device_configs/"
+ )
+ self._tag_groups = self._get_tag_groups()
+
+ def _get_config_from_backup_files(self):
+ dir = _BASE_REPO_PATH / "logs/device_configs/recovery_configs"
+ files = glob("*.yaml", root_dir=dir)
+ return reduce(
+ operator.or_,
+ map(partial(_devices_from_file, include_source=False), (str(dir / f) for f in files)),
+ )
+
+ 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)))
+
+ 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._tag_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()}
+
+ def tags(self) -> set[str]:
+ return reduce(operator.or_, (dev.deviceTags for dev in self._raw_device_set))
+
+ def tag_group(self, tag: str) -> set[HashableDevice]:
+ return self.tag_groups[tag]
+
+
+def get_backend() -> DeviceResourceBackend:
+ return _ConfigFileBackend()
diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_tag_group.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_tag_group.py
new file mode 100644
index 00000000..b04fcea8
--- /dev/null
+++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_tag_group.py
@@ -0,0 +1,202 @@
+from textwrap import dedent
+from typing import NamedTuple
+
+from bec_qthemes import material_icon
+from qtpy.QtCore import QSize
+from qtpy.QtWidgets import QFrame, QHBoxLayout, QLabel, QListWidgetItem, QVBoxLayout, QWidget
+
+from bec_widgets.utils.expandable_frame import ExpandableGroupFrame
+from bec_widgets.widgets.control.device_manager.components.available_device_resources.device_resource_backend import (
+ HashableDevice,
+)
+from bec_widgets.widgets.control.device_manager.components.available_device_resources.device_tag_group_ui import (
+ Ui_DeviceTagGroup,
+)
+
+DEVICE_HASH_ROLE = 101
+
+
+def _warning_string(spec: HashableDevice):
+ name_warning = (
+ f"Device defined with multiple names! Please check:\n {'\n '.join(spec.names)}\n"
+ if len(spec.names) > 1
+ else ""
+ )
+ source_warning = (
+ f"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.setFrameShape(QFrame.Shape.StyledPanel)
+ self.setFrameShadow(QFrame.Shadow.Raised)
+
+ self._layout = QVBoxLayout()
+ self._layout.setContentsMargins(0, 0, 0, 0)
+ 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} |