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, bundle_theme_dirs: &[PathBuf]) -> ThemeRegistry {
119 let mut themes = HashMap::new();
120 let mut theme_list = Vec::new();
121
122 for builtin in BUILTIN_THEMES {
124 if let Ok(theme_file) = serde_json::from_str::<ThemeFile>(builtin.json) {
125 let theme: Theme = theme_file.into();
126 let normalized = normalize_theme_name(builtin.name);
127 themes.insert(normalized.clone(), theme);
128 theme_list.push(ThemeInfo::new(normalized, builtin.pack));
129 }
130 }
131
132 if let Some(ref user_dir) = self.user_themes_dir {
134 self.scan_directory(user_dir, "user", &mut themes, &mut theme_list);
135 }
136
137 if let Some(ref user_dir) = self.user_themes_dir {
140 let packages_dir = user_dir.join("packages");
141 if packages_dir.exists() {
142 if let Ok(entries) = std::fs::read_dir(&packages_dir) {
143 for entry in entries.flatten() {
144 let path = entry.path();
145 if path.is_dir() {
146 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
147 if !name.starts_with('.') {
149 let manifest_path = path.join("package.json");
151 if manifest_path.exists() {
152 self.load_package_themes(
153 &path,
154 name,
155 &mut themes,
156 &mut theme_list,
157 );
158 } else {
159 let pack_name = format!("pkg/{}", name);
161 self.scan_directory(
162 &path,
163 &pack_name,
164 &mut themes,
165 &mut theme_list,
166 );
167 }
168 }
169 }
170 }
171 }
172 }
173 }
174 }
175
176 for bundle_dir in bundle_theme_dirs {
178 if let Some(name) = bundle_dir.file_name().and_then(|n| n.to_str()) {
179 let manifest_path = bundle_dir.join("package.json");
180 if manifest_path.exists() {
181 self.load_package_themes(bundle_dir, name, &mut themes, &mut theme_list);
182 }
183 }
184 }
185
186 ThemeRegistry { themes, theme_list }
187 }
188
189 fn load_package_themes(
191 &self,
192 pkg_dir: &Path,
193 pkg_name: &str,
194 themes: &mut HashMap<String, Theme>,
195 theme_list: &mut Vec<ThemeInfo>,
196 ) {
197 let manifest_path = pkg_dir.join("package.json");
198 let manifest_content = match std::fs::read_to_string(&manifest_path) {
199 Ok(c) => c,
200 Err(_) => return,
201 };
202
203 let manifest: serde_json::Value = match serde_json::from_str(&manifest_content) {
205 Ok(v) => v,
206 Err(_) => return,
207 };
208
209 if let Some(fresh) = manifest.get("fresh") {
211 if let Some(theme_entries) = fresh.get("themes").and_then(|t| t.as_array()) {
212 for entry in theme_entries {
213 if let (Some(file), Some(name)) = (
214 entry.get("file").and_then(|f| f.as_str()),
215 entry.get("name").and_then(|n| n.as_str()),
216 ) {
217 let theme_path = pkg_dir.join(file);
218 if theme_path.exists() {
219 if let Ok(content) = std::fs::read_to_string(&theme_path) {
220 if let Ok(theme_file) = serde_json::from_str::<ThemeFile>(&content)
221 {
222 let theme: Theme = theme_file.into();
223 let normalized_name = normalize_theme_name(name);
224 if !themes.contains_key(&normalized_name) {
226 themes.insert(normalized_name.clone(), theme);
227 let pack_name = format!("pkg/{}", pkg_name);
228 theme_list
229 .push(ThemeInfo::new(normalized_name, &pack_name));
230 }
231 }
232 }
233 }
234 }
235 }
236 return;
237 }
238 }
239
240 let pack_name = format!("pkg/{}", pkg_name);
242 self.scan_directory(pkg_dir, &pack_name, themes, theme_list);
243 }
244
245 fn scan_directory(
247 &self,
248 dir: &Path,
249 pack: &str,
250 themes: &mut HashMap<String, Theme>,
251 theme_list: &mut Vec<ThemeInfo>,
252 ) {
253 let entries = match std::fs::read_dir(dir) {
254 Ok(e) => e,
255 Err(_) => return,
256 };
257
258 for entry in entries.flatten() {
259 let path = entry.path();
260
261 if path.is_dir() {
262 let subdir_name = path.file_name().unwrap().to_string_lossy();
263
264 if pack == "user" && subdir_name == "packages" {
267 continue;
268 }
269
270 let new_pack = if pack == "user" {
272 format!("user/{}", subdir_name)
273 } else {
274 format!("{}/{}", pack, subdir_name)
275 };
276 self.scan_directory(&path, &new_pack, themes, theme_list);
277 } else if path.extension().is_some_and(|ext| ext == "json") {
278 let raw_name = path.file_stem().unwrap().to_string_lossy().to_string();
280 let name = normalize_theme_name(&raw_name);
281
282 if themes.contains_key(&name) {
284 continue;
285 }
286
287 if let Ok(content) = std::fs::read_to_string(&path) {
288 if let Ok(theme_file) = serde_json::from_str::<ThemeFile>(&content) {
289 let theme: Theme = theme_file.into();
290 themes.insert(name.clone(), theme);
291 theme_list.push(ThemeInfo::new(name, pack));
292 }
293 }
294 }
295 }
296 }
297}
298
299impl Theme {
301 pub fn set_terminal_cursor_color(&self) {
304 use super::types::color_to_rgb;
305 use std::io::Write;
306 if let Some((r, g, b)) = color_to_rgb(self.cursor) {
307 #[allow(clippy::let_underscore_must_use)]
310 let _ = write!(
311 std::io::stdout(),
312 "\x1b]12;#{:02x}{:02x}{:02x}\x07",
313 r,
314 g,
315 b
316 );
317 #[allow(clippy::let_underscore_must_use)]
318 let _ = std::io::stdout().flush();
319 }
320 }
321
322 pub fn reset_terminal_cursor_color() {
324 use std::io::Write;
325 #[allow(clippy::let_underscore_must_use)]
328 let _ = write!(std::io::stdout(), "\x1b]112\x07");
329 #[allow(clippy::let_underscore_must_use)]
330 let _ = std::io::stdout().flush();
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 #[test]
339 fn test_theme_registry_get() {
340 let loader = ThemeLoader::embedded_only();
341 let registry = loader.load_all(&[]);
342
343 assert!(registry.get("dark").is_some());
345 assert!(registry.get("light").is_some());
346 assert!(registry.get("high-contrast").is_some());
347
348 assert!(registry.get("Dark").is_some());
350 assert!(registry.get("DARK").is_some());
351 assert!(registry.get("high_contrast").is_some());
352 assert!(registry.get("high contrast").is_some());
353
354 assert!(registry.get("nonexistent-theme").is_none());
356 }
357
358 #[test]
359 fn test_theme_registry_list() {
360 let loader = ThemeLoader::embedded_only();
361 let registry = loader.load_all(&[]);
362
363 let list = registry.list();
364 assert!(list.len() >= 7); assert!(list.iter().any(|t| t.name == "dark"));
368 assert!(list.iter().any(|t| t.name == "light"));
369 }
370
371 #[test]
372 fn test_theme_registry_contains() {
373 let loader = ThemeLoader::embedded_only();
374 let registry = loader.load_all(&[]);
375
376 assert!(registry.contains("dark"));
377 assert!(registry.contains("Dark")); assert!(!registry.contains("nonexistent"));
379 }
380
381 #[test]
382 fn test_theme_loader_load_all() {
383 let loader = ThemeLoader::embedded_only();
384 let registry = loader.load_all(&[]);
385
386 assert!(registry.len() >= 7); let dark = registry.get("dark").unwrap();
391 assert_eq!(dark.name, "dark");
392 }
393
394 #[test]
399 fn test_custom_theme_loading_from_user_dir() {
400 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
402 let themes_dir = temp_dir.path().to_path_buf();
403
404 let theme_json = r#"{
406 "name": "my-custom-theme",
407 "editor": {},
408 "ui": {},
409 "search": {},
410 "diagnostic": {},
411 "syntax": {}
412 }"#;
413 std::fs::write(themes_dir.join("my-custom-theme.json"), theme_json)
414 .expect("Failed to write theme file");
415
416 let loader = ThemeLoader::new(themes_dir.clone());
418 let registry = loader.load_all(&[]);
419
420 assert!(
422 registry.contains("my-custom-theme"),
423 "Custom theme should be loaded from user themes directory"
424 );
425 assert!(
426 registry.get("my-custom-theme").is_some(),
427 "Custom theme should be retrievable"
428 );
429
430 let theme_list = registry.list();
432 assert!(
433 theme_list.iter().any(|t| t.name == "my-custom-theme"),
434 "Custom theme should appear in theme list for Select Theme menu"
435 );
436
437 let theme_info = theme_list
439 .iter()
440 .find(|t| t.name == "my-custom-theme")
441 .unwrap();
442 assert_eq!(
443 theme_info.pack, "user",
444 "Custom theme should have 'user' pack"
445 );
446
447 #[cfg(not(target_arch = "wasm32"))]
450 {
451 let menu_items = crate::config::generate_dynamic_items("copy_with_theme", &themes_dir);
452 let theme_names: Vec<_> = menu_items
453 .iter()
454 .filter_map(|item| match item {
455 crate::config::MenuItem::Action { args, .. } => {
456 args.get("theme").map(|v| v.as_str().unwrap_or_default())
457 }
458 _ => None,
459 })
460 .collect();
461 assert!(
462 theme_names.contains(&"my-custom-theme"),
463 "Custom theme should appear in dynamic menu items"
464 );
465 }
466 }
467
468 #[test]
470 fn test_custom_theme_package_loading() {
471 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
473 let themes_dir = temp_dir.path().to_path_buf();
474
475 let packages_dir = themes_dir.join("packages");
477 let pkg_dir = packages_dir.join("my-theme-pack");
478 std::fs::create_dir_all(&pkg_dir).expect("Failed to create package dir");
479
480 let manifest = r#"{
482 "name": "my-theme-pack",
483 "fresh": {
484 "themes": [
485 { "name": "Packaged Theme", "file": "packaged-theme.json" }
486 ]
487 }
488 }"#;
489 std::fs::write(pkg_dir.join("package.json"), manifest)
490 .expect("Failed to write package.json");
491
492 let theme_json = r#"{
494 "name": "packaged-theme",
495 "editor": {},
496 "ui": {},
497 "search": {},
498 "diagnostic": {},
499 "syntax": {}
500 }"#;
501 std::fs::write(pkg_dir.join("packaged-theme.json"), theme_json)
502 .expect("Failed to write theme file");
503
504 let loader = ThemeLoader::new(themes_dir);
506 let registry = loader.load_all(&[]);
507
508 assert!(
510 registry.contains("packaged-theme"),
511 "Packaged theme should be loaded"
512 );
513
514 let theme_list = registry.list();
516 let theme_info = theme_list
517 .iter()
518 .find(|t| t.name == "packaged-theme")
519 .expect("Packaged theme should be in theme list");
520 assert_eq!(
521 theme_info.pack, "pkg/my-theme-pack",
522 "Packaged theme should have correct pack name"
523 );
524 }
525
526 #[test]
527 fn test_normalize_theme_name() {
528 assert_eq!(normalize_theme_name("dark"), "dark");
529 assert_eq!(normalize_theme_name("Dark"), "dark");
530 assert_eq!(normalize_theme_name("high_contrast"), "high-contrast");
531 assert_eq!(normalize_theme_name("Catppuccin Mocha"), "catppuccin-mocha");
532 assert_eq!(normalize_theme_name("My_Custom Theme"), "my-custom-theme");
533 assert_eq!(normalize_theme_name("SOLARIZED_DARK"), "solarized-dark");
534 }
535
536 #[test]
540 fn test_theme_name_mismatch_json_vs_filename() {
541 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
542 let themes_dir = temp_dir.path().to_path_buf();
543
544 let theme_json = r#"{
547 "name": "Catppuccin Mocha",
548 "editor": {},
549 "ui": {},
550 "search": {},
551 "diagnostic": {},
552 "syntax": {}
553 }"#;
554 std::fs::write(themes_dir.join("catppuccin-mocha.json"), theme_json)
555 .expect("Failed to write theme file");
556
557 let loader = ThemeLoader::new(themes_dir);
558 let registry = loader.load_all(&[]);
559
560 assert!(
562 registry.contains("catppuccin-mocha"),
563 "Theme should be found by normalized filename"
564 );
565
566 assert!(
568 registry.contains("Catppuccin Mocha"),
569 "Theme should be found by JSON name with spaces (normalized to hyphens)"
570 );
571
572 assert!(
574 registry.contains("CATPPUCCIN-MOCHA"),
575 "Theme should be found regardless of casing"
576 );
577
578 let theme_list = registry.list();
580 let theme_info = theme_list
581 .iter()
582 .find(|t| t.name == "catppuccin-mocha")
583 .expect("Theme should appear with normalized name in theme list");
584 assert_eq!(theme_info.pack, "user");
585 }
586
587 #[test]
589 fn test_custom_theme_in_subdirectory() {
590 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
592 let themes_dir = temp_dir.path().to_path_buf();
593
594 let subdir = themes_dir.join("my-collection");
596 std::fs::create_dir_all(&subdir).expect("Failed to create subdir");
597
598 let theme_json = r#"{
600 "name": "nested-theme",
601 "editor": {},
602 "ui": {},
603 "search": {},
604 "diagnostic": {},
605 "syntax": {}
606 }"#;
607 std::fs::write(subdir.join("nested-theme.json"), theme_json)
608 .expect("Failed to write theme file");
609
610 let loader = ThemeLoader::new(themes_dir);
612 let registry = loader.load_all(&[]);
613
614 assert!(
616 registry.contains("nested-theme"),
617 "Theme in subdirectory should be loaded"
618 );
619
620 let theme_list = registry.list();
622 let theme_info = theme_list
623 .iter()
624 .find(|t| t.name == "nested-theme")
625 .expect("Nested theme should be in theme list");
626 assert_eq!(
627 theme_info.pack, "user/my-collection",
628 "Nested theme should have subdirectory in pack name"
629 );
630 }
631
632 #[test]
633 fn test_to_json_map() {
634 let loader = ThemeLoader::embedded_only();
635 let registry = loader.load_all(&[]);
636
637 let json_map = registry.to_json_map();
638
639 assert_eq!(json_map.len(), registry.len());
641
642 let dark = json_map
644 .get("dark")
645 .expect("dark theme should be in json map");
646 assert!(dark.is_object(), "theme should serialize to a JSON object");
647 assert_eq!(
648 dark.get("name").and_then(|v| v.as_str()),
649 Some("dark"),
650 "theme JSON should have correct name"
651 );
652
653 assert!(dark.get("editor").is_some(), "should have editor section");
655 assert!(dark.get("ui").is_some(), "should have ui section");
656 assert!(dark.get("syntax").is_some(), "should have syntax section");
657 }
658}