1pub mod animated;
5pub mod bundled;
7pub mod defaults;
9pub mod dialog_order;
11pub mod font;
13pub mod icon_sizes;
15pub mod icons;
17pub mod resolved;
19pub mod spacing;
21pub mod widgets;
23
24pub use animated::{AnimatedIcon, TransformAnimation};
25pub use bundled::{bundled_icon_by_name, bundled_icon_svg};
26pub use defaults::ThemeDefaults;
27pub use dialog_order::DialogButtonOrder;
28pub use font::{FontSpec, ResolvedFontSpec, TextScale, TextScaleEntry};
29pub use icon_sizes::IconSizes;
30pub use icons::{
31 IconData, IconProvider, IconRole, IconSet, icon_name, system_icon_set, system_icon_theme,
32};
33pub use resolved::{
34 ResolvedIconSizes, ResolvedTextScale, ResolvedTextScaleEntry, ResolvedThemeDefaults,
35 ResolvedThemeSpacing, ResolvedThemeVariant,
36};
37pub use spacing::ThemeSpacing;
38pub use widgets::*; use serde::{Deserialize, Serialize};
41
42#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
58#[serde(default)]
59pub struct ThemeVariant {
60 #[serde(default, skip_serializing_if = "ThemeDefaults::is_empty")]
62 pub defaults: ThemeDefaults,
63
64 #[serde(default, skip_serializing_if = "TextScale::is_empty")]
66 pub text_scale: TextScale,
67
68 #[serde(default, skip_serializing_if = "WindowTheme::is_empty")]
70 pub window: WindowTheme,
71
72 #[serde(default, skip_serializing_if = "ButtonTheme::is_empty")]
74 pub button: ButtonTheme,
75
76 #[serde(default, skip_serializing_if = "InputTheme::is_empty")]
78 pub input: InputTheme,
79
80 #[serde(default, skip_serializing_if = "CheckboxTheme::is_empty")]
82 pub checkbox: CheckboxTheme,
83
84 #[serde(default, skip_serializing_if = "MenuTheme::is_empty")]
86 pub menu: MenuTheme,
87
88 #[serde(default, skip_serializing_if = "TooltipTheme::is_empty")]
90 pub tooltip: TooltipTheme,
91
92 #[serde(default, skip_serializing_if = "ScrollbarTheme::is_empty")]
94 pub scrollbar: ScrollbarTheme,
95
96 #[serde(default, skip_serializing_if = "SliderTheme::is_empty")]
98 pub slider: SliderTheme,
99
100 #[serde(default, skip_serializing_if = "ProgressBarTheme::is_empty")]
102 pub progress_bar: ProgressBarTheme,
103
104 #[serde(default, skip_serializing_if = "TabTheme::is_empty")]
106 pub tab: TabTheme,
107
108 #[serde(default, skip_serializing_if = "SidebarTheme::is_empty")]
110 pub sidebar: SidebarTheme,
111
112 #[serde(default, skip_serializing_if = "ToolbarTheme::is_empty")]
114 pub toolbar: ToolbarTheme,
115
116 #[serde(default, skip_serializing_if = "StatusBarTheme::is_empty")]
118 pub status_bar: StatusBarTheme,
119
120 #[serde(default, skip_serializing_if = "ListTheme::is_empty")]
122 pub list: ListTheme,
123
124 #[serde(default, skip_serializing_if = "PopoverTheme::is_empty")]
126 pub popover: PopoverTheme,
127
128 #[serde(default, skip_serializing_if = "SplitterTheme::is_empty")]
130 pub splitter: SplitterTheme,
131
132 #[serde(default, skip_serializing_if = "SeparatorTheme::is_empty")]
134 pub separator: SeparatorTheme,
135
136 #[serde(default, skip_serializing_if = "SwitchTheme::is_empty")]
138 pub switch: SwitchTheme,
139
140 #[serde(default, skip_serializing_if = "DialogTheme::is_empty")]
142 pub dialog: DialogTheme,
143
144 #[serde(default, skip_serializing_if = "SpinnerTheme::is_empty")]
146 pub spinner: SpinnerTheme,
147
148 #[serde(default, skip_serializing_if = "ComboBoxTheme::is_empty")]
150 pub combo_box: ComboBoxTheme,
151
152 #[serde(default, skip_serializing_if = "SegmentedControlTheme::is_empty")]
154 pub segmented_control: SegmentedControlTheme,
155
156 #[serde(default, skip_serializing_if = "CardTheme::is_empty")]
158 pub card: CardTheme,
159
160 #[serde(default, skip_serializing_if = "ExpanderTheme::is_empty")]
162 pub expander: ExpanderTheme,
163
164 #[serde(default, skip_serializing_if = "LinkTheme::is_empty")]
166 pub link: LinkTheme,
167
168 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub icon_set: Option<IconSet>,
175
176 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub icon_theme: Option<String>,
183}
184
185impl_merge!(ThemeVariant {
186 option { icon_set, icon_theme }
187 nested {
188 defaults, text_scale, window, button, input, checkbox, menu,
189 tooltip, scrollbar, slider, progress_bar, tab, sidebar,
190 toolbar, status_bar, list, popover, splitter, separator,
191 switch, dialog, spinner, combo_box, segmented_control,
192 card, expander, link
193 }
194});
195
196#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
225#[must_use = "constructing a theme without using it is likely a bug"]
226pub struct ThemeSpec {
227 pub name: String,
229
230 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub light: Option<ThemeVariant>,
233
234 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub dark: Option<ThemeVariant>,
237}
238
239impl ThemeSpec {
240 pub fn new(name: impl Into<String>) -> Self {
242 Self {
243 name: name.into(),
244 light: None,
245 dark: None,
246 }
247 }
248
249 pub fn merge(&mut self, overlay: &Self) {
256 match (&mut self.light, &overlay.light) {
259 (Some(base), Some(over)) => base.merge(over),
260 (None, Some(over)) => self.light = Some(over.clone()),
261 _ => {}
262 }
263
264 match (&mut self.dark, &overlay.dark) {
265 (Some(base), Some(over)) => base.merge(over),
266 (None, Some(over)) => self.dark = Some(over.clone()),
267 _ => {}
268 }
269 }
270
271 #[must_use = "this returns the selected variant; it does not apply it"]
277 pub fn pick_variant(&self, is_dark: bool) -> Option<&ThemeVariant> {
278 if is_dark {
279 self.dark.as_ref().or(self.light.as_ref())
280 } else {
281 self.light.as_ref().or(self.dark.as_ref())
282 }
283 }
284
285 #[must_use = "this returns the extracted variant; it does not apply it"]
302 pub fn into_variant(self, is_dark: bool) -> Option<ThemeVariant> {
303 if is_dark {
304 self.dark.or(self.light)
305 } else {
306 self.light.or(self.dark)
307 }
308 }
309
310 pub fn is_empty(&self) -> bool {
312 self.light.is_none() && self.dark.is_none()
313 }
314
315 #[must_use = "this returns a theme preset; it does not apply it"]
329 pub fn preset(name: &str) -> crate::Result<Self> {
330 crate::presets::preset(name)
331 }
332
333 #[must_use = "this parses a TOML string into a theme; it does not apply it"]
415 pub fn from_toml(toml_str: &str) -> crate::Result<Self> {
416 crate::presets::from_toml(toml_str)
417 }
418
419 pub fn from_toml_with_base(toml_str: &str, base: &str) -> crate::Result<Self> {
444 let mut theme = Self::preset(base)?;
445 let overlay = Self::from_toml(toml_str)?;
446 theme.merge(&overlay);
447 Ok(theme)
448 }
449
450 #[must_use = "this loads a theme from a file; it does not apply it"]
460 pub fn from_file(path: impl AsRef<std::path::Path>) -> crate::Result<Self> {
461 crate::presets::from_file(path)
462 }
463
464 #[must_use = "this returns the list of preset names"]
472 pub fn list_presets() -> &'static [&'static str] {
473 crate::presets::list_presets()
474 }
475
476 #[must_use = "this returns the filtered list of preset names for this platform"]
489 pub fn list_presets_for_platform() -> Vec<&'static str> {
490 crate::presets::list_presets_for_platform()
491 }
492
493 #[must_use = "this serializes the theme to TOML; it does not write to a file"]
505 pub fn to_toml(&self) -> crate::Result<String> {
506 crate::presets::to_toml(self)
507 }
508
509 pub fn lint_toml(toml_str: &str) -> crate::Result<Vec<String>> {
535 use crate::model::defaults::ThemeDefaults;
536
537 let value: toml::Value = toml::from_str(toml_str)
538 .map_err(|e: toml::de::Error| crate::Error::Format(e.to_string()))?;
539
540 let mut warnings = Vec::new();
541
542 let top_table = match &value {
543 toml::Value::Table(t) => t,
544 _ => return Ok(warnings),
545 };
546
547 const TOP_KEYS: &[&str] = &["name", "light", "dark"];
549
550 for key in top_table.keys() {
551 if !TOP_KEYS.contains(&key.as_str()) {
552 warnings.push(format!("unknown field: {key}"));
553 }
554 }
555
556 const VARIANT_KEYS: &[&str] = &[
558 "defaults",
559 "text_scale",
560 "window",
561 "button",
562 "input",
563 "checkbox",
564 "menu",
565 "tooltip",
566 "scrollbar",
567 "slider",
568 "progress_bar",
569 "tab",
570 "sidebar",
571 "toolbar",
572 "status_bar",
573 "list",
574 "popover",
575 "splitter",
576 "separator",
577 "switch",
578 "dialog",
579 "spinner",
580 "combo_box",
581 "segmented_control",
582 "card",
583 "expander",
584 "link",
585 "icon_set",
586 "icon_theme",
587 ];
588
589 const TEXT_SCALE_ENTRY_FIELDS: &[&str] = &["size", "weight", "line_height"];
591
592 const TEXT_SCALE_KEYS: &[&str] = &["caption", "section_heading", "dialog_title", "display"];
594
595 const FONT_FIELDS: &[&str] = &["family", "size", "weight"];
597
598 const SPACING_FIELDS: &[&str] = &["xxs", "xs", "s", "m", "l", "xl", "xxl"];
600
601 const ICON_SIZES_FIELDS: &[&str] = &["toolbar", "small", "large", "dialog", "panel"];
603
604 fn widget_fields(section: &str) -> Option<&'static [&'static str]> {
606 match section {
607 "window" => Some(WindowTheme::FIELD_NAMES),
608 "button" => Some(ButtonTheme::FIELD_NAMES),
609 "input" => Some(InputTheme::FIELD_NAMES),
610 "checkbox" => Some(CheckboxTheme::FIELD_NAMES),
611 "menu" => Some(MenuTheme::FIELD_NAMES),
612 "tooltip" => Some(TooltipTheme::FIELD_NAMES),
613 "scrollbar" => Some(ScrollbarTheme::FIELD_NAMES),
614 "slider" => Some(SliderTheme::FIELD_NAMES),
615 "progress_bar" => Some(ProgressBarTheme::FIELD_NAMES),
616 "tab" => Some(TabTheme::FIELD_NAMES),
617 "sidebar" => Some(SidebarTheme::FIELD_NAMES),
618 "toolbar" => Some(ToolbarTheme::FIELD_NAMES),
619 "status_bar" => Some(StatusBarTheme::FIELD_NAMES),
620 "list" => Some(ListTheme::FIELD_NAMES),
621 "popover" => Some(PopoverTheme::FIELD_NAMES),
622 "splitter" => Some(SplitterTheme::FIELD_NAMES),
623 "separator" => Some(SeparatorTheme::FIELD_NAMES),
624 "switch" => Some(SwitchTheme::FIELD_NAMES),
625 "dialog" => Some(DialogTheme::FIELD_NAMES),
626 "spinner" => Some(SpinnerTheme::FIELD_NAMES),
627 "combo_box" => Some(ComboBoxTheme::FIELD_NAMES),
628 "segmented_control" => Some(SegmentedControlTheme::FIELD_NAMES),
629 "card" => Some(CardTheme::FIELD_NAMES),
630 "expander" => Some(ExpanderTheme::FIELD_NAMES),
631 "link" => Some(LinkTheme::FIELD_NAMES),
632 _ => None,
633 }
634 }
635
636 fn lint_text_scale(
638 table: &toml::map::Map<String, toml::Value>,
639 prefix: &str,
640 warnings: &mut Vec<String>,
641 ) {
642 for key in table.keys() {
643 if !TEXT_SCALE_KEYS.contains(&key.as_str()) {
644 warnings.push(format!("unknown field: {prefix}.{key}"));
645 } else if let Some(toml::Value::Table(entry_table)) = table.get(key) {
646 for ekey in entry_table.keys() {
647 if !TEXT_SCALE_ENTRY_FIELDS.contains(&ekey.as_str()) {
648 warnings.push(format!("unknown field: {prefix}.{key}.{ekey}"));
649 }
650 }
651 }
652 }
653 }
654
655 fn lint_defaults(
657 table: &toml::map::Map<String, toml::Value>,
658 prefix: &str,
659 warnings: &mut Vec<String>,
660 ) {
661 for key in table.keys() {
662 if !ThemeDefaults::FIELD_NAMES.contains(&key.as_str()) {
663 warnings.push(format!("unknown field: {prefix}.{key}"));
664 continue;
665 }
666 if let Some(toml::Value::Table(sub)) = table.get(key) {
668 let known = match key.as_str() {
669 "font" | "mono_font" => FONT_FIELDS,
670 "spacing" => SPACING_FIELDS,
671 "icon_sizes" => ICON_SIZES_FIELDS,
672 _ => continue,
673 };
674 for skey in sub.keys() {
675 if !known.contains(&skey.as_str()) {
676 warnings.push(format!("unknown field: {prefix}.{key}.{skey}"));
677 }
678 }
679 }
680 }
681 }
682
683 fn lint_variant(
685 table: &toml::map::Map<String, toml::Value>,
686 prefix: &str,
687 warnings: &mut Vec<String>,
688 ) {
689 for key in table.keys() {
690 if !VARIANT_KEYS.contains(&key.as_str()) {
691 warnings.push(format!("unknown field: {prefix}.{key}"));
692 continue;
693 }
694
695 if let Some(toml::Value::Table(sub)) = table.get(key) {
696 let sub_prefix = format!("{prefix}.{key}");
697 match key.as_str() {
698 "defaults" => lint_defaults(sub, &sub_prefix, warnings),
699 "text_scale" => lint_text_scale(sub, &sub_prefix, warnings),
700 _ => {
701 if let Some(fields) = widget_fields(key) {
702 for skey in sub.keys() {
703 if !fields.contains(&skey.as_str()) {
704 warnings
705 .push(format!("unknown field: {sub_prefix}.{skey}"));
706 }
707 }
708 }
709 }
710 }
711 }
712 }
713 }
714
715 for variant_key in &["light", "dark"] {
717 if let Some(toml::Value::Table(variant_table)) = top_table.get(*variant_key) {
718 lint_variant(variant_table, variant_key, &mut warnings);
719 }
720 }
721
722 Ok(warnings)
723 }
724}
725
726#[cfg(test)]
727#[allow(clippy::unwrap_used, clippy::expect_used)]
728mod tests {
729 use super::*;
730 use crate::Rgba;
731
732 #[test]
735 fn theme_variant_default_is_empty() {
736 assert!(ThemeVariant::default().is_empty());
737 }
738
739 #[test]
740 fn theme_variant_not_empty_when_color_set() {
741 let mut v = ThemeVariant::default();
742 v.defaults.accent = Some(Rgba::rgb(0, 120, 215));
743 assert!(!v.is_empty());
744 }
745
746 #[test]
747 fn theme_variant_not_empty_when_font_set() {
748 let mut v = ThemeVariant::default();
749 v.defaults.font.family = Some("Inter".into());
750 assert!(!v.is_empty());
751 }
752
753 #[test]
754 fn theme_variant_merge_recursively() {
755 let mut base = ThemeVariant::default();
756 base.defaults.background = Some(Rgba::rgb(255, 255, 255));
757 base.defaults.font.family = Some("Noto Sans".into());
758
759 let mut overlay = ThemeVariant::default();
760 overlay.defaults.accent = Some(Rgba::rgb(0, 120, 215));
761 overlay.defaults.spacing.m = Some(12.0);
762
763 base.merge(&overlay);
764
765 assert_eq!(base.defaults.background, Some(Rgba::rgb(255, 255, 255)));
767 assert_eq!(base.defaults.accent, Some(Rgba::rgb(0, 120, 215)));
769 assert_eq!(base.defaults.font.family.as_deref(), Some("Noto Sans"));
771 assert_eq!(base.defaults.spacing.m, Some(12.0));
773 }
774
775 #[test]
776 fn theme_variant_has_all_widgets() {
777 let mut v = ThemeVariant::default();
778 v.window.radius = Some(4.0);
780 v.button.min_height = Some(32.0);
781 v.input.min_height = Some(32.0);
782 v.checkbox.indicator_size = Some(18.0);
783 v.menu.item_height = Some(28.0);
784 v.tooltip.padding_horizontal = Some(6.0);
785 v.scrollbar.width = Some(14.0);
786 v.slider.track_height = Some(4.0);
787 v.progress_bar.height = Some(6.0);
788 v.tab.min_height = Some(32.0);
789 v.sidebar.background = Some(Rgba::rgb(240, 240, 240));
790 v.toolbar.height = Some(40.0);
791 v.status_bar.font = Some(crate::model::FontSpec::default());
792 v.list.item_height = Some(28.0);
793 v.popover.radius = Some(6.0);
794 v.splitter.width = Some(4.0);
795 v.separator.color = Some(Rgba::rgb(200, 200, 200));
796 v.switch.track_width = Some(32.0);
797 v.dialog.min_width = Some(320.0);
798 v.spinner.diameter = Some(24.0);
799 v.combo_box.min_height = Some(32.0);
800 v.segmented_control.segment_height = Some(28.0);
801 v.card.radius = Some(8.0);
802 v.expander.header_height = Some(32.0);
803 v.link.underline = Some(true);
804
805 assert!(!v.is_empty());
806 assert!(!v.window.is_empty());
807 assert!(!v.button.is_empty());
808 assert!(!v.input.is_empty());
809 assert!(!v.checkbox.is_empty());
810 assert!(!v.menu.is_empty());
811 assert!(!v.tooltip.is_empty());
812 assert!(!v.scrollbar.is_empty());
813 assert!(!v.slider.is_empty());
814 assert!(!v.progress_bar.is_empty());
815 assert!(!v.tab.is_empty());
816 assert!(!v.sidebar.is_empty());
817 assert!(!v.toolbar.is_empty());
818 assert!(!v.status_bar.is_empty());
819 assert!(!v.list.is_empty());
820 assert!(!v.popover.is_empty());
821 assert!(!v.splitter.is_empty());
822 assert!(!v.separator.is_empty());
823 assert!(!v.switch.is_empty());
824 assert!(!v.dialog.is_empty());
825 assert!(!v.spinner.is_empty());
826 assert!(!v.combo_box.is_empty());
827 assert!(!v.segmented_control.is_empty());
828 assert!(!v.card.is_empty());
829 assert!(!v.expander.is_empty());
830 assert!(!v.link.is_empty());
831 }
832
833 #[test]
834 fn theme_variant_merge_per_widget() {
835 let mut base = ThemeVariant::default();
836 base.button.background = Some(Rgba::rgb(200, 200, 200));
837 base.button.foreground = Some(Rgba::rgb(0, 0, 0));
838 base.tooltip.background = Some(Rgba::rgb(50, 50, 50));
839
840 let mut overlay = ThemeVariant::default();
841 overlay.button.background = Some(Rgba::rgb(255, 255, 255));
842 overlay.button.min_height = Some(32.0);
843
844 base.merge(&overlay);
845
846 assert_eq!(base.button.background, Some(Rgba::rgb(255, 255, 255)));
848 assert_eq!(base.button.min_height, Some(32.0));
850 assert_eq!(base.button.foreground, Some(Rgba::rgb(0, 0, 0)));
852 assert_eq!(base.tooltip.background, Some(Rgba::rgb(50, 50, 50)));
854 }
855
856 #[test]
859 fn native_theme_new_constructor() {
860 let theme = ThemeSpec::new("Breeze");
861 assert_eq!(theme.name, "Breeze");
862 assert!(theme.light.is_none());
863 assert!(theme.dark.is_none());
864 }
865
866 #[test]
867 fn native_theme_default_is_empty() {
868 let theme = ThemeSpec::default();
869 assert!(theme.is_empty());
870 assert_eq!(theme.name, "");
871 }
872
873 #[test]
874 fn native_theme_merge_keeps_base_name() {
875 let mut base = ThemeSpec::new("Base Theme");
876 let overlay = ThemeSpec::new("Overlay Theme");
877 base.merge(&overlay);
878 assert_eq!(base.name, "Base Theme");
879 }
880
881 #[test]
882 fn native_theme_merge_overlay_light_into_none() {
883 let mut base = ThemeSpec::new("Theme");
884
885 let mut overlay = ThemeSpec::new("Overlay");
886 let mut light = ThemeVariant::default();
887 light.defaults.accent = Some(Rgba::rgb(0, 120, 215));
888 overlay.light = Some(light);
889
890 base.merge(&overlay);
891
892 assert!(base.light.is_some());
893 assert_eq!(
894 base.light.as_ref().unwrap().defaults.accent,
895 Some(Rgba::rgb(0, 120, 215))
896 );
897 }
898
899 #[test]
900 fn native_theme_merge_both_light_variants() {
901 let mut base = ThemeSpec::new("Theme");
902 let mut base_light = ThemeVariant::default();
903 base_light.defaults.background = Some(Rgba::rgb(255, 255, 255));
904 base.light = Some(base_light);
905
906 let mut overlay = ThemeSpec::new("Overlay");
907 let mut overlay_light = ThemeVariant::default();
908 overlay_light.defaults.accent = Some(Rgba::rgb(0, 120, 215));
909 overlay.light = Some(overlay_light);
910
911 base.merge(&overlay);
912
913 let light = base.light.as_ref().unwrap();
914 assert_eq!(light.defaults.background, Some(Rgba::rgb(255, 255, 255)));
916 assert_eq!(light.defaults.accent, Some(Rgba::rgb(0, 120, 215)));
918 }
919
920 #[test]
921 fn native_theme_merge_base_light_only_preserved() {
922 let mut base = ThemeSpec::new("Theme");
923 let mut base_light = ThemeVariant::default();
924 base_light.defaults.font.family = Some("Inter".into());
925 base.light = Some(base_light);
926
927 let overlay = ThemeSpec::new("Overlay"); base.merge(&overlay);
930
931 assert!(base.light.is_some());
932 assert_eq!(
933 base.light.as_ref().unwrap().defaults.font.family.as_deref(),
934 Some("Inter")
935 );
936 }
937
938 #[test]
939 fn native_theme_merge_dark_variant() {
940 let mut base = ThemeSpec::new("Theme");
941
942 let mut overlay = ThemeSpec::new("Overlay");
943 let mut dark = ThemeVariant::default();
944 dark.defaults.background = Some(Rgba::rgb(30, 30, 30));
945 overlay.dark = Some(dark);
946
947 base.merge(&overlay);
948
949 assert!(base.dark.is_some());
950 assert_eq!(
951 base.dark.as_ref().unwrap().defaults.background,
952 Some(Rgba::rgb(30, 30, 30))
953 );
954 }
955
956 #[test]
957 fn native_theme_not_empty_with_light() {
958 let mut theme = ThemeSpec::new("Theme");
959 theme.light = Some(ThemeVariant::default());
960 assert!(!theme.is_empty());
961 }
962
963 #[test]
966 fn pick_variant_dark_with_both_variants_returns_dark() {
967 let mut theme = ThemeSpec::new("Test");
968 let mut light = ThemeVariant::default();
969 light.defaults.background = Some(Rgba::rgb(255, 255, 255));
970 theme.light = Some(light);
971 let mut dark = ThemeVariant::default();
972 dark.defaults.background = Some(Rgba::rgb(30, 30, 30));
973 theme.dark = Some(dark);
974
975 let picked = theme.pick_variant(true).unwrap();
976 assert_eq!(picked.defaults.background, Some(Rgba::rgb(30, 30, 30)));
977 }
978
979 #[test]
980 fn pick_variant_light_with_both_variants_returns_light() {
981 let mut theme = ThemeSpec::new("Test");
982 let mut light = ThemeVariant::default();
983 light.defaults.background = Some(Rgba::rgb(255, 255, 255));
984 theme.light = Some(light);
985 let mut dark = ThemeVariant::default();
986 dark.defaults.background = Some(Rgba::rgb(30, 30, 30));
987 theme.dark = Some(dark);
988
989 let picked = theme.pick_variant(false).unwrap();
990 assert_eq!(picked.defaults.background, Some(Rgba::rgb(255, 255, 255)));
991 }
992
993 #[test]
994 fn pick_variant_dark_with_only_light_falls_back() {
995 let mut theme = ThemeSpec::new("Test");
996 let mut light = ThemeVariant::default();
997 light.defaults.background = Some(Rgba::rgb(255, 255, 255));
998 theme.light = Some(light);
999
1000 let picked = theme.pick_variant(true).unwrap();
1001 assert_eq!(picked.defaults.background, Some(Rgba::rgb(255, 255, 255)));
1002 }
1003
1004 #[test]
1005 fn pick_variant_light_with_only_dark_falls_back() {
1006 let mut theme = ThemeSpec::new("Test");
1007 let mut dark = ThemeVariant::default();
1008 dark.defaults.background = Some(Rgba::rgb(30, 30, 30));
1009 theme.dark = Some(dark);
1010
1011 let picked = theme.pick_variant(false).unwrap();
1012 assert_eq!(picked.defaults.background, Some(Rgba::rgb(30, 30, 30)));
1013 }
1014
1015 #[test]
1016 fn pick_variant_with_no_variants_returns_none() {
1017 let theme = ThemeSpec::new("Empty");
1018 assert!(theme.pick_variant(true).is_none());
1019 assert!(theme.pick_variant(false).is_none());
1020 }
1021
1022 #[test]
1025 fn icon_set_default_is_none() {
1026 assert!(ThemeVariant::default().icon_set.is_none());
1027 }
1028
1029 #[test]
1030 fn icon_set_merge_overlay() {
1031 let mut base = ThemeVariant::default();
1032 let overlay = ThemeVariant {
1033 icon_set: Some(IconSet::Material),
1034 ..Default::default()
1035 };
1036 base.merge(&overlay);
1037 assert_eq!(base.icon_set, Some(IconSet::Material));
1038 }
1039
1040 #[test]
1041 fn icon_set_merge_none_preserves() {
1042 let mut base = ThemeVariant {
1043 icon_set: Some(IconSet::SfSymbols),
1044 ..Default::default()
1045 };
1046 let overlay = ThemeVariant::default();
1047 base.merge(&overlay);
1048 assert_eq!(base.icon_set, Some(IconSet::SfSymbols));
1049 }
1050
1051 #[test]
1052 fn icon_set_is_empty_when_set() {
1053 assert!(ThemeVariant::default().is_empty());
1054 let v = ThemeVariant {
1055 icon_set: Some(IconSet::Material),
1056 ..Default::default()
1057 };
1058 assert!(!v.is_empty());
1059 }
1060
1061 #[test]
1062 fn icon_set_toml_round_trip() {
1063 let variant = ThemeVariant {
1064 icon_set: Some(IconSet::Material),
1065 ..Default::default()
1066 };
1067 let toml_str = toml::to_string(&variant).unwrap();
1068 assert!(toml_str.contains("icon_set"));
1069 let deserialized: ThemeVariant = toml::from_str(&toml_str).unwrap();
1070 assert_eq!(deserialized.icon_set, Some(IconSet::Material));
1071 }
1072
1073 #[test]
1074 fn icon_set_toml_absent_deserializes_to_none() {
1075 let toml_str = r##"
1076[defaults]
1077accent = "#ff0000"
1078"##;
1079 let variant: ThemeVariant = toml::from_str(toml_str).unwrap();
1080 assert!(variant.icon_set.is_none());
1081 }
1082
1083 #[test]
1084 fn native_theme_serde_toml_round_trip() {
1085 let mut theme = ThemeSpec::new("Test Theme");
1086 let mut light = ThemeVariant::default();
1087 light.defaults.accent = Some(Rgba::rgb(0, 120, 215));
1088 light.defaults.font.family = Some("Segoe UI".into());
1089 light.defaults.radius = Some(4.0);
1090 light.defaults.spacing.m = Some(12.0);
1091 theme.light = Some(light);
1092
1093 let toml_str = toml::to_string(&theme).unwrap();
1094 let deserialized: ThemeSpec = toml::from_str(&toml_str).unwrap();
1095
1096 assert_eq!(deserialized.name, "Test Theme");
1097 let l = deserialized.light.unwrap();
1098 assert_eq!(l.defaults.accent, Some(Rgba::rgb(0, 120, 215)));
1099 assert_eq!(l.defaults.font.family.as_deref(), Some("Segoe UI"));
1100 assert_eq!(l.defaults.radius, Some(4.0));
1101 assert_eq!(l.defaults.spacing.m, Some(12.0));
1102 }
1103
1104 #[test]
1107 fn from_toml_with_base_merges_colors_onto_preset() {
1108 let custom_toml = r##"
1109name = "Custom Colors"
1110
1111[dark.defaults]
1112accent = "#ff6600"
1113background = "#1e1e1e"
1114foreground = "#e0e0e0"
1115"##;
1116 let theme = ThemeSpec::from_toml_with_base(custom_toml, "material").unwrap();
1117
1118 assert_eq!(theme.name, "Material");
1120
1121 assert!(theme.light.is_some());
1123 assert!(theme.dark.is_some());
1124
1125 let dark = theme.dark.as_ref().unwrap();
1127 assert_eq!(dark.defaults.accent, Some(Rgba::rgb(255, 102, 0)));
1128 assert_eq!(dark.defaults.background, Some(Rgba::rgb(30, 30, 30)));
1129 assert_eq!(dark.defaults.foreground, Some(Rgba::rgb(224, 224, 224)));
1130
1131 assert!(dark.button.min_height.is_some());
1133 assert!(dark.defaults.spacing.m.is_some());
1134
1135 let mut dark_clone = dark.clone();
1137 dark_clone.resolve_all();
1138 dark_clone.validate().unwrap();
1139 }
1140
1141 #[test]
1142 fn from_toml_with_base_unknown_preset_returns_error() {
1143 let err = ThemeSpec::from_toml_with_base("name = \"X\"", "nonexistent").unwrap_err();
1144 match err {
1145 crate::Error::Unavailable(msg) => assert!(msg.contains("nonexistent")),
1146 other => panic!("expected Unavailable, got: {other:?}"),
1147 }
1148 }
1149
1150 #[test]
1151 fn from_toml_with_base_invalid_toml_returns_error() {
1152 let err = ThemeSpec::from_toml_with_base("{{{{invalid", "material").unwrap_err();
1153 match err {
1154 crate::Error::Format(_) => {}
1155 other => panic!("expected Format, got: {other:?}"),
1156 }
1157 }
1158
1159 #[test]
1162 fn lint_toml_valid_returns_empty() {
1163 let toml = r##"
1164name = "Valid Theme"
1165[light.defaults]
1166accent = "#ff0000"
1167background = "#ffffff"
1168[light.defaults.font]
1169family = "Inter"
1170size = 14.0
1171[light.button]
1172min_height = 32.0
1173padding_horizontal = 12.0
1174"##;
1175 let warnings = ThemeSpec::lint_toml(toml).unwrap();
1176 assert!(
1177 warnings.is_empty(),
1178 "Expected no warnings, got: {warnings:?}"
1179 );
1180 }
1181
1182 #[test]
1183 fn lint_toml_detects_unknown_top_level() {
1184 let toml = r##"
1185name = "Test"
1186theme_version = 2
1187"##;
1188 let warnings = ThemeSpec::lint_toml(toml).unwrap();
1189 assert_eq!(warnings.len(), 1);
1190 assert!(warnings[0].contains("theme_version"));
1191 }
1192
1193 #[test]
1194 fn lint_toml_detects_misspelled_defaults_field() {
1195 let toml = r##"
1196name = "Test"
1197[light.defaults]
1198backround = "#ffffff"
1199"##;
1200 let warnings = ThemeSpec::lint_toml(toml).unwrap();
1201 assert_eq!(warnings.len(), 1);
1202 assert!(warnings[0].contains("backround"));
1203 assert!(warnings[0].contains("light.defaults.backround"));
1204 }
1205
1206 #[test]
1207 fn lint_toml_detects_unknown_widget_field() {
1208 let toml = r##"
1209name = "Test"
1210[dark.button]
1211primary_bg = "#0078d7"
1212"##;
1213 let warnings = ThemeSpec::lint_toml(toml).unwrap();
1214 assert_eq!(warnings.len(), 1);
1215 assert!(warnings[0].contains("primary_bg"));
1216 }
1217
1218 #[test]
1219 fn lint_toml_detects_unknown_variant_section() {
1220 let toml = r##"
1221name = "Test"
1222[light.badges]
1223color = "#ff0000"
1224"##;
1225 let warnings = ThemeSpec::lint_toml(toml).unwrap();
1226 assert_eq!(warnings.len(), 1);
1227 assert!(warnings[0].contains("badges"));
1228 }
1229
1230 #[test]
1231 fn lint_toml_detects_unknown_font_subfield() {
1232 let toml = r##"
1233name = "Test"
1234[light.defaults.font]
1235famly = "Inter"
1236"##;
1237 let warnings = ThemeSpec::lint_toml(toml).unwrap();
1238 assert_eq!(warnings.len(), 1);
1239 assert!(warnings[0].contains("famly"));
1240 }
1241
1242 #[test]
1243 fn lint_toml_detects_unknown_spacing_subfield() {
1244 let toml = r##"
1245name = "Test"
1246[light.defaults.spacing]
1247medium = 12.0
1248"##;
1249 let warnings = ThemeSpec::lint_toml(toml).unwrap();
1250 assert_eq!(warnings.len(), 1);
1251 assert!(warnings[0].contains("medium"));
1252 }
1253
1254 #[test]
1255 fn lint_toml_detects_unknown_text_scale_entry() {
1256 let toml = r##"
1257name = "Test"
1258[light.text_scale.headline]
1259size = 24.0
1260"##;
1261 let warnings = ThemeSpec::lint_toml(toml).unwrap();
1262 assert_eq!(warnings.len(), 1);
1263 assert!(warnings[0].contains("headline"));
1264 }
1265
1266 #[test]
1267 fn lint_toml_detects_unknown_text_scale_entry_field() {
1268 let toml = r##"
1269name = "Test"
1270[light.text_scale.caption]
1271font_size = 12.0
1272"##;
1273 let warnings = ThemeSpec::lint_toml(toml).unwrap();
1274 assert_eq!(warnings.len(), 1);
1275 assert!(warnings[0].contains("font_size"));
1276 }
1277
1278 #[test]
1279 fn lint_toml_multiple_errors() {
1280 let toml = r##"
1281name = "Test"
1282author = "Me"
1283[light.defaults]
1284backround = "#ffffff"
1285[light.button]
1286primay_bg = "#0078d7"
1287"##;
1288 let warnings = ThemeSpec::lint_toml(toml).unwrap();
1289 assert_eq!(warnings.len(), 3);
1290 }
1291
1292 #[test]
1293 fn lint_toml_invalid_toml_returns_error() {
1294 let result = ThemeSpec::lint_toml("{{{{invalid");
1295 assert!(result.is_err());
1296 }
1297
1298 #[test]
1299 fn lint_toml_preset_has_no_warnings() {
1300 let theme = ThemeSpec::preset("catppuccin-mocha").unwrap();
1301 let toml_str = theme.to_toml().unwrap();
1302 let warnings = ThemeSpec::lint_toml(&toml_str).unwrap();
1303 assert!(
1304 warnings.is_empty(),
1305 "Preset catppuccin-mocha should have no lint warnings, got: {warnings:?}"
1306 );
1307 }
1308
1309 #[test]
1310 fn lint_toml_all_presets_clean() {
1311 for name in ThemeSpec::list_presets() {
1312 let theme = ThemeSpec::preset(name).unwrap();
1313 let toml_str = theme.to_toml().unwrap();
1314 let warnings = ThemeSpec::lint_toml(&toml_str).unwrap();
1315 assert!(
1316 warnings.is_empty(),
1317 "Preset '{name}' has lint warnings: {warnings:?}"
1318 );
1319 }
1320 }
1321}