mirror of
https://github.com/bec-project/bec_widgets.git
synced 2025-07-13 19:21:50 +02:00
fix(widget_state_manager): skip QLabel saving; skip_setting property widget excluded from INI; stored=False property excluded from INI
This commit is contained in:
@ -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()
|
||||
|
||||
|
@ -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"<b>{title}</b>")
|
||||
title_label.setStyleSheet("font-size: 16px;")
|
||||
spacer = QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding)
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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)
|
||||
|
Reference in New Issue
Block a user