fresh/view/theme/
loader.rs

1//! Theme loading with I/O abstraction.
2//!
3//! This module provides the `ThemeLoader` trait for loading themes from various sources,
4//! and `LocalThemeLoader` as the default filesystem-based implementation.
5
6use std::path::{Path, PathBuf};
7
8use super::types::{Theme, ThemeFile, BUILTIN_THEMES};
9
10/// Trait for loading theme files from various sources.
11///
12/// This abstraction allows:
13/// - Testing with mock implementations
14/// - WASM builds with fetch-based loaders
15/// - Custom theme sources (network, embedded, etc.)
16pub trait ThemeLoader: Send + Sync {
17    /// Load theme JSON content by name.
18    /// Returns None if theme doesn't exist.
19    fn load_theme(&self, name: &str) -> Option<String>;
20
21    /// List all available theme names from this loader.
22    fn available_themes(&self) -> Vec<String>;
23
24    /// Check if a theme exists by name.
25    fn theme_exists(&self, name: &str) -> bool {
26        self.load_theme(name).is_some()
27    }
28}
29
30/// Default implementation using local filesystem.
31///
32/// Searches for themes in:
33/// 1. User themes directory (~/.config/fresh/themes/)
34/// 2. Built-in themes directory (themes/ relative paths)
35pub struct LocalThemeLoader {
36    user_themes_dir: Option<PathBuf>,
37}
38
39impl LocalThemeLoader {
40    /// Create a new LocalThemeLoader with default directories.
41    pub fn new() -> Self {
42        Self {
43            user_themes_dir: dirs::config_dir().map(|p| p.join("fresh").join("themes")),
44        }
45    }
46
47    /// Create a LocalThemeLoader with a custom user themes directory.
48    pub fn with_user_dir(user_themes_dir: Option<PathBuf>) -> Self {
49        Self { user_themes_dir }
50    }
51
52    /// Get the user themes directory path.
53    pub fn user_themes_dir(&self) -> Option<&Path> {
54        self.user_themes_dir.as_deref()
55    }
56
57    /// Try to load a theme from a specific file path.
58    fn load_from_path(path: &Path) -> Option<String> {
59        std::fs::read_to_string(path).ok()
60    }
61
62    /// Get paths to search for a theme by name.
63    fn theme_paths(&self, name: &str) -> Vec<PathBuf> {
64        let mut paths = Vec::new();
65
66        // User themes directory (highest priority)
67        if let Some(ref user_dir) = self.user_themes_dir {
68            paths.push(user_dir.join(format!("{}.json", name)));
69        }
70
71        // Built-in themes directory (various relative paths for development)
72        paths.extend([
73            PathBuf::from(format!("themes/{}.json", name)),
74            PathBuf::from(format!("../themes/{}.json", name)),
75            PathBuf::from(format!("../../themes/{}.json", name)),
76        ]);
77
78        paths
79    }
80}
81
82impl Default for LocalThemeLoader {
83    fn default() -> Self {
84        Self::new()
85    }
86}
87
88impl ThemeLoader for LocalThemeLoader {
89    fn load_theme(&self, name: &str) -> Option<String> {
90        for path in self.theme_paths(name) {
91            if let Some(content) = Self::load_from_path(&path) {
92                return Some(content);
93            }
94        }
95        None
96    }
97
98    fn available_themes(&self) -> Vec<String> {
99        let mut themes = Vec::new();
100
101        // Scan built-in themes directory
102        if let Ok(entries) = std::fs::read_dir("themes") {
103            for entry in entries.flatten() {
104                let path = entry.path();
105                if path.extension().is_some_and(|ext| ext == "json") {
106                    if let Some(stem) = path.file_stem() {
107                        themes.push(stem.to_string_lossy().to_string());
108                    }
109                }
110            }
111        }
112
113        // Scan user themes directory
114        if let Some(ref user_dir) = self.user_themes_dir {
115            if let Ok(entries) = std::fs::read_dir(user_dir) {
116                for entry in entries.flatten() {
117                    let path = entry.path();
118                    if path.extension().is_some_and(|ext| ext == "json") {
119                        if let Some(stem) = path.file_stem() {
120                            let name = stem.to_string_lossy().to_string();
121                            if !themes.contains(&name) {
122                                themes.push(name);
123                            }
124                        }
125                    }
126                }
127            }
128        }
129
130        themes
131    }
132}
133
134// Extension methods on Theme that use ThemeLoader
135impl Theme {
136    /// Load a theme from a JSON file path.
137    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, String> {
138        let content = std::fs::read_to_string(path)
139            .map_err(|e| format!("Failed to read theme file: {}", e))?;
140        let theme_file: ThemeFile = serde_json::from_str(&content)
141            .map_err(|e| format!("Failed to parse theme file: {}", e))?;
142        Ok(theme_file.into())
143    }
144
145    /// Load theme by name using a ThemeLoader.
146    /// First checks builtin themes (embedded), then uses loader for filesystem themes.
147    pub fn load(name: &str, loader: &dyn ThemeLoader) -> Option<Self> {
148        let normalized = name.to_lowercase().replace('_', "-");
149
150        // Try builtin first (no I/O)
151        if let Some(theme) = Self::load_builtin(&normalized) {
152            return Some(theme);
153        }
154
155        // Try loader
156        loader
157            .load_theme(&normalized)
158            .and_then(|json| Self::from_json(&json).ok())
159    }
160
161    /// Get all available themes (builtin + from loader).
162    pub fn all_available(loader: &dyn ThemeLoader) -> Vec<String> {
163        let mut themes: Vec<String> = BUILTIN_THEMES.iter().map(|t| t.name.to_string()).collect();
164
165        for name in loader.available_themes() {
166            if !themes.contains(&name) {
167                themes.push(name);
168            }
169        }
170
171        themes
172    }
173
174    /// Set the terminal cursor color using OSC 12 escape sequence.
175    /// This makes the hardware cursor visible on any background.
176    pub fn set_terminal_cursor_color(&self) {
177        use super::types::color_to_rgb;
178        use std::io::Write;
179        if let Some((r, g, b)) = color_to_rgb(self.cursor) {
180            // OSC 12 sets cursor color: \x1b]12;#RRGGBB\x07
181            let _ = write!(
182                std::io::stdout(),
183                "\x1b]12;#{:02x}{:02x}{:02x}\x07",
184                r,
185                g,
186                b
187            );
188            let _ = std::io::stdout().flush();
189        }
190    }
191
192    /// Reset the terminal cursor color to default.
193    pub fn reset_terminal_cursor_color() {
194        use std::io::Write;
195        // OSC 112 resets cursor color to default
196        let _ = write!(std::io::stdout(), "\x1b]112\x07");
197        let _ = std::io::stdout().flush();
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use std::collections::HashMap;
205
206    /// Mock theme loader for testing
207    struct MockThemeLoader {
208        themes: HashMap<String, String>,
209    }
210
211    impl MockThemeLoader {
212        fn new() -> Self {
213            Self {
214                themes: HashMap::new(),
215            }
216        }
217
218        fn with_theme(mut self, name: &str, json: &str) -> Self {
219            self.themes.insert(name.to_string(), json.to_string());
220            self
221        }
222    }
223
224    impl ThemeLoader for MockThemeLoader {
225        fn load_theme(&self, name: &str) -> Option<String> {
226            self.themes.get(name).cloned()
227        }
228
229        fn available_themes(&self) -> Vec<String> {
230            self.themes.keys().cloned().collect()
231        }
232    }
233
234    #[test]
235    fn test_mock_theme_loader() {
236        let loader = MockThemeLoader::new().with_theme(
237            "custom",
238            r#"{"name":"custom","editor":{},"ui":{},"search":{},"diagnostic":{},"syntax":{}}"#,
239        );
240
241        assert!(loader.theme_exists("custom"));
242        assert!(!loader.theme_exists("nonexistent"));
243
244        let themes = loader.available_themes();
245        assert!(themes.contains(&"custom".to_string()));
246    }
247
248    #[test]
249    fn test_theme_load_with_mock() {
250        let loader = MockThemeLoader::new().with_theme(
251            "test-theme",
252            r#"{"name":"test-theme","editor":{},"ui":{},"search":{},"diagnostic":{},"syntax":{}}"#,
253        );
254
255        let theme = Theme::load("test-theme", &loader);
256        assert!(theme.is_some());
257        assert_eq!(theme.unwrap().name, "test-theme");
258    }
259
260    #[test]
261    fn test_theme_load_builtin_priority() {
262        // Builtin themes should be loaded even if loader doesn't have them
263        let loader = MockThemeLoader::new();
264
265        let theme = Theme::load("dark", &loader);
266        assert!(theme.is_some());
267        assert_eq!(theme.unwrap().name, "dark");
268    }
269
270    #[test]
271    fn test_load_with_loader() {
272        // load should work for builtin themes with any loader
273        let loader = LocalThemeLoader::new();
274        let theme = Theme::load("dark", &loader);
275        assert!(theme.is_some());
276        assert_eq!(theme.unwrap().name, "dark");
277
278        let theme = Theme::load("light", &loader);
279        assert!(theme.is_some());
280        assert_eq!(theme.unwrap().name, "light");
281    }
282
283    #[test]
284    fn test_all_available_themes() {
285        let loader = LocalThemeLoader::new();
286        let themes = Theme::all_available(&loader);
287        // Should have at least the builtin themes
288        assert!(themes.len() >= 4);
289        assert!(themes.contains(&"dark".to_string()));
290        assert!(themes.contains(&"light".to_string()));
291        assert!(themes.contains(&"high-contrast".to_string()));
292        assert!(themes.contains(&"nostalgia".to_string()));
293    }
294}