1pub mod animated;
5pub mod bundled;
7pub mod defaults;
9pub mod dialog_order;
11pub mod font;
13pub mod icon_sizes;
15pub mod icons;
17pub mod resolved;
19pub mod spacing;
21pub mod widgets;
23
24pub use animated::{AnimatedIcon, TransformAnimation};
25pub use bundled::{bundled_icon_by_name, bundled_icon_svg};
26pub use defaults::ThemeDefaults;
27pub use dialog_order::DialogButtonOrder;
28pub use font::{FontSpec, ResolvedFontSpec, TextScale, TextScaleEntry};
29pub use icon_sizes::IconSizes;
30pub use icons::{
31 IconData, IconProvider, IconRole, IconSet, icon_name, system_icon_set, system_icon_theme,
32};
33pub use resolved::{
34 ResolvedIconSizes, ResolvedTextScale, ResolvedTextScaleEntry, ResolvedThemeDefaults,
35 ResolvedThemeSpacing, ResolvedThemeVariant,
36};
37pub use spacing::ThemeSpacing;
38pub use widgets::*; use serde::{Deserialize, Serialize};
41
42#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
58#[serde(default)]
59pub struct ThemeVariant {
60 #[serde(default, skip_serializing_if = "ThemeDefaults::is_empty")]
62 pub defaults: ThemeDefaults,
63
64 #[serde(default, skip_serializing_if = "TextScale::is_empty")]
66 pub text_scale: TextScale,
67
68 #[serde(default, skip_serializing_if = "WindowTheme::is_empty")]
70 pub window: WindowTheme,
71
72 #[serde(default, skip_serializing_if = "ButtonTheme::is_empty")]
74 pub button: ButtonTheme,
75
76 #[serde(default, skip_serializing_if = "InputTheme::is_empty")]
78 pub input: InputTheme,
79
80 #[serde(default, skip_serializing_if = "CheckboxTheme::is_empty")]
82 pub checkbox: CheckboxTheme,
83
84 #[serde(default, skip_serializing_if = "MenuTheme::is_empty")]
86 pub menu: MenuTheme,
87
88 #[serde(default, skip_serializing_if = "TooltipTheme::is_empty")]
90 pub tooltip: TooltipTheme,
91
92 #[serde(default, skip_serializing_if = "ScrollbarTheme::is_empty")]
94 pub scrollbar: ScrollbarTheme,
95
96 #[serde(default, skip_serializing_if = "SliderTheme::is_empty")]
98 pub slider: SliderTheme,
99
100 #[serde(default, skip_serializing_if = "ProgressBarTheme::is_empty")]
102 pub progress_bar: ProgressBarTheme,
103
104 #[serde(default, skip_serializing_if = "TabTheme::is_empty")]
106 pub tab: TabTheme,
107
108 #[serde(default, skip_serializing_if = "SidebarTheme::is_empty")]
110 pub sidebar: SidebarTheme,
111
112 #[serde(default, skip_serializing_if = "ToolbarTheme::is_empty")]
114 pub toolbar: ToolbarTheme,
115
116 #[serde(default, skip_serializing_if = "StatusBarTheme::is_empty")]
118 pub status_bar: StatusBarTheme,
119
120 #[serde(default, skip_serializing_if = "ListTheme::is_empty")]
122 pub list: ListTheme,
123
124 #[serde(default, skip_serializing_if = "PopoverTheme::is_empty")]
126 pub popover: PopoverTheme,
127
128 #[serde(default, skip_serializing_if = "SplitterTheme::is_empty")]
130 pub splitter: SplitterTheme,
131
132 #[serde(default, skip_serializing_if = "SeparatorTheme::is_empty")]
134 pub separator: SeparatorTheme,
135
136 #[serde(default, skip_serializing_if = "SwitchTheme::is_empty")]
138 pub switch: SwitchTheme,
139
140 #[serde(default, skip_serializing_if = "DialogTheme::is_empty")]
142 pub dialog: DialogTheme,
143
144 #[serde(default, skip_serializing_if = "SpinnerTheme::is_empty")]
146 pub spinner: SpinnerTheme,
147
148 #[serde(default, skip_serializing_if = "ComboBoxTheme::is_empty")]
150 pub combo_box: ComboBoxTheme,
151
152 #[serde(default, skip_serializing_if = "SegmentedControlTheme::is_empty")]
154 pub segmented_control: SegmentedControlTheme,
155
156 #[serde(default, skip_serializing_if = "CardTheme::is_empty")]
158 pub card: CardTheme,
159
160 #[serde(default, skip_serializing_if = "ExpanderTheme::is_empty")]
162 pub expander: ExpanderTheme,
163
164 #[serde(default, skip_serializing_if = "LinkTheme::is_empty")]
166 pub link: LinkTheme,
167
168 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub icon_set: Option<IconSet>,
172
173 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub icon_theme: Option<String>,
178}
179
180impl_merge!(ThemeVariant {
181 option { icon_set, icon_theme }
182 nested {
183 defaults, text_scale, window, button, input, checkbox, menu,
184 tooltip, scrollbar, slider, progress_bar, tab, sidebar,
185 toolbar, status_bar, list, popover, splitter, separator,
186 switch, dialog, spinner, combo_box, segmented_control,
187 card, expander, link
188 }
189});
190
191#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
220#[must_use = "constructing a theme without using it is likely a bug"]
221pub struct ThemeSpec {
222 pub name: String,
224
225 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub light: Option<ThemeVariant>,
228
229 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub dark: Option<ThemeVariant>,
232}
233
234impl ThemeSpec {
235 pub fn new(name: impl Into<String>) -> Self {
237 Self {
238 name: name.into(),
239 light: None,
240 dark: None,
241 }
242 }
243
244 pub fn merge(&mut self, overlay: &Self) {
251 match (&mut self.light, &overlay.light) {
254 (Some(base), Some(over)) => base.merge(over),
255 (None, Some(over)) => self.light = Some(over.clone()),
256 _ => {}
257 }
258
259 match (&mut self.dark, &overlay.dark) {
260 (Some(base), Some(over)) => base.merge(over),
261 (None, Some(over)) => self.dark = Some(over.clone()),
262 _ => {}
263 }
264 }
265
266 #[must_use = "this returns the selected variant; it does not apply it"]
272 pub fn pick_variant(&self, is_dark: bool) -> Option<&ThemeVariant> {
273 if is_dark {
274 self.dark.as_ref().or(self.light.as_ref())
275 } else {
276 self.light.as_ref().or(self.dark.as_ref())
277 }
278 }
279
280 #[must_use = "this returns the extracted variant; it does not apply it"]
297 pub fn into_variant(self, is_dark: bool) -> Option<ThemeVariant> {
298 if is_dark {
299 self.dark.or(self.light)
300 } else {
301 self.light.or(self.dark)
302 }
303 }
304
305 pub fn is_empty(&self) -> bool {
307 self.light.is_none() && self.dark.is_none()
308 }
309
310 #[must_use = "this returns a theme preset; it does not apply it"]
324 pub fn preset(name: &str) -> crate::Result<Self> {
325 crate::presets::preset(name)
326 }
327
328 #[must_use = "this parses a TOML string into a theme; it does not apply it"]
410 pub fn from_toml(toml_str: &str) -> crate::Result<Self> {
411 crate::presets::from_toml(toml_str)
412 }
413
414 #[must_use = "this loads a theme from a file; it does not apply it"]
424 pub fn from_file(path: impl AsRef<std::path::Path>) -> crate::Result<Self> {
425 crate::presets::from_file(path)
426 }
427
428 #[must_use = "this returns the list of preset names"]
436 pub fn list_presets() -> &'static [&'static str] {
437 crate::presets::list_presets()
438 }
439
440 #[must_use = "this returns the filtered list of preset names for this platform"]
453 pub fn list_presets_for_platform() -> Vec<&'static str> {
454 crate::presets::list_presets_for_platform()
455 }
456
457 #[must_use = "this serializes the theme to TOML; it does not write to a file"]
469 pub fn to_toml(&self) -> crate::Result<String> {
470 crate::presets::to_toml(self)
471 }
472}
473
474#[cfg(test)]
475#[allow(clippy::unwrap_used, clippy::expect_used)]
476mod tests {
477 use super::*;
478 use crate::Rgba;
479
480 #[test]
483 fn theme_variant_default_is_empty() {
484 assert!(ThemeVariant::default().is_empty());
485 }
486
487 #[test]
488 fn theme_variant_not_empty_when_color_set() {
489 let mut v = ThemeVariant::default();
490 v.defaults.accent = Some(Rgba::rgb(0, 120, 215));
491 assert!(!v.is_empty());
492 }
493
494 #[test]
495 fn theme_variant_not_empty_when_font_set() {
496 let mut v = ThemeVariant::default();
497 v.defaults.font.family = Some("Inter".into());
498 assert!(!v.is_empty());
499 }
500
501 #[test]
502 fn theme_variant_merge_recursively() {
503 let mut base = ThemeVariant::default();
504 base.defaults.background = Some(Rgba::rgb(255, 255, 255));
505 base.defaults.font.family = Some("Noto Sans".into());
506
507 let mut overlay = ThemeVariant::default();
508 overlay.defaults.accent = Some(Rgba::rgb(0, 120, 215));
509 overlay.defaults.spacing.m = Some(12.0);
510
511 base.merge(&overlay);
512
513 assert_eq!(base.defaults.background, Some(Rgba::rgb(255, 255, 255)));
515 assert_eq!(base.defaults.accent, Some(Rgba::rgb(0, 120, 215)));
517 assert_eq!(base.defaults.font.family.as_deref(), Some("Noto Sans"));
519 assert_eq!(base.defaults.spacing.m, Some(12.0));
521 }
522
523 #[test]
524 fn theme_variant_has_all_widgets() {
525 let mut v = ThemeVariant::default();
526 v.window.radius = Some(4.0);
528 v.button.min_height = Some(32.0);
529 v.input.min_height = Some(32.0);
530 v.checkbox.indicator_size = Some(18.0);
531 v.menu.item_height = Some(28.0);
532 v.tooltip.padding_horizontal = Some(6.0);
533 v.scrollbar.width = Some(14.0);
534 v.slider.track_height = Some(4.0);
535 v.progress_bar.height = Some(6.0);
536 v.tab.min_height = Some(32.0);
537 v.sidebar.background = Some(Rgba::rgb(240, 240, 240));
538 v.toolbar.height = Some(40.0);
539 v.status_bar.font = Some(crate::model::FontSpec::default());
540 v.list.item_height = Some(28.0);
541 v.popover.radius = Some(6.0);
542 v.splitter.width = Some(4.0);
543 v.separator.color = Some(Rgba::rgb(200, 200, 200));
544 v.switch.track_width = Some(32.0);
545 v.dialog.min_width = Some(320.0);
546 v.spinner.diameter = Some(24.0);
547 v.combo_box.min_height = Some(32.0);
548 v.segmented_control.segment_height = Some(28.0);
549 v.card.radius = Some(8.0);
550 v.expander.header_height = Some(32.0);
551 v.link.underline = Some(true);
552
553 assert!(!v.is_empty());
554 assert!(!v.window.is_empty());
555 assert!(!v.button.is_empty());
556 assert!(!v.input.is_empty());
557 assert!(!v.checkbox.is_empty());
558 assert!(!v.menu.is_empty());
559 assert!(!v.tooltip.is_empty());
560 assert!(!v.scrollbar.is_empty());
561 assert!(!v.slider.is_empty());
562 assert!(!v.progress_bar.is_empty());
563 assert!(!v.tab.is_empty());
564 assert!(!v.sidebar.is_empty());
565 assert!(!v.toolbar.is_empty());
566 assert!(!v.status_bar.is_empty());
567 assert!(!v.list.is_empty());
568 assert!(!v.popover.is_empty());
569 assert!(!v.splitter.is_empty());
570 assert!(!v.separator.is_empty());
571 assert!(!v.switch.is_empty());
572 assert!(!v.dialog.is_empty());
573 assert!(!v.spinner.is_empty());
574 assert!(!v.combo_box.is_empty());
575 assert!(!v.segmented_control.is_empty());
576 assert!(!v.card.is_empty());
577 assert!(!v.expander.is_empty());
578 assert!(!v.link.is_empty());
579 }
580
581 #[test]
582 fn theme_variant_merge_per_widget() {
583 let mut base = ThemeVariant::default();
584 base.button.background = Some(Rgba::rgb(200, 200, 200));
585 base.button.foreground = Some(Rgba::rgb(0, 0, 0));
586 base.tooltip.background = Some(Rgba::rgb(50, 50, 50));
587
588 let mut overlay = ThemeVariant::default();
589 overlay.button.background = Some(Rgba::rgb(255, 255, 255));
590 overlay.button.min_height = Some(32.0);
591
592 base.merge(&overlay);
593
594 assert_eq!(base.button.background, Some(Rgba::rgb(255, 255, 255)));
596 assert_eq!(base.button.min_height, Some(32.0));
598 assert_eq!(base.button.foreground, Some(Rgba::rgb(0, 0, 0)));
600 assert_eq!(base.tooltip.background, Some(Rgba::rgb(50, 50, 50)));
602 }
603
604 #[test]
607 fn native_theme_new_constructor() {
608 let theme = ThemeSpec::new("Breeze");
609 assert_eq!(theme.name, "Breeze");
610 assert!(theme.light.is_none());
611 assert!(theme.dark.is_none());
612 }
613
614 #[test]
615 fn native_theme_default_is_empty() {
616 let theme = ThemeSpec::default();
617 assert!(theme.is_empty());
618 assert_eq!(theme.name, "");
619 }
620
621 #[test]
622 fn native_theme_merge_keeps_base_name() {
623 let mut base = ThemeSpec::new("Base Theme");
624 let overlay = ThemeSpec::new("Overlay Theme");
625 base.merge(&overlay);
626 assert_eq!(base.name, "Base Theme");
627 }
628
629 #[test]
630 fn native_theme_merge_overlay_light_into_none() {
631 let mut base = ThemeSpec::new("Theme");
632
633 let mut overlay = ThemeSpec::new("Overlay");
634 let mut light = ThemeVariant::default();
635 light.defaults.accent = Some(Rgba::rgb(0, 120, 215));
636 overlay.light = Some(light);
637
638 base.merge(&overlay);
639
640 assert!(base.light.is_some());
641 assert_eq!(
642 base.light.as_ref().unwrap().defaults.accent,
643 Some(Rgba::rgb(0, 120, 215))
644 );
645 }
646
647 #[test]
648 fn native_theme_merge_both_light_variants() {
649 let mut base = ThemeSpec::new("Theme");
650 let mut base_light = ThemeVariant::default();
651 base_light.defaults.background = Some(Rgba::rgb(255, 255, 255));
652 base.light = Some(base_light);
653
654 let mut overlay = ThemeSpec::new("Overlay");
655 let mut overlay_light = ThemeVariant::default();
656 overlay_light.defaults.accent = Some(Rgba::rgb(0, 120, 215));
657 overlay.light = Some(overlay_light);
658
659 base.merge(&overlay);
660
661 let light = base.light.as_ref().unwrap();
662 assert_eq!(light.defaults.background, Some(Rgba::rgb(255, 255, 255)));
664 assert_eq!(light.defaults.accent, Some(Rgba::rgb(0, 120, 215)));
666 }
667
668 #[test]
669 fn native_theme_merge_base_light_only_preserved() {
670 let mut base = ThemeSpec::new("Theme");
671 let mut base_light = ThemeVariant::default();
672 base_light.defaults.font.family = Some("Inter".into());
673 base.light = Some(base_light);
674
675 let overlay = ThemeSpec::new("Overlay"); base.merge(&overlay);
678
679 assert!(base.light.is_some());
680 assert_eq!(
681 base.light.as_ref().unwrap().defaults.font.family.as_deref(),
682 Some("Inter")
683 );
684 }
685
686 #[test]
687 fn native_theme_merge_dark_variant() {
688 let mut base = ThemeSpec::new("Theme");
689
690 let mut overlay = ThemeSpec::new("Overlay");
691 let mut dark = ThemeVariant::default();
692 dark.defaults.background = Some(Rgba::rgb(30, 30, 30));
693 overlay.dark = Some(dark);
694
695 base.merge(&overlay);
696
697 assert!(base.dark.is_some());
698 assert_eq!(
699 base.dark.as_ref().unwrap().defaults.background,
700 Some(Rgba::rgb(30, 30, 30))
701 );
702 }
703
704 #[test]
705 fn native_theme_not_empty_with_light() {
706 let mut theme = ThemeSpec::new("Theme");
707 theme.light = Some(ThemeVariant::default());
708 assert!(!theme.is_empty());
709 }
710
711 #[test]
714 fn pick_variant_dark_with_both_variants_returns_dark() {
715 let mut theme = ThemeSpec::new("Test");
716 let mut light = ThemeVariant::default();
717 light.defaults.background = Some(Rgba::rgb(255, 255, 255));
718 theme.light = Some(light);
719 let mut dark = ThemeVariant::default();
720 dark.defaults.background = Some(Rgba::rgb(30, 30, 30));
721 theme.dark = Some(dark);
722
723 let picked = theme.pick_variant(true).unwrap();
724 assert_eq!(picked.defaults.background, Some(Rgba::rgb(30, 30, 30)));
725 }
726
727 #[test]
728 fn pick_variant_light_with_both_variants_returns_light() {
729 let mut theme = ThemeSpec::new("Test");
730 let mut light = ThemeVariant::default();
731 light.defaults.background = Some(Rgba::rgb(255, 255, 255));
732 theme.light = Some(light);
733 let mut dark = ThemeVariant::default();
734 dark.defaults.background = Some(Rgba::rgb(30, 30, 30));
735 theme.dark = Some(dark);
736
737 let picked = theme.pick_variant(false).unwrap();
738 assert_eq!(picked.defaults.background, Some(Rgba::rgb(255, 255, 255)));
739 }
740
741 #[test]
742 fn pick_variant_dark_with_only_light_falls_back() {
743 let mut theme = ThemeSpec::new("Test");
744 let mut light = ThemeVariant::default();
745 light.defaults.background = Some(Rgba::rgb(255, 255, 255));
746 theme.light = Some(light);
747
748 let picked = theme.pick_variant(true).unwrap();
749 assert_eq!(picked.defaults.background, Some(Rgba::rgb(255, 255, 255)));
750 }
751
752 #[test]
753 fn pick_variant_light_with_only_dark_falls_back() {
754 let mut theme = ThemeSpec::new("Test");
755 let mut dark = ThemeVariant::default();
756 dark.defaults.background = Some(Rgba::rgb(30, 30, 30));
757 theme.dark = Some(dark);
758
759 let picked = theme.pick_variant(false).unwrap();
760 assert_eq!(picked.defaults.background, Some(Rgba::rgb(30, 30, 30)));
761 }
762
763 #[test]
764 fn pick_variant_with_no_variants_returns_none() {
765 let theme = ThemeSpec::new("Empty");
766 assert!(theme.pick_variant(true).is_none());
767 assert!(theme.pick_variant(false).is_none());
768 }
769
770 #[test]
773 fn icon_set_default_is_none() {
774 assert!(ThemeVariant::default().icon_set.is_none());
775 }
776
777 #[test]
778 fn icon_set_merge_overlay() {
779 let mut base = ThemeVariant::default();
780 let overlay = ThemeVariant {
781 icon_set: Some(IconSet::Material),
782 ..Default::default()
783 };
784 base.merge(&overlay);
785 assert_eq!(base.icon_set, Some(IconSet::Material));
786 }
787
788 #[test]
789 fn icon_set_merge_none_preserves() {
790 let mut base = ThemeVariant {
791 icon_set: Some(IconSet::SfSymbols),
792 ..Default::default()
793 };
794 let overlay = ThemeVariant::default();
795 base.merge(&overlay);
796 assert_eq!(base.icon_set, Some(IconSet::SfSymbols));
797 }
798
799 #[test]
800 fn icon_set_is_empty_when_set() {
801 assert!(ThemeVariant::default().is_empty());
802 let v = ThemeVariant {
803 icon_set: Some(IconSet::Material),
804 ..Default::default()
805 };
806 assert!(!v.is_empty());
807 }
808
809 #[test]
810 fn icon_set_toml_round_trip() {
811 let variant = ThemeVariant {
812 icon_set: Some(IconSet::Material),
813 ..Default::default()
814 };
815 let toml_str = toml::to_string(&variant).unwrap();
816 assert!(toml_str.contains("icon_set"));
817 let deserialized: ThemeVariant = toml::from_str(&toml_str).unwrap();
818 assert_eq!(deserialized.icon_set, Some(IconSet::Material));
819 }
820
821 #[test]
822 fn icon_set_toml_absent_deserializes_to_none() {
823 let toml_str = r##"
824[defaults]
825accent = "#ff0000"
826"##;
827 let variant: ThemeVariant = toml::from_str(toml_str).unwrap();
828 assert!(variant.icon_set.is_none());
829 }
830
831 #[test]
832 fn native_theme_serde_toml_round_trip() {
833 let mut theme = ThemeSpec::new("Test Theme");
834 let mut light = ThemeVariant::default();
835 light.defaults.accent = Some(Rgba::rgb(0, 120, 215));
836 light.defaults.font.family = Some("Segoe UI".into());
837 light.defaults.radius = Some(4.0);
838 light.defaults.spacing.m = Some(12.0);
839 theme.light = Some(light);
840
841 let toml_str = toml::to_string(&theme).unwrap();
842 let deserialized: ThemeSpec = toml::from_str(&toml_str).unwrap();
843
844 assert_eq!(deserialized.name, "Test Theme");
845 let l = deserialized.light.unwrap();
846 assert_eq!(l.defaults.accent, Some(Rgba::rgb(0, 120, 215)));
847 assert_eq!(l.defaults.font.family.as_deref(), Some("Segoe UI"));
848 assert_eq!(l.defaults.radius, Some(4.0));
849 assert_eq!(l.defaults.spacing.m, Some(12.0));
850 }
851}