LCOV - code coverage report
Current view: top level - src/pairinteraction_gui - settings.py (source / functions) Coverage Total Hit
Test: coverage.info Lines: 90.5 % 95 86
Test Date: 2026-11-02 09:44:19 Functions: 100.0 % 10 10

            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
        

Generated by: LCOV version 2.0-1