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:
@ -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)
|
||||
|
@ -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()
|
||||
|
@ -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):
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
]
|
||||
|
Reference in New Issue
Block a user