Skip to main content

native_theme/model/widgets/
mod.rs

1// Per-widget struct pairs and macros
2
3use crate::Rgba;
4use crate::model::{DialogButtonOrder, FontSpec};
5
6/// A resolved (non-optional) font specification produced after theme resolution.
7///
8/// Unlike [`crate::model::FontSpec`], all fields are required (non-optional)
9/// because resolution has already filled in all defaults.
10#[derive(Clone, Debug, Default, PartialEq)]
11pub struct ResolvedFontSpec {
12    /// Font family name.
13    pub family: String,
14    /// Font size in logical pixels.
15    pub size: f32,
16    /// CSS font weight (100–900).
17    pub weight: u16,
18}
19
20/// Generates a paired Option-based theme struct and a Resolved struct from a single definition.
21///
22/// # Usage
23///
24/// ```ignore
25/// define_widget_pair! {
26///     /// Doc comment
27///     ButtonTheme / ResolvedButtonTheme {
28///         option {
29///             color: crate::Rgba,
30///             size: f32,
31///         }
32///         optional_nested {
33///             font: [crate::model::FontSpec, ResolvedFontSpec],
34///         }
35///     }
36/// }
37/// ```
38///
39/// This generates:
40/// - `ButtonTheme` with all `option` fields as `Option<T>` and all `optional_nested` fields
41///   as `Option<FontSpec>` (the first type in the pair). Derives: Clone, Debug, Default,
42///   PartialEq, Serialize, Deserialize. Attributes: skip_serializing_none, serde(default).
43/// - `ResolvedButtonTheme` with all `option` fields as plain `T` and all `optional_nested`
44///   fields as `ResolvedFontSpec` (the second type in the pair). Derives: Clone, Debug, PartialEq.
45/// - `impl_merge!` invocation for `ButtonTheme` using the `optional_nested` clause for font fields.
46macro_rules! define_widget_pair {
47    (
48        $(#[$attr:meta])*
49        $opt_name:ident / $resolved_name:ident {
50            $(option {
51                $($opt_field:ident : $opt_type:ty),* $(,)?
52            })?
53            $(optional_nested {
54                $($on_field:ident : [$on_opt_type:ty, $on_res_type:ty]),* $(,)?
55            })?
56        }
57    ) => {
58        $(#[$attr])*
59        #[serde_with::skip_serializing_none]
60        #[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
61        #[serde(default)]
62        #[allow(missing_docs)]
63        pub struct $opt_name {
64            $($(pub $opt_field: Option<$opt_type>,)*)?
65            $($(pub $on_field: Option<$on_opt_type>,)*)?
66        }
67
68        $(#[$attr])*
69        #[derive(Clone, Debug, PartialEq)]
70        #[allow(missing_docs)]
71        pub struct $resolved_name {
72            $($(pub $opt_field: $opt_type,)*)?
73            $($(pub $on_field: $on_res_type,)*)?
74        }
75
76        $crate::impl_merge!($opt_name {
77            $(option { $($opt_field),* })?
78            $(optional_nested { $($on_field),* })?
79        });
80    };
81}
82
83// ── §2.2 Window / Application Chrome ────────────────────────────────────────
84
85define_widget_pair! {
86    /// Window chrome: background, title bar colors, inactive states, geometry.
87    WindowTheme / ResolvedWindow {
88        option {
89            background: Rgba,
90            foreground: Rgba,
91            border: Rgba,
92            title_bar_background: Rgba,
93            title_bar_foreground: Rgba,
94            inactive_title_bar_background: Rgba,
95            inactive_title_bar_foreground: Rgba,
96            radius: f32,
97            shadow: bool,
98        }
99        optional_nested {
100            title_bar_font: [FontSpec, ResolvedFontSpec],
101        }
102    }
103}
104
105// ── §2.3 Button ──────────────────────────────────────────────────────────────
106
107define_widget_pair! {
108    /// Push button: colors, sizing, spacing, geometry.
109    ButtonTheme / ResolvedButton {
110        option {
111            background: Rgba,
112            foreground: Rgba,
113            border: Rgba,
114            primary_bg: Rgba,
115            primary_fg: Rgba,
116            min_width: f32,
117            min_height: f32,
118            padding_horizontal: f32,
119            padding_vertical: f32,
120            radius: f32,
121            icon_spacing: f32,
122            disabled_opacity: f32,
123            shadow: bool,
124        }
125        optional_nested {
126            font: [FontSpec, ResolvedFontSpec],
127        }
128    }
129}
130
131// ── §2.4 Text Input ──────────────────────────────────────────────────────────
132
133define_widget_pair! {
134    /// Single-line and multi-line text input fields.
135    InputTheme / ResolvedInput {
136        option {
137            background: Rgba,
138            foreground: Rgba,
139            border: Rgba,
140            placeholder: Rgba,
141            caret: Rgba,
142            selection: Rgba,
143            selection_foreground: Rgba,
144            min_height: f32,
145            padding_horizontal: f32,
146            padding_vertical: f32,
147            radius: f32,
148            border_width: f32,
149        }
150        optional_nested {
151            font: [FontSpec, ResolvedFontSpec],
152        }
153    }
154}
155
156// ── §2.5 Checkbox / Radio Button ────────────────────────────────────────────
157
158define_widget_pair! {
159    /// Checkbox and radio button indicator geometry.
160    CheckboxTheme / ResolvedCheckbox {
161        option {
162            checked_bg: Rgba,
163            indicator_size: f32,
164            spacing: f32,
165            radius: f32,
166            border_width: f32,
167        }
168    }
169}
170
171// ── §2.6 Menu ────────────────────────────────────────────────────────────────
172
173define_widget_pair! {
174    /// Popup and context menu appearance.
175    MenuTheme / ResolvedMenu {
176        option {
177            background: Rgba,
178            foreground: Rgba,
179            separator: Rgba,
180            item_height: f32,
181            padding_horizontal: f32,
182            padding_vertical: f32,
183            icon_spacing: f32,
184        }
185        optional_nested {
186            font: [FontSpec, ResolvedFontSpec],
187        }
188    }
189}
190
191// ── §2.7 Tooltip ─────────────────────────────────────────────────────────────
192
193define_widget_pair! {
194    /// Tooltip popup appearance.
195    TooltipTheme / ResolvedTooltip {
196        option {
197            background: Rgba,
198            foreground: Rgba,
199            padding_horizontal: f32,
200            padding_vertical: f32,
201            max_width: f32,
202            radius: f32,
203        }
204        optional_nested {
205            font: [FontSpec, ResolvedFontSpec],
206        }
207    }
208}
209
210// ── §2.8 Scrollbar ───────────────────────────────────────────────────────────
211
212define_widget_pair! {
213    /// Scrollbar colors and geometry.
214    ScrollbarTheme / ResolvedScrollbar {
215        option {
216            track: Rgba,
217            thumb: Rgba,
218            thumb_hover: Rgba,
219            width: f32,
220            min_thumb_height: f32,
221            slider_width: f32,
222            overlay_mode: bool,
223        }
224    }
225}
226
227// ── §2.9 Slider ──────────────────────────────────────────────────────────────
228
229define_widget_pair! {
230    /// Slider control colors and geometry.
231    SliderTheme / ResolvedSlider {
232        option {
233            fill: Rgba,
234            track: Rgba,
235            thumb: Rgba,
236            track_height: f32,
237            thumb_size: f32,
238            tick_length: f32,
239        }
240    }
241}
242
243// ── §2.10 Progress Bar ───────────────────────────────────────────────────────
244
245define_widget_pair! {
246    /// Progress bar colors and geometry.
247    ProgressBarTheme / ResolvedProgressBar {
248        option {
249            fill: Rgba,
250            track: Rgba,
251            height: f32,
252            min_width: f32,
253            radius: f32,
254        }
255    }
256}
257
258// ── §2.11 Tab Bar ─────────────────────────────────────────────────────────────
259
260define_widget_pair! {
261    /// Tab bar colors and sizing.
262    TabTheme / ResolvedTab {
263        option {
264            background: Rgba,
265            foreground: Rgba,
266            active_background: Rgba,
267            active_foreground: Rgba,
268            bar_background: Rgba,
269            min_width: f32,
270            min_height: f32,
271            padding_horizontal: f32,
272            padding_vertical: f32,
273        }
274    }
275}
276
277// ── §2.12 Sidebar ─────────────────────────────────────────────────────────────
278
279define_widget_pair! {
280    /// Sidebar panel background and foreground colors.
281    SidebarTheme / ResolvedSidebar {
282        option {
283            background: Rgba,
284            foreground: Rgba,
285        }
286    }
287}
288
289// ── §2.13 Toolbar ─────────────────────────────────────────────────────────────
290
291define_widget_pair! {
292    /// Toolbar sizing, spacing, and font.
293    ToolbarTheme / ResolvedToolbar {
294        option {
295            height: f32,
296            item_spacing: f32,
297            padding: f32,
298        }
299        optional_nested {
300            font: [FontSpec, ResolvedFontSpec],
301        }
302    }
303}
304
305// ── §2.14 Status Bar ──────────────────────────────────────────────────────────
306
307define_widget_pair! {
308    /// Status bar font.
309    StatusBarTheme / ResolvedStatusBar {
310        optional_nested {
311            font: [FontSpec, ResolvedFontSpec],
312        }
313    }
314}
315
316// ── §2.15 List / Table ────────────────────────────────────────────────────────
317
318define_widget_pair! {
319    /// List and table colors and row geometry.
320    ListTheme / ResolvedList {
321        option {
322            background: Rgba,
323            foreground: Rgba,
324            alternate_row: Rgba,
325            selection: Rgba,
326            selection_foreground: Rgba,
327            header_background: Rgba,
328            header_foreground: Rgba,
329            grid_color: Rgba,
330            item_height: f32,
331            padding_horizontal: f32,
332            padding_vertical: f32,
333        }
334    }
335}
336
337// ── §2.16 Popover / Dropdown ──────────────────────────────────────────────────
338
339define_widget_pair! {
340    /// Popover / dropdown panel appearance.
341    PopoverTheme / ResolvedPopover {
342        option {
343            background: Rgba,
344            foreground: Rgba,
345            border: Rgba,
346            radius: f32,
347        }
348    }
349}
350
351// ── §2.17 Splitter ────────────────────────────────────────────────────────────
352
353define_widget_pair! {
354    /// Splitter handle width.
355    SplitterTheme / ResolvedSplitter {
356        option {
357            width: f32,
358        }
359    }
360}
361
362// ── §2.18 Separator ───────────────────────────────────────────────────────────
363
364define_widget_pair! {
365    /// Separator line color.
366    SeparatorTheme / ResolvedSeparator {
367        option {
368            color: Rgba,
369        }
370    }
371}
372
373// ── §2.21 Switch / Toggle ─────────────────────────────────────────────────────
374
375define_widget_pair! {
376    /// Toggle switch track, thumb, and geometry.
377    SwitchTheme / ResolvedSwitch {
378        option {
379            checked_bg: Rgba,
380            unchecked_bg: Rgba,
381            thumb_bg: Rgba,
382            track_width: f32,
383            track_height: f32,
384            thumb_size: f32,
385            track_radius: f32,
386        }
387    }
388}
389
390// ── §2.22 Dialog ──────────────────────────────────────────────────────────────
391
392define_widget_pair! {
393    /// Dialog sizing, spacing, button order, and title font.
394    DialogTheme / ResolvedDialog {
395        option {
396            min_width: f32,
397            max_width: f32,
398            min_height: f32,
399            max_height: f32,
400            content_padding: f32,
401            button_spacing: f32,
402            radius: f32,
403            icon_size: f32,
404            button_order: DialogButtonOrder,
405        }
406        optional_nested {
407            title_font: [FontSpec, ResolvedFontSpec],
408        }
409    }
410}
411
412// ── §2.23 Spinner / Progress Ring ─────────────────────────────────────────────
413
414define_widget_pair! {
415    /// Spinner / indeterminate progress indicator.
416    SpinnerTheme / ResolvedSpinner {
417        option {
418            fill: Rgba,
419            diameter: f32,
420            min_size: f32,
421            stroke_width: f32,
422        }
423    }
424}
425
426// ── §2.24 ComboBox / Dropdown Trigger ─────────────────────────────────────────
427
428define_widget_pair! {
429    /// ComboBox / dropdown trigger sizing.
430    ComboBoxTheme / ResolvedComboBox {
431        option {
432            min_height: f32,
433            min_width: f32,
434            padding_horizontal: f32,
435            arrow_size: f32,
436            arrow_area_width: f32,
437            radius: f32,
438        }
439    }
440}
441
442// ── §2.25 Segmented Control ───────────────────────────────────────────────────
443
444define_widget_pair! {
445    /// Segmented control sizing (macOS-primary; KDE uses tab bar metrics as proxy).
446    SegmentedControlTheme / ResolvedSegmentedControl {
447        option {
448            segment_height: f32,
449            separator_width: f32,
450            padding_horizontal: f32,
451            radius: f32,
452        }
453    }
454}
455
456// ── §2.26 Card / Container ────────────────────────────────────────────────────
457
458define_widget_pair! {
459    /// Card / container colors and geometry.
460    CardTheme / ResolvedCard {
461        option {
462            background: Rgba,
463            border: Rgba,
464            radius: f32,
465            padding: f32,
466            shadow: bool,
467        }
468    }
469}
470
471// ── §2.27 Expander / Disclosure ───────────────────────────────────────────────
472
473define_widget_pair! {
474    /// Expander / disclosure row geometry.
475    ExpanderTheme / ResolvedExpander {
476        option {
477            header_height: f32,
478            arrow_size: f32,
479            content_padding: f32,
480            radius: f32,
481        }
482    }
483}
484
485// ── §2.28 Link ────────────────────────────────────────────────────────────────
486
487define_widget_pair! {
488    /// Hyperlink colors and underline setting.
489    LinkTheme / ResolvedLink {
490        option {
491            color: Rgba,
492            visited: Rgba,
493            background: Rgba,
494            hover_bg: Rgba,
495            underline: bool,
496        }
497    }
498}
499
500#[cfg(test)]
501#[allow(clippy::unwrap_used, clippy::expect_used)]
502mod tests {
503    use super::*;
504    use crate::Rgba;
505    use crate::model::{DialogButtonOrder, FontSpec};
506
507    // Define a test widget pair using the macro (validates macro itself still works)
508    define_widget_pair! {
509        /// Test widget for macro verification.
510        TestWidget / ResolvedTestWidget {
511            option {
512                size: f32,
513                label: String,
514            }
515            optional_nested {
516                font: [FontSpec, ResolvedFontSpec],
517            }
518        }
519    }
520
521    // === ResolvedFontSpec tests ===
522
523    #[test]
524    fn resolved_font_spec_fields_are_concrete() {
525        let rfs = ResolvedFontSpec {
526            family: "Inter".into(),
527            size: 14.0,
528            weight: 400,
529        };
530        assert_eq!(rfs.family, "Inter");
531        assert_eq!(rfs.size, 14.0);
532        assert_eq!(rfs.weight, 400);
533    }
534
535    // === define_widget_pair! generated struct tests ===
536
537    #[test]
538    fn generated_option_struct_has_option_fields() {
539        let w = TestWidget::default();
540        assert!(w.size.is_none());
541        assert!(w.label.is_none());
542        assert!(w.font.is_none());
543    }
544
545    #[test]
546    fn generated_option_struct_is_empty_by_default() {
547        assert!(TestWidget::default().is_empty());
548    }
549
550    #[test]
551    fn generated_option_struct_not_empty_when_size_set() {
552        let w = TestWidget {
553            size: Some(24.0),
554            ..Default::default()
555        };
556        assert!(!w.is_empty());
557    }
558
559    #[test]
560    fn generated_option_struct_not_empty_when_font_set() {
561        let w = TestWidget {
562            font: Some(FontSpec {
563                size: Some(14.0),
564                ..Default::default()
565            }),
566            ..Default::default()
567        };
568        assert!(!w.is_empty());
569    }
570
571    #[test]
572    fn generated_resolved_struct_has_concrete_fields() {
573        let resolved = ResolvedTestWidget {
574            size: 24.0,
575            label: "Click me".into(),
576            font: ResolvedFontSpec {
577                family: "Inter".into(),
578                size: 14.0,
579                weight: 400,
580            },
581        };
582        assert_eq!(resolved.size, 24.0);
583        assert_eq!(resolved.label, "Click me");
584        assert_eq!(resolved.font.family, "Inter");
585    }
586
587    // === merge tests for generated structs ===
588
589    #[test]
590    fn generated_merge_option_field_overlay_wins() {
591        let mut base = TestWidget {
592            size: Some(20.0),
593            ..Default::default()
594        };
595        let overlay = TestWidget {
596            size: Some(24.0),
597            ..Default::default()
598        };
599        base.merge(&overlay);
600        assert_eq!(base.size, Some(24.0));
601    }
602
603    #[test]
604    fn generated_merge_option_field_none_preserves_base() {
605        let mut base = TestWidget {
606            size: Some(20.0),
607            ..Default::default()
608        };
609        let overlay = TestWidget::default();
610        base.merge(&overlay);
611        assert_eq!(base.size, Some(20.0));
612    }
613
614    #[test]
615    fn generated_merge_optional_nested_both_some_merges_inner() {
616        let mut base = TestWidget {
617            font: Some(FontSpec {
618                family: Some("Noto Sans".into()),
619                size: Some(12.0),
620                weight: None,
621            }),
622            ..Default::default()
623        };
624        let overlay = TestWidget {
625            font: Some(FontSpec {
626                family: None,
627                size: None,
628                weight: Some(700),
629            }),
630            ..Default::default()
631        };
632        base.merge(&overlay);
633        let font = base.font.as_ref().unwrap();
634        assert_eq!(font.family.as_deref(), Some("Noto Sans")); // preserved
635        assert_eq!(font.size, Some(12.0)); // preserved
636        assert_eq!(font.weight, Some(700)); // overlay sets
637    }
638
639    #[test]
640    fn generated_merge_optional_nested_none_plus_some_clones() {
641        let mut base = TestWidget::default();
642        let overlay = TestWidget {
643            font: Some(FontSpec {
644                family: Some("Inter".into()),
645                size: Some(14.0),
646                weight: Some(400),
647            }),
648            ..Default::default()
649        };
650        base.merge(&overlay);
651        let font = base.font.as_ref().unwrap();
652        assert_eq!(font.family.as_deref(), Some("Inter"));
653        assert_eq!(font.size, Some(14.0));
654        assert_eq!(font.weight, Some(400));
655    }
656
657    #[test]
658    fn generated_merge_optional_nested_some_plus_none_preserves_base() {
659        let mut base = TestWidget {
660            font: Some(FontSpec {
661                family: Some("Inter".into()),
662                size: Some(14.0),
663                weight: Some(400),
664            }),
665            ..Default::default()
666        };
667        let overlay = TestWidget::default();
668        base.merge(&overlay);
669        let font = base.font.as_ref().unwrap();
670        assert_eq!(font.family.as_deref(), Some("Inter"));
671    }
672
673    #[test]
674    fn generated_merge_optional_nested_none_plus_none_stays_none() {
675        let mut base = TestWidget::default();
676        let overlay = TestWidget::default();
677        base.merge(&overlay);
678        assert!(base.font.is_none());
679    }
680
681    // === impl_merge! optional_nested clause direct tests ===
682
683    // Verify the optional_nested clause directly on a FontSpec-containing struct
684    #[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
685    struct WithFont {
686        name: Option<String>,
687        font: Option<FontSpec>,
688    }
689
690    impl_merge!(WithFont {
691        option { name }
692        optional_nested { font }
693    });
694
695    #[test]
696    fn impl_merge_optional_nested_none_none_stays_none() {
697        let mut base = WithFont::default();
698        let overlay = WithFont::default();
699        base.merge(&overlay);
700        assert!(base.font.is_none());
701    }
702
703    #[test]
704    fn impl_merge_optional_nested_some_none_preserves_base() {
705        let mut base = WithFont {
706            font: Some(FontSpec {
707                size: Some(12.0),
708                ..Default::default()
709            }),
710            ..Default::default()
711        };
712        let overlay = WithFont::default();
713        base.merge(&overlay);
714        assert_eq!(base.font.as_ref().unwrap().size, Some(12.0));
715    }
716
717    #[test]
718    fn impl_merge_optional_nested_none_some_clones_overlay() {
719        let mut base = WithFont::default();
720        let overlay = WithFont {
721            font: Some(FontSpec {
722                family: Some("Inter".into()),
723                ..Default::default()
724            }),
725            ..Default::default()
726        };
727        base.merge(&overlay);
728        assert_eq!(base.font.as_ref().unwrap().family.as_deref(), Some("Inter"));
729    }
730
731    #[test]
732    fn impl_merge_optional_nested_some_some_merges_inner() {
733        let mut base = WithFont {
734            font: Some(FontSpec {
735                family: Some("Noto".into()),
736                size: Some(11.0),
737                weight: None,
738            }),
739            ..Default::default()
740        };
741        let overlay = WithFont {
742            font: Some(FontSpec {
743                family: None,
744                size: Some(14.0),
745                weight: Some(400),
746            }),
747            ..Default::default()
748        };
749        base.merge(&overlay);
750        let f = base.font.as_ref().unwrap();
751        assert_eq!(f.family.as_deref(), Some("Noto")); // preserved
752        assert_eq!(f.size, Some(14.0)); // overlay wins
753        assert_eq!(f.weight, Some(400)); // overlay sets
754    }
755
756    #[test]
757    fn impl_merge_optional_nested_is_empty_none() {
758        let w = WithFont::default();
759        assert!(w.is_empty());
760    }
761
762    #[test]
763    fn impl_merge_optional_nested_is_empty_some() {
764        let w = WithFont {
765            font: Some(FontSpec::default()),
766            ..Default::default()
767        };
768        assert!(!w.is_empty());
769    }
770
771    // === ButtonTheme: 14 fields ===
772
773    #[test]
774    fn button_theme_has_all_fields_and_not_empty_when_set() {
775        let b = ButtonTheme {
776            background: Some(Rgba::rgb(200, 200, 200)),
777            foreground: Some(Rgba::rgb(30, 30, 30)),
778            border: Some(Rgba::rgb(150, 150, 150)),
779            primary_bg: Some(Rgba::rgb(0, 120, 215)),
780            primary_fg: Some(Rgba::rgb(255, 255, 255)),
781            min_width: Some(64.0),
782            min_height: Some(28.0),
783            padding_horizontal: Some(12.0),
784            padding_vertical: Some(6.0),
785            radius: Some(4.0),
786            icon_spacing: Some(6.0),
787            disabled_opacity: Some(0.5),
788            shadow: Some(false),
789            font: Some(FontSpec {
790                family: Some("Inter".into()),
791                size: Some(14.0),
792                weight: Some(400),
793            }),
794        };
795        assert!(!b.is_empty());
796        assert_eq!(b.min_width, Some(64.0));
797        assert_eq!(b.primary_bg, Some(Rgba::rgb(0, 120, 215)));
798    }
799
800    #[test]
801    fn button_theme_default_is_empty() {
802        assert!(ButtonTheme::default().is_empty());
803    }
804
805    #[test]
806    fn button_theme_merge_font_optional_nested() {
807        let mut base = ButtonTheme {
808            font: Some(FontSpec {
809                family: Some("Noto Sans".into()),
810                size: Some(11.0),
811                weight: None,
812            }),
813            ..Default::default()
814        };
815        let overlay = ButtonTheme {
816            font: Some(FontSpec {
817                family: None,
818                weight: Some(700),
819                ..Default::default()
820            }),
821            ..Default::default()
822        };
823        base.merge(&overlay);
824        let f = base.font.as_ref().unwrap();
825        assert_eq!(f.family.as_deref(), Some("Noto Sans")); // preserved
826        assert_eq!(f.weight, Some(700)); // overlay
827    }
828
829    #[test]
830    fn button_theme_toml_round_trip_with_font() {
831        let b = ButtonTheme {
832            background: Some(Rgba::rgb(200, 200, 200)),
833            radius: Some(4.0),
834            font: Some(FontSpec {
835                family: Some("Inter".into()),
836                size: Some(14.0),
837                weight: Some(400),
838            }),
839            ..Default::default()
840        };
841        let toml_str = toml::to_string(&b).unwrap();
842        let b2: ButtonTheme = toml::from_str(&toml_str).unwrap();
843        assert_eq!(b, b2);
844    }
845
846    // === WindowTheme: inactive title bar fields ===
847
848    #[test]
849    fn window_theme_has_inactive_title_bar_fields() {
850        let w = WindowTheme {
851            inactive_title_bar_background: Some(Rgba::rgb(180, 180, 180)),
852            inactive_title_bar_foreground: Some(Rgba::rgb(120, 120, 120)),
853            title_bar_font: Some(FontSpec {
854                weight: Some(700),
855                ..Default::default()
856            }),
857            ..Default::default()
858        };
859        assert!(!w.is_empty());
860        assert!(w.inactive_title_bar_background.is_some());
861        assert!(w.inactive_title_bar_foreground.is_some());
862        assert!(w.title_bar_font.is_some());
863    }
864
865    #[test]
866    fn window_theme_default_is_empty() {
867        assert!(WindowTheme::default().is_empty());
868    }
869
870    // === DialogTheme: button_order field ===
871
872    #[test]
873    fn dialog_theme_button_order_works() {
874        let d = DialogTheme {
875            button_order: Some(DialogButtonOrder::TrailingAffirmative),
876            min_width: Some(300.0),
877            ..Default::default()
878        };
879        assert_eq!(d.button_order, Some(DialogButtonOrder::TrailingAffirmative));
880        assert_eq!(d.min_width, Some(300.0));
881        assert!(!d.is_empty());
882    }
883
884    #[test]
885    fn dialog_theme_button_order_toml_round_trip() {
886        let d = DialogTheme {
887            button_order: Some(DialogButtonOrder::LeadingAffirmative),
888            radius: Some(8.0),
889            ..Default::default()
890        };
891        let toml_str = toml::to_string(&d).unwrap();
892        let d2: DialogTheme = toml::from_str(&toml_str).unwrap();
893        assert_eq!(d, d2);
894    }
895
896    #[test]
897    fn dialog_theme_default_is_empty() {
898        assert!(DialogTheme::default().is_empty());
899    }
900
901    // === SplitterTheme: 1 field ===
902
903    #[test]
904    fn splitter_theme_single_field_merge() {
905        let mut base = SplitterTheme { width: Some(4.0) };
906        let overlay = SplitterTheme { width: Some(6.0) };
907        base.merge(&overlay);
908        assert_eq!(base.width, Some(6.0));
909    }
910
911    #[test]
912    fn splitter_theme_merge_none_preserves_base() {
913        let mut base = SplitterTheme { width: Some(4.0) };
914        let overlay = SplitterTheme::default();
915        base.merge(&overlay);
916        assert_eq!(base.width, Some(4.0));
917    }
918
919    #[test]
920    fn splitter_theme_default_is_empty() {
921        assert!(SplitterTheme::default().is_empty());
922    }
923
924    #[test]
925    fn splitter_theme_not_empty_when_set() {
926        assert!(!SplitterTheme { width: Some(4.0) }.is_empty());
927    }
928
929    // === SeparatorTheme: 1 field ===
930
931    #[test]
932    fn separator_theme_single_field() {
933        let s = SeparatorTheme {
934            color: Some(Rgba::rgb(200, 200, 200)),
935        };
936        assert!(!s.is_empty());
937    }
938
939    // === All 25 widget theme defaults are empty ===
940
941    #[test]
942    fn all_widget_theme_defaults_are_empty() {
943        assert!(WindowTheme::default().is_empty());
944        assert!(ButtonTheme::default().is_empty());
945        assert!(InputTheme::default().is_empty());
946        assert!(CheckboxTheme::default().is_empty());
947        assert!(MenuTheme::default().is_empty());
948        assert!(TooltipTheme::default().is_empty());
949        assert!(ScrollbarTheme::default().is_empty());
950        assert!(SliderTheme::default().is_empty());
951        assert!(ProgressBarTheme::default().is_empty());
952        assert!(TabTheme::default().is_empty());
953        assert!(SidebarTheme::default().is_empty());
954        assert!(ToolbarTheme::default().is_empty());
955        assert!(StatusBarTheme::default().is_empty());
956        assert!(ListTheme::default().is_empty());
957        assert!(PopoverTheme::default().is_empty());
958        assert!(SplitterTheme::default().is_empty());
959        assert!(SeparatorTheme::default().is_empty());
960        assert!(SwitchTheme::default().is_empty());
961        assert!(DialogTheme::default().is_empty());
962        assert!(SpinnerTheme::default().is_empty());
963        assert!(ComboBoxTheme::default().is_empty());
964        assert!(SegmentedControlTheme::default().is_empty());
965        assert!(CardTheme::default().is_empty());
966        assert!(ExpanderTheme::default().is_empty());
967        assert!(LinkTheme::default().is_empty());
968    }
969
970    // === Representative TOML round-trips ===
971
972    #[test]
973    fn input_theme_toml_round_trip() {
974        let t = InputTheme {
975            background: Some(Rgba::rgb(255, 255, 255)),
976            border: Some(Rgba::rgb(180, 180, 180)),
977            radius: Some(4.0),
978            font: Some(FontSpec {
979                family: Some("Noto Sans".into()),
980                ..Default::default()
981            }),
982            ..Default::default()
983        };
984        let toml_str = toml::to_string(&t).unwrap();
985        let t2: InputTheme = toml::from_str(&toml_str).unwrap();
986        assert_eq!(t, t2);
987    }
988
989    #[test]
990    fn switch_theme_toml_round_trip() {
991        let s = SwitchTheme {
992            checked_bg: Some(Rgba::rgb(0, 120, 215)),
993            track_width: Some(40.0),
994            track_height: Some(20.0),
995            thumb_size: Some(14.0),
996            track_radius: Some(10.0),
997            ..Default::default()
998        };
999        let toml_str = toml::to_string(&s).unwrap();
1000        let s2: SwitchTheme = toml::from_str(&toml_str).unwrap();
1001        assert_eq!(s, s2);
1002    }
1003
1004    #[test]
1005    fn card_theme_has_shadow_bool_field() {
1006        let c = CardTheme {
1007            shadow: Some(true),
1008            radius: Some(8.0),
1009            ..Default::default()
1010        };
1011        assert!(!c.is_empty());
1012        assert_eq!(c.shadow, Some(true));
1013    }
1014
1015    #[test]
1016    fn link_theme_has_underline_bool_field() {
1017        let l = LinkTheme {
1018            color: Some(Rgba::rgb(0, 100, 200)),
1019            underline: Some(true),
1020            ..Default::default()
1021        };
1022        assert!(!l.is_empty());
1023        assert_eq!(l.underline, Some(true));
1024    }
1025
1026    #[test]
1027    fn status_bar_theme_has_only_font_field() {
1028        // StatusBarTheme has only a font optional_nested field
1029        let s = StatusBarTheme {
1030            font: Some(FontSpec {
1031                size: Some(11.0),
1032                ..Default::default()
1033            }),
1034        };
1035        assert!(!s.is_empty());
1036    }
1037}