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