1pub mod animated;
5pub mod border;
7pub mod bundled;
9pub mod defaults;
11pub mod dialog_order;
13pub mod font;
15pub mod icon_sizes;
17pub mod icons;
19pub mod resolved;
21pub mod widgets;
23
24pub use animated::{AnimatedIcon, TransformAnimation};
25pub use border::{BorderSpec, ResolvedBorderSpec};
26pub use bundled::{bundled_icon_by_name, bundled_icon_svg};
27pub use defaults::ThemeDefaults;
28pub use dialog_order::DialogButtonOrder;
29pub use font::{FontSize, FontSpec, FontStyle, ResolvedFontSpec, TextScale, TextScaleEntry};
30pub use icon_sizes::IconSizes;
31pub use icons::{
32 IconData, IconProvider, IconRole, IconSet, icon_name, system_icon_set, system_icon_theme,
33};
34pub use resolved::{
35 ResolvedIconSizes, ResolvedTextScale, ResolvedTextScaleEntry, ResolvedThemeDefaults,
36 ResolvedThemeVariant,
37};
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 #[serde(default, skip_serializing_if = "LayoutTheme::is_empty")]
240 pub layout: LayoutTheme,
241}
242
243impl ThemeSpec {
244 pub fn new(name: impl Into<String>) -> Self {
246 Self {
247 name: name.into(),
248 light: None,
249 dark: None,
250 layout: LayoutTheme::default(),
251 }
252 }
253
254 pub fn merge(&mut self, overlay: &Self) {
261 match (&mut self.light, &overlay.light) {
264 (Some(base), Some(over)) => base.merge(over),
265 (None, Some(over)) => self.light = Some(over.clone()),
266 _ => {}
267 }
268
269 match (&mut self.dark, &overlay.dark) {
270 (Some(base), Some(over)) => base.merge(over),
271 (None, Some(over)) => self.dark = Some(over.clone()),
272 _ => {}
273 }
274
275 self.layout.merge(&overlay.layout);
276 }
277
278 #[must_use = "this returns the selected variant; it does not apply it"]
284 pub fn pick_variant(&self, is_dark: bool) -> Option<&ThemeVariant> {
285 if is_dark {
286 self.dark.as_ref().or(self.light.as_ref())
287 } else {
288 self.light.as_ref().or(self.dark.as_ref())
289 }
290 }
291
292 #[must_use = "this returns the extracted variant; it does not apply it"]
309 pub fn into_variant(self, is_dark: bool) -> Option<ThemeVariant> {
310 if is_dark {
311 self.dark.or(self.light)
312 } else {
313 self.light.or(self.dark)
314 }
315 }
316
317 pub fn is_empty(&self) -> bool {
319 self.light.is_none() && self.dark.is_none() && self.layout.is_empty()
320 }
321
322 #[must_use = "this returns a theme preset; it does not apply it"]
336 pub fn preset(name: &str) -> crate::Result<Self> {
337 crate::presets::preset(name)
338 }
339
340 #[must_use = "this parses a TOML string into a theme; it does not apply it"]
420 pub fn from_toml(toml_str: &str) -> crate::Result<Self> {
421 crate::presets::from_toml(toml_str)
422 }
423
424 pub fn from_toml_with_base(toml_str: &str, base: &str) -> crate::Result<Self> {
449 let mut theme = Self::preset(base)?;
450 let overlay = Self::from_toml(toml_str)?;
451 theme.merge(&overlay);
452 Ok(theme)
453 }
454
455 #[must_use = "this loads a theme from a file; it does not apply it"]
466 pub fn from_file(path: impl AsRef<std::path::Path>) -> crate::Result<Self> {
467 crate::presets::from_file(path)
468 }
469
470 #[must_use = "this returns the list of preset names"]
478 pub fn list_presets() -> &'static [&'static str] {
479 crate::presets::list_presets()
480 }
481
482 #[must_use = "this returns the filtered list of preset names for this platform"]
495 pub fn list_presets_for_platform() -> Vec<&'static str> {
496 crate::presets::list_presets_for_platform()
497 }
498
499 #[must_use = "this serializes the theme to TOML; it does not write to a file"]
511 pub fn to_toml(&self) -> crate::Result<String> {
512 crate::presets::to_toml(self)
513 }
514
515 pub fn lint_toml(toml_str: &str) -> crate::Result<Vec<String>> {
541 use crate::model::defaults::ThemeDefaults;
542
543 let value: toml::Value = toml::from_str(toml_str)
544 .map_err(|e: toml::de::Error| crate::Error::Format(e.to_string()))?;
545
546 let mut warnings = Vec::new();
547
548 let top_table = match &value {
549 toml::Value::Table(t) => t,
550 _ => return Ok(warnings),
551 };
552
553 const TOP_KEYS: &[&str] = &["name", "light", "dark", "layout"];
555
556 for key in top_table.keys() {
557 if !TOP_KEYS.contains(&key.as_str()) {
558 warnings.push(format!("unknown field: {key}"));
559 }
560 }
561
562 const VARIANT_KEYS: &[&str] = &[
564 "defaults",
565 "text_scale",
566 "window",
567 "button",
568 "input",
569 "checkbox",
570 "menu",
571 "tooltip",
572 "scrollbar",
573 "slider",
574 "progress_bar",
575 "tab",
576 "sidebar",
577 "toolbar",
578 "status_bar",
579 "list",
580 "popover",
581 "splitter",
582 "separator",
583 "switch",
584 "dialog",
585 "spinner",
586 "combo_box",
587 "segmented_control",
588 "card",
589 "expander",
590 "link",
591 "icon_set",
592 "icon_theme",
593 ];
594
595 fn widget_fields(section: &str) -> Option<&'static [&'static str]> {
600 match section {
601 "window" => Some(WindowTheme::FIELD_NAMES),
602 "button" => Some(ButtonTheme::FIELD_NAMES),
603 "input" => Some(InputTheme::FIELD_NAMES),
604 "checkbox" => Some(CheckboxTheme::FIELD_NAMES),
605 "menu" => Some(MenuTheme::FIELD_NAMES),
606 "tooltip" => Some(TooltipTheme::FIELD_NAMES),
607 "scrollbar" => Some(ScrollbarTheme::FIELD_NAMES),
608 "slider" => Some(SliderTheme::FIELD_NAMES),
609 "progress_bar" => Some(ProgressBarTheme::FIELD_NAMES),
610 "tab" => Some(TabTheme::FIELD_NAMES),
611 "sidebar" => Some(SidebarTheme::FIELD_NAMES),
612 "toolbar" => Some(ToolbarTheme::FIELD_NAMES),
613 "status_bar" => Some(StatusBarTheme::FIELD_NAMES),
614 "list" => Some(ListTheme::FIELD_NAMES),
615 "popover" => Some(PopoverTheme::FIELD_NAMES),
616 "splitter" => Some(SplitterTheme::FIELD_NAMES),
617 "separator" => Some(SeparatorTheme::FIELD_NAMES),
618 "switch" => Some(SwitchTheme::FIELD_NAMES),
619 "dialog" => Some(DialogTheme::FIELD_NAMES),
620 "spinner" => Some(SpinnerTheme::FIELD_NAMES),
621 "combo_box" => Some(ComboBoxTheme::FIELD_NAMES),
622 "segmented_control" => Some(SegmentedControlTheme::FIELD_NAMES),
623 "card" => Some(CardTheme::FIELD_NAMES),
624 "expander" => Some(ExpanderTheme::FIELD_NAMES),
625 "link" => Some(LinkTheme::FIELD_NAMES),
626 _ => None,
627 }
628 }
629
630 fn lint_text_scale(
632 table: &toml::map::Map<String, toml::Value>,
633 prefix: &str,
634 warnings: &mut Vec<String>,
635 ) {
636 for key in table.keys() {
637 if !TextScale::FIELD_NAMES.contains(&key.as_str()) {
638 warnings.push(format!("unknown field: {prefix}.{key}"));
639 } else if let Some(toml::Value::Table(entry_table)) = table.get(key) {
640 for ekey in entry_table.keys() {
641 if !TextScaleEntry::FIELD_NAMES.contains(&ekey.as_str()) {
642 warnings.push(format!("unknown field: {prefix}.{key}.{ekey}"));
643 }
644 }
645 }
646 }
647 }
648
649 fn lint_defaults(
651 table: &toml::map::Map<String, toml::Value>,
652 prefix: &str,
653 warnings: &mut Vec<String>,
654 ) {
655 for key in table.keys() {
656 if !ThemeDefaults::FIELD_NAMES.contains(&key.as_str()) {
657 warnings.push(format!("unknown field: {prefix}.{key}"));
658 continue;
659 }
660 if let Some(toml::Value::Table(sub)) = table.get(key) {
662 let known = match key.as_str() {
663 "font" | "mono_font" => FontSpec::FIELD_NAMES,
664 "border" => BorderSpec::FIELD_NAMES,
665 "icon_sizes" => IconSizes::FIELD_NAMES,
666 _ => continue,
667 };
668 for skey in sub.keys() {
669 if !known.contains(&skey.as_str()) {
670 warnings.push(format!("unknown field: {prefix}.{key}.{skey}"));
671 }
672 }
673 }
674 }
675 }
676
677 fn lint_variant(
679 table: &toml::map::Map<String, toml::Value>,
680 prefix: &str,
681 warnings: &mut Vec<String>,
682 ) {
683 for key in table.keys() {
684 if !VARIANT_KEYS.contains(&key.as_str()) {
685 warnings.push(format!("unknown field: {prefix}.{key}"));
686 continue;
687 }
688
689 if let Some(toml::Value::Table(sub)) = table.get(key) {
690 let sub_prefix = format!("{prefix}.{key}");
691 match key.as_str() {
692 "defaults" => lint_defaults(sub, &sub_prefix, warnings),
693 "text_scale" => lint_text_scale(sub, &sub_prefix, warnings),
694 _ => {
695 if let Some(fields) = widget_fields(key) {
696 for skey in sub.keys() {
697 if !fields.contains(&skey.as_str()) {
698 warnings
699 .push(format!("unknown field: {sub_prefix}.{skey}"));
700 }
701 if let Some(toml::Value::Table(nested)) = sub.get(skey) {
703 let nested_known = match skey.as_str() {
704 s if s == "font" || s.ends_with("_font") => {
705 Some(FontSpec::FIELD_NAMES)
706 }
707 "border" => Some(BorderSpec::FIELD_NAMES),
708 _ => None,
709 };
710 if let Some(known) = nested_known {
711 for nkey in nested.keys() {
712 if !known.contains(&nkey.as_str()) {
713 warnings.push(format!(
714 "unknown field: {sub_prefix}.{skey}.{nkey}"
715 ));
716 }
717 }
718 }
719 }
720 }
721 }
722 }
723 }
724 }
725 }
726 }
727
728 for variant_key in &["light", "dark"] {
730 if let Some(toml::Value::Table(variant_table)) = top_table.get(*variant_key) {
731 lint_variant(variant_table, variant_key, &mut warnings);
732 }
733 }
734
735 if let Some(toml::Value::Table(layout_table)) = top_table.get("layout") {
737 for key in layout_table.keys() {
738 if !LayoutTheme::FIELD_NAMES.contains(&key.as_str()) {
739 warnings.push(format!("unknown field: layout.{key}"));
740 }
741 }
742 }
743
744 Ok(warnings)
745 }
746}
747
748#[cfg(test)]
749#[allow(clippy::unwrap_used, clippy::expect_used)]
750mod tests {
751 use super::*;
752 use crate::Rgba;
753
754 #[test]
757 fn theme_variant_default_is_empty() {
758 assert!(ThemeVariant::default().is_empty());
759 }
760
761 #[test]
762 fn theme_variant_not_empty_when_color_set() {
763 let mut v = ThemeVariant::default();
764 v.defaults.accent_color = Some(Rgba::rgb(0, 120, 215));
765 assert!(!v.is_empty());
766 }
767
768 #[test]
769 fn theme_variant_not_empty_when_font_set() {
770 let mut v = ThemeVariant::default();
771 v.defaults.font.family = Some("Inter".into());
772 assert!(!v.is_empty());
773 }
774
775 #[test]
776 fn theme_variant_merge_recursively() {
777 let mut base = ThemeVariant::default();
778 base.defaults.background_color = Some(Rgba::rgb(255, 255, 255));
779 base.defaults.font.family = Some("Noto Sans".into());
780
781 let mut overlay = ThemeVariant::default();
782 overlay.defaults.accent_color = Some(Rgba::rgb(0, 120, 215));
783 overlay.defaults.border.corner_radius = Some(4.0);
784
785 base.merge(&overlay);
786
787 assert_eq!(
789 base.defaults.background_color,
790 Some(Rgba::rgb(255, 255, 255))
791 );
792 assert_eq!(base.defaults.accent_color, Some(Rgba::rgb(0, 120, 215)));
794 assert_eq!(base.defaults.font.family.as_deref(), Some("Noto Sans"));
796 assert_eq!(base.defaults.border.corner_radius, Some(4.0));
798 }
799
800 #[test]
801 fn theme_variant_has_all_widgets() {
802 let mut v = ThemeVariant::default();
803 v.window.background_color = Some(Rgba::rgb(255, 255, 255));
805 v.button.min_height = Some(32.0);
806 v.input.min_height = Some(32.0);
807 v.checkbox.indicator_width = Some(18.0);
808 v.menu.row_height = Some(28.0);
809 v.tooltip.max_width = Some(300.0);
810 v.scrollbar.groove_width = Some(14.0);
811 v.slider.track_height = Some(4.0);
812 v.progress_bar.track_height = Some(6.0);
813 v.tab.min_height = Some(32.0);
814 v.sidebar.background_color = Some(Rgba::rgb(240, 240, 240));
815 v.toolbar.bar_height = Some(40.0);
816 v.status_bar.background_color = Some(Rgba::rgb(240, 240, 240));
817 v.list.row_height = Some(28.0);
818 v.popover.background_color = Some(Rgba::rgb(255, 255, 255));
819 v.splitter.divider_width = Some(4.0);
820 v.separator.line_color = Some(Rgba::rgb(200, 200, 200));
821 v.switch.track_width = Some(32.0);
822 v.dialog.min_width = Some(320.0);
823 v.spinner.diameter = Some(24.0);
824 v.combo_box.min_height = Some(32.0);
825 v.segmented_control.segment_height = Some(28.0);
826 v.card.background_color = Some(Rgba::rgb(255, 255, 255));
827 v.expander.header_height = Some(32.0);
828 v.link.underline_enabled = Some(true);
829
830 assert!(!v.is_empty());
831 assert!(!v.window.is_empty());
832 assert!(!v.button.is_empty());
833 assert!(!v.input.is_empty());
834 assert!(!v.checkbox.is_empty());
835 assert!(!v.menu.is_empty());
836 assert!(!v.tooltip.is_empty());
837 assert!(!v.scrollbar.is_empty());
838 assert!(!v.slider.is_empty());
839 assert!(!v.progress_bar.is_empty());
840 assert!(!v.tab.is_empty());
841 assert!(!v.sidebar.is_empty());
842 assert!(!v.toolbar.is_empty());
843 assert!(!v.status_bar.is_empty());
844 assert!(!v.list.is_empty());
845 assert!(!v.popover.is_empty());
846 assert!(!v.splitter.is_empty());
847 assert!(!v.separator.is_empty());
848 assert!(!v.switch.is_empty());
849 assert!(!v.dialog.is_empty());
850 assert!(!v.spinner.is_empty());
851 assert!(!v.combo_box.is_empty());
852 assert!(!v.segmented_control.is_empty());
853 assert!(!v.card.is_empty());
854 assert!(!v.expander.is_empty());
855 assert!(!v.link.is_empty());
856 }
857
858 #[test]
859 fn theme_variant_merge_per_widget() {
860 let mut base = ThemeVariant::default();
861 base.button.background_color = Some(Rgba::rgb(200, 200, 200));
862 base.button.min_height = Some(28.0);
863 base.tooltip.background_color = Some(Rgba::rgb(50, 50, 50));
864
865 let mut overlay = ThemeVariant::default();
866 overlay.button.background_color = Some(Rgba::rgb(255, 255, 255));
867 overlay.button.min_width = Some(64.0);
868
869 base.merge(&overlay);
870
871 assert_eq!(base.button.background_color, Some(Rgba::rgb(255, 255, 255)));
873 assert_eq!(base.button.min_width, Some(64.0));
875 assert_eq!(base.button.min_height, Some(28.0));
877 assert_eq!(base.tooltip.background_color, Some(Rgba::rgb(50, 50, 50)));
879 }
880
881 #[test]
884 fn native_theme_new_constructor() {
885 let theme = ThemeSpec::new("Breeze");
886 assert_eq!(theme.name, "Breeze");
887 assert!(theme.light.is_none());
888 assert!(theme.dark.is_none());
889 }
890
891 #[test]
892 fn native_theme_default_is_empty() {
893 let theme = ThemeSpec::default();
894 assert!(theme.is_empty());
895 assert_eq!(theme.name, "");
896 }
897
898 #[test]
899 fn native_theme_merge_keeps_base_name() {
900 let mut base = ThemeSpec::new("Base Theme");
901 let overlay = ThemeSpec::new("Overlay Theme");
902 base.merge(&overlay);
903 assert_eq!(base.name, "Base Theme");
904 }
905
906 #[test]
907 fn native_theme_merge_overlay_light_into_none() {
908 let mut base = ThemeSpec::new("Theme");
909
910 let mut overlay = ThemeSpec::new("Overlay");
911 let mut light = ThemeVariant::default();
912 light.defaults.accent_color = Some(Rgba::rgb(0, 120, 215));
913 overlay.light = Some(light);
914
915 base.merge(&overlay);
916
917 assert!(base.light.is_some());
918 assert_eq!(
919 base.light.as_ref().unwrap().defaults.accent_color,
920 Some(Rgba::rgb(0, 120, 215))
921 );
922 }
923
924 #[test]
925 fn native_theme_merge_both_light_variants() {
926 let mut base = ThemeSpec::new("Theme");
927 let mut base_light = ThemeVariant::default();
928 base_light.defaults.background_color = Some(Rgba::rgb(255, 255, 255));
929 base.light = Some(base_light);
930
931 let mut overlay = ThemeSpec::new("Overlay");
932 let mut overlay_light = ThemeVariant::default();
933 overlay_light.defaults.accent_color = Some(Rgba::rgb(0, 120, 215));
934 overlay.light = Some(overlay_light);
935
936 base.merge(&overlay);
937
938 let light = base.light.as_ref().unwrap();
939 assert_eq!(
941 light.defaults.background_color,
942 Some(Rgba::rgb(255, 255, 255))
943 );
944 assert_eq!(light.defaults.accent_color, Some(Rgba::rgb(0, 120, 215)));
946 }
947
948 #[test]
949 fn native_theme_merge_base_light_only_preserved() {
950 let mut base = ThemeSpec::new("Theme");
951 let mut base_light = ThemeVariant::default();
952 base_light.defaults.font.family = Some("Inter".into());
953 base.light = Some(base_light);
954
955 let overlay = ThemeSpec::new("Overlay"); base.merge(&overlay);
958
959 assert!(base.light.is_some());
960 assert_eq!(
961 base.light.as_ref().unwrap().defaults.font.family.as_deref(),
962 Some("Inter")
963 );
964 }
965
966 #[test]
967 fn native_theme_merge_dark_variant() {
968 let mut base = ThemeSpec::new("Theme");
969
970 let mut overlay = ThemeSpec::new("Overlay");
971 let mut dark = ThemeVariant::default();
972 dark.defaults.background_color = Some(Rgba::rgb(30, 30, 30));
973 overlay.dark = Some(dark);
974
975 base.merge(&overlay);
976
977 assert!(base.dark.is_some());
978 assert_eq!(
979 base.dark.as_ref().unwrap().defaults.background_color,
980 Some(Rgba::rgb(30, 30, 30))
981 );
982 }
983
984 #[test]
985 fn native_theme_not_empty_with_light() {
986 let mut theme = ThemeSpec::new("Theme");
987 theme.light = Some(ThemeVariant::default());
988 assert!(!theme.is_empty());
989 }
990
991 #[test]
994 fn pick_variant_dark_with_both_variants_returns_dark() {
995 let mut theme = ThemeSpec::new("Test");
996 let mut light = ThemeVariant::default();
997 light.defaults.background_color = Some(Rgba::rgb(255, 255, 255));
998 theme.light = Some(light);
999 let mut dark = ThemeVariant::default();
1000 dark.defaults.background_color = Some(Rgba::rgb(30, 30, 30));
1001 theme.dark = Some(dark);
1002
1003 let picked = theme.pick_variant(true).unwrap();
1004 assert_eq!(
1005 picked.defaults.background_color,
1006 Some(Rgba::rgb(30, 30, 30))
1007 );
1008 }
1009
1010 #[test]
1011 fn pick_variant_light_with_both_variants_returns_light() {
1012 let mut theme = ThemeSpec::new("Test");
1013 let mut light = ThemeVariant::default();
1014 light.defaults.background_color = Some(Rgba::rgb(255, 255, 255));
1015 theme.light = Some(light);
1016 let mut dark = ThemeVariant::default();
1017 dark.defaults.background_color = Some(Rgba::rgb(30, 30, 30));
1018 theme.dark = Some(dark);
1019
1020 let picked = theme.pick_variant(false).unwrap();
1021 assert_eq!(
1022 picked.defaults.background_color,
1023 Some(Rgba::rgb(255, 255, 255))
1024 );
1025 }
1026
1027 #[test]
1028 fn pick_variant_dark_with_only_light_falls_back() {
1029 let mut theme = ThemeSpec::new("Test");
1030 let mut light = ThemeVariant::default();
1031 light.defaults.background_color = Some(Rgba::rgb(255, 255, 255));
1032 theme.light = Some(light);
1033
1034 let picked = theme.pick_variant(true).unwrap();
1035 assert_eq!(
1036 picked.defaults.background_color,
1037 Some(Rgba::rgb(255, 255, 255))
1038 );
1039 }
1040
1041 #[test]
1042 fn pick_variant_light_with_only_dark_falls_back() {
1043 let mut theme = ThemeSpec::new("Test");
1044 let mut dark = ThemeVariant::default();
1045 dark.defaults.background_color = Some(Rgba::rgb(30, 30, 30));
1046 theme.dark = Some(dark);
1047
1048 let picked = theme.pick_variant(false).unwrap();
1049 assert_eq!(
1050 picked.defaults.background_color,
1051 Some(Rgba::rgb(30, 30, 30))
1052 );
1053 }
1054
1055 #[test]
1056 fn pick_variant_with_no_variants_returns_none() {
1057 let theme = ThemeSpec::new("Empty");
1058 assert!(theme.pick_variant(true).is_none());
1059 assert!(theme.pick_variant(false).is_none());
1060 }
1061
1062 #[test]
1065 fn icon_set_default_is_none() {
1066 assert!(ThemeVariant::default().icon_set.is_none());
1067 }
1068
1069 #[test]
1070 fn icon_set_merge_overlay() {
1071 let mut base = ThemeVariant::default();
1072 let overlay = ThemeVariant {
1073 icon_set: Some(IconSet::Material),
1074 ..Default::default()
1075 };
1076 base.merge(&overlay);
1077 assert_eq!(base.icon_set, Some(IconSet::Material));
1078 }
1079
1080 #[test]
1081 fn icon_set_merge_none_preserves() {
1082 let mut base = ThemeVariant {
1083 icon_set: Some(IconSet::SfSymbols),
1084 ..Default::default()
1085 };
1086 let overlay = ThemeVariant::default();
1087 base.merge(&overlay);
1088 assert_eq!(base.icon_set, Some(IconSet::SfSymbols));
1089 }
1090
1091 #[test]
1092 fn icon_set_is_empty_when_set() {
1093 assert!(ThemeVariant::default().is_empty());
1094 let v = ThemeVariant {
1095 icon_set: Some(IconSet::Material),
1096 ..Default::default()
1097 };
1098 assert!(!v.is_empty());
1099 }
1100
1101 #[test]
1102 fn icon_set_toml_round_trip() {
1103 let variant = ThemeVariant {
1104 icon_set: Some(IconSet::Material),
1105 ..Default::default()
1106 };
1107 let toml_str = toml::to_string(&variant).unwrap();
1108 assert!(toml_str.contains("icon_set"));
1109 let deserialized: ThemeVariant = toml::from_str(&toml_str).unwrap();
1110 assert_eq!(deserialized.icon_set, Some(IconSet::Material));
1111 }
1112
1113 #[test]
1114 fn icon_set_toml_absent_deserializes_to_none() {
1115 let toml_str = r##"
1116[defaults]
1117accent_color = "#ff0000"
1118"##;
1119 let variant: ThemeVariant = toml::from_str(toml_str).unwrap();
1120 assert!(variant.icon_set.is_none());
1121 }
1122
1123 #[test]
1124 fn native_theme_serde_toml_round_trip() {
1125 let theme = ThemeSpec::preset("material").expect("material preset should load");
1127 let toml_str = theme.to_toml().expect("should serialize");
1128 let theme2 = ThemeSpec::from_toml(&toml_str).expect("should deserialize");
1129 assert_eq!(theme, theme2, "round-trip should preserve ThemeSpec");
1130 }
1131
1132 #[test]
1135 fn from_toml_with_base_merges_colors_onto_preset() {
1136 let overlay_toml = r##"
1137name = "Custom"
1138[light.defaults]
1139accent_color = "#ff00ff"
1140"##;
1141 let theme = ThemeSpec::from_toml_with_base(overlay_toml, "material").expect("should merge");
1142 let light = theme.light.as_ref().expect("light variant should exist");
1144 assert_eq!(
1145 light.defaults.accent_color,
1146 Some(crate::Rgba::rgb(255, 0, 255)),
1147 "overlay accent_color should replace preset"
1148 );
1149 assert!(
1151 light.defaults.font.family.is_some(),
1152 "preset font family should be preserved after merge"
1153 );
1154 }
1155
1156 #[test]
1157 fn from_toml_with_base_unknown_preset_returns_error() {
1158 let err = ThemeSpec::from_toml_with_base("name = \"X\"", "nonexistent").unwrap_err();
1159 match err {
1160 crate::Error::Unavailable(msg) => assert!(msg.contains("nonexistent")),
1161 other => panic!("expected Unavailable, got: {other:?}"),
1162 }
1163 }
1164
1165 #[test]
1166 fn from_toml_with_base_invalid_toml_returns_error() {
1167 let err = ThemeSpec::from_toml_with_base("{{{{invalid", "material").unwrap_err();
1168 match err {
1169 crate::Error::Format(_) => {}
1170 other => panic!("expected Format, got: {other:?}"),
1171 }
1172 }
1173
1174 #[test]
1177 fn lint_toml_valid_returns_empty() {
1178 let toml = r##"
1179name = "Valid Theme"
1180[light.defaults]
1181accent_color = "#ff0000"
1182background_color = "#ffffff"
1183[light.defaults.font]
1184family = "Inter"
1185size_px = 14.0
1186[light.button]
1187min_height_px = 32.0
1188"##;
1189 let warnings = ThemeSpec::lint_toml(toml).unwrap();
1190 assert!(
1191 warnings.is_empty(),
1192 "Expected no warnings, got: {warnings:?}"
1193 );
1194 }
1195
1196 #[test]
1197 fn lint_toml_detects_unknown_top_level() {
1198 let toml = r##"
1199name = "Test"
1200theme_version = 2
1201"##;
1202 let warnings = ThemeSpec::lint_toml(toml).unwrap();
1203 assert_eq!(warnings.len(), 1);
1204 assert!(warnings[0].contains("theme_version"));
1205 }
1206
1207 #[test]
1208 fn lint_toml_detects_misspelled_defaults_field() {
1209 let toml = r##"
1210name = "Test"
1211[light.defaults]
1212backround = "#ffffff"
1213"##;
1214 let warnings = ThemeSpec::lint_toml(toml).unwrap();
1215 assert_eq!(warnings.len(), 1);
1216 assert!(warnings[0].contains("backround"));
1217 assert!(warnings[0].contains("light.defaults.backround"));
1218 }
1219
1220 #[test]
1221 fn lint_toml_detects_unknown_widget_field() {
1222 let toml = r##"
1223name = "Test"
1224[dark.button]
1225primary_bg = "#0078d7"
1226"##;
1227 let warnings = ThemeSpec::lint_toml(toml).unwrap();
1228 assert_eq!(warnings.len(), 1);
1229 assert!(warnings[0].contains("primary_bg"));
1230 }
1231
1232 #[test]
1233 fn lint_toml_detects_unknown_variant_section() {
1234 let toml = r##"
1235name = "Test"
1236[light.badges]
1237color = "#ff0000"
1238"##;
1239 let warnings = ThemeSpec::lint_toml(toml).unwrap();
1240 assert_eq!(warnings.len(), 1);
1241 assert!(warnings[0].contains("badges"));
1242 }
1243
1244 #[test]
1245 fn lint_toml_detects_unknown_font_subfield() {
1246 let toml = r##"
1247name = "Test"
1248[light.defaults.font]
1249famly = "Inter"
1250"##;
1251 let warnings = ThemeSpec::lint_toml(toml).unwrap();
1252 assert_eq!(warnings.len(), 1);
1253 assert!(warnings[0].contains("famly"));
1254 }
1255
1256 #[test]
1257 fn lint_toml_detects_unknown_border_subfield() {
1258 let toml = r##"
1259name = "Test"
1260[light.defaults.border]
1261radiusss = 4.0
1262"##;
1263 let warnings = ThemeSpec::lint_toml(toml).unwrap();
1264 assert_eq!(warnings.len(), 1);
1265 assert!(warnings[0].contains("radiusss"));
1266 }
1267
1268 #[test]
1269 fn lint_toml_detects_unknown_text_scale_entry() {
1270 let toml = r##"
1271name = "Test"
1272[light.text_scale.headline]
1273size = 24.0
1274"##;
1275 let warnings = ThemeSpec::lint_toml(toml).unwrap();
1276 assert_eq!(warnings.len(), 1);
1277 assert!(warnings[0].contains("headline"));
1278 }
1279
1280 #[test]
1281 fn lint_toml_detects_unknown_text_scale_entry_field() {
1282 let toml = r##"
1283name = "Test"
1284[light.text_scale.caption]
1285font_size = 12.0
1286"##;
1287 let warnings = ThemeSpec::lint_toml(toml).unwrap();
1288 assert_eq!(warnings.len(), 1);
1289 assert!(warnings[0].contains("font_size"));
1290 }
1291
1292 #[test]
1293 fn lint_toml_multiple_errors() {
1294 let toml = r##"
1295name = "Test"
1296author = "Me"
1297[light.defaults]
1298backround = "#ffffff"
1299[light.button]
1300primay_bg = "#0078d7"
1301"##;
1302 let warnings = ThemeSpec::lint_toml(toml).unwrap();
1303 assert_eq!(warnings.len(), 3);
1304 }
1305
1306 #[test]
1307 fn lint_toml_invalid_toml_returns_error() {
1308 let result = ThemeSpec::lint_toml("{{{{invalid");
1309 assert!(result.is_err());
1310 }
1311
1312 #[test]
1313 fn lint_toml_preset_has_no_warnings() {
1314 let theme = ThemeSpec::preset("material").expect("material preset should load");
1316 let toml_str = theme.to_toml().expect("should serialize");
1317 let warnings = ThemeSpec::lint_toml(&toml_str).expect("should parse");
1318 assert!(
1319 warnings.is_empty(),
1320 "material preset should have no lint warnings, got: {warnings:?}"
1321 );
1322 }
1323
1324 #[test]
1325 fn lint_toml_all_presets_clean() {
1326 for name in ThemeSpec::list_presets() {
1327 let theme = ThemeSpec::preset(name).unwrap_or_else(|e| {
1330 panic!("preset {name} should load: {e}");
1331 });
1332 let toml_str = theme.to_toml().unwrap_or_else(|e| {
1333 panic!("preset {name} should serialize: {e}");
1334 });
1335 let warnings = ThemeSpec::lint_toml(&toml_str).unwrap_or_else(|e| {
1336 panic!("preset {name} should lint: {e}");
1337 });
1338 assert!(
1339 warnings.is_empty(),
1340 "preset {name} should have no lint warnings, got: {warnings:?}"
1341 );
1342 }
1343 }
1344
1345 #[test]
1348 fn theme_spec_layout_merge() {
1349 let mut base = ThemeSpec::new("Base");
1350 base.layout.widget_gap = Some(6.0);
1351
1352 let mut overlay = ThemeSpec::new("Overlay");
1353 overlay.layout.container_margin = Some(8.0);
1354
1355 base.merge(&overlay);
1356 assert_eq!(base.layout.widget_gap, Some(6.0));
1357 assert_eq!(base.layout.container_margin, Some(8.0));
1358 }
1359
1360 #[test]
1361 fn theme_spec_layout_toml_round_trip() {
1362 let mut theme = ThemeSpec::new("Layout Test");
1363 theme.layout.widget_gap = Some(8.0);
1364 theme.layout.container_margin = Some(12.0);
1365 theme.layout.window_margin = Some(16.0);
1366 theme.layout.section_gap = Some(24.0);
1367
1368 let toml_str = theme.to_toml().unwrap();
1369 let theme2 = ThemeSpec::from_toml(&toml_str).unwrap();
1370 assert_eq!(theme.layout, theme2.layout);
1371 }
1372
1373 #[test]
1374 fn theme_spec_is_empty_with_layout() {
1375 let mut theme = ThemeSpec::new("Layout Only");
1376 assert!(theme.is_empty()); theme.layout.widget_gap = Some(8.0);
1378 assert!(!theme.is_empty());
1379 }
1380
1381 #[test]
1382 fn theme_spec_layout_top_level_toml() {
1383 let mut theme = ThemeSpec::new("Top Level");
1384 theme.layout.widget_gap = Some(8.0);
1385
1386 let toml_str = theme.to_toml().unwrap();
1387 assert!(
1389 toml_str.contains("[layout]"),
1390 "TOML should have [layout] section"
1391 );
1392 assert!(!toml_str.contains("[light.layout]"));
1393 assert!(!toml_str.contains("[dark.layout]"));
1394 }
1395}