0
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-07-13 11:11:49 +02:00

feat: allow editing device config from browser

This commit is contained in:
2025-05-22 11:17:43 +02:00
committed by David Perl
parent 7fc85bac7f
commit 886964bb54
10 changed files with 302 additions and 24 deletions

View File

@ -37,6 +37,16 @@ class ExpandableGroupFrame(QFrame):
self._layout.setContentsMargins(0, 0, 0, 0) self._layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self._layout) 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._title_layout = QHBoxLayout()
self._layout.addLayout(self._title_layout) self._layout.addLayout(self._title_layout)
@ -54,13 +64,6 @@ class ExpandableGroupFrame(QFrame):
self._update_expansion_icon() self._update_expansion_icon()
self._title_layout.addWidget(self._expansion_button, stretch=1) 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: def set_layout(self, layout: QLayout) -> None:
self._contents.setLayout(layout) self._contents.setLayout(layout)
self._contents.layout().setContentsMargins(0, 0, 0, 0) # type: ignore self._contents.layout().setContentsMargins(0, 0, 0, 0) # type: ignore

View File

@ -7,7 +7,7 @@ from bec_lib.logger import bec_logger
from bec_qthemes import material_icon from bec_qthemes import material_icon
from pydantic import BaseModel, ValidationError from pydantic import BaseModel, ValidationError
from qtpy.QtCore import Signal # type: ignore 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.bec_widget import BECWidget
from bec_widgets.utils.compact_popup import CompactPopupWidget from bec_widgets.utils.compact_popup import CompactPopupWidget
@ -88,10 +88,13 @@ 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_types: dict | None = None
self._widget_from_type = widget_from_type self._widget_from_type = widget_from_type
self._post_init() self._post_init()
def _post_init(self): def _post_init(self):
"""Override this if a subclass should do things after super().__init__ and before populate()"""
self.populate() self.populate()
self.enabled = self._enabled # type: ignore # QProperty self.enabled = self._enabled # type: ignore # QProperty
@ -106,7 +109,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 = self._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.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)
@ -185,7 +188,7 @@ class PydanticModelForm(TypedForm):
Args: Args:
data_model (type[BaseModel]): the model class for which to generate a form. 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. 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

@ -10,6 +10,7 @@ from bec_lib.logger import bec_logger
from bec_qthemes import material_icon from bec_qthemes import material_icon
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic.fields import FieldInfo from pydantic.fields import FieldInfo
from pydantic_core import PydanticUndefined
from qtpy.QtCore import Signal # type: ignore from qtpy.QtCore import Signal # type: ignore
from qtpy.QtWidgets import ( from qtpy.QtWidgets import (
QApplication, QApplication,
@ -43,6 +44,7 @@ from bec_widgets.widgets.editors.scan_metadata._util import (
field_minlen, field_minlen,
field_precision, field_precision,
) )
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
logger = bec_logger.logger logger = bec_logger.logger
@ -217,7 +219,7 @@ class StrMetadataField(DynamicFormItem):
def setValue(self, value: str): def setValue(self, value: str):
if value is None: if value is None:
self._main_widget.setText("") return self._main_widget.setText("")
self._main_widget.setText(str(value)) self._main_widget.setText(str(value))
@ -305,10 +307,26 @@ class BoolMetadataField(DynamicFormItem):
self._main_widget.setChecked(value) 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): class DictMetadataField(DynamicFormItem):
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None: def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec) super().__init__(parent=parent, spec=spec)
self._main_widget.data_changed.connect(self._value_changed) self._main_widget.data_changed.connect(self._value_changed)
if spec.info.default is not PydanticUndefined:
self._main_widget.set_default(spec.info.default)
def _set_pretty_display(self): def _set_pretty_display(self):
self._main_widget.set_button_visibility(False) self._main_widget.set_button_visibility(False)
@ -326,13 +344,11 @@ class DictMetadataField(DynamicFormItem):
self._main_widget.replace_data(value) self._main_widget.replace_data(value)
_T = TypeVar("_T") class _ItemAndWidgetType(NamedTuple):
# TODO: this should be generic but not supported in 3.10
item: type[int | float | str]
class _ItemAndWidgetType(NamedTuple, Generic[_T]):
item: type[_T]
widget: type widget: type
default: _T default: int | float | str
class ListMetadataField(DynamicFormItem): class ListMetadataField(DynamicFormItem):

