LCOV - code coverage report
Current view: top level - src/pairinteraction_gui/theme - __init__.py (source / functions) Coverage Total Hit
Test: coverage.info Lines: 32.1 % 106 34
Test Date: 2026-11-02 09:44:19 Functions: 37.5 % 8 3

            Line data    Source code
       1              : # SPDX-FileCopyrightText: 2025 PairInteraction Developers
       2              : # SPDX-License-Identifier: LGPL-3.0-or-later
       3            1 : from __future__ import annotations
       4              : 
       5            1 : import importlib.util
       6            1 : import logging
       7            1 : import sys
       8            1 : from pathlib import Path
       9            1 : from typing import TYPE_CHECKING
      10              : 
      11            1 : from PySide6.QtCore import QFileSystemWatcher, QObject, QTimer, Signal
      12              : 
      13            1 : from pairinteraction_gui.theme.palette import build_application_palette
      14              : 
      15              : if TYPE_CHECKING:
      16              :     from types import ModuleType
      17              : 
      18              :     from PySide6.QtGui import QPalette
      19              : 
      20            1 : logger = logging.getLogger(__name__)
      21              : 
      22            1 : _THEME_FILE_NAME = "theme.qss"
      23            1 : _PALETTE_FILE_NAME = "palette.py"
      24              : 
      25              : 
      26            1 : class ThemeSignals(QObject):
      27              :     """Signals emitted when theme files change."""
      28              : 
      29            1 :     themes_reloaded = Signal()
      30              : 
      31              : 
      32            1 : class ThemeManager(QObject):
      33              :     """Load theme files and optionally watch them for development reloads."""
      34              : 
      35            1 :     def __init__(self, theme_dir: Path | None = None) -> None:
      36            1 :         super().__init__()
      37            1 :         self.theme_dir = theme_dir or Path(__file__).parent
      38            1 :         self._theme = (self.theme_dir / _THEME_FILE_NAME).read_text()
      39            1 :         self._palette = build_application_palette()
      40              : 
      41              :         # Variables for hot reload functionality, initialized when hot reload is enabled
      42            1 :         self._palette_source: str | None = None
      43            1 :         self._watcher: QFileSystemWatcher | None = None
      44            1 :         self._reload_timer: QTimer | None = None
      45              : 
      46              :         # Signal for hot reload notifications
      47            1 :         self.signals = ThemeSignals(self)
      48              : 
      49            1 :     def get_theme(self) -> str:
      50              :         """Return the current stylesheet content."""
      51            1 :         return self._theme
      52              : 
      53            1 :     def get_palette(self) -> QPalette:
      54              :         """Return the configured palette."""
      55            1 :         return self._palette
      56              : 
      57            1 :     def reload(self) -> None:
      58              :         """Reload the stylesheet and palette configuration from disk."""
      59            0 :         try:
      60            0 :             updated_theme = (self.theme_dir / _THEME_FILE_NAME).read_text()
      61            0 :         except OSError:
      62            0 :             logger.debug("Theme reload deferred because a theme file is temporarily unavailable")
      63            0 :             if self._watcher is not None and self._reload_timer is not None:
      64            0 :                 self._reload_timer.start()
      65            0 :             return
      66              : 
      67            0 :         try:
      68            0 :             updated_palette_source = (self.theme_dir / _PALETTE_FILE_NAME).read_text()
      69            0 :             palette_module = self._load_palette_module(self.theme_dir / _PALETTE_FILE_NAME, updated_palette_source)
      70            0 :             updated_palette = palette_module.build_application_palette()
      71            0 :         except Exception:
      72            0 :             logger.exception("Theme reload deferred because the palette configuration is unavailable or invalid")
      73            0 :             if self._watcher is not None and self._reload_timer is not None:
      74            0 :                 self._reload_timer.start()
      75            0 :             return
      76              : 
      77            0 :         self._refresh_watched_paths()
      78              : 
      79            0 :         updated = False
      80              : 
      81            0 :         if updated_palette_source != self._palette_source:
      82            0 :             self._palette_source = updated_palette_source
      83            0 :             self._palette = updated_palette
      84            0 :             updated = True
      85              : 
      86            0 :         if updated_theme != self._theme:
      87            0 :             self._theme = updated_theme
      88            0 :             updated = True
      89              : 
      90            0 :         if updated:
      91            0 :             logger.info("Reloaded GUI theme files from %s", self.theme_dir)
      92            0 :             self.signals.themes_reloaded.emit()
      93              : 
      94            1 :     def enable_hot_reload(self) -> None:
      95              :         """Watch the theme directory and reload stylesheets when files change."""
      96            0 :         if self._palette_source is None:
      97            0 :             self._palette_source = (self.theme_dir / _PALETTE_FILE_NAME).read_text()
      98              : 
      99            0 :         if self._watcher is None:
     100            0 :             self._watcher = QFileSystemWatcher(self)
     101            0 :             self._watcher.fileChanged.connect(self._schedule_reload)
     102            0 :             self._watcher.directoryChanged.connect(self._schedule_reload)
     103              : 
     104            0 :         if self._reload_timer is None:
     105            0 :             self._reload_timer = QTimer(self)
     106            0 :             self._reload_timer.setSingleShot(True)
     107            0 :             self._reload_timer.setInterval(100)
     108            0 :             self._reload_timer.timeout.connect(self.reload)
     109              : 
     110            0 :         self._refresh_watched_paths()
     111            0 :         logger.info("Enabled GUI theme hot reload for development")
     112              : 
     113            1 :     def _schedule_reload(self, _path: str) -> None:
     114            0 :         if self._reload_timer is None:
     115            0 :             return
     116              : 
     117            0 :         self._reload_timer.start()
     118              : 
     119            1 :     def _refresh_watched_paths(self) -> None:
     120            0 :         if self._watcher is None:
     121            0 :             return
     122              : 
     123            0 :         file_paths = [str(self.theme_dir / _THEME_FILE_NAME), str(self.theme_dir / _PALETTE_FILE_NAME)]
     124            0 :         watched_files = set(self._watcher.files())
     125            0 :         watched_directories = set(self._watcher.directories())
     126            0 :         expected_directory = str(self.theme_dir)
     127              : 
     128            0 :         missing_files = [path for path in file_paths if path not in watched_files and Path(path).exists()]
     129            0 :         stale_files = [path for path in watched_files if path not in file_paths]
     130            0 :         missing_directory = expected_directory not in watched_directories and self.theme_dir.exists()
     131            0 :         stale_directories = [path for path in watched_directories if path != expected_directory]
     132              : 
     133            0 :         if stale_files:
     134            0 :             self._watcher.removePaths(stale_files)
     135            0 :         if stale_directories:
     136            0 :             self._watcher.removePaths(stale_directories)
     137            0 :         if missing_files:
     138            0 :             self._watcher.addPaths(missing_files)
     139            0 :         if missing_directory:
     140            0 :             self._watcher.addPath(expected_directory)
     141              : 
     142            1 :     @staticmethod
     143            1 :     def _load_palette_module(module_path: Path, source: str) -> ModuleType:
     144            0 :         module_name = "pairinteraction_gui_theme_palette_runtime"
     145            0 :         spec = importlib.util.spec_from_file_location(module_name, module_path)
     146            0 :         if spec is None or spec.loader is None:
     147            0 :             raise ImportError(f"Could not load palette module from {module_path}")
     148              : 
     149            0 :         module = importlib.util.module_from_spec(spec)
     150            0 :         sys.modules[module_name] = module
     151            0 :         try:
     152            0 :             spec.loader.exec_module(module)
     153            0 :             return module
     154              :         finally:
     155            0 :             sys.modules.pop(module_name, None)
     156              : 
     157              : 
     158            1 : theme_manager = ThemeManager()
        

Generated by: LCOV version 2.0-1