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::detect_linux_de(&crate::detect::xdg_current_desktop())
138 == crate::detect::LinuxDesktop::Kde
139 {
140 return "linux-kde";
141 }
142 "linux"
143 }
144 #[cfg(target_os = "ios")]
145 {
146 return "ios";
147 }
148 #[cfg(not(any(
149 target_os = "linux",
150 target_os = "windows",
151 target_os = "macos",
152 target_os = "ios"
153 )))]
154 {
155 "linux"
156 }
157}
158
159pub(crate) fn list_presets_for_platform() -> Vec<&'static str> {
164 let platform = detect_platform();
165
166 PRESET_NAMES
167 .iter()
168 .filter(|name| {
169 if let Some((_, platforms)) = PLATFORM_SPECIFIC.iter().find(|(n, _)| n == *name) {
170 platforms.iter().any(|p| platform.starts_with(p))
171 } else {
172 true }
174 })
175 .copied()
176 .collect()
177}
178
179pub(crate) fn from_toml(toml_str: &str) -> Result<ThemeSpec> {
180 let theme: ThemeSpec = toml::from_str(toml_str)?;
181 Ok(theme)
182}
183
184pub(crate) fn from_file(path: impl AsRef<Path>) -> Result<ThemeSpec> {
185 let contents = std::fs::read_to_string(path)?;
186 from_toml(&contents)
187}
188
189pub(crate) fn to_toml(theme: &ThemeSpec) -> Result<String> {
190 let s = toml::to_string_pretty(theme)?;
191 Ok(s)
192}
193
194#[cfg(test)]
195#[allow(clippy::unwrap_used, clippy::expect_used)]
196mod tests {
197 use super::*;
198
199 #[test]
204 fn preset_unknown_name_returns_unavailable() {
205 let err = preset("nonexistent").unwrap_err();
206 match err {
207 Error::Unavailable(msg) => assert!(msg.contains("nonexistent")),
208 other => panic!("expected Unavailable, got: {other:?}"),
209 }
210 }
211
212 #[test]
216 fn from_toml_minimal_valid() {
217 let toml_str = r##"
218name = "Minimal"
219
220[light.defaults]
221accent_color = "#ff0000"
222"##;
223 let theme = from_toml(toml_str).unwrap();
224 assert_eq!(theme.name, "Minimal");
225 assert!(theme.light.is_some());
226 let light = theme.light.unwrap();
227 assert_eq!(
228 light.defaults.accent_color,
229 Some(crate::Rgba::rgb(255, 0, 0))
230 );
231 }
232
233 #[test]
234 fn from_toml_invalid_returns_format_error() {
235 let err = from_toml("{{{{invalid toml").unwrap_err();
236 match err {
237 Error::Format(_) => {}
238 other => panic!("expected Format, got: {other:?}"),
239 }
240 }
241
242 #[test]
243 fn to_toml_produces_valid_round_trip() {
244 let theme = preset("catppuccin-mocha").unwrap();
245 let toml_str = to_toml(&theme).unwrap();
246
247 let reparsed = from_toml(&toml_str).unwrap();
249 assert_eq!(reparsed.name, theme.name);
250 assert!(reparsed.light.is_some());
251 assert!(reparsed.dark.is_some());
252
253 let orig_light = theme.light.as_ref().unwrap();
255 let new_light = reparsed.light.as_ref().unwrap();
256 assert_eq!(
257 orig_light.defaults.accent_color,
258 new_light.defaults.accent_color
259 );
260 }
261
262 #[test]
263 fn from_file_with_tempfile() {
264 let dir = std::env::temp_dir();
265 let path = dir.join("native_theme_test_preset.toml");
266 let toml_str = r##"
267name = "File Test"
268
269[light.defaults]
270accent_color = "#00ff00"
271"##;
272 std::fs::write(&path, toml_str).unwrap();
273
274 let theme = from_file(&path).unwrap();
275 assert_eq!(theme.name, "File Test");
276 assert!(theme.light.is_some());
277
278 let _ = std::fs::remove_file(&path);
280 }
281
282 #[test]
285 fn icon_set_native_presets_have_correct_values() {
286 use crate::IconSet;
287 let cases: &[(&str, IconSet)] = &[
288 ("windows-11", IconSet::SegoeIcons),
289 ("macos-sonoma", IconSet::SfSymbols),
290 ("ios", IconSet::SfSymbols),
291 ("adwaita", IconSet::Freedesktop),
292 ("kde-breeze", IconSet::Freedesktop),
293 ("material", IconSet::Material),
294 ];
295 for (name, expected) in cases {
296 let theme = preset(name).unwrap();
297 let light = theme.light.as_ref().unwrap();
298 assert_eq!(
299 light.icon_set,
300 Some(*expected),
301 "preset '{name}' light.icon_set should be Some({expected:?})"
302 );
303 let dark = theme.dark.as_ref().unwrap();
304 assert_eq!(
305 dark.icon_set,
306 Some(*expected),
307 "preset '{name}' dark.icon_set should be Some({expected:?})"
308 );
309 }
310 }
311
312 #[test]
313 fn icon_set_community_presets_have_lucide() {
314 let community = &[
315 "catppuccin-latte",
316 "catppuccin-frappe",
317 "catppuccin-macchiato",
318 "catppuccin-mocha",
319 "nord",
320 "dracula",
321 "gruvbox",
322 "solarized",
323 "tokyo-night",
324 "one-dark",
325 ];
326 for name in community {
327 let theme = preset(name).unwrap();
328 let light = theme.light.as_ref().unwrap();
329 assert_eq!(
330 light.icon_set,
331 Some(crate::IconSet::Lucide),
332 "preset '{name}' light.icon_set should be Lucide"
333 );
334 let dark = theme.dark.as_ref().unwrap();
335 assert_eq!(
336 dark.icon_set,
337 Some(crate::IconSet::Lucide),
338 "preset '{name}' dark.icon_set should be Lucide"
339 );
340 }
341 }
342
343 #[test]
344 fn icon_set_community_presets_resolve_to_platform_value() {
345 let community = &[
346 "catppuccin-latte",
347 "catppuccin-frappe",
348 "catppuccin-macchiato",
349 "catppuccin-mocha",
350 "nord",
351 "dracula",
352 "gruvbox",
353 "solarized",
354 "tokyo-night",
355 "one-dark",
356 ];
357 for name in community {
358 let theme = preset(name).unwrap();
359 let mut light = theme.light.clone().unwrap();
360 light.resolve_all();
361 assert!(
362 light.icon_set.is_some(),
363 "preset '{name}' light.icon_set should be Some after resolve_all()"
364 );
365 let mut dark = theme.dark.clone().unwrap();
366 dark.resolve_all();
367 assert!(
368 dark.icon_set.is_some(),
369 "preset '{name}' dark.icon_set should be Some after resolve_all()"
370 );
371 }
372 }
373
374 #[test]
375 fn from_file_nonexistent_returns_error() {
376 let err = from_file("/tmp/nonexistent_theme_file_12345.toml").unwrap_err();
377 match err {
378 Error::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::NotFound),
379 other => panic!("expected Io, got: {other:?}"),
380 }
381 }
382
383 #[test]
384 fn preset_names_match_list() {
385 for name in list_presets() {
387 assert!(preset(name).is_ok(), "preset '{name}' not loadable");
388 }
389 }
390
391 #[test]
396 fn platform_presets_no_derived_fields() {
397 let platform_presets = &["kde-breeze", "adwaita", "windows-11", "macos-sonoma"];
399 for name in platform_presets {
400 let theme = preset(name).unwrap();
401 for (label, variant_opt) in [
402 ("light", theme.light.as_ref()),
403 ("dark", theme.dark.as_ref()),
404 ] {
405 let variant = variant_opt.unwrap();
406 assert!(
408 variant.button.primary_background.is_none(),
409 "preset '{name}' {label}.button.primary_background should be None (derived)"
410 );
411 assert!(
413 variant.checkbox.checked_background.is_none(),
414 "preset '{name}' {label}.checkbox.checked_background should be None (derived)"
415 );
416 assert!(
418 variant.slider.fill_color.is_none(),
419 "preset '{name}' {label}.slider.fill_color should be None (derived)"
420 );
421 assert!(
423 variant.progress_bar.fill_color.is_none(),
424 "preset '{name}' {label}.progress_bar.fill_color should be None (derived)"
425 );
426 assert!(
428 variant.switch.checked_background.is_none(),
429 "preset '{name}' {label}.switch.checked_background should be None (derived)"
430 );
431 }
432 }
433 }
434
435 #[test]
438 fn all_presets_resolve_validate() {
439 for name in list_presets() {
440 let theme = preset(name).unwrap();
441 if let Some(mut light) = theme.light.clone() {
442 light.resolve_all();
443 light.validate().unwrap_or_else(|e| {
444 panic!("preset {name} light variant failed validation: {e}");
445 });
446 }
447 if let Some(mut dark) = theme.dark.clone() {
448 dark.resolve_all();
449 dark.validate().unwrap_or_else(|e| {
450 panic!("preset {name} dark variant failed validation: {e}");
451 });
452 }
453 }
454 }
455
456 #[test]
457 fn resolve_fills_accent_derived_fields() {
458 let theme = preset("catppuccin-mocha").unwrap();
461 let mut light = theme.light.clone().unwrap();
462
463 assert!(
465 light.button.primary_background.is_none(),
466 "primary_background should be None pre-resolve"
467 );
468 assert!(
469 light.checkbox.checked_background.is_none(),
470 "checkbox.checked_background should be None pre-resolve"
471 );
472 assert!(
473 light.slider.fill_color.is_none(),
474 "slider.fill should be None pre-resolve"
475 );
476 assert!(
477 light.progress_bar.fill_color.is_none(),
478 "progress_bar.fill should be None pre-resolve"
479 );
480 assert!(
481 light.switch.checked_background.is_none(),
482 "switch.checked_background should be None pre-resolve"
483 );
484
485 light.resolve();
486
487 let accent = light.defaults.accent_color.unwrap();
489 assert_eq!(
490 light.button.primary_background,
491 Some(accent),
492 "button.primary_background should match accent"
493 );
494 assert_eq!(
495 light.checkbox.checked_background,
496 Some(accent),
497 "checkbox.checked_background should match accent"
498 );
499 assert_eq!(
500 light.slider.fill_color,
501 Some(accent),
502 "slider.fill should match accent"
503 );
504 assert_eq!(
505 light.progress_bar.fill_color,
506 Some(accent),
507 "progress_bar.fill should match accent"
508 );
509 assert_eq!(
510 light.switch.checked_background,
511 Some(accent),
512 "switch.checked_background should match accent"
513 );
514 }
515
516 #[test]
517 fn resolve_then_validate_produces_complete_theme() {
518 let theme = preset("catppuccin-mocha").unwrap();
519 let mut light = theme.light.clone().unwrap();
520 light.resolve_all();
521 let resolved = light.validate().unwrap();
522
523 assert_eq!(resolved.defaults.font.family, "Inter");
524 assert_eq!(resolved.defaults.font.size, 14.0);
525 assert_eq!(resolved.defaults.font.weight, 400);
526 assert_eq!(resolved.defaults.line_height, 1.2);
527 assert_eq!(resolved.defaults.border.corner_radius, 8.0);
528 assert_eq!(resolved.defaults.focus_ring_width, 2.0);
529 assert_eq!(resolved.defaults.icon_sizes.toolbar, 24.0);
530 assert_eq!(resolved.defaults.text_scaling_factor, 1.0);
531 assert!(!resolved.defaults.reduce_motion);
532 assert_eq!(
534 resolved.window.background_color,
535 resolved.defaults.background_color
536 );
537 assert_eq!(resolved.icon_set, crate::IconSet::Lucide);
539 }
540
541 #[test]
542 fn font_subfield_inheritance_integration() {
543 let theme = preset("catppuccin-mocha").unwrap();
546 let mut light = theme.light.clone().unwrap();
547
548 use crate::model::FontSpec;
550 use crate::model::font::FontSize;
551 light.menu.font = Some(FontSpec {
552 family: None,
553 size: Some(FontSize::Px(12.0)),
554 weight: None,
555 ..Default::default()
556 });
557
558 light.resolve_all();
559 let resolved = light.validate().unwrap();
560
561 assert_eq!(
563 resolved.menu.font.family, "Inter",
564 "menu font family should inherit from defaults"
565 );
566 assert_eq!(
567 resolved.menu.font.size, 12.0,
568 "menu font size should be the explicit value"
569 );
570 assert_eq!(
571 resolved.menu.font.weight, 400,
572 "menu font weight should inherit from defaults"
573 );
574 }
575
576 #[test]
577 fn text_scale_inheritance_integration() {
578 let theme = preset("catppuccin-mocha").unwrap();
580 let mut light = theme.light.clone().unwrap();
581
582 light.text_scale.caption = None;
584
585 light.resolve_all();
586 let resolved = light.validate().unwrap();
587
588 let expected_size = 14.0;
591 assert!(
592 (resolved.text_scale.caption.size - expected_size).abs() < 0.01,
593 "caption size = defaults.font.size, got {}",
594 resolved.text_scale.caption.size
595 );
596 assert_eq!(
597 resolved.text_scale.caption.weight, 400,
598 "caption weight from defaults.font.weight"
599 );
600 let expected_lh = 1.2 * expected_size;
602 assert!(
603 (resolved.text_scale.caption.line_height - expected_lh).abs() < 0.01,
604 "caption line_height should be line_height_multiplier * caption_size = {expected_lh}, got {}",
605 resolved.text_scale.caption.line_height
606 );
607 }
608
609 #[test]
615 fn live_presets_loadable() {
616 let live_names = &[
617 "kde-breeze-live",
618 "adwaita-live",
619 "macos-sonoma-live",
620 "windows-11-live",
621 ];
622 for name in live_names {
623 let theme = preset(name)
624 .unwrap_or_else(|e| panic!("live preset '{name}' failed to parse: {e}"));
625
626 assert!(
628 theme.light.is_some(),
629 "live preset '{name}' missing light variant"
630 );
631 assert!(
632 theme.dark.is_some(),
633 "live preset '{name}' missing dark variant"
634 );
635
636 let light = theme.light.as_ref().unwrap();
637 let dark = theme.dark.as_ref().unwrap();
638
639 assert!(
641 light.defaults.accent_color.is_none(),
642 "live preset '{name}' light should have no accent"
643 );
644 assert!(
645 light.defaults.background_color.is_none(),
646 "live preset '{name}' light should have no background"
647 );
648 assert!(
649 light.defaults.text_color.is_none(),
650 "live preset '{name}' light should have no foreground"
651 );
652 assert!(
653 dark.defaults.accent_color.is_none(),
654 "live preset '{name}' dark should have no accent"
655 );
656 assert!(
657 dark.defaults.background_color.is_none(),
658 "live preset '{name}' dark should have no background"
659 );
660 assert!(
661 dark.defaults.text_color.is_none(),
662 "live preset '{name}' dark should have no foreground"
663 );
664
665 assert!(
667 light.defaults.font.family.is_none(),
668 "live preset '{name}' light should have no font family"
669 );
670 assert!(
671 light.defaults.font.size.is_none(),
672 "live preset '{name}' light should have no font size"
673 );
674 assert!(
675 light.defaults.font.weight.is_none(),
676 "live preset '{name}' light should have no font weight"
677 );
678 assert!(
679 dark.defaults.font.family.is_none(),
680 "live preset '{name}' dark should have no font family"
681 );
682 assert!(
683 dark.defaults.font.size.is_none(),
684 "live preset '{name}' dark should have no font size"
685 );
686 assert!(
687 dark.defaults.font.weight.is_none(),
688 "live preset '{name}' dark should have no font weight"
689 );
690 }
691 }
692
693 #[test]
694 fn list_presets_for_platform_returns_subset() {
695 let all = list_presets();
696 let filtered = list_presets_for_platform();
697 for name in &filtered {
699 assert!(
700 all.contains(name),
701 "filtered preset '{name}' not in full list"
702 );
703 }
704 let community = &[
706 "catppuccin-latte",
707 "catppuccin-frappe",
708 "catppuccin-macchiato",
709 "catppuccin-mocha",
710 "nord",
711 "dracula",
712 "gruvbox",
713 "solarized",
714 "tokyo-night",
715 "one-dark",
716 ];
717 for name in community {
718 assert!(
719 filtered.contains(name),
720 "community preset '{name}' should always be in filtered list"
721 );
722 }
723 assert!(
725 filtered.contains(&"material"),
726 "material should always be in filtered list"
727 );
728 }
729
730 #[test]
731 fn live_presets_fail_validate_standalone() {
732 let live_names = &[
733 "kde-breeze-live",
734 "adwaita-live",
735 "macos-sonoma-live",
736 "windows-11-live",
737 ];
738 for name in live_names {
739 let theme = preset(name).unwrap();
740 let mut light = theme.light.clone().unwrap();
741 light.resolve();
742 let result = light.validate();
743 assert!(
744 result.is_err(),
745 "live preset '{name}' light should fail validation standalone (missing colors/fonts)"
746 );
747
748 let mut dark = theme.dark.clone().unwrap();
749 dark.resolve();
750 let result = dark.validate();
751 assert!(
752 result.is_err(),
753 "live preset '{name}' dark should fail validation standalone (missing colors/fonts)"
754 );
755 }
756 }
757}