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//! 2 core platform (kde-breeze, adwaita), 4 platform (windows-11,
5//! macos-sonoma, material, ios), and 10 community (Catppuccin 4 flavors,
6//! Nord, Dracula, Gruvbox, Solarized, Tokyo Night, One Dark), plus
7//! 4 internal live presets (geometry-only, used by the OS-first pipeline)
8//! and functions for loading themes from TOML strings and files.
9
10use crate::{Error, Result, ThemeSpec};
11use std::path::Path;
12use std::sync::LazyLock;
13
14// Embed preset TOML files at compile time
15const KDE_BREEZE_TOML: &str = include_str!("presets/kde-breeze.toml");
16const ADWAITA_TOML: &str = include_str!("presets/adwaita.toml");
17const WINDOWS_11_TOML: &str = include_str!("presets/windows-11.toml");
18const MACOS_SONOMA_TOML: &str = include_str!("presets/macos-sonoma.toml");
19const MATERIAL_TOML: &str = include_str!("presets/material.toml");
20const IOS_TOML: &str = include_str!("presets/ios.toml");
21const CATPPUCCIN_LATTE_TOML: &str = include_str!("presets/catppuccin-latte.toml");
22const CATPPUCCIN_FRAPPE_TOML: &str = include_str!("presets/catppuccin-frappe.toml");
23const CATPPUCCIN_MACCHIATO_TOML: &str = include_str!("presets/catppuccin-macchiato.toml");
24const CATPPUCCIN_MOCHA_TOML: &str = include_str!("presets/catppuccin-mocha.toml");
25const NORD_TOML: &str = include_str!("presets/nord.toml");
26const DRACULA_TOML: &str = include_str!("presets/dracula.toml");
27const GRUVBOX_TOML: &str = include_str!("presets/gruvbox.toml");
28const SOLARIZED_TOML: &str = include_str!("presets/solarized.toml");
29const TOKYO_NIGHT_TOML: &str = include_str!("presets/tokyo-night.toml");
30const ONE_DARK_TOML: &str = include_str!("presets/one-dark.toml");
31
32// Live presets: geometry/metrics only (internal, not user-selectable)
33const KDE_BREEZE_LIVE_TOML: &str = include_str!("presets/kde-breeze-live.toml");
34const ADWAITA_LIVE_TOML: &str = include_str!("presets/adwaita-live.toml");
35const MACOS_SONOMA_LIVE_TOML: &str = include_str!("presets/macos-sonoma-live.toml");
36const WINDOWS_11_LIVE_TOML: &str = include_str!("presets/windows-11-live.toml");
37
38/// All available user-facing preset names (excludes internal live presets).
39const PRESET_NAMES: &[&str] = &[
40    "kde-breeze",
41    "adwaita",
42    "windows-11",
43    "macos-sonoma",
44    "material",
45    "ios",
46    "catppuccin-latte",
47    "catppuccin-frappe",
48    "catppuccin-macchiato",
49    "catppuccin-mocha",
50    "nord",
51    "dracula",
52    "gruvbox",
53    "solarized",
54    "tokyo-night",
55    "one-dark",
56];
57
58// Cached presets: each parsed at most once for the process lifetime.
59// Errors are stored as String (Error is not Clone) and propagated to callers.
60mod cached {
61    use super::*;
62
63    type Parsed = std::result::Result<ThemeSpec, String>;
64
65    fn parse(toml: &str) -> Parsed {
66        from_toml(toml).map_err(|e| e.to_string())
67    }
68
69    static KDE_BREEZE: LazyLock<Parsed> = LazyLock::new(|| parse(KDE_BREEZE_TOML));
70    static ADWAITA: LazyLock<Parsed> = LazyLock::new(|| parse(ADWAITA_TOML));
71    static WINDOWS_11: LazyLock<Parsed> = LazyLock::new(|| parse(WINDOWS_11_TOML));
72    static MACOS_SONOMA: LazyLock<Parsed> = LazyLock::new(|| parse(MACOS_SONOMA_TOML));
73    static MATERIAL: LazyLock<Parsed> = LazyLock::new(|| parse(MATERIAL_TOML));
74    static IOS: LazyLock<Parsed> = LazyLock::new(|| parse(IOS_TOML));
75    static CATPPUCCIN_LATTE: LazyLock<Parsed> = LazyLock::new(|| parse(CATPPUCCIN_LATTE_TOML));
76    static CATPPUCCIN_FRAPPE: LazyLock<Parsed> = LazyLock::new(|| parse(CATPPUCCIN_FRAPPE_TOML));
77    static CATPPUCCIN_MACCHIATO: LazyLock<Parsed> =
78        LazyLock::new(|| parse(CATPPUCCIN_MACCHIATO_TOML));
79    static CATPPUCCIN_MOCHA: LazyLock<Parsed> = LazyLock::new(|| parse(CATPPUCCIN_MOCHA_TOML));
80    static NORD: LazyLock<Parsed> = LazyLock::new(|| parse(NORD_TOML));
81    static DRACULA: LazyLock<Parsed> = LazyLock::new(|| parse(DRACULA_TOML));
82    static GRUVBOX: LazyLock<Parsed> = LazyLock::new(|| parse(GRUVBOX_TOML));
83    static SOLARIZED: LazyLock<Parsed> = LazyLock::new(|| parse(SOLARIZED_TOML));
84    static TOKYO_NIGHT: LazyLock<Parsed> = LazyLock::new(|| parse(TOKYO_NIGHT_TOML));
85    static ONE_DARK: LazyLock<Parsed> = LazyLock::new(|| parse(ONE_DARK_TOML));
86    // Internal live presets
87    static KDE_BREEZE_LIVE: LazyLock<Parsed> = LazyLock::new(|| parse(KDE_BREEZE_LIVE_TOML));
88    static ADWAITA_LIVE: LazyLock<Parsed> = LazyLock::new(|| parse(ADWAITA_LIVE_TOML));
89    static MACOS_SONOMA_LIVE: LazyLock<Parsed> = LazyLock::new(|| parse(MACOS_SONOMA_LIVE_TOML));
90    static WINDOWS_11_LIVE: LazyLock<Parsed> = LazyLock::new(|| parse(WINDOWS_11_LIVE_TOML));
91
92    pub(crate) fn get(name: &str) -> Option<&'static Parsed> {
93        match name {
94            "kde-breeze" => Some(&KDE_BREEZE),
95            "adwaita" => Some(&ADWAITA),
96            "windows-11" => Some(&WINDOWS_11),
97            "macos-sonoma" => Some(&MACOS_SONOMA),
98            "material" => Some(&MATERIAL),
99            "ios" => Some(&IOS),
100            "catppuccin-latte" => Some(&CATPPUCCIN_LATTE),
101            "catppuccin-frappe" => Some(&CATPPUCCIN_FRAPPE),
102            "catppuccin-macchiato" => Some(&CATPPUCCIN_MACCHIATO),
103            "catppuccin-mocha" => Some(&CATPPUCCIN_MOCHA),
104            "nord" => Some(&NORD),
105            "dracula" => Some(&DRACULA),
106            "gruvbox" => Some(&GRUVBOX),
107            "solarized" => Some(&SOLARIZED),
108            "tokyo-night" => Some(&TOKYO_NIGHT),
109            "one-dark" => Some(&ONE_DARK),
110            "kde-breeze-live" => Some(&KDE_BREEZE_LIVE),
111            "adwaita-live" => Some(&ADWAITA_LIVE),
112            "macos-sonoma-live" => Some(&MACOS_SONOMA_LIVE),
113            "windows-11-live" => Some(&WINDOWS_11_LIVE),
114            _ => None,
115        }
116    }
117}
118
119pub(crate) fn preset(name: &str) -> Result<ThemeSpec> {
120    match cached::get(name) {
121        None => Err(Error::Unavailable(format!("unknown preset: {name}"))),
122        Some(Ok(theme)) => Ok(theme.clone()),
123        Some(Err(msg)) => Err(Error::Format(format!("bundled preset '{name}': {msg}"))),
124    }
125}
126
127pub(crate) fn list_presets() -> &'static [&'static str] {
128    PRESET_NAMES
129}
130
131/// Platform-specific preset names that should only appear on their native platform.
132const PLATFORM_SPECIFIC: &[(&str, &[&str])] = &[
133    ("kde-breeze", &["linux-kde"]),
134    ("adwaita", &["linux"]),
135    ("windows-11", &["windows"]),
136    ("macos-sonoma", &["macos"]),
137    ("ios", &["macos", "ios"]),
138];
139
140/// Detect the current platform tag for preset filtering.
141///
142/// Returns a string like "linux-kde", "linux", "windows", or "macos".
143#[allow(unreachable_code)]
144fn detect_platform() -> &'static str {
145    #[cfg(target_os = "macos")]
146    {
147        return "macos";
148    }
149    #[cfg(target_os = "windows")]
150    {
151        return "windows";
152    }
153    #[cfg(target_os = "linux")]
154    {
155        let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
156        for component in desktop.split(':') {
157            if component == "KDE" {
158                return "linux-kde";
159            }
160        }
161        "linux"
162    }
163    #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
164    {
165        "linux"
166    }
167}
168
169/// Returns preset names appropriate for the current platform.
170///
171/// Platform-specific presets (kde-breeze, adwaita, windows-11, macos-sonoma, ios)
172/// are only included on their native platform. Community themes are always included.
173pub(crate) fn list_presets_for_platform() -> Vec<&'static str> {
174    let platform = detect_platform();
175
176    PRESET_NAMES
177        .iter()
178        .filter(|name| {
179            if let Some((_, platforms)) = PLATFORM_SPECIFIC.iter().find(|(n, _)| n == *name) {
180                platforms.iter().any(|p| platform.starts_with(p))
181            } else {
182                true // Community themes always visible
183            }
184        })
185        .copied()
186        .collect()
187}
188
189pub(crate) fn from_toml(toml_str: &str) -> Result<ThemeSpec> {
190    let theme: ThemeSpec = toml::from_str(toml_str)?;
191    Ok(theme)
192}
193
194pub(crate) fn from_file(path: impl AsRef<Path>) -> Result<ThemeSpec> {
195    let contents = std::fs::read_to_string(path)?;
196    from_toml(&contents)
197}
198
199pub(crate) fn to_toml(theme: &ThemeSpec) -> Result<String> {
200    let s = toml::to_string_pretty(theme)?;
201    Ok(s)
202}
203
204#[cfg(test)]
205#[allow(clippy::unwrap_used, clippy::expect_used)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn all_presets_loadable_via_preset_fn() {
211        for name in list_presets() {
212            let theme =
213                preset(name).unwrap_or_else(|e| panic!("preset '{name}' failed to parse: {e}"));
214            assert!(
215                theme.light.is_some(),
216                "preset '{name}' missing light variant"
217            );
218            assert!(theme.dark.is_some(), "preset '{name}' missing dark variant");
219        }
220    }
221
222    #[test]
223    fn all_presets_have_nonempty_core_colors() {
224        for name in list_presets() {
225            let theme = preset(name).unwrap();
226            let light = theme.light.as_ref().unwrap();
227            let dark = theme.dark.as_ref().unwrap();
228
229            assert!(
230                light.defaults.accent.is_some(),
231                "preset '{name}' light missing accent"
232            );
233            assert!(
234                light.defaults.background.is_some(),
235                "preset '{name}' light missing background"
236            );
237            assert!(
238                light.defaults.foreground.is_some(),
239                "preset '{name}' light missing foreground"
240            );
241            assert!(
242                dark.defaults.accent.is_some(),
243                "preset '{name}' dark missing accent"
244            );
245            assert!(
246                dark.defaults.background.is_some(),
247                "preset '{name}' dark missing background"
248            );
249            assert!(
250                dark.defaults.foreground.is_some(),
251                "preset '{name}' dark missing foreground"
252            );
253        }
254    }
255
256    #[test]
257    fn preset_unknown_name_returns_unavailable() {
258        let err = preset("nonexistent").unwrap_err();
259        match err {
260            Error::Unavailable(msg) => assert!(msg.contains("nonexistent")),
261            other => panic!("expected Unavailable, got: {other:?}"),
262        }
263    }
264
265    #[test]
266    fn list_presets_returns_all_sixteen() {
267        let names = list_presets();
268        assert_eq!(names.len(), 16);
269        assert!(names.contains(&"kde-breeze"));
270        assert!(names.contains(&"adwaita"));
271        assert!(names.contains(&"windows-11"));
272        assert!(names.contains(&"macos-sonoma"));
273        assert!(names.contains(&"material"));
274        assert!(names.contains(&"ios"));
275        assert!(names.contains(&"catppuccin-latte"));
276        assert!(names.contains(&"catppuccin-frappe"));
277        assert!(names.contains(&"catppuccin-macchiato"));
278        assert!(names.contains(&"catppuccin-mocha"));
279        assert!(names.contains(&"nord"));
280        assert!(names.contains(&"dracula"));
281        assert!(names.contains(&"gruvbox"));
282        assert!(names.contains(&"solarized"));
283        assert!(names.contains(&"tokyo-night"));
284        assert!(names.contains(&"one-dark"));
285    }
286
287    #[test]
288    fn from_toml_minimal_valid() {
289        let toml_str = r##"
290name = "Minimal"
291
292[light.defaults]
293accent = "#ff0000"
294"##;
295        let theme = from_toml(toml_str).unwrap();
296        assert_eq!(theme.name, "Minimal");
297        assert!(theme.light.is_some());
298        let light = theme.light.unwrap();
299        assert_eq!(light.defaults.accent, Some(crate::Rgba::rgb(255, 0, 0)));
300    }
301
302    #[test]
303    fn from_toml_invalid_returns_format_error() {
304        let err = from_toml("{{{{invalid toml").unwrap_err();
305        match err {
306            Error::Format(_) => {}
307            other => panic!("expected Format, got: {other:?}"),
308        }
309    }
310
311    #[test]
312    fn to_toml_produces_valid_round_trip() {
313        let theme = preset("catppuccin-mocha").unwrap();
314        let toml_str = to_toml(&theme).unwrap();
315
316        // Must be parseable back into a ThemeSpec
317        let reparsed = from_toml(&toml_str).unwrap();
318        assert_eq!(reparsed.name, theme.name);
319        assert!(reparsed.light.is_some());
320        assert!(reparsed.dark.is_some());
321
322        // Core colors should survive the round-trip
323        let orig_light = theme.light.as_ref().unwrap();
324        let new_light = reparsed.light.as_ref().unwrap();
325        assert_eq!(orig_light.defaults.accent, new_light.defaults.accent);
326    }
327
328    #[test]
329    fn from_file_with_tempfile() {
330        let dir = std::env::temp_dir();
331        let path = dir.join("native_theme_test_preset.toml");
332        let toml_str = r##"
333name = "File Test"
334
335[light.defaults]
336accent = "#00ff00"
337"##;
338        std::fs::write(&path, toml_str).unwrap();
339
340        let theme = from_file(&path).unwrap();
341        assert_eq!(theme.name, "File Test");
342        assert!(theme.light.is_some());
343
344        // Clean up
345        let _ = std::fs::remove_file(&path);
346    }
347
348    // === icon_set preset tests ===
349
350    #[test]
351    fn icon_set_native_presets_have_correct_values() {
352        use crate::IconSet;
353        let cases: &[(&str, IconSet)] = &[
354            ("windows-11", IconSet::SegoeIcons),
355            ("macos-sonoma", IconSet::SfSymbols),
356            ("ios", IconSet::SfSymbols),
357            ("adwaita", IconSet::Freedesktop),
358            ("kde-breeze", IconSet::Freedesktop),
359            ("material", IconSet::Material),
360        ];
361        for (name, expected) in cases {
362            let theme = preset(name).unwrap();
363            let light = theme.light.as_ref().unwrap();
364            assert_eq!(
365                light.icon_set,
366                Some(*expected),
367                "preset '{name}' light.icon_set should be Some({expected:?})"
368            );
369            let dark = theme.dark.as_ref().unwrap();
370            assert_eq!(
371                dark.icon_set,
372                Some(*expected),
373                "preset '{name}' dark.icon_set should be Some({expected:?})"
374            );
375        }
376    }
377
378    #[test]
379    fn icon_set_community_presets_are_freedesktop() {
380        use crate::IconSet;
381        let community = &[
382            "catppuccin-latte",
383            "catppuccin-frappe",
384            "catppuccin-macchiato",
385            "catppuccin-mocha",
386            "nord",
387            "dracula",
388            "gruvbox",
389            "solarized",
390            "tokyo-night",
391            "one-dark",
392        ];
393        for name in community {
394            let theme = preset(name).unwrap();
395            let light = theme.light.as_ref().unwrap();
396            assert_eq!(
397                light.icon_set,
398                Some(IconSet::Freedesktop),
399                "preset '{name}' light.icon_set should be Some(Freedesktop)"
400            );
401            let dark = theme.dark.as_ref().unwrap();
402            assert_eq!(
403                dark.icon_set,
404                Some(IconSet::Freedesktop),
405                "preset '{name}' dark.icon_set should be Some(Freedesktop)"
406            );
407        }
408    }
409
410    #[test]
411    fn from_file_nonexistent_returns_error() {
412        let err = from_file("/tmp/nonexistent_theme_file_12345.toml").unwrap_err();
413        match err {
414            Error::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::NotFound),
415            other => panic!("expected Io, got: {other:?}"),
416        }
417    }
418
419    #[test]
420    fn preset_names_match_list() {
421        // Every name in list_presets() must be loadable via preset()
422        for name in list_presets() {
423            assert!(preset(name).is_ok(), "preset '{name}' not loadable");
424        }
425    }
426
427    #[test]
428    fn presets_have_correct_names() {
429        assert_eq!(preset("kde-breeze").unwrap().name, "KDE Breeze");
430        assert_eq!(preset("adwaita").unwrap().name, "Adwaita");
431        assert_eq!(preset("windows-11").unwrap().name, "Windows 11");
432        assert_eq!(preset("macos-sonoma").unwrap().name, "macOS Sonoma");
433        assert_eq!(preset("material").unwrap().name, "Material");
434        assert_eq!(preset("ios").unwrap().name, "iOS");
435        assert_eq!(preset("catppuccin-latte").unwrap().name, "Catppuccin Latte");
436        assert_eq!(
437            preset("catppuccin-frappe").unwrap().name,
438            "Catppuccin Frappe"
439        );
440        assert_eq!(
441            preset("catppuccin-macchiato").unwrap().name,
442            "Catppuccin Macchiato"
443        );
444        assert_eq!(preset("catppuccin-mocha").unwrap().name, "Catppuccin Mocha");
445        assert_eq!(preset("nord").unwrap().name, "Nord");
446        assert_eq!(preset("dracula").unwrap().name, "Dracula");
447        assert_eq!(preset("gruvbox").unwrap().name, "Gruvbox");
448        assert_eq!(preset("solarized").unwrap().name, "Solarized");
449        assert_eq!(preset("tokyo-night").unwrap().name, "Tokyo Night");
450        assert_eq!(preset("one-dark").unwrap().name, "One Dark");
451    }
452
453    #[test]
454    fn all_presets_with_fonts_have_valid_sizes() {
455        for name in list_presets() {
456            let theme = preset(name).unwrap();
457            for (label, variant) in [
458                ("light", theme.light.as_ref()),
459                ("dark", theme.dark.as_ref()),
460            ] {
461                let variant = variant.unwrap();
462                // Community color themes may omit fonts entirely — skip those.
463                if let Some(size) = variant.defaults.font.size {
464                    assert!(
465                        size > 0.0,
466                        "preset '{name}' {label} font size must be positive, got {size}"
467                    );
468                }
469                if let Some(mono_size) = variant.defaults.mono_font.size {
470                    assert!(
471                        mono_size > 0.0,
472                        "preset '{name}' {label} mono font size must be positive, got {mono_size}"
473                    );
474                }
475            }
476        }
477    }
478
479    #[test]
480    fn platform_presets_no_derived_fields() {
481        // Platform presets must not contain fields that are derived by resolve()
482        let platform_presets = &["kde-breeze", "adwaita", "windows-11", "macos-sonoma"];
483        for name in platform_presets {
484            let theme = preset(name).unwrap();
485            for (label, variant_opt) in [
486                ("light", theme.light.as_ref()),
487                ("dark", theme.dark.as_ref()),
488            ] {
489                let variant = variant_opt.unwrap();
490                // button.primary_bg is derived from accent - should not be in presets
491                assert!(
492                    variant.button.primary_bg.is_none(),
493                    "preset '{name}' {label}.button.primary_bg should be None (derived)"
494                );
495                // checkbox.checked_bg is derived from accent
496                assert!(
497                    variant.checkbox.checked_bg.is_none(),
498                    "preset '{name}' {label}.checkbox.checked_bg should be None (derived)"
499                );
500                // slider.fill is derived from accent
501                assert!(
502                    variant.slider.fill.is_none(),
503                    "preset '{name}' {label}.slider.fill should be None (derived)"
504                );
505                // progress_bar.fill is derived from accent
506                assert!(
507                    variant.progress_bar.fill.is_none(),
508                    "preset '{name}' {label}.progress_bar.fill should be None (derived)"
509                );
510                // switch.checked_bg is derived from accent
511                assert!(
512                    variant.switch.checked_bg.is_none(),
513                    "preset '{name}' {label}.switch.checked_bg should be None (derived)"
514                );
515            }
516        }
517    }
518
519    // === resolve()/validate() integration tests (PRESET-03) ===
520
521    #[test]
522    fn all_presets_resolve_validate() {
523        for name in list_presets() {
524            let theme = preset(name).unwrap();
525            if let Some(mut light) = theme.light.clone() {
526                light.resolve();
527                light.validate().unwrap_or_else(|e| {
528                    panic!("preset {name} light variant failed validation: {e}");
529                });
530            }
531            if let Some(mut dark) = theme.dark.clone() {
532                dark.resolve();
533                dark.validate().unwrap_or_else(|e| {
534                    panic!("preset {name} dark variant failed validation: {e}");
535                });
536            }
537        }
538    }
539
540    #[test]
541    fn resolve_fills_accent_derived_fields() {
542        // Load a preset that only has accent set (not explicit widget accent-derived fields).
543        // After resolve(), the accent-derived fields should be populated.
544        let theme = preset("catppuccin-mocha").unwrap();
545        let mut light = theme.light.clone().unwrap();
546
547        // Before resolve: accent-derived fields should be None (not in preset TOML)
548        assert!(
549            light.button.primary_bg.is_none(),
550            "primary_bg should be None pre-resolve"
551        );
552        assert!(
553            light.checkbox.checked_bg.is_none(),
554            "checkbox.checked_bg should be None pre-resolve"
555        );
556        assert!(
557            light.slider.fill.is_none(),
558            "slider.fill should be None pre-resolve"
559        );
560        assert!(
561            light.progress_bar.fill.is_none(),
562            "progress_bar.fill should be None pre-resolve"
563        );
564        assert!(
565            light.switch.checked_bg.is_none(),
566            "switch.checked_bg should be None pre-resolve"
567        );
568
569        light.resolve();
570
571        // After resolve: all accent-derived fields should equal accent
572        let accent = light.defaults.accent.unwrap();
573        assert_eq!(
574            light.button.primary_bg,
575            Some(accent),
576            "button.primary_bg should match accent"
577        );
578        assert_eq!(
579            light.checkbox.checked_bg,
580            Some(accent),
581            "checkbox.checked_bg should match accent"
582        );
583        assert_eq!(
584            light.slider.fill,
585            Some(accent),
586            "slider.fill should match accent"
587        );
588        assert_eq!(
589            light.progress_bar.fill,
590            Some(accent),
591            "progress_bar.fill should match accent"
592        );
593        assert_eq!(
594            light.switch.checked_bg,
595            Some(accent),
596            "switch.checked_bg should match accent"
597        );
598    }
599
600    #[test]
601    fn resolve_then_validate_produces_complete_theme() {
602        let theme = preset("catppuccin-mocha").unwrap();
603        let mut light = theme.light.clone().unwrap();
604        light.resolve();
605        let resolved = light.validate().unwrap();
606
607        assert_eq!(resolved.defaults.font.family, "Inter");
608        assert_eq!(resolved.defaults.font.size, 14.0);
609        assert_eq!(resolved.defaults.font.weight, 400);
610        assert_eq!(resolved.defaults.line_height, 1.2);
611        assert_eq!(resolved.defaults.radius, 8.0);
612        assert_eq!(resolved.defaults.focus_ring_width, 2.0);
613        assert_eq!(resolved.defaults.icon_sizes.toolbar, 24.0);
614        assert_eq!(resolved.defaults.text_scaling_factor, 1.0);
615        assert!(!resolved.defaults.reduce_motion);
616        // Window inherits from defaults
617        assert_eq!(resolved.window.background, resolved.defaults.background);
618        // icon_set should be populated
619        assert_eq!(resolved.icon_set, crate::IconSet::Freedesktop);
620    }
621
622    #[test]
623    fn font_subfield_inheritance_integration() {
624        // Load a preset, set menu.font to only have size=12.0 (clear family/weight),
625        // resolve, and verify family/weight are inherited from defaults.
626        let theme = preset("catppuccin-mocha").unwrap();
627        let mut light = theme.light.clone().unwrap();
628
629        // Set partial font on menu
630        use crate::model::FontSpec;
631        light.menu.font = Some(FontSpec {
632            family: None,
633            size: Some(12.0),
634            weight: None,
635        });
636
637        light.resolve();
638        let resolved = light.validate().unwrap();
639
640        // menu font should have inherited family/weight from defaults
641        assert_eq!(
642            resolved.menu.font.family, "Inter",
643            "menu font family should inherit from defaults"
644        );
645        assert_eq!(
646            resolved.menu.font.size, 12.0,
647            "menu font size should be the explicit value"
648        );
649        assert_eq!(
650            resolved.menu.font.weight, 400,
651            "menu font weight should inherit from defaults"
652        );
653    }
654
655    #[test]
656    fn text_scale_inheritance_integration() {
657        // Load a preset, ensure text_scale.caption gets populated from defaults.
658        let theme = preset("catppuccin-mocha").unwrap();
659        let mut light = theme.light.clone().unwrap();
660
661        // Clear caption to test inheritance
662        light.text_scale.caption = None;
663
664        light.resolve();
665        let resolved = light.validate().unwrap();
666
667        // caption should have been populated from defaults.font
668        assert_eq!(
669            resolved.text_scale.caption.size, 14.0,
670            "caption size from defaults.font.size"
671        );
672        assert_eq!(
673            resolved.text_scale.caption.weight, 400,
674            "caption weight from defaults.font.weight"
675        );
676        // line_height = defaults.line_height * size = 1.2 * 14.0 = 16.8
677        assert!(
678            (resolved.text_scale.caption.line_height - 16.8).abs() < 0.01,
679            "caption line_height should be line_height_multiplier * size = 16.8, got {}",
680            resolved.text_scale.caption.line_height
681        );
682    }
683
684    #[test]
685    fn all_presets_round_trip_exact() {
686        // All 16 presets must survive a serde round-trip
687        for name in list_presets() {
688            let theme1 =
689                preset(name).unwrap_or_else(|e| panic!("preset '{name}' failed to parse: {e}"));
690            let toml_str = to_toml(&theme1)
691                .unwrap_or_else(|e| panic!("preset '{name}' failed to serialize: {e}"));
692            let theme2 = from_toml(&toml_str)
693                .unwrap_or_else(|e| panic!("preset '{name}' failed to re-parse: {e}"));
694            assert_eq!(
695                theme1, theme2,
696                "preset '{name}' round-trip produced different value"
697            );
698        }
699    }
700
701    // === Live preset tests ===
702
703    #[test]
704    fn live_presets_loadable() {
705        let live_names = &[
706            "kde-breeze-live",
707            "adwaita-live",
708            "macos-sonoma-live",
709            "windows-11-live",
710        ];
711        for name in live_names {
712            let theme = preset(name)
713                .unwrap_or_else(|e| panic!("live preset '{name}' failed to parse: {e}"));
714
715            // Both variants must exist
716            assert!(
717                theme.light.is_some(),
718                "live preset '{name}' missing light variant"
719            );
720            assert!(
721                theme.dark.is_some(),
722                "live preset '{name}' missing dark variant"
723            );
724
725            let light = theme.light.as_ref().unwrap();
726            let dark = theme.dark.as_ref().unwrap();
727
728            // No colors
729            assert!(
730                light.defaults.accent.is_none(),
731                "live preset '{name}' light should have no accent"
732            );
733            assert!(
734                light.defaults.background.is_none(),
735                "live preset '{name}' light should have no background"
736            );
737            assert!(
738                light.defaults.foreground.is_none(),
739                "live preset '{name}' light should have no foreground"
740            );
741            assert!(
742                dark.defaults.accent.is_none(),
743                "live preset '{name}' dark should have no accent"
744            );
745            assert!(
746                dark.defaults.background.is_none(),
747                "live preset '{name}' dark should have no background"
748            );
749            assert!(
750                dark.defaults.foreground.is_none(),
751                "live preset '{name}' dark should have no foreground"
752            );
753
754            // No fonts
755            assert!(
756                light.defaults.font.family.is_none(),
757                "live preset '{name}' light should have no font family"
758            );
759            assert!(
760                light.defaults.font.size.is_none(),
761                "live preset '{name}' light should have no font size"
762            );
763            assert!(
764                light.defaults.font.weight.is_none(),
765                "live preset '{name}' light should have no font weight"
766            );
767            assert!(
768                dark.defaults.font.family.is_none(),
769                "live preset '{name}' dark should have no font family"
770            );
771            assert!(
772                dark.defaults.font.size.is_none(),
773                "live preset '{name}' dark should have no font size"
774            );
775            assert!(
776                dark.defaults.font.weight.is_none(),
777                "live preset '{name}' dark should have no font weight"
778            );
779        }
780    }
781
782    #[test]
783    fn list_presets_for_platform_returns_subset() {
784        let all = list_presets();
785        let filtered = list_presets_for_platform();
786        // Filtered list should be a subset of all presets
787        for name in &filtered {
788            assert!(
789                all.contains(name),
790                "filtered preset '{name}' not in full list"
791            );
792        }
793        // Community themes should always be present
794        let community = &[
795            "catppuccin-latte",
796            "catppuccin-frappe",
797            "catppuccin-macchiato",
798            "catppuccin-mocha",
799            "nord",
800            "dracula",
801            "gruvbox",
802            "solarized",
803            "tokyo-night",
804            "one-dark",
805        ];
806        for name in community {
807            assert!(
808                filtered.contains(name),
809                "community preset '{name}' should always be in filtered list"
810            );
811        }
812        // material is cross-platform, always present
813        assert!(
814            filtered.contains(&"material"),
815            "material should always be in filtered list"
816        );
817    }
818
819    #[test]
820    fn live_presets_fail_validate_standalone() {
821        let live_names = &[
822            "kde-breeze-live",
823            "adwaita-live",
824            "macos-sonoma-live",
825            "windows-11-live",
826        ];
827        for name in live_names {
828            let theme = preset(name).unwrap();
829            let mut light = theme.light.clone().unwrap();
830            light.resolve();
831            let result = light.validate();
832            assert!(
833                result.is_err(),
834                "live preset '{name}' light should fail validation standalone (missing colors/fonts)"
835            );
836
837            let mut dark = theme.dark.clone().unwrap();
838            dark.resolve();
839            let result = dark.validate();
840            assert!(
841                result.is_err(),
842                "live preset '{name}' dark should fail validation standalone (missing colors/fonts)"
843            );
844        }
845    }
846}