1use crate::{Error, Result, ThemeSpec};
11use std::collections::HashMap;
12use std::path::Path;
13use std::sync::LazyLock;
14
15const PRESET_ENTRIES: &[(&str, &str)] = &[
19 ("kde-breeze", include_str!("presets/kde-breeze.toml")),
21 ("adwaita", include_str!("presets/adwaita.toml")),
22 ("windows-11", include_str!("presets/windows-11.toml")),
23 ("macos-sonoma", include_str!("presets/macos-sonoma.toml")),
24 ("material", include_str!("presets/material.toml")),
25 ("ios", include_str!("presets/ios.toml")),
26 (
28 "catppuccin-latte",
29 include_str!("presets/catppuccin-latte.toml"),
30 ),
31 (
32 "catppuccin-frappe",
33 include_str!("presets/catppuccin-frappe.toml"),
34 ),
35 (
36 "catppuccin-macchiato",
37 include_str!("presets/catppuccin-macchiato.toml"),
38 ),
39 (
40 "catppuccin-mocha",
41 include_str!("presets/catppuccin-mocha.toml"),
42 ),
43 ("nord", include_str!("presets/nord.toml")),
44 ("dracula", include_str!("presets/dracula.toml")),
45 ("gruvbox", include_str!("presets/gruvbox.toml")),
46 ("solarized", include_str!("presets/solarized.toml")),
47 ("tokyo-night", include_str!("presets/tokyo-night.toml")),
48 ("one-dark", include_str!("presets/one-dark.toml")),
49 (
51 "kde-breeze-live",
52 include_str!("presets/kde-breeze-live.toml"),
53 ),
54 ("adwaita-live", include_str!("presets/adwaita-live.toml")),
55 (
56 "macos-sonoma-live",
57 include_str!("presets/macos-sonoma-live.toml"),
58 ),
59 (
60 "windows-11-live",
61 include_str!("presets/windows-11-live.toml"),
62 ),
63];
64
65const PRESET_NAMES: &[&str] = &[
67 "kde-breeze",
68 "adwaita",
69 "windows-11",
70 "macos-sonoma",
71 "material",
72 "ios",
73 "catppuccin-latte",
74 "catppuccin-frappe",
75 "catppuccin-macchiato",
76 "catppuccin-mocha",
77 "nord",
78 "dracula",
79 "gruvbox",
80 "solarized",
81 "tokyo-night",
82 "one-dark",
83];
84
85type Parsed = std::result::Result<ThemeSpec, String>;
89
90fn parse(toml_str: &str) -> Parsed {
91 from_toml(toml_str).map_err(|e| e.to_string())
92}
93
94static CACHE: LazyLock<HashMap<&str, Parsed>> = LazyLock::new(|| {
95 PRESET_ENTRIES
96 .iter()
97 .map(|(name, toml_str)| (*name, parse(toml_str)))
98 .collect()
99});
100
101pub(crate) fn preset(name: &str) -> Result<ThemeSpec> {
102 match CACHE.get(name) {
103 None => Err(Error::Unavailable(format!("unknown preset: {name}"))),
104 Some(Ok(theme)) => Ok(theme.clone()),
105 Some(Err(msg)) => Err(Error::Format(format!("bundled preset '{name}': {msg}"))),
106 }
107}
108
109pub(crate) fn list_presets() -> &'static [&'static str] {
110 PRESET_NAMES
111}
112
113const PLATFORM_SPECIFIC: &[(&str, &[&str])] = &[
115 ("kde-breeze", &["linux-kde"]),
116 ("adwaita", &["linux"]),
117 ("windows-11", &["windows"]),
118 ("macos-sonoma", &["macos"]),
119 ("ios", &["macos", "ios"]),
120];
121
122#[allow(unreachable_code)]
126fn detect_platform() -> &'static str {
127 #[cfg(target_os = "macos")]
128 {
129 return "macos";
130 }
131 #[cfg(target_os = "windows")]
132 {
133 return "windows";
134 }
135 #[cfg(target_os = "linux")]
136 {
137 if crate::detect_linux_de(&crate::xdg_current_desktop()) == crate::LinuxDesktop::Kde {
138 return "linux-kde";
139 }
140 "linux"
141 }
142 #[cfg(target_os = "ios")]
143 {
144 return "ios";
145 }
146 #[cfg(not(any(
147 target_os = "linux",
148 target_os = "windows",
149 target_os = "macos",
150 target_os = "ios"
151 )))]
152 {
153 "linux"
154 }
155}
156
157pub(crate) fn list_presets_for_platform() -> Vec<&'static str> {
162 let platform = detect_platform();
163
164 PRESET_NAMES
165 .iter()
166 .filter(|name| {
167 if let Some((_, platforms)) = PLATFORM_SPECIFIC.iter().find(|(n, _)| n == *name) {
168 platforms.iter().any(|p| platform.starts_with(p))
169 } else {
170 true }
172 })
173 .copied()
174 .collect()
175}
176
177pub(crate) fn from_toml(toml_str: &str) -> Result<ThemeSpec> {
178 let theme: ThemeSpec = toml::from_str(toml_str)?;
179 Ok(theme)
180}
181
182pub(crate) fn from_file(path: impl AsRef<Path>) -> Result<ThemeSpec> {
183 let contents = std::fs::read_to_string(path)?;
184 from_toml(&contents)
185}
186
187pub(crate) fn to_toml(theme: &ThemeSpec) -> Result<String> {
188 let s = toml::to_string_pretty(theme)?;
189 Ok(s)
190}
191
192#[cfg(test)]
193#[allow(clippy::unwrap_used, clippy::expect_used)]
194mod tests {
195 use super::*;
196
197 #[test]
202 fn preset_unknown_name_returns_unavailable() {
203 let err = preset("nonexistent").unwrap_err();
204 match err {
205 Error::Unavailable(msg) => assert!(msg.contains("nonexistent")),
206 other => panic!("expected Unavailable, got: {other:?}"),
207 }
208 }
209
210 #[test]
214 fn from_toml_minimal_valid() {
215 let toml_str = r##"
216name = "Minimal"
217
218[light.defaults]
219accent_color = "#ff0000"
220"##;
221 let theme = from_toml(toml_str).unwrap();
222 assert_eq!(theme.name, "Minimal");
223 assert!(theme.light.is_some());
224 let light = theme.light.unwrap();
225 assert_eq!(
226 light.defaults.accent_color,
227 Some(crate::Rgba::rgb(255, 0, 0))
228 );
229 }
230
231 #[test]
232 fn from_toml_invalid_returns_format_error() {
233 let err = from_toml("{{{{invalid toml").unwrap_err();
234 match err {
235 Error::Format(_) => {}
236 other => panic!("expected Format, got: {other:?}"),
237 }
238 }
239
240 #[test]
241 fn to_toml_produces_valid_round_trip() {
242 let theme = preset("catppuccin-mocha").unwrap();
243 let toml_str = to_toml(&theme).unwrap();
244
245 let reparsed = from_toml(&toml_str).unwrap();
247 assert_eq!(reparsed.name, theme.name);
248 assert!(reparsed.light.is_some());
249 assert!(reparsed.dark.is_some());
250
251 let orig_light = theme.light.as_ref().unwrap();
253 let new_light = reparsed.light.as_ref().unwrap();
254 assert_eq!(
255 orig_light.defaults.accent_color,
256 new_light.defaults.accent_color
257 );
258 }
259
260 #[test]
261 fn from_file_with_tempfile() {
262 let dir = std::env::temp_dir();
263 let path = dir.join("native_theme_test_preset.toml");
264 let toml_str = r##"
265name = "File Test"
266
267[light.defaults]
268accent_color = "#00ff00"
269"##;
270 std::fs::write(&path, toml_str).unwrap();
271
272 let theme = from_file(&path).unwrap();
273 assert_eq!(theme.name, "File Test");
274 assert!(theme.light.is_some());
275
276 let _ = std::fs::remove_file(&path);
278 }
279
280 #[test]
283 fn icon_set_native_presets_have_correct_values() {
284 use crate::IconSet;
285 let cases: &[(&str, IconSet)] = &[
286 ("windows-11", IconSet::SegoeIcons),
287 ("macos-sonoma", IconSet::SfSymbols),
288 ("ios", IconSet::SfSymbols),
289 ("adwaita", IconSet::Freedesktop),
290 ("kde-breeze", IconSet::Freedesktop),
291 ("material", IconSet::Material),
292 ];
293 for (name, expected) in cases {
294 let theme = preset(name).unwrap();
295 let light = theme.light.as_ref().unwrap();
296 assert_eq!(
297 light.icon_set,
298 Some(*expected),
299 "preset '{name}' light.icon_set should be Some({expected:?})"
300 );
301 let dark = theme.dark.as_ref().unwrap();
302 assert_eq!(
303 dark.icon_set,
304 Some(*expected),
305 "preset '{name}' dark.icon_set should be Some({expected:?})"
306 );
307 }
308 }
309
310 #[test]
311 fn icon_set_community_presets_have_lucide() {
312 let community = &[
313 "catppuccin-latte",
314 "catppuccin-frappe",
315 "catppuccin-macchiato",
316 "catppuccin-mocha",
317 "nord",
318 "dracula",
319 "gruvbox",
320 "solarized",
321 "tokyo-night",
322 "one-dark",
323 ];
324 for name in community {
325 let theme = preset(name).unwrap();
326 let light = theme.light.as_ref().unwrap();
327 assert_eq!(
328 light.icon_set,
329 Some(crate::IconSet::Lucide),
330 "preset '{name}' light.icon_set should be Lucide"
331 );
332 let dark = theme.dark.as_ref().unwrap();
333 assert_eq!(
334 dark.icon_set,
335 Some(crate::IconSet::Lucide),
336 "preset '{name}' dark.icon_set should be Lucide"
337 );
338 }
339 }
340
341 #[test]
342 fn icon_set_community_presets_resolve_to_platform_value() {
343 let community = &[
344 "catppuccin-latte",
345 "catppuccin-frappe",
346 "catppuccin-macchiato",
347 "catppuccin-mocha",
348 "nord",
349 "dracula",
350 "gruvbox",
351 "solarized",
352 "tokyo-night",
353 "one-dark",
354 ];
355 for name in community {
356 let theme = preset(name).unwrap();
357 let mut light = theme.light.clone().unwrap();
358 light.resolve_all();
359 assert!(
360 light.icon_set.is_some(),
361 "preset '{name}' light.icon_set should be Some after resolve_all()"
362 );
363 let mut dark = theme.dark.clone().unwrap();
364 dark.resolve_all();
365 assert!(
366 dark.icon_set.is_some(),
367 "preset '{name}' dark.icon_set should be Some after resolve_all()"
368 );
369 }
370 }
371
372 #[test]
373 fn from_file_nonexistent_returns_error() {
374 let err = from_file("/tmp/nonexistent_theme_file_12345.toml").unwrap_err();
375 match err {
376 Error::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::NotFound),
377 other => panic!("expected Io, got: {other:?}"),
378 }
379 }
380
381 #[test]
382 fn preset_names_match_list() {
383 for name in list_presets() {
385 assert!(preset(name).is_ok(), "preset '{name}' not loadable");
386 }
387 }
388
389 #[test]
394 fn platform_presets_no_derived_fields() {
395 let platform_presets = &["kde-breeze", "adwaita", "windows-11", "macos-sonoma"];
397 for name in platform_presets {
398 let theme = preset(name).unwrap();
399 for (label, variant_opt) in [
400 ("light", theme.light.as_ref()),
401 ("dark", theme.dark.as_ref()),
402 ] {
403 let variant = variant_opt.unwrap();
404 assert!(
406 variant.button.primary_background.is_none(),
407 "preset '{name}' {label}.button.primary_background should be None (derived)"
408 );
409 assert!(
411 variant.checkbox.checked_background.is_none(),
412 "preset '{name}' {label}.checkbox.checked_background should be None (derived)"
413 );
414 assert!(
416 variant.slider.fill_color.is_none(),
417 "preset '{name}' {label}.slider.fill_color should be None (derived)"
418 );
419 assert!(
421 variant.progress_bar.fill_color.is_none(),
422 "preset '{name}' {label}.progress_bar.fill_color should be None (derived)"
423 );
424 assert!(
426 variant.switch.checked_background.is_none(),
427 "preset '{name}' {label}.switch.checked_background should be None (derived)"
428 );
429 }
430 }
431 }
432
433 #[test]
436 fn all_presets_resolve_validate() {
437 for name in list_presets() {
438 let theme = preset(name).unwrap();
439 if let Some(mut light) = theme.light.clone() {
440 light.resolve_all();
441 light.validate().unwrap_or_else(|e| {
442 panic!("preset {name} light variant failed validation: {e}");
443 });
444 }
445 if let Some(mut dark) = theme.dark.clone() {
446 dark.resolve_all();
447 dark.validate().unwrap_or_else(|e| {
448 panic!("preset {name} dark variant failed validation: {e}");
449 });
450 }
451 }
452 }
453
454 #[test]
455 fn resolve_fills_accent_derived_fields() {
456 let theme = preset("catppuccin-mocha").unwrap();
459 let mut light = theme.light.clone().unwrap();
460
461 assert!(
463 light.button.primary_background.is_none(),
464 "primary_background should be None pre-resolve"
465 );
466 assert!(
467 light.checkbox.checked_background.is_none(),
468 "checkbox.checked_background should be None pre-resolve"
469 );
470 assert!(
471 light.slider.fill_color.is_none(),
472 "slider.fill should be None pre-resolve"
473 );
474 assert!(
475 light.progress_bar.fill_color.is_none(),
476 "progress_bar.fill should be None pre-resolve"
477 );
478 assert!(
479 light.switch.checked_background.is_none(),
480 "switch.checked_background should be None pre-resolve"
481 );
482
483 light.resolve();
484
485 let accent = light.defaults.accent_color.unwrap();
487 assert_eq!(
488 light.button.primary_background,
489 Some(accent),
490 "button.primary_background should match accent"
491 );
492 assert_eq!(
493 light.checkbox.checked_background,
494 Some(accent),
495 "checkbox.checked_background should match accent"
496 );
497 assert_eq!(
498 light.slider.fill_color,
499 Some(accent),
500 "slider.fill should match accent"
501 );
502 assert_eq!(
503 light.progress_bar.fill_color,
504 Some(accent),
505 "progress_bar.fill should match accent"
506 );
507 assert_eq!(
508 light.switch.checked_background,
509 Some(accent),
510 "switch.checked_background should match accent"
511 );
512 }
513
514 #[test]
515 fn resolve_then_validate_produces_complete_theme() {
516 let theme = preset("catppuccin-mocha").unwrap();
517 let mut light = theme.light.clone().unwrap();
518 light.resolve_all();
519 let resolved = light.validate().unwrap();
520
521 assert_eq!(resolved.defaults.font.family, "Inter");
522 assert_eq!(resolved.defaults.font.size, 14.0);
523 assert_eq!(resolved.defaults.font.weight, 400);
524 assert_eq!(resolved.defaults.line_height, 1.2);
525 assert_eq!(resolved.defaults.border.corner_radius, 8.0);
526 assert_eq!(resolved.defaults.focus_ring_width, 2.0);
527 assert_eq!(resolved.defaults.icon_sizes.toolbar, 24.0);
528 assert_eq!(resolved.defaults.text_scaling_factor, 1.0);
529 assert!(!resolved.defaults.reduce_motion);
530 assert_eq!(
532 resolved.window.background_color,
533 resolved.defaults.background_color
534 );
535 assert_eq!(resolved.icon_set, crate::IconSet::Lucide);
537 }
538
539 #[test]
540 fn font_subfield_inheritance_integration() {
541 let theme = preset("catppuccin-mocha").unwrap();
544 let mut light = theme.light.clone().unwrap();
545
546 use crate::model::FontSpec;
548 use crate::model::font::FontSize;
549 light.menu.font = Some(FontSpec {
550 family: None,
551 size: Some(FontSize::Px(12.0)),
552 weight: None,
553 ..Default::default()
554 });
555
556 light.resolve_all();
557 let resolved = light.validate().unwrap();
558
559 assert_eq!(
561 resolved.menu.font.family, "Inter",
562 "menu font family should inherit from defaults"
563 );
564 assert_eq!(
565 resolved.menu.font.size, 12.0,
566 "menu font size should be the explicit value"
567 );
568 assert_eq!(
569 resolved.menu.font.weight, 400,
570 "menu font weight should inherit from defaults"
571 );
572 }
573
574 #[test]
575 fn text_scale_inheritance_integration() {
576 let theme = preset("catppuccin-mocha").unwrap();
578 let mut light = theme.light.clone().unwrap();
579
580 light.text_scale.caption = None;
582
583 light.resolve_all();
584 let resolved = light.validate().unwrap();
585
586 let expected_size = 14.0;
589 assert!(
590 (resolved.text_scale.caption.size - expected_size).abs() < 0.01,
591 "caption size = defaults.font.size, got {}",
592 resolved.text_scale.caption.size
593 );
594 assert_eq!(
595 resolved.text_scale.caption.weight, 400,
596 "caption weight from defaults.font.weight"
597 );
598 let expected_lh = 1.2 * expected_size;
600 assert!(
601 (resolved.text_scale.caption.line_height - expected_lh).abs() < 0.01,
602 "caption line_height should be line_height_multiplier * caption_size = {expected_lh}, got {}",
603 resolved.text_scale.caption.line_height
604 );
605 }
606
607 #[test]
613 fn live_presets_loadable() {
614 let live_names = &[
615 "kde-breeze-live",
616 "adwaita-live",
617 "macos-sonoma-live",
618 "windows-11-live",
619 ];
620 for name in live_names {
621 let theme = preset(name)
622 .unwrap_or_else(|e| panic!("live preset '{name}' failed to parse: {e}"));
623
624 assert!(
626 theme.light.is_some(),
627 "live preset '{name}' missing light variant"
628 );
629 assert!(
630 theme.dark.is_some(),
631 "live preset '{name}' missing dark variant"
632 );
633
634 let light = theme.light.as_ref().unwrap();
635 let dark = theme.dark.as_ref().unwrap();
636
637 assert!(
639 light.defaults.accent_color.is_none(),
640 "live preset '{name}' light should have no accent"
641 );
642 assert!(
643 light.defaults.background_color.is_none(),
644 "live preset '{name}' light should have no background"
645 );
646 assert!(
647 light.defaults.text_color.is_none(),
648 "live preset '{name}' light should have no foreground"
649 );
650 assert!(
651 dark.defaults.accent_color.is_none(),
652 "live preset '{name}' dark should have no accent"
653 );
654 assert!(
655 dark.defaults.background_color.is_none(),
656 "live preset '{name}' dark should have no background"
657 );
658 assert!(
659 dark.defaults.text_color.is_none(),
660 "live preset '{name}' dark should have no foreground"
661 );
662
663 assert!(
665 light.defaults.font.family.is_none(),
666 "live preset '{name}' light should have no font family"
667 );
668 assert!(
669 light.defaults.font.size.is_none(),
670 "live preset '{name}' light should have no font size"
671 );
672 assert!(
673 light.defaults.font.weight.is_none(),
674 "live preset '{name}' light should have no font weight"
675 );
676 assert!(
677 dark.defaults.font.family.is_none(),
678 "live preset '{name}' dark should have no font family"
679 );
680 assert!(
681 dark.defaults.font.size.is_none(),
682 "live preset '{name}' dark should have no font size"
683 );
684 assert!(
685 dark.defaults.font.weight.is_none(),
686 "live preset '{name}' dark should have no font weight"
687 );
688 }
689 }
690
691 #[test]
692 fn list_presets_for_platform_returns_subset() {
693 let all = list_presets();
694 let filtered = list_presets_for_platform();
695 for name in &filtered {
697 assert!(
698 all.contains(name),
699 "filtered preset '{name}' not in full list"
700 );
701 }
702 let community = &[
704 "catppuccin-latte",
705 "catppuccin-frappe",
706 "catppuccin-macchiato",
707 "catppuccin-mocha",
708 "nord",
709 "dracula",
710 "gruvbox",
711 "solarized",
712 "tokyo-night",
713 "one-dark",
714 ];
715 for name in community {
716 assert!(
717 filtered.contains(name),
718 "community preset '{name}' should always be in filtered list"
719 );
720 }
721 assert!(
723 filtered.contains(&"material"),
724 "material should always be in filtered list"
725 );
726 }
727
728 #[test]
729 fn live_presets_fail_validate_standalone() {
730 let live_names = &[
731 "kde-breeze-live",
732 "adwaita-live",
733 "macos-sonoma-live",
734 "windows-11-live",
735 ];
736 for name in live_names {
737 let theme = preset(name).unwrap();
738 let mut light = theme.light.clone().unwrap();
739 light.resolve();
740 let result = light.validate();
741 assert!(
742 result.is_err(),
743 "live preset '{name}' light should fail validation standalone (missing colors/fonts)"
744 );
745
746 let mut dark = theme.dark.clone().unwrap();
747 dark.resolve();
748 let result = dark.validate();
749 assert!(
750 result.is_err(),
751 "live preset '{name}' dark should fail validation standalone (missing colors/fonts)"
752 );
753 }
754 }
755}