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