1use std::collections::HashMap;
63use std::path::{Path, PathBuf};
64
65use console::Style;
66
67use crate::colorspace::ThemePalette;
68
69use super::super::style::{
70 parse_stylesheet, StyleValidationError, StyleValue, Styles, StylesheetError, ThemeVariants,
71};
72
73use super::adaptive::ColorMode;
74use super::icon_def::{IconDefinition, IconSet};
75use super::icon_mode::IconMode;
76
77#[derive(Debug, Clone)]
117pub struct Theme {
118 name: Option<String>,
120 source_path: Option<PathBuf>,
122 base: HashMap<String, Style>,
124 light: HashMap<String, Style>,
126 dark: HashMap<String, Style>,
128 aliases: HashMap<String, String>,
130 icons: IconSet,
132 palette: Option<ThemePalette>,
134}
135
136impl Theme {
137 pub fn new() -> Self {
139 Self {
140 name: None,
141 source_path: None,
142 base: HashMap::new(),
143 light: HashMap::new(),
144 dark: HashMap::new(),
145 aliases: HashMap::new(),
146 icons: IconSet::new(),
147 palette: None,
148 }
149 }
150
151 pub fn named(name: impl Into<String>) -> Self {
153 Self {
154 name: Some(name.into()),
155 source_path: None,
156 base: HashMap::new(),
157 light: HashMap::new(),
158 dark: HashMap::new(),
159 aliases: HashMap::new(),
160 icons: IconSet::new(),
161 palette: None,
162 }
163 }
164
165 pub fn with_name(mut self, name: impl Into<String>) -> Self {
170 self.name = Some(name.into());
171 self
172 }
173
174 pub fn with_palette(mut self, palette: ThemePalette) -> Self {
190 self.palette = Some(palette);
191 self
192 }
193
194 pub fn palette(&self) -> Option<&ThemePalette> {
196 self.palette.as_ref()
197 }
198
199 pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, StylesheetError> {
217 let path = path.as_ref();
218 let content = std::fs::read_to_string(path).map_err(|e| StylesheetError::Load {
219 message: format!("Failed to read {}: {}", path.display(), e),
220 })?;
221
222 let name = path
223 .file_stem()
224 .and_then(|s| s.to_str())
225 .map(|s| s.to_string());
226
227 let icons = parse_icons_from_yaml_str(&content)?;
228 let variants = parse_stylesheet(&content, None)?;
229 Ok(Self {
230 name,
231 source_path: Some(path.to_path_buf()),
232 base: variants.base().clone(),
233 light: variants.light().clone(),
234 dark: variants.dark().clone(),
235 aliases: variants.aliases().clone(),
236 icons,
237 palette: None,
238 })
239 }
240
241 pub fn from_yaml(yaml: &str) -> Result<Self, StylesheetError> {
272 let icons = parse_icons_from_yaml_str(yaml)?;
273 let variants = parse_stylesheet(yaml, None)?;
274 Ok(Self {
275 name: None,
276 source_path: None,
277 base: variants.base().clone(),
278 light: variants.light().clone(),
279 dark: variants.dark().clone(),
280 aliases: variants.aliases().clone(),
281 icons,
282 palette: None,
283 })
284 }
285
286 pub fn from_css(css: &str) -> Result<Self, StylesheetError> {
307 let variants = crate::parse_css(css, None)?;
308 Ok(Self {
309 name: None,
310 source_path: None,
311 base: variants.base().clone(),
312 light: variants.light().clone(),
313 dark: variants.dark().clone(),
314 aliases: variants.aliases().clone(),
315 icons: IconSet::new(),
316 palette: None,
317 })
318 }
319
320 pub fn from_css_file<P: AsRef<Path>>(path: P) -> Result<Self, StylesheetError> {
338 let path = path.as_ref();
339 let content = std::fs::read_to_string(path).map_err(|e| StylesheetError::Load {
340 message: format!("Failed to read {}: {}", path.display(), e),
341 })?;
342
343 let name = path
344 .file_stem()
345 .and_then(|s| s.to_str())
346 .map(|s| s.to_string());
347
348 let variants = crate::parse_css(&content, None)?;
349 Ok(Self {
350 name,
351 source_path: Some(path.to_path_buf()),
352 base: variants.base().clone(),
353 light: variants.light().clone(),
354 dark: variants.dark().clone(),
355 aliases: variants.aliases().clone(),
356 icons: IconSet::new(),
357 palette: None,
358 })
359 }
360
361 pub fn from_variants(variants: ThemeVariants) -> Self {
363 Self {
364 name: None,
365 source_path: None,
366 base: variants.base().clone(),
367 light: variants.light().clone(),
368 dark: variants.dark().clone(),
369 aliases: variants.aliases().clone(),
370 icons: IconSet::new(),
371 palette: None,
372 }
373 }
374
375 pub fn name(&self) -> Option<&str> {
380 self.name.as_deref()
381 }
382
383 pub fn source_path(&self) -> Option<&Path> {
385 self.source_path.as_deref()
386 }
387
388 pub fn refresh(&mut self) -> Result<(), StylesheetError> {
408 let path = self
409 .source_path
410 .as_ref()
411 .ok_or_else(|| StylesheetError::Load {
412 message: "Cannot refresh: theme has no source file".to_string(),
413 })?;
414
415 let content = std::fs::read_to_string(path).map_err(|e| StylesheetError::Load {
416 message: format!("Failed to read {}: {}", path.display(), e),
417 })?;
418
419 let icons = parse_icons_from_yaml_str(&content)?;
420 let variants = parse_stylesheet(&content, self.palette.as_ref())?;
421 self.base = variants.base().clone();
422 self.light = variants.light().clone();
423 self.dark = variants.dark().clone();
424 self.aliases = variants.aliases().clone();
425 self.icons = icons;
426
427 Ok(())
428 }
429
430 pub fn add<V: Into<StyleValue>>(mut self, name: &str, value: V) -> Self {
457 match value.into() {
458 StyleValue::Concrete(style) => {
459 self.base.insert(name.to_string(), style);
460 }
461 StyleValue::Alias(target) => {
462 self.aliases.insert(name.to_string(), target);
463 }
464 }
465 self
466 }
467
468 pub fn add_adaptive(
489 mut self,
490 name: &str,
491 base: Style,
492 light: Option<Style>,
493 dark: Option<Style>,
494 ) -> Self {
495 self.base.insert(name.to_string(), base);
496 if let Some(light_style) = light {
497 self.light.insert(name.to_string(), light_style);
498 }
499 if let Some(dark_style) = dark {
500 self.dark.insert(name.to_string(), dark_style);
501 }
502 self
503 }
504
505 pub fn add_icon(mut self, name: &str, def: IconDefinition) -> Self {
521 self.icons.insert(name.to_string(), def);
522 self
523 }
524
525 pub fn resolve_icons(&self, mode: IconMode) -> HashMap<String, String> {
544 self.icons.resolve(mode)
545 }
546
547 pub fn icons(&self) -> &IconSet {
549 &self.icons
550 }
551
552 pub fn resolve_styles(&self, mode: Option<ColorMode>) -> Styles {
580 let mut styles = Styles::new();
581
582 let mode_overrides = match mode {
584 Some(ColorMode::Light) => &self.light,
585 Some(ColorMode::Dark) => &self.dark,
586 None => &HashMap::new(),
587 };
588
589 for (name, base_style) in &self.base {
591 let style = mode_overrides.get(name).unwrap_or(base_style);
592 styles = styles.add(name, style.clone());
593 }
594
595 for (name, target) in &self.aliases {
597 styles = styles.add(name, target.clone());
598 }
599
600 styles
601 }
602
603 pub fn validate(&self) -> Result<(), StyleValidationError> {
608 self.resolve_styles(None).validate()
610 }
611
612 pub fn is_empty(&self) -> bool {
614 self.base.is_empty() && self.aliases.is_empty()
615 }
616
617 pub fn len(&self) -> usize {
619 self.base.len() + self.aliases.len()
620 }
621
622 pub fn get_style(&self, name: &str, mode: Option<ColorMode>) -> Option<Style> {
626 let styles = self.resolve_styles(mode);
627 styles.resolve(name).cloned()
633 }
634
635 pub fn light_override_count(&self) -> usize {
637 self.light.len()
638 }
639
640 pub fn dark_override_count(&self) -> usize {
642 self.dark.len()
643 }
644
645 pub fn merge(mut self, other: Theme) -> Self {
663 self.base.extend(other.base);
664 self.light.extend(other.light);
665 self.dark.extend(other.dark);
666 self.aliases.extend(other.aliases);
667 self.icons = self.icons.merge(other.icons);
668 if other.palette.is_some() {
669 self.palette = other.palette;
670 }
671 self
672 }
673}
674
675impl Default for Theme {
676 fn default() -> Self {
677 use console::{Color, Style};
678
679 Self::new()
689 .add("table_row_even", Style::new())
691 .add_adaptive(
692 "table_row_odd",
693 Style::new(),
694 Some(Style::new().bg(Color::Color256(254))),
695 Some(Style::new().bg(Color::Color256(236))),
696 )
697 .add("table_row_even_gray", "table_row_even")
699 .add("table_row_odd_gray", "table_row_odd")
700 .add("table_row_even_blue", Style::new())
702 .add_adaptive(
703 "table_row_odd_blue",
704 Style::new(),
705 Some(Style::new().bg(Color::Color256(189))),
706 Some(Style::new().bg(Color::Color256(17))),
707 )
708 .add("table_row_even_red", Style::new())
710 .add_adaptive(
711 "table_row_odd_red",
712 Style::new(),
713 Some(Style::new().bg(Color::Color256(224))),
714 Some(Style::new().bg(Color::Color256(52))),
715 )
716 .add("table_row_even_green", Style::new())
718 .add_adaptive(
719 "table_row_odd_green",
720 Style::new(),
721 Some(Style::new().bg(Color::Color256(194))),
722 Some(Style::new().bg(Color::Color256(22))),
723 )
724 .add("table_row_even_purple", Style::new())
726 .add_adaptive(
727 "table_row_odd_purple",
728 Style::new(),
729 Some(Style::new().bg(Color::Color256(225))),
730 Some(Style::new().bg(Color::Color256(53))),
731 )
732 }
733}
734
735fn parse_icons_from_yaml_str(yaml: &str) -> Result<IconSet, StylesheetError> {
753 let root: serde_yaml::Value =
754 serde_yaml::from_str(yaml).map_err(|e| StylesheetError::Parse {
755 path: None,
756 message: e.to_string(),
757 })?;
758
759 parse_icons_from_yaml(&root)
760}
761
762fn parse_icons_from_yaml(root: &serde_yaml::Value) -> Result<IconSet, StylesheetError> {
764 let mut icon_set = IconSet::new();
765
766 let mapping = match root.as_mapping() {
767 Some(m) => m,
768 None => return Ok(icon_set),
769 };
770
771 let icons_value = match mapping.get(serde_yaml::Value::String("icons".into())) {
772 Some(v) => v,
773 None => return Ok(icon_set),
774 };
775
776 let icons_map = icons_value
777 .as_mapping()
778 .ok_or_else(|| StylesheetError::Parse {
779 path: None,
780 message: "'icons' must be a mapping".to_string(),
781 })?;
782
783 for (key, value) in icons_map {
784 let name = key.as_str().ok_or_else(|| StylesheetError::Parse {
785 path: None,
786 message: format!("Icon name must be a string, got {:?}", key),
787 })?;
788
789 let def = match value {
790 serde_yaml::Value::String(s) => {
791 IconDefinition::new(s.clone())
793 }
794 serde_yaml::Value::Mapping(map) => {
795 let classic = map
796 .get(serde_yaml::Value::String("classic".into()))
797 .and_then(|v| v.as_str())
798 .ok_or_else(|| StylesheetError::InvalidDefinition {
799 style: name.to_string(),
800 message: "Icon mapping must have a 'classic' key".to_string(),
801 path: None,
802 })?;
803 let nerdfont = map
804 .get(serde_yaml::Value::String("nerdfont".into()))
805 .and_then(|v| v.as_str());
806 let mut def = IconDefinition::new(classic);
807 if let Some(nf) = nerdfont {
808 def = def.with_nerdfont(nf);
809 }
810 def
811 }
812 _ => {
813 return Err(StylesheetError::InvalidDefinition {
814 style: name.to_string(),
815 message: "Icon must be a string or mapping with 'classic' key".to_string(),
816 path: None,
817 });
818 }
819 };
820
821 icon_set.insert(name.to_string(), def);
822 }
823
824 Ok(icon_set)
825}
826
827#[cfg(test)]
828mod tests {
829 use super::*;
830
831 #[test]
832 fn test_theme_new_is_empty() {
833 let theme = Theme::new();
834 assert!(theme.is_empty());
835 assert_eq!(theme.len(), 0);
836 }
837
838 #[test]
839 fn test_theme_add_concrete() {
840 let theme = Theme::new().add("bold", Style::new().bold());
841 assert!(!theme.is_empty());
842 assert_eq!(theme.len(), 1);
843 }
844
845 #[test]
846 fn test_theme_add_alias_str() {
847 let theme = Theme::new()
848 .add("base", Style::new().dim())
849 .add("alias", "base");
850
851 assert_eq!(theme.len(), 2);
852
853 let styles = theme.resolve_styles(None);
854 assert!(styles.has("base"));
855 assert!(styles.has("alias"));
856 }
857
858 #[test]
859 fn test_theme_add_alias_string() {
860 let target = String::from("base");
861 let theme = Theme::new()
862 .add("base", Style::new().dim())
863 .add("alias", target);
864
865 let styles = theme.resolve_styles(None);
866 assert!(styles.has("alias"));
867 }
868
869 #[test]
870 fn test_theme_validate_valid() {
871 let theme = Theme::new()
872 .add("visual", Style::new().cyan())
873 .add("semantic", "visual");
874
875 assert!(theme.validate().is_ok());
876 }
877
878 #[test]
879 fn test_theme_validate_invalid() {
880 let theme = Theme::new().add("orphan", "missing");
881 assert!(theme.validate().is_err());
882 }
883
884 #[test]
885 fn test_theme_default() {
886 let theme = Theme::default();
887 assert!(!theme.is_empty());
889 let styles = theme.resolve_styles(Some(crate::ColorMode::Dark));
890 assert!(styles.resolve("table_row_even").is_some());
891 assert!(styles.resolve("table_row_odd").is_some());
892 }
893
894 #[test]
899 fn test_theme_add_adaptive() {
900 let theme = Theme::new().add_adaptive(
901 "panel",
902 Style::new().dim(),
903 Some(Style::new().bold()),
904 Some(Style::new().italic()),
905 );
906
907 assert_eq!(theme.len(), 1);
908 assert_eq!(theme.light_override_count(), 1);
909 assert_eq!(theme.dark_override_count(), 1);
910 }
911
912 #[test]
913 fn test_theme_add_adaptive_light_only() {
914 let theme =
915 Theme::new().add_adaptive("panel", Style::new().dim(), Some(Style::new().bold()), None);
916
917 assert_eq!(theme.light_override_count(), 1);
918 assert_eq!(theme.dark_override_count(), 0);
919 }
920
921 #[test]
922 fn test_theme_add_adaptive_dark_only() {
923 let theme =
924 Theme::new().add_adaptive("panel", Style::new().dim(), None, Some(Style::new().bold()));
925
926 assert_eq!(theme.light_override_count(), 0);
927 assert_eq!(theme.dark_override_count(), 1);
928 }
929
930 #[test]
931 fn test_theme_resolve_styles_no_mode() {
932 let theme = Theme::new()
933 .add("header", Style::new().cyan())
934 .add_adaptive(
935 "panel",
936 Style::new().dim(),
937 Some(Style::new().bold()),
938 Some(Style::new().italic()),
939 );
940
941 let styles = theme.resolve_styles(None);
942 assert!(styles.has("header"));
943 assert!(styles.has("panel"));
944 }
945
946 #[test]
947 fn test_theme_resolve_styles_light_mode() {
948 let theme = Theme::new().add_adaptive(
949 "panel",
950 Style::new().dim(),
951 Some(Style::new().bold()),
952 Some(Style::new().italic()),
953 );
954
955 let styles = theme.resolve_styles(Some(ColorMode::Light));
956 assert!(styles.has("panel"));
957 }
960
961 #[test]
962 fn test_theme_resolve_styles_dark_mode() {
963 let theme = Theme::new().add_adaptive(
964 "panel",
965 Style::new().dim(),
966 Some(Style::new().bold()),
967 Some(Style::new().italic()),
968 );
969
970 let styles = theme.resolve_styles(Some(ColorMode::Dark));
971 assert!(styles.has("panel"));
972 }
973
974 #[test]
975 fn test_theme_resolve_styles_preserves_aliases() {
976 let theme = Theme::new()
977 .add("base", Style::new().dim())
978 .add("alias", "base");
979
980 let styles = theme.resolve_styles(Some(ColorMode::Light));
981 assert!(styles.has("base"));
982 assert!(styles.has("alias"));
983
984 assert!(styles.validate().is_ok());
986 }
987
988 #[test]
993 fn test_theme_from_yaml_simple() {
994 let theme = Theme::from_yaml(
995 r#"
996 header:
997 fg: cyan
998 bold: true
999 "#,
1000 )
1001 .unwrap();
1002
1003 assert_eq!(theme.len(), 1);
1004 let styles = theme.resolve_styles(None);
1005 assert!(styles.has("header"));
1006 }
1007
1008 #[test]
1009 fn test_theme_from_yaml_shorthand() {
1010 let theme = Theme::from_yaml(
1011 r#"
1012 bold_text: bold
1013 accent: cyan
1014 warning: "yellow italic"
1015 "#,
1016 )
1017 .unwrap();
1018
1019 assert_eq!(theme.len(), 3);
1020 }
1021
1022 #[test]
1023 fn test_theme_from_yaml_alias() {
1024 let theme = Theme::from_yaml(
1025 r#"
1026 muted:
1027 dim: true
1028 disabled: muted
1029 "#,
1030 )
1031 .unwrap();
1032
1033 assert_eq!(theme.len(), 2);
1034 assert!(theme.validate().is_ok());
1035 }
1036
1037 #[test]
1038 fn test_theme_from_yaml_adaptive() {
1039 let theme = Theme::from_yaml(
1040 r#"
1041 panel:
1042 fg: gray
1043 light:
1044 fg: black
1045 dark:
1046 fg: white
1047 "#,
1048 )
1049 .unwrap();
1050
1051 assert_eq!(theme.len(), 1);
1052 assert_eq!(theme.light_override_count(), 1);
1053 assert_eq!(theme.dark_override_count(), 1);
1054 }
1055
1056 #[test]
1057 fn test_theme_from_yaml_invalid() {
1058 let result = Theme::from_yaml("not valid yaml: [");
1059 assert!(result.is_err());
1060 }
1061
1062 #[test]
1063 fn test_theme_from_yaml_complete() {
1064 let theme = Theme::from_yaml(
1065 r##"
1066 # Visual layer
1067 muted:
1068 dim: true
1069
1070 accent:
1071 fg: cyan
1072 bold: true
1073
1074 # Adaptive
1075 background:
1076 light:
1077 bg: "#f8f8f8"
1078 dark:
1079 bg: "#1e1e1e"
1080
1081 # Aliases
1082 header: accent
1083 footer: muted
1084 "##,
1085 )
1086 .unwrap();
1087
1088 assert_eq!(theme.len(), 5);
1090 assert!(theme.validate().is_ok());
1091
1092 assert_eq!(theme.light_override_count(), 1);
1094 assert_eq!(theme.dark_override_count(), 1);
1095 }
1096
1097 #[test]
1102 fn test_theme_named() {
1103 let theme = Theme::named("darcula");
1104 assert_eq!(theme.name(), Some("darcula"));
1105 assert!(theme.is_empty());
1106 }
1107
1108 #[test]
1109 fn test_theme_new_has_no_name() {
1110 let theme = Theme::new();
1111 assert_eq!(theme.name(), None);
1112 assert_eq!(theme.source_path(), None);
1113 }
1114
1115 #[test]
1116 fn test_theme_from_file() {
1117 use std::fs;
1118 use tempfile::TempDir;
1119
1120 let temp_dir = TempDir::new().unwrap();
1121 let theme_path = temp_dir.path().join("darcula.yaml");
1122 fs::write(
1123 &theme_path,
1124 r#"
1125 header:
1126 fg: cyan
1127 bold: true
1128 muted:
1129 dim: true
1130 "#,
1131 )
1132 .unwrap();
1133
1134 let theme = Theme::from_file(&theme_path).unwrap();
1135 assert_eq!(theme.name(), Some("darcula"));
1136 assert_eq!(theme.source_path(), Some(theme_path.as_path()));
1137 assert_eq!(theme.len(), 2);
1138 }
1139
1140 #[test]
1141 fn test_theme_from_file_not_found() {
1142 let result = Theme::from_file("/nonexistent/path/theme.yaml");
1143 assert!(result.is_err());
1144 }
1145
1146 #[test]
1147 fn test_theme_refresh() {
1148 use std::fs;
1149 use tempfile::TempDir;
1150
1151 let temp_dir = TempDir::new().unwrap();
1152 let theme_path = temp_dir.path().join("dynamic.yaml");
1153 fs::write(
1154 &theme_path,
1155 r#"
1156 header:
1157 fg: red
1158 "#,
1159 )
1160 .unwrap();
1161
1162 let mut theme = Theme::from_file(&theme_path).unwrap();
1163 assert_eq!(theme.len(), 1);
1164
1165 fs::write(
1167 &theme_path,
1168 r#"
1169 header:
1170 fg: blue
1171 footer:
1172 dim: true
1173 "#,
1174 )
1175 .unwrap();
1176
1177 theme.refresh().unwrap();
1179 assert_eq!(theme.len(), 2);
1180 }
1181
1182 #[test]
1183 fn test_theme_refresh_without_source() {
1184 let mut theme = Theme::new();
1185 let result = theme.refresh();
1186 assert!(result.is_err());
1187 }
1188
1189 #[test]
1190 fn test_theme_merge() {
1191 let base = Theme::new()
1192 .add("keep", Style::new().dim())
1193 .add("overwrite", Style::new().red());
1194
1195 let extension = Theme::new()
1196 .add("overwrite", Style::new().blue())
1197 .add("new", Style::new().bold());
1198
1199 let merged = base.merge(extension);
1200
1201 let styles = merged.resolve_styles(None);
1202
1203 assert!(styles.has("keep"));
1205
1206 assert!(styles.has("overwrite"));
1208
1209 assert!(styles.has("new"));
1211
1212 assert_eq!(merged.len(), 3);
1213 }
1214
1215 #[test]
1220 fn test_theme_add_icon() {
1221 let theme = Theme::new()
1222 .add_icon("pending", IconDefinition::new("⚪"))
1223 .add_icon("done", IconDefinition::new("⚫").with_nerdfont("\u{f00c}"));
1224
1225 assert_eq!(theme.icons().len(), 2);
1226 assert!(!theme.icons().is_empty());
1227 }
1228
1229 #[test]
1230 fn test_theme_resolve_icons_classic() {
1231 let theme = Theme::new()
1232 .add_icon("pending", IconDefinition::new("⚪"))
1233 .add_icon("done", IconDefinition::new("⚫").with_nerdfont("\u{f00c}"));
1234
1235 let resolved = theme.resolve_icons(IconMode::Classic);
1236 assert_eq!(resolved.get("pending").unwrap(), "⚪");
1237 assert_eq!(resolved.get("done").unwrap(), "⚫");
1238 }
1239
1240 #[test]
1241 fn test_theme_resolve_icons_nerdfont() {
1242 let theme = Theme::new()
1243 .add_icon("pending", IconDefinition::new("⚪"))
1244 .add_icon("done", IconDefinition::new("⚫").with_nerdfont("\u{f00c}"));
1245
1246 let resolved = theme.resolve_icons(IconMode::NerdFont);
1247 assert_eq!(resolved.get("pending").unwrap(), "⚪"); assert_eq!(resolved.get("done").unwrap(), "\u{f00c}");
1249 }
1250
1251 #[test]
1252 fn test_theme_icons_empty_by_default() {
1253 let theme = Theme::new();
1254 assert!(theme.icons().is_empty());
1255 }
1256
1257 #[test]
1258 fn test_theme_merge_with_icons() {
1259 let base = Theme::new()
1260 .add_icon("keep", IconDefinition::new("K"))
1261 .add_icon("override", IconDefinition::new("OLD"));
1262
1263 let ext = Theme::new()
1264 .add_icon("override", IconDefinition::new("NEW"))
1265 .add_icon("added", IconDefinition::new("A"));
1266
1267 let merged = base.merge(ext);
1268 assert_eq!(merged.icons().len(), 3);
1269
1270 let resolved = merged.resolve_icons(IconMode::Classic);
1271 assert_eq!(resolved.get("keep").unwrap(), "K");
1272 assert_eq!(resolved.get("override").unwrap(), "NEW");
1273 assert_eq!(resolved.get("added").unwrap(), "A");
1274 }
1275
1276 #[test]
1277 fn test_theme_from_yaml_with_icons() {
1278 let theme = Theme::from_yaml(
1279 r#"
1280 header:
1281 fg: cyan
1282 bold: true
1283 icons:
1284 pending: "⚪"
1285 done:
1286 classic: "⚫"
1287 nerdfont: "nf_done"
1288 "#,
1289 )
1290 .unwrap();
1291
1292 assert_eq!(theme.len(), 1);
1294 let styles = theme.resolve_styles(None);
1295 assert!(styles.has("header"));
1296
1297 assert_eq!(theme.icons().len(), 2);
1299 let resolved = theme.resolve_icons(IconMode::Classic);
1300 assert_eq!(resolved.get("pending").unwrap(), "⚪");
1301 assert_eq!(resolved.get("done").unwrap(), "⚫");
1302
1303 let resolved = theme.resolve_icons(IconMode::NerdFont);
1304 assert_eq!(resolved.get("done").unwrap(), "nf_done");
1305 }
1306
1307 #[test]
1308 fn test_theme_from_yaml_no_icons() {
1309 let theme = Theme::from_yaml(
1310 r#"
1311 header:
1312 fg: cyan
1313 "#,
1314 )
1315 .unwrap();
1316
1317 assert!(theme.icons().is_empty());
1318 }
1319
1320 #[test]
1321 fn test_theme_from_yaml_icons_only() {
1322 let theme = Theme::from_yaml(
1323 r#"
1324 icons:
1325 check: "✓"
1326 "#,
1327 )
1328 .unwrap();
1329
1330 assert_eq!(theme.icons().len(), 1);
1331 assert_eq!(theme.len(), 0); }
1333
1334 #[test]
1335 fn test_theme_from_yaml_icons_invalid_type() {
1336 let result = Theme::from_yaml(
1337 r#"
1338 icons:
1339 bad: 42
1340 "#,
1341 );
1342 assert!(result.is_err());
1343 }
1344
1345 #[test]
1346 fn test_theme_from_yaml_icons_mapping_without_classic() {
1347 let result = Theme::from_yaml(
1348 r#"
1349 icons:
1350 bad:
1351 nerdfont: "nf"
1352 "#,
1353 );
1354 assert!(result.is_err());
1355 }
1356
1357 #[test]
1358 fn test_theme_from_file_with_icons() {
1359 use std::fs;
1360 use tempfile::TempDir;
1361
1362 let temp_dir = TempDir::new().unwrap();
1363 let theme_path = temp_dir.path().join("iconic.yaml");
1364 fs::write(
1365 &theme_path,
1366 r#"
1367 header:
1368 fg: cyan
1369 icons:
1370 check:
1371 classic: "[ok]"
1372 nerdfont: "nf_check"
1373 "#,
1374 )
1375 .unwrap();
1376
1377 let theme = Theme::from_file(&theme_path).unwrap();
1378 assert_eq!(theme.icons().len(), 1);
1379 let resolved = theme.resolve_icons(IconMode::NerdFont);
1380 assert_eq!(resolved.get("check").unwrap(), "nf_check");
1381 }
1382
1383 #[test]
1384 fn test_theme_refresh_with_icons() {
1385 use std::fs;
1386 use tempfile::TempDir;
1387
1388 let temp_dir = TempDir::new().unwrap();
1389 let theme_path = temp_dir.path().join("refresh.yaml");
1390 fs::write(
1391 &theme_path,
1392 r#"
1393 icons:
1394 v1: "one"
1395 "#,
1396 )
1397 .unwrap();
1398
1399 let mut theme = Theme::from_file(&theme_path).unwrap();
1400 assert_eq!(theme.icons().len(), 1);
1401
1402 fs::write(
1403 &theme_path,
1404 r#"
1405 icons:
1406 v1: "one"
1407 v2: "two"
1408 "#,
1409 )
1410 .unwrap();
1411
1412 theme.refresh().unwrap();
1413 assert_eq!(theme.icons().len(), 2);
1414 }
1415
1416 #[test]
1421 fn test_theme_no_palette_by_default() {
1422 let theme = Theme::new();
1423 assert!(theme.palette().is_none());
1424 }
1425
1426 #[test]
1427 fn test_theme_with_palette() {
1428 use crate::colorspace::{Rgb, ThemePalette};
1429
1430 let palette = ThemePalette::new([
1431 Rgb(40, 40, 40),
1432 Rgb(204, 36, 29),
1433 Rgb(152, 151, 26),
1434 Rgb(215, 153, 33),
1435 Rgb(69, 133, 136),
1436 Rgb(177, 98, 134),
1437 Rgb(104, 157, 106),
1438 Rgb(168, 153, 132),
1439 ]);
1440
1441 let theme = Theme::new().with_palette(palette);
1442 assert!(theme.palette().is_some());
1443 }
1444
1445 #[test]
1446 fn test_theme_merge_palette_from_other() {
1447 use crate::colorspace::ThemePalette;
1448
1449 let base = Theme::new();
1450 let other = Theme::new().with_palette(ThemePalette::default_xterm());
1451
1452 let merged = base.merge(other);
1453 assert!(merged.palette().is_some());
1454 }
1455
1456 #[test]
1457 fn test_theme_merge_keeps_own_palette() {
1458 use crate::colorspace::ThemePalette;
1459
1460 let base = Theme::new().with_palette(ThemePalette::default_xterm());
1461 let other = Theme::new();
1462
1463 let merged = base.merge(other);
1464 assert!(merged.palette().is_some());
1465 }
1466}