1pub mod bundled;
4pub mod colors;
5pub mod fonts;
6pub mod geometry;
7pub mod icons;
8pub mod spacing;
9pub mod widget_metrics;
10
11pub use bundled::{bundled_icon_by_name, bundled_icon_svg};
12pub use colors::ThemeColors;
13pub use fonts::ThemeFonts;
14pub use geometry::ThemeGeometry;
15pub use icons::{IconData, IconRole, IconSet, icon_name, system_icon_set, system_icon_theme};
16pub use spacing::ThemeSpacing;
17pub use widget_metrics::{
18 ButtonMetrics, CheckboxMetrics, InputMetrics, ListItemMetrics, MenuItemMetrics,
19 ProgressBarMetrics, ScrollbarMetrics, SliderMetrics, SplitterMetrics, TabMetrics,
20 ToolbarMetrics, TooltipMetrics, WidgetMetrics,
21};
22
23use serde::{Deserialize, Serialize};
24
25#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
41#[serde(default)]
42#[non_exhaustive]
43pub struct ThemeVariant {
44 #[serde(default, skip_serializing_if = "ThemeColors::is_empty")]
45 pub colors: ThemeColors,
46
47 #[serde(default, skip_serializing_if = "ThemeFonts::is_empty")]
48 pub fonts: ThemeFonts,
49
50 #[serde(default, skip_serializing_if = "ThemeGeometry::is_empty")]
51 pub geometry: ThemeGeometry,
52
53 #[serde(default, skip_serializing_if = "ThemeSpacing::is_empty")]
54 pub spacing: ThemeSpacing,
55
56 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub widget_metrics: Option<WidgetMetrics>,
63
64 #[serde(default, skip_serializing_if = "Option::is_none", alias = "icon_theme")]
67 pub icon_set: Option<String>,
68}
69
70impl ThemeVariant {
71 pub fn merge(&mut self, overlay: &Self) {
75 self.colors.merge(&overlay.colors);
76 self.fonts.merge(&overlay.fonts);
77 self.geometry.merge(&overlay.geometry);
78 self.spacing.merge(&overlay.spacing);
79
80 match (&mut self.widget_metrics, &overlay.widget_metrics) {
81 (Some(base), Some(over)) => base.merge(over),
82 (None, Some(over)) => self.widget_metrics = Some(over.clone()),
83 _ => {}
84 }
85
86 if overlay.icon_set.is_some() {
87 self.icon_set.clone_from(&overlay.icon_set);
88 }
89 }
90
91 pub fn is_empty(&self) -> bool {
93 self.colors.is_empty()
94 && self.fonts.is_empty()
95 && self.geometry.is_empty()
96 && self.spacing.is_empty()
97 && self.widget_metrics.as_ref().is_none_or(|wm| wm.is_empty())
98 && self.icon_set.is_none()
99 }
100}
101
102#[derive(Clone, Debug, Default, Serialize, Deserialize)]
131#[non_exhaustive]
132#[must_use = "constructing a theme without using it is likely a bug"]
133pub struct NativeTheme {
134 pub name: String,
136
137 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub light: Option<ThemeVariant>,
140
141 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub dark: Option<ThemeVariant>,
144}
145
146impl NativeTheme {
147 pub fn new(name: impl Into<String>) -> Self {
149 Self {
150 name: name.into(),
151 light: None,
152 dark: None,
153 }
154 }
155
156 pub fn merge(&mut self, overlay: &Self) {
163 match (&mut self.light, &overlay.light) {
166 (Some(base), Some(over)) => base.merge(over),
167 (None, Some(over)) => self.light = Some(over.clone()),
168 _ => {}
169 }
170
171 match (&mut self.dark, &overlay.dark) {
172 (Some(base), Some(over)) => base.merge(over),
173 (None, Some(over)) => self.dark = Some(over.clone()),
174 _ => {}
175 }
176 }
177
178 #[must_use = "this returns the selected variant; it does not apply it"]
184 pub fn pick_variant(&self, is_dark: bool) -> Option<&ThemeVariant> {
185 if is_dark {
186 self.dark.as_ref().or(self.light.as_ref())
187 } else {
188 self.light.as_ref().or(self.dark.as_ref())
189 }
190 }
191
192 pub fn is_empty(&self) -> bool {
194 self.light.is_none() && self.dark.is_none()
195 }
196
197 #[must_use = "this returns a theme preset; it does not apply it"]
211 pub fn preset(name: &str) -> crate::Result<Self> {
212 crate::presets::preset(name)
213 }
214
215 #[must_use = "this parses a TOML string into a theme; it does not apply it"]
311 pub fn from_toml(toml_str: &str) -> crate::Result<Self> {
312 crate::presets::from_toml(toml_str)
313 }
314
315 #[must_use = "this loads a theme from a file; it does not apply it"]
325 pub fn from_file(path: impl AsRef<std::path::Path>) -> crate::Result<Self> {
326 crate::presets::from_file(path)
327 }
328
329 #[must_use = "this returns the list of preset names"]
337 pub fn list_presets() -> &'static [&'static str] {
338 crate::presets::list_presets()
339 }
340
341 #[must_use = "this serializes the theme to TOML; it does not write to a file"]
353 pub fn to_toml(&self) -> crate::Result<String> {
354 crate::presets::to_toml(self)
355 }
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361 use crate::Rgba;
362
363 #[test]
366 fn theme_variant_default_is_empty() {
367 assert!(ThemeVariant::default().is_empty());
368 }
369
370 #[test]
371 fn theme_variant_not_empty_when_color_set() {
372 let mut v = ThemeVariant::default();
373 v.colors.accent = Some(Rgba::rgb(0, 120, 215));
374 assert!(!v.is_empty());
375 }
376
377 #[test]
378 fn theme_variant_not_empty_when_font_set() {
379 let mut v = ThemeVariant::default();
380 v.fonts.family = Some("Inter".into());
381 assert!(!v.is_empty());
382 }
383
384 #[test]
385 fn theme_variant_merge_recursively() {
386 let mut base = ThemeVariant::default();
387 base.colors.background = Some(Rgba::rgb(255, 255, 255));
388 base.fonts.family = Some("Noto Sans".into());
389
390 let mut overlay = ThemeVariant::default();
391 overlay.colors.accent = Some(Rgba::rgb(0, 120, 215));
392 overlay.spacing.m = Some(12.0);
393
394 base.merge(&overlay);
395
396 assert_eq!(base.colors.background, Some(Rgba::rgb(255, 255, 255)));
398 assert_eq!(base.colors.accent, Some(Rgba::rgb(0, 120, 215)));
400 assert_eq!(base.fonts.family.as_deref(), Some("Noto Sans"));
402 assert_eq!(base.spacing.m, Some(12.0));
404 }
405
406 #[test]
409 fn native_theme_new_constructor() {
410 let theme = NativeTheme::new("Breeze");
411 assert_eq!(theme.name, "Breeze");
412 assert!(theme.light.is_none());
413 assert!(theme.dark.is_none());
414 }
415
416 #[test]
417 fn native_theme_default_is_empty() {
418 let theme = NativeTheme::default();
419 assert!(theme.is_empty());
420 assert_eq!(theme.name, "");
421 }
422
423 #[test]
424 fn native_theme_merge_keeps_base_name() {
425 let mut base = NativeTheme::new("Base Theme");
426 let overlay = NativeTheme::new("Overlay Theme");
427 base.merge(&overlay);
428 assert_eq!(base.name, "Base Theme");
429 }
430
431 #[test]
432 fn native_theme_merge_overlay_light_into_none() {
433 let mut base = NativeTheme::new("Theme");
434
435 let mut overlay = NativeTheme::new("Overlay");
436 let mut light = ThemeVariant::default();
437 light.colors.accent = Some(Rgba::rgb(0, 120, 215));
438 overlay.light = Some(light);
439
440 base.merge(&overlay);
441
442 assert!(base.light.is_some());
443 assert_eq!(
444 base.light.as_ref().unwrap().colors.accent,
445 Some(Rgba::rgb(0, 120, 215))
446 );
447 }
448
449 #[test]
450 fn native_theme_merge_both_light_variants() {
451 let mut base = NativeTheme::new("Theme");
452 let mut base_light = ThemeVariant::default();
453 base_light.colors.background = Some(Rgba::rgb(255, 255, 255));
454 base.light = Some(base_light);
455
456 let mut overlay = NativeTheme::new("Overlay");
457 let mut overlay_light = ThemeVariant::default();
458 overlay_light.colors.accent = Some(Rgba::rgb(0, 120, 215));
459 overlay.light = Some(overlay_light);
460
461 base.merge(&overlay);
462
463 let light = base.light.as_ref().unwrap();
464 assert_eq!(light.colors.background, Some(Rgba::rgb(255, 255, 255)));
466 assert_eq!(light.colors.accent, Some(Rgba::rgb(0, 120, 215)));
468 }
469
470 #[test]
471 fn native_theme_merge_base_light_only_preserved() {
472 let mut base = NativeTheme::new("Theme");
473 let mut base_light = ThemeVariant::default();
474 base_light.fonts.family = Some("Inter".into());
475 base.light = Some(base_light);
476
477 let overlay = NativeTheme::new("Overlay"); base.merge(&overlay);
480
481 assert!(base.light.is_some());
482 assert_eq!(
483 base.light.as_ref().unwrap().fonts.family.as_deref(),
484 Some("Inter")
485 );
486 }
487
488 #[test]
489 fn native_theme_merge_dark_variant() {
490 let mut base = NativeTheme::new("Theme");
491
492 let mut overlay = NativeTheme::new("Overlay");
493 let mut dark = ThemeVariant::default();
494 dark.colors.background = Some(Rgba::rgb(30, 30, 30));
495 overlay.dark = Some(dark);
496
497 base.merge(&overlay);
498
499 assert!(base.dark.is_some());
500 assert_eq!(
501 base.dark.as_ref().unwrap().colors.background,
502 Some(Rgba::rgb(30, 30, 30))
503 );
504 }
505
506 #[test]
507 fn native_theme_not_empty_with_light() {
508 let mut theme = NativeTheme::new("Theme");
509 theme.light = Some(ThemeVariant::default());
510 assert!(!theme.is_empty());
511 }
512
513 #[test]
516 fn pick_variant_dark_with_both_variants_returns_dark() {
517 let mut theme = NativeTheme::new("Test");
518 let mut light = ThemeVariant::default();
519 light.colors.background = Some(Rgba::rgb(255, 255, 255));
520 theme.light = Some(light);
521 let mut dark = ThemeVariant::default();
522 dark.colors.background = Some(Rgba::rgb(30, 30, 30));
523 theme.dark = Some(dark);
524
525 let picked = theme.pick_variant(true).unwrap();
526 assert_eq!(picked.colors.background, Some(Rgba::rgb(30, 30, 30)));
527 }
528
529 #[test]
530 fn pick_variant_light_with_both_variants_returns_light() {
531 let mut theme = NativeTheme::new("Test");
532 let mut light = ThemeVariant::default();
533 light.colors.background = Some(Rgba::rgb(255, 255, 255));
534 theme.light = Some(light);
535 let mut dark = ThemeVariant::default();
536 dark.colors.background = Some(Rgba::rgb(30, 30, 30));
537 theme.dark = Some(dark);
538
539 let picked = theme.pick_variant(false).unwrap();
540 assert_eq!(picked.colors.background, Some(Rgba::rgb(255, 255, 255)));
541 }
542
543 #[test]
544 fn pick_variant_dark_with_only_light_falls_back() {
545 let mut theme = NativeTheme::new("Test");
546 let mut light = ThemeVariant::default();
547 light.colors.background = Some(Rgba::rgb(255, 255, 255));
548 theme.light = Some(light);
549
550 let picked = theme.pick_variant(true).unwrap();
551 assert_eq!(picked.colors.background, Some(Rgba::rgb(255, 255, 255)));
552 }
553
554 #[test]
555 fn pick_variant_light_with_only_dark_falls_back() {
556 let mut theme = NativeTheme::new("Test");
557 let mut dark = ThemeVariant::default();
558 dark.colors.background = Some(Rgba::rgb(30, 30, 30));
559 theme.dark = Some(dark);
560
561 let picked = theme.pick_variant(false).unwrap();
562 assert_eq!(picked.colors.background, Some(Rgba::rgb(30, 30, 30)));
563 }
564
565 #[test]
566 fn pick_variant_with_no_variants_returns_none() {
567 let theme = NativeTheme::new("Empty");
568 assert!(theme.pick_variant(true).is_none());
569 assert!(theme.pick_variant(false).is_none());
570 }
571
572 #[test]
575 fn icon_set_default_is_none() {
576 assert!(ThemeVariant::default().icon_set.is_none());
577 }
578
579 #[test]
580 fn icon_set_merge_overlay() {
581 let mut base = ThemeVariant::default();
582 let overlay = ThemeVariant {
583 icon_set: Some("material".into()),
584 ..Default::default()
585 };
586 base.merge(&overlay);
587 assert_eq!(base.icon_set.as_deref(), Some("material"));
588 }
589
590 #[test]
591 fn icon_set_merge_none_preserves() {
592 let mut base = ThemeVariant {
593 icon_set: Some("sf-symbols".into()),
594 ..Default::default()
595 };
596 let overlay = ThemeVariant::default();
597 base.merge(&overlay);
598 assert_eq!(base.icon_set.as_deref(), Some("sf-symbols"));
599 }
600
601 #[test]
602 fn icon_set_is_empty_when_set() {
603 assert!(ThemeVariant::default().is_empty());
604 let v = ThemeVariant {
605 icon_set: Some("material".into()),
606 ..Default::default()
607 };
608 assert!(!v.is_empty());
609 }
610
611 #[test]
612 fn icon_set_toml_round_trip() {
613 let variant = ThemeVariant {
614 icon_set: Some("material".into()),
615 ..Default::default()
616 };
617 let toml_str = toml::to_string(&variant).unwrap();
618 assert!(toml_str.contains("icon_set"));
619 let deserialized: ThemeVariant = toml::from_str(&toml_str).unwrap();
620 assert_eq!(deserialized.icon_set.as_deref(), Some("material"));
621 }
622
623 #[test]
624 fn icon_set_toml_alias_backward_compat() {
625 let toml_str = r#"icon_theme = "freedesktop""#;
627 let variant: ThemeVariant = toml::from_str(toml_str).unwrap();
628 assert_eq!(variant.icon_set.as_deref(), Some("freedesktop"));
629 }
630
631 #[test]
632 fn icon_set_toml_absent_deserializes_to_none() {
633 let toml_str = r##"
634[colors]
635accent = "#ff0000"
636"##;
637 let variant: ThemeVariant = toml::from_str(toml_str).unwrap();
638 assert!(variant.icon_set.is_none());
639 }
640
641 #[test]
642 fn native_theme_serde_toml_round_trip() {
643 let mut theme = NativeTheme::new("Test Theme");
644 let mut light = ThemeVariant::default();
645 light.colors.accent = Some(Rgba::rgb(0, 120, 215));
646 light.fonts.family = Some("Segoe UI".into());
647 light.geometry.radius = Some(4.0);
648 light.spacing.m = Some(12.0);
649 theme.light = Some(light);
650
651 let toml_str = toml::to_string(&theme).unwrap();
652 let deserialized: NativeTheme = toml::from_str(&toml_str).unwrap();
653
654 assert_eq!(deserialized.name, "Test Theme");
655 let l = deserialized.light.unwrap();
656 assert_eq!(l.colors.accent, Some(Rgba::rgb(0, 120, 215)));
657 assert_eq!(l.fonts.family.as_deref(), Some("Segoe UI"));
658 assert_eq!(l.geometry.radius, Some(4.0));
659 assert_eq!(l.spacing.m, Some(12.0));
660 }
661}