mirror of
https://github.com/bec-project/bec_widgets.git
synced 2025-07-13 19:21: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 __future__ import annotations
|
||||||
|
|
||||||
from decimal import Decimal
|
|
||||||
from types import NoneType
|
from types import NoneType
|
||||||
from typing import NamedTuple
|
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!")
|
logger.error("Must specify one and only one of items and form_item_specs!")
|
||||||
items = []
|
items = []
|
||||||
super().__init__(parent=parent, client=client, **kwargs)
|
super().__init__(parent=parent, client=client, **kwargs)
|
||||||
self._items = (
|
self._items = form_item_specs or [
|
||||||
form_item_specs
|
FormItemSpec(name=name, item_type=item_type, pretty_display=pretty_display)
|
||||||
if form_item_specs is not None
|
for name, item_type in items # type: ignore
|
||||||
else [
|
]
|
||||||
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.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
|
||||||
self._layout = QVBoxLayout()
|
self._layout = QVBoxLayout()
|
||||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||||
@ -93,7 +88,10 @@ class TypedForm(BECWidget, QWidget):
|
|||||||
self._layout.addWidget(self._form_grid_container)
|
self._layout.addWidget(self._form_grid_container)
|
||||||
self._form_grid_container.setLayout(QVBoxLayout())
|
self._form_grid_container.setLayout(QVBoxLayout())
|
||||||
self._form_grid.setLayout(self._new_grid_layout())
|
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.populate()
|
||||||
self.enabled = self._enabled # type: ignore # QProperty
|
self.enabled = self._enabled # type: ignore # QProperty
|
||||||
|
|
||||||
@ -108,7 +106,7 @@ class TypedForm(BECWidget, QWidget):
|
|||||||
label.setProperty("_model_field_name", item.name)
|
label.setProperty("_model_field_name", item.name)
|
||||||
label.setToolTip(item.info.description or item.name)
|
label.setToolTip(item.info.description or item.name)
|
||||||
grid.addWidget(label, row, 0)
|
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.valueChanged.connect(self.value_changed)
|
||||||
widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
|
widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
|
||||||
grid.addWidget(widget, row, 1)
|
grid.addWidget(widget, row, 1)
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import typing
|
||||||
from abc import abstractmethod
|
from abc import abstractmethod
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from types import GenericAlias, UnionType
|
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_lib.logger import bec_logger
|
||||||
from bec_qthemes import material_icon
|
from bec_qthemes import material_icon
|
||||||
@ -20,13 +21,19 @@ from qtpy.QtWidgets import (
|
|||||||
QLabel,
|
QLabel,
|
||||||
QLayout,
|
QLayout,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
|
QListWidget,
|
||||||
|
QListWidgetItem,
|
||||||
|
QPushButton,
|
||||||
QRadioButton,
|
QRadioButton,
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
QSpinBox,
|
QSpinBox,
|
||||||
QToolButton,
|
QToolButton,
|
||||||
|
QVBoxLayout,
|
||||||
QWidget,
|
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.dict_backed_table import DictBackedTable
|
||||||
from bec_widgets.widgets.editors.scan_metadata._util import (
|
from bec_widgets.widgets.editors.scan_metadata._util import (
|
||||||
clearable_required,
|
clearable_required,
|
||||||
@ -123,7 +130,7 @@ class ClearableBoolEntry(QWidget):
|
|||||||
self._false.setToolTip(tooltip)
|
self._false.setToolTip(tooltip)
|
||||||
|
|
||||||
|
|
||||||
DynamicFormItemType = str | int | float | Decimal | bool | dict
|
DynamicFormItemType = str | int | float | Decimal | bool | dict | list | None
|
||||||
|
|
||||||
|
|
||||||
class DynamicFormItem(QWidget):
|
class DynamicFormItem(QWidget):
|
||||||
@ -146,7 +153,7 @@ class DynamicFormItem(QWidget):
|
|||||||
self._desc = self._spec.info.description
|
self._desc = self._spec.info.description
|
||||||
self.setLayout(self._layout)
|
self.setLayout(self._layout)
|
||||||
self._add_main_widget()
|
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)
|
self._main_widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
|
||||||
if not spec.pretty_display:
|
if not spec.pretty_display:
|
||||||
if clearable_required(spec.info):
|
if clearable_required(spec.info):
|
||||||
@ -319,26 +326,133 @@ class DictMetadataField(DynamicFormItem):
|
|||||||
self._main_widget.replace_data(value)
|
self._main_widget.replace_data(value)
|
||||||
|
|
||||||
|
|
||||||
def widget_from_type(annotation: type | UnionType | None) -> type[DynamicFormItem]:
|
_T = TypeVar("_T")
|
||||||
if annotation in [str, str | None]:
|
|
||||||
return StrMetadataField
|
|
||||||
if annotation in [int, int | None]:
|
class _ItemAndWidgetType(NamedTuple, Generic[_T]):
|
||||||
return IntMetadataField
|
item: type[_T]
|
||||||
if annotation in [float, float | None, Decimal, Decimal | None]:
|
widget: type
|
||||||
return FloatDecimalMetadataField
|
default: _T
|
||||||
if annotation in [bool, bool | None]:
|
|
||||||
return BoolMetadataField
|
|
||||||
if annotation in [dict, dict | None] or (
|
class ListMetadataField(DynamicFormItem):
|
||||||
isinstance(annotation, GenericAlias) and annotation.__origin__ is dict
|
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
|
||||||
):
|
super().__init__(parent=parent, spec=spec)
|
||||||
return DictMetadataField
|
self._main_widget: QListWidget
|
||||||
if annotation in [list, list | None] or (
|
if spec.info.annotation is list:
|
||||||
isinstance(annotation, GenericAlias) and annotation.__origin__ is list
|
self._types = _ItemAndWidgetType(str, QLineEdit, "")
|
||||||
):
|
elif isinstance(spec.info.annotation, GenericAlias):
|
||||||
return StrMetadataField
|
args = set(typing.get_args(spec.info.annotation))
|
||||||
else:
|
if args == {str}:
|
||||||
logger.warning(f"Type {annotation} is not (yet) supported in metadata form creation.")
|
self._types = _ItemAndWidgetType(str, QLineEdit, "")
|
||||||
return StrMetadataField
|
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
|
if __name__ == "__main__": # pragma: no cover
|
||||||
@ -349,14 +463,24 @@ if __name__ == "__main__": # pragma: no cover
|
|||||||
value3: bool = Field(True)
|
value3: bool = Field(True)
|
||||||
value4: int = Field(123)
|
value4: int = Field(123)
|
||||||
value5: int | None = Field()
|
value5: int | None = Field()
|
||||||
|
value6: list[int] = Field()
|
||||||
|
value7: list = Field()
|
||||||
|
|
||||||
app = QApplication([])
|
app = QApplication([])
|
||||||
w = QWidget()
|
w = QWidget()
|
||||||
layout = QGridLayout()
|
layout = QGridLayout()
|
||||||
w.setLayout(layout)
|
w.setLayout(layout)
|
||||||
|
items = []
|
||||||
for i, (field_name, info) in enumerate(TestModel.model_fields.items()):
|
for i, (field_name, info) in enumerate(TestModel.model_fields.items()):
|
||||||
layout.addWidget(QLabel(field_name), i, 0)
|
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()
|
w.show()
|
||||||
app.exec()
|
app.exec()
|
||||||
|
@ -172,6 +172,7 @@ class DictBackedTable(QWidget):
|
|||||||
self._add_button.clicked.connect(self._table_model.add_row)
|
self._add_button.clicked.connect(self._table_model.add_row)
|
||||||
self._remove_button.clicked.connect(self.delete_selected_rows)
|
self._remove_button.clicked.connect(self.delete_selected_rows)
|
||||||
self.delete_rows.connect(self._table_model.delete_rows)
|
self.delete_rows.connect(self._table_model.delete_rows)
|
||||||
|
|
||||||
self._table_model.dataChanged.connect(lambda *_: self.data_changed.emit(self.dump_dict()))
|
self._table_model.dataChanged.connect(lambda *_: self.data_changed.emit(self.dump_dict()))
|
||||||
|
|
||||||
def set_button_visibility(self, value: bool):
|
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.colors import get_theme_name
|
||||||
from bec_widgets.utils.forms_from_types import styles
|
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.forms import PydanticModelForm
|
||||||
|
from bec_widgets.utils.forms_from_types.items import DEFAULT_WIDGET_TYPES, BoolMetadataField
|
||||||
|
|
||||||
|
|
||||||
class DeviceConfigForm(PydanticModelForm):
|
class DeviceConfigForm(PydanticModelForm):
|
||||||
@ -20,6 +21,8 @@ class DeviceConfigForm(PydanticModelForm):
|
|||||||
client=client,
|
client=client,
|
||||||
**kwargs,
|
**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._validity.setVisible(False)
|
||||||
self._connect_to_theme_change()
|
self._connect_to_theme_change()
|
||||||
|
|
||||||
|
@ -69,7 +69,7 @@ def test_scan_metadata_for_custom_scan(
|
|||||||
def do_test():
|
def do_test():
|
||||||
# Set the metadata
|
# Set the metadata
|
||||||
grid: QGridLayout = scan_control._metadata_form._form_grid.layout()
|
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")
|
field_name = grid.itemAtPosition(i, 0).widget().property("_model_field_name")
|
||||||
if (value_to_set := md.pop(field_name, None)) is not None:
|
if (value_to_set := md.pop(field_name, None)) is not None:
|
||||||
grid.itemAtPosition(i, 1).widget().setValue(value_to_set)
|
grid.itemAtPosition(i, 1).widget().setValue(value_to_set)
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import sys
|
import sys
|
||||||
from typing import Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from pydantic.fields import FieldInfo
|
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")
|
@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:
|
else:
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
FormItemSpec.model_validate(input)
|
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