mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-07-20 02:03:02 +02:00
refactor: rearrange base of metadata forms for generic use
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from types import NoneType
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from qtpy.QtCore import Signal # type: ignore
|
||||
from qtpy.QtWidgets import QGridLayout, QLabel, QLayout, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.compact_popup import CompactPopupWidget
|
||||
from bec_widgets.utils.forms_from_types.items import FormItemSpec, widget_from_type
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class TypedForm(BECWidget, QWidget):
|
||||
PLUGIN = True
|
||||
ICON_NAME = "list_alt"
|
||||
|
||||
value_changed = Signal()
|
||||
|
||||
RPC = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
items: list[tuple[str, type]] | None = None,
|
||||
form_item_specs: list[FormItemSpec] | None = None,
|
||||
parent=None,
|
||||
client=None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Widget with a list of form items based on a list of types.
|
||||
|
||||
Args:
|
||||
items (list[tuple[str, type]]): list of tuples of a name for the field and its type.
|
||||
Should be a type supported by the logic in items.py
|
||||
form_item_specs (list[FormItemSpec]): list of form item specs, equivalent to items.
|
||||
only one of items or form_item_specs should be
|
||||
supplied.
|
||||
|
||||
"""
|
||||
if (items is not None and form_item_specs is not None) or (
|
||||
items is None and form_item_specs is None
|
||||
):
|
||||
raise ValueError("Must specify one and only one of items and form_item_specs")
|
||||
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)
|
||||
for name, item_type in items # type: ignore
|
||||
]
|
||||
)
|
||||
self._layout = QVBoxLayout()
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self._layout)
|
||||
|
||||
self._form_grid_container = QWidget(parent=self)
|
||||
self._form_grid = QWidget(parent=self._form_grid_container)
|
||||
self._layout.addWidget(self._form_grid_container)
|
||||
self._form_grid_container.setLayout(QVBoxLayout())
|
||||
self._form_grid.setLayout(self._new_grid_layout())
|
||||
|
||||
self.populate()
|
||||
|
||||
def populate(self):
|
||||
self._clear_grid()
|
||||
for r, item in enumerate(self._items):
|
||||
self._add_griditem(item, r)
|
||||
|
||||
def _add_griditem(self, item: FormItemSpec, row: int):
|
||||
grid = self._form_grid.layout()
|
||||
label = QLabel(item.name)
|
||||
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.valueChanged.connect(self.value_changed)
|
||||
grid.addWidget(widget, row, 1)
|
||||
|
||||
def _dict_from_grid(self) -> dict[str, str | int | float | Decimal | bool]:
|
||||
grid: QGridLayout = self._form_grid.layout() # type: ignore
|
||||
return {
|
||||
grid.itemAtPosition(i, 0)
|
||||
.widget()
|
||||
.property("_model_field_name"): grid.itemAtPosition(i, 1)
|
||||
.widget()
|
||||
.getValue() # type: ignore # we only add 'DynamicFormItem's here
|
||||
for i in range(grid.rowCount())
|
||||
}
|
||||
|
||||
def _clear_grid(self):
|
||||
if (old_layout := self._form_grid.layout()) is not None:
|
||||
while old_layout.count():
|
||||
item = old_layout.takeAt(0)
|
||||
widget = item.widget()
|
||||
if widget is not None:
|
||||
widget.deleteLater()
|
||||
old_layout.deleteLater()
|
||||
self._form_grid.deleteLater()
|
||||
self._form_grid = QWidget()
|
||||
|
||||
self._form_grid.setLayout(self._new_grid_layout())
|
||||
self._form_grid_container.layout().addWidget(self._form_grid)
|
||||
|
||||
self._form_grid.adjustSize()
|
||||
self._form_grid_container.adjustSize()
|
||||
self.adjustSize()
|
||||
|
||||
def _new_grid_layout(self):
|
||||
new_grid = QGridLayout()
|
||||
new_grid.setContentsMargins(0, 0, 0, 0)
|
||||
new_grid.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
|
||||
return new_grid
|
||||
|
||||
|
||||
class PydanticModelForm(TypedForm):
|
||||
metadata_updated = Signal(dict)
|
||||
metadata_cleared = Signal(NoneType)
|
||||
|
||||
def __init__(self, metadata_model: type[BaseModel], parent=None, client=None, **kwargs):
|
||||
"""
|
||||
A form generated from a pydantic model.
|
||||
|
||||
Args:
|
||||
metadata_model (type[BaseModel]): the model class for which to generate a form.
|
||||
"""
|
||||
self._md_schema = metadata_model
|
||||
super().__init__(form_item_specs=self._form_item_specs(), parent=parent, client=client)
|
||||
|
||||
self._validity = CompactPopupWidget()
|
||||
self._validity.compact_view = True # type: ignore
|
||||
self._validity.label = "Metadata validity" # type: ignore
|
||||
self._validity.compact_show_popup.setIcon(
|
||||
material_icon(icon_name="info", size=(10, 10), convert_to_pixmap=False)
|
||||
)
|
||||
self._validity_message = QLabel("Not yet validated")
|
||||
self._validity.addWidget(self._validity_message)
|
||||
self._layout.addWidget(self._validity)
|
||||
self.value_changed.connect(self.validate_form)
|
||||
|
||||
def set_schema(self, schema: type[BaseModel]):
|
||||
self._md_schema = schema
|
||||
self.populate()
|
||||
|
||||
def _form_item_specs(self):
|
||||
return [
|
||||
FormItemSpec(name=name, info=info, item_type=info.annotation)
|
||||
for name, info in self._md_schema.model_fields.items()
|
||||
]
|
||||
|
||||
def update_items_from_schema(self):
|
||||
self._items = self._form_item_specs()
|
||||
|
||||
def populate(self):
|
||||
self.update_items_from_schema()
|
||||
super().populate()
|
||||
|
||||
def get_form_data(self):
|
||||
"""Get the entered metadata as a dict."""
|
||||
return self._dict_from_grid()
|
||||
|
||||
def validate_form(self, *_) -> bool:
|
||||
"""validate the currently entered metadata against the pydantic schema.
|
||||
If successful, returns on metadata_emitted and returns true.
|
||||
Otherwise, emits on metadata_cleared and returns false."""
|
||||
try:
|
||||
metadata_dict = self.get_form_data()
|
||||
self._md_schema.model_validate(metadata_dict)
|
||||
self._validity.set_global_state("success")
|
||||
self._validity_message.setText("No errors!")
|
||||
self.metadata_updated.emit(metadata_dict)
|
||||
return True
|
||||
except ValidationError as e:
|
||||
self._validity.set_global_state("emergency")
|
||||
self._validity_message.setText(str(e))
|
||||
self.metadata_cleared.emit(None)
|
||||
return False
|
||||
@@ -0,0 +1,286 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
from decimal import Decimal
|
||||
from types import UnionType
|
||||
from typing import Callable, Protocol
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic.fields import FieldInfo
|
||||
from qtpy.QtCore import Signal # type: ignore
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QButtonGroup,
|
||||
QCheckBox,
|
||||
QDoubleSpinBox,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLayout,
|
||||
QLineEdit,
|
||||
QRadioButton,
|
||||
QSpinBox,
|
||||
QToolButton,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.widgets.editors.scan_metadata._util import (
|
||||
clearable_required,
|
||||
field_default,
|
||||
field_limits,
|
||||
field_maxlen,
|
||||
field_minlen,
|
||||
field_precision,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class FormItemSpec(BaseModel):
|
||||
"""
|
||||
The specification for an item in a dynamically generated form. Uses a pydantic FieldInfo
|
||||
to store most annotation info, since one of the main purposes is to store data for
|
||||
forms genrated from pydantic models, but can also be composed from other sources or by hand.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
item_type: type | UnionType
|
||||
name: str
|
||||
info: FieldInfo = FieldInfo()
|
||||
|
||||
|
||||
class ClearableBoolEntry(QWidget):
|
||||
stateChanged = Signal()
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._layout = QHBoxLayout()
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self._layout)
|
||||
self._layout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
|
||||
self._entry = QButtonGroup()
|
||||
self._true = QRadioButton("true", parent=self)
|
||||
self._false = QRadioButton("false", parent=self)
|
||||
for button in [self._true, self._false]:
|
||||
self._layout.addWidget(button)
|
||||
self._entry.addButton(button)
|
||||
button.toggled.connect(self.stateChanged)
|
||||
|
||||
def clear(self):
|
||||
self._entry.setExclusive(False)
|
||||
self._true.setChecked(False)
|
||||
self._false.setChecked(False)
|
||||
self._entry.setExclusive(True)
|
||||
|
||||
def isChecked(self) -> bool | None:
|
||||
if not self._true.isChecked() and not self._false.isChecked():
|
||||
return None
|
||||
return self._true.isChecked()
|
||||
|
||||
def setChecked(self, value: bool | None):
|
||||
if value is None:
|
||||
self.clear()
|
||||
elif value:
|
||||
self._true.setChecked(True)
|
||||
self._false.setChecked(False)
|
||||
else:
|
||||
self._true.setChecked(False)
|
||||
self._false.setChecked(True)
|
||||
|
||||
def setToolTip(self, tooltip: str):
|
||||
self._true.setToolTip(tooltip)
|
||||
self._false.setToolTip(tooltip)
|
||||
|
||||
|
||||
class DynamicFormItem(QWidget):
|
||||
valueChanged = Signal()
|
||||
|
||||
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
|
||||
super().__init__(parent)
|
||||
self._spec = spec
|
||||
self._layout = QHBoxLayout()
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._layout.setSizeConstraint(QLayout.SizeConstraint.SetMaximumSize)
|
||||
self._default = field_default(self._spec.info)
|
||||
self._desc = self._spec.info.description
|
||||
self.setLayout(self._layout)
|
||||
self._add_main_widget()
|
||||
if clearable_required(spec.info):
|
||||
self._add_clear_button()
|
||||
|
||||
@abstractmethod
|
||||
def getValue(self): ...
|
||||
|
||||
@abstractmethod
|
||||
def setValue(self, value): ...
|
||||
|
||||
@abstractmethod
|
||||
def _add_main_widget(self) -> None:
|
||||
"""Add the main data entry widget to self._main_widget and appply any
|
||||
constraints from the field info"""
|
||||
|
||||
def _describe(self, pad=" "):
|
||||
return pad + (self._desc if self._desc else "")
|
||||
|
||||
def _add_clear_button(self):
|
||||
self._clear_button = QToolButton()
|
||||
self._clear_button.setIcon(
|
||||
material_icon(icon_name="close", size=(10, 10), convert_to_pixmap=False)
|
||||
)
|
||||
self._layout.addWidget(self._clear_button)
|
||||
# the widget added in _add_main_widget must implement .clear() if value is not required
|
||||
self._clear_button.setToolTip("Clear value or reset to default.")
|
||||
self._clear_button.clicked.connect(self._main_widget.clear) # type: ignore
|
||||
|
||||
def _value_changed(self, *_, **__):
|
||||
self.valueChanged.emit()
|
||||
|
||||
|
||||
class StrMetadataField(DynamicFormItem):
|
||||
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self._main_widget.textChanged.connect(self._value_changed)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = QLineEdit()
|
||||
self._layout.addWidget(self._main_widget)
|
||||
min_length, max_length = (field_minlen(self._spec.info), field_maxlen(self._spec.info))
|
||||
if max_length:
|
||||
self._main_widget.setMaxLength(max_length)
|
||||
self._main_widget.setToolTip(
|
||||
f"(length min: {min_length} max: {max_length}){self._describe()}"
|
||||
)
|
||||
if self._default:
|
||||
self._main_widget.setText(self._default)
|
||||
self._add_clear_button()
|
||||
|
||||
def getValue(self):
|
||||
if self._main_widget.text() == "":
|
||||
return self._default
|
||||
return self._main_widget.text()
|
||||
|
||||
def setValue(self, value: str):
|
||||
if value is None:
|
||||
self._main_widget.setText("")
|
||||
self._main_widget.setText(value)
|
||||
|
||||
|
||||
class IntMetadataField(DynamicFormItem):
|
||||
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self._main_widget.textChanged.connect(self._value_changed)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = QSpinBox()
|
||||
self._layout.addWidget(self._main_widget)
|
||||
min_, max_ = field_limits(self._spec.info, int)
|
||||
self._main_widget.setMinimum(min_)
|
||||
self._main_widget.setMaximum(max_)
|
||||
self._main_widget.setToolTip(f"(range {min_} to {max_}){self._describe()}")
|
||||
if self._default is not None:
|
||||
self._main_widget.setValue(self._default)
|
||||
self._add_clear_button()
|
||||
else:
|
||||
self._main_widget.clear()
|
||||
|
||||
def getValue(self):
|
||||
if self._main_widget.text() == "":
|
||||
return self._default
|
||||
return self._main_widget.value()
|
||||
|
||||
def setValue(self, value: int):
|
||||
if value is None:
|
||||
self._main_widget.clear()
|
||||
self._main_widget.setValue(value)
|
||||
|
||||
|
||||
class FloatDecimalMetadataField(DynamicFormItem):
|
||||
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self._main_widget.textChanged.connect(self._value_changed)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = QDoubleSpinBox()
|
||||
self._layout.addWidget(self._main_widget)
|
||||
min_, max_ = field_limits(self._spec.info, int)
|
||||
self._main_widget.setMinimum(min_)
|
||||
self._main_widget.setMaximum(max_)
|
||||
precision = field_precision(self._spec.info)
|
||||
if precision:
|
||||
self._main_widget.setDecimals(precision)
|
||||
minstr = f"{float(min_):.3f}" if abs(min_) <= 1000 else f"{float(min_):.3e}"
|
||||
maxstr = f"{float(max_):.3f}" if abs(max_) <= 1000 else f"{float(max_):.3e}"
|
||||
self._main_widget.setToolTip(f"(range {minstr} to {maxstr}){self._describe()}")
|
||||
if self._default is not None:
|
||||
self._main_widget.setValue(self._default)
|
||||
self._add_clear_button()
|
||||
else:
|
||||
self._main_widget.clear()
|
||||
|
||||
def getValue(self):
|
||||
if self._main_widget.text() == "":
|
||||
return self._default
|
||||
return self._main_widget.value()
|
||||
|
||||
def setValue(self, value: float):
|
||||
if value is None:
|
||||
self._main_widget.clear()
|
||||
self._main_widget.setValue(value)
|
||||
|
||||
|
||||
class BoolMetadataField(DynamicFormItem):
|
||||
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self._main_widget.stateChanged.connect(self._value_changed)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
if clearable_required(self._spec.info):
|
||||
self._main_widget = ClearableBoolEntry()
|
||||
else:
|
||||
self._main_widget = QCheckBox()
|
||||
self._layout.addWidget(self._main_widget)
|
||||
self._main_widget.setToolTip(self._describe(""))
|
||||
self._main_widget.setChecked(self._default) # type: ignore # if there is no default then it will be ClearableBoolEntry and can be set with None
|
||||
|
||||
def getValue(self):
|
||||
return self._main_widget.isChecked()
|
||||
|
||||
def setValue(self, value):
|
||||
self._main_widget.setChecked(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
|
||||
else:
|
||||
logger.warning(f"Type {annotation} is not (yet) supported in metadata form creation.")
|
||||
return StrMetadataField
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
class TestModel(BaseModel):
|
||||
value1: str | None = Field(None)
|
||||
value2: bool | None = Field(None)
|
||||
value3: bool = Field(True)
|
||||
value4: int = Field(123)
|
||||
value5: int | None = Field()
|
||||
|
||||
app = QApplication([])
|
||||
w = QWidget()
|
||||
layout = QGridLayout()
|
||||
w.setLayout(layout)
|
||||
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)
|
||||
|
||||
w.show()
|
||||
app.exec()
|
||||
Reference in New Issue
Block a user