diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 8d93a579..26adf75f 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -50,6 +50,7 @@ _Widgets = { "ResumeButton": "ResumeButton", "RingProgressBar": "RingProgressBar", "ScanControl": "ScanControl", + "ScanMetadata": "ScanMetadata", "ScatterWaveform": "ScatterWaveform", "SignalComboBox": "SignalComboBox", "SignalLabel": "SignalLabel", @@ -3061,6 +3062,22 @@ class ScanControl(RPCBase): """ +class ScanMetadata(RPCBase): + @property + @rpc_call + def enabled(self): + """ + None + """ + + @enabled.setter + @rpc_call + def enabled(self): + """ + None + """ + + class ScatterCurve(RPCBase): """Scatter curve item for the scatter waveform widget.""" diff --git a/bec_widgets/utils/colors.py b/bec_widgets/utils/colors.py index 412bbc12..9aa40c3b 100644 --- a/bec_widgets/utils/colors.py +++ b/bec_widgets/utils/colors.py @@ -15,12 +15,15 @@ if TYPE_CHECKING: # pragma: no cover from bec_qthemes._main import AccentColors -def get_theme_palette(): +def get_theme_name(): if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"): - theme = "dark" + return "dark" else: - theme = QApplication.instance().theme.theme - return bec_qthemes.load_palette(theme) + return QApplication.instance().theme.theme + + +def get_theme_palette(): + return bec_qthemes.load_palette(get_theme_name()) def get_accent_colors() -> AccentColors | None: diff --git a/bec_widgets/utils/expandable_frame.py b/bec_widgets/utils/expandable_frame.py index b5fd590b..f8c0c965 100644 --- a/bec_widgets/utils/expandable_frame.py +++ b/bec_widgets/utils/expandable_frame.py @@ -1,7 +1,9 @@ from __future__ import annotations from bec_qthemes import material_icon +from qtpy.QtCore import Signal from qtpy.QtWidgets import ( + QApplication, QFrame, QHBoxLayout, QLabel, @@ -17,11 +19,13 @@ from bec_widgets.utils.error_popups import SafeProperty, SafeSlot class ExpandableGroupFrame(QFrame): + expansion_state_changed = Signal() + EXPANDED_ICON_NAME: str = "collapse_all" COLLAPSED_ICON_NAME: str = "expand_all" def __init__( - self, title: str, parent: QWidget | None = None, expanded: bool = True, icon: str = "" + self, parent: QWidget | None = None, title: str = "", expanded: bool = True, icon: str = "" ) -> None: super().__init__(parent=parent) self._expanded = expanded @@ -52,6 +56,7 @@ class ExpandableGroupFrame(QFrame): self._expansion_button.clicked.connect(self.switch_expanded_state) self.expanded = self._expanded # type: ignore + self.expansion_state_changed.emit() def set_layout(self, layout: QLayout) -> None: self._contents.setLayout(layout) @@ -61,6 +66,7 @@ class ExpandableGroupFrame(QFrame): def switch_expanded_state(self): self.expanded = not self.expanded # type: ignore self._update_expansion_icon() + self.expansion_state_changed.emit() @SafeProperty(bool) def expanded(self): # type: ignore @@ -71,6 +77,7 @@ class ExpandableGroupFrame(QFrame): self._expanded = expanded self._contents.setVisible(expanded) self.updateGeometry() + self.adjustSize() def _update_expansion_icon(self): self._expansion_button.setIcon( @@ -98,3 +105,18 @@ class ExpandableGroupFrame(QFrame): ) else: self._title_icon.setVisible(False) + + +# Application example +if __name__ == "__main__": # pragma: no cover + + app = QApplication([]) + frame = ExpandableGroupFrame() + layout = QVBoxLayout() + frame.set_layout(layout) + layout.addWidget(QLabel("test1")) + layout.addWidget(QLabel("test2")) + layout.addWidget(QLabel("test3")) + + frame.show() + app.exec() diff --git a/bec_widgets/utils/forms_from_types/forms.py b/bec_widgets/utils/forms_from_types/forms.py index 8f9fd868..c31344fa 100644 --- a/bec_widgets/utils/forms_from_types/forms.py +++ b/bec_widgets/utils/forms_from_types/forms.py @@ -44,6 +44,7 @@ class TypedForm(BECWidget, QWidget): items: list[tuple[str, type]] | None = None, form_item_specs: list[FormItemSpec] | None = None, enabled: bool = True, + pretty_display: bool = False, client=None, **kwargs, ): @@ -55,7 +56,8 @@ class TypedForm(BECWidget, QWidget): form_item_specs (list[FormItemSpec]): list of form item specs, equivalent to items. only one of items or form_item_specs should be supplied. - enabled (bool): whether fields are enabled for editing. + enabled (bool, optional): whether fields are enabled for editing. + pretty_display (bool, optional): Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display. """ if (items is not None and form_item_specs is not None) or ( items is None and form_item_specs is None @@ -66,7 +68,7 @@ class TypedForm(BECWidget, QWidget): form_item_specs if form_item_specs is not None else [ - FormItemSpec(name=name, item_type=item_type) + FormItemSpec(name=name, item_type=item_type, pretty_display=pretty_display) for name, item_type in items # type: ignore ] ) @@ -83,6 +85,7 @@ class TypedForm(BECWidget, QWidget): self._form_grid.setLayout(self._new_grid_layout()) self.populate() + self.enabled = self._enabled # type: ignore # QProperty def populate(self): self._clear_grid() @@ -139,10 +142,6 @@ class TypedForm(BECWidget, QWidget): new_grid.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize) return new_grid - def _set_widgets_enabled(self, enabled: bool): - for row in self.enumerate_form_widgets(): - row.widget.setEnabled(enabled) - @property def widget_dict(self): return { @@ -157,7 +156,7 @@ class TypedForm(BECWidget, QWidget): @enabled.setter def enabled(self, value: bool): self._enabled = value - self._set_widgets_enabled(value) + self.setEnabled(value) class PydanticModelForm(TypedForm): @@ -169,6 +168,7 @@ class PydanticModelForm(TypedForm): parent=None, data_model: type[BaseModel] | None = None, enabled: bool = True, + pretty_display: bool = False, client=None, **kwargs, ): @@ -178,7 +178,10 @@ class PydanticModelForm(TypedForm): Args: data_model (type[BaseModel]): the model class for which to generate a form. enabled (bool): whether fields are enabled for editing. + pretty_display (bool, optional): Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display. + """ + self._pretty_display = pretty_display self._md_schema = data_model super().__init__( parent=parent, form_item_specs=self._form_item_specs(), enabled=enabled, client=client @@ -214,7 +217,9 @@ class PydanticModelForm(TypedForm): def _form_item_specs(self): return [ - FormItemSpec(name=name, info=info, item_type=info.annotation) + FormItemSpec( + name=name, info=info, item_type=info.annotation, pretty_display=self._pretty_display + ) for name, info in self._md_schema.model_fields.items() ] diff --git a/bec_widgets/utils/forms_from_types/items.py b/bec_widgets/utils/forms_from_types/items.py index 1a58f749..c5807700 100644 --- a/bec_widgets/utils/forms_from_types/items.py +++ b/bec_widgets/utils/forms_from_types/items.py @@ -2,12 +2,12 @@ from __future__ import annotations from abc import abstractmethod from decimal import Decimal -from types import UnionType -from typing import Callable, Protocol +from types import GenericAlias, UnionType +from typing import Literal from bec_lib.logger import bec_logger from bec_qthemes import material_icon -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic.fields import FieldInfo from qtpy.QtCore import Signal # type: ignore from qtpy.QtWidgets import ( @@ -47,9 +47,36 @@ class FormItemSpec(BaseModel): """ model_config = ConfigDict(arbitrary_types_allowed=True) - item_type: type | UnionType + + item_type: type | UnionType | GenericAlias name: str info: FieldInfo = FieldInfo() + pretty_display: bool = Field( + default=False, + description="Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display.", + ) + + @field_validator("item_type", mode="before") + @classmethod + def _validate_type(cls, v): + allowed_primitives = [str, int, float, bool] + if isinstance(v, (type, UnionType)): + return v + if isinstance(v, GenericAlias): + if v.__origin__ in [list, dict] and all( + arg in allowed_primitives for arg in v.__args__ + ): + return v + raise ValueError( + f"Generics of type {v} are not supported - only lists and dicts of primitive types {allowed_primitives}" + ) + if type(v) is type(Literal[""]): # _LiteralGenericAlias is not exported from typing + arg_types = set(type(arg) for arg in v.__args__) + if len(arg_types) != 1: + raise ValueError("Mixtures of literal types are not supported!") + if (t := arg_types.pop()) in allowed_primitives: + return t + raise ValueError(f"Literals of type {t} are not supported") class ClearableBoolEntry(QWidget): @@ -102,6 +129,13 @@ class DynamicFormItem(QWidget): valueChanged = Signal() def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None: + """ + Initializes the form item widget. + + Args: + parent (QWidget | None, optional): The parent widget. Defaults to None. + spec (FormItemSpec): The specification for the form item. + """ super().__init__(parent) self._spec = spec self._layout = QHBoxLayout() @@ -111,8 +145,11 @@ class DynamicFormItem(QWidget): self._desc = self._spec.info.description self.setLayout(self._layout) self._add_main_widget() - if clearable_required(spec.info): - self._add_clear_button() + if not spec.pretty_display: + if clearable_required(spec.info): + self._add_clear_button() + else: + self._set_pretty_display() @abstractmethod def getValue(self) -> DynamicFormItemType: ... @@ -125,6 +162,9 @@ class DynamicFormItem(QWidget): """Add the main data entry widget to self._main_widget and appply any constraints from the field info""" + def _set_pretty_display(self): + self.setEnabled(False) + def _describe(self, pad=" "): return pad + (self._desc if self._desc else "") @@ -168,7 +208,7 @@ class StrMetadataField(DynamicFormItem): def setValue(self, value: str): if value is None: self._main_widget.setText("") - self._main_widget.setText(value) + self._main_widget.setText(str(value)) class IntMetadataField(DynamicFormItem): @@ -260,8 +300,12 @@ class DictMetadataField(DynamicFormItem): super().__init__(parent=parent, spec=spec) self._main_widget.data_changed.connect(self._value_changed) + def _set_pretty_display(self): + self._main_widget.set_button_visibility(False) + super()._set_pretty_display() + def _add_main_widget(self) -> None: - self._main_widget = DictBackedTable([]) + self._main_widget = DictBackedTable(self, []) self._layout.addWidget(self._main_widget) self._main_widget.setToolTip(self._describe("")) diff --git a/bec_widgets/utils/forms_from_types/styles.py b/bec_widgets/utils/forms_from_types/styles.py new file mode 100644 index 00000000..bc1f7426 --- /dev/null +++ b/bec_widgets/utils/forms_from_types/styles.py @@ -0,0 +1,22 @@ +import bec_qthemes + + +def pretty_display_theme(theme: str = "dark"): + print(f"loading theme {theme}") + palette = bec_qthemes.load_palette(theme) + foreground = palette.text().color().name() + background = palette.base().color().name() + border = palette.shadow().color().name() + accent = palette.accent().color().name() + return f""" +QWidget {{color: {foreground}; background-color: {background}}} +QLabel {{ font-weight: bold; }} +QLineEdit,QLabel,QTreeView {{ border-style: solid; border-width: 2px; border-color: {border} }} +QRadioButton {{ color: {foreground}; }} +QRadioButton::indicator::checked {{ color: {accent}; }} +QCheckBox {{ color: {accent}; }} +""" + + +if __name__ == "__main__": + print(pretty_display_theme()) diff --git a/bec_widgets/widgets/control/scan_control/scan_control.py b/bec_widgets/widgets/control/scan_control/scan_control.py index f1fefc83..23a4976e 100644 --- a/bec_widgets/widgets/control/scan_control/scan_control.py +++ b/bec_widgets/widgets/control/scan_control/scan_control.py @@ -89,6 +89,7 @@ class ScanControl(BECWidget, QWidget): self.config.allowed_scans = allowed_scans self._scan_metadata: dict | None = None + self._metadata_form = ScanMetadata(parent=parent) # Create and set main layout self._init_UI() @@ -165,7 +166,6 @@ class ScanControl(BECWidget, QWidget): self.layout.addStretch() def _add_metadata_form(self): - self._metadata_form = ScanMetadata(parent=self) self.layout.addWidget(self._metadata_form) self._metadata_form.update_with_new_scan(self.comboBox_scan_selection.currentText()) self.scan_selected.connect(self._metadata_form.update_with_new_scan) diff --git a/bec_widgets/widgets/editors/dict_backed_table.py b/bec_widgets/widgets/editors/dict_backed_table.py index a504b3d5..f4b1ea9c 100644 --- a/bec_widgets/widgets/editors/dict_backed_table.py +++ b/bec_widgets/widgets/editors/dict_backed_table.py @@ -121,14 +121,14 @@ class DictBackedTable(QWidget): delete_rows = Signal(list) data_changed = Signal(dict) - def __init__(self, initial_data: list[list[str]]): + def __init__(self, parent: QWidget | None = None, initial_data: list[list[str]] = []): """Widget which uses a DictBackedTableModel to display an editable table which can be extracted as a dict. Args: initial_data (list[list[str]]): list of key-value pairs to initialise with """ - super().__init__() + super().__init__(parent) self._layout = QHBoxLayout() self.setLayout(self._layout) @@ -141,8 +141,10 @@ class DictBackedTable(QWidget): self._table_view.setAlternatingRowColors(True) self._layout.addWidget(self._table_view) + self._button_holder = QWidget() self._buttons = QVBoxLayout() - self._layout.addLayout(self._buttons) + self._button_holder.setLayout(self._buttons) + self._layout.addWidget(self._button_holder) self._add_button = QPushButton("+") self._add_button.setToolTip("add a new row") self._remove_button = QPushButton("-") @@ -154,6 +156,9 @@ class DictBackedTable(QWidget): self.delete_rows.connect(self._table_model.delete_rows) self._table_model.dataChanged.connect(lambda *_: self.data_changed.emit(self.dump_dict())) + def set_button_visibility(self, value: bool): + self._button_holder.setVisible(value) + @SafeSlot() def clear(self): self._table_model.replaceData({}) @@ -186,6 +191,6 @@ if __name__ == "__main__": # pragma: no cover app = QApplication([]) set_theme("dark") - window = DictBackedTable([["key1", "value1"], ["key2", "value2"], ["key3", "value3"]]) + window = DictBackedTable(None, [["key1", "value1"], ["key2", "value2"], ["key3", "value3"]]) window.show() app.exec() diff --git a/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py b/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py index 5df8003e..2f620f1f 100644 --- a/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py +++ b/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py @@ -36,11 +36,13 @@ class ScanMetadata(PydanticModelForm): # self.populate() gets called in super().__init__ # so make sure self._additional_metadata exists - self._additional_md_box = ExpandableGroupFrame("Additional metadata", expanded=False) + self._additional_md_box = ExpandableGroupFrame( + parent, "Additional metadata", expanded=False + ) self._additional_md_box_layout = QHBoxLayout() self._additional_md_box.set_layout(self._additional_md_box_layout) - self._additional_metadata = DictBackedTable(initial_extras or []) + self._additional_metadata = DictBackedTable(parent, initial_extras or []) self._scan_name = scan_name or "" self._md_schema = get_metadata_schema_for_scan(self._scan_name) self._additional_metadata.data_changed.connect(self.validate_form) @@ -127,6 +129,7 @@ if __name__ == "__main__": # pragma: no cover w.setLayout(layout) scan_metadata = ScanMetadata( + parent=w, scan_name="grid_scan", initial_extras=[["key1", "value1"], ["key2", "value2"], ["key3", "value3"]], ) diff --git a/bec_widgets/widgets/services/device_browser/device_browser.py b/bec_widgets/widgets/services/device_browser/device_browser.py index 1a4cbedc..895c89cd 100644 --- a/bec_widgets/widgets/services/device_browser/device_browser.py +++ b/bec_widgets/widgets/services/device_browser/device_browser.py @@ -1,15 +1,23 @@ import os import re -from typing import Optional +from functools import partial from bec_lib.callback_handler import EventType +from bec_lib.logger import bec_logger +from bec_lib.messages import ConfigAction from pyqtgraph import SignalProxy -from qtpy.QtCore import Signal, Slot -from qtpy.QtWidgets import QListWidgetItem, QVBoxLayout, QWidget +from PySide6.QtCore import QSize +from qtpy.QtCore import Signal +from qtpy.QtWidgets import QListWidget, QListWidgetItem, 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.ui_loader import UILoader from bec_widgets.widgets.services.device_browser.device_item import DeviceItem +from bec_widgets.widgets.services.device_browser.util import map_device_type_to_icon + +logger = bec_logger.logger class DeviceBrowser(BECWidget, QWidget): @@ -23,18 +31,18 @@ class DeviceBrowser(BECWidget, QWidget): def __init__( self, - parent: Optional[QWidget] = None, + parent: QWidget | None = None, config=None, client=None, - gui_id: Optional[str] = None, + gui_id: str | None = None, **kwargs, ) -> None: super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs) - self.get_bec_shortcuts() self.ui = None self.ini_ui() - + self.dev_list: QListWidget = self.ui.device_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 ) @@ -43,6 +51,7 @@ class DeviceBrowser(BECWidget, QWidget): ) self.device_update.connect(self.update_device_list) + self.init_device_list() self.update_device_list() def ini_ui(self) -> None: @@ -50,14 +59,12 @@ class DeviceBrowser(BECWidget, QWidget): Initialize the UI by loading the UI file and setting the layout. """ layout = QVBoxLayout() - layout.setContentsMargins(0, 0, 0, 0) - ui_file_path = os.path.join(os.path.dirname(__file__), "device_browser.ui") self.ui = UILoader(self).loader(ui_file_path) layout.addWidget(self.ui) self.setLayout(layout) - def on_device_update(self, action: str, content: dict) -> None: + def on_device_update(self, action: ConfigAction, content: dict) -> None: """ Callback for device update events. Triggers the device_update signal. @@ -68,8 +75,43 @@ class DeviceBrowser(BECWidget, QWidget): if action in ["add", "remove", "reload"]: self.device_update.emit() - @Slot() - def update_device_list(self) -> None: + def init_device_list(self): + self.dev_list.clear() + self._device_items: dict[str, QListWidgetItem] = {} + + 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()}") + + with RPCRegister.delayed_broadcast(): + for device, device_obj in self.dev.items(): + item = QListWidgetItem(self.dev_list) + device_item = DeviceItem( + parent=self, device=device, icon=map_device_type_to_icon(device_obj) + ) + + device_item.expansion_state_changed.connect(partial(_updatesize, item, device_item)) + + device_config = self.dev[device]._config # pylint: disable=protected-access + device_item.set_display_config(device_config) + tooltip = 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() + def reset_device_list(self) -> None: + self.init_device_list() + self.update_device_list() + + @SafeSlot() + @SafeSlot(str) + def update_device_list(self, *_) -> None: """ Update the device list based on the filter input. There are two ways to trigger this function: @@ -80,23 +122,14 @@ class DeviceBrowser(BECWidget, QWidget): """ filter_text = self.ui.filter_input.text() try: - regex = re.compile(filter_text, re.IGNORECASE) + self.regex = re.compile(filter_text, re.IGNORECASE) except re.error: - regex = None # Invalid regex, disable filtering - - dev_list = self.ui.device_list - dev_list.clear() + self.regex = None # Invalid regex, disable filtering + for device in self.dev: + self._device_items[device].setHidden(False) + return for device in self.dev: - if regex is None or regex.search(device): - item = QListWidgetItem(dev_list) - device_item = DeviceItem(device) - - # pylint: disable=protected-access - tooltip = self.dev[device]._config.get("description", "") - device_item.setToolTip(tooltip) - item.setSizeHint(device_item.sizeHint()) - dev_list.setItemWidget(item, device_item) - dev_list.addItem(item) + self._device_items[device].setHidden(not self.regex.search(device)) if __name__ == "__main__": # pragma: no cover @@ -104,10 +137,10 @@ if __name__ == "__main__": # pragma: no cover from qtpy.QtWidgets import QApplication - from bec_widgets.utils.colors import apply_theme + from bec_widgets.utils.colors import set_theme app = QApplication(sys.argv) - apply_theme("light") + set_theme("light") widget = DeviceBrowser() widget.show() sys.exit(app.exec_()) 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 4d0f4186..7091975c 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 @@ -2,10 +2,18 @@ from __future__ import annotations from typing import TYPE_CHECKING +from bec_lib.atlas_models import Device as DeviceConfigModel from bec_lib.logger import bec_logger -from qtpy.QtCore import QMimeData, Qt +from qtpy.QtCore import QMimeData, QSize, Qt, Signal from qtpy.QtGui import QDrag -from qtpy.QtWidgets import QApplication, QHBoxLayout, QLabel, QWidget +from qtpy.QtWidgets import QApplication, QHBoxLayout, QWidget + +from bec_widgets.utils.colors import get_theme_name +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.expandable_frame import ExpandableGroupFrame +from bec_widgets.utils.forms_from_types import styles +from bec_widgets.utils.forms_from_types.forms import PydanticModelForm +from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton if TYPE_CHECKING: # pragma: no cover from qtpy.QtGui import QMouseEvent @@ -13,26 +21,75 @@ if TYPE_CHECKING: # pragma: no cover logger = bec_logger.logger -class DeviceItem(QWidget): - def __init__(self, device: str) -> None: - super().__init__() +class DeviceItemForm(PydanticModelForm): + RPC = False + PLUGIN = False + + def __init__(self, parent=None, client=None, pretty_display=False, **kwargs): + super().__init__( + parent=parent, + data_model=DeviceConfigModel, + pretty_display=pretty_display, + client=client, + **kwargs, + ) + self._validity.setVisible(False) + self._connect_to_theme_change() + + def set_pretty_display_theme(self, theme: str | None = None): + if theme is None: + theme = get_theme_name() + self.setStyleSheet(styles.pretty_display_theme(theme)) + + def _connect_to_theme_change(self): + """Connect to the theme change signal.""" + qapp = QApplication.instance() + if hasattr(qapp, "theme_signal"): + qapp.theme_signal.theme_updated.connect(self.set_pretty_display_theme) # type: ignore + + +class DeviceItem(ExpandableGroupFrame): + broadcast_size_hint = Signal(QSize) + + RPC = False + + def __init__(self, parent, device: str, icon: str = "") -> None: + super().__init__(parent, title=device, expanded=False, icon=icon) self._drag_pos = None - + self._expanded_first_time = False + self._data = None self.device = device layout = QHBoxLayout() - layout.setContentsMargins(10, 2, 10, 2) - self.label = QLabel(device) - layout.addWidget(self.label) - self.setLayout(layout) + layout.setContentsMargins(0, 0, 0, 0) + self.set_layout(layout) - self.setStyleSheet( - """ - border: 1px solid #ddd; - border-radius: 5px; - padding: 10px; - """ - ) + self.adjustSize() + + @SafeSlot() + def switch_expanded_state(self): + if not self.expanded and not self._expanded_first_time: + self._expanded_first_time = True + self.form = DeviceItemForm(parent=self, pretty_display=True) + self._contents.layout().addWidget(self.form) + if self._data: + self.form.set_data(self._data) + self.broadcast_size_hint.emit(self.sizeHint()) + super().switch_expanded_state() + if self._expanded_first_time: + self.form.adjustSize() + self.updateGeometry() + if self._expanded: + self.form.set_pretty_display_theme() + self.adjustSize() + self.broadcast_size_hint.emit(self.sizeHint()) + + def set_display_config(self, config_dict: dict): + """Set the displayed information from a device config dict, which must conform to the + bec_lib.atlas_models.Device config model.""" + self._data = DeviceConfigModel.model_validate(config_dict) + if self._expanded_first_time: + self.form.set_data(self._data) def mousePressEvent(self, event: QMouseEvent) -> None: super().mousePressEvent(event) @@ -63,6 +120,25 @@ if __name__ == "__main__": # pragma: no cover from qtpy.QtWidgets import QApplication app = QApplication(sys.argv) - widget = DeviceItem("Device") + widget = QWidget() + layout = QHBoxLayout() + widget.setLayout(layout) + item = DeviceItem("Device") + layout.addWidget(DarkModeButton()) + layout.addWidget(item) + item.set_display_config( + { + "name": "Test Device", + "enabled": True, + "deviceClass": "FakeDeviceClass", + "deviceConfig": {"kwarg1": "value1"}, + "readoutPriority": "baseline", + "description": "A device for testing out a widget", + "readOnly": True, + "softwareTrigger": False, + "deviceTags": ["tag1", "tag2", "tag3"], + "userParameter": {"some_setting": "some_ value"}, + } + ) widget.show() sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/services/device_browser/util.py b/bec_widgets/widgets/services/device_browser/util.py new file mode 100644 index 00000000..d04c4587 --- /dev/null +++ b/bec_widgets/widgets/services/device_browser/util.py @@ -0,0 +1,11 @@ +from bec_lib.device import Device + + +def map_device_type_to_icon(device_obj: Device) -> str: + """Associate device types with material icon names""" + match device_obj._info.get("device_base_class", "").lower(): + case "positioner": + return "precision_manufacturing" + case "signal": + return "vital_signs" + return "deployed_code" diff --git a/tests/unit_tests/test_device_browser.py b/tests/unit_tests/test_device_browser.py index b530aa7d..2911f809 100644 --- a/tests/unit_tests/test_device_browser.py +++ b/tests/unit_tests/test_device_browser.py @@ -5,6 +5,7 @@ import pytest from qtpy.QtCore import QPoint, Qt from bec_widgets.widgets.services.device_browser.device_browser import DeviceBrowser +from bec_widgets.widgets.services.device_browser.device_item.device_item import DeviceItemForm from .client_mocks import mocked_client @@ -36,25 +37,24 @@ def test_device_browser_init_with_devices(device_browser): assert device_list.count() == len(device_browser.dev) -def test_device_browser_filtering(qtbot, device_browser): +@pytest.mark.parametrize( + ["search_term", "expected_num_visible"], + [("sam", 3), ("nonexistent", 0), ("", -1), (r"(\)", -1)], +) +def test_device_browser_filtering( + qtbot, device_browser, search_term: str, expected_num_visible: int +): """ Test that the device browser is able to filter the device list. """ + 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()))) - device_browser.ui.filter_input.setText("sam") - qtbot.wait(1000) - assert num_visible(device_browser._device_items) == 3 - - device_browser.ui.filter_input.setText("nonexistent") - qtbot.wait(1000) - assert num_visible(device_browser._device_items) == 0 - - device_browser.ui.filter_input.setText("") - qtbot.wait(1000) - assert num_visible(device_browser._device_items) == len(device_browser.dev) + device_browser.ui.filter_input.setText(search_term) + qtbot.wait(100) + assert num_visible(device_browser._device_items) == expected def test_device_item_mouse_press_event(device_browser, qtbot): @@ -67,6 +67,30 @@ def test_device_item_mouse_press_event(device_browser, qtbot): qtbot.mouseClick(widget._title, Qt.MouseButton.LeftButton) +def test_update_event_captured(device_browser, qtbot): + device_browser.update_device_list = mock.MagicMock() + device_browser.update_device_list.assert_not_called() + device_browser.on_device_update("remove", {}) + device_browser.update_device_list.assert_called_once() + device_browser.on_device_update("", {}) + + +def test_device_item_expansion(device_browser, qtbot): + """ + Test that the form is displayed when the item is expanded + """ + device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0) + widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item) + qtbot.mouseClick(widget._expansion_button, Qt.MouseButton.LeftButton) + form = widget._contents.layout().itemAt(0).widget() + qtbot.waitUntil(lambda: isinstance(form, DeviceItemForm), timeout=500) + assert widget.expanded + assert (name_field := form.widget_dict.get("name")) is not None + assert name_field.getValue() == "samx" + qtbot.mouseClick(widget._expansion_button, Qt.MouseButton.LeftButton) + assert not widget.expanded + + def test_device_item_mouse_press_and_move_events_creates_drag(device_browser, qtbot): """ Test that the mousePressEvent is triggered correctly and initiates a drag. diff --git a/tests/unit_tests/test_generated_form_items.py b/tests/unit_tests/test_generated_form_items.py new file mode 100644 index 00000000..4eac06c0 --- /dev/null +++ b/tests/unit_tests/test_generated_form_items.py @@ -0,0 +1,60 @@ +import sys +from typing import Literal + +import pytest +from pydantic import ValidationError +from pydantic.fields import FieldInfo + +from bec_widgets.utils.forms_from_types.items import FormItemSpec + + +@pytest.mark.skipif(sys.version_info < (3, 11), reason="Generic types don't support this in 3.10") +@pytest.mark.parametrize( + ["input", "validity"], + [ + ({}, False), + ({"item_type": int, "name": "test", "info": FieldInfo(), "pretty_display": True}, True), + ( + { + "item_type": dict[dict, dict], + "name": "test", + "info": FieldInfo(), + "pretty_display": True, + }, + False, + ), + ( + { + "item_type": dict[str, str], + "name": "test", + "info": FieldInfo(), + "pretty_display": True, + }, + True, + ), + ( + { + "item_type": Literal["a", "b"], + "name": "test", + "info": FieldInfo(), + "pretty_display": True, + }, + True, + ), + ( + { + "item_type": Literal["a", 2], + "name": "test", + "info": FieldInfo(), + "pretty_display": True, + }, + False, + ), + ], +) +def test_form_item_spec(input, validity): + if validity: + assert FormItemSpec.model_validate(input) + else: + with pytest.raises(ValidationError): + FormItemSpec.model_validate(input) diff --git a/tests/unit_tests/test_scan_metadata.py b/tests/unit_tests/test_scan_metadata.py index a563b76a..c7e19646 100644 --- a/tests/unit_tests/test_scan_metadata.py +++ b/tests/unit_tests/test_scan_metadata.py @@ -183,7 +183,9 @@ def test_numbers_clipped_to_limits( @pytest.fixture def table(): - table = DictBackedTable([["key1", "value1"], ["key2", "value2"], ["key3", "value3"]]) + table = DictBackedTable( + initial_data=[["key1", "value1"], ["key2", "value2"], ["key3", "value3"]] + ) yield table table._table_model.deleteLater() table._table_view.deleteLater()