fresh/view/theme/
loader.rs1use std::path::{Path, PathBuf};
7
8use super::types::{Theme, ThemeFile, BUILTIN_THEMES};
9
10pub trait ThemeLoader: Send + Sync {
17 fn load_theme(&self, name: &str) -> Option<String>;
20
21 fn available_themes(&self) -> Vec<String>;
23
24 fn theme_exists(&self, name: &str) -> bool {
26 self.load_theme(name).is_some()
27 }
28}
29
30pub struct LocalThemeLoader {
36 user_themes_dir: Option<PathBuf>,
37}
38
39impl LocalThemeLoader {
40 pub fn new() -> Self {
42 Self {
43 user_themes_dir: dirs::config_dir().map(|p| p.join("fresh").join("themes")),
44 }
45 }
46
47 pub fn with_user_dir(user_themes_dir: Option<PathBuf>) -> Self {
49 Self { user_themes_dir }
50 }
51
52 pub fn user_themes_dir(&self) -> Option<&Path> {
54 self.user_themes_dir.as_deref()
55 }
56
57 fn load_from_path(path: &Path) -> Option<String> {
59 std::fs::read_to_string(path).ok()
60 }
61
62 fn theme_paths(&self, name: &str) -> Vec<PathBuf> {
64 let mut paths = Vec::new();
65
66 if let Some(ref user_dir) = self.user_themes_dir {
68 paths.push(user_dir.join(format!("{}.json", name)));
69 }
70
71 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 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 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
134impl Theme {
136 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 pub fn load(name: &str, loader: &dyn ThemeLoader) -> Option<Self> {
148 let normalized = name.to_lowercase().replace('_', "-");
149
150 if let Some(theme) = Self::load_builtin(&normalized) {
152 return Some(theme);
153 }
154
155 loader
157 .load_theme(&normalized)
158 .and_then(|json| Self::from_json(&json).ok())
159 }
160
161 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 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 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 pub fn reset_terminal_cursor_color() {
194 use std::io::Write;
195 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 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 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 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 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}