1use crate::{Error, NativeTheme, Result};
10use std::path::Path;
11
12const 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
31const 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 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 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 let _ = std::fs::remove_file(&path);
237 }
238
239 #[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 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 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}