From be73349c706582c144813f70dbc477372057de86 Mon Sep 17 00:00:00 2001 From: David Perl Date: Fri, 13 Jun 2025 12:37:33 +0200 Subject: [PATCH] feat: add set form item --- bec_widgets/tests/utils.py | 4 +- bec_widgets/utils/forms_from_types/forms.py | 6 +- bec_widgets/utils/forms_from_types/items.py | 77 +++++++++++++++---- .../device_browser/device_item/device_item.py | 2 +- .../test_device_config_form_dialog.py | 2 +- tests/unit_tests/test_generated_form_items.py | 2 +- 6 files changed, 71 insertions(+), 22 deletions(-) diff --git a/bec_widgets/tests/utils.py b/bec_widgets/tests/utils.py index 50874fcf..07922ee3 100644 --- a/bec_widgets/tests/utils.py +++ b/bec_widgets/tests/utils.py @@ -19,7 +19,7 @@ class FakeDevice(BECDevice): "readoutPriority": "baseline", "deviceClass": "ophyd.Device", "deviceConfig": {}, - "deviceTags": ["user device"], + "deviceTags": {"user device"}, "enabled": enabled, "readOnly": False, "name": self.name, @@ -89,7 +89,7 @@ class FakePositioner(BECPositioner): "readoutPriority": "baseline", "deviceClass": "ophyd_devices.SimPositioner", "deviceConfig": {"delay": 1, "tolerance": 0.01, "update_frequency": 400}, - "deviceTags": ["user motors"], + "deviceTags": {"user motors"}, "enabled": enabled, "readOnly": False, "name": self.name, diff --git a/bec_widgets/utils/forms_from_types/forms.py b/bec_widgets/utils/forms_from_types/forms.py index f8d07492..04384d74 100644 --- a/bec_widgets/utils/forms_from_types/forms.py +++ b/bec_widgets/utils/forms_from_types/forms.py @@ -102,6 +102,8 @@ class TypedForm(BECWidget, QWidget): self._clear_grid() for r, item in enumerate(self._items): self._add_griditem(item, r) + gl: QGridLayout = self._form_grid.layout() + gl.setRowStretch(gl.rowCount(), 1) def _add_griditem(self, item: FormItemSpec, row: int): grid = self._form_grid.layout() @@ -116,9 +118,9 @@ class TypedForm(BECWidget, QWidget): def enumerate_form_widgets(self): """Return a generator over the rows of the form, with the row number, the label widget (to - which the field name is attached as a property), and the entry widget""" + which the field name is attached as a property "_model_field_name"), and the entry widget""" grid: QGridLayout = self._form_grid.layout() # type: ignore - for i in range(grid.rowCount()): + for i in range(grid.rowCount() - 1): # One extra row for stretch yield GridRow(i, grid.itemAtPosition(i, 0).widget(), grid.itemAtPosition(i, 1).widget()) def _dict_from_grid(self) -> dict[str, DynamicFormItemType]: diff --git a/bec_widgets/utils/forms_from_types/items.py b/bec_widgets/utils/forms_from_types/items.py index babd17fa..f3fcd74d 100644 --- a/bec_widgets/utils/forms_from_types/items.py +++ b/bec_widgets/utils/forms_from_types/items.py @@ -4,13 +4,14 @@ import typing from abc import abstractmethod from decimal import Decimal from types import GenericAlias, UnionType -from typing import Callable, Final, Generic, Literal, NamedTuple, OrderedDict, TypeVar, get_args +from typing import Callable, Final, Iterable, Literal, NamedTuple, OrderedDict, get_args from bec_lib.logger import bec_logger from bec_qthemes import material_icon from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic.fields import FieldInfo from pydantic_core import PydanticUndefined +from PySide6 import QtCore from PySide6.QtWidgets import QComboBox from qtpy.QtCore import Signal # type: ignore from qtpy.QtWidgets import ( @@ -348,14 +349,12 @@ class DictFormItem(DynamicFormItem): class _ItemAndWidgetType(NamedTuple): # TODO: this should be generic but not supported in 3.10 item: type[int | float | str] - widget: type + widget: type[QWidget] default: int | float | str class ListFormItem(DynamicFormItem): def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None: - super().__init__(parent=parent, spec=spec) - self._main_widget: QListWidget if spec.info.annotation is list: self._types = _ItemAndWidgetType(str, QLineEdit, "") elif isinstance(spec.info.annotation, GenericAlias): @@ -367,7 +366,9 @@ class ListFormItem(DynamicFormItem): if args == {float} or args == {int, float}: self._types = _ItemAndWidgetType(float, QDoubleSpinBox, 0.0) else: - self._types = _ItemAndWidgetType(str, QLineEdit, 0) + self._types = _ItemAndWidgetType(str, QLineEdit, "") + super().__init__(parent=parent, spec=spec) + self._main_widget: QListWidget self._data = [] def _add_main_widget(self) -> None: @@ -396,24 +397,30 @@ class ListFormItem(DynamicFormItem): def _repop(self, data): self._main_widget.clear() for val in data: - self._add_item(val) + self._add_list_item(val) - def _add_item(self, val=None): + def _add_data_item(self, val=None): val = val or self._types.default self._data.append(val) + self._add_list_item(val) + + def _add_list_item(self, val): item = QListWidgetItem(self._main_widget) + item.setFlags(item.flags() | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsEditable) item_widget = self._types.widget(parent=self) WidgetIO.set_value(item_widget, val) self._main_widget.setItemWidget(item, item_widget) self._main_widget.addItem(item) WidgetIO.connect_widget_change_signal(item_widget, self._update) + return item_widget def _update(self, _, value, *args): self._data[self._main_widget.currentRow()] = value @SafeSlot() def _add_row(self): - self._add_item(0) + self._add_data_item(self._types.default) + self._repop(self._data) @SafeSlot() def _delete_row(self): @@ -422,6 +429,7 @@ class ListFormItem(DynamicFormItem): row = self._main_widget.currentRow() self._main_widget.takeItem(row) self._data.pop(row) + self._repop(self._data) @SafeSlot() def clear(self): @@ -430,10 +438,11 @@ class ListFormItem(DynamicFormItem): def getValue(self): return self._data - def setValue(self, value: list): + def setValue(self, value: Iterable): if set(map(type, value)) | {self._types.item} != {self._types.item}: raise ValueError(f"This widget only accepts items of type {self._types.item}") - self._repop(value) + self._data = list(value) + self._repop(self._data) class StrLiteralFormItem(DynamicFormItem): @@ -460,6 +469,44 @@ class StrLiteralFormItem(DynamicFormItem): self._main_widget.setCurrentIndex(-1) +class SetFormItem(ListFormItem): + + def _add_main_widget(self) -> None: + super()._add_main_widget() + self._add_item_field = self._types.widget() + self._buttons.addWidget(QLabel("Add new:")) + self._buttons.addWidget(self._add_item_field) + + @SafeSlot() + def _add_row(self): + self._add_data_item(WidgetIO.get_value(self._add_item_field)) + self._repop(self._data) + + def _update(self, _, value, *args): + if value in self._data: + return + return super()._update(_, value, *args) + + def _add_data_item(self, val=None): + val = val or self._types.default + if val == self._types.default or val in self._data: + return + self._data.append(val) + self._add_list_item(val) + + def _add_list_item(self, val): + item_widget = super()._add_list_item(val) + if isinstance(item_widget, QLineEdit): + item_widget.setReadOnly(True) + return item_widget + + def getValue(self): + return set(self._data) + + def setValue(self, value: set): + return super().setValue(set(self._data)) + + WidgetTypeRegistry = OrderedDict[str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem]]] DEFAULT_WIDGET_TYPES: Final[WidgetTypeRegistry] = OrderedDict() | { @@ -511,6 +558,7 @@ def widget_from_type( if __name__ == "__main__": # pragma: no cover class TestModel(BaseModel): + value0: set = Field(set(["a", "b"])) value1: str | None = Field(None) value2: bool | None = Field(None) value3: bool = Field(True) @@ -525,15 +573,14 @@ if __name__ == "__main__": # pragma: no cover w.setLayout(layout) items = [] for i, (field_name, info) in enumerate(TestModel.model_fields.items()): + spec = spec = FormItemSpec(item_type=info.annotation, name=field_name, info=info) layout.addWidget(QLabel(field_name), i, 0) - widg = widget_from_type(info.annotation)( - spec=FormItemSpec(item_type=info.annotation, name=field_name, info=info) - ) + widg = widget_from_type(spec)(spec=spec) items.append(widg) layout.addWidget(widg, i, 1) - items[5].setValue([1, 2, 3, 4]) - items[6].setValue(["1", "2", "asdfg", "qwerty"]) + items[6].setValue([1, 2, 3, 4]) + items[7].setValue(["1", "2", "asdfg", "qwerty"]) w.show() 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 b98e6ebc..92d6e79b 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 @@ -131,7 +131,7 @@ if __name__ == "__main__": # pragma: no cover "description": "A device for testing out a widget", "readOnly": True, "softwareTrigger": False, - "deviceTags": ["tag1", "tag2", "tag3"], + "deviceTags": {"tag1", "tag2", "tag3"}, "userParameter": {"some_setting": "some_ value"}, } ) diff --git a/tests/unit_tests/test_device_config_form_dialog.py b/tests/unit_tests/test_device_config_form_dialog.py index 749cbe7a..5ebb4014 100644 --- a/tests/unit_tests/test_device_config_form_dialog.py +++ b/tests/unit_tests/test_device_config_form_dialog.py @@ -79,7 +79,7 @@ def test_waiting_display(dialog, qtbot): def test_update_cycle(dialog, qtbot): - update = {"enabled": False, "readoutPriority": "baseline", "deviceTags": ["tag"]} + update = {"enabled": False, "readoutPriority": "baseline", "deviceTags": {"tag"}} def _mock_send(a, c, w): dialog.client.device_manager.devices["test_device"]._config = c["test_device"] diff --git a/tests/unit_tests/test_generated_form_items.py b/tests/unit_tests/test_generated_form_items.py index 0f4a0abd..bce5d35b 100644 --- a/tests/unit_tests/test_generated_form_items.py +++ b/tests/unit_tests/test_generated_form_items.py @@ -104,7 +104,7 @@ def test_list_metadata_field(list_field_and_values: tuple[ListFormItem, list, An assert list_field._main_widget.count() == 3 assert list_field.getValue() == [vals[0], extra, list_field._types.default] - list_field._add_item(extra) + list_field._add_data_item(extra) assert list_field._main_widget.count() == 4 assert list_field.getValue() == [vals[0], extra, list_field._types.default, extra]