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(
696 "standout_warning_banner",
697 Style::new()
698 .fg(Color::Black)
699 .bg(Color::Color256(208))
700 .bold(),
701 )
702 .add("standout_warning_item", Style::new())
703 .add("table_row_even", Style::new())
705 .add_adaptive(
706 "table_row_odd",
707 Style::new(),
708 Some(Style::new().bg(Color::Color256(254))),
709 Some(Style::new().bg(Color::Color256(236))),
710 )
711 .add("table_row_even_gray", "table_row_even")
713 .add("table_row_odd_gray", "table_row_odd")
714 .add("table_row_even_blue", Style::new())
716 .add_adaptive(
717 "table_row_odd_blue",
718 Style::new(),
719 Some(Style::new().bg(Color::Color256(189))),
720 Some(Style::new().bg(Color::Color256(17))),
721 )
722 .add("table_row_even_red", Style::new())
724 .add_adaptive(
725 "table_row_odd_red",
726 Style::new(),
727 Some(Style::new().bg(Color::Color256(224))),
728 Some(Style::new().bg(Color::Color256(52))),
729 )
730 .add("table_row_even_green", Style::new())
732 .add_adaptive(
733 "table_row_odd_green",
734 Style::new(),
735 Some(Style::new().bg(Color::Color256(194))),
736 Some(Style::new().bg(Color::Color256(22))),
737 )
738 .add("table_row_even_purple", Style::new())
740 .add_adaptive(
741 "table_row_odd_purple",
742 Style::new(),
743 Some(Style::new().bg(Color::Color256(225))),
744 Some(Style::new().bg(Color::Color256(53))),
745 )
746 }
747}
748
749fn parse_icons_from_yaml_str(yaml: &str) -> Result<IconSet, StylesheetError> {
767 let root: serde_yaml::Value =
768 serde_yaml::from_str(yaml).map_err(|e| StylesheetError::Parse {
769 path: None,
770 message: e.to_string(),
771 })?;
772
773 parse_icons_from_yaml(&root)
774}
775
776fn parse_icons_from_yaml(root: &serde_yaml::Value) -> Result<IconSet, StylesheetError> {
778 let mut icon_set = IconSet::new();
779
780 let mapping = match root.as_mapping() {
781 Some(m) => m,
782 None => return Ok(icon_set),
783 };
784
785 let icons_value = match mapping.get(serde_yaml::Value::String("icons".into())) {
786 Some(v) => v,
787 None => return Ok(icon_set),
788 };
789
790 let icons_map = icons_value
791 .as_mapping()
792 .ok_or_else(|| StylesheetError::Parse {
793 path: None,
794 message: "'icons' must be a mapping".to_string(),
795 })?;
796
797 for (key, value) in icons_map {
798 let name = key.as_str().ok_or_else(|| StylesheetError::Parse {
799 path: None,
800 message: format!("Icon name must be a string, got {:?}", key),
801 })?;
802
803 let def = match value {
804 serde_yaml::Value::String(s) => {
805 IconDefinition::new(s.clone())
807 }
808 serde_yaml::Value::Mapping(map) => {
809 let classic = map
810 .get(serde_yaml::Value::String("classic".into()))
811 .and_then(|v| v.as_str())
812 .ok_or_else(|| StylesheetError::InvalidDefinition {
813 style: name.to_string(),
814 message: "Icon mapping must have a 'classic' key".to_string(),
815 path: None,
816 })?;
817 let nerdfont = map
818 .get(serde_yaml::Value::String("nerdfont".into()))
819 .and_then(|v| v.as_str());
820 let mut def = IconDefinition::new(classic);
821 if let Some(nf) = nerdfont {
822 def = def.with_nerdfont(nf);
823 }
824 def
825 }
826 _ => {
827 return Err(StylesheetError::InvalidDefinition {
828 style: name.to_string(),
829 message: "Icon must be a string or mapping with 'classic' key".to_string(),
830 path: None,
831 });
832 }
833 };
834
835 icon_set.insert(name.to_string(), def);
836 }
837
838 Ok(icon_set)
839}
840
841#[cfg(test)]
842mod tests {
843 use super::*;
844
845 #[test]
846 fn test_theme_new_is_empty() {
847 let theme = Theme::new();
848 assert!(theme.is_empty());
849 assert_eq!(theme.len(), 0);
850 }
851
852 #[test]
853 fn test_theme_add_concrete() {
854 let theme = Theme::new().add("bold", Style::new().bold());
855 assert!(!theme.is_empty());
856 assert_eq!(theme.len(), 1);
857 }
858
859 #[test]
860 fn test_theme_add_alias_str() {
861 let theme = Theme::new()
862 .add("base", Style::new().dim())
863 .add("alias", "base");
864
865 assert_eq!(theme.len(), 2);
866
867 let styles = theme.resolve_styles(None);
868 assert!(styles.has("base"));
869 assert!(styles.has("alias"));
870 }
871
872 #[test]
873 fn test_theme_add_alias_string() {
874 let target = String::from("base");
875 let theme = Theme::new()
876 .add("base", Style::new().dim())
877 .add("alias", target);
878
879 let styles = theme.resolve_styles(None);
880 assert!(styles.has("alias"));
881 }
882
883 #[test]
884 fn test_theme_validate_valid() {
885 let theme = Theme::new()
886 .add("visual", Style::new().cyan())
887 .add("semantic", "visual");
888
889 assert!(theme.validate().is_ok());
890 }
891
892 #[test]
893 fn test_theme_validate_invalid() {
894 let theme = Theme::new().add("orphan", "missing");
895 assert!(theme.validate().is_err());
896 }
897
898 #[test]
899 fn test_theme_default() {
900 let theme = Theme::default();
901 assert!(!theme.is_empty());
903 let styles = theme.resolve_styles(Some(crate::ColorMode::Dark));
904 assert!(styles.resolve("table_row_even").is_some());
905 assert!(styles.resolve("table_row_odd").is_some());
906 }
907
908 #[test]
913 fn test_theme_add_adaptive() {
914 let theme = Theme::new().add_adaptive(
915 "panel",
916 Style::new().dim(),
917 Some(Style::new().bold()),
918 Some(Style::new().italic()),
919 );
920
921 assert_eq!(theme.len(), 1);
922 assert_eq!(theme.light_override_count(), 1);
923 assert_eq!(theme.dark_override_count(), 1);
924 }
925
926 #[test]
927 fn test_theme_add_adaptive_light_only() {
928 let theme =
929 Theme::new().add_adaptive("panel", Style::new().dim(), Some(Style::new().bold()), None);
930
931 assert_eq!(theme.light_override_count(), 1);
932 assert_eq!(theme.dark_override_count(), 0);
933 }
934
935 #[test]
936 fn test_theme_add_adaptive_dark_only() {
937 let theme =
938 Theme::new().add_adaptive("panel", Style::new().dim(), None, Some(Style::new().bold()));
939
940 assert_eq!(theme.light_override_count(), 0);
941 assert_eq!(theme.dark_override_count(), 1);
942 }
943
944 #[test]
945 fn test_theme_resolve_styles_no_mode() {
946 let theme = Theme::new()
947 .add("header", Style::new().cyan())
948 .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(None);
956 assert!(styles.has("header"));
957 assert!(styles.has("panel"));
958 }
959
960 #[test]
961 fn test_theme_resolve_styles_light_mode() {
962 let theme = Theme::new().add_adaptive(
963 "panel",
964 Style::new().dim(),
965 Some(Style::new().bold()),
966 Some(Style::new().italic()),
967 );
968
969 let styles = theme.resolve_styles(Some(ColorMode::Light));
970 assert!(styles.has("panel"));
971 }
974
975 #[test]
976 fn test_theme_resolve_styles_dark_mode() {
977 let theme = Theme::new().add_adaptive(
978 "panel",
979 Style::new().dim(),
980 Some(Style::new().bold()),
981 Some(Style::new().italic()),
982 );
983
984 let styles = theme.resolve_styles(Some(ColorMode::Dark));
985 assert!(styles.has("panel"));
986 }
987
988 #[test]
989 fn test_theme_resolve_styles_preserves_aliases() {
990 let theme = Theme::new()
991 .add("base", Style::new().dim())
992 .add("alias", "base");
993
994 let styles = theme.resolve_styles(Some(ColorMode::Light));
995 assert!(styles.has("base"));
996 assert!(styles.has("alias"));
997
998 assert!(styles.validate().is_ok());
1000 }
1001
1002 #[test]
1007 fn test_theme_from_yaml_simple() {
1008 let theme = Theme::from_yaml(
1009 r#"
1010 header:
1011 fg: cyan
1012 bold: true
1013 "#,
1014 )
1015 .unwrap();
1016
1017 assert_eq!(theme.len(), 1);
1018 let styles = theme.resolve_styles(None);
1019 assert!(styles.has("header"));
1020 }
1021
1022 #[test]
1023 fn test_theme_from_yaml_shorthand() {
1024 let theme = Theme::from_yaml(
1025 r#"
1026 bold_text: bold
1027 accent: cyan
1028 warning: "yellow italic"
1029 "#,
1030 )
1031 .unwrap();
1032
1033 assert_eq!(theme.len(), 3);
1034 }
1035
1036 #[test]
1037 fn test_theme_from_yaml_alias() {
1038 let theme = Theme::from_yaml(
1039 r#"
1040 muted:
1041 dim: true
1042 disabled: muted
1043 "#,
1044 )
1045 .unwrap();
1046
1047 assert_eq!(theme.len(), 2);
1048 assert!(theme.validate().is_ok());
1049 }
1050
1051 #[test]
1052 fn test_theme_from_yaml_adaptive() {
1053 let theme = Theme::from_yaml(
1054 r#"
1055 panel:
1056 fg: gray
1057 light:
1058 fg: black
1059 dark:
1060 fg: white
1061 "#,
1062 )
1063 .unwrap();
1064
1065 assert_eq!(theme.len(), 1);
1066 assert_eq!(theme.light_override_count(), 1);
1067 assert_eq!(theme.dark_override_count(), 1);
1068 }
1069
1070 #[test]
1071 fn test_theme_from_yaml_invalid() {
1072 let result = Theme::from_yaml("not valid yaml: [");
1073 assert!(result.is_err());
1074 }
1075
1076 #[test]
1077 fn test_theme_from_yaml_complete() {
1078 let theme = Theme::from_yaml(
1079 r##"
1080 # Visual layer
1081 muted:
1082 dim: true
1083
1084 accent:
1085 fg: cyan
1086 bold: true
1087
1088 # Adaptive
1089 background:
1090 light:
1091 bg: "#f8f8f8"
1092 dark:
1093 bg: "#1e1e1e"
1094
1095 # Aliases
1096 header: accent
1097 footer: muted
1098 "##,
1099 )
1100 .unwrap();
1101
1102 assert_eq!(theme.len(), 5);
1104 assert!(theme.validate().is_ok());
1105
1106 assert_eq!(theme.light_override_count(), 1);
1108 assert_eq!(theme.dark_override_count(), 1);
1109 }
1110
1111 #[test]
1116 fn test_theme_named() {
1117 let theme = Theme::named("darcula");
1118 assert_eq!(theme.name(), Some("darcula"));
1119 assert!(theme.is_empty());
1120 }
1121
1122 #[test]
1123 fn test_theme_new_has_no_name() {
1124 let theme = Theme::new();
1125 assert_eq!(theme.name(), None);
1126 assert_eq!(theme.source_path(), None);
1127 }
1128
1129 #[test]
1130 fn test_theme_from_file() {
1131 use std::fs;
1132 use tempfile::TempDir;
1133
1134 let temp_dir = TempDir::new().unwrap();
1135 let theme_path = temp_dir.path().join("darcula.yaml");
1136 fs::write(
1137 &theme_path,
1138 r#"
1139 header:
1140 fg: cyan
1141 bold: true
1142 muted:
1143 dim: true
1144 "#,
1145 )
1146 .unwrap();
1147
1148 let theme = Theme::from_file(&theme_path).unwrap();
1149 assert_eq!(theme.name(), Some("darcula"));
1150 assert_eq!(theme.source_path(), Some(theme_path.as_path()));
1151 assert_eq!(theme.len(), 2);
1152 }
1153
1154 #[test]
1155 fn test_theme_from_file_not_found() {
1156 let result = Theme::from_file("/nonexistent/path/theme.yaml");
1157 assert!(result.is_err());
1158 }
1159
1160 #[test]
1161 fn test_theme_refresh() {
1162 use std::fs;
1163 use tempfile::TempDir;
1164
1165 let temp_dir = TempDir::new().unwrap();
1166 let theme_path = temp_dir.path().join("dynamic.yaml");
1167 fs::write(
1168 &theme_path,
1169 r#"
1170 header:
1171 fg: red
1172 "#,
1173 )
1174 .unwrap();
1175
1176 let mut theme = Theme::from_file(&theme_path).unwrap();
1177 assert_eq!(theme.len(), 1);
1178
1179 fs::write(
1181 &theme_path,
1182 r#"
1183 header:
1184 fg: blue
1185 footer:
1186 dim: true
1187 "#,
1188 )
1189 .unwrap();
1190
1191 theme.refresh().unwrap();
1193 assert_eq!(theme.len(), 2);
1194 }
1195
1196 #[test]
1197 fn test_theme_refresh_without_source() {
1198 let mut theme = Theme::new();
1199 let result = theme.refresh();
1200 assert!(result.is_err());
1201 }
1202
1203 #[test]
1204 fn test_theme_merge() {
1205 let base = Theme::new()
1206 .add("keep", Style::new().dim())
1207 .add("overwrite", Style::new().red());
1208
1209 let extension = Theme::new()
1210 .add("overwrite", Style::new().blue())
1211 .add("new", Style::new().bold());
1212
1213 let merged = base.merge(extension);
1214
1215 let styles = merged.resolve_styles(None);
1216
1217 assert!(styles.has("keep"));
1219
1220 assert!(styles.has("overwrite"));
1222
1223 assert!(styles.has("new"));
1225
1226 assert_eq!(merged.len(), 3);
1227 }
1228
1229 #[test]
1234 fn test_theme_add_icon() {
1235 let theme = Theme::new()
1236 .add_icon("pending", IconDefinition::new("⚪"))
1237 .add_icon("done", IconDefinition::new("⚫").with_nerdfont("\u{f00c}"));
1238
1239 assert_eq!(theme.icons().len(), 2);
1240 assert!(!theme.icons().is_empty());
1241 }
1242
1243 #[test]
1244 fn test_theme_resolve_icons_classic() {
1245 let theme = Theme::new()
1246 .add_icon("pending", IconDefinition::new("⚪"))
1247 .add_icon("done", IconDefinition::new("⚫").with_nerdfont("\u{f00c}"));
1248
1249 let resolved = theme.resolve_icons(IconMode::Classic);
1250 assert_eq!(resolved.get("pending").unwrap(), "⚪");
1251 assert_eq!(resolved.get("done").unwrap(), "⚫");
1252 }
1253
1254 #[test]
1255 fn test_theme_resolve_icons_nerdfont() {
1256 let theme = Theme::new()
1257 .add_icon("pending", IconDefinition::new("⚪"))
1258 .add_icon("done", IconDefinition::new("⚫").with_nerdfont("\u{f00c}"));
1259
1260 let resolved = theme.resolve_icons(IconMode::NerdFont);
1261 assert_eq!(resolved.get("pending").unwrap(), "⚪"); assert_eq!(resolved.get("done").unwrap(), "\u{f00c}");
1263 }
1264
1265 #[test]
1266 fn test_theme_icons_empty_by_default() {
1267 let theme = Theme::new();
1268 assert!(theme.icons().is_empty());
1269 }
1270
1271 #[test]
1272 fn test_theme_merge_with_icons() {
1273 let base = Theme::new()
1274 .add_icon("keep", IconDefinition::new("K"))
1275 .add_icon("override", IconDefinition::new("OLD"));
1276
1277 let ext = Theme::new()
1278 .add_icon("override", IconDefinition::new("NEW"))
1279 .add_icon("added", IconDefinition::new("A"));
1280
1281 let merged = base.merge(ext);
1282 assert_eq!(merged.icons().len(), 3);
1283
1284 let resolved = merged.resolve_icons(IconMode::Classic);
1285 assert_eq!(resolved.get("keep").unwrap(), "K");
1286 assert_eq!(resolved.get("override").unwrap(), "NEW");
1287 assert_eq!(resolved.get("added").unwrap(), "A");
1288 }
1289
1290 #[test]
1291 fn test_theme_from_yaml_with_icons() {
1292 let theme = Theme::from_yaml(
1293 r#"
1294 header:
1295 fg: cyan
1296 bold: true
1297 icons:
1298 pending: "⚪"
1299 done:
1300 classic: "⚫"
1301 nerdfont: "nf_done"
1302 "#,
1303 )
1304 .unwrap();
1305
1306 assert_eq!(theme.len(), 1);
1308 let styles = theme.resolve_styles(None);
1309 assert!(styles.has("header"));
1310
1311 assert_eq!(theme.icons().len(), 2);
1313 let resolved = theme.resolve_icons(IconMode::Classic);
1314 assert_eq!(resolved.get("pending").unwrap(), "⚪");
1315 assert_eq!(resolved.get("done").unwrap(), "⚫");
1316
1317 let resolved = theme.resolve_icons(IconMode::NerdFont);
1318 assert_eq!(resolved.get("done").unwrap(), "nf_done");
1319 }
1320
1321 #[test]
1322 fn test_theme_from_yaml_no_icons() {
1323 let theme = Theme::from_yaml(
1324 r#"
1325 header:
1326 fg: cyan
1327 "#,
1328 )
1329 .unwrap();
1330
1331 assert!(theme.icons().is_empty());
1332 }
1333
1334 #[test]
1335 fn test_theme_from_yaml_icons_only() {
1336 let theme = Theme::from_yaml(
1337 r#"
1338 icons:
1339 check: "✓"
1340 "#,
1341 )
1342 .unwrap();
1343
1344 assert_eq!(theme.icons().len(), 1);
1345 assert_eq!(theme.len(), 0); }
1347
1348 #[test]
1349 fn test_theme_from_yaml_icons_invalid_type() {
1350 let result = Theme::from_yaml(
1351 r#"
1352 icons:
1353 bad: 42
1354 "#,
1355 );
1356 assert!(result.is_err());
1357 }
1358
1359 #[test]
1360 fn test_theme_from_yaml_icons_mapping_without_classic() {
1361 let result = Theme::from_yaml(
1362 r#"
1363 icons:
1364 bad:
1365 nerdfont: "nf"
1366 "#,
1367 );
1368 assert!(result.is_err());
1369 }
1370
1371 #[test]
1372 fn test_theme_from_file_with_icons() {
1373 use std::fs;
1374 use tempfile::TempDir;
1375
1376 let temp_dir = TempDir::new().unwrap();
1377 let theme_path = temp_dir.path().join("iconic.yaml");
1378 fs::write(
1379 &theme_path,
1380 r#"
1381 header:
1382 fg: cyan
1383 icons:
1384 check:
1385 classic: "[ok]"
1386 nerdfont: "nf_check"
1387 "#,
1388 )
1389 .unwrap();
1390
1391 let theme = Theme::from_file(&theme_path).unwrap();
1392 assert_eq!(theme.icons().len(), 1);
1393 let resolved = theme.resolve_icons(IconMode::NerdFont);
1394 assert_eq!(resolved.get("check").unwrap(), "nf_check");
1395 }
1396
1397 #[test]
1398 fn test_theme_refresh_with_icons() {
1399 use std::fs;
1400 use tempfile::TempDir;
1401
1402 let temp_dir = TempDir::new().unwrap();
1403 let theme_path = temp_dir.path().join("refresh.yaml");
1404 fs::write(
1405 &theme_path,
1406 r#"
1407 icons:
1408 v1: "one"
1409 "#,
1410 )
1411 .unwrap();
1412
1413 let mut theme = Theme::from_file(&theme_path).unwrap();
1414 assert_eq!(theme.icons().len(), 1);
1415
1416 fs::write(
1417 &theme_path,
1418 r#"
1419 icons:
1420 v1: "one"
1421 v2: "two"
1422 "#,
1423 )
1424 .unwrap();
1425
1426 theme.refresh().unwrap();
1427 assert_eq!(theme.icons().len(), 2);
1428 }
1429
1430 #[test]
1435 fn test_theme_no_palette_by_default() {
1436 let theme = Theme::new();
1437 assert!(theme.palette().is_none());
1438 }
1439
1440 #[test]
1441 fn test_theme_with_palette() {
1442 use crate::colorspace::{Rgb, ThemePalette};
1443
1444 let palette = ThemePalette::new([
1445 Rgb(40, 40, 40),
1446 Rgb(204, 36, 29),
1447 Rgb(152, 151, 26),
1448 Rgb(215, 153, 33),
1449 Rgb(69, 133, 136),
1450 Rgb(177, 98, 134),
1451 Rgb(104, 157, 106),
1452 Rgb(168, 153, 132),
1453 ]);
1454
1455 let theme = Theme::new().with_palette(palette);
1456 assert!(theme.palette().is_some());
1457 }
1458
1459 #[test]
1460 fn test_theme_merge_palette_from_other() {
1461 use crate::colorspace::ThemePalette;
1462
1463 let base = Theme::new();
1464 let other = Theme::new().with_palette(ThemePalette::default_xterm());
1465
1466 let merged = base.merge(other);
1467 assert!(merged.palette().is_some());
1468 }
1469
1470 #[test]
1471 fn test_theme_merge_keeps_own_palette() {
1472 use crate::colorspace::ThemePalette;
1473
1474 let base = Theme::new().with_palette(ThemePalette::default_xterm());
1475 let other = Theme::new();
1476
1477 let merged = base.merge(other);
1478 assert!(merged.palette().is_some());
1479 }
1480}