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        }
62    ) => {
63        $(#[$attr])*
64        #[serde_with::skip_serializing_none]
65        #[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
66        #[serde(default)]
67        pub struct $opt_name {
68            $($($(#[doc = $opt_doc])* $(#[serde(rename = $opt_rename)])? pub $opt_field: Option<$opt_type>,)*)?
69            $($($(#[doc = $so_doc])* pub $so_field: Option<$so_type>,)*)?
70            $($($(#[doc = $on_doc])* pub $on_field: Option<$on_opt_type>,)*)?
71        }
72
73        $(#[$attr])*
74        #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
75        #[non_exhaustive]
76        pub struct $resolved_name {
77            $($($(#[doc = $opt_doc])* pub $opt_field: $opt_type,)*)?
78            $($($(#[doc = $so_doc])* pub $so_field: Option<$so_type>,)*)?
79            $($($(#[doc = $on_doc])* pub $on_field: $on_res_type,)*)?
80        }
81
82        impl $opt_name {
83            /// All serialized field names for this widget theme, for TOML linting.
84            pub const FIELD_NAMES: &[&str] = &[
85                $($($crate::model::widgets::__field_name!($opt_field $(, $opt_rename)?),)*)?
86                $($(stringify!($so_field),)*)?
87                $($(stringify!($on_field),)*)?
88            ];
89        }
90
91        impl_merge!($opt_name {
92            $(option { $($opt_field),* })?
93            $(soft_option { $($so_field),* })?
94            $(optional_nested { $($on_field),* })?
95        });
96    };
97}
98
99// ── §2.2 Window / Application Chrome ────────────────────────────────────────
100
101define_widget_pair! {
102    /// Window chrome: background, title bar colors, inactive states, geometry.
103    WindowTheme / ResolvedWindowTheme {
104        option {
105            /// Main window background fill.
106            background_color: Rgba,
107            /// Active title bar background fill.
108            title_bar_background: Rgba,
109            /// Title bar background when the window is unfocused.
110            inactive_title_bar_background: Rgba,
111            /// Title bar text color when the window is unfocused.
112            inactive_title_bar_text_color: Rgba,
113        }
114        optional_nested {
115            /// Title bar font specification.
116            title_bar_font: [FontSpec, ResolvedFontSpec],
117            /// Window border specification.
118            border: [BorderSpec, ResolvedBorderSpec],
119        }
120    }
121}
122
123// ── §2.3 Button ──────────────────────────────────────────────────────────────
124
125define_widget_pair! {
126    /// Push button: colors, sizing, spacing, geometry.
127    ButtonTheme / ResolvedButtonTheme {
128        option {
129            /// Default button background fill.
130            background_color: Rgba,
131            /// Primary / accent button background fill.
132            primary_background: Rgba,
133            /// Primary / accent button text/icon color.
134            primary_text_color: Rgba,
135            /// Minimum button width in logical pixels.
136            min_width as "min_width_px": f32,
137            /// Minimum button height in logical pixels.
138            min_height as "min_height_px": f32,
139            /// Space between icon and label.
140            icon_text_gap as "icon_text_gap_px": f32,
141            /// Opacity multiplier when the button is disabled (0.0-1.0).
142            disabled_opacity: f32,
143            /// Button background on hover.
144            hover_background: Rgba,
145            /// Button text color on hover.
146            hover_text_color: Rgba,
147            /// Button text color when pressed/active.
148            active_text_color: Rgba,
149            /// Button text color when disabled.
150            disabled_text_color: Rgba,
151        }
152        soft_option {
153            /// Button background when pressed/active.
154            active_background: Rgba,
155            /// Button background when disabled.
156            disabled_background: Rgba,
157        }
158        optional_nested {
159            /// Button label font specification.
160            font: [FontSpec, ResolvedFontSpec],
161            /// Button border specification.
162            border: [BorderSpec, ResolvedBorderSpec],
163        }
164    }
165}
166
167// ── §2.4 Text Input ──────────────────────────────────────────────────────────
168
169define_widget_pair! {
170    /// Single-line and multi-line text input fields.
171    InputTheme / ResolvedInputTheme {
172        option {
173            /// Input field background fill.
174            background_color: Rgba,
175            /// Placeholder text color.
176            placeholder_color: Rgba,
177            /// Text cursor (caret) color.
178            caret_color: Rgba,
179            /// Text selection highlight color.
180            selection_background: Rgba,
181            /// Text color inside the selection highlight.
182            selection_text_color: Rgba,
183            /// Minimum field height in logical pixels.
184            min_height as "min_height_px": f32,
185            /// Opacity multiplier when disabled (0.0-1.0).
186            disabled_opacity: f32,
187            /// Input text color when disabled.
188            disabled_text_color: Rgba,
189        }
190        soft_option {
191            /// Border color when the input is hovered.
192            hover_border_color: Rgba,
193            /// Border color when the input has focus.
194            focus_border_color: Rgba,
195            /// Input background when disabled.
196            disabled_background: Rgba,
197        }
198        optional_nested {
199            /// Input text font specification.
200            font: [FontSpec, ResolvedFontSpec],
201            /// Input border specification.
202            border: [BorderSpec, ResolvedBorderSpec],
203        }
204    }
205}
206
207// ── §2.5 Checkbox / Radio Button ────────────────────────────────────────────
208
209define_widget_pair! {
210    /// Checkbox and radio button theme: colors, indicator, label font, border, and interactive states.
211    CheckboxTheme / ResolvedCheckboxTheme {
212        option {
213            /// Checkbox background color.
214            background_color: Rgba,
215            /// Indicator background when checked.
216            checked_background: Rgba,
217            /// Indicator (check mark / radio dot) color.
218            indicator_color: Rgba,
219            /// Indicator (check mark / radio dot) width in logical pixels.
220            indicator_width as "indicator_width_px": f32,
221            /// Space between indicator and label.
222            label_gap as "label_gap_px": f32,
223            /// Opacity multiplier when disabled (0.0-1.0).
224            disabled_opacity: f32,
225            /// Checkbox label text color when disabled.
226            disabled_text_color: Rgba,
227        }
228        soft_option {
229            /// Checkbox background on hover.
230            hover_background: Rgba,
231            /// Checkbox background when disabled.
232            disabled_background: Rgba,
233            /// Indicator background when unchecked.
234            unchecked_background: Rgba,
235            /// Border color when unchecked.
236            unchecked_border_color: Rgba,
237        }
238        optional_nested {
239            /// Checkbox label font specification.
240            font: [FontSpec, ResolvedFontSpec],
241            /// Checkbox border specification.
242            border: [BorderSpec, ResolvedBorderSpec],
243        }
244    }
245}
246
247// ── §2.6 Menu ────────────────────────────────────────────────────────────────
248
249define_widget_pair! {
250    /// Popup and context menu appearance.
251    MenuTheme / ResolvedMenuTheme {
252        option {
253            /// Menu panel background fill.
254            background_color: Rgba,
255            /// Separator line color between menu items.
256            separator_color: Rgba,
257            /// Height of a single menu item row.
258            row_height as "row_height_px": f32,
259            /// Space between a menu item's icon and its label.
260            icon_text_gap as "icon_text_gap_px": f32,
261            /// Menu item icon size in logical pixels.
262            icon_size as "icon_size_px": f32,
263            /// Menu item background on hover.
264            hover_background: Rgba,
265            /// Menu item text color on hover.
266            hover_text_color: Rgba,
267            /// Disabled menu item text color.
268            disabled_text_color: Rgba,
269        }
270        optional_nested {
271            /// Menu item font specification.
272            font: [FontSpec, ResolvedFontSpec],
273            /// Menu border specification.
274            border: [BorderSpec, ResolvedBorderSpec],
275        }
276    }
277}
278
279// ── §2.7 Tooltip ─────────────────────────────────────────────────────────────
280
281define_widget_pair! {
282    /// Tooltip popup appearance.
283    TooltipTheme / ResolvedTooltipTheme {
284        option {
285            /// Tooltip background fill.
286            background_color: Rgba,
287            /// Maximum tooltip width before wrapping.
288            max_width as "max_width_px": f32,
289        }
290        optional_nested {
291            /// Tooltip font specification.
292            font: [FontSpec, ResolvedFontSpec],
293            /// Tooltip border specification.
294            border: [BorderSpec, ResolvedBorderSpec],
295        }
296    }
297}
298
299// ── §2.8 Scrollbar ───────────────────────────────────────────────────────────
300
301define_widget_pair! {
302    /// Scrollbar colors and geometry.
303    ScrollbarTheme / ResolvedScrollbarTheme {
304        option {
305            /// Scrollbar track (gutter) color.
306            track_color: Rgba,
307            /// Scrollbar thumb color.
308            thumb_color: Rgba,
309            /// Thumb color on hover.
310            thumb_hover_color: Rgba,
311            /// Scrollbar groove width in logical pixels.
312            groove_width as "groove_width_px": f32,
313            /// Minimum thumb length in logical pixels.
314            min_thumb_length as "min_thumb_length_px": f32,
315            /// Width of the thumb rail within the scrollbar.
316            thumb_width as "thumb_width_px": f32,
317            /// Whether the scrollbar overlays content instead of taking layout space.
318            overlay_mode: bool,
319        }
320        soft_option {
321            /// Thumb color when pressed/dragging.
322            thumb_active_color: Rgba,
323        }
324    }
325}
326
327// ── §2.9 Slider ──────────────────────────────────────────────────────────────
328
329define_widget_pair! {
330    /// Slider control colors and geometry.
331    SliderTheme / ResolvedSliderTheme {
332        option {
333            /// Filled portion of the slider track.
334            fill_color: Rgba,
335            /// Unfilled track color.
336            track_color: Rgba,
337            /// Thumb (handle) color.
338            thumb_color: Rgba,
339            /// Track height in logical pixels.
340            track_height as "track_height_px": f32,
341            /// Thumb diameter in logical pixels.
342            thumb_diameter as "thumb_diameter_px": f32,
343            /// Tick mark length in logical pixels.
344            tick_mark_length as "tick_mark_length_px": f32,
345            /// Opacity multiplier when disabled (0.0-1.0).
346            disabled_opacity: f32,
347        }
348        soft_option {
349            /// Thumb color on hover.
350            thumb_hover_color: Rgba,
351            /// Filled track color when disabled.
352            disabled_fill_color: Rgba,
353            /// Unfilled track color when disabled.
354            disabled_track_color: Rgba,
355            /// Thumb color when disabled.
356            disabled_thumb_color: Rgba,
357        }
358    }
359}
360
361// ── §2.10 Progress Bar ───────────────────────────────────────────────────────
362
363define_widget_pair! {
364    /// Progress bar colors and geometry.
365    ProgressBarTheme / ResolvedProgressBarTheme {
366        option {
367            /// Filled progress bar color.
368            fill_color: Rgba,
369            /// Background track color.
370            track_color: Rgba,
371            /// Bar height in logical pixels.
372            track_height as "track_height_px": f32,
373            /// Minimum bar width in logical pixels.
374            min_width as "min_width_px": f32,
375        }
376        optional_nested {
377            /// Progress bar border specification.
378            border: [BorderSpec, ResolvedBorderSpec],
379        }
380    }
381}
382
383// ── §2.11 Tab Bar ─────────────────────────────────────────────────────────────
384
385define_widget_pair! {
386    /// Tab bar colors and sizing.
387    TabTheme / ResolvedTabTheme {
388        option {
389            /// Inactive tab background.
390            background_color: Rgba,
391            /// Active (selected) tab background.
392            active_background: Rgba,
393            /// Active (selected) tab text color.
394            active_text_color: Rgba,
395            /// Tab bar strip background.
396            bar_background: Rgba,
397            /// Minimum tab width in logical pixels.
398            min_width as "min_width_px": f32,
399            /// Minimum tab height in logical pixels.
400            min_height as "min_height_px": f32,
401            /// Tab text color on hover.
402            hover_text_color: Rgba,
403        }
404        soft_option {
405            /// Tab background on hover.
406            hover_background: Rgba,
407        }
408        optional_nested {
409            /// Tab font specification.
410            font: [FontSpec, ResolvedFontSpec],
411            /// Tab border specification.
412            border: [BorderSpec, ResolvedBorderSpec],
413        }
414    }
415}
416
417// ── §2.12 Sidebar ─────────────────────────────────────────────────────────────
418
419define_widget_pair! {
420    /// Sidebar panel background, selection, and hover colors.
421    SidebarTheme / ResolvedSidebarTheme {
422        option {
423            /// Sidebar panel background fill.
424            background_color: Rgba,
425            /// Selected item background color.
426            selection_background: Rgba,
427            /// Selected item text color.
428            selection_text_color: Rgba,
429            /// Hovered item background color.
430            hover_background: Rgba,
431        }
432        optional_nested {
433            /// Sidebar font specification.
434            font: [FontSpec, ResolvedFontSpec],
435            /// Sidebar border specification.
436            border: [BorderSpec, ResolvedBorderSpec],
437        }
438    }
439}
440
441// ── §2.13 Toolbar ─────────────────────────────────────────────────────────────
442
443define_widget_pair! {
444    /// Toolbar sizing, spacing, and font.
445    ToolbarTheme / ResolvedToolbarTheme {
446        option {
447            /// Toolbar background color.
448            background_color: Rgba,
449            /// Toolbar height in logical pixels.
450            bar_height as "bar_height_px": f32,
451            /// Horizontal space between toolbar items.
452            item_gap as "item_gap_px": f32,
453            /// Toolbar icon size in logical pixels.
454            icon_size as "icon_size_px": f32,
455        }
456        optional_nested {
457            /// Toolbar label font specification.
458            font: [FontSpec, ResolvedFontSpec],
459            /// Toolbar border specification.
460            border: [BorderSpec, ResolvedBorderSpec],
461        }
462    }
463}
464
465// ── §2.14 Status Bar ──────────────────────────────────────────────────────────
466
467define_widget_pair! {
468    /// Status bar font and background.
469    StatusBarTheme / ResolvedStatusBarTheme {
470        option {
471            /// Status bar background color.
472            background_color: Rgba,
473        }
474        optional_nested {
475            /// Status bar font specification.
476            font: [FontSpec, ResolvedFontSpec],
477            /// Status bar border specification.
478            border: [BorderSpec, ResolvedBorderSpec],
479        }
480    }
481}
482
483// ── §2.15 List / Table ────────────────────────────────────────────────────────
484
485define_widget_pair! {
486    /// List and table colors and row geometry.
487    ListTheme / ResolvedListTheme {
488        option {
489            /// List background fill.
490            background_color: Rgba,
491            /// Alternate row background for striped lists.
492            alternate_row_background: Rgba,
493            /// Selected row highlight color.
494            selection_background: Rgba,
495            /// Text color inside a selected row.
496            selection_text_color: Rgba,
497            /// Column header background fill.
498            header_background: Rgba,
499            /// Grid line color between rows/columns.
500            grid_color: Rgba,
501            /// Row height in logical pixels.
502            row_height as "row_height_px": f32,
503            /// Hovered row background color.
504            hover_background: Rgba,
505            /// Hovered row text color.
506            hover_text_color: Rgba,
507            /// Disabled row text color.
508            disabled_text_color: Rgba,
509        }
510        optional_nested {
511            /// List item font specification.
512            item_font: [FontSpec, ResolvedFontSpec],
513            /// Column header font specification.
514            header_font: [FontSpec, ResolvedFontSpec],
515            /// List border specification.
516            border: [BorderSpec, ResolvedBorderSpec],
517        }
518    }
519}
520
521// ── §2.16 Popover / Dropdown ──────────────────────────────────────────────────
522
523define_widget_pair! {
524    /// Popover / dropdown panel appearance.
525    PopoverTheme / ResolvedPopoverTheme {
526        option {
527            /// Panel background fill.
528            background_color: Rgba,
529        }
530        optional_nested {
531            /// Popover font specification.
532            font: [FontSpec, ResolvedFontSpec],
533            /// Popover border specification.
534            border: [BorderSpec, ResolvedBorderSpec],
535        }
536    }
537}
538
539// ── §2.17 Splitter ────────────────────────────────────────────────────────────
540
541define_widget_pair! {
542    /// Splitter handle width and color.
543    SplitterTheme / ResolvedSplitterTheme {
544        option {
545            /// Handle width in logical pixels.
546            divider_width as "divider_width_px": f32,
547            /// Divider color.
548            divider_color: Rgba,
549            /// Divider color on hover.
550            hover_color: Rgba,
551        }
552    }
553}
554
555// ── §2.18 Separator ───────────────────────────────────────────────────────────
556
557define_widget_pair! {
558    /// Separator line color and width.
559    SeparatorTheme / ResolvedSeparatorTheme {
560        option {
561            /// Separator line color.
562            line_color: Rgba,
563            /// Separator line width in logical pixels.
564            line_width as "line_width_px": f32,
565        }
566    }
567}
568
569// ── §2.21 Switch / Toggle ─────────────────────────────────────────────────────
570
571define_widget_pair! {
572    /// Toggle switch track, thumb, geometry, and interactive states.
573    SwitchTheme / ResolvedSwitchTheme {
574        option {
575            /// Track background when the switch is on.
576            checked_background: Rgba,
577            /// Track background when the switch is off.
578            unchecked_background: Rgba,
579            /// Thumb (knob) color.
580            thumb_background: Rgba,
581            /// Track width in logical pixels.
582            track_width as "track_width_px": f32,
583            /// Track height in logical pixels.
584            track_height as "track_height_px": f32,
585            /// Thumb diameter in logical pixels.
586            thumb_diameter as "thumb_diameter_px": f32,
587            /// Track corner radius in logical pixels.
588            track_radius as "track_radius_px": f32,
589            /// Opacity multiplier when disabled (0.0-1.0).
590            disabled_opacity: f32,
591        }
592        soft_option {
593            /// Track hover color when checked (on).
594            hover_checked_background: Rgba,
595            /// Track hover color when unchecked (off).
596            hover_unchecked_background: Rgba,
597            /// Track color when disabled and checked.
598            disabled_checked_background: Rgba,
599            /// Track color when disabled and unchecked.
600            disabled_unchecked_background: Rgba,
601            /// Thumb color when disabled.
602            disabled_thumb_color: Rgba,
603        }
604    }
605}
606
607// ── §2.22 Dialog ──────────────────────────────────────────────────────────────
608
609define_widget_pair! {
610    /// Dialog sizing, spacing, button order, fonts, border, and background.
611    DialogTheme / ResolvedDialogTheme {
612        option {
613            /// Dialog background color.
614            background_color: Rgba,
615            /// Minimum dialog width in logical pixels.
616            min_width as "min_width_px": f32,
617            /// Maximum dialog width in logical pixels.
618            max_width as "max_width_px": f32,
619            /// Minimum dialog height in logical pixels.
620            min_height as "min_height_px": f32,
621            /// Maximum dialog height in logical pixels.
622            max_height as "max_height_px": f32,
623            /// Horizontal space between dialog buttons.
624            button_gap as "button_gap_px": f32,
625            /// Icon size for dialog type icons (warning, error, etc.).
626            icon_size as "icon_size_px": f32,
627            /// Platform button order convention (e.g., OK/Cancel vs Cancel/OK).
628            button_order: DialogButtonOrder,
629        }
630        optional_nested {
631            /// Dialog title font specification.
632            title_font: [FontSpec, ResolvedFontSpec],
633            /// Dialog body font specification.
634            body_font: [FontSpec, ResolvedFontSpec],
635            /// Dialog border specification.
636            border: [BorderSpec, ResolvedBorderSpec],
637        }
638    }
639}
640
641// ── §2.23 Spinner / Progress Ring ─────────────────────────────────────────────
642
643define_widget_pair! {
644    /// Spinner / indeterminate progress indicator.
645    SpinnerTheme / ResolvedSpinnerTheme {
646        option {
647            /// Spinner arc fill color.
648            fill_color: Rgba,
649            /// Spinner outer diameter in logical pixels.
650            diameter as "diameter_px": f32,
651            /// Minimum rendered size in logical pixels.
652            min_diameter as "min_diameter_px": f32,
653            /// Arc stroke width in logical pixels.
654            stroke_width as "stroke_width_px": f32,
655        }
656    }
657}
658
659// ── §2.24 ComboBox / Dropdown Trigger ─────────────────────────────────────────
660
661define_widget_pair! {
662    /// ComboBox / dropdown trigger sizing.
663    ComboBoxTheme / ResolvedComboBoxTheme {
664        option {
665            /// ComboBox background color.
666            background_color: Rgba,
667            /// Minimum trigger height in logical pixels.
668            min_height as "min_height_px": f32,
669            /// Minimum trigger width in logical pixels.
670            min_width as "min_width_px": f32,
671            /// Dropdown arrow size in logical pixels.
672            arrow_icon_size as "arrow_icon_size_px": f32,
673            /// Width of the arrow clickable area.
674            arrow_area_width as "arrow_area_width_px": f32,
675            /// Opacity multiplier when disabled (0.0-1.0).
676            disabled_opacity: f32,
677            /// ComboBox text color when disabled.
678            disabled_text_color: Rgba,
679        }
680        soft_option {
681            /// ComboBox background on hover.
682            hover_background: Rgba,
683            /// ComboBox background when disabled.
684            disabled_background: Rgba,
685        }
686        optional_nested {
687            /// ComboBox font specification.
688            font: [FontSpec, ResolvedFontSpec],
689            /// ComboBox border specification.
690            border: [BorderSpec, ResolvedBorderSpec],
691        }
692    }
693}
694
695// ── §2.25 Segmented Control ───────────────────────────────────────────────────
696
697define_widget_pair! {
698    /// Segmented control sizing (macOS-primary; KDE uses tab bar metrics as proxy).
699    SegmentedControlTheme / ResolvedSegmentedControlTheme {
700        option {
701            /// Segmented control background color.
702            background_color: Rgba,
703            /// Active segment background.
704            active_background: Rgba,
705            /// Active segment text color.
706            active_text_color: Rgba,
707            /// Segment height in logical pixels.
708            segment_height as "segment_height_px": f32,
709            /// Width of the separator between segments.
710            separator_width as "separator_width_px": f32,
711            /// Opacity multiplier when disabled (0.0-1.0).
712            disabled_opacity: f32,
713        }
714        soft_option {
715            /// Segment background on hover.
716            hover_background: Rgba,
717        }
718        optional_nested {
719            /// Segmented control font specification.
720            font: [FontSpec, ResolvedFontSpec],
721            /// Segmented control border specification.
722            border: [BorderSpec, ResolvedBorderSpec],
723        }
724    }
725}
726
727// ── §2.26 Card / Container ────────────────────────────────────────────────────
728
729define_widget_pair! {
730    /// Card / container colors and geometry.
731    CardTheme / ResolvedCardTheme {
732        option {
733            /// Card background fill.
734            background_color: Rgba,
735        }
736        optional_nested {
737            /// Card border specification.
738            border: [BorderSpec, ResolvedBorderSpec],
739        }
740    }
741}
742
743// ── §2.27 Expander / Disclosure ───────────────────────────────────────────────
744
745define_widget_pair! {
746    /// Expander / disclosure row geometry.
747    ExpanderTheme / ResolvedExpanderTheme {
748        option {
749            /// Collapsed header row height in logical pixels.
750            header_height as "header_height_px": f32,
751            /// Disclosure arrow size in logical pixels.
752            arrow_icon_size as "arrow_icon_size_px": f32,
753        }
754        soft_option {
755            /// Expander header background on hover.
756            hover_background: Rgba,
757            /// Disclosure arrow/chevron color.
758            arrow_color: Rgba,
759        }
760        optional_nested {
761            /// Expander font specification.
762            font: [FontSpec, ResolvedFontSpec],
763            /// Expander border specification.
764            border: [BorderSpec, ResolvedBorderSpec],
765        }
766    }
767}
768
769// ── §2.28 Link ────────────────────────────────────────────────────────────────
770
771define_widget_pair! {
772    /// Hyperlink colors and underline setting.
773    LinkTheme / ResolvedLinkTheme {
774        option {
775            /// Visited link text color.
776            visited_text_color: Rgba,
777            /// Whether links are underlined.
778            underline_enabled: bool,
779            /// Link background fill (typically transparent).
780            background_color: Rgba,
781            /// Link background on hover.
782            hover_background: Rgba,
783            /// Link text color on hover.
784            hover_text_color: Rgba,
785            /// Link text color when pressed/active.
786            active_text_color: Rgba,
787            /// Link text color when disabled.
788            disabled_text_color: Rgba,
789        }
790        optional_nested {
791            /// Link font specification.
792            font: [FontSpec, ResolvedFontSpec],
793        }
794    }
795}
796
797// -- Layout (top-level, not per-variant) ------------------------------------------
798
799define_widget_pair! {
800    /// Layout spacing constants shared between light and dark variants.
801    ///
802    /// Unlike other widget themes, LayoutTheme lives on [`crate::ThemeSpec`] (top-level)
803    /// rather than [`crate::ThemeVariant`] because spacing is variant-independent.
804    LayoutTheme / ResolvedLayoutTheme {
805        option {
806            /// Space between adjacent widgets in logical pixels.
807            widget_gap as "widget_gap_px": f32,
808            /// Padding inside containers in logical pixels.
809            container_margin as "container_margin_px": f32,
810            /// Padding inside the main window in logical pixels.
811            window_margin as "window_margin_px": f32,
812            /// Space between major content sections in logical pixels.
813            section_gap as "section_gap_px": f32,
814        }
815    }
816}
817
818#[cfg(test)]
819#[allow(clippy::unwrap_used, clippy::expect_used, dead_code)]
820mod tests {
821    use super::*;
822    use crate::Rgba;
823    use crate::model::border::{BorderSpec, ResolvedBorderSpec};
824    use crate::model::font::FontSize;
825    use crate::model::{DialogButtonOrder, FontSpec};
826
827    // Define a test widget pair using the macro (validates macro itself still works)
828    define_widget_pair! {
829        /// Test widget for macro verification.
830        TestWidget / ResolvedTestWidget {
831            option {
832                size: f32,
833                label: String,
834            }
835            optional_nested {
836                font: [FontSpec, ResolvedFontSpec],
837            }
838        }
839    }
840
841    // === ResolvedFontSpec tests ===
842
843    #[test]
844    fn resolved_font_spec_fields_are_concrete() {
845        let rfs = ResolvedFontSpec {
846            family: "Inter".into(),
847            size: 14.0,
848            weight: 400,
849            style: crate::model::font::FontStyle::Normal,
850            color: crate::Rgba::rgb(0, 0, 0),
851        };
852        assert_eq!(rfs.family, "Inter");
853        assert_eq!(rfs.size, 14.0);
854        assert_eq!(rfs.weight, 400);
855    }
856
857    // === define_widget_pair! generated struct tests ===
858
859    #[test]
860    fn generated_option_struct_has_option_fields() {
861        let w = TestWidget::default();
862        assert!(w.size.is_none());
863        assert!(w.label.is_none());
864        assert!(w.font.is_none());
865    }
866
867    #[test]
868    fn generated_option_struct_is_empty_by_default() {
869        assert!(TestWidget::default().is_empty());
870    }
871
872    #[test]
873    fn generated_option_struct_not_empty_when_size_set() {
874        let w = TestWidget {
875            size: Some(24.0),
876            ..Default::default()
877        };
878        assert!(!w.is_empty());
879    }
880
881    #[test]
882    fn generated_option_struct_not_empty_when_font_set() {
883        let w = TestWidget {
884            font: Some(FontSpec {
885                size: Some(FontSize::Px(14.0)),
886                ..Default::default()
887            }),
888            ..Default::default()
889        };
890        assert!(!w.is_empty());
891    }
892
893    #[test]
894    fn generated_resolved_struct_has_concrete_fields() {
895        let resolved = ResolvedTestWidget {
896            size: 24.0,
897            label: "Click me".into(),
898            font: ResolvedFontSpec {
899                family: "Inter".into(),
900                size: 14.0,
901                weight: 400,
902                style: crate::model::font::FontStyle::Normal,
903                color: crate::Rgba::rgb(0, 0, 0),
904            },
905        };
906        assert_eq!(resolved.size, 24.0);
907        assert_eq!(resolved.label, "Click me");
908        assert_eq!(resolved.font.family, "Inter");
909    }
910
911    // === merge tests for generated structs ===
912
913    #[test]
914    fn generated_merge_option_field_overlay_wins() {
915        let mut base = TestWidget {
916            size: Some(20.0),
917            ..Default::default()
918        };
919        let overlay = TestWidget {
920            size: Some(24.0),
921            ..Default::default()
922        };
923        base.merge(&overlay);
924        assert_eq!(base.size, Some(24.0));
925    }
926
927    #[test]
928    fn generated_merge_option_field_none_preserves_base() {
929        let mut base = TestWidget {
930            size: Some(20.0),
931            ..Default::default()
932        };
933        let overlay = TestWidget::default();
934        base.merge(&overlay);
935        assert_eq!(base.size, Some(20.0));
936    }
937
938    #[test]
939    fn generated_merge_optional_nested_both_some_merges_inner() {
940        let mut base = TestWidget {
941            font: Some(FontSpec {
942                family: Some("Noto Sans".into()),
943                size: Some(FontSize::Px(12.0)),
944                weight: None,
945                ..Default::default()
946            }),
947            ..Default::default()
948        };
949        let overlay = TestWidget {
950            font: Some(FontSpec {
951                family: None,
952                size: None,
953                weight: Some(700),
954                ..Default::default()
955            }),
956            ..Default::default()
957        };
958        base.merge(&overlay);
959        let font = base.font.as_ref().unwrap();
960        assert_eq!(font.family.as_deref(), Some("Noto Sans")); // preserved
961        assert_eq!(font.size, Some(FontSize::Px(12.0))); // preserved
962        assert_eq!(font.weight, Some(700)); // overlay sets
963    }
964
965    #[test]
966    fn generated_merge_optional_nested_none_plus_some_clones() {
967        let mut base = TestWidget::default();
968        let overlay = TestWidget {
969            font: Some(FontSpec {
970                family: Some("Inter".into()),
971                size: Some(FontSize::Px(14.0)),
972                weight: Some(400),
973                ..Default::default()
974            }),
975            ..Default::default()
976        };
977        base.merge(&overlay);
978        let font = base.font.as_ref().unwrap();
979        assert_eq!(font.family.as_deref(), Some("Inter"));
980        assert_eq!(font.size, Some(FontSize::Px(14.0)));
981        assert_eq!(font.weight, Some(400));
982    }
983
984    #[test]
985    fn generated_merge_optional_nested_some_plus_none_preserves_base() {
986        let mut base = TestWidget {
987            font: Some(FontSpec {
988                family: Some("Inter".into()),
989                size: Some(FontSize::Px(14.0)),
990                weight: Some(400),
991                ..Default::default()
992            }),
993            ..Default::default()
994        };
995        let overlay = TestWidget::default();
996        base.merge(&overlay);
997        let font = base.font.as_ref().unwrap();
998        assert_eq!(font.family.as_deref(), Some("Inter"));
999    }
1000
1001    #[test]
1002    fn generated_merge_optional_nested_none_plus_none_stays_none() {
1003        let mut base = TestWidget::default();
1004        let overlay = TestWidget::default();
1005        base.merge(&overlay);
1006        assert!(base.font.is_none());
1007    }
1008
1009    // === impl_merge! optional_nested clause direct tests ===
1010
1011    // Verify the optional_nested clause directly on a FontSpec-containing struct
1012    #[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1013    struct WithFont {
1014        name: Option<String>,
1015        font: Option<FontSpec>,
1016    }
1017
1018    impl_merge!(WithFont {
1019        option { name }
1020        optional_nested { font }
1021    });
1022
1023    #[test]
1024    fn impl_merge_optional_nested_none_none_stays_none() {
1025        let mut base = WithFont::default();
1026        let overlay = WithFont::default();
1027        base.merge(&overlay);
1028        assert!(base.font.is_none());
1029    }
1030
1031    #[test]
1032    fn impl_merge_optional_nested_some_none_preserves_base() {
1033        let mut base = WithFont {
1034            font: Some(FontSpec {
1035                size: Some(FontSize::Px(12.0)),
1036                ..Default::default()
1037            }),
1038            ..Default::default()
1039        };
1040        let overlay = WithFont::default();
1041        base.merge(&overlay);
1042        assert_eq!(base.font.as_ref().unwrap().size, Some(FontSize::Px(12.0)));
1043    }
1044
1045    #[test]
1046    fn impl_merge_optional_nested_none_some_clones_overlay() {
1047        let mut base = WithFont::default();
1048        let overlay = WithFont {
1049            font: Some(FontSpec {
1050                family: Some("Inter".into()),
1051                ..Default::default()
1052            }),
1053            ..Default::default()
1054        };
1055        base.merge(&overlay);
1056        assert_eq!(base.font.as_ref().unwrap().family.as_deref(), Some("Inter"));
1057    }
1058
1059    #[test]
1060    fn impl_merge_optional_nested_some_some_merges_inner() {
1061        let mut base = WithFont {
1062            font: Some(FontSpec {
1063                family: Some("Noto".into()),
1064                size: Some(FontSize::Px(11.0)),
1065                weight: None,
1066                ..Default::default()
1067            }),
1068            ..Default::default()
1069        };
1070        let overlay = WithFont {
1071            font: Some(FontSpec {
1072                family: None,
1073                size: Some(FontSize::Px(14.0)),
1074                weight: Some(400),
1075                ..Default::default()
1076            }),
1077            ..Default::default()
1078        };
1079        base.merge(&overlay);
1080        let f = base.font.as_ref().unwrap();
1081        assert_eq!(f.family.as_deref(), Some("Noto")); // preserved
1082        assert_eq!(f.size, Some(FontSize::Px(14.0))); // overlay wins
1083        assert_eq!(f.weight, Some(400)); // overlay sets
1084    }
1085
1086    #[test]
1087    fn impl_merge_optional_nested_is_empty_none() {
1088        let w = WithFont::default();
1089        assert!(w.is_empty());
1090    }
1091
1092    #[test]
1093    fn impl_merge_optional_nested_is_empty_some_default() {
1094        // Some(FontSpec::default()) with all-None sub-fields counts as empty (D-2 fix).
1095        let w = WithFont {
1096            font: Some(FontSpec::default()),
1097            ..Default::default()
1098        };
1099        assert!(w.is_empty());
1100    }
1101
1102    #[test]
1103    fn impl_merge_optional_nested_is_not_empty_when_populated() {
1104        let w = WithFont {
1105            font: Some(FontSpec {
1106                size: Some(FontSize::Px(14.0)),
1107                ..Default::default()
1108            }),
1109            ..Default::default()
1110        };
1111        assert!(!w.is_empty());
1112    }
1113
1114    // === ButtonTheme tests ===
1115
1116    #[test]
1117    fn button_theme_default_is_empty() {
1118        assert!(ButtonTheme::default().is_empty());
1119    }
1120
1121    #[test]
1122    fn button_theme_not_empty_when_set() {
1123        let b = ButtonTheme {
1124            background_color: Some(Rgba::rgb(200, 200, 200)),
1125            min_width: Some(64.0),
1126            ..Default::default()
1127        };
1128        assert!(!b.is_empty());
1129    }
1130
1131    #[test]
1132    fn button_theme_merge_font_optional_nested() {
1133        let mut base = ButtonTheme {
1134            font: Some(FontSpec {
1135                family: Some("Noto Sans".into()),
1136                size: Some(FontSize::Px(11.0)),
1137                weight: None,
1138                ..Default::default()
1139            }),
1140            ..Default::default()
1141        };
1142        let overlay = ButtonTheme {
1143            font: Some(FontSpec {
1144                family: None,
1145                weight: Some(700),
1146                ..Default::default()
1147            }),
1148            ..Default::default()
1149        };
1150        base.merge(&overlay);
1151        let f = base.font.as_ref().unwrap();
1152        assert_eq!(f.family.as_deref(), Some("Noto Sans")); // preserved
1153        assert_eq!(f.weight, Some(700)); // overlay
1154    }
1155
1156    #[test]
1157    fn button_theme_toml_round_trip_with_font_and_border() {
1158        let b = ButtonTheme {
1159            background_color: Some(Rgba::rgb(200, 200, 200)),
1160            font: Some(FontSpec {
1161                family: Some("Inter".into()),
1162                size: Some(FontSize::Px(14.0)),
1163                weight: Some(400),
1164                ..Default::default()
1165            }),
1166            border: Some(BorderSpec {
1167                corner_radius: Some(4.0),
1168                ..Default::default()
1169            }),
1170            ..Default::default()
1171        };
1172        let toml_str = toml::to_string(&b).unwrap();
1173        let b2: ButtonTheme = toml::from_str(&toml_str).unwrap();
1174        assert_eq!(b, b2);
1175    }
1176
1177    // === WindowTheme tests ===
1178
1179    #[test]
1180    fn window_theme_has_new_fields() {
1181        let w = WindowTheme {
1182            inactive_title_bar_background: Some(Rgba::rgb(180, 180, 180)),
1183            inactive_title_bar_text_color: Some(Rgba::rgb(120, 120, 120)),
1184            title_bar_font: Some(FontSpec {
1185                weight: Some(700),
1186                ..Default::default()
1187            }),
1188            border: Some(BorderSpec {
1189                corner_radius: Some(4.0),
1190                shadow_enabled: Some(true),
1191                ..Default::default()
1192            }),
1193            ..Default::default()
1194        };
1195        assert!(!w.is_empty());
1196        assert!(w.inactive_title_bar_background.is_some());
1197        assert!(w.inactive_title_bar_text_color.is_some());
1198        assert!(w.title_bar_font.is_some());
1199        assert!(w.border.is_some());
1200    }
1201
1202    #[test]
1203    fn window_theme_default_is_empty() {
1204        assert!(WindowTheme::default().is_empty());
1205    }
1206
1207    // === DialogTheme tests ===
1208
1209    #[test]
1210    fn dialog_theme_button_order_works() {
1211        let d = DialogTheme {
1212            button_order: Some(DialogButtonOrder::PrimaryRight),
1213            min_width: Some(300.0),
1214            ..Default::default()
1215        };
1216        assert_eq!(d.button_order, Some(DialogButtonOrder::PrimaryRight));
1217        assert_eq!(d.min_width, Some(300.0));
1218        assert!(!d.is_empty());
1219    }
1220
1221    #[test]
1222    fn dialog_theme_button_order_toml_round_trip() {
1223        let d = DialogTheme {
1224            button_order: Some(DialogButtonOrder::PrimaryLeft),
1225            ..Default::default()
1226        };
1227        let toml_str = toml::to_string(&d).unwrap();
1228        let d2: DialogTheme = toml::from_str(&toml_str).unwrap();
1229        assert_eq!(d, d2);
1230    }
1231
1232    #[test]
1233    fn dialog_theme_default_is_empty() {
1234        assert!(DialogTheme::default().is_empty());
1235    }
1236
1237    // === SplitterTheme tests ===
1238
1239    #[test]
1240    fn splitter_theme_single_field_merge() {
1241        let mut base = SplitterTheme {
1242            divider_width: Some(4.0),
1243            ..Default::default()
1244        };
1245        let overlay = SplitterTheme {
1246            divider_width: Some(6.0),
1247            ..Default::default()
1248        };
1249        base.merge(&overlay);
1250        assert_eq!(base.divider_width, Some(6.0));
1251    }
1252
1253    #[test]
1254    fn splitter_theme_merge_none_preserves_base() {
1255        let mut base = SplitterTheme {
1256            divider_width: Some(4.0),
1257            ..Default::default()
1258        };
1259        let overlay = SplitterTheme::default();
1260        base.merge(&overlay);
1261        assert_eq!(base.divider_width, Some(4.0));
1262    }
1263
1264    #[test]
1265    fn splitter_theme_default_is_empty() {
1266        assert!(SplitterTheme::default().is_empty());
1267    }
1268
1269    #[test]
1270    fn splitter_theme_not_empty_when_set() {
1271        assert!(
1272            !SplitterTheme {
1273                divider_width: Some(4.0),
1274                ..Default::default()
1275            }
1276            .is_empty()
1277        );
1278    }
1279
1280    // === SeparatorTheme tests ===
1281
1282    #[test]
1283    fn separator_theme_single_field() {
1284        let s = SeparatorTheme {
1285            line_color: Some(Rgba::rgb(200, 200, 200)),
1286            ..Default::default()
1287        };
1288        assert!(!s.is_empty());
1289    }
1290
1291    // === All 25 widget theme defaults are empty ===
1292
1293    #[test]
1294    fn all_widget_theme_defaults_are_empty() {
1295        assert!(WindowTheme::default().is_empty());
1296        assert!(ButtonTheme::default().is_empty());
1297        assert!(InputTheme::default().is_empty());
1298        assert!(CheckboxTheme::default().is_empty());
1299        assert!(MenuTheme::default().is_empty());
1300        assert!(TooltipTheme::default().is_empty());
1301        assert!(ScrollbarTheme::default().is_empty());
1302        assert!(SliderTheme::default().is_empty());
1303        assert!(ProgressBarTheme::default().is_empty());
1304        assert!(TabTheme::default().is_empty());
1305        assert!(SidebarTheme::default().is_empty());
1306        assert!(ToolbarTheme::default().is_empty());
1307        assert!(StatusBarTheme::default().is_empty());
1308        assert!(ListTheme::default().is_empty());
1309        assert!(PopoverTheme::default().is_empty());
1310        assert!(SplitterTheme::default().is_empty());
1311        assert!(SeparatorTheme::default().is_empty());
1312        assert!(SwitchTheme::default().is_empty());
1313        assert!(DialogTheme::default().is_empty());
1314        assert!(SpinnerTheme::default().is_empty());
1315        assert!(ComboBoxTheme::default().is_empty());
1316        assert!(SegmentedControlTheme::default().is_empty());
1317        assert!(CardTheme::default().is_empty());
1318        assert!(ExpanderTheme::default().is_empty());
1319        assert!(LinkTheme::default().is_empty());
1320    }
1321
1322    // === Representative TOML round-trips ===
1323
1324    #[test]
1325    fn input_theme_toml_round_trip() {
1326        let t = InputTheme {
1327            background_color: Some(Rgba::rgb(255, 255, 255)),
1328            font: Some(FontSpec {
1329                family: Some("Noto Sans".into()),
1330                ..Default::default()
1331            }),
1332            border: Some(BorderSpec {
1333                color: Some(Rgba::rgb(180, 180, 180)),
1334                corner_radius: Some(4.0),
1335                ..Default::default()
1336            }),
1337            ..Default::default()
1338        };
1339        let toml_str = toml::to_string(&t).unwrap();
1340        let t2: InputTheme = toml::from_str(&toml_str).unwrap();
1341        assert_eq!(t, t2);
1342    }
1343
1344    #[test]
1345    fn switch_theme_toml_round_trip() {
1346        let s = SwitchTheme {
1347            checked_background: Some(Rgba::rgb(0, 120, 215)),
1348            track_width: Some(40.0),
1349            track_height: Some(20.0),
1350            thumb_diameter: Some(14.0),
1351            track_radius: Some(10.0),
1352            ..Default::default()
1353        };
1354        let toml_str = toml::to_string(&s).unwrap();
1355        let s2: SwitchTheme = toml::from_str(&toml_str).unwrap();
1356        assert_eq!(s, s2);
1357    }
1358
1359    #[test]
1360    fn card_theme_with_border() {
1361        let c = CardTheme {
1362            background_color: Some(Rgba::rgb(255, 255, 255)),
1363            border: Some(BorderSpec {
1364                corner_radius: Some(8.0),
1365                shadow_enabled: Some(true),
1366                ..Default::default()
1367            }),
1368        };
1369        assert!(!c.is_empty());
1370    }
1371
1372    #[test]
1373    fn link_theme_has_underline_enabled_bool_field() {
1374        let l = LinkTheme {
1375            visited_text_color: Some(Rgba::rgb(100, 0, 200)),
1376            underline_enabled: Some(true),
1377            ..Default::default()
1378        };
1379        assert!(!l.is_empty());
1380        assert_eq!(l.underline_enabled, Some(true));
1381    }
1382
1383    #[test]
1384    fn status_bar_theme_has_font_and_background() {
1385        let s = StatusBarTheme {
1386            background_color: Some(Rgba::rgb(240, 240, 240)),
1387            font: Some(FontSpec {
1388                size: Some(FontSize::Px(11.0)),
1389                ..Default::default()
1390            }),
1391            ..Default::default()
1392        };
1393        assert!(!s.is_empty());
1394    }
1395
1396    // === SC4: Dual optional_nested (font + border) test widget ===
1397
1398    // SC4: Verify define_widget_pair! handles dual optional_nested (font + border)
1399    define_widget_pair! {
1400        /// Test widget with both font and border nested sub-structs.
1401        DualNestedTestWidget / ResolvedDualNestedTestWidget {
1402            option {
1403                background: Rgba,
1404                min_height: f32,
1405            }
1406            optional_nested {
1407                font: [FontSpec, ResolvedFontSpec],
1408                border: [BorderSpec, ResolvedBorderSpec],
1409            }
1410        }
1411    }
1412
1413    #[test]
1414    fn dual_nested_default_is_empty() {
1415        assert!(DualNestedTestWidget::default().is_empty());
1416    }
1417
1418    #[test]
1419    fn dual_nested_field_names() {
1420        assert_eq!(DualNestedTestWidget::FIELD_NAMES.len(), 4);
1421        assert!(DualNestedTestWidget::FIELD_NAMES.contains(&"background"));
1422        assert!(DualNestedTestWidget::FIELD_NAMES.contains(&"min_height"));
1423        assert!(DualNestedTestWidget::FIELD_NAMES.contains(&"font"));
1424        assert!(DualNestedTestWidget::FIELD_NAMES.contains(&"border"));
1425    }
1426
1427    #[test]
1428    fn dual_nested_not_empty_when_font_set() {
1429        let w = DualNestedTestWidget {
1430            font: Some(FontSpec {
1431                family: Some("Inter".into()),
1432                ..Default::default()
1433            }),
1434            ..Default::default()
1435        };
1436        assert!(!w.is_empty());
1437    }
1438
1439    #[test]
1440    fn dual_nested_not_empty_when_border_set() {
1441        let w = DualNestedTestWidget {
1442            border: Some(BorderSpec {
1443                color: Some(Rgba::rgb(100, 100, 100)),
1444                ..Default::default()
1445            }),
1446            ..Default::default()
1447        };
1448        assert!(!w.is_empty());
1449    }
1450
1451    #[test]
1452    fn dual_nested_merge_both_nested() {
1453        let mut base = DualNestedTestWidget {
1454            font: Some(FontSpec {
1455                family: Some("Noto Sans".into()),
1456                ..Default::default()
1457            }),
1458            ..Default::default()
1459        };
1460        let overlay = DualNestedTestWidget {
1461            border: Some(BorderSpec {
1462                corner_radius: Some(4.0),
1463                ..Default::default()
1464            }),
1465            ..Default::default()
1466        };
1467        base.merge(&overlay);
1468        assert!(base.font.is_some());
1469        assert!(base.border.is_some());
1470        assert_eq!(
1471            base.font.as_ref().and_then(|f| f.family.as_deref()),
1472            Some("Noto Sans")
1473        );
1474        assert_eq!(
1475            base.border.as_ref().and_then(|b| b.corner_radius),
1476            Some(4.0)
1477        );
1478    }
1479
1480    #[test]
1481    fn dual_nested_merge_inner_font_fields() {
1482        let mut base = DualNestedTestWidget {
1483            font: Some(FontSpec {
1484                family: Some("Noto Sans".into()),
1485                ..Default::default()
1486            }),
1487            ..Default::default()
1488        };
1489        let overlay = DualNestedTestWidget {
1490            font: Some(FontSpec {
1491                size: Some(FontSize::Px(14.0)),
1492                ..Default::default()
1493            }),
1494            ..Default::default()
1495        };
1496        base.merge(&overlay);
1497        let font = base.font.as_ref().unwrap();
1498        assert_eq!(font.family.as_deref(), Some("Noto Sans")); // preserved
1499        assert_eq!(font.size, Some(FontSize::Px(14.0))); // overlay sets
1500    }
1501
1502    #[test]
1503    fn dual_nested_toml_round_trip() {
1504        let w = DualNestedTestWidget {
1505            background: Some(Rgba::rgb(240, 240, 240)),
1506            min_height: Some(32.0),
1507            font: Some(FontSpec {
1508                family: Some("Inter".into()),
1509                size: Some(FontSize::Px(14.0)),
1510                weight: Some(400),
1511                ..Default::default()
1512            }),
1513            border: Some(BorderSpec {
1514                color: Some(Rgba::rgb(180, 180, 180)),
1515                corner_radius: Some(4.0),
1516                line_width: Some(1.0),
1517                ..Default::default()
1518            }),
1519        };
1520        let toml_str = toml::to_string(&w).unwrap();
1521        let w2: DualNestedTestWidget = toml::from_str(&toml_str).unwrap();
1522        assert_eq!(w, w2);
1523    }
1524
1525    // === LayoutTheme tests ===
1526
1527    #[test]
1528    fn layout_theme_default_is_empty() {
1529        assert!(LayoutTheme::default().is_empty());
1530    }
1531
1532    #[test]
1533    fn layout_theme_not_empty_when_widget_gap_set() {
1534        let l = LayoutTheme {
1535            widget_gap: Some(8.0),
1536            ..Default::default()
1537        };
1538        assert!(!l.is_empty());
1539    }
1540
1541    #[test]
1542    fn layout_theme_field_names() {
1543        assert_eq!(LayoutTheme::FIELD_NAMES.len(), 4);
1544        assert!(LayoutTheme::FIELD_NAMES.contains(&"widget_gap_px"));
1545        assert!(LayoutTheme::FIELD_NAMES.contains(&"container_margin_px"));
1546        assert!(LayoutTheme::FIELD_NAMES.contains(&"window_margin_px"));
1547        assert!(LayoutTheme::FIELD_NAMES.contains(&"section_gap_px"));
1548    }
1549
1550    #[test]
1551    fn layout_theme_toml_round_trip() {
1552        let l = LayoutTheme {
1553            widget_gap: Some(8.0),
1554            container_margin: Some(12.0),
1555            window_margin: Some(16.0),
1556            section_gap: Some(24.0),
1557        };
1558        let toml_str = toml::to_string(&l).unwrap();
1559        let l2: LayoutTheme = toml::from_str(&toml_str).unwrap();
1560        assert_eq!(l, l2);
1561    }
1562
1563    #[test]
1564    fn layout_theme_merge() {
1565        let mut base = LayoutTheme {
1566            widget_gap: Some(6.0),
1567            container_margin: Some(10.0),
1568            ..Default::default()
1569        };
1570        let overlay = LayoutTheme {
1571            widget_gap: Some(8.0),
1572            section_gap: Some(24.0),
1573            ..Default::default()
1574        };
1575        base.merge(&overlay);
1576        // overlay widget_gap replaces base
1577        assert_eq!(base.widget_gap, Some(8.0));
1578        // base container_margin preserved
1579        assert_eq!(base.container_margin, Some(10.0));
1580        // overlay section_gap added
1581        assert_eq!(base.section_gap, Some(24.0));
1582        // window_margin stays None
1583        assert!(base.window_margin.is_none());
1584    }
1585}