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

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 Args: 

50 default_theme: Theme ID to load initially. 

51 

52 Raises: 

53 ValueError: If the requested default theme is missing. 

54 """ 

55 

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

57 self._current_theme_id = default_theme 

58 self._themes: dict[str, ThemeInfo] = {} 

59 

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

63 

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 

68 

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 

73 

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 ) 

80 

81 @property 

82 def current_theme(self) -> ThemeInfo: 

83 """Get the current theme. 

84 

85 Returns: 

86 The current active theme. 

87 """ 

88 return self._themes[self._current_theme_id] 

89 

90 @property 

91 def current_theme_id(self) -> str: 

92 """Get the current theme ID. 

93 

94 Returns: 

95 The ID of the current active theme. 

96 """ 

97 return self._current_theme_id 

98 

99 @property 

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

101 """Get all available themes. 

102 

103 Returns: 

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

105 """ 

106 return self._themes.copy() 

107 

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

109 """Set the current theme. 

110 

111 Args: 

112 theme_id: Theme identifier to activate. 

113 

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 

122 

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

124 """Get a specific theme by ID. 

125 

126 Args: 

127 theme_id: The ID of the theme to retrieve. 

128 

129 Returns: 

130 The ThemeInfo object if found, otherwise None. 

131 """ 

132 return self._themes.get(theme_id) 

133 

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

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

136 

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

138 

139 Args: 

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

141 

142 Returns: 

143 A dictionary of themes matching the specified appearance. 

144 """ 

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

146 

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

148 """Get themes filtered by vendor. 

149 

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

151 

152 Args: 

153 vendor: The vendor name to filter by. 

154 

155 Returns: 

156 A dictionary of themes from the specified vendor. 

157 """ 

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

159 

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

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

162 

163 Args: 

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

165 

166 Returns: 

167 ID of the newly selected theme. 

168 

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 ] 

177 

178 if not themes: 

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

180 

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] 

188 

189 self.set_theme(next_theme_id) 

190 return next_theme_id 

191 

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

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

194 

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

196 to ensure consistency with TypeScript and other platforms. 

197 

198 The actual generation logic is delegated to the css_variables module 

199 which provides focused, testable helper functions. 

200 

201 Returns: 

202 Mapping of CSS variable names to values. 

203 """ 

204 return generate_css_variables(self.current_theme.tokens) 

205 

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

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

208 

209 Args: 

210 tokens: Token dataclass tree to convert. 

211 

212 Returns: 

213 Dict representation of the provided tokens. 

214 """ 

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

216 

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 

228 

229 return result 

230 

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

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

233 

234 Args: 

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

236 

237 Returns: 

238 JSON string containing theme data. 

239 

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) 

271 

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

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

274 

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) 

282 

283 

284# Global instance for convenience 

285_default_manager = ThemeManager() 

286 

287 

288def get_theme_manager() -> ThemeManager: 

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

290 

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

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

293 

294 Returns: 

295 The global ThemeManager instance. 

296 """ 

297 return _default_manager 

298 

299 

300def reset_theme_manager() -> None: 

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

302 

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

304 """ 

305 global _default_manager 

306 _default_manager = ThemeManager() 

307 

308 

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

310 """Set the global theme. 

311 

312 Args: 

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

314 """ 

315 _default_manager.set_theme(theme_id) 

316 

317 

318def get_current_theme() -> ThemeInfo: 

319 """Get the current global theme. 

320 

321 Returns: 

322 The current active global theme. 

323 """ 

324 return _default_manager.current_theme 

325 

326 

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

328 """Cycle the global theme. 

329 

330 Args: 

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

332 

333 Returns: 

334 The ID of the newly set global theme. 

335 """ 

336 return _default_manager.cycle_theme(appearance)