Skip to main content

native_theme/model/widgets/
mod.rs

1// Per-widget struct pairs and macros
2
3use crate::Rgba;
4use crate::model::border::{BorderSpec, ResolvedBorderSpec};
5use crate::model::{DialogButtonOrder, FontSpec, ResolvedFontSpec};
6
7/// Helper macro for FIELD_NAMES: emits the TOML key name for an option field.
8/// With a rename literal, returns the literal; without, returns `stringify!(field)`.
9#[doc(hidden)]
10macro_rules! __field_name {
11    ($field:ident) => {
12        stringify!($field)
13    };
14    ($field:ident, $rename:literal) => {
15        $rename
16    };
17}
18pub(crate) use __field_name;
19
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 as "size_px": 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///   Fields with `as "name"` get `#[serde(rename = "name")]` on the Option struct only.
44/// - `ResolvedButtonTheme` with all `option` fields as plain `T` and all `optional_nested`
45///   fields as `ResolvedFontSpec` (the second type in the pair). Derives: Clone, Debug, PartialEq.
46///   Resolved structs never get serde renames.
47/// - `impl_merge!` invocation for `ButtonTheme` using the `optional_nested` clause for font fields.
48macro_rules! define_widget_pair {
49    (
50        $(#[$attr:meta])*
51        $opt_name:ident / $resolved_name:ident {
52            $(option {
53                $($(#[doc = $opt_doc:expr])* $opt_field:ident $(as $opt_rename:literal)? : $opt_type:ty),* $(,)?
54            })?
55            $(soft_option {
56                $($(#[doc = $so_doc:expr])* $so_field:ident : $so_type:ty),* $(,)?
57            })?
58            $(optional_nested {
59                $($(#[doc = $on_doc:expr])* $on_field:ident : [$on_opt_type:ty, $on_res_type:ty]),* $(,)?
60            })?
61            $(border_partial {
62                $($(#[doc = $bp_doc:expr])* $bp_field:ident : [$bp_opt_type:ty, $bp_res_type:ty]),* $(,)?
63            })?
64            $(border_optional {
65                $($(#[doc = $bo_doc:expr])* $bo_field:ident : [$bo_opt_type:ty, $bo_res_type:ty]),* $(,)?
66            })?
67        }
68    ) => {
69        $(#[$attr])*
70        #[serde_with::skip_serializing_none]
71        #[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
72        #[serde(default)]
73        pub struct $opt_name {
74            $($($(#[doc = $opt_doc])* $(#[serde(rename = $opt_rename)])? pub $opt_field: Option<$opt_type>,)*)?
75            $($($(#[doc = $so_doc])* pub $so_field: Option<$so_type>,)*)?
76            $($($(#[doc = $on_doc])* pub $on_field: Option<$on_opt_type>,)*)?
77            $($($(#[doc = $bp_doc])* pub $bp_field: Option<$bp_opt_type>,)*)?
78            $($($(#[doc = $bo_doc])* pub $bo_field: Option<$bo_opt_type>,)*)?
79        }
80
81        $(#[$attr])*
82        #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
83        #[non_exhaustive]
84        pub struct $resolved_name {
85            $($($(#[doc = $opt_doc])* pub $opt_field: $opt_type,)*)?
86            $($($(#[doc = $so_doc])* pub $so_field: Option<$so_type>,)*)?
87            $($($(#[doc = $on_doc])* pub $on_field: $on_res_type,)*)?
88            $($($(#[doc = $bp_doc])* pub $bp_field: $bp_res_type,)*)?
89            $($($(#[doc = $bo_doc])* pub $bo_field: $bo_res_type,)*)?
90        }
91
92        impl $opt_name {
93            /// All serialized field names for this widget theme, for TOML linting.
94            pub const FIELD_NAMES: &[&str] = &[
95                $($($crate::model::widgets::__field_name!($opt_field $(, $opt_rename)?),)*)?
96                $($(stringify!($so_field),)*)?
97                $($(stringify!($on_field),)*)?
98                $($(stringify!($bp_field),)*)?
99                $($(stringify!($bo_field),)*)?
100            ];
101        }
102
103        impl_merge!($opt_name {
104            $(option { $($opt_field),* })?
105            $(soft_option { $($so_field),* })?
106            $(optional_nested { $($on_field),* })?
107            $(optional_nested { $($bp_field),* })?
108            $(optional_nested { $($bo_field),* })?
109        });
110
111        #[allow(dead_code)] // LayoutTheme is top-level, not per-variant
112        impl $resolved_name {
113            /// Extract and validate fields from the Option-based source struct.
114            /// Generated by `define_widget_pair!` -- field paths use `stringify!()`.
115            pub(crate) fn validate_widget(
116                source: &$opt_name,
117                prefix: &str,
118                _dpi: f32,
119                missing: &mut Vec<String>,
120            ) -> Self {
121                Self {
122                    $($(
123                        $opt_field: crate::resolve::validate_helpers::require(
124                            &source.$opt_field,
125                            &format!("{}.{}", prefix, stringify!($opt_field)),
126                            missing,
127                        ),
128                    )*)?
129                    $($(
130                        $so_field: source.$so_field,
131                    )*)?
132                    $($(
133                        $on_field: <$on_opt_type as crate::resolve::validate_helpers::ValidateNested>::validate_nested(
134                            &source.$on_field,
135                            &format!("{}.{}", prefix, stringify!($on_field)),
136                            _dpi,
137                            missing,
138                        ),
139                    )*)?
140                    $($(
141                        $bp_field: crate::resolve::validate_helpers::require_border_partial(
142                            &source.$bp_field,
143                            &format!("{}.{}", prefix, stringify!($bp_field)),
144                            missing,
145                        ),
146                    )*)?
147                    $($(
148                        $bo_field: crate::resolve::validate_helpers::border_all_optional(
149                            &source.$bo_field,
150                        ),
151                    )*)?
152                }
153            }
154        }
155    };
156}
157
158// ── §2.2 Window / Application Chrome ────────────────────────────────────────
159
160define_widget_pair! {
161    /// Window chrome: background, title bar colors, inactive states, geometry.
162    WindowTheme / ResolvedWindowTheme {
163        option {
164            /// Main window background fill.
165            background_color: Rgba,
166            /// Active title bar background fill.
167            title_bar_background: Rgba,
168            /// Title bar background when the window is unfocused.
169            inactive_title_bar_background: Rgba,
170            /// Title bar text color when the window is unfocused.
171            inactive_title_bar_text_color: Rgba,
172        }
173        optional_nested {
174            /// Title bar font specification.
175            title_bar_font: [FontSpec, ResolvedFontSpec],
176            /// Window border specification.
177            border: [BorderSpec, ResolvedBorderSpec],
178        }
179    }
180}
181
182// ── §2.3 Button ──────────────────────────────────────────────────────────────
183
184define_widget_pair! {
185    /// Push button: colors, sizing, spacing, geometry.
186    ButtonTheme / ResolvedButtonTheme {
187        option {
188            /// Default button background fill.
189            background_color: Rgba,
190            /// Primary / accent button background fill.
191            primary_background: Rgba,
192            /// Primary / accent button text/icon color.
193            primary_text_color: Rgba,
194            /// Minimum button width in logical pixels.
195            min_width as "min_width_px": f32,
196            /// Minimum button height in logical pixels.
197            min_height as "min_height_px": f32,
198            /// Space between icon and label.
199            icon_text_gap as "icon_text_gap_px": f32,
200            /// Opacity multiplier when the button is disabled (0.0-1.0).
201            disabled_opacity: f32,
202            /// Button background on hover.
203            hover_background: Rgba,
204            /// Button text color on hover.
205            hover_text_color: Rgba,
206            /// Button text color when pressed/active.
207            active_text_color: Rgba,
208            /// Button text color when disabled.
209            disabled_text_color: Rgba,
210        }
211        soft_option {
212            /// Button background when pressed/active.
213            active_background: Rgba,
214            /// Button background when disabled.
215            disabled_background: Rgba,
216        }
217        optional_nested {
218            /// Button label font specification.
219            font: [FontSpec, ResolvedFontSpec],
220            /// Button border specification.
221            border: [BorderSpec, ResolvedBorderSpec],
222        }
223    }
224}
225
226// ── §2.4 Text Input ──────────────────────────────────────────────────────────
227
228define_widget_pair! {
229    /// Single-line and multi-line text input fields.
230    InputTheme / ResolvedInputTheme {
231        option {
232            /// Input field background fill.
233            background_color: Rgba,
234            /// Placeholder text color.
235            placeholder_color: Rgba,
236            /// Text cursor (caret) color.
237            caret_color: Rgba,
238            /// Text selection highlight color.
239            selection_background: Rgba,
240            /// Text color inside the selection highlight.
241            selection_text_color: Rgba,
242            /// Minimum field height in logical pixels.
243            min_height as "min_height_px": f32,
244            /// Opacity multiplier when disabled (0.0-1.0).
245            disabled_opacity: f32,
246            /// Input text color when disabled.
247            disabled_text_color: Rgba,
248        }
249        soft_option {
250            /// Border color when the input is hovered.
251            hover_border_color: Rgba,
252            /// Border color when the input has focus.
253            focus_border_color: Rgba,
254            /// Input background when disabled.
255            disabled_background: Rgba,
256        }
257        optional_nested {
258            /// Input text font specification.
259            font: [FontSpec, ResolvedFontSpec],
260            /// Input border specification.
261            border: [BorderSpec, ResolvedBorderSpec],
262        }
263    }
264}
265
266// ── §2.5 Checkbox / Radio Button ────────────────────────────────────────────
267
268define_widget_pair! {
269    /// Checkbox and radio button theme: colors, indicator, label font, border, and interactive states.
270    CheckboxTheme / ResolvedCheckboxTheme {
271        option {
272            /// Checkbox background color.
273            background_color: Rgba,
274            /// Indicator background when checked.
275            checked_background: Rgba,
276            /// Indicator (check mark / radio dot) color.
277            indicator_color: Rgba,
278            /// Indicator (check mark / radio dot) width in logical pixels.
279            indicator_width as "indicator_width_px": f32,
280            /// Space between indicator and label.
281            label_gap as "label_gap_px": f32,
282            /// Opacity multiplier when disabled (0.0-1.0).
283            disabled_opacity: f32,
284            /// Checkbox label text color when disabled.
285            disabled_text_color: Rgba,
286        }
287        soft_option {
288            /// Checkbox background on hover.
289            hover_background: Rgba,
290            /// Checkbox background when disabled.
291            disabled_background: Rgba,
292            /// Indicator background when unchecked.
293            unchecked_background: Rgba,
294            /// Border color when unchecked.
295            unchecked_border_color: Rgba,
296        }
297        optional_nested {
298            /// Checkbox label font specification.
299            font: [FontSpec, ResolvedFontSpec],
300            /// Checkbox border specification.
301            border: [BorderSpec, ResolvedBorderSpec],
302        }
303    }
304}
305
306// ── §2.6 Menu ────────────────────────────────────────────────────────────────
307
308define_widget_pair! {
309    /// Popup and context menu appearance.
310    MenuTheme / ResolvedMenuTheme {
311        option {
312            /// Menu panel background fill.
313            background_color: Rgba,
314            /// Separator line color between menu items.
315            separator_color: Rgba,
316            /// Height of a single menu item row.
317            row_height as "row_height_px": f32,
318            /// Space between a menu item's icon and its label.
319            icon_text_gap as "icon_text_gap_px": f32,
320            /// Menu item icon size in logical pixels.
321            icon_size as "icon_size_px": f32,
322            /// Menu item background on hover.
323            hover_background: Rgba,
324            /// Menu item text color on hover.
325            hover_text_color: Rgba,
326            /// Disabled menu item text color.
327            disabled_text_color: Rgba,
328        }
329        optional_nested {
330            /// Menu item font specification.
331            font: [FontSpec, ResolvedFontSpec],
332        }
333        border_optional {
334            /// Menu border specification.
335            border: [BorderSpec, ResolvedBorderSpec],
336        }
337    }
338}
339
340// ── §2.7 Tooltip ─────────────────────────────────────────────────────────────
341
342define_widget_pair! {
343    /// Tooltip popup appearance.
344    TooltipTheme / ResolvedTooltipTheme {
345        option {
346            /// Tooltip background fill.
347            background_color: Rgba,
348            /// Maximum tooltip width before wrapping.
349            max_width as "max_width_px": f32,
350        }
351        optional_nested {
352            /// Tooltip font specification.
353            font: [FontSpec, ResolvedFontSpec],
354            /// Tooltip border specification.
355            border: [BorderSpec, ResolvedBorderSpec],
356        }
357    }
358}
359
360// ── §2.8 Scrollbar ───────────────────────────────────────────────────────────
361
362define_widget_pair! {
363    /// Scrollbar colors and geometry.
364    ScrollbarTheme / ResolvedScrollbarTheme {
365        option {
366            /// Scrollbar track (gutter) color.
367            track_color: Rgba,
368            /// Scrollbar thumb color.
369            thumb_color: Rgba,
370            /// Thumb color on hover.
371            thumb_hover_color: Rgba,
372            /// Scrollbar groove width in logical pixels.
373            groove_width as "groove_width_px": f32,
374            /// Minimum thumb length in logical pixels.
375            min_thumb_length as "min_thumb_length_px": f32,
376            /// Width of the thumb rail within the scrollbar.
377            thumb_width as "thumb_width_px": f32,
378            /// Whether the scrollbar overlays content instead of taking layout space.
379            overlay_mode: bool,
380        }
381        soft_option {
382            /// Thumb color when pressed/dragging.
383            thumb_active_color: Rgba,
384        }
385    }
386}
387
388// ── §2.9 Slider ──────────────────────────────────────────────────────────────
389
390define_widget_pair! {
391    /// Slider control colors and geometry.
392    SliderTheme / ResolvedSliderTheme {
393        option {
394            /// Filled portion of the slider track.
395            fill_color: Rgba,
396            /// Unfilled track color.
397            track_color: Rgba,
398            /// Thumb (handle) color.
399            thumb_color: Rgba,
400            /// Track height in logical pixels.
401            track_height as "track_height_px": f32,
402            /// Thumb diameter in logical pixels.
403            thumb_diameter as "thumb_diameter_px": f32,
404            /// Tick mark length in logical pixels.
405            tick_mark_length as "tick_mark_length_px": f32,
406            /// Opacity multiplier when disabled (0.0-1.0).
407            disabled_opacity: f32,
408        }
409        soft_option {
410            /// Thumb color on hover.
411            thumb_hover_color: Rgba,
412            /// Filled track color when disabled.
413            disabled_fill_color: Rgba,
414            /// Unfilled track color when disabled.
415            disabled_track_color: Rgba,
416            /// Thumb color when disabled.
417            disabled_thumb_color: Rgba,
418        }
419    }
420}
421
422// ── §2.10 Progress Bar ───────────────────────────────────────────────────────
423
424define_widget_pair! {
425    /// Progress bar colors and geometry.
426    ProgressBarTheme / ResolvedProgressBarTheme {
427        option {
428            /// Filled progress bar color.
429            fill_color: Rgba,
430            /// Background track color.
431            track_color: Rgba,
432            /// Bar height in logical pixels.
433            track_height as "track_height_px": f32,
434            /// Minimum bar width in logical pixels.
435            min_width as "min_width_px": f32,
436        }
437        optional_nested {
438            /// Progress bar border specification.
439            border: [BorderSpec, ResolvedBorderSpec],
440        }
441    }
442}
443
444// ── §2.11 Tab Bar ─────────────────────────────────────────────────────────────
445
446define_widget_pair! {
447    /// Tab bar colors and sizing.
448    TabTheme / ResolvedTabTheme {
449        option {
450            /// Inactive tab background.
451            background_color: Rgba,
452            /// Active (selected) tab background.
453            active_background: Rgba,
454            /// Active (selected) tab text color.
455            active_text_color: Rgba,
456            /// Tab bar strip background.
457            bar_background: Rgba,
458            /// Minimum tab width in logical pixels.
459            min_width as "min_width_px": f32,
460            /// Minimum tab height in logical pixels.
461            min_height as "min_height_px": f32,
462            /// Tab text color on hover.
463            hover_text_color: Rgba,
464        }
465        soft_option {
466            /// Tab background on hover.
467            hover_background: Rgba,
468        }
469        optional_nested {
470            /// Tab font specification.
471            font: [FontSpec, ResolvedFontSpec],
472        }
473        border_optional {
474            /// Tab border specification.
475            border: [BorderSpec, ResolvedBorderSpec],
476        }
477    }
478}
479
480// ── §2.12 Sidebar ─────────────────────────────────────────────────────────────
481
482define_widget_pair! {
483    /// Sidebar panel background, selection, and hover colors.
484    SidebarTheme / ResolvedSidebarTheme {
485        option {
486            /// Sidebar panel background fill.
487            background_color: Rgba,
488            /// Selected item background color.
489            selection_background: Rgba,
490            /// Selected item text color.
491            selection_text_color: Rgba,
492            /// Hovered item background color.
493            hover_background: Rgba,
494        }
495        optional_nested {
496            /// Sidebar font specification.
497            font: [FontSpec, ResolvedFontSpec],
498        }
499        border_partial {
500            /// Sidebar border specification.
501            border: [BorderSpec, ResolvedBorderSpec],
502        }
503    }
504}
505
506// ── §2.13 Toolbar ─────────────────────────────────────────────────────────────
507
508define_widget_pair! {
509    /// Toolbar sizing, spacing, and font.
510    ToolbarTheme / ResolvedToolbarTheme {
511        option {
512            /// Toolbar background color.
513            background_color: Rgba,
514            /// Toolbar height in logical pixels.
515            bar_height as "bar_height_px": f32,
516            /// Horizontal space between toolbar items.
517            item_gap as "item_gap_px": f32,
518            /// Toolbar icon size in logical pixels.
519            icon_size as "icon_size_px": f32,
520        }
521        optional_nested {
522            /// Toolbar label font specification.
523            font: [FontSpec, ResolvedFontSpec],
524            /// Toolbar border specification.
525            border: [BorderSpec, ResolvedBorderSpec],
526        }
527    }
528}
529
530// ── §2.14 Status Bar ──────────────────────────────────────────────────────────
531
532define_widget_pair! {
533    /// Status bar font and background.
534    StatusBarTheme / ResolvedStatusBarTheme {
535        option {
536            /// Status bar background color.
537            background_color: Rgba,
538        }
539        optional_nested {
540            /// Status bar font specification.
541            font: [FontSpec, ResolvedFontSpec],
542        }
543        border_partial {
544            /// Status bar border specification.
545            border: [BorderSpec, ResolvedBorderSpec],
546        }
547    }
548}
549
550// ── §2.15 List / Table ────────────────────────────────────────────────────────
551
552define_widget_pair! {
553    /// List and table colors and row geometry.
554    ListTheme / ResolvedListTheme {
555        option {
556            /// List background fill.
557            background_color: Rgba,
558            /// Alternate row background for striped lists.
559            alternate_row_background: Rgba,
560            /// Selected row highlight color.
561            selection_background: Rgba,
562            /// Text color inside a selected row.
563            selection_text_color: Rgba,
564            /// Column header background fill.
565            header_background: Rgba,
566            /// Grid line color between rows/columns.
567            grid_color: Rgba,
568            /// Row height in logical pixels.
569            row_height as "row_height_px": f32,
570            /// Hovered row background color.
571            hover_background: Rgba,
572            /// Hovered row text color.
573            hover_text_color: Rgba,
574            /// Disabled row text color.
575            disabled_text_color: Rgba,
576        }
577        optional_nested {
578            /// List item font specification.
579            item_font: [FontSpec, ResolvedFontSpec],
580            /// Column header font specification.
581            header_font: [FontSpec, ResolvedFontSpec],
582            /// List border specification.
583            border: [BorderSpec, ResolvedBorderSpec],
584        }
585    }
586}
587
588// ── §2.16 Popover / Dropdown ──────────────────────────────────────────────────
589
590define_widget_pair! {
591    /// Popover / dropdown panel appearance.
592    PopoverTheme / ResolvedPopoverTheme {
593        option {
594            /// Panel background fill.
595            background_color: Rgba,
596        }
597        optional_nested {
598            /// Popover font specification.
599            font: [FontSpec, ResolvedFontSpec],
600            /// Popover border specification.
601            border: [BorderSpec, ResolvedBorderSpec],
602        }
603    }
604}
605
606// ── §2.17 Splitter ────────────────────────────────────────────────────────────
607
608define_widget_pair! {
609    /// Splitter handle width and color.
610    SplitterTheme / ResolvedSplitterTheme {
611        option {
612            /// Handle width in logical pixels.
613            divider_width as "divider_width_px": f32,
614            /// Divider color.
615            divider_color: Rgba,
616            /// Divider color on hover.
617            hover_color: Rgba,
618        }
619    }
620}
621
622// ── §2.18 Separator ───────────────────────────────────────────────────────────
623
624define_widget_pair! {
625    /// Separator line color and width.
626    SeparatorTheme / ResolvedSeparatorTheme {
627        option {
628            /// Separator line color.
629            line_color: Rgba,
630            /// Separator line width in logical pixels.
631            line_width as "line_width_px": f32,
632        }
633    }
634}
635
636// ── §2.21 Switch / Toggle ─────────────────────────────────────────────────────
637
638define_widget_pair! {
639    /// Toggle switch track, thumb, geometry, and interactive states.
640    SwitchTheme / ResolvedSwitchTheme {
641        option {
642            /// Track background when the switch is on.
643            checked_background: Rgba,
644            /// Track background when the switch is off.
645            unchecked_background: Rgba,
646            /// Thumb (knob) color.
647            thumb_background: Rgba,
648            /// Track width in logical pixels.
649            track_width as "track_width_px": f32,
650            /// Track height in logical pixels.
651            track_height as "track_height_px": f32,
652            /// Thumb diameter in logical pixels.
653            thumb_diameter as "thumb_diameter_px": f32,
654            /// Track corner radius in logical pixels.
655            track_radius as "track_radius_px": f32,
656            /// Opacity multiplier when disabled (0.0-1.0).
657            disabled_opacity: f32,
658        }
659        soft_option {
660            /// Track hover color when checked (on).
661            hover_checked_background: Rgba,
662            /// Track hover color when unchecked (off).
663            hover_unchecked_background: Rgba,
664            /// Track color when disabled and checked.
665            disabled_checked_background: Rgba,
666            /// Track color when disabled and unchecked.
667            disabled_unchecked_background: Rgba,
668            /// Thumb color when disabled.
669            disabled_thumb_color: Rgba,
670        }
671    }
672}
673
674// ── §2.22 Dialog ──────────────────────────────────────────────────────────────
675
676define_widget_pair! {
677    /// Dialog sizing, spacing, button order, fonts, border, and background.
678    DialogTheme / ResolvedDialogTheme {
679        option {
680            /// Dialog background color.
681            background_color: Rgba,
682            /// Minimum dialog width in logical pixels.
683            min_width as "min_width_px": f32,
684            /// Maximum dialog width in logical pixels.
685            max_width as "max_width_px": f32,
686            /// Minimum dialog height in logical pixels.
687            min_height as "min_height_px": f32,
688            /// Maximum dialog height in logical pixels.
689            max_height as "max_height_px": f32,
690            /// Horizontal space between dialog buttons.
691            button_gap as "button_gap_px": f32,
692            /// Icon size for dialog type icons (warning, error, etc.).
693            icon_size as "icon_size_px": f32,
694            /// Platform button order convention (e.g., OK/Cancel vs Cancel/OK).
695            button_order: DialogButtonOrder,
696        }
697        optional_nested {
698            /// Dialog title font specification.
699            title_font: [FontSpec, ResolvedFontSpec],
700            /// Dialog body font specification.
701            body_font: [FontSpec, ResolvedFontSpec],
702            /// Dialog border specification.
703            border: [BorderSpec, ResolvedBorderSpec],
704        }
705    }
706}
707
708// ── §2.23 Spinner / Progress Ring ─────────────────────────────────────────────
709
710define_widget_pair! {
711    /// Spinner / indeterminate progress indicator.
712    SpinnerTheme / ResolvedSpinnerTheme {
713        option {
714            /// Spinner arc fill color.
715            fill_color: Rgba,
716            /// Spinner outer diameter in logical pixels.
717            diameter as "diameter_px": f32,
718            /// Minimum rendered size in logical pixels.
719            min_diameter as "min_diameter_px": f32,
720            /// Arc stroke width in logical pixels.
721            stroke_width as "stroke_width_px": f32,
722        }
723    }
724}
725
726// ── §2.24 ComboBox / Dropdown Trigger ─────────────────────────────────────────
727
728define_widget_pair! {
729    /// ComboBox / dropdown trigger sizing.
730    ComboBoxTheme / ResolvedComboBoxTheme {
731        option {
732            /// ComboBox background color.
733            background_color: Rgba,
734            /// Minimum trigger height in logical pixels.
735            min_height as "min_height_px": f32,
736            /// Minimum trigger width in logical pixels.
737            min_width as "min_width_px": f32,
738            /// Dropdown arrow size in logical pixels.
739            arrow_icon_size as "arrow_icon_size_px": f32,
740            /// Width of the arrow clickable area.
741            arrow_area_width as "arrow_area_width_px": f32,
742            /// Opacity multiplier when disabled (0.0-1.0).
743            disabled_opacity: f32,
744            /// ComboBox text color when disabled.
745            disabled_text_color: Rgba,
746        }
747        soft_option {
748            /// ComboBox background on hover.
749            hover_background: Rgba,
750            /// ComboBox background when disabled.
751            disabled_background: Rgba,
752        }
753        optional_nested {
754            /// ComboBox font specification.
755            font: [FontSpec, ResolvedFontSpec],
756            /// ComboBox border specification.
757            border: [BorderSpec, ResolvedBorderSpec],
758        }
759    }
760}
761
762// ── §2.25 Segmented Control ───────────────────────────────────────────────────
763
764define_widget_pair! {
765    /// Segmented control sizing (macOS-primary; KDE uses tab bar metrics as proxy).
766    SegmentedControlTheme / ResolvedSegmentedControlTheme {
767        option {
768            /// Segmented control background color.
769            background_color: Rgba,
770            /// Active segment background.
771            active_background: Rgba,
772            /// Active segment text color.
773            active_text_color: Rgba,
774            /// Segment height in logical pixels.
775            segment_height as "segment_height_px": f32,
776            /// Width of the separator between segments.
777            separator_width as "separator_width_px": f32,
778            /// Opacity multiplier when disabled (0.0-1.0).
779            disabled_opacity: f32,
780        }
781        soft_option {
782            /// Segment background on hover.
783            hover_background: Rgba,
784        }
785        optional_nested {
786            /// Segmented control font specification.
787            font: [FontSpec, ResolvedFontSpec],
788            /// Segmented control border specification.
789            border: [BorderSpec, ResolvedBorderSpec],
790        }
791    }
792}
793
794// ── §2.26 Card / Container ────────────────────────────────────────────────────
795
796define_widget_pair! {
797    /// Card / container colors and geometry.
798    CardTheme / ResolvedCardTheme {
799        option {
800            /// Card background fill.
801            background_color: Rgba,
802        }
803        border_optional {
804            /// Card border specification.
805            border: [BorderSpec, ResolvedBorderSpec],
806        }
807    }
808}
809
810// ── §2.27 Expander / Disclosure ───────────────────────────────────────────────
811
812define_widget_pair! {
813    /// Expander / disclosure row geometry.
814    ExpanderTheme / ResolvedExpanderTheme {
815        option {
816            /// Collapsed header row height in logical pixels.
817            header_height as "header_height_px": f32,
818            /// Disclosure arrow size in logical pixels.
819            arrow_icon_size as "arrow_icon_size_px": f32,
820        }
821        soft_option {
822            /// Expander header background on hover.
823            hover_background: Rgba,
824            /// Disclosure arrow/chevron color.
825            arrow_color: Rgba,
826        }
827        optional_nested {
828            /// Expander font specification.
829            font: [FontSpec, ResolvedFontSpec],
830            /// Expander border specification.
831            border: [BorderSpec, ResolvedBorderSpec],
832        }
833    }
834}
835
836// ── §2.28 Link ────────────────────────────────────────────────────────────────
837
838define_widget_pair! {
839    /// Hyperlink colors and underline setting.
840    LinkTheme / ResolvedLinkTheme {
841        option {
842            /// Visited link text color.
843            visited_text_color: Rgba,
844            /// Whether links are underlined.
845            underline_enabled: bool,
846            /// Link background fill (typically transparent).
847            background_color: Rgba,
848            /// Link background on hover.
849            hover_background: Rgba,
850            /// Link text color on hover.
851            hover_text_color: Rgba,
852            /// Link text color when pressed/active.
853            active_text_color: Rgba,
854            /// Link text color when disabled.
855            disabled_text_color: Rgba,
856        }
857        optional_nested {
858            /// Link font specification.
859            font: [FontSpec, ResolvedFontSpec],
860        }
861    }
862}
863
864// -- Layout (top-level, not per-variant) ------------------------------------------
865
866define_widget_pair! {
867    /// Layout spacing constants shared between light and dark variants.
868    ///
869    /// Unlike other widget themes, LayoutTheme lives on [`crate::ThemeSpec`] (top-level)
870    /// rather than [`crate::ThemeVariant`] because spacing is variant-independent.
871    LayoutTheme / ResolvedLayoutTheme {
872        option {
873            /// Space between adjacent widgets in logical pixels.
874            widget_gap as "widget_gap_px": f32,
875            /// Padding inside containers in logical pixels.
876            container_margin as "container_margin_px": f32,
877            /// Padding inside the main window in logical pixels.
878            window_margin as "window_margin_px": f32,
879            /// Space between major content sections in logical pixels.
880            section_gap as "section_gap_px": f32,
881        }
882    }
883}
884
885// --- Per-widget range checks (called from validate()) ---
886
887use crate::resolve::validate_helpers::{
888    check_min_max, check_non_negative, check_positive, check_range_f32, check_range_u16,
889};
890
891impl ResolvedWindowTheme {
892    pub(crate) fn check_ranges(&self, prefix: &str, errors: &mut Vec<String>) {
893        check_positive(
894            self.title_bar_font.size,
895            &format!("{prefix}.title_bar_font.size"),
896            errors,
897        );
898        check_range_u16(
899            self.title_bar_font.weight,
900            100,
901            900,
902            &format!("{prefix}.title_bar_font.weight"),
903            errors,
904        );
905    }
906}
907
908impl ResolvedButtonTheme {
909    pub(crate) fn check_ranges(&self, prefix: &str, errors: &mut Vec<String>) {
910        check_non_negative(self.min_width, &format!("{prefix}.min_width"), errors);
911        check_non_negative(self.min_height, &format!("{prefix}.min_height"), errors);
912        check_non_negative(
913            self.icon_text_gap,
914            &format!("{prefix}.icon_text_gap"),
915            errors,
916        );
917        check_range_f32(
918            self.disabled_opacity,
919            0.0,
920            1.0,
921            &format!("{prefix}.disabled_opacity"),
922            errors,
923        );
924        check_positive(self.font.size, &format!("{prefix}.font.size"), errors);
925        check_range_u16(
926            self.font.weight,
927            100,
928            900,
929            &format!("{prefix}.font.weight"),
930            errors,
931        );
932    }
933}
934
935impl ResolvedInputTheme {
936    pub(crate) fn check_ranges(&self, prefix: &str, errors: &mut Vec<String>) {
937        check_non_negative(self.min_height, &format!("{prefix}.min_height"), errors);
938        check_range_f32(
939            self.disabled_opacity,
940            0.0,
941            1.0,
942            &format!("{prefix}.disabled_opacity"),
943            errors,
944        );
945        check_positive(self.font.size, &format!("{prefix}.font.size"), errors);
946        check_range_u16(
947            self.font.weight,
948            100,
949            900,
950            &format!("{prefix}.font.weight"),
951            errors,
952        );
953    }
954}
955
956impl ResolvedCheckboxTheme {
957    pub(crate) fn check_ranges(&self, prefix: &str, errors: &mut Vec<String>) {
958        check_non_negative(
959            self.indicator_width,
960            &format!("{prefix}.indicator_width"),
961            errors,
962        );
963        check_non_negative(self.label_gap, &format!("{prefix}.label_gap"), errors);
964        check_range_f32(
965            self.disabled_opacity,
966            0.0,
967            1.0,
968            &format!("{prefix}.disabled_opacity"),
969            errors,
970        );
971        check_positive(self.font.size, &format!("{prefix}.font.size"), errors);
972        check_range_u16(
973            self.font.weight,
974            100,
975            900,
976            &format!("{prefix}.font.weight"),
977            errors,
978        );
979    }
980}
981
982impl ResolvedMenuTheme {
983    pub(crate) fn check_ranges(&self, prefix: &str, errors: &mut Vec<String>) {
984        check_non_negative(self.row_height, &format!("{prefix}.row_height"), errors);
985        check_non_negative(
986            self.icon_text_gap,
987            &format!("{prefix}.icon_text_gap"),
988            errors,
989        );
990        check_non_negative(self.icon_size, &format!("{prefix}.icon_size"), errors);
991        check_positive(self.font.size, &format!("{prefix}.font.size"), errors);
992        check_range_u16(
993            self.font.weight,
994            100,
995            900,
996            &format!("{prefix}.font.weight"),
997            errors,
998        );
999    }
1000}
1001
1002impl ResolvedTooltipTheme {
1003    pub(crate) fn check_ranges(&self, prefix: &str, errors: &mut Vec<String>) {
1004        check_non_negative(self.max_width, &format!("{prefix}.max_width"), errors);
1005        check_positive(self.font.size, &format!("{prefix}.font.size"), errors);
1006        check_range_u16(
1007            self.font.weight,
1008            100,
1009            900,
1010            &format!("{prefix}.font.weight"),
1011            errors,
1012        );
1013    }
1014}
1015
1016impl ResolvedScrollbarTheme {
1017    pub(crate) fn check_ranges(&self, prefix: &str, errors: &mut Vec<String>) {
1018        check_non_negative(self.groove_width, &format!("{prefix}.groove_width"), errors);
1019        check_non_negative(
1020            self.min_thumb_length,
1021            &format!("{prefix}.min_thumb_length"),
1022            errors,
1023        );
1024        check_non_negative(self.thumb_width, &format!("{prefix}.thumb_width"), errors);
1025    }
1026}
1027
1028impl ResolvedSliderTheme {
1029    pub(crate) fn check_ranges(&self, prefix: &str, errors: &mut Vec<String>) {
1030        check_non_negative(self.track_height, &format!("{prefix}.track_height"), errors);
1031        check_non_negative(
1032            self.thumb_diameter,
1033            &format!("{prefix}.thumb_diameter"),
1034            errors,
1035        );
1036        check_non_negative(
1037            self.tick_mark_length,
1038            &format!("{prefix}.tick_mark_length"),
1039            errors,
1040        );
1041        check_range_f32(
1042            self.disabled_opacity,
1043            0.0,
1044            1.0,
1045            &format!("{prefix}.disabled_opacity"),
1046            errors,
1047        );
1048    }
1049}
1050
1051impl ResolvedProgressBarTheme {
1052    pub(crate) fn check_ranges(&self, prefix: &str, errors: &mut Vec<String>) {
1053        check_non_negative(self.track_height, &format!("{prefix}.track_height"), errors);
1054        check_non_negative(self.min_width, &format!("{prefix}.min_width"), errors);
1055    }
1056}
1057
1058impl ResolvedTabTheme {
1059    pub(crate) fn check_ranges(&self, prefix: &str, errors: &mut Vec<String>) {
1060        check_non_negative(self.min_width, &format!("{prefix}.min_width"), errors);
1061        check_non_negative(self.min_height, &format!("{prefix}.min_height"), errors);
1062        check_positive(self.font.size, &format!("{prefix}.font.size"), errors);
1063        check_range_u16(
1064            self.font.weight,
1065            100,
1066            900,
1067            &format!("{prefix}.font.weight"),
1068            errors,
1069        );
1070    }
1071}
1072
1073impl ResolvedSidebarTheme {
1074    pub(crate) fn check_ranges(&self, prefix: &str, errors: &mut Vec<String>) {
1075        check_positive(self.font.size, &format!("{prefix}.font.size"), errors);
1076        check_range_u16(
1077            self.font.weight,
1078            100,
1079            900,
1080            &format!("{prefix}.font.weight"),
1081            errors,
1082        );
1083    }
1084}
1085
1086impl ResolvedToolbarTheme {
1087    pub(crate) fn check_ranges(&self, prefix: &str, errors: &mut Vec<String>) {
1088        check_non_negative(self.bar_height, &format!("{prefix}.bar_height"), errors);
1089        check_non_negative(self.item_gap, &format!("{prefix}.item_gap"), errors);
1090        check_non_negative(self.icon_size, &format!("{prefix}.icon_size"), errors);
1091        check_positive(self.font.size, &format!("{prefix}.font.size"), errors);
1092        check_range_u16(
1093            self.font.weight,
1094            100,
1095            900,
1096            &format!("{prefix}.font.weight"),
1097            errors,
1098        );
1099    }
1100}
1101
1102impl ResolvedStatusBarTheme {
1103    pub(crate) fn check_ranges(&self, prefix: &str, errors: &mut Vec<String>) {
1104        check_positive(self.font.size, &format!("{prefix}.font.size"), errors);
1105        check_range_u16(
1106            self.font.weight,
1107            100,
1108            900,
1109            &format!("{prefix}.font.weight"),
1110            errors,
1111        );
1112    }
1113}
1114
1115impl ResolvedListTheme {
1116    pub(crate) fn check_ranges(&self, prefix: &str, errors: &mut Vec<String>) {
1117        check_non_negative(self.row_height, &format!("{prefix}.row_height"), errors);
1118        check_positive(
1119            self.item_font.size,
1120            &format!("{prefix}.item_font.size"),
1121            errors,
1122        );
1123        check_range_u16(
1124            self.item_font.weight,
1125            100,
1126            900,
1127            &format!("{prefix}.item_font.weight"),
1128            errors,
1129        );
1130        check_positive(
1131            self.header_font.size,
1132            &format!("{prefix}.header_font.size"),
1133            errors,
1134        );
1135        check_range_u16(
1136            self.header_font.weight,
1137            100,
1138            900,
1139            &format!("{prefix}.header_font.weight"),
1140            errors,
1141        );
1142    }
1143}
1144
1145impl ResolvedPopoverTheme {
1146    pub(crate) fn check_ranges(&self, prefix: &str, errors: &mut Vec<String>) {
1147        check_positive(self.font.size, &format!("{prefix}.font.size"), errors);
1148        check_range_u16(
1149            self.font.weight,
1150            100,
1151            900,
1152            &format!("{prefix}.font.weight"),
1153            errors,
1154        );
1155    }
1156}
1157
1158impl ResolvedSplitterTheme {
1159    pub(crate) fn check_ranges(&self, prefix: &str, errors: &mut Vec<String>) {
1160        check_non_negative(
1161            self.divider_width,
1162            &format!("{prefix}.divider_width"),
1163            errors,
1164        );
1165    }
1166}
1167
1168impl ResolvedSeparatorTheme {
1169    pub(crate) fn check_ranges(&self, prefix: &str, errors: &mut Vec<String>) {
1170        check_non_negative(self.line_width, &format!("{prefix}.line_width"), errors);
1171    }
1172}
1173
1174impl ResolvedSwitchTheme {
1175    pub(crate) fn check_ranges(&self, prefix: &str, errors: &mut Vec<String>) {
1176        check_non_negative(self.track_width, &format!("{prefix}.track_width"), errors);
1177        check_non_negative(self.track_height, &format!("{prefix}.track_height"), errors);
1178        check_non_negative(
1179            self.thumb_diameter,
1180            &format!("{prefix}.thumb_diameter"),
1181            errors,
1182        );
1183        check_non_negative(self.track_radius, &format!("{prefix}.track_radius"), errors);
1184        check_range_f32(
1185            self.disabled_opacity,
1186            0.0,
1187            1.0,
1188            &format!("{prefix}.disabled_opacity"),
1189            errors,
1190        );
1191    }
1192}
1193
1194impl ResolvedDialogTheme {
1195    pub(crate) fn check_ranges(&self, prefix: &str, errors: &mut Vec<String>) {
1196        check_non_negative(self.min_width, &format!("{prefix}.min_width"), errors);
1197        check_non_negative(self.max_width, &format!("{prefix}.max_width"), errors);
1198        check_non_negative(self.min_height, &format!("{prefix}.min_height"), errors);
1199        check_non_negative(self.max_height, &format!("{prefix}.max_height"), errors);
1200        check_non_negative(self.button_gap, &format!("{prefix}.button_gap"), errors);
1201        check_non_negative(self.icon_size, &format!("{prefix}.icon_size"), errors);
1202        check_positive(
1203            self.title_font.size,
1204            &format!("{prefix}.title_font.size"),
1205            errors,
1206        );
1207        check_range_u16(
1208            self.title_font.weight,
1209            100,
1210            900,
1211            &format!("{prefix}.title_font.weight"),
1212            errors,
1213        );
1214        check_positive(
1215            self.body_font.size,
1216            &format!("{prefix}.body_font.size"),
1217            errors,
1218        );
1219        check_range_u16(
1220            self.body_font.weight,
1221            100,
1222            900,
1223            &format!("{prefix}.body_font.weight"),
1224            errors,
1225        );
1226        check_min_max(
1227            self.min_width,
1228            self.max_width,
1229            &format!("{prefix}.min_width"),
1230            &format!("{prefix}.max_width"),
1231            errors,
1232        );
1233        check_min_max(
1234            self.min_height,
1235            self.max_height,
1236            &format!("{prefix}.min_height"),
1237            &format!("{prefix}.max_height"),
1238            errors,
1239        );
1240    }
1241}
1242
1243impl ResolvedSpinnerTheme {
1244    pub(crate) fn check_ranges(&self, prefix: &str, errors: &mut Vec<String>) {
1245        check_non_negative(self.diameter, &format!("{prefix}.diameter"), errors);
1246        check_non_negative(self.min_diameter, &format!("{prefix}.min_diameter"), errors);
1247        check_non_negative(self.stroke_width, &format!("{prefix}.stroke_width"), errors);
1248    }
1249}
1250
1251impl ResolvedLinkTheme {
1252    pub(crate) fn check_ranges(&self, prefix: &str, errors: &mut Vec<String>) {
1253        check_positive(self.font.size, &format!("{prefix}.font.size"), errors);
1254        check_range_u16(
1255            self.font.weight,
1256            100,
1257            900,
1258            &format!("{prefix}.font.weight"),
1259            errors,
1260        );
1261    }
1262}
1263
1264impl ResolvedComboBoxTheme {
1265    pub(crate) fn check_ranges(&self, prefix: &str, errors: &mut Vec<String>) {
1266        check_non_negative(self.min_height, &format!("{prefix}.min_height"), errors);
1267        check_non_negative(self.min_width, &format!("{prefix}.min_width"), errors);
1268        check_non_negative(
1269            self.arrow_icon_size,
1270            &format!("{prefix}.arrow_icon_size"),
1271            errors,
1272        );
1273        check_non_negative(
1274            self.arrow_area_width,
1275            &format!("{prefix}.arrow_area_width"),
1276            errors,
1277        );
1278        check_range_f32(
1279            self.disabled_opacity,
1280            0.0,
1281            1.0,
1282            &format!("{prefix}.disabled_opacity"),
1283            errors,
1284        );
1285        check_positive(self.font.size, &format!("{prefix}.font.size"), errors);
1286        check_range_u16(
1287            self.font.weight,
1288            100,
1289            900,
1290            &format!("{prefix}.font.weight"),
1291            errors,
1292        );
1293    }
1294}
1295
1296impl ResolvedSegmentedControlTheme {
1297    pub(crate) fn check_ranges(&self, prefix: &str, errors: &mut Vec<String>) {
1298        check_non_negative(
1299            self.segment_height,
1300            &format!("{prefix}.segment_height"),
1301            errors,
1302        );
1303        check_non_negative(
1304            self.separator_width,
1305            &format!("{prefix}.separator_width"),
1306            errors,
1307        );
1308        check_range_f32(
1309            self.disabled_opacity,
1310            0.0,
1311            1.0,
1312            &format!("{prefix}.disabled_opacity"),
1313            errors,
1314        );
1315        check_positive(self.font.size, &format!("{prefix}.font.size"), errors);
1316        check_range_u16(
1317            self.font.weight,
1318            100,
1319            900,
1320            &format!("{prefix}.font.weight"),
1321            errors,
1322        );
1323    }
1324}
1325
1326impl ResolvedExpanderTheme {
1327    pub(crate) fn check_ranges(&self, prefix: &str, errors: &mut Vec<String>) {
1328        check_non_negative(
1329            self.header_height,
1330            &format!("{prefix}.header_height"),
1331            errors,
1332        );
1333        check_non_negative(
1334            self.arrow_icon_size,
1335            &format!("{prefix}.arrow_icon_size"),
1336            errors,
1337        );
1338        check_positive(self.font.size, &format!("{prefix}.font.size"), errors);
1339        check_range_u16(
1340            self.font.weight,
1341            100,
1342            900,
1343            &format!("{prefix}.font.weight"),
1344            errors,
1345        );
1346    }
1347}
1348
1349#[cfg(test)]
1350#[allow(clippy::unwrap_used, clippy::expect_used, dead_code)]
1351mod tests {
1352    use super::*;
1353    use crate::Rgba;
1354    use crate::model::border::{BorderSpec, ResolvedBorderSpec};
1355    use crate::model::font::FontSize;
1356    use crate::model::{DialogButtonOrder, FontSpec};
1357
1358    // Define a test widget pair using the macro (validates macro itself still works)
1359    define_widget_pair! {
1360        /// Test widget for macro verification.
1361        TestWidget / ResolvedTestWidget {
1362            option {
1363                size: f32,
1364                label: String,
1365            }
1366            optional_nested {
1367                font: [FontSpec, ResolvedFontSpec],
1368            }
1369        }
1370    }
1371
1372    // === ResolvedFontSpec tests ===
1373
1374    #[test]
1375    fn resolved_font_spec_fields_are_concrete() {
1376        let rfs = ResolvedFontSpec {
1377            family: "Inter".into(),
1378            size: 14.0,
1379            weight: 400,
1380            style: crate::model::font::FontStyle::Normal,
1381            color: crate::Rgba::rgb(0, 0, 0),
1382        };
1383        assert_eq!(rfs.family, "Inter");
1384        assert_eq!(rfs.size, 14.0);
1385        assert_eq!(rfs.weight, 400);
1386    }
1387
1388    // === define_widget_pair! generated struct tests ===
1389
1390    #[test]
1391    fn generated_option_struct_has_option_fields() {
1392        let w = TestWidget::default();
1393        assert!(w.size.is_none());
1394        assert!(w.label.is_none());
1395        assert!(w.font.is_none());
1396    }
1397
1398    #[test]
1399    fn generated_option_struct_is_empty_by_default() {
1400        assert!(TestWidget::default().is_empty());
1401    }
1402
1403    #[test]
1404    fn generated_option_struct_not_empty_when_size_set() {
1405        let w = TestWidget {
1406            size: Some(24.0),
1407            ..Default::default()
1408        };
1409        assert!(!w.is_empty());
1410    }
1411
1412    #[test]
1413    fn generated_option_struct_not_empty_when_font_set() {
1414        let w = TestWidget {
1415            font: Some(FontSpec {
1416                size: Some(FontSize::Px(14.0)),
1417                ..Default::default()
1418            }),
1419            ..Default::default()
1420        };
1421        assert!(!w.is_empty());
1422    }
1423
1424    #[test]
1425    fn generated_resolved_struct_has_concrete_fields() {
1426        let resolved = ResolvedTestWidget {
1427            size: 24.0,
1428            label: "Click me".into(),
1429            font: ResolvedFontSpec {
1430                family: "Inter".into(),
1431                size: 14.0,
1432                weight: 400,
1433                style: crate::model::font::FontStyle::Normal,
1434                color: crate::Rgba::rgb(0, 0, 0),
1435            },
1436        };
1437        assert_eq!(resolved.size, 24.0);
1438        assert_eq!(resolved.label, "Click me");
1439        assert_eq!(resolved.font.family, "Inter");
1440    }
1441
1442    // === merge tests for generated structs ===
1443
1444    #[test]
1445    fn generated_merge_option_field_overlay_wins() {
1446        let mut base = TestWidget {
1447            size: Some(20.0),
1448            ..Default::default()
1449        };
1450        let overlay = TestWidget {
1451            size: Some(24.0),
1452            ..Default::default()
1453        };
1454        base.merge(&overlay);
1455        assert_eq!(base.size, Some(24.0));
1456    }
1457
1458    #[test]
1459    fn generated_merge_option_field_none_preserves_base() {
1460        let mut base = TestWidget {
1461            size: Some(20.0),
1462            ..Default::default()
1463        };
1464        let overlay = TestWidget::default();
1465        base.merge(&overlay);
1466        assert_eq!(base.size, Some(20.0));
1467    }
1468
1469    #[test]
1470    fn generated_merge_optional_nested_both_some_merges_inner() {
1471        let mut base = TestWidget {
1472            font: Some(FontSpec {
1473                family: Some("Noto Sans".into()),
1474                size: Some(FontSize::Px(12.0)),
1475                weight: None,
1476                ..Default::default()
1477            }),
1478            ..Default::default()
1479        };
1480        let overlay = TestWidget {
1481            font: Some(FontSpec {
1482                family: None,
1483                size: None,
1484                weight: Some(700),
1485                ..Default::default()
1486            }),
1487            ..Default::default()
1488        };
1489        base.merge(&overlay);
1490        let font = base.font.as_ref().unwrap();
1491        assert_eq!(font.family.as_deref(), Some("Noto Sans")); // preserved
1492        assert_eq!(font.size, Some(FontSize::Px(12.0))); // preserved
1493        assert_eq!(font.weight, Some(700)); // overlay sets
1494    }
1495
1496    #[test]
1497    fn generated_merge_optional_nested_none_plus_some_clones() {
1498        let mut base = TestWidget::default();
1499        let overlay = TestWidget {
1500            font: Some(FontSpec {
1501                family: Some("Inter".into()),
1502                size: Some(FontSize::Px(14.0)),
1503                weight: Some(400),
1504                ..Default::default()
1505            }),
1506            ..Default::default()
1507        };
1508        base.merge(&overlay);
1509        let font = base.font.as_ref().unwrap();
1510        assert_eq!(font.family.as_deref(), Some("Inter"));
1511        assert_eq!(font.size, Some(FontSize::Px(14.0)));
1512        assert_eq!(font.weight, Some(400));
1513    }
1514
1515    #[test]
1516    fn generated_merge_optional_nested_some_plus_none_preserves_base() {
1517        let mut base = TestWidget {
1518            font: Some(FontSpec {
1519                family: Some("Inter".into()),
1520                size: Some(FontSize::Px(14.0)),
1521                weight: Some(400),
1522                ..Default::default()
1523            }),
1524            ..Default::default()
1525        };
1526        let overlay = TestWidget::default();
1527        base.merge(&overlay);
1528        let font = base.font.as_ref().unwrap();
1529        assert_eq!(font.family.as_deref(), Some("Inter"));
1530    }
1531
1532    #[test]
1533    fn generated_merge_optional_nested_none_plus_none_stays_none() {
1534        let mut base = TestWidget::default();
1535        let overlay = TestWidget::default();
1536        base.merge(&overlay);
1537        assert!(base.font.is_none());
1538    }
1539
1540    // === impl_merge! optional_nested clause direct tests ===
1541
1542    // Verify the optional_nested clause directly on a FontSpec-containing struct
1543    #[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1544    struct WithFont {
1545        name: Option<String>,
1546        font: Option<FontSpec>,
1547    }
1548
1549    impl_merge!(WithFont {
1550        option { name }
1551        optional_nested { font }
1552    });
1553
1554    #[test]
1555    fn impl_merge_optional_nested_none_none_stays_none() {
1556        let mut base = WithFont::default();
1557        let overlay = WithFont::default();
1558        base.merge(&overlay);
1559        assert!(base.font.is_none());
1560    }
1561
1562    #[test]
1563    fn impl_merge_optional_nested_some_none_preserves_base() {
1564        let mut base = WithFont {
1565            font: Some(FontSpec {
1566                size: Some(FontSize::Px(12.0)),
1567                ..Default::default()
1568            }),
1569            ..Default::default()
1570        };
1571        let overlay = WithFont::default();
1572        base.merge(&overlay);
1573        assert_eq!(base.font.as_ref().unwrap().size, Some(FontSize::Px(12.0)));
1574    }
1575
1576    #[test]
1577    fn impl_merge_optional_nested_none_some_clones_overlay() {
1578        let mut base = WithFont::default();
1579        let overlay = WithFont {
1580            font: Some(FontSpec {
1581                family: Some("Inter".into()),
1582                ..Default::default()
1583            }),
1584            ..Default::default()
1585        };
1586        base.merge(&overlay);
1587        assert_eq!(base.font.as_ref().unwrap().family.as_deref(), Some("Inter"));
1588    }
1589
1590    #[test]
1591    fn impl_merge_optional_nested_some_some_merges_inner() {
1592        let mut base = WithFont {
1593            font: Some(FontSpec {
1594                family: Some("Noto".into()),
1595                size: Some(FontSize::Px(11.0)),
1596                weight: None,
1597                ..Default::default()
1598            }),
1599            ..Default::default()
1600        };
1601        let overlay = WithFont {
1602            font: Some(FontSpec {
1603                family: None,
1604                size: Some(FontSize::Px(14.0)),
1605                weight: Some(400),
1606                ..Default::default()
1607            }),
1608            ..Default::default()
1609        };
1610        base.merge(&overlay);
1611        let f = base.font.as_ref().unwrap();
1612        assert_eq!(f.family.as_deref(), Some("Noto")); // preserved
1613        assert_eq!(f.size, Some(FontSize::Px(14.0))); // overlay wins
1614        assert_eq!(f.weight, Some(400)); // overlay sets
1615    }
1616
1617    #[test]
1618    fn impl_merge_optional_nested_is_empty_none() {
1619        let w = WithFont::default();
1620        assert!(w.is_empty());
1621    }
1622
1623    #[test]
1624    fn impl_merge_optional_nested_is_empty_some_default() {
1625        // Some(FontSpec::default()) with all-None sub-fields counts as empty (D-2 fix).
1626        let w = WithFont {
1627            font: Some(FontSpec::default()),
1628            ..Default::default()
1629        };
1630        assert!(w.is_empty());
1631    }
1632
1633    #[test]
1634    fn impl_merge_optional_nested_is_not_empty_when_populated() {
1635        let w = WithFont {
1636            font: Some(FontSpec {
1637                size: Some(FontSize::Px(14.0)),
1638                ..Default::default()
1639            }),
1640            ..Default::default()
1641        };
1642        assert!(!w.is_empty());
1643    }
1644
1645    // === ButtonTheme tests ===
1646
1647    #[test]
1648    fn button_theme_default_is_empty() {
1649        assert!(ButtonTheme::default().is_empty());
1650    }
1651
1652    #[test]
1653    fn button_theme_not_empty_when_set() {
1654        let b = ButtonTheme {
1655            background_color: Some(Rgba::rgb(200, 200, 200)),
1656            min_width: Some(64.0),
1657            ..Default::default()
1658        };
1659        assert!(!b.is_empty());
1660    }
1661
1662    #[test]
1663    fn button_theme_merge_font_optional_nested() {
1664        let mut base = ButtonTheme {
1665            font: Some(FontSpec {
1666                family: Some("Noto Sans".into()),
1667                size: Some(FontSize::Px(11.0)),
1668                weight: None,
1669                ..Default::default()
1670            }),
1671            ..Default::default()
1672        };
1673        let overlay = ButtonTheme {
1674            font: Some(FontSpec {
1675                family: None,
1676                weight: Some(700),
1677                ..Default::default()
1678            }),
1679            ..Default::default()
1680        };
1681        base.merge(&overlay);
1682        let f = base.font.as_ref().unwrap();
1683        assert_eq!(f.family.as_deref(), Some("Noto Sans")); // preserved
1684        assert_eq!(f.weight, Some(700)); // overlay
1685    }
1686
1687    #[test]
1688    fn button_theme_toml_round_trip_with_font_and_border() {
1689        let b = ButtonTheme {
1690            background_color: Some(Rgba::rgb(200, 200, 200)),
1691            font: Some(FontSpec {
1692                family: Some("Inter".into()),
1693                size: Some(FontSize::Px(14.0)),
1694                weight: Some(400),
1695                ..Default::default()
1696            }),
1697            border: Some(BorderSpec {
1698                corner_radius: Some(4.0),
1699                ..Default::default()
1700            }),
1701            ..Default::default()
1702        };
1703        let toml_str = toml::to_string(&b).unwrap();
1704        let b2: ButtonTheme = toml::from_str(&toml_str).unwrap();
1705        assert_eq!(b, b2);
1706    }
1707
1708    // === WindowTheme tests ===
1709
1710    #[test]
1711    fn window_theme_has_new_fields() {
1712        let w = WindowTheme {
1713            inactive_title_bar_background: Some(Rgba::rgb(180, 180, 180)),
1714            inactive_title_bar_text_color: Some(Rgba::rgb(120, 120, 120)),
1715            title_bar_font: Some(FontSpec {
1716                weight: Some(700),
1717                ..Default::default()
1718            }),
1719            border: Some(BorderSpec {
1720                corner_radius: Some(4.0),
1721                shadow_enabled: Some(true),
1722                ..Default::default()
1723            }),
1724            ..Default::default()
1725        };
1726        assert!(!w.is_empty());
1727        assert!(w.inactive_title_bar_background.is_some());
1728        assert!(w.inactive_title_bar_text_color.is_some());
1729        assert!(w.title_bar_font.is_some());
1730        assert!(w.border.is_some());
1731    }
1732
1733    #[test]
1734    fn window_theme_default_is_empty() {
1735        assert!(WindowTheme::default().is_empty());
1736    }
1737
1738    // === DialogTheme tests ===
1739
1740    #[test]
1741    fn dialog_theme_button_order_works() {
1742        let d = DialogTheme {
1743            button_order: Some(DialogButtonOrder::PrimaryRight),
1744            min_width: Some(300.0),
1745            ..Default::default()
1746        };
1747        assert_eq!(d.button_order, Some(DialogButtonOrder::PrimaryRight));
1748        assert_eq!(d.min_width, Some(300.0));
1749        assert!(!d.is_empty());
1750    }
1751
1752    #[test]
1753    fn dialog_theme_button_order_toml_round_trip() {
1754        let d = DialogTheme {
1755            button_order: Some(DialogButtonOrder::PrimaryLeft),
1756            ..Default::default()
1757        };
1758        let toml_str = toml::to_string(&d).unwrap();
1759        let d2: DialogTheme = toml::from_str(&toml_str).unwrap();
1760        assert_eq!(d, d2);
1761    }
1762
1763    #[test]
1764    fn dialog_theme_default_is_empty() {
1765        assert!(DialogTheme::default().is_empty());
1766    }
1767
1768    // === SplitterTheme tests ===
1769
1770    #[test]
1771    fn splitter_theme_single_field_merge() {
1772        let mut base = SplitterTheme {
1773            divider_width: Some(4.0),
1774            ..Default::default()
1775        };
1776        let overlay = SplitterTheme {
1777            divider_width: Some(6.0),
1778            ..Default::default()
1779        };
1780        base.merge(&overlay);
1781        assert_eq!(base.divider_width, Some(6.0));
1782    }
1783
1784    #[test]
1785    fn splitter_theme_merge_none_preserves_base() {
1786        let mut base = SplitterTheme {
1787            divider_width: Some(4.0),
1788            ..Default::default()
1789        };
1790        let overlay = SplitterTheme::default();
1791        base.merge(&overlay);
1792        assert_eq!(base.divider_width, Some(4.0));
1793    }
1794
1795    #[test]
1796    fn splitter_theme_default_is_empty() {
1797        assert!(SplitterTheme::default().is_empty());
1798    }
1799
1800    #[test]
1801    fn splitter_theme_not_empty_when_set() {
1802        assert!(
1803            !SplitterTheme {
1804                divider_width: Some(4.0),
1805                ..Default::default()
1806            }
1807            .is_empty()
1808        );
1809    }
1810
1811    // === SeparatorTheme tests ===
1812
1813    #[test]
1814    fn separator_theme_single_field() {
1815        let s = SeparatorTheme {
1816            line_color: Some(Rgba::rgb(200, 200, 200)),
1817            ..Default::default()
1818        };
1819        assert!(!s.is_empty());
1820    }
1821
1822    // === All 25 widget theme defaults are empty ===
1823
1824    #[test]
1825    fn all_widget_theme_defaults_are_empty() {
1826        assert!(WindowTheme::default().is_empty());
1827        assert!(ButtonTheme::default().is_empty());
1828        assert!(InputTheme::default().is_empty());
1829        assert!(CheckboxTheme::default().is_empty());
1830        assert!(MenuTheme::default().is_empty());
1831        assert!(TooltipTheme::default().is_empty());
1832        assert!(ScrollbarTheme::default().is_empty());
1833        assert!(SliderTheme::default().is_empty());
1834        assert!(ProgressBarTheme::default().is_empty());
1835        assert!(TabTheme::default().is_empty());
1836        assert!(SidebarTheme::default().is_empty());
1837        assert!(ToolbarTheme::default().is_empty());
1838        assert!(StatusBarTheme::default().is_empty());
1839        assert!(ListTheme::default().is_empty());
1840        assert!(PopoverTheme::default().is_empty());
1841        assert!(SplitterTheme::default().is_empty());
1842        assert!(SeparatorTheme::default().is_empty());
1843        assert!(SwitchTheme::default().is_empty());
1844        assert!(DialogTheme::default().is_empty());
1845        assert!(SpinnerTheme::default().is_empty());
1846        assert!(ComboBoxTheme::default().is_empty());
1847        assert!(SegmentedControlTheme::default().is_empty());
1848        assert!(CardTheme::default().is_empty());
1849        assert!(ExpanderTheme::default().is_empty());
1850        assert!(LinkTheme::default().is_empty());
1851    }
1852
1853    // === Representative TOML round-trips ===
1854
1855    #[test]
1856    fn input_theme_toml_round_trip() {
1857        let t = InputTheme {
1858            background_color: Some(Rgba::rgb(255, 255, 255)),
1859            font: Some(FontSpec {
1860                family: Some("Noto Sans".into()),
1861                ..Default::default()
1862            }),
1863            border: Some(BorderSpec {
1864                color: Some(Rgba::rgb(180, 180, 180)),
1865                corner_radius: Some(4.0),
1866                ..Default::default()
1867            }),
1868            ..Default::default()
1869        };
1870        let toml_str = toml::to_string(&t).unwrap();
1871        let t2: InputTheme = toml::from_str(&toml_str).unwrap();
1872        assert_eq!(t, t2);
1873    }
1874
1875    #[test]
1876    fn switch_theme_toml_round_trip() {
1877        let s = SwitchTheme {
1878            checked_background: Some(Rgba::rgb(0, 120, 215)),
1879            track_width: Some(40.0),
1880            track_height: Some(20.0),
1881            thumb_diameter: Some(14.0),
1882            track_radius: Some(10.0),
1883            ..Default::default()
1884        };
1885        let toml_str = toml::to_string(&s).unwrap();
1886        let s2: SwitchTheme = toml::from_str(&toml_str).unwrap();
1887        assert_eq!(s, s2);
1888    }
1889
1890    #[test]
1891    fn card_theme_with_border() {
1892        let c = CardTheme {
1893            background_color: Some(Rgba::rgb(255, 255, 255)),
1894            border: Some(BorderSpec {
1895                corner_radius: Some(8.0),
1896                shadow_enabled: Some(true),
1897                ..Default::default()
1898            }),
1899        };
1900        assert!(!c.is_empty());
1901    }
1902
1903    #[test]
1904    fn link_theme_has_underline_enabled_bool_field() {
1905        let l = LinkTheme {
1906            visited_text_color: Some(Rgba::rgb(100, 0, 200)),
1907            underline_enabled: Some(true),
1908            ..Default::default()
1909        };
1910        assert!(!l.is_empty());
1911        assert_eq!(l.underline_enabled, Some(true));
1912    }
1913
1914    #[test]
1915    fn status_bar_theme_has_font_and_background() {
1916        let s = StatusBarTheme {
1917            background_color: Some(Rgba::rgb(240, 240, 240)),
1918            font: Some(FontSpec {
1919                size: Some(FontSize::Px(11.0)),
1920                ..Default::default()
1921            }),
1922            ..Default::default()
1923        };
1924        assert!(!s.is_empty());
1925    }
1926
1927    // === SC4: Dual optional_nested (font + border) test widget ===
1928
1929    // SC4: Verify define_widget_pair! handles dual optional_nested (font + border)
1930    define_widget_pair! {
1931        /// Test widget with both font and border nested sub-structs.
1932        DualNestedTestWidget / ResolvedDualNestedTestWidget {
1933            option {
1934                background: Rgba,
1935                min_height: f32,
1936            }
1937            optional_nested {
1938                font: [FontSpec, ResolvedFontSpec],
1939                border: [BorderSpec, ResolvedBorderSpec],
1940            }
1941        }
1942    }
1943
1944    #[test]
1945    fn dual_nested_default_is_empty() {
1946        assert!(DualNestedTestWidget::default().is_empty());
1947    }
1948
1949    #[test]
1950    fn dual_nested_field_names() {
1951        assert_eq!(DualNestedTestWidget::FIELD_NAMES.len(), 4);
1952        assert!(DualNestedTestWidget::FIELD_NAMES.contains(&"background"));
1953        assert!(DualNestedTestWidget::FIELD_NAMES.contains(&"min_height"));
1954        assert!(DualNestedTestWidget::FIELD_NAMES.contains(&"font"));
1955        assert!(DualNestedTestWidget::FIELD_NAMES.contains(&"border"));
1956    }
1957
1958    #[test]
1959    fn dual_nested_not_empty_when_font_set() {
1960        let w = DualNestedTestWidget {
1961            font: Some(FontSpec {
1962                family: Some("Inter".into()),
1963                ..Default::default()
1964            }),
1965            ..Default::default()
1966        };
1967        assert!(!w.is_empty());
1968    }
1969
1970    #[test]
1971    fn dual_nested_not_empty_when_border_set() {
1972        let w = DualNestedTestWidget {
1973            border: Some(BorderSpec {
1974                color: Some(Rgba::rgb(100, 100, 100)),
1975                ..Default::default()
1976            }),
1977            ..Default::default()
1978        };
1979        assert!(!w.is_empty());
1980    }
1981
1982    #[test]
1983    fn dual_nested_merge_both_nested() {
1984        let mut base = DualNestedTestWidget {
1985            font: Some(FontSpec {
1986                family: Some("Noto Sans".into()),
1987                ..Default::default()
1988            }),
1989            ..Default::default()
1990        };
1991        let overlay = DualNestedTestWidget {
1992            border: Some(BorderSpec {
1993                corner_radius: Some(4.0),
1994                ..Default::default()
1995            }),
1996            ..Default::default()
1997        };
1998        base.merge(&overlay);
1999        assert!(base.font.is_some());
2000        assert!(base.border.is_some());
2001        assert_eq!(
2002            base.font.as_ref().and_then(|f| f.family.as_deref()),
2003            Some("Noto Sans")
2004        );
2005        assert_eq!(
2006            base.border.as_ref().and_then(|b| b.corner_radius),
2007            Some(4.0)
2008        );
2009    }
2010
2011    #[test]
2012    fn dual_nested_merge_inner_font_fields() {
2013        let mut base = DualNestedTestWidget {
2014            font: Some(FontSpec {
2015                family: Some("Noto Sans".into()),
2016                ..Default::default()
2017            }),
2018            ..Default::default()
2019        };
2020        let overlay = DualNestedTestWidget {
2021            font: Some(FontSpec {
2022                size: Some(FontSize::Px(14.0)),
2023                ..Default::default()
2024            }),
2025            ..Default::default()
2026        };
2027        base.merge(&overlay);
2028        let font = base.font.as_ref().unwrap();
2029        assert_eq!(font.family.as_deref(), Some("Noto Sans")); // preserved
2030        assert_eq!(font.size, Some(FontSize::Px(14.0))); // overlay sets
2031    }
2032
2033    #[test]
2034    fn dual_nested_toml_round_trip() {
2035        let w = DualNestedTestWidget {
2036            background: Some(Rgba::rgb(240, 240, 240)),
2037            min_height: Some(32.0),
2038            font: Some(FontSpec {
2039                family: Some("Inter".into()),
2040                size: Some(FontSize::Px(14.0)),
2041                weight: Some(400),
2042                ..Default::default()
2043            }),
2044            border: Some(BorderSpec {
2045                color: Some(Rgba::rgb(180, 180, 180)),
2046                corner_radius: Some(4.0),
2047                line_width: Some(1.0),
2048                ..Default::default()
2049            }),
2050        };
2051        let toml_str = toml::to_string(&w).unwrap();
2052        let w2: DualNestedTestWidget = toml::from_str(&toml_str).unwrap();
2053        assert_eq!(w, w2);
2054    }
2055
2056    // === LayoutTheme tests ===
2057
2058    // === validate_widget() generation tests ===
2059
2060    #[test]
2061    fn button_validate_widget_extracts_all_fields() {
2062        let button = ButtonTheme {
2063            background_color: Some(Rgba::rgb(200, 200, 200)),
2064            primary_background: Some(Rgba::rgb(0, 120, 215)),
2065            primary_text_color: Some(Rgba::rgb(255, 255, 255)),
2066            min_width: Some(80.0),
2067            min_height: Some(32.0),
2068            icon_text_gap: Some(8.0),
2069            disabled_opacity: Some(0.4),
2070            hover_background: Some(Rgba::rgb(210, 210, 210)),
2071            hover_text_color: Some(Rgba::rgb(0, 0, 0)),
2072            active_text_color: Some(Rgba::rgb(0, 0, 0)),
2073            disabled_text_color: Some(Rgba::rgb(128, 128, 128)),
2074            active_background: Some(Rgba::rgb(180, 180, 180)),
2075            disabled_background: Some(Rgba::rgb(220, 220, 220)),
2076            font: Some(FontSpec {
2077                family: Some("Inter".into()),
2078                size: Some(FontSize::Px(14.0)),
2079                weight: Some(400),
2080                style: Some(crate::model::font::FontStyle::Normal),
2081                color: Some(Rgba::rgb(0, 0, 0)),
2082            }),
2083            border: Some(BorderSpec {
2084                color: Some(Rgba::rgb(100, 100, 100)),
2085                corner_radius: Some(4.0),
2086                corner_radius_lg: Some(8.0),
2087                line_width: Some(1.0),
2088                opacity: Some(0.8),
2089                shadow_enabled: Some(false),
2090                padding_horizontal: Some(12.0),
2091                padding_vertical: Some(6.0),
2092            }),
2093        };
2094        let mut missing = Vec::new();
2095        let resolved = ResolvedButtonTheme::validate_widget(&button, "button", 96.0, &mut missing);
2096        assert!(missing.is_empty(), "unexpected missing: {missing:?}");
2097        assert_eq!(resolved.background_color, Rgba::rgb(200, 200, 200));
2098        assert_eq!(resolved.min_width, 80.0);
2099        assert_eq!(resolved.font.family, "Inter");
2100        assert_eq!(resolved.font.size, 14.0);
2101        assert_eq!(resolved.border.corner_radius, 4.0);
2102        // soft_option fields pass through as Option
2103        assert_eq!(resolved.active_background, Some(Rgba::rgb(180, 180, 180)));
2104        assert_eq!(resolved.disabled_background, Some(Rgba::rgb(220, 220, 220)));
2105    }
2106
2107    #[test]
2108    fn button_validate_widget_records_missing_fields() {
2109        let button = ButtonTheme::default(); // all None
2110        let mut missing = Vec::new();
2111        let _ = ResolvedButtonTheme::validate_widget(&button, "button", 96.0, &mut missing);
2112        // option fields should be recorded as missing
2113        assert!(missing.contains(&"button.background_color".to_string()));
2114        assert!(missing.contains(&"button.min_width".to_string()));
2115        // font (optional_nested) should be recorded
2116        assert!(missing.contains(&"button.font".to_string()));
2117        // border (optional_nested) should be recorded
2118        assert!(missing.contains(&"button.border".to_string()));
2119        // soft_option fields should NOT be recorded as missing
2120        assert!(!missing.iter().any(|m| m.contains("active_background")));
2121        assert!(!missing.iter().any(|m| m.contains("disabled_background")));
2122    }
2123
2124    // === LayoutTheme tests ===
2125
2126    #[test]
2127    fn layout_theme_default_is_empty() {
2128        assert!(LayoutTheme::default().is_empty());
2129    }
2130
2131    #[test]
2132    fn layout_theme_not_empty_when_widget_gap_set() {
2133        let l = LayoutTheme {
2134            widget_gap: Some(8.0),
2135            ..Default::default()
2136        };
2137        assert!(!l.is_empty());
2138    }
2139
2140    #[test]
2141    fn layout_theme_field_names() {
2142        assert_eq!(LayoutTheme::FIELD_NAMES.len(), 4);
2143        assert!(LayoutTheme::FIELD_NAMES.contains(&"widget_gap_px"));
2144        assert!(LayoutTheme::FIELD_NAMES.contains(&"container_margin_px"));
2145        assert!(LayoutTheme::FIELD_NAMES.contains(&"window_margin_px"));
2146        assert!(LayoutTheme::FIELD_NAMES.contains(&"section_gap_px"));
2147    }
2148
2149    #[test]
2150    fn layout_theme_toml_round_trip() {
2151        let l = LayoutTheme {
2152            widget_gap: Some(8.0),
2153            container_margin: Some(12.0),
2154            window_margin: Some(16.0),
2155            section_gap: Some(24.0),
2156        };
2157        let toml_str = toml::to_string(&l).unwrap();
2158        let l2: LayoutTheme = toml::from_str(&toml_str).unwrap();
2159        assert_eq!(l, l2);
2160    }
2161
2162    #[test]
2163    fn layout_theme_merge() {
2164        let mut base = LayoutTheme {
2165            widget_gap: Some(6.0),
2166            container_margin: Some(10.0),
2167            ..Default::default()
2168        };
2169        let overlay = LayoutTheme {
2170            widget_gap: Some(8.0),
2171            section_gap: Some(24.0),
2172            ..Default::default()
2173        };
2174        base.merge(&overlay);
2175        // overlay widget_gap replaces base
2176        assert_eq!(base.widget_gap, Some(8.0));
2177        // base container_margin preserved
2178        assert_eq!(base.container_margin, Some(10.0));
2179        // overlay section_gap added
2180        assert_eq!(base.section_gap, Some(24.0));
2181        // window_margin stays None
2182        assert!(base.window_margin.is_none());
2183    }
2184}