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