1#![warn(missing_docs)]
71#![forbid(unsafe_code)]
72#![deny(clippy::unwrap_used)]
73#![deny(clippy::expect_used)]
74
75pub(crate) mod colors;
76pub(crate) mod config;
77pub(crate) mod derive;
78pub mod icons;
79
80pub use native_theme::{
84 AnimatedIcon, DialogButtonOrder, Error, IconData, IconProvider, IconRole, IconSet,
85 ResolvedThemeVariant, Result, Rgba, SystemTheme, ThemeSpec, ThemeVariant, TransformAnimation,
86};
87
88#[cfg(target_os = "linux")]
89pub use native_theme::LinuxDesktop;
90
91use gpui::{SharedString, px};
92use gpui_component::scroll::ScrollbarShow;
93use gpui_component::theme::{Theme, ThemeMode};
94use std::rc::Rc;
95
96#[must_use = "this returns the theme; it does not apply it"]
116pub fn to_theme(resolved: &ResolvedThemeVariant, name: &str, is_dark: bool) -> Theme {
117 let theme_color = colors::to_theme_color(resolved, is_dark);
118 let mode = if is_dark {
119 ThemeMode::Dark
120 } else {
121 ThemeMode::Light
122 };
123 let d = &resolved.defaults;
124
125 let mut theme = Theme::from(&theme_color);
126 theme.mode = mode;
130 theme.font_family = SharedString::from(d.font.family.clone());
131 theme.font_size = px(d.font.size);
132 theme.mono_font_family = SharedString::from(d.mono_font.family.clone());
133 theme.mono_font_size = px(d.mono_font.size);
134 theme.radius = px(d.border.corner_radius.max(0.0));
136 theme.radius_lg = px(d.border.corner_radius_lg.max(0.0));
137 theme.shadow = d.border.shadow_enabled;
138
139 theme.scrollbar_show = if resolved.scrollbar.overlay_mode {
141 ScrollbarShow::Scrolling
142 } else {
143 ScrollbarShow::Always
144 };
145
146 theme.highlight_theme = if is_dark {
149 gpui_component::highlighter::HighlightTheme::default_dark()
150 } else {
151 gpui_component::highlighter::HighlightTheme::default_light()
152 };
153
154 let config: Rc<_> = Rc::new(config::to_theme_config(resolved, name, mode));
156 if mode == ThemeMode::Dark {
157 theme.dark_theme = config;
158 } else {
159 theme.light_theme = config;
160 }
161 theme
162}
163
164#[must_use = "this returns the theme; it does not apply it"]
186pub fn from_preset(
187 name: &str,
188 is_dark: bool,
189) -> native_theme::Result<(Theme, ResolvedThemeVariant)> {
190 let spec = ThemeSpec::preset(name)?;
191 let display_name = spec.name.clone();
192 let mode_str = if is_dark { "dark" } else { "light" };
193 let variant = spec.into_variant(is_dark).ok_or_else(|| {
194 native_theme::Error::Format(format!("preset '{name}' has no {mode_str} variant"))
195 })?;
196 let resolved = variant.into_resolved()?;
197 let theme = to_theme(&resolved, &display_name, is_dark);
198 Ok((theme, resolved))
199}
200
201#[must_use = "this returns the theme; it does not apply it"]
230pub fn from_system() -> native_theme::Result<(Theme, ResolvedThemeVariant, bool)> {
231 let sys = SystemTheme::from_system()?;
232 let is_dark = sys.is_dark;
233 let name = sys.name; let resolved = if is_dark { sys.dark } else { sys.light };
235 let theme = to_theme(&resolved, &name, is_dark);
236 Ok((theme, resolved, is_dark))
237}
238
239pub trait SystemThemeExt {
250 #[must_use = "this returns the theme; it does not apply it"]
255 fn to_gpui_theme(&self) -> Theme;
256}
257
258impl SystemThemeExt for SystemTheme {
259 fn to_gpui_theme(&self) -> Theme {
260 to_theme(self.active(), &self.name, self.is_dark)
261 }
262}
263
264#[must_use]
275pub fn is_dark_resolved(resolved: &ResolvedThemeVariant) -> bool {
276 colors::rgba_to_hsla(resolved.defaults.background_color).l < 0.5
277}
278
279#[must_use]
285pub fn is_dark(resolved: &ResolvedThemeVariant) -> bool {
286 is_dark_resolved(resolved)
287}
288
289#[must_use]
291pub fn is_reduced_motion(resolved: &ResolvedThemeVariant) -> bool {
292 resolved.defaults.reduce_motion
293}
294
295#[must_use]
297pub fn is_high_contrast(resolved: &ResolvedThemeVariant) -> bool {
298 resolved.defaults.high_contrast
299}
300
301#[must_use]
303pub fn is_reduced_transparency(resolved: &ResolvedThemeVariant) -> bool {
304 resolved.defaults.reduce_transparency
305}
306
307#[must_use]
311pub fn frame_width(resolved: &ResolvedThemeVariant) -> f32 {
312 resolved.defaults.border.line_width
313}
314
315#[must_use]
317pub fn disabled_opacity(resolved: &ResolvedThemeVariant) -> f32 {
318 resolved.defaults.disabled_opacity
319}
320
321#[must_use]
323pub fn border_opacity(resolved: &ResolvedThemeVariant) -> f32 {
324 resolved.defaults.border.opacity
325}
326
327#[must_use]
329pub fn shadow_enabled(resolved: &ResolvedThemeVariant) -> bool {
330 resolved.defaults.border.shadow_enabled
331}
332
333#[must_use]
335pub fn text_scaling_factor(resolved: &ResolvedThemeVariant) -> f32 {
336 resolved.defaults.text_scaling_factor
337}
338
339#[must_use]
345pub fn icon_sizes(resolved: &ResolvedThemeVariant) -> &native_theme::ResolvedIconSizes {
346 &resolved.defaults.icon_sizes
347}
348
349#[must_use]
353pub fn text_scale(resolved: &ResolvedThemeVariant) -> &native_theme::ResolvedTextScale {
354 &resolved.text_scale
355}
356
357#[must_use]
361pub fn line_height_multiplier(resolved: &ResolvedThemeVariant) -> f32 {
362 resolved.defaults.line_height
363}
364
365#[must_use]
371pub fn font_weight(resolved: &ResolvedThemeVariant) -> u16 {
372 resolved.defaults.font.weight
373}
374
375#[must_use]
381pub fn mono_font_weight(resolved: &ResolvedThemeVariant) -> u16 {
382 resolved.defaults.mono_font.weight
383}
384
385#[must_use]
393pub fn dialog_button_order(resolved: &ResolvedThemeVariant) -> DialogButtonOrder {
394 resolved.dialog.button_order
395}
396
397#[must_use]
403pub fn dialog_content_padding(resolved: &ResolvedThemeVariant) -> f32 {
404 resolved.dialog.border.padding_horizontal
405}
406
407#[must_use]
409pub fn dialog_button_spacing(resolved: &ResolvedThemeVariant) -> f32 {
410 resolved.dialog.button_gap
411}
412
413#[must_use]
415pub fn scrollbar_width(resolved: &ResolvedThemeVariant) -> f32 {
416 resolved.scrollbar.groove_width
417}
418
419#[must_use]
421pub fn selection_foreground(resolved: &ResolvedThemeVariant) -> Rgba {
422 resolved.defaults.selection_text_color
423}
424
425#[must_use]
427pub fn selection_inactive(resolved: &ResolvedThemeVariant) -> Rgba {
428 resolved.defaults.selection_inactive_background
429}
430
431#[must_use]
433pub fn disabled_foreground(resolved: &ResolvedThemeVariant) -> Rgba {
434 resolved.defaults.disabled_text_color
435}
436
437#[must_use]
439pub fn focus_ring_width(resolved: &ResolvedThemeVariant) -> f32 {
440 resolved.defaults.focus_ring_width
441}
442
443#[must_use]
445pub fn focus_ring_offset(resolved: &ResolvedThemeVariant) -> f32 {
446 resolved.defaults.focus_ring_offset
447}
448
449#[cfg(test)]
450#[allow(clippy::unwrap_used, clippy::expect_used)]
451mod tests {
452 use super::*;
453
454 fn test_resolved() -> ResolvedThemeVariant {
456 let nt = ThemeSpec::preset("catppuccin-mocha").expect("preset must exist");
457 let variant = nt
458 .into_variant(true)
459 .expect("preset must have dark variant");
460 variant
461 .into_resolved()
462 .expect("resolved preset must validate")
463 }
464
465 #[test]
466 fn to_theme_produces_valid_theme() {
467 let resolved = test_resolved();
468 let theme = to_theme(&resolved, "Test", true);
469
470 assert!(theme.is_dark());
472 }
473
474 #[test]
475 fn to_theme_dark_mode() {
476 let nt = ThemeSpec::preset("catppuccin-mocha").expect("preset must exist");
477 let variant = nt
478 .into_variant(true)
479 .expect("preset must have dark variant");
480 let resolved = variant
481 .into_resolved()
482 .expect("resolved preset must validate");
483 let theme = to_theme(&resolved, "DarkTest", true);
484
485 assert!(theme.is_dark());
486 }
487
488 #[test]
489 fn to_theme_applies_font_and_geometry() {
490 let resolved = test_resolved();
491 let theme = to_theme(&resolved, "Test", true);
492
493 assert_eq!(theme.font_family.to_string(), resolved.defaults.font.family);
494 assert_eq!(theme.font_size, px(resolved.defaults.font.size));
495 assert_eq!(
496 theme.mono_font_family.to_string(),
497 resolved.defaults.mono_font.family
498 );
499 assert_eq!(theme.mono_font_size, px(resolved.defaults.mono_font.size));
500 assert_eq!(
501 theme.radius,
502 px(resolved.defaults.border.corner_radius.max(0.0))
503 );
504 assert_eq!(
505 theme.radius_lg,
506 px(resolved.defaults.border.corner_radius_lg.max(0.0))
507 );
508 assert_eq!(theme.shadow, resolved.defaults.border.shadow_enabled);
509 }
510
511 #[test]
513 fn scrollbar_show_from_overlay_mode() {
514 let resolved = test_resolved();
515 let theme = to_theme(&resolved, "Scroll", true);
516 if resolved.scrollbar.overlay_mode {
517 assert!(
518 matches!(theme.scrollbar_show, ScrollbarShow::Scrolling),
519 "overlay_mode=true should set Scrolling"
520 );
521 } else {
522 assert!(
523 matches!(theme.scrollbar_show, ScrollbarShow::Always),
524 "overlay_mode=false should set Always"
525 );
526 }
527 }
528
529 #[test]
531 fn highlight_theme_matches_is_dark() {
532 let resolved = test_resolved();
533 let dark_theme = to_theme(&resolved, "Dark", true);
534 assert_eq!(
535 dark_theme.highlight_theme.appearance,
536 ThemeMode::Dark,
537 "dark theme should use dark highlight"
538 );
539
540 let light_resolved = {
541 let spec = ThemeSpec::preset("catppuccin-latte").expect("preset must exist");
542 let variant = spec.into_variant(false).expect("light variant");
543 variant.into_resolved().expect("must validate")
544 };
545 let light_theme = to_theme(&light_resolved, "Light", false);
546 assert_eq!(
547 light_theme.highlight_theme.appearance,
548 ThemeMode::Light,
549 "light theme should use light highlight"
550 );
551 }
552
553 #[test]
556 fn from_preset_valid_light() {
557 let (theme, _resolved) =
558 from_preset("catppuccin-latte", false).expect("preset should load");
559 assert!(!theme.is_dark());
560 }
561
562 #[test]
563 fn from_preset_valid_dark() {
564 let (theme, _resolved) = from_preset("catppuccin-mocha", true).expect("preset should load");
565 assert!(theme.is_dark());
566 }
567
568 #[test]
569 fn from_preset_returns_resolved() {
570 let (_theme, resolved) = from_preset("catppuccin-mocha", true).expect("preset should load");
571 assert!(resolved.defaults.font.size > 0.0);
573 }
574
575 #[test]
576 fn from_preset_invalid_name() {
577 let result = from_preset("nonexistent-preset", false);
578 assert!(result.is_err(), "invalid preset should return Err");
579 }
580
581 #[test]
583 fn from_preset_error_message_includes_mode() {
584 let _ = from_preset("catppuccin-mocha", true).expect("dark should work");
586 let _ = from_preset("catppuccin-mocha", false).expect("light should work");
587 }
588
589 #[test]
592 fn system_theme_ext_to_gpui_theme() {
593 let Ok(sys) = SystemTheme::from_system() else {
595 return;
596 };
597 let theme = sys.to_gpui_theme();
598 assert_eq!(
599 theme.is_dark(),
600 sys.is_dark,
601 "to_gpui_theme() is_dark should match SystemTheme.is_dark"
602 );
603 }
604
605 #[test]
606 fn from_system_does_not_panic() {
607 let _ = from_system();
609 }
610
611 #[test]
612 fn from_system_returns_tuple() {
613 let Ok((theme, resolved, _is_dark)) = from_system() else {
614 return;
615 };
616 assert!(resolved.defaults.font.size > 0.0);
618 let _ = theme.is_dark();
620 }
621
622 #[test]
623 fn from_system_matches_manual_path() {
624 let Ok(sys) = SystemTheme::from_system() else {
625 return;
626 };
627 let via_convenience = sys.to_gpui_theme();
628 let via_manual = to_theme(sys.active(), &sys.name, sys.is_dark);
629 assert_eq!(
631 via_convenience.is_dark(),
632 via_manual.is_dark(),
633 "convenience and manual paths should agree on is_dark"
634 );
635 let resolved = sys.active();
640 assert!(
641 resolved.defaults.accent_color != native_theme::Rgba::default()
642 || resolved.defaults.background_color != native_theme::Rgba::default(),
643 "resolved variant should have at least accent or background populated"
644 );
645 }
646
647 #[test]
650 fn is_dark_resolved_matches_background() {
651 let resolved = test_resolved();
652 let bg = colors::rgba_to_hsla(resolved.defaults.background_color);
653 assert_eq!(
654 is_dark_resolved(&resolved),
655 bg.l < 0.5,
656 "is_dark_resolved should match background lightness"
657 );
658 }
659
660 #[test]
661 fn accessibility_helpers() {
662 let resolved = test_resolved();
663 let _ = is_reduced_motion(&resolved);
665 let _ = is_high_contrast(&resolved);
666 let _ = is_reduced_transparency(&resolved);
667 }
668
669 #[test]
670 fn defaults_field_helpers() {
671 let resolved = test_resolved();
672 assert!(frame_width(&resolved) >= 0.0);
673 assert!(disabled_opacity(&resolved) >= 0.0);
674 assert!(disabled_opacity(&resolved) <= 1.0);
675 assert!(border_opacity(&resolved) >= 0.0);
676 assert!(text_scaling_factor(&resolved) > 0.0);
677 }
678
679 #[test]
680 fn icon_sizes_helper() {
681 let resolved = test_resolved();
682 let sizes = icon_sizes(&resolved);
683 assert!(sizes.toolbar > 0.0, "toolbar icon size should be positive");
684 }
685
686 #[test]
687 fn text_scale_helper() {
688 let resolved = test_resolved();
689 let ts = text_scale(&resolved);
690 assert!(ts.caption.size > 0.0, "caption size should be positive");
691 }
692
693 #[test]
694 fn font_weight_helper() {
695 let resolved = test_resolved();
696 let w = font_weight(&resolved);
697 assert!((100..=900).contains(&w), "font weight should be 100-900");
698 }
699
700 #[test]
701 fn mono_font_weight_helper() {
702 let resolved = test_resolved();
703 let w = mono_font_weight(&resolved);
704 assert!(
705 (100..=900).contains(&w),
706 "mono font weight should be 100-900"
707 );
708 }
709
710 #[test]
711 fn dialog_button_order_helper() {
712 let resolved = test_resolved();
713 let _order = dialog_button_order(&resolved);
714 }
716
717 #[test]
718 fn line_height_helper() {
719 let resolved = test_resolved();
720 assert!(
721 line_height_multiplier(&resolved) > 0.0,
722 "line height should be positive"
723 );
724 }
725
726 #[test]
727 fn geometry_helpers() {
728 let resolved = test_resolved();
729 assert!(dialog_content_padding(&resolved) >= 0.0);
730 assert!(dialog_button_spacing(&resolved) >= 0.0);
731 assert!(scrollbar_width(&resolved) > 0.0);
732 }
733
734 #[test]
735 fn selection_and_disabled_helpers() {
736 let resolved = test_resolved();
737 let _ = selection_foreground(&resolved);
738 let _ = selection_inactive(&resolved);
739 let _ = disabled_foreground(&resolved);
740 }
741
742 #[test]
743 fn focus_ring_helpers() {
744 let resolved = test_resolved();
745 assert!(focus_ring_width(&resolved) >= 0.0);
746 assert!(focus_ring_offset(&resolved) >= 0.0);
747 }
748
749 #[test]
752 fn all_presets_dark_mode_no_panic() {
753 let presets = ThemeSpec::list_presets();
754 for name in presets {
755 let result = from_preset(name, true);
756 assert!(
757 result.is_ok(),
758 "from_preset({name}, true) failed: {:?}",
759 result.err()
760 );
761 }
762 }
763
764 #[test]
765 fn all_presets_light_mode_no_panic() {
766 let presets = ThemeSpec::list_presets();
767 for name in presets {
768 let result = from_preset(name, false);
769 assert!(
770 result.is_ok(),
771 "from_preset({name}, false) failed: {:?}",
772 result.err()
773 );
774 }
775 }
776}