diff --git a/bec_widgets/utils/expandable_frame.py b/bec_widgets/utils/expandable_frame.py
index 9f65500e..138dac27 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,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()
@@ -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/list_of_expandable_frames.py b/bec_widgets/utils/list_of_expandable_frames.py
new file mode 100644
index 00000000..1eae99a0
--- /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.available_device_resources._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/services/device_browser/device_browser.py b/bec_widgets/widgets/services/device_browser/device_browser.py
index fbe6fe7d..3bf106ac 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):
@@ -198,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/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):