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()