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