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

77 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +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 

23from pathlib import Path 

24 

25from jinja2.utils import htmlsafe_json_dumps 

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 htmlsafe_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 # JSON is embedded in a script element. Escape HTML delimiters even 

150 # though today's metadata comes from package-shipped theme files. 

151 return htmlsafe_json_dumps(metadata) 

152 

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

154 """Generate options list for settings UI. 

155 

156 Returns: 

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

158 """ 

159 options = [] 

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

161 options.append( 

162 { 

163 "label": theme.label, 

164 "value": theme.id, 

165 } 

166 ) 

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

168 

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

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

171 

172 Returns: 

173 List of {label, value, group} dicts 

174 """ 

175 options = [] 

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

177 options.append( 

178 { 

179 "label": theme.label, 

180 "value": theme.id, 

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

182 } 

183 ) 

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

185 

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

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

188 

189 Args: 

190 theme_id: The theme identifier to validate 

191 

192 Returns: 

193 True if theme exists, False otherwise 

194 """ 

195 return theme_id in self.themes 

196 

197 def clear_cache(self) -> None: 

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

199 self._loader.clear_cache() 

200 logger.debug("Theme cache cleared") 

201 

202 

203# Singleton instance 

204theme_registry = ThemeRegistry() 

205 

206 

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

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

209 """Get list of available theme names. 

210 

211 Returns: 

212 Sorted list of theme ID strings 

213 """ 

214 return theme_registry.get_theme_ids() 

215 

216 

217def get_themes_json() -> Markup: 

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

219 

220 Returns: 

221 Markup-safe JSON array of theme IDs 

222 """ 

223 return theme_registry.get_themes_json() 

224 

225 

226def get_theme_metadata() -> Markup: 

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

228 

229 Returns: 

230 Markup-safe JSON object with theme metadata 

231 """ 

232 return theme_registry.get_metadata_json() 

233 

234 

235__all__ = [ 

236 "ThemeRegistry", 

237 "ThemeMetadata", 

238 "theme_registry", 

239 "get_themes", 

240 "get_themes_json", 

241 "get_theme_metadata", 

242 "THEMES_DIR", 

243 "GROUP_LABELS", 

244]