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/// Normalize a theme name for consistent lookup and storage.
13///
14/// Converts to lowercase and replaces underscores and spaces with hyphens.
15/// This ensures that theme names can be matched regardless of how they appear
16/// in filenames vs. JSON content (e.g., "Catppuccin Mocha" matches "catppuccin-mocha").
17pub fn normalize_theme_name(name: &str) -> String {
18    name.to_lowercase().replace(['_', ' '], "-")
19}
20
21/// A registry holding all loaded themes.
22///
23/// This is a pure data structure - no I/O operations.
24/// Use `ThemeLoader` to create and populate a registry.
25#[derive(Debug, Clone)]
26pub struct ThemeRegistry {
27    /// All loaded themes, keyed by name
28    themes: HashMap<String, Theme>,
29    /// Theme metadata for listing
30    theme_list: Vec<ThemeInfo>,
31}
32
33impl ThemeRegistry {
34    /// Get a theme by name.
35    pub fn get(&self, name: &str) -> Option<&Theme> {
36        self.themes.get(&normalize_theme_name(name))
37    }
38
39    /// Get a cloned theme by name.
40    pub fn get_cloned(&self, name: &str) -> Option<Theme> {
41        self.get(name).cloned()
42    }
43
44    /// List all available themes with metadata.
45    pub fn list(&self) -> &[ThemeInfo] {
46        &self.theme_list
47    }
48
49    /// Get all theme names.
50    pub fn names(&self) -> Vec<String> {
51        self.theme_list.iter().map(|t| t.name.clone()).collect()
52    }
53
54    /// Check if a theme exists.
55    pub fn contains(&self, name: &str) -> bool {
56        self.themes.contains_key(&normalize_theme_name(name))
57    }
58
59    /// Number of themes in the registry.
60    pub fn len(&self) -> usize {
61        self.themes.len()
62    }
63
64    /// Check if registry is empty.
65    pub fn is_empty(&self) -> bool {
66        self.themes.is_empty()
67    }
68
69    /// Convert all themes to a JSON map (name → serde_json::Value).
70    ///
71    /// Uses the existing `From<Theme> for ThemeFile` conversion to produce
72    /// JSON that matches what the theme files look like on disk.
73    pub fn to_json_map(&self) -> HashMap<String, serde_json::Value> {
74        use super::types::ThemeFile;
75
76        self.themes
77            .iter()
78            .filter_map(|(name, theme)| {
79                let theme_file: ThemeFile = theme.clone().into();
80                serde_json::to_value(theme_file)
81                    .ok()
82                    .map(|v| (name.clone(), v))
83            })
84            .collect()
85    }
86}
87
88/// Loads themes and creates a ThemeRegistry.
89pub struct ThemeLoader {
90    user_themes_dir: Option<PathBuf>,
91}
92
93impl ThemeLoader {
94    /// Create a ThemeLoader with the given user themes directory.
95    pub fn new(user_themes_dir: PathBuf) -> Self {
96        Self {
97            user_themes_dir: Some(user_themes_dir),
98        }
99    }
100
101    /// Create a ThemeLoader for embedded themes only (no user themes).
102    pub fn embedded_only() -> Self {
103        Self {
104            user_themes_dir: None,
105        }
106    }
107
108    /// Get the user themes directory path.
109    pub fn user_themes_dir(&self) -> Option<&Path> {
110        self.user_themes_dir.as_deref()
111    }
112
113    /// Load all themes (embedded + user + packages + bundle dirs) into a registry.
114    ///
115    /// Pass `&[]` for `bundle_theme_dirs` if there are no bundle themes.
116    /// Each bundle directory should contain a `package.json` with a `fresh.themes`
117    /// array (same format as theme packages).
118    pub fn load_all(&self, bundle_theme_dirs: &[PathBuf]) -> ThemeRegistry {
119        let mut themes = HashMap::new();
120        let mut theme_list = Vec::new();
121
122        // Load all embedded themes
123        for builtin in BUILTIN_THEMES {
124            if let Ok(theme_file) = serde_json::from_str::<ThemeFile>(builtin.json) {
125                let theme: Theme = theme_file.into();
126                let normalized = normalize_theme_name(builtin.name);
127                themes.insert(normalized.clone(), theme);
128                theme_list.push(ThemeInfo::new(normalized, builtin.pack));
129            }
130        }
131
132        // Load user themes from ~/.config/fresh/themes/ (recursively)
133        if let Some(ref user_dir) = self.user_themes_dir {
134            self.scan_directory(user_dir, "user", &mut themes, &mut theme_list);
135        }
136
137        // Load theme packages from ~/.config/fresh/themes/packages/*/
138        // Each package directory may contain multiple theme JSON files
139        if let Some(ref user_dir) = self.user_themes_dir {
140            let packages_dir = user_dir.join("packages");
141            if packages_dir.exists() {
142                if let Ok(entries) = std::fs::read_dir(&packages_dir) {
143                    for entry in entries.flatten() {
144                        let path = entry.path();
145                        if path.is_dir() {
146                            if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
147                                // Skip hidden directories (like .index)
148                                if !name.starts_with('.') {
149                                    // Check for package.json to get theme metadata
150                                    let manifest_path = path.join("package.json");
151                                    if manifest_path.exists() {
152                                        self.load_package_themes(
153                                            &path,
154                                            name,
155                                            &mut themes,
156                                            &mut theme_list,
157                                        );
158                                    } else {
159                                        // Fallback: scan directory for JSON files
160                                        let pack_name = format!("pkg/{}", name);
161                                        self.scan_directory(
162                                            &path,
163                                            &pack_name,
164                                            &mut themes,
165                                            &mut theme_list,
166                                        );
167                                    }
168                                }
169                            }
170                        }
171                    }
172                }
173            }
174        }
175
176        // Load themes from bundle packages
177        for bundle_dir in bundle_theme_dirs {
178            if let Some(name) = bundle_dir.file_name().and_then(|n| n.to_str()) {
179                let manifest_path = bundle_dir.join("package.json");
180                if manifest_path.exists() {
181                    self.load_package_themes(bundle_dir, name, &mut themes, &mut theme_list);
182                }
183            }
184        }
185
186        ThemeRegistry { themes, theme_list }
187    }
188
189    /// Load themes from a package with package.json manifest.
190    fn load_package_themes(
191        &self,
192        pkg_dir: &Path,
193        pkg_name: &str,
194        themes: &mut HashMap<String, Theme>,
195        theme_list: &mut Vec<ThemeInfo>,
196    ) {
197        let manifest_path = pkg_dir.join("package.json");
198        let manifest_content = match std::fs::read_to_string(&manifest_path) {
199            Ok(c) => c,
200            Err(_) => return,
201        };
202
203        // Parse manifest to find theme entries
204        let manifest: serde_json::Value = match serde_json::from_str(&manifest_content) {
205            Ok(v) => v,
206            Err(_) => return,
207        };
208
209        // Check for fresh.themes array in manifest
210        if let Some(fresh) = manifest.get("fresh") {
211            if let Some(theme_entries) = fresh.get("themes").and_then(|t| t.as_array()) {
212                for entry in theme_entries {
213                    if let (Some(file), Some(name)) = (
214                        entry.get("file").and_then(|f| f.as_str()),
215                        entry.get("name").and_then(|n| n.as_str()),
216                    ) {
217                        let theme_path = pkg_dir.join(file);
218                        if theme_path.exists() {
219                            if let Ok(content) = std::fs::read_to_string(&theme_path) {
220                                if let Ok(theme_file) = serde_json::from_str::<ThemeFile>(&content)
221                                {
222                                    let theme: Theme = theme_file.into();
223                                    let normalized_name = normalize_theme_name(name);
224                                    // Don't overwrite existing themes
225                                    if !themes.contains_key(&normalized_name) {
226                                        themes.insert(normalized_name.clone(), theme);
227                                        let pack_name = format!("pkg/{}", pkg_name);
228                                        theme_list
229                                            .push(ThemeInfo::new(normalized_name, &pack_name));
230                                    }
231                                }
232                            }
233                        }
234                    }
235                }
236                return;
237            }
238        }
239
240        // Fallback: if no fresh.themes, scan for JSON files
241        let pack_name = format!("pkg/{}", pkg_name);
242        self.scan_directory(pkg_dir, &pack_name, themes, theme_list);
243    }
244
245    /// Recursively scan a directory for theme files.
246    fn scan_directory(
247        &self,
248        dir: &Path,
249        pack: &str,
250        themes: &mut HashMap<String, Theme>,
251        theme_list: &mut Vec<ThemeInfo>,
252    ) {
253        let entries = match std::fs::read_dir(dir) {
254            Ok(e) => e,
255            Err(_) => return,
256        };
257
258        for entry in entries.flatten() {
259            let path = entry.path();
260
261            if path.is_dir() {
262                let subdir_name = path.file_name().unwrap().to_string_lossy();
263
264                // Skip "packages" subdirectory at top level - it's handled separately
265                // by load_package_themes for proper package metadata
266                if pack == "user" && subdir_name == "packages" {
267                    continue;
268                }
269
270                // Recurse into subdirectory with updated pack name
271                let new_pack = if pack == "user" {
272                    format!("user/{}", subdir_name)
273                } else {
274                    format!("{}/{}", pack, subdir_name)
275                };
276                self.scan_directory(&path, &new_pack, themes, theme_list);
277            } else if path.extension().is_some_and(|ext| ext == "json") {
278                // Load theme file
279                let raw_name = path.file_stem().unwrap().to_string_lossy().to_string();
280                let name = normalize_theme_name(&raw_name);
281
282                // Skip if already loaded (embedded themes take priority)
283                if themes.contains_key(&name) {
284                    continue;
285                }
286
287                if let Ok(content) = std::fs::read_to_string(&path) {
288                    if let Ok(theme_file) = serde_json::from_str::<ThemeFile>(&content) {
289                        let theme: Theme = theme_file.into();
290                        themes.insert(name.clone(), theme);
291                        theme_list.push(ThemeInfo::new(name, pack));
292                    }
293                }
294            }
295        }
296    }
297}
298
299// Cursor color methods on Theme (no I/O for theme loading)
300impl Theme {
301    /// Set the terminal cursor color using OSC 12 escape sequence.
302    /// This makes the hardware cursor visible on any background.
303    pub fn set_terminal_cursor_color(&self) {
304        use super::types::color_to_rgb;
305        use std::io::Write;
306        if let Some((r, g, b)) = color_to_rgb(self.cursor) {
307            // OSC 12 sets cursor color: \x1b]12;#RRGGBB\x07
308            // Best-effort terminal escape writes
309            #[allow(clippy::let_underscore_must_use)]
310            let _ = write!(
311                std::io::stdout(),
312                "\x1b]12;#{:02x}{:02x}{:02x}\x07",
313                r,
314                g,
315                b
316            );
317            #[allow(clippy::let_underscore_must_use)]
318            let _ = std::io::stdout().flush();
319        }
320    }
321
322    /// Reset the terminal cursor color to default.
323    pub fn reset_terminal_cursor_color() {
324        use std::io::Write;
325        // OSC 112 resets cursor color to default
326        // Best-effort terminal escape writes
327        #[allow(clippy::let_underscore_must_use)]
328        let _ = write!(std::io::stdout(), "\x1b]112\x07");
329        #[allow(clippy::let_underscore_must_use)]
330        let _ = std::io::stdout().flush();
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    #[test]
339    fn test_theme_registry_get() {
340        let loader = ThemeLoader::embedded_only();
341        let registry = loader.load_all(&[]);
342
343        // Should find builtin themes
344        assert!(registry.get("dark").is_some());
345        assert!(registry.get("light").is_some());
346        assert!(registry.get("high-contrast").is_some());
347
348        // Name normalization: casing, underscores, spaces
349        assert!(registry.get("Dark").is_some());
350        assert!(registry.get("DARK").is_some());
351        assert!(registry.get("high_contrast").is_some());
352        assert!(registry.get("high contrast").is_some());
353
354        // Non-existent
355        assert!(registry.get("nonexistent-theme").is_none());
356    }
357
358    #[test]
359    fn test_theme_registry_list() {
360        let loader = ThemeLoader::embedded_only();
361        let registry = loader.load_all(&[]);
362
363        let list = registry.list();
364        assert!(list.len() >= 7); // At least the builtin themes
365
366        // Check some expected themes
367        assert!(list.iter().any(|t| t.name == "dark"));
368        assert!(list.iter().any(|t| t.name == "light"));
369    }
370
371    #[test]
372    fn test_theme_registry_contains() {
373        let loader = ThemeLoader::embedded_only();
374        let registry = loader.load_all(&[]);
375
376        assert!(registry.contains("dark"));
377        assert!(registry.contains("Dark")); // normalized
378        assert!(!registry.contains("nonexistent"));
379    }
380
381    #[test]
382    fn test_theme_loader_load_all() {
383        let loader = ThemeLoader::embedded_only();
384        let registry = loader.load_all(&[]);
385
386        // Should have loaded all embedded themes
387        assert!(registry.len() >= 7); // 7 root themes (xscriptor moved to external repo)
388
389        // Verify theme content is correct
390        let dark = registry.get("dark").unwrap();
391        assert_eq!(dark.name, "dark");
392    }
393
394    /// Test that custom themes in user themes directory are loaded and available.
395    /// This is a regression test for the macOS bug where themes in ~/.config/fresh/themes/
396    /// were not appearing in the "Select Theme" command because ThemeLoader was using
397    /// the wrong directory path on macOS.
398    #[test]
399    fn test_custom_theme_loading_from_user_dir() {
400        // Create isolated temp directory for this test
401        let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
402        let themes_dir = temp_dir.path().to_path_buf();
403
404        // Create a custom theme file directly in the themes directory
405        let theme_json = r#"{
406            "name": "my-custom-theme",
407            "editor": {},
408            "ui": {},
409            "search": {},
410            "diagnostic": {},
411            "syntax": {}
412        }"#;
413        std::fs::write(themes_dir.join("my-custom-theme.json"), theme_json)
414            .expect("Failed to write theme file");
415
416        // Load themes with the custom themes directory
417        let loader = ThemeLoader::new(themes_dir.clone());
418        let registry = loader.load_all(&[]);
419
420        // Verify the custom theme is loaded
421        assert!(
422            registry.contains("my-custom-theme"),
423            "Custom theme should be loaded from user themes directory"
424        );
425        assert!(
426            registry.get("my-custom-theme").is_some(),
427            "Custom theme should be retrievable"
428        );
429
430        // Verify it appears in the theme list (used for "Select Theme" menu)
431        let theme_list = registry.list();
432        assert!(
433            theme_list.iter().any(|t| t.name == "my-custom-theme"),
434            "Custom theme should appear in theme list for Select Theme menu"
435        );
436
437        // Verify the theme has the correct pack metadata
438        let theme_info = theme_list
439            .iter()
440            .find(|t| t.name == "my-custom-theme")
441            .unwrap();
442        assert_eq!(
443            theme_info.pack, "user",
444            "Custom theme should have 'user' pack"
445        );
446
447        // Verify the theme is also available via generate_dynamic_items
448        // (the function used for Select Theme menu items)
449        #[cfg(not(target_arch = "wasm32"))]
450        {
451            let menu_items = crate::config::generate_dynamic_items("copy_with_theme", &themes_dir);
452            let theme_names: Vec<_> = menu_items
453                .iter()
454                .filter_map(|item| match item {
455                    crate::config::MenuItem::Action { args, .. } => {
456                        args.get("theme").map(|v| v.as_str().unwrap_or_default())
457                    }
458                    _ => None,
459                })
460                .collect();
461            assert!(
462                theme_names.contains(&"my-custom-theme"),
463                "Custom theme should appear in dynamic menu items"
464            );
465        }
466    }
467
468    /// Test that custom themes in a package directory (with package.json) are loaded.
469    #[test]
470    fn test_custom_theme_package_loading() {
471        // Create isolated temp directory for this test
472        let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
473        let themes_dir = temp_dir.path().to_path_buf();
474
475        // Create packages subdirectory
476        let packages_dir = themes_dir.join("packages");
477        let pkg_dir = packages_dir.join("my-theme-pack");
478        std::fs::create_dir_all(&pkg_dir).expect("Failed to create package dir");
479
480        // Create package.json manifest
481        let manifest = r#"{
482            "name": "my-theme-pack",
483            "fresh": {
484                "themes": [
485                    { "name": "Packaged Theme", "file": "packaged-theme.json" }
486                ]
487            }
488        }"#;
489        std::fs::write(pkg_dir.join("package.json"), manifest)
490            .expect("Failed to write package.json");
491
492        // Create the theme file referenced in package.json
493        let theme_json = r#"{
494            "name": "packaged-theme",
495            "editor": {},
496            "ui": {},
497            "search": {},
498            "diagnostic": {},
499            "syntax": {}
500        }"#;
501        std::fs::write(pkg_dir.join("packaged-theme.json"), theme_json)
502            .expect("Failed to write theme file");
503
504        // Load themes
505        let loader = ThemeLoader::new(themes_dir);
506        let registry = loader.load_all(&[]);
507
508        // Verify the packaged theme is loaded (name is normalized from "Packaged Theme")
509        assert!(
510            registry.contains("packaged-theme"),
511            "Packaged theme should be loaded"
512        );
513
514        // Verify it appears in the theme list with correct pack name
515        let theme_list = registry.list();
516        let theme_info = theme_list
517            .iter()
518            .find(|t| t.name == "packaged-theme")
519            .expect("Packaged theme should be in theme list");
520        assert_eq!(
521            theme_info.pack, "pkg/my-theme-pack",
522            "Packaged theme should have correct pack name"
523        );
524    }
525
526    #[test]
527    fn test_normalize_theme_name() {
528        assert_eq!(normalize_theme_name("dark"), "dark");
529        assert_eq!(normalize_theme_name("Dark"), "dark");
530        assert_eq!(normalize_theme_name("high_contrast"), "high-contrast");
531        assert_eq!(normalize_theme_name("Catppuccin Mocha"), "catppuccin-mocha");
532        assert_eq!(normalize_theme_name("My_Custom Theme"), "my-custom-theme");
533        assert_eq!(normalize_theme_name("SOLARIZED_DARK"), "solarized-dark");
534    }
535
536    /// Regression test for #1001: theme whose JSON "name" field differs from the
537    /// filename (e.g., filename "catppuccin-mocha.json" but JSON name "Catppuccin Mocha")
538    /// should be findable by either name after normalization.
539    #[test]
540    fn test_theme_name_mismatch_json_vs_filename() {
541        let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
542        let themes_dir = temp_dir.path().to_path_buf();
543
544        // Simulate a theme where the JSON name has spaces/mixed case
545        // but the filename uses hyphens (common for community themes)
546        let theme_json = r#"{
547            "name": "Catppuccin Mocha",
548            "editor": {},
549            "ui": {},
550            "search": {},
551            "diagnostic": {},
552            "syntax": {}
553        }"#;
554        std::fs::write(themes_dir.join("catppuccin-mocha.json"), theme_json)
555            .expect("Failed to write theme file");
556
557        let loader = ThemeLoader::new(themes_dir);
558        let registry = loader.load_all(&[]);
559
560        // Should be findable by the normalized filename
561        assert!(
562            registry.contains("catppuccin-mocha"),
563            "Theme should be found by normalized filename"
564        );
565
566        // Should also be findable by the JSON name (spaces normalized to hyphens)
567        assert!(
568            registry.contains("Catppuccin Mocha"),
569            "Theme should be found by JSON name with spaces (normalized to hyphens)"
570        );
571
572        // Should also be findable with mixed casing
573        assert!(
574            registry.contains("CATPPUCCIN-MOCHA"),
575            "Theme should be found regardless of casing"
576        );
577
578        // The registry key should be the normalized form
579        let theme_list = registry.list();
580        let theme_info = theme_list
581            .iter()
582            .find(|t| t.name == "catppuccin-mocha")
583            .expect("Theme should appear with normalized name in theme list");
584        assert_eq!(theme_info.pack, "user");
585    }
586
587    /// Test that themes in subdirectories of the user themes directory are loaded.
588    #[test]
589    fn test_custom_theme_in_subdirectory() {
590        // Create isolated temp directory for this test
591        let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
592        let themes_dir = temp_dir.path().to_path_buf();
593
594        // Create a subdirectory
595        let subdir = themes_dir.join("my-collection");
596        std::fs::create_dir_all(&subdir).expect("Failed to create subdir");
597
598        // Create a theme in the subdirectory
599        let theme_json = r#"{
600            "name": "nested-theme",
601            "editor": {},
602            "ui": {},
603            "search": {},
604            "diagnostic": {},
605            "syntax": {}
606        }"#;
607        std::fs::write(subdir.join("nested-theme.json"), theme_json)
608            .expect("Failed to write theme file");
609
610        // Load themes
611        let loader = ThemeLoader::new(themes_dir);
612        let registry = loader.load_all(&[]);
613
614        // Verify the nested theme is loaded
615        assert!(
616            registry.contains("nested-theme"),
617            "Theme in subdirectory should be loaded"
618        );
619
620        // Verify pack name includes the subdirectory
621        let theme_list = registry.list();
622        let theme_info = theme_list
623            .iter()
624            .find(|t| t.name == "nested-theme")
625            .expect("Nested theme should be in theme list");
626        assert_eq!(
627            theme_info.pack, "user/my-collection",
628            "Nested theme should have subdirectory in pack name"
629        );
630    }
631
632    #[test]
633    fn test_to_json_map() {
634        let loader = ThemeLoader::embedded_only();
635        let registry = loader.load_all(&[]);
636
637        let json_map = registry.to_json_map();
638
639        // Should contain all themes
640        assert_eq!(json_map.len(), registry.len());
641
642        // Each entry should be a valid JSON object with a "name" field
643        let dark = json_map
644            .get("dark")
645            .expect("dark theme should be in json map");
646        assert!(dark.is_object(), "theme should serialize to a JSON object");
647        assert_eq!(
648            dark.get("name").and_then(|v| v.as_str()),
649            Some("dark"),
650            "theme JSON should have correct name"
651        );
652
653        // Should have the expected section keys
654        assert!(dark.get("editor").is_some(), "should have editor section");
655        assert!(dark.get("ui").is_some(), "should have ui section");
656        assert!(dark.get("syntax").is_some(), "should have syntax section");
657    }
658}