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 32f756d4..fee3905e 100644 --- a/bec_widgets/utils/forms_from_types/forms.py +++ b/bec_widgets/utils/forms_from_types/forms.py @@ -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. """ diff --git a/bec_widgets/utils/forms_from_types/items.py b/bec_widgets/utils/forms_from_types/items.py index 17a34143..a8da67b1 100644 --- a/bec_widgets/utils/forms_from_types/items.py +++ b/bec_widgets/utils/forms_from_types/items.py @@ -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 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..215043a8 --- /dev/null +++ b/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py @@ -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() 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 6c222533..815ef902 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,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) 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..76c0fb76 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,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() 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