import numbers
from pathlib import Path
from typing import List, Union, Dict, Optional, Tuple, Any
from qtpy import QtWidgets, QtCore, QtGui
from pymodaq_gui.parameter.utils import filter_parameter_tree
from pymodaq_gui.utils.widgets.collapsible_widget import CollapsibleWidget
from pymodaq_gui.utils.widgets.search_lineedit import SearchLineEdit
from pymodaq_gui.managers.action_manager import ActionManager
from pymodaq_gui.parameter import Parameter, ParameterTree, ioxml, utils
from pymodaq_gui.utils.file_io import select_file
from pymodaq_utils.config import get_set_config_dir
from pymodaq_utils.logger import set_logger, get_module_name
logger = set_logger(get_module_name(__file__))
[docs]
class ParameterManager:
"""Class dealing with Parameter and ParameterTree management.
This class provides a complete parameter management system with support for
saving, loading, and updating parameters from XML files. It also includes
search functionality and callback methods for responding to parameter changes.
Parameters
----------
settings_name : str, optional
The name to assign to the root Parameter object. If None, uses the
class attribute 'settings_name'. Default is None.
action_list : tuple, optional
Tuple of action names to include in the toolbar. Valid values are:
'search', 'save', 'update', and 'load'.
Default is ('search', 'save', 'update', 'load').
tree: ParameterTree
Allow the use of modified ParameterTree (allowing drag/drop for instance)
Attributes
----------
params : list of dicts
Class attribute defining the Parameter tree structure. Should be overridden
in subclasses to define the specific parameter hierarchy.
settings_name : str
The particular name given to the root Parameter object (self.settings)
settings : Parameter
The root Parameter object containing all parameter definitions
settings_tree : QWidget
Widget holding a ParameterTree and a toolbar for interacting with the tree
tree : ParameterTree
The underlying ParameterTree widget for displaying parameters
Examples
--------
>>> class MyManager(ParameterManager):
... settings_name = 'my_settings'
... params = [
... {'title': 'Main:', 'name': 'main_settings', 'type': 'group', 'children': [
... {'title': 'Value:', 'name': 'value', 'type': 'int', 'value': 0},
... ]},
... ]
...
... def value_changed(self, param):
... if param.name() == 'value':
... print(f'Value changed to: {param.value()}')
"""
settings_name = "custom_settings"
params = []
def __init__(
self,
settings_name: Optional[str] = None,
action_list: tuple = ("search", "save", "update", "load"),
tree: ParameterTree = None
):
self._current_filter_text = ""
if settings_name is None:
settings_name = self.settings_name
# create a settings tree to be shown eventually in a dock
# object containing the settings defined in the preamble
# create a settings tree to be shown eventually in a dock
self._settings_tree = ParameterTreeWidget(action_list, tree)
self._settings_tree.get_action(f"save_settings").connect_to(
self.save_settings_slot
)
self._settings_tree.get_action(f"update_settings").connect_to(
self.update_settings_slot
)
self._settings_tree.get_action(f"load_settings").connect_to(
self.load_settings_slot
)
# Add this line to connect the search widget
if "search" in action_list:
self._settings_tree.get_action("search_settings").searchTextChanged.connect(
self.search_settings_slot
)
self._settings_tree.collapsible_widget.toggled_signal.connect(
self.on_toolbar_toggled
)
self.settings = Parameter.create(
name=settings_name, type="group", children=self.params, showTop=False
) # create a Parameter
# object containing the settings defined in the preamble
self._settings_tree.tree.header().setSectionResizeMode(
QtWidgets.QHeaderView.ResizeToContents
)
@property
def settings_tree(self) -> QtWidgets.QWidget:
"""QWidget: The main widget containing the parameter tree and toolbar."""
return self._settings_tree.widget
@property
def tree(self) -> ParameterTree:
"""ParameterTree: The underlying parameter tree widget."""
return self._settings_tree.tree
@property
def settings(self) -> Parameter:
"""Parameter: The root parameter object containing all settings."""
return self._settings
@settings.setter
def settings(self, settings: Union[Parameter, List[Dict[str, str]], Path]):
"""Set the settings parameter from various input types.
Parameters
----------
settings : Parameter, list of dict, or Path
The settings to load. Can be:
- A Parameter object
- A list of dictionaries defining parameter structure
- A Path to an XML file containing saved parameters
"""
settings = self.create_parameter(settings)
self._settings = settings
self.tree.setParameters(
self._settings, showTop=False
) # load the tree with this parameter object
self._settings.sigTreeStateChanged.connect(self.parameter_tree_changed)
[docs]
@staticmethod
def create_parameter(
settings: Union[Parameter, List[Dict[str, str]], Path],
) -> Parameter:
"""Create a Parameter object from various input types.
Parameters
----------
settings : Parameter, list of dict, Path, or str
The settings to convert. Can be:
- A Parameter object (creates a copy)
- A list of dictionaries defining parameter structure
- A Path or string pointing to an XML file with saved parameters
Returns
-------
Parameter
A new Parameter object created from the input settings
Raises
------
TypeError
If settings is not one of the supported types
Examples
--------
>>> params_list = [{'title': 'Value', 'name': 'val', 'type': 'int', 'value': 5}]
>>> param = ParameterManager.create_parameter(params_list)
>>> print(param.child('val').value())
5
"""
if isinstance(settings, List):
_settings = Parameter.create(
title="Settings",
name="settings",
type="group",
children=settings,
showTop=False,
)
elif isinstance(settings, Path) or isinstance(settings, str):
settings = Path(settings)
_settings = Parameter.create(
title="Settings",
name="settings",
type="group",
showTop=False,
children=ioxml.XML_file_to_parameter(str(settings)),
)
elif isinstance(settings, Parameter):
_settings = Parameter.create(
title="Settings", name=settings.name(), type="group", showTop=False
)
_settings.restoreState(settings.saveState())
else:
raise TypeError(f"Cannot create Parameter object from {settings}")
return _settings
[docs]
def parameter_tree_changed(self, param, changes):
"""Handle changes in the parameter tree and dispatch to specific handlers.
This method is called whenever any change occurs in the parameter tree.
It processes the changes and calls the appropriate handler method based
on the type of change.
Parameters
----------
param : Parameter
The parameter object that emitted the change signal
changes : list of tuple
List of changes, where each change is a tuple of
(parameter, change_type, data)
Notes
-----
The following change types are handled:
- 'childAdded': A new child parameter was added
- 'value': A parameter value was changed
- 'parent': A parameter was removed (parent changed to None)
- 'options': Parameter options were modified
- 'limits': Parameter limits were changed
"""
for param, change, data in changes:
path = self._settings.childPath(param)
if change == "childAdded":
self.child_added(param, data)
elif change == "value":
self.value_changed(param)
elif change == "parent":
self.param_deleted(param)
elif change == "options":
self.options_changed(param, data)
elif change == "limits":
self.limits_changed(param, data)
[docs]
def value_changed(self, param: Parameter):
"""Non-mandatory method to be subclassed for actions to perform when a parameter value changes.
This method is called automatically when a parameter's value is changed using
the setValue() method. Override this method in subclasses to implement
custom behavior in response to value changes.
Parameters
----------
param : Parameter
The parameter whose value has just changed
Examples
--------
>>> def value_changed(self, param):
... if param.name() == 'enable_feature':
... if param.value():
... print('Feature enabled')
... self.settings.child('status', 'ready').setValue(True)
... else:
... print('Feature disabled')
Notes
-----
For this method to be triggered, changes must be made using the Parameter.setValue()
method, not by direct attribute assignment.
"""
...
[docs]
def child_added(self, param: Parameter, data: Parameter):
"""Non-mandatory method to be subclassed for actions to perform when a child parameter is added.
This method is called automatically when a new child parameter is added to the
parameter tree. Override this method in subclasses to implement custom behavior
in response to parameter additions.
Parameters
----------
param : Parameter
The parent parameter to which the child is being added
data : Parameter
The child parameter that was added
Examples
--------
>>> def child_added(self, param, data):
... if param.name() == 'dynamic_list':
... print(f'New item added: {data.name()}')
... self.update_item_count()
Notes
-----
For this method to be triggered, one of the following Parameter methods must be used:
- addChild()
- addChildren()
- insertChildren()
"""
pass
[docs]
def param_deleted(self, param: Parameter):
"""Non-mandatory method to be subclassed for actions to perform when a parameter is deleted.
This method is called automatically when a parameter is removed from the
parameter tree. Override this method in subclasses to implement custom
cleanup or notification behavior.
Parameters
----------
param : Parameter
The parameter that has been deleted from the tree
Examples
--------
>>> def param_deleted(self, param):
... if param.name() == 'temporary_setting':
... print(f'Temporary setting {param.name()} was removed')
... self.cleanup_related_resources(param)
Notes
-----
For this method to be triggered, the Parameter.removeChild() method must be used.
"""
pass
[docs]
def options_changed(self, param: Parameter, data: Dict[str, Any]):
"""Non-mandatory method to be subclassed for actions to perform when parameter options change.
This method is called automatically when options of a parameter are modified
using the setOpts() method. Override this method in subclasses to respond
to option changes such as visibility, enabled state, or other properties.
Parameters
----------
param : Parameter
The parameter whose options have been changed
data : dict
Dictionary where keys are option names (strings) and values are the
new option values. Common options include 'visible', 'enabled', 'readonly', etc.
Examples
--------
>>> def options_changed(self, param, data):
... if param.name() == 'advanced_mode' and 'visible' in data:
... if data['visible']:
... print('Advanced options are now visible')
... else:
... print('Advanced options are now hidden')
Notes
-----
For this method to be triggered, the Parameter.setOpts() method must be used.
"""
pass
[docs]
def limits_changed(
self, param: Parameter, data: Tuple[numbers.Number, numbers.Number]
):
"""Non-mandatory method to be subclassed for actions to perform when parameter limits change.
This method is called automatically when the limits (min/max bounds) of a
parameter are changed. Override this method in subclasses to respond to
limit changes, such as validating dependent parameters or updating the UI.
Parameters
----------
param : Parameter
The parameter whose limits have been changed
data : tuple of numbers
Tuple containing (min_limit, max_limit). For numeric parameters, these
are typically float or int values. For specialized parameters, could be
other comparable objects.
Examples
--------
>>> def limits_changed(self, param, data):
... if param.name() == 'temperature':
... min_temp, max_temp = data
... print(f'Temperature range updated: {min_temp}°C to {max_temp}°C')
... self.validate_current_temperature()
Notes
-----
For this method to be triggered, the Parameter.setLimits() method must be used.
"""
pass
[docs]
def save_settings_slot(self, file_path: Path = None):
"""Save the current settings to an XML file.
Opens a file dialog for the user to select a save location, or uses the
provided file path. The settings are serialized to XML format and saved
to disk.
Parameters
----------
file_path : Path, optional
Path where the settings should be saved. If None or False, opens a
file dialog for the user to select a location. The file extension
must be '.xml'. Default is None.
Notes
-----
The starting directory for the file dialog is the user's config folder
with a 'settings' subfolder. The file is automatically given a .xml
extension if not already present.
Examples
--------
>>> manager = ParameterManager()
>>> # Interactive save
>>> manager.save_settings_slot()
>>> # Programmatic save
>>> manager.save_settings_slot(Path('my_settings.xml'))
"""
if file_path is None or file_path is False:
file_path = select_file(
get_set_config_dir("settings", user=True),
save=True,
ext="xml",
filter="*.xml",
force_save_extension=True,
)
else:
file_path = Path(file_path)
if ".xml" != file_path.suffix:
return
if file_path:
ioxml.parameter_to_xml_file(self.settings, file_path.resolve())
logger.info(f"The settings have been successfully saved at {file_path}")
def _get_settings_from_file(self):
"""Open a file dialog to select an XML settings file.
Returns
-------
Path or None
Path to the selected XML file, or None if the dialog was cancelled
Notes
-----
The file dialog starts in the user's config folder with a 'settings' subfolder.
"""
return select_file(
get_set_config_dir("settings", user=True),
save=False,
ext="xml",
filter="*.xml",
force_save_extension=True,
)
[docs]
def load_settings_slot(self, file_path: Path = None):
"""Load settings from an XML file, replacing current settings entirely.
Opens a file dialog for the user to select a file, or uses the provided
file path. The current parameter tree structure is completely replaced
with the loaded settings.
Parameters
----------
file_path : Path, optional
Path to the XML file containing saved settings. If None or False,
opens a file dialog for the user to select a file. Default is None.
Notes
-----
The starting directory for the file dialog is the user's config folder
with a 'settings' subfolder. This method completely replaces the current
settings structure, unlike update_settings_slot() which requires matching
structure.
Warning
-------
This operation replaces all current settings. Any unsaved changes will be lost.
Examples
--------
>>> manager = ParameterManager()
>>> # Interactive load
>>> manager.load_settings_slot()
>>> # Programmatic load
>>> manager.load_settings_slot(Path('saved_settings.xml'))
See Also
--------
update_settings_slot : Update settings while preserving structure
save_settings_slot : Save current settings to file
"""
if file_path is None or file_path is False:
file_path = self._get_settings_from_file()
if file_path:
self.settings = file_path.resolve()
logger.info(f"The settings from {file_path} have been successfully loaded")
[docs]
def update_settings_slot(self, file_path: Path = None):
"""Update settings from an XML file with matching structure validation.
Opens a file dialog for the user to select a file, or uses the provided
file path. The loaded settings must have the same structure (parameter names
and hierarchy) as the current settings. Only the values are updated, not
the structure.
Parameters
----------
file_path : Path, optional
Path to the XML file containing settings to apply. If None or False,
opens a file dialog for the user to select a file. Default is None.
Notes
-----
The starting directory for the file dialog is the user's config folder
with a 'settings' subfolder. The loaded settings must have identical
structure (same parameter names and children) as the current settings,
otherwise the update is rejected with a warning message.
Examples
--------
>>> manager = ParameterManager()
>>> # Interactive update
>>> manager.update_settings_slot()
>>> # Programmatic update
>>> manager.update_settings_slot(Path('compatible_settings.xml'))
See Also
--------
load_settings_slot : Load settings without structure validation
save_settings_slot : Save current settings to file
"""
if file_path is None or file_path is False:
file_path = self._get_settings_from_file()
if file_path:
_settings = self.create_parameter(file_path.resolve())
# Checking if both parameters have the same structure
sameStruct = utils.compareStructureParameter(self.settings, _settings)
if sameStruct: # Update if true
self.settings = _settings
logger.info(
f"The settings from {file_path} have been successfully applied"
)
else:
logger.info(
f"The loaded settings from {file_path} do not match the current settings structure and cannot be applied."
)
def _apply_filter(self, text: str):
"""Apply search filter to the parameter tree with optimized updates.
Parameters
----------
text : str
The search text to filter parameters by. Empty string shows all parameters.
Notes
-----
Uses a tree change blocker to batch UI updates for better performance when
filtering large parameter trees.
"""
with self.settings.treeChangeBlocker():
# Filter each child independently to avoid calling show() on the root parameter
# This prevents issues with the root parameter name when showTop=False
for child in self.settings.children():
filter_parameter_tree(child, text)
[docs]
def search_settings_slot(self, text: str = ""):
"""Handle search text changes and filter the parameter tree.
This slot is connected to the search widget's text changed signal. It stores
the current search text and applies the filter to show only matching parameters.
Parameters
----------
text : str, optional
The search text to filter parameters by. Empty string shows all
parameters. Default is "".
Notes
-----
The search is typically case-insensitive and matches against parameter
names and titles.
"""
self._current_filter_text = text
self._apply_filter(text)