1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-03-08 01:37:52 +01:00

wip feat: allow editing device config in browser

This commit is contained in:
2025-05-22 11:17:43 +02:00
parent f5bba072ea
commit 21b9dc086d
8 changed files with 247 additions and 35 deletions

View File

@@ -37,6 +37,16 @@ class ExpandableGroupFrame(QFrame):
self._layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self._layout)
self._create_title_layout(title, icon)
self._contents = QWidget(self)
self._layout.addWidget(self._contents)
self._expansion_button.clicked.connect(self.switch_expanded_state)
self.expanded = self._expanded # type: ignore
self.expansion_state_changed.emit()
def _create_title_layout(self, title: str, icon: str):
self._title_layout = QHBoxLayout()
self._layout.addLayout(self._title_layout)
@@ -54,13 +64,6 @@ class ExpandableGroupFrame(QFrame):
self._update_expansion_icon()
self._title_layout.addWidget(self._expansion_button, stretch=1)
self._contents = QWidget(self)
self._layout.addWidget(self._contents)
self._expansion_button.clicked.connect(self.switch_expanded_state)
self.expanded = self._expanded # type: ignore
self.expansion_state_changed.emit()
def set_layout(self, layout: QLayout) -> None:
self._contents.setLayout(layout)
self._contents.layout().setContentsMargins(0, 0, 0, 0) # type: ignore

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
from decimal import Decimal
from types import NoneType
from typing import NamedTuple
@@ -8,7 +7,7 @@ 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 QApplication, QGridLayout, QLabel, QVBoxLayout, QWidget
from qtpy.QtWidgets import QApplication, QGridLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.compact_popup import CompactPopupWidget
@@ -18,6 +17,7 @@ from bec_widgets.utils.forms_from_types.items import (
DynamicFormItem,
DynamicFormItemType,
FormItemSpec,
default_widget_types,
widget_from_type,
)
@@ -93,7 +93,11 @@ 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_types = default_widget_types
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 +112,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, self._widget_types)(parent=self, spec=item)
widget.valueChanged.connect(self.value_changed)
widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
grid.addWidget(widget, row, 1)
@@ -187,7 +191,7 @@ class PydanticModelForm(TypedForm):
Args:
data_model (type[BaseModel]): the model class for which to generate a form.
enabled (bool): whether fields are enabled for editing.
enabled (bool, optional): whether fields are enabled for editing.
pretty_display (bool, optional): Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display.
"""

View File

@@ -3,12 +3,13 @@ from __future__ import annotations
from abc import abstractmethod
from decimal import Decimal
from types import GenericAlias, UnionType
from typing import Literal
from typing import Callable, Literal, TypedDict
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic.fields import FieldInfo
from pydantic_core import PydanticUndefined
from qtpy.QtCore import Signal # type: ignore
from qtpy.QtWidgets import (
QApplication,
@@ -36,6 +37,7 @@ from bec_widgets.widgets.editors.scan_metadata._util import (
field_minlen,
field_precision,
)
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
logger = bec_logger.logger
@@ -210,7 +212,7 @@ class StrMetadataField(DynamicFormItem):
def setValue(self, value: str):
if value is None:
self._main_widget.setText("")
return self._main_widget.setText("")
self._main_widget.setText(str(value))
@@ -298,6 +300,20 @@ class BoolMetadataField(DynamicFormItem):
self._main_widget.setChecked(value)
class BoolToggleMetadataField(BoolMetadataField):
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
if spec.info.default is PydanticUndefined:
spec.info.default = False
super().__init__(parent=parent, spec=spec)
def _add_main_widget(self) -> None:
self._main_widget = ToggleSwitch()
self._layout.addWidget(self._main_widget)
self._main_widget.setToolTip(self._describe(""))
if self._default is not None:
self._main_widget.setChecked(self._default)
class DictMetadataField(DynamicFormItem):
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
@@ -319,26 +335,39 @@ 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
WidgetTypeRegistry = dict[
str, tuple[Callable[[type | UnionType | None], bool], type[DynamicFormItem]]
]
default_widget_types: 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),
StrMetadataField,
),
}
def widget_from_type(
annotation: type | UnionType | None, widget_types: WidgetTypeRegistry
) -> type[DynamicFormItem]:
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

View File

@@ -67,4 +67,6 @@ def field_default(info: FieldInfo):
def clearable_required(info: FieldInfo):
return type(None) in get_args(info.annotation) or info.is_required()
return type(None) in get_args(info.annotation) or (
info.is_required() and info.default is PydanticUndefined
)

View File

@@ -0,0 +1,124 @@
from bec_lib.atlas_models import Device as DeviceConfigModel
from bec_lib.config_helper import CONF as DEVICE_CONF_KEYS
from bec_lib.config_helper import ConfigHelper
from bec_lib.logger import bec_logger
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.services.device_browser.device_item.device_config_form import (
DeviceConfigForm,
)
logger = bec_logger.logger
class DeviceConfigDialog(BECWidget, QDialog):
RPC = False
def __init__(
self, parent=None, device: str | None = None, config_helper: ConfigHelper | None = None
):
super().__init__(parent=parent)
self._config_helper = config_helper or ConfigHelper(
self.client.connector, "gui/device_config_dialog"
)
self._layout = QVBoxLayout()
self.setLayout(self._layout)
self._form = DeviceConfigForm()
self._layout.addWidget(self._form)
self._device = device
self._fetch_config()
self._fill_form()
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
self._layout.addWidget(button_box)
def _fetch_config(self):
self._initial_config = {}
if (
self.client.device_manager is not None
and self._device in self.client.device_manager.devices
):
self._initial_config = self.client.device_manager.devices.get(self._device)._config
def _fill_form(self):
self._form.set_data(DeviceConfigModel.model_validate(self._initial_config))
def updated_config(self):
new_config = self._form.get_form_data()
return {
k: v for k, v in new_config.items() if self._initial_config.get(k) != new_config.get(k)
}
@SafeSlot()
def accept(self):
updated_config = self.updated_config()
if (device_name := updated_config.get("name")) == "":
logger.warning("Can't create a device with no name!")
super().accept()
return
if set(updated_config.keys()) & set(DEVICE_CONF_KEYS.NON_UPDATABLE):
logger.info(
f"Removing old device {self._device} and adding new device {device_name or self._device} with modified config: {updated_config}"
)
super().accept()
return
self._update_device_config(updated_config)
super().accept()
return
def _update_device_config(self, config: dict):
logger.info(f"Sending request to update device config: {config}")
try:
self._config_helper.send_config_request(
action="update", config={config.pop("name"): config}
)
except Exception as e:
logger.error(e)
def main(): # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication, QLineEdit, QPushButton, QWidget
from bec_widgets.utils.colors import set_theme
dialog = None
app = QApplication(sys.argv)
set_theme("light")
widget = QWidget()
widget.setLayout(QVBoxLayout())
device = QLineEdit()
widget.layout().addWidget(device)
def _destroy_dialog(*_):
nonlocal dialog
dialog = None
def accept(*args):
logger.success(f"submitted device config form {dialog} {args}")
_destroy_dialog()
def _show_dialog(*_):
nonlocal dialog
if dialog is None:
dialog = DeviceConfigDialog(device=device.text())
dialog.accepted.connect(accept)
dialog.rejected.connect(_destroy_dialog)
dialog.open()
button = QPushButton("Show device dialog")
widget.layout().addWidget(button)
button.clicked.connect(_show_dialog)
widget.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()

View File

@@ -1,11 +1,17 @@
from __future__ import annotations
from bec_lib.atlas_models import Device as DeviceConfigModel
from pydantic import BaseModel
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 (
BoolMetadataField,
BoolToggleMetadataField,
default_widget_types,
)
class DeviceConfigForm(PydanticModelForm):
@@ -20,8 +26,14 @@ class DeviceConfigForm(PydanticModelForm):
client=client,
**kwargs,
)
self._widget_types = default_widget_types.copy()
self._widget_types["bool"] = (lambda anno: anno == bool, BoolToggleMetadataField)
self._widget_types["optional_bool"] = (lambda anno: anno == bool | None, BoolMetadataField)
self._validity.setVisible(False)
self._connect_to_theme_change()
self.populate()
def _post_init(self): ...
def set_pretty_display_theme(self, theme: str | None = None):
if theme is None:
@@ -33,3 +45,9 @@ class DeviceConfigForm(PydanticModelForm):
qapp = QApplication.instance()
if hasattr(qapp, "theme_signal"):
qapp.theme_signal.theme_updated.connect(self.set_pretty_display_theme) # type: ignore
def set_schema(self, schema: type[BaseModel]):
raise TypeError("This class doesn't support changing the schema")
def set_data(self, data: DeviceConfigModel): # type: ignore # This class locks the type
super().set_data(data)

View File

@@ -4,20 +4,25 @@ from typing import TYPE_CHECKING
from bec_lib.atlas_models import Device as DeviceConfigModel
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from PySide6.QtWidgets import QToolButton
from qtpy.QtCore import QMimeData, QSize, Qt, Signal
from qtpy.QtGui import QDrag
from qtpy.QtWidgets import QApplication, QHBoxLayout, QWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.expandable_frame import ExpandableGroupFrame
from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import (
DeviceConfigDialog,
)
from bec_widgets.widgets.services.device_browser.device_item.device_config_form import (
DeviceConfigForm,
)
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
if TYPE_CHECKING: # pragma: no cover
from qtpy.QtGui import QMouseEvent
logger = bec_logger.logger
@@ -39,6 +44,19 @@ class DeviceItem(ExpandableGroupFrame):
self.adjustSize()
def _create_title_layout(self, title: str, icon: str):
super()._create_title_layout(title, icon)
self.edit_button = QToolButton()
self.edit_button.setIcon(
material_icon(icon_name="edit", size=(10, 10), convert_to_pixmap=False)
)
self._title_layout.insertWidget(self._title_layout.count() - 1, self.edit_button)
self.edit_button.clicked.connect(self._create_edit_dialog)
def _create_edit_dialog(self):
dialog = DeviceConfigDialog(parent=self, device=self.device)
dialog.open()
@SafeSlot()
def switch_expanded_state(self):
if not self.expanded and not self._expanded_first_time:
@@ -92,6 +110,11 @@ if __name__ == "__main__": # pragma: no cover
from qtpy.QtWidgets import QApplication
from bec_widgets.widgets.services.device_browser.device_item.device_config_form import (
DeviceConfigForm,
)
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
app = QApplication(sys.argv)
widget = QWidget()
layout = QHBoxLayout()

View File

@@ -10,6 +10,7 @@ class ToggleSwitch(QWidget):
A simple toggle.
"""
stateChanged = Signal(bool)
enabled = Signal(bool)
ICON_NAME = "toggle_on"
PLUGIN = True
@@ -42,11 +43,19 @@ class ToggleSwitch(QWidget):
@checked.setter
def checked(self, state):
if self._checked != state:
self.stateChanged.emit(state)
self._checked = state
self.update_colors()
self.set_thumb_pos_to_state()
self.enabled.emit(self._checked)
def setChecked(self, state: bool):
self.checked = state
def isChecked(self):
return self.checked
@Property(QPointF)
def thumb_pos(self):
return self._thumb_pos