Line data Source code
1 : # SPDX-FileCopyrightText: 2026 PairInteraction Developers
2 : # SPDX-License-Identifier: LGPL-3.0-or-later
3 1 : from __future__ import annotations
4 :
5 1 : import logging
6 1 : from typing import TYPE_CHECKING, ClassVar
7 :
8 1 : from PySide6.QtCore import QObject, QSettings
9 1 : from PySide6.QtWidgets import (
10 : QCheckBox,
11 : QComboBox,
12 : QDoubleSpinBox,
13 : QRadioButton,
14 : QSpinBox,
15 : QStackedWidget,
16 : QWidget,
17 : )
18 :
19 1 : from pairinteraction import _backend
20 1 : from pairinteraction_gui.config.base_config import BaseConfig
21 :
22 : if TYPE_CHECKING:
23 : from pathlib import Path
24 :
25 :
26 1 : logger = logging.getLogger(__name__)
27 :
28 :
29 1 : class SettingsManager(QObject):
30 : """Settings manager."""
31 :
32 1 : widget_mappers: ClassVar[dict[type, tuple[str, str, type]]] = {
33 : QCheckBox: ("isChecked", "setChecked", bool),
34 : QSpinBox: ("value", "setValue", int),
35 : QDoubleSpinBox: ("value", "setValue", float),
36 : QRadioButton: ("isChecked", "setChecked", bool),
37 : QComboBox: ("currentText", "setCurrentText", str),
38 : }
39 :
40 1 : def __init__(self, cache_dir: Path | None = None) -> None:
41 1 : super().__init__()
42 1 : if cache_dir is None:
43 0 : cache_dir = _backend.get_cache_directory()
44 1 : path = cache_dir / "gui_settings.ini"
45 1 : path.parent.mkdir(parents=True, exist_ok=True)
46 1 : self._settings = QSettings(str(path), QSettings.Format.IniFormat)
47 : # Values already handed to QSettings, keyed by their full path. Used to skip redundant writes,
48 : # since QSettings.setValue marks the store dirty even when the value did not change.
49 1 : self._written: dict[str, object] = {}
50 1 : self._dirty = False
51 :
52 1 : def value(self, key: str, default: object = None, value_type: type | None = None) -> object:
53 : """Read a stored value."""
54 1 : if value_type is not None:
55 1 : return self._settings.value(key, defaultValue=default, type=value_type)
56 0 : return self._settings.value(key, defaultValue=default)
57 :
58 1 : def set_value(self, key: str, value: object) -> None:
59 : """Store a value.
60 :
61 : Writing a value that is already stored is skipped, since QSettings.setValue marks the store dirty even when
62 : the value did not change, which would make the periodic autosave rewrite the whole ini file.
63 : """
64 : # Resolve `key` against the group that is currently open, as QSettings itself would.
65 1 : group = self._settings.group()
66 1 : full_key = f"{group}/{key}" if group else key
67 :
68 1 : if full_key in self._written and self._written[full_key] == value:
69 1 : return
70 1 : self._settings.setValue(key, value)
71 1 : self._written[full_key] = value
72 1 : self._dirty = True
73 :
74 1 : def sync(self) -> bool:
75 : """Flush pending changes to disk, if there are any.
76 :
77 : Returns True if there were unflushed changes (note that Qt may have flushed them already on its own).
78 : Skipping the flush when nothing changed keeps a periodic autosave from rewriting the whole ini file.
79 : """
80 1 : if not self._dirty:
81 1 : return False
82 1 : self._settings.sync()
83 1 : self._dirty = False
84 1 : return True
85 :
86 1 : def _get_mapper(self, widget: QWidget) -> tuple[str, str, type] | tuple[None, None, None]:
87 1 : return next((m for c, m in self.widget_mappers.items() if isinstance(widget, c)), (None, None, None))
88 :
89 1 : def update_widgets_from_settings(self, widget_map: dict[str, QWidget], *, combos_only: bool = False) -> None:
90 : """Set widget states from stored settings values."""
91 1 : for name, widget in widget_map.items():
92 1 : if combos_only and not isinstance(widget, QComboBox):
93 1 : continue
94 :
95 1 : getter, setter, dtype = self._get_mapper(widget)
96 1 : if not getter:
97 0 : continue
98 :
99 1 : value = getattr(widget, getter)()
100 1 : stored = self.value(name, value, dtype)
101 1 : if stored is None:
102 0 : continue
103 :
104 1 : if setter:
105 1 : try:
106 1 : getattr(widget, setter)(stored)
107 0 : except Exception as e:
108 0 : logger.warning("Failed to restore setting '%s' with value '%s': %s", name, stored, e)
109 :
110 1 : def update_settings_from_widgets(self, widget_map: dict[str, QWidget]) -> None:
111 : """Save widget states into settings."""
112 1 : for name, widget in widget_map.items():
113 1 : getter, _setter, _dtype = self._get_mapper(widget)
114 1 : if getter:
115 1 : value = getattr(widget, getter)()
116 1 : if value is not None:
117 1 : self.set_value(name, value)
118 :
119 1 : def save_widget_state(self, root: QWidget, group: str) -> None:
120 : """Write the current state of all named input widgets under `group`."""
121 1 : if not isinstance(root, BaseConfig):
122 0 : return
123 1 : widget_map = self.collect_widgets(root)
124 1 : self._settings.beginGroup(group)
125 1 : self.update_settings_from_widgets(widget_map)
126 1 : self._settings.endGroup()
127 :
128 1 : def restore_widget_state(self, root: QWidget, group: str) -> None:
129 : """Restore widget state (two-pass: combos first, then others)."""
130 1 : if not isinstance(root, BaseConfig):
131 0 : return
132 1 : widget_map = self.collect_widgets(root)
133 1 : self._settings.beginGroup(group)
134 1 : self.update_widgets_from_settings(widget_map, combos_only=True)
135 1 : widget_map = self.collect_widgets(root)
136 1 : self.update_widgets_from_settings(widget_map, combos_only=False)
137 1 : self._settings.endGroup()
138 :
139 1 : def collect_widgets(self, root: QWidget, widget_map: dict[str, QWidget] | None = None) -> dict[str, QWidget]:
140 1 : if widget_map is None:
141 1 : widget_map = {}
142 1 : for child in root.children():
143 1 : if not isinstance(child, QWidget):
144 1 : continue
145 1 : name = child.objectName()
146 1 : if name and any(isinstance(child, c) for c in self.widget_mappers):
147 1 : if name in widget_map:
148 0 : logger.warning("Duplicate widget name '%s' found. Only the last one will be saved/restored.", name)
149 1 : widget_map[name] = child
150 :
151 : # For stacked widgets, only recurse into the currently shown page
152 1 : if isinstance(child, QStackedWidget):
153 1 : current = child.currentWidget()
154 1 : if current is not None:
155 1 : self.collect_widgets(current, widget_map)
156 : else:
157 1 : self.collect_widgets(child, widget_map)
158 :
159 1 : return widget_map
|