diff --git a/bec_widgets/qt_utils/round_frame.py b/bec_widgets/qt_utils/round_frame.py index 393d9362..0a0d265e 100644 --- a/bec_widgets/qt_utils/round_frame.py +++ b/bec_widgets/qt_utils/round_frame.py @@ -29,6 +29,7 @@ class RoundedFrame(BECWidget, QFrame): self._radius = radius # Apply rounded frame styling + self.setProperty("skip_settings", True) self.setObjectName("roundedFrame") self.update_style() diff --git a/bec_widgets/qt_utils/side_panel.py b/bec_widgets/qt_utils/side_panel.py index d4f95c8e..f69a2a53 100644 --- a/bec_widgets/qt_utils/side_panel.py +++ b/bec_widgets/qt_utils/side_panel.py @@ -34,6 +34,9 @@ class SidePanel(QWidget): ): super().__init__(parent=parent) + self.setProperty("skip_settings", True) + self.setObjectName("SidePanel") + self._orientation = orientation self._panel_max_width = panel_max_width self._animation_duration = animation_duration @@ -286,7 +289,6 @@ class SidePanel(QWidget): """ container_widget = QWidget() container_layout = QVBoxLayout(container_widget) - container_widget.setStyleSheet("background-color: rgba(0,0,0,0);") title_label = QLabel(f"{title}") title_label.setStyleSheet("font-size: 16px;") spacer = QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding) diff --git a/bec_widgets/utils/widget_state_manager.py b/bec_widgets/utils/widget_state_manager.py index 785b29ca..9537097c 100644 --- a/bec_widgets/utils/widget_state_manager.py +++ b/bec_widgets/utils/widget_state_manager.py @@ -1,11 +1,13 @@ from __future__ import annotations +from bec_lib import bec_logger from qtpy.QtCore import QSettings from qtpy.QtWidgets import ( QApplication, QCheckBox, QFileDialog, QHBoxLayout, + QLabel, QLineEdit, QPushButton, QSpinBox, @@ -13,6 +15,8 @@ from qtpy.QtWidgets import ( QWidget, ) +logger = bec_logger.logger + class WidgetStateManager: """ @@ -27,7 +31,7 @@ class WidgetStateManager: def save_state(self, filename: str = None): """ - Save the state of the widget to a INI file. + Save the state of the widget to an INI file. Args: filename(str): The filename to save the state to. @@ -42,7 +46,7 @@ class WidgetStateManager: def load_state(self, filename: str = None): """ - Load the state of the widget from a INI file. + Load the state of the widget from an INI file. Args: filename(str): The filename to load the state from. @@ -63,18 +67,33 @@ class WidgetStateManager: widget(QWidget): The widget to save the state for. settings(QSettings): The QSettings object to save the state to. """ + if widget.property("skip_settings") is True: + return + meta = widget.metaObject() - settings.beginGroup(widget.objectName()) + widget_name = self._get_full_widget_name(widget) + settings.beginGroup(widget_name) for i in range(meta.propertyCount()): prop = meta.property(i) name = prop.name() + if ( + name == "objectName" + or not prop.isReadable() + or not prop.isWritable() + or not prop.isStored() # can be extended to fine filter + ): + continue value = widget.property(name) settings.setValue(name, value) settings.endGroup() - # Recursively save child widgets - for child in widget.findChildren(QWidget): - if child.objectName(): + # Recursively process children (only if they aren't skipped) + for child in widget.children(): + if ( + child.objectName() + and child.property("skip_settings") is not True + and not isinstance(child, QLabel) + ): self._save_widget_state_qsettings(child, settings) def _load_widget_state_qsettings(self, widget: QWidget, settings: QSettings): @@ -85,8 +104,12 @@ class WidgetStateManager: widget(QWidget): The widget to load the state for. settings(QSettings): The QSettings object to load the state from. """ + if widget.property("skip_settings") is True: + return + meta = widget.metaObject() - settings.beginGroup(widget.objectName()) + widget_name = self._get_full_widget_name(widget) + settings.beginGroup(widget_name) for i in range(meta.propertyCount()): prop = meta.property(i) name = prop.name() @@ -95,13 +118,35 @@ class WidgetStateManager: widget.setProperty(name, value) settings.endGroup() - # Recursively load child widgets - for child in widget.findChildren(QWidget): - if child.objectName(): + # Recursively process children (only if they aren't skipped) + for child in widget.children(): + if ( + child.objectName() + and child.property("skip_settings") is not True + and not isinstance(child, QLabel) + ): self._load_widget_state_qsettings(child, settings) + def _get_full_widget_name(self, widget: QWidget): + """ + Get the full name of the widget including its parent names. -class ExampleApp(QWidget): # pragma: no cover + Args: + widget(QWidget): The widget to get the full name for. + + Returns: + str: The full name of the widget. + """ + name = widget.objectName() + parent = widget.parent() + while parent: + obj_name = parent.objectName() or parent.metaObject().className() + name = obj_name + "." + name + parent = parent.parent() + return name + + +class ExampleApp(QWidget): # pragma: no cover: def __init__(self): super().__init__() self.setObjectName("MainWindow") @@ -126,7 +171,34 @@ class ExampleApp(QWidget): # pragma: no cover self.check_box.setObjectName("MyCheckBox") layout.addWidget(self.check_box) - # Buttons to save and load state + # A checkbox that we want to skip + self.check_box_skip = QCheckBox("Enable feature - skip save?", self) + self.check_box_skip.setProperty("skip_state", True) + self.check_box_skip.setObjectName("MyCheckBoxSkip") + layout.addWidget(self.check_box_skip) + + # CREATE A "SIDE PANEL" with nested structure and skip all what is inside + self.side_panel = QWidget(self) + self.side_panel.setObjectName("SidePanel") + self.side_panel.setProperty("skip_settings", True) # skip the ENTIRE panel + layout.addWidget(self.side_panel) + + # Put some sub-widgets inside side_panel + panel_layout = QVBoxLayout(self.side_panel) + self.panel_label = QLabel("Label in side panel", self.side_panel) + self.panel_label.setObjectName("PanelLabel") + panel_layout.addWidget(self.panel_label) + + self.panel_edit = QLineEdit(self.side_panel) + self.panel_edit.setObjectName("PanelLineEdit") + self.panel_edit.setPlaceholderText("I am inside side panel") + panel_layout.addWidget(self.panel_edit) + + self.panel_checkbox = QCheckBox("Enable feature in side panel?", self.side_panel) + self.panel_checkbox.setObjectName("PanelCheckBox") + panel_layout.addWidget(self.panel_checkbox) + + # Save/Load buttons button_layout = QHBoxLayout() self.save_button = QPushButton("Save State", self) self.load_button = QPushButton("Load State", self) diff --git a/bec_widgets/widgets/containers/layout_manager/layout_manager.py b/bec_widgets/widgets/containers/layout_manager/layout_manager.py index a5238cca..f215e98f 100644 --- a/bec_widgets/widgets/containers/layout_manager/layout_manager.py +++ b/bec_widgets/widgets/containers/layout_manager/layout_manager.py @@ -34,6 +34,7 @@ class LayoutManagerWidget(QWidget): def __init__(self, parent=None, auto_reindex=True): super().__init__(parent) + self.setObjectName("LayoutManagerWidget") self.layout = QGridLayout(self) self.auto_reindex = auto_reindex diff --git a/bec_widgets/widgets/dap/lmfit_dialog/lmfit_dialog.py b/bec_widgets/widgets/dap/lmfit_dialog/lmfit_dialog.py index 6ee6063d..38f5e8ae 100644 --- a/bec_widgets/widgets/dap/lmfit_dialog/lmfit_dialog.py +++ b/bec_widgets/widgets/dap/lmfit_dialog/lmfit_dialog.py @@ -1,9 +1,10 @@ import os from bec_lib.logger import bec_logger -from qtpy.QtCore import Property, Signal, Slot +from qtpy.QtCore import Signal from qtpy.QtWidgets import QPushButton, QTreeWidgetItem, QVBoxLayout, QWidget +from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils import UILoader from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.colors import get_accent_colors @@ -43,6 +44,8 @@ class LMFitDialog(BECWidget, QWidget): """ super().__init__(client=client, config=config, gui_id=gui_id) QWidget.__init__(self, parent=parent) + self.setProperty("skip_settings", True) + self.setObjectName("LMFitDialog") self._ui_file = ui_file self.target_widget = target_widget @@ -65,7 +68,7 @@ class LMFitDialog(BECWidget, QWidget): @property def enable_actions(self) -> bool: - """Property to enable the move to buttons.""" + """SafeProperty to enable the move to buttons.""" return self._enable_actions @enable_actions.setter @@ -74,37 +77,37 @@ class LMFitDialog(BECWidget, QWidget): for button in self.action_buttons.values(): button.setEnabled(enable) - @Property(list) + @SafeProperty(list) def active_action_list(self) -> list[str]: - """Property to list the names of the fit parameters for which actions should be enabled.""" + """SafeProperty to list the names of the fit parameters for which actions should be enabled.""" return self._active_actions @active_action_list.setter def active_action_list(self, actions: list[str]): self._active_actions = actions - # This slot needed? - @Slot(bool) + # This SafeSlot needed? + @SafeSlot(bool) def set_actions_enabled(self, enable: bool) -> bool: - """Slot to enable the move to buttons. + """SafeSlot to enable the move to buttons. Args: enable (bool): Whether to enable the action buttons. """ self.enable_actions = enable - @Property(bool) + @SafeProperty(bool) def always_show_latest(self): - """Property to indicate if always the latest DAP update is displayed.""" + """SafeProperty to indicate if always the latest DAP update is displayed.""" return self._always_show_latest @always_show_latest.setter def always_show_latest(self, show: bool): self._always_show_latest = show - @Property(bool) + @SafeProperty(bool) def hide_curve_selection(self): - """Property for showing the curve selection.""" + """SafeProperty for showing the curve selection.""" return not self.ui.group_curve_selection.isVisible() @hide_curve_selection.setter @@ -116,9 +119,9 @@ class LMFitDialog(BECWidget, QWidget): """ self.ui.group_curve_selection.setVisible(not show) - @Property(bool) + @SafeProperty(bool) def hide_summary(self) -> bool: - """Property for showing the summary.""" + """SafeProperty for showing the summary.""" return not self.ui.group_summary.isVisible() @hide_summary.setter @@ -130,9 +133,9 @@ class LMFitDialog(BECWidget, QWidget): """ self.ui.group_summary.setVisible(not show) - @Property(bool) + @SafeProperty(bool) def hide_parameters(self) -> bool: - """Property for showing the parameters.""" + """SafeProperty for showing the parameters.""" return not self.ui.group_parameters.isVisible() @hide_parameters.setter @@ -146,7 +149,7 @@ class LMFitDialog(BECWidget, QWidget): @property def fit_curve_id(self) -> str: - """Property for the currently displayed fit curve_id.""" + """SafeProperty for the currently displayed fit curve_id.""" return self._fit_curve_id @fit_curve_id.setter @@ -159,7 +162,7 @@ class LMFitDialog(BECWidget, QWidget): self._fit_curve_id = curve_id self.selected_fit.emit(curve_id) - @Slot(str) + @SafeSlot(str) def remove_dap_data(self, curve_id: str): """Remove the DAP data for the given curve_id. @@ -169,7 +172,7 @@ class LMFitDialog(BECWidget, QWidget): self.summary_data.pop(curve_id, None) self.refresh_curve_list() - @Slot(str) + @SafeSlot(str) def select_curve(self, curve_id: str): """Select active curve_id in the curve list. @@ -178,7 +181,7 @@ class LMFitDialog(BECWidget, QWidget): """ self.fit_curve_id = curve_id - @Slot(dict, dict) + @SafeSlot(dict, dict) def update_summary_tree(self, data: dict, metadata: dict): """Update the summary tree with the given data. diff --git a/tests/unit_tests/test_widget_state_manager.py b/tests/unit_tests/test_widget_state_manager.py index 76989e5c..a2b79586 100644 --- a/tests/unit_tests/test_widget_state_manager.py +++ b/tests/unit_tests/test_widget_state_manager.py @@ -3,7 +3,7 @@ import tempfile import pytest from qtpy.QtCore import Property -from qtpy.QtWidgets import QLineEdit, QVBoxLayout, QWidget +from qtpy.QtWidgets import QCheckBox, QGroupBox, QLineEdit, QVBoxLayout, QWidget from bec_widgets.utils.widget_state_manager import WidgetStateManager @@ -23,6 +23,21 @@ class MyLineEdit(QLineEdit): self._customColor = color +# A specialized widget that has a property declared with stored=False +class MyLineEditStoredFalse(QLineEdit): + def __init__(self, parent=None): + super().__init__(parent) + self._noStoreProperty = "" + + @Property(str, stored=False) + def noStoreProperty(self): + return self._noStoreProperty + + @noStoreProperty.setter + def noStoreProperty(self, value): + self._noStoreProperty = value + + @pytest.fixture def test_widget(qtbot): w = QWidget() @@ -39,8 +54,15 @@ def test_widget(qtbot): child2.setText("World") child2.customColor = "blue" + # A widget that we want to skip settings + skip_widget = QCheckBox("Skip Widget", w) + skip_widget.setObjectName("SkipCheckBox") + skip_widget.setChecked(True) + skip_widget.setProperty("skip_settings", True) + layout.addWidget(child1) layout.addWidget(child2) + layout.addWidget(skip_widget) qtbot.addWidget(w) qtbot.waitExposed(w) @@ -51,7 +73,6 @@ def test_save_load_widget_state(test_widget): """ Test saving and loading the state """ - manager = WidgetStateManager(test_widget) # Before saving, confirm initial properties @@ -97,7 +118,6 @@ def test_save_load_without_filename(test_widget, monkeypatch, qtbot): """ Test that the dialog would open if filename is not provided. """ - manager = WidgetStateManager(test_widget) # Mock QFileDialog.getSaveFileName to return a temporary filename @@ -133,3 +153,130 @@ def test_save_load_without_filename(test_widget, monkeypatch, qtbot): # Clean up os.remove(tmp_filename) + + +def test_skip_settings(test_widget): + """ + Verify that a widget with skip_settings=True is not saved/loaded. + """ + manager = WidgetStateManager(test_widget) + + skip_checkbox = test_widget.findChild(QCheckBox, "SkipCheckBox") + # Double check initial state + assert skip_checkbox.isChecked() is True + + with tempfile.NamedTemporaryFile(delete=False, suffix=".ini") as tmp_file: + tmp_filename = tmp_file.name + + # Save state + manager.save_state(tmp_filename) + + # Change skip checkbox state + skip_checkbox.setChecked(False) + assert skip_checkbox.isChecked() is False + + # Load state + manager.load_state(tmp_filename) + + # The skip checkbox should not revert because it was never saved. + assert skip_checkbox.isChecked() is False + + os.remove(tmp_filename) + + +def test_property_stored_false(qtbot): + """ + Verify that a property with stored=False is not saved. + """ + w = QWidget() + w.setObjectName("TestStoredFalse") + layout = QVBoxLayout(w) + + stored_false_widget = MyLineEditStoredFalse(w) + stored_false_widget.setObjectName("NoStoreLineEdit") + stored_false_widget.setText("VisibleText") # normal text property is stored + stored_false_widget.noStoreProperty = "ShouldNotBeStored" + layout.addWidget(stored_false_widget) + + qtbot.addWidget(w) + qtbot.waitExposed(w) + + manager = WidgetStateManager(w) + + with tempfile.NamedTemporaryFile(delete=False, suffix=".ini") as tmp_file: + tmp_filename = tmp_file.name + + # Save the current state + manager.save_state(tmp_filename) + + # Modify the properties + stored_false_widget.setText("ChangedText") + stored_false_widget.noStoreProperty = "ChangedNoStore" + + # Load the previous state + manager.load_state(tmp_filename) + + # The text should have reverted + assert stored_false_widget.text() == "VisibleText" + # The noStoreProperty should remain changed, as it was never saved. + assert stored_false_widget.noStoreProperty == "ChangedNoStore" + + os.remove(tmp_filename) + + +def test_skip_parent_settings(qtbot): + """ + Demonstrates that if a PARENT widget has skip_settings=True, all its + children (even if they do NOT have skip_settings=True) also get skipped. + """ + main_widget = QWidget() + main_widget.setObjectName("TopWidget") + layout = QVBoxLayout(main_widget) + + # Create a parent widget with skip_settings=True + parent_group = QGroupBox("ParentGroup", main_widget) + parent_group.setObjectName("ParentGroupBox") + parent_group.setProperty("skip_settings", True) # The crucial setting + + child_layout = QVBoxLayout(parent_group) + + child_line_edit_1 = MyLineEdit(parent_group) + child_line_edit_1.setObjectName("ChildLineEditA") + child_line_edit_1.setText("OriginalA") + + child_line_edit_2 = MyLineEdit(parent_group) + child_line_edit_2.setObjectName("ChildLineEditB") + child_line_edit_2.setText("OriginalB") + + child_layout.addWidget(child_line_edit_1) + child_layout.addWidget(child_line_edit_2) + parent_group.setLayout(child_layout) + + layout.addWidget(parent_group) + main_widget.setLayout(layout) + + qtbot.addWidget(main_widget) + qtbot.waitExposed(main_widget) + + manager = WidgetStateManager(main_widget) + + # Create a temp file to hold settings + with tempfile.NamedTemporaryFile(delete=False, suffix=".ini") as tmp_file: + tmp_filename = tmp_file.name + + # Save the state + manager.save_state(tmp_filename) + + # Change child widget values + child_line_edit_1.setText("ChangedA") + child_line_edit_2.setText("ChangedB") + + # Load state + manager.load_state(tmp_filename) + + # Because the PARENT has skip_settings=True, none of its children get saved or loaded + # Hence, the changes remain and do NOT revert + assert child_line_edit_1.text() == "ChangedA" + assert child_line_edit_2.text() == "ChangedB" + + os.remove(tmp_filename)