1use crate::Rgba;
4use crate::model::border::{BorderSpec, ResolvedBorderSpec};
5use crate::model::{DialogButtonOrder, FontSpec, ResolvedFontSpec};
6
7#[doc(hidden)]
10macro_rules! __field_name {
11 ($field:ident) => {
12 stringify!($field)
13 };
14 ($field:ident, $rename:literal) => {
15 $rename
16 };
17}
18pub(crate) use __field_name;
19
20macro_rules! define_widget_pair {
49 (
50 $(#[$attr:meta])*
51 $opt_name:ident / $resolved_name:ident {
52 $(option {
53 $($(#[doc = $opt_doc:expr])* $opt_field:ident $(as $opt_rename:literal)? : $opt_type:ty),* $(,)?
54 })?
55 $(soft_option {
56 $($(#[doc = $so_doc:expr])* $so_field:ident : $so_type:ty),* $(,)?
57 })?
58 $(optional_nested {
59 $($(#[doc = $on_doc:expr])* $on_field:ident : [$on_opt_type:ty, $on_res_type:ty]),* $(,)?
60 })?
61 }
62 ) => {
63 $(#[$attr])*
64 #[serde_with::skip_serializing_none]
65 #[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
66 #[serde(default)]
67 pub struct $opt_name {
68 $($($(#[doc = $opt_doc])* $(#[serde(rename = $opt_rename)])? pub $opt_field: Option<$opt_type>,)*)?
69 $($($(#[doc = $so_doc])* pub $so_field: Option<$so_type>,)*)?
70 $($($(#[doc = $on_doc])* pub $on_field: Option<$on_opt_type>,)*)?
71 }
72
73 $(#[$attr])*
74 #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
75 #[non_exhaustive]
76 pub struct $resolved_name {
77 $($($(#[doc = $opt_doc])* pub $opt_field: $opt_type,)*)?
78 $($($(#[doc = $so_doc])* pub $so_field: Option<$so_type>,)*)?
79 $($($(#[doc = $on_doc])* pub $on_field: $on_res_type,)*)?
80 }
81
82 impl $opt_name {
83 pub const FIELD_NAMES: &[&str] = &[
85 $($($crate::model::widgets::__field_name!($opt_field $(, $opt_rename)?),)*)?
86 $($(stringify!($so_field),)*)?
87 $($(stringify!($on_field),)*)?
88 ];
89 }
90
91 impl_merge!($opt_name {
92 $(option { $($opt_field),* })?
93 $(soft_option { $($so_field),* })?
94 $(optional_nested { $($on_field),* })?
95 });
96 };
97}
98
99define_widget_pair! {
102 WindowTheme / ResolvedWindowTheme {
104 option {
105 background_color: Rgba,
107 title_bar_background: Rgba,
109 inactive_title_bar_background: Rgba,
111 inactive_title_bar_text_color: Rgba,
113 }
114 optional_nested {
115 title_bar_font: [FontSpec, ResolvedFontSpec],
117 border: [BorderSpec, ResolvedBorderSpec],
119 }
120 }
121}
122
123define_widget_pair! {
126 ButtonTheme / ResolvedButtonTheme {
128 option {
129 background_color: Rgba,
131 primary_background: Rgba,
133 primary_text_color: Rgba,
135 min_width as "min_width_px": f32,
137 min_height as "min_height_px": f32,
139 icon_text_gap as "icon_text_gap_px": f32,
141 disabled_opacity: f32,
143 hover_background: Rgba,
145 hover_text_color: Rgba,
147 active_text_color: Rgba,
149 disabled_text_color: Rgba,
151 }
152 soft_option {
153 active_background: Rgba,
155 disabled_background: Rgba,
157 }
158 optional_nested {
159 font: [FontSpec, ResolvedFontSpec],
161 border: [BorderSpec, ResolvedBorderSpec],
163 }
164 }
165}
166
167define_widget_pair! {
170 InputTheme / ResolvedInputTheme {
172 option {
173 background_color: Rgba,
175 placeholder_color: Rgba,
177 caret_color: Rgba,
179 selection_background: Rgba,
181 selection_text_color: Rgba,
183 min_height as "min_height_px": f32,
185 disabled_opacity: f32,
187 disabled_text_color: Rgba,
189 }
190 soft_option {
191 hover_border_color: Rgba,
193 focus_border_color: Rgba,
195 disabled_background: Rgba,
197 }
198 optional_nested {
199 font: [FontSpec, ResolvedFontSpec],
201 border: [BorderSpec, ResolvedBorderSpec],
203 }
204 }
205}
206
207define_widget_pair! {
210 CheckboxTheme / ResolvedCheckboxTheme {
212 option {
213 background_color: Rgba,
215 checked_background: Rgba,
217 indicator_color: Rgba,
219 indicator_width as "indicator_width_px": f32,
221 label_gap as "label_gap_px": f32,
223 disabled_opacity: f32,
225 disabled_text_color: Rgba,
227 }
228 soft_option {
229 hover_background: Rgba,
231 disabled_background: Rgba,
233 unchecked_background: Rgba,
235 unchecked_border_color: Rgba,
237 }
238 optional_nested {
239 font: [FontSpec, ResolvedFontSpec],
241 border: [BorderSpec, ResolvedBorderSpec],
243 }
244 }
245}
246
247define_widget_pair! {
250 MenuTheme / ResolvedMenuTheme {
252 option {
253 background_color: Rgba,
255 separator_color: Rgba,
257 row_height as "row_height_px": f32,
259 icon_text_gap as "icon_text_gap_px": f32,
261 icon_size as "icon_size_px": f32,
263 hover_background: Rgba,
265 hover_text_color: Rgba,
267 disabled_text_color: Rgba,
269 }
270 optional_nested {
271 font: [FontSpec, ResolvedFontSpec],
273 border: [BorderSpec, ResolvedBorderSpec],
275 }
276 }
277}
278
279define_widget_pair! {
282 TooltipTheme / ResolvedTooltipTheme {
284 option {
285 background_color: Rgba,
287 max_width as "max_width_px": f32,
289 }
290 optional_nested {
291 font: [FontSpec, ResolvedFontSpec],
293 border: [BorderSpec, ResolvedBorderSpec],
295 }
296 }
297}
298
299define_widget_pair! {
302 ScrollbarTheme / ResolvedScrollbarTheme {
304 option {
305 track_color: Rgba,
307 thumb_color: Rgba,
309 thumb_hover_color: Rgba,
311 groove_width as "groove_width_px": f32,
313 min_thumb_length as "min_thumb_length_px": f32,
315 thumb_width as "thumb_width_px": f32,
317 overlay_mode: bool,
319 }
320 soft_option {
321 thumb_active_color: Rgba,
323 }
324 }
325}
326
327define_widget_pair! {
330 SliderTheme / ResolvedSliderTheme {
332 option {
333 fill_color: Rgba,
335 track_color: Rgba,
337 thumb_color: Rgba,
339 track_height as "track_height_px": f32,
341 thumb_diameter as "thumb_diameter_px": f32,
343 tick_mark_length as "tick_mark_length_px": f32,
345 disabled_opacity: f32,
347 }
348 soft_option {
349 thumb_hover_color: Rgba,
351 disabled_fill_color: Rgba,
353 disabled_track_color: Rgba,
355 disabled_thumb_color: Rgba,
357 }
358 }
359}
360
361define_widget_pair! {
364 ProgressBarTheme / ResolvedProgressBarTheme {
366 option {
367 fill_color: Rgba,
369 track_color: Rgba,
371 track_height as "track_height_px": f32,
373 min_width as "min_width_px": f32,
375 }
376 optional_nested {
377 border: [BorderSpec, ResolvedBorderSpec],
379 }
380 }
381}
382
383define_widget_pair! {
386 TabTheme / ResolvedTabTheme {
388 option {
389 background_color: Rgba,
391 active_background: Rgba,
393 active_text_color: Rgba,
395 bar_background: Rgba,
397 min_width as "min_width_px": f32,
399 min_height as "min_height_px": f32,
401 hover_text_color: Rgba,
403 }
404 soft_option {
405 hover_background: Rgba,
407 }
408 optional_nested {
409 font: [FontSpec, ResolvedFontSpec],
411 border: [BorderSpec, ResolvedBorderSpec],
413 }
414 }
415}
416
417define_widget_pair! {
420 SidebarTheme / ResolvedSidebarTheme {
422 option {
423 background_color: Rgba,
425 selection_background: Rgba,
427 selection_text_color: Rgba,
429 hover_background: Rgba,
431 }
432 optional_nested {
433 font: [FontSpec, ResolvedFontSpec],
435 border: [BorderSpec, ResolvedBorderSpec],
437 }
438 }
439}
440
441define_widget_pair! {
444 ToolbarTheme / ResolvedToolbarTheme {
446 option {
447 background_color: Rgba,
449 bar_height as "bar_height_px": f32,
451 item_gap as "item_gap_px": f32,
453 icon_size as "icon_size_px": f32,
455 }
456 optional_nested {
457 font: [FontSpec, ResolvedFontSpec],
459 border: [BorderSpec, ResolvedBorderSpec],
461 }
462 }
463}
464
465define_widget_pair! {
468 StatusBarTheme / ResolvedStatusBarTheme {
470 option {
471 background_color: Rgba,
473 }
474 optional_nested {
475 font: [FontSpec, ResolvedFontSpec],
477 border: [BorderSpec, ResolvedBorderSpec],
479 }
480 }
481}
482
483define_widget_pair! {
486 ListTheme / ResolvedListTheme {
488 option {
489 background_color: Rgba,
491 alternate_row_background: Rgba,
493 selection_background: Rgba,
495 selection_text_color: Rgba,
497 header_background: Rgba,
499 grid_color: Rgba,
501 row_height as "row_height_px": f32,
503 hover_background: Rgba,
505 hover_text_color: Rgba,
507 disabled_text_color: Rgba,
509 }
510 optional_nested {
511 item_font: [FontSpec, ResolvedFontSpec],
513 header_font: [FontSpec, ResolvedFontSpec],
515 border: [BorderSpec, ResolvedBorderSpec],
517 }
518 }
519}
520
521define_widget_pair! {
524 PopoverTheme / ResolvedPopoverTheme {
526 option {
527 background_color: Rgba,
529 }
530 optional_nested {
531 font: [FontSpec, ResolvedFontSpec],
533 border: [BorderSpec, ResolvedBorderSpec],
535 }
536 }
537}
538
539define_widget_pair! {
542 SplitterTheme / ResolvedSplitterTheme {
544 option {
545 divider_width as "divider_width_px": f32,
547 divider_color: Rgba,
549 hover_color: Rgba,
551 }
552 }
553}
554
555define_widget_pair! {
558 SeparatorTheme / ResolvedSeparatorTheme {
560 option {
561 line_color: Rgba,
563 line_width as "line_width_px": f32,
565 }
566 }
567}
568
569define_widget_pair! {
572 SwitchTheme / ResolvedSwitchTheme {
574 option {
575 checked_background: Rgba,
577 unchecked_background: Rgba,
579 thumb_background: Rgba,
581 track_width as "track_width_px": f32,
583 track_height as "track_height_px": f32,
585 thumb_diameter as "thumb_diameter_px": f32,
587 track_radius as "track_radius_px": f32,
589 disabled_opacity: f32,
591 }
592 soft_option {
593 hover_checked_background: Rgba,
595 hover_unchecked_background: Rgba,
597 disabled_checked_background: Rgba,
599 disabled_unchecked_background: Rgba,
601 disabled_thumb_color: Rgba,
603 }
604 }
605}
606
607define_widget_pair! {
610 DialogTheme / ResolvedDialogTheme {
612 option {
613 background_color: Rgba,
615 min_width as "min_width_px": f32,
617 max_width as "max_width_px": f32,
619 min_height as "min_height_px": f32,
621 max_height as "max_height_px": f32,
623 button_gap as "button_gap_px": f32,
625 icon_size as "icon_size_px": f32,
627 button_order: DialogButtonOrder,
629 }
630 optional_nested {
631 title_font: [FontSpec, ResolvedFontSpec],
633 body_font: [FontSpec, ResolvedFontSpec],
635 border: [BorderSpec, ResolvedBorderSpec],
637 }
638 }
639}
640
641define_widget_pair! {
644 SpinnerTheme / ResolvedSpinnerTheme {
646 option {
647 fill_color: Rgba,
649 diameter as "diameter_px": f32,
651 min_diameter as "min_diameter_px": f32,
653 stroke_width as "stroke_width_px": f32,
655 }
656 }
657}
658
659define_widget_pair! {
662 ComboBoxTheme / ResolvedComboBoxTheme {
664 option {
665 background_color: Rgba,
667 min_height as "min_height_px": f32,
669 min_width as "min_width_px": f32,
671 arrow_icon_size as "arrow_icon_size_px": f32,
673 arrow_area_width as "arrow_area_width_px": f32,
675 disabled_opacity: f32,
677 disabled_text_color: Rgba,
679 }
680 soft_option {
681 hover_background: Rgba,
683 disabled_background: Rgba,
685 }
686 optional_nested {
687 font: [FontSpec, ResolvedFontSpec],
689 border: [BorderSpec, ResolvedBorderSpec],
691 }
692 }
693}
694
695define_widget_pair! {
698 SegmentedControlTheme / ResolvedSegmentedControlTheme {
700 option {
701 background_color: Rgba,
703 active_background: Rgba,
705 active_text_color: Rgba,
707 segment_height as "segment_height_px": f32,
709 separator_width as "separator_width_px": f32,
711 disabled_opacity: f32,
713 }
714 soft_option {
715 hover_background: Rgba,
717 }
718 optional_nested {
719 font: [FontSpec, ResolvedFontSpec],
721 border: [BorderSpec, ResolvedBorderSpec],
723 }
724 }
725}
726
727define_widget_pair! {
730 CardTheme / ResolvedCardTheme {
732 option {
733 background_color: Rgba,
735 }
736 optional_nested {
737 border: [BorderSpec, ResolvedBorderSpec],
739 }
740 }
741}
742
743define_widget_pair! {
746 ExpanderTheme / ResolvedExpanderTheme {
748 option {
749 header_height as "header_height_px": f32,
751 arrow_icon_size as "arrow_icon_size_px": f32,
753 }
754 soft_option {
755 hover_background: Rgba,
757 arrow_color: Rgba,
759 }
760 optional_nested {
761 font: [FontSpec, ResolvedFontSpec],
763 border: [BorderSpec, ResolvedBorderSpec],
765 }
766 }
767}
768
769define_widget_pair! {
772 LinkTheme / ResolvedLinkTheme {
774 option {
775 visited_text_color: Rgba,
777 underline_enabled: bool,
779 background_color: Rgba,
781 hover_background: Rgba,
783 hover_text_color: Rgba,
785 active_text_color: Rgba,
787 disabled_text_color: Rgba,
789 }
790 optional_nested {
791 font: [FontSpec, ResolvedFontSpec],
793 }
794 }
795}
796
797define_widget_pair! {
800 LayoutTheme / ResolvedLayoutTheme {
805 option {
806 widget_gap as "widget_gap_px": f32,
808 container_margin as "container_margin_px": f32,
810 window_margin as "window_margin_px": f32,
812 section_gap as "section_gap_px": f32,
814 }
815 }
816}
817
818#[cfg(test)]
819#[allow(clippy::unwrap_used, clippy::expect_used, dead_code)]
820mod tests {
821 use super::*;
822 use crate::Rgba;
823 use crate::model::border::{BorderSpec, ResolvedBorderSpec};
824 use crate::model::font::FontSize;
825 use crate::model::{DialogButtonOrder, FontSpec};
826
827 define_widget_pair! {
829 TestWidget / ResolvedTestWidget {
831 option {
832 size: f32,
833 label: String,
834 }
835 optional_nested {
836 font: [FontSpec, ResolvedFontSpec],
837 }
838 }
839 }
840
841 #[test]
844 fn resolved_font_spec_fields_are_concrete() {
845 let rfs = ResolvedFontSpec {
846 family: "Inter".into(),
847 size: 14.0,
848 weight: 400,
849 style: crate::model::font::FontStyle::Normal,
850 color: crate::Rgba::rgb(0, 0, 0),
851 };
852 assert_eq!(rfs.family, "Inter");
853 assert_eq!(rfs.size, 14.0);
854 assert_eq!(rfs.weight, 400);
855 }
856
857 #[test]
860 fn generated_option_struct_has_option_fields() {
861 let w = TestWidget::default();
862 assert!(w.size.is_none());
863 assert!(w.label.is_none());
864 assert!(w.font.is_none());
865 }
866
867 #[test]
868 fn generated_option_struct_is_empty_by_default() {
869 assert!(TestWidget::default().is_empty());
870 }
871
872 #[test]
873 fn generated_option_struct_not_empty_when_size_set() {
874 let w = TestWidget {
875 size: Some(24.0),
876 ..Default::default()
877 };
878 assert!(!w.is_empty());
879 }
880
881 #[test]
882 fn generated_option_struct_not_empty_when_font_set() {
883 let w = TestWidget {
884 font: Some(FontSpec {
885 size: Some(FontSize::Px(14.0)),
886 ..Default::default()
887 }),
888 ..Default::default()
889 };
890 assert!(!w.is_empty());
891 }
892
893 #[test]
894 fn generated_resolved_struct_has_concrete_fields() {
895 let resolved = ResolvedTestWidget {
896 size: 24.0,
897 label: "Click me".into(),
898 font: ResolvedFontSpec {
899 family: "Inter".into(),
900 size: 14.0,
901 weight: 400,
902 style: crate::model::font::FontStyle::Normal,
903 color: crate::Rgba::rgb(0, 0, 0),
904 },
905 };
906 assert_eq!(resolved.size, 24.0);
907 assert_eq!(resolved.label, "Click me");
908 assert_eq!(resolved.font.family, "Inter");
909 }
910
911 #[test]
914 fn generated_merge_option_field_overlay_wins() {
915 let mut base = TestWidget {
916 size: Some(20.0),
917 ..Default::default()
918 };
919 let overlay = TestWidget {
920 size: Some(24.0),
921 ..Default::default()
922 };
923 base.merge(&overlay);
924 assert_eq!(base.size, Some(24.0));
925 }
926
927 #[test]
928 fn generated_merge_option_field_none_preserves_base() {
929 let mut base = TestWidget {
930 size: Some(20.0),
931 ..Default::default()
932 };
933 let overlay = TestWidget::default();
934 base.merge(&overlay);
935 assert_eq!(base.size, Some(20.0));
936 }
937
938 #[test]
939 fn generated_merge_optional_nested_both_some_merges_inner() {
940 let mut base = TestWidget {
941 font: Some(FontSpec {
942 family: Some("Noto Sans".into()),
943 size: Some(FontSize::Px(12.0)),
944 weight: None,
945 ..Default::default()
946 }),
947 ..Default::default()
948 };
949 let overlay = TestWidget {
950 font: Some(FontSpec {
951 family: None,
952 size: None,
953 weight: Some(700),
954 ..Default::default()
955 }),
956 ..Default::default()
957 };
958 base.merge(&overlay);
959 let font = base.font.as_ref().unwrap();
960 assert_eq!(font.family.as_deref(), Some("Noto Sans")); assert_eq!(font.size, Some(FontSize::Px(12.0))); assert_eq!(font.weight, Some(700)); }
964
965 #[test]
966 fn generated_merge_optional_nested_none_plus_some_clones() {
967 let mut base = TestWidget::default();
968 let overlay = TestWidget {
969 font: Some(FontSpec {
970 family: Some("Inter".into()),
971 size: Some(FontSize::Px(14.0)),
972 weight: Some(400),
973 ..Default::default()
974 }),
975 ..Default::default()
976 };
977 base.merge(&overlay);
978 let font = base.font.as_ref().unwrap();
979 assert_eq!(font.family.as_deref(), Some("Inter"));
980 assert_eq!(font.size, Some(FontSize::Px(14.0)));
981 assert_eq!(font.weight, Some(400));
982 }
983
984 #[test]
985 fn generated_merge_optional_nested_some_plus_none_preserves_base() {
986 let mut base = TestWidget {
987 font: Some(FontSpec {
988 family: Some("Inter".into()),
989 size: Some(FontSize::Px(14.0)),
990 weight: Some(400),
991 ..Default::default()
992 }),
993 ..Default::default()
994 };
995 let overlay = TestWidget::default();
996 base.merge(&overlay);
997 let font = base.font.as_ref().unwrap();
998 assert_eq!(font.family.as_deref(), Some("Inter"));
999 }
1000
1001 #[test]
1002 fn generated_merge_optional_nested_none_plus_none_stays_none() {
1003 let mut base = TestWidget::default();
1004 let overlay = TestWidget::default();
1005 base.merge(&overlay);
1006 assert!(base.font.is_none());
1007 }
1008
1009 #[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1013 struct WithFont {
1014 name: Option<String>,
1015 font: Option<FontSpec>,
1016 }
1017
1018 impl_merge!(WithFont {
1019 option { name }
1020 optional_nested { font }
1021 });
1022
1023 #[test]
1024 fn impl_merge_optional_nested_none_none_stays_none() {
1025 let mut base = WithFont::default();
1026 let overlay = WithFont::default();
1027 base.merge(&overlay);
1028 assert!(base.font.is_none());
1029 }
1030
1031 #[test]
1032 fn impl_merge_optional_nested_some_none_preserves_base() {
1033 let mut base = WithFont {
1034 font: Some(FontSpec {
1035 size: Some(FontSize::Px(12.0)),
1036 ..Default::default()
1037 }),
1038 ..Default::default()
1039 };
1040 let overlay = WithFont::default();
1041 base.merge(&overlay);
1042 assert_eq!(base.font.as_ref().unwrap().size, Some(FontSize::Px(12.0)));
1043 }
1044
1045 #[test]
1046 fn impl_merge_optional_nested_none_some_clones_overlay() {
1047 let mut base = WithFont::default();
1048 let overlay = WithFont {
1049 font: Some(FontSpec {
1050 family: Some("Inter".into()),
1051 ..Default::default()
1052 }),
1053 ..Default::default()
1054 };
1055 base.merge(&overlay);
1056 assert_eq!(base.font.as_ref().unwrap().family.as_deref(), Some("Inter"));
1057 }
1058
1059 #[test]
1060 fn impl_merge_optional_nested_some_some_merges_inner() {
1061 let mut base = WithFont {
1062 font: Some(FontSpec {
1063 family: Some("Noto".into()),
1064 size: Some(FontSize::Px(11.0)),
1065 weight: None,
1066 ..Default::default()
1067 }),
1068 ..Default::default()
1069 };
1070 let overlay = WithFont {
1071 font: Some(FontSpec {
1072 family: None,
1073 size: Some(FontSize::Px(14.0)),
1074 weight: Some(400),
1075 ..Default::default()
1076 }),
1077 ..Default::default()
1078 };
1079 base.merge(&overlay);
1080 let f = base.font.as_ref().unwrap();
1081 assert_eq!(f.family.as_deref(), Some("Noto")); assert_eq!(f.size, Some(FontSize::Px(14.0))); assert_eq!(f.weight, Some(400)); }
1085
1086 #[test]
1087 fn impl_merge_optional_nested_is_empty_none() {
1088 let w = WithFont::default();
1089 assert!(w.is_empty());
1090 }
1091
1092 #[test]
1093 fn impl_merge_optional_nested_is_empty_some_default() {
1094 let w = WithFont {
1096 font: Some(FontSpec::default()),
1097 ..Default::default()
1098 };
1099 assert!(w.is_empty());
1100 }
1101
1102 #[test]
1103 fn impl_merge_optional_nested_is_not_empty_when_populated() {
1104 let w = WithFont {
1105 font: Some(FontSpec {
1106 size: Some(FontSize::Px(14.0)),
1107 ..Default::default()
1108 }),
1109 ..Default::default()
1110 };
1111 assert!(!w.is_empty());
1112 }
1113
1114 #[test]
1117 fn button_theme_default_is_empty() {
1118 assert!(ButtonTheme::default().is_empty());
1119 }
1120
1121 #[test]
1122 fn button_theme_not_empty_when_set() {
1123 let b = ButtonTheme {
1124 background_color: Some(Rgba::rgb(200, 200, 200)),
1125 min_width: Some(64.0),
1126 ..Default::default()
1127 };
1128 assert!(!b.is_empty());
1129 }
1130
1131 #[test]
1132 fn button_theme_merge_font_optional_nested() {
1133 let mut base = ButtonTheme {
1134 font: Some(FontSpec {
1135 family: Some("Noto Sans".into()),
1136 size: Some(FontSize::Px(11.0)),
1137 weight: None,
1138 ..Default::default()
1139 }),
1140 ..Default::default()
1141 };
1142 let overlay = ButtonTheme {
1143 font: Some(FontSpec {
1144 family: None,
1145 weight: Some(700),
1146 ..Default::default()
1147 }),
1148 ..Default::default()
1149 };
1150 base.merge(&overlay);
1151 let f = base.font.as_ref().unwrap();
1152 assert_eq!(f.family.as_deref(), Some("Noto Sans")); assert_eq!(f.weight, Some(700)); }
1155
1156 #[test]
1157 fn button_theme_toml_round_trip_with_font_and_border() {
1158 let b = ButtonTheme {
1159 background_color: Some(Rgba::rgb(200, 200, 200)),
1160 font: Some(FontSpec {
1161 family: Some("Inter".into()),
1162 size: Some(FontSize::Px(14.0)),
1163 weight: Some(400),
1164 ..Default::default()
1165 }),
1166 border: Some(BorderSpec {
1167 corner_radius: Some(4.0),
1168 ..Default::default()
1169 }),
1170 ..Default::default()
1171 };
1172 let toml_str = toml::to_string(&b).unwrap();
1173 let b2: ButtonTheme = toml::from_str(&toml_str).unwrap();
1174 assert_eq!(b, b2);
1175 }
1176
1177 #[test]
1180 fn window_theme_has_new_fields() {
1181 let w = WindowTheme {
1182 inactive_title_bar_background: Some(Rgba::rgb(180, 180, 180)),
1183 inactive_title_bar_text_color: Some(Rgba::rgb(120, 120, 120)),
1184 title_bar_font: Some(FontSpec {
1185 weight: Some(700),
1186 ..Default::default()
1187 }),
1188 border: Some(BorderSpec {
1189 corner_radius: Some(4.0),
1190 shadow_enabled: Some(true),
1191 ..Default::default()
1192 }),
1193 ..Default::default()
1194 };
1195 assert!(!w.is_empty());
1196 assert!(w.inactive_title_bar_background.is_some());
1197 assert!(w.inactive_title_bar_text_color.is_some());
1198 assert!(w.title_bar_font.is_some());
1199 assert!(w.border.is_some());
1200 }
1201
1202 #[test]
1203 fn window_theme_default_is_empty() {
1204 assert!(WindowTheme::default().is_empty());
1205 }
1206
1207 #[test]
1210 fn dialog_theme_button_order_works() {
1211 let d = DialogTheme {
1212 button_order: Some(DialogButtonOrder::PrimaryRight),
1213 min_width: Some(300.0),
1214 ..Default::default()
1215 };
1216 assert_eq!(d.button_order, Some(DialogButtonOrder::PrimaryRight));
1217 assert_eq!(d.min_width, Some(300.0));
1218 assert!(!d.is_empty());
1219 }
1220
1221 #[test]
1222 fn dialog_theme_button_order_toml_round_trip() {
1223 let d = DialogTheme {
1224 button_order: Some(DialogButtonOrder::PrimaryLeft),
1225 ..Default::default()
1226 };
1227 let toml_str = toml::to_string(&d).unwrap();
1228 let d2: DialogTheme = toml::from_str(&toml_str).unwrap();
1229 assert_eq!(d, d2);
1230 }
1231
1232 #[test]
1233 fn dialog_theme_default_is_empty() {
1234 assert!(DialogTheme::default().is_empty());
1235 }
1236
1237 #[test]
1240 fn splitter_theme_single_field_merge() {
1241 let mut base = SplitterTheme {
1242 divider_width: Some(4.0),
1243 ..Default::default()
1244 };
1245 let overlay = SplitterTheme {
1246 divider_width: Some(6.0),
1247 ..Default::default()
1248 };
1249 base.merge(&overlay);
1250 assert_eq!(base.divider_width, Some(6.0));
1251 }
1252
1253 #[test]
1254 fn splitter_theme_merge_none_preserves_base() {
1255 let mut base = SplitterTheme {
1256 divider_width: Some(4.0),
1257 ..Default::default()
1258 };
1259 let overlay = SplitterTheme::default();
1260 base.merge(&overlay);
1261 assert_eq!(base.divider_width, Some(4.0));
1262 }
1263
1264 #[test]
1265 fn splitter_theme_default_is_empty() {
1266 assert!(SplitterTheme::default().is_empty());
1267 }
1268
1269 #[test]
1270 fn splitter_theme_not_empty_when_set() {
1271 assert!(
1272 !SplitterTheme {
1273 divider_width: Some(4.0),
1274 ..Default::default()
1275 }
1276 .is_empty()
1277 );
1278 }
1279
1280 #[test]
1283 fn separator_theme_single_field() {
1284 let s = SeparatorTheme {
1285 line_color: Some(Rgba::rgb(200, 200, 200)),
1286 ..Default::default()
1287 };
1288 assert!(!s.is_empty());
1289 }
1290
1291 #[test]
1294 fn all_widget_theme_defaults_are_empty() {
1295 assert!(WindowTheme::default().is_empty());
1296 assert!(ButtonTheme::default().is_empty());
1297 assert!(InputTheme::default().is_empty());
1298 assert!(CheckboxTheme::default().is_empty());
1299 assert!(MenuTheme::default().is_empty());
1300 assert!(TooltipTheme::default().is_empty());
1301 assert!(ScrollbarTheme::default().is_empty());
1302 assert!(SliderTheme::default().is_empty());
1303 assert!(ProgressBarTheme::default().is_empty());
1304 assert!(TabTheme::default().is_empty());
1305 assert!(SidebarTheme::default().is_empty());
1306 assert!(ToolbarTheme::default().is_empty());
1307 assert!(StatusBarTheme::default().is_empty());
1308 assert!(ListTheme::default().is_empty());
1309 assert!(PopoverTheme::default().is_empty());
1310 assert!(SplitterTheme::default().is_empty());
1311 assert!(SeparatorTheme::default().is_empty());
1312 assert!(SwitchTheme::default().is_empty());
1313 assert!(DialogTheme::default().is_empty());
1314 assert!(SpinnerTheme::default().is_empty());
1315 assert!(ComboBoxTheme::default().is_empty());
1316 assert!(SegmentedControlTheme::default().is_empty());
1317 assert!(CardTheme::default().is_empty());
1318 assert!(ExpanderTheme::default().is_empty());
1319 assert!(LinkTheme::default().is_empty());
1320 }
1321
1322 #[test]
1325 fn input_theme_toml_round_trip() {
1326 let t = InputTheme {
1327 background_color: Some(Rgba::rgb(255, 255, 255)),
1328 font: Some(FontSpec {
1329 family: Some("Noto Sans".into()),
1330 ..Default::default()
1331 }),
1332 border: Some(BorderSpec {
1333 color: Some(Rgba::rgb(180, 180, 180)),
1334 corner_radius: Some(4.0),
1335 ..Default::default()
1336 }),
1337 ..Default::default()
1338 };
1339 let toml_str = toml::to_string(&t).unwrap();
1340 let t2: InputTheme = toml::from_str(&toml_str).unwrap();
1341 assert_eq!(t, t2);
1342 }
1343
1344 #[test]
1345 fn switch_theme_toml_round_trip() {
1346 let s = SwitchTheme {
1347 checked_background: Some(Rgba::rgb(0, 120, 215)),
1348 track_width: Some(40.0),
1349 track_height: Some(20.0),
1350 thumb_diameter: Some(14.0),
1351 track_radius: Some(10.0),
1352 ..Default::default()
1353 };
1354 let toml_str = toml::to_string(&s).unwrap();
1355 let s2: SwitchTheme = toml::from_str(&toml_str).unwrap();
1356 assert_eq!(s, s2);
1357 }
1358
1359 #[test]
1360 fn card_theme_with_border() {
1361 let c = CardTheme {
1362 background_color: Some(Rgba::rgb(255, 255, 255)),
1363 border: Some(BorderSpec {
1364 corner_radius: Some(8.0),
1365 shadow_enabled: Some(true),
1366 ..Default::default()
1367 }),
1368 };
1369 assert!(!c.is_empty());
1370 }
1371
1372 #[test]
1373 fn link_theme_has_underline_enabled_bool_field() {
1374 let l = LinkTheme {
1375 visited_text_color: Some(Rgba::rgb(100, 0, 200)),
1376 underline_enabled: Some(true),
1377 ..Default::default()
1378 };
1379 assert!(!l.is_empty());
1380 assert_eq!(l.underline_enabled, Some(true));
1381 }
1382
1383 #[test]
1384 fn status_bar_theme_has_font_and_background() {
1385 let s = StatusBarTheme {
1386 background_color: Some(Rgba::rgb(240, 240, 240)),
1387 font: Some(FontSpec {
1388 size: Some(FontSize::Px(11.0)),
1389 ..Default::default()
1390 }),
1391 ..Default::default()
1392 };
1393 assert!(!s.is_empty());
1394 }
1395
1396 define_widget_pair! {
1400 DualNestedTestWidget / ResolvedDualNestedTestWidget {
1402 option {
1403 background: Rgba,
1404 min_height: f32,
1405 }
1406 optional_nested {
1407 font: [FontSpec, ResolvedFontSpec],
1408 border: [BorderSpec, ResolvedBorderSpec],
1409 }
1410 }
1411 }
1412
1413 #[test]
1414 fn dual_nested_default_is_empty() {
1415 assert!(DualNestedTestWidget::default().is_empty());
1416 }
1417
1418 #[test]
1419 fn dual_nested_field_names() {
1420 assert_eq!(DualNestedTestWidget::FIELD_NAMES.len(), 4);
1421 assert!(DualNestedTestWidget::FIELD_NAMES.contains(&"background"));
1422 assert!(DualNestedTestWidget::FIELD_NAMES.contains(&"min_height"));
1423 assert!(DualNestedTestWidget::FIELD_NAMES.contains(&"font"));
1424 assert!(DualNestedTestWidget::FIELD_NAMES.contains(&"border"));
1425 }
1426
1427 #[test]
1428 fn dual_nested_not_empty_when_font_set() {
1429 let w = DualNestedTestWidget {
1430 font: Some(FontSpec {
1431 family: Some("Inter".into()),
1432 ..Default::default()
1433 }),
1434 ..Default::default()
1435 };
1436 assert!(!w.is_empty());
1437 }
1438
1439 #[test]
1440 fn dual_nested_not_empty_when_border_set() {
1441 let w = DualNestedTestWidget {
1442 border: Some(BorderSpec {
1443 color: Some(Rgba::rgb(100, 100, 100)),
1444 ..Default::default()
1445 }),
1446 ..Default::default()
1447 };
1448 assert!(!w.is_empty());
1449 }
1450
1451 #[test]
1452 fn dual_nested_merge_both_nested() {
1453 let mut base = DualNestedTestWidget {
1454 font: Some(FontSpec {
1455 family: Some("Noto Sans".into()),
1456 ..Default::default()
1457 }),
1458 ..Default::default()
1459 };
1460 let overlay = DualNestedTestWidget {
1461 border: Some(BorderSpec {
1462 corner_radius: Some(4.0),
1463 ..Default::default()
1464 }),
1465 ..Default::default()
1466 };
1467 base.merge(&overlay);
1468 assert!(base.font.is_some());
1469 assert!(base.border.is_some());
1470 assert_eq!(
1471 base.font.as_ref().and_then(|f| f.family.as_deref()),
1472 Some("Noto Sans")
1473 );
1474 assert_eq!(
1475 base.border.as_ref().and_then(|b| b.corner_radius),
1476 Some(4.0)
1477 );
1478 }
1479
1480 #[test]
1481 fn dual_nested_merge_inner_font_fields() {
1482 let mut base = DualNestedTestWidget {
1483 font: Some(FontSpec {
1484 family: Some("Noto Sans".into()),
1485 ..Default::default()
1486 }),
1487 ..Default::default()
1488 };
1489 let overlay = DualNestedTestWidget {
1490 font: Some(FontSpec {
1491 size: Some(FontSize::Px(14.0)),
1492 ..Default::default()
1493 }),
1494 ..Default::default()
1495 };
1496 base.merge(&overlay);
1497 let font = base.font.as_ref().unwrap();
1498 assert_eq!(font.family.as_deref(), Some("Noto Sans")); assert_eq!(font.size, Some(FontSize::Px(14.0))); }
1501
1502 #[test]
1503 fn dual_nested_toml_round_trip() {
1504 let w = DualNestedTestWidget {
1505 background: Some(Rgba::rgb(240, 240, 240)),
1506 min_height: Some(32.0),
1507 font: Some(FontSpec {
1508 family: Some("Inter".into()),
1509 size: Some(FontSize::Px(14.0)),
1510 weight: Some(400),
1511 ..Default::default()
1512 }),
1513 border: Some(BorderSpec {
1514 color: Some(Rgba::rgb(180, 180, 180)),
1515 corner_radius: Some(4.0),
1516 line_width: Some(1.0),
1517 ..Default::default()
1518 }),
1519 };
1520 let toml_str = toml::to_string(&w).unwrap();
1521 let w2: DualNestedTestWidget = toml::from_str(&toml_str).unwrap();
1522 assert_eq!(w, w2);
1523 }
1524
1525 #[test]
1528 fn layout_theme_default_is_empty() {
1529 assert!(LayoutTheme::default().is_empty());
1530 }
1531
1532 #[test]
1533 fn layout_theme_not_empty_when_widget_gap_set() {
1534 let l = LayoutTheme {
1535 widget_gap: Some(8.0),
1536 ..Default::default()
1537 };
1538 assert!(!l.is_empty());
1539 }
1540
1541 #[test]
1542 fn layout_theme_field_names() {
1543 assert_eq!(LayoutTheme::FIELD_NAMES.len(), 4);
1544 assert!(LayoutTheme::FIELD_NAMES.contains(&"widget_gap_px"));
1545 assert!(LayoutTheme::FIELD_NAMES.contains(&"container_margin_px"));
1546 assert!(LayoutTheme::FIELD_NAMES.contains(&"window_margin_px"));
1547 assert!(LayoutTheme::FIELD_NAMES.contains(&"section_gap_px"));
1548 }
1549
1550 #[test]
1551 fn layout_theme_toml_round_trip() {
1552 let l = LayoutTheme {
1553 widget_gap: Some(8.0),
1554 container_margin: Some(12.0),
1555 window_margin: Some(16.0),
1556 section_gap: Some(24.0),
1557 };
1558 let toml_str = toml::to_string(&l).unwrap();
1559 let l2: LayoutTheme = toml::from_str(&toml_str).unwrap();
1560 assert_eq!(l, l2);
1561 }
1562
1563 #[test]
1564 fn layout_theme_merge() {
1565 let mut base = LayoutTheme {
1566 widget_gap: Some(6.0),
1567 container_margin: Some(10.0),
1568 ..Default::default()
1569 };
1570 let overlay = LayoutTheme {
1571 widget_gap: Some(8.0),
1572 section_gap: Some(24.0),
1573 ..Default::default()
1574 };
1575 base.merge(&overlay);
1576 assert_eq!(base.widget_gap, Some(8.0));
1578 assert_eq!(base.container_margin, Some(10.0));
1580 assert_eq!(base.section_gap, Some(24.0));
1582 assert!(base.window_margin.is_none());
1584 }
1585}