Skip to main content

fresh/view/theme/
loader.rs

1//! Theme loading and registry.
2//!
3//! This module provides:
4//! - `ThemeRegistry`: A pure data structure holding all loaded themes
5//! - `ThemeLoader`: Scans and loads themes into a registry
6
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use super::types::{Theme, ThemeFile, ThemeInfo, BUILTIN_THEMES};
11
12/// A registry holding all loaded themes.
13///
14/// This is a pure data structure - no I/O operations.
15/// Use `ThemeLoader` to create and populate a registry.
16#[derive(Debug, Clone)]
17pub struct ThemeRegistry {
18    /// All loaded themes, keyed by name
19    themes: HashMap<String, Theme>,
20    /// Theme metadata for listing
21    theme_list: Vec<ThemeInfo>,
22}
23
24impl ThemeRegistry {
25    /// Get a theme by name.
26    pub fn get(&self, name: &str) -> Option<&Theme> {
27        let normalized = name.to_lowercase().replace('_', "-");
28        self.themes.get(&normalized)
29    }
30
31    /// Get a cloned theme by name.
32    pub fn get_cloned(&self, name: &str) -> Option<Theme> {
33        self.get(name).cloned()
34    }
35
36    /// List all available themes with metadata.
37    pub fn list(&self) -> &[ThemeInfo] {
38        &self.theme_list
39    }
40
41    /// Get all theme names.
42    pub fn names(&self) -> Vec<String> {
43        self.theme_list.iter().map(|t| t.name.clone()).collect()
44    }
45
46    /// Check if a theme exists.
47    pub fn contains(&self, name: &str) -> bool {
48        let normalized = name.to_lowercase().replace('_', "-");
49        self.themes.contains_key(&normalized)
50    }
51
52    /// Number of themes in the registry.
53    pub fn len(&self) -> usize {
54        self.themes.len()
55    }
56
57    /// Check if registry is empty.
58    pub fn is_empty(&self) -> bool {
59        self.themes.is_empty()
60    }
61}
62
63/// Loads themes and creates a ThemeRegistry.
64pub struct ThemeLoader {
65    user_themes_dir: Option<PathBuf>,
66}
67
68impl ThemeLoader {
69    /// Create a new ThemeLoader with default user themes directory.
70    pub fn new() -> Self {
71        Self {
72            user_themes_dir: dirs::config_dir().map(|p| p.join("fresh").join("themes")),
73        }
74    }
75
76    /// Create a ThemeLoader with a custom user themes directory.
77    pub fn with_user_dir(user_themes_dir: Option<PathBuf>) -> Self {
78        Self { user_themes_dir }
79    }
80
81    /// Get the user themes directory path.
82    pub fn user_themes_dir(&self) -> Option<&Path> {
83        self.user_themes_dir.as_deref()
84    }
85
86    /// Load all themes (embedded + user + packages) into a registry.
87    pub fn load_all(&self) -> ThemeRegistry {
88        let mut themes = HashMap::new();
89        let mut theme_list = Vec::new();
90
91        // Load all embedded themes
92        for builtin in BUILTIN_THEMES {
93            if let Ok(theme_file) = serde_json::from_str::<ThemeFile>(builtin.json) {
94                let theme: Theme = theme_file.into();
95                themes.insert(builtin.name.to_string(), theme);
96                theme_list.push(ThemeInfo::new(builtin.name, builtin.pack));
97            }
98        }
99
100        // Load user themes from ~/.config/fresh/themes/ (recursively)
101        if let Some(ref user_dir) = self.user_themes_dir {
102            self.scan_directory(user_dir, "user", &mut themes, &mut theme_list);
103        }
104
105        // Load theme packages from ~/.config/fresh/themes/packages/*/
106        // Each package directory may contain multiple theme JSON files
107        if let Some(ref user_dir) = self.user_themes_dir {
108            let packages_dir = user_dir.join("packages");
109            if packages_dir.exists() {
110                if let Ok(entries) = std::fs::read_dir(&packages_dir) {
111                    for entry in entries.flatten() {
112                        let path = entry.path();
113                        if path.is_dir() {
114                            if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
115                                // Skip hidden directories (like .index)
116                                if !name.starts_with('.') {
117                                    // Check for package.json to get theme metadata
118                                    let manifest_path = path.join("package.json");
119                                    if manifest_path.exists() {
120                                        self.load_package_themes(
121                                            &path,
122                                            name,
123                                            &mut themes,
124                                            &mut theme_list,
125                                        );
126                                    } else {
127                                        // Fallback: scan directory for JSON files
128                                        let pack_name = format!("pkg/{}", name);
129                                        self.scan_directory(
130                                            &path,
131                                            &pack_name,
132                                            &mut themes,
133                                            &mut theme_list,
134                                        );
135                                    }
136                                }
137                            }
138                        }
139                    }
140                }
141            }
142        }
143
144        ThemeRegistry { themes, theme_list }
145    }
146
147    /// Load themes from a package with package.json manifest.
148    fn load_package_themes(
149        &self,
150        pkg_dir: &Path,
151        pkg_name: &str,
152        themes: &mut HashMap<String, Theme>,
153        theme_list: &mut Vec<ThemeInfo>,
154    ) {
155        let manifest_path = pkg_dir.join("package.json");
156        let manifest_content = match std::fs::read_to_string(&manifest_path) {
157            Ok(c) => c,
158            Err(_) => return,
159        };
160
161        // Parse manifest to find theme entries
162        let manifest: serde_json::Value = match serde_json::from_str(&manifest_content) {
163            Ok(v) => v,
164            Err(_) => return,
165        };
166
167        // Check for fresh.themes array in manifest
168        if let Some(fresh) = manifest.get("fresh") {
169            if let Some(theme_entries) = fresh.get("themes").and_then(|t| t.as_array()) {
170                for entry in theme_entries {
171                    if let (Some(file), Some(name)) = (
172                        entry.get("file").and_then(|f| f.as_str()),
173                        entry.get("name").and_then(|n| n.as_str()),
174                    ) {
175                        let theme_path = pkg_dir.join(file);
176                        if theme_path.exists() {
177                            if let Ok(content) = std::fs::read_to_string(&theme_path) {
178                                if let Ok(theme_file) = serde_json::from_str::<ThemeFile>(&content)
179                                {
180                                    let theme: Theme = theme_file.into();
181                                    let normalized_name = name.to_lowercase().replace(' ', "-");
182                                    // Don't overwrite existing themes
183                                    if !themes.contains_key(&normalized_name) {
184                                        themes.insert(normalized_name.clone(), theme);
185                                        let pack_name = format!("pkg/{}", pkg_name);
186                                        theme_list
187                                            .push(ThemeInfo::new(normalized_name, &pack_name));
188                                    }
189                                }
190                            }
191                        }
192                    }
193                }
194                return;
195            }
196        }
197
198        // Fallback: if no fresh.themes, scan for JSON files
199        let pack_name = format!("pkg/{}", pkg_name);
200        self.scan_directory(pkg_dir, &pack_name, themes, theme_list);
201    }
202
203    /// Recursively scan a directory for theme files.
204    fn scan_directory(
205        &self,
206        dir: &Path,
207        pack: &str,
208        themes: &mut HashMap<String, Theme>,
209        theme_list: &mut Vec<ThemeInfo>,
210    ) {
211        let entries = match std::fs::read_dir(dir) {
212            Ok(e) => e,
213            Err(_) => return,
214        };
215
216        for entry in entries.flatten() {
217            let path = entry.path();
218
219            if path.is_dir() {
220                // Recurse into subdirectory with updated pack name
221                let subdir_name = path.file_name().unwrap().to_string_lossy();
222                let new_pack = if pack == "user" {
223                    format!("user/{}", subdir_name)
224                } else {
225                    format!("{}/{}", pack, subdir_name)
226                };
227                self.scan_directory(&path, &new_pack, themes, theme_list);
228            } else if path.extension().is_some_and(|ext| ext == "json") {
229                // Load theme file
230                let name = path.file_stem().unwrap().to_string_lossy().to_string();
231
232                // Skip if already loaded (embedded themes take priority)
233                if themes.contains_key(&name) {
234                    continue;
235                }
236
237                if let Ok(content) = std::fs::read_to_string(&path) {
238                    if let Ok(theme_file) = serde_json::from_str::<ThemeFile>(&content) {
239                        let theme: Theme = theme_file.into();
240                        themes.insert(name.clone(), theme);
241                        theme_list.push(ThemeInfo::new(name, pack));
242                    }
243                }
244            }
245        }
246    }
247}
248
249// Cursor color methods on Theme (no I/O for theme loading)
250impl Theme {
251    /// Set the terminal cursor color using OSC 12 escape sequence.
252    /// This makes the hardware cursor visible on any background.
253    pub fn set_terminal_cursor_color(&self) {
254        use super::types::color_to_rgb;
255        use std::io::Write;
256        if let Some((r, g, b)) = color_to_rgb(self.cursor) {
257            // OSC 12 sets cursor color: \x1b]12;#RRGGBB\x07
258            let _ = write!(
259                std::io::stdout(),
260                "\x1b]12;#{:02x}{:02x}{:02x}\x07",
261                r,
262                g,
263                b
264            );
265            let _ = std::io::stdout().flush();
266        }
267    }
268
269    /// Reset the terminal cursor color to default.
270    pub fn reset_terminal_cursor_color() {
271        use std::io::Write;
272        // OSC 112 resets cursor color to default
273        let _ = write!(std::io::stdout(), "\x1b]112\x07");
274        let _ = std::io::stdout().flush();
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn test_theme_registry_get() {
284        let loader = ThemeLoader::new();
285        let registry = loader.load_all();
286
287        // Should find builtin themes
288        assert!(registry.get("dark").is_some());
289        assert!(registry.get("light").is_some());
290        assert!(registry.get("high-contrast").is_some());
291
292        // Name normalization
293        assert!(registry.get("Dark").is_some());
294        assert!(registry.get("DARK").is_some());
295        assert!(registry.get("high_contrast").is_some());
296
297        // Non-existent
298        assert!(registry.get("nonexistent-theme").is_none());
299    }
300
301    #[test]
302    fn test_theme_registry_list() {
303        let loader = ThemeLoader::new();
304        let registry = loader.load_all();
305
306        let list = registry.list();
307        assert!(list.len() >= 7); // At least the builtin themes
308
309        // Check some expected themes
310        assert!(list.iter().any(|t| t.name == "dark"));
311        assert!(list.iter().any(|t| t.name == "light"));
312    }
313
314    #[test]
315    fn test_theme_registry_contains() {
316        let loader = ThemeLoader::new();
317        let registry = loader.load_all();
318
319        assert!(registry.contains("dark"));
320        assert!(registry.contains("Dark")); // normalized
321        assert!(!registry.contains("nonexistent"));
322    }
323
324    #[test]
325    fn test_theme_loader_load_all() {
326        let loader = ThemeLoader::new();
327        let registry = loader.load_all();
328
329        // Should have loaded all embedded themes
330        assert!(registry.len() >= 7); // 7 root themes (xscriptor moved to external repo)
331
332        // Verify theme content is correct
333        let dark = registry.get("dark").unwrap();
334        assert_eq!(dark.name, "dark");
335    }
336}