0
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-07-12 18:51:50 +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.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

@ -7,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
@ -88,10 +88,13 @@ 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: dict | None = None
self._widget_from_type = widget_from_type
self._post_init()
def _post_init(self):
"""Override this if a subclass should do things after super().__init__ and before populate()"""
self.populate()
self.enabled = self._enabled # type: ignore # QProperty
@ -106,7 +109,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 = 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.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
grid.addWidget(widget, row, 1)
@ -185,7 +188,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

@ -10,6 +10,7 @@ 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,
@ -43,6 +44,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
@ -217,7 +219,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))
@ -305,10 +307,26 @@ 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)
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):
self._main_widget.set_button_visibility(False)
@ -326,13 +344,11 @@ class DictMetadataField(DynamicFormItem):
self._main_widget.replace_data(value)
_T = TypeVar("_T")
class _ItemAndWidgetType(NamedTuple, Generic[_T]):
item: type[_T]
class _ItemAndWidgetType(NamedTuple):
# TODO: this should be generic but not supported in 3.10
item: type[int | float | str]
widget: type
default: _T
default: int | float | str
class ListMetadataField(DynamicFormItem):

View File

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

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,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 functools import partial
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 DEFAULT_WIDGET_TYPES, BoolMetadataField
from bec_widgets.utils.forms_from_types.items import (
DEFAULT_WIDGET_TYPES,
BoolMetadataField,
BoolToggleMetadataField,
widget_from_type,
)
class DeviceConfigForm(PydanticModelForm):
@ -22,9 +30,13 @@ class DeviceConfigForm(PydanticModelForm):
**kwargs,
)
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._connect_to_theme_change()
self.populate()
def _post_init(self): ...
def set_pretty_display_theme(self, theme: str | None = None):
if theme is None:
@ -36,3 +48,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,24 @@ 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 qtpy.QtCore import QMimeData, QSize, Qt, Signal
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.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 +43,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 +109,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

View File

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