1#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
18#[non_exhaustive]
19pub enum ColorMode {
20 Light,
22 Dark,
24}
25
26impl ColorMode {
27 #[must_use]
29 pub fn is_dark(self) -> bool {
30 matches!(self, Self::Dark)
31 }
32}
33
34pub mod animated;
36pub mod arrow_side;
38pub mod border;
40pub mod bundled;
42pub mod defaults;
44pub mod dialog_order;
46pub mod font;
48pub mod icon_sizes;
50pub mod icons;
52pub mod resolved;
54pub mod tab_indicator_side;
56pub mod widgets;
58
59pub use animated::{
60 AnimatedIcon, EmptyFrameListError, FrameList, FramesData, TransformAnimation, TransformData,
61};
62pub use border::{
63 DefaultsBorderSpec, ResolvedDefaultsBorder, ResolvedPadding, ResolvedWidgetBorder,
64 WidgetBorderSpec,
65};
66pub use arrow_side::ArrowSide;
68pub(crate) use bundled::{bundled_icon_by_name, bundled_icon_svg};
69pub use defaults::ThemeDefaults;
70pub use dialog_order::DialogButtonOrder;
71pub use font::{
72 FontSize, FontSpec, FontStyle, ResolvedFontSpec, TextScale, TextScaleEntry, intern_font_family,
73};
74pub use icon_sizes::IconSizes;
75pub use icons::{
76 IconData, IconProvider, IconRole, IconSet, icon_name, system_icon_set, system_icon_theme,
77};
78pub use resolved::{
79 Resolved, ResolvedDefaults, ResolvedIconSizes, ResolvedTextScale, ResolvedTextScaleEntry,
80 ResolvedTheme,
81};
82pub use tab_indicator_side::TabIndicatorSide;
83pub use widgets::*; use std::borrow::Cow;
86
87use serde::{Deserialize, Serialize};
88
89#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
106#[serde(default)]
107pub struct ThemeMode {
108 #[serde(default, skip_serializing_if = "ThemeDefaults::is_empty")]
110 pub defaults: ThemeDefaults,
111
112 #[serde(default, skip_serializing_if = "TextScale::is_empty")]
114 pub text_scale: TextScale,
115
116 #[serde(default, skip_serializing_if = "WindowTheme::is_empty")]
118 pub window: WindowTheme,
119
120 #[serde(default, skip_serializing_if = "ButtonTheme::is_empty")]
122 pub button: ButtonTheme,
123
124 #[serde(default, skip_serializing_if = "InputTheme::is_empty")]
126 pub input: InputTheme,
127
128 #[serde(default, skip_serializing_if = "TextAreaTheme::is_empty")]
130 pub text_area: TextAreaTheme,
131
132 #[serde(default, skip_serializing_if = "CheckboxTheme::is_empty")]
134 pub checkbox: CheckboxTheme,
135
136 #[serde(default, skip_serializing_if = "MenuTheme::is_empty")]
138 pub menu: MenuTheme,
139
140 #[serde(default, skip_serializing_if = "TooltipTheme::is_empty")]
142 pub tooltip: TooltipTheme,
143
144 #[serde(default, skip_serializing_if = "ScrollbarTheme::is_empty")]
146 pub scrollbar: ScrollbarTheme,
147
148 #[serde(default, skip_serializing_if = "SliderTheme::is_empty")]
150 pub slider: SliderTheme,
151
152 #[serde(default, skip_serializing_if = "ProgressBarTheme::is_empty")]
154 pub progress_bar: ProgressBarTheme,
155
156 #[serde(default, skip_serializing_if = "TabTheme::is_empty")]
158 pub tab: TabTheme,
159
160 #[serde(default, skip_serializing_if = "SidebarTheme::is_empty")]
162 pub sidebar: SidebarTheme,
163
164 #[serde(default, skip_serializing_if = "ToolbarTheme::is_empty")]
166 pub toolbar: ToolbarTheme,
167
168 #[serde(default, skip_serializing_if = "StatusBarTheme::is_empty")]
170 pub status_bar: StatusBarTheme,
171
172 #[serde(default, skip_serializing_if = "ListTheme::is_empty")]
174 pub list: ListTheme,
175
176 #[serde(default, skip_serializing_if = "PopoverTheme::is_empty")]
178 pub popover: PopoverTheme,
179
180 #[serde(default, skip_serializing_if = "SplitterTheme::is_empty")]
182 pub splitter: SplitterTheme,
183
184 #[serde(default, skip_serializing_if = "SeparatorTheme::is_empty")]
186 pub separator: SeparatorTheme,
187
188 #[serde(default, skip_serializing_if = "SwitchTheme::is_empty")]
190 pub switch: SwitchTheme,
191
192 #[serde(default, skip_serializing_if = "DialogTheme::is_empty")]
194 pub dialog: DialogTheme,
195
196 #[serde(default, skip_serializing_if = "SpinnerTheme::is_empty")]
198 pub spinner: SpinnerTheme,
199
200 #[serde(default, skip_serializing_if = "ComboBoxTheme::is_empty")]
202 pub combo_box: ComboBoxTheme,
203
204 #[serde(default, skip_serializing_if = "SegmentedControlTheme::is_empty")]
206 pub segmented_control: SegmentedControlTheme,
207
208 #[serde(default, skip_serializing_if = "CardTheme::is_empty")]
210 pub card: CardTheme,
211
212 #[serde(default, skip_serializing_if = "ExpanderTheme::is_empty")]
214 pub expander: ExpanderTheme,
215
216 #[serde(default, skip_serializing_if = "LinkTheme::is_empty")]
218 pub link: LinkTheme,
219}
220
221impl_merge!(ThemeMode {
222 nested {
223 defaults, text_scale, window, button, input, text_area, checkbox, menu,
224 tooltip, scrollbar, slider, progress_bar, tab, sidebar,
225 toolbar, status_bar, list, popover, splitter, separator,
226 switch, dialog, spinner, combo_box, segmented_control,
227 card, expander, link
228 }
229});
230
231#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
260pub struct Theme {
261 pub name: Cow<'static, str>,
268
269 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub light: Option<ThemeMode>,
272
273 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub dark: Option<ThemeMode>,
276
277 #[serde(default, skip_serializing_if = "LayoutTheme::is_empty")]
279 pub layout: LayoutTheme,
280
281 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub icon_set: Option<IconSet>,
297
298 #[serde(default, skip_serializing_if = "Option::is_none")]
314 pub icon_theme: Option<Cow<'static, str>>,
315}
316
317impl Default for Theme {
318 fn default() -> Self {
319 Self {
320 name: Cow::Borrowed(""),
321 light: None,
322 dark: None,
323 layout: LayoutTheme::default(),
324 icon_set: None,
325 icon_theme: None,
326 }
327 }
328}
329
330impl Theme {
331 pub fn merge(&mut self, overlay: &Self) {
338 match (&mut self.light, &overlay.light) {
341 (Some(base), Some(over)) => base.merge(over),
342 (None, Some(over)) => self.light = Some(over.clone()),
343 _ => {}
344 }
345
346 match (&mut self.dark, &overlay.dark) {
347 (Some(base), Some(over)) => base.merge(over),
348 (None, Some(over)) => self.dark = Some(over.clone()),
349 _ => {}
350 }
351
352 self.layout.merge(&overlay.layout);
353
354 if overlay.icon_set.is_some() {
355 self.icon_set = overlay.icon_set;
356 }
357
358 if overlay.icon_theme.is_some() {
359 self.icon_theme.clone_from(&overlay.icon_theme);
360 }
361 }
362
363 pub fn pick_variant(&self, mode: ColorMode) -> crate::Result<&ThemeMode> {
373 match mode {
374 ColorMode::Dark => self.dark.as_ref().or(self.light.as_ref()),
375 ColorMode::Light => self.light.as_ref().or(self.dark.as_ref()),
376 }
377 .ok_or(crate::Error::NoVariant { mode })
378 }
379
380 pub fn into_variant(self, mode: ColorMode) -> crate::Result<ThemeMode> {
404 match mode {
405 ColorMode::Dark => self.dark.or(self.light),
406 ColorMode::Light => self.light.or(self.dark),
407 }
408 .ok_or(crate::Error::NoVariant { mode })
409 }
410
411 pub fn resolve(&self, mode: ColorMode) -> crate::Result<Resolved> {
461 self.resolve_in(mode, &crate::resolve::ResolutionContext::from_system())
462 }
463
464 pub(crate) fn resolve_in(
467 &self,
468 mode: ColorMode,
469 ctx: &crate::resolve::ResolutionContext,
470 ) -> crate::Result<Resolved> {
471 let variant_ref = self.pick_variant(mode)?;
472
473 let tier1 = variant_ref.defaults.icon_theme.clone();
474 let tier2 = self.icon_theme.clone();
475 let icon_theme_explicit = tier1.is_some() || tier2.is_some();
476 let icon_theme = tier1.or(tier2).or_else(|| ctx.icon_theme.clone());
477
478 let icon_set = self
479 .icon_set
480 .unwrap_or_else(crate::model::icons::system_icon_set);
481
482 let variant = variant_ref.clone().into_resolved(ctx)?;
483
484 Ok(Resolved {
485 variant,
486 icon_set,
487 icon_theme,
488 icon_theme_explicit,
489 })
490 }
491
492 pub fn is_empty(&self) -> bool {
494 self.light.is_none()
495 && self.dark.is_none()
496 && self.layout.is_empty()
497 && self.icon_set.is_none()
498 && self.icon_theme.is_none()
499 }
500
501 pub fn preset(name: &str) -> crate::Result<Self> {
525 crate::presets::preset(name)
526 }
527
528 pub fn from_toml(toml_str: &str) -> crate::Result<Self> {
632 crate::presets::from_toml(toml_str)
633 }
634
635 pub fn from_file(path: impl AsRef<std::path::Path>) -> crate::Result<Self> {
646 crate::presets::from_file(path)
647 }
648
649 #[must_use]
663 pub fn list_presets() -> &'static [crate::presets::PresetInfo] {
664 crate::presets::list_presets()
665 }
666
667 #[must_use]
686 pub fn list_presets_for_platform() -> Vec<crate::presets::PresetInfo> {
687 crate::presets::list_presets_for_platform()
688 }
689
690 pub fn to_toml(&self) -> crate::Result<String> {
702 crate::presets::to_toml(self)
703 }
704
705 pub fn lint_toml(toml_str: &str) -> crate::Result<Vec<String>> {
731 let value: toml::Value =
732 toml::from_str(toml_str).map_err(|e: toml::de::Error| crate::Error::Toml(e))?;
733
734 let mut warnings = Vec::new();
735
736 let top_table = match &value {
737 toml::Value::Table(t) => t,
738 _ => return Ok(warnings),
739 };
740
741 const TOP_KEYS: &[&str] = &["name", "light", "dark", "layout", "icon_set", "icon_theme"];
743
744 for key in top_table.keys() {
745 if !TOP_KEYS.contains(&key.as_str()) {
746 warnings.push(format!("unknown field: {key}"));
747 }
748 }
749
750 const STRUCTURAL_KEYS: &[&str] = &["defaults", "text_scale"];
752
753 let widget_registry: std::collections::HashMap<&str, &[&str]> =
759 inventory::iter::<crate::resolve::WidgetFieldInfo>()
760 .map(|info| (info.widget_name, info.field_names))
761 .collect();
762 let struct_registry: std::collections::HashMap<&str, &[&str]> =
763 inventory::iter::<crate::resolve::FieldInfo>()
764 .map(|info| (info.struct_name, info.field_names))
765 .collect();
766
767 let get_struct_fields =
772 |name: &str| -> Option<&[&str]> { struct_registry.get(name).copied() };
773
774 let lint_text_scale = |table: &toml::map::Map<String, toml::Value>,
776 prefix: &str,
777 warnings: &mut Vec<String>| {
778 let Some(scale_fields) = get_struct_fields("TextScale") else {
779 return;
780 };
781 let entry_fields = get_struct_fields("TextScaleEntry");
782 for key in table.keys() {
783 if !scale_fields.contains(&key.as_str()) {
784 warnings.push(format!("unknown field: {prefix}.{key}"));
785 } else if let Some(toml::Value::Table(entry_table)) = table.get(key)
786 && let Some(entry_fields) = entry_fields
787 {
788 for ekey in entry_table.keys() {
789 if !entry_fields.contains(&ekey.as_str()) {
790 warnings.push(format!("unknown field: {prefix}.{key}.{ekey}"));
791 }
792 }
793 }
794 }
795 };
796
797 let lint_defaults = |table: &toml::map::Map<String, toml::Value>,
799 prefix: &str,
800 warnings: &mut Vec<String>| {
801 let Some(defaults_fields) = get_struct_fields("ThemeDefaults") else {
802 return;
803 };
804 for key in table.keys() {
805 if !defaults_fields.contains(&key.as_str()) {
806 warnings.push(format!("unknown field: {prefix}.{key}"));
807 continue;
808 }
809 if let Some(toml::Value::Table(sub)) = table.get(key) {
811 let known = match key.as_str() {
812 "font" | "mono_font" => get_struct_fields("FontSpec"),
813 "border" => get_struct_fields("DefaultsBorderSpec"),
814 "icon_sizes" => get_struct_fields("IconSizes"),
815 _ => continue,
816 };
817 let Some(known) = known else { continue };
818 for skey in sub.keys() {
819 if !known.contains(&skey.as_str()) {
820 warnings.push(format!("unknown field: {prefix}.{key}.{skey}"));
821 }
822 }
823 }
824 }
825 };
826
827 let lint_variant = |table: &toml::map::Map<String, toml::Value>,
829 prefix: &str,
830 warnings: &mut Vec<String>| {
831 for key in table.keys() {
832 let key_str = key.as_str();
833
834 let is_structural = STRUCTURAL_KEYS.contains(&key_str);
836 let widget_fields = widget_registry.get(key_str);
837
838 if !is_structural && widget_fields.is_none() {
839 warnings.push(format!("unknown field: {prefix}.{key}"));
840 continue;
841 }
842
843 if let Some(toml::Value::Table(sub)) = table.get(key) {
844 let sub_prefix = format!("{prefix}.{key}");
845 match key_str {
846 "defaults" => lint_defaults(sub, &sub_prefix, warnings),
847 "text_scale" => lint_text_scale(sub, &sub_prefix, warnings),
848 _ => {
849 if let Some(fields) = widget_fields {
850 for skey in sub.keys() {
851 if !fields.contains(&skey.as_str()) {
852 warnings
853 .push(format!("unknown field: {sub_prefix}.{skey}"));
854 }
855 if let Some(toml::Value::Table(nested)) = sub.get(skey) {
857 let nested_known = match skey.as_str() {
858 s if s == "font" || s.ends_with("_font") => {
859 get_struct_fields("FontSpec")
860 }
861 "border" => get_struct_fields("WidgetBorderSpec"),
862 _ => None,
863 };
864 if let Some(known) = nested_known {
865 for nkey in nested.keys() {
866 if !known.contains(&nkey.as_str()) {
867 warnings.push(format!(
868 "unknown field: {sub_prefix}.{skey}.{nkey}"
869 ));
870 }
871 }
872 }
873 }
874 }
875 }
876 }
877 }
878 }
879 }
880 };
881
882 for variant_key in &["light", "dark"] {
884 if let Some(toml::Value::Table(variant_table)) = top_table.get(*variant_key) {
885 lint_variant(variant_table, variant_key, &mut warnings);
886 }
887 }
888
889 if let Some(toml::Value::Table(layout_table)) = top_table.get("layout")
893 && let Some(layout_fields) = get_struct_fields("LayoutTheme")
894 {
895 for key in layout_table.keys() {
896 if !layout_fields.contains(&key.as_str()) {
897 warnings.push(format!("unknown field: layout.{key}"));
898 }
899 }
900 }
901
902 Ok(warnings)
903 }
904}
905
906#[cfg(test)]
907#[allow(clippy::unwrap_used, clippy::expect_used)]
908mod tests {
909 use super::*;
910 use crate::Rgba;
911
912 #[test]
915 fn theme_variant_default_is_empty() {
916 assert!(ThemeMode::default().is_empty());
917 }
918
919 #[test]
920 fn theme_variant_not_empty_when_color_set() {
921 let mut v = ThemeMode::default();
922 v.defaults.accent_color = Some(Rgba::rgb(0, 120, 215));
923 assert!(!v.is_empty());
924 }
925
926 #[test]
927 fn theme_variant_not_empty_when_font_set() {
928 let mut v = ThemeMode::default();
929 v.defaults.font.family = Some("Inter".into());
930 assert!(!v.is_empty());
931 }
932
933 #[test]
934 fn theme_variant_merge_recursively() {
935 let mut base = ThemeMode::default();
936 base.defaults.background_color = Some(Rgba::rgb(255, 255, 255));
937 base.defaults.font.family = Some("Noto Sans".into());
938
939 let mut overlay = ThemeMode::default();
940 overlay.defaults.accent_color = Some(Rgba::rgb(0, 120, 215));
941 overlay.defaults.border.corner_radius = Some(4.0);
942
943 base.merge(&overlay);
944
945 assert_eq!(
947 base.defaults.background_color,
948 Some(Rgba::rgb(255, 255, 255))
949 );
950 assert_eq!(base.defaults.accent_color, Some(Rgba::rgb(0, 120, 215)));
952 assert_eq!(base.defaults.font.family.as_deref(), Some("Noto Sans"));
954 assert_eq!(base.defaults.border.corner_radius, Some(4.0));
956 }
957
958 #[test]
959 fn theme_variant_has_all_widgets() {
960 let mut v = ThemeMode::default();
961 v.window.background_color = Some(Rgba::rgb(255, 255, 255));
963 v.button.min_height = Some(32.0);
964 v.input.min_height = Some(32.0);
965 v.checkbox.indicator_width = Some(18.0);
966 v.menu.row_height = Some(28.0);
967 v.tooltip.max_width = Some(300.0);
968 v.scrollbar.groove_width = Some(14.0);
969 v.slider.track_height = Some(4.0);
970 v.progress_bar.track_height = Some(6.0);
971 v.tab.min_height = Some(32.0);
972 v.sidebar.background_color = Some(Rgba::rgb(240, 240, 240));
973 v.toolbar.bar_height = Some(40.0);
974 v.status_bar.background_color = Some(Rgba::rgb(240, 240, 240));
975 v.list.row_height = Some(28.0);
976 v.popover.background_color = Some(Rgba::rgb(255, 255, 255));
977 v.splitter.divider_width = Some(4.0);
978 v.separator.line_color = Some(Rgba::rgb(200, 200, 200));
979 v.switch.track_width = Some(32.0);
980 v.dialog.min_width = Some(320.0);
981 v.spinner.diameter = Some(24.0);
982 v.combo_box.min_height = Some(32.0);
983 v.segmented_control.segment_height = Some(28.0);
984 v.card.background_color = Some(Rgba::rgb(255, 255, 255));
985 v.expander.header_height = Some(32.0);
986 v.link.underline_enabled = Some(true);
987
988 assert!(!v.is_empty());
989 assert!(!v.window.is_empty());
990 assert!(!v.button.is_empty());
991 assert!(!v.input.is_empty());
992 assert!(!v.checkbox.is_empty());
993 assert!(!v.menu.is_empty());
994 assert!(!v.tooltip.is_empty());
995 assert!(!v.scrollbar.is_empty());
996 assert!(!v.slider.is_empty());
997 assert!(!v.progress_bar.is_empty());
998 assert!(!v.tab.is_empty());
999 assert!(!v.sidebar.is_empty());
1000 assert!(!v.toolbar.is_empty());
1001 assert!(!v.status_bar.is_empty());
1002 assert!(!v.list.is_empty());
1003 assert!(!v.popover.is_empty());
1004 assert!(!v.splitter.is_empty());
1005 assert!(!v.separator.is_empty());
1006 assert!(!v.switch.is_empty());
1007 assert!(!v.dialog.is_empty());
1008 assert!(!v.spinner.is_empty());
1009 assert!(!v.combo_box.is_empty());
1010 assert!(!v.segmented_control.is_empty());
1011 assert!(!v.card.is_empty());
1012 assert!(!v.expander.is_empty());
1013 assert!(!v.link.is_empty());
1014 }
1015
1016 #[test]
1017 fn theme_variant_merge_per_widget() {
1018 let mut base = ThemeMode::default();
1019 base.button.background_color = Some(Rgba::rgb(200, 200, 200));
1020 base.button.min_height = Some(28.0);
1021 base.tooltip.background_color = Some(Rgba::rgb(50, 50, 50));
1022
1023 let mut overlay = ThemeMode::default();
1024 overlay.button.background_color = Some(Rgba::rgb(255, 255, 255));
1025 overlay.button.min_width = Some(64.0);
1026
1027 base.merge(&overlay);
1028
1029 assert_eq!(base.button.background_color, Some(Rgba::rgb(255, 255, 255)));
1031 assert_eq!(base.button.min_width, Some(64.0));
1033 assert_eq!(base.button.min_height, Some(28.0));
1035 assert_eq!(base.tooltip.background_color, Some(Rgba::rgb(50, 50, 50)));
1037 }
1038
1039 #[test]
1042 fn native_theme_default_is_empty() {
1043 let theme = Theme::default();
1044 assert!(theme.is_empty());
1045 assert_eq!(theme.name, "");
1046 }
1047
1048 #[test]
1049 fn native_theme_merge_keeps_base_name() {
1050 let mut base = Theme {
1051 name: "Base Theme".into(),
1052 ..Theme::default()
1053 };
1054 let overlay = Theme {
1055 name: "Overlay Theme".into(),
1056 ..Theme::default()
1057 };
1058 base.merge(&overlay);
1059 assert_eq!(base.name, "Base Theme");
1060 }
1061
1062 #[test]
1063 fn native_theme_merge_overlay_light_into_none() {
1064 let mut base = Theme {
1065 name: "Theme".into(),
1066 ..Theme::default()
1067 };
1068
1069 let mut overlay = Theme {
1070 name: "Overlay".into(),
1071 ..Theme::default()
1072 };
1073 let mut light = ThemeMode::default();
1074 light.defaults.accent_color = Some(Rgba::rgb(0, 120, 215));
1075 overlay.light = Some(light);
1076
1077 base.merge(&overlay);
1078
1079 assert!(base.light.is_some());
1080 assert_eq!(
1081 base.light.as_ref().unwrap().defaults.accent_color,
1082 Some(Rgba::rgb(0, 120, 215))
1083 );
1084 }
1085
1086 #[test]
1087 fn native_theme_merge_both_light_variants() {
1088 let mut base = Theme {
1089 name: "Theme".into(),
1090 ..Theme::default()
1091 };
1092 let mut base_light = ThemeMode::default();
1093 base_light.defaults.background_color = Some(Rgba::rgb(255, 255, 255));
1094 base.light = Some(base_light);
1095
1096 let mut overlay = Theme {
1097 name: "Overlay".into(),
1098 ..Theme::default()
1099 };
1100 let mut overlay_light = ThemeMode::default();
1101 overlay_light.defaults.accent_color = Some(Rgba::rgb(0, 120, 215));
1102 overlay.light = Some(overlay_light);
1103
1104 base.merge(&overlay);
1105
1106 let light = base.light.as_ref().unwrap();
1107 assert_eq!(
1109 light.defaults.background_color,
1110 Some(Rgba::rgb(255, 255, 255))
1111 );
1112 assert_eq!(light.defaults.accent_color, Some(Rgba::rgb(0, 120, 215)));
1114 }
1115
1116 #[test]
1117 fn native_theme_merge_base_light_only_preserved() {
1118 let mut base = Theme {
1119 name: "Theme".into(),
1120 ..Theme::default()
1121 };
1122 let mut base_light = ThemeMode::default();
1123 base_light.defaults.font.family = Some("Inter".into());
1124 base.light = Some(base_light);
1125
1126 let overlay = Theme {
1127 name: "Overlay".into(),
1128 ..Theme::default()
1129 }; base.merge(&overlay);
1132
1133 assert!(base.light.is_some());
1134 assert_eq!(
1135 base.light.as_ref().unwrap().defaults.font.family.as_deref(),
1136 Some("Inter")
1137 );
1138 }
1139
1140 #[test]
1141 fn native_theme_merge_dark_variant() {
1142 let mut base = Theme {
1143 name: "Theme".into(),
1144 ..Theme::default()
1145 };
1146
1147 let mut overlay = Theme {
1148 name: "Overlay".into(),
1149 ..Theme::default()
1150 };
1151 let mut dark = ThemeMode::default();
1152 dark.defaults.background_color = Some(Rgba::rgb(30, 30, 30));
1153 overlay.dark = Some(dark);
1154
1155 base.merge(&overlay);
1156
1157 assert!(base.dark.is_some());
1158 assert_eq!(
1159 base.dark.as_ref().unwrap().defaults.background_color,
1160 Some(Rgba::rgb(30, 30, 30))
1161 );
1162 }
1163
1164 #[test]
1165 fn native_theme_not_empty_with_light() {
1166 let mut theme = Theme {
1167 name: "Theme".into(),
1168 ..Theme::default()
1169 };
1170 theme.light = Some(ThemeMode::default());
1171 assert!(!theme.is_empty());
1172 }
1173
1174 #[test]
1177 fn pick_variant_dark_with_both_variants_returns_dark() {
1178 let mut theme = Theme {
1179 name: "Test".into(),
1180 ..Theme::default()
1181 };
1182 let mut light = ThemeMode::default();
1183 light.defaults.background_color = Some(Rgba::rgb(255, 255, 255));
1184 theme.light = Some(light);
1185 let mut dark = ThemeMode::default();
1186 dark.defaults.background_color = Some(Rgba::rgb(30, 30, 30));
1187 theme.dark = Some(dark);
1188
1189 let picked = theme.pick_variant(ColorMode::Dark).unwrap();
1190 assert_eq!(
1191 picked.defaults.background_color,
1192 Some(Rgba::rgb(30, 30, 30))
1193 );
1194 }
1195
1196 #[test]
1197 fn pick_variant_light_with_both_variants_returns_light() {
1198 let mut theme = Theme {
1199 name: "Test".into(),
1200 ..Theme::default()
1201 };
1202 let mut light = ThemeMode::default();
1203 light.defaults.background_color = Some(Rgba::rgb(255, 255, 255));
1204 theme.light = Some(light);
1205 let mut dark = ThemeMode::default();
1206 dark.defaults.background_color = Some(Rgba::rgb(30, 30, 30));
1207 theme.dark = Some(dark);
1208
1209 let picked = theme.pick_variant(ColorMode::Light).unwrap();
1210 assert_eq!(
1211 picked.defaults.background_color,
1212 Some(Rgba::rgb(255, 255, 255))
1213 );
1214 }
1215
1216 #[test]
1217 fn pick_variant_dark_with_only_light_falls_back() {
1218 let mut theme = Theme {
1219 name: "Test".into(),
1220 ..Theme::default()
1221 };
1222 let mut light = ThemeMode::default();
1223 light.defaults.background_color = Some(Rgba::rgb(255, 255, 255));
1224 theme.light = Some(light);
1225
1226 let picked = theme.pick_variant(ColorMode::Dark).unwrap();
1227 assert_eq!(
1228 picked.defaults.background_color,
1229 Some(Rgba::rgb(255, 255, 255))
1230 );
1231 }
1232
1233 #[test]
1234 fn pick_variant_light_with_only_dark_falls_back() {
1235 let mut theme = Theme {
1236 name: "Test".into(),
1237 ..Theme::default()
1238 };
1239 let mut dark = ThemeMode::default();
1240 dark.defaults.background_color = Some(Rgba::rgb(30, 30, 30));
1241 theme.dark = Some(dark);
1242
1243 let picked = theme.pick_variant(ColorMode::Light).unwrap();
1244 assert_eq!(
1245 picked.defaults.background_color,
1246 Some(Rgba::rgb(30, 30, 30))
1247 );
1248 }
1249
1250 #[test]
1251 fn pick_variant_with_no_variants_returns_err() {
1252 let theme = Theme {
1253 name: "Empty".into(),
1254 ..Theme::default()
1255 };
1256 assert!(theme.pick_variant(ColorMode::Dark).is_err());
1257 assert!(theme.pick_variant(ColorMode::Light).is_err());
1258 }
1259
1260 #[test]
1263 fn icon_set_default_is_none() {
1264 assert!(Theme::default().icon_set.is_none());
1265 }
1266
1267 #[test]
1268 fn icon_set_merge_overlay() {
1269 let mut base = Theme {
1270 name: "Base".into(),
1271 ..Theme::default()
1272 };
1273 let mut overlay = Theme {
1274 name: "Overlay".into(),
1275 ..Theme::default()
1276 };
1277 overlay.icon_set = Some(IconSet::Material);
1278 base.merge(&overlay);
1279 assert_eq!(base.icon_set, Some(IconSet::Material));
1280 }
1281
1282 #[test]
1283 fn icon_set_merge_none_preserves() {
1284 let mut base = Theme {
1285 name: "Base".into(),
1286 ..Theme::default()
1287 };
1288 base.icon_set = Some(IconSet::SfSymbols);
1289 let overlay = Theme {
1290 name: "Overlay".into(),
1291 ..Theme::default()
1292 };
1293 base.merge(&overlay);
1294 assert_eq!(base.icon_set, Some(IconSet::SfSymbols));
1295 }
1296
1297 #[test]
1298 fn icon_set_is_empty_when_set() {
1299 assert!(Theme::default().is_empty());
1300 let mut t = Theme {
1301 name: "Test".into(),
1302 ..Theme::default()
1303 };
1304 t.icon_set = Some(IconSet::Material);
1305 assert!(!t.is_empty());
1306 }
1307
1308 #[test]
1309 fn icon_set_toml_round_trip() {
1310 let mut theme = Theme {
1311 name: "Test".into(),
1312 ..Theme::default()
1313 };
1314 theme.icon_set = Some(IconSet::Material);
1315 let mut light = ThemeMode::default();
1316 light.defaults.icon_theme = Some("material".into());
1317 theme.light = Some(light);
1318 let toml_str = theme.to_toml().unwrap();
1319 assert!(toml_str.contains("icon_set"));
1320 let deserialized = Theme::from_toml(&toml_str).unwrap();
1321 assert_eq!(deserialized.icon_set, Some(IconSet::Material));
1322 assert_eq!(
1323 deserialized
1324 .light
1325 .as_ref()
1326 .unwrap()
1327 .defaults
1328 .icon_theme
1329 .as_deref(),
1330 Some("material")
1331 );
1332 }
1333
1334 #[test]
1335 fn icon_set_toml_absent_deserializes_to_none() {
1336 let toml_str = r##"
1337name = "Bare"
1338[light.defaults]
1339accent_color = "#ff0000"
1340"##;
1341 let theme = Theme::from_toml(toml_str).unwrap();
1342 assert!(theme.icon_set.is_none());
1343 assert!(theme.light.as_ref().unwrap().defaults.icon_theme.is_none());
1344 }
1345
1346 #[test]
1347 fn native_theme_serde_toml_round_trip() {
1348 let theme = Theme::preset("material").expect("material preset should load");
1350 let toml_str = theme.to_toml().expect("should serialize");
1351 let theme2 = Theme::from_toml(&toml_str).expect("should deserialize");
1352 assert_eq!(theme, theme2, "round-trip should preserve Theme");
1353 }
1354
1355 #[test]
1358 fn resolve_material_uses_theme_level_icon_theme() {
1359 let theme = Theme::preset("material").unwrap();
1362 for mode in [ColorMode::Light, ColorMode::Dark] {
1363 let r = theme.resolve(mode).unwrap();
1364 assert_eq!(
1365 r.icon_theme.as_deref(),
1366 Some("material"),
1367 "material (tier 2) for {mode:?}"
1368 );
1369 assert!(
1370 r.icon_theme_explicit,
1371 "explicit flag must be true for {mode:?}"
1372 );
1373 assert_eq!(r.icon_set, IconSet::Material);
1374 }
1375 }
1376
1377 #[test]
1378 fn resolve_kde_breeze_uses_per_variant_icon_theme() {
1379 let theme = Theme::preset("kde-breeze").unwrap();
1382
1383 let light = theme.resolve(ColorMode::Light).unwrap();
1384 assert_eq!(light.icon_theme.as_deref(), Some("breeze"));
1385 assert!(light.icon_theme_explicit);
1386
1387 let dark = theme.resolve(ColorMode::Dark).unwrap();
1388 assert_eq!(dark.icon_theme.as_deref(), Some("breeze-dark"));
1389 assert!(dark.icon_theme_explicit);
1390
1391 assert_eq!(light.icon_set, IconSet::Freedesktop);
1392 assert_eq!(dark.icon_set, IconSet::Freedesktop);
1393 }
1394
1395 fn adwaita_without_icon_theme() -> Theme {
1397 let mut theme = Theme::preset("adwaita").unwrap();
1398 theme.icon_theme = None;
1399 if let Some(v) = theme.light.as_mut() {
1400 v.defaults.icon_theme = None;
1401 }
1402 if let Some(v) = theme.dark.as_mut() {
1403 v.defaults.icon_theme = None;
1404 }
1405 theme
1406 }
1407
1408 #[test]
1409 fn resolve_minimal_theme_takes_the_detected_icon_theme() {
1410 let ctx = crate::resolve::ResolutionContext::with_detected_icon_theme(Ok(
1412 "detected-theme".to_string()
1413 ));
1414 let r = adwaita_without_icon_theme()
1415 .resolve_in(ColorMode::Light, &ctx)
1416 .unwrap();
1417 assert_eq!(r.icon_theme.as_deref(), Some("detected-theme"));
1418 assert!(
1419 !r.icon_theme_explicit,
1420 "tier 3 fallback must report explicit = false"
1421 );
1422 }
1423
1424 #[test]
1425 fn resolve_minimal_theme_without_detection_has_no_icon_theme() {
1426 let ctx = crate::resolve::ResolutionContext::with_detected_icon_theme(Err(
1429 crate::Error::PlatformUnsupported {
1430 platform: "the test's failing detection",
1431 },
1432 ));
1433 let r = adwaita_without_icon_theme()
1434 .resolve_in(ColorMode::Light, &ctx)
1435 .expect("a failed icon-theme detection must not fail resolution");
1436 assert_eq!(r.icon_theme, None);
1437 assert!(!r.icon_theme_explicit);
1438 }
1439
1440 #[test]
1441 fn resolve_minimal_theme_resolves_whatever_detection_gives() {
1442 let r = adwaita_without_icon_theme()
1445 .resolve(ColorMode::Light)
1446 .unwrap();
1447 assert!(!r.icon_theme_explicit);
1448 assert_eq!(
1449 r.icon_theme.as_deref(),
1450 crate::model::icons::system_icon_theme().ok().as_deref()
1451 );
1452 }
1453
1454 #[test]
1455 fn resolve_tier1_wins_over_tier2() {
1456 let mut theme = Theme::preset("adwaita").unwrap();
1459 theme.icon_theme = Some(Cow::Borrowed("theme-level"));
1460 theme.light.as_mut().unwrap().defaults.icon_theme = Some(Cow::Borrowed("per-variant"));
1461 let r = theme.resolve(ColorMode::Light).unwrap();
1462 assert_eq!(r.icon_theme.as_deref(), Some("per-variant"));
1463 assert!(r.icon_theme_explicit);
1464 }
1465
1466 #[test]
1467 fn resolve_cross_mode_fallback_picks_available_variant() {
1468 let mut theme = Theme::preset("adwaita").unwrap();
1471 theme.dark = None;
1472 theme.icon_theme = None;
1473 theme.light.as_mut().unwrap().defaults.icon_theme = Some(Cow::Borrowed("only-one"));
1474 let r = theme.resolve(ColorMode::Dark).unwrap();
1475 assert_eq!(r.icon_theme.as_deref(), Some("only-one"));
1476 assert!(r.icon_theme_explicit);
1477 }
1478
1479 #[test]
1480 fn resolve_empty_theme_returns_no_variant_error() {
1481 let theme = Theme {
1482 name: "Empty".into(),
1483 ..Theme::default()
1484 };
1485 assert!(matches!(
1486 theme.resolve(ColorMode::Light),
1487 Err(crate::Error::NoVariant {
1488 mode: ColorMode::Light
1489 })
1490 ));
1491 }
1492
1493 #[test]
1494 fn resolve_all_presets_succeed() {
1495 for info in Theme::list_presets() {
1497 let theme = Theme::preset(info.key).unwrap();
1498 for mode in [ColorMode::Light, ColorMode::Dark] {
1499 theme.resolve(mode).unwrap_or_else(|e| {
1500 panic!("preset '{}' failed to resolve({mode:?}): {e}", info.key)
1501 });
1502 }
1503 }
1504 }
1505
1506 #[test]
1509 fn lint_toml_valid_returns_empty() {
1510 let toml = r##"
1511name = "Valid Theme"
1512[light.defaults]
1513accent_color = "#ff0000"
1514background_color = "#ffffff"
1515[light.defaults.font]
1516family = "Inter"
1517size_px = 14.0
1518[light.button]
1519min_height_px = 32.0
1520"##;
1521 let warnings = Theme::lint_toml(toml).unwrap();
1522 assert!(
1523 warnings.is_empty(),
1524 "Expected no warnings, got: {warnings:?}"
1525 );
1526 }
1527
1528 #[test]
1529 fn lint_toml_detects_unknown_top_level() {
1530 let toml = r##"
1531name = "Test"
1532theme_version = 2
1533"##;
1534 let warnings = Theme::lint_toml(toml).unwrap();
1535 assert_eq!(warnings.len(), 1);
1536 assert!(warnings[0].contains("theme_version"));
1537 }
1538
1539 #[test]
1540 fn lint_toml_detects_misspelled_defaults_field() {
1541 let toml = r##"
1542name = "Test"
1543[light.defaults]
1544backround = "#ffffff"
1545"##;
1546 let warnings = Theme::lint_toml(toml).unwrap();
1547 assert_eq!(warnings.len(), 1);
1548 assert!(warnings[0].contains("backround"));
1549 assert!(warnings[0].contains("light.defaults.backround"));
1550 }
1551
1552 #[test]
1553 fn lint_toml_detects_unknown_widget_field() {
1554 let toml = r##"
1555name = "Test"
1556[dark.button]
1557primary_bg = "#0078d7"
1558"##;
1559 let warnings = Theme::lint_toml(toml).unwrap();
1560 assert_eq!(warnings.len(), 1);
1561 assert!(warnings[0].contains("primary_bg"));
1562 }
1563
1564 #[test]
1565 fn lint_toml_detects_unknown_variant_section() {
1566 let toml = r##"
1567name = "Test"
1568[light.badges]
1569color = "#ff0000"
1570"##;
1571 let warnings = Theme::lint_toml(toml).unwrap();
1572 assert_eq!(warnings.len(), 1);
1573 assert!(warnings[0].contains("badges"));
1574 }
1575
1576 #[test]
1577 fn lint_toml_detects_unknown_font_subfield() {
1578 let toml = r##"
1579name = "Test"
1580[light.defaults.font]
1581famly = "Inter"
1582"##;
1583 let warnings = Theme::lint_toml(toml).unwrap();
1584 assert_eq!(warnings.len(), 1);
1585 assert!(warnings[0].contains("famly"));
1586 }
1587
1588 #[test]
1589 fn lint_toml_detects_unknown_border_subfield() {
1590 let toml = r##"
1591name = "Test"
1592[light.defaults.border]
1593radiusss = 4.0
1594"##;
1595 let warnings = Theme::lint_toml(toml).unwrap();
1596 assert_eq!(warnings.len(), 1);
1597 assert!(warnings[0].contains("radiusss"));
1598 }
1599
1600 #[test]
1601 fn lint_toml_detects_unknown_text_scale_entry() {
1602 let toml = r##"
1603name = "Test"
1604[light.text_scale.headline]
1605size = 24.0
1606"##;
1607 let warnings = Theme::lint_toml(toml).unwrap();
1608 assert_eq!(warnings.len(), 1);
1609 assert!(warnings[0].contains("headline"));
1610 }
1611
1612 #[test]
1613 fn lint_toml_detects_unknown_text_scale_entry_field() {
1614 let toml = r##"
1615name = "Test"
1616[light.text_scale.caption]
1617font_size = 12.0
1618"##;
1619 let warnings = Theme::lint_toml(toml).unwrap();
1620 assert_eq!(warnings.len(), 1);
1621 assert!(warnings[0].contains("font_size"));
1622 }
1623
1624 #[test]
1625 fn lint_toml_multiple_errors() {
1626 let toml = r##"
1627name = "Test"
1628author = "Me"
1629[light.defaults]
1630backround = "#ffffff"
1631[light.button]
1632primay_bg = "#0078d7"
1633"##;
1634 let warnings = Theme::lint_toml(toml).unwrap();
1635 assert_eq!(warnings.len(), 3);
1636 }
1637
1638 #[test]
1639 fn lint_toml_invalid_toml_returns_error() {
1640 let result = Theme::lint_toml("{{{{invalid");
1641 assert!(result.is_err());
1642 }
1643
1644 #[test]
1645 fn lint_toml_preset_has_no_warnings() {
1646 let theme = Theme::preset("material").expect("material preset should load");
1648 let toml_str = theme.to_toml().expect("should serialize");
1649 let warnings = Theme::lint_toml(&toml_str).expect("should parse");
1650 assert!(
1651 warnings.is_empty(),
1652 "material preset should have no lint warnings, got: {warnings:?}"
1653 );
1654 }
1655
1656 #[test]
1657 fn lint_toml_all_presets_clean() {
1658 for info in Theme::list_presets() {
1659 let name = info.key;
1660 let theme = Theme::preset(name).unwrap_or_else(|e| {
1663 panic!("preset {name} should load: {e}");
1664 });
1665 let toml_str = theme.to_toml().unwrap_or_else(|e| {
1666 panic!("preset {name} should serialize: {e}");
1667 });
1668 let warnings = Theme::lint_toml(&toml_str).unwrap_or_else(|e| {
1669 panic!("preset {name} should lint: {e}");
1670 });
1671 assert!(
1672 warnings.is_empty(),
1673 "preset {name} should have no lint warnings, got: {warnings:?}"
1674 );
1675 }
1676 }
1677
1678 #[test]
1679 fn lint_toml_accepts_padding_sides_and_shorthand() {
1680 let toml = r##"
1681name = "Test"
1682[light.button.border]
1683padding_top_px = 1.0
1684padding_right_px = 2.0
1685padding_bottom_px = 3.0
1686padding_left_px = 4.0
1687[light.input.border]
1688padding_horizontal_px = 6.0
1689padding_vertical_px = 0.0
1690"##;
1691 let warnings = Theme::lint_toml(toml).unwrap();
1692 assert!(warnings.is_empty(), "got: {warnings:?}");
1693 let warnings =
1694 Theme::lint_toml("name = \"T\"\n[light.input.border]\npadding_sideways_px = 1.0\n")
1695 .unwrap();
1696 assert_eq!(warnings.len(), 1, "got: {warnings:?}");
1697 }
1698
1699 #[test]
1700 fn lint_toml_rejects_unknown_field_on_registered_widget() {
1701 let toml = r##"
1705name = "Test"
1706[light.button]
1707nonexistent_field = "#ff0000"
1708"##;
1709 let warnings = Theme::lint_toml(toml).unwrap();
1710 assert_eq!(warnings.len(), 1);
1711 assert!(warnings[0].contains("nonexistent_field"));
1712 assert!(warnings[0].contains("light.button"));
1713 }
1714
1715 #[test]
1716 fn lint_toml_recognizes_all_registered_widgets() {
1717 for entry in inventory::iter::<crate::resolve::WidgetFieldInfo> {
1720 let toml_str = format!("name = \"Test\"\n[light.{}]\n", entry.widget_name,);
1721 let warnings = Theme::lint_toml(&toml_str).unwrap();
1722 assert!(
1723 warnings.is_empty(),
1724 "widget '{}' should be recognized, got: {:?}",
1725 entry.widget_name,
1726 warnings,
1727 );
1728 }
1729 }
1730
1731 #[test]
1734 fn theme_spec_layout_merge() {
1735 let mut base = Theme {
1736 name: "Base".into(),
1737 ..Theme::default()
1738 };
1739 base.layout.widget_gap = Some(6.0);
1740
1741 let mut overlay = Theme {
1742 name: "Overlay".into(),
1743 ..Theme::default()
1744 };
1745 overlay.layout.container_margin = Some(8.0);
1746
1747 base.merge(&overlay);
1748 assert_eq!(base.layout.widget_gap, Some(6.0));
1749 assert_eq!(base.layout.container_margin, Some(8.0));
1750 }
1751
1752 #[test]
1753 fn theme_spec_layout_toml_round_trip() {
1754 let mut theme = Theme {
1755 name: "Layout Test".into(),
1756 ..Theme::default()
1757 };
1758 theme.layout.widget_gap = Some(8.0);
1759 theme.layout.container_margin = Some(12.0);
1760 theme.layout.window_margin = Some(16.0);
1761 theme.layout.section_gap = Some(24.0);
1762
1763 let toml_str = theme.to_toml().unwrap();
1764 let theme2 = Theme::from_toml(&toml_str).unwrap();
1765 assert_eq!(theme.layout, theme2.layout);
1766 }
1767
1768 #[test]
1769 fn theme_spec_is_empty_with_layout() {
1770 let mut theme = Theme {
1771 name: "Layout Only".into(),
1772 ..Theme::default()
1773 };
1774 assert!(theme.is_empty()); theme.layout.widget_gap = Some(8.0);
1776 assert!(!theme.is_empty());
1777 }
1778
1779 #[test]
1780 fn theme_spec_layout_top_level_toml() {
1781 let mut theme = Theme {
1782 name: "Top Level".into(),
1783 ..Theme::default()
1784 };
1785 theme.layout.widget_gap = Some(8.0);
1786
1787 let toml_str = theme.to_toml().unwrap();
1788 assert!(
1790 toml_str.contains("[layout]"),
1791 "TOML should have [layout] section"
1792 );
1793 assert!(!toml_str.contains("[light.layout]"));
1794 assert!(!toml_str.contains("[dark.layout]"));
1795 }
1796
1797 fn field_info_entry(name: &'static str) -> Option<&'static [&'static str]> {
1806 inventory::iter::<crate::resolve::FieldInfo>()
1807 .find(|info| info.struct_name == name)
1808 .map(|info| info.field_names)
1809 }
1810
1811 #[test]
1812 fn font_spec_field_info_matches_baseline() {
1813 let baseline: &[&str] = &["family", "size_pt", "size_px", "weight", "style", "color"];
1814 assert_eq!(field_info_entry("FontSpec"), Some(baseline));
1815 }
1816
1817 #[test]
1818 fn text_scale_entry_field_info_matches_baseline() {
1819 let baseline: &[&str] = &[
1820 "size_pt",
1821 "size_px",
1822 "weight",
1823 "line_height_pt",
1824 "line_height_px",
1825 ];
1826 assert_eq!(field_info_entry("TextScaleEntry"), Some(baseline));
1827 }
1828
1829 #[test]
1830 fn text_scale_field_info_matches_baseline() {
1831 let baseline: &[&str] = &["caption", "section_heading", "dialog_title", "display"];
1832 assert_eq!(field_info_entry("TextScale"), Some(baseline));
1833 }
1834
1835 #[test]
1836 fn defaults_border_spec_field_info_matches_baseline() {
1837 let baseline: &[&str] = &[
1838 "color",
1839 "corner_radius_px",
1840 "corner_radius_lg_px",
1841 "line_width_px",
1842 "opacity",
1843 "shadow_enabled",
1844 ];
1845 assert_eq!(field_info_entry("DefaultsBorderSpec"), Some(baseline));
1846 }
1847
1848 #[test]
1849 fn widget_border_spec_field_info_matches_baseline() {
1850 let baseline: &[&str] = &[
1851 "color",
1852 "corner_radius_px",
1853 "line_width_px",
1854 "shadow_enabled",
1855 "padding_top_px",
1856 "padding_right_px",
1857 "padding_bottom_px",
1858 "padding_left_px",
1859 "padding_horizontal_px",
1860 "padding_vertical_px",
1861 ];
1862 assert_eq!(field_info_entry("WidgetBorderSpec"), Some(baseline));
1863 }
1864
1865 #[test]
1866 fn theme_defaults_field_info_matches_baseline() {
1867 let baseline: &[&str] = &[
1868 "font",
1869 "line_height",
1870 "mono_font",
1871 "background_color",
1872 "text_color",
1873 "accent_color",
1874 "accent_text_color",
1875 "surface_color",
1876 "muted_color",
1877 "shadow_color",
1878 "link_color",
1879 "selection_background",
1880 "selection_text_color",
1881 "selection_inactive_background",
1882 "text_selection_background",
1883 "text_selection_color",
1884 "disabled_text_color",
1885 "danger_color",
1886 "danger_text_color",
1887 "warning_color",
1888 "warning_text_color",
1889 "success_color",
1890 "success_text_color",
1891 "info_color",
1892 "info_text_color",
1893 "border",
1894 "disabled_opacity",
1895 "focus_ring_color",
1896 "focus_ring_width_px",
1897 "focus_ring_offset_px",
1898 "icon_sizes",
1899 "icon_theme",
1900 ];
1901 assert_eq!(field_info_entry("ThemeDefaults"), Some(baseline));
1902 }
1903
1904 #[test]
1905 fn icon_sizes_field_info_matches_baseline() {
1906 let baseline: &[&str] = &[
1907 "toolbar_px",
1908 "small_px",
1909 "large_px",
1910 "dialog_px",
1911 "panel_px",
1912 ];
1913 assert_eq!(field_info_entry("IconSizes"), Some(baseline));
1914 }
1915
1916 #[test]
1917 fn layout_theme_field_info_matches_baseline() {
1918 let baseline: &[&str] = &[
1919 "widget_gap_px",
1920 "container_margin_px",
1921 "window_margin_px",
1922 "section_gap_px",
1923 ];
1924 assert_eq!(field_info_entry("LayoutTheme"), Some(baseline));
1925 }
1926
1927 #[test]
1928 fn widget_registry_still_has_all_widgets() {
1929 let widget_count = inventory::iter::<crate::resolve::WidgetFieldInfo>().count();
1934 assert!(
1935 widget_count >= 25,
1936 "expected >=25 widgets in WidgetFieldInfo, got {widget_count}"
1937 );
1938 }
1939}