From c06eb36701e52cf8a7ed471ca0d7df5369d9f783 Mon Sep 17 00:00:00 2001 From: David Perl Date: Mon, 18 Aug 2025 14:55:18 +0200 Subject: [PATCH] feat: add available devices widget --- .../device_manager_view.py | 16 +- bec_widgets/utils/expandable_frame.py | 27 ++- .../utils/list_of_expandable_frames.py | 44 +++- .../available_device_resources/__init__.py | 3 + .../available_device_resources.py | 79 +++++++ .../available_device_resources_ui.py | 40 ++++ .../device_resource_backend.py | 111 ++++++++++ .../device_tag_group.py | 202 ++++++++++++++++++ .../device_tag_group_ui.py | 65 ++++++ .../components/device_table_view.py | 4 + .../services/device_browser/device_browser.py | 9 +- .../test_available_device_resources.py | 78 +++++++ 12 files changed, 658 insertions(+), 20 deletions(-) create mode 100644 bec_widgets/widgets/control/device_manager/components/available_device_resources/__init__.py create mode 100644 bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py create mode 100644 bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py create mode 100644 bec_widgets/widgets/control/device_manager/components/available_device_resources/device_resource_backend.py create mode 100644 bec_widgets/widgets/control/device_manager/components/available_device_resources/device_tag_group.py create mode 100644 bec_widgets/widgets/control/device_manager/components/available_device_resources/device_tag_group_ui.py create mode 100644 tests/unit_tests/test_available_device_resources.py 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}
+ """ + ) + + 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 DeviceTagGroup(ExpandableGroupFrame, Ui_DeviceTagGroup): + def __init__( + self, parent=None, name: str = "TagGroupTitle", data: set[HashableDevice] = set(), **kwargs + ): + super().__init__(parent=parent, **kwargs) + self.setupUi(self) + self.title_text = name # type: ignore + 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() + + self.add_to_composition_button.clicked.connect(self.test) + + def _add_item(self, device: HashableDevice): + item = QListWidgetItem(self.device_list) + 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 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, + ) + + 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 test(self, *args): + print(self.get_selection()) + + 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 = DeviceTagGroup(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/device_tag_group_ui.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_tag_group_ui.py new file mode 100644 index 00000000..378467bc --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_tag_group_ui.py @@ -0,0 +1,65 @@ +from functools import partial + +from bec_qthemes import material_icon +from PySide6.QtWidgets import QFrame +from qtpy.QtCore import QMetaObject +from qtpy.QtWidgets import QLabel, QListWidget, QToolButton, QVBoxLayout + + +class Ui_DeviceTagGroup(object): + def setupUi(self, DeviceTagGroup): + if not DeviceTagGroup.objectName(): + DeviceTagGroup.setObjectName("DeviceTagGroup") + DeviceTagGroup.setMinimumWidth(150) + self.verticalLayout = QVBoxLayout() + self.verticalLayout.setObjectName("verticalLayout") + DeviceTagGroup.set_layout(self.verticalLayout) + + title_layout = DeviceTagGroup.get_title_layout() + + self.n_included = QLabel(DeviceTagGroup, text="...") + self.n_included.setObjectName("n_included") + title_layout.addWidget(self.n_included) + + self.delete_tag_button = QToolButton(DeviceTagGroup) + self.delete_tag_button.setObjectName("delete_tag_button") + title_layout.addWidget(self.delete_tag_button) + + self.remove_from_composition_button = QToolButton(DeviceTagGroup) + self.remove_from_composition_button.setObjectName("remove_from_composition_button") + title_layout.addWidget(self.remove_from_composition_button) + + self.add_to_composition_button = QToolButton(DeviceTagGroup) + self.add_to_composition_button.setObjectName("add_to_composition_button") + title_layout.addWidget(self.add_to_composition_button) + + self.remove_all_button = QToolButton(DeviceTagGroup) + self.remove_all_button.setObjectName("remove_all_from_composition_button") + title_layout.addWidget(self.remove_all_button) + + self.add_all_button = QToolButton(DeviceTagGroup) + self.add_all_button.setObjectName("add_all_to_composition_button") + title_layout.addWidget(self.add_all_button) + + self.device_list = QListWidget(DeviceTagGroup) + self.device_list.setObjectName("device_list") + self.device_list.setFrameStyle(0) + + self.verticalLayout.addWidget(self.device_list) + + self.set_icons() + + QMetaObject.connectSlotsByName(DeviceTagGroup) + + def set_icons(self): + icon = partial(material_icon, size=(15, 15), convert_to_pixmap=False) + self.delete_tag_button.setIcon(icon("delete")) + self.delete_tag_button.setToolTip("Delete tag group") + self.remove_from_composition_button.setIcon(icon("remove")) + self.remove_from_composition_button.setToolTip("Remove selected from composition") + self.add_to_composition_button.setIcon(icon("add")) + self.add_to_composition_button.setToolTip("Add selected to composition") + self.remove_all_button.setIcon(icon("chips")) + self.remove_all_button.setToolTip("Remove all with this tag from composition") + self.add_all_button.setIcon(icon("add_box")) + self.add_all_button.setToolTip("Add all with this tag to composition") 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 40f31cec..cfa1c082 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 @@ -115,6 +115,9 @@ class DeviceTableModel(QtCore.QAbstractTableModel): Sort logic is implemented directly on the data of the table view. """ + device_added = QtCore.Signal(dict) + devices_reset = QtCore.Signal(list) + def __init__(self, device_config: list[dict] | None = None, parent=None): super().__init__(parent) self._device_config = device_config or [] @@ -250,6 +253,7 @@ class DeviceTableModel(QtCore.QAbstractTableModel): self.beginResetModel() self._device_config = list(device_config) self.endResetModel() + self.devices_reset.emit(self._device_config) @SafeSlot(dict) def add_device(self, device: dict): diff --git a/bec_widgets/widgets/services/device_browser/device_browser.py b/bec_widgets/widgets/services/device_browser/device_browser.py index f852949d..3bf106ac 100644 --- a/bec_widgets/widgets/services/device_browser/device_browser.py +++ b/bec_widgets/widgets/services/device_browser/device_browser.py @@ -176,18 +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.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 - self.dev_list.unhide_all() - return - self.dev_list.set_hidden(filter(lambda d: not self.regex.search(d), self.dev.keys())) + self.dev_list.update_filter(self.ui.filter_input.text()) @SafeSlot() def _load_from_file(self): diff --git a/tests/unit_tests/test_available_device_resources.py b/tests/unit_tests/test_available_device_resources.py new file mode 100644 index 00000000..23b644f7 --- /dev/null +++ b/tests/unit_tests/test_available_device_resources.py @@ -0,0 +1,78 @@ +from copy import copy + +import pytest + +from bec_widgets.widgets.control.device_manager.components.available_device_resources.device_resource_backend import ( + HashableDevice, + _HashableDeviceSet, +) + +TEST_DEVICE_DICT = { + "name": "test_device", + "deviceClass": "TestDeviceClass", + "readoutPriority": "baseline", + "enabled": True, +} + + +def _test_device_dict(**kwargs): + new = copy(TEST_DEVICE_DICT) + new.update(kwargs) + return new + + +@pytest.mark.parametrize( + "kwargs_1, kwargs_2, kwargs_3, kwargs_4, n", + [ + ({}, {}, {}, {}, 1), + ({}, {}, {}, {"deviceConfig": {"a": 1}}, 1), + ({}, {}, {}, {"name": "test_device_2"}, 2), + ({}, {}, {"name": "test_device_2"}, {"deviceClass": "OtherDeviceClass"}, 3), + ], +) +def test_hashable_device_set_merges_equal(kwargs_1, kwargs_2, kwargs_3, kwargs_4, n): + item_1 = HashableDevice(**_test_device_dict(**kwargs_1)) + item_2 = HashableDevice(**_test_device_dict(**kwargs_2)) + item_3 = HashableDevice(**_test_device_dict(**kwargs_3)) + item_4 = HashableDevice(**_test_device_dict(**kwargs_4)) + + test_set = _HashableDeviceSet((item_1, item_2, item_3, item_4)) + assert len(test_set) == n + + +def test_hashable_device_set_or_adds_sources(): + item_1 = HashableDevice(**_test_device_dict(), source_files={"a", "b"}) + item_2 = HashableDevice(**_test_device_dict(), source_files={"c", "d"}) + + set_1 = _HashableDeviceSet((item_1,)) + set_2 = _HashableDeviceSet((item_2,)) + + combined = set_1 | set_2 + assert len(combined) == 1 + assert combined.pop().source_files == {"a", "b", "c", "d"} + + +def test_hashable_device_set_or_adds_tags(): + item_1 = HashableDevice( + **_test_device_dict(deviceTags={"tag1"}, deviceConfig={"param": "value"}), + source_files={"a", "b"}, + ) + item_2 = HashableDevice( + **_test_device_dict(deviceTags={"tag2"}, deviceConfig={"param": "value"}), + source_files={"c", "d"}, + ) + item_3 = HashableDevice( + **_test_device_dict(deviceTags={"tag3"}, deviceConfig={"param": "other_value"}), + source_files={"q"}, + ) + + set_1 = _HashableDeviceSet((item_1,)) + set_2 = _HashableDeviceSet((item_2,)) + set_3 = _HashableDeviceSet((item_3,)) + + combined = sorted(set_1 | set_2 | set_3, key=lambda hd: hd.deviceConfig["param"]) + assert len(combined) == 2 + assert combined[0].source_files == {"q"} + assert combined[0].deviceTags == {"tag3"} + assert combined[1].source_files == {"a", "b", "c", "d"} + assert combined[1].deviceTags == {"tag1", "tag2"}