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
« 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.
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"""
23import json
24from pathlib import Path
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 Markup(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 return Markup(json.dumps(metadata))
151 def get_settings_options(self) -> list[dict]:
152 """Generate options list for settings UI.
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"])
167 def get_grouped_settings_options(self) -> list[dict]:
168 """Generate grouped options for settings UI with optgroup support.
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"]))
184 def is_valid_theme(self, theme_id: str) -> bool:
185 """Check if a theme ID is valid.
187 Args:
188 theme_id: The theme identifier to validate
190 Returns:
191 True if theme exists, False otherwise
192 """
193 return theme_id in self.themes
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")
201# Singleton instance
202theme_registry = ThemeRegistry()
205# Flask integration functions (for backward compatibility with theme_helper pattern)
206def get_themes() -> list[str]:
207 """Get list of available theme names.
209 Returns:
210 Sorted list of theme ID strings
211 """
212 return theme_registry.get_theme_ids()
215def get_themes_json() -> Markup:
216 """Get themes as JSON string for embedding in HTML.
218 Returns:
219 Markup-safe JSON array of theme IDs
220 """
221 return theme_registry.get_themes_json()
224def get_theme_metadata() -> Markup:
225 """Get full theme metadata as JSON for JavaScript.
227 Returns:
228 Markup-safe JSON object with theme metadata
229 """
230 return theme_registry.get_metadata_json()
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]