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