0
0
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:
2025-01-27 22:09:09 +01:00
parent 12e06fa971
commit b2b0450bcb
6 changed files with 261 additions and 35 deletions

View File

@ -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()

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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.

View File

@ -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)