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 ThemeDefaults {
137    /// All serialized field names for ThemeDefaults, for TOML linting.
138    pub const FIELD_NAMES: &[&str] = &[
139        "font",
140        "line_height",
141        "mono_font",
142        "background",
143        "foreground",
144        "accent",
145        "accent_foreground",
146        "surface",
147        "border",
148        "muted",
149        "shadow",
150        "link",
151        "selection",
152        "selection_foreground",
153        "selection_inactive",
154        "disabled_foreground",
155        "danger",
156        "danger_foreground",
157        "warning",
158        "warning_foreground",
159        "success",
160        "success_foreground",
161        "info",
162        "info_foreground",
163        "radius",
164        "radius_lg",
165        "frame_width",
166        "disabled_opacity",
167        "border_opacity",
168        "shadow_enabled",
169        "focus_ring_color",
170        "focus_ring_width",
171        "focus_ring_offset",
172        "spacing",
173        "icon_sizes",
174        "text_scaling_factor",
175        "reduce_motion",
176        "high_contrast",
177        "reduce_transparency",
178    ];
179}
180
181impl_merge!(ThemeDefaults {
182    option {
183        line_height,
184        background, foreground, accent, accent_foreground,
185        surface, border, muted, shadow, link, selection, selection_foreground,
186        selection_inactive, disabled_foreground,
187        danger, danger_foreground, warning, warning_foreground,
188        success, success_foreground, info, info_foreground,
189        radius, radius_lg, frame_width, disabled_opacity, border_opacity,
190        shadow_enabled, focus_ring_color, focus_ring_width, focus_ring_offset,
191        text_scaling_factor, reduce_motion, high_contrast, reduce_transparency
192    }
193    nested { font, mono_font, spacing, icon_sizes }
194});
195
196#[cfg(test)]
197#[allow(clippy::unwrap_used, clippy::expect_used)]
198mod tests {
199    use super::*;
200    use crate::Rgba;
201    use crate::model::spacing::ThemeSpacing;
202    use crate::model::{FontSpec, IconSizes};
203
204    // === default / is_empty ===
205
206    #[test]
207    fn default_has_all_none_options() {
208        let d = ThemeDefaults::default();
209        assert!(d.background.is_none());
210        assert!(d.foreground.is_none());
211        assert!(d.accent.is_none());
212        assert!(d.accent_foreground.is_none());
213        assert!(d.surface.is_none());
214        assert!(d.border.is_none());
215        assert!(d.muted.is_none());
216        assert!(d.shadow.is_none());
217        assert!(d.link.is_none());
218        assert!(d.selection.is_none());
219        assert!(d.selection_foreground.is_none());
220        assert!(d.selection_inactive.is_none());
221        assert!(d.disabled_foreground.is_none());
222        assert!(d.danger.is_none());
223        assert!(d.danger_foreground.is_none());
224        assert!(d.warning.is_none());
225        assert!(d.warning_foreground.is_none());
226        assert!(d.success.is_none());
227        assert!(d.success_foreground.is_none());
228        assert!(d.info.is_none());
229        assert!(d.info_foreground.is_none());
230        assert!(d.radius.is_none());
231        assert!(d.radius_lg.is_none());
232        assert!(d.frame_width.is_none());
233        assert!(d.disabled_opacity.is_none());
234        assert!(d.border_opacity.is_none());
235        assert!(d.shadow_enabled.is_none());
236        assert!(d.focus_ring_color.is_none());
237        assert!(d.focus_ring_width.is_none());
238        assert!(d.focus_ring_offset.is_none());
239        assert!(d.text_scaling_factor.is_none());
240        assert!(d.reduce_motion.is_none());
241        assert!(d.high_contrast.is_none());
242        assert!(d.reduce_transparency.is_none());
243        assert!(d.line_height.is_none());
244    }
245
246    #[test]
247    fn default_nested_structs_are_all_empty() {
248        let d = ThemeDefaults::default();
249        assert!(d.font.is_empty());
250        assert!(d.mono_font.is_empty());
251        assert!(d.spacing.is_empty());
252        assert!(d.icon_sizes.is_empty());
253    }
254
255    #[test]
256    fn default_is_empty() {
257        assert!(ThemeDefaults::default().is_empty());
258    }
259
260    #[test]
261    fn not_empty_when_accent_set() {
262        let d = ThemeDefaults {
263            accent: Some(Rgba::rgb(0, 120, 215)),
264            ..Default::default()
265        };
266        assert!(!d.is_empty());
267    }
268
269    #[test]
270    fn not_empty_when_font_family_set() {
271        let d = ThemeDefaults {
272            font: FontSpec {
273                family: Some("Inter".into()),
274                ..Default::default()
275            },
276            ..Default::default()
277        };
278        assert!(!d.is_empty());
279    }
280
281    #[test]
282    fn not_empty_when_spacing_set() {
283        let d = ThemeDefaults {
284            spacing: ThemeSpacing {
285                m: Some(12.0),
286                ..Default::default()
287            },
288            ..Default::default()
289        };
290        assert!(!d.is_empty());
291    }
292
293    // === font and mono_font are plain FontSpec (not Option) ===
294
295    #[test]
296    fn font_is_plain_fontspec_not_option() {
297        let d = ThemeDefaults::default();
298        // If this compiles, font is FontSpec (not Option<FontSpec>)
299        let _ = d.font.family;
300        let _ = d.font.size;
301        let _ = d.font.weight;
302    }
303
304    #[test]
305    fn mono_font_is_plain_fontspec_not_option() {
306        let d = ThemeDefaults::default();
307        let _ = d.mono_font.family;
308    }
309
310    // === merge ===
311
312    #[test]
313    fn merge_option_overlay_wins() {
314        let mut base = ThemeDefaults {
315            accent: Some(Rgba::rgb(100, 100, 100)),
316            ..Default::default()
317        };
318        let overlay = ThemeDefaults {
319            accent: Some(Rgba::rgb(0, 120, 215)),
320            ..Default::default()
321        };
322        base.merge(&overlay);
323        assert_eq!(base.accent, Some(Rgba::rgb(0, 120, 215)));
324    }
325
326    #[test]
327    fn merge_none_preserves_base() {
328        let mut base = ThemeDefaults {
329            accent: Some(Rgba::rgb(0, 120, 215)),
330            ..Default::default()
331        };
332        let overlay = ThemeDefaults::default();
333        base.merge(&overlay);
334        assert_eq!(base.accent, Some(Rgba::rgb(0, 120, 215)));
335    }
336
337    #[test]
338    fn merge_font_family_preserved_when_overlay_family_none() {
339        let mut base = ThemeDefaults {
340            font: FontSpec {
341                family: Some("Noto Sans".into()),
342                size: Some(11.0),
343                weight: None,
344            },
345            ..Default::default()
346        };
347        let overlay = ThemeDefaults {
348            font: FontSpec {
349                family: None,
350                size: None,
351                weight: Some(700),
352            },
353            ..Default::default()
354        };
355        base.merge(&overlay);
356        assert_eq!(base.font.family.as_deref(), Some("Noto Sans")); // preserved
357        assert_eq!(base.font.size, Some(11.0)); // preserved
358        assert_eq!(base.font.weight, Some(700)); // overlay wins
359    }
360
361    #[test]
362    fn merge_spacing_nested_merges_recursively() {
363        let mut base = ThemeDefaults {
364            spacing: ThemeSpacing {
365                m: Some(12.0),
366                ..Default::default()
367            },
368            ..Default::default()
369        };
370        let overlay = ThemeDefaults {
371            spacing: ThemeSpacing {
372                s: Some(6.0),
373                ..Default::default()
374            },
375            ..Default::default()
376        };
377        base.merge(&overlay);
378        assert_eq!(base.spacing.m, Some(12.0)); // preserved
379        assert_eq!(base.spacing.s, Some(6.0)); // overlay wins
380    }
381
382    #[test]
383    fn merge_icon_sizes_nested_merges_recursively() {
384        let mut base = ThemeDefaults {
385            icon_sizes: IconSizes {
386                toolbar: Some(22.0),
387                ..Default::default()
388            },
389            ..Default::default()
390        };
391        let overlay = ThemeDefaults {
392            icon_sizes: IconSizes {
393                small: Some(16.0),
394                ..Default::default()
395            },
396            ..Default::default()
397        };
398        base.merge(&overlay);
399        assert_eq!(base.icon_sizes.toolbar, Some(22.0)); // preserved
400        assert_eq!(base.icon_sizes.small, Some(16.0)); // overlay wins
401    }
402
403    // === TOML round-trip ===
404
405    #[test]
406    fn toml_round_trip_accent_and_font_family() {
407        let d = ThemeDefaults {
408            accent: Some(Rgba::rgb(0, 120, 215)),
409            font: FontSpec {
410                family: Some("Inter".into()),
411                ..Default::default()
412            },
413            ..Default::default()
414        };
415        let toml_str = toml::to_string(&d).unwrap();
416        // Font section should appear
417        assert!(
418            toml_str.contains("[font]"),
419            "Expected [font] section, got: {toml_str}"
420        );
421        // accent should appear as hex
422        assert!(
423            toml_str.contains("accent"),
424            "Expected accent field, got: {toml_str}"
425        );
426        // Round-trip
427        let d2: ThemeDefaults = toml::from_str(&toml_str).unwrap();
428        assert_eq!(d, d2);
429    }
430
431    #[test]
432    fn toml_empty_sections_suppressed() {
433        // An all-default ThemeDefaults should produce minimal or empty TOML
434        let d = ThemeDefaults::default();
435        let toml_str = toml::to_string(&d).unwrap();
436        // No sub-tables should appear for empty nested structs
437        assert!(
438            !toml_str.contains("[font]"),
439            "Empty font should be suppressed: {toml_str}"
440        );
441        assert!(
442            !toml_str.contains("[mono_font]"),
443            "Empty mono_font should be suppressed: {toml_str}"
444        );
445        assert!(
446            !toml_str.contains("[spacing]"),
447            "Empty spacing should be suppressed: {toml_str}"
448        );
449        assert!(
450            !toml_str.contains("[icon_sizes]"),
451            "Empty icon_sizes should be suppressed: {toml_str}"
452        );
453    }
454
455    #[test]
456    fn toml_mono_font_sub_table() {
457        let d = ThemeDefaults {
458            mono_font: FontSpec {
459                family: Some("JetBrains Mono".into()),
460                size: Some(12.0),
461                ..Default::default()
462            },
463            ..Default::default()
464        };
465        let toml_str = toml::to_string(&d).unwrap();
466        assert!(
467            toml_str.contains("[mono_font]"),
468            "Expected [mono_font] section, got: {toml_str}"
469        );
470        let d2: ThemeDefaults = toml::from_str(&toml_str).unwrap();
471        assert_eq!(d, d2);
472    }
473
474    #[test]
475    fn toml_spacing_sub_table() {
476        let d = ThemeDefaults {
477            spacing: ThemeSpacing {
478                m: Some(12.0),
479                l: Some(18.0),
480                ..Default::default()
481            },
482            ..Default::default()
483        };
484        let toml_str = toml::to_string(&d).unwrap();
485        assert!(
486            toml_str.contains("[spacing]"),
487            "Expected [spacing] section, got: {toml_str}"
488        );
489        let d2: ThemeDefaults = toml::from_str(&toml_str).unwrap();
490        assert_eq!(d, d2);
491    }
492
493    #[test]
494    fn accessibility_fields_round_trip() {
495        let d = ThemeDefaults {
496            text_scaling_factor: Some(1.25),
497            reduce_motion: Some(true),
498            high_contrast: Some(false),
499            reduce_transparency: Some(true),
500            ..Default::default()
501        };
502        let toml_str = toml::to_string(&d).unwrap();
503        let d2: ThemeDefaults = toml::from_str(&toml_str).unwrap();
504        assert_eq!(d, d2);
505    }
506}