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