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