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, Repeat, TransformAnimation};
25pub use bundled::{bundled_icon_by_name, bundled_icon_svg};
26pub use defaults::ThemeDefaults;
27pub use dialog_order::DialogButtonOrder;
28pub use font::{FontSpec, 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 ResolvedDefaults, ResolvedIconSizes, ResolvedSpacing, ResolvedTextScale,
35 ResolvedTextScaleEntry, ResolvedTheme,
36};
37pub use spacing::ThemeSpacing;
38pub use widgets::*; use serde::{Deserialize, Serialize};
41
42#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
58#[serde(default)]
59#[non_exhaustive]
60pub struct ThemeVariant {
61 #[serde(default, skip_serializing_if = "ThemeDefaults::is_empty")]
63 pub defaults: ThemeDefaults,
64
65 #[serde(default, skip_serializing_if = "TextScale::is_empty")]
67 pub text_scale: TextScale,
68
69 #[serde(default, skip_serializing_if = "WindowTheme::is_empty")]
71 pub window: WindowTheme,
72
73 #[serde(default, skip_serializing_if = "ButtonTheme::is_empty")]
75 pub button: ButtonTheme,
76
77 #[serde(default, skip_serializing_if = "InputTheme::is_empty")]
79 pub input: InputTheme,
80
81 #[serde(default, skip_serializing_if = "CheckboxTheme::is_empty")]
83 pub checkbox: CheckboxTheme,
84
85 #[serde(default, skip_serializing_if = "MenuTheme::is_empty")]
87 pub menu: MenuTheme,
88
89 #[serde(default, skip_serializing_if = "TooltipTheme::is_empty")]
91 pub tooltip: TooltipTheme,
92
93 #[serde(default, skip_serializing_if = "ScrollbarTheme::is_empty")]
95 pub scrollbar: ScrollbarTheme,
96
97 #[serde(default, skip_serializing_if = "SliderTheme::is_empty")]
99 pub slider: SliderTheme,
100
101 #[serde(default, skip_serializing_if = "ProgressBarTheme::is_empty")]
103 pub progress_bar: ProgressBarTheme,
104
105 #[serde(default, skip_serializing_if = "TabTheme::is_empty")]
107 pub tab: TabTheme,
108
109 #[serde(default, skip_serializing_if = "SidebarTheme::is_empty")]
111 pub sidebar: SidebarTheme,
112
113 #[serde(default, skip_serializing_if = "ToolbarTheme::is_empty")]
115 pub toolbar: ToolbarTheme,
116
117 #[serde(default, skip_serializing_if = "StatusBarTheme::is_empty")]
119 pub status_bar: StatusBarTheme,
120
121 #[serde(default, skip_serializing_if = "ListTheme::is_empty")]
123 pub list: ListTheme,
124
125 #[serde(default, skip_serializing_if = "PopoverTheme::is_empty")]
127 pub popover: PopoverTheme,
128
129 #[serde(default, skip_serializing_if = "SplitterTheme::is_empty")]
131 pub splitter: SplitterTheme,
132
133 #[serde(default, skip_serializing_if = "SeparatorTheme::is_empty")]
135 pub separator: SeparatorTheme,
136
137 #[serde(default, skip_serializing_if = "SwitchTheme::is_empty")]
139 pub switch: SwitchTheme,
140
141 #[serde(default, skip_serializing_if = "DialogTheme::is_empty")]
143 pub dialog: DialogTheme,
144
145 #[serde(default, skip_serializing_if = "SpinnerTheme::is_empty")]
147 pub spinner: SpinnerTheme,
148
149 #[serde(default, skip_serializing_if = "ComboBoxTheme::is_empty")]
151 pub combo_box: ComboBoxTheme,
152
153 #[serde(default, skip_serializing_if = "SegmentedControlTheme::is_empty")]
155 pub segmented_control: SegmentedControlTheme,
156
157 #[serde(default, skip_serializing_if = "CardTheme::is_empty")]
159 pub card: CardTheme,
160
161 #[serde(default, skip_serializing_if = "ExpanderTheme::is_empty")]
163 pub expander: ExpanderTheme,
164
165 #[serde(default, skip_serializing_if = "LinkTheme::is_empty")]
167 pub link: LinkTheme,
168
169 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub icon_set: Option<String>,
173}
174
175impl_merge!(ThemeVariant {
176 option { icon_set }
177 nested {
178 defaults, text_scale, window, button, input, checkbox, menu,
179 tooltip, scrollbar, slider, progress_bar, tab, sidebar,
180 toolbar, status_bar, list, popover, splitter, separator,
181 switch, dialog, spinner, combo_box, segmented_control,
182 card, expander, link
183 }
184});
185
186#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
215#[non_exhaustive]
216#[must_use = "constructing a theme without using it is likely a bug"]
217pub struct NativeTheme {
218 pub name: String,
220
221 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub light: Option<ThemeVariant>,
224
225 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub dark: Option<ThemeVariant>,
228}
229
230impl NativeTheme {
231 pub fn new(name: impl Into<String>) -> Self {
233 Self {
234 name: name.into(),
235 light: None,
236 dark: None,
237 }
238 }
239
240 pub fn merge(&mut self, overlay: &Self) {
247 match (&mut self.light, &overlay.light) {
250 (Some(base), Some(over)) => base.merge(over),
251 (None, Some(over)) => self.light = Some(over.clone()),
252 _ => {}
253 }
254
255 match (&mut self.dark, &overlay.dark) {
256 (Some(base), Some(over)) => base.merge(over),
257 (None, Some(over)) => self.dark = Some(over.clone()),
258 _ => {}
259 }
260 }
261
262 #[must_use = "this returns the selected variant; it does not apply it"]
268 pub fn pick_variant(&self, is_dark: bool) -> Option<&ThemeVariant> {
269 if is_dark {
270 self.dark.as_ref().or(self.light.as_ref())
271 } else {
272 self.light.as_ref().or(self.dark.as_ref())
273 }
274 }
275
276 pub fn is_empty(&self) -> bool {
278 self.light.is_none() && self.dark.is_none()
279 }
280
281 #[must_use = "this returns a theme preset; it does not apply it"]
295 pub fn preset(name: &str) -> crate::Result<Self> {
296 crate::presets::preset(name)
297 }
298
299 #[must_use = "this parses a TOML string into a theme; it does not apply it"]
381 pub fn from_toml(toml_str: &str) -> crate::Result<Self> {
382 crate::presets::from_toml(toml_str)
383 }
384
385 #[must_use = "this loads a theme from a file; it does not apply it"]
395 pub fn from_file(path: impl AsRef<std::path::Path>) -> crate::Result<Self> {
396 crate::presets::from_file(path)
397 }
398
399 #[must_use = "this returns the list of preset names"]
407 pub fn list_presets() -> &'static [&'static str] {
408 crate::presets::list_presets()
409 }
410
411 #[must_use = "this returns the filtered list of preset names for this platform"]
424 pub fn list_presets_for_platform() -> Vec<&'static str> {
425 crate::presets::list_presets_for_platform()
426 }
427
428 #[must_use = "this serializes the theme to TOML; it does not write to a file"]
440 pub fn to_toml(&self) -> crate::Result<String> {
441 crate::presets::to_toml(self)
442 }
443}
444
445#[cfg(test)]
446#[allow(clippy::unwrap_used, clippy::expect_used)]
447mod tests {
448 use super::*;
449 use crate::Rgba;
450
451 #[test]
454 fn theme_variant_default_is_empty() {
455 assert!(ThemeVariant::default().is_empty());
456 }
457
458 #[test]
459 fn theme_variant_not_empty_when_color_set() {
460 let mut v = ThemeVariant::default();
461 v.defaults.accent = Some(Rgba::rgb(0, 120, 215));
462 assert!(!v.is_empty());
463 }
464
465 #[test]
466 fn theme_variant_not_empty_when_font_set() {
467 let mut v = ThemeVariant::default();
468 v.defaults.font.family = Some("Inter".into());
469 assert!(!v.is_empty());
470 }
471
472 #[test]
473 fn theme_variant_merge_recursively() {
474 let mut base = ThemeVariant::default();
475 base.defaults.background = Some(Rgba::rgb(255, 255, 255));
476 base.defaults.font.family = Some("Noto Sans".into());
477
478 let mut overlay = ThemeVariant::default();
479 overlay.defaults.accent = Some(Rgba::rgb(0, 120, 215));
480 overlay.defaults.spacing.m = Some(12.0);
481
482 base.merge(&overlay);
483
484 assert_eq!(base.defaults.background, Some(Rgba::rgb(255, 255, 255)));
486 assert_eq!(base.defaults.accent, Some(Rgba::rgb(0, 120, 215)));
488 assert_eq!(base.defaults.font.family.as_deref(), Some("Noto Sans"));
490 assert_eq!(base.defaults.spacing.m, Some(12.0));
492 }
493
494 #[test]
495 fn theme_variant_has_all_widgets() {
496 let mut v = ThemeVariant::default();
497 v.window.radius = Some(4.0);
499 v.button.min_height = Some(32.0);
500 v.input.min_height = Some(32.0);
501 v.checkbox.indicator_size = Some(18.0);
502 v.menu.item_height = Some(28.0);
503 v.tooltip.padding_horizontal = Some(6.0);
504 v.scrollbar.width = Some(14.0);
505 v.slider.track_height = Some(4.0);
506 v.progress_bar.height = Some(6.0);
507 v.tab.min_height = Some(32.0);
508 v.sidebar.background = Some(Rgba::rgb(240, 240, 240));
509 v.toolbar.height = Some(40.0);
510 v.status_bar.font = Some(crate::model::FontSpec::default());
511 v.list.item_height = Some(28.0);
512 v.popover.radius = Some(6.0);
513 v.splitter.width = Some(4.0);
514 v.separator.color = Some(Rgba::rgb(200, 200, 200));
515 v.switch.track_width = Some(32.0);
516 v.dialog.min_width = Some(320.0);
517 v.spinner.diameter = Some(24.0);
518 v.combo_box.min_height = Some(32.0);
519 v.segmented_control.segment_height = Some(28.0);
520 v.card.radius = Some(8.0);
521 v.expander.header_height = Some(32.0);
522 v.link.underline = Some(true);
523
524 assert!(!v.is_empty());
525 assert!(!v.window.is_empty());
526 assert!(!v.button.is_empty());
527 assert!(!v.input.is_empty());
528 assert!(!v.checkbox.is_empty());
529 assert!(!v.menu.is_empty());
530 assert!(!v.tooltip.is_empty());
531 assert!(!v.scrollbar.is_empty());
532 assert!(!v.slider.is_empty());
533 assert!(!v.progress_bar.is_empty());
534 assert!(!v.tab.is_empty());
535 assert!(!v.sidebar.is_empty());
536 assert!(!v.toolbar.is_empty());
537 assert!(!v.status_bar.is_empty());
538 assert!(!v.list.is_empty());
539 assert!(!v.popover.is_empty());
540 assert!(!v.splitter.is_empty());
541 assert!(!v.separator.is_empty());
542 assert!(!v.switch.is_empty());
543 assert!(!v.dialog.is_empty());
544 assert!(!v.spinner.is_empty());
545 assert!(!v.combo_box.is_empty());
546 assert!(!v.segmented_control.is_empty());
547 assert!(!v.card.is_empty());
548 assert!(!v.expander.is_empty());
549 assert!(!v.link.is_empty());
550 }
551
552 #[test]
553 fn theme_variant_merge_per_widget() {
554 let mut base = ThemeVariant::default();
555 base.button.background = Some(Rgba::rgb(200, 200, 200));
556 base.button.foreground = Some(Rgba::rgb(0, 0, 0));
557 base.tooltip.background = Some(Rgba::rgb(50, 50, 50));
558
559 let mut overlay = ThemeVariant::default();
560 overlay.button.background = Some(Rgba::rgb(255, 255, 255));
561 overlay.button.min_height = Some(32.0);
562
563 base.merge(&overlay);
564
565 assert_eq!(base.button.background, Some(Rgba::rgb(255, 255, 255)));
567 assert_eq!(base.button.min_height, Some(32.0));
569 assert_eq!(base.button.foreground, Some(Rgba::rgb(0, 0, 0)));
571 assert_eq!(base.tooltip.background, Some(Rgba::rgb(50, 50, 50)));
573 }
574
575 #[test]
578 fn native_theme_new_constructor() {
579 let theme = NativeTheme::new("Breeze");
580 assert_eq!(theme.name, "Breeze");
581 assert!(theme.light.is_none());
582 assert!(theme.dark.is_none());
583 }
584
585 #[test]
586 fn native_theme_default_is_empty() {
587 let theme = NativeTheme::default();
588 assert!(theme.is_empty());
589 assert_eq!(theme.name, "");
590 }
591
592 #[test]
593 fn native_theme_merge_keeps_base_name() {
594 let mut base = NativeTheme::new("Base Theme");
595 let overlay = NativeTheme::new("Overlay Theme");
596 base.merge(&overlay);
597 assert_eq!(base.name, "Base Theme");
598 }
599
600 #[test]
601 fn native_theme_merge_overlay_light_into_none() {
602 let mut base = NativeTheme::new("Theme");
603
604 let mut overlay = NativeTheme::new("Overlay");
605 let mut light = ThemeVariant::default();
606 light.defaults.accent = Some(Rgba::rgb(0, 120, 215));
607 overlay.light = Some(light);
608
609 base.merge(&overlay);
610
611 assert!(base.light.is_some());
612 assert_eq!(
613 base.light.as_ref().unwrap().defaults.accent,
614 Some(Rgba::rgb(0, 120, 215))
615 );
616 }
617
618 #[test]
619 fn native_theme_merge_both_light_variants() {
620 let mut base = NativeTheme::new("Theme");
621 let mut base_light = ThemeVariant::default();
622 base_light.defaults.background = Some(Rgba::rgb(255, 255, 255));
623 base.light = Some(base_light);
624
625 let mut overlay = NativeTheme::new("Overlay");
626 let mut overlay_light = ThemeVariant::default();
627 overlay_light.defaults.accent = Some(Rgba::rgb(0, 120, 215));
628 overlay.light = Some(overlay_light);
629
630 base.merge(&overlay);
631
632 let light = base.light.as_ref().unwrap();
633 assert_eq!(light.defaults.background, Some(Rgba::rgb(255, 255, 255)));
635 assert_eq!(light.defaults.accent, Some(Rgba::rgb(0, 120, 215)));
637 }
638
639 #[test]
640 fn native_theme_merge_base_light_only_preserved() {
641 let mut base = NativeTheme::new("Theme");
642 let mut base_light = ThemeVariant::default();
643 base_light.defaults.font.family = Some("Inter".into());
644 base.light = Some(base_light);
645
646 let overlay = NativeTheme::new("Overlay"); base.merge(&overlay);
649
650 assert!(base.light.is_some());
651 assert_eq!(
652 base.light.as_ref().unwrap().defaults.font.family.as_deref(),
653 Some("Inter")
654 );
655 }
656
657 #[test]
658 fn native_theme_merge_dark_variant() {
659 let mut base = NativeTheme::new("Theme");
660
661 let mut overlay = NativeTheme::new("Overlay");
662 let mut dark = ThemeVariant::default();
663 dark.defaults.background = Some(Rgba::rgb(30, 30, 30));
664 overlay.dark = Some(dark);
665
666 base.merge(&overlay);
667
668 assert!(base.dark.is_some());
669 assert_eq!(
670 base.dark.as_ref().unwrap().defaults.background,
671 Some(Rgba::rgb(30, 30, 30))
672 );
673 }
674
675 #[test]
676 fn native_theme_not_empty_with_light() {
677 let mut theme = NativeTheme::new("Theme");
678 theme.light = Some(ThemeVariant::default());
679 assert!(!theme.is_empty());
680 }
681
682 #[test]
685 fn pick_variant_dark_with_both_variants_returns_dark() {
686 let mut theme = NativeTheme::new("Test");
687 let mut light = ThemeVariant::default();
688 light.defaults.background = Some(Rgba::rgb(255, 255, 255));
689 theme.light = Some(light);
690 let mut dark = ThemeVariant::default();
691 dark.defaults.background = Some(Rgba::rgb(30, 30, 30));
692 theme.dark = Some(dark);
693
694 let picked = theme.pick_variant(true).unwrap();
695 assert_eq!(picked.defaults.background, Some(Rgba::rgb(30, 30, 30)));
696 }
697
698 #[test]
699 fn pick_variant_light_with_both_variants_returns_light() {
700 let mut theme = NativeTheme::new("Test");
701 let mut light = ThemeVariant::default();
702 light.defaults.background = Some(Rgba::rgb(255, 255, 255));
703 theme.light = Some(light);
704 let mut dark = ThemeVariant::default();
705 dark.defaults.background = Some(Rgba::rgb(30, 30, 30));
706 theme.dark = Some(dark);
707
708 let picked = theme.pick_variant(false).unwrap();
709 assert_eq!(picked.defaults.background, Some(Rgba::rgb(255, 255, 255)));
710 }
711
712 #[test]
713 fn pick_variant_dark_with_only_light_falls_back() {
714 let mut theme = NativeTheme::new("Test");
715 let mut light = ThemeVariant::default();
716 light.defaults.background = Some(Rgba::rgb(255, 255, 255));
717 theme.light = Some(light);
718
719 let picked = theme.pick_variant(true).unwrap();
720 assert_eq!(picked.defaults.background, Some(Rgba::rgb(255, 255, 255)));
721 }
722
723 #[test]
724 fn pick_variant_light_with_only_dark_falls_back() {
725 let mut theme = NativeTheme::new("Test");
726 let mut dark = ThemeVariant::default();
727 dark.defaults.background = Some(Rgba::rgb(30, 30, 30));
728 theme.dark = Some(dark);
729
730 let picked = theme.pick_variant(false).unwrap();
731 assert_eq!(picked.defaults.background, Some(Rgba::rgb(30, 30, 30)));
732 }
733
734 #[test]
735 fn pick_variant_with_no_variants_returns_none() {
736 let theme = NativeTheme::new("Empty");
737 assert!(theme.pick_variant(true).is_none());
738 assert!(theme.pick_variant(false).is_none());
739 }
740
741 #[test]
744 fn icon_set_default_is_none() {
745 assert!(ThemeVariant::default().icon_set.is_none());
746 }
747
748 #[test]
749 fn icon_set_merge_overlay() {
750 let mut base = ThemeVariant::default();
751 let overlay = ThemeVariant {
752 icon_set: Some("material".into()),
753 ..Default::default()
754 };
755 base.merge(&overlay);
756 assert_eq!(base.icon_set.as_deref(), Some("material"));
757 }
758
759 #[test]
760 fn icon_set_merge_none_preserves() {
761 let mut base = ThemeVariant {
762 icon_set: Some("sf-symbols".into()),
763 ..Default::default()
764 };
765 let overlay = ThemeVariant::default();
766 base.merge(&overlay);
767 assert_eq!(base.icon_set.as_deref(), Some("sf-symbols"));
768 }
769
770 #[test]
771 fn icon_set_is_empty_when_set() {
772 assert!(ThemeVariant::default().is_empty());
773 let v = ThemeVariant {
774 icon_set: Some("material".into()),
775 ..Default::default()
776 };
777 assert!(!v.is_empty());
778 }
779
780 #[test]
781 fn icon_set_toml_round_trip() {
782 let variant = ThemeVariant {
783 icon_set: Some("material".into()),
784 ..Default::default()
785 };
786 let toml_str = toml::to_string(&variant).unwrap();
787 assert!(toml_str.contains("icon_set"));
788 let deserialized: ThemeVariant = toml::from_str(&toml_str).unwrap();
789 assert_eq!(deserialized.icon_set.as_deref(), Some("material"));
790 }
791
792 #[test]
793 fn icon_set_toml_absent_deserializes_to_none() {
794 let toml_str = r##"
795[defaults]
796accent = "#ff0000"
797"##;
798 let variant: ThemeVariant = toml::from_str(toml_str).unwrap();
799 assert!(variant.icon_set.is_none());
800 }
801
802 #[test]
803 fn native_theme_serde_toml_round_trip() {
804 let mut theme = NativeTheme::new("Test Theme");
805 let mut light = ThemeVariant::default();
806 light.defaults.accent = Some(Rgba::rgb(0, 120, 215));
807 light.defaults.font.family = Some("Segoe UI".into());
808 light.defaults.radius = Some(4.0);
809 light.defaults.spacing.m = Some(12.0);
810 theme.light = Some(light);
811
812 let toml_str = toml::to_string(&theme).unwrap();
813 let deserialized: NativeTheme = toml::from_str(&toml_str).unwrap();
814
815 assert_eq!(deserialized.name, "Test Theme");
816 let l = deserialized.light.unwrap();
817 assert_eq!(l.defaults.accent, Some(Rgba::rgb(0, 120, 215)));
818 assert_eq!(l.defaults.font.family.as_deref(), Some("Segoe UI"));
819 assert_eq!(l.defaults.radius, Some(4.0));
820 assert_eq!(l.defaults.spacing.m, Some(12.0));
821 }
822}