Skip to main content

native_theme/model/
defaults.rs

1// ThemeDefaults: global properties shared across widgets
2
3use crate::Rgba;
4use crate::model::spacing::ThemeSpacing;
5use crate::model::{FontSpec, IconSizes};
6use serde::{Deserialize, Serialize};
7
8/// Global theme defaults shared across all widgets.
9///
10/// All `Option<T>` fields default to `None`. Plain nested structs
11/// (`font`, `mono_font`, `spacing`, `icon_sizes`) default to their
12/// all-None state and are suppressed in serialization when empty.
13///
14/// When resolving a widget's properties, `None` on the widget struct
15/// means "inherit from `ThemeDefaults`".
16#[serde_with::skip_serializing_none]
17#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
18#[serde(default)]
19#[non_exhaustive]
20pub struct ThemeDefaults {
21    // ---- Base font ----
22    /// Primary UI font (family, size, weight).
23    #[serde(default, skip_serializing_if = "FontSpec::is_empty")]
24    pub font: FontSpec,
25
26    /// Line height multiplier (e.g. 1.4 = 140% of font size).
27    pub line_height: Option<f32>,
28
29    /// Monospace font for code/terminal content.
30    #[serde(default, skip_serializing_if = "FontSpec::is_empty")]
31    pub mono_font: FontSpec,
32
33    // ---- Base colors ----
34    /// Main window/surface background color.
35    pub background: Option<Rgba>,
36    /// Default text color.
37    pub foreground: Option<Rgba>,
38    /// Accent/brand color for interactive elements.
39    pub accent: Option<Rgba>,
40    /// Text color used on accent-colored backgrounds.
41    pub accent_foreground: Option<Rgba>,
42    /// Elevated surface color (cards, dialogs, popovers).
43    pub surface: Option<Rgba>,
44    /// Border/divider color.
45    pub border: Option<Rgba>,
46    /// Secondary/subdued text color.
47    pub muted: Option<Rgba>,
48    /// Drop shadow color (with alpha).
49    pub shadow: Option<Rgba>,
50    /// Hyperlink text color.
51    pub link: Option<Rgba>,
52    /// Selection highlight background.
53    pub selection: Option<Rgba>,
54    /// Text color over selection highlight.
55    pub selection_foreground: Option<Rgba>,
56    /// Selection background when window is unfocused.
57    pub selection_inactive: Option<Rgba>,
58    /// Text color for disabled controls.
59    pub disabled_foreground: Option<Rgba>,
60
61    // ---- Status colors ----
62    /// Danger/error color.
63    pub danger: Option<Rgba>,
64    /// Text color on danger-colored backgrounds.
65    pub danger_foreground: Option<Rgba>,
66    /// Warning color.
67    pub warning: Option<Rgba>,
68    /// Text color on warning-colored backgrounds.
69    pub warning_foreground: Option<Rgba>,
70    /// Success/confirmation color.
71    pub success: Option<Rgba>,
72    /// Text color on success-colored backgrounds.
73    pub success_foreground: Option<Rgba>,
74    /// Informational color.
75    pub info: Option<Rgba>,
76    /// Text color on info-colored backgrounds.
77    pub info_foreground: Option<Rgba>,
78
79    // ---- Global geometry ----
80    /// Default corner radius in logical pixels.
81    pub radius: Option<f32>,
82    /// Large corner radius (dialogs, floating panels).
83    pub radius_lg: Option<f32>,
84    /// Border/frame width in logical pixels.
85    pub frame_width: Option<f32>,
86    /// Opacity for disabled controls (0.0–1.0).
87    pub disabled_opacity: Option<f32>,
88    /// Border alpha multiplier (0.0–1.0).
89    pub border_opacity: Option<f32>,
90    /// Whether drop shadows are enabled.
91    pub shadow_enabled: Option<bool>,
92
93    // ---- Focus ring ----
94    /// Focus indicator outline color.
95    pub focus_ring_color: Option<Rgba>,
96    /// Focus indicator outline width.
97    pub focus_ring_width: Option<f32>,
98    /// Gap between element edge and focus indicator.
99    pub focus_ring_offset: Option<f32>,
100
101    // ---- Spacing scale ----
102    /// Logical spacing scale (xxs through xxl).
103    #[serde(default, skip_serializing_if = "ThemeSpacing::is_empty")]
104    pub spacing: ThemeSpacing,
105
106    // ---- Icon sizes ----
107    /// Per-context icon sizes.
108    #[serde(default, skip_serializing_if = "IconSizes::is_empty")]
109    pub icon_sizes: IconSizes,
110
111    // ---- Accessibility ----
112    /// Text scaling factor (1.0 = no scaling).
113    pub text_scaling_factor: Option<f32>,
114    /// Whether the user has requested reduced motion.
115    pub reduce_motion: Option<bool>,
116    /// Whether a high-contrast mode is active.
117    pub high_contrast: Option<bool>,
118    /// Whether the user has requested reduced transparency.
119    pub reduce_transparency: Option<bool>,
120}
121
122impl_merge!(ThemeDefaults {
123    option {
124        line_height,
125        background, foreground, accent, accent_foreground,
126        surface, border, muted, shadow, link, selection, selection_foreground,
127        selection_inactive, disabled_foreground,
128        danger, danger_foreground, warning, warning_foreground,
129        success, success_foreground, info, info_foreground,
130        radius, radius_lg, frame_width, disabled_opacity, border_opacity,
131        shadow_enabled, focus_ring_color, focus_ring_width, focus_ring_offset,
132        text_scaling_factor, reduce_motion, high_contrast, reduce_transparency
133    }
134    nested { font, mono_font, spacing, icon_sizes }
135});
136
137#[cfg(test)]
138#[allow(clippy::unwrap_used, clippy::expect_used)]
139mod tests {
140    use super::*;
141    use crate::Rgba;
142    use crate::model::spacing::ThemeSpacing;
143    use crate::model::{FontSpec, IconSizes};
144
145    // === default / is_empty ===
146
147    #[test]
148    fn default_has_all_none_options() {
149        let d = ThemeDefaults::default();
150        assert!(d.background.is_none());
151        assert!(d.foreground.is_none());
152        assert!(d.accent.is_none());
153        assert!(d.accent_foreground.is_none());
154        assert!(d.surface.is_none());
155        assert!(d.border.is_none());
156        assert!(d.muted.is_none());
157        assert!(d.shadow.is_none());
158        assert!(d.link.is_none());
159        assert!(d.selection.is_none());
160        assert!(d.selection_foreground.is_none());
161        assert!(d.selection_inactive.is_none());
162        assert!(d.disabled_foreground.is_none());
163        assert!(d.danger.is_none());
164        assert!(d.danger_foreground.is_none());
165        assert!(d.warning.is_none());
166        assert!(d.warning_foreground.is_none());
167        assert!(d.success.is_none());
168        assert!(d.success_foreground.is_none());
169        assert!(d.info.is_none());
170        assert!(d.info_foreground.is_none());
171        assert!(d.radius.is_none());
172        assert!(d.radius_lg.is_none());
173        assert!(d.frame_width.is_none());
174        assert!(d.disabled_opacity.is_none());
175        assert!(d.border_opacity.is_none());
176        assert!(d.shadow_enabled.is_none());
177        assert!(d.focus_ring_color.is_none());
178        assert!(d.focus_ring_width.is_none());
179        assert!(d.focus_ring_offset.is_none());
180        assert!(d.text_scaling_factor.is_none());
181        assert!(d.reduce_motion.is_none());
182        assert!(d.high_contrast.is_none());
183        assert!(d.reduce_transparency.is_none());
184        assert!(d.line_height.is_none());
185    }
186
187    #[test]
188    fn default_nested_structs_are_all_empty() {
189        let d = ThemeDefaults::default();
190        assert!(d.font.is_empty());
191        assert!(d.mono_font.is_empty());
192        assert!(d.spacing.is_empty());
193        assert!(d.icon_sizes.is_empty());
194    }
195
196    #[test]
197    fn default_is_empty() {
198        assert!(ThemeDefaults::default().is_empty());
199    }
200
201    #[test]
202    fn not_empty_when_accent_set() {
203        let d = ThemeDefaults {
204            accent: Some(Rgba::rgb(0, 120, 215)),
205            ..Default::default()
206        };
207        assert!(!d.is_empty());
208    }
209
210    #[test]
211    fn not_empty_when_font_family_set() {
212        let d = ThemeDefaults {
213            font: FontSpec {
214                family: Some("Inter".into()),
215                ..Default::default()
216            },
217            ..Default::default()
218        };
219        assert!(!d.is_empty());
220    }
221
222    #[test]
223    fn not_empty_when_spacing_set() {
224        let d = ThemeDefaults {
225            spacing: ThemeSpacing {
226                m: Some(12.0),
227                ..Default::default()
228            },
229            ..Default::default()
230        };
231        assert!(!d.is_empty());
232    }
233
234    // === font and mono_font are plain FontSpec (not Option) ===
235
236    #[test]
237    fn font_is_plain_fontspec_not_option() {
238        let d = ThemeDefaults::default();
239        // If this compiles, font is FontSpec (not Option<FontSpec>)
240        let _ = d.font.family;
241        let _ = d.font.size;
242        let _ = d.font.weight;
243    }
244
245    #[test]
246    fn mono_font_is_plain_fontspec_not_option() {
247        let d = ThemeDefaults::default();
248        let _ = d.mono_font.family;
249    }
250
251    // === merge ===
252
253    #[test]
254    fn merge_option_overlay_wins() {
255        let mut base = ThemeDefaults {
256            accent: Some(Rgba::rgb(100, 100, 100)),
257            ..Default::default()
258        };
259        let overlay = ThemeDefaults {
260            accent: Some(Rgba::rgb(0, 120, 215)),
261            ..Default::default()
262        };
263        base.merge(&overlay);
264        assert_eq!(base.accent, Some(Rgba::rgb(0, 120, 215)));
265    }
266
267    #[test]
268    fn merge_none_preserves_base() {
269        let mut base = ThemeDefaults {
270            accent: Some(Rgba::rgb(0, 120, 215)),
271            ..Default::default()
272        };
273        let overlay = ThemeDefaults::default();
274        base.merge(&overlay);
275        assert_eq!(base.accent, Some(Rgba::rgb(0, 120, 215)));
276    }
277
278    #[test]
279    fn merge_font_family_preserved_when_overlay_family_none() {
280        let mut base = ThemeDefaults {
281            font: FontSpec {
282                family: Some("Noto Sans".into()),
283                size: Some(11.0),
284                weight: None,
285            },
286            ..Default::default()
287        };
288        let overlay = ThemeDefaults {
289            font: FontSpec {
290                family: None,
291                size: None,
292                weight: Some(700),
293            },
294            ..Default::default()
295        };
296        base.merge(&overlay);
297        assert_eq!(base.font.family.as_deref(), Some("Noto Sans")); // preserved
298        assert_eq!(base.font.size, Some(11.0)); // preserved
299        assert_eq!(base.font.weight, Some(700)); // overlay wins
300    }
301
302    #[test]
303    fn merge_spacing_nested_merges_recursively() {
304        let mut base = ThemeDefaults {
305            spacing: ThemeSpacing {
306                m: Some(12.0),
307                ..Default::default()
308            },
309            ..Default::default()
310        };
311        let overlay = ThemeDefaults {
312            spacing: ThemeSpacing {
313                s: Some(6.0),
314                ..Default::default()
315            },
316            ..Default::default()
317        };
318        base.merge(&overlay);
319        assert_eq!(base.spacing.m, Some(12.0)); // preserved
320        assert_eq!(base.spacing.s, Some(6.0)); // overlay wins
321    }
322
323    #[test]
324    fn merge_icon_sizes_nested_merges_recursively() {
325        let mut base = ThemeDefaults {
326            icon_sizes: IconSizes {
327                toolbar: Some(22.0),
328                ..Default::default()
329            },
330            ..Default::default()
331        };
332        let overlay = ThemeDefaults {
333            icon_sizes: IconSizes {
334                small: Some(16.0),
335                ..Default::default()
336            },
337            ..Default::default()
338        };
339        base.merge(&overlay);
340        assert_eq!(base.icon_sizes.toolbar, Some(22.0)); // preserved
341        assert_eq!(base.icon_sizes.small, Some(16.0)); // overlay wins
342    }
343
344    // === TOML round-trip ===
345
346    #[test]
347    fn toml_round_trip_accent_and_font_family() {
348        let d = ThemeDefaults {
349            accent: Some(Rgba::rgb(0, 120, 215)),
350            font: FontSpec {
351                family: Some("Inter".into()),
352                ..Default::default()
353            },
354            ..Default::default()
355        };
356        let toml_str = toml::to_string(&d).unwrap();
357        // Font section should appear
358        assert!(
359            toml_str.contains("[font]"),
360            "Expected [font] section, got: {toml_str}"
361        );
362        // accent should appear as hex
363        assert!(
364            toml_str.contains("accent"),
365            "Expected accent field, got: {toml_str}"
366        );
367        // Round-trip
368        let d2: ThemeDefaults = toml::from_str(&toml_str).unwrap();
369        assert_eq!(d, d2);
370    }
371
372    #[test]
373    fn toml_empty_sections_suppressed() {
374        // An all-default ThemeDefaults should produce minimal or empty TOML
375        let d = ThemeDefaults::default();
376        let toml_str = toml::to_string(&d).unwrap();
377        // No sub-tables should appear for empty nested structs
378        assert!(
379            !toml_str.contains("[font]"),
380            "Empty font should be suppressed: {toml_str}"
381        );
382        assert!(
383            !toml_str.contains("[mono_font]"),
384            "Empty mono_font should be suppressed: {toml_str}"
385        );
386        assert!(
387            !toml_str.contains("[spacing]"),
388            "Empty spacing should be suppressed: {toml_str}"
389        );
390        assert!(
391            !toml_str.contains("[icon_sizes]"),
392            "Empty icon_sizes should be suppressed: {toml_str}"
393        );
394    }
395
396    #[test]
397    fn toml_mono_font_sub_table() {
398        let d = ThemeDefaults {
399            mono_font: FontSpec {
400                family: Some("JetBrains Mono".into()),
401                size: Some(12.0),
402                ..Default::default()
403            },
404            ..Default::default()
405        };
406        let toml_str = toml::to_string(&d).unwrap();
407        assert!(
408            toml_str.contains("[mono_font]"),
409            "Expected [mono_font] section, got: {toml_str}"
410        );
411        let d2: ThemeDefaults = toml::from_str(&toml_str).unwrap();
412        assert_eq!(d, d2);
413    }
414
415    #[test]
416    fn toml_spacing_sub_table() {
417        let d = ThemeDefaults {
418            spacing: ThemeSpacing {
419                m: Some(12.0),
420                l: Some(18.0),
421                ..Default::default()
422            },
423            ..Default::default()
424        };
425        let toml_str = toml::to_string(&d).unwrap();
426        assert!(
427            toml_str.contains("[spacing]"),
428            "Expected [spacing] section, got: {toml_str}"
429        );
430        let d2: ThemeDefaults = toml::from_str(&toml_str).unwrap();
431        assert_eq!(d, d2);
432    }
433
434    #[test]
435    fn accessibility_fields_round_trip() {
436        let d = ThemeDefaults {
437            text_scaling_factor: Some(1.25),
438            reduce_motion: Some(true),
439            high_contrast: Some(false),
440            reduce_transparency: Some(true),
441            ..Default::default()
442        };
443        let toml_str = toml::to_string(&d).unwrap();
444        let d2: ThemeDefaults = toml::from_str(&toml_str).unwrap();
445        assert_eq!(d, d2);
446    }
447}