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