Coverage for src / turbo_themes / manager.py: 94%
100 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-17 07:32 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-17 07:32 +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 def __init__(self, default_theme: str = "catppuccin-mocha"):
50 """Initialize theme manager with default theme.
52 Args:
53 default_theme: Theme ID to load initially.
55 Raises:
56 ValueError: If the requested default theme is missing.
57 """
58 self._current_theme_id = default_theme
59 self._themes: dict[str, ThemeInfo] = {}
61 # Pre-computed filter caches for O(1) lookup
62 self._by_appearance: dict[str, dict[str, ThemeInfo]] = {"light": {}, "dark": {}}
63 self._by_vendor: dict[str, dict[str, ThemeInfo]] = {}
65 # Load themes from Quicktype-generated ThemeValue objects
66 for theme_id, theme_value in THEMES.items():
67 theme_info = ThemeInfo.from_theme_value(theme_value)
68 self._themes[theme_id] = theme_info
70 # Build filter caches
71 if theme_info.appearance in self._by_appearance: 71 ↛ 73line 71 didn't jump to line 73 because the condition on line 71 was always true
72 self._by_appearance[theme_info.appearance][theme_id] = theme_info
73 self._by_vendor.setdefault(theme_info.vendor, {})[theme_id] = theme_info
75 # Validate default theme exists
76 if default_theme not in self._themes:
77 available = list(self._themes.keys())
78 raise ValueError(
79 f"Default theme '{default_theme}' not found. Available: {available}"
80 )
82 @property
83 def current_theme(self) -> ThemeInfo:
84 """Get the current theme.
86 Returns:
87 The current active theme.
88 """
89 return self._themes[self._current_theme_id]
91 @property
92 def current_theme_id(self) -> str:
93 """Get the current theme ID.
95 Returns:
96 The ID of the current active theme.
97 """
98 return self._current_theme_id
100 @property
101 def available_themes(self) -> dict[str, ThemeInfo]:
102 """Get all available themes.
104 Returns:
105 A dictionary of all available themes, keyed by their IDs.
106 """
107 return self._themes.copy()
109 def set_theme(self, theme_id: str) -> None:
110 """Set the current theme.
112 Args:
113 theme_id: Theme identifier to activate.
115 Raises:
116 ValueError: If the theme is not registered.
117 """
118 if theme_id not in self._themes:
119 raise ValueError(
120 f"Theme '{theme_id}' not found. Available: {list(self._themes.keys())}"
121 )
122 self._current_theme_id = theme_id
124 def get_theme(self, theme_id: str) -> ThemeInfo | None:
125 """Get a specific theme by ID.
127 Args:
128 theme_id: The ID of the theme to retrieve.
130 Returns:
131 The ThemeInfo object if found, otherwise None.
132 """
133 return self._themes.get(theme_id)
135 def get_themes_by_appearance(self, appearance: str) -> dict[str, ThemeInfo]:
136 """Get themes filtered by appearance (light/dark).
138 Uses pre-computed cache for O(1) lookup.
140 Args:
141 appearance: The desired appearance ('light' or 'dark').
143 Returns:
144 A dictionary of themes matching the specified appearance.
145 """
146 return self._by_appearance.get(appearance, {}).copy()
148 def get_themes_by_vendor(self, vendor: str) -> dict[str, ThemeInfo]:
149 """Get themes filtered by vendor.
151 Uses pre-computed cache for O(1) lookup.
153 Args:
154 vendor: The vendor name to filter by.
156 Returns:
157 A dictionary of themes from the specified vendor.
158 """
159 return self._by_vendor.get(vendor, {}).copy()
161 def cycle_theme(self, appearance: str | None = None) -> str:
162 """Cycle to the next theme, optionally filtered by appearance.
164 Args:
165 appearance: Optional appearance filter ("light" or "dark").
167 Returns:
168 ID of the newly selected theme.
170 Raises:
171 ValueError: If no themes exist for the requested appearance.
172 """
173 themes = list(self.available_themes.keys())
174 if appearance:
175 themes = [
176 tid for tid in themes if self._themes[tid].appearance == appearance
177 ]
179 if not themes:
180 raise ValueError(f"No themes found for appearance '{appearance}'")
182 current_index = (
183 themes.index(self._current_theme_id)
184 if self._current_theme_id in themes
185 else 0
186 )
187 next_index = (current_index + 1) % len(themes)
188 next_theme_id = themes[next_index]
190 self.set_theme(next_theme_id)
191 return next_theme_id
193 def apply_theme_to_css_variables(self) -> dict[str, str]:
194 """Generate CSS custom properties for the current theme.
196 Uses centralized mapping configuration from config/token-mappings.json
197 to ensure consistency with TypeScript and other platforms.
199 The actual generation logic is delegated to the css_variables module
200 which provides focused, testable helper functions.
202 Returns:
203 Mapping of CSS variable names to values.
204 """
205 return generate_css_variables(self.current_theme.tokens)
207 def _theme_tokens_to_dict(self, tokens: Tokens) -> dict[str, Any]:
208 """Convert Tokens to dict for JSON serialization.
210 Args:
211 tokens: Token dataclass tree to convert.
213 Returns:
214 Dict representation of the provided tokens.
215 """
216 result: dict[str, Any] = {}
218 # Convert each field recursively
219 for field_name, field_value in tokens.__dict__.items():
220 if field_value is None: 220 ↛ 221line 220 didn't jump to line 221 because the condition on line 220 was never true
221 result[field_name] = None
222 elif hasattr(field_value, "__dict__"):
223 # Recursively convert nested dataclasses
224 result[field_name] = self._theme_tokens_to_dict(field_value)
225 elif isinstance(field_value, tuple): 225 ↛ 226line 225 didn't jump to line 226 because the condition on line 225 was never true
226 result[field_name] = list(field_value)
227 else:
228 result[field_name] = field_value
230 return result
232 def export_theme_json(self, theme_id: str | None = None) -> str:
233 """Export theme(s) as JSON string.
235 Args:
236 theme_id: Optional theme ID to export; exports all when omitted.
238 Returns:
239 JSON string containing theme data.
241 Raises:
242 ValueError: If the requested theme does not exist.
243 """
244 if theme_id:
245 theme = self.get_theme(theme_id)
246 if not theme:
247 raise ValueError(f"Theme '{theme_id}' not found")
248 return json.dumps(
249 {
250 theme_id: {
251 "id": theme.id,
252 "label": theme.name,
253 "vendor": theme.vendor,
254 "appearance": theme.appearance,
255 "tokens": self._theme_tokens_to_dict(theme.tokens),
256 }
257 },
258 indent=2,
259 )
260 else:
261 # Export all themes
262 themes_data = {}
263 for tid, theme in self._themes.items():
264 themes_data[tid] = {
265 "id": theme.id,
266 "label": theme.name,
267 "vendor": theme.vendor,
268 "appearance": theme.appearance,
269 "tokens": self._theme_tokens_to_dict(theme.tokens),
270 }
271 return json.dumps(themes_data, indent=2)
273 def save_theme_to_file(self, filepath: str, theme_id: str | None = None) -> None:
274 """Save theme(s) to a JSON file.
276 Args:
277 filepath: Destination file path.
278 theme_id: Optional theme to export; exports all when omitted.
279 """
280 json_data = self.export_theme_json(theme_id)
281 with open(filepath, "w", encoding="utf-8") as f:
282 f.write(json_data)
285# Global instance for convenience
286_default_manager = ThemeManager()
289def get_theme_manager() -> ThemeManager:
290 """Get the default global theme manager instance.
292 Note: This returns the global singleton. Theme state is preserved
293 between calls. For test isolation, create a new ThemeManager instance.
295 Returns:
296 The global ThemeManager instance.
297 """
298 return _default_manager
301def reset_theme_manager() -> None:
302 """Reset the global theme manager to default state.
304 This is primarily useful for test cleanup to avoid cross-test pollution.
305 """
306 global _default_manager
307 _default_manager = ThemeManager()
310def set_theme(theme_id: str) -> None:
311 """Set the global theme.
313 Args:
314 theme_id: The ID of the theme to set globally.
315 """
316 _default_manager.set_theme(theme_id)
319def get_current_theme() -> ThemeInfo:
320 """Get the current global theme.
322 Returns:
323 The current active global theme.
324 """
325 return _default_manager.current_theme
328def cycle_theme(appearance: str | None = None) -> str:
329 """Cycle the global theme.
331 Args:
332 appearance: Optional filter for theme appearance (light/dark).
334 Returns:
335 The ID of the newly set global theme.
336 """
337 return _default_manager.cycle_theme(appearance)