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

77 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-25 01:07 +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 

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

46 """Ensure singleton pattern.""" 

47 if cls._instance is None: 

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

49 cls._instance._loader = ThemeLoader(THEMES_DIR) 

50 cls._instance._initialized = False 

51 return cls._instance 

52 

53 @property 

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

55 """Get all loaded themes.""" 

56 return self._loader.load_all_themes() 

57 

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

59 """Get a specific theme by ID. 

60 

61 Args: 

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

63 

64 Returns: 

65 ThemeMetadata object or None if not found 

66 """ 

67 return self.themes.get(theme_id) 

68 

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

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

71 

72 Returns: 

73 List of theme ID strings 

74 """ 

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

76 

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

78 """Get themes organized by group. 

79 

80 Returns: 

81 Dictionary mapping group names to lists of ThemeMetadata 

82 """ 

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

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

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

86 # Sort themes within each group by label 

87 for group in grouped: 

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

89 return grouped 

90 

91 def get_combined_css(self) -> str: 

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

93 

94 Strips frontmatter and concatenates all theme CSS. 

95 

96 Returns: 

97 Combined CSS string 

98 """ 

99 css_parts: list[str] = [] 

100 

101 # Add header comment 

102 css_parts.append( 

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

104 ) 

105 css_parts.append( 

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

107 ) 

108 

109 # Process themes in group order for predictable output 

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

111 for group in group_order: 

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

113 if not group_themes: 

114 continue 

115 

116 css_parts.append( 

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

118 ) 

119 

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

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

122 content = self._loader.get_css_content(theme) 

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

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

125 css_parts.append(content) 

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

127 

128 return "\n".join(css_parts) 

129 

130 def get_themes_json(self) -> Markup: 

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

132 

133 Returns: 

134 Markup-safe JSON array string 

135 """ 

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

137 

138 def get_metadata_json(self) -> Markup: 

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

140 

141 Returns: 

142 Markup-safe JSON object string with theme metadata 

143 """ 

144 metadata = {} 

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

146 metadata[theme_id] = theme.to_dict() 

147 return Markup(json.dumps(metadata)) 

148 

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

150 """Generate options list for settings UI. 

151 

152 Returns: 

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

154 """ 

155 options = [] 

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

157 options.append( 

158 { 

159 "label": theme.label, 

160 "value": theme.id, 

161 } 

162 ) 

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

164 

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

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

167 

168 Returns: 

169 List of {label, value, group} dicts 

170 """ 

171 options = [] 

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

173 options.append( 

174 { 

175 "label": theme.label, 

176 "value": theme.id, 

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

178 } 

179 ) 

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

181 

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

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

184 

185 Args: 

186 theme_id: The theme identifier to validate 

187 

188 Returns: 

189 True if theme exists, False otherwise 

190 """ 

191 return theme_id in self.themes 

192 

193 def clear_cache(self) -> None: 

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

195 self._loader.clear_cache() 

196 logger.debug("Theme cache cleared") 

197 

198 

199# Singleton instance 

200theme_registry = ThemeRegistry() 

201 

202 

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

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

205 """Get list of available theme names. 

206 

207 Returns: 

208 Sorted list of theme ID strings 

209 """ 

210 return theme_registry.get_theme_ids() 

211 

212 

213def get_themes_json() -> Markup: 

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

215 

216 Returns: 

217 Markup-safe JSON array of theme IDs 

218 """ 

219 return theme_registry.get_themes_json() 

220 

221 

222def get_theme_metadata() -> Markup: 

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

224 

225 Returns: 

226 Markup-safe JSON object with theme metadata 

227 """ 

228 return theme_registry.get_metadata_json() 

229 

230 

231__all__ = [ 

232 "ThemeRegistry", 

233 "ThemeMetadata", 

234 "theme_registry", 

235 "get_themes", 

236 "get_themes_json", 

237 "get_theme_metadata", 

238 "THEMES_DIR", 

239 "GROUP_LABELS", 

240]