diff --git a/bec_widgets/utils/forms_from_types/forms.py b/bec_widgets/utils/forms_from_types/forms.py index 85023752..6fe17b38 100644 --- a/bec_widgets/utils/forms_from_types/forms.py +++ b/bec_widgets/utils/forms_from_types/forms.py @@ -1,6 +1,5 @@ from __future__ import annotations -from decimal import Decimal from types import NoneType from typing import NamedTuple @@ -69,14 +68,10 @@ class TypedForm(BECWidget, QWidget): logger.error("Must specify one and only one of items and form_item_specs!") items = [] super().__init__(parent=parent, client=client, **kwargs) - self._items = ( - form_item_specs - if form_item_specs is not None - else [ - FormItemSpec(name=name, item_type=item_type, pretty_display=pretty_display) - for name, item_type in items # type: ignore - ] - ) + self._items = form_item_specs or [ + FormItemSpec(name=name, item_type=item_type, pretty_display=pretty_display) + for name, item_type in items # type: ignore + ] self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding) self._layout = QVBoxLayout() self._layout.setContentsMargins(0, 0, 0, 0) @@ -93,7 +88,10 @@ class TypedForm(BECWidget, QWidget): self._layout.addWidget(self._form_grid_container) self._form_grid_container.setLayout(QVBoxLayout()) self._form_grid.setLayout(self._new_grid_layout()) + self._widget_from_type = widget_from_type + self._post_init() + def _post_init(self): self.populate() self.enabled = self._enabled # type: ignore # QProperty @@ -108,7 +106,7 @@ class TypedForm(BECWidget, QWidget): label.setProperty("_model_field_name", item.name) label.setToolTip(item.info.description or item.name) grid.addWidget(label, row, 0) - widget = widget_from_type(item.item_type)(parent=self, spec=item) + widget = self._widget_from_type(item.item_type)(parent=self, spec=item) widget.valueChanged.connect(self.value_changed) widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding) grid.addWidget(widget, row, 1) diff --git a/bec_widgets/utils/forms_from_types/items.py b/bec_widgets/utils/forms_from_types/items.py index a46b7e12..f100b53c 100644 --- a/bec_widgets/utils/forms_from_types/items.py +++ b/bec_widgets/utils/forms_from_types/items.py @@ -1,9 +1,10 @@ from __future__ import annotations +import typing from abc import abstractmethod from decimal import Decimal from types import GenericAlias, UnionType -from typing import Literal +from typing import Callable, Final, Generic, Literal, NamedTuple, TypeVar from bec_lib.logger import bec_logger from bec_qthemes import material_icon @@ -20,13 +21,19 @@ from qtpy.QtWidgets import ( QLabel, QLayout, QLineEdit, + QListWidget, + QListWidgetItem, + QPushButton, QRadioButton, QSizePolicy, QSpinBox, QToolButton, + QVBoxLayout, QWidget, ) +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.widget_io import WidgetIO from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable from bec_widgets.widgets.editors.scan_metadata._util import ( clearable_required, @@ -123,7 +130,7 @@ class ClearableBoolEntry(QWidget): self._false.setToolTip(tooltip) -DynamicFormItemType = str | int | float | Decimal | bool | dict +DynamicFormItemType = str | int | float | Decimal | bool | dict | list | None class DynamicFormItem(QWidget): @@ -146,7 +153,7 @@ class DynamicFormItem(QWidget): self._desc = self._spec.info.description self.setLayout(self._layout) self._add_main_widget() - self._main_widget: QWidget + assert isinstance(self._main_widget, QWidget), "Please set a widget in _add_main_widget()" # type: ignore self._main_widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding) if not spec.pretty_display: if clearable_required(spec.info): @@ -319,26 +326,133 @@ class DictMetadataField(DynamicFormItem): self._main_widget.replace_data(value) -def widget_from_type(annotation: type | UnionType | None) -> type[DynamicFormItem]: - if annotation in [str, str | None]: - return StrMetadataField - if annotation in [int, int | None]: - return IntMetadataField - if annotation in [float, float | None, Decimal, Decimal | None]: - return FloatDecimalMetadataField - if annotation in [bool, bool | None]: - return BoolMetadataField - if annotation in [dict, dict | None] or ( - isinstance(annotation, GenericAlias) and annotation.__origin__ is dict - ): - return DictMetadataField - if annotation in [list, list | None] or ( - isinstance(annotation, GenericAlias) and annotation.__origin__ is list - ): - return StrMetadataField - else: - logger.warning(f"Type {annotation} is not (yet) supported in metadata form creation.") - return StrMetadataField +_T = TypeVar("_T") + + +class _ItemAndWidgetType(NamedTuple, Generic[_T]): + item: type[_T] + widget: type + default: _T + + +class ListMetadataField(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): + args = set(typing.get_args(spec.info.annotation)) + if args == {str}: + self._types = _ItemAndWidgetType(str, QLineEdit, "") + if args == {int}: + self._types = _ItemAndWidgetType(int, QSpinBox, 0) + if args == {float} or args == {int, float}: + self._types = _ItemAndWidgetType(float, QDoubleSpinBox, 0.0) + else: + self._types = _ItemAndWidgetType(str, QLineEdit, 0) + self._data = [] + + def _add_main_widget(self) -> None: + self._main_widget = QListWidget() + self._layout.addWidget(self._main_widget) + self._add_buttons() + + def _add_buttons(self): + self._button_holder = QWidget() + self._buttons = QVBoxLayout() + 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("-") + self._remove_button.setToolTip("delete the focused row (if any)") + self._add_button.clicked.connect(self._add_row) + self._remove_button.clicked.connect(self._delete_row) + self._buttons.addWidget(self._add_button) + self._buttons.addWidget(self._remove_button) + + def _set_pretty_display(self): + super()._set_pretty_display() + self._button_holder.setHidden(True) + + def _repop(self, data): + self._main_widget.clear() + for val in data: + self._add_item(val) + + def _add_item(self, val=None): + val = val or self._types.default + self._data.append(val) + item = QListWidgetItem(self._main_widget) + 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) + + def _update(self, _, value, *args): + self._data[self._main_widget.currentRow()] = value + + @SafeSlot() + def _add_row(self): + self._add_item(0) + + @SafeSlot() + def _delete_row(self): + if selected := self._main_widget.currentItem(): + self._main_widget.removeItemWidget(selected) + row = self._main_widget.currentRow() + self._main_widget.takeItem(row) + self._data.pop(row) + + @SafeSlot() + def clear(self): + self._repop([]) + + def getValue(self): + return self._data + + def setValue(self, value: list): + if set(map(type, value)) != {self._types.item}: + raise ValueError(f"This widget only accepts items of type {self._types.item}") + self._repop(value) + + +WidgetTypeRegistry = dict[ + str, tuple[Callable[[type | UnionType | None], bool], type[DynamicFormItem]] +] + +DEFAULT_WIDGET_TYPES: Final[WidgetTypeRegistry] = { + "str": (lambda anno: anno in [str, str | None, None], StrMetadataField), + "int": (lambda anno: anno in [int, int | None], IntMetadataField), + "float_decimal": ( + lambda anno: anno in [float, float | None, Decimal, Decimal | None], + FloatDecimalMetadataField, + ), + "bool": (lambda anno: anno in [bool, bool | None], BoolMetadataField), + "dict": ( + lambda anno: anno in [dict, dict | None] + or (isinstance(anno, GenericAlias) and anno.__origin__ is dict), + DictMetadataField, + ), + "list": ( + lambda anno: anno in [list, list | None] + or (isinstance(anno, GenericAlias) and anno.__origin__ is list), + ListMetadataField, + ), +} + + +def widget_from_type( + annotation: type | UnionType | None, widget_types: WidgetTypeRegistry | None = None +) -> type[DynamicFormItem]: + widget_types = widget_types or DEFAULT_WIDGET_TYPES + for predicate, widget_type in widget_types.values(): + if predicate(annotation): + return widget_type + logger.warning(f"Type {annotation} is not (yet) supported in metadata form creation.") + return StrMetadataField if __name__ == "__main__": # pragma: no cover @@ -349,14 +463,24 @@ if __name__ == "__main__": # pragma: no cover value3: bool = Field(True) value4: int = Field(123) value5: int | None = Field() + value6: list[int] = Field() + value7: list = Field() app = QApplication([]) w = QWidget() layout = QGridLayout() w.setLayout(layout) + items = [] for i, (field_name, info) in enumerate(TestModel.model_fields.items()): layout.addWidget(QLabel(field_name), i, 0) - layout.addWidget(widget_from_type(info.annotation)(info), i, 1) + widg = widget_from_type(info.annotation)( + spec=FormItemSpec(item_type=info.annotation, name=field_name, info=info) + ) + items.append(widg) + layout.addWidget(widg, i, 1) + + items[5].setValue([1, 2, 3, 4]) + items[6].setValue(["1", "2", "asdfg", "qwerty"]) w.show() app.exec() diff --git a/bec_widgets/widgets/editors/dict_backed_table.py b/bec_widgets/widgets/editors/dict_backed_table.py index d7b3ac3e..27c7228b 100644 --- a/bec_widgets/widgets/editors/dict_backed_table.py +++ b/bec_widgets/widgets/editors/dict_backed_table.py @@ -172,6 +172,7 @@ class DictBackedTable(QWidget): self._add_button.clicked.connect(self._table_model.add_row) self._remove_button.clicked.connect(self.delete_selected_rows) 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): diff --git a/bec_widgets/widgets/services/device_browser/device_item/device_config_form.py b/bec_widgets/widgets/services/device_browser/device_item/device_config_form.py index 6c222533..5fe4d235 100644 --- a/bec_widgets/widgets/services/device_browser/device_item/device_config_form.py +++ b/bec_widgets/widgets/services/device_browser/device_item/device_config_form.py @@ -6,6 +6,7 @@ from qtpy.QtWidgets import QApplication from bec_widgets.utils.colors import get_theme_name from bec_widgets.utils.forms_from_types import styles from bec_widgets.utils.forms_from_types.forms import PydanticModelForm +from bec_widgets.utils.forms_from_types.items import DEFAULT_WIDGET_TYPES, BoolMetadataField class DeviceConfigForm(PydanticModelForm): @@ -20,6 +21,8 @@ class DeviceConfigForm(PydanticModelForm): client=client, **kwargs, ) + self._widget_types = DEFAULT_WIDGET_TYPES.copy() + self._widget_types["optional_bool"] = (lambda anno: anno is bool | None, BoolMetadataField) self._validity.setVisible(False) self._connect_to_theme_change() diff --git a/tests/end-2-end/test_with_plugins_e2e.py b/tests/end-2-end/test_with_plugins_e2e.py index c828d3d0..84889f77 100644 --- a/tests/end-2-end/test_with_plugins_e2e.py +++ b/tests/end-2-end/test_with_plugins_e2e.py @@ -69,7 +69,7 @@ def test_scan_metadata_for_custom_scan( def do_test(): # Set the metadata grid: QGridLayout = scan_control._metadata_form._form_grid.layout() - for i in range(grid.rowCount()): # type: ignore + for i in range(grid.rowCount() - 1): # type: ignore field_name = grid.itemAtPosition(i, 0).widget().property("_model_field_name") if (value_to_set := md.pop(field_name, None)) is not None: grid.itemAtPosition(i, 1).widget().setValue(value_to_set) diff --git a/tests/unit_tests/test_generated_form_items.py b/tests/unit_tests/test_generated_form_items.py index 4eac06c0..4cedd7c7 100644 --- a/tests/unit_tests/test_generated_form_items.py +++ b/tests/unit_tests/test_generated_form_items.py @@ -1,11 +1,12 @@ import sys -from typing import Literal +from typing import Any, Literal import pytest from pydantic import ValidationError from pydantic.fields import FieldInfo -from bec_widgets.utils.forms_from_types.items import FormItemSpec +from bec_widgets.utils.forms_from_types.items import FormItemSpec, ListMetadataField +from bec_widgets.utils.widget_io import WidgetIO @pytest.mark.skipif(sys.version_info < (3, 11), reason="Generic types don't support this in 3.10") @@ -58,3 +59,58 @@ def test_form_item_spec(input, validity): else: with pytest.raises(ValidationError): FormItemSpec.model_validate(input) + + +@pytest.fixture( + params=[ + {"type": list[int], "value": [1, 2, 3], "extra": 79}, + {"type": list[str], "value": ["a", "b", "c"], "extra": "string"}, + {"type": list[float], "value": [0.1, 0.2, 0.3], "extra": 79.0}, + ] +) +def list_metadata_field_and_values(request, qtbot): + itype, vals, extra = ( + request.param.get("type"), + request.param.get("value"), + request.param.get("extra"), + ) + spec = FormItemSpec(item_type=itype, name="test_list", info=FieldInfo(annotation=itype)) + (widget := ListMetadataField(parent=None, spec=spec)).setValue(vals) + qtbot.addWidget(widget) + yield widget, vals, extra + + +def test_list_metadata_field(list_metadata_field_and_values: tuple[ListMetadataField, list, Any]): + list_metadata_field, vals, extra = list_metadata_field_and_values + assert list_metadata_field.getValue() == vals + assert list_metadata_field._main_widget.count() == 3 + + list_metadata_field._add_button.click() + assert len(list_metadata_field.getValue()) == 4 + assert list_metadata_field._main_widget.count() == 4 + + list_metadata_field._main_widget.setCurrentRow(-1) + list_metadata_field._remove_button.click() + assert len(list_metadata_field.getValue()) == 4 + assert list_metadata_field._main_widget.count() == 4 + + list_metadata_field._main_widget.setCurrentRow(2) + list_metadata_field._remove_button.click() + assert list_metadata_field.getValue() == vals[:2] + [list_metadata_field._types.default] + assert list_metadata_field._main_widget.count() == 3 + + list_metadata_field._main_widget.setCurrentRow(1) + WidgetIO.set_value( + list_metadata_field._main_widget.itemWidget(list_metadata_field._main_widget.item(1)), extra + ) + assert list_metadata_field._main_widget.count() == 3 + assert list_metadata_field.getValue() == [vals[0], extra, list_metadata_field._types.default] + + list_metadata_field._add_item(extra) + assert list_metadata_field._main_widget.count() == 4 + assert list_metadata_field.getValue() == [ + vals[0], + extra, + list_metadata_field._types.default, + extra, + ]