native_theme/resolve/mod.rs
1// Resolution engine: resolve() fills inheritance rules, validate() produces ResolvedThemeVariant.
2//
3// Split into submodules:
4// - inheritance: Phase 1-5 resolution rules (fill None fields from defaults/other widgets)
5// - validate: Field extraction, range checks, ResolvedThemeVariant construction
6
7mod inheritance;
8pub(crate) mod validate;
9pub(crate) mod validate_helpers;
10
11use crate::model::ThemeVariant;
12use crate::model::resolved::ResolvedThemeVariant;
13
14impl ThemeVariant {
15 /// Apply all ~91 inheritance rules in 5-phase order (pure data transform).
16 ///
17 /// After calling resolve(), most Option fields that were None will be filled
18 /// from defaults or related widget fields. Calling resolve() twice produces
19 /// the same result (idempotent).
20 ///
21 /// This method is a pure data transform: it does not perform any OS detection
22 /// or I/O. For full resolution including platform defaults (icon theme from
23 /// the system), use [`resolve_all()`](Self::resolve_all).
24 ///
25 /// # Phases
26 ///
27 /// 1. **Defaults internal chains** -- accent derives selection, focus_ring_color;
28 /// selection derives selection_inactive.
29 /// 2. **Safety nets** -- platform-divergent fields get a reasonable fallback.
30 /// 3. **Widget-from-defaults** -- colors, geometry, fonts, text scale entries
31 /// all inherit from defaults.
32 /// 4. **Widget-to-widget** -- inactive title bar fields fall back to active.
33 /// 5. **Icon set** -- fills icon_set from the compile-time system default.
34 pub fn resolve(&mut self) {
35 self.resolve_defaults_internal();
36 self.resolve_safety_nets();
37 self.resolve_widgets_from_defaults();
38 self.resolve_widget_to_widget();
39
40 // Phase 5: icon_set fallback — fill from system default if not set
41 if self.icon_set.is_none() {
42 self.icon_set = Some(crate::model::icons::system_icon_set());
43 }
44 }
45
46 /// Fill platform-detected defaults that require OS interaction.
47 ///
48 /// Currently fills `icon_theme` from the system icon theme if not already set.
49 /// This is separated from [`resolve()`](Self::resolve) because it performs
50 /// runtime OS detection (reading desktop environment settings), unlike the
51 /// pure inheritance rules in resolve().
52 pub fn resolve_platform_defaults(&mut self) {
53 if self.icon_theme.is_none() {
54 self.icon_theme = Some(crate::model::icons::system_icon_theme().to_string());
55 }
56 }
57
58 /// Apply all inheritance rules and platform defaults.
59 ///
60 /// Convenience method that calls [`resolve()`](Self::resolve) followed by
61 /// [`resolve_platform_defaults()`](Self::resolve_platform_defaults).
62 ///
63 /// **Note:** this does *not* auto-detect `font_dpi`. If `font_dpi` is
64 /// `None`, validation will use `DEFAULT_FONT_DPI` (96.0) for pt-to-px
65 /// conversion. To get automatic DPI detection, use
66 /// [`into_resolved()`](Self::into_resolved) or set `font_dpi` before
67 /// calling this method.
68 pub fn resolve_all(&mut self) {
69 self.resolve();
70 self.resolve_platform_defaults();
71 }
72
73 /// Resolve all inheritance rules and validate in one step.
74 ///
75 /// This is the recommended way to convert a `ThemeVariant` into a
76 /// [`ResolvedThemeVariant`]. It calls [`resolve_all()`](Self::resolve_all)
77 /// followed by [`validate()`](Self::validate), ensuring no fields are left
78 /// unresolved.
79 ///
80 /// # Errors
81 ///
82 /// Returns [`crate::Error::Resolution`] if any fields remain `None` after
83 /// resolution (e.g., when accent color is missing and cannot be derived).
84 ///
85 /// # Examples
86 ///
87 /// ```
88 /// use native_theme::ThemeSpec;
89 ///
90 /// let theme = ThemeSpec::preset("dracula").unwrap();
91 /// let variant = theme.dark.unwrap();
92 /// let resolved = variant.into_resolved().unwrap();
93 /// // All fields are now guaranteed populated
94 /// let accent = resolved.defaults.accent_color;
95 /// ```
96 #[must_use = "this returns the resolved theme and consumes self"]
97 pub fn into_resolved(mut self) -> crate::Result<ResolvedThemeVariant> {
98 // Auto-detect font_dpi from the OS when not already set (e.g. by an
99 // OS reader or TOML overlay). This ensures standalone preset loading
100 // applies the correct pt-to-px conversion for the current display.
101 // Done here (not in resolve_all) to preserve resolve_all idempotency.
102 if self.defaults.font_dpi.is_none() {
103 self.defaults.font_dpi = Some(crate::detect::system_font_dpi());
104 }
105 self.resolve_all();
106 self.validate()
107 }
108}
109
110#[cfg(test)]
111#[allow(clippy::unwrap_used, clippy::expect_used)]
112mod tests;