Coverage for src / local_deep_research / web / themes / __init__.py: 98%

77 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-14 23:55 +0000

1""" 

2Theme System - Modular theme management with individual theme files. 

3 

4This module provides a centralized theme registry that: 

5- Auto-discovers themes from CSS files with TOML frontmatter 

6- Generates combined CSS for browser consumption 

7- Provides theme metadata for UI dropdowns 

8- Integrates with Flask templates via Jinja2 globals 

9 

10Usage: 

11 from local_deep_research.web.themes import theme_registry 

12 

13 # Get all theme IDs 

14 themes = theme_registry.get_theme_ids() 

15 

16 # Get theme metadata for JavaScript 

17 metadata_json = theme_registry.get_metadata_json() 

18 

19 # Generate combined CSS 

20 css = theme_registry.get_combined_css() 

21""" 

22 

23import json 

24from pathlib import Path 

25 

26from loguru import logger 

27from markupsafe import Markup 

28 

29from .loader import ThemeLoader 

30from .schema import GROUP_LABELS, ThemeMetadata 

31 

32# Package directory (where theme subdirectories live) 

33THEMES_DIR = Path(__file__).parent 

34 

35 

36class ThemeRegistry: 

37 """Central registry for theme management. 

38 

39 This is a singleton class that manages all theme loading, caching, 

40 and provides methods for Flask integration. 

41 """ 

42 

43 _instance: "ThemeRegistry | None" = None 

44 _loader: "ThemeLoader" 

45 _initialized: bool 

46 

47 def __new__(cls) -> "ThemeRegistry": 

48 """Ensure singleton pattern.""" 

49 if cls._instance is None: 

50 cls._instance = super().__new__(cls) 

51 cls._instance._loader = ThemeLoader(THEMES_DIR) 

52 cls._instance._initialized = False 

53 return cls._instance 

54 

55 @property 

56 def themes(self) -> dict[str, ThemeMetadata]: 

57 """Get all loaded themes.""" 

58 return self._loader.load_all_themes() 

59 

60 def get_theme(self, theme_id: str) -> ThemeMetadata | None: 

61 """Get a specific theme by ID. 

62 

63 Args: 

64 theme_id: The theme identifier (e.g., "nord", "dracula") 

65 

66 Returns: 

67 ThemeMetadata object or None if not found 

68 """ 

69 return self.themes.get(theme_id) 

70 

71 def get_theme_ids(self) -> list[str]: 

72 """Get sorted list of all theme IDs. 

73 

74 Returns: 

75 List of theme ID strings 

76 """ 

77 return sorted(self.themes.keys()) 

78 

79 def get_themes_by_group(self) -> dict[str, list[ThemeMetadata]]: 

80 """Get themes organized by group. 

81 

82 Returns: 

83 Dictionary mapping group names to lists of ThemeMetadata 

84 """ 

85 grouped: dict[str, list[ThemeMetadata]] = {} 

86 for theme in self.themes.values(): 

87 grouped.setdefault(theme.group, []).append(theme) 

88 # Sort themes within each group by label 

89 for group in grouped: 

90 grouped[group].sort(key=lambda t: t.label) 

91 return grouped 

92 

93 def get_combined_css(self) -> str: 

94 """Generate combined CSS from all theme files. 

95 

96 Strips frontmatter and concatenates all theme CSS. 

97 

98 Returns: 

99 Combined CSS string 

100 """ 

101 css_parts: list[str] = [] 

102 

103 # Add header comment 

104 css_parts.append( 

105 "/* Auto-generated theme CSS - Do not edit directly */" 

106 ) 

107 css_parts.append( 

108 f"/* Generated from {len(self.themes)} theme files */\n" 

109 ) 

110 

111 # Process themes in group order for predictable output 

112 group_order = ["core", "nature", "dev", "research", "other"] 

113 for group in group_order: 

114 group_themes = [t for t in self.themes.values() if t.group == group] 

115 if not group_themes: 

116 continue 

117 

