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)]
30pub struct ThemeRegistry {
31 themes: HashMap<String, Theme>,
33 theme_list: Vec<ThemeInfo>,
35}
36
37impl ThemeRegistry {
38 pub fn get(&self, key_or_name: &str) -> Option<&Theme> {
43 if let Some(theme) = self.themes.get(key_or_name) {
45 return Some(theme);
46 }
47 let normalized = normalize_theme_name(key_or_name);
49 self.theme_list
50 .iter()
51 .find(|info| normalize_theme_name(&info.name) == normalized)
52 .and_then(|info| self.themes.get(&info.key))
53 }
54
55 pub fn get_cloned(&self, key_or_name: &str) -> Option<Theme> {
57 self.get(key_or_name).cloned()
58 }
59
60 pub fn resolve_key<'a>(&'a self, key_or_name: &'a str) -> Option<&'a str> {
62 if self.themes.contains_key(key_or_name) {
63 return Some(key_or_name);
64 }
65 let normalized = normalize_theme_name(key_or_name);
66 self.theme_list
67 .iter()
68 .find(|info| normalize_theme_name(&info.name) == normalized)
69 .map(|info| info.key.as_str())
70 }
71
72 pub fn list(&self) -> &[ThemeInfo] {
74 &self.theme_list
75 }
76
77 pub fn names(&self) -> Vec<String> {
79 self.theme_list.iter().map(|t| t.name.clone()).collect()
80 }
81
82 pub fn contains(&self, key_or_name: &str) -> bool {
84 self.get(key_or_name).is_some()
85 }
86
87 pub fn len(&self) -> usize {
89 self.themes.len()
90 }
91
92 pub fn is_empty(&self) -> bool {
94 self.themes.is_empty()
95 }
96
97 pub fn to_json_map(&self) -> HashMap<String, serde_json::Value> {
103 use super::types::ThemeFile;
104
105 let mut map = HashMap::new();
106 for info in &self.theme_list {
107 if let Some(theme) = self.themes.get(&info.key) {
108 let theme_file: ThemeFile = theme.clone().into();
109 if let Ok(mut v) = serde_json::to_value(theme_file) {
110 if let Some(obj) = v.as_object_mut() {
111 obj.insert("_key".to_string(), serde_json::json!(info.key));
112 obj.insert("_pack".to_string(), serde_json::json!(info.pack));
113 }
114 map.insert(info.key.clone(), v);
115 }
116 }
117 }
118 map
119 }
120}
121
122pub struct ThemeLoader {
124 user_themes_dir: Option<PathBuf>,
125}
126
127impl ThemeLoader {
128 pub fn new(user_themes_dir: PathBuf) -> Self {
130 Self {
131 user_themes_dir: Some(user_themes_dir),
132 }
133 }
134
135 pub fn embedded_only() -> Self {
137 Self {
138 user_themes_dir: None,
139 }
140 }
141
142 pub fn user_themes_dir(&self) -> Option<&Path> {
144 self.user_themes_dir.as_deref()
145 }
146
147 pub fn load_all(&self, bundle_theme_dirs: &[PathBuf]) -> ThemeRegistry {
153 let mut themes = HashMap::new();
154 let mut theme_list = Vec::new();
155
156 for builtin in BUILTIN_THEMES {
158 if let Ok(theme_file) = serde_json::from_str::<ThemeFile>(builtin.json) {
159 let theme: Theme = theme_file.into();
160 let normalized = normalize_theme_name(builtin.name);
161 let info = ThemeInfo::new(&normalized, builtin.pack);
162 themes.insert(info.key.clone(), theme);
163 theme_list.push(info);
164 }
165 }
166
167 if let Some(ref user_dir) = self.user_themes_dir {
169 self.scan_directory(user_dir, "user", None, &mut themes, &mut theme_list);
170 }
171
172 if let Some(ref user_dir) = self.user_themes_dir {
174 let packages_dir = user_dir.join("packages");
175 if packages_dir.exists() {
176 if let Ok(entries) = std::fs::read_dir(&packages_dir) {
177 for entry in entries.flatten() {
178 let path = entry.path();
179 if path.is_dir() {
180 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
181 if !name.starts_with('.') {
182 let manifest_path = path.join("package.json");
183 if manifest_path.exists() {
184 self.load_package_themes(
185 &path,
186 name,
187 &mut themes,
188 &mut theme_list,
189 );
190 } else {
191 let pack_name = format!("pkg/{}", name);
192 self.scan_directory(
193 &path,
194 &pack_name,
195 None,
196 &mut themes,
197 &mut theme_list,
198 );
199 }
200 }
201 }
202 }
203 }
204 }
205 }
206 }
207
208 for bundle_dir in bundle_theme_dirs {
210 if let Some(name) = bundle_dir.file_name().and_then(|n| n.to_str()) {
211 let manifest_path = bundle_dir.join("package.json");
212 if manifest_path.exists() {
213 self.load_package_themes(bundle_dir, name, &mut themes, &mut theme_list);
214 }
215 }
216 }
217
218 ThemeRegistry { themes, theme_list }
219 }
220
221 fn read_repository(manifest: &serde_json::Value) -> Option<String> {
223 manifest
224 .get("repository")
225 .and_then(|v| v.as_str())
226 .map(|s| s.to_string())
227 }
228
229 fn load_package_themes(
231 &self,
232 pkg_dir: &Path,
233 pkg_name: &str,
234 themes: &mut HashMap<String, Theme>,
235 theme_list: &mut Vec<ThemeInfo>,
236 ) {
237 let manifest_path = pkg_dir.join("package.json");
238 let manifest_content = match std::fs::read_to_string(&manifest_path) {
239 Ok(c) => c,
240 Err(_) => return,
241 };
242
243 let manifest: serde_json::Value = match serde_json::from_str(&manifest_content) {
244 Ok(v) => v,
245 Err(_) => return,
246 };
247
248 let repository = Self::read_repository(&manifest);
249 let pack_name = format!("pkg/{}", pkg_name);
250
251 if let Some(fresh) = manifest.get("fresh") {
253 if let Some(theme_entries) = fresh.get("themes").and_then(|t| t.as_array()) {
254 for entry in theme_entries {
255 if let (Some(file), Some(name)) = (
256 entry.get("file").and_then(|f| f.as_str()),
257 entry.get("name").and_then(|n| n.as_str()),
258 ) {
259 let theme_path = pkg_dir.join(file);
260 if theme_path.exists() {
261 if let Ok(content) = std::fs::read_to_string(&theme_path) {
262 if let Ok(theme_file) = serde_json::from_str::<ThemeFile>(&content)
263 {
264 let theme: Theme = theme_file.into();
265 let normalized_name = normalize_theme_name(name);
266 let info = if let Some(ref repo) = repository {
267 ThemeInfo::with_key(
268 &normalized_name,
269 &pack_name,
270 format!("{}#{}", repo, normalized_name),
271 )
272 } else {
273 ThemeInfo::new(&normalized_name, &pack_name)
274 };
275 if !themes.contains_key(&info.key) {
276 themes.insert(info.key.clone(), theme);
277 theme_list.push(info);
278 }
279 }
280 }
281 }
282 }
283 }
284 return;
285 }
286 }
287
288 self.scan_directory(
290 pkg_dir,
291 &pack_name,
292 repository.as_deref(),
293 themes,
294 theme_list,
295 );
296 }
297
298 fn scan_directory(
303 &self,
304 dir: &Path,
305 pack: &str,
306 repository: Option<&str>,
307 themes: &mut HashMap<String, Theme>,
308 theme_list: &mut Vec<ThemeInfo>,
309 ) {
310 let entries = match std::fs::read_dir(dir) {
311 Ok(e) => e,
312 Err(_) => return,
313 };
314
315 for entry in entries.flatten() {
316 let path = entry.path();
317
318 if path.is_dir() {
319 let subdir_name = path.file_name().unwrap().to_string_lossy();
320
321 if pack == "user" && subdir_name == "packages" {
324 continue;
325 }
326
327 let new_pack = if pack == "user" {
328 format!("user/{}", subdir_name)
329 } else {
330 format!("{}/{}", pack, subdir_name)
331 };
332 self.scan_directory(&path, &new_pack, repository, themes, theme_list);
333 } else if path.extension().is_some_and(|ext| ext == "json") {
334 if let Ok(content) = std::fs::read_to_string(&path) {
335 if let Ok(theme_file) = serde_json::from_str::<ThemeFile>(&content) {
336 let name = normalize_theme_name(&theme_file.name);
337 let info = if let Some(repo) = repository {
338 ThemeInfo::with_key(&name, pack, format!("{}#{}", repo, name))
339 } else if pack.starts_with("user") {
340 ThemeInfo::with_key(&name, pack, format!("file://{}", path.display()))
342 } else {
343 ThemeInfo::new(&name, pack)
344 };
345
346 if themes.contains_key(&info.key) {
348 continue;
349 }
350
351 let theme: Theme = theme_file.into();
352 themes.insert(info.key.clone(), theme);
353 theme_list.push(info);
354 }
355 }
356 }
357 }
358 }
359}
360
361impl Theme {
363 pub fn set_terminal_cursor_color(&self) {
366 use super::types::color_to_rgb;
367 use std::io::Write;
368 if let Some((r, g, b)) = color_to_rgb(self.cursor) {
369 #[allow(clippy::let_underscore_must_use)]
372 let _ = write!(
373 std::io::stdout(),
374 "\x1b]12;#{:02x}{:02x}{:02x}\x07",
375 r,
376 g,
377 b
378 );
379 #[allow(clippy::let_underscore_must_use)]
380 let _ = std::io::stdout().flush();
381 }
382 }
383
384 pub fn reset_terminal_cursor_color() {
386 use std::io::Write;
387 #[allow(clippy::let_underscore_must_use)]
390 let _ = write!(std::io::stdout(), "\x1b]112\x07");
391 #[allow(clippy::let_underscore_must_use)]
392 let _ = std::io::stdout().flush();
393 }
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 #[test]
401 fn test_theme_registry_get() {
402 let loader = ThemeLoader::embedded_only();
403 let registry = loader.load_all(&[]);
404
405 assert!(registry.get("dark").is_some());
407 assert!(registry.get("light").is_some());
408 assert!(registry.get("high-contrast").is_some());
409
410 assert!(registry.get("Dark").is_some());
412 assert!(registry.get("DARK").is_some());
413 assert!(registry.get("high_contrast").is_some());
414 assert!(registry.get("high contrast").is_some());
415
416 assert!(registry.get("nonexistent-theme").is_none());
418 }
419
420 #[test]
421 fn test_theme_registry_list() {
422 let loader = ThemeLoader::embedded_only();
423 let registry = loader.load_all(&[]);
424
425 let list = registry.list();
426 assert!(list.len() >= 7); assert!(list.iter().any(|t| t.name == "dark"));
430 assert!(list.iter().any(|t| t.name == "light"));
431 }
432
433 #[test]
434 fn test_theme_registry_contains() {
435 let loader = ThemeLoader::embedded_only();
436 let registry = loader.load_all(&[]);
437
438 assert!(registry.contains("dark"));
439 assert!(registry.contains("Dark")); assert!(!registry.contains("nonexistent"));
441 }
442
443 #[test]
444 fn test_theme_loader_load_all() {
445 let loader = ThemeLoader::embedded_only();
446 let registry = loader.load_all(&[]);
447
448 assert!(registry.len() >= 7); let dark = registry.get("dark").unwrap();
453 assert_eq!(dark.name, "dark");
454 }
455
456 #[test]
461 fn test_custom_theme_loading_from_user_dir() {
462 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
464 let themes_dir = temp_dir.path().to_path_buf();
465
466 let theme_json = r#"{
468 "name": "my-custom-theme",
469 "editor": {},
470 "ui": {},
471 "search": {},
472 "diagnostic": {},
473 "syntax": {}
474 }"#;
475 std::fs::write(themes_dir.join("my-custom-theme.json"), theme_json)
476 .expect("Failed to write theme file");
477
478 let loader = ThemeLoader::new(themes_dir.clone());
480 let registry = loader.load_all(&[]);
481
482 assert!(
484 registry.contains("my-custom-theme"),
485 "Custom theme should be loaded from user themes directory"
486 );
487 assert!(
488 registry.get("my-custom-theme").is_some(),
489 "Custom theme should be retrievable"
490 );
491
492 let theme_list = registry.list();
494 assert!(
495 theme_list.iter().any(|t| t.name == "my-custom-theme"),
496 "Custom theme should appear in theme list for Select Theme menu"
497 );
498
499 let theme_info = theme_list
501 .iter()
502 .find(|t| t.name == "my-custom-theme")
503 .unwrap();
504 assert_eq!(
505 theme_info.pack, "user",
506 "Custom theme should have 'user' pack"
507 );
508
509 #[cfg(not(target_arch = "wasm32"))]
513 {
514 let menu_items = crate::config::generate_dynamic_items("copy_with_theme", &themes_dir);
515 let theme_keys: Vec<_> = menu_items
516 .iter()
517 .filter_map(|item| match item {
518 crate::config::MenuItem::Action { args, .. } => args
519 .get("theme")
520 .map(|v| v.as_str().unwrap_or_default().to_string()),
521 _ => None,
522 })
523 .collect();
524 assert!(
525 theme_keys.iter().any(|k| k.contains("my-custom-theme")),
526 "Custom theme key should appear in dynamic menu items, got: {:?}",
527 theme_keys
528 );
529 }
530 }
531
532 #[test]
534 fn test_custom_theme_package_loading() {
535 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
537 let themes_dir = temp_dir.path().to_path_buf();
538
539 let packages_dir = themes_dir.join("packages");
541 let pkg_dir = packages_dir.join("my-theme-pack");
542 std::fs::create_dir_all(&pkg_dir).expect("Failed to create package dir");
543
544 let manifest = r#"{
546 "name": "my-theme-pack",
547 "fresh": {
548 "themes": [
549 { "name": "Packaged Theme", "file": "packaged-theme.json" }
550 ]
551 }
552 }"#;
553 std::fs::write(pkg_dir.join("package.json"), manifest)
554 .expect("Failed to write package.json");
555
556 let theme_json = r#"{
558 "name": "packaged-theme",
559 "editor": {},
560 "ui": {},
561 "search": {},
562 "diagnostic": {},
563 "syntax": {}
564 }"#;
565 std::fs::write(pkg_dir.join("packaged-theme.json"), theme_json)
566 .expect("Failed to write theme file");
567
568 let loader = ThemeLoader::new(themes_dir);
570 let registry = loader.load_all(&[]);
571
572 assert!(
574 registry.contains("packaged-theme"),
575 "Packaged theme should be loaded"
576 );
577
578 let theme_list = registry.list();
580 let theme_info = theme_list
581 .iter()
582 .find(|t| t.name == "packaged-theme")
583 .expect("Packaged theme should be in theme list");
584 assert_eq!(
585 theme_info.pack, "pkg/my-theme-pack",
586 "Packaged theme should have correct pack name"
587 );
588 }
589
590 #[test]
591 fn test_normalize_theme_name() {
592 assert_eq!(normalize_theme_name("dark"), "dark");
593 assert_eq!(normalize_theme_name("Dark"), "dark");
594 assert_eq!(normalize_theme_name("high_contrast"), "high-contrast");
595 assert_eq!(normalize_theme_name("Catppuccin Mocha"), "catppuccin-mocha");
596 assert_eq!(normalize_theme_name("My_Custom Theme"), "my-custom-theme");
597 assert_eq!(normalize_theme_name("SOLARIZED_DARK"), "solarized-dark");
598 }
599
600 #[test]
604 fn test_theme_name_mismatch_json_vs_filename() {
605 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
606 let themes_dir = temp_dir.path().to_path_buf();
607
608 let theme_json = r#"{
611 "name": "Catppuccin Mocha",
612 "editor": {},
613 "ui": {},
614 "search": {},
615 "diagnostic": {},
616 "syntax": {}
617 }"#;
618 std::fs::write(themes_dir.join("catppuccin-mocha.json"), theme_json)
619 .expect("Failed to write theme file");
620
621 let loader = ThemeLoader::new(themes_dir);
622 let registry = loader.load_all(&[]);
623
624 assert!(
626 registry.contains("catppuccin-mocha"),
627 "Theme should be found by normalized filename"
628 );
629
630 assert!(
632 registry.contains("Catppuccin Mocha"),
633 "Theme should be found by JSON name with spaces (normalized to hyphens)"
634 );
635
636 assert!(
638 registry.contains("CATPPUCCIN-MOCHA"),
639 "Theme should be found regardless of casing"
640 );
641
642 let theme_list = registry.list();
644 let theme_info = theme_list
645 .iter()
646 .find(|t| t.name == "catppuccin-mocha")
647 .expect("Theme should appear with normalized name in theme list");
648 assert_eq!(theme_info.pack, "user");
649 }
650
651 #[test]
653 fn test_custom_theme_in_subdirectory() {
654 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
656 let themes_dir = temp_dir.path().to_path_buf();
657
658 let subdir = themes_dir.join("my-collection");
660 std::fs::create_dir_all(&subdir).expect("Failed to create subdir");
661
662 let theme_json = r#"{
664 "name": "nested-theme",
665 "editor": {},
666 "ui": {},
667 "search": {},
668 "diagnostic": {},
669 "syntax": {}
670 }"#;
671 std::fs::write(subdir.join("nested-theme.json"), theme_json)
672 .expect("Failed to write theme file");
673
674 let loader = ThemeLoader::new(themes_dir);
676 let registry = loader.load_all(&[]);
677
678 assert!(
680 registry.contains("nested-theme"),
681 "Theme in subdirectory should be loaded"
682 );
683
684 let theme_list = registry.list();
686 let theme_info = theme_list
687 .iter()
688 .find(|t| t.name == "nested-theme")
689 .expect("Nested theme should be in theme list");
690 assert_eq!(
691 theme_info.pack, "user/my-collection",
692 "Nested theme should have subdirectory in pack name"
693 );
694 }
695
696 #[test]
697 fn test_to_json_map() {
698 let loader = ThemeLoader::embedded_only();
699 let registry = loader.load_all(&[]);
700
701 let json_map = registry.to_json_map();
702
703 assert_eq!(json_map.len(), registry.len());
705
706 let dark = json_map
708 .get("dark")
709 .expect("dark theme should be in json map");
710 assert!(dark.is_object(), "theme should serialize to a JSON object");
711 assert_eq!(
712 dark.get("name").and_then(|v| v.as_str()),
713 Some("dark"),
714 "theme JSON should have correct name"
715 );
716
717 assert!(dark.get("editor").is_some(), "should have editor section");
719 assert!(dark.get("ui").is_some(), "should have ui section");
720 assert!(dark.get("syntax").is_some(), "should have syntax section");
721
722 assert_eq!(
724 dark.get("_key").and_then(|v| v.as_str()),
725 Some("dark"),
726 "theme JSON should have _key metadata"
727 );
728 assert!(
729 dark.get("_pack").is_some(),
730 "theme JSON should have _pack metadata"
731 );
732 }
733}