1use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use super::types::{Theme, ThemeFile, ThemeInfo, BUILTIN_THEMES};
11
12pub fn normalize_theme_name(name: &str) -> String {
18 name.to_lowercase().replace(['_', ' '], "-")
19}
20
21#[derive(Debug, Clone)]
26pub struct ThemeRegistry {
27 themes: HashMap<String, Theme>,
29 theme_list: Vec<ThemeInfo>,
31}
32
33impl ThemeRegistry {
34 pub fn get(&self, name: &str) -> Option<&Theme> {
36 self.themes.get(&normalize_theme_name(name))
37 }
38
39 pub fn get_cloned(&self, name: &str) -> Option<Theme> {
41 self.get(name).cloned()
42 }
43
44 pub fn list(&self) -> &[ThemeInfo] {
46 &self.theme_list
47 }
48
49 pub fn names(&self) -> Vec<String> {
51 self.theme_list.iter().map(|t| t.name.clone()).collect()
52 }
53
54 pub fn contains(&self, name: &str) -> bool {
56 self.themes.contains_key(&normalize_theme_name(name))
57 }
58
59 pub fn len(&self) -> usize {
61 self.themes.len()
62 }
63
64 pub fn is_empty(&self) -> bool {
66 self.themes.is_empty()
67 }
68
69 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
88pub struct ThemeLoader {
90 user_themes_dir: Option<PathBuf>,
91}
92
93impl ThemeLoader {
94 pub fn new(user_themes_dir: PathBuf) -> Self {
96 Self {
97 user_themes_dir: Some(user_themes_dir),
98 }
99 }
100
101 pub fn embedded_only() -> Self {
103 Self {
104 user_themes_dir: None,
105 }
106 }
107
108 pub fn user_themes_dir(&self) -> Option<&Path> {
110 self.user_themes_dir.as_deref()
111 }
112
113 pub fn load_all(&self) -> ThemeRegistry {
115 let mut themes = HashMap::new();
116 let mut theme_list = Vec::new();
117
118 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 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 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 if !name.starts_with('.') {
145 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 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 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 let manifest: serde_json::Value = match serde_json::from_str(&manifest_content) {
191 Ok(v) => v,
192 Err(_) => return,
193 };
194
195 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 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 let pack_name = format!("pkg/{}", pkg_name);
228 self.scan_directory(pkg_dir, &pack_name, themes, theme_list);
229 }
230
231 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 if pack == "user" && subdir_name == "packages" {
253 continue;
254 }
255
256 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 let raw_name = path.file_stem().unwrap().to_string_lossy().to_string();
266 let name = normalize_theme_name(&raw_name);
267
268 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
285impl Theme {
287 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 #[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 pub fn reset_terminal_cursor_color() {
310 use std::io::Write;
311 #[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 assert!(registry.get("dark").is_some());
331 assert!(registry.get("light").is_some());
332 assert!(registry.get("high-contrast").is_some());
333
334 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 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); 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")); 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 assert!(registry.len() >= 7); let dark = registry.get("dark").unwrap();
377 assert_eq!(dark.name, "dark");
378 }
379
380 #[test]
385 fn test_custom_theme_loading_from_user_dir() {
386 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
388 let themes_dir = temp_dir.path().to_path_buf();
389
390 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 let loader = ThemeLoader::new(themes_dir.clone());
404 let registry = loader.load_all();
405
406 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 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 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 #[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]
456 fn test_custom_theme_package_loading() {
457 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
459 let themes_dir = temp_dir.path().to_path_buf();
460
461 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 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 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 let loader = ThemeLoader::new(themes_dir);
492 let registry = loader.load_all();
493
494 assert!(
496 registry.contains("packaged-theme"),
497 "Packaged theme should be loaded"
498 );
499
500 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 #[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 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 assert!(
548 registry.contains("catppuccin-mocha"),
549 "Theme should be found by normalized filename"
550 );
551
552 assert!(
554 registry.contains("Catppuccin Mocha"),
555 "Theme should be found by JSON name with spaces (normalized to hyphens)"
556 );
557
558 assert!(
560 registry.contains("CATPPUCCIN-MOCHA"),
561 "Theme should be found regardless of casing"
562 );
563
564 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]
575 fn test_custom_theme_in_subdirectory() {
576 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
578 let themes_dir = temp_dir.path().to_path_buf();
579
580 let subdir = themes_dir.join("my-collection");
582 std::fs::create_dir_all(&subdir).expect("Failed to create subdir");
583
584 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 let loader = ThemeLoader::new(themes_dir);
598 let registry = loader.load_all();
599
600 assert!(
602 registry.contains("nested-theme"),
603 "Theme in subdirectory should be loaded"
604 );
605
606 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 assert_eq!(json_map.len(), registry.len());
627
628 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 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}