118 css_parts.append( 

119 f"\n/* === {GROUP_LABELS.get(group, group)} === */\n" 

120 ) 

121 

122 for theme in sorted(group_themes, key=lambda t: t.label): 

123 if theme.css_path and theme.css_path.exists(): 123 ↛ 122line 123 didn't jump to line 122 because the condition on line 123 was always true

124 content = self._loader.get_css_content(theme) 

125 if content: 125 ↛ 122line 125 didn't jump to line 122 because the condition on line 125 was always true

126 css_parts.append(f"/* Theme: {theme.label} */") 

127 css_parts.append(content) 

128 css_parts.append("") # Empty line between themes 

129 

130 return "\n".join(css_parts) 

131 

132 def get_themes_json(self) -> Markup: 

133 """Get theme ID list as JSON for JavaScript. 

134 

135 Returns: 

136 Markup-safe JSON array string 

137 """ 

138 return Markup(json.dumps(self.get_theme_ids())) 

139 

140 def get_metadata_json(self) -> Markup: 

141 """Get full theme metadata as JSON for JavaScript. 

142 

143 Returns: 

144 Markup-safe JSON object string with theme metadata 

145 """ 

146 metadata = {} 

147 for theme_id, theme in self.themes.items(): 

148 metadata[theme_id] = theme.to_dict() 

149 return Markup(json.dumps(metadata)) 

150 

151 def get_settings_options(self) -> list[dict]: 

152 """Generate options list for settings UI. 

153 

154 Returns: 

155 List of {label, value} dicts suitable for default_settings.json format 

156 """ 

157 options = [] 

158 for theme in self.themes.values(): 

159 options.append( 

160 { 

161 "label": theme.label, 

162 "value": theme.id, 

163 } 

164 ) 

165 return sorted(options, key=lambda x: x["label"]) 

166 

167 def get_grouped_settings_options(self) -> list[dict]: 

168 """Generate grouped options for settings UI with optgroup support. 

169 

170 Returns: 

171 List of {label, value, group} dicts 

172 """ 

173 options = [] 

174 for theme in self.themes.values(): 

175 options.append( 

176 { 

177 "label": theme.label, 

178 "value": theme.id, 

179 "group": GROUP_LABELS.get(theme.group, theme.group), 

180 } 

181 ) 

182 return sorted(options, key=lambda x: (x["group"], x["label"])) 

183 

184 def is_valid_theme(self, theme_id: str) -> bool: 

185 """Check if a theme ID is valid. 

186 

187 Args: 

188 theme_id: The theme identifier to validate 

189 

190 Returns: 

191 True if theme exists, False otherwise 

192 """ 

193 return theme_id in self.themes 

194 

195 def clear_cache(self) -> None: 

196 """Clear theme cache (useful for development/hot-reload).""" 

197 self._loader.clear_cache() 

198 logger.debug("Theme cache cleared") 

199 

200 

201# Singleton instance 

202theme_registry = ThemeRegistry() 

203 

204 

205# Flask integration functions (for backward compatibility with theme_helper pattern) 

206def get_themes() -> list[str]: 

207 """Get list of available theme names. 

208 

209 Returns: 

210 Sorted list of theme ID strings 

211 """ 

212 return theme_registry.get_theme_ids() 

213 

214 

215def get_themes_json() -> Markup: 

216 """Get themes as JSON string for embedding in HTML. 

217 

218 Returns: 

219 Markup-safe JSON array of theme IDs 

220 """ 

221 return theme_registry.get_themes_json() 

222 

223 

224def get_theme_metadata() -> Markup: 

225 """Get full theme metadata as JSON for JavaScript. 

226 

227 Returns: 

228 Markup-safe JSON object with theme metadata 

229 """ 

230 return theme_registry.get_metadata_json() 

231 

232 

233__all__ = [ 

234 "ThemeRegistry", 

235 "ThemeMetadata", 

236 "theme_registry", 

237 "get_themes", 

238 "get_themes_json", 

239 "get_theme_metadata", 

240 "THEMES_DIR", 

241 "GROUP_LABELS", 

242]