1pub mod animated;
5pub mod bundled;
7pub mod colors;
9pub mod fonts;
11pub mod geometry;
13pub mod icons;
15pub mod spacing;
17pub mod widget_metrics;
19
20pub use animated::{AnimatedIcon, Repeat, TransformAnimation};
21pub use bundled::{bundled_icon_by_name, bundled_icon_svg};
22pub use colors::ThemeColors;
23pub use fonts::ThemeFonts;
24pub use geometry::ThemeGeometry;
25pub use icons::{
26 IconData, IconProvider, IconRole, IconSet, icon_name, system_icon_set, system_icon_theme,
27};
28pub use spacing::ThemeSpacing;
29pub use widget_metrics::{
30 ButtonMetrics, CheckboxMetrics, InputMetrics, ListItemMetrics, MenuItemMetrics,
31 ProgressBarMetrics, ScrollbarMetrics, SliderMetrics, SplitterMetrics, TabMetrics,
32 ToolbarMetrics, TooltipMetrics, WidgetMetrics,
33};
34
35use serde::{Deserialize, Serialize};
36
37#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
53#[serde(default)]
54#[non_exhaustive]
55pub struct ThemeVariant {
56 #[serde(default, skip_serializing_if = "ThemeColors::is_empty")]
58 pub colors: ThemeColors,
59
60 #[serde(default, skip_serializing_if = "ThemeFonts::is_empty")]
62 pub fonts: ThemeFonts,
63
64 #[serde(default, skip_serializing_if = "ThemeGeometry::is_empty")]
66 pub geometry: ThemeGeometry,
67
68 #[serde(default, skip_serializing_if = "ThemeSpacing::is_empty")]
70 pub spacing: ThemeSpacing,
71
72 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub widget_metrics: Option<WidgetMetrics>,
79
80 #[serde(default, skip_serializing_if = "Option::is_none", alias = "icon_theme")]
83 pub icon_set: Option<String>,
84}
85
86impl ThemeVariant {
87 pub fn merge(&mut self, overlay: &Self) {
91 self.colors.merge(&overlay.colors);
92 self.fonts.merge(&overlay.fonts);
93 self.geometry.merge(&overlay.geometry);
94 self.spacing.merge(&overlay.spacing);
95
96 match (&mut self.widget_metrics, &overlay.widget_metrics) {
97 (Some(base), Some(over)) => base.merge(over),
98 (None, Some(over)) => self.widget_metrics = Some(over.clone()),
99 _ => {}
100 }
101
102 if overlay.icon_set.is_some() {
103 self.icon_set.clone_from(&overlay.icon_set);
104 }
105 }
106
107 pub fn is_empty(&self) -> bool {
109 self.colors.is_empty()
110 && self.fonts.is_empty()
111 && self.geometry.is_empty()
112 && self.spacing.is_empty()
113 && self.widget_metrics.as_ref().is_none_or(|wm| wm.is_empty())
114 && self.icon_set.is_none()
115 }
116}
117
118#[derive(Clone, Debug, Default, Serialize, Deserialize)]
147#[non_exhaustive]
148#[must_use = "constructing a theme without using it is likely a bug"]
149pub struct NativeTheme {
150 pub name: String,
152
153 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub light: Option<ThemeVariant>,
156
157 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub dark: Option<ThemeVariant>,
160}
161
162impl NativeTheme {
163 pub fn new(name: impl Into<String>) -> Self {
165 Self {
166 name: name.into(),
167 light: None,
168 dark: None,
169 }
170 }
171
172 pub fn merge(&mut self, overlay: &Self) {
179 match (&mut self.light, &overlay.light) {
182 (Some(base), Some(over)) => base.merge(over),
183 (None, Some(over)) => self.light = Some(over.clone()),
184 _ => {}
185 }
186
187 match (&mut self.dark, &overlay.dark) {
188 (Some(base), Some(over)) => base.merge(over),
189 (None, Some(over)) => self.dark = Some(over.clone()),
190 _ => {}
191 }
192 }
193
194 #[must_use = "this returns the selected variant; it does not apply it"]
200 pub fn pick_variant(&self, is_dark: bool) -> Option<&ThemeVariant> {
201 if is_dark {
202 self.dark.as_ref().or(self.light.as_ref())
203 } else {
204 self.light.as_ref().or(self.dark.as_ref())
205 }
206 }
207
208 pub fn is_empty(&self) -> bool {
210 self.light.is_none() && self.dark.is_none()
211 }
212
213 #[must_use = "this returns a theme preset; it does not apply it"]
227 pub fn preset(name: &str) -> crate::Result<Self> {
228 crate::presets::preset(name)
229 }
230
231 #[must_use = "this parses a TOML string into a theme; it does not apply it"]
327 pub fn from_toml(toml_str: &str) -> crate::Result<Self> {
328 crate::presets::from_toml(toml_str)
329 }
330
331 #[must_use = "this loads a theme from a file; it does not apply it"]
341 pub fn from_file(path: impl AsRef<std::path::Path>) -> crate::Result<Self> {
342 crate::presets::from_file(path)
343 }
344
345 #[must_use = "this returns the list of preset names"]
353 pub fn list_presets() -> &'static [&'static str] {
354 crate::presets::list_presets()
355 }
356
357 #[must_use = "this serializes the theme to TOML; it does not write to a file"]
369 pub fn to_toml(&self) -> crate::Result<String> {
370 crate::presets::to_toml(self)
371 }
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377 use crate::Rgba;
378
379 #[test]
382 fn theme_variant_default_is_empty() {
383 assert!(ThemeVariant::default().is_empty());
384 }
385
386 #[test]
387 fn theme_variant_not_empty_when_color_set() {
388 let mut v = ThemeVariant::default();
389 v.colors.accent = Some(Rgba::rgb(0, 120, 215));
390 assert!(!v.is_empty());
391 }
392
393 #[test]
394 fn theme_variant_not_empty_when_font_set() {
395 let mut v = ThemeVariant::default();
396 v.fonts.family = Some("Inter".into());
397 assert!(!v.is_empty());
398 }
399
400 #[test]
401 fn theme_variant_merge_recursively() {
402 let mut base = ThemeVariant::default();
403 base.colors.background = Some(Rgba::rgb(255, 255, 255));
404 base.fonts.family = Some("Noto Sans".into());
405
406 let mut overlay = ThemeVariant::default();
407 overlay.colors.accent = Some(Rgba::rgb(0, 120, 215));
408 overlay.spacing.m = Some(12.0);
409
410 base.merge(&overlay);
411
412 assert_eq!(base.colors.background, Some(Rgba::rgb(255, 255, 255)));
414 assert_eq!(base.colors.accent, Some(Rgba::rgb(0, 120, 215)));
416 assert_eq!(base.fonts.family.as_deref(), Some("Noto Sans"));
418 assert_eq!(base.spacing.m, Some(12.0));
420 }
421
422 #[test]
425 fn native_theme_new_constructor() {
426 let theme = NativeTheme::new("Breeze");
427 assert_eq!(theme.name, "Breeze");
428 assert!(theme.light.is_none());
429 assert!(theme.dark.is_none());
430 }
431
432 #[test]
433 fn native_theme_default_is_empty() {
434 let theme = NativeTheme::default();
435 assert!(theme.is_empty());
436 assert_eq!(theme.name, "");
437 }
438
439 #[test]
440 fn native_theme_merge_keeps_base_name() {
441 let mut base = NativeTheme::new("Base Theme");
442 let overlay = NativeTheme::new("Overlay Theme");
443 base.merge(&overlay);
444 assert_eq!(base.name, "Base Theme");
445 }
446
447 #[test]
448 fn native_theme_merge_overlay_light_into_none() {
449 let mut base = NativeTheme::new("Theme");
450
451 let mut overlay = NativeTheme::new("Overlay");
452 let mut light = ThemeVariant::default();
453 light.colors.accent = Some(Rgba::rgb(0, 120, 215));
454 overlay.light = Some(light);
455
456 base.merge(&overlay);
457
458 assert!(base.light.is_some());
459 assert_eq!(
460 base.light.as_ref().unwrap().colors.accent,
461 Some(Rgba::rgb(0, 120, 215))
462 );
463 }
464
465 #[test]
466 fn native_theme_merge_both_light_variants() {
467 let mut base = NativeTheme::new("Theme");
468 let mut base_light = ThemeVariant::default();
469 base_light.colors.background = Some(Rgba::rgb(255, 255, 255));
470 base.light = Some(base_light);
471
472 let mut overlay = NativeTheme::new("Overlay");
473 let mut overlay_light = ThemeVariant::default();
474 overlay_light.colors.accent = Some(Rgba::rgb(0, 120, 215));
475 overlay.light = Some(overlay_light);
476
477 base.merge(&overlay);
478
479 let light = base.light.as_ref().unwrap();
480 assert_eq!(light.colors.background, Some(Rgba::rgb(255, 255, 255)));
482 assert_eq!(light.colors.accent, Some(Rgba::rgb(0, 120, 215)));
484 }
485
486 #[test]
487 fn native_theme_merge_base_light_only_preserved() {
488 let mut base = NativeTheme::new("Theme");
489 let mut base_light = ThemeVariant::default();
490 base_light.fonts.family = Some("Inter".into());
491 base.light = Some(base_light);
492
493 let overlay = NativeTheme::new("Overlay"); base.merge(&overlay);
496
497 assert!(base.light.is_some());
498 assert_eq!(
499 base.light.as_ref().unwrap().fonts.family.as_deref(),
500 Some("Inter")
501 );
502 }
503
504 #[test]
505 fn native_theme_merge_dark_variant() {
506 let mut base = NativeTheme::new("Theme");
507
508 let mut overlay = NativeTheme::new("Overlay");
509 let mut dark = ThemeVariant::default();
510 dark.colors.background = Some(Rgba::rgb(30, 30, 30));
511 overlay.dark = Some(dark);
512
513 base.merge(&overlay);
514
515 assert!(base.dark.is_some());
516 assert_eq!(
517 base.dark.as_ref().unwrap().colors.background,
518 Some(Rgba::rgb(30, 30, 30))
519 );
520 }
521
522 #[test]
523 fn native_theme_not_empty_with_light() {
524 let mut theme = NativeTheme::new("Theme");
525 theme.light = Some(ThemeVariant::default());
526 assert!(!theme.is_empty());
527 }
528
529 #[test]
532 fn pick_variant_dark_with_both_variants_returns_dark() {
533 let mut theme = NativeTheme::new("Test");
534 let mut light = ThemeVariant::default();
535 light.colors.background = Some(Rgba::rgb(255, 255, 255));
536 theme.light = Some(light);
537 let mut dark = ThemeVariant::default();
538 dark.colors.background = Some(Rgba::rgb(30, 30, 30));
539 theme.dark = Some(dark);
540
541 let picked = theme.pick_variant(true).unwrap();
542 assert_eq!(picked.colors.background, Some(Rgba::rgb(30, 30, 30)));
543 }
544
545 #[test]
546 fn pick_variant_light_with_both_variants_returns_light() {
547 let mut theme = NativeTheme::new("Test");
548 let mut light = ThemeVariant::default();
549 light.colors.background = Some(Rgba::rgb(255, 255, 255));
550 theme.light = Some(light);
551 let mut dark = ThemeVariant::default();
552 dark.colors.background = Some(Rgba::rgb(30, 30, 30));
553 theme.dark = Some(dark);
554
555 let picked = theme.pick_variant(false).unwrap();
556 assert_eq!(picked.colors.background, Some(Rgba::rgb(255, 255, 255)));
557 }
558
559 #[test]
560 fn pick_variant_dark_with_only_light_falls_back() {
561 let mut theme = NativeTheme::new("Test");
562 let mut light = ThemeVariant::default();
563 light.colors.background = Some(Rgba::rgb(255, 255, 255));
564 theme.light = Some(light);
565
566 let picked = theme.pick_variant(true).unwrap();
567 assert_eq!(picked.colors.background, Some(Rgba::rgb(255, 255, 255)));
568 }
569
570 #[test]
571 fn pick_variant_light_with_only_dark_falls_back() {
572 let mut theme = NativeTheme::new("Test");
573 let mut dark = ThemeVariant::default();
574 dark.colors.background = Some(Rgba::rgb(30, 30, 30));
575 theme.dark = Some(dark);
576
577 let picked = theme.pick_variant(false).unwrap();
578 assert_eq!(picked.colors.background, Some(Rgba::rgb(30, 30, 30)));
579 }
580
581 #[test]
582 fn pick_variant_with_no_variants_returns_none() {
583 let theme = NativeTheme::new("Empty");
584 assert!(theme.pick_variant(true).is_none());
585 assert!(theme.pick_variant(false).is_none());
586 }
587
588 #[test]
591 fn icon_set_default_is_none() {
592 assert!(ThemeVariant::default().icon_set.is_none());
593 }
594
595 #[test]
596 fn icon_set_merge_overlay() {
597 let mut base = ThemeVariant::default();
598 let overlay = ThemeVariant {
599 icon_set: Some("material".into()),
600 ..Default::default()
601 };
602 base.merge(&overlay);
603 assert_eq!(base.icon_set.as_deref(), Some("material"));
604 }
605
606 #[test]
607 fn icon_set_merge_none_preserves() {
608 let mut base = ThemeVariant {
609 icon_set: Some("sf-symbols".into()),
610 ..Default::default()
611 };
612 let overlay = ThemeVariant::default();
613 base.merge(&overlay);
614 assert_eq!(base.icon_set.as_deref(), Some("sf-symbols"));
615 }
616
617 #[test]
618 fn icon_set_is_empty_when_set() {
619 assert!(ThemeVariant::default().is_empty());
620 let v = ThemeVariant {
621 icon_set: Some("material".into()),
622 ..Default::default()
623 };
624 assert!(!v.is_empty());
625 }
626
627 #[test]
628 fn icon_set_toml_round_trip() {
629 let variant = ThemeVariant {
630 icon_set: Some("material".into()),
631 ..Default::default()
632 };
633 let toml_str = toml::to_string(&variant).unwrap();
634 assert!(toml_str.contains("icon_set"));
635 let deserialized: ThemeVariant = toml::from_str(&toml_str).unwrap();
636 assert_eq!(deserialized.icon_set.as_deref(), Some("material"));
637 }
638
639 #[test]
640 fn icon_set_toml_alias_backward_compat() {
641 let toml_str = r#"icon_theme = "freedesktop""#;
643 let variant: ThemeVariant = toml::from_str(toml_str).unwrap();
644 assert_eq!(variant.icon_set.as_deref(), Some("freedesktop"));
645 }
646
647 #[test]
648 fn icon_set_toml_absent_deserializes_to_none() {
649 let toml_str = r##"
650[colors]
651accent = "#ff0000"
652"##;
653 let variant: ThemeVariant = toml::from_str(toml_str).unwrap();
654 assert!(variant.icon_set.is_none());
655 }
656
657 #[test]
658 fn native_theme_serde_toml_round_trip() {
659 let mut theme = NativeTheme::new("Test Theme");
660 let mut light = ThemeVariant::default();
661 light.colors.accent = Some(Rgba::rgb(0, 120, 215));
662 light.fonts.family = Some("Segoe UI".into());
663 light.geometry.radius = Some(4.0);
664 light.spacing.m = Some(12.0);
665 theme.light = Some(light);
666
667 let toml_str = toml::to_string(&theme).unwrap();
668 let deserialized: NativeTheme = toml::from_str(&toml_str).unwrap();
669
670 assert_eq!(deserialized.name, "Test Theme");
671 let l = deserialized.light.unwrap();
672 assert_eq!(l.colors.accent, Some(Rgba::rgb(0, 120, 215)));
673 assert_eq!(l.fonts.family.as_deref(), Some("Segoe UI"));
674 assert_eq!(l.geometry.radius, Some(4.0));
675 assert_eq!(l.spacing.m, Some(12.0));
676 }
677}