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;