Skip to main content

native_theme/resolve/
mod.rs

1// Resolution engine: three-stage pipeline.
2//
3// 1. resolve() -- pure data transform: fills None fields from defaults and
4//    related widgets via ~91 inheritance rules. No OS detection, no I/O.
5// 2. resolve_platform_defaults() -- fills fields that require OS detection:
6//    button_order (from detected desktop environment).
7// 3. validate() -- extracts Option<T> -> T, producing ResolvedTheme.
8//
9// Convenience: resolve_all() = 1+2, into_resolved() = 1+2+3.
10//
11// Split into submodules:
12// - inheritance: Phase 1-5 resolution rules (fill None fields from defaults/other widgets)
13// - validate: Field extraction, range checks, ResolvedTheme construction
14
15pub(crate) mod context;
16pub(crate) mod inheritance;
17pub(crate) mod validate;
18pub(crate) mod validate_helpers;
19
20pub use context::ResolutionContext;
21
22/// Widget field metadata for TOML linting. Populated by `#[derive(ThemeWidget)]`.
23pub(crate) struct WidgetFieldInfo {
24    /// Snake_case widget name (e.g., "button", "segmented_control").
25    pub widget_name: &'static str,
26    /// All serialized TOML field names for this widget.
27    pub field_names: &'static [&'static str],
28}
29inventory::collect!(WidgetFieldInfo);
30
31/// Non-widget (plain struct) field metadata for TOML linting.
32///
33/// Populated by `#[derive(ThemeFields)]` on plain structs like `FontSpec`,
34/// `IconSizes`, `ThemeDefaults`, etc. Consumed by `lint_toml` to detect
35/// unknown keys in sub-tables. See `docs/archive/v0.5.7_gaps.md` §G5 for
36/// rationale — this sister registry to `WidgetFieldInfo` eliminates the
37/// hand-authored `FIELD_NAMES` constants that previously duplicated
38/// struct field lists.
39pub(crate) struct FieldInfo {
40    /// Pascal-case type name (e.g. "FontSpec", "IconSizes", "ThemeDefaults").
41    pub struct_name: &'static str,
42    /// All serialized TOML field names for this struct.
43    pub field_names: &'static [&'static str],
44}
45inventory::collect!(FieldInfo);
46
47/// Border inheritance registry (Phase 94-01 G6).
48///
49/// Populated by `#[derive(ThemeWidget)]` for widgets that declare
50/// `#[theme_inherit(border_kind = "full" | "full_lg" | "partial")]`. Each
51/// declaration produces one row in this registry.
52///
53/// Consumed by the inverted drift test
54/// `border_inheritance_toml_matches_macro_emit` in `inheritance.rs::tests`
55/// which asserts `docs/inheritance-rules.toml [border_inheritance]` matches
56/// the registry byte-for-byte (macro is the source of truth post-G6;
57/// TOML is a generated-documentation output).
58///
59/// Sister registry to [`WidgetFieldInfo`] / [`FieldInfo`] — same
60/// inventory-crate pattern (`inventory::submit!` from the derive output).
61///
62/// Fields carry `#[cfg_attr(not(test), allow(dead_code))]` because they are
63/// consumed only by the drift tests in `inheritance.rs::tests`
64/// (introspection via `inventory::iter`). A non-test build compiles the
65/// registry rows but never reads them — matching Phase 93-09's conditional
66/// `allow(dead_code)` pattern for self-unmasking (if a future non-test
67/// consumer lands, the allow stops firing and real dead-code regressions
68/// surface).
69pub(crate) struct BorderInheritanceInfo {
70    /// Snake_case widget name (e.g., "button", "segmented_control").
71    #[cfg_attr(not(test), allow(dead_code))]
72    pub widget_name: &'static str,
73    /// Inheritance kind: `"full"`, `"full_lg"`, or `"partial"`.
74    ///
75    /// Uses a plain `&'static str` instead of an enum to keep the
76    /// inventory schema dependency-free — `BorderInheritanceKind` lives
77    /// in `native-theme-derive` which the runtime crate cannot import.
78    #[cfg_attr(not(test), allow(dead_code))]
79    pub kind: &'static str,
80}
81inventory::collect!(BorderInheritanceInfo);
82
83/// Font inheritance registry (Phase 94-01 G6).
84///
85/// Populated by `#[derive(ThemeWidget)]` for widgets that declare one or
86/// more `#[theme_inherit(font = "<field>")]` attributes. Each attribute
87/// produces one row in this registry; widgets like `list` (item_font +
88/// header_font) and `dialog` (title_font + body_font) contribute two rows.
89///
90/// Consumed by the inverted drift test
91/// `font_inheritance_toml_matches_macro_emit` in `inheritance.rs::tests`
92/// which asserts `docs/inheritance-rules.toml [font_inheritance]` matches
93/// the registry byte-for-byte.
94///
95/// Fields carry `#[cfg_attr(not(test), allow(dead_code))]` — see
96/// [`BorderInheritanceInfo`] for rationale.
97pub(crate) struct FontInheritanceInfo {
98    /// Snake_case widget name (e.g., "button", "list", "dialog", "link").
99    #[cfg_attr(not(test), allow(dead_code))]
100    pub widget_name: &'static str,
101    /// Name of the font field on the widget Option struct
102    /// (e.g., `"font"`, `"title_bar_font"`, `"item_font"`, `"header_font"`,
103    /// `"title_font"`, `"body_font"`).
104    #[cfg_attr(not(test), allow(dead_code))]
105    pub font_field: &'static str,
106}
107inventory::collect!(FontInheritanceInfo);
108
109use crate::model::ThemeMode;
110use crate::model::resolved::ResolvedTheme;
111
112impl ThemeMode {
113    /// Apply all ~91 inheritance rules in 4-phase order (pure data transform).
114    ///
115    /// After calling resolve(), most Option fields that were None will be filled
116    /// from defaults or related widget fields. Calling resolve() twice produces
117    /// the same result (idempotent).
118    ///
119    /// This method is a pure data transform: it does not perform any OS detection
120    /// or I/O. For full resolution including platform defaults (dialog button
121    /// order from the desktop environment), use [`resolve_all()`](Self::resolve_all).
122    ///
123    /// # Phases
124    ///
125    /// 1. **Defaults internal chains** -- accent derives selection, focus_ring_color;
126    ///    selection derives selection_inactive.
127    /// 2. **Safety nets** -- platform-divergent fields get a reasonable fallback.
128    /// 3. **Widget-from-defaults** -- colors, geometry, fonts, text scale entries
129    ///    all inherit from defaults.
130    /// 4. **Widget-to-widget** -- inactive title bar fields fall back to active.
131    #[doc(hidden)]
132    pub fn resolve(&mut self) {
133        self.resolve_defaults_internal();
134        self.resolve_safety_nets();
135        self.resolve_widgets_from_defaults();
136        self.resolve_widget_to_widget();
137    }
138
139    /// Fill platform-detected defaults that require OS interaction.
140    ///
141    /// Currently fills:
142    /// - `dialog.button_order` from the detected desktop environment if not already set
143    ///
144    /// This is separated from [`resolve()`](Self::resolve) because it performs
145    /// runtime OS detection (reading desktop environment settings), unlike the
146    /// pure inheritance rules in resolve().
147    ///
148    /// Note: `icon_set` resolution is handled at the
149    /// [`Theme`](crate::Theme) / pipeline level. `icon_theme` is per-variant
150    /// on [`ThemeDefaults`](crate::ThemeDefaults) and resolved in the pipeline.
151    #[doc(hidden)]
152    pub fn resolve_platform_defaults(&mut self) {
153        if self.dialog.button_order.is_none() {
154            self.dialog.button_order = Some(inheritance::platform_button_order());
155        }
156    }
157
158    /// Apply all inheritance rules and platform defaults.
159    ///
160    /// Convenience method that calls [`resolve()`](Self::resolve) followed by
161    /// [`resolve_platform_defaults()`](Self::resolve_platform_defaults).
162    ///
163    /// **Note:** this does *not* handle `font_dpi`. Pass the DPI value to
164    /// [`validate_with_dpi()`](Self::validate_with_dpi) or use
165    /// [`into_resolved()`](Self::into_resolved), which takes the DPI from its
166    /// [`ResolutionContext`].
167    #[doc(hidden)]
168    pub fn resolve_all(&mut self) {
169        self.resolve();
170        self.resolve_platform_defaults();
171    }
172
173    /// Apply inheritance rules using a pre-built [`ResolutionContext`].
174    ///
175    /// Replacement for [`resolve_all()`](Self::resolve_all) that reads
176    /// `ctx.button_order` instead of calling
177    /// `inheritance::platform_button_order` inline. Used by
178    /// [`into_resolved`](Self::into_resolved) so that platform detection
179    /// happens once per theme-build (in
180    /// [`ResolutionContext::from_system`](crate::resolve::ResolutionContext::from_system))
181    /// rather than inside each variant resolution.
182    ///
183    /// Note: `icon_theme` resolution lives in
184    /// [`pipeline::run_pipeline`](crate::pipeline) with three-tier
185    /// precedence. This method intentionally does not read
186    /// `ctx.icon_theme`.
187    #[doc(hidden)]
188    pub fn resolve_all_with_context(&mut self, ctx: &ResolutionContext) {
189        self.resolve();
190        if self.dialog.button_order.is_none() {
191            self.dialog.button_order = Some(ctx.button_order);
192        }
193    }
194
195    /// Resolve all inheritance rules and validate in one step.
196    ///
197    /// This is the recommended way to convert a `ThemeMode` into a
198    /// [`ResolvedTheme`]. It applies every inheritance rule (including
199    /// the `ctx.button_order` platform default) followed by
200    /// [`validate_with_dpi()`](Self::validate_with_dpi), ensuring no
201    /// fields are left unresolved.
202    ///
203    /// # Arguments
204    ///
205    /// * `ctx` -- resolution-time inputs captured once per theme-build.
206    ///   Use [`ResolutionContext::from_system`](crate::resolve::ResolutionContext::from_system)
207    ///   for OS-detected values (Linux/Windows ≈ 96 DPI, macOS = 72 DPI,
208    ///   button order per desktop environment), or
209    ///   [`ResolutionContext::for_tests`](crate::resolve::ResolutionContext::for_tests)
210    ///   for deterministic test values (96 DPI, `PrimaryRight`,
211    ///   no `icon_theme`). The [`resolve_system()`](Self::resolve_system)
212    ///   shortcut wraps the `from_system()` case.
213    ///
214    /// # Errors
215    ///
216    /// Returns [`crate::Error::ResolutionIncomplete`] if any fields
217    /// remain `None` after resolution, or
218    /// [`crate::Error::ResolutionInvalid`] if range checks fail.
219    ///
220    /// # Examples
221    ///
222    /// ```
223    /// use native_theme::resolve::ResolutionContext;
224    /// use native_theme::theme::Theme;
225    ///
226    /// let theme = Theme::preset("dracula")?;
227    /// let variant = theme.dark.ok_or("no dark variant")?;
228    /// let resolved = variant.into_resolved(&ResolutionContext::from_system())?;
229    /// // Or use the zero-argument shortcut:
230    /// // let resolved = variant.resolve_system()?;
231    /// let _accent = resolved.defaults.accent_color;
232    /// # Ok::<(), Box<dyn std::error::Error>>(())
233    /// ```
234    pub fn into_resolved(mut self, ctx: &ResolutionContext) -> crate::Result<ResolvedTheme> {
235        self.resolve_all_with_context(ctx);
236        self.validate_with_dpi(ctx.font_dpi)
237    }
238
239    /// Resolve using the OS-detected context.
240    ///
241    /// Equivalent to
242    /// `self.into_resolved(&ResolutionContext::from_system())`.
243    ///
244    /// Placed on `ThemeMode` (not `Theme`) because `Theme` has both
245    /// light and dark variants — variant selection must be explicit via
246    /// [`Theme::into_variant`](crate::theme::Theme::into_variant), a
247    /// deliberate deviation from `docs/archive/v0.5.7_gaps.md` §G7 step 4.
248    ///
249    /// # Errors
250    ///
251    /// Returns [`crate::Error::ResolutionIncomplete`] if any fields
252    /// remain `None` after resolution, or
253    /// [`crate::Error::ResolutionInvalid`] if range checks fail.
254    ///
255    /// # Examples
256    ///
257    /// ```
258    /// use native_theme::theme::{ColorMode, Theme};
259    ///
260    /// let theme = Theme::preset("dracula")?;
261    /// let variant = theme.into_variant(ColorMode::Dark)?;
262    /// let resolved = variant.resolve_system()?;
263    /// let _accent = resolved.defaults.accent_color;
264    /// # Ok::<(), Box<dyn std::error::Error>>(())
265    /// ```
266    pub fn resolve_system(self) -> crate::Result<ResolvedTheme> {
267        self.into_resolved(&ResolutionContext::from_system())
268    }
269}
270
271#[cfg(test)]
272#[allow(clippy::unwrap_used, clippy::expect_used)]
273mod tests;