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