Skip to main content

native_theme/
presets.rs

1//! Bundled theme presets and TOML serialization API.
2//!
3//! Provides 17 built-in presets embedded at compile time: 3 core (default,
4//! kde-breeze, adwaita), 4 platform (windows-11, macos-sonoma, material, ios),
5//! and 10 community (Catppuccin 4 flavors, Nord, Dracula, Gruvbox, Solarized,
6//! Tokyo Night, One Dark), plus functions for loading themes from TOML strings
7//! and files.
8
9use crate::{Error, NativeTheme, Result};
10use std::path::Path;
11
12// Embed preset TOML files at compile time
13const DEFAULT_TOML: &str = include_str!("presets/default.toml");
14const KDE_BREEZE_TOML: &str = include_str!("presets/kde-breeze.toml");
15const ADWAITA_TOML: &str = include_str!("presets/adwaita.toml");
16const WINDOWS_11_TOML: &str = include_str!("presets/windows-11.toml");
17const MACOS_SONOMA_TOML: &str = include_str!("presets/macos-sonoma.toml");
18const MATERIAL_TOML: &str = include_str!("presets/material.toml");
19const IOS_TOML: &str = include_str!("presets/ios.toml");
20const CATPPUCCIN_LATTE_TOML: &str = include_str!("presets/catppuccin-latte.toml");
21const CATPPUCCIN_FRAPPE_TOML: &str = include_str!("presets/catppuccin-frappe.toml");
22const CATPPUCCIN_MACCHIATO_TOML: &str = include_str!("presets/catppuccin-macchiato.toml");
23const CATPPUCCIN_MOCHA_TOML: &str = include_str!("presets/catppuccin-mocha.toml");
24const NORD_TOML: &str = include_str!("presets/nord.toml");
25const DRACULA_TOML: &str = include_str!("presets/dracula.toml");
26const GRUVBOX_TOML: &str = include_str!("presets/gruvbox.toml");
27const SOLARIZED_TOML: &str = include_str!("presets/solarized.toml");
28const TOKYO_NIGHT_TOML: &str = include_str!("presets/tokyo-night.toml");
29const ONE_DARK_TOML: &str = include_str!("presets/one-dark.toml");
30
31/// All available preset names.
32const PRESET_NAMES: &[&str] = &[
33    "default",
34    "kde-breeze",
35    "adwaita",
36    "windows-11",
37    "macos-sonoma",
38    "material",
39    "ios",
40    "catppuccin-latte",
41    "catppuccin-frappe",
42    "catppuccin-macchiato",
43    "catppuccin-mocha",
44    "nord",
45    "dracula",
46    "gruvbox",
47    "solarized",
48    "tokyo-night",
49    "one-dark",
50];
51
52pub(crate) fn preset(name: &str) -> Result<NativeTheme> {
53    let toml_str = match name {
54        "default" => DEFAULT_TOML,
55        "kde-breeze" => KDE_BREEZE_TOML,
56        "adwaita" => ADWAITA_TOML,
57        "windows-11" => WINDOWS_11_TOML,
58        "macos-sonoma" => MACOS_SONOMA_TOML,
59        "material" => MATERIAL_TOML,
60        "ios" => IOS_TOML,
61        "catppuccin-latte" => CATPPUCCIN_LATTE_TOML,
62        "catppuccin-frappe" => CATPPUCCIN_FRAPPE_TOML,
63        "catppuccin-macchiato" => CATPPUCCIN_MACCHIATO_TOML,
64        "catppuccin-mocha" => CATPPUCCIN_MOCHA_TOML,
65        "nord" => NORD_TOML,
66        "dracula" => DRACULA_TOML,
67        "gruvbox" => GRUVBOX_TOML,
68        "solarized" => SOLARIZED_TOML,
69        "tokyo-night" => TOKYO_NIGHT_TOML,
70        "one-dark" => ONE_DARK_TOML,
71        _ => return Err(Error::Unavailable(format!("unknown preset: {name}"))),
72    };
73    from_toml(toml_str)
74}
75
76pub(crate) fn list_presets() -> &'static [&'static str] {
77    PRESET_NAMES
78}
79
80pub(crate) fn from_toml(toml_str: &str) -> Result<NativeTheme> {
81    let theme: NativeTheme = toml::from_str(toml_str)?;
82    Ok(theme)
83}
84
85pub(crate) fn from_file(path: impl AsRef<Path>) -> Result<NativeTheme> {
86    let contents = std::fs::read_to_string(path)?;
87    from_toml(&contents)
88}
89
90pub(crate) fn to_toml(theme: &NativeTheme) -> Result<String> {
91    let s = toml::to_string_pretty(theme)?;
92    Ok(s)
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn all_presets_loadable_via_preset_fn() {
101        for name in list_presets() {
102            let theme =
103                preset(name).unwrap_or_else(|e| panic!("preset '{name}' failed to parse: {e}"));
104            assert!(
105                theme.light.is_some(),
106                "preset '{name}' missing light variant"
107            );
108            assert!(theme.dark.is_some(), "preset '{name}' missing dark variant");
109        }
110    }
111
112    #[test]
113    fn all_presets_have_nonempty_core_colors() {
114        for name in list_presets() {
115            let theme = preset(name).unwrap();
116            let light = theme.light.as_ref().unwrap();
117            let dark = theme.dark.as_ref().unwrap();
118
119            assert!(
120                light.colors.accent.is_some(),
121                "preset '{name}' light missing accent"
122            );
123            assert!(
124                light.colors.background.is_some(),
125                "preset '{name}' light missing background"
126            );
127            assert!(
128                light.colors.foreground.is_some(),
129                "preset '{name}' light missing foreground"
130            );
131            assert!(
132                dark.colors.accent.is_some(),
133                "preset '{name}' dark missing accent"
134            );
135            assert!(
136                dark.colors.background.is_some(),
137                "preset '{name}' dark missing background"
138            );
139            assert!(
140                dark.colors.foreground.is_some(),
141                "preset '{name}' dark missing foreground"
142            );
143        }
144    }
145
146    #[test]
147    fn preset_unknown_name_returns_unavailable() {
148        let err = preset("nonexistent").unwrap_err();
149        match err {
150            Error::Unavailable(msg) => assert!(msg.contains("nonexistent")),
151            other => panic!("expected Unavailable, got: {other:?}"),
152        }
153    }
154
155    #[test]
156    fn list_presets_returns_all_seventeen() {
157        let names = list_presets();
158        assert_eq!(names.len(), 17);
159        assert!(names.contains(&"default"));
160        assert!(names.contains(&"kde-breeze"));
161        assert!(names.contains(&"adwaita"));
162        assert!(names.contains(&"windows-11"));
163        assert!(names.contains(&"macos-sonoma"));
164        assert!(names.contains(&"material"));
165        assert!(names.contains(&"ios"));
166        assert!(names.contains(&"catppuccin-latte"));
167        assert!(names.contains(&"catppuccin-frappe"));
168        assert!(names.contains(&"catppuccin-macchiato"));
169        assert!(names.contains(&"catppuccin-mocha"));
170        assert!(names.contains(&"nord"));
171        assert!(names.contains(&"dracula"));
172        assert!(names.contains(&"gruvbox"));
173        assert!(names.contains(&"solarized"));
174        assert!(names.contains(&"tokyo-night"));
175        assert!(names.contains(&"one-dark"));
176    }
177
178    #[test]
179    fn from_toml_minimal_valid() {
180        let toml_str = r##"
181name = "Minimal"
182
183[light.colors]
184accent = "#ff0000"
185"##;
186        let theme = from_toml(toml_str).unwrap();
187        assert_eq!(theme.name, "Minimal");
188        assert!(theme.light.is_some());
189        let light = theme.light.unwrap();
190        assert_eq!(light.colors.accent, Some(crate::Rgba::rgb(255, 0, 0)));
191    }
192
193    #[test]
194    fn from_toml_invalid_returns_format_error() {
195        let err = from_toml("{{{{invalid toml").unwrap_err();
196        match err {
197            Error::Format(_) => {}
198            other => panic!("expected Format, got: {other:?}"),
199        }
200    }
201
202    #[test]
203    fn to_toml_produces_valid_round_trip() {
204        let theme = preset("default").unwrap();
205        let toml_str = to_toml(&theme).unwrap();
206
207        // Must be parseable back into a NativeTheme
208        let reparsed = from_toml(&toml_str).unwrap();
209        assert_eq!(reparsed.name, theme.name);
210        assert!(reparsed.light.is_some());
211        assert!(reparsed.dark.is_some());
212
213        // Core colors should survive the round-trip
214        let orig_light = theme.light.as_ref().unwrap();
215        let new_light = reparsed.light.as_ref().unwrap();
216        assert_eq!(orig_light.colors.accent, new_light.colors.accent);
217    }
218
219    #[test]
220    fn from_file_with_tempfile() {
221        let dir = std::env::temp_dir();
222        let path = dir.join("native_theme_test_preset.toml");
223        let toml_str = r##"
224name = "File Test"
225
226[light.colors]
227accent = "#00ff00"
228"##;
229        std::fs::write(&path, toml_str).unwrap();
230
231        let theme = from_file(&path).unwrap();
232        assert_eq!(theme.name, "File Test");
233        assert!(theme.light.is_some());
234
235        // Clean up
236        let _ = std::fs::remove_file(&path);
237    }
238
239    // === icon_set preset tests ===
240
241    #[test]
242    fn icon_set_native_presets_have_correct_values() {
243        let cases: &[(&str, &str)] = &[
244            ("windows-11", "segoe-fluent"),
245            ("macos-sonoma", "sf-symbols"),
246            ("ios", "sf-symbols"),
247            ("adwaita", "freedesktop"),
248            ("kde-breeze", "freedesktop"),
249            ("material", "material"),
250        ];
251        for (name, expected) in cases {
252            let theme = preset(name).unwrap();
253            let light = theme.light.as_ref().unwrap();
254            assert_eq!(
255                light.icon_set.as_deref(),
256                Some(*expected),
257                "preset '{name}' light.icon_set should be Some(\"{expected}\")"
258            );
259            let dark = theme.dark.as_ref().unwrap();
260            assert_eq!(
261                dark.icon_set.as_deref(),
262                Some(*expected),
263                "preset '{name}' dark.icon_set should be Some(\"{expected}\")"
264            );
265        }
266    }
267
268    #[test]
269    fn icon_set_community_presets_are_none() {
270        let community = &[
271            "catppuccin-latte",
272            "catppuccin-frappe",
273            "catppuccin-macchiato",
274            "catppuccin-mocha",
275            "nord",
276            "dracula",
277            "gruvbox",
278            "solarized",
279            "tokyo-night",
280            "one-dark",
281            "default",
282        ];
283        for name in community {
284            let theme = preset(name).unwrap();
285            let light = theme.light.as_ref().unwrap();
286            assert!(
287                light.icon_set.is_none(),
288                "preset '{name}' light.icon_set should be None"
289            );
290            let dark = theme.dark.as_ref().unwrap();
291            assert!(
292                dark.icon_set.is_none(),
293                "preset '{name}' dark.icon_set should be None"
294            );
295        }
296    }
297
298    #[test]
299    fn from_file_nonexistent_returns_error() {
300        let err = from_file("/tmp/nonexistent_theme_file_12345.toml").unwrap_err();
301        match err {
302            Error::Unavailable(_) => {}
303            other => panic!("expected Unavailable, got: {other:?}"),
304        }
305    }
306
307    #[test]
308    fn preset_names_match_list() {
309        // Every name in list_presets() must be loadable via preset()
310        for name in list_presets() {
311            assert!(preset(name).is_ok(), "preset '{name}' not loadable");
312        }
313    }
314
315    #[test]
316    fn presets_have_correct_names() {
317        assert_eq!(preset("default").unwrap().name, "Default");
318        assert_eq!(preset("kde-breeze").unwrap().name, "KDE Breeze");
319        assert_eq!(preset("adwaita").unwrap().name, "Adwaita");
320        assert_eq!(preset("windows-11").unwrap().name, "Windows 11");
321        assert_eq!(preset("macos-sonoma").unwrap().name, "macOS Sonoma");
322        assert_eq!(preset("material").unwrap().name, "Material");
323        assert_eq!(preset("ios").unwrap().name, "iOS");
324        assert_eq!(preset("catppuccin-latte").unwrap().name, "Catppuccin Latte");
325        assert_eq!(
326            preset("catppuccin-frappe").unwrap().name,
327            "Catppuccin Frappe"
328        );
329        assert_eq!(
330            preset("catppuccin-macchiato").unwrap().name,
331            "Catppuccin Macchiato"
332        );
333        assert_eq!(preset("catppuccin-mocha").unwrap().name, "Catppuccin Mocha");
334        assert_eq!(preset("nord").unwrap().name, "Nord");
335        assert_eq!(preset("dracula").unwrap().name, "Dracula");
336        assert_eq!(preset("gruvbox").unwrap().name, "Gruvbox");
337        assert_eq!(preset("solarized").unwrap().name, "Solarized");
338        assert_eq!(preset("tokyo-night").unwrap().name, "Tokyo Night");
339        assert_eq!(preset("one-dark").unwrap().name, "One Dark");
340    }
341
342    #[test]
343    fn all_presets_with_fonts_have_valid_sizes() {
344        for name in list_presets() {
345            let theme = preset(name).unwrap();
346            for (label, variant) in [
347                ("light", theme.light.as_ref()),
348                ("dark", theme.dark.as_ref()),
349            ] {
350                let variant = variant.unwrap();
351                // Community color themes may omit fonts entirely — skip those.
352                if let Some(size) = variant.fonts.size {
353                    assert!(
354                        size > 0.0,
355                        "preset '{name}' {label} font size must be positive, got {size}"
356                    );
357                }
358                if let Some(mono_size) = variant.fonts.mono_size {
359                    assert!(
360                        mono_size > 0.0,
361                        "preset '{name}' {label} mono font size must be positive, got {mono_size}"
362                    );
363                }
364            }
365        }
366    }
367}