View File

@ -17,6 +17,8 @@ from qtpy.QtWidgets import (
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
_NOT_SET = object()
class DictBackedTableModel(QAbstractTableModel): class DictBackedTableModel(QAbstractTableModel):
def __init__(self, data): def __init__(self, data):
@ -27,6 +29,7 @@ class DictBackedTableModel(QAbstractTableModel):
data (list[list[str]]): list of key-value pairs to initialise with""" data (list[list[str]]): list of key-value pairs to initialise with"""
super().__init__() super().__init__()
self._data: list[list[str]] = data self._data: list[list[str]] = data
self._default = _NOT_SET
self._disallowed_keys: list[str] = [] self._disallowed_keys: list[str] = []
# pylint: disable=missing-function-docstring # pylint: disable=missing-function-docstring
@ -113,8 +116,13 @@ class DictBackedTableModel(QAbstractTableModel):
for row in sorted(rows, reverse=True): for row in sorted(rows, reverse=True):
self.removeRows(row, 1, QModelIndex()) self.removeRows(row, 1, QModelIndex())
def set_default(self, value: dict | None):
self._default = value
def dump_dict(self): def dump_dict(self):
if self._data == [[]]: if self._data in [[], [[]], [["", ""]]]:
if self._default is not _NOT_SET:
return self._default
return {} return {}
return dict(self._data) return dict(self._data)
@ -175,6 +183,9 @@ class DictBackedTable(QWidget):
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_default(self, value: dict | None):
self._table_model.set_default(value)
def set_button_visibility(self, value: bool): def set_button_visibility(self, value: bool):
self._button_holder.setVisible(value) self._button_holder.setVisible(value)

View File

@ -67,4 +67,6 @@ def field_default(info: FieldInfo):
def clearable_required(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,194 @@
import traceback
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.QtCore import QSize, Qt
from qtpy.QtWidgets import (
QApplication,
QDialog,
QDialogButtonBox,
QStackedLayout,
QVBoxLayout,
QWidget,
)
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,
)
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
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, self.client._service_name
)
self._device = device
self.setWindowTitle(f"Edit config for: {device}")
self._container = QStackedLayout()
self._container.setStackingMode(QStackedLayout.StackAll)
self._add_form()
self._add_overlay()
self._add_buttons()
self.setLayout(self._container)
self._overlay_widget.setVisible(False)
def _add_form(self):
self._form_widget = QWidget()
self._layout = QVBoxLayout()
self._form_widget.setLayout(self._layout)
self._form = DeviceConfigForm()
self._layout.addWidget(self._form)
self._fetch_config()
self._fill_form()
self._container.addWidget(self._form_widget)
def _add_overlay(self):
self._overlay_widget = QWidget()
self._overlay_widget.setStyleSheet("background-color:rgba(128,128,128,128);")
self._overlay_widget.setAutoFillBackground(True)
self._overlay_layout = QVBoxLayout()
self._overlay_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._overlay_widget.setLayout(self._overlay_layout)
self._spinner = SpinnerWidget(parent=self)
self._spinner.setMinimumSize(QSize(100, 100))
self._overlay_layout.addWidget(self._spinner)
self._container.addWidget(self._overlay_widget)
def _add_buttons(self):
button_box = QDialogButtonBox(
QDialogButtonBox.Apply | QDialogButtonBox.Ok | QDialogButtonBox.Cancel
)
button_box.button(QDialogButtonBox.Apply).clicked.connect(self.apply)
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 apply(self):
self._process_update_action()
@SafeSlot()
def accept(self):
self._process_update_action()
return super().accept()
def _process_update_action(self):
updated_config = self.updated_config()
if (device_name := updated_config.get("name")) == "":
logger.warning("Can't create a device with no name!")
elif 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}"
)
else:
self._update_device_config(updated_config)
def _update_device_config(self, config: dict):
if config == {}:
logger.info("No changes made to device config")
return
logger.info(f"Sending request to update device config: {config}")
try:
self._start_waiting_display()
RID = self._config_helper.send_config_request(
action="update", config={self._device: config}, wait_for_response=False
)
reply = self._config_helper.wait_for_config_reply(
RID, timeout=self._config_helper.suggested_timeout_s(config)
)
self._config_helper.handle_update_reply(reply, RID)
self._stop_waiting_display()
except Exception as e:
self._stop_waiting_display()
logger.error(f"Error updating config: \n {''.join(traceback.format_exception(e))}")
finally:
self._fetch_config()
self._fill_form()
def _start_waiting_display(self):
self._overlay_widget.setVisible(True)
self._spinner.start()
QApplication.processEvents()
def _stop_waiting_display(self):
self._overlay_widget.setVisible(False)
self._spinner.stop()
QApplication.processEvents()
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,12 +1,20 @@
from __future__ import annotations from __future__ import annotations
from functools import partial
from bec_lib.atlas_models import Device as DeviceConfigModel from bec_lib.atlas_models import Device as DeviceConfigModel
from pydantic import BaseModel
from qtpy.QtWidgets import QApplication 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 from bec_widgets.utils.forms_from_types.items import (
DEFAULT_WIDGET_TYPES,
BoolMetadataField,
BoolToggleMetadataField,
widget_from_type,
)
class DeviceConfigForm(PydanticModelForm): class DeviceConfigForm(PydanticModelForm):
@ -22,9 +30,13 @@ class DeviceConfigForm(PydanticModelForm):
**kwargs, **kwargs,
) )
self._widget_types = DEFAULT_WIDGET_TYPES.copy() self._widget_types = DEFAULT_WIDGET_TYPES.copy()
self._widget_types["optional_bool"] = (lambda anno: anno is bool | None, BoolMetadataField) self._widget_types["bool"] = (lambda anno: anno is bool, BoolToggleMetadataField)
self._widget_types["optional_bool"] = (lambda anno: anno == bool | None, BoolMetadataField)
self._validity.setVisible(False) self._validity.setVisible(False)
self._connect_to_theme_change() self._connect_to_theme_change()
self.populate()
def _post_init(self): ...
def set_pretty_display_theme(self, theme: str | None = None): def set_pretty_display_theme(self, theme: str | None = None):
if theme is None: if theme is None:
@ -36,3 +48,9 @@ class DeviceConfigForm(PydanticModelForm):
qapp = QApplication.instance() qapp = QApplication.instance()
if hasattr(qapp, "theme_signal"): if hasattr(qapp, "theme_signal"):
qapp.theme_signal.theme_updated.connect(self.set_pretty_display_theme) # type: ignore 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,24 @@ from typing import TYPE_CHECKING
from bec_lib.atlas_models import Device as DeviceConfigModel from bec_lib.atlas_models import Device as DeviceConfigModel
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from qtpy.QtCore import QMimeData, QSize, Qt, Signal from qtpy.QtCore import QMimeData, QSize, Qt, Signal
from qtpy.QtGui import QDrag from qtpy.QtGui import QDrag
from qtpy.QtWidgets import QApplication, QHBoxLayout, QWidget from qtpy.QtWidgets import QApplication, QHBoxLayout, QToolButton, QWidget
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.expandable_frame import ExpandableGroupFrame 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 ( from bec_widgets.widgets.services.device_browser.device_item.device_config_form import (
DeviceConfigForm, DeviceConfigForm,
) )
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
from qtpy.QtGui import QMouseEvent from qtpy.QtGui import QMouseEvent
logger = bec_logger.logger logger = bec_logger.logger
@ -39,6 +43,19 @@ class DeviceItem(ExpandableGroupFrame):
self.adjustSize() 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() @SafeSlot()
def switch_expanded_state(self): def switch_expanded_state(self):
if not self.expanded and not self._expanded_first_time: if not self.expanded and not self._expanded_first_time:
@ -92,6 +109,11 @@ if __name__ == "__main__": # pragma: no cover
from qtpy.QtWidgets import QApplication 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) app = QApplication(sys.argv)
widget = QWidget() widget = QWidget()
layout = QHBoxLayout() layout = QHBoxLayout()

View File

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

View File

@ -42,7 +42,7 @@ class ExampleSchema(BasicScanMetadata):
TEST_DICT = { TEST_DICT = {
"sample_name": "test name", "sample_name": "test name",
"str_optional": "None", "str_optional": None,
"str_required": "something", "str_required": "something",
"bool_optional": None, "bool_optional": None,
"bool_required_default": True, "bool_required_default": True,