Skip to main content

native_theme/
presets.rs

1//! Bundled theme presets and TOML serialization API.
2//!
3//! Provides 16 user-facing built-in presets embedded at compile time:
4//! 6 platform (kde-breeze, adwaita, windows-11, macos-sonoma, material,
5//! ios) and 10 community (Catppuccin 4 flavors, Nord, Dracula, Gruvbox,
6//! Solarized, Tokyo Night, One Dark), plus 4 internal live presets
7//! (no colours or font families: geometry, metrics and sizes, used by the
8//! OS-first pipeline) and functions for
9//! loading themes from TOML strings and files.
10
11use std::borrow::Cow;
12use std::collections::HashMap;
13use std::path::Path;
14use std::sync::LazyLock;
15
16use crate::{Error, Result, Theme};
17
18/// All preset entries (name + embedded TOML source), parsed once into a HashMap.
19///
20/// To add a new preset, add it here and (if user-facing) to [`PRESET_NAMES`].
21const PRESET_ENTRIES: &[(&str, &str)] = &[
22    // Platform presets
23    ("kde-breeze", include_str!("presets/kde-breeze.toml")),
24    ("adwaita", include_str!("presets/adwaita.toml")),
25    ("windows-11", include_str!("presets/windows-11.toml")),
26    ("macos-sonoma", include_str!("presets/macos-sonoma.toml")),
27    ("material", include_str!("presets/material.toml")),
28    ("ios", include_str!("presets/ios.toml")),
29    // Community presets
30    (
31        "catppuccin-latte",
32        include_str!("presets/catppuccin-latte.toml"),
33    ),
34    (
35        "catppuccin-frappe",
36        include_str!("presets/catppuccin-frappe.toml"),
37    ),
38    (
39        "catppuccin-macchiato",
40        include_str!("presets/catppuccin-macchiato.toml"),
41    ),
42    (
43        "catppuccin-mocha",
44        include_str!("presets/catppuccin-mocha.toml"),
45    ),
46    ("nord", include_str!("presets/nord.toml")),
47    ("dracula", include_str!("presets/dracula.toml")),
48    ("gruvbox", include_str!("presets/gruvbox.toml")),
49    ("solarized", include_str!("presets/solarized.toml")),
50    ("tokyo-night", include_str!("presets/tokyo-night.toml")),
51    ("one-dark", include_str!("presets/one-dark.toml")),
52    // Internal live presets (geometry-only, not user-selectable)
53    (
54        "kde-breeze-live",
55        include_str!("presets/kde-breeze-live.toml"),
56    ),
57    ("adwaita-live", include_str!("presets/adwaita-live.toml")),
58    (
59        "macos-sonoma-live",
60        include_str!("presets/macos-sonoma-live.toml"),
61    ),
62    (
63        "windows-11-live",
64        include_str!("presets/windows-11-live.toml"),
65    ),
66];
67
68/// All available user-facing preset names (excludes internal live presets).
69const PRESET_NAMES: &[&str] = &[
70    "kde-breeze",
71    "adwaita",
72    "windows-11",
73    "macos-sonoma",
74    "material",
75    "ios",
76    "catppuccin-latte",
77    "catppuccin-frappe",
78    "catppuccin-macchiato",
79    "catppuccin-mocha",
80    "nord",
81    "dracula",
82    "gruvbox",
83    "solarized",
84    "tokyo-night",
85    "one-dark",
86];
87
88/// Known display names for bundled presets (borrowed from static strings).
89///
90/// After TOML deserialization the `Theme.name` field is `Cow::Owned` because
91/// TOML parsing cannot borrow into static memory. This map lets the cache
92/// replace the owned name with a `Cow::Borrowed` reference, avoiding a
93/// per-load `String` allocation for bundled preset names.
94const PRESET_DISPLAY_NAMES: &[(&str, &str)] = &[
95    ("kde-breeze", "KDE Breeze"),
96    ("kde-breeze-live", "KDE Breeze"),
97    ("adwaita", "Adwaita"),
98    ("adwaita-live", "Adwaita"),
99    ("windows-11", "Windows 11"),
100    ("windows-11-live", "Windows 11"),
101    ("macos-sonoma", "macOS Sonoma"),
102    ("macos-sonoma-live", "macOS Sonoma"),
103    ("material", "Material"),
104    ("ios", "iOS"),
105    ("catppuccin-latte", "Catppuccin Latte"),
106    ("catppuccin-frappe", "Catppuccin Frappe"),
107    ("catppuccin-macchiato", "Catppuccin Macchiato"),
108    ("catppuccin-mocha", "Catppuccin Mocha"),
109    ("nord", "Nord"),
110    ("dracula", "Dracula"),
111    ("gruvbox", "Gruvbox"),
112    ("solarized", "Solarized"),
113    ("tokyo-night", "Tokyo Night"),
114    ("one-dark", "One Dark"),
115];
116
117/// Structured information about a bundled preset.
118///
119/// Returned by [`Theme::list_presets()`] and [`Theme::list_presets_for_platform()`]
120/// to provide richer metadata than bare preset names.
121#[derive(Clone, Debug, PartialEq, Eq)]
122pub struct PresetInfo {
123    /// Machine-readable preset key (e.g. `"catppuccin-mocha"`, `"kde-breeze"`).
124    /// Pass this to [`Theme::preset()`] to load the preset.
125    pub key: &'static str,
126    /// Human-readable display name (e.g. `"Catppuccin Mocha"`, `"KDE Breeze"`).
127    pub display_name: &'static str,
128    /// Target platform tags (e.g. `&["linux-kde"]`). Empty for presets offered
129    /// on every platform (`material` and the community themes).
130    pub platforms: &'static [&'static str],
131    /// Whether the preset provides only a light variant (no dark variant).
132    pub light_only: bool,
133}
134
135/// Structured metadata for all 16 user-facing presets.
136///
137/// Combines data from [`PRESET_NAMES`], [`PRESET_DISPLAY_NAMES`], and
138/// [`PLATFORM_SPECIFIC`] into a single array for richer preset enumeration.
139const PRESET_INFOS: &[PresetInfo] = &[
140    // Platform presets
141    PresetInfo {
142        key: "kde-breeze",
143        display_name: "KDE Breeze",
144        platforms: &["linux-kde"],
145        light_only: false,
146    },
147    PresetInfo {
148        key: "adwaita",
149        display_name: "Adwaita",
150        platforms: &["linux"],
151        light_only: false,
152    },
153    PresetInfo {
154        key: "windows-11",
155        display_name: "Windows 11",
156        platforms: &["windows"],
157        light_only: false,
158    },
159    PresetInfo {
160        key: "macos-sonoma",
161        display_name: "macOS Sonoma",
162        platforms: &["macos"],
163        light_only: false,
164    },
165    PresetInfo {
166        key: "material",
167        display_name: "Material",
168        platforms: &[],
169        light_only: false,
170    },
171    PresetInfo {
172        key: "ios",
173        display_name: "iOS",
174        platforms: &["macos", "ios"],
175        light_only: false,
176    },
177    // Community presets
178    PresetInfo {
179        key: "catppuccin-latte",
180        display_name: "Catppuccin Latte",
181        platforms: &[],
182        light_only: false,
183    },
184    PresetInfo {
185        key: "catppuccin-frappe",
186        display_name: "Catppuccin Frappe",
187        platforms: &[],
188        light_only: false,
189    },
190    PresetInfo {
191        key: "catppuccin-macchiato",
192        display_name: "Catppuccin Macchiato",
193        platforms: &[],
194        light_only: false,
195    },
196    PresetInfo {
197        key: "catppuccin-mocha",
198        display_name: "Catppuccin Mocha",
199        platforms: &[],
200        light_only: false,
201    },
202    PresetInfo {
203        key: "nord",
204        display_name: "Nord",
205        platforms: &[],
206        light_only: false,
207    },
208    PresetInfo {
209        key: "dracula",
210        display_name: "Dracula",
211        platforms: &[],
212        light_only: false,
213    },
214    PresetInfo {
215        key: "gruvbox",
216        display_name: "Gruvbox",
217        platforms: &[],
218        light_only: false,
219    },
220    PresetInfo {
221        key: "solarized",
222        display_name: "Solarized",
223        platforms: &[],
224        light_only: false,
225    },
226    PresetInfo {
227        key: "tokyo-night",
228        display_name: "Tokyo Night",
229        platforms: &[],
230        light_only: false,
231    },
232    PresetInfo {
233        key: "one-dark",
234        display_name: "One Dark",
235        platforms: &[],
236        light_only: false,
237    },
238];
239
240/// Cached parse result. Uses `String` for the error type (not `Error`) because:
241/// - `Error` does not implement `Clone`, which is needed for cache retrieval
242/// - Preset parse errors are rare (bundled TOML is validated at dev time)
243/// - The string-erased error message is sufficient for user diagnostics
244type Parsed = std::result::Result<Theme, String>;
245
246fn parse(toml_str: &str) -> Parsed {
247    from_toml(toml_str).map_err(|e| e.to_string())
248}
249
250static CACHE: LazyLock<HashMap<&str, Parsed>> = LazyLock::new(|| {
251    PRESET_ENTRIES
252        .iter()
253        .map(|(key, toml_str)| {
254            let mut result = parse(toml_str);
255            // Replace the TOML-deserialized owned name with a borrowed static string
256            if let Ok(ref mut theme) = result
257                && let Some((_, display_name)) = PRESET_DISPLAY_NAMES.iter().find(|(k, _)| k == key)
258            {
259                theme.name = Cow::Borrowed(display_name);
260            }
261            (*key, result)
262        })
263        .collect()
264});
265
266pub(crate) fn preset(name: &str) -> Result<Theme> {
267    match CACHE.get(name) {
268        None => Err(Error::UnknownPreset {
269            name: name.to_string(),
270            known: PRESET_NAMES,
271        }),
272        Some(Ok(theme)) => Ok(theme.clone()),
273        Some(Err(msg)) => Err(Error::ReaderFailed {
274            reader: "preset_cache",
275            source: format!("bundled preset '{name}': {msg}").into(),
276        }),
277    }
278}
279
280pub(crate) fn list_presets() -> &'static [PresetInfo] {
281    PRESET_INFOS
282}
283
284/// Detect the current platform tag for preset filtering.
285///
286/// Returns a string like "linux-kde", "linux", "windows", or "macos".
287#[allow(unreachable_code)]
288fn detect_platform() -> &'static str {
289    #[cfg(target_os = "macos")]
290    {
291        return "macos";
292    }
293    #[cfg(target_os = "windows")]
294    {
295        return "windows";
296    }
297    #[cfg(target_os = "linux")]
298    {
299        if crate::detect::detect_linux_desktop() == crate::detect::LinuxDesktop::Kde {
300            return "linux-kde";
301        }
302        "linux"
303    }
304    #[cfg(target_os = "ios")]
305    {
306        return "ios";
307    }
308    #[cfg(not(any(
309        target_os = "linux",
310        target_os = "windows",
311        target_os = "macos",
312        target_os = "ios"
313    )))]
314    {
315        "linux"
316    }
317}
318
319/// Returns preset info appropriate for the current platform.
320///
321/// Platform-specific presets (kde-breeze, adwaita, windows-11, macos-sonoma, ios)
322/// are only included on their native platform. Community themes are always included.
323pub(crate) fn list_presets_for_platform() -> Vec<PresetInfo> {
324    let platform = detect_platform();
325
326    PRESET_INFOS
327        .iter()
328        .filter(|info| {
329            info.platforms.is_empty() || info.platforms.iter().any(|p| platform.starts_with(p))
330        })
331        .cloned()
332        .collect()
333}
334
335pub(crate) fn from_toml(toml_str: &str) -> Result<Theme> {
336    let theme: Theme = toml::from_str(toml_str)?;
337    Ok(theme)
338}
339
340pub(crate) fn from_file(path: impl AsRef<Path>) -> Result<Theme> {
341    let contents = std::fs::read_to_string(path)?;
342    from_toml(&contents)
343}
344
345pub(crate) fn to_toml(theme: &Theme) -> Result<String> {
346    let s = toml::to_string_pretty(theme)?;
347    Ok(s)
348}
349
350#[cfg(test)]
351mod documented_sizes;
352
353#[cfg(test)]
354#[allow(clippy::unwrap_used, clippy::expect_used)]
355mod tests {
356    use super::*;
357
358    // NOTE: all_presets_loadable_via_preset_fn and all_presets_have_nonempty_core_colors
359    // are covered by tests/preset_loading.rs (all_presets_parse_without_error,
360    // all_presets_have_both_variants, all_presets_have_core_colors).
361
362    /// Every bundled preset's own source lints clean, shorthand keys and
363    /// all. `lint_toml_all_presets_clean` lints `to_toml()` output, which
364    /// writes padding sides only, so it cannot see a shorthand key.
365    #[test]
366    fn every_bundled_preset_source_lints_clean() {
367        for (name, source) in PRESET_ENTRIES {
368            let warnings = crate::Theme::lint_toml(source)
369                .unwrap_or_else(|e| panic!("preset {name} should lint: {e}"));
370            assert!(
371                warnings.is_empty(),
372                "preset {name}'s source has lint warnings: {warnings:?}"
373            );
374        }
375        assert!(
376            PRESET_ENTRIES
377                .iter()
378                .any(|(_, s)| s.contains("padding_horizontal_px")),
379            "no bundled preset uses the shorthand any more, so this test no \
380             longer proves the linter accepts it"
381        );
382    }
383
384    #[test]
385    fn preset_unknown_name_returns_unknown_preset() {
386        let err = preset("nonexistent").unwrap_err();
387        let Error::UnknownPreset { name, .. } = err else {
388            return;
389        };
390        assert!(name.contains("nonexistent"));
391    }
392
393    // NOTE: list_presets_returns_all_sixteen is covered by
394    // tests/preset_loading.rs::list_presets_returns_sixteen_entries.
395
396    #[test]
397    fn from_toml_minimal_valid() {
398        let toml_str = r##"
399name = "Minimal"
400
401[light.defaults]
402accent_color = "#ff0000"
403"##;
404        let theme = from_toml(toml_str).unwrap();
405        assert_eq!(theme.name, "Minimal");
406        assert!(theme.light.is_some());
407        let light = theme.light.unwrap();
408        assert_eq!(
409            light.defaults.accent_color,
410            Some(crate::Rgba::rgb(255, 0, 0))
411        );
412    }
413
414    #[test]
415    fn from_toml_invalid_returns_toml_error() {
416        let err = from_toml("{{{{invalid toml").unwrap_err();
417        assert!(
418            matches!(err, Error::Toml(_)),
419            "expected Toml variant, got: {err:?}"
420        );
421    }
422
423    #[test]
424    fn to_toml_produces_valid_round_trip() {
425        let theme = preset("catppuccin-mocha").unwrap();
426        let toml_str = to_toml(&theme).unwrap();
427
428        // Must be parseable back into a Theme
429        let reparsed = from_toml(&toml_str).unwrap();
430        assert_eq!(reparsed.name, theme.name);
431        assert!(reparsed.light.is_some());
432        assert!(reparsed.dark.is_some());
433
434        // Core colors should survive the round-trip
435        let orig_light = theme.light.as_ref().unwrap();
436        let new_light = reparsed.light.as_ref().unwrap();
437        assert_eq!(
438            orig_light.defaults.accent_color,
439            new_light.defaults.accent_color
440        );
441    }
442
443    #[test]
444    fn from_file_with_tempfile() {
445        let dir = std::env::temp_dir();
446        let path = dir.join("native_theme_test_preset.toml");
447        let toml_str = r##"
448name = "File Test"
449
450[light.defaults]
451accent_color = "#00ff00"
452"##;
453        std::fs::write(&path, toml_str).unwrap();
454
455        let theme = from_file(&path).unwrap();
456        assert_eq!(theme.name, "File Test");
457        assert!(theme.light.is_some());
458
459        // Clean up
460        let _ = std::fs::remove_file(&path);
461    }
462
463    // === icon_set preset tests ===
464
465    #[test]
466    fn icon_set_native_presets_have_correct_values() {
467        use crate::IconSet;
468        let cases: &[(&str, IconSet)] = &[
469            ("windows-11", IconSet::SegoeIcons),
470            ("macos-sonoma", IconSet::SfSymbols),
471            ("ios", IconSet::SfSymbols),
472            ("adwaita", IconSet::Freedesktop),
473            ("kde-breeze", IconSet::Freedesktop),
474            ("material", IconSet::Material),
475        ];
476        for (name, expected) in cases {
477            let theme = preset(name).unwrap();
478            assert_eq!(
479                theme.icon_set,
480                Some(*expected),
481                "preset '{name}' icon_set should be Some({expected:?})"
482            );
483        }
484    }
485
486    #[test]
487    fn icon_set_community_presets_have_lucide() {
488        let community = &[
489            "catppuccin-latte",
490            "catppuccin-frappe",
491            "catppuccin-macchiato",
492            "catppuccin-mocha",
493            "nord",
494            "dracula",
495            "gruvbox",
496            "solarized",
497            "tokyo-night",
498            "one-dark",
499        ];
500        for name in community {
501            let theme = preset(name).unwrap();
502            assert_eq!(
503                theme.icon_set,
504                Some(crate::IconSet::Lucide),
505                "preset '{name}' icon_set should be Lucide"
506            );
507        }
508    }
509
510    #[test]
511    fn from_file_nonexistent_returns_error() {
512        let err = from_file("/tmp/nonexistent_theme_file_12345.toml").unwrap_err();
513        match err {
514            Error::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::NotFound),
515            other => panic!("expected Io, got: {other:?}"),
516        }
517    }
518
519    #[test]
520    fn preset_names_match_list() {
521        // Every name in list_presets() must be loadable via preset()
522        for info in list_presets() {
523            assert!(
524                preset(info.key).is_ok(),
525                "preset '{}' not loadable",
526                info.key
527            );
528        }
529    }
530
531    // NOTE: presets_have_correct_names is tested via tests/preset_loading.rs.
532    // NOTE: all_presets_with_fonts_have_valid_sizes is covered by
533    // tests/preset_loading.rs::all_presets_have_valid_fonts.
534
535    #[test]
536    fn platform_presets_no_derived_fields() {
537        // Platform presets must not contain fields that are derived by resolve()
538        let platform_presets = &["kde-breeze", "adwaita", "windows-11", "macos-sonoma"];
539        for name in platform_presets {
540            let theme = preset(name).unwrap();
541            for (label, variant_opt) in [
542                ("light", theme.light.as_ref()),
543                ("dark", theme.dark.as_ref()),
544            ] {
545                let variant = variant_opt.unwrap();
546                // button.primary_background is derived from accent - should not be in presets
547                assert!(
548                    variant.button.primary_background.is_none(),
549                    "preset '{name}' {label}.button.primary_background should be None (derived)"
550                );
551                // checkbox.checked_background is derived from accent, except on
552                // KDE, where Breeze tints the button colour instead
553                // (docs/platform-facts.md:1218, §2.5)
554                assert_eq!(
555                    variant.checkbox.checked_background.is_some(),
556                    *name == "kde-breeze",
557                    "preset '{name}' {label}.checkbox.checked_background: stated only where \
558                     platform-facts does not derive it from the accent"
559                );
560                // slider.fill is derived from accent
561                assert!(
562                    variant.slider.fill_color.is_none(),
563                    "preset '{name}' {label}.slider.fill_color should be None (derived)"
564                );
565                // progress_bar.fill is derived from accent, except on KDE,
566                // where Breeze lays the accent at 0.7 over the window
567                // (docs/platform-facts.md:1301, §2.10)
568                assert_eq!(
569                    variant.progress_bar.fill_color.is_some(),
570                    *name == "kde-breeze",
571                    "preset '{name}' {label}.progress_bar.fill_color: stated only where \
572                     platform-facts does not derive it from the accent"
573                );
574                // switch.checked_background is derived from accent
575                assert!(
576                    variant.switch.checked_background.is_none(),
577                    "preset '{name}' {label}.switch.checked_background should be None (derived)"
578                );
579            }
580        }
581    }
582
583    // === resolve()/validate() integration tests (PRESET-03) ===
584
585    #[test]
586    fn all_presets_resolve_validate() {
587        for info in list_presets() {
588            let name = info.key;
589            let theme = preset(name).unwrap();
590            if let Some(mut light) = theme.light.clone() {
591                light.resolve_all();
592                light.validate().unwrap_or_else(|e| {
593                    panic!("preset {name} light variant failed validation: {e}");
594                });
595            }
596            if let Some(mut dark) = theme.dark.clone() {
597                dark.resolve_all();
598                dark.validate().unwrap_or_else(|e| {
599                    panic!("preset {name} dark variant failed validation: {e}");
600                });
601            }
602        }
603    }
604
605    #[test]
606    fn all_presets_indicator_color_inherits_accent_text_color() {
607        // platform-facts.md §2.5 records the check mark / radio dot as the
608        // on-accent colour on all four platforms, and it is painted on
609        // checkbox.checked_background <- defaults.accent_color.
610        for info in list_presets() {
611            let name = info.key;
612            let theme = preset(name).unwrap();
613            for (label, variant_opt) in
614                [("light", theme.light.clone()), ("dark", theme.dark.clone())]
615            {
616                let Some(mut variant) = variant_opt else {
617                    continue;
618                };
619                if variant.checkbox.indicator_color.is_some() {
620                    continue;
621                }
622                variant.resolve_all();
623                assert_eq!(
624                    variant.checkbox.indicator_color, variant.defaults.accent_text_color,
625                    "preset '{name}' {label}.checkbox.indicator_color should inherit \
626                     defaults.accent_text_color"
627                );
628            }
629        }
630    }
631
632    #[test]
633    fn resolve_fills_accent_derived_fields() {
634        // Load a preset that only has accent set (not explicit widget accent-derived fields).
635        // After resolve(), the accent-derived fields should be populated.
636        let theme = preset("catppuccin-mocha").unwrap();
637        let mut light = theme.light.clone().unwrap();
638
639        // Before resolve: accent-derived fields should be None (not in preset TOML)
640        assert!(
641            light.button.primary_background.is_none(),
642            "primary_background should be None pre-resolve"
643        );
644        assert!(
645            light.checkbox.checked_background.is_none(),
646            "checkbox.checked_background should be None pre-resolve"
647        );
648        assert!(
649            light.slider.fill_color.is_none(),
650            "slider.fill should be None pre-resolve"
651        );
652        assert!(
653            light.progress_bar.fill_color.is_none(),
654            "progress_bar.fill should be None pre-resolve"
655        );
656        assert!(
657            light.switch.checked_background.is_none(),
658            "switch.checked_background should be None pre-resolve"
659        );
660
661        light.resolve();
662
663        // After resolve: all accent-derived fields should equal accent
664        let accent = light.defaults.accent_color.unwrap();
665        assert_eq!(
666            light.button.primary_background,
667            Some(accent),
668            "button.primary_background should match accent"
669        );
670        assert_eq!(
671            light.checkbox.checked_background,
672            Some(accent),
673            "checkbox.checked_background should match accent"
674        );
675        assert_eq!(
676            light.slider.fill_color,
677            Some(accent),
678            "slider.fill should match accent"
679        );
680        assert_eq!(
681            light.progress_bar.fill_color,
682            Some(accent),
683            "progress_bar.fill should match accent"
684        );
685        assert_eq!(
686            light.switch.checked_background,
687            Some(accent),
688            "switch.checked_background should match accent"
689        );
690    }
691
692    #[test]
693    fn resolve_then_validate_produces_complete_theme() {
694        let theme = preset("catppuccin-mocha").unwrap();
695        let mut light = theme.light.clone().unwrap();
696        light.resolve_all();
697        let resolved = light.validate().unwrap();
698
699        assert_eq!(resolved.defaults.font.family.as_ref(), "Inter");
700        assert_eq!(resolved.defaults.font.size, 14.0);
701        assert_eq!(resolved.defaults.font.weight, 400);
702        assert_eq!(resolved.defaults.line_height, 1.2);
703        assert_eq!(resolved.defaults.border.corner_radius, 8.0);
704        assert_eq!(resolved.defaults.focus_ring_width, 2.0);
705        assert_eq!(resolved.defaults.icon_sizes.toolbar, 24.0);
706        assert_eq!(resolved.defaults.icon_sizes.toolbar, 24.0);
707        // Window inherits from defaults
708        assert_eq!(
709            resolved.window.background_color,
710            resolved.defaults.background_color
711        );
712        // icon_set is now on Theme, not on ResolvedTheme
713        assert_eq!(theme.icon_set, Some(crate::IconSet::Lucide));
714    }
715
716    #[test]
717    fn font_subfield_inheritance_integration() {
718        // Load a preset, set menu.font to only have size=12.0 (clear family/weight),
719        // resolve, and verify family/weight are inherited from defaults.
720        let theme = preset("catppuccin-mocha").unwrap();
721        let mut light = theme.light.clone().unwrap();
722
723        // Set partial font on menu
724        use crate::model::FontSpec;
725        use crate::model::font::FontSize;
726        light.menu.font = Some(FontSpec {
727            family: None,
728            size: Some(FontSize::Px(12.0)),
729            weight: None,
730            ..Default::default()
731        });
732
733        light.resolve_all();
734        let resolved = light.validate().unwrap();
735
736        // menu font should have inherited family/weight from defaults
737        assert_eq!(
738            resolved.menu.font.family.as_ref(),
739            "Inter",
740            "menu font family should inherit from defaults"
741        );
742        assert_eq!(
743            resolved.menu.font.size, 12.0,
744            "menu font size should be the explicit value"
745        );
746        assert_eq!(
747            resolved.menu.font.weight, 400,
748            "menu font weight should inherit from defaults"
749        );
750    }
751
752    #[test]
753    fn text_scale_inheritance_integration() {
754        // Load a preset, ensure text_scale.caption gets populated from defaults.
755        let theme = preset("catppuccin-mocha").unwrap();
756        let mut light = theme.light.clone().unwrap();
757
758        // Clear caption to test inheritance
759        light.text_scale.caption = None;
760
761        light.resolve_all();
762        let resolved = light.validate().unwrap();
763
764        // caption should have been populated from defaults.font (no ratio)
765        // defaults.font.size = 14.0, so caption.size = 14.0
766        let expected_size = 14.0;
767        assert!(
768            (resolved.text_scale.caption.size - expected_size).abs() < 0.01,
769            "caption size = defaults.font.size, got {}",
770            resolved.text_scale.caption.size
771        );
772        assert_eq!(
773            resolved.text_scale.caption.weight, 400,
774            "caption weight from defaults.font.weight"
775        );
776        // line_height = defaults.line_height * caption_size = 1.2 * 14.0 = 16.8
777        let expected_lh = 1.2 * expected_size;
778        assert!(
779            (resolved.text_scale.caption.line_height - expected_lh).abs() < 0.01,
780            "caption line_height should be line_height_multiplier * caption_size = {expected_lh}, got {}",
781            resolved.text_scale.caption.line_height
782        );
783    }
784
785    // NOTE: all_presets_round_trip_exact is covered by
786    // tests/preset_loading.rs::all_presets_round_trip_toml.
787
788    // === Live preset tests ===
789
790    #[test]
791    fn live_presets_loadable() {
792        let live_names = &[
793            "kde-breeze-live",
794            "adwaita-live",
795            "macos-sonoma-live",
796            "windows-11-live",
797        ];
798        for name in live_names {
799            let theme = preset(name)
800                .unwrap_or_else(|e| panic!("live preset '{name}' failed to parse: {e}"));
801
802            // Both variants must exist
803            assert!(
804                theme.light.is_some(),
805                "live preset '{name}' missing light variant"
806            );
807            assert!(
808                theme.dark.is_some(),
809                "live preset '{name}' missing dark variant"
810            );
811
812            let light = theme.light.as_ref().unwrap();
813            let dark = theme.dark.as_ref().unwrap();
814
815            // No colors
816            assert!(
817                light.defaults.accent_color.is_none(),
818                "live preset '{name}' light should have no accent"
819            );
820            assert!(
821                light.defaults.background_color.is_none(),
822                "live preset '{name}' light should have no background"
823            );
824            assert!(
825                light.defaults.text_color.is_none(),
826                "live preset '{name}' light should have no foreground"
827            );
828            assert!(
829                dark.defaults.accent_color.is_none(),
830                "live preset '{name}' dark should have no accent"
831            );
832            assert!(
833                dark.defaults.background_color.is_none(),
834                "live preset '{name}' dark should have no background"
835            );
836            assert!(
837                dark.defaults.text_color.is_none(),
838                "live preset '{name}' dark should have no foreground"
839            );
840
841            // No fonts
842            assert!(
843                light.defaults.font.family.is_none(),
844                "live preset '{name}' light should have no font family"
845            );
846            assert!(
847                light.defaults.font.size.is_none(),
848                "live preset '{name}' light should have no font size"
849            );
850            assert!(
851                light.defaults.font.weight.is_none(),
852                "live preset '{name}' light should have no font weight"
853            );
854            assert!(
855                dark.defaults.font.family.is_none(),
856                "live preset '{name}' dark should have no font family"
857            );
858            assert!(
859                dark.defaults.font.size.is_none(),
860                "live preset '{name}' dark should have no font size"
861            );
862            assert!(
863                dark.defaults.font.weight.is_none(),
864                "live preset '{name}' dark should have no font weight"
865            );
866        }
867    }
868
869    #[test]
870    fn list_presets_for_platform_returns_subset() {
871        let all = list_presets();
872        let filtered = list_presets_for_platform();
873        // Filtered list should be a subset of all presets
874        for info in &filtered {
875            assert!(
876                all.iter().any(|a| a.key == info.key),
877                "filtered preset '{}' not in full list",
878                info.key
879            );
880        }
881        // Community themes should always be present
882        let community = &[
883            "catppuccin-latte",
884            "catppuccin-frappe",
885            "catppuccin-macchiato",
886            "catppuccin-mocha",
887            "nord",
888            "dracula",
889            "gruvbox",
890            "solarized",
891            "tokyo-night",
892            "one-dark",
893        ];
894        for name in community {
895            assert!(
896                filtered.iter().any(|info| info.key == *name),
897                "community preset '{name}' should always be in filtered list"
898            );
899        }
900        // material is cross-platform, always present
901        assert!(
902            filtered.iter().any(|info| info.key == "material"),
903            "material should always be in filtered list"
904        );
905    }
906
907    #[test]
908    fn live_presets_fail_validate_standalone() {
909        let live_names = &[
910            "kde-breeze-live",
911            "adwaita-live",
912            "macos-sonoma-live",
913            "windows-11-live",
914        ];
915        for name in live_names {
916            let theme = preset(name).unwrap();
917            let mut light = theme.light.clone().unwrap();
918            light.resolve();
919            let result = light.validate();
920            assert!(
921                result.is_err(),
922                "live preset '{name}' light should fail validation standalone (missing colors/fonts)"
923            );
924
925            let mut dark = theme.dark.clone().unwrap();
926            dark.resolve();
927            let result = dark.validate();
928            assert!(
929                result.is_err(),
930                "live preset '{name}' dark should fail validation standalone (missing colors/fonts)"
931            );
932        }
933    }
934}