0
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-07-12 18:51:50 +02:00

feat: add a widget to edit lists in forms

This commit is contained in:
2025-06-04 14:21:05 +02:00
committed by David Perl
parent d626caae3d
commit 7fc85bac7f
6 changed files with 219 additions and 37 deletions

View File

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

View File

@ -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()

View File

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

View File

@ -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()

View File

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

View File

@ -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,
]