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

          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 1.16