Skip to main content

native_theme/
resolve.rs

1// Resolution engine: resolve() fills inheritance rules, validate() produces ResolvedThemeVariant.
2
3use crate::error::ThemeResolutionError;
4use crate::model::resolved::{
5    ResolvedIconSizes, ResolvedTextScale, ResolvedTextScaleEntry, ResolvedThemeDefaults,
6    ResolvedThemeSpacing, ResolvedThemeVariant,
7};
8use crate::model::{FontSpec, ResolvedFontSpec, TextScaleEntry, ThemeVariant};
9
10/// Resolve a per-widget font from defaults.
11/// If the widget font is None, clone defaults entirely.
12/// If the widget font is Some but has None sub-fields, fill from defaults.
13fn resolve_font(widget_font: &mut Option<FontSpec>, defaults_font: &FontSpec) {
14    match widget_font {
15        None => {
16            *widget_font = Some(defaults_font.clone());
17        }
18        Some(font) => {
19            if font.family.is_none() {
20                font.family = defaults_font.family.clone();
21            }
22            if font.size.is_none() {
23                font.size = defaults_font.size;
24            }
25            if font.weight.is_none() {
26                font.weight = defaults_font.weight;
27            }
28        }
29    }
30}
31
32/// Resolve a text scale entry from defaults.
33/// Creates the entry if None, fills sub-fields from defaults.font,
34/// computes line_height from defaults.line_height * resolved_size.
35fn resolve_text_scale_entry(
36    entry: &mut Option<TextScaleEntry>,
37    defaults_font: &FontSpec,
38    defaults_line_height: Option<f32>,
39) {
40    let entry = entry.get_or_insert_with(TextScaleEntry::default);
41    if entry.size.is_none() {
42        entry.size = defaults_font.size;
43    }
44    if entry.weight.is_none() {
45        entry.weight = defaults_font.weight;
46    }
47    if entry.line_height.is_none()
48        && let (Some(lh_mult), Some(size)) = (defaults_line_height, entry.size)
49    {
50        entry.line_height = Some(lh_mult * size);
51    }
52}
53
54// --- validate() helpers ---
55
56/// Extract a required field, recording the path if missing.
57///
58/// Returns the value if present, or `T::default()` as a placeholder if missing.
59/// The placeholder is never used: `validate()` returns `Err` before constructing
60/// `ResolvedThemeVariant` when any field was recorded as missing.
61fn require<T: Clone + Default>(field: &Option<T>, path: &str, missing: &mut Vec<String>) -> T {
62    match field {
63        Some(val) => val.clone(),
64        None => {
65            missing.push(path.to_string());
66            T::default()
67        }
68    }
69}
70
71/// Validate a FontSpec that is stored directly (not wrapped in Option).
72/// Checks each sub-field individually.
73fn require_font(font: &FontSpec, prefix: &str, missing: &mut Vec<String>) -> ResolvedFontSpec {
74    let family = require(&font.family, &format!("{prefix}.family"), missing);
75    let size = require(&font.size, &format!("{prefix}.size"), missing);
76    let weight = require(&font.weight, &format!("{prefix}.weight"), missing);
77    ResolvedFontSpec {
78        family,
79        size,
80        weight,
81    }
82}
83
84/// Validate an `Option<FontSpec>` (widget font fields).
85/// If None, records the path as missing.
86fn require_font_opt(
87    font: &Option<FontSpec>,
88    prefix: &str,
89    missing: &mut Vec<String>,
90) -> ResolvedFontSpec {
91    match font {
92        None => {
93            missing.push(prefix.to_string());
94            ResolvedFontSpec::default()
95        }
96        Some(f) => {
97            let family = require(&f.family, &format!("{prefix}.family"), missing);
98            let size = require(&f.size, &format!("{prefix}.size"), missing);
99            let weight = require(&f.weight, &format!("{prefix}.weight"), missing);
100            ResolvedFontSpec {
101                family,
102                size,
103                weight,
104            }
105        }
106    }
107}
108
109/// Validate an `Option<TextScaleEntry>`.
110fn require_text_scale_entry(
111    entry: &Option<TextScaleEntry>,
112    prefix: &str,
113    missing: &mut Vec<String>,
114) -> ResolvedTextScaleEntry {
115    match entry {
116        None => {
117            missing.push(prefix.to_string());
118            ResolvedTextScaleEntry::default()
119        }
120        Some(e) => {
121            let size = require(&e.size, &format!("{prefix}.size"), missing);
122            let weight = require(&e.weight, &format!("{prefix}.weight"), missing);
123            let line_height = require(&e.line_height, &format!("{prefix}.line_height"), missing);
124            ResolvedTextScaleEntry {
125                size,
126                weight,
127                line_height,
128            }
129        }
130    }
131}
132
133impl ThemeVariant {
134    /// Apply all ~90 inheritance rules in 4-phase order.
135    ///
136    /// After calling resolve(), most Option fields that were None will be filled
137    /// from defaults or related widget fields. Calling resolve() twice produces
138    /// the same result (idempotent).
139    ///
140    /// # Phases
141    ///
142    /// 1. **Defaults internal chains** -- accent derives selection, focus_ring_color;
143    ///    selection derives selection_inactive.
144    /// 2. **Safety nets** -- platform-divergent fields get a reasonable fallback.
145    /// 3. **Widget-from-defaults** -- colors, geometry, fonts, text scale entries
146    ///    all inherit from defaults.
147    /// 4. **Widget-to-widget** -- inactive title bar fields fall back to active.
148    pub fn resolve(&mut self) {
149        self.resolve_defaults_internal();
150        self.resolve_safety_nets();
151        self.resolve_widgets_from_defaults();
152        self.resolve_widget_to_widget();
153
154        // Phase 5: icon_set fallback — fill from system default if not set
155        if self.icon_set.is_none() {
156            self.icon_set = Some(crate::model::icons::system_icon_set());
157        }
158
159        // Phase 6: icon_theme fallback — fill from system icon theme if not set
160        if self.icon_theme.is_none() {
161            self.icon_theme = Some(crate::model::icons::system_icon_theme().to_string());
162        }
163    }
164
165    /// Resolve all inheritance rules and validate in one step.
166    ///
167    /// This is the recommended way to convert a `ThemeVariant` into a
168    /// [`ResolvedThemeVariant`]. It calls [`resolve()`](Self::resolve) followed
169    /// by [`validate()`](Self::validate), ensuring no fields are left
170    /// unresolved.
171    ///
172    /// # Errors
173    ///
174    /// Returns [`crate::Error::Resolution`] if any fields remain `None` after
175    /// resolution (e.g., when accent color is missing and cannot be derived).
176    ///
177    /// # Examples
178    ///
179    /// ```
180    /// use native_theme::ThemeSpec;
181    ///
182    /// let theme = ThemeSpec::preset("dracula").unwrap();
183    /// let variant = theme.dark.unwrap();
184    /// let resolved = variant.into_resolved().unwrap();
185    /// // All fields are now guaranteed populated
186    /// let accent = resolved.defaults.accent;
187    /// ```
188    #[must_use = "this returns the resolved theme; it does not modify self"]
189    pub fn into_resolved(mut self) -> crate::Result<ResolvedThemeVariant> {
190        self.resolve();
191        self.validate()
192    }
193
194    // --- Phase 1: Defaults internal chains ---
195
196    fn resolve_defaults_internal(&mut self) {
197        let d = &mut self.defaults;
198
199        // selection <- accent
200        if d.selection.is_none() {
201            d.selection = d.accent;
202        }
203        // focus_ring_color <- accent
204        if d.focus_ring_color.is_none() {
205            d.focus_ring_color = d.accent;
206        }
207        // selection_inactive <- selection (MUST run after selection is set)
208        if d.selection_inactive.is_none() {
209            d.selection_inactive = d.selection;
210        }
211    }
212
213    // --- Phase 2: Safety nets ---
214
215    fn resolve_safety_nets(&mut self) {
216        // input.caret <- defaults.foreground
217        if self.input.caret.is_none() {
218            self.input.caret = self.defaults.foreground;
219        }
220        // scrollbar.track <- defaults.background
221        if self.scrollbar.track.is_none() {
222            self.scrollbar.track = self.defaults.background;
223        }
224        // spinner.fill <- defaults.foreground
225        if self.spinner.fill.is_none() {
226            self.spinner.fill = self.defaults.foreground;
227        }
228        // popover.background <- defaults.background
229        if self.popover.background.is_none() {
230            self.popover.background = self.defaults.background;
231        }
232        // list.background <- defaults.background
233        if self.list.background.is_none() {
234            self.list.background = self.defaults.background;
235        }
236    }
237
238    // --- Phase 3: Widget-from-defaults ---
239
240    fn resolve_widgets_from_defaults(&mut self) {
241        self.resolve_color_inheritance();
242        self.resolve_font_inheritance();
243        self.resolve_text_scale();
244    }
245
246    fn resolve_color_inheritance(&mut self) {
247        let d = &self.defaults;
248
249        // --- window ---
250        if self.window.background.is_none() {
251            self.window.background = d.background;
252        }
253        if self.window.foreground.is_none() {
254            self.window.foreground = d.foreground;
255        }
256        if self.window.border.is_none() {
257            self.window.border = d.border;
258        }
259        if self.window.title_bar_background.is_none() {
260            self.window.title_bar_background = d.surface;
261        }
262        if self.window.title_bar_foreground.is_none() {
263            self.window.title_bar_foreground = d.foreground;
264        }
265        if self.window.radius.is_none() {
266            self.window.radius = d.radius_lg;
267        }
268        if self.window.shadow.is_none() {
269            self.window.shadow = d.shadow_enabled;
270        }
271
272        // --- button ---
273        if self.button.background.is_none() {
274            self.button.background = d.background;
275        }
276        if self.button.foreground.is_none() {
277            self.button.foreground = d.foreground;
278        }
279        if self.button.border.is_none() {
280            self.button.border = d.border;
281        }
282        if self.button.primary_bg.is_none() {
283            self.button.primary_bg = d.accent;
284        }
285        if self.button.primary_fg.is_none() {
286            self.button.primary_fg = d.accent_foreground;
287        }
288        if self.button.radius.is_none() {
289            self.button.radius = d.radius;
290        }
291        if self.button.disabled_opacity.is_none() {
292            self.button.disabled_opacity = d.disabled_opacity;
293        }
294        if self.button.shadow.is_none() {
295            self.button.shadow = d.shadow_enabled;
296        }
297
298        // --- input ---
299        if self.input.background.is_none() {
300            self.input.background = d.background;
301        }
302        if self.input.foreground.is_none() {
303            self.input.foreground = d.foreground;
304        }
305        if self.input.border.is_none() {
306            self.input.border = d.border;
307        }
308        if self.input.placeholder.is_none() {
309            self.input.placeholder = d.muted;
310        }
311        if self.input.selection.is_none() {
312            self.input.selection = d.selection;
313        }
314        if self.input.selection_foreground.is_none() {
315            self.input.selection_foreground = d.selection_foreground;
316        }
317        if self.input.radius.is_none() {
318            self.input.radius = d.radius;
319        }
320        if self.input.border_width.is_none() {
321            self.input.border_width = d.frame_width;
322        }
323
324        // --- checkbox ---
325        if self.checkbox.checked_bg.is_none() {
326            self.checkbox.checked_bg = d.accent;
327        }
328        if self.checkbox.radius.is_none() {
329            self.checkbox.radius = d.radius;
330        }
331        if self.checkbox.border_width.is_none() {
332            self.checkbox.border_width = d.frame_width;
333        }
334
335        // --- menu ---
336        if self.menu.background.is_none() {
337            self.menu.background = d.background;
338        }
339        if self.menu.foreground.is_none() {
340            self.menu.foreground = d.foreground;
341        }
342        if self.menu.separator.is_none() {
343            self.menu.separator = d.border;
344        }
345
346        // --- tooltip ---
347        if self.tooltip.background.is_none() {
348            self.tooltip.background = d.background;
349        }
350        if self.tooltip.foreground.is_none() {
351            self.tooltip.foreground = d.foreground;
352        }
353        if self.tooltip.radius.is_none() {
354            self.tooltip.radius = d.radius;
355        }
356
357        // --- scrollbar ---
358        if self.scrollbar.thumb.is_none() {
359            self.scrollbar.thumb = d.muted;
360        }
361        if self.scrollbar.thumb_hover.is_none() {
362            self.scrollbar.thumb_hover = d.muted;
363        }
364
365        // --- slider ---
366        if self.slider.fill.is_none() {
367            self.slider.fill = d.accent;
368        }
369        if self.slider.track.is_none() {
370            self.slider.track = d.muted;
371        }
372        if self.slider.thumb.is_none() {
373            self.slider.thumb = d.surface;
374        }
375
376        // --- progress_bar ---
377        if self.progress_bar.fill.is_none() {
378            self.progress_bar.fill = d.accent;
379        }
380        if self.progress_bar.track.is_none() {
381            self.progress_bar.track = d.muted;
382        }
383        if self.progress_bar.radius.is_none() {
384            self.progress_bar.radius = d.radius;
385        }
386
387        // --- tab ---
388        if self.tab.background.is_none() {
389            self.tab.background = d.background;
390        }
391        if self.tab.foreground.is_none() {
392            self.tab.foreground = d.foreground;
393        }
394        if self.tab.active_background.is_none() {
395            self.tab.active_background = d.background;
396        }
397        if self.tab.active_foreground.is_none() {
398            self.tab.active_foreground = d.foreground;
399        }
400        if self.tab.bar_background.is_none() {
401            self.tab.bar_background = d.background;
402        }
403
404        // --- sidebar ---
405        if self.sidebar.background.is_none() {
406            self.sidebar.background = d.background;
407        }
408        if self.sidebar.foreground.is_none() {
409            self.sidebar.foreground = d.foreground;
410        }
411
412        // --- list ---
413        if self.list.foreground.is_none() {
414            self.list.foreground = d.foreground;
415        }
416        if self.list.alternate_row.is_none() {
417            self.list.alternate_row = self.list.background;
418        }
419        if self.list.selection.is_none() {
420            self.list.selection = d.selection;
421        }
422        if self.list.selection_foreground.is_none() {
423            self.list.selection_foreground = d.selection_foreground;
424        }
425        if self.list.header_background.is_none() {
426            self.list.header_background = d.surface;
427        }
428        if self.list.header_foreground.is_none() {
429            self.list.header_foreground = d.foreground;
430        }
431        if self.list.grid_color.is_none() {
432            self.list.grid_color = d.border;
433        }
434
435        // --- popover ---
436        if self.popover.foreground.is_none() {
437            self.popover.foreground = d.foreground;
438        }
439        if self.popover.border.is_none() {
440            self.popover.border = d.border;
441        }
442        if self.popover.radius.is_none() {
443            self.popover.radius = d.radius_lg;
444        }
445
446        // --- separator ---
447        if self.separator.color.is_none() {
448            self.separator.color = d.border;
449        }
450
451        // --- switch ---
452        if self.switch.checked_bg.is_none() {
453            self.switch.checked_bg = d.accent;
454        }
455        if self.switch.thumb_bg.is_none() {
456            self.switch.thumb_bg = d.surface;
457        }
458
459        // --- dialog ---
460        if self.dialog.radius.is_none() {
461            self.dialog.radius = d.radius_lg;
462        }
463
464        // --- combo_box ---
465        if self.combo_box.radius.is_none() {
466            self.combo_box.radius = d.radius;
467        }
468
469        // --- segmented_control ---
470        if self.segmented_control.radius.is_none() {
471            self.segmented_control.radius = d.radius;
472        }
473
474        // --- card ---
475        if self.card.background.is_none() {
476            self.card.background = d.surface;
477        }
478        if self.card.border.is_none() {
479            self.card.border = d.border;
480        }
481        if self.card.radius.is_none() {
482            self.card.radius = d.radius_lg;
483        }
484        if self.card.shadow.is_none() {
485            self.card.shadow = d.shadow_enabled;
486        }
487
488        // --- expander ---
489        if self.expander.radius.is_none() {
490            self.expander.radius = d.radius;
491        }
492
493        // --- link ---
494        if self.link.color.is_none() {
495            self.link.color = d.link;
496        }
497        if self.link.visited.is_none() {
498            self.link.visited = d.link;
499        }
500    }
501
502    fn resolve_font_inheritance(&mut self) {
503        let defaults_font = &self.defaults.font.clone();
504        resolve_font(&mut self.window.title_bar_font, defaults_font);
505        resolve_font(&mut self.button.font, defaults_font);
506        resolve_font(&mut self.input.font, defaults_font);
507        resolve_font(&mut self.menu.font, defaults_font);
508        resolve_font(&mut self.tooltip.font, defaults_font);
509        resolve_font(&mut self.toolbar.font, defaults_font);
510        resolve_font(&mut self.status_bar.font, defaults_font);
511        resolve_font(&mut self.dialog.title_font, defaults_font);
512    }
513
514    fn resolve_text_scale(&mut self) {
515        let defaults_font = &self.defaults.font.clone();
516        let defaults_lh = self.defaults.line_height;
517        resolve_text_scale_entry(&mut self.text_scale.caption, defaults_font, defaults_lh);
518        resolve_text_scale_entry(
519            &mut self.text_scale.section_heading,
520            defaults_font,
521            defaults_lh,
522        );
523        resolve_text_scale_entry(
524            &mut self.text_scale.dialog_title,
525            defaults_font,
526            defaults_lh,
527        );
528        resolve_text_scale_entry(&mut self.text_scale.display, defaults_font, defaults_lh);
529    }
530
531    // --- Phase 4: Widget-to-widget chains ---
532
533    fn resolve_widget_to_widget(&mut self) {
534        // inactive title bar <- active title bar
535        if self.window.inactive_title_bar_background.is_none() {
536            self.window.inactive_title_bar_background = self.window.title_bar_background;
537        }
538        if self.window.inactive_title_bar_foreground.is_none() {
539            self.window.inactive_title_bar_foreground = self.window.title_bar_foreground;
540        }
541    }
542
543    // --- validate() ---
544
545    /// Convert this ThemeVariant into a [`ResolvedThemeVariant`] with all fields guaranteed.
546    ///
547    /// Should be called after [`resolve()`](ThemeVariant::resolve). Walks every field
548    /// and collects missing (None) field paths. Returns `Ok(ResolvedThemeVariant)` if all fields
549    /// are populated, or `Err(Error::Resolution(...))` listing every missing field.
550    ///
551    /// # Errors
552    ///
553    /// Returns [`crate::Error::Resolution`] containing a [`ThemeResolutionError`]
554    /// with all missing field paths if any fields remain None.
555    #[must_use = "this returns the resolved theme; it does not modify self"]
556    pub fn validate(&self) -> crate::Result<ResolvedThemeVariant> {
557        let mut missing = Vec::new();
558
559        // --- defaults ---
560
561        let defaults_font = require_font(&self.defaults.font, "defaults.font", &mut missing);
562        let defaults_line_height = require(
563            &self.defaults.line_height,
564            "defaults.line_height",
565            &mut missing,
566        );
567        let defaults_mono_font =
568            require_font(&self.defaults.mono_font, "defaults.mono_font", &mut missing);
569
570        let defaults_background = require(
571            &self.defaults.background,
572            "defaults.background",
573            &mut missing,
574        );
575        let defaults_foreground = require(
576            &self.defaults.foreground,
577            "defaults.foreground",
578            &mut missing,
579        );
580        let defaults_accent = require(&self.defaults.accent, "defaults.accent", &mut missing);
581        let defaults_accent_foreground = require(
582            &self.defaults.accent_foreground,
583            "defaults.accent_foreground",
584            &mut missing,
585        );
586        let defaults_surface = require(&self.defaults.surface, "defaults.surface", &mut missing);
587        let defaults_border = require(&self.defaults.border, "defaults.border", &mut missing);
588        let defaults_muted = require(&self.defaults.muted, "defaults.muted", &mut missing);
589        let defaults_shadow = require(&self.defaults.shadow, "defaults.shadow", &mut missing);
590        let defaults_link = require(&self.defaults.link, "defaults.link", &mut missing);
591        let defaults_selection =
592            require(&self.defaults.selection, "defaults.selection", &mut missing);
593        let defaults_selection_foreground = require(
594            &self.defaults.selection_foreground,
595            "defaults.selection_foreground",
596            &mut missing,
597        );
598        let defaults_selection_inactive = require(
599            &self.defaults.selection_inactive,
600            "defaults.selection_inactive",
601            &mut missing,
602        );
603        let defaults_disabled_foreground = require(
604            &self.defaults.disabled_foreground,
605            "defaults.disabled_foreground",
606            &mut missing,
607        );
608
609        let defaults_danger = require(&self.defaults.danger, "defaults.danger", &mut missing);
610        let defaults_danger_foreground = require(
611            &self.defaults.danger_foreground,
612            "defaults.danger_foreground",
613            &mut missing,
614        );
615        let defaults_warning = require(&self.defaults.warning, "defaults.warning", &mut missing);
616        let defaults_warning_foreground = require(
617            &self.defaults.warning_foreground,
618            "defaults.warning_foreground",
619            &mut missing,
620        );
621        let defaults_success = require(&self.defaults.success, "defaults.success", &mut missing);
622        let defaults_success_foreground = require(
623            &self.defaults.success_foreground,
624            "defaults.success_foreground",
625            &mut missing,
626        );
627        let defaults_info = require(&self.defaults.info, "defaults.info", &mut missing);
628        let defaults_info_foreground = require(
629            &self.defaults.info_foreground,
630            "defaults.info_foreground",
631            &mut missing,
632        );
633
634        let defaults_radius = require(&self.defaults.radius, "defaults.radius", &mut missing);
635        let defaults_radius_lg =
636            require(&self.defaults.radius_lg, "defaults.radius_lg", &mut missing);
637        let defaults_frame_width = require(
638            &self.defaults.frame_width,
639            "defaults.frame_width",
640            &mut missing,
641        );
642        let defaults_disabled_opacity = require(
643            &self.defaults.disabled_opacity,
644            "defaults.disabled_opacity",
645            &mut missing,
646        );
647        let defaults_border_opacity = require(
648            &self.defaults.border_opacity,
649            "defaults.border_opacity",
650            &mut missing,
651        );
652        let defaults_shadow_enabled = require(
653            &self.defaults.shadow_enabled,
654            "defaults.shadow_enabled",
655            &mut missing,
656        );
657
658        let defaults_focus_ring_color = require(
659            &self.defaults.focus_ring_color,
660            "defaults.focus_ring_color",
661            &mut missing,
662        );
663        let defaults_focus_ring_width = require(
664            &self.defaults.focus_ring_width,
665            "defaults.focus_ring_width",
666            &mut missing,
667        );
668        let defaults_focus_ring_offset = require(
669            &self.defaults.focus_ring_offset,
670            "defaults.focus_ring_offset",
671            &mut missing,
672        );
673
674        let defaults_spacing_xxs = require(
675            &self.defaults.spacing.xxs,
676            "defaults.spacing.xxs",
677            &mut missing,
678        );
679        let defaults_spacing_xs = require(
680            &self.defaults.spacing.xs,
681            "defaults.spacing.xs",
682            &mut missing,
683        );
684        let defaults_spacing_s =
685            require(&self.defaults.spacing.s, "defaults.spacing.s", &mut missing);
686        let defaults_spacing_m =
687            require(&self.defaults.spacing.m, "defaults.spacing.m", &mut missing);
688        let defaults_spacing_l =
689            require(&self.defaults.spacing.l, "defaults.spacing.l", &mut missing);
690        let defaults_spacing_xl = require(
691            &self.defaults.spacing.xl,
692            "defaults.spacing.xl",
693            &mut missing,
694        );
695        let defaults_spacing_xxl = require(
696            &self.defaults.spacing.xxl,
697            "defaults.spacing.xxl",
698            &mut missing,
699        );
700
701        let defaults_icon_sizes_toolbar = require(
702            &self.defaults.icon_sizes.toolbar,
703            "defaults.icon_sizes.toolbar",
704            &mut missing,
705        );
706        let defaults_icon_sizes_small = require(
707            &self.defaults.icon_sizes.small,
708            "defaults.icon_sizes.small",
709            &mut missing,
710        );
711        let defaults_icon_sizes_large = require(
712            &self.defaults.icon_sizes.large,
713            "defaults.icon_sizes.large",
714            &mut missing,
715        );
716        let defaults_icon_sizes_dialog = require(
717            &self.defaults.icon_sizes.dialog,
718            "defaults.icon_sizes.dialog",
719            &mut missing,
720        );
721        let defaults_icon_sizes_panel = require(
722            &self.defaults.icon_sizes.panel,
723            "defaults.icon_sizes.panel",
724            &mut missing,
725        );
726
727        let defaults_text_scaling_factor = require(
728            &self.defaults.text_scaling_factor,
729            "defaults.text_scaling_factor",
730            &mut missing,
731        );
732        let defaults_reduce_motion = require(
733            &self.defaults.reduce_motion,
734            "defaults.reduce_motion",
735            &mut missing,
736        );
737        let defaults_high_contrast = require(
738            &self.defaults.high_contrast,
739            "defaults.high_contrast",
740            &mut missing,
741        );
742        let defaults_reduce_transparency = require(
743            &self.defaults.reduce_transparency,
744            "defaults.reduce_transparency",
745            &mut missing,
746        );
747
748        // --- text_scale ---
749
750        let ts_caption =
751            require_text_scale_entry(&self.text_scale.caption, "text_scale.caption", &mut missing);
752        let ts_section_heading = require_text_scale_entry(
753            &self.text_scale.section_heading,
754            "text_scale.section_heading",
755            &mut missing,
756        );
757        let ts_dialog_title = require_text_scale_entry(
758            &self.text_scale.dialog_title,
759            "text_scale.dialog_title",
760            &mut missing,
761        );
762        let ts_display =
763            require_text_scale_entry(&self.text_scale.display, "text_scale.display", &mut missing);
764
765        // --- window ---
766
767        let window_background = require(&self.window.background, "window.background", &mut missing);
768        let window_foreground = require(&self.window.foreground, "window.foreground", &mut missing);
769        let window_border = require(&self.window.border, "window.border", &mut missing);
770        let window_title_bar_background = require(
771            &self.window.title_bar_background,
772            "window.title_bar_background",
773            &mut missing,
774        );
775        let window_title_bar_foreground = require(
776            &self.window.title_bar_foreground,
777            "window.title_bar_foreground",
778            &mut missing,
779        );
780        let window_inactive_title_bar_background = require(
781            &self.window.inactive_title_bar_background,
782            "window.inactive_title_bar_background",
783            &mut missing,
784        );
785        let window_inactive_title_bar_foreground = require(
786            &self.window.inactive_title_bar_foreground,
787            "window.inactive_title_bar_foreground",
788            &mut missing,
789        );
790        let window_radius = require(&self.window.radius, "window.radius", &mut missing);
791        let window_shadow = require(&self.window.shadow, "window.shadow", &mut missing);
792        let window_title_bar_font = require_font_opt(
793            &self.window.title_bar_font,
794            "window.title_bar_font",
795            &mut missing,
796        );
797
798        // --- button ---
799
800        let button_background = require(&self.button.background, "button.background", &mut missing);
801        let button_foreground = require(&self.button.foreground, "button.foreground", &mut missing);
802        let button_border = require(&self.button.border, "button.border", &mut missing);
803        let button_primary_bg = require(&self.button.primary_bg, "button.primary_bg", &mut missing);
804        let button_primary_fg = require(&self.button.primary_fg, "button.primary_fg", &mut missing);
805        let button_min_width = require(&self.button.min_width, "button.min_width", &mut missing);
806        let button_min_height = require(&self.button.min_height, "button.min_height", &mut missing);
807        let button_padding_horizontal = require(
808            &self.button.padding_horizontal,
809            "button.padding_horizontal",
810            &mut missing,
811        );
812        let button_padding_vertical = require(
813            &self.button.padding_vertical,
814            "button.padding_vertical",
815            &mut missing,
816        );
817        let button_radius = require(&self.button.radius, "button.radius", &mut missing);
818        let button_icon_spacing = require(
819            &self.button.icon_spacing,
820            "button.icon_spacing",
821            &mut missing,
822        );
823        let button_disabled_opacity = require(
824            &self.button.disabled_opacity,
825            "button.disabled_opacity",
826            &mut missing,
827        );
828        let button_shadow = require(&self.button.shadow, "button.shadow", &mut missing);
829        let button_font = require_font_opt(&self.button.font, "button.font", &mut missing);
830
831        // --- input ---
832
833        let input_background = require(&self.input.background, "input.background", &mut missing);
834        let input_foreground = require(&self.input.foreground, "input.foreground", &mut missing);
835        let input_border = require(&self.input.border, "input.border", &mut missing);
836        let input_placeholder = require(&self.input.placeholder, "input.placeholder", &mut missing);
837        let input_caret = require(&self.input.caret, "input.caret", &mut missing);
838        let input_selection = require(&self.input.selection, "input.selection", &mut missing);
839        let input_selection_foreground = require(
840            &self.input.selection_foreground,
841            "input.selection_foreground",
842            &mut missing,
843        );
844        let input_min_height = require(&self.input.min_height, "input.min_height", &mut missing);
845        let input_padding_horizontal = require(
846            &self.input.padding_horizontal,
847            "input.padding_horizontal",
848            &mut missing,
849        );
850        let input_padding_vertical = require(
851            &self.input.padding_vertical,
852            "input.padding_vertical",
853            &mut missing,
854        );
855        let input_radius = require(&self.input.radius, "input.radius", &mut missing);
856        let input_border_width =
857            require(&self.input.border_width, "input.border_width", &mut missing);
858        let input_font = require_font_opt(&self.input.font, "input.font", &mut missing);
859
860        // --- checkbox ---
861
862        let checkbox_checked_bg = require(
863            &self.checkbox.checked_bg,
864            "checkbox.checked_bg",
865            &mut missing,
866        );
867        let checkbox_indicator_size = require(
868            &self.checkbox.indicator_size,
869            "checkbox.indicator_size",
870            &mut missing,
871        );
872        let checkbox_spacing = require(&self.checkbox.spacing, "checkbox.spacing", &mut missing);
873        let checkbox_radius = require(&self.checkbox.radius, "checkbox.radius", &mut missing);
874        let checkbox_border_width = require(
875            &self.checkbox.border_width,
876            "checkbox.border_width",
877            &mut missing,
878        );
879
880        // --- menu ---
881
882        let menu_background = require(&self.menu.background, "menu.background", &mut missing);
883        let menu_foreground = require(&self.menu.foreground, "menu.foreground", &mut missing);
884        let menu_separator = require(&self.menu.separator, "menu.separator", &mut missing);
885        let menu_item_height = require(&self.menu.item_height, "menu.item_height", &mut missing);
886        let menu_padding_horizontal = require(
887            &self.menu.padding_horizontal,
888            "menu.padding_horizontal",
889            &mut missing,
890        );
891        let menu_padding_vertical = require(
892            &self.menu.padding_vertical,
893            "menu.padding_vertical",
894            &mut missing,
895        );
896        let menu_icon_spacing = require(&self.menu.icon_spacing, "menu.icon_spacing", &mut missing);
897        let menu_font = require_font_opt(&self.menu.font, "menu.font", &mut missing);
898
899        // --- tooltip ---
900
901        let tooltip_background =
902            require(&self.tooltip.background, "tooltip.background", &mut missing);
903        let tooltip_foreground =
904            require(&self.tooltip.foreground, "tooltip.foreground", &mut missing);
905        let tooltip_padding_horizontal = require(
906            &self.tooltip.padding_horizontal,
907            "tooltip.padding_horizontal",
908            &mut missing,
909        );
910        let tooltip_padding_vertical = require(
911            &self.tooltip.padding_vertical,
912            "tooltip.padding_vertical",
913            &mut missing,
914        );
915        let tooltip_max_width = require(&self.tooltip.max_width, "tooltip.max_width", &mut missing);
916        let tooltip_radius = require(&self.tooltip.radius, "tooltip.radius", &mut missing);
917        let tooltip_font = require_font_opt(&self.tooltip.font, "tooltip.font", &mut missing);
918
919        // --- scrollbar ---
920
921        let scrollbar_track = require(&self.scrollbar.track, "scrollbar.track", &mut missing);
922        let scrollbar_thumb = require(&self.scrollbar.thumb, "scrollbar.thumb", &mut missing);
923        let scrollbar_thumb_hover = require(
924            &self.scrollbar.thumb_hover,
925            "scrollbar.thumb_hover",
926            &mut missing,
927        );
928        let scrollbar_width = require(&self.scrollbar.width, "scrollbar.width", &mut missing);
929        let scrollbar_min_thumb_height = require(
930            &self.scrollbar.min_thumb_height,
931            "scrollbar.min_thumb_height",
932            &mut missing,
933        );
934        let scrollbar_slider_width = require(
935            &self.scrollbar.slider_width,
936            "scrollbar.slider_width",
937            &mut missing,
938        );
939        let scrollbar_overlay_mode = require(
940            &self.scrollbar.overlay_mode,
941            "scrollbar.overlay_mode",
942            &mut missing,
943        );
944
945        // --- slider ---
946
947        let slider_fill = require(&self.slider.fill, "slider.fill", &mut missing);
948        let slider_track = require(&self.slider.track, "slider.track", &mut missing);
949        let slider_thumb = require(&self.slider.thumb, "slider.thumb", &mut missing);
950        let slider_track_height = require(
951            &self.slider.track_height,
952            "slider.track_height",
953            &mut missing,
954        );
955        let slider_thumb_size = require(&self.slider.thumb_size, "slider.thumb_size", &mut missing);
956        let slider_tick_length =
957            require(&self.slider.tick_length, "slider.tick_length", &mut missing);
958
959        // --- progress_bar ---
960
961        let progress_bar_fill = require(&self.progress_bar.fill, "progress_bar.fill", &mut missing);
962        let progress_bar_track =
963            require(&self.progress_bar.track, "progress_bar.track", &mut missing);
964        let progress_bar_height = require(
965            &self.progress_bar.height,
966            "progress_bar.height",
967            &mut missing,
968        );
969        let progress_bar_min_width = require(
970            &self.progress_bar.min_width,
971            "progress_bar.min_width",
972            &mut missing,
973        );
974        let progress_bar_radius = require(
975            &self.progress_bar.radius,
976            "progress_bar.radius",
977            &mut missing,
978        );
979
980        // --- tab ---
981
982        let tab_background = require(&self.tab.background, "tab.background", &mut missing);
983        let tab_foreground = require(&self.tab.foreground, "tab.foreground", &mut missing);
984        let tab_active_background = require(
985            &self.tab.active_background,
986            "tab.active_background",
987            &mut missing,
988        );
989        let tab_active_foreground = require(
990            &self.tab.active_foreground,
991            "tab.active_foreground",
992            &mut missing,
993        );
994        let tab_bar_background =
995            require(&self.tab.bar_background, "tab.bar_background", &mut missing);
996        let tab_min_width = require(&self.tab.min_width, "tab.min_width", &mut missing);
997        let tab_min_height = require(&self.tab.min_height, "tab.min_height", &mut missing);
998        let tab_padding_horizontal = require(
999            &self.tab.padding_horizontal,
1000            "tab.padding_horizontal",
1001            &mut missing,
1002        );
1003        let tab_padding_vertical = require(
1004            &self.tab.padding_vertical,
1005            "tab.padding_vertical",
1006            &mut missing,
1007        );
1008
1009        // --- sidebar ---
1010
1011        let sidebar_background =
1012            require(&self.sidebar.background, "sidebar.background", &mut missing);
1013        let sidebar_foreground =
1014            require(&self.sidebar.foreground, "sidebar.foreground", &mut missing);
1015
1016        // --- toolbar ---
1017
1018        let toolbar_height = require(&self.toolbar.height, "toolbar.height", &mut missing);
1019        let toolbar_item_spacing = require(
1020            &self.toolbar.item_spacing,
1021            "toolbar.item_spacing",
1022            &mut missing,
1023        );
1024        let toolbar_padding = require(&self.toolbar.padding, "toolbar.padding", &mut missing);
1025        let toolbar_font = require_font_opt(&self.toolbar.font, "toolbar.font", &mut missing);
1026
1027        // --- status_bar ---
1028
1029        let status_bar_font =
1030            require_font_opt(&self.status_bar.font, "status_bar.font", &mut missing);
1031
1032        // --- list ---
1033
1034        let list_background = require(&self.list.background, "list.background", &mut missing);
1035        let list_foreground = require(&self.list.foreground, "list.foreground", &mut missing);
1036        let list_alternate_row =
1037            require(&self.list.alternate_row, "list.alternate_row", &mut missing);
1038        let list_selection = require(&self.list.selection, "list.selection", &mut missing);
1039        let list_selection_foreground = require(
1040            &self.list.selection_foreground,
1041            "list.selection_foreground",
1042            &mut missing,
1043        );
1044        let list_header_background = require(
1045            &self.list.header_background,
1046            "list.header_background",
1047            &mut missing,
1048        );
1049        let list_header_foreground = require(
1050            &self.list.header_foreground,
1051            "list.header_foreground",
1052            &mut missing,
1053        );
1054        let list_grid_color = require(&self.list.grid_color, "list.grid_color", &mut missing);
1055        let list_item_height = require(&self.list.item_height, "list.item_height", &mut missing);
1056        let list_padding_horizontal = require(
1057            &self.list.padding_horizontal,
1058            "list.padding_horizontal",
1059            &mut missing,
1060        );
1061        let list_padding_vertical = require(
1062            &self.list.padding_vertical,
1063            "list.padding_vertical",
1064            &mut missing,
1065        );
1066
1067        // --- popover ---
1068
1069        let popover_background =
1070            require(&self.popover.background, "popover.background", &mut missing);
1071        let popover_foreground =
1072            require(&self.popover.foreground, "popover.foreground", &mut missing);
1073        let popover_border = require(&self.popover.border, "popover.border", &mut missing);
1074        let popover_radius = require(&self.popover.radius, "popover.radius", &mut missing);
1075
1076        // --- splitter ---
1077
1078        let splitter_width = require(&self.splitter.width, "splitter.width", &mut missing);
1079
1080        // --- separator ---
1081
1082        let separator_color = require(&self.separator.color, "separator.color", &mut missing);
1083
1084        // --- switch ---
1085
1086        let switch_checked_bg = require(&self.switch.checked_bg, "switch.checked_bg", &mut missing);
1087        let switch_unchecked_bg = require(
1088            &self.switch.unchecked_bg,
1089            "switch.unchecked_bg",
1090            &mut missing,
1091        );
1092        let switch_thumb_bg = require(&self.switch.thumb_bg, "switch.thumb_bg", &mut missing);
1093        let switch_track_width =
1094            require(&self.switch.track_width, "switch.track_width", &mut missing);
1095        let switch_track_height = require(
1096            &self.switch.track_height,
1097            "switch.track_height",
1098            &mut missing,
1099        );
1100        let switch_thumb_size = require(&self.switch.thumb_size, "switch.thumb_size", &mut missing);
1101        let switch_track_radius = require(
1102            &self.switch.track_radius,
1103            "switch.track_radius",
1104            &mut missing,
1105        );
1106
1107        // --- dialog ---
1108
1109        let dialog_min_width = require(&self.dialog.min_width, "dialog.min_width", &mut missing);
1110        let dialog_max_width = require(&self.dialog.max_width, "dialog.max_width", &mut missing);
1111        let dialog_min_height = require(&self.dialog.min_height, "dialog.min_height", &mut missing);
1112        let dialog_max_height = require(&self.dialog.max_height, "dialog.max_height", &mut missing);
1113        let dialog_content_padding = require(
1114            &self.dialog.content_padding,
1115            "dialog.content_padding",
1116            &mut missing,
1117        );
1118        let dialog_button_spacing = require(
1119            &self.dialog.button_spacing,
1120            "dialog.button_spacing",
1121            &mut missing,
1122        );
1123        let dialog_radius = require(&self.dialog.radius, "dialog.radius", &mut missing);
1124        let dialog_icon_size = require(&self.dialog.icon_size, "dialog.icon_size", &mut missing);
1125        let dialog_button_order = require(
1126            &self.dialog.button_order,
1127            "dialog.button_order",
1128            &mut missing,
1129        );
1130        let dialog_title_font =
1131            require_font_opt(&self.dialog.title_font, "dialog.title_font", &mut missing);
1132
1133        // --- spinner ---
1134
1135        let spinner_fill = require(&self.spinner.fill, "spinner.fill", &mut missing);
1136        let spinner_diameter = require(&self.spinner.diameter, "spinner.diameter", &mut missing);
1137        let spinner_min_size = require(&self.spinner.min_size, "spinner.min_size", &mut missing);
1138        let spinner_stroke_width = require(
1139            &self.spinner.stroke_width,
1140            "spinner.stroke_width",
1141            &mut missing,
1142        );
1143
1144        // --- combo_box ---
1145
1146        let combo_box_min_height = require(
1147            &self.combo_box.min_height,
1148            "combo_box.min_height",
1149            &mut missing,
1150        );
1151        let combo_box_min_width = require(
1152            &self.combo_box.min_width,
1153            "combo_box.min_width",
1154            &mut missing,
1155        );
1156        let combo_box_padding_horizontal = require(
1157            &self.combo_box.padding_horizontal,
1158            "combo_box.padding_horizontal",
1159            &mut missing,
1160        );
1161        let combo_box_arrow_size = require(
1162            &self.combo_box.arrow_size,
1163            "combo_box.arrow_size",
1164            &mut missing,
1165        );
1166        let combo_box_arrow_area_width = require(
1167            &self.combo_box.arrow_area_width,
1168            "combo_box.arrow_area_width",
1169            &mut missing,
1170        );
1171        let combo_box_radius = require(&self.combo_box.radius, "combo_box.radius", &mut missing);
1172
1173        // --- segmented_control ---
1174
1175        let segmented_control_segment_height = require(
1176            &self.segmented_control.segment_height,
1177            "segmented_control.segment_height",
1178            &mut missing,
1179        );
1180        let segmented_control_separator_width = require(
1181            &self.segmented_control.separator_width,
1182            "segmented_control.separator_width",
1183            &mut missing,
1184        );
1185        let segmented_control_padding_horizontal = require(
1186            &self.segmented_control.padding_horizontal,
1187            "segmented_control.padding_horizontal",
1188            &mut missing,
1189        );
1190        let segmented_control_radius = require(
1191            &self.segmented_control.radius,
1192            "segmented_control.radius",
1193            &mut missing,
1194        );
1195
1196        // --- card ---
1197
1198        let card_background = require(&self.card.background, "card.background", &mut missing);
1199        let card_border = require(&self.card.border, "card.border", &mut missing);
1200        let card_radius = require(&self.card.radius, "card.radius", &mut missing);
1201        let card_padding = require(&self.card.padding, "card.padding", &mut missing);
1202        let card_shadow = require(&self.card.shadow, "card.shadow", &mut missing);
1203
1204        // --- expander ---
1205
1206        let expander_header_height = require(
1207            &self.expander.header_height,
1208            "expander.header_height",
1209            &mut missing,
1210        );
1211        let expander_arrow_size = require(
1212            &self.expander.arrow_size,
1213            "expander.arrow_size",
1214            &mut missing,
1215        );
1216        let expander_content_padding = require(
1217            &self.expander.content_padding,
1218            "expander.content_padding",
1219            &mut missing,
1220        );
1221        let expander_radius = require(&self.expander.radius, "expander.radius", &mut missing);
1222
1223        // --- link ---
1224
1225        let link_color = require(&self.link.color, "link.color", &mut missing);
1226        let link_visited = require(&self.link.visited, "link.visited", &mut missing);
1227        let link_background = require(&self.link.background, "link.background", &mut missing);
1228        let link_hover_bg = require(&self.link.hover_bg, "link.hover_bg", &mut missing);
1229        let link_underline = require(&self.link.underline, "link.underline", &mut missing);
1230
1231        // --- icon_set / icon_theme ---
1232
1233        let icon_set = require(&self.icon_set, "icon_set", &mut missing);
1234        let icon_theme = require(&self.icon_theme, "icon_theme", &mut missing);
1235
1236        // --- check for missing fields ---
1237
1238        if !missing.is_empty() {
1239            return Err(crate::Error::Resolution(ThemeResolutionError {
1240                missing_fields: missing,
1241            }));
1242        }
1243
1244        // All fields present -- construct ResolvedThemeVariant.
1245        // require() returns T directly (using T::default() as placeholder for missing),
1246        // so no unwrap() is needed. The defaults are never used: we returned Err above.
1247        Ok(ResolvedThemeVariant {
1248            defaults: ResolvedThemeDefaults {
1249                font: defaults_font,
1250                line_height: defaults_line_height,
1251                mono_font: defaults_mono_font,
1252                background: defaults_background,
1253                foreground: defaults_foreground,
1254                accent: defaults_accent,
1255                accent_foreground: defaults_accent_foreground,
1256                surface: defaults_surface,
1257                border: defaults_border,
1258                muted: defaults_muted,
1259                shadow: defaults_shadow,
1260                link: defaults_link,
1261                selection: defaults_selection,
1262                selection_foreground: defaults_selection_foreground,
1263                selection_inactive: defaults_selection_inactive,
1264                disabled_foreground: defaults_disabled_foreground,
1265                danger: defaults_danger,
1266                danger_foreground: defaults_danger_foreground,
1267                warning: defaults_warning,
1268                warning_foreground: defaults_warning_foreground,
1269                success: defaults_success,
1270                success_foreground: defaults_success_foreground,
1271                info: defaults_info,
1272                info_foreground: defaults_info_foreground,
1273                radius: defaults_radius,
1274                radius_lg: defaults_radius_lg,
1275                frame_width: defaults_frame_width,
1276                disabled_opacity: defaults_disabled_opacity,
1277                border_opacity: defaults_border_opacity,
1278                shadow_enabled: defaults_shadow_enabled,
1279                focus_ring_color: defaults_focus_ring_color,
1280                focus_ring_width: defaults_focus_ring_width,
1281                focus_ring_offset: defaults_focus_ring_offset,
1282                spacing: ResolvedThemeSpacing {
1283                    xxs: defaults_spacing_xxs,
1284                    xs: defaults_spacing_xs,
1285                    s: defaults_spacing_s,
1286                    m: defaults_spacing_m,
1287                    l: defaults_spacing_l,
1288                    xl: defaults_spacing_xl,
1289                    xxl: defaults_spacing_xxl,
1290                },
1291                icon_sizes: ResolvedIconSizes {
1292                    toolbar: defaults_icon_sizes_toolbar,
1293                    small: defaults_icon_sizes_small,
1294                    large: defaults_icon_sizes_large,
1295                    dialog: defaults_icon_sizes_dialog,
1296                    panel: defaults_icon_sizes_panel,
1297                },
1298                text_scaling_factor: defaults_text_scaling_factor,
1299                reduce_motion: defaults_reduce_motion,
1300                high_contrast: defaults_high_contrast,
1301                reduce_transparency: defaults_reduce_transparency,
1302            },
1303            text_scale: ResolvedTextScale {
1304                caption: ts_caption,
1305                section_heading: ts_section_heading,
1306                dialog_title: ts_dialog_title,
1307                display: ts_display,
1308            },
1309            window: crate::model::widgets::ResolvedWindowTheme {
1310                background: window_background,
1311                foreground: window_foreground,
1312                border: window_border,
1313                title_bar_background: window_title_bar_background,
1314                title_bar_foreground: window_title_bar_foreground,
1315                inactive_title_bar_background: window_inactive_title_bar_background,
1316                inactive_title_bar_foreground: window_inactive_title_bar_foreground,
1317                radius: window_radius,
1318                shadow: window_shadow,
1319                title_bar_font: window_title_bar_font,
1320            },
1321            button: crate::model::widgets::ResolvedButtonTheme {
1322                background: button_background,
1323                foreground: button_foreground,
1324                border: button_border,
1325                primary_bg: button_primary_bg,
1326                primary_fg: button_primary_fg,
1327                min_width: button_min_width,
1328                min_height: button_min_height,
1329                padding_horizontal: button_padding_horizontal,
1330                padding_vertical: button_padding_vertical,
1331                radius: button_radius,
1332                icon_spacing: button_icon_spacing,
1333                disabled_opacity: button_disabled_opacity,
1334                shadow: button_shadow,
1335                font: button_font,
1336            },
1337            input: crate::model::widgets::ResolvedInputTheme {
1338                background: input_background,
1339                foreground: input_foreground,
1340                border: input_border,
1341                placeholder: input_placeholder,
1342                caret: input_caret,
1343                selection: input_selection,
1344                selection_foreground: input_selection_foreground,
1345                min_height: input_min_height,
1346                padding_horizontal: input_padding_horizontal,
1347                padding_vertical: input_padding_vertical,
1348                radius: input_radius,
1349                border_width: input_border_width,
1350                font: input_font,
1351            },
1352            checkbox: crate::model::widgets::ResolvedCheckboxTheme {
1353                checked_bg: checkbox_checked_bg,
1354                indicator_size: checkbox_indicator_size,
1355                spacing: checkbox_spacing,
1356                radius: checkbox_radius,
1357                border_width: checkbox_border_width,
1358            },
1359            menu: crate::model::widgets::ResolvedMenuTheme {
1360                background: menu_background,
1361                foreground: menu_foreground,
1362                separator: menu_separator,
1363                item_height: menu_item_height,
1364                padding_horizontal: menu_padding_horizontal,
1365                padding_vertical: menu_padding_vertical,
1366                icon_spacing: menu_icon_spacing,
1367                font: menu_font,
1368            },
1369            tooltip: crate::model::widgets::ResolvedTooltipTheme {
1370                background: tooltip_background,
1371                foreground: tooltip_foreground,
1372                padding_horizontal: tooltip_padding_horizontal,
1373                padding_vertical: tooltip_padding_vertical,
1374                max_width: tooltip_max_width,
1375                radius: tooltip_radius,
1376                font: tooltip_font,
1377            },
1378            scrollbar: crate::model::widgets::ResolvedScrollbarTheme {
1379                track: scrollbar_track,
1380                thumb: scrollbar_thumb,
1381                thumb_hover: scrollbar_thumb_hover,
1382                width: scrollbar_width,
1383                min_thumb_height: scrollbar_min_thumb_height,
1384                slider_width: scrollbar_slider_width,
1385                overlay_mode: scrollbar_overlay_mode,
1386            },
1387            slider: crate::model::widgets::ResolvedSliderTheme {
1388                fill: slider_fill,
1389                track: slider_track,
1390                thumb: slider_thumb,
1391                track_height: slider_track_height,
1392                thumb_size: slider_thumb_size,
1393                tick_length: slider_tick_length,
1394            },
1395            progress_bar: crate::model::widgets::ResolvedProgressBarTheme {
1396                fill: progress_bar_fill,
1397                track: progress_bar_track,
1398                height: progress_bar_height,
1399                min_width: progress_bar_min_width,
1400                radius: progress_bar_radius,
1401            },
1402            tab: crate::model::widgets::ResolvedTabTheme {
1403                background: tab_background,
1404                foreground: tab_foreground,
1405                active_background: tab_active_background,
1406                active_foreground: tab_active_foreground,
1407                bar_background: tab_bar_background,
1408                min_width: tab_min_width,
1409                min_height: tab_min_height,
1410                padding_horizontal: tab_padding_horizontal,
1411                padding_vertical: tab_padding_vertical,
1412            },
1413            sidebar: crate::model::widgets::ResolvedSidebarTheme {
1414                background: sidebar_background,
1415                foreground: sidebar_foreground,
1416            },
1417            toolbar: crate::model::widgets::ResolvedToolbarTheme {
1418                height: toolbar_height,
1419                item_spacing: toolbar_item_spacing,
1420                padding: toolbar_padding,
1421                font: toolbar_font,
1422            },
1423            status_bar: crate::model::widgets::ResolvedStatusBarTheme {
1424                font: status_bar_font,
1425            },
1426            list: crate::model::widgets::ResolvedListTheme {
1427                background: list_background,
1428                foreground: list_foreground,
1429                alternate_row: list_alternate_row,
1430                selection: list_selection,
1431                selection_foreground: list_selection_foreground,
1432                header_background: list_header_background,
1433                header_foreground: list_header_foreground,
1434                grid_color: list_grid_color,
1435                item_height: list_item_height,
1436                padding_horizontal: list_padding_horizontal,
1437                padding_vertical: list_padding_vertical,
1438            },
1439            popover: crate::model::widgets::ResolvedPopoverTheme {
1440                background: popover_background,
1441                foreground: popover_foreground,
1442                border: popover_border,
1443                radius: popover_radius,
1444            },
1445            splitter: crate::model::widgets::ResolvedSplitterTheme {
1446                width: splitter_width,
1447            },
1448            separator: crate::model::widgets::ResolvedSeparatorTheme {
1449                color: separator_color,
1450            },
1451            switch: crate::model::widgets::ResolvedSwitchTheme {
1452                checked_bg: switch_checked_bg,
1453                unchecked_bg: switch_unchecked_bg,
1454                thumb_bg: switch_thumb_bg,
1455                track_width: switch_track_width,
1456                track_height: switch_track_height,
1457                thumb_size: switch_thumb_size,
1458                track_radius: switch_track_radius,
1459            },
1460            dialog: crate::model::widgets::ResolvedDialogTheme {
1461                min_width: dialog_min_width,
1462                max_width: dialog_max_width,
1463                min_height: dialog_min_height,
1464                max_height: dialog_max_height,
1465                content_padding: dialog_content_padding,
1466                button_spacing: dialog_button_spacing,
1467                radius: dialog_radius,
1468                icon_size: dialog_icon_size,
1469                button_order: dialog_button_order,
1470                title_font: dialog_title_font,
1471            },
1472            spinner: crate::model::widgets::ResolvedSpinnerTheme {
1473                fill: spinner_fill,
1474                diameter: spinner_diameter,
1475                min_size: spinner_min_size,
1476                stroke_width: spinner_stroke_width,
1477            },
1478            combo_box: crate::model::widgets::ResolvedComboBoxTheme {
1479                min_height: combo_box_min_height,
1480                min_width: combo_box_min_width,
1481                padding_horizontal: combo_box_padding_horizontal,
1482                arrow_size: combo_box_arrow_size,
1483                arrow_area_width: combo_box_arrow_area_width,
1484                radius: combo_box_radius,
1485            },
1486            segmented_control: crate::model::widgets::ResolvedSegmentedControlTheme {
1487                segment_height: segmented_control_segment_height,
1488                separator_width: segmented_control_separator_width,
1489                padding_horizontal: segmented_control_padding_horizontal,
1490                radius: segmented_control_radius,
1491            },
1492            card: crate::model::widgets::ResolvedCardTheme {
1493                background: card_background,
1494                border: card_border,
1495                radius: card_radius,
1496                padding: card_padding,
1497                shadow: card_shadow,
1498            },
1499            expander: crate::model::widgets::ResolvedExpanderTheme {
1500                header_height: expander_header_height,
1501                arrow_size: expander_arrow_size,
1502                content_padding: expander_content_padding,
1503                radius: expander_radius,
1504            },
1505            link: crate::model::widgets::ResolvedLinkTheme {
1506                color: link_color,
1507                visited: link_visited,
1508                background: link_background,
1509                hover_bg: link_hover_bg,
1510                underline: link_underline,
1511            },
1512            icon_set,
1513            icon_theme,
1514        })
1515    }
1516}
1517
1518#[cfg(test)]
1519#[allow(clippy::unwrap_used, clippy::expect_used)]
1520mod tests {
1521    use super::*;
1522    use crate::Rgba;
1523    use crate::model::{DialogButtonOrder, FontSpec};
1524
1525    /// Helper: build a ThemeVariant with all defaults.* fields populated.
1526    fn variant_with_defaults() -> ThemeVariant {
1527        let c1 = Rgba::rgb(0, 120, 215); // accent
1528        let c2 = Rgba::rgb(255, 255, 255); // background
1529        let c3 = Rgba::rgb(30, 30, 30); // foreground
1530        let c4 = Rgba::rgb(240, 240, 240); // surface
1531        let c5 = Rgba::rgb(200, 200, 200); // border
1532        let c6 = Rgba::rgb(128, 128, 128); // muted
1533        let c7 = Rgba::rgb(0, 0, 0); // shadow
1534        let c8 = Rgba::rgb(0, 100, 200); // link
1535        let c9 = Rgba::rgb(255, 255, 255); // accent_foreground
1536        let c10 = Rgba::rgb(220, 53, 69); // danger
1537        let c11 = Rgba::rgb(255, 255, 255); // danger_foreground
1538        let c12 = Rgba::rgb(240, 173, 78); // warning
1539        let c13 = Rgba::rgb(30, 30, 30); // warning_foreground
1540        let c14 = Rgba::rgb(40, 167, 69); // success
1541        let c15 = Rgba::rgb(255, 255, 255); // success_foreground
1542        let c16 = Rgba::rgb(0, 120, 215); // info
1543        let c17 = Rgba::rgb(255, 255, 255); // info_foreground
1544
1545        let mut v = ThemeVariant::default();
1546        v.defaults.accent = Some(c1);
1547        v.defaults.background = Some(c2);
1548        v.defaults.foreground = Some(c3);
1549        v.defaults.surface = Some(c4);
1550        v.defaults.border = Some(c5);
1551        v.defaults.muted = Some(c6);
1552        v.defaults.shadow = Some(c7);
1553        v.defaults.link = Some(c8);
1554        v.defaults.accent_foreground = Some(c9);
1555        v.defaults.selection_foreground = Some(Rgba::rgb(255, 255, 255));
1556        v.defaults.disabled_foreground = Some(Rgba::rgb(160, 160, 160));
1557        v.defaults.danger = Some(c10);
1558        v.defaults.danger_foreground = Some(c11);
1559        v.defaults.warning = Some(c12);
1560        v.defaults.warning_foreground = Some(c13);
1561        v.defaults.success = Some(c14);
1562        v.defaults.success_foreground = Some(c15);
1563        v.defaults.info = Some(c16);
1564        v.defaults.info_foreground = Some(c17);
1565
1566        v.defaults.radius = Some(4.0);
1567        v.defaults.radius_lg = Some(8.0);
1568        v.defaults.frame_width = Some(1.0);
1569        v.defaults.disabled_opacity = Some(0.5);
1570        v.defaults.border_opacity = Some(0.15);
1571        v.defaults.shadow_enabled = Some(true);
1572
1573        v.defaults.focus_ring_width = Some(2.0);
1574        v.defaults.focus_ring_offset = Some(1.0);
1575
1576        v.defaults.font = FontSpec {
1577            family: Some("Inter".into()),
1578            size: Some(14.0),
1579            weight: Some(400),
1580        };
1581        v.defaults.line_height = Some(1.4);
1582        v.defaults.mono_font = FontSpec {
1583            family: Some("JetBrains Mono".into()),
1584            size: Some(13.0),
1585            weight: Some(400),
1586        };
1587
1588        v.defaults.spacing.xxs = Some(2.0);
1589        v.defaults.spacing.xs = Some(4.0);
1590        v.defaults.spacing.s = Some(6.0);
1591        v.defaults.spacing.m = Some(12.0);
1592        v.defaults.spacing.l = Some(18.0);
1593        v.defaults.spacing.xl = Some(24.0);
1594        v.defaults.spacing.xxl = Some(36.0);
1595
1596        v.defaults.icon_sizes.toolbar = Some(24.0);
1597        v.defaults.icon_sizes.small = Some(16.0);
1598        v.defaults.icon_sizes.large = Some(32.0);
1599        v.defaults.icon_sizes.dialog = Some(22.0);
1600        v.defaults.icon_sizes.panel = Some(20.0);
1601
1602        v.defaults.text_scaling_factor = Some(1.0);
1603        v.defaults.reduce_motion = Some(false);
1604        v.defaults.high_contrast = Some(false);
1605        v.defaults.reduce_transparency = Some(false);
1606
1607        v
1608    }
1609
1610    // ===== Phase 1: Defaults internal chains =====
1611
1612    #[test]
1613    fn resolve_phase1_accent_fills_selection_and_focus_ring() {
1614        let mut v = ThemeVariant::default();
1615        v.defaults.accent = Some(Rgba::rgb(0, 120, 215));
1616        v.resolve();
1617        assert_eq!(v.defaults.selection, Some(Rgba::rgb(0, 120, 215)));
1618        assert_eq!(v.defaults.focus_ring_color, Some(Rgba::rgb(0, 120, 215)));
1619    }
1620
1621    #[test]
1622    fn resolve_phase1_selection_fills_selection_inactive() {
1623        let mut v = ThemeVariant::default();
1624        v.defaults.accent = Some(Rgba::rgb(0, 120, 215));
1625        v.resolve();
1626        // selection_inactive should be set from selection (which was set from accent)
1627        assert_eq!(v.defaults.selection_inactive, Some(Rgba::rgb(0, 120, 215)));
1628    }
1629
1630    #[test]
1631    fn resolve_phase1_explicit_selection_preserved() {
1632        let mut v = ThemeVariant::default();
1633        v.defaults.accent = Some(Rgba::rgb(0, 120, 215));
1634        v.defaults.selection = Some(Rgba::rgb(100, 100, 100));
1635        v.resolve();
1636        // Explicit selection preserved
1637        assert_eq!(v.defaults.selection, Some(Rgba::rgb(100, 100, 100)));
1638        // selection_inactive inherits from the explicit selection
1639        assert_eq!(
1640            v.defaults.selection_inactive,
1641            Some(Rgba::rgb(100, 100, 100))
1642        );
1643    }
1644
1645    // ===== Phase 2: Safety nets =====
1646
1647    #[test]
1648    fn resolve_phase2_safety_nets() {
1649        let mut v = ThemeVariant::default();
1650        v.defaults.foreground = Some(Rgba::rgb(30, 30, 30));
1651        v.defaults.background = Some(Rgba::rgb(255, 255, 255));
1652        v.resolve();
1653
1654        assert_eq!(
1655            v.input.caret,
1656            Some(Rgba::rgb(30, 30, 30)),
1657            "input.caret <- foreground"
1658        );
1659        assert_eq!(
1660            v.scrollbar.track,
1661            Some(Rgba::rgb(255, 255, 255)),
1662            "scrollbar.track <- background"
1663        );
1664        assert_eq!(
1665            v.spinner.fill,
1666            Some(Rgba::rgb(30, 30, 30)),
1667            "spinner.fill <- foreground"
1668        );
1669        assert_eq!(
1670            v.popover.background,
1671            Some(Rgba::rgb(255, 255, 255)),
1672            "popover.background <- background"
1673        );
1674        assert_eq!(
1675            v.list.background,
1676            Some(Rgba::rgb(255, 255, 255)),
1677            "list.background <- background"
1678        );
1679    }
1680
1681    // ===== Phase 3: Accent propagation (RESOLVE-06) =====
1682
1683    #[test]
1684    fn resolve_phase3_accent_propagation() {
1685        let mut v = ThemeVariant::default();
1686        v.defaults.accent = Some(Rgba::rgb(0, 120, 215));
1687        v.resolve();
1688
1689        assert_eq!(
1690            v.button.primary_bg,
1691            Some(Rgba::rgb(0, 120, 215)),
1692            "button.primary_bg <- accent"
1693        );
1694        assert_eq!(
1695            v.checkbox.checked_bg,
1696            Some(Rgba::rgb(0, 120, 215)),
1697            "checkbox.checked_bg <- accent"
1698        );
1699        assert_eq!(
1700            v.slider.fill,
1701            Some(Rgba::rgb(0, 120, 215)),
1702            "slider.fill <- accent"
1703        );
1704        assert_eq!(
1705            v.progress_bar.fill,
1706            Some(Rgba::rgb(0, 120, 215)),
1707            "progress_bar.fill <- accent"
1708        );
1709        assert_eq!(
1710            v.switch.checked_bg,
1711            Some(Rgba::rgb(0, 120, 215)),
1712            "switch.checked_bg <- accent"
1713        );
1714    }
1715
1716    // ===== Phase 3: Font sub-field inheritance (RESOLVE-04) =====
1717
1718    #[test]
1719    fn resolve_phase3_font_subfield_inheritance() {
1720        let mut v = ThemeVariant::default();
1721        v.defaults.font = FontSpec {
1722            family: Some("Inter".into()),
1723            size: Some(14.0),
1724            weight: Some(400),
1725        };
1726        // Menu has a font with only size set
1727        v.menu.font = Some(FontSpec {
1728            family: None,
1729            size: Some(12.0),
1730            weight: None,
1731        });
1732        v.resolve();
1733
1734        let menu_font = v.menu.font.as_ref().unwrap();
1735        assert_eq!(
1736            menu_font.family.as_deref(),
1737            Some("Inter"),
1738            "family from defaults"
1739        );
1740        assert_eq!(menu_font.size, Some(12.0), "explicit size preserved");
1741        assert_eq!(menu_font.weight, Some(400), "weight from defaults");
1742    }
1743
1744    #[test]
1745    fn resolve_phase3_font_entire_inheritance() {
1746        let mut v = ThemeVariant::default();
1747        v.defaults.font = FontSpec {
1748            family: Some("Inter".into()),
1749            size: Some(14.0),
1750            weight: Some(400),
1751        };
1752        // button.font is None, should inherit entire defaults.font
1753        assert!(v.button.font.is_none());
1754        v.resolve();
1755
1756        let button_font = v.button.font.as_ref().unwrap();
1757        assert_eq!(button_font.family.as_deref(), Some("Inter"));
1758        assert_eq!(button_font.size, Some(14.0));
1759        assert_eq!(button_font.weight, Some(400));
1760    }
1761
1762    // ===== Phase 3: Text scale inheritance (RESOLVE-05) =====
1763
1764    #[test]
1765    fn resolve_phase3_text_scale_inheritance() {
1766        let mut v = ThemeVariant::default();
1767        v.defaults.font = FontSpec {
1768            family: Some("Inter".into()),
1769            size: Some(14.0),
1770            weight: Some(400),
1771        };
1772        v.defaults.line_height = Some(1.4);
1773        // Leave text_scale.caption as None
1774        v.resolve();
1775
1776        let caption = v.text_scale.caption.as_ref().unwrap();
1777        assert_eq!(caption.size, Some(14.0), "size from defaults.font.size");
1778        assert_eq!(
1779            caption.weight,
1780            Some(400),
1781            "weight from defaults.font.weight"
1782        );
1783        // line_height = defaults.line_height * resolved_size = 1.4 * 14.0 = 19.6
1784        assert!(
1785            (caption.line_height.unwrap() - 19.6).abs() < 0.001,
1786            "line_height computed"
1787        );
1788    }
1789
1790    // ===== Phase 3: Color inheritance =====
1791
1792    #[test]
1793    fn resolve_phase3_color_inheritance() {
1794        let mut v = variant_with_defaults();
1795        v.resolve();
1796
1797        // window
1798        assert_eq!(v.window.background, Some(Rgba::rgb(255, 255, 255)));
1799        assert_eq!(v.window.border, v.defaults.border);
1800        // button
1801        assert_eq!(v.button.border, v.defaults.border);
1802        // tooltip
1803        assert_eq!(v.tooltip.radius, v.defaults.radius);
1804    }
1805
1806    // ===== Phase 4: Widget-to-widget =====
1807
1808    #[test]
1809    fn resolve_phase4_inactive_title_bar_from_active() {
1810        let mut v = ThemeVariant::default();
1811        v.defaults.surface = Some(Rgba::rgb(240, 240, 240));
1812        v.defaults.foreground = Some(Rgba::rgb(30, 30, 30));
1813        v.resolve();
1814
1815        // title_bar_background was set to surface in Phase 3
1816        // inactive should inherit from active
1817        assert_eq!(
1818            v.window.inactive_title_bar_background,
1819            v.window.title_bar_background
1820        );
1821        assert_eq!(
1822            v.window.inactive_title_bar_foreground,
1823            v.window.title_bar_foreground
1824        );
1825    }
1826
1827    // ===== Preserve explicit values =====
1828
1829    #[test]
1830    fn resolve_does_not_overwrite_existing_some_values() {
1831        let mut v = variant_with_defaults();
1832        let explicit = Rgba::rgb(255, 0, 0);
1833        v.window.background = Some(explicit);
1834        v.button.primary_bg = Some(explicit);
1835        v.resolve();
1836
1837        assert_eq!(
1838            v.window.background,
1839            Some(explicit),
1840            "window.background preserved"
1841        );
1842        assert_eq!(
1843            v.button.primary_bg,
1844            Some(explicit),
1845            "button.primary_bg preserved"
1846        );
1847    }
1848
1849    // ===== Idempotent =====
1850
1851    #[test]
1852    fn resolve_is_idempotent() {
1853        let mut v = variant_with_defaults();
1854        v.resolve();
1855        let after_first = v.clone();
1856        v.resolve();
1857        assert_eq!(v, after_first, "second resolve() produces same result");
1858    }
1859
1860    // ===== All 8 font-carrying widgets get resolved fonts =====
1861
1862    #[test]
1863    fn resolve_all_font_carrying_widgets_get_resolved_fonts() {
1864        let mut v = ThemeVariant::default();
1865        v.defaults.font = FontSpec {
1866            family: Some("Inter".into()),
1867            size: Some(14.0),
1868            weight: Some(400),
1869        };
1870        v.resolve();
1871
1872        // All 8 should now have Some(FontSpec)
1873        assert!(v.window.title_bar_font.is_some(), "window.title_bar_font");
1874        assert!(v.button.font.is_some(), "button.font");
1875        assert!(v.input.font.is_some(), "input.font");
1876        assert!(v.menu.font.is_some(), "menu.font");
1877        assert!(v.tooltip.font.is_some(), "tooltip.font");
1878        assert!(v.toolbar.font.is_some(), "toolbar.font");
1879        assert!(v.status_bar.font.is_some(), "status_bar.font");
1880        assert!(v.dialog.title_font.is_some(), "dialog.title_font");
1881
1882        // Each should have the defaults values
1883        for (name, font) in [
1884            ("window.title_bar_font", &v.window.title_bar_font),
1885            ("button.font", &v.button.font),
1886            ("input.font", &v.input.font),
1887            ("menu.font", &v.menu.font),
1888            ("tooltip.font", &v.tooltip.font),
1889            ("toolbar.font", &v.toolbar.font),
1890            ("status_bar.font", &v.status_bar.font),
1891            ("dialog.title_font", &v.dialog.title_font),
1892        ] {
1893            let f = font.as_ref().unwrap();
1894            assert_eq!(f.family.as_deref(), Some("Inter"), "{name} family");
1895            assert_eq!(f.size, Some(14.0), "{name} size");
1896            assert_eq!(f.weight, Some(400), "{name} weight");
1897        }
1898    }
1899
1900    // ===== validate() tests =====
1901
1902    /// Build a fully-populated ThemeVariant (all fields Some) for validate() testing.
1903    fn fully_populated_variant() -> ThemeVariant {
1904        let mut v = variant_with_defaults();
1905        let c = Rgba::rgb(128, 128, 128);
1906
1907        // Ensure derived defaults are set (variant_with_defaults doesn't set these)
1908        v.defaults.selection = Some(Rgba::rgb(0, 120, 215));
1909        v.defaults.selection_foreground = Some(Rgba::rgb(255, 255, 255));
1910        v.defaults.selection_inactive = Some(Rgba::rgb(0, 120, 215));
1911        v.defaults.focus_ring_color = Some(Rgba::rgb(0, 120, 215));
1912
1913        // icon_set / icon_theme
1914        v.icon_set = Some(crate::IconSet::Freedesktop);
1915        v.icon_theme = Some("breeze".into());
1916
1917        // window
1918        v.window.background = Some(c);
1919        v.window.foreground = Some(c);
1920        v.window.border = Some(c);
1921        v.window.title_bar_background = Some(c);
1922        v.window.title_bar_foreground = Some(c);
1923        v.window.inactive_title_bar_background = Some(c);
1924        v.window.inactive_title_bar_foreground = Some(c);
1925        v.window.radius = Some(8.0);
1926        v.window.shadow = Some(true);
1927        v.window.title_bar_font = Some(FontSpec {
1928            family: Some("Inter".into()),
1929            size: Some(14.0),
1930            weight: Some(400),
1931        });
1932
1933        // button
1934        v.button.background = Some(c);
1935        v.button.foreground = Some(c);
1936        v.button.border = Some(c);
1937        v.button.primary_bg = Some(c);
1938        v.button.primary_fg = Some(c);
1939        v.button.min_width = Some(64.0);
1940        v.button.min_height = Some(28.0);
1941        v.button.padding_horizontal = Some(12.0);
1942        v.button.padding_vertical = Some(6.0);
1943        v.button.radius = Some(4.0);
1944        v.button.icon_spacing = Some(6.0);
1945        v.button.disabled_opacity = Some(0.5);
1946        v.button.shadow = Some(false);
1947        v.button.font = Some(FontSpec {
1948            family: Some("Inter".into()),
1949            size: Some(14.0),
1950            weight: Some(400),
1951        });
1952
1953        // input
1954        v.input.background = Some(c);
1955        v.input.foreground = Some(c);
1956        v.input.border = Some(c);
1957        v.input.placeholder = Some(c);
1958        v.input.caret = Some(c);
1959        v.input.selection = Some(c);
1960        v.input.selection_foreground = Some(c);
1961        v.input.min_height = Some(28.0);
1962        v.input.padding_horizontal = Some(8.0);
1963        v.input.padding_vertical = Some(4.0);
1964        v.input.radius = Some(4.0);
1965        v.input.border_width = Some(1.0);
1966        v.input.font = Some(FontSpec {
1967            family: Some("Inter".into()),
1968            size: Some(14.0),
1969            weight: Some(400),
1970        });
1971
1972        // checkbox
1973        v.checkbox.checked_bg = Some(c);
1974        v.checkbox.indicator_size = Some(18.0);
1975        v.checkbox.spacing = Some(6.0);
1976        v.checkbox.radius = Some(2.0);
1977        v.checkbox.border_width = Some(1.0);
1978
1979        // menu
1980        v.menu.background = Some(c);
1981        v.menu.foreground = Some(c);
1982        v.menu.separator = Some(c);
1983        v.menu.item_height = Some(28.0);
1984        v.menu.padding_horizontal = Some(8.0);
1985        v.menu.padding_vertical = Some(4.0);
1986        v.menu.icon_spacing = Some(6.0);
1987        v.menu.font = Some(FontSpec {
1988            family: Some("Inter".into()),
1989            size: Some(14.0),
1990            weight: Some(400),
1991        });
1992
1993        // tooltip
1994        v.tooltip.background = Some(c);
1995        v.tooltip.foreground = Some(c);
1996        v.tooltip.padding_horizontal = Some(6.0);
1997        v.tooltip.padding_vertical = Some(4.0);
1998        v.tooltip.max_width = Some(300.0);
1999        v.tooltip.radius = Some(4.0);
2000        v.tooltip.font = Some(FontSpec {
2001            family: Some("Inter".into()),
2002            size: Some(14.0),
2003            weight: Some(400),
2004        });
2005
2006        // scrollbar
2007        v.scrollbar.track = Some(c);
2008        v.scrollbar.thumb = Some(c);
2009        v.scrollbar.thumb_hover = Some(c);
2010        v.scrollbar.width = Some(14.0);
2011        v.scrollbar.min_thumb_height = Some(20.0);
2012        v.scrollbar.slider_width = Some(8.0);
2013        v.scrollbar.overlay_mode = Some(false);
2014
2015        // slider
2016        v.slider.fill = Some(c);
2017        v.slider.track = Some(c);
2018        v.slider.thumb = Some(c);
2019        v.slider.track_height = Some(4.0);
2020        v.slider.thumb_size = Some(16.0);
2021        v.slider.tick_length = Some(6.0);
2022
2023        // progress_bar
2024        v.progress_bar.fill = Some(c);
2025        v.progress_bar.track = Some(c);
2026        v.progress_bar.height = Some(6.0);
2027        v.progress_bar.min_width = Some(100.0);
2028        v.progress_bar.radius = Some(3.0);
2029
2030        // tab
2031        v.tab.background = Some(c);
2032        v.tab.foreground = Some(c);
2033        v.tab.active_background = Some(c);
2034        v.tab.active_foreground = Some(c);
2035        v.tab.bar_background = Some(c);
2036        v.tab.min_width = Some(60.0);
2037        v.tab.min_height = Some(32.0);
2038        v.tab.padding_horizontal = Some(12.0);
2039        v.tab.padding_vertical = Some(6.0);
2040
2041        // sidebar
2042        v.sidebar.background = Some(c);
2043        v.sidebar.foreground = Some(c);
2044
2045        // toolbar
2046        v.toolbar.height = Some(40.0);
2047        v.toolbar.item_spacing = Some(4.0);
2048        v.toolbar.padding = Some(4.0);
2049        v.toolbar.font = Some(FontSpec {
2050            family: Some("Inter".into()),
2051            size: Some(14.0),
2052            weight: Some(400),
2053        });
2054
2055        // status_bar
2056        v.status_bar.font = Some(FontSpec {
2057            family: Some("Inter".into()),
2058            size: Some(14.0),
2059            weight: Some(400),
2060        });
2061
2062        // list
2063        v.list.background = Some(c);
2064        v.list.foreground = Some(c);
2065        v.list.alternate_row = Some(c);
2066        v.list.selection = Some(c);
2067        v.list.selection_foreground = Some(c);
2068        v.list.header_background = Some(c);
2069        v.list.header_foreground = Some(c);
2070        v.list.grid_color = Some(c);
2071        v.list.item_height = Some(28.0);
2072        v.list.padding_horizontal = Some(8.0);
2073        v.list.padding_vertical = Some(4.0);
2074
2075        // popover
2076        v.popover.background = Some(c);
2077        v.popover.foreground = Some(c);
2078        v.popover.border = Some(c);
2079        v.popover.radius = Some(6.0);
2080
2081        // splitter
2082        v.splitter.width = Some(4.0);
2083
2084        // separator
2085        v.separator.color = Some(c);
2086
2087        // switch
2088        v.switch.checked_bg = Some(c);
2089        v.switch.unchecked_bg = Some(c);
2090        v.switch.thumb_bg = Some(c);
2091        v.switch.track_width = Some(40.0);
2092        v.switch.track_height = Some(20.0);
2093        v.switch.thumb_size = Some(14.0);
2094        v.switch.track_radius = Some(10.0);
2095
2096        // dialog
2097        v.dialog.min_width = Some(320.0);
2098        v.dialog.max_width = Some(600.0);
2099        v.dialog.min_height = Some(200.0);
2100        v.dialog.max_height = Some(800.0);
2101        v.dialog.content_padding = Some(16.0);
2102        v.dialog.button_spacing = Some(8.0);
2103        v.dialog.radius = Some(8.0);
2104        v.dialog.icon_size = Some(22.0);
2105        v.dialog.button_order = Some(DialogButtonOrder::TrailingAffirmative);
2106        v.dialog.title_font = Some(FontSpec {
2107            family: Some("Inter".into()),
2108            size: Some(16.0),
2109            weight: Some(700),
2110        });
2111
2112        // spinner
2113        v.spinner.fill = Some(c);
2114        v.spinner.diameter = Some(24.0);
2115        v.spinner.min_size = Some(16.0);
2116        v.spinner.stroke_width = Some(2.0);
2117
2118        // combo_box
2119        v.combo_box.min_height = Some(28.0);
2120        v.combo_box.min_width = Some(80.0);
2121        v.combo_box.padding_horizontal = Some(8.0);
2122        v.combo_box.arrow_size = Some(12.0);
2123        v.combo_box.arrow_area_width = Some(20.0);
2124        v.combo_box.radius = Some(4.0);
2125
2126        // segmented_control
2127        v.segmented_control.segment_height = Some(28.0);
2128        v.segmented_control.separator_width = Some(1.0);
2129        v.segmented_control.padding_horizontal = Some(12.0);
2130        v.segmented_control.radius = Some(4.0);
2131
2132        // card
2133        v.card.background = Some(c);
2134        v.card.border = Some(c);
2135        v.card.radius = Some(8.0);
2136        v.card.padding = Some(12.0);
2137        v.card.shadow = Some(true);
2138
2139        // expander
2140        v.expander.header_height = Some(32.0);
2141        v.expander.arrow_size = Some(12.0);
2142        v.expander.content_padding = Some(8.0);
2143        v.expander.radius = Some(4.0);
2144
2145        // link
2146        v.link.color = Some(c);
2147        v.link.visited = Some(c);
2148        v.link.background = Some(c);
2149        v.link.hover_bg = Some(c);
2150        v.link.underline = Some(true);
2151
2152        // text_scale (all 4 entries fully populated)
2153        v.text_scale.caption = Some(crate::model::TextScaleEntry {
2154            size: Some(11.0),
2155            weight: Some(400),
2156            line_height: Some(15.4),
2157        });
2158        v.text_scale.section_heading = Some(crate::model::TextScaleEntry {
2159            size: Some(14.0),
2160            weight: Some(600),
2161            line_height: Some(19.6),
2162        });
2163        v.text_scale.dialog_title = Some(crate::model::TextScaleEntry {
2164            size: Some(16.0),
2165            weight: Some(700),
2166            line_height: Some(22.4),
2167        });
2168        v.text_scale.display = Some(crate::model::TextScaleEntry {
2169            size: Some(24.0),
2170            weight: Some(300),
2171            line_height: Some(33.6),
2172        });
2173
2174        v
2175    }
2176
2177    #[test]
2178    fn validate_fully_populated_returns_ok() {
2179        let v = fully_populated_variant();
2180        let result = v.validate();
2181        assert!(
2182            result.is_ok(),
2183            "validate() should succeed on fully populated variant, got: {:?}",
2184            result.err()
2185        );
2186        let resolved = result.unwrap();
2187        assert_eq!(resolved.defaults.font.family, "Inter");
2188        assert_eq!(resolved.icon_set, crate::IconSet::Freedesktop);
2189    }
2190
2191    #[test]
2192    fn validate_missing_3_fields_returns_all_paths() {
2193        let mut v = fully_populated_variant();
2194        // Remove 3 specific fields (non-cascading)
2195        v.defaults.muted = None;
2196        v.window.radius = None;
2197        v.icon_set = None;
2198
2199        let result = v.validate();
2200        assert!(result.is_err());
2201        let err = match result.unwrap_err() {
2202            crate::Error::Resolution(e) => e,
2203            other => panic!("expected Resolution error, got: {other:?}"),
2204        };
2205        assert_eq!(
2206            err.missing_fields.len(),
2207            3,
2208            "should report exactly 3 missing fields, got: {:?}",
2209            err.missing_fields
2210        );
2211        assert!(err.missing_fields.contains(&"defaults.muted".to_string()));
2212        assert!(err.missing_fields.contains(&"window.radius".to_string()));
2213        assert!(err.missing_fields.contains(&"icon_set".to_string()));
2214    }
2215
2216    #[test]
2217    fn validate_error_message_includes_count_and_paths() {
2218        let mut v = fully_populated_variant();
2219        v.defaults.muted = None;
2220        v.button.min_height = None;
2221
2222        let result = v.validate();
2223        assert!(result.is_err());
2224        let err = match result.unwrap_err() {
2225            crate::Error::Resolution(e) => e,
2226            other => panic!("expected Resolution error, got: {other:?}"),
2227        };
2228        let msg = err.to_string();
2229        assert!(msg.contains("2 missing field(s)"), "got: {msg}");
2230        assert!(msg.contains("defaults.muted"), "got: {msg}");
2231        assert!(msg.contains("button.min_height"), "got: {msg}");
2232    }
2233
2234    #[test]
2235    fn validate_checks_all_defaults_fields() {
2236        // Default variant has ALL fields None, so validate should report many missing
2237        let v = ThemeVariant::default();
2238        let result = v.validate();
2239        assert!(result.is_err());
2240        let err = match result.unwrap_err() {
2241            crate::Error::Resolution(e) => e,
2242            other => panic!("expected Resolution error, got: {other:?}"),
2243        };
2244        // Should include defaults fields
2245        assert!(
2246            err.missing_fields
2247                .iter()
2248                .any(|f| f.starts_with("defaults.")),
2249            "should include defaults.* fields in missing"
2250        );
2251        // Check a representative set of defaults fields
2252        assert!(
2253            err.missing_fields
2254                .contains(&"defaults.font.family".to_string())
2255        );
2256        assert!(
2257            err.missing_fields
2258                .contains(&"defaults.background".to_string())
2259        );
2260        assert!(err.missing_fields.contains(&"defaults.accent".to_string()));
2261        assert!(err.missing_fields.contains(&"defaults.radius".to_string()));
2262        assert!(
2263            err.missing_fields
2264                .contains(&"defaults.spacing.m".to_string())
2265        );
2266        assert!(
2267            err.missing_fields
2268                .contains(&"defaults.icon_sizes.toolbar".to_string())
2269        );
2270        assert!(
2271            err.missing_fields
2272                .contains(&"defaults.text_scaling_factor".to_string())
2273        );
2274    }
2275
2276    #[test]
2277    fn validate_checks_all_widget_structs() {
2278        let v = ThemeVariant::default();
2279        let result = v.validate();
2280        let err = match result.unwrap_err() {
2281            crate::Error::Resolution(e) => e,
2282            other => panic!("expected Resolution error, got: {other:?}"),
2283        };
2284        // Every widget should have at least one field reported
2285        for prefix in [
2286            "window.",
2287            "button.",
2288            "input.",
2289            "checkbox.",
2290            "menu.",
2291            "tooltip.",
2292            "scrollbar.",
2293            "slider.",
2294            "progress_bar.",
2295            "tab.",
2296            "sidebar.",
2297            "toolbar.",
2298            "status_bar.",
2299            "list.",
2300            "popover.",
2301            "splitter.",
2302            "separator.",
2303            "switch.",
2304            "dialog.",
2305            "spinner.",
2306            "combo_box.",
2307            "segmented_control.",
2308            "card.",
2309            "expander.",
2310            "link.",
2311        ] {
2312            assert!(
2313                err.missing_fields.iter().any(|f| f.starts_with(prefix)),
2314                "missing fields should include {prefix}* but got: {:?}",
2315                err.missing_fields
2316                    .iter()
2317                    .filter(|f| f.starts_with(prefix))
2318                    .collect::<Vec<_>>()
2319            );
2320        }
2321    }
2322
2323    #[test]
2324    fn validate_checks_text_scale_entries() {
2325        let v = ThemeVariant::default();
2326        let result = v.validate();
2327        let err = match result.unwrap_err() {
2328            crate::Error::Resolution(e) => e,
2329            other => panic!("expected Resolution error, got: {other:?}"),
2330        };
2331        assert!(
2332            err.missing_fields
2333                .contains(&"text_scale.caption".to_string())
2334        );
2335        assert!(
2336            err.missing_fields
2337                .contains(&"text_scale.section_heading".to_string())
2338        );
2339        assert!(
2340            err.missing_fields
2341                .contains(&"text_scale.dialog_title".to_string())
2342        );
2343        assert!(
2344            err.missing_fields
2345                .contains(&"text_scale.display".to_string())
2346        );
2347    }
2348
2349    #[test]
2350    fn validate_checks_icon_set() {
2351        let mut v = fully_populated_variant();
2352        v.icon_set = None;
2353
2354        let result = v.validate();
2355        let err = match result.unwrap_err() {
2356            crate::Error::Resolution(e) => e,
2357            other => panic!("expected Resolution error, got: {other:?}"),
2358        };
2359        assert!(err.missing_fields.contains(&"icon_set".to_string()));
2360    }
2361
2362    #[test]
2363    fn validate_after_resolve_succeeds_for_derivable_fields() {
2364        // Start with defaults populated but widgets empty
2365        let mut v = variant_with_defaults();
2366        // Add non-derivable widget sizing fields
2367        v.icon_set = Some(crate::IconSet::Freedesktop);
2368
2369        // Non-derivable fields that resolve() cannot fill:
2370        // button sizing
2371        v.button.min_width = Some(64.0);
2372        v.button.min_height = Some(28.0);
2373        v.button.padding_horizontal = Some(12.0);
2374        v.button.padding_vertical = Some(6.0);
2375        v.button.icon_spacing = Some(6.0);
2376        // input sizing
2377        v.input.min_height = Some(28.0);
2378        v.input.padding_horizontal = Some(8.0);
2379        v.input.padding_vertical = Some(4.0);
2380        // checkbox sizing
2381        v.checkbox.indicator_size = Some(18.0);
2382        v.checkbox.spacing = Some(6.0);
2383        // menu sizing
2384        v.menu.item_height = Some(28.0);
2385        v.menu.padding_horizontal = Some(8.0);
2386        v.menu.padding_vertical = Some(4.0);
2387        v.menu.icon_spacing = Some(6.0);
2388        // tooltip sizing
2389        v.tooltip.padding_horizontal = Some(6.0);
2390        v.tooltip.padding_vertical = Some(4.0);
2391        v.tooltip.max_width = Some(300.0);
2392        // scrollbar sizing
2393        v.scrollbar.width = Some(14.0);
2394        v.scrollbar.min_thumb_height = Some(20.0);
2395        v.scrollbar.slider_width = Some(8.0);
2396        v.scrollbar.overlay_mode = Some(false);
2397        // slider sizing
2398        v.slider.track_height = Some(4.0);
2399        v.slider.thumb_size = Some(16.0);
2400        v.slider.tick_length = Some(6.0);
2401        // progress_bar sizing
2402        v.progress_bar.height = Some(6.0);
2403        v.progress_bar.min_width = Some(100.0);
2404        // tab sizing
2405        v.tab.min_width = Some(60.0);
2406        v.tab.min_height = Some(32.0);
2407        v.tab.padding_horizontal = Some(12.0);
2408        v.tab.padding_vertical = Some(6.0);
2409        // toolbar sizing
2410        v.toolbar.height = Some(40.0);
2411        v.toolbar.item_spacing = Some(4.0);
2412        v.toolbar.padding = Some(4.0);
2413        // list sizing
2414        v.list.item_height = Some(28.0);
2415        v.list.padding_horizontal = Some(8.0);
2416        v.list.padding_vertical = Some(4.0);
2417        // splitter
2418        v.splitter.width = Some(4.0);
2419        // switch sizing
2420        v.switch.unchecked_bg = Some(Rgba::rgb(180, 180, 180));
2421        v.switch.track_width = Some(40.0);
2422        v.switch.track_height = Some(20.0);
2423        v.switch.thumb_size = Some(14.0);
2424        v.switch.track_radius = Some(10.0);
2425        // dialog sizing
2426        v.dialog.min_width = Some(320.0);
2427        v.dialog.max_width = Some(600.0);
2428        v.dialog.min_height = Some(200.0);
2429        v.dialog.max_height = Some(800.0);
2430        v.dialog.content_padding = Some(16.0);
2431        v.dialog.button_spacing = Some(8.0);
2432        v.dialog.icon_size = Some(22.0);
2433        v.dialog.button_order = Some(DialogButtonOrder::TrailingAffirmative);
2434        // spinner sizing
2435        v.spinner.diameter = Some(24.0);
2436        v.spinner.min_size = Some(16.0);
2437        v.spinner.stroke_width = Some(2.0);
2438        // combo_box sizing
2439        v.combo_box.min_height = Some(28.0);
2440        v.combo_box.min_width = Some(80.0);
2441        v.combo_box.padding_horizontal = Some(8.0);
2442        v.combo_box.arrow_size = Some(12.0);
2443        v.combo_box.arrow_area_width = Some(20.0);
2444        // segmented_control sizing
2445        v.segmented_control.segment_height = Some(28.0);
2446        v.segmented_control.separator_width = Some(1.0);
2447        v.segmented_control.padding_horizontal = Some(12.0);
2448        // card
2449        v.card.padding = Some(12.0);
2450        // expander
2451        v.expander.header_height = Some(32.0);
2452        v.expander.arrow_size = Some(12.0);
2453        v.expander.content_padding = Some(8.0);
2454        // link
2455        v.link.background = Some(Rgba::rgb(255, 255, 255));
2456        v.link.hover_bg = Some(Rgba::rgb(230, 230, 255));
2457        v.link.underline = Some(true);
2458
2459        v.resolve();
2460        let result = v.validate();
2461        assert!(
2462            result.is_ok(),
2463            "validate() should succeed after resolve() with all non-derivable fields set, got: {:?}",
2464            result.err()
2465        );
2466    }
2467
2468    #[test]
2469    fn test_gnome_resolve_validate() {
2470        // Simulate GNOME reader pipeline: adwaita base + GNOME reader overlay.
2471        // On a non-GNOME system, build_gnome_variant() only sets dialog.button_order
2472        // and icon_set (gsettings calls return None). We simulate the full merge.
2473        let adwaita = crate::ThemeSpec::preset("adwaita").unwrap();
2474
2475        // Pick dark variant from adwaita (matches GNOME PreferDark path).
2476        let mut variant = adwaita
2477            .dark
2478            .clone()
2479            .expect("adwaita should have dark variant");
2480
2481        // Apply what build_gnome_variant() would set.
2482        variant.dialog.button_order = Some(DialogButtonOrder::TrailingAffirmative);
2483        // icon_set comes from gsettings icon-theme; simulate typical GNOME value.
2484        variant.icon_set = Some(crate::IconSet::Freedesktop);
2485
2486        // Simulate GNOME reader font output (gsettings font-name on a GNOME system).
2487        variant.defaults.font = FontSpec {
2488            family: Some("Cantarell".to_string()),
2489            size: Some(11.0),
2490            weight: Some(400),
2491        };
2492
2493        variant.resolve();
2494        let resolved = variant.validate().unwrap_or_else(|e| {
2495            panic!("GNOME resolve/validate pipeline failed: {e}");
2496        });
2497
2498        // Spot-check: adwaita-base fields present.
2499        // Adwaita dark accent is #3584e4 = rgb(53, 132, 228)
2500        assert_eq!(
2501            resolved.defaults.accent,
2502            Rgba::rgb(53, 132, 228),
2503            "accent should be from adwaita preset"
2504        );
2505        assert_eq!(
2506            resolved.defaults.font.family, "Cantarell",
2507            "font family should be from GNOME reader overlay"
2508        );
2509        assert_eq!(
2510            resolved.dialog.button_order,
2511            DialogButtonOrder::TrailingAffirmative,
2512            "dialog button order should be trailing affirmative for GNOME"
2513        );
2514        assert_eq!(
2515            resolved.icon_set,
2516            crate::IconSet::Freedesktop,
2517            "icon_set should be from GNOME reader"
2518        );
2519    }
2520}