Skip to main content

native_theme/model/
defaults.rs

1// ThemeDefaults: global properties shared across widgets
2
3use crate::Rgba;
4use crate::model::border::BorderSpec;
5use crate::model::{FontSpec, IconSizes};
6use serde::{Deserialize, Serialize};
7
8/// Global theme defaults shared across all widgets.
9///
10/// # Field structure
11///
12/// This struct uses two patterns for its fields:
13///
14/// - **`Option<T>` leaf fields** (`accent_color`, `disabled_opacity`, `line_height`, etc.) —
15///   `None` means "not set." During merge, an overlay's `Some` value replaces
16///   the base wholesale.
17///
18/// - **Non-Option nested struct fields** (`font`, `mono_font`, `border`,
19///   `icon_sizes`) — these support partial field-by-field override during
20///   merge. For example, an overlay that sets only `font.size` will inherit
21///   the base's `font.family` and `font.weight`. This makes theme merging
22///   more flexible: you can fine-tune individual properties without replacing
23///   the entire sub-struct.
24///
25/// This asymmetry is intentional. Checking "is accent_color set?" is
26/// `defaults.accent_color.is_some()`, while checking "is font set?" requires
27/// inspecting individual fields like `defaults.font.family.is_some()`.
28///
29/// When resolving a widget's properties, `None` on the widget struct
30/// means "inherit from `ThemeDefaults`".
31#[serde_with::skip_serializing_none]
32#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
33#[serde(default)]
34pub struct ThemeDefaults {
35    // ---- Base font ----
36    /// Primary UI font (family, size, weight).
37    #[serde(default, skip_serializing_if = "FontSpec::is_empty")]
38    pub font: FontSpec,
39
40    /// Line height multiplier (e.g. 1.4 = 140% of font size).
41    pub line_height: Option<f32>,
42
43    /// Monospace font for code/terminal content.
44    #[serde(default, skip_serializing_if = "FontSpec::is_empty")]
45    pub mono_font: FontSpec,
46
47    // ---- Base colors ----
48    /// Main window/surface background color.
49    pub background_color: Option<Rgba>,
50    /// Default text color.
51    pub text_color: Option<Rgba>,
52    /// Accent/brand color for interactive elements.
53    pub accent_color: Option<Rgba>,
54    /// Text color used on accent-colored backgrounds.
55    pub accent_text_color: Option<Rgba>,
56    /// Elevated surface color (cards, dialogs, popovers).
57    pub surface_color: Option<Rgba>,
58    /// Secondary/subdued text color.
59    pub muted_color: Option<Rgba>,
60    /// Drop shadow color (with alpha).
61    pub shadow_color: Option<Rgba>,
62    /// Hyperlink text color.
63    pub link_color: Option<Rgba>,
64    /// Selection highlight background.
65    pub selection_background: Option<Rgba>,
66    /// Text color over selection highlight.
67    pub selection_text_color: Option<Rgba>,
68    /// Selection background when window is unfocused.
69    pub selection_inactive_background: Option<Rgba>,
70    /// Text selection background (inline text highlight).
71    pub text_selection_background: Option<Rgba>,
72    /// Text selection color (inline text highlight).
73    pub text_selection_color: Option<Rgba>,
74    /// Text color for disabled controls.
75    pub disabled_text_color: Option<Rgba>,
76
77    // ---- Status colors ----
78    /// Danger/error color.
79    pub danger_color: Option<Rgba>,
80    /// Text color on danger-colored backgrounds.
81    pub danger_text_color: Option<Rgba>,
82    /// Warning color.
83    pub warning_color: Option<Rgba>,
84    /// Text color on warning-colored backgrounds.
85    pub warning_text_color: Option<Rgba>,
86    /// Success/confirmation color.
87    pub success_color: Option<Rgba>,
88    /// Text color on success-colored backgrounds.
89    pub success_text_color: Option<Rgba>,
90    /// Informational color.
91    pub info_color: Option<Rgba>,
92    /// Text color on info-colored backgrounds.
93    pub info_text_color: Option<Rgba>,
94
95    // ---- Global geometry ----
96    /// Border sub-struct (color, corner_radius, line_width, etc.).
97    #[serde(default, skip_serializing_if = "BorderSpec::is_empty")]
98    pub border: BorderSpec,
99    /// Opacity for disabled controls (0.0–1.0).
100    pub disabled_opacity: Option<f32>,
101
102    // ---- Focus ring ----
103    /// Focus indicator outline color.
104    pub focus_ring_color: Option<Rgba>,
105    /// Focus indicator outline width.
106    #[serde(rename = "focus_ring_width_px")]
107    pub focus_ring_width: Option<f32>,
108    /// Gap between element edge and focus indicator.
109    #[serde(rename = "focus_ring_offset_px")]
110    pub focus_ring_offset: Option<f32>,
111
112    // ---- Icon sizes ----
113    /// Per-context icon sizes.
114    #[serde(default, skip_serializing_if = "IconSizes::is_empty")]
115    pub icon_sizes: IconSizes,
116
117    // ---- Font DPI ----
118    /// Font DPI for pt-to-px conversion. When `Some(dpi)`, font sizes
119    /// in this variant are in typographic points and will be converted
120    /// during resolution: `px = pt * font_dpi / 72`. When `None`,
121    /// [`into_resolved()`](crate::ThemeVariant::into_resolved) auto-detects
122    /// the DPI from the OS.
123    ///
124    /// This is a **runtime** value — not stored in TOML presets. OS readers
125    /// auto-detect it from system settings. Applications can override it
126    /// via the Rust API (e.g. when a window moves between monitors with
127    /// different DPIs).
128    #[serde(skip)]
129    pub font_dpi: Option<f32>,
130
131    // ---- Accessibility ----
132    /// Text scaling factor (1.0 = no scaling).
133    pub text_scaling_factor: Option<f32>,
134    /// Whether the user has requested reduced motion.
135    pub reduce_motion: Option<bool>,
136    /// Whether a high-contrast mode is active.
137    pub high_contrast: Option<bool>,
138    /// Whether the user has requested reduced transparency.
139    pub reduce_transparency: Option<bool>,
140}
141
142impl ThemeDefaults {
143    /// All serialized field names for ThemeDefaults, for TOML linting.
144    pub const FIELD_NAMES: &[&str] = &[
145        "font",
146        "line_height",
147        "mono_font",
148        "background_color",
149        "text_color",
150        "accent_color",
151        "accent_text_color",
152        "surface_color",
153        "muted_color",
154        "shadow_color",
155        "link_color",
156        "selection_background",
157        "selection_text_color",
158        "selection_inactive_background",
159        "text_selection_background",
160        "text_selection_color",
161        "disabled_text_color",
162        "danger_color",
163        "danger_text_color",
164        "warning_color",
165        "warning_text_color",
166        "success_color",
167        "success_text_color",
168        "info_color",
169        "info_text_color",
170        "border",
171        "disabled_opacity",
172        "focus_ring_color",
173        "focus_ring_width_px",
174        "focus_ring_offset_px",
175        "icon_sizes",
176        "text_scaling_factor",
177        "reduce_motion",
178        "high_contrast",
179        "reduce_transparency",
180    ];
181}
182
183impl_merge!(ThemeDefaults {
184    option {
185        line_height,
186        background_color, text_color, accent_color, accent_text_color,
187        surface_color, muted_color, shadow_color, link_color,
188        selection_background, selection_text_color,
189        selection_inactive_background,
190        text_selection_background, text_selection_color,
191        disabled_text_color,
192        danger_color, danger_text_color, warning_color, warning_text_color,
193        success_color, success_text_color, info_color, info_text_color,
194        disabled_opacity, focus_ring_color, focus_ring_width, focus_ring_offset,
195        font_dpi,
196        text_scaling_factor, reduce_motion, high_contrast, reduce_transparency
197    }
198    nested { font, mono_font, border, icon_sizes }
199});
200
201#[cfg(test)]
202#[allow(clippy::unwrap_used, clippy::expect_used)]
203mod tests {
204    use super::*;
205    use crate::Rgba;
206    use crate::model::border::BorderSpec;
207    use crate::model::font::FontSize;
208    use crate::model::{FontSpec, IconSizes};
209
210    // === default / is_empty ===
211
212    #[test]
213    fn default_has_all_none_options() {
214        let d = ThemeDefaults::default();
215        assert!(d.background_color.is_none());
216        assert!(d.text_color.is_none());
217        assert!(d.accent_color.is_none());
218        assert!(d.accent_text_color.is_none());
219        assert!(d.surface_color.is_none());
220        assert!(d.muted_color.is_none());
221        assert!(d.shadow_color.is_none());
222        assert!(d.link_color.is_none());
223        assert!(d.selection_background.is_none());
224        assert!(d.selection_text_color.is_none());
225        assert!(d.selection_inactive_background.is_none());
226        assert!(d.text_selection_background.is_none());
227        assert!(d.text_selection_color.is_none());
228        assert!(d.disabled_text_color.is_none());
229        assert!(d.danger_color.is_none());
230        assert!(d.danger_text_color.is_none());
231        assert!(d.warning_color.is_none());
232        assert!(d.warning_text_color.is_none());
233        assert!(d.success_color.is_none());
234        assert!(d.success_text_color.is_none());
235        assert!(d.info_color.is_none());
236        assert!(d.info_text_color.is_none());
237        assert!(d.disabled_opacity.is_none());
238        assert!(d.focus_ring_color.is_none());
239        assert!(d.focus_ring_width.is_none());
240        assert!(d.focus_ring_offset.is_none());
241        assert!(d.text_scaling_factor.is_none());
242        assert!(d.reduce_motion.is_none());
243        assert!(d.high_contrast.is_none());
244        assert!(d.reduce_transparency.is_none());
245        assert!(d.line_height.is_none());
246        assert!(d.font_dpi.is_none());
247    }
248
249    #[test]
250    fn default_nested_structs_are_all_empty() {
251        let d = ThemeDefaults::default();
252        assert!(d.font.is_empty());
253        assert!(d.mono_font.is_empty());
254        assert!(d.border.is_empty());
255        assert!(d.icon_sizes.is_empty());
256    }
257
258    #[test]
259    fn default_is_empty() {
260        assert!(ThemeDefaults::default().is_empty());
261    }
262
263    #[test]
264    fn not_empty_when_accent_color_set() {
265        let d = ThemeDefaults {
266            accent_color: Some(Rgba::rgb(0, 120, 215)),
267            ..Default::default()
268        };
269        assert!(!d.is_empty());
270    }
271
272    #[test]
273    fn not_empty_when_font_family_set() {
274        let d = ThemeDefaults {
275            font: FontSpec {
276                family: Some("Inter".into()),
277                ..Default::default()
278            },
279            ..Default::default()
280        };
281        assert!(!d.is_empty());
282    }
283
284    #[test]
285    fn not_empty_when_border_set() {
286        let d = ThemeDefaults {
287            border: BorderSpec {
288                corner_radius: Some(4.0),
289                ..Default::default()
290            },
291            ..Default::default()
292        };
293        assert!(!d.is_empty());
294    }
295
296    // === font and mono_font are plain FontSpec (not Option) ===
297
298    #[test]
299    fn font_is_plain_fontspec_not_option() {
300        let d = ThemeDefaults::default();
301        // If this compiles, font is FontSpec (not Option<FontSpec>)
302        let _ = d.font.family;
303        let _ = d.font.size;
304        let _ = d.font.weight;
305    }
306
307    #[test]
308    fn mono_font_is_plain_fontspec_not_option() {
309        let d = ThemeDefaults::default();
310        let _ = d.mono_font.family;
311    }
312
313    // === merge ===
314
315    #[test]
316    fn merge_option_overlay_wins() {
317        let mut base = ThemeDefaults {
318            accent_color: Some(Rgba::rgb(100, 100, 100)),
319            ..Default::default()
320        };
321        let overlay = ThemeDefaults {
322            accent_color: Some(Rgba::rgb(0, 120, 215)),
323            ..Default::default()
324        };
325        base.merge(&overlay);
326        assert_eq!(base.accent_color, Some(Rgba::rgb(0, 120, 215)));
327    }
328
329    #[test]
330    fn merge_none_preserves_base() {
331        let mut base = ThemeDefaults {
332            accent_color: Some(Rgba::rgb(0, 120, 215)),
333            ..Default::default()
334        };
335        let overlay = ThemeDefaults::default();
336        base.merge(&overlay);
337        assert_eq!(base.accent_color, Some(Rgba::rgb(0, 120, 215)));
338    }
339
340    #[test]
341    fn merge_font_family_preserved_when_overlay_family_none() {
342        let mut base = ThemeDefaults {
343            font: FontSpec {
344                family: Some("Noto Sans".into()),
345                size: Some(FontSize::Px(11.0)),
346                weight: None,
347                ..Default::default()
348            },
349            ..Default::default()
350        };
351        let overlay = ThemeDefaults {
352            font: FontSpec {
353                family: None,
354                size: None,
355                weight: Some(700),
356                ..Default::default()
357            },
358            ..Default::default()
359        };
360        base.merge(&overlay);
361        assert_eq!(base.font.family.as_deref(), Some("Noto Sans")); // preserved
362        assert_eq!(base.font.size, Some(FontSize::Px(11.0))); // preserved
363        assert_eq!(base.font.weight, Some(700)); // overlay wins
364    }
365
366    #[test]
367    fn merge_border_nested_merges_recursively() {
368        let mut base = ThemeDefaults {
369            border: BorderSpec {
370                corner_radius: Some(4.0),
371                ..Default::default()
372            },
373            ..Default::default()
374        };
375        let overlay = ThemeDefaults {
376            border: BorderSpec {
377                line_width: Some(1.0),
378                ..Default::default()
379            },
380            ..Default::default()
381        };
382        base.merge(&overlay);
383        assert_eq!(base.border.corner_radius, Some(4.0)); // preserved
384        assert_eq!(base.border.line_width, Some(1.0)); // overlay wins
385    }
386
387    #[test]
388    fn merge_icon_sizes_nested_merges_recursively() {
389        let mut base = ThemeDefaults {
390            icon_sizes: IconSizes {
391                toolbar: Some(22.0),
392                ..Default::default()
393            },
394            ..Default::default()
395        };
396        let overlay = ThemeDefaults {
397            icon_sizes: IconSizes {
398                small: Some(16.0),
399                ..Default::default()
400            },
401            ..Default::default()
402        };
403        base.merge(&overlay);
404        assert_eq!(base.icon_sizes.toolbar, Some(22.0)); // preserved
405        assert_eq!(base.icon_sizes.small, Some(16.0)); // overlay wins
406    }
407
408    // === TOML round-trip ===
409
410    #[test]
411    fn toml_round_trip_accent_color_and_font_family() {
412        let d = ThemeDefaults {
413            accent_color: Some(Rgba::rgb(0, 120, 215)),
414            font: FontSpec {
415                family: Some("Inter".into()),
416                ..Default::default()
417            },
418            ..Default::default()
419        };
420        let toml_str = toml::to_string(&d).unwrap();
421        // Font section should appear
422        assert!(
423            toml_str.contains("[font]"),
424            "Expected [font] section, got: {toml_str}"
425        );
426        // accent_color should appear as hex
427        assert!(
428            toml_str.contains("accent_color"),
429            "Expected accent_color field, got: {toml_str}"
430        );
431        // Round-trip
432        let d2: ThemeDefaults = toml::from_str(&toml_str).unwrap();
433        assert_eq!(d, d2);
434    }
435
436    #[test]
437    fn toml_empty_sections_suppressed() {
438        // An all-default ThemeDefaults should produce minimal or empty TOML
439        let d = ThemeDefaults::default();
440        let toml_str = toml::to_string(&d).unwrap();
441        // No sub-tables should appear for empty nested structs
442        assert!(
443            !toml_str.contains("[font]"),
444            "Empty font should be suppressed: {toml_str}"
445        );
446        assert!(
447            !toml_str.contains("[mono_font]"),
448            "Empty mono_font should be suppressed: {toml_str}"
449        );
450        assert!(
451            !toml_str.contains("[border]"),
452            "Empty border should be suppressed: {toml_str}"
453        );
454        assert!(
455            !toml_str.contains("[icon_sizes]"),
456            "Empty icon_sizes should be suppressed: {toml_str}"
457        );
458    }
459
460    #[test]
461    fn toml_mono_font_sub_table() {
462        let d = ThemeDefaults {
463            mono_font: FontSpec {
464                family: Some("JetBrains Mono".into()),
465                size: Some(FontSize::Px(12.0)),
466                ..Default::default()
467            },
468            ..Default::default()
469        };
470        let toml_str = toml::to_string(&d).unwrap();
471        assert!(
472            toml_str.contains("[mono_font]"),
473            "Expected [mono_font] section, got: {toml_str}"
474        );
475        let d2: ThemeDefaults = toml::from_str(&toml_str).unwrap();
476        assert_eq!(d, d2);
477    }
478
479    #[test]
480    fn toml_border_sub_table() {
481        let d = ThemeDefaults {
482            border: BorderSpec {
483                corner_radius: Some(4.0),
484                line_width: Some(1.0),
485                ..Default::default()
486            },
487            ..Default::default()
488        };
489        let toml_str = toml::to_string(&d).unwrap();
490        assert!(
491            toml_str.contains("[border]"),
492            "Expected [border] section, got: {toml_str}"
493        );
494        let d2: ThemeDefaults = toml::from_str(&toml_str).unwrap();
495        assert_eq!(d, d2);
496    }
497
498    #[test]
499    fn accessibility_fields_round_trip() {
500        let d = ThemeDefaults {
501            text_scaling_factor: Some(1.25),
502            reduce_motion: Some(true),
503            high_contrast: Some(false),
504            reduce_transparency: Some(true),
505            ..Default::default()
506        };
507        let toml_str = toml::to_string(&d).unwrap();
508        let d2: ThemeDefaults = toml::from_str(&toml_str).unwrap();
509        assert_eq!(d, d2);
510    }
511}