diff --git a/bec_widgets/utils/expandable_frame.py b/bec_widgets/utils/expandable_frame.py index af7d68ef..9f65500e 100644 --- a/bec_widgets/utils/expandable_frame.py +++ b/bec_widgets/utils/expandable_frame.py @@ -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 diff --git a/bec_widgets/utils/forms_from_types/forms.py b/bec_widgets/utils/forms_from_types/forms.py index 6fe17b38..36ba6551 100644 --- a/bec_widgets/utils/forms_from_types/forms.py +++ b/bec_widgets/utils/forms_from_types/forms.py @@ -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. """ diff --git a/bec_widgets/utils/forms_from_types/items.py b/bec_widgets/utils/forms_from_types/items.py index f100b53c..039b35ac 100644 --- a/bec_widgets/utils/forms_from_types/items.py +++ b/bec_widgets/utils/forms_from_types/items.py @@ -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): diff --git a/bec_widgets/widgets/editors/dict_backed_table.py b/bec_widgets/widgets/editors/dict_backed_table.py index 27c7228b..f0cebdd3 100644 --- a/bec_widgets/widgets/editors/dict_backed_table.py +++ b/bec_widgets/widgets/editors/dict_backed_table.py @@ -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) diff --git a/bec_widgets/widgets/editors/scan_metadata/_util.py b/bec_widgets/widgets/editors/scan_metadata/_util.py index 3e4fa092..da9b2ddf 100644 --- a/bec_widgets/widgets/editors/scan_metadata/_util.py +++ b/bec_widgets/widgets/editors/scan_metadata/_util.py @@ -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 + ) diff --git a/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py b/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py new file mode 100644 index 00000000..5cc734e2 --- /dev/null +++ b/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py @@ -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() diff --git a/bec_widgets/widgets/services/device_browser/device_item/device_config_form.py b/bec_widgets/widgets/services/device_browser/device_item/device_config_form.py index 5fe4d235..6914d489 100644 --- a/bec_widgets/widgets/services/device_browser/device_item/device_config_form.py +++ b/bec_widgets/widgets/services/device_browser/device_item/device_config_form.py @@ -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) diff --git a/bec_widgets/widgets/services/device_browser/device_item/device_item.py b/bec_widgets/widgets/services/device_browser/device_item/device_item.py index 44fdf2a3..b98e6ebc 100644 --- a/bec_widgets/widgets/services/device_browser/device_item/device_item.py +++ b/bec_widgets/widgets/services/device_browser/device_item/device_item.py @@ -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() diff --git a/bec_widgets/widgets/utility/toggle/toggle.py b/bec_widgets/widgets/utility/toggle/toggle.py index d21fa7a0..c69ee48b 100644 --- a/bec_widgets/widgets/utility/toggle/toggle.py +++ b/bec_widgets/widgets/utility/toggle/toggle.py @@ -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 diff --git a/tests/unit_tests/test_scan_metadata.py b/tests/unit_tests/test_scan_metadata.py index f9ddcd3b..7d26057f 100644 --- a/tests/unit_tests/test_scan_metadata.py +++ b/tests/unit_tests/test_scan_metadata.py @@ -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,