Skip to main content

native_theme/model/
resolved.rs

1// Resolved (non-optional) theme types produced after theme resolution.
2//
3// These types mirror their Option-based counterparts in defaults.rs, font.rs,
4// icon_sizes.rs, and mod.rs (ThemeMode), but with all fields
5// guaranteed populated. Produced by validate() after resolve().
6
7use super::border::ResolvedDefaultsBorder;
8use super::font::ResolvedFontSpec;
9use crate::Rgba;
10
11// --- ResolvedIconSizes ---
12
13/// Fully resolved per-context icon sizes where every context is guaranteed populated.
14#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
15pub struct ResolvedIconSizes {
16    /// Icon size for toolbar buttons.
17    pub toolbar: f32,
18    /// Small icon size for inline use.
19    pub small: f32,
20    /// Large icon size for menus/lists.
21    pub large: f32,
22    /// Icon size for dialog buttons.
23    pub dialog: f32,
24    /// Icon size for panel headers.
25    pub panel: f32,
26}
27
28// --- ResolvedTextScaleEntry ---
29
30/// A single resolved text scale entry with guaranteed size, weight, and line height.
31///
32/// No `Default` derive: Constructed from a populated
33/// `TextScaleEntry` during resolution; the missing-field sentinel is
34/// written inline in
35/// `crate::resolve::validate_helpers::require_text_scale_entry` (private).
36#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
37pub struct ResolvedTextScaleEntry {
38    /// Font size in logical pixels. A size stated in points is converted at
39    /// the resolution context's
40    /// [`font_dpi`](crate::resolve::ResolutionContext::font_dpi).
41    pub size: f32,
42    /// CSS font weight (100-900).
43    pub weight: u16,
44    /// Line height in logical pixels. Computed as `defaults.line_height * size`
45    /// when not explicitly set. An explicit value stated in points is converted
46    /// at the same `font_dpi` as the size.
47    pub line_height: f32,
48}
49
50// --- ResolvedTextScale ---
51
52/// A fully resolved text scale with all four typographic roles populated.
53#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
54pub struct ResolvedTextScale {
55    /// Caption / small label text.
56    pub caption: ResolvedTextScaleEntry,
57    /// Section heading text.
58    pub section_heading: ResolvedTextScaleEntry,
59    /// Dialog title text.
60    pub dialog_title: ResolvedTextScaleEntry,
61    /// Large display / hero text.
62    pub display: ResolvedTextScaleEntry,
63}
64
65// --- ResolvedDefaults ---
66
67/// Fully resolved global theme defaults where every field is guaranteed populated.
68///
69/// Mirrors [`crate::model::ThemeDefaults`] but with concrete (non-Option) types.
70/// Produced by the resolution/validation pipeline.
71#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
72pub struct ResolvedDefaults {
73    // ---- Base font ----
74    /// Primary UI font.
75    pub font: ResolvedFontSpec,
76    /// Line height multiplier.
77    pub line_height: f32,
78    /// Monospace font for code/terminal content.
79    pub mono_font: ResolvedFontSpec,
80
81    // ---- Base colors ----
82    /// Main window/surface background color.
83    pub background_color: Rgba,
84    /// Default text color.
85    pub text_color: Rgba,
86    /// Accent/brand color for interactive elements.
87    pub accent_color: Rgba,
88    /// Text color used on accent-colored backgrounds.
89    pub accent_text_color: Rgba,
90    /// Elevated surface color.
91    pub surface_color: Rgba,
92    /// Secondary/subdued text color.
93    pub muted_color: Rgba,
94    /// Drop shadow color.
95    pub shadow_color: Rgba,
96    /// Hyperlink text color.
97    pub link_color: Rgba,
98    /// Selection highlight background.
99    pub selection_background: Rgba,
100    /// Text color over selection highlight.
101    pub selection_text_color: Rgba,
102    /// Selection background when window is unfocused.
103    pub selection_inactive_background: Rgba,
104    /// Text selection background (inline text highlight).
105    pub text_selection_background: Rgba,
106    /// Text selection color (inline text highlight).
107    pub text_selection_color: Rgba,
108    /// Text color for disabled controls.
109    pub disabled_text_color: Rgba,
110
111    // ---- Status colors ----
112    /// Danger/error color.
113    pub danger_color: Rgba,
114    /// Text color on danger-colored backgrounds.
115    pub danger_text_color: Rgba,
116    /// Warning color.
117    pub warning_color: Rgba,
118    /// Text color on warning-colored backgrounds.
119    pub warning_text_color: Rgba,
120    /// Success/confirmation color.
121    pub success_color: Rgba,
122    /// Text color on success-colored backgrounds.
123    pub success_text_color: Rgba,
124    /// Informational color.
125    pub info_color: Rgba,
126    /// Text color on info-colored backgrounds.
127    pub info_text_color: Rgba,
128
129    // ---- Global geometry ----
130    /// Border sub-struct (color, corner_radius, line_width, etc.).
131    pub border: ResolvedDefaultsBorder,
132    /// Opacity for disabled controls, applied to the whole widget together
133    /// with the disabled colours; 1.0 where the platform dims by colour
134    /// alone. See [`ThemeDefaults::disabled_opacity`](crate::model::ThemeDefaults::disabled_opacity).
135    pub disabled_opacity: f32,
136
137    // ---- Focus ring ----
138    /// Focus indicator outline color.
139    pub focus_ring_color: Rgba,
140    /// Focus indicator outline width.
141    pub focus_ring_width: f32,
142    /// Gap between element edge and focus indicator.
143    pub focus_ring_offset: f32,
144
145    // ---- Icon sizes ----
146    /// Per-context icon sizes.
147    pub icon_sizes: ResolvedIconSizes,
148}
149
150// --- ResolvedTheme ---
151
152/// A fully resolved theme where every field is guaranteed populated.
153///
154/// Produced by `validate()` after `resolve()`. Consumed by toolkit connectors.
155/// Mirrors [`crate::model::ThemeMode`] but with concrete (non-Option) types
156/// for all 26 per-widget structs plus defaults and text scale.
157#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
158pub struct ResolvedTheme {
159    /// Global defaults.
160    pub defaults: ResolvedDefaults,
161    /// Per-role text scale.
162    pub text_scale: ResolvedTextScale,
163
164    // ---- Per-widget resolved structs ----
165    /// Window chrome.
166    pub window: super::widgets::ResolvedWindowTheme,
167    /// Push button.
168    pub button: super::widgets::ResolvedButtonTheme,
169    /// Text input.
170    pub input: super::widgets::ResolvedInputTheme,
171    /// Multi-line text field's padding; the rest is `input`'s.
172    pub text_area: super::widgets::ResolvedTextAreaTheme,
173    /// Checkbox / radio button.
174    pub checkbox: super::widgets::ResolvedCheckboxTheme,
175    /// Popup / context menu.
176    pub menu: super::widgets::ResolvedMenuTheme,
177    /// Tooltip.
178    pub tooltip: super::widgets::ResolvedTooltipTheme,
179    /// Scrollbar.
180    pub scrollbar: super::widgets::ResolvedScrollbarTheme,
181    /// Slider.
182    pub slider: super::widgets::ResolvedSliderTheme,
183    /// Progress bar.
184    pub progress_bar: super::widgets::ResolvedProgressBarTheme,
185    /// Tab bar.
186    pub tab: super::widgets::ResolvedTabTheme,
187    /// Sidebar panel.
188    pub sidebar: super::widgets::ResolvedSidebarTheme,
189    /// Toolbar.
190    pub toolbar: super::widgets::ResolvedToolbarTheme,
191    /// Status bar.
192    pub status_bar: super::widgets::ResolvedStatusBarTheme,
193    /// List / table.
194    pub list: super::widgets::ResolvedListTheme,
195    /// Popover / dropdown.
196    pub popover: super::widgets::ResolvedPopoverTheme,
197    /// Splitter handle.
198    pub splitter: super::widgets::ResolvedSplitterTheme,
199    /// Separator line.
200    pub separator: super::widgets::ResolvedSeparatorTheme,
201    /// Toggle switch.
202    pub switch: super::widgets::ResolvedSwitchTheme,
203    /// Dialog.
204    pub dialog: super::widgets::ResolvedDialogTheme,
205    /// Spinner / progress ring.
206    pub spinner: super::widgets::ResolvedSpinnerTheme,
207    /// ComboBox / dropdown trigger.
208    pub combo_box: super::widgets::ResolvedComboBoxTheme,
209    /// Segmented control.
210    pub segmented_control: super::widgets::ResolvedSegmentedControlTheme,
211    /// Card / container.
212    pub card: super::widgets::ResolvedCardTheme,
213    /// Expander / disclosure.
214    pub expander: super::widgets::ResolvedExpanderTheme,
215    /// Hyperlink.
216    pub link: super::widgets::ResolvedLinkTheme,
217}
218
219// --- Resolved ---
220
221/// A theme fully resolved for a specific color mode.
222///
223/// Returned by [`Theme::resolve`](super::Theme::resolve). Bundles everything
224/// a UI framework needs to render a window: the picked variant's resolved
225/// visual properties, the theme's icon set, and the icon theme name — each
226/// already passed through its precedence chain.
227///
228/// # Icon theme precedence
229///
230/// The [`icon_theme`](Self::icon_theme) field is filled by checking, in order:
231///
232/// 1. The picked variant's `defaults.icon_theme` (per-mode override, e.g.
233///    KDE Plasma's `"breeze"` light / `"breeze-dark"` dark).
234/// 2. The parent [`Theme::icon_theme`](super::Theme::icon_theme) (shared
235///    across light and dark).
236/// 3. [`system_icon_theme()`](super::system_icon_theme) (runtime detect).
237///
238/// The first value that is `Some` wins. Where none is — the TOML states no
239/// icon theme and detection fails — the field is `None`: no theme stands in,
240/// and [`system_icon_theme()`](super::system_icon_theme) gives the reason.
241/// [`icon_theme_explicit`](Self::icon_theme_explicit) reports whether the
242/// value came from the TOML (tiers 1 or 2) or from runtime detection
243/// (tier 3) — useful for UI code that labels the theme-author's choice as
244/// `default (X)`.
245///
246/// # Examples
247///
248/// ```
249/// use native_theme::theme::{ColorMode, Theme};
250///
251/// let theme = Theme::preset("material")?;
252/// let r = theme.resolve(ColorMode::Light)?;
253/// assert_eq!(r.icon_theme.as_deref(), Some("material"));
254/// assert!(r.icon_theme_explicit);
255/// # Ok::<(), native_theme::error::Error>(())
256/// ```
257#[derive(Clone, Debug)]
258pub struct Resolved {
259    /// The picked variant's fully-resolved visual properties.
260    pub variant: ResolvedTheme,
261    /// The theme's icon set. Falls back to
262    /// [`system_icon_set()`](super::system_icon_set) when the TOML omits it.
263    pub icon_set: super::IconSet,
264    /// The icon theme name, resolved through the three-tier precedence
265    /// documented on the struct. `None` when the TOML states none and
266    /// detection fails; the app can call
267    /// [`system_icon_theme()`](super::system_icon_theme) for the reason.
268    pub icon_theme: Option<std::borrow::Cow<'static, str>>,
269    /// `true` if [`icon_theme`](Self::icon_theme) came from the TOML (tiers 1
270    /// or 2); `false` if it was left to runtime system detection (tier 3),
271    /// whether or not that detection named a theme.
272    pub icon_theme_explicit: bool,
273}
274
275#[cfg(test)]
276#[allow(
277    clippy::unwrap_used,
278    clippy::expect_used,
279    clippy::bool_assert_comparison
280)]
281mod tests {
282    use super::*;
283    use crate::Rgba;
284    use crate::model::ResolvedFontSpec;
285    use crate::model::border::ResolvedDefaultsBorder;
286    use crate::model::font::FontStyle;
287
288    fn sample_font() -> ResolvedFontSpec {
289        ResolvedFontSpec {
290            family: "Inter".into(),
291            size: 14.0,
292            defined_size: Some(crate::model::font::FontSize::Px(14.0)),
293            weight: 400,
294            style: FontStyle::Normal,
295            color: Rgba::rgb(128, 128, 128),
296        }
297    }
298
299    fn sample_border() -> ResolvedDefaultsBorder {
300        ResolvedDefaultsBorder {
301            color: Rgba::rgb(200, 200, 200),
302            corner_radius: 4.0,
303            corner_radius_lg: 8.0,
304            line_width: 1.0,
305            opacity: 0.15,
306            shadow_enabled: true,
307        }
308    }
309
310    fn sample_icon_sizes() -> ResolvedIconSizes {
311        ResolvedIconSizes {
312            toolbar: 24.0,
313            small: 16.0,
314            large: 32.0,
315            dialog: 22.0,
316            panel: 20.0,
317        }
318    }
319
320    fn sample_text_scale_entry() -> ResolvedTextScaleEntry {
321        ResolvedTextScaleEntry {
322            size: 12.0,
323            weight: 400,
324            line_height: 1.4,
325        }
326    }
327
328    fn sample_defaults() -> ResolvedDefaults {
329        let c = Rgba::rgb(128, 128, 128);
330        ResolvedDefaults {
331            font: sample_font(),
332            line_height: 1.4,
333            mono_font: ResolvedFontSpec {
334                family: "JetBrains Mono".into(),
335                size: 12.0,
336                defined_size: Some(crate::model::font::FontSize::Px(12.0)),
337                weight: 400,
338                style: FontStyle::Normal,
339                color: Rgba::rgb(128, 128, 128),
340            },
341            background_color: c,
342            text_color: c,
343            accent_color: c,
344            accent_text_color: c,
345            surface_color: c,
346            muted_color: c,
347            shadow_color: c,
348            link_color: c,
349            selection_background: c,
350            selection_text_color: c,
351            selection_inactive_background: c,
352            text_selection_background: c,
353            text_selection_color: c,
354            disabled_text_color: c,
355            danger_color: c,
356            danger_text_color: c,
357            warning_color: c,
358            warning_text_color: c,
359            success_color: c,
360            success_text_color: c,
361            info_color: c,
362            info_text_color: c,
363            border: sample_border(),
364            disabled_opacity: 0.5,
365            focus_ring_color: c,
366            focus_ring_width: 2.0,
367            focus_ring_offset: 1.0,
368            icon_sizes: sample_icon_sizes(),
369        }
370    }
371
372    // --- ResolvedIconSizes tests ---
373
374    #[test]
375    fn resolved_icon_sizes_has_5_concrete_fields() {
376        let i = sample_icon_sizes();
377        assert_eq!(i.toolbar, 24.0);
378        assert_eq!(i.small, 16.0);
379        assert_eq!(i.large, 32.0);
380        assert_eq!(i.dialog, 22.0);
381        assert_eq!(i.panel, 20.0);
382    }
383
384    #[test]
385    fn resolved_icon_sizes_derives_clone_debug_partialeq() {
386        let i = sample_icon_sizes();
387        let i2 = i.clone();
388        assert_eq!(i, i2);
389        let dbg = format!("{i:?}");
390        assert!(dbg.contains("ResolvedIconSizes"));
391    }
392
393    // --- ResolvedTextScaleEntry tests ---
394
395    #[test]
396    fn resolved_text_scale_entry_has_3_concrete_fields() {
397        let e = sample_text_scale_entry();
398        assert_eq!(e.size, 12.0);
399        assert_eq!(e.weight, 400);
400        assert_eq!(e.line_height, 1.4);
401    }
402
403    #[test]
404    fn resolved_text_scale_entry_derives_clone_debug_partialeq() {
405        let e = sample_text_scale_entry();
406        let e2 = e.clone();
407        assert_eq!(e, e2);
408        let dbg = format!("{e:?}");
409        assert!(dbg.contains("ResolvedTextScaleEntry"));
410    }
411
412    // --- ResolvedTextScale tests ---
413
414    #[test]
415    fn resolved_text_scale_has_4_entries() {
416        let ts = ResolvedTextScale {
417            caption: ResolvedTextScaleEntry {
418                size: 11.0,
419                weight: 400,
420                line_height: 1.3,
421            },
422            section_heading: ResolvedTextScaleEntry {
423                size: 14.0,
424                weight: 600,
425                line_height: 1.4,
426            },
427            dialog_title: ResolvedTextScaleEntry {
428                size: 16.0,
429                weight: 700,
430                line_height: 1.2,
431            },
432            display: ResolvedTextScaleEntry {
433                size: 24.0,
434                weight: 300,
435                line_height: 1.1,
436            },
437        };
438        assert_eq!(ts.caption.size, 11.0);
439        assert_eq!(ts.section_heading.weight, 600);
440        assert_eq!(ts.dialog_title.size, 16.0);
441        assert_eq!(ts.display.weight, 300);
442    }
443
444    #[test]
445    fn resolved_text_scale_derives_clone_debug_partialeq() {
446        let e = sample_text_scale_entry();
447        let ts = ResolvedTextScale {
448            caption: e.clone(),
449            section_heading: e.clone(),
450            dialog_title: e.clone(),
451            display: e,
452        };
453        let ts2 = ts.clone();
454        assert_eq!(ts, ts2);
455        let dbg = format!("{ts:?}");
456        assert!(dbg.contains("ResolvedTextScale"));
457    }
458
459    // --- ResolvedDefaults tests ---
460
461    #[test]
462    fn resolved_defaults_all_fields_concrete() {
463        let d = sample_defaults();
464        // Fonts
465        assert_eq!(d.font.family.as_ref(), "Inter");
466        assert_eq!(d.mono_font.family.as_ref(), "JetBrains Mono");
467        assert_eq!(d.line_height, 1.4);
468        // Some colors
469        assert_eq!(d.background_color, Rgba::rgb(128, 128, 128));
470        assert_eq!(d.accent_color, Rgba::rgb(128, 128, 128));
471        // Geometry (border sub-struct)
472        assert_eq!(d.border.corner_radius, 4.0);
473        assert_eq!(d.border.shadow_enabled, true);
474        // Focus ring
475        assert_eq!(d.focus_ring_width, 2.0);
476        // Icon sizes
477        assert_eq!(d.icon_sizes.toolbar, 24.0);
478    }
479
480    #[test]
481    fn resolved_defaults_derives_clone_debug_partialeq() {
482        let d = sample_defaults();
483        let d2 = d.clone();
484        assert_eq!(d, d2);
485        let dbg = format!("{d:?}");
486        assert!(dbg.contains("ResolvedDefaults"));
487    }
488
489    // --- ResolvedTheme tests ---
490    // NOTE: These tests construct ResolvedTheme with all 25 widget structs.
491    // The widget Resolved* types will have new field names after Task 2,
492    // but for now they reference the old names -- Plan 02 (resolve.rs) will
493    // update all consumers. These tests are intentionally commented out until
494    // the full atomic commit is assembled.
495    //
496    // The structural tests for ResolvedDefaults above verify the defaults
497    // rename is correct.
498
499    // --- Behavioral tests (issue 2d) ---
500    // These tests call into resolve() and presets, which will break until
501    // Plans 02-04 update all consumers. They are kept for reference but
502    // will not compile until the atomic commit is complete.
503}