Coverage for src / turbo_themes / manager.py: 94%
100 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-02-02 07:11 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-02-02 07:11 +0000
1"""Theme manager for Turbo Themes.
3Provides high-level utilities for managing themes, applying them to applications,
4and handling theme switching.
5"""
7from __future__ import annotations
9from typing import Any
10import json
11from dataclasses import dataclass
12from .themes import THEMES
13from .models import Tokens, ThemeValue
14from .css_variables import generate_css_variables
17@dataclass
18class ThemeInfo:
19 """Information about a theme."""
21 id: str
22 name: str
23 vendor: str
24 appearance: str
25 tokens: Tokens
27 @classmethod
28 def from_theme_value(cls, theme_value: ThemeValue) -> ThemeInfo:
29 """Create ThemeInfo from a ThemeValue object.
31 Args:
32 theme_value: Quicktype-generated ThemeValue object.
34 Returns:
35 Parsed ThemeInfo instance.
36 """
37 return cls(
38 id=theme_value.id,
39 name=theme_value.label,
40 vendor=theme_value.vendor,
41 appearance=theme_value.appearance.value,
42 tokens=theme_value.tokens,
43 )
46class ThemeManager:
47 """Manages theme switching and application.
49 Args:
50 default_theme: Theme ID to load initially.
52 Raises:
53 ValueError: If the requested default theme is missing.
54 """
56 def __init__(self, default_theme: str = "catppuccin-mocha"):
57 self._current_theme_id = default_theme
58 self._themes: dict[str, ThemeInfo] = {}
60 # Pre-computed filter caches for O(1) lookup
61 self._by_appearance: dict[str, dict[str, ThemeInfo]] = {"light": {}, "dark": {}}
62 self._by_vendor: dict[str, dict[str, ThemeInfo]] = {}
64 # Load themes from Quicktype-generated ThemeValue objects
65 for theme_id, theme_value in THEMES.items():
66 theme_info = ThemeInfo.from_theme_value(theme_value)
67 self._themes[theme_id] = theme_info
69 # Build filter caches
70 if theme_info.appearance in self._by_appearance: 70 ↛ 72line 70 didn't jump to line 72 because the condition on line 70 was always true
71 self._by_appearance[theme_info.appearance][theme_id] = theme_info
72 self._by_vendor.setdefault(theme_info.vendor, {})[theme_id] = theme_info
74 # Validate default theme exists
75 if default_theme not in self._themes:
76 available = list(self._themes.keys())
77 raise ValueError(
78 f"Default theme '{default_theme}' not found. Available: {available}"
79 )
81 @property
82 def current_theme(self) -> ThemeInfo:
83 """Get the current theme.
85 Returns:
86 The current active theme.
87 """
88 return self._themes[self._current_theme_id]
90 @property
91 def current_theme_id(self) -> str:
92 """Get the current theme ID.
94 Returns:
95 The ID of the current active theme.
96 """
97 return self._current_theme_id
99 @property
100 def available_themes(self) -> dict[str, ThemeInfo]:
101 """Get all available themes.
103 Returns:
104 A dictionary of all available themes, keyed by their IDs.
105 """
106 return self._themes.copy()
108 def set_theme(self, theme_id: str) -> None:
109 """Set the current theme.
111 Args:
112 theme_id: Theme identifier to activate.
114 Raises:
115 ValueError: If the theme is not registered.
116 """
117 if theme_id not in self._themes:
118 raise ValueError(
119 f"Theme '{theme_id}' not found. Available: {list(self._themes.keys())}"
120 )
121 self._current_theme_id = theme_id
123 def get_theme(self, theme_id: str) -> ThemeInfo | None:
124 """Get a specific theme by ID.
126 Args:
127 theme_id: The ID of the theme to retrieve.
129 Returns:
130 The ThemeInfo object if found, otherwise None.
131 """
132 return self._themes.get(theme_id)
134 def get_themes_by_appearance(self, appearance: str) -> dict[str, ThemeInfo]:
135 """Get themes filtered by appearance (light/dark).
137 Uses pre-computed cache for O(1) lookup.
139 Args:
140 appearance: The desired appearance ('light' or 'dark').
142 Returns:
143 A dictionary of themes matching the specified appearance.
144 """
145 return self._by_appearance.get(appearance, {}).copy()
147 def get_themes_by_vendor(self, vendor: str) -> dict[str, ThemeInfo]:
148 """Get themes filtered by vendor.
150 Uses pre-computed cache for O(1) lookup.
152 Args:
153 vendor: The vendor name to filter by.
155 Returns:
156 A dictionary of themes from the specified vendor.
157 """
158 return self._by_vendor.get(vendor, {}).copy()
160 def cycle_theme(self, appearance: str | None = None) -> str:
161 """Cycle to the next theme, optionally filtered by appearance.
163 Args:
164 appearance: Optional appearance filter ("light" or "dark").
166 Returns:
167 ID of the newly selected theme.
169 Raises:
170 ValueError: If no themes exist for the requested appearance.
171 """
172 themes = list(self.available_themes.keys())
173 if appearance:
174 themes = [
175 tid for tid in themes if self._themes[tid].appearance == appearance
176 ]
178 if not themes:
179 raise ValueError(f"No themes found for appearance '{appearance}'")
181 current_index = (
182 themes.index(self._current_theme_id)
183 if self._current_theme_id in themes
184 else 0
185 )
186 next_index = (current_index + 1) % len(themes)
187 next_theme_id = themes[next_index]
189 self.set_theme(next_theme_id)
190 return next_theme_id
192 def apply_theme_to_css_variables(self) -> dict[str, str]:
193 """Generate CSS custom properties for the current theme.
195 Uses centralized mapping configuration from config/token-mappings.json
196 to ensure consistency with TypeScript and other platforms.
198 The actual generation logic is delegated to the css_variables module
199 which provides focused, testable helper functions.
201 Returns:
202 Mapping of CSS variable names to values.
203 """
204 return generate_css_variables(self.current_theme.tokens)
206 def _theme_tokens_to_dict(self, tokens: Tokens) -> dict[str, Any]:
207 """Convert Tokens to dict for JSON serialization.
209 Args:
210 tokens: Token dataclass tree to convert.
212 Returns:
213 Dict representation of the provided tokens.
214 """
215 result: dict[str, Any] = {}
217 # Convert each field recursively
218 for field_name, field_value in tokens.__dict__.items():
219 if field_value is None: 219 ↛ 220line 219 didn't jump to line 220 because the condition on line 219 was never true
220 result[field_name] = None
221 elif hasattr(field_value, "__dict__"):
222 # Recursively convert nested dataclasses
223 result[field_name] = self._theme_tokens_to_dict(field_value)
224 elif isinstance(field_value, tuple): 224 ↛ 225line 224 didn't jump to line 225 because the condition on line 224 was never true
225 result[field_name] = list(field_value)
226 else:
227 result[field_name] = field_value
229 return result
231 def export_theme_json(self, theme_id: str | None = None) -> str:
232 """Export theme(s) as JSON string.
234 Args:
235 theme_id: Optional theme ID to export; exports all when omitted.
237 Returns:
238 JSON string containing theme data.
240 Raises:
241 ValueError: If the requested theme does not exist.
242 """
243 if theme_id:
244 theme = self.get_theme(theme_id)
245 if not theme:
246 raise ValueError(f"Theme '{theme_id}' not found")
247 return json.dumps(
248 {
249 theme_id: {
250 "id": theme.id,
251 "label": theme.name,
252 "vendor": theme.vendor,
253 "appearance": theme.appearance,
254 "tokens": self._theme_tokens_to_dict(theme.tokens),
255 }
256 },
257 indent=2,
258 )
259 else:
260 # Export all themes
261 themes_data = {}
262 for tid, theme in self._themes.items():
263 themes_data[tid] = {
264 "id": theme.id,
265 "label": theme.name,
266 "vendor": theme.vendor,
267 "appearance": theme.appearance,
268 "tokens": self._theme_tokens_to_dict(theme.tokens),
269 }
270 return json.dumps(themes_data, indent=2)
272 def save_theme_to_file(self, filepath: str, theme_id: str | None = None) -> None:
273 """Save theme(s) to a JSON file.
275 Args:
276 filepath: Destination file path.
277 theme_id: Optional theme to export; exports all when omitted.
278 """
279 json_data = self.export_theme_json(theme_id)
280 with open(filepath, "w", encoding="utf-8") as f:
281 f.write(json_data)
284# Global instance for convenience
285_default_manager = ThemeManager()
288def get_theme_manager() -> ThemeManager:
289 """Get the default global theme manager instance.
291 Note: This returns the global singleton. Theme state is preserved
292 between calls. For test isolation, create a new ThemeManager instance.
294 Returns:
295 The global ThemeManager instance.
296 """
297 return _default_manager
300def reset_theme_manager() -> None:
301 """Reset the global theme manager to default state.
303 This is primarily useful for test cleanup to avoid cross-test pollution.
304 """
305 global _default_manager
306 _default_manager = ThemeManager()
309def set_theme(theme_id: str) -> None:
310 """Set the global theme.
312 Args:
313 theme_id: The ID of the theme to set globally.
314 """
315 _default_manager.set_theme(theme_id)
318def get_current_theme() -> ThemeInfo:
319 """Get the current global theme.
321 Returns:
322 The current active global theme.
323 """
324 return _default_manager.current_theme
327def cycle_theme(appearance: str | None = None) -> str:
328 """Cycle the global theme.
330 Args:
331 appearance: Optional filter for theme appearance (light/dark).
333 Returns:
334 The ID of the newly set global theme.
335 """
336 return _default_manager.cycle_theme(appearance)