1#![warn(missing_docs)]
76#![forbid(unsafe_code)]
77#![deny(clippy::unwrap_used)]
78#![deny(clippy::expect_used)]
79
80pub(crate) mod extended;
81pub mod icons;
82pub mod palette;
83
84pub use native_theme::{
86 AnimatedIcon, DialogButtonOrder, Error, IconData, IconProvider, IconRole, IconSet,
87 ResolvedThemeVariant, Result, Rgba, SystemTheme, ThemeSpec, ThemeVariant, TransformAnimation,
88};
89
90#[cfg(target_os = "linux")]
91pub use native_theme::LinuxDesktop;
92
93#[must_use = "this returns the theme; it does not apply it"]
113pub fn to_theme(
114 resolved: &native_theme::ResolvedThemeVariant,
115 name: &str,
116) -> iced_core::theme::Theme {
117 let pal = palette::to_palette(resolved);
118
119 let colors = extended::OverrideColors {
122 btn_bg: resolved.button.background_color,
123 btn_fg: resolved.button.font.color,
124 surface: resolved.defaults.surface_color,
125 foreground: resolved.defaults.text_color,
126 accent_fg: resolved.defaults.accent_text_color,
127 success_fg: resolved.defaults.success_text_color,
128 danger_fg: resolved.defaults.danger_text_color,
129 warning_fg: resolved.defaults.warning_text_color,
130 success_bg: resolved.defaults.success_color,
131 danger_bg: resolved.defaults.danger_color,
132 warning_bg: resolved.defaults.warning_color,
133 };
134
135 iced_core::theme::Theme::custom_with_fn(name.to_string(), pal, move |p| {
136 let mut ext = iced_core::theme::palette::Extended::generate(p);
137 extended::apply_overrides(&mut ext, &colors);
138 ext
139 })
140}
141
142#[must_use = "this returns the theme; it does not apply it"]
151pub fn from_preset(
152 name: &str,
153 is_dark: bool,
154) -> native_theme::Result<(iced_core::theme::Theme, native_theme::ResolvedThemeVariant)> {
155 let spec = native_theme::ThemeSpec::preset(name)?;
156 let display_name = spec.name.clone();
157 let mode = if is_dark { "dark" } else { "light" };
158 let variant = spec.into_variant(is_dark).ok_or_else(|| {
159 native_theme::Error::Format(format!(
160 "preset '{name}' has no usable variant (requested {mode}, fallback also empty)"
161 ))
162 })?;
163 let resolved = variant.into_resolved()?;
164 let theme = to_theme(&resolved, &display_name);
165 Ok((theme, resolved))
166}
167
168#[must_use = "this returns the theme; it does not apply it"]
178pub fn from_system() -> native_theme::Result<(
179 iced_core::theme::Theme,
180 native_theme::ResolvedThemeVariant,
181 bool,
182)> {
183 let sys = native_theme::SystemTheme::from_system()?;
184 let is_dark = sys.is_dark;
185 let name = sys.name;
186 let resolved = if is_dark { sys.dark } else { sys.light };
187 let theme = to_theme(&resolved, &name);
188 Ok((theme, resolved, is_dark))
189}
190
191pub trait SystemThemeExt {
193 #[must_use = "this returns the theme; it does not apply it"]
198 fn to_iced_theme(&self) -> (iced_core::theme::Theme, native_theme::ResolvedThemeVariant);
199}
200
201impl SystemThemeExt for native_theme::SystemTheme {
202 fn to_iced_theme(&self) -> (iced_core::theme::Theme, native_theme::ResolvedThemeVariant) {
203 let resolved = self.active().clone();
204 let theme = to_theme(&resolved, &self.name);
205 (theme, resolved)
206 }
207}
208
209#[must_use]
213pub fn button_padding(resolved: &native_theme::ResolvedThemeVariant) -> iced_core::Padding {
214 iced_core::Padding::from([
215 resolved.button.border.padding_vertical,
216 resolved.button.border.padding_horizontal,
217 ])
218}
219
220#[must_use]
224pub fn input_padding(resolved: &native_theme::ResolvedThemeVariant) -> iced_core::Padding {
225 iced_core::Padding::from([
226 resolved.input.border.padding_vertical,
227 resolved.input.border.padding_horizontal,
228 ])
229}
230
231#[must_use]
233pub fn border_radius(resolved: &native_theme::ResolvedThemeVariant) -> f32 {
234 resolved.defaults.border.corner_radius
235}
236
237#[must_use]
239pub fn border_radius_lg(resolved: &native_theme::ResolvedThemeVariant) -> f32 {
240 resolved.defaults.border.corner_radius_lg
241}
242
243#[must_use]
245pub fn scrollbar_width(resolved: &native_theme::ResolvedThemeVariant) -> f32 {
246 resolved.scrollbar.groove_width
247}
248
249#[must_use]
251pub fn font_family(resolved: &native_theme::ResolvedThemeVariant) -> &str {
252 &resolved.defaults.font.family
253}
254
255#[must_use]
260pub fn font_size(resolved: &native_theme::ResolvedThemeVariant) -> f32 {
261 resolved.defaults.font.size
262}
263
264#[must_use]
266pub fn mono_font_family(resolved: &native_theme::ResolvedThemeVariant) -> &str {
267 &resolved.defaults.mono_font.family
268}
269
270#[must_use]
275pub fn mono_font_size(resolved: &native_theme::ResolvedThemeVariant) -> f32 {
276 resolved.defaults.mono_font.size
277}
278
279#[must_use]
281pub fn font_weight(resolved: &native_theme::ResolvedThemeVariant) -> u16 {
282 resolved.defaults.font.weight
283}
284
285#[must_use]
287pub fn mono_font_weight(resolved: &native_theme::ResolvedThemeVariant) -> u16 {
288 resolved.defaults.mono_font.weight
289}
290
291#[must_use]
293pub fn border_color(resolved: &native_theme::ResolvedThemeVariant) -> iced_core::Color {
294 palette::to_color(resolved.defaults.border.color)
295}
296
297#[must_use]
299pub fn disabled_opacity(resolved: &native_theme::ResolvedThemeVariant) -> f32 {
300 resolved.defaults.disabled_opacity
301}
302
303#[must_use]
305pub fn focus_ring_color(resolved: &native_theme::ResolvedThemeVariant) -> iced_core::Color {
306 palette::to_color(resolved.defaults.focus_ring_color)
307}
308
309#[must_use]
311pub fn link_color(resolved: &native_theme::ResolvedThemeVariant) -> iced_core::Color {
312 palette::to_color(resolved.defaults.link_color)
313}
314
315#[must_use]
317pub fn selection_color(resolved: &native_theme::ResolvedThemeVariant) -> iced_core::Color {
318 palette::to_color(resolved.defaults.selection_background)
319}
320
321#[must_use]
326pub fn info_color(resolved: &native_theme::ResolvedThemeVariant) -> iced_core::Color {
327 palette::to_color(resolved.defaults.info_color)
328}
329
330#[must_use]
332pub fn info_foreground_color(resolved: &native_theme::ResolvedThemeVariant) -> iced_core::Color {
333 palette::to_color(resolved.defaults.info_text_color)
334}
335
336#[must_use]
341pub fn warning_foreground_color(resolved: &native_theme::ResolvedThemeVariant) -> iced_core::Color {
342 palette::to_color(resolved.defaults.warning_text_color)
343}
344
345#[must_use]
347pub fn icon_sizes(
348 resolved: &native_theme::ResolvedThemeVariant,
349) -> &native_theme::ResolvedIconSizes {
350 &resolved.defaults.icon_sizes
351}
352
353#[must_use]
363pub fn line_height_multiplier(resolved: &native_theme::ResolvedThemeVariant) -> f32 {
364 resolved.defaults.line_height
365}
366
367#[must_use]
381pub fn to_iced_weight(css_weight: u16) -> iced_core::font::Weight {
382 use iced_core::font::Weight;
383 match css_weight {
384 0..=149 => Weight::Thin,
385 150..=249 => Weight::ExtraLight,
386 250..=349 => Weight::Light,
387 350..=449 => Weight::Normal,
388 450..=549 => Weight::Medium,
389 550..=649 => Weight::Semibold,
390 650..=749 => Weight::Bold,
391 750..=849 => Weight::ExtraBold,
392 850.. => Weight::Black,
393 }
394}
395
396#[cfg(test)]
397#[allow(clippy::unwrap_used, clippy::expect_used)]
398mod tests {
399 use super::*;
400 use native_theme::ThemeSpec;
401
402 fn make_resolved_preset(name: &str, is_dark: bool) -> native_theme::ResolvedThemeVariant {
403 ThemeSpec::preset(name)
404 .unwrap()
405 .into_variant(is_dark)
406 .unwrap()
407 .into_resolved()
408 .unwrap()
409 }
410
411 fn make_resolved(is_dark: bool) -> native_theme::ResolvedThemeVariant {
412 make_resolved_preset("catppuccin-mocha", is_dark)
413 }
414
415 #[test]
418 fn to_theme_produces_non_default_theme() {
419 let resolved = make_resolved(true);
420 let theme = to_theme(&resolved, "Test Theme");
421
422 assert_ne!(theme, iced_core::theme::Theme::Light);
423 assert_ne!(theme, iced_core::theme::Theme::Dark);
424
425 let palette = theme.palette();
426 let expected = palette::to_color(resolved.defaults.accent_color);
428 assert_eq!(palette.primary, expected, "primary should match accent");
429 }
430
431 #[test]
432 fn to_theme_from_preset() {
433 let resolved = make_resolved(false);
434 let theme = to_theme(&resolved, "Default");
435
436 let palette = theme.palette();
437 assert!(
439 palette.background.r > 0.9,
440 "light background should be bright"
441 );
442 }
443
444 #[test]
445 fn to_theme_dark_variant() {
446 let resolved = make_resolved(true);
447 let theme = to_theme(&resolved, "Dark Test");
448
449 let palette = theme.palette();
450 assert!(palette.background.r < 0.3, "dark background should be dark");
451 }
452
453 #[test]
454 fn to_theme_different_presets_differ() {
455 let r1 = ThemeSpec::preset("catppuccin-mocha")
456 .unwrap()
457 .into_variant(true)
458 .unwrap()
459 .into_resolved()
460 .unwrap();
461 let r2 = ThemeSpec::preset("dracula")
462 .unwrap()
463 .into_variant(true)
464 .unwrap()
465 .into_resolved()
466 .unwrap();
467
468 let t1 = to_theme(&r1, "mocha");
469 let t2 = to_theme(&r2, "dracula");
470
471 assert_ne!(t1.palette().primary, t2.palette().primary);
473 }
474
475 #[test]
476 fn to_theme_with_adwaita_preset() {
477 let resolved = make_resolved_preset("adwaita", false);
478 let theme = to_theme(&resolved, "Adwaita");
479 let palette = theme.palette();
480 assert!(palette.primary.a > 0.0, "adwaita primary should be visible");
481 }
482
483 #[test]
486 fn border_radius_returns_resolved_value() {
487 let resolved = make_resolved(false);
488 let r = border_radius(&resolved);
489 assert!(r > 0.0, "resolved radius should be > 0");
490 }
491
492 #[test]
493 fn border_radius_lg_returns_resolved_value() {
494 let resolved = make_resolved(false);
495 let r = border_radius_lg(&resolved);
496 assert!(r > 0.0, "resolved radius_lg should be > 0");
497 assert!(
498 r >= border_radius(&resolved),
499 "radius_lg should be >= radius"
500 );
501 }
502
503 #[test]
504 fn scrollbar_width_returns_resolved_value() {
505 let resolved = make_resolved(false);
506 let w = scrollbar_width(&resolved);
507 assert!(w > 0.0, "scrollbar width should be > 0");
508 }
509
510 #[test]
511 fn button_padding_returns_iced_padding() {
512 let resolved = make_resolved(false);
513 let pad = button_padding(&resolved);
514 assert!(
517 pad.top >= 0.0,
518 "button vertical (top) padding should be >= 0"
519 );
520 assert!(
521 pad.right >= 0.0,
522 "button horizontal (right) padding should be >= 0"
523 );
524 assert_eq!(pad.top, pad.bottom, "top and bottom should be equal");
526 assert_eq!(pad.left, pad.right, "left and right should be equal");
527 }
528
529 #[test]
530 fn input_padding_returns_iced_padding() {
531 let resolved = make_resolved(false);
532 let pad = input_padding(&resolved);
533 assert!(
536 pad.top >= 0.0,
537 "input vertical (top) padding should be >= 0"
538 );
539 assert!(
540 pad.right >= 0.0,
541 "input horizontal (right) padding should be >= 0"
542 );
543 assert_eq!(pad.top, pad.bottom, "top and bottom should be equal");
545 assert_eq!(pad.left, pad.right, "left and right should be equal");
546 }
547
548 #[test]
551 fn border_color_returns_concrete_value() {
552 let resolved = make_resolved(false);
553 let c = border_color(&resolved);
554 assert!(c.a > 0.0, "border color should have non-zero alpha");
555 }
556
557 #[test]
558 fn disabled_opacity_returns_value() {
559 let resolved = make_resolved(false);
560 let o = disabled_opacity(&resolved);
561 assert!(
562 o > 0.0 && o <= 1.0,
563 "disabled opacity should be in (0, 1], got {o}"
564 );
565 }
566
567 #[test]
568 fn focus_ring_color_returns_concrete_value() {
569 let resolved = make_resolved(false);
570 let c = focus_ring_color(&resolved);
571 assert!(c.a > 0.0, "focus ring color should have non-zero alpha");
572 }
573
574 #[test]
575 fn link_color_returns_concrete_value() {
576 let resolved = make_resolved(false);
577 let c = link_color(&resolved);
578 assert!(
579 c.r > 0.0 || c.g > 0.0 || c.b > 0.0,
580 "link color should be non-black"
581 );
582 }
583
584 #[test]
585 fn selection_color_returns_concrete_value() {
586 let resolved = make_resolved(false);
587 let c = selection_color(&resolved);
588 assert!(c.a > 0.0, "selection color should have non-zero alpha");
589 }
590
591 #[test]
592 fn info_color_returns_concrete_value() {
593 let resolved = make_resolved(false);
594 let c = info_color(&resolved);
595 assert!(
596 c.r > 0.0 || c.g > 0.0 || c.b > 0.0,
597 "info color should be non-black"
598 );
599 }
600
601 #[test]
602 fn info_foreground_color_returns_concrete_value() {
603 let resolved = make_resolved(false);
604 let c = info_foreground_color(&resolved);
605 assert!(c.a > 0.0, "info foreground should have non-zero alpha");
606 }
607
608 #[test]
609 fn warning_foreground_color_returns_concrete_value() {
610 let resolved = make_resolved(false);
611 let c = warning_foreground_color(&resolved);
612 assert!(c.a > 0.0, "warning foreground should have non-zero alpha");
613 }
614
615 #[test]
616 fn icon_sizes_returns_concrete_values() {
617 let resolved = make_resolved(false);
618 let is = icon_sizes(&resolved);
619 assert!(is.small > 0.0, "small icon size should be > 0");
620 assert!(is.toolbar > 0.0, "toolbar icon size should be > 0");
621 }
622
623 #[test]
626 fn font_family_returns_concrete_value() {
627 let resolved = make_resolved(false);
628 let ff = font_family(&resolved);
629 assert!(!ff.is_empty(), "font family should not be empty");
630 }
631
632 #[test]
633 fn font_size_returns_concrete_value() {
634 let resolved = make_resolved(false);
635 let fs = font_size(&resolved);
636 assert!(fs > 0.0, "font size should be > 0");
637 }
638
639 #[test]
640 fn mono_font_family_returns_concrete_value() {
641 let resolved = make_resolved(false);
642 let mf = mono_font_family(&resolved);
643 assert!(!mf.is_empty(), "mono font family should not be empty");
644 }
645
646 #[test]
647 fn mono_font_size_returns_concrete_value() {
648 let resolved = make_resolved(false);
649 let ms = mono_font_size(&resolved);
650 assert!(ms > 0.0, "mono font size should be > 0");
651 }
652
653 #[test]
654 fn font_weight_returns_concrete_value() {
655 let resolved = make_resolved(false);
656 let w = font_weight(&resolved);
657 assert!(
658 (100..=900).contains(&w),
659 "font weight should be 100-900, got {}",
660 w
661 );
662 }
663
664 #[test]
665 fn mono_font_weight_returns_concrete_value() {
666 let resolved = make_resolved(false);
667 let w = mono_font_weight(&resolved);
668 assert!(
669 (100..=900).contains(&w),
670 "mono font weight should be 100-900, got {}",
671 w
672 );
673 }
674
675 #[test]
676 fn line_height_multiplier_returns_concrete_value() {
677 let resolved = make_resolved(false);
678 let lh = line_height_multiplier(&resolved);
679 assert!(lh > 0.0, "line height multiplier should be > 0");
680 assert!(
681 lh < 5.0,
682 "line height multiplier should be a multiplier (e.g. 1.4), got {}",
683 lh
684 );
685 }
686
687 #[test]
688 fn to_iced_weight_standard_weights() {
689 use iced_core::font::Weight;
690 assert_eq!(to_iced_weight(100), Weight::Thin);
691 assert_eq!(to_iced_weight(200), Weight::ExtraLight);
692 assert_eq!(to_iced_weight(300), Weight::Light);
693 assert_eq!(to_iced_weight(400), Weight::Normal);
694 assert_eq!(to_iced_weight(500), Weight::Medium);
695 assert_eq!(to_iced_weight(600), Weight::Semibold);
696 assert_eq!(to_iced_weight(700), Weight::Bold);
697 assert_eq!(to_iced_weight(800), Weight::ExtraBold);
698 assert_eq!(to_iced_weight(900), Weight::Black);
699 }
700
701 #[test]
702 fn to_iced_weight_non_standard_rounds_correctly() {
703 use iced_core::font::Weight;
704 assert_eq!(to_iced_weight(350), Weight::Normal);
705 assert_eq!(to_iced_weight(450), Weight::Medium);
706 assert_eq!(to_iced_weight(550), Weight::Semibold);
707 assert_eq!(to_iced_weight(0), Weight::Thin);
708 assert_eq!(to_iced_weight(1000), Weight::Black);
709 }
710
711 #[test]
714 fn from_preset_valid_light() {
715 let (theme, resolved) = from_preset("catppuccin-mocha", false).expect("preset should load");
716 assert_ne!(theme, iced_core::theme::Theme::Light);
717 assert!(!resolved.defaults.font.family.is_empty());
718 let palette = theme.palette();
720 assert!(
721 palette.background.r > 0.9,
722 "light variant should have bright background, got r={}",
723 palette.background.r
724 );
725 }
726
727 #[test]
728 fn from_preset_valid_dark() {
729 let (theme, _resolved) = from_preset("catppuccin-mocha", true).expect("preset should load");
730 assert_ne!(theme, iced_core::theme::Theme::Dark);
731 let palette = theme.palette();
733 assert!(
734 palette.background.r < 0.3,
735 "dark variant should have dark background, got r={}",
736 palette.background.r
737 );
738 }
739
740 #[test]
741 fn from_preset_invalid_name() {
742 let result = from_preset("nonexistent-preset", false);
743 assert!(result.is_err(), "invalid preset should return Err");
744 }
745
746 #[test]
747 fn from_preset_error_shows_requested_mode() {
748 let result = from_preset("nonexistent-preset", true);
752 assert!(result.is_err());
753 }
754
755 #[test]
756 fn system_theme_ext_to_iced_theme() {
757 let Ok(sys) = native_theme::SystemTheme::from_system() else {
759 return;
760 };
761 let (_theme, _resolved) = sys.to_iced_theme();
762 }
763
764 #[test]
765 fn from_system_does_not_panic() {
766 let _ = from_system();
767 }
768
769 #[test]
770 fn from_system_returns_is_dark() {
771 if let Ok((_theme, _resolved, is_dark)) = from_system() {
773 let _ = is_dark;
775 }
776 }
777
778 #[test]
779 fn to_theme_extended_overrides_take_effect() {
780 let resolved = make_resolved(true);
781 let theme = to_theme(&resolved, "test");
782 let ext = theme.extended_palette();
783 let auto_palette = iced_core::theme::palette::Extended::generate(theme.palette());
785 assert_ne!(
788 ext.secondary.base.color, auto_palette.secondary.base.color,
789 "secondary.base.color should be overridden, not auto-generated"
790 );
791 }
792
793 #[test]
796 fn all_presets_produce_valid_themes() {
797 for name in ThemeSpec::list_presets() {
798 for is_dark in [false, true] {
799 let spec = ThemeSpec::preset(name).unwrap();
800 if let Some(variant) = spec.into_variant(is_dark) {
801 let resolved = variant.into_resolved().unwrap();
802 let theme = to_theme(&resolved, name);
803 let palette = theme.palette();
804 assert!(
806 palette.background.a > 0.0,
807 "{name}/{is_dark}: background alpha"
808 );
809 assert!(palette.text.a > 0.0, "{name}/{is_dark}: text alpha");
810 assert!(palette.primary.a > 0.0, "{name}/{is_dark}: primary alpha");
811 assert!(palette.success.a > 0.0, "{name}/{is_dark}: success alpha");
812 assert!(palette.warning.a > 0.0, "{name}/{is_dark}: warning alpha");
813 assert!(palette.danger.a > 0.0, "{name}/{is_dark}: danger alpha");
814 }
815 }
816 }
817 }
818
819 #[test]
822 fn palette_field_count_tripwire() {
823 let field_count = std::mem::size_of::<iced_core::theme::Palette>()
826 / std::mem::size_of::<iced_core::Color>();
827 assert_eq!(
828 field_count, 6,
829 "iced Palette field count changed from 6 to {field_count} -- update to_palette()"
830 );
831 }
832}