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 (ThemeVariant), but with all fields
5// guaranteed populated. Produced by validate() after resolve().
6
7use super::border::ResolvedBorderSpec;
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#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
32pub struct ResolvedTextScaleEntry {
33    /// Font size in logical pixels (converted from points during resolution
34    /// if `font_dpi` was set).
35    pub size: f32,
36    /// CSS font weight (100-900).
37    pub weight: u16,
38    /// Line height in logical pixels. Computed as `defaults.line_height * size`
39    /// when not explicitly set. Explicit values from presets are converted from
40    /// points to pixels along with sizes when `font_dpi` is set.
41    pub line_height: f32,
42}
43
44// --- ResolvedTextScale ---
45
46/// A fully resolved text scale with all four typographic roles populated.
47#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
48pub struct ResolvedTextScale {
49    /// Caption / small label text.
50    pub caption: ResolvedTextScaleEntry,
51    /// Section heading text.
52    pub section_heading: ResolvedTextScaleEntry,
53    /// Dialog title text.
54    pub dialog_title: ResolvedTextScaleEntry,
55    /// Large display / hero text.
56    pub display: ResolvedTextScaleEntry,
57}
58
59// --- ResolvedThemeDefaults ---
60
61/// Fully resolved global theme defaults where every field is guaranteed populated.
62///
63/// Mirrors [`crate::model::ThemeDefaults`] but with concrete (non-Option) types.
64/// Produced by the resolution/validation pipeline.
65#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
66pub struct ResolvedThemeDefaults {
67    // ---- Base font ----
68    /// Primary UI font.
69    pub font: ResolvedFontSpec,
70    /// Line height multiplier.
71    pub line_height: f32,
72    /// Monospace font for code/terminal content.
73    pub mono_font: ResolvedFontSpec,
74
75    // ---- Base colors ----
76    /// Main window/surface background color.
77    pub background_color: Rgba,
78    /// Default text color.
79    pub text_color: Rgba,
80    /// Accent/brand color for interactive elements.
81    pub accent_color: Rgba,
82    /// Text color used on accent-colored backgrounds.
83    pub accent_text_color: Rgba,
84    /// Elevated surface color.
85    pub surface_color: Rgba,
86    /// Secondary/subdued text color.
87    pub muted_color: Rgba,
88    /// Drop shadow color.
89    pub shadow_color: Rgba,
90    /// Hyperlink text color.
91    pub link_color: Rgba,
92    /// Selection highlight background.
93    pub selection_background: Rgba,
94    /// Text color over selection highlight.
95    pub selection_text_color: Rgba,
96    /// Selection background when window is unfocused.
97    pub selection_inactive_background: Rgba,
98    /// Text selection background (inline text highlight).
99    pub text_selection_background: Rgba,
100    /// Text selection color (inline text highlight).
101    pub text_selection_color: Rgba,
102    /// Text color for disabled controls.
103    pub disabled_text_color: Rgba,
104
105    // ---- Status colors ----
106    /// Danger/error color.
107    pub danger_color: Rgba,
108    /// Text color on danger-colored backgrounds.
109    pub danger_text_color: Rgba,
110    /// Warning color.
111    pub warning_color: Rgba,
112    /// Text color on warning-colored backgrounds.
113    pub warning_text_color: Rgba,
114    /// Success/confirmation color.
115    pub success_color: Rgba,
116    /// Text color on success-colored backgrounds.
117    pub success_text_color: Rgba,
118    /// Informational color.
119    pub info_color: Rgba,
120    /// Text color on info-colored backgrounds.
121    pub info_text_color: Rgba,
122
123    // ---- Global geometry ----
124    /// Border sub-struct (color, corner_radius, line_width, etc.).
125    pub border: ResolvedBorderSpec,
126    /// Opacity for disabled controls.
127    pub disabled_opacity: f32,
128
129    // ---- Focus ring ----
130    /// Focus indicator outline color.
131    pub focus_ring_color: Rgba,
132    /// Focus indicator outline width.
133    pub focus_ring_width: f32,
134    /// Gap between element edge and focus indicator.
135    pub focus_ring_offset: f32,
136
137    // ---- Icon sizes ----
138    /// Per-context icon sizes.
139    pub icon_sizes: ResolvedIconSizes,
140
141    // ---- Font DPI ----
142    /// Font DPI used for pt-to-px conversion during resolution.
143    /// Defaults to 96.0 when not set on the unresolved variant.
144    pub font_dpi: f32,
145
146    // ---- Accessibility ----
147    /// Text scaling factor -- an accessibility multiplier for enlarged text
148    /// (1.0 = no scaling). Connectors and apps should multiply `font.size` by
149    /// this factor when the user's preference for larger text should be honored.
150    /// This is independent of `font_dpi` (which handles DPI-based
151    /// point-to-pixel conversion).
152    pub text_scaling_factor: f32,
153    /// Whether the user has requested reduced motion.
154    pub reduce_motion: bool,
155    /// Whether a high-contrast mode is active.
156    pub high_contrast: bool,
157    /// Whether the user has requested reduced transparency.
158    pub reduce_transparency: bool,
159}
160
161// --- ResolvedThemeVariant ---
162
163/// A fully resolved theme where every field is guaranteed populated.
164///
165/// Produced by `validate()` after `resolve()`. Consumed by toolkit connectors.
166/// Mirrors [`crate::model::ThemeVariant`] but with concrete (non-Option) types
167/// for all 25 per-widget structs plus defaults and text scale.
168#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
169pub struct ResolvedThemeVariant {
170    /// Global defaults.
171    pub defaults: ResolvedThemeDefaults,
172    /// Per-role text scale.
173    pub text_scale: ResolvedTextScale,
174
175    // ---- Per-widget resolved structs ----
176    /// Window chrome.
177    pub window: super::widgets::ResolvedWindowTheme,
178    /// Push button.
179    pub button: super::widgets::ResolvedButtonTheme,
180    /// Text input.
181    pub input: super::widgets::ResolvedInputTheme,
182    /// Checkbox / radio button.
183    pub checkbox: super::widgets::ResolvedCheckboxTheme,
184    /// Popup / context menu.
185    pub menu: super::widgets::ResolvedMenuTheme,
186    /// Tooltip.
187    pub tooltip: super::widgets::ResolvedTooltipTheme,
188    /// Scrollbar.
189    pub scrollbar: super::widgets::ResolvedScrollbarTheme,
190    /// Slider.
191    pub slider: super::widgets::ResolvedSliderTheme,
192    /// Progress bar.
193    pub progress_bar: super::widgets::ResolvedProgressBarTheme,
194    /// Tab bar.
195    pub tab: super::widgets::ResolvedTabTheme,
196    /// Sidebar panel.
197    pub sidebar: super::widgets::ResolvedSidebarTheme,
198    /// Toolbar.
199    pub toolbar: super::widgets::ResolvedToolbarTheme,
200    /// Status bar.
201    pub status_bar: super::widgets::ResolvedStatusBarTheme,
202    /// List / table.
203    pub list: super::widgets::ResolvedListTheme,
204    /// Popover / dropdown.
205    pub popover: super::widgets::ResolvedPopoverTheme,
206    /// Splitter handle.
207    pub splitter: super::widgets::ResolvedSplitterTheme,
208    /// Separator line.
209    pub separator: super::widgets::ResolvedSeparatorTheme,
210    /// Toggle switch.
211    pub switch: super::widgets::ResolvedSwitchTheme,
212    /// Dialog.
213    pub dialog: super::widgets::ResolvedDialogTheme,
214    /// Spinner / progress ring.
215    pub spinner: super::widgets::ResolvedSpinnerTheme,
216    /// ComboBox / dropdown trigger.
217    pub combo_box: super::widgets::ResolvedComboBoxTheme,
218    /// Segmented control.
219    pub segmented_control: super::widgets::ResolvedSegmentedControlTheme,
220    /// Card / container.
221    pub card: super::widgets::ResolvedCardTheme,
222    /// Expander / disclosure.
223    pub expander: super::widgets::ResolvedExpanderTheme,
224    /// Hyperlink.
225    pub link: super::widgets::ResolvedLinkTheme,
226
227    /// Which icon loading mechanism to use -- determines *how* icons are looked
228    /// up (freedesktop theme directories, bundled SVG tables, SF Symbols, etc.).
229    pub icon_set: crate::IconSet,
230
231    /// The name of the visual icon theme that provides the actual icon files
232    /// (e.g. `"breeze"`, `"Adwaita"`, `"Lucide"`).
233    pub icon_theme: String,
234}
235
236#[cfg(test)]
237#[allow(
238    clippy::unwrap_used,
239    clippy::expect_used,
240    clippy::bool_assert_comparison
241)]
242mod tests {
243    use super::*;
244    use crate::Rgba;
245    use crate::model::ResolvedFontSpec;
246    use crate::model::border::ResolvedBorderSpec;
247    use crate::model::font::FontStyle;
248
249    fn sample_font() -> ResolvedFontSpec {
250        ResolvedFontSpec {
251            family: "Inter".into(),
252            size: 14.0,
253            weight: 400,
254            style: FontStyle::Normal,
255            color: Rgba::rgb(128, 128, 128),
256        }
257    }
258
259    fn sample_border() -> ResolvedBorderSpec {
260        ResolvedBorderSpec {
261            color: Rgba::rgb(200, 200, 200),
262            corner_radius: 4.0,
263            corner_radius_lg: 8.0,
264            line_width: 1.0,
265            opacity: 0.15,
266            shadow_enabled: true,
267            padding_horizontal: 0.0,
268            padding_vertical: 0.0,
269        }
270    }
271
272    fn sample_icon_sizes() -> ResolvedIconSizes {
273        ResolvedIconSizes {
274            toolbar: 24.0,
275            small: 16.0,
276            large: 32.0,
277            dialog: 22.0,
278            panel: 20.0,
279        }
280    }
281
282    fn sample_text_scale_entry() -> ResolvedTextScaleEntry {
283        ResolvedTextScaleEntry {
284            size: 12.0,
285            weight: 400,
286            line_height: 1.4,
287        }
288    }
289
290    fn sample_defaults() -> ResolvedThemeDefaults {
291        let c = Rgba::rgb(128, 128, 128);
292        ResolvedThemeDefaults {
293            font: sample_font(),
294            line_height: 1.4,
295            mono_font: ResolvedFontSpec {
296                family: "JetBrains Mono".into(),
297                size: 12.0,
298                weight: 400,
299                style: FontStyle::Normal,
300                color: Rgba::rgb(128, 128, 128),
301            },
302            background_color: c,
303            text_color: c,
304            accent_color: c,
305            accent_text_color: c,
306            surface_color: c,
307            muted_color: c,
308            shadow_color: c,
309            link_color: c,
310            selection_background: c,
311            selection_text_color: c,
312            selection_inactive_background: c,
313            text_selection_background: c,
314            text_selection_color: c,
315            disabled_text_color: c,
316            danger_color: c,
317            danger_text_color: c,
318            warning_color: c,
319            warning_text_color: c,
320            success_color: c,
321            success_text_color: c,
322            info_color: c,
323            info_text_color: c,
324            border: sample_border(),
325            disabled_opacity: 0.5,
326            focus_ring_color: c,
327            focus_ring_width: 2.0,
328            focus_ring_offset: 1.0,
329            icon_sizes: sample_icon_sizes(),
330            font_dpi: 96.0,
331            text_scaling_factor: 1.0,
332            reduce_motion: false,
333            high_contrast: false,
334            reduce_transparency: false,
335        }
336    }
337
338    // --- ResolvedIconSizes tests ---
339
340    #[test]
341    fn resolved_icon_sizes_has_5_concrete_fields() {
342        let i = sample_icon_sizes();
343        assert_eq!(i.toolbar, 24.0);
344        assert_eq!(i.small, 16.0);
345        assert_eq!(i.large, 32.0);
346        assert_eq!(i.dialog, 22.0);
347        assert_eq!(i.panel, 20.0);
348    }
349
350    #[test]
351    fn resolved_icon_sizes_derives_clone_debug_partialeq() {
352        let i = sample_icon_sizes();
353        let i2 = i.clone();
354        assert_eq!(i, i2);
355        let dbg = format!("{i:?}");
356        assert!(dbg.contains("ResolvedIconSizes"));
357    }
358
359    // --- ResolvedTextScaleEntry tests ---
360
361    #[test]
362    fn resolved_text_scale_entry_has_3_concrete_fields() {
363        let e = sample_text_scale_entry();
364        assert_eq!(e.size, 12.0);
365        assert_eq!(e.weight, 400);
366        assert_eq!(e.line_height, 1.4);
367    }
368
369    #[test]
370    fn resolved_text_scale_entry_derives_clone_debug_partialeq() {
371        let e = sample_text_scale_entry();
372        let e2 = e.clone();
373        assert_eq!(e, e2);
374        let dbg = format!("{e:?}");
375        assert!(dbg.contains("ResolvedTextScaleEntry"));
376    }
377
378    // --- ResolvedTextScale tests ---
379
380    #[test]
381    fn resolved_text_scale_has_4_entries() {
382        let ts = ResolvedTextScale {
383            caption: ResolvedTextScaleEntry {
384                size: 11.0,
385                weight: 400,
386                line_height: 1.3,
387            },
388            section_heading: ResolvedTextScaleEntry {
389                size: 14.0,
390                weight: 600,
391                line_height: 1.4,
392            },
393            dialog_title: ResolvedTextScaleEntry {
394                size: 16.0,
395                weight: 700,
396                line_height: 1.2,
397            },
398            display: ResolvedTextScaleEntry {
399                size: 24.0,
400                weight: 300,
401                line_height: 1.1,
402            },
403        };
404        assert_eq!(ts.caption.size, 11.0);
405        assert_eq!(ts.section_heading.weight, 600);
406        assert_eq!(ts.dialog_title.size, 16.0);
407        assert_eq!(ts.display.weight, 300);
408    }
409
410    #[test]
411    fn resolved_text_scale_derives_clone_debug_partialeq() {
412        let e = sample_text_scale_entry();
413        let ts = ResolvedTextScale {
414            caption: e.clone(),
415            section_heading: e.clone(),
416            dialog_title: e.clone(),
417            display: e,
418        };
419        let ts2 = ts.clone();
420        assert_eq!(ts, ts2);
421        let dbg = format!("{ts:?}");
422        assert!(dbg.contains("ResolvedTextScale"));
423    }
424
425    // --- ResolvedThemeDefaults tests ---
426
427    #[test]
428    fn resolved_defaults_all_fields_concrete() {
429        let d = sample_defaults();
430        // Fonts
431        assert_eq!(d.font.family, "Inter");
432        assert_eq!(d.mono_font.family, "JetBrains Mono");
433        assert_eq!(d.line_height, 1.4);
434        // Some colors
435        assert_eq!(d.background_color, Rgba::rgb(128, 128, 128));
436        assert_eq!(d.accent_color, Rgba::rgb(128, 128, 128));
437        // Geometry (border sub-struct)
438        assert_eq!(d.border.corner_radius, 4.0);
439        assert_eq!(d.border.shadow_enabled, true);
440        // Focus ring
441        assert_eq!(d.focus_ring_width, 2.0);
442        // Icon sizes
443        assert_eq!(d.icon_sizes.toolbar, 24.0);
444        // Accessibility
445        assert_eq!(d.text_scaling_factor, 1.0);
446        assert_eq!(d.reduce_motion, false);
447    }
448
449    #[test]
450    fn resolved_defaults_derives_clone_debug_partialeq() {
451        let d = sample_defaults();
452        let d2 = d.clone();
453        assert_eq!(d, d2);
454        let dbg = format!("{d:?}");
455        assert!(dbg.contains("ResolvedThemeDefaults"));
456    }
457
458    // --- ResolvedThemeVariant tests ---
459    // NOTE: These tests construct ResolvedThemeVariant with all 25 widget structs.
460    // The widget Resolved* types will have new field names after Task 2,
461    // but for now they reference the old names -- Plan 02 (resolve.rs) will
462    // update all consumers. These tests are intentionally commented out until
463    // the full atomic commit is assembled.
464    //
465    // The structural tests for ResolvedThemeDefaults above verify the defaults
466    // rename is correct.
467
468    // --- Behavioral tests (issue 2d) ---
469    // These tests call into resolve() and presets, which will break until
470    // Plans 02-04 update all consumers. They are kept for reference but
471    // will not compile until the atomic commit is complete.
472}