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}