Skip to main content

native_theme/model/
defaults.rs

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