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

1"""Theme manager for Turbo Themes. 

2 

3Provides high-level utilities for managing themes, applying them to applications, 

4and handling theme switching. 

5""" 

6 

7from __future__ import annotations 

8 

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 

15 

16 

17@dataclass 

18class ThemeInfo: 

19 """Information about a theme.""" 

20 

21 id: str 

22 name: str 

23 vendor: str 

24 appearance: str 

25 tokens: Tokens 

26 

27 @classmethod 

28 def from_theme_value(cls, theme_value: ThemeValue) -> ThemeInfo: 

29 """Create ThemeInfo from a ThemeValue object. 

30 

31 Args: 

32 theme_value: Quicktype-generated ThemeValue object. 

33 

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 ) 

44 

45 

46class ThemeManager: 

47 """Manages theme switching and application.""" 

48 

49 def __init__(self, default_theme: str = "catppuccin-mocha"): 

50 """Initialize theme manager with default theme. 

51 

52 Args: 

53 default_theme: Theme ID to load initially. 

54 

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] = {} 

60 

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]] = {} 

64 

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 

69 

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 

74 

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 ) 

81 

82 @property 

83 def current_theme(self) -> ThemeInfo: 

84 """Get the current theme. 

85 

86 Returns: 

87 The current active theme. 

88 """ 

89 return self._themes[self._current_theme_id] 

90 

91 @property 

92 def current_theme_id(self) -> str: 

93 """Get the current theme ID. 

94 

95 Returns: 

96 The ID of the current active theme. 

97 """ 

98 return self._current_theme_id 

99 

100 @property 

101 def available_themes(self) -> dict[str, ThemeInfo]: 

102 """Get all available themes. 

103 

104 Returns: 

105 A dictionary of all available themes, keyed by their IDs. 

106 """ 

107 return self._themes.copy() 

108 

109 def set_theme(self, theme_id: str) -> None: 

110 """Set the current theme. 

111 

112 Args: 

113 theme_id: Theme identifier to activate. 

114 

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 

123 

124 def get_theme(self, theme_id: str) -> ThemeInfo | None: 

125 """Get a specific theme by ID. 

126 

127 Args: 

128 theme_id: The ID of the theme to retrieve. 

129 

130 Returns: 

131 The ThemeInfo object if found, otherwise None. 

132 """ 

133 return self._themes.get(theme_id) 

134 

135 def get_themes_by_appearance(self, appearance: str) -> dict[str, ThemeInfo]: 

136 """Get themes filtered by appearance (light/dark). 

137 

138 Uses pre-computed cache for O(1) lookup. 

139 

140 Args: 

141 appearance: The desired appearance ('light' or 'dark'). 

142 

143 Returns: 

144 A dictionary of themes matching the specified appearance. 

145 """ 

146 return self._by_appearance.get(appearance, {}).copy() 

147 

148 def get_themes_by_vendor(self, vendor: str) -> dict[str, ThemeInfo]: 

149 """Get themes filtered by vendor. 

150 

151 Uses pre-computed cache for O(1) lookup. 

152 

153 Args: 

154 vendor: The vendor name to filter by. 

155 

156 Returns: 

157 A dictionary of themes from the specified vendor. 

158 """ 

159 return self._by_vendor.get(vendor, {}).copy() 

160 

161 def cycle_theme(self, appearance: str | None = None) -> str: 

162 """Cycle to the next theme, optionally filtered by appearance. 

163 

164 Args: 

165 appearance: Optional appearance filter ("light" or "dark"). 

166 

167 Returns: 

168 ID of the newly selected theme. 

169 

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 ] 

178 

179 if not themes: 

180 raise ValueError(f"No themes found for appearance '{appearance}'") 

181 

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] 

189 

190 self.set_theme(next_theme_id) 

191 return next_theme_id 

192 

193 def apply_theme_to_css_variables(self) -> dict[str, str]: 

194 """Generate CSS custom properties for the current theme. 

195 

196 Uses centralized mapping configuration from config/token-mappings.json 

197 to ensure consistency with TypeScript and other platforms. 

198 

199 The actual generation logic is delegated to the css_variables module 

200 which provides focused, testable helper functions. 

201 

202 Returns: 

203 Mapping of CSS variable names to values. 

204 """ 

205 return generate_css_variables(self.current_theme.tokens) 

206 

207 def _theme_tokens_to_dict(self, tokens: Tokens) -> dict[str, Any]: 

208 """Convert Tokens to dict for JSON serialization. 

209 

210 Args: 

211 tokens: Token dataclass tree to convert. 

212 

213 Returns: 

214 Dict representation of the provided tokens. 

215 """ 

216 result: dict[str, Any] = {} 

217 

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 

229 

230 return result 

231 

232 def export_theme_json(self, theme_id: str | None = None) -> str: 

233 """Export theme(s) as JSON string. 

234 

235 Args: 

236 theme_id: Optional theme ID to export; exports all when omitted. 

237 

238 Returns: 

239 JSON string containing theme data. 

240 

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) 

272 

273 def save_theme_to_file(self, filepath: str, theme_id: str | None = None) -> None: 

274 """Save theme(s) to a JSON file. 

275 

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) 

283 

284 

285# Global instance for convenience 

286_default_manager = ThemeManager() 

287 

288 

289def get_theme_manager() -> ThemeManager: 

290 """Get the default global theme manager instance. 

291 

292 Note: This returns the global singleton. Theme state is preserved 

293 between calls. For test isolation, create a new ThemeManager instance. 

294 

295 Returns: 

296 The global ThemeManager instance. 

297 """ 

298 return _default_manager 

299 

300 

301def reset_theme_manager() -> None: 

302 """Reset the global theme manager to default state. 

303 

304 This is primarily useful for test cleanup to avoid cross-test pollution. 

305 """ 

306 global _default_manager 

307 _default_manager = ThemeManager() 

308 

309 

310def set_theme(theme_id: str) -> None: 

311 """Set the global theme. 

312 

313 Args: 

314 theme_id: The ID of the theme to set globally. 

315 """ 

316 _default_manager.set_theme(theme_id) 

317 

318 

319def get_current_theme() -> ThemeInfo: 

320 """Get the current global theme. 

321 

322 Returns: 

323 The current active global theme. 

324 """ 

325 return _default_manager.current_theme 

326 

327 

328def cycle_theme(appearance: str | None = None) -> str: 

329 """Cycle the global theme. 

330 

331 Args: 

332 appearance: Optional filter for theme appearance (light/dark). 

333 

334 Returns: 

335 The ID of the newly set global theme. 

336 """ 

337 return _default_manager.cycle_theme(appearance)