diff --git a/bec_widgets/utils/expandable_frame.py b/bec_widgets/utils/expandable_frame.py index 9f65500e..82db8bfc 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" 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..a6d964ad --- /dev/null +++ b/bec_widgets/utils/list_of_expandable_frames.py @@ -0,0 +1,82 @@ +from functools import partial +from typing import Generic, Iterable, NamedTuple, TypeVar + +from bec_lib.logger import bec_logger +from PySide6.QtWidgets import QListWidgetItem, QWidget +from qtpy.QtCore import QSize +from qtpy.QtWidgets import QListWidget + +from bec_widgets.utils.expandable_frame import ExpandableGroupFrame + +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) -> _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 = QListWidgetItem(self) + 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) + item.setSizeHint(item_widget.sizeHint()) + + self.setItemWidget(item, item_widget) + self.addItem(item) + self._item_dict[id] = self.item_tuple(item, item_widget) + + return item_widget + + def get_item_widget(self, id: str): + if (item := self._item_dict.get(id)) is None: + return None + return item + + def set_hidden(self, ids: Iterable[str]): + for id in ids: + if (_item := self._item_dict.get(id)) is not None: + _item.widget.setHidden(True) + else: + logger.warning( + f"List {self.__qualname__} does not have an item with ID {id} to hide!" + ) + + def unhide_all(self): + map(lambda i: i.widget.setHidden(False), self._item_dict.values()) diff --git a/bec_widgets/widgets/services/device_browser/device_browser.py b/bec_widgets/widgets/services/device_browser/device_browser.py index fbe6fe7d..f852949d 100644 --- a/bec_widgets/widgets/services/device_browser/device_browser.py +++ b/bec_widgets/widgets/services/device_browser/device_browser.py @@ -11,19 +11,13 @@ 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 ( @@ -59,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 @@ -132,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, @@ -158,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,18 +178,16 @@ class DeviceBrowser(BECWidget, QWidget): """ 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) + self.dev_list.unhide_all() return - for device in self.dev: - self._device_items[device].setHidden(not self.regex.search(device)) + self.dev_list.set_hidden(filter(lambda d: not self.regex.search(d), self.dev.keys())) @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/device_item.py b/bec_widgets/widgets/services/device_browser/device_item/device_item.py index def709eb..f24e2fd2 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 @@ -35,9 +35,6 @@ logger = bec_logger.logger class DeviceItem(ExpandableGroupFrame): - broadcast_size_hint = Signal(QSize) - imminent_deletion = Signal() - RPC = False def __init__( diff --git a/tests/unit_tests/test_device_browser.py b/tests/unit_tests/test_device_browser.py index 3ef97af8..2ccb2585 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) @@ -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):