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
« 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.
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
10Usage:
11 from local_deep_research.web.themes import theme_registry
13 # Get all theme IDs
14 themes = theme_registry.get_theme_ids()
16 # Get theme metadata for JavaScript
17 metadata_json = theme_registry.get_metadata_json()
19 # Generate combined CSS
20 css = theme_registry.get_combined_css()
21"""
23from pathlib import Path
25from jinja2.utils import htmlsafe_json_dumps
26from loguru import logger
27from markupsafe import Markup
29from .loader import ThemeLoader
30from .schema import GROUP_LABELS, ThemeMetadata
32# Package directory (where theme subdirectories live)
33THEMES_DIR = Path(__file__).parent
36class ThemeRegistry:
37 """Central registry for theme management.
39 This is a singleton class that manages all theme loading, caching,
40 and provides methods for Flask integration.
41 """
43 _instance: "ThemeRegistry | None" = None
44 _loader: "ThemeLoader"
45 _initialized: bool
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
55 @property
56 def themes(self) -> dict[str, ThemeMetadata]:
57 """Get all loaded themes."""
58 return self._loader.load_all_themes()
60 def get_theme(self, theme_id: str) -> ThemeMetadata | None:
61 """Get a specific theme by ID.
63 Args:
64 theme_id: The theme identifier (e.g., "nord", "dracula")
66 Returns:
67 ThemeMetadata object or None if not found
68 """
69 return self.themes.get(theme_id)
71 def get_theme_ids(self) -> list[str]:
72 """Get sorted list of all theme IDs.
74 Returns:
75 List of theme ID strings
76 """
77 return sorted(self.themes.keys())
79 def get_themes_by_group(self) -> dict[str, list[ThemeMetadata]]:
80 """Get themes organized by group.
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
93 def get_combined_css(self) -> str:
94 """Generate combined CSS from all theme files.
96 Strips frontmatter and concatenates all theme CSS.
98 Returns:
99 Combined CSS string
100 """
101 css_parts: list[str] = []
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 )
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
118 css_parts.append(
119 f"\n/* === {GROUP_LABELS.get(group, group)} === */\n"
120 )
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
130 return "\n".join(css_parts)
132 def get_themes_json(self) -> Markup:
133 """Get theme ID list as JSON for JavaScript.
135 Returns:
136 Markup-safe JSON array string
137 """
138 return htmlsafe_json_dumps(self.get_theme_ids())
140 def get_metadata_json(self) -> Markup:
141 """Get full theme metadata as JSON for JavaScript.
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)
153 def get_settings_options(self) -> list[dict]:
154 """Generate options list for settings UI.
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"])
169 def get_grouped_settings_options(self) -> list[dict]:
170 """Generate grouped options for settings UI with optgroup support.
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"]))
186 def is_valid_theme(self, theme_id: str) -> bool:
187 """Check if a theme ID is valid.
189 Args:
190 theme_id: The theme identifier to validate
192 Returns:
193 True if theme exists, False otherwise
194 """
195 return theme_id in self.themes
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")
203# Singleton instance
204theme_registry = ThemeRegistry()
207# Flask integration functions (for backward compatibility with theme_helper pattern)
208def get_themes() -> list[str]:
209 """Get list of available theme names.
211 Returns:
212 Sorted list of theme ID strings
213 """
214 return theme_registry.get_theme_ids()
217def get_themes_json() -> Markup:
218 """Get themes as JSON string for embedding in HTML.
220 Returns:
221 Markup-safe JSON array of theme IDs
222 """
223 return theme_registry.get_themes_json()
226def get_theme_metadata() -> Markup:
227 """Get full theme metadata as JSON for JavaScript.
229 Returns:
230 Markup-safe JSON object with theme metadata
231 """
232 return theme_registry.get_metadata_json()
